altium-toolkit 1.0.8 → 1.0.10

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