@statelyai/layout 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/NOTICE.md +4 -2
  2. package/README.md +221 -13
  3. package/dist/elkjs/index.cjs +3 -2
  4. package/dist/elkjs/index.d.cts +13 -2
  5. package/dist/elkjs/index.d.mts +13 -2
  6. package/dist/elkjs/index.mjs +3 -3
  7. package/dist/elkjs/main.cjs +2 -2
  8. package/dist/elkjs/main.d.cts +2 -2
  9. package/dist/elkjs/main.d.mts +2 -2
  10. package/dist/elkjs/main.mjs +2 -2
  11. package/dist/elkjs/worker-api.cjs +1 -1
  12. package/dist/elkjs/worker-api.d.cts +2 -2
  13. package/dist/elkjs/worker-api.d.mts +2 -2
  14. package/dist/elkjs/worker-api.mjs +1 -1
  15. package/dist/elkjs/worker.cjs +2 -2
  16. package/dist/elkjs/worker.mjs +2 -2
  17. package/dist/elkjs-UciYx7lD.mjs +2973 -0
  18. package/dist/elkjs-ggjsBuVP.cjs +3002 -0
  19. package/dist/index-B6cWNrMw.d.cts +80 -0
  20. package/dist/index-CJQLUKD9.d.mts +80 -0
  21. package/dist/{index-BSH2bloA.d.mts → index-DT6lPMfS.d.cts} +117 -5
  22. package/dist/{index-CG712jzV.d.cts → index-F1U-fCTu.d.mts} +117 -5
  23. package/dist/index.cjs +39 -9
  24. package/dist/index.d.cts +4 -3
  25. package/dist/index.d.mts +4 -3
  26. package/dist/index.mjs +4 -3
  27. package/dist/layered/index.cjs +3 -1
  28. package/dist/layered/index.d.cts +3 -2
  29. package/dist/layered/index.d.mts +3 -2
  30. package/dist/layered/index.mjs +2 -2
  31. package/dist/{layered-Bz2nWmVp.mjs → layered-C7ERzZjR.mjs} +12592 -4682
  32. package/dist/{layered-Cf9Rn8l7.cjs → layered-CBPg6_6_.cjs} +12711 -4717
  33. package/dist/{layout-engine-BNex9jxg.cjs → layout-engine-DAw06RhN.cjs} +5 -18
  34. package/dist/{layout-engine-WbC0YjjT.mjs → layout-engine-DC3IYkIy.mjs} +3 -10
  35. package/dist/routing/index.cjs +29 -0
  36. package/dist/routing/index.d.cts +3 -0
  37. package/dist/routing/index.d.mts +3 -0
  38. package/dist/routing/index.mjs +3 -0
  39. package/dist/routing-CR8flv8b.mjs +2780 -0
  40. package/dist/routing-QIdGGFBe.cjs +2996 -0
  41. package/dist/types-CT2akdK0.d.mts +133 -0
  42. package/dist/types-uxPlGRof.d.cts +133 -0
  43. package/dist/{worker-client-DAT_z2OR.d.mts → worker-client-BwGGRD5y.d.mts} +1 -1
  44. package/dist/{worker-client-DVhklF6A.d.cts → worker-client-D4zNyPEI.d.cts} +1 -1
  45. package/package.json +14 -3
  46. package/src/authoring/coordinates.ts +38 -10
  47. package/src/authoring/partial.ts +7 -2
  48. package/src/authoring/routing.ts +4 -1
  49. package/src/constraints.ts +174 -6
  50. package/src/elkjs/index.ts +1397 -452
  51. package/src/elkjs/layout-quality.ts +219 -0
  52. package/src/elkjs/opposing-tracks.ts +161 -0
  53. package/src/elkjs/orthogonal-junctions.ts +174 -0
  54. package/src/elkjs/routes.ts +147 -0
  55. package/src/elkjs/statechart.ts +9 -3
  56. package/src/elkjs/types.ts +2 -0
  57. package/src/index.ts +8 -0
  58. package/src/internal/elkjs-compatibility.ts +4 -1
  59. package/src/internal/layered-trace.ts +48 -0
  60. package/src/internal/layout-engine.ts +2 -1
  61. package/src/java-random.ts +6 -0
  62. package/src/layered/bk-node-placement.ts +403 -92
  63. package/src/layered/center-labels.ts +398 -0
  64. package/src/layered/compaction-bounds.ts +26 -0
  65. package/src/layered/compaction-errors.ts +2 -0
  66. package/src/layered/compaction-scanline.ts +63 -0
  67. package/src/layered/compound-boundaries.ts +99 -0
  68. package/src/layered/compound.ts +445 -0
  69. package/src/layered/constraint-orientation.ts +141 -0
  70. package/src/layered/crossing-constraints.ts +243 -0
  71. package/src/layered/crossing-counter.ts +221 -0
  72. package/src/layered/crossing-graph.ts +401 -0
  73. package/src/layered/cycle-random.ts +22 -0
  74. package/src/layered/elk-port-lists.ts +243 -0
  75. package/src/layered/end-labels.ts +124 -0
  76. package/src/layered/external-port-dummy.ts +137 -0
  77. package/src/layered/feedback-chains.ts +114 -0
  78. package/src/layered/fixed-self-loop.ts +150 -0
  79. package/src/layered/greedy-switch-decider.ts +356 -0
  80. package/src/layered/grouped-compaction.ts +787 -0
  81. package/src/layered/gwt-sort.ts +53 -0
  82. package/src/layered/hierarchical-port-phases.ts +242 -0
  83. package/src/layered/hierarchical-port-restoration.ts +233 -0
  84. package/src/layered/hierarchical-port-routing.ts +179 -0
  85. package/src/layered/hierarchy-crossing.ts +121 -0
  86. package/src/layered/hierarchy-sweepiness.ts +83 -0
  87. package/src/layered/hints.ts +371 -0
  88. package/src/layered/hyperedge-dummy-merger.ts +195 -0
  89. package/src/layered/index.ts +911 -261
  90. package/src/layered/initial-model-order.ts +404 -0
  91. package/src/layered/inverted-ports.ts +207 -0
  92. package/src/layered/label-dummy-switcher.ts +200 -0
  93. package/src/layered/long-edges.ts +135 -44
  94. package/src/layered/loop-envelopes.ts +184 -0
  95. package/src/layered/native-routing.ts +275 -0
  96. package/src/layered/network-simplex.ts +11 -15
  97. package/src/layered/node-margins.ts +59 -0
  98. package/src/layered/north-south-ports.ts +271 -0
  99. package/src/layered/opposing-tracks.ts +944 -0
  100. package/src/layered/orthogonal-cycle-order.ts +106 -0
  101. package/src/layered/orthogonal-hypersegments.ts +92 -0
  102. package/src/layered/orthogonal-junctions.ts +202 -0
  103. package/src/layered/orthogonal-segments.ts +243 -0
  104. package/src/layered/placement-orientation.ts +22 -0
  105. package/src/layered/port-distributor.ts +192 -0
  106. package/src/layered/port-edge-order.ts +20 -0
  107. package/src/layered/replace-routing.ts +45 -0
  108. package/src/layered/routing-coordinates.ts +32 -0
  109. package/src/layered/separate-exterior-labels.ts +24 -6
  110. package/src/layered/spacing.ts +34 -12
  111. package/src/layered/splitter-order.ts +106 -0
  112. package/src/layered/strategies.ts +2024 -883
  113. package/src/layered/types.ts +34 -0
  114. package/src/layered/weighted-compaction.ts +84 -0
  115. package/src/routing/adapters.ts +143 -0
  116. package/src/routing/algorithms.ts +1088 -0
  117. package/src/routing/coordination.ts +91 -0
  118. package/src/routing/engine.ts +473 -0
  119. package/src/routing/index.ts +69 -0
  120. package/src/routing/layout-cache.ts +65 -0
  121. package/src/routing/model.ts +218 -0
  122. package/src/routing/path.ts +324 -0
  123. package/src/routing/persistent.ts +117 -0
  124. package/src/routing/search.ts +358 -0
  125. package/src/routing/spatial.ts +78 -0
  126. package/src/routing/types.ts +146 -0
  127. package/dist/elkjs-BVnDwnBQ.cjs +0 -1551
  128. package/dist/elkjs-CFlfkEBz.mjs +0 -1528
  129. /package/dist/{public-types-B8rAALJq.d.mts → public-types-9NCPoqbp.d.mts} +0 -0
  130. /package/dist/{public-types-CVB0hXGk.d.cts → public-types-BcRZoJE3.d.cts} +0 -0
  131. /package/dist/{worker-client-DkwS4sHx.mjs → worker-client-BNjwT1AJ.mjs} +0 -0
  132. /package/dist/{worker-client-D3HMK33r.cjs → worker-client-D4oEvhAl.cjs} +0 -0
@@ -0,0 +1,1088 @@
1
+ import type {
2
+ Route,
3
+ RouteBounds,
4
+ RouteEndpoint,
5
+ RoutePath,
6
+ RoutePoint,
7
+ RouteSection,
8
+ RouteSide,
9
+ RouteStyle,
10
+ RoutingDiagnostic,
11
+ RoutingMetrics,
12
+ } from "./types";
13
+ import {
14
+ anchor,
15
+ ancestors,
16
+ center,
17
+ groupKey,
18
+ labelRect,
19
+ sideToward,
20
+ vector,
21
+ worldRect,
22
+ type EdgeGeometry,
23
+ type State,
24
+ } from "./model";
25
+ import {
26
+ pathReservations,
27
+ conflictCost,
28
+ outsideTerminal,
29
+ reservationBounds,
30
+ type Reservation,
31
+ } from "./coordination";
32
+ import { crossesRect } from "../authoring/routing";
33
+ import { distance, getPathBounds, pathFromPoints, roundCorners, segmentCrossesRect } from "./path";
34
+ import { inflate, intersects, union } from "./spatial";
35
+ import {
36
+ clear,
37
+ findPath,
38
+ findPathBetweenLeads,
39
+ pointsBounds,
40
+ segmentBounds,
41
+ simplify,
42
+ type SearchContext,
43
+ } from "./search";
44
+
45
+ type RoutingInput = Pick<
46
+ State,
47
+ "nodes" | "edges" | "groups" | "obstacles" | "settings" | "incident"
48
+ >;
49
+
50
+ export type Metrics = { -readonly [K in keyof RoutingMetrics]: RoutingMetrics[K] };
51
+ interface Terminal {
52
+ point: RoutePoint;
53
+ side: RouteSide;
54
+ ref: RouteEndpoint;
55
+ }
56
+ interface Plan {
57
+ path: RoutePath;
58
+ id: string;
59
+ }
60
+ export interface Batch {
61
+ readonly plans: Map<string, Plan | null>;
62
+ dependencyBounds?: RouteBounds;
63
+ edgeCost?(a: RoutePoint, b: RoutePoint): number;
64
+ conflicts?(a: RoutePoint, b: RoutePoint): boolean;
65
+ guides?(bounds: RouteBounds): readonly RouteBounds[];
66
+ }
67
+ function contextFor(
68
+ state: RoutingInput,
69
+ excluded: ReadonlySet<string>,
70
+ endpoints: ReadonlySet<string>,
71
+ metrics: Metrics,
72
+ batch: Batch,
73
+ padding = 0,
74
+ ): SearchContext {
75
+ return {
76
+ edgeCost: (a, b) => batch.edgeCost?.(a, b) ?? 0,
77
+ guides: (bounds) => batch.guides?.(bounds) ?? [],
78
+ // Reserve part of the edge budget for a hard-obstacle retry, rather than
79
+ // spending it all optimizing soft route reservations.
80
+ maxSearchNodes: Math.max(1, Math.floor(state.settings.maxSearchNodes / 2)),
81
+ maxGridNodes: state.settings.maxSearchNodes,
82
+ bendPenalty: state.settings.bendPenalty,
83
+ visited: 0,
84
+ budgetExceeded: false,
85
+ obstacles(bounds) {
86
+ const queried = inflate(bounds, state.settings.clearance + padding);
87
+ batch.dependencyBounds = batch.dependencyBounds
88
+ ? union(batch.dependencyBounds, queried)
89
+ : queried;
90
+ const ids = state.obstacles.query(queried);
91
+ metrics.obstacleCandidates += ids.length;
92
+ return ids
93
+ .filter((id) => !excluded.has(id))
94
+ .map((id) =>
95
+ inflate(
96
+ state.obstacles.bounds.get(id)!,
97
+ endpoints.has(id) ? 0 : state.settings.clearance + padding,
98
+ ),
99
+ );
100
+ },
101
+ };
102
+ }
103
+ function pathLength(path: RoutePath): number {
104
+ return pathReservations(path).reduce((sum, segment) => sum + distance(segment.a, segment.b), 0);
105
+ }
106
+ function pathCost(path: RoutePath, context: SearchContext): number {
107
+ const segments = pathReservations(path);
108
+ const lengthAndConflicts = segments.reduce(
109
+ (sum, segment) =>
110
+ sum + distance(segment.a, segment.b) + (context.edgeCost?.(segment.a, segment.b) ?? 0),
111
+ 0,
112
+ );
113
+ // Use the same turn cost as A* when choosing a preferred path or shifting
114
+ // tracks. Otherwise a tiny length saving can discard a route with fewer bends.
115
+ if (path.segments.some((segment) => segment.kind !== "line")) return lengthAndConflicts;
116
+ let turns = 0;
117
+ for (let i = 1; i < segments.length; i++) {
118
+ const a = segments[i - 1]!,
119
+ b = segments[i]!;
120
+ const ax = a.b.x - a.a.x,
121
+ ay = a.b.y - a.a.y;
122
+ const bx = b.b.x - b.a.x,
123
+ by = b.b.y - b.a.y;
124
+ turns += ax * bx + ay * by < -1e-8 ? 4 : Math.abs(ax * by - ay * bx) > 1e-8 ? 1 : 0;
125
+ }
126
+ return lengthAndConflicts + context.bendPenalty * turns;
127
+ }
128
+ function safe(path: RoutePath, context: SearchContext): boolean {
129
+ let start = path.start;
130
+ for (const segment of path.segments) {
131
+ const bounds = getPathBounds({ start, segments: [segment] });
132
+ if (context.obstacles(bounds).some((rect) => segmentCrossesRect(start, segment, rect)))
133
+ return false;
134
+ start = segment.to;
135
+ }
136
+ return true;
137
+ }
138
+ function cubic(start: Terminal, end: Terminal): RoutePath {
139
+ const amount = Math.max(20, distance(start.point, end.point) / 2),
140
+ a = vector(start.side),
141
+ b = vector(end.side);
142
+ return {
143
+ start: start.point,
144
+ segments: [
145
+ {
146
+ kind: "cubic",
147
+ control1: { x: start.point.x + a.x * amount, y: start.point.y + a.y * amount },
148
+ control2: { x: end.point.x + b.x * amount, y: end.point.y + b.y * amount },
149
+ to: end.point,
150
+ },
151
+ ],
152
+ };
153
+ }
154
+ function curve(
155
+ points: readonly RoutePoint[],
156
+ state: RoutingInput,
157
+ context: SearchContext,
158
+ ): RoutePath {
159
+ const path = pathFromPoints(points);
160
+ for (let radius = state.settings.radius; radius > 0.01; radius /= 2) {
161
+ const rounded = roundCorners(path, { radius });
162
+ if (safe(rounded, context)) return rounded;
163
+ }
164
+ return path;
165
+ }
166
+ /** Elastic string relaxation, constrained by collision checks on each proposed move. */
167
+ function organic(
168
+ points: readonly RoutePoint[],
169
+ state: RoutingInput,
170
+ context: SearchContext,
171
+ ): RoutePath {
172
+ let samples: RoutePoint[] = [];
173
+ for (let i = 1; i < points.length; i++) {
174
+ const a = points[i - 1]!,
175
+ b = points[i]!,
176
+ count = Math.min(16, Math.max(2, Math.ceil(distance(a, b) / 32)));
177
+ for (let j = 0; j < count; j++)
178
+ samples.push({ x: a.x + ((b.x - a.x) * j) / count, y: a.y + ((b.y - a.y) * j) / count });
179
+ }
180
+ samples.push(points.at(-1)!);
181
+ for (let iteration = 0; iteration < state.settings.organicIterations; iteration++) {
182
+ const next = [...samples];
183
+ for (let i = 1; i < samples.length - 1; i++) {
184
+ const p = samples[i]!,
185
+ a = samples[i - 1]!,
186
+ b = samples[i + 1]!;
187
+ let x = (a.x + b.x - 2 * p.x) * 0.2,
188
+ y = (a.y + b.y - 2 * p.y) * 0.2;
189
+ for (const r of context.obstacles(inflate({ ...p, width: 0, height: 0 }, 24))) {
190
+ const q = {
191
+ x: Math.max(r.x, Math.min(r.x + r.width, p.x)),
192
+ y: Math.max(r.y, Math.min(r.y + r.height, p.y)),
193
+ },
194
+ d = distance(p, q);
195
+ if (d > 0 && d < 24) {
196
+ x += ((p.x - q.x) / d) * (24 - d) * 0.2;
197
+ y += ((p.y - q.y) / d) * (24 - d) * 0.2;
198
+ }
199
+ }
200
+ const candidate = { x: p.x + x, y: p.y + y };
201
+ if (clear(next[i - 1]!, candidate, context) && clear(candidate, b, context))
202
+ next[i] = candidate;
203
+ }
204
+ samples = next;
205
+ }
206
+ return curve(simplify(samples), state, context);
207
+ }
208
+ function groupPlan(
209
+ key: string,
210
+ state: RoutingInput,
211
+ metrics: Metrics,
212
+ batch: Batch,
213
+ style: RouteStyle,
214
+ ): Plan | null {
215
+ if (batch.plans.has(key)) return batch.plans.get(key)!;
216
+ const edges = [...(state.groups.get(key)?.keys() ?? [])].map((id) => state.edges.get(id)!);
217
+ const nodes = [...new Set(edges.flatMap((e) => [e.sourceId, e.targetId]))];
218
+ const rectangles = nodes
219
+ .map((id) => state.nodes.get(id))
220
+ .map((n) => n && worldRect(n, state))
221
+ .filter((r): r is RouteBounds => r !== undefined);
222
+ if (rectangles.length !== nodes.length || !rectangles.length) {
223
+ batch.plans.set(key, null);
224
+ return null;
225
+ }
226
+ const common = new Set(ancestors(nodes[0]!, state));
227
+ for (const id of nodes.slice(1)) {
228
+ const set = new Set(ancestors(id, state));
229
+ for (const parent of common) if (!set.has(parent)) common.delete(parent);
230
+ }
231
+ const context = contextFor(
232
+ state,
233
+ new Set([...common].map((id) => `n:${id}`)),
234
+ new Set(),
235
+ metrics,
236
+ batch,
237
+ );
238
+ const bounds = rectangles.reduce(union),
239
+ spacing = state.settings.clearance + state.settings.edgeSpacing + 1;
240
+ let result: Plan | null = null;
241
+ const targets = edges.map(
242
+ (e) => labelRect(e, state) ?? worldRect(state.nodes.get(e.targetId)!, state)!,
243
+ );
244
+ const sources = edges.map((e) => worldRect(state.nodes.get(e.sourceId)!, state)!);
245
+ const candidates: { start: RoutePoint; end: RoutePoint }[] = [];
246
+ if (new Set(edges.map((e) => e.sourceId)).size === 1) {
247
+ const source = sources[0]!,
248
+ target = targets.reduce(union);
249
+ const side = sideToward(center(source), center(target)),
250
+ v = vector(side);
251
+ const port = edges[0]!.sourcePort;
252
+ const named =
253
+ port === undefined
254
+ ? undefined
255
+ : state.nodes.get(edges[0]!.sourceId)!.ports?.find((p) => p.name === port);
256
+ const attachment =
257
+ named && Number.isFinite(named.x) && Number.isFinite(named.y)
258
+ ? {
259
+ x: source.x + named.x! + (named.width ?? 0) / 2,
260
+ y: source.y + named.y! + (named.height ?? 0) / 2,
261
+ }
262
+ : anchor(source, side);
263
+ const start = { x: attachment.x + v.x * spacing, y: attachment.y + v.y * spacing };
264
+ const end =
265
+ side === "right"
266
+ ? { x: target.x - spacing, y: start.y }
267
+ : side === "left"
268
+ ? { x: target.x + target.width + spacing, y: start.y }
269
+ : side === "bottom"
270
+ ? { x: start.x, y: target.y - spacing }
271
+ : { x: start.x, y: target.y + target.height + spacing };
272
+ if ((end.x - start.x) * v.x + (end.y - start.y) * v.y > 1e-8) candidates.push({ start, end });
273
+ }
274
+ // Try the source-facing interior corridor before exterior alternatives.
275
+ const xs = [bounds.x - spacing, bounds.x + bounds.width + spacing];
276
+ const ys = [bounds.y - spacing, bounds.y + bounds.height + spacing];
277
+ const exterior = [
278
+ ...xs.map((x) => ({ start: { x, y: ys[0]! }, end: { x, y: ys[1]! } })),
279
+ ...ys.map((y) => ({ start: { x: xs[0]!, y }, end: { x: xs[1]!, y } })),
280
+ ].flatMap((p) => [p, { start: p.end, end: p.start }]);
281
+ const score = (p: { start: RoutePoint; end: RoutePoint }) =>
282
+ distance(p.start, p.end) +
283
+ sources.reduce((sum, r) => sum + distance(center(r), p.start), 0) +
284
+ targets.reduce((sum, r) => sum + distance(p.end, center(r)), 0);
285
+ exterior.sort((a, b) => score(a) - score(b));
286
+ candidates.push(...exterior);
287
+ for (const candidate of candidates) {
288
+ const points = findPath(candidate.start, candidate.end, "orthogonal", context);
289
+ if (!points) continue;
290
+ result = {
291
+ id: `trunk:${key}`,
292
+ path: style === "bundle" ? curve(points, state, context) : pathFromPoints(points),
293
+ };
294
+ break;
295
+ }
296
+ metrics.searchNodes += context.visited;
297
+ batch.plans.set(key, result);
298
+ return result;
299
+ }
300
+ export function routeEdge(
301
+ edge: EdgeGeometry,
302
+ state: RoutingInput,
303
+ style: RouteStyle,
304
+ metrics: Metrics,
305
+ batch: Batch,
306
+ ): Route {
307
+ const config = state.settings,
308
+ perEdge = config.edges[edge.id] ?? {},
309
+ diagnostics: RoutingDiagnostic[] = [];
310
+ const report = (code: RoutingDiagnostic["code"], message: string) => {
311
+ if (!diagnostics.some((d) => d.code === code))
312
+ diagnostics.push({ code, message, edgeId: edge.id });
313
+ };
314
+ const sourceNode = state.nodes.get(edge.sourceId),
315
+ targetNode = state.nodes.get(edge.targetId);
316
+ const sourceRect = sourceNode && worldRect(sourceNode, state),
317
+ targetRect = targetNode && worldRect(targetNode, state);
318
+ if (!sourceRect || !targetRect)
319
+ report("MISSING_GEOMETRY", "Missing endpoint geometry; rendered a fallback connection");
320
+ const sourceBounds = sourceRect ?? { x: 0, y: 0, width: 0, height: 0 },
321
+ targetBounds = targetRect ?? {
322
+ x: sourceBounds.x + 40,
323
+ y: sourceBounds.y + 40,
324
+ width: 0,
325
+ height: 0,
326
+ };
327
+ const loop = edge.sourceId === edge.targetId;
328
+ const group = groupKey(edge, style, config),
329
+ peers = group ? [...(state.groups.get(group)?.keys() ?? [])] : [];
330
+ const grouped = peers.length > 1 && ["bus", "bundle", "fan"].includes(style);
331
+ const lane = !grouped && peers.length > 1 ? peers.length - 1 - peers.indexOf(edge.id) : 0;
332
+ function terminal(source: boolean): Terminal {
333
+ const node = source ? sourceNode : targetNode,
334
+ outer = source ? sourceBounds : targetBounds,
335
+ attachment = source ? perEdge.sourceAttachment : perEdge.targetAttachment,
336
+ bounds = attachment
337
+ ? {
338
+ ...attachment.bounds,
339
+ x: outer.x + attachment.bounds.x,
340
+ y: outer.y + attachment.bounds.y,
341
+ }
342
+ : outer,
343
+ other = source ? targetBounds : sourceBounds;
344
+ const name = source ? edge.sourcePort : edge.targetPort;
345
+ let side =
346
+ (source ? perEdge.sourceSide : perEdge.targetSide) ??
347
+ (loop ? (source ? "right" : "top") : sideToward(center(bounds), center(other)));
348
+ let point = anchor(bounds, side);
349
+ if (attachment) {
350
+ const toward = labelRect(edge, state) ?? other;
351
+ side =
352
+ (source ? perEdge.sourceSide : perEdge.targetSide) ??
353
+ sideToward(center(bounds), center(toward));
354
+ point = anchor(bounds, side);
355
+ const margin = Math.min(config.clearance, Math.min(bounds.width, bounds.height) / 2);
356
+ if (side === "left" || side === "right")
357
+ point = {
358
+ ...point,
359
+ y: Math.max(
360
+ bounds.y + margin,
361
+ Math.min(bounds.y + bounds.height - margin, center(toward).y),
362
+ ),
363
+ };
364
+ else
365
+ point = {
366
+ ...point,
367
+ x: Math.max(
368
+ bounds.x + margin,
369
+ Math.min(bounds.x + bounds.width - margin, center(toward).x),
370
+ ),
371
+ };
372
+ }
373
+ if (!attachment && name === undefined && !["bus", "fan", "bundle"].includes(style) && !loop) {
374
+ const nodeId = source ? edge.sourceId : edge.targetId;
375
+ const adjacent = [...(state.incident.get(nodeId)?.keys() ?? [])].filter((id) => {
376
+ const e = state.edges.get(id)!;
377
+ if (e.sourceId === e.targetId) return false;
378
+ const from = e.sourceId === nodeId;
379
+ if ((from ? e.sourcePort : e.targetPort) !== undefined) return false;
380
+ const otherNode = state.nodes.get(from ? e.targetId : e.sourceId);
381
+ const rect = otherNode && worldRect(otherNode, state);
382
+ if (!rect) return false;
383
+ const explicit = from ? config.edges[id]?.sourceSide : config.edges[id]?.targetSide;
384
+ return (explicit ?? sideToward(center(bounds), center(rect))) === side;
385
+ });
386
+ const horizontal = side === "left" || side === "right";
387
+ const projected = (id: string) => {
388
+ const e = state.edges.get(id)!;
389
+ const n = state.nodes.get(e.sourceId === nodeId ? e.targetId : e.sourceId)!;
390
+ const p = center(worldRect(n, state)!);
391
+ return horizontal ? p.y : p.x;
392
+ };
393
+ adjacent.sort((a, b) => projected(a) - projected(b) || (a < b ? -1 : a > b ? 1 : 0));
394
+ const span = horizontal ? bounds.height : bounds.width;
395
+ const spacing = Math.min(config.edgeSpacing, (span * 0.7) / Math.max(1, adjacent.length - 1));
396
+ const shift = (adjacent.indexOf(edge.id) - (adjacent.length - 1) / 2) * spacing;
397
+ point = horizontal ? { x: point.x, y: point.y + shift } : { x: point.x + shift, y: point.y };
398
+ }
399
+ if (name !== undefined) {
400
+ const port = node?.ports?.find((p) => p.name === name);
401
+ if (
402
+ port &&
403
+ Number.isFinite(port.x) &&
404
+ Number.isFinite(port.y) &&
405
+ Number.isFinite(port.width ?? 0) &&
406
+ Number.isFinite(port.height ?? 0)
407
+ ) {
408
+ point = {
409
+ x: outer.x + port.x! + (port.width ?? 0) / 2,
410
+ y: outer.y + port.y! + (port.height ?? 0) / 2,
411
+ };
412
+ if (!(source ? perEdge.sourceSide : perEdge.targetSide))
413
+ side = sideToward(center(outer), point);
414
+ } else
415
+ report("MISSING_PORT", `Port ${name} has no positioned geometry; used the node boundary`);
416
+ }
417
+ if (attachment && name === undefined && !(source ? perEdge.sourceSide : perEdge.targetSide)) {
418
+ const excludedParents = new Set(
419
+ [...ancestors(edge.sourceId, state), ...ancestors(edge.targetId, state)].filter(
420
+ (id) => id !== edge.sourceId && id !== edge.targetId,
421
+ ),
422
+ );
423
+ const blocked = (p: RoutePoint, s: RouteSide) => {
424
+ const sign = attachment.facing === "inward" ? -1 : 1,
425
+ v = vector(s);
426
+ const lead = { x: p.x + v.x * sign, y: p.y + v.y * sign };
427
+ return state.obstacles.query(segmentBounds(p, lead)).some((id) => {
428
+ if (
429
+ id === `n:${source ? edge.sourceId : edge.targetId}` ||
430
+ (id.startsWith("n:") && excludedParents.has(id.slice(2)))
431
+ )
432
+ return false;
433
+ return crossesRect(p, lead, state.obstacles.bounds.get(id)!);
434
+ });
435
+ };
436
+ if (blocked(point, side)) {
437
+ const toward = center(labelRect(edge, state) ?? other);
438
+ for (const alternative of ["left", "right", "top", "bottom"] as const) {
439
+ const p = { ...anchor(bounds, alternative) };
440
+ if (alternative === "left" || alternative === "right")
441
+ p.y = Math.max(bounds.y, Math.min(bounds.y + bounds.height, toward.y));
442
+ else p.x = Math.max(bounds.x, Math.min(bounds.x + bounds.width, toward.x));
443
+ if (!blocked(p, alternative)) {
444
+ side = alternative;
445
+ point = p;
446
+ break;
447
+ }
448
+ }
449
+ }
450
+ }
451
+ return {
452
+ point,
453
+ side:
454
+ attachment?.facing === "inward"
455
+ ? ({ left: "right", right: "left", top: "bottom", bottom: "top" } as const)[side]
456
+ : side,
457
+ ref: {
458
+ kind: "node",
459
+ nodeId: source ? edge.sourceId : edge.targetId,
460
+ ...(name === undefined ? {} : { port: name }),
461
+ },
462
+ };
463
+ }
464
+ const source = terminal(true),
465
+ target = terminal(false),
466
+ label = labelRect(edge, state);
467
+ const excluded = new Set(
468
+ [...ancestors(edge.sourceId, state), ...ancestors(edge.targetId, state)]
469
+ .filter((id) => id !== edge.sourceId && id !== edge.targetId)
470
+ .map((id) => `n:${id}`),
471
+ );
472
+ if (perEdge.sourceAttachment !== undefined) excluded.add(`n:${edge.sourceId}`);
473
+ if (perEdge.targetAttachment !== undefined) excluded.add(`n:${edge.targetId}`);
474
+ const context = contextFor(
475
+ state,
476
+ excluded,
477
+ new Set([`n:${edge.sourceId}`, `n:${edge.targetId}`, `e:${edge.id}`]),
478
+ metrics,
479
+ batch,
480
+ lane * config.edgeSpacing,
481
+ );
482
+ const searchContext = ["curved", "organic", "bundle", "bezier"].includes(style)
483
+ ? contextFor(
484
+ state,
485
+ excluded,
486
+ new Set([`n:${edge.sourceId}`, `n:${edge.targetId}`, `e:${edge.id}`]),
487
+ metrics,
488
+ batch,
489
+ config.radius * 2 + lane * config.edgeSpacing,
490
+ )
491
+ : context;
492
+ let futureTargetCorridor: RouteBounds | undefined;
493
+ const protectFutureTarget = (ctx: SearchContext) => {
494
+ const obstacles = ctx.obstacles;
495
+ ctx.obstacles = (bounds) => {
496
+ const result = obstacles(bounds);
497
+ return futureTargetCorridor && intersects(bounds, futureTargetCorridor)
498
+ ? [...result, futureTargetCorridor]
499
+ : result;
500
+ };
501
+ return ctx;
502
+ };
503
+ for (const ctx of new Set([context, searchContext])) protectFutureTarget(ctx);
504
+ const drawn: Reservation[] = [];
505
+ // Sharing terminal attachment regions is clipped below. A later leg must
506
+ // never retrace a collinear segment of this same edge outside those regions.
507
+ const selfCost = (a: RoutePoint, b: RoutePoint) =>
508
+ drawn.reduce((cost, segment) => {
509
+ const overlap = conflictCost(a, b, segment, {
510
+ ...config,
511
+ edgeSpacing: 0,
512
+ crossingPenalty: 0,
513
+ overlapPenalty: 1,
514
+ });
515
+ return cost + (overlap > 1e-8 ? Infinity : conflictCost(a, b, segment, config));
516
+ }, 0);
517
+ for (const ctx of new Set([context, searchContext])) {
518
+ const cost = ctx.edgeCost,
519
+ guides = ctx.guides;
520
+ ctx.edgeCost = (a, b) => (cost?.(a, b) ?? 0) + selfCost(a, b);
521
+ ctx.guides = (bounds) => [
522
+ ...(guides?.(bounds) ?? []),
523
+ ...drawn.map((s) => reservationBounds(s, config.edgeSpacing)),
524
+ ];
525
+ }
526
+ const labelAnchor = (toward: RoutePoint) => {
527
+ const side = sideToward(center(label!), toward),
528
+ point = anchor(label!, side);
529
+ // Border attachments may slide within the label; avoid tiny center-offset jogs.
530
+ if (
531
+ (side === "left" || side === "right") &&
532
+ toward.y > label!.y &&
533
+ toward.y < label!.y + label!.height
534
+ )
535
+ return { x: point.x, y: toward.y };
536
+ if (
537
+ (side === "top" || side === "bottom") &&
538
+ toward.x > label!.x &&
539
+ toward.x < label!.x + label!.width
540
+ )
541
+ return { x: toward.x, y: point.y };
542
+ return point;
543
+ };
544
+ const firstTarget: Terminal = label
545
+ ? {
546
+ point: labelAnchor(source.point),
547
+ side: sideToward(center(label), source.point),
548
+ ref: { kind: "label", edgeId: edge.id },
549
+ }
550
+ : target;
551
+ const lastSource: Terminal | undefined = label
552
+ ? {
553
+ point:
554
+ firstTarget.side === sideToward(center(label), target.point)
555
+ ? anchor(
556
+ label,
557
+ ({ left: "right", right: "left", top: "bottom", bottom: "top" } as const)[
558
+ firstTarget.side
559
+ ],
560
+ )
561
+ : labelAnchor(target.point),
562
+ side:
563
+ firstTarget.side === sideToward(center(label), target.point)
564
+ ? ({ left: "right", right: "left", top: "bottom", bottom: "top" } as const)[
565
+ firstTarget.side
566
+ ]
567
+ : sideToward(center(label), target.point),
568
+ ref: { kind: "label", edgeId: edge.id },
569
+ }
570
+ : undefined;
571
+ metrics.routedEdges++;
572
+ const sections: RouteSection[] = [];
573
+ const stub = (t: Terminal) => {
574
+ if (t.ref.kind === "junction") return t.point;
575
+ const v = vector(t.side),
576
+ length =
577
+ config.clearance +
578
+ 1 +
579
+ (!grouped && peers.length > 1 ? peers.indexOf(edge.id) : 0) * config.edgeSpacing;
580
+ return { x: t.point.x + v.x * length, y: t.point.y + v.y * length };
581
+ };
582
+ function connection(a: Terminal, b: Terminal, via: readonly RoutePoint[] = []): RoutePath {
583
+ let path: RoutePath | undefined, preferred: RoutePath | undefined;
584
+ if (distance(a.point, b.point) < 1e-8 && !via.length) {
585
+ const v = vector(a.side),
586
+ tangent = { x: -v.y, y: v.x };
587
+ const gap = config.clearance + config.edgeSpacing + 10;
588
+ const candidates: RoutePath[] = [];
589
+ // A loop at one port must extend out along that port's normal. The
590
+ // common initial/return lead stays inside the shared attachment region.
591
+ for (const extent of [
592
+ config.clearance + 1,
593
+ (config.clearance + 1) * 2,
594
+ gap,
595
+ gap * 2,
596
+ gap * 3,
597
+ ])
598
+ for (const sign of [1, -1]) {
599
+ const lead = Math.min(config.clearance + 1, extent / 3);
600
+ const at = (normal: number, lateral: number) => ({
601
+ x: a.point.x + v.x * normal + tangent.x * lateral,
602
+ y: a.point.y + v.y * normal + tangent.y * lateral,
603
+ });
604
+ const candidate = pathFromPoints([
605
+ a.point,
606
+ at(lead, 0),
607
+ at(lead, sign * extent),
608
+ at(extent, sign * extent),
609
+ at(extent, 0),
610
+ b.point,
611
+ ]);
612
+ if (safe(candidate, context)) candidates.push(candidate);
613
+ }
614
+ if (!candidates.length) {
615
+ // Optional clearance may hide a real corridor beside a label or node.
616
+ const hard = contextFor(
617
+ state,
618
+ excluded,
619
+ new Set([`n:${edge.sourceId}`, `n:${edge.targetId}`, `e:${edge.id}`]),
620
+ metrics,
621
+ batch,
622
+ -config.clearance,
623
+ );
624
+ for (const extent of [config.clearance + 1, gap, gap * 2])
625
+ for (const sign of [1, -1]) {
626
+ const at = (normal: number, lateral: number) => ({
627
+ x: a.point.x + v.x * normal + tangent.x * lateral,
628
+ y: a.point.y + v.y * normal + tangent.y * lateral,
629
+ });
630
+ const candidate = pathFromPoints([
631
+ a.point,
632
+ at(1, 0),
633
+ at(1, sign * extent),
634
+ at(extent, sign * extent),
635
+ at(extent, 0),
636
+ b.point,
637
+ ]);
638
+ if (safe(candidate, hard)) candidates.push(candidate);
639
+ }
640
+ }
641
+ candidates.sort((x, y) => pathCost(x, context) - pathCost(y, context));
642
+ if (candidates.length) return candidates[0]!;
643
+ report("CONSTRAINT_VIOLATION", "Coincident terminals; no clear outward loop corridor");
644
+ return pathFromPoints([
645
+ a.point,
646
+ { x: a.point.x + v.x * gap, y: a.point.y + v.y * gap },
647
+ b.point,
648
+ ]);
649
+ }
650
+ if (
651
+ loop &&
652
+ !via.length &&
653
+ a.ref.kind === "node" &&
654
+ b.ref.kind === "node" &&
655
+ a.side === "right" &&
656
+ b.side === "top"
657
+ ) {
658
+ const r = inflate(sourceBounds, config.clearance + config.edgeSpacing + 1);
659
+ const points = [
660
+ a.point,
661
+ { x: r.x + r.width, y: a.point.y },
662
+ { x: r.x + r.width, y: r.y },
663
+ { x: b.point.x, y: r.y },
664
+ b.point,
665
+ ];
666
+ const loopPath = pathFromPoints(points);
667
+ if (safe(loopPath, context))
668
+ return ["curved", "bezier", "bundle"].includes(style)
669
+ ? curve(points, state, context)
670
+ : style === "organic"
671
+ ? organic(points, state, context)
672
+ : loopPath;
673
+ }
674
+ if (style === "straight" && !via.length && !loop) path = pathFromPoints([a.point, b.point]);
675
+ else if (style === "bezier" && !via.length && !loop) {
676
+ const direct = cubic(a, b);
677
+ if (safe(direct, context)) {
678
+ if (pathCost(direct, context) === pathLength(direct)) return direct;
679
+ preferred = direct;
680
+ }
681
+ }
682
+ if (!path) {
683
+ const alignedFacing =
684
+ (a.point.x === b.point.x || a.point.y === b.point.y) &&
685
+ (b.point.x - a.point.x) * vector(a.side).x + (b.point.y - a.point.y) * vector(a.side).y >
686
+ 0 &&
687
+ (a.point.x - b.point.x) * vector(b.side).x + (a.point.y - b.point.y) * vector(b.side).y > 0;
688
+ const boundedStub = (t: Terminal) => {
689
+ const p = stub(t),
690
+ gap = distance(a.point, b.point) / 3;
691
+ if (!alignedFacing || distance(t.point, p) <= gap) return p;
692
+ const v = vector(t.side);
693
+ return { x: t.point.x + v.x * gap, y: t.point.y + v.y * gap };
694
+ };
695
+ const start = boundedStub(a),
696
+ end = boundedStub(b),
697
+ waypoints = [...via];
698
+ if (style === "parallel" && peers.length > 1) {
699
+ // Find a feasible corridor before choosing lanes; an arbitrary midpoint
700
+ // may be inside an obstacle even when good parallel routes exist.
701
+ const base = findPathBetweenLeads(start, end, "polyline", context, [
702
+ [a.point, start],
703
+ [end, b.point],
704
+ ]);
705
+ const mid =
706
+ base && base.length > 2
707
+ ? base[Math.floor(base.length / 2)]!
708
+ : center(segmentBounds(start, end));
709
+ const d = Math.max(1, distance(start, end)),
710
+ rank = peers.indexOf(edge.id) + 1;
711
+ const orientation = edge.sourceId < edge.targetId ? 1 : -1;
712
+ if (base && base.length > 2) waypoints.push(mid);
713
+ for (const sign of base && base.length > 2 ? [] : [orientation, -orientation]) {
714
+ const lane = {
715
+ x: mid.x - ((end.y - start.y) / d) * config.edgeSpacing * rank * sign,
716
+ y: mid.y + ((end.x - start.x) / d) * config.edgeSpacing * rank * sign,
717
+ };
718
+ if (clear(lane, lane, context)) {
719
+ waypoints.push(lane);
720
+ break;
721
+ }
722
+ }
723
+ if (waypoints.length === via.length)
724
+ report("CONSTRAINT_VIOLATION", "No clear parallel lane; rendered an individual route");
725
+ }
726
+ const points: RoutePoint[] = [a.point],
727
+ anchors = [start, ...waypoints, end];
728
+ const searchStyle =
729
+ style === "polyline" || style === "organic" || style === "parallel"
730
+ ? "polyline"
731
+ : style === "octilinear"
732
+ ? "octilinear"
733
+ : "orthogonal";
734
+ let ok = clear(a.point, start, context) && clear(end, b.point, context);
735
+ for (let i = 1; ok && i < anchors.length; i++) {
736
+ const directions = {
737
+ incoming: i === 1 && a.ref.kind !== "junction" ? vector(a.side) : undefined,
738
+ outgoing:
739
+ i === anchors.length - 1 ? { x: -vector(b.side).x, y: -vector(b.side).y } : undefined,
740
+ };
741
+ let found = findPathBetweenLeads(
742
+ anchors[i - 1]!,
743
+ anchors[i]!,
744
+ searchStyle,
745
+ searchContext,
746
+ [
747
+ [a.point, start],
748
+ [end, b.point],
749
+ ],
750
+ directions,
751
+ );
752
+ if (!found && searchContext !== context && !searchContext.budgetExceeded) {
753
+ // Extra curve room is optional: tight channels may use safe line segments.
754
+ const narrow = { ...searchContext, obstacles: context.obstacles };
755
+ found = findPathBetweenLeads(
756
+ anchors[i - 1]!,
757
+ anchors[i]!,
758
+ searchStyle,
759
+ narrow,
760
+ [
761
+ [a.point, start],
762
+ [end, b.point],
763
+ ],
764
+ directions,
765
+ );
766
+ searchContext.visited = narrow.visited;
767
+ searchContext.budgetExceeded = narrow.budgetExceeded;
768
+ }
769
+ if (found) points.push(...found.slice(i === 1 ? 0 : 1));
770
+ else ok = false;
771
+ }
772
+ points.push(b.point);
773
+ if (ok) {
774
+ const simple = simplify(points);
775
+ path =
776
+ style === "curved" || style === "bundle" || style === "bezier"
777
+ ? curve(simple, state, context)
778
+ : style === "organic"
779
+ ? organic(simple, state, context)
780
+ : pathFromPoints(simple);
781
+ }
782
+ }
783
+ if (
784
+ preferred &&
785
+ Number.isFinite(pathCost(preferred, context)) &&
786
+ (!path || !safe(path, context) || pathCost(preferred, context) <= pathCost(path, context))
787
+ )
788
+ return preferred;
789
+ if (path && safe(path, context) && Number.isFinite(pathCost(path, context))) return path;
790
+ // Clearance and soft edge reservations must not turn a feasible connection
791
+ // into an obstacle-crossing fallback. Retry against the hard geometry with
792
+ // short terminal leads and a fresh, bounded search budget.
793
+ if (!["straight", "bezier", "polyline", "organic", "parallel"].includes(style)) {
794
+ const hard = {
795
+ ...contextFor(
796
+ state,
797
+ excluded,
798
+ new Set([`n:${edge.sourceId}`, `n:${edge.targetId}`, `e:${edge.id}`]),
799
+ metrics,
800
+ batch,
801
+ -config.clearance,
802
+ ),
803
+ maxSearchNodes: Math.max(
804
+ 0,
805
+ config.maxSearchNodes -
806
+ searchContext.visited -
807
+ (searchContext === context ? 0 : context.visited),
808
+ ),
809
+ };
810
+ protectFutureTarget(hard);
811
+ hard.guides = undefined;
812
+ hard.edgeCost = selfCost;
813
+ const lead = (t: Terminal) => {
814
+ const v = vector(t.side);
815
+ // A positive subpixel gap is still a feasible corridor. A fixed 1px
816
+ // retry lead can enter an adjacent label before search even begins.
817
+ for (let length = 1; length >= 1e-6; length /= 2) {
818
+ const point = { x: t.point.x + v.x * length, y: t.point.y + v.y * length };
819
+ if (clear(t.point, point, hard)) return point;
820
+ }
821
+ return t.point;
822
+ };
823
+ const start = lead(a),
824
+ end = lead(b),
825
+ anchors = [start, ...via, end];
826
+ const points = [a.point];
827
+ let ok = clear(a.point, start, hard) && clear(end, b.point, hard);
828
+ for (let i = 1; ok && i < anchors.length; i++) {
829
+ const found = findPathBetweenLeads(
830
+ anchors[i - 1]!,
831
+ anchors[i]!,
832
+ style === "octilinear" ? "octilinear" : "orthogonal",
833
+ hard,
834
+ [
835
+ [a.point, start],
836
+ [end, b.point],
837
+ ],
838
+ {
839
+ incoming: i === 1 ? vector(a.side) : undefined,
840
+ outgoing:
841
+ i === anchors.length - 1 ? { x: -vector(b.side).x, y: -vector(b.side).y } : undefined,
842
+ },
843
+ );
844
+ if (found) points.push(...found.slice(i === 1 ? 0 : 1));
845
+ else ok = false;
846
+ }
847
+ points.push(b.point);
848
+ const retry = pathFromPoints(simplify(points));
849
+ searchContext.visited += hard.visited;
850
+ if (ok && safe(retry, hard) && Number.isFinite(pathCost(retry, hard))) {
851
+ let separated = retry;
852
+ // Shift whole interior tracks, retaining their orthogonal neighboring
853
+ // legs. This provides parallel lanes even when a crowded visibility
854
+ // grid cannot afford another global optimization search.
855
+ for (let pass = 0; pass < 6; pass++) {
856
+ const track = [separated.start, ...separated.segments.map((s) => s.to)];
857
+ let best = separated,
858
+ bestCost = pathCost(separated, context);
859
+ for (let i = 1; i < track.length - 2; i++) {
860
+ const p = track[i]!,
861
+ q = track[i + 1]!;
862
+ if ((context.edgeCost?.(p, q) ?? 0) === 0) continue;
863
+ const horizontal = p.y === q.y;
864
+ if (!horizontal && p.x !== q.x) continue;
865
+ for (const offset of [
866
+ -config.edgeSpacing,
867
+ config.edgeSpacing,
868
+ -config.edgeSpacing * 2,
869
+ config.edgeSpacing * 2,
870
+ -config.edgeSpacing / 2,
871
+ config.edgeSpacing / 2,
872
+ -config.edgeSpacing / 3,
873
+ config.edgeSpacing / 3,
874
+ -config.edgeSpacing * 4,
875
+ config.edgeSpacing * 4,
876
+ ]) {
877
+ const candidatePoints = track.map((r, j) =>
878
+ j === i || j === i + 1
879
+ ? horizontal
880
+ ? { x: r.x, y: r.y + offset }
881
+ : { x: r.x + offset, y: r.y }
882
+ : r,
883
+ );
884
+ if (
885
+ candidatePoints
886
+ .slice(1)
887
+ .some((r, j) => r.x !== candidatePoints[j]!.x && r.y !== candidatePoints[j]!.y)
888
+ )
889
+ continue;
890
+ const candidate = pathFromPoints(simplify(candidatePoints));
891
+ const segments = pathReservations(candidate);
892
+ const retraces = segments.some((s, j) =>
893
+ segments.slice(j + 1).some((t) => {
894
+ if (s.a.x === s.b.x && t.a.x === t.b.x && s.a.x === t.a.x)
895
+ return (
896
+ Math.min(Math.max(s.a.y, s.b.y), Math.max(t.a.y, t.b.y)) -
897
+ Math.max(Math.min(s.a.y, s.b.y), Math.min(t.a.y, t.b.y)) >
898
+ 1e-8
899
+ );
900
+ if (s.a.y === s.b.y && t.a.y === t.b.y && s.a.y === t.a.y)
901
+ return (
902
+ Math.min(Math.max(s.a.x, s.b.x), Math.max(t.a.x, t.b.x)) -
903
+ Math.max(Math.min(s.a.x, s.b.x), Math.min(t.a.x, t.b.x)) >
904
+ 1e-8
905
+ );
906
+ return false;
907
+ }),
908
+ );
909
+ if (retraces || !safe(candidate, hard)) continue;
910
+ const cost = pathCost(candidate, context);
911
+ if (cost < bestCost) {
912
+ best = candidate;
913
+ bestCost = cost;
914
+ }
915
+ }
916
+ }
917
+ if (best === separated) break;
918
+ separated = best;
919
+ }
920
+ // Keep the feasible path even if soft optimization runs out of budget.
921
+ // A few nearby reservation guides allow separated tracks without
922
+ // rebuilding a grid from every earlier route in the scene.
923
+ if (!via.length && pathCost(retry, context) > pathLength(retry)) {
924
+ const remaining = Math.max(
925
+ 0,
926
+ config.maxSearchNodes -
927
+ searchContext.visited -
928
+ (searchContext === context ? 0 : context.visited),
929
+ );
930
+ const optimized = {
931
+ ...hard,
932
+ visited: 0,
933
+ budgetExceeded: false,
934
+ maxSearchNodes: Math.min(2000, remaining),
935
+ edgeCost: (a: RoutePoint, b: RoutePoint) =>
936
+ (batch.edgeCost?.(a, b) ?? 0) + selfCost(a, b),
937
+ guides: (bounds: RouteBounds) => (batch.guides?.(bounds) ?? []).slice(0, 8),
938
+ };
939
+ const found = findPathBetweenLeads(
940
+ start,
941
+ end,
942
+ style === "octilinear" ? "octilinear" : "orthogonal",
943
+ optimized,
944
+ [
945
+ [a.point, start],
946
+ [end, b.point],
947
+ ],
948
+ {
949
+ incoming: vector(a.side),
950
+ outgoing: { x: -vector(b.side).x, y: -vector(b.side).y },
951
+ },
952
+ );
953
+ searchContext.visited += optimized.visited;
954
+ if (found) {
955
+ const candidate = pathFromPoints(simplify([a.point, ...found, b.point]));
956
+ if (
957
+ safe(candidate, hard) &&
958
+ pathCost(candidate, context) < pathCost(separated, context)
959
+ )
960
+ return candidate;
961
+ }
962
+ }
963
+ return separated;
964
+ }
965
+ }
966
+ report(
967
+ searchContext.budgetExceeded ? "SEARCH_BUDGET" : "ROUTE_BLOCKED",
968
+ "Preferred route unavailable; fallback may cross obstacles or violate routing constraints",
969
+ );
970
+ // Always keep every endpoint and authored waypoint visible, even if no feasible path exists.
971
+ if (loop && !via.length) {
972
+ const r = inflate(sourceBounds, config.clearance + config.edgeSpacing + 1);
973
+ return pathFromPoints([
974
+ a.point,
975
+ { x: r.x + r.width, y: a.point.y },
976
+ { x: r.x + r.width, y: r.y },
977
+ { x: b.point.x, y: r.y },
978
+ b.point,
979
+ ]);
980
+ }
981
+ return pathFromPoints([a.point, ...via, b.point]);
982
+ }
983
+ const via = perEdge.waypoints ?? [];
984
+ if (via.some((p) => !Number.isFinite(p.x) || !Number.isFinite(p.y)))
985
+ throw new RangeError(`Non-finite waypoint on ${edge.id}`);
986
+ // Plan the label's first leg without consuming the final terminal approach.
987
+ // Otherwise the first leg can run through the target; its self-reservation
988
+ // then makes the second leg impossible even in an open corridor.
989
+ if (lastSource && !loop) {
990
+ const hard = contextFor(
991
+ state,
992
+ excluded,
993
+ new Set([`n:${edge.sourceId}`, `n:${edge.targetId}`, `e:${edge.id}`]),
994
+ metrics,
995
+ batch,
996
+ -config.clearance,
997
+ );
998
+ const v = vector(target.side);
999
+ for (let length = config.clearance + 1; length >= 1e-6; length /= 2) {
1000
+ const lead = {
1001
+ x: target.point.x + v.x * length,
1002
+ y: target.point.y + v.y * length,
1003
+ };
1004
+ const corridor = inflate(segmentBounds(target.point, lead), Math.min(0.5, length / 4));
1005
+ if (
1006
+ clear(target.point, lead, hard) &&
1007
+ !intersects(segmentBounds(source.point, source.point), corridor) &&
1008
+ !intersects(segmentBounds(firstTarget.point, firstTarget.point), corridor)
1009
+ ) {
1010
+ futureTargetCorridor = corridor;
1011
+ break;
1012
+ }
1013
+ }
1014
+ }
1015
+ const plan = grouped ? groupPlan(group!, state, metrics, batch, style) : undefined;
1016
+ if (grouped && !plan)
1017
+ report("ROUTE_BLOCKED", "Shared trunk unavailable; used individual fallback routing");
1018
+ if (plan) {
1019
+ const start = plan.path.start,
1020
+ end = plan.path.segments.at(-1)?.to ?? start;
1021
+ const j0: Terminal = {
1022
+ point: start,
1023
+ side: sideToward(start, source.point),
1024
+ ref: { kind: "junction", id: `${plan.id}:start` },
1025
+ };
1026
+ const j1: Terminal = {
1027
+ point: end,
1028
+ side: sideToward(end, firstTarget.point),
1029
+ ref: { kind: "junction", id: `${plan.id}:end` },
1030
+ };
1031
+ sections.push(
1032
+ { id: `${edge.id}:source`, from: source.ref, to: j0.ref, path: connection(source, j0, via) },
1033
+ { id: `${edge.id}:trunk`, sharedId: plan.id, from: j0.ref, to: j1.ref, path: plan.path },
1034
+ {
1035
+ id: `${edge.id}:target`,
1036
+ from: j1.ref,
1037
+ to: firstTarget.ref,
1038
+ path: connection(j1, firstTarget),
1039
+ },
1040
+ );
1041
+ } else
1042
+ sections.push({
1043
+ id: `${edge.id}:0`,
1044
+ from: source.ref,
1045
+ to: firstTarget.ref,
1046
+ path: connection(source, firstTarget, via),
1047
+ });
1048
+ if (lastSource) {
1049
+ futureTargetCorridor = undefined;
1050
+ let segments = sections.flatMap((s) => pathReservations(s.path));
1051
+ // The two legs meet at a label and, for a self-loop, at their node. Sharing
1052
+ // those attachment regions is legitimate; retracing a distant trunk is not.
1053
+ // Label interiors are gaps, but strokes along their borders remain visible
1054
+ // and must stay reserved. Node attachment regions include their borders.
1055
+ segments = segments.flatMap((s) => outsideTerminal(s, label!, false));
1056
+ if (loop)
1057
+ segments = segments.flatMap((s) =>
1058
+ outsideTerminal(s, inflate(sourceBounds, config.clearance + 1)),
1059
+ );
1060
+ drawn.push(...segments);
1061
+ sections.push({
1062
+ id: `${edge.id}:label-target`,
1063
+ from: lastSource.ref,
1064
+ to: target.ref,
1065
+ path: connection(lastSource, target),
1066
+ });
1067
+ }
1068
+ if (
1069
+ sections.some((section) =>
1070
+ pathReservations(section.path).some((segment) => batch.conflicts?.(segment.a, segment.b)),
1071
+ )
1072
+ )
1073
+ report(
1074
+ "ROUTE_CONFLICT",
1075
+ "Route crosses or overlaps another edge; retained a compact visible path",
1076
+ );
1077
+ metrics.searchNodes += searchContext.visited + (searchContext === context ? 0 : context.visited);
1078
+ return {
1079
+ edgeId: edge.id,
1080
+ sections,
1081
+ status: diagnostics.length ? "fallback" : "routed",
1082
+ diagnostics,
1083
+ };
1084
+ }
1085
+ export function routeBounds(route: Route): RouteBounds {
1086
+ return route.sections.map((s) => getPathBounds(s.path)).reduce(union);
1087
+ }
1088
+ export { pointsBounds };