altium-toolkit 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 (82) hide show
  1. package/AGENTS.md +67 -0
  2. package/COMMERCIAL-LICENSE.md +20 -0
  3. package/CONTRIBUTING.md +19 -0
  4. package/LICENSE +22 -0
  5. package/LICENSES/CC-BY-SA-4.0.txt +170 -0
  6. package/LICENSES/GPL-3.0-or-later.txt +232 -0
  7. package/NOTICE.md +32 -0
  8. package/README.md +116 -0
  9. package/docs/api.md +73 -0
  10. package/docs/model-format.md +36 -0
  11. package/docs/testing.md +25 -0
  12. package/examples/README.md +47 -0
  13. package/examples/arduino-uno/PcbThreeSceneRenderer.mjs +635 -0
  14. package/examples/arduino-uno/SvgViewportController.mjs +306 -0
  15. package/examples/arduino-uno/example.mjs +480 -0
  16. package/examples/arduino-uno/index.html +163 -0
  17. package/examples/arduino-uno/styles.css +552 -0
  18. package/examples/server.mjs +212 -0
  19. package/package.json +53 -0
  20. package/spec/library-scope.md +32 -0
  21. package/src/core/BinaryReader.mjs +127 -0
  22. package/src/core/altium/AltiumLayoutParser.mjs +485 -0
  23. package/src/core/altium/AltiumParser.mjs +1007 -0
  24. package/src/core/altium/AsciiRecordParser.mjs +151 -0
  25. package/src/core/altium/ParserUtils.mjs +173 -0
  26. package/src/core/altium/PcbBinaryPrimitiveParser.mjs +424 -0
  27. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +505 -0
  28. package/src/core/altium/PcbModelParser.mjs +336 -0
  29. package/src/core/altium/PcbOutlineRasterizer.mjs +852 -0
  30. package/src/core/altium/PcbOutlineRecovery.mjs +957 -0
  31. package/src/core/altium/PcbStreamExtractor.mjs +210 -0
  32. package/src/core/altium/PrintableTextDecoder.mjs +156 -0
  33. package/src/core/altium/SchematicAnnotationParser.mjs +220 -0
  34. package/src/core/altium/SchematicBusEntryParser.mjs +48 -0
  35. package/src/core/altium/SchematicDirectiveParser.mjs +47 -0
  36. package/src/core/altium/SchematicImageParser.mjs +173 -0
  37. package/src/core/altium/SchematicJunctionParser.mjs +43 -0
  38. package/src/core/altium/SchematicMultipartOwnerMatcher.mjs +564 -0
  39. package/src/core/altium/SchematicNetlistBuilder.mjs +351 -0
  40. package/src/core/altium/SchematicPinParser.mjs +767 -0
  41. package/src/core/altium/SchematicPrimitiveParser.mjs +716 -0
  42. package/src/core/altium/SchematicSheetParser.mjs +241 -0
  43. package/src/core/altium/SchematicSheetStyleResolver.mjs +46 -0
  44. package/src/core/altium/SchematicStandaloneCalloutNormalizer.mjs +592 -0
  45. package/src/core/altium/SchematicTextParser.mjs +708 -0
  46. package/src/core/altium/SchematicTextPostProcessor.mjs +801 -0
  47. package/src/core/ole/OleCompoundDocument.mjs +439 -0
  48. package/src/core/ole/OleConstants.mjs +64 -0
  49. package/src/core/ole/OleDirectoryEntry.mjs +95 -0
  50. package/src/index.mjs +7 -0
  51. package/src/parser.mjs +21 -0
  52. package/src/renderers.mjs +15 -0
  53. package/src/scene3d.mjs +9 -0
  54. package/src/styles/altium-renderers.css +358 -0
  55. package/src/ui/BomTableRenderer.mjs +46 -0
  56. package/src/ui/PcbArcUtils.mjs +189 -0
  57. package/src/ui/PcbEdgeFacingGlyphNormalizer.mjs +808 -0
  58. package/src/ui/PcbFootprintPrimitiveSelector.mjs +128 -0
  59. package/src/ui/PcbScene3dBuilder.mjs +742 -0
  60. package/src/ui/PcbScene3dModelRegistry.mjs +309 -0
  61. package/src/ui/PcbScene3dPackages.mjs +137 -0
  62. package/src/ui/PcbScene3dScenePreparator.mjs +36 -0
  63. package/src/ui/PcbScene3dSummaryRenderer.mjs +65 -0
  64. package/src/ui/PcbSvgRenderer.mjs +906 -0
  65. package/src/ui/SchematicColorResolver.mjs +132 -0
  66. package/src/ui/SchematicContentLayout.mjs +661 -0
  67. package/src/ui/SchematicDirectiveRenderer.mjs +184 -0
  68. package/src/ui/SchematicImageRenderer.mjs +135 -0
  69. package/src/ui/SchematicJunctionRenderer.mjs +381 -0
  70. package/src/ui/SchematicNoteRenderer.mjs +427 -0
  71. package/src/ui/SchematicOwnerPinLabelLayout.mjs +173 -0
  72. package/src/ui/SchematicPinSvgRenderer.mjs +495 -0
  73. package/src/ui/SchematicPortRenderer.mjs +558 -0
  74. package/src/ui/SchematicPowerPortRenderer.mjs +574 -0
  75. package/src/ui/SchematicRegionRenderer.mjs +94 -0
  76. package/src/ui/SchematicShapeRenderer.mjs +398 -0
  77. package/src/ui/SchematicSheetChromeRenderer.mjs +1025 -0
  78. package/src/ui/SchematicSheetSymbolRenderer.mjs +228 -0
  79. package/src/ui/SchematicSvgRenderer.mjs +756 -0
  80. package/src/ui/SchematicSvgUtils.mjs +182 -0
  81. package/src/ui/SchematicTypography.mjs +204 -0
  82. package/src/workers/altium-parser.worker.mjs +29 -0
@@ -0,0 +1,801 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Applies placement-oriented cleanup passes to normalized schematic text.
7
+ */
8
+ export class SchematicTextPostProcessor {
9
+ /**
10
+ * Removes free text labels already covered by visible off-sheet ports.
11
+ * @param {{ x: number, y: number, text: string, recordType?: string }[]} texts
12
+ * @param {{ x: number, y: number, width: number, name: string }[]} ports
13
+ * @returns {{ x: number, y: number, text: string, recordType?: string }[]}
14
+ */
15
+ static dropDuplicatePortLabels(texts, ports) {
16
+ return texts.filter(
17
+ (text) =>
18
+ !ports.some((port) =>
19
+ SchematicTextPostProcessor.#isDuplicatePortLabel(text, port)
20
+ )
21
+ )
22
+ }
23
+
24
+ /**
25
+ * Returns true when one free wire label duplicates a visible off-sheet port
26
+ * label immediately beside the port body.
27
+ * @param {{ x: number, y: number, text: string, recordType?: string }} text
28
+ * @param {{ x: number, y: number, width: number, name: string }} port
29
+ * @returns {boolean}
30
+ */
31
+ static #isDuplicatePortLabel(text, port) {
32
+ if (
33
+ text.recordType !== '25' ||
34
+ port.name !== text.text ||
35
+ Math.abs(port.y - text.y) > 2
36
+ ) {
37
+ return false
38
+ }
39
+
40
+ if ((port.direction || 'right') !== 'left') {
41
+ return false
42
+ }
43
+
44
+ const maxGap = Math.max(port.width + 20, 80)
45
+
46
+ return text.x <= port.x && port.x - text.x <= maxGap
47
+ }
48
+
49
+ /**
50
+ * Adds multipart section suffixes like A/B/J to visible designator texts
51
+ * when the active Altium part id is stored separately from the base
52
+ * designator string.
53
+ * @param {{ text: string, name?: string, ownerIndex?: string, recordType?: string }[]} texts
54
+ * @param {Map<string, string>} activeMultipartOwnerParts
55
+ * @returns {{ text: string, name?: string, ownerIndex?: string, recordType?: string }[]}
56
+ */
57
+ static decorateMultipartDesignators(texts, activeMultipartOwnerParts) {
58
+ const duplicatedActiveBaseDesignators =
59
+ SchematicTextPostProcessor.#collectDuplicatedActiveMultipartDesignators(
60
+ texts,
61
+ activeMultipartOwnerParts
62
+ )
63
+
64
+ return texts.map((text) => {
65
+ const ownerIndex = String(text.ownerIndex || '')
66
+ const suffix =
67
+ SchematicTextPostProcessor.#formatMultipartPartSuffix(
68
+ activeMultipartOwnerParts.get(ownerIndex)
69
+ )
70
+
71
+ if (
72
+ !suffix ||
73
+ text.recordType !== '34' ||
74
+ String(text.name || '')
75
+ .trim()
76
+ .toLowerCase() !== 'designator' ||
77
+ !duplicatedActiveBaseDesignators.has(text.text)
78
+ ) {
79
+ return text
80
+ }
81
+
82
+ if (!/\d$/i.test(text.text) || text.text.endsWith(suffix)) {
83
+ return text
84
+ }
85
+
86
+ return {
87
+ ...text,
88
+ text: text.text + suffix
89
+ }
90
+ })
91
+ }
92
+
93
+ /**
94
+ * Collects base multipart designators that belong to more than one active
95
+ * visible owner on the current sheet.
96
+ * @param {{ text: string, name?: string, ownerIndex?: string, recordType?: string }[]} texts
97
+ * @param {Map<string, string>} activeMultipartOwnerParts
98
+ * @returns {Set<string>}
99
+ */
100
+ static #collectDuplicatedActiveMultipartDesignators(
101
+ texts,
102
+ activeMultipartOwnerParts
103
+ ) {
104
+ const counts = new Map()
105
+
106
+ for (const text of texts) {
107
+ const ownerIndex = String(text.ownerIndex || '')
108
+
109
+ if (
110
+ !activeMultipartOwnerParts.has(ownerIndex) ||
111
+ text.recordType !== '34' ||
112
+ String(text.name || '')
113
+ .trim()
114
+ .toLowerCase() !== 'designator' ||
115
+ !/\d$/i.test(String(text.text || '').trim())
116
+ ) {
117
+ continue
118
+ }
119
+
120
+ const baseDesignator = String(text.text || '').trim()
121
+ counts.set(baseDesignator, (counts.get(baseDesignator) || 0) + 1)
122
+ }
123
+
124
+ return new Set(
125
+ [...counts.entries()]
126
+ .filter(([, count]) => count > 1)
127
+ .map(([baseDesignator]) => baseDesignator)
128
+ )
129
+ }
130
+
131
+ /**
132
+ * Re-anchors horizontal component texts from their owner primitive bounds
133
+ * so left-side standalone designators can right-align without disturbing
134
+ * stacked owner-side value text.
135
+ * @param {{ x: number, y: number, text: string, name?: string, ownerIndex?: string, recordType?: string, rotation?: number, anchor?: 'start' | 'middle' | 'end' }[]} texts
136
+ * @param {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string }[]} lines
137
+ * @param {{ x: number, y: number, ownerIndex: string, length: number, orientation: 'left' | 'right' | 'top' | 'bottom' }[]} pins
138
+ * @param {{ x: number, y: number, width: number, direction?: 'left' | 'right' | 'up' | 'down' }[]} ports
139
+ * @returns {{ x: number, y: number, text: string, name?: string, ownerIndex?: string, recordType?: string, rotation?: number, anchor?: 'start' | 'middle' | 'end' }[]}
140
+ */
141
+ static anchorComponentTextsFromOwnerBounds(texts, lines, pins, ports = []) {
142
+ const ownerBounds = SchematicTextPostProcessor.#buildOwnerBounds(
143
+ lines,
144
+ pins
145
+ )
146
+ const ownerPinCounts =
147
+ SchematicTextPostProcessor.#buildOwnerPinCounts(pins)
148
+ const ownerPinOrientations =
149
+ SchematicTextPostProcessor.#buildOwnerPinOrientations(pins)
150
+
151
+ return texts.map((text) => {
152
+ if (
153
+ !text ||
154
+ !SchematicTextPostProcessor.#isDesignatorText(text) ||
155
+ text.rotation ||
156
+ !text.ownerIndex
157
+ ) {
158
+ return text
159
+ }
160
+
161
+ const bounds = ownerBounds.get(text.ownerIndex)
162
+
163
+ if (!bounds) {
164
+ return text
165
+ }
166
+
167
+ const paddedText = SchematicTextPostProcessor.#isDesignatorText(
168
+ text
169
+ )
170
+ ? SchematicTextPostProcessor.#padDesignatorAboveOwner(
171
+ text,
172
+ bounds
173
+ )
174
+ : text
175
+ const ownerPinCount = ownerPinCounts.get(text.ownerIndex) || 0
176
+
177
+ if (text.y > bounds.maxY) {
178
+ return paddedText
179
+ }
180
+
181
+ if (text.y < bounds.minY - 1) {
182
+ return paddedText
183
+ }
184
+
185
+ if (paddedText.x <= bounds.minX + 2) {
186
+ if (
187
+ SchematicTextPostProcessor.#hasVisibleOwnerSideTextStack(
188
+ paddedText,
189
+ texts,
190
+ bounds
191
+ ) ||
192
+ SchematicTextPostProcessor.#hasVisibleOppositeSideValuePair(
193
+ paddedText,
194
+ texts,
195
+ bounds,
196
+ ownerPinOrientations.get(text.ownerIndex) || new Set()
197
+ ) ||
198
+ SchematicTextPostProcessor.#hasNearbyLeftWireLabel(
199
+ paddedText,
200
+ texts,
201
+ lines,
202
+ pins,
203
+ ports
204
+ ) ||
205
+ SchematicTextPostProcessor.#isCompactTwoPinOwner(
206
+ bounds,
207
+ ownerPinCount
208
+ )
209
+ ) {
210
+ return paddedText
211
+ }
212
+
213
+ return {
214
+ ...paddedText,
215
+ anchor: 'end'
216
+ }
217
+ }
218
+
219
+ if (paddedText.x >= bounds.maxX - 2) {
220
+ return {
221
+ ...paddedText,
222
+ anchor: 'start'
223
+ }
224
+ }
225
+
226
+ return paddedText
227
+ })
228
+ }
229
+
230
+ /**
231
+ * Right-aligns wire labels that precede a same-row component designator so
232
+ * they stay clear of the symbol body.
233
+ * Labels that sit on a wire segment whose left endpoint is an actual pin
234
+ * or off-sheet port keep their original left-to-right flow.
235
+ * @param {{ x: number, y: number, text: string, name?: string, ownerIndex?: string, recordType?: string, rotation?: number, anchor?: 'start' | 'middle' | 'end' }[]} texts
236
+ * @param {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string }[]} lines
237
+ * @param {{ x: number, y: number, length: number, orientation: 'left' | 'right' | 'top' | 'bottom' }[]} pins
238
+ * @param {{ x: number, y: number, width: number, direction?: 'left' | 'right' | 'up' | 'down' }[]} ports
239
+ * @returns {{ x: number, y: number, text: string, name?: string, ownerIndex?: string, recordType?: string, rotation?: number, anchor?: 'start' | 'middle' | 'end' }[]}
240
+ */
241
+ static anchorWireLabelsNearDesignators(texts, lines, pins, ports = []) {
242
+ return texts.map((text) => {
243
+ if (
244
+ !text ||
245
+ text.recordType !== '25' ||
246
+ text.rotation ||
247
+ text.anchor !== 'start'
248
+ ) {
249
+ return text
250
+ }
251
+
252
+ const hasNearbyRightDesignator = texts.some(
253
+ (candidate) =>
254
+ candidate &&
255
+ candidate.name === 'Designator' &&
256
+ !candidate.rotation &&
257
+ candidate.x > text.x &&
258
+ candidate.x - text.x <= 80 &&
259
+ Math.abs(candidate.y - text.y) <= 2
260
+ )
261
+
262
+ if (!hasNearbyRightDesignator) {
263
+ return text
264
+ }
265
+
266
+ if (
267
+ SchematicTextPostProcessor.#hasPinConnectedAtWireStart(
268
+ text,
269
+ lines,
270
+ pins
271
+ ) ||
272
+ SchematicTextPostProcessor.#hasLineConnectedAtWireStart(
273
+ text,
274
+ lines
275
+ ) ||
276
+ SchematicTextPostProcessor.#hasPortConnectedAtWireStart(
277
+ text,
278
+ lines,
279
+ ports
280
+ )
281
+ ) {
282
+ return text
283
+ }
284
+
285
+ return {
286
+ ...text,
287
+ anchor: 'end'
288
+ }
289
+ })
290
+ }
291
+
292
+ /**
293
+ * Builds per-owner primitive bounds from drawable lines and pins.
294
+ * @param {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string }[]} lines
295
+ * @param {{ x: number, y: number, ownerIndex: string }[]} pins
296
+ * @returns {Map<string, { minX: number, minY: number, maxX: number, maxY: number }>}
297
+ */
298
+ static #buildOwnerBounds(lines, pins) {
299
+ const ownerBounds = new Map()
300
+
301
+ for (const line of lines) {
302
+ if (!line.ownerIndex) {
303
+ continue
304
+ }
305
+
306
+ SchematicTextPostProcessor.#extendBounds(
307
+ ownerBounds,
308
+ line.ownerIndex,
309
+ [
310
+ { x: line.x1, y: line.y1 },
311
+ { x: line.x2, y: line.y2 }
312
+ ]
313
+ )
314
+ }
315
+
316
+ for (const pin of pins) {
317
+ if (!pin.ownerIndex) {
318
+ continue
319
+ }
320
+
321
+ SchematicTextPostProcessor.#extendBounds(
322
+ ownerBounds,
323
+ pin.ownerIndex,
324
+ [{ x: pin.x, y: pin.y }]
325
+ )
326
+ }
327
+
328
+ return ownerBounds
329
+ }
330
+
331
+ /**
332
+ * Counts visible pins per owner so compact passive parts can keep their
333
+ * left-to-right designator flow.
334
+ * @param {{ ownerIndex: string }[]} pins
335
+ * @returns {Map<string, number>}
336
+ */
337
+ static #buildOwnerPinCounts(pins) {
338
+ const ownerPinCounts = new Map()
339
+
340
+ for (const pin of pins) {
341
+ if (!pin.ownerIndex) {
342
+ continue
343
+ }
344
+
345
+ ownerPinCounts.set(
346
+ pin.ownerIndex,
347
+ (ownerPinCounts.get(pin.ownerIndex) || 0) + 1
348
+ )
349
+ }
350
+
351
+ return ownerPinCounts
352
+ }
353
+
354
+ /**
355
+ * Collects the visible pin orientations per owner.
356
+ * @param {{ ownerIndex: string, orientation: 'left' | 'right' | 'top' | 'bottom' }[]} pins
357
+ * @returns {Map<string, Set<'left' | 'right' | 'top' | 'bottom'>>}
358
+ */
359
+ static #buildOwnerPinOrientations(pins) {
360
+ const ownerPinOrientations = new Map()
361
+
362
+ for (const pin of pins) {
363
+ if (!pin.ownerIndex || !pin.orientation) {
364
+ continue
365
+ }
366
+
367
+ if (!ownerPinOrientations.has(pin.ownerIndex)) {
368
+ ownerPinOrientations.set(pin.ownerIndex, new Set())
369
+ }
370
+
371
+ ownerPinOrientations.get(pin.ownerIndex).add(pin.orientation)
372
+ }
373
+
374
+ return ownerPinOrientations
375
+ }
376
+
377
+ /**
378
+ * Returns true when a text is a visible component designator.
379
+ * @param {{ name?: string }} text
380
+ * @returns {boolean}
381
+ */
382
+ static #isDesignatorText(text) {
383
+ return (
384
+ String(text.name || '')
385
+ .trim()
386
+ .toLowerCase() === 'designator'
387
+ )
388
+ }
389
+
390
+ /**
391
+ * Returns true when a left-side designator shares its owner-side stack with
392
+ * a visible value or comment text at the same owner-side x position.
393
+ * @param {{ x: number, y: number, ownerIndex?: string }} text
394
+ * @param {{ x: number, y: number, name?: string, ownerIndex?: string, rotation?: number }[]} texts
395
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number }} bounds
396
+ * @returns {boolean}
397
+ */
398
+ static #hasVisibleOwnerSideTextStack(text, texts, bounds) {
399
+ const side = SchematicTextPostProcessor.#resolveOwnerSide(text, bounds)
400
+
401
+ if (!side) {
402
+ return false
403
+ }
404
+
405
+ return texts.some((candidate) => {
406
+ const normalizedName = String(candidate?.name || '')
407
+ .trim()
408
+ .toLowerCase()
409
+
410
+ return (
411
+ candidate &&
412
+ candidate !== text &&
413
+ candidate.ownerIndex === text.ownerIndex &&
414
+ !candidate.rotation &&
415
+ (normalizedName === 'value' || normalizedName === 'comment') &&
416
+ Math.abs(candidate.x - text.x) <= 2 &&
417
+ SchematicTextPostProcessor.#isTextOnOwnerSide(
418
+ candidate,
419
+ bounds,
420
+ side
421
+ )
422
+ )
423
+ })
424
+ }
425
+
426
+ /**
427
+ * Resolves which horizontal owner side a text sits on.
428
+ * @param {{ x: number }} text
429
+ * @param {{ minX: number, maxX: number }} bounds
430
+ * @returns {'left' | 'right' | null}
431
+ */
432
+ static #resolveOwnerSide(text, bounds) {
433
+ if (text.x <= bounds.minX + 2) {
434
+ return 'left'
435
+ }
436
+
437
+ if (text.x >= bounds.maxX - 2) {
438
+ return 'right'
439
+ }
440
+
441
+ return null
442
+ }
443
+
444
+ /**
445
+ * Returns true when a text sits on the requested horizontal owner side.
446
+ * @param {{ x: number }} text
447
+ * @param {{ minX: number, maxX: number }} bounds
448
+ * @param {'left' | 'right'} side
449
+ * @returns {boolean}
450
+ */
451
+ static #isTextOnOwnerSide(text, bounds, side) {
452
+ if (side === 'left') {
453
+ return text.x <= bounds.minX + 2
454
+ }
455
+
456
+ return text.x >= bounds.maxX - 2
457
+ }
458
+
459
+ /**
460
+ * Returns true when a horizontal owner already exposes a visible
461
+ * value/comment on the far side of the body, so the left designator should
462
+ * keep its original left-to-right source anchor.
463
+ * @param {{ x: number, y: number, ownerIndex?: string }} text
464
+ * @param {{ x: number, y: number, name?: string, ownerIndex?: string, rotation?: number }[]} texts
465
+ * @param {{ minX: number, maxX: number }} bounds
466
+ * @param {Set<'left' | 'right' | 'top' | 'bottom'>} ownerOrientations
467
+ * @returns {boolean}
468
+ */
469
+ static #hasVisibleOppositeSideValuePair(
470
+ text,
471
+ texts,
472
+ bounds,
473
+ ownerOrientations
474
+ ) {
475
+ if (
476
+ !ownerOrientations.has('left') ||
477
+ !ownerOrientations.has('right') ||
478
+ ownerOrientations.has('top') ||
479
+ ownerOrientations.has('bottom')
480
+ ) {
481
+ return false
482
+ }
483
+
484
+ return texts.some((candidate) => {
485
+ const normalizedName = String(candidate?.name || '')
486
+ .trim()
487
+ .toLowerCase()
488
+
489
+ return (
490
+ candidate &&
491
+ candidate !== text &&
492
+ candidate.ownerIndex === text.ownerIndex &&
493
+ !candidate.rotation &&
494
+ (normalizedName === 'value' || normalizedName === 'comment') &&
495
+ candidate.x >= bounds.maxX - 2 &&
496
+ candidate.x > text.x &&
497
+ Math.abs(candidate.y - text.y) <= 2
498
+ )
499
+ })
500
+ }
501
+
502
+ /**
503
+ * Adds a small gap between a top-side designator and the owner outline.
504
+ * @param {{ x: number, y: number }} text
505
+ * @param {{ maxY: number }} bounds
506
+ * @returns {{ x: number, y: number }}
507
+ */
508
+ static #padDesignatorAboveOwner(text, bounds) {
509
+ if (text.y <= bounds.maxY) {
510
+ return text
511
+ }
512
+
513
+ return {
514
+ ...text,
515
+ y: bounds.maxY + 4
516
+ }
517
+ }
518
+
519
+ /**
520
+ * Returns true for compact two-pin symbols whose left-side designators
521
+ * should keep reading left-to-right instead of flipping toward the body.
522
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number }} bounds
523
+ * @param {number} ownerPinCount
524
+ * @returns {boolean}
525
+ */
526
+ static #isCompactTwoPinOwner(bounds, ownerPinCount) {
527
+ return (
528
+ ownerPinCount === 2 &&
529
+ bounds.maxX - bounds.minX <= 12 &&
530
+ bounds.maxY - bounds.minY <= 20
531
+ )
532
+ }
533
+
534
+ /**
535
+ * Returns true when a component text sits immediately to the right of a
536
+ * visible same-row wire label and should preserve the left-to-right flow.
537
+ * @param {{ x: number, y: number, recordType?: string, rotation?: number }} text
538
+ * @param {{ x: number, y: number, recordType?: string, rotation?: number }}[] texts
539
+ * @param {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string }[]} lines
540
+ * @param {{ x: number, y: number, length: number, orientation: 'left' | 'right' | 'top' | 'bottom' }[]} pins
541
+ * @param {{ x: number, y: number, width: number, direction?: 'left' | 'right' | 'up' | 'down' }[]} ports
542
+ * @returns {boolean}
543
+ */
544
+ static #hasNearbyLeftWireLabel(text, texts, lines, pins, ports) {
545
+ return texts.some(
546
+ (candidate) =>
547
+ candidate &&
548
+ candidate !== text &&
549
+ candidate.recordType === '25' &&
550
+ !candidate.rotation &&
551
+ candidate.x < text.x &&
552
+ text.x - candidate.x <= 80 &&
553
+ Math.abs(candidate.y - text.y) <= 2 &&
554
+ (SchematicTextPostProcessor.#hasPinConnectedAtWireStart(
555
+ candidate,
556
+ lines,
557
+ pins
558
+ ) ||
559
+ SchematicTextPostProcessor.#hasLineConnectedAtWireStart(
560
+ candidate,
561
+ lines
562
+ ) ||
563
+ SchematicTextPostProcessor.#hasPortConnectedAtWireStart(
564
+ candidate,
565
+ lines,
566
+ ports
567
+ ))
568
+ )
569
+ }
570
+
571
+ /**
572
+ * Returns true when the left endpoint of the label's wire segment is
573
+ * already connected into another wire segment, such as a bus breakout.
574
+ * Those labels should keep reading left-to-right from the junction.
575
+ * @param {{ x: number, y: number }} text
576
+ * @param {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string }[]} lines
577
+ * @returns {boolean}
578
+ */
579
+ static #hasLineConnectedAtWireStart(text, lines) {
580
+ const containingSegment =
581
+ SchematicTextPostProcessor.#findContainingHorizontalWireSegment(
582
+ text,
583
+ lines
584
+ )
585
+
586
+ if (!containingSegment) {
587
+ return false
588
+ }
589
+
590
+ const leftPoint = {
591
+ x: Math.min(containingSegment.x1, containingSegment.x2),
592
+ y: text.y
593
+ }
594
+
595
+ return lines.some(
596
+ (line) =>
597
+ line !== containingSegment &&
598
+ SchematicTextPostProcessor.#pointTouchesLine(leftPoint, line)
599
+ )
600
+ }
601
+
602
+ /**
603
+ * Converts one numeric multipart section id into an alphabetic suffix.
604
+ * @param {string | undefined} partId
605
+ * @returns {string}
606
+ */
607
+ static #formatMultipartPartSuffix(partId) {
608
+ const numericPartId = Number.parseInt(String(partId || ''), 10)
609
+ if (!Number.isInteger(numericPartId) || numericPartId <= 0) {
610
+ return ''
611
+ }
612
+
613
+ let suffix = ''
614
+ let remaining = numericPartId
615
+
616
+ while (remaining > 0) {
617
+ remaining -= 1
618
+ suffix = String.fromCharCode(65 + (remaining % 26)) + suffix
619
+ remaining = Math.floor(remaining / 26)
620
+ }
621
+
622
+ return suffix
623
+ }
624
+
625
+ /**
626
+ * Returns true when the horizontal wire segment under the label starts at a
627
+ * pin endpoint, which means the label should continue reading rightward.
628
+ * @param {{ x: number, y: number }} text
629
+ * @param {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string }[]} lines
630
+ * @param {{ x: number, y: number, length: number, orientation: 'left' | 'right' | 'top' | 'bottom' }[]} pins
631
+ * @returns {boolean}
632
+ */
633
+ static #hasPinConnectedAtWireStart(text, lines, pins) {
634
+ const containingSegment =
635
+ SchematicTextPostProcessor.#findContainingHorizontalWireSegment(
636
+ text,
637
+ lines
638
+ )
639
+
640
+ if (!containingSegment) {
641
+ return false
642
+ }
643
+
644
+ const leftX = Math.min(containingSegment.x1, containingSegment.x2)
645
+
646
+ return pins.some((pin) => {
647
+ const endpoint =
648
+ SchematicTextPostProcessor.#projectPinOuterEndpoint(pin)
649
+
650
+ return (
651
+ endpoint &&
652
+ Math.abs(endpoint.x - leftX) <= 2 &&
653
+ Math.abs(endpoint.y - text.y) <= 2
654
+ )
655
+ })
656
+ }
657
+
658
+ /**
659
+ * Returns true when the horizontal wire segment under the label starts at an
660
+ * off-sheet port connection, which means the label should keep reading
661
+ * rightward from that port.
662
+ * @param {{ x: number, y: number }} text
663
+ * @param {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string }[]} lines
664
+ * @param {{ x: number, y: number, width: number, direction?: 'left' | 'right' | 'up' | 'down' }[]} ports
665
+ * @returns {boolean}
666
+ */
667
+ static #hasPortConnectedAtWireStart(text, lines, ports) {
668
+ const containingSegment =
669
+ SchematicTextPostProcessor.#findContainingHorizontalWireSegment(
670
+ text,
671
+ lines
672
+ )
673
+
674
+ if (!containingSegment) {
675
+ return false
676
+ }
677
+
678
+ const leftX = Math.min(containingSegment.x1, containingSegment.x2)
679
+
680
+ return ports.some(
681
+ (port) =>
682
+ Math.abs(port.y - text.y) <= 2 &&
683
+ (Math.abs(port.x - leftX) <= 2 ||
684
+ Math.abs(port.x + port.width - leftX) <= 2)
685
+ )
686
+ }
687
+
688
+ /**
689
+ * Finds the horizontal wire segment that carries a text label.
690
+ * @param {{ x: number, y: number }} text
691
+ * @param {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string }[]} lines
692
+ * @returns {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string } | null}
693
+ */
694
+ static #findContainingHorizontalWireSegment(text, lines) {
695
+ const candidates = lines.filter(
696
+ (line) =>
697
+ Math.abs(line.y1 - text.y) <= 2 &&
698
+ Math.abs(line.y2 - text.y) <= 2 &&
699
+ Math.min(line.x1, line.x2) - 2 <= text.x &&
700
+ text.x <= Math.max(line.x1, line.x2) + 2
701
+ )
702
+
703
+ if (!candidates.length) {
704
+ return null
705
+ }
706
+
707
+ return candidates.sort(
708
+ (left, right) =>
709
+ Math.abs(Math.min(left.x1, left.x2) - text.x) -
710
+ Math.abs(Math.min(right.x1, right.x2) - text.x)
711
+ )[0]
712
+ }
713
+
714
+ /**
715
+ * Returns true when a point lands on a line segment endpoint or on an
716
+ * axis-aligned segment interior.
717
+ * @param {{ x: number, y: number }} point
718
+ * @param {{ x1: number, y1: number, x2: number, y2: number }} line
719
+ * @returns {boolean}
720
+ */
721
+ static #pointTouchesLine(point, line) {
722
+ const touchesStart =
723
+ Math.abs(line.x1 - point.x) <= 2 && Math.abs(line.y1 - point.y) <= 2
724
+ const touchesEnd =
725
+ Math.abs(line.x2 - point.x) <= 2 && Math.abs(line.y2 - point.y) <= 2
726
+
727
+ if (touchesStart || touchesEnd) {
728
+ return true
729
+ }
730
+
731
+ const minX = Math.min(line.x1, line.x2) - 2
732
+ const maxX = Math.max(line.x1, line.x2) + 2
733
+ const minY = Math.min(line.y1, line.y2) - 2
734
+ const maxY = Math.max(line.y1, line.y2) + 2
735
+
736
+ if (
737
+ Math.abs(line.x1 - line.x2) <= 2 &&
738
+ Math.abs(point.x - line.x1) <= 2 &&
739
+ point.y >= minY &&
740
+ point.y <= maxY
741
+ ) {
742
+ return true
743
+ }
744
+
745
+ if (
746
+ Math.abs(line.y1 - line.y2) <= 2 &&
747
+ Math.abs(point.y - line.y1) <= 2 &&
748
+ point.x >= minX &&
749
+ point.x <= maxX
750
+ ) {
751
+ return true
752
+ }
753
+
754
+ return false
755
+ }
756
+
757
+ /**
758
+ * Expands one owner-bound entry with a set of points.
759
+ * @param {Map<string, { minX: number, minY: number, maxX: number, maxY: number }>} ownerBounds
760
+ * @param {string} ownerIndex
761
+ * @param {{ x: number, y: number }[]} points
762
+ * @returns {void}
763
+ */
764
+ static #extendBounds(ownerBounds, ownerIndex, points) {
765
+ const current = ownerBounds.get(ownerIndex) || {
766
+ minX: Number.POSITIVE_INFINITY,
767
+ minY: Number.POSITIVE_INFINITY,
768
+ maxX: Number.NEGATIVE_INFINITY,
769
+ maxY: Number.NEGATIVE_INFINITY
770
+ }
771
+
772
+ for (const point of points) {
773
+ current.minX = Math.min(current.minX, point.x)
774
+ current.minY = Math.min(current.minY, point.y)
775
+ current.maxX = Math.max(current.maxX, point.x)
776
+ current.maxY = Math.max(current.maxY, point.y)
777
+ }
778
+
779
+ ownerBounds.set(ownerIndex, current)
780
+ }
781
+
782
+ /**
783
+ * Projects one pin into its wire-connected outer endpoint.
784
+ * @param {{ x: number, y: number, length: number, orientation: 'left' | 'right' | 'top' | 'bottom' }} pin
785
+ * @returns {{ x: number, y: number } | null}
786
+ */
787
+ static #projectPinOuterEndpoint(pin) {
788
+ switch (pin.orientation) {
789
+ case 'left':
790
+ return { x: pin.x - pin.length, y: pin.y }
791
+ case 'right':
792
+ return { x: pin.x + pin.length, y: pin.y }
793
+ case 'top':
794
+ return { x: pin.x, y: pin.y + pin.length }
795
+ case 'bottom':
796
+ return { x: pin.x, y: pin.y - pin.length }
797
+ default:
798
+ return null
799
+ }
800
+ }
801
+ }