altium-toolkit 1.0.9 → 1.1.0

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 (79) hide show
  1. package/docs/api.md +6 -2
  2. package/docs/model-format.md +29 -4
  3. package/docs/schemas/altium_toolkit/ci_artifact_bundle_a1.schema.json +80 -0
  4. package/docs/schemas/altium_toolkit/contract_gate_a1.schema.json +34 -0
  5. package/docs/schemas/altium_toolkit/draftsman_board_view_cache_a1.schema.json +115 -0
  6. package/docs/schemas/altium_toolkit/draftsman_digest_a1.schema.json +166 -0
  7. package/docs/schemas/altium_toolkit/host_capabilities_a1.schema.json +39 -0
  8. package/docs/schemas/altium_toolkit/library_merge_plan_a1.schema.json +56 -0
  9. package/docs/schemas/altium_toolkit/library_qa_a1.schema.json +70 -0
  10. package/docs/schemas/altium_toolkit/netlist_a1.schema.json +6 -0
  11. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +856 -7
  12. package/docs/schemas/altium_toolkit/parser_compatibility_fuzz_a1.schema.json +25 -0
  13. package/docs/schemas/altium_toolkit/pcb_bom_profile_a1.schema.json +48 -0
  14. package/docs/schemas/altium_toolkit/pcb_layer_stack_a1.schema.json +98 -0
  15. package/docs/schemas/altium_toolkit/pcb_layer_stack_fidelity_a1.schema.json +66 -0
  16. package/docs/schemas/altium_toolkit/pcb_placed_footprint_extraction_a1.schema.json +31 -0
  17. package/docs/schemas/altium_toolkit/pcb_review_metadata_a1.schema.json +62 -0
  18. package/docs/schemas/altium_toolkit/pcb_rigid_flex_topology_a1.schema.json +52 -0
  19. package/docs/schemas/altium_toolkit/pcb_svg_semantics_a1.schema.json +27 -0
  20. package/docs/schemas/altium_toolkit/pcblib_parity_a1.schema.json +24 -0
  21. package/docs/schemas/altium_toolkit/project_bom_pnp_reconciliation_a1.schema.json +63 -0
  22. package/docs/schemas/altium_toolkit/project_bundle_a1.schema.json +6 -0
  23. package/docs/schemas/altium_toolkit/project_document_graph_a1.schema.json +33 -0
  24. package/docs/schemas/altium_toolkit/project_outjob_digest_a1.schema.json +46 -0
  25. package/docs/schemas/altium_toolkit/project_script_a1.schema.json +50 -0
  26. package/docs/schemas/altium_toolkit/schematic_render_ops_a1.schema.json +55 -0
  27. package/docs/schemas/altium_toolkit/schematic_template_extraction_a1.schema.json +37 -0
  28. package/docs/schemas/altium_toolkit/svg_model_cross_link_a1.schema.json +39 -0
  29. package/package.json +1 -1
  30. package/src/core/altium/AltiumParser.mjs +12 -2
  31. package/src/core/altium/CiArtifactBundleBuilder.mjs +213 -0
  32. package/src/core/altium/ContractGateReportBuilder.mjs +351 -0
  33. package/src/core/altium/DraftsmanBoardViewMetadataBuilder.mjs +653 -0
  34. package/src/core/altium/DraftsmanDigestParser.mjs +928 -0
  35. package/src/core/altium/DraftsmanImagePayloadManifestBuilder.mjs +178 -0
  36. package/src/core/altium/HostCapabilityDiagnosticsBuilder.mjs +271 -0
  37. package/src/core/altium/LibraryQaReportBuilder.mjs +504 -0
  38. package/src/core/altium/LibraryRenderManifestBuilder.mjs +172 -2
  39. package/src/core/altium/ParserCompatibilityFuzzer.mjs +192 -0
  40. package/src/core/altium/PcbBomProfileBuilder.mjs +263 -0
  41. package/src/core/altium/PcbComponentKindPolicy.mjs +146 -0
  42. package/src/core/altium/PcbLayerStackFidelityReportBuilder.mjs +141 -0
  43. package/src/core/altium/PcbLayerStackInterchangeParser.mjs +453 -0
  44. package/src/core/altium/PcbLayerStackQueryHelper.mjs +195 -0
  45. package/src/core/altium/PcbLayerStackReadModelBuilder.mjs +906 -0
  46. package/src/core/altium/PcbLayerStackSourceMetadataParser.mjs +488 -0
  47. package/src/core/altium/PcbLibModelParser.mjs +2 -0
  48. package/src/core/altium/PcbLibParityReportBuilder.mjs +242 -0
  49. package/src/core/altium/PcbModelParser.mjs +211 -22
  50. package/src/core/altium/PcbPadStackParser.mjs +171 -2
  51. package/src/core/altium/PcbPickPlacePositionResolver.mjs +11 -1
  52. package/src/core/altium/PcbPlacedFootprintManifestBuilder.mjs +338 -0
  53. package/src/core/altium/PcbPolygonRecordParser.mjs +120 -0
  54. package/src/core/altium/PcbRegionPrimitiveParser.mjs +71 -2
  55. package/src/core/altium/PcbReviewDrillMetadataBuilder.mjs +301 -0
  56. package/src/core/altium/PcbReviewMetadataBuilder.mjs +373 -0
  57. package/src/core/altium/PcbReviewPolygonRealizationBuilder.mjs +269 -0
  58. package/src/core/altium/PcbReviewRouteHighlightProfileBuilder.mjs +298 -0
  59. package/src/core/altium/PcbRigidFlexTopologyBuilder.mjs +171 -0
  60. package/src/core/altium/PcbRouteAnalysisBuilder.mjs +730 -0
  61. package/src/core/altium/PcbStatisticsBuilder.mjs +9 -0
  62. package/src/core/altium/PrintableTextDecoder.mjs +70 -6
  63. package/src/core/altium/PrjPcbModelParser.mjs +69 -2
  64. package/src/core/altium/PrjScrModelParser.mjs +386 -0
  65. package/src/core/altium/ProjectBomPnpReconciliationBuilder.mjs +237 -0
  66. package/src/core/altium/ProjectDesignBundleBuilder.mjs +76 -2
  67. package/src/core/altium/ProjectDocumentGraphBuilder.mjs +280 -0
  68. package/src/core/altium/ProjectNetlistExporter.mjs +5 -1
  69. package/src/core/altium/ProjectOutJobDigestBuilder.mjs +424 -13
  70. package/src/core/altium/SvgModelCrossLinkValidator.mjs +435 -0
  71. package/src/core/circuit-json/CircuitJsonModelAdapter.mjs +300 -96
  72. package/src/core/circuit-json/CircuitJsonModelAdapterPcbElements.mjs +244 -0
  73. package/src/core/circuit-json/CircuitJsonModelSchema.mjs +1 -1
  74. package/src/parser.mjs +21 -0
  75. package/src/ui/PcbFootprintPrimitiveSelector.mjs +13 -1
  76. package/src/ui/PcbScene3dBuilder.mjs +26 -4
  77. package/src/ui/PcbSvgRenderer.mjs +65 -0
  78. package/src/ui/SchematicRenderOpsSidecarBuilder.mjs +554 -0
  79. package/src/ui/SchematicSvgRenderer.mjs +48 -2
@@ -0,0 +1,906 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { ParserUtils } from './ParserUtils.mjs'
6
+ import { PcbLayerStackFidelityReportBuilder } from './PcbLayerStackFidelityReportBuilder.mjs'
7
+ import { PcbLayerStackSourceMetadataParser } from './PcbLayerStackSourceMetadataParser.mjs'
8
+
9
+ const { parseNumericField } = ParserUtils
10
+
11
+ /**
12
+ * Builds a source-aware PCB layer-stack read model from decoded board records.
13
+ */
14
+ export class PcbLayerStackReadModelBuilder {
15
+ static SCHEMA_ID = 'altium-toolkit.pcb.layer-stack.a1'
16
+
17
+ /**
18
+ * Builds the layer-stack sidecar.
19
+ * @param {{ fileName: string, boardRecords: { fields: Record<string, string | string[]>, sourceStream?: string }[], streamNames?: string[], layers: object[], primitiveLayers: object[], layerSubstacks: object[], boardRegions: object[] }} input Source model context.
20
+ * @returns {object | undefined}
21
+ */
22
+ static build(input) {
23
+ const fields = PcbLayerStackReadModelBuilder.#mergeFields(
24
+ input.boardRecords || []
25
+ )
26
+ const layers = PcbLayerStackReadModelBuilder.#layers(
27
+ input.layers || [],
28
+ input.primitiveLayers || [],
29
+ fields
30
+ )
31
+ const layerById = new Map(
32
+ layers
33
+ .filter((layer) => Number.isFinite(layer.layerId))
34
+ .map((layer) => [layer.layerId, layer])
35
+ )
36
+ const substacks = PcbLayerStackReadModelBuilder.#substacks(
37
+ input.layerSubstacks || [],
38
+ fields,
39
+ layerById,
40
+ input.boardRegions || []
41
+ )
42
+ const branches = PcbLayerStackReadModelBuilder.#branches(fields)
43
+ const topLevelBendLines =
44
+ PcbLayerStackSourceMetadataParser.topLevelBendLines(fields)
45
+ const cavityReport = PcbLayerStackSourceMetadataParser.cavityReport(
46
+ layers,
47
+ input.boardRegions || []
48
+ )
49
+ const impedanceProfiles =
50
+ PcbLayerStackReadModelBuilder.#impedanceProfiles(fields)
51
+ const transmissionLines =
52
+ PcbLayerStackReadModelBuilder.#transmissionLines(fields, layerById)
53
+ const viaSpans = PcbLayerStackReadModelBuilder.#layerSpans(
54
+ fields,
55
+ layerById,
56
+ 'via'
57
+ )
58
+ const backdrillSpans = PcbLayerStackReadModelBuilder.#layerSpans(
59
+ fields,
60
+ layerById,
61
+ 'backdrill'
62
+ )
63
+ const diagnostics = PcbLayerStackReadModelBuilder.#diagnostics({
64
+ substacks,
65
+ branches,
66
+ impedanceProfiles,
67
+ transmissionLines,
68
+ viaSpans,
69
+ backdrillSpans,
70
+ layerById
71
+ })
72
+ const summary = {
73
+ layerCount: layers.length,
74
+ substackCount: substacks.length,
75
+ boardRegionCount: (input.boardRegions || []).length,
76
+ branchCount: branches.length,
77
+ impedanceProfileCount: impedanceProfiles.length,
78
+ transmissionLineCount: transmissionLines.length,
79
+ viaSpanCount: viaSpans.length,
80
+ backdrillSpanCount: backdrillSpans.length,
81
+ topLevelBendLineCount: topLevelBendLines.length,
82
+ cavityRegionCount: cavityReport.cavityRegionCount,
83
+ stiffenerLayerCount: cavityReport.stiffenerLayerCount,
84
+ adhesiveLayerCount: cavityReport.adhesiveLayerCount,
85
+ diagnosticCount: diagnostics.length
86
+ }
87
+
88
+ if (!Object.values(summary).some((value) => value > 0)) {
89
+ return undefined
90
+ }
91
+
92
+ const readModel = {
93
+ schema: PcbLayerStackReadModelBuilder.SCHEMA_ID,
94
+ summary,
95
+ source: PcbLayerStackReadModelBuilder.#source(input),
96
+ sourceMap: PcbLayerStackSourceMetadataParser.sourceMap(
97
+ layers,
98
+ topLevelBendLines,
99
+ cavityReport
100
+ ),
101
+ layers,
102
+ substacks,
103
+ branches,
104
+ topLevelBendLines,
105
+ cavityReport,
106
+ impedanceProfiles,
107
+ transmissionLines,
108
+ viaSpans,
109
+ backdrillSpans,
110
+ diagnostics
111
+ }
112
+ readModel.fidelityReport =
113
+ PcbLayerStackFidelityReportBuilder.build(readModel)
114
+
115
+ return readModel
116
+ }
117
+
118
+ /**
119
+ * Merges board-record field maps for indexed sidecar scans.
120
+ * @param {{ fields: Record<string, string | string[]> }[]} records Board records.
121
+ * @returns {Record<string, string | string[]>}
122
+ */
123
+ static #mergeFields(records) {
124
+ return Object.assign(
125
+ {},
126
+ ...records.map((record) => record.fields || {})
127
+ )
128
+ }
129
+
130
+ /**
131
+ * Builds source provenance for the sidecar.
132
+ * @param {{ fileName: string, boardRecords: { sourceStream?: string }[], streamNames?: string[], boardRegions?: object[] }} input Source model context.
133
+ * @returns {object}
134
+ */
135
+ static #source(input) {
136
+ const nativeStreams = [
137
+ ...new Set(
138
+ (input.boardRecords || [])
139
+ .map((record) => record.sourceStream)
140
+ .filter(Boolean)
141
+ )
142
+ ]
143
+
144
+ return {
145
+ fileName: input.fileName,
146
+ nativeStreams,
147
+ hasNativeBoardData: nativeStreams.includes('Board6/Data'),
148
+ hasBoardRegionsData:
149
+ (input.streamNames || []).includes('BoardRegions/Data') ||
150
+ (input.boardRegions || []).length > 0
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Normalizes stack layers and primitive-layer fallbacks.
156
+ * @param {object[]} layers Parsed physical layers.
157
+ * @param {object[]} primitiveLayers Primitive layer map.
158
+ * @param {Record<string, string | string[]>} fields Source fields.
159
+ * @returns {object[]}
160
+ */
161
+ static #layers(layers, primitiveLayers, fields) {
162
+ if (layers.length) {
163
+ return layers.map((layer) =>
164
+ PcbLayerStackReadModelBuilder.#stripUndefined({
165
+ index: layer.index,
166
+ layerId: layer.layerId,
167
+ layerKey: PcbLayerStackReadModelBuilder.#layerKey(
168
+ layer.layerId
169
+ ),
170
+ name: layer.name,
171
+ kind: layer.kind,
172
+ material: layer.material,
173
+ thicknessMil: layer.thicknessMil,
174
+ copperThicknessMil: layer.copperThicknessMil,
175
+ copperWeight: layer.copperWeight,
176
+ dielectricConstant: layer.dielectricConstant,
177
+ dissipationFactor: layer.dissipationFactor,
178
+ ...PcbLayerStackSourceMetadataParser.layerSourceFields(
179
+ fields,
180
+ layer.index
181
+ )
182
+ })
183
+ )
184
+ }
185
+
186
+ return (primitiveLayers || []).map((layer, index) =>
187
+ PcbLayerStackReadModelBuilder.#stripUndefined({
188
+ index: index + 1,
189
+ layerId: layer.layerId,
190
+ layerKey: PcbLayerStackReadModelBuilder.#layerKey(
191
+ layer.layerId
192
+ ),
193
+ name: layer.name,
194
+ kind: layer.kind || layer.role,
195
+ ...PcbLayerStackSourceMetadataParser.layerSourceFields(
196
+ fields,
197
+ index + 1
198
+ )
199
+ })
200
+ )
201
+ }
202
+
203
+ /**
204
+ * Normalizes substacks and links them to board-region rows.
205
+ * @param {object[]} layerSubstacks Parsed substacks.
206
+ * @param {Record<string, string | string[]>} fields Board fields.
207
+ * @param {Map<number, object>} layerById Layer lookup.
208
+ * @param {object[]} boardRegions Board regions.
209
+ * @returns {object[]}
210
+ */
211
+ static #substacks(layerSubstacks, fields, layerById, boardRegions) {
212
+ return layerSubstacks.map((substack) => {
213
+ const layerIds = PcbLayerStackReadModelBuilder.#layerIdList(
214
+ PcbLayerStackReadModelBuilder.#indexedField(
215
+ fields,
216
+ ['V9_SUBSTACK', 'SUBSTACK', 'LAYERSUBSTACK_V8_'],
217
+ substack.index,
218
+ ['LAYERS', 'LAYERIDS', 'LAYER_IDS', 'STACKLAYERS']
219
+ )
220
+ )
221
+ const regions = boardRegions
222
+ .map((region, regionIndex) => ({ region, regionIndex }))
223
+ .filter(
224
+ ({ region }) =>
225
+ region.layerStackId &&
226
+ region.layerStackId === substack.id
227
+ )
228
+
229
+ return PcbLayerStackReadModelBuilder.#stripUndefined({
230
+ index: substack.index,
231
+ id: substack.id,
232
+ name: substack.name,
233
+ isFlex: substack.isFlex,
234
+ layerIds,
235
+ layerKeys: layerIds
236
+ .map((layerId) => layerById.get(layerId)?.layerKey)
237
+ .filter(Boolean),
238
+ boardRegionIndexes: regions.map(
239
+ ({ regionIndex }) => regionIndex
240
+ ),
241
+ boardRegionNames: regions
242
+ .map(({ region }) => region.name)
243
+ .filter(Boolean),
244
+ bendingLineCount: regions.reduce(
245
+ (count, { region }) =>
246
+ count + (region.bendingLineCount || 0),
247
+ 0
248
+ )
249
+ })
250
+ })
251
+ }
252
+
253
+ /**
254
+ * Parses layer-stack branch rows.
255
+ * @param {Record<string, string | string[]>} fields Board fields.
256
+ * @returns {object[]}
257
+ */
258
+ static #branches(fields) {
259
+ return PcbLayerStackReadModelBuilder.#indexedRows(fields, [
260
+ /^STACKBRANCH(\d+)_ID$/iu,
261
+ /^V9_STACKBRANCH(\d+)_ID$/iu
262
+ ]).map((index) =>
263
+ PcbLayerStackReadModelBuilder.#stripUndefined({
264
+ index,
265
+ id: PcbLayerStackReadModelBuilder.#indexedField(
266
+ fields,
267
+ ['STACKBRANCH', 'V9_STACKBRANCH'],
268
+ index,
269
+ ['ID']
270
+ ),
271
+ name: PcbLayerStackReadModelBuilder.#indexedField(
272
+ fields,
273
+ ['STACKBRANCH', 'V9_STACKBRANCH'],
274
+ index,
275
+ ['NAME']
276
+ ),
277
+ rootStackRef: PcbLayerStackReadModelBuilder.#indexedField(
278
+ fields,
279
+ ['STACKBRANCH', 'V9_STACKBRANCH'],
280
+ index,
281
+ ['ROOTSTACKREF', 'ROOT_STACK_REF', 'PARENTSTACKREF']
282
+ ),
283
+ stackRefs: PcbLayerStackReadModelBuilder.#list(
284
+ PcbLayerStackReadModelBuilder.#indexedField(
285
+ fields,
286
+ ['STACKBRANCH', 'V9_STACKBRANCH'],
287
+ index,
288
+ ['STACKREFS', 'STACK_REFS', 'SECTIONSTACKREFS']
289
+ )
290
+ ),
291
+ description: PcbLayerStackReadModelBuilder.#indexedField(
292
+ fields,
293
+ ['STACKBRANCH', 'V9_STACKBRANCH'],
294
+ index,
295
+ ['DESCRIPTION']
296
+ ),
297
+ parentBranchId: PcbLayerStackReadModelBuilder.#indexedField(
298
+ fields,
299
+ ['STACKBRANCH', 'V9_STACKBRANCH'],
300
+ index,
301
+ ['PARENTBRANCHID', 'PARENT_BRANCH_ID']
302
+ ),
303
+ sections: PcbLayerStackSourceMetadataParser.optionalArray(
304
+ PcbLayerStackSourceMetadataParser.branchSections(
305
+ fields,
306
+ index
307
+ )
308
+ )
309
+ })
310
+ )
311
+ }
312
+
313
+ /**
314
+ * Parses impedance profile rows.
315
+ * @param {Record<string, string | string[]>} fields Board fields.
316
+ * @returns {object[]}
317
+ */
318
+ static #impedanceProfiles(fields) {
319
+ return PcbLayerStackReadModelBuilder.#indexedRows(fields, [
320
+ /^IMPEDANCEPROFILE(\d+)_ID$/iu,
321
+ /^V9_IMPEDANCEPROFILE(\d+)_ID$/iu
322
+ ]).map((index) =>
323
+ PcbLayerStackReadModelBuilder.#stripUndefined({
324
+ index,
325
+ id: PcbLayerStackReadModelBuilder.#indexedField(
326
+ fields,
327
+ ['IMPEDANCEPROFILE', 'V9_IMPEDANCEPROFILE'],
328
+ index,
329
+ ['ID']
330
+ ),
331
+ name: PcbLayerStackReadModelBuilder.#indexedField(
332
+ fields,
333
+ ['IMPEDANCEPROFILE', 'V9_IMPEDANCEPROFILE'],
334
+ index,
335
+ ['NAME']
336
+ ),
337
+ targetImpedanceOhm: PcbLayerStackReadModelBuilder.#number(
338
+ fields,
339
+ ['IMPEDANCEPROFILE', 'V9_IMPEDANCEPROFILE'],
340
+ index,
341
+ ['TARGETIMPEDANCE', 'TARGET_IMPEDANCE', 'IMPEDANCE']
342
+ ),
343
+ kind: PcbLayerStackReadModelBuilder.#indexedField(
344
+ fields,
345
+ ['IMPEDANCEPROFILE', 'V9_IMPEDANCEPROFILE'],
346
+ index,
347
+ ['KIND', 'TYPE']
348
+ ),
349
+ profileTypeRaw: PcbLayerStackReadModelBuilder.#number(
350
+ fields,
351
+ ['IMPEDANCEPROFILE', 'V9_IMPEDANCEPROFILE'],
352
+ index,
353
+ ['TYPERAW', 'TYPE_RAW', 'PROFILETYPERAW']
354
+ ),
355
+ tolerance: PcbLayerStackReadModelBuilder.#indexedField(
356
+ fields,
357
+ ['IMPEDANCEPROFILE', 'V9_IMPEDANCEPROFILE'],
358
+ index,
359
+ ['TOLERANCE']
360
+ ),
361
+ transmissionLineCount: PcbLayerStackReadModelBuilder.#number(
362
+ fields,
363
+ ['IMPEDANCEPROFILE', 'V9_IMPEDANCEPROFILE'],
364
+ index,
365
+ ['TRANSMISSIONLINECOUNT', 'TRANSMISSION_LINE_COUNT']
366
+ )
367
+ })
368
+ )
369
+ }
370
+
371
+ /**
372
+ * Parses transmission-line rows tied to impedance profiles.
373
+ * @param {Record<string, string | string[]>} fields Board fields.
374
+ * @param {Map<number, object>} layerById Layer lookup.
375
+ * @returns {object[]}
376
+ */
377
+ static #transmissionLines(fields, layerById) {
378
+ return PcbLayerStackReadModelBuilder.#indexedRows(fields, [
379
+ /^TRANSMISSIONLINE(\d+)_ID$/iu,
380
+ /^V9_TRANSMISSIONLINE(\d+)_ID$/iu
381
+ ]).map((index) => {
382
+ const layerId = PcbLayerStackReadModelBuilder.#number(
383
+ fields,
384
+ ['TRANSMISSIONLINE', 'V9_TRANSMISSIONLINE'],
385
+ index,
386
+ ['LAYERID', 'LAYER_ID', 'SIGNALLAYERID']
387
+ )
388
+ const referenceLayerId = PcbLayerStackReadModelBuilder.#number(
389
+ fields,
390
+ ['TRANSMISSIONLINE', 'V9_TRANSMISSIONLINE'],
391
+ index,
392
+ [
393
+ 'REFERENCE_LAYERID',
394
+ 'REFERENCELAYERID',
395
+ 'REFERENCE_LAYER_ID',
396
+ 'REFERENCELAYER'
397
+ ]
398
+ )
399
+
400
+ return PcbLayerStackReadModelBuilder.#stripUndefined({
401
+ index,
402
+ id: PcbLayerStackReadModelBuilder.#indexedField(
403
+ fields,
404
+ ['TRANSMISSIONLINE', 'V9_TRANSMISSIONLINE'],
405
+ index,
406
+ ['ID']
407
+ ),
408
+ name: PcbLayerStackReadModelBuilder.#indexedField(
409
+ fields,
410
+ ['TRANSMISSIONLINE', 'V9_TRANSMISSIONLINE'],
411
+ index,
412
+ ['NAME']
413
+ ),
414
+ profileId: PcbLayerStackReadModelBuilder.#indexedField(
415
+ fields,
416
+ ['TRANSMISSIONLINE', 'V9_TRANSMISSIONLINE'],
417
+ index,
418
+ ['PROFILEID', 'PROFILE_ID', 'IMPEDANCEPROFILEID']
419
+ ),
420
+ substackId: PcbLayerStackReadModelBuilder.#indexedField(
421
+ fields,
422
+ ['TRANSMISSIONLINE', 'V9_TRANSMISSIONLINE'],
423
+ index,
424
+ ['SUBSTACKID', 'SUBSTACK_ID']
425
+ ),
426
+ layerId,
427
+ layerKey: layerById.get(layerId)?.layerKey,
428
+ referenceLayerId,
429
+ referenceLayerKey: layerById.get(referenceLayerId)?.layerKey,
430
+ topRefId: PcbLayerStackReadModelBuilder.#indexedField(
431
+ fields,
432
+ ['TRANSMISSIONLINE', 'V9_TRANSMISSIONLINE'],
433
+ index,
434
+ ['TOPREFID', 'TOP_REF_ID']
435
+ ),
436
+ bottomRefId: PcbLayerStackReadModelBuilder.#indexedField(
437
+ fields,
438
+ ['TRANSMISSIONLINE', 'V9_TRANSMISSIONLINE'],
439
+ index,
440
+ ['BOTTOMREFID', 'BOTTOM_REF_ID']
441
+ ),
442
+ widthMil: PcbLayerStackReadModelBuilder.#number(
443
+ fields,
444
+ ['TRANSMISSIONLINE', 'V9_TRANSMISSIONLINE'],
445
+ index,
446
+ ['WIDTH', 'TRACEWIDTH']
447
+ ),
448
+ gapMil: PcbLayerStackReadModelBuilder.#number(
449
+ fields,
450
+ ['TRANSMISSIONLINE', 'V9_TRANSMISSIONLINE'],
451
+ index,
452
+ ['GAP', 'PAIRGAP']
453
+ ),
454
+ isDifferential: PcbLayerStackReadModelBuilder.#optionalBoolean(
455
+ PcbLayerStackReadModelBuilder.#indexedField(
456
+ fields,
457
+ ['TRANSMISSIONLINE', 'V9_TRANSMISSIONLINE'],
458
+ index,
459
+ ['ISDIFFERENTIAL', 'IS_DIFFERENTIAL']
460
+ )
461
+ ),
462
+ calcMode: PcbLayerStackReadModelBuilder.#indexedField(
463
+ fields,
464
+ ['TRANSMISSIONLINE', 'V9_TRANSMISSIONLINE'],
465
+ index,
466
+ ['CALCMODE', 'CALC_MODE']
467
+ ),
468
+ calcModeRaw: PcbLayerStackReadModelBuilder.#number(
469
+ fields,
470
+ ['TRANSMISSIONLINE', 'V9_TRANSMISSIONLINE'],
471
+ index,
472
+ ['CALCMODERAW', 'CALC_MODE_RAW']
473
+ ),
474
+ impedanceError: PcbLayerStackReadModelBuilder.#indexedField(
475
+ fields,
476
+ ['TRANSMISSIONLINE', 'V9_TRANSMISSIONLINE'],
477
+ index,
478
+ ['IMPEDANCEERROR', 'IMPEDANCE_ERROR']
479
+ ),
480
+ tlTypeName: PcbLayerStackReadModelBuilder.#indexedField(
481
+ fields,
482
+ ['TRANSMISSIONLINE', 'V9_TRANSMISSIONLINE'],
483
+ index,
484
+ ['TLTYPENAME', 'TL_TYPE_NAME']
485
+ ),
486
+ hasPlating: PcbLayerStackReadModelBuilder.#optionalBoolean(
487
+ PcbLayerStackReadModelBuilder.#indexedField(
488
+ fields,
489
+ ['TRANSMISSIONLINE', 'V9_TRANSMISSIONLINE'],
490
+ index,
491
+ ['HASPLATING', 'HAS_PLATING']
492
+ )
493
+ ),
494
+ useSolderMask: PcbLayerStackReadModelBuilder.#optionalBoolean(
495
+ PcbLayerStackReadModelBuilder.#indexedField(
496
+ fields,
497
+ ['TRANSMISSIONLINE', 'V9_TRANSMISSIONLINE'],
498
+ index,
499
+ ['USESOLDERMASK', 'USE_SOLDER_MASK']
500
+ )
501
+ ),
502
+ coatedHeight1: PcbLayerStackReadModelBuilder.#indexedField(
503
+ fields,
504
+ ['TRANSMISSIONLINE', 'V9_TRANSMISSIONLINE'],
505
+ index,
506
+ ['COATEDHEIGHT1', 'COATED_HEIGHT_1']
507
+ ),
508
+ coatedHeight2: PcbLayerStackReadModelBuilder.#indexedField(
509
+ fields,
510
+ ['TRANSMISSIONLINE', 'V9_TRANSMISSIONLINE'],
511
+ index,
512
+ ['COATEDHEIGHT2', 'COATED_HEIGHT_2']
513
+ ),
514
+ clearanceToPlane: PcbLayerStackReadModelBuilder.#indexedField(
515
+ fields,
516
+ ['TRANSMISSIONLINE', 'V9_TRANSMISSIONLINE'],
517
+ index,
518
+ ['CLEARANCETOPLANE', 'CLEARANCE_TO_PLANE']
519
+ ),
520
+ electricParameters: PcbLayerStackReadModelBuilder.#keyValueMap(
521
+ PcbLayerStackReadModelBuilder.#indexedField(
522
+ fields,
523
+ ['TRANSMISSIONLINE', 'V9_TRANSMISSIONLINE'],
524
+ index,
525
+ ['ELECTRICPARAMETERS', 'ELECTRIC_PARAMETERS']
526
+ )
527
+ )
528
+ })
529
+ })
530
+ }
531
+
532
+ /**
533
+ * Parses via/backdrill span rows.
534
+ * @param {Record<string, string | string[]>} fields Board fields.
535
+ * @param {Map<number, object>} layerById Layer lookup.
536
+ * @param {'via' | 'backdrill'} kind Span kind.
537
+ * @returns {object[]}
538
+ */
539
+ static #layerSpans(fields, layerById, kind) {
540
+ const prefixes =
541
+ kind === 'via'
542
+ ? ['VIASPAN', 'V9_VIASPAN']
543
+ : ['BACKDRILLSPAN', 'V9_BACKDRILLSPAN']
544
+ const patterns =
545
+ kind === 'via'
546
+ ? [/^VIASPAN(\d+)_ID$/iu, /^V9_VIASPAN(\d+)_ID$/iu]
547
+ : [/^BACKDRILLSPAN(\d+)_ID$/iu, /^V9_BACKDRILLSPAN(\d+)_ID$/iu]
548
+
549
+ return PcbLayerStackReadModelBuilder.#indexedRows(fields, patterns).map(
550
+ (index) => {
551
+ const startLayerId = PcbLayerStackReadModelBuilder.#number(
552
+ fields,
553
+ prefixes,
554
+ index,
555
+ ['STARTLAYER', 'STARTLAYERID', 'START_LAYERID', 'FROMLAYER']
556
+ )
557
+ const endLayerId = PcbLayerStackReadModelBuilder.#number(
558
+ fields,
559
+ prefixes,
560
+ index,
561
+ ['ENDLAYER', 'ENDLAYERID', 'END_LAYERID', 'TOLAYER']
562
+ )
563
+
564
+ return PcbLayerStackReadModelBuilder.#stripUndefined({
565
+ index,
566
+ id: PcbLayerStackReadModelBuilder.#indexedField(
567
+ fields,
568
+ prefixes,
569
+ index,
570
+ ['ID']
571
+ ),
572
+ name: PcbLayerStackReadModelBuilder.#indexedField(
573
+ fields,
574
+ prefixes,
575
+ index,
576
+ ['NAME']
577
+ ),
578
+ startLayerId,
579
+ startLayerKey: layerById.get(startLayerId)?.layerKey,
580
+ endLayerId,
581
+ endLayerKey: layerById.get(endLayerId)?.layerKey,
582
+ targetStubMil: PcbLayerStackReadModelBuilder.#number(
583
+ fields,
584
+ prefixes,
585
+ index,
586
+ ['TARGETSTUB', 'TARGET_STUB', 'MAXSTUB']
587
+ )
588
+ })
589
+ }
590
+ )
591
+ }
592
+
593
+ /**
594
+ * Builds preservation-first diagnostics for unresolved references.
595
+ * @param {object} input Sidecar sections.
596
+ * @returns {object[]}
597
+ */
598
+ static #diagnostics(input) {
599
+ const diagnostics = []
600
+ const stackIds = new Set(
601
+ input.substacks.map((substack) => substack.id).filter(Boolean)
602
+ )
603
+ const profileIds = new Set(
604
+ input.impedanceProfiles.map((profile) => profile.id).filter(Boolean)
605
+ )
606
+
607
+ for (const branch of input.branches) {
608
+ for (const stackRef of branch.stackRefs || []) {
609
+ if (stackIds.has(stackRef)) continue
610
+ diagnostics.push(
611
+ PcbLayerStackReadModelBuilder.#diagnostic(
612
+ 'pcb.layer-stack.unresolved-branch-substack',
613
+ 'Layer-stack branch references an unknown substack.',
614
+ { branchId: branch.id, stackRef }
615
+ )
616
+ )
617
+ }
618
+ }
619
+
620
+ for (const line of input.transmissionLines) {
621
+ if (line.profileId && !profileIds.has(line.profileId)) {
622
+ diagnostics.push(
623
+ PcbLayerStackReadModelBuilder.#diagnostic(
624
+ 'pcb.layer-stack.unresolved-impedance-profile',
625
+ 'Transmission-line metadata references an unknown impedance profile.',
626
+ {
627
+ transmissionLineId: line.id,
628
+ profileId: line.profileId
629
+ }
630
+ )
631
+ )
632
+ }
633
+ PcbLayerStackReadModelBuilder.#layerDiagnostic(
634
+ diagnostics,
635
+ input.layerById,
636
+ line.layerId,
637
+ 'pcb.layer-stack.unresolved-transmission-layer',
638
+ { transmissionLineId: line.id, layerRole: 'signal' }
639
+ )
640
+ PcbLayerStackReadModelBuilder.#layerDiagnostic(
641
+ diagnostics,
642
+ input.layerById,
643
+ line.referenceLayerId,
644
+ 'pcb.layer-stack.unresolved-reference-layer',
645
+ { transmissionLineId: line.id, layerRole: 'reference' }
646
+ )
647
+ }
648
+
649
+ for (const span of [...input.viaSpans, ...input.backdrillSpans]) {
650
+ PcbLayerStackReadModelBuilder.#layerDiagnostic(
651
+ diagnostics,
652
+ input.layerById,
653
+ span.startLayerId,
654
+ 'pcb.layer-stack.unresolved-span-start-layer',
655
+ { spanId: span.id }
656
+ )
657
+ PcbLayerStackReadModelBuilder.#layerDiagnostic(
658
+ diagnostics,
659
+ input.layerById,
660
+ span.endLayerId,
661
+ 'pcb.layer-stack.unresolved-span-end-layer',
662
+ { spanId: span.id }
663
+ )
664
+ }
665
+
666
+ return diagnostics
667
+ }
668
+
669
+ /**
670
+ * Adds an unresolved-layer diagnostic when needed.
671
+ * @param {object[]} diagnostics Diagnostic target.
672
+ * @param {Map<number, object>} layerById Layer lookup.
673
+ * @param {number | undefined} layerId Layer id.
674
+ * @param {string} code Diagnostic code.
675
+ * @param {object} extra Extra fields.
676
+ * @returns {void}
677
+ */
678
+ static #layerDiagnostic(diagnostics, layerById, layerId, code, extra) {
679
+ if (!Number.isFinite(layerId) || layerById.has(layerId)) return
680
+
681
+ diagnostics.push(
682
+ PcbLayerStackReadModelBuilder.#diagnostic(
683
+ code,
684
+ 'Layer-stack metadata references an unknown layer.',
685
+ { ...extra, layerId }
686
+ )
687
+ )
688
+ }
689
+
690
+ /**
691
+ * Builds a structured diagnostic row.
692
+ * @param {string} code Diagnostic code.
693
+ * @param {string} message Diagnostic message.
694
+ * @param {object} extra Extra fields.
695
+ * @returns {object}
696
+ */
697
+ static #diagnostic(code, message, extra) {
698
+ return {
699
+ code,
700
+ severity: 'warning',
701
+ message,
702
+ ...extra
703
+ }
704
+ }
705
+
706
+ /**
707
+ * Finds all indexes matching any row-id pattern.
708
+ * @param {Record<string, string | string[]>} fields Source fields.
709
+ * @param {RegExp[]} patterns Index patterns.
710
+ * @returns {number[]}
711
+ */
712
+ static #indexedRows(fields, patterns) {
713
+ return [
714
+ ...new Set(
715
+ Object.keys(fields).flatMap((key) => {
716
+ for (const pattern of patterns) {
717
+ const match = pattern.exec(key)
718
+ if (match) return [Number.parseInt(match[1], 10)]
719
+ }
720
+ return []
721
+ })
722
+ )
723
+ ].sort((left, right) => left - right)
724
+ }
725
+
726
+ /**
727
+ * Finds nested indexes for fields with a common prefix and suffix.
728
+ * @param {Record<string, string | string[]>} fields Source fields.
729
+ * @param {string} prefix Field prefix before the nested index.
730
+ * @param {string} suffix Field suffix after the nested index.
731
+ * @returns {number[]}
732
+ */
733
+ static #nestedIndexes(fields, prefix, suffix) {
734
+ const pattern = new RegExp(
735
+ '^' +
736
+ prefix.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&') +
737
+ '(\\d+)' +
738
+ suffix.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&') +
739
+ '$',
740
+ 'iu'
741
+ )
742
+
743
+ return [
744
+ ...new Set(
745
+ Object.keys(fields).flatMap((key) => {
746
+ const match = pattern.exec(key)
747
+ return match ? [Number.parseInt(match[1], 10)] : []
748
+ })
749
+ )
750
+ ].sort((left, right) => left - right)
751
+ }
752
+
753
+ /**
754
+ * Reads the first matching indexed field.
755
+ * @param {Record<string, string | string[]>} fields Source fields.
756
+ * @param {string[]} prefixes Row prefixes.
757
+ * @param {number} index Row index.
758
+ * @param {string[]} suffixes Field suffixes.
759
+ * @returns {string}
760
+ */
761
+ static #indexedField(fields, prefixes, index, suffixes) {
762
+ for (const prefix of prefixes) {
763
+ for (const suffix of suffixes) {
764
+ const value = PcbLayerStackReadModelBuilder.#field(
765
+ fields,
766
+ prefix + index + '_' + suffix
767
+ )
768
+ if (value) return value
769
+ }
770
+ }
771
+
772
+ return ''
773
+ }
774
+
775
+ /**
776
+ * Parses one indexed numeric field.
777
+ * @param {Record<string, string | string[]>} fields Source fields.
778
+ * @param {string[]} prefixes Row prefixes.
779
+ * @param {number} index Row index.
780
+ * @param {string[]} suffixes Field suffixes.
781
+ * @returns {number | undefined}
782
+ */
783
+ static #number(fields, prefixes, index, suffixes) {
784
+ for (const prefix of prefixes) {
785
+ for (const suffix of suffixes) {
786
+ const key = prefix + index + '_' + suffix
787
+ const parsed = parseNumericField(fields, key)
788
+ if (parsed !== null) return parsed
789
+ }
790
+ }
791
+
792
+ return undefined
793
+ }
794
+
795
+ /**
796
+ * Reads a case-insensitive field value.
797
+ * @param {Record<string, string | string[]>} fields Source fields.
798
+ * @param {string} key Field key.
799
+ * @returns {string}
800
+ */
801
+ static #field(fields, key) {
802
+ if (Object.hasOwn(fields, key)) {
803
+ return ParserUtils.getField(fields, key)
804
+ }
805
+ const upperKey = key.toUpperCase()
806
+ const realKey = Object.keys(fields).find(
807
+ (fieldKey) => fieldKey.toUpperCase() === upperKey
808
+ )
809
+
810
+ return realKey ? ParserUtils.getField(fields, realKey) : ''
811
+ }
812
+
813
+ /**
814
+ * Parses layer-id lists.
815
+ * @param {string} value Raw list value.
816
+ * @returns {number[]}
817
+ */
818
+ static #layerIdList(value) {
819
+ return PcbLayerStackReadModelBuilder.#list(value)
820
+ .map((item) => Number.parseInt(item, 10))
821
+ .filter(Number.isFinite)
822
+ }
823
+
824
+ /**
825
+ * Splits a native list field.
826
+ * @param {string} value Raw list value.
827
+ * @returns {string[]}
828
+ */
829
+ static #list(value) {
830
+ return String(value || '')
831
+ .split(/[;,]/u)
832
+ .map((item) => item.trim())
833
+ .filter(Boolean)
834
+ }
835
+
836
+ /**
837
+ * Parses a native key-value property bag.
838
+ * @param {string} value Raw value.
839
+ * @returns {object | undefined}
840
+ */
841
+ static #keyValueMap(value) {
842
+ const entries = String(value || '')
843
+ .split(/[|;]/u)
844
+ .map((item) => item.trim())
845
+ .filter(Boolean)
846
+ .flatMap((item) => {
847
+ const separator = item.indexOf('=')
848
+ if (separator < 0) return []
849
+ return [
850
+ [
851
+ item.slice(0, separator).trim(),
852
+ item.slice(separator + 1).trim()
853
+ ]
854
+ ]
855
+ })
856
+ .filter(([key]) => key)
857
+
858
+ return entries.length ? Object.fromEntries(entries) : undefined
859
+ }
860
+
861
+ /**
862
+ * Parses an optional boolean value.
863
+ * @param {string} value Raw value.
864
+ * @returns {boolean | undefined}
865
+ */
866
+ static #optionalBoolean(value) {
867
+ const normalized = String(value || '')
868
+ .trim()
869
+ .toLowerCase()
870
+ if (!normalized) return undefined
871
+ return ['true', 't', '1', 'yes'].includes(normalized)
872
+ }
873
+
874
+ /**
875
+ * Parses a numeric token.
876
+ * @param {string | undefined} value Raw token.
877
+ * @returns {number | undefined}
878
+ */
879
+ static #numberToken(value) {
880
+ const parsed = Number.parseFloat(String(value || '').trim())
881
+ return Number.isFinite(parsed) ? parsed : undefined
882
+ }
883
+
884
+ /**
885
+ * Builds a stable layer key.
886
+ * @param {number | null | undefined} layerId Layer id.
887
+ * @returns {string | undefined}
888
+ */
889
+ static #layerKey(layerId) {
890
+ return Number.isFinite(layerId) ? 'L' + layerId : undefined
891
+ }
892
+
893
+ /**
894
+ * Removes undefined and empty string values while keeping false and empty
895
+ * arrays stable.
896
+ * @param {Record<string, unknown>} object Source object.
897
+ * @returns {object}
898
+ */
899
+ static #stripUndefined(object) {
900
+ return Object.fromEntries(
901
+ Object.entries(object).filter(
902
+ ([, value]) => value !== undefined && value !== ''
903
+ )
904
+ )
905
+ }
906
+ }