altium-toolkit 0.1.0 → 0.1.16

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 (54) hide show
  1. package/README.md +24 -6
  2. package/docs/api.md +42 -4
  3. package/docs/model-format.md +95 -5
  4. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +553 -0
  5. package/docs/testing.md +7 -2
  6. package/package.json +21 -2
  7. package/spec/library-scope.md +7 -1
  8. package/src/core/altium/AltiumParser.mjs +22 -325
  9. package/src/core/altium/NormalizedModelSchema.mjs +28 -0
  10. package/src/core/altium/PcbArcPrimitiveParser.mjs +87 -0
  11. package/src/core/altium/PcbBinaryPrimitiveParser.mjs +43 -370
  12. package/src/core/altium/PcbBoardRegionSemanticsParser.mjs +477 -0
  13. package/src/core/altium/PcbComponentAnnotationNormalizer.mjs +290 -0
  14. package/src/core/altium/PcbComponentBodyPlacementNormalizer.mjs +52 -0
  15. package/src/core/altium/PcbComponentPrimitiveIndexer.mjs +109 -0
  16. package/src/core/altium/PcbEmbeddedFontExtractor.mjs +484 -0
  17. package/src/core/altium/PcbFillPrimitiveParser.mjs +84 -0
  18. package/src/core/altium/PcbFontMetricsParser.mjs +308 -0
  19. package/src/core/altium/PcbGeometryFlipper.mjs +244 -0
  20. package/src/core/altium/PcbLayerIdCodec.mjs +136 -0
  21. package/src/core/altium/PcbLibModelParser.mjs +202 -0
  22. package/src/core/altium/PcbLibStreamExtractor.mjs +968 -0
  23. package/src/core/altium/PcbModelParser.mjs +618 -66
  24. package/src/core/altium/PcbOutlineRecovery.mjs +4 -112
  25. package/src/core/altium/PcbPadPrimitiveParser.mjs +347 -0
  26. package/src/core/altium/PcbPadShapeCodec.mjs +158 -0
  27. package/src/core/altium/PcbPadStackParser.mjs +903 -0
  28. package/src/core/altium/PcbPrimitiveOwnershipIndexParser.mjs +60 -0
  29. package/src/core/altium/PcbPrimitiveParameterParser.mjs +212 -0
  30. package/src/core/altium/PcbPrimitiveRecordSlicer.mjs +243 -0
  31. package/src/core/altium/PcbRawRecordRegistry.mjs +831 -0
  32. package/src/core/altium/PcbRegionPrimitiveParser.mjs +317 -0
  33. package/src/core/altium/PcbRuleParser.mjs +587 -0
  34. package/src/core/altium/PcbStreamExtractor.mjs +127 -4
  35. package/src/core/altium/PcbTextPrimitiveParser.mjs +537 -0
  36. package/src/core/altium/PcbTrackPrimitiveParser.mjs +87 -0
  37. package/src/core/altium/PcbViaPrimitiveParser.mjs +88 -0
  38. package/src/core/altium/PcbViaStackParser.mjs +548 -0
  39. package/src/core/altium/PcbWideStringTableParser.mjs +108 -0
  40. package/src/core/altium/PrjPcbModelParser.mjs +797 -0
  41. package/src/core/altium/SchematicComponentTextResolver.mjs +355 -0
  42. package/src/parser.mjs +13 -0
  43. package/src/renderers.mjs +5 -0
  44. package/src/styles/altium-renderers.css +11 -6
  45. package/src/ui/PcbCopperPrimitiveSplitter.mjs +113 -0
  46. package/src/ui/PcbEdgeFacingGlyphNormalizer.mjs +6 -5
  47. package/src/ui/PcbEmbeddedFontFaceRenderer.mjs +126 -0
  48. package/src/ui/PcbFootprintPrimitiveSelector.mjs +27 -6
  49. package/src/ui/PcbRegionPrimitiveRenderer.mjs +243 -0
  50. package/src/ui/PcbSideResolvedRenderModel.mjs +336 -0
  51. package/src/ui/PcbSvgRenderer.mjs +101 -109
  52. package/src/ui/PcbTextPrimitiveRenderer.mjs +252 -0
  53. package/src/ui/SchematicSheetChromeRenderer.mjs +2 -93
  54. package/src/ui/SchematicSheetZoneRenderer.mjs +104 -0
@@ -0,0 +1,903 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { PcbPadShapeCodec } from './PcbPadShapeCodec.mjs'
6
+
7
+ /**
8
+ * Decodes the extended Altium pad stack fields from PAD subrecords.
9
+ */
10
+ export class PcbPadStackParser {
11
+ static #FLAGS_OFFSET = 1
12
+
13
+ static #UNION_INDEX_OFFSET = 9
14
+
15
+ static #PAD_MODE_OFFSET = 62
16
+
17
+ static #PLANE_CONNECTION_STYLE_OFFSET = 67
18
+
19
+ static #THERMAL_RELIEF_CONDUCTOR_WIDTH_OFFSET = 68
20
+
21
+ static #THERMAL_RELIEF_CONDUCTOR_COUNT_OFFSET = 72
22
+
23
+ static #THERMAL_RELIEF_AIR_GAP_OFFSET = 74
24
+
25
+ static #POWER_PLANE_RELIEF_EXPANSION_OFFSET = 78
26
+
27
+ static #POWER_PLANE_CLEARANCE_OFFSET = 82
28
+
29
+ static #PASTE_MASK_EXPANSION_OFFSET = 86
30
+
31
+ static #SOLDER_MASK_EXPANSION_OFFSET = 90
32
+
33
+ static #PLANE_CONNECTION_CACHE_VALID_OFFSET = 96
34
+
35
+ static #THERMAL_RELIEF_CONDUCTOR_WIDTH_CACHE_VALID_OFFSET = 97
36
+
37
+ static #THERMAL_RELIEF_CONDUCTOR_COUNT_CACHE_VALID_OFFSET = 98
38
+
39
+ static #THERMAL_RELIEF_AIR_GAP_CACHE_VALID_OFFSET = 99
40
+
41
+ static #POWER_PLANE_RELIEF_EXPANSION_CACHE_VALID_OFFSET = 100
42
+
43
+ static #PASTE_MASK_MODE_OFFSET = 101
44
+
45
+ static #SOLDER_MASK_MODE_OFFSET = 102
46
+
47
+ static #PASTE_MASK_CACHE_VALID_OFFSET = 103
48
+
49
+ static #SOLDER_MASK_CACHE_VALID_OFFSET = 104
50
+
51
+ static #EXTENSION_MIN_BYTE_LENGTH = 596
52
+
53
+ static #INNER_LAYER_COUNT = 29
54
+
55
+ static #PHYSICAL_LAYER_COUNT = 32
56
+
57
+ static #TOP_LAYER_ID = 1
58
+
59
+ static #BOTTOM_LAYER_ID = 32
60
+
61
+ static #MULTI_LAYER_ID = 74
62
+
63
+ static #DEFAULT_SOLDER_MASK_EXPANSION = 4
64
+
65
+ static #MIN_PASTE_OPENING = 0.04
66
+
67
+ /**
68
+ * Decodes optional main-record and extension-record pad stack metadata.
69
+ * @param {DataView} mainRecord
70
+ * @param {DataView | undefined} extensionRecord
71
+ * @param {{ layerId?: number | null, sizeTopX?: number, sizeTopY?: number, sizeBottomX?: number, sizeBottomY?: number, holeDiameter?: number }} [padContext]
72
+ * @returns {Record<string, unknown>}
73
+ */
74
+ static parse(mainRecord, extensionRecord, padContext = {}) {
75
+ const flags = PcbPadStackParser.#parseFlags(mainRecord)
76
+ const mainRecordTail =
77
+ PcbPadStackParser.#parseMainRecordTail(mainRecord)
78
+
79
+ return {
80
+ ...flags,
81
+ ...mainRecordTail,
82
+ ...PcbPadStackParser.#parseMaskExpansionSemantics(
83
+ flags,
84
+ mainRecordTail,
85
+ padContext
86
+ ),
87
+ ...PcbPadStackParser.#parseExtensionRecord(
88
+ extensionRecord,
89
+ padContext
90
+ )
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Decodes optional pad flags from the main record.
96
+ * @param {DataView} mainRecord
97
+ * @returns {Record<string, boolean | number>}
98
+ */
99
+ static #parseFlags(mainRecord) {
100
+ if (
101
+ !mainRecord ||
102
+ PcbPadStackParser.#FLAGS_OFFSET + 2 > mainRecord.byteLength
103
+ ) {
104
+ return {}
105
+ }
106
+
107
+ const flags = mainRecord.getUint16(
108
+ PcbPadStackParser.#FLAGS_OFFSET,
109
+ true
110
+ )
111
+
112
+ if (!flags) {
113
+ return {}
114
+ }
115
+
116
+ const result = {
117
+ padFlags: flags
118
+ }
119
+
120
+ if (flags & 0x0008) {
121
+ result.isUserRouted = true
122
+ }
123
+ if (flags & 0x0010) {
124
+ result.isTestFabTop = true
125
+ result.isFabTestPointTop = true
126
+ }
127
+ if (flags & 0x0020) {
128
+ result.isTentingTop = true
129
+ }
130
+ if (flags & 0x0040) {
131
+ result.isTentingBottom = true
132
+ }
133
+ if (flags & 0x0080) {
134
+ result.isAssemblyTestPointTop = true
135
+ }
136
+ if (flags & 0x0100) {
137
+ result.isTestFabBottom = true
138
+ result.isFabTestPointBottom = true
139
+ }
140
+ if (flags & 0x0200) {
141
+ result.isAssemblyTestPointBottom = true
142
+ }
143
+
144
+ return result
145
+ }
146
+
147
+ /**
148
+ * Decodes optional pad mode, plane-cache, and mask-expansion fields from the
149
+ * main record.
150
+ * @param {DataView} mainRecord
151
+ * @returns {Record<string, boolean | number>}
152
+ */
153
+ static #parseMainRecordTail(mainRecord) {
154
+ if (!mainRecord || mainRecord.byteLength < 105) {
155
+ return {}
156
+ }
157
+
158
+ const result = {
159
+ padMode: mainRecord.getUint8(PcbPadStackParser.#PAD_MODE_OFFSET),
160
+ padModeName: PcbPadShapeCodec.padModeName(
161
+ mainRecord.getUint8(PcbPadStackParser.#PAD_MODE_OFFSET)
162
+ ),
163
+ pasteMaskExpansion: PcbPadStackParser.#readMil(
164
+ mainRecord,
165
+ PcbPadStackParser.#PASTE_MASK_EXPANSION_OFFSET
166
+ ),
167
+ solderMaskExpansion: PcbPadStackParser.#readMil(
168
+ mainRecord,
169
+ PcbPadStackParser.#SOLDER_MASK_EXPANSION_OFFSET
170
+ ),
171
+ pasteMaskExpansionMode: mainRecord.getUint8(
172
+ PcbPadStackParser.#PASTE_MASK_MODE_OFFSET
173
+ ),
174
+ solderMaskExpansionMode: mainRecord.getUint8(
175
+ PcbPadStackParser.#SOLDER_MASK_MODE_OFFSET
176
+ )
177
+ }
178
+
179
+ PcbPadStackParser.#assignPadCacheFields(result, mainRecord)
180
+ PcbPadStackParser.#assignMaskCacheFields(result, mainRecord)
181
+
182
+ return result
183
+ }
184
+
185
+ /**
186
+ * Adds non-zero pad-cache and thermal-relief fields to an output object.
187
+ * @param {Record<string, unknown>} result
188
+ * @param {DataView} mainRecord
189
+ */
190
+ static #assignPadCacheFields(result, mainRecord) {
191
+ const padCache = {
192
+ planeConnectionStyle: mainRecord.getUint8(
193
+ PcbPadStackParser.#PLANE_CONNECTION_STYLE_OFFSET
194
+ ),
195
+ thermalReliefConductorWidth: PcbPadStackParser.#readMil(
196
+ mainRecord,
197
+ PcbPadStackParser.#THERMAL_RELIEF_CONDUCTOR_WIDTH_OFFSET
198
+ ),
199
+ thermalReliefConductorCount: mainRecord.getUint16(
200
+ PcbPadStackParser.#THERMAL_RELIEF_CONDUCTOR_COUNT_OFFSET,
201
+ true
202
+ ),
203
+ thermalReliefAirGap: PcbPadStackParser.#readMil(
204
+ mainRecord,
205
+ PcbPadStackParser.#THERMAL_RELIEF_AIR_GAP_OFFSET
206
+ ),
207
+ powerPlaneReliefExpansion: PcbPadStackParser.#readMil(
208
+ mainRecord,
209
+ PcbPadStackParser.#POWER_PLANE_RELIEF_EXPANSION_OFFSET
210
+ ),
211
+ powerPlaneClearance: PcbPadStackParser.#readMil(
212
+ mainRecord,
213
+ PcbPadStackParser.#POWER_PLANE_CLEARANCE_OFFSET
214
+ ),
215
+ validity: {
216
+ planeConnection: mainRecord.getUint8(
217
+ PcbPadStackParser.#PLANE_CONNECTION_CACHE_VALID_OFFSET
218
+ ),
219
+ thermalReliefConductorWidth: mainRecord.getUint8(
220
+ PcbPadStackParser
221
+ .#THERMAL_RELIEF_CONDUCTOR_WIDTH_CACHE_VALID_OFFSET
222
+ ),
223
+ thermalReliefConductorCount: mainRecord.getUint8(
224
+ PcbPadStackParser
225
+ .#THERMAL_RELIEF_CONDUCTOR_COUNT_CACHE_VALID_OFFSET
226
+ ),
227
+ thermalReliefAirGap: mainRecord.getUint8(
228
+ PcbPadStackParser.#THERMAL_RELIEF_AIR_GAP_CACHE_VALID_OFFSET
229
+ ),
230
+ powerPlaneReliefExpansion: mainRecord.getUint8(
231
+ PcbPadStackParser
232
+ .#POWER_PLANE_RELIEF_EXPANSION_CACHE_VALID_OFFSET
233
+ )
234
+ }
235
+ }
236
+ const fieldValues = {
237
+ unionIndex: mainRecord.getUint32(
238
+ PcbPadStackParser.#UNION_INDEX_OFFSET,
239
+ true
240
+ ),
241
+ planeConnectionStyle: padCache.planeConnectionStyle,
242
+ thermalReliefConductorWidth: padCache.thermalReliefConductorWidth,
243
+ thermalReliefConductorCount: padCache.thermalReliefConductorCount,
244
+ thermalReliefAirGap: padCache.thermalReliefAirGap,
245
+ powerPlaneReliefExpansion: padCache.powerPlaneReliefExpansion,
246
+ powerPlaneClearance: padCache.powerPlaneClearance,
247
+ planeConnectionCacheValid: padCache.validity.planeConnection,
248
+ thermalReliefConductorWidthCacheValid:
249
+ padCache.validity.thermalReliefConductorWidth,
250
+ thermalReliefConductorCountCacheValid:
251
+ padCache.validity.thermalReliefConductorCount,
252
+ thermalReliefAirGapCacheValid:
253
+ padCache.validity.thermalReliefAirGap,
254
+ thermalReliefCacheValid: padCache.validity.thermalReliefAirGap,
255
+ powerPlaneReliefExpansionCacheValid:
256
+ padCache.validity.powerPlaneReliefExpansion,
257
+ powerPlaneReliefCacheValid:
258
+ padCache.validity.powerPlaneReliefExpansion
259
+ }
260
+
261
+ for (const [key, value] of Object.entries(fieldValues)) {
262
+ if (value) {
263
+ result[key] = value
264
+ }
265
+ }
266
+
267
+ if (PcbPadStackParser.#hasNonZeroPadCacheValue(padCache)) {
268
+ result.padCache = padCache
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Adds mask-expansion cache-validity fields to an output object.
274
+ * @param {Record<string, unknown>} result
275
+ * @param {DataView} mainRecord
276
+ */
277
+ static #assignMaskCacheFields(result, mainRecord) {
278
+ const pasteCacheValid = mainRecord.getUint8(
279
+ PcbPadStackParser.#PASTE_MASK_CACHE_VALID_OFFSET
280
+ )
281
+ const solderCacheValid = mainRecord.getUint8(
282
+ PcbPadStackParser.#SOLDER_MASK_CACHE_VALID_OFFSET
283
+ )
284
+
285
+ if (pasteCacheValid) {
286
+ result.pasteMaskExpansionCacheValid = pasteCacheValid
287
+ result.pasteMaskExpansionRuleCacheValid = true
288
+ }
289
+ if (solderCacheValid) {
290
+ result.solderMaskExpansionCacheValid = solderCacheValid
291
+ result.solderMaskExpansionRuleCacheValid = true
292
+ }
293
+ }
294
+
295
+ /**
296
+ * Returns whether one decoded pad cache contains meaningful data.
297
+ * @param {{ planeConnectionStyle: number, thermalReliefConductorWidth: number, thermalReliefConductorCount: number, thermalReliefAirGap: number, powerPlaneReliefExpansion: number, powerPlaneClearance: number, validity: Record<string, number> }} padCache
298
+ * @returns {boolean}
299
+ */
300
+ static #hasNonZeroPadCacheValue(padCache) {
301
+ const values = [
302
+ padCache.planeConnectionStyle,
303
+ padCache.thermalReliefConductorWidth,
304
+ padCache.thermalReliefConductorCount,
305
+ padCache.thermalReliefAirGap,
306
+ padCache.powerPlaneReliefExpansion,
307
+ padCache.powerPlaneClearance,
308
+ ...Object.values(padCache.validity)
309
+ ]
310
+
311
+ return values.some((value) => value !== 0)
312
+ }
313
+
314
+ /**
315
+ * Adds derived mask-expansion and layer-opening semantics.
316
+ * @param {Record<string, boolean | number>} flags
317
+ * @param {Record<string, boolean | number>} mainRecordTail
318
+ * @param {{ layerId?: number | null, sizeTopX?: number, sizeTopY?: number, sizeBottomX?: number, sizeBottomY?: number, holeDiameter?: number }} padContext
319
+ * @returns {Record<string, unknown>}
320
+ */
321
+ static #parseMaskExpansionSemantics(flags, mainRecordTail, padContext) {
322
+ if (!('pasteMaskExpansionMode' in mainRecordTail)) {
323
+ return {}
324
+ }
325
+
326
+ const pasteMode = Number(mainRecordTail.pasteMaskExpansionMode)
327
+ const solderMode = Number(mainRecordTail.solderMaskExpansionMode)
328
+ const pasteExpansion = Number(mainRecordTail.pasteMaskExpansion)
329
+ const solderExpansion = Number(mainRecordTail.solderMaskExpansion)
330
+ const effectivePasteExpansion =
331
+ PcbPadStackParser.#effectivePasteMaskExpansion(
332
+ pasteMode,
333
+ pasteExpansion
334
+ )
335
+ const effectiveSolderExpansion =
336
+ PcbPadStackParser.#effectiveSolderMaskExpansion(
337
+ solderMode,
338
+ solderExpansion
339
+ )
340
+ const pasteCacheValid =
341
+ Number(mainRecordTail.pasteMaskExpansionCacheValid) || 0
342
+ const solderCacheValid =
343
+ Number(mainRecordTail.solderMaskExpansionCacheValid) || 0
344
+ const layerOpenings = PcbPadStackParser.#deriveLayerOpenings(
345
+ flags,
346
+ padContext,
347
+ effectivePasteExpansion,
348
+ effectiveSolderExpansion
349
+ )
350
+
351
+ return {
352
+ pasteMaskExpansionSource:
353
+ PcbPadStackParser.#maskExpansionSource(pasteMode),
354
+ solderMaskExpansionSource:
355
+ PcbPadStackParser.#maskExpansionSource(solderMode),
356
+ effectivePasteMaskExpansion: effectivePasteExpansion,
357
+ effectiveSolderMaskExpansion: effectiveSolderExpansion,
358
+ maskExpansion: {
359
+ paste: {
360
+ mode: pasteMode,
361
+ source: PcbPadStackParser.#maskExpansionSource(pasteMode),
362
+ expansion: pasteExpansion,
363
+ effectiveExpansion: effectivePasteExpansion,
364
+ cacheValid: pasteCacheValid
365
+ },
366
+ solder: {
367
+ mode: solderMode,
368
+ source: PcbPadStackParser.#maskExpansionSource(solderMode),
369
+ expansion: solderExpansion,
370
+ effectiveExpansion: effectiveSolderExpansion,
371
+ cacheValid: solderCacheValid
372
+ },
373
+ defaultSolderExpansion:
374
+ PcbPadStackParser.#DEFAULT_SOLDER_MASK_EXPANSION,
375
+ minPasteOpening: PcbPadStackParser.#MIN_PASTE_OPENING
376
+ },
377
+ ...layerOpenings
378
+ }
379
+ }
380
+
381
+ /**
382
+ * Maps one raw mask-expansion mode byte to a stable source label.
383
+ * @param {number} mode
384
+ * @returns {string}
385
+ */
386
+ static #maskExpansionSource(mode) {
387
+ if (mode === 1) {
388
+ return 'rule'
389
+ }
390
+ if (mode === 2) {
391
+ return 'manual'
392
+ }
393
+ if (mode === 0) {
394
+ return 'default'
395
+ }
396
+
397
+ return `unknown-${mode}`
398
+ }
399
+
400
+ /**
401
+ * Resolves the effective paste-mask expansion for rendering decisions.
402
+ * @param {number} mode
403
+ * @param {number} expansion
404
+ * @returns {number}
405
+ */
406
+ static #effectivePasteMaskExpansion(mode, expansion) {
407
+ return mode === 1 || mode === 2 ? expansion : 0
408
+ }
409
+
410
+ /**
411
+ * Resolves the effective solder-mask expansion for rendering decisions.
412
+ * @param {number} mode
413
+ * @param {number} expansion
414
+ * @returns {number}
415
+ */
416
+ static #effectiveSolderMaskExpansion(mode, expansion) {
417
+ return mode === 1 || mode === 2
418
+ ? expansion
419
+ : PcbPadStackParser.#DEFAULT_SOLDER_MASK_EXPANSION
420
+ }
421
+
422
+ /**
423
+ * Derives side-specific paste/solder opening booleans.
424
+ * @param {Record<string, boolean | number>} flags
425
+ * @param {{ layerId?: number | null, sizeTopX?: number, sizeTopY?: number, sizeBottomX?: number, sizeBottomY?: number, holeDiameter?: number }} padContext
426
+ * @param {number} effectivePasteExpansion
427
+ * @param {number} effectiveSolderExpansion
428
+ * @returns {{ hasTopPasteMaskOpening: boolean, hasBottomPasteMaskOpening: boolean, hasTopSolderMaskOpening: boolean, hasBottomSolderMaskOpening: boolean, isSolderMaskOnly: boolean }}
429
+ */
430
+ static #deriveLayerOpenings(
431
+ flags,
432
+ padContext,
433
+ effectivePasteExpansion,
434
+ effectiveSolderExpansion
435
+ ) {
436
+ const sourceSides = PcbPadStackParser.#sourceSides(
437
+ Number(padContext.layerId) || null
438
+ )
439
+ const hasHole = Number(padContext.holeDiameter) > 0
440
+ const topPasteOpening =
441
+ !hasHole &&
442
+ sourceSides.top &&
443
+ PcbPadStackParser.#hasPasteOpening(
444
+ Number(padContext.sizeTopX) || 0,
445
+ Number(padContext.sizeTopY) || 0,
446
+ effectivePasteExpansion
447
+ )
448
+ const bottomPasteOpening =
449
+ !hasHole &&
450
+ sourceSides.bottom &&
451
+ PcbPadStackParser.#hasPasteOpening(
452
+ Number(padContext.sizeBottomX) || 0,
453
+ Number(padContext.sizeBottomY) || 0,
454
+ effectivePasteExpansion
455
+ )
456
+ const isSolderMaskOnly = PcbPadStackParser.#isSolderMaskOnly(
457
+ flags,
458
+ padContext,
459
+ topPasteOpening,
460
+ bottomPasteOpening
461
+ )
462
+
463
+ return {
464
+ hasTopPasteMaskOpening: topPasteOpening && !isSolderMaskOnly,
465
+ hasBottomPasteMaskOpening: bottomPasteOpening && !isSolderMaskOnly,
466
+ hasTopSolderMaskOpening:
467
+ sourceSides.top &&
468
+ !flags.isTentingTop &&
469
+ PcbPadStackParser.#hasPositiveOpening(
470
+ Number(padContext.sizeTopX) || 0,
471
+ Number(padContext.sizeTopY) || 0,
472
+ effectiveSolderExpansion
473
+ ),
474
+ hasBottomSolderMaskOpening:
475
+ sourceSides.bottom &&
476
+ !flags.isTentingBottom &&
477
+ PcbPadStackParser.#hasPositiveOpening(
478
+ Number(padContext.sizeBottomX) || 0,
479
+ Number(padContext.sizeBottomY) || 0,
480
+ effectiveSolderExpansion
481
+ ),
482
+ isSolderMaskOnly
483
+ }
484
+ }
485
+
486
+ /**
487
+ * Resolves which copper side owns one pad.
488
+ * @param {number | null} layerId
489
+ * @returns {{ top: boolean, bottom: boolean }}
490
+ */
491
+ static #sourceSides(layerId) {
492
+ return {
493
+ top:
494
+ layerId === PcbPadStackParser.#TOP_LAYER_ID ||
495
+ layerId === PcbPadStackParser.#MULTI_LAYER_ID,
496
+ bottom:
497
+ layerId === PcbPadStackParser.#BOTTOM_LAYER_ID ||
498
+ layerId === PcbPadStackParser.#MULTI_LAYER_ID
499
+ }
500
+ }
501
+
502
+ /**
503
+ * Returns whether a paste aperture remains open after expansion.
504
+ * @param {number} width
505
+ * @param {number} height
506
+ * @param {number} expansion
507
+ * @returns {boolean}
508
+ */
509
+ static #hasPasteOpening(width, height, expansion) {
510
+ if (width <= 0 || height <= 0) {
511
+ return false
512
+ }
513
+
514
+ return (
515
+ width + 2 * expansion >= PcbPadStackParser.#MIN_PASTE_OPENING &&
516
+ height + 2 * expansion >= PcbPadStackParser.#MIN_PASTE_OPENING
517
+ )
518
+ }
519
+
520
+ /**
521
+ * Returns whether a mask aperture has positive dimensions.
522
+ * @param {number} width
523
+ * @param {number} height
524
+ * @param {number} expansion
525
+ * @returns {boolean}
526
+ */
527
+ static #hasPositiveOpening(width, height, expansion) {
528
+ return width + 2 * expansion > 0 && height + 2 * expansion > 0
529
+ }
530
+
531
+ /**
532
+ * Applies the narrow mask-only SMD testpoint heuristic used by exporters.
533
+ * @param {Record<string, boolean | number>} flags
534
+ * @param {{ layerId?: number | null, holeDiameter?: number }} padContext
535
+ * @param {boolean} topPasteOpening
536
+ * @param {boolean} bottomPasteOpening
537
+ * @returns {boolean}
538
+ */
539
+ static #isSolderMaskOnly(
540
+ flags,
541
+ padContext,
542
+ topPasteOpening,
543
+ bottomPasteOpening
544
+ ) {
545
+ const layerId = Number(padContext.layerId) || null
546
+
547
+ if (Number(padContext.holeDiameter) > 0) {
548
+ return false
549
+ }
550
+ if (
551
+ layerId === PcbPadStackParser.#TOP_LAYER_ID &&
552
+ !topPasteOpening &&
553
+ PcbPadStackParser.#hasTopTestPointFlag(flags)
554
+ ) {
555
+ return true
556
+ }
557
+ if (
558
+ layerId === PcbPadStackParser.#BOTTOM_LAYER_ID &&
559
+ !bottomPasteOpening &&
560
+ PcbPadStackParser.#hasBottomTestPointFlag(flags)
561
+ ) {
562
+ return true
563
+ }
564
+
565
+ return false
566
+ }
567
+
568
+ /**
569
+ * Returns whether top-side testpoint-like pad flags are present.
570
+ * @param {Record<string, boolean | number>} flags
571
+ * @returns {boolean}
572
+ */
573
+ static #hasTopTestPointFlag(flags) {
574
+ return Boolean(
575
+ flags.isAssemblyTestPointTop ||
576
+ flags.isFabTestPointTop ||
577
+ flags.isTestFabTop
578
+ )
579
+ }
580
+
581
+ /**
582
+ * Returns whether bottom-side testpoint-like pad flags are present.
583
+ * @param {Record<string, boolean | number>} flags
584
+ * @returns {boolean}
585
+ */
586
+ static #hasBottomTestPointFlag(flags) {
587
+ return Boolean(
588
+ flags.isAssemblyTestPointBottom ||
589
+ flags.isFabTestPointBottom ||
590
+ flags.isTestFabBottom
591
+ )
592
+ }
593
+
594
+ /**
595
+ * Decodes optional per-layer pad stack fields from the extension record.
596
+ * @param {DataView | undefined} extensionRecord
597
+ * @param {{ shapeMid?: number, holeDiameter?: number }} padContext
598
+ * @returns {Record<string, unknown>}
599
+ */
600
+ static #parseExtensionRecord(extensionRecord, padContext) {
601
+ if (
602
+ !extensionRecord ||
603
+ extensionRecord.byteLength <
604
+ PcbPadStackParser.#EXTENSION_MIN_BYTE_LENGTH
605
+ ) {
606
+ return {
607
+ holeShape: null,
608
+ holeSlotLength: null,
609
+ holeRotation: null,
610
+ hasRoundedRect: false,
611
+ roundedRectShapeTop: null,
612
+ cornerRadiusTop: null,
613
+ offsetTopX: 0,
614
+ offsetTopY: 0
615
+ }
616
+ }
617
+
618
+ const innerLayerSizes =
619
+ PcbPadStackParser.#parseInnerLayerSizes(extensionRecord)
620
+ const innerLayerShapes = PcbPadStackParser.#parseInnerLayerShapes(
621
+ extensionRecord,
622
+ innerLayerSizes,
623
+ padContext.shapeMid
624
+ )
625
+ const layerOffsets =
626
+ PcbPadStackParser.#parseLayerOffsets(extensionRecord)
627
+ const layerShapes = PcbPadStackParser.#parseLayerShapes(extensionRecord)
628
+ const cornerRadiusByLayer =
629
+ PcbPadStackParser.#parseCornerRadiusByLayer(extensionRecord)
630
+ const holeShape = extensionRecord.getUint8(262)
631
+ const holeSlotLength = PcbPadStackParser.#readMil(extensionRecord, 263)
632
+ const holeRotation = extensionRecord.getFloat64(267, true)
633
+ const roundedRectShapeTop = extensionRecord.getUint8(532)
634
+
635
+ return {
636
+ holeShape,
637
+ holeShapeName: PcbPadShapeCodec.holeShapeName(holeShape),
638
+ holeSlotLength,
639
+ holeRotation,
640
+ holeGeometry: PcbPadShapeCodec.describeHoleGeometry({
641
+ shape: holeShape,
642
+ diameter: Number(padContext.holeDiameter || 0),
643
+ slotLength: holeSlotLength,
644
+ rotation: holeRotation
645
+ }),
646
+ hasRoundedRect: extensionRecord.getUint8(531) !== 0,
647
+ roundedRectShapeTop,
648
+ roundedRectShapeTopName:
649
+ PcbPadShapeCodec.padShapeName(roundedRectShapeTop),
650
+ cornerRadiusTop: extensionRecord.getUint8(564),
651
+ offsetTopX: PcbPadStackParser.#readMil(extensionRecord, 275),
652
+ offsetTopY: PcbPadStackParser.#readMil(extensionRecord, 403),
653
+ innerLayerSizes,
654
+ innerLayerShapes,
655
+ middleLayerPads: PcbPadStackParser.#buildMiddleLayerPads(
656
+ innerLayerSizes,
657
+ innerLayerShapes
658
+ ),
659
+ layerOffsets,
660
+ layerShapes,
661
+ cornerRadiusByLayer,
662
+ fullStackLayerEntries:
663
+ PcbPadStackParser.#parseFullStackLayerEntries(extensionRecord)
664
+ }
665
+ }
666
+
667
+ /**
668
+ * Decodes non-empty inner-layer pad sizes.
669
+ * @param {DataView} extensionRecord
670
+ * @returns {{ layerNumber: number, width: number, height: number }[]}
671
+ */
672
+ static #parseInnerLayerSizes(extensionRecord) {
673
+ const entries = []
674
+
675
+ for (
676
+ let index = 0;
677
+ index < PcbPadStackParser.#INNER_LAYER_COUNT;
678
+ index += 1
679
+ ) {
680
+ const width = PcbPadStackParser.#readMil(extensionRecord, index * 4)
681
+ const height = PcbPadStackParser.#readMil(
682
+ extensionRecord,
683
+ 116 + index * 4
684
+ )
685
+
686
+ if (width || height) {
687
+ entries.push({
688
+ layerNumber: index + 2,
689
+ width,
690
+ height
691
+ })
692
+ }
693
+ }
694
+
695
+ return entries
696
+ }
697
+
698
+ /**
699
+ * Decodes inner-layer shape values for layers that carry size or shape data.
700
+ * @param {DataView} extensionRecord
701
+ * @param {{ layerNumber: number }[]} innerLayerSizes
702
+ * @param {number | null | undefined} fallbackShape
703
+ * @returns {{ layerNumber: number, shape: number, shapeName: string | null, effectiveShape: number, effectiveShapeName: string | null }[]}
704
+ */
705
+ static #parseInnerLayerShapes(
706
+ extensionRecord,
707
+ innerLayerSizes,
708
+ fallbackShape
709
+ ) {
710
+ const sizedLayers = new Set(
711
+ innerLayerSizes.map((entry) => entry.layerNumber)
712
+ )
713
+ const entries = []
714
+
715
+ for (
716
+ let index = 0;
717
+ index < PcbPadStackParser.#INNER_LAYER_COUNT;
718
+ index += 1
719
+ ) {
720
+ const shape = extensionRecord.getUint8(232 + index)
721
+ const layerNumber = index + 2
722
+
723
+ if (shape || sizedLayers.has(layerNumber)) {
724
+ entries.push({
725
+ layerNumber,
726
+ ...PcbPadShapeCodec.describeMiddleLayerShape(
727
+ shape,
728
+ fallbackShape
729
+ )
730
+ })
731
+ }
732
+ }
733
+
734
+ return entries
735
+ }
736
+
737
+ /**
738
+ * Merges middle-layer size records with their raw and effective shapes.
739
+ * @param {{ layerNumber: number, width: number, height: number }[]} innerLayerSizes
740
+ * @param {{ layerNumber: number, shape: number, shapeName: string | null, effectiveShape: number, effectiveShapeName: string | null }[]} innerLayerShapes
741
+ * @returns {{ layerNumber: number, width: number, height: number, shape: number, shapeName: string | null, effectiveShape: number, effectiveShapeName: string | null }[]}
742
+ */
743
+ static #buildMiddleLayerPads(innerLayerSizes, innerLayerShapes) {
744
+ const sizesByLayer = new Map(
745
+ innerLayerSizes.map((entry) => [entry.layerNumber, entry])
746
+ )
747
+
748
+ return innerLayerShapes.map((shapeEntry) => {
749
+ const sizeEntry = sizesByLayer.get(shapeEntry.layerNumber) || {}
750
+
751
+ return {
752
+ layerNumber: shapeEntry.layerNumber,
753
+ width: Number(sizeEntry.width || 0),
754
+ height: Number(sizeEntry.height || 0),
755
+ shape: shapeEntry.shape,
756
+ shapeName: shapeEntry.shapeName,
757
+ effectiveShape: shapeEntry.effectiveShape,
758
+ effectiveShapeName: shapeEntry.effectiveShapeName
759
+ }
760
+ })
761
+ }
762
+
763
+ /**
764
+ * Decodes non-empty per-layer pad-center offsets.
765
+ * @param {DataView} extensionRecord
766
+ * @returns {{ layerNumber: number, x: number, y: number }[]}
767
+ */
768
+ static #parseLayerOffsets(extensionRecord) {
769
+ const entries = []
770
+
771
+ for (
772
+ let index = 0;
773
+ index < PcbPadStackParser.#PHYSICAL_LAYER_COUNT;
774
+ index += 1
775
+ ) {
776
+ const x = PcbPadStackParser.#readMil(
777
+ extensionRecord,
778
+ 275 + index * 4
779
+ )
780
+ const y = PcbPadStackParser.#readMil(
781
+ extensionRecord,
782
+ 403 + index * 4
783
+ )
784
+
785
+ if (x || y) {
786
+ entries.push({
787
+ layerNumber: index + 1,
788
+ x,
789
+ y
790
+ })
791
+ }
792
+ }
793
+
794
+ return entries
795
+ }
796
+
797
+ /**
798
+ * Decodes non-empty per-layer alternative pad shapes.
799
+ * @param {DataView} extensionRecord
800
+ * @returns {{ layerNumber: number, shape: number, shapeName: string | null }[]}
801
+ */
802
+ static #parseLayerShapes(extensionRecord) {
803
+ const entries = []
804
+
805
+ for (
806
+ let index = 0;
807
+ index < PcbPadStackParser.#PHYSICAL_LAYER_COUNT;
808
+ index += 1
809
+ ) {
810
+ const shape = extensionRecord.getUint8(532 + index)
811
+
812
+ if (shape) {
813
+ entries.push({
814
+ layerNumber: index + 1,
815
+ shape,
816
+ shapeName: PcbPadShapeCodec.padShapeName(shape)
817
+ })
818
+ }
819
+ }
820
+
821
+ return entries
822
+ }
823
+
824
+ /**
825
+ * Decodes non-empty per-layer corner-radius percentages.
826
+ * @param {DataView} extensionRecord
827
+ * @returns {{ layerNumber: number, cornerRadius: number }[]}
828
+ */
829
+ static #parseCornerRadiusByLayer(extensionRecord) {
830
+ const entries = []
831
+
832
+ for (
833
+ let index = 0;
834
+ index < PcbPadStackParser.#PHYSICAL_LAYER_COUNT;
835
+ index += 1
836
+ ) {
837
+ const cornerRadius = extensionRecord.getUint8(564 + index)
838
+
839
+ if (cornerRadius) {
840
+ entries.push({
841
+ layerNumber: index + 1,
842
+ cornerRadius
843
+ })
844
+ }
845
+ }
846
+
847
+ return entries
848
+ }
849
+
850
+ /**
851
+ * Decodes the optional full-stack tail table.
852
+ * @param {DataView} extensionRecord
853
+ * @returns {{ layerCode: number, modeFlags: number, enabled: boolean, sizeX: number, sizeY: number, cornerRadius: number }[]}
854
+ */
855
+ static #parseFullStackLayerEntries(extensionRecord) {
856
+ const tailOffset = PcbPadStackParser.#EXTENSION_MIN_BYTE_LENGTH
857
+ const tableHeaderOffset = tailOffset + 32
858
+
859
+ if (extensionRecord.byteLength < tableHeaderOffset + 8) {
860
+ return []
861
+ }
862
+
863
+ const count = extensionRecord.getUint32(tableHeaderOffset, true)
864
+ const stride = extensionRecord.getUint32(tableHeaderOffset + 4, true)
865
+
866
+ if (count <= 0 || count > 128 || stride < 15) {
867
+ return []
868
+ }
869
+
870
+ const dataStart = tableHeaderOffset + 8
871
+ const dataEnd = dataStart + count * stride
872
+
873
+ if (dataEnd > extensionRecord.byteLength) {
874
+ return []
875
+ }
876
+
877
+ const entries = []
878
+
879
+ for (let index = 0; index < count; index += 1) {
880
+ const offset = dataStart + index * stride
881
+ entries.push({
882
+ layerCode: extensionRecord.getInt16(offset, true),
883
+ modeFlags: extensionRecord.getUint16(offset + 2, true),
884
+ enabled: extensionRecord.getUint8(offset + 4) !== 0,
885
+ sizeX: PcbPadStackParser.#readMil(extensionRecord, offset + 5),
886
+ sizeY: PcbPadStackParser.#readMil(extensionRecord, offset + 9),
887
+ cornerRadius: extensionRecord.getUint16(offset + 13, true)
888
+ })
889
+ }
890
+
891
+ return entries
892
+ }
893
+
894
+ /**
895
+ * Reads one signed fixed-point mil value.
896
+ * @param {DataView} view
897
+ * @param {number} offset
898
+ * @returns {number}
899
+ */
900
+ static #readMil(view, offset) {
901
+ return view.getInt32(offset, true) / 10000
902
+ }
903
+ }