A user's approval-workflow flowchart came out with the return arrow drawn
straight through two unrelated steps, and a second report showed arrows
leaving a box and bending straight back across it. Measured over 250
generated flowcharts (2722 edges): 347 arrows crossed an unrelated box and
215 waypoints landed inside a shape. Four defects, each measured in
isolation:
1. The invisible layer containers draw_graph emits were handed to the
router as frames, so every clean return path was rejected for
"trespassing" on a border that is not drawn, and the fallback cut
through two boxes. Excluding invisible containers: 347 -> 218 crossing
arrows, no diagram made worse.
2. The router chose the horizontal-vs-vertical axis BEFORE searching, so
when the only clean corridor ran along the other axis it was never
looked at. A complete two-bend candidate generator that tries both
trunk axes, all four sides at each end, and the port fractions:
218 -> 27. (An independent ablation measured the axis pre-choice alone
at a 40% per-edge failure rate.)
3. Nothing stopped a route's first leg from turning back across its own
source shape - the obstacle test exempts an edge's own endpoints, and
must, since the line has to touch them. A terminal-leg rule refuses
such routes outright: 215 -> 4 hooks.
4. A two-bend search cannot express the staircase needed when a box sits
directly between two vertically aligned nodes (21 of the last 27
crossings). Added the orthogonal visibility graph + A* from Wybrow,
Marriott & Stuckey, "Orthogonal Connector Routing" (GD 2009) - the
libavoid algorithm - as the backstop when the candidate search finds
nothing. The interesting-points grid is provably sufficient: any valid
route shrinks onto it without getting longer or gaining bends. The A*
state is (point, incoming direction) with libavoid's bend cost of 10,
and the admissible bends-remaining heuristic, so it returns a cheapest
route, not merely a route. Implemented from the paper, not ported.
After all four: 0 crossing arrows and 0 hooks over the same 250 diagrams,
page area unchanged (494k px^2 mean), 260ms for the whole corpus, mean
0.82 bends per edge. The shape ladder still runs first, so routes that
were already clean are byte-identical.
Also post-nudge validation now checks the whole path (the nudge pass only
reverts the single segment it moved, judged in isolation) and restores the
search's route if nudging made it dirty.
Fixes the reported problem: arrows overlapped on top of icons and ran through shapes
they had nothing to do with.
The cause was a missing layer, not a bug. render.ts emitted only source and target, so
draw.io routed every edge itself — and its router sees the two terminals' bounds and
nothing else, not where the other icons are. It therefore ran lines straight through
whatever was in between and left several edges leaving one node at the same point.
Ported from drawio-ai-kit's router (MIT, see NOTICE):
- Port de-collision. Edges leaving the same side of the same node spread along it,
ordered by where their far end sits so they do not cross on the way out. An edge
with a clean straight shot keeps the centre.
- Obstacle avoidance. Candidate shapes in order of directness — straight, a Z through
the gap, an L, a two-bend detour — each tested against every icon on the page.
- Frame placement. A frame is passable (an edge into a VPC must cross its border) but
not free: running alongside a border, or cutting through a frame that holds only one
of the two endpoints, is penalised.
- Port snapping. On a bent route, each port moves to the side its leg actually arrives
from. Without this the terminal segment can pierce the icon to reach a far-side port.
- Lane claiming. Each routed edge records the lanes it occupies so later edges avoid
them, rather than colliding and being pulled apart afterwards.
- Global nudge, three passes, reverting any move that makes a path worse.
Two things I got wrong on the way, both caught by looking at real geometry:
- The Z corridor was computed as min/max of both nodes' edges, which spans the whole
distance between them — including anything parked in between. So the lane sweep
would place the detour's middle leg on top of the very icon it was avoiding. It has
to be the gap: trailing edge of the first node to the leading edge of the other.
- The router was fed layout's slot rectangles, but an icon's cell is the glyph square
centred in a slot roughly twice as wide. Collision tests against slots both missed
real overlaps and invented false ones. Extracted cellRect() so the router and the
emitted XML cannot diverge.
Accept-or-reject was not enough on its own. When one endpoint is inside a VPC and the
other outside, EVERY path trespasses on that frame, so the strict rule always fails and
the relaxed pass took whatever it tried first — which is how a line ended up cutting
across a whole VPC. Candidates are now scored (frame offences 500, lane sharing 700,
bends 80, length 1) and the cheapest wins, so an edge that must trespass still gets the
least-bad route.
Waypoints are still written only when load-bearing — a labelled bend or a deliberate
detour — so an unobstructed edge stays drag-friendly.
455 unit tests (27 new, asserting produced geometry rather than algorithm shape).
Verified by rendering the reported diagram in the real editor: the two arrows that
overlapped on the EC2 icon now leave from different sides, and the load balancer's
fan-out leaves from three distinct points.