@statelyai/layout 0.3.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 (110) hide show
  1. package/NOTICE.md +4 -2
  2. package/README.md +150 -11
  3. package/dist/elkjs/index.cjs +2 -2
  4. package/dist/elkjs/index.d.cts +2 -0
  5. package/dist/elkjs/index.d.mts +2 -0
  6. package/dist/elkjs/index.mjs +2 -2
  7. package/dist/elkjs/main.cjs +1 -1
  8. package/dist/elkjs/main.mjs +1 -1
  9. package/dist/elkjs/worker.cjs +2 -2
  10. package/dist/elkjs/worker.mjs +2 -2
  11. package/dist/elkjs-UciYx7lD.mjs +2973 -0
  12. package/dist/elkjs-ggjsBuVP.cjs +3002 -0
  13. package/dist/{index-BEA_pzeT.d.cts → index-B6cWNrMw.d.cts} +9 -1
  14. package/dist/{index-DNTLRsDl.d.mts → index-CJQLUKD9.d.mts} +9 -1
  15. package/dist/{index-DNixtE3h.d.mts → index-DT6lPMfS.d.cts} +85 -5
  16. package/dist/{index-D9dhxrRl.d.cts → index-F1U-fCTu.d.mts} +85 -5
  17. package/dist/index.cjs +6 -3
  18. package/dist/index.d.cts +3 -3
  19. package/dist/index.d.mts +3 -3
  20. package/dist/index.mjs +4 -4
  21. package/dist/layered/index.cjs +3 -1
  22. package/dist/layered/index.d.cts +2 -2
  23. package/dist/layered/index.d.mts +2 -2
  24. package/dist/layered/index.mjs +2 -2
  25. package/dist/{layered-DGFTeQSl.mjs → layered-C7ERzZjR.mjs} +9605 -1655
  26. package/dist/{layered-B2buJqMf.cjs → layered-CBPg6_6_.cjs} +12481 -4435
  27. package/dist/{layout-engine-Dn1PiFwP.cjs → layout-engine-DAw06RhN.cjs} +5 -18
  28. package/dist/{layout-engine-Cs5tatA6.mjs → layout-engine-DC3IYkIy.mjs} +3 -10
  29. package/dist/routing/index.cjs +2 -1
  30. package/dist/routing/index.d.cts +2 -2
  31. package/dist/routing/index.d.mts +2 -2
  32. package/dist/routing/index.mjs +2 -2
  33. package/dist/{routing-A2hrK9MP.mjs → routing-CR8flv8b.mjs} +977 -593
  34. package/dist/{routing-ujEP7Qh6.cjs → routing-QIdGGFBe.cjs} +986 -596
  35. package/package.json +8 -2
  36. package/src/authoring/coordinates.ts +28 -8
  37. package/src/authoring/partial.ts +7 -2
  38. package/src/authoring/routing.ts +4 -1
  39. package/src/constraints.ts +174 -6
  40. package/src/elkjs/index.ts +1392 -449
  41. package/src/elkjs/layout-quality.ts +219 -0
  42. package/src/elkjs/opposing-tracks.ts +161 -0
  43. package/src/elkjs/orthogonal-junctions.ts +174 -0
  44. package/src/elkjs/statechart.ts +9 -3
  45. package/src/elkjs/types.ts +2 -0
  46. package/src/index.ts +2 -0
  47. package/src/internal/elkjs-compatibility.ts +4 -1
  48. package/src/internal/layered-trace.ts +48 -0
  49. package/src/internal/layout-engine.ts +2 -1
  50. package/src/java-random.ts +6 -0
  51. package/src/layered/bk-node-placement.ts +403 -92
  52. package/src/layered/center-labels.ts +398 -0
  53. package/src/layered/compaction-bounds.ts +26 -0
  54. package/src/layered/compaction-errors.ts +2 -0
  55. package/src/layered/compaction-scanline.ts +63 -0
  56. package/src/layered/compound-boundaries.ts +99 -0
  57. package/src/layered/compound.ts +35 -7
  58. package/src/layered/constraint-orientation.ts +141 -0
  59. package/src/layered/crossing-constraints.ts +243 -0
  60. package/src/layered/crossing-counter.ts +221 -0
  61. package/src/layered/crossing-graph.ts +401 -0
  62. package/src/layered/cycle-random.ts +22 -0
  63. package/src/layered/elk-port-lists.ts +243 -0
  64. package/src/layered/end-labels.ts +124 -0
  65. package/src/layered/external-port-dummy.ts +137 -0
  66. package/src/layered/feedback-chains.ts +114 -0
  67. package/src/layered/fixed-self-loop.ts +150 -0
  68. package/src/layered/greedy-switch-decider.ts +356 -0
  69. package/src/layered/grouped-compaction.ts +787 -0
  70. package/src/layered/gwt-sort.ts +53 -0
  71. package/src/layered/hierarchical-port-phases.ts +242 -0
  72. package/src/layered/hierarchical-port-restoration.ts +233 -0
  73. package/src/layered/hierarchical-port-routing.ts +179 -0
  74. package/src/layered/hierarchy-crossing.ts +121 -0
  75. package/src/layered/hierarchy-sweepiness.ts +83 -0
  76. package/src/layered/hints.ts +371 -0
  77. package/src/layered/hyperedge-dummy-merger.ts +195 -0
  78. package/src/layered/index.ts +886 -161
  79. package/src/layered/initial-model-order.ts +404 -0
  80. package/src/layered/inverted-ports.ts +207 -0
  81. package/src/layered/label-dummy-switcher.ts +200 -0
  82. package/src/layered/long-edges.ts +135 -44
  83. package/src/layered/loop-envelopes.ts +184 -0
  84. package/src/layered/native-routing.ts +275 -0
  85. package/src/layered/network-simplex.ts +11 -15
  86. package/src/layered/node-margins.ts +59 -0
  87. package/src/layered/north-south-ports.ts +271 -0
  88. package/src/layered/opposing-tracks.ts +944 -0
  89. package/src/layered/orthogonal-cycle-order.ts +106 -0
  90. package/src/layered/orthogonal-hypersegments.ts +92 -0
  91. package/src/layered/orthogonal-junctions.ts +202 -0
  92. package/src/layered/orthogonal-segments.ts +243 -0
  93. package/src/layered/placement-orientation.ts +22 -0
  94. package/src/layered/port-distributor.ts +192 -0
  95. package/src/layered/port-edge-order.ts +20 -0
  96. package/src/layered/replace-routing.ts +45 -0
  97. package/src/layered/routing-coordinates.ts +32 -0
  98. package/src/layered/separate-exterior-labels.ts +24 -6
  99. package/src/layered/spacing.ts +34 -12
  100. package/src/layered/splitter-order.ts +106 -0
  101. package/src/layered/strategies.ts +2024 -883
  102. package/src/layered/types.ts +13 -0
  103. package/src/layered/weighted-compaction.ts +84 -0
  104. package/src/routing/adapters.ts +32 -0
  105. package/src/routing/algorithms.ts +433 -21
  106. package/src/routing/coordination.ts +8 -3
  107. package/src/routing/index.ts +1 -0
  108. package/src/routing/search.ts +93 -11
  109. package/dist/elkjs-DaHMISnQ.mjs +0 -1677
  110. package/dist/elkjs-dMghiiWW.cjs +0 -1706
@@ -22,12 +22,20 @@ import {
22
22
  type EdgeGeometry,
23
23
  type State,
24
24
  } from "./model";
25
- import { pathReservations } from "./coordination";
25
+ import {
26
+ pathReservations,
27
+ conflictCost,
28
+ outsideTerminal,
29
+ reservationBounds,
30
+ type Reservation,
31
+ } from "./coordination";
32
+ import { crossesRect } from "../authoring/routing";
26
33
  import { distance, getPathBounds, pathFromPoints, roundCorners, segmentCrossesRect } from "./path";
27
- import { inflate, union } from "./spatial";
34
+ import { inflate, intersects, union } from "./spatial";
28
35
  import {
29
36
  clear,
30
37
  findPath,
38
+ findPathBetweenLeads,
31
39
  pointsBounds,
32
40
  segmentBounds,
33
41
  simplify,
@@ -67,7 +75,10 @@ function contextFor(
67
75
  return {
68
76
  edgeCost: (a, b) => batch.edgeCost?.(a, b) ?? 0,
69
77
  guides: (bounds) => batch.guides?.(bounds) ?? [],
70
- maxSearchNodes: state.settings.maxSearchNodes,
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,
71
82
  bendPenalty: state.settings.bendPenalty,
72
83
  visited: 0,
73
84
  budgetExceeded: false,
@@ -93,11 +104,26 @@ function pathLength(path: RoutePath): number {
93
104
  return pathReservations(path).reduce((sum, segment) => sum + distance(segment.a, segment.b), 0);
94
105
  }
95
106
  function pathCost(path: RoutePath, context: SearchContext): number {
96
- return pathReservations(path).reduce(
107
+ const segments = pathReservations(path);
108
+ const lengthAndConflicts = segments.reduce(
97
109
  (sum, segment) =>
98
110
  sum + distance(segment.a, segment.b) + (context.edgeCost?.(segment.a, segment.b) ?? 0),
99
111
  0,
100
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;
101
127
  }
102
128
  function safe(path: RoutePath, context: SearchContext): boolean {
103
129
  let start = path.start;
@@ -388,6 +414,40 @@ export function routeEdge(
388
414
  } else
389
415
  report("MISSING_PORT", `Port ${name} has no positioned geometry; used the node boundary`);
390
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
+ }
391
451
  return {
392
452
  point,
393
453
  side:
@@ -429,6 +489,40 @@ export function routeEdge(
429
489
  config.radius * 2 + lane * config.edgeSpacing,
430
490
  )
431
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
+ }
432
526
  const labelAnchor = (toward: RoutePoint) => {
433
527
  const side = sideToward(center(label!), toward),
434
528
  point = anchor(label!, side);
@@ -456,8 +550,21 @@ export function routeEdge(
456
550
  : target;
457
551
  const lastSource: Terminal | undefined = label
458
552
  ? {
459
- point: labelAnchor(target.point),
460
- side: sideToward(center(label), target.point),
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),
461
568
  ref: { kind: "label", edgeId: edge.id },
462
569
  }
463
570
  : undefined;
@@ -475,13 +582,68 @@ export function routeEdge(
475
582
  function connection(a: Terminal, b: Terminal, via: readonly RoutePoint[] = []): RoutePath {
476
583
  let path: RoutePath | undefined, preferred: RoutePath | undefined;
477
584
  if (distance(a.point, b.point) < 1e-8 && !via.length) {
478
- report("CONSTRAINT_VIOLATION", "Coincident terminals; rendered a visible loop fallback");
585
+ const v = vector(a.side),
586
+ tangent = { x: -v.y, y: v.x };
479
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");
480
644
  return pathFromPoints([
481
645
  a.point,
482
- { x: a.point.x + gap, y: a.point.y },
483
- { x: a.point.x + gap, y: a.point.y - gap },
484
- { x: a.point.x, y: a.point.y - gap },
646
+ { x: a.point.x + v.x * gap, y: a.point.y + v.y * gap },
485
647
  b.point,
486
648
  ]);
487
649
  }
@@ -518,17 +680,28 @@ export function routeEdge(
518
680
  }
519
681
  }
520
682
  if (!path) {
521
- const start = stub(a),
522
- end = stub(b),
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),
523
697
  waypoints = [...via];
524
- if (loop && !waypoints.length) {
525
- const r = inflate(sourceBounds, config.clearance + config.edgeSpacing + 1);
526
- waypoints.push({ x: r.x + r.width, y: r.y });
527
- }
528
698
  if (style === "parallel" && peers.length > 1) {
529
699
  // Find a feasible corridor before choosing lanes; an arbitrary midpoint
530
700
  // may be inside an obstacle even when good parallel routes exist.
531
- const base = findPath(start, end, "polyline", context);
701
+ const base = findPathBetweenLeads(start, end, "polyline", context, [
702
+ [a.point, start],
703
+ [end, b.point],
704
+ ]);
532
705
  const mid =
533
706
  base && base.length > 2
534
707
  ? base[Math.floor(base.length / 2)]!
@@ -565,11 +738,31 @@ export function routeEdge(
565
738
  outgoing:
566
739
  i === anchors.length - 1 ? { x: -vector(b.side).x, y: -vector(b.side).y } : undefined,
567
740
  };
568
- let found = findPath(anchors[i - 1]!, anchors[i]!, searchStyle, searchContext, directions);
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
+ );
569
752
  if (!found && searchContext !== context && !searchContext.budgetExceeded) {
570
753
  // Extra curve room is optional: tight channels may use safe line segments.
571
754
  const narrow = { ...searchContext, obstacles: context.obstacles };
572
- found = findPath(anchors[i - 1]!, anchors[i]!, searchStyle, narrow, directions);
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
+ );
573
766
  searchContext.visited = narrow.visited;
574
767
  searchContext.budgetExceeded = narrow.budgetExceeded;
575
768
  }
@@ -589,10 +782,187 @@ export function routeEdge(
589
782
  }
590
783
  if (
591
784
  preferred &&
785
+ Number.isFinite(pathCost(preferred, context)) &&
592
786
  (!path || !safe(path, context) || pathCost(preferred, context) <= pathCost(path, context))
593
787
  )
594
788
  return preferred;
595
- if (path && safe(path, context)) return path;
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
+ }
596
966
  report(
597
967
  searchContext.budgetExceeded ? "SEARCH_BUDGET" : "ROUTE_BLOCKED",
598
968
  "Preferred route unavailable; fallback may cross obstacles or violate routing constraints",
@@ -613,6 +983,35 @@ export function routeEdge(
613
983
  const via = perEdge.waypoints ?? [];
614
984
  if (via.some((p) => !Number.isFinite(p.x) || !Number.isFinite(p.y)))
615
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
+ }
616
1015
  const plan = grouped ? groupPlan(group!, state, metrics, batch, style) : undefined;
617
1016
  if (grouped && !plan)
618
1017
  report("ROUTE_BLOCKED", "Shared trunk unavailable; used individual fallback routing");
@@ -646,13 +1045,26 @@ export function routeEdge(
646
1045
  to: firstTarget.ref,
647
1046
  path: connection(source, firstTarget, via),
648
1047
  });
649
- if (lastSource)
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);
650
1061
  sections.push({
651
1062
  id: `${edge.id}:label-target`,
652
1063
  from: lastSource.ref,
653
1064
  to: target.ref,
654
1065
  path: connection(lastSource, target),
655
1066
  });
1067
+ }
656
1068
  if (
657
1069
  sections.some((section) =>
658
1070
  pathReservations(section.path).some((segment) => batch.conflicts?.(segment.a, segment.b)),
@@ -59,7 +59,11 @@ export function conflictCost(
59
59
  }
60
60
 
61
61
  /** Remove only the common terminal's attachment region from soft reservations. */
62
- export function outsideTerminal(segment: Reservation, rect: RouteBounds): Reservation[] {
62
+ export function outsideTerminal(
63
+ segment: Reservation,
64
+ rect: RouteBounds,
65
+ closed = true,
66
+ ): Reservation[] {
63
67
  let lo = 0,
64
68
  hi = 1;
65
69
  const dx = segment.b.x - segment.a.x,
@@ -69,7 +73,8 @@ export function outsideTerminal(segment: Reservation, rect: RouteBounds): Reserv
69
73
  [segment.a.y, dy, rect.y, rect.y + rect.height],
70
74
  ] as const) {
71
75
  if (Math.abs(delta) < 1e-8) {
72
- if (start < min || start > max) return [segment];
76
+ if (closed ? start < min || start > max : start <= min + 1e-8 || start >= max - 1e-8)
77
+ return [segment];
73
78
  } else {
74
79
  const a = (min - start) / delta,
75
80
  b = (max - start) / delta;
@@ -77,7 +82,7 @@ export function outsideTerminal(segment: Reservation, rect: RouteBounds): Reserv
77
82
  hi = Math.min(hi, Math.max(a, b));
78
83
  }
79
84
  }
80
- if (lo > hi) return [segment];
85
+ if (closed ? lo > hi : lo >= hi) return [segment];
81
86
  const at = (t: number) => ({ x: segment.a.x + dx * t, y: segment.a.y + dy * t });
82
87
  return [
83
88
  ...(lo > 1e-8 ? [{ ...segment, b: at(lo) }] : []),
@@ -65,4 +65,5 @@ export {
65
65
  pathFromSplinePoints,
66
66
  routeToGraphPatch,
67
67
  routeToPolylines,
68
+ routeToPoints,
68
69
  } from "./adapters";