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.
- package/AGENTS.md +67 -0
- package/COMMERCIAL-LICENSE.md +15 -0
- package/CONTRIBUTING.md +14 -0
- package/LICENSE +19 -0
- package/LICENSES/AGPL-3.0-or-later.txt +235 -0
- package/LICENSES/CC-BY-SA-4.0.txt +170 -0
- package/LICENSES/LGPL-2.1-or-later.txt +176 -0
- package/LICENSES/LicenseRef-PolyForm-Noncommercial-1.0.0.txt +131 -0
- package/NOTICE.md +36 -0
- package/README.md +128 -0
- package/REUSE.toml +16 -0
- package/docs/api.md +148 -0
- package/docs/circuitjson.md +190 -0
- package/docs/model-format.md +117 -0
- package/docs/testing.md +23 -0
- package/package.json +65 -0
- package/spec/library-scope.md +36 -0
- package/src/PcbModelArchiveExporter.mjs +320 -0
- package/src/PcbScene3dArcUtils.mjs +27 -0
- package/src/PcbScene3dBoardAssemblyPlacement.mjs +36 -0
- package/src/PcbScene3dBoardAssemblyPresentation.mjs +859 -0
- package/src/PcbScene3dBoardEdgeCutoutBuilder.mjs +537 -0
- package/src/PcbScene3dBoardMaterialPalette.mjs +40 -0
- package/src/PcbScene3dBoardShapeFactory.mjs +895 -0
- package/src/PcbScene3dBoardSolderMaskFactory.mjs +613 -0
- package/src/PcbScene3dCameraRig.mjs +168 -0
- package/src/PcbScene3dCircuitJsonAdapter.mjs +545 -0
- package/src/PcbScene3dController.mjs +956 -0
- package/src/PcbScene3dCopperDetailFilter.mjs +490 -0
- package/src/PcbScene3dCopperFactory.mjs +559 -0
- package/src/PcbScene3dCopperTextFactory.mjs +534 -0
- package/src/PcbScene3dCutoutGeometryFilter.mjs +873 -0
- package/src/PcbScene3dDetailCoordinateNormalizer.mjs +65 -0
- package/src/PcbScene3dDrillCutoutFilter.mjs +224 -0
- package/src/PcbScene3dDrillPathFactory.mjs +362 -0
- package/src/PcbScene3dDrillVoidFactory.mjs +268 -0
- package/src/PcbScene3dExternalModelLoadOrder.mjs +54 -0
- package/src/PcbScene3dExternalModels.mjs +968 -0
- package/src/PcbScene3dFallbackVisibility.mjs +82 -0
- package/src/PcbScene3dInteractionHints.mjs +56 -0
- package/src/PcbScene3dMountRig.mjs +53 -0
- package/src/PcbScene3dOutlineBuilder.mjs +210 -0
- package/src/PcbScene3dPadFactory.mjs +553 -0
- package/src/PcbScene3dPresetState.mjs +48 -0
- package/src/PcbScene3dRenderGroupVisibility.mjs +134 -0
- package/src/PcbScene3dRuntime.mjs +996 -0
- package/src/PcbScene3dRuntimeBoardMeshes.mjs +99 -0
- package/src/PcbScene3dSelectionStyler.mjs +252 -0
- package/src/PcbScene3dShapePathFactory.mjs +220 -0
- package/src/PcbScene3dShellRenderer.mjs +131 -0
- package/src/PcbScene3dSilkscreenFactory.mjs +854 -0
- package/src/PcbScene3dSilkscreenStrokeWidthResolver.mjs +81 -0
- package/src/PcbScene3dStepLoader.mjs +611 -0
- package/src/PcbScene3dStrokeFont.mjs +671 -0
- package/src/PcbScene3dStrokeGeometryBuilder.mjs +322 -0
- package/src/PcbScene3dText.mjs +99 -0
- package/src/PcbScene3dTrueTypeTextFactory.mjs +885 -0
- package/src/PcbScene3dViaFactory.mjs +176 -0
- package/src/PcbScene3dViewCompensation.mjs +109 -0
- package/src/PcbScene3dViewScale.mjs +24 -0
- package/src/PcbScene3dViewportResize.mjs +35 -0
- package/src/PcbScene3dWorkerClient.mjs +123 -0
- package/src/index.mjs +1 -0
- package/src/scene3d.mjs +44 -0
- 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
|
+
}
|