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
@@ -0,0 +1,245 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Builds a read-only PCB primitive ownership graph from normalized indexes.
7
+ */
8
+ export class PcbOwnershipGraphBuilder {
9
+ static SCHEMA_ID = 'altium-toolkit.pcb.ownership.a1'
10
+
11
+ /**
12
+ * Builds primitive owner groups keyed by component, net, and polygon index.
13
+ * @param {{ components?: object[], nets?: object[], fills?: object[], tracks?: object[], arcs?: object[], vias?: object[], pads?: object[], regions?: object[], shapeBasedRegions?: object[], texts?: object[] }} pcb Normalized PCB model.
14
+ * @returns {{ schema: string, primitiveOwners: object[], componentsByIndex: Record<string, object>, netsByIndex: Record<string, object>, polygonsByIndex: Record<string, object> }}
15
+ */
16
+ static build(pcb) {
17
+ const componentNames = PcbOwnershipGraphBuilder.#componentNames(
18
+ pcb?.components || []
19
+ )
20
+ const netNames = PcbOwnershipGraphBuilder.#netNames(pcb?.nets || [])
21
+ const componentsByIndex =
22
+ PcbOwnershipGraphBuilder.#initialComponentGroups(
23
+ pcb?.components || []
24
+ )
25
+ const netsByIndex = {}
26
+ const polygonsByIndex = {}
27
+ const primitiveOwners = []
28
+
29
+ for (const item of PcbOwnershipGraphBuilder.#primitiveItems(pcb)) {
30
+ const owner = PcbOwnershipGraphBuilder.#primitiveOwner(
31
+ item,
32
+ componentNames,
33
+ netNames
34
+ )
35
+ if (!owner) {
36
+ continue
37
+ }
38
+
39
+ primitiveOwners.push(owner)
40
+ PcbOwnershipGraphBuilder.#addGroupKey(
41
+ componentsByIndex,
42
+ owner.componentIndex,
43
+ {
44
+ componentIndex: owner.componentIndex,
45
+ designator: owner.component || '',
46
+ primitiveKeys: []
47
+ },
48
+ owner.primitiveKey
49
+ )
50
+ PcbOwnershipGraphBuilder.#addGroupKey(
51
+ netsByIndex,
52
+ owner.netIndex,
53
+ {
54
+ netIndex: owner.netIndex,
55
+ name: owner.net || '',
56
+ primitiveKeys: []
57
+ },
58
+ owner.primitiveKey
59
+ )
60
+ PcbOwnershipGraphBuilder.#addGroupKey(
61
+ polygonsByIndex,
62
+ owner.polygonIndex,
63
+ {
64
+ polygonIndex: owner.polygonIndex,
65
+ primitiveKeys: []
66
+ },
67
+ owner.primitiveKey
68
+ )
69
+ }
70
+
71
+ return {
72
+ schema: PcbOwnershipGraphBuilder.SCHEMA_ID,
73
+ primitiveOwners,
74
+ componentsByIndex,
75
+ netsByIndex,
76
+ polygonsByIndex
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Builds primitive iterable entries in stable renderer collection order.
82
+ * @param {object} pcb Normalized PCB model.
83
+ * @returns {{ primitiveKind: string, primitiveKey: string, primitive: object }[]}
84
+ */
85
+ static #primitiveItems(pcb) {
86
+ return [
87
+ ['fill', pcb?.fills || []],
88
+ ['track', pcb?.tracks || []],
89
+ ['arc', pcb?.arcs || []],
90
+ ['via', pcb?.vias || []],
91
+ ['pad', pcb?.pads || []],
92
+ ['region', pcb?.regions || []],
93
+ ['shape-based-region', pcb?.shapeBasedRegions || []],
94
+ ['text', pcb?.texts || []],
95
+ ['polygon', pcb?.polygons || []]
96
+ ].flatMap(([primitiveKind, primitives]) =>
97
+ primitives.map((primitive, index) => ({
98
+ primitiveKind,
99
+ primitiveKey: primitiveKind + '-' + index,
100
+ primitive
101
+ }))
102
+ )
103
+ }
104
+
105
+ /**
106
+ * Builds a compact primitive owner row.
107
+ * @param {{ primitiveKind: string, primitiveKey: string, primitive: object }} item Primitive item.
108
+ * @param {Map<number, string>} componentNames Component names by native index.
109
+ * @param {Map<number, string>} netNames Net names by native index.
110
+ * @returns {object | null}
111
+ */
112
+ static #primitiveOwner(item, componentNames, netNames) {
113
+ const componentIndex =
114
+ PcbOwnershipGraphBuilder.#optionalInteger(
115
+ item.primitive.componentIndex
116
+ ) ??
117
+ (item.primitiveKind === 'text'
118
+ ? PcbOwnershipGraphBuilder.#optionalInteger(
119
+ item.primitive.ownerIndex
120
+ )
121
+ : null)
122
+ const netIndex = PcbOwnershipGraphBuilder.#optionalInteger(
123
+ item.primitive.netIndex
124
+ )
125
+ const polygonIndex = PcbOwnershipGraphBuilder.#optionalInteger(
126
+ item.primitive.polygonIndex
127
+ )
128
+
129
+ if (
130
+ componentIndex === null &&
131
+ netIndex === null &&
132
+ polygonIndex === null
133
+ ) {
134
+ return null
135
+ }
136
+
137
+ return {
138
+ primitiveKey: item.primitiveKey,
139
+ primitiveKind: item.primitiveKind,
140
+ componentIndex,
141
+ component:
142
+ componentIndex === null
143
+ ? ''
144
+ : componentNames.get(componentIndex) || '',
145
+ netIndex,
146
+ net: netIndex === null ? '' : netNames.get(netIndex) || '',
147
+ polygonIndex
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Builds component designator lookup by native component index.
153
+ * @param {object[]} components Component rows.
154
+ * @returns {Map<number, string>}
155
+ */
156
+ static #componentNames(components) {
157
+ const names = new Map()
158
+
159
+ for (const component of components || []) {
160
+ const componentIndex = PcbOwnershipGraphBuilder.#optionalInteger(
161
+ component?.componentIndex
162
+ )
163
+ if (componentIndex !== null) {
164
+ names.set(componentIndex, String(component.designator || ''))
165
+ }
166
+ }
167
+
168
+ return names
169
+ }
170
+
171
+ /**
172
+ * Builds net name lookup by native net index.
173
+ * @param {object[]} nets Net rows.
174
+ * @returns {Map<number, string>}
175
+ */
176
+ static #netNames(nets) {
177
+ const names = new Map()
178
+
179
+ for (const net of nets || []) {
180
+ const netIndex = PcbOwnershipGraphBuilder.#optionalInteger(
181
+ net?.netIndex
182
+ )
183
+ if (netIndex !== null) {
184
+ names.set(netIndex, String(net.name || ''))
185
+ }
186
+ }
187
+
188
+ return names
189
+ }
190
+
191
+ /**
192
+ * Creates empty component groups so consumers can inspect ownerless rows.
193
+ * @param {object[]} components Component rows.
194
+ * @returns {Record<string, object>}
195
+ */
196
+ static #initialComponentGroups(components) {
197
+ const groups = {}
198
+
199
+ for (const component of components || []) {
200
+ const componentIndex = PcbOwnershipGraphBuilder.#optionalInteger(
201
+ component?.componentIndex
202
+ )
203
+ if (componentIndex !== null) {
204
+ groups[String(componentIndex)] = {
205
+ componentIndex,
206
+ designator: String(component.designator || ''),
207
+ primitiveKeys: []
208
+ }
209
+ }
210
+ }
211
+
212
+ return groups
213
+ }
214
+
215
+ /**
216
+ * Adds one primitive key to a numeric owner group.
217
+ * @param {Record<string, object>} groups Group map.
218
+ * @param {number | null} index Owner index.
219
+ * @param {object} fallbackGroup Group to create when missing.
220
+ * @param {string} primitiveKey Primitive key to append.
221
+ */
222
+ static #addGroupKey(groups, index, fallbackGroup, primitiveKey) {
223
+ if (index === null) {
224
+ return
225
+ }
226
+
227
+ const key = String(index)
228
+ if (!groups[key]) {
229
+ groups[key] = fallbackGroup
230
+ }
231
+ if (!groups[key].primitiveKeys.includes(primitiveKey)) {
232
+ groups[key].primitiveKeys.push(primitiveKey)
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Parses an optional integer value.
238
+ * @param {unknown} value Candidate value.
239
+ * @returns {number | null}
240
+ */
241
+ static #optionalInteger(value) {
242
+ const parsed = Number(value)
243
+ return Number.isInteger(parsed) ? parsed : null
244
+ }
245
+ }
@@ -35,19 +35,18 @@ export class PcbPadPrimitiveParser {
35
35
  return []
36
36
  }
37
37
 
38
- let offset = 0
38
+ const records = PcbPadPrimitiveParser.#readPadRecordSequence(
39
+ normalizedData,
40
+ 0,
41
+ count
42
+ )
39
43
  const pads = []
40
44
 
41
- for (let index = 0; index < count; index += 1) {
42
- const record = PcbPadPrimitiveParser.#readPadRecordAt(
43
- normalizedData,
44
- offset
45
- )
46
-
47
- if (!record) {
48
- return []
49
- }
45
+ if (!records) {
46
+ return []
47
+ }
50
48
 
49
+ for (const record of records) {
51
50
  const pad = PcbPadPrimitiveParser.#parsePadSubrecords(
52
51
  record.subrecords
53
52
  )
@@ -57,25 +56,69 @@ export class PcbPadPrimitiveParser {
57
56
  }
58
57
 
59
58
  pads.push(pad)
60
- offset = record.nextOffset
61
-
62
- if (index < count - 1) {
63
- const nextOffset =
64
- PcbPadPrimitiveParser.#findNextPadRecordOffset(
65
- normalizedData,
66
- offset,
67
- count - index - 1
68
- )
69
-
70
- if (nextOffset === null) {
71
- return []
59
+ }
60
+
61
+ return pads
62
+ }
63
+
64
+ /**
65
+ * Reads all expected pad records without recursive suffix validation.
66
+ * @param {Uint8Array} bytes
67
+ * @param {number} offset
68
+ * @param {number} count
69
+ * @returns {{ subrecords: DataView[], nextOffset: number }[] | null}
70
+ */
71
+ static #readPadRecordSequence(bytes, offset, count) {
72
+ const firstRecord = PcbPadPrimitiveParser.#readPadRecordAt(
73
+ bytes,
74
+ offset
75
+ )
76
+
77
+ if (!firstRecord) {
78
+ return null
79
+ }
80
+
81
+ const records = [firstRecord]
82
+ const alternativeScanOffsets = [null]
83
+ let depth = 1
84
+ let scanOffset = firstRecord.nextOffset
85
+
86
+ while (depth < count) {
87
+ const candidate = PcbPadPrimitiveParser.#findNextPadRecordCandidate(
88
+ bytes,
89
+ scanOffset
90
+ )
91
+
92
+ if (!candidate) {
93
+ let foundAlternative = false
94
+
95
+ while (depth > 1 && !foundAlternative) {
96
+ depth -= 1
97
+ records.length = depth
98
+
99
+ const alternativeOffset = alternativeScanOffsets[depth]
100
+ alternativeScanOffsets.length = depth
101
+
102
+ if (alternativeOffset !== null) {
103
+ scanOffset = alternativeOffset
104
+ foundAlternative = true
105
+ }
72
106
  }
73
107
 
74
- offset = nextOffset
108
+ if (!foundAlternative) {
109
+ return null
110
+ }
111
+
112
+ continue
75
113
  }
114
+
115
+ records[depth] = candidate.record
116
+ alternativeScanOffsets[depth] = candidate.alternativeOffset
117
+ depth += 1
118
+ scanOffset = candidate.record.nextOffset
76
119
  }
77
120
 
78
- return pads
121
+ return records
79
122
  }
80
123
 
81
124
  /**
@@ -149,31 +192,28 @@ export class PcbPadPrimitiveParser {
149
192
  }
150
193
 
151
194
  /**
152
- * Finds the next pad record boundary after optional unknown subrecords.
195
+ * Finds the next readable pad record after optional unknown subrecords.
153
196
  * @param {Uint8Array} bytes
154
197
  * @param {number} offset
155
- * @param {number} remainingCount
156
- * @returns {number | null}
198
+ * @returns {{ record: { subrecords: DataView[], nextOffset: number }, alternativeOffset: number | null } | null}
157
199
  */
158
- static #findNextPadRecordOffset(bytes, offset, remainingCount) {
200
+ static #findNextPadRecordCandidate(bytes, offset) {
159
201
  let cursor = offset
160
202
 
161
203
  while (cursor < bytes.byteLength) {
162
- if (
163
- PcbPadPrimitiveParser.#canReadPadRecordSequence(
164
- bytes,
165
- cursor,
166
- remainingCount
167
- )
168
- ) {
169
- return cursor
170
- }
171
-
204
+ const record = PcbPadPrimitiveParser.#readPadRecordAt(bytes, cursor)
172
205
  const unknownSubrecord = PcbPadPrimitiveParser.#readSubrecordAt(
173
206
  bytes,
174
207
  cursor
175
208
  )
176
209
 
210
+ if (record) {
211
+ return {
212
+ record,
213
+ alternativeOffset: unknownSubrecord?.nextOffset ?? null
214
+ }
215
+ }
216
+
177
217
  if (!unknownSubrecord) {
178
218
  return null
179
219
  }
@@ -184,33 +224,6 @@ export class PcbPadPrimitiveParser {
184
224
  return null
185
225
  }
186
226
 
187
- /**
188
- * Checks whether the remaining pad records can be read from an offset.
189
- * @param {Uint8Array} bytes
190
- * @param {number} offset
191
- * @param {number} remainingCount
192
- * @returns {boolean}
193
- */
194
- static #canReadPadRecordSequence(bytes, offset, remainingCount) {
195
- const record = PcbPadPrimitiveParser.#readPadRecordAt(bytes, offset)
196
-
197
- if (!record) {
198
- return false
199
- }
200
-
201
- if (remainingCount <= 1) {
202
- return true
203
- }
204
-
205
- return (
206
- PcbPadPrimitiveParser.#findNextPadRecordOffset(
207
- bytes,
208
- record.nextOffset,
209
- remainingCount - 1
210
- ) !== null
211
- )
212
- }
213
-
214
227
  /**
215
228
  * Decodes one pad payload from its subrecords.
216
229
  * @param {DataView[]} subrecords
@@ -48,6 +48,12 @@ export class PcbPadStackParser {
48
48
 
49
49
  static #SOLDER_MASK_CACHE_VALID_OFFSET = 104
50
50
 
51
+ static #POSITIVE_TOLERANCE_OFFSET = 162
52
+
53
+ static #NEGATIVE_TOLERANCE_OFFSET = 166
54
+
55
+ static #HOLE_TOLERANCE_UNSET = 0x7fffffff
56
+
51
57
  static #EXTENSION_MIN_BYTE_LENGTH = 596
52
58
 
53
59
  static #INNER_LAYER_COUNT = 29
@@ -75,6 +81,10 @@ export class PcbPadStackParser {
75
81
  const flags = PcbPadStackParser.#parseFlags(mainRecord)
76
82
  const mainRecordTail =
77
83
  PcbPadStackParser.#parseMainRecordTail(mainRecord)
84
+ const extension = PcbPadStackParser.#parseExtensionRecord(
85
+ extensionRecord,
86
+ padContext
87
+ )
78
88
 
79
89
  return {
80
90
  ...flags,
@@ -84,8 +94,10 @@ export class PcbPadStackParser {
84
94
  mainRecordTail,
85
95
  padContext
86
96
  ),
87
- ...PcbPadStackParser.#parseExtensionRecord(
88
- extensionRecord,
97
+ ...extension,
98
+ ...PcbPadStackParser.#buildLocalStack(
99
+ mainRecordTail,
100
+ extension,
89
101
  padContext
90
102
  )
91
103
  }
@@ -178,6 +190,7 @@ export class PcbPadStackParser {
178
190
 
179
191
  PcbPadStackParser.#assignPadCacheFields(result, mainRecord)
180
192
  PcbPadStackParser.#assignMaskCacheFields(result, mainRecord)
193
+ PcbPadStackParser.#assignHoleToleranceFields(result, mainRecord)
181
194
 
182
195
  return result
183
196
  }
@@ -292,6 +305,35 @@ export class PcbPadStackParser {
292
305
  }
293
306
  }
294
307
 
308
+ /**
309
+ * Adds optional hole tolerance fields to an output object.
310
+ * @param {Record<string, unknown>} result
311
+ * @param {DataView} mainRecord
312
+ */
313
+ static #assignHoleToleranceFields(result, mainRecord) {
314
+ const positiveTolerance = PcbPadStackParser.#readHoleTolerance(
315
+ mainRecord,
316
+ PcbPadStackParser.#POSITIVE_TOLERANCE_OFFSET
317
+ )
318
+ const negativeTolerance = PcbPadStackParser.#readHoleTolerance(
319
+ mainRecord,
320
+ PcbPadStackParser.#NEGATIVE_TOLERANCE_OFFSET
321
+ )
322
+ const holeTolerance = {}
323
+
324
+ if (positiveTolerance !== null) {
325
+ result.positiveTolerance = positiveTolerance
326
+ holeTolerance.positive = positiveTolerance
327
+ }
328
+ if (negativeTolerance !== null) {
329
+ result.negativeTolerance = negativeTolerance
330
+ holeTolerance.negative = negativeTolerance
331
+ }
332
+ if (Object.keys(holeTolerance).length) {
333
+ result.holeTolerance = holeTolerance
334
+ }
335
+ }
336
+
295
337
  /**
296
338
  * Returns whether one decoded pad cache contains meaningful data.
297
339
  * @param {{ planeConnectionStyle: number, thermalReliefConductorWidth: number, thermalReliefConductorCount: number, thermalReliefAirGap: number, powerPlaneReliefExpansion: number, powerPlaneClearance: number, validity: Record<string, number> }} padCache
@@ -311,6 +353,28 @@ export class PcbPadStackParser {
311
353
  return values.some((value) => value !== 0)
312
354
  }
313
355
 
356
+ /**
357
+ * Reads one optional hole tolerance from a pad main record.
358
+ * @param {DataView} mainRecord
359
+ * @param {number} offset
360
+ * @returns {number | null}
361
+ */
362
+ static #readHoleTolerance(mainRecord, offset) {
363
+ if (!mainRecord || offset + 4 > mainRecord.byteLength) {
364
+ return null
365
+ }
366
+
367
+ const rawValue = mainRecord.getInt32(offset, true)
368
+ if (
369
+ rawValue === 0 ||
370
+ rawValue === PcbPadStackParser.#HOLE_TOLERANCE_UNSET
371
+ ) {
372
+ return null
373
+ }
374
+
375
+ return rawValue / 10000
376
+ }
377
+
314
378
  /**
315
379
  * Adds derived mask-expansion and layer-opening semantics.
316
380
  * @param {Record<string, boolean | number>} flags
@@ -664,6 +728,169 @@ export class PcbPadStackParser {
664
728
  }
665
729
  }
666
730
 
731
+ /**
732
+ * Builds a normalized local-stack geometry read model.
733
+ * @param {Record<string, boolean | number>} mainRecordTail Main tail fields.
734
+ * @param {Record<string, unknown>} extension Extension fields.
735
+ * @param {Record<string, unknown>} padContext Parsed pad fields.
736
+ * @returns {{ localStack?: object }}
737
+ */
738
+ static #buildLocalStack(mainRecordTail, extension, padContext) {
739
+ const mode = Number(mainRecordTail.padMode)
740
+ if (mode === 1) {
741
+ return {
742
+ localStack: {
743
+ schema: 'altium-toolkit.pcb.pad-local-stack.a1',
744
+ mode,
745
+ modeName: String(mainRecordTail.padModeName || ''),
746
+ source: 'main-record',
747
+ layers: [
748
+ PcbPadStackParser.#localStackLayer(
749
+ 'top',
750
+ 1,
751
+ 'L1',
752
+ padContext,
753
+ extension
754
+ ),
755
+ PcbPadStackParser.#localStackLayer(
756
+ 'middle',
757
+ null,
758
+ 'INNER',
759
+ padContext,
760
+ extension
761
+ ),
762
+ PcbPadStackParser.#localStackLayer(
763
+ 'bottom',
764
+ 32,
765
+ 'L32',
766
+ padContext,
767
+ extension
768
+ )
769
+ ],
770
+ hole: PcbPadStackParser.#localStackHole(
771
+ padContext,
772
+ extension
773
+ )
774
+ }
775
+ }
776
+ }
777
+
778
+ if (
779
+ mode === 2 &&
780
+ Array.isArray(extension.fullStackLayerEntries) &&
781
+ extension.fullStackLayerEntries.length
782
+ ) {
783
+ return {
784
+ localStack: {
785
+ schema: 'altium-toolkit.pcb.pad-local-stack.a1',
786
+ mode,
787
+ modeName: String(mainRecordTail.padModeName || ''),
788
+ source: 'extension-record',
789
+ layers: extension.fullStackLayerEntries.map((entry) => ({
790
+ role: 'layer',
791
+ layerId: Number(entry.layerCode),
792
+ layerKey: 'L' + Number(entry.layerCode),
793
+ enabled: entry.enabled,
794
+ width: entry.sizeX,
795
+ height: entry.sizeY,
796
+ cornerRadius: entry.cornerRadius,
797
+ modeFlags: entry.modeFlags
798
+ })),
799
+ hole: PcbPadStackParser.#localStackHole(
800
+ padContext,
801
+ extension
802
+ )
803
+ }
804
+ }
805
+ }
806
+
807
+ return {}
808
+ }
809
+
810
+ /**
811
+ * Builds one top/middle/bottom local-stack layer entry.
812
+ * @param {'top' | 'middle' | 'bottom'} role Layer role.
813
+ * @param {number | null} layerId Layer id.
814
+ * @param {string} layerKey Stable layer key.
815
+ * @param {Record<string, unknown>} padContext Parsed pad fields.
816
+ * @param {Record<string, unknown>} extension Extension fields.
817
+ * @returns {object}
818
+ */
819
+ static #localStackLayer(role, layerId, layerKey, padContext, extension) {
820
+ const suffix =
821
+ role === 'top' ? 'Top' : role === 'bottom' ? 'Bottom' : 'Mid'
822
+ const offset = PcbPadStackParser.#layerOffset(role, extension)
823
+
824
+ return {
825
+ role,
826
+ layerId,
827
+ layerKey,
828
+ width: Number(padContext['size' + suffix + 'X'] || 0),
829
+ height: Number(padContext['size' + suffix + 'Y'] || 0),
830
+ shape: PcbPadStackParser.#numericOrNull(
831
+ padContext['shape' + suffix]
832
+ ),
833
+ shapeName: PcbPadShapeCodec.padShapeName(
834
+ padContext['shape' + suffix]
835
+ ),
836
+ offsetX: offset.x,
837
+ offsetY: offset.y
838
+ }
839
+ }
840
+
841
+ /**
842
+ * Resolves layer offsets from extension data when present.
843
+ * @param {'top' | 'middle' | 'bottom'} role Layer role.
844
+ * @param {Record<string, unknown>} extension Extension fields.
845
+ * @returns {{ x: number, y: number }}
846
+ */
847
+ static #layerOffset(role, extension) {
848
+ const layerNumber = role === 'top' ? 1 : role === 'bottom' ? 32 : null
849
+ const offset = Array.isArray(extension.layerOffsets)
850
+ ? extension.layerOffsets.find(
851
+ (entry) => entry.layerNumber === layerNumber
852
+ )
853
+ : null
854
+
855
+ return {
856
+ x: Number(offset?.x || 0),
857
+ y: Number(offset?.y || 0)
858
+ }
859
+ }
860
+
861
+ /**
862
+ * Builds local-stack hole geometry.
863
+ * @param {Record<string, unknown>} padContext Parsed pad fields.
864
+ * @param {Record<string, unknown>} extension Extension fields.
865
+ * @returns {object}
866
+ */
867
+ static #localStackHole(padContext, extension) {
868
+ const shape = PcbPadStackParser.#numericOrNull(extension.holeShape)
869
+
870
+ return {
871
+ diameter: Number(padContext.holeDiameter || 0),
872
+ shape,
873
+ shapeName:
874
+ shape === null ? null : PcbPadShapeCodec.holeShapeName(shape),
875
+ slotLength: extension.holeSlotLength ?? null,
876
+ rotation: extension.holeRotation ?? null
877
+ }
878
+ }
879
+
880
+ /**
881
+ * Converts finite numeric values and nullish values into stable output.
882
+ * @param {unknown} value Candidate value.
883
+ * @returns {number | null}
884
+ */
885
+ static #numericOrNull(value) {
886
+ if (value === null || value === undefined || value === '') {
887
+ return null
888
+ }
889
+
890
+ const number = Number(value)
891
+ return Number.isFinite(number) ? number : null
892
+ }
893
+
667
894
  /**
668
895
  * Decodes non-empty inner-layer pad sizes.
669
896
  * @param {DataView} extensionRecord