altium-toolkit 1.1.1 → 1.1.3

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 (40) hide show
  1. package/package.json +1 -1
  2. package/src/core/altium/AltiumLayoutParser.mjs +275 -13
  3. package/src/core/altium/AltiumParser.mjs +240 -8
  4. package/src/core/altium/NaturalStringComparator.mjs +175 -0
  5. package/src/core/altium/ParserUtils.mjs +83 -14
  6. package/src/core/altium/PcbComponentPrimitiveIndexer.mjs +104 -33
  7. package/src/core/altium/PcbEmbeddedFontExtractor.mjs +186 -43
  8. package/src/core/altium/PcbLayerStackReadModelBuilder.mjs +26 -4
  9. package/src/core/altium/PcbLayerStackSourceMetadataParser.mjs +28 -5
  10. package/src/core/altium/PcbOwnershipGraphBuilder.mjs +1 -3
  11. package/src/core/altium/PcbReviewMetadataBuilder.mjs +59 -28
  12. package/src/core/altium/PcbReviewRouteHighlightProfileBuilder.mjs +66 -29
  13. package/src/core/altium/PcbRouteAnalysisBuilder.mjs +50 -16
  14. package/src/core/altium/PrintableTextDecoder.mjs +133 -13
  15. package/src/core/altium/SchematicComponentOwnerTextResolver.mjs +13 -0
  16. package/src/core/altium/SchematicComponentTextResolver.mjs +40 -1
  17. package/src/core/altium/SchematicDisplayModeCatalogParser.mjs +36 -11
  18. package/src/core/altium/SchematicImageParser.mjs +291 -6
  19. package/src/core/altium/SchematicImplementationParser.mjs +75 -36
  20. package/src/core/altium/SchematicMultipartDesignatorNormalizer.mjs +164 -0
  21. package/src/core/altium/SchematicMultipartOwnerMatcher.mjs +2 -0
  22. package/src/core/altium/SchematicPinParser.mjs +175 -4
  23. package/src/core/altium/SchematicQaReportBuilder.mjs +115 -38
  24. package/src/core/altium/SchematicSheetStyleResolver.mjs +38 -0
  25. package/src/core/altium/SchematicTextParser.mjs +125 -11
  26. package/src/core/altium/SchematicTextPostProcessor.mjs +146 -102
  27. package/src/ui/PcbInteractionIndex.mjs +16 -1
  28. package/src/ui/SchematicColorResolver.mjs +78 -0
  29. package/src/ui/SchematicContentLayout.mjs +58 -1
  30. package/src/ui/SchematicImageRenderer.mjs +125 -10
  31. package/src/ui/SchematicJunctionRenderer.mjs +1 -1
  32. package/src/ui/SchematicNativeFooterPartitioner.mjs +275 -0
  33. package/src/ui/SchematicNoteRenderer.mjs +82 -6
  34. package/src/ui/SchematicOwnerPinLabelLayout.mjs +292 -3
  35. package/src/ui/SchematicPinSvgRenderer.mjs +197 -15
  36. package/src/ui/SchematicPowerDiagramImageProcessor.mjs +970 -0
  37. package/src/ui/SchematicPowerDiagramLineMasks.mjs +631 -0
  38. package/src/ui/SchematicPowerPortRenderer.mjs +1 -1
  39. package/src/ui/SchematicShapeRenderer.mjs +82 -23
  40. package/src/ui/SchematicSvgRenderer.mjs +293 -47
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "altium-toolkit",
3
- "version": "1.1.1",
3
+ "version": "1.1.3",
4
4
  "description": "Altium document parsing and non-interactive rendering utilities",
5
5
  "keywords": [
6
6
  "altium",
@@ -267,7 +267,7 @@ export class AltiumLayoutParser {
267
267
  * @param {{ width: number, height: number, marginWidth: number, paperSize?: string }} sheet
268
268
  * @param {{ fields: Record<string, string | string[]> }[]} textRecords
269
269
  * @param {{ x1: number, y1: number, x2: number, y2: number }[]} lines
270
- * @param {{ x: number, y: number }[]} texts
270
+ * @param {{ x: number, y: number, cornerX?: number, cornerY?: number }[]} texts
271
271
  * @param {{ x: number, y: number }[]} components
272
272
  * @param {{ x: number, y: number }[]} pins
273
273
  * @param {{ x: number, y: number, width: number, height: number }[]} rectangles
@@ -303,12 +303,47 @@ export class AltiumLayoutParser {
303
303
  }
304
304
 
305
305
  const margin = Math.max(Number(sheet?.marginWidth || 20), 20)
306
+ const nativeTemplateSheet =
307
+ AltiumLayoutParser.#resolveNativeStandardTemplateSheetSize(
308
+ sheet,
309
+ bounds,
310
+ margin
311
+ )
312
+ if (nativeTemplateSheet) {
313
+ return {
314
+ ...sheet,
315
+ ...nativeTemplateSheet
316
+ }
317
+ }
318
+
319
+ if (
320
+ AltiumLayoutParser.#shouldPreserveNativeStandardTemplateSize(
321
+ sheet,
322
+ bounds,
323
+ margin
324
+ )
325
+ ) {
326
+ return sheet
327
+ }
328
+
306
329
  const footerBounds = AltiumLayoutParser.#collectSchematicFooterBounds(
307
330
  textRecords,
308
331
  Number(sheet?.width || 0)
309
332
  )
310
- const requiredWidth =
311
- Math.max(bounds.maxX, footerBounds?.maxX || 0) + margin * 2
333
+ const footerLineBounds =
334
+ AltiumLayoutParser.#collectSchematicFooterLineBounds(
335
+ lines,
336
+ sheet,
337
+ margin
338
+ )
339
+ const requiredWidthResult =
340
+ AltiumLayoutParser.#resolveSchematicRequiredWidth(
341
+ sheet,
342
+ Math.max(bounds.maxX, footerBounds?.maxX || 0),
343
+ footerLineBounds?.maxX || 0,
344
+ margin
345
+ )
346
+ const requiredWidth = requiredWidthResult.width
312
347
  const requiredHeight =
313
348
  Math.max(bounds.maxY, footerBounds?.maxY || 0) + margin * 2
314
349
 
@@ -322,10 +357,12 @@ export class AltiumLayoutParser {
322
357
  return sheet
323
358
  }
324
359
 
325
- const standardSheet = AltiumLayoutParser.#resolveStandardSheetSize(
326
- requiredWidth,
327
- requiredHeight
328
- )
360
+ const standardSheet = requiredWidthResult.usesNativeFrameEdge
361
+ ? null
362
+ : AltiumLayoutParser.#resolveStandardSheetSize(
363
+ requiredWidth,
364
+ requiredHeight
365
+ )
329
366
 
330
367
  if (standardSheet) {
331
368
  return {
@@ -344,11 +381,12 @@ export class AltiumLayoutParser {
344
381
  sheet.height,
345
382
  requiredHeight
346
383
  )
347
- const resolvedStandardSheet =
348
- AltiumLayoutParser.#resolveStandardSheetSize(
349
- resolvedWidth,
350
- resolvedHeight
351
- )
384
+ const resolvedStandardSheet = requiredWidthResult.usesNativeFrameEdge
385
+ ? null
386
+ : AltiumLayoutParser.#resolveStandardSheetSize(
387
+ resolvedWidth,
388
+ resolvedHeight
389
+ )
352
390
 
353
391
  if (resolvedStandardSheet) {
354
392
  return {
@@ -367,6 +405,126 @@ export class AltiumLayoutParser {
367
405
  }
368
406
  }
369
407
 
408
+ /**
409
+ * Resolves the standard template page named by Altium standard sheet
410
+ * records, preserving landscape or portrait orientation from the stored
411
+ * custom dimensions.
412
+ * @param {Record<string, string | string[]> | undefined} fields Sheet fields.
413
+ * @param {number} fallbackWidth Stored custom width.
414
+ * @param {number} fallbackHeight Stored custom height.
415
+ * @returns {{ width: number, height: number, paperSize: string, sourceWidth?: number, sourceHeight?: number } | null}
416
+ */
417
+ static resolveSchematicTemplatePageSize(
418
+ fields,
419
+ fallbackWidth,
420
+ fallbackHeight
421
+ ) {
422
+ if (parseNumericField(fields, 'SheetStyle') !== 1) {
423
+ return null
424
+ }
425
+
426
+ const templateFileName = getField(fields, 'TemplateFileName')
427
+ const match = String(templateFileName || '').match(
428
+ /(?:^|[^a-z0-9])A([0-5])(?:[^a-z0-9]|$)/iu
429
+ )
430
+
431
+ if (!match) {
432
+ return null
433
+ }
434
+
435
+ const paperSize = 'A' + match[1]
436
+ const portraitSheet = ISO_A_PORTRAIT_SHEETS.find(
437
+ (sheet) => sheet.label === paperSize
438
+ )
439
+
440
+ if (!portraitSheet) {
441
+ return null
442
+ }
443
+
444
+ const normalizedFallbackWidth = Number(fallbackWidth || 0)
445
+ const normalizedFallbackHeight = Number(fallbackHeight || 0)
446
+ const portraitTemplate =
447
+ parseNumericField(fields, 'WorkspaceOrientation') === 1 ||
448
+ /portrait/iu.test(String(templateFileName || ''))
449
+
450
+ if (
451
+ portraitTemplate &&
452
+ normalizedFallbackWidth > normalizedFallbackHeight &&
453
+ normalizedFallbackHeight > 0
454
+ ) {
455
+ return {
456
+ width: normalizedFallbackHeight,
457
+ height: normalizedFallbackWidth,
458
+ sourceWidth: normalizedFallbackHeight,
459
+ sourceHeight: normalizedFallbackWidth,
460
+ paperSize
461
+ }
462
+ }
463
+
464
+ const landscape = normalizedFallbackWidth >= normalizedFallbackHeight
465
+
466
+ return {
467
+ width: landscape ? portraitSheet.height : portraitSheet.width,
468
+ height: landscape ? portraitSheet.width : portraitSheet.height,
469
+ paperSize
470
+ }
471
+ }
472
+
473
+ /**
474
+ * Resolves the page width required by recovered schematic geometry.
475
+ * Native template linework can use the declared custom width as the inner
476
+ * frame edge; adding both margins would create an artificial right gutter.
477
+ * @param {{ width?: number, borderOn?: boolean, titleBlockOn?: boolean, sheetStyle?: number }} sheet
478
+ * @param {number} maxX
479
+ * @param {number} footerLineMaxX
480
+ * @param {number} margin
481
+ * @returns {{ width: number, usesNativeFrameEdge: boolean }}
482
+ */
483
+ static #resolveSchematicRequiredWidth(sheet, maxX, footerLineMaxX, margin) {
484
+ const reachesNativeFrameEdge =
485
+ Number(sheet?.sheetStyle || 0) !== 1 &&
486
+ Boolean(sheet?.borderOn || sheet?.titleBlockOn) &&
487
+ footerLineMaxX > 0 &&
488
+ maxX <= footerLineMaxX + 0.01
489
+ const rightPadding = reachesNativeFrameEdge ? margin : margin * 2
490
+
491
+ return {
492
+ width: maxX + rightPadding,
493
+ usesNativeFrameEdge: reachesNativeFrameEdge
494
+ }
495
+ }
496
+
497
+ /**
498
+ * Collects owned lower-page linework that represents native title-block or
499
+ * footer chrome rather than schematic content.
500
+ * @param {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string }[]} lines
501
+ * @param {{ width?: number }} sheet
502
+ * @param {number} margin
503
+ * @returns {{ maxX: number } | null}
504
+ */
505
+ static #collectSchematicFooterLineBounds(lines, sheet, margin) {
506
+ const footerLimit = Math.max(margin * 6, 120)
507
+ const sheetWidth = Math.max(Number(sheet?.width || 0), 0)
508
+ const footerStartX = sheetWidth > 0 ? sheetWidth * 0.5 : 0
509
+ const coordinates = []
510
+
511
+ for (const line of lines || []) {
512
+ if (!line?.ownerIndex) continue
513
+ if (Math.max(line.y1, line.y2) > footerLimit) continue
514
+ if (Math.max(line.x1, line.x2) < footerStartX) continue
515
+
516
+ coordinates.push(line.x1, line.x2)
517
+ }
518
+
519
+ if (!coordinates.length) {
520
+ return null
521
+ }
522
+
523
+ return {
524
+ maxX: Math.max(...coordinates)
525
+ }
526
+ }
527
+
370
528
  /**
371
529
  * Returns true when the parser should trust the authored custom sheet
372
530
  * dimensions instead of shrinking the page to visible content bounds.
@@ -403,11 +561,108 @@ export class AltiumLayoutParser {
403
561
  return false
404
562
  }
405
563
 
564
+ /**
565
+ * Returns true when a standard template has already resolved to its native
566
+ * template coordinate frame rather than a promoted ISO sheet envelope.
567
+ * @param {{ width?: number, height?: number, sourceWidth?: number, sourceHeight?: number, paperSize?: string, sheetStyle?: number }} sheet
568
+ * @param {{ maxX: number, maxY: number }} bounds
569
+ * @param {number} margin
570
+ * @returns {boolean}
571
+ */
572
+ static #shouldPreserveNativeStandardTemplateSize(sheet, bounds, margin) {
573
+ const width = Number(sheet?.width || 0)
574
+ const height = Number(sheet?.height || 0)
575
+ const sourceWidth = Number(sheet?.sourceWidth || 0)
576
+ const sourceHeight = Number(sheet?.sourceHeight || 0)
577
+
578
+ if (
579
+ Number(sheet?.sheetStyle || 0) !== 1 ||
580
+ !sheet?.paperSize ||
581
+ width <= 0 ||
582
+ height <= 0 ||
583
+ width !== sourceWidth ||
584
+ height !== sourceHeight
585
+ ) {
586
+ return false
587
+ }
588
+
589
+ return (
590
+ Number(bounds?.maxX || 0) <= width - margin + 0.01 &&
591
+ Number(bounds?.maxY || 0) <= height - margin + 0.01
592
+ )
593
+ }
594
+
595
+ /**
596
+ * Derives a native standard-template frame from embedded template graphics
597
+ * when those graphics overrun the stored custom dimensions but fit inside
598
+ * a smaller-than-ISO template envelope.
599
+ * @param {{ width?: number, height?: number, sourceWidth?: number, sourceHeight?: number, paperSize?: string, sheetStyle?: number }} sheet
600
+ * @param {{ maxX: number, maxY: number }} bounds
601
+ * @param {number} margin
602
+ * @returns {{ width: number, height: number, sourceWidth: number, sourceHeight: number } | null}
603
+ */
604
+ static #resolveNativeStandardTemplateSheetSize(sheet, bounds, margin) {
605
+ const width = Number(sheet?.width || 0)
606
+ const height = Number(sheet?.height || 0)
607
+ const sourceWidth = Number(sheet?.sourceWidth || 0)
608
+ const sourceHeight = Number(sheet?.sourceHeight || 0)
609
+
610
+ if (
611
+ Number(sheet?.sheetStyle || 0) !== 1 ||
612
+ !sheet?.paperSize ||
613
+ sourceWidth <= 0 ||
614
+ sourceHeight <= 0 ||
615
+ width <= sourceWidth ||
616
+ height <= sourceHeight
617
+ ) {
618
+ return null
619
+ }
620
+
621
+ const maxX = Number(bounds?.maxX || 0)
622
+ const maxY = Number(bounds?.maxY || 0)
623
+ if (maxX <= sourceWidth && maxY <= sourceHeight) {
624
+ return null
625
+ }
626
+
627
+ const nativeWidth = AltiumLayoutParser.#roundTemplateExtent(
628
+ maxX + margin
629
+ )
630
+ const nativeHeight = AltiumLayoutParser.#roundTemplateExtent(
631
+ maxY + margin
632
+ )
633
+
634
+ if (
635
+ nativeWidth <= sourceWidth ||
636
+ nativeHeight <= sourceHeight ||
637
+ nativeWidth >= width ||
638
+ nativeHeight >= height
639
+ ) {
640
+ return null
641
+ }
642
+
643
+ return {
644
+ width: nativeWidth,
645
+ height: nativeHeight,
646
+ sourceWidth: nativeWidth,
647
+ sourceHeight: nativeHeight
648
+ }
649
+ }
650
+
651
+ /**
652
+ * Rounds a recovered template frame edge to the next schematic grid
653
+ * interval.
654
+ * @param {number} value Raw extent.
655
+ * @returns {number}
656
+ */
657
+ static #roundTemplateExtent(value) {
658
+ return Math.ceil(Math.max(Number(value || 0), 0) / 10) * 10
659
+ }
660
+
406
661
  /**
407
662
  * Collects the visible coordinate envelope from recovered schematic
408
663
  * primitives.
409
664
  * @param {{ x1: number, y1: number, x2: number, y2: number }[]} lines
410
- * @param {{ x: number, y: number }[]} texts
665
+ * @param {{ x: number, y: number, cornerX?: number, cornerY?: number }[]} texts
411
666
  * @param {{ x: number, y: number }[]} components
412
667
  * @param {{ x: number, y: number }[]} pins
413
668
  * @param {{ x: number, y: number, width: number, height: number }[]} rectangles
@@ -434,6 +689,13 @@ export class AltiumLayoutParser {
434
689
 
435
690
  for (const text of texts) {
436
691
  coordinates.push([text.x, text.y])
692
+
693
+ if (
694
+ Number.isFinite(Number(text.cornerX)) &&
695
+ Number.isFinite(Number(text.cornerY))
696
+ ) {
697
+ coordinates.push([Number(text.cornerX), Number(text.cornerY)])
698
+ }
437
699
  }
438
700
 
439
701
  for (const component of components) {
@@ -59,6 +59,7 @@ const {
59
59
  extractSchematicFonts,
60
60
  extractSchematicFontDiagnostics,
61
61
  extractSchematicMetadata,
62
+ extractSchematicOwnerMetadata,
62
63
  extractSchematicTitleBlock,
63
64
  normalizeSchematicTextRecord
64
65
  } = SchematicTextParser
@@ -186,7 +187,7 @@ export class AltiumParser {
186
187
  AltiumParser.#collectOwnersWithImplicitDisplayMode(records)
187
188
  const activeMultipartOwnerParts =
188
189
  SchematicMultipartOwnerMatcher.collectActiveMultipartOwnerParts(
189
- records,
190
+ recordIndexAwareRecords,
190
191
  componentRecords
191
192
  )
192
193
  const sheetRecord = records.find(
@@ -329,21 +330,30 @@ export class AltiumParser {
329
330
  }
330
331
 
331
332
  const metadataTexts = extractSchematicMetadata(textRecords)
333
+ const ownerMetadataTexts = extractSchematicOwnerMetadata(textRecords)
332
334
  const schematicFonts = extractSchematicFonts(sheetRecord?.fields)
333
335
  const schematicRenderDiagnostics = extractSchematicFontDiagnostics(
334
336
  sheetRecord?.fields
335
337
  )
336
- const sheetWidth =
338
+ const storedSheetWidth =
337
339
  parseNumericField(sheetRecord?.fields, 'CustomX') || 1500
338
- const sheetHeight =
340
+ const storedSheetHeight =
339
341
  parseNumericField(sheetRecord?.fields, 'CustomY') || 950
342
+ const templatePageSize =
343
+ AltiumLayoutParser.resolveSchematicTemplatePageSize(
344
+ sheetRecord?.fields,
345
+ storedSheetWidth,
346
+ storedSheetHeight
347
+ )
348
+ const sheetWidth = templatePageSize?.width || storedSheetWidth
349
+ const sheetHeight = templatePageSize?.height || storedSheetHeight
340
350
  const sheetMargin =
341
351
  parseNumericField(sheetRecord?.fields, 'CustomMarginWidth') || 20
342
352
  const sheet = {
343
353
  width: sheetWidth,
344
354
  height: sheetHeight,
345
- sourceWidth: sheetWidth,
346
- sourceHeight: sheetHeight,
355
+ sourceWidth: templatePageSize?.sourceWidth || storedSheetWidth,
356
+ sourceHeight: templatePageSize?.sourceHeight || storedSheetHeight,
347
357
  visibleGrid:
348
358
  parseNumericField(sheetRecord?.fields, 'VisibleGridSize') || 10,
349
359
  snapGrid:
@@ -363,6 +373,9 @@ export class AltiumParser {
363
373
  fonts: schematicFonts,
364
374
  sheetStyle:
365
375
  parseNumericField(sheetRecord?.fields, 'SheetStyle') || 0,
376
+ ...(templatePageSize?.paperSize
377
+ ? { paperSize: templatePageSize.paperSize }
378
+ : {}),
366
379
  titleBlock: extractSchematicTitleBlock(
367
380
  textRecords,
368
381
  metadataTexts,
@@ -415,7 +428,19 @@ export class AltiumParser {
415
428
  )
416
429
  )
417
430
  ]
418
- const pins = parseSchematicPins(pinRecords)
431
+ const ownerDrawnInternalPinOwners =
432
+ AltiumParser.#collectInaccessibleOwnerDrawnPrimitiveOwners(
433
+ drawableRecords
434
+ )
435
+ const numericEndpointLabelOwners =
436
+ AltiumParser.#collectNumericEndpointLabelOwners(
437
+ componentRecords,
438
+ recordIndexAwareRecords
439
+ )
440
+ const pins = parseSchematicPins(pinRecords, {
441
+ ownerDrawnInternalPinOwners,
442
+ numericEndpointLabelOwners
443
+ })
419
444
  const junctions = SchematicJunctionParser.parseSchematicJunctions(
420
445
  recordIndexAwareRecords
421
446
  )
@@ -489,7 +514,8 @@ export class AltiumParser {
489
514
  record.fields,
490
515
  metadataTexts,
491
516
  sheet,
492
- schematicFonts
517
+ schematicFonts,
518
+ ownerMetadataTexts
493
519
  )
494
520
  )
495
521
  .filter(Boolean)
@@ -524,7 +550,13 @@ export class AltiumParser {
524
550
  normalizedLines,
525
551
  pins,
526
552
  ports,
527
- rectangles
553
+ {
554
+ rectangles,
555
+ roundedRectangles,
556
+ ellipses,
557
+ arcs,
558
+ pies
559
+ }
528
560
  ),
529
561
  normalizedLines,
530
562
  pins,
@@ -584,6 +616,8 @@ export class AltiumParser {
584
616
 
585
617
  resolvedSheet.xZones =
586
618
  SchematicSheetStyleResolver.resolveXZones(resolvedSheet)
619
+ resolvedSheet.yZones =
620
+ SchematicSheetStyleResolver.resolveYZones(resolvedSheet)
587
621
  delete resolvedSheet.sheetStyle
588
622
 
589
623
  const title =
@@ -649,6 +683,11 @@ export class AltiumParser {
649
683
  busEntries,
650
684
  sheetEntries
651
685
  })
686
+ AltiumParser.#suppressRedundantSinglePinPowerNetNames(
687
+ pins,
688
+ nets,
689
+ anchoredTexts
690
+ )
652
691
  diagnostics.push(...netDiagnostics)
653
692
  const qa = SchematicQaReportBuilder.build({
654
693
  records: recordIndexAwareRecords,
@@ -741,6 +780,142 @@ export class AltiumParser {
741
780
  })
742
781
  }
743
782
 
783
+ /**
784
+ * Hides one-pin owner pin names when a connected power port already labels
785
+ * the same net, preserving the numeric pin endpoint label.
786
+ * @param {{ ownerIndex?: string, name?: string, labelMode?: 'hidden' | 'number-only' | 'name-only' | 'name-and-number' }[]} pins
787
+ * @param {{ powerPorts?: { text?: string }[], pins?: { ownerIndex?: string, name?: string, labelMode?: 'hidden' | 'number-only' | 'name-only' | 'name-and-number' }[] }[]} nets
788
+ * @param {{ x: number, y: number, text?: string, recordType?: string }[]} texts
789
+ * @returns {void}
790
+ */
791
+ static #suppressRedundantSinglePinPowerNetNames(pins, nets, texts = []) {
792
+ const ownerPinCounts = new Map()
793
+
794
+ for (const pin of pins || []) {
795
+ const ownerIndex = String(pin.ownerIndex || '').trim()
796
+
797
+ if (!ownerIndex) continue
798
+
799
+ ownerPinCounts.set(
800
+ ownerIndex,
801
+ (ownerPinCounts.get(ownerIndex) || 0) + 1
802
+ )
803
+ }
804
+
805
+ for (const net of nets || []) {
806
+ const powerPortNames = new Set(
807
+ (net.powerPorts || [])
808
+ .map((powerPort) =>
809
+ AltiumParser.#normalizePowerNetLabel(powerPort.text)
810
+ )
811
+ .filter(Boolean)
812
+ )
813
+
814
+ if (!powerPortNames.size) continue
815
+
816
+ for (const pin of net.pins || []) {
817
+ if (
818
+ AltiumParser.#canSuppressSinglePinName(
819
+ pin,
820
+ ownerPinCounts
821
+ ) &&
822
+ powerPortNames.has(
823
+ AltiumParser.#normalizePowerNetLabel(pin.name)
824
+ )
825
+ ) {
826
+ pin.labelMode = 'number-only'
827
+ }
828
+ }
829
+ }
830
+
831
+ const powerPorts = (texts || []).filter(
832
+ (text) => text?.recordType === '17'
833
+ )
834
+
835
+ for (const pin of pins || []) {
836
+ if (!AltiumParser.#canSuppressSinglePinName(pin, ownerPinCounts)) {
837
+ continue
838
+ }
839
+
840
+ const pinName = AltiumParser.#normalizePowerNetLabel(pin.name)
841
+ const connectionPoint = AltiumParser.#resolvePinConnectionPoint(pin)
842
+ const matchesDirectPowerPort = powerPorts.some(
843
+ (powerPort) =>
844
+ pinName ===
845
+ AltiumParser.#normalizePowerNetLabel(powerPort.text) &&
846
+ AltiumParser.#pointsAreNear(connectionPoint, powerPort, 2)
847
+ )
848
+
849
+ if (matchesDirectPowerPort) {
850
+ pin.labelMode = 'number-only'
851
+ }
852
+ }
853
+ }
854
+
855
+ /**
856
+ * Returns true when a pin is the only pin on its owner and currently shows
857
+ * a name label.
858
+ * @param {{ ownerIndex?: string, labelMode?: 'hidden' | 'number-only' | 'name-only' | 'name-and-number' }} pin
859
+ * @param {Map<string, number>} ownerPinCounts
860
+ * @returns {boolean}
861
+ */
862
+ static #canSuppressSinglePinName(pin, ownerPinCounts) {
863
+ const ownerIndex = String(pin?.ownerIndex || '').trim()
864
+ const labelMode = pin?.labelMode || 'name-and-number'
865
+
866
+ return (
867
+ ownerIndex &&
868
+ ownerPinCounts.get(ownerIndex) === 1 &&
869
+ labelMode !== 'hidden' &&
870
+ labelMode !== 'number-only'
871
+ )
872
+ }
873
+
874
+ /**
875
+ * Resolves the outer electrical endpoint for a normalized schematic pin.
876
+ * @param {{ x: number, y: number, length: number, orientation: 'left' | 'right' | 'top' | 'bottom' }} pin
877
+ * @returns {{ x: number, y: number }}
878
+ */
879
+ static #resolvePinConnectionPoint(pin) {
880
+ switch (pin.orientation) {
881
+ case 'right':
882
+ return { x: pin.x + pin.length, y: pin.y }
883
+ case 'top':
884
+ return { x: pin.x, y: pin.y + pin.length }
885
+ case 'bottom':
886
+ return { x: pin.x, y: pin.y - pin.length }
887
+ case 'left':
888
+ default:
889
+ return { x: pin.x - pin.length, y: pin.y }
890
+ }
891
+ }
892
+
893
+ /**
894
+ * Returns true when two schematic points are close enough to represent the
895
+ * same recovered connection endpoint.
896
+ * @param {{ x: number, y: number }} left
897
+ * @param {{ x: number, y: number }} right
898
+ * @param {number} tolerance
899
+ * @returns {boolean}
900
+ */
901
+ static #pointsAreNear(left, right, tolerance) {
902
+ return (
903
+ Math.abs(Number(left.x) - Number(right.x)) <= tolerance &&
904
+ Math.abs(Number(left.y) - Number(right.y)) <= tolerance
905
+ )
906
+ }
907
+
908
+ /**
909
+ * Normalizes a power-net label for structural comparisons.
910
+ * @param {string | undefined} label
911
+ * @returns {string}
912
+ */
913
+ static #normalizePowerNetLabel(label) {
914
+ return String(label || '')
915
+ .trim()
916
+ .toUpperCase()
917
+ }
918
+
744
919
  /**
745
920
  * Finds a visible text string with a given logical name.
746
921
  * @param {{ fields: Record<string, string | string[]> }[]} records
@@ -784,6 +959,63 @@ export class AltiumParser {
784
959
  return owners
785
960
  }
786
961
 
962
+ /**
963
+ * Collects symbol owners that draw an inaccessible body around compact
964
+ * internal pins.
965
+ * @param {{ fields: Record<string, string | string[]> }[]} records
966
+ * @returns {Set<string>}
967
+ */
968
+ static #collectInaccessibleOwnerDrawnPrimitiveOwners(records) {
969
+ const owners = new Set()
970
+
971
+ for (const record of records) {
972
+ const ownerIndex = getField(record.fields, 'OwnerIndex')
973
+ const ownerPartId = getField(record.fields, 'OwnerPartId')
974
+
975
+ if (
976
+ ownerIndex &&
977
+ ownerPartId &&
978
+ ownerPartId !== '-1' &&
979
+ parseBoolean(record.fields.IsNotAccesible) &&
980
+ SchematicComponentOwnerTextResolver.isDisplayModeSelectablePrimitive(
981
+ record.fields
982
+ )
983
+ ) {
984
+ owners.add(ownerIndex)
985
+ }
986
+ }
987
+
988
+ return owners
989
+ }
990
+
991
+ /**
992
+ * Collects component owners whose passive two-pin numeric endpoints are
993
+ * intended to remain visible in the schematic body.
994
+ * @param {{ fields: Record<string, string | string[]>, recordIndex?: number }[]} componentRecords Component records.
995
+ * @param {{ fields: Record<string, string | string[]>, recordIndex?: number }[]} records Indexed schematic records.
996
+ * @returns {Set<string>}
997
+ */
998
+ static #collectNumericEndpointLabelOwners(componentRecords, records) {
999
+ const owners = new Set()
1000
+
1001
+ for (const componentRecord of componentRecords) {
1002
+ if (
1003
+ parseNumericField(componentRecord.fields, 'ComponentKind') !== 4
1004
+ ) {
1005
+ continue
1006
+ }
1007
+
1008
+ for (const ownerIndex of SchematicComponentOwnerTextResolver.resolveOwnerIndexes(
1009
+ componentRecord,
1010
+ records
1011
+ )) {
1012
+ owners.add(ownerIndex)
1013
+ }
1014
+ }
1015
+
1016
+ return owners
1017
+ }
1018
+
787
1019
  /**
788
1020
  * Returns true when one schematic record belongs to the active symbol
789
1021
  * display mode for its owner.