pcb-scene3d-viewer 1.1.49 → 1.2.0

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 (63) hide show
  1. package/README.md +46 -21
  2. package/docs/api.md +109 -8
  3. package/docs/circuitjson.md +143 -29
  4. package/docs/model-format.md +50 -7
  5. package/docs/release-notes-v1.2.0.md +122 -0
  6. package/docs/testing.md +30 -1
  7. package/package.json +6 -3
  8. package/spec/library-scope.md +10 -2
  9. package/src/CircuitJsonCadModelAssetResolver.mjs +697 -83
  10. package/src/PcbAssemblyBoardSubstrateBuilder.mjs +43 -0
  11. package/src/PcbAssemblyGeometryBuilder.mjs +40 -18
  12. package/src/PcbAssemblyModelMeshLoader.mjs +101 -150
  13. package/src/PcbAssemblyPadMeshBuilder.mjs +22 -0
  14. package/src/PcbModelArchiveExporter.mjs +28 -112
  15. package/src/PcbModelArchiveSourceBundle.mjs +326 -0
  16. package/src/PcbScene3dAabbIndex.mjs +464 -0
  17. package/src/PcbScene3dBoardAssemblyPresentation.mjs +1 -1
  18. package/src/PcbScene3dBoardEdgeCutoutBuilder.mjs +9 -5
  19. package/src/PcbScene3dBoardMaterialPalette.mjs +29 -0
  20. package/src/PcbScene3dBoardShapeFactory.mjs +11 -118
  21. package/src/PcbScene3dBoardSolderMaskFactory.mjs +65 -39
  22. package/src/PcbScene3dCircuitJsonAdapter.mjs +151 -48
  23. package/src/PcbScene3dCircuitJsonDrillDetail.mjs +31 -0
  24. package/src/PcbScene3dCircuitJsonGeometry.mjs +184 -33
  25. package/src/PcbScene3dCircuitJsonInput.mjs +132 -0
  26. package/src/PcbScene3dCircuitJsonModelAsset.mjs +40 -0
  27. package/src/PcbScene3dController.mjs +40 -34
  28. package/src/PcbScene3dCopperFactory.mjs +36 -22
  29. package/src/PcbScene3dCopperFillAreaClipper.mjs +133 -235
  30. package/src/PcbScene3dCopperFillCoverageContext.mjs +204 -0
  31. package/src/PcbScene3dCopperFillLoopSetResolver.mjs +192 -0
  32. package/src/PcbScene3dCopperFillMeshBuilder.mjs +77 -295
  33. package/src/PcbScene3dCopperTextFactory.mjs +12 -4
  34. package/src/PcbScene3dCutoutCircleDetector.mjs +34 -17
  35. package/src/PcbScene3dCutoutGeometryFilter.mjs +104 -269
  36. package/src/PcbScene3dCutoutGridIndex.mjs +184 -0
  37. package/src/PcbScene3dDeferredModelFinalizer.mjs +52 -0
  38. package/src/PcbScene3dDescriptorSafeRecord.mjs +38 -0
  39. package/src/PcbScene3dDrillCutoutFilter.mjs +149 -143
  40. package/src/PcbScene3dDrillPathFactory.mjs +86 -16
  41. package/src/PcbScene3dDrillVoidFactory.mjs +35 -10
  42. package/src/PcbScene3dExternalModelGroupLoader.mjs +472 -31
  43. package/src/PcbScene3dExternalModels.mjs +23 -24
  44. package/src/PcbScene3dFacetedModelGroupBuilder.mjs +217 -0
  45. package/src/PcbScene3dGeometryZCompressor.mjs +4 -2
  46. package/src/PcbScene3dMaskCoveredCopperSideGroupBuilder.mjs +18 -4
  47. package/src/PcbScene3dMaskCoveredCopperSurfaceFilter.mjs +75 -10
  48. package/src/PcbScene3dModelContent.mjs +236 -0
  49. package/src/PcbScene3dModelFetchPolicy.mjs +304 -0
  50. package/src/PcbScene3dModelIdentity.mjs +106 -0
  51. package/src/PcbScene3dPlatedDrillSpecResolver.mjs +141 -0
  52. package/src/PcbScene3dPreparedPolygon.mjs +709 -0
  53. package/src/PcbScene3dPreparedPolygonSet.mjs +70 -0
  54. package/src/PcbScene3dRuntime.mjs +45 -45
  55. package/src/PcbScene3dRuntimeBoardMeshes.mjs +85 -1
  56. package/src/PcbScene3dShapeHoleGeometryCleaner.mjs +4 -2
  57. package/src/PcbScene3dShellRenderer.mjs +75 -6
  58. package/src/PcbScene3dSilkscreenCutoutContext.mjs +255 -0
  59. package/src/PcbScene3dSilkscreenFactory.mjs +87 -127
  60. package/src/PcbScene3dSilkscreenFillSeamBuilder.mjs +11 -5
  61. package/src/PcbScene3dStepLoader.mjs +11 -10
  62. package/src/PcbScene3dText.mjs +1 -1
  63. package/src/PcbScene3dTriangleVertexQueryBounds.mjs +302 -0
@@ -1,6 +1,7 @@
1
1
  import earcut from 'earcut'
2
- import { PcbAssemblyFillGeometryResolver } from './PcbAssemblyFillGeometryResolver.mjs'
3
2
  import { PcbScene3dCopperFillAreaClipper } from './PcbScene3dCopperFillAreaClipper.mjs'
3
+ import { PcbScene3dCopperFillCoverageContext } from './PcbScene3dCopperFillCoverageContext.mjs'
4
+ import { PcbScene3dCopperFillLoopSetResolver } from './PcbScene3dCopperFillLoopSetResolver.mjs'
4
5
  import { PcbScene3dCopperFillPolygonBoolean } from './PcbScene3dCopperFillPolygonBoolean.mjs'
5
6
  import { PcbScene3dCopperOcclusionClipper } from './PcbScene3dCopperOcclusionClipper.mjs'
6
7
 
@@ -8,8 +9,6 @@ import { PcbScene3dCopperOcclusionClipper } from './PcbScene3dCopperOcclusionCli
8
9
  * Builds saved copper fill meshes for the interactive 3D scene.
9
10
  */
10
11
  export class PcbScene3dCopperFillMeshBuilder {
11
- static #AREA_EPSILON = 0.001
12
-
13
12
  /**
14
13
  * Builds one combined copper fill mesh.
15
14
  * @param {any} THREE Three.js namespace.
@@ -20,7 +19,7 @@ export class PcbScene3dCopperFillMeshBuilder {
20
19
  * @param {boolean} mirrorY Whether to mirror underside Y coordinates.
21
20
  * @param {any} material Copper material.
22
21
  * @param {{ x: number, y: number }[][]} [cutouts] Optional normalized overlay cutouts.
23
- * @param {{ surfaceOnly?: boolean, clipContainedFillOverlaps?: boolean }} [options] Optional mesh options.
22
+ * @param {{ surfaceOnly?: boolean, clipContainedFillOverlaps?: boolean, loopSets?: { outer: number[][], holes: number[][][], bounds: object }[], coverageContext?: object }} [options] Optional mesh options.
24
23
  * @returns {any | null}
25
24
  */
26
25
  static build(
@@ -38,6 +37,13 @@ export class PcbScene3dCopperFillMeshBuilder {
38
37
  const halfThickness = Math.max(Number(thickness || 0), 0.001) / 2
39
38
  const bottomZ = Number(z || 0) - halfThickness
40
39
  const topZ = Number(z || 0) + halfThickness
40
+ const loopSets = Array.isArray(options?.loopSets)
41
+ ? options.loopSets
42
+ : PcbScene3dCopperFillLoopSetResolver.resolve(
43
+ fills,
44
+ normalizeBoardPoint,
45
+ mirrorY
46
+ )
41
47
 
42
48
  if (
43
49
  PcbScene3dCopperFillMeshBuilder.#shouldClipContainedFillOverlaps(
@@ -47,22 +53,18 @@ export class PcbScene3dCopperFillMeshBuilder {
47
53
  PcbScene3dCopperFillMeshBuilder.#appendClippedFillPositions(
48
54
  THREE,
49
55
  positions,
50
- fills,
56
+ loopSets,
51
57
  bottomZ,
52
58
  topZ,
53
- normalizeBoardPoint,
54
- mirrorY,
55
59
  options
56
60
  )
57
61
  } else {
58
- for (const fill of fills || []) {
59
- PcbScene3dCopperFillMeshBuilder.#appendFillPositions(
62
+ for (const loopSet of loopSets) {
63
+ PcbScene3dCopperFillMeshBuilder.#appendNormalizedLoopSetPositions(
60
64
  positions,
61
- fill,
65
+ loopSet,
62
66
  bottomZ,
63
67
  topZ,
64
- normalizeBoardPoint,
65
- mirrorY,
66
68
  options
67
69
  )
68
70
  }
@@ -107,70 +109,34 @@ export class PcbScene3dCopperFillMeshBuilder {
107
109
  return PcbScene3dCopperOcclusionClipper.filter(THREE, geometry, cutouts)
108
110
  }
109
111
 
110
- /**
111
- * Appends one filled primitive to a shared position buffer.
112
- * @param {number[]} positions Position buffer.
113
- * @param {object} fill Filled copper primitive.
114
- * @param {number} bottomZ Lower Z.
115
- * @param {number} topZ Upper Z.
116
- * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint Board normalizer.
117
- * @param {boolean} mirrorY Whether to mirror underside Y coordinates.
118
- * @param {{ surfaceOnly?: boolean }} options Mesh options.
119
- * @returns {void}
120
- */
121
- static #appendFillPositions(
122
- positions,
123
- fill,
124
- bottomZ,
125
- topZ,
126
- normalizeBoardPoint,
127
- mirrorY,
128
- options
129
- ) {
130
- for (const loops of PcbAssemblyFillGeometryResolver.resolveAll(fill)) {
131
- PcbScene3dCopperFillMeshBuilder.#appendLoopSetPositions(
132
- positions,
133
- loops,
134
- bottomZ,
135
- topZ,
136
- normalizeBoardPoint,
137
- mirrorY,
138
- options
139
- )
140
- }
141
- }
142
-
143
112
  /**
144
113
  * Appends fill positions while removing duplicate overlap surfaces.
145
114
  * @param {any} THREE Three.js namespace.
146
115
  * @param {number[]} positions Position buffer.
147
- * @param {object[]} fills Filled copper primitives.
116
+ * @param {{ outer: number[][], holes: number[][][], bounds: object }[]} loopSets Normalized fill islands.
148
117
  * @param {number} bottomZ Lower Z.
149
118
  * @param {number} topZ Upper Z.
150
- * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint Board normalizer.
151
- * @param {boolean} mirrorY Whether to mirror underside Y coordinates.
152
- * @param {{ surfaceOnly?: boolean, clipContainedFillOverlaps?: boolean }} options Mesh options.
119
+ * @param {{ surfaceOnly?: boolean, clipContainedFillOverlaps?: boolean, coverageContext?: object }} options Mesh options.
153
120
  * @returns {void}
154
121
  */
155
122
  static #appendClippedFillPositions(
156
123
  THREE,
157
124
  positions,
158
- fills,
125
+ loopSets,
159
126
  bottomZ,
160
127
  topZ,
161
- normalizeBoardPoint,
162
- mirrorY,
163
128
  options
164
129
  ) {
165
- const loopSets = PcbScene3dCopperFillMeshBuilder.#resolveLoopSets(
166
- fills,
167
- normalizeBoardPoint,
168
- mirrorY
169
- )
170
130
  const emittedPolygons = []
171
131
  const emittedLoopSets = []
132
+ let coverageContext = null
172
133
 
173
- for (const loopSet of loopSets) {
134
+ for (
135
+ let loopSetIndex = 0;
136
+ loopSetIndex < loopSets.length;
137
+ loopSetIndex += 1
138
+ ) {
139
+ const loopSet = loopSets[loopSetIndex]
174
140
  const remainingLoopSets =
175
141
  PcbScene3dCopperFillPolygonBoolean.resolveRemainingLoopSets(
176
142
  loopSet,
@@ -188,11 +154,23 @@ export class PcbScene3dCopperFillMeshBuilder {
188
154
  )
189
155
  }
190
156
  } else {
157
+ if (!coverageContext) {
158
+ coverageContext =
159
+ options?.coverageContext?.matchesLoopSets?.(
160
+ loopSets
161
+ ) === true
162
+ ? options.coverageContext
163
+ : PcbScene3dCopperFillCoverageContext.fromLoopSets(
164
+ loopSets
165
+ )
166
+ }
191
167
  PcbScene3dCopperFillMeshBuilder.#appendTriangleClippedLoopSet(
192
168
  THREE,
193
169
  positions,
194
170
  loopSet,
195
171
  emittedLoopSets,
172
+ loopSetIndex,
173
+ coverageContext,
196
174
  bottomZ,
197
175
  topZ,
198
176
  options
@@ -214,6 +192,8 @@ export class PcbScene3dCopperFillMeshBuilder {
214
192
  * @param {number[]} positions Position buffer.
215
193
  * @param {{ outer: number[][], holes: number[][][], bounds: object }} loopSet Candidate loop set.
216
194
  * @param {{ outer: number[][], holes: number[][][], bounds: object }[]} emittedLoopSets Already emitted loop sets.
195
+ * @param {number} loopSetIndex Flattened candidate source index.
196
+ * @param {object} coverageContext Prepared ordered fill coverage.
217
197
  * @param {number} bottomZ Lower Z.
218
198
  * @param {number} topZ Upper Z.
219
199
  * @param {{ surfaceOnly?: boolean, clipContainedFillOverlaps?: boolean }} options Mesh options.
@@ -224,6 +204,8 @@ export class PcbScene3dCopperFillMeshBuilder {
224
204
  positions,
225
205
  loopSet,
226
206
  emittedLoopSets,
207
+ loopSetIndex,
208
+ coverageContext,
227
209
  bottomZ,
228
210
  topZ,
229
211
  options
@@ -249,10 +231,12 @@ export class PcbScene3dCopperFillMeshBuilder {
249
231
  )
250
232
  PcbScene3dCopperFillMeshBuilder.#appendPositions(
251
233
  positions,
252
- PcbScene3dCopperFillMeshBuilder.#clipPositionsAgainstLoopSets(
234
+ PcbScene3dCopperFillMeshBuilder.#clipPositionsAgainstCoverage(
253
235
  THREE,
254
236
  loopPositions,
255
- PcbScene3dCopperFillMeshBuilder.#resolveClipLoopSets(
237
+ coverageContext,
238
+ loopSetIndex,
239
+ PcbScene3dCopperFillMeshBuilder.#resolveClipSourceIndexes(
256
240
  loopSet,
257
241
  emittedLoopSets
258
242
  )
@@ -261,14 +245,21 @@ export class PcbScene3dCopperFillMeshBuilder {
261
245
  }
262
246
 
263
247
  /**
264
- * Resolves earlier loop sets that can actually overlap a candidate.
248
+ * Resolves indexes of earlier loop sets that can overlap a candidate.
265
249
  * @param {{ outer: number[][], holes: number[][][], bounds: object }} candidate Candidate loop set.
266
250
  * @param {{ outer: number[][], holes: number[][][], bounds: object }[]} loopSets Emitted loop sets.
267
- * @returns {{ outer: number[][], holes: number[][][], bounds: object }[]}
251
+ * @returns {Set<number>}
268
252
  */
269
- static #resolveClipLoopSets(candidate, loopSets) {
270
- return loopSets.filter(
271
- (loopSet) =>
253
+ static #resolveClipSourceIndexes(candidate, loopSets) {
254
+ const sourceIndexes = new Set()
255
+
256
+ for (
257
+ let sourceIndex = 0;
258
+ sourceIndex < loopSets.length;
259
+ sourceIndex += 1
260
+ ) {
261
+ const loopSet = loopSets[sourceIndex]
262
+ if (
272
263
  PcbScene3dCopperFillMeshBuilder.#boundsOverlap(
273
264
  candidate.bounds,
274
265
  loopSet.bounds
@@ -277,7 +268,12 @@ export class PcbScene3dCopperFillMeshBuilder {
277
268
  candidate,
278
269
  loopSet
279
270
  )
280
- )
271
+ ) {
272
+ sourceIndexes.add(sourceIndex)
273
+ }
274
+ }
275
+
276
+ return sourceIndexes
281
277
  }
282
278
 
283
279
  /**
@@ -293,14 +289,22 @@ export class PcbScene3dCopperFillMeshBuilder {
293
289
  }
294
290
 
295
291
  /**
296
- * Clips position triangles against already-emitted fill loop sets.
292
+ * Clips position triangles against eligible prepared fill areas.
297
293
  * @param {any} THREE Three.js namespace.
298
294
  * @param {number[]} positions Candidate triangle positions.
299
- * @param {{ outer: number[][], holes: number[][][] }[]} loopSets Filled areas already represented.
295
+ * @param {object} coverageContext Prepared ordered fill coverage.
296
+ * @param {number} beforeSourceIndex Exclusive source-order prefix.
297
+ * @param {Set<number>} allowedSourceIndexes Eligible earlier sources.
300
298
  * @returns {number[]}
301
299
  */
302
- static #clipPositionsAgainstLoopSets(THREE, positions, loopSets) {
303
- if (!positions.length || !loopSets.length) {
300
+ static #clipPositionsAgainstCoverage(
301
+ THREE,
302
+ positions,
303
+ coverageContext,
304
+ beforeSourceIndex,
305
+ allowedSourceIndexes
306
+ ) {
307
+ if (!positions.length || !allowedSourceIndexes.size) {
304
308
  return positions
305
309
  }
306
310
 
@@ -310,12 +314,11 @@ export class PcbScene3dCopperFillMeshBuilder {
310
314
  new THREE.Float32BufferAttribute(positions, 3)
311
315
  )
312
316
  const mesh = new THREE.Mesh(geometry)
313
- const clippedMesh = PcbScene3dCopperFillAreaClipper.filter(
317
+ const clippedMesh = PcbScene3dCopperFillAreaClipper.filterPrepared(
314
318
  THREE,
315
319
  mesh,
316
- PcbScene3dCopperFillMeshBuilder.#loopSetsToFills(loopSets),
317
- (x, y) => ({ x, y }),
318
- false
320
+ coverageContext,
321
+ { beforeSourceIndex, allowedSourceIndexes }
319
322
  )
320
323
  if (!clippedMesh) {
321
324
  return []
@@ -323,77 +326,6 @@ export class PcbScene3dCopperFillMeshBuilder {
323
326
 
324
327
  return Array.from(clippedMesh.geometry.attributes.position.array)
325
328
  }
326
-
327
- /**
328
- * Converts normalized loop sets back to fill primitives for shared clipping.
329
- * @param {{ outer: number[][], holes: number[][][] }[]} loopSets Loop sets.
330
- * @returns {object[]}
331
- */
332
- static #loopSetsToFills(loopSets) {
333
- return loopSets.map((loopSet) => ({
334
- points: loopSet.outer.map((point) => ({
335
- x: point[0],
336
- y: point[1]
337
- })),
338
- holes: (loopSet.holes || []).map((hole) =>
339
- hole.map((point) => ({
340
- x: point[0],
341
- y: point[1]
342
- }))
343
- )
344
- }))
345
- }
346
-
347
- /**
348
- * Appends one fill island loop set to a shared position buffer.
349
- * @param {number[]} positions Position buffer.
350
- * @param {{ outer: number[][], holes: number[][][] }} loops Fill loop set.
351
- * @param {number} bottomZ Lower Z.
352
- * @param {number} topZ Upper Z.
353
- * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint Board normalizer.
354
- * @param {boolean} mirrorY Whether to mirror underside Y coordinates.
355
- * @param {{ surfaceOnly?: boolean }} options Mesh options.
356
- * @returns {void}
357
- */
358
- static #appendLoopSetPositions(
359
- positions,
360
- loops,
361
- bottomZ,
362
- topZ,
363
- normalizeBoardPoint,
364
- mirrorY,
365
- options
366
- ) {
367
- const outer = PcbScene3dCopperFillMeshBuilder.#normalizeLoop(
368
- loops.outer,
369
- normalizeBoardPoint,
370
- mirrorY
371
- )
372
- const holes = (loops.holes || [])
373
- .map((loop) =>
374
- PcbScene3dCopperFillMeshBuilder.#normalizeLoop(
375
- loop,
376
- normalizeBoardPoint,
377
- mirrorY
378
- )
379
- )
380
- .filter((loop) =>
381
- PcbScene3dCopperFillMeshBuilder.#isValidLoop(loop)
382
- )
383
-
384
- if (!PcbScene3dCopperFillMeshBuilder.#isValidLoop(outer)) {
385
- return
386
- }
387
-
388
- PcbScene3dCopperFillMeshBuilder.#appendNormalizedLoopSetPositions(
389
- positions,
390
- { outer, holes },
391
- bottomZ,
392
- topZ,
393
- options
394
- )
395
- }
396
-
397
329
  /**
398
330
  * Appends one already-normalized fill island loop set.
399
331
  * @param {number[]} positions Position buffer.
@@ -453,63 +385,6 @@ export class PcbScene3dCopperFillMeshBuilder {
453
385
  }
454
386
  }
455
387
 
456
- /**
457
- * Resolves normalized fill loop sets.
458
- * @param {object[]} fills Filled copper primitives.
459
- * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint Board normalizer.
460
- * @param {boolean} mirrorY Whether to mirror underside Y coordinates.
461
- * @returns {{ outer: number[][], holes: number[][][], bounds: object }[]}
462
- */
463
- static #resolveLoopSets(fills, normalizeBoardPoint, mirrorY) {
464
- return (fills || []).flatMap((fill) =>
465
- PcbAssemblyFillGeometryResolver.resolveAll(fill)
466
- .map((loops) =>
467
- PcbScene3dCopperFillMeshBuilder.#normalizeLoopSet(
468
- loops,
469
- normalizeBoardPoint,
470
- mirrorY
471
- )
472
- )
473
- .filter(Boolean)
474
- )
475
- }
476
-
477
- /**
478
- * Normalizes one fill loop set.
479
- * @param {{ outer: number[][], holes: number[][][] }} loops Source loops.
480
- * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint Board normalizer.
481
- * @param {boolean} mirrorY Whether to mirror underside Y coordinates.
482
- * @returns {{ outer: number[][], holes: number[][][], bounds: object } | null}
483
- */
484
- static #normalizeLoopSet(loops, normalizeBoardPoint, mirrorY) {
485
- const outer = PcbScene3dCopperFillMeshBuilder.#normalizeLoop(
486
- loops.outer,
487
- normalizeBoardPoint,
488
- mirrorY
489
- )
490
- if (!PcbScene3dCopperFillMeshBuilder.#isValidLoop(outer)) {
491
- return null
492
- }
493
-
494
- const holes = (loops.holes || [])
495
- .map((loop) =>
496
- PcbScene3dCopperFillMeshBuilder.#normalizeLoop(
497
- loop,
498
- normalizeBoardPoint,
499
- mirrorY
500
- )
501
- )
502
- .filter((loop) =>
503
- PcbScene3dCopperFillMeshBuilder.#isValidLoop(loop)
504
- )
505
-
506
- return {
507
- outer,
508
- holes,
509
- bounds: PcbScene3dCopperFillMeshBuilder.#loopBounds(outer)
510
- }
511
- }
512
-
513
388
  /**
514
389
  * Resolves later fill loops fully contained by an earlier loop set.
515
390
  * @param {{ outer: number[][], holes: number[][][], bounds: object }} loopSet Current loop set.
@@ -579,31 +454,6 @@ export class PcbScene3dCopperFillMeshBuilder {
579
454
  return options?.clipContainedFillOverlaps === true
580
455
  }
581
456
 
582
- /**
583
- * Normalizes one loop into local copper-side coordinates.
584
- * @param {number[][]} loop Source loop.
585
- * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint Board normalizer.
586
- * @param {boolean} mirrorY Whether to mirror underside Y coordinates.
587
- * @returns {number[][]}
588
- */
589
- static #normalizeLoop(loop, normalizeBoardPoint, mirrorY) {
590
- const points = []
591
- for (const point of loop || []) {
592
- const normalized = normalizeBoardPoint(
593
- Number(point?.[0]),
594
- Number(point?.[1])
595
- )
596
- const nextPoint = [
597
- Number(normalized?.x),
598
- mirrorY ? -Number(normalized?.y) : Number(normalized?.y)
599
- ]
600
- if (nextPoint.every(Number.isFinite)) {
601
- points.push(nextPoint)
602
- }
603
- }
604
- return PcbScene3dCopperFillMeshBuilder.#cleanLoop(points)
605
- }
606
-
607
457
  /**
608
458
  * Flattens loops for triangulation.
609
459
  * @param {number[][]} outer Outer loop.
@@ -711,59 +561,6 @@ export class PcbScene3dCopperFillMeshBuilder {
711
561
  positions.push(point[0], point[1], z)
712
562
  }
713
563
 
714
- /**
715
- * Removes invalid and duplicate loop points.
716
- * @param {number[][]} points Candidate points.
717
- * @returns {number[][]}
718
- */
719
- static #cleanLoop(points) {
720
- const loop = []
721
- for (const point of points || []) {
722
- const x = Number(point?.[0])
723
- const y = Number(point?.[1])
724
- if (!Number.isFinite(x) || !Number.isFinite(y)) {
725
- continue
726
- }
727
-
728
- const previous = loop[loop.length - 1]
729
- if (
730
- previous &&
731
- Math.abs(previous[0] - x) < 0.001 &&
732
- Math.abs(previous[1] - y) < 0.001
733
- ) {
734
- continue
735
- }
736
- loop.push([x, y])
737
- }
738
-
739
- const first = loop[0]
740
- const last = loop[loop.length - 1]
741
- if (
742
- first &&
743
- last &&
744
- Math.abs(first[0] - last[0]) < 0.001 &&
745
- Math.abs(first[1] - last[1]) < 0.001
746
- ) {
747
- loop.pop()
748
- }
749
-
750
- return loop
751
- }
752
-
753
- /**
754
- * Checks whether one loop has enough non-collinear area.
755
- * @param {number[][]} loop Candidate loop.
756
- * @returns {boolean}
757
- */
758
- static #isValidLoop(loop) {
759
- return (
760
- Array.isArray(loop) &&
761
- loop.length >= 3 &&
762
- Math.abs(PcbScene3dCopperFillMeshBuilder.#signedArea(loop)) >
763
- PcbScene3dCopperFillMeshBuilder.#AREA_EPSILON
764
- )
765
- }
766
-
767
564
  /**
768
565
  * Computes axis-aligned bounds for one loop.
769
566
  * @param {number[][]} loop Candidate loop.
@@ -897,19 +694,4 @@ export class PcbScene3dCopperFillMeshBuilder {
897
694
 
898
695
  return inside
899
696
  }
900
-
901
- /**
902
- * Computes signed loop area.
903
- * @param {number[][]} loop Candidate loop.
904
- * @returns {number}
905
- */
906
- static #signedArea(loop) {
907
- let area = 0
908
- for (let index = 0; index < loop.length; index += 1) {
909
- const current = loop[index]
910
- const next = loop[(index + 1) % loop.length]
911
- area += current[0] * next[1] - next[0] * current[1]
912
- }
913
- return area / 2
914
- }
915
697
  }
@@ -17,7 +17,7 @@ export class PcbScene3dCopperTextFactory {
17
17
  * @param {any[]} texts
18
18
  * @param {number} z
19
19
  * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint
20
- * @param {{ side?: 'top' | 'bottom', mirrorY?: boolean, materialColor?: number, materialProperties?: { materialKind?: 'basic' | 'standard', roughness?: number, metalness?: number, transparent?: boolean, opacity?: number, toneMapped?: boolean, fog?: boolean }, filterSide?: boolean, glyphYUp?: boolean, drillCutouts?: { x: number, y: number }[][] }} [options]
20
+ * @param {{ side?: 'top' | 'bottom', mirrorY?: boolean, materialColor?: number, materialProperties?: { materialKind?: 'basic' | 'standard', roughness?: number, metalness?: number, transparent?: boolean, opacity?: number, toneMapped?: boolean, fog?: boolean }, filterSide?: boolean, glyphYUp?: boolean, drillCutouts?: { x: number, y: number }[][], preparedPolygonCache?: Map }} [options]
21
21
  * @returns {any}
22
22
  */
23
23
  static buildGroup(THREE, texts, z, normalizeBoardPoint, options = {}) {
@@ -59,7 +59,8 @@ export class PcbScene3dCopperTextFactory {
59
59
  PcbScene3dCopperTextFactory.#filterDrillCutouts(
60
60
  THREE,
61
61
  geometry,
62
- options?.drillCutouts
62
+ options?.drillCutouts,
63
+ options?.preparedPolygonCache
63
64
  )
64
65
 
65
66
  if (
@@ -88,14 +89,21 @@ export class PcbScene3dCopperTextFactory {
88
89
  * @param {any} THREE
89
90
  * @param {any} geometry
90
91
  * @param {{ x: number, y: number }[][] | undefined} drillCutouts
92
+ * @param {Map | undefined} preparedPolygonCache Request-scoped prepared cache.
91
93
  * @returns {any}
92
94
  */
93
- static #filterDrillCutouts(THREE, geometry, drillCutouts) {
95
+ static #filterDrillCutouts(
96
+ THREE,
97
+ geometry,
98
+ drillCutouts,
99
+ preparedPolygonCache
100
+ ) {
94
101
  return Array.isArray(drillCutouts) && drillCutouts.length
95
102
  ? PcbScene3dCutoutGeometryFilter.filter(
96
103
  THREE,
97
104
  geometry,
98
- drillCutouts
105
+ drillCutouts,
106
+ { preparedPolygonCache }
99
107
  )
100
108
  : geometry
101
109
  }
@@ -21,14 +21,27 @@ export class PcbScene3dCutoutCircleDetector {
21
21
  }
22
22
 
23
23
  const center = PcbScene3dCutoutCircleDetector.#resolveCentroid(points)
24
- const radii = points.map((point) =>
25
- Math.hypot(point.x - center.x, point.y - center.y)
26
- )
27
- const radius =
28
- radii.reduce((sum, value) => sum + value, 0) / radii.length
29
- const maxError = Math.max(
30
- ...radii.map((value) => Math.abs(value - radius))
31
- )
24
+ let radiusSum = 0
25
+ for (let index = 0; index < points.length; index += 1) {
26
+ if (!(index in points)) {
27
+ continue
28
+ }
29
+
30
+ const point = points[index]
31
+ radiusSum += Math.hypot(point.x - center.x, point.y - center.y)
32
+ }
33
+ const radius = radiusSum / points.length
34
+ let maxError = -Infinity
35
+ for (let index = 0; index < points.length; index += 1) {
36
+ if (!(index in points)) {
37
+ maxError = Math.max(maxError, undefined)
38
+ continue
39
+ }
40
+
41
+ const point = points[index]
42
+ const value = Math.hypot(point.x - center.x, point.y - center.y)
43
+ maxError = Math.max(maxError, Math.abs(value - radius))
44
+ }
32
45
  const tolerance = Math.max(
33
46
  Number(epsilon || 0),
34
47
  radius * PcbScene3dCutoutCircleDetector.#MAX_RELATIVE_ERROR
@@ -68,17 +81,21 @@ export class PcbScene3dCutoutCircleDetector {
68
81
  * @returns {{ x: number, y: number }}
69
82
  */
70
83
  static #resolveCentroid(points) {
71
- const sum = points.reduce(
72
- (accumulator, point) => ({
73
- x: accumulator.x + Number(point.x || 0),
74
- y: accumulator.y + Number(point.y || 0)
75
- }),
76
- { x: 0, y: 0 }
77
- )
84
+ let totalX = 0
85
+ let totalY = 0
86
+ for (let index = 0; index < points.length; index += 1) {
87
+ if (!(index in points)) {
88
+ continue
89
+ }
90
+
91
+ const point = points[index]
92
+ totalX += Number(point.x || 0)
93
+ totalY += Number(point.y || 0)
94
+ }
78
95
 
79
96
  return {
80
- x: sum.x / points.length,
81
- y: sum.y / points.length
97
+ x: totalX / points.length,
98
+ y: totalY / points.length
82
99
  }
83
100
  }
84
101
  }