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
@@ -9,6 +9,8 @@ import { PcbScene3dCopperFillMeshBuilder } from './PcbScene3dCopperFillMeshBuild
9
9
  import { PcbScene3dMaskCoveredCopperSideGroupBuilder } from './PcbScene3dMaskCoveredCopperSideGroupBuilder.mjs'
10
10
  import { PcbScene3dCopperDrillCutoutBuilder } from './PcbScene3dCopperDrillCutoutBuilder.mjs'
11
11
  import { PcbScene3dCopperFillAreaClipper } from './PcbScene3dCopperFillAreaClipper.mjs'
12
+ import { PcbScene3dCopperFillCoverageContext } from './PcbScene3dCopperFillCoverageContext.mjs'
13
+ import { PcbScene3dCopperFillLoopSetResolver } from './PcbScene3dCopperFillLoopSetResolver.mjs'
12
14
 
13
15
  /**
14
16
  * Builds copper-detail meshes for the interactive 3D PCB scene.
@@ -284,6 +286,15 @@ export class PcbScene3dCopperFactory {
284
286
  occlusionCutouts,
285
287
  options = {}
286
288
  ) {
289
+ const fills = detail?.fills || []
290
+ const loopSets = PcbScene3dCopperFillLoopSetResolver.resolve(
291
+ fills,
292
+ normalizeBoardPoint,
293
+ mirrorY
294
+ )
295
+ const shouldUnionCoveredLayerPrimitives =
296
+ PcbScene3dCopperFactory.#shouldUnionCoveredLayerPrimitives(options)
297
+ let coverageContext = null
287
298
  let trackMesh = PcbScene3dCopperFactory.#buildTrackMesh(
288
299
  THREE,
289
300
  detail?.tracks || [],
@@ -303,28 +314,32 @@ export class PcbScene3dCopperFactory {
303
314
  occlusionCutouts
304
315
  )
305
316
  if (
306
- PcbScene3dCopperFactory.#shouldUnionCoveredLayerPrimitives(options)
317
+ shouldUnionCoveredLayerPrimitives &&
318
+ loopSets.length &&
319
+ (trackMesh || arcMesh)
307
320
  ) {
308
- trackMesh = PcbScene3dCopperFillAreaClipper.filter(
309
- THREE,
310
- trackMesh,
311
- detail?.fills || [],
312
- normalizeBoardPoint,
313
- mirrorY,
314
- { subdividePartialTriangles: false }
315
- )
316
- arcMesh = PcbScene3dCopperFillAreaClipper.filter(
317
- THREE,
318
- arcMesh,
319
- detail?.fills || [],
320
- normalizeBoardPoint,
321
- mirrorY,
322
- { subdividePartialTriangles: false }
323
- )
321
+ coverageContext =
322
+ PcbScene3dCopperFillCoverageContext.fromLoopSets(loopSets)
323
+ if (trackMesh) {
324
+ trackMesh = PcbScene3dCopperFillAreaClipper.filterPrepared(
325
+ THREE,
326
+ trackMesh,
327
+ coverageContext,
328
+ { subdividePartialTriangles: false }
329
+ )
330
+ }
331
+ if (arcMesh) {
332
+ arcMesh = PcbScene3dCopperFillAreaClipper.filterPrepared(
333
+ THREE,
334
+ arcMesh,
335
+ coverageContext,
336
+ { subdividePartialTriangles: false }
337
+ )
338
+ }
324
339
  }
325
340
  const fillMesh = PcbScene3dCopperFillMeshBuilder.build(
326
341
  THREE,
327
- detail?.fills || [],
342
+ fills,
328
343
  z,
329
344
  PcbScene3dCopperPrismBuilder.thicknessMil(),
330
345
  normalizeBoardPoint,
@@ -333,10 +348,9 @@ export class PcbScene3dCopperFactory {
333
348
  occlusionCutouts,
334
349
  {
335
350
  surfaceOnly: true,
336
- clipContainedFillOverlaps:
337
- PcbScene3dCopperFactory.#shouldUnionCoveredLayerPrimitives(
338
- options
339
- )
351
+ clipContainedFillOverlaps: shouldUnionCoveredLayerPrimitives,
352
+ loopSets,
353
+ coverageContext
340
354
  }
341
355
  )
342
356
  return PcbScene3dMaskCoveredCopperSideGroupBuilder.build(THREE, {
@@ -1,10 +1,11 @@
1
- import { PcbAssemblyFillGeometryResolver } from './PcbAssemblyFillGeometryResolver.mjs'
1
+ import { PcbScene3dCopperFillCoverageContext } from './PcbScene3dCopperFillCoverageContext.mjs'
2
+ import { PcbScene3dCopperFillLoopSetResolver } from './PcbScene3dCopperFillLoopSetResolver.mjs'
3
+ import { PcbScene3dTriangleVertexQueryBounds } from './PcbScene3dTriangleVertexQueryBounds.mjs'
2
4
 
3
5
  /**
4
6
  * Clips redundant copper relief where track geometry is already inside a fill.
5
7
  */
6
8
  export class PcbScene3dCopperFillAreaClipper {
7
- static #AREA_EPSILON = 0.001
8
9
  static #GEOMETRY_EPSILON = 0.001
9
10
  static #MAX_DEPTH = 10
10
11
  static #MAX_EDGE_LENGTH = 2
@@ -27,17 +28,44 @@ export class PcbScene3dCopperFillAreaClipper {
27
28
  mirrorY,
28
29
  options = {}
29
30
  ) {
30
- const areas = PcbScene3dCopperFillAreaClipper.#prepareAreas(
31
+ const loopSets = PcbScene3dCopperFillLoopSetResolver.resolve(
31
32
  fills,
32
33
  normalizeBoardPoint,
33
34
  mirrorY
34
35
  )
36
+ const coverageContext =
37
+ PcbScene3dCopperFillCoverageContext.fromLoopSets(loopSets)
38
+
39
+ return PcbScene3dCopperFillAreaClipper.filterPrepared(
40
+ THREE,
41
+ mesh,
42
+ coverageContext,
43
+ options
44
+ )
45
+ }
46
+
47
+ /**
48
+ * Removes triangles covered by a prepared fill-coverage context.
49
+ * @param {any} THREE Three.js namespace.
50
+ * @param {any | null} mesh Mesh to clip.
51
+ * @param {{ queryAreas: (bounds: object, target: object[], options?: object) => object[] }} coverageContext Prepared side-local fill coverage.
52
+ * @param {{ subdividePartialTriangles?: boolean, beforeSourceIndex?: number, allowedSourceIndexes?: Set<number> | number[] | null }} [options] Clipping and source-order options.
53
+ * @returns {any | null}
54
+ */
55
+ static filterPrepared(THREE, mesh, coverageContext, options = {}) {
56
+ if (!mesh || coverageContext?.areaCount === 0) {
57
+ return mesh
58
+ }
59
+
35
60
  const sourceGeometry = mesh?.geometry?.index
36
61
  ? mesh.geometry.toNonIndexed?.() || mesh.geometry
37
62
  : mesh?.geometry
38
63
  const position = sourceGeometry?.getAttribute?.('position')
39
64
 
40
- if (!mesh || !position?.count || !areas.length) {
65
+ if (
66
+ !position?.count ||
67
+ typeof coverageContext?.queryAreas !== 'function'
68
+ ) {
41
69
  return mesh
42
70
  }
43
71
 
@@ -50,7 +78,7 @@ export class PcbScene3dCopperFillAreaClipper {
50
78
  position,
51
79
  index
52
80
  ),
53
- areas,
81
+ coverageContext,
54
82
  0,
55
83
  state,
56
84
  options
@@ -75,113 +103,29 @@ export class PcbScene3dCopperFillAreaClipper {
75
103
  return mesh
76
104
  }
77
105
 
78
- /**
79
- * Resolves normalized fill areas with internal holes.
80
- * @param {object[]} fills Copper fill primitives.
81
- * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint Board normalizer.
82
- * @param {boolean} mirrorY Whether to mirror underside Y coordinates.
83
- * @returns {{ outer: { x: number, y: number }[], holes: { points: { x: number, y: number }[], bounds: object, segments: object[] }[], bounds: object, segments: object[] }[]}
84
- */
85
- static #prepareAreas(fills, normalizeBoardPoint, mirrorY) {
86
- return (fills || []).flatMap((fill) =>
87
- PcbAssemblyFillGeometryResolver.resolveAll(fill)
88
- .map((loops) =>
89
- PcbScene3dCopperFillAreaClipper.#prepareLoopSet(
90
- loops,
91
- normalizeBoardPoint,
92
- mirrorY
93
- )
94
- )
95
- .filter(Boolean)
96
- )
97
- }
98
-
99
- /**
100
- * Resolves one normalized fill loop set.
101
- * @param {{ outer?: any[], holes?: any[][] }} loops Fill loop set.
102
- * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint Board normalizer.
103
- * @param {boolean} mirrorY Whether to mirror underside Y coordinates.
104
- * @returns {object | null}
105
- */
106
- static #prepareLoopSet(loops, normalizeBoardPoint, mirrorY) {
107
- const outer = PcbScene3dCopperFillAreaClipper.#normalizeLoop(
108
- loops?.outer,
109
- normalizeBoardPoint,
110
- mirrorY
111
- )
112
-
113
- if (!PcbScene3dCopperFillAreaClipper.#isValidLoop(outer)) {
114
- return null
115
- }
116
-
117
- const holes = (loops?.holes || [])
118
- .map((hole) =>
119
- PcbScene3dCopperFillAreaClipper.#normalizeLoop(
120
- hole,
121
- normalizeBoardPoint,
122
- mirrorY
123
- )
124
- )
125
- .filter((hole) =>
126
- PcbScene3dCopperFillAreaClipper.#isValidLoop(hole)
127
- )
128
- .map((hole) => ({
129
- points: hole,
130
- bounds: PcbScene3dCopperFillAreaClipper.#resolveBounds(hole),
131
- segments: PcbScene3dCopperFillAreaClipper.#segments(hole)
132
- }))
133
-
134
- return {
135
- outer,
136
- holes,
137
- bounds: PcbScene3dCopperFillAreaClipper.#resolveBounds(outer),
138
- segments: PcbScene3dCopperFillAreaClipper.#segments(outer)
139
- }
140
- }
141
-
142
- /**
143
- * Normalizes one fill loop.
144
- * @param {any[]} loop Source loop points.
145
- * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint Board normalizer.
146
- * @param {boolean} mirrorY Whether to mirror underside Y coordinates.
147
- * @returns {{ x: number, y: number }[]}
148
- */
149
- static #normalizeLoop(loop, normalizeBoardPoint, mirrorY) {
150
- const points = []
151
- for (const point of loop || []) {
152
- const normalized = normalizeBoardPoint(
153
- Number(point?.x ?? point?.[0]),
154
- Number(point?.y ?? point?.[1])
155
- )
156
- const nextPoint = {
157
- x: Number(normalized?.x),
158
- y: mirrorY ? -Number(normalized?.y) : Number(normalized?.y)
159
- }
160
- if (Number.isFinite(nextPoint.x) && Number.isFinite(nextPoint.y)) {
161
- points.push(nextPoint)
162
- }
163
- }
164
- return PcbScene3dCopperFillAreaClipper.#cleanLoop(points)
165
- }
166
-
167
106
  /**
168
107
  * Appends one triangle after removing filled-area overlap.
169
108
  * @param {number[]} positions Output position buffer.
170
109
  * @param {{ x: number, y: number, z: number }[]} triangle Source triangle.
171
- * @param {object[]} areas Filled copper areas.
110
+ * @param {{ queryAreas: (bounds: object, target: object[], options?: object) => object[] }} coverageContext Prepared fill coverage.
172
111
  * @param {number} depth Subdivision depth.
173
112
  * @param {{ changed: boolean }} state Mutation state.
174
- * @param {{ subdividePartialTriangles?: boolean }} options Clipping options.
113
+ * @param {{ subdividePartialTriangles?: boolean, beforeSourceIndex?: number, allowedSourceIndexes?: Set<number> | number[] | null }} options Clipping options.
175
114
  * @returns {void}
176
115
  */
177
116
  static #appendFilteredTriangle(
178
117
  positions,
179
118
  triangle,
180
- areas,
119
+ coverageContext,
181
120
  depth,
182
121
  state,
183
122
  options
184
123
  ) {
124
+ const areas = coverageContext.queryAreas(
125
+ PcbScene3dCopperFillAreaClipper.#resolveBounds(triangle),
126
+ [],
127
+ options
128
+ )
185
129
  const coverage =
186
130
  PcbScene3dCopperFillAreaClipper.#resolveTriangleCoverage(
187
131
  triangle,
@@ -233,7 +177,7 @@ export class PcbScene3dCopperFillAreaClipper {
233
177
  PcbScene3dCopperFillAreaClipper.#appendFilteredTriangle(
234
178
  positions,
235
179
  child,
236
- areas,
180
+ coverageContext,
237
181
  depth + 1,
238
182
  state,
239
183
  options
@@ -258,12 +202,19 @@ export class PcbScene3dCopperFillAreaClipper {
258
202
  */
259
203
  static #resolveTriangleCoverage(triangle, areas) {
260
204
  const samples = [
261
- ...triangle,
205
+ triangle[0],
206
+ triangle[1],
207
+ triangle[2],
262
208
  PcbScene3dCopperFillAreaClipper.#centroid(triangle)
263
209
  ]
264
- const coveredSamples = samples.filter((point) =>
265
- PcbScene3dCopperFillAreaClipper.#isPointInAnyArea(point, areas)
266
- ).length
210
+ let coveredSamples = 0
211
+ for (const point of samples) {
212
+ if (
213
+ PcbScene3dCopperFillAreaClipper.#isPointInAnyArea(point, areas)
214
+ ) {
215
+ coveredSamples += 1
216
+ }
217
+ }
267
218
  const crosses =
268
219
  PcbScene3dCopperFillAreaClipper.#triangleCrossesAnyBoundary(
269
220
  triangle,
@@ -288,15 +239,19 @@ export class PcbScene3dCopperFillAreaClipper {
288
239
  * @returns {boolean}
289
240
  */
290
241
  static #isPointInAnyArea(point, areas) {
291
- return areas.some((area) =>
292
- PcbScene3dCopperFillAreaClipper.#isPointInArea(point, area)
293
- )
242
+ for (const area of areas) {
243
+ if (PcbScene3dCopperFillAreaClipper.#isPointInArea(point, area)) {
244
+ return true
245
+ }
246
+ }
247
+
248
+ return false
294
249
  }
295
250
 
296
251
  /**
297
252
  * Returns true when one point is inside a fill outer and outside holes.
298
253
  * @param {{ x: number, y: number }} point Candidate point.
299
- * @param {{ outer: { x: number, y: number }[], holes: object[], bounds: object }} area Filled copper area.
254
+ * @param {{ outer: object, holes: object[], bounds: object }} area Filled copper area.
300
255
  * @returns {boolean}
301
256
  */
302
257
  static #isPointInArea(point, area) {
@@ -305,7 +260,10 @@ export class PcbScene3dCopperFillAreaClipper {
305
260
  area.bounds,
306
261
  point
307
262
  ) ||
308
- !PcbScene3dCopperFillAreaClipper.#pointInPolygon(point, area.outer)
263
+ !PcbScene3dCopperFillAreaClipper.#pointInPreparedPolygon(
264
+ point,
265
+ area.outer
266
+ )
309
267
  ) {
310
268
  return false
311
269
  }
@@ -316,9 +274,9 @@ export class PcbScene3dCopperFillAreaClipper {
316
274
  hole.bounds,
317
275
  point
318
276
  ) &&
319
- PcbScene3dCopperFillAreaClipper.#pointInPolygon(
277
+ PcbScene3dCopperFillAreaClipper.#pointInPreparedPolygon(
320
278
  point,
321
- hole.points
279
+ hole
322
280
  )
323
281
  )
324
282
  }
@@ -341,7 +299,8 @@ export class PcbScene3dCopperFillAreaClipper {
341
299
  ) &&
342
300
  PcbScene3dCopperFillAreaClipper.#triangleCrossesAreaBoundary(
343
301
  triangle,
344
- area
302
+ area,
303
+ triangleBounds
345
304
  )
346
305
  )
347
306
  }
@@ -349,15 +308,16 @@ export class PcbScene3dCopperFillAreaClipper {
349
308
  /**
350
309
  * Returns true when a triangle crosses one filled area's boundaries.
351
310
  * @param {{ x: number, y: number }[]} triangle Triangle points.
352
- * @param {{ outer: { x: number, y: number }[], holes: object[], segments: object[] }} area Filled copper area.
311
+ * @param {{ outer: object, holes: object[] }} area Filled copper area.
312
+ * @param {{ minX: number, maxX: number, minY: number, maxY: number }} triangleBounds Triangle bounds.
353
313
  * @returns {boolean}
354
314
  */
355
- static #triangleCrossesAreaBoundary(triangle, area) {
356
- return [area, ...area.holes].some((loop) =>
315
+ static #triangleCrossesAreaBoundary(triangle, area, triangleBounds) {
316
+ return [area.outer, ...area.holes].some((loop) =>
357
317
  PcbScene3dCopperFillAreaClipper.#triangleCrossesLoopBoundary(
358
318
  triangle,
359
- loop.points || loop.outer,
360
- loop.segments
319
+ loop,
320
+ triangleBounds
361
321
  )
362
322
  )
363
323
  }
@@ -365,11 +325,11 @@ export class PcbScene3dCopperFillAreaClipper {
365
325
  /**
366
326
  * Returns true when triangle edges cross one loop.
367
327
  * @param {{ x: number, y: number }[]} triangle Triangle points.
368
- * @param {{ x: number, y: number }[]} loop Loop points.
369
- * @param {{ start: object, end: object, bounds: object }[]} segments Loop segments.
328
+ * @param {{ points: { x: number, y: number }[], bounds: object, querySegments: (bounds: object, target: object[]) => object[], queryVertices: (bounds: object, target: object[]) => object[] }} loop Prepared loop.
329
+ * @param {{ minX: number, maxX: number, minY: number, maxY: number }} triangleBounds Triangle bounds.
370
330
  * @returns {boolean}
371
331
  */
372
- static #triangleCrossesLoopBoundary(triangle, loop, segments) {
332
+ static #triangleCrossesLoopBoundary(triangle, loop, triangleBounds) {
373
333
  for (let index = 0; index < triangle.length; index += 1) {
374
334
  const start = triangle[index]
375
335
  const end = triangle[(index + 1) % triangle.length]
@@ -378,25 +338,37 @@ export class PcbScene3dCopperFillAreaClipper {
378
338
  end
379
339
  ])
380
340
  if (
381
- segments.some(
382
- (segment) =>
383
- PcbScene3dCopperFillAreaClipper.#boundsOverlap(
384
- edgeBounds,
385
- segment.bounds
386
- ) &&
387
- PcbScene3dCopperFillAreaClipper.#segmentsIntersect(
388
- start,
389
- end,
390
- segment.start,
391
- segment.end
392
- )
393
- )
341
+ loop
342
+ .querySegments(edgeBounds, [])
343
+ .some(
344
+ (segment) =>
345
+ PcbScene3dCopperFillAreaClipper.#boundsOverlap(
346
+ edgeBounds,
347
+ segment.bounds
348
+ ) &&
349
+ PcbScene3dCopperFillAreaClipper.#segmentsIntersect(
350
+ start,
351
+ end,
352
+ segment.start,
353
+ segment.end
354
+ )
355
+ )
394
356
  ) {
395
357
  return true
396
358
  }
397
359
  }
398
360
 
399
- return loop.some((point) =>
361
+ const queryBounds = PcbScene3dTriangleVertexQueryBounds.resolve(
362
+ triangle,
363
+ triangleBounds,
364
+ loop.bounds,
365
+ PcbScene3dCopperFillAreaClipper.#GEOMETRY_EPSILON
366
+ )
367
+ const vertices = queryBounds
368
+ ? loop.queryVertices(queryBounds, [])
369
+ : loop.points
370
+
371
+ return vertices.some((point) =>
400
372
  PcbScene3dCopperFillAreaClipper.#isPointInsideTriangle(
401
373
  point,
402
374
  triangle
@@ -418,25 +390,6 @@ export class PcbScene3dCopperFillAreaClipper {
418
390
  }))
419
391
  }
420
392
 
421
- /**
422
- * Builds loop segments with cached bounds.
423
- * @param {{ x: number, y: number }[]} points Loop points.
424
- * @returns {{ start: object, end: object, bounds: object }[]}
425
- */
426
- static #segments(points) {
427
- return points.map((start, index) => {
428
- const end = points[(index + 1) % points.length]
429
- return {
430
- start,
431
- end,
432
- bounds: PcbScene3dCopperFillAreaClipper.#resolveBounds([
433
- start,
434
- end
435
- ])
436
- }
437
- })
438
- }
439
-
440
393
  /**
441
394
  * Subdivides one triangle into four children.
442
395
  * @param {{ x: number, y: number, z: number }[]} triangle Source triangle.
@@ -518,20 +471,26 @@ export class PcbScene3dCopperFillAreaClipper {
518
471
  }
519
472
 
520
473
  /**
521
- * Returns true when one point is inside a polygon.
474
+ * Replays the legacy horizontal-ray expression on prepared edge candidates.
522
475
  * @param {{ x: number, y: number }} point Candidate point.
523
- * @param {{ x: number, y: number }[]} polygon Polygon points.
476
+ * @param {{ bounds: object, querySegments: (bounds: object, target: object[]) => object[] }} polygon Prepared polygon.
524
477
  * @returns {boolean}
525
478
  */
526
- static #pointInPolygon(point, polygon) {
479
+ static #pointInPreparedPolygon(point, polygon) {
480
+ const segments = polygon.querySegments(
481
+ {
482
+ minX: point.x,
483
+ maxX: polygon.bounds.maxX,
484
+ minY: point.y,
485
+ maxY: point.y
486
+ },
487
+ []
488
+ )
527
489
  let inside = false
528
- for (
529
- let index = 0, previousIndex = polygon.length - 1;
530
- index < polygon.length;
531
- previousIndex = index, index += 1
532
- ) {
533
- const current = polygon[index]
534
- const previous = polygon[previousIndex]
490
+
491
+ for (const segment of segments) {
492
+ const current = segment.end
493
+ const previous = segment.start
535
494
  const intersects =
536
495
  current.y > point.y !== previous.y > point.y &&
537
496
  point.x <
@@ -552,19 +511,22 @@ export class PcbScene3dCopperFillAreaClipper {
552
511
  * @returns {boolean}
553
512
  */
554
513
  static #isPointInsideTriangle(point, triangle) {
555
- const signs = triangle.map((current, index) => {
514
+ let hasNegative = false
515
+ let hasPositive = false
516
+
517
+ for (let index = 0; index < triangle.length; index += 1) {
518
+ const current = triangle[index]
556
519
  const next = triangle[(index + 1) % triangle.length]
557
- return (
520
+ const sign =
558
521
  (point.x - next.x) * (current.y - next.y) -
559
522
  (current.x - next.x) * (point.y - next.y)
560
- )
561
- })
562
- const hasNegative = signs.some(
563
- (sign) => sign < -PcbScene3dCopperFillAreaClipper.#GEOMETRY_EPSILON
564
- )
565
- const hasPositive = signs.some(
566
- (sign) => sign > PcbScene3dCopperFillAreaClipper.#GEOMETRY_EPSILON
567
- )
523
+
524
+ hasNegative ||=
525
+ sign < -PcbScene3dCopperFillAreaClipper.#GEOMETRY_EPSILON
526
+ hasPositive ||=
527
+ sign > PcbScene3dCopperFillAreaClipper.#GEOMETRY_EPSILON
528
+ }
529
+
568
530
  return !(hasNegative && hasPositive)
569
531
  }
570
532
 
@@ -599,70 +561,6 @@ export class PcbScene3dCopperFillAreaClipper {
599
561
  return (b.x - a.x) * (point.y - a.y) - (b.y - a.y) * (point.x - a.x)
600
562
  }
601
563
 
602
- /**
603
- * Removes duplicate and closing points.
604
- * @param {{ x: number, y: number }[]} points Candidate points.
605
- * @returns {{ x: number, y: number }[]}
606
- */
607
- static #cleanLoop(points) {
608
- const output = []
609
- for (const point of points || []) {
610
- const previous = output[output.length - 1]
611
- if (
612
- previous &&
613
- Math.abs(previous.x - point.x) <
614
- PcbScene3dCopperFillAreaClipper.#GEOMETRY_EPSILON &&
615
- Math.abs(previous.y - point.y) <
616
- PcbScene3dCopperFillAreaClipper.#GEOMETRY_EPSILON
617
- ) {
618
- continue
619
- }
620
- output.push(point)
621
- }
622
-
623
- const first = output[0]
624
- const last = output[output.length - 1]
625
- if (
626
- first &&
627
- last &&
628
- Math.abs(first.x - last.x) <
629
- PcbScene3dCopperFillAreaClipper.#GEOMETRY_EPSILON &&
630
- Math.abs(first.y - last.y) <
631
- PcbScene3dCopperFillAreaClipper.#GEOMETRY_EPSILON
632
- ) {
633
- output.pop()
634
- }
635
- return output
636
- }
637
-
638
- /**
639
- * Checks whether one loop has enough area.
640
- * @param {{ x: number, y: number }[]} loop Candidate loop.
641
- * @returns {boolean}
642
- */
643
- static #isValidLoop(loop) {
644
- return (
645
- loop.length >= 3 &&
646
- Math.abs(PcbScene3dCopperFillAreaClipper.#signedArea(loop)) >
647
- PcbScene3dCopperFillAreaClipper.#AREA_EPSILON
648
- )
649
- }
650
-
651
- /**
652
- * Computes signed loop area.
653
- * @param {{ x: number, y: number }[]} loop Candidate loop.
654
- * @returns {number}
655
- */
656
- static #signedArea(loop) {
657
- let area = 0
658
- for (let index = 0; index < loop.length; index += 1) {
659
- const current = loop[index]
660
- const next = loop[(index + 1) % loop.length]
661
- area += current.x * next.y - next.x * current.y
662
- }
663
- return area / 2
664
- }
665
-
666
564
  /**
667
565
  * Resolves bounds for points.
668
566
  * @param {{ x: number, y: number }[]} points Candidate points.