pcb-scene3d-viewer 1.1.21 → 1.1.22
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 +1 -1
- package/src/PcbAssemblyBoardSubstrateBuilder.mjs +7 -5
- package/src/PcbAssemblyGeometryBuilder.mjs +7 -134
- package/src/PcbAssemblyMeshUtils.mjs +117 -0
- package/src/PcbAssemblyPadMeshBuilder.mjs +394 -0
- package/src/PcbAssemblyStepWriter.mjs +24 -2
- package/src/PcbModelArchiveExporter.mjs +504 -5
- package/src/PcbScene3dCompanionBasePlacementAdjuster.mjs +242 -0
- package/src/PcbScene3dComponentVisibility.mjs +101 -5
- package/src/PcbScene3dExternalCompanionFallback.mjs +202 -0
- package/src/PcbScene3dExternalModels.mjs +6 -0
- package/src/PcbScene3dFallbackBodyFactory.mjs +105 -0
- package/src/PcbScene3dFallbackVisibility.mjs +58 -1
- package/src/PcbScene3dRenderGroupVisibility.mjs +8 -1
- package/src/PcbScene3dRuntime.mjs +54 -63
- package/src/PcbScene3dStaticBodyFactory.mjs +241 -0
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import { PcbAssemblyMeshUtils } from './PcbAssemblyMeshUtils.mjs'
|
|
2
|
+
|
|
3
|
+
const GEOMETRY_EPSILON = 0.001
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Builds PCB assembly copper pad meshes.
|
|
7
|
+
*/
|
|
8
|
+
export class PcbAssemblyPadMeshBuilder {
|
|
9
|
+
/**
|
|
10
|
+
* Builds one pad mesh for an assembly export.
|
|
11
|
+
* @param {string} name Mesh name.
|
|
12
|
+
* @param {object} pad Pad primitive.
|
|
13
|
+
* @param {'top' | 'bottom'} side Pad side.
|
|
14
|
+
* @param {number} z Center Z.
|
|
15
|
+
* @param {number} thickness Extrusion thickness.
|
|
16
|
+
* @param {number[]} color Mesh color.
|
|
17
|
+
* @returns {object | null}
|
|
18
|
+
*/
|
|
19
|
+
static build(name, pad, side, z, thickness, color) {
|
|
20
|
+
const size = PcbAssemblyPadMeshBuilder.#padSize(pad, side)
|
|
21
|
+
if (!size) {
|
|
22
|
+
return null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const offset = PcbAssemblyPadMeshBuilder.#padOffset(pad, side)
|
|
26
|
+
const x = Number(pad?.x || 0) + offset.x
|
|
27
|
+
const y = Number(pad?.y || 0) + offset.y
|
|
28
|
+
const shape = PcbAssemblyPadMeshBuilder.#padShape(pad, side)
|
|
29
|
+
const isCircle =
|
|
30
|
+
Math.abs(size.width - size.height) < GEOMETRY_EPSILON && shape !== 2
|
|
31
|
+
const hole = PcbAssemblyPadMeshBuilder.#holeLoop(pad, x, y)
|
|
32
|
+
const mesh = hole
|
|
33
|
+
? PcbAssemblyPadMeshBuilder.#drilledMesh(
|
|
34
|
+
name,
|
|
35
|
+
x,
|
|
36
|
+
y,
|
|
37
|
+
size,
|
|
38
|
+
isCircle,
|
|
39
|
+
hole,
|
|
40
|
+
z,
|
|
41
|
+
thickness,
|
|
42
|
+
color
|
|
43
|
+
)
|
|
44
|
+
: PcbAssemblyPadMeshBuilder.#solidMesh(
|
|
45
|
+
name,
|
|
46
|
+
x,
|
|
47
|
+
y,
|
|
48
|
+
size,
|
|
49
|
+
isCircle,
|
|
50
|
+
z,
|
|
51
|
+
thickness,
|
|
52
|
+
color
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
return PcbAssemblyPadMeshBuilder.#rotateMeshAroundZ(
|
|
56
|
+
mesh,
|
|
57
|
+
Number(pad?.rotation || 0),
|
|
58
|
+
x,
|
|
59
|
+
y
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Builds one solid pad mesh.
|
|
65
|
+
* @param {string} name Mesh name.
|
|
66
|
+
* @param {number} x Pad center X.
|
|
67
|
+
* @param {number} y Pad center Y.
|
|
68
|
+
* @param {{ width: number, height: number }} size Pad size.
|
|
69
|
+
* @param {boolean} isCircle Whether the pad is circular.
|
|
70
|
+
* @param {number} z Center Z.
|
|
71
|
+
* @param {number} thickness Extrusion thickness.
|
|
72
|
+
* @param {number[]} color Mesh color.
|
|
73
|
+
* @returns {object}
|
|
74
|
+
*/
|
|
75
|
+
static #solidMesh(name, x, y, size, isCircle, z, thickness, color) {
|
|
76
|
+
return isCircle
|
|
77
|
+
? PcbAssemblyMeshUtils.cylinder(name, {
|
|
78
|
+
x,
|
|
79
|
+
y,
|
|
80
|
+
z,
|
|
81
|
+
radius: size.width / 2,
|
|
82
|
+
height: thickness,
|
|
83
|
+
color
|
|
84
|
+
})
|
|
85
|
+
: PcbAssemblyMeshUtils.box(name, {
|
|
86
|
+
x,
|
|
87
|
+
y,
|
|
88
|
+
z,
|
|
89
|
+
width: size.width,
|
|
90
|
+
depth: size.height,
|
|
91
|
+
height: thickness,
|
|
92
|
+
color
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Builds one drilled pad mesh.
|
|
98
|
+
* @param {string} name Mesh name.
|
|
99
|
+
* @param {number} x Pad center X.
|
|
100
|
+
* @param {number} y Pad center Y.
|
|
101
|
+
* @param {{ width: number, height: number }} size Pad size.
|
|
102
|
+
* @param {boolean} isCircle Whether the pad is circular.
|
|
103
|
+
* @param {number[][]} hole Hole loop.
|
|
104
|
+
* @param {number} z Center Z.
|
|
105
|
+
* @param {number} thickness Extrusion thickness.
|
|
106
|
+
* @param {number[]} color Mesh color.
|
|
107
|
+
* @returns {object | null}
|
|
108
|
+
*/
|
|
109
|
+
static #drilledMesh(name, x, y, size, isCircle, hole, z, thickness, color) {
|
|
110
|
+
if (
|
|
111
|
+
PcbAssemblyPadMeshBuilder.#holeConsumesPad(
|
|
112
|
+
x,
|
|
113
|
+
y,
|
|
114
|
+
size,
|
|
115
|
+
isCircle,
|
|
116
|
+
hole
|
|
117
|
+
)
|
|
118
|
+
) {
|
|
119
|
+
return null
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const outer = isCircle
|
|
123
|
+
? PcbAssemblyMeshUtils.circlePoints(x, y, size.width / 2, 32)
|
|
124
|
+
: PcbAssemblyPadMeshBuilder.#rectanglePoints(x, y, size)
|
|
125
|
+
|
|
126
|
+
return PcbAssemblyMeshUtils.prismWithHoles(
|
|
127
|
+
name,
|
|
128
|
+
outer,
|
|
129
|
+
[hole],
|
|
130
|
+
z,
|
|
131
|
+
thickness,
|
|
132
|
+
color
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Returns true when a drill leaves no meaningful copper surface.
|
|
138
|
+
* @param {number} x Pad center X.
|
|
139
|
+
* @param {number} y Pad center Y.
|
|
140
|
+
* @param {{ width: number, height: number }} size Pad size.
|
|
141
|
+
* @param {boolean} isCircle Whether the pad is circular.
|
|
142
|
+
* @param {number[][]} hole Hole loop.
|
|
143
|
+
* @returns {boolean}
|
|
144
|
+
*/
|
|
145
|
+
static #holeConsumesPad(x, y, size, isCircle, hole) {
|
|
146
|
+
if (isCircle) {
|
|
147
|
+
const outerRadius = Math.min(size.width, size.height) / 2
|
|
148
|
+
const innerRadius = hole.reduce(
|
|
149
|
+
(minRadius, point) =>
|
|
150
|
+
Math.min(minRadius, Math.hypot(point[0] - x, point[1] - y)),
|
|
151
|
+
Infinity
|
|
152
|
+
)
|
|
153
|
+
return innerRadius >= outerRadius - GEOMETRY_EPSILON
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const outerArea = Math.max(size.width, 0) * Math.max(size.height, 0)
|
|
157
|
+
const holeArea = Math.abs(PcbAssemblyPadMeshBuilder.#polygonArea(hole))
|
|
158
|
+
return holeArea >= outerArea - GEOMETRY_EPSILON
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Builds a drill loop when a pad has a usable hole.
|
|
163
|
+
* @param {object} pad Pad primitive.
|
|
164
|
+
* @param {number} x Pad center X.
|
|
165
|
+
* @param {number} y Pad center Y.
|
|
166
|
+
* @returns {number[][] | null}
|
|
167
|
+
*/
|
|
168
|
+
static #holeLoop(pad, x, y) {
|
|
169
|
+
const diameter = PcbAssemblyPadMeshBuilder.#firstPositive([
|
|
170
|
+
pad?.holeDiameter,
|
|
171
|
+
pad?.drillDiameter,
|
|
172
|
+
pad?.holeSize,
|
|
173
|
+
pad?.drill,
|
|
174
|
+
pad?.holeGeometry?.diameter,
|
|
175
|
+
pad?.holeGeometry?.width
|
|
176
|
+
])
|
|
177
|
+
if (!diameter) {
|
|
178
|
+
return null
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const slotLength = PcbAssemblyPadMeshBuilder.#firstPositive([
|
|
182
|
+
pad?.holeSlotLength,
|
|
183
|
+
pad?.slotLength,
|
|
184
|
+
pad?.holeGeometry?.slotLength,
|
|
185
|
+
pad?.holeGeometry?.length
|
|
186
|
+
])
|
|
187
|
+
if (slotLength > diameter + GEOMETRY_EPSILON) {
|
|
188
|
+
return PcbAssemblyPadMeshBuilder.#rotatedPoints(
|
|
189
|
+
PcbAssemblyMeshUtils.capsulePoints(
|
|
190
|
+
x - (slotLength - diameter) / 2,
|
|
191
|
+
y,
|
|
192
|
+
x + (slotLength - diameter) / 2,
|
|
193
|
+
y,
|
|
194
|
+
diameter / 2
|
|
195
|
+
),
|
|
196
|
+
x,
|
|
197
|
+
y,
|
|
198
|
+
Number(pad?.holeRotation ?? pad?.rotation ?? 0) -
|
|
199
|
+
Number(pad?.rotation || 0)
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return PcbAssemblyMeshUtils.circlePoints(x, y, diameter / 2, 24)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Resolves side-specific pad dimensions.
|
|
208
|
+
* @param {object} pad Pad primitive.
|
|
209
|
+
* @param {'top' | 'bottom'} side Pad side.
|
|
210
|
+
* @returns {{ width: number, height: number } | null}
|
|
211
|
+
*/
|
|
212
|
+
static #padSize(pad, side) {
|
|
213
|
+
const prefix = side === 'bottom' ? 'Bottom' : 'Top'
|
|
214
|
+
let width = PcbAssemblyPadMeshBuilder.#firstPositive([
|
|
215
|
+
pad?.['size' + prefix + 'X']
|
|
216
|
+
])
|
|
217
|
+
let height = PcbAssemblyPadMeshBuilder.#firstPositive([
|
|
218
|
+
pad?.['size' + prefix + 'Y']
|
|
219
|
+
])
|
|
220
|
+
|
|
221
|
+
if (side === 'top' && !width) {
|
|
222
|
+
width = PcbAssemblyPadMeshBuilder.#firstPositive([
|
|
223
|
+
pad?.width,
|
|
224
|
+
pad?.sizeX,
|
|
225
|
+
pad?.diameter,
|
|
226
|
+
pad?.sizeMidX,
|
|
227
|
+
pad?.sizeBottomX
|
|
228
|
+
])
|
|
229
|
+
}
|
|
230
|
+
if (side === 'top' && !height) {
|
|
231
|
+
height = PcbAssemblyPadMeshBuilder.#firstPositive([
|
|
232
|
+
pad?.height,
|
|
233
|
+
pad?.sizeY,
|
|
234
|
+
pad?.diameter,
|
|
235
|
+
pad?.sizeMidY,
|
|
236
|
+
pad?.sizeBottomY,
|
|
237
|
+
width
|
|
238
|
+
])
|
|
239
|
+
}
|
|
240
|
+
if (width && !height) {
|
|
241
|
+
height = width
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return width && height
|
|
245
|
+
? {
|
|
246
|
+
width: Math.max(width, 1),
|
|
247
|
+
height: Math.max(height, 1)
|
|
248
|
+
}
|
|
249
|
+
: null
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Resolves side-specific pad center offset.
|
|
254
|
+
* @param {object} pad Pad primitive.
|
|
255
|
+
* @param {'top' | 'bottom'} side Pad side.
|
|
256
|
+
* @returns {{ x: number, y: number }}
|
|
257
|
+
*/
|
|
258
|
+
static #padOffset(pad, side) {
|
|
259
|
+
const prefix = side === 'bottom' ? 'Bottom' : 'Top'
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
x: Number(pad?.['offset' + prefix + 'X'] || 0),
|
|
263
|
+
y: Number(pad?.['offset' + prefix + 'Y'] || 0)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Resolves the renderer pad shape code for one side.
|
|
269
|
+
* @param {object} pad Pad primitive.
|
|
270
|
+
* @param {'top' | 'bottom'} side Pad side.
|
|
271
|
+
* @returns {number}
|
|
272
|
+
*/
|
|
273
|
+
static #padShape(pad, side) {
|
|
274
|
+
const prefix = side === 'bottom' ? 'Bottom' : 'Top'
|
|
275
|
+
const rounded = pad?.['roundedRectShape' + prefix]
|
|
276
|
+
if (Number.isInteger(Number(rounded))) {
|
|
277
|
+
return Number(rounded)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return Number(
|
|
281
|
+
pad?.['shape' + prefix] ||
|
|
282
|
+
pad?.shapeMid ||
|
|
283
|
+
(side === 'top' ? pad?.shapeBottom : pad?.shapeTop) ||
|
|
284
|
+
0
|
|
285
|
+
)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Builds rectangle points for an axis-aligned pad.
|
|
290
|
+
* @param {number} x Center X.
|
|
291
|
+
* @param {number} y Center Y.
|
|
292
|
+
* @param {{ width: number, height: number }} size Pad size.
|
|
293
|
+
* @returns {number[][]}
|
|
294
|
+
*/
|
|
295
|
+
static #rectanglePoints(x, y, size) {
|
|
296
|
+
const halfWidth = size.width / 2
|
|
297
|
+
const halfHeight = size.height / 2
|
|
298
|
+
return [
|
|
299
|
+
[x - halfWidth, y - halfHeight],
|
|
300
|
+
[x + halfWidth, y - halfHeight],
|
|
301
|
+
[x + halfWidth, y + halfHeight],
|
|
302
|
+
[x - halfWidth, y + halfHeight]
|
|
303
|
+
]
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Rotates points around a center.
|
|
308
|
+
* @param {number[][]} points Source points.
|
|
309
|
+
* @param {number} centerX Center X.
|
|
310
|
+
* @param {number} centerY Center Y.
|
|
311
|
+
* @param {number} rotationDeg Rotation in degrees.
|
|
312
|
+
* @returns {number[][]}
|
|
313
|
+
*/
|
|
314
|
+
static #rotatedPoints(points, centerX, centerY, rotationDeg) {
|
|
315
|
+
if (Math.abs(Number(rotationDeg || 0)) < GEOMETRY_EPSILON) {
|
|
316
|
+
return points
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const angle = (Number(rotationDeg || 0) * Math.PI) / 180
|
|
320
|
+
const cos = Math.cos(angle)
|
|
321
|
+
const sin = Math.sin(angle)
|
|
322
|
+
return points.map((point) => {
|
|
323
|
+
const dx = point[0] - centerX
|
|
324
|
+
const dy = point[1] - centerY
|
|
325
|
+
return [
|
|
326
|
+
centerX + dx * cos - dy * sin,
|
|
327
|
+
centerY + dx * sin + dy * cos
|
|
328
|
+
]
|
|
329
|
+
})
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Calculates polygon area using the shoelace formula.
|
|
334
|
+
* @param {number[][]} points Polygon points.
|
|
335
|
+
* @returns {number}
|
|
336
|
+
*/
|
|
337
|
+
static #polygonArea(points) {
|
|
338
|
+
let area = 0
|
|
339
|
+
for (let index = 0; index < points.length; index += 1) {
|
|
340
|
+
const point = points[index]
|
|
341
|
+
const next = points[(index + 1) % points.length]
|
|
342
|
+
area += point[0] * next[1] - next[0] * point[1]
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return area / 2
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Rotates one mesh around a Z-axis origin.
|
|
350
|
+
* @param {object | null} mesh Mesh to rotate.
|
|
351
|
+
* @param {number} rotationDeg Rotation angle.
|
|
352
|
+
* @param {number} originX Origin X.
|
|
353
|
+
* @param {number} originY Origin Y.
|
|
354
|
+
* @returns {object | null}
|
|
355
|
+
*/
|
|
356
|
+
static #rotateMeshAroundZ(mesh, rotationDeg, originX, originY) {
|
|
357
|
+
if (!mesh || Math.abs(Number(rotationDeg || 0)) < GEOMETRY_EPSILON) {
|
|
358
|
+
return mesh
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const rotationRad = (Number(rotationDeg || 0) * Math.PI) / 180
|
|
362
|
+
const cos = Math.cos(rotationRad)
|
|
363
|
+
const sin = Math.sin(rotationRad)
|
|
364
|
+
|
|
365
|
+
return {
|
|
366
|
+
...mesh,
|
|
367
|
+
vertices: mesh.vertices.map((vertex) => {
|
|
368
|
+
const dx = Number(vertex[0] || 0) - originX
|
|
369
|
+
const dy = Number(vertex[1] || 0) - originY
|
|
370
|
+
return [
|
|
371
|
+
originX + dx * cos - dy * sin,
|
|
372
|
+
originY + dx * sin + dy * cos,
|
|
373
|
+
Number(vertex[2] || 0)
|
|
374
|
+
]
|
|
375
|
+
})
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Returns the first positive finite number.
|
|
381
|
+
* @param {unknown[]} values Candidate values.
|
|
382
|
+
* @returns {number}
|
|
383
|
+
*/
|
|
384
|
+
static #firstPositive(values) {
|
|
385
|
+
for (const value of values) {
|
|
386
|
+
const number = Number(value)
|
|
387
|
+
if (Number.isFinite(number) && number > 0) {
|
|
388
|
+
return number
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return 0
|
|
393
|
+
}
|
|
394
|
+
}
|
|
@@ -739,17 +739,39 @@ export class PcbAssemblyStepWriter {
|
|
|
739
739
|
}
|
|
740
740
|
|
|
741
741
|
/**
|
|
742
|
-
* Returns
|
|
742
|
+
* Returns display-space RGB components for one mesh color.
|
|
743
743
|
* @param {number[] | undefined} color Mesh RGB color.
|
|
744
744
|
* @returns {number[]}
|
|
745
745
|
*/
|
|
746
746
|
static #colorComponents(color) {
|
|
747
747
|
const normalized = Array.isArray(color) ? color : [0.55, 0.56, 0.58]
|
|
748
748
|
return [0, 1, 2].map((index) =>
|
|
749
|
-
|
|
749
|
+
PcbAssemblyStepWriter.#linearToSrgb(
|
|
750
|
+
PcbAssemblyStepWriter.#clampColorChannel(normalized[index])
|
|
751
|
+
)
|
|
750
752
|
)
|
|
751
753
|
}
|
|
752
754
|
|
|
755
|
+
/**
|
|
756
|
+
* Clamps one color channel to the STEP RGB range.
|
|
757
|
+
* @param {unknown} value Candidate color channel.
|
|
758
|
+
* @returns {number}
|
|
759
|
+
*/
|
|
760
|
+
static #clampColorChannel(value) {
|
|
761
|
+
return Math.max(Math.min(Number(value || 0), 1), 0)
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* Converts one linear RGB channel to display-space sRGB.
|
|
766
|
+
* @param {number} value Linear RGB channel.
|
|
767
|
+
* @returns {number}
|
|
768
|
+
*/
|
|
769
|
+
static #linearToSrgb(value) {
|
|
770
|
+
return value <= 0.0031308
|
|
771
|
+
? value * 12.92
|
|
772
|
+
: 1.055 * Math.pow(value, 1 / 2.4) - 0.055
|
|
773
|
+
}
|
|
774
|
+
|
|
753
775
|
/**
|
|
754
776
|
* Tests whether a vertex has finite coordinates.
|
|
755
777
|
* @param {unknown} vertex Candidate vertex.
|