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,767 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { ParserUtils } from './ParserUtils.mjs'
6
+
7
+ /**
8
+ * Helpers for normalized schematic pins, ports, and crosses.
9
+ */
10
+ export class SchematicPinParser {
11
+ /**
12
+ * Normalizes schematic pin records into drawable pin primitives.
13
+ * @param {{ fields: Record<string, string | string[]> }[]} records
14
+ * @returns {{ x: number, y: number, length: number, name: string, nameSegments?: { text: string, overline: boolean }[], designator: string, orientation: 'left' | 'right' | 'top' | 'bottom', electrical?: number, symbolOuter?: number, color: string, labelColor: string, labelMode: 'hidden' | 'number-only' | 'name-only' | 'name-and-number', ownerIndex: string }[]}
15
+ */
16
+ static parseSchematicPins(records) {
17
+ const groups = new Map()
18
+
19
+ for (const record of records) {
20
+ const ownerIndex = ParserUtils.getField(record.fields, 'OwnerIndex')
21
+ const x = ParserUtils.parseNumericField(record.fields, 'Location.X')
22
+ const y = ParserUtils.parseNumericField(record.fields, 'Location.Y')
23
+ const length = ParserUtils.parseNumericField(
24
+ record.fields,
25
+ 'PinLength'
26
+ )
27
+ const orientation =
28
+ SchematicPinParser.#inferSchematicPinOrientation(
29
+ ParserUtils.parseNumericField(
30
+ record.fields,
31
+ 'PinConglomerate'
32
+ )
33
+ )
34
+
35
+ if (
36
+ x === null ||
37
+ y === null ||
38
+ length === null ||
39
+ length <= 0 ||
40
+ !orientation
41
+ ) {
42
+ continue
43
+ }
44
+
45
+ if (!groups.has(ownerIndex)) {
46
+ groups.set(ownerIndex, [])
47
+ }
48
+
49
+ groups.get(ownerIndex).push({
50
+ x,
51
+ y,
52
+ length,
53
+ conglomerate:
54
+ ParserUtils.parseNumericField(
55
+ record.fields,
56
+ 'PinConglomerate'
57
+ ) || undefined,
58
+ ...SchematicPinParser.#parseSchematicPinName(
59
+ ParserUtils.getField(record.fields, 'Name')
60
+ ),
61
+ designator: ParserUtils.getField(record.fields, 'Designator'),
62
+ orientation,
63
+ electrical:
64
+ ParserUtils.parseNumericField(
65
+ record.fields,
66
+ 'Electrical'
67
+ ) || undefined,
68
+ symbolOuter:
69
+ ParserUtils.parseNumericField(
70
+ record.fields,
71
+ 'SymBol_Outer'
72
+ ) || undefined,
73
+ ownerIndex
74
+ })
75
+ }
76
+
77
+ return [...groups.values()].flatMap((pins) =>
78
+ SchematicPinParser.#normalizeSchematicPinGroup(pins)
79
+ )
80
+ }
81
+
82
+ /**
83
+ * Normalizes schematic port records into drawable port boxes.
84
+ * @param {{ fields: Record<string, string | string[]> }[]} records
85
+ * @param {{ x1: number, y1: number, x2: number, y2: number }[]} [lines]
86
+ * @returns {{ x: number, y: number, width: number, height: number, name: string, fill: string, color: string, direction: 'left' | 'right' | 'up' | 'down', shape: 'single' | 'double' | 'plain' }[]}
87
+ */
88
+ static parseSchematicPorts(records, lines = []) {
89
+ return records
90
+ .map((record) => {
91
+ const x =
92
+ ParserUtils.parseNumericField(
93
+ record.fields,
94
+ 'Location.X'
95
+ ) || 0
96
+ const y =
97
+ ParserUtils.parseNumericField(
98
+ record.fields,
99
+ 'Location.Y'
100
+ ) || 0
101
+ const width =
102
+ ParserUtils.parseNumericField(record.fields, 'Width') || 40
103
+
104
+ return {
105
+ x,
106
+ y,
107
+ width,
108
+ height:
109
+ ParserUtils.parseNumericField(
110
+ record.fields,
111
+ 'Height'
112
+ ) || 10,
113
+ name: ParserUtils.getField(record.fields, 'Name'),
114
+ fill: ParserUtils.toColor(
115
+ record.fields.AreaColor,
116
+ '#ffe16f'
117
+ ),
118
+ color: ParserUtils.toColor(
119
+ record.fields.TextColor || record.fields.Color,
120
+ '#8d2b2b'
121
+ ),
122
+ shape: SchematicPinParser.#resolveSchematicPortShape(
123
+ record.fields
124
+ ),
125
+ direction:
126
+ SchematicPinParser.#resolveSchematicPortDirection(
127
+ record.fields,
128
+ x,
129
+ y,
130
+ width,
131
+ lines
132
+ )
133
+ }
134
+ })
135
+ .filter((port) => port.name)
136
+ }
137
+
138
+ /**
139
+ * Resolves which horizontal port silhouette Altium requested.
140
+ * @param {Record<string, string | string[]>} fields
141
+ * @returns {'single' | 'double' | 'plain'}
142
+ */
143
+ static #resolveSchematicPortShape(fields) {
144
+ if (ParserUtils.parseNumericField(fields, 'Style') === 4) {
145
+ return 'single'
146
+ }
147
+
148
+ if (ParserUtils.getField(fields, 'IOType') === '3') {
149
+ return 'double'
150
+ }
151
+
152
+ if (
153
+ !ParserUtils.getField(fields, 'Alignment') &&
154
+ !ParserUtils.getField(fields, 'IOType')
155
+ ) {
156
+ return 'plain'
157
+ }
158
+
159
+ return 'single'
160
+ }
161
+
162
+ /**
163
+ * Resolves which side of an off-sheet port should taper.
164
+ * @param {Record<string, string | string[]>} fields
165
+ * @param {number} x
166
+ * @param {number} y
167
+ * @param {number} width
168
+ * @param {{ x1: number, y1: number, x2: number, y2: number }[]} lines
169
+ * @returns {'left' | 'right' | 'up' | 'down'}
170
+ */
171
+ static #resolveSchematicPortDirection(fields, x, y, width, lines) {
172
+ if (ParserUtils.parseNumericField(fields, 'Style') === 4) {
173
+ const verticalWireSide =
174
+ SchematicPinParser.#findSchematicVerticalPortWireSide(
175
+ x,
176
+ y,
177
+ width,
178
+ lines
179
+ )
180
+
181
+ return verticalWireSide || 'up'
182
+ }
183
+
184
+ const wireSide = SchematicPinParser.#findSchematicPortWireSide(
185
+ x,
186
+ y,
187
+ width,
188
+ lines
189
+ )
190
+ const ioType = ParserUtils.getField(fields, 'IOType')
191
+
192
+ if (wireSide && ioType) {
193
+ return SchematicPinParser.#inferSchematicPortDirectionFromIoType(
194
+ ioType,
195
+ wireSide
196
+ )
197
+ }
198
+
199
+ return SchematicPinParser.#inferSchematicPortDirectionFromAlignment(
200
+ ParserUtils.getField(fields, 'Alignment')
201
+ )
202
+ }
203
+
204
+ /**
205
+ * Returns which horizontal side a recovered wire touches for one port.
206
+ * @param {number} x
207
+ * @param {number} y
208
+ * @param {number} width
209
+ * @param {{ x1: number, y1: number, x2: number, y2: number }[]} lines
210
+ * @returns {'left' | 'right' | null}
211
+ */
212
+ static #findSchematicPortWireSide(x, y, width, lines) {
213
+ const tolerance = 0.01
214
+ let touchesLeft = false
215
+ let touchesRight = false
216
+
217
+ for (const line of lines) {
218
+ if (
219
+ Math.abs(Number(line.y1) - y) > tolerance ||
220
+ Math.abs(Number(line.y2) - y) > tolerance
221
+ ) {
222
+ continue
223
+ }
224
+
225
+ touchesLeft =
226
+ touchesLeft ||
227
+ Math.abs(Number(line.x1) - x) <= tolerance ||
228
+ Math.abs(Number(line.x2) - x) <= tolerance
229
+ touchesRight =
230
+ touchesRight ||
231
+ Math.abs(Number(line.x1) - (x + width)) <= tolerance ||
232
+ Math.abs(Number(line.x2) - (x + width)) <= tolerance
233
+
234
+ if (touchesLeft && touchesRight) {
235
+ return null
236
+ }
237
+ }
238
+
239
+ if (touchesLeft) {
240
+ return 'left'
241
+ }
242
+
243
+ if (touchesRight) {
244
+ return 'right'
245
+ }
246
+
247
+ return null
248
+ }
249
+
250
+ /**
251
+ * Returns which vertical side of one style-4 port touches recovered wire
252
+ * geometry. Those ports use `x` as the vertical centerline and `y` as the
253
+ * lower bound of the callout footprint.
254
+ * @param {number} x
255
+ * @param {number} y
256
+ * @param {number} width
257
+ * @param {{ x1: number, y1: number, x2: number, y2: number }[]} lines
258
+ * @returns {'up' | 'down' | null}
259
+ */
260
+ static #findSchematicVerticalPortWireSide(x, y, width, lines) {
261
+ const topPoint = {
262
+ x,
263
+ y: y + width
264
+ }
265
+ const bottomPoint = {
266
+ x,
267
+ y
268
+ }
269
+ let touchesTop = false
270
+ let touchesBottom = false
271
+
272
+ for (const line of lines) {
273
+ touchesTop =
274
+ touchesTop ||
275
+ SchematicPinParser.#pointTouchesLine(topPoint, line, 0.01)
276
+ touchesBottom =
277
+ touchesBottom ||
278
+ SchematicPinParser.#pointTouchesLine(bottomPoint, line, 0.01)
279
+
280
+ if (touchesTop && touchesBottom) {
281
+ return null
282
+ }
283
+ }
284
+
285
+ if (touchesTop) {
286
+ return 'up'
287
+ }
288
+
289
+ if (touchesBottom) {
290
+ return 'down'
291
+ }
292
+
293
+ return null
294
+ }
295
+
296
+ /**
297
+ * Infers the tapered side from port IO type plus attached wire side.
298
+ * @param {string} ioType
299
+ * @param {'left' | 'right'} wireSide
300
+ * @returns {'left' | 'right'}
301
+ */
302
+ static #inferSchematicPortDirectionFromIoType(ioType, wireSide) {
303
+ if (String(ioType) === '2') {
304
+ return wireSide
305
+ }
306
+
307
+ return wireSide === 'left' ? 'right' : 'left'
308
+ }
309
+
310
+ /**
311
+ * Infers which side of an off-sheet port should taper from legacy
312
+ * alignment data when no better connectivity clue is available.
313
+ * @param {string} alignment
314
+ * @returns {'left' | 'right'}
315
+ */
316
+ static #inferSchematicPortDirectionFromAlignment(alignment) {
317
+ return String(alignment || '') === '2' ? 'right' : 'left'
318
+ }
319
+
320
+ /**
321
+ * Returns true when a point lands on one line endpoint or on an
322
+ * axis-aligned segment within a small tolerance.
323
+ * @param {{ x: number, y: number }} point
324
+ * @param {{ x1: number, y1: number, x2: number, y2: number }} line
325
+ * @param {number} tolerance
326
+ * @returns {boolean}
327
+ */
328
+ static #pointTouchesLine(point, line, tolerance) {
329
+ const effectiveTolerance = Math.max(Number(tolerance || 0.01), 0.01)
330
+ const touchesStart =
331
+ Math.abs(Number(line.x1) - point.x) <= effectiveTolerance &&
332
+ Math.abs(Number(line.y1) - point.y) <= effectiveTolerance
333
+ const touchesEnd =
334
+ Math.abs(Number(line.x2) - point.x) <= effectiveTolerance &&
335
+ Math.abs(Number(line.y2) - point.y) <= effectiveTolerance
336
+
337
+ if (touchesStart || touchesEnd) {
338
+ return true
339
+ }
340
+
341
+ const minX =
342
+ Math.min(Number(line.x1), Number(line.x2)) - effectiveTolerance
343
+ const maxX =
344
+ Math.max(Number(line.x1), Number(line.x2)) + effectiveTolerance
345
+ const minY =
346
+ Math.min(Number(line.y1), Number(line.y2)) - effectiveTolerance
347
+ const maxY =
348
+ Math.max(Number(line.y1), Number(line.y2)) + effectiveTolerance
349
+
350
+ if (
351
+ Math.abs(Number(line.x1) - Number(line.x2)) <= effectiveTolerance &&
352
+ Math.abs(point.x - Number(line.x1)) <= effectiveTolerance &&
353
+ point.y >= minY &&
354
+ point.y <= maxY
355
+ ) {
356
+ return true
357
+ }
358
+
359
+ if (
360
+ Math.abs(Number(line.y1) - Number(line.y2)) <= effectiveTolerance &&
361
+ Math.abs(point.y - Number(line.y1)) <= effectiveTolerance &&
362
+ point.x >= minX &&
363
+ point.x <= maxX
364
+ ) {
365
+ return true
366
+ }
367
+
368
+ return false
369
+ }
370
+
371
+ /**
372
+ * Normalizes no-connect crosses from schematic records.
373
+ * @param {{ fields: Record<string, string | string[]> }[]} records
374
+ * @returns {{ x: number, y: number, size: number, color: string }[]}
375
+ */
376
+ static parseSchematicCrosses(records) {
377
+ return records
378
+ .map((record) => ({
379
+ x:
380
+ ParserUtils.parseNumericField(
381
+ record.fields,
382
+ 'Location.X'
383
+ ) || 0,
384
+ y:
385
+ ParserUtils.parseNumericField(
386
+ record.fields,
387
+ 'Location.Y'
388
+ ) || 0,
389
+ size: 6,
390
+ color: ParserUtils.toColor(record.fields.Color, '#ff0000')
391
+ }))
392
+ .filter((cross) => cross.x || cross.y)
393
+ }
394
+
395
+ /**
396
+ * Expands a schematic polyline record into drawable line segments.
397
+ * @param {Record<string, string | string[]>} fields
398
+ * @param {{ isBus?: boolean }} [options]
399
+ * @returns {{ x1: number, y1: number, x2: number, y2: number, color: string, width: number, lineStyle: number, isBus?: boolean }[]}
400
+ */
401
+ static parseSchematicPolyline(fields, options = {}) {
402
+ const locationCount = ParserUtils.parseNumericField(
403
+ fields,
404
+ 'LocationCount'
405
+ )
406
+
407
+ if (locationCount === null || locationCount < 2) {
408
+ return []
409
+ }
410
+
411
+ const points = []
412
+
413
+ for (let index = 1; index <= locationCount; index += 1) {
414
+ const x = ParserUtils.parseNumericField(fields, 'X' + index)
415
+ const y = ParserUtils.parseNumericField(fields, 'Y' + index)
416
+
417
+ if (x === null || y === null) {
418
+ break
419
+ }
420
+
421
+ points.push({ x, y })
422
+ }
423
+
424
+ const segments = []
425
+ const lineStyle =
426
+ ParserUtils.parseNumericField(fields, 'LineStyle') || 0
427
+
428
+ for (let index = 1; index < points.length; index += 1) {
429
+ const previous = points[index - 1]
430
+ const current = points[index]
431
+
432
+ segments.push({
433
+ x1: previous.x,
434
+ y1: previous.y,
435
+ x2: current.x,
436
+ y2: current.y,
437
+ color: ParserUtils.toColor(fields.Color, '#a44a1b'),
438
+ width: ParserUtils.parseNumericField(fields, 'LineWidth') || 1,
439
+ lineStyle,
440
+ isBus: options.isBus === true ? true : undefined
441
+ })
442
+ }
443
+
444
+ return segments
445
+ }
446
+
447
+ /**
448
+ * Expands a schematic polygon record into closed drawable line segments.
449
+ * @param {Record<string, string | string[]>} fields
450
+ * @returns {{ x1: number, y1: number, x2: number, y2: number, color: string, width: number, lineStyle: number }[]}
451
+ */
452
+ static parseSchematicPolygon(fields) {
453
+ const locationCount = ParserUtils.parseNumericField(
454
+ fields,
455
+ 'LocationCount'
456
+ )
457
+
458
+ if (locationCount === null || locationCount < 2) {
459
+ return []
460
+ }
461
+
462
+ const points = []
463
+
464
+ for (let index = 1; index <= locationCount; index += 1) {
465
+ const x = ParserUtils.parseNumericField(fields, 'X' + index)
466
+ const y = ParserUtils.parseNumericField(fields, 'Y' + index)
467
+
468
+ if (x === null || y === null) {
469
+ break
470
+ }
471
+
472
+ points.push({ x, y })
473
+ }
474
+
475
+ if (points.length < 2) {
476
+ return []
477
+ }
478
+
479
+ const segments = []
480
+ const lineStyle =
481
+ ParserUtils.parseNumericField(fields, 'LineStyle') || 0
482
+
483
+ for (let index = 1; index < points.length; index += 1) {
484
+ const previous = points[index - 1]
485
+ const current = points[index]
486
+
487
+ segments.push({
488
+ x1: previous.x,
489
+ y1: previous.y,
490
+ x2: current.x,
491
+ y2: current.y,
492
+ color: ParserUtils.toColor(fields.Color, '#a44a1b'),
493
+ width: ParserUtils.parseNumericField(fields, 'LineWidth') || 1,
494
+ lineStyle
495
+ })
496
+ }
497
+
498
+ const firstPoint = points[0]
499
+ const lastPoint = points[points.length - 1]
500
+
501
+ segments.push({
502
+ x1: lastPoint.x,
503
+ y1: lastPoint.y,
504
+ x2: firstPoint.x,
505
+ y2: firstPoint.y,
506
+ color: ParserUtils.toColor(fields.Color, '#a44a1b'),
507
+ width: ParserUtils.parseNumericField(fields, 'LineWidth') || 1,
508
+ lineStyle
509
+ })
510
+
511
+ return segments
512
+ }
513
+
514
+ /**
515
+ * Deduces the visible pins for one schematic symbol owner.
516
+ * @param {{ x: number, y: number, length: number, conglomerate?: number, name: string, nameSegments?: { text: string, overline: boolean }[], designator: string, orientation: 'left' | 'right' | 'top' | 'bottom', electrical?: number, symbolOuter?: number, ownerIndex: string }[]} pins
517
+ * @returns {{ x: number, y: number, length: number, name: string, nameSegments?: { text: string, overline: boolean }[], designator: string, orientation: 'left' | 'right' | 'top' | 'bottom', electrical?: number, symbolOuter?: number, color: string, labelColor: string, labelMode: 'hidden' | 'number-only' | 'name-only' | 'name-and-number', ownerIndex: string }[]}
518
+ */
519
+ static #normalizeSchematicPinGroup(pins) {
520
+ const deduped = SchematicPinParser.#dedupeSchematicPins(pins)
521
+ const names = [
522
+ ...new Set(deduped.map((pin) => pin.name).filter(Boolean))
523
+ ]
524
+ const orientationCount = new Set(deduped.map((pin) => pin.orientation))
525
+ .size
526
+ const allPassive = names.every((name) =>
527
+ SchematicPinParser.#isPassivePinName(name)
528
+ )
529
+ const semanticNames = names.filter(
530
+ (name) => !SchematicPinParser.#isPassivePinName(name)
531
+ )
532
+ const allNumberedPins =
533
+ deduped.length > 0 &&
534
+ deduped.every(
535
+ (pin) =>
536
+ /^\d+$/.test(String(pin.designator || '').trim()) &&
537
+ (!pin.name || /^\d+$/.test(String(pin.name || '').trim()))
538
+ )
539
+ let labelMode = 'name-and-number'
540
+
541
+ if (SchematicPinParser.#isDenseTwoSidedHorizontal4850Family(deduped)) {
542
+ labelMode = 'number-only'
543
+ }
544
+
545
+ if (allPassive && orientationCount > 2) {
546
+ // Keep dense multi-side connector symbols whose contacts are only
547
+ // identified by numbers; dropping them loses both pin numbers and
548
+ // any power-port attachment geometry recovered from those pins.
549
+ if (deduped.length > 4 && !allNumberedPins) {
550
+ return []
551
+ }
552
+
553
+ labelMode = 'number-only'
554
+ }
555
+
556
+ if (allPassive && deduped.length <= 2) {
557
+ labelMode = SchematicPinParser.#isCanonicalPassiveTwoPinGroup(
558
+ deduped
559
+ )
560
+ ? 'hidden'
561
+ : 'number-only'
562
+ } else if (!semanticNames.length && orientationCount <= 2) {
563
+ labelMode = 'number-only'
564
+ } else if (
565
+ semanticNames.length >= Math.max(names.length - 1, 3) &&
566
+ orientationCount <= 2 &&
567
+ deduped.length <= 4
568
+ ) {
569
+ labelMode = 'name-only'
570
+ }
571
+
572
+ return deduped.map(({ conglomerate, ...pin }) => ({
573
+ ...pin,
574
+ color: '#0000ff',
575
+ labelColor: '#1f1f1f',
576
+ labelMode
577
+ }))
578
+ }
579
+
580
+ /**
581
+ * Returns true when one passive two-pin symbol uses the ordinary 1/2 pin
582
+ * numbering that should stay hidden for simple resistor-like parts.
583
+ * @param {{ designator: string }[]} pins
584
+ * @returns {boolean}
585
+ */
586
+ static #isCanonicalPassiveTwoPinGroup(pins) {
587
+ if (pins.length !== 2) {
588
+ return false
589
+ }
590
+
591
+ const designators = pins
592
+ .map((pin) => String(pin.designator || '').trim())
593
+ .sort((left, right) => Number(left) - Number(right))
594
+
595
+ return designators[0] === '1' && designators[1] === '2'
596
+ }
597
+
598
+ /**
599
+ * Returns true when one owner uses the dense two-sided horizontal 48/50
600
+ * pin family whose semantic names belong to the owner-drawn symbol body
601
+ * rather than to visible external pin labels.
602
+ * @param {{ conglomerate?: number, orientation: 'left' | 'right' | 'top' | 'bottom' }[]} pins
603
+ * @returns {boolean}
604
+ */
605
+ static #isDenseTwoSidedHorizontal4850Family(pins) {
606
+ if (pins.length < 6) {
607
+ return false
608
+ }
609
+
610
+ if (
611
+ pins.some(
612
+ (pin) =>
613
+ pin.orientation !== 'left' && pin.orientation !== 'right'
614
+ )
615
+ ) {
616
+ return false
617
+ }
618
+
619
+ const conglomerates = new Set(
620
+ pins.map((pin) => Number(pin.conglomerate || 0))
621
+ )
622
+
623
+ return (
624
+ conglomerates.size > 0 &&
625
+ [...conglomerates].every(
626
+ (conglomerate) => conglomerate === 48 || conglomerate === 50
627
+ )
628
+ )
629
+ }
630
+
631
+ /**
632
+ * Removes duplicate pin records emitted for alternate display modes.
633
+ * @param {{ x: number, y: number, length: number, conglomerate?: number, name: string, nameSegments?: { text: string, overline: boolean }[], designator: string, orientation: 'left' | 'right' | 'top' | 'bottom', electrical?: number, symbolOuter?: number, ownerIndex: string }[]} pins
634
+ * @returns {{ x: number, y: number, length: number, conglomerate?: number, name: string, nameSegments?: { text: string, overline: boolean }[], designator: string, orientation: 'left' | 'right' | 'top' | 'bottom', electrical?: number, symbolOuter?: number, ownerIndex: string }[]}
635
+ */
636
+ static #dedupeSchematicPins(pins) {
637
+ const seen = new Set()
638
+ const deduped = []
639
+
640
+ for (const pin of pins) {
641
+ const key = [
642
+ pin.ownerIndex,
643
+ pin.x,
644
+ pin.y,
645
+ pin.length,
646
+ pin.name,
647
+ pin.designator,
648
+ pin.orientation,
649
+ pin.electrical,
650
+ pin.symbolOuter || '',
651
+ SchematicPinParser.#serializeSchematicPinNameSegments(
652
+ pin.nameSegments
653
+ )
654
+ ].join('::')
655
+
656
+ if (seen.has(key)) continue
657
+
658
+ seen.add(key)
659
+ deduped.push(pin)
660
+ }
661
+
662
+ return deduped
663
+ }
664
+
665
+ /**
666
+ * Decodes Altium backslash suffix markers into visible pin text and
667
+ * overline runs for active-low labels.
668
+ * @param {string} name
669
+ * @returns {{ name: string, nameSegments?: { text: string, overline: boolean }[] }}
670
+ */
671
+ static #parseSchematicPinName(name) {
672
+ const characters = []
673
+
674
+ for (const character of String(name || '').trim()) {
675
+ if (character === '\\') {
676
+ const previousCharacter = characters.at(-1)
677
+ if (previousCharacter) {
678
+ previousCharacter.overline = true
679
+ }
680
+ continue
681
+ }
682
+
683
+ characters.push({
684
+ text: character,
685
+ overline: false
686
+ })
687
+ }
688
+
689
+ const normalizedName = characters
690
+ .map((character) => character.text)
691
+ .join('')
692
+ const nameSegments = []
693
+
694
+ for (const character of characters) {
695
+ const previousSegment = nameSegments.at(-1)
696
+ if (
697
+ previousSegment &&
698
+ previousSegment.overline === character.overline
699
+ ) {
700
+ previousSegment.text += character.text
701
+ continue
702
+ }
703
+
704
+ nameSegments.push({
705
+ text: character.text,
706
+ overline: character.overline
707
+ })
708
+ }
709
+
710
+ return {
711
+ name: normalizedName,
712
+ nameSegments: nameSegments.some((segment) => segment.overline)
713
+ ? nameSegments
714
+ : undefined
715
+ }
716
+ }
717
+
718
+ /**
719
+ * Serializes overline runs into a dedupe-safe signature.
720
+ * @param {{ text: string, overline: boolean }[] | undefined} nameSegments
721
+ * @returns {string}
722
+ */
723
+ static #serializeSchematicPinNameSegments(nameSegments) {
724
+ return (nameSegments || [])
725
+ .map(
726
+ (segment) => (segment.overline ? '1' : '0') + ':' + segment.text
727
+ )
728
+ .join('|')
729
+ }
730
+
731
+ /**
732
+ * Returns true when a pin name looks like a passive-symbol terminal.
733
+ * @param {string} name
734
+ * @returns {boolean}
735
+ */
736
+ static #isPassivePinName(name) {
737
+ return /^(\d+|[AK])$/i.test(String(name || '').trim())
738
+ }
739
+
740
+ /**
741
+ * Maps Altium pin conglomerate flags into a side orientation.
742
+ * @param {number | null} conglomerate
743
+ * @returns {'left' | 'right' | 'top' | 'bottom' | null}
744
+ */
745
+ static #inferSchematicPinOrientation(conglomerate) {
746
+ switch (conglomerate) {
747
+ case 34:
748
+ case 50:
749
+ case 58:
750
+ return 'left'
751
+ case 32:
752
+ case 48:
753
+ case 56:
754
+ return 'right'
755
+ case 33:
756
+ case 49:
757
+ case 57:
758
+ return 'top'
759
+ case 35:
760
+ case 51:
761
+ case 59:
762
+ return 'bottom'
763
+ default:
764
+ return null
765
+ }
766
+ }
767
+ }