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,477 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { ParserUtils } from './ParserUtils.mjs'
6
+
7
+ const { getField, parseBoolean, parseNumericField } = ParserUtils
8
+
9
+ const SUBSTACK_FIELD_FAMILIES = [
10
+ {
11
+ fieldFamily: 'v9',
12
+ indexPattern: /^V9_SUBSTACK(\d+)_ID$/i,
13
+ fields: {
14
+ id: 'V9_SUBSTACK{index}_ID',
15
+ name: 'V9_SUBSTACK{index}_NAME',
16
+ isFlex: 'V9_SUBSTACK{index}_ISFLEX',
17
+ showTopDielectric: 'V9_SUBSTACK{index}_SHOWTOPDIELECTRIC',
18
+ showBottomDielectric: 'V9_SUBSTACK{index}_SHOWBOTTOMDIELECTRIC',
19
+ serviceStackup: 'V9_SUBSTACK{index}_SERVICE',
20
+ usedByPrimitives: 'V9_SUBSTACK{index}_USEDBYPRIMS',
21
+ rawStackupType: 'V9_SUBSTACK{index}_TYPE'
22
+ }
23
+ },
24
+ {
25
+ fieldFamily: 'legacy',
26
+ indexPattern: /^SUBSTACK(\d+)_ID$/i,
27
+ fields: {
28
+ id: 'SUBSTACK{index}_ID',
29
+ name: 'SUBSTACK{index}_NAME',
30
+ isFlex: 'SUBSTACK{index}_ISFLEX',
31
+ showTopDielectric: 'SUBSTACK{index}_SHOWTOPDIELECTRIC',
32
+ showBottomDielectric: 'SUBSTACK{index}_SHOWBOTTOMDIELECTRIC',
33
+ serviceStackup: 'SUBSTACK{index}_SERVICE',
34
+ usedByPrimitives: 'SUBSTACK{index}_USEDBYPRIMS',
35
+ rawStackupType: 'SUBSTACK{index}_TYPE'
36
+ }
37
+ },
38
+ {
39
+ fieldFamily: 'v8',
40
+ indexPattern: /^LAYERSUBSTACK_V8_(\d+)ID$/i,
41
+ fields: {
42
+ id: 'LAYERSUBSTACK_V8_{index}ID',
43
+ name: 'LAYERSUBSTACK_V8_{index}NAME',
44
+ isFlex: 'LAYERSUBSTACK_V8_{index}ISFLEX',
45
+ showTopDielectric: 'LAYERSUBSTACK_V8_{index}SHOWTOPDIELECTRIC',
46
+ showBottomDielectric:
47
+ 'LAYERSUBSTACK_V8_{index}SHOWBOTTOMDIELECTRIC',
48
+ serviceStackup: 'LAYERSUBSTACK_V8_{index}SERVICE',
49
+ usedByPrimitives: 'LAYERSUBSTACK_V8_{index}USEDBYPRIMS',
50
+ rawStackupType: 'LAYERSUBSTACK_V8_{index}TYPE'
51
+ }
52
+ }
53
+ ]
54
+
55
+ /**
56
+ * Normalizes rigid-flex board-region semantics from decoded PCB records.
57
+ */
58
+ export class PcbBoardRegionSemanticsParser {
59
+ /**
60
+ * Extracts layer-substack metadata from Board6/Data field sets.
61
+ * @param {Record<string, string | string[]>[]} fieldSets
62
+ * @returns {{ index: number, fieldFamily: string, id: string, name: string, isFlex: boolean | null, showTopDielectric: boolean | null, showBottomDielectric: boolean | null, serviceStackup: boolean | null, usedByPrimitives: boolean | null, rawStackupType: string }[]}
63
+ */
64
+ static parseLayerSubstacks(fieldSets) {
65
+ const fields = PcbBoardRegionSemanticsParser.#mergeFieldSets(fieldSets)
66
+ const substacks = []
67
+ const seenIds = new Set()
68
+
69
+ for (const family of SUBSTACK_FIELD_FAMILIES) {
70
+ const indexes = PcbBoardRegionSemanticsParser.#findFamilyIndexes(
71
+ fields,
72
+ family.indexPattern
73
+ )
74
+
75
+ for (const index of indexes) {
76
+ const substack =
77
+ PcbBoardRegionSemanticsParser.#parseLayerSubstack(
78
+ fields,
79
+ family,
80
+ index
81
+ )
82
+
83
+ if (!substack || seenIds.has(substack.id)) {
84
+ continue
85
+ }
86
+
87
+ seenIds.add(substack.id)
88
+ substacks.push(substack)
89
+ }
90
+ }
91
+
92
+ return substacks
93
+ }
94
+
95
+ /**
96
+ * Adds board-planning semantics to decoded BoardRegions/Data records.
97
+ * @param {object[]} boardRegions
98
+ * @param {{ index: number, id: string, name: string, isFlex: boolean | null }[]} layerSubstacks
99
+ * @returns {object[]}
100
+ */
101
+ static enrichBoardRegions(boardRegions, layerSubstacks) {
102
+ const substacksById = new Map(
103
+ (layerSubstacks || [])
104
+ .filter((substack) => substack.id)
105
+ .map((substack) => [substack.id, substack])
106
+ )
107
+
108
+ return (boardRegions || []).map((region, regionIndex) =>
109
+ PcbBoardRegionSemanticsParser.#enrichBoardRegion(
110
+ region,
111
+ regionIndex,
112
+ substacksById
113
+ )
114
+ )
115
+ }
116
+
117
+ /**
118
+ * Builds a compact region-to-substack context list.
119
+ * @param {object[]} boardRegions
120
+ * @returns {{ regionIndex: number, name: string, layerStackId: string, substackIndex: number | null, substackName: string, isFlex: boolean | null, locked3d: boolean, bendingLineCount: number }[]}
121
+ */
122
+ static buildBoardRegionContexts(boardRegions) {
123
+ return (boardRegions || []).map((region, regionIndex) => ({
124
+ regionIndex,
125
+ name: region.name || '',
126
+ layerStackId: region.layerStackId || '',
127
+ substackIndex:
128
+ region.substackIndex === undefined
129
+ ? null
130
+ : region.substackIndex,
131
+ substackName: region.substackName || '',
132
+ isFlex:
133
+ region.isFlexRegion === undefined ? null : region.isFlexRegion,
134
+ locked3d: region.locked3d === true,
135
+ bendingLineCount: Number(region.bendingLineCount || 0)
136
+ }))
137
+ }
138
+
139
+ /**
140
+ * Counts board-region semantic records for model summaries.
141
+ * @param {object[]} boardRegions
142
+ * @returns {{ boardRegionCount: number, flexRegionCount: number, bendingLineCount: number }}
143
+ */
144
+ static summarizeBoardRegions(boardRegions) {
145
+ const regions = boardRegions || []
146
+
147
+ return {
148
+ boardRegionCount: regions.length,
149
+ flexRegionCount: regions.filter(
150
+ (region) => region.isFlexRegion === true
151
+ ).length,
152
+ bendingLineCount: regions.reduce(
153
+ (total, region) => total + Number(region.bendingLineCount || 0),
154
+ 0
155
+ )
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Merges field sets into one lookup while preserving later native records.
161
+ * @param {Record<string, string | string[]>[]} fieldSets
162
+ * @returns {Record<string, string | string[]>}
163
+ */
164
+ static #mergeFieldSets(fieldSets) {
165
+ return Object.assign({}, ...(fieldSets || []))
166
+ }
167
+
168
+ /**
169
+ * Finds all substack indexes for one native field family.
170
+ * @param {Record<string, string | string[]>} fields
171
+ * @param {RegExp} pattern
172
+ * @returns {number[]}
173
+ */
174
+ static #findFamilyIndexes(fields, pattern) {
175
+ return [
176
+ ...new Set(
177
+ Object.keys(fields || {})
178
+ .map((key) => pattern.exec(key)?.[1])
179
+ .filter((index) => index !== undefined)
180
+ .map((index) => Number.parseInt(index, 10))
181
+ .filter((index) => Number.isInteger(index))
182
+ )
183
+ ].sort((left, right) => left - right)
184
+ }
185
+
186
+ /**
187
+ * Extracts one layer-substack row.
188
+ * @param {Record<string, string | string[]>} fields
189
+ * @param {{ fieldFamily: string, fields: Record<string, string> }} family
190
+ * @param {number} index
191
+ * @returns {object | null}
192
+ */
193
+ static #parseLayerSubstack(fields, family, index) {
194
+ const names = PcbBoardRegionSemanticsParser.#fieldNames(
195
+ family.fields,
196
+ index
197
+ )
198
+ const id = getField(fields, names.id)
199
+
200
+ if (!id) {
201
+ return null
202
+ }
203
+
204
+ return {
205
+ index,
206
+ fieldFamily: family.fieldFamily,
207
+ id,
208
+ name:
209
+ getField(fields, names.name) ||
210
+ 'Board Layer Stack ' + String(index),
211
+ isFlex: PcbBoardRegionSemanticsParser.#parseOptionalBoolean(
212
+ fields,
213
+ names.isFlex
214
+ ),
215
+ showTopDielectric:
216
+ PcbBoardRegionSemanticsParser.#parseOptionalBoolean(
217
+ fields,
218
+ names.showTopDielectric
219
+ ),
220
+ showBottomDielectric:
221
+ PcbBoardRegionSemanticsParser.#parseOptionalBoolean(
222
+ fields,
223
+ names.showBottomDielectric
224
+ ),
225
+ serviceStackup: PcbBoardRegionSemanticsParser.#parseOptionalBoolean(
226
+ fields,
227
+ names.serviceStackup
228
+ ),
229
+ usedByPrimitives:
230
+ PcbBoardRegionSemanticsParser.#parseOptionalBoolean(
231
+ fields,
232
+ names.usedByPrimitives
233
+ ),
234
+ rawStackupType: getField(fields, names.rawStackupType)
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Replaces field-name placeholders with an index.
240
+ * @param {Record<string, string>} templates
241
+ * @param {number} index
242
+ * @returns {Record<string, string>}
243
+ */
244
+ static #fieldNames(templates, index) {
245
+ return Object.fromEntries(
246
+ Object.entries(templates).map(([key, template]) => [
247
+ key,
248
+ template.replace('{index}', String(index))
249
+ ])
250
+ )
251
+ }
252
+
253
+ /**
254
+ * Adds typed fields to one decoded board region.
255
+ * @param {object} region
256
+ * @param {number} regionIndex
257
+ * @param {Map<string, object>} substacksById
258
+ * @returns {object}
259
+ */
260
+ static #enrichBoardRegion(region, regionIndex, substacksById) {
261
+ const properties = region?.properties || {}
262
+ const layerStackId = getField(properties, 'LAYERSTACKID')
263
+ const substack = substacksById.get(layerStackId)
264
+ const bendingLines =
265
+ PcbBoardRegionSemanticsParser.#parseBendingLines(properties)
266
+ const bendingLineCount =
267
+ PcbBoardRegionSemanticsParser.#parseIntegerField(
268
+ properties,
269
+ 'BENDINGLINECOUNT'
270
+ ) ?? bendingLines.length
271
+ const isFlexRegion =
272
+ substack?.isFlex === undefined ? null : substack.isFlex
273
+
274
+ return {
275
+ ...region,
276
+ boardRegionIndex: regionIndex,
277
+ objectKind:
278
+ getField(properties, 'OBJECTKIND') ||
279
+ (layerStackId ? 'BoardRegion' : ''),
280
+ name: getField(properties, 'NAME'),
281
+ v7Layer: getField(properties, 'V7_LAYER'),
282
+ boardLayerToken: getField(properties, 'LAYER'),
283
+ layerStackId,
284
+ substackIndex: substack?.index ?? null,
285
+ substackName: substack?.name || '',
286
+ isFlexRegion,
287
+ isRigidRegion:
288
+ isFlexRegion === null ? null : isFlexRegion === false,
289
+ locked3d:
290
+ PcbBoardRegionSemanticsParser.#parseOptionalBoolean(
291
+ properties,
292
+ 'LOCKED3D'
293
+ ) === true,
294
+ cavityHeight: getField(properties, 'CAVITYHEIGHT'),
295
+ arcResolution: getField(properties, 'ARCRESOLUTION'),
296
+ bendingLineCount,
297
+ bendingLines
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Parses indexed BENDINGLINE{n} values in stream order.
303
+ * @param {Record<string, string | string[]>} properties
304
+ * @returns {object[]}
305
+ */
306
+ static #parseBendingLines(properties) {
307
+ return Object.entries(properties || {})
308
+ .map(([key, value]) => ({
309
+ match: /^BENDINGLINE(\d+)$/i.exec(key),
310
+ value
311
+ }))
312
+ .filter((item) => item.match)
313
+ .map((item) => ({
314
+ index: Number.parseInt(item.match[1], 10),
315
+ raw: PcbBoardRegionSemanticsParser.#stringValue(item.value)
316
+ }))
317
+ .sort((left, right) => left.index - right.index)
318
+ .map((item) =>
319
+ PcbBoardRegionSemanticsParser.#parseBendingLine(
320
+ item.index,
321
+ item.raw
322
+ )
323
+ )
324
+ }
325
+
326
+ /**
327
+ * Parses one semicolon-delimited board-region bending line.
328
+ * @param {number} index
329
+ * @param {string} raw
330
+ * @returns {object}
331
+ */
332
+ static #parseBendingLine(index, raw) {
333
+ const tokens = String(raw || '')
334
+ .split(';')
335
+ .map((token) => token.trim())
336
+ const angleDeg = PcbBoardRegionSemanticsParser.#parseOptionalNumber(
337
+ tokens[0]
338
+ )
339
+ const radiusRaw = PcbBoardRegionSemanticsParser.#parseOptionalInteger(
340
+ tokens[1]
341
+ )
342
+ const radiusMil =
343
+ radiusRaw === null
344
+ ? null
345
+ : PcbBoardRegionSemanticsParser.#toMil(radiusRaw)
346
+
347
+ return {
348
+ index,
349
+ raw,
350
+ angleDeg,
351
+ radiusRaw,
352
+ radiusMil,
353
+ affectedWidthMil:
354
+ angleDeg === null || radiusMil === null
355
+ ? null
356
+ : PcbBoardRegionSemanticsParser.#roundMil(
357
+ (Math.abs(angleDeg) / 360) * 2 * Math.PI * radiusMil
358
+ ),
359
+ foldIndex: PcbBoardRegionSemanticsParser.#parseOptionalInteger(
360
+ tokens[2]
361
+ ),
362
+ x1Raw: PcbBoardRegionSemanticsParser.#parseOptionalInteger(
363
+ tokens[3]
364
+ ),
365
+ y1Raw: PcbBoardRegionSemanticsParser.#parseOptionalInteger(
366
+ tokens[4]
367
+ ),
368
+ x2Raw: PcbBoardRegionSemanticsParser.#parseOptionalInteger(
369
+ tokens[5]
370
+ ),
371
+ y2Raw: PcbBoardRegionSemanticsParser.#parseOptionalInteger(
372
+ tokens[6]
373
+ ),
374
+ x1: PcbBoardRegionSemanticsParser.#toMilOrNull(tokens[3]),
375
+ y1: PcbBoardRegionSemanticsParser.#toMilOrNull(tokens[4]),
376
+ x2: PcbBoardRegionSemanticsParser.#toMilOrNull(tokens[5]),
377
+ y2: PcbBoardRegionSemanticsParser.#toMilOrNull(tokens[6])
378
+ }
379
+ }
380
+
381
+ /**
382
+ * Parses one optional boolean field.
383
+ * @param {Record<string, string | string[]>} fields
384
+ * @param {string} key
385
+ * @returns {boolean | null}
386
+ */
387
+ static #parseOptionalBoolean(fields, key) {
388
+ const raw = getField(fields, key)
389
+
390
+ if (!raw) {
391
+ return null
392
+ }
393
+
394
+ return parseBoolean(raw)
395
+ }
396
+
397
+ /**
398
+ * Parses one optional integer field.
399
+ * @param {Record<string, string | string[]>} fields
400
+ * @param {string} key
401
+ * @returns {number | null}
402
+ */
403
+ static #parseIntegerField(fields, key) {
404
+ const parsed = parseNumericField(fields, key)
405
+
406
+ return parsed === null ? null : Math.trunc(parsed)
407
+ }
408
+
409
+ /**
410
+ * Parses one optional number token.
411
+ * @param {string | undefined} raw
412
+ * @returns {number | null}
413
+ */
414
+ static #parseOptionalNumber(raw) {
415
+ if (raw === undefined || raw === '') {
416
+ return null
417
+ }
418
+
419
+ const parsed = Number(raw)
420
+ return Number.isFinite(parsed) ? parsed : null
421
+ }
422
+
423
+ /**
424
+ * Parses one optional integer token.
425
+ * @param {string | undefined} raw
426
+ * @returns {number | null}
427
+ */
428
+ static #parseOptionalInteger(raw) {
429
+ const parsed = PcbBoardRegionSemanticsParser.#parseOptionalNumber(raw)
430
+
431
+ return parsed === null ? null : Math.trunc(parsed)
432
+ }
433
+
434
+ /**
435
+ * Converts one internal Altium coordinate token to mils.
436
+ * @param {string | undefined} raw
437
+ * @returns {number | null}
438
+ */
439
+ static #toMilOrNull(raw) {
440
+ const value = PcbBoardRegionSemanticsParser.#parseOptionalInteger(raw)
441
+
442
+ return value === null
443
+ ? null
444
+ : PcbBoardRegionSemanticsParser.#toMil(value)
445
+ }
446
+
447
+ /**
448
+ * Converts one internal Altium coordinate to mils.
449
+ * @param {number} value
450
+ * @returns {number}
451
+ */
452
+ static #toMil(value) {
453
+ return PcbBoardRegionSemanticsParser.#roundMil(
454
+ Number(value || 0) / 10000
455
+ )
456
+ }
457
+
458
+ /**
459
+ * Rounds a mil value for stable JSON output.
460
+ * @param {number} value
461
+ * @returns {number}
462
+ */
463
+ static #roundMil(value) {
464
+ return Math.round(Number(value || 0) * 1000000) / 1000000
465
+ }
466
+
467
+ /**
468
+ * Returns the last text value from one field payload.
469
+ * @param {string | string[] | undefined} raw
470
+ * @returns {string}
471
+ */
472
+ static #stringValue(raw) {
473
+ const values = Array.isArray(raw) ? raw : [raw]
474
+
475
+ return String(values.findLast((value) => value !== undefined) || '')
476
+ }
477
+ }