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
@@ -3,11 +3,16 @@
3
3
  // SPDX-License-Identifier: GPL-3.0-or-later
4
4
 
5
5
  import { AsciiRecordParser } from './AsciiRecordParser.mjs'
6
+ import { EmbeddedFileInventoryBuilder } from './EmbeddedFileInventoryBuilder.mjs'
6
7
  import { PcbBinaryPrimitiveParser } from './PcbBinaryPrimitiveParser.mjs'
8
+ import { PcbCustomPadShapeParser } from './PcbCustomPadShapeParser.mjs'
7
9
  import { PcbEmbeddedFontExtractor } from './PcbEmbeddedFontExtractor.mjs'
8
10
  import { PcbEmbeddedModelExtractor } from './PcbEmbeddedModelExtractor.mjs'
11
+ import { PcbExtendedPrimitiveInformationParser } from './PcbExtendedPrimitiveInformationParser.mjs'
9
12
  import { PcbPrimitiveParameterParser } from './PcbPrimitiveParameterParser.mjs'
10
13
  import { PcbRawRecordRegistry } from './PcbRawRecordRegistry.mjs'
14
+ import { PcbUnionParser } from './PcbUnionParser.mjs'
15
+ import { PcbViaStructureParser } from './PcbViaStructureParser.mjs'
11
16
  import { PcbWideStringTableParser } from './PcbWideStringTableParser.mjs'
12
17
  import { OleCompoundDocument } from '../ole/OleCompoundDocument.mjs'
13
18
  import { OleConstants } from '../ole/OleConstants.mjs'
@@ -45,7 +50,7 @@ export class PcbStreamExtractor {
45
50
  /**
46
51
  * Extracts PCB content directly from one OLE-backed PcbDoc buffer.
47
52
  * @param {ArrayBuffer} arrayBuffer
48
- * @returns {{ records: Array<{ raw: string, fields: Record<string, string | string[]>, sourceStream: string }>, streamNames: string[], binaryPrimitives: Record<string, object[]>, primitiveParameters: object, wideStrings: object, diagnostics: { printableRecordCount: number, printableStreamCount: number, binaryPrimitiveCount: number, primitiveParameterGroupCount: number, wideStringCount: number } } | null}
53
+ * @returns {{ records: Array<{ raw: string, fields: Record<string, string | string[]>, sourceStream: string }>, streamNames: string[], binaryPrimitives: Record<string, object[]>, primitiveParameters: object, wideStrings: object, viaStructures: object, extendedPrimitiveInformation: object, customPadShapes: object, unions: object, diagnostics: { printableRecordCount: number, printableStreamCount: number, binaryPrimitiveCount: number, primitiveParameterGroupCount: number, wideStringCount: number, viaStructureCount: number } } | null}
49
54
  */
50
55
  static extractFromArrayBuffer(arrayBuffer) {
51
56
  if (!PcbStreamExtractor.isCompoundDocument(arrayBuffer)) {
@@ -67,7 +72,7 @@ export class PcbStreamExtractor {
67
72
  * Extracts stream-scoped printable records and known binary primitives from
68
73
  * a stream map.
69
74
  * @param {Map<string, Uint8Array>} streams
70
- * @returns {{ records: Array<{ raw: string, fields: Record<string, string | string[]>, sourceStream: string }>, streamNames: string[], binaryPrimitives: Record<string, object[]>, primitiveParameters: object, wideStrings: object, diagnostics: { printableRecordCount: number, printableStreamCount: number, binaryPrimitiveCount: number, primitiveParameterGroupCount: number, wideStringCount: number } }}
75
+ * @returns {{ records: Array<{ raw: string, fields: Record<string, string | string[]>, sourceStream: string }>, streamNames: string[], binaryPrimitives: Record<string, object[]>, primitiveParameters: object, wideStrings: object, viaStructures: object, extendedPrimitiveInformation: object, customPadShapes: object, unions: object, diagnostics: { printableRecordCount: number, printableStreamCount: number, binaryPrimitiveCount: number, primitiveParameterGroupCount: number, wideStringCount: number, viaStructureCount: number } }}
71
76
  */
72
77
  static extractFromStreams(streams) {
73
78
  const records = []
@@ -106,7 +111,9 @@ export class PcbStreamExtractor {
106
111
  continue
107
112
  }
108
113
 
109
- records.push(...streamRecords)
114
+ for (const record of streamRecords) {
115
+ records.push(record)
116
+ }
110
117
  printableStreamNames.add(name)
111
118
  usedStreamNames.add(name)
112
119
  }
@@ -142,6 +149,15 @@ export class PcbStreamExtractor {
142
149
  const wideStrings = PcbWideStringTableParser.parse(
143
150
  streams.get('WideStrings6/Data')
144
151
  )
152
+ const viaStructures = PcbViaStructureParser.extractFromStreams(streams)
153
+ const extendedPrimitiveInformation =
154
+ PcbExtendedPrimitiveInformationParser.parse(
155
+ streams.get('ExtendedPrimitiveInformation/Data')
156
+ )
157
+ const customPadShapes = PcbCustomPadShapeParser.parse(
158
+ streams.get('CustomShapes/Data')
159
+ )
160
+ const unions = PcbUnionParser.extractFromStreams(streams)
145
161
 
146
162
  if (primitiveParameters.groups.length) {
147
163
  usedStreamNames.add('PrimitiveParameters/Data')
@@ -151,6 +167,26 @@ export class PcbStreamExtractor {
151
167
  usedStreamNames.add('WideStrings6/Data')
152
168
  }
153
169
 
170
+ for (const sourceStream of PcbStreamExtractor.#viaStructureStreamNames(
171
+ viaStructures
172
+ )) {
173
+ usedStreamNames.add(sourceStream)
174
+ }
175
+
176
+ if (extendedPrimitiveInformation.entries.length) {
177
+ usedStreamNames.add('ExtendedPrimitiveInformation/Data')
178
+ }
179
+
180
+ if (customPadShapes.entries.length) {
181
+ usedStreamNames.add('CustomShapes/Data')
182
+ }
183
+
184
+ for (const sourceStream of PcbStreamExtractor.#unionStreamNames(
185
+ unions
186
+ )) {
187
+ usedStreamNames.add(sourceStream)
188
+ }
189
+
154
190
  if (arcHeaderBytes && arcDataBytes) {
155
191
  binaryPrimitives.arcs = PcbBinaryPrimitiveParser.parseArcStream(
156
192
  arcHeaderBytes,
@@ -176,6 +212,10 @@ export class PcbStreamExtractor {
176
212
  viaHeaderBytes,
177
213
  viaDataBytes
178
214
  )
215
+ PcbViaStructureParser.attachToVias(
216
+ binaryPrimitives.vias,
217
+ viaStructures
218
+ )
179
219
  if (binaryPrimitives.vias.length) {
180
220
  usedStreamNames.add('Vias6/Data')
181
221
  }
@@ -246,6 +286,12 @@ export class PcbStreamExtractor {
246
286
  }
247
287
  }
248
288
 
289
+ PcbExtendedPrimitiveInformationParser.attachToPrimitives(
290
+ binaryPrimitives,
291
+ extendedPrimitiveInformation
292
+ )
293
+ PcbUnionParser.attachToPrimitives(binaryPrimitives, unions)
294
+
249
295
  const embeddedModels =
250
296
  PcbEmbeddedModelExtractor.extractFromStreams(streams)
251
297
  const embeddedFonts =
@@ -276,6 +322,19 @@ export class PcbStreamExtractor {
276
322
  )
277
323
  }
278
324
 
325
+ const embeddedFiles = EmbeddedFileInventoryBuilder.buildFromStreams(
326
+ streams,
327
+ {
328
+ skipStreamNames: usedStreamNames
329
+ }
330
+ )
331
+ embeddedFiles.files.forEach((file) =>
332
+ usedStreamNames.add(file.sourceStream)
333
+ )
334
+ embeddedFiles.diagnostics.forEach((diagnostic) =>
335
+ usedStreamNames.add(diagnostic.sourceStream)
336
+ )
337
+
279
338
  return {
280
339
  records,
281
340
  streamNames: [...usedStreamNames].sort((left, right) =>
@@ -284,16 +343,32 @@ export class PcbStreamExtractor {
284
343
  binaryPrimitives,
285
344
  primitiveParameters,
286
345
  wideStrings,
346
+ viaStructures,
347
+ extendedPrimitiveInformation,
348
+ customPadShapes,
349
+ unions,
287
350
  embeddedModels,
288
351
  embeddedFonts,
352
+ embeddedFiles,
289
353
  rawRecords,
290
354
  diagnostics: {
291
355
  printableRecordCount: records.length,
292
356
  printableStreamCount: printableStreamNames.size,
293
357
  embeddedFontCount: embeddedFonts.fonts.length,
358
+ embeddedFileCount: embeddedFiles.files.length,
359
+ embeddedFileIssueCount: embeddedFiles.diagnostics.length,
360
+ embeddedModelIssueCount:
361
+ embeddedModels.integrity?.issues?.length || 0,
294
362
  rawRecordCount: rawRecords.length,
295
363
  primitiveParameterGroupCount: primitiveParameters.groups.length,
296
364
  wideStringCount: wideStrings.entries.length,
365
+ viaStructureCount: viaStructures.structures.length,
366
+ viaStructureLinkCount: viaStructures.links.length,
367
+ extendedPrimitiveInformationCount:
368
+ extendedPrimitiveInformation.entries.length,
369
+ customPadShapeCount: customPadShapes.entries.length,
370
+ userUnionCount: unions.userUnions.length,
371
+ smartUnionCount: unions.smartUnions.length,
297
372
  binaryPrimitiveCount:
298
373
  binaryPrimitives.arcs.length +
299
374
  binaryPrimitives.tracks.length +
@@ -327,7 +402,39 @@ export class PcbStreamExtractor {
327
402
  */
328
403
  static #isBinarySidecarDataStream(name) {
329
404
  return (
330
- name === 'PrimitiveParameters/Data' || name === 'WideStrings6/Data'
405
+ name === 'PrimitiveParameters/Data' ||
406
+ name === 'WideStrings6/Data' ||
407
+ name === 'ViaStructures/Data' ||
408
+ name === 'ViaStructureManager/Data' ||
409
+ name === 'ExtendedPrimitiveInformation/Data' ||
410
+ name === 'CustomShapes/Data' ||
411
+ name === 'UnionNames/Data' ||
412
+ name === 'SmartUnions/Data'
331
413
  )
332
414
  }
415
+
416
+ /**
417
+ * Collects sidecar source stream names that produced via-structure data.
418
+ * @param {{ structures?: { sourceStream?: string }[], links?: { sourceStream?: string }[] }} viaStructures
419
+ * @returns {string[]}
420
+ */
421
+ static #viaStructureStreamNames(viaStructures) {
422
+ return [
423
+ ...(viaStructures.structures || []),
424
+ ...(viaStructures.links || [])
425
+ ]
426
+ .map((record) => record.sourceStream)
427
+ .filter(Boolean)
428
+ }
429
+
430
+ /**
431
+ * Collects sidecar source stream names that produced union metadata.
432
+ * @param {{ userUnions?: { sourceStream?: string }[], smartUnions?: { sourceStream?: string }[] }} unions
433
+ * @returns {string[]}
434
+ */
435
+ static #unionStreamNames(unions) {
436
+ return [...(unions.userUnions || []), ...(unions.smartUnions || [])]
437
+ .map((record) => record.sourceStream)
438
+ .filter(Boolean)
439
+ }
333
440
  }
@@ -235,10 +235,70 @@ export class PcbTextPrimitiveParser {
235
235
  if (payload.byteLength >= 133) {
236
236
  extendedFields.textboxRectJustification = payload.getUint8(132)
237
237
  }
238
+ if (fontType === 2 && payload.byteLength >= 157) {
239
+ extendedFields.barcode =
240
+ PcbTextPrimitiveParser.#parseBarcodeFields(payload)
241
+ }
238
242
 
239
243
  return extendedFields
240
244
  }
241
245
 
246
+ /**
247
+ * Parses barcode-specific fields from modern barcode text records.
248
+ * @param {DataView} payload Text payload.
249
+ * @returns {{ kind: number, kindName: string, renderMode: number, renderModeName: string, fullWidth: number, fullHeight: number, marginX: number, marginY: number, minBarWidth: number, showText: boolean, inverted: boolean }}
250
+ */
251
+ static #parseBarcodeFields(payload) {
252
+ const kind = payload.getUint8(133)
253
+ const renderMode = payload.getUint8(134)
254
+
255
+ return {
256
+ kind,
257
+ kindName: PcbTextPrimitiveParser.#barcodeKindName(kind),
258
+ renderMode,
259
+ renderModeName:
260
+ PcbTextPrimitiveParser.#barcodeRenderModeName(renderMode),
261
+ fullWidth: PcbTextPrimitiveParser.#readMil(payload, 135),
262
+ fullHeight: PcbTextPrimitiveParser.#readMil(payload, 139),
263
+ marginX: PcbTextPrimitiveParser.#readMil(payload, 143),
264
+ marginY: PcbTextPrimitiveParser.#readMil(payload, 147),
265
+ minBarWidth: PcbTextPrimitiveParser.#readMil(payload, 151),
266
+ showText: payload.getUint8(155) !== 0,
267
+ inverted: payload.getUint8(156) !== 0
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Resolves a barcode kind label.
273
+ * @param {number} kind Barcode kind id.
274
+ * @returns {string}
275
+ */
276
+ static #barcodeKindName(kind) {
277
+ return (
278
+ {
279
+ 0: 'code39',
280
+ 1: 'code128',
281
+ 2: 'ean13',
282
+ 3: 'qr'
283
+ }[Number(kind)] || 'unknown'
284
+ )
285
+ }
286
+
287
+ /**
288
+ * Resolves a barcode render-mode label.
289
+ * @param {number} mode Barcode render mode id.
290
+ * @returns {string}
291
+ */
292
+ static #barcodeRenderModeName(mode) {
293
+ return (
294
+ {
295
+ 0: 'minimum',
296
+ 1: 'fit-text',
297
+ 2: 'full-size'
298
+ }[Number(mode)] || 'unknown'
299
+ )
300
+ }
301
+
242
302
  /**
243
303
  * Decodes one fixed-length UTF-16LE field from a payload view.
244
304
  * @param {DataView} payload
@@ -0,0 +1,307 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { PcbSidecarRecordParser } from './PcbSidecarRecordParser.mjs'
6
+
7
+ /**
8
+ * Decodes PCB user-union and smart-union sidecar streams.
9
+ */
10
+ export class PcbUnionParser {
11
+ static #OBJECT_ID_TO_COLLECTION = {
12
+ 1: 'arcs',
13
+ 2: 'pads',
14
+ 3: 'vias',
15
+ 4: 'tracks',
16
+ 5: 'texts',
17
+ 6: 'fills',
18
+ 11: 'regions'
19
+ }
20
+
21
+ /**
22
+ * Extracts user and smart union records from a stream map.
23
+ * @param {Map<string, Uint8Array>} streams
24
+ * @returns {{ userUnions: object[], smartUnions: object[], byIndex: Record<string, object>, smartByIndex: Record<string, object>, membersByPrimitiveKey: Record<string, object[]> }}
25
+ */
26
+ static extractFromStreams(streams) {
27
+ const userUnions = PcbUnionParser.#parseUserUnions(
28
+ streams.get('UnionNames/Data')
29
+ )
30
+ const smartUnions = PcbUnionParser.#parseSmartUnions(
31
+ streams.get('SmartUnions/Data')
32
+ )
33
+
34
+ return PcbUnionParser.#buildLookups(userUnions, smartUnions)
35
+ }
36
+
37
+ /**
38
+ * Adds smart-union memberships to decoded primitives in place.
39
+ * @param {Record<string, object[]>} binaryPrimitives
40
+ * @param {{ smartUnions?: object[] }} unions
41
+ */
42
+ static attachToPrimitives(binaryPrimitives, unions) {
43
+ if (!binaryPrimitives || !Array.isArray(unions?.smartUnions)) {
44
+ return
45
+ }
46
+
47
+ for (const smartUnion of unions.smartUnions) {
48
+ for (const member of smartUnion.members || []) {
49
+ const collectionName =
50
+ PcbUnionParser.#OBJECT_ID_TO_COLLECTION[
51
+ member.primitiveObjectId
52
+ ]
53
+ const collection = binaryPrimitives[collectionName]
54
+ const primitive = Array.isArray(collection)
55
+ ? collection[member.primitiveIndex]
56
+ : null
57
+
58
+ if (!primitive) {
59
+ continue
60
+ }
61
+
62
+ primitive.unionMemberships = primitive.unionMemberships || []
63
+ primitive.unionMemberships.push(
64
+ PcbUnionParser.#publicMembership(smartUnion)
65
+ )
66
+ }
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Parses UnionNames/Data entries.
72
+ * @param {Uint8Array | undefined} bytes
73
+ * @returns {object[]}
74
+ */
75
+ static #parseUserUnions(bytes) {
76
+ return PcbSidecarRecordParser.parseLengthPrefixedRecords(
77
+ bytes,
78
+ 'UnionNames/Data'
79
+ )
80
+ .map((record) => PcbUnionParser.#normalizeUserUnion(record))
81
+ .filter(Boolean)
82
+ }
83
+
84
+ /**
85
+ * Parses SmartUnions/Data entries.
86
+ * @param {Uint8Array | undefined} bytes
87
+ * @returns {object[]}
88
+ */
89
+ static #parseSmartUnions(bytes) {
90
+ return PcbSidecarRecordParser.parseLengthPrefixedRecords(
91
+ bytes,
92
+ 'SmartUnions/Data'
93
+ )
94
+ .map((record) => PcbUnionParser.#normalizeSmartUnion(record))
95
+ .filter(Boolean)
96
+ }
97
+
98
+ /**
99
+ * Normalizes one user-union record.
100
+ * @param {{ fields: Record<string, string>, sourceStream: string, recordIndex: number }} record
101
+ * @returns {object}
102
+ */
103
+ static #normalizeUserUnion(record) {
104
+ const index =
105
+ PcbSidecarRecordParser.parseInteger(
106
+ PcbSidecarRecordParser.firstField(record.fields, [
107
+ 'UNIONINDEX',
108
+ 'INDEX'
109
+ ])
110
+ ) ?? record.recordIndex
111
+
112
+ return {
113
+ index,
114
+ name: PcbSidecarRecordParser.firstField(record.fields, ['NAME']),
115
+ sourceStream: record.sourceStream,
116
+ fields: record.fields
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Normalizes one smart-union record.
122
+ * @param {{ fields: Record<string, string>, sourceStream: string, recordIndex: number }} record
123
+ * @returns {object}
124
+ */
125
+ static #normalizeSmartUnion(record) {
126
+ const index =
127
+ PcbSidecarRecordParser.parseInteger(
128
+ PcbSidecarRecordParser.firstField(record.fields, [
129
+ 'UNIONINDEX',
130
+ 'SMARTUNIONINDEX',
131
+ 'INDEX'
132
+ ])
133
+ ) ?? record.recordIndex
134
+ const type = PcbUnionParser.#parseType(record.fields)
135
+
136
+ return {
137
+ index,
138
+ name: PcbSidecarRecordParser.firstField(record.fields, ['NAME']),
139
+ type,
140
+ typeName: PcbUnionParser.#smartUnionTypeName(type),
141
+ sourceStream: record.sourceStream,
142
+ members: PcbUnionParser.#parseMembers(record.fields),
143
+ fields: record.fields
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Parses a smart-union type value.
149
+ * @param {Record<string, string>} fields
150
+ * @returns {number | string}
151
+ */
152
+ static #parseType(fields) {
153
+ const value = PcbSidecarRecordParser.firstField(fields, [
154
+ 'UNIONTYPE',
155
+ 'TYPE',
156
+ 'SMARTUNIONTYPE'
157
+ ])
158
+ const parsed = PcbSidecarRecordParser.parseInteger(value)
159
+
160
+ return parsed === null ? value : parsed
161
+ }
162
+
163
+ /**
164
+ * Parses member primitive references from numbered sidecar fields.
165
+ * @param {Record<string, string>} fields
166
+ * @returns {{ primitiveObjectId: number, primitiveIndex: number }[]}
167
+ */
168
+ static #parseMembers(fields) {
169
+ const members = []
170
+ const memberNumbers = new Set()
171
+
172
+ for (const key of Object.keys(fields)) {
173
+ const match = key.match(/^PRIMITIVEOBJECTID(\d+)$/u)
174
+ if (match) {
175
+ memberNumbers.add(match[1])
176
+ }
177
+ }
178
+
179
+ for (const memberNumber of [...memberNumbers].sort(
180
+ (left, right) => Number(left) - Number(right)
181
+ )) {
182
+ const primitiveObjectId = PcbSidecarRecordParser.parseInteger(
183
+ fields['PRIMITIVEOBJECTID' + memberNumber]
184
+ )
185
+ const primitiveIndex = PcbSidecarRecordParser.parseInteger(
186
+ fields['PRIMITIVEINDEX' + memberNumber]
187
+ )
188
+
189
+ if (
190
+ Number.isInteger(primitiveObjectId) &&
191
+ Number.isInteger(primitiveIndex)
192
+ ) {
193
+ members.push({ primitiveObjectId, primitiveIndex })
194
+ }
195
+ }
196
+
197
+ if (!members.length) {
198
+ members.push(...PcbUnionParser.#parseFlatMembers(fields))
199
+ }
200
+
201
+ return members
202
+ }
203
+
204
+ /**
205
+ * Parses flat primitive member fields used by compact fake fixtures.
206
+ * @param {Record<string, string>} fields
207
+ * @returns {{ primitiveObjectId: number, primitiveIndex: number }[]}
208
+ */
209
+ static #parseFlatMembers(fields) {
210
+ const primitiveObjectId = PcbSidecarRecordParser.parseInteger(
211
+ PcbSidecarRecordParser.firstField(fields, [
212
+ 'PRIMITIVEOBJECTID',
213
+ 'OBJECTID'
214
+ ])
215
+ )
216
+ const primitiveIndexes = String(
217
+ PcbSidecarRecordParser.firstField(fields, [
218
+ 'PRIMITIVEINDEXES',
219
+ 'PRIMITIVEINDEX'
220
+ ])
221
+ )
222
+ .split(/[;,\s]+/u)
223
+ .map((value) => PcbSidecarRecordParser.parseInteger(value))
224
+ .filter(Number.isInteger)
225
+
226
+ if (!Number.isInteger(primitiveObjectId)) {
227
+ return []
228
+ }
229
+
230
+ return primitiveIndexes.map((primitiveIndex) => ({
231
+ primitiveObjectId,
232
+ primitiveIndex
233
+ }))
234
+ }
235
+
236
+ /**
237
+ * Builds union lookups.
238
+ * @param {object[]} userUnions
239
+ * @param {object[]} smartUnions
240
+ * @returns {{ userUnions: object[], smartUnions: object[], byIndex: Record<string, object>, smartByIndex: Record<string, object>, membersByPrimitiveKey: Record<string, object[]> }}
241
+ */
242
+ static #buildLookups(userUnions, smartUnions) {
243
+ const byIndex = {}
244
+ const smartByIndex = {}
245
+ const membersByPrimitiveKey = {}
246
+
247
+ for (const union of userUnions) {
248
+ byIndex[String(union.index)] = union
249
+ }
250
+
251
+ for (const smartUnion of smartUnions) {
252
+ smartByIndex[String(smartUnion.index)] = smartUnion
253
+ for (const member of smartUnion.members || []) {
254
+ const key =
255
+ member.primitiveObjectId + ':' + member.primitiveIndex
256
+ membersByPrimitiveKey[key] = membersByPrimitiveKey[key] || []
257
+ membersByPrimitiveKey[key].push(smartUnion)
258
+ }
259
+ }
260
+
261
+ return {
262
+ userUnions,
263
+ smartUnions,
264
+ byIndex,
265
+ smartByIndex,
266
+ membersByPrimitiveKey
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Returns the public membership shape attached to primitives.
272
+ * @param {object} smartUnion
273
+ * @returns {object}
274
+ */
275
+ static #publicMembership(smartUnion) {
276
+ return {
277
+ index: smartUnion.index,
278
+ name: smartUnion.name,
279
+ type: smartUnion.type,
280
+ typeName: smartUnion.typeName,
281
+ sourceStream: smartUnion.sourceStream
282
+ }
283
+ }
284
+
285
+ /**
286
+ * Maps known smart-union type ids to stable labels.
287
+ * @param {number | string} type
288
+ * @returns {string}
289
+ */
290
+ static #smartUnionTypeName(type) {
291
+ if (typeof type === 'string' && type) {
292
+ return type
293
+ }
294
+
295
+ return (
296
+ {
297
+ 1: 'drill-table',
298
+ 2: 'via-stitching',
299
+ 3: 'layer-stack-table',
300
+ 4: 'length-tuning',
301
+ 5: 'metadata-ole-object',
302
+ 6: 'via-shielding',
303
+ 9: 'rectangle'
304
+ }[Number(type)] || 'unknown'
305
+ )
306
+ }
307
+ }