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.
- package/README.md +5 -3
- package/docs/api.md +43 -17
- package/docs/model-format.md +51 -11
- package/docs/schemas/altium_toolkit/library_compatibility_a1.schema.json +116 -0
- package/docs/schemas/altium_toolkit/library_qa_a1.schema.json +3 -0
- package/docs/schemas/altium_toolkit/unsupported_features_a1.schema.json +58 -1
- package/package.json +1 -1
- package/spec/library-compatibility.md +65 -0
- package/src/core/altium/AltiumParser.mjs +8 -1
- package/src/core/altium/LibraryCompatibilityGeometry.mjs +618 -0
- package/src/core/altium/LibraryCompatibilityModelHintBuilder.mjs +296 -0
- package/src/core/altium/LibraryCompatibilityReportBuilder.mjs +930 -0
- package/src/core/altium/LibraryQaReportBuilder.mjs +29 -1
- package/src/core/altium/PcbDimensionParser.mjs +1 -0
- package/src/core/altium/PcbDimensionReportBuilder.mjs +190 -0
- package/src/core/altium/PcbEmbeddedModelExtractor.mjs +140 -10
- package/src/core/altium/PcbFabricationReadinessReportBuilder.mjs +607 -0
- package/src/core/altium/PcbInspectionReportBuilder.mjs +56 -3
- package/src/core/altium/PcbLayerStackReadModelBuilder.mjs +138 -3
- package/src/core/altium/PcbLibModelParser.mjs +24 -2
- package/src/core/altium/PcbLibStorageNameResolver.mjs +171 -0
- package/src/core/altium/PcbLibStreamExtractor.mjs +15 -93
- package/src/core/altium/PcbPrimitiveFieldOffsetReportBuilder.mjs +352 -0
- package/src/core/altium/PcbRuleImpactReportBuilder.mjs +313 -0
- package/src/core/altium/PcbShapeBasedBodyGeometryParser.mjs +514 -0
- package/src/core/altium/SchematicConnectivityQaBuilder.mjs +442 -2
- package/src/core/altium/SchematicImageDiagnosticsBuilder.mjs +214 -0
- package/src/core/altium/UnsupportedFeatureReportBuilder.mjs +523 -3
- package/src/parser.mjs +8 -0
- package/src/styles/altium-renderers.css +36 -0
- package/src/ui/PcbDimensionPrimitiveRenderer.mjs +422 -0
- package/src/ui/PcbScene3dBoardOutlineRefiner.mjs +6 -0
- package/src/ui/PcbScene3dBuilder.mjs +10 -2
- package/src/ui/PcbScene3dStaticBodyPlacementBuilder.mjs +225 -0
- 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
|
+
}
|