altium-toolkit 0.1.20 → 0.1.21

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,401 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Infers omitted numeric schematic pin designators from compact owner geometry.
7
+ */
8
+ export class SchematicPinDesignatorInferer {
9
+ /**
10
+ * Infers omitted source-order pin numbers for compact four-pin symbols
11
+ * whose printable records keep enough numeric hints to prove the sequence.
12
+ * @param {{ x: number, y: number, length: number, designator: string, orientation: 'left' | 'right' | 'top' | 'bottom' }[]} pins
13
+ * @returns {{ x: number, y: number, length: number, designator: string, orientation: 'left' | 'right' | 'top' | 'bottom' }[] | null}
14
+ */
15
+ static inferSequentialCompactFourPinDesignators(pins) {
16
+ if (
17
+ pins.length !== 4 ||
18
+ !SchematicPinDesignatorInferer.#isCompactFourPinOwner(pins)
19
+ ) {
20
+ return null
21
+ }
22
+
23
+ let explicitCount = 0
24
+
25
+ for (let index = 0; index < pins.length; index += 1) {
26
+ const designator = String(pins[index].designator || '').trim()
27
+
28
+ if (!designator) {
29
+ continue
30
+ }
31
+
32
+ explicitCount += 1
33
+
34
+ if (!/^\d+$/.test(designator)) {
35
+ return null
36
+ }
37
+
38
+ if (Number(designator) !== index + 1) {
39
+ return null
40
+ }
41
+ }
42
+
43
+ if (explicitCount < 2 || explicitCount === pins.length) {
44
+ return null
45
+ }
46
+
47
+ return pins.map((pin, index) => ({
48
+ ...pin,
49
+ designator: String(index + 1)
50
+ }))
51
+ }
52
+
53
+ /**
54
+ * Infers omitted numeric labels for compact two-column owners whose
55
+ * physical side geometry implies the same sequence Altium displays.
56
+ * @param {{ x: number, y: number, length: number, designator: string, orientation: 'left' | 'right' | 'top' | 'bottom' }[]} pins
57
+ * @returns {{ x: number, y: number, length: number, designator: string, orientation: 'left' | 'right' | 'top' | 'bottom' }[] | null}
58
+ */
59
+ static inferCompactTwoColumnDesignators(pins) {
60
+ if (pins.length < 5) {
61
+ return null
62
+ }
63
+
64
+ const leftPins = pins.filter((pin) => pin.orientation === 'left')
65
+ const rightPins = pins.filter((pin) => pin.orientation === 'right')
66
+
67
+ if (
68
+ leftPins.length < 2 ||
69
+ rightPins.length < 2 ||
70
+ leftPins.length + rightPins.length !== pins.length ||
71
+ !SchematicPinDesignatorInferer.#isCompactTwoColumnOwner(
72
+ pins,
73
+ leftPins,
74
+ rightPins
75
+ )
76
+ ) {
77
+ return null
78
+ }
79
+
80
+ if (pins.some((pin) => String(pin.designator || '').trim())) {
81
+ return SchematicPinDesignatorInferer.#inferExplicitTwoColumnDesignators(
82
+ pins,
83
+ leftPins,
84
+ rightPins
85
+ )
86
+ }
87
+
88
+ const designators = new Map()
89
+
90
+ SchematicPinDesignatorInferer.#sortPinsTopToBottom(leftPins).forEach(
91
+ (pin, index) => {
92
+ designators.set(pin, String(index + 1))
93
+ }
94
+ )
95
+ SchematicPinDesignatorInferer.#sortPinsTopToBottom(rightPins).forEach(
96
+ (pin, index) => {
97
+ designators.set(pin, String(pins.length - index))
98
+ }
99
+ )
100
+
101
+ return pins.map((pin) => ({
102
+ ...pin,
103
+ designator: designators.get(pin) || ''
104
+ }))
105
+ }
106
+
107
+ /**
108
+ * Infers omitted labels from explicit per-side arithmetic pin sequences.
109
+ * @param {{ x: number, y: number, length: number, designator: string, orientation: 'left' | 'right' | 'top' | 'bottom' }[]} pins
110
+ * @param {{ x: number, y: number, length: number, designator: string, orientation: 'left' | 'right' | 'top' | 'bottom' }[]} leftPins
111
+ * @param {{ x: number, y: number, length: number, designator: string, orientation: 'left' | 'right' | 'top' | 'bottom' }[]} rightPins
112
+ * @returns {{ x: number, y: number, length: number, designator: string, orientation: 'left' | 'right' | 'top' | 'bottom' }[] | null}
113
+ */
114
+ static #inferExplicitTwoColumnDesignators(pins, leftPins, rightPins) {
115
+ if (
116
+ pins.some((pin) => {
117
+ const designator = String(pin.designator || '').trim()
118
+
119
+ return designator && !/^\d+$/.test(designator)
120
+ })
121
+ ) {
122
+ return null
123
+ }
124
+
125
+ const leftDesignators =
126
+ SchematicPinDesignatorInferer.#inferSideSequenceDesignators(
127
+ leftPins
128
+ )
129
+ const rightDesignators =
130
+ SchematicPinDesignatorInferer.#inferSideSequenceDesignators(
131
+ rightPins
132
+ )
133
+
134
+ if (!leftDesignators || !rightDesignators) {
135
+ return null
136
+ }
137
+
138
+ const designators = new Map([...leftDesignators, ...rightDesignators])
139
+
140
+ if (designators.size !== pins.length) {
141
+ return null
142
+ }
143
+
144
+ return pins.map((pin) => ({
145
+ ...pin,
146
+ designator: designators.get(pin) || ''
147
+ }))
148
+ }
149
+
150
+ /**
151
+ * Infers omitted numeric labels for compact one-sided connector columns
152
+ * when the visible labels prove a continuous top-to-bottom sequence.
153
+ * @param {{ x: number, y: number, length: number, designator: string, orientation: 'left' | 'right' | 'top' | 'bottom' }[]} pins
154
+ * @returns {{ x: number, y: number, length: number, designator: string, orientation: 'left' | 'right' | 'top' | 'bottom' }[] | null}
155
+ */
156
+ static inferSingleColumnDesignators(pins) {
157
+ if (
158
+ pins.length < 5 ||
159
+ pins.every((pin) => String(pin.designator || '').trim())
160
+ ) {
161
+ return null
162
+ }
163
+
164
+ const orientations = new Set(pins.map((pin) => pin.orientation))
165
+ const orientation = [...orientations][0]
166
+
167
+ if (
168
+ orientations.size !== 1 ||
169
+ (orientation !== 'left' && orientation !== 'right')
170
+ ) {
171
+ return null
172
+ }
173
+
174
+ const maxLength = Math.max(
175
+ ...pins.map((pin) => Number(pin.length) || 0),
176
+ 1
177
+ )
178
+
179
+ if (
180
+ !SchematicPinDesignatorInferer.#isCompactVerticalPinColumn(
181
+ pins,
182
+ maxLength
183
+ )
184
+ ) {
185
+ return null
186
+ }
187
+
188
+ const designators =
189
+ SchematicPinDesignatorInferer.#inferSideSequenceDesignators(pins)
190
+
191
+ if (!designators || designators.size !== pins.length) {
192
+ return null
193
+ }
194
+
195
+ return pins.map((pin) => ({
196
+ ...pin,
197
+ designator: designators.get(pin) || ''
198
+ }))
199
+ }
200
+
201
+ /**
202
+ * Infers one compact side's complete numbering when existing labels fit a
203
+ * top-to-bottom ascending or descending sequence.
204
+ * @param {{ x: number, y: number, designator: string }[]} pins
205
+ * @returns {Map<{ x: number, y: number, designator: string }, string> | null}
206
+ */
207
+ static #inferSideSequenceDesignators(pins) {
208
+ const sortedPins =
209
+ SchematicPinDesignatorInferer.#sortPinsTopToBottom(pins)
210
+ const explicitPins = sortedPins
211
+ .map((pin, index) => ({
212
+ pin,
213
+ index,
214
+ designator: String(pin.designator || '').trim()
215
+ }))
216
+ .filter((pin) => pin.designator)
217
+
218
+ if (explicitPins.length < 2) {
219
+ return null
220
+ }
221
+
222
+ for (const step of [1, -1]) {
223
+ const offset =
224
+ Number(explicitPins[0].designator) -
225
+ explicitPins[0].index * step
226
+ const fits = explicitPins.every(
227
+ (pin) => Number(pin.designator) === offset + pin.index * step
228
+ )
229
+
230
+ if (!fits) {
231
+ continue
232
+ }
233
+
234
+ const inferred = new Map()
235
+ const values = new Set()
236
+
237
+ for (let index = 0; index < sortedPins.length; index += 1) {
238
+ const value = offset + index * step
239
+
240
+ if (value <= 0 || !Number.isInteger(value)) {
241
+ return null
242
+ }
243
+
244
+ values.add(value)
245
+ inferred.set(sortedPins[index], String(value))
246
+ }
247
+
248
+ if (values.size === sortedPins.length) {
249
+ return inferred
250
+ }
251
+ }
252
+
253
+ return null
254
+ }
255
+
256
+ /**
257
+ * Returns true when pins form one compact rectangular two-column owner.
258
+ * @param {{ x: number, y: number, length: number }[]} pins
259
+ * @param {{ x: number, y: number, length: number }[]} leftPins
260
+ * @param {{ x: number, y: number, length: number }[]} rightPins
261
+ * @returns {boolean}
262
+ */
263
+ static #isCompactTwoColumnOwner(pins, leftPins, rightPins) {
264
+ const xs = pins.map((pin) => Number(pin.x))
265
+ const ys = pins.map((pin) => Number(pin.y))
266
+ const lengths = pins.map((pin) => Number(pin.length) || 0)
267
+ const maxLength = Math.max(...lengths, 1)
268
+ const horizontalSpan = Math.max(...xs) - Math.min(...xs)
269
+ const verticalSpan = Math.max(...ys) - Math.min(...ys)
270
+
271
+ return (
272
+ horizontalSpan >= maxLength * 2 &&
273
+ horizontalSpan <= maxLength * 5 &&
274
+ verticalSpan <= maxLength * 4 &&
275
+ SchematicPinDesignatorInferer.#isCompactVerticalPinColumn(
276
+ leftPins,
277
+ maxLength
278
+ ) &&
279
+ SchematicPinDesignatorInferer.#isCompactVerticalPinColumn(
280
+ rightPins,
281
+ maxLength
282
+ ) &&
283
+ SchematicPinDesignatorInferer.#verticalPinColumnRangesOverlap(
284
+ leftPins,
285
+ rightPins,
286
+ maxLength
287
+ )
288
+ )
289
+ }
290
+
291
+ /**
292
+ * Returns true when a side's pins share one vertical edge with compact
293
+ * spacing between adjacent contacts.
294
+ * @param {{ x: number, y: number }[]} pins
295
+ * @param {number} maxLength
296
+ * @returns {boolean}
297
+ */
298
+ static #isCompactVerticalPinColumn(pins, maxLength) {
299
+ const xs = pins.map((pin) => Number(pin.x))
300
+ const xSpan = Math.max(...xs) - Math.min(...xs)
301
+ const sortedPins =
302
+ SchematicPinDesignatorInferer.#sortPinsBottomToTop(pins)
303
+ const tolerance = 0.01
304
+
305
+ if (xSpan > Math.max(tolerance, maxLength * 0.05)) {
306
+ return false
307
+ }
308
+
309
+ for (let index = 1; index < sortedPins.length; index += 1) {
310
+ const gap =
311
+ Number(sortedPins[index].y) - Number(sortedPins[index - 1].y)
312
+
313
+ if (gap <= tolerance || gap > maxLength) {
314
+ return false
315
+ }
316
+ }
317
+
318
+ return true
319
+ }
320
+
321
+ /**
322
+ * Returns true when the two side columns occupy the same compact body span.
323
+ * @param {{ y: number }[]} leftPins
324
+ * @param {{ y: number }[]} rightPins
325
+ * @param {number} maxLength
326
+ * @returns {boolean}
327
+ */
328
+ static #verticalPinColumnRangesOverlap(leftPins, rightPins, maxLength) {
329
+ const leftRange = SchematicPinDesignatorInferer.#pinYRange(leftPins)
330
+ const rightRange = SchematicPinDesignatorInferer.#pinYRange(rightPins)
331
+ const overlap =
332
+ Math.min(leftRange.max, rightRange.max) -
333
+ Math.max(leftRange.min, rightRange.min)
334
+ const requiredOverlap = Math.min(
335
+ maxLength,
336
+ leftRange.max - leftRange.min,
337
+ rightRange.max - rightRange.min
338
+ )
339
+
340
+ return overlap >= requiredOverlap
341
+ }
342
+
343
+ /**
344
+ * Sorts pins from schematic top to bottom.
345
+ * @param {{ x: number, y: number }[]} pins
346
+ * @returns {{ x: number, y: number }[]}
347
+ */
348
+ static #sortPinsTopToBottom(pins) {
349
+ return [...pins].sort((left, right) => Number(right.y) - Number(left.y))
350
+ }
351
+
352
+ /**
353
+ * Sorts pins from schematic bottom to top.
354
+ * @param {{ x: number, y: number }[]} pins
355
+ * @returns {{ x: number, y: number }[]}
356
+ */
357
+ static #sortPinsBottomToTop(pins) {
358
+ return [...pins].sort((left, right) => Number(left.y) - Number(right.y))
359
+ }
360
+
361
+ /**
362
+ * Returns the vertical bounds of a pin column.
363
+ * @param {{ y: number }[]} pins
364
+ * @returns {{ min: number, max: number }}
365
+ */
366
+ static #pinYRange(pins) {
367
+ const ys = pins.map((pin) => Number(pin.y))
368
+
369
+ return {
370
+ min: Math.min(...ys),
371
+ max: Math.max(...ys)
372
+ }
373
+ }
374
+
375
+ /**
376
+ * Returns true when four pins form one compact two-sided symbol body.
377
+ * @param {{ x: number, y: number, length: number, orientation: 'left' | 'right' | 'top' | 'bottom' }[]} pins
378
+ * @returns {boolean}
379
+ */
380
+ static #isCompactFourPinOwner(pins) {
381
+ const orientations = new Set(pins.map((pin) => pin.orientation))
382
+
383
+ if (
384
+ orientations.size !== 2 ||
385
+ !orientations.has('left') ||
386
+ !orientations.has('right')
387
+ ) {
388
+ return false
389
+ }
390
+
391
+ const xs = pins.map((pin) => Number(pin.x))
392
+ const ys = pins.map((pin) => Number(pin.y))
393
+ const lengths = pins.map((pin) => Number(pin.length) || 0)
394
+ const maxLength = Math.max(...lengths, 1)
395
+
396
+ return (
397
+ Math.max(...xs) - Math.min(...xs) <= maxLength * 3 &&
398
+ Math.max(...ys) - Math.min(...ys) <= maxLength * 3
399
+ )
400
+ }
401
+ }
@@ -3,6 +3,7 @@
3
3
  // SPDX-License-Identifier: GPL-3.0-or-later
4
4
 
5
5
  import { ParserUtils } from './ParserUtils.mjs'
6
+ import { SchematicPinDesignatorInferer } from './SchematicPinDesignatorInferer.mjs'
6
7
 
7
8
  /**
8
9
  * Helpers for normalized schematic pins, ports, and crosses.
@@ -395,31 +396,11 @@ export class SchematicPinParser {
395
396
  /**
396
397
  * Expands a schematic polyline record into drawable line segments.
397
398
  * @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 }[]}
399
+ * @param {{ isBus?: boolean, recordType?: string }} [options]
400
+ * @returns {{ x1: number, y1: number, x2: number, y2: number, color: string, width: number, lineStyle: number, isBus?: boolean, recordType?: string }[]}
400
401
  */
401
402
  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
- }
403
+ const points = SchematicPinParser.#collectSchematicPointList(fields)
423
404
 
424
405
  const segments = []
425
406
  const lineStyle = SchematicPinParser.#resolveSchematicLineStyle(fields)
@@ -436,7 +417,8 @@ export class SchematicPinParser {
436
417
  color: ParserUtils.toColor(fields.Color, '#a44a1b'),
437
418
  width: ParserUtils.parseNumericField(fields, 'LineWidth') || 1,
438
419
  lineStyle,
439
- isBus: options.isBus === true ? true : undefined
420
+ isBus: options.isBus === true ? true : undefined,
421
+ recordType: options.recordType || undefined
440
422
  })
441
423
  }
442
424
 
@@ -449,27 +431,7 @@ export class SchematicPinParser {
449
431
  * @returns {{ x1: number, y1: number, x2: number, y2: number, color: string, width: number, lineStyle: number }[]}
450
432
  */
451
433
  static parseSchematicPolygon(fields) {
452
- const locationCount = ParserUtils.parseNumericField(
453
- fields,
454
- 'LocationCount'
455
- )
456
-
457
- if (locationCount === null || locationCount < 2) {
458
- return []
459
- }
460
-
461
- const points = []
462
-
463
- for (let index = 1; index <= locationCount; index += 1) {
464
- const x = ParserUtils.parseNumericField(fields, 'X' + index)
465
- const y = ParserUtils.parseNumericField(fields, 'Y' + index)
466
-
467
- if (x === null || y === null) {
468
- break
469
- }
470
-
471
- points.push({ x, y })
472
- }
434
+ const points = SchematicPinParser.#collectSchematicPointList(fields)
473
435
 
474
436
  if (points.length < 2) {
475
437
  return []
@@ -527,6 +489,49 @@ export class SchematicPinParser {
527
489
  return ParserUtils.parseNumericField(fields, 'LineStyle') || 0
528
490
  }
529
491
 
492
+ /**
493
+ * Collects a schematic point list, carrying forward a missing coordinate
494
+ * axis from the preceding point when Altium omitted an unchanged value.
495
+ * @param {Record<string, string | string[]>} fields
496
+ * @returns {{ x: number, y: number }[]}
497
+ */
498
+ static #collectSchematicPointList(fields) {
499
+ const locationCount = ParserUtils.parseNumericField(
500
+ fields,
501
+ 'LocationCount'
502
+ )
503
+
504
+ if (locationCount === null || locationCount < 2) {
505
+ return []
506
+ }
507
+
508
+ const points = []
509
+ let previousX = null
510
+ let previousY = null
511
+
512
+ for (let index = 1; index <= locationCount; index += 1) {
513
+ const x = ParserUtils.parseNumericField(fields, 'X' + index)
514
+ const y = ParserUtils.parseNumericField(fields, 'Y' + index)
515
+
516
+ if (x === null && y === null) {
517
+ break
518
+ }
519
+
520
+ const pointX = x === null ? previousX : x
521
+ const pointY = y === null ? previousY : y
522
+
523
+ if (pointX === null || pointY === null) {
524
+ break
525
+ }
526
+
527
+ points.push({ x: pointX, y: pointY })
528
+ previousX = pointX
529
+ previousY = pointY
530
+ }
531
+
532
+ return points
533
+ }
534
+
530
535
  /**
531
536
  * Deduces the visible pins for one schematic symbol owner.
532
537
  * @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
@@ -534,11 +539,32 @@ export class SchematicPinParser {
534
539
  */
535
540
  static #normalizeSchematicPinGroup(pins) {
536
541
  const deduped = SchematicPinParser.#dedupeSchematicPins(pins)
542
+ const inferredSequentialDesignators =
543
+ SchematicPinDesignatorInferer.inferSequentialCompactFourPinDesignators(
544
+ deduped
545
+ )
546
+ const inferredTwoColumnDesignators = inferredSequentialDesignators
547
+ ? null
548
+ : SchematicPinDesignatorInferer.inferCompactTwoColumnDesignators(
549
+ deduped
550
+ )
551
+ const inferredSingleColumnDesignators =
552
+ inferredSequentialDesignators || inferredTwoColumnDesignators
553
+ ? null
554
+ : SchematicPinDesignatorInferer.inferSingleColumnDesignators(
555
+ deduped
556
+ )
557
+ const normalizedPins =
558
+ inferredSequentialDesignators ||
559
+ inferredTwoColumnDesignators ||
560
+ inferredSingleColumnDesignators ||
561
+ deduped
537
562
  const names = [
538
- ...new Set(deduped.map((pin) => pin.name).filter(Boolean))
563
+ ...new Set(normalizedPins.map((pin) => pin.name).filter(Boolean))
539
564
  ]
540
- const orientationCount = new Set(deduped.map((pin) => pin.orientation))
541
- .size
565
+ const orientationCount = new Set(
566
+ normalizedPins.map((pin) => pin.orientation)
567
+ ).size
542
568
  const allPassive = names.every((name) =>
543
569
  SchematicPinParser.#isPassivePinName(name)
544
570
  )
@@ -546,15 +572,20 @@ export class SchematicPinParser {
546
572
  (name) => !SchematicPinParser.#isPassivePinName(name)
547
573
  )
548
574
  const allNumberedPins =
549
- deduped.length > 0 &&
550
- deduped.every(
575
+ normalizedPins.length > 0 &&
576
+ normalizedPins.every(
551
577
  (pin) =>
552
578
  /^\d+$/.test(String(pin.designator || '').trim()) &&
553
579
  (!pin.name || /^\d+$/.test(String(pin.name || '').trim()))
554
580
  )
555
581
  let labelMode = 'name-and-number'
556
582
 
557
- if (SchematicPinParser.#isDenseTwoSidedHorizontal4850Family(deduped)) {
583
+ if (
584
+ inferredSequentialDesignators ||
585
+ SchematicPinParser.#isDenseTwoSidedHorizontal4850Family(
586
+ normalizedPins
587
+ )
588
+ ) {
558
589
  labelMode = 'number-only'
559
590
  }
560
591
 
@@ -562,30 +593,38 @@ export class SchematicPinParser {
562
593
  // Keep dense multi-side connector symbols whose contacts are only
563
594
  // identified by numbers; dropping them loses both pin numbers and
564
595
  // any power-port attachment geometry recovered from those pins.
565
- if (deduped.length > 4 && !allNumberedPins) {
596
+ if (normalizedPins.length > 4 && !allNumberedPins) {
566
597
  return []
567
598
  }
568
599
 
569
600
  labelMode = 'number-only'
570
601
  }
571
602
 
572
- if (allPassive && deduped.length <= 2) {
603
+ if (allPassive && normalizedPins.length <= 2) {
573
604
  labelMode = SchematicPinParser.#isCanonicalPassiveTwoPinGroup(
574
- deduped
605
+ normalizedPins
575
606
  )
576
607
  ? 'hidden'
577
608
  : 'number-only'
609
+ } else if (
610
+ SchematicPinParser.#isOwnerDrawnTerminalGlyphGroup(
611
+ normalizedPins,
612
+ semanticNames,
613
+ orientationCount
614
+ )
615
+ ) {
616
+ labelMode = 'hidden'
578
617
  } else if (!semanticNames.length && orientationCount <= 2) {
579
618
  labelMode = 'number-only'
580
619
  } else if (
581
620
  semanticNames.length >= Math.max(names.length - 1, 3) &&
582
621
  orientationCount <= 2 &&
583
- deduped.length <= 4
622
+ normalizedPins.length <= 4
584
623
  ) {
585
624
  labelMode = 'name-only'
586
625
  }
587
626
 
588
- return deduped.map(({ conglomerate, ...pin }) => ({
627
+ return normalizedPins.map(({ conglomerate, ...pin }) => ({
589
628
  ...pin,
590
629
  color: '#0000ff',
591
630
  labelColor: '#1f1f1f',
@@ -593,6 +632,47 @@ export class SchematicPinParser {
593
632
  }))
594
633
  }
595
634
 
635
+ /**
636
+ * Returns true when a compact multi-side owner has transistor-like terminal
637
+ * letters that are part of the drawn symbol body, not external pin labels.
638
+ * @param {{ designator: string, name: string, orientation: 'left' | 'right' | 'top' | 'bottom' }[]} pins
639
+ * @param {string[]} semanticNames
640
+ * @param {number} orientationCount
641
+ * @returns {boolean}
642
+ */
643
+ static #isOwnerDrawnTerminalGlyphGroup(
644
+ pins,
645
+ semanticNames,
646
+ orientationCount
647
+ ) {
648
+ if (
649
+ pins.length < 3 ||
650
+ pins.length > 4 ||
651
+ orientationCount < 3 ||
652
+ pins.some((pin) => String(pin.designator || '').trim())
653
+ ) {
654
+ return false
655
+ }
656
+
657
+ if (semanticNames.length !== pins.length) {
658
+ return false
659
+ }
660
+
661
+ return semanticNames.every((name) =>
662
+ SchematicPinParser.#isTransistorTerminalName(name)
663
+ )
664
+ }
665
+
666
+ /**
667
+ * Returns true for one-letter terminal glyphs commonly drawn inside
668
+ * transistor-style schematic symbols.
669
+ * @param {string} name
670
+ * @returns {boolean}
671
+ */
672
+ static #isTransistorTerminalName(name) {
673
+ return /^[BCDEGS]$/i.test(String(name || '').trim())
674
+ }
675
+
596
676
  /**
597
677
  * Returns true when one passive two-pin symbol uses the ordinary 1/2 pin
598
678
  * numbering that should stay hidden for simple resistor-like parts.