@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.
- package/NOTICE.md +4 -2
- package/README.md +150 -11
- package/dist/elkjs/index.cjs +2 -2
- package/dist/elkjs/index.d.cts +2 -0
- package/dist/elkjs/index.d.mts +2 -0
- package/dist/elkjs/index.mjs +2 -2
- package/dist/elkjs/main.cjs +1 -1
- package/dist/elkjs/main.mjs +1 -1
- package/dist/elkjs/worker.cjs +2 -2
- package/dist/elkjs/worker.mjs +2 -2
- package/dist/elkjs-UciYx7lD.mjs +2973 -0
- package/dist/elkjs-ggjsBuVP.cjs +3002 -0
- package/dist/{index-BEA_pzeT.d.cts → index-B6cWNrMw.d.cts} +9 -1
- package/dist/{index-DNTLRsDl.d.mts → index-CJQLUKD9.d.mts} +9 -1
- package/dist/{index-DNixtE3h.d.mts → index-DT6lPMfS.d.cts} +85 -5
- package/dist/{index-D9dhxrRl.d.cts → index-F1U-fCTu.d.mts} +85 -5
- package/dist/index.cjs +6 -3
- package/dist/index.d.cts +3 -3
- package/dist/index.d.mts +3 -3
- package/dist/index.mjs +4 -4
- package/dist/layered/index.cjs +3 -1
- package/dist/layered/index.d.cts +2 -2
- package/dist/layered/index.d.mts +2 -2
- package/dist/layered/index.mjs +2 -2
- package/dist/{layered-DGFTeQSl.mjs → layered-C7ERzZjR.mjs} +9605 -1655
- package/dist/{layered-B2buJqMf.cjs → layered-CBPg6_6_.cjs} +12481 -4435
- package/dist/{layout-engine-Dn1PiFwP.cjs → layout-engine-DAw06RhN.cjs} +5 -18
- package/dist/{layout-engine-Cs5tatA6.mjs → layout-engine-DC3IYkIy.mjs} +3 -10
- package/dist/routing/index.cjs +2 -1
- package/dist/routing/index.d.cts +2 -2
- package/dist/routing/index.d.mts +2 -2
- package/dist/routing/index.mjs +2 -2
- package/dist/{routing-A2hrK9MP.mjs → routing-CR8flv8b.mjs} +977 -593
- package/dist/{routing-ujEP7Qh6.cjs → routing-QIdGGFBe.cjs} +986 -596
- package/package.json +8 -2
- package/src/authoring/coordinates.ts +28 -8
- package/src/authoring/partial.ts +7 -2
- package/src/authoring/routing.ts +4 -1
- package/src/constraints.ts +174 -6
- package/src/elkjs/index.ts +1392 -449
- package/src/elkjs/layout-quality.ts +219 -0
- package/src/elkjs/opposing-tracks.ts +161 -0
- package/src/elkjs/orthogonal-junctions.ts +174 -0
- package/src/elkjs/statechart.ts +9 -3
- package/src/elkjs/types.ts +2 -0
- package/src/index.ts +2 -0
- package/src/internal/elkjs-compatibility.ts +4 -1
- package/src/internal/layered-trace.ts +48 -0
- package/src/internal/layout-engine.ts +2 -1
- package/src/java-random.ts +6 -0
- package/src/layered/bk-node-placement.ts +403 -92
- package/src/layered/center-labels.ts +398 -0
- package/src/layered/compaction-bounds.ts +26 -0
- package/src/layered/compaction-errors.ts +2 -0
- package/src/layered/compaction-scanline.ts +63 -0
- package/src/layered/compound-boundaries.ts +99 -0
- package/src/layered/compound.ts +35 -7
- package/src/layered/constraint-orientation.ts +141 -0
- package/src/layered/crossing-constraints.ts +243 -0
- package/src/layered/crossing-counter.ts +221 -0
- package/src/layered/crossing-graph.ts +401 -0
- package/src/layered/cycle-random.ts +22 -0
- package/src/layered/elk-port-lists.ts +243 -0
- package/src/layered/end-labels.ts +124 -0
- package/src/layered/external-port-dummy.ts +137 -0
- package/src/layered/feedback-chains.ts +114 -0
- package/src/layered/fixed-self-loop.ts +150 -0
- package/src/layered/greedy-switch-decider.ts +356 -0
- package/src/layered/grouped-compaction.ts +787 -0
- package/src/layered/gwt-sort.ts +53 -0
- package/src/layered/hierarchical-port-phases.ts +242 -0
- package/src/layered/hierarchical-port-restoration.ts +233 -0
- package/src/layered/hierarchical-port-routing.ts +179 -0
- package/src/layered/hierarchy-crossing.ts +121 -0
- package/src/layered/hierarchy-sweepiness.ts +83 -0
- package/src/layered/hints.ts +371 -0
- package/src/layered/hyperedge-dummy-merger.ts +195 -0
- package/src/layered/index.ts +886 -161
- package/src/layered/initial-model-order.ts +404 -0
- package/src/layered/inverted-ports.ts +207 -0
- package/src/layered/label-dummy-switcher.ts +200 -0
- package/src/layered/long-edges.ts +135 -44
- package/src/layered/loop-envelopes.ts +184 -0
- package/src/layered/native-routing.ts +275 -0
- package/src/layered/network-simplex.ts +11 -15
- package/src/layered/node-margins.ts +59 -0
- package/src/layered/north-south-ports.ts +271 -0
- package/src/layered/opposing-tracks.ts +944 -0
- package/src/layered/orthogonal-cycle-order.ts +106 -0
- package/src/layered/orthogonal-hypersegments.ts +92 -0
- package/src/layered/orthogonal-junctions.ts +202 -0
- package/src/layered/orthogonal-segments.ts +243 -0
- package/src/layered/placement-orientation.ts +22 -0
- package/src/layered/port-distributor.ts +192 -0
- package/src/layered/port-edge-order.ts +20 -0
- package/src/layered/replace-routing.ts +45 -0
- package/src/layered/routing-coordinates.ts +32 -0
- package/src/layered/separate-exterior-labels.ts +24 -6
- package/src/layered/spacing.ts +34 -12
- package/src/layered/splitter-order.ts +106 -0
- package/src/layered/strategies.ts +2024 -883
- package/src/layered/types.ts +13 -0
- package/src/layered/weighted-compaction.ts +84 -0
- package/src/routing/adapters.ts +32 -0
- package/src/routing/algorithms.ts +433 -21
- package/src/routing/coordination.ts +8 -3
- package/src/routing/index.ts +1 -0
- package/src/routing/search.ts +93 -11
- package/dist/elkjs-DaHMISnQ.mjs +0 -1677
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
460
|
-
|
|
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
|
-
|
|
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
|
|
522
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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(
|
|
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
|
|
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) }] : []),
|