pcb-scene3d-viewer 1.1.2 → 1.1.11

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 (29) hide show
  1. package/package.json +1 -1
  2. package/src/PcbScene3dArcUtils.mjs +18 -0
  3. package/src/PcbScene3dBoardAssemblyPresentation.mjs +3 -0
  4. package/src/PcbScene3dBoardSolderMaskFactory.mjs +2 -2
  5. package/src/PcbScene3dCameraRig.mjs +16 -1
  6. package/src/PcbScene3dCopperDetailFilter.mjs +73 -1
  7. package/src/PcbScene3dCopperDetailGroupBuilder.mjs +482 -0
  8. package/src/PcbScene3dCopperFactory.mjs +499 -85
  9. package/src/PcbScene3dCopperLayerFilter.mjs +48 -0
  10. package/src/PcbScene3dCopperOcclusionClipper.mjs +86 -0
  11. package/src/PcbScene3dCopperTextFactory.mjs +26 -6
  12. package/src/PcbScene3dCutoutGeometryFilter.mjs +25 -28
  13. package/src/PcbScene3dDrillCutoutFilter.mjs +123 -0
  14. package/src/PcbScene3dGeometryFaceRefiner.mjs +218 -0
  15. package/src/PcbScene3dGeometryZCompressor.mjs +48 -0
  16. package/src/PcbScene3dMaskCoveredCopperMaterial.mjs +72 -0
  17. package/src/PcbScene3dMaterialFinish.mjs +49 -0
  18. package/src/PcbScene3dPadFactory.mjs +160 -3
  19. package/src/PcbScene3dPadSurfaceStack.mjs +155 -0
  20. package/src/PcbScene3dPolygonOverlap.mjs +283 -0
  21. package/src/PcbScene3dRuntime.mjs +11 -64
  22. package/src/PcbScene3dRuntimeBoardMeshes.mjs +8 -3
  23. package/src/PcbScene3dShapeHoleGeometryCleaner.mjs +212 -0
  24. package/src/PcbScene3dShapeHoleMerger.mjs +476 -0
  25. package/src/PcbScene3dSilkscreenFactory.mjs +193 -126
  26. package/src/PcbScene3dSilkscreenFillSeamBuilder.mjs +241 -0
  27. package/src/PcbScene3dStrokeGeometryBuilder.mjs +7 -6
  28. package/src/PcbScene3dTerminalCutoutClassifier.mjs +92 -0
  29. package/src/PcbScene3dTrueTypeTextFactory.mjs +16 -4
@@ -1,15 +1,28 @@
1
1
  import { PcbScene3dArcUtils } from './PcbScene3dArcUtils.mjs'
2
2
  import { PcbScene3dPadFactory } from './PcbScene3dPadFactory.mjs'
3
3
  import { PcbScene3dCopperTextFactory } from './PcbScene3dCopperTextFactory.mjs'
4
+ import { PcbScene3dMaskCoveredCopperMaterial } from './PcbScene3dMaskCoveredCopperMaterial.mjs'
5
+ import { PcbScene3dGeometryZCompressor } from './PcbScene3dGeometryZCompressor.mjs'
6
+ import { PcbScene3dCopperOcclusionClipper } from './PcbScene3dCopperOcclusionClipper.mjs'
7
+ import { PcbScene3dCopperLayerFilter } from './PcbScene3dCopperLayerFilter.mjs'
4
8
 
5
9
  /**
6
10
  * Builds copper-detail meshes for the interactive 3D PCB scene.
7
11
  */
8
12
  export class PcbScene3dCopperFactory {
9
- static #TOP_COPPER_LAYER_ID = 1
10
- static #BOTTOM_COPPER_LAYER_ID = 32
13
+ static #ARC_SEGMENT_DEGREES = 3
11
14
  static #FULL_CIRCLE_EPSILON = 0.001
12
15
  static #ROUND_CAP_SEGMENTS = 16
16
+ static #COPPER_THICKNESS_MIL = 2.2
17
+ static #COPPER_COLOR = 0xd9a61d
18
+
19
+ /**
20
+ * Returns half the visual copper extrusion thickness.
21
+ * @returns {number}
22
+ */
23
+ static visualHalfThicknessMil() {
24
+ return PcbScene3dCopperFactory.#COPPER_THICKNESS_MIL / 2
25
+ }
13
26
 
14
27
  /**
15
28
  * Builds the combined top and bottom copper group.
@@ -33,11 +46,11 @@ export class PcbScene3dCopperFactory {
33
46
  const topGroup = PcbScene3dCopperFactory.#buildSideGroup(
34
47
  THREE,
35
48
  {
36
- tracks: PcbScene3dCopperFactory.#filterTracks(
49
+ tracks: PcbScene3dCopperLayerFilter.tracks(
37
50
  detail?.tracks,
38
51
  'top'
39
52
  ),
40
- arcs: PcbScene3dCopperFactory.#filterArcs(detail?.arcs, 'top'),
53
+ arcs: PcbScene3dCopperLayerFilter.arcs(detail?.arcs, 'top'),
41
54
  pads: detail?.pads || [],
42
55
  vias: detail?.vias || [],
43
56
  copperTexts: detail?.copperTexts || []
@@ -50,14 +63,11 @@ export class PcbScene3dCopperFactory {
50
63
  const bottomGroup = PcbScene3dCopperFactory.#buildSideGroup(
51
64
  THREE,
52
65
  {
53
- tracks: PcbScene3dCopperFactory.#filterTracks(
66
+ tracks: PcbScene3dCopperLayerFilter.tracks(
54
67
  detail?.tracks,
55
68
  'bottom'
56
69
  ),
57
- arcs: PcbScene3dCopperFactory.#filterArcs(
58
- detail?.arcs,
59
- 'bottom'
60
- ),
70
+ arcs: PcbScene3dCopperLayerFilter.arcs(detail?.arcs, 'bottom'),
61
71
  pads: detail?.pads || [],
62
72
  vias: detail?.vias || [],
63
73
  copperTexts: detail?.copperTexts || []
@@ -78,6 +88,78 @@ export class PcbScene3dCopperFactory {
78
88
  return group
79
89
  }
80
90
 
91
+ /**
92
+ * Builds top and bottom traces that are covered by solder mask.
93
+ * @param {any} THREE
94
+ * @param {{ tracks?: any[], arcs?: any[] }} detail Mask-covered detail.
95
+ * @param {number} topZ
96
+ * @param {number} bottomZ
97
+ * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint
98
+ * @param {{ solderMaskColor?: number, occlusionCutouts?: { top?: { x: number, y: number }[][], bottom?: { x: number, y: number }[][] } }} [options]
99
+ * @returns {any}
100
+ */
101
+ static buildMaskCoveredGroup(
102
+ THREE,
103
+ detail,
104
+ topZ,
105
+ bottomZ,
106
+ normalizeBoardPoint,
107
+ options = {}
108
+ ) {
109
+ const group = new THREE.Group()
110
+ const material = PcbScene3dMaskCoveredCopperMaterial.build(
111
+ THREE,
112
+ options
113
+ )
114
+ const topGroup = PcbScene3dCopperFactory.#buildMaskCoveredSideGroup(
115
+ THREE,
116
+ {
117
+ tracks: PcbScene3dCopperLayerFilter.tracks(
118
+ detail?.tracks,
119
+ 'top'
120
+ ),
121
+ arcs: PcbScene3dCopperLayerFilter.arcs(detail?.arcs, 'top')
122
+ },
123
+ Math.abs(Number(topZ || 0)),
124
+ normalizeBoardPoint,
125
+ false,
126
+ material,
127
+ PcbScene3dCopperOcclusionClipper.normalizeCutouts(
128
+ options?.occlusionCutouts?.top,
129
+ normalizeBoardPoint,
130
+ false
131
+ )
132
+ )
133
+ const bottomGroup = PcbScene3dCopperFactory.#buildMaskCoveredSideGroup(
134
+ THREE,
135
+ {
136
+ tracks: PcbScene3dCopperLayerFilter.tracks(
137
+ detail?.tracks,
138
+ 'bottom'
139
+ ),
140
+ arcs: PcbScene3dCopperLayerFilter.arcs(detail?.arcs, 'bottom')
141
+ },
142
+ Math.abs(Number(bottomZ || 0)),
143
+ normalizeBoardPoint,
144
+ true,
145
+ material,
146
+ PcbScene3dCopperOcclusionClipper.normalizeCutouts(
147
+ options?.occlusionCutouts?.bottom,
148
+ normalizeBoardPoint,
149
+ true
150
+ )
151
+ )
152
+
153
+ if (topGroup.children.length) {
154
+ group.add(topGroup)
155
+ }
156
+ if (bottomGroup.children.length) {
157
+ group.add(bottomGroup)
158
+ }
159
+
160
+ return group
161
+ }
162
+
81
163
  /**
82
164
  * Builds one side-specific copper group.
83
165
  * @param {any} THREE
@@ -152,6 +234,69 @@ export class PcbScene3dCopperFactory {
152
234
  return group
153
235
  }
154
236
 
237
+ /**
238
+ * Builds one side of the mask-covered trace relief.
239
+ * @param {any} THREE
240
+ * @param {{ tracks?: any[], arcs?: any[] }} detail Mask-covered detail.
241
+ * @param {number} z
242
+ * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint
243
+ * @param {boolean} mirrorY
244
+ * @param {any} material Shared covered-trace material.
245
+ * @param {{ x: number, y: number }[][]} occlusionCutouts Silkscreen ink polygons covering this copper.
246
+ * @returns {any}
247
+ */
248
+ static #buildMaskCoveredSideGroup(
249
+ THREE,
250
+ detail,
251
+ z,
252
+ normalizeBoardPoint,
253
+ mirrorY,
254
+ material,
255
+ occlusionCutouts
256
+ ) {
257
+ const group = new THREE.Group()
258
+ const trackMesh = PcbScene3dCopperFactory.#buildTrackMesh(
259
+ THREE,
260
+ detail?.tracks || [],
261
+ z,
262
+ normalizeBoardPoint,
263
+ mirrorY,
264
+ material,
265
+ occlusionCutouts
266
+ )
267
+ const arcMesh = PcbScene3dCopperFactory.#buildArcMesh(
268
+ THREE,
269
+ detail?.arcs || [],
270
+ z,
271
+ normalizeBoardPoint,
272
+ mirrorY,
273
+ material,
274
+ occlusionCutouts
275
+ )
276
+
277
+ if (trackMesh) {
278
+ PcbScene3dGeometryZCompressor.compressMaskCoveredCopperMesh(
279
+ trackMesh,
280
+ z
281
+ )
282
+ trackMesh.name = 'mask-covered-copper-tracks'
283
+ group.add(trackMesh)
284
+ }
285
+ if (arcMesh) {
286
+ PcbScene3dGeometryZCompressor.compressMaskCoveredCopperMesh(
287
+ arcMesh,
288
+ z
289
+ )
290
+ arcMesh.name = 'mask-covered-copper-arcs'
291
+ group.add(arcMesh)
292
+ }
293
+ if (mirrorY && group.children.length) {
294
+ group.rotation.x = Math.PI
295
+ }
296
+
297
+ return group
298
+ }
299
+
155
300
  /**
156
301
  * Checks whether copper text glyph strokes are already in y-up scene space.
157
302
  * @param {{ coordinateSystem?: string } | undefined} options
@@ -164,13 +309,23 @@ export class PcbScene3dCopperFactory {
164
309
  /**
165
310
  * Builds one widened copper-track mesh for one face.
166
311
  * @param {any} THREE
167
- * @param {{ x1?: number, y1?: number, x2?: number, y2?: number, width?: number }[]} tracks
312
+ * @param {{ x1?: number, y1?: number, x2?: number, y2?: number, width?: number, capStartRound?: boolean, capEndRound?: boolean, capStartSideWall?: boolean, capEndSideWall?: boolean }[]} tracks
168
313
  * @param {number} z
169
314
  * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint
170
315
  * @param {boolean} mirrorY
316
+ * @param {any | null} [material] Optional material override.
317
+ * @param {{ x: number, y: number }[][]} [cutouts] Optional geometry cutouts.
171
318
  * @returns {any | null}
172
319
  */
173
- static #buildTrackMesh(THREE, tracks, z, normalizeBoardPoint, mirrorY) {
320
+ static #buildTrackMesh(
321
+ THREE,
322
+ tracks,
323
+ z,
324
+ normalizeBoardPoint,
325
+ mirrorY,
326
+ material = null,
327
+ cutouts = []
328
+ ) {
174
329
  const positions = []
175
330
 
176
331
  for (const track of tracks) {
@@ -191,11 +346,17 @@ export class PcbScene3dCopperFactory {
191
346
  start,
192
347
  end,
193
348
  Number(track?.width || 0),
194
- z
349
+ z,
350
+ track
195
351
  )
196
352
  }
197
353
 
198
- return PcbScene3dCopperFactory.#buildStrokeMesh(THREE, positions)
354
+ return PcbScene3dCopperFactory.#buildStrokeMesh(
355
+ THREE,
356
+ positions,
357
+ material,
358
+ cutouts
359
+ )
199
360
  }
200
361
 
201
362
  /**
@@ -205,9 +366,19 @@ export class PcbScene3dCopperFactory {
205
366
  * @param {number} z
206
367
  * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint
207
368
  * @param {boolean} mirrorY
369
+ * @param {any | null} [material] Optional material override.
370
+ * @param {{ x: number, y: number }[][]} [cutouts] Optional geometry cutouts.
208
371
  * @returns {any | null}
209
372
  */
210
- static #buildArcMesh(THREE, arcs, z, normalizeBoardPoint, mirrorY) {
373
+ static #buildArcMesh(
374
+ THREE,
375
+ arcs,
376
+ z,
377
+ normalizeBoardPoint,
378
+ mirrorY,
379
+ material = null,
380
+ cutouts = []
381
+ ) {
211
382
  const positions = []
212
383
 
213
384
  for (const arc of arcs) {
@@ -226,16 +397,23 @@ export class PcbScene3dCopperFactory {
226
397
  )
227
398
  }
228
399
 
229
- return PcbScene3dCopperFactory.#buildStrokeMesh(THREE, positions)
400
+ return PcbScene3dCopperFactory.#buildStrokeMesh(
401
+ THREE,
402
+ positions,
403
+ material,
404
+ cutouts
405
+ )
230
406
  }
231
407
 
232
408
  /**
233
409
  * Builds one copper stroke mesh from triangle positions.
234
410
  * @param {any} THREE
235
411
  * @param {number[]} positions
412
+ * @param {any | null} [material] Optional material override.
413
+ * @param {{ x: number, y: number }[][]} [cutouts] Optional geometry cutouts.
236
414
  * @returns {any | null}
237
415
  */
238
- static #buildStrokeMesh(THREE, positions) {
416
+ static #buildStrokeMesh(THREE, positions, material = null, cutouts = []) {
239
417
  if (!positions.length) {
240
418
  return null
241
419
  }
@@ -245,11 +423,19 @@ export class PcbScene3dCopperFactory {
245
423
  'position',
246
424
  new THREE.Float32BufferAttribute(positions, 3)
247
425
  )
248
- geometry.computeVertexNormals?.()
426
+ const clippedGeometry = PcbScene3dCopperOcclusionClipper.filter(
427
+ THREE,
428
+ geometry,
429
+ cutouts
430
+ )
431
+
432
+ if (!clippedGeometry) {
433
+ return null
434
+ }
249
435
 
250
436
  return new THREE.Mesh(
251
- geometry,
252
- PcbScene3dCopperFactory.#buildMaterial(THREE)
437
+ clippedGeometry,
438
+ material || PcbScene3dCopperFactory.#buildMaterial(THREE)
253
439
  )
254
440
  }
255
441
 
@@ -260,7 +446,7 @@ export class PcbScene3dCopperFactory {
260
446
  */
261
447
  static #buildMaterial(THREE) {
262
448
  return new THREE.MeshStandardMaterial({
263
- color: 0xd9a61d,
449
+ color: PcbScene3dCopperFactory.#COPPER_COLOR,
264
450
  roughness: 0.38,
265
451
  metalness: 0.55,
266
452
  side: THREE.DoubleSide,
@@ -270,47 +456,6 @@ export class PcbScene3dCopperFactory {
270
456
  })
271
457
  }
272
458
 
273
- /**
274
- * Filters one track list to one outer copper face.
275
- * @param {any[] | undefined} tracks
276
- * @param {'top' | 'bottom'} side
277
- * @returns {any[]}
278
- */
279
- static #filterTracks(tracks, side) {
280
- return (tracks || []).filter((track) =>
281
- PcbScene3dCopperFactory.#matchesCopperLayer(track, side)
282
- )
283
- }
284
-
285
- /**
286
- * Filters one arc list to one outer copper face.
287
- * @param {any[] | undefined} arcs
288
- * @param {'top' | 'bottom'} side
289
- * @returns {any[]}
290
- */
291
- static #filterArcs(arcs, side) {
292
- return (arcs || []).filter((arc) =>
293
- PcbScene3dCopperFactory.#matchesCopperLayer(arc, side)
294
- )
295
- }
296
-
297
- /**
298
- * Returns true when one primitive belongs to the requested outer copper
299
- * face.
300
- * @param {{ layerId?: number, layerCode?: number }} primitive
301
- * @param {'top' | 'bottom'} side
302
- * @returns {boolean}
303
- */
304
- static #matchesCopperLayer(primitive, side) {
305
- const layerId = Number(
306
- primitive?.layerId ?? primitive?.layerCode ?? NaN
307
- )
308
-
309
- return side === 'bottom'
310
- ? layerId === PcbScene3dCopperFactory.#BOTTOM_COPPER_LAYER_ID
311
- : layerId === PcbScene3dCopperFactory.#TOP_COPPER_LAYER_ID
312
- }
313
-
314
459
  /**
315
460
  * Appends one widened track quad as two triangles.
316
461
  * @param {number[]} positions
@@ -318,9 +463,17 @@ export class PcbScene3dCopperFactory {
318
463
  * @param {{ x: number, y: number }} end
319
464
  * @param {number} width
320
465
  * @param {number} z
466
+ * @param {{ capStartRound?: boolean, capEndRound?: boolean, capStartSideWall?: boolean, capEndSideWall?: boolean }} [options]
321
467
  * @returns {void}
322
468
  */
323
- static #appendTrackTriangles(positions, start, end, width, z) {
469
+ static #appendTrackTriangles(
470
+ positions,
471
+ start,
472
+ end,
473
+ width,
474
+ z,
475
+ options = {}
476
+ ) {
324
477
  const dx = end.x - start.x
325
478
  const dy = end.y - start.y
326
479
  const length = Math.hypot(dx, dy)
@@ -352,25 +505,47 @@ export class PcbScene3dCopperFactory {
352
505
  { x: start.x - normalX, y: start.y - normalY },
353
506
  z
354
507
  )
355
- PcbScene3dCopperFactory.#appendDiscTriangles(
508
+ PcbScene3dCopperFactory.#appendBoundarySideTriangles(
356
509
  positions,
357
- start,
358
- halfWidth,
510
+ { x: start.x + normalX, y: start.y + normalY },
511
+ { x: end.x + normalX, y: end.y + normalY },
359
512
  z
360
513
  )
361
- PcbScene3dCopperFactory.#appendDiscTriangles(
514
+ PcbScene3dCopperFactory.#appendBoundarySideTriangles(
362
515
  positions,
363
- end,
364
- halfWidth,
516
+ { x: end.x - normalX, y: end.y - normalY },
517
+ { x: start.x - normalX, y: start.y - normalY },
365
518
  z
366
519
  )
520
+ if (options?.capStartRound !== false) {
521
+ PcbScene3dCopperFactory.#appendRoundCapTriangles(
522
+ positions,
523
+ start,
524
+ halfWidth,
525
+ z,
526
+ -dx / length,
527
+ -dy / length,
528
+ options?.capStartSideWall !== false
529
+ )
530
+ }
531
+ if (options?.capEndRound !== false) {
532
+ PcbScene3dCopperFactory.#appendRoundCapTriangles(
533
+ positions,
534
+ end,
535
+ halfWidth,
536
+ z,
537
+ dx / length,
538
+ dy / length,
539
+ options?.capEndSideWall !== false
540
+ )
541
+ }
367
542
  }
368
543
 
369
544
  /**
370
545
  * Appends one widened arc band as triangles.
371
546
  * @param {number[]} positions
372
547
  * @param {{ x: number, y: number }} center
373
- * @param {{ radius?: number, width?: number, startAngle?: number, endAngle?: number }} arc
548
+ * @param {{ radius?: number, width?: number, startAngle?: number, endAngle?: number, sweepAngle?: number }} arc
374
549
  * @param {number} z
375
550
  * @param {boolean} mirrorY
376
551
  * @returns {void}
@@ -381,10 +556,7 @@ export class PcbScene3dCopperFactory {
381
556
  const outerRadius = radius + strokeWidth / 2
382
557
  const innerRadius = Math.max(radius - strokeWidth / 2, 0)
383
558
  const startAngleRad = (Number(arc?.startAngle || 0) * Math.PI) / 180
384
- const deltaAngleDeg = PcbScene3dArcUtils.resolveSweepDelta(
385
- Number(arc?.startAngle || 0),
386
- Number(arc?.endAngle || 0)
387
- )
559
+ const deltaAngleDeg = PcbScene3dArcUtils.resolveArcSweepDelta(arc)
388
560
  const isFullCircle =
389
561
  Math.abs(deltaAngleDeg) <=
390
562
  PcbScene3dCopperFactory.#FULL_CIRCLE_EPSILON ||
@@ -395,7 +567,10 @@ export class PcbScene3dCopperFactory {
395
567
  : (deltaAngleDeg * Math.PI) / 180
396
568
  const segments = Math.max(
397
569
  isFullCircle ? 20 : 8,
398
- Math.ceil((Math.abs(deltaAngleRad) / Math.PI) * 18)
570
+ Math.ceil(
571
+ Math.abs(deltaAngleDeg) /
572
+ PcbScene3dCopperFactory.#ARC_SEGMENT_DEGREES
573
+ )
399
574
  )
400
575
  const yDirection = mirrorY ? -1 : 1
401
576
 
@@ -421,6 +596,12 @@ export class PcbScene3dCopperFactory {
421
596
  outerEnd,
422
597
  z
423
598
  )
599
+ PcbScene3dCopperFactory.#appendBoundarySideTriangles(
600
+ positions,
601
+ outerStart,
602
+ outerEnd,
603
+ z
604
+ )
424
605
  continue
425
606
  }
426
607
 
@@ -441,19 +622,45 @@ export class PcbScene3dCopperFactory {
441
622
  innerStart,
442
623
  z
443
624
  )
625
+ PcbScene3dCopperFactory.#appendBoundarySideTriangles(
626
+ positions,
627
+ outerStart,
628
+ outerEnd,
629
+ z
630
+ )
631
+ PcbScene3dCopperFactory.#appendBoundarySideTriangles(
632
+ positions,
633
+ innerEnd,
634
+ innerStart,
635
+ z
636
+ )
444
637
  }
445
638
 
446
639
  if (!isFullCircle) {
447
- PcbScene3dCopperFactory.#appendDiscTriangles(
640
+ const sweepDirection = deltaAngleRad < 0 ? -1 : 1
641
+ const startTangent = PcbScene3dCopperFactory.#resolveArcTangent(
642
+ startAngleRad,
643
+ yDirection,
644
+ sweepDirection
645
+ )
646
+ const endTangent = PcbScene3dCopperFactory.#resolveArcTangent(
647
+ startAngleRad + deltaAngleRad,
648
+ yDirection,
649
+ sweepDirection
650
+ )
651
+
652
+ PcbScene3dCopperFactory.#appendRoundCapTriangles(
448
653
  positions,
449
654
  {
450
655
  x: center.x + Math.cos(startAngleRad) * radius,
451
656
  y: center.y + Math.sin(startAngleRad) * radius * yDirection
452
657
  },
453
658
  strokeWidth / 2,
454
- z
659
+ z,
660
+ -startTangent.x,
661
+ -startTangent.y
455
662
  )
456
- PcbScene3dCopperFactory.#appendDiscTriangles(
663
+ PcbScene3dCopperFactory.#appendRoundCapTriangles(
457
664
  positions,
458
665
  {
459
666
  x:
@@ -466,7 +673,9 @@ export class PcbScene3dCopperFactory {
466
673
  yDirection
467
674
  },
468
675
  strokeWidth / 2,
469
- z
676
+ z,
677
+ endTangent.x,
678
+ endTangent.y
470
679
  )
471
680
  }
472
681
  }
@@ -525,6 +734,153 @@ export class PcbScene3dCopperFactory {
525
734
  },
526
735
  z
527
736
  )
737
+ PcbScene3dCopperFactory.#appendBoundarySideTriangles(
738
+ positions,
739
+ {
740
+ x: center.x + Math.cos(startAngle) * safeRadius,
741
+ y: center.y + Math.sin(startAngle) * safeRadius
742
+ },
743
+ {
744
+ x: center.x + Math.cos(endAngle) * safeRadius,
745
+ y: center.y + Math.sin(endAngle) * safeRadius
746
+ },
747
+ z
748
+ )
749
+ }
750
+ }
751
+
752
+ /**
753
+ * Appends only the exposed half of one rounded stroke endpoint.
754
+ * @param {number[]} positions Position buffer.
755
+ * @param {{ x: number, y: number }} center Cap center.
756
+ * @param {number} radius Cap radius.
757
+ * @param {number} z Center Z position.
758
+ * @param {number} outwardX Unit X direction pointing out of the stroke.
759
+ * @param {number} outwardY Unit Y direction pointing out of the stroke.
760
+ * @param {boolean} [includeSideWall] Whether to emit the cap perimeter wall.
761
+ * @returns {void}
762
+ */
763
+ static #appendRoundCapTriangles(
764
+ positions,
765
+ center,
766
+ radius,
767
+ z,
768
+ outwardX,
769
+ outwardY,
770
+ includeSideWall = true
771
+ ) {
772
+ const safeRadius = Math.max(Number(radius || 0), 0)
773
+ const outwardLength = Math.hypot(outwardX, outwardY)
774
+ if (safeRadius <= 0) {
775
+ return
776
+ }
777
+ if (outwardLength <= 0.001) {
778
+ PcbScene3dCopperFactory.#appendDiscTriangles(
779
+ positions,
780
+ center,
781
+ safeRadius,
782
+ z
783
+ )
784
+ return
785
+ }
786
+
787
+ const unitX = outwardX / outwardLength
788
+ const unitY = outwardY / outwardLength
789
+ const normalX = -unitY
790
+ const normalY = unitX
791
+
792
+ for (
793
+ let index = 0;
794
+ index < PcbScene3dCopperFactory.#ROUND_CAP_SEGMENTS;
795
+ index += 1
796
+ ) {
797
+ const startAngle =
798
+ -Math.PI / 2 +
799
+ (Math.PI * index) / PcbScene3dCopperFactory.#ROUND_CAP_SEGMENTS
800
+ const endAngle =
801
+ -Math.PI / 2 +
802
+ (Math.PI * (index + 1)) /
803
+ PcbScene3dCopperFactory.#ROUND_CAP_SEGMENTS
804
+ const start = PcbScene3dCopperFactory.#resolveCapPoint(
805
+ center,
806
+ safeRadius,
807
+ unitX,
808
+ unitY,
809
+ normalX,
810
+ normalY,
811
+ startAngle
812
+ )
813
+ const end = PcbScene3dCopperFactory.#resolveCapPoint(
814
+ center,
815
+ safeRadius,
816
+ unitX,
817
+ unitY,
818
+ normalX,
819
+ normalY,
820
+ endAngle
821
+ )
822
+
823
+ PcbScene3dCopperFactory.#appendTriangle(
824
+ positions,
825
+ center,
826
+ start,
827
+ end,
828
+ z
829
+ )
830
+ if (includeSideWall) {
831
+ PcbScene3dCopperFactory.#appendBoundarySideTriangles(
832
+ positions,
833
+ start,
834
+ end,
835
+ z
836
+ )
837
+ }
838
+ }
839
+ }
840
+
841
+ /**
842
+ * Resolves one point on an oriented round cap boundary.
843
+ * @param {{ x: number, y: number }} center Cap center.
844
+ * @param {number} radius Cap radius.
845
+ * @param {number} unitX Outward X direction.
846
+ * @param {number} unitY Outward Y direction.
847
+ * @param {number} normalX Cap normal X direction.
848
+ * @param {number} normalY Cap normal Y direction.
849
+ * @param {number} angle Local cap angle.
850
+ * @returns {{ x: number, y: number }}
851
+ */
852
+ static #resolveCapPoint(
853
+ center,
854
+ radius,
855
+ unitX,
856
+ unitY,
857
+ normalX,
858
+ normalY,
859
+ angle
860
+ ) {
861
+ return {
862
+ x:
863
+ center.x +
864
+ unitX * Math.cos(angle) * radius +
865
+ normalX * Math.sin(angle) * radius,
866
+ y:
867
+ center.y +
868
+ unitY * Math.cos(angle) * radius +
869
+ normalY * Math.sin(angle) * radius
870
+ }
871
+ }
872
+
873
+ /**
874
+ * Resolves one normalized centerline tangent for an arc endpoint.
875
+ * @param {number} angle Endpoint angle in radians.
876
+ * @param {number} yDirection Mirrored Y direction.
877
+ * @param {number} sweepDirection Arc sweep direction.
878
+ * @returns {{ x: number, y: number }}
879
+ */
880
+ static #resolveArcTangent(angle, yDirection, sweepDirection) {
881
+ return {
882
+ x: -Math.sin(angle) * sweepDirection,
883
+ y: Math.cos(angle) * yDirection * sweepDirection
528
884
  }
529
885
  }
530
886
 
@@ -548,15 +904,73 @@ export class PcbScene3dCopperFactory {
548
904
  }
549
905
 
550
906
  /**
551
- * Appends one triangle into the position buffer.
552
- * @param {number[]} positions
553
- * @param {{ x: number, y: number }} a
554
- * @param {{ x: number, y: number }} b
555
- * @param {{ x: number, y: number }} c
556
- * @param {number} z
907
+ * Appends one shallow triangular prism into the position buffer.
908
+ * @param {number[]} positions Position buffer.
909
+ * @param {{ x: number, y: number }} a First point.
910
+ * @param {{ x: number, y: number }} b Second point.
911
+ * @param {{ x: number, y: number }} c Third point.
912
+ * @param {number} z Center Z position.
557
913
  * @returns {void}
558
914
  */
559
915
  static #appendTriangle(positions, a, b, c, z) {
560
- positions.push(a.x, a.y, z, b.x, b.y, z, c.x, c.y, z)
916
+ const halfThickness = PcbScene3dCopperFactory.#COPPER_THICKNESS_MIL / 2
917
+ const topZ = z + halfThickness
918
+ const bottomZ = z - halfThickness
919
+
920
+ positions.push(
921
+ a.x,
922
+ a.y,
923
+ topZ,
924
+ b.x,
925
+ b.y,
926
+ topZ,
927
+ c.x,
928
+ c.y,
929
+ topZ,
930
+ c.x,
931
+ c.y,
932
+ bottomZ,
933
+ b.x,
934
+ b.y,
935
+ bottomZ,
936
+ a.x,
937
+ a.y,
938
+ bottomZ
939
+ )
940
+ }
941
+
942
+ /**
943
+ * Appends a side wall for one actual copper boundary edge.
944
+ * @param {number[]} positions Position buffer.
945
+ * @param {{ x: number, y: number }} start Wall start point.
946
+ * @param {{ x: number, y: number }} end Wall end point.
947
+ * @param {number} z Center Z position.
948
+ * @returns {void}
949
+ */
950
+ static #appendBoundarySideTriangles(positions, start, end, z) {
951
+ const halfThickness = PcbScene3dCopperFactory.#COPPER_THICKNESS_MIL / 2
952
+ const topZ = z + halfThickness
953
+ const bottomZ = z - halfThickness
954
+
955
+ positions.push(
956
+ start.x,
957
+ start.y,
958
+ topZ,
959
+ end.x,
960
+ end.y,
961
+ topZ,
962
+ end.x,
963
+ end.y,
964
+ bottomZ,
965
+ start.x,
966
+ start.y,
967
+ topZ,
968
+ end.x,
969
+ end.y,
970
+ bottomZ,
971
+ start.x,
972
+ start.y,
973
+ bottomZ
974
+ )
561
975
  }
562
976
  }