pcb-scene3d-viewer 1.0.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.
Files changed (65) hide show
  1. package/AGENTS.md +67 -0
  2. package/COMMERCIAL-LICENSE.md +15 -0
  3. package/CONTRIBUTING.md +14 -0
  4. package/LICENSE +19 -0
  5. package/LICENSES/AGPL-3.0-or-later.txt +235 -0
  6. package/LICENSES/CC-BY-SA-4.0.txt +170 -0
  7. package/LICENSES/LGPL-2.1-or-later.txt +176 -0
  8. package/LICENSES/LicenseRef-PolyForm-Noncommercial-1.0.0.txt +131 -0
  9. package/NOTICE.md +36 -0
  10. package/README.md +128 -0
  11. package/REUSE.toml +16 -0
  12. package/docs/api.md +148 -0
  13. package/docs/circuitjson.md +190 -0
  14. package/docs/model-format.md +117 -0
  15. package/docs/testing.md +23 -0
  16. package/package.json +65 -0
  17. package/spec/library-scope.md +36 -0
  18. package/src/PcbModelArchiveExporter.mjs +320 -0
  19. package/src/PcbScene3dArcUtils.mjs +27 -0
  20. package/src/PcbScene3dBoardAssemblyPlacement.mjs +36 -0
  21. package/src/PcbScene3dBoardAssemblyPresentation.mjs +859 -0
  22. package/src/PcbScene3dBoardEdgeCutoutBuilder.mjs +537 -0
  23. package/src/PcbScene3dBoardMaterialPalette.mjs +40 -0
  24. package/src/PcbScene3dBoardShapeFactory.mjs +895 -0
  25. package/src/PcbScene3dBoardSolderMaskFactory.mjs +613 -0
  26. package/src/PcbScene3dCameraRig.mjs +168 -0
  27. package/src/PcbScene3dCircuitJsonAdapter.mjs +545 -0
  28. package/src/PcbScene3dController.mjs +956 -0
  29. package/src/PcbScene3dCopperDetailFilter.mjs +490 -0
  30. package/src/PcbScene3dCopperFactory.mjs +559 -0
  31. package/src/PcbScene3dCopperTextFactory.mjs +534 -0
  32. package/src/PcbScene3dCutoutGeometryFilter.mjs +873 -0
  33. package/src/PcbScene3dDetailCoordinateNormalizer.mjs +65 -0
  34. package/src/PcbScene3dDrillCutoutFilter.mjs +224 -0
  35. package/src/PcbScene3dDrillPathFactory.mjs +362 -0
  36. package/src/PcbScene3dDrillVoidFactory.mjs +268 -0
  37. package/src/PcbScene3dExternalModelLoadOrder.mjs +54 -0
  38. package/src/PcbScene3dExternalModels.mjs +968 -0
  39. package/src/PcbScene3dFallbackVisibility.mjs +82 -0
  40. package/src/PcbScene3dInteractionHints.mjs +56 -0
  41. package/src/PcbScene3dMountRig.mjs +53 -0
  42. package/src/PcbScene3dOutlineBuilder.mjs +210 -0
  43. package/src/PcbScene3dPadFactory.mjs +553 -0
  44. package/src/PcbScene3dPresetState.mjs +48 -0
  45. package/src/PcbScene3dRenderGroupVisibility.mjs +134 -0
  46. package/src/PcbScene3dRuntime.mjs +996 -0
  47. package/src/PcbScene3dRuntimeBoardMeshes.mjs +99 -0
  48. package/src/PcbScene3dSelectionStyler.mjs +252 -0
  49. package/src/PcbScene3dShapePathFactory.mjs +220 -0
  50. package/src/PcbScene3dShellRenderer.mjs +131 -0
  51. package/src/PcbScene3dSilkscreenFactory.mjs +854 -0
  52. package/src/PcbScene3dSilkscreenStrokeWidthResolver.mjs +81 -0
  53. package/src/PcbScene3dStepLoader.mjs +611 -0
  54. package/src/PcbScene3dStrokeFont.mjs +671 -0
  55. package/src/PcbScene3dStrokeGeometryBuilder.mjs +322 -0
  56. package/src/PcbScene3dText.mjs +99 -0
  57. package/src/PcbScene3dTrueTypeTextFactory.mjs +885 -0
  58. package/src/PcbScene3dViaFactory.mjs +176 -0
  59. package/src/PcbScene3dViewCompensation.mjs +109 -0
  60. package/src/PcbScene3dViewScale.mjs +24 -0
  61. package/src/PcbScene3dViewportResize.mjs +35 -0
  62. package/src/PcbScene3dWorkerClient.mjs +123 -0
  63. package/src/index.mjs +1 -0
  64. package/src/scene3d.mjs +44 -0
  65. package/src/styles/scene3d.css +295 -0
@@ -0,0 +1,873 @@
1
+ /**
2
+ * Clips filled 2D geometry against drill-cutout polygons.
3
+ */
4
+ export class PcbScene3dCutoutGeometryFilter {
5
+ static #GEOMETRY_EPSILON = 0.001
6
+ static #DEFAULT_MAX_DEPTH = 9
7
+ static #DEFAULT_MAX_EDGE_LENGTH = 4
8
+ static #SPATIAL_INDEX_MIN_CELL_SIZE = 8
9
+ static #SPATIAL_INDEX_MAX_CELLS_PER_CUTOUT = 128
10
+
11
+ /**
12
+ * Removes triangles that still overlap cutouts after triangulation.
13
+ * @param {any} THREE
14
+ * @param {any} geometry
15
+ * @param {{ x: number, y: number }[][]} cutouts
16
+ * @param {{ maxDepth?: number, maxEdgeLength?: number }} [options]
17
+ * @returns {any}
18
+ */
19
+ static filter(THREE, geometry, cutouts, options = {}) {
20
+ if (
21
+ !Array.isArray(cutouts) ||
22
+ !cutouts.length ||
23
+ !geometry?.getAttribute ||
24
+ !THREE.BufferGeometry ||
25
+ !THREE.Float32BufferAttribute
26
+ ) {
27
+ return geometry
28
+ }
29
+
30
+ const sourceGeometry =
31
+ geometry.index && geometry.toNonIndexed
32
+ ? geometry.toNonIndexed()
33
+ : geometry
34
+ const position = sourceGeometry.getAttribute('position')
35
+ if (!position?.count) {
36
+ return geometry
37
+ }
38
+
39
+ const preparedCutouts =
40
+ PcbScene3dCutoutGeometryFilter.#prepareCutouts(cutouts)
41
+ const cutoutIndex =
42
+ PcbScene3dCutoutGeometryFilter.#buildCutoutSpatialIndex(
43
+ preparedCutouts
44
+ )
45
+ const settings =
46
+ PcbScene3dCutoutGeometryFilter.#resolveSettings(options)
47
+ const positions = []
48
+ const state = { changed: false }
49
+ for (let index = 0; index < position.count; index += 3) {
50
+ const triangle =
51
+ PcbScene3dCutoutGeometryFilter.#resolveGeometryTriangle(
52
+ position,
53
+ index
54
+ )
55
+ PcbScene3dCutoutGeometryFilter.#appendFilteredTriangle(
56
+ positions,
57
+ triangle,
58
+ preparedCutouts,
59
+ settings,
60
+ 0,
61
+ state,
62
+ cutoutIndex
63
+ )
64
+ }
65
+
66
+ if (!state.changed) {
67
+ return geometry
68
+ }
69
+
70
+ const filteredGeometry = new THREE.BufferGeometry()
71
+ filteredGeometry.setAttribute(
72
+ 'position',
73
+ new THREE.Float32BufferAttribute(positions, 3)
74
+ )
75
+ filteredGeometry.computeVertexNormals?.()
76
+ return filteredGeometry
77
+ }
78
+
79
+ /**
80
+ * Resolves clipping settings.
81
+ * @param {{ maxDepth?: number, maxEdgeLength?: number }} options
82
+ * @returns {{ maxDepth: number, maxEdgeLength: number }}
83
+ */
84
+ static #resolveSettings(options) {
85
+ const maxEdgeLength = Math.max(
86
+ Number(options?.maxEdgeLength) ||
87
+ PcbScene3dCutoutGeometryFilter.#DEFAULT_MAX_EDGE_LENGTH,
88
+ PcbScene3dCutoutGeometryFilter.#GEOMETRY_EPSILON
89
+ )
90
+
91
+ return {
92
+ maxDepth: Math.max(
93
+ Number(options?.maxDepth) ||
94
+ PcbScene3dCutoutGeometryFilter.#DEFAULT_MAX_DEPTH,
95
+ 0
96
+ ),
97
+ maxEdgeLength
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Prepares cutout polygons with bounds for fast overlap checks.
103
+ * @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 } }[]}
105
+ */
106
+ static #prepareCutouts(cutouts) {
107
+ return cutouts
108
+ .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
+ }))
115
+ }
116
+
117
+ /**
118
+ * Builds reusable segment metadata for one cutout polygon.
119
+ * @param {{ x: number, y: number }[]} points
120
+ * @returns {{ 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 } }[]}
121
+ */
122
+ static #buildCutoutSegments(points) {
123
+ const segments = []
124
+
125
+ for (let index = 0; index < points.length; index += 1) {
126
+ const start = points[index]
127
+ const end = points[(index + 1) % points.length]
128
+ const dx = end.x - start.x
129
+ const dy = end.y - start.y
130
+
131
+ segments.push({
132
+ start,
133
+ end,
134
+ dx,
135
+ dy,
136
+ lengthSquared: dx * dx + dy * dy,
137
+ bounds: {
138
+ minX: Math.min(start.x, end.x),
139
+ maxX: Math.max(start.x, end.x),
140
+ minY: Math.min(start.y, end.y),
141
+ maxY: Math.max(start.y, end.y)
142
+ }
143
+ })
144
+ }
145
+
146
+ return segments
147
+ }
148
+
149
+ /**
150
+ * Appends a triangle, subdividing near cutouts before removal.
151
+ * @param {number[]} positions
152
+ * @param {{ x: number, y: number, z: number }[]} triangle
153
+ * @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
155
+ * @param {number} depth
156
+ * @param {{ changed: boolean }} state
157
+ * @param {object | null} [cutoutIndex]
158
+ * @returns {void}
159
+ */
160
+ static #appendFilteredTriangle(
161
+ positions,
162
+ triangle,
163
+ cutouts,
164
+ settings,
165
+ depth,
166
+ state,
167
+ cutoutIndex = null
168
+ ) {
169
+ const triangleBounds =
170
+ PcbScene3dCutoutGeometryFilter.#resolveBounds(triangle)
171
+ const candidateCutouts = cutoutIndex
172
+ ? PcbScene3dCutoutGeometryFilter.#collectCandidateCutouts(
173
+ triangleBounds,
174
+ cutoutIndex
175
+ )
176
+ : cutouts
177
+ const overlappingCutouts = []
178
+ for (const cutout of candidateCutouts) {
179
+ if (
180
+ PcbScene3dCutoutGeometryFilter.#boundsOverlap(
181
+ triangleBounds,
182
+ cutout.bounds
183
+ ) &&
184
+ PcbScene3dCutoutGeometryFilter.#doesTriangleOverlapCutout(
185
+ triangle,
186
+ cutout
187
+ )
188
+ ) {
189
+ overlappingCutouts.push(cutout)
190
+ }
191
+ }
192
+
193
+ if (!overlappingCutouts.length) {
194
+ PcbScene3dCutoutGeometryFilter.#appendTriangle(positions, triangle)
195
+ return
196
+ }
197
+
198
+ state.changed = true
199
+
200
+ if (
201
+ depth >= settings.maxDepth ||
202
+ PcbScene3dCutoutGeometryFilter.#maxEdgeLength(triangle) <=
203
+ settings.maxEdgeLength
204
+ ) {
205
+ return
206
+ }
207
+
208
+ PcbScene3dCutoutGeometryFilter.#subdivideTriangle(triangle).forEach(
209
+ (childTriangle) => {
210
+ PcbScene3dCutoutGeometryFilter.#appendFilteredTriangle(
211
+ positions,
212
+ childTriangle,
213
+ overlappingCutouts,
214
+ settings,
215
+ depth + 1,
216
+ state,
217
+ null
218
+ )
219
+ }
220
+ )
221
+ }
222
+
223
+ /**
224
+ * Builds a spatial index for prepared cutout bounds.
225
+ * @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
226
+ * @returns {{ cutouts: object[], cellSize: number, cells: Map<string, number[]>, overflowIndexes: number[], marks: Uint32Array, mark: number }}
227
+ */
228
+ static #buildCutoutSpatialIndex(cutouts) {
229
+ const cellSize =
230
+ PcbScene3dCutoutGeometryFilter.#resolveSpatialCellSize(cutouts)
231
+ const cells = new Map()
232
+ const overflowIndexes = []
233
+
234
+ cutouts.forEach((cutout, index) => {
235
+ const range = PcbScene3dCutoutGeometryFilter.#resolveCellRange(
236
+ cutout.bounds,
237
+ cellSize
238
+ )
239
+ const cellCount =
240
+ (range.maxX - range.minX + 1) * (range.maxY - range.minY + 1)
241
+
242
+ if (
243
+ cellCount >
244
+ PcbScene3dCutoutGeometryFilter
245
+ .#SPATIAL_INDEX_MAX_CELLS_PER_CUTOUT
246
+ ) {
247
+ overflowIndexes.push(index)
248
+ return
249
+ }
250
+
251
+ for (let cellX = range.minX; cellX <= range.maxX; cellX += 1) {
252
+ for (let cellY = range.minY; cellY <= range.maxY; cellY += 1) {
253
+ const key = PcbScene3dCutoutGeometryFilter.#cellKey(
254
+ cellX,
255
+ cellY
256
+ )
257
+ const bucket = cells.get(key)
258
+
259
+ if (bucket) {
260
+ bucket.push(index)
261
+ } else {
262
+ cells.set(key, [index])
263
+ }
264
+ }
265
+ }
266
+ })
267
+
268
+ return {
269
+ cutouts,
270
+ cellSize,
271
+ cells,
272
+ overflowIndexes,
273
+ marks: new Uint32Array(cutouts.length),
274
+ mark: 0
275
+ }
276
+ }
277
+
278
+ /**
279
+ * Collects cutouts whose spatial buckets overlap one triangle bounds box.
280
+ * @param {{ minX: number, maxX: number, minY: number, maxY: number }} bounds
281
+ * @param {{ cutouts: object[], cellSize: number, cells: Map<string, number[]>, overflowIndexes: number[], marks: Uint32Array, mark: number }} cutoutIndex
282
+ * @returns {object[]}
283
+ */
284
+ static #collectCandidateCutouts(bounds, cutoutIndex) {
285
+ const candidates = []
286
+ const range = PcbScene3dCutoutGeometryFilter.#resolveCellRange(
287
+ bounds,
288
+ cutoutIndex.cellSize
289
+ )
290
+
291
+ cutoutIndex.mark += 1
292
+ if (cutoutIndex.mark >= 0xffffffff) {
293
+ cutoutIndex.marks.fill(0)
294
+ cutoutIndex.mark = 1
295
+ }
296
+
297
+ for (let cellX = range.minX; cellX <= range.maxX; cellX += 1) {
298
+ for (let cellY = range.minY; cellY <= range.maxY; cellY += 1) {
299
+ const bucket = cutoutIndex.cells.get(
300
+ PcbScene3dCutoutGeometryFilter.#cellKey(cellX, cellY)
301
+ )
302
+
303
+ if (bucket) {
304
+ for (const index of bucket) {
305
+ PcbScene3dCutoutGeometryFilter.#appendCutoutCandidate(
306
+ candidates,
307
+ cutoutIndex,
308
+ index
309
+ )
310
+ }
311
+ }
312
+ }
313
+ }
314
+
315
+ for (const index of cutoutIndex.overflowIndexes) {
316
+ PcbScene3dCutoutGeometryFilter.#appendCutoutCandidate(
317
+ candidates,
318
+ cutoutIndex,
319
+ index
320
+ )
321
+ }
322
+ return candidates
323
+ }
324
+
325
+ /**
326
+ * Appends one unique spatial-index cutout candidate.
327
+ * @param {object[]} candidates
328
+ * @param {{ cutouts: object[], marks: Uint32Array, mark: number }} cutoutIndex
329
+ * @param {number} index
330
+ * @returns {void}
331
+ */
332
+ static #appendCutoutCandidate(candidates, cutoutIndex, index) {
333
+ if (cutoutIndex.marks[index] === cutoutIndex.mark) {
334
+ return
335
+ }
336
+
337
+ cutoutIndex.marks[index] = cutoutIndex.mark
338
+ candidates.push(cutoutIndex.cutouts[index])
339
+ }
340
+
341
+ /**
342
+ * Resolves a spatial index cell size from typical cutout spans.
343
+ * @param {{ bounds: { minX: number, maxX: number, minY: number, maxY: number } }[]} cutouts
344
+ * @returns {number}
345
+ */
346
+ static #resolveSpatialCellSize(cutouts) {
347
+ const spans = cutouts
348
+ .map((cutout) =>
349
+ Math.max(
350
+ Number(cutout.bounds.maxX) - Number(cutout.bounds.minX),
351
+ Number(cutout.bounds.maxY) - Number(cutout.bounds.minY),
352
+ 0
353
+ )
354
+ )
355
+ .filter((span) => Number.isFinite(span))
356
+ .sort((left, right) => left - right)
357
+ const medianSpan = spans[Math.floor(spans.length / 2)] || 0
358
+
359
+ return Math.max(
360
+ medianSpan * 4,
361
+ PcbScene3dCutoutGeometryFilter.#DEFAULT_MAX_EDGE_LENGTH * 2,
362
+ PcbScene3dCutoutGeometryFilter.#SPATIAL_INDEX_MIN_CELL_SIZE
363
+ )
364
+ }
365
+
366
+ /**
367
+ * Resolves the inclusive spatial cell range for a bounds box.
368
+ * @param {{ minX: number, maxX: number, minY: number, maxY: number }} bounds
369
+ * @param {number} cellSize
370
+ * @returns {{ minX: number, maxX: number, minY: number, maxY: number }}
371
+ */
372
+ static #resolveCellRange(bounds, cellSize) {
373
+ return {
374
+ minX: Math.floor(Number(bounds.minX) / cellSize),
375
+ maxX: Math.floor(Number(bounds.maxX) / cellSize),
376
+ minY: Math.floor(Number(bounds.minY) / cellSize),
377
+ maxY: Math.floor(Number(bounds.maxY) / cellSize)
378
+ }
379
+ }
380
+
381
+ /**
382
+ * Builds one deterministic spatial index key.
383
+ * @param {number} cellX
384
+ * @param {number} cellY
385
+ * @returns {string}
386
+ */
387
+ static #cellKey(cellX, cellY) {
388
+ return `${cellX}:${cellY}`
389
+ }
390
+
391
+ /**
392
+ * Appends one triangle to the flattened position buffer.
393
+ * @param {number[]} positions
394
+ * @param {{ x: number, y: number, z: number }[]} triangle
395
+ * @returns {void}
396
+ */
397
+ static #appendTriangle(positions, triangle) {
398
+ for (const point of triangle) {
399
+ positions.push(point.x, point.y, point.z)
400
+ }
401
+ }
402
+
403
+ /**
404
+ * Splits one triangle into four child triangles.
405
+ * @param {{ x: number, y: number, z: number }[]} triangle
406
+ * @returns {{ x: number, y: number, z: number }[][]}
407
+ */
408
+ static #subdivideTriangle(triangle) {
409
+ const [first, second, third] = triangle
410
+ const firstSecond = PcbScene3dCutoutGeometryFilter.#midpoint(
411
+ first,
412
+ second
413
+ )
414
+ const secondThird = PcbScene3dCutoutGeometryFilter.#midpoint(
415
+ second,
416
+ third
417
+ )
418
+ const thirdFirst = PcbScene3dCutoutGeometryFilter.#midpoint(
419
+ third,
420
+ first
421
+ )
422
+
423
+ return [
424
+ [first, firstSecond, thirdFirst],
425
+ [firstSecond, second, secondThird],
426
+ [thirdFirst, secondThird, third],
427
+ [firstSecond, secondThird, thirdFirst]
428
+ ]
429
+ }
430
+
431
+ /**
432
+ * Resolves the midpoint between two 3D points.
433
+ * @param {{ x: number, y: number, z: number }} first
434
+ * @param {{ x: number, y: number, z: number }} second
435
+ * @returns {{ x: number, y: number, z: number }}
436
+ */
437
+ static #midpoint(first, second) {
438
+ return {
439
+ x: (first.x + second.x) / 2,
440
+ y: (first.y + second.y) / 2,
441
+ z: (first.z + second.z) / 2
442
+ }
443
+ }
444
+
445
+ /**
446
+ * Resolves the longest edge in one triangle.
447
+ * @param {{ x: number, y: number }[]} triangle
448
+ * @returns {number}
449
+ */
450
+ static #maxEdgeLength(triangle) {
451
+ let maxLength = 0
452
+
453
+ for (let index = 0; index < triangle.length; index += 1) {
454
+ const point = triangle[index]
455
+ 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
+ )
460
+ }
461
+
462
+ return maxLength
463
+ }
464
+
465
+ /**
466
+ * Resolves a polygon or triangle bounding box.
467
+ * @param {{ x: number, y: number }[]} points
468
+ * @returns {{ minX: number, maxX: number, minY: number, maxY: number }}
469
+ */
470
+ static #resolveBounds(points) {
471
+ const bounds = {
472
+ minX: Infinity,
473
+ maxX: -Infinity,
474
+ minY: Infinity,
475
+ maxY: -Infinity
476
+ }
477
+
478
+ for (const point of points) {
479
+ const x = Number(point.x || 0)
480
+ const y = Number(point.y || 0)
481
+
482
+ bounds.minX = Math.min(bounds.minX, x)
483
+ bounds.maxX = Math.max(bounds.maxX, x)
484
+ bounds.minY = Math.min(bounds.minY, y)
485
+ bounds.maxY = Math.max(bounds.maxY, y)
486
+ }
487
+
488
+ return bounds
489
+ }
490
+
491
+ /**
492
+ * Returns true when two bounding boxes overlap.
493
+ * @param {{ minX: number, maxX: number, minY: number, maxY: number }} first
494
+ * @param {{ minX: number, maxX: number, minY: number, maxY: number }} second
495
+ * @returns {boolean}
496
+ */
497
+ static #boundsOverlap(first, second) {
498
+ return (
499
+ first.minX <= second.maxX &&
500
+ first.maxX >= second.minX &&
501
+ first.minY <= second.maxY &&
502
+ first.maxY >= second.minY
503
+ )
504
+ }
505
+
506
+ /**
507
+ * Resolves one XY triangle from a geometry position attribute.
508
+ * @param {any} position
509
+ * @param {number} startIndex
510
+ * @returns {{ x: number, y: number, z: number }[]}
511
+ */
512
+ static #resolveGeometryTriangle(position, startIndex) {
513
+ return [
514
+ PcbScene3dCutoutGeometryFilter.#resolveGeometryPoint(
515
+ position,
516
+ startIndex
517
+ ),
518
+ PcbScene3dCutoutGeometryFilter.#resolveGeometryPoint(
519
+ position,
520
+ startIndex + 1
521
+ ),
522
+ PcbScene3dCutoutGeometryFilter.#resolveGeometryPoint(
523
+ position,
524
+ startIndex + 2
525
+ )
526
+ ]
527
+ }
528
+
529
+ /**
530
+ * Resolves one geometry position as a 3D point.
531
+ * @param {any} position
532
+ * @param {number} index
533
+ * @returns {{ x: number, y: number, z: number }}
534
+ */
535
+ static #resolveGeometryPoint(position, index) {
536
+ return {
537
+ x: Number(position.getX(index)),
538
+ y: Number(position.getY(index)),
539
+ z: Number(position.getZ?.(index) || 0)
540
+ }
541
+ }
542
+
543
+ /**
544
+ * Returns true when one triangle intersects or covers a cutout.
545
+ * @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
547
+ * @returns {boolean}
548
+ */
549
+ static #doesTriangleOverlapCutout(triangle, cutout) {
550
+ if (
551
+ !Array.isArray(triangle) ||
552
+ triangle.length !== 3 ||
553
+ !Array.isArray(cutout?.points) ||
554
+ cutout.points.length < 3
555
+ ) {
556
+ return false
557
+ }
558
+
559
+ for (const point of triangle) {
560
+ if (
561
+ PcbScene3dCutoutGeometryFilter.#isPointInsideOrOnCutout(
562
+ point,
563
+ cutout
564
+ )
565
+ ) {
566
+ return true
567
+ }
568
+ }
569
+
570
+ for (const point of cutout.points) {
571
+ if (
572
+ PcbScene3dCutoutGeometryFilter.#isPointInsideOrOnTriangle(
573
+ point,
574
+ triangle
575
+ )
576
+ ) {
577
+ return true
578
+ }
579
+ }
580
+
581
+ return PcbScene3dCutoutGeometryFilter.#hasIntersectingEdges(
582
+ triangle,
583
+ cutout
584
+ )
585
+ }
586
+
587
+ /**
588
+ * Returns true when a point is inside or on a cutout.
589
+ * @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
591
+ * @returns {boolean}
592
+ */
593
+ static #isPointInsideOrOnCutout(point, cutout) {
594
+ return (
595
+ PcbScene3dCutoutGeometryFilter.#isPointOnCutoutBoundary(
596
+ point,
597
+ cutout
598
+ ) ||
599
+ PcbScene3dCutoutGeometryFilter.#isPointStrictlyInsideCutout(
600
+ point,
601
+ cutout
602
+ )
603
+ )
604
+ }
605
+
606
+ /**
607
+ * Returns true when a point lies inside a cutout and away from its border.
608
+ * @param {{ x: number, y: number }} point
609
+ * @param {{ points: { x: number, y: number }[], bounds: { minX: number, maxX: number, minY: number, maxY: number } }} cutout
610
+ * @returns {boolean}
611
+ */
612
+ static #isPointStrictlyInsideCutout(point, cutout) {
613
+ const polygon = cutout.points
614
+
615
+ let inside = false
616
+ for (
617
+ let index = 0, previousIndex = polygon.length - 1;
618
+ index < polygon.length;
619
+ previousIndex = index, index += 1
620
+ ) {
621
+ const current = polygon[index]
622
+ const previous = polygon[previousIndex]
623
+ const intersects =
624
+ current.y > point.y !== previous.y > point.y &&
625
+ point.x <
626
+ ((previous.x - current.x) * (point.y - current.y)) /
627
+ (previous.y - current.y) +
628
+ current.x
629
+
630
+ if (intersects) {
631
+ inside = !inside
632
+ }
633
+ }
634
+
635
+ return inside
636
+ }
637
+
638
+ /**
639
+ * Returns true when a point lies on a cutout edge.
640
+ * @param {{ x: number, y: number }} point
641
+ * @param {{ points: { x: number, y: number }[] }} cutout
642
+ * @returns {boolean}
643
+ */
644
+ static #isPointOnCutoutBoundary(point, cutout) {
645
+ const polygon = cutout.points
646
+
647
+ for (let index = 0; index < polygon.length; index += 1) {
648
+ if (
649
+ PcbScene3dCutoutGeometryFilter.#isPointOnSegment(
650
+ point,
651
+ polygon[index],
652
+ polygon[(index + 1) % polygon.length]
653
+ )
654
+ ) {
655
+ return true
656
+ }
657
+ }
658
+
659
+ return false
660
+ }
661
+
662
+ /**
663
+ * Returns true when a point is inside or on one triangle.
664
+ * @param {{ x: number, y: number }} point
665
+ * @param {{ x: number, y: number }[]} triangle
666
+ * @returns {boolean}
667
+ */
668
+ static #isPointInsideOrOnTriangle(point, triangle) {
669
+ let hasNegative = false
670
+ let hasPositive = false
671
+
672
+ for (let index = 0; index < triangle.length; index += 1) {
673
+ const current = triangle[index]
674
+ const next = triangle[(index + 1) % triangle.length]
675
+ const sign = PcbScene3dCutoutGeometryFilter.#cross(
676
+ point,
677
+ current,
678
+ next
679
+ )
680
+
681
+ hasNegative =
682
+ hasNegative ||
683
+ sign < -PcbScene3dCutoutGeometryFilter.#GEOMETRY_EPSILON
684
+ hasPositive =
685
+ hasPositive ||
686
+ sign > PcbScene3dCutoutGeometryFilter.#GEOMETRY_EPSILON
687
+ }
688
+
689
+ return !(hasNegative && hasPositive)
690
+ }
691
+
692
+ /**
693
+ * Returns true when any triangle and cutout edges intersect.
694
+ * @param {{ x: number, y: number }[]} triangle
695
+ * @param {{ segments: { start: { x: number, y: number }, end: { x: number, y: number }, bounds: { minX: number, maxX: number, minY: number, maxY: number } }[] }} cutout
696
+ * @returns {boolean}
697
+ */
698
+ static #hasIntersectingEdges(triangle, cutout) {
699
+ for (
700
+ let triangleIndex = 0;
701
+ triangleIndex < triangle.length;
702
+ triangleIndex += 1
703
+ ) {
704
+ const triangleStart = triangle[triangleIndex]
705
+ const triangleEnd = triangle[(triangleIndex + 1) % triangle.length]
706
+
707
+ for (const segment of cutout.segments) {
708
+ if (
709
+ PcbScene3dCutoutGeometryFilter.#segmentsIntersect(
710
+ triangleStart,
711
+ triangleEnd,
712
+ segment.start,
713
+ segment.end
714
+ )
715
+ ) {
716
+ return true
717
+ }
718
+ }
719
+ }
720
+
721
+ return false
722
+ }
723
+
724
+ /**
725
+ * Returns true when two finite line segments intersect.
726
+ * @param {{ x: number, y: number }} firstStart
727
+ * @param {{ x: number, y: number }} firstEnd
728
+ * @param {{ x: number, y: number }} secondStart
729
+ * @param {{ x: number, y: number }} secondEnd
730
+ * @returns {boolean}
731
+ */
732
+ static #segmentsIntersect(firstStart, firstEnd, secondStart, secondEnd) {
733
+ const firstOrientation = PcbScene3dCutoutGeometryFilter.#cross(
734
+ firstStart,
735
+ firstEnd,
736
+ secondStart
737
+ )
738
+ const secondOrientation = PcbScene3dCutoutGeometryFilter.#cross(
739
+ firstStart,
740
+ firstEnd,
741
+ secondEnd
742
+ )
743
+ const thirdOrientation = PcbScene3dCutoutGeometryFilter.#cross(
744
+ secondStart,
745
+ secondEnd,
746
+ firstStart
747
+ )
748
+ const fourthOrientation = PcbScene3dCutoutGeometryFilter.#cross(
749
+ secondStart,
750
+ secondEnd,
751
+ firstEnd
752
+ )
753
+
754
+ if (
755
+ PcbScene3dCutoutGeometryFilter.#hasOppositeSigns(
756
+ firstOrientation,
757
+ secondOrientation
758
+ ) &&
759
+ PcbScene3dCutoutGeometryFilter.#hasOppositeSigns(
760
+ thirdOrientation,
761
+ fourthOrientation
762
+ )
763
+ ) {
764
+ return true
765
+ }
766
+
767
+ return (
768
+ PcbScene3dCutoutGeometryFilter.#isCollinearPointOnSegment(
769
+ secondStart,
770
+ firstStart,
771
+ firstEnd,
772
+ firstOrientation
773
+ ) ||
774
+ PcbScene3dCutoutGeometryFilter.#isCollinearPointOnSegment(
775
+ secondEnd,
776
+ firstStart,
777
+ firstEnd,
778
+ secondOrientation
779
+ ) ||
780
+ PcbScene3dCutoutGeometryFilter.#isCollinearPointOnSegment(
781
+ firstStart,
782
+ secondStart,
783
+ secondEnd,
784
+ thirdOrientation
785
+ ) ||
786
+ PcbScene3dCutoutGeometryFilter.#isCollinearPointOnSegment(
787
+ firstEnd,
788
+ secondStart,
789
+ secondEnd,
790
+ fourthOrientation
791
+ )
792
+ )
793
+ }
794
+
795
+ /**
796
+ * Returns true when two signed areas are meaningfully opposite.
797
+ * @param {number} first
798
+ * @param {number} second
799
+ * @returns {boolean}
800
+ */
801
+ static #hasOppositeSigns(first, second) {
802
+ return (
803
+ (first > PcbScene3dCutoutGeometryFilter.#GEOMETRY_EPSILON &&
804
+ second < -PcbScene3dCutoutGeometryFilter.#GEOMETRY_EPSILON) ||
805
+ (first < -PcbScene3dCutoutGeometryFilter.#GEOMETRY_EPSILON &&
806
+ second > PcbScene3dCutoutGeometryFilter.#GEOMETRY_EPSILON)
807
+ )
808
+ }
809
+
810
+ /**
811
+ * Returns true when a collinear point lies on a segment.
812
+ * @param {{ x: number, y: number }} point
813
+ * @param {{ x: number, y: number }} start
814
+ * @param {{ x: number, y: number }} end
815
+ * @param {number} orientation
816
+ * @returns {boolean}
817
+ */
818
+ static #isCollinearPointOnSegment(point, start, end, orientation) {
819
+ return (
820
+ Math.abs(orientation) <=
821
+ PcbScene3dCutoutGeometryFilter.#GEOMETRY_EPSILON &&
822
+ PcbScene3dCutoutGeometryFilter.#isPointOnSegment(point, start, end)
823
+ )
824
+ }
825
+
826
+ /**
827
+ * Returns true when a point lies on a segment within geometry tolerance.
828
+ * @param {{ x: number, y: number }} point
829
+ * @param {{ x: number, y: number }} start
830
+ * @param {{ x: number, y: number }} end
831
+ * @returns {boolean}
832
+ */
833
+ static #isPointOnSegment(point, start, end) {
834
+ const cross =
835
+ (point.y - start.y) * (end.x - start.x) -
836
+ (point.x - start.x) * (end.y - start.y)
837
+
838
+ if (
839
+ Math.abs(cross) > PcbScene3dCutoutGeometryFilter.#GEOMETRY_EPSILON
840
+ ) {
841
+ return false
842
+ }
843
+
844
+ const dot =
845
+ (point.x - start.x) * (end.x - start.x) +
846
+ (point.y - start.y) * (end.y - start.y)
847
+
848
+ if (dot < -PcbScene3dCutoutGeometryFilter.#GEOMETRY_EPSILON) {
849
+ return false
850
+ }
851
+
852
+ const lengthSquared = (end.x - start.x) ** 2 + (end.y - start.y) ** 2
853
+
854
+ return (
855
+ dot <=
856
+ lengthSquared + PcbScene3dCutoutGeometryFilter.#GEOMETRY_EPSILON
857
+ )
858
+ }
859
+
860
+ /**
861
+ * Resolves the signed area for three points.
862
+ * @param {{ x: number, y: number }} first
863
+ * @param {{ x: number, y: number }} second
864
+ * @param {{ x: number, y: number }} third
865
+ * @returns {number}
866
+ */
867
+ static #cross(first, second, third) {
868
+ return (
869
+ (second.x - first.x) * (third.y - first.y) -
870
+ (second.y - first.y) * (third.x - first.x)
871
+ )
872
+ }
873
+ }