pcb-scene3d-viewer 1.1.10 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pcb-scene3d-viewer",
3
- "version": "1.1.10",
3
+ "version": "1.1.11",
4
4
  "description": "Reusable Three.js PCB 3D scene viewer for normalized ECAD and CircuitJSON scene descriptions",
5
5
  "keywords": [
6
6
  "pcb",
@@ -1,12 +1,16 @@
1
1
  import { PcbScene3dBoardMaterialPalette } from './PcbScene3dBoardMaterialPalette.mjs'
2
2
  import { PcbScene3dCopperDetailFilter } from './PcbScene3dCopperDetailFilter.mjs'
3
3
  import { PcbScene3dCopperFactory } from './PcbScene3dCopperFactory.mjs'
4
+ import { PcbScene3dPadFactory } from './PcbScene3dPadFactory.mjs'
4
5
  import { PcbScene3dViaFactory } from './PcbScene3dViaFactory.mjs'
5
6
 
6
7
  /**
7
8
  * Builds deferred copper-detail groups for the 3D runtime.
8
9
  */
9
10
  export class PcbScene3dCopperDetailGroupBuilder {
11
+ static #CIRCLE_SEGMENTS = 64
12
+ static #ROUNDED_CORNER_SEGMENTS = 12
13
+
10
14
  /**
11
15
  * Builds visible exposed and mask-covered copper detail.
12
16
  * @param {any} THREE Three.js namespace.
@@ -68,6 +72,10 @@ export class PcbScene3dCopperDetailGroupBuilder {
68
72
  sceneDescription?.boardAssemblyModel
69
73
  )
70
74
  }
75
+ ),
76
+ occlusionCutouts:
77
+ PcbScene3dCopperDetailGroupBuilder.#resolveCoveredCopperOcclusions(
78
+ sceneDescription?.detail
71
79
  )
72
80
  }
73
81
  )
@@ -117,4 +125,358 @@ export class PcbScene3dCopperDetailGroupBuilder {
117
125
  normalizePoint
118
126
  )
119
127
  }
128
+
129
+ /**
130
+ * Resolves opaque silkscreen fill polygons that should hide covered copper.
131
+ * @param {{ top?: object, bottom?: object } | undefined} silkscreen Scene silkscreen detail.
132
+ * @returns {{ top: { x: number, y: number }[][], bottom: { x: number, y: number }[][] }}
133
+ */
134
+ static #resolveSilkscreenFillOcclusions(silkscreen) {
135
+ return {
136
+ top: PcbScene3dCopperDetailGroupBuilder.#resolveSideFillOcclusions(
137
+ silkscreen?.top
138
+ ),
139
+ bottom: PcbScene3dCopperDetailGroupBuilder.#resolveSideFillOcclusions(
140
+ silkscreen?.bottom
141
+ )
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Resolves fill contours on one silkscreen side.
147
+ * @param {{ fills?: any[] } | undefined} side Side-specific silkscreen detail.
148
+ * @returns {{ x: number, y: number }[][]}
149
+ */
150
+ static #resolveSideFillOcclusions(side) {
151
+ return (Array.isArray(side?.fills) ? side.fills : [])
152
+ .map((fill) =>
153
+ Array.isArray(fill?.points) && fill.points.length >= 3
154
+ ? fill.points
155
+ : PcbScene3dCopperDetailGroupBuilder.#resolveBoxFillPoints(
156
+ fill
157
+ )
158
+ )
159
+ .map((points) =>
160
+ points
161
+ .map((point) => ({
162
+ x: Number(point?.x),
163
+ y: Number(point?.y)
164
+ }))
165
+ .filter(
166
+ (point) =>
167
+ Number.isFinite(point.x) && Number.isFinite(point.y)
168
+ )
169
+ )
170
+ .filter((points) => points.length >= 3)
171
+ }
172
+
173
+ /**
174
+ * Resolves a rectangular fill as a polygon contour.
175
+ * @param {{ x1?: number, y1?: number, x2?: number, y2?: number } | undefined} fill Fill record.
176
+ * @returns {{ x: number, y: number }[]}
177
+ */
178
+ static #resolveBoxFillPoints(fill) {
179
+ const x1 = Number(fill?.x1)
180
+ const y1 = Number(fill?.y1)
181
+ const x2 = Number(fill?.x2)
182
+ const y2 = Number(fill?.y2)
183
+
184
+ if (
185
+ !Number.isFinite(x1) ||
186
+ !Number.isFinite(y1) ||
187
+ !Number.isFinite(x2) ||
188
+ !Number.isFinite(y2)
189
+ ) {
190
+ return []
191
+ }
192
+
193
+ return [
194
+ { x: x1, y: y1 },
195
+ { x: x2, y: y1 },
196
+ { x: x2, y: y2 },
197
+ { x: x1, y: y2 }
198
+ ]
199
+ }
200
+
201
+ /**
202
+ * Resolves opaque geometry that should hide mask-covered copper.
203
+ * @param {object | undefined} detail Scene detail.
204
+ * @returns {{ top: { x: number, y: number }[][], bottom: { x: number, y: number }[][] }}
205
+ */
206
+ static #resolveCoveredCopperOcclusions(detail) {
207
+ const silkscreenOcclusions =
208
+ PcbScene3dCopperDetailGroupBuilder.#resolveSilkscreenFillOcclusions(
209
+ detail?.silkscreen
210
+ )
211
+
212
+ return {
213
+ top: silkscreenOcclusions.top.concat(
214
+ PcbScene3dCopperDetailGroupBuilder.#resolvePadSurfaceOcclusions(
215
+ detail?.pads,
216
+ 'top'
217
+ )
218
+ ),
219
+ bottom: silkscreenOcclusions.bottom.concat(
220
+ PcbScene3dCopperDetailGroupBuilder.#resolvePadSurfaceOcclusions(
221
+ detail?.pads,
222
+ 'bottom'
223
+ )
224
+ )
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Resolves exposed pad faces on one side as occlusion polygons.
230
+ * @param {object[] | undefined} pads Pad detail rows.
231
+ * @param {'top' | 'bottom'} side Board side.
232
+ * @returns {{ x: number, y: number }[][]}
233
+ */
234
+ static #resolvePadSurfaceOcclusions(pads, side) {
235
+ return (Array.isArray(pads) ? pads : [])
236
+ .filter((pad) =>
237
+ PcbScene3dCopperDetailGroupBuilder.#hasVisiblePadSurface(
238
+ pad,
239
+ side
240
+ )
241
+ )
242
+ .map((pad) =>
243
+ PcbScene3dCopperDetailGroupBuilder.#resolvePadSurfacePolygon(
244
+ pad,
245
+ side
246
+ )
247
+ )
248
+ .filter((points) => points.length >= 3)
249
+ }
250
+
251
+ /**
252
+ * Checks whether one pad face has exposed copper on a side.
253
+ * @param {object} pad Pad detail row.
254
+ * @param {'top' | 'bottom'} side Board side.
255
+ * @returns {boolean}
256
+ */
257
+ static #hasVisiblePadSurface(pad, side) {
258
+ if (
259
+ PcbScene3dCopperDetailGroupBuilder.#resolveSolderMaskOpening(
260
+ pad,
261
+ side
262
+ ) === false
263
+ ) {
264
+ return false
265
+ }
266
+
267
+ if (PcbScene3dCopperDetailGroupBuilder.#hasSideSize(pad, side)) {
268
+ return true
269
+ }
270
+
271
+ const oppositeSide = side === 'bottom' ? 'top' : 'bottom'
272
+ if (
273
+ PcbScene3dCopperDetailGroupBuilder.#hasSideSize(pad, oppositeSide)
274
+ ) {
275
+ return false
276
+ }
277
+
278
+ return (
279
+ Number(pad?.sizeMidX || 0) > 0 ||
280
+ Number(pad?.sizeMidY || 0) > 0 ||
281
+ Number(pad?.holeDiameter || 0) > 0
282
+ )
283
+ }
284
+
285
+ /**
286
+ * Resolves one side-specific solder mask opening flag.
287
+ * @param {object} pad Pad detail row.
288
+ * @param {'top' | 'bottom'} side Board side.
289
+ * @returns {boolean | null}
290
+ */
291
+ static #resolveSolderMaskOpening(pad, side) {
292
+ const fieldName =
293
+ side === 'bottom'
294
+ ? 'hasBottomSolderMaskOpening'
295
+ : 'hasTopSolderMaskOpening'
296
+
297
+ return typeof pad?.[fieldName] === 'boolean' ? pad[fieldName] : null
298
+ }
299
+
300
+ /**
301
+ * Checks whether one pad has an explicit copper size for a side.
302
+ * @param {object} pad Pad detail row.
303
+ * @param {'top' | 'bottom'} side Board side.
304
+ * @returns {boolean}
305
+ */
306
+ static #hasSideSize(pad, side) {
307
+ return side === 'bottom'
308
+ ? Number(pad?.sizeBottomX || 0) > 0 ||
309
+ Number(pad?.sizeBottomY || 0) > 0
310
+ : Number(pad?.sizeTopX || 0) > 0 || Number(pad?.sizeTopY || 0) > 0
311
+ }
312
+
313
+ /**
314
+ * Resolves one pad surface as a board-coordinate polygon.
315
+ * @param {object} pad Pad detail row.
316
+ * @param {'top' | 'bottom'} side Board side.
317
+ * @returns {{ x: number, y: number }[]}
318
+ */
319
+ static #resolvePadSurfacePolygon(pad, side) {
320
+ const spec = PcbScene3dPadFactory.resolvePadSurfaceSpec(pad, side)
321
+ const center = {
322
+ x: Number(pad?.x || 0) + spec.offsetX,
323
+ y: Number(pad?.y || 0) + spec.offsetY
324
+ }
325
+
326
+ return PcbScene3dCopperDetailGroupBuilder.#transformPoints(
327
+ PcbScene3dCopperDetailGroupBuilder.#buildPadLocalPoints(spec),
328
+ center,
329
+ Number(pad?.rotation || 0)
330
+ )
331
+ }
332
+
333
+ /**
334
+ * Builds local points for one pad face.
335
+ * @param {{ width: number, height: number, kind: string, cornerRadius: number }} spec Pad surface spec.
336
+ * @returns {{ x: number, y: number }[]}
337
+ */
338
+ static #buildPadLocalPoints(spec) {
339
+ if (spec.kind === 'circle') {
340
+ return PcbScene3dCopperDetailGroupBuilder.#buildCirclePoints(
341
+ Math.max(spec.width, spec.height) / 2
342
+ )
343
+ }
344
+
345
+ if (spec.kind === 'rounded-rect' && Number(spec.cornerRadius) > 0) {
346
+ return PcbScene3dCopperDetailGroupBuilder.#buildRoundedRectPoints(
347
+ spec.width,
348
+ spec.height,
349
+ spec.cornerRadius
350
+ )
351
+ }
352
+
353
+ return PcbScene3dCopperDetailGroupBuilder.#buildRectPoints(
354
+ spec.width,
355
+ spec.height
356
+ )
357
+ }
358
+
359
+ /**
360
+ * Builds a circular polygon centered at the origin.
361
+ * @param {number} radius Circle radius.
362
+ * @returns {{ x: number, y: number }[]}
363
+ */
364
+ static #buildCirclePoints(radius) {
365
+ return Array.from(
366
+ { length: PcbScene3dCopperDetailGroupBuilder.#CIRCLE_SEGMENTS },
367
+ (_unused, index) => {
368
+ const angle =
369
+ (Math.PI * 2 * index) /
370
+ PcbScene3dCopperDetailGroupBuilder.#CIRCLE_SEGMENTS
371
+
372
+ return {
373
+ x: Math.cos(angle) * radius,
374
+ y: Math.sin(angle) * radius
375
+ }
376
+ }
377
+ )
378
+ }
379
+
380
+ /**
381
+ * Builds a rectangle polygon centered at the origin.
382
+ * @param {number} width Rectangle width.
383
+ * @param {number} height Rectangle height.
384
+ * @returns {{ x: number, y: number }[]}
385
+ */
386
+ static #buildRectPoints(width, height) {
387
+ const halfWidth = Number(width || 0) / 2
388
+ const halfHeight = Number(height || 0) / 2
389
+
390
+ return [
391
+ { x: -halfWidth, y: -halfHeight },
392
+ { x: halfWidth, y: -halfHeight },
393
+ { x: halfWidth, y: halfHeight },
394
+ { x: -halfWidth, y: halfHeight }
395
+ ]
396
+ }
397
+
398
+ /**
399
+ * Builds a rounded rectangle polygon centered at the origin.
400
+ * @param {number} width Rectangle width.
401
+ * @param {number} height Rectangle height.
402
+ * @param {number} radius Corner radius.
403
+ * @returns {{ x: number, y: number }[]}
404
+ */
405
+ static #buildRoundedRectPoints(width, height, radius) {
406
+ const halfWidth = Number(width || 0) / 2
407
+ const halfHeight = Number(height || 0) / 2
408
+ const cornerRadius = Math.min(
409
+ Math.max(Number(radius || 0), 0),
410
+ halfWidth,
411
+ halfHeight
412
+ )
413
+ const corners = [
414
+ { x: halfWidth - cornerRadius, y: halfHeight - cornerRadius },
415
+ { x: -halfWidth + cornerRadius, y: halfHeight - cornerRadius },
416
+ { x: -halfWidth + cornerRadius, y: -halfHeight + cornerRadius },
417
+ { x: halfWidth - cornerRadius, y: -halfHeight + cornerRadius }
418
+ ]
419
+ const angleRanges = [
420
+ [0, Math.PI / 2],
421
+ [Math.PI / 2, Math.PI],
422
+ [Math.PI, (Math.PI * 3) / 2],
423
+ [(Math.PI * 3) / 2, Math.PI * 2]
424
+ ]
425
+
426
+ return corners.flatMap((corner, cornerIndex) =>
427
+ PcbScene3dCopperDetailGroupBuilder.#buildCornerPoints(
428
+ corner,
429
+ cornerRadius,
430
+ angleRanges[cornerIndex]
431
+ )
432
+ )
433
+ }
434
+
435
+ /**
436
+ * Builds sampled points for one rounded corner.
437
+ * @param {{ x: number, y: number }} center Corner arc center.
438
+ * @param {number} radius Corner radius.
439
+ * @param {number[]} angleRange Start and end angle.
440
+ * @returns {{ x: number, y: number }[]}
441
+ */
442
+ static #buildCornerPoints(center, radius, angleRange) {
443
+ const [startAngle, endAngle] = angleRange
444
+
445
+ return Array.from(
446
+ {
447
+ length:
448
+ PcbScene3dCopperDetailGroupBuilder
449
+ .#ROUNDED_CORNER_SEGMENTS + 1
450
+ },
451
+ (_unused, index) => {
452
+ const ratio =
453
+ index /
454
+ PcbScene3dCopperDetailGroupBuilder.#ROUNDED_CORNER_SEGMENTS
455
+ const angle = startAngle + (endAngle - startAngle) * ratio
456
+
457
+ return {
458
+ x: center.x + Math.cos(angle) * radius,
459
+ y: center.y + Math.sin(angle) * radius
460
+ }
461
+ }
462
+ )
463
+ }
464
+
465
+ /**
466
+ * Rotates and translates local points into board coordinates.
467
+ * @param {{ x: number, y: number }[]} points Local points.
468
+ * @param {{ x: number, y: number }} center Board center.
469
+ * @param {number} rotationDeg Rotation in degrees.
470
+ * @returns {{ x: number, y: number }[]}
471
+ */
472
+ static #transformPoints(points, center, rotationDeg) {
473
+ const angle = (Number(rotationDeg || 0) * Math.PI) / 180
474
+ const cos = Math.cos(angle)
475
+ const sin = Math.sin(angle)
476
+
477
+ return points.map((point) => ({
478
+ x: center.x + point.x * cos - point.y * sin,
479
+ y: center.y + point.x * sin + point.y * cos
480
+ }))
481
+ }
120
482
  }
@@ -3,14 +3,14 @@ import { PcbScene3dPadFactory } from './PcbScene3dPadFactory.mjs'
3
3
  import { PcbScene3dCopperTextFactory } from './PcbScene3dCopperTextFactory.mjs'
4
4
  import { PcbScene3dMaskCoveredCopperMaterial } from './PcbScene3dMaskCoveredCopperMaterial.mjs'
5
5
  import { PcbScene3dGeometryZCompressor } from './PcbScene3dGeometryZCompressor.mjs'
6
+ import { PcbScene3dCopperOcclusionClipper } from './PcbScene3dCopperOcclusionClipper.mjs'
7
+ import { PcbScene3dCopperLayerFilter } from './PcbScene3dCopperLayerFilter.mjs'
6
8
 
7
9
  /**
8
10
  * Builds copper-detail meshes for the interactive 3D PCB scene.
9
11
  */
10
12
  export class PcbScene3dCopperFactory {
11
13
  static #ARC_SEGMENT_DEGREES = 3
12
- static #TOP_COPPER_LAYER_ID = 1
13
- static #BOTTOM_COPPER_LAYER_ID = 32
14
14
  static #FULL_CIRCLE_EPSILON = 0.001
15
15
  static #ROUND_CAP_SEGMENTS = 16
16
16
  static #COPPER_THICKNESS_MIL = 2.2
@@ -46,11 +46,11 @@ export class PcbScene3dCopperFactory {
46
46
  const topGroup = PcbScene3dCopperFactory.#buildSideGroup(
47
47
  THREE,
48
48
  {
49
- tracks: PcbScene3dCopperFactory.#filterTracks(
49
+ tracks: PcbScene3dCopperLayerFilter.tracks(
50
50
  detail?.tracks,
51
51
  'top'
52
52
  ),
53
- arcs: PcbScene3dCopperFactory.#filterArcs(detail?.arcs, 'top'),
53
+ arcs: PcbScene3dCopperLayerFilter.arcs(detail?.arcs, 'top'),
54
54
  pads: detail?.pads || [],
55
55
  vias: detail?.vias || [],
56
56
  copperTexts: detail?.copperTexts || []
@@ -63,14 +63,11 @@ export class PcbScene3dCopperFactory {
63
63
  const bottomGroup = PcbScene3dCopperFactory.#buildSideGroup(
64
64
  THREE,
65
65
  {
66
- tracks: PcbScene3dCopperFactory.#filterTracks(
66
+ tracks: PcbScene3dCopperLayerFilter.tracks(
67
67
  detail?.tracks,
68
68
  'bottom'
69
69
  ),
70
- arcs: PcbScene3dCopperFactory.#filterArcs(
71
- detail?.arcs,
72
- 'bottom'
73
- ),
70
+ arcs: PcbScene3dCopperLayerFilter.arcs(detail?.arcs, 'bottom'),
74
71
  pads: detail?.pads || [],
75
72
  vias: detail?.vias || [],
76
73
  copperTexts: detail?.copperTexts || []
@@ -98,7 +95,7 @@ export class PcbScene3dCopperFactory {
98
95
  * @param {number} topZ
99
96
  * @param {number} bottomZ
100
97
  * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint
101
- * @param {{ solderMaskColor?: number }} [options]
98
+ * @param {{ solderMaskColor?: number, occlusionCutouts?: { top?: { x: number, y: number }[][], bottom?: { x: number, y: number }[][] } }} [options]
102
99
  * @returns {any}
103
100
  */
104
101
  static buildMaskCoveredGroup(
@@ -117,33 +114,40 @@ export class PcbScene3dCopperFactory {
117
114
  const topGroup = PcbScene3dCopperFactory.#buildMaskCoveredSideGroup(
118
115
  THREE,
119
116
  {
120
- tracks: PcbScene3dCopperFactory.#filterTracks(
117
+ tracks: PcbScene3dCopperLayerFilter.tracks(
121
118
  detail?.tracks,
122
119
  'top'
123
120
  ),
124
- arcs: PcbScene3dCopperFactory.#filterArcs(detail?.arcs, 'top')
121
+ arcs: PcbScene3dCopperLayerFilter.arcs(detail?.arcs, 'top')
125
122
  },
126
123
  Math.abs(Number(topZ || 0)),
127
124
  normalizeBoardPoint,
128
125
  false,
129
- material
126
+ material,
127
+ PcbScene3dCopperOcclusionClipper.normalizeCutouts(
128
+ options?.occlusionCutouts?.top,
129
+ normalizeBoardPoint,
130
+ false
131
+ )
130
132
  )
131
133
  const bottomGroup = PcbScene3dCopperFactory.#buildMaskCoveredSideGroup(
132
134
  THREE,
133
135
  {
134
- tracks: PcbScene3dCopperFactory.#filterTracks(
136
+ tracks: PcbScene3dCopperLayerFilter.tracks(
135
137
  detail?.tracks,
136
138
  'bottom'
137
139
  ),
138
- arcs: PcbScene3dCopperFactory.#filterArcs(
139
- detail?.arcs,
140
- 'bottom'
141
- )
140
+ arcs: PcbScene3dCopperLayerFilter.arcs(detail?.arcs, 'bottom')
142
141
  },
143
142
  Math.abs(Number(bottomZ || 0)),
144
143
  normalizeBoardPoint,
145
144
  true,
146
- material
145
+ material,
146
+ PcbScene3dCopperOcclusionClipper.normalizeCutouts(
147
+ options?.occlusionCutouts?.bottom,
148
+ normalizeBoardPoint,
149
+ true
150
+ )
147
151
  )
148
152
 
149
153
  if (topGroup.children.length) {
@@ -238,6 +242,7 @@ export class PcbScene3dCopperFactory {
238
242
  * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint
239
243
  * @param {boolean} mirrorY
240
244
  * @param {any} material Shared covered-trace material.
245
+ * @param {{ x: number, y: number }[][]} occlusionCutouts Silkscreen ink polygons covering this copper.
241
246
  * @returns {any}
242
247
  */
243
248
  static #buildMaskCoveredSideGroup(
@@ -246,7 +251,8 @@ export class PcbScene3dCopperFactory {
246
251
  z,
247
252
  normalizeBoardPoint,
248
253
  mirrorY,
249
- material
254
+ material,
255
+ occlusionCutouts
250
256
  ) {
251
257
  const group = new THREE.Group()
252
258
  const trackMesh = PcbScene3dCopperFactory.#buildTrackMesh(
@@ -255,7 +261,8 @@ export class PcbScene3dCopperFactory {
255
261
  z,
256
262
  normalizeBoardPoint,
257
263
  mirrorY,
258
- material
264
+ material,
265
+ occlusionCutouts
259
266
  )
260
267
  const arcMesh = PcbScene3dCopperFactory.#buildArcMesh(
261
268
  THREE,
@@ -263,7 +270,8 @@ export class PcbScene3dCopperFactory {
263
270
  z,
264
271
  normalizeBoardPoint,
265
272
  mirrorY,
266
- material
273
+ material,
274
+ occlusionCutouts
267
275
  )
268
276
 
269
277
  if (trackMesh) {
@@ -306,6 +314,7 @@ export class PcbScene3dCopperFactory {
306
314
  * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint
307
315
  * @param {boolean} mirrorY
308
316
  * @param {any | null} [material] Optional material override.
317
+ * @param {{ x: number, y: number }[][]} [cutouts] Optional geometry cutouts.
309
318
  * @returns {any | null}
310
319
  */
311
320
  static #buildTrackMesh(
@@ -314,7 +323,8 @@ export class PcbScene3dCopperFactory {
314
323
  z,
315
324
  normalizeBoardPoint,
316
325
  mirrorY,
317
- material = null
326
+ material = null,
327
+ cutouts = []
318
328
  ) {
319
329
  const positions = []
320
330
 
@@ -344,7 +354,8 @@ export class PcbScene3dCopperFactory {
344
354
  return PcbScene3dCopperFactory.#buildStrokeMesh(
345
355
  THREE,
346
356
  positions,
347
- material
357
+ material,
358
+ cutouts
348
359
  )
349
360
  }
350
361
 
@@ -356,6 +367,7 @@ export class PcbScene3dCopperFactory {
356
367
  * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint
357
368
  * @param {boolean} mirrorY
358
369
  * @param {any | null} [material] Optional material override.
370
+ * @param {{ x: number, y: number }[][]} [cutouts] Optional geometry cutouts.
359
371
  * @returns {any | null}
360
372
  */
361
373
  static #buildArcMesh(
@@ -364,7 +376,8 @@ export class PcbScene3dCopperFactory {
364
376
  z,
365
377
  normalizeBoardPoint,
366
378
  mirrorY,
367
- material = null
379
+ material = null,
380
+ cutouts = []
368
381
  ) {
369
382
  const positions = []
370
383
 
@@ -387,7 +400,8 @@ export class PcbScene3dCopperFactory {
387
400
  return PcbScene3dCopperFactory.#buildStrokeMesh(
388
401
  THREE,
389
402
  positions,
390
- material
403
+ material,
404
+ cutouts
391
405
  )
392
406
  }
393
407
 
@@ -396,9 +410,10 @@ export class PcbScene3dCopperFactory {
396
410
  * @param {any} THREE
397
411
  * @param {number[]} positions
398
412
  * @param {any | null} [material] Optional material override.
413
+ * @param {{ x: number, y: number }[][]} [cutouts] Optional geometry cutouts.
399
414
  * @returns {any | null}
400
415
  */
401
- static #buildStrokeMesh(THREE, positions, material = null) {
416
+ static #buildStrokeMesh(THREE, positions, material = null, cutouts = []) {
402
417
  if (!positions.length) {
403
418
  return null
404
419
  }
@@ -408,10 +423,18 @@ export class PcbScene3dCopperFactory {
408
423
  'position',
409
424
  new THREE.Float32BufferAttribute(positions, 3)
410
425
  )
411
- geometry.computeVertexNormals?.()
426
+ const clippedGeometry = PcbScene3dCopperOcclusionClipper.filter(
427
+ THREE,
428
+ geometry,
429
+ cutouts
430
+ )
431
+
432
+ if (!clippedGeometry) {
433
+ return null
434
+ }
412
435
 
413
436
  return new THREE.Mesh(
414
- geometry,
437
+ clippedGeometry,
415
438
  material || PcbScene3dCopperFactory.#buildMaterial(THREE)
416
439
  )
417
440
  }
@@ -433,47 +456,6 @@ export class PcbScene3dCopperFactory {
433
456
  })
434
457
  }
435
458
 
436
- /**
437
- * Filters one track list to one outer copper face.
438
- * @param {any[] | undefined} tracks
439
- * @param {'top' | 'bottom'} side
440
- * @returns {any[]}
441
- */
442
- static #filterTracks(tracks, side) {
443
- return (tracks || []).filter((track) =>
444
- PcbScene3dCopperFactory.#matchesCopperLayer(track, side)
445
- )
446
- }
447
-
448
- /**
449
- * Filters one arc list to one outer copper face.
450
- * @param {any[] | undefined} arcs
451
- * @param {'top' | 'bottom'} side
452
- * @returns {any[]}
453
- */
454
- static #filterArcs(arcs, side) {
455
- return (arcs || []).filter((arc) =>
456
- PcbScene3dCopperFactory.#matchesCopperLayer(arc, side)
457
- )
458
- }
459
-
460
- /**
461
- * Returns true when one primitive belongs to the requested outer copper
462
- * face.
463
- * @param {{ layerId?: number, layerCode?: number }} primitive
464
- * @param {'top' | 'bottom'} side
465
- * @returns {boolean}
466
- */
467
- static #matchesCopperLayer(primitive, side) {
468
- const layerId = Number(
469
- primitive?.layerId ?? primitive?.layerCode ?? NaN
470
- )
471
-
472
- return side === 'bottom'
473
- ? layerId === PcbScene3dCopperFactory.#BOTTOM_COPPER_LAYER_ID
474
- : layerId === PcbScene3dCopperFactory.#TOP_COPPER_LAYER_ID
475
- }
476
-
477
459
  /**
478
460
  * Appends one widened track quad as two triangles.
479
461
  * @param {number[]} positions