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,541 @@
|
|
|
1
|
+
const FULL_CIRCLE_DEGREES = 360
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared mesh helpers for PCB assembly export.
|
|
5
|
+
*/
|
|
6
|
+
export class PcbAssemblyMeshUtils {
|
|
7
|
+
/**
|
|
8
|
+
* Builds a rectangular box mesh.
|
|
9
|
+
* @param {string} name Mesh name.
|
|
10
|
+
* @param {{ x?: number, y?: number, z?: number, width?: number, depth?: number, height?: number, color?: number[] }} options Box options in mils.
|
|
11
|
+
* @returns {{ name: string, vertices: number[][], faces: number[][], color?: number[] }}
|
|
12
|
+
*/
|
|
13
|
+
static box(name, options = {}) {
|
|
14
|
+
const x = Number(options.x || 0)
|
|
15
|
+
const y = Number(options.y || 0)
|
|
16
|
+
const z = Number(options.z || 0)
|
|
17
|
+
const halfWidth = Math.max(Number(options.width || 0), 0.001) / 2
|
|
18
|
+
const halfDepth = Math.max(Number(options.depth || 0), 0.001) / 2
|
|
19
|
+
const halfHeight = Math.max(Number(options.height || 0), 0.001) / 2
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
name,
|
|
23
|
+
vertices: [
|
|
24
|
+
[x - halfWidth, y - halfDepth, z - halfHeight],
|
|
25
|
+
[x + halfWidth, y - halfDepth, z - halfHeight],
|
|
26
|
+
[x + halfWidth, y + halfDepth, z - halfHeight],
|
|
27
|
+
[x - halfWidth, y + halfDepth, z - halfHeight],
|
|
28
|
+
[x - halfWidth, y - halfDepth, z + halfHeight],
|
|
29
|
+
[x + halfWidth, y - halfDepth, z + halfHeight],
|
|
30
|
+
[x + halfWidth, y + halfDepth, z + halfHeight],
|
|
31
|
+
[x - halfWidth, y + halfDepth, z + halfHeight]
|
|
32
|
+
],
|
|
33
|
+
faces: [
|
|
34
|
+
[0, 3, 2, 1],
|
|
35
|
+
[4, 5, 6, 7],
|
|
36
|
+
[0, 1, 5, 4],
|
|
37
|
+
[1, 2, 6, 5],
|
|
38
|
+
[2, 3, 7, 6],
|
|
39
|
+
[3, 0, 4, 7]
|
|
40
|
+
],
|
|
41
|
+
...(Array.isArray(options.color) ? { color: options.color } : {})
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Builds an extruded polygon mesh.
|
|
47
|
+
* @param {string} name Mesh name.
|
|
48
|
+
* @param {number[][]} points Polygon points in mils.
|
|
49
|
+
* @param {number} z Center Z in mils.
|
|
50
|
+
* @param {number} thickness Extrusion thickness in mils.
|
|
51
|
+
* @param {number[] | undefined} color Optional RGB color.
|
|
52
|
+
* @returns {{ name: string, vertices: number[][], faces: number[][], color?: number[] } | null}
|
|
53
|
+
*/
|
|
54
|
+
static prism(name, points, z, thickness, color = undefined) {
|
|
55
|
+
const loop = PcbAssemblyMeshUtils.cleanLoop(points)
|
|
56
|
+
if (loop.length < 3) {
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const halfThickness = Math.max(Number(thickness || 0), 0.001) / 2
|
|
61
|
+
const bottomZ = Number(z || 0) - halfThickness
|
|
62
|
+
const topZ = Number(z || 0) + halfThickness
|
|
63
|
+
const vertices = [
|
|
64
|
+
...loop.map((point) => [point[0], point[1], bottomZ]),
|
|
65
|
+
...loop.map((point) => [point[0], point[1], topZ])
|
|
66
|
+
]
|
|
67
|
+
const topOffset = loop.length
|
|
68
|
+
const faces = [
|
|
69
|
+
[...loop.keys()].reverse(),
|
|
70
|
+
[...loop.keys()].map((index) => index + topOffset)
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
for (let index = 0; index < loop.length; index += 1) {
|
|
74
|
+
const nextIndex = (index + 1) % loop.length
|
|
75
|
+
faces.push([
|
|
76
|
+
index,
|
|
77
|
+
nextIndex,
|
|
78
|
+
nextIndex + topOffset,
|
|
79
|
+
index + topOffset
|
|
80
|
+
])
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
name,
|
|
85
|
+
vertices,
|
|
86
|
+
faces,
|
|
87
|
+
...(Array.isArray(color) ? { color } : {})
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Builds a cylinder mesh from polygonal rings.
|
|
93
|
+
* @param {string} name Mesh name.
|
|
94
|
+
* @param {{ x?: number, y?: number, z?: number, radius?: number, height?: number, segments?: number, color?: number[] }} options Cylinder options.
|
|
95
|
+
* @returns {{ name: string, vertices: number[][], faces: number[][], color?: number[] }}
|
|
96
|
+
*/
|
|
97
|
+
static cylinder(name, options = {}) {
|
|
98
|
+
const radius = Math.max(Number(options.radius || 0), 0.001)
|
|
99
|
+
const segments = Math.max(Number(options.segments || 24), 8)
|
|
100
|
+
const points = PcbAssemblyMeshUtils.circlePoints(
|
|
101
|
+
Number(options.x || 0),
|
|
102
|
+
Number(options.y || 0),
|
|
103
|
+
radius,
|
|
104
|
+
segments
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
return PcbAssemblyMeshUtils.prism(
|
|
108
|
+
name,
|
|
109
|
+
points,
|
|
110
|
+
Number(options.z || 0),
|
|
111
|
+
Number(options.height || 0.001),
|
|
112
|
+
options.color
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Builds a capsule loop around a line segment.
|
|
118
|
+
* @param {number} x1 Start X.
|
|
119
|
+
* @param {number} y1 Start Y.
|
|
120
|
+
* @param {number} x2 End X.
|
|
121
|
+
* @param {number} y2 End Y.
|
|
122
|
+
* @param {number} radius Stroke radius.
|
|
123
|
+
* @param {number} segmentsPerCap Number of samples on each semicircle.
|
|
124
|
+
* @returns {number[][]}
|
|
125
|
+
*/
|
|
126
|
+
static capsulePoints(x1, y1, x2, y2, radius, segmentsPerCap = 12) {
|
|
127
|
+
const startX = Number(x1 || 0)
|
|
128
|
+
const startY = Number(y1 || 0)
|
|
129
|
+
const endX = Number(x2 || 0)
|
|
130
|
+
const endY = Number(y2 || 0)
|
|
131
|
+
const capRadius = Math.max(Number(radius || 0), 0.001)
|
|
132
|
+
const dx = endX - startX
|
|
133
|
+
const dy = endY - startY
|
|
134
|
+
const length = Math.hypot(dx, dy)
|
|
135
|
+
if (length <= 0.001) {
|
|
136
|
+
return PcbAssemblyMeshUtils.circlePoints(
|
|
137
|
+
startX,
|
|
138
|
+
startY,
|
|
139
|
+
capRadius,
|
|
140
|
+
Math.max(segmentsPerCap * 2, 8)
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const capSegments = Math.max(Number(segmentsPerCap || 12), 4)
|
|
145
|
+
const normalAngle = Math.atan2(dx / length, -dy / length)
|
|
146
|
+
const points = [
|
|
147
|
+
[
|
|
148
|
+
startX + Math.cos(normalAngle) * capRadius,
|
|
149
|
+
startY + Math.sin(normalAngle) * capRadius
|
|
150
|
+
],
|
|
151
|
+
[
|
|
152
|
+
endX + Math.cos(normalAngle) * capRadius,
|
|
153
|
+
endY + Math.sin(normalAngle) * capRadius
|
|
154
|
+
]
|
|
155
|
+
]
|
|
156
|
+
|
|
157
|
+
for (let index = 1; index <= capSegments; index += 1) {
|
|
158
|
+
const angle = normalAngle - (Math.PI * index) / capSegments
|
|
159
|
+
points.push([
|
|
160
|
+
endX + Math.cos(angle) * capRadius,
|
|
161
|
+
endY + Math.sin(angle) * capRadius
|
|
162
|
+
])
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
points.push([
|
|
166
|
+
startX - Math.cos(normalAngle) * capRadius,
|
|
167
|
+
startY - Math.sin(normalAngle) * capRadius
|
|
168
|
+
])
|
|
169
|
+
|
|
170
|
+
for (let index = 1; index <= capSegments; index += 1) {
|
|
171
|
+
const angle =
|
|
172
|
+
normalAngle + Math.PI - (Math.PI * index) / capSegments
|
|
173
|
+
points.push([
|
|
174
|
+
startX + Math.cos(angle) * capRadius,
|
|
175
|
+
startY + Math.sin(angle) * capRadius
|
|
176
|
+
])
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return PcbAssemblyMeshUtils.cleanLoop(points)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Builds a hollow cylindrical sleeve mesh.
|
|
184
|
+
* @param {string} name Mesh name.
|
|
185
|
+
* @param {{ x?: number, y?: number, z?: number, outerRadius?: number, innerRadius?: number, height?: number, segments?: number, color?: number[] }} options Ring options.
|
|
186
|
+
* @returns {{ name: string, vertices: number[][], faces: number[][], color?: number[] }}
|
|
187
|
+
*/
|
|
188
|
+
static ringCylinder(name, options = {}) {
|
|
189
|
+
const x = Number(options.x || 0)
|
|
190
|
+
const y = Number(options.y || 0)
|
|
191
|
+
const z = Number(options.z || 0)
|
|
192
|
+
const outerRadius = Math.max(Number(options.outerRadius || 0), 0.001)
|
|
193
|
+
const innerRadius = Math.max(
|
|
194
|
+
Math.min(Number(options.innerRadius || 0), outerRadius - 0.001),
|
|
195
|
+
0.001
|
|
196
|
+
)
|
|
197
|
+
const height = Math.max(Number(options.height || 0), 0.001)
|
|
198
|
+
const halfHeight = height / 2
|
|
199
|
+
const segments = Math.max(Number(options.segments || 24), 8)
|
|
200
|
+
const outer = PcbAssemblyMeshUtils.circlePoints(
|
|
201
|
+
x,
|
|
202
|
+
y,
|
|
203
|
+
outerRadius,
|
|
204
|
+
segments
|
|
205
|
+
)
|
|
206
|
+
const inner = PcbAssemblyMeshUtils.circlePoints(
|
|
207
|
+
x,
|
|
208
|
+
y,
|
|
209
|
+
innerRadius,
|
|
210
|
+
segments
|
|
211
|
+
)
|
|
212
|
+
const vertices = [
|
|
213
|
+
...outer.map((point) => [point[0], point[1], z - halfHeight]),
|
|
214
|
+
...inner.map((point) => [point[0], point[1], z - halfHeight]),
|
|
215
|
+
...outer.map((point) => [point[0], point[1], z + halfHeight]),
|
|
216
|
+
...inner.map((point) => [point[0], point[1], z + halfHeight])
|
|
217
|
+
]
|
|
218
|
+
const faces = []
|
|
219
|
+
const innerBottom = segments
|
|
220
|
+
const outerTop = segments * 2
|
|
221
|
+
const innerTop = segments * 3
|
|
222
|
+
|
|
223
|
+
for (let index = 0; index < segments; index += 1) {
|
|
224
|
+
const next = (index + 1) % segments
|
|
225
|
+
faces.push([index, next, next + outerTop, index + outerTop])
|
|
226
|
+
faces.push([
|
|
227
|
+
innerBottom + next,
|
|
228
|
+
innerBottom + index,
|
|
229
|
+
innerTop + index,
|
|
230
|
+
innerTop + next
|
|
231
|
+
])
|
|
232
|
+
faces.push([
|
|
233
|
+
outerTop + index,
|
|
234
|
+
outerTop + next,
|
|
235
|
+
innerTop + next,
|
|
236
|
+
innerTop + index
|
|
237
|
+
])
|
|
238
|
+
faces.push([next, index, innerBottom + index, innerBottom + next])
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
name,
|
|
243
|
+
vertices,
|
|
244
|
+
faces,
|
|
245
|
+
...(Array.isArray(options.color) ? { color: options.color } : {})
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Builds points around a circle.
|
|
251
|
+
* @param {number} x Center X.
|
|
252
|
+
* @param {number} y Center Y.
|
|
253
|
+
* @param {number} radius Radius.
|
|
254
|
+
* @param {number} segments Segment count.
|
|
255
|
+
* @returns {number[][]}
|
|
256
|
+
*/
|
|
257
|
+
static circlePoints(x, y, radius, segments = 24) {
|
|
258
|
+
const count = Math.max(Number(segments || 24), 8)
|
|
259
|
+
const points = []
|
|
260
|
+
|
|
261
|
+
for (let index = 0; index < count; index += 1) {
|
|
262
|
+
const angle = (Math.PI * 2 * index) / count
|
|
263
|
+
points.push([
|
|
264
|
+
Number(x || 0) + Math.cos(angle) * radius,
|
|
265
|
+
Number(y || 0) + Math.sin(angle) * radius
|
|
266
|
+
])
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return points
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Builds an arc-band polygon.
|
|
274
|
+
* @param {{ x?: number, y?: number, radius?: number, width?: number, startAngle?: number, sweepAngle?: number, endAngle?: number }} arc Arc primitive.
|
|
275
|
+
* @returns {number[][]}
|
|
276
|
+
*/
|
|
277
|
+
static arcBandPoints(arc) {
|
|
278
|
+
const centerX = Number(arc?.x || arc?.centerX || 0)
|
|
279
|
+
const centerY = Number(arc?.y || arc?.centerY || 0)
|
|
280
|
+
const width = Math.max(Number(arc?.width || 1), 0.001)
|
|
281
|
+
const radius = Math.max(Number(arc?.radius || 0), width / 2)
|
|
282
|
+
const outerRadius = radius + width / 2
|
|
283
|
+
const innerRadius = Math.max(radius - width / 2, 0.001)
|
|
284
|
+
const start = Number(arc?.startAngle || 0)
|
|
285
|
+
const sweep = PcbAssemblyMeshUtils.resolveSweep(arc)
|
|
286
|
+
const segments = Math.max(Math.ceil(Math.abs(sweep) / 6), 8)
|
|
287
|
+
const outer = []
|
|
288
|
+
const inner = []
|
|
289
|
+
|
|
290
|
+
for (let index = 0; index <= segments; index += 1) {
|
|
291
|
+
const angle = ((start + (sweep * index) / segments) * Math.PI) / 180
|
|
292
|
+
outer.push([
|
|
293
|
+
centerX + Math.cos(angle) * outerRadius,
|
|
294
|
+
centerY + Math.sin(angle) * outerRadius
|
|
295
|
+
])
|
|
296
|
+
inner.push([
|
|
297
|
+
centerX + Math.cos(angle) * innerRadius,
|
|
298
|
+
centerY + Math.sin(angle) * innerRadius
|
|
299
|
+
])
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return [...outer, ...inner.reverse()]
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Resolves an arc sweep angle.
|
|
307
|
+
* @param {{ sweepAngle?: number, startAngle?: number, endAngle?: number }} arc Arc primitive.
|
|
308
|
+
* @returns {number}
|
|
309
|
+
*/
|
|
310
|
+
static resolveSweep(arc) {
|
|
311
|
+
if (Number.isFinite(Number(arc?.sweepAngle))) {
|
|
312
|
+
const sweep = Number(arc.sweepAngle)
|
|
313
|
+
return Math.abs(sweep) > 0.001 ? sweep : FULL_CIRCLE_DEGREES
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (Number.isFinite(Number(arc?.endAngle))) {
|
|
317
|
+
const sweep = Number(arc.endAngle) - Number(arc?.startAngle || 0)
|
|
318
|
+
return Math.abs(sweep) > 0.001 ? sweep : FULL_CIRCLE_DEGREES
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return FULL_CIRCLE_DEGREES
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Removes duplicate and invalid polygon points.
|
|
326
|
+
* @param {number[][]} points Candidate points.
|
|
327
|
+
* @returns {number[][]}
|
|
328
|
+
*/
|
|
329
|
+
static cleanLoop(points) {
|
|
330
|
+
const loop = []
|
|
331
|
+
|
|
332
|
+
for (const point of Array.isArray(points) ? points : []) {
|
|
333
|
+
const x = Number(point?.[0])
|
|
334
|
+
const y = Number(point?.[1])
|
|
335
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
|
336
|
+
continue
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const previous = loop[loop.length - 1]
|
|
340
|
+
if (
|
|
341
|
+
previous &&
|
|
342
|
+
Math.abs(previous[0] - x) < 0.001 &&
|
|
343
|
+
Math.abs(previous[1] - y) < 0.001
|
|
344
|
+
) {
|
|
345
|
+
continue
|
|
346
|
+
}
|
|
347
|
+
loop.push([x, y])
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const first = loop[0]
|
|
351
|
+
const last = loop[loop.length - 1]
|
|
352
|
+
if (
|
|
353
|
+
first &&
|
|
354
|
+
last &&
|
|
355
|
+
Math.abs(first[0] - last[0]) < 0.001 &&
|
|
356
|
+
Math.abs(first[1] - last[1]) < 0.001
|
|
357
|
+
) {
|
|
358
|
+
loop.pop()
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return loop
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Applies a placement transform to one mesh.
|
|
366
|
+
* @param {{ name: string, vertices: number[][], faces: number[][], color?: number[] }} mesh Source mesh.
|
|
367
|
+
* @param {object} placement Placement metadata.
|
|
368
|
+
* @returns {{ name: string, vertices: number[][], faces: number[][], color?: number[] }}
|
|
369
|
+
*/
|
|
370
|
+
static transformMesh(mesh, placement = {}) {
|
|
371
|
+
const rotationDeg = Number(placement?.rotationDeg || 0)
|
|
372
|
+
const rotationRad = (rotationDeg * Math.PI) / 180
|
|
373
|
+
const cos = Math.cos(rotationRad)
|
|
374
|
+
const sin = Math.sin(rotationRad)
|
|
375
|
+
const position = placement?.positionMil || {}
|
|
376
|
+
const mountSide = String(placement?.mountSide || 'top').toLowerCase()
|
|
377
|
+
const zSign = mountSide === 'bottom' ? -1 : 1
|
|
378
|
+
|
|
379
|
+
return {
|
|
380
|
+
...mesh,
|
|
381
|
+
vertices: mesh.vertices.map((vertex) => {
|
|
382
|
+
const modelPoint = PcbAssemblyMeshUtils.#applyModelTransform(
|
|
383
|
+
vertex,
|
|
384
|
+
placement?.modelTransform
|
|
385
|
+
)
|
|
386
|
+
const z = modelPoint.z * zSign
|
|
387
|
+
const rotatedX = modelPoint.x * cos - modelPoint.y * sin
|
|
388
|
+
const rotatedY = modelPoint.x * sin + modelPoint.y * cos
|
|
389
|
+
|
|
390
|
+
return [
|
|
391
|
+
rotatedX + Number(position.x || 0),
|
|
392
|
+
rotatedY + Number(position.y || 0),
|
|
393
|
+
z + Number(position.z || 0)
|
|
394
|
+
]
|
|
395
|
+
})
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Applies the source-model transform before footprint placement.
|
|
401
|
+
* @param {number[]} vertex Source vertex.
|
|
402
|
+
* @param {object | null | undefined} modelTransform Model transform.
|
|
403
|
+
* @returns {{ x: number, y: number, z: number }}
|
|
404
|
+
*/
|
|
405
|
+
static #applyModelTransform(vertex, modelTransform) {
|
|
406
|
+
const scale = PcbAssemblyMeshUtils.#resolveModelScale(modelTransform)
|
|
407
|
+
const offset = PcbAssemblyMeshUtils.#resolveModelOffset(modelTransform)
|
|
408
|
+
const rotated = PcbAssemblyMeshUtils.#rotateModelPoint(
|
|
409
|
+
{
|
|
410
|
+
x: Number(vertex?.[0] || 0) * scale.x,
|
|
411
|
+
y: Number(vertex?.[1] || 0) * scale.y,
|
|
412
|
+
z: Number(vertex?.[2] || 0) * scale.z
|
|
413
|
+
},
|
|
414
|
+
modelTransform?.rotationDeg || {}
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
return {
|
|
418
|
+
x: rotated.x + offset.x,
|
|
419
|
+
y: rotated.y + offset.y,
|
|
420
|
+
z: rotated.z + offset.z
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Resolves model-local offset fields.
|
|
426
|
+
* @param {object | null | undefined} modelTransform Model transform.
|
|
427
|
+
* @returns {{ x: number, y: number, z: number }}
|
|
428
|
+
*/
|
|
429
|
+
static #resolveModelOffset(modelTransform) {
|
|
430
|
+
const offset = modelTransform?.offsetMil || {}
|
|
431
|
+
|
|
432
|
+
return {
|
|
433
|
+
x: Number(offset.x ?? modelTransform?.dxMil ?? 0),
|
|
434
|
+
y: Number(offset.y ?? modelTransform?.dyMil ?? 0),
|
|
435
|
+
z: Number(offset.z ?? modelTransform?.dzMil ?? 0)
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Resolves model-local scale fields.
|
|
441
|
+
* @param {object | null | undefined} modelTransform Model transform.
|
|
442
|
+
* @returns {{ x: number, y: number, z: number }}
|
|
443
|
+
*/
|
|
444
|
+
static #resolveModelScale(modelTransform) {
|
|
445
|
+
const scale = modelTransform?.scale || {}
|
|
446
|
+
|
|
447
|
+
return {
|
|
448
|
+
x: Number(scale.x ?? 1) || 1,
|
|
449
|
+
y: Number(scale.y ?? 1) || 1,
|
|
450
|
+
z: Number(scale.z ?? 1) || 1
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Applies KiCad-compatible model rotation order to a point.
|
|
456
|
+
* @param {{ x: number, y: number, z: number }} point Source point.
|
|
457
|
+
* @param {{ x?: number, y?: number, z?: number }} rotationDeg Rotation angles.
|
|
458
|
+
* @returns {{ x: number, y: number, z: number }}
|
|
459
|
+
*/
|
|
460
|
+
static #rotateModelPoint(point, rotationDeg) {
|
|
461
|
+
const afterX = PcbAssemblyMeshUtils.#rotateX(
|
|
462
|
+
point,
|
|
463
|
+
-Number(rotationDeg?.x || 0)
|
|
464
|
+
)
|
|
465
|
+
const afterY = PcbAssemblyMeshUtils.#rotateY(
|
|
466
|
+
afterX,
|
|
467
|
+
-Number(rotationDeg?.y || 0)
|
|
468
|
+
)
|
|
469
|
+
return PcbAssemblyMeshUtils.#rotateZ(
|
|
470
|
+
afterY,
|
|
471
|
+
-Number(rotationDeg?.z || 0)
|
|
472
|
+
)
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Rotates one point around X.
|
|
477
|
+
* @param {{ x: number, y: number, z: number }} point Source point.
|
|
478
|
+
* @param {number} angleDeg Rotation in degrees.
|
|
479
|
+
* @returns {{ x: number, y: number, z: number }}
|
|
480
|
+
*/
|
|
481
|
+
static #rotateX(point, angleDeg) {
|
|
482
|
+
const angle = (angleDeg * Math.PI) / 180
|
|
483
|
+
const cos = Math.cos(angle)
|
|
484
|
+
const sin = Math.sin(angle)
|
|
485
|
+
|
|
486
|
+
return {
|
|
487
|
+
x: point.x,
|
|
488
|
+
y: point.y * cos - point.z * sin,
|
|
489
|
+
z: point.y * sin + point.z * cos
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Rotates one point around Y.
|
|
495
|
+
* @param {{ x: number, y: number, z: number }} point Source point.
|
|
496
|
+
* @param {number} angleDeg Rotation in degrees.
|
|
497
|
+
* @returns {{ x: number, y: number, z: number }}
|
|
498
|
+
*/
|
|
499
|
+
static #rotateY(point, angleDeg) {
|
|
500
|
+
const angle = (angleDeg * Math.PI) / 180
|
|
501
|
+
const cos = Math.cos(angle)
|
|
502
|
+
const sin = Math.sin(angle)
|
|
503
|
+
|
|
504
|
+
return {
|
|
505
|
+
x: point.x * cos + point.z * sin,
|
|
506
|
+
y: point.y,
|
|
507
|
+
z: -point.x * sin + point.z * cos
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Rotates one point around Z.
|
|
513
|
+
* @param {{ x: number, y: number, z: number }} point Source point.
|
|
514
|
+
* @param {number} angleDeg Rotation in degrees.
|
|
515
|
+
* @returns {{ x: number, y: number, z: number }}
|
|
516
|
+
*/
|
|
517
|
+
static #rotateZ(point, angleDeg) {
|
|
518
|
+
const angle = (angleDeg * Math.PI) / 180
|
|
519
|
+
const cos = Math.cos(angle)
|
|
520
|
+
const sin = Math.sin(angle)
|
|
521
|
+
|
|
522
|
+
return {
|
|
523
|
+
x: point.x * cos - point.y * sin,
|
|
524
|
+
y: point.x * sin + point.y * cos,
|
|
525
|
+
z: point.z
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Sanitizes a mesh name token.
|
|
531
|
+
* @param {string} value Source value.
|
|
532
|
+
* @returns {string}
|
|
533
|
+
*/
|
|
534
|
+
static safeName(value) {
|
|
535
|
+
return String(value || 'mesh')
|
|
536
|
+
.trim()
|
|
537
|
+
.replace(/[^A-Za-z0-9_.-]+/gu, '-')
|
|
538
|
+
.replace(/^-+|-+$/gu, '')
|
|
539
|
+
.slice(0, 80)
|
|
540
|
+
}
|
|
541
|
+
}
|