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,485 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { ParserUtils } from './ParserUtils.mjs'
6
+ import { SchematicTextParser } from './SchematicTextParser.mjs'
7
+
8
+ const { getField, parseNumericField } = ParserUtils
9
+ const ISO_A_PORTRAIT_SHEETS = [
10
+ { label: 'A5', width: 583, height: 827 },
11
+ { label: 'A4', width: 827, height: 1169 },
12
+ { label: 'A3', width: 1169, height: 1654 },
13
+ { label: 'A2', width: 1654, height: 2339 },
14
+ { label: 'A1', width: 2339, height: 3307 },
15
+ { label: 'A0', width: 3307, height: 4681 }
16
+ ]
17
+ const STANDARD_PAGE_MAX_SLACK_RATIO = 0.12
18
+
19
+ /**
20
+ * Shared layout helpers for recovered schematic and PCB document geometry.
21
+ */
22
+ export class AltiumLayoutParser {
23
+ /**
24
+ * Builds an outline from the serialized board polygon fields.
25
+ * @param {Record<string, string | string[]>} fields
26
+ * @returns {{ widthMil: number, heightMil: number, minX: number, minY: number, segments: Array<Record<string, number | string>> }}
27
+ */
28
+ static parseBoardOutline(fields) {
29
+ const vertices = []
30
+
31
+ for (let index = 0; index < 1024; index += 1) {
32
+ const kind = parseNumericField(fields, 'KIND' + index)
33
+ const x = parseNumericField(fields, 'VX' + index)
34
+ const y = parseNumericField(fields, 'VY' + index)
35
+
36
+ if (kind === null || x === null || y === null) {
37
+ break
38
+ }
39
+
40
+ vertices.push({
41
+ kind,
42
+ x,
43
+ y,
44
+ cx: parseNumericField(fields, 'CX' + index),
45
+ cy: parseNumericField(fields, 'CY' + index),
46
+ radius: parseNumericField(fields, 'R' + index),
47
+ startAngle: parseNumericField(fields, 'SA' + index),
48
+ endAngle: parseNumericField(fields, 'EA' + index)
49
+ })
50
+ }
51
+
52
+ if (!vertices.length) {
53
+ return {
54
+ widthMil: 0,
55
+ heightMil: 0,
56
+ minX: 0,
57
+ minY: 0,
58
+ segments: []
59
+ }
60
+ }
61
+
62
+ const segments = []
63
+ const xs = vertices.map((vertex) => vertex.x)
64
+ const ys = vertices.map((vertex) => vertex.y)
65
+
66
+ for (let index = 0; index < vertices.length; index += 1) {
67
+ const current = vertices[index]
68
+ const next = vertices[(index + 1) % vertices.length]
69
+
70
+ if (current.kind === 1 && current.radius) {
71
+ segments.push({
72
+ type: 'arc',
73
+ x1: current.x,
74
+ y1: current.y,
75
+ x2: next.x,
76
+ y2: next.y,
77
+ cx: current.cx || current.x,
78
+ cy: current.cy || current.y,
79
+ radius: current.radius,
80
+ startAngle: current.startAngle || 0,
81
+ endAngle: current.endAngle || 0
82
+ })
83
+ continue
84
+ }
85
+
86
+ segments.push({
87
+ type: 'line',
88
+ x1: current.x,
89
+ y1: current.y,
90
+ x2: next.x,
91
+ y2: next.y
92
+ })
93
+ }
94
+
95
+ return {
96
+ widthMil: Math.max(...xs) - Math.min(...xs),
97
+ heightMil: Math.max(...ys) - Math.min(...ys),
98
+ minX: Math.min(...xs),
99
+ minY: Math.min(...ys),
100
+ segments
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Extracts the declared layer stack.
106
+ * @param {Record<string, string | string[]>} fields
107
+ * @returns {{ index: number, name: string, layerId: number | null }[]}
108
+ */
109
+ static parseLayerStack(fields) {
110
+ const layers = []
111
+
112
+ for (const key of Object.keys(fields)) {
113
+ const match = /^V9_STACK_LAYER(\d+)_NAME$/.exec(key)
114
+ if (!match) continue
115
+
116
+ const index = Number.parseInt(match[1], 10)
117
+ layers.push({
118
+ index,
119
+ name: getField(fields, key),
120
+ layerId: parseNumericField(
121
+ fields,
122
+ 'V9_STACK_LAYER' + index + '_LAYERID'
123
+ )
124
+ })
125
+ }
126
+
127
+ return layers.sort((left, right) => left.index - right.index)
128
+ }
129
+
130
+ /**
131
+ * Extracts legacy primitive-layer names keyed by the numeric layer IDs used
132
+ * by decoded binary track and fill streams.
133
+ * @param {Record<string, string | string[]>[]} fieldSets
134
+ * @returns {{ layerId: number, name: string }[]}
135
+ */
136
+ static parsePrimitiveLayerNames(fieldSets) {
137
+ const layers = new Map()
138
+
139
+ for (const fields of fieldSets) {
140
+ for (const key of Object.keys(fields)) {
141
+ const match = /^LAYER(\d+)NAME$/.exec(key)
142
+
143
+ if (!match) {
144
+ continue
145
+ }
146
+
147
+ const layerId = Number.parseInt(match[1], 10)
148
+ const name = getField(fields, key)
149
+
150
+ if (!Number.isInteger(layerId) || !name) {
151
+ continue
152
+ }
153
+
154
+ if (!layers.has(layerId)) {
155
+ layers.set(layerId, {
156
+ layerId,
157
+ name
158
+ })
159
+ }
160
+ }
161
+ }
162
+
163
+ return [...layers.values()].sort(
164
+ (left, right) => left.layerId - right.layerId
165
+ )
166
+ }
167
+
168
+ /**
169
+ * Resolves one schematic page size from recovered geometry when the stored
170
+ * custom dimensions leave excessive blank space around visible content.
171
+ * @param {{ width: number, height: number, marginWidth: number, paperSize?: string }} sheet
172
+ * @param {{ fields: Record<string, string | string[]> }[]} textRecords
173
+ * @param {{ x1: number, y1: number, x2: number, y2: number }[]} lines
174
+ * @param {{ x: number, y: number }[]} texts
175
+ * @param {{ x: number, y: number }[]} components
176
+ * @param {{ x: number, y: number }[]} pins
177
+ * @param {{ x: number, y: number, width: number, height: number }[]} rectangles
178
+ * @param {{ x: number, y: number, width: number, height: number }[]} regions
179
+ * @param {{ x: number, y: number, width: number, height: number }[]} ports
180
+ * @param {{ x: number, y: number }[]} crosses
181
+ * @returns {{ width: number, height: number, marginWidth: number, paperSize?: string }}
182
+ */
183
+ static resolveSchematicSheetSize(
184
+ sheet,
185
+ textRecords,
186
+ lines,
187
+ texts,
188
+ components,
189
+ pins,
190
+ rectangles,
191
+ regions,
192
+ ports,
193
+ crosses
194
+ ) {
195
+ const bounds = AltiumLayoutParser.#collectSchematicDrawableBounds(
196
+ lines,
197
+ texts,
198
+ components,
199
+ pins,
200
+ rectangles,
201
+ regions,
202
+ ports,
203
+ crosses
204
+ )
205
+ if (!bounds) {
206
+ return sheet
207
+ }
208
+
209
+ const margin = Math.max(Number(sheet?.marginWidth || 20), 20)
210
+ const footerBounds = AltiumLayoutParser.#collectSchematicFooterBounds(
211
+ textRecords,
212
+ Number(sheet?.width || 0)
213
+ )
214
+ const requiredWidth =
215
+ Math.max(bounds.maxX, footerBounds?.maxX || 0) + margin * 2
216
+ const requiredHeight =
217
+ Math.max(bounds.maxY, footerBounds?.maxY || 0) + margin * 2
218
+
219
+ if (
220
+ AltiumLayoutParser.#shouldPreserveDeclaredCustomSheetSize(
221
+ sheet,
222
+ requiredWidth,
223
+ requiredHeight
224
+ )
225
+ ) {
226
+ return sheet
227
+ }
228
+
229
+ const standardSheet = AltiumLayoutParser.#resolveStandardSheetSize(
230
+ requiredWidth,
231
+ requiredHeight
232
+ )
233
+
234
+ if (standardSheet) {
235
+ return {
236
+ ...sheet,
237
+ width: standardSheet.width,
238
+ height: standardSheet.height,
239
+ paperSize: standardSheet.label
240
+ }
241
+ }
242
+
243
+ const resolvedWidth = AltiumLayoutParser.#pickResolvedSheetAxis(
244
+ sheet.width,
245
+ requiredWidth
246
+ )
247
+ const resolvedHeight = AltiumLayoutParser.#pickResolvedSheetAxis(
248
+ sheet.height,
249
+ requiredHeight
250
+ )
251
+ const resolvedStandardSheet =
252
+ AltiumLayoutParser.#resolveStandardSheetSize(
253
+ resolvedWidth,
254
+ resolvedHeight
255
+ )
256
+
257
+ if (resolvedStandardSheet) {
258
+ return {
259
+ ...sheet,
260
+ width: resolvedStandardSheet.width,
261
+ height: resolvedStandardSheet.height,
262
+ paperSize: resolvedStandardSheet.label
263
+ }
264
+ }
265
+
266
+ return {
267
+ ...sheet,
268
+ width: resolvedWidth,
269
+ height: resolvedHeight,
270
+ paperSize: sheet?.paperSize
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Returns true when the parser should trust the authored custom sheet
276
+ * dimensions instead of shrinking the page to visible content bounds.
277
+ * @param {{ width?: number, height?: number, borderOn?: boolean, titleBlockOn?: boolean, sheetStyle?: number } | undefined} sheet
278
+ * @param {number} requiredWidth
279
+ * @param {number} requiredHeight
280
+ * @returns {boolean}
281
+ */
282
+ static #shouldPreserveDeclaredCustomSheetSize(
283
+ sheet,
284
+ requiredWidth,
285
+ requiredHeight
286
+ ) {
287
+ const declaredStandardSheet =
288
+ AltiumLayoutParser.#resolveStandardSheetSize(
289
+ Number(sheet?.width || 0),
290
+ Number(sheet?.height || 0)
291
+ )
292
+
293
+ if (
294
+ Number(sheet?.sheetStyle || 0) !== 1 &&
295
+ Boolean(sheet?.borderOn || sheet?.titleBlockOn) &&
296
+ !declaredStandardSheet
297
+ ) {
298
+ const declaredWidth = Math.max(Number(sheet?.width || 0), 0)
299
+ const declaredHeight = Math.max(Number(sheet?.height || 0), 0)
300
+
301
+ return (
302
+ requiredWidth <= declaredWidth &&
303
+ requiredHeight <= declaredHeight
304
+ )
305
+ }
306
+
307
+ return false
308
+ }
309
+
310
+ /**
311
+ * Collects the visible coordinate envelope from recovered schematic
312
+ * primitives.
313
+ * @param {{ x1: number, y1: number, x2: number, y2: number }[]} lines
314
+ * @param {{ x: number, y: number }[]} texts
315
+ * @param {{ x: number, y: number }[]} components
316
+ * @param {{ x: number, y: number }[]} pins
317
+ * @param {{ x: number, y: number, width: number, height: number }[]} rectangles
318
+ * @param {{ x: number, y: number, width: number, height: number }[]} regions
319
+ * @param {{ x: number, y: number, width: number, height: number, direction?: 'left' | 'right' | 'up' | 'down' }[]} ports
320
+ * @param {{ x: number, y: number }[]} crosses
321
+ * @returns {{ maxX: number, maxY: number } | null}
322
+ */
323
+ static #collectSchematicDrawableBounds(
324
+ lines,
325
+ texts,
326
+ components,
327
+ pins,
328
+ rectangles,
329
+ regions,
330
+ ports,
331
+ crosses
332
+ ) {
333
+ const coordinates = []
334
+
335
+ for (const line of lines) {
336
+ coordinates.push([line.x1, line.y1], [line.x2, line.y2])
337
+ }
338
+
339
+ for (const text of texts) {
340
+ coordinates.push([text.x, text.y])
341
+ }
342
+
343
+ for (const component of components) {
344
+ coordinates.push([component.x, component.y])
345
+ }
346
+
347
+ for (const pin of pins) {
348
+ coordinates.push([pin.x, pin.y])
349
+ }
350
+
351
+ for (const rectangle of rectangles) {
352
+ coordinates.push(
353
+ [rectangle.x, rectangle.y],
354
+ [rectangle.x + rectangle.width, rectangle.y + rectangle.height]
355
+ )
356
+ }
357
+
358
+ for (const region of regions) {
359
+ coordinates.push(
360
+ [region.x, region.y],
361
+ [region.x + region.width, region.y + region.height]
362
+ )
363
+ }
364
+
365
+ for (const port of ports) {
366
+ if (port.direction === 'up' || port.direction === 'down') {
367
+ const halfWidth = Number(port.height || 0) / 2
368
+
369
+ coordinates.push(
370
+ [port.x - halfWidth, port.y],
371
+ [port.x + halfWidth, port.y + port.width]
372
+ )
373
+ continue
374
+ }
375
+
376
+ coordinates.push(
377
+ [port.x, port.y],
378
+ [port.x + port.width, port.y + port.height]
379
+ )
380
+ }
381
+
382
+ for (const cross of crosses) {
383
+ coordinates.push([cross.x, cross.y])
384
+ }
385
+
386
+ if (!coordinates.length) {
387
+ return null
388
+ }
389
+
390
+ return {
391
+ maxX: Math.max(...coordinates.map(([x]) => x)),
392
+ maxY: Math.max(...coordinates.map(([, y]) => y))
393
+ }
394
+ }
395
+
396
+ /**
397
+ * Collects the visible title-block footer extent recovered from footer
398
+ * value placeholders.
399
+ * @param {{ fields: Record<string, string | string[]> }[]} textRecords
400
+ * @param {number} sheetWidth
401
+ * @returns {{ maxX: number, maxY: number } | null}
402
+ */
403
+ static #collectSchematicFooterBounds(textRecords, sheetWidth) {
404
+ const footerCoordinates = textRecords
405
+ .filter((record) =>
406
+ SchematicTextParser.isTitleBlockFooterRecord(
407
+ record.fields,
408
+ sheetWidth
409
+ )
410
+ )
411
+ .map((record) => ({
412
+ x: parseNumericField(record.fields, 'Location.X') || 0,
413
+ y: parseNumericField(record.fields, 'Location.Y') || 0
414
+ }))
415
+
416
+ if (!footerCoordinates.length) {
417
+ return null
418
+ }
419
+
420
+ return {
421
+ maxX: Math.max(
422
+ ...footerCoordinates.map((coordinate) => coordinate.x)
423
+ ),
424
+ maxY: Math.max(
425
+ ...footerCoordinates.map((coordinate) => coordinate.y)
426
+ )
427
+ }
428
+ }
429
+
430
+ /**
431
+ * Resolves the smallest matching ISO A sheet when the recovered geometry
432
+ * closely matches a standard page size.
433
+ * @param {number} requiredWidth
434
+ * @param {number} requiredHeight
435
+ * @returns {{ label: string, width: number, height: number } | null}
436
+ */
437
+ static #resolveStandardSheetSize(requiredWidth, requiredHeight) {
438
+ const landscape = requiredWidth >= requiredHeight
439
+ const candidates = ISO_A_PORTRAIT_SHEETS.map((sheet) => ({
440
+ label: sheet.label,
441
+ width: landscape ? sheet.height : sheet.width,
442
+ height: landscape ? sheet.width : sheet.height
443
+ }))
444
+ const matchingSheet =
445
+ candidates.find(
446
+ (sheet) =>
447
+ sheet.width >= requiredWidth &&
448
+ sheet.height >= requiredHeight
449
+ ) || null
450
+
451
+ if (!matchingSheet) {
452
+ return null
453
+ }
454
+
455
+ const widthSlackRatio =
456
+ (matchingSheet.width - requiredWidth) / requiredWidth
457
+ const heightSlackRatio =
458
+ (matchingSheet.height - requiredHeight) / requiredHeight
459
+
460
+ return widthSlackRatio <= STANDARD_PAGE_MAX_SLACK_RATIO &&
461
+ heightSlackRatio <= STANDARD_PAGE_MAX_SLACK_RATIO
462
+ ? matchingSheet
463
+ : null
464
+ }
465
+
466
+ /**
467
+ * Chooses a sheet axis length, preferring recovered bounds when the stored
468
+ * size is substantially larger than the visible geometry.
469
+ * @param {number} declaredAxis
470
+ * @param {number} inferredAxis
471
+ * @returns {number}
472
+ */
473
+ static #pickResolvedSheetAxis(declaredAxis, inferredAxis) {
474
+ const normalizedDeclared = Math.max(Number(declaredAxis || 0), 100)
475
+ const normalizedInferred = Math.max(Number(inferredAxis || 0), 100)
476
+
477
+ if (normalizedDeclared < normalizedInferred) {
478
+ return normalizedInferred
479
+ }
480
+
481
+ return normalizedDeclared > normalizedInferred * 1.15
482
+ ? normalizedInferred
483
+ : normalizedDeclared
484
+ }
485
+ }