@schemd/core 0.1.1 → 0.1.2

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/dist/layout.js CHANGED
@@ -1,9 +1,11 @@
1
- import { CLASSICAL_GATE_KINDS, SchematicSyntaxError } from './types.js';
2
- import { mathLabelGlyphLength } from './math-label.js';
1
+ import { CLASSICAL_GATE_KINDS, UML_COMPONENT_KINDS, SchematicSyntaxError } from './types.js';
2
+ import { mathLabelTextWidth } from './math-label.js';
3
+ import { MAX_SCHEMATIC_WIRE_CROSSINGS } from './limits.js';
3
4
  export const PORT_HOTSPOT_RADIUS = 4;
4
5
  export const SCHEMATIC_OBSTACLE_CLEARANCE = 12;
5
6
  export const SCHEMATIC_BRIDGE_RADIUS = 5;
6
- const MAX_OBSTACLE_PASSES = 16;
7
+ const MAX_ROUTER_STATES = 40_000;
8
+ const ROUTER_BEND_PENALTY = 0.25;
7
9
  const CROSSING_BUCKET_SIZE = 64;
8
10
  const DESIGNATOR_BASELINE_GAP = 10;
9
11
  const LABEL_BASELINE_GAP = 19;
@@ -31,6 +33,49 @@ export function classicalGateHeight(component) {
31
33
  function isClassicalGate(component) {
32
34
  return CLASSICAL_GATE_KINDS.includes(component.kind);
33
35
  }
36
+ function isUmlComponent(component) {
37
+ return UML_COMPONENT_KINDS.includes(component.kind);
38
+ }
39
+ function umlHalfExtents(component) {
40
+ switch (component.kind) {
41
+ case 'class':
42
+ case 'state':
43
+ case 'usecase':
44
+ case 'lifeline':
45
+ case 'note':
46
+ case 'package':
47
+ return { halfWidth: component.bodyWidth / 2, halfHeight: component.bodyHeight / 2 };
48
+ case 'actor':
49
+ return { halfWidth: 24, halfHeight: 50 };
50
+ case 'initial':
51
+ case 'final':
52
+ return { halfWidth: 12, halfHeight: 12 };
53
+ }
54
+ }
55
+ function umlPortPoint(component, port) {
56
+ const { halfWidth, halfHeight } = umlHalfExtents(component);
57
+ if (port === 'left' || port === 'in')
58
+ return { x: component.x - halfWidth, y: component.y };
59
+ if (port === 'right' || port === 'out')
60
+ return { x: component.x + halfWidth, y: component.y };
61
+ if (port === 'top')
62
+ return { x: component.x, y: component.y - halfHeight };
63
+ if (port === 'bottom')
64
+ return { x: component.x, y: component.y + halfHeight };
65
+ if (component.kind === 'lifeline') {
66
+ const match = port.match(/^(left|right)(\d+)$/);
67
+ if (match) {
68
+ const offset = Number(match[2]);
69
+ if (offset >= 0 && offset <= component.bodyHeight) {
70
+ return {
71
+ x: component.x + (match[1] === 'left' ? -halfWidth : halfWidth),
72
+ y: component.y - halfHeight + offset
73
+ };
74
+ }
75
+ }
76
+ }
77
+ return undefined;
78
+ }
34
79
  function indexedPortPoint(component, port) {
35
80
  const output = port.startsWith('out');
36
81
  const count = output ? component.outputs : component.inputs;
@@ -59,32 +104,51 @@ export function positionIcPin(component, side, index) {
59
104
  (side === 'top' ? -component.bodyHeight / 2 - 16 : component.bodyHeight / 2 + 16)
60
105
  };
61
106
  }
62
- function findIcPin(component, port) {
107
+ function findIcPinLocation(component, port) {
63
108
  const sides = ['left', 'right', 'top', 'bottom'];
64
109
  for (const side of sides) {
65
110
  const index = component.pins[side].indexOf(port);
66
111
  if (index >= 0)
67
- return positionIcPin(component, side, index);
112
+ return { side, index, point: positionIcPin(component, side, index) };
68
113
  }
69
114
  return undefined;
70
115
  }
71
- function icPinPoint(component, port) {
116
+ function icPinLocation(component, port) {
72
117
  if (port === 'in' || port === 'out') {
73
- const canonical = findIcPin(component, `${port}1`);
118
+ const canonical = findIcPinLocation(component, `${port}1`);
74
119
  if (canonical !== undefined)
75
120
  return canonical;
76
121
  const aliasSides = port === 'in' ? ['left', 'top'] : ['right', 'bottom'];
77
122
  for (const side of aliasSides) {
78
- if (component.pins[side].length > 0)
79
- return positionIcPin(component, side, 0);
123
+ if (component.pins[side].length > 0) {
124
+ return { side, point: positionIcPin(component, side, 0) };
125
+ }
80
126
  }
81
127
  return undefined;
82
128
  }
83
- return findIcPin(component, port);
129
+ return findIcPinLocation(component, port);
130
+ }
131
+ function sideNormal(side) {
132
+ switch (side) {
133
+ case 'left':
134
+ return { x: -1, y: 0 };
135
+ case 'right':
136
+ return { x: 1, y: 0 };
137
+ case 'top':
138
+ return { x: 0, y: -1 };
139
+ case 'bottom':
140
+ return { x: 0, y: 1 };
141
+ }
84
142
  }
85
143
  export function resolvePortPoint(component, port) {
86
144
  if (isClassicalGate(component))
87
145
  return indexedPortPoint(component, port);
146
+ if (isUmlComponent(component)) {
147
+ const point = umlPortPoint(component, port);
148
+ if (point !== undefined)
149
+ return point;
150
+ throw new Error(`Validated port ${component.id}.${port} is missing.`);
151
+ }
88
152
  const left = { x: component.x - 42, y: component.y };
89
153
  const right = { x: component.x + 42, y: component.y };
90
154
  switch (component.kind) {
@@ -140,16 +204,67 @@ export function resolvePortPoint(component, port) {
140
204
  return { x: component.x, y: component.y + 16 };
141
205
  break;
142
206
  case 'ic': {
143
- const point = icPinPoint(component, port);
144
- if (point !== undefined)
145
- return point;
207
+ const location = icPinLocation(component, port);
208
+ if (location !== undefined)
209
+ return location.point;
146
210
  break;
147
211
  }
148
212
  }
149
213
  throw new Error(`Validated port ${component.id}.${port} is missing.`);
150
214
  }
215
+ export function resolvePortGeometry(component, port) {
216
+ const point = resolvePortPoint(component, port);
217
+ if (isClassicalGate(component)) {
218
+ return { point, normal: port.startsWith('out') ? { x: 1, y: 0 } : { x: -1, y: 0 } };
219
+ }
220
+ if (isUmlComponent(component)) {
221
+ if (port === 'left' || port === 'in' || port.startsWith('left')) {
222
+ return { point, normal: { x: -1, y: 0 } };
223
+ }
224
+ if (port === 'right' || port === 'out' || port.startsWith('right')) {
225
+ return { point, normal: { x: 1, y: 0 } };
226
+ }
227
+ return { point, normal: port === 'top' ? { x: 0, y: -1 } : { x: 0, y: 1 } };
228
+ }
229
+ switch (component.kind) {
230
+ case 'resistor':
231
+ case 'capacitor':
232
+ case 'inductor':
233
+ return {
234
+ point,
235
+ normal: PASSIVE_OUTPUT_PORTS.has(port) ? { x: 1, y: 0 } : { x: -1, y: 0 }
236
+ };
237
+ case 'diode':
238
+ return {
239
+ point,
240
+ normal: DIODE_CATHODE_PORTS.has(port) ? { x: 1, y: 0 } : { x: -1, y: 0 }
241
+ };
242
+ case 'transistor':
243
+ return {
244
+ point,
245
+ normal: TRANSISTOR_CONTROL_PORTS.has(port) ? { x: -1, y: 0 } : { x: 1, y: 0 }
246
+ };
247
+ case 'port':
248
+ case 'hadamard':
249
+ case 'qgate':
250
+ return { point, normal: port === 'out' ? { x: 1, y: 0 } : { x: -1, y: 0 } };
251
+ case 'ground':
252
+ return { point, normal: { x: 0, y: -1 } };
253
+ case 'cnot':
254
+ if (port === 'control')
255
+ return { point, normal: { x: 0, y: -1 } };
256
+ if (port === 'target')
257
+ return { point, normal: { x: 0, y: 1 } };
258
+ return { point, normal: port === 'out' ? { x: 1, y: 0 } : { x: -1, y: 0 } };
259
+ case 'ic': {
260
+ const location = icPinLocation(component, port);
261
+ return { point, normal: sideNormal(location.side) };
262
+ }
263
+ }
264
+ throw new Error(`Validated port ${port} is missing.`);
265
+ }
151
266
  function namedPort(component, id) {
152
- return { id, point: resolvePortPoint(component, id) };
267
+ return { id, ...resolvePortGeometry(component, id) };
153
268
  }
154
269
  export function enumerateComponentPorts(component) {
155
270
  if (isClassicalGate(component)) {
@@ -158,6 +273,9 @@ export function enumerateComponentPorts(component) {
158
273
  ...Array.from({ length: component.outputs }, (_, index) => namedPort(component, `out${index + 1}`))
159
274
  ];
160
275
  }
276
+ if (isUmlComponent(component)) {
277
+ return ['left', 'right', 'top', 'bottom'].map((port) => namedPort(component, port));
278
+ }
161
279
  switch (component.kind) {
162
280
  case 'resistor':
163
281
  case 'capacitor':
@@ -195,17 +313,18 @@ export function enumerateComponentPorts(component) {
195
313
  const sides = ['left', 'right', 'top', 'bottom'];
196
314
  return sides.flatMap((side) => component.pins[side].map((id, index) => ({
197
315
  id,
198
- point: positionIcPin(component, side, index)
316
+ point: positionIcPin(component, side, index),
317
+ normal: sideNormal(side)
199
318
  })));
200
319
  }
201
320
  }
202
321
  }
203
- function endpointPoint(endpoint, components) {
322
+ function endpointGeometry(endpoint, components) {
204
323
  const component = components.get(endpoint.componentId);
205
324
  if (component === undefined) {
206
325
  throw new Error(`Validated component ${endpoint.componentId} is missing.`);
207
326
  }
208
- return resolvePortPoint(component, endpoint.port);
327
+ return { component, ...resolvePortGeometry(component, endpoint.port) };
209
328
  }
210
329
  function samePoint(left, right) {
211
330
  return Math.abs(left.x - right.x) < 0.0005 && Math.abs(left.y - right.y) < 0.0005;
@@ -271,15 +390,14 @@ function segmentIntersectsRectangle(start, end, rectangle) {
271
390
  Math.max(start.y, end.y) > rectangle.minY &&
272
391
  Math.min(start.y, end.y) < rectangle.maxY);
273
392
  }
274
- function candidateCollisionCount(points, obstacles) {
275
- let collisions = 0;
393
+ function routeIntersectsObstacles(points, obstacles) {
276
394
  for (let index = 1; index < points.length; index += 1) {
277
395
  for (const obstacle of obstacles) {
278
396
  if (segmentIntersectsRectangle(points[index - 1], points[index], obstacle))
279
- collisions += 1;
397
+ return true;
280
398
  }
281
399
  }
282
- return collisions;
400
+ return false;
283
401
  }
284
402
  function candidateLength(points) {
285
403
  let length = 0;
@@ -290,61 +408,212 @@ function candidateLength(points) {
290
408
  }
291
409
  return length;
292
410
  }
293
- function detourRoute(routeStart, routeEnd, obstacle, obstacles) {
294
- const candidates = [
295
- [
296
- routeStart,
297
- { x: routeStart.x, y: obstacle.minY },
298
- { x: routeEnd.x, y: obstacle.minY },
299
- routeEnd
300
- ],
301
- [
302
- routeStart,
303
- { x: routeStart.x, y: obstacle.maxY },
304
- { x: routeEnd.x, y: obstacle.maxY },
305
- routeEnd
306
- ],
307
- [
308
- routeStart,
309
- { x: obstacle.minX, y: routeStart.y },
310
- { x: obstacle.minX, y: routeEnd.y },
311
- routeEnd
312
- ],
313
- [
314
- routeStart,
315
- { x: obstacle.maxX, y: routeStart.y },
316
- { x: obstacle.maxX, y: routeEnd.y },
317
- routeEnd
318
- ]
319
- ];
320
- return [...candidates].sort((left, right) => {
321
- const collisionDifference = candidateCollisionCount(left, obstacles) - candidateCollisionCount(right, obstacles);
322
- if (collisionDifference !== 0)
323
- return collisionDifference;
324
- return candidateLength(left) - candidateLength(right);
325
- })[0];
326
- }
327
- function avoidComponentObstacles(points, obstacles) {
328
- let routed = compactOrthogonalPoints(points);
329
- for (let pass = 0; pass < MAX_OBSTACLE_PASSES; pass += 1) {
330
- let collision;
331
- for (let segmentIndex = 1; segmentIndex < routed.length && collision === undefined; segmentIndex += 1) {
411
+ function candidateBends(points) {
412
+ return Math.max(0, points.length - 2);
413
+ }
414
+ class RouteHeap {
415
+ #items = [];
416
+ get size() {
417
+ return this.#items.length;
418
+ }
419
+ push(item) {
420
+ this.#items.push(item);
421
+ let index = this.#items.length - 1;
422
+ while (index > 0) {
423
+ const parent = (index - 1) >> 1;
424
+ if (!RouteHeap.before(item, this.#items[parent]))
425
+ break;
426
+ this.#items[index] = this.#items[parent];
427
+ index = parent;
428
+ }
429
+ this.#items[index] = item;
430
+ }
431
+ pop() {
432
+ const first = this.#items[0];
433
+ const last = this.#items.pop();
434
+ if (first === undefined || last === undefined || this.#items.length === 0)
435
+ return first;
436
+ let index = 0;
437
+ while (true) {
438
+ const left = index * 2 + 1;
439
+ if (left >= this.#items.length)
440
+ break;
441
+ const right = left + 1;
442
+ const child = right < this.#items.length && RouteHeap.before(this.#items[right], this.#items[left])
443
+ ? right
444
+ : left;
445
+ if (!RouteHeap.before(this.#items[child], last))
446
+ break;
447
+ this.#items[index] = this.#items[child];
448
+ index = child;
449
+ }
450
+ this.#items[index] = last;
451
+ return first;
452
+ }
453
+ static before(left, right) {
454
+ return left.f !== right.f
455
+ ? left.f < right.f
456
+ : left.g !== right.g
457
+ ? left.g < right.g
458
+ : left.state < right.state;
459
+ }
460
+ }
461
+ function uniqueSorted(values) {
462
+ return [...new Set(values)].sort((left, right) => left - right);
463
+ }
464
+ function searchOrthogonalRoute(start, end, obstacles, bounds, line) {
465
+ const withinX = (value) => bounds === undefined || (value >= 0 && value <= bounds.width);
466
+ const withinY = (value) => bounds === undefined || (value >= 0 && value <= bounds.height);
467
+ const boundaryXs = bounds === undefined ? [] : [0, bounds.width];
468
+ const boundaryYs = bounds === undefined ? [] : [0, bounds.height];
469
+ const xs = uniqueSorted([
470
+ start.x,
471
+ end.x,
472
+ ...boundaryXs,
473
+ ...obstacles.flatMap((obstacle) => [obstacle.minX, obstacle.maxX]).filter(withinX)
474
+ ]);
475
+ const ys = uniqueSorted([
476
+ start.y,
477
+ end.y,
478
+ ...boundaryYs,
479
+ ...obstacles.flatMap((obstacle) => [obstacle.minY, obstacle.maxY]).filter(withinY)
480
+ ]);
481
+ const width = xs.length;
482
+ const startX = xs.indexOf(start.x);
483
+ const startY = ys.indexOf(start.y);
484
+ const endX = xs.indexOf(end.x);
485
+ const endY = ys.indexOf(end.y);
486
+ const startCell = startY * width + startX;
487
+ const endCell = endY * width + endX;
488
+ const stateId = (cell, direction) => cell * 3 + direction;
489
+ const gScore = new Map();
490
+ const previous = new Map();
491
+ const heap = new RouteHeap();
492
+ const startState = stateId(startCell, 0);
493
+ gScore.set(startState, 0);
494
+ heap.push({
495
+ state: startState,
496
+ cell: startCell,
497
+ direction: 0,
498
+ g: 0,
499
+ f: Math.abs(start.x - end.x) + Math.abs(start.y - end.y)
500
+ });
501
+ let expanded = 0;
502
+ let finalState;
503
+ while (heap.size > 0 && expanded < MAX_ROUTER_STATES) {
504
+ const current = heap.pop();
505
+ if (current.g !== gScore.get(current.state))
506
+ continue;
507
+ if (current.cell === endCell) {
508
+ finalState = current.state;
509
+ break;
510
+ }
511
+ expanded += 1;
512
+ const xIndex = current.cell % width;
513
+ const yIndex = Math.floor(current.cell / width);
514
+ const from = { x: xs[xIndex], y: ys[yIndex] };
515
+ const neighbors = [
516
+ [xIndex - 1, yIndex, 1],
517
+ [xIndex + 1, yIndex, 1],
518
+ [xIndex, yIndex - 1, 2],
519
+ [xIndex, yIndex + 1, 2]
520
+ ];
521
+ for (const [nextX, nextY, direction] of neighbors) {
522
+ if (nextX < 0 || nextY < 0 || nextX >= width || nextY >= ys.length)
523
+ continue;
524
+ const to = { x: xs[nextX], y: ys[nextY] };
525
+ let blocked = false;
332
526
  for (const obstacle of obstacles) {
333
- if (segmentIntersectsRectangle(routed[segmentIndex - 1], routed[segmentIndex], obstacle)) {
334
- collision = { segmentIndex, obstacle };
527
+ if (segmentIntersectsRectangle(from, to, obstacle)) {
528
+ blocked = true;
335
529
  break;
336
530
  }
337
531
  }
532
+ if (blocked)
533
+ continue;
534
+ const cell = nextY * width + nextX;
535
+ const state = stateId(cell, direction);
536
+ const bend = current.direction !== 0 && current.direction !== direction;
537
+ const g = current.g + Math.abs(from.x - to.x) + Math.abs(from.y - to.y) + (bend ? ROUTER_BEND_PENALTY : 0);
538
+ if (g >= (gScore.get(state) ?? Number.POSITIVE_INFINITY))
539
+ continue;
540
+ gScore.set(state, g);
541
+ previous.set(state, current.state);
542
+ heap.push({
543
+ state,
544
+ cell,
545
+ direction,
546
+ g,
547
+ f: g + Math.abs(to.x - end.x) + Math.abs(to.y - end.y)
548
+ });
338
549
  }
339
- if (collision === undefined)
340
- break;
341
- routed = compactOrthogonalPoints(detourRoute(routed[0], routed.at(-1), collision.obstacle, obstacles));
342
550
  }
343
- return routed;
551
+ if (finalState === undefined && heap.size > 0 && expanded >= MAX_ROUTER_STATES) {
552
+ throw new SchematicSyntaxError(`Orthogonal routing complexity exceeds ${MAX_ROUTER_STATES.toLocaleString('en-US')} search states.`, line);
553
+ }
554
+ if (finalState === undefined)
555
+ return undefined;
556
+ const reversed = [];
557
+ let state = finalState;
558
+ while (state !== undefined) {
559
+ const cell = Math.floor(state / 3);
560
+ reversed.push({ x: xs[cell % width], y: ys[Math.floor(cell / width)] });
561
+ state = previous.get(state);
562
+ }
563
+ return compactOrthogonalPoints(reversed.reverse());
564
+ }
565
+ function routeBetweenEscapes(start, end, obstacles, bounds, line) {
566
+ const middleX = (start.x + end.x) / 2;
567
+ const direct = compactOrthogonalPoints([
568
+ start,
569
+ { x: middleX, y: start.y },
570
+ { x: middleX, y: end.y },
571
+ end
572
+ ]);
573
+ if (!routeIntersectsObstacles(direct, obstacles))
574
+ return direct;
575
+ const candidates = [];
576
+ const yLanes = uniqueSorted([
577
+ start.y,
578
+ end.y,
579
+ ...(bounds === undefined ? [] : [0, bounds.height]),
580
+ ...obstacles.flatMap((obstacle) => [obstacle.minY, obstacle.maxY])
581
+ ]);
582
+ for (const y of yLanes) {
583
+ if (bounds !== undefined && (y < 0 || y > bounds.height))
584
+ continue;
585
+ candidates.push([start, { x: start.x, y }, { x: end.x, y }, end]);
586
+ }
587
+ const xLanes = uniqueSorted([
588
+ start.x,
589
+ end.x,
590
+ ...(bounds === undefined ? [] : [0, bounds.width]),
591
+ ...obstacles.flatMap((obstacle) => [obstacle.minX, obstacle.maxX])
592
+ ]);
593
+ for (const x of xLanes) {
594
+ if (bounds !== undefined && (x < 0 || x > bounds.width))
595
+ continue;
596
+ candidates.push([start, { x, y: start.y }, { x, y: end.y }, end]);
597
+ }
598
+ let best;
599
+ let bestScore = Number.POSITIVE_INFINITY;
600
+ for (const raw of candidates) {
601
+ const candidate = compactOrthogonalPoints(raw);
602
+ if (routeIntersectsObstacles(candidate, obstacles))
603
+ continue;
604
+ const score = candidateLength(candidate) + candidateBends(candidate) * ROUTER_BEND_PENALTY;
605
+ if (score < bestScore) {
606
+ best = candidate;
607
+ bestScore = score;
608
+ }
609
+ }
610
+ return best ?? searchOrthogonalRoute(start, end, obstacles, bounds, line);
344
611
  }
345
- export function routeConnection(connection, components) {
346
- const start = endpointPoint(connection.from, components);
347
- const end = endpointPoint(connection.to, components);
612
+ function routeConnectionInternal(connection, components, bounds, cachedObstacles) {
613
+ const from = endpointGeometry(connection.from, components);
614
+ const to = endpointGeometry(connection.to, components);
615
+ const start = from.point;
616
+ const end = to.point;
348
617
  const sx = formatNumber(start.x);
349
618
  const sy = formatNumber(start.y);
350
619
  const ex = formatNumber(end.x);
@@ -354,54 +623,85 @@ export function routeConnection(connection, components) {
354
623
  const controlA = { x: middleX, y: start.y };
355
624
  const controlB = { x: middleX, y: end.y };
356
625
  return {
626
+ curve: 'bezier',
357
627
  d: `M ${sx} ${sy} C ${formatNumber(controlA.x)} ${formatNumber(controlA.y)}, ${formatNumber(controlB.x)} ${formatNumber(controlB.y)}, ${ex} ${ey}`,
358
628
  points: [start, controlA, controlB, end]
359
629
  };
360
630
  }
361
631
  if (connection.curve === 'ortho') {
362
- const middleX = (start.x + end.x) / 2;
363
- const cornerA = { x: middleX, y: start.y };
364
- const cornerB = { x: middleX, y: end.y };
365
- const obstacles = Array.from(components.values())
366
- .filter((component) => component.id !== connection.from.componentId && component.id !== connection.to.componentId)
367
- .map((component) => componentObstacleRectangle(component));
368
- const points = avoidComponentObstacles([start, cornerA, cornerB, end], obstacles);
632
+ const obstacleCache = cachedObstacles ??
633
+ Array.from(components.values(), (component) => ({
634
+ id: component.id,
635
+ expanded: componentObstacleRectangle(component),
636
+ body: componentObstacleRectangle(component, 0)
637
+ }));
638
+ const fromObstacle = componentObstacleRectangle(from.component);
639
+ const toObstacle = componentObstacleRectangle(to.component);
640
+ const escape = (geometry, obstacle) => ({
641
+ x: geometry.normal.x < 0
642
+ ? obstacle.minX
643
+ : geometry.normal.x > 0
644
+ ? obstacle.maxX
645
+ : geometry.point.x,
646
+ y: geometry.normal.y < 0
647
+ ? obstacle.minY
648
+ : geometry.normal.y > 0
649
+ ? obstacle.maxY
650
+ : geometry.point.y
651
+ });
652
+ const startEscape = escape(from, fromObstacle);
653
+ const endEscape = escape(to, toObstacle);
654
+ const obstacleEntries = obstacleCache.map((entry) => ({
655
+ id: entry.id,
656
+ rectangle: entry.id === from.component.id || entry.id === to.component.id
657
+ ? entry.body
658
+ : entry.expanded
659
+ }));
660
+ const obstacleRectangles = obstacleEntries.map((entry) => entry.rectangle);
661
+ const middle = routeBetweenEscapes(startEscape, endEscape, obstacleRectangles, bounds, connection.line);
662
+ if (middle === undefined) {
663
+ throw new SchematicSyntaxError('No collision-free orthogonal route exists.', connection.line);
664
+ }
665
+ const points = compactOrthogonalPoints([start, ...middle, end]);
666
+ for (let index = 1; index < points.length; index += 1) {
667
+ const allowFrom = index === 1;
668
+ const allowTo = index === points.length - 1;
669
+ for (const obstacle of obstacleEntries) {
670
+ if (!(allowFrom && obstacle.id === from.component.id) &&
671
+ !(allowTo && obstacle.id === to.component.id) &&
672
+ segmentIntersectsRectangle(points[index - 1], points[index], obstacle.rectangle)) {
673
+ throw new SchematicSyntaxError(`Orthogonal route intersects ${obstacle.id} after routing.`, connection.line);
674
+ }
675
+ }
676
+ }
369
677
  return {
678
+ curve: 'ortho',
370
679
  d: orthogonalPath(points),
371
680
  points: points
372
681
  };
373
682
  }
374
- return { d: `M ${sx} ${sy} L ${ex} ${ey}`, points: [start, end] };
683
+ return { curve: 'line', d: `M ${sx} ${sy} L ${ex} ${ey}`, points: [start, end] };
684
+ }
685
+ export function routeConnection(connection, components, bounds) {
686
+ return routeConnectionInternal(connection, components, bounds, undefined);
375
687
  }
376
688
  function axisSegments(route, routeIndex) {
377
689
  const segments = [];
690
+ if (route.curve !== 'ortho')
691
+ return segments;
378
692
  for (let index = 1; index < route.points.length; index += 1) {
379
693
  const start = route.points[index - 1];
380
694
  const end = route.points[index];
381
695
  if (start.x === end.x && start.y !== end.y) {
382
696
  segments.push({ routeIndex, segmentIndex: index - 1, start, end, orientation: 'vertical' });
383
697
  }
384
- else if (start.y === end.y && start.x !== end.x) {
698
+ else {
385
699
  segments.push({ routeIndex, segmentIndex: index - 1, start, end, orientation: 'horizontal' });
386
700
  }
387
701
  }
388
702
  return segments;
389
703
  }
390
- function segmentBucketKeys(segment) {
391
- const minX = Math.floor(Math.min(segment.start.x, segment.end.x) / CROSSING_BUCKET_SIZE);
392
- const maxX = Math.floor(Math.max(segment.start.x, segment.end.x) / CROSSING_BUCKET_SIZE);
393
- const minY = Math.floor(Math.min(segment.start.y, segment.end.y) / CROSSING_BUCKET_SIZE);
394
- const maxY = Math.floor(Math.max(segment.start.y, segment.end.y) / CROSSING_BUCKET_SIZE);
395
- const keys = [];
396
- for (let x = minX; x <= maxX; x += 1) {
397
- for (let y = minY; y <= maxY; y += 1)
398
- keys.push(`${x}:${y}`);
399
- }
400
- return keys;
401
- }
402
704
  function segmentCrossing(left, right) {
403
- if (left.orientation === right.orientation)
404
- return undefined;
405
705
  const horizontal = left.orientation === 'horizontal' ? left : right;
406
706
  const vertical = left.orientation === 'vertical' ? left : right;
407
707
  const point = { x: vertical.start.x, y: horizontal.start.y };
@@ -427,65 +727,116 @@ function bridgedOrthogonalPath(route, crossings) {
427
727
  const direction = Math.sign(horizontal ? end.x - start.x : end.y - start.y);
428
728
  const coordinate = horizontal ? 'x' : 'y';
429
729
  segmentCrossings.sort((left, right) => direction * (left[coordinate] - right[coordinate]));
730
+ const radii = segmentCrossings.map((crossing, crossingIndex) => {
731
+ const position = crossing[coordinate];
732
+ const startDistance = Math.abs(position - start[coordinate]);
733
+ const endDistance = Math.abs(end[coordinate] - position);
734
+ const previousDistance = crossingIndex === 0
735
+ ? Number.POSITIVE_INFINITY
736
+ : Math.abs(position - segmentCrossings[crossingIndex - 1][coordinate]) / 2;
737
+ const nextDistance = crossingIndex === segmentCrossings.length - 1
738
+ ? Number.POSITIVE_INFINITY
739
+ : Math.abs(segmentCrossings[crossingIndex + 1][coordinate] - position) / 2;
740
+ return Math.min(SCHEMATIC_BRIDGE_RADIUS, startDistance, endDistance, previousDistance, nextDistance);
741
+ });
430
742
  if (horizontal) {
431
- for (const crossing of segmentCrossings) {
432
- const before = crossing.x - direction * SCHEMATIC_BRIDGE_RADIUS;
433
- const after = crossing.x + direction * SCHEMATIC_BRIDGE_RADIUS;
434
- path += ` H ${formatNumber(before)} A ${SCHEMATIC_BRIDGE_RADIUS} ${SCHEMATIC_BRIDGE_RADIUS} 0 0 ${direction > 0 ? 1 : 0} ${formatNumber(after)} ${formatNumber(crossing.y)}`;
435
- boundsPoints.push({ x: crossing.x, y: crossing.y - SCHEMATIC_BRIDGE_RADIUS });
743
+ let cursorX = start.x;
744
+ for (const [crossingIndex, crossing] of segmentCrossings.entries()) {
745
+ const radius = radii[crossingIndex];
746
+ const before = crossing.x - direction * radius;
747
+ const after = crossing.x + direction * radius;
748
+ if (before !== cursorX)
749
+ path += ` H ${formatNumber(before)}`;
750
+ path += ` A ${formatNumber(radius)} ${formatNumber(radius)} 0 0 ${direction > 0 ? 1 : 0} ${formatNumber(after)} ${formatNumber(crossing.y)}`;
751
+ cursorX = after;
752
+ boundsPoints.push({ x: before, y: crossing.y }, { x: after, y: crossing.y }, { x: crossing.x, y: crossing.y - radius });
436
753
  }
437
- path += ` H ${formatNumber(end.x)}`;
754
+ if (cursorX !== end.x)
755
+ path += ` H ${formatNumber(end.x)}`;
438
756
  }
439
757
  else {
440
- for (const crossing of segmentCrossings) {
441
- const before = crossing.y - direction * SCHEMATIC_BRIDGE_RADIUS;
442
- const after = crossing.y + direction * SCHEMATIC_BRIDGE_RADIUS;
443
- path += ` V ${formatNumber(before)} A ${SCHEMATIC_BRIDGE_RADIUS} ${SCHEMATIC_BRIDGE_RADIUS} 0 0 ${direction > 0 ? 0 : 1} ${formatNumber(crossing.x)} ${formatNumber(after)}`;
444
- boundsPoints.push({ x: crossing.x + SCHEMATIC_BRIDGE_RADIUS, y: crossing.y });
758
+ let cursorY = start.y;
759
+ for (const [crossingIndex, crossing] of segmentCrossings.entries()) {
760
+ const radius = radii[crossingIndex];
761
+ const before = crossing.y - direction * radius;
762
+ const after = crossing.y + direction * radius;
763
+ if (before !== cursorY)
764
+ path += ` V ${formatNumber(before)}`;
765
+ path += ` A ${formatNumber(radius)} ${formatNumber(radius)} 0 0 ${direction > 0 ? 0 : 1} ${formatNumber(crossing.x)} ${formatNumber(after)}`;
766
+ cursorY = after;
767
+ boundsPoints.push({ x: crossing.x, y: before }, { x: crossing.x, y: after }, { x: crossing.x - radius, y: crossing.y });
445
768
  }
446
- path += ` V ${formatNumber(end.y)}`;
769
+ if (cursorY !== end.y)
770
+ path += ` V ${formatNumber(end.y)}`;
447
771
  }
448
772
  }
449
773
  return {
774
+ curve: route.curve,
450
775
  d: path,
451
776
  points: boundsPoints
452
777
  };
453
778
  }
454
- export function routeConnections(connections, components) {
455
- const routes = connections.map((connection) => routeConnection(connection, components));
456
- const buckets = new Map();
779
+ export function routeConnections(connections, components, bounds) {
780
+ const cachedObstacles = Array.from(components.values(), (component) => ({
781
+ id: component.id,
782
+ expanded: componentObstacleRectangle(component),
783
+ body: componentObstacleRectangle(component, 0)
784
+ }));
785
+ const routes = connections.map((connection) => routeConnectionInternal(connection, components, bounds, cachedObstacles));
786
+ const horizontalBuckets = new Map();
787
+ const verticalBuckets = new Map();
457
788
  const crossingsByRoute = new Map();
789
+ const recordedCrossings = new Set();
790
+ let crossingCount = 0;
791
+ const recordCrossing = (routeIndex, segmentIndex, crossing) => {
792
+ let routeCrossings = crossingsByRoute.get(routeIndex);
793
+ if (routeCrossings === undefined) {
794
+ routeCrossings = new Map();
795
+ crossingsByRoute.set(routeIndex, routeCrossings);
796
+ }
797
+ const points = routeCrossings.get(segmentIndex) ?? [];
798
+ const key = `${routeIndex}:${segmentIndex}:${crossing.x}:${crossing.y}`;
799
+ if (!recordedCrossings.has(key)) {
800
+ recordedCrossings.add(key);
801
+ crossingCount += 1;
802
+ if (crossingCount > MAX_SCHEMATIC_WIRE_CROSSINGS) {
803
+ throw new SchematicSyntaxError(`Wire crossing complexity exceeds ${MAX_SCHEMATIC_WIRE_CROSSINGS.toLocaleString('en-US')} intersections.`, connections[routeIndex]?.line);
804
+ }
805
+ points.push(crossing);
806
+ }
807
+ routeCrossings.set(segmentIndex, points);
808
+ };
458
809
  for (let routeIndex = 0; routeIndex < routes.length; routeIndex += 1) {
459
810
  const route = routes[routeIndex];
460
811
  for (const segment of axisSegments(route, routeIndex)) {
461
- const visited = new Set();
462
- for (const key of segmentBucketKeys(segment)) {
463
- for (const previous of buckets.get(key) ?? []) {
464
- const identity = `${previous.routeIndex}:${previous.segmentIndex}`;
465
- if (visited.has(identity))
466
- continue;
467
- visited.add(identity);
468
- const crossing = segmentCrossing(segment, previous);
469
- if (crossing === undefined)
470
- continue;
471
- let routeCrossings = crossingsByRoute.get(routeIndex);
472
- if (routeCrossings === undefined) {
473
- routeCrossings = new Map();
474
- crossingsByRoute.set(routeIndex, routeCrossings);
812
+ if (segment.orientation === 'horizontal') {
813
+ const minBucket = Math.floor(Math.min(segment.start.x, segment.end.x) / CROSSING_BUCKET_SIZE);
814
+ const maxBucket = Math.floor(Math.max(segment.start.x, segment.end.x) / CROSSING_BUCKET_SIZE);
815
+ for (let bucket = minBucket; bucket <= maxBucket; bucket += 1) {
816
+ for (const previous of verticalBuckets.get(bucket) ?? []) {
817
+ const crossing = segmentCrossing(segment, previous);
818
+ if (crossing !== undefined)
819
+ recordCrossing(routeIndex, segment.segmentIndex, crossing);
475
820
  }
476
- const points = routeCrossings.get(segment.segmentIndex) ?? [];
477
- if (!points.some((point) => samePoint(point, crossing)))
478
- points.push(crossing);
479
- routeCrossings.set(segment.segmentIndex, points);
480
821
  }
481
822
  }
482
- }
483
- for (const segment of axisSegments(route, routeIndex)) {
484
- for (const key of segmentBucketKeys(segment)) {
485
- const bucket = buckets.get(key) ?? [];
486
- bucket.push(segment);
487
- buckets.set(key, bucket);
823
+ else {
824
+ const minBucket = Math.floor(Math.min(segment.start.y, segment.end.y) / CROSSING_BUCKET_SIZE);
825
+ const maxBucket = Math.floor(Math.max(segment.start.y, segment.end.y) / CROSSING_BUCKET_SIZE);
826
+ for (let bucket = minBucket; bucket <= maxBucket; bucket += 1) {
827
+ for (const previous of horizontalBuckets.get(bucket) ?? []) {
828
+ const crossing = segmentCrossing(segment, previous);
829
+ if (crossing !== undefined)
830
+ recordCrossing(routeIndex, segment.segmentIndex, crossing);
831
+ }
832
+ }
488
833
  }
834
+ const bucketIndex = Math.floor((segment.orientation === 'horizontal' ? segment.start.y : segment.start.x) /
835
+ CROSSING_BUCKET_SIZE);
836
+ const index = segment.orientation === 'horizontal' ? horizontalBuckets : verticalBuckets;
837
+ const bucket = index.get(bucketIndex) ?? [];
838
+ bucket.push(segment);
839
+ index.set(bucketIndex, bucket);
489
840
  }
490
841
  }
491
842
  return routes.map((route, index) => bridgedOrthogonalPath(route, crossingsByRoute.get(index) ?? new Map()));
@@ -497,6 +848,9 @@ function componentHalfExtents(component) {
497
848
  halfWidth = 48;
498
849
  halfHeight = classicalGateHeight(component) / 2;
499
850
  }
851
+ else if (isUmlComponent(component)) {
852
+ return umlHalfExtents(component);
853
+ }
500
854
  else {
501
855
  switch (component.kind) {
502
856
  case 'resistor':
@@ -543,12 +897,27 @@ export function componentTextAnchors(component) {
543
897
  return {
544
898
  designatorY: -halfHeight - DESIGNATOR_BASELINE_GAP,
545
899
  labelY: halfHeight + LABEL_BASELINE_GAP,
546
- designatorWidth: Array.from(component.id).length * TEXT_ADVANCE,
547
- labelWidth: mathLabelGlyphLength(component.label) * TEXT_ADVANCE
900
+ designatorWidth: mathLabelTextWidth(component.id, TEXT_ADVANCE),
901
+ labelWidth: mathLabelTextWidth(component.label, TEXT_ADVANCE)
548
902
  };
549
903
  }
550
904
  export function componentRectangle(component) {
551
- const { halfWidth } = componentHalfExtents(component);
905
+ const { halfWidth, halfHeight } = componentHalfExtents(component);
906
+ if (isUmlComponent(component)) {
907
+ const rectangle = {
908
+ minX: component.x - halfWidth,
909
+ minY: component.y - halfHeight,
910
+ maxX: component.x + halfWidth,
911
+ maxY: component.y + halfHeight
912
+ };
913
+ for (const port of enumerateComponentPorts(component)) {
914
+ rectangle.minX = Math.min(rectangle.minX, port.point.x - PORT_HOTSPOT_RADIUS);
915
+ rectangle.minY = Math.min(rectangle.minY, port.point.y - PORT_HOTSPOT_RADIUS);
916
+ rectangle.maxX = Math.max(rectangle.maxX, port.point.x + PORT_HOTSPOT_RADIUS);
917
+ rectangle.maxY = Math.max(rectangle.maxY, port.point.y + PORT_HOTSPOT_RADIUS);
918
+ }
919
+ return rectangle;
920
+ }
552
921
  const anchors = componentTextAnchors(component);
553
922
  const textHalfWidth = Math.max(anchors.designatorWidth, anchors.labelWidth) / 2 + TEXT_HORIZONTAL_GUTTER;
554
923
  const boundedHalfWidth = Math.max(halfWidth, textHalfWidth);
@@ -575,16 +944,19 @@ function rectangleInsideBounds(rectangle, bounds) {
575
944
  function pointInsideBounds(point, bounds) {
576
945
  return point.x >= 0 && point.y >= 0 && point.x <= bounds.width && point.y <= bounds.height;
577
946
  }
578
- export function validateDocumentGeometry(document, fence) {
947
+ export function validateDocumentGeometry(document, fence, routedConnections) {
579
948
  for (const component of document.components) {
580
949
  if (!rectangleInsideBounds(componentRectangle(component), fence.bounds)) {
581
950
  throw new SchematicSyntaxError(`${component.id} geometry exceeds the declared ${fence.bounds.width}x${fence.bounds.height} bounds.`, component.line);
582
951
  }
583
952
  }
584
- const components = new Map(document.components.map((component) => [component.id, component]));
585
- const routedConnections = routeConnections(document.connections, components);
953
+ const routes = routedConnections ??
954
+ routeConnections(document.connections, new Map(document.components.map((component) => [component.id, component])), fence.bounds);
955
+ if (routes.length !== document.connections.length) {
956
+ throw new TypeError('Routed connection count does not match the schematic document.');
957
+ }
586
958
  for (const [index, connection] of document.connections.entries()) {
587
- const routed = routedConnections[index];
959
+ const routed = routes[index];
588
960
  if (!routed.points.every((point) => pointInsideBounds(point, fence.bounds))) {
589
961
  throw new SchematicSyntaxError('Connection trace exceeds the declared schematic bounds.', connection.line);
590
962
  }