boxes is a pure-Python library for rendering UML/SysML-like block diagrams using Unicode braille characters (via drawille) or as SVG. It supports four routing engines, ports as small boundary boxes, node attributes, multiple arrowhead styles, and label collision avoidance.
⡏⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⡇
⡇ «block» ⡇
⡇ ECU ⡇
⡇⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠄⡇
⡇+ voltage : float ⡇
⡇ ⡇
⡇ + temp : float ⡇
⡇ # state : int ⡇
⡇ ⡇
⠧⠤⠤⠤⠤⡤⠤⠤⠤⠤⡤⠤⠤⠤⠤⡤⠤⠤⠤⠤⠇
⣼⣿⡄ ⣼⣿⡄ ⣼⣿⡄ ⡰⠁⠑⠒⠒⠒⠒⠒⠒⢲
⠹⡿⠁ ⠹⡿⠁ ⠹⡿⠁ ⢸ ⢸
⡇ ⡇ ⡇ reads ⢸
⡇ ⡇ ⡇ ⢸ ⢸
⡇ ⡇ ⡇ ⢸ ⢸
⣇⣀⣀⡀ ⣇⣀⣀⣀⣀⣇⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⡀
⡇ ⢸ ⡇
⡇ commands ⢸ updates ⡇
⡇ ⢸ ⡇
⡇ ⢸ ⡇
⠠⡀⡇⢀⠄ ⡄⢸ ⡄ ⢄ ⡇⢀⠄
⠣⣧⠊ ⠈⣾⡎ ⠈⢢⣧⠊
⡏⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⡏⠉⠉⠉⡇ ⡏⠉⠉⠉⡏⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⡇ ⣀⣀⣀⣀⡏⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⡇
⡇ «block» ⡇→ ⡧out⠤⠤⠤⠤⠤⠤⠤⠤data⠤⡇→ ⡇ «block» ⡇ in ⡇→ ⡇ «block» ⡇
⡇SensorCluster ⡇ ⡇ ⡇ ⡇ActuatorDriver ⡧⠤⠤⠤⡖⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⡇ ⡇DisplayUnit ⡇
⡇⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠄⡏⠉⠉⠉⠁ data ⠉⠉⠉⠉⡇⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠄⡇→ ⡇out output ⣇⣀⣀⣀⡇ ⡇
⡇- calibrate() ⡇ ⡇ + apply() ⡇ ⡇ ⠧⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠇
⡇ ⡏⠉⠉⠉⡇ ⡏⠉⠉⠉⡇
⡇ + read() ⡇→ ⡧cfg⠤⠤⠄ ⠤⠤⠄ ⠤cfg⠤⡇→ ⡇ - limit() ⡇
⡇ ⡇ ⡇ ⡇ ⡇
⠓⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠋⠉⠉⠉⠁ config ⠉⠉⠉⠉⠓⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠃
Terminal output of
demo.py— a SysML block diagram with top-down composition edges and lateral port-to-port connections. Rendered withrouting='orthogonal'.
git clone <this-repo>
cd boxes
poetry installThis creates a virtualenv and installs drawille (the only Python dependency).
Run scripts with poetry run (no PYTHONPATH needed):
poetry run python demo.py
poetry run python stress_test.pyNote: boxes is installed in editable mode, so any changes to the source
are picked up automatically.
For professional-quality orthogonal routing via the Eclipse Layout Kernel:
npm install elkjsThen use routing='elk' (see §Routing Engines below).
from boxes import Diagram, OPEN, FILLED
d = Diagram()
vehicle = d.add_node('Vehicle', ['block'])
engine = d.add_node('Engine', ['part'])
wheel = d.add_node('Wheel', ['part'])
d.add_edge(vehicle, engine, source_style=FILLED, target_style=OPEN,
label='composed of')
d.add_edge(vehicle, wheel, source_style=FILLED, target_style=OPEN,
label='composed of')
print(d.render()) # terminal outputThe render() method layouts the diagram and returns a string of Unicode braille
characters. Print it to see the diagram in your terminal.
A Node has a name, optional stereotypes (shown as «stereotype» above the
name), and optional attributes (shown below a separator line).
from boxes import Diagram
d = Diagram()
sensor = d.add_node('TemperatureSensor', ['block'],
attributes=['+ id : int', '+ value : float', '- threshold : float'])The node width auto-sizes to the longest text. Stereotypes are wrapped in
guillemets (\u00ab...\u00bb).
An Edge connects two nodes, with optional arrowheads at each end and an
optional label.
from boxes import Diagram, OPEN, TRIANGLE, FILLED, DASHED
d.add_edge(source, target) # default: open arrow at target
d.add_edge(source, target, source_style=FILLED, # filled diamond at source
target_style=TRIANGLE, label='generalizes') # triangle at target
d.add_edge(source, target, line_style=DASHED) # dashed line| Constant | Shape | Usage |
|---|---|---|
NONE |
(none) | Plain line end |
OPEN |
> |
Default target arrow, composition |
TRIANGLE |
△ |
Generalization / inheritance |
DIAMOND |
◇ |
Aggregation (unfilled) |
FILLED |
◆ |
Composition (filled) |
DEFINITION |
△⋯ |
SysMLv2 definition (triangle + two dots) |
REDEFINITION |
△∣ |
SysMLv2 redefinition (triangle + bar) |
REFERENCE_SUBSETTING |
△⋯⋯ |
SysMLv2 reference-subsetting (triangle + two dot pairs) |
PORTION |
◐ |
Pac-man shape (filled circle with wedge cut from trailing side) |
| Constant | Appearance |
|---|---|
SOLID |
─── continuous line |
DASHED |
╌╌ dashed line |
Diagram provides three shorthand methods that set the correct arrowhead
and line styles automatically:
d.compose(whole, part, label='contains') # FILLED diamond, solid line
d.aggregate(whole, part, label='has') # DIAMOND (empty), solid line
d.generalize(child, parent, label='is a') # TRIANGLE (open), solid line
d.depend(client, supplier, label='uses') # OPEN arrow, dashed line
d.annotate(client, supplier, label='spec') # OPEN arrow, dashed line (SysMLv2)Each accepts the same **kw as add_edge — pass label, source_port,
target_port, etc. Explicit source_style/target_style override the
defaults.
Run poetry run python demo_relations.py to see all arrowhead styles
and convenience methods in action.
Ports are small boxes on a node's boundary, used in UML/SysML for structured connectors and proxy ports.
sensor = d.add_node('SensorCluster', ['block'])
actuator = d.add_node('ActuatorDriver', ['block'])
out_port = sensor.add_port('out', side='right', direction='out') # auto-distributed
in_port = actuator.add_port('in', side='left', direction='in')
d.add_edge(sensor, actuator, source_port=out_port, target_port=in_port,
target_style=None, label='data')Auto-distribution: when offset=None (the default), ports on the same side
are automatically spaced evenly with a minimum gap of 8 px. This makes it easy
to add multiple ports:
hub = d.add_node('Hub')
# 3 ports on the left, 2 on the right — all evenly spaced
hub.add_port('L1', side='left')
hub.add_port('L2', side='left')
hub.add_port('L3', side='left')
hub.add_port('R1', side='right')
hub.add_port('R2', side='right')For precise positioning, pass an explicit offset (0.0–1.0, proportion along
the side):
port = node.add_port('out', side='right', offset=0.25) # 25% from topPort-to-port edges use Z-shaped (3-segment) routing with a perpendicular docking segment into the target port face.
svg = d.render_svg(routing='orthogonal', node_gap=60)
with open('/tmp/diagram.svg', 'w') as f:
f.write(svg)The SVG uses vector lines, polygons for arrowheads, and <text> elements.
Open the result in any browser or vector-editing tool. The scale parameter
(default 1.5) controls the output size.
from boxes import Diagram, OPEN, TRIANGLE, FILLED, DASHED
d = Diagram()
# Layer 0
ecu = d.add_node('ECU', ['block'],
attributes=['+ voltage : float', '+ temp : float'])
# Layer 1
sensor = d.add_node('SensorCluster', ['block'])
actuator = d.add_node('ActuatorDriver', ['block'],
attributes=['+ apply()'])
display = d.add_node('DisplayUnit', ['block'])
# Ports (direction: in = arrow toward node, out = arrow away from node)
sns_out = sensor.add_port('out', side='right', offset=0.3, direction='out')
act_in = actuator.add_port('in', side='left', offset=0.3, direction='in')
act_out = actuator.add_port('out', side='right', offset=0.5, direction='out')
disp_in = display.add_port('in', side='left', offset=0.5, direction='in')
# Traditional top-down edges
d.add_edge(ecu, sensor, source_style=FILLED, target_style=OPEN, label='reads')
d.add_edge(ecu, actuator, source_style=FILLED, target_style=OPEN, label='commands')
d.add_edge(ecu, display, source_style=FILLED, target_style=OPEN, label='updates')
# Lateral port-to-port edges (port direction arrows suffice, no edge arrowheads)
d.add_edge(sensor, actuator, source_port=sns_out, target_port=act_in,
target_style=None, label='data')
d.add_edge(actuator, display, source_port=act_out, target_port=disp_in,
target_style=None, label='output')
# Terminal output
print(d.render(routing='orthogonal', node_gap=60))
# SVG output
svg = d.render_svg(routing='orthogonal', node_gap=60)
with open('/tmp/diagram.svg', 'w') as f:
f.write(svg)boxes provides four routing engines, selectable via the routing parameter:
d.render(routing='orthogonal') # or 'straight', 'sugiyama', 'elk'
d.render_svg(routing='sugiyama')File: layout.py:Drawing._route_straight() (line 99)
Center-to-center straight lines. For adjacent layers, ports are distributed
evenly along the node boundary using _distribute_ports(). For non-adjacent
layers or same-layer edges, falls back to direct center-to-center diagonals.
Best for: quick sketches, small diagrams (≤10 edges).
File: layout.py:Drawing._route_orthogonal() (line 191)
Three-segment Manhattan paths (down → across → down) with:
- Distributed boundary ports via
_get_port()/_distribute_ports() - Gap y-level candidates between layers, tested for obstacle intersection
via
_segment_hits() - Edge-edge avoidance via
gap_usedcounter — each edge sharing a gap gets a 3 px vertical offset - Reverse-edge handling (source below target) — routes upward from the source's top port to the target's bottom port
- Port-to-port routing via
_port_route()— Z-shaped (3-segment) paths with a 4 px perpendicular docking segment into the target port face
Best for: medium diagrams (10–30 edges), SysML block diagrams, UML composite structure.
File: sugiyama.py (410 lines)
Full 5-stage Sugiyama framework:
| Stage | Function | Lines | Description |
|---|---|---|---|
| 1. Cycle removal | remove_cycles() |
~40 | Greedy DFS: reverses back-edges |
| 2. Layer assignment | assign_layers() |
~50 | Longest-path from roots to sinks |
| 3. Crossing minimization | minimize_crossings() |
~120 | Barycenter layer sweep (forward + backward, up to 24 passes) |
| 4. Node positioning | position_nodes() |
~80 | X/Y assignment within layers, centered |
| 5. Edge routing | route_edges() |
~200 | Orthogonal with obstacle avoidance |
Key properties:
- Correctly handles non-adjacent edges (unlike the homegrown
orthogonalwhich uses BFS-shortest-path layering) - Crossing minimization can get stuck in local minima
- Does not support ports (they are ignored in Sugiyama mode)
Best for: directed graphs, flow charts, diagrams with non-adjacent edges.
File: elk.py (211 lines)
Bridges to the Eclipse Layout Kernel via a Node.js subprocess. The pipeline:
to_elk_json()— converts Diagram → ELK JSON graph format withelk.algorithm: layered,elk.edgeRouting: ORTHOGONAL, port constraints, and label placement optionselk_layout()— spawnsnode -e, pipes JSON via stdin, reads result from stdout (30-second timeout)apply_elk_result()— maps ELK positions/waypoints back to Diagram nodes and edges
Requirements:
npm install elkjs
node --version # v16+Layout options configured (elk.py lines 136–148):
| Option | Value | Effect |
|---|---|---|
elk.algorithm |
layered |
Layered (Sugiyama-based) |
elk.direction |
DOWN |
Top-to-bottom |
elk.edgeRouting |
ORTHOGONAL |
Right-angle bends |
elk.layered.spacing.nodeNodeBetweenLayers |
60 |
Vertical layer gap |
elk.spacing.nodeNode |
20 |
Horizontal node gap |
elk.portConstraints |
FIXED_SIDE |
Ports stay on assigned side |
elk.layered.nodePlacement.strategy |
SIMPLE |
Basic node positioning |
Best for: production-quality layouts, large diagrams (30+ nodes), when visual polish matters more than startup time (~500 ms subprocess spawn overhead).
File: primitives.py (219 lines)
The terminal renderer uses the drawille library to convert vector primitives to Unicode braille characters (U+2800–U+28FF). Each braille character represents a 2×4 pixel grid.
Architecture:
drawille.Canvas.set(x, y)— sets a pixel at (x, y)drawille.line(x1, y1, x2, y2)— Bresenham line iterator returning pixel coordinatesdrawille.Canvas.set_text(x, y, text)— writes ASCII text at a pixel position (converted to character row/col viax//2, y//4)drawille.Canvas.frame()— produces the output string
Drawing functions (all in primitives.py):
| Function | Lines | What it draws |
|---|---|---|
draw_line() |
74–82 | Single line segment (solid or dashed) |
draw_arrowhead() |
85–87 | Dispatches to _draw_open, _draw_triangle, _draw_diamond |
draw_relation() |
92–107 | Straight line + arrowheads + perpendicular label |
draw_polyline() |
112–177 | Multi-segment polyline + arrowheads + Manhattan-midpoint label with collision avoidance |
draw_class_box() |
199–219 | Rectangular box + centered text + optional separator + attributes |
draw_port_box() |
267–305 | Small rectangle (8×12 px) with optional direction arrow and label |
Label collision avoidance (draw_polyline() lines 162–176):
Uses a shared used_labels set. When a label position is already occupied
(within a 10×5 px window), it tries a spiral of offsets:
(0,0) → (0,12) → (12,0) → (0,-8) → (-12,0) → (0,20) → ...
File: svg_canvas.py (252 lines)
The SVG renderer produces W3C-standard SVG 1.1 output. It mirrors the structure of the terminal renderer but operates at the vector level.
SvgCanvas class (svg_canvas.py lines 41–146):
| Method | SVG element |
|---|---|
add_line() |
<line> with optional stroke-dasharray |
add_rect() |
<rect> with fill and stroke |
add_text() |
<text> with text-anchor, font-family="monospace" |
add_polygon() |
<polygon> for arrowheads |
add_polyline() |
<polyline> for multi-segment edges |
add_group() / close_group() |
<g> for grouping |
output() |
Wraps all elements in <svg> with computed viewBox |
All coordinates are scaled by a configurable scale factor (default 1.5)
via SvgCanvas.s(). Text labels use text-anchor="middle" for automatic
centering.
SVG drawing functions:
| Function | What it draws |
|---|---|
svg_draw_edge() |
Edge polylines with arrowhead polygons at endpoints and label text |
svg_draw_node() |
Rect + centered text lines + optional separator line |
svg_draw_port() |
Small rect + centered label |
Arrowheads are computed as (<polygon> ...) vectors via _arrow_polygon()
(lines 44–81), scaled to ARROW_SIZE for better SVG visibility.
from boxes import (
# Arrowhead styles
NONE, OPEN, TRIANGLE, DIAMOND, FILLED, DEFINITION, REDEFINITION, REFERENCE_SUBSETTING, PORTION,
# Line styles
SOLID, DASHED,
# Drawing primitives
draw_line, draw_arrowhead, draw_relation, draw_polyline,
draw_class_box, draw_port_box, PORT_W, PORT_H, ARROW_SIZE,
# Core classes
Diagram, Node, Edge, Port,
# Routing engines
sugiyama_layout, layout_with_elk,
# SVG renderer
SvgCanvas, svg_draw_edge, svg_draw_node, svg_draw_port,
)| Method / Attribute | Description |
|---|---|
Node(name, stereotypes=None, attributes=None) |
Create a node |
.add_port(label, side='left', offset=None) |
Add a port; None = auto-distribute; returns Port |
.add_attribute(text) |
Add an attribute line |
.x, .y, .w, .h |
Position and size (set by layout) |
.cx, .cy |
Center coordinates (properties) |
.box() |
Returns (x1, y1, x2, y2) |
.contains(px, py) |
Point-in-rect test |
| Attribute | Description |
|---|---|
.label |
Port label text |
.side |
One of 'left', 'right', 'top', 'bottom' |
.offset |
Proportional position along side (0.0–1.0) or None for auto-distribution |
.parent |
The owning Node |
.x, .y, .w, .h |
Position and size (8×12 px) |
.cx, .cy |
Center coordinates |
.box() |
Returns (x1, y1, x2, y2) |
.update_pos() |
Recompute position from parent's current bounds |
| Parameter | Description |
|---|---|
Edge(source, target, ...) |
source/target are Node objects |
source_style |
Arrowhead at source: NONE, OPEN, TRIANGLE, DIAMOND, FILLED, DEFINITION, REDEFINITION, REFERENCE_SUBSETTING, PORTION |
target_style |
Arrowhead at target (default: OPEN) |
line_style |
SOLID or DASHED |
label |
Text label at midpoint (optional) |
source_port |
Port object for source connection (optional) |
target_port |
Port object for target connection (optional) |
.waypoints |
List of (x, y) tuples set by routing |
.route(*points) |
Set waypoints |
| Method | Description |
|---|---|
add_node(name, stereotypes=None, attributes=None) |
Create and register a Node |
add_edge(source, target, **kw) |
Create and register an Edge |
compose(whole, part, **kw) |
Composition: source_style=FILLED, target_style=NONE |
aggregate(whole, part, **kw) |
Aggregation: source_style=DIAMOND, target_style=NONE |
generalize(child, parent, **kw) |
Generalization: source_style=NONE, target_style=TRIANGLE |
depend(client, supplier, **kw) |
Dependency: line_style=DASHED, target_style=OPEN |
annotate(client, supplier, **kw) |
SysMLv2 annotation (same style as depend) |
layout(routing='orthogonal', layer_gap=50, node_gap=12, margin=8) |
Compute positions and waypoints |
render(routing='orthogonal', ...) |
Layout + return terminal braille string |
render_svg(routing='orthogonal', ..., scale=1.5) |
Layout + return SVG string |
┌──────────────────────────┐
│ Diagram (layout.py) │
│ Node, Edge, Port classes │
│ Layer assignment, routing │
└──────┬──────────┬─────────┘
│ │
┌────────────┘ └────────────┐
↓ ↓
┌─────────────────┐ ┌──────────────────┐
│ primitives.py │ │ svg_canvas.py │
│ drawille-based │ │ SVG 1.1 output │
│ raster drawing │ │ vector drawing │
└────────┬─────────┘ └────────┬─────────┘
↓ ↓
┌─────────────────┐ ┌──────────────────┐
│ drawille.Canvas │ │ <svg> string │
│ braille chars │ │ XML elements │
└─────────────────┘ └──────────────────┘
Routing engines (all feed into Diagram):
layout._route_orthogonal() — homegrown 3-segment
layout._route_straight() — center-to-center
sugiyama.sugiyama_layout() — 5-stage Sugiyama
elk.layout_with_elk() — ELKjs subprocess
- Label-over-line overwrite: drawille's
set_text()replaces braille characters with ASCII text at the target position. Labels falling on bus lines break the line visually. The SVG renderer does not have this issue. - Sugiyama crossing minimization can get stuck in local minima on complex graphs.
- Ports are not supported in Sugiyama mode (ports are ignored).
- ELKjs subprocess overhead (~500 ms per layout). Not suitable for interactive use.
- ELKjs produces wider diagrams with different spacing than our Python routers — may exceed terminal width.