@statelyai/layout 0.4.2 → 0.5.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 (56) hide show
  1. package/README.md +40 -3
  2. package/dist/elkjs/index.cjs +2 -2
  3. package/dist/elkjs/index.d.cts +1 -1
  4. package/dist/elkjs/index.d.mts +1 -1
  5. package/dist/elkjs/index.mjs +2 -2
  6. package/dist/elkjs/main.cjs +1 -1
  7. package/dist/elkjs/main.mjs +1 -1
  8. package/dist/elkjs/worker.cjs +2 -2
  9. package/dist/elkjs/worker.mjs +2 -2
  10. package/dist/{elkjs-CSi7oB5J.mjs → elkjs-DBkalvt1.mjs} +25 -3
  11. package/dist/{elkjs-CnBvdyBu.cjs → elkjs-DonMqGjf.cjs} +25 -3
  12. package/dist/{index-B6cWNrMw.d.cts → index-0YVSvMvA.d.mts} +13 -3
  13. package/dist/{index-CJQLUKD9.d.mts → index-C0J4ttZe.d.cts} +13 -3
  14. package/dist/{index-DT6lPMfS.d.cts → index-Xc4R6y3z.d.cts} +9 -2
  15. package/dist/{index-F1U-fCTu.d.mts → index-lSp0oCJC.d.mts} +9 -2
  16. package/dist/index.cjs +7 -4
  17. package/dist/index.d.cts +4 -4
  18. package/dist/index.d.mts +4 -4
  19. package/dist/index.mjs +4 -4
  20. package/dist/layered/index.cjs +1 -1
  21. package/dist/layered/index.d.cts +2 -2
  22. package/dist/layered/index.d.mts +2 -2
  23. package/dist/layered/index.mjs +1 -1
  24. package/dist/{layered-BsAdm_u8.mjs → layered-CmL7oynw.mjs} +245 -69
  25. package/dist/{layered-BJu8A21s.cjs → layered-SB2mlAqt.cjs} +245 -69
  26. package/dist/{layout-engine-DnlmGxHL.mjs → layout-engine-BXoIpYL4.mjs} +1 -1
  27. package/dist/{layout-engine-BFP1TZbF.cjs → layout-engine-CAyJ-I-u.cjs} +1 -1
  28. package/dist/routing/index.cjs +5 -2
  29. package/dist/routing/index.d.cts +3 -3
  30. package/dist/routing/index.d.mts +3 -3
  31. package/dist/routing/index.mjs +2 -2
  32. package/dist/routing-D-LfMXOF.mjs +5189 -0
  33. package/dist/routing-Phxu1R0x.cjs +5423 -0
  34. package/dist/{types-CT2akdK0.d.mts → types-D8pYSn8I.d.cts} +112 -2
  35. package/dist/{types-uxPlGRof.d.cts → types-MAUtJfKQ.d.mts} +112 -2
  36. package/package.json +4 -1
  37. package/src/authoring/partial.ts +1 -7
  38. package/src/authoring/routing.ts +64 -19
  39. package/src/elkjs/index.ts +26 -0
  40. package/src/layered/feedback-chains.ts +22 -4
  41. package/src/layered/index.ts +173 -41
  42. package/src/layered/north-south-ports.ts +17 -4
  43. package/src/layered/strategies.ts +143 -15
  44. package/src/routing/algorithms.ts +1436 -148
  45. package/src/routing/bezier-fit.ts +605 -0
  46. package/src/routing/bezier-visibility.ts +107 -0
  47. package/src/routing/bezier.ts +596 -0
  48. package/src/routing/engine.ts +65 -13
  49. package/src/routing/index.ts +14 -0
  50. package/src/routing/model.ts +185 -1
  51. package/src/routing/path.ts +1 -1
  52. package/src/routing/policy-geometry.ts +536 -0
  53. package/src/routing/search.ts +10 -2
  54. package/src/routing/types.ts +77 -1
  55. package/dist/routing-CR8flv8b.mjs +0 -2780
  56. package/dist/routing-QIdGGFBe.cjs +0 -2996
@@ -3,15 +3,20 @@ import type {
3
3
  RouteBounds,
4
4
  RouteEndpoint,
5
5
  RoutePath,
6
+ RouteSegment,
6
7
  RoutePoint,
7
8
  RouteSection,
8
9
  RouteSide,
9
10
  RouteStyle,
10
11
  RoutingDiagnostic,
11
12
  RoutingMetrics,
13
+ RoutingPolicy,
14
+ RouteLabelPlacement,
12
15
  } from "./types";
13
16
  import {
14
17
  anchor,
18
+ effectivePolicy,
19
+ type Settings,
15
20
  ancestors,
16
21
  center,
17
22
  groupKey,
@@ -30,7 +35,25 @@ import {
30
35
  type Reservation,
31
36
  } from "./coordination";
32
37
  import { crossesRect } from "../authoring/routing";
33
- import { distance, getPathBounds, pathFromPoints, roundCorners, segmentCrossesRect } from "./path";
38
+ import {
39
+ distance,
40
+ flattenPath,
41
+ getPathBounds,
42
+ pathFromPoints,
43
+ roundCorners,
44
+ segmentCrossesRect,
45
+ } from "./path";
46
+ import {
47
+ bezierThroughPoints,
48
+ catmullRomPath,
49
+ pathEndpointTangent,
50
+ pathHasSelfIntersection,
51
+ pathCurveOvershoot,
52
+ limitBezierHandles,
53
+ roundedBezierPath,
54
+ pathReverseTravel,
55
+ placeFollowingLabel,
56
+ } from "./policy-geometry";
34
57
  import { inflate, intersects, union } from "./spatial";
35
58
  import {
36
59
  clear,
@@ -42,7 +65,7 @@ import {
42
65
  type SearchContext,
43
66
  } from "./search";
44
67
 
45
- type RoutingInput = Pick<
68
+ export type RoutingInput = Pick<
46
69
  State,
47
70
  "nodes" | "edges" | "groups" | "obstacles" | "settings" | "incident"
48
71
  >;
@@ -52,17 +75,23 @@ interface Terminal {
52
75
  point: RoutePoint;
53
76
  side: RouteSide;
54
77
  ref: RouteEndpoint;
78
+ tangent?: RoutePoint;
55
79
  }
56
80
  interface Plan {
57
81
  path: RoutePath;
58
82
  id: string;
59
83
  }
60
84
  export interface Batch {
85
+ policy?: RoutingPolicy;
86
+ routes?: ReadonlyMap<string, Route>;
87
+ previous?: Route;
88
+ previousSettings?: Settings;
61
89
  readonly plans: Map<string, Plan | null>;
62
90
  dependencyBounds?: RouteBounds;
63
91
  edgeCost?(a: RoutePoint, b: RoutePoint): number;
64
92
  conflicts?(a: RoutePoint, b: RoutePoint): boolean;
65
93
  guides?(bounds: RouteBounds): readonly RouteBounds[];
94
+ labelOverlaps?(bounds: RouteBounds): readonly string[];
66
95
  }
67
96
  function contextFor(
68
97
  state: RoutingInput,
@@ -90,7 +119,12 @@ function contextFor(
90
119
  const ids = state.obstacles.query(queried);
91
120
  metrics.obstacleCandidates += ids.length;
92
121
  return ids
93
- .filter((id) => !excluded.has(id))
122
+ .filter(
123
+ (id) =>
124
+ !excluded.has(id) &&
125
+ !(id.startsWith("n:") && batch.policy?.collisions?.nodes === "allow") &&
126
+ !(id.startsWith("e:") && batch.policy?.collisions?.labels === "allow"),
127
+ )
94
128
  .map((id) =>
95
129
  inflate(
96
130
  state.obstacles.bounds.get(id)!,
@@ -168,6 +202,7 @@ function organic(
168
202
  points: readonly RoutePoint[],
169
203
  state: RoutingInput,
170
204
  context: SearchContext,
205
+ anchors: readonly RoutePoint[] = [],
171
206
  ): RoutePath {
172
207
  let samples: RoutePoint[] = [];
173
208
  for (let i = 1; i < points.length; i++) {
@@ -181,8 +216,9 @@ function organic(
181
216
  for (let iteration = 0; iteration < state.settings.organicIterations; iteration++) {
182
217
  const next = [...samples];
183
218
  for (let i = 1; i < samples.length - 1; i++) {
184
- const p = samples[i]!,
185
- a = samples[i - 1]!,
219
+ const p = samples[i]!;
220
+ if (anchors.some((anchor) => distance(anchor, p) < 1e-8)) continue;
221
+ const a = samples[i - 1]!,
186
222
  b = samples[i + 1]!;
187
223
  let x = (a.x + b.x - 2 * p.x) * 0.2,
188
224
  y = (a.y + b.y - 2 * p.y) * 0.2;
@@ -198,12 +234,71 @@ function organic(
198
234
  }
199
235
  }
200
236
  const candidate = { x: p.x + x, y: p.y + y };
201
- if (clear(next[i - 1]!, candidate, context) && clear(candidate, b, context))
237
+ const previous = next[i - 1]!;
238
+ const conflictCost = (point: RoutePoint) =>
239
+ (context.edgeCost?.(previous, point) ?? 0) + (context.edgeCost?.(point, b) ?? 0);
240
+ if (
241
+ clear(previous, candidate, context) &&
242
+ clear(candidate, b, context) &&
243
+ conflictCost(candidate) <= conflictCost(p) + 1e-7
244
+ )
202
245
  next[i] = candidate;
203
246
  }
204
247
  samples = next;
205
248
  }
206
- return curve(simplify(samples), state, context);
249
+ const simplified = simplify(samples);
250
+ if (simplified.length === 2 && !anchors.length) return pathFromPoints(simplified);
251
+ const roundedSegments: RouteSegment[] = [];
252
+ const rounded: RoutePath = { start: samples[0]!, segments: roundedSegments };
253
+ let first = 0;
254
+ for (let i = 1; i < samples.length; i++) {
255
+ if (i !== samples.length - 1 && !anchors.some((anchor) => distance(anchor, samples[i]!) < 1e-8))
256
+ continue;
257
+ const piece = curve(simplify(samples.slice(first, i + 1)), state, context);
258
+ roundedSegments.push(...piece.segments);
259
+ first = i;
260
+ }
261
+ const conflictBudget = pathCost(rounded, context) - pathLength(rounded);
262
+ for (const stride of [3, 2, 1]) {
263
+ const knots = samples.filter(
264
+ (point, index) =>
265
+ index % stride === 0 ||
266
+ index === samples.length - 1 ||
267
+ anchors.some((anchor) => distance(anchor, point) < 1e-8),
268
+ );
269
+ const fitted = catmullRomPath(knots);
270
+ for (const tension of [1, 0.5, 0.25]) {
271
+ let from = fitted.start;
272
+ const candidate = {
273
+ start: from,
274
+ segments: fitted.segments.map((segment) => {
275
+ const adjusted =
276
+ segment.kind === "cubic"
277
+ ? {
278
+ ...segment,
279
+ control1: {
280
+ x: from.x + (segment.control1.x - from.x) * tension,
281
+ y: from.y + (segment.control1.y - from.y) * tension,
282
+ },
283
+ control2: {
284
+ x: segment.to.x + (segment.control2.x - segment.to.x) * tension,
285
+ y: segment.to.y + (segment.control2.y - segment.to.y) * tension,
286
+ },
287
+ }
288
+ : segment;
289
+ from = segment.to;
290
+ return adjusted;
291
+ }),
292
+ };
293
+ if (
294
+ safe(candidate, context) &&
295
+ !pathHasSelfIntersection(candidate) &&
296
+ pathCost(candidate, context) - pathLength(candidate) <= conflictBudget + 1e-7
297
+ )
298
+ return candidate;
299
+ }
300
+ }
301
+ return rounded;
207
302
  }
208
303
  function groupPlan(
209
304
  key: string,
@@ -238,9 +333,13 @@ function groupPlan(
238
333
  const bounds = rectangles.reduce(union),
239
334
  spacing = state.settings.clearance + state.settings.edgeSpacing + 1;
240
335
  let result: Plan | null = null;
241
- const targets = edges.map(
242
- (e) => labelRect(e, state) ?? worldRect(state.nodes.get(e.targetId)!, state)!,
243
- );
336
+ const targets = edges.map((e) => {
337
+ const role = effectivePolicy(state.settings, e.id)?.label?.kind;
338
+ return (
339
+ (role === "follow" || role === "fixed" ? undefined : labelRect(e, state)) ??
340
+ worldRect(state.nodes.get(e.targetId)!, state)!
341
+ );
342
+ });
244
343
  const sources = edges.map((e) => worldRect(state.nodes.get(e.sourceId)!, state)!);
245
344
  const candidates: { start: RoutePoint; end: RoutePoint }[] = [];
246
345
  if (new Set(edges.map((e) => e.sourceId)).size === 1) {
@@ -287,10 +386,23 @@ function groupPlan(
287
386
  for (const candidate of candidates) {
288
387
  const points = findPath(candidate.start, candidate.end, "orthogonal", context);
289
388
  if (!points) continue;
290
- result = {
291
- id: `trunk:${key}`,
292
- path: style === "bundle" ? curve(points, state, context) : pathFromPoints(points),
293
- };
389
+ const policy = batch.policy?.curve;
390
+ const path =
391
+ policy?.kind === "catmull-rom"
392
+ ? catmullRomPath(points)
393
+ : policy?.kind === "bezier"
394
+ ? bezierThroughPoints(points, new Map(), policy.curvature ?? 0.5)
395
+ : policy?.kind === "rounded-orthogonal"
396
+ ? roundCorners(pathFromPoints(points), {
397
+ radius: policy.radius ?? state.settings.radius,
398
+ })
399
+ : policy?.kind === "polyline"
400
+ ? pathFromPoints(points)
401
+ : style === "bundle"
402
+ ? curve(points, state, context)
403
+ : pathFromPoints(points);
404
+ if (batch.policy && !safe(path, context)) continue;
405
+ result = { id: `trunk:${key}`, path };
294
406
  break;
295
407
  }
296
408
  metrics.searchNodes += context.visited;
@@ -304,17 +416,85 @@ export function routeEdge(
304
416
  metrics: Metrics,
305
417
  batch: Batch,
306
418
  ): Route {
307
- const config = state.settings,
419
+ const policy = effectivePolicy(state.settings, edge.id);
420
+ const strategyStyle = style;
421
+ if (policy?.curve?.kind === "rounded-orthogonal" && !["bundle", "bus", "fan"].includes(style))
422
+ style = "orthogonal";
423
+ const config = policy ? { ...state.settings, ...policy.collisions } : state.settings,
308
424
  perEdge = config.edges[edge.id] ?? {},
309
425
  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 });
426
+ const report = (
427
+ code: RoutingDiagnostic["code"],
428
+ message: string,
429
+ constraint?: RoutingDiagnostic["constraint"],
430
+ ) => {
431
+ if (!diagnostics.some((d) => d.code === code && d.constraint === constraint))
432
+ diagnostics.push({ code, message, edgeId: edge.id, ...(constraint ? { constraint } : {}) });
313
433
  };
314
- const sourceNode = state.nodes.get(edge.sourceId),
434
+ if (policy) {
435
+ if (perEdge.bezier?.disabled) {
436
+ report("CONSTRAINT_VIOLATION", "Bézier routing disabled by the edge constraints");
437
+ return { edgeId: edge.id, sections: [], status: "fallback", diagnostics };
438
+ }
439
+ let obstacles = state.obstacles;
440
+ const extraObstacles =
441
+ (perEdge.bezier?.avoidObstacles ?? config.bezier?.avoidObstacles) === false
442
+ ? []
443
+ : (perEdge.bezier?.obstacles ?? []);
444
+ for (const [index, bounds] of [
445
+ ...extraObstacles,
446
+ ...(perEdge.bezier?.terminalBounds ?? []),
447
+ ].entries())
448
+ obstacles = obstacles.set(`policy-barrier:${edge.id}:${index}`, bounds);
449
+ if (obstacles !== state.obstacles) state = { ...state, obstacles };
450
+ const seen = new Set([edge.id]);
451
+ let parent = perEdge.sourceLabel;
452
+ while (parent !== undefined) {
453
+ if (seen.has(parent))
454
+ return {
455
+ edgeId: edge.id,
456
+ sections: [],
457
+ status: "fallback",
458
+ diagnostics: [
459
+ {
460
+ edgeId: edge.id,
461
+ code: "CONSTRAINT_VIOLATION",
462
+ message: "Cyclic source label reference",
463
+ },
464
+ ],
465
+ };
466
+ seen.add(parent);
467
+ parent = config.edges[parent]?.sourceLabel;
468
+ }
469
+ }
470
+ const parentLabel =
471
+ perEdge.sourceLabel === undefined ? undefined : state.edges.get(perEdge.sourceLabel);
472
+ const sourceNode = perEdge.sourceLabel === undefined ? state.nodes.get(edge.sourceId) : undefined,
315
473
  targetNode = state.nodes.get(edge.targetId);
316
- const sourceRect = sourceNode && worldRect(sourceNode, state),
317
- targetRect = targetNode && worldRect(targetNode, state);
474
+ const sourceRect = parentLabel
475
+ ? (batch.routes?.get(parentLabel.id)?.label?.bounds ?? labelRect(parentLabel, state))
476
+ : perEdge.bezier?.source?.kind === "floating"
477
+ ? perEdge.bezier.source.bounds
478
+ : perEdge.bezier?.source?.kind === "port"
479
+ ? perEdge.bezier.source.owner
480
+ : sourceNode && worldRect(sourceNode, state),
481
+ targetRect = perEdge.bezier?.target?.bounds ?? (targetNode && worldRect(targetNode, state));
482
+ if (
483
+ perEdge.sourceLabel !== undefined &&
484
+ batch.routes?.get(perEdge.sourceLabel)?.sections.length === 0
485
+ )
486
+ return {
487
+ edgeId: edge.id,
488
+ sections: [],
489
+ status: "fallback",
490
+ diagnostics: [
491
+ {
492
+ code: "CONSTRAINT_VIOLATION",
493
+ edgeId: edge.id,
494
+ message: "Source label route was rejected",
495
+ },
496
+ ],
497
+ };
318
498
  if (!sourceRect || !targetRect)
319
499
  report("MISSING_GEOMETRY", "Missing endpoint geometry; rendered a fallback connection");
320
500
  const sourceBounds = sourceRect ?? { x: 0, y: 0, width: 0, height: 0 },
@@ -324,12 +504,43 @@ export function routeEdge(
324
504
  width: 0,
325
505
  height: 0,
326
506
  };
327
- const loop = edge.sourceId === edge.targetId;
328
- const group = groupKey(edge, style, config),
507
+ const loop = perEdge.sourceLabel === undefined && edge.sourceId === edge.targetId;
508
+ const group = groupKey(edge, strategyStyle, config),
329
509
  peers = group ? [...(state.groups.get(group)?.keys() ?? [])] : [];
330
- const grouped = peers.length > 1 && ["bus", "bundle", "fan"].includes(style);
510
+ const compatibleGroup =
511
+ !policy ||
512
+ peers.every(
513
+ (id) => JSON.stringify(effectivePolicy(state.settings, id)) === JSON.stringify(policy),
514
+ );
515
+ const grouped = peers.length > 1 && ["bus", "bundle", "fan"].includes(style) && compatibleGroup;
516
+ if (!compatibleGroup)
517
+ report(
518
+ "CONSTRAINT_VIOLATION",
519
+ "Incompatible bundle policies use independent branches",
520
+ "bundle",
521
+ );
331
522
  const lane = !grouped && peers.length > 1 ? peers.length - 1 - peers.indexOf(edge.id) : 0;
332
- function terminal(source: boolean): Terminal {
523
+ const attractingLabel =
524
+ policy?.label?.kind === "follow" || policy?.label?.kind === "fixed"
525
+ ? undefined
526
+ : labelRect(edge, state);
527
+ const automaticFaces =
528
+ !!policy &&
529
+ !loop &&
530
+ !grouped &&
531
+ !parentLabel &&
532
+ !perEdge.sourceAttachment &&
533
+ !perEdge.targetAttachment &&
534
+ !perEdge.bezier &&
535
+ !perEdge.sourceSide &&
536
+ !perEdge.targetSide &&
537
+ edge.sourcePort === undefined &&
538
+ edge.targetPort === undefined &&
539
+ !perEdge.waypoints?.length &&
540
+ policy.sourceTangent?.kind !== "vector" &&
541
+ policy.targetTangent?.kind !== "vector" &&
542
+ (!labelRect(edge, state) || policy.label?.kind === "follow" || policy.label?.kind === "fixed");
543
+ function terminal(source: boolean, selectedSide?: RouteSide): Terminal {
333
544
  const node = source ? sourceNode : targetNode,
334
545
  outer = source ? sourceBounds : targetBounds,
335
546
  attachment = source ? perEdge.sourceAttachment : perEdge.targetAttachment,
@@ -341,16 +552,47 @@ export function routeEdge(
341
552
  }
342
553
  : outer,
343
554
  other = source ? targetBounds : sourceBounds;
344
- const name = source ? edge.sourcePort : edge.targetPort;
555
+ const name = source
556
+ ? perEdge.sourceLabel === undefined
557
+ ? edge.sourcePort
558
+ : undefined
559
+ : edge.targetPort;
345
560
  let side =
561
+ selectedSide ??
346
562
  (source ? perEdge.sourceSide : perEdge.targetSide) ??
347
563
  (loop ? (source ? "right" : "top") : sideToward(center(bounds), center(other)));
564
+ const requestedTangent = source ? policy?.sourceTangent : policy?.targetTangent;
565
+ if (
566
+ requestedTangent?.kind === "vector" &&
567
+ name === undefined &&
568
+ !(source ? perEdge.sourceSide : perEdge.targetSide)
569
+ ) {
570
+ const outward = source
571
+ ? requestedTangent.direction
572
+ : { x: -requestedTangent.direction.x, y: -requestedTangent.direction.y };
573
+ const allowed = source ? perEdge.sourceSides : perEdge.targetSides;
574
+ const preferred = sideToward({ x: 0, y: 0 }, outward);
575
+ side =
576
+ !allowed || allowed.includes(preferred)
577
+ ? preferred
578
+ : (allowed.find((s) => vector(s).x * outward.x + vector(s).y * outward.y > 1e-8) ?? side);
579
+ }
580
+ const allowedSides = source ? perEdge.sourceSides : perEdge.targetSides;
581
+ if (
582
+ policy &&
583
+ !attachment &&
584
+ name === undefined &&
585
+ !(source ? perEdge.sourceSide : perEdge.targetSide) &&
586
+ allowedSides?.length &&
587
+ !allowedSides.includes(side)
588
+ )
589
+ side = allowedSides[0]!;
348
590
  let point = anchor(bounds, side);
349
591
  if (attachment) {
350
- const toward = labelRect(edge, state) ?? other;
592
+ const toward = attractingLabel ?? other;
351
593
  side =
352
594
  (source ? perEdge.sourceSide : perEdge.targetSide) ??
353
- sideToward(center(bounds), center(toward));
595
+ (requestedTangent?.kind === "vector" ? side : sideToward(center(bounds), center(toward)));
354
596
  point = anchor(bounds, side);
355
597
  const margin = Math.min(config.clearance, Math.min(bounds.width, bounds.height) / 2);
356
598
  if (side === "left" || side === "right")
@@ -370,9 +612,33 @@ export function routeEdge(
370
612
  ),
371
613
  };
372
614
  }
373
- if (!attachment && name === undefined && !["bus", "fan", "bundle"].includes(style) && !loop) {
615
+ const duplicateTargets =
616
+ !source && grouped
617
+ ? peers.filter((id) => {
618
+ const peer = state.edges.get(id)!;
619
+ const options = config.edges[id];
620
+ return (
621
+ peer.targetId === edge.targetId &&
622
+ peer.targetPort === undefined &&
623
+ options?.bezier?.target === undefined &&
624
+ options?.targetAttachment === undefined
625
+ );
626
+ })
627
+ : [];
628
+ if (
629
+ !attachment &&
630
+ name === undefined &&
631
+ !(source && parentLabel) &&
632
+ (!["bus", "fan", "bundle"].includes(style) ||
633
+ (duplicateTargets.length > 1 && duplicateTargets.includes(edge.id))) &&
634
+ !loop
635
+ ) {
374
636
  const nodeId = source ? edge.sourceId : edge.targetId;
375
- const adjacent = [...(state.incident.get(nodeId)?.keys() ?? [])].filter((id) => {
637
+ const adjacent = (
638
+ duplicateTargets.length > 1
639
+ ? duplicateTargets
640
+ : [...(state.incident.get(nodeId)?.keys() ?? [])]
641
+ ).filter((id) => {
376
642
  const e = state.edges.get(id)!;
377
643
  if (e.sourceId === e.targetId) return false;
378
644
  const from = e.sourceId === nodeId;
@@ -381,7 +647,12 @@ export function routeEdge(
381
647
  const rect = otherNode && worldRect(otherNode, state);
382
648
  if (!rect) return false;
383
649
  const explicit = from ? config.edges[id]?.sourceSide : config.edges[id]?.targetSide;
384
- return (explicit ?? sideToward(center(bounds), center(rect))) === side;
650
+ const natural = sideToward(center(bounds), center(rect));
651
+ const allowed = from ? config.edges[id]?.sourceSides : config.edges[id]?.targetSides;
652
+ const selected =
653
+ explicit ??
654
+ (policy && allowed?.length && !allowed.includes(natural) ? allowed[0]! : natural);
655
+ return id === edge.id || selected === side;
385
656
  });
386
657
  const horizontal = side === "left" || side === "right";
387
658
  const projected = (id: string) => {
@@ -434,7 +705,7 @@ export function routeEdge(
434
705
  });
435
706
  };
436
707
  if (blocked(point, side)) {
437
- const toward = center(labelRect(edge, state) ?? other);
708
+ const toward = center(attractingLabel ?? other);
438
709
  for (const alternative of ["left", "right", "top", "bottom"] as const) {
439
710
  const p = { ...anchor(bounds, alternative) };
440
711
  if (alternative === "left" || alternative === "right")
@@ -448,47 +719,118 @@ export function routeEdge(
448
719
  }
449
720
  }
450
721
  }
722
+ if (source && !parentLabel && perEdge.bezier?.source?.kind === "port") {
723
+ point = perEdge.bezier.source.escape;
724
+ side = perEdge.bezier.source.direction;
725
+ }
726
+ if (policy) {
727
+ const allowed = source ? perEdge.sourceSides : perEdge.targetSides;
728
+ if (allowed && !allowed.includes(side)) {
729
+ if (
730
+ name !== undefined ||
731
+ (source ? perEdge.sourceSide : perEdge.targetSide) ||
732
+ !allowed.length
733
+ )
734
+ report(
735
+ "CONSTRAINT_VIOLATION",
736
+ "Endpoint port conflicts with allowed sides",
737
+ source ? "source-tangent" : "target-tangent",
738
+ );
739
+ else {
740
+ side = allowed[0]!;
741
+ point = anchor(bounds, side);
742
+ }
743
+ }
744
+ }
451
745
  return {
452
746
  point,
453
747
  side:
454
748
  attachment?.facing === "inward"
455
749
  ? ({ left: "right", right: "left", top: "bottom", bottom: "top" } as const)[side]
456
750
  : side,
457
- ref: {
458
- kind: "node",
459
- nodeId: source ? edge.sourceId : edge.targetId,
460
- ...(name === undefined ? {} : { port: name }),
461
- },
751
+ ref:
752
+ source && parentLabel
753
+ ? { kind: "label", edgeId: parentLabel.id }
754
+ : {
755
+ kind: "node",
756
+ nodeId: source ? edge.sourceId : edge.targetId,
757
+ ...(name === undefined ? {} : { port: name }),
758
+ },
462
759
  };
463
760
  }
464
- const source = terminal(true),
465
- target = terminal(false),
466
- label = labelRect(edge, state);
761
+ const previousSide = (source: boolean) => {
762
+ if (!automaticFaces || policy?.stability?.kind !== "preserve-valid") return undefined;
763
+ const side = source
764
+ ? batch.previous?.sections[0]?.fromSide
765
+ : batch.previous?.sections.at(-1)?.toSide;
766
+ const allowed = source ? perEdge.sourceSides : perEdge.targetSides;
767
+ return side && (!allowed || allowed.includes(side)) ? side : undefined;
768
+ };
769
+ const source = terminal(true, previousSide(true)),
770
+ target = terminal(false, previousSide(false)),
771
+ positionedLabel = labelRect(edge, state),
772
+ label =
773
+ policy?.label?.kind === "follow" || policy?.label?.kind === "fixed"
774
+ ? undefined
775
+ : positionedLabel;
467
776
  const excluded = new Set(
468
777
  [...ancestors(edge.sourceId, state), ...ancestors(edge.targetId, state)]
469
778
  .filter((id) => id !== edge.sourceId && id !== edge.targetId)
470
779
  .map((id) => `n:${id}`),
471
780
  );
781
+ if (policy?.label?.kind === "through") excluded.add(`e:${edge.id}`);
472
782
  if (perEdge.sourceAttachment !== undefined) excluded.add(`n:${edge.sourceId}`);
473
783
  if (perEdge.targetAttachment !== undefined) excluded.add(`n:${edge.targetId}`);
474
784
  const context = contextFor(
475
785
  state,
476
786
  excluded,
477
- new Set([`n:${edge.sourceId}`, `n:${edge.targetId}`, `e:${edge.id}`]),
787
+ new Set([
788
+ `n:${edge.sourceId}`,
789
+ `n:${edge.targetId}`,
790
+ `e:${edge.id}`,
791
+ ...(parentLabel ? [`e:${parentLabel.id}`] : []),
792
+ ]),
478
793
  metrics,
479
794
  batch,
480
795
  lane * config.edgeSpacing,
481
796
  );
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;
797
+ const searchContext =
798
+ policy?.curve?.kind === "bezier" ||
799
+ policy?.curve?.kind === "catmull-rom" ||
800
+ (!policy?.curve && ["curved", "organic", "bundle", "bezier"].includes(style))
801
+ ? contextFor(
802
+ state,
803
+ excluded,
804
+ new Set([
805
+ `n:${edge.sourceId}`,
806
+ `n:${edge.targetId}`,
807
+ `e:${edge.id}`,
808
+ ...(parentLabel ? [`e:${parentLabel.id}`] : []),
809
+ ]),
810
+ metrics,
811
+ batch,
812
+ config.radius * (style === "organic" ? 1 : 2) + lane * config.edgeSpacing,
813
+ )
814
+ : context;
815
+ let monotonicSource = source,
816
+ monotonicTarget = target;
817
+ const monotonic = policy?.monotonic;
818
+ const allowsStep =
819
+ monotonic?.strength === "require"
820
+ ? (a: RoutePoint, b: RoutePoint) => {
821
+ const axes: readonly ("x" | "y")[] =
822
+ monotonic.axis === "both" ? ["x", "y"] : [monotonic.axis];
823
+ return axes.every((axis) => {
824
+ const direction = Math.sign(
825
+ (portEscapes.get(monotonicTarget)?.point ?? monotonicTarget.point)[axis] -
826
+ (portEscapes.get(monotonicSource)?.point ?? monotonicSource.point)[axis],
827
+ );
828
+ const change = b[axis] - a[axis];
829
+ return direction ? direction * change >= -1e-8 : Math.abs(change) <= 1e-8;
830
+ });
831
+ }
832
+ : undefined;
833
+ context.allowsStep = searchContext.allowsStep = allowsStep;
492
834
  let futureTargetCorridor: RouteBounds | undefined;
493
835
  const protectFutureTarget = (ctx: SearchContext) => {
494
836
  const obstacles = ctx.obstacles;
@@ -523,8 +865,12 @@ export function routeEdge(
523
865
  ...drawn.map((s) => reservationBounds(s, config.edgeSpacing)),
524
866
  ];
525
867
  }
868
+ const labelSide = (toward: RoutePoint) => {
869
+ const preferred = sideToward(center(label!), toward);
870
+ return perEdge.labelSides?.includes(preferred) === false ? perEdge.labelSides[0]! : preferred;
871
+ };
526
872
  const labelAnchor = (toward: RoutePoint) => {
527
- const side = sideToward(center(label!), toward),
873
+ const side = labelSide(toward),
528
874
  point = anchor(label!, side);
529
875
  // Border attachments may slide within the label; avoid tiny center-offset jogs.
530
876
  if (
@@ -544,42 +890,599 @@ export function routeEdge(
544
890
  const firstTarget: Terminal = label
545
891
  ? {
546
892
  point: labelAnchor(source.point),
547
- side: sideToward(center(label), source.point),
893
+ side: labelSide(source.point),
548
894
  ref: { kind: "label", edgeId: edge.id },
549
895
  }
550
896
  : 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
- ],
897
+ const outgoingSide = label && labelSide(target.point);
898
+ const exitSide =
899
+ label &&
900
+ (outgoingSide === firstTarget.side
901
+ ? perEdge.labelSides
902
+ ? (perEdge.labelSides.find((side) => side !== firstTarget.side) ?? firstTarget.side)
903
+ : ({ left: "right", right: "left", top: "bottom", bottom: "top" } as const)[
904
+ firstTarget.side
905
+ ]
906
+ : outgoingSide);
907
+ if (label && exitSide === firstTarget.side) {
908
+ const horizontal = exitSide === "top" || exitSide === "bottom";
909
+ const fraction = (
910
+ horizontal ? source.point.x > target.point.x : source.point.y > target.point.y
911
+ )
912
+ ? 2 / 3
913
+ : 1 / 3;
914
+ firstTarget.point = {
915
+ ...anchor(label, exitSide),
916
+ ...(horizontal
917
+ ? { x: label.x + label.width * fraction }
918
+ : { y: label.y + label.height * fraction }),
919
+ };
920
+ }
921
+ const lastSource: Terminal | undefined =
922
+ label && exitSide
923
+ ? {
924
+ point:
925
+ exitSide === firstTarget.side
926
+ ? {
927
+ ...anchor(label, exitSide),
928
+ ...(exitSide === "top" || exitSide === "bottom"
929
+ ? { x: label.x + label.width - (firstTarget.point.x - label.x) }
930
+ : { y: label.y + label.height - (firstTarget.point.y - label.y) }),
931
+ }
932
+ : exitSide === outgoingSide
933
+ ? labelAnchor(target.point)
934
+ : anchor(label, exitSide),
935
+ side: exitSide,
936
+ ref: { kind: "label", edgeId: edge.id },
937
+ }
938
+ : undefined;
939
+ const curvePolicy =
940
+ policy?.curve ??
941
+ (style === "bezier" || style === "curved" || style === "organic" || style === "bundle"
942
+ ? { kind: "bezier" as const }
943
+ : { kind: "polyline" as const });
944
+ const reverse = (p: RoutePoint): RoutePoint => ({ x: -p.x, y: -p.y });
945
+ const normalized = (p: RoutePoint): RoutePoint => {
946
+ const length = Math.hypot(p.x, p.y);
947
+ return length ? { x: p.x / length, y: p.y / length } : { x: 1, y: 0 };
948
+ };
949
+ const directionMatches = (actual: RoutePoint, requested: RoutePoint) =>
950
+ actual.x * requested.x + actual.y * requested.y > 1 - 1e-6;
951
+ let labelDirection: RoutePoint | undefined;
952
+ const portEscapes = new Map<Terminal, { point: RoutePoint; context: SearchContext }>();
953
+ if (policy) {
954
+ for (const [terminal, requested, sourceEnd] of [
955
+ [source, policy.sourceTangent, true],
956
+ [target, policy.targetTangent, false],
957
+ ] as const) {
958
+ const normal = sourceEnd ? vector(terminal.side) : reverse(vector(terminal.side));
959
+ terminal.tangent =
960
+ requested?.kind === "vector"
961
+ ? requested.direction
962
+ : requested?.kind === "border-normal" ||
963
+ (terminal.ref.kind === "node" && terminal.ref.port !== undefined)
964
+ ? normal
965
+ : undefined;
966
+ if (terminal.tangent && terminal.tangent.x * normal.x + terminal.tangent.y * normal.y <= 1e-8)
967
+ report(
968
+ "CONSTRAINT_VIOLATION",
969
+ "Requested tangent conflicts with the selected attachment face",
970
+ sourceEnd ? "source-tangent" : "target-tangent",
971
+ );
972
+ if (
973
+ curvePolicy.kind === "rounded-orthogonal" &&
974
+ terminal.tangent &&
975
+ Math.abs(terminal.tangent.x * terminal.tangent.y) > 1e-8
976
+ )
977
+ report(
978
+ "CONSTRAINT_VIOLATION",
979
+ "Orthogonal paths require axis-aligned terminal tangents",
980
+ sourceEnd ? "source-tangent" : "target-tangent",
981
+ );
982
+ }
983
+ for (const terminal of [source, target]) {
984
+ if (terminal.ref.kind !== "node" || terminal.ref.port === undefined) continue;
985
+ const owner = terminal === source ? sourceBounds : targetBounds;
986
+ const point = terminal.point;
987
+ if (
988
+ point.x <= owner.x ||
989
+ point.x >= owner.x + owner.width ||
990
+ point.y <= owner.y ||
991
+ point.y >= owner.y + owner.height
992
+ )
993
+ continue;
994
+ const outward = terminal === source ? terminal.tangent! : reverse(terminal.tangent!);
995
+ const length = Math.min(
996
+ outward.x > 0
997
+ ? (owner.x + owner.width - point.x) / outward.x
998
+ : outward.x < 0
999
+ ? (owner.x - point.x) / outward.x
1000
+ : Infinity,
1001
+ outward.y > 0
1002
+ ? (owner.y + owner.height - point.y) / outward.y
1003
+ : outward.y < 0
1004
+ ? (owner.y - point.y) / outward.y
1005
+ : Infinity,
1006
+ );
1007
+ portEscapes.set(terminal, {
1008
+ point,
1009
+ context: protectFutureTarget(
1010
+ contextFor(
1011
+ state,
1012
+ new Set([...excluded, `n:${terminal.ref.nodeId}`]),
1013
+ new Set([
1014
+ `n:${edge.sourceId}`,
1015
+ `n:${edge.targetId}`,
1016
+ `e:${edge.id}`,
1017
+ ...(parentLabel ? [`e:${parentLabel.id}`] : []),
1018
+ ]),
1019
+ metrics,
1020
+ batch,
1021
+ lane * config.edgeSpacing,
1022
+ ),
1023
+ ),
1024
+ });
1025
+ terminal.point = { x: point.x + outward.x * length, y: point.y + outward.y * length };
1026
+ }
1027
+ if (policy.label && policy.label.kind !== "follow" && !positionedLabel)
1028
+ report("MISSING_GEOMETRY", "This label policy requires positioned label geometry");
1029
+ if (label && lastSource && (policy.label?.kind === "through" || policy.label?.kind === "gap")) {
1030
+ const tangent = policy.label.tangent;
1031
+ labelDirection =
1032
+ tangent?.kind === "vector"
1033
+ ? tangent.direction
1034
+ : tangent?.kind === "border-normal"
1035
+ ? reverse(vector(firstTarget.side))
1036
+ : normalized({
1037
+ x: target.point.x - source.point.x,
1038
+ y: target.point.y - source.point.y,
1039
+ });
1040
+ if (curvePolicy.kind === "rounded-orthogonal" && tangent?.kind !== "vector")
1041
+ labelDirection = reverse(vector(firstTarget.side));
1042
+ const direction = labelDirection;
1043
+ if (policy.label.kind === "gap" && tangent?.kind === "border-normal") {
1044
+ firstTarget.tangent = reverse(vector(firstTarget.side));
1045
+ lastSource.tangent = vector(lastSource.side);
1046
+ } else {
1047
+ const middle = center(label);
1048
+ const extent = Math.min(
1049
+ direction.x ? label.width / (2 * Math.abs(direction.x)) : Infinity,
1050
+ direction.y ? label.height / (2 * Math.abs(direction.y)) : Infinity,
1051
+ );
1052
+ const enter = { x: middle.x - direction.x * extent, y: middle.y - direction.y * extent };
1053
+ const leave = { x: middle.x + direction.x * extent, y: middle.y + direction.y * extent };
1054
+ const horizontalFace =
1055
+ Math.abs(direction.x) / label.width >= Math.abs(direction.y) / label.height;
1056
+ firstTarget.side = horizontalFace
1057
+ ? direction.x > 0
1058
+ ? "left"
1059
+ : "right"
1060
+ : direction.y > 0
1061
+ ? "top"
1062
+ : "bottom";
1063
+ lastSource.side = horizontalFace
1064
+ ? direction.x > 0
1065
+ ? "right"
1066
+ : "left"
1067
+ : direction.y > 0
1068
+ ? "bottom"
1069
+ : "top";
1070
+ firstTarget.point = policy.label.kind === "through" ? middle : enter;
1071
+ lastSource.point = policy.label.kind === "through" ? middle : leave;
1072
+ firstTarget.tangent = lastSource.tangent = direction;
1073
+ }
1074
+ if (
1075
+ perEdge.labelSides &&
1076
+ (!perEdge.labelSides.includes(firstTarget.side) ||
1077
+ !perEdge.labelSides.includes(lastSource.side))
1078
+ )
1079
+ report(
1080
+ "CONSTRAINT_VIOLATION",
1081
+ "Label tangent conflicts with allowed entry and exit faces",
1082
+ "label-tangent",
1083
+ );
1084
+ if (curvePolicy.kind === "rounded-orthogonal" && Math.abs(direction.x * direction.y) > 1e-8)
1085
+ report(
1086
+ "CONSTRAINT_VIOLATION",
1087
+ "Orthogonal paths require axis-aligned label tangents",
1088
+ "label-tangent",
1089
+ );
1090
+ }
1091
+ }
1092
+ const portSegment = (from: RoutePoint, to: RoutePoint): RouteSegment =>
1093
+ curvePolicy.kind === "bezier" || curvePolicy.kind === "catmull-rom"
1094
+ ? {
1095
+ kind: "cubic",
1096
+ control1: { x: from.x + (to.x - from.x) / 3, y: from.y + (to.y - from.y) / 3 },
1097
+ control2: {
1098
+ x: from.x + ((to.x - from.x) * 2) / 3,
1099
+ y: from.y + ((to.y - from.y) * 2) / 3,
1100
+ },
1101
+ to,
1102
+ }
1103
+ : { kind: "line", to };
1104
+ function withPortEscapes(path: RoutePath, a: Terminal, b: Terminal): RoutePath {
1105
+ const first = portEscapes.get(a),
1106
+ last = portEscapes.get(b);
1107
+ return first || last
1108
+ ? {
1109
+ start: first?.point ?? path.start,
1110
+ segments: [
1111
+ ...(first ? [portSegment(first.point, a.point)] : []),
1112
+ ...path.segments,
1113
+ ...(last ? [portSegment(b.point, last.point)] : []),
1114
+ ],
1115
+ }
1116
+ : path;
1117
+ }
1118
+ function policyPathSafe(path: RoutePath): boolean {
1119
+ if (!portEscapes.size) return safe(path, context);
1120
+ let from = path.start;
1121
+ return path.segments.every((segment, index) => {
1122
+ let checkedContext = context;
1123
+ for (const [terminal, escape] of portEscapes) {
1124
+ const sourceEnd = terminal === source;
1125
+ const expected = sourceEnd
1126
+ ? portSegment(escape.point, terminal.point)
1127
+ : portSegment(terminal.point, escape.point);
1128
+ if (
1129
+ (sourceEnd
1130
+ ? index === 0 && distance(from, escape.point) < 1e-7
1131
+ : index === path.segments.length - 1 && distance(from, terminal.point) < 1e-7) &&
1132
+ JSON.stringify(segment) === JSON.stringify(expected)
1133
+ )
1134
+ checkedContext = escape.context;
1135
+ }
1136
+ const checked = { start: from, segments: [segment] };
1137
+ from = segment.to;
1138
+ return safe(checked, checkedContext);
1139
+ });
1140
+ }
1141
+ function terminalClearanceCost(path: RoutePath, a = source, b = target): number {
1142
+ if (perEdge.waypoints?.length || loop) return 0;
1143
+ let cost = 0;
1144
+ for (const [terminal, bounds] of [
1145
+ [a, sourceBounds],
1146
+ [b, targetBounds],
1147
+ ] as const) {
1148
+ if (terminal.ref.kind !== "node") continue;
1149
+ const halo = inflate(bounds, config.clearance);
1150
+ const attachment = inflate({ ...terminal.point, width: 0, height: 0 }, config.clearance * 2);
1151
+ for (const segment of pathReservations(path).flatMap((segment) =>
1152
+ outsideTerminal(segment, attachment),
1153
+ )) {
1154
+ const outside = outsideTerminal(segment, halo).reduce(
1155
+ (length, part) => length + distance(part.a, part.b),
1156
+ 0,
1157
+ );
1158
+ cost += Math.max(0, distance(segment.a, segment.b) - outside) * 4;
1159
+ }
1160
+ }
1161
+ return cost;
1162
+ }
1163
+ const authoredCrossing =
1164
+ loop ||
1165
+ pathHasSelfIntersection(
1166
+ pathFromPoints([source.point, ...(perEdge.waypoints ?? []), target.point]),
1167
+ );
1168
+ function assessPath(
1169
+ path: RoutePath,
1170
+ a?: Terminal,
1171
+ b?: Terminal,
1172
+ ): { valid: boolean; cost: number } {
1173
+ const reverseTravel = policy?.monotonic
1174
+ ? pathReverseTravel(
1175
+ path,
1176
+ policy.monotonic.axis,
1177
+ portEscapes.get(monotonicSource)?.point ?? monotonicSource.point,
1178
+ portEscapes.get(monotonicTarget)?.point ?? monotonicTarget.point,
1179
+ )
1180
+ : 0;
1181
+ const valid =
1182
+ policyPathSafe(path) &&
1183
+ (authoredCrossing || !pathHasSelfIntersection(path)) &&
1184
+ (!a?.tangent || directionMatches(pathEndpointTangent(path, "start"), a.tangent)) &&
1185
+ (!b?.tangent || directionMatches(pathEndpointTangent(path, "end"), b.tangent)) &&
1186
+ !(policy?.monotonic?.strength === "require" && reverseTravel > 1e-7);
1187
+ return {
1188
+ valid,
1189
+ cost:
1190
+ pathCost(path, context) +
1191
+ terminalClearanceCost(path, a, b) +
1192
+ (curvePolicy.kind === "bezier" && path.segments.length > 1
1193
+ ? path.segments.filter((segment, index) => {
1194
+ if (segment.kind !== "cubic") return false;
1195
+ const start = index ? path.segments[index - 1]!.to : path.start;
1196
+ const dx = segment.to.x - start.x,
1197
+ dy = segment.to.y - start.y;
1198
+ return [segment.control1, segment.control2].every(
1199
+ (point) => Math.abs(dx * (point.y - start.y) - dy * (point.x - start.x)) < 1e-7,
1200
+ );
1201
+ }).length * context.bendPenalty
1202
+ : 0) +
1203
+ (curvePolicy.kind === "catmull-rom"
1204
+ ? Math.max(0, path.segments.length - (perEdge.waypoints?.length ?? 0) - 1) *
1205
+ context.bendPenalty
1206
+ : 0) +
1207
+ (policy?.monotonic?.strength === "prefer" ? reverseTravel * 80 : 0),
1208
+ };
1209
+ }
1210
+ let shapeFallback = false;
1211
+ function compactBezierGuide(a: Terminal, b: Terminal, via: readonly RoutePoint[], padding = 0) {
1212
+ const remaining =
1213
+ config.maxSearchNodes -
1214
+ searchContext.visited -
1215
+ (searchContext === context ? 0 : context.visited);
1216
+ if (remaining <= 0) return undefined;
1217
+ const compact = {
1218
+ ...context,
1219
+ obstacles: (bounds: RouteBounds) =>
1220
+ context.obstacles(inflate(bounds, padding)).map((rect) => {
1221
+ const contains = (point: RoutePoint) =>
1222
+ point.x >= rect.x &&
1223
+ point.x <= rect.x + rect.width &&
1224
+ point.y >= rect.y &&
1225
+ point.y <= rect.y + rect.height;
1226
+ return inflate(
1227
+ rect,
1228
+ contains(a.point) || contains(b.point)
1229
+ ? padding
1230
+ ? config.clearance + padding
1231
+ : 0
1232
+ : padding,
1233
+ );
1234
+ }),
1235
+ visited: 0,
1236
+ budgetExceeded: false,
1237
+ maxSearchNodes: remaining,
1238
+ };
1239
+ const lead = (terminal: Terminal, end: boolean) => {
1240
+ if (!padding) return terminal.point;
1241
+ const direction = terminal.tangent
1242
+ ? end
1243
+ ? reverse(terminal.tangent)
1244
+ : terminal.tangent
1245
+ : vector(terminal.side);
1246
+ const length = Math.max(config.clearance + padding + 1, config.radius * 2);
1247
+ return {
1248
+ x: terminal.point.x + direction.x * length,
1249
+ y: terminal.point.y + direction.y * length,
1250
+ };
1251
+ };
1252
+ const anchors = [lead(a, false), ...via, lead(b, true)];
1253
+ const points = [a.point];
1254
+ let complete = true;
1255
+ for (let i = 1; i < anchors.length; i++) {
1256
+ const found = findPath(anchors[i - 1]!, anchors[i]!, "polyline", compact);
1257
+ if (!found) {
1258
+ complete = false;
1259
+ break;
1260
+ }
1261
+ points.push(...found.slice(i === 1 && padding ? 0 : 1));
1262
+ }
1263
+ if (padding) points.push(b.point);
1264
+ searchContext.visited += compact.visited;
1265
+ return complete ? points : undefined;
1266
+ }
1267
+ function selectPolicyPath(
1268
+ a: Terminal,
1269
+ b: Terminal,
1270
+ via: readonly RoutePoint[],
1271
+ guide: RoutePath,
1272
+ ): RoutePath {
1273
+ if (loop && !via.length && curvePolicy.kind === "bezier" && distance(a.point, b.point) < 1e-8) {
1274
+ const normal = vector(a.side),
1275
+ lateral = { x: -normal.y, y: normal.x };
1276
+ const extent = config.clearance + config.edgeSpacing + 10;
1277
+ const makeLoop = (reach: number, width: number): RoutePath => {
1278
+ const at = (outward: number, across: number) => ({
1279
+ x: a.point.x + normal.x * outward + lateral.x * across,
1280
+ y: a.point.y + normal.y * outward + lateral.y * across,
1281
+ });
1282
+ return {
1283
+ start: a.point,
1284
+ segments: [
1285
+ {
1286
+ kind: "cubic",
1287
+ control1: at(reach / 2, 0),
1288
+ control2: at(reach, -width),
1289
+ to: at(reach, 0),
1290
+ },
1291
+ { kind: "cubic", control1: at(reach, width), control2: at(reach / 2, 0), to: b.point },
1292
+ ],
1293
+ };
1294
+ };
1295
+ const loops: RoutePath[] = [];
1296
+ for (const reach of [extent, extent * 2, extent * 3])
1297
+ for (const width of [extent, extent * 2]) loops.push(makeLoop(reach, width));
1298
+ const viable = loops
1299
+ .map((path) => ({ path, ...assessPath(path, a, b) }))
1300
+ .filter((candidate) => candidate.valid);
1301
+ viable.sort((a, b) => a.cost - b.cost);
1302
+ if (viable.length) return viable[0]!.path;
1303
+ for (const width of [extent * 2, extent * 3]) {
1304
+ let low = 0,
1305
+ high = extent;
1306
+ for (let step = 0; step < 12; step++) {
1307
+ const reach = (low + high) / 2;
1308
+ if (assessPath(makeLoop(reach, width), a, b).valid) low = reach;
1309
+ else high = reach;
1310
+ }
1311
+ if (low > 1) return makeLoop(low, width);
1312
+ }
1313
+ }
1314
+ const guidePoints = [guide.start, ...guide.segments.map((part) => part.to)];
1315
+ if (curvePolicy.kind === "bezier" && !via.length) {
1316
+ const tangents = new Map<number, RoutePoint>();
1317
+ if (a.tangent) tangents.set(0, a.tangent);
1318
+ if (b.tangent) tangents.set(1, b.tangent);
1319
+ const direct = bezierThroughPoints(
1320
+ [a.point, b.point],
1321
+ tangents,
1322
+ curvePolicy.curvature ?? 0.5,
1323
+ );
1324
+ if (
1325
+ assessPath(direct, a, b).valid &&
1326
+ pathReverseTravel(direct, "both", a.point, b.point) < 1e-7 &&
1327
+ pathCost(direct, context) - pathLength(direct) < 1e-7 &&
1328
+ terminalClearanceCost(direct, a, b) < 1e-7
1329
+ )
1330
+ return direct;
1331
+ }
1332
+ const candidates: RoutePath[] = [];
1333
+ const unshapedCandidates = new Set<RoutePath>();
1334
+ const compact =
1335
+ curvePolicy.kind === "bezier" || curvePolicy.kind === "catmull-rom"
1336
+ ? compactBezierGuide(a, b, via)
1337
+ : undefined;
1338
+ const smoothCompact =
1339
+ curvePolicy.kind === "bezier" && !via.length && terminalClearanceCost(guide, a, b) > 1e-7
1340
+ ? compactBezierGuide(a, b, via, config.radius / 2)
1341
+ : undefined;
1342
+ const pointCandidates = [
1343
+ [a.point, ...via, b.point],
1344
+ guidePoints,
1345
+ ...(compact ? [compact] : []),
1346
+ ...(smoothCompact ? [smoothCompact] : []),
1347
+ ...(smoothCompact && smoothCompact.length > 4
1348
+ ? [[a.point, ...smoothCompact.slice(2, -2), b.point]]
1349
+ : []),
1350
+ ].filter((points) => {
1351
+ if (!points.some((point) => distance(point, points[0]!) > 1e-8)) return false;
1352
+ let index = 0;
1353
+ return via.every((point) => {
1354
+ while (index < points.length && distance(points[index]!, point) > 1e-7) index++;
1355
+ return index < points.length;
1356
+ });
1357
+ });
1358
+ for (const points of pointCandidates) {
1359
+ const tangents = new Map<number, RoutePoint>();
1360
+ if (a.tangent) tangents.set(0, a.tangent);
1361
+ if (b.tangent) tangents.set(points.length - 1, b.tangent);
1362
+ if (curvePolicy.kind === "polyline") candidates.push(pathFromPoints(points));
1363
+ else if (curvePolicy.kind === "catmull-rom")
1364
+ candidates.push(catmullRomPath(points, tangents));
1365
+ else if (curvePolicy.kind === "bezier") {
1366
+ const curvature = curvePolicy.curvature ?? 0.5;
1367
+ const requested = bezierThroughPoints(points, tangents, curvature);
1368
+ candidates.push(requested);
1369
+ const returnNormal = vector(b.side);
1370
+ const lastKnot = points.at(-2)!;
1371
+ const returnFit =
1372
+ !via.length &&
1373
+ points.length > 2 &&
1374
+ b.tangent &&
1375
+ directionMatches(b.tangent, reverse(returnNormal)) &&
1376
+ (lastKnot.x - b.point.x) * returnNormal.x + (lastKnot.y - b.point.y) * returnNormal.y >
1377
+ 1e-7
1378
+ ? bezierThroughPoints(
1379
+ points,
1380
+ new Map([...tangents, [points.length - 2, returnNormal]]),
1381
+ curvature,
560
1382
  )
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 },
1383
+ : undefined;
1384
+ if (returnFit) candidates.push(returnFit);
1385
+ if (
1386
+ !assessPath(requested, a, b).valid ||
1387
+ (!via.length && points.length > 2 && pathCurveOvershoot(requested) > 0.1)
1388
+ ) {
1389
+ const alternatives: RoutePath[] = returnFit ? [returnFit] : [];
1390
+ if (!via.length && points.length > 2) {
1391
+ for (const ends of [
1392
+ [true, false],
1393
+ [false, true],
1394
+ [true, true],
1395
+ ]) {
1396
+ const directions = new Map(tangents);
1397
+ if (ends[0] && !a.tangent) directions.set(0, vector(a.side));
1398
+ if (ends[1] && !b.tangent) directions.set(points.length - 1, reverse(vector(b.side)));
1399
+ const limited = limitBezierHandles(
1400
+ bezierThroughPoints(points, directions, curvature),
1401
+ );
1402
+ if (limited) alternatives.push(limited);
1403
+ }
1404
+ }
1405
+ if (
1406
+ !assessPath(requested, a, b).valid &&
1407
+ !alternatives.some((path) => assessPath(path, a, b).valid)
1408
+ ) {
1409
+ for (const scale of [0.5, 0.25, 0.125, 0.0625]) {
1410
+ const alternative = bezierThroughPoints(points, tangents, curvature * scale);
1411
+ alternatives.push(alternative);
1412
+ if (assessPath(alternative, a, b).valid && pathCurveOvershoot(alternative) < 0.1)
1413
+ break;
1414
+ }
1415
+ }
1416
+ if (!via.length && points.length > 2)
1417
+ for (const scale of [1, 0.5, 0.25])
1418
+ alternatives.push(roundedBezierPath(points, config.radius * scale));
1419
+ candidates.push(...alternatives);
1420
+ if (
1421
+ !assessPath(requested, a, b).valid &&
1422
+ !alternatives.some((path) => assessPath(path, a, b).valid)
1423
+ ) {
1424
+ const unshaped = pathFromPoints(points);
1425
+ candidates.push(unshaped);
1426
+ unshapedCandidates.add(unshaped);
1427
+ }
1428
+ }
1429
+ } else if (
1430
+ points
1431
+ .slice(1)
1432
+ .every((point, index) => point.x === points[index]!.x || point.y === points[index]!.y)
1433
+ ) {
1434
+ for (const scale of [1, 0.5, 0.25]) {
1435
+ const segments: RoutePath["segments"][number][] = [];
1436
+ let start = 0;
1437
+ for (let end = 1; end < points.length; end++) {
1438
+ if (
1439
+ end !== points.length - 1 &&
1440
+ !via.some((point) => distance(point, points[end]!) < 1e-7)
1441
+ )
1442
+ continue;
1443
+ segments.push(
1444
+ ...roundCorners(pathFromPoints(points.slice(start, end + 1)), {
1445
+ radius: (curvePolicy.radius ?? config.radius) * scale,
1446
+ }).segments,
1447
+ );
1448
+ start = end;
1449
+ }
1450
+ candidates.push({ start: points[0]!, segments });
1451
+ }
569
1452
  }
570
- : undefined;
1453
+ }
1454
+ const viable = candidates
1455
+ .map((path, index) => ({ path, index, ...assessPath(path, a, b) }))
1456
+ .filter((candidate) => candidate.valid && Number.isFinite(candidate.cost))
1457
+ .sort(
1458
+ (a, b) =>
1459
+ Number(unshapedCandidates.has(a.path)) - Number(unshapedCandidates.has(b.path)) ||
1460
+ a.cost - b.cost ||
1461
+ a.index - b.index,
1462
+ );
1463
+ if (viable.length) {
1464
+ if (unshapedCandidates.has(viable[0]!.path)) shapeFallback = true;
1465
+ return viable[0]!.path;
1466
+ }
1467
+ shapeFallback = true;
1468
+ return guide;
1469
+ }
571
1470
  metrics.routedEdges++;
572
1471
  const sections: RouteSection[] = [];
573
- const stub = (t: Terminal) => {
1472
+ const stub = (t: Terminal, end: boolean) => {
574
1473
  if (t.ref.kind === "junction") return t.point;
575
- const v = vector(t.side),
576
- length =
1474
+ const v = t.tangent ? (end ? reverse(t.tangent) : t.tangent) : vector(t.side),
1475
+ length = Math.max(
577
1476
  config.clearance +
578
- 1 +
579
- (!grouped && peers.length > 1 ? peers.indexOf(edge.id) : 0) * config.edgeSpacing;
1477
+ 1 +
1478
+ (!grouped && peers.length > 1 ? peers.indexOf(edge.id) : 0) * config.edgeSpacing,
1479
+ policy && curvePolicy.kind === "rounded-orthogonal"
1480
+ ? (curvePolicy.radius ?? config.radius) * 2
1481
+ : 0,
1482
+ );
580
1483
  return { x: t.point.x + v.x * length, y: t.point.y + v.y * length };
581
1484
  };
582
- function connection(a: Terminal, b: Terminal, via: readonly RoutePoint[] = []): RoutePath {
1485
+ function guideConnection(a: Terminal, b: Terminal, via: readonly RoutePoint[] = []): RoutePath {
583
1486
  let path: RoutePath | undefined, preferred: RoutePath | undefined;
584
1487
  if (distance(a.point, b.point) < 1e-8 && !via.length) {
585
1488
  const v = vector(a.side),
@@ -616,10 +1519,15 @@ export function routeEdge(
616
1519
  const hard = contextFor(
617
1520
  state,
618
1521
  excluded,
619
- new Set([`n:${edge.sourceId}`, `n:${edge.targetId}`, `e:${edge.id}`]),
1522
+ new Set([
1523
+ `n:${edge.sourceId}`,
1524
+ `n:${edge.targetId}`,
1525
+ `e:${edge.id}`,
1526
+ ...(parentLabel ? [`e:${parentLabel.id}`] : []),
1527
+ ]),
620
1528
  metrics,
621
1529
  batch,
622
- -config.clearance,
1530
+ policy ? 0 : -config.clearance,
623
1531
  );
624
1532
  for (const extent of [config.clearance + 1, gap, gap * 2])
625
1533
  for (const sign of [1, -1]) {
@@ -671,8 +1579,9 @@ export function routeEdge(
671
1579
  ? organic(points, state, context)
672
1580
  : loopPath;
673
1581
  }
674
- if (style === "straight" && !via.length && !loop) path = pathFromPoints([a.point, b.point]);
675
- else if (style === "bezier" && !via.length && !loop) {
1582
+ if (!policy && style === "straight" && !via.length && !loop)
1583
+ path = pathFromPoints([a.point, b.point]);
1584
+ else if (!policy && style === "bezier" && !via.length && !loop) {
676
1585
  const direct = cubic(a, b);
677
1586
  if (safe(direct, context)) {
678
1587
  if (pathCost(direct, context) === pathLength(direct)) return direct;
@@ -685,15 +1594,15 @@ export function routeEdge(
685
1594
  (b.point.x - a.point.x) * vector(a.side).x + (b.point.y - a.point.y) * vector(a.side).y >
686
1595
  0 &&
687
1596
  (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),
1597
+ const boundedStub = (t: Terminal, end: boolean) => {
1598
+ const p = stub(t, end),
690
1599
  gap = distance(a.point, b.point) / 3;
691
1600
  if (!alignedFacing || distance(t.point, p) <= gap) return p;
692
1601
  const v = vector(t.side);
693
1602
  return { x: t.point.x + v.x * gap, y: t.point.y + v.y * gap };
694
1603
  };
695
- const start = boundedStub(a),
696
- end = boundedStub(b),
1604
+ const start = boundedStub(a, false),
1605
+ end = boundedStub(b, true),
697
1606
  waypoints = [...via];
698
1607
  if (style === "parallel" && peers.length > 1) {
699
1608
  // Find a feasible corridor before choosing lanes; an arbitrary midpoint
@@ -726,11 +1635,13 @@ export function routeEdge(
726
1635
  const points: RoutePoint[] = [a.point],
727
1636
  anchors = [start, ...waypoints, end];
728
1637
  const searchStyle =
729
- style === "polyline" || style === "organic" || style === "parallel"
730
- ? "polyline"
731
- : style === "octilinear"
732
- ? "octilinear"
733
- : "orthogonal";
1638
+ policy?.curve?.kind === "rounded-orthogonal"
1639
+ ? "orthogonal"
1640
+ : style === "polyline" || style === "organic" || style === "parallel"
1641
+ ? "polyline"
1642
+ : style === "octilinear"
1643
+ ? "octilinear"
1644
+ : "orthogonal";
734
1645
  let ok = clear(a.point, start, context) && clear(end, b.point, context);
735
1646
  for (let i = 1; ok && i < anchors.length; i++) {
736
1647
  const directions = {
@@ -771,12 +1682,12 @@ export function routeEdge(
771
1682
  }
772
1683
  points.push(b.point);
773
1684
  if (ok) {
774
- const simple = simplify(points);
1685
+ const simple = (policy || style === "organic") && via.length ? points : simplify(points);
775
1686
  path =
776
- style === "curved" || style === "bundle" || style === "bezier"
1687
+ !policy && (style === "curved" || style === "bundle" || style === "bezier")
777
1688
  ? curve(simple, state, context)
778
1689
  : style === "organic"
779
- ? organic(simple, state, context)
1690
+ ? organic(simple, state, context, via)
780
1691
  : pathFromPoints(simple);
781
1692
  }
782
1693
  }
@@ -790,15 +1701,20 @@ export function routeEdge(
790
1701
  // Clearance and soft edge reservations must not turn a feasible connection
791
1702
  // into an obstacle-crossing fallback. Retry against the hard geometry with
792
1703
  // short terminal leads and a fresh, bounded search budget.
793
- if (!["straight", "bezier", "polyline", "organic", "parallel"].includes(style)) {
1704
+ if (policy || !["straight", "bezier", "polyline", "organic", "parallel"].includes(style)) {
794
1705
  const hard = {
795
1706
  ...contextFor(
796
1707
  state,
797
1708
  excluded,
798
- new Set([`n:${edge.sourceId}`, `n:${edge.targetId}`, `e:${edge.id}`]),
1709
+ new Set([
1710
+ `n:${edge.sourceId}`,
1711
+ `n:${edge.targetId}`,
1712
+ `e:${edge.id}`,
1713
+ ...(parentLabel ? [`e:${parentLabel.id}`] : []),
1714
+ ]),
799
1715
  metrics,
800
1716
  batch,
801
- -config.clearance,
1717
+ policy ? 0 : -config.clearance,
802
1718
  ),
803
1719
  maxSearchNodes: Math.max(
804
1720
  0,
@@ -807,6 +1723,7 @@ export function routeEdge(
807
1723
  (searchContext === context ? 0 : context.visited),
808
1724
  ),
809
1725
  };
1726
+ hard.allowsStep = allowsStep;
810
1727
  protectFutureTarget(hard);
811
1728
  hard.guides = undefined;
812
1729
  hard.edgeCost = selfCost;
@@ -906,7 +1823,12 @@ export function routeEdge(
906
1823
  return false;
907
1824
  }),
908
1825
  );
909
- if (retraces || !safe(candidate, hard)) continue;
1826
+ if (
1827
+ retraces ||
1828
+ !safe(candidate, hard) ||
1829
+ segments.some((segment) => !(allowsStep?.(segment.a, segment.b) ?? true))
1830
+ )
1831
+ continue;
910
1832
  const cost = pathCost(candidate, context);
911
1833
  if (cost < bestCost) {
912
1834
  best = candidate;
@@ -980,6 +1902,74 @@ export function routeEdge(
980
1902
  }
981
1903
  return pathFromPoints([a.point, ...via, b.point]);
982
1904
  }
1905
+ function connection(a: Terminal, b: Terminal, via: readonly RoutePoint[] = []): RoutePath {
1906
+ if (!policy) return guideConnection(a, b, via);
1907
+ const diagnosticStart = diagnostics.length;
1908
+ const fallbackBefore = shapeFallback;
1909
+ const evaluate = (from: Terminal, to: Terminal) => {
1910
+ if (a === source && b === target) {
1911
+ monotonicSource = from;
1912
+ monotonicTarget = to;
1913
+ }
1914
+ diagnostics.splice(diagnosticStart);
1915
+ shapeFallback = fallbackBefore;
1916
+ const guide = guideConnection(from, to, via);
1917
+ const path = selectPolicyPath(from, to, via, guide);
1918
+ if (path !== guide)
1919
+ for (let i = diagnostics.length - 1; i >= diagnosticStart; i--)
1920
+ if (diagnostics[i]!.code === "ROUTE_BLOCKED" || diagnostics[i]!.code === "SEARCH_BUDGET")
1921
+ diagnostics.splice(i, 1);
1922
+ return {
1923
+ path: withPortEscapes(path, from, to),
1924
+ from,
1925
+ to,
1926
+ ...assessPath(path, from, to),
1927
+ diagnostics: diagnostics.slice(diagnosticStart),
1928
+ fallback: shapeFallback,
1929
+ };
1930
+ };
1931
+ let best = evaluate(a, b);
1932
+ if (
1933
+ automaticFaces &&
1934
+ a === source &&
1935
+ b === target &&
1936
+ (!best.valid || pathReverseTravel(best.path, "both", a.point, b.point) > 1)
1937
+ ) {
1938
+ for (const sourceEnd of [true, false]) {
1939
+ const current = sourceEnd ? a : b;
1940
+ const allowed = (sourceEnd ? perEdge.sourceSides : perEdge.targetSides) ?? [
1941
+ "left",
1942
+ "right",
1943
+ "top",
1944
+ "bottom",
1945
+ ];
1946
+ for (const side of allowed) {
1947
+ if (side === current.side) continue;
1948
+ if (
1949
+ searchContext.visited + (searchContext === context ? 0 : context.visited) >=
1950
+ config.maxSearchNodes
1951
+ )
1952
+ break;
1953
+ const alternative = terminal(sourceEnd, side);
1954
+ const requested = sourceEnd ? policy.sourceTangent : policy.targetTangent;
1955
+ if (requested?.kind === "border-normal")
1956
+ alternative.tangent = sourceEnd ? vector(side) : reverse(vector(side));
1957
+ const candidate = evaluate(sourceEnd ? alternative : a, sourceEnd ? b : alternative);
1958
+ if (candidate.valid && (!best.valid || candidate.cost < best.cost - 1e-7))
1959
+ best = candidate;
1960
+ }
1961
+ }
1962
+ }
1963
+ diagnostics.splice(diagnosticStart, diagnostics.length - diagnosticStart, ...best.diagnostics);
1964
+ shapeFallback = best.fallback;
1965
+ monotonicSource = source;
1966
+ monotonicTarget = target;
1967
+ if (a === source && b === target) {
1968
+ Object.assign(source, best.from);
1969
+ Object.assign(target, best.to);
1970
+ }
1971
+ return best.path;
1972
+ }
983
1973
  const via = perEdge.waypoints ?? [];
984
1974
  if (via.some((p) => !Number.isFinite(p.x) || !Number.isFinite(p.y)))
985
1975
  throw new RangeError(`Non-finite waypoint on ${edge.id}`);
@@ -990,7 +1980,12 @@ export function routeEdge(
990
1980
  const hard = contextFor(
991
1981
  state,
992
1982
  excluded,
993
- new Set([`n:${edge.sourceId}`, `n:${edge.targetId}`, `e:${edge.id}`]),
1983
+ new Set([
1984
+ `n:${edge.sourceId}`,
1985
+ `n:${edge.targetId}`,
1986
+ `e:${edge.id}`,
1987
+ ...(parentLabel ? [`e:${parentLabel.id}`] : []),
1988
+ ]),
994
1989
  metrics,
995
1990
  batch,
996
1991
  -config.clearance,
@@ -1012,58 +2007,347 @@ export function routeEdge(
1012
2007
  }
1013
2008
  }
1014
2009
  }
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` },
2010
+ const oldPolicy = batch.previousSettings && effectivePolicy(batch.previousSettings, edge.id);
2011
+ const shapePolicy = (p: RoutingPolicy | undefined) =>
2012
+ p && {
2013
+ curve: p.curve,
2014
+ sourceTangent: p.sourceTangent,
2015
+ targetTangent: p.targetTangent,
2016
+ label: p.label,
2017
+ monotonic: p.monotonic,
1025
2018
  };
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,
2019
+ const oldConfig = batch.previousSettings?.edges[edge.id];
2020
+ const sameConstraints =
2021
+ JSON.stringify({ ...oldConfig, policy: undefined }) ===
2022
+ JSON.stringify({ ...perEdge, policy: undefined }) &&
2023
+ JSON.stringify(shapePolicy(oldPolicy)) === JSON.stringify(shapePolicy(policy));
2024
+ const oldSections = batch.previous?.sections;
2025
+ const oldFirst = oldSections?.[0]?.path,
2026
+ oldLast = oldSections?.at(-1)?.path;
2027
+ const corridor = futureTargetCorridor;
2028
+ futureTargetCorridor = undefined;
2029
+ const reusable =
2030
+ policy?.stability?.kind === "preserve-valid" &&
2031
+ sameConstraints &&
2032
+ batch.previous?.status === "routed" &&
2033
+ oldFirst &&
2034
+ oldLast &&
2035
+ oldSections!.every((section) => section.sharedId === undefined) &&
2036
+ distance(oldFirst.start, portEscapes.get(source)?.point ?? source.point) < 1e-7 &&
2037
+ distance(
2038
+ oldLast.segments.at(-1)?.to ?? oldLast.start,
2039
+ portEscapes.get(target)?.point ?? target.point,
2040
+ ) < 1e-7 &&
2041
+ oldSections!.every((section) => assessPath(section.path).valid) &&
2042
+ (!source.tangent || directionMatches(pathEndpointTangent(oldFirst, "start"), source.tangent)) &&
2043
+ (!target.tangent || directionMatches(pathEndpointTangent(oldLast, "end"), target.tangent)) &&
2044
+ (!lastSource ||
2045
+ (distance(
2046
+ oldSections!.at(-2)?.path.segments.at(-1)?.to ?? oldFirst.start,
2047
+ firstTarget.point,
2048
+ ) < 1e-7 &&
2049
+ distance(oldLast.start, lastSource.point) < 1e-7 &&
2050
+ (!firstTarget.tangent ||
2051
+ directionMatches(
2052
+ pathEndpointTangent(oldSections!.at(-2)!.path, "end"),
2053
+ firstTarget.tangent,
2054
+ )) &&
2055
+ (!lastSource.tangent ||
2056
+ directionMatches(pathEndpointTangent(oldLast, "start"), lastSource.tangent))));
2057
+ futureTargetCorridor = corridor;
2058
+ if (reusable) {
2059
+ sections.push(...oldSections!);
2060
+ futureTargetCorridor = undefined;
2061
+ } else {
2062
+ const plan = grouped ? groupPlan(group!, state, metrics, batch, style) : undefined;
2063
+ if (grouped && !plan)
2064
+ report("ROUTE_BLOCKED", "Shared trunk unavailable; used individual fallback routing");
2065
+ if (plan) {
2066
+ const start = plan.path.start,
2067
+ end = plan.path.segments.at(-1)?.to ?? start;
2068
+ const j0: Terminal = {
2069
+ point: start,
2070
+ side: sideToward(start, source.point),
2071
+ ref: { kind: "junction", id: `${plan.id}:start` },
2072
+ };
2073
+ const j1: Terminal = {
2074
+ point: end,
2075
+ side: sideToward(end, firstTarget.point),
2076
+ ref: { kind: "junction", id: `${plan.id}:end` },
2077
+ };
2078
+ sections.push(
2079
+ {
2080
+ id: `${edge.id}:source`,
2081
+ from: source.ref,
2082
+ to: j0.ref,
2083
+ path: connection(source, j0, via),
2084
+ },
2085
+ { id: `${edge.id}:trunk`, sharedId: plan.id, from: j0.ref, to: j1.ref, path: plan.path },
2086
+ {
2087
+ id: `${edge.id}:target`,
2088
+ from: j1.ref,
2089
+ to: firstTarget.ref,
2090
+ path: connection(j1, firstTarget),
2091
+ },
2092
+ );
2093
+ } else
2094
+ sections.push({
2095
+ id: `${edge.id}:0`,
2096
+ from: source.ref,
1037
2097
  to: firstTarget.ref,
1038
- path: connection(j1, firstTarget),
1039
- },
2098
+ path: connection(source, firstTarget, via),
2099
+ ...(policy ? { fromSide: source.side, toSide: firstTarget.side } : {}),
2100
+ });
2101
+ if (lastSource) {
2102
+ futureTargetCorridor = undefined;
2103
+ let segments = sections.flatMap((s) => pathReservations(s.path));
2104
+ // The two legs meet at a label and, for a self-loop, at their node. Sharing
2105
+ // those attachment regions is legitimate; retracing a distant trunk is not.
2106
+ // Label interiors are gaps, but strokes along their borders remain visible
2107
+ // and must stay reserved. Node attachment regions include their borders.
2108
+ segments = segments.flatMap((s) => outsideTerminal(s, label!, false));
2109
+ if (loop)
2110
+ segments = segments.flatMap((s) =>
2111
+ outsideTerminal(s, inflate(sourceBounds, config.clearance + 1)),
2112
+ );
2113
+ drawn.push(...segments);
2114
+ sections.push({
2115
+ id: `${edge.id}:label-target`,
2116
+ from: lastSource.ref,
2117
+ to: target.ref,
2118
+ path: connection(lastSource, target),
2119
+ ...(policy ? { fromSide: lastSource.side, toSide: target.side } : {}),
2120
+ });
2121
+ }
2122
+ }
2123
+ metrics.searchNodes += searchContext.visited + (searchContext === context ? 0 : context.visited);
2124
+ if (policy) {
2125
+ const labelKind = policy.label?.kind;
2126
+ const logicalPaths = sections.map((section) => section.path);
2127
+ if (labelKind === "gap" && lastSource)
2128
+ logicalPaths.splice(
2129
+ logicalPaths.length - 1,
2130
+ 0,
2131
+ pathFromPoints([firstTarget.point, center(label!), lastSource.point]),
2132
+ );
2133
+ const first = sections[0]?.path,
2134
+ last = sections.at(-1)?.path;
2135
+ if (
2136
+ first &&
2137
+ source.tangent &&
2138
+ !directionMatches(pathEndpointTangent(first, "start"), source.tangent)
2139
+ )
2140
+ report(
2141
+ "CONSTRAINT_VIOLATION",
2142
+ "Final source tangent differs from the requested direction",
2143
+ "source-tangent",
2144
+ );
2145
+ if (
2146
+ last &&
2147
+ target.tangent &&
2148
+ !directionMatches(pathEndpointTangent(last, "end"), target.tangent)
2149
+ )
2150
+ report(
2151
+ "CONSTRAINT_VIOLATION",
2152
+ "Final target tangent differs from the requested direction",
2153
+ "target-tangent",
2154
+ );
2155
+ if (lastSource && labelDirection && (labelKind === "through" || labelKind === "gap")) {
2156
+ const incoming = sections.at(-2)?.path,
2157
+ outgoing = last;
2158
+ if (
2159
+ !incoming ||
2160
+ !outgoing ||
2161
+ !directionMatches(pathEndpointTangent(incoming, "end"), firstTarget.tangent!) ||
2162
+ !directionMatches(pathEndpointTangent(outgoing, "start"), lastSource.tangent!)
2163
+ )
2164
+ report(
2165
+ "CONSTRAINT_VIOLATION",
2166
+ "Final label tangents differ from the requested direction",
2167
+ "label-tangent",
2168
+ );
2169
+ }
2170
+ if (
2171
+ logicalPaths.some(
2172
+ (path) =>
2173
+ policy.monotonic?.strength === "require" &&
2174
+ pathReverseTravel(
2175
+ path,
2176
+ policy.monotonic.axis,
2177
+ portEscapes.get(source)?.point ?? source.point,
2178
+ portEscapes.get(target)?.point ?? target.point,
2179
+ ) > 1e-7,
2180
+ )
2181
+ )
2182
+ report(
2183
+ "CONSTRAINT_VIOLATION",
2184
+ "No final curve satisfies required monotonicity",
2185
+ "monotonicity",
2186
+ );
2187
+ if (sections.some((section) => !policyPathSafe(section.path))) {
2188
+ const labelsBlocked =
2189
+ policy.collisions?.labels !== "allow" &&
2190
+ sections.some((section) => {
2191
+ let from = section.path.start;
2192
+ return section.path.segments.some((segment) => {
2193
+ const blocked = state.obstacles
2194
+ .query(getPathBounds({ start: from, segments: [segment] }))
2195
+ .some(
2196
+ (id) =>
2197
+ id.startsWith("e:") &&
2198
+ !excluded.has(id) &&
2199
+ segmentCrossesRect(
2200
+ from,
2201
+ segment,
2202
+ inflate(
2203
+ state.obstacles.bounds.get(id)!,
2204
+ id === `e:${edge.id}` || id === `e:${parentLabel?.id}` ? 0 : config.clearance,
2205
+ ),
2206
+ ),
2207
+ );
2208
+ from = segment.to;
2209
+ return blocked;
2210
+ });
2211
+ });
2212
+ report(
2213
+ "CONSTRAINT_VIOLATION",
2214
+ "No final curve satisfies obstacle clearance",
2215
+ labelsBlocked ? "label-clearance" : "node-clearance",
2216
+ );
2217
+ }
2218
+ const hardFailure = diagnostics.some(
2219
+ (d) =>
2220
+ d.code === "MISSING_GEOMETRY" ||
2221
+ d.code === "MISSING_PORT" ||
2222
+ (d.code === "CONSTRAINT_VIOLATION" && d.constraint !== "bundle"),
1040
2223
  );
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)),
2224
+ if ((hardFailure || shapeFallback) && grouped) {
2225
+ const independent = routeEdge(
2226
+ edge,
2227
+ {
2228
+ ...state,
2229
+ settings: {
2230
+ ...state.settings,
2231
+ edges: {
2232
+ ...state.settings.edges,
2233
+ [edge.id]: { ...perEdge, group: `independent:${edge.id}` },
2234
+ },
2235
+ },
2236
+ },
2237
+ strategyStyle,
2238
+ metrics,
2239
+ batch,
1059
2240
  );
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
- });
2241
+ return {
2242
+ ...independent,
2243
+ diagnostics: [
2244
+ ...independent.diagnostics,
2245
+ {
2246
+ code: "CONSTRAINT_VIOLATION",
2247
+ edgeId: edge.id,
2248
+ constraint: "bundle",
2249
+ message: "Shared geometry violates this branch policy; routed independently",
2250
+ },
2251
+ ],
2252
+ };
2253
+ }
2254
+ if (hardFailure) return { edgeId: edge.id, sections: [], status: "fallback", diagnostics };
2255
+ if (shapeFallback)
2256
+ report("CONSTRAINT_VIOLATION", "Requested curve unavailable; retained a hard-safe guide");
2257
+ if (
2258
+ policy.monotonic?.strength === "prefer" &&
2259
+ logicalPaths.some(
2260
+ (path) =>
2261
+ pathReverseTravel(
2262
+ path,
2263
+ policy.monotonic!.axis,
2264
+ portEscapes.get(source)?.point ?? source.point,
2265
+ portEscapes.get(target)?.point ?? target.point,
2266
+ ) > 1e-7,
2267
+ )
2268
+ )
2269
+ report(
2270
+ "CONSTRAINT_VIOLATION",
2271
+ "Obstacle-clear route requires reverse travel",
2272
+ "monotonicity",
2273
+ );
2274
+ let placement: RouteLabelPlacement | undefined;
2275
+ if (
2276
+ labelKind === "follow" &&
2277
+ policy.label?.kind === "follow" &&
2278
+ Number.isFinite(edge.width) &&
2279
+ Number.isFinite(edge.height) &&
2280
+ edge.width! > 0 &&
2281
+ edge.height! > 0
2282
+ )
2283
+ placement = placeFollowingLabel(
2284
+ logicalPaths,
2285
+ { width: edge.width!, height: edge.height! },
2286
+ policy.label,
2287
+ );
2288
+ else if (policy.label && positionedLabel) {
2289
+ const anchor = center(positionedLabel);
2290
+ let tangent = labelDirection ?? { x: 0, y: 0 },
2291
+ nearest = Infinity;
2292
+ if (!labelDirection)
2293
+ for (const path of logicalPaths) {
2294
+ const points = flattenPath(path, { tolerance: 0.1 });
2295
+ for (let i = 1; i < points.length; i++) {
2296
+ const a = points[i - 1]!,
2297
+ b = points[i]!,
2298
+ dx = b.x - a.x,
2299
+ dy = b.y - a.y,
2300
+ squared = dx * dx + dy * dy;
2301
+ const t = squared
2302
+ ? Math.max(0, Math.min(1, ((anchor.x - a.x) * dx + (anchor.y - a.y) * dy) / squared))
2303
+ : 0;
2304
+ const gap = distance(anchor, { x: a.x + dx * t, y: a.y + dy * t });
2305
+ if (gap < nearest) {
2306
+ nearest = gap;
2307
+ tangent = normalized({ x: dx, y: dy });
2308
+ }
2309
+ }
2310
+ }
2311
+ placement = { bounds: positionedLabel, anchor, tangent };
2312
+ }
2313
+ if (placement && labelKind === "follow") {
2314
+ batch.dependencyBounds = batch.dependencyBounds
2315
+ ? union(batch.dependencyBounds, placement.bounds)
2316
+ : placement.bounds;
2317
+ const overlaps = state.obstacles
2318
+ .query(placement.bounds)
2319
+ .filter((id) => intersects(placement!.bounds, state.obstacles.bounds.get(id)!));
2320
+ overlaps.push(...(batch.labelOverlaps?.(placement.bounds) ?? []));
2321
+ if (overlaps.length)
2322
+ diagnostics.push({
2323
+ code: "LABEL_OVERLAP",
2324
+ edgeId: edge.id,
2325
+ message: "Following label overlaps fixed geometry or another label",
2326
+ relatedIds: [...new Set(overlaps)].sort(),
2327
+ });
2328
+ }
2329
+ if (
2330
+ sections.some((section) =>
2331
+ pathReservations(section.path).some((segment) => batch.conflicts?.(segment.a, segment.b)),
2332
+ )
2333
+ )
2334
+ report("ROUTE_CONFLICT", "Final curve crosses or overlaps another edge");
2335
+ const failed =
2336
+ shapeFallback ||
2337
+ diagnostics.some(
2338
+ (d) =>
2339
+ d.code !== "ROUTE_CONFLICT" &&
2340
+ d.code !== "LABEL_OVERLAP" &&
2341
+ d.constraint !== "monotonicity" &&
2342
+ d.constraint !== "bundle",
2343
+ );
2344
+ return {
2345
+ edgeId: edge.id,
2346
+ sections,
2347
+ status: failed ? "fallback" : "routed",
2348
+ diagnostics,
2349
+ ...(placement ? { label: placement } : {}),
2350
+ };
1067
2351
  }
1068
2352
  if (
1069
2353
  sections.some((section) =>
@@ -1074,7 +2358,6 @@ export function routeEdge(
1074
2358
  "ROUTE_CONFLICT",
1075
2359
  "Route crosses or overlaps another edge; retained a compact visible path",
1076
2360
  );
1077
- metrics.searchNodes += searchContext.visited + (searchContext === context ? 0 : context.visited);
1078
2361
  return {
1079
2362
  edgeId: edge.id,
1080
2363
  sections,
@@ -1083,6 +2366,11 @@ export function routeEdge(
1083
2366
  };
1084
2367
  }
1085
2368
  export function routeBounds(route: Route): RouteBounds {
1086
- return route.sections.map((s) => getPathBounds(s.path)).reduce(union);
2369
+ let bounds: RouteBounds | undefined = route.label?.bounds;
2370
+ for (const section of route.sections) {
2371
+ const next = getPathBounds(section.path);
2372
+ bounds = bounds ? union(bounds, next) : next;
2373
+ }
2374
+ return bounds ?? { x: 0, y: 0, width: 0, height: 0 };
1087
2375
  }
1088
2376
  export { pointsBounds };