altium-toolkit 1.0.7 → 1.0.9

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 (93) hide show
  1. package/README.md +18 -6
  2. package/docs/api.md +78 -16
  3. package/docs/model-format.md +229 -8
  4. package/docs/schemas/altium_toolkit/netlist_a1.schema.json +47 -0
  5. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +1661 -104
  6. package/docs/schemas/altium_toolkit/pcb_svg_semantics_a1.schema.json +59 -0
  7. package/docs/schemas/altium_toolkit/project_bundle_a1.schema.json +57 -0
  8. package/docs/schemas/altium_toolkit/schematic_svg_semantics_a1.schema.json +50 -0
  9. package/docs/testing.md +9 -3
  10. package/package.json +1 -1
  11. package/spec/library-scope.md +7 -1
  12. package/src/core/altium/AltiumLayoutParser.mjs +104 -8
  13. package/src/core/altium/AltiumParser.mjs +191 -45
  14. package/src/core/altium/EmbeddedFileInventoryBuilder.mjs +255 -0
  15. package/src/core/altium/IntLibModelParser.mjs +240 -0
  16. package/src/core/altium/IntLibStreamExtractor.mjs +366 -0
  17. package/src/core/altium/LibraryRenderManifestBuilder.mjs +417 -0
  18. package/src/core/altium/LibrarySearchIndex.mjs +215 -0
  19. package/src/core/altium/NormalizedModelSchema.mjs +36 -0
  20. package/src/core/altium/PcbCustomPadShapeParser.mjs +244 -0
  21. package/src/core/altium/PcbDefaultsParser.mjs +171 -0
  22. package/src/core/altium/PcbDimensionParser.mjs +229 -0
  23. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +232 -6
  24. package/src/core/altium/PcbExtendedPrimitiveInformationParser.mjs +256 -0
  25. package/src/core/altium/PcbLibModelParser.mjs +235 -14
  26. package/src/core/altium/PcbLibStreamExtractor.mjs +62 -4
  27. package/src/core/altium/PcbMaskPasteResolver.mjs +354 -0
  28. package/src/core/altium/PcbMechanicalLayerPairParser.mjs +204 -0
  29. package/src/core/altium/PcbModelParser.mjs +466 -28
  30. package/src/core/altium/PcbOwnershipGraphBuilder.mjs +245 -0
  31. package/src/core/altium/PcbPadPrimitiveParser.mjs +78 -65
  32. package/src/core/altium/PcbPadStackParser.mjs +58 -0
  33. package/src/core/altium/PcbPickPlacePositionResolver.mjs +217 -0
  34. package/src/core/altium/PcbPrimitiveParameterParser.mjs +3 -2
  35. package/src/core/altium/PcbRawRecordRegistry.mjs +121 -130
  36. package/src/core/altium/PcbRegionPrimitiveParser.mjs +5 -1
  37. package/src/core/altium/PcbRuleParser.mjs +354 -33
  38. package/src/core/altium/PcbSidecarRecordParser.mjs +177 -0
  39. package/src/core/altium/PcbSpecialStringResolver.mjs +220 -0
  40. package/src/core/altium/PcbStatisticsBuilder.mjs +532 -0
  41. package/src/core/altium/PcbStreamExtractor.mjs +111 -4
  42. package/src/core/altium/PcbTextPrimitiveParser.mjs +60 -0
  43. package/src/core/altium/PcbUnionParser.mjs +307 -0
  44. package/src/core/altium/PcbViaStackParser.mjs +98 -10
  45. package/src/core/altium/PcbViaStructureParser.mjs +335 -0
  46. package/src/core/altium/PrintableTextDecoder.mjs +53 -3
  47. package/src/core/altium/PrjPcbModelParser.mjs +257 -5
  48. package/src/core/altium/ProjectAnnotationParser.mjs +205 -0
  49. package/src/core/altium/ProjectDesignBundleBuilder.mjs +477 -0
  50. package/src/core/altium/ProjectNetlistExporter.mjs +499 -0
  51. package/src/core/altium/ProjectOutJobDigestBuilder.mjs +109 -0
  52. package/src/core/altium/ProjectVariantViewBuilder.mjs +334 -0
  53. package/src/core/altium/SchematicBindingProvenanceParser.mjs +223 -0
  54. package/src/core/altium/SchematicComponentOwnerTextResolver.mjs +312 -0
  55. package/src/core/altium/SchematicComponentTextResolver.mjs +72 -19
  56. package/src/core/altium/SchematicConnectivityQaBuilder.mjs +271 -0
  57. package/src/core/altium/SchematicCrossSheetConnectorParser.mjs +140 -0
  58. package/src/core/altium/SchematicDirectiveParser.mjs +312 -0
  59. package/src/core/altium/SchematicDisplayModeCatalogParser.mjs +231 -0
  60. package/src/core/altium/SchematicHarnessParser.mjs +302 -0
  61. package/src/core/altium/SchematicImageParser.mjs +474 -3
  62. package/src/core/altium/SchematicImplementationParser.mjs +518 -0
  63. package/src/core/altium/SchematicNetlistBuilder.mjs +15 -2
  64. package/src/core/altium/SchematicOwnershipGraphParser.mjs +195 -0
  65. package/src/core/altium/SchematicPinParser.mjs +84 -1
  66. package/src/core/altium/SchematicPrimitiveParser.mjs +301 -0
  67. package/src/core/altium/SchematicProjectParameterResolver.mjs +361 -0
  68. package/src/core/altium/SchematicQaReportBuilder.mjs +284 -0
  69. package/src/core/altium/SchematicRecordTypeRegistry.mjs +137 -0
  70. package/src/core/altium/SchematicRepeatedChannelParser.mjs +229 -0
  71. package/src/core/altium/SchematicStreamExtractor.mjs +10 -1
  72. package/src/core/altium/SchematicTemplateParser.mjs +256 -0
  73. package/src/core/altium/SchematicTextParser.mjs +123 -0
  74. package/src/core/ole/OleCompoundDocument.mjs +20 -0
  75. package/src/parser.mjs +29 -0
  76. package/src/renderers.mjs +3 -0
  77. package/src/styles/altium-renderers.css +25 -0
  78. package/src/ui/PcbBarcodeTextRenderer.mjs +436 -0
  79. package/src/ui/PcbInteractionGeometry.mjs +350 -0
  80. package/src/ui/PcbInteractionIndex.mjs +593 -0
  81. package/src/ui/PcbInteractionItemRegistry.mjs +66 -0
  82. package/src/ui/PcbInteractionLayerModel.mjs +99 -0
  83. package/src/ui/PcbScene3dBoardOutlineRefiner.mjs +74 -9
  84. package/src/ui/PcbScene3dBuilder.mjs +169 -7
  85. package/src/ui/PcbScene3dModelRegistry.mjs +74 -0
  86. package/src/ui/PcbSvgRenderer.mjs +1187 -34
  87. package/src/ui/PcbTextPrimitiveRenderer.mjs +193 -7
  88. package/src/ui/SchematicNoteRenderer.mjs +9 -2
  89. package/src/ui/SchematicOwnerPinLabelLayout.mjs +206 -0
  90. package/src/ui/SchematicShapeRenderer.mjs +362 -0
  91. package/src/ui/SchematicSvgRenderer.mjs +1442 -92
  92. package/src/ui/SchematicTypography.mjs +48 -5
  93. package/src/ui/TextGeometrySidecarBuilder.mjs +147 -0
@@ -8,8 +8,17 @@ import { PcbBoardRegionSemanticsParser } from './PcbBoardRegionSemanticsParser.m
8
8
  import { PcbComponentAnnotationNormalizer } from './PcbComponentAnnotationNormalizer.mjs'
9
9
  import { PcbComponentBodyPlacementNormalizer } from './PcbComponentBodyPlacementNormalizer.mjs'
10
10
  import { PcbComponentPrimitiveIndexer } from './PcbComponentPrimitiveIndexer.mjs'
11
+ import { PcbCustomPadShapeParser } from './PcbCustomPadShapeParser.mjs'
12
+ import { PcbDimensionParser } from './PcbDimensionParser.mjs'
13
+ import { PcbMechanicalLayerPairParser } from './PcbMechanicalLayerPairParser.mjs'
14
+ import { PcbDefaultsParser } from './PcbDefaultsParser.mjs'
15
+ import { PcbMaskPasteResolver } from './PcbMaskPasteResolver.mjs'
11
16
  import { PcbOutlineRecovery } from './PcbOutlineRecovery.mjs'
17
+ import { PcbOwnershipGraphBuilder } from './PcbOwnershipGraphBuilder.mjs'
18
+ import { PcbPickPlacePositionResolver } from './PcbPickPlacePositionResolver.mjs'
12
19
  import { PcbRuleParser } from './PcbRuleParser.mjs'
20
+ import { PcbSpecialStringResolver } from './PcbSpecialStringResolver.mjs'
21
+ import { PcbStatisticsBuilder } from './PcbStatisticsBuilder.mjs'
13
22
  import { ParserUtils } from './ParserUtils.mjs'
14
23
 
15
24
  const {
@@ -33,7 +42,7 @@ export class PcbModelParser {
33
42
  * resolved primitive netName fields keyed by numeric netIndex values.
34
43
  * @param {string} fileName
35
44
  * @param {{ raw: string, fields: Record<string, string | string[]>, sourceStream?: string }[]} records
36
- * @param {{ streamNames: string[], binaryPrimitives: Record<string, object[]>, primitiveParameters?: object, diagnostics: { printableRecordCount: number, printableStreamCount: number, binaryPrimitiveCount: number } } | null} pcbExtraction
45
+ * @param {{ streamNames: string[], binaryPrimitives: Record<string, object[]>, primitiveParameters?: object, viaStructures?: object, customPadShapes?: object, extendedPrimitiveInformation?: object, unions?: object, diagnostics: { printableRecordCount: number, printableStreamCount: number, binaryPrimitiveCount: number } } | null} pcbExtraction
37
46
  * @returns {{ schema: string, kind: 'pcb', fileType: 'PcbDoc', fileName: string, summary: Record<string, number | string>, diagnostics: { severity: 'info' | 'warning', message: string }[], pcb: Record<string, unknown>, bom: { designators: string[], quantity: number, pattern: string, source: string, value: string }[] }}
38
47
  */
39
48
  static parse(fileName, records, pcbExtraction = null) {
@@ -93,11 +102,27 @@ export class PcbModelParser {
93
102
  const primitiveLayers = AltiumLayoutParser.parsePrimitiveLayerNames(
94
103
  boardRecords.map((record) => record.fields)
95
104
  )
105
+ const mechanicalLayerPairs = PcbMechanicalLayerPairParser.parse(
106
+ boardRecords.map((record) => record.fields),
107
+ layers,
108
+ primitiveLayers
109
+ )
110
+ const layerFlipMetadata =
111
+ PcbMechanicalLayerPairParser.buildFlipMetadata(mechanicalLayerPairs)
96
112
  const appearance3d = PcbModelParser.#parseAppearance3d(boardRecords)
97
113
  const nets = PcbModelParser.#parseNetRecords(records)
98
114
  const netNameByIndex = PcbModelParser.#buildNetNameMap(nets)
99
115
  const classes = PcbModelParser.#parseClassRecords(records)
116
+ const differentialPairData = PcbModelParser.#buildDifferentialPairData(
117
+ PcbModelParser.#parseDifferentialPairRecords(records),
118
+ classes
119
+ )
100
120
  const rules = PcbRuleParser.parse(records)
121
+ const defaults = PcbDefaultsParser.parse(
122
+ boardRecord?.fields || {},
123
+ 'pcb-document'
124
+ )
125
+ const dimensions = PcbDimensionParser.parse(records)
101
126
  const polygons = polygonRecords
102
127
  .map((record) => ({
103
128
  layer: getField(record.fields, 'LAYER') || 'UNKNOWN',
@@ -155,15 +180,50 @@ export class PcbModelParser {
155
180
  )
156
181
  ? pcbExtraction.embeddedFonts.fonts
157
182
  : []
183
+ const embeddedFiles = pcbExtraction?.embeddedFiles || {
184
+ schema: 'altium-toolkit.embedded-files.a1',
185
+ files: [],
186
+ diagnostics: []
187
+ }
188
+ const embeddedModelIntegrity = pcbExtraction?.embeddedModels
189
+ ?.integrity || {
190
+ schema: 'altium-toolkit.pcb.embedded-model-integrity.a1',
191
+ issues: []
192
+ }
158
193
  const rawRecords = Array.isArray(pcbExtraction?.rawRecords)
159
194
  ? pcbExtraction.rawRecords
160
195
  : []
161
- const texts = PcbModelParser.#annotateTextFontMetrics(
162
- PcbComponentAnnotationNormalizer.normalizeTexts(
163
- rawTextPrimitives,
164
- rawComponentRecords
196
+ const viaStructures = pcbExtraction?.viaStructures || {
197
+ structures: [],
198
+ links: [],
199
+ byPrimitiveIndex: {}
200
+ }
201
+ const customPadShapes = pcbExtraction?.customPadShapes || {
202
+ entries: [],
203
+ byPrimitiveIndex: {}
204
+ }
205
+ const extendedPrimitiveInformation =
206
+ pcbExtraction?.extendedPrimitiveInformation || {
207
+ entries: [],
208
+ byPrimitiveIndex: {},
209
+ byPrimitiveKey: {}
210
+ }
211
+ const unions = pcbExtraction?.unions || {
212
+ userUnions: [],
213
+ smartUnions: [],
214
+ byIndex: {},
215
+ smartByIndex: {},
216
+ membersByPrimitiveKey: {}
217
+ }
218
+ const texts = PcbSpecialStringResolver.annotateTexts(
219
+ PcbModelParser.#annotateTextFontMetrics(
220
+ PcbComponentAnnotationNormalizer.normalizeTexts(
221
+ rawTextPrimitives,
222
+ rawComponentRecords
223
+ ),
224
+ extractedEmbeddedFonts
165
225
  ),
166
- extractedEmbeddedFonts
226
+ pcbExtraction?.specialStringParameters || {}
167
227
  )
168
228
  const recoveredOutline = PcbOutlineRecovery.recoverOutline({
169
229
  fallbackOutline: fallbackBoardOutline,
@@ -185,6 +245,11 @@ export class PcbModelParser {
185
245
  texts,
186
246
  components: componentRecords
187
247
  })
248
+ PcbCustomPadShapeParser.attachToPads(
249
+ normalizedPcb.pads,
250
+ customPadShapes,
251
+ normalizedPcb
252
+ )
188
253
  const boardRegionContexts =
189
254
  PcbBoardRegionSemanticsParser.buildBoardRegionContexts(
190
255
  normalizedPcb.boardRegions
@@ -207,6 +272,27 @@ export class PcbModelParser {
207
272
  const componentPrimitives = PcbComponentPrimitiveIndexer.indexGroups(
208
273
  componentPrimitiveGroups
209
274
  )
275
+ const ownership = PcbOwnershipGraphBuilder.build({
276
+ ...normalizedPcb,
277
+ nets
278
+ })
279
+ const pnp = PcbPickPlacePositionResolver.buildModel(
280
+ normalizedPcb.components,
281
+ componentPrimitiveGroups,
282
+ { sourceComponents: componentRecords }
283
+ )
284
+ const statistics = PcbStatisticsBuilder.build({
285
+ ...normalizedPcb,
286
+ layers,
287
+ primitiveLayers,
288
+ rules
289
+ })
290
+ const maskPaste = PcbMaskPasteResolver.build({
291
+ pads: normalizedPcb.pads,
292
+ vias: normalizedPcb.vias,
293
+ rules,
294
+ defaults
295
+ })
210
296
  const bom = PcbModelParser.#groupBomRows(
211
297
  componentRecords.map((component) => ({
212
298
  designator: component.designator,
@@ -325,6 +411,38 @@ export class PcbModelParser {
325
411
  })
326
412
  }
327
413
 
414
+ if (embeddedFiles.files.length) {
415
+ diagnostics.push({
416
+ severity: 'info',
417
+ message:
418
+ 'Inventoried ' +
419
+ embeddedFiles.files.length +
420
+ ' generic embedded payload ' +
421
+ PcbModelParser.#plural(
422
+ embeddedFiles.files.length,
423
+ 'stream',
424
+ 'streams'
425
+ ) +
426
+ '.'
427
+ })
428
+ }
429
+
430
+ for (const issue of embeddedModelIntegrity.issues || []) {
431
+ diagnostics.push({
432
+ severity: issue.severity === 'info' ? 'info' : 'warning',
433
+ code: issue.code,
434
+ message: issue.message
435
+ })
436
+ }
437
+
438
+ for (const issue of embeddedFiles.diagnostics || []) {
439
+ diagnostics.push({
440
+ severity: issue.severity === 'info' ? 'info' : 'warning',
441
+ code: issue.code,
442
+ message: issue.message
443
+ })
444
+ }
445
+
328
446
  if (rawRecords.length) {
329
447
  diagnostics.push({
330
448
  severity: 'info',
@@ -335,6 +453,54 @@ export class PcbModelParser {
335
453
  })
336
454
  }
337
455
 
456
+ if (viaStructures.structures?.length) {
457
+ diagnostics.push({
458
+ severity: 'info',
459
+ message:
460
+ 'Recovered ' +
461
+ viaStructures.structures.length +
462
+ ' PCB via protection structure ' +
463
+ PcbModelParser.#plural(
464
+ viaStructures.structures.length,
465
+ 'definition',
466
+ 'definitions'
467
+ ) +
468
+ '.'
469
+ })
470
+ }
471
+
472
+ if (extendedPrimitiveInformation.entries?.length) {
473
+ diagnostics.push({
474
+ severity: 'info',
475
+ message:
476
+ 'Recovered ' +
477
+ extendedPrimitiveInformation.entries.length +
478
+ ' extended PCB primitive information records.'
479
+ })
480
+ }
481
+
482
+ if (customPadShapes.entries?.length) {
483
+ diagnostics.push({
484
+ severity: 'info',
485
+ message:
486
+ 'Recovered ' +
487
+ customPadShapes.entries.length +
488
+ ' custom pad shape sidecar records.'
489
+ })
490
+ }
491
+
492
+ if (unions.userUnions?.length || unions.smartUnions?.length) {
493
+ diagnostics.push({
494
+ severity: 'info',
495
+ message:
496
+ 'Recovered ' +
497
+ unions.userUnions.length +
498
+ ' user unions and ' +
499
+ unions.smartUnions.length +
500
+ ' smart unions.'
501
+ })
502
+ }
503
+
338
504
  if (recoveredOutline.source === 'board-route') {
339
505
  diagnostics.push({
340
506
  severity: 'info',
@@ -373,15 +539,30 @@ export class PcbModelParser {
373
539
  bomRowCount: bom.length,
374
540
  netCount: nets.length,
375
541
  classCount: classes.length,
542
+ differentialPairCount:
543
+ differentialPairData.differentialPairs.length,
544
+ differentialPairClassCount:
545
+ differentialPairData.differentialPairClasses.length,
376
546
  ruleCount: rules.length,
547
+ dimensionCount: dimensions.length,
548
+ mechanicalLayerPairCount: mechanicalLayerPairs.length,
377
549
  polygonCount: polygons.length,
378
550
  trackCount: tracks.length,
379
551
  arcCount: arcs.length,
380
552
  viaCount: vias.length,
553
+ viaStructureCount: viaStructures.structures?.length || 0,
554
+ extendedPrimitiveInformationCount:
555
+ extendedPrimitiveInformation.entries?.length || 0,
556
+ customPadShapeCount: customPadShapes.entries?.length || 0,
557
+ userUnionCount: unions.userUnions?.length || 0,
558
+ smartUnionCount: unions.smartUnions?.length || 0,
381
559
  boardRegionCount: boardRegionSummary.boardRegionCount,
382
560
  flexRegionCount: boardRegionSummary.flexRegionCount,
383
561
  bendingLineCount: boardRegionSummary.bendingLineCount,
562
+ embeddedModelIssueCount:
563
+ embeddedModelIntegrity.issues?.length || 0,
384
564
  embeddedFontCount: extractedEmbeddedFonts.length,
565
+ embeddedFileCount: embeddedFiles.files.length,
385
566
  rawRecordCount: rawRecords.length,
386
567
  boardWidthMil: Math.round(boardOutline.widthMil),
387
568
  boardHeightMil: Math.round(boardOutline.heightMil)
@@ -391,30 +572,48 @@ export class PcbModelParser {
391
572
  boardOutline: normalizedPcb.boardOutline,
392
573
  layers,
393
574
  layerSubstacks,
575
+ mechanicalLayerPairs,
576
+ layerFlipMetadata,
394
577
  boardRegionContexts,
395
578
  primitiveLayers,
396
579
  appearance3d,
397
580
  nets,
398
581
  classes,
582
+ differentialPairs: differentialPairData.differentialPairs,
583
+ differentialPairClasses:
584
+ differentialPairData.differentialPairClasses,
399
585
  rules,
586
+ ...(defaults ? { defaults } : {}),
587
+ maskPaste,
588
+ dimensions,
400
589
  components: normalizedPcb.components,
590
+ pickPlace: pnp,
401
591
  polygons: normalizedPcb.polygons,
402
592
  fills: normalizedPcb.fills,
403
593
  tracks: normalizedPcb.tracks,
404
594
  arcs: normalizedPcb.arcs,
405
595
  vias: normalizedPcb.vias,
596
+ viaStructures,
597
+ extendedPrimitiveInformation,
598
+ customPadShapes,
599
+ unions,
406
600
  pads: normalizedPcb.pads,
407
601
  regions: normalizedPcb.regions,
408
602
  shapeBasedRegions: normalizedPcb.shapeBasedRegions,
409
603
  boardRegions: normalizedPcb.boardRegions,
410
604
  texts: normalizedPcb.texts,
411
605
  embeddedModels: extractedEmbeddedModels,
606
+ embeddedModelIntegrity,
412
607
  embeddedFonts: extractedEmbeddedFonts,
608
+ embeddedFiles,
413
609
  rawRecords,
414
610
  componentBodies,
415
611
  componentPrimitives,
416
- componentPrimitiveGroups
612
+ componentPrimitiveGroups,
613
+ ownership,
614
+ statistics
417
615
  },
616
+ pnp,
418
617
  bom
419
618
  })
420
619
  }
@@ -444,33 +643,106 @@ export class PcbModelParser {
444
643
  /**
445
644
  * Normalizes component placement fields while preserving native index order.
446
645
  * @param {{ fields: Record<string, string | string[]> }[]} records
447
- * @returns {{ componentIndex: number, designator: string, uniqueId: string, x: number, y: number, layer: string, pattern: string, rotation: number, source: string, description: string, height: number | null, nameOn: boolean, commentOn: boolean }[]}
646
+ * @returns {{ componentIndex: number, designator: string, uniqueId: string, x: number, y: number, layer: string, pattern: string, rotation: number, source: string, description: string, height: number | null, provenance: object, nameOn: boolean, commentOn: boolean }[]}
448
647
  */
449
648
  static #normalizeComponentRecords(records) {
450
649
  return records
451
- .map((record, index) => ({
452
- componentIndex: index,
453
- designator: getField(record.fields, 'SOURCEDESIGNATOR'),
454
- uniqueId:
455
- getField(record.fields, 'UNIQUEID') ||
456
- getField(record.fields, 'UID') ||
457
- getField(record.fields, 'UNIQUEIDPRIMITIVEINFORMATION'),
458
- x: parseNumericField(record.fields, 'X') || 0,
459
- y: parseNumericField(record.fields, 'Y') || 0,
460
- layer: getField(record.fields, 'LAYER') || 'TOP',
461
- pattern: getField(record.fields, 'PATTERN'),
462
- rotation: parseNumericField(record.fields, 'ROTATION') || 0,
463
- source:
464
- getField(record.fields, 'SOURCELIBREFERENCE') ||
465
- getField(record.fields, 'SOURCEFOOTPRINTLIBRARY'),
466
- description: getField(record.fields, 'SOURCEDESCRIPTION'),
467
- height: parseNumericField(record.fields, 'HEIGHT'),
468
- nameOn: parseBoolean(record.fields.NAMEON),
469
- commentOn: parseBoolean(record.fields.COMMENTON)
470
- }))
650
+ .map((record, index) => {
651
+ const provenance = PcbModelParser.#parseComponentProvenance(
652
+ record.fields
653
+ )
654
+
655
+ return {
656
+ componentIndex: index,
657
+ designator: getField(record.fields, 'SOURCEDESIGNATOR'),
658
+ uniqueId:
659
+ getField(record.fields, 'UNIQUEID') ||
660
+ getField(record.fields, 'UID') ||
661
+ getField(record.fields, 'UNIQUEIDPRIMITIVEINFORMATION'),
662
+ x: parseNumericField(record.fields, 'X') || 0,
663
+ y: parseNumericField(record.fields, 'Y') || 0,
664
+ layer: getField(record.fields, 'LAYER') || 'TOP',
665
+ pattern: getField(record.fields, 'PATTERN'),
666
+ rotation: parseNumericField(record.fields, 'ROTATION') || 0,
667
+ source:
668
+ getField(record.fields, 'SOURCELIBREFERENCE') ||
669
+ getField(record.fields, 'SOURCEFOOTPRINTLIBRARY'),
670
+ description: getField(record.fields, 'SOURCEDESCRIPTION'),
671
+ height: parseNumericField(record.fields, 'HEIGHT'),
672
+ ...(Object.keys(provenance).length ? { provenance } : {}),
673
+ nameOn: parseBoolean(record.fields.NAMEON),
674
+ commentOn: parseBoolean(record.fields.COMMENTON)
675
+ }
676
+ })
471
677
  .filter((component) => component.pattern && component.designator)
472
678
  }
473
679
 
680
+ /**
681
+ * Parses schematic/project provenance from one PCB component row.
682
+ * @param {Record<string, string | string[]>} fields
683
+ * @returns {Record<string, unknown>}
684
+ */
685
+ static #parseComponentProvenance(fields) {
686
+ const sourceUniqueId = getField(fields, 'SOURCEUNIQUEID')
687
+ const sourceHierarchicalPath = getField(
688
+ fields,
689
+ 'SOURCEHIERARCHICALPATH'
690
+ )
691
+ const sourceFootprintLibrary = getField(
692
+ fields,
693
+ 'SOURCEFOOTPRINTLIBRARY'
694
+ )
695
+
696
+ const provenance = PcbModelParser.#stripEmptyObject({
697
+ channelOffset: parseNumericField(fields, 'CHANNELOFFSET'),
698
+ sourceDesignator: getField(fields, 'SOURCEDESIGNATOR'),
699
+ sourceUniqueId,
700
+ sourceUniqueIdSegments:
701
+ PcbModelParser.#splitAltiumPath(sourceUniqueId),
702
+ sourceHierarchicalPath,
703
+ sourceHierarchySegments: PcbModelParser.#splitAltiumPath(
704
+ sourceHierarchicalPath
705
+ ),
706
+ sourceFootprintLibrary,
707
+ sourceFootprintLibraryName: PcbModelParser.#basenameFromAltiumPath(
708
+ sourceFootprintLibrary
709
+ ),
710
+ sourceLibReference: getField(fields, 'SOURCELIBREFERENCE'),
711
+ sourceComponentLibrary: getField(fields, 'SOURCECOMPONENTLIBRARY'),
712
+ sourceComponentLibraryIdentifierKind: parseNumericField(
713
+ fields,
714
+ 'SOURCECOMPLIBIDENTIFIERKIND'
715
+ ),
716
+ sourceComponentLibraryIdentifier: getField(
717
+ fields,
718
+ 'SOURCECOMPLIBRARYIDENTIFIER'
719
+ ),
720
+ footprintDescription: getField(fields, 'FOOTPRINTDESCRIPTION'),
721
+ nameAutoPosition: parseNumericField(fields, 'NAMEAUTOPOSITION'),
722
+ commentAutoPosition: parseNumericField(
723
+ fields,
724
+ 'COMMENTAUTOPOSITION'
725
+ ),
726
+ lockStrings: PcbModelParser.#optionalBooleanField(
727
+ fields,
728
+ 'LOCKSTRINGS'
729
+ ),
730
+ enablePinSwapping: PcbModelParser.#optionalBooleanField(
731
+ fields,
732
+ 'ENABLEPINSWAPPING'
733
+ ),
734
+ enablePartSwapping: PcbModelParser.#optionalBooleanField(
735
+ fields,
736
+ 'ENABLEPARTSWAPPING'
737
+ )
738
+ })
739
+ const nonRedundantKeys = Object.keys(provenance).filter(
740
+ (key) => !['sourceDesignator', 'sourceLibReference'].includes(key)
741
+ )
742
+
743
+ return nonRedundantKeys.length ? provenance : {}
744
+ }
745
+
474
746
  /**
475
747
  * Normalizes native Nets6/Data records in stream order.
476
748
  * @param {{ fields: Record<string, string | string[]>, sourceStream?: string }[]} records
@@ -560,6 +832,172 @@ export class PcbModelParser {
560
832
  return netNameByIndex
561
833
  }
562
834
 
835
+ /**
836
+ * Parses native DifferentialPairs6/Data records in stream order.
837
+ * @param {{ fields: Record<string, string | string[]>, sourceStream?: string }[]} records
838
+ * @returns {{ pairIndex: number, name: string, positiveNetName: string, negativeNetName: string, netNames: string[], gatherControl: boolean, uniqueId: string }[]}
839
+ */
840
+ static #parseDifferentialPairRecords(records) {
841
+ return records
842
+ .filter(
843
+ (record) => record.sourceStream === 'DifferentialPairs6/Data'
844
+ )
845
+ .map((record, index) => {
846
+ const positiveNetName = getField(
847
+ record.fields,
848
+ 'POSITIVENETNAME'
849
+ )
850
+ const negativeNetName = getField(
851
+ record.fields,
852
+ 'NEGATIVENETNAME'
853
+ )
854
+
855
+ return {
856
+ pairIndex: index,
857
+ name: getField(record.fields, 'NAME'),
858
+ positiveNetName,
859
+ negativeNetName,
860
+ netNames: [positiveNetName, negativeNetName].filter(
861
+ Boolean
862
+ ),
863
+ gatherControl: PcbModelParser.#parseBooleanField(
864
+ record.fields,
865
+ 'GATHERCONTROL',
866
+ false
867
+ ),
868
+ uniqueId:
869
+ getField(record.fields, 'UNIQUEID') ||
870
+ getField(record.fields, 'UID')
871
+ }
872
+ })
873
+ .filter(
874
+ (pair) =>
875
+ pair.name ||
876
+ pair.positiveNetName ||
877
+ pair.negativeNetName ||
878
+ pair.uniqueId
879
+ )
880
+ }
881
+
882
+ /**
883
+ * Joins differential-pair class members to concrete pair records.
884
+ * @param {{ pairIndex: number, name: string, positiveNetName: string, negativeNetName: string, netNames: string[], gatherControl: boolean, uniqueId: string }[]} pairs
885
+ * @param {{ classIndex: number, name: string, kindName: string, members: string[] }[]} classes
886
+ * @returns {{ differentialPairs: object[], differentialPairClasses: object[] }}
887
+ */
888
+ static #buildDifferentialPairData(pairs, classes) {
889
+ const pairsByName = new Map(
890
+ (pairs || []).map((pair) => [
891
+ PcbModelParser.#normalizeLookupName(pair.name),
892
+ pair
893
+ ])
894
+ )
895
+ const classNamesByPair = new Map()
896
+ const differentialPairClasses = (classes || [])
897
+ .filter((classRecord) => classRecord.kindName === 'diff-pair')
898
+ .map((classRecord) => {
899
+ const pairNames = []
900
+ const unresolvedMembers = []
901
+
902
+ for (const member of classRecord.members || []) {
903
+ const pair = pairsByName.get(
904
+ PcbModelParser.#normalizeLookupName(member)
905
+ )
906
+
907
+ if (!pair) {
908
+ unresolvedMembers.push(member)
909
+ continue
910
+ }
911
+
912
+ pairNames.push(pair.name)
913
+ const classNames = classNamesByPair.get(pair.name) || []
914
+ classNames.push(classRecord.name)
915
+ classNamesByPair.set(pair.name, classNames)
916
+ }
917
+
918
+ return {
919
+ classIndex: classRecord.classIndex,
920
+ name: classRecord.name,
921
+ members: [...classRecord.members],
922
+ pairNames,
923
+ unresolvedMembers
924
+ }
925
+ })
926
+
927
+ return {
928
+ differentialPairs: (pairs || []).map((pair) => ({
929
+ ...pair,
930
+ classNames: classNamesByPair.get(pair.name) || []
931
+ })),
932
+ differentialPairClasses
933
+ }
934
+ }
935
+
936
+ /**
937
+ * Splits an authored hierarchy or unique-id path into stable segments.
938
+ * @param {string | undefined} value Path-like field value.
939
+ * @returns {string[]}
940
+ */
941
+ static #splitAltiumPath(value) {
942
+ return String(value || '')
943
+ .split(/[\\/]+/u)
944
+ .map((segment) => segment.trim())
945
+ .filter(Boolean)
946
+ }
947
+
948
+ /**
949
+ * Returns the terminal segment from a native path-like field value.
950
+ * @param {string | undefined} value Path-like field value.
951
+ * @returns {string}
952
+ */
953
+ static #basenameFromAltiumPath(value) {
954
+ const segments = PcbModelParser.#splitAltiumPath(value)
955
+
956
+ return segments.length ? segments[segments.length - 1] : ''
957
+ }
958
+
959
+ /**
960
+ * Removes empty values while preserving explicit false and zero values.
961
+ * @param {Record<string, unknown>} value Object to normalize.
962
+ * @returns {Record<string, unknown>}
963
+ */
964
+ static #stripEmptyObject(value) {
965
+ return Object.fromEntries(
966
+ Object.entries(value || {}).filter(([, entryValue]) => {
967
+ if (Array.isArray(entryValue)) {
968
+ return entryValue.length > 0
969
+ }
970
+ if (typeof entryValue === 'string') {
971
+ return entryValue.length > 0
972
+ }
973
+ return entryValue !== null && entryValue !== undefined
974
+ })
975
+ )
976
+ }
977
+
978
+ /**
979
+ * Parses an optional boolean field without inventing a default value.
980
+ * @param {Record<string, string | string[]>} fields Native fields.
981
+ * @param {string} key Field name.
982
+ * @returns {boolean | null}
983
+ */
984
+ static #optionalBooleanField(fields, key) {
985
+ const raw = getField(fields, key)
986
+
987
+ return raw ? parseBoolean(raw) : null
988
+ }
989
+
990
+ /**
991
+ * Builds a case-insensitive lookup key for class and pair names.
992
+ * @param {string | undefined} value Raw lookup value.
993
+ * @returns {string}
994
+ */
995
+ static #normalizeLookupName(value) {
996
+ return String(value || '')
997
+ .trim()
998
+ .toUpperCase()
999
+ }
1000
+
563
1001
  /**
564
1002
  * Extracts authored Altium 3D appearance colors from board metadata.
565
1003
  * @param {{ fields: Record<string, string | string[]> }[]} boardRecords