@statelyai/layout 0.4.3 → 0.5.1
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/README.md +32 -3
- package/dist/elkjs/index.cjs +2 -2
- package/dist/elkjs/index.d.cts +1 -1
- package/dist/elkjs/index.d.mts +1 -1
- 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-Cj-lWiK1.cjs → elkjs-Bh_E59N_.cjs} +3 -3
- package/dist/{elkjs-B5kN_zS6.mjs → elkjs-Dk9yOk9U.mjs} +3 -3
- package/dist/{index-B6cWNrMw.d.cts → index-0YVSvMvA.d.mts} +13 -3
- package/dist/{index-CJQLUKD9.d.mts → index-C0J4ttZe.d.cts} +13 -3
- package/dist/{index-DT6lPMfS.d.cts → index-Xc4R6y3z.d.cts} +9 -2
- package/dist/{index-F1U-fCTu.d.mts → index-lSp0oCJC.d.mts} +9 -2
- package/dist/index.cjs +7 -4
- package/dist/index.d.cts +4 -4
- package/dist/index.d.mts +4 -4
- package/dist/index.mjs +4 -4
- package/dist/layered/index.cjs +1 -1
- package/dist/layered/index.d.cts +2 -2
- package/dist/layered/index.d.mts +2 -2
- package/dist/layered/index.mjs +1 -1
- package/dist/{layered-BsAdm_u8.mjs → layered-CUwft7on.mjs} +488 -91
- package/dist/{layered-BJu8A21s.cjs → layered-QzYtokgN.cjs} +488 -91
- package/dist/{layout-engine-BFP1TZbF.cjs → layout-engine-CQ5FcBPC.cjs} +1 -1
- package/dist/{layout-engine-DnlmGxHL.mjs → layout-engine-DvDkMQM3.mjs} +1 -1
- package/dist/routing/index.cjs +5 -2
- package/dist/routing/index.d.cts +3 -3
- package/dist/routing/index.d.mts +3 -3
- package/dist/routing/index.mjs +2 -2
- package/dist/routing-D-LfMXOF.mjs +5189 -0
- package/dist/routing-Phxu1R0x.cjs +5423 -0
- package/dist/{types-CT2akdK0.d.mts → types-D8pYSn8I.d.cts} +112 -2
- package/dist/{types-uxPlGRof.d.cts → types-MAUtJfKQ.d.mts} +112 -2
- package/package.json +4 -1
- package/src/authoring/partial.ts +1 -7
- package/src/authoring/routing.ts +64 -19
- package/src/layered/feedback-chains.ts +22 -4
- package/src/layered/index.ts +186 -42
- package/src/layered/loop-envelopes.ts +96 -26
- package/src/layered/north-south-ports.ts +17 -4
- package/src/layered/self-loop-ports.ts +158 -0
- package/src/layered/strategies.ts +183 -32
- package/src/routing/algorithms.ts +1436 -148
- package/src/routing/bezier-fit.ts +605 -0
- package/src/routing/bezier-visibility.ts +107 -0
- package/src/routing/bezier.ts +596 -0
- package/src/routing/engine.ts +65 -13
- package/src/routing/index.ts +14 -0
- package/src/routing/model.ts +185 -1
- package/src/routing/path.ts +1 -1
- package/src/routing/policy-geometry.ts +536 -0
- package/src/routing/search.ts +10 -2
- package/src/routing/types.ts +77 -1
- package/dist/routing-CR8flv8b.mjs +0 -2780
- 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 {
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
|
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 = (
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
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 =
|
|
317
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
|
465
|
-
|
|
466
|
-
|
|
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([
|
|
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 =
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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 =
|
|
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:
|
|
893
|
+
side: labelSide(source.point),
|
|
548
894
|
ref: { kind: "label", edgeId: edge.id },
|
|
549
895
|
}
|
|
550
896
|
: target;
|
|
551
|
-
const
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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
|
-
:
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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
|
-
|
|
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
|
-
|
|
579
|
-
|
|
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
|
|
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([
|
|
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)
|
|
675
|
-
|
|
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
|
-
|
|
730
|
-
? "
|
|
731
|
-
: style === "
|
|
732
|
-
? "
|
|
733
|
-
: "
|
|
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([
|
|
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 (
|
|
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([
|
|
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
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
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
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
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(
|
|
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
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
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
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
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
|
-
|
|
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 };
|