pcb-scene3d-viewer 1.0.1 → 1.1.1

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,128 @@
1
+ /**
2
+ * Creates Three.js buffer attributes from importer mesh arrays.
3
+ */
4
+ export class PcbScene3dBufferAttributeFactory {
5
+ /**
6
+ * Creates a floating-point attribute.
7
+ * @param {any} THREE Three.js namespace.
8
+ * @param {ArrayLike<number>} values Source values.
9
+ * @param {number} itemSize Attribute item size.
10
+ * @returns {any}
11
+ */
12
+ static createFloat32(THREE, values, itemSize) {
13
+ return PcbScene3dBufferAttributeFactory.#create(
14
+ THREE,
15
+ values,
16
+ itemSize,
17
+ Float32Array,
18
+ 'Float32BufferAttribute'
19
+ )
20
+ }
21
+
22
+ /**
23
+ * Creates an unsigned integer attribute.
24
+ * @param {any} THREE Three.js namespace.
25
+ * @param {ArrayLike<number>} values Source values.
26
+ * @param {number} itemSize Attribute item size.
27
+ * @returns {any}
28
+ */
29
+ static createUint32(THREE, values, itemSize) {
30
+ return PcbScene3dBufferAttributeFactory.#create(
31
+ THREE,
32
+ values,
33
+ itemSize,
34
+ Uint32Array,
35
+ 'Uint32BufferAttribute'
36
+ )
37
+ }
38
+
39
+ /**
40
+ * Returns true when a value is an array-like numeric sequence.
41
+ * @param {unknown} values Candidate values.
42
+ * @returns {boolean}
43
+ */
44
+ static isNumberSequence(values) {
45
+ return (
46
+ Array.isArray(values) ||
47
+ PcbScene3dBufferAttributeFactory.#isTypedNumberArray(values)
48
+ )
49
+ }
50
+
51
+ /**
52
+ * Creates a buffer attribute with the most efficient constructor available.
53
+ * @param {any} THREE Three.js namespace.
54
+ * @param {ArrayLike<number>} values Source values.
55
+ * @param {number} itemSize Attribute item size.
56
+ * @param {Float32ArrayConstructor | Uint32ArrayConstructor} TypedArrayConstructor Fallback typed array constructor.
57
+ * @param {string} fallbackConstructorName Legacy Three attribute constructor name.
58
+ * @returns {any}
59
+ */
60
+ static #create(
61
+ THREE,
62
+ values,
63
+ itemSize,
64
+ TypedArrayConstructor,
65
+ fallbackConstructorName
66
+ ) {
67
+ const array = PcbScene3dBufferAttributeFactory.#normalizeValues(
68
+ values,
69
+ TypedArrayConstructor
70
+ )
71
+
72
+ if (typeof THREE?.BufferAttribute === 'function') {
73
+ return new THREE.BufferAttribute(array, itemSize)
74
+ }
75
+
76
+ if (typeof THREE?.[fallbackConstructorName] === 'function') {
77
+ return new THREE[fallbackConstructorName](array, itemSize)
78
+ }
79
+
80
+ return {
81
+ array,
82
+ itemSize,
83
+ count: Math.floor(Number(array.length || 0) / itemSize),
84
+ isBufferAttribute: true
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Normalizes plain arrays while preserving importer-owned typed arrays.
90
+ * @param {ArrayLike<number> | undefined | null} values Source values.
91
+ * @param {Float32ArrayConstructor | Uint32ArrayConstructor} TypedArrayConstructor Fallback typed array constructor.
92
+ * @returns {ArrayLike<number>}
93
+ */
94
+ static #normalizeValues(values, TypedArrayConstructor) {
95
+ if (PcbScene3dBufferAttributeFactory.#isTypedNumberArray(values)) {
96
+ return values
97
+ }
98
+
99
+ return new TypedArrayConstructor(Array.from(values || []))
100
+ }
101
+
102
+ /**
103
+ * Returns true when a sequence is already backed by a typed numeric array.
104
+ * @param {unknown} values Candidate values.
105
+ * @returns {boolean}
106
+ */
107
+ static #isTypedNumberArray(values) {
108
+ return (
109
+ ArrayBuffer.isView(values) &&
110
+ !(values instanceof DataView) &&
111
+ !PcbScene3dBufferAttributeFactory.#isBigIntTypedArray(values)
112
+ )
113
+ }
114
+
115
+ /**
116
+ * Returns true when a typed-array view stores bigint values.
117
+ * @param {unknown} values Candidate values.
118
+ * @returns {boolean}
119
+ */
120
+ static #isBigIntTypedArray(values) {
121
+ const tag = Object.prototype.toString.call(values)
122
+
123
+ return (
124
+ tag === '[object BigInt64Array]' ||
125
+ tag === '[object BigUint64Array]'
126
+ )
127
+ }
128
+ }
@@ -0,0 +1,167 @@
1
+ import { PcbScene3dCutoutCircleDetector } from './PcbScene3dCutoutCircleDetector.mjs'
2
+
3
+ /**
4
+ * Resolves analytic overlap checks for sampled circular drill cutouts.
5
+ */
6
+ export class PcbScene3dCircularCutoutOverlap {
7
+ /**
8
+ * Returns true when one triangle overlaps a circular cutout.
9
+ * @param {{ x: number, y: number }[]} triangle
10
+ * @param {{ points?: { x: number, y: number }[], centerX?: number, centerY?: number, radius?: number }} cutout
11
+ * @param {number} [epsilon]
12
+ * @returns {boolean}
13
+ */
14
+ static overlapsTriangle(triangle, cutout, epsilon = 0.001) {
15
+ const radius = Number(cutout?.radius || 0) + Number(epsilon || 0)
16
+ if (!Number.isFinite(radius) || radius <= 0) {
17
+ return false
18
+ }
19
+
20
+ const center = {
21
+ x: Number(cutout?.centerX || 0),
22
+ y: Number(cutout?.centerY || 0)
23
+ }
24
+ const radiusSquared = radius * radius
25
+
26
+ for (const point of triangle) {
27
+ if (
28
+ PcbScene3dCutoutCircleDetector.distanceSquared(point, cutout) <=
29
+ radiusSquared
30
+ ) {
31
+ return true
32
+ }
33
+ }
34
+
35
+ if (
36
+ PcbScene3dCircularCutoutOverlap.#isPointInsideOrOnTriangle(
37
+ center,
38
+ triangle,
39
+ epsilon
40
+ )
41
+ ) {
42
+ return true
43
+ }
44
+
45
+ for (let index = 0; index < triangle.length; index += 1) {
46
+ const current = triangle[index]
47
+ const next = triangle[(index + 1) % triangle.length]
48
+
49
+ if (
50
+ PcbScene3dCircularCutoutOverlap.#distanceToSegmentSquared(
51
+ center,
52
+ current,
53
+ next,
54
+ epsilon
55
+ ) <= radiusSquared
56
+ ) {
57
+ return true
58
+ }
59
+ }
60
+
61
+ return PcbScene3dCircularCutoutOverlap.#hasSampledBoundaryOverlap(
62
+ triangle,
63
+ cutout,
64
+ epsilon
65
+ )
66
+ }
67
+
68
+ /**
69
+ * Returns true when a sampled cutout boundary point lies in the triangle.
70
+ * @param {{ x: number, y: number }[]} triangle
71
+ * @param {{ points?: { x: number, y: number }[] }} cutout
72
+ * @param {number} epsilon
73
+ * @returns {boolean}
74
+ */
75
+ static #hasSampledBoundaryOverlap(triangle, cutout, epsilon) {
76
+ const boundaryPoints = Array.isArray(cutout?.points)
77
+ ? cutout.points
78
+ : []
79
+ const step = Math.max(1, Math.floor(boundaryPoints.length / 8))
80
+
81
+ for (let index = 0; index < boundaryPoints.length; index += step) {
82
+ if (
83
+ PcbScene3dCircularCutoutOverlap.#isPointInsideOrOnTriangle(
84
+ boundaryPoints[index],
85
+ triangle,
86
+ epsilon
87
+ )
88
+ ) {
89
+ return true
90
+ }
91
+ }
92
+
93
+ return false
94
+ }
95
+
96
+ /**
97
+ * Returns true when a point is inside or on one triangle.
98
+ * @param {{ x: number, y: number }} point
99
+ * @param {{ x: number, y: number }[]} triangle
100
+ * @param {number} epsilon
101
+ * @returns {boolean}
102
+ */
103
+ static #isPointInsideOrOnTriangle(point, triangle, epsilon) {
104
+ let hasNegative = false
105
+ let hasPositive = false
106
+
107
+ for (let index = 0; index < triangle.length; index += 1) {
108
+ const current = triangle[index]
109
+ const next = triangle[(index + 1) % triangle.length]
110
+ const sign = PcbScene3dCircularCutoutOverlap.#cross(
111
+ point,
112
+ current,
113
+ next
114
+ )
115
+
116
+ hasNegative = hasNegative || sign < -epsilon
117
+ hasPositive = hasPositive || sign > epsilon
118
+ }
119
+
120
+ return !(hasNegative && hasPositive)
121
+ }
122
+
123
+ /**
124
+ * Resolves the squared distance from one point to a finite segment.
125
+ * @param {{ x: number, y: number }} point
126
+ * @param {{ x: number, y: number }} start
127
+ * @param {{ x: number, y: number }} end
128
+ * @param {number} epsilon
129
+ * @returns {number}
130
+ */
131
+ static #distanceToSegmentSquared(point, start, end, epsilon) {
132
+ const dx = end.x - start.x
133
+ const dy = end.y - start.y
134
+ const lengthSquared = dx * dx + dy * dy
135
+
136
+ if (lengthSquared <= epsilon) {
137
+ return (point.x - start.x) ** 2 + (point.y - start.y) ** 2
138
+ }
139
+
140
+ const ratio = Math.max(
141
+ 0,
142
+ Math.min(
143
+ 1,
144
+ ((point.x - start.x) * dx + (point.y - start.y) * dy) /
145
+ lengthSquared
146
+ )
147
+ )
148
+ const projectedX = start.x + ratio * dx
149
+ const projectedY = start.y + ratio * dy
150
+
151
+ return (point.x - projectedX) ** 2 + (point.y - projectedY) ** 2
152
+ }
153
+
154
+ /**
155
+ * Resolves the signed area for three points.
156
+ * @param {{ x: number, y: number }} first
157
+ * @param {{ x: number, y: number }} second
158
+ * @param {{ x: number, y: number }} third
159
+ * @returns {number}
160
+ */
161
+ static #cross(first, second, third) {
162
+ return (
163
+ (second.x - first.x) * (third.y - first.y) -
164
+ (second.y - first.y) * (third.x - first.x)
165
+ )
166
+ }
167
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Detects sampled circular drill cutouts for faster geometry clipping.
3
+ */
4
+ export class PcbScene3dCutoutCircleDetector {
5
+ static #DEFAULT_EPSILON = 0.001
6
+ static #MAX_RELATIVE_ERROR = 0.025
7
+ static #MIN_POINT_COUNT = 8
8
+
9
+ /**
10
+ * Resolves circle metadata for sampled circular drill cutouts.
11
+ * @param {{ x: number, y: number }[]} points
12
+ * @param {number} [epsilon]
13
+ * @returns {{ isCircular: true, centerX: number, centerY: number, radius: number } | null}
14
+ */
15
+ static resolve(points, epsilon = this.#DEFAULT_EPSILON) {
16
+ if (
17
+ !Array.isArray(points) ||
18
+ points.length < PcbScene3dCutoutCircleDetector.#MIN_POINT_COUNT
19
+ ) {
20
+ return null
21
+ }
22
+
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
+ )
32
+ const tolerance = Math.max(
33
+ Number(epsilon || 0),
34
+ radius * PcbScene3dCutoutCircleDetector.#MAX_RELATIVE_ERROR
35
+ )
36
+
37
+ if (
38
+ !Number.isFinite(radius) ||
39
+ radius <= Number(epsilon || 0) ||
40
+ maxError > tolerance
41
+ ) {
42
+ return null
43
+ }
44
+
45
+ return {
46
+ isCircular: true,
47
+ centerX: center.x,
48
+ centerY: center.y,
49
+ radius
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Resolves squared distance from a point to circular cutout center.
55
+ * @param {{ x: number, y: number }} point
56
+ * @param {{ centerX?: number, centerY?: number }} cutout
57
+ * @returns {number}
58
+ */
59
+ static distanceSquared(point, cutout) {
60
+ const dx = point.x - Number(cutout.centerX || 0)
61
+ const dy = point.y - Number(cutout.centerY || 0)
62
+ return dx * dx + dy * dy
63
+ }
64
+
65
+ /**
66
+ * Resolves the centroid of one point list.
67
+ * @param {{ x: number, y: number }[]} points
68
+ * @returns {{ x: number, y: number }}
69
+ */
70
+ 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
+ )
78
+
79
+ return {
80
+ x: sum.x / points.length,
81
+ y: sum.y / points.length
82
+ }
83
+ }
84
+ }
@@ -1,3 +1,6 @@
1
+ import { PcbScene3dCircularCutoutOverlap } from './PcbScene3dCircularCutoutOverlap.mjs'
2
+ import { PcbScene3dCutoutCircleDetector } from './PcbScene3dCutoutCircleDetector.mjs'
3
+
1
4
  /**
2
5
  * Clips filled 2D geometry against drill-cutout polygons.
3
6
  */
@@ -79,7 +82,7 @@ export class PcbScene3dCutoutGeometryFilter {
79
82
  /**
80
83
  * Resolves clipping settings.
81
84
  * @param {{ maxDepth?: number, maxEdgeLength?: number }} options
82
- * @returns {{ maxDepth: number, maxEdgeLength: number }}
85
+ * @returns {{ maxDepth: number, maxEdgeLength: number, maxEdgeLengthSquared: number }}
83
86
  */
84
87
  static #resolveSettings(options) {
85
88
  const maxEdgeLength = Math.max(
@@ -94,24 +97,35 @@ export class PcbScene3dCutoutGeometryFilter {
94
97
  PcbScene3dCutoutGeometryFilter.#DEFAULT_MAX_DEPTH,
95
98
  0
96
99
  ),
97
- maxEdgeLength
100
+ maxEdgeLength,
101
+ maxEdgeLengthSquared: maxEdgeLength * maxEdgeLength
98
102
  }
99
103
  }
100
104
 
101
105
  /**
102
106
  * Prepares cutout polygons with bounds for fast overlap checks.
103
107
  * @param {{ x: number, y: number }[][]} cutouts
104
- * @returns {{ points: { x: number, y: number }[], segments: { start: { x: number, y: number }, end: { x: number, y: number }, dx: number, dy: number, lengthSquared: number, bounds: { minX: number, maxX: number, minY: number, maxY: number } }[], bounds: { minX: number, maxX: number, minY: number, maxY: number } }[]}
108
+ * @returns {{ points: { x: number, y: number }[], segments: { start: { x: number, y: number }, end: { x: number, y: number }, dx: number, dy: number, lengthSquared: number, bounds: { minX: number, maxX: number, minY: number, maxY: number } }[], bounds: { minX: number, maxX: number, minY: number, maxY: number }, isCircular?: boolean, centerX?: number, centerY?: number, radius?: number }[]}
105
109
  */
106
110
  static #prepareCutouts(cutouts) {
107
111
  return cutouts
108
112
  .filter((cutout) => Array.isArray(cutout) && cutout.length >= 3)
109
- .map((cutout) => ({
110
- points: cutout,
111
- segments:
112
- PcbScene3dCutoutGeometryFilter.#buildCutoutSegments(cutout),
113
- bounds: PcbScene3dCutoutGeometryFilter.#resolveBounds(cutout)
114
- }))
113
+ .map((cutout) => {
114
+ const circularCutout =
115
+ PcbScene3dCutoutCircleDetector.resolve(cutout)
116
+
117
+ return {
118
+ points: cutout,
119
+ segments:
120
+ PcbScene3dCutoutGeometryFilter.#buildCutoutSegments(
121
+ cutout
122
+ ),
123
+ bounds: PcbScene3dCutoutGeometryFilter.#resolveBounds(
124
+ cutout
125
+ ),
126
+ ...circularCutout
127
+ }
128
+ })
115
129
  }
116
130
 
117
131
  /**
@@ -151,7 +165,7 @@ export class PcbScene3dCutoutGeometryFilter {
151
165
  * @param {number[]} positions
152
166
  * @param {{ x: number, y: number, z: number }[]} triangle
153
167
  * @param {{ points: { x: number, y: number }[], segments: { start: { x: number, y: number }, end: { x: number, y: number }, dx: number, dy: number, lengthSquared: number, bounds: { minX: number, maxX: number, minY: number, maxY: number } }[], bounds: { minX: number, maxX: number, minY: number, maxY: number } }[]} cutouts
154
- * @param {{ maxDepth: number, maxEdgeLength: number }} settings
168
+ * @param {{ maxDepth: number, maxEdgeLength: number, maxEdgeLengthSquared: number }} settings
155
169
  * @param {number} depth
156
170
  * @param {{ changed: boolean }} state
157
171
  * @param {object | null} [cutoutIndex]
@@ -199,8 +213,8 @@ export class PcbScene3dCutoutGeometryFilter {
199
213
 
200
214
  if (
201
215
  depth >= settings.maxDepth ||
202
- PcbScene3dCutoutGeometryFilter.#maxEdgeLength(triangle) <=
203
- settings.maxEdgeLength
216
+ PcbScene3dCutoutGeometryFilter.#maxEdgeLengthSquared(triangle) <=
217
+ settings.maxEdgeLengthSquared
204
218
  ) {
205
219
  return
206
220
  }
@@ -443,23 +457,22 @@ export class PcbScene3dCutoutGeometryFilter {
443
457
  }
444
458
 
445
459
  /**
446
- * Resolves the longest edge in one triangle.
460
+ * Resolves the longest squared edge length in one triangle.
447
461
  * @param {{ x: number, y: number }[]} triangle
448
462
  * @returns {number}
449
463
  */
450
- static #maxEdgeLength(triangle) {
451
- let maxLength = 0
464
+ static #maxEdgeLengthSquared(triangle) {
465
+ let maxLengthSquared = 0
452
466
 
453
467
  for (let index = 0; index < triangle.length; index += 1) {
454
468
  const point = triangle[index]
455
469
  const next = triangle[(index + 1) % triangle.length]
456
- maxLength = Math.max(
457
- maxLength,
458
- Math.hypot(point.x - next.x, point.y - next.y)
459
- )
470
+ const dx = point.x - next.x
471
+ const dy = point.y - next.y
472
+ maxLengthSquared = Math.max(maxLengthSquared, dx * dx + dy * dy)
460
473
  }
461
474
 
462
- return maxLength
475
+ return maxLengthSquared
463
476
  }
464
477
 
465
478
  /**
@@ -496,10 +509,17 @@ export class PcbScene3dCutoutGeometryFilter {
496
509
  */
497
510
  static #boundsOverlap(first, second) {
498
511
  return (
499
- first.minX <= second.maxX &&
500
- first.maxX >= second.minX &&
501
- first.minY <= second.maxY &&
502
- first.maxY >= second.minY
512
+ first.minX <=
513
+ second.maxX +
514
+ PcbScene3dCutoutGeometryFilter.#GEOMETRY_EPSILON &&
515
+ first.maxX >=
516
+ second.minX -
517
+ PcbScene3dCutoutGeometryFilter.#GEOMETRY_EPSILON &&
518
+ first.minY <=
519
+ second.maxY +
520
+ PcbScene3dCutoutGeometryFilter.#GEOMETRY_EPSILON &&
521
+ first.maxY >=
522
+ second.minY - PcbScene3dCutoutGeometryFilter.#GEOMETRY_EPSILON
503
523
  )
504
524
  }
505
525
 
@@ -543,7 +563,7 @@ export class PcbScene3dCutoutGeometryFilter {
543
563
  /**
544
564
  * Returns true when one triangle intersects or covers a cutout.
545
565
  * @param {{ x: number, y: number }[]} triangle
546
- * @param {{ points: { x: number, y: number }[], segments: { start: { x: number, y: number }, end: { x: number, y: number }, dx: number, dy: number, lengthSquared: number, bounds: { minX: number, maxX: number, minY: number, maxY: number } }[], bounds: { minX: number, maxX: number, minY: number, maxY: number } }} cutout
566
+ * @param {{ points: { x: number, y: number }[], segments: { start: { x: number, y: number }, end: { x: number, y: number }, dx: number, dy: number, lengthSquared: number, bounds: { minX: number, maxX: number, minY: number, maxY: number } }[], bounds: { minX: number, maxX: number, minY: number, maxY: number }, isCircular?: boolean, centerX?: number, centerY?: number, radius?: number }} cutout
547
567
  * @returns {boolean}
548
568
  */
549
569
  static #doesTriangleOverlapCutout(triangle, cutout) {
@@ -556,6 +576,14 @@ export class PcbScene3dCutoutGeometryFilter {
556
576
  return false
557
577
  }
558
578
 
579
+ if (cutout.isCircular) {
580
+ return PcbScene3dCircularCutoutOverlap.overlapsTriangle(
581
+ triangle,
582
+ cutout,
583
+ PcbScene3dCutoutGeometryFilter.#GEOMETRY_EPSILON
584
+ )
585
+ }
586
+
559
587
  for (const point of triangle) {
560
588
  if (
561
589
  PcbScene3dCutoutGeometryFilter.#isPointInsideOrOnCutout(
@@ -587,10 +615,28 @@ export class PcbScene3dCutoutGeometryFilter {
587
615
  /**
588
616
  * Returns true when a point is inside or on a cutout.
589
617
  * @param {{ x: number, y: number }} point
590
- * @param {{ points: { x: number, y: number }[], segments: { start: { x: number, y: number }, end: { x: number, y: number }, dx: number, dy: number, lengthSquared: number, bounds: { minX: number, maxX: number, minY: number, maxY: number } }[], bounds: { minX: number, maxX: number, minY: number, maxY: number } }} cutout
618
+ * @param {{ points: { x: number, y: number }[], segments: { start: { x: number, y: number }, end: { x: number, y: number }, dx: number, dy: number, lengthSquared: number, bounds: { minX: number, maxX: number, minY: number, maxY: number } }[], bounds: { minX: number, maxX: number, minY: number, maxY: number }, isCircular?: boolean, centerX?: number, centerY?: number, radius?: number }} cutout
591
619
  * @returns {boolean}
592
620
  */
593
621
  static #isPointInsideOrOnCutout(point, cutout) {
622
+ if (
623
+ !PcbScene3dCutoutGeometryFilter.#pointOverlapsBounds(
624
+ point,
625
+ cutout.bounds
626
+ )
627
+ ) {
628
+ return false
629
+ }
630
+
631
+ if (cutout.isCircular) {
632
+ return (
633
+ PcbScene3dCutoutCircleDetector.distanceSquared(point, cutout) <=
634
+ (Number(cutout.radius || 0) +
635
+ PcbScene3dCutoutGeometryFilter.#GEOMETRY_EPSILON) **
636
+ 2
637
+ )
638
+ }
639
+
594
640
  return (
595
641
  PcbScene3dCutoutGeometryFilter.#isPointOnCutoutBoundary(
596
642
  point,
@@ -603,13 +649,47 @@ export class PcbScene3dCutoutGeometryFilter {
603
649
  )
604
650
  }
605
651
 
652
+ /**
653
+ * Returns true when a point lies within a bounding box tolerance.
654
+ * @param {{ x: number, y: number }} point
655
+ * @param {{ minX: number, maxX: number, minY: number, maxY: number }} bounds
656
+ * @returns {boolean}
657
+ */
658
+ static #pointOverlapsBounds(point, bounds) {
659
+ return (
660
+ point.x >=
661
+ bounds.minX -
662
+ PcbScene3dCutoutGeometryFilter.#GEOMETRY_EPSILON &&
663
+ point.x <=
664
+ bounds.maxX +
665
+ PcbScene3dCutoutGeometryFilter.#GEOMETRY_EPSILON &&
666
+ point.y >=
667
+ bounds.minY -
668
+ PcbScene3dCutoutGeometryFilter.#GEOMETRY_EPSILON &&
669
+ point.y <=
670
+ bounds.maxY + PcbScene3dCutoutGeometryFilter.#GEOMETRY_EPSILON
671
+ )
672
+ }
673
+
606
674
  /**
607
675
  * Returns true when a point lies inside a cutout and away from its border.
608
676
  * @param {{ x: number, y: number }} point
609
- * @param {{ points: { x: number, y: number }[], bounds: { minX: number, maxX: number, minY: number, maxY: number } }} cutout
677
+ * @param {{ points: { x: number, y: number }[], bounds: { minX: number, maxX: number, minY: number, maxY: number }, isCircular?: boolean, centerX?: number, centerY?: number, radius?: number }} cutout
610
678
  * @returns {boolean}
611
679
  */
612
680
  static #isPointStrictlyInsideCutout(point, cutout) {
681
+ if (cutout.isCircular) {
682
+ const radius = Math.max(
683
+ 0,
684
+ Number(cutout.radius || 0) -
685
+ PcbScene3dCutoutGeometryFilter.#GEOMETRY_EPSILON
686
+ )
687
+ return (
688
+ PcbScene3dCutoutCircleDetector.distanceSquared(point, cutout) <
689
+ radius * radius
690
+ )
691
+ }
692
+
613
693
  const polygon = cutout.points
614
694
 
615
695
  let inside = false
@@ -638,18 +718,33 @@ export class PcbScene3dCutoutGeometryFilter {
638
718
  /**
639
719
  * Returns true when a point lies on a cutout edge.
640
720
  * @param {{ x: number, y: number }} point
641
- * @param {{ points: { x: number, y: number }[] }} cutout
721
+ * @param {{ segments: { start: { x: number, y: number }, end: { x: number, y: number }, bounds: { minX: number, maxX: number, minY: number, maxY: number } }[], isCircular?: boolean, centerX?: number, centerY?: number, radius?: number }} cutout
642
722
  * @returns {boolean}
643
723
  */
644
724
  static #isPointOnCutoutBoundary(point, cutout) {
645
- const polygon = cutout.points
725
+ if (cutout.isCircular) {
726
+ return (
727
+ Math.abs(
728
+ Math.sqrt(
729
+ PcbScene3dCutoutCircleDetector.distanceSquared(
730
+ point,
731
+ cutout
732
+ )
733
+ ) - Number(cutout.radius || 0)
734
+ ) <= PcbScene3dCutoutGeometryFilter.#GEOMETRY_EPSILON
735
+ )
736
+ }
646
737
 
647
- for (let index = 0; index < polygon.length; index += 1) {
738
+ for (const segment of cutout.segments) {
648
739
  if (
740
+ PcbScene3dCutoutGeometryFilter.#pointOverlapsBounds(
741
+ point,
742
+ segment.bounds
743
+ ) &&
649
744
  PcbScene3dCutoutGeometryFilter.#isPointOnSegment(
650
745
  point,
651
- polygon[index],
652
- polygon[(index + 1) % polygon.length]
746
+ segment.start,
747
+ segment.end
653
748
  )
654
749
  ) {
655
750
  return true
@@ -703,9 +798,18 @@ export class PcbScene3dCutoutGeometryFilter {
703
798
  ) {
704
799
  const triangleStart = triangle[triangleIndex]
705
800
  const triangleEnd = triangle[(triangleIndex + 1) % triangle.length]
801
+ const triangleSegmentBounds =
802
+ PcbScene3dCutoutGeometryFilter.#resolveSegmentBounds(
803
+ triangleStart,
804
+ triangleEnd
805
+ )
706
806
 
707
807
  for (const segment of cutout.segments) {
708
808
  if (
809
+ PcbScene3dCutoutGeometryFilter.#boundsOverlap(
810
+ triangleSegmentBounds,
811
+ segment.bounds
812
+ ) &&
709
813
  PcbScene3dCutoutGeometryFilter.#segmentsIntersect(
710
814
  triangleStart,
711
815
  triangleEnd,
@@ -721,6 +825,21 @@ export class PcbScene3dCutoutGeometryFilter {
721
825
  return false
722
826
  }
723
827
 
828
+ /**
829
+ * Resolves one finite segment bounding box.
830
+ * @param {{ x: number, y: number }} firstStart
831
+ * @param {{ x: number, y: number }} firstEnd
832
+ * @returns {{ minX: number, maxX: number, minY: number, maxY: number }}
833
+ */
834
+ static #resolveSegmentBounds(firstStart, firstEnd) {
835
+ return {
836
+ minX: Math.min(firstStart.x, firstEnd.x),
837
+ maxX: Math.max(firstStart.x, firstEnd.x),
838
+ minY: Math.min(firstStart.y, firstEnd.y),
839
+ maxY: Math.max(firstStart.y, firstEnd.y)
840
+ }
841
+ }
842
+
724
843
  /**
725
844
  * Returns true when two finite line segments intersect.
726
845
  * @param {{ x: number, y: number }} firstStart