altium-toolkit 1.0.1 → 1.0.7

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.
@@ -0,0 +1,1162 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Normalizes recovered schematic wire geometry after primitive parsing.
7
+ */
8
+ export class SchematicWireNormalizer {
9
+ static #MAX_COLLAPSED_PIN_SPAN = 60
10
+ static #MAX_CALLOUT_LEADER_SPAN = 40
11
+ static #CALLOUT_ARROWHEAD_SCALE = 1.25
12
+
13
+ /**
14
+ * Corrects standalone callout arrowhead triangles whose final coordinate
15
+ * was carried from the previous point instead of reflected across the
16
+ * leader direction.
17
+ * @param {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string, recordType?: string, lineStyle?: number, renderOrder?: number }[]} lines
18
+ * @param {{ points: { x: number, y: number }[], isSolid?: boolean, transparent?: boolean, ownerIndex?: string, renderOrder?: number }[]} polygons
19
+ * @returns {{ lines: { x1: number, y1: number, x2: number, y2: number, ownerIndex?: string, recordType?: string, lineStyle?: number, renderOrder?: number }[], polygons: { points: { x: number, y: number }[], isSolid?: boolean, transparent?: boolean, ownerIndex?: string, renderOrder?: number }[] }}
20
+ */
21
+ static normalizeStandaloneCalloutArrowheads(lines, polygons) {
22
+ const updates = []
23
+ const normalizedPolygons = polygons.map((polygon) => {
24
+ const normalizedPoints =
25
+ SchematicWireNormalizer.#resolveStandaloneCalloutArrowheadPoints(
26
+ polygon,
27
+ lines
28
+ )
29
+
30
+ if (!normalizedPoints) {
31
+ return polygon
32
+ }
33
+
34
+ updates.push({
35
+ renderOrder: polygon.renderOrder,
36
+ points: normalizedPoints
37
+ })
38
+
39
+ return {
40
+ ...polygon,
41
+ points: normalizedPoints
42
+ }
43
+ })
44
+
45
+ return {
46
+ lines: lines.map((line) =>
47
+ SchematicWireNormalizer.#normalizeCalloutArrowheadOutlineLine(
48
+ line,
49
+ updates
50
+ )
51
+ ),
52
+ polygons: normalizedPolygons
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Extends collapsed final wire segments to nearby pin endpoints when an
58
+ * omitted coordinate axis made the segment degenerate.
59
+ * @param {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string, recordType?: string, omittedEndpointAxis?: 'x' | 'y' }[]} lines
60
+ * @param {{ x: number, y: number, length: number, orientation: 'left' | 'right' | 'top' | 'bottom' }[]} pins
61
+ * @param {{ x: number, y: number }[]} [junctions]
62
+ * @returns {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string, recordType?: string }[]}
63
+ */
64
+ static extendCollapsedPolylineEndpoints(lines, pins, junctions = []) {
65
+ const calloutLines =
66
+ SchematicWireNormalizer.#restoreStandaloneCalloutLeaderEndpoints(
67
+ lines
68
+ )
69
+ const crossedLines =
70
+ SchematicWireNormalizer.#restoreCrossedPolylineEndpoints(
71
+ calloutLines,
72
+ pins
73
+ )
74
+
75
+ return crossedLines
76
+ .map((line, index) => {
77
+ const extension =
78
+ SchematicWireNormalizer.#resolveCollapsedEndpoint(
79
+ line,
80
+ index,
81
+ crossedLines,
82
+ pins,
83
+ junctions
84
+ )
85
+
86
+ if (!extension) {
87
+ return line
88
+ }
89
+
90
+ return {
91
+ ...line,
92
+ x2: extension.x,
93
+ y2: extension.y
94
+ }
95
+ })
96
+ .map((line) => SchematicWireNormalizer.#stripRecoveryMetadata(line))
97
+ }
98
+
99
+ /**
100
+ * Restores the omitted endpoint axis on standalone dashed callout leaders
101
+ * by snapping the leader endpoint to a nearby standalone dashed frame
102
+ * corner.
103
+ * @param {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string, recordType?: string, lineStyle?: number, renderOrder?: number, omittedEndpointAxis?: 'x' | 'y' }[]} lines
104
+ * @returns {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string, recordType?: string, lineStyle?: number, renderOrder?: number, omittedEndpointAxis?: 'x' | 'y' }[]}
105
+ */
106
+ static #restoreStandaloneCalloutLeaderEndpoints(lines) {
107
+ return lines.map((line, index) => {
108
+ const endpoint =
109
+ SchematicWireNormalizer.#resolveStandaloneCalloutLeaderEndpoint(
110
+ line,
111
+ index,
112
+ lines
113
+ )
114
+
115
+ if (!endpoint) {
116
+ return line
117
+ }
118
+
119
+ return {
120
+ ...line,
121
+ x2: endpoint.x,
122
+ y2: endpoint.y
123
+ }
124
+ })
125
+ }
126
+
127
+ /**
128
+ * Resolves corrected points for one standalone callout arrowhead.
129
+ * @param {{ points: { x: number, y: number }[], isSolid?: boolean, transparent?: boolean, ownerIndex?: string, renderOrder?: number }} polygon
130
+ * @param {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string, recordType?: string, lineStyle?: number, renderOrder?: number }[]} lines
131
+ * @returns {{ x: number, y: number }[] | null}
132
+ */
133
+ static #resolveStandaloneCalloutArrowheadPoints(polygon, lines) {
134
+ if (!SchematicWireNormalizer.#isStandaloneArrowheadPolygon(polygon)) {
135
+ return null
136
+ }
137
+
138
+ const [tip, firstBasePoint, carriedBasePoint] = polygon.points
139
+ const leaderLine =
140
+ SchematicWireNormalizer.#findStandaloneArrowheadLeaderLine(
141
+ polygon,
142
+ tip,
143
+ lines
144
+ )
145
+
146
+ if (!leaderLine) {
147
+ return null
148
+ }
149
+
150
+ const leaderEnd = SchematicWireNormalizer.#resolveOtherEndpoint(
151
+ leaderLine,
152
+ tip
153
+ )
154
+ if (!leaderEnd) {
155
+ return null
156
+ }
157
+
158
+ const reflectedPoint = SchematicWireNormalizer.#reflectPointAcrossLine(
159
+ firstBasePoint,
160
+ tip,
161
+ leaderEnd
162
+ )
163
+ const recoveredBasePoint =
164
+ SchematicWireNormalizer.#resolveRecoveredArrowheadBasePoint(
165
+ firstBasePoint,
166
+ carriedBasePoint,
167
+ reflectedPoint
168
+ )
169
+
170
+ if (!recoveredBasePoint) {
171
+ return null
172
+ }
173
+
174
+ return SchematicWireNormalizer.#expandStandaloneArrowheadPoints(
175
+ tip,
176
+ firstBasePoint,
177
+ recoveredBasePoint
178
+ )
179
+ }
180
+
181
+ /**
182
+ * Expands recovered standalone callout arrowheads from the raw truncated
183
+ * polygon dimensions to Altium's rendered arrowhead size.
184
+ * @param {{ x: number, y: number }} tip
185
+ * @param {{ x: number, y: number }} firstBasePoint
186
+ * @param {{ x: number, y: number }} secondBasePoint
187
+ * @returns {{ x: number, y: number }[]}
188
+ */
189
+ static #expandStandaloneArrowheadPoints(
190
+ tip,
191
+ firstBasePoint,
192
+ secondBasePoint
193
+ ) {
194
+ return [
195
+ tip,
196
+ SchematicWireNormalizer.#scalePointFromTip(
197
+ tip,
198
+ firstBasePoint,
199
+ SchematicWireNormalizer.#CALLOUT_ARROWHEAD_SCALE
200
+ ),
201
+ SchematicWireNormalizer.#scalePointFromTip(
202
+ tip,
203
+ secondBasePoint,
204
+ SchematicWireNormalizer.#CALLOUT_ARROWHEAD_SCALE
205
+ )
206
+ ]
207
+ }
208
+
209
+ /**
210
+ * Scales one point away from an arrowhead tip.
211
+ * @param {{ x: number, y: number }} tip
212
+ * @param {{ x: number, y: number }} point
213
+ * @param {number} scale
214
+ * @returns {{ x: number, y: number }}
215
+ */
216
+ static #scalePointFromTip(tip, point, scale) {
217
+ return {
218
+ x: SchematicWireNormalizer.#normalizeRecoveredCoordinate(
219
+ tip.x + (point.x - tip.x) * scale
220
+ ),
221
+ y: SchematicWireNormalizer.#normalizeRecoveredCoordinate(
222
+ tip.y + (point.y - tip.y) * scale
223
+ )
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Returns true when a polygon has the standalone filled triangle shape used
229
+ * by note callout arrowheads.
230
+ * @param {{ points: { x: number, y: number }[], isSolid?: boolean, transparent?: boolean, ownerIndex?: string }} polygon
231
+ * @returns {boolean}
232
+ */
233
+ static #isStandaloneArrowheadPolygon(polygon) {
234
+ return (
235
+ !polygon.ownerIndex &&
236
+ polygon.isSolid === true &&
237
+ polygon.transparent !== true &&
238
+ polygon.points?.length === 3
239
+ )
240
+ }
241
+
242
+ /**
243
+ * Finds the diagonal dashed leader touching one arrowhead tip.
244
+ * @param {{ renderOrder?: number }} polygon
245
+ * @param {{ x: number, y: number }} tip
246
+ * @param {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string, recordType?: string, lineStyle?: number, renderOrder?: number }[]} lines
247
+ * @returns {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string, recordType?: string, lineStyle?: number, renderOrder?: number } | null}
248
+ */
249
+ static #findStandaloneArrowheadLeaderLine(polygon, tip, lines) {
250
+ const candidates = lines
251
+ .filter(
252
+ (line) =>
253
+ line.recordType === '6' &&
254
+ !line.ownerIndex &&
255
+ line.lineStyle === 1 &&
256
+ SchematicWireNormalizer.#isDiagonalLine(line) &&
257
+ SchematicWireNormalizer.#lineTouchesPoint(line, tip)
258
+ )
259
+ .map((line) => ({
260
+ line,
261
+ orderDistance: Math.abs(
262
+ Number(polygon.renderOrder || 0) -
263
+ Number(line.renderOrder || 0)
264
+ )
265
+ }))
266
+ .filter(({ orderDistance }) => orderDistance <= 2)
267
+ .sort((left, right) => left.orderDistance - right.orderDistance)
268
+
269
+ return candidates[0]?.line || null
270
+ }
271
+
272
+ /**
273
+ * Resolves the missing second base point from the reflected counterpart.
274
+ * @param {{ x: number, y: number }} firstBasePoint
275
+ * @param {{ x: number, y: number }} carriedBasePoint
276
+ * @param {{ x: number, y: number }} reflectedPoint
277
+ * @returns {{ x: number, y: number } | null}
278
+ */
279
+ static #resolveRecoveredArrowheadBasePoint(
280
+ firstBasePoint,
281
+ carriedBasePoint,
282
+ reflectedPoint
283
+ ) {
284
+ if (
285
+ firstBasePoint.y === carriedBasePoint.y &&
286
+ SchematicWireNormalizer.#nearlyEqual(
287
+ reflectedPoint.x,
288
+ carriedBasePoint.x
289
+ )
290
+ ) {
291
+ return {
292
+ x: carriedBasePoint.x,
293
+ y: SchematicWireNormalizer.#normalizeRecoveredCoordinate(
294
+ reflectedPoint.y
295
+ )
296
+ }
297
+ }
298
+
299
+ if (
300
+ firstBasePoint.x === carriedBasePoint.x &&
301
+ SchematicWireNormalizer.#nearlyEqual(
302
+ reflectedPoint.y,
303
+ carriedBasePoint.y
304
+ )
305
+ ) {
306
+ return {
307
+ x: SchematicWireNormalizer.#normalizeRecoveredCoordinate(
308
+ reflectedPoint.x
309
+ ),
310
+ y: carriedBasePoint.y
311
+ }
312
+ }
313
+
314
+ return null
315
+ }
316
+
317
+ /**
318
+ * Mirrors one point across a leader axis.
319
+ * @param {{ x: number, y: number }} point
320
+ * @param {{ x: number, y: number }} lineStart
321
+ * @param {{ x: number, y: number }} lineEnd
322
+ * @returns {{ x: number, y: number }}
323
+ */
324
+ static #reflectPointAcrossLine(point, lineStart, lineEnd) {
325
+ const dx = lineEnd.x - lineStart.x
326
+ const dy = lineEnd.y - lineStart.y
327
+ const lengthSquared = dx * dx + dy * dy
328
+
329
+ if (lengthSquared <= 0) {
330
+ return point
331
+ }
332
+
333
+ const projectionScale =
334
+ ((point.x - lineStart.x) * dx + (point.y - lineStart.y) * dy) /
335
+ lengthSquared
336
+ const projection = {
337
+ x: lineStart.x + projectionScale * dx,
338
+ y: lineStart.y + projectionScale * dy
339
+ }
340
+
341
+ return {
342
+ x: projection.x * 2 - point.x,
343
+ y: projection.y * 2 - point.y
344
+ }
345
+ }
346
+
347
+ /**
348
+ * Updates one matching record-7 outline segment to the normalized arrowhead
349
+ * points.
350
+ * @param {{ x1: number, y1: number, x2: number, y2: number, recordType?: string, renderOrder?: number }} line
351
+ * @param {{ renderOrder?: number, points: { x: number, y: number }[] }[]} updates
352
+ * @returns {{ x1: number, y1: number, x2: number, y2: number, recordType?: string, renderOrder?: number }}
353
+ */
354
+ static #normalizeCalloutArrowheadOutlineLine(line, updates) {
355
+ for (const update of updates) {
356
+ const segmentIndex =
357
+ SchematicWireNormalizer.#resolveArrowheadOutlineSegmentIndex(
358
+ line,
359
+ update.renderOrder
360
+ )
361
+
362
+ if (segmentIndex === null) {
363
+ continue
364
+ }
365
+
366
+ const startPoint = update.points[segmentIndex]
367
+ const endPoint = update.points[(segmentIndex + 1) % 3]
368
+
369
+ return {
370
+ ...line,
371
+ x1: startPoint.x,
372
+ y1: startPoint.y,
373
+ x2: endPoint.x,
374
+ y2: endPoint.y
375
+ }
376
+ }
377
+
378
+ return line
379
+ }
380
+
381
+ /**
382
+ * Resolves which outline segment a record-7 line belongs to.
383
+ * @param {{ recordType?: string, renderOrder?: number }} line
384
+ * @param {number | undefined} polygonRenderOrder
385
+ * @returns {number | null}
386
+ */
387
+ static #resolveArrowheadOutlineSegmentIndex(line, polygonRenderOrder) {
388
+ if (
389
+ line.recordType !== '7' ||
390
+ !Number.isFinite(line.renderOrder) ||
391
+ !Number.isFinite(polygonRenderOrder)
392
+ ) {
393
+ return null
394
+ }
395
+
396
+ const segmentIndex = Math.round(
397
+ (line.renderOrder - polygonRenderOrder) * 100
398
+ )
399
+ const expectedRenderOrder = polygonRenderOrder + segmentIndex / 100
400
+
401
+ if (
402
+ segmentIndex < 0 ||
403
+ segmentIndex >= 3 ||
404
+ !SchematicWireNormalizer.#nearlyEqual(
405
+ line.renderOrder,
406
+ expectedRenderOrder
407
+ )
408
+ ) {
409
+ return null
410
+ }
411
+
412
+ return segmentIndex
413
+ }
414
+
415
+ /**
416
+ * Resolves the nearby dashed frame endpoint for one callout leader.
417
+ * @param {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string, recordType?: string, lineStyle?: number, renderOrder?: number, omittedEndpointAxis?: 'x' | 'y' }} line
418
+ * @param {number} index
419
+ * @param {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string, recordType?: string, lineStyle?: number, renderOrder?: number }[]} lines
420
+ * @returns {{ x: number, y: number } | null}
421
+ */
422
+ static #resolveStandaloneCalloutLeaderEndpoint(line, index, lines) {
423
+ if (!SchematicWireNormalizer.#isStandaloneCalloutLeader(line)) {
424
+ return null
425
+ }
426
+
427
+ const carriedEndpoint = { x: line.x2, y: line.y2 }
428
+ const startPoint = { x: line.x1, y: line.y1 }
429
+ const candidates = []
430
+
431
+ for (
432
+ let candidateIndex = 0;
433
+ candidateIndex < lines.length;
434
+ candidateIndex += 1
435
+ ) {
436
+ if (candidateIndex === index) {
437
+ continue
438
+ }
439
+
440
+ const frameLine = lines[candidateIndex]
441
+ if (
442
+ !SchematicWireNormalizer.#isStandaloneCalloutFrameLine(
443
+ frameLine,
444
+ line
445
+ )
446
+ ) {
447
+ continue
448
+ }
449
+
450
+ for (const framePoint of [
451
+ { x: frameLine.x1, y: frameLine.y1 },
452
+ { x: frameLine.x2, y: frameLine.y2 }
453
+ ]) {
454
+ const endpoint =
455
+ SchematicWireNormalizer.#buildCalloutLeaderEndpoint(
456
+ line,
457
+ carriedEndpoint,
458
+ framePoint
459
+ )
460
+
461
+ if (
462
+ !endpoint ||
463
+ !SchematicWireNormalizer.#isDiagonalBetween(
464
+ startPoint,
465
+ endpoint
466
+ )
467
+ ) {
468
+ continue
469
+ }
470
+
471
+ const distance = SchematicWireNormalizer.#axisDistance(
472
+ carriedEndpoint,
473
+ endpoint
474
+ )
475
+ if (
476
+ distance > SchematicWireNormalizer.#MAX_CALLOUT_LEADER_SPAN
477
+ ) {
478
+ continue
479
+ }
480
+
481
+ candidates.push({
482
+ endpoint,
483
+ distance,
484
+ orderDistance: Math.abs(
485
+ Number(line.renderOrder || 0) -
486
+ Number(frameLine.renderOrder || 0)
487
+ )
488
+ })
489
+ }
490
+ }
491
+
492
+ candidates.sort(
493
+ (left, right) =>
494
+ left.distance - right.distance ||
495
+ left.orderDistance - right.orderDistance
496
+ )
497
+
498
+ return candidates[0]?.endpoint || null
499
+ }
500
+
501
+ /**
502
+ * Builds a recovered leader endpoint from one candidate frame point.
503
+ * @param {{ omittedEndpointAxis?: 'x' | 'y' }} line
504
+ * @param {{ x: number, y: number }} carriedEndpoint
505
+ * @param {{ x: number, y: number }} framePoint
506
+ * @returns {{ x: number, y: number } | null}
507
+ */
508
+ static #buildCalloutLeaderEndpoint(line, carriedEndpoint, framePoint) {
509
+ if (
510
+ line.omittedEndpointAxis === 'y' &&
511
+ framePoint.x === carriedEndpoint.x &&
512
+ framePoint.y !== carriedEndpoint.y
513
+ ) {
514
+ return { x: carriedEndpoint.x, y: framePoint.y }
515
+ }
516
+
517
+ if (
518
+ line.omittedEndpointAxis === 'x' &&
519
+ framePoint.y === carriedEndpoint.y &&
520
+ framePoint.x !== carriedEndpoint.x
521
+ ) {
522
+ return { x: framePoint.x, y: carriedEndpoint.y }
523
+ }
524
+
525
+ return null
526
+ }
527
+
528
+ /**
529
+ * Returns true when a record-6 line looks like an unowned dashed callout
530
+ * leader whose endpoint carried an omitted coordinate.
531
+ * @param {{ ownerIndex?: string, recordType?: string, lineStyle?: number, omittedEndpointAxis?: 'x' | 'y', sourceLocationCount?: number }} line
532
+ * @returns {boolean}
533
+ */
534
+ static #isStandaloneCalloutLeader(line) {
535
+ return (
536
+ line.recordType === '6' &&
537
+ !line.ownerIndex &&
538
+ line.lineStyle === 1 &&
539
+ line.sourceLocationCount === 2 &&
540
+ Boolean(line.omittedEndpointAxis)
541
+ )
542
+ }
543
+
544
+ /**
545
+ * Returns true when one record-6 line can supply a dashed callout frame
546
+ * corner for the candidate leader.
547
+ * @param {{ ownerIndex?: string, recordType?: string, lineStyle?: number }} frameLine
548
+ * @param {{ lineStyle?: number }} leaderLine
549
+ * @returns {boolean}
550
+ */
551
+ static #isStandaloneCalloutFrameLine(frameLine, leaderLine) {
552
+ return (
553
+ frameLine.recordType === '6' &&
554
+ !frameLine.ownerIndex &&
555
+ frameLine.lineStyle === leaderLine.lineStyle &&
556
+ !SchematicWireNormalizer.#isCollapsedLine(frameLine)
557
+ )
558
+ }
559
+
560
+ /**
561
+ * Restores a carried final axis when paired diagonal wire legs prove the
562
+ * endpoint should land on a neighboring pin instead of flattening.
563
+ * @param {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string, recordType?: string, omittedEndpointAxis?: 'x' | 'y' }[]} lines
564
+ * @param {{ x: number, y: number, length: number, orientation: 'left' | 'right' | 'top' | 'bottom' }[]} pins
565
+ * @returns {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string, recordType?: string, omittedEndpointAxis?: 'x' | 'y' }[]}
566
+ */
567
+ static #restoreCrossedPolylineEndpoints(lines, pins) {
568
+ const pinEndpoints = pins.map((pin) =>
569
+ SchematicWireNormalizer.#projectPinEndpoint(pin)
570
+ )
571
+
572
+ return lines.map((line, index) => {
573
+ const startPoint =
574
+ SchematicWireNormalizer.#resolveCrossedPolylineEndpoint(
575
+ line,
576
+ index,
577
+ lines,
578
+ pinEndpoints,
579
+ 'start'
580
+ )
581
+
582
+ if (startPoint) {
583
+ return { ...line, x1: startPoint.x, y1: startPoint.y }
584
+ }
585
+
586
+ const endPoint =
587
+ SchematicWireNormalizer.#resolveCrossedPolylineEndpoint(
588
+ line,
589
+ index,
590
+ lines,
591
+ pinEndpoints,
592
+ 'end'
593
+ )
594
+
595
+ if (endPoint) {
596
+ return { ...line, x2: endPoint.x, y2: endPoint.y }
597
+ }
598
+
599
+ return line
600
+ })
601
+ }
602
+
603
+ /**
604
+ * Resolves one endpoint of a flattened crossed-wire segment.
605
+ * @param {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string, recordType?: string, omittedEndpointAxis?: 'x' | 'y' }} line
606
+ * @param {number} index
607
+ * @param {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string, recordType?: string }[]} lines
608
+ * @param {{ x: number, y: number }[]} pinEndpoints
609
+ * @param {'start' | 'end'} terminal
610
+ * @returns {{ x: number, y: number } | null}
611
+ */
612
+ static #resolveCrossedPolylineEndpoint(
613
+ line,
614
+ index,
615
+ lines,
616
+ pinEndpoints,
617
+ terminal
618
+ ) {
619
+ if (
620
+ !SchematicWireNormalizer.#isUnownedWire(line) ||
621
+ !line.omittedEndpointAxis ||
622
+ SchematicWireNormalizer.#isCollapsedLine(line)
623
+ ) {
624
+ return null
625
+ }
626
+
627
+ const axis = SchematicWireNormalizer.#resolveLineAxis(line)
628
+
629
+ if (!axis) {
630
+ return null
631
+ }
632
+
633
+ const carriedPoint =
634
+ terminal === 'start'
635
+ ? { x: line.x1, y: line.y1 }
636
+ : { x: line.x2, y: line.y2 }
637
+ const fixedPoint =
638
+ terminal === 'start'
639
+ ? { x: line.x2, y: line.y2 }
640
+ : { x: line.x1, y: line.y1 }
641
+
642
+ for (
643
+ let candidateIndex = 0;
644
+ candidateIndex < lines.length;
645
+ candidateIndex += 1
646
+ ) {
647
+ if (candidateIndex === index) {
648
+ continue
649
+ }
650
+
651
+ const diagonalLine = lines[candidateIndex]
652
+
653
+ if (
654
+ !SchematicWireNormalizer.#isUnownedWire(diagonalLine) ||
655
+ !SchematicWireNormalizer.#isDiagonalLine(diagonalLine)
656
+ ) {
657
+ continue
658
+ }
659
+
660
+ const diagonalPoint = SchematicWireNormalizer.#resolveOtherEndpoint(
661
+ diagonalLine,
662
+ carriedPoint
663
+ )
664
+
665
+ if (!diagonalPoint) {
666
+ continue
667
+ }
668
+
669
+ const recoveredPoint =
670
+ axis === 'horizontal'
671
+ ? { x: carriedPoint.x, y: diagonalPoint.y }
672
+ : { x: diagonalPoint.x, y: carriedPoint.y }
673
+
674
+ if (
675
+ SchematicWireNormalizer.#pointsEqual(
676
+ recoveredPoint,
677
+ carriedPoint
678
+ ) ||
679
+ !SchematicWireNormalizer.#isDiagonalBetween(
680
+ fixedPoint,
681
+ recoveredPoint
682
+ ) ||
683
+ !SchematicWireNormalizer.#hasPoint(pinEndpoints, recoveredPoint)
684
+ ) {
685
+ continue
686
+ }
687
+
688
+ return recoveredPoint
689
+ }
690
+
691
+ return null
692
+ }
693
+
694
+ /**
695
+ * Resolves the endpoint for one collapsed wire segment.
696
+ * @param {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string, recordType?: string, omittedEndpointAxis?: 'x' | 'y' }} line
697
+ * @param {number} index
698
+ * @param {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string, recordType?: string }[]} lines
699
+ * @param {{ x: number, y: number, length: number, orientation: 'left' | 'right' | 'top' | 'bottom' }[]} pins
700
+ * @param {{ x: number, y: number }[]} junctions
701
+ * @returns {{ x: number, y: number } | null}
702
+ */
703
+ static #resolveCollapsedEndpoint(line, index, lines, pins, junctions) {
704
+ if (!SchematicWireNormalizer.#isCollapsedWire(line)) {
705
+ return null
706
+ }
707
+
708
+ const previousLine = lines[index - 1]
709
+ const sourcePoint = { x: line.x1, y: line.y1 }
710
+
711
+ if (
712
+ !previousLine ||
713
+ SchematicWireNormalizer.#isCollapsedLine(previousLine) ||
714
+ !SchematicWireNormalizer.#lineTouchesPoint(
715
+ previousLine,
716
+ sourcePoint
717
+ )
718
+ ) {
719
+ return null
720
+ }
721
+
722
+ const fallbackAxis =
723
+ SchematicWireNormalizer.#resolvePerpendicularAxis(previousLine)
724
+ const axes = line.omittedEndpointAxis
725
+ ? [line.omittedEndpointAxis]
726
+ : fallbackAxis
727
+ ? [fallbackAxis]
728
+ : ['x', 'y']
729
+
730
+ return SchematicWireNormalizer.#findNearestContinuationPoint(
731
+ sourcePoint,
732
+ axes,
733
+ pins,
734
+ lines,
735
+ index,
736
+ junctions
737
+ )
738
+ }
739
+
740
+ /**
741
+ * Returns true when a line is an unowned collapsed wire primitive.
742
+ * @param {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string, recordType?: string }} line
743
+ * @returns {boolean}
744
+ */
745
+ static #isCollapsedWire(line) {
746
+ return (
747
+ SchematicWireNormalizer.#isUnownedWire(line) &&
748
+ SchematicWireNormalizer.#isCollapsedLine(line)
749
+ )
750
+ }
751
+
752
+ /**
753
+ * Removes parser-only recovery hints before returning renderer lines.
754
+ * @param {{ x1: number, y1: number, x2: number, y2: number, omittedEndpointAxis?: 'x' | 'y', sourceLocationCount?: number, [key: string]: unknown }} line
755
+ * @returns {{ x1: number, y1: number, x2: number, y2: number, [key: string]: unknown }}
756
+ */
757
+ static #stripRecoveryMetadata(line) {
758
+ const { omittedEndpointAxis, sourceLocationCount, ...rendererLine } =
759
+ line
760
+ return rendererLine
761
+ }
762
+
763
+ /**
764
+ * Returns true when a line is an unowned schematic wire primitive.
765
+ * @param {{ ownerIndex?: string, recordType?: string }} line
766
+ * @returns {boolean}
767
+ */
768
+ static #isUnownedWire(line) {
769
+ return line.recordType === '27' && !line.ownerIndex
770
+ }
771
+
772
+ /**
773
+ * Returns true when a line has no drawable length.
774
+ * @param {{ x1: number, y1: number, x2: number, y2: number }} line
775
+ * @returns {boolean}
776
+ */
777
+ static #isCollapsedLine(line) {
778
+ return line.x1 === line.x2 && line.y1 === line.y2
779
+ }
780
+
781
+ /**
782
+ * Resolves the missing axis from the preceding segment orientation.
783
+ * @param {{ x1: number, y1: number, x2: number, y2: number }} line
784
+ * @returns {'x' | 'y' | null}
785
+ */
786
+ static #resolvePerpendicularAxis(line) {
787
+ if (line.y1 === line.y2 && line.x1 !== line.x2) {
788
+ return 'y'
789
+ }
790
+
791
+ if (line.x1 === line.x2 && line.y1 !== line.y2) {
792
+ return 'x'
793
+ }
794
+
795
+ return null
796
+ }
797
+
798
+ /**
799
+ * Resolves whether a line is horizontal or vertical.
800
+ * @param {{ x1: number, y1: number, x2: number, y2: number }} line
801
+ * @returns {'horizontal' | 'vertical' | null}
802
+ */
803
+ static #resolveLineAxis(line) {
804
+ if (line.y1 === line.y2 && line.x1 !== line.x2) {
805
+ return 'horizontal'
806
+ }
807
+
808
+ if (line.x1 === line.x2 && line.y1 !== line.y2) {
809
+ return 'vertical'
810
+ }
811
+
812
+ return null
813
+ }
814
+
815
+ /**
816
+ * Returns true when a line has both axes changing.
817
+ * @param {{ x1: number, y1: number, x2: number, y2: number }} line
818
+ * @returns {boolean}
819
+ */
820
+ static #isDiagonalLine(line) {
821
+ return line.x1 !== line.x2 && line.y1 !== line.y2
822
+ }
823
+
824
+ /**
825
+ * Finds the nearest aligned continuation for a collapsed segment point.
826
+ * @param {{ x: number, y: number }} sourcePoint
827
+ * @param {('x' | 'y')[]} axes
828
+ * @param {{ x: number, y: number, length: number, orientation: 'left' | 'right' | 'top' | 'bottom' }[]} pins
829
+ * @param {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string, recordType?: string }[]} lines
830
+ * @param {number} currentIndex
831
+ * @param {{ x: number, y: number }[]} junctions
832
+ * @returns {{ x: number, y: number } | null}
833
+ */
834
+ static #findNearestContinuationPoint(
835
+ sourcePoint,
836
+ axes,
837
+ pins,
838
+ lines,
839
+ currentIndex,
840
+ junctions
841
+ ) {
842
+ const junctionCandidates =
843
+ SchematicWireNormalizer.#collectAlignedJunctionCandidates(
844
+ sourcePoint,
845
+ axes,
846
+ junctions
847
+ )
848
+ const pinCandidates = pins
849
+ .map((pin) => SchematicWireNormalizer.#projectPinEndpoint(pin))
850
+ .filter((endpoint) =>
851
+ SchematicWireNormalizer.#isAlignedWithAnyAxis(
852
+ sourcePoint,
853
+ endpoint,
854
+ axes
855
+ )
856
+ )
857
+ .map((endpoint) => ({
858
+ ...SchematicWireNormalizer.#buildCandidate(
859
+ sourcePoint,
860
+ endpoint
861
+ ),
862
+ priority: 1
863
+ }))
864
+ .filter(({ distance }) =>
865
+ SchematicWireNormalizer.#isRecoverableDistance(distance)
866
+ )
867
+
868
+ const lineCandidates =
869
+ SchematicWireNormalizer.#collectAlignedLineCandidates(
870
+ sourcePoint,
871
+ axes,
872
+ lines,
873
+ currentIndex
874
+ )
875
+
876
+ const candidates = [
877
+ ...junctionCandidates,
878
+ ...pinCandidates,
879
+ ...lineCandidates
880
+ ].sort(
881
+ (left, right) =>
882
+ left.priority - right.priority || left.distance - right.distance
883
+ )
884
+
885
+ return candidates[0]?.endpoint || null
886
+ }
887
+
888
+ /**
889
+ * Collects authored junctions aligned with the missing endpoint axis.
890
+ * @param {{ x: number, y: number }} sourcePoint
891
+ * @param {('x' | 'y')[]} axes
892
+ * @param {{ x: number, y: number }[]} junctions
893
+ * @returns {{ endpoint: { x: number, y: number }, distance: number, priority: number }[]}
894
+ */
895
+ static #collectAlignedJunctionCandidates(sourcePoint, axes, junctions) {
896
+ return junctions
897
+ .filter((junction) =>
898
+ SchematicWireNormalizer.#isAlignedWithAnyAxis(
899
+ sourcePoint,
900
+ junction,
901
+ axes
902
+ )
903
+ )
904
+ .map((junction) => ({
905
+ ...SchematicWireNormalizer.#buildCandidate(
906
+ sourcePoint,
907
+ junction
908
+ ),
909
+ priority: 0
910
+ }))
911
+ .filter(({ distance }) =>
912
+ SchematicWireNormalizer.#isRecoverableDistance(distance)
913
+ )
914
+ }
915
+
916
+ /**
917
+ * Collects nearby same-axis wire continuations for a collapsed point.
918
+ * @param {{ x: number, y: number }} sourcePoint
919
+ * @param {('x' | 'y')[]} axes
920
+ * @param {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string, recordType?: string }[]} lines
921
+ * @param {number} currentIndex
922
+ * @returns {{ endpoint: { x: number, y: number }, distance: number, priority: number }[]}
923
+ */
924
+ static #collectAlignedLineCandidates(
925
+ sourcePoint,
926
+ axes,
927
+ lines,
928
+ currentIndex
929
+ ) {
930
+ const candidates = []
931
+
932
+ for (let index = 0; index < lines.length; index += 1) {
933
+ if (index === currentIndex) {
934
+ continue
935
+ }
936
+
937
+ const line = lines[index]
938
+
939
+ if (
940
+ !SchematicWireNormalizer.#isUnownedWire(line) ||
941
+ SchematicWireNormalizer.#isCollapsedLine(line)
942
+ ) {
943
+ continue
944
+ }
945
+
946
+ if (
947
+ axes.includes('y') &&
948
+ line.y1 === line.y2 &&
949
+ SchematicWireNormalizer.#between(
950
+ sourcePoint.x,
951
+ line.x1,
952
+ line.x2
953
+ )
954
+ ) {
955
+ candidates.push({
956
+ ...SchematicWireNormalizer.#buildCandidate(sourcePoint, {
957
+ x: sourcePoint.x,
958
+ y: line.y1
959
+ }),
960
+ priority: 1
961
+ })
962
+ }
963
+
964
+ if (
965
+ axes.includes('x') &&
966
+ line.x1 === line.x2 &&
967
+ SchematicWireNormalizer.#between(
968
+ sourcePoint.y,
969
+ line.y1,
970
+ line.y2
971
+ )
972
+ ) {
973
+ candidates.push({
974
+ ...SchematicWireNormalizer.#buildCandidate(sourcePoint, {
975
+ x: line.x1,
976
+ y: sourcePoint.y
977
+ }),
978
+ priority: 1
979
+ })
980
+ }
981
+ }
982
+
983
+ return candidates.filter(({ distance }) =>
984
+ SchematicWireNormalizer.#isRecoverableDistance(distance)
985
+ )
986
+ }
987
+
988
+ /**
989
+ * Returns a distance-scored continuation candidate.
990
+ * @param {{ x: number, y: number }} sourcePoint
991
+ * @param {{ x: number, y: number }} endpoint
992
+ * @returns {{ endpoint: { x: number, y: number }, distance: number }}
993
+ */
994
+ static #buildCandidate(sourcePoint, endpoint) {
995
+ return {
996
+ endpoint,
997
+ distance: SchematicWireNormalizer.#axisDistance(
998
+ sourcePoint,
999
+ endpoint
1000
+ )
1001
+ }
1002
+ }
1003
+
1004
+ /**
1005
+ * Returns true when a point lies on any requested missing axis.
1006
+ * @param {{ x: number, y: number }} sourcePoint
1007
+ * @param {{ x: number, y: number }} endpoint
1008
+ * @param {('x' | 'y')[]} axes
1009
+ * @returns {boolean}
1010
+ */
1011
+ static #isAlignedWithAnyAxis(sourcePoint, endpoint, axes) {
1012
+ return axes.some((axis) =>
1013
+ axis === 'y'
1014
+ ? endpoint.x === sourcePoint.x
1015
+ : endpoint.y === sourcePoint.y
1016
+ )
1017
+ }
1018
+
1019
+ /**
1020
+ * Returns true when a recovery distance is useful and bounded.
1021
+ * @param {number} distance
1022
+ * @returns {boolean}
1023
+ */
1024
+ static #isRecoverableDistance(distance) {
1025
+ return (
1026
+ distance > 0 &&
1027
+ distance <= SchematicWireNormalizer.#MAX_COLLAPSED_PIN_SPAN
1028
+ )
1029
+ }
1030
+
1031
+ /**
1032
+ * Returns the Manhattan distance for axis-aligned endpoint recovery.
1033
+ * @param {{ x: number, y: number }} sourcePoint
1034
+ * @param {{ x: number, y: number }} endpoint
1035
+ * @returns {number}
1036
+ */
1037
+ static #axisDistance(sourcePoint, endpoint) {
1038
+ return (
1039
+ Math.abs(sourcePoint.x - endpoint.x) +
1040
+ Math.abs(sourcePoint.y - endpoint.y)
1041
+ )
1042
+ }
1043
+
1044
+ /**
1045
+ * Returns true when one line touches a point at either endpoint.
1046
+ * @param {{ x1: number, y1: number, x2: number, y2: number }} line
1047
+ * @param {{ x: number, y: number }} point
1048
+ * @returns {boolean}
1049
+ */
1050
+ static #lineTouchesPoint(line, point) {
1051
+ return (
1052
+ (line.x1 === point.x && line.y1 === point.y) ||
1053
+ (line.x2 === point.x && line.y2 === point.y)
1054
+ )
1055
+ }
1056
+
1057
+ /**
1058
+ * Returns the opposite endpoint if a line touches the requested point.
1059
+ * @param {{ x1: number, y1: number, x2: number, y2: number }} line
1060
+ * @param {{ x: number, y: number }} point
1061
+ * @returns {{ x: number, y: number } | null}
1062
+ */
1063
+ static #resolveOtherEndpoint(line, point) {
1064
+ if (line.x1 === point.x && line.y1 === point.y) {
1065
+ return { x: line.x2, y: line.y2 }
1066
+ }
1067
+
1068
+ if (line.x2 === point.x && line.y2 === point.y) {
1069
+ return { x: line.x1, y: line.y1 }
1070
+ }
1071
+
1072
+ return null
1073
+ }
1074
+
1075
+ /**
1076
+ * Returns true when two points share the same coordinates.
1077
+ * @param {{ x: number, y: number }} left
1078
+ * @param {{ x: number, y: number }} right
1079
+ * @returns {boolean}
1080
+ */
1081
+ static #pointsEqual(left, right) {
1082
+ return left.x === right.x && left.y === right.y
1083
+ }
1084
+
1085
+ /**
1086
+ * Returns true when two coordinates are equivalent within parser rounding
1087
+ * tolerance.
1088
+ * @param {number} left
1089
+ * @param {number} right
1090
+ * @returns {boolean}
1091
+ */
1092
+ static #nearlyEqual(left, right) {
1093
+ return Math.abs(left - right) <= 1e-6
1094
+ }
1095
+
1096
+ /**
1097
+ * Stabilizes recovered floating-point coordinates.
1098
+ * @param {number} value
1099
+ * @returns {number}
1100
+ */
1101
+ static #normalizeRecoveredCoordinate(value) {
1102
+ const rounded = Math.round(value)
1103
+
1104
+ if (SchematicWireNormalizer.#nearlyEqual(value, rounded)) {
1105
+ return rounded
1106
+ }
1107
+
1108
+ return Number(value.toFixed(3))
1109
+ }
1110
+
1111
+ /**
1112
+ * Returns true when a list contains one point.
1113
+ * @param {{ x: number, y: number }[]} points
1114
+ * @param {{ x: number, y: number }} target
1115
+ * @returns {boolean}
1116
+ */
1117
+ static #hasPoint(points, target) {
1118
+ return points.some((point) =>
1119
+ SchematicWireNormalizer.#pointsEqual(point, target)
1120
+ )
1121
+ }
1122
+
1123
+ /**
1124
+ * Returns true when two points form a diagonal segment.
1125
+ * @param {{ x: number, y: number }} left
1126
+ * @param {{ x: number, y: number }} right
1127
+ * @returns {boolean}
1128
+ */
1129
+ static #isDiagonalBetween(left, right) {
1130
+ return left.x !== right.x && left.y !== right.y
1131
+ }
1132
+
1133
+ /**
1134
+ * Returns true when a value lies inside an unordered inclusive range.
1135
+ * @param {number} value
1136
+ * @param {number} left
1137
+ * @param {number} right
1138
+ * @returns {boolean}
1139
+ */
1140
+ static #between(value, left, right) {
1141
+ return value >= Math.min(left, right) && value <= Math.max(left, right)
1142
+ }
1143
+
1144
+ /**
1145
+ * Projects one pin into its wire-connected endpoint.
1146
+ * @param {{ x: number, y: number, length: number, orientation: 'left' | 'right' | 'top' | 'bottom' }} pin
1147
+ * @returns {{ x: number, y: number }}
1148
+ */
1149
+ static #projectPinEndpoint(pin) {
1150
+ switch (pin.orientation) {
1151
+ case 'right':
1152
+ return { x: pin.x + pin.length, y: pin.y }
1153
+ case 'top':
1154
+ return { x: pin.x, y: pin.y + pin.length }
1155
+ case 'bottom':
1156
+ return { x: pin.x, y: pin.y - pin.length }
1157
+ case 'left':
1158
+ default:
1159
+ return { x: pin.x - pin.length, y: pin.y }
1160
+ }
1161
+ }
1162
+ }