@openpowershift/logic-diagram-language 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +139 -0
  3. package/dist/examples.d.ts +2 -0
  4. package/dist/examples.js +469 -0
  5. package/dist/export-image.d.ts +10 -0
  6. package/dist/export-image.js +47 -0
  7. package/dist/index.d.ts +10 -0
  8. package/dist/index.html +13 -0
  9. package/dist/index.js +18 -0
  10. package/dist/parser/ast.d.ts +118 -0
  11. package/dist/parser/ast.js +79 -0
  12. package/dist/parser/index.d.ts +3 -0
  13. package/dist/parser/index.js +2 -0
  14. package/dist/parser/parser.d.ts +2 -0
  15. package/dist/parser/parser.js +635 -0
  16. package/dist/renderer/astar-router.d.ts +16 -0
  17. package/dist/renderer/astar-router.js +532 -0
  18. package/dist/renderer/gates.d.ts +19 -0
  19. package/dist/renderer/gates.js +92 -0
  20. package/dist/renderer/graph.d.ts +31 -0
  21. package/dist/renderer/graph.js +403 -0
  22. package/dist/renderer/layout.d.ts +76 -0
  23. package/dist/renderer/layout.js +3121 -0
  24. package/dist/renderer/math-renderer.d.ts +16 -0
  25. package/dist/renderer/math-renderer.js +119 -0
  26. package/dist/renderer/svg-renderer.d.ts +3 -0
  27. package/dist/renderer/svg-renderer.js +599 -0
  28. package/dist/renderer/wires.d.ts +5 -0
  29. package/dist/renderer/wires.js +12 -0
  30. package/dist/theme/themes.d.ts +58 -0
  31. package/dist/theme/themes.js +104 -0
  32. package/docs/api.adoc +196 -0
  33. package/docs/ldl-for-llms.md +163 -0
  34. package/docs/user-guide.adoc +318 -0
  35. package/package.json +78 -0
  36. package/spec/render.sh +22 -0
  37. package/spec/sections/attributes.adoc +80 -0
  38. package/spec/sections/connections.adoc +39 -0
  39. package/spec/sections/examples.adoc +212 -0
  40. package/spec/sections/expressions.adoc +182 -0
  41. package/spec/sections/file-extension.adoc +5 -0
  42. package/spec/sections/function-blocks.adoc +120 -0
  43. package/spec/sections/grammar.adoc +64 -0
  44. package/spec/sections/hyperlinks.adoc +18 -0
  45. package/spec/sections/introduction.adoc +16 -0
  46. package/spec/sections/layout-rules.adoc +491 -0
  47. package/spec/sections/lexical-conventions.adoc +68 -0
  48. package/spec/sections/objects.adoc +31 -0
  49. package/spec/sections/options.adoc +146 -0
  50. package/spec/sections/ports.adoc +77 -0
  51. package/spec/sections/rendering-contract.adoc +11 -0
  52. package/spec/sections/revision-history.adoc +9 -0
  53. package/spec/sections/styling.adoc +83 -0
  54. package/spec/sections/svg-symbol-specification.adoc +149 -0
  55. package/spec/sections/symbol-definitions.adoc +143 -0
  56. package/spec/sections/templates.adoc +31 -0
  57. package/spec/spec.adoc +49 -0
@@ -0,0 +1,532 @@
1
+ const CELL_SIZE = 5;
2
+ const BLOCKED_COST = 1e7;
3
+ const WIRE_CROSS_COST = 8;
4
+ const WIRE_PROXIMITY_COST = 3; // soft cost up to PROXIMITY_RADIUS cells from other-source wires
5
+ const PROXIMITY_RADIUS = 2; // spread parallel wires into separate tracks (~10px apart)
6
+ const SAME_SOURCE_BONUS = -8; // makes overlapping a same-source trunk free, so fan-out shares one trunk
7
+ const WRONG_SIDE_COST = 30;
8
+ const BEND_PENALTY = 4; // tuned so the optimum is a straight line or a single clean Z
9
+ const GATE_BUFFER_RATIO = 0.2;
10
+ const GATE_BUFFER_MIN = 10; // absolute min horizontal clearance (px) wires keep from a gate body
11
+ // Larger vertical clearance: a horizontal wire passing above/below a gate stays >= 2x the
12
+ // wire gap off its top/bottom edge, so it never looks visually crammed against the body.
13
+ const GATE_BUFFER_MIN_Y = 20;
14
+ class MinHeap {
15
+ constructor() {
16
+ this.data = [];
17
+ }
18
+ push(item) {
19
+ this.data.push(item);
20
+ this._bubbleUp(this.data.length - 1);
21
+ }
22
+ pop() {
23
+ if (this.data.length === 0)
24
+ return undefined;
25
+ const top = this.data[0];
26
+ const last = this.data.pop();
27
+ if (this.data.length > 0) {
28
+ this.data[0] = last;
29
+ this._sinkDown(0);
30
+ }
31
+ return top;
32
+ }
33
+ get size() {
34
+ return this.data.length;
35
+ }
36
+ _bubbleUp(i) {
37
+ while (i > 0) {
38
+ const parent = (i - 1) >> 1;
39
+ if (this.data[parent].f <= this.data[i].f)
40
+ break;
41
+ [this.data[parent], this.data[i]] = [this.data[i], this.data[parent]];
42
+ i = parent;
43
+ }
44
+ }
45
+ _sinkDown(i) {
46
+ const n = this.data.length;
47
+ while (true) {
48
+ let smallest = i;
49
+ const left = 2 * i + 1;
50
+ const right = 2 * i + 2;
51
+ if (left < n && this.data[left].f < this.data[smallest].f)
52
+ smallest = left;
53
+ if (right < n && this.data[right].f < this.data[smallest].f)
54
+ smallest = right;
55
+ if (smallest === i)
56
+ break;
57
+ [this.data[smallest], this.data[i]] = [this.data[i], this.data[smallest]];
58
+ i = smallest;
59
+ }
60
+ }
61
+ }
62
+ const DIRS = [
63
+ { dx: 0, dy: -1 },
64
+ { dx: 1, dy: 0 },
65
+ { dx: 0, dy: 1 },
66
+ { dx: -1, dy: 0 },
67
+ ];
68
+ function toGrid(v) {
69
+ return Math.round(v / CELL_SIZE);
70
+ }
71
+ function toCanvas(v) {
72
+ return v * CELL_SIZE;
73
+ }
74
+ function rasterizeRect(grid, gridW, gridH, x, y, w, h, cost, bufferX, bufferY, ox = 0, oy = 0) {
75
+ const x0 = Math.max(0, toGrid(x - bufferX) - ox);
76
+ const y0 = Math.max(0, toGrid(y - bufferY) - oy);
77
+ const x1 = Math.min(gridW - 1, toGrid(x + w + bufferX) - ox);
78
+ const y1 = Math.min(gridH - 1, toGrid(y + h + bufferY) - oy);
79
+ for (let gy = y0; gy <= y1; gy++) {
80
+ for (let gx = x0; gx <= x1; gx++) {
81
+ const idx = gy * gridW + gx;
82
+ if (cost > grid[idx])
83
+ grid[idx] = cost;
84
+ }
85
+ }
86
+ }
87
+ function setCellCost(grid, gridW, gridH, gx, gy, cost) {
88
+ if (gx >= 0 && gx < gridW && gy >= 0 && gy < gridH) {
89
+ const idx = gy * gridW + gx;
90
+ if (cost > grid[idx] && grid[idx] < BLOCKED_COST)
91
+ grid[idx] = cost;
92
+ }
93
+ }
94
+ function rasterizeWireSegments(grid, gridW, gridH, segments, sameSourceFromId, ox = 0, oy = 0) {
95
+ for (const seg of segments) {
96
+ const isSameSource = sameSourceFromId !== undefined && seg.fromId === sameSourceFromId;
97
+ const crossCost = isSameSource ? Math.max(0, WIRE_CROSS_COST + SAME_SOURCE_BONUS) : WIRE_CROSS_COST;
98
+ const proxityCost = isSameSource ? 0 : WIRE_PROXIMITY_COST;
99
+ const pts = seg.points;
100
+ for (let i = 0; i < pts.length - 1; i++) {
101
+ const p0 = pts[i], p1 = pts[i + 1];
102
+ if (Math.abs(p0.y - p1.y) < 1) {
103
+ const y = toGrid(p0.y) - oy;
104
+ const gx0 = toGrid(Math.min(p0.x, p1.x)) - ox;
105
+ const gx1 = toGrid(Math.max(p0.x, p1.x)) - ox;
106
+ for (let gx = gx0; gx <= gx1; gx++) {
107
+ setCellCost(grid, gridW, gridH, gx, y, crossCost);
108
+ for (let r = 1; r <= PROXIMITY_RADIUS && proxityCost > 0; r++) {
109
+ setCellCost(grid, gridW, gridH, gx, y - r, proxityCost);
110
+ setCellCost(grid, gridW, gridH, gx, y + r, proxityCost);
111
+ }
112
+ }
113
+ }
114
+ else if (Math.abs(p0.x - p1.x) < 1) {
115
+ const x = toGrid(p0.x) - ox;
116
+ const gy0 = toGrid(Math.min(p0.y, p1.y)) - oy;
117
+ const gy1 = toGrid(Math.max(p0.y, p1.y)) - oy;
118
+ for (let gy = gy0; gy <= gy1; gy++) {
119
+ setCellCost(grid, gridW, gridH, x, gy, crossCost);
120
+ for (let r = 1; r <= PROXIMITY_RADIUS && proxityCost > 0; r++) {
121
+ setCellCost(grid, gridW, gridH, x - r, gy, proxityCost);
122
+ setCellCost(grid, gridW, gridH, x + r, gy, proxityCost);
123
+ }
124
+ }
125
+ }
126
+ }
127
+ }
128
+ }
129
+ function rasterizeWrongSideZone(grid, gridW, gridH, gateX, gateY, gateW, gateH, portX, ox = 0, oy = 0) {
130
+ const x0 = Math.max(0, toGrid(portX) - ox);
131
+ const x1 = Math.min(gridW - 1, toGrid(gateX + gateW) - ox);
132
+ const y0 = Math.max(0, toGrid(gateY) - oy);
133
+ const y1 = Math.min(gridH - 1, toGrid(gateY + gateH) - oy);
134
+ for (let gy = y0; gy <= y1; gy++) {
135
+ for (let gx = x0; gx <= x1; gx++) {
136
+ const idx = gy * gridW + gx;
137
+ if (WRONG_SIDE_COST > grid[idx])
138
+ grid[idx] = WRONG_SIDE_COST;
139
+ }
140
+ }
141
+ }
142
+ function simplifyPath(path) {
143
+ if (path.length <= 2)
144
+ return path;
145
+ const result = [path[0]];
146
+ let prevDx = path[1].x - path[0].x;
147
+ let prevDy = path[1].y - path[0].y;
148
+ for (let i = 1; i < path.length - 1; i++) {
149
+ const dx = path[i + 1].x - path[i].x;
150
+ const dy = path[i + 1].y - path[i].y;
151
+ if (dx !== prevDx || dy !== prevDy) {
152
+ result.push(path[i]);
153
+ prevDx = dx;
154
+ prevDy = dy;
155
+ }
156
+ }
157
+ result.push(path[path.length - 1]);
158
+ return result;
159
+ }
160
+ // A straight horizontal wire is allowed unless it actually passes through a gate
161
+ // BODY (plus a small margin). The 20% routing buffer is intentionally NOT used here:
162
+ // a wire running clear of a gate should stay straight rather than be forced into A*.
163
+ function lineHitsObstacle(y, x1, x2, obstacles, sourceGateX, sourceGateY, destGateX, destGateY) {
164
+ const xMin = Math.min(x1, x2);
165
+ const xMax = Math.max(x1, x2);
166
+ const m = 3; // horizontal margin (just clear the gate's left/right edge)
167
+ const my = GATE_BUFFER_MIN_Y; // vertical clearance (keep off the gate's top/bottom edge)
168
+ for (const obs of obstacles) {
169
+ if (obs.x === sourceGateX && obs.y === sourceGateY)
170
+ continue;
171
+ if (obs.x === destGateX && obs.y === destGateY)
172
+ continue;
173
+ // An obstacle entirely left of the wire's start (or right of its end) cannot lie on this
174
+ // rightward horizontal run — skip it before applying the margin, so a same-column input
175
+ // (whose right edge meets the wire's start X) never falsely blocks its neighbour's wire.
176
+ if (obs.x + obs.w <= xMin + 0.5 || obs.x >= xMax - 0.5)
177
+ continue;
178
+ if (rectsOverlap(xMin, y - 1, xMax - xMin, 2, obs.x - m, obs.y - my, obs.w + m * 2, obs.h + my * 2, 0)) {
179
+ return true;
180
+ }
181
+ }
182
+ return false;
183
+ }
184
+ function rectsOverlap(x1, y1, w1, h1, x2, y2, w2, h2, pad) {
185
+ return x1 + w1 + pad > x2 && x2 + w2 + pad > x1 &&
186
+ y1 + h1 + pad > y2 && y2 + h2 + pad > y1;
187
+ }
188
+ // True if the axis-aligned segment passes through any gate body (plus the routing buffer),
189
+ // excluding the wire's own source and destination gates.
190
+ function segHitsObstacle(ax, ay, bx, by, obstacles, sgx, sgy, dgx, dgy) {
191
+ const xMin = Math.min(ax, bx), yMin = Math.min(ay, by);
192
+ const xMax = Math.max(ax, bx);
193
+ const w = Math.abs(bx - ax) + 1, h = Math.abs(by - ay) + 1;
194
+ for (const o of obstacles) {
195
+ if (o.x === sgx && o.y === sgy)
196
+ continue;
197
+ if (o.x === dgx && o.y === dgy)
198
+ continue;
199
+ // An obstacle whose BODY is entirely left of the segment's start (or right of its end) can't lie
200
+ // on the segment — only its buffer would reach in. Don't let that buffer block: it's what makes
201
+ // a wire's exit-stub past its same-column neighbour (e.g. the input stacked just below the
202
+ // source) impossible. (Mirrors the straight-line fast-path check.)
203
+ if (o.x + o.w <= xMin + 0.5 || o.x >= xMax - 0.5)
204
+ continue;
205
+ const bufX = Math.max(GATE_BUFFER_MIN, Math.ceil(o.w * GATE_BUFFER_RATIO));
206
+ const bufY = Math.max(GATE_BUFFER_MIN_Y, Math.ceil(o.h * GATE_BUFFER_RATIO));
207
+ if (rectsOverlap(xMin - 0.5, yMin - 0.5, w, h, o.x - bufX, o.y - bufY, o.w + 2 * bufX, o.h + 2 * bufY, 0))
208
+ return true;
209
+ }
210
+ return false;
211
+ }
212
+ // True if the axis-aligned segment crosses (or runs along) a routed wire from a different
213
+ // source. Same-source overlaps are allowed (shared trunks).
214
+ function segCrossesWire(ax, ay, bx, by, routed, fromId) {
215
+ const horiz = Math.abs(ay - by) < 0.5;
216
+ const axMin = Math.min(ax, bx), axMax = Math.max(ax, bx);
217
+ const ayMin = Math.min(ay, by), ayMax = Math.max(ay, by);
218
+ for (const s of routed) {
219
+ if (fromId !== undefined && s.fromId === fromId)
220
+ continue;
221
+ for (let i = 0; i < s.points.length - 1; i++) {
222
+ const p = s.points[i], q = s.points[i + 1];
223
+ const sHoriz = Math.abs(p.y - q.y) < 0.5, sVert = Math.abs(p.x - q.x) < 0.5;
224
+ const sxMin = Math.min(p.x, q.x), sxMax = Math.max(p.x, q.x);
225
+ const syMin = Math.min(p.y, q.y), syMax = Math.max(p.y, q.y);
226
+ if (horiz && sVert) {
227
+ if (p.x >= axMin - 0.5 && p.x <= axMax + 0.5 && ay >= syMin - 0.5 && ay <= syMax + 0.5)
228
+ return true;
229
+ }
230
+ else if (!horiz && sHoriz) {
231
+ if (ax >= sxMin - 0.5 && ax <= sxMax + 0.5 && p.y >= ayMin - 0.5 && p.y <= ayMax + 0.5)
232
+ return true;
233
+ }
234
+ else if (horiz && sHoriz && Math.abs(ay - p.y) < 0.5) {
235
+ if (axMax > sxMin + 0.5 && axMin < sxMax - 0.5)
236
+ return true;
237
+ }
238
+ else if (!horiz && sVert && Math.abs(ax - p.x) < 0.5) {
239
+ if (ayMax > syMin + 0.5 && ayMin < syMax - 0.5)
240
+ return true;
241
+ }
242
+ }
243
+ }
244
+ return false;
245
+ }
246
+ // True if a channel vertical at `cx` (spanning vy0..vy1) would run within `minGap` of another
247
+ // net's vertical that overlaps it in Y — i.e. they'd read as one cramped bundle. Used so the
248
+ // clean-Z fast path spreads parallel verticals into separate tracks (matching A*'s proximity
249
+ // cost, which the fast path would otherwise bypass).
250
+ function verticalTooClose(cx, vy0, vy1, routed, fromId, minGap) {
251
+ const vyMin = Math.min(vy0, vy1), vyMax = Math.max(vy0, vy1);
252
+ for (const s of routed) {
253
+ if (fromId !== undefined && s.fromId === fromId)
254
+ continue;
255
+ for (let i = 0; i < s.points.length - 1; i++) {
256
+ const p = s.points[i], q = s.points[i + 1];
257
+ if (Math.abs(p.x - q.x) < 0.5 && Math.abs(p.x - cx) > 0.5 && Math.abs(p.x - cx) < minGap &&
258
+ Math.max(p.y, q.y) > vyMin - 0.5 && Math.min(p.y, q.y) < vyMax + 0.5)
259
+ return true;
260
+ }
261
+ }
262
+ return false;
263
+ }
264
+ // Try to connect source→dest with a clean Z (horizontal, vertical channel, horizontal)
265
+ // without a grid search: cheap, and the common case. Returns null if no clear channel is
266
+ // found (then the caller falls back to A*). The balanced-Z pass later re-centres the channel.
267
+ function tryCleanZ(sx, sy, dx, dy, obstacles, sgx, sgy, dgx, dgy, routed, fromId) {
268
+ if (dx <= sx + 2 * CELL_SIZE)
269
+ return null; // need forward room; let A* handle the rest
270
+ const mid = Math.round((sx + dx) / 2 / CELL_SIZE) * CELL_SIZE;
271
+ // Search the whole span between source and dest (ordered from the midpoint outward), not
272
+ // just a narrow band. When many wires fan into one gate they each need a *distinct* clean
273
+ // channel; a wide search finds one per wire and avoids an expensive grid search each.
274
+ const cands = [mid];
275
+ const maxOff = Math.max(6 * CELL_SIZE, dx - sx);
276
+ for (let off = CELL_SIZE; off <= maxOff; off += CELL_SIZE) {
277
+ cands.push(mid + off);
278
+ cands.push(mid - off);
279
+ }
280
+ // Two passes: first prefer a channel that also keeps clear of other nets' parallel
281
+ // verticals (so wires spread into separate, readable tracks); if none exists, accept any
282
+ // obstacle- and crossing-free channel rather than fall back to an expensive grid search.
283
+ const SPREAD = 3 * CELL_SIZE;
284
+ for (const requireSpread of [true, false]) {
285
+ for (const cx of cands) {
286
+ if (cx <= sx + CELL_SIZE || cx >= dx)
287
+ continue;
288
+ if (segHitsObstacle(sx, sy, cx, sy, obstacles, sgx, sgy, dgx, dgy))
289
+ continue;
290
+ if (segHitsObstacle(cx, sy, cx, dy, obstacles, sgx, sgy, dgx, dgy))
291
+ continue;
292
+ if (segHitsObstacle(cx, dy, dx, dy, obstacles, sgx, sgy, dgx, dgy))
293
+ continue;
294
+ if (segCrossesWire(sx, sy, cx, sy, routed, fromId))
295
+ continue;
296
+ if (segCrossesWire(cx, sy, cx, dy, routed, fromId))
297
+ continue;
298
+ if (segCrossesWire(cx, dy, dx, dy, routed, fromId))
299
+ continue;
300
+ if (requireSpread && verticalTooClose(cx, sy, dy, routed, fromId, SPREAD))
301
+ continue;
302
+ return [{ x: sx, y: sy }, { x: cx, y: sy }, { x: cx, y: dy }, { x: dx, y: dy }];
303
+ }
304
+ }
305
+ return null;
306
+ }
307
+ // Drop duplicate and colinear vertices so the path is a minimal list of corners.
308
+ function cleanColinear(pts) {
309
+ if (pts.length <= 2)
310
+ return pts;
311
+ const out = [pts[0]];
312
+ for (let i = 1; i < pts.length; i++) {
313
+ const cur = pts[i];
314
+ const prev = out[out.length - 1];
315
+ if (Math.abs(cur.x - prev.x) < 1 && Math.abs(cur.y - prev.y) < 1)
316
+ continue; // duplicate
317
+ if (out.length >= 2) {
318
+ const p2 = out[out.length - 2];
319
+ const colinH = Math.abs(p2.y - prev.y) < 1 && Math.abs(prev.y - cur.y) < 1;
320
+ const colinV = Math.abs(p2.x - prev.x) < 1 && Math.abs(prev.x - cur.x) < 1;
321
+ if (colinH || colinV) {
322
+ out[out.length - 1] = cur;
323
+ continue;
324
+ }
325
+ }
326
+ out.push(cur);
327
+ }
328
+ return out;
329
+ }
330
+ /**
331
+ * Convert a grid-aligned A* corner path to canvas coordinates with EXACT port
332
+ * endpoints, guaranteeing every segment is horizontal or vertical and that the
333
+ * path always begins at the source and ends at the destination.
334
+ *
335
+ * Interior corners come straight from the grid path (already on the 5px grid).
336
+ * The exact endpoints replace the first/last grid points; if that leaves a
337
+ * diagonal segment at an endpoint we insert a single orthogonal corner. The last
338
+ * segment is made horizontal so wires enter their destination port from the side.
339
+ */
340
+ // Orthogonal fallback between two points when no routed path is available: a straight line if
341
+ // they share a Y, otherwise a clean Z (exit and enter horizontally). NEVER a diagonal — an
342
+ // orthogonal route that may clip an obstacle is always preferable to a non-orthogonal segment.
343
+ // When obstacles are supplied, the Z's bend X is chosen (searching from the midpoint outward) so
344
+ // the three segments clear all gate bodies where possible — only clipping as an absolute last resort.
345
+ function orthFallback(sourceX, sourceY, destX, destY, obstacles, sgx = 0, sgy = 0, dgx = 0, dgy = 0) {
346
+ if (Math.abs(sourceY - destY) < 1)
347
+ return [{ x: sourceX, y: sourceY }, { x: destX, y: destY }];
348
+ const z = (mx) => [
349
+ { x: sourceX, y: sourceY }, { x: mx, y: sourceY }, { x: mx, y: destY }, { x: destX, y: destY },
350
+ ];
351
+ const mid = Math.round((sourceX + destX) / 2 / CELL_SIZE) * CELL_SIZE;
352
+ if (obstacles) {
353
+ const lo = Math.min(sourceX, destX), hi = Math.max(sourceX, destX);
354
+ const cands = [mid];
355
+ for (let off = CELL_SIZE; off <= Math.abs(destX - sourceX); off += CELL_SIZE) {
356
+ cands.push(mid + off);
357
+ cands.push(mid - off);
358
+ }
359
+ for (const mx of cands) {
360
+ if (mx <= lo + CELL_SIZE || mx >= hi)
361
+ continue;
362
+ if (segHitsObstacle(sourceX, sourceY, mx, sourceY, obstacles, sgx, sgy, dgx, dgy))
363
+ continue;
364
+ if (segHitsObstacle(mx, sourceY, mx, destY, obstacles, sgx, sgy, dgx, dgy))
365
+ continue;
366
+ if (segHitsObstacle(mx, destY, destX, destY, obstacles, sgx, sgy, dgx, dgy))
367
+ continue;
368
+ return z(mx);
369
+ }
370
+ }
371
+ return z(mid);
372
+ }
373
+ function orthogonalize(gridPath, sourceX, sourceY, destX, destY) {
374
+ if (gridPath.length <= 1) {
375
+ return orthFallback(sourceX, sourceY, destX, destY);
376
+ }
377
+ const pts = gridPath.map(p => ({ x: toCanvas(p.x), y: toCanvas(p.y) }));
378
+ pts[0] = { x: sourceX, y: sourceY };
379
+ pts[pts.length - 1] = { x: destX, y: destY };
380
+ const out = [pts[0]];
381
+ for (let i = 1; i < pts.length; i++) {
382
+ const prev = out[out.length - 1];
383
+ const cur = pts[i];
384
+ const diagX = Math.abs(cur.x - prev.x) >= 1;
385
+ const diagY = Math.abs(cur.y - prev.y) >= 1;
386
+ if (diagX && diagY) {
387
+ // Insert one corner to keep the segment orthogonal. For the final segment,
388
+ // corner vertically first so the wire enters the dest port horizontally;
389
+ // otherwise corner horizontally first (exit/travel along rows).
390
+ if (i === pts.length - 1)
391
+ out.push({ x: prev.x, y: cur.y });
392
+ else
393
+ out.push({ x: cur.x, y: prev.y });
394
+ }
395
+ out.push(cur);
396
+ }
397
+ return cleanColinear(out);
398
+ }
399
+ export function routeWireAStar(sourceX, sourceY, destX, destY, obstacles, sourceGateX, sourceGateY, sourceGateW, sourceGateH, destGateX, destGateY, destGateW, destGateH, destIsGate, routedSegments, canvasW, canvasH, sameSourceFromId) {
400
+ // Fast path: straight line when Y is same and no obstacle blocks
401
+ if (Math.abs(sourceY - destY) < 1) {
402
+ if (!lineHitsObstacle(sourceY, sourceX, destX, obstacles, sourceGateX, sourceGateY, destGateX, destGateY)) {
403
+ return [{ x: sourceX, y: sourceY }, { x: destX, y: destY }];
404
+ }
405
+ }
406
+ // Fast path: a clean Z-route avoiding gate bodies and other nets. Skips the grid search
407
+ // for the common case (no obstacle between source and destination), which keeps layout
408
+ // time low on large diagrams. A* is only used when no clear channel exists.
409
+ const zPath = tryCleanZ(sourceX, sourceY, destX, destY, obstacles, sourceGateX, sourceGateY, destGateX, destGateY, routedSegments, sameSourceFromId);
410
+ if (zPath)
411
+ return zPath;
412
+ // Grid A* over a region [oGX,oGY] (grid-cell origin) of size gW x gH cells. Returns the
413
+ // routed path, or null if the goal is unreachable within the region. Reconstructed points
414
+ // are in GLOBAL grid coordinates so orthogonalize() maps them straight to canvas pixels.
415
+ const NODE_FIELDS = 5;
416
+ const solve = (oGX, oGY, gW, gH) => {
417
+ const startX = toGrid(sourceX) - oGX, startY = toGrid(sourceY) - oGY;
418
+ const goalX = toGrid(destX) - oGX, goalY = toGrid(destY) - oGY;
419
+ if (startX < 0 || startX >= gW || startY < 0 || startY >= gH)
420
+ return null;
421
+ if (goalX < 0 || goalX >= gW || goalY < 0 || goalY >= gH)
422
+ return null;
423
+ const gridSize = gW * gH;
424
+ const grid = new Float32Array(gridSize);
425
+ grid.fill(1);
426
+ for (const obs of obstacles) {
427
+ const isSource = obs.x === sourceGateX && obs.y === sourceGateY;
428
+ const isDest = obs.x === destGateX && obs.y === destGateY;
429
+ const bufferX = Math.max(GATE_BUFFER_MIN, Math.ceil(obs.w * GATE_BUFFER_RATIO));
430
+ const bufferY = Math.max(GATE_BUFFER_MIN_Y, Math.ceil(obs.h * GATE_BUFFER_RATIO));
431
+ if (isSource || isDest)
432
+ rasterizeRect(grid, gW, gH, obs.x, obs.y, obs.w, obs.h, BLOCKED_COST, 0, 0, oGX, oGY);
433
+ else
434
+ rasterizeRect(grid, gW, gH, obs.x, obs.y, obs.w, obs.h, BLOCKED_COST, bufferX, bufferY, oGX, oGY);
435
+ }
436
+ rasterizeWireSegments(grid, gW, gH, routedSegments, sameSourceFromId, oGX, oGY);
437
+ if (destIsGate)
438
+ rasterizeWrongSideZone(grid, gW, gH, destGateX, destGateY, destGateW, destGateH, destX, oGX, oGY);
439
+ // Clear corridors at the ports and around start/goal so A* can always exit/enter.
440
+ for (let gx = startX; gx <= toGrid(sourceGateX + sourceGateW + 5) - oGX; gx++) {
441
+ if (gx >= 0 && gx < gW && startY >= 0 && startY < gH)
442
+ grid[startY * gW + gx] = 1;
443
+ }
444
+ for (let gx = Math.max(0, toGrid(destGateX - 5) - oGX); gx <= goalX; gx++) {
445
+ if (gx >= 0 && gx < gW && goalY >= 0 && goalY < gH)
446
+ grid[goalY * gW + gx] = 1;
447
+ }
448
+ for (const [px, py] of [[startX - 1, startY], [startX + 1, startY], [startX, startY - 1], [startX, startY + 1], [goalX - 1, goalY], [goalX + 1, goalY], [goalX, goalY - 1], [goalX, goalY + 1]]) {
449
+ if (px >= 0 && px < gW && py >= 0 && py < gH)
450
+ grid[py * gW + px] = 1;
451
+ }
452
+ grid[startY * gW + startX] = 1;
453
+ grid[goalY * gW + goalX] = 1;
454
+ const nodes = new Float64Array(gridSize * NODE_FIELDS);
455
+ const closedFlags = new Uint8Array(gridSize);
456
+ for (let i = 0; i < gridSize; i++)
457
+ nodes[i * NODE_FIELDS] = Infinity;
458
+ const startIdx = startY * gW + startX;
459
+ nodes[startIdx * NODE_FIELDS] = 0;
460
+ nodes[startIdx * NODE_FIELDS + 1] = Math.abs(goalX - startX) + Math.abs(goalY - startY);
461
+ nodes[startIdx * NODE_FIELDS + 4] = -1;
462
+ const openHeap = new MinHeap();
463
+ openHeap.push({ f: Math.abs(goalX - startX) + Math.abs(goalY - startY), x: startX, y: startY });
464
+ while (openHeap.size > 0) {
465
+ const current = openHeap.pop();
466
+ const cx = current.x, cy = current.y;
467
+ const cIdx = cy * gW + cx;
468
+ if (closedFlags[cIdx])
469
+ continue;
470
+ closedFlags[cIdx] = 1;
471
+ if (cx === goalX && cy === goalY)
472
+ break;
473
+ const currentG = nodes[cIdx * NODE_FIELDS];
474
+ const currentDir = nodes[cIdx * NODE_FIELDS + 4];
475
+ for (let d = 0; d < 4; d++) {
476
+ const nx = cx + DIRS[d].dx;
477
+ const ny = cy + DIRS[d].dy;
478
+ if (nx < 0 || nx >= gW || ny < 0 || ny >= gH)
479
+ continue;
480
+ const nIdx = ny * gW + nx;
481
+ if (closedFlags[nIdx])
482
+ continue;
483
+ const cellCost = grid[nIdx];
484
+ if (cellCost >= BLOCKED_COST)
485
+ continue;
486
+ let moveCost = 1 + cellCost;
487
+ if (currentDir >= 0 && currentDir !== d)
488
+ moveCost += BEND_PENALTY;
489
+ const newG = currentG + moveCost;
490
+ const nGOffset = nIdx * NODE_FIELDS;
491
+ if (newG < nodes[nGOffset]) {
492
+ nodes[nGOffset] = newG;
493
+ nodes[nIdx * NODE_FIELDS + 1] = newG + Math.abs(goalX - nx) + Math.abs(goalY - ny);
494
+ nodes[nIdx * NODE_FIELDS + 2] = cx;
495
+ nodes[nIdx * NODE_FIELDS + 3] = cy;
496
+ nodes[nIdx * NODE_FIELDS + 4] = d;
497
+ openHeap.push({ f: nodes[nIdx * NODE_FIELDS + 1], x: nx, y: ny });
498
+ }
499
+ }
500
+ }
501
+ const goalIdx = goalY * gW + goalX;
502
+ if (nodes[goalIdx * NODE_FIELDS] === Infinity)
503
+ return null;
504
+ const gridPath = [];
505
+ let cx = goalX, cy = goalY;
506
+ while (cx !== startX || cy !== startY) {
507
+ gridPath.push({ x: cx + oGX, y: cy + oGY });
508
+ const idx = cy * gW + cx;
509
+ cx = nodes[idx * NODE_FIELDS + 2];
510
+ cy = nodes[idx * NODE_FIELDS + 3];
511
+ }
512
+ gridPath.push({ x: startX + oGX, y: startY + oGY });
513
+ gridPath.reverse();
514
+ return orthogonalize(simplifyPath(gridPath), sourceX, sourceY, destX, destY);
515
+ };
516
+ // Try a region bounded to the source/dest bounding box (plus margin for detours) first —
517
+ // this keeps grid allocation and search proportional to the wire, not the whole canvas.
518
+ // Fall back to the full canvas if no path is found in the bounded region.
519
+ const fullGW = Math.ceil(canvasW / CELL_SIZE);
520
+ const fullGH = Math.ceil(canvasH / CELL_SIZE);
521
+ const margin = 160;
522
+ const minXpx = Math.max(0, Math.min(sourceX, destX, sourceGateX, destGateX) - margin);
523
+ const maxXpx = Math.min(canvasW, Math.max(sourceX, destX, sourceGateX + sourceGateW, destGateX + destGateW) + margin);
524
+ const minYpx = Math.max(0, Math.min(sourceY, destY, sourceGateY, destGateY) - margin);
525
+ const maxYpx = Math.min(canvasH, Math.max(sourceY, destY, sourceGateY + sourceGateH, destGateY + destGateH) + margin);
526
+ const oGX = toGrid(minXpx), oGY = toGrid(minYpx);
527
+ const bGW = Math.min(fullGW, toGrid(maxXpx) - oGX + 1);
528
+ const bGH = Math.min(fullGH, toGrid(maxYpx) - oGY + 1);
529
+ return solve(oGX, oGY, bGW, bGH)
530
+ ?? solve(0, 0, fullGW, fullGH)
531
+ ?? orthFallback(sourceX, sourceY, destX, destY, obstacles, sourceGateX, sourceGateY, destGateX, destGateY); // orthogonal, gate-clear, never a diagonal
532
+ }
@@ -0,0 +1,19 @@
1
+ import type { DiagramTheme } from '../theme/themes.js';
2
+ export declare const PORT_SIZE = 5;
3
+ export declare const PORT_R = 3;
4
+ export declare const GATE_W = 60;
5
+ export declare const GATE_W_MULTI = 75;
6
+ export declare const NOT_TRIANGLE_W = 50;
7
+ export declare const BUBBLE_R = 5;
8
+ export declare const NOT_GATE_TOTAL_W: number;
9
+ export declare const NOT_GATE_H = 40;
10
+ export declare const AND_GATE_H_BASE = 45;
11
+ export declare const PORT_SPACING = 15;
12
+ export declare const OR_CURVE_DEPTH = 10;
13
+ export declare function andGateBody(w: number, h: number): string;
14
+ export declare function orGateBody(w: number, h: number): string;
15
+ export declare function orCurveTapX(h: number, localY: number): number;
16
+ export declare function notGateBody(w: number, h: number): string;
17
+ export declare function renderJunctionDot(x: number, y: number, theme: DiagramTheme): string;
18
+ export declare function renderInputPortLabel(absX: number, absY: number, label: string, theme: DiagramTheme, name?: string, description?: string): string;
19
+ export declare function renderOutputPortLabel(absX: number, absY: number, label: string, theme: DiagramTheme, name?: string, description?: string): string;
@@ -0,0 +1,92 @@
1
+ export const PORT_SIZE = 5;
2
+ export const PORT_R = 3;
3
+ export const GATE_W = 60;
4
+ export const GATE_W_MULTI = 75;
5
+ export const NOT_TRIANGLE_W = 50;
6
+ export const BUBBLE_R = 5;
7
+ export const NOT_GATE_TOTAL_W = NOT_TRIANGLE_W + BUBBLE_R * 2 + 5;
8
+ export const NOT_GATE_H = 40;
9
+ export const AND_GATE_H_BASE = 45;
10
+ export const PORT_SPACING = 15;
11
+ // Maximum rightward bulge of the OR gate's concave left edge, at mid-height.
12
+ // Fixed (independent of gate height) so concavity reads consistently across sizes.
13
+ export const OR_CURVE_DEPTH = 10;
14
+ function esc(s) {
15
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
16
+ }
17
+ export function andGateBody(w, h) {
18
+ // Rounded right side. Cap the corner radius at w/2 so tall gates don't produce a
19
+ // negative top edge (r=h/2 would make w-r < 0 once h > w) — the body then becomes a
20
+ // stadium/rounded rectangle. For short gates (h <= w) this is exactly the classic
21
+ // semicircular "D" shape, since the straight right edge between the corners is zero.
22
+ const r = Math.min(h / 2, w / 2);
23
+ return [
24
+ `M 0,0`,
25
+ `L ${w - r},0`,
26
+ `A ${r},${r} 0 0 1 ${w},${r}`,
27
+ `L ${w},${h - r}`,
28
+ `A ${r},${r} 0 0 1 ${w - r},${h}`,
29
+ `L 0,${h}`,
30
+ `Z`,
31
+ ].join(' ');
32
+ }
33
+ export function orGateBody(w, h) {
34
+ // Left edge is a single quadratic Bézier whose control point (2*depth, h/2)
35
+ // makes the curve's X an exact function of Y: x(localY) = orCurveTapX(h, localY).
36
+ // This lets input ports tap the curve precisely and keeps a constant concavity.
37
+ const cx = OR_CURVE_DEPTH * 2;
38
+ return [
39
+ `M 0,0`,
40
+ `C ${w * 0.5},0 ${w * 0.8},${h * 0.15} ${w},${h / 2}`,
41
+ `C ${w * 0.8},${h * 0.85} ${w * 0.5},${h} 0,${h}`,
42
+ `Q ${cx},${h / 2} 0,0`,
43
+ `Z`,
44
+ ].join(' ');
45
+ }
46
+ // Exact X (rightward offset from the bbox left edge) of the OR gate's concave left
47
+ // curve at a given local Y. Derived from the quadratic Bézier in orGateBody:
48
+ // x(localY) = 4 * depth * (localY/h) * (1 - localY/h)
49
+ // Peaks at OR_CURVE_DEPTH when localY == h/2, and is 0 at the top/bottom corners.
50
+ export function orCurveTapX(h, localY) {
51
+ if (h <= 0)
52
+ return 0;
53
+ const f = localY / h;
54
+ return 4 * OR_CURVE_DEPTH * f * (1 - f);
55
+ }
56
+ export function notGateBody(w, h) {
57
+ return `M 0,0 L ${w},${h / 2} L 0,${h} Z`;
58
+ }
59
+ export function renderJunctionDot(x, y, theme) {
60
+ return `<circle class="ldl-junction" cx="${x}" cy="${y}" r="4" fill="${theme.junctionFill}" stroke="${theme.junctionFill}" stroke-width="1"/>`;
61
+ }
62
+ export function renderInputPortLabel(absX, absY, label, theme, name, description) {
63
+ const displayName = name || label;
64
+ const labelGap = 6;
65
+ const textX = absX - labelGap;
66
+ const nameY = description ? absY - 6 : absY + 4;
67
+ const descY = description ? absY + 12 : 0;
68
+ const parts = [];
69
+ parts.push(`<text class="ldl-label ldl-name" x="${textX}" y="${nameY}" text-anchor="end" fill="${theme.nameFill}" font-size="12" font-family="sans-serif" font-weight="500">${esc(displayName)}</text>`);
70
+ if (description) {
71
+ parts.push(`<text class="ldl-label ldl-description" x="${textX}" y="${descY}" text-anchor="end" fill="${theme.descFill}" font-size="9" font-family="sans-serif">${esc(description)}</text>`);
72
+ }
73
+ // The id text is emitted separately by renderNodeIds (svg-renderer), gated on showIds, so it is
74
+ // omitted from the SVG entirely when IDs are off — a CSS-hidden id leaks into non-browser SVG
75
+ // viewers, which was the bug. `label` is retained above as the displayName fallback.
76
+ return parts.join('\n');
77
+ }
78
+ export function renderOutputPortLabel(absX, absY, label, theme, name, description) {
79
+ const displayName = name || label;
80
+ const labelGap = 6;
81
+ const textX = absX + labelGap;
82
+ const nameY = description ? absY - 6 : absY + 4;
83
+ const descY = description ? absY + 12 : 0;
84
+ const parts = [];
85
+ parts.push(`<text class="ldl-label ldl-name" x="${textX}" y="${nameY}" text-anchor="start" fill="${theme.nameOutFill}" font-size="12" font-family="sans-serif" font-weight="600">${esc(displayName)}</text>`);
86
+ if (description) {
87
+ parts.push(`<text class="ldl-label ldl-description" x="${textX}" y="${descY}" text-anchor="start" fill="${theme.descFill}" font-size="9" font-family="sans-serif">${esc(description)}</text>`);
88
+ }
89
+ // The id text is emitted separately by renderNodeIds (svg-renderer), gated on showIds — see note
90
+ // in renderInputPortLabel.
91
+ return parts.join('\n');
92
+ }
@@ -0,0 +1,31 @@
1
+ import type { LogicNode, Diagram, PortMeta, RenderOptions } from '../parser/ast.js';
2
+ export interface FlatNode {
3
+ id: string;
4
+ kind: 'gate' | 'input' | 'output';
5
+ gateType?: string;
6
+ label?: string;
7
+ name?: string;
8
+ description?: string;
9
+ depth: number;
10
+ inputIds: string[];
11
+ invertedInputs?: Set<number>;
12
+ bubbledOutput?: boolean;
13
+ blockType?: string;
14
+ params?: Record<string, string>;
15
+ usedPorts?: Set<string>;
16
+ inputPorts?: (string | undefined)[];
17
+ inputLabels?: (string | undefined)[];
18
+ portGap?: number;
19
+ }
20
+ export interface IntermediateLabel {
21
+ driverId: string;
22
+ port?: string;
23
+ name?: string;
24
+ description?: string;
25
+ }
26
+ export interface Graph {
27
+ nodes: Map<string, FlatNode>;
28
+ intermediateLabels: IntermediateLabel[];
29
+ }
30
+ export declare function flattenGate(node: LogicNode): LogicNode;
31
+ export declare function buildGraph(diagram: Diagram, portMeta: PortMeta[], opts: RenderOptions, uid: (prefix: string) => string): Graph;