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,184 @@
1
+ /**
2
+ * Stable cell-grid candidate index matching cutout filter broad-phase rules.
3
+ */
4
+ export class PcbScene3dCutoutGridIndex {
5
+ static #DEFAULT_MAX_EDGE_LENGTH = 4
6
+ static #MIN_CELL_SIZE = 8
7
+ static #MAX_CELLS_PER_CUTOUT = 128
8
+
9
+ /** @type {*[]} */
10
+ #cutouts
11
+
12
+ /** @type {number} */
13
+ #cellSize
14
+
15
+ /** @type {Map<string, number[]>} */
16
+ #cells
17
+
18
+ /** @type {number[]} */
19
+ #overflowIndexes
20
+
21
+ /** @type {Uint32Array} */
22
+ #marks
23
+
24
+ /** @type {number} */
25
+ #mark
26
+
27
+ /**
28
+ * Builds the established source-order cutout cell index.
29
+ * @param {Iterable<{ bounds: { minX: number, maxX: number, minY: number, maxY: number } }>} cutouts
30
+ */
31
+ constructor(cutouts) {
32
+ this.#cutouts = Array.from(cutouts || [])
33
+ this.#cellSize = PcbScene3dCutoutGridIndex.#resolveCellSize(
34
+ this.#cutouts
35
+ )
36
+ this.#cells = new Map()
37
+ this.#overflowIndexes = []
38
+ this.#marks = new Uint32Array(this.#cutouts.length)
39
+ this.#mark = 0
40
+
41
+ this.#indexCutouts()
42
+ }
43
+
44
+ /**
45
+ * Returns unique candidates in established cell traversal order.
46
+ * @param {{ minX: number, maxX: number, minY: number, maxY: number }} bounds
47
+ * @returns {*[]}
48
+ */
49
+ query(bounds) {
50
+ const candidates = []
51
+ const range = PcbScene3dCutoutGridIndex.#resolveCellRange(
52
+ bounds,
53
+ this.#cellSize
54
+ )
55
+
56
+ this.#advanceMark()
57
+ for (let cellX = range.minX; cellX <= range.maxX; cellX += 1) {
58
+ for (let cellY = range.minY; cellY <= range.maxY; cellY += 1) {
59
+ const bucket = this.#cells.get(
60
+ PcbScene3dCutoutGridIndex.#cellKey(cellX, cellY)
61
+ )
62
+ if (bucket) {
63
+ for (const index of bucket) {
64
+ this.#appendCandidate(candidates, index)
65
+ }
66
+ }
67
+ }
68
+ }
69
+
70
+ for (const index of this.#overflowIndexes) {
71
+ this.#appendCandidate(candidates, index)
72
+ }
73
+ return candidates
74
+ }
75
+
76
+ /**
77
+ * Inserts finite-size cutouts into source-order cell buckets.
78
+ * @returns {void}
79
+ */
80
+ #indexCutouts() {
81
+ this.#cutouts.forEach((cutout, index) => {
82
+ const range = PcbScene3dCutoutGridIndex.#resolveCellRange(
83
+ cutout.bounds,
84
+ this.#cellSize
85
+ )
86
+ const cellCount =
87
+ (range.maxX - range.minX + 1) * (range.maxY - range.minY + 1)
88
+
89
+ if (cellCount > PcbScene3dCutoutGridIndex.#MAX_CELLS_PER_CUTOUT) {
90
+ this.#overflowIndexes.push(index)
91
+ return
92
+ }
93
+
94
+ for (let cellX = range.minX; cellX <= range.maxX; cellX += 1) {
95
+ for (let cellY = range.minY; cellY <= range.maxY; cellY += 1) {
96
+ const key = PcbScene3dCutoutGridIndex.#cellKey(cellX, cellY)
97
+ const bucket = this.#cells.get(key)
98
+ if (bucket) {
99
+ bucket.push(index)
100
+ } else {
101
+ this.#cells.set(key, [index])
102
+ }
103
+ }
104
+ }
105
+ })
106
+ }
107
+
108
+ /**
109
+ * Advances the query marker, clearing marks before integer rollover.
110
+ * @returns {void}
111
+ */
112
+ #advanceMark() {
113
+ this.#mark += 1
114
+ if (this.#mark >= 0xffffffff) {
115
+ this.#marks.fill(0)
116
+ this.#mark = 1
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Appends one source-index candidate at most once per query.
122
+ * @param {*[]} candidates
123
+ * @param {number} index
124
+ * @returns {void}
125
+ */
126
+ #appendCandidate(candidates, index) {
127
+ if (this.#marks[index] === this.#mark) {
128
+ return
129
+ }
130
+
131
+ this.#marks[index] = this.#mark
132
+ candidates.push(this.#cutouts[index])
133
+ }
134
+
135
+ /**
136
+ * Resolves cell size from the established median-span policy.
137
+ * @param {{ bounds: { minX: number, maxX: number, minY: number, maxY: number } }[]} cutouts
138
+ * @returns {number}
139
+ */
140
+ static #resolveCellSize(cutouts) {
141
+ const spans = cutouts
142
+ .map((cutout) =>
143
+ Math.max(
144
+ Number(cutout.bounds.maxX) - Number(cutout.bounds.minX),
145
+ Number(cutout.bounds.maxY) - Number(cutout.bounds.minY),
146
+ 0
147
+ )
148
+ )
149
+ .filter((span) => Number.isFinite(span))
150
+ .sort((left, right) => left - right)
151
+ const medianSpan = spans[Math.floor(spans.length / 2)] || 0
152
+
153
+ return Math.max(
154
+ medianSpan * 4,
155
+ PcbScene3dCutoutGridIndex.#DEFAULT_MAX_EDGE_LENGTH * 2,
156
+ PcbScene3dCutoutGridIndex.#MIN_CELL_SIZE
157
+ )
158
+ }
159
+
160
+ /**
161
+ * Resolves an inclusive cell range without coordinate epsilon expansion.
162
+ * @param {{ minX: number, maxX: number, minY: number, maxY: number }} bounds
163
+ * @param {number} cellSize
164
+ * @returns {{ minX: number, maxX: number, minY: number, maxY: number }}
165
+ */
166
+ static #resolveCellRange(bounds, cellSize) {
167
+ return {
168
+ minX: Math.floor(Number(bounds.minX) / cellSize),
169
+ maxX: Math.floor(Number(bounds.maxX) / cellSize),
170
+ minY: Math.floor(Number(bounds.minY) / cellSize),
171
+ maxY: Math.floor(Number(bounds.maxY) / cellSize)
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Builds one deterministic cell key.
177
+ * @param {number} cellX
178
+ * @param {number} cellY
179
+ * @returns {string}
180
+ */
181
+ static #cellKey(cellX, cellY) {
182
+ return `${cellX}:${cellY}`
183
+ }
184
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Gates one handled model-load result until deferred detail can integrate it.
3
+ */
4
+ export class PcbScene3dDeferredModelFinalizer {
5
+ #releaseGate
6
+ #completion
7
+ #reportError
8
+
9
+ /**
10
+ * @param {Promise<any>} modelPromise
11
+ * @param {{ isDisposed: () => boolean, onSuccess: (value: any) => void, onError: (error: any) => void }} hooks
12
+ */
13
+ constructor(modelPromise, hooks) {
14
+ let releaseGate = () => {}
15
+ const integrationGate = new Promise((resolve) => {
16
+ releaseGate = resolve
17
+ })
18
+ this.#releaseGate = releaseGate
19
+ this.#reportError = true
20
+ this.#completion = Promise.all([
21
+ modelPromise.then(
22
+ (value) => ({ status: 'fulfilled', value }),
23
+ (error) => ({ status: 'rejected', error })
24
+ ),
25
+ integrationGate
26
+ ]).then(([result]) => {
27
+ if (hooks.isDisposed()) {
28
+ return
29
+ }
30
+ if (result.status === 'rejected') {
31
+ if (this.#reportError) {
32
+ hooks.onError(result.error)
33
+ }
34
+ return
35
+ }
36
+ hooks.onSuccess(result.value)
37
+ })
38
+ }
39
+
40
+ /**
41
+ * Opens the integration gate and resolves after model finalization.
42
+ * @param {{ reportError?: boolean }} [options]
43
+ * @returns {Promise<void>}
44
+ */
45
+ release(options = {}) {
46
+ if (options.reportError === false) {
47
+ this.#reportError = false
48
+ }
49
+ this.#releaseGate()
50
+ return this.#completion
51
+ }
52
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Copies records across untrusted viewer boundaries without invoking accessors.
3
+ */
4
+ export class PcbScene3dDescriptorSafeRecord {
5
+ /**
6
+ * Copies enumerable own data properties without invoking accessors.
7
+ * @param {unknown} value Record candidate.
8
+ * @returns {Record<string, unknown>}
9
+ */
10
+ static copy(value) {
11
+ if (!value || typeof value !== 'object') return {}
12
+ let descriptors
13
+ try {
14
+ descriptors = Object.getOwnPropertyDescriptors(value)
15
+ } catch {
16
+ return {}
17
+ }
18
+
19
+ const result = {}
20
+ for (const key of Reflect.ownKeys(descriptors)) {
21
+ const descriptor = descriptors[key]
22
+ if (
23
+ typeof key !== 'string' ||
24
+ descriptor.enumerable !== true ||
25
+ !Object.hasOwn(descriptor, 'value')
26
+ ) {
27
+ continue
28
+ }
29
+ Object.defineProperty(result, key, {
30
+ configurable: true,
31
+ enumerable: true,
32
+ value: descriptor.value,
33
+ writable: true
34
+ })
35
+ }
36
+ return result
37
+ }
38
+ }
@@ -1,3 +1,6 @@
1
+ import { PcbScene3dPreparedPolygon } from './PcbScene3dPreparedPolygon.mjs'
2
+ import { PcbScene3dPreparedPolygonSet } from './PcbScene3dPreparedPolygonSet.mjs'
3
+
1
4
  /**
2
5
  * Filters drill cutout polygons before they are applied to filled artwork.
3
6
  */
@@ -7,18 +10,28 @@ export class PcbScene3dDrillCutoutFilter {
7
10
  /**
8
11
  * Removes cutout polygons that are fully covered by a larger sibling.
9
12
  * @param {{ x: number, y: number }[][]} cutouts Cutout polygons.
13
+ * @param {{ preparedPolygonCache?: Map }} [options] Request-scoped options.
10
14
  * @returns {{ x: number, y: number }[][]}
11
15
  */
12
- static removeNestedCutouts(cutouts) {
13
- const validCutoutInfos =
14
- PcbScene3dDrillCutoutFilter.#buildPolygonInfos(cutouts)
16
+ static removeNestedCutouts(cutouts, options = {}) {
17
+ const preparedPolygonCache =
18
+ PcbScene3dDrillCutoutFilter.#resolvePreparedPolygonCache(options)
19
+ const validCutoutInfos = PcbScene3dDrillCutoutFilter.#buildPolygonInfos(
20
+ cutouts,
21
+ preparedPolygonCache
22
+ )
23
+ const candidateContext =
24
+ PcbScene3dDrillCutoutFilter.#buildCandidateContext(validCutoutInfos)
15
25
 
16
26
  return validCutoutInfos
17
27
  .filter(
18
28
  (cutoutInfo) =>
19
29
  !PcbScene3dDrillCutoutFilter.#isNestedCutoutInfo(
20
30
  cutoutInfo,
21
- validCutoutInfos
31
+ PcbScene3dDrillCutoutFilter.#queryCandidates(
32
+ candidateContext,
33
+ cutoutInfo.bounds
34
+ )
22
35
  )
23
36
  )
24
37
  .map((cutoutInfo) => cutoutInfo.source)
@@ -27,13 +40,18 @@ export class PcbScene3dDrillCutoutFilter {
27
40
  /**
28
41
  * Builds reusable polygon metadata for valid polygons.
29
42
  * @param {{ x: number, y: number }[][]} polygons Source polygons.
30
- * @returns {{ source: { x: number, y: number }[], points: { x: number, y: number }[], bounds: { minX: number, maxX: number, minY: number, maxY: number }, centroid: { x: number, y: number }, area: number, index: number }[]}
43
+ * @param {Map | null} preparedPolygonCache Request-scoped prepared cache.
44
+ * @returns {{ source: { x: number, y: number }[], points: { x: number, y: number }[], bounds: { minX: number, maxX: number, minY: number, maxY: number }, centroid: { x: number, y: number }, area: number, index: number, prepared: PcbScene3dPreparedPolygon }[]}
31
45
  */
32
- static #buildPolygonInfos(polygons) {
46
+ static #buildPolygonInfos(polygons, preparedPolygonCache) {
33
47
  return (Array.isArray(polygons) ? polygons : [])
34
48
  .filter((polygon) => Array.isArray(polygon) && polygon.length >= 3)
35
49
  .map((polygon, index) =>
36
- PcbScene3dDrillCutoutFilter.#buildPolygonInfo(polygon, index)
50
+ PcbScene3dDrillCutoutFilter.#buildPolygonInfo(
51
+ polygon,
52
+ index,
53
+ preparedPolygonCache
54
+ )
37
55
  )
38
56
  }
39
57
 
@@ -41,48 +59,97 @@ export class PcbScene3dDrillCutoutFilter {
41
59
  * Builds reusable numeric metadata for one polygon.
42
60
  * @param {{ x: number, y: number }[]} polygon Source polygon.
43
61
  * @param {number} index Polygon index among valid polygons.
44
- * @returns {{ source: { x: number, y: number }[], points: { x: number, y: number }[], bounds: { minX: number, maxX: number, minY: number, maxY: number }, centroid: { x: number, y: number }, area: number, index: number }}
62
+ * @param {Map | null} preparedPolygonCache Request-scoped prepared cache.
63
+ * @returns {{ source: { x: number, y: number }[], points: { x: number, y: number }[], bounds: { minX: number, maxX: number, minY: number, maxY: number }, centroid: { x: number, y: number }, area: number, index: number, prepared: PcbScene3dPreparedPolygon }}
45
64
  */
46
- static #buildPolygonInfo(polygon, index) {
47
- const points = polygon.map((point) => ({
48
- x: Number(point?.x || 0),
49
- y: Number(point?.y || 0)
50
- }))
51
- const bounds = {
52
- minX: Infinity,
53
- maxX: -Infinity,
54
- minY: Infinity,
55
- maxY: -Infinity
65
+ static #buildPolygonInfo(polygon, index, preparedPolygonCache) {
66
+ let prepared = preparedPolygonCache?.has(polygon)
67
+ ? preparedPolygonCache.get(polygon)
68
+ : null
69
+
70
+ if (
71
+ !['numeric', 'raw-numeric'].includes(prepared?.pointRepresentation)
72
+ ) {
73
+ const points = polygon.map((point) => ({
74
+ x: Number(point?.x || 0),
75
+ y: Number(point?.y || 0)
76
+ }))
77
+ prepared = new PcbScene3dPreparedPolygon(points, {
78
+ source: polygon,
79
+ sourceIndex: index,
80
+ epsilon: PcbScene3dDrillCutoutFilter.#GEOMETRY_EPSILON,
81
+ detectCircle: false,
82
+ pointRepresentation: 'numeric'
83
+ })
84
+ preparedPolygonCache?.set(polygon, prepared)
85
+ }
86
+
87
+ return {
88
+ source: polygon,
89
+ points: prepared.points,
90
+ bounds: prepared.bounds,
91
+ centroid: prepared.centroid,
92
+ area: prepared.area,
93
+ index,
94
+ prepared
56
95
  }
57
- let area = 0
58
- let totalX = 0
59
- let totalY = 0
96
+ }
60
97
 
61
- points.forEach((point, pointIndex) => {
62
- const next = points[(pointIndex + 1) % points.length]
98
+ /**
99
+ * Resolves a supported request-scoped prepared polygon cache.
100
+ * @param {{ preparedPolygonCache?: Map }} options Request options.
101
+ * @returns {Map | null}
102
+ */
103
+ static #resolvePreparedPolygonCache(options) {
104
+ return options?.preparedPolygonCache instanceof Map
105
+ ? options.preparedPolygonCache
106
+ : null
107
+ }
63
108
 
64
- bounds.minX = Math.min(bounds.minX, point.x)
65
- bounds.maxX = Math.max(bounds.maxX, point.x)
66
- bounds.minY = Math.min(bounds.minY, point.y)
67
- bounds.maxY = Math.max(bounds.maxY, point.y)
68
- totalX += point.x
69
- totalY += point.y
70
- area += point.x * next.y - next.x * point.y
71
- })
109
+ /**
110
+ * Builds one stable broad-phase set and duplicate-aware info groups.
111
+ * @param {{ prepared: PcbScene3dPreparedPolygon }[]} polygonInfos Polygon metadata.
112
+ * @returns {{ set: PcbScene3dPreparedPolygonSet, infoGroups: Map<PcbScene3dPreparedPolygon, object[]> }}
113
+ */
114
+ static #buildCandidateContext(polygonInfos) {
115
+ const infoGroups = new Map()
116
+
117
+ for (const polygonInfo of polygonInfos) {
118
+ const group = infoGroups.get(polygonInfo.prepared) || []
119
+ group.push(polygonInfo)
120
+ infoGroups.set(polygonInfo.prepared, group)
121
+ }
72
122
 
73
123
  return {
74
- source: polygon,
75
- points,
76
- bounds,
77
- centroid: {
78
- x: totalX / Math.max(points.length, 1),
79
- y: totalY / Math.max(points.length, 1)
80
- },
81
- area: Math.abs(area) / 2,
82
- index
124
+ set: new PcbScene3dPreparedPolygonSet(
125
+ polygonInfos.map((polygonInfo) => polygonInfo.prepared)
126
+ ),
127
+ infoGroups
83
128
  }
84
129
  }
85
130
 
131
+ /**
132
+ * Returns stable polygon-info candidates whose bounds overlap a query.
133
+ * @param {{ set: PcbScene3dPreparedPolygonSet, infoGroups: Map<PcbScene3dPreparedPolygon, object[]> }} context Candidate context.
134
+ * @param {{ minX: number, maxX: number, minY: number, maxY: number }} bounds Query bounds.
135
+ * @returns {object[]}
136
+ */
137
+ static #queryCandidates(context, bounds) {
138
+ const groupOffsets = new Map()
139
+
140
+ return context.set
141
+ .query(bounds, {
142
+ epsilon: PcbScene3dDrillCutoutFilter.#GEOMETRY_EPSILON,
143
+ stable: true
144
+ })
145
+ .map((prepared) => {
146
+ const offset = groupOffsets.get(prepared) || 0
147
+ const group = context.infoGroups.get(prepared)
148
+ groupOffsets.set(prepared, offset + 1)
149
+ return group[offset]
150
+ })
151
+ }
152
+
86
153
  /**
87
154
  * Maps polygon source arrays to their reusable metadata.
88
155
  * @param {{ source: { x: number, y: number }[] }[]} polygonInfos Polygon metadata.
@@ -98,12 +165,14 @@ export class PcbScene3dDrillCutoutFilter {
98
165
  * Removes drill cutouts that are already covered by authored fill holes.
99
166
  * @param {{ x: number, y: number }[][]} drillCutouts
100
167
  * @param {{ x: number, y: number }[][]} fillHoles
168
+ * @param {{ preparedPolygonCache?: Map }} [options] Request-scoped options.
101
169
  * @returns {{ x: number, y: number }[][]}
102
170
  */
103
- static removeCoveredCutouts(drillCutouts, fillHoles) {
171
+ static removeCoveredCutouts(drillCutouts, fillHoles, options = {}) {
104
172
  return PcbScene3dDrillCutoutFilter.partitionFillHoles(
105
173
  drillCutouts,
106
- fillHoles
174
+ fillHoles,
175
+ options
107
176
  ).uncoveredCutouts
108
177
  }
109
178
 
@@ -111,9 +180,10 @@ export class PcbScene3dDrillCutoutFilter {
111
180
  * Splits authored holes from physical drill holes already copied to a fill.
112
181
  * @param {{ x: number, y: number }[][]} drillCutouts
113
182
  * @param {{ x: number, y: number }[][]} fillHoles
183
+ * @param {{ preparedPolygonCache?: Map }} [options] Request-scoped options.
114
184
  * @returns {{ authoredHoles: { x: number, y: number }[][], drillHoles: { x: number, y: number }[][], uncoveredCutouts: { x: number, y: number }[][] }}
115
185
  */
116
- static partitionFillHoles(drillCutouts, fillHoles) {
186
+ static partitionFillHoles(drillCutouts, fillHoles, options = {}) {
117
187
  const cutouts = Array.isArray(drillCutouts) ? drillCutouts : []
118
188
  const holes = Array.isArray(fillHoles) ? fillHoles : []
119
189
 
@@ -133,9 +203,23 @@ export class PcbScene3dDrillCutoutFilter {
133
203
  }
134
204
  }
135
205
 
136
- const cutoutInfos =
137
- PcbScene3dDrillCutoutFilter.#buildPolygonInfos(cutouts)
138
- const holeInfos = PcbScene3dDrillCutoutFilter.#buildPolygonInfos(holes)
206
+ const preparedPolygonCache =
207
+ PcbScene3dDrillCutoutFilter.#resolvePreparedPolygonCache(options)
208
+ const cutoutInfos = PcbScene3dDrillCutoutFilter.#buildPolygonInfos(
209
+ cutouts,
210
+ preparedPolygonCache
211
+ )
212
+ const holeInfos = PcbScene3dDrillCutoutFilter.#buildPolygonInfos(
213
+ holes,
214
+ preparedPolygonCache
215
+ )
216
+ const candidateContext =
217
+ PcbScene3dDrillCutoutFilter.#buildCandidateContext([
218
+ ...cutoutInfos,
219
+ ...holeInfos
220
+ ])
221
+ const cutoutInfoSet = new Set(cutoutInfos)
222
+ const holeInfoSet = new Set(holeInfos)
139
223
  const cutoutInfoMap =
140
224
  PcbScene3dDrillCutoutFilter.#buildPolygonInfoMap(cutoutInfos)
141
225
  const holeInfoMap =
@@ -149,7 +233,10 @@ export class PcbScene3dDrillCutoutFilter {
149
233
  holeInfo &&
150
234
  PcbScene3dDrillCutoutFilter.#isDrillHoleInfo(
151
235
  holeInfo,
152
- cutoutInfos
236
+ PcbScene3dDrillCutoutFilter.#queryCandidates(
237
+ candidateContext,
238
+ holeInfo.bounds
239
+ ).filter((candidate) => cutoutInfoSet.has(candidate))
153
240
  )
154
241
  ) {
155
242
  drillHoles.push(hole)
@@ -165,12 +252,17 @@ export class PcbScene3dDrillCutoutFilter {
165
252
  const cutoutInfo = cutoutInfoMap.get(cutout)
166
253
  return (
167
254
  !cutoutInfo ||
168
- !holeInfos.some((holeInfo) =>
169
- PcbScene3dDrillCutoutFilter.#doesHoleInfoCoverCutout(
170
- holeInfo,
171
- cutoutInfo
172
- )
255
+ !PcbScene3dDrillCutoutFilter.#queryCandidates(
256
+ candidateContext,
257
+ cutoutInfo.bounds
173
258
  )
259
+ .filter((candidate) => holeInfoSet.has(candidate))
260
+ .some((holeInfo) =>
261
+ PcbScene3dDrillCutoutFilter.#doesHoleInfoCoverCutout(
262
+ holeInfo,
263
+ cutoutInfo
264
+ )
265
+ )
174
266
  )
175
267
  })
176
268
  }
@@ -237,12 +329,12 @@ export class PcbScene3dDrillCutoutFilter {
237
329
  ) &&
238
330
  PcbScene3dDrillCutoutFilter.#isPointInsideOrOnPolygon(
239
331
  cutoutInfo.centroid,
240
- holeInfo.points
332
+ holeInfo
241
333
  ) &&
242
334
  cutoutInfo.points.every((point) =>
243
335
  PcbScene3dDrillCutoutFilter.#isPointInsideOrOnPolygon(
244
336
  point,
245
- holeInfo.points
337
+ holeInfo
246
338
  )
247
339
  )
248
340
  )
@@ -272,96 +364,10 @@ export class PcbScene3dDrillCutoutFilter {
272
364
  /**
273
365
  * Returns true when a point is inside or on one polygon.
274
366
  * @param {{ x: number, y: number }} point
275
- * @param {{ x: number, y: number }[]} polygon
367
+ * @param {{ prepared: PcbScene3dPreparedPolygon }} polygonInfo
276
368
  * @returns {boolean}
277
369
  */
278
- static #isPointInsideOrOnPolygon(point, polygon) {
279
- return (
280
- PcbScene3dDrillCutoutFilter.#isPointOnPolygonBoundary(
281
- point,
282
- polygon
283
- ) ||
284
- PcbScene3dDrillCutoutFilter.#isPointStrictlyInsidePolygon(
285
- point,
286
- polygon
287
- )
288
- )
289
- }
290
-
291
- /**
292
- * Returns true when a point lies inside a polygon and away from its border.
293
- * @param {{ x: number, y: number }} point
294
- * @param {{ x: number, y: number }[]} polygon
295
- * @returns {boolean}
296
- */
297
- static #isPointStrictlyInsidePolygon(point, polygon) {
298
- let inside = false
299
- for (
300
- let index = 0, previousIndex = polygon.length - 1;
301
- index < polygon.length;
302
- previousIndex = index, index += 1
303
- ) {
304
- const current = polygon[index]
305
- const previous = polygon[previousIndex]
306
- const intersects =
307
- current.y > point.y !== previous.y > point.y &&
308
- point.x <
309
- ((previous.x - current.x) * (point.y - current.y)) /
310
- (previous.y - current.y) +
311
- current.x
312
-
313
- if (intersects) {
314
- inside = !inside
315
- }
316
- }
317
-
318
- return inside
319
- }
320
-
321
- /**
322
- * Returns true when a point lies on any polygon edge.
323
- * @param {{ x: number, y: number }} point
324
- * @param {{ x: number, y: number }[]} polygon
325
- * @returns {boolean}
326
- */
327
- static #isPointOnPolygonBoundary(point, polygon) {
328
- return polygon.some((start, index) =>
329
- PcbScene3dDrillCutoutFilter.#isPointOnSegment(
330
- point,
331
- start,
332
- polygon[(index + 1) % polygon.length]
333
- )
334
- )
335
- }
336
-
337
- /**
338
- * Returns true when a point lies on one finite segment.
339
- * @param {{ x: number, y: number }} point
340
- * @param {{ x: number, y: number }} start
341
- * @param {{ x: number, y: number }} end
342
- * @returns {boolean}
343
- */
344
- static #isPointOnSegment(point, start, end) {
345
- const cross =
346
- (point.y - start.y) * (end.x - start.x) -
347
- (point.x - start.x) * (end.y - start.y)
348
-
349
- if (Math.abs(cross) > PcbScene3dDrillCutoutFilter.#GEOMETRY_EPSILON) {
350
- return false
351
- }
352
-
353
- const dot =
354
- (point.x - start.x) * (end.x - start.x) +
355
- (point.y - start.y) * (end.y - start.y)
356
-
357
- if (dot < -PcbScene3dDrillCutoutFilter.#GEOMETRY_EPSILON) {
358
- return false
359
- }
360
-
361
- const lengthSquared = (end.x - start.x) ** 2 + (end.y - start.y) ** 2
362
-
363
- return (
364
- dot <= lengthSquared + PcbScene3dDrillCutoutFilter.#GEOMETRY_EPSILON
365
- )
370
+ static #isPointInsideOrOnPolygon(point, polygonInfo) {
371
+ return polygonInfo.prepared.containsPointOrBoundary(point)
366
372
  }
367
373
  }