altium-toolkit 1.0.7 → 1.0.9

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 (93) 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/netlist_a1.schema.json +47 -0
  5. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +1661 -104
  6. package/docs/schemas/altium_toolkit/pcb_svg_semantics_a1.schema.json +59 -0
  7. package/docs/schemas/altium_toolkit/project_bundle_a1.schema.json +57 -0
  8. package/docs/schemas/altium_toolkit/schematic_svg_semantics_a1.schema.json +50 -0
  9. package/docs/testing.md +9 -3
  10. package/package.json +1 -1
  11. package/spec/library-scope.md +7 -1
  12. package/src/core/altium/AltiumLayoutParser.mjs +104 -8
  13. package/src/core/altium/AltiumParser.mjs +191 -45
  14. package/src/core/altium/EmbeddedFileInventoryBuilder.mjs +255 -0
  15. package/src/core/altium/IntLibModelParser.mjs +240 -0
  16. package/src/core/altium/IntLibStreamExtractor.mjs +366 -0
  17. package/src/core/altium/LibraryRenderManifestBuilder.mjs +417 -0
  18. package/src/core/altium/LibrarySearchIndex.mjs +215 -0
  19. package/src/core/altium/NormalizedModelSchema.mjs +36 -0
  20. package/src/core/altium/PcbCustomPadShapeParser.mjs +244 -0
  21. package/src/core/altium/PcbDefaultsParser.mjs +171 -0
  22. package/src/core/altium/PcbDimensionParser.mjs +229 -0
  23. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +232 -6
  24. package/src/core/altium/PcbExtendedPrimitiveInformationParser.mjs +256 -0
  25. package/src/core/altium/PcbLibModelParser.mjs +235 -14
  26. package/src/core/altium/PcbLibStreamExtractor.mjs +62 -4
  27. package/src/core/altium/PcbMaskPasteResolver.mjs +354 -0
  28. package/src/core/altium/PcbMechanicalLayerPairParser.mjs +204 -0
  29. package/src/core/altium/PcbModelParser.mjs +466 -28
  30. package/src/core/altium/PcbOwnershipGraphBuilder.mjs +245 -0
  31. package/src/core/altium/PcbPadPrimitiveParser.mjs +78 -65
  32. package/src/core/altium/PcbPadStackParser.mjs +58 -0
  33. package/src/core/altium/PcbPickPlacePositionResolver.mjs +217 -0
  34. package/src/core/altium/PcbPrimitiveParameterParser.mjs +3 -2
  35. package/src/core/altium/PcbRawRecordRegistry.mjs +121 -130
  36. package/src/core/altium/PcbRegionPrimitiveParser.mjs +5 -1
  37. package/src/core/altium/PcbRuleParser.mjs +354 -33
  38. package/src/core/altium/PcbSidecarRecordParser.mjs +177 -0
  39. package/src/core/altium/PcbSpecialStringResolver.mjs +220 -0
  40. package/src/core/altium/PcbStatisticsBuilder.mjs +532 -0
  41. package/src/core/altium/PcbStreamExtractor.mjs +111 -4
  42. package/src/core/altium/PcbTextPrimitiveParser.mjs +60 -0
  43. package/src/core/altium/PcbUnionParser.mjs +307 -0
  44. package/src/core/altium/PcbViaStackParser.mjs +98 -10
  45. package/src/core/altium/PcbViaStructureParser.mjs +335 -0
  46. package/src/core/altium/PrintableTextDecoder.mjs +53 -3
  47. package/src/core/altium/PrjPcbModelParser.mjs +257 -5
  48. package/src/core/altium/ProjectAnnotationParser.mjs +205 -0
  49. package/src/core/altium/ProjectDesignBundleBuilder.mjs +477 -0
  50. package/src/core/altium/ProjectNetlistExporter.mjs +499 -0
  51. package/src/core/altium/ProjectOutJobDigestBuilder.mjs +109 -0
  52. package/src/core/altium/ProjectVariantViewBuilder.mjs +334 -0
  53. package/src/core/altium/SchematicBindingProvenanceParser.mjs +223 -0
  54. package/src/core/altium/SchematicComponentOwnerTextResolver.mjs +312 -0
  55. package/src/core/altium/SchematicComponentTextResolver.mjs +72 -19
  56. package/src/core/altium/SchematicConnectivityQaBuilder.mjs +271 -0
  57. package/src/core/altium/SchematicCrossSheetConnectorParser.mjs +140 -0
  58. package/src/core/altium/SchematicDirectiveParser.mjs +312 -0
  59. package/src/core/altium/SchematicDisplayModeCatalogParser.mjs +231 -0
  60. package/src/core/altium/SchematicHarnessParser.mjs +302 -0
  61. package/src/core/altium/SchematicImageParser.mjs +474 -3
  62. package/src/core/altium/SchematicImplementationParser.mjs +518 -0
  63. package/src/core/altium/SchematicNetlistBuilder.mjs +15 -2
  64. package/src/core/altium/SchematicOwnershipGraphParser.mjs +195 -0
  65. package/src/core/altium/SchematicPinParser.mjs +84 -1
  66. package/src/core/altium/SchematicPrimitiveParser.mjs +301 -0
  67. package/src/core/altium/SchematicProjectParameterResolver.mjs +361 -0
  68. package/src/core/altium/SchematicQaReportBuilder.mjs +284 -0
  69. package/src/core/altium/SchematicRecordTypeRegistry.mjs +137 -0
  70. package/src/core/altium/SchematicRepeatedChannelParser.mjs +229 -0
  71. package/src/core/altium/SchematicStreamExtractor.mjs +10 -1
  72. package/src/core/altium/SchematicTemplateParser.mjs +256 -0
  73. package/src/core/altium/SchematicTextParser.mjs +123 -0
  74. package/src/core/ole/OleCompoundDocument.mjs +20 -0
  75. package/src/parser.mjs +29 -0
  76. package/src/renderers.mjs +3 -0
  77. package/src/styles/altium-renderers.css +25 -0
  78. package/src/ui/PcbBarcodeTextRenderer.mjs +436 -0
  79. package/src/ui/PcbInteractionGeometry.mjs +350 -0
  80. package/src/ui/PcbInteractionIndex.mjs +593 -0
  81. package/src/ui/PcbInteractionItemRegistry.mjs +66 -0
  82. package/src/ui/PcbInteractionLayerModel.mjs +99 -0
  83. package/src/ui/PcbScene3dBoardOutlineRefiner.mjs +74 -9
  84. package/src/ui/PcbScene3dBuilder.mjs +169 -7
  85. package/src/ui/PcbScene3dModelRegistry.mjs +74 -0
  86. package/src/ui/PcbSvgRenderer.mjs +1187 -34
  87. package/src/ui/PcbTextPrimitiveRenderer.mjs +193 -7
  88. package/src/ui/SchematicNoteRenderer.mjs +9 -2
  89. package/src/ui/SchematicOwnerPinLabelLayout.mjs +206 -0
  90. package/src/ui/SchematicShapeRenderer.mjs +362 -0
  91. package/src/ui/SchematicSvgRenderer.mjs +1442 -92
  92. package/src/ui/SchematicTypography.mjs +48 -5
  93. package/src/ui/TextGeometrySidecarBuilder.mjs +147 -0
@@ -0,0 +1,354 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Resolves effective paste/solder mask expansion from primitive, sidecar,
7
+ * rule, and document-default sources.
8
+ */
9
+ export class PcbMaskPasteResolver {
10
+ static SCHEMA_ID = 'altium-toolkit.pcb.mask-paste.a1'
11
+
12
+ /**
13
+ * Builds and attaches effective mask/paste metadata.
14
+ * @param {{ pads?: object[], vias?: object[], rules?: object[], defaults?: object | null }} context Resolver context.
15
+ * @returns {object}
16
+ */
17
+ static build(context) {
18
+ const pads = Array.isArray(context?.pads) ? context.pads : []
19
+ const vias = Array.isArray(context?.vias) ? context.vias : []
20
+ const defaults = context?.defaults || null
21
+ const rules = Array.isArray(context?.rules) ? context.rules : []
22
+ const primitiveRows = [
23
+ ...PcbMaskPasteResolver.#resolveFamily(
24
+ 'pad',
25
+ pads,
26
+ defaults,
27
+ rules
28
+ ),
29
+ ...PcbMaskPasteResolver.#resolveFamily('via', vias, defaults, rules)
30
+ ]
31
+ const summary = PcbMaskPasteResolver.#summarize(primitiveRows)
32
+
33
+ return {
34
+ schema: PcbMaskPasteResolver.SCHEMA_ID,
35
+ summary,
36
+ defaults: defaults?.maskPaste || {},
37
+ rules: PcbMaskPasteResolver.#maskRules(rules),
38
+ primitives: primitiveRows
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Resolves one primitive family and attaches primitive metadata.
44
+ * @param {'pad' | 'via'} primitiveKind Primitive kind.
45
+ * @param {object[]} primitives Family primitives.
46
+ * @param {object | null} defaults Board defaults.
47
+ * @param {object[]} rules Parsed rule rows.
48
+ * @returns {object[]}
49
+ */
50
+ static #resolveFamily(primitiveKind, primitives, defaults, rules) {
51
+ return primitives.flatMap((primitive, index) => {
52
+ const primitiveIndex = Number.isInteger(primitive.primitiveIndex)
53
+ ? primitive.primitiveIndex
54
+ : index
55
+ const effectiveMaskPaste = {
56
+ paste: PcbMaskPasteResolver.#resolveSide(
57
+ primitive,
58
+ 'paste',
59
+ defaults,
60
+ rules
61
+ ),
62
+ solder: PcbMaskPasteResolver.#resolveSide(
63
+ primitive,
64
+ 'solder',
65
+ defaults,
66
+ rules
67
+ )
68
+ }
69
+
70
+ if (
71
+ effectiveMaskPaste.paste.source === 'unresolved' &&
72
+ effectiveMaskPaste.solder.source === 'unresolved'
73
+ ) {
74
+ return []
75
+ }
76
+
77
+ primitive.effectiveMaskPaste = effectiveMaskPaste
78
+
79
+ return [
80
+ {
81
+ primitiveKey: primitiveKind + '-' + String(primitiveIndex),
82
+ primitiveKind,
83
+ primitiveIndex,
84
+ ...effectiveMaskPaste
85
+ }
86
+ ]
87
+ })
88
+ }
89
+
90
+ /**
91
+ * Resolves one mask side with source precedence.
92
+ * @param {object} primitive Primitive row.
93
+ * @param {'paste' | 'solder'} side Mask side.
94
+ * @param {object | null} defaults Board defaults.
95
+ * @param {object[]} rules Parsed rules.
96
+ * @returns {object}
97
+ */
98
+ static #resolveSide(primitive, side, defaults, rules) {
99
+ const sidecar = PcbMaskPasteResolver.#sidecarValue(primitive, side)
100
+ if (sidecar) return sidecar
101
+
102
+ const primitiveValue = PcbMaskPasteResolver.#primitiveValue(
103
+ primitive,
104
+ side
105
+ )
106
+ if (primitiveValue) return primitiveValue
107
+
108
+ const ruleValue = PcbMaskPasteResolver.#ruleValue(rules, side)
109
+ if (ruleValue) return ruleValue
110
+
111
+ const defaultExpansion = PcbMaskPasteResolver.#defaultExpansion(
112
+ defaults,
113
+ side
114
+ )
115
+ if (defaultExpansion !== null) {
116
+ return {
117
+ source: 'document-default',
118
+ expansionMil: defaultExpansion,
119
+ unit: 'mil'
120
+ }
121
+ }
122
+
123
+ return {
124
+ source: 'unresolved',
125
+ expansionMil: null,
126
+ unit: 'mil'
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Returns a manual sidecar value when present.
132
+ * @param {object} primitive Primitive row.
133
+ * @param {'paste' | 'solder'} side Mask side.
134
+ * @returns {object | null}
135
+ */
136
+ static #sidecarValue(primitive, side) {
137
+ const sidecar =
138
+ primitive?.extendedPrimitiveInformation?.maskExpansion?.[side]
139
+ if (
140
+ sidecar?.manualExpansion === null ||
141
+ sidecar?.manualExpansion === undefined
142
+ ) {
143
+ return null
144
+ }
145
+
146
+ const manualExpansion = Number(sidecar.manualExpansion)
147
+
148
+ if (Number.isFinite(manualExpansion)) {
149
+ return {
150
+ source: 'sidecar-manual',
151
+ mode: Number.isInteger(sidecar.mode) ? sidecar.mode : null,
152
+ expansionMil: manualExpansion,
153
+ unit: 'mil'
154
+ }
155
+ }
156
+
157
+ return null
158
+ }
159
+
160
+ /**
161
+ * Returns a primitive-local mask value when present.
162
+ * @param {object} primitive Primitive row.
163
+ * @param {'paste' | 'solder'} side Mask side.
164
+ * @returns {object | null}
165
+ */
166
+ static #primitiveValue(primitive, side) {
167
+ const prefix = side === 'paste' ? 'paste' : 'solder'
168
+ const mode = PcbMaskPasteResolver.#numberOrNull(
169
+ primitive?.[prefix + 'MaskExpansionMode']
170
+ )
171
+ const rawExpansion = PcbMaskPasteResolver.#numberOrNull(
172
+ primitive?.[prefix + 'MaskExpansion']
173
+ )
174
+ const effectiveExpansion = PcbMaskPasteResolver.#numberOrNull(
175
+ primitive?.[
176
+ 'effective' +
177
+ PcbMaskPasteResolver.#title(prefix) +
178
+ 'MaskExpansion'
179
+ ]
180
+ )
181
+
182
+ if (mode === 1) {
183
+ return {
184
+ source: 'rule',
185
+ mode,
186
+ expansionMil: effectiveExpansion ?? rawExpansion,
187
+ unit: 'mil'
188
+ }
189
+ }
190
+ if (mode === 2) {
191
+ return {
192
+ source: 'primitive-manual',
193
+ mode,
194
+ expansionMil: effectiveExpansion ?? rawExpansion,
195
+ unit: 'mil'
196
+ }
197
+ }
198
+
199
+ return null
200
+ }
201
+
202
+ /**
203
+ * Returns the first matching rule-derived expansion.
204
+ * @param {object[]} rules Parsed rules.
205
+ * @param {'paste' | 'solder'} side Mask side.
206
+ * @returns {object | null}
207
+ */
208
+ static #ruleValue(rules, side) {
209
+ const rule = PcbMaskPasteResolver.#maskRules(rules).find(
210
+ (candidate) => candidate.side === side
211
+ )
212
+
213
+ if (!rule || rule.expansionMil === null) {
214
+ return null
215
+ }
216
+
217
+ return {
218
+ source: 'rule',
219
+ ruleName: rule.name,
220
+ ruleKind: rule.ruleKind,
221
+ expansionMil: rule.expansionMil,
222
+ unit: 'mil'
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Extracts mask/paste rule summaries.
228
+ * @param {object[]} rules Parsed rules.
229
+ * @returns {object[]}
230
+ */
231
+ static #maskRules(rules) {
232
+ return (rules || [])
233
+ .map((rule) => PcbMaskPasteResolver.#maskRule(rule))
234
+ .filter(Boolean)
235
+ }
236
+
237
+ /**
238
+ * Extracts one mask/paste rule summary.
239
+ * @param {object} rule Parsed rule.
240
+ * @returns {object | null}
241
+ */
242
+ static #maskRule(rule) {
243
+ const token = [
244
+ rule?.ruleKind,
245
+ rule?.ruleType?.kind,
246
+ rule?.ruleType?.displayName,
247
+ rule?.name
248
+ ]
249
+ .join(' ')
250
+ .toLowerCase()
251
+ const side = token.includes('paste')
252
+ ? 'paste'
253
+ : token.includes('solder') || token.includes('mask')
254
+ ? 'solder'
255
+ : ''
256
+
257
+ if (!side) {
258
+ return null
259
+ }
260
+
261
+ return {
262
+ name: rule.name || '',
263
+ ruleKind: rule.ruleKind || '',
264
+ side,
265
+ expansionMil: PcbMaskPasteResolver.#firstConstraintMil(rule) ?? null
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Returns the first length-valued constraint in mils.
271
+ * @param {object} rule Parsed rule.
272
+ * @returns {number | null}
273
+ */
274
+ static #firstConstraintMil(rule) {
275
+ for (const value of Object.values(rule?.constraintValues || {})) {
276
+ if (Number.isFinite(value?.valueMil)) return value.valueMil
277
+ if (
278
+ value?.type === 'number' &&
279
+ Number.isFinite(Number(value.value))
280
+ ) {
281
+ return Number(value.value)
282
+ }
283
+ }
284
+
285
+ return null
286
+ }
287
+
288
+ /**
289
+ * Returns the document default expansion for one side.
290
+ * @param {object | null} defaults Board defaults.
291
+ * @param {'paste' | 'solder'} side Mask side.
292
+ * @returns {number | null}
293
+ */
294
+ static #defaultExpansion(defaults, side) {
295
+ const expansion = Number(defaults?.maskPaste?.[side]?.expansionMil)
296
+ return Number.isFinite(expansion) ? expansion : null
297
+ }
298
+
299
+ /**
300
+ * Builds summary counts from resolved primitive rows.
301
+ * @param {object[]} primitiveRows Resolved primitive rows.
302
+ * @returns {object}
303
+ */
304
+ static #summarize(primitiveRows) {
305
+ const summary = {
306
+ primitiveCount: primitiveRows.length,
307
+ manualCount: 0,
308
+ ruleCount: 0,
309
+ defaultCount: 0,
310
+ unresolvedCount: 0
311
+ }
312
+
313
+ for (const row of primitiveRows) {
314
+ for (const side of ['paste', 'solder']) {
315
+ const source = row[side]?.source || 'unresolved'
316
+ if (
317
+ source === 'sidecar-manual' ||
318
+ source === 'primitive-manual'
319
+ ) {
320
+ summary.manualCount += 1
321
+ } else if (source === 'rule') {
322
+ summary.ruleCount += 1
323
+ } else if (source === 'document-default') {
324
+ summary.defaultCount += 1
325
+ } else {
326
+ summary.unresolvedCount += 1
327
+ }
328
+ }
329
+ }
330
+
331
+ return summary
332
+ }
333
+
334
+ /**
335
+ * Converts a value to a finite number or null.
336
+ * @param {unknown} value Source value.
337
+ * @returns {number | null}
338
+ */
339
+ static #numberOrNull(value) {
340
+ const number = Number(value)
341
+ return Number.isFinite(number) ? number : null
342
+ }
343
+
344
+ /**
345
+ * Title-cases one ASCII token.
346
+ * @param {string} value Token.
347
+ * @returns {string}
348
+ */
349
+ static #title(value) {
350
+ return String(value || '').replace(/^./u, (letter) =>
351
+ letter.toUpperCase()
352
+ )
353
+ }
354
+ }
@@ -0,0 +1,204 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { ParserUtils } from './ParserUtils.mjs'
6
+
7
+ /**
8
+ * Parses mechanical-layer pair metadata used when footprints move between
9
+ * board sides.
10
+ */
11
+ export class PcbMechanicalLayerPairParser {
12
+ /**
13
+ * Parses mechanical-layer pairs from board metadata records.
14
+ * @param {Record<string, string | string[]>[]} fieldSets Board field sets.
15
+ * @param {{ layerId: number | null, name: string }[]} layers Layer stack rows.
16
+ * @param {{ layerId: number, name: string }[]} primitiveLayers Primitive layer rows.
17
+ * @returns {{ index: number, layer1Id: number, layer2Id: number, layer1Name: string, layer2Name: string, layer1V7SaveId?: number, layer2V7SaveId?: number }[]}
18
+ */
19
+ static parse(fieldSets, layers = [], primitiveLayers = []) {
20
+ const layerNames = PcbMechanicalLayerPairParser.#buildLayerNameMap(
21
+ fieldSets,
22
+ layers,
23
+ primitiveLayers
24
+ )
25
+ const pairs = []
26
+
27
+ for (const fields of fieldSets || []) {
28
+ for (const index of PcbMechanicalLayerPairParser.#pairIndexes(
29
+ fields
30
+ )) {
31
+ const layer1Id = PcbMechanicalLayerPairParser.#numberField(
32
+ fields,
33
+ index,
34
+ 'layer1'
35
+ )
36
+ const layer2Id = PcbMechanicalLayerPairParser.#numberField(
37
+ fields,
38
+ index,
39
+ 'layer2'
40
+ )
41
+ if (
42
+ !Number.isInteger(layer1Id) ||
43
+ !Number.isInteger(layer2Id)
44
+ ) {
45
+ continue
46
+ }
47
+
48
+ pairs.push({
49
+ index,
50
+ layer1Id,
51
+ layer2Id,
52
+ layer1Name: layerNames.get(layer1Id) || 'Layer ' + layer1Id,
53
+ layer2Name: layerNames.get(layer2Id) || 'Layer ' + layer2Id,
54
+ ...PcbMechanicalLayerPairParser.#v7Fields(fields, index)
55
+ })
56
+ }
57
+ }
58
+
59
+ return PcbMechanicalLayerPairParser.#dedupePairs(pairs)
60
+ }
61
+
62
+ /**
63
+ * Builds layer-flip metadata from parsed mechanical pairs.
64
+ * @param {{ layer1Id: number, layer2Id: number }[]} pairs Mechanical pairs.
65
+ * @returns {{ mechanicalFlipMap: Record<string, number>, pairedLayerIds: number[] }}
66
+ */
67
+ static buildFlipMetadata(pairs) {
68
+ const mechanicalFlipMap = {}
69
+ for (const pair of pairs || []) {
70
+ mechanicalFlipMap[String(pair.layer1Id)] = pair.layer2Id
71
+ mechanicalFlipMap[String(pair.layer2Id)] = pair.layer1Id
72
+ }
73
+
74
+ return {
75
+ mechanicalFlipMap,
76
+ pairedLayerIds: Object.keys(mechanicalFlipMap)
77
+ .map((layerId) => Number.parseInt(layerId, 10))
78
+ .sort((left, right) => left - right)
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Builds a layer-id to display-name map.
84
+ * @param {Record<string, string | string[]>[]} fieldSets Board field sets.
85
+ * @param {{ layerId: number | null, name: string }[]} layers Layer stack rows.
86
+ * @param {{ layerId: number, name: string }[]} primitiveLayers Primitive layer rows.
87
+ * @returns {Map<number, string>}
88
+ */
89
+ static #buildLayerNameMap(fieldSets, layers, primitiveLayers) {
90
+ const names = new Map()
91
+
92
+ for (const layer of [...(layers || []), ...(primitiveLayers || [])]) {
93
+ const layerId = Number(layer.layerId)
94
+ if (Number.isInteger(layerId) && layer.name) {
95
+ names.set(layerId, layer.name)
96
+ }
97
+ }
98
+
99
+ for (const fields of fieldSets || []) {
100
+ for (const [key, value] of Object.entries(fields || {})) {
101
+ const match = String(key).match(/^LAYER(\d+)NAME$/iu)
102
+ if (!match) continue
103
+ names.set(Number.parseInt(match[1], 10), String(value || ''))
104
+ }
105
+ }
106
+
107
+ return names
108
+ }
109
+
110
+ /**
111
+ * Finds mechanical pair indexes declared in one field set.
112
+ * @param {Record<string, string | string[]>} fields Board fields.
113
+ * @returns {number[]}
114
+ */
115
+ static #pairIndexes(fields) {
116
+ const indexes = new Set()
117
+ const declaredCount =
118
+ ParserUtils.parseNumericField(
119
+ fields,
120
+ 'MECHANICAL_LAYER_PAIR_COUNT'
121
+ ) ||
122
+ ParserUtils.parseNumericField(fields, 'MECHANICALPAIRCOUNT') ||
123
+ 0
124
+
125
+ for (let index = 1; index <= declaredCount; index += 1) {
126
+ indexes.add(index)
127
+ }
128
+
129
+ for (const key of Object.keys(fields || {})) {
130
+ const match = String(key).match(/^MECHANICAL_?LAYER_?PAIR(\d+)_?/iu)
131
+ if (match) indexes.add(Number.parseInt(match[1], 10))
132
+ }
133
+
134
+ return [...indexes].sort((left, right) => left - right)
135
+ }
136
+
137
+ /**
138
+ * Reads one layer id from supported mechanical pair key variants.
139
+ * @param {Record<string, string | string[]>} fields Board fields.
140
+ * @param {number} index Pair index.
141
+ * @param {'layer1' | 'layer2'} role Pair side.
142
+ * @returns {number | null}
143
+ */
144
+ static #numberField(fields, index, role) {
145
+ const keys =
146
+ role === 'layer1'
147
+ ? [
148
+ `MECHANICAL_LAYER_PAIR${index}_LAYER1`,
149
+ `MECHANICAL_LAYER_PAIR${index}_FIRST`,
150
+ `MECHANICALLAYERPAIR${index}LAYER1`,
151
+ `MECHPAIR${index}LAYER1`
152
+ ]
153
+ : [
154
+ `MECHANICAL_LAYER_PAIR${index}_LAYER2`,
155
+ `MECHANICAL_LAYER_PAIR${index}_SECOND`,
156
+ `MECHANICALLAYERPAIR${index}LAYER2`,
157
+ `MECHPAIR${index}LAYER2`
158
+ ]
159
+
160
+ for (const key of keys) {
161
+ const value = ParserUtils.parseNumericField(fields, key)
162
+ if (Number.isInteger(value)) return value
163
+ }
164
+
165
+ return null
166
+ }
167
+
168
+ /**
169
+ * Reads optional V7 saved-layer ids for one pair.
170
+ * @param {Record<string, string | string[]>} fields Board fields.
171
+ * @param {number} index Pair index.
172
+ * @returns {{ layer1V7SaveId?: number, layer2V7SaveId?: number }}
173
+ */
174
+ static #v7Fields(fields, index) {
175
+ const layer1V7SaveId = ParserUtils.parseNumericField(
176
+ fields,
177
+ `MECHANICAL_LAYER_PAIR${index}_LAYER1V7`
178
+ )
179
+ const layer2V7SaveId = ParserUtils.parseNumericField(
180
+ fields,
181
+ `MECHANICAL_LAYER_PAIR${index}_LAYER2V7`
182
+ )
183
+ return {
184
+ ...(Number.isInteger(layer1V7SaveId) ? { layer1V7SaveId } : {}),
185
+ ...(Number.isInteger(layer2V7SaveId) ? { layer2V7SaveId } : {})
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Removes duplicate pairs while preserving first-seen metadata.
191
+ * @param {object[]} pairs Candidate pairs.
192
+ * @returns {object[]}
193
+ */
194
+ static #dedupePairs(pairs) {
195
+ const byKey = new Map()
196
+ for (const pair of pairs || []) {
197
+ const key = [pair.layer1Id, pair.layer2Id].sort().join(':')
198
+ if (!byKey.has(key)) {
199
+ byKey.set(key, pair)
200
+ }
201
+ }
202
+ return [...byKey.values()]
203
+ }
204
+ }