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,60 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Decodes nullable owner indexes shared by binary PCB primitive records.
7
+ */
8
+ export class PcbPrimitiveOwnershipIndexParser {
9
+ /**
10
+ * Reads component, net, and polygon ownership indexes from one record.
11
+ * @param {DataView} view
12
+ * @param {{ component: number, net: number, polygon: number }} offsets
13
+ * @returns {{ componentIndex: number | null, netIndex: number | null, polygonIndex: number | null }}
14
+ */
15
+ static readOwnershipIndexes(view, offsets) {
16
+ return {
17
+ componentIndex: PcbPrimitiveOwnershipIndexParser.readComponentIndex(
18
+ view,
19
+ offsets.component
20
+ ),
21
+ netIndex: PcbPrimitiveOwnershipIndexParser.readLinkIndex(
22
+ view,
23
+ offsets.net
24
+ ),
25
+ polygonIndex: PcbPrimitiveOwnershipIndexParser.readLinkIndex(
26
+ view,
27
+ offsets.polygon
28
+ )
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Reads one nullable component index.
34
+ * @param {DataView} view
35
+ * @param {number} offset
36
+ * @returns {number | null}
37
+ */
38
+ static readComponentIndex(view, offset) {
39
+ return PcbPrimitiveOwnershipIndexParser.readLinkIndex(view, offset)
40
+ }
41
+
42
+ /**
43
+ * Reads one nullable two-byte Altium object link index.
44
+ * @param {DataView} view
45
+ * @param {number} offset
46
+ * @returns {number | null}
47
+ */
48
+ static readLinkIndex(view, offset) {
49
+ if (
50
+ !Number.isInteger(offset) ||
51
+ offset < 0 ||
52
+ offset + 2 > view.byteLength
53
+ ) {
54
+ return null
55
+ }
56
+
57
+ const value = view.getUint16(offset, true)
58
+ return value === 0xffff ? null : value
59
+ }
60
+ }
@@ -0,0 +1,212 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Decodes Altium PrimitiveParameters/Data sidecar records.
7
+ */
8
+ export class PcbPrimitiveParameterParser {
9
+ /**
10
+ * Parses length-prefixed primitive parameter records.
11
+ * @param {Uint8Array | ArrayBuffer | undefined} dataBytes
12
+ * @returns {{ groups: { primitiveId: string, id: string, appurtenance: string, variantGuid: string, declaredCount: number | null, parameters: Record<string, string>, records: Record<string, string>[] }[], byPrimitiveId: Record<string, Record<string, string>> }}
13
+ */
14
+ static parse(dataBytes) {
15
+ const bytes = PcbPrimitiveParameterParser.#toUint8Array(dataBytes)
16
+ const groups = []
17
+ const groupsByPrimitiveId = new Map()
18
+ let currentGroup = null
19
+ let offset = 0
20
+
21
+ while (offset + 4 <= bytes.byteLength) {
22
+ const recordLength = PcbPrimitiveParameterParser.#readUint32(
23
+ bytes,
24
+ offset
25
+ )
26
+ offset += 4
27
+
28
+ if (recordLength < 0 || offset + recordLength > bytes.byteLength) {
29
+ break
30
+ }
31
+
32
+ const recordBytes = bytes.subarray(offset, offset + recordLength)
33
+ offset += recordLength
34
+
35
+ const fields =
36
+ PcbPrimitiveParameterParser.#parseRecordFields(recordBytes)
37
+ if (!Object.keys(fields).length) {
38
+ continue
39
+ }
40
+
41
+ const primitiveId = fields.PRIMITIVEID || ''
42
+ if (primitiveId) {
43
+ currentGroup = PcbPrimitiveParameterParser.#groupForPrimitiveId(
44
+ primitiveId,
45
+ fields,
46
+ groups,
47
+ groupsByPrimitiveId
48
+ )
49
+ currentGroup.records.push(fields)
50
+ continue
51
+ }
52
+
53
+ if (currentGroup) {
54
+ PcbPrimitiveParameterParser.#appendParameterRecord(
55
+ currentGroup,
56
+ fields
57
+ )
58
+ }
59
+ }
60
+
61
+ return {
62
+ groups,
63
+ byPrimitiveId:
64
+ PcbPrimitiveParameterParser.#buildPrimitiveParameterLookup(
65
+ groups
66
+ )
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Returns an existing group or creates one for a primitive unique ID.
72
+ * @param {string} primitiveId
73
+ * @param {Record<string, string>} fields
74
+ * @param {{ primitiveId: string, id: string, appurtenance: string, variantGuid: string, declaredCount: number | null, parameters: Record<string, string>, records: Record<string, string>[] }[]} groups
75
+ * @param {Map<string, { primitiveId: string, id: string, appurtenance: string, variantGuid: string, declaredCount: number | null, parameters: Record<string, string>, records: Record<string, string>[] }>} groupsByPrimitiveId
76
+ * @returns {{ primitiveId: string, id: string, appurtenance: string, variantGuid: string, declaredCount: number | null, parameters: Record<string, string>, records: Record<string, string>[] }}
77
+ */
78
+ static #groupForPrimitiveId(
79
+ primitiveId,
80
+ fields,
81
+ groups,
82
+ groupsByPrimitiveId
83
+ ) {
84
+ if (!groupsByPrimitiveId.has(primitiveId)) {
85
+ const group = {
86
+ primitiveId,
87
+ id: '',
88
+ appurtenance: '',
89
+ variantGuid: '',
90
+ declaredCount: null,
91
+ parameters: {},
92
+ records: []
93
+ }
94
+ groupsByPrimitiveId.set(primitiveId, group)
95
+ groups.push(group)
96
+ }
97
+
98
+ const group = groupsByPrimitiveId.get(primitiveId)
99
+ PcbPrimitiveParameterParser.#mergeGroupFields(group, fields)
100
+
101
+ return group
102
+ }
103
+
104
+ /**
105
+ * Merges group-level metadata fields into one primitive parameter group.
106
+ * @param {{ id: string, appurtenance: string, variantGuid: string, declaredCount: number | null }} group
107
+ * @param {Record<string, string>} fields
108
+ */
109
+ static #mergeGroupFields(group, fields) {
110
+ group.id = fields.ID || group.id
111
+ group.appurtenance = fields.APPURTENANCE || group.appurtenance
112
+ group.variantGuid = fields.VARIANTGUID || group.variantGuid
113
+
114
+ const declaredCount = Number(fields.COUNT)
115
+ if (Number.isInteger(declaredCount) && declaredCount > 0) {
116
+ group.declaredCount = declaredCount
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Appends one name/value parameter record to a group.
122
+ * @param {{ parameters: Record<string, string>, records: Record<string, string>[] }} group
123
+ * @param {Record<string, string>} fields
124
+ */
125
+ static #appendParameterRecord(group, fields) {
126
+ const name = fields.NAME || ''
127
+
128
+ if (name) {
129
+ group.parameters[name] = fields.VALUE || ''
130
+ }
131
+
132
+ group.records.push(fields)
133
+ }
134
+
135
+ /**
136
+ * Builds a plain-object lookup keyed by primitive unique ID.
137
+ * @param {{ primitiveId: string, parameters: Record<string, string> }[]} groups
138
+ * @returns {Record<string, Record<string, string>>}
139
+ */
140
+ static #buildPrimitiveParameterLookup(groups) {
141
+ const byPrimitiveId = {}
142
+
143
+ for (const group of groups) {
144
+ byPrimitiveId[group.primitiveId] = { ...group.parameters }
145
+ }
146
+
147
+ return byPrimitiveId
148
+ }
149
+
150
+ /**
151
+ * Parses one pipe-delimited primitive parameter record.
152
+ * @param {Uint8Array} bytes
153
+ * @returns {Record<string, string>}
154
+ */
155
+ static #parseRecordFields(bytes) {
156
+ const text = new TextDecoder()
157
+ .decode(bytes)
158
+ .replace(/\u0000/gu, '')
159
+ .replace(/\r\n?/gu, '\n')
160
+ .trim()
161
+ const fields = {}
162
+
163
+ for (const segment of text.split('|')) {
164
+ const candidate = segment.trim()
165
+ const separatorIndex = candidate.indexOf('=')
166
+
167
+ if (separatorIndex <= 0) {
168
+ continue
169
+ }
170
+
171
+ const key = candidate.slice(0, separatorIndex).trim()
172
+ if (!key) {
173
+ continue
174
+ }
175
+
176
+ fields[key] = candidate.slice(separatorIndex + 1).trim()
177
+ }
178
+
179
+ return fields
180
+ }
181
+
182
+ /**
183
+ * Reads one little-endian unsigned integer from a byte view.
184
+ * @param {Uint8Array} bytes
185
+ * @param {number} offset
186
+ * @returns {number}
187
+ */
188
+ static #readUint32(bytes, offset) {
189
+ return new DataView(
190
+ bytes.buffer,
191
+ bytes.byteOffset + offset,
192
+ 4
193
+ ).getUint32(0, true)
194
+ }
195
+
196
+ /**
197
+ * Normalizes one byte-like input into a Uint8Array view.
198
+ * @param {Uint8Array | ArrayBuffer | undefined} bytes
199
+ * @returns {Uint8Array}
200
+ */
201
+ static #toUint8Array(bytes) {
202
+ if (!bytes) {
203
+ return new Uint8Array(0)
204
+ }
205
+
206
+ if (bytes instanceof Uint8Array) {
207
+ return bytes
208
+ }
209
+
210
+ return new Uint8Array(bytes)
211
+ }
212
+ }
@@ -0,0 +1,243 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Splits mixed-format PCB primitive streams.
7
+ */
8
+ export class PcbPrimitiveRecordSlicer {
9
+ /**
10
+ * Splits a primitive stream, preferring object-id/length-prefixed records
11
+ * and falling back to legacy fixed-layout records.
12
+ * @param {{ headerBytes: Uint8Array | ArrayBuffer, dataBytes: Uint8Array | ArrayBuffer, objectId: number, fixedRecordByteLength: number, minimumPayloadByteLength: number, lengthPrefixedView?: 'payload' | 'record' }} options
13
+ * @returns {{ view: DataView, viewBytes: Uint8Array, recordBytes: Uint8Array, offset: number, byteLength: number, payloadByteLength: number | null, encoding: 'length-prefixed' | 'fixed', objectId: number | null, recordIndex: number }[]}
14
+ */
15
+ static slicePrimitiveRecords(options) {
16
+ const lengthPrefixedRecords =
17
+ PcbPrimitiveRecordSlicer.#sliceLengthPrefixedRecords(options)
18
+
19
+ if (lengthPrefixedRecords.length) {
20
+ return lengthPrefixedRecords
21
+ }
22
+
23
+ return PcbPrimitiveRecordSlicer.#sliceFixedRecords(
24
+ options.headerBytes,
25
+ options.dataBytes,
26
+ options.fixedRecordByteLength
27
+ )
28
+ }
29
+
30
+ /**
31
+ * Splits object-id/length-prefixed records when the full stream matches
32
+ * the expected object id, count, payload lengths, and byte length exactly.
33
+ * @param {{ headerBytes: Uint8Array | ArrayBuffer, dataBytes: Uint8Array | ArrayBuffer, objectId: number, minimumPayloadByteLength: number, lengthPrefixedView?: 'payload' | 'record' }} options
34
+ * @returns {{ view: DataView, viewBytes: Uint8Array, recordBytes: Uint8Array, offset: number, byteLength: number, payloadByteLength: number | null, encoding: 'length-prefixed' | 'fixed', objectId: number | null, recordIndex: number }[]}
35
+ */
36
+ static #sliceLengthPrefixedRecords(options) {
37
+ const normalizedData = PcbPrimitiveRecordSlicer.#toUint8Array(
38
+ options.dataBytes
39
+ )
40
+ const count = PcbPrimitiveRecordSlicer.#readRecordCount(
41
+ options.headerBytes
42
+ )
43
+
44
+ if (!count) {
45
+ return []
46
+ }
47
+
48
+ let offset = 0
49
+ const records = []
50
+
51
+ for (let index = 0; index < count; index += 1) {
52
+ const record = PcbPrimitiveRecordSlicer.#readLengthPrefixedRecordAt(
53
+ normalizedData,
54
+ offset,
55
+ options
56
+ )
57
+
58
+ if (!record) {
59
+ return []
60
+ }
61
+
62
+ records.push({ ...record, recordIndex: index })
63
+ offset += record.byteLength
64
+ }
65
+
66
+ return offset === normalizedData.byteLength ? records : []
67
+ }
68
+
69
+ /**
70
+ * Reads one length-prefixed record at a byte offset.
71
+ * @param {Uint8Array} bytes
72
+ * @param {number} offset
73
+ * @param {{ objectId: number, minimumPayloadByteLength: number, lengthPrefixedView?: 'payload' | 'record' }} options
74
+ * @returns {{ view: DataView, viewBytes: Uint8Array, recordBytes: Uint8Array, offset: number, byteLength: number, payloadByteLength: number | null, encoding: 'length-prefixed', objectId: number, recordIndex: number } | null}
75
+ */
76
+ static #readLengthPrefixedRecordAt(bytes, offset, options) {
77
+ if (
78
+ offset + 5 > bytes.byteLength ||
79
+ bytes[offset] !== options.objectId
80
+ ) {
81
+ return null
82
+ }
83
+
84
+ const payloadLength = PcbPrimitiveRecordSlicer.#readUint32FromBytes(
85
+ bytes,
86
+ offset + 1
87
+ )
88
+ const byteLength = 5 + payloadLength
89
+
90
+ if (
91
+ payloadLength < options.minimumPayloadByteLength ||
92
+ offset + byteLength > bytes.byteLength
93
+ ) {
94
+ return null
95
+ }
96
+
97
+ const viewOffset =
98
+ options.lengthPrefixedView === 'record' ? offset : offset + 5
99
+ const viewByteLength =
100
+ options.lengthPrefixedView === 'record' ? byteLength : payloadLength
101
+
102
+ return PcbPrimitiveRecordSlicer.#createRecord(
103
+ bytes,
104
+ offset,
105
+ viewOffset,
106
+ viewByteLength,
107
+ byteLength,
108
+ {
109
+ encoding: 'length-prefixed',
110
+ objectId: options.objectId,
111
+ payloadByteLength: payloadLength
112
+ }
113
+ )
114
+ }
115
+
116
+ /**
117
+ * Splits one legacy fixed-layout primitive stream into record views.
118
+ * @param {Uint8Array | ArrayBuffer} headerBytes
119
+ * @param {Uint8Array | ArrayBuffer} dataBytes
120
+ * @param {number} recordByteLength
121
+ * @returns {{ view: DataView, viewBytes: Uint8Array, recordBytes: Uint8Array, offset: number, byteLength: number, payloadByteLength: number | null, encoding: 'fixed', objectId: null, recordIndex: number }[]}
122
+ */
123
+ static #sliceFixedRecords(headerBytes, dataBytes, recordByteLength) {
124
+ const normalizedData = PcbPrimitiveRecordSlicer.#toUint8Array(dataBytes)
125
+ const count = PcbPrimitiveRecordSlicer.#readRecordCount(headerBytes)
126
+
127
+ if (!count) {
128
+ return []
129
+ }
130
+
131
+ if (normalizedData.byteLength < count * recordByteLength) {
132
+ return []
133
+ }
134
+
135
+ const records = []
136
+
137
+ for (let index = 0; index < count; index += 1) {
138
+ records.push(
139
+ PcbPrimitiveRecordSlicer.#createRecord(
140
+ normalizedData,
141
+ index * recordByteLength,
142
+ index * recordByteLength,
143
+ recordByteLength,
144
+ recordByteLength,
145
+ {
146
+ encoding: 'fixed',
147
+ objectId: null,
148
+ payloadByteLength: null,
149
+ recordIndex: index
150
+ }
151
+ )
152
+ )
153
+ }
154
+
155
+ return records
156
+ }
157
+
158
+ /**
159
+ * Creates one record view tuple over a source byte array.
160
+ * @param {Uint8Array} bytes
161
+ * @param {number} recordOffset
162
+ * @param {number} viewOffset
163
+ * @param {number} viewByteLength
164
+ * @param {number} byteLength
165
+ * @param {{ encoding: 'length-prefixed' | 'fixed', objectId: number | null, payloadByteLength: number | null, recordIndex?: number }} metadata
166
+ * @returns {{ view: DataView, viewBytes: Uint8Array, recordBytes: Uint8Array, offset: number, byteLength: number, payloadByteLength: number | null, encoding: 'length-prefixed' | 'fixed', objectId: number | null, recordIndex: number }}
167
+ */
168
+ static #createRecord(
169
+ bytes,
170
+ recordOffset,
171
+ viewOffset,
172
+ viewByteLength,
173
+ byteLength,
174
+ metadata
175
+ ) {
176
+ return {
177
+ view: new DataView(
178
+ bytes.buffer,
179
+ bytes.byteOffset + viewOffset,
180
+ viewByteLength
181
+ ),
182
+ viewBytes: new Uint8Array(
183
+ bytes.buffer,
184
+ bytes.byteOffset + viewOffset,
185
+ viewByteLength
186
+ ),
187
+ recordBytes: new Uint8Array(
188
+ bytes.buffer,
189
+ bytes.byteOffset + recordOffset,
190
+ byteLength
191
+ ),
192
+ offset: recordOffset,
193
+ byteLength,
194
+ payloadByteLength: metadata.payloadByteLength,
195
+ encoding: metadata.encoding,
196
+ objectId: metadata.objectId,
197
+ recordIndex: metadata.recordIndex ?? 0
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Reads one primitive record count from a stream header.
203
+ * @param {Uint8Array | ArrayBuffer} headerBytes
204
+ * @returns {number}
205
+ */
206
+ static #readRecordCount(headerBytes) {
207
+ const normalizedHeader =
208
+ PcbPrimitiveRecordSlicer.#toUint8Array(headerBytes)
209
+
210
+ if (normalizedHeader.byteLength < 4) {
211
+ return 0
212
+ }
213
+
214
+ return new DataView(
215
+ normalizedHeader.buffer,
216
+ normalizedHeader.byteOffset,
217
+ 4
218
+ ).getUint32(0, true)
219
+ }
220
+
221
+ /**
222
+ * Reads one little-endian unsigned 32-bit value from a byte array.
223
+ * @param {Uint8Array} bytes
224
+ * @param {number} offset
225
+ * @returns {number}
226
+ */
227
+ static #readUint32FromBytes(bytes, offset) {
228
+ return new DataView(
229
+ bytes.buffer,
230
+ bytes.byteOffset + offset,
231
+ 4
232
+ ).getUint32(0, true)
233
+ }
234
+
235
+ /**
236
+ * Normalizes a byte source to Uint8Array.
237
+ * @param {Uint8Array | ArrayBuffer} bytes
238
+ * @returns {Uint8Array}
239
+ */
240
+ static #toUint8Array(bytes) {
241
+ return bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)
242
+ }
243
+ }