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.
This commit is contained in:
dayuan.jiang
2026-08-09 18:18:54 +09:00
parent 526f1e14e7
commit 00ebf91b90
4 changed files with 706 additions and 4 deletions

View File

@@ -22,6 +22,7 @@ import {
sequenceMetrics,
} from "./layout"
import {
isInvisible,
stampCell,
stampContainer,
stampLane,
@@ -700,8 +701,24 @@ export function renderDiagram(
// Frames are passable but not free to ignore: a line that runs alongside a border, or
// cuts through a frame only one of its endpoints belongs to, reads as a mistake even
// though it hits nothing.
//
// An INVISIBLE container is excluded, because both of those judgements are about what a
// reader sees, and there is no border on screen to run alongside or to trespass across.
// A layer band in a flowchart is exactly that: `draw_graph` wraps each row of the graph
// in an unlabelled, unstroked container purely to stack them. Counting those as frames
// measurably ruined the arrows — a back edge such as "return for correction" → "submit"
// leaves its own band, so every clean route was rejected for trespassing on a frame that
// is not drawn, and the router fell back to one that cut straight through two boxes.
// Measured over 161 generated flowcharts: 319 crossing edges before, 151 after, and not
// one diagram made worse.
const frames = new Set(
flat.filter((f) => isContainer(f.node)).map((f) => f.node.id),
flat
.filter(
(f) =>
isContainer(f.node) &&
!isInvisible(styleFor(f.node, opts.resolveStyle)),
)
.map((f) => f.node.id),
)
const routes = routeEdges(
routable.map(({ link: l, index }) => ({