altium-toolkit 1.0.8 → 1.0.9

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 (88) hide show
  1. package/README.md +18 -6
  2. package/docs/api.md +78 -16
  3. package/docs/model-format.md +229 -8
  4. package/docs/schemas/altium_toolkit/netlist_a1.schema.json +47 -0
  5. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +1661 -104
  6. package/docs/schemas/altium_toolkit/pcb_svg_semantics_a1.schema.json +59 -0
  7. package/docs/schemas/altium_toolkit/project_bundle_a1.schema.json +57 -0
  8. package/docs/schemas/altium_toolkit/schematic_svg_semantics_a1.schema.json +50 -0
  9. package/docs/testing.md +9 -3
  10. package/package.json +1 -1
  11. package/spec/library-scope.md +7 -1
  12. package/src/core/altium/AltiumLayoutParser.mjs +104 -8
  13. package/src/core/altium/AltiumParser.mjs +191 -45
  14. package/src/core/altium/EmbeddedFileInventoryBuilder.mjs +255 -0
  15. package/src/core/altium/IntLibModelParser.mjs +240 -0
  16. package/src/core/altium/IntLibStreamExtractor.mjs +366 -0
  17. package/src/core/altium/LibraryRenderManifestBuilder.mjs +417 -0
  18. package/src/core/altium/LibrarySearchIndex.mjs +215 -0
  19. package/src/core/altium/NormalizedModelSchema.mjs +36 -0
  20. package/src/core/altium/PcbCustomPadShapeParser.mjs +244 -0
  21. package/src/core/altium/PcbDefaultsParser.mjs +171 -0
  22. package/src/core/altium/PcbDimensionParser.mjs +229 -0
  23. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +232 -6
  24. package/src/core/altium/PcbExtendedPrimitiveInformationParser.mjs +256 -0
  25. package/src/core/altium/PcbLibModelParser.mjs +235 -14
  26. package/src/core/altium/PcbLibStreamExtractor.mjs +62 -4
  27. package/src/core/altium/PcbMaskPasteResolver.mjs +354 -0
  28. package/src/core/altium/PcbMechanicalLayerPairParser.mjs +204 -0
  29. package/src/core/altium/PcbModelParser.mjs +466 -28
  30. package/src/core/altium/PcbOwnershipGraphBuilder.mjs +245 -0
  31. package/src/core/altium/PcbPadPrimitiveParser.mjs +78 -65
  32. package/src/core/altium/PcbPadStackParser.mjs +58 -0
  33. package/src/core/altium/PcbPickPlacePositionResolver.mjs +217 -0
  34. package/src/core/altium/PcbPrimitiveParameterParser.mjs +3 -2
  35. package/src/core/altium/PcbRawRecordRegistry.mjs +121 -130
  36. package/src/core/altium/PcbRegionPrimitiveParser.mjs +5 -1
  37. package/src/core/altium/PcbRuleParser.mjs +354 -33
  38. package/src/core/altium/PcbSidecarRecordParser.mjs +177 -0
  39. package/src/core/altium/PcbSpecialStringResolver.mjs +220 -0
  40. package/src/core/altium/PcbStatisticsBuilder.mjs +532 -0
  41. package/src/core/altium/PcbStreamExtractor.mjs +111 -4
  42. package/src/core/altium/PcbTextPrimitiveParser.mjs +60 -0
  43. package/src/core/altium/PcbUnionParser.mjs +307 -0
  44. package/src/core/altium/PcbViaStackParser.mjs +98 -10
  45. package/src/core/altium/PcbViaStructureParser.mjs +335 -0
  46. package/src/core/altium/PrintableTextDecoder.mjs +53 -3
  47. package/src/core/altium/PrjPcbModelParser.mjs +257 -5
  48. package/src/core/altium/ProjectAnnotationParser.mjs +205 -0
  49. package/src/core/altium/ProjectDesignBundleBuilder.mjs +477 -0
  50. package/src/core/altium/ProjectNetlistExporter.mjs +499 -0
  51. package/src/core/altium/ProjectOutJobDigestBuilder.mjs +109 -0
  52. package/src/core/altium/ProjectVariantViewBuilder.mjs +334 -0
  53. package/src/core/altium/SchematicBindingProvenanceParser.mjs +223 -0
  54. package/src/core/altium/SchematicComponentOwnerTextResolver.mjs +312 -0
  55. package/src/core/altium/SchematicComponentTextResolver.mjs +72 -19
  56. package/src/core/altium/SchematicConnectivityQaBuilder.mjs +271 -0
  57. package/src/core/altium/SchematicCrossSheetConnectorParser.mjs +140 -0
  58. package/src/core/altium/SchematicDirectiveParser.mjs +312 -0
  59. package/src/core/altium/SchematicDisplayModeCatalogParser.mjs +231 -0
  60. package/src/core/altium/SchematicHarnessParser.mjs +302 -0
  61. package/src/core/altium/SchematicImageParser.mjs +474 -3
  62. package/src/core/altium/SchematicImplementationParser.mjs +518 -0
  63. package/src/core/altium/SchematicNetlistBuilder.mjs +15 -2
  64. package/src/core/altium/SchematicOwnershipGraphParser.mjs +195 -0
  65. package/src/core/altium/SchematicPinParser.mjs +84 -1
  66. package/src/core/altium/SchematicPrimitiveParser.mjs +301 -0
  67. package/src/core/altium/SchematicProjectParameterResolver.mjs +361 -0
  68. package/src/core/altium/SchematicQaReportBuilder.mjs +284 -0
  69. package/src/core/altium/SchematicRecordTypeRegistry.mjs +137 -0
  70. package/src/core/altium/SchematicRepeatedChannelParser.mjs +229 -0
  71. package/src/core/altium/SchematicStreamExtractor.mjs +10 -1
  72. package/src/core/altium/SchematicTemplateParser.mjs +256 -0
  73. package/src/core/altium/SchematicTextParser.mjs +123 -0
  74. package/src/core/ole/OleCompoundDocument.mjs +20 -0
  75. package/src/parser.mjs +29 -0
  76. package/src/styles/altium-renderers.css +19 -0
  77. package/src/ui/PcbBarcodeTextRenderer.mjs +436 -0
  78. package/src/ui/PcbInteractionIndex.mjs +9 -4
  79. package/src/ui/PcbScene3dBuilder.mjs +137 -3
  80. package/src/ui/PcbScene3dModelRegistry.mjs +74 -0
  81. package/src/ui/PcbSvgRenderer.mjs +1187 -34
  82. package/src/ui/PcbTextPrimitiveRenderer.mjs +193 -7
  83. package/src/ui/SchematicNoteRenderer.mjs +9 -2
  84. package/src/ui/SchematicOwnerPinLabelLayout.mjs +206 -0
  85. package/src/ui/SchematicShapeRenderer.mjs +362 -0
  86. package/src/ui/SchematicSvgRenderer.mjs +1442 -92
  87. package/src/ui/SchematicTypography.mjs +48 -5
  88. package/src/ui/TextGeometrySidecarBuilder.mjs +147 -0
@@ -0,0 +1,436 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { SchematicSvgUtils } from './SchematicSvgUtils.mjs'
6
+
7
+ /**
8
+ * Renders PCB barcode text primitives as deterministic SVG bar groups.
9
+ */
10
+ export class PcbBarcodeTextRenderer {
11
+ static #DEFAULT_MODULE_WIDTH = 2
12
+ static #DEFAULT_HEIGHT_RATIO = 1.8
13
+ static #CAPTION_RATIO = 0.34
14
+
15
+ static #CODE39_PATTERNS = new Map([
16
+ ['0', '101001101101'],
17
+ ['1', '110100101011'],
18
+ ['2', '101100101011'],
19
+ ['3', '110110010101'],
20
+ ['4', '101001101011'],
21
+ ['5', '110100110101'],
22
+ ['6', '101100110101'],
23
+ ['7', '101001011011'],
24
+ ['8', '110100101101'],
25
+ ['9', '101100101101'],
26
+ ['A', '110101001011'],
27
+ ['B', '101101001011'],
28
+ ['C', '110110100101'],
29
+ ['D', '101011001011'],
30
+ ['E', '110101100101'],
31
+ ['F', '101101100101'],
32
+ ['G', '101010011011'],
33
+ ['H', '110101001101'],
34
+ ['I', '101101001101'],
35
+ ['J', '101011001101'],
36
+ ['K', '110101010011'],
37
+ ['L', '101101010011'],
38
+ ['M', '110110101001'],
39
+ ['N', '101011010011'],
40
+ ['O', '110101101001'],
41
+ ['P', '101101101001'],
42
+ ['Q', '101010110011'],
43
+ ['R', '110101011001'],
44
+ ['S', '101101011001'],
45
+ ['T', '101011011001'],
46
+ ['U', '110010101011'],
47
+ ['V', '100110101011'],
48
+ ['W', '110011010101'],
49
+ ['X', '100101101011'],
50
+ ['Y', '110010110101'],
51
+ ['Z', '100110110101'],
52
+ ['-', '100101011011'],
53
+ ['.', '110010101101'],
54
+ [' ', '100110101101'],
55
+ ['$', '100100100101'],
56
+ ['/', '100100101001'],
57
+ ['+', '100101001001'],
58
+ ['%', '101001001001'],
59
+ ['*', '100101101101']
60
+ ])
61
+
62
+ static #CODE128_PATTERNS = [
63
+ '11011001100',
64
+ '11001101100',
65
+ '11001100110',
66
+ '10010011000',
67
+ '10010001100',
68
+ '10001001100',
69
+ '10011001000',
70
+ '10011000100',
71
+ '10001100100',
72
+ '11001001000',
73
+ '11001000100',
74
+ '11000100100',
75
+ '10110011100',
76
+ '10011011100',
77
+ '10011001110',
78
+ '10111001100',
79
+ '10011101100',
80
+ '10011100110',
81
+ '11001110010',
82
+ '11001011100',
83
+ '11001001110',
84
+ '11011100100',
85
+ '11001110100',
86
+ '11101101110',
87
+ '11101001100',
88
+ '11100101100',
89
+ '11100100110',
90
+ '11101100100',
91
+ '11100110100',
92
+ '11100110010',
93
+ '11011011000',
94
+ '11011000110',
95
+ '11000110110',
96
+ '10100011000',
97
+ '10001011000',
98
+ '10001000110',
99
+ '10110001000',
100
+ '10001101000',
101
+ '10001100010',
102
+ '11010001000',
103
+ '11000101000',
104
+ '11000100010',
105
+ '10110111000',
106
+ '10110001110',
107
+ '10001101110',
108
+ '10111011000',
109
+ '10111000110',
110
+ '10001110110',
111
+ '11101110110',
112
+ '11010001110',
113
+ '11000101110',
114
+ '11011101000',
115
+ '11011100010',
116
+ '11011101110',
117
+ '11101011000',
118
+ '11101000110',
119
+ '11100010110',
120
+ '11101101000',
121
+ '11101100010',
122
+ '11100011010',
123
+ '11101111010',
124
+ '11001000010',
125
+ '11110001010',
126
+ '10100110000',
127
+ '10100001100',
128
+ '10010110000',
129
+ '10010000110',
130
+ '10000101100',
131
+ '10000100110',
132
+ '10110010000',
133
+ '10110000100',
134
+ '10011010000',
135
+ '10011000010',
136
+ '10000110100',
137
+ '10000110010',
138
+ '11000010010',
139
+ '11001010000',
140
+ '11110111010',
141
+ '11000010100',
142
+ '10001111010',
143
+ '10100111100',
144
+ '10010111100',
145
+ '10010011110',
146
+ '10111100100',
147
+ '10011110100',
148
+ '10011110010',
149
+ '11110100100',
150
+ '11110010100',
151
+ '11110010010',
152
+ '11011011110',
153
+ '11011110110',
154
+ '11110110110',
155
+ '10101111000',
156
+ '10100011110',
157
+ '10001011110',
158
+ '10111101000',
159
+ '10111100010',
160
+ '11110101000',
161
+ '11110100010',
162
+ '10111011110',
163
+ '10111101110',
164
+ '11101011110',
165
+ '11110101110',
166
+ '11010000100',
167
+ '11010010000',
168
+ '11010011100',
169
+ '1100011101011'
170
+ ]
171
+
172
+ /**
173
+ * Renders one barcode text primitive.
174
+ * @param {{ text: string, layerId?: number, barcode?: object }} text Text primitive.
175
+ * @param {{ transform: string, fontSize: number, semanticAttributes: string }} options Render options.
176
+ * @returns {string}
177
+ */
178
+ static render(text, options) {
179
+ const encoding = PcbBarcodeTextRenderer.#encoding(text)
180
+ const layout = PcbBarcodeTextRenderer.#layout(
181
+ text,
182
+ options.fontSize,
183
+ encoding.pattern.length
184
+ )
185
+ const bars = PcbBarcodeTextRenderer.#bars(encoding.pattern, layout)
186
+ const className =
187
+ 'pcb-text pcb-text--layer-' +
188
+ SchematicSvgUtils.escapeHtml(String(Number(text.layerId || 0))) +
189
+ ' pcb-text--barcode' +
190
+ (text?.barcode?.inverted ? ' pcb-text--barcode-inverted' : '')
191
+ const background = text?.barcode?.inverted
192
+ ? '<rect class="pcb-barcode__background" x="0" y="0" width="' +
193
+ SchematicSvgUtils.formatNumber(layout.width) +
194
+ '" height="' +
195
+ SchematicSvgUtils.formatNumber(layout.height) +
196
+ '" />'
197
+ : ''
198
+ const caption = text?.barcode?.showText
199
+ ? PcbBarcodeTextRenderer.#caption(text, layout)
200
+ : ''
201
+
202
+ return (
203
+ '<g class="' +
204
+ className +
205
+ '" transform="' +
206
+ options.transform +
207
+ '"' +
208
+ options.semanticAttributes +
209
+ ' data-barcode-symbology="' +
210
+ SchematicSvgUtils.escapeHtml(encoding.symbology) +
211
+ '" data-barcode-module-count="' +
212
+ SchematicSvgUtils.escapeHtml(String(encoding.pattern.length)) +
213
+ '"' +
214
+ '>' +
215
+ background +
216
+ '<g class="pcb-barcode__bars" transform="translate(' +
217
+ SchematicSvgUtils.formatNumber(layout.marginX) +
218
+ ' ' +
219
+ SchematicSvgUtils.formatNumber(layout.marginY) +
220
+ ')">' +
221
+ bars +
222
+ '</g>' +
223
+ caption +
224
+ '</g>'
225
+ )
226
+ }
227
+
228
+ /**
229
+ * Resolves barcode layout dimensions.
230
+ * @param {{ text: string, height?: number, barcode?: object }} text Text primitive.
231
+ * @param {number} fontSize Resolved text font size.
232
+ * @param {number} patternLength Encoded module count.
233
+ * @returns {{ width: number, height: number, barHeight: number, marginX: number, marginY: number, moduleWidth: number }}
234
+ */
235
+ static #layout(text, fontSize, patternLength) {
236
+ const barcode = text?.barcode || {}
237
+ const moduleWidth = Math.max(
238
+ Number(barcode.minBarWidth) ||
239
+ PcbBarcodeTextRenderer.#DEFAULT_MODULE_WIDTH,
240
+ 0.1
241
+ )
242
+ const contentWidth = patternLength * moduleWidth
243
+ const marginX = Math.max(Number(barcode.marginX) || moduleWidth, 0)
244
+ const marginY = Math.max(Number(barcode.marginY) || moduleWidth, 0)
245
+ const width = Math.max(
246
+ Number(barcode.fullWidth) || 0,
247
+ contentWidth + marginX * 2
248
+ )
249
+ const height = Math.max(
250
+ Number(barcode.fullHeight) || 0,
251
+ fontSize * PcbBarcodeTextRenderer.#DEFAULT_HEIGHT_RATIO
252
+ )
253
+ const captionHeight = barcode.showText
254
+ ? fontSize * PcbBarcodeTextRenderer.#CAPTION_RATIO
255
+ : 0
256
+
257
+ return {
258
+ width,
259
+ height,
260
+ barHeight: Math.max(height - marginY * 2 - captionHeight, 1),
261
+ marginX,
262
+ marginY,
263
+ moduleWidth:
264
+ contentWidth > 0
265
+ ? Math.max((width - marginX * 2) / patternLength, 0.1)
266
+ : moduleWidth
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Renders barcode bars.
272
+ * @param {string} pattern Encoded barcode module pattern.
273
+ * @param {{ barHeight: number, moduleWidth: number }} layout Barcode layout.
274
+ * @returns {string}
275
+ */
276
+ static #bars(pattern, layout) {
277
+ const runs = []
278
+ let cursor = 0
279
+
280
+ while (cursor < pattern.length) {
281
+ const value = pattern[cursor]
282
+ let length = 1
283
+ while (pattern[cursor + length] === value) {
284
+ length += 1
285
+ }
286
+ if (value === '1') {
287
+ runs.push({ offset: cursor, length })
288
+ }
289
+ cursor += length
290
+ }
291
+
292
+ return runs
293
+ .map(
294
+ (run) =>
295
+ '<rect class="pcb-barcode__bar" x="' +
296
+ SchematicSvgUtils.formatNumber(
297
+ run.offset * layout.moduleWidth
298
+ ) +
299
+ '" y="0" width="' +
300
+ SchematicSvgUtils.formatNumber(
301
+ run.length * layout.moduleWidth
302
+ ) +
303
+ '" height="' +
304
+ SchematicSvgUtils.formatNumber(layout.barHeight) +
305
+ '" />'
306
+ )
307
+ .join('')
308
+ }
309
+
310
+ /**
311
+ * Renders optional human-readable barcode text.
312
+ * @param {{ text: string }} text Text primitive.
313
+ * @param {{ width: number, height: number, marginY: number }} layout Barcode layout.
314
+ * @returns {string}
315
+ */
316
+ static #caption(text, layout) {
317
+ const fontSize = Math.max(layout.height * 0.16, 4)
318
+
319
+ return (
320
+ '<text class="pcb-barcode__caption" x="' +
321
+ SchematicSvgUtils.formatNumber(layout.width / 2) +
322
+ '" y="' +
323
+ SchematicSvgUtils.formatNumber(layout.height - layout.marginY) +
324
+ '" font-size="' +
325
+ SchematicSvgUtils.formatNumber(fontSize) +
326
+ '" text-anchor="middle" dominant-baseline="alphabetic">' +
327
+ SchematicSvgUtils.escapeHtml(String(text.text || '')) +
328
+ '</text>'
329
+ )
330
+ }
331
+
332
+ /**
333
+ * Encodes one barcode payload.
334
+ * @param {{ text: string, barcode?: object }} text Text primitive.
335
+ * @returns {{ pattern: string, symbology: string }}
336
+ */
337
+ static #encoding(text) {
338
+ const kind = String(text?.barcode?.kindName || '').toLowerCase()
339
+ if (kind === 'code39') {
340
+ return PcbBarcodeTextRenderer.#encodeCode39(text)
341
+ }
342
+ if (kind === 'code128') {
343
+ return PcbBarcodeTextRenderer.#encodeCode128B(text)
344
+ }
345
+
346
+ return {
347
+ pattern: PcbBarcodeTextRenderer.#fallbackPattern(text),
348
+ symbology: 'deterministic'
349
+ }
350
+ }
351
+
352
+ /**
353
+ * Encodes valid Code 39 content.
354
+ * @param {{ text: string }} text Text primitive.
355
+ * @returns {{ pattern: string, symbology: string }}
356
+ */
357
+ static #encodeCode39(text) {
358
+ const content = String(text?.text || '').toUpperCase()
359
+ const encoded = '*' + content + '*'
360
+ const parts = []
361
+
362
+ for (const character of encoded) {
363
+ const pattern =
364
+ PcbBarcodeTextRenderer.#CODE39_PATTERNS.get(character)
365
+ if (!pattern) {
366
+ return {
367
+ pattern: PcbBarcodeTextRenderer.#fallbackPattern(text),
368
+ symbology: 'deterministic'
369
+ }
370
+ }
371
+ parts.push(pattern)
372
+ }
373
+
374
+ return {
375
+ pattern: parts.join('0'),
376
+ symbology: 'Code 39'
377
+ }
378
+ }
379
+
380
+ /**
381
+ * Encodes printable ASCII content as Code 128 set B.
382
+ * @param {{ text: string }} text Text primitive.
383
+ * @returns {{ pattern: string, symbology: string }}
384
+ */
385
+ static #encodeCode128B(text) {
386
+ const values = [104]
387
+ for (const character of String(text?.text || '')) {
388
+ const codePoint = character.codePointAt(0) || 63
389
+ const value =
390
+ codePoint >= 32 && codePoint <= 127 ? codePoint - 32 : 31
391
+ values.push(value)
392
+ }
393
+
394
+ let checksum = values[0]
395
+ for (let index = 1; index < values.length; index += 1) {
396
+ checksum += values[index] * index
397
+ }
398
+ values.push(checksum % 103)
399
+ values.push(106)
400
+
401
+ return {
402
+ pattern: values
403
+ .map((value) => PcbBarcodeTextRenderer.#CODE128_PATTERNS[value])
404
+ .join(''),
405
+ symbology: 'Code 128B'
406
+ }
407
+ }
408
+
409
+ /**
410
+ * Builds a deterministic fallback module pattern for unsupported content.
411
+ * @param {{ text: string }} text Text primitive.
412
+ * @returns {string}
413
+ */
414
+ static #fallbackPattern(text) {
415
+ const value = String(text?.text || '')
416
+ const parts = ['11010010000']
417
+ for (const character of value) {
418
+ parts.push(
419
+ PcbBarcodeTextRenderer.#fallbackCharacterPattern(character)
420
+ )
421
+ }
422
+ parts.push('1100011101011')
423
+ return parts.join('0')
424
+ }
425
+
426
+ /**
427
+ * Builds a stable 11-module fallback pattern for one character.
428
+ * @param {string} character Barcode character.
429
+ * @returns {string}
430
+ */
431
+ static #fallbackCharacterPattern(character) {
432
+ const code = character.codePointAt(0) || 0
433
+ const mixed = (code * 1103515245 + 12345) >>> 0
434
+ return mixed.toString(2).padStart(32, '0').slice(0, 11)
435
+ }
436
+ }
@@ -469,12 +469,17 @@ export class PcbInteractionIndex {
469
469
  const componentIndex = Number(primitive?.componentIndex)
470
470
  if (!Number.isInteger(componentIndex)) return null
471
471
 
472
- return (
472
+ const explicitMatch =
473
473
  context.components.find(
474
- (component, index) =>
475
- Number(component?.componentIndex) === componentIndex ||
476
- index === componentIndex
474
+ (component) =>
475
+ Number(component?.componentIndex) === componentIndex
477
476
  ) || null
477
+ if (explicitMatch) return explicitMatch
478
+
479
+ return (
480
+ context.components.find((component, index) => {
481
+ return index === componentIndex
482
+ }) || null
478
483
  )
479
484
  }
480
485
 
@@ -18,7 +18,7 @@ export class PcbScene3dBuilder {
18
18
  static #DENSE_OVERLAY_MIN_REGION_AREA_RATIO = 0.2
19
19
  static #DENSE_OVERLAY_MIN_TRACK_COUNT = 250
20
20
  static #DENSE_OVERLAY_KNOCKOUT_COLOR = 0x2f6a2c
21
- static #PRECISE_BODY_MATCH_TOLERANCE_MIL = 5
21
+ static #PRECISE_BODY_MATCH_TOLERANCE_MIL = 20
22
22
  static #UNMATCHED_BODY_OVERHANG_RATIO = 0.25
23
23
  static #UNMATCHED_BODY_MIN_OVERHANG_MIL = 150
24
24
  static #UNMATCHED_BODY_MAX_OVERHANG_MIL = 600
@@ -27,8 +27,8 @@ export class PcbScene3dBuilder {
27
27
  /**
28
28
  * Builds a scene description for host 3D renderers.
29
29
  * @param {{ pcb?: { boardOutline?: { widthMil?: number, heightMil?: number, minX?: number, minY?: number, segments?: Array<Record<string, number | string>> }, primitiveLayers?: { layerId: number, name: string }[], pads?: { x: number, y: number, sizeTopX?: number, sizeTopY?: number, sizeMidX?: number, sizeMidY?: number, sizeBottomX?: number, sizeBottomY?: number }[], tracks?: any[], arcs?: any[], fills?: any[], vias?: any[], polygons?: any[], embeddedModels?: any[], componentBodies?: { modelId?: string, checksum?: number | null, embedded?: boolean, name?: string, identifier?: string, positionMil?: { x?: number, y?: number }, rotationDeg?: number, modelRotationDeg?: { x?: number, y?: number, z?: number }, dzMil?: number }[], components?: { designator: string, x: number, y: number, layer?: string, pattern?: string, rotation?: number, height?: number | null, source?: string, modelPath?: string }[] } }} documentModel
30
- * @param {{ modelRegistry?: { resolveComponentModel: (component: any) => { name: string, relativePath: string, format: string } | null, resolveComponentBodyModel?: (componentBody: any) => { origin: string, name: string, format: string, payloadText?: string, sourceStream?: string, relativePath?: string } | null } | null, boardThicknessMil?: number }} [options]
31
- * @returns {{ board: { widthMil: number, heightMil: number, thicknessMil: number, minX: number, minY: number, centerX: number, centerY: number, segments: Array<Record<string, number | string>> }, components: { designator: string, mountSide: string, rotationDeg: number, positionMil: { x: number, y: number, z: number }, boardPositionMil: { x: number, y: number, z: number }, pattern: string, source: string, body: { family: string, sizeMil: { width: number, depth: number, height: number } }, externalModel: { name: string, relativePath: string, format: string } | null }[], externalPlacements: { designator: string, mountSide: string, rotationDeg: number, positionMil: { x: number, y: number, z: number }, bodyPositionMil: { x: number, y: number }, bodyRotationDeg: number, modelTransform: { rotationDeg: { x: number, y: number, z: number }, dzMil: number }, externalModel: { origin: string, name: string, format: string, payloadText?: string, sourceStream?: string, relativePath?: string } }[], detail: { pads: any[], tracks: any[], arcs: any[], fills: any[], vias: any[], polygons: any[], silkscreen: { top: { fills: any[], tracks: any[], arcs: any[], texts: any[], fillColor?: number, strokeColor?: number }, bottom: { fills: any[], tracks: any[], arcs: any[], texts: any[], fillColor?: number, strokeColor?: number } } } }}
30
+ * @param {{ modelRegistry?: { resolveComponentModel: (component: any) => { name: string, relativePath: string, format: string } | null, resolveComponentBodyModel?: (componentBody: any) => { origin: string, name: string, format: string, payloadText?: string, sourceStream?: string, relativePath?: string } | null, resolveBoardAssemblyModel?: (documentModel: any) => { origin: string, name: string, format: string, file?: File | Blob | null, relativePath?: string } | null } | null, boardThicknessMil?: number }} [options]
31
+ * @returns {{ board: { widthMil: number, heightMil: number, thicknessMil: number, minX: number, minY: number, centerX: number, centerY: number, segments: Array<Record<string, number | string>> }, boardAssemblyModel: { origin: string, name: string, format: string, file?: File | Blob | null, relativePath?: string } | null, components: { designator: string, mountSide: string, rotationDeg: number, positionMil: { x: number, y: number, z: number }, boardPositionMil: { x: number, y: number, z: number }, pattern: string, source: string, body: { family: string, sizeMil: { width: number, depth: number, height: number } }, externalModel: { name: string, relativePath: string, format: string } | null }[], externalPlacements: { designator: string, mountSide: string, rotationDeg: number, positionMil: { x: number, y: number, z: number }, bodyPositionMil: { x: number, y: number }, bodyRotationDeg: number, modelTransform: { rotationDeg: { x: number, y: number, z: number }, dzMil: number }, externalModel: { origin: string, name: string, format: string, payloadText?: string, sourceStream?: string, relativePath?: string } }[], detail: { pads: any[], tracks: any[], arcs: any[], fills: any[], vias: any[], polygons: any[], silkscreen: { top: { fills: any[], tracks: any[], arcs: any[], texts: any[], fillColor?: number, strokeColor?: number }, bottom: { fills: any[], tracks: any[], arcs: any[], texts: any[], fillColor?: number, strokeColor?: number } } } }}
32
32
  */
33
33
  static build(documentModel, options = {}) {
34
34
  const pcb = documentModel?.pcb || {}
@@ -63,6 +63,10 @@ export class PcbScene3dBuilder {
63
63
  centerY:
64
64
  Number(boardOutline.minY || 0) +
65
65
  Number(boardOutline.heightMil || 0) / 2,
66
+ surfaceColor: Number.isInteger(appearance3d.solderMaskTopColor)
67
+ ? appearance3d.solderMaskTopColor
68
+ : appearance3d.solderMaskBottomColor,
69
+ edgeColor: appearance3d.boardCoreColor,
66
70
  segments: Array.isArray(boardOutline.segments)
67
71
  ? boardOutline.segments
68
72
  : []
@@ -106,6 +110,9 @@ export class PcbScene3dBuilder {
106
110
  const sceneDescription = {
107
111
  sourceFormat: 'altium',
108
112
  board,
113
+ boardAssemblyModel:
114
+ modelRegistry?.resolveBoardAssemblyModel?.(documentModel) ||
115
+ null,
109
116
  components: components.map((component) =>
110
117
  PcbScene3dBuilder.#buildComponent(
111
118
  component,
@@ -121,6 +128,7 @@ export class PcbScene3dBuilder {
121
128
  componentBody,
122
129
  bodyMatches[index],
123
130
  components,
131
+ pads,
124
132
  board,
125
133
  thicknessMil,
126
134
  modelRegistry
@@ -208,6 +216,7 @@ export class PcbScene3dBuilder {
208
216
  * @param {{ modelId?: string, checksum?: number | null, embedded?: boolean, name?: string, identifier?: string, layer?: string, positionMil?: { x?: number, y?: number }, rotationDeg?: number, modelRotationDeg?: { x?: number, y?: number, z?: number }, dzMil?: number }} componentBody
209
217
  * @param {{ designator: string, x: number, y: number, layer?: string, pattern?: string, rotation?: number, height?: number | null } | null} matchedComponent
210
218
  * @param {{ designator: string, x: number, y: number, layer?: string, pattern?: string, source?: string, modelPath?: string }[]} components
219
+ * @param {{ x: number, y: number, sizeTopX?: number, sizeTopY?: number, sizeMidX?: number, sizeMidY?: number, sizeBottomX?: number, sizeBottomY?: number }[]} pads
211
220
  * @param {{ centerX: number, centerY: number }} board
212
221
  * @param {number} thicknessMil
213
222
  * @param {{ resolveComponentBodyModel?: (componentBody: any) => { origin: string, name: string, format: string, payloadText?: string, sourceStream?: string, relativePath?: string } | null } | null} modelRegistry
@@ -217,6 +226,7 @@ export class PcbScene3dBuilder {
217
226
  componentBody,
218
227
  matchedComponent,
219
228
  components,
229
+ pads,
220
230
  board,
221
231
  thicknessMil,
222
232
  modelRegistry
@@ -275,10 +285,134 @@ export class PcbScene3dBuilder {
275
285
  rotationDeg: modelRotation,
276
286
  dzMil: Number(componentBody.dzMil || 0)
277
287
  },
288
+ projection: PcbScene3dBuilder.#resolveProjectionDiagnostics(
289
+ componentBody,
290
+ matchedComponent,
291
+ pads,
292
+ resolvedModel
293
+ ),
278
294
  externalModel: resolvedModel
279
295
  }
280
296
  }
281
297
 
298
+ /**
299
+ * Explains which footprint projection source informed one external model.
300
+ * @param {object} componentBody Normalized component body row.
301
+ * @param {{ x: number, y: number, height?: number | null } | null} matchedComponent Matched component.
302
+ * @param {object[]} pads Normalized pad rows.
303
+ * @param {object | null} resolvedModel Resolved model metadata.
304
+ * @returns {{ source: string, reason: string, boundsMil: { width: number, depth: number, height: number } }}
305
+ */
306
+ static #resolveProjectionDiagnostics(
307
+ componentBody,
308
+ matchedComponent,
309
+ pads,
310
+ resolvedModel
311
+ ) {
312
+ const authoredBounds = PcbScene3dBuilder.#firstBounds([
313
+ componentBody?.projectionOverrideMil,
314
+ componentBody?.projectionOverride?.boundsMil,
315
+ componentBody?.projectionBoundsMil
316
+ ])
317
+ if (authoredBounds) {
318
+ return {
319
+ source: 'authored-override',
320
+ reason: 'Component body carried an explicit projection override.',
321
+ boundsMil: authoredBounds
322
+ }
323
+ }
324
+
325
+ const modelBounds = PcbScene3dBuilder.#firstBounds([
326
+ componentBody?.modelBoundsMil,
327
+ resolvedModel?.boundsMil
328
+ ])
329
+ if (modelBounds) {
330
+ return {
331
+ source: 'model-bounds',
332
+ reason: 'Resolved 3D model bounds were available.',
333
+ boundsMil: modelBounds
334
+ }
335
+ }
336
+
337
+ if (matchedComponent) {
338
+ const padSpan = PcbScene3dBuilder.#resolvePadSpan(
339
+ matchedComponent,
340
+ pads
341
+ )
342
+ if (padSpan.width > 0 || padSpan.depth > 0) {
343
+ return {
344
+ source: 'pad-fallback',
345
+ reason: 'Projection fell back to nearby component pad span.',
346
+ boundsMil: {
347
+ width: padSpan.width,
348
+ depth: padSpan.depth,
349
+ height: Number(matchedComponent.height || 0)
350
+ }
351
+ }
352
+ }
353
+
354
+ const body = PcbScene3dPackages.resolve(matchedComponent, padSpan)
355
+ return {
356
+ source: 'component-fallback',
357
+ reason: 'Projection fell back to the procedural component body.',
358
+ boundsMil: {
359
+ width: body.sizeMil.width,
360
+ depth: body.sizeMil.depth,
361
+ height: body.sizeMil.height
362
+ }
363
+ }
364
+ }
365
+
366
+ return {
367
+ source: 'model-anchor-fallback',
368
+ reason: 'Projection used the model anchor because no owner geometry was available.',
369
+ boundsMil: { width: 0, depth: 0, height: 0 }
370
+ }
371
+ }
372
+
373
+ /**
374
+ * Returns the first complete bounds object from candidate metadata.
375
+ * @param {unknown[]} candidates Candidate bounds records.
376
+ * @returns {{ width: number, depth: number, height: number } | null}
377
+ */
378
+ static #firstBounds(candidates) {
379
+ for (const candidate of candidates || []) {
380
+ const bounds = PcbScene3dBuilder.#normalizeBounds(candidate)
381
+ if (bounds) {
382
+ return bounds
383
+ }
384
+ }
385
+
386
+ return null
387
+ }
388
+
389
+ /**
390
+ * Normalizes width/depth/height bounds metadata.
391
+ * @param {unknown} candidate Candidate bounds record.
392
+ * @returns {{ width: number, depth: number, height: number } | null}
393
+ */
394
+ static #normalizeBounds(candidate) {
395
+ if (!candidate || typeof candidate !== 'object') {
396
+ return null
397
+ }
398
+
399
+ const width = Number(candidate.width ?? candidate.x ?? candidate.sizeX)
400
+ const depth = Number(candidate.depth ?? candidate.y ?? candidate.sizeY)
401
+ const height = Number(
402
+ candidate.height ?? candidate.z ?? candidate.sizeZ
403
+ )
404
+
405
+ if (
406
+ !Number.isFinite(width) ||
407
+ !Number.isFinite(depth) ||
408
+ !Number.isFinite(height)
409
+ ) {
410
+ return null
411
+ }
412
+
413
+ return { width, depth, height }
414
+ }
415
+
282
416
  /**
283
417
  * Resolves explicit body placements to component anchors using a unique
284
418
  * nearest-neighbor pass plus an ordered-affinity fallback for repeated