pcb-scene3d-viewer 1.1.19 → 1.1.21
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/package.json +3 -2
- package/src/PcbAssemblyBoardSubstrateBuilder.mjs +467 -0
- package/src/PcbAssemblyExportCoordinateFrame.mjs +19 -0
- package/src/PcbAssemblyGeometryBuildProgress.mjs +201 -0
- package/src/PcbAssemblyGeometryBuilder.mjs +994 -0
- package/src/PcbAssemblyMeshUtils.mjs +541 -0
- package/src/PcbAssemblyModelMeshLoader.mjs +447 -0
- package/src/PcbAssemblyPolygonTriangulator.mjs +301 -0
- package/src/PcbAssemblyStepWriter.mjs +952 -0
- package/src/PcbAssemblyWrlWriter.mjs +144 -0
- package/src/PcbScene3dCameraRig.mjs +11 -5
- package/src/PcbScene3dComponentVisibility.mjs +161 -0
- package/src/PcbScene3dController.mjs +29 -4
- package/src/PcbScene3dExternalModelPlacementRepair.mjs +1 -1
- package/src/PcbScene3dInteractionHints.mjs +14 -6
- package/src/PcbScene3dRenderScheduler.mjs +55 -0
- package/src/PcbScene3dRuntime.mjs +56 -4
- package/src/PcbScene3dSelectionInspectorRenderer.mjs +64 -3
- package/src/PcbScene3dSelectionVisibilityBinder.mjs +119 -0
- package/src/PcbScene3dText.mjs +4 -2
- package/src/scene3d.mjs +12 -0
- package/src/styles/scene3d.css +48 -0
|
@@ -0,0 +1,994 @@
|
|
|
1
|
+
import { PcbAssemblyBoardSubstrateBuilder } from './PcbAssemblyBoardSubstrateBuilder.mjs'
|
|
2
|
+
import { PcbAssemblyMeshUtils } from './PcbAssemblyMeshUtils.mjs'
|
|
3
|
+
|
|
4
|
+
const COPPER_THICKNESS_MIL = 2.2
|
|
5
|
+
const SILKSCREEN_THICKNESS_MIL = 0.8
|
|
6
|
+
const BOARD_COLOR = [0.05, 0.32, 0.18]
|
|
7
|
+
const COPPER_COLOR = [0.85, 0.62, 0.12]
|
|
8
|
+
const SILKSCREEN_COLOR = [0.96, 0.95, 0.9]
|
|
9
|
+
const COMPONENT_COLOR = [0.55, 0.56, 0.58]
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Converts a prepared 3D scene description into exportable faceted meshes.
|
|
13
|
+
*/
|
|
14
|
+
export class PcbAssemblyGeometryBuilder {
|
|
15
|
+
/**
|
|
16
|
+
* Builds assembly meshes and diagnostics.
|
|
17
|
+
* @param {{ board?: object, detail?: object, components?: object[], externalPlacements?: object[] }} sceneDescription Prepared scene description.
|
|
18
|
+
* @param {{ modelMeshLoader?: (placement: object) => Promise<object | object[]>, progress?: { advance?: (units: number, message: string) => Promise<void>, finish?: (message: string) => Promise<void> } }} [options] Build options.
|
|
19
|
+
* @returns {Promise<{ meshes: object[], diagnostics: object[] }>}
|
|
20
|
+
*/
|
|
21
|
+
static async build(sceneDescription, options = {}) {
|
|
22
|
+
const diagnostics = []
|
|
23
|
+
const progress = options.progress || null
|
|
24
|
+
const boardMeshes =
|
|
25
|
+
PcbAssemblyGeometryBuilder.#buildBoardMeshes(sceneDescription)
|
|
26
|
+
await progress?.advance?.(1, 'Building board substrate')
|
|
27
|
+
const copperMeshes =
|
|
28
|
+
await PcbAssemblyGeometryBuilder.#buildCopperMeshes(
|
|
29
|
+
sceneDescription,
|
|
30
|
+
progress
|
|
31
|
+
)
|
|
32
|
+
const silkscreenMeshes =
|
|
33
|
+
await PcbAssemblyGeometryBuilder.#buildSilkscreenMeshes(
|
|
34
|
+
sceneDescription,
|
|
35
|
+
progress
|
|
36
|
+
)
|
|
37
|
+
const pcbMeshes = [...boardMeshes, ...copperMeshes, ...silkscreenMeshes]
|
|
38
|
+
const meshes = [
|
|
39
|
+
...PcbAssemblyGeometryBuilder.#translateMeshes(
|
|
40
|
+
pcbMeshes,
|
|
41
|
+
PcbAssemblyGeometryBuilder.#boardLocalOffset(sceneDescription)
|
|
42
|
+
),
|
|
43
|
+
...(await PcbAssemblyGeometryBuilder.#buildComponentMeshes(
|
|
44
|
+
sceneDescription,
|
|
45
|
+
options,
|
|
46
|
+
diagnostics,
|
|
47
|
+
progress
|
|
48
|
+
))
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
PcbAssemblyGeometryBuilder.#appendMissingModelDiagnostics(
|
|
52
|
+
sceneDescription,
|
|
53
|
+
diagnostics
|
|
54
|
+
)
|
|
55
|
+
await progress?.finish?.('Geometry meshes ready')
|
|
56
|
+
return {
|
|
57
|
+
meshes: meshes.filter((mesh) => mesh?.vertices?.length),
|
|
58
|
+
diagnostics
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Builds substrate meshes.
|
|
64
|
+
* @param {{ board?: object }} sceneDescription Prepared scene description.
|
|
65
|
+
* @returns {object[]}
|
|
66
|
+
*/
|
|
67
|
+
static #buildBoardMeshes(sceneDescription) {
|
|
68
|
+
const board = sceneDescription?.board || {}
|
|
69
|
+
const width = Math.max(Number(board.widthMil || 0), 1)
|
|
70
|
+
const height = Math.max(Number(board.heightMil || 0), 1)
|
|
71
|
+
const thickness = Math.max(Number(board.thicknessMil || 63), 1)
|
|
72
|
+
const outlinePoints =
|
|
73
|
+
PcbAssemblyGeometryBuilder.#boardOutlinePoints(board)
|
|
74
|
+
const outlineMesh = PcbAssemblyBoardSubstrateBuilder.build(
|
|
75
|
+
'board',
|
|
76
|
+
outlinePoints,
|
|
77
|
+
sceneDescription,
|
|
78
|
+
thickness,
|
|
79
|
+
BOARD_COLOR
|
|
80
|
+
)
|
|
81
|
+
if (outlineMesh) {
|
|
82
|
+
return [outlineMesh]
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return [
|
|
86
|
+
PcbAssemblyMeshUtils.box('board', {
|
|
87
|
+
width,
|
|
88
|
+
depth: height,
|
|
89
|
+
height: thickness,
|
|
90
|
+
color: BOARD_COLOR
|
|
91
|
+
})
|
|
92
|
+
]
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Builds copper, pads, and via meshes.
|
|
97
|
+
* @param {{ board?: object, detail?: object }} sceneDescription Prepared scene description.
|
|
98
|
+
* @param {{ advance?: (units: number, message: string) => Promise<void> } | null} progress Progress tracker.
|
|
99
|
+
* @returns {Promise<object[]>}
|
|
100
|
+
*/
|
|
101
|
+
static async #buildCopperMeshes(sceneDescription, progress = null) {
|
|
102
|
+
const detail = sceneDescription?.detail || {}
|
|
103
|
+
const board = sceneDescription?.board || {}
|
|
104
|
+
const topZ =
|
|
105
|
+
Number(board.thicknessMil || 63) / 2 + COPPER_THICKNESS_MIL / 2
|
|
106
|
+
const bottomZ = -topZ
|
|
107
|
+
const meshes = []
|
|
108
|
+
|
|
109
|
+
const tracks = PcbAssemblyGeometryBuilder.#array(detail.tracks)
|
|
110
|
+
for (let index = 0; index < tracks.length; index += 1) {
|
|
111
|
+
const track = tracks[index]
|
|
112
|
+
const side = PcbAssemblyGeometryBuilder.#resolveSide(track)
|
|
113
|
+
const mesh = PcbAssemblyGeometryBuilder.#trackMesh(
|
|
114
|
+
'copper-' + side + '-track-' + (index + 1),
|
|
115
|
+
track,
|
|
116
|
+
side === 'bottom' ? bottomZ : topZ,
|
|
117
|
+
COPPER_THICKNESS_MIL,
|
|
118
|
+
COPPER_COLOR
|
|
119
|
+
)
|
|
120
|
+
if (mesh) meshes.push(mesh)
|
|
121
|
+
await progress?.advance?.(
|
|
122
|
+
1,
|
|
123
|
+
'Building copper tracks ' + (index + 1) + '/' + tracks.length
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const arcs = PcbAssemblyGeometryBuilder.#array(detail.arcs)
|
|
128
|
+
for (let index = 0; index < arcs.length; index += 1) {
|
|
129
|
+
const arc = arcs[index]
|
|
130
|
+
const side = PcbAssemblyGeometryBuilder.#resolveSide(arc)
|
|
131
|
+
const mesh = PcbAssemblyMeshUtils.prism(
|
|
132
|
+
'copper-' + side + '-arc-' + (index + 1),
|
|
133
|
+
PcbAssemblyMeshUtils.arcBandPoints(arc),
|
|
134
|
+
side === 'bottom' ? bottomZ : topZ,
|
|
135
|
+
COPPER_THICKNESS_MIL,
|
|
136
|
+
COPPER_COLOR
|
|
137
|
+
)
|
|
138
|
+
if (mesh) meshes.push(mesh)
|
|
139
|
+
await progress?.advance?.(
|
|
140
|
+
1,
|
|
141
|
+
'Building copper arcs ' + (index + 1) + '/' + arcs.length
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const fills = [
|
|
146
|
+
...PcbAssemblyGeometryBuilder.#array(detail.fills),
|
|
147
|
+
...PcbAssemblyGeometryBuilder.#array(detail.polygons)
|
|
148
|
+
]
|
|
149
|
+
for (let index = 0; index < fills.length; index += 1) {
|
|
150
|
+
const fill = fills[index]
|
|
151
|
+
const side = PcbAssemblyGeometryBuilder.#resolveSide(fill)
|
|
152
|
+
const mesh = PcbAssemblyMeshUtils.prism(
|
|
153
|
+
'copper-' + side + '-fill-' + (index + 1),
|
|
154
|
+
PcbAssemblyGeometryBuilder.#points(fill),
|
|
155
|
+
side === 'bottom' ? bottomZ : topZ,
|
|
156
|
+
COPPER_THICKNESS_MIL,
|
|
157
|
+
COPPER_COLOR
|
|
158
|
+
)
|
|
159
|
+
if (mesh) meshes.push(mesh)
|
|
160
|
+
await progress?.advance?.(
|
|
161
|
+
1,
|
|
162
|
+
'Building copper fills ' + (index + 1) + '/' + fills.length
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const pads = PcbAssemblyGeometryBuilder.#array(detail.pads)
|
|
167
|
+
for (let index = 0; index < pads.length; index += 1) {
|
|
168
|
+
const pad = pads[index]
|
|
169
|
+
const topMesh = PcbAssemblyGeometryBuilder.#padMesh(
|
|
170
|
+
'pad-top-' + (index + 1),
|
|
171
|
+
pad,
|
|
172
|
+
'top',
|
|
173
|
+
topZ,
|
|
174
|
+
COPPER_THICKNESS_MIL
|
|
175
|
+
)
|
|
176
|
+
const bottomMesh = PcbAssemblyGeometryBuilder.#padMesh(
|
|
177
|
+
'pad-bottom-' + (index + 1),
|
|
178
|
+
pad,
|
|
179
|
+
'bottom',
|
|
180
|
+
bottomZ,
|
|
181
|
+
COPPER_THICKNESS_MIL
|
|
182
|
+
)
|
|
183
|
+
if (topMesh) meshes.push(topMesh)
|
|
184
|
+
await progress?.advance?.(
|
|
185
|
+
1,
|
|
186
|
+
'Building pads ' + (index + 1) + '/' + pads.length
|
|
187
|
+
)
|
|
188
|
+
if (bottomMesh) meshes.push(bottomMesh)
|
|
189
|
+
await progress?.advance?.(
|
|
190
|
+
1,
|
|
191
|
+
'Building pads ' + (index + 1) + '/' + pads.length
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const vias = PcbAssemblyGeometryBuilder.#array(detail.vias)
|
|
196
|
+
for (let index = 0; index < vias.length; index += 1) {
|
|
197
|
+
const via = vias[index]
|
|
198
|
+
const mesh = PcbAssemblyGeometryBuilder.#viaMesh(
|
|
199
|
+
'via-' + (index + 1),
|
|
200
|
+
via,
|
|
201
|
+
Number(board.thicknessMil || 63) + COPPER_THICKNESS_MIL
|
|
202
|
+
)
|
|
203
|
+
if (mesh) meshes.push(mesh)
|
|
204
|
+
await progress?.advance?.(
|
|
205
|
+
1,
|
|
206
|
+
'Building vias ' + (index + 1) + '/' + vias.length
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const copperTexts = PcbAssemblyGeometryBuilder.#array(
|
|
211
|
+
detail.copperTexts
|
|
212
|
+
)
|
|
213
|
+
for (let index = 0; index < copperTexts.length; index += 1) {
|
|
214
|
+
const text = copperTexts[index]
|
|
215
|
+
const side = PcbAssemblyGeometryBuilder.#resolveSide(text)
|
|
216
|
+
const mesh = PcbAssemblyGeometryBuilder.#textMesh(
|
|
217
|
+
'copper-' + side + '-text-' + (index + 1),
|
|
218
|
+
text,
|
|
219
|
+
side === 'bottom' ? bottomZ : topZ,
|
|
220
|
+
COPPER_COLOR,
|
|
221
|
+
COPPER_THICKNESS_MIL
|
|
222
|
+
)
|
|
223
|
+
if (mesh) meshes.push(mesh)
|
|
224
|
+
await progress?.advance?.(
|
|
225
|
+
1,
|
|
226
|
+
'Building copper labels ' +
|
|
227
|
+
(index + 1) +
|
|
228
|
+
'/' +
|
|
229
|
+
copperTexts.length
|
|
230
|
+
)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return meshes
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Builds silkscreen meshes.
|
|
238
|
+
* @param {{ board?: object, detail?: object }} sceneDescription Prepared scene description.
|
|
239
|
+
* @param {{ advance?: (units: number, message: string) => Promise<void> } | null} progress Progress tracker.
|
|
240
|
+
* @returns {Promise<object[]>}
|
|
241
|
+
*/
|
|
242
|
+
static async #buildSilkscreenMeshes(sceneDescription, progress = null) {
|
|
243
|
+
const detail = sceneDescription?.detail || {}
|
|
244
|
+
const board = sceneDescription?.board || {}
|
|
245
|
+
const topZ =
|
|
246
|
+
Number(board.thicknessMil || 63) / 2 +
|
|
247
|
+
COPPER_THICKNESS_MIL +
|
|
248
|
+
SILKSCREEN_THICKNESS_MIL / 2
|
|
249
|
+
const bottomZ = -topZ
|
|
250
|
+
const meshes = []
|
|
251
|
+
const sides = [
|
|
252
|
+
['top', detail?.silkscreen?.top || {}, topZ],
|
|
253
|
+
['bottom', detail?.silkscreen?.bottom || {}, bottomZ]
|
|
254
|
+
]
|
|
255
|
+
|
|
256
|
+
for (const [side, silkscreen, z] of sides) {
|
|
257
|
+
const tracks = PcbAssemblyGeometryBuilder.#array(silkscreen.tracks)
|
|
258
|
+
for (let index = 0; index < tracks.length; index += 1) {
|
|
259
|
+
const mesh = PcbAssemblyGeometryBuilder.#trackMesh(
|
|
260
|
+
'silkscreen-' + side + '-track-' + (index + 1),
|
|
261
|
+
tracks[index],
|
|
262
|
+
z,
|
|
263
|
+
SILKSCREEN_THICKNESS_MIL,
|
|
264
|
+
SILKSCREEN_COLOR
|
|
265
|
+
)
|
|
266
|
+
if (mesh) meshes.push(mesh)
|
|
267
|
+
await progress?.advance?.(
|
|
268
|
+
1,
|
|
269
|
+
'Building silkscreen tracks ' +
|
|
270
|
+
(index + 1) +
|
|
271
|
+
'/' +
|
|
272
|
+
tracks.length
|
|
273
|
+
)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const arcs = PcbAssemblyGeometryBuilder.#array(silkscreen.arcs)
|
|
277
|
+
for (let index = 0; index < arcs.length; index += 1) {
|
|
278
|
+
const mesh = PcbAssemblyMeshUtils.prism(
|
|
279
|
+
'silkscreen-' + side + '-arc-' + (index + 1),
|
|
280
|
+
PcbAssemblyMeshUtils.arcBandPoints(arcs[index]),
|
|
281
|
+
z,
|
|
282
|
+
SILKSCREEN_THICKNESS_MIL,
|
|
283
|
+
SILKSCREEN_COLOR
|
|
284
|
+
)
|
|
285
|
+
if (mesh) meshes.push(mesh)
|
|
286
|
+
await progress?.advance?.(
|
|
287
|
+
1,
|
|
288
|
+
'Building silkscreen arcs ' +
|
|
289
|
+
(index + 1) +
|
|
290
|
+
'/' +
|
|
291
|
+
arcs.length
|
|
292
|
+
)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const fills = PcbAssemblyGeometryBuilder.#array(silkscreen.fills)
|
|
296
|
+
for (let index = 0; index < fills.length; index += 1) {
|
|
297
|
+
const mesh = PcbAssemblyMeshUtils.prism(
|
|
298
|
+
'silkscreen-' + side + '-fill-' + (index + 1),
|
|
299
|
+
PcbAssemblyGeometryBuilder.#points(fills[index]),
|
|
300
|
+
z,
|
|
301
|
+
SILKSCREEN_THICKNESS_MIL,
|
|
302
|
+
SILKSCREEN_COLOR
|
|
303
|
+
)
|
|
304
|
+
if (mesh) meshes.push(mesh)
|
|
305
|
+
await progress?.advance?.(
|
|
306
|
+
1,
|
|
307
|
+
'Building silkscreen fills ' +
|
|
308
|
+
(index + 1) +
|
|
309
|
+
'/' +
|
|
310
|
+
fills.length
|
|
311
|
+
)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const texts = PcbAssemblyGeometryBuilder.#array(silkscreen.texts)
|
|
315
|
+
for (let index = 0; index < texts.length; index += 1) {
|
|
316
|
+
meshes.push(
|
|
317
|
+
PcbAssemblyGeometryBuilder.#textMesh(
|
|
318
|
+
'silkscreen-' + side + '-text-' + (index + 1),
|
|
319
|
+
texts[index],
|
|
320
|
+
z,
|
|
321
|
+
SILKSCREEN_COLOR,
|
|
322
|
+
SILKSCREEN_THICKNESS_MIL
|
|
323
|
+
)
|
|
324
|
+
)
|
|
325
|
+
await progress?.advance?.(
|
|
326
|
+
1,
|
|
327
|
+
'Building silkscreen labels ' +
|
|
328
|
+
(index + 1) +
|
|
329
|
+
'/' +
|
|
330
|
+
texts.length
|
|
331
|
+
)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return meshes.filter(Boolean)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Resolves the PCB source-coordinate to board-local export offset.
|
|
340
|
+
* @param {{ board?: object }} sceneDescription Prepared scene description.
|
|
341
|
+
* @returns {{ x: number, y: number }}
|
|
342
|
+
*/
|
|
343
|
+
static #boardLocalOffset(sceneDescription) {
|
|
344
|
+
const board = sceneDescription?.board || {}
|
|
345
|
+
const centerX = Number(board?.centerX)
|
|
346
|
+
const centerY = Number(board?.centerY)
|
|
347
|
+
|
|
348
|
+
return {
|
|
349
|
+
x: Number.isFinite(centerX) ? -centerX : 0,
|
|
350
|
+
y: Number.isFinite(centerY) ? -centerY : 0
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Translates PCB-generated meshes into the component placement origin.
|
|
356
|
+
* @param {object[]} meshes Source meshes.
|
|
357
|
+
* @param {{ x: number, y: number }} offset Translation offset in mils.
|
|
358
|
+
* @returns {object[]}
|
|
359
|
+
*/
|
|
360
|
+
static #translateMeshes(meshes, offset) {
|
|
361
|
+
if (
|
|
362
|
+
Math.abs(Number(offset?.x || 0)) < 0.001 &&
|
|
363
|
+
Math.abs(Number(offset?.y || 0)) < 0.001
|
|
364
|
+
) {
|
|
365
|
+
return meshes
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return meshes.map((mesh) =>
|
|
369
|
+
PcbAssemblyGeometryBuilder.#translateMesh(mesh, offset)
|
|
370
|
+
)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Translates one mesh in XY without changing Z.
|
|
375
|
+
* @param {object} mesh Source mesh.
|
|
376
|
+
* @param {{ x: number, y: number }} offset Translation offset in mils.
|
|
377
|
+
* @returns {object}
|
|
378
|
+
*/
|
|
379
|
+
static #translateMesh(mesh, offset) {
|
|
380
|
+
return {
|
|
381
|
+
...mesh,
|
|
382
|
+
vertices: PcbAssemblyGeometryBuilder.#array(mesh?.vertices).map(
|
|
383
|
+
(vertex) => [
|
|
384
|
+
Number(vertex?.[0] || 0) + Number(offset?.x || 0),
|
|
385
|
+
Number(vertex?.[1] || 0) + Number(offset?.y || 0),
|
|
386
|
+
Number(vertex?.[2] || 0)
|
|
387
|
+
]
|
|
388
|
+
)
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Builds component model meshes.
|
|
394
|
+
* @param {{ externalPlacements?: object[] }} sceneDescription Prepared scene description.
|
|
395
|
+
* @param {{ modelMeshLoader?: (placement: object) => Promise<object | object[]> }} options Build options.
|
|
396
|
+
* @param {object[]} diagnostics Mutable diagnostics list.
|
|
397
|
+
* @param {{ advance?: (units: number, message: string) => Promise<void> } | null} progress Progress tracker.
|
|
398
|
+
* @returns {Promise<object[]>}
|
|
399
|
+
*/
|
|
400
|
+
static async #buildComponentMeshes(
|
|
401
|
+
sceneDescription,
|
|
402
|
+
options,
|
|
403
|
+
diagnostics,
|
|
404
|
+
progress = null
|
|
405
|
+
) {
|
|
406
|
+
const loader =
|
|
407
|
+
typeof options.modelMeshLoader === 'function'
|
|
408
|
+
? options.modelMeshLoader
|
|
409
|
+
: null
|
|
410
|
+
if (!loader) {
|
|
411
|
+
return []
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const meshes = []
|
|
415
|
+
const placements = PcbAssemblyGeometryBuilder.#array(
|
|
416
|
+
sceneDescription?.externalPlacements
|
|
417
|
+
).filter((placement) => placement?.externalModel)
|
|
418
|
+
for (
|
|
419
|
+
let placementIndex = 0;
|
|
420
|
+
placementIndex < placements.length;
|
|
421
|
+
placementIndex += 1
|
|
422
|
+
) {
|
|
423
|
+
const placement = placements[placementIndex]
|
|
424
|
+
|
|
425
|
+
try {
|
|
426
|
+
const loaded = await loader(placement)
|
|
427
|
+
const loadedMeshes = Array.isArray(loaded) ? loaded : [loaded]
|
|
428
|
+
loadedMeshes.filter(Boolean).forEach((mesh, index) => {
|
|
429
|
+
const designator = PcbAssemblyMeshUtils.safeName(
|
|
430
|
+
placement?.designator || 'component'
|
|
431
|
+
)
|
|
432
|
+
const transformed = PcbAssemblyMeshUtils.transformMesh(
|
|
433
|
+
{
|
|
434
|
+
...mesh,
|
|
435
|
+
name:
|
|
436
|
+
'component-' +
|
|
437
|
+
designator +
|
|
438
|
+
(loadedMeshes.length > 1
|
|
439
|
+
? '-' + (index + 1)
|
|
440
|
+
: ''),
|
|
441
|
+
color: mesh.color || COMPONENT_COLOR
|
|
442
|
+
},
|
|
443
|
+
placement
|
|
444
|
+
)
|
|
445
|
+
meshes.push(transformed)
|
|
446
|
+
})
|
|
447
|
+
PcbAssemblyGeometryBuilder.#appendModelDiagnostic(
|
|
448
|
+
placement,
|
|
449
|
+
diagnostics
|
|
450
|
+
)
|
|
451
|
+
} catch (error) {
|
|
452
|
+
diagnostics.push(
|
|
453
|
+
PcbAssemblyGeometryBuilder.#diagnostic(
|
|
454
|
+
'warning',
|
|
455
|
+
'component_model_conversion_failed',
|
|
456
|
+
'Could not convert 3D model for ' +
|
|
457
|
+
String(placement?.designator || 'component') +
|
|
458
|
+
': ' +
|
|
459
|
+
String(error?.message || error)
|
|
460
|
+
)
|
|
461
|
+
)
|
|
462
|
+
}
|
|
463
|
+
await progress?.advance?.(
|
|
464
|
+
1,
|
|
465
|
+
'Loading component models ' +
|
|
466
|
+
(placementIndex + 1) +
|
|
467
|
+
'/' +
|
|
468
|
+
placements.length
|
|
469
|
+
)
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
return meshes
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Adds a format-specific component model diagnostic.
|
|
477
|
+
* @param {object} placement Component placement.
|
|
478
|
+
* @param {object[]} diagnostics Mutable diagnostics list.
|
|
479
|
+
* @returns {void}
|
|
480
|
+
*/
|
|
481
|
+
static #appendModelDiagnostic(placement, diagnostics) {
|
|
482
|
+
const format = String(
|
|
483
|
+
placement?.externalModel?.format || ''
|
|
484
|
+
).toLowerCase()
|
|
485
|
+
if (format === 'wrl' || format === 'vrml') {
|
|
486
|
+
diagnostics.push(
|
|
487
|
+
PcbAssemblyGeometryBuilder.#diagnostic(
|
|
488
|
+
'info',
|
|
489
|
+
'component_wrl_faceted_step',
|
|
490
|
+
'Converted WRL model for ' +
|
|
491
|
+
String(placement?.designator || 'component') +
|
|
492
|
+
' as faceted geometry.'
|
|
493
|
+
)
|
|
494
|
+
)
|
|
495
|
+
return
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (format === 'step' || format === 'stp') {
|
|
499
|
+
diagnostics.push(
|
|
500
|
+
PcbAssemblyGeometryBuilder.#diagnostic(
|
|
501
|
+
'info',
|
|
502
|
+
'component_step_faceted_export',
|
|
503
|
+
'Included STEP model for ' +
|
|
504
|
+
String(placement?.designator || 'component') +
|
|
505
|
+
' through export mesh geometry.'
|
|
506
|
+
)
|
|
507
|
+
)
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Adds diagnostics for components without resolved external models.
|
|
513
|
+
* @param {{ components?: object[], externalPlacements?: object[] }} sceneDescription Scene description.
|
|
514
|
+
* @param {object[]} diagnostics Mutable diagnostics list.
|
|
515
|
+
* @returns {void}
|
|
516
|
+
*/
|
|
517
|
+
static #appendMissingModelDiagnostics(sceneDescription, diagnostics) {
|
|
518
|
+
const placementDesignators = new Set(
|
|
519
|
+
PcbAssemblyGeometryBuilder.#array(
|
|
520
|
+
sceneDescription?.externalPlacements
|
|
521
|
+
)
|
|
522
|
+
.map((placement) => String(placement?.designator || '').trim())
|
|
523
|
+
.filter(Boolean)
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
PcbAssemblyGeometryBuilder.#array(sceneDescription?.components).forEach(
|
|
527
|
+
(component) => {
|
|
528
|
+
const designator = String(component?.designator || '').trim()
|
|
529
|
+
if (
|
|
530
|
+
!designator ||
|
|
531
|
+
component?.externalModel ||
|
|
532
|
+
placementDesignators.has(designator)
|
|
533
|
+
) {
|
|
534
|
+
return
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
diagnostics.push(
|
|
538
|
+
PcbAssemblyGeometryBuilder.#diagnostic(
|
|
539
|
+
'warning',
|
|
540
|
+
'component_model_missing',
|
|
541
|
+
'No resolved 3D model was available for ' +
|
|
542
|
+
designator +
|
|
543
|
+
'.'
|
|
544
|
+
)
|
|
545
|
+
)
|
|
546
|
+
}
|
|
547
|
+
)
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Builds one widened track mesh.
|
|
552
|
+
* @param {string} name Mesh name.
|
|
553
|
+
* @param {object} track Track primitive.
|
|
554
|
+
* @param {number} z Center Z.
|
|
555
|
+
* @param {number} thickness Extrusion thickness.
|
|
556
|
+
* @param {number[]} color Mesh color.
|
|
557
|
+
* @returns {object | null}
|
|
558
|
+
*/
|
|
559
|
+
static #trackMesh(name, track, z, thickness, color) {
|
|
560
|
+
const x1 = Number(track?.x1 ?? track?.startX ?? 0)
|
|
561
|
+
const y1 = Number(track?.y1 ?? track?.startY ?? 0)
|
|
562
|
+
const x2 = Number(track?.x2 ?? track?.endX ?? x1)
|
|
563
|
+
const y2 = Number(track?.y2 ?? track?.endY ?? y1)
|
|
564
|
+
const width = Math.max(Number(track?.width || 1), 0.001)
|
|
565
|
+
const length = Math.hypot(x2 - x1, y2 - y1)
|
|
566
|
+
if (length <= 0.001) {
|
|
567
|
+
return PcbAssemblyMeshUtils.cylinder(name, {
|
|
568
|
+
x: x1,
|
|
569
|
+
y: y1,
|
|
570
|
+
z,
|
|
571
|
+
radius: width / 2,
|
|
572
|
+
height: thickness,
|
|
573
|
+
color
|
|
574
|
+
})
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
return PcbAssemblyMeshUtils.prism(
|
|
578
|
+
name,
|
|
579
|
+
PcbAssemblyMeshUtils.capsulePoints(x1, y1, x2, y2, width / 2),
|
|
580
|
+
z,
|
|
581
|
+
thickness,
|
|
582
|
+
color
|
|
583
|
+
)
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Builds one pad mesh.
|
|
588
|
+
* @param {string} name Mesh name.
|
|
589
|
+
* @param {object} pad Pad primitive.
|
|
590
|
+
* @param {'top' | 'bottom'} side Pad side.
|
|
591
|
+
* @param {number} z Center Z.
|
|
592
|
+
* @param {number} thickness Extrusion thickness.
|
|
593
|
+
* @returns {object | null}
|
|
594
|
+
*/
|
|
595
|
+
static #padMesh(name, pad, side, z, thickness) {
|
|
596
|
+
const size = PcbAssemblyGeometryBuilder.#padSize(pad, side)
|
|
597
|
+
if (!size) {
|
|
598
|
+
return null
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const offset = PcbAssemblyGeometryBuilder.#padOffset(pad, side)
|
|
602
|
+
const x = Number(pad?.x || 0) + offset.x
|
|
603
|
+
const y = Number(pad?.y || 0) + offset.y
|
|
604
|
+
const shape = PcbAssemblyGeometryBuilder.#padShape(pad, side)
|
|
605
|
+
const isCircle =
|
|
606
|
+
Math.abs(size.width - size.height) < 0.001 && shape !== 2
|
|
607
|
+
const mesh = isCircle
|
|
608
|
+
? PcbAssemblyMeshUtils.cylinder(name, {
|
|
609
|
+
x,
|
|
610
|
+
y,
|
|
611
|
+
z,
|
|
612
|
+
radius: size.width / 2,
|
|
613
|
+
height: thickness,
|
|
614
|
+
color: COPPER_COLOR
|
|
615
|
+
})
|
|
616
|
+
: PcbAssemblyMeshUtils.box(name, {
|
|
617
|
+
x,
|
|
618
|
+
y,
|
|
619
|
+
z,
|
|
620
|
+
width: size.width,
|
|
621
|
+
depth: size.height,
|
|
622
|
+
height: thickness,
|
|
623
|
+
color: COPPER_COLOR
|
|
624
|
+
})
|
|
625
|
+
|
|
626
|
+
return PcbAssemblyGeometryBuilder.#rotateMeshAroundZ(
|
|
627
|
+
mesh,
|
|
628
|
+
Number(pad?.rotation || 0),
|
|
629
|
+
x,
|
|
630
|
+
y
|
|
631
|
+
)
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Resolves side-specific pad dimensions.
|
|
636
|
+
* @param {object} pad Pad primitive.
|
|
637
|
+
* @param {'top' | 'bottom'} side Pad side.
|
|
638
|
+
* @returns {{ width: number, height: number } | null}
|
|
639
|
+
*/
|
|
640
|
+
static #padSize(pad, side) {
|
|
641
|
+
const prefix = side === 'bottom' ? 'Bottom' : 'Top'
|
|
642
|
+
let width = PcbAssemblyGeometryBuilder.#firstPositive([
|
|
643
|
+
pad?.['size' + prefix + 'X']
|
|
644
|
+
])
|
|
645
|
+
let height = PcbAssemblyGeometryBuilder.#firstPositive([
|
|
646
|
+
pad?.['size' + prefix + 'Y']
|
|
647
|
+
])
|
|
648
|
+
|
|
649
|
+
if (side === 'top' && !width) {
|
|
650
|
+
width = PcbAssemblyGeometryBuilder.#firstPositive([
|
|
651
|
+
pad?.width,
|
|
652
|
+
pad?.sizeX,
|
|
653
|
+
pad?.diameter,
|
|
654
|
+
pad?.sizeMidX,
|
|
655
|
+
pad?.sizeBottomX
|
|
656
|
+
])
|
|
657
|
+
}
|
|
658
|
+
if (side === 'top' && !height) {
|
|
659
|
+
height = PcbAssemblyGeometryBuilder.#firstPositive([
|
|
660
|
+
pad?.height,
|
|
661
|
+
pad?.sizeY,
|
|
662
|
+
pad?.diameter,
|
|
663
|
+
pad?.sizeMidY,
|
|
664
|
+
pad?.sizeBottomY,
|
|
665
|
+
width
|
|
666
|
+
])
|
|
667
|
+
}
|
|
668
|
+
if (width && !height) {
|
|
669
|
+
height = width
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
return width && height
|
|
673
|
+
? {
|
|
674
|
+
width: Math.max(width, 1),
|
|
675
|
+
height: Math.max(height, 1)
|
|
676
|
+
}
|
|
677
|
+
: null
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Resolves side-specific pad center offset.
|
|
682
|
+
* @param {object} pad Pad primitive.
|
|
683
|
+
* @param {'top' | 'bottom'} side Pad side.
|
|
684
|
+
* @returns {{ x: number, y: number }}
|
|
685
|
+
*/
|
|
686
|
+
static #padOffset(pad, side) {
|
|
687
|
+
const prefix = side === 'bottom' ? 'Bottom' : 'Top'
|
|
688
|
+
|
|
689
|
+
return {
|
|
690
|
+
x: Number(pad?.['offset' + prefix + 'X'] || 0),
|
|
691
|
+
y: Number(pad?.['offset' + prefix + 'Y'] || 0)
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Resolves the renderer pad shape code for one side.
|
|
697
|
+
* @param {object} pad Pad primitive.
|
|
698
|
+
* @param {'top' | 'bottom'} side Pad side.
|
|
699
|
+
* @returns {number}
|
|
700
|
+
*/
|
|
701
|
+
static #padShape(pad, side) {
|
|
702
|
+
const prefix = side === 'bottom' ? 'Bottom' : 'Top'
|
|
703
|
+
const rounded = pad?.['roundedRectShape' + prefix]
|
|
704
|
+
if (Number.isInteger(Number(rounded))) {
|
|
705
|
+
return Number(rounded)
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
return Number(
|
|
709
|
+
pad?.['shape' + prefix] ||
|
|
710
|
+
pad?.shapeMid ||
|
|
711
|
+
(side === 'top' ? pad?.shapeBottom : pad?.shapeTop) ||
|
|
712
|
+
0
|
|
713
|
+
)
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Builds one via mesh.
|
|
718
|
+
* @param {string} name Mesh name.
|
|
719
|
+
* @param {object} via Via primitive.
|
|
720
|
+
* @param {number} height Via height.
|
|
721
|
+
* @returns {object}
|
|
722
|
+
*/
|
|
723
|
+
static #viaMesh(name, via, height) {
|
|
724
|
+
const outerRadius = Math.max(Number(via?.diameter || 0) / 2, 1)
|
|
725
|
+
const innerRadius = Math.max(Number(via?.holeDiameter || 0) / 2, 0)
|
|
726
|
+
const options = {
|
|
727
|
+
x: Number(via?.x || 0),
|
|
728
|
+
y: Number(via?.y || 0),
|
|
729
|
+
z: 0,
|
|
730
|
+
outerRadius,
|
|
731
|
+
innerRadius,
|
|
732
|
+
height,
|
|
733
|
+
color: COPPER_COLOR
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
return innerRadius > 0 && innerRadius < outerRadius
|
|
737
|
+
? PcbAssemblyMeshUtils.ringCylinder(name, options)
|
|
738
|
+
: PcbAssemblyMeshUtils.cylinder(name, {
|
|
739
|
+
x: options.x,
|
|
740
|
+
y: options.y,
|
|
741
|
+
z: 0,
|
|
742
|
+
radius: outerRadius,
|
|
743
|
+
height,
|
|
744
|
+
color: COPPER_COLOR
|
|
745
|
+
})
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Builds a simple text placeholder prism for recoverable text labels.
|
|
750
|
+
* @param {string} name Mesh name.
|
|
751
|
+
* @param {object} text Text primitive.
|
|
752
|
+
* @param {number} z Center Z.
|
|
753
|
+
* @param {number[]} color Mesh color.
|
|
754
|
+
* @param {number} thickness Mesh thickness.
|
|
755
|
+
* @returns {object}
|
|
756
|
+
*/
|
|
757
|
+
static #textMesh(name, text, z, color, thickness) {
|
|
758
|
+
const value = String(text?.text || text?.value || text?.string || '')
|
|
759
|
+
const height = Math.max(
|
|
760
|
+
Number(text?.height || text?.size || text?.sizeY || 25),
|
|
761
|
+
1
|
|
762
|
+
)
|
|
763
|
+
const width = Math.max(
|
|
764
|
+
Number(text?.width || text?.sizeX || value.length * height * 0.62),
|
|
765
|
+
height
|
|
766
|
+
)
|
|
767
|
+
const mesh = PcbAssemblyMeshUtils.box(name, {
|
|
768
|
+
x: Number(text?.x || 0) + width / 2,
|
|
769
|
+
y: Number(text?.y || 0) + height / 2,
|
|
770
|
+
z,
|
|
771
|
+
width,
|
|
772
|
+
depth: height,
|
|
773
|
+
height: thickness,
|
|
774
|
+
color
|
|
775
|
+
})
|
|
776
|
+
|
|
777
|
+
return PcbAssemblyGeometryBuilder.#rotateMeshAroundZ(
|
|
778
|
+
mesh,
|
|
779
|
+
Number(text?.rotation || 0),
|
|
780
|
+
Number(text?.x || 0),
|
|
781
|
+
Number(text?.y || 0)
|
|
782
|
+
)
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* Rotates one mesh around a Z-axis origin.
|
|
787
|
+
* @param {object} mesh Mesh to rotate.
|
|
788
|
+
* @param {number} rotationDeg Rotation angle.
|
|
789
|
+
* @param {number} originX Origin X.
|
|
790
|
+
* @param {number} originY Origin Y.
|
|
791
|
+
* @returns {object}
|
|
792
|
+
*/
|
|
793
|
+
static #rotateMeshAroundZ(mesh, rotationDeg, originX, originY) {
|
|
794
|
+
if (!mesh || Math.abs(Number(rotationDeg || 0)) < 0.001) {
|
|
795
|
+
return mesh
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
const rotationRad = (Number(rotationDeg || 0) * Math.PI) / 180
|
|
799
|
+
const cos = Math.cos(rotationRad)
|
|
800
|
+
const sin = Math.sin(rotationRad)
|
|
801
|
+
|
|
802
|
+
return {
|
|
803
|
+
...mesh,
|
|
804
|
+
vertices: mesh.vertices.map((vertex) => {
|
|
805
|
+
const dx = Number(vertex[0] || 0) - originX
|
|
806
|
+
const dy = Number(vertex[1] || 0) - originY
|
|
807
|
+
return [
|
|
808
|
+
originX + dx * cos - dy * sin,
|
|
809
|
+
originY + dx * sin + dy * cos,
|
|
810
|
+
Number(vertex[2] || 0)
|
|
811
|
+
]
|
|
812
|
+
})
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* Resolves a copper side from layer metadata.
|
|
818
|
+
* @param {object} item Detail primitive.
|
|
819
|
+
* @returns {'top' | 'bottom'}
|
|
820
|
+
*/
|
|
821
|
+
static #resolveSide(item) {
|
|
822
|
+
const raw = String(
|
|
823
|
+
item?.side ||
|
|
824
|
+
item?.layerSide ||
|
|
825
|
+
item?.layer ||
|
|
826
|
+
item?.layerName ||
|
|
827
|
+
''
|
|
828
|
+
).toLowerCase()
|
|
829
|
+
|
|
830
|
+
return raw.includes('bottom') ||
|
|
831
|
+
raw.includes('back') ||
|
|
832
|
+
raw.includes('b.')
|
|
833
|
+
? 'bottom'
|
|
834
|
+
: 'top'
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* Extracts polygon points from a primitive.
|
|
839
|
+
* @param {object} source Primitive with point data.
|
|
840
|
+
* @returns {number[][]}
|
|
841
|
+
*/
|
|
842
|
+
static #points(source) {
|
|
843
|
+
return PcbAssemblyGeometryBuilder.#array(
|
|
844
|
+
source?.points || source?.vertices || source?.polygon || []
|
|
845
|
+
).map((point) => [
|
|
846
|
+
Number(point?.x || point?.[0] || 0),
|
|
847
|
+
Number(point?.y || point?.[1] || 0)
|
|
848
|
+
])
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* Builds an ordered board outline loop from explicit points or segments.
|
|
853
|
+
* @param {object} board Board metadata.
|
|
854
|
+
* @returns {number[][]}
|
|
855
|
+
*/
|
|
856
|
+
static #boardOutlinePoints(board) {
|
|
857
|
+
const explicitPoints = PcbAssemblyGeometryBuilder.#points({
|
|
858
|
+
points: board?.points || board?.outlinePoints || board?.vertices
|
|
859
|
+
})
|
|
860
|
+
if (explicitPoints.length >= 3) {
|
|
861
|
+
return explicitPoints
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const segments = PcbAssemblyGeometryBuilder.#array(board?.segments)
|
|
865
|
+
const points = []
|
|
866
|
+
segments.forEach((segment, index) => {
|
|
867
|
+
if (index === 0) {
|
|
868
|
+
const start = PcbAssemblyGeometryBuilder.#segmentStart(segment)
|
|
869
|
+
if (start) points.push(start)
|
|
870
|
+
}
|
|
871
|
+
points.push(
|
|
872
|
+
...PcbAssemblyGeometryBuilder.#segmentTailPoints(segment)
|
|
873
|
+
)
|
|
874
|
+
})
|
|
875
|
+
|
|
876
|
+
return PcbAssemblyMeshUtils.cleanLoop(points)
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* Extracts a segment start point.
|
|
881
|
+
* @param {object} segment Segment primitive.
|
|
882
|
+
* @returns {number[] | null}
|
|
883
|
+
*/
|
|
884
|
+
static #segmentStart(segment) {
|
|
885
|
+
return PcbAssemblyGeometryBuilder.#pointFromFields(segment, [
|
|
886
|
+
['x1', 'y1'],
|
|
887
|
+
['startX', 'startY']
|
|
888
|
+
])
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* Extracts line or sampled arc tail points from one segment.
|
|
893
|
+
* @param {object} segment Segment primitive.
|
|
894
|
+
* @returns {number[][]}
|
|
895
|
+
*/
|
|
896
|
+
static #segmentTailPoints(segment) {
|
|
897
|
+
if (
|
|
898
|
+
String(segment?.type || '').toLowerCase() === 'arc' ||
|
|
899
|
+
Number.isFinite(Number(segment?.radius))
|
|
900
|
+
) {
|
|
901
|
+
return PcbAssemblyGeometryBuilder.#arcOutlinePoints(segment)
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
const end = PcbAssemblyGeometryBuilder.#pointFromFields(segment, [
|
|
905
|
+
['x2', 'y2'],
|
|
906
|
+
['endX', 'endY']
|
|
907
|
+
])
|
|
908
|
+
|
|
909
|
+
return end ? [end] : []
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/**
|
|
913
|
+
* Samples a board outline arc.
|
|
914
|
+
* @param {object} arc Arc segment.
|
|
915
|
+
* @returns {number[][]}
|
|
916
|
+
*/
|
|
917
|
+
static #arcOutlinePoints(arc) {
|
|
918
|
+
const centerX = Number(arc?.x || arc?.cx || arc?.centerX || 0)
|
|
919
|
+
const centerY = Number(arc?.y || arc?.cy || arc?.centerY || 0)
|
|
920
|
+
const radius = Number(arc?.radius || 0)
|
|
921
|
+
if (!Number.isFinite(radius) || radius <= 0) {
|
|
922
|
+
return []
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
const start = Number(arc?.startAngle || 0)
|
|
926
|
+
const sweep = PcbAssemblyMeshUtils.resolveSweep(arc)
|
|
927
|
+
const segments = Math.max(Math.ceil(Math.abs(sweep) / 8), 4)
|
|
928
|
+
const points = []
|
|
929
|
+
|
|
930
|
+
for (let index = 1; index <= segments; index += 1) {
|
|
931
|
+
const angle = ((start + (sweep * index) / segments) * Math.PI) / 180
|
|
932
|
+
points.push([
|
|
933
|
+
centerX + Math.cos(angle) * radius,
|
|
934
|
+
centerY + Math.sin(angle) * radius
|
|
935
|
+
])
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
return points
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* Reads a point from the first complete field pair.
|
|
943
|
+
* @param {object} source Source object.
|
|
944
|
+
* @param {string[][]} fieldPairs Candidate field pairs.
|
|
945
|
+
* @returns {number[] | null}
|
|
946
|
+
*/
|
|
947
|
+
static #pointFromFields(source, fieldPairs) {
|
|
948
|
+
for (const [xField, yField] of fieldPairs) {
|
|
949
|
+
const x = Number(source?.[xField])
|
|
950
|
+
const y = Number(source?.[yField])
|
|
951
|
+
if (Number.isFinite(x) && Number.isFinite(y)) {
|
|
952
|
+
return [x, y]
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
return null
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* Returns the first positive finite number.
|
|
961
|
+
* @param {unknown[]} values Candidate values.
|
|
962
|
+
* @returns {number}
|
|
963
|
+
*/
|
|
964
|
+
static #firstPositive(values) {
|
|
965
|
+
for (const value of values) {
|
|
966
|
+
const number = Number(value)
|
|
967
|
+
if (Number.isFinite(number) && number > 0) {
|
|
968
|
+
return number
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
return 0
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/**
|
|
976
|
+
* Creates a normalized diagnostic object.
|
|
977
|
+
* @param {string} severity Diagnostic severity.
|
|
978
|
+
* @param {string} code Diagnostic code.
|
|
979
|
+
* @param {string} message User-facing message.
|
|
980
|
+
* @returns {object}
|
|
981
|
+
*/
|
|
982
|
+
static #diagnostic(severity, code, message) {
|
|
983
|
+
return { severity, code, message }
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* Normalizes a value to an array.
|
|
988
|
+
* @param {unknown} value Candidate value.
|
|
989
|
+
* @returns {any[]}
|
|
990
|
+
*/
|
|
991
|
+
static #array(value) {
|
|
992
|
+
return Array.isArray(value) ? value : []
|
|
993
|
+
}
|
|
994
|
+
}
|