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.
@@ -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
+ }