altium-toolkit 1.1.24 → 1.1.26

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 (35) hide show
  1. package/README.md +5 -3
  2. package/docs/api.md +43 -17
  3. package/docs/model-format.md +51 -11
  4. package/docs/schemas/altium_toolkit/library_compatibility_a1.schema.json +116 -0
  5. package/docs/schemas/altium_toolkit/library_qa_a1.schema.json +3 -0
  6. package/docs/schemas/altium_toolkit/unsupported_features_a1.schema.json +58 -1
  7. package/package.json +1 -1
  8. package/spec/library-compatibility.md +65 -0
  9. package/src/core/altium/AltiumParser.mjs +8 -1
  10. package/src/core/altium/LibraryCompatibilityGeometry.mjs +618 -0
  11. package/src/core/altium/LibraryCompatibilityModelHintBuilder.mjs +296 -0
  12. package/src/core/altium/LibraryCompatibilityReportBuilder.mjs +930 -0
  13. package/src/core/altium/LibraryQaReportBuilder.mjs +29 -1
  14. package/src/core/altium/PcbDimensionParser.mjs +1 -0
  15. package/src/core/altium/PcbDimensionReportBuilder.mjs +190 -0
  16. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +140 -10
  17. package/src/core/altium/PcbFabricationReadinessReportBuilder.mjs +607 -0
  18. package/src/core/altium/PcbInspectionReportBuilder.mjs +56 -3
  19. package/src/core/altium/PcbLayerStackReadModelBuilder.mjs +138 -3
  20. package/src/core/altium/PcbLibModelParser.mjs +24 -2
  21. package/src/core/altium/PcbLibStorageNameResolver.mjs +171 -0
  22. package/src/core/altium/PcbLibStreamExtractor.mjs +15 -93
  23. package/src/core/altium/PcbPrimitiveFieldOffsetReportBuilder.mjs +352 -0
  24. package/src/core/altium/PcbRuleImpactReportBuilder.mjs +313 -0
  25. package/src/core/altium/PcbShapeBasedBodyGeometryParser.mjs +514 -0
  26. package/src/core/altium/SchematicConnectivityQaBuilder.mjs +442 -2
  27. package/src/core/altium/SchematicImageDiagnosticsBuilder.mjs +214 -0
  28. package/src/core/altium/UnsupportedFeatureReportBuilder.mjs +523 -3
  29. package/src/parser.mjs +8 -0
  30. package/src/styles/altium-renderers.css +36 -0
  31. package/src/ui/PcbDimensionPrimitiveRenderer.mjs +422 -0
  32. package/src/ui/PcbScene3dBoardOutlineRefiner.mjs +6 -0
  33. package/src/ui/PcbScene3dBuilder.mjs +10 -2
  34. package/src/ui/PcbScene3dStaticBodyPlacementBuilder.mjs +225 -0
  35. package/src/ui/PcbSvgRenderer.mjs +78 -2
@@ -0,0 +1,422 @@
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 parsed PCB dimension records into static SVG primitives.
9
+ */
10
+ export class PcbDimensionPrimitiveRenderer {
11
+ /**
12
+ * Builds the dimension SVG group.
13
+ * @param {object[]} dimensions Normalized dimension records.
14
+ * @param {{ attributes?: (dimension: object, index: number) => string }} options Render options.
15
+ * @returns {string}
16
+ */
17
+ static buildMarkup(dimensions, options = {}) {
18
+ const markup = (dimensions || [])
19
+ .map((dimension, index) =>
20
+ PcbDimensionPrimitiveRenderer.#renderDimension(
21
+ dimension,
22
+ index,
23
+ options.attributes
24
+ )
25
+ )
26
+ .filter(Boolean)
27
+ .join('')
28
+
29
+ return markup ? '<g class="pcb-dimensions">' + markup + '</g>' : ''
30
+ }
31
+
32
+ /**
33
+ * Pushes dimension extents into reusable viewBox coordinate arrays.
34
+ * @param {number[]} xs X extents.
35
+ * @param {number[]} ys Y extents.
36
+ * @param {object[]} dimensions Dimension records.
37
+ * @returns {void}
38
+ */
39
+ static pushExtents(xs, ys, dimensions) {
40
+ for (const dimension of dimensions || []) {
41
+ for (const point of [
42
+ ...(dimension?.references || []),
43
+ dimension?.textLocation
44
+ ]) {
45
+ if (!point) {
46
+ continue
47
+ }
48
+ const x = Number(point.x)
49
+ const y = Number(point.y)
50
+ if (Number.isFinite(x) && Number.isFinite(y)) {
51
+ xs.push(x)
52
+ ys.push(y)
53
+ }
54
+ }
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Renders one dimension record.
60
+ * @param {object} dimension Dimension record.
61
+ * @param {number} index Fallback index.
62
+ * @param {(dimension: object, index: number) => string | undefined} attributes Attribute callback.
63
+ * @returns {string}
64
+ */
65
+ static #renderDimension(dimension, index, attributes) {
66
+ const kind = PcbDimensionPrimitiveRenderer.#kind(dimension)
67
+ const references = PcbDimensionPrimitiveRenderer.#references(dimension)
68
+ const body = PcbDimensionPrimitiveRenderer.#bodyMarkup(
69
+ kind,
70
+ dimension,
71
+ references
72
+ )
73
+
74
+ if (!body) {
75
+ return ''
76
+ }
77
+
78
+ return (
79
+ '<g class="pcb-dimension pcb-dimension--' +
80
+ SchematicSvgUtils.escapeHtml(kind) +
81
+ '"' +
82
+ (typeof attributes === 'function'
83
+ ? attributes(dimension, index) || ''
84
+ : '') +
85
+ '>' +
86
+ body +
87
+ '</g>'
88
+ )
89
+ }
90
+
91
+ /**
92
+ * Renders the kind-specific dimension body.
93
+ * @param {string} kind Dimension kind.
94
+ * @param {object} dimension Dimension record.
95
+ * @param {{ x: number, y: number }[]} references Reference points.
96
+ * @returns {string}
97
+ */
98
+ static #bodyMarkup(kind, dimension, references) {
99
+ if (kind === 'angular') {
100
+ return PcbDimensionPrimitiveRenderer.#angularMarkup(
101
+ dimension,
102
+ references
103
+ )
104
+ }
105
+
106
+ if (['diameter', 'radial'].includes(kind)) {
107
+ return PcbDimensionPrimitiveRenderer.#leaderMarkup(
108
+ kind,
109
+ dimension,
110
+ references
111
+ )
112
+ }
113
+
114
+ return PcbDimensionPrimitiveRenderer.#linearMarkup(
115
+ dimension,
116
+ references
117
+ )
118
+ }
119
+
120
+ /**
121
+ * Renders one linear dimension.
122
+ * @param {object} dimension Dimension record.
123
+ * @param {{ x: number, y: number }[]} references Reference points.
124
+ * @returns {string}
125
+ */
126
+ static #linearMarkup(dimension, references) {
127
+ if (references.length < 2) {
128
+ return ''
129
+ }
130
+
131
+ const [start, end] = references
132
+ const label = PcbDimensionPrimitiveRenderer.#labelPoint(
133
+ dimension,
134
+ start,
135
+ end
136
+ )
137
+ const mid = PcbDimensionPrimitiveRenderer.#midpoint(start, end)
138
+
139
+ return (
140
+ PcbDimensionPrimitiveRenderer.#line(
141
+ 'pcb-dimension__measure',
142
+ start,
143
+ end
144
+ ) +
145
+ PcbDimensionPrimitiveRenderer.#line(
146
+ 'pcb-dimension__leader',
147
+ mid,
148
+ label
149
+ ) +
150
+ PcbDimensionPrimitiveRenderer.#label(dimension, label)
151
+ )
152
+ }
153
+
154
+ /**
155
+ * Renders one angular dimension.
156
+ * @param {object} dimension Dimension record.
157
+ * @param {{ x: number, y: number }[]} references Reference points.
158
+ * @returns {string}
159
+ */
160
+ static #angularMarkup(dimension, references) {
161
+ if (references.length < 3) {
162
+ return ''
163
+ }
164
+
165
+ const [start, vertex, end] = references
166
+ const startArc = PcbDimensionPrimitiveRenderer.#arcPoint(vertex, start)
167
+ const endArc = PcbDimensionPrimitiveRenderer.#arcPoint(vertex, end)
168
+ const radius = PcbDimensionPrimitiveRenderer.#distance(vertex, startArc)
169
+ const sweep =
170
+ PcbDimensionPrimitiveRenderer.#cross(vertex, start, end) >= 0
171
+ ? 1
172
+ : 0
173
+ const label =
174
+ PcbDimensionPrimitiveRenderer.#point(dimension?.textLocation) ||
175
+ PcbDimensionPrimitiveRenderer.#midpoint(startArc, endArc)
176
+
177
+ return (
178
+ PcbDimensionPrimitiveRenderer.#line(
179
+ 'pcb-dimension__extension',
180
+ vertex,
181
+ start
182
+ ) +
183
+ PcbDimensionPrimitiveRenderer.#line(
184
+ 'pcb-dimension__extension',
185
+ vertex,
186
+ end
187
+ ) +
188
+ '<path class="pcb-dimension__arc" d="M ' +
189
+ SchematicSvgUtils.formatNumber(startArc.x) +
190
+ ' ' +
191
+ SchematicSvgUtils.formatNumber(startArc.y) +
192
+ ' A ' +
193
+ SchematicSvgUtils.formatNumber(radius) +
194
+ ' ' +
195
+ SchematicSvgUtils.formatNumber(radius) +
196
+ ' 0 0 ' +
197
+ sweep +
198
+ ' ' +
199
+ SchematicSvgUtils.formatNumber(endArc.x) +
200
+ ' ' +
201
+ SchematicSvgUtils.formatNumber(endArc.y) +
202
+ '" />' +
203
+ PcbDimensionPrimitiveRenderer.#label(dimension, label)
204
+ )
205
+ }
206
+
207
+ /**
208
+ * Renders radial and diameter dimensions as leader dimensions.
209
+ * @param {string} kind Dimension kind.
210
+ * @param {object} dimension Dimension record.
211
+ * @param {{ x: number, y: number }[]} references Reference points.
212
+ * @returns {string}
213
+ */
214
+ static #leaderMarkup(kind, dimension, references) {
215
+ if (references.length < 2) {
216
+ return ''
217
+ }
218
+
219
+ const [start, end] = references
220
+ const label = PcbDimensionPrimitiveRenderer.#labelPoint(
221
+ dimension,
222
+ start,
223
+ end
224
+ )
225
+ const centerMark =
226
+ kind === 'diameter'
227
+ ? '<circle class="pcb-dimension__center" cx="' +
228
+ SchematicSvgUtils.formatNumber(start.x) +
229
+ '" cy="' +
230
+ SchematicSvgUtils.formatNumber(start.y) +
231
+ '" r="3" />'
232
+ : ''
233
+
234
+ return (
235
+ centerMark +
236
+ PcbDimensionPrimitiveRenderer.#line(
237
+ 'pcb-dimension__measure',
238
+ start,
239
+ end
240
+ ) +
241
+ PcbDimensionPrimitiveRenderer.#line(
242
+ 'pcb-dimension__leader',
243
+ end,
244
+ label
245
+ ) +
246
+ PcbDimensionPrimitiveRenderer.#label(dimension, label)
247
+ )
248
+ }
249
+
250
+ /**
251
+ * Renders one SVG line.
252
+ * @param {string} className SVG class name.
253
+ * @param {{ x: number, y: number }} start Start point.
254
+ * @param {{ x: number, y: number }} end End point.
255
+ * @returns {string}
256
+ */
257
+ static #line(className, start, end) {
258
+ return (
259
+ '<line class="' +
260
+ className +
261
+ '" x1="' +
262
+ SchematicSvgUtils.formatNumber(start.x) +
263
+ '" y1="' +
264
+ SchematicSvgUtils.formatNumber(start.y) +
265
+ '" x2="' +
266
+ SchematicSvgUtils.formatNumber(end.x) +
267
+ '" y2="' +
268
+ SchematicSvgUtils.formatNumber(end.y) +
269
+ '" />'
270
+ )
271
+ }
272
+
273
+ /**
274
+ * Renders one dimension label.
275
+ * @param {object} dimension Dimension record.
276
+ * @param {{ x: number, y: number }} point Label point.
277
+ * @returns {string}
278
+ */
279
+ static #label(dimension, point) {
280
+ const text =
281
+ dimension?.text ||
282
+ PcbDimensionPrimitiveRenderer.#formatMeasuredValue(dimension)
283
+ if (!text) {
284
+ return ''
285
+ }
286
+
287
+ return (
288
+ '<text class="pcb-dimension__label" x="' +
289
+ SchematicSvgUtils.formatNumber(point.x) +
290
+ '" y="' +
291
+ SchematicSvgUtils.formatNumber(point.y) +
292
+ '">' +
293
+ SchematicSvgUtils.escapeHtml(text) +
294
+ '</text>'
295
+ )
296
+ }
297
+
298
+ /**
299
+ * Resolves a display label from measured numeric fields.
300
+ * @param {object} dimension Dimension record.
301
+ * @returns {string}
302
+ */
303
+ static #formatMeasuredValue(dimension) {
304
+ const value =
305
+ dimension?.kind === 'angular'
306
+ ? dimension?.angleValue
307
+ : dimension?.measuredValue
308
+ if (!Number.isFinite(Number(value))) {
309
+ return ''
310
+ }
311
+
312
+ return (
313
+ SchematicSvgUtils.formatNumber(Number(value)) +
314
+ (dimension?.unit ? ' ' + String(dimension.unit).trim() : '')
315
+ )
316
+ }
317
+
318
+ /**
319
+ * Resolves a label point with a midpoint fallback.
320
+ * @param {object} dimension Dimension record.
321
+ * @param {{ x: number, y: number }} start Start point.
322
+ * @param {{ x: number, y: number }} end End point.
323
+ * @returns {{ x: number, y: number }}
324
+ */
325
+ static #labelPoint(dimension, start, end) {
326
+ return (
327
+ PcbDimensionPrimitiveRenderer.#point(dimension?.textLocation) ||
328
+ PcbDimensionPrimitiveRenderer.#midpoint(start, end)
329
+ )
330
+ }
331
+
332
+ /**
333
+ * Normalizes references into finite points.
334
+ * @param {object} dimension Dimension record.
335
+ * @returns {{ x: number, y: number }[]}
336
+ */
337
+ static #references(dimension) {
338
+ return (dimension?.references || [])
339
+ .map((reference) => PcbDimensionPrimitiveRenderer.#point(reference))
340
+ .filter(Boolean)
341
+ }
342
+
343
+ /**
344
+ * Normalizes one point-like object.
345
+ * @param {object | null | undefined} point Point candidate.
346
+ * @returns {{ x: number, y: number } | null}
347
+ */
348
+ static #point(point) {
349
+ const x = Number(point?.x)
350
+ const y = Number(point?.y)
351
+ return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : null
352
+ }
353
+
354
+ /**
355
+ * Resolves the canonical dimension kind.
356
+ * @param {object} dimension Dimension record.
357
+ * @returns {string}
358
+ */
359
+ static #kind(dimension) {
360
+ const kind = String(dimension?.kind || 'linear')
361
+ .trim()
362
+ .toLowerCase()
363
+ return kind || 'linear'
364
+ }
365
+
366
+ /**
367
+ * Returns the midpoint between two points.
368
+ * @param {{ x: number, y: number }} start Start point.
369
+ * @param {{ x: number, y: number }} end End point.
370
+ * @returns {{ x: number, y: number }}
371
+ */
372
+ static #midpoint(start, end) {
373
+ return {
374
+ x: (start.x + end.x) / 2,
375
+ y: (start.y + end.y) / 2
376
+ }
377
+ }
378
+
379
+ /**
380
+ * Returns a stable point on an angular dimension arc.
381
+ * @param {{ x: number, y: number }} vertex Arc center.
382
+ * @param {{ x: number, y: number }} reference Reference point.
383
+ * @returns {{ x: number, y: number }}
384
+ */
385
+ static #arcPoint(vertex, reference) {
386
+ const dx = reference.x - vertex.x
387
+ const dy = reference.y - vertex.y
388
+ const length = Math.max(Math.sqrt(dx * dx + dy * dy), 1)
389
+ const radius = Math.min(Math.max(length * 0.55, 16), 80)
390
+
391
+ return {
392
+ x: vertex.x + (dx / length) * radius,
393
+ y: vertex.y + (dy / length) * radius
394
+ }
395
+ }
396
+
397
+ /**
398
+ * Calculates distance between two points.
399
+ * @param {{ x: number, y: number }} start Start point.
400
+ * @param {{ x: number, y: number }} end End point.
401
+ * @returns {number}
402
+ */
403
+ static #distance(start, end) {
404
+ const dx = end.x - start.x
405
+ const dy = end.y - start.y
406
+ return Math.max(Math.sqrt(dx * dx + dy * dy), 1)
407
+ }
408
+
409
+ /**
410
+ * Calculates the sign of the angle from start to end around vertex.
411
+ * @param {{ x: number, y: number }} vertex Vertex point.
412
+ * @param {{ x: number, y: number }} start Start point.
413
+ * @param {{ x: number, y: number }} end End point.
414
+ * @returns {number}
415
+ */
416
+ static #cross(vertex, start, end) {
417
+ return (
418
+ (start.x - vertex.x) * (end.y - vertex.y) -
419
+ (start.y - vertex.y) * (end.x - vertex.x)
420
+ )
421
+ }
422
+ }
@@ -70,6 +70,12 @@ export class PcbScene3dBoardOutlineRefiner {
70
70
  sceneDescription?.externalPlacements,
71
71
  board,
72
72
  refinedBoard
73
+ ),
74
+ staticBodyPlacements:
75
+ PcbScene3dBoardOutlineRefiner.#realignLocalPlacements(
76
+ sceneDescription?.staticBodyPlacements,
77
+ board,
78
+ refinedBoard
73
79
  )
74
80
  }
75
81
  }
@@ -8,6 +8,7 @@ import { PcbScene3dDrillCutoutBuilder } from './PcbScene3dDrillCutoutBuilder.mjs
8
8
  import { PcbFootprintPrimitiveSelector } from './PcbFootprintPrimitiveSelector.mjs'
9
9
  import { PcbScene3dPackages } from './PcbScene3dPackages.mjs'
10
10
  import { PcbScene3dPlacementSideResolver } from './PcbScene3dPlacementSideResolver.mjs'
11
+ import { PcbScene3dStaticBodyPlacementBuilder } from './PcbScene3dStaticBodyPlacementBuilder.mjs'
11
12
  import { PcbScene3dTextBoxLayoutResolver } from './PcbScene3dTextBoxLayoutResolver.mjs'
12
13
 
13
14
  /**
@@ -26,9 +27,9 @@ export class PcbScene3dBuilder {
26
27
 
27
28
  /**
28
29
  * Builds a scene description for host 3D renderers.
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 {{ 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, staticGeometry?: object }[], components?: { designator: string, x: number, y: number, layer?: string, pattern?: string, rotation?: number, height?: number | null, source?: string, modelPath?: string }[] } }} documentModel
30
31
  * @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
+ * @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 } }[], staticBodyPlacements: { designator: string, mountSide: string, rotationDeg: number, positionMil: { x: number, y: number, z: number }, bodyPositionMil: { x: number, y: number }, geometry: object }[], 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
33
  */
33
34
  static build(documentModel, options = {}) {
34
35
  const pcb = documentModel?.pcb || {}
@@ -142,6 +143,13 @@ export class PcbScene3dBuilder {
142
143
  )
143
144
  )
144
145
  .filter(Boolean),
146
+ staticBodyPlacements: PcbScene3dStaticBodyPlacementBuilder.build(
147
+ componentBodies,
148
+ bodyMatches,
149
+ components,
150
+ board,
151
+ thicknessMil
152
+ ),
145
153
  detail: {
146
154
  embeddedFonts: Array.isArray(pcb.embeddedFonts)
147
155
  ? pcb.embeddedFonts
@@ -0,0 +1,225 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { PcbScene3dPlacementSideResolver } from './PcbScene3dPlacementSideResolver.mjs'
6
+
7
+ /**
8
+ * Builds scene placements for static shape-based 3D bodies.
9
+ */
10
+ export class PcbScene3dStaticBodyPlacementBuilder {
11
+ static #UNMATCHED_BODY_OVERHANG_RATIO = 0.25
12
+ static #UNMATCHED_BODY_MIN_OVERHANG_MIL = 150
13
+ static #UNMATCHED_BODY_MAX_OVERHANG_MIL = 600
14
+
15
+ /**
16
+ * Builds static shape-body scene placements.
17
+ * @param {{ identifier?: string, name?: string, layer?: string, positionMil?: { x?: number, y?: number }, rotationDeg?: number, standoffHeightMil?: number | null, overallHeightMil?: number | null, staticGeometry?: object }[]} componentBodies Component bodies.
18
+ * @param {({ designator: string, x: number, y: number, layer?: string, pattern?: string, rotation?: number, height?: number | null } | null)[]} bodyMatches Matched components.
19
+ * @param {{ designator: string, x: number, y: number, layer?: string, pattern?: string, source?: string, modelPath?: string }[]} components Components.
20
+ * @param {{ centerX: number, centerY: number, minX?: number, minY?: number, widthMil?: number, heightMil?: number }} board Board.
21
+ * @param {number} thicknessMil Board thickness.
22
+ * @returns {{ designator: string, mountSide: string, rotationDeg: number, positionMil: { x: number, y: number, z: number }, bodyPositionMil: { x: number, y: number }, geometry: object }[]}
23
+ */
24
+ static build(
25
+ componentBodies,
26
+ bodyMatches,
27
+ components,
28
+ board,
29
+ thicknessMil
30
+ ) {
31
+ return (Array.isArray(componentBodies) ? componentBodies : [])
32
+ .map((componentBody, index) =>
33
+ PcbScene3dStaticBodyPlacementBuilder.#buildPlacement(
34
+ componentBody,
35
+ bodyMatches?.[index] || null,
36
+ components,
37
+ board,
38
+ thicknessMil
39
+ )
40
+ )
41
+ .filter(Boolean)
42
+ }
43
+
44
+ /**
45
+ * Builds one static shape-body scene placement.
46
+ * @param {{ identifier?: string, name?: string, layer?: string, positionMil?: { x?: number, y?: number }, rotationDeg?: number, standoffHeightMil?: number | null, overallHeightMil?: number | null, staticGeometry?: object }} componentBody Component body.
47
+ * @param {{ designator: string, x: number, y: number, layer?: string, pattern?: string, rotation?: number, height?: number | null } | null} matchedComponent Matched component.
48
+ * @param {{ designator: string, x: number, y: number, layer?: string, pattern?: string, source?: string, modelPath?: string }[]} components Components.
49
+ * @param {{ centerX: number, centerY: number, minX?: number, minY?: number, widthMil?: number, heightMil?: number }} board Board.
50
+ * @param {number} thicknessMil Board thickness.
51
+ * @returns {{ designator: string, mountSide: string, rotationDeg: number, positionMil: { x: number, y: number, z: number }, bodyPositionMil: { x: number, y: number }, geometry: object } | null}
52
+ */
53
+ static #buildPlacement(
54
+ componentBody,
55
+ matchedComponent,
56
+ components,
57
+ board,
58
+ thicknessMil
59
+ ) {
60
+ const geometry = componentBody?.staticGeometry
61
+
62
+ if (!geometry || geometry.status !== 'complete') {
63
+ return null
64
+ }
65
+
66
+ if (
67
+ !matchedComponent &&
68
+ !PcbScene3dStaticBodyPlacementBuilder.#isBodyPositionNearBoard(
69
+ componentBody,
70
+ board
71
+ )
72
+ ) {
73
+ return null
74
+ }
75
+
76
+ const mountSide = PcbScene3dPlacementSideResolver.resolvePlacementSide(
77
+ componentBody,
78
+ matchedComponent,
79
+ components,
80
+ board
81
+ )
82
+ const sourcePosition =
83
+ PcbScene3dStaticBodyPlacementBuilder.#sourcePosition(componentBody)
84
+ const heightMil =
85
+ PcbScene3dStaticBodyPlacementBuilder.#geometryHeight(geometry)
86
+ const standoffMil = Math.abs(
87
+ Number(
88
+ geometry.standoffHeightMil ?? componentBody.standoffHeightMil
89
+ )
90
+ )
91
+ const zOffset =
92
+ Number(thicknessMil || 0) / 2 +
93
+ (Number.isFinite(standoffMil) ? standoffMil : 0) +
94
+ heightMil / 2
95
+
96
+ return {
97
+ designator:
98
+ matchedComponent?.designator ||
99
+ String(
100
+ componentBody.identifier || componentBody.name || '3D body'
101
+ ),
102
+ mountSide,
103
+ rotationDeg: PcbScene3dStaticBodyPlacementBuilder.#normalizeAngle(
104
+ Number(componentBody.rotationDeg || 0) +
105
+ Number(matchedComponent?.rotation || 0)
106
+ ),
107
+ positionMil: {
108
+ x: PcbScene3dStaticBodyPlacementBuilder.#roundMil(
109
+ Number(sourcePosition.x || 0) - Number(board.centerX || 0)
110
+ ),
111
+ y: PcbScene3dStaticBodyPlacementBuilder.#roundMil(
112
+ Number(sourcePosition.y || 0) - Number(board.centerY || 0)
113
+ ),
114
+ z: PcbScene3dStaticBodyPlacementBuilder.#roundMil(
115
+ mountSide === 'bottom' ? -zOffset : zOffset
116
+ )
117
+ },
118
+ bodyPositionMil: {
119
+ x: Number(componentBody.positionMil?.x || 0),
120
+ y: Number(componentBody.positionMil?.y || 0)
121
+ },
122
+ geometry
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Returns the native body anchor.
128
+ * @param {{ positionMil?: { x?: number, y?: number } }} componentBody Component body.
129
+ * @returns {{ x: number, y: number }}
130
+ */
131
+ static #sourcePosition(componentBody) {
132
+ return {
133
+ x: Number(componentBody?.positionMil?.x || 0),
134
+ y: Number(componentBody?.positionMil?.y || 0)
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Resolves static body height from geometry metadata.
140
+ * @param {object} geometry Static geometry.
141
+ * @returns {number}
142
+ */
143
+ static #geometryHeight(geometry) {
144
+ const height = Number(geometry?.heightMil)
145
+ if (Number.isFinite(height) && height > 0) {
146
+ return height
147
+ }
148
+
149
+ const radius = Number(geometry?.radiusMil)
150
+ if (Number.isFinite(radius) && radius > 0) {
151
+ return radius * 2
152
+ }
153
+
154
+ return 0
155
+ }
156
+
157
+ /**
158
+ * Returns true when one body anchor lies close enough to the board.
159
+ * @param {{ positionMil?: { x?: number, y?: number } }} componentBody Component body.
160
+ * @param {{ minX?: number, minY?: number, widthMil?: number, heightMil?: number }} board Board.
161
+ * @returns {boolean}
162
+ */
163
+ static #isBodyPositionNearBoard(componentBody, board) {
164
+ const bodyX = Number(componentBody?.positionMil?.x || 0)
165
+ const bodyY = Number(componentBody?.positionMil?.y || 0)
166
+ const xOverhang =
167
+ PcbScene3dStaticBodyPlacementBuilder.#unmatchedBodyOverhang(
168
+ board?.widthMil
169
+ )
170
+ const yOverhang =
171
+ PcbScene3dStaticBodyPlacementBuilder.#unmatchedBodyOverhang(
172
+ board?.heightMil
173
+ )
174
+ const minX = Number(board?.minX || 0) - xOverhang
175
+ const minY = Number(board?.minY || 0) - yOverhang
176
+ const maxX =
177
+ Number(board?.minX || 0) + Number(board?.widthMil || 0) + xOverhang
178
+ const maxY =
179
+ Number(board?.minY || 0) + Number(board?.heightMil || 0) + yOverhang
180
+
181
+ return bodyX >= minX && bodyX <= maxX && bodyY >= minY && bodyY <= maxY
182
+ }
183
+
184
+ /**
185
+ * Resolves a proportional unresolved-body margin for one board axis.
186
+ * @param {number | string | undefined} spanMil Board axis span.
187
+ * @returns {number}
188
+ */
189
+ static #unmatchedBodyOverhang(spanMil) {
190
+ const proportional =
191
+ Math.max(Number(spanMil || 0), 0) *
192
+ PcbScene3dStaticBodyPlacementBuilder.#UNMATCHED_BODY_OVERHANG_RATIO
193
+
194
+ return Math.min(
195
+ PcbScene3dStaticBodyPlacementBuilder
196
+ .#UNMATCHED_BODY_MAX_OVERHANG_MIL,
197
+ Math.max(
198
+ proportional,
199
+ PcbScene3dStaticBodyPlacementBuilder
200
+ .#UNMATCHED_BODY_MIN_OVERHANG_MIL
201
+ )
202
+ )
203
+ }
204
+
205
+ /**
206
+ * Rounds one mil value for stable scene output.
207
+ * @param {number} value Candidate value.
208
+ * @returns {number}
209
+ */
210
+ static #roundMil(value) {
211
+ const rounded = Math.round(Number(value) * 10000) / 10000
212
+ return Object.is(rounded, -0) ? 0 : rounded
213
+ }
214
+
215
+ /**
216
+ * Normalizes one angle into the range [0, 360).
217
+ * @param {number} angle Candidate angle.
218
+ * @returns {number}
219
+ */
220
+ static #normalizeAngle(angle) {
221
+ const normalized = Number(angle || 0) % 360
222
+
223
+ return normalized < 0 ? normalized + 360 : normalized
224
+ }
225
+ }