pcb-scene3d-viewer 1.1.48 → 1.1.49

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.
@@ -0,0 +1,726 @@
1
+ import { PcbAssemblyFillGeometryResolver } from './PcbAssemblyFillGeometryResolver.mjs'
2
+
3
+ /**
4
+ * Clips redundant copper relief where track geometry is already inside a fill.
5
+ */
6
+ export class PcbScene3dCopperFillAreaClipper {
7
+ static #AREA_EPSILON = 0.001
8
+ static #GEOMETRY_EPSILON = 0.001
9
+ static #MAX_DEPTH = 10
10
+ static #MAX_EDGE_LENGTH = 2
11
+
12
+ /**
13
+ * Removes mesh triangles covered by filled copper areas.
14
+ * @param {any} THREE Three.js namespace.
15
+ * @param {any | null} mesh Mesh to clip.
16
+ * @param {object[]} fills Copper fill primitives.
17
+ * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint Board normalizer.
18
+ * @param {boolean} mirrorY Whether to mirror underside Y coordinates.
19
+ * @param {{ subdividePartialTriangles?: boolean }} [options] Clipping options.
20
+ * @returns {any | null}
21
+ */
22
+ static filter(
23
+ THREE,
24
+ mesh,
25
+ fills,
26
+ normalizeBoardPoint,
27
+ mirrorY,
28
+ options = {}
29
+ ) {
30
+ const areas = PcbScene3dCopperFillAreaClipper.#prepareAreas(
31
+ fills,
32
+ normalizeBoardPoint,
33
+ mirrorY
34
+ )
35
+ const sourceGeometry = mesh?.geometry?.index
36
+ ? mesh.geometry.toNonIndexed?.() || mesh.geometry
37
+ : mesh?.geometry
38
+ const position = sourceGeometry?.getAttribute?.('position')
39
+
40
+ if (!mesh || !position?.count || !areas.length) {
41
+ return mesh
42
+ }
43
+
44
+ const positions = []
45
+ const state = { changed: false }
46
+ for (let index = 0; index < position.count; index += 3) {
47
+ PcbScene3dCopperFillAreaClipper.#appendFilteredTriangle(
48
+ positions,
49
+ PcbScene3dCopperFillAreaClipper.#resolveTriangle(
50
+ position,
51
+ index
52
+ ),
53
+ areas,
54
+ 0,
55
+ state,
56
+ options
57
+ )
58
+ }
59
+
60
+ if (!state.changed) {
61
+ return mesh
62
+ }
63
+
64
+ if (!positions.length) {
65
+ return null
66
+ }
67
+
68
+ const geometry = new THREE.BufferGeometry()
69
+ geometry.setAttribute(
70
+ 'position',
71
+ new THREE.Float32BufferAttribute(positions, 3)
72
+ )
73
+ geometry.computeVertexNormals?.()
74
+ mesh.geometry = geometry
75
+ return mesh
76
+ }
77
+
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
+ /**
168
+ * Appends one triangle after removing filled-area overlap.
169
+ * @param {number[]} positions Output position buffer.
170
+ * @param {{ x: number, y: number, z: number }[]} triangle Source triangle.
171
+ * @param {object[]} areas Filled copper areas.
172
+ * @param {number} depth Subdivision depth.
173
+ * @param {{ changed: boolean }} state Mutation state.
174
+ * @param {{ subdividePartialTriangles?: boolean }} options Clipping options.
175
+ * @returns {void}
176
+ */
177
+ static #appendFilteredTriangle(
178
+ positions,
179
+ triangle,
180
+ areas,
181
+ depth,
182
+ state,
183
+ options
184
+ ) {
185
+ const coverage =
186
+ PcbScene3dCopperFillAreaClipper.#resolveTriangleCoverage(
187
+ triangle,
188
+ areas
189
+ )
190
+
191
+ if (coverage === 'none') {
192
+ PcbScene3dCopperFillAreaClipper.#appendTriangle(positions, triangle)
193
+ return
194
+ }
195
+
196
+ if (
197
+ coverage === 'partial' &&
198
+ !PcbScene3dCopperFillAreaClipper.#shouldSubdividePartialTriangles(
199
+ options
200
+ )
201
+ ) {
202
+ PcbScene3dCopperFillAreaClipper.#appendTriangle(positions, triangle)
203
+ return
204
+ }
205
+
206
+ state.changed = true
207
+ if (coverage === 'full') {
208
+ return
209
+ }
210
+
211
+ if (
212
+ depth >= PcbScene3dCopperFillAreaClipper.#MAX_DEPTH ||
213
+ PcbScene3dCopperFillAreaClipper.#maxEdgeLengthSquared(triangle) <=
214
+ PcbScene3dCopperFillAreaClipper.#MAX_EDGE_LENGTH ** 2
215
+ ) {
216
+ if (
217
+ !PcbScene3dCopperFillAreaClipper.#isPointInAnyArea(
218
+ PcbScene3dCopperFillAreaClipper.#centroid(triangle),
219
+ areas
220
+ )
221
+ ) {
222
+ PcbScene3dCopperFillAreaClipper.#appendTriangle(
223
+ positions,
224
+ triangle
225
+ )
226
+ }
227
+ return
228
+ }
229
+
230
+ for (const child of PcbScene3dCopperFillAreaClipper.#subdivideTriangle(
231
+ triangle
232
+ )) {
233
+ PcbScene3dCopperFillAreaClipper.#appendFilteredTriangle(
234
+ positions,
235
+ child,
236
+ areas,
237
+ depth + 1,
238
+ state,
239
+ options
240
+ )
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Checks whether partial triangles should be recursively clipped.
246
+ * @param {{ subdividePartialTriangles?: boolean } | undefined} options Clipping options.
247
+ * @returns {boolean}
248
+ */
249
+ static #shouldSubdividePartialTriangles(options) {
250
+ return options?.subdividePartialTriangles !== false
251
+ }
252
+
253
+ /**
254
+ * Resolves whether a triangle is outside, inside, or crossing filled areas.
255
+ * @param {{ x: number, y: number }[]} triangle Triangle points.
256
+ * @param {object[]} areas Filled copper areas.
257
+ * @returns {'none' | 'partial' | 'full'}
258
+ */
259
+ static #resolveTriangleCoverage(triangle, areas) {
260
+ const samples = [
261
+ ...triangle,
262
+ PcbScene3dCopperFillAreaClipper.#centroid(triangle)
263
+ ]
264
+ const coveredSamples = samples.filter((point) =>
265
+ PcbScene3dCopperFillAreaClipper.#isPointInAnyArea(point, areas)
266
+ ).length
267
+ const crosses =
268
+ PcbScene3dCopperFillAreaClipper.#triangleCrossesAnyBoundary(
269
+ triangle,
270
+ areas
271
+ )
272
+
273
+ if (coveredSamples === 0 && !crosses) {
274
+ return 'none'
275
+ }
276
+
277
+ if (coveredSamples === samples.length && !crosses) {
278
+ return 'full'
279
+ }
280
+
281
+ return 'partial'
282
+ }
283
+
284
+ /**
285
+ * Returns true when one point is in any filled area.
286
+ * @param {{ x: number, y: number }} point Candidate point.
287
+ * @param {object[]} areas Filled copper areas.
288
+ * @returns {boolean}
289
+ */
290
+ static #isPointInAnyArea(point, areas) {
291
+ return areas.some((area) =>
292
+ PcbScene3dCopperFillAreaClipper.#isPointInArea(point, area)
293
+ )
294
+ }
295
+
296
+ /**
297
+ * Returns true when one point is inside a fill outer and outside holes.
298
+ * @param {{ x: number, y: number }} point Candidate point.
299
+ * @param {{ outer: { x: number, y: number }[], holes: object[], bounds: object }} area Filled copper area.
300
+ * @returns {boolean}
301
+ */
302
+ static #isPointInArea(point, area) {
303
+ if (
304
+ !PcbScene3dCopperFillAreaClipper.#boundsContainPoint(
305
+ area.bounds,
306
+ point
307
+ ) ||
308
+ !PcbScene3dCopperFillAreaClipper.#pointInPolygon(point, area.outer)
309
+ ) {
310
+ return false
311
+ }
312
+
313
+ return !area.holes.some(
314
+ (hole) =>
315
+ PcbScene3dCopperFillAreaClipper.#boundsContainPoint(
316
+ hole.bounds,
317
+ point
318
+ ) &&
319
+ PcbScene3dCopperFillAreaClipper.#pointInPolygon(
320
+ point,
321
+ hole.points
322
+ )
323
+ )
324
+ }
325
+
326
+ /**
327
+ * Returns true when a triangle crosses a fill or hole boundary.
328
+ * @param {{ x: number, y: number }[]} triangle Triangle points.
329
+ * @param {object[]} areas Filled copper areas.
330
+ * @returns {boolean}
331
+ */
332
+ static #triangleCrossesAnyBoundary(triangle, areas) {
333
+ const triangleBounds =
334
+ PcbScene3dCopperFillAreaClipper.#resolveBounds(triangle)
335
+
336
+ return areas.some(
337
+ (area) =>
338
+ PcbScene3dCopperFillAreaClipper.#boundsOverlap(
339
+ triangleBounds,
340
+ area.bounds
341
+ ) &&
342
+ PcbScene3dCopperFillAreaClipper.#triangleCrossesAreaBoundary(
343
+ triangle,
344
+ area
345
+ )
346
+ )
347
+ }
348
+
349
+ /**
350
+ * Returns true when a triangle crosses one filled area's boundaries.
351
+ * @param {{ x: number, y: number }[]} triangle Triangle points.
352
+ * @param {{ outer: { x: number, y: number }[], holes: object[], segments: object[] }} area Filled copper area.
353
+ * @returns {boolean}
354
+ */
355
+ static #triangleCrossesAreaBoundary(triangle, area) {
356
+ return [area, ...area.holes].some((loop) =>
357
+ PcbScene3dCopperFillAreaClipper.#triangleCrossesLoopBoundary(
358
+ triangle,
359
+ loop.points || loop.outer,
360
+ loop.segments
361
+ )
362
+ )
363
+ }
364
+
365
+ /**
366
+ * Returns true when triangle edges cross one loop.
367
+ * @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.
370
+ * @returns {boolean}
371
+ */
372
+ static #triangleCrossesLoopBoundary(triangle, loop, segments) {
373
+ for (let index = 0; index < triangle.length; index += 1) {
374
+ const start = triangle[index]
375
+ const end = triangle[(index + 1) % triangle.length]
376
+ const edgeBounds = PcbScene3dCopperFillAreaClipper.#resolveBounds([
377
+ start,
378
+ end
379
+ ])
380
+ 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
+ )
394
+ ) {
395
+ return true
396
+ }
397
+ }
398
+
399
+ return loop.some((point) =>
400
+ PcbScene3dCopperFillAreaClipper.#isPointInsideTriangle(
401
+ point,
402
+ triangle
403
+ )
404
+ )
405
+ }
406
+
407
+ /**
408
+ * Resolves one triangle from a Three position attribute.
409
+ * @param {any} position Position attribute.
410
+ * @param {number} startIndex Triangle start index.
411
+ * @returns {{ x: number, y: number, z: number }[]}
412
+ */
413
+ static #resolveTriangle(position, startIndex) {
414
+ return [0, 1, 2].map((offset) => ({
415
+ x: Number(position.getX(startIndex + offset)),
416
+ y: Number(position.getY(startIndex + offset)),
417
+ z: Number(position.getZ(startIndex + offset))
418
+ }))
419
+ }
420
+
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
+ /**
441
+ * Subdivides one triangle into four children.
442
+ * @param {{ x: number, y: number, z: number }[]} triangle Source triangle.
443
+ * @returns {{ x: number, y: number, z: number }[][]}
444
+ */
445
+ static #subdivideTriangle(triangle) {
446
+ const [a, b, c] = triangle
447
+ const ab = PcbScene3dCopperFillAreaClipper.#midpoint(a, b)
448
+ const bc = PcbScene3dCopperFillAreaClipper.#midpoint(b, c)
449
+ const ca = PcbScene3dCopperFillAreaClipper.#midpoint(c, a)
450
+
451
+ return [
452
+ [a, ab, ca],
453
+ [ab, b, bc],
454
+ [ca, bc, c],
455
+ [ab, bc, ca]
456
+ ]
457
+ }
458
+
459
+ /**
460
+ * Resolves a midpoint.
461
+ * @param {{ x: number, y: number, z: number }} a First point.
462
+ * @param {{ x: number, y: number, z: number }} b Second point.
463
+ * @returns {{ x: number, y: number, z: number }}
464
+ */
465
+ static #midpoint(a, b) {
466
+ return {
467
+ x: (a.x + b.x) / 2,
468
+ y: (a.y + b.y) / 2,
469
+ z: (a.z + b.z) / 2
470
+ }
471
+ }
472
+
473
+ /**
474
+ * Resolves triangle centroid.
475
+ * @param {{ x: number, y: number, z?: number }[]} triangle Triangle points.
476
+ * @returns {{ x: number, y: number, z: number }}
477
+ */
478
+ static #centroid(triangle) {
479
+ return {
480
+ x: (triangle[0].x + triangle[1].x + triangle[2].x) / 3,
481
+ y: (triangle[0].y + triangle[1].y + triangle[2].y) / 3,
482
+ z:
483
+ (Number(triangle[0].z || 0) +
484
+ Number(triangle[1].z || 0) +
485
+ Number(triangle[2].z || 0)) /
486
+ 3
487
+ }
488
+ }
489
+
490
+ /**
491
+ * Appends one triangle to a position buffer.
492
+ * @param {number[]} positions Output position buffer.
493
+ * @param {{ x: number, y: number, z: number }[]} triangle Triangle points.
494
+ * @returns {void}
495
+ */
496
+ static #appendTriangle(positions, triangle) {
497
+ for (const point of triangle) {
498
+ positions.push(point.x, point.y, point.z)
499
+ }
500
+ }
501
+
502
+ /**
503
+ * Resolves maximum squared edge length.
504
+ * @param {{ x: number, y: number }[]} triangle Triangle points.
505
+ * @returns {number}
506
+ */
507
+ static #maxEdgeLengthSquared(triangle) {
508
+ let maximum = 0
509
+ for (let index = 0; index < triangle.length; index += 1) {
510
+ const start = triangle[index]
511
+ const end = triangle[(index + 1) % triangle.length]
512
+ maximum = Math.max(
513
+ maximum,
514
+ (end.x - start.x) ** 2 + (end.y - start.y) ** 2
515
+ )
516
+ }
517
+ return maximum
518
+ }
519
+
520
+ /**
521
+ * Returns true when one point is inside a polygon.
522
+ * @param {{ x: number, y: number }} point Candidate point.
523
+ * @param {{ x: number, y: number }[]} polygon Polygon points.
524
+ * @returns {boolean}
525
+ */
526
+ static #pointInPolygon(point, polygon) {
527
+ 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]
535
+ const intersects =
536
+ current.y > point.y !== previous.y > point.y &&
537
+ point.x <
538
+ ((previous.x - current.x) * (point.y - current.y)) /
539
+ (previous.y - current.y) +
540
+ current.x
541
+ if (intersects) {
542
+ inside = !inside
543
+ }
544
+ }
545
+ return inside
546
+ }
547
+
548
+ /**
549
+ * Returns true when one point is inside or on a triangle.
550
+ * @param {{ x: number, y: number }} point Candidate point.
551
+ * @param {{ x: number, y: number }[]} triangle Triangle points.
552
+ * @returns {boolean}
553
+ */
554
+ static #isPointInsideTriangle(point, triangle) {
555
+ const signs = triangle.map((current, index) => {
556
+ const next = triangle[(index + 1) % triangle.length]
557
+ return (
558
+ (point.x - next.x) * (current.y - next.y) -
559
+ (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
+ )
568
+ return !(hasNegative && hasPositive)
569
+ }
570
+
571
+ /**
572
+ * Returns true when two segments intersect.
573
+ * @param {{ x: number, y: number }} a First segment start.
574
+ * @param {{ x: number, y: number }} b First segment end.
575
+ * @param {{ x: number, y: number }} c Second segment start.
576
+ * @param {{ x: number, y: number }} d Second segment end.
577
+ * @returns {boolean}
578
+ */
579
+ static #segmentsIntersect(a, b, c, d) {
580
+ const abC = PcbScene3dCopperFillAreaClipper.#orientation(a, b, c)
581
+ const abD = PcbScene3dCopperFillAreaClipper.#orientation(a, b, d)
582
+ const cdA = PcbScene3dCopperFillAreaClipper.#orientation(c, d, a)
583
+ const cdB = PcbScene3dCopperFillAreaClipper.#orientation(c, d, b)
584
+
585
+ return (
586
+ abC * abD <= PcbScene3dCopperFillAreaClipper.#GEOMETRY_EPSILON &&
587
+ cdA * cdB <= PcbScene3dCopperFillAreaClipper.#GEOMETRY_EPSILON
588
+ )
589
+ }
590
+
591
+ /**
592
+ * Resolves oriented segment side.
593
+ * @param {{ x: number, y: number }} a Segment start.
594
+ * @param {{ x: number, y: number }} b Segment end.
595
+ * @param {{ x: number, y: number }} point Candidate point.
596
+ * @returns {number}
597
+ */
598
+ static #orientation(a, b, point) {
599
+ return (b.x - a.x) * (point.y - a.y) - (b.y - a.y) * (point.x - a.x)
600
+ }
601
+
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
+ /**
667
+ * Resolves bounds for points.
668
+ * @param {{ x: number, y: number }[]} points Candidate points.
669
+ * @returns {{ minX: number, maxX: number, minY: number, maxY: number }}
670
+ */
671
+ static #resolveBounds(points) {
672
+ return points.reduce(
673
+ (bounds, point) => ({
674
+ minX: Math.min(bounds.minX, point.x),
675
+ maxX: Math.max(bounds.maxX, point.x),
676
+ minY: Math.min(bounds.minY, point.y),
677
+ maxY: Math.max(bounds.maxY, point.y)
678
+ }),
679
+ { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity }
680
+ )
681
+ }
682
+
683
+ /**
684
+ * Returns true when bounds include one point.
685
+ * @param {{ minX: number, maxX: number, minY: number, maxY: number }} bounds Bounds.
686
+ * @param {{ x: number, y: number }} point Candidate point.
687
+ * @returns {boolean}
688
+ */
689
+ static #boundsContainPoint(bounds, point) {
690
+ return (
691
+ point.x >=
692
+ bounds.minX -
693
+ PcbScene3dCopperFillAreaClipper.#GEOMETRY_EPSILON &&
694
+ point.x <=
695
+ bounds.maxX +
696
+ PcbScene3dCopperFillAreaClipper.#GEOMETRY_EPSILON &&
697
+ point.y >=
698
+ bounds.minY -
699
+ PcbScene3dCopperFillAreaClipper.#GEOMETRY_EPSILON &&
700
+ point.y <=
701
+ bounds.maxY + PcbScene3dCopperFillAreaClipper.#GEOMETRY_EPSILON
702
+ )
703
+ }
704
+
705
+ /**
706
+ * Returns true when two bounds overlap.
707
+ * @param {{ minX: number, maxX: number, minY: number, maxY: number }} left Left bounds.
708
+ * @param {{ minX: number, maxX: number, minY: number, maxY: number }} right Right bounds.
709
+ * @returns {boolean}
710
+ */
711
+ static #boundsOverlap(left, right) {
712
+ return !(
713
+ left.maxX <
714
+ right.minX -
715
+ PcbScene3dCopperFillAreaClipper.#GEOMETRY_EPSILON ||
716
+ left.minX >
717
+ right.maxX +
718
+ PcbScene3dCopperFillAreaClipper.#GEOMETRY_EPSILON ||
719
+ left.maxY <
720
+ right.minY -
721
+ PcbScene3dCopperFillAreaClipper.#GEOMETRY_EPSILON ||
722
+ left.minY >
723
+ right.maxY + PcbScene3dCopperFillAreaClipper.#GEOMETRY_EPSILON
724
+ )
725
+ }
726
+ }