altium-toolkit 1.1.3 → 1.1.23

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 (125) hide show
  1. package/README.md +34 -5
  2. package/docs/api.md +171 -23
  3. package/docs/model-format.md +192 -20
  4. package/docs/schemas/altium_toolkit/embedded_assets_a1.schema.json +56 -0
  5. package/docs/schemas/altium_toolkit/fixture_coverage_matrix_a1.schema.json +89 -0
  6. package/docs/schemas/altium_toolkit/geometry_bounds_a1.schema.json +86 -0
  7. package/docs/schemas/altium_toolkit/library_catalog_a1.schema.json +65 -0
  8. package/docs/schemas/altium_toolkit/library_diff_a1.schema.json +54 -0
  9. package/docs/schemas/altium_toolkit/library_inspection_a1.schema.json +94 -0
  10. package/docs/schemas/altium_toolkit/library_qa_a1.schema.json +4 -0
  11. package/docs/schemas/altium_toolkit/native_stream_inventory_a1.schema.json +66 -0
  12. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +513 -3
  13. package/docs/schemas/altium_toolkit/parameter_record_inventory_a1.schema.json +84 -0
  14. package/docs/schemas/altium_toolkit/parser_diagnostics_a1.schema.json +63 -0
  15. package/docs/schemas/altium_toolkit/parser_value_verification_a1.schema.json +74 -0
  16. package/docs/schemas/altium_toolkit/pcb_class_report_a1.schema.json +79 -0
  17. package/docs/schemas/altium_toolkit/pcb_inspection_a1.schema.json +65 -0
  18. package/docs/schemas/altium_toolkit/pcb_net_membership_a1.schema.json +98 -0
  19. package/docs/schemas/altium_toolkit/project_bundle_a1.schema.json +3 -0
  20. package/docs/schemas/altium_toolkit/project_hierarchy_a1.schema.json +79 -0
  21. package/docs/schemas/altium_toolkit/unsupported_features_a1.schema.json +212 -0
  22. package/docs/testing.md +7 -0
  23. package/examples/README.md +21 -0
  24. package/examples/cli-utils.mjs +148 -0
  25. package/examples/corpus-smoke.mjs +523 -0
  26. package/examples/extract-bom.mjs +47 -0
  27. package/examples/generate-pnp.mjs +59 -0
  28. package/examples/inspect-board.mjs +70 -0
  29. package/examples/inspect-schematic.mjs +406 -0
  30. package/examples/library-catalog.mjs +115 -0
  31. package/examples/net-report.mjs +61 -0
  32. package/examples/validate-library.mjs +59 -0
  33. package/package.json +1 -1
  34. package/spec/library-scope.md +5 -0
  35. package/src/core/BinaryReader.mjs +213 -2
  36. package/src/core/altium/AltiumLibraryBatchExporter.mjs +206 -0
  37. package/src/core/altium/AltiumLibraryRecordBuilder.mjs +293 -0
  38. package/src/core/altium/AltiumParser.mjs +357 -16
  39. package/src/core/altium/AltiumPcbLibExporter.mjs +101 -0
  40. package/src/core/altium/AltiumSchLibExporter.mjs +57 -0
  41. package/src/core/altium/AltiumUnits.mjs +205 -0
  42. package/src/core/altium/AsciiRecordParser.mjs +52 -11
  43. package/src/core/altium/EmbeddedAssetReportBuilder.mjs +383 -0
  44. package/src/core/altium/FixtureCoverageMatrixBuilder.mjs +304 -0
  45. package/src/core/altium/GeometryBoundsReportBuilder.mjs +935 -0
  46. package/src/core/altium/LibraryCatalogArtifactBuilder.mjs +296 -0
  47. package/src/core/altium/LibraryDiffReportBuilder.mjs +260 -0
  48. package/src/core/altium/LibraryInspectionReportBuilder.mjs +156 -0
  49. package/src/core/altium/LibraryQaReportBuilder.mjs +374 -1
  50. package/src/core/altium/NativeStreamInventoryBuilder.mjs +177 -0
  51. package/src/core/altium/NormalizedModelSchema.mjs +3 -31
  52. package/src/core/altium/ParameterCollection.mjs +431 -0
  53. package/src/core/altium/ParameterRecordInventoryBuilder.mjs +274 -0
  54. package/src/core/altium/ParserCompatibilityFuzzer.mjs +106 -2
  55. package/src/core/altium/ParserDiagnosticNormalizer.mjs +213 -0
  56. package/src/core/altium/ParserErrors.mjs +90 -0
  57. package/src/core/altium/ParserFieldCoverageReportBuilder.mjs +656 -0
  58. package/src/core/altium/ParserUtils.mjs +24 -0
  59. package/src/core/altium/ParserValueVerificationReportBuilder.mjs +323 -0
  60. package/src/core/altium/PcbClassReportBuilder.mjs +366 -0
  61. package/src/core/altium/PcbComponentKindPolicy.mjs +9 -9
  62. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +22 -3
  63. package/src/core/altium/PcbInspectionReportBuilder.mjs +313 -0
  64. package/src/core/altium/PcbLayerGroups.mjs +308 -0
  65. package/src/core/altium/PcbLayerStackCustomDataParser.mjs +183 -0
  66. package/src/core/altium/PcbLayerStackInterchangeParser.mjs +473 -4
  67. package/src/core/altium/PcbLayerStackReadModelBuilder.mjs +83 -15
  68. package/src/core/altium/PcbLayerStackSourceMetadataParser.mjs +74 -4
  69. package/src/core/altium/PcbLibModelParser.mjs +20 -4
  70. package/src/core/altium/PcbLibStreamExtractor.mjs +49 -6
  71. package/src/core/altium/PcbModelParser.mjs +223 -4
  72. package/src/core/altium/PcbNetMembershipReportBuilder.mjs +270 -0
  73. package/src/core/altium/PcbOutlineRecovery.mjs +94 -0
  74. package/src/core/altium/PcbStreamExtractor.mjs +130 -6
  75. package/src/core/altium/PcbTrackPrimitiveParser.mjs +66 -2
  76. package/src/core/altium/ProjectDesignBundleBuilder.mjs +15 -0
  77. package/src/core/altium/ProjectHierarchyReportBuilder.mjs +660 -0
  78. package/src/core/altium/ProjectNetlistExporter.mjs +2 -0
  79. package/src/core/altium/RawDataPreservationReportBuilder.mjs +348 -0
  80. package/src/core/altium/SchLibModelParser.mjs +840 -0
  81. package/src/core/altium/SchLibStreamExtractor.mjs +586 -0
  82. package/src/core/altium/SchematicBusEntryParser.mjs +3 -2
  83. package/src/core/altium/SchematicCodeSymbolParser.mjs +663 -0
  84. package/src/core/altium/SchematicConnectivityQaBuilder.mjs +177 -2
  85. package/src/core/altium/SchematicDirectiveParser.mjs +5 -17
  86. package/src/core/altium/SchematicDisplayModeCatalogParser.mjs +10 -1
  87. package/src/core/altium/SchematicFieldCoverageReportBuilder.mjs +549 -0
  88. package/src/core/altium/SchematicHarnessParser.mjs +9 -3
  89. package/src/core/altium/SchematicHyperlinkParser.mjs +122 -0
  90. package/src/core/altium/SchematicNetlistBuilder.mjs +271 -8
  91. package/src/core/altium/SchematicNoErcSymbolResolver.mjs +36 -0
  92. package/src/core/altium/SchematicOwnershipGraphParser.mjs +102 -3
  93. package/src/core/altium/SchematicPinParser.mjs +99 -65
  94. package/src/core/altium/SchematicPrimitiveParser.mjs +125 -22
  95. package/src/core/altium/SchematicQaReportBuilder.mjs +2 -0
  96. package/src/core/altium/SchematicRecordStreamParser.mjs +183 -0
  97. package/src/core/altium/SchematicRecordTypeRegistry.mjs +6 -1
  98. package/src/core/altium/SchematicSheetParser.mjs +8 -2
  99. package/src/core/altium/SchematicStreamExtractor.mjs +107 -21
  100. package/src/core/altium/SchematicTextOrientationResolver.mjs +76 -0
  101. package/src/core/altium/SchematicTextParser.mjs +28 -12
  102. package/src/core/altium/SchematicTextRunParser.mjs +81 -0
  103. package/src/core/altium/SchematicThumbnailParser.mjs +425 -0
  104. package/src/core/altium/SourceBundleExporter.mjs +156 -0
  105. package/src/core/altium/SourceComponentBundleNormalizer.mjs +295 -0
  106. package/src/core/altium/SourceComponentClient.mjs +239 -0
  107. package/src/core/altium/UnsupportedFeatureReportBuilder.mjs +380 -0
  108. package/src/core/ole/OleCompoundDocumentWriter.mjs +449 -0
  109. package/src/parser.mjs +43 -1
  110. package/src/renderers.mjs +1 -0
  111. package/src/styles/altium-renderers.css +6 -6
  112. package/src/ui/PcbArcUtils.mjs +19 -2
  113. package/src/ui/PcbScene3dBuilder.mjs +202 -20
  114. package/src/ui/PcbScene3dModelRegistry.mjs +28 -18
  115. package/src/ui/PcbScene3dPlacementSideResolver.mjs +48 -6
  116. package/src/ui/SchematicColorResolver.mjs +185 -0
  117. package/src/ui/SchematicDirectiveRenderer.mjs +133 -22
  118. package/src/ui/SchematicLineColorResolver.mjs +88 -0
  119. package/src/ui/SchematicNoteRenderer.mjs +5 -1
  120. package/src/ui/SchematicOwnerPinLabelLayout.mjs +269 -8
  121. package/src/ui/SchematicOwnerPinMarkerLineThemer.mjs +155 -0
  122. package/src/ui/SchematicPinSvgRenderer.mjs +229 -62
  123. package/src/ui/SchematicShapeRenderer.mjs +86 -17
  124. package/src/ui/SchematicSvgRenderer.mjs +980 -58
  125. package/src/ui/SchematicTypography.mjs +4 -3
@@ -31,10 +31,9 @@ export class PcbLayerStackInterchangeParser {
31
31
  */
32
32
  static parseText(fileName, text) {
33
33
  const format = PcbLayerStackInterchangeParser.#format(fileName, text)
34
- const fields =
35
- format === 'stackupx'
36
- ? PcbLayerStackInterchangeParser.#stackupxFields(text)
37
- : PcbLayerStackInterchangeParser.#stackupFields(text)
34
+ const fields = PcbLayerStackInterchangeParser.parseTextToFields(text, {
35
+ format
36
+ })
38
37
  const layers = AltiumLayoutParser.parseLayerStack(fields)
39
38
  const layerSubstacks =
40
39
  PcbBoardRegionSemanticsParser.parseLayerSubstacks([fields])
@@ -57,6 +56,21 @@ export class PcbLayerStackInterchangeParser {
57
56
  }
58
57
  }
59
58
 
59
+ /**
60
+ * Converts layer-stack interchange text into board-compatible fields.
61
+ * @param {string} text Source text.
62
+ * @param {{ format?: 'stackup' | 'stackupx' }} options Parse options.
63
+ * @returns {Record<string, string>}
64
+ */
65
+ static parseTextToFields(text, options = {}) {
66
+ const format =
67
+ options.format || PcbLayerStackInterchangeParser.#format('', text)
68
+
69
+ return format === 'stackupx'
70
+ ? PcbLayerStackInterchangeParser.#stackupxFields(text)
71
+ : PcbLayerStackInterchangeParser.#stackupFields(text)
72
+ }
73
+
60
74
  /**
61
75
  * Detects the interchange format.
62
76
  * @param {string} fileName Source file name.
@@ -114,6 +128,14 @@ export class PcbLayerStackInterchangeParser {
114
128
  static #stackupxFields(text) {
115
129
  const fields = {}
116
130
 
131
+ if (PcbLayerStackInterchangeParser.#hasTag(text, 'StackupDocument')) {
132
+ PcbLayerStackInterchangeParser.#assignStackupDocumentFields(
133
+ fields,
134
+ text
135
+ )
136
+ return fields
137
+ }
138
+
117
139
  for (const layer of PcbLayerStackInterchangeParser.#tagFields(text, [
118
140
  'Layer'
119
141
  ])) {
@@ -169,6 +191,243 @@ export class PcbLayerStackInterchangeParser {
169
191
  return fields
170
192
  }
171
193
 
194
+ /**
195
+ * Converts StackupDocument XML into board-compatible fields.
196
+ * @param {Record<string, string>} fields Target fields.
197
+ * @param {string} text Source XML text.
198
+ * @returns {void}
199
+ */
200
+ static #assignStackupDocumentFields(fields, text) {
201
+ const document = PcbLayerStackInterchangeParser.#tagBlocks(text, [
202
+ 'StackupDocument'
203
+ ])[0]
204
+
205
+ if (!document) {
206
+ return
207
+ }
208
+
209
+ PcbLayerStackInterchangeParser.#assignTopLevelFields(
210
+ fields,
211
+ 'STACKUPX',
212
+ document.fields,
213
+ {
214
+ SerializerVersion: 'SERIALIZER_VERSION',
215
+ RevisionId: 'REVISION_ID'
216
+ }
217
+ )
218
+ PcbLayerStackInterchangeParser.#assignStackupFeatures(
219
+ fields,
220
+ document.body
221
+ )
222
+
223
+ const stackup = PcbLayerStackInterchangeParser.#tagBlocks(
224
+ document.body,
225
+ ['Stackup']
226
+ )[0]
227
+
228
+ if (!stackup) {
229
+ return
230
+ }
231
+
232
+ PcbLayerStackInterchangeParser.#assignTopLevelFields(
233
+ fields,
234
+ 'STACKUPX',
235
+ stackup.fields,
236
+ {
237
+ Type: 'TYPE',
238
+ RoughnessType: 'ROUGHNESS_TYPE',
239
+ RoughnessFactorSR: 'ROUGHNESS_FACTOR_SR',
240
+ RoughnessFactor: 'ROUGHNESS_FACTOR',
241
+ RealisticRatio: 'REALISTIC_RATIO'
242
+ }
243
+ )
244
+
245
+ const stacks = PcbLayerStackInterchangeParser.#stackupStacks(
246
+ stackup.body
247
+ )
248
+ let nextLayerIndex = 1
249
+
250
+ stacks.forEach((stack, stackIndex) => {
251
+ const layers = PcbLayerStackInterchangeParser.#stackLayers(
252
+ stack.body
253
+ )
254
+ const layerIds = []
255
+
256
+ PcbLayerStackInterchangeParser.#assignSubstackFields(
257
+ fields,
258
+ stackIndex,
259
+ {
260
+ Id: stack.fields.Id || stack.fields.ID || '',
261
+ Name: stack.fields.Name || '',
262
+ IsFlex: stack.fields.IsFlex || '',
263
+ StackType: stack.fields.Type || ''
264
+ }
265
+ )
266
+
267
+ for (const layer of layers) {
268
+ const layerIndex =
269
+ PcbLayerStackInterchangeParser.#positiveInteger(
270
+ layer.fields.Index || layer.fields.index
271
+ ) || nextLayerIndex
272
+ const layerId =
273
+ PcbLayerStackInterchangeParser.#positiveInteger(
274
+ layer.fields.LayerId || layer.fields.LayerID
275
+ ) || layerIndex
276
+ const properties =
277
+ PcbLayerStackInterchangeParser.#layerProperties(layer.body)
278
+ const layerValues =
279
+ PcbLayerStackInterchangeParser.#stackupDocumentLayerValues(
280
+ layer,
281
+ layerId,
282
+ properties
283
+ )
284
+
285
+ PcbLayerStackInterchangeParser.#assignLayerFields(
286
+ fields,
287
+ layerIndex,
288
+ layerValues
289
+ )
290
+ layerIds.push(layerId)
291
+ nextLayerIndex = Math.max(nextLayerIndex, layerIndex + 1)
292
+ }
293
+
294
+ if (layerIds.length) {
295
+ fields['V9_SUBSTACK' + stackIndex + '_LAYERS'] =
296
+ layerIds.join(',')
297
+ }
298
+ })
299
+ }
300
+
301
+ /**
302
+ * Assigns StackupDocument feature rows.
303
+ * @param {Record<string, string>} fields Target fields.
304
+ * @param {string} body Document body.
305
+ * @returns {void}
306
+ */
307
+ static #assignStackupFeatures(fields, body) {
308
+ const features = PcbLayerStackInterchangeParser.#tagBlocks(body, [
309
+ 'Feature'
310
+ ])
311
+
312
+ features.forEach((feature, index) => {
313
+ const prefix = 'STACKUPX_FEATURE' + index + '_'
314
+ const featureId = feature.fields.Id || feature.fields.ID || ''
315
+ const featureKind =
316
+ PcbLayerStackInterchangeParser.#featureKindFromId(featureId)
317
+
318
+ fields[prefix + 'ID'] = featureId
319
+ fields[prefix + 'NAME'] =
320
+ PcbLayerStackInterchangeParser.#decodeXmlText(feature.body)
321
+ if (featureKind) {
322
+ fields[prefix + 'KIND'] = featureKind
323
+ }
324
+ })
325
+ }
326
+
327
+ /**
328
+ * Finds stack rows inside the Stackup/Stacks container.
329
+ * @param {string} stackupBody Stackup XML body.
330
+ * @returns {{ tagName: string, fields: Record<string, string>, body: string }[]}
331
+ */
332
+ static #stackupStacks(stackupBody) {
333
+ const stacksContainer = PcbLayerStackInterchangeParser.#tagBlocks(
334
+ stackupBody,
335
+ ['Stacks']
336
+ )[0]
337
+
338
+ return PcbLayerStackInterchangeParser.#tagBlocks(
339
+ stacksContainer?.body || '',
340
+ ['Stack']
341
+ )
342
+ }
343
+
344
+ /**
345
+ * Finds layer rows inside one Stack/Layers container.
346
+ * @param {string} stackBody Stack XML body.
347
+ * @returns {{ tagName: string, fields: Record<string, string>, body: string }[]}
348
+ */
349
+ static #stackLayers(stackBody) {
350
+ const layersContainer = PcbLayerStackInterchangeParser.#tagBlocks(
351
+ stackBody,
352
+ ['Layers']
353
+ )[0]
354
+
355
+ return PcbLayerStackInterchangeParser.#tagBlocks(
356
+ layersContainer?.body || '',
357
+ ['Layer']
358
+ )
359
+ }
360
+
361
+ /**
362
+ * Converts one StackupDocument Layer element into known field names.
363
+ * @param {{ fields: Record<string, string>, body: string }} layer Layer block.
364
+ * @param {number} layerId Normalized layer id.
365
+ * @param {Record<string, string>} properties Layer properties.
366
+ * @returns {Record<string, string>}
367
+ */
368
+ static #stackupDocumentLayerValues(layer, layerId, properties) {
369
+ const typeId = layer.fields.TypeId || layer.fields.TypeID || ''
370
+ const values = {
371
+ Name: layer.fields.Name || '',
372
+ LayerId: String(layerId),
373
+ Kind:
374
+ layer.fields.Kind ||
375
+ layer.fields.Type ||
376
+ PcbLayerStackInterchangeParser.#kindFromLayerTypeId(typeId),
377
+ SourceRecordId: layer.fields.Id || layer.fields.ID || '',
378
+ StackupxShared: layer.fields.IsShared || '',
379
+ StackupxProperties:
380
+ PcbLayerStackInterchangeParser.#propertiesField(properties)
381
+ }
382
+
383
+ const propertyValueNames = {
384
+ Material: 'Material',
385
+ Thickness: 'Thickness',
386
+ Weight: 'CopperWeight',
387
+ CopperThickness: 'CopperThickness',
388
+ DielectricConstant: 'Dk',
389
+ LossTangent: 'Df',
390
+ Process: 'Process',
391
+ PullbackDistance: 'PullbackDistance',
392
+ CopperOrientation: 'CopperOrientation',
393
+ Orientation: 'Orientation',
394
+ Note: 'Note',
395
+ Comment: 'Comment',
396
+ 'Material.Manufacturer': 'MaterialManufacturer',
397
+ 'Material.Description': 'MaterialDescription',
398
+ 'Material.GlassTransitionTemp': 'MaterialGlassTransitionTemp',
399
+ GlassTransTemp: 'GlassTransitionTemp',
400
+ DielectricStrength: 'DielectricStrength',
401
+ VolumeResistivity: 'VolumeResistivity',
402
+ Resin: 'Resin',
403
+ Solid: 'Solid',
404
+ 'Material.Frequency': 'MaterialFrequency',
405
+ Frequency: 'Frequency',
406
+ Constructions: 'Constructions',
407
+ CoverlayExpansion: 'CoverlayExpansion'
408
+ }
409
+
410
+ for (const [propertyName, valueName] of Object.entries(
411
+ propertyValueNames
412
+ )) {
413
+ PcbLayerStackInterchangeParser.#assignPropertyValue(
414
+ values,
415
+ properties,
416
+ propertyName,
417
+ valueName
418
+ )
419
+ }
420
+
421
+ if (PcbLayerStackInterchangeParser.#isAdhesiveLayerType(typeId)) {
422
+ values.IsAdhesive = 'true'
423
+ }
424
+ if (PcbLayerStackInterchangeParser.#isStiffenerLayerType(typeId)) {
425
+ values.IsStiffener = 'true'
426
+ }
427
+
428
+ return values
429
+ }
430
+
172
431
  /**
173
432
  * Assigns one sectioned text field.
174
433
  * @param {Record<string, string>} fields Target fields.
@@ -248,11 +507,31 @@ export class PcbLayerStackInterchangeParser {
248
507
  CopperThickness: 'COPPERTHICKNESS',
249
508
  Dk: 'DK',
250
509
  Df: 'DF',
510
+ Process: 'PROCESS',
511
+ PullbackDistance: 'PULLBACKDISTANCE',
512
+ CopperOrientation: 'COPPERORIENTATION',
513
+ Orientation: 'ORIENTATION',
514
+ Note: 'NOTE',
515
+ Comment: 'COMMENT',
516
+ MaterialManufacturer: 'MATERIALMANUFACTURER',
517
+ MaterialDescription: 'MATERIALDESCRIPTION',
518
+ MaterialGlassTransitionTemp: 'MATERIALGLASSTRANSITIONTEMP',
519
+ GlassTransitionTemp: 'GLASSTRANSITIONTEMP',
520
+ DielectricStrength: 'DIELECTRICSTRENGTH',
521
+ VolumeResistivity: 'VOLUMERESISTIVITY',
522
+ Resin: 'RESIN',
523
+ Solid: 'SOLID',
524
+ MaterialFrequency: 'MATERIALFREQUENCY',
525
+ Frequency: 'FREQUENCY',
526
+ Constructions: 'CONSTRUCTIONS',
251
527
  IsAdhesive: 'ISADHESIVE',
252
528
  IsStiffener: 'ISSTIFFENER',
529
+ CopperWeight: 'COPPERWEIGHT',
530
+ CoverlayExpansion: 'COVERLAYEXPANSION',
253
531
  SurfaceFinish: 'SURFACEFINISH',
254
532
  SourceRecordId: 'SOURCE_RECORD_ID',
255
533
  SourceKeys: 'SOURCE_KEYS',
534
+ StackupxShared: 'STACKUPX_SHARED',
256
535
  StackupxProperties: 'STACKUPX_PROPERTIES'
257
536
  }
258
537
  const suffix = suffixByKey[key]
@@ -292,6 +571,7 @@ export class PcbLayerStackInterchangeParser {
292
571
  Name: 'NAME',
293
572
  IsFlex: 'ISFLEX',
294
573
  Layers: 'LAYERS',
574
+ StackupxShared: 'STACKUPX_SHARED',
295
575
  StackType: 'STACKUPX_STACKTYPE'
296
576
  }
297
577
  const suffix = suffixByKey[key]
@@ -387,6 +667,195 @@ export class PcbLayerStackInterchangeParser {
387
667
  }
388
668
  }
389
669
 
670
+ /**
671
+ * Assigns top-level scalar fields using one prefix.
672
+ * @param {Record<string, string>} fields Target fields.
673
+ * @param {string} prefix Field prefix.
674
+ * @param {Record<string, string>} values Source attributes.
675
+ * @param {Record<string, string>} keyMap Source-to-target suffixes.
676
+ * @returns {void}
677
+ */
678
+ static #assignTopLevelFields(fields, prefix, values, keyMap) {
679
+ for (const [sourceKey, suffix] of Object.entries(keyMap)) {
680
+ if (values[sourceKey]) {
681
+ fields[prefix + '_' + suffix] = values[sourceKey]
682
+ }
683
+ }
684
+ }
685
+
686
+ /**
687
+ * Assigns one known property value to layer values.
688
+ * @param {Record<string, string>} values Target values.
689
+ * @param {Record<string, string>} properties Source properties.
690
+ * @param {string} propertyName Source property name.
691
+ * @param {string} valueName Target value name.
692
+ * @returns {void}
693
+ */
694
+ static #assignPropertyValue(values, properties, propertyName, valueName) {
695
+ if (properties[propertyName]) {
696
+ values[valueName] = properties[propertyName]
697
+ }
698
+ }
699
+
700
+ /**
701
+ * Parses nested layer Property elements into a key/value map.
702
+ * @param {string} body Layer XML body.
703
+ * @returns {Record<string, string>}
704
+ */
705
+ static #layerProperties(body) {
706
+ const propertiesContainer = PcbLayerStackInterchangeParser.#tagBlocks(
707
+ body,
708
+ ['Properties']
709
+ )[0]
710
+ const properties = {}
711
+
712
+ for (const property of PcbLayerStackInterchangeParser.#tagBlocks(
713
+ propertiesContainer?.body || '',
714
+ ['Property']
715
+ )) {
716
+ const name = property.fields.Name || property.fields.name || ''
717
+ if (!name) {
718
+ continue
719
+ }
720
+
721
+ properties[name] = PcbLayerStackInterchangeParser.#decodeXmlText(
722
+ property.body
723
+ )
724
+ }
725
+
726
+ return properties
727
+ }
728
+
729
+ /**
730
+ * Serializes property rows into the existing key/value sidecar field.
731
+ * @param {Record<string, string>} properties Source properties.
732
+ * @returns {string}
733
+ */
734
+ static #propertiesField(properties) {
735
+ return Object.entries(properties)
736
+ .map(([key, value]) => key + '=' + value)
737
+ .join('|')
738
+ }
739
+
740
+ /**
741
+ * Resolves a positive integer.
742
+ * @param {string | number | undefined} value Source value.
743
+ * @returns {number}
744
+ */
745
+ static #positiveInteger(value) {
746
+ const parsed = Number.parseInt(String(value || ''), 10)
747
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : 0
748
+ }
749
+
750
+ /**
751
+ * Returns true when text contains the requested XML-like tag.
752
+ * @param {string} text Source text.
753
+ * @param {string} tagName Tag name.
754
+ * @returns {boolean}
755
+ */
756
+ static #hasTag(text, tagName) {
757
+ return new RegExp('<\\s*' + tagName + '\\b', 'iu').test(
758
+ String(text || '')
759
+ )
760
+ }
761
+
762
+ /**
763
+ * Decodes simple XML text content used by stack metadata.
764
+ * @param {string} value Encoded text.
765
+ * @returns {string}
766
+ */
767
+ static #decodeXmlText(value) {
768
+ return String(value || '')
769
+ .replace(/<!\[CDATA\[([\s\S]*?)\]\]>/gu, '$1')
770
+ .replace(/&quot;/gu, '"')
771
+ .replace(/&apos;/gu, "'")
772
+ .replace(/&lt;/gu, '<')
773
+ .replace(/&gt;/gu, '>')
774
+ .replace(/&amp;/gu, '&')
775
+ .trim()
776
+ }
777
+
778
+ /**
779
+ * Maps known stack layer type ids to broad layer kinds.
780
+ * @param {string} typeId Source type id.
781
+ * @returns {string}
782
+ */
783
+ static #kindFromLayerTypeId(typeId) {
784
+ const normalized = PcbLayerStackInterchangeParser.#normalizeGuid(typeId)
785
+ const kindByTypeId = new Map([
786
+ ['31E48829-E750-4C28-95E0-1A8313F0158E', 'signal'],
787
+ ['F59FAB94-C5ED-467D-94CD-F60A323C5D5B', 'plane'],
788
+ ['F4ECCD87-2CFB-4F37-BE50-4F3A272B4D01', 'signal'],
789
+ ['B0827674-798C-4CF8-807C-8E6C2A11C145', 'finish'],
790
+ ['92B02D5E-8D69-48A8-880E-AC4B77DB099D', 'dielectric'],
791
+ ['136C62EF-1FA6-4897-AE71-7E797B632B92', 'dielectric'],
792
+ ['1A79611A-039D-4D40-A204-53C26C50F8B5', 'dielectric'],
793
+ ['90B89AA0-A48A-45F4-82F5-B3ECA4EC8CCE', 'plating'],
794
+ ['448F9952-79BA-41D8-A8F4-4713EE7A3828', 'mechanical'],
795
+ ['9FD889FA-C97A-401C-A066-E5F746678381', 'mechanical'],
796
+ ['786B5F28-F093-4084-BBA7-46E8F4F24F55', 'mechanical'],
797
+ ['886956F5-B2E9-4114-93F3-F69AF872BFFB', 'marking']
798
+ ])
799
+
800
+ return kindByTypeId.get(normalized) || ''
801
+ }
802
+
803
+ /**
804
+ * Maps known stackup feature ids to stable feature kinds.
805
+ * @param {string} featureId Source feature id.
806
+ * @returns {string}
807
+ */
808
+ static #featureKindFromId(featureId) {
809
+ const normalized =
810
+ PcbLayerStackInterchangeParser.#normalizeGuid(featureId)
811
+ const kindByFeatureId = new Map([
812
+ ['C8939E8A-FD0E-4D52-8860-B7A98F452016', 'standard-stackup'],
813
+ ['E3DF2B86-5F1B-49CA-B266-D1AE57F0BA6F', 'impedance-calculator'],
814
+ ['5277E6A4-9E5F-4F54-951F-DC18CFEB7530', 'rigid-flex'],
815
+ ['68E477FE-0406-4BD2-AD1D-6DD49217052C', 'printed-electronics'],
816
+ ['0A82BA33-E4D8-43F3-9C01-412DC26BDD5E', 'back-drills'],
817
+ ['231FB828-14F8-43F8-9DDF-B2A90A4C5283', 'generic-stackup']
818
+ ])
819
+
820
+ return kindByFeatureId.get(normalized) || ''
821
+ }
822
+
823
+ /**
824
+ * Normalizes GUID text for case-insensitive lookup.
825
+ * @param {string} value Source GUID text.
826
+ * @returns {string}
827
+ */
828
+ static #normalizeGuid(value) {
829
+ return String(value || '')
830
+ .trim()
831
+ .replace(/[{}]/gu, '')
832
+ .toUpperCase()
833
+ }
834
+
835
+ /**
836
+ * Returns true for known adhesive layer type ids.
837
+ * @param {string} typeId Source type id.
838
+ * @returns {boolean}
839
+ */
840
+ static #isAdhesiveLayerType(typeId) {
841
+ return (
842
+ PcbLayerStackInterchangeParser.#normalizeGuid(typeId) ===
843
+ '448F9952-79BA-41D8-A8F4-4713EE7A3828'
844
+ )
845
+ }
846
+
847
+ /**
848
+ * Returns true for known stiffener layer type ids.
849
+ * @param {string} typeId Source type id.
850
+ * @returns {boolean}
851
+ */
852
+ static #isStiffenerLayerType(typeId) {
853
+ return (
854
+ PcbLayerStackInterchangeParser.#normalizeGuid(typeId) ===
855
+ '9FD889FA-C97A-401C-A066-E5F746678381'
856
+ )
857
+ }
858
+
390
859
  /**
391
860
  * Extracts XML-like tag fields.
392
861
  * @param {string} text Source text.
@@ -62,6 +62,7 @@ export class PcbLayerStackReadModelBuilder {
62
62
  layerById,
63
63
  'backdrill'
64
64
  )
65
+ const stackup = PcbLayerStackReadModelBuilder.#stackup(fields)
65
66
  const diagnostics = PcbLayerStackReadModelBuilder.#diagnostics({
66
67
  substacks,
67
68
  branches,
@@ -105,6 +106,7 @@ export class PcbLayerStackReadModelBuilder {
105
106
  branches,
106
107
  topLevelBendLines,
107
108
  cavityReport,
109
+ ...(stackup ? { stackup } : {}),
108
110
  impedanceProfiles,
109
111
  transmissionLines,
110
112
  viaSpans,
@@ -166,9 +168,9 @@ export class PcbLayerStackReadModelBuilder {
166
168
  PcbLayerStackReadModelBuilder.#stripUndefined({
167
169
  index: layer.index,
168
170
  layerId: layer.layerId,
169
- layerKey: PcbLayerStackReadModelBuilder.#layerKey(
170
- layer.layerId
171
- ),
171
+ layerKey: Number.isFinite(layer.layerId)
172
+ ? 'L' + layer.layerId
173
+ : undefined,
172
174
  name: layer.name,
173
175
  kind: layer.kind,
174
176
  material: layer.material,
@@ -189,9 +191,9 @@ export class PcbLayerStackReadModelBuilder {
189
191
  PcbLayerStackReadModelBuilder.#stripUndefined({
190
192
  index: index + 1,
191
193
  layerId: layer.layerId,
192
- layerKey: PcbLayerStackReadModelBuilder.#layerKey(
193
- layer.layerId
194
- ),
194
+ layerKey: Number.isFinite(layer.layerId)
195
+ ? 'L' + layer.layerId
196
+ : undefined,
195
197
  name: layer.name,
196
198
  kind: layer.kind || layer.role,
197
199
  ...PcbLayerStackSourceMetadataParser.layerSourceFields(
@@ -592,6 +594,81 @@ export class PcbLayerStackReadModelBuilder {
592
594
  )
593
595
  }
594
596
 
597
+ /**
598
+ * Parses document-level layer-stack metadata.
599
+ * @param {Record<string, string | string[]>} fields Board fields.
600
+ * @returns {object | undefined}
601
+ */
602
+ static #stackup(fields) {
603
+ const features = PcbLayerStackReadModelBuilder.#stackupFeatures(fields)
604
+ const stackup = PcbLayerStackReadModelBuilder.#stripUndefined({
605
+ serializerVersion: PcbLayerStackReadModelBuilder.#field(
606
+ fields,
607
+ 'STACKUPX_SERIALIZER_VERSION'
608
+ ),
609
+ revisionId: PcbLayerStackReadModelBuilder.#field(
610
+ fields,
611
+ 'STACKUPX_REVISION_ID'
612
+ ),
613
+ type: PcbLayerStackReadModelBuilder.#field(fields, 'STACKUPX_TYPE'),
614
+ roughnessType: PcbLayerStackReadModelBuilder.#field(
615
+ fields,
616
+ 'STACKUPX_ROUGHNESS_TYPE'
617
+ ),
618
+ roughnessFactorSR: PcbLayerStackReadModelBuilder.#field(
619
+ fields,
620
+ 'STACKUPX_ROUGHNESS_FACTOR_SR'
621
+ ),
622
+ roughnessFactor: PcbLayerStackReadModelBuilder.#field(
623
+ fields,
624
+ 'STACKUPX_ROUGHNESS_FACTOR'
625
+ ),
626
+ realisticRatio: PcbLayerStackReadModelBuilder.#optionalBoolean(
627
+ PcbLayerStackReadModelBuilder.#field(
628
+ fields,
629
+ 'STACKUPX_REALISTIC_RATIO'
630
+ )
631
+ ),
632
+ features: PcbLayerStackSourceMetadataParser.optionalArray(features)
633
+ })
634
+
635
+ return Object.keys(stackup).length ? stackup : undefined
636
+ }
637
+
638
+ /**
639
+ * Parses stackup feature rows.
640
+ * @param {Record<string, string | string[]>} fields Board fields.
641
+ * @returns {object[]}
642
+ */
643
+ static #stackupFeatures(fields) {
644
+ return PcbLayerStackReadModelBuilder.#indexedRows(fields, [
645
+ /^STACKUPX_FEATURE(\d+)_ID$/iu,
646
+ /^STACKUP_FEATURE(\d+)_ID$/iu
647
+ ]).map((index) =>
648
+ PcbLayerStackReadModelBuilder.#stripUndefined({
649
+ index,
650
+ id: PcbLayerStackReadModelBuilder.#indexedField(
651
+ fields,
652
+ ['STACKUPX_FEATURE', 'STACKUP_FEATURE'],
653
+ index,
654
+ ['ID']
655
+ ),
656
+ name: PcbLayerStackReadModelBuilder.#indexedField(
657
+ fields,
658
+ ['STACKUPX_FEATURE', 'STACKUP_FEATURE'],
659
+ index,
660
+ ['NAME']
661
+ ),
662
+ kind: PcbLayerStackReadModelBuilder.#indexedField(
663
+ fields,
664
+ ['STACKUPX_FEATURE', 'STACKUP_FEATURE'],
665
+ index,
666
+ ['KIND']
667
+ )
668
+ })
669
+ )
670
+ }
671
+
595
672
  /**
596
673
  * Builds preservation-first diagnostics for unresolved references.
597
674
  * @param {object} input Sidecar sections.
@@ -903,15 +980,6 @@ export class PcbLayerStackReadModelBuilder {
903
980
  return Number.isFinite(parsed) ? parsed : undefined
904
981
  }
905
982
 
906
- /**
907
- * Builds a stable layer key.
908
- * @param {number | null | undefined} layerId Layer id.
909
- * @returns {string | undefined}
910
- */
911
- static #layerKey(layerId) {
912
- return Number.isFinite(layerId) ? 'L' + layerId : undefined
913
- }
914
-
915
983
  /**
916
984
  * Removes undefined and empty string values while keeping false and empty
917
985
  * arrays stable.