altium-toolkit 0.1.1 → 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 +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
@@ -0,0 +1,537 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Decodes Altium PCB text primitive streams.
7
+ */
8
+ export class PcbTextPrimitiveParser {
9
+ static #TEXT_OBJECT_ID = 5
10
+
11
+ static #TEXT_RECORD_MIN_BYTE_LENGTH = 64
12
+
13
+ static #TEXT_RECORD_MAX_BYTE_LENGTH = 2048
14
+
15
+ /**
16
+ * Decodes one variable-length PCB text stream.
17
+ * @param {Uint8Array | ArrayBuffer} headerBytes
18
+ * @param {Uint8Array | ArrayBuffer} dataBytes
19
+ * @param {{ wideStrings?: Map<number | string, string> | Record<string, string> | { byIndex?: Record<string, string> } }} [options]
20
+ * @returns {{ text: string, x: number, y: number, height: number, layerId: number, ownerIndex: number | null, kind: number, visibilityFlags: number, rotation: number, strokeFontType?: number, strokeWidth?: number, fontType?: number, fontTypeName?: string, fontName?: string, fontFamily?: string, isBold?: boolean, isItalic?: boolean, fontWeight?: number, fontStyle?: string, wideStringIndex?: number, textSource?: string, role?: string, isDesignator?: boolean, isComment?: boolean, isPlaceholder?: boolean, componentIndex?: number }[]}
21
+ */
22
+ static parseTextStream(headerBytes, dataBytes, options = {}) {
23
+ const count = PcbTextPrimitiveParser.#readRecordCount(headerBytes)
24
+ const normalizedData = PcbTextPrimitiveParser.#toUint8Array(dataBytes)
25
+ const wideStrings = PcbTextPrimitiveParser.#normalizeWideStrings(
26
+ options.wideStrings
27
+ )
28
+
29
+ if (!count) {
30
+ return []
31
+ }
32
+
33
+ let offset = 0
34
+ const texts = []
35
+
36
+ for (let index = 0; index < count; index += 1) {
37
+ const record = PcbTextPrimitiveParser.#readTextRecordAt(
38
+ normalizedData,
39
+ offset,
40
+ index === count - 1,
41
+ wideStrings
42
+ )
43
+
44
+ if (!record) {
45
+ return texts
46
+ }
47
+
48
+ if (record.text) {
49
+ texts.push(record.text)
50
+ }
51
+
52
+ offset = record.nextOffset
53
+ }
54
+
55
+ return texts
56
+ }
57
+
58
+ /**
59
+ * Reads one text record and its trailing string bytes at an offset.
60
+ * @param {Uint8Array} bytes
61
+ * @param {number} offset
62
+ * @param {boolean} isLastRecord
63
+ * @param {Map<string, string>} wideStrings
64
+ * @returns {{ text: { text: string, x: number, y: number, height: number, layerId: number, ownerIndex: number | null, kind: number, visibilityFlags: number, rotation: number } | null, nextOffset: number } | null}
65
+ */
66
+ static #readTextRecordAt(bytes, offset, isLastRecord, wideStrings) {
67
+ if (!PcbTextPrimitiveParser.#isTextRecordStart(bytes, offset)) {
68
+ return null
69
+ }
70
+
71
+ const payloadLength = PcbTextPrimitiveParser.#readUint32FromBytes(
72
+ bytes,
73
+ offset + 1
74
+ )
75
+ const payloadOffset = offset + 5
76
+ const payloadEnd = payloadOffset + payloadLength
77
+
78
+ if (payloadEnd > bytes.byteLength) {
79
+ return null
80
+ }
81
+
82
+ const nextOffset = isLastRecord
83
+ ? bytes.byteLength
84
+ : PcbTextPrimitiveParser.#findNextTextRecordOffset(
85
+ bytes,
86
+ payloadEnd
87
+ )
88
+ const text = PcbTextPrimitiveParser.#parseTextRecord(
89
+ new DataView(
90
+ bytes.buffer,
91
+ bytes.byteOffset + payloadOffset,
92
+ payloadLength
93
+ ),
94
+ bytes.slice(payloadEnd, nextOffset),
95
+ wideStrings
96
+ )
97
+
98
+ return {
99
+ text,
100
+ nextOffset
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Parses one fixed PCB text payload and its variable string tail.
106
+ * @param {DataView} payload
107
+ * @param {Uint8Array} textBytes
108
+ * @param {Map<string, string>} wideStrings
109
+ * @returns {{ text: string, x: number, y: number, height: number, layerId: number, ownerIndex: number | null, kind: number, visibilityFlags: number, rotation: number, strokeFontType?: number, strokeWidth?: number, fontType?: number, fontTypeName?: string, fontName?: string, fontFamily?: string, isBold?: boolean, isItalic?: boolean, fontWeight?: number, fontStyle?: string, wideStringIndex?: number, textSource?: string, role?: string, isDesignator?: boolean, isComment?: boolean, isPlaceholder?: boolean, componentIndex?: number } | null}
110
+ */
111
+ static #parseTextRecord(payload, textBytes, wideStrings) {
112
+ if (
113
+ payload.byteLength <
114
+ PcbTextPrimitiveParser.#TEXT_RECORD_MIN_BYTE_LENGTH
115
+ ) {
116
+ return null
117
+ }
118
+
119
+ const extendedText =
120
+ PcbTextPrimitiveParser.#parseExtendedTextFields(payload)
121
+ const resolvedText = PcbTextPrimitiveParser.#resolveTextContent(
122
+ PcbTextPrimitiveParser.#decodeTextBytes(textBytes),
123
+ extendedText.wideStringIndex,
124
+ wideStrings
125
+ )
126
+
127
+ if (!resolvedText.text) {
128
+ return null
129
+ }
130
+
131
+ const ownerIndex = payload.getInt16(7, true)
132
+ const normalizedOwnerIndex = ownerIndex === -1 ? null : ownerIndex
133
+ const hasExtendedFontFields = payload.byteLength >= 110
134
+ const visibilityFlags = hasExtendedFontFields
135
+ ? 0
136
+ : payload.getUint32(41, true)
137
+
138
+ const role = PcbTextPrimitiveParser.#parseTextRole(
139
+ payload,
140
+ normalizedOwnerIndex,
141
+ hasExtendedFontFields
142
+ )
143
+
144
+ return {
145
+ text: resolvedText.text,
146
+ layerId: payload.getUint8(0),
147
+ ownerIndex: normalizedOwnerIndex,
148
+ x: PcbTextPrimitiveParser.#readMil(payload, 13),
149
+ y: PcbTextPrimitiveParser.#readMil(payload, 17),
150
+ height: PcbTextPrimitiveParser.#readMil(payload, 21),
151
+ kind: hasExtendedFontFields
152
+ ? payload.getUint16(25, true)
153
+ : payload.getUint32(25, true),
154
+ visibilityFlags,
155
+ rotation: PcbTextPrimitiveParser.#resolveTextRotation(
156
+ payload,
157
+ visibilityFlags,
158
+ hasExtendedFontFields
159
+ ),
160
+ ...extendedText,
161
+ ...resolvedText.metadata,
162
+ ...role,
163
+ ...PcbTextPrimitiveParser.#parsePlaceholderMetadata(
164
+ resolvedText.text,
165
+ role
166
+ )
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Parses extended TrueType/barcode font metadata when the payload carries
172
+ * the modern PCB text field block.
173
+ * @param {DataView} payload
174
+ * @returns {{ strokeFontType?: number, strokeWidth?: number, fontType?: number, fontTypeName?: string, fontName?: string, fontFamily?: string, isBold?: boolean, isItalic?: boolean, fontWeight?: number, fontStyle?: string, wideStringIndex?: number }}
175
+ */
176
+ static #parseExtendedTextFields(payload) {
177
+ if (payload.byteLength < 110) {
178
+ return {}
179
+ }
180
+
181
+ const fontType = payload.getUint8(43)
182
+ const isBold = payload.getUint8(44) !== 0
183
+ const isItalic = payload.getUint8(45) !== 0
184
+ const fontName = PcbTextPrimitiveParser.#decodeFixedUtf16(
185
+ payload,
186
+ 46,
187
+ 64
188
+ )
189
+ const extendedFields = {
190
+ strokeFontType: payload.getUint16(25, true),
191
+ strokeWidth: PcbTextPrimitiveParser.#readMil(payload, 36),
192
+ fontType,
193
+ fontTypeName: PcbTextPrimitiveParser.#fontTypeName(fontType),
194
+ fontName,
195
+ fontFamily:
196
+ fontName ||
197
+ PcbTextPrimitiveParser.#fallbackFontFamily(fontType),
198
+ isBold,
199
+ isItalic,
200
+ fontWeight: isBold ? 700 : 400,
201
+ fontStyle: isItalic ? 'italic' : 'normal'
202
+ }
203
+
204
+ if (payload.byteLength >= 119) {
205
+ extendedFields.wideStringIndex = payload.getUint32(115, true)
206
+ }
207
+
208
+ return extendedFields
209
+ }
210
+
211
+ /**
212
+ * Decodes one fixed-length UTF-16LE field from a payload view.
213
+ * @param {DataView} payload
214
+ * @param {number} offset
215
+ * @param {number} byteLength
216
+ * @returns {string}
217
+ */
218
+ static #decodeFixedUtf16(payload, offset, byteLength) {
219
+ if (offset + byteLength > payload.byteLength) {
220
+ return ''
221
+ }
222
+
223
+ return new TextDecoder('utf-16le')
224
+ .decode(
225
+ new Uint8Array(
226
+ payload.buffer,
227
+ payload.byteOffset + offset,
228
+ byteLength
229
+ )
230
+ )
231
+ .replace(/\u0000+$/gu, '')
232
+ .trim()
233
+ }
234
+
235
+ /**
236
+ * Resolves inline text first, then WideStrings6 text-table references.
237
+ * @param {string} inlineText
238
+ * @param {number | undefined} wideStringIndex
239
+ * @param {Map<string, string>} wideStrings
240
+ * @returns {{ text: string, metadata: { textSource?: string } }}
241
+ */
242
+ static #resolveTextContent(inlineText, wideStringIndex, wideStrings) {
243
+ if (inlineText) {
244
+ return { text: inlineText, metadata: {} }
245
+ }
246
+
247
+ if (!Number.isInteger(wideStringIndex)) {
248
+ return { text: '', metadata: {} }
249
+ }
250
+
251
+ const wideText = wideStrings.get(String(wideStringIndex)) || ''
252
+
253
+ return wideText
254
+ ? {
255
+ text: wideText,
256
+ metadata: { textSource: 'WideStrings6/Data' }
257
+ }
258
+ : { text: '', metadata: {} }
259
+ }
260
+
261
+ /**
262
+ * Parses explicit modern Texts6 role metadata.
263
+ * @param {DataView} payload
264
+ * @param {number | null} ownerIndex
265
+ * @param {boolean} hasExtendedFontFields
266
+ * @returns {{ role?: string, isDesignator?: boolean, isComment?: boolean, componentIndex?: number }}
267
+ */
268
+ static #parseTextRole(payload, ownerIndex, hasExtendedFontFields) {
269
+ if (
270
+ !hasExtendedFontFields ||
271
+ payload.byteLength < 42 ||
272
+ !Number.isInteger(ownerIndex)
273
+ ) {
274
+ return {}
275
+ }
276
+
277
+ if (payload.getUint8(41) !== 0) {
278
+ return {
279
+ role: 'designator',
280
+ isDesignator: true,
281
+ componentIndex: ownerIndex
282
+ }
283
+ }
284
+
285
+ const kind = payload.getUint16(25, true)
286
+ if (kind === 1) {
287
+ return {
288
+ role: 'comment',
289
+ isComment: true,
290
+ componentIndex: ownerIndex
291
+ }
292
+ }
293
+
294
+ return {
295
+ componentIndex: ownerIndex
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Marks unresolved component annotation placeholders once during parsing.
301
+ * @param {string} text
302
+ * @param {{ role?: string, isDesignator?: boolean, isComment?: boolean, componentIndex?: number }} role
303
+ * @returns {{ role?: string, isDesignator?: boolean, isComment?: boolean, isPlaceholder?: boolean }}
304
+ */
305
+ static #parsePlaceholderMetadata(text, role) {
306
+ const value = String(text || '').trim()
307
+ const hasComponentOwner = Number.isInteger(role?.componentIndex)
308
+
309
+ if (role?.isComment === true || role?.role === 'comment') {
310
+ return value === 'Comment' ? { isPlaceholder: true } : {}
311
+ }
312
+
313
+ if (role?.isDesignator === true || role?.role === 'designator') {
314
+ return /^Designator\d*$/u.test(value) ? { isPlaceholder: true } : {}
315
+ }
316
+
317
+ if (hasComponentOwner && value === 'Comment') {
318
+ return {
319
+ role: 'comment',
320
+ isComment: true,
321
+ isPlaceholder: true
322
+ }
323
+ }
324
+
325
+ if (hasComponentOwner && /^Designator\d*$/u.test(value)) {
326
+ return {
327
+ role: 'designator',
328
+ isDesignator: true,
329
+ isPlaceholder: true
330
+ }
331
+ }
332
+
333
+ return {}
334
+ }
335
+
336
+ /**
337
+ * Resolves a public label for one PCB text font type.
338
+ * @param {number} fontType
339
+ * @returns {'Stroke' | 'TrueType' | 'BarCode' | 'Unknown'}
340
+ */
341
+ static #fontTypeName(fontType) {
342
+ return (
343
+ {
344
+ 0: 'Stroke',
345
+ 1: 'TrueType',
346
+ 2: 'BarCode'
347
+ }[Number(fontType)] || 'Unknown'
348
+ )
349
+ }
350
+
351
+ /**
352
+ * Returns a generic fallback family for text records without a font name.
353
+ * @param {number} fontType
354
+ * @returns {string}
355
+ */
356
+ static #fallbackFontFamily(fontType) {
357
+ return Number(fontType) === 0 ? 'Stroke' : 'Arial'
358
+ }
359
+
360
+ /**
361
+ * Decodes the printable text payload that trails a fixed text record.
362
+ * @param {Uint8Array} bytes
363
+ * @returns {string}
364
+ */
365
+ static #decodeTextBytes(bytes) {
366
+ const start = bytes.findIndex((byte) => byte >= 0x20 && byte <= 0x7e)
367
+
368
+ if (start < 0) {
369
+ return ''
370
+ }
371
+
372
+ return new TextDecoder()
373
+ .decode(bytes.slice(start))
374
+ .replace(/\u0000/gu, '')
375
+ .replace(/\r\n?/gu, '\n')
376
+ .replace(/^[\u0000-\u001f\u007f-\u009f]+/gu, '')
377
+ .trim()
378
+ }
379
+
380
+ /**
381
+ * Finds the next length-prefixed PCB text record after one string tail.
382
+ * @param {Uint8Array} bytes
383
+ * @param {number} offset
384
+ * @returns {number}
385
+ */
386
+ static #findNextTextRecordOffset(bytes, offset) {
387
+ for (let cursor = offset; cursor < bytes.byteLength - 5; cursor += 1) {
388
+ if (PcbTextPrimitiveParser.#isTextRecordStart(bytes, cursor)) {
389
+ return cursor
390
+ }
391
+ }
392
+
393
+ return bytes.byteLength
394
+ }
395
+
396
+ /**
397
+ * Returns true when a byte offset looks like a text record boundary.
398
+ * @param {Uint8Array} bytes
399
+ * @param {number} offset
400
+ * @returns {boolean}
401
+ */
402
+ static #isTextRecordStart(bytes, offset) {
403
+ if (offset + 5 > bytes.byteLength) {
404
+ return false
405
+ }
406
+
407
+ if (bytes[offset] !== PcbTextPrimitiveParser.#TEXT_OBJECT_ID) {
408
+ return false
409
+ }
410
+
411
+ const payloadLength = PcbTextPrimitiveParser.#readUint32FromBytes(
412
+ bytes,
413
+ offset + 1
414
+ )
415
+ const payloadEnd = offset + 5 + payloadLength
416
+
417
+ return (
418
+ payloadLength >=
419
+ PcbTextPrimitiveParser.#TEXT_RECORD_MIN_BYTE_LENGTH &&
420
+ payloadLength <=
421
+ PcbTextPrimitiveParser.#TEXT_RECORD_MAX_BYTE_LENGTH &&
422
+ payloadEnd <= bytes.byteLength
423
+ )
424
+ }
425
+
426
+ /**
427
+ * Resolves the text rotation encoded in the visibility/options bit field.
428
+ * @param {number} visibilityFlags
429
+ * @returns {number}
430
+ */
431
+ static #textRotationFromFlags(visibilityFlags) {
432
+ return (Number(visibilityFlags) & 0x00010000) !== 0 ? 90 : 0
433
+ }
434
+
435
+ /**
436
+ * Resolves text rotation from modern double fields with legacy flag
437
+ * fallback.
438
+ * @param {DataView} payload
439
+ * @param {number} visibilityFlags
440
+ * @param {boolean} hasExtendedFontFields
441
+ * @returns {number}
442
+ */
443
+ static #resolveTextRotation(
444
+ payload,
445
+ visibilityFlags,
446
+ hasExtendedFontFields
447
+ ) {
448
+ if (hasExtendedFontFields && payload.byteLength >= 35) {
449
+ const rotation = payload.getFloat64(27, true)
450
+ if (Number.isFinite(rotation) && Math.abs(rotation) > 0.000001) {
451
+ return rotation
452
+ }
453
+ }
454
+
455
+ return PcbTextPrimitiveParser.#textRotationFromFlags(visibilityFlags)
456
+ }
457
+
458
+ /**
459
+ * Normalizes supported WideStrings6 lookup shapes into a string-keyed map.
460
+ * @param {Map<number | string, string> | Record<string, string> | { byIndex?: Record<string, string> } | undefined} wideStrings
461
+ * @returns {Map<string, string>}
462
+ */
463
+ static #normalizeWideStrings(wideStrings) {
464
+ const normalized = new Map()
465
+ const lookup = wideStrings?.byIndex || wideStrings || {}
466
+
467
+ if (lookup instanceof Map) {
468
+ for (const [index, text] of lookup.entries()) {
469
+ normalized.set(String(index), String(text || ''))
470
+ }
471
+ return normalized
472
+ }
473
+
474
+ for (const [index, text] of Object.entries(lookup)) {
475
+ normalized.set(String(index), String(text || ''))
476
+ }
477
+
478
+ return normalized
479
+ }
480
+
481
+ /**
482
+ * Reads one little-endian record count from a binary stream header.
483
+ * @param {Uint8Array | ArrayBuffer} headerBytes
484
+ * @returns {number}
485
+ */
486
+ static #readRecordCount(headerBytes) {
487
+ const normalizedHeader =
488
+ PcbTextPrimitiveParser.#toUint8Array(headerBytes)
489
+
490
+ if (normalizedHeader.byteLength < 4) {
491
+ return 0
492
+ }
493
+
494
+ return new DataView(
495
+ normalizedHeader.buffer,
496
+ normalizedHeader.byteOffset,
497
+ 4
498
+ ).getUint32(0, true)
499
+ }
500
+
501
+ /**
502
+ * Reads one little-endian unsigned integer from a byte view.
503
+ * @param {Uint8Array} bytes
504
+ * @param {number} offset
505
+ * @returns {number}
506
+ */
507
+ static #readUint32FromBytes(bytes, offset) {
508
+ return new DataView(
509
+ bytes.buffer,
510
+ bytes.byteOffset + offset,
511
+ 4
512
+ ).getUint32(0, true)
513
+ }
514
+
515
+ /**
516
+ * Reads one signed fixed-point mil coordinate.
517
+ * @param {DataView} view
518
+ * @param {number} offset
519
+ * @returns {number}
520
+ */
521
+ static #readMil(view, offset) {
522
+ return view.getInt32(offset, true) / 10000
523
+ }
524
+
525
+ /**
526
+ * Normalizes one byte-like input into a Uint8Array view.
527
+ * @param {Uint8Array | ArrayBuffer} bytes
528
+ * @returns {Uint8Array}
529
+ */
530
+ static #toUint8Array(bytes) {
531
+ if (bytes instanceof Uint8Array) {
532
+ return bytes
533
+ }
534
+
535
+ return new Uint8Array(bytes)
536
+ }
537
+ }
@@ -0,0 +1,87 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { PcbPrimitiveRecordSlicer } from './PcbPrimitiveRecordSlicer.mjs'
6
+ import { PcbPrimitiveOwnershipIndexParser } from './PcbPrimitiveOwnershipIndexParser.mjs'
7
+
8
+ /**
9
+ * Decodes Altium track primitive streams.
10
+ */
11
+ export class PcbTrackPrimitiveParser {
12
+ static #TRACK_OBJECT_ID = 4
13
+
14
+ static #TRACK_RECORD_BYTE_LENGTH = 49
15
+
16
+ static #TRACK_PAYLOAD_MIN_BYTE_LENGTH = 33
17
+
18
+ /**
19
+ * Decodes one track stream.
20
+ * @param {Uint8Array | ArrayBuffer} headerBytes
21
+ * @param {Uint8Array | ArrayBuffer} dataBytes
22
+ * @returns {{ x1: number, y1: number, x2: number, y2: number, width: number, componentIndex: number | null, netIndex: number | null, polygonIndex: number | null, layerCode: number, layerId: number }[]}
23
+ */
24
+ static parseTrackStream(headerBytes, dataBytes) {
25
+ return PcbTrackPrimitiveParser.#sliceTrackRecords(
26
+ headerBytes,
27
+ dataBytes
28
+ ).map((view) => PcbTrackPrimitiveParser.#parseTrackRecord(view))
29
+ }
30
+
31
+ /**
32
+ * Decodes one track record view into a normalized primitive.
33
+ * @param {DataView} view
34
+ * @returns {{ x1: number, y1: number, x2: number, y2: number, width: number, componentIndex: number | null, netIndex: number | null, polygonIndex: number | null, layerCode: number, layerId: number }}
35
+ */
36
+ static #parseTrackRecord(view) {
37
+ const layerId = view.getUint8(0)
38
+ const ownershipIndexes =
39
+ PcbPrimitiveOwnershipIndexParser.readOwnershipIndexes(view, {
40
+ component: 7,
41
+ net: 3,
42
+ polygon: 5
43
+ })
44
+
45
+ return {
46
+ x1: PcbTrackPrimitiveParser.#readMil(view, 13),
47
+ y1: PcbTrackPrimitiveParser.#readMil(view, 17),
48
+ x2: PcbTrackPrimitiveParser.#readMil(view, 21),
49
+ y2: PcbTrackPrimitiveParser.#readMil(view, 25),
50
+ width: PcbTrackPrimitiveParser.#readMil(view, 29),
51
+ ...ownershipIndexes,
52
+ layerCode: layerId,
53
+ layerId
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Splits a track stream into record views, preserving variable payload
59
+ * lengths when tracks are stored with object-id and payload-length
60
+ * prefixes.
61
+ * @param {Uint8Array | ArrayBuffer} headerBytes
62
+ * @param {Uint8Array | ArrayBuffer} dataBytes
63
+ * @returns {DataView[]}
64
+ */
65
+ static #sliceTrackRecords(headerBytes, dataBytes) {
66
+ return PcbPrimitiveRecordSlicer.slicePrimitiveRecords({
67
+ headerBytes,
68
+ dataBytes,
69
+ objectId: PcbTrackPrimitiveParser.#TRACK_OBJECT_ID,
70
+ fixedRecordByteLength:
71
+ PcbTrackPrimitiveParser.#TRACK_RECORD_BYTE_LENGTH,
72
+ minimumPayloadByteLength:
73
+ PcbTrackPrimitiveParser.#TRACK_PAYLOAD_MIN_BYTE_LENGTH,
74
+ lengthPrefixedView: 'payload'
75
+ }).map((record) => record.view)
76
+ }
77
+
78
+ /**
79
+ * Reads one standard fixed-point mil value.
80
+ * @param {DataView} view
81
+ * @param {number} offset
82
+ * @returns {number}
83
+ */
84
+ static #readMil(view, offset) {
85
+ return view.getInt32(offset, true) / 10000
86
+ }
87
+ }
@@ -0,0 +1,88 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { PcbPrimitiveRecordSlicer } from './PcbPrimitiveRecordSlicer.mjs'
6
+ import { PcbPrimitiveOwnershipIndexParser } from './PcbPrimitiveOwnershipIndexParser.mjs'
7
+ import { PcbViaStackParser } from './PcbViaStackParser.mjs'
8
+
9
+ /**
10
+ * Decodes Altium via primitive streams.
11
+ */
12
+ export class PcbViaPrimitiveParser {
13
+ static #VIA_OBJECT_ID = 3
14
+
15
+ static #VIA_RECORD_BYTE_LENGTH = 326
16
+
17
+ static #VIA_PAYLOAD_MIN_BYTE_LENGTH = 321
18
+
19
+ /**
20
+ * Decodes one via stream.
21
+ * @param {Uint8Array | ArrayBuffer} headerBytes
22
+ * @param {Uint8Array | ArrayBuffer} dataBytes
23
+ * @returns {{ x: number, y: number, diameter: number, holeDiameter: number, componentIndex: number | null, netIndex: number | null, polygonIndex: number | null, layerCode: number | null, layerId: number | null, layerStartId: number | null, layerEndId: number | null, [key: string]: unknown }[]}
24
+ */
25
+ static parseViaStream(headerBytes, dataBytes) {
26
+ return PcbViaPrimitiveParser.#sliceViaRecords(
27
+ headerBytes,
28
+ dataBytes
29
+ ).map((view) => PcbViaPrimitiveParser.#parseViaRecord(view))
30
+ }
31
+
32
+ /**
33
+ * Decodes one via record view into a normalized primitive.
34
+ * @param {DataView} view
35
+ * @returns {{ x: number, y: number, diameter: number, holeDiameter: number, componentIndex: number | null, netIndex: number | null, polygonIndex: number | null, layerCode: number | null, layerId: number | null, layerStartId: number | null, layerEndId: number | null, [key: string]: unknown }}
36
+ */
37
+ static #parseViaRecord(view) {
38
+ const ownershipIndexes =
39
+ PcbPrimitiveOwnershipIndexParser.readOwnershipIndexes(view, {
40
+ component: 12,
41
+ net: 8,
42
+ polygon: 10
43
+ })
44
+
45
+ return {
46
+ x: PcbViaPrimitiveParser.#readMil(view, 18),
47
+ y: PcbViaPrimitiveParser.#readMil(view, 22),
48
+ diameter: PcbViaPrimitiveParser.#readMil(view, 26),
49
+ holeDiameter: PcbViaPrimitiveParser.#readMil(view, 30),
50
+ ...ownershipIndexes,
51
+ layerCode: view.getUint8(5) || null,
52
+ layerId: view.getUint8(5) || null,
53
+ layerStartId: view.getUint8(34) || null,
54
+ layerEndId: view.getUint8(35) || null,
55
+ ...PcbViaStackParser.parse(view)
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Splits a via stream into record views, preserving variable tail lengths
61
+ * when Altium stores object-id and payload-length prefixes.
62
+ * @param {Uint8Array | ArrayBuffer} headerBytes
63
+ * @param {Uint8Array | ArrayBuffer} dataBytes
64
+ * @returns {DataView[]}
65
+ */
66
+ static #sliceViaRecords(headerBytes, dataBytes) {
67
+ return PcbPrimitiveRecordSlicer.slicePrimitiveRecords({
68
+ headerBytes,
69
+ dataBytes,
70
+ objectId: PcbViaPrimitiveParser.#VIA_OBJECT_ID,
71
+ fixedRecordByteLength:
72
+ PcbViaPrimitiveParser.#VIA_RECORD_BYTE_LENGTH,
73
+ minimumPayloadByteLength:
74
+ PcbViaPrimitiveParser.#VIA_PAYLOAD_MIN_BYTE_LENGTH,
75
+ lengthPrefixedView: 'record'
76
+ }).map((record) => record.view)
77
+ }
78
+
79
+ /**
80
+ * Reads one standard fixed-point mil value.
81
+ * @param {DataView} view
82
+ * @param {number} offset
83
+ * @returns {number}
84
+ */
85
+ static #readMil(view, offset) {
86
+ return view.getInt32(offset, true) / 10000
87
+ }
88
+ }