feat(diagram-engine): edge router — pinned ports, obstacle avoidance, cost search
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.
2026-08-09 12:46:44 +09:00
|
|
|
/**
|
|
|
|
|
* Edge routing: where an arrow leaves a node, and how it gets to the other end.
|
|
|
|
|
*
|
|
|
|
|
* Without this, `source` and `target` are all draw.io has to work with. Its own router
|
|
|
|
|
* knows the two terminals' bounds and nothing else — not where the other icons are — so
|
|
|
|
|
* it runs arrows straight through unrelated shapes and stacks several on one point.
|
|
|
|
|
*
|
|
|
|
|
* Three stages, the same shape as drawio-ai-kit's router (MIT — see NOTICE):
|
|
|
|
|
*
|
|
|
|
|
* 1. pick a side per edge, then DE-COLLIDE: several edges leaving the same side of the
|
|
|
|
|
* same node get spread along that side. An edge that has a clean straight shot keeps
|
|
|
|
|
* the centre; the others move off it.
|
|
|
|
|
* 2. try progressively less direct paths — straight, a Z through the gap between the
|
|
|
|
|
* two nodes, an L — testing each against every icon on the page, and keep the first
|
|
|
|
|
* that is clear.
|
|
|
|
|
* 3. NUDGE: globally separate parallel segments that ended up on top of each other, so
|
|
|
|
|
* the result does not depend on the order edges were declared in.
|
|
|
|
|
*
|
|
|
|
|
* What gets written to the XML is deliberately asymmetric:
|
|
|
|
|
*
|
|
|
|
|
* - Connection points ALWAYS. They are fractions of the terminal's bounds, so draw.io
|
|
|
|
|
* recomputes them from live geometry on every edit — they follow a node when the user
|
|
|
|
|
* drags it, and cost nothing in exchange.
|
|
|
|
|
* - Waypoints only when they are load-bearing: the edge carries a label (which sits at
|
|
|
|
|
* the path midpoint and needs a straight segment under it), or the router deliberately
|
|
|
|
|
* bent around something a straight line would have hit. Waypoints are absolute, so
|
|
|
|
|
* draw.io keeps them after a drag and the route deforms; spending that only where it
|
|
|
|
|
* buys something keeps the diagram editable.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import type { Rect } from "./types"
|
fix(diagram-engine): eliminate arrows drawn through boxes, complete the route search
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.
2026-08-09 18:18:54 +09:00
|
|
|
import { routeOrthogonal, SIDE_DIR } from "./visgraph"
|
feat(diagram-engine): edge router — pinned ports, obstacle avoidance, cost search
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.
2026-08-09 12:46:44 +09:00
|
|
|
|
|
|
|
|
/** Which side of a node an edge attaches to. */
|
|
|
|
|
export type Side = "L" | "R" | "T" | "B"
|
|
|
|
|
|
|
|
|
|
export interface RouteInput {
|
|
|
|
|
id: string
|
|
|
|
|
source: string
|
|
|
|
|
target: string
|
|
|
|
|
/** Edges with a label need a straight segment under the midpoint. */
|
|
|
|
|
hasLabel: boolean
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface RoutedEdge {
|
|
|
|
|
id: string
|
|
|
|
|
/** Exit point as a fraction of the source's bounds. */
|
|
|
|
|
exit: { x: number; y: number }
|
|
|
|
|
/** Entry point as a fraction of the target's bounds. */
|
|
|
|
|
entry: { x: number; y: number }
|
|
|
|
|
/** Absolute waypoints — emitted only when `freeze` is set. */
|
|
|
|
|
waypoints: { x: number; y: number }[]
|
|
|
|
|
/** Whether the waypoints must be written to the XML. */
|
|
|
|
|
freeze: boolean
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface Point {
|
|
|
|
|
x: number
|
|
|
|
|
y: number
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Clearance kept around an icon when testing whether a segment hits it. */
|
|
|
|
|
const MARGIN = 7
|
|
|
|
|
/** Track separation used by the nudge pass. */
|
|
|
|
|
const SEP = 16
|
|
|
|
|
/** How close to a frame's border counts as running alongside it. */
|
|
|
|
|
const BORDER_MARGIN = 24
|
|
|
|
|
/** Segments shorter than this are connector stubs, not runs worth judging. */
|
|
|
|
|
const MIN_RUN = 28
|
|
|
|
|
|
|
|
|
|
/** The point on `r`'s side `side`, at fraction `f` along it. */
|
|
|
|
|
function portPoint(r: Rect, side: Side, f: number): Point {
|
|
|
|
|
if (side === "L") return { x: r.x, y: Math.round(r.y + f * r.h) }
|
|
|
|
|
if (side === "R") return { x: r.x + r.w, y: Math.round(r.y + f * r.h) }
|
|
|
|
|
if (side === "T") return { x: Math.round(r.x + f * r.w), y: r.y }
|
|
|
|
|
return { x: Math.round(r.x + f * r.w), y: r.y + r.h }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Does an axis-aligned segment cross this rect (plus its margin)? */
|
|
|
|
|
function segHitsRect(p: Point, q: Point, r: Rect): boolean {
|
|
|
|
|
const x0 = r.x - MARGIN
|
|
|
|
|
const x1 = r.x + r.w + MARGIN
|
|
|
|
|
const y0 = r.y - MARGIN
|
|
|
|
|
const y1 = r.y + r.h + MARGIN
|
|
|
|
|
if (Math.abs(p.y - q.y) < 1)
|
|
|
|
|
return (
|
|
|
|
|
p.y > y0 &&
|
|
|
|
|
p.y < y1 &&
|
|
|
|
|
Math.min(p.x, q.x) < x1 &&
|
|
|
|
|
Math.max(p.x, q.x) > x0
|
|
|
|
|
)
|
|
|
|
|
if (Math.abs(p.x - q.x) < 1)
|
|
|
|
|
return (
|
|
|
|
|
p.x > x0 &&
|
|
|
|
|
p.x < x1 &&
|
|
|
|
|
Math.min(p.y, q.y) < y1 &&
|
|
|
|
|
Math.max(p.y, q.y) > y0
|
|
|
|
|
)
|
|
|
|
|
// A diagonal should not occur, but treat its bounding box as a hit rather than
|
|
|
|
|
// silently letting it through.
|
|
|
|
|
return (
|
|
|
|
|
Math.min(p.x, q.x) < x1 &&
|
|
|
|
|
Math.max(p.x, q.x) > x0 &&
|
|
|
|
|
Math.min(p.y, q.y) < y1 &&
|
|
|
|
|
Math.max(p.y, q.y) > y0
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Candidate route shapes, in the order they are tried. */
|
|
|
|
|
type Shape =
|
|
|
|
|
| { kind: "straight" }
|
|
|
|
|
| { kind: "Zx"; lane: number }
|
|
|
|
|
| { kind: "Zy"; lane: number }
|
|
|
|
|
| { kind: "Lhv" }
|
|
|
|
|
| { kind: "Lvh" }
|
|
|
|
|
|
|
|
|
|
/** Turn a shape into the concrete point list for one edge. */
|
|
|
|
|
function shapePoints(
|
|
|
|
|
a: Rect,
|
|
|
|
|
b: Rect,
|
|
|
|
|
exitSide: Side,
|
|
|
|
|
entrySide: Side,
|
|
|
|
|
sf: number,
|
|
|
|
|
tf: number,
|
|
|
|
|
shape: Shape,
|
|
|
|
|
): { sp: Point; ep: Point; wp: Point[] } {
|
|
|
|
|
const sp = portPoint(a, exitSide, sf)
|
|
|
|
|
const ep = portPoint(b, entrySide, tf)
|
|
|
|
|
let wp: Point[] = []
|
|
|
|
|
if (shape.kind === "Zx")
|
|
|
|
|
wp = [
|
|
|
|
|
{ x: shape.lane, y: sp.y },
|
|
|
|
|
{ x: shape.lane, y: ep.y },
|
|
|
|
|
]
|
|
|
|
|
else if (shape.kind === "Zy")
|
|
|
|
|
wp = [
|
|
|
|
|
{ x: sp.x, y: shape.lane },
|
|
|
|
|
{ x: ep.x, y: shape.lane },
|
|
|
|
|
]
|
|
|
|
|
else if (shape.kind === "Lhv") wp = [{ x: ep.x, y: sp.y }]
|
|
|
|
|
else if (shape.kind === "Lvh") wp = [{ x: sp.x, y: ep.y }]
|
|
|
|
|
return { sp, ep, wp }
|
|
|
|
|
}
|
|
|
|
|
|
fix(diagram-engine): eliminate arrows drawn through boxes, complete the route search
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.
2026-08-09 18:18:54 +09:00
|
|
|
/**
|
|
|
|
|
* Does this route's first or last leg turn back over the shape it belongs to?
|
|
|
|
|
*
|
|
|
|
|
* An arrow has to leave its own shape going AWAY from it. When the first bend lands back
|
|
|
|
|
* within the source's own span, draw.io draws the arrow out of one side and immediately back
|
|
|
|
|
* across the shape's own edge — the small hook seen coming out of a box's right side and
|
|
|
|
|
* turning straight back over it. Nothing is technically crossed, which is why the ordinary
|
|
|
|
|
* obstacle test misses it: an edge is exempt from its own two endpoints, and that exemption
|
|
|
|
|
* has to exist, since the line must touch them.
|
|
|
|
|
*
|
|
|
|
|
* This checks the two terminal legs only. A middle leg running past its own endpoint is
|
|
|
|
|
* normal — that is what a return path does.
|
|
|
|
|
*/
|
|
|
|
|
function doublesBack(pts: Point[], a: Rect, b: Rect): boolean {
|
|
|
|
|
const backOver = (from: Point, to: Point, r: Rect): boolean => {
|
|
|
|
|
// Leaving through a vertical side: the leg must not head back inside the box's width.
|
|
|
|
|
if (Math.abs(from.x - r.x) < 1 || Math.abs(from.x - (r.x + r.w)) < 1) {
|
|
|
|
|
if (Math.abs(from.y - to.y) < 1)
|
|
|
|
|
return to.x > r.x + 1 && to.x < r.x + r.w - 1
|
|
|
|
|
}
|
|
|
|
|
// Leaving through a horizontal side: likewise for the box's height.
|
|
|
|
|
if (Math.abs(from.y - r.y) < 1 || Math.abs(from.y - (r.y + r.h)) < 1) {
|
|
|
|
|
if (Math.abs(from.x - to.x) < 1)
|
|
|
|
|
return to.y > r.y + 1 && to.y < r.y + r.h - 1
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
if (pts.length < 3) return false
|
|
|
|
|
return (
|
|
|
|
|
backOver(pts[0], pts[1], a) ||
|
|
|
|
|
backOver(pts[pts.length - 1], pts[pts.length - 2], b)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
feat(diagram-engine): edge router — pinned ports, obstacle avoidance, cost search
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.
2026-08-09 12:46:44 +09:00
|
|
|
/**
|
|
|
|
|
* Lane positions to try inside a gap, from the middle outwards.
|
|
|
|
|
*
|
|
|
|
|
* The middle of the corridor is where a route looks intentional; stepping outwards from
|
|
|
|
|
* there finds the nearest clear lane when the middle is taken.
|
|
|
|
|
*/
|
|
|
|
|
function laneSweep(lo: number, hi: number): number[] {
|
|
|
|
|
const mid = (lo + hi) / 2
|
|
|
|
|
const out = [Math.round(mid)]
|
|
|
|
|
for (let k = 1; k <= 24; k++) {
|
|
|
|
|
const down = mid - k * 10
|
|
|
|
|
const up = mid + k * 10
|
|
|
|
|
if (down > lo + 2) out.push(Math.round(down))
|
|
|
|
|
if (up < hi - 2) out.push(Math.round(up))
|
|
|
|
|
}
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Route every edge.
|
|
|
|
|
*
|
|
|
|
|
* `rects` must hold every node on the page, `obstacles` the ids of the leaf shapes an arrow
|
|
|
|
|
* must not cross, and `containers` the ids of the frames.
|
|
|
|
|
*
|
|
|
|
|
* Containers are not obstacles — an edge from outside a VPC to something inside it has to
|
|
|
|
|
* cross the VPC's border. But they are not free to ignore either: a line that runs
|
|
|
|
|
* alongside a border, or straight through a frame neither of its endpoints belongs to,
|
|
|
|
|
* reads as a mistake even though it hits nothing. Those two cases are penalised instead.
|
|
|
|
|
*/
|
|
|
|
|
export function routeEdges(
|
|
|
|
|
edges: RouteInput[],
|
|
|
|
|
rects: Map<string, Rect>,
|
|
|
|
|
obstacles: Set<string>,
|
|
|
|
|
containers: Set<string> = new Set(),
|
|
|
|
|
): RoutedEdge[] {
|
|
|
|
|
const cards: { id: string; r: Rect }[] = []
|
|
|
|
|
for (const id of obstacles) {
|
|
|
|
|
const r = rects.get(id)
|
|
|
|
|
if (r) cards.push({ id, r })
|
|
|
|
|
}
|
|
|
|
|
const frames: Rect[] = []
|
|
|
|
|
for (const id of containers) {
|
|
|
|
|
const r = rects.get(id)
|
|
|
|
|
if (r) frames.push(r)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Does this path cross any icon other than its own two endpoints? */
|
|
|
|
|
const pathHits = (pts: Point[], exempt: Set<string>): boolean => {
|
|
|
|
|
for (let i = 0; i < pts.length - 1; i++)
|
|
|
|
|
for (const c of cards) {
|
|
|
|
|
if (exempt.has(c.id)) continue
|
|
|
|
|
if (segHitsRect(pts[i], pts[i + 1], c.r)) return true
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const encloses = (frame: Rect, n: Rect) =>
|
|
|
|
|
frame.x <= n.x + 1 &&
|
|
|
|
|
frame.y <= n.y + 1 &&
|
|
|
|
|
frame.x + frame.w >= n.x + n.w - 1 &&
|
|
|
|
|
frame.y + frame.h >= n.y + n.h - 1
|
|
|
|
|
|
|
|
|
|
/** Is this point inside any frame? Routing inside a frame is normal. */
|
|
|
|
|
const insideAnyFrame = (px: number, py: number) =>
|
|
|
|
|
frames.some(
|
|
|
|
|
(c) =>
|
|
|
|
|
px > c.x + 1 &&
|
|
|
|
|
px < c.x + c.w - 1 &&
|
|
|
|
|
py > c.y + 1 &&
|
|
|
|
|
py < c.y + c.h - 1,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Is this segment badly placed relative to the frames, even though it hits nothing?
|
|
|
|
|
*
|
|
|
|
|
* Two ways it can be:
|
|
|
|
|
*
|
|
|
|
|
* - It runs ALONGSIDE a border, within BORDER_MARGIN of it. That looks like a line
|
|
|
|
|
* trying and failing to be the frame's edge. Only counted when the segment is
|
|
|
|
|
* outside every frame: inside one, running near the wall is unavoidable and fine.
|
|
|
|
|
* - It passes THROUGH a frame that contains exactly one of the two endpoints. The
|
|
|
|
|
* line then appears to belong to that frame's contents when it does not — this is
|
|
|
|
|
* the case where an edge from outside a VPC cuts across the whole VPC interior on
|
|
|
|
|
* its way somewhere else.
|
|
|
|
|
*/
|
|
|
|
|
const segAlongFrame = (
|
|
|
|
|
p: Point,
|
|
|
|
|
q: Point,
|
|
|
|
|
a: Rect | null,
|
|
|
|
|
b: Rect | null,
|
|
|
|
|
): boolean => {
|
|
|
|
|
const vertical = Math.abs(p.x - q.x) < 1
|
|
|
|
|
const lo = vertical ? Math.min(p.y, q.y) : Math.min(p.x, q.x)
|
|
|
|
|
const hi = vertical ? Math.max(p.y, q.y) : Math.max(p.x, q.x)
|
|
|
|
|
// A short segment is a connector stub, not a run along a wall.
|
|
|
|
|
if (hi - lo < MIN_RUN) return false
|
|
|
|
|
|
|
|
|
|
const mid = (lo + hi) / 2
|
|
|
|
|
if (!insideAnyFrame(vertical ? p.x : mid, vertical ? mid : p.y)) {
|
|
|
|
|
for (const c of frames) {
|
|
|
|
|
const borders = vertical ? [c.x, c.x + c.w] : [c.y, c.y + c.h]
|
|
|
|
|
const cLo = vertical ? c.y : c.x
|
|
|
|
|
const cHi = vertical ? c.y + c.h : c.x + c.w
|
|
|
|
|
const shared = Math.min(hi, cHi) - Math.max(lo, cLo)
|
|
|
|
|
if (shared <= MIN_RUN) continue
|
|
|
|
|
for (const border of borders)
|
|
|
|
|
if (
|
|
|
|
|
Math.abs((vertical ? p.x : p.y) - border) <
|
|
|
|
|
BORDER_MARGIN
|
|
|
|
|
)
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (a && b)
|
|
|
|
|
for (const c of frames) {
|
|
|
|
|
const across = vertical
|
|
|
|
|
? p.x > c.x + 8 && p.x < c.x + c.w - 8
|
|
|
|
|
: p.y > c.y + 8 && p.y < c.y + c.h - 8
|
|
|
|
|
if (!across) continue
|
|
|
|
|
const cLo = vertical ? c.y : c.x
|
|
|
|
|
const cHi = vertical ? c.y + c.h : c.x + c.w
|
|
|
|
|
if (Math.min(hi, cHi) - Math.max(lo, cLo) <= MIN_RUN) continue
|
|
|
|
|
// Exactly one endpoint inside → the segment is trespassing.
|
|
|
|
|
if (encloses(c, a) !== encloses(c, b)) return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const pathAlongFrame = (
|
|
|
|
|
pts: Point[],
|
|
|
|
|
a: Rect | null,
|
|
|
|
|
b: Rect | null,
|
|
|
|
|
): boolean => {
|
|
|
|
|
for (let i = 0; i < pts.length - 1; i++)
|
|
|
|
|
if (segAlongFrame(pts[i], pts[i + 1], a, b)) return true
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** How many segments of this path are badly placed relative to the frames. */
|
|
|
|
|
const frameOffences = (
|
|
|
|
|
pts: Point[],
|
|
|
|
|
a: Rect | null,
|
|
|
|
|
b: Rect | null,
|
|
|
|
|
): number => {
|
|
|
|
|
let n = 0
|
|
|
|
|
for (let i = 0; i < pts.length - 1; i++)
|
|
|
|
|
if (segAlongFrame(pts[i], pts[i + 1], a, b)) n++
|
|
|
|
|
return n
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Total length of a path, for preferring the shorter of two equally tidy routes. */
|
|
|
|
|
const pathLength = (pts: Point[]): number => {
|
|
|
|
|
let d = 0
|
|
|
|
|
for (let i = 0; i < pts.length - 1; i++)
|
|
|
|
|
d +=
|
|
|
|
|
Math.abs(pts[i + 1].x - pts[i].x) +
|
|
|
|
|
Math.abs(pts[i + 1].y - pts[i].y)
|
|
|
|
|
return d
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- stage 1a: which side does each edge leave from?
|
|
|
|
|
interface Face {
|
|
|
|
|
exit: Side
|
|
|
|
|
entry: Side
|
|
|
|
|
horiz: boolean
|
|
|
|
|
}
|
|
|
|
|
const faces: (Face | null)[] = edges.map((e) => {
|
|
|
|
|
const a = rects.get(e.source)
|
|
|
|
|
const b = rects.get(e.target)
|
|
|
|
|
if (!a || !b) return null
|
|
|
|
|
const fwdX = b.x + b.w / 2 >= a.x + a.w / 2
|
|
|
|
|
const fwdY = b.y + b.h / 2 >= a.y + a.h / 2
|
|
|
|
|
const xOverlap = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x)
|
|
|
|
|
const yOverlap = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y)
|
|
|
|
|
// Prefer the axis the two nodes are separated along: if their vertical extents
|
|
|
|
|
// overlap they sit side by side, so the arrow should run horizontally.
|
|
|
|
|
const horiz =
|
|
|
|
|
yOverlap > 8
|
|
|
|
|
? true
|
|
|
|
|
: xOverlap > 8
|
|
|
|
|
? false
|
|
|
|
|
: Math.abs(b.x - a.x) >= Math.abs(b.y - a.y)
|
|
|
|
|
return horiz
|
|
|
|
|
? { exit: fwdX ? "R" : "L", entry: fwdX ? "L" : "R", horiz: true }
|
|
|
|
|
: { exit: fwdY ? "B" : "T", entry: fwdY ? "T" : "B", horiz: false }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// --- stage 1b: de-collide ports sharing one (node, side)
|
|
|
|
|
const frac = edges.map(() => ({ s: 0.5, t: 0.5 }))
|
|
|
|
|
const groups = new Map<string, { i: number; end: "s" | "t" }[]>()
|
|
|
|
|
edges.forEach((e, i) => {
|
|
|
|
|
const f = faces[i]
|
|
|
|
|
if (!f) return
|
|
|
|
|
for (const end of ["s", "t"] as const) {
|
|
|
|
|
const node = end === "s" ? e.source : e.target
|
|
|
|
|
const side = end === "s" ? f.exit : f.entry
|
|
|
|
|
const key = `${node}|${side}`
|
|
|
|
|
const list = groups.get(key)
|
|
|
|
|
if (list) list.push({ i, end })
|
|
|
|
|
else groups.set(key, [{ i, end }])
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
for (const [key, members] of groups) {
|
|
|
|
|
if (members.length < 2) continue
|
|
|
|
|
const sepIdx = key.lastIndexOf("|")
|
|
|
|
|
const nodeId = key.slice(0, sepIdx)
|
|
|
|
|
const side = key.slice(sepIdx + 1) as Side
|
|
|
|
|
const node = rects.get(nodeId)
|
|
|
|
|
if (!node) continue
|
|
|
|
|
const vertical = side === "L" || side === "R"
|
|
|
|
|
const nodeCentre = vertical ? node.y + node.h / 2 : node.x + node.w / 2
|
|
|
|
|
|
|
|
|
|
// Where the far end of each edge sits along this side's axis — the order edges
|
|
|
|
|
// should be stacked in, so they do not cross each other on the way out.
|
|
|
|
|
const info = members.map((m) => {
|
|
|
|
|
const farId = m.end === "s" ? edges[m.i].target : edges[m.i].source
|
|
|
|
|
const far = rects.get(farId)
|
|
|
|
|
const farCentre = far
|
|
|
|
|
? vertical
|
|
|
|
|
? far.y + far.h / 2
|
|
|
|
|
: far.x + far.w / 2
|
|
|
|
|
: nodeCentre
|
|
|
|
|
return { m, farCentre }
|
|
|
|
|
})
|
|
|
|
|
const setFrac = (m: { i: number; end: "s" | "t" }, f: number) => {
|
|
|
|
|
if (m.end === "s") frac[m.i].s = f
|
|
|
|
|
else frac[m.i].t = f
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// An edge whose far end is on this side's centre line has a clean straight shot.
|
|
|
|
|
// Keep it centred and push the others off, rather than bending all of them.
|
|
|
|
|
const aligned = info.filter(
|
|
|
|
|
(x) => Math.abs(x.farCentre - nodeCentre) < 8,
|
|
|
|
|
)
|
|
|
|
|
if (aligned.length === 1 && members.length <= 3) {
|
|
|
|
|
setFrac(aligned[0].m, 0.5)
|
|
|
|
|
const rest = info.filter((x) => x !== aligned[0])
|
|
|
|
|
const below = rest
|
|
|
|
|
.filter((x) => x.farCentre <= nodeCentre)
|
|
|
|
|
.sort((a, b) => b.farCentre - a.farCentre)
|
|
|
|
|
const above = rest
|
|
|
|
|
.filter((x) => x.farCentre > nodeCentre)
|
|
|
|
|
.sort((a, b) => a.farCentre - b.farCentre)
|
|
|
|
|
below.forEach((x, j) => {
|
|
|
|
|
setFrac(x.m, 0.3 - j * 0.14)
|
|
|
|
|
})
|
|
|
|
|
above.forEach((x, j) => {
|
|
|
|
|
setFrac(x.m, 0.7 + j * 0.14)
|
|
|
|
|
})
|
|
|
|
|
} else {
|
|
|
|
|
info.sort((a, b) => a.farCentre - b.farCentre)
|
|
|
|
|
info.forEach((x, j) => {
|
|
|
|
|
setFrac(x.m, (j + 1) / (members.length + 1))
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- stage 2: try shapes in order of directness, keep the first that is clear
|
|
|
|
|
//
|
|
|
|
|
// Segments already claimed by a routed edge, so later edges can avoid sharing a lane
|
|
|
|
|
// rather than relying on the nudge pass to separate them afterwards.
|
|
|
|
|
const usedSegs: { x1: number; y1: number; x2: number; y2: number }[] = []
|
|
|
|
|
const overlap1 = (a0: number, a1: number, b0: number, b1: number) =>
|
|
|
|
|
Math.min(a1, b1) - Math.max(a0, b0)
|
|
|
|
|
/** Does this path run along a lane an earlier edge already occupies? */
|
|
|
|
|
const overlapsUsed = (pts: Point[]): boolean => {
|
|
|
|
|
for (let i = 0; i < pts.length - 1; i++) {
|
|
|
|
|
const p = pts[i]
|
|
|
|
|
const q = pts[i + 1]
|
|
|
|
|
const vertical = Math.abs(p.x - q.x) < 1
|
|
|
|
|
for (const s of usedSegs) {
|
|
|
|
|
const sVertical = Math.abs(s.x1 - s.x2) < 1
|
|
|
|
|
if (vertical !== sVertical) continue
|
|
|
|
|
if (vertical) {
|
|
|
|
|
if (Math.abs(p.x - s.x1) >= 6) continue
|
|
|
|
|
if (
|
|
|
|
|
overlap1(
|
|
|
|
|
Math.min(p.y, q.y),
|
|
|
|
|
Math.max(p.y, q.y),
|
|
|
|
|
Math.min(s.y1, s.y2),
|
|
|
|
|
Math.max(s.y1, s.y2),
|
|
|
|
|
) > 14
|
|
|
|
|
)
|
|
|
|
|
return true
|
|
|
|
|
} else {
|
|
|
|
|
if (Math.abs(p.y - s.y1) >= 6) continue
|
|
|
|
|
if (
|
|
|
|
|
overlap1(
|
|
|
|
|
Math.min(p.x, q.x),
|
|
|
|
|
Math.max(p.x, q.x),
|
|
|
|
|
Math.min(s.x1, s.x2),
|
|
|
|
|
Math.max(s.x1, s.x2),
|
|
|
|
|
) > 14
|
|
|
|
|
)
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
const claimLanes = (pts: Point[]) => {
|
|
|
|
|
for (let i = 0; i < pts.length - 1; i++)
|
|
|
|
|
usedSegs.push({
|
|
|
|
|
x1: pts[i].x,
|
|
|
|
|
y1: pts[i].y,
|
|
|
|
|
x2: pts[i + 1].x,
|
|
|
|
|
y2: pts[i + 1].y,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const routes: {
|
|
|
|
|
exitSide: Side
|
|
|
|
|
entrySide: Side
|
|
|
|
|
wp: Point[]
|
|
|
|
|
/** The router had to bend around something — a straight line would have hit it. */
|
|
|
|
|
avoided: boolean
|
|
|
|
|
}[] = []
|
|
|
|
|
|
fix(diagram-engine): eliminate arrows drawn through boxes, complete the route search
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.
2026-08-09 18:18:54 +09:00
|
|
|
/**
|
|
|
|
|
* Every two-bend route that is geometrically distinct, ranked; the cheapest clear one.
|
|
|
|
|
*
|
|
|
|
|
* The ladder above tries a fixed list of shapes and, crucially, decides the axis BEFORE it
|
|
|
|
|
* searches — so when a vertical corridor is the only clean option and the axis came out
|
|
|
|
|
* horizontal, the answer is in the half that was never looked at. This does not choose an
|
|
|
|
|
* axis: it tries both trunk directions and all four sides at each end.
|
|
|
|
|
*
|
|
|
|
|
* The lanes come from the obstacles themselves rather than a fixed step. Whether a lane
|
|
|
|
|
* collides can only change where it crosses an obstacle's boundary, so one lane taken from
|
|
|
|
|
* each gap between boundaries covers every distinct outcome — sampling every 10px would
|
|
|
|
|
* test the same corridor repeatedly and still miss a narrow one.
|
|
|
|
|
*/
|
|
|
|
|
const complete = (
|
|
|
|
|
a: Rect,
|
|
|
|
|
b: Rect,
|
|
|
|
|
exempt: Set<string>,
|
|
|
|
|
sf: number,
|
|
|
|
|
tf: number,
|
|
|
|
|
): {
|
|
|
|
|
exitSide: Side
|
|
|
|
|
entrySide: Side
|
|
|
|
|
wp: Point[]
|
|
|
|
|
avoided: boolean
|
|
|
|
|
sf: number
|
|
|
|
|
tf: number
|
|
|
|
|
} | null => {
|
|
|
|
|
const blockers = cards.filter((c) => !exempt.has(c.id))
|
|
|
|
|
// Candidate trunk positions: just outside each obstacle edge, and the middle of each
|
|
|
|
|
// gap between consecutive edges.
|
|
|
|
|
const linesFrom = (
|
|
|
|
|
values: number[],
|
|
|
|
|
lo: number,
|
|
|
|
|
hi: number,
|
|
|
|
|
): number[] => {
|
|
|
|
|
const sorted = [...new Set(values)].sort((p, q) => p - q)
|
|
|
|
|
const out = [lo, hi]
|
|
|
|
|
for (const v of sorted) {
|
|
|
|
|
out.push(v - MARGIN - 1, v + MARGIN + 1)
|
|
|
|
|
}
|
|
|
|
|
for (let k = 0; k + 1 < sorted.length; k++)
|
|
|
|
|
out.push(Math.round((sorted[k] + sorted[k + 1]) / 2))
|
|
|
|
|
return [...new Set(out)]
|
|
|
|
|
}
|
|
|
|
|
const xLanes = linesFrom(
|
|
|
|
|
blockers.flatMap((c) => [c.r.x, c.r.x + c.r.w]),
|
|
|
|
|
Math.min(a.x, b.x) - 40,
|
|
|
|
|
Math.max(a.x + a.w, b.x + b.w) + 40,
|
|
|
|
|
)
|
|
|
|
|
const yLanes = linesFrom(
|
|
|
|
|
blockers.flatMap((c) => [c.r.y, c.r.y + c.r.h]),
|
|
|
|
|
Math.min(a.y, b.y) - 40,
|
|
|
|
|
Math.max(a.y + a.h, b.y + b.h) + 40,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const spread = [0.5, 0.3, 0.7, 0.16, 0.84]
|
|
|
|
|
const portTries: [number, number][] = [[sf, tf]]
|
|
|
|
|
for (const p of spread) for (const q of spread) portTries.push([p, q])
|
|
|
|
|
|
|
|
|
|
let best: {
|
|
|
|
|
exitSide: Side
|
|
|
|
|
entrySide: Side
|
|
|
|
|
wp: Point[]
|
|
|
|
|
avoided: boolean
|
|
|
|
|
/** Fractions this route needs; they may differ from the assigned pair. */
|
|
|
|
|
sf: number
|
|
|
|
|
tf: number
|
|
|
|
|
} | null = null
|
|
|
|
|
let bestCost = Number.POSITIVE_INFINITY
|
|
|
|
|
|
|
|
|
|
const consider = (
|
|
|
|
|
es: Side,
|
|
|
|
|
en: Side,
|
|
|
|
|
shape: Shape,
|
|
|
|
|
pf: number,
|
|
|
|
|
qf: number,
|
|
|
|
|
) => {
|
|
|
|
|
const g = shapePoints(a, b, es, en, pf, qf, shape)
|
|
|
|
|
const pts = [g.sp, ...g.wp, g.ep]
|
|
|
|
|
if (pathHits(pts, exempt)) return
|
|
|
|
|
if (doublesBack(pts, a, b)) return
|
|
|
|
|
const cost =
|
|
|
|
|
frameOffences(pts, a, b) * 5000 +
|
|
|
|
|
(overlapsUsed(pts) ? 700 : 0) +
|
|
|
|
|
g.wp.length * 80 +
|
|
|
|
|
pathLength(pts)
|
|
|
|
|
if (cost < bestCost) {
|
|
|
|
|
bestCost = cost
|
|
|
|
|
best = {
|
|
|
|
|
exitSide: es,
|
|
|
|
|
entrySide: en,
|
|
|
|
|
wp: g.wp,
|
|
|
|
|
avoided: g.wp.length > 0,
|
|
|
|
|
sf: pf,
|
|
|
|
|
tf: qf,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A vertical trunk leaves and enters through a left or right side; a horizontal one
|
|
|
|
|
// through a top or bottom. Both ends are tried on both sides, which is what lets an
|
|
|
|
|
// arrow leave the way it has to rather than the way the axis guess expected.
|
|
|
|
|
//
|
|
|
|
|
// The port FRACTIONS are searched too, not just the sides. The de-collide pass assigns
|
|
|
|
|
// one fraction per (node, side) before any path is known, purely to stop several
|
|
|
|
|
// arrows stacking on one point; when that guess leaves every route dirty, a Z's short
|
|
|
|
|
// stubs are what clip the box, and no choice of trunk lane can help. Spreading ports
|
|
|
|
|
// is a tidiness preference, and not drawing a line through a shape outranks it — so
|
|
|
|
|
// the assigned fraction is tried first and alternatives only if it fails.
|
|
|
|
|
for (const [pf, qf] of portTries) {
|
|
|
|
|
for (const lane of xLanes)
|
|
|
|
|
for (const es of ["L", "R"] as const)
|
|
|
|
|
for (const en of ["L", "R"] as const)
|
|
|
|
|
consider(es, en, { kind: "Zx", lane }, pf, qf)
|
|
|
|
|
for (const lane of yLanes)
|
|
|
|
|
for (const es of ["T", "B"] as const)
|
|
|
|
|
for (const en of ["T", "B"] as const)
|
|
|
|
|
consider(es, en, { kind: "Zy", lane }, pf, qf)
|
|
|
|
|
// One-bend routes, which are tidier when they happen to be clear.
|
|
|
|
|
for (const es of ["L", "R", "T", "B"] as const)
|
|
|
|
|
for (const en of ["L", "R", "T", "B"] as const) {
|
|
|
|
|
consider(es, en, { kind: "Lhv" }, pf, qf)
|
|
|
|
|
consider(es, en, { kind: "Lvh" }, pf, qf)
|
|
|
|
|
}
|
|
|
|
|
// Stop at the first fraction pair that yields a clean route: the assigned one is
|
|
|
|
|
// first, so a tidy answer is preferred whenever it exists.
|
|
|
|
|
if (best) break
|
|
|
|
|
}
|
|
|
|
|
return best
|
|
|
|
|
}
|
|
|
|
|
|
feat(diagram-engine): edge router — pinned ports, obstacle avoidance, cost search
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.
2026-08-09 12:46:44 +09:00
|
|
|
edges.forEach((e, i) => {
|
|
|
|
|
const f = faces[i]
|
|
|
|
|
const a = rects.get(e.source)
|
|
|
|
|
const b = rects.get(e.target)
|
|
|
|
|
if (!f || !a || !b) {
|
|
|
|
|
routes.push({
|
|
|
|
|
exitSide: "R",
|
|
|
|
|
entrySide: "L",
|
|
|
|
|
wp: [],
|
|
|
|
|
avoided: false,
|
|
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
const exempt = new Set([e.source, e.target])
|
|
|
|
|
const sf = frac[i].s
|
|
|
|
|
const tf = frac[i].t
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Try one candidate. `strict` also rejects a path that is merely badly placed
|
|
|
|
|
* relative to the frames — running alongside a border, or cutting through a frame
|
|
|
|
|
* only one endpoint belongs to.
|
|
|
|
|
*
|
|
|
|
|
* Every shape is tried strictly first and the whole ladder re-run relaxed, so a
|
|
|
|
|
* tidier route always wins over a nearer one, and an edge that has no tidy option
|
|
|
|
|
* still gets a sensible path rather than the fallback.
|
|
|
|
|
*/
|
|
|
|
|
const attempt = (
|
|
|
|
|
exitSide: Side,
|
|
|
|
|
entrySide: Side,
|
|
|
|
|
shape: Shape,
|
|
|
|
|
strict: boolean,
|
|
|
|
|
): Point[] | null => {
|
|
|
|
|
const g = shapePoints(a, b, exitSide, entrySide, sf, tf, shape)
|
|
|
|
|
const pts = [g.sp, ...g.wp, g.ep]
|
|
|
|
|
if (pathHits(pts, exempt)) return null
|
fix(diagram-engine): eliminate arrows drawn through boxes, complete the route search
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.
2026-08-09 18:18:54 +09:00
|
|
|
// An arrow that leaves its own shape and immediately turns back across it reads as
|
|
|
|
|
// a mistake, and the obstacle test cannot see it: an edge is exempt from its own
|
|
|
|
|
// endpoints. Refused outright rather than scored, since there is never a reason to
|
|
|
|
|
// prefer it — the generator below will find a route that leaves cleanly.
|
|
|
|
|
if (doublesBack(pts, a, b)) return null
|
feat(diagram-engine): edge router — pinned ports, obstacle avoidance, cost search
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.
2026-08-09 12:46:44 +09:00
|
|
|
if (strict && pathAlongFrame(pts, a, b)) return null
|
|
|
|
|
// Strict mode also declines a lane an earlier edge already runs along. Waiting
|
|
|
|
|
// for the nudge pass to pull them apart afterwards is worse: it can only move
|
|
|
|
|
// a segment so far before it hits something, so two edges that both picked the
|
|
|
|
|
// corridor's centre may stay overlapping.
|
|
|
|
|
if (strict && overlapsUsed(pts)) return null
|
|
|
|
|
return g.wp
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The ladder of candidate shapes, most direct first.
|
|
|
|
|
*
|
|
|
|
|
* Run once refusing anything that hugs or trespasses on a frame, then again with
|
|
|
|
|
* that relaxed. So a tidy longer route beats an untidy shorter one, and an edge
|
|
|
|
|
* with no tidy option still gets a real path instead of the fallback.
|
|
|
|
|
*/
|
|
|
|
|
const ladder = (
|
|
|
|
|
strict: boolean,
|
|
|
|
|
): {
|
|
|
|
|
exitSide: Side
|
|
|
|
|
entrySide: Side
|
|
|
|
|
wp: Point[]
|
|
|
|
|
avoided: boolean
|
|
|
|
|
} | null => {
|
|
|
|
|
// straight, when the two ports already line up
|
|
|
|
|
const aligned = f.horiz
|
|
|
|
|
? Math.abs(a.y + sf * a.h - (b.y + tf * b.h)) < 2
|
|
|
|
|
: Math.abs(a.x + sf * a.w - (b.x + tf * b.w)) < 2
|
|
|
|
|
if (aligned) {
|
|
|
|
|
const wp = attempt(
|
|
|
|
|
f.exit,
|
|
|
|
|
f.entry,
|
|
|
|
|
{ kind: "straight" },
|
|
|
|
|
strict,
|
|
|
|
|
)
|
|
|
|
|
if (wp)
|
|
|
|
|
return {
|
|
|
|
|
exitSide: f.exit,
|
|
|
|
|
entrySide: f.entry,
|
|
|
|
|
wp,
|
|
|
|
|
avoided: false,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A Z whose middle leg sits in the gap between the two nodes.
|
|
|
|
|
//
|
|
|
|
|
// The gap runs from the trailing edge of whichever node comes first to the
|
|
|
|
|
// leading edge of the other. Taking min/max of both edges instead would span
|
|
|
|
|
// the whole distance between them, including anything parked in between — so
|
|
|
|
|
// the sweep would happily place the leg on top of an icon it is meant to
|
|
|
|
|
// route around.
|
|
|
|
|
if (f.horiz) {
|
|
|
|
|
const aFirst = a.x <= b.x
|
|
|
|
|
const lo = aFirst ? a.x + a.w : b.x + b.w
|
|
|
|
|
const hi = aFirst ? b.x : a.x
|
|
|
|
|
for (const lane of laneSweep(lo, hi)) {
|
|
|
|
|
const wp = attempt(
|
|
|
|
|
f.exit,
|
|
|
|
|
f.entry,
|
|
|
|
|
{ kind: "Zx", lane },
|
|
|
|
|
strict,
|
|
|
|
|
)
|
|
|
|
|
if (wp)
|
|
|
|
|
return {
|
|
|
|
|
exitSide: f.exit,
|
|
|
|
|
entrySide: f.entry,
|
|
|
|
|
wp,
|
|
|
|
|
avoided: true,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
const aFirst = a.y <= b.y
|
|
|
|
|
const lo = aFirst ? a.y + a.h : b.y + b.h
|
|
|
|
|
const hi = aFirst ? b.y : a.y
|
|
|
|
|
for (const lane of laneSweep(lo, hi)) {
|
|
|
|
|
const wp = attempt(
|
|
|
|
|
f.exit,
|
|
|
|
|
f.entry,
|
|
|
|
|
{ kind: "Zy", lane },
|
|
|
|
|
strict,
|
|
|
|
|
)
|
|
|
|
|
if (wp)
|
|
|
|
|
return {
|
|
|
|
|
exitSide: f.exit,
|
|
|
|
|
entrySide: f.entry,
|
|
|
|
|
wp,
|
|
|
|
|
avoided: true,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// an L, turning once — this needs a different side at one end
|
|
|
|
|
const downward = b.y + b.h / 2 >= a.y + a.h / 2
|
|
|
|
|
const rightward = b.x + b.w / 2 >= a.x + a.w / 2
|
|
|
|
|
const lCandidates: [Side, Side, Shape][] = f.horiz
|
|
|
|
|
? [
|
|
|
|
|
[f.exit, downward ? "T" : "B", { kind: "Lhv" }],
|
|
|
|
|
[downward ? "B" : "T", f.entry, { kind: "Lvh" }],
|
|
|
|
|
]
|
|
|
|
|
: [
|
|
|
|
|
[f.exit, rightward ? "L" : "R", { kind: "Lvh" }],
|
|
|
|
|
[rightward ? "R" : "L", f.entry, { kind: "Lhv" }],
|
|
|
|
|
]
|
|
|
|
|
for (const [es, en, shape] of lCandidates) {
|
|
|
|
|
const wp = attempt(es, en, shape, strict)
|
|
|
|
|
if (wp)
|
|
|
|
|
return { exitSide: es, entrySide: en, wp, avoided: true }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A detour: out of the way, across, and back. Two bends, which is what it
|
|
|
|
|
// takes to get past something sitting directly between the two nodes — a Z's
|
|
|
|
|
// middle leg runs along the blocked axis and an L only turns once, so neither
|
|
|
|
|
// can clear it.
|
|
|
|
|
const blockers = cards.filter((c) => !exempt.has(c.id))
|
|
|
|
|
const detour = f.horiz
|
|
|
|
|
? (() => {
|
|
|
|
|
const spanLo = Math.min(a.x, b.x)
|
|
|
|
|
const spanHi = Math.max(a.x + a.w, b.x + b.w)
|
|
|
|
|
const between = blockers.filter(
|
|
|
|
|
(c) => c.r.x + c.r.w > spanLo && c.r.x < spanHi,
|
|
|
|
|
)
|
|
|
|
|
if (between.length === 0) return null
|
|
|
|
|
const top = Math.min(...between.map((c) => c.r.y))
|
|
|
|
|
const bottom = Math.max(
|
|
|
|
|
...between.map((c) => c.r.y + c.r.h),
|
|
|
|
|
)
|
|
|
|
|
const aMid = a.y + a.h / 2
|
|
|
|
|
const goUp =
|
|
|
|
|
Math.abs(aMid - top) <= Math.abs(bottom - aMid)
|
|
|
|
|
const lane = goUp
|
|
|
|
|
? top - MARGIN - 14
|
|
|
|
|
: bottom + MARGIN + 14
|
|
|
|
|
const side: Side = goUp ? "T" : "B"
|
|
|
|
|
return {
|
|
|
|
|
exitSide: side,
|
|
|
|
|
entrySide: side,
|
|
|
|
|
wp: [
|
|
|
|
|
{ x: portPoint(a, side, sf).x, y: lane },
|
|
|
|
|
{ x: portPoint(b, side, tf).x, y: lane },
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
})()
|
|
|
|
|
: (() => {
|
|
|
|
|
const spanLo = Math.min(a.y, b.y)
|
|
|
|
|
const spanHi = Math.max(a.y + a.h, b.y + b.h)
|
|
|
|
|
const between = blockers.filter(
|
|
|
|
|
(c) => c.r.y + c.r.h > spanLo && c.r.y < spanHi,
|
|
|
|
|
)
|
|
|
|
|
if (between.length === 0) return null
|
|
|
|
|
const left = Math.min(...between.map((c) => c.r.x))
|
|
|
|
|
const right = Math.max(
|
|
|
|
|
...between.map((c) => c.r.x + c.r.w),
|
|
|
|
|
)
|
|
|
|
|
const aMid = a.x + a.w / 2
|
|
|
|
|
const goLeft =
|
|
|
|
|
Math.abs(aMid - left) <= Math.abs(right - aMid)
|
|
|
|
|
const lane = goLeft
|
|
|
|
|
? left - MARGIN - 14
|
|
|
|
|
: right + MARGIN + 14
|
|
|
|
|
const side: Side = goLeft ? "L" : "R"
|
|
|
|
|
return {
|
|
|
|
|
exitSide: side,
|
|
|
|
|
entrySide: side,
|
|
|
|
|
wp: [
|
|
|
|
|
{ x: lane, y: portPoint(a, side, sf).y },
|
|
|
|
|
{ x: lane, y: portPoint(b, side, tf).y },
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
})()
|
|
|
|
|
if (detour) {
|
|
|
|
|
const sp = portPoint(a, detour.exitSide, sf)
|
|
|
|
|
const ep = portPoint(b, detour.entrySide, tf)
|
|
|
|
|
const pts = [sp, ...detour.wp, ep]
|
|
|
|
|
const ok =
|
|
|
|
|
!pathHits(pts, exempt) &&
|
|
|
|
|
(!strict || !pathAlongFrame(pts, a, b))
|
|
|
|
|
if (ok)
|
|
|
|
|
return {
|
|
|
|
|
exitSide: detour.exitSide,
|
|
|
|
|
entrySide: detour.entrySide,
|
|
|
|
|
wp: detour.wp,
|
|
|
|
|
avoided: true,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Score every candidate shape and return the cheapest.
|
|
|
|
|
*
|
|
|
|
|
* Weights, in the reference router's proportions: an icon hit is disqualifying, a
|
|
|
|
|
* frame offence costs far more than a bend, a bend costs more than distance. So a
|
|
|
|
|
* route that trespasses on one frame beats one that trespasses on two, and among
|
|
|
|
|
* equals the shorter and straighter wins.
|
|
|
|
|
*/
|
|
|
|
|
const cheapest = (): {
|
|
|
|
|
exitSide: Side
|
|
|
|
|
entrySide: Side
|
|
|
|
|
wp: Point[]
|
|
|
|
|
avoided: boolean
|
|
|
|
|
} | null => {
|
|
|
|
|
const candidates: [Side, Side, Shape][] = []
|
|
|
|
|
candidates.push([f.exit, f.entry, { kind: "straight" }])
|
|
|
|
|
if (f.horiz) {
|
|
|
|
|
const aFirst = a.x <= b.x
|
|
|
|
|
const gapLo = aFirst ? a.x + a.w : b.x + b.w
|
|
|
|
|
const gapHi = aFirst ? b.x : a.x
|
|
|
|
|
for (const lane of laneSweep(gapLo, gapHi))
|
|
|
|
|
candidates.push([f.exit, f.entry, { kind: "Zx", lane }])
|
|
|
|
|
// Also consider lanes outside the gap: when the gap is narrow or blocked,
|
|
|
|
|
// going around the outside can be much tidier.
|
|
|
|
|
for (const lane of laneSweep(
|
|
|
|
|
Math.min(a.x, b.x) - 140,
|
|
|
|
|
Math.max(a.x + a.w, b.x + b.w) + 140,
|
|
|
|
|
))
|
|
|
|
|
candidates.push([f.exit, f.entry, { kind: "Zx", lane }])
|
|
|
|
|
} else {
|
|
|
|
|
const aFirst = a.y <= b.y
|
|
|
|
|
const gapLo = aFirst ? a.y + a.h : b.y + b.h
|
|
|
|
|
const gapHi = aFirst ? b.y : a.y
|
|
|
|
|
for (const lane of laneSweep(gapLo, gapHi))
|
|
|
|
|
candidates.push([f.exit, f.entry, { kind: "Zy", lane }])
|
|
|
|
|
for (const lane of laneSweep(
|
|
|
|
|
Math.min(a.y, b.y) - 140,
|
|
|
|
|
Math.max(a.y + a.h, b.y + b.h) + 140,
|
|
|
|
|
))
|
|
|
|
|
candidates.push([f.exit, f.entry, { kind: "Zy", lane }])
|
|
|
|
|
}
|
|
|
|
|
const downward = b.y + b.h / 2 >= a.y + a.h / 2
|
|
|
|
|
const rightward = b.x + b.w / 2 >= a.x + a.w / 2
|
|
|
|
|
candidates.push([f.exit, downward ? "T" : "B", { kind: "Lhv" }])
|
|
|
|
|
candidates.push([downward ? "B" : "T", f.entry, { kind: "Lvh" }])
|
|
|
|
|
candidates.push([f.exit, rightward ? "L" : "R", { kind: "Lvh" }])
|
|
|
|
|
candidates.push([rightward ? "R" : "L", f.entry, { kind: "Lhv" }])
|
|
|
|
|
|
|
|
|
|
let best: {
|
|
|
|
|
exitSide: Side
|
|
|
|
|
entrySide: Side
|
|
|
|
|
wp: Point[]
|
|
|
|
|
avoided: boolean
|
|
|
|
|
} | null = null
|
|
|
|
|
let bestCost = Number.POSITIVE_INFINITY
|
|
|
|
|
|
|
|
|
|
for (const [es, en, shape] of candidates) {
|
|
|
|
|
const g = shapePoints(a, b, es, en, sf, tf, shape)
|
|
|
|
|
const pts = [g.sp, ...g.wp, g.ep]
|
|
|
|
|
if (pathHits(pts, exempt)) continue
|
fix(diagram-engine): eliminate arrows drawn through boxes, complete the route search
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.
2026-08-09 18:18:54 +09:00
|
|
|
if (doublesBack(pts, a, b)) continue
|
feat(diagram-engine): edge router — pinned ports, obstacle avoidance, cost search
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.
2026-08-09 12:46:44 +09:00
|
|
|
// Sharing a lane with an existing edge is weighed as heavily as trespassing
|
|
|
|
|
// on a frame. Two lines drawn on top of each other are indistinguishable —
|
|
|
|
|
// strictly worse to read than one line crossing a border it has to cross
|
|
|
|
|
// anyway. Cheaper weights here made the search accept an overlap in order
|
|
|
|
|
// to save one frame crossing.
|
|
|
|
|
const cost =
|
|
|
|
|
frameOffences(pts, a, b) * 500 +
|
|
|
|
|
(overlapsUsed(pts) ? 700 : 0) +
|
|
|
|
|
g.wp.length * 80 +
|
|
|
|
|
pathLength(pts)
|
|
|
|
|
if (cost < bestCost) {
|
|
|
|
|
bestCost = cost
|
|
|
|
|
best = {
|
|
|
|
|
exitSide: es,
|
|
|
|
|
entrySide: en,
|
|
|
|
|
wp: g.wp,
|
|
|
|
|
avoided: g.wp.length > 0,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return best
|
|
|
|
|
}
|
|
|
|
|
|
fix(diagram-engine): eliminate arrows drawn through boxes, complete the route search
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.
2026-08-09 18:18:54 +09:00
|
|
|
/**
|
|
|
|
|
* The graph search, for the edges a bounded candidate list cannot express.
|
|
|
|
|
*
|
|
|
|
|
* Same shape as `complete` — try the side pairs, keep the cheapest clear result — but
|
|
|
|
|
* each attempt is a full obstacle-avoiding search rather than one fixed shape, so it
|
|
|
|
|
* finds staircases of any number of bends. A* is optimal for a GIVEN pair of sides,
|
|
|
|
|
* which is why the sides are still enumerated out here.
|
|
|
|
|
*/
|
|
|
|
|
const viaGraph = (
|
|
|
|
|
a: Rect,
|
|
|
|
|
b: Rect,
|
|
|
|
|
exempt: Set<string>,
|
|
|
|
|
sf: number,
|
|
|
|
|
tf: number,
|
|
|
|
|
): {
|
|
|
|
|
exitSide: Side
|
|
|
|
|
entrySide: Side
|
|
|
|
|
wp: Point[]
|
|
|
|
|
avoided: boolean
|
|
|
|
|
sf: number
|
|
|
|
|
tf: number
|
|
|
|
|
} | null => {
|
|
|
|
|
const obstacles = cards
|
|
|
|
|
.filter((c) => !exempt.has(c.id))
|
|
|
|
|
.map((c) => c.r)
|
|
|
|
|
let best: {
|
|
|
|
|
exitSide: Side
|
|
|
|
|
entrySide: Side
|
|
|
|
|
wp: Point[]
|
|
|
|
|
avoided: boolean
|
|
|
|
|
sf: number
|
|
|
|
|
tf: number
|
|
|
|
|
} | null = null
|
|
|
|
|
let bestCost = Number.POSITIVE_INFINITY
|
|
|
|
|
|
|
|
|
|
for (const es of ["L", "R", "T", "B"] as const)
|
|
|
|
|
for (const en of ["L", "R", "T", "B"] as const) {
|
|
|
|
|
const sp = portPoint(a, es, sf)
|
|
|
|
|
const ep = portPoint(b, en, tf)
|
|
|
|
|
const path = routeOrthogonal(
|
|
|
|
|
sp,
|
|
|
|
|
ep,
|
|
|
|
|
SIDE_DIR[es],
|
|
|
|
|
// The path must ARRIVE heading into the target's side, which is the
|
|
|
|
|
// reverse of the direction that side faces.
|
|
|
|
|
(SIDE_DIR[en] + 2) % 4,
|
|
|
|
|
obstacles,
|
|
|
|
|
{ xs: [sp.x, ep.x], ys: [sp.y, ep.y] },
|
|
|
|
|
)
|
|
|
|
|
if (!path || path.length < 2) continue
|
|
|
|
|
const wp = path.slice(1, -1)
|
|
|
|
|
const pts = [sp, ...wp, ep]
|
|
|
|
|
if (pathHits(pts, exempt)) continue
|
|
|
|
|
if (doublesBack(pts, a, b)) continue
|
|
|
|
|
const cost =
|
|
|
|
|
frameOffences(pts, a, b) * 5000 +
|
|
|
|
|
(overlapsUsed(pts) ? 700 : 0) +
|
|
|
|
|
wp.length * 80 +
|
|
|
|
|
pathLength(pts)
|
|
|
|
|
if (cost < bestCost) {
|
|
|
|
|
bestCost = cost
|
|
|
|
|
best = {
|
|
|
|
|
exitSide: es,
|
|
|
|
|
entrySide: en,
|
|
|
|
|
wp,
|
|
|
|
|
avoided: wp.length > 0,
|
|
|
|
|
sf,
|
|
|
|
|
tf,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return best
|
|
|
|
|
}
|
|
|
|
|
|
feat(diagram-engine): edge router — pinned ports, obstacle avoidance, cost search
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.
2026-08-09 12:46:44 +09:00
|
|
|
// Strict first: a route that offends no frame wins outright. Failing that, score
|
|
|
|
|
// every candidate and take the least-bad one.
|
|
|
|
|
//
|
|
|
|
|
// Scoring is not optional here. Some edges CANNOT satisfy the strict rule: when one
|
|
|
|
|
// endpoint sits inside a VPC and the other outside it, every possible path
|
|
|
|
|
// trespasses on that frame. Accept-or-reject leaves those edges unoptimised — the
|
|
|
|
|
// relaxed pass takes whatever it happens to try first, which is how a line ends up
|
|
|
|
|
// cutting diagonally across a whole VPC. Weighing the offences instead picks the
|
|
|
|
|
// path that trespasses least and is shortest.
|
fix(diagram-engine): eliminate arrows drawn through boxes, complete the route search
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.
2026-08-09 18:18:54 +09:00
|
|
|
// A bounded candidate search covers all but a fraction of edges and produces tidier
|
|
|
|
|
// routes, so it goes first; the graph search is the backstop for what it cannot do.
|
|
|
|
|
// Measured over 250 generated flowcharts: the candidate search leaves 27 arrows
|
|
|
|
|
// crossing a box out of 2722 edges, and 21 of those need three bends — which is
|
|
|
|
|
// exactly the case a two-bend enumeration cannot express.
|
|
|
|
|
const viaComplete =
|
|
|
|
|
complete(a, b, exempt, sf, tf) ?? viaGraph(a, b, exempt, sf, tf)
|
|
|
|
|
// A route the generator found may need different port positions from the ones the
|
|
|
|
|
// de-collide pass assigned; record them, so the emitted connection points match the
|
|
|
|
|
// path that was actually verified clear.
|
|
|
|
|
if (viaComplete) {
|
|
|
|
|
frac[i].s = viaComplete.sf
|
|
|
|
|
frac[i].t = viaComplete.tf
|
|
|
|
|
}
|
|
|
|
|
const chosen = viaComplete ?? ladder(true) ?? cheapest()
|
feat(diagram-engine): edge router — pinned ports, obstacle avoidance, cost search
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.
2026-08-09 12:46:44 +09:00
|
|
|
if (chosen) {
|
|
|
|
|
routes.push(chosen)
|
|
|
|
|
// Claim this route's lanes so the edges after it look elsewhere.
|
|
|
|
|
claimLanes([
|
fix(diagram-engine): eliminate arrows drawn through boxes, complete the route search
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.
2026-08-09 18:18:54 +09:00
|
|
|
portPoint(a, chosen.exitSide, frac[i].s),
|
feat(diagram-engine): edge router — pinned ports, obstacle avoidance, cost search
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.
2026-08-09 12:46:44 +09:00
|
|
|
...chosen.wp,
|
fix(diagram-engine): eliminate arrows drawn through boxes, complete the route search
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.
2026-08-09 18:18:54 +09:00
|
|
|
portPoint(b, chosen.entrySide, frac[i].t),
|
feat(diagram-engine): edge router — pinned ports, obstacle avoidance, cost search
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.
2026-08-09 12:46:44 +09:00
|
|
|
])
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Nothing was clear even relaxed. Sweep a wider band for a lane that at least
|
|
|
|
|
// clears every icon before settling for one that does not — an unconditional
|
|
|
|
|
// mid-point corridor was the reference project's own reported failure: it could cut
|
|
|
|
|
// straight through nodes.
|
|
|
|
|
const wide = f.horiz
|
|
|
|
|
? {
|
|
|
|
|
lo: Math.min(a.x, b.x) - 160,
|
|
|
|
|
hi: Math.max(a.x + a.w, b.x + b.w) + 160,
|
|
|
|
|
}
|
|
|
|
|
: {
|
|
|
|
|
lo: Math.min(a.y, b.y) - 160,
|
|
|
|
|
hi: Math.max(a.y + a.h, b.y + b.h) + 160,
|
|
|
|
|
}
|
|
|
|
|
let fallbackWp: Point[] | null = null
|
|
|
|
|
for (const lane of laneSweep(wide.lo, wide.hi)) {
|
|
|
|
|
const wp = attempt(
|
|
|
|
|
f.exit,
|
|
|
|
|
f.entry,
|
|
|
|
|
{ kind: f.horiz ? "Zx" : "Zy", lane },
|
|
|
|
|
false,
|
|
|
|
|
)
|
|
|
|
|
if (wp) {
|
|
|
|
|
fallbackWp = wp
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (!fallbackWp) {
|
|
|
|
|
const lane = f.horiz
|
|
|
|
|
? Math.round((a.x + a.w + b.x) / 2)
|
|
|
|
|
: Math.round((a.y + a.h + b.y) / 2)
|
|
|
|
|
fallbackWp = shapePoints(a, b, f.exit, f.entry, sf, tf, {
|
|
|
|
|
kind: f.horiz ? "Zx" : "Zy",
|
|
|
|
|
lane,
|
|
|
|
|
}).wp
|
|
|
|
|
}
|
|
|
|
|
routes.push({
|
|
|
|
|
exitSide: f.exit,
|
|
|
|
|
entrySide: f.entry,
|
|
|
|
|
wp: fallbackWp,
|
|
|
|
|
avoided: true,
|
|
|
|
|
})
|
|
|
|
|
claimLanes([
|
|
|
|
|
portPoint(a, f.exit, sf),
|
|
|
|
|
...fallbackWp,
|
|
|
|
|
portPoint(b, f.entry, tf),
|
|
|
|
|
])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// --- stage 3: nudge parallel segments apart
|
|
|
|
|
// Absolute point paths, which the nudge pass mutates in place.
|
|
|
|
|
const paths: (Point[] | null)[] = edges.map((e, i) => {
|
|
|
|
|
const a = rects.get(e.source)
|
|
|
|
|
const b = rects.get(e.target)
|
|
|
|
|
if (!a || !b) return null
|
|
|
|
|
const r = routes[i]
|
|
|
|
|
const sp = portPoint(a, r.exitSide, frac[i].s)
|
|
|
|
|
const ep = portPoint(b, r.entrySide, frac[i].t)
|
|
|
|
|
return [sp, ...r.wp.map((p) => ({ x: p.x, y: p.y })), ep]
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
interface Seg {
|
|
|
|
|
i: number
|
|
|
|
|
axis: "v" | "h"
|
|
|
|
|
a: Point
|
|
|
|
|
b: Point
|
|
|
|
|
pos: number
|
|
|
|
|
lo: number
|
|
|
|
|
hi: number
|
|
|
|
|
tie: number
|
|
|
|
|
}
|
|
|
|
|
const conflict = (s: Seg, t: Seg) =>
|
|
|
|
|
s.axis === t.axis &&
|
|
|
|
|
Math.abs(s.pos - t.pos) < SEP &&
|
|
|
|
|
Math.min(s.hi, t.hi) - Math.max(s.lo, t.lo) > 8
|
|
|
|
|
|
|
|
|
|
// Repeat: moving one segment can bring it within SEP of a bundle it was not grouped
|
|
|
|
|
// with, and a single pass would leave that new conflict unresolved.
|
|
|
|
|
for (let pass = 0; pass < 3; pass++) {
|
|
|
|
|
const segs: Seg[] = []
|
|
|
|
|
paths.forEach((P, i) => {
|
|
|
|
|
if (!P) return
|
|
|
|
|
// Skip the terminal segments: they touch a port, which is fixed.
|
|
|
|
|
for (let k = 1; k < P.length - 2; k++) {
|
|
|
|
|
const p = P[k]
|
|
|
|
|
const q = P[k + 1]
|
|
|
|
|
if (Math.abs(p.x - q.x) < 1 && Math.abs(p.y - q.y) >= 1)
|
|
|
|
|
segs.push({
|
|
|
|
|
i,
|
|
|
|
|
axis: "v",
|
|
|
|
|
a: P[k],
|
|
|
|
|
b: P[k + 1],
|
|
|
|
|
pos: p.x,
|
|
|
|
|
lo: Math.min(p.y, q.y),
|
|
|
|
|
hi: Math.max(p.y, q.y),
|
|
|
|
|
tie: P[k - 1].x + P[k + 2].x,
|
|
|
|
|
})
|
|
|
|
|
else if (Math.abs(p.y - q.y) < 1 && Math.abs(p.x - q.x) >= 1)
|
|
|
|
|
segs.push({
|
|
|
|
|
i,
|
|
|
|
|
axis: "h",
|
|
|
|
|
a: P[k],
|
|
|
|
|
b: P[k + 1],
|
|
|
|
|
pos: p.y,
|
|
|
|
|
lo: Math.min(p.x, q.x),
|
|
|
|
|
hi: Math.max(p.x, q.x),
|
|
|
|
|
tie: P[k - 1].y + P[k + 2].y,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Group overlapping parallel segments into bundles (connected components).
|
|
|
|
|
const comp = segs.map(() => -1)
|
|
|
|
|
let next = 0
|
|
|
|
|
for (let x = 0; x < segs.length; x++) {
|
|
|
|
|
if (comp[x] === -1) comp[x] = next++
|
|
|
|
|
for (let y = x + 1; y < segs.length; y++) {
|
|
|
|
|
if (!conflict(segs[x], segs[y])) continue
|
|
|
|
|
if (comp[y] === -1) comp[y] = comp[x]
|
|
|
|
|
else if (comp[y] !== comp[x]) {
|
|
|
|
|
const from = comp[y]
|
|
|
|
|
const to = comp[x]
|
|
|
|
|
for (let z = 0; z < segs.length; z++)
|
|
|
|
|
if (comp[z] === from) comp[z] = to
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const bundles = new Map<number, Seg[]>()
|
|
|
|
|
segs.forEach((s, idx) => {
|
|
|
|
|
const list = bundles.get(comp[idx])
|
|
|
|
|
if (list) list.push(s)
|
|
|
|
|
else bundles.set(comp[idx], [s])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
let moved = 0
|
|
|
|
|
for (const bundle of bundles.values()) {
|
|
|
|
|
if (bundle.length < 2) continue
|
|
|
|
|
// Order by current track, then by where the segment's neighbours are, so the
|
|
|
|
|
// spread does not introduce new crossings.
|
|
|
|
|
bundle.sort((a, b) => a.pos - b.pos || a.tie - b.tie)
|
|
|
|
|
const centre = bundle.reduce((s, x) => s + x.pos, 0) / bundle.length
|
|
|
|
|
bundle.forEach((s, j) => {
|
|
|
|
|
const target = Math.round(
|
|
|
|
|
centre + (j - (bundle.length - 1) / 2) * SEP,
|
|
|
|
|
)
|
|
|
|
|
if (target === s.pos) return
|
|
|
|
|
const P = paths[s.i]
|
|
|
|
|
if (!P) return
|
|
|
|
|
const e = edges[s.i]
|
|
|
|
|
const exempt = new Set([e.source, e.target])
|
|
|
|
|
const sa = rects.get(e.source) ?? null
|
|
|
|
|
const sb = rects.get(e.target) ?? null
|
|
|
|
|
// Whether this path already had a frame problem: if so, one more is not
|
|
|
|
|
// the nudge's fault and should not block a tidier spread.
|
|
|
|
|
const alongBefore = pathAlongFrame(P, sa, sb)
|
|
|
|
|
const before = s.pos
|
|
|
|
|
if (s.axis === "v") {
|
|
|
|
|
s.a.x = target
|
|
|
|
|
s.b.x = target
|
|
|
|
|
} else {
|
|
|
|
|
s.a.y = target
|
|
|
|
|
s.b.y = target
|
|
|
|
|
}
|
|
|
|
|
// Revert a move that makes the path WORSE — through an icon, or newly
|
|
|
|
|
// hugging a frame border. Tidier is not worth less correct.
|
|
|
|
|
const worse =
|
|
|
|
|
pathHits(P, exempt) ||
|
|
|
|
|
(!alongBefore && pathAlongFrame(P, sa, sb))
|
|
|
|
|
if (worse) {
|
|
|
|
|
if (s.axis === "v") {
|
|
|
|
|
s.a.x = before
|
|
|
|
|
s.b.x = before
|
|
|
|
|
} else {
|
|
|
|
|
s.a.y = before
|
|
|
|
|
s.b.y = before
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
s.pos = target
|
|
|
|
|
moved++
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
if (!moved) break
|
|
|
|
|
}
|
|
|
|
|
|
fix(diagram-engine): eliminate arrows drawn through boxes, complete the route search
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.
2026-08-09 18:18:54 +09:00
|
|
|
// --- stage 3b: a final check on the whole path
|
|
|
|
|
//
|
|
|
|
|
// The nudge pass judges each segment it moves on its own and reverts that segment if the
|
|
|
|
|
// path got worse. That is not the same as the path being clean: two segments can each be
|
|
|
|
|
// acceptable in isolation while their combination clips a box, and a revert restores only
|
|
|
|
|
// the segment last touched. Re-checking the finished path and restoring the route the
|
|
|
|
|
// search chose is what makes the guarantee hold end to end.
|
|
|
|
|
paths.forEach((P, i) => {
|
|
|
|
|
if (!P) return
|
|
|
|
|
const e = edges[i]
|
|
|
|
|
const exempt = new Set([e.source, e.target])
|
|
|
|
|
const a = rects.get(e.source)
|
|
|
|
|
const b = rects.get(e.target)
|
|
|
|
|
if (!a || !b) return
|
|
|
|
|
if (!pathHits(P, exempt) && !doublesBack(P, a, b)) return
|
|
|
|
|
const r = routes[i]
|
|
|
|
|
const restored = [
|
|
|
|
|
portPoint(a, r.exitSide, frac[i].s),
|
|
|
|
|
...r.wp.map((p) => ({ x: p.x, y: p.y })),
|
|
|
|
|
portPoint(b, r.entrySide, frac[i].t),
|
|
|
|
|
]
|
|
|
|
|
if (!pathHits(restored, exempt) && !doublesBack(restored, a, b))
|
|
|
|
|
paths[i] = restored
|
|
|
|
|
})
|
|
|
|
|
|
feat(diagram-engine): edge router — pinned ports, obstacle avoidance, cost search
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.
2026-08-09 12:46:44 +09:00
|
|
|
// --- emit
|
|
|
|
|
const sideFraction = (side: Side, f: number) =>
|
|
|
|
|
side === "L"
|
|
|
|
|
? { x: 0, y: f }
|
|
|
|
|
: side === "R"
|
|
|
|
|
? { x: 1, y: f }
|
|
|
|
|
: side === "T"
|
|
|
|
|
? { x: f, y: 0 }
|
|
|
|
|
: { x: f, y: 1 }
|
|
|
|
|
|
|
|
|
|
const round3 = (v: number) => Math.round(v * 1000) / 1000
|
|
|
|
|
const clamp01 = (v: number) => Math.max(0.04, Math.min(0.96, v))
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Move a port to the side the adjacent waypoint actually arrives from.
|
|
|
|
|
*
|
|
|
|
|
* The side is chosen before the path is known, so on a bent route the two can end up
|
|
|
|
|
* disagreeing: the search settles on, say, a bottom entry while the last leg comes in
|
|
|
|
|
* from above. draw.io then draws the terminal segment straight THROUGH the icon to
|
|
|
|
|
* reach the far-side port — an arrow that appears to pierce the shape it points at.
|
|
|
|
|
*
|
|
|
|
|
* Snapping is only meaningful for a bent route: a straight one connects two aligned
|
|
|
|
|
* ports and cannot pierce anything. When the waypoint sits diagonally off a corner
|
|
|
|
|
* there is no single side it arrives from, so the router's original choice stands.
|
|
|
|
|
*/
|
|
|
|
|
const snapPort = (
|
|
|
|
|
n: Rect,
|
|
|
|
|
adjacent: Point,
|
|
|
|
|
fallback: { x: number; y: number },
|
|
|
|
|
): { x: number; y: number } => {
|
|
|
|
|
const withinX = adjacent.x > n.x + 1 && adjacent.x < n.x + n.w - 1
|
|
|
|
|
const withinY = adjacent.y > n.y + 1 && adjacent.y < n.y + n.h - 1
|
|
|
|
|
if (withinX === withinY) return fallback
|
|
|
|
|
const cx = n.x + n.w / 2
|
|
|
|
|
const cy = n.y + n.h / 2
|
|
|
|
|
return withinX
|
|
|
|
|
? {
|
|
|
|
|
x: clamp01((adjacent.x - n.x) / n.w),
|
|
|
|
|
y: adjacent.y <= cy ? 0 : 1,
|
|
|
|
|
}
|
|
|
|
|
: {
|
|
|
|
|
x: adjacent.x <= cx ? 0 : 1,
|
|
|
|
|
y: clamp01((adjacent.y - n.y) / n.h),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return edges.map((e, i) => {
|
|
|
|
|
const r = routes[i]
|
|
|
|
|
const P = paths[i]
|
|
|
|
|
// Drop points the nudge made collinear or duplicate — draw.io renders a redundant
|
|
|
|
|
// waypoint as a visible kink.
|
|
|
|
|
let wp: Point[] = []
|
|
|
|
|
if (P && P.length > 2) {
|
|
|
|
|
const kept: Point[] = [P[0]]
|
|
|
|
|
for (let k = 1; k < P.length - 1; k++) {
|
|
|
|
|
const prev = kept[kept.length - 1]
|
|
|
|
|
const cur = P[k]
|
|
|
|
|
const nxt = P[k + 1]
|
|
|
|
|
const collinear =
|
|
|
|
|
(Math.abs(prev.x - cur.x) < 1 &&
|
|
|
|
|
Math.abs(cur.x - nxt.x) < 1) ||
|
|
|
|
|
(Math.abs(prev.y - cur.y) < 1 &&
|
|
|
|
|
Math.abs(cur.y - nxt.y) < 1)
|
|
|
|
|
if (collinear) continue
|
|
|
|
|
if (
|
|
|
|
|
Math.abs(prev.x - cur.x) < 1 &&
|
|
|
|
|
Math.abs(prev.y - cur.y) < 1
|
|
|
|
|
)
|
|
|
|
|
continue
|
|
|
|
|
kept.push(cur)
|
|
|
|
|
}
|
|
|
|
|
wp = kept.slice(1)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let exit = sideFraction(r.exitSide, frac[i].s)
|
|
|
|
|
let entry = sideFraction(r.entrySide, frac[i].t)
|
|
|
|
|
// On a bent route, make each port face where its leg actually comes from.
|
|
|
|
|
const src = rects.get(e.source)
|
|
|
|
|
const tgt = rects.get(e.target)
|
|
|
|
|
if (wp.length > 0) {
|
|
|
|
|
if (src) exit = snapPort(src, wp[0], exit)
|
|
|
|
|
if (tgt) entry = snapPort(tgt, wp[wp.length - 1], entry)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
id: e.id,
|
|
|
|
|
// Round both axes: the fraction lands in y for a left/right side and in x for
|
|
|
|
|
// a top/bottom one.
|
|
|
|
|
exit: { x: round3(exit.x), y: round3(exit.y) },
|
|
|
|
|
entry: { x: round3(entry.x), y: round3(entry.y) },
|
|
|
|
|
waypoints: wp,
|
|
|
|
|
// Freeze only what a re-route would get wrong: a labelled bend (the label
|
|
|
|
|
// needs a straight segment under it) or a deliberate detour.
|
|
|
|
|
freeze: wp.length > 0 && (e.hasLabel || r.avoided),
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|