altium-toolkit 1.0.10 → 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 (63) 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 +4 -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 +132 -1
  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/normalized_model_a1.schema.json +692 -2
  11. package/docs/schemas/altium_toolkit/pcb_bom_profile_a1.schema.json +48 -0
  12. package/docs/schemas/altium_toolkit/pcb_layer_stack_a1.schema.json +98 -0
  13. package/docs/schemas/altium_toolkit/pcb_layer_stack_fidelity_a1.schema.json +66 -0
  14. package/docs/schemas/altium_toolkit/pcb_placed_footprint_extraction_a1.schema.json +31 -0
  15. package/docs/schemas/altium_toolkit/pcb_review_metadata_a1.schema.json +62 -0
  16. package/docs/schemas/altium_toolkit/pcb_rigid_flex_topology_a1.schema.json +52 -0
  17. package/docs/schemas/altium_toolkit/pcblib_parity_a1.schema.json +24 -0
  18. package/docs/schemas/altium_toolkit/project_bom_pnp_reconciliation_a1.schema.json +63 -0
  19. package/docs/schemas/altium_toolkit/project_outjob_digest_a1.schema.json +46 -0
  20. package/docs/schemas/altium_toolkit/project_script_a1.schema.json +50 -0
  21. package/docs/schemas/altium_toolkit/schematic_render_ops_a1.schema.json +55 -0
  22. package/docs/schemas/altium_toolkit/schematic_template_extraction_a1.schema.json +37 -0
  23. package/package.json +1 -1
  24. package/src/core/altium/AltiumParser.mjs +7 -2
  25. package/src/core/altium/CiArtifactBundleBuilder.mjs +16 -5
  26. package/src/core/altium/ContractGateReportBuilder.mjs +351 -0
  27. package/src/core/altium/DraftsmanBoardViewMetadataBuilder.mjs +653 -0
  28. package/src/core/altium/DraftsmanDigestParser.mjs +246 -7
  29. package/src/core/altium/DraftsmanImagePayloadManifestBuilder.mjs +178 -0
  30. package/src/core/altium/HostCapabilityDiagnosticsBuilder.mjs +271 -0
  31. package/src/core/altium/LibraryQaReportBuilder.mjs +504 -0
  32. package/src/core/altium/LibraryRenderManifestBuilder.mjs +172 -2
  33. package/src/core/altium/PcbBomProfileBuilder.mjs +263 -0
  34. package/src/core/altium/PcbComponentKindPolicy.mjs +146 -0
  35. package/src/core/altium/PcbLayerStackFidelityReportBuilder.mjs +141 -0
  36. package/src/core/altium/PcbLayerStackInterchangeParser.mjs +453 -0
  37. package/src/core/altium/PcbLayerStackQueryHelper.mjs +195 -0
  38. package/src/core/altium/PcbLayerStackReadModelBuilder.mjs +906 -0
  39. package/src/core/altium/PcbLayerStackSourceMetadataParser.mjs +488 -0
  40. package/src/core/altium/PcbLibModelParser.mjs +2 -0
  41. package/src/core/altium/PcbLibParityReportBuilder.mjs +242 -0
  42. package/src/core/altium/PcbModelParser.mjs +182 -18
  43. package/src/core/altium/PcbPickPlacePositionResolver.mjs +3 -0
  44. package/src/core/altium/PcbPlacedFootprintManifestBuilder.mjs +338 -0
  45. package/src/core/altium/PcbPolygonRecordParser.mjs +120 -0
  46. package/src/core/altium/PcbReviewDrillMetadataBuilder.mjs +301 -0
  47. package/src/core/altium/PcbReviewMetadataBuilder.mjs +373 -0
  48. package/src/core/altium/PcbReviewPolygonRealizationBuilder.mjs +269 -0
  49. package/src/core/altium/PcbReviewRouteHighlightProfileBuilder.mjs +298 -0
  50. package/src/core/altium/PcbRigidFlexTopologyBuilder.mjs +171 -0
  51. package/src/core/altium/PrintableTextDecoder.mjs +70 -6
  52. package/src/core/altium/PrjPcbModelParser.mjs +45 -0
  53. package/src/core/altium/PrjScrModelParser.mjs +386 -0
  54. package/src/core/altium/ProjectBomPnpReconciliationBuilder.mjs +237 -0
  55. package/src/core/altium/ProjectDesignBundleBuilder.mjs +61 -2
  56. package/src/core/altium/ProjectOutJobDigestBuilder.mjs +424 -13
  57. package/src/core/altium/SvgModelCrossLinkValidator.mjs +35 -2
  58. package/src/core/circuit-json/CircuitJsonModelAdapter.mjs +164 -0
  59. package/src/parser.mjs +15 -0
  60. package/src/ui/PcbFootprintPrimitiveSelector.mjs +13 -1
  61. package/src/ui/PcbScene3dBuilder.mjs +26 -4
  62. package/src/ui/SchematicRenderOpsSidecarBuilder.mjs +554 -0
  63. package/src/ui/SchematicSvgRenderer.mjs +48 -2
@@ -0,0 +1,453 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { AltiumLayoutParser } from './AltiumLayoutParser.mjs'
6
+ import { PcbBoardRegionSemanticsParser } from './PcbBoardRegionSemanticsParser.mjs'
7
+ import { PcbLayerStackReadModelBuilder } from './PcbLayerStackReadModelBuilder.mjs'
8
+
9
+ /**
10
+ * Parses read-only layer-stack interchange text into the PCB stack sidecar.
11
+ */
12
+ export class PcbLayerStackInterchangeParser {
13
+ /**
14
+ * Parses a UTF-8 layer-stack interchange buffer.
15
+ * @param {string} fileName Source file name.
16
+ * @param {ArrayBuffer} arrayBuffer Source bytes.
17
+ * @returns {object}
18
+ */
19
+ static parseArrayBuffer(fileName, arrayBuffer) {
20
+ return PcbLayerStackInterchangeParser.parseText(
21
+ fileName,
22
+ new TextDecoder().decode(arrayBuffer)
23
+ )
24
+ }
25
+
26
+ /**
27
+ * Parses layer-stack interchange text.
28
+ * @param {string} fileName Source file name.
29
+ * @param {string} text Source text.
30
+ * @returns {object}
31
+ */
32
+ static parseText(fileName, text) {
33
+ const format = PcbLayerStackInterchangeParser.#format(fileName, text)
34
+ const fields =
35
+ format === 'stackupx'
36
+ ? PcbLayerStackInterchangeParser.#stackupxFields(text)
37
+ : PcbLayerStackInterchangeParser.#stackupFields(text)
38
+ const layers = AltiumLayoutParser.parseLayerStack(fields)
39
+ const layerSubstacks =
40
+ PcbBoardRegionSemanticsParser.parseLayerSubstacks([fields])
41
+ const model = PcbLayerStackReadModelBuilder.build({
42
+ fileName,
43
+ boardRecords: [{ fields }],
44
+ streamNames: [],
45
+ layers,
46
+ primitiveLayers: [],
47
+ layerSubstacks,
48
+ boardRegions: []
49
+ })
50
+
51
+ return {
52
+ ...model,
53
+ source: {
54
+ ...model.source,
55
+ interchangeFormat: format
56
+ }
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Detects the interchange format.
62
+ * @param {string} fileName Source file name.
63
+ * @param {string} text Source text.
64
+ * @returns {'stackup' | 'stackupx'}
65
+ */
66
+ static #format(fileName, text) {
67
+ const lowerName = String(fileName || '').toLowerCase()
68
+ if (lowerName.endsWith('.stackupx')) return 'stackupx'
69
+ if (
70
+ String(text || '')
71
+ .trimStart()
72
+ .startsWith('<')
73
+ )
74
+ return 'stackupx'
75
+ return 'stackup'
76
+ }
77
+
78
+ /**
79
+ * Converts simple sectioned stackup text into board fields.
80
+ * @param {string} text Source text.
81
+ * @returns {Record<string, string>}
82
+ */
83
+ static #stackupFields(text) {
84
+ const fields = {}
85
+ let section = ''
86
+
87
+ for (const rawLine of String(text || '').split(/\r?\n/u)) {
88
+ const line = rawLine.trim()
89
+ if (!line || line.startsWith('#')) continue
90
+ const sectionMatch = /^\[([^\]]+)\]$/u.exec(line)
91
+ if (sectionMatch) {
92
+ section = sectionMatch[1]
93
+ continue
94
+ }
95
+
96
+ const separator = line.indexOf('=')
97
+ if (separator < 0 || !section) continue
98
+ PcbLayerStackInterchangeParser.#assignSectionField(
99
+ fields,
100
+ section,
101
+ line.slice(0, separator).trim(),
102
+ line.slice(separator + 1).trim()
103
+ )
104
+ }
105
+
106
+ return fields
107
+ }
108
+
109
+ /**
110
+ * Converts XML-like stackup text into board fields.
111
+ * @param {string} text Source XML text.
112
+ * @returns {Record<string, string>}
113
+ */
114
+ static #stackupxFields(text) {
115
+ const fields = {}
116
+
117
+ for (const layer of PcbLayerStackInterchangeParser.#tagFields(text, [
118
+ 'Layer'
119
+ ])) {
120
+ const index = Number.parseInt(layer.Index || layer.index || '0', 10)
121
+ PcbLayerStackInterchangeParser.#assignLayerFields(
122
+ fields,
123
+ index,
124
+ layer
125
+ )
126
+ }
127
+ for (const substack of PcbLayerStackInterchangeParser.#tagFields(text, [
128
+ 'Substack'
129
+ ])) {
130
+ const index = Number.parseInt(
131
+ substack.Index || substack.index || '0',
132
+ 10
133
+ )
134
+ PcbLayerStackInterchangeParser.#assignSubstackFields(
135
+ fields,
136
+ index,
137
+ substack
138
+ )
139
+ }
140
+ for (const branch of PcbLayerStackInterchangeParser.#tagBlocks(text, [
141
+ 'Branch'
142
+ ])) {
143
+ const index = Number.parseInt(
144
+ branch.fields.Index || branch.fields.index || '0',
145
+ 10
146
+ )
147
+ PcbLayerStackInterchangeParser.#assignBranchFields(
148
+ fields,
149
+ index,
150
+ branch.fields,
151
+ branch.body
152
+ )
153
+ }
154
+ for (const span of PcbLayerStackInterchangeParser.#tagFields(text, [
155
+ 'ViaSpan',
156
+ 'BackdrillSpan'
157
+ ])) {
158
+ const index = Number.parseInt(span.Index || span.index || '0', 10)
159
+ const prefix =
160
+ span.__tagName === 'ViaSpan' ? 'VIASPAN' : 'BACKDRILLSPAN'
161
+ PcbLayerStackInterchangeParser.#assignIndexedFields(
162
+ fields,
163
+ prefix,
164
+ index,
165
+ span
166
+ )
167
+ }
168
+
169
+ return fields
170
+ }
171
+
172
+ /**
173
+ * Assigns one sectioned text field.
174
+ * @param {Record<string, string>} fields Target fields.
175
+ * @param {string} section Section name.
176
+ * @param {string} key Key.
177
+ * @param {string} value Value.
178
+ * @returns {void}
179
+ */
180
+ static #assignSectionField(fields, section, key, value) {
181
+ const match =
182
+ /^(Layer|Substack|Branch|ImpedanceProfile|TransmissionLine|ViaSpan|BackdrillSpan)(\d+)$/u.exec(
183
+ section
184
+ )
185
+ if (!match) return
186
+
187
+ const [, family, indexText] = match
188
+ const index = Number.parseInt(indexText, 10)
189
+ if (family === 'Layer') {
190
+ PcbLayerStackInterchangeParser.#assignLayerField(
191
+ fields,
192
+ index,
193
+ key,
194
+ value
195
+ )
196
+ return
197
+ }
198
+ if (family === 'Substack') {
199
+ PcbLayerStackInterchangeParser.#assignSubstackField(
200
+ fields,
201
+ index,
202
+ key,
203
+ value
204
+ )
205
+ return
206
+ }
207
+
208
+ const prefix =
209
+ family === 'ImpedanceProfile'
210
+ ? 'IMPEDANCEPROFILE'
211
+ : family.toUpperCase()
212
+ fields[prefix + index + '_' + key.toUpperCase()] = value
213
+ }
214
+
215
+ /**
216
+ * Assigns XML layer fields.
217
+ * @param {Record<string, string>} fields Target fields.
218
+ * @param {number} index Layer index.
219
+ * @param {Record<string, string>} values Source values.
220
+ * @returns {void}
221
+ */
222
+ static #assignLayerFields(fields, index, values) {
223
+ for (const [key, value] of Object.entries(values)) {
224
+ PcbLayerStackInterchangeParser.#assignLayerField(
225
+ fields,
226
+ index,
227
+ key,
228
+ value
229
+ )
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Assigns one layer field.
235
+ * @param {Record<string, string>} fields Target fields.
236
+ * @param {number} index Layer index.
237
+ * @param {string} key Source key.
238
+ * @param {string} value Source value.
239
+ * @returns {void}
240
+ */
241
+ static #assignLayerField(fields, index, key, value) {
242
+ const suffixByKey = {
243
+ Name: 'NAME',
244
+ LayerId: 'LAYERID',
245
+ Kind: 'KIND',
246
+ Material: 'MATERIAL',
247
+ Thickness: 'THICKNESS',
248
+ CopperThickness: 'COPPERTHICKNESS',
249
+ Dk: 'DK',
250
+ Df: 'DF',
251
+ IsAdhesive: 'ISADHESIVE',
252
+ IsStiffener: 'ISSTIFFENER',
253
+ SurfaceFinish: 'SURFACEFINISH',
254
+ SourceRecordId: 'SOURCE_RECORD_ID',
255
+ SourceKeys: 'SOURCE_KEYS',
256
+ StackupxProperties: 'STACKUPX_PROPERTIES'
257
+ }
258
+ const suffix = suffixByKey[key]
259
+ if (!suffix) return
260
+ fields['V9_STACK_LAYER' + index + '_' + suffix] = value
261
+ }
262
+
263
+ /**
264
+ * Assigns XML substack fields.
265
+ * @param {Record<string, string>} fields Target fields.
266
+ * @param {number} index Substack index.
267
+ * @param {Record<string, string>} values Source values.
268
+ * @returns {void}
269
+ */
270
+ static #assignSubstackFields(fields, index, values) {
271
+ for (const [key, value] of Object.entries(values)) {
272
+ PcbLayerStackInterchangeParser.#assignSubstackField(
273
+ fields,
274
+ index,
275
+ key,
276
+ value
277
+ )
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Assigns one substack field.
283
+ * @param {Record<string, string>} fields Target fields.
284
+ * @param {number} index Substack index.
285
+ * @param {string} key Source key.
286
+ * @param {string} value Source value.
287
+ * @returns {void}
288
+ */
289
+ static #assignSubstackField(fields, index, key, value) {
290
+ const suffixByKey = {
291
+ Id: 'ID',
292
+ Name: 'NAME',
293
+ IsFlex: 'ISFLEX',
294
+ Layers: 'LAYERS',
295
+ StackType: 'STACKUPX_STACKTYPE'
296
+ }
297
+ const suffix = suffixByKey[key]
298
+ if (!suffix) return
299
+ fields['V9_SUBSTACK' + index + '_' + suffix] = value
300
+ }
301
+
302
+ /**
303
+ * Assigns one branch and nested section/stack fields.
304
+ * @param {Record<string, string>} fields Target fields.
305
+ * @param {number} index Branch index.
306
+ * @param {Record<string, string>} values Branch fields.
307
+ * @param {string} body Branch body.
308
+ * @returns {void}
309
+ */
310
+ static #assignBranchFields(fields, index, values, body) {
311
+ fields['STACKBRANCH' + index + '_ID'] = values.Id || values.ID || ''
312
+ fields['STACKBRANCH' + index + '_NAME'] = values.Name || ''
313
+
314
+ for (const section of PcbLayerStackInterchangeParser.#tagBlocks(body, [
315
+ 'Section'
316
+ ])) {
317
+ const sectionIndex = Number.parseInt(
318
+ section.fields.Index || '0',
319
+ 10
320
+ )
321
+ const sectionPrefix =
322
+ 'STACKBRANCH' + index + '_SECTION' + sectionIndex
323
+ fields[sectionPrefix + '_ID'] = section.fields.Id || ''
324
+ fields[sectionPrefix + '_NAME'] = section.fields.Name || ''
325
+ fields[sectionPrefix + '_PARENTID'] = section.fields.ParentId || ''
326
+
327
+ for (const stack of PcbLayerStackInterchangeParser.#tagFields(
328
+ section.body,
329
+ ['Stack']
330
+ )) {
331
+ const stackIndex = Number.parseInt(stack.Index || '0', 10)
332
+ const stackPrefix = sectionPrefix + '_STACK' + stackIndex
333
+ PcbLayerStackInterchangeParser.#assignStackFields(
334
+ fields,
335
+ stackPrefix,
336
+ stack
337
+ )
338
+ }
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Assigns branch stack fields.
344
+ * @param {Record<string, string>} fields Target fields.
345
+ * @param {string} prefix Field prefix.
346
+ * @param {Record<string, string>} stack Stack fields.
347
+ * @returns {void}
348
+ */
349
+ static #assignStackFields(fields, prefix, stack) {
350
+ const keyMap = {
351
+ Ref: 'REF',
352
+ MaterialUsage: 'MATERIALUSAGE',
353
+ Source: 'SOURCE',
354
+ IntrusionLeftBottom: 'INTRUSIONLEFTBOTTOM',
355
+ IntrusionLeftTop: 'INTRUSIONLEFTTOP',
356
+ IntrusionRightBottom: 'INTRUSIONRIGHTBOTTOM',
357
+ IntrusionRightTop: 'INTRUSIONRIGHTTOP'
358
+ }
359
+
360
+ for (const [sourceKey, suffix] of Object.entries(keyMap)) {
361
+ if (stack[sourceKey])
362
+ fields[prefix + '_' + suffix] = stack[sourceKey]
363
+ }
364
+ }
365
+
366
+ /**
367
+ * Assigns indexed span fields.
368
+ * @param {Record<string, string>} fields Target fields.
369
+ * @param {string} prefix Field prefix.
370
+ * @param {number} index Row index.
371
+ * @param {Record<string, string>} values Source values.
372
+ * @returns {void}
373
+ */
374
+ static #assignIndexedFields(fields, prefix, index, values) {
375
+ const keyMap = {
376
+ Id: 'ID',
377
+ Name: 'NAME',
378
+ StartLayer: 'STARTLAYER',
379
+ EndLayer: 'ENDLAYER',
380
+ TargetStub: 'TARGETSTUB'
381
+ }
382
+
383
+ for (const [sourceKey, suffix] of Object.entries(keyMap)) {
384
+ if (values[sourceKey]) {
385
+ fields[prefix + index + '_' + suffix] = values[sourceKey]
386
+ }
387
+ }
388
+ }
389
+
390
+ /**
391
+ * Extracts XML-like tag fields.
392
+ * @param {string} text Source text.
393
+ * @param {string[]} tagNames Tag names.
394
+ * @returns {Record<string, string>[]}
395
+ */
396
+ static #tagFields(text, tagNames) {
397
+ return PcbLayerStackInterchangeParser.#tagBlocks(text, tagNames).map(
398
+ (block) => ({
399
+ __tagName: block.tagName,
400
+ ...block.fields
401
+ })
402
+ )
403
+ }
404
+
405
+ /**
406
+ * Extracts XML-like tag blocks.
407
+ * @param {string} text Source text.
408
+ * @param {string[]} tagNames Tag names.
409
+ * @returns {{ tagName: string, fields: Record<string, string>, body: string }[]}
410
+ */
411
+ static #tagBlocks(text, tagNames) {
412
+ const names = tagNames
413
+ .map((tagName) => tagName.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'))
414
+ .join('|')
415
+ const pattern = new RegExp(
416
+ '<\\s*(' +
417
+ names +
418
+ ')\\b([^>]*)>([\\s\\S]*?)<\\/\\s*\\1\\s*>|<\\s*(' +
419
+ names +
420
+ ')\\b([^>]*)\\/>',
421
+ 'giu'
422
+ )
423
+ const blocks = []
424
+ let match = pattern.exec(text || '')
425
+ while (match) {
426
+ blocks.push({
427
+ tagName: match[1] || match[4],
428
+ fields: PcbLayerStackInterchangeParser.#attributes(
429
+ match[2] || match[5] || ''
430
+ ),
431
+ body: match[3] || ''
432
+ })
433
+ match = pattern.exec(text || '')
434
+ }
435
+ return blocks
436
+ }
437
+
438
+ /**
439
+ * Parses XML-like attributes.
440
+ * @param {string} text Attribute text.
441
+ * @returns {Record<string, string>}
442
+ */
443
+ static #attributes(text) {
444
+ const fields = {}
445
+ const pattern = /([A-Za-z0-9_.:-]+)\s*=\s*("([^"]*)"|'([^']*)')/gu
446
+ let match = pattern.exec(text || '')
447
+ while (match) {
448
+ fields[match[1]] = match[3] ?? match[4] ?? ''
449
+ match = pattern.exec(text || '')
450
+ }
451
+ return fields
452
+ }
453
+ }
@@ -0,0 +1,195 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Provides read-only lookup helpers for normalized PCB layer-stack models.
7
+ */
8
+ export class PcbLayerStackQueryHelper {
9
+ /**
10
+ * Finds a substack by native source ref, accepting refs with or without
11
+ * braces and case differences.
12
+ * @param {object} readModel Layer-stack read model.
13
+ * @param {string} sourceRef Native stack reference.
14
+ * @returns {object | null}
15
+ */
16
+ static substackBySourceRef(readModel, sourceRef) {
17
+ const normalizedRef = PcbLayerStackQueryHelper.#normalizeRef(sourceRef)
18
+ if (!normalizedRef) return null
19
+
20
+ return (
21
+ (readModel?.substacks || []).find((substack) =>
22
+ PcbLayerStackQueryHelper.#substackRefs(substack).some(
23
+ (candidate) =>
24
+ PcbLayerStackQueryHelper.#normalizeRef(candidate) ===
25
+ normalizedRef
26
+ )
27
+ ) || null
28
+ )
29
+ }
30
+
31
+ /**
32
+ * Resolves physical layers that belong to one substack.
33
+ * @param {object} readModel Layer-stack read model.
34
+ * @param {object | string} substackOrRef Substack object or source ref.
35
+ * @returns {object[]}
36
+ */
37
+ static layersForSubstack(readModel, substackOrRef) {
38
+ const substack =
39
+ typeof substackOrRef === 'string'
40
+ ? PcbLayerStackQueryHelper.substackBySourceRef(
41
+ readModel,
42
+ substackOrRef
43
+ )
44
+ : substackOrRef
45
+ if (!substack) return []
46
+
47
+ const layersById = new Map(
48
+ (readModel?.layers || [])
49
+ .filter((layer) => Number.isFinite(layer.layerId))
50
+ .map((layer) => [layer.layerId, layer])
51
+ )
52
+ const layersByKey = new Map(
53
+ (readModel?.layers || [])
54
+ .filter((layer) => layer.layerKey)
55
+ .map((layer) => [layer.layerKey, layer])
56
+ )
57
+ const layerIds = Array.isArray(substack.layerIds)
58
+ ? substack.layerIds
59
+ : []
60
+ const layerKeys = Array.isArray(substack.layerKeys)
61
+ ? substack.layerKeys
62
+ : []
63
+
64
+ return [
65
+ ...layerIds.map((layerId) => layersById.get(layerId)),
66
+ ...layerKeys.map((layerKey) => layersByKey.get(layerKey))
67
+ ].filter(Boolean)
68
+ }
69
+
70
+ /**
71
+ * Finds board-region rows attached to one layer-stack id.
72
+ * @param {object} readModel Layer-stack read model.
73
+ * @param {string} layerStackId Native stack id.
74
+ * @returns {object[]}
75
+ */
76
+ static boardRegionsForLayerStackId(readModel, layerStackId) {
77
+ const normalizedRef =
78
+ PcbLayerStackQueryHelper.#normalizeRef(layerStackId)
79
+ if (!normalizedRef) return []
80
+
81
+ const explicitRegions = PcbLayerStackQueryHelper.#boardRegions(
82
+ readModel
83
+ ).filter(
84
+ (region) =>
85
+ PcbLayerStackQueryHelper.#normalizeRef(region.layerStackId) ===
86
+ normalizedRef
87
+ )
88
+ if (explicitRegions.length) return explicitRegions
89
+
90
+ const substack = PcbLayerStackQueryHelper.substackBySourceRef(
91
+ readModel,
92
+ layerStackId
93
+ )
94
+ if (!substack) return []
95
+
96
+ return (substack.boardRegionIndexes || []).map((index, rowIndex) => ({
97
+ index,
98
+ name: substack.boardRegionNames?.[rowIndex] || '',
99
+ layerStackId: substack.id
100
+ }))
101
+ }
102
+
103
+ /**
104
+ * Resolves physical layers for a board-region row.
105
+ * @param {object} readModel Layer-stack read model.
106
+ * @param {object | string} regionOrStackRef Region object or stack ref.
107
+ * @returns {object[]}
108
+ */
109
+ static layersForBoardRegion(readModel, regionOrStackRef) {
110
+ const stackRef =
111
+ typeof regionOrStackRef === 'string'
112
+ ? regionOrStackRef
113
+ : regionOrStackRef?.layerStackId
114
+ const substack = PcbLayerStackQueryHelper.substackBySourceRef(
115
+ readModel,
116
+ stackRef
117
+ )
118
+
119
+ return PcbLayerStackQueryHelper.layersForSubstack(readModel, substack)
120
+ }
121
+
122
+ /**
123
+ * Finds branch rows that reference one stack.
124
+ * @param {object} readModel Layer-stack read model.
125
+ * @param {string} stackRef Native stack reference.
126
+ * @returns {object[]}
127
+ */
128
+ static branchesForStackRef(readModel, stackRef) {
129
+ const normalizedRef = PcbLayerStackQueryHelper.#normalizeRef(stackRef)
130
+ if (!normalizedRef) return []
131
+
132
+ return (readModel?.branches || []).filter((branch) =>
133
+ PcbLayerStackQueryHelper.#branchRefs(branch).some(
134
+ (candidate) =>
135
+ PcbLayerStackQueryHelper.#normalizeRef(candidate) ===
136
+ normalizedRef
137
+ )
138
+ )
139
+ }
140
+
141
+ /**
142
+ * Returns candidate refs for a substack row.
143
+ * @param {object} substack Substack row.
144
+ * @returns {string[]}
145
+ */
146
+ static #substackRefs(substack) {
147
+ return [
148
+ substack?.id,
149
+ substack?.sourceStackupRef,
150
+ substack?.sourceRef,
151
+ substack?.stackRef
152
+ ].filter(Boolean)
153
+ }
154
+
155
+ /**
156
+ * Returns candidate refs for a branch row.
157
+ * @param {object} branch Branch row.
158
+ * @returns {string[]}
159
+ */
160
+ static #branchRefs(branch) {
161
+ return [
162
+ branch?.rootStackRef,
163
+ ...(branch?.stackRefs || []),
164
+ ...(branch?.sections || []).flatMap((section) =>
165
+ (section.stacks || []).map((stack) => stack.stackRef)
166
+ )
167
+ ].filter(Boolean)
168
+ }
169
+
170
+ /**
171
+ * Returns explicit board-region rows from known read-model locations.
172
+ * @param {object} readModel Layer-stack read model.
173
+ * @returns {object[]}
174
+ */
175
+ static #boardRegions(readModel) {
176
+ return [
177
+ ...(readModel?.boardRegions || []),
178
+ ...(readModel?.regions || []),
179
+ ...(readModel?.cavityReport?.cavityRegions || [])
180
+ ]
181
+ }
182
+
183
+ /**
184
+ * Normalizes native ids and refs for lookup.
185
+ * @param {unknown} value Raw ref.
186
+ * @returns {string}
187
+ */
188
+ static #normalizeRef(value) {
189
+ return String(value || '')
190
+ .trim()
191
+ .replace(/^\{|\}$/gu, '')
192
+ .toLowerCase()
193
+ .replace(/[^a-z0-9]+/gu, '')
194
+ }
195
+ }