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
@@ -0,0 +1,709 @@
1
+ import { PcbScene3dAabbIndex } from './PcbScene3dAabbIndex.mjs'
2
+ import { PcbScene3dCutoutCircleDetector } from './PcbScene3dCutoutCircleDetector.mjs'
3
+
4
+ /**
5
+ * @typedef {object} PcbScene3dPreparedPolygonBounds
6
+ * @property {number} minX
7
+ * @property {number} maxX
8
+ * @property {number} minY
9
+ * @property {number} maxY
10
+ */
11
+
12
+ /**
13
+ * @typedef {object} PcbScene3dPreparedPolygonSegment
14
+ * @property {{ x: number, y: number }} start
15
+ * @property {{ x: number, y: number }} end
16
+ * @property {number} dx
17
+ * @property {number} dy
18
+ * @property {number} lengthSquared
19
+ * @property {PcbScene3dPreparedPolygonBounds} bounds
20
+ */
21
+
22
+ /**
23
+ * Request-scoped polygon metadata and exact-query acceleration.
24
+ */
25
+ export class PcbScene3dPreparedPolygon {
26
+ static #DEFAULT_EPSILON = 0.001
27
+ static #LINEAR_QUERY_LIMIT = 64
28
+ static #SEGMENT_INDEX_CONSTRUCTION_BUDGET = 1024
29
+
30
+ /** @type {*} */
31
+ #source
32
+
33
+ /** @type {number} */
34
+ #sourceIndex
35
+
36
+ /** @type {{ x: number, y: number }[]} */
37
+ #points
38
+
39
+ /** @type {PcbScene3dPreparedPolygonSegment[]} */
40
+ #segments
41
+
42
+ /** @type {PcbScene3dPreparedPolygonBounds} */
43
+ #bounds
44
+
45
+ /** @type {{ x: number, y: number }} */
46
+ #centroid
47
+
48
+ /** @type {number} */
49
+ #signedArea
50
+
51
+ /** @type {number} */
52
+ #epsilon
53
+
54
+ /** @type {{ isCircular: true, centerX: number, centerY: number, radius: number } | null} */
55
+ #circle
56
+
57
+ /** @type {boolean} */
58
+ #circleDetectionEnabled
59
+
60
+ /** @type {'raw' | 'numeric' | 'raw-numeric' | null} */
61
+ #pointRepresentation
62
+
63
+ /** @type {PcbScene3dAabbIndex | null} */
64
+ #segmentIndex
65
+
66
+ /** @type {number} */
67
+ #segmentQueryWork
68
+
69
+ /** @type {PcbScene3dAabbIndex | null} */
70
+ #vertexIndex
71
+
72
+ /**
73
+ * Prepares reusable polygon metadata without mutating source points.
74
+ * @param {{ x: number, y: number }[]} points
75
+ * @param {{ source?: *, sourceIndex?: number, epsilon?: number, detectCircle?: boolean, metadataPoints?: { x: number, y: number }[], pointRepresentation?: 'raw' | 'numeric' | 'raw-numeric' }} [options]
76
+ */
77
+ constructor(points, options = {}) {
78
+ this.#points = Array.isArray(points) ? points : []
79
+ this.#source = Object.prototype.hasOwnProperty.call(options, 'source')
80
+ ? options.source
81
+ : points
82
+ this.#sourceIndex = options.sourceIndex ?? 0
83
+ this.#epsilon = Number(
84
+ options.epsilon ?? PcbScene3dPreparedPolygon.#DEFAULT_EPSILON
85
+ )
86
+
87
+ const metadataPoints = Array.isArray(options.metadataPoints)
88
+ ? options.metadataPoints
89
+ : this.#points
90
+ const metadata =
91
+ PcbScene3dPreparedPolygon.#resolveMetadata(metadataPoints)
92
+ this.#bounds = metadata.bounds
93
+ this.#centroid = metadata.centroid
94
+ this.#signedArea = metadata.signedArea
95
+ this.#pointRepresentation = ['raw', 'numeric', 'raw-numeric'].includes(
96
+ options.pointRepresentation
97
+ )
98
+ ? options.pointRepresentation
99
+ : null
100
+ this.#segments = PcbScene3dPreparedPolygon.#buildSegments(this.#points)
101
+ this.#segmentIndex = null
102
+ this.#segmentQueryWork = 0
103
+ this.#vertexIndex = null
104
+ this.#circleDetectionEnabled = options.detectCircle === true
105
+ this.#circle = this.#circleDetectionEnabled
106
+ ? PcbScene3dCutoutCircleDetector.resolve(
107
+ this.#points,
108
+ this.#epsilon
109
+ )
110
+ : null
111
+ }
112
+
113
+ /**
114
+ * Returns the optional original source identity.
115
+ * @returns {*}
116
+ */
117
+ get source() {
118
+ return this.#source
119
+ }
120
+
121
+ /**
122
+ * Returns the source position supplied by the preparing caller.
123
+ * @returns {number}
124
+ */
125
+ get sourceIndex() {
126
+ return this.#sourceIndex
127
+ }
128
+
129
+ /**
130
+ * Returns the exact points supplied by the caller.
131
+ * @returns {{ x: number, y: number }[]}
132
+ */
133
+ get points() {
134
+ return this.#points
135
+ }
136
+
137
+ /**
138
+ * Returns the producer-declared exact-point representation for cache reuse.
139
+ * @returns {'raw' | 'numeric' | 'raw-numeric' | null}
140
+ */
141
+ get pointRepresentation() {
142
+ return this.#pointRepresentation
143
+ }
144
+
145
+ /**
146
+ * Returns source-order polygon segments with cached arithmetic.
147
+ * @returns {PcbScene3dPreparedPolygonSegment[]}
148
+ */
149
+ get segments() {
150
+ return this.#segments
151
+ }
152
+
153
+ /**
154
+ * Returns polygon axis-aligned bounds.
155
+ * @returns {PcbScene3dPreparedPolygonBounds}
156
+ */
157
+ get bounds() {
158
+ return this.#bounds
159
+ }
160
+
161
+ /**
162
+ * Returns the source-order arithmetic-mean centroid.
163
+ * @returns {{ x: number, y: number }}
164
+ */
165
+ get centroid() {
166
+ return this.#centroid
167
+ }
168
+
169
+ /**
170
+ * Returns the signed shoelace area.
171
+ * @returns {number}
172
+ */
173
+ get signedArea() {
174
+ return this.#signedArea
175
+ }
176
+
177
+ /**
178
+ * Returns the absolute shoelace area.
179
+ * @returns {number}
180
+ */
181
+ get area() {
182
+ return Math.abs(this.#signedArea)
183
+ }
184
+
185
+ /**
186
+ * Returns whether sampled-circle detection was performed during preparation.
187
+ * @returns {boolean}
188
+ */
189
+ get circleDetectionEnabled() {
190
+ return this.#circleDetectionEnabled
191
+ }
192
+
193
+ /**
194
+ * Returns cached sampled-circle metadata when detection was requested.
195
+ * @returns {{ isCircular: true, centerX: number, centerY: number, radius: number } | null}
196
+ */
197
+ get circle() {
198
+ return this.#circle
199
+ }
200
+
201
+ /**
202
+ * Returns whether sampled-circle metadata was detected.
203
+ * @returns {boolean}
204
+ */
205
+ get isCircular() {
206
+ return this.#circle?.isCircular === true
207
+ }
208
+
209
+ /**
210
+ * Returns the cached circle center X coordinate when available.
211
+ * @returns {number | undefined}
212
+ */
213
+ get centerX() {
214
+ return this.#circle?.centerX
215
+ }
216
+
217
+ /**
218
+ * Returns the cached circle center Y coordinate when available.
219
+ * @returns {number | undefined}
220
+ */
221
+ get centerY() {
222
+ return this.#circle?.centerY
223
+ }
224
+
225
+ /**
226
+ * Returns the cached circle radius when available.
227
+ * @returns {number | undefined}
228
+ */
229
+ get radius() {
230
+ return this.#circle?.radius
231
+ }
232
+
233
+ /**
234
+ * Returns true when a point lies strictly inside the polygon.
235
+ * @param {{ x: number, y: number }} point
236
+ * @param {{ segmentBoundsEpsilon?: number }} [options]
237
+ * @returns {boolean}
238
+ */
239
+ containsPointStrict(point, options = {}) {
240
+ if (this.isPointOnBoundary(point, options)) {
241
+ return false
242
+ }
243
+
244
+ return (
245
+ PcbScene3dPreparedPolygon.#pointOverlapsBounds(
246
+ point,
247
+ this.#bounds
248
+ ) && this.#containsPointByHorizontalRay(point)
249
+ )
250
+ }
251
+
252
+ /**
253
+ * Returns true when a point lies inside or on the polygon boundary.
254
+ * @param {{ x: number, y: number }} point
255
+ * @param {{ segmentBoundsEpsilon?: number }} [options]
256
+ * @returns {boolean}
257
+ */
258
+ containsPointOrBoundary(point, options = {}) {
259
+ if (this.isPointOnBoundary(point, options)) {
260
+ return true
261
+ }
262
+
263
+ return (
264
+ PcbScene3dPreparedPolygon.#pointOverlapsBounds(
265
+ point,
266
+ this.#bounds
267
+ ) && this.#containsPointByHorizontalRay(point)
268
+ )
269
+ }
270
+
271
+ /**
272
+ * Returns true when a point lies on a polygon segment within tolerance.
273
+ * @param {{ x: number, y: number }} point
274
+ * @param {{ segmentBoundsEpsilon?: number }} [options]
275
+ * @returns {boolean}
276
+ */
277
+ isPointOnBoundary(point, options = {}) {
278
+ const candidates = this.querySegments(
279
+ {
280
+ minX: point.x,
281
+ maxX: point.x,
282
+ minY: point.y,
283
+ maxY: point.y
284
+ },
285
+ []
286
+ )
287
+ const segmentBoundsEpsilon = Number(options.segmentBoundsEpsilon)
288
+ const requireSegmentBoundsOverlap =
289
+ Number.isFinite(segmentBoundsEpsilon)
290
+
291
+ return candidates.some(
292
+ (segment) =>
293
+ (!requireSegmentBoundsOverlap ||
294
+ PcbScene3dPreparedPolygon.#pointOverlapsBounds(
295
+ point,
296
+ segment.bounds,
297
+ Math.max(0, segmentBoundsEpsilon)
298
+ )) &&
299
+ this.#isPointOnSegment(point, segment)
300
+ )
301
+ }
302
+
303
+ /**
304
+ * Appends segment broad-phase candidates to a target.
305
+ * @param {PcbScene3dPreparedPolygonBounds} bounds
306
+ * @param {PcbScene3dPreparedPolygonSegment[]} [target]
307
+ * @returns {PcbScene3dPreparedPolygonSegment[]}
308
+ */
309
+ querySegments(bounds, target = []) {
310
+ const segmentCount = this.#segments.length
311
+ if (segmentCount > PcbScene3dPreparedPolygon.#LINEAR_QUERY_LIMIT) {
312
+ return this.#resolveSegmentIndex().queryInto(bounds, target)
313
+ }
314
+
315
+ if (
316
+ this.#segmentIndex ||
317
+ this.#segmentQueryWork + segmentCount >=
318
+ PcbScene3dPreparedPolygon.#SEGMENT_INDEX_CONSTRUCTION_BUDGET
319
+ ) {
320
+ return this.#resolveSegmentIndex().queryInto(bounds, target, {
321
+ stable: true
322
+ })
323
+ }
324
+
325
+ this.#segmentQueryWork += segmentCount
326
+ return this.#querySegmentsLinear(bounds, target)
327
+ }
328
+
329
+ /**
330
+ * Appends vertex broad-phase candidates to a target.
331
+ * @param {PcbScene3dPreparedPolygonBounds} bounds
332
+ * @param {{ x: number, y: number }[]} [target]
333
+ * @returns {{ x: number, y: number }[]}
334
+ */
335
+ queryVertices(bounds, target = []) {
336
+ if (
337
+ this.#points.length <= PcbScene3dPreparedPolygon.#LINEAR_QUERY_LIMIT
338
+ ) {
339
+ return this.#queryVerticesLinear(bounds, target)
340
+ }
341
+
342
+ return this.#resolveVertexIndex().queryInto(bounds, target, {
343
+ epsilon: this.#epsilon
344
+ })
345
+ }
346
+
347
+ /**
348
+ * Resolves and caches the segment AABB index on its first large query.
349
+ * @returns {PcbScene3dAabbIndex}
350
+ */
351
+ #resolveSegmentIndex() {
352
+ if (!this.#segmentIndex) {
353
+ this.#segmentIndex = new PcbScene3dAabbIndex(this.#segments, {
354
+ resolveBounds: (segment) =>
355
+ PcbScene3dPreparedPolygon.#resolveSegmentIndexBounds(
356
+ segment,
357
+ this.#epsilon
358
+ )
359
+ })
360
+ }
361
+
362
+ return this.#segmentIndex
363
+ }
364
+
365
+ /**
366
+ * Resolves and caches the vertex AABB index on its first large query.
367
+ * @returns {PcbScene3dAabbIndex}
368
+ */
369
+ #resolveVertexIndex() {
370
+ if (!this.#vertexIndex) {
371
+ this.#vertexIndex = new PcbScene3dAabbIndex(this.#points, {
372
+ resolveBounds: PcbScene3dPreparedPolygon.#resolvePointBounds
373
+ })
374
+ }
375
+
376
+ return this.#vertexIndex
377
+ }
378
+
379
+ /**
380
+ * Appends exact segment candidates without constructing a small index.
381
+ * @param {PcbScene3dPreparedPolygonBounds} bounds Query bounds.
382
+ * @param {PcbScene3dPreparedPolygonSegment[]} target Candidate target.
383
+ * @returns {PcbScene3dPreparedPolygonSegment[]}
384
+ */
385
+ #querySegmentsLinear(bounds, target) {
386
+ const segmentCount = this.#segments.length
387
+ for (let index = 0; index < segmentCount; index += 1) {
388
+ const segment = this.#segments[index]
389
+ if (
390
+ PcbScene3dPreparedPolygon.#segmentOverlapsQuery(
391
+ segment,
392
+ bounds,
393
+ this.#epsilon
394
+ )
395
+ ) {
396
+ target.push(segment)
397
+ }
398
+ }
399
+
400
+ return target
401
+ }
402
+
403
+ /**
404
+ * Appends exact vertex candidates without constructing a small index.
405
+ * @param {PcbScene3dPreparedPolygonBounds} bounds Query bounds.
406
+ * @param {{ x: number, y: number }[]} target Candidate target.
407
+ * @returns {{ x: number, y: number }[]}
408
+ */
409
+ #queryVerticesLinear(bounds, target) {
410
+ const pointCount = this.#points.length
411
+ for (let index = 0; index < pointCount; index += 1) {
412
+ const point = this.#points[index]
413
+ if (
414
+ PcbScene3dPreparedPolygon.#pointOverlapsQuery(
415
+ point,
416
+ bounds,
417
+ this.#epsilon
418
+ )
419
+ ) {
420
+ target.push(point)
421
+ }
422
+ }
423
+
424
+ return target
425
+ }
426
+
427
+ /**
428
+ * Applies the existing horizontal ray expression to crossing candidates.
429
+ * @param {{ x: number, y: number }} point
430
+ * @returns {boolean}
431
+ */
432
+ #containsPointByHorizontalRay(point) {
433
+ const candidates = this.querySegments(
434
+ {
435
+ minX: point.x,
436
+ maxX: this.#bounds.maxX,
437
+ minY: point.y,
438
+ maxY: point.y
439
+ },
440
+ []
441
+ )
442
+ let inside = false
443
+
444
+ for (const segment of candidates) {
445
+ const current = segment.end
446
+ const previous = segment.start
447
+ const intersects =
448
+ current.y > point.y !== previous.y > point.y &&
449
+ point.x <
450
+ ((previous.x - current.x) * (point.y - current.y)) /
451
+ (previous.y - current.y) +
452
+ current.x
453
+
454
+ if (intersects) {
455
+ inside = !inside
456
+ }
457
+ }
458
+
459
+ return inside
460
+ }
461
+
462
+ /**
463
+ * Returns true when a point passes current cross, dot, and length checks.
464
+ * @param {{ x: number, y: number }} point
465
+ * @param {PcbScene3dPreparedPolygonSegment} segment
466
+ * @returns {boolean}
467
+ */
468
+ #isPointOnSegment(point, segment) {
469
+ const cross =
470
+ (point.y - segment.start.y) * segment.dx -
471
+ (point.x - segment.start.x) * segment.dy
472
+
473
+ if (Math.abs(cross) > this.#epsilon) {
474
+ return false
475
+ }
476
+
477
+ const dot =
478
+ (point.x - segment.start.x) * segment.dx +
479
+ (point.y - segment.start.y) * segment.dy
480
+
481
+ if (dot < -this.#epsilon) {
482
+ return false
483
+ }
484
+
485
+ return dot <= segment.lengthSquared + this.#epsilon
486
+ }
487
+
488
+ /**
489
+ * Builds source-order segments and their reusable arithmetic.
490
+ * @param {{ x: number, y: number }[]} points
491
+ * @returns {PcbScene3dPreparedPolygonSegment[]}
492
+ */
493
+ static #buildSegments(points) {
494
+ return points.map((start, index) => {
495
+ const end = points[(index + 1) % points.length]
496
+ const dx = end.x - start.x
497
+ const dy = end.y - start.y
498
+
499
+ return {
500
+ start,
501
+ end,
502
+ dx,
503
+ dy,
504
+ lengthSquared: dx * dx + dy * dy,
505
+ bounds: {
506
+ minX: Math.min(start.x, end.x),
507
+ maxX: Math.max(start.x, end.x),
508
+ minY: Math.min(start.y, end.y),
509
+ maxY: Math.max(start.y, end.y)
510
+ }
511
+ }
512
+ })
513
+ }
514
+
515
+ /**
516
+ * Resolves source-order bounds, centroid, and signed area.
517
+ * @param {{ x: number, y: number }[]} points
518
+ * @returns {{ bounds: PcbScene3dPreparedPolygonBounds, centroid: { x: number, y: number }, signedArea: number }}
519
+ */
520
+ static #resolveMetadata(points) {
521
+ const bounds = {
522
+ minX: Infinity,
523
+ maxX: -Infinity,
524
+ minY: Infinity,
525
+ maxY: -Infinity
526
+ }
527
+ let totalX = 0
528
+ let totalY = 0
529
+ let doubledArea = 0
530
+
531
+ for (let index = 0; index < points.length; index += 1) {
532
+ const point = points[index]
533
+ const next = points[(index + 1) % points.length]
534
+
535
+ bounds.minX = Math.min(bounds.minX, point.x)
536
+ bounds.maxX = Math.max(bounds.maxX, point.x)
537
+ bounds.minY = Math.min(bounds.minY, point.y)
538
+ bounds.maxY = Math.max(bounds.maxY, point.y)
539
+ totalX += point.x
540
+ totalY += point.y
541
+ doubledArea += point.x * next.y - next.x * point.y
542
+ }
543
+
544
+ const count = Math.max(points.length, 1)
545
+ return {
546
+ bounds,
547
+ centroid: { x: totalX / count, y: totalY / count },
548
+ signedArea: doubledArea / 2
549
+ }
550
+ }
551
+
552
+ /**
553
+ * Expands index bounds enough to retain all tolerance predicate matches.
554
+ * @param {PcbScene3dPreparedPolygonSegment} segment
555
+ * @param {number} epsilon
556
+ * @returns {PcbScene3dPreparedPolygonBounds}
557
+ */
558
+ static #resolveSegmentIndexBounds(segment, epsilon) {
559
+ if (
560
+ segment.lengthSquared === 0 ||
561
+ !Number.isFinite(segment.lengthSquared) ||
562
+ !PcbScene3dPreparedPolygon.#hasFiniteBounds(segment.bounds)
563
+ ) {
564
+ return PcbScene3dPreparedPolygon.#resolveAllSpaceBounds()
565
+ }
566
+
567
+ const margin = Math.max(
568
+ epsilon,
569
+ (Math.SQRT2 * epsilon) / Math.sqrt(segment.lengthSquared)
570
+ )
571
+ return {
572
+ minX: segment.bounds.minX - margin,
573
+ maxX: segment.bounds.maxX + margin,
574
+ minY: segment.bounds.minY - margin,
575
+ maxY: segment.bounds.maxY + margin
576
+ }
577
+ }
578
+
579
+ /**
580
+ * Resolves a point as zero-area AABB index bounds.
581
+ * @param {{ x: number, y: number }} point
582
+ * @returns {PcbScene3dPreparedPolygonBounds}
583
+ */
584
+ static #resolvePointBounds(point) {
585
+ const x = Number(point?.x)
586
+ const y = Number(point?.y)
587
+
588
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
589
+ return PcbScene3dPreparedPolygon.#resolveAllSpaceBounds()
590
+ }
591
+
592
+ return {
593
+ minX: x,
594
+ maxX: x,
595
+ minY: y,
596
+ maxY: y
597
+ }
598
+ }
599
+
600
+ /**
601
+ * Returns whether a segment's conservative index envelope overlaps a query.
602
+ * @param {PcbScene3dPreparedPolygonSegment} segment Segment metadata.
603
+ * @param {PcbScene3dPreparedPolygonBounds} queryBounds Query bounds.
604
+ * @param {number} epsilon Exact predicate tolerance.
605
+ * @returns {boolean}
606
+ */
607
+ static #segmentOverlapsQuery(segment, queryBounds, epsilon) {
608
+ if (
609
+ !PcbScene3dPreparedPolygon.#hasFiniteBounds(queryBounds) ||
610
+ segment.lengthSquared === 0 ||
611
+ !Number.isFinite(segment.lengthSquared) ||
612
+ !PcbScene3dPreparedPolygon.#hasFiniteBounds(segment.bounds)
613
+ ) {
614
+ return true
615
+ }
616
+
617
+ const margin = Math.max(
618
+ epsilon,
619
+ (Math.SQRT2 * epsilon) / Math.sqrt(segment.lengthSquared)
620
+ )
621
+ const minX = segment.bounds.minX - margin
622
+ const maxX = segment.bounds.maxX + margin
623
+ const minY = segment.bounds.minY - margin
624
+ const maxY = segment.bounds.maxY + margin
625
+
626
+ return (
627
+ !Number.isFinite(minX) ||
628
+ !Number.isFinite(maxX) ||
629
+ !Number.isFinite(minY) ||
630
+ !Number.isFinite(maxY) ||
631
+ !(
632
+ maxX < queryBounds.minX ||
633
+ minX > queryBounds.maxX ||
634
+ maxY < queryBounds.minY ||
635
+ minY > queryBounds.maxY
636
+ )
637
+ )
638
+ }
639
+
640
+ /**
641
+ * Returns whether a vertex's conservative index envelope overlaps a query.
642
+ * @param {{ x: number, y: number }} point Vertex.
643
+ * @param {PcbScene3dPreparedPolygonBounds} queryBounds Query bounds.
644
+ * @param {number} epsilon Exact predicate tolerance.
645
+ * @returns {boolean}
646
+ */
647
+ static #pointOverlapsQuery(point, queryBounds, epsilon) {
648
+ if (!PcbScene3dPreparedPolygon.#hasFiniteBounds(queryBounds)) {
649
+ return true
650
+ }
651
+
652
+ const x = Number(point?.x)
653
+ const y = Number(point?.y)
654
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
655
+ return true
656
+ }
657
+
658
+ return !(
659
+ x < queryBounds.minX - epsilon ||
660
+ x > queryBounds.maxX + epsilon ||
661
+ y < queryBounds.minY - epsilon ||
662
+ y > queryBounds.maxY + epsilon
663
+ )
664
+ }
665
+
666
+ /**
667
+ * Returns true when every bounds coordinate is finite.
668
+ * @param {PcbScene3dPreparedPolygonBounds} bounds
669
+ * @returns {boolean}
670
+ */
671
+ static #hasFiniteBounds(bounds) {
672
+ return Boolean(
673
+ bounds &&
674
+ Number.isFinite(bounds.minX) &&
675
+ Number.isFinite(bounds.maxX) &&
676
+ Number.isFinite(bounds.minY) &&
677
+ Number.isFinite(bounds.maxY)
678
+ )
679
+ }
680
+
681
+ /**
682
+ * Resolves conservative all-space bounds for non-finite exact geometry.
683
+ * @returns {PcbScene3dPreparedPolygonBounds}
684
+ */
685
+ static #resolveAllSpaceBounds() {
686
+ return {
687
+ minX: -Infinity,
688
+ maxX: Infinity,
689
+ minY: -Infinity,
690
+ maxY: Infinity
691
+ }
692
+ }
693
+
694
+ /**
695
+ * Returns true when a point falls inside finite polygon bounds.
696
+ * @param {{ x: number, y: number }} point
697
+ * @param {PcbScene3dPreparedPolygonBounds} bounds
698
+ * @param {number} [epsilon]
699
+ * @returns {boolean}
700
+ */
701
+ static #pointOverlapsBounds(point, bounds, epsilon = 0) {
702
+ return (
703
+ point.x >= bounds.minX - epsilon &&
704
+ point.x <= bounds.maxX + epsilon &&
705
+ point.y >= bounds.minY - epsilon &&
706
+ point.y <= bounds.maxY + epsilon
707
+ )
708
+ }
709
+ }