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.
- package/README.md +46 -21
- package/docs/api.md +109 -8
- package/docs/circuitjson.md +143 -29
- package/docs/model-format.md +50 -7
- package/docs/release-notes-v1.2.0.md +122 -0
- package/docs/testing.md +30 -1
- package/package.json +6 -3
- package/spec/library-scope.md +10 -2
- package/src/CircuitJsonCadModelAssetResolver.mjs +697 -83
- package/src/PcbAssemblyBoardSubstrateBuilder.mjs +43 -0
- package/src/PcbAssemblyGeometryBuilder.mjs +40 -18
- package/src/PcbAssemblyModelMeshLoader.mjs +101 -150
- package/src/PcbAssemblyPadMeshBuilder.mjs +22 -0
- package/src/PcbModelArchiveExporter.mjs +28 -112
- package/src/PcbModelArchiveSourceBundle.mjs +326 -0
- package/src/PcbScene3dAabbIndex.mjs +464 -0
- package/src/PcbScene3dBoardAssemblyPresentation.mjs +1 -1
- package/src/PcbScene3dBoardEdgeCutoutBuilder.mjs +9 -5
- package/src/PcbScene3dBoardMaterialPalette.mjs +29 -0
- package/src/PcbScene3dBoardShapeFactory.mjs +11 -118
- package/src/PcbScene3dBoardSolderMaskFactory.mjs +65 -39
- package/src/PcbScene3dCircuitJsonAdapter.mjs +151 -48
- package/src/PcbScene3dCircuitJsonDrillDetail.mjs +31 -0
- package/src/PcbScene3dCircuitJsonGeometry.mjs +184 -33
- package/src/PcbScene3dCircuitJsonInput.mjs +132 -0
- package/src/PcbScene3dCircuitJsonModelAsset.mjs +40 -0
- package/src/PcbScene3dController.mjs +40 -34
- package/src/PcbScene3dCopperFactory.mjs +36 -22
- package/src/PcbScene3dCopperFillAreaClipper.mjs +133 -235
- package/src/PcbScene3dCopperFillCoverageContext.mjs +204 -0
- package/src/PcbScene3dCopperFillLoopSetResolver.mjs +192 -0
- package/src/PcbScene3dCopperFillMeshBuilder.mjs +77 -295
- package/src/PcbScene3dCopperTextFactory.mjs +12 -4
- package/src/PcbScene3dCutoutCircleDetector.mjs +34 -17
- package/src/PcbScene3dCutoutGeometryFilter.mjs +104 -269
- package/src/PcbScene3dCutoutGridIndex.mjs +184 -0
- package/src/PcbScene3dDeferredModelFinalizer.mjs +52 -0
- package/src/PcbScene3dDescriptorSafeRecord.mjs +38 -0
- package/src/PcbScene3dDrillCutoutFilter.mjs +149 -143
- package/src/PcbScene3dDrillPathFactory.mjs +86 -16
- package/src/PcbScene3dDrillVoidFactory.mjs +35 -10
- package/src/PcbScene3dExternalModelGroupLoader.mjs +472 -31
- package/src/PcbScene3dExternalModels.mjs +23 -24
- package/src/PcbScene3dFacetedModelGroupBuilder.mjs +217 -0
- package/src/PcbScene3dGeometryZCompressor.mjs +4 -2
- package/src/PcbScene3dMaskCoveredCopperSideGroupBuilder.mjs +18 -4
- package/src/PcbScene3dMaskCoveredCopperSurfaceFilter.mjs +75 -10
- package/src/PcbScene3dModelContent.mjs +236 -0
- package/src/PcbScene3dModelFetchPolicy.mjs +304 -0
- package/src/PcbScene3dModelIdentity.mjs +106 -0
- package/src/PcbScene3dPlatedDrillSpecResolver.mjs +141 -0
- package/src/PcbScene3dPreparedPolygon.mjs +709 -0
- package/src/PcbScene3dPreparedPolygonSet.mjs +70 -0
- package/src/PcbScene3dRuntime.mjs +45 -45
- package/src/PcbScene3dRuntimeBoardMeshes.mjs +85 -1
- package/src/PcbScene3dShapeHoleGeometryCleaner.mjs +4 -2
- package/src/PcbScene3dShellRenderer.mjs +75 -6
- package/src/PcbScene3dSilkscreenCutoutContext.mjs +255 -0
- package/src/PcbScene3dSilkscreenFactory.mjs +87 -127
- package/src/PcbScene3dSilkscreenFillSeamBuilder.mjs +11 -5
- package/src/PcbScene3dStepLoader.mjs +11 -10
- package/src/PcbScene3dText.mjs +1 -1
- 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
|
|
14
|
-
PcbScene3dDrillCutoutFilter.#
|
|
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
|
-
|
|
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
|
-
* @
|
|
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(
|
|
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
|
-
* @
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
58
|
-
let totalX = 0
|
|
59
|
-
let totalY = 0
|
|
96
|
+
}
|
|
60
97
|
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
137
|
-
PcbScene3dDrillCutoutFilter.#
|
|
138
|
-
const
|
|
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
|
-
|
|
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
|
-
!
|
|
169
|
-
|
|
170
|
-
|
|
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
|
|
332
|
+
holeInfo
|
|
241
333
|
) &&
|
|
242
334
|
cutoutInfo.points.every((point) =>
|
|
243
335
|
PcbScene3dDrillCutoutFilter.#isPointInsideOrOnPolygon(
|
|
244
336
|
point,
|
|
245
|
-
holeInfo
|
|
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 {{
|
|
367
|
+
* @param {{ prepared: PcbScene3dPreparedPolygon }} polygonInfo
|
|
276
368
|
* @returns {boolean}
|
|
277
369
|
*/
|
|
278
|
-
static #isPointInsideOrOnPolygon(point,
|
|
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
|
}
|