altium-toolkit 0.1.1 → 0.1.17

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 +25 -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 +6 -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
@@ -2,6 +2,7 @@
2
2
  //
3
3
  // SPDX-License-Identifier: GPL-3.0-or-later
4
4
 
5
+ import { PcbGeometryFlipper } from './PcbGeometryFlipper.mjs'
5
6
  import { PcbOutlineRasterizer } from './PcbOutlineRasterizer.mjs'
6
7
 
7
8
  /**
@@ -92,81 +93,11 @@ export class PcbOutlineRecovery {
92
93
  /**
93
94
  * Mirrors one normalized PCB model vertically so the SVG matches the
94
95
  * authored top-view orientation.
95
- * @param {{ boardOutline: { minX: number, minY: number, widthMil: number, heightMil: number, segments: Array<Record<string, number | string>> }, polygons?: { layer?: string, segments: Array<Record<string, number | string>> }[], fills?: { x1: number, y1: number, x2: number, y2: number, layerCode?: number, layerId?: number }[], tracks?: { x1: number, y1: number, x2: number, y2: number, width: number, layerCode?: number, layerId?: number }[], arcs?: { x: number, y: number, radius: number, startAngle: number, endAngle: number, width: number, layerCode?: number, layerId?: number }[], vias?: { x: number, y: number, diameter: number, holeDiameter: number }[], pads?: { x: number, y: number, rotation?: number, holeRotation?: number | null }[], components?: { designator: string, x: number, y: number, rotation: number, layer: string, pattern: string }[] }} pcb
96
- * @returns {{ boardOutline: { minX: number, minY: number, widthMil: number, heightMil: number, segments: Array<Record<string, number | string>> }, polygons: { layer?: string, segments: Array<Record<string, number | string>> }[], fills: { x1: number, y1: number, x2: number, y2: number, layerCode?: number, layerId?: number }[], tracks: { x1: number, y1: number, x2: number, y2: number, width: number, layerCode?: number, layerId?: number }[], arcs: { x: number, y: number, radius: number, startAngle: number, endAngle: number, width: number, layerCode?: number, layerId?: number }[], vias: { x: number, y: number, diameter: number, holeDiameter: number }[], pads: { x: number, y: number, rotation?: number, holeRotation?: number | null }[], components: { designator: string, x: number, y: number, rotation: number, layer: string, pattern: string }[] }}
96
+ * @param {{ boardOutline: { minX: number, minY: number, widthMil: number, heightMil: number, segments: Array<Record<string, number | string>> }, polygons?: { layer?: string, segments: Array<Record<string, number | string>> }[], fills?: { x1: number, y1: number, x2: number, y2: number, layerCode?: number, layerId?: number }[], tracks?: { x1: number, y1: number, x2: number, y2: number, width: number, layerCode?: number, layerId?: number }[], arcs?: { x: number, y: number, radius: number, startAngle: number, endAngle: number, width: number, layerCode?: number, layerId?: number }[], vias?: { x: number, y: number, diameter: number, holeDiameter: number }[], pads?: { x: number, y: number, rotation?: number, holeRotation?: number | null }[], texts?: { x: number, y: number, rotation?: number }[], components?: { designator: string, x: number, y: number, rotation: number, layer: string, pattern: string }[] }} pcb
97
+ * @returns {{ boardOutline: { minX: number, minY: number, widthMil: number, heightMil: number, segments: Array<Record<string, number | string>> }, polygons: { layer?: string, segments: Array<Record<string, number | string>> }[], fills: { x1: number, y1: number, x2: number, y2: number, layerCode?: number, layerId?: number }[], tracks: { x1: number, y1: number, x2: number, y2: number, width: number, layerCode?: number, layerId?: number }[], arcs: { x: number, y: number, radius: number, startAngle: number, endAngle: number, width: number, layerCode?: number, layerId?: number }[], vias: { x: number, y: number, diameter: number, holeDiameter: number }[], pads: { x: number, y: number, rotation?: number, holeRotation?: number | null }[], texts: { x: number, y: number, rotation?: number }[], components: { designator: string, x: number, y: number, rotation: number, layer: string, pattern: string }[] }}
97
98
  */
98
99
  static flipGeometryVertically(pcb) {
99
- const outline = pcb?.boardOutline
100
- const maxY =
101
- Number(outline?.minY || 0) + Number(outline?.heightMil || 0)
102
- const mirrorY = (value) =>
103
- Number(outline?.minY || 0) + maxY - Number(value || 0)
104
-
105
- return {
106
- ...pcb,
107
- boardOutline: {
108
- ...outline,
109
- segments: (outline?.segments || []).map((segment) =>
110
- PcbOutlineRecovery.#flipSegment(segment, mirrorY)
111
- )
112
- },
113
- polygons: (pcb?.polygons || []).map((polygon) => ({
114
- ...polygon,
115
- segments: (polygon.segments || []).map((segment) =>
116
- PcbOutlineRecovery.#flipSegment(segment, mirrorY)
117
- )
118
- })),
119
- fills: (pcb?.fills || []).map((fill) => {
120
- const y1 = mirrorY(fill.y1)
121
- const y2 = mirrorY(fill.y2)
122
-
123
- return {
124
- ...fill,
125
- y1: Math.min(y1, y2),
126
- y2: Math.max(y1, y2)
127
- }
128
- }),
129
- tracks: (pcb?.tracks || []).map((track) => ({
130
- ...track,
131
- y1: mirrorY(track.y1),
132
- y2: mirrorY(track.y2)
133
- })),
134
- arcs: (pcb?.arcs || []).map((arc) => ({
135
- ...arc,
136
- y: mirrorY(arc.y),
137
- startAngle: PcbOutlineRecovery.#normalizeAngle(
138
- 360 - Number(arc.startAngle || 0)
139
- ),
140
- endAngle: PcbOutlineRecovery.#normalizeAngle(
141
- 360 - Number(arc.endAngle || 0)
142
- )
143
- })),
144
- vias: (pcb?.vias || []).map((via) => ({
145
- ...via,
146
- y: mirrorY(via.y)
147
- })),
148
- pads: (pcb?.pads || []).map((pad) => ({
149
- ...pad,
150
- y: mirrorY(pad.y),
151
- rotation: PcbOutlineRecovery.#normalizeAngle(
152
- 360 - Number(pad.rotation || 0)
153
- ),
154
- holeRotation:
155
- pad?.holeRotation === null ||
156
- pad?.holeRotation === undefined
157
- ? (pad?.holeRotation ?? null)
158
- : PcbOutlineRecovery.#normalizeAngle(
159
- 360 - Number(pad.holeRotation || 0)
160
- )
161
- })),
162
- components: (pcb?.components || []).map((component) => ({
163
- ...component,
164
- y: mirrorY(component.y),
165
- rotation: PcbOutlineRecovery.#normalizeAngle(
166
- 360 - Number(component.rotation || 0)
167
- )
168
- }))
169
- }
100
+ return PcbGeometryFlipper.flipGeometryVertically(pcb)
170
101
  }
171
102
 
172
103
  /**
@@ -915,43 +846,4 @@ export class PcbOutlineRecovery {
915
846
 
916
847
  return delta
917
848
  }
918
-
919
- /**
920
- * Mirrors one outline or polygon segment across the board Y axis.
921
- * @param {Record<string, number | string>} segment
922
- * @param {(value: number) => number} mirrorY
923
- * @returns {Record<string, number | string>}
924
- */
925
- static #flipSegment(segment, mirrorY) {
926
- if (segment.type !== 'arc') {
927
- return {
928
- ...segment,
929
- y1: mirrorY(Number(segment.y1 || 0)),
930
- y2: mirrorY(Number(segment.y2 || 0))
931
- }
932
- }
933
-
934
- const startAngle = Number(segment.startAngle || 0)
935
- const endAngle = Number(segment.endAngle || 0)
936
-
937
- return {
938
- ...segment,
939
- y1: mirrorY(Number(segment.y1 || 0)),
940
- y2: mirrorY(Number(segment.y2 || 0)),
941
- cy: mirrorY(Number(segment.cy || 0)),
942
- startAngle: PcbOutlineRecovery.#normalizeAngle(360 - startAngle),
943
- endAngle: PcbOutlineRecovery.#normalizeAngle(360 - endAngle)
944
- }
945
- }
946
-
947
- /**
948
- * Normalizes one circular angle into the [0, 360) range.
949
- * @param {number} angle
950
- * @returns {number}
951
- */
952
- static #normalizeAngle(angle) {
953
- const normalized = Number(angle || 0) % 360
954
-
955
- return normalized < 0 ? normalized + 360 : normalized
956
- }
957
849
  }
@@ -0,0 +1,347 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { PcbLayerIdCodec } from './PcbLayerIdCodec.mjs'
6
+ import { PcbPadShapeCodec } from './PcbPadShapeCodec.mjs'
7
+ import { PcbPadStackParser } from './PcbPadStackParser.mjs'
8
+ import { PcbPrimitiveOwnershipIndexParser } from './PcbPrimitiveOwnershipIndexParser.mjs'
9
+
10
+ /**
11
+ * Decodes Altium pad primitive streams.
12
+ */
13
+ export class PcbPadPrimitiveParser {
14
+ static #PAD_OBJECT_ID = 2
15
+
16
+ static #PAD_SUBRECORD_COUNT = 6
17
+
18
+ static #PAD_MAIN_SUBRECORD_INDEX = 4
19
+
20
+ static #PAD_EXTENSION_SUBRECORD_INDEX = 5
21
+
22
+ static #PAD_MAIN_RECORD_MIN_BYTE_LENGTH = 61
23
+
24
+ /**
25
+ * Decodes one variable-length pad stream.
26
+ * @param {Uint8Array | ArrayBuffer} headerBytes
27
+ * @param {Uint8Array | ArrayBuffer} dataBytes
28
+ * @returns {{ x: number, y: number, sizeTopX: number, sizeTopY: number, sizeMidX: number, sizeMidY: number, sizeBottomX: number, sizeBottomY: number, holeDiameter: number, shapeTop: number, shapeMid: number, shapeBottom: number, shapeTopName: string | null, shapeMidName: string | null, shapeBottomName: string | null, padShapeNames: { top: string | null, middle: string | null, bottom: string | null }, rotation: number, isPlated: boolean, componentIndex: number | null, netIndex: number | null, polygonIndex: number | null, layerCode: number | null, layerId: number | null, legacyLayerId: number | null, layerV7SaveId: number | null, [key: string]: unknown }[]}
29
+ */
30
+ static parsePadStream(headerBytes, dataBytes) {
31
+ const count = PcbPadPrimitiveParser.#readRecordCount(headerBytes)
32
+ const normalizedData = PcbPadPrimitiveParser.#toUint8Array(dataBytes)
33
+
34
+ if (!count) {
35
+ return []
36
+ }
37
+
38
+ let offset = 0
39
+ const pads = []
40
+
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
+ }
50
+
51
+ const pad = PcbPadPrimitiveParser.#parsePadSubrecords(
52
+ record.subrecords
53
+ )
54
+
55
+ if (!pad) {
56
+ return []
57
+ }
58
+
59
+ 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 []
72
+ }
73
+
74
+ offset = nextOffset
75
+ }
76
+ }
77
+
78
+ return pads
79
+ }
80
+
81
+ /**
82
+ * Reads one pad record and its known subrecords from a stream offset.
83
+ * @param {Uint8Array} bytes
84
+ * @param {number} offset
85
+ * @returns {{ subrecords: DataView[], nextOffset: number } | null}
86
+ */
87
+ static #readPadRecordAt(bytes, offset) {
88
+ if (
89
+ offset + 1 > bytes.byteLength ||
90
+ bytes[offset] !== PcbPadPrimitiveParser.#PAD_OBJECT_ID
91
+ ) {
92
+ return null
93
+ }
94
+
95
+ let cursor = offset + 1
96
+ const subrecords = []
97
+
98
+ for (
99
+ let subrecordIndex = 0;
100
+ subrecordIndex < PcbPadPrimitiveParser.#PAD_SUBRECORD_COUNT;
101
+ subrecordIndex += 1
102
+ ) {
103
+ const subrecord = PcbPadPrimitiveParser.#readSubrecordAt(
104
+ bytes,
105
+ cursor
106
+ )
107
+
108
+ if (!subrecord) {
109
+ return null
110
+ }
111
+
112
+ subrecords.push(subrecord.view)
113
+ cursor = subrecord.nextOffset
114
+ }
115
+
116
+ return { subrecords, nextOffset: cursor }
117
+ }
118
+
119
+ /**
120
+ * Reads one length-prefixed pad subrecord.
121
+ * @param {Uint8Array} bytes
122
+ * @param {number} offset
123
+ * @returns {{ view: DataView, nextOffset: number } | null}
124
+ */
125
+ static #readSubrecordAt(bytes, offset) {
126
+ if (offset + 4 > bytes.byteLength) {
127
+ return null
128
+ }
129
+
130
+ const subrecordLength = PcbPadPrimitiveParser.#readUint32FromBytes(
131
+ bytes,
132
+ offset
133
+ )
134
+ const payloadOffset = offset + 4
135
+ const nextOffset = payloadOffset + subrecordLength
136
+
137
+ if (nextOffset > bytes.byteLength) {
138
+ return null
139
+ }
140
+
141
+ return {
142
+ view: new DataView(
143
+ bytes.buffer,
144
+ bytes.byteOffset + payloadOffset,
145
+ subrecordLength
146
+ ),
147
+ nextOffset
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Finds the next pad record boundary after optional unknown subrecords.
153
+ * @param {Uint8Array} bytes
154
+ * @param {number} offset
155
+ * @param {number} remainingCount
156
+ * @returns {number | null}
157
+ */
158
+ static #findNextPadRecordOffset(bytes, offset, remainingCount) {
159
+ let cursor = offset
160
+
161
+ while (cursor < bytes.byteLength) {
162
+ if (
163
+ PcbPadPrimitiveParser.#canReadPadRecordSequence(
164
+ bytes,
165
+ cursor,
166
+ remainingCount
167
+ )
168
+ ) {
169
+ return cursor
170
+ }
171
+
172
+ const unknownSubrecord = PcbPadPrimitiveParser.#readSubrecordAt(
173
+ bytes,
174
+ cursor
175
+ )
176
+
177
+ if (!unknownSubrecord) {
178
+ return null
179
+ }
180
+
181
+ cursor = unknownSubrecord.nextOffset
182
+ }
183
+
184
+ return null
185
+ }
186
+
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
+ /**
215
+ * Decodes one pad payload from its subrecords.
216
+ * @param {DataView[]} subrecords
217
+ * @returns {{ x: number, y: number, sizeTopX: number, sizeTopY: number, sizeMidX: number, sizeMidY: number, sizeBottomX: number, sizeBottomY: number, holeDiameter: number, shapeTop: number, shapeMid: number, shapeBottom: number, shapeTopName: string | null, shapeMidName: string | null, shapeBottomName: string | null, padShapeNames: { top: string | null, middle: string | null, bottom: string | null }, rotation: number, isPlated: boolean, componentIndex: number | null, netIndex: number | null, polygonIndex: number | null, layerCode: number | null, layerId: number | null, legacyLayerId: number | null, layerV7SaveId: number | null, [key: string]: unknown } | null}
218
+ */
219
+ static #parsePadSubrecords(subrecords) {
220
+ const mainRecord =
221
+ subrecords[PcbPadPrimitiveParser.#PAD_MAIN_SUBRECORD_INDEX]
222
+ const extensionRecord =
223
+ subrecords[PcbPadPrimitiveParser.#PAD_EXTENSION_SUBRECORD_INDEX]
224
+
225
+ if (
226
+ !mainRecord ||
227
+ mainRecord.byteLength <
228
+ PcbPadPrimitiveParser.#PAD_MAIN_RECORD_MIN_BYTE_LENGTH
229
+ ) {
230
+ return null
231
+ }
232
+
233
+ const layerState = PcbPadPrimitiveParser.#parsePadLayerState(mainRecord)
234
+ const ownershipIndexes =
235
+ PcbPrimitiveOwnershipIndexParser.readOwnershipIndexes(mainRecord, {
236
+ component: 7,
237
+ net: 3,
238
+ polygon: 5
239
+ })
240
+
241
+ const pad = {
242
+ x: PcbPadPrimitiveParser.#readMil(mainRecord, 13),
243
+ y: PcbPadPrimitiveParser.#readMil(mainRecord, 17),
244
+ sizeTopX: PcbPadPrimitiveParser.#readMil(mainRecord, 21),
245
+ sizeTopY: PcbPadPrimitiveParser.#readMil(mainRecord, 25),
246
+ sizeMidX: PcbPadPrimitiveParser.#readMil(mainRecord, 29),
247
+ sizeMidY: PcbPadPrimitiveParser.#readMil(mainRecord, 33),
248
+ sizeBottomX: PcbPadPrimitiveParser.#readMil(mainRecord, 37),
249
+ sizeBottomY: PcbPadPrimitiveParser.#readMil(mainRecord, 41),
250
+ holeDiameter: PcbPadPrimitiveParser.#readMil(mainRecord, 45),
251
+ shapeTop: mainRecord.getUint8(49),
252
+ shapeMid: mainRecord.getUint8(50),
253
+ shapeBottom: mainRecord.getUint8(51),
254
+ rotation: mainRecord.getFloat64(52, true),
255
+ isPlated: mainRecord.getUint8(60) !== 0,
256
+ ...ownershipIndexes,
257
+ layerCode: layerState.layerId,
258
+ layerId: layerState.layerId,
259
+ legacyLayerId: layerState.legacyLayerId,
260
+ layerV7SaveId: layerState.layerV7SaveId
261
+ }
262
+
263
+ return {
264
+ ...pad,
265
+ ...PcbPadShapeCodec.describePadShapes(pad),
266
+ ...PcbPadStackParser.parse(mainRecord, extensionRecord, pad)
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Decodes the visible and hidden saved-layer state from one pad main record.
272
+ * @param {DataView} mainRecord
273
+ * @returns {{ layerId: number | null, legacyLayerId: number | null, layerV7SaveId: number | null }}
274
+ */
275
+ static #parsePadLayerState(mainRecord) {
276
+ const legacyLayerId = mainRecord.getUint8(0) || null
277
+ const layerV7SaveId =
278
+ mainRecord.byteLength >= 118
279
+ ? mainRecord.getUint32(114, true) || null
280
+ : null
281
+ const decodedLayerId =
282
+ PcbLayerIdCodec.legacyLayerIdFromV7SaveId(layerV7SaveId)
283
+
284
+ return {
285
+ layerId: decodedLayerId || legacyLayerId,
286
+ legacyLayerId,
287
+ layerV7SaveId
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Reads one standard fixed-point mil value.
293
+ * @param {DataView} view
294
+ * @param {number} offset
295
+ * @returns {number}
296
+ */
297
+ static #readMil(view, offset) {
298
+ return view.getInt32(offset, true) / 10000
299
+ }
300
+
301
+ /**
302
+ * Reads the little-endian record count from one stream header.
303
+ * @param {Uint8Array | ArrayBuffer} headerBytes
304
+ * @returns {number}
305
+ */
306
+ static #readRecordCount(headerBytes) {
307
+ const normalizedHeader =
308
+ PcbPadPrimitiveParser.#toUint8Array(headerBytes)
309
+
310
+ if (normalizedHeader.byteLength < 4) {
311
+ return 0
312
+ }
313
+
314
+ return new DataView(
315
+ normalizedHeader.buffer,
316
+ normalizedHeader.byteOffset,
317
+ 4
318
+ ).getUint32(0, true)
319
+ }
320
+
321
+ /**
322
+ * Reads one little-endian unsigned 32-bit value from a byte array.
323
+ * @param {Uint8Array} bytes
324
+ * @param {number} offset
325
+ * @returns {number}
326
+ */
327
+ static #readUint32FromBytes(bytes, offset) {
328
+ return new DataView(
329
+ bytes.buffer,
330
+ bytes.byteOffset + offset,
331
+ 4
332
+ ).getUint32(0, true)
333
+ }
334
+
335
+ /**
336
+ * Converts an ArrayBuffer or view into a Uint8Array view.
337
+ * @param {Uint8Array | ArrayBuffer} bytes
338
+ * @returns {Uint8Array}
339
+ */
340
+ static #toUint8Array(bytes) {
341
+ if (bytes instanceof Uint8Array) {
342
+ return bytes
343
+ }
344
+
345
+ return new Uint8Array(bytes)
346
+ }
347
+ }
@@ -0,0 +1,158 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Maps Altium pad, drill, and pad-stack mode codes to stable model labels.
7
+ */
8
+ export class PcbPadShapeCodec {
9
+ static #PAD_SHAPE_NAMES = new Map([
10
+ [0, 'none'],
11
+ [1, 'round'],
12
+ [2, 'rectangular'],
13
+ [3, 'octagonal'],
14
+ [9, 'rounded-rectangle']
15
+ ])
16
+
17
+ static #HOLE_SHAPE_NAMES = new Map([
18
+ [-1, 'none'],
19
+ [0, 'round'],
20
+ [1, 'square'],
21
+ [2, 'slot']
22
+ ])
23
+
24
+ static #PAD_MODE_NAMES = new Map([
25
+ [0, 'simple'],
26
+ [1, 'top-middle-bottom'],
27
+ [2, 'full-stack']
28
+ ])
29
+
30
+ /**
31
+ * Returns a stable normalized label for one raw pad shape code.
32
+ * @param {number | null | undefined} shape
33
+ * @returns {string | null}
34
+ */
35
+ static padShapeName(shape) {
36
+ return PcbPadShapeCodec.#mappedCodeName(
37
+ shape,
38
+ PcbPadShapeCodec.#PAD_SHAPE_NAMES
39
+ )
40
+ }
41
+
42
+ /**
43
+ * Returns a stable normalized label for one raw drill-hole shape code.
44
+ * @param {number | null | undefined} shape
45
+ * @returns {string | null}
46
+ */
47
+ static holeShapeName(shape) {
48
+ return PcbPadShapeCodec.#mappedCodeName(
49
+ shape,
50
+ PcbPadShapeCodec.#HOLE_SHAPE_NAMES
51
+ )
52
+ }
53
+
54
+ /**
55
+ * Returns a stable normalized label for one raw pad stack mode code.
56
+ * @param {number | null | undefined} mode
57
+ * @returns {string | null}
58
+ */
59
+ static padModeName(mode) {
60
+ return PcbPadShapeCodec.#mappedCodeName(
61
+ mode,
62
+ PcbPadShapeCodec.#PAD_MODE_NAMES
63
+ )
64
+ }
65
+
66
+ /**
67
+ * Builds normalized top/middle/bottom shape labels for one pad.
68
+ * @param {{ shapeTop?: number, shapeMid?: number, shapeBottom?: number }} pad
69
+ * @returns {{ shapeTopName: string | null, shapeMidName: string | null, shapeBottomName: string | null, padShapeNames: { top: string | null, middle: string | null, bottom: string | null } }}
70
+ */
71
+ static describePadShapes(pad) {
72
+ const top = PcbPadShapeCodec.padShapeName(pad.shapeTop)
73
+ const middle = PcbPadShapeCodec.padShapeName(pad.shapeMid)
74
+ const bottom = PcbPadShapeCodec.padShapeName(pad.shapeBottom)
75
+
76
+ return {
77
+ shapeTopName: top,
78
+ shapeMidName: middle,
79
+ shapeBottomName: bottom,
80
+ padShapeNames: {
81
+ top,
82
+ middle,
83
+ bottom
84
+ }
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Adds normalized names and an effective fallback to one middle-layer shape.
90
+ * @param {number} shape
91
+ * @param {number | null | undefined} fallbackShape
92
+ * @returns {{ shape: number, shapeName: string | null, effectiveShape: number, effectiveShapeName: string | null }}
93
+ */
94
+ static describeMiddleLayerShape(shape, fallbackShape) {
95
+ const effectiveShape =
96
+ Number(shape) === 0 && Number.isFinite(Number(fallbackShape))
97
+ ? Number(fallbackShape)
98
+ : Number(shape)
99
+
100
+ return {
101
+ shape: Number(shape),
102
+ shapeName: PcbPadShapeCodec.padShapeName(shape),
103
+ effectiveShape,
104
+ effectiveShapeName: PcbPadShapeCodec.padShapeName(effectiveShape)
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Builds normalized slot or drill geometry for one parsed pad hole.
110
+ * @param {{ shape: number | null, diameter: number, slotLength: number | null, rotation: number | null }} hole
111
+ * @returns {{ shape: number, shapeName: string | null, diameter: number, slotLength: number | null, rotation: number | null, length: number, width: number } | null}
112
+ */
113
+ static describeHoleGeometry(hole) {
114
+ if (hole.shape === null || hole.shape === undefined) {
115
+ return null
116
+ }
117
+
118
+ const diameter = Number(hole.diameter || 0)
119
+ const slotLength = Number(hole.slotLength || 0) || null
120
+ const length =
121
+ PcbPadShapeCodec.holeShapeName(hole.shape) === 'slot'
122
+ ? Math.max(Number(slotLength || 0), diameter)
123
+ : diameter
124
+
125
+ return {
126
+ shape: Number(hole.shape),
127
+ shapeName: PcbPadShapeCodec.holeShapeName(hole.shape),
128
+ diameter,
129
+ slotLength,
130
+ rotation: hole.rotation ?? null,
131
+ length,
132
+ width: diameter
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Resolves a code through a mapping table while preserving unknown values.
138
+ * @param {number | null | undefined} code
139
+ * @param {Map<number, string>} mapping
140
+ * @returns {string | null}
141
+ */
142
+ static #mappedCodeName(code, mapping) {
143
+ if (code === null || code === undefined) {
144
+ return null
145
+ }
146
+
147
+ const numericCode = Number(code)
148
+ if (!Number.isFinite(numericCode)) {
149
+ return null
150
+ }
151
+
152
+ if (mapping.has(numericCode)) {
153
+ return mapping.get(numericCode) ?? null
154
+ }
155
+
156
+ return `unknown-${numericCode}`
157
+ }
158
+ }