altium-toolkit 0.1.0

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 (82) hide show
  1. package/AGENTS.md +67 -0
  2. package/COMMERCIAL-LICENSE.md +20 -0
  3. package/CONTRIBUTING.md +19 -0
  4. package/LICENSE +22 -0
  5. package/LICENSES/CC-BY-SA-4.0.txt +170 -0
  6. package/LICENSES/GPL-3.0-or-later.txt +232 -0
  7. package/NOTICE.md +32 -0
  8. package/README.md +116 -0
  9. package/docs/api.md +73 -0
  10. package/docs/model-format.md +36 -0
  11. package/docs/testing.md +25 -0
  12. package/examples/README.md +47 -0
  13. package/examples/arduino-uno/PcbThreeSceneRenderer.mjs +635 -0
  14. package/examples/arduino-uno/SvgViewportController.mjs +306 -0
  15. package/examples/arduino-uno/example.mjs +480 -0
  16. package/examples/arduino-uno/index.html +163 -0
  17. package/examples/arduino-uno/styles.css +552 -0
  18. package/examples/server.mjs +212 -0
  19. package/package.json +53 -0
  20. package/spec/library-scope.md +32 -0
  21. package/src/core/BinaryReader.mjs +127 -0
  22. package/src/core/altium/AltiumLayoutParser.mjs +485 -0
  23. package/src/core/altium/AltiumParser.mjs +1007 -0
  24. package/src/core/altium/AsciiRecordParser.mjs +151 -0
  25. package/src/core/altium/ParserUtils.mjs +173 -0
  26. package/src/core/altium/PcbBinaryPrimitiveParser.mjs +424 -0
  27. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +505 -0
  28. package/src/core/altium/PcbModelParser.mjs +336 -0
  29. package/src/core/altium/PcbOutlineRasterizer.mjs +852 -0
  30. package/src/core/altium/PcbOutlineRecovery.mjs +957 -0
  31. package/src/core/altium/PcbStreamExtractor.mjs +210 -0
  32. package/src/core/altium/PrintableTextDecoder.mjs +156 -0
  33. package/src/core/altium/SchematicAnnotationParser.mjs +220 -0
  34. package/src/core/altium/SchematicBusEntryParser.mjs +48 -0
  35. package/src/core/altium/SchematicDirectiveParser.mjs +47 -0
  36. package/src/core/altium/SchematicImageParser.mjs +173 -0
  37. package/src/core/altium/SchematicJunctionParser.mjs +43 -0
  38. package/src/core/altium/SchematicMultipartOwnerMatcher.mjs +564 -0
  39. package/src/core/altium/SchematicNetlistBuilder.mjs +351 -0
  40. package/src/core/altium/SchematicPinParser.mjs +767 -0
  41. package/src/core/altium/SchematicPrimitiveParser.mjs +716 -0
  42. package/src/core/altium/SchematicSheetParser.mjs +241 -0
  43. package/src/core/altium/SchematicSheetStyleResolver.mjs +46 -0
  44. package/src/core/altium/SchematicStandaloneCalloutNormalizer.mjs +592 -0
  45. package/src/core/altium/SchematicTextParser.mjs +708 -0
  46. package/src/core/altium/SchematicTextPostProcessor.mjs +801 -0
  47. package/src/core/ole/OleCompoundDocument.mjs +439 -0
  48. package/src/core/ole/OleConstants.mjs +64 -0
  49. package/src/core/ole/OleDirectoryEntry.mjs +95 -0
  50. package/src/index.mjs +7 -0
  51. package/src/parser.mjs +21 -0
  52. package/src/renderers.mjs +15 -0
  53. package/src/scene3d.mjs +9 -0
  54. package/src/styles/altium-renderers.css +358 -0
  55. package/src/ui/BomTableRenderer.mjs +46 -0
  56. package/src/ui/PcbArcUtils.mjs +189 -0
  57. package/src/ui/PcbEdgeFacingGlyphNormalizer.mjs +808 -0
  58. package/src/ui/PcbFootprintPrimitiveSelector.mjs +128 -0
  59. package/src/ui/PcbScene3dBuilder.mjs +742 -0
  60. package/src/ui/PcbScene3dModelRegistry.mjs +309 -0
  61. package/src/ui/PcbScene3dPackages.mjs +137 -0
  62. package/src/ui/PcbScene3dScenePreparator.mjs +36 -0
  63. package/src/ui/PcbScene3dSummaryRenderer.mjs +65 -0
  64. package/src/ui/PcbSvgRenderer.mjs +906 -0
  65. package/src/ui/SchematicColorResolver.mjs +132 -0
  66. package/src/ui/SchematicContentLayout.mjs +661 -0
  67. package/src/ui/SchematicDirectiveRenderer.mjs +184 -0
  68. package/src/ui/SchematicImageRenderer.mjs +135 -0
  69. package/src/ui/SchematicJunctionRenderer.mjs +381 -0
  70. package/src/ui/SchematicNoteRenderer.mjs +427 -0
  71. package/src/ui/SchematicOwnerPinLabelLayout.mjs +173 -0
  72. package/src/ui/SchematicPinSvgRenderer.mjs +495 -0
  73. package/src/ui/SchematicPortRenderer.mjs +558 -0
  74. package/src/ui/SchematicPowerPortRenderer.mjs +574 -0
  75. package/src/ui/SchematicRegionRenderer.mjs +94 -0
  76. package/src/ui/SchematicShapeRenderer.mjs +398 -0
  77. package/src/ui/SchematicSheetChromeRenderer.mjs +1025 -0
  78. package/src/ui/SchematicSheetSymbolRenderer.mjs +228 -0
  79. package/src/ui/SchematicSvgRenderer.mjs +756 -0
  80. package/src/ui/SchematicSvgUtils.mjs +182 -0
  81. package/src/ui/SchematicTypography.mjs +204 -0
  82. package/src/workers/altium-parser.worker.mjs +29 -0
@@ -0,0 +1,336 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { AltiumLayoutParser } from './AltiumLayoutParser.mjs'
6
+ import { PcbOutlineRecovery } from './PcbOutlineRecovery.mjs'
7
+ import { ParserUtils } from './ParserUtils.mjs'
8
+
9
+ const {
10
+ countMatchingKeys,
11
+ dedupeByDesignator,
12
+ getField,
13
+ parseNumericField,
14
+ stripExtension
15
+ } = ParserUtils
16
+
17
+ /**
18
+ * Normalizes PCB records into the viewer's board model.
19
+ */
20
+ export class PcbModelParser {
21
+ /**
22
+ * Parses a normalized PCB model.
23
+ * @param {string} fileName
24
+ * @param {{ raw: string, fields: Record<string, string | string[]>, sourceStream?: string }[]} records
25
+ * @param {{ streamNames: string[], binaryPrimitives: { 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, sizeTopX: number, sizeTopY: number, sizeMidX: number, sizeMidY: number, sizeBottomX: number, sizeBottomY: number, holeDiameter: number, shapeTop: number, shapeMid: number, shapeBottom: number, rotation: number, isPlated: boolean }[] }, diagnostics: { printableRecordCount: number, printableStreamCount: number, binaryPrimitiveCount: number } } | null} pcbExtraction
26
+ * @returns {{ kind: 'pcb', fileType: 'PcbDoc', fileName: string, summary: Record<string, number | string>, diagnostics: { severity: 'info' | 'warning', message: string }[], pcb: { boardOutline: { widthMil: number, heightMil: number, minX: number, minY: number, segments: Array<Record<string, number | string>> }, layers: { index: number, name: string, layerId: number | null }[], primitiveLayers: { layerId: number, name: string }[], components: { designator: string, x: number, y: number, layer: string, pattern: string, rotation: number, source: string, description: string, height: number | null }[], 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, sizeTopX: number, sizeTopY: number, sizeMidX: number, sizeMidY: number, sizeBottomX: number, sizeBottomY: number, holeDiameter: number, shapeTop: number, shapeMid: number, shapeBottom: number, rotation: number, isPlated: boolean }[] }, bom: { designators: string[], quantity: number, pattern: string, source: string, value: string }[] }}
27
+ */
28
+ static parse(fileName, records, pcbExtraction = null) {
29
+ const boardRecords = records.filter(
30
+ (record) => record.sourceStream === 'Board6/Data'
31
+ )
32
+ const boardRecord =
33
+ boardRecords.find(
34
+ (record) =>
35
+ getField(record.fields, 'KIND0') &&
36
+ record.sourceStream === 'Board6/Data'
37
+ ) || records.find((record) => getField(record.fields, 'KIND0'))
38
+ const layerRecord =
39
+ boardRecords.find(
40
+ (record) =>
41
+ countMatchingKeys(
42
+ record.fields,
43
+ /^V9_STACK_LAYER\d+_NAME$/
44
+ ) > 0 && record.sourceStream === 'Board6/Data'
45
+ ) ||
46
+ records.find(
47
+ (record) =>
48
+ countMatchingKeys(
49
+ record.fields,
50
+ /^V9_STACK_LAYER\d+_NAME$/
51
+ ) > 0
52
+ )
53
+ const componentRecords = dedupeByDesignator(
54
+ records
55
+ .filter(
56
+ (record) =>
57
+ getField(record.fields, 'PATTERN') &&
58
+ getField(record.fields, 'SOURCEDESIGNATOR')
59
+ )
60
+ .map((record) => ({
61
+ designator: getField(record.fields, 'SOURCEDESIGNATOR'),
62
+ x: parseNumericField(record.fields, 'X') || 0,
63
+ y: parseNumericField(record.fields, 'Y') || 0,
64
+ layer: getField(record.fields, 'LAYER') || 'TOP',
65
+ pattern: getField(record.fields, 'PATTERN'),
66
+ rotation: parseNumericField(record.fields, 'ROTATION') || 0,
67
+ source:
68
+ getField(record.fields, 'SOURCELIBREFERENCE') ||
69
+ getField(record.fields, 'SOURCEFOOTPRINTLIBRARY'),
70
+ description: getField(record.fields, 'SOURCEDESCRIPTION'),
71
+ height: parseNumericField(record.fields, 'HEIGHT')
72
+ }))
73
+ )
74
+ const polygonRecords = records.filter(
75
+ (record) =>
76
+ record.sourceStream === 'Polygons6/Data' &&
77
+ getField(record.fields, 'KIND0')
78
+ )
79
+ const fallbackBoardOutline = AltiumLayoutParser.parseBoardOutline(
80
+ boardRecord?.fields || {}
81
+ )
82
+ const layers = AltiumLayoutParser.parseLayerStack(
83
+ layerRecord?.fields || {}
84
+ )
85
+ const primitiveLayers = AltiumLayoutParser.parsePrimitiveLayerNames(
86
+ boardRecords.map((record) => record.fields)
87
+ )
88
+ const polygons = polygonRecords
89
+ .map((record) => ({
90
+ layer: getField(record.fields, 'LAYER') || 'UNKNOWN',
91
+ segments: AltiumLayoutParser.parseBoardOutline(record.fields)
92
+ .segments
93
+ }))
94
+ .filter((polygon) => polygon.segments.length > 0)
95
+ const tracks = pcbExtraction?.binaryPrimitives?.tracks || []
96
+ const arcs = pcbExtraction?.binaryPrimitives?.arcs || []
97
+ const vias = pcbExtraction?.binaryPrimitives?.vias || []
98
+ const fills = pcbExtraction?.binaryPrimitives?.fills || []
99
+ const pads = pcbExtraction?.binaryPrimitives?.pads || []
100
+ const extractedEmbeddedModels = Array.isArray(
101
+ pcbExtraction?.embeddedModels?.models
102
+ )
103
+ ? pcbExtraction.embeddedModels.models
104
+ : []
105
+ const extractedComponentBodies = Array.isArray(
106
+ pcbExtraction?.embeddedModels?.componentBodies
107
+ )
108
+ ? pcbExtraction.embeddedModels.componentBodies
109
+ : []
110
+ const recoveredOutline = PcbOutlineRecovery.recoverOutline({
111
+ fallbackOutline: fallbackBoardOutline,
112
+ components: componentRecords,
113
+ tracks
114
+ })
115
+ const boardOutline = recoveredOutline.outline
116
+ const normalizedPcb = PcbOutlineRecovery.flipGeometryVertically({
117
+ boardOutline,
118
+ polygons,
119
+ fills,
120
+ tracks,
121
+ arcs,
122
+ vias,
123
+ pads,
124
+ components: componentRecords
125
+ })
126
+ const componentBodies = PcbModelParser.#normalizeComponentBodies(
127
+ extractedComponentBodies,
128
+ boardOutline
129
+ )
130
+ const bom = PcbModelParser.#groupBomRows(
131
+ componentRecords.map((component) => ({
132
+ designator: component.designator,
133
+ pattern: component.pattern,
134
+ source: component.source,
135
+ value: component.description || component.pattern
136
+ }))
137
+ )
138
+
139
+ const diagnostics = [
140
+ {
141
+ severity: 'info',
142
+ message:
143
+ 'Recovered ' + records.length + ' printable PCB records.'
144
+ },
145
+ {
146
+ severity: 'info',
147
+ message:
148
+ 'Recovered ' +
149
+ componentRecords.length +
150
+ ' PCB component placements.'
151
+ },
152
+ {
153
+ severity: 'info',
154
+ message: 'Recovered ' + layers.length + ' layer stack entries.'
155
+ }
156
+ ]
157
+
158
+ if (pcbExtraction) {
159
+ diagnostics.push({
160
+ severity: 'info',
161
+ message:
162
+ 'Recovered ' +
163
+ pcbExtraction.streamNames.length +
164
+ ' PCB data streams from the compound document.'
165
+ })
166
+ diagnostics.push({
167
+ severity: 'info',
168
+ message:
169
+ 'Decoded ' +
170
+ tracks.length +
171
+ ' tracks, ' +
172
+ arcs.length +
173
+ ' arcs, ' +
174
+ vias.length +
175
+ ' vias, ' +
176
+ pads.length +
177
+ ' pads, ' +
178
+ fills.length +
179
+ ' fills, and ' +
180
+ polygons.length +
181
+ ' polygons.'
182
+ })
183
+ }
184
+
185
+ if (extractedEmbeddedModels.length) {
186
+ diagnostics.push({
187
+ severity: 'info',
188
+ message:
189
+ 'Recovered ' +
190
+ extractedEmbeddedModels.length +
191
+ ' embedded 3D model payloads.'
192
+ })
193
+ }
194
+
195
+ if (recoveredOutline.source === 'board-route') {
196
+ diagnostics.push({
197
+ severity: 'info',
198
+ message:
199
+ 'Recovered board outline from the authored board-route contour.'
200
+ })
201
+ }
202
+
203
+ if (recoveredOutline.source === 'mechanical-track-layer') {
204
+ diagnostics.push({
205
+ severity: 'info',
206
+ message:
207
+ 'Recovered board outline from mechanical track layer ' +
208
+ recoveredOutline.layerId +
209
+ '.'
210
+ })
211
+ }
212
+
213
+ if (!boardRecord) {
214
+ diagnostics.push({
215
+ severity: 'warning',
216
+ message:
217
+ 'Board geometry record was not found. PCB view uses component extents only.'
218
+ })
219
+ }
220
+
221
+ return {
222
+ kind: 'pcb',
223
+ fileType: 'PcbDoc',
224
+ fileName,
225
+ summary: {
226
+ title: stripExtension(fileName),
227
+ componentCount: componentRecords.length,
228
+ layerCount: layers.length,
229
+ outlineSegmentCount: boardOutline.segments.length,
230
+ bomRowCount: bom.length,
231
+ polygonCount: polygons.length,
232
+ trackCount: tracks.length,
233
+ arcCount: arcs.length,
234
+ viaCount: vias.length,
235
+ boardWidthMil: Math.round(boardOutline.widthMil),
236
+ boardHeightMil: Math.round(boardOutline.heightMil)
237
+ },
238
+ diagnostics,
239
+ pcb: {
240
+ boardOutline: normalizedPcb.boardOutline,
241
+ layers,
242
+ primitiveLayers,
243
+ components: normalizedPcb.components,
244
+ polygons: normalizedPcb.polygons,
245
+ fills: normalizedPcb.fills,
246
+ tracks: normalizedPcb.tracks,
247
+ arcs: normalizedPcb.arcs,
248
+ vias: normalizedPcb.vias,
249
+ pads: normalizedPcb.pads,
250
+ embeddedModels: extractedEmbeddedModels,
251
+ componentBodies
252
+ },
253
+ bom
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Groups component placements into BOM rows.
259
+ * @param {{ designator: string, pattern: string, source: string, value: string }[]} componentRecords
260
+ * @returns {{ designators: string[], quantity: number, pattern: string, source: string, value: string }[]}
261
+ */
262
+ static #groupBomRows(componentRecords) {
263
+ const groupedRows = new Map()
264
+
265
+ for (const component of componentRecords) {
266
+ const groupKey = [
267
+ component.pattern || '',
268
+ component.source || '',
269
+ component.value || ''
270
+ ].join('\u0000')
271
+
272
+ if (!groupedRows.has(groupKey)) {
273
+ groupedRows.set(groupKey, {
274
+ designators: [],
275
+ quantity: 0,
276
+ pattern: component.pattern || 'Unknown footprint',
277
+ source: component.source || 'Unknown source',
278
+ value:
279
+ component.value || component.pattern || 'Unknown part'
280
+ })
281
+ }
282
+
283
+ const row = groupedRows.get(groupKey)
284
+ row.designators.push(component.designator)
285
+ row.quantity += 1
286
+ }
287
+
288
+ return [...groupedRows.values()].sort((left, right) =>
289
+ left.pattern.localeCompare(right.pattern)
290
+ )
291
+ }
292
+
293
+ /**
294
+ * Flips embedded component-body placements into the viewer coordinate
295
+ * system.
296
+ * @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
297
+ * @param {{ minY: number, heightMil: number }} boardOutline
298
+ * @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 }[]}
299
+ */
300
+ static #normalizeComponentBodies(componentBodies, boardOutline) {
301
+ const maxY =
302
+ Number(boardOutline?.minY || 0) +
303
+ Number(boardOutline?.heightMil || 0)
304
+ const mirrorY = (value) =>
305
+ Number(boardOutline?.minY || 0) + maxY - Number(value || 0)
306
+
307
+ return componentBodies.map((componentBody) => ({
308
+ ...componentBody,
309
+ positionMil: {
310
+ x: Number(componentBody.positionMil?.x || 0),
311
+ y: mirrorY(componentBody.positionMil?.y || 0)
312
+ },
313
+ rotationDeg: PcbModelParser.#normalizeAngle(
314
+ 360 - Number(componentBody.rotationDeg || 0)
315
+ ),
316
+ modelRotationDeg: {
317
+ x: Number(componentBody.modelRotationDeg?.x || 0),
318
+ y: Number(componentBody.modelRotationDeg?.y || 0),
319
+ z: PcbModelParser.#normalizeAngle(
320
+ 360 - Number(componentBody.modelRotationDeg?.z || 0)
321
+ )
322
+ }
323
+ }))
324
+ }
325
+
326
+ /**
327
+ * Normalizes one angle into the range [0, 360).
328
+ * @param {number} angle
329
+ * @returns {number}
330
+ */
331
+ static #normalizeAngle(angle) {
332
+ const normalized = Number(angle || 0) % 360
333
+
334
+ return normalized < 0 ? normalized + 360 : normalized
335
+ }
336
+ }