@dxos/diagram 0.11.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/LICENSE +105 -0
- package/README.md +10 -0
- package/dist/lib/index.mjs +3975 -0
- package/dist/lib/index.mjs.map +1 -0
- package/dist/lib/testing.mjs +68 -0
- package/dist/lib/testing.mjs.map +1 -0
- package/dist/types/src/content.d.ts +60 -0
- package/dist/types/src/content.d.ts.map +1 -0
- package/dist/types/src/diagnostics.d.ts +56 -0
- package/dist/types/src/diagnostics.d.ts.map +1 -0
- package/dist/types/src/diagnostics.test.d.ts +2 -0
- package/dist/types/src/diagnostics.test.d.ts.map +1 -0
- package/dist/types/src/dialect.d.ts +19 -0
- package/dist/types/src/dialect.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +16 -0
- package/dist/types/src/index.d.ts.map +1 -0
- package/dist/types/src/layout.d.ts +26 -0
- package/dist/types/src/layout.d.ts.map +1 -0
- package/dist/types/src/mermaid-engine.d.ts +83 -0
- package/dist/types/src/mermaid-engine.d.ts.map +1 -0
- package/dist/types/src/mermaid-engine.test.d.ts +2 -0
- package/dist/types/src/mermaid-engine.test.d.ts.map +1 -0
- package/dist/types/src/mermaid.d.ts +56 -0
- package/dist/types/src/mermaid.d.ts.map +1 -0
- package/dist/types/src/mermaid.test.d.ts +3 -0
- package/dist/types/src/mermaid.test.d.ts.map +1 -0
- package/dist/types/src/objective.d.ts +75 -0
- package/dist/types/src/objective.d.ts.map +1 -0
- package/dist/types/src/objective.test.d.ts +2 -0
- package/dist/types/src/objective.test.d.ts.map +1 -0
- package/dist/types/src/ortho-router.d.ts +13 -0
- package/dist/types/src/ortho-router.d.ts.map +1 -0
- package/dist/types/src/scene.d.ts +1006 -0
- package/dist/types/src/scene.d.ts.map +1 -0
- package/dist/types/src/scene.test.d.ts +2 -0
- package/dist/types/src/scene.test.d.ts.map +1 -0
- package/dist/types/src/svg-handler.d.ts +5 -0
- package/dist/types/src/svg-handler.d.ts.map +1 -0
- package/dist/types/src/svg-handler.test.d.ts +2 -0
- package/dist/types/src/svg-handler.test.d.ts.map +1 -0
- package/dist/types/src/testing.d.ts +11 -0
- package/dist/types/src/testing.d.ts.map +1 -0
- package/dist/types/src/ui.d.ts +67 -0
- package/dist/types/src/ui.d.ts.map +1 -0
- package/dist/types/src/ui.test.d.ts +2 -0
- package/dist/types/src/ui.test.d.ts.map +1 -0
- package/dist/types/src/uml-engine.d.ts +14 -0
- package/dist/types/src/uml-engine.d.ts.map +1 -0
- package/dist/types/src/uml-engine.test.d.ts +2 -0
- package/dist/types/src/uml-engine.test.d.ts.map +1 -0
- package/dist/types/src/uml-grid.d.ts +99 -0
- package/dist/types/src/uml-grid.d.ts.map +1 -0
- package/dist/types/src/uml-grid.test.d.ts +2 -0
- package/dist/types/src/uml-grid.test.d.ts.map +1 -0
- package/dist/types/src/uml-rules.d.ts +56 -0
- package/dist/types/src/uml-rules.d.ts.map +1 -0
- package/dist/types/src/uml-rules.test.d.ts +2 -0
- package/dist/types/src/uml-rules.test.d.ts.map +1 -0
- package/dist/types/src/uml-search.d.ts +26 -0
- package/dist/types/src/uml-search.d.ts.map +1 -0
- package/dist/types/src/uml-search.test.d.ts +2 -0
- package/dist/types/src/uml-search.test.d.ts.map +1 -0
- package/dist/types/src/uml.d.ts +60 -0
- package/dist/types/src/uml.d.ts.map +1 -0
- package/dist/types/src/uml.test.d.ts +2 -0
- package/dist/types/src/uml.test.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -0
- package/package.json +48 -0
- package/src/__snapshots__/diagnostics.test.ts.snap +36 -0
- package/src/__snapshots__/mermaid-engine.test.ts.snap +22 -0
- package/src/content.ts +180 -0
- package/src/diagnostics.test.ts +173 -0
- package/src/diagnostics.ts +393 -0
- package/src/dialect.ts +53 -0
- package/src/index.ts +19 -0
- package/src/layout.ts +99 -0
- package/src/mermaid-engine.test.ts +244 -0
- package/src/mermaid-engine.ts +863 -0
- package/src/mermaid.test.ts +138 -0
- package/src/mermaid.ts +326 -0
- package/src/objective.test.ts +82 -0
- package/src/objective.ts +157 -0
- package/src/ortho-router.ts +372 -0
- package/src/scene.test.ts +36 -0
- package/src/scene.ts +281 -0
- package/src/svg-handler.test.ts +75 -0
- package/src/svg-handler.ts +119 -0
- package/src/testing.ts +68 -0
- package/src/ui.test.ts +183 -0
- package/src/ui.ts +398 -0
- package/src/uml-engine.test.ts +63 -0
- package/src/uml-engine.ts +146 -0
- package/src/uml-grid.test.ts +175 -0
- package/src/uml-grid.ts +559 -0
- package/src/uml-rules.test.ts +109 -0
- package/src/uml-rules.ts +319 -0
- package/src/uml-search.test.ts +108 -0
- package/src/uml-search.ts +266 -0
- package/src/uml.test.ts +172 -0
- package/src/uml.ts +390 -0
package/src/objective.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
//
|
|
6
|
+
// The layout objective: how heuristics are expressed. A heuristic is either a CONSTRAINT (hard —
|
|
7
|
+
// a layout that violates it is not acceptable) or a COST TERM (soft — weighted, minimized). Both
|
|
8
|
+
// are evaluated over the emitted scene through `Diagnostics`, so the objective that picks a layout
|
|
9
|
+
// and the report that grades one are the same measurements. Engines and rules are candidate
|
|
10
|
+
// generators; the objective is the judge. See `docs/DESIGN.md`.
|
|
11
|
+
//
|
|
12
|
+
|
|
13
|
+
import { invariant } from '@dxos/invariant';
|
|
14
|
+
|
|
15
|
+
import type * as Diagnostics from './diagnostics.ts';
|
|
16
|
+
import type * as Scene from './scene.ts';
|
|
17
|
+
import { GRID } from './uml-grid.ts';
|
|
18
|
+
|
|
19
|
+
/** A candidate layout: the scene plus its analysis. */
|
|
20
|
+
export type Layout = {
|
|
21
|
+
objects: readonly Scene.WorldObject[];
|
|
22
|
+
report: Diagnostics.Report;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Hard requirement; `violations` names each way the layout breaks it, empty when it holds. */
|
|
26
|
+
export type Constraint = {
|
|
27
|
+
id: string;
|
|
28
|
+
description: string;
|
|
29
|
+
violations: (layout: Layout) => readonly string[];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Soft preference; `measure` is in the term's natural unit and `weight` converts it to cost. */
|
|
33
|
+
export type CostTerm = {
|
|
34
|
+
id: string;
|
|
35
|
+
description: string;
|
|
36
|
+
weight: number;
|
|
37
|
+
measure: (layout: Layout) => number;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type Objective = {
|
|
41
|
+
constraints: readonly Constraint[];
|
|
42
|
+
costs: readonly CostTerm[];
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type Evaluation = {
|
|
46
|
+
violations: readonly { constraint: string; message: string }[];
|
|
47
|
+
/** Weighted sum of the cost terms. */
|
|
48
|
+
cost: number;
|
|
49
|
+
terms: readonly { id: string; value: number; weighted: number }[];
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const evaluate = (objective: Objective, layout: Layout): Evaluation => {
|
|
53
|
+
const violations = objective.constraints.flatMap((constraint) =>
|
|
54
|
+
constraint.violations(layout).map((message) => ({ constraint: constraint.id, message })),
|
|
55
|
+
);
|
|
56
|
+
const terms = objective.costs.map((term) => {
|
|
57
|
+
const value = term.measure(layout);
|
|
58
|
+
return { id: term.id, value, weighted: value * term.weight };
|
|
59
|
+
});
|
|
60
|
+
return { violations, cost: terms.reduce((total, term) => total + term.weighted, 0), terms };
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export type Ranked<T> = { candidate: T; evaluation: Evaluation };
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Picks the best candidate: fewest constraint violations first (so an infeasible set still yields
|
|
67
|
+
* the least-bad layout rather than nothing), then lowest cost. Stable on ties, so the generator's
|
|
68
|
+
* own order breaks them.
|
|
69
|
+
*/
|
|
70
|
+
export const select = <T extends { layout: Layout }>(
|
|
71
|
+
objective: Objective,
|
|
72
|
+
candidates: readonly T[],
|
|
73
|
+
): { chosen: Ranked<T>; ranked: readonly Ranked<T>[] } => {
|
|
74
|
+
invariant(candidates.length > 0, 'select needs at least one candidate');
|
|
75
|
+
const ranked = candidates
|
|
76
|
+
.map((candidate) => ({ candidate, evaluation: evaluate(objective, candidate.layout) }))
|
|
77
|
+
.sort(
|
|
78
|
+
(left, right) =>
|
|
79
|
+
left.evaluation.violations.length - right.evaluation.violations.length ||
|
|
80
|
+
left.evaluation.cost - right.evaluation.cost,
|
|
81
|
+
);
|
|
82
|
+
return { chosen: ranked[0], ranked };
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
//
|
|
86
|
+
// Built-in constraints and cost terms. Weights are in "one crossing ≈ three bends" units; the
|
|
87
|
+
// corpus snapshots are where a weight change shows its consequences.
|
|
88
|
+
//
|
|
89
|
+
|
|
90
|
+
/** Every `error` diagnostic is a violation: overlaps, routes through nodes, labels that do not fit. */
|
|
91
|
+
export const noHardDefects: Constraint = {
|
|
92
|
+
id: 'no-hard-defects',
|
|
93
|
+
description: 'No node overlaps, no connector through a node, every label fits its shape.',
|
|
94
|
+
violations: ({ report }) =>
|
|
95
|
+
report.diagnostics.filter(({ severity }) => severity === 'error').map(({ message }) => message),
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/** Containers keep at least `gap` between them, so packages read as separate. */
|
|
99
|
+
export const framesApart = (gap: number = GRID): Constraint => ({
|
|
100
|
+
id: 'frames-apart',
|
|
101
|
+
description: `Frames are at least ${gap} apart.`,
|
|
102
|
+
violations: ({ report }) =>
|
|
103
|
+
report.metrics.containers >= 2 && report.metrics.frameGapMin < gap
|
|
104
|
+
? [`Frames are ${report.metrics.frameGapMin} apart; at least ${gap} required.`]
|
|
105
|
+
: [],
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
export const crossings: CostTerm = {
|
|
109
|
+
id: 'crossings',
|
|
110
|
+
description: 'Connector pairs that cross.',
|
|
111
|
+
weight: 3,
|
|
112
|
+
measure: ({ report }) => report.metrics.crossings,
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export const bends: CostTerm = {
|
|
116
|
+
id: 'bends',
|
|
117
|
+
description: 'Turns taken by connectors.',
|
|
118
|
+
weight: 1,
|
|
119
|
+
measure: ({ report }) => report.metrics.bends,
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Uneven gutters between frames, in grid units. Weighted so a whole cell of unevenness costs about
|
|
124
|
+
* half a crossing: on the pipeline corpus diagram a weight of 2 made the objective accept three
|
|
125
|
+
* crossings to even out a 7-unit spread, which no reader would.
|
|
126
|
+
*/
|
|
127
|
+
export const unevenFrameGaps: CostTerm = {
|
|
128
|
+
id: 'uneven-frame-gaps',
|
|
129
|
+
description: 'Spread of the gaps between neighbouring frames.',
|
|
130
|
+
weight: 0.5,
|
|
131
|
+
measure: ({ report }) => report.metrics.frameGapSpread / GRID,
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Total connector length in grid units: a long edge is hard to follow even when it is straight.
|
|
136
|
+
* Weighted so ~100 grid units of extra connector cost one crossing — at 0.1 the objective traded
|
|
137
|
+
* crossings for shorter edges on the compute and edge corpus diagrams.
|
|
138
|
+
*/
|
|
139
|
+
export const connectorLength: CostTerm = {
|
|
140
|
+
id: 'connector-length',
|
|
141
|
+
description: 'Total connector length.',
|
|
142
|
+
weight: 0.03,
|
|
143
|
+
measure: ({ report }) => report.metrics.length / GRID,
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/** Bounding perimeter in grid units; a light preference for the tighter of otherwise-equal layouts. */
|
|
147
|
+
export const compactness: CostTerm = {
|
|
148
|
+
id: 'compactness',
|
|
149
|
+
description: 'Bounding width + height.',
|
|
150
|
+
weight: 0.05,
|
|
151
|
+
measure: ({ report }) => (report.metrics.width + report.metrics.height) / GRID,
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
export const DEFAULT: Objective = {
|
|
155
|
+
constraints: [noHardDefects, framesApart()],
|
|
156
|
+
costs: [crossings, bends, connectorLength, unevenFrameGaps, compactness],
|
|
157
|
+
};
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
//
|
|
6
|
+
// Obstacle-avoiding orthogonal router: A* over a fine grid with a turn-dominant cost, so a
|
|
7
|
+
// connector never crosses a node and takes the fewest bends that avoidance allows (length breaks
|
|
8
|
+
// ties). A shared usage penalty nudges later edges off cells earlier edges already run through.
|
|
9
|
+
// Plugs into the dialects via the `Router` contract (`uml-grid.ts`).
|
|
10
|
+
//
|
|
11
|
+
|
|
12
|
+
import type * as Scene from './scene.ts';
|
|
13
|
+
import type { Rect, RoutedRelation, Router } from './uml-grid.ts';
|
|
14
|
+
|
|
15
|
+
/** Clearance kept between a route and any node border, in steps. */
|
|
16
|
+
const CLEARANCE = 1;
|
|
17
|
+
/** One bend costs as much as this many steps — turns dominate, distance breaks ties. */
|
|
18
|
+
const TURN_COST = 1000;
|
|
19
|
+
/** Traversing a cell another edge already used costs this many extra steps. */
|
|
20
|
+
const USED_COST = 24;
|
|
21
|
+
/** Margin around the diagram bounds the search may roam into, in steps. */
|
|
22
|
+
const MARGIN = 8;
|
|
23
|
+
|
|
24
|
+
type Point = Scene.Point;
|
|
25
|
+
|
|
26
|
+
/** Directions: right, down, left, up. */
|
|
27
|
+
const DX = [1, 0, -1, 0];
|
|
28
|
+
const DY = [0, 1, 0, -1];
|
|
29
|
+
|
|
30
|
+
export type AvoidingRouterOptions = {
|
|
31
|
+
/** Search-cell size (scene px): a fraction of the layout grid, so routes can hug node borders. */
|
|
32
|
+
step: number;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Creates a router that avoids the given node rects. Stateful across edges: earlier routes
|
|
37
|
+
* penalize (not block) the cells they occupy, spreading parallel runs apart. `fallback` handles
|
|
38
|
+
* the (fenced-in) edges the search cannot reach. The step is passed in rather than derived from
|
|
39
|
+
* `uml-grid`'s `GRID`, which keeps this module free of a value import cycle with the dialects.
|
|
40
|
+
*/
|
|
41
|
+
export const makeAvoidingRouter = (
|
|
42
|
+
obstacles: Rect[],
|
|
43
|
+
fallback: Router,
|
|
44
|
+
{ step: STEP }: AvoidingRouterOptions,
|
|
45
|
+
): Router => {
|
|
46
|
+
const xs = obstacles.flatMap((rect) => [rect.x, rect.x + rect.w]);
|
|
47
|
+
const ys = obstacles.flatMap((rect) => [rect.y, rect.y + rect.h]);
|
|
48
|
+
const bounds = {
|
|
49
|
+
x0: Math.floor(Math.min(...xs) / STEP) - MARGIN,
|
|
50
|
+
y0: Math.floor(Math.min(...ys) / STEP) - MARGIN,
|
|
51
|
+
x1: Math.ceil(Math.max(...xs) / STEP) + MARGIN,
|
|
52
|
+
y1: Math.ceil(Math.max(...ys) / STEP) + MARGIN,
|
|
53
|
+
};
|
|
54
|
+
const width = bounds.x1 - bounds.x0 + 1;
|
|
55
|
+
const height = bounds.y1 - bounds.y0 + 1;
|
|
56
|
+
const inBounds = (x: number, y: number) => x >= bounds.x0 && x <= bounds.x1 && y >= bounds.y0 && y <= bounds.y1;
|
|
57
|
+
const cellIndex = (x: number, y: number) => (y - bounds.y0) * width + (x - bounds.x0);
|
|
58
|
+
|
|
59
|
+
// Flat grids in place of per-cell string keys: the search touches every cell many times over and
|
|
60
|
+
// the string building was half its running time.
|
|
61
|
+
const blockedGrid = new Uint8Array(width * height);
|
|
62
|
+
for (const rect of obstacles) {
|
|
63
|
+
const x0 = Math.floor(rect.x / STEP) - CLEARANCE;
|
|
64
|
+
const y0 = Math.floor(rect.y / STEP) - CLEARANCE;
|
|
65
|
+
const x1 = Math.ceil((rect.x + rect.w) / STEP) + CLEARANCE;
|
|
66
|
+
const y1 = Math.ceil((rect.y + rect.h) / STEP) + CLEARANCE;
|
|
67
|
+
for (let y = Math.max(bounds.y0, y0 + 1); y < Math.min(bounds.y1 + 1, y1); y++) {
|
|
68
|
+
for (let x = Math.max(bounds.x0, x0 + 1); x < Math.min(bounds.x1 + 1, x1); x++) {
|
|
69
|
+
blockedGrid[cellIndex(x, y)] = 1;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const blocked = (x: number, y: number) => inBounds(x, y) && blockedGrid[cellIndex(x, y)] !== 0;
|
|
74
|
+
const usedGrid = new Uint8Array(width * height);
|
|
75
|
+
const isUsed = (x: number, y: number) => inBounds(x, y) && usedGrid[cellIndex(x, y)] !== 0;
|
|
76
|
+
const markUsed = (x: number, y: number) => {
|
|
77
|
+
if (inBounds(x, y)) {
|
|
78
|
+
usedGrid[cellIndex(x, y)] = 1;
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
// Settled costs per (cell, heading), reused across searches through a generation stamp so no
|
|
82
|
+
// search pays to clear them.
|
|
83
|
+
const settledCost = new Float64Array(width * height * 4);
|
|
84
|
+
const settledGen = new Int32Array(width * height * 4);
|
|
85
|
+
let generation = 0;
|
|
86
|
+
|
|
87
|
+
/** `f` is cost + estimate, the heap's key. */
|
|
88
|
+
type State = { x: number; y: number; dir: number; cost: number; f: number; prev?: State };
|
|
89
|
+
type Terminal = { point: Point; dir: number };
|
|
90
|
+
type Found = { cost: number; cells: Point[] };
|
|
91
|
+
|
|
92
|
+
/** Binary min-heap on cost + estimate, so each pop is O(log n) instead of a frontier scan. */
|
|
93
|
+
const heapPush = (heap: State[], state: State) => {
|
|
94
|
+
heap.push(state);
|
|
95
|
+
let index = heap.length - 1;
|
|
96
|
+
while (index > 0) {
|
|
97
|
+
const parent = (index - 1) >> 1;
|
|
98
|
+
if (heap[parent].f <= heap[index].f) {
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
[heap[parent], heap[index]] = [heap[index], heap[parent]];
|
|
102
|
+
index = parent;
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
const heapPop = (heap: State[]): State => {
|
|
106
|
+
const top = heap[0];
|
|
107
|
+
const last = heap.pop()!;
|
|
108
|
+
if (heap.length > 0) {
|
|
109
|
+
heap[0] = last;
|
|
110
|
+
let index = 0;
|
|
111
|
+
for (;;) {
|
|
112
|
+
const left = index * 2 + 1;
|
|
113
|
+
const right = left + 1;
|
|
114
|
+
let smallest = index;
|
|
115
|
+
if (left < heap.length && heap[left].f < heap[smallest].f) {
|
|
116
|
+
smallest = left;
|
|
117
|
+
}
|
|
118
|
+
if (right < heap.length && heap[right].f < heap[smallest].f) {
|
|
119
|
+
smallest = right;
|
|
120
|
+
}
|
|
121
|
+
if (smallest === index) {
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
[heap[smallest], heap[index]] = [heap[index], heap[smallest]];
|
|
125
|
+
index = smallest;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return top;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Fewest turns any path from (x, y) heading `dir` needs to reach the target: none while aligned
|
|
133
|
+
* and heading at it, two when heading away along its axis, otherwise one. Admissible, and since
|
|
134
|
+
* a turn costs as much as a thousand steps it is what makes the search converge on a diagram
|
|
135
|
+
* hundreds of cells across — distance alone leaves it exploring almost uniformly.
|
|
136
|
+
*/
|
|
137
|
+
const turnsNeeded = (x: number, y: number, dir: number, target: Point): number => {
|
|
138
|
+
const dx = target.x - x;
|
|
139
|
+
const dy = target.y - y;
|
|
140
|
+
if (dx !== 0 && dy !== 0) {
|
|
141
|
+
return 1;
|
|
142
|
+
}
|
|
143
|
+
if (dx === 0 && dy === 0) {
|
|
144
|
+
return 0;
|
|
145
|
+
}
|
|
146
|
+
const towards = dx !== 0 ? (dx > 0 ? 0 : 2) : dy > 0 ? 1 : 3;
|
|
147
|
+
return dir === towards ? 0 : (dir + 2) % 4 === towards ? 2 : 1;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const estimateFrom = (x: number, y: number, dir: number, target: Point): number =>
|
|
151
|
+
Math.abs(target.x - x) + Math.abs(target.y - y) + turnsNeeded(x, y, dir, target) * TURN_COST;
|
|
152
|
+
|
|
153
|
+
// Every cell in every heading, so a reachable target is never abandoned on a large diagram.
|
|
154
|
+
const budget = Math.max(50_000, (bounds.x1 - bounds.x0) * (bounds.y1 - bounds.y0) * 4);
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Bounded A* between two stub ends (grid coordinates). Successors already dominated by a
|
|
158
|
+
* settled state are pruned before pushing; undefined when the target is unreachable.
|
|
159
|
+
*/
|
|
160
|
+
const search = (source: Point, startDir: number, target: Point, endDir: number): Found | undefined => {
|
|
161
|
+
const open: State[] = [
|
|
162
|
+
{ x: source.x, y: source.y, dir: startDir, cost: 0, f: estimateFrom(source.x, source.y, startDir, target) },
|
|
163
|
+
];
|
|
164
|
+
generation++;
|
|
165
|
+
const settledAt = (index: number) => (settledGen[index] === generation ? settledCost[index] : undefined);
|
|
166
|
+
let found: State | undefined;
|
|
167
|
+
|
|
168
|
+
for (let iterations = 0; open.length > 0 && iterations < budget; iterations++) {
|
|
169
|
+
const current = heapPop(open);
|
|
170
|
+
if (current.x === target.x && current.y === target.y) {
|
|
171
|
+
found = current;
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
const stateIndex = cellIndex(current.x, current.y) * 4 + current.dir;
|
|
175
|
+
const seen = settledAt(stateIndex);
|
|
176
|
+
if (seen !== undefined && seen <= current.cost) {
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
settledGen[stateIndex] = generation;
|
|
180
|
+
settledCost[stateIndex] = current.cost;
|
|
181
|
+
|
|
182
|
+
for (let dir = 0; dir < 4; dir++) {
|
|
183
|
+
if ((dir + 2) % 4 === current.dir) {
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const x = current.x + DX[dir];
|
|
187
|
+
const y = current.y + DY[dir];
|
|
188
|
+
if (!inBounds(x, y)) {
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
const cell = cellIndex(x, y);
|
|
192
|
+
if (blockedGrid[cell] !== 0) {
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
const cost =
|
|
196
|
+
current.cost +
|
|
197
|
+
1 +
|
|
198
|
+
(dir === current.dir ? 0 : TURN_COST) +
|
|
199
|
+
(usedGrid[cell] !== 0 ? USED_COST : 0) +
|
|
200
|
+
// Entering the target off-axis forces one more bend at arrival; fold it in.
|
|
201
|
+
(x === target.x && y === target.y && dir !== endDir ? TURN_COST : 0);
|
|
202
|
+
const dominated = settledAt(cell * 4 + dir);
|
|
203
|
+
if (dominated !== undefined && dominated <= cost) {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
heapPush(open, { x, y, dir, cost, f: cost + estimateFrom(x, y, dir, target), prev: current });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (!found) {
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
const cells: Point[] = [];
|
|
214
|
+
for (let state: State | undefined = found; state; state = state.prev) {
|
|
215
|
+
cells.unshift({ x: state.x, y: state.y });
|
|
216
|
+
}
|
|
217
|
+
return { cost: found.cost, cells };
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
return (edge: RoutedRelation): Point[] => {
|
|
221
|
+
const { from, to, horizontal, ports } = edge;
|
|
222
|
+
// Flow-axis faces mirror the Z-router (and the port assignment in `emit`).
|
|
223
|
+
const sameLane = horizontal ? from.x === to.x : from.y === to.y;
|
|
224
|
+
const alongY = horizontal ? sameLane : !sameLane;
|
|
225
|
+
|
|
226
|
+
const down = to.y >= from.y;
|
|
227
|
+
const right = to.x >= from.x;
|
|
228
|
+
const flowStart: Terminal = alongY
|
|
229
|
+
? { point: { x: ports?.start ?? from.x + from.w / 2, y: down ? from.y + from.h : from.y }, dir: down ? 1 : 3 }
|
|
230
|
+
: { point: { x: right ? from.x + from.w : from.x, y: ports?.start ?? from.y + from.h / 2 }, dir: right ? 0 : 2 };
|
|
231
|
+
const flowEnd: Terminal = alongY
|
|
232
|
+
? { point: { x: ports?.end ?? to.x + to.w / 2, y: down ? to.y : to.y + to.h }, dir: down ? 1 : 3 }
|
|
233
|
+
: { point: { x: right ? to.x : to.x + to.w, y: ports?.end ?? to.y + to.h / 2 }, dir: right ? 0 : 2 };
|
|
234
|
+
// Cross-axis faces enable single-bend Ls when the displacement is diagonal — a fixed
|
|
235
|
+
// flow-face pair forces a Z between laterally offset nodes.
|
|
236
|
+
const crossStart: Terminal = alongY
|
|
237
|
+
? { point: { x: right ? from.x + from.w : from.x, y: from.y + from.h / 2 }, dir: right ? 0 : 2 }
|
|
238
|
+
: { point: { x: from.x + from.w / 2, y: down ? from.y + from.h : from.y }, dir: down ? 1 : 3 };
|
|
239
|
+
const crossEnd: Terminal = alongY
|
|
240
|
+
? { point: { x: right ? to.x : to.x + to.w, y: to.y + to.h / 2 }, dir: right ? 0 : 2 }
|
|
241
|
+
: { point: { x: to.x + to.w / 2, y: down ? to.y : to.y + to.h }, dir: down ? 1 : 3 };
|
|
242
|
+
|
|
243
|
+
const diagonal = alongY ? flowStart.point.x !== flowEnd.point.x : flowStart.point.y !== flowEnd.point.y;
|
|
244
|
+
const configurations: [Terminal, Terminal][] = diagonal
|
|
245
|
+
? [
|
|
246
|
+
[flowStart, flowEnd],
|
|
247
|
+
[crossStart, flowEnd],
|
|
248
|
+
[flowStart, crossEnd],
|
|
249
|
+
]
|
|
250
|
+
: [[flowStart, flowEnd]];
|
|
251
|
+
|
|
252
|
+
// Stubs step from the border past the clearance zone; A* runs between the stub ends.
|
|
253
|
+
// Rounded to whole cells: ports may sit on exact fractional coordinates (collision fallback),
|
|
254
|
+
// and a fractional goal cell would never match — the visible sub-cell offset lives in the
|
|
255
|
+
// first/last segment.
|
|
256
|
+
const stub = (point: Point, dir: number, out: boolean): Point => {
|
|
257
|
+
const sign = out ? 1 : -1;
|
|
258
|
+
return {
|
|
259
|
+
x: Math.round(point.x / STEP) + DX[dir] * (CLEARANCE + 1) * sign,
|
|
260
|
+
y: Math.round(point.y / STEP) + DY[dir] * (CLEARANCE + 1) * sign,
|
|
261
|
+
};
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
let start: Point | undefined;
|
|
265
|
+
let end: Point | undefined;
|
|
266
|
+
let best: Found | undefined;
|
|
267
|
+
for (const [startTerminal, endTerminal] of configurations) {
|
|
268
|
+
const result = search(
|
|
269
|
+
stub(startTerminal.point, startTerminal.dir, true),
|
|
270
|
+
startTerminal.dir,
|
|
271
|
+
stub(endTerminal.point, endTerminal.dir, false),
|
|
272
|
+
endTerminal.dir,
|
|
273
|
+
);
|
|
274
|
+
if (result && (!best || result.cost < best.cost)) {
|
|
275
|
+
best = result;
|
|
276
|
+
start = startTerminal.point;
|
|
277
|
+
end = endTerminal.point;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (!best || !start || !end) {
|
|
281
|
+
return fallback(edge);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Reconstruct and collapse collinear points; usage is marked from the FINAL path below, so
|
|
285
|
+
// the centering pass can distinguish foreign channels from this edge's own cells.
|
|
286
|
+
const points: Point[] = [start, ...best.cells.map((cell) => ({ x: cell.x * STEP, y: cell.y * STEP })), end];
|
|
287
|
+
const simplified: Point[] = [points[0]];
|
|
288
|
+
for (let index = 1; index < points.length - 1; index++) {
|
|
289
|
+
const previous = simplified[simplified.length - 1];
|
|
290
|
+
const next = points[index + 1];
|
|
291
|
+
const point = points[index];
|
|
292
|
+
const collinear =
|
|
293
|
+
(previous.x === point.x && point.x === next.x) || (previous.y === point.y && point.y === next.y);
|
|
294
|
+
if (!collinear) {
|
|
295
|
+
simplified.push(point);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
simplified.push(points[points.length - 1]);
|
|
299
|
+
|
|
300
|
+
// A single-jog Z centers its middle run equidistant from the two node borders — the A* path
|
|
301
|
+
// is turn-minimal but expansion order lets it hug one node. Keep the shift only if the
|
|
302
|
+
// adjusted segments avoid every obstacle AND every cell earlier edges already run through,
|
|
303
|
+
// so parallel channels stay separated.
|
|
304
|
+
const clearRun = (a: Point, b: Point): boolean => {
|
|
305
|
+
const ax = Math.round(a.x / STEP);
|
|
306
|
+
const ay = Math.round(a.y / STEP);
|
|
307
|
+
const bx = Math.round(b.x / STEP);
|
|
308
|
+
const by = Math.round(b.y / STEP);
|
|
309
|
+
const steps = Math.max(Math.abs(bx - ax), Math.abs(by - ay));
|
|
310
|
+
const dx = Math.sign(bx - ax);
|
|
311
|
+
const dy = Math.sign(by - ay);
|
|
312
|
+
for (let step = 0; step <= steps; step++) {
|
|
313
|
+
if (blocked(ax + dx * step, ay + dy * step) || isUsed(ax + dx * step, ay + dy * step)) {
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return true;
|
|
318
|
+
};
|
|
319
|
+
// Stub checks start past the terminal's own clearance zone, which is legitimately "blocked".
|
|
320
|
+
const past = (from: Point, to: Point): Point => ({
|
|
321
|
+
x: from.x + Math.sign(to.x - from.x) * (CLEARANCE + 1) * STEP,
|
|
322
|
+
y: from.y + Math.sign(to.y - from.y) * (CLEARANCE + 1) * STEP,
|
|
323
|
+
});
|
|
324
|
+
if (simplified.length === 4) {
|
|
325
|
+
const [p0, p1, p2, p3] = simplified;
|
|
326
|
+
if (p1.y === p2.y && p0.y !== p3.y) {
|
|
327
|
+
const mid = Math.round((p0.y + p3.y) / 2 / STEP) * STEP;
|
|
328
|
+
const shifted = [
|
|
329
|
+
{ x: p1.x, y: mid },
|
|
330
|
+
{ x: p2.x, y: mid },
|
|
331
|
+
];
|
|
332
|
+
if (
|
|
333
|
+
clearRun(shifted[0], shifted[1]) &&
|
|
334
|
+
clearRun(past(p0, shifted[0]), shifted[0]) &&
|
|
335
|
+
clearRun(shifted[1], past(p3, shifted[1]))
|
|
336
|
+
) {
|
|
337
|
+
simplified[1] = shifted[0];
|
|
338
|
+
simplified[2] = shifted[1];
|
|
339
|
+
}
|
|
340
|
+
} else if (p1.x === p2.x && p0.x !== p3.x) {
|
|
341
|
+
const mid = Math.round((p0.x + p3.x) / 2 / STEP) * STEP;
|
|
342
|
+
const shifted = [
|
|
343
|
+
{ x: mid, y: p1.y },
|
|
344
|
+
{ x: mid, y: p2.y },
|
|
345
|
+
];
|
|
346
|
+
if (
|
|
347
|
+
clearRun(shifted[0], shifted[1]) &&
|
|
348
|
+
clearRun(past(p0, shifted[0]), shifted[0]) &&
|
|
349
|
+
clearRun(shifted[1], past(p3, shifted[1]))
|
|
350
|
+
) {
|
|
351
|
+
simplified[1] = shifted[0];
|
|
352
|
+
simplified[2] = shifted[1];
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Mark the final path's cells so later edges route (and center) around it.
|
|
358
|
+
for (let index = 0; index < simplified.length - 1; index++) {
|
|
359
|
+
const a = simplified[index];
|
|
360
|
+
const b = simplified[index + 1];
|
|
361
|
+
const ax = Math.round(a.x / STEP);
|
|
362
|
+
const ay = Math.round(a.y / STEP);
|
|
363
|
+
const steps = Math.max(Math.abs(Math.round(b.x / STEP) - ax), Math.abs(Math.round(b.y / STEP) - ay));
|
|
364
|
+
const dx = Math.sign(b.x - a.x);
|
|
365
|
+
const dy = Math.sign(b.y - a.y);
|
|
366
|
+
for (let step = 0; step <= steps; step++) {
|
|
367
|
+
markUsed(ax + dx * step, ay + dy * step);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return simplified;
|
|
371
|
+
};
|
|
372
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as Schema from 'effect/Schema';
|
|
6
|
+
import { describe, test } from 'vitest';
|
|
7
|
+
|
|
8
|
+
import { Element, formatRef, parseRef, resolveRef } from './scene.ts';
|
|
9
|
+
|
|
10
|
+
describe('scene refs', () => {
|
|
11
|
+
test('parses every ref form', ({ expect }) => {
|
|
12
|
+
expect(parseRef('box')).toEqual({ element: 'box' });
|
|
13
|
+
expect(parseRef('face/box')).toEqual({ object: 'face', element: 'box' });
|
|
14
|
+
expect(parseRef('box#east')).toEqual({ element: 'box', port: 'east' });
|
|
15
|
+
expect(parseRef('face/box#east')).toEqual({ object: 'face', element: 'box', port: 'east' });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('formatRef inverts parseRef', ({ expect }) => {
|
|
19
|
+
for (const ref of ['box', 'face/box', 'box#east', 'face/box#east']) {
|
|
20
|
+
expect(formatRef(parseRef(ref))).toBe(ref);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('resolveRef yields the object/element handle without the port', ({ expect }) => {
|
|
25
|
+
expect(resolveRef('box', 'face')).toBe('face/box');
|
|
26
|
+
expect(resolveRef('box#east', 'face')).toBe('face/box');
|
|
27
|
+
expect(resolveRef('hat/brim#south', 'face')).toBe('hat/brim');
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('scene elements', () => {
|
|
32
|
+
test('accepts a portal', ({ expect }) => {
|
|
33
|
+
const portal = { kind: 'portal', id: 'child', x: 0, y: 0, w: 128, h: 96, ref: 'dxn:echo:@:abc', text: 'Child' };
|
|
34
|
+
expect(Schema.decodeUnknownSync(Element)(portal)).toEqual(portal);
|
|
35
|
+
});
|
|
36
|
+
});
|