mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-02 01:20:23 +08:00
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.
261 lines
10 KiB
TypeScript
261 lines
10 KiB
TypeScript
/**
|
|
* Obstacle-avoiding orthogonal routing: the orthogonal visibility graph, and A* over it.
|
|
*
|
|
* The router beside this file works by trying a list of candidate shapes — straight, an L, a
|
|
* Z with its trunk in some lane — and keeping the first that is clear. That can only ever be
|
|
* as good as the list, and a fixed list is not enough: measured over 250 generated
|
|
* flowcharts, 347 arrows were drawn through a box that had nothing to do with them. Adding
|
|
* shapes to the list moves the failures around rather than removing them.
|
|
*
|
|
* This is the complete alternative, from Wybrow, Marriott & Stuckey, "Orthogonal Connector
|
|
* Routing" (Graph Drawing 2009) — the algorithm behind libavoid. Two ideas make it work:
|
|
*
|
|
* 1. THE GRID IS FINITE AND SUFFICIENT. Take the "interesting points": every obstacle
|
|
* corner and every connection point. Their x-coordinates and y-coordinates define a
|
|
* grid. The paper's observation, with proof: for any valid orthogonal route there is a
|
|
* route using only this grid that is no longer and has no more bends — shrink each
|
|
* segment onto the nearest grid line. So searching the grid loses nothing, and there is
|
|
* no resolution to tune. This is what a uniform pixel grid gets wrong in both
|
|
* directions at once: too coarse and it cannot fit through a narrow gap, too fine and
|
|
* the search explodes.
|
|
*
|
|
* 2. THE STATE INCLUDES THE DIRECTION OF ARRIVAL. Bends have to be paid for, and whether
|
|
* the next step is a bend depends on which way this one came in. So a search state is
|
|
* (point, incoming direction), not just (point). Without that the cost function cannot
|
|
* see bends at all.
|
|
*
|
|
* The heuristic is the one libavoid uses: Manhattan distance to the target plus the minimum
|
|
* number of bends still needed, times the bend cost. It never overestimates — the remaining
|
|
* path is at least the straight-line Manhattan distance, and it must contain at least that
|
|
* many bends — so A* returns a cheapest route, not merely a route.
|
|
*
|
|
* Written from the paper's description rather than ported: the reference implementation is a
|
|
* C++ library built for interactive re-routing, with incremental scanline updates and pin
|
|
* management that a one-shot XML generator has no use for.
|
|
*/
|
|
|
|
import type { Rect } from "./types"
|
|
|
|
export interface Point {
|
|
x: number
|
|
y: number
|
|
}
|
|
|
|
/** Which way a path segment travels. Indices are used as array offsets. */
|
|
const DIRS = [
|
|
{ dx: 0, dy: -1 }, // 0 north
|
|
{ dx: 1, dy: 0 }, // 1 east
|
|
{ dx: 0, dy: 1 }, // 2 south
|
|
{ dx: -1, dy: 0 }, // 3 west
|
|
] as const
|
|
|
|
/**
|
|
* Cost of one bend, in pixels of path length.
|
|
*
|
|
* libavoid's default is 10. It has to be positive or the search has no reason to prefer a
|
|
* straight line to a staircase of the same length, and the two look nothing alike.
|
|
*/
|
|
const BEND_COST = 10
|
|
|
|
/** Clearance kept around an obstacle, matching the router's own margin. */
|
|
const MARGIN = 7
|
|
|
|
/**
|
|
* Does the segment from `p` to `q` pass through any obstacle?
|
|
*
|
|
* Obstacles are expanded by `MARGIN` first, so a route grazing a border counts as a hit —
|
|
* an arrow drawn hard against a box reads as touching it.
|
|
*/
|
|
function blocked(p: Point, q: Point, obstacles: Rect[]): boolean {
|
|
const lo = { x: Math.min(p.x, q.x), y: Math.min(p.y, q.y) }
|
|
const hi = { x: Math.max(p.x, q.x), y: Math.max(p.y, q.y) }
|
|
for (const r of obstacles) {
|
|
if (
|
|
lo.x < r.x + r.w + MARGIN &&
|
|
hi.x > r.x - MARGIN &&
|
|
lo.y < r.y + r.h + MARGIN &&
|
|
hi.y > r.y - MARGIN
|
|
)
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* The minimum number of bends to get from `p`, travelling in direction `d`, to `t`.
|
|
*
|
|
* This is the table in the paper's Figure 2(a), as an arithmetic rule rather than sixteen
|
|
* cases. Two independent questions: is the target ahead along the current axis, and is it
|
|
* off to the side? Each answer costs bends, and they compose.
|
|
*/
|
|
function bendsToTarget(p: Point, d: number, t: Point): number {
|
|
const { dx, dy } = DIRS[d]
|
|
// How far the target lies along the direction of travel, and across it.
|
|
const along = dx !== 0 ? (t.x - p.x) * dx : (t.y - p.y) * dy
|
|
const across = dx !== 0 ? t.y - p.y : t.x - p.x
|
|
if (across === 0) {
|
|
// Dead ahead: no bend. Directly behind: out and back, two bends.
|
|
return along >= 0 ? 0 : 2
|
|
}
|
|
// Off to the side: one bend if it is also ahead, two if it is behind.
|
|
return along > 0 ? 1 : 2
|
|
}
|
|
|
|
/**
|
|
* A cheapest obstacle-free orthogonal path from `from` to `to`, or null if none exists.
|
|
*
|
|
* `startDir` and `endDir` are the directions the path must leave and arrive by — the side of
|
|
* the shape each end attaches to. Constraining them is what stops an arrow leaving a box and
|
|
* immediately turning back across it: a departure direction the search must honour on its
|
|
* first step cannot double back.
|
|
*
|
|
* `extraLanes` lets the caller add grid lines the obstacles alone would not produce, which
|
|
* matters when a port sits somewhere other than an obstacle corner.
|
|
*/
|
|
export function routeOrthogonal(
|
|
from: Point,
|
|
to: Point,
|
|
startDir: number,
|
|
endDir: number,
|
|
obstacles: Rect[],
|
|
extraLanes: { xs: number[]; ys: number[] } = { xs: [], ys: [] },
|
|
): Point[] | null {
|
|
// --- the interesting-points grid
|
|
const xs = new Set<number>([from.x, to.x, ...extraLanes.xs])
|
|
const ys = new Set<number>([from.y, to.y, ...extraLanes.ys])
|
|
for (const r of obstacles) {
|
|
// Just outside each edge, so a lane hugging an obstacle is still usable.
|
|
xs.add(r.x - MARGIN - 1)
|
|
xs.add(r.x + r.w + MARGIN + 1)
|
|
ys.add(r.y - MARGIN - 1)
|
|
ys.add(r.y + r.h + MARGIN + 1)
|
|
}
|
|
const X = [...xs].sort((a, b) => a - b)
|
|
const Y = [...ys].sort((a, b) => a - b)
|
|
const xi = new Map(X.map((v, i) => [v, i]))
|
|
const yi = new Map(Y.map((v, i) => [v, i]))
|
|
|
|
const sx = xi.get(from.x)
|
|
const sy = yi.get(from.y)
|
|
const tx = xi.get(to.x)
|
|
const ty = yi.get(to.y)
|
|
if (sx == null || sy == null || tx == null || ty == null) return null
|
|
|
|
// --- A* over (grid point, incoming direction)
|
|
const key = (ix: number, iy: number, d: number) =>
|
|
(iy * X.length + ix) * 4 + d
|
|
const best = new Map<number, number>()
|
|
const parent = new Map<number, number>()
|
|
// A binary heap would be tidier, but the frontier stays small on diagram-sized inputs and
|
|
// a sorted insert keeps this readable.
|
|
const open: { ix: number; iy: number; d: number; g: number; f: number }[] =
|
|
[]
|
|
const push = (ix: number, iy: number, d: number, g: number, f: number) => {
|
|
let lo = 0
|
|
let hi = open.length
|
|
while (lo < hi) {
|
|
const mid = (lo + hi) >> 1
|
|
if (open[mid].f > f) lo = mid + 1
|
|
else hi = mid
|
|
}
|
|
open.splice(lo, 0, { ix, iy, d, g, f })
|
|
}
|
|
|
|
const h = (ix: number, iy: number, d: number) =>
|
|
Math.abs(X[ix] - to.x) +
|
|
Math.abs(Y[iy] - to.y) +
|
|
bendsToTarget({ x: X[ix], y: Y[iy] }, d, to) * BEND_COST
|
|
|
|
const startKey = key(sx, sy, startDir)
|
|
best.set(startKey, 0)
|
|
push(sx, sy, startDir, 0, h(sx, sy, startDir))
|
|
|
|
// The path must ARRIVE travelling in `endDir`, so that is the only accepting state.
|
|
const goalKey = key(tx, ty, endDir)
|
|
let found = false
|
|
|
|
while (open.length > 0) {
|
|
const cur = open.pop() as {
|
|
ix: number
|
|
iy: number
|
|
d: number
|
|
g: number
|
|
f: number
|
|
}
|
|
const ck = key(cur.ix, cur.iy, cur.d)
|
|
if (cur.g > (best.get(ck) ?? Number.POSITIVE_INFINITY)) continue
|
|
if (ck === goalKey) {
|
|
found = true
|
|
break
|
|
}
|
|
|
|
const here = { x: X[cur.ix], y: Y[cur.iy] }
|
|
for (let nd = 0; nd < 4; nd++) {
|
|
// No reversing: it can never help, and it lets a path retrace itself.
|
|
if (nd === (cur.d + 2) % 4) continue
|
|
const { dx, dy } = DIRS[nd]
|
|
// Step to the NEXT grid line in this direction — the grid's whole point is that
|
|
// intermediate positions cannot change whether a route is clear.
|
|
const nix = cur.ix + dx
|
|
const niy = cur.iy + dy
|
|
if (nix < 0 || nix >= X.length || niy < 0 || niy >= Y.length)
|
|
continue
|
|
const next = { x: X[nix], y: Y[niy] }
|
|
if (blocked(here, next, obstacles)) continue
|
|
|
|
const step = Math.abs(next.x - here.x) + Math.abs(next.y - here.y)
|
|
const g = cur.g + step + (nd === cur.d ? 0 : BEND_COST)
|
|
const nk = key(nix, niy, nd)
|
|
if (g >= (best.get(nk) ?? Number.POSITIVE_INFINITY)) continue
|
|
best.set(nk, g)
|
|
parent.set(nk, ck)
|
|
push(nix, niy, nd, g, g + h(nix, niy, nd))
|
|
}
|
|
}
|
|
|
|
if (!found) return null
|
|
|
|
// --- rebuild, then drop the points that are not bends
|
|
const path: Point[] = []
|
|
let node: number | undefined = goalKey
|
|
while (node !== undefined) {
|
|
const d = node % 4
|
|
const rest = (node - d) / 4
|
|
path.unshift({
|
|
x: X[rest % X.length],
|
|
y: Y[(rest - (rest % X.length)) / X.length],
|
|
})
|
|
node = parent.get(node)
|
|
}
|
|
return simplify(path)
|
|
}
|
|
|
|
/** Drop collinear and duplicate points: draw.io renders a redundant waypoint as a kink. */
|
|
function simplify(pts: Point[]): Point[] {
|
|
const out: Point[] = []
|
|
for (const p of pts) {
|
|
const last = out[out.length - 1]
|
|
if (last && Math.abs(last.x - p.x) < 1 && Math.abs(last.y - p.y) < 1)
|
|
continue
|
|
out.push(p)
|
|
}
|
|
const kept: Point[] = []
|
|
for (let i = 0; i < out.length; i++) {
|
|
if (i === 0 || i === out.length - 1) {
|
|
kept.push(out[i])
|
|
continue
|
|
}
|
|
const prev = kept[kept.length - 1]
|
|
const next = out[i + 1]
|
|
const collinear =
|
|
(Math.abs(prev.x - out[i].x) < 1 &&
|
|
Math.abs(out[i].x - next.x) < 1) ||
|
|
(Math.abs(prev.y - out[i].y) < 1 && Math.abs(out[i].y - next.y) < 1)
|
|
if (!collinear) kept.push(out[i])
|
|
}
|
|
return kept
|
|
}
|
|
|
|
/** The direction leaving a given side of a shape: away from it. */
|
|
export const SIDE_DIR = { T: 0, R: 1, B: 2, L: 3 } as const
|