pcb-scene3d-viewer 1.0.1

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.
Files changed (65) hide show
  1. package/AGENTS.md +67 -0
  2. package/COMMERCIAL-LICENSE.md +15 -0
  3. package/CONTRIBUTING.md +14 -0
  4. package/LICENSE +19 -0
  5. package/LICENSES/AGPL-3.0-or-later.txt +235 -0
  6. package/LICENSES/CC-BY-SA-4.0.txt +170 -0
  7. package/LICENSES/LGPL-2.1-or-later.txt +176 -0
  8. package/LICENSES/LicenseRef-PolyForm-Noncommercial-1.0.0.txt +131 -0
  9. package/NOTICE.md +36 -0
  10. package/README.md +128 -0
  11. package/REUSE.toml +16 -0
  12. package/docs/api.md +148 -0
  13. package/docs/circuitjson.md +190 -0
  14. package/docs/model-format.md +117 -0
  15. package/docs/testing.md +23 -0
  16. package/package.json +65 -0
  17. package/spec/library-scope.md +36 -0
  18. package/src/PcbModelArchiveExporter.mjs +320 -0
  19. package/src/PcbScene3dArcUtils.mjs +27 -0
  20. package/src/PcbScene3dBoardAssemblyPlacement.mjs +36 -0
  21. package/src/PcbScene3dBoardAssemblyPresentation.mjs +859 -0
  22. package/src/PcbScene3dBoardEdgeCutoutBuilder.mjs +537 -0
  23. package/src/PcbScene3dBoardMaterialPalette.mjs +40 -0
  24. package/src/PcbScene3dBoardShapeFactory.mjs +895 -0
  25. package/src/PcbScene3dBoardSolderMaskFactory.mjs +613 -0
  26. package/src/PcbScene3dCameraRig.mjs +168 -0
  27. package/src/PcbScene3dCircuitJsonAdapter.mjs +545 -0
  28. package/src/PcbScene3dController.mjs +956 -0
  29. package/src/PcbScene3dCopperDetailFilter.mjs +490 -0
  30. package/src/PcbScene3dCopperFactory.mjs +559 -0
  31. package/src/PcbScene3dCopperTextFactory.mjs +534 -0
  32. package/src/PcbScene3dCutoutGeometryFilter.mjs +873 -0
  33. package/src/PcbScene3dDetailCoordinateNormalizer.mjs +65 -0
  34. package/src/PcbScene3dDrillCutoutFilter.mjs +224 -0
  35. package/src/PcbScene3dDrillPathFactory.mjs +362 -0
  36. package/src/PcbScene3dDrillVoidFactory.mjs +268 -0
  37. package/src/PcbScene3dExternalModelLoadOrder.mjs +54 -0
  38. package/src/PcbScene3dExternalModels.mjs +968 -0
  39. package/src/PcbScene3dFallbackVisibility.mjs +82 -0
  40. package/src/PcbScene3dInteractionHints.mjs +56 -0
  41. package/src/PcbScene3dMountRig.mjs +53 -0
  42. package/src/PcbScene3dOutlineBuilder.mjs +210 -0
  43. package/src/PcbScene3dPadFactory.mjs +553 -0
  44. package/src/PcbScene3dPresetState.mjs +48 -0
  45. package/src/PcbScene3dRenderGroupVisibility.mjs +134 -0
  46. package/src/PcbScene3dRuntime.mjs +996 -0
  47. package/src/PcbScene3dRuntimeBoardMeshes.mjs +99 -0
  48. package/src/PcbScene3dSelectionStyler.mjs +252 -0
  49. package/src/PcbScene3dShapePathFactory.mjs +220 -0
  50. package/src/PcbScene3dShellRenderer.mjs +131 -0
  51. package/src/PcbScene3dSilkscreenFactory.mjs +854 -0
  52. package/src/PcbScene3dSilkscreenStrokeWidthResolver.mjs +81 -0
  53. package/src/PcbScene3dStepLoader.mjs +611 -0
  54. package/src/PcbScene3dStrokeFont.mjs +671 -0
  55. package/src/PcbScene3dStrokeGeometryBuilder.mjs +322 -0
  56. package/src/PcbScene3dText.mjs +99 -0
  57. package/src/PcbScene3dTrueTypeTextFactory.mjs +885 -0
  58. package/src/PcbScene3dViaFactory.mjs +176 -0
  59. package/src/PcbScene3dViewCompensation.mjs +109 -0
  60. package/src/PcbScene3dViewScale.mjs +24 -0
  61. package/src/PcbScene3dViewportResize.mjs +35 -0
  62. package/src/PcbScene3dWorkerClient.mjs +123 -0
  63. package/src/index.mjs +1 -0
  64. package/src/scene3d.mjs +44 -0
  65. package/src/styles/scene3d.css +295 -0
@@ -0,0 +1,553 @@
1
+ import { PcbScene3dDrillPathFactory } from './PcbScene3dDrillPathFactory.mjs'
2
+
3
+ /**
4
+ * Builds copper pad meshes for the interactive 3D PCB scene.
5
+ */
6
+ export class PcbScene3dPadFactory {
7
+ static #PAD_SHAPE_RECTANGULAR = 2
8
+ static #PAD_THICKNESS_MIL = 2.2
9
+
10
+ /**
11
+ * Builds the pad mesh group for one scene.
12
+ * @param {any} THREE
13
+ * @param {any[]} pads
14
+ * @param {number} z
15
+ * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint
16
+ * @param {{ side?: 'top' | 'bottom', mirrorY?: boolean }} [options]
17
+ * @returns {any}
18
+ */
19
+ static buildGroup(THREE, pads, z, normalizeBoardPoint, options = {}) {
20
+ const group = new THREE.Group()
21
+ const material = new THREE.MeshStandardMaterial({
22
+ color: 0xd9a61d,
23
+ roughness: 0.38,
24
+ metalness: 0.55,
25
+ side: THREE.DoubleSide
26
+ })
27
+ const geometryCache = new Map()
28
+ const side = PcbScene3dPadFactory.#normalizeSide(options?.side)
29
+ const mirrorY = Boolean(options?.mirrorY)
30
+
31
+ ;(pads || []).forEach((pad) => {
32
+ if (!PcbScene3dPadFactory.#hasVisibleSurface(pad, side)) {
33
+ return
34
+ }
35
+
36
+ const spec = PcbScene3dPadFactory.resolvePadSurfaceSpec(pad, side)
37
+ const geometry = PcbScene3dPadFactory.#resolveGeometry(
38
+ THREE,
39
+ geometryCache,
40
+ spec,
41
+ pad
42
+ )
43
+ const point = PcbScene3dPadFactory.#normalizePoint(
44
+ normalizeBoardPoint,
45
+ Number(pad?.x || 0),
46
+ Number(pad?.y || 0),
47
+ mirrorY
48
+ )
49
+ const root = new THREE.Group()
50
+ const mesh = new THREE.Mesh(geometry, material)
51
+ root.position.set(point.x, point.y, 0)
52
+ root.rotation.z = (Number(pad?.rotation || 0) * Math.PI) / 180
53
+ mesh.position.set(
54
+ spec.offsetX,
55
+ mirrorY ? -spec.offsetY : spec.offsetY,
56
+ z
57
+ )
58
+ if (geometry.type === 'CylinderGeometry') {
59
+ mesh.rotation.x = Math.PI / 2
60
+ }
61
+ root.add(mesh)
62
+ group.add(root)
63
+ })
64
+
65
+ return group
66
+ }
67
+
68
+ /**
69
+ * Resolves the visible copper shape for one pad face.
70
+ * @param {{ sizeTopX?: number, sizeTopY?: number, sizeMidX?: number, sizeMidY?: number, sizeBottomX?: number, sizeBottomY?: number, holeDiameter?: number, holeShape?: number | null, holeSlotLength?: number | null, holeRotation?: number | null, shapeTop?: number, shapeMid?: number, shapeBottom?: number, hasRoundedRect?: boolean, roundedRectShapeTop?: number | null, roundedRectShapeBottom?: number | null, cornerRadiusTop?: number | null, cornerRadiusBottom?: number | null, offsetTopX?: number, offsetTopY?: number, offsetBottomX?: number, offsetBottomY?: number }} pad
71
+ * @param {'top' | 'bottom'} [side]
72
+ * @returns {{ width: number, height: number, kind: 'circle' | 'rect' | 'rounded-rect', radius: number, cornerRadius: number, offsetX: number, offsetY: number, hasHole: boolean, holeDiameter: number, holeSlotLength: number | null, holeRotation: number }}
73
+ */
74
+ static resolvePadSurfaceSpec(pad, side = 'top') {
75
+ const normalizedSide = PcbScene3dPadFactory.#normalizeSide(side)
76
+ const size = PcbScene3dPadFactory.#resolvePadSurfaceSize(
77
+ pad,
78
+ normalizedSide
79
+ )
80
+ const cornerRadius = PcbScene3dPadFactory.#resolvePadCornerRadius(
81
+ pad,
82
+ size,
83
+ normalizedSide
84
+ )
85
+ const offset = PcbScene3dPadFactory.#resolvePadOffset(
86
+ pad,
87
+ normalizedSide
88
+ )
89
+
90
+ return {
91
+ width: size.width,
92
+ height: size.height,
93
+ kind: PcbScene3dPadFactory.#resolvePadKind(
94
+ pad,
95
+ size,
96
+ cornerRadius,
97
+ normalizedSide
98
+ ),
99
+ radius: Math.max(size.width, size.height) / 2,
100
+ cornerRadius,
101
+ offsetX: offset.x,
102
+ offsetY: offset.y,
103
+ hasHole: Number(pad?.holeDiameter || 0) > 0,
104
+ holeDiameter: Math.max(Number(pad?.holeDiameter || 0), 0),
105
+ holeSlotLength:
106
+ Number(pad?.holeSlotLength || 0) >
107
+ Number(pad?.holeDiameter || 0)
108
+ ? Number(pad?.holeSlotLength || 0)
109
+ : null,
110
+ holeRotation: Number(pad?.holeRotation || 0)
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Resolves the visible copper size for one pad face.
116
+ * @param {{ sizeTopX?: number, sizeTopY?: number, sizeMidX?: number, sizeMidY?: number, sizeBottomX?: number, sizeBottomY?: number, holeDiameter?: number }} pad
117
+ * @param {'top' | 'bottom'} side
118
+ * @returns {{ width: number, height: number }}
119
+ */
120
+ static #resolvePadSurfaceSize(pad, side) {
121
+ const preferredWidth =
122
+ side === 'bottom'
123
+ ? Number(pad?.sizeBottomX || 0)
124
+ : Number(pad?.sizeTopX || 0)
125
+ const preferredHeight =
126
+ side === 'bottom'
127
+ ? Number(pad?.sizeBottomY || 0)
128
+ : Number(pad?.sizeTopY || 0)
129
+ const width =
130
+ Number(
131
+ preferredWidth ||
132
+ pad?.sizeMidX ||
133
+ (side === 'bottom' ? pad?.sizeTopX : pad?.sizeBottomX) ||
134
+ pad?.holeDiameter ||
135
+ 0
136
+ ) || 0
137
+ const height =
138
+ Number(
139
+ preferredHeight ||
140
+ pad?.sizeMidY ||
141
+ (side === 'bottom' ? pad?.sizeTopY : pad?.sizeBottomY) ||
142
+ pad?.holeDiameter ||
143
+ 0
144
+ ) || 0
145
+ const holeDiameter = Number(pad?.holeDiameter || 0)
146
+
147
+ return {
148
+ width: Math.max(width, holeDiameter, 1),
149
+ height: Math.max(height, holeDiameter, 1)
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Resolves the authored pad kind for one face.
155
+ * @param {{ shapeTop?: number, shapeMid?: number, shapeBottom?: number, hasRoundedRect?: boolean, roundedRectShapeTop?: number | null, roundedRectShapeBottom?: number | null }} pad
156
+ * @param {{ width: number, height: number }} size
157
+ * @param {number} cornerRadius
158
+ * @param {'top' | 'bottom'} side
159
+ * @returns {'circle' | 'rect' | 'rounded-rect'}
160
+ */
161
+ static #resolvePadKind(pad, size, cornerRadius, side) {
162
+ if (PcbScene3dPadFactory.#isCircularPad(pad, size, side)) {
163
+ return 'circle'
164
+ }
165
+
166
+ if (cornerRadius > 0.001) {
167
+ return 'rounded-rect'
168
+ }
169
+
170
+ return 'rect'
171
+ }
172
+
173
+ /**
174
+ * Returns true when one pad should render as a circular copper disc.
175
+ * @param {{ shapeTop?: number, shapeMid?: number, shapeBottom?: number, hasRoundedRect?: boolean, roundedRectShapeTop?: number | null, roundedRectShapeBottom?: number | null }} pad
176
+ * @param {{ width: number, height: number }} size
177
+ * @param {'top' | 'bottom'} side
178
+ * @returns {boolean}
179
+ */
180
+ static #isCircularPad(pad, size, side) {
181
+ if (
182
+ PcbScene3dPadFactory.#resolvePadShape(pad, side) ===
183
+ PcbScene3dPadFactory.#PAD_SHAPE_RECTANGULAR
184
+ ) {
185
+ return false
186
+ }
187
+
188
+ return Math.abs(Number(size.width) - Number(size.height)) < 0.001
189
+ }
190
+
191
+ /**
192
+ * Resolves the effective pad shape code for one face, including any
193
+ * rounded-rect extension override when present.
194
+ * @param {{ shapeTop?: number, shapeMid?: number, shapeBottom?: number, hasRoundedRect?: boolean, roundedRectShapeTop?: number | null, roundedRectShapeBottom?: number | null }} pad
195
+ * @param {'top' | 'bottom'} side
196
+ * @returns {number}
197
+ */
198
+ static #resolvePadShape(pad, side) {
199
+ if (side === 'bottom') {
200
+ if (
201
+ pad?.hasRoundedRect &&
202
+ Number.isInteger(pad?.roundedRectShapeBottom)
203
+ ) {
204
+ return Number(pad.roundedRectShapeBottom)
205
+ }
206
+
207
+ return Number(
208
+ pad?.shapeBottom || pad?.shapeMid || pad?.shapeTop || 0
209
+ )
210
+ }
211
+
212
+ if (pad?.hasRoundedRect && Number.isInteger(pad?.roundedRectShapeTop)) {
213
+ return Number(pad.roundedRectShapeTop)
214
+ }
215
+
216
+ return Number(pad?.shapeTop || pad?.shapeMid || pad?.shapeBottom || 0)
217
+ }
218
+
219
+ /**
220
+ * Resolves the visible corner radius for one pad face.
221
+ * @param {{ shapeTop?: number, shapeMid?: number, shapeBottom?: number, hasRoundedRect?: boolean, roundedRectShapeTop?: number | null, roundedRectShapeBottom?: number | null, cornerRadiusTop?: number | null, cornerRadiusBottom?: number | null }} pad
222
+ * @param {{ width: number, height: number }} size
223
+ * @param {'top' | 'bottom'} side
224
+ * @returns {number}
225
+ */
226
+ static #resolvePadCornerRadius(pad, size, side) {
227
+ const roundedRectCornerRadius =
228
+ PcbScene3dPadFactory.#resolveRoundedRectCornerRadius(
229
+ pad,
230
+ size,
231
+ side
232
+ )
233
+ if (roundedRectCornerRadius > 0) {
234
+ return roundedRectCornerRadius
235
+ }
236
+
237
+ if (PcbScene3dPadFactory.#resolvePadShape(pad, side) === 1) {
238
+ return Math.min(size.width, size.height) / 2
239
+ }
240
+
241
+ return 0
242
+ }
243
+
244
+ /**
245
+ * Resolves one optional explicit rounded-rect corner radius.
246
+ * @param {{ hasRoundedRect?: boolean, cornerRadiusTop?: number | null, cornerRadiusBottom?: number | null }} pad
247
+ * @param {{ width: number, height: number }} size
248
+ * @param {'top' | 'bottom'} side
249
+ * @returns {number}
250
+ */
251
+ static #resolveRoundedRectCornerRadius(pad, size, side) {
252
+ const rawCornerRadius =
253
+ side === 'bottom'
254
+ ? Number(pad?.cornerRadiusBottom)
255
+ : Number(pad?.cornerRadiusTop)
256
+ if (
257
+ pad?.hasRoundedRect &&
258
+ Number.isFinite(rawCornerRadius) &&
259
+ rawCornerRadius > 0
260
+ ) {
261
+ return Math.min(size.width, size.height) * (rawCornerRadius / 100)
262
+ }
263
+
264
+ return 0
265
+ }
266
+
267
+ /**
268
+ * Resolves the local copper offset for one face.
269
+ * @param {{ offsetTopX?: number, offsetTopY?: number, offsetBottomX?: number, offsetBottomY?: number }} pad
270
+ * @param {'top' | 'bottom'} side
271
+ * @returns {{ x: number, y: number }}
272
+ */
273
+ static #resolvePadOffset(pad, side) {
274
+ if (side === 'bottom') {
275
+ return {
276
+ x: Number(pad?.offsetBottomX ?? pad?.offsetTopX ?? 0),
277
+ y: Number(pad?.offsetBottomY ?? pad?.offsetTopY ?? 0)
278
+ }
279
+ }
280
+
281
+ return {
282
+ x: Number(pad?.offsetTopX ?? pad?.offsetBottomX ?? 0),
283
+ y: Number(pad?.offsetTopY ?? pad?.offsetBottomY ?? 0)
284
+ }
285
+ }
286
+
287
+ /**
288
+ * Returns true when one pad has visible copper on the requested face.
289
+ * @param {{ sizeTopX?: number, sizeTopY?: number, sizeMidX?: number, sizeMidY?: number, sizeBottomX?: number, sizeBottomY?: number, holeDiameter?: number, hasTopSolderMaskOpening?: boolean, hasBottomSolderMaskOpening?: boolean }} pad
290
+ * @param {'top' | 'bottom'} side
291
+ * @returns {boolean}
292
+ */
293
+ static #hasVisibleSurface(pad, side) {
294
+ const maskOpening = PcbScene3dPadFactory.#resolveSolderMaskOpening(
295
+ pad,
296
+ side
297
+ )
298
+ if (maskOpening === false) {
299
+ return false
300
+ }
301
+
302
+ const preferredSideHasSize = PcbScene3dPadFactory.#hasSideSize(
303
+ pad,
304
+ side
305
+ )
306
+ if (preferredSideHasSize) {
307
+ return true
308
+ }
309
+
310
+ const alternateSideHasSize = PcbScene3dPadFactory.#hasSideSize(
311
+ pad,
312
+ side === 'bottom' ? 'top' : 'bottom'
313
+ )
314
+ const midHasSize =
315
+ Number(pad?.sizeMidX || 0) > 0 || Number(pad?.sizeMidY || 0) > 0
316
+
317
+ return (
318
+ (!alternateSideHasSize && midHasSize) ||
319
+ (!alternateSideHasSize &&
320
+ !midHasSize &&
321
+ Number(pad?.holeDiameter || 0) > 0)
322
+ )
323
+ }
324
+
325
+ /**
326
+ * Resolves an explicit side-specific solder-mask opening when available.
327
+ * @param {{ hasTopSolderMaskOpening?: boolean, hasBottomSolderMaskOpening?: boolean }} pad
328
+ * @param {'top' | 'bottom'} side
329
+ * @returns {boolean | null}
330
+ */
331
+ static #resolveSolderMaskOpening(pad, side) {
332
+ const fieldName =
333
+ side === 'bottom'
334
+ ? 'hasBottomSolderMaskOpening'
335
+ : 'hasTopSolderMaskOpening'
336
+
337
+ if (typeof pad?.[fieldName] === 'boolean') {
338
+ return pad[fieldName]
339
+ }
340
+
341
+ return null
342
+ }
343
+
344
+ /**
345
+ * Returns true when one face has an explicit copper size.
346
+ * @param {{ sizeTopX?: number, sizeTopY?: number, sizeBottomX?: number, sizeBottomY?: number }} pad
347
+ * @param {'top' | 'bottom'} side
348
+ * @returns {boolean}
349
+ */
350
+ static #hasSideSize(pad, side) {
351
+ if (side === 'bottom') {
352
+ return (
353
+ Number(pad?.sizeBottomX || 0) > 0 ||
354
+ Number(pad?.sizeBottomY || 0) > 0
355
+ )
356
+ }
357
+
358
+ return Number(pad?.sizeTopX || 0) > 0 || Number(pad?.sizeTopY || 0) > 0
359
+ }
360
+
361
+ /**
362
+ * Normalizes one face selector to a supported side.
363
+ * @param {string | undefined} side
364
+ * @returns {'top' | 'bottom'}
365
+ */
366
+ static #normalizeSide(side) {
367
+ return String(side || 'top').toLowerCase() === 'bottom'
368
+ ? 'bottom'
369
+ : 'top'
370
+ }
371
+
372
+ /**
373
+ * Normalizes one board point and optionally mirrors it for underside
374
+ * copper groups before the parent face flip rotates them below the board.
375
+ * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint
376
+ * @param {number} x
377
+ * @param {number} y
378
+ * @param {boolean} mirrorY
379
+ * @returns {{ x: number, y: number }}
380
+ */
381
+ static #normalizePoint(normalizeBoardPoint, x, y, mirrorY) {
382
+ const point = normalizeBoardPoint(x, y)
383
+
384
+ return {
385
+ x: point.x,
386
+ y: mirrorY ? -point.y : point.y
387
+ }
388
+ }
389
+
390
+ /**
391
+ * Resolves or creates one reusable geometry for the pad spec.
392
+ * @param {any} THREE
393
+ * @param {Map<string, any>} geometryCache
394
+ * @param {any} pad
395
+ * @param {{ width: number, height: number, kind: 'circle' | 'rect' | 'rounded-rect', radius: number, cornerRadius: number, hasHole: boolean, holeDiameter: number, holeSlotLength: number | null, holeRotation: number }} spec
396
+ * @returns {any}
397
+ */
398
+ static #resolveGeometry(THREE, geometryCache, spec, pad) {
399
+ const cacheKey = [
400
+ spec.kind,
401
+ spec.width.toFixed(4),
402
+ spec.height.toFixed(4),
403
+ spec.cornerRadius.toFixed(4),
404
+ spec.holeDiameter.toFixed(4),
405
+ Number(spec.holeSlotLength || 0).toFixed(4),
406
+ spec.holeRotation.toFixed(4)
407
+ ].join(':')
408
+ const cached = geometryCache.get(cacheKey)
409
+ if (cached) {
410
+ return cached
411
+ }
412
+
413
+ let geometry
414
+ if (spec.kind === 'circle' && !spec.hasHole) {
415
+ geometry = new THREE.CylinderGeometry(
416
+ spec.radius,
417
+ spec.radius,
418
+ PcbScene3dPadFactory.#PAD_THICKNESS_MIL,
419
+ 28
420
+ )
421
+ } else {
422
+ const shape = PcbScene3dPadFactory.#buildOuterShape(THREE, spec)
423
+ const drillHole = PcbScene3dDrillPathFactory.buildPadHolePath(
424
+ THREE,
425
+ pad
426
+ )
427
+ if (drillHole) {
428
+ shape.holes.push(drillHole)
429
+ }
430
+ geometry = new THREE.ExtrudeGeometry(shape, {
431
+ depth: PcbScene3dPadFactory.#PAD_THICKNESS_MIL,
432
+ bevelEnabled: false,
433
+ curveSegments: 16,
434
+ steps: 1
435
+ })
436
+ geometry.translate?.(
437
+ 0,
438
+ 0,
439
+ -PcbScene3dPadFactory.#PAD_THICKNESS_MIL / 2
440
+ )
441
+ }
442
+
443
+ geometryCache.set(cacheKey, geometry)
444
+ return geometry
445
+ }
446
+
447
+ /**
448
+ * Builds one pad outline shape for extrusion.
449
+ * @param {any} THREE
450
+ * @param {{ width: number, height: number, kind: 'circle' | 'rect' | 'rounded-rect', radius: number, cornerRadius: number }} spec
451
+ * @returns {any}
452
+ */
453
+ static #buildOuterShape(THREE, spec) {
454
+ if (spec.kind === 'circle') {
455
+ const shape = new THREE.Shape()
456
+ shape.moveTo(spec.radius, 0)
457
+ shape.absarc(0, 0, spec.radius, 0, Math.PI, false)
458
+ shape.absarc(0, 0, spec.radius, Math.PI, Math.PI * 2, false)
459
+ shape.closePath()
460
+ return shape
461
+ }
462
+
463
+ if (spec.kind === 'rounded-rect') {
464
+ return PcbScene3dPadFactory.#buildRoundedRectShape(THREE, spec)
465
+ }
466
+
467
+ return PcbScene3dPadFactory.#buildRectShape(THREE, spec)
468
+ }
469
+
470
+ /**
471
+ * Builds one rectangular pad profile.
472
+ * @param {any} THREE
473
+ * @param {{ width: number, height: number }} spec
474
+ * @returns {any}
475
+ */
476
+ static #buildRectShape(THREE, spec) {
477
+ const halfWidth = spec.width / 2
478
+ const halfHeight = spec.height / 2
479
+ const shape = new THREE.Shape()
480
+
481
+ shape.moveTo(-halfWidth, -halfHeight)
482
+ shape.lineTo(halfWidth, -halfHeight)
483
+ shape.lineTo(halfWidth, halfHeight)
484
+ shape.lineTo(-halfWidth, halfHeight)
485
+ shape.closePath()
486
+ return shape
487
+ }
488
+
489
+ /**
490
+ * Builds one rounded rectangular pad profile.
491
+ * @param {any} THREE
492
+ * @param {{ width: number, height: number, cornerRadius: number }} spec
493
+ * @returns {any}
494
+ */
495
+ static #buildRoundedRectShape(THREE, spec) {
496
+ const halfWidth = spec.width / 2
497
+ const halfHeight = spec.height / 2
498
+ const radius = Math.max(
499
+ 0,
500
+ Math.min(spec.cornerRadius, halfWidth, halfHeight)
501
+ )
502
+ const shape = new THREE.Shape()
503
+
504
+ if (radius <= 0.001) {
505
+ shape.moveTo(-halfWidth, -halfHeight)
506
+ shape.lineTo(halfWidth, -halfHeight)
507
+ shape.lineTo(halfWidth, halfHeight)
508
+ shape.lineTo(-halfWidth, halfHeight)
509
+ shape.closePath()
510
+ return shape
511
+ }
512
+
513
+ shape.moveTo(-halfWidth + radius, -halfHeight)
514
+ shape.lineTo(halfWidth - radius, -halfHeight)
515
+ shape.absarc(
516
+ halfWidth - radius,
517
+ -halfHeight + radius,
518
+ radius,
519
+ -Math.PI / 2,
520
+ 0,
521
+ false
522
+ )
523
+ shape.lineTo(halfWidth, halfHeight - radius)
524
+ shape.absarc(
525
+ halfWidth - radius,
526
+ halfHeight - radius,
527
+ radius,
528
+ 0,
529
+ Math.PI / 2,
530
+ false
531
+ )
532
+ shape.lineTo(-halfWidth + radius, halfHeight)
533
+ shape.absarc(
534
+ -halfWidth + radius,
535
+ halfHeight - radius,
536
+ radius,
537
+ Math.PI / 2,
538
+ Math.PI,
539
+ false
540
+ )
541
+ shape.lineTo(-halfWidth, -halfHeight + radius)
542
+ shape.absarc(
543
+ -halfWidth + radius,
544
+ -halfHeight + radius,
545
+ radius,
546
+ Math.PI,
547
+ (Math.PI * 3) / 2,
548
+ false
549
+ )
550
+ shape.closePath()
551
+ return shape
552
+ }
553
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Tracks the last requested 3D camera preset while the scene runtime starts.
3
+ */
4
+ export class PcbScene3dPresetState {
5
+ /** @type {string} */
6
+ #preset
7
+
8
+ constructor() {
9
+ this.#preset = 'isometric'
10
+ }
11
+
12
+ /**
13
+ * Stores one normalized preset name.
14
+ * @param {string} preset
15
+ * @returns {string}
16
+ */
17
+ set(preset) {
18
+ this.#preset = PcbScene3dPresetState.#normalize(preset)
19
+ return this.#preset
20
+ }
21
+
22
+ /**
23
+ * Returns the last normalized preset name.
24
+ * @returns {string}
25
+ */
26
+ get() {
27
+ return this.#preset
28
+ }
29
+
30
+ /**
31
+ * Normalizes one preset name to a supported camera preset.
32
+ * @param {string} preset
33
+ * @returns {string}
34
+ */
35
+ static #normalize(preset) {
36
+ const normalized = String(preset || 'isometric').toLowerCase()
37
+ if (
38
+ normalized === 'top' ||
39
+ normalized === 'bottom' ||
40
+ normalized === 'isometric' ||
41
+ normalized === 'reset'
42
+ ) {
43
+ return normalized
44
+ }
45
+
46
+ return 'isometric'
47
+ }
48
+ }