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
@@ -0,0 +1,290 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Applies component annotations recovered from PCB sidecar streams.
7
+ */
8
+ export class PcbComponentAnnotationNormalizer {
9
+ /**
10
+ * Enriches component records with Texts6 designators and parameters.
11
+ * @param {{ componentIndex: number, designator: string, uniqueId?: string, description?: string, parameters?: Record<string, string> }[]} components
12
+ * @param {{ text?: string, ownerIndex?: number | null, componentIndex?: number | null, role?: string, isDesignator?: boolean }[]} texts
13
+ * @param {{ byPrimitiveId?: Record<string, Record<string, string>>, groups?: { primitiveId: string, parameters: Record<string, string> }[] } | undefined} primitiveParameters
14
+ * @returns {object[]}
15
+ */
16
+ static enrichComponents(components, texts, primitiveParameters) {
17
+ return PcbComponentAnnotationNormalizer.#applyPrimitiveParameters(
18
+ PcbComponentAnnotationNormalizer.#applyTextDesignators(
19
+ components,
20
+ texts
21
+ ),
22
+ PcbComponentAnnotationNormalizer.#primitiveParameterLookup(
23
+ primitiveParameters
24
+ )
25
+ )
26
+ }
27
+
28
+ /**
29
+ * Marks decoded PCB text primitives as visible or hidden based on linked
30
+ * component display flags.
31
+ * @param {{ text: string, ownerIndex?: number | null, componentIndex?: number | null, kind?: number, visibilityFlags?: number, role?: string, isDesignator?: boolean }[]} texts
32
+ * @param {{ componentIndex: number, designator: string, nameOn: boolean, commentOn: boolean }[]} components
33
+ * @returns {object[]}
34
+ */
35
+ static normalizeTexts(texts, components) {
36
+ return (texts || []).map((text) => ({
37
+ ...text,
38
+ visible: PcbComponentAnnotationNormalizer.#isVisibleText(
39
+ text,
40
+ components
41
+ )
42
+ }))
43
+ }
44
+
45
+ /**
46
+ * Applies Texts6-owned designator strings to their native component.
47
+ * @param {{ componentIndex: number, designator: string }[]} components
48
+ * @param {{ text?: string, ownerIndex?: number | null, componentIndex?: number | null, role?: string, isDesignator?: boolean }[]} texts
49
+ * @returns {object[]}
50
+ */
51
+ static #applyTextDesignators(components, texts) {
52
+ const designatorsByComponentIndex =
53
+ PcbComponentAnnotationNormalizer.#textDesignatorLookup(texts)
54
+
55
+ return (components || []).map((component) => {
56
+ const displayDesignator = designatorsByComponentIndex.get(
57
+ Number(component.componentIndex)
58
+ )
59
+
60
+ if (
61
+ !displayDesignator ||
62
+ PcbComponentAnnotationNormalizer.#normalizeText(
63
+ displayDesignator
64
+ ) ===
65
+ PcbComponentAnnotationNormalizer.#normalizeText(
66
+ component.designator
67
+ )
68
+ ) {
69
+ return component
70
+ }
71
+
72
+ return {
73
+ ...component,
74
+ baseDesignator: component.designator,
75
+ designator: displayDesignator,
76
+ displayDesignator,
77
+ designatorSource: 'Texts6/Data'
78
+ }
79
+ })
80
+ }
81
+
82
+ /**
83
+ * Builds a component-indexed lookup from explicit Texts6 designators.
84
+ * @param {{ text?: string, ownerIndex?: number | null, componentIndex?: number | null, role?: string, isDesignator?: boolean }[]} texts
85
+ * @returns {Map<number, string>}
86
+ */
87
+ static #textDesignatorLookup(texts) {
88
+ const designatorsByComponentIndex = new Map()
89
+
90
+ for (const text of texts || []) {
91
+ const componentIndex =
92
+ PcbComponentAnnotationNormalizer.#textComponentIndex(text)
93
+ if (
94
+ !Number.isInteger(componentIndex) ||
95
+ !PcbComponentAnnotationNormalizer.#isDesignatorTextPrimitive(
96
+ text
97
+ ) ||
98
+ !text.text
99
+ ) {
100
+ continue
101
+ }
102
+
103
+ designatorsByComponentIndex.set(componentIndex, String(text.text))
104
+ }
105
+
106
+ return designatorsByComponentIndex
107
+ }
108
+
109
+ /**
110
+ * Resolves the native component index from a decoded text primitive.
111
+ * @param {{ ownerIndex?: number | null, componentIndex?: number | null }} text
112
+ * @returns {number | null}
113
+ */
114
+ static #textComponentIndex(text) {
115
+ const componentIndex = Number(text?.componentIndex)
116
+ if (Number.isInteger(componentIndex)) {
117
+ return componentIndex
118
+ }
119
+
120
+ const ownerIndex = Number(text?.ownerIndex)
121
+ return Number.isInteger(ownerIndex) ? ownerIndex : null
122
+ }
123
+
124
+ /**
125
+ * Returns true when one text primitive explicitly represents a designator.
126
+ * @param {{ role?: string, isDesignator?: boolean }} text
127
+ * @returns {boolean}
128
+ */
129
+ static #isDesignatorTextPrimitive(text) {
130
+ return text?.isDesignator === true || text?.role === 'designator'
131
+ }
132
+
133
+ /**
134
+ * Applies PrimitiveParameters/Data values to components by unique ID.
135
+ * @param {{ uniqueId?: string, description?: string, parameters?: Record<string, string> }[]} components
136
+ * @param {Map<string, Record<string, string>>} parametersByPrimitiveId
137
+ * @returns {object[]}
138
+ */
139
+ static #applyPrimitiveParameters(components, parametersByPrimitiveId) {
140
+ return (components || []).map((component) => {
141
+ const primitiveId = String(component.uniqueId || '')
142
+ const parameters = primitiveId
143
+ ? parametersByPrimitiveId.get(primitiveId)
144
+ : null
145
+
146
+ if (!parameters) {
147
+ return component
148
+ }
149
+
150
+ const mergedParameters = {
151
+ ...(component.parameters || {}),
152
+ ...parameters
153
+ }
154
+
155
+ return {
156
+ ...component,
157
+ description:
158
+ component.description ||
159
+ PcbComponentAnnotationNormalizer.#firstParameterValue(
160
+ mergedParameters,
161
+ ['Description', 'Comment', 'Value']
162
+ ),
163
+ parameters: mergedParameters,
164
+ parameterSource: 'PrimitiveParameters/Data'
165
+ }
166
+ })
167
+ }
168
+
169
+ /**
170
+ * Builds a primitive-parameter lookup from supported extraction shapes.
171
+ * @param {{ byPrimitiveId?: Record<string, Record<string, string>>, groups?: { primitiveId: string, parameters: Record<string, string> }[] } | undefined} primitiveParameters
172
+ * @returns {Map<string, Record<string, string>>}
173
+ */
174
+ static #primitiveParameterLookup(primitiveParameters) {
175
+ const lookup = new Map()
176
+
177
+ for (const [primitiveId, parameters] of Object.entries(
178
+ primitiveParameters?.byPrimitiveId || {}
179
+ )) {
180
+ lookup.set(String(primitiveId), { ...(parameters || {}) })
181
+ }
182
+
183
+ for (const group of primitiveParameters?.groups || []) {
184
+ if (group?.primitiveId && !lookup.has(String(group.primitiveId))) {
185
+ lookup.set(String(group.primitiveId), {
186
+ ...(group.parameters || {})
187
+ })
188
+ }
189
+ }
190
+
191
+ return lookup
192
+ }
193
+
194
+ /**
195
+ * Returns the first non-empty parameter value using case-insensitive names.
196
+ * @param {Record<string, string>} parameters
197
+ * @param {string[]} names
198
+ * @returns {string}
199
+ */
200
+ static #firstParameterValue(parameters, names) {
201
+ const normalizedParameters = new Map(
202
+ Object.entries(parameters || {}).map(([name, value]) => [
203
+ name.toLowerCase(),
204
+ String(value || '')
205
+ ])
206
+ )
207
+
208
+ for (const name of names) {
209
+ const value = normalizedParameters.get(name.toLowerCase())
210
+ if (value) {
211
+ return value
212
+ }
213
+ }
214
+
215
+ return ''
216
+ }
217
+
218
+ /**
219
+ * Returns true when one PCB text primitive should render in board view.
220
+ * @param {{ text: string, ownerIndex?: number | null, componentIndex?: number | null, kind?: number, visibilityFlags?: number, role?: string, isDesignator?: boolean }} text
221
+ * @param {{ componentIndex: number, designator: string, nameOn: boolean, commentOn: boolean }[]} components
222
+ * @returns {boolean}
223
+ */
224
+ static #isVisibleText(text, components) {
225
+ const componentIndex =
226
+ PcbComponentAnnotationNormalizer.#textComponentIndex(text)
227
+
228
+ if (!Number.isInteger(componentIndex)) {
229
+ return true
230
+ }
231
+
232
+ const component =
233
+ PcbComponentAnnotationNormalizer.#componentByNativeIndex(
234
+ components,
235
+ componentIndex
236
+ )
237
+ if (!component) {
238
+ return (Number(text?.visibilityFlags || 0) & 1) === 0
239
+ }
240
+
241
+ if (PcbComponentAnnotationNormalizer.#isDesignatorTextPrimitive(text)) {
242
+ return component.nameOn
243
+ }
244
+
245
+ if (
246
+ PcbComponentAnnotationNormalizer.#normalizeText(text.text) ===
247
+ PcbComponentAnnotationNormalizer.#normalizeText(
248
+ component.designator
249
+ )
250
+ ) {
251
+ return component.nameOn
252
+ }
253
+
254
+ if (Number(text?.kind) === 1) {
255
+ return component.commentOn
256
+ }
257
+
258
+ if ((Number(text?.visibilityFlags || 0) & 1) !== 0) {
259
+ return component.nameOn
260
+ }
261
+
262
+ return true
263
+ }
264
+
265
+ /**
266
+ * Finds one normalized component by native component table index.
267
+ * @param {{ componentIndex: number }[]} components
268
+ * @param {number} componentIndex
269
+ * @returns {object | null}
270
+ */
271
+ static #componentByNativeIndex(components, componentIndex) {
272
+ return (
273
+ (components || []).find(
274
+ (component) =>
275
+ Number(component?.componentIndex) === componentIndex
276
+ ) || null
277
+ )
278
+ }
279
+
280
+ /**
281
+ * Normalizes text for display-flag comparisons.
282
+ * @param {unknown} text
283
+ * @returns {string}
284
+ */
285
+ static #normalizeText(text) {
286
+ return String(text || '')
287
+ .trim()
288
+ .toUpperCase()
289
+ }
290
+ }
@@ -0,0 +1,52 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Normalizes embedded component-body placements into viewer coordinates.
7
+ */
8
+ export class PcbComponentBodyPlacementNormalizer {
9
+ /**
10
+ * Flips embedded component-body placements into the viewer coordinate
11
+ * system.
12
+ * @param {{ sourceStream: string, layer: string, identifier: string, modelId: string, checksum: number | null, embedded: boolean, name: string, positionMil: { x: number, y: number }, rotationDeg: number, modelRotationDeg: { x: number, y: number, z: number }, dzMil: number, overallHeightMil: number | null, standoffHeightMil: number | null }[]} componentBodies
13
+ * @param {{ minY: number, heightMil: number }} boardOutline
14
+ * @returns {{ sourceStream: string, layer: string, identifier: string, modelId: string, checksum: number | null, embedded: boolean, name: string, positionMil: { x: number, y: number }, rotationDeg: number, modelRotationDeg: { x: number, y: number, z: number }, dzMil: number, overallHeightMil: number | null, standoffHeightMil: number | null }[]}
15
+ */
16
+ static normalizeComponentBodies(componentBodies, boardOutline) {
17
+ const maxY =
18
+ Number(boardOutline?.minY || 0) +
19
+ Number(boardOutline?.heightMil || 0)
20
+ const mirrorY = (value) =>
21
+ Number(boardOutline?.minY || 0) + maxY - Number(value || 0)
22
+
23
+ return componentBodies.map((componentBody) => ({
24
+ ...componentBody,
25
+ positionMil: {
26
+ x: Number(componentBody.positionMil?.x || 0),
27
+ y: mirrorY(componentBody.positionMil?.y || 0)
28
+ },
29
+ rotationDeg: PcbComponentBodyPlacementNormalizer.#normalizeAngle(
30
+ 360 - Number(componentBody.rotationDeg || 0)
31
+ ),
32
+ modelRotationDeg: {
33
+ x: Number(componentBody.modelRotationDeg?.x || 0),
34
+ y: Number(componentBody.modelRotationDeg?.y || 0),
35
+ z: PcbComponentBodyPlacementNormalizer.#normalizeAngle(
36
+ 360 - Number(componentBody.modelRotationDeg?.z || 0)
37
+ )
38
+ }
39
+ }))
40
+ }
41
+
42
+ /**
43
+ * Normalizes one angle into the range [0, 360).
44
+ * @param {number} angle
45
+ * @returns {number}
46
+ */
47
+ static #normalizeAngle(angle) {
48
+ const normalized = Number(angle || 0) % 360
49
+
50
+ return normalized < 0 ? normalized + 360 : normalized
51
+ }
52
+ }
@@ -0,0 +1,109 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Groups PCB primitives by native Altium component ownership indexes.
7
+ */
8
+ export class PcbComponentPrimitiveIndexer {
9
+ /**
10
+ * Groups normalized primitives by their native component index.
11
+ * @param {{ componentIndex: number, designator: string }[]} components
12
+ * @param {{ fills?: object[], tracks?: object[], arcs?: object[], vias?: object[], pads?: object[], regions?: object[], shapeBasedRegions?: object[], texts?: object[] }} pcb
13
+ * @param {{ componentIndex?: number | null }[]} componentBodies
14
+ * @returns {{ componentIndex: number, designator: string, pads: object[], tracks: object[], arcs: object[], fills: object[], vias: object[], regions: object[], shapeBasedRegions: object[], texts: object[], componentBodies: object[] }[]}
15
+ */
16
+ static buildGroups(components, pcb, componentBodies) {
17
+ return (components || []).map((component) => {
18
+ const componentIndex = Number(component.componentIndex)
19
+
20
+ return {
21
+ componentIndex,
22
+ designator: component.designator,
23
+ pads: PcbComponentPrimitiveIndexer.#primitivesForComponent(
24
+ pcb.pads,
25
+ componentIndex
26
+ ),
27
+ tracks: PcbComponentPrimitiveIndexer.#primitivesForComponent(
28
+ pcb.tracks,
29
+ componentIndex
30
+ ),
31
+ arcs: PcbComponentPrimitiveIndexer.#primitivesForComponent(
32
+ pcb.arcs,
33
+ componentIndex
34
+ ),
35
+ fills: PcbComponentPrimitiveIndexer.#primitivesForComponent(
36
+ pcb.fills,
37
+ componentIndex
38
+ ),
39
+ vias: PcbComponentPrimitiveIndexer.#primitivesForComponent(
40
+ pcb.vias,
41
+ componentIndex
42
+ ),
43
+ regions: PcbComponentPrimitiveIndexer.#primitivesForComponent(
44
+ pcb.regions,
45
+ componentIndex
46
+ ),
47
+ shapeBasedRegions:
48
+ PcbComponentPrimitiveIndexer.#primitivesForComponent(
49
+ pcb.shapeBasedRegions,
50
+ componentIndex
51
+ ),
52
+ texts: (pcb.texts || []).filter(
53
+ (text) => Number(text?.ownerIndex) === componentIndex
54
+ ),
55
+ componentBodies:
56
+ PcbComponentPrimitiveIndexer.#primitivesForComponent(
57
+ componentBodies,
58
+ componentIndex
59
+ )
60
+ }
61
+ })
62
+ }
63
+
64
+ /**
65
+ * Indexes component primitive groups by their native component index.
66
+ * @param {{ componentIndex: number, designator: string, pads: object[], tracks: object[], arcs: object[], fills: object[], vias: object[], regions: object[], shapeBasedRegions: object[], texts: object[], componentBodies: object[] }[]} componentPrimitiveGroups
67
+ * @returns {({ componentIndex: number, designator: string, pads: object[], tracks: object[], arcs: object[], fills: object[], vias: object[], regions: object[], shapeBasedRegions: object[], texts: object[], componentBodies: object[] } | null)[]}
68
+ */
69
+ static indexGroups(componentPrimitiveGroups) {
70
+ const indexedGroups = []
71
+
72
+ for (const group of componentPrimitiveGroups || []) {
73
+ const componentIndex = Number(group.componentIndex)
74
+
75
+ if (!Number.isInteger(componentIndex) || componentIndex < 0) {
76
+ continue
77
+ }
78
+
79
+ while (indexedGroups.length <= componentIndex) {
80
+ indexedGroups.push(null)
81
+ }
82
+
83
+ indexedGroups[componentIndex] = group
84
+ }
85
+
86
+ return indexedGroups
87
+ }
88
+
89
+ /**
90
+ * Returns primitives linked to a component by native Altium index.
91
+ * @param {{ componentIndex?: number | null }[] | undefined} primitives
92
+ * @param {number} componentIndex
93
+ * @returns {object[]}
94
+ */
95
+ static #primitivesForComponent(primitives, componentIndex) {
96
+ return (primitives || []).filter((primitive) => {
97
+ const rawComponentIndex = primitive?.componentIndex
98
+ if (
99
+ rawComponentIndex === null ||
100
+ rawComponentIndex === undefined ||
101
+ rawComponentIndex === ''
102
+ ) {
103
+ return false
104
+ }
105
+
106
+ return Number(rawComponentIndex) === componentIndex
107
+ })
108
+ }
109
+ }