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,7 +3,10 @@
3
3
  // SPDX-License-Identifier: GPL-3.0-or-later
4
4
 
5
5
  import { PcbBinaryPrimitiveParser } from './PcbBinaryPrimitiveParser.mjs'
6
+ import { PcbCustomPadShapeParser } from './PcbCustomPadShapeParser.mjs'
6
7
  import { PcbEmbeddedFontExtractor } from './PcbEmbeddedFontExtractor.mjs'
8
+ import { PcbEmbeddedModelExtractor } from './PcbEmbeddedModelExtractor.mjs'
9
+ import { PcbExtendedPrimitiveInformationParser } from './PcbExtendedPrimitiveInformationParser.mjs'
7
10
  import { PcbRawRecordRegistry } from './PcbRawRecordRegistry.mjs'
8
11
  import { OleCompoundDocument } from '../ole/OleCompoundDocument.mjs'
9
12
  import { OleConstants } from '../ole/OleConstants.mjs'
@@ -159,6 +162,8 @@ export class PcbLibStreamExtractor {
159
162
  )
160
163
  const embeddedFonts =
161
164
  PcbEmbeddedFontExtractor.extractFromStreams(streams)
165
+ const embeddedModels =
166
+ PcbEmbeddedModelExtractor.extractFromStreams(streams)
162
167
 
163
168
  return {
164
169
  libraryHeader: parsedLibraryData.libraryHeader,
@@ -168,15 +173,19 @@ export class PcbLibStreamExtractor {
168
173
  streamNames: PcbLibStreamExtractor.#collectUsedStreamNames(
169
174
  footprints,
170
175
  streams,
171
- embeddedFonts
176
+ embeddedFonts,
177
+ embeddedModels
172
178
  ),
173
179
  embeddedFonts,
180
+ embeddedModels,
174
181
  diagnostics: {
175
182
  declaredFootprintCount: parsedLibraryData.footprintNames.length,
176
183
  footprintCount: footprints.length,
177
184
  primitiveCount,
178
185
  rawRecordCount,
179
186
  embeddedFontCount: embeddedFonts.fonts.length,
187
+ embeddedModelCount: embeddedModels.models.length,
188
+ componentBodyCount: embeddedModels.componentBodies.length,
180
189
  missingFootprintCount:
181
190
  parsedLibraryData.footprintNames.length - footprints.length
182
191
  }
@@ -339,6 +348,21 @@ export class PcbLibStreamExtractor {
339
348
  const wideStrings = PcbLibStreamExtractor.#parseWideStrings(
340
349
  streams.get(storageName + '/WideStrings')
341
350
  )
351
+ const extendedPrimitiveInformation =
352
+ PcbExtendedPrimitiveInformationParser.parse(
353
+ PcbLibStreamExtractor.#firstStream(streams, [
354
+ storageName + '/ExtendedPrimitiveInformation/Data',
355
+ storageName + '/ExtendedPrimitiveInformation'
356
+ ]),
357
+ storageName + '/ExtendedPrimitiveInformation/Data'
358
+ )
359
+ const customPadShapes = PcbCustomPadShapeParser.parse(
360
+ PcbLibStreamExtractor.#firstStream(streams, [
361
+ storageName + '/CustomShapes/Data',
362
+ storageName + '/CustomShapes'
363
+ ]),
364
+ storageName + '/CustomShapes/Data'
365
+ )
342
366
  const parsedData = PcbLibStreamExtractor.#parseFootprintData(
343
367
  streams.get(storageName + '/Data') || new Uint8Array(),
344
368
  declaredPrimitiveCount,
@@ -364,10 +388,22 @@ export class PcbLibStreamExtractor {
364
388
  vias: parsedData.vias,
365
389
  fills: parsedData.fills,
366
390
  texts: parsedData.texts,
367
- regions: parsedData.regions
391
+ regions: parsedData.regions,
392
+ extendedPrimitiveInformation,
393
+ customPadShapes
368
394
  }
369
395
  }
370
396
 
397
+ /**
398
+ * Returns the first present stream from a candidate list.
399
+ * @param {Map<string, Uint8Array>} streams Stream map.
400
+ * @param {string[]} candidates Candidate stream names.
401
+ * @returns {Uint8Array | undefined}
402
+ */
403
+ static #firstStream(streams, candidates) {
404
+ return candidates.map((name) => streams.get(name)).find(Boolean)
405
+ }
406
+
371
407
  /**
372
408
  * Parses one footprint Data stream after its leading name block.
373
409
  * @param {Uint8Array} bytes
@@ -903,9 +939,15 @@ export class PcbLibStreamExtractor {
903
939
  * @param {object[]} footprints
904
940
  * @param {Map<string, Uint8Array>} streams
905
941
  * @param {{ fonts?: { sourceStream: string }[] }} embeddedFonts
942
+ * @param {{ models?: { sourceStream: string }[], componentBodies?: { sourceStream: string }[] }} embeddedModels
906
943
  * @returns {string[]}
907
944
  */
908
- static #collectUsedStreamNames(footprints, streams, embeddedFonts) {
945
+ static #collectUsedStreamNames(
946
+ footprints,
947
+ streams,
948
+ embeddedFonts,
949
+ embeddedModels
950
+ ) {
909
951
  const names = new Set()
910
952
 
911
953
  for (const baseName of [
@@ -923,7 +965,11 @@ export class PcbLibStreamExtractor {
923
965
  'Header',
924
966
  'Data',
925
967
  'Parameters',
926
- 'WideStrings'
968
+ 'WideStrings',
969
+ 'ExtendedPrimitiveInformation/Data',
970
+ 'ExtendedPrimitiveInformation',
971
+ 'CustomShapes/Data',
972
+ 'CustomShapes'
927
973
  ]) {
928
974
  const name = footprint.sourceStorage + '/' + suffix
929
975
  if (streams.has(name)) {
@@ -938,6 +984,18 @@ export class PcbLibStreamExtractor {
938
984
  }
939
985
  }
940
986
 
987
+ for (const model of embeddedModels.models || []) {
988
+ if (streams.has(model.sourceStream)) {
989
+ names.add(model.sourceStream)
990
+ }
991
+ }
992
+
993
+ for (const componentBody of embeddedModels.componentBodies || []) {
994
+ if (streams.has(componentBody.sourceStream)) {
995
+ names.add(componentBody.sourceStream)
996
+ }
997
+ }
998
+
941
999
  return [...names].sort((left, right) => left.localeCompare(right))
942
1000
  }
943
1001
 
@@ -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
+ }