pcb-scene3d-viewer 1.1.48 → 1.1.49
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/package.json +2 -1
- package/src/PcbScene3dCopperDetailGroupBuilder.mjs +16 -0
- package/src/PcbScene3dCopperFactory.mjs +64 -93
- package/src/PcbScene3dCopperFillAreaClipper.mjs +726 -0
- package/src/PcbScene3dCopperFillMeshBuilder.mjs +530 -8
- package/src/PcbScene3dCopperFillPolygonBoolean.mjs +235 -0
- package/src/PcbScene3dCopperPrismBuilder.mjs +92 -0
- package/src/PcbScene3dMaskCoveredCopperSideGroupBuilder.mjs +2 -1
- package/src/PcbScene3dMaskCoveredCopperSurfaceFilter.mjs +64 -0
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
import { PcbAssemblyFillGeometryResolver } from './PcbAssemblyFillGeometryResolver.mjs'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Clips redundant copper relief where track geometry is already inside a fill.
|
|
5
|
+
*/
|
|
6
|
+
export class PcbScene3dCopperFillAreaClipper {
|
|
7
|
+
static #AREA_EPSILON = 0.001
|
|
8
|
+
static #GEOMETRY_EPSILON = 0.001
|
|
9
|
+
static #MAX_DEPTH = 10
|
|
10
|
+
static #MAX_EDGE_LENGTH = 2
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Removes mesh triangles covered by filled copper areas.
|
|
14
|
+
* @param {any} THREE Three.js namespace.
|
|
15
|
+
* @param {any | null} mesh Mesh to clip.
|
|
16
|
+
* @param {object[]} fills Copper fill primitives.
|
|
17
|
+
* @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint Board normalizer.
|
|
18
|
+
* @param {boolean} mirrorY Whether to mirror underside Y coordinates.
|
|
19
|
+
* @param {{ subdividePartialTriangles?: boolean }} [options] Clipping options.
|
|
20
|
+
* @returns {any | null}
|
|
21
|
+
*/
|
|
22
|
+
static filter(
|
|
23
|
+
THREE,
|
|
24
|
+
mesh,
|
|
25
|
+
fills,
|
|
26
|
+
normalizeBoardPoint,
|
|
27
|
+
mirrorY,
|
|
28
|
+
options = {}
|
|
29
|
+
) {
|
|
30
|
+
const areas = PcbScene3dCopperFillAreaClipper.#prepareAreas(
|
|
31
|
+
fills,
|
|
32
|
+
normalizeBoardPoint,
|
|
33
|
+
mirrorY
|
|
34
|
+
)
|
|
35
|
+
const sourceGeometry = mesh?.geometry?.index
|
|
36
|
+
? mesh.geometry.toNonIndexed?.() || mesh.geometry
|
|
37
|
+
: mesh?.geometry
|
|
38
|
+
const position = sourceGeometry?.getAttribute?.('position')
|
|
39
|
+
|
|
40
|
+
if (!mesh || !position?.count || !areas.length) {
|
|
41
|
+
return mesh
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const positions = []
|
|
45
|
+
const state = { changed: false }
|
|
46
|
+
for (let index = 0; index < position.count; index += 3) {
|
|
47
|
+
PcbScene3dCopperFillAreaClipper.#appendFilteredTriangle(
|
|
48
|
+
positions,
|
|
49
|
+
PcbScene3dCopperFillAreaClipper.#resolveTriangle(
|
|
50
|
+
position,
|
|
51
|
+
index
|
|
52
|
+
),
|
|
53
|
+
areas,
|
|
54
|
+
0,
|
|
55
|
+
state,
|
|
56
|
+
options
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!state.changed) {
|
|
61
|
+
return mesh
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!positions.length) {
|
|
65
|
+
return null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const geometry = new THREE.BufferGeometry()
|
|
69
|
+
geometry.setAttribute(
|
|
70
|
+
'position',
|
|
71
|
+
new THREE.Float32BufferAttribute(positions, 3)
|
|
72
|
+
)
|
|
73
|
+
geometry.computeVertexNormals?.()
|
|
74
|
+
mesh.geometry = geometry
|
|
75
|
+
return mesh
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Resolves normalized fill areas with internal holes.
|
|
80
|
+
* @param {object[]} fills Copper fill primitives.
|
|
81
|
+
* @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint Board normalizer.
|
|
82
|
+
* @param {boolean} mirrorY Whether to mirror underside Y coordinates.
|
|
83
|
+
* @returns {{ outer: { x: number, y: number }[], holes: { points: { x: number, y: number }[], bounds: object, segments: object[] }[], bounds: object, segments: object[] }[]}
|
|
84
|
+
*/
|
|
85
|
+
static #prepareAreas(fills, normalizeBoardPoint, mirrorY) {
|
|
86
|
+
return (fills || []).flatMap((fill) =>
|
|
87
|
+
PcbAssemblyFillGeometryResolver.resolveAll(fill)
|
|
88
|
+
.map((loops) =>
|
|
89
|
+
PcbScene3dCopperFillAreaClipper.#prepareLoopSet(
|
|
90
|
+
loops,
|
|
91
|
+
normalizeBoardPoint,
|
|
92
|
+
mirrorY
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
.filter(Boolean)
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Resolves one normalized fill loop set.
|
|
101
|
+
* @param {{ outer?: any[], holes?: any[][] }} loops Fill loop set.
|
|
102
|
+
* @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint Board normalizer.
|
|
103
|
+
* @param {boolean} mirrorY Whether to mirror underside Y coordinates.
|
|
104
|
+
* @returns {object | null}
|
|
105
|
+
*/
|
|
106
|
+
static #prepareLoopSet(loops, normalizeBoardPoint, mirrorY) {
|
|
107
|
+
const outer = PcbScene3dCopperFillAreaClipper.#normalizeLoop(
|
|
108
|
+
loops?.outer,
|
|
109
|
+
normalizeBoardPoint,
|
|
110
|
+
mirrorY
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
if (!PcbScene3dCopperFillAreaClipper.#isValidLoop(outer)) {
|
|
114
|
+
return null
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const holes = (loops?.holes || [])
|
|
118
|
+
.map((hole) =>
|
|
119
|
+
PcbScene3dCopperFillAreaClipper.#normalizeLoop(
|
|
120
|
+
hole,
|
|
121
|
+
normalizeBoardPoint,
|
|
122
|
+
mirrorY
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
.filter((hole) =>
|
|
126
|
+
PcbScene3dCopperFillAreaClipper.#isValidLoop(hole)
|
|
127
|
+
)
|
|
128
|
+
.map((hole) => ({
|
|
129
|
+
points: hole,
|
|
130
|
+
bounds: PcbScene3dCopperFillAreaClipper.#resolveBounds(hole),
|
|
131
|
+
segments: PcbScene3dCopperFillAreaClipper.#segments(hole)
|
|
132
|
+
}))
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
outer,
|
|
136
|
+
holes,
|
|
137
|
+
bounds: PcbScene3dCopperFillAreaClipper.#resolveBounds(outer),
|
|
138
|
+
segments: PcbScene3dCopperFillAreaClipper.#segments(outer)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Normalizes one fill loop.
|
|
144
|
+
* @param {any[]} loop Source loop points.
|
|
145
|
+
* @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint Board normalizer.
|
|
146
|
+
* @param {boolean} mirrorY Whether to mirror underside Y coordinates.
|
|
147
|
+
* @returns {{ x: number, y: number }[]}
|
|
148
|
+
*/
|
|
149
|
+
static #normalizeLoop(loop, normalizeBoardPoint, mirrorY) {
|
|
150
|
+
const points = []
|
|
151
|
+
for (const point of loop || []) {
|
|
152
|
+
const normalized = normalizeBoardPoint(
|
|
153
|
+
Number(point?.x ?? point?.[0]),
|
|
154
|
+
Number(point?.y ?? point?.[1])
|
|
155
|
+
)
|
|
156
|
+
const nextPoint = {
|
|
157
|
+
x: Number(normalized?.x),
|
|
158
|
+
y: mirrorY ? -Number(normalized?.y) : Number(normalized?.y)
|
|
159
|
+
}
|
|
160
|
+
if (Number.isFinite(nextPoint.x) && Number.isFinite(nextPoint.y)) {
|
|
161
|
+
points.push(nextPoint)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return PcbScene3dCopperFillAreaClipper.#cleanLoop(points)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Appends one triangle after removing filled-area overlap.
|
|
169
|
+
* @param {number[]} positions Output position buffer.
|
|
170
|
+
* @param {{ x: number, y: number, z: number }[]} triangle Source triangle.
|
|
171
|
+
* @param {object[]} areas Filled copper areas.
|
|
172
|
+
* @param {number} depth Subdivision depth.
|
|
173
|
+
* @param {{ changed: boolean }} state Mutation state.
|
|
174
|
+
* @param {{ subdividePartialTriangles?: boolean }} options Clipping options.
|
|
175
|
+
* @returns {void}
|
|
176
|
+
*/
|
|
177
|
+
static #appendFilteredTriangle(
|
|
178
|
+
positions,
|
|
179
|
+
triangle,
|
|
180
|
+
areas,
|
|
181
|
+
depth,
|
|
182
|
+
state,
|
|
183
|
+
options
|
|
184
|
+
) {
|
|
185
|
+
const coverage =
|
|
186
|
+
PcbScene3dCopperFillAreaClipper.#resolveTriangleCoverage(
|
|
187
|
+
triangle,
|
|
188
|
+
areas
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
if (coverage === 'none') {
|
|
192
|
+
PcbScene3dCopperFillAreaClipper.#appendTriangle(positions, triangle)
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (
|
|
197
|
+
coverage === 'partial' &&
|
|
198
|
+
!PcbScene3dCopperFillAreaClipper.#shouldSubdividePartialTriangles(
|
|
199
|
+
options
|
|
200
|
+
)
|
|
201
|
+
) {
|
|
202
|
+
PcbScene3dCopperFillAreaClipper.#appendTriangle(positions, triangle)
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
state.changed = true
|
|
207
|
+
if (coverage === 'full') {
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (
|
|
212
|
+
depth >= PcbScene3dCopperFillAreaClipper.#MAX_DEPTH ||
|
|
213
|
+
PcbScene3dCopperFillAreaClipper.#maxEdgeLengthSquared(triangle) <=
|
|
214
|
+
PcbScene3dCopperFillAreaClipper.#MAX_EDGE_LENGTH ** 2
|
|
215
|
+
) {
|
|
216
|
+
if (
|
|
217
|
+
!PcbScene3dCopperFillAreaClipper.#isPointInAnyArea(
|
|
218
|
+
PcbScene3dCopperFillAreaClipper.#centroid(triangle),
|
|
219
|
+
areas
|
|
220
|
+
)
|
|
221
|
+
) {
|
|
222
|
+
PcbScene3dCopperFillAreaClipper.#appendTriangle(
|
|
223
|
+
positions,
|
|
224
|
+
triangle
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
for (const child of PcbScene3dCopperFillAreaClipper.#subdivideTriangle(
|
|
231
|
+
triangle
|
|
232
|
+
)) {
|
|
233
|
+
PcbScene3dCopperFillAreaClipper.#appendFilteredTriangle(
|
|
234
|
+
positions,
|
|
235
|
+
child,
|
|
236
|
+
areas,
|
|
237
|
+
depth + 1,
|
|
238
|
+
state,
|
|
239
|
+
options
|
|
240
|
+
)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Checks whether partial triangles should be recursively clipped.
|
|
246
|
+
* @param {{ subdividePartialTriangles?: boolean } | undefined} options Clipping options.
|
|
247
|
+
* @returns {boolean}
|
|
248
|
+
*/
|
|
249
|
+
static #shouldSubdividePartialTriangles(options) {
|
|
250
|
+
return options?.subdividePartialTriangles !== false
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Resolves whether a triangle is outside, inside, or crossing filled areas.
|
|
255
|
+
* @param {{ x: number, y: number }[]} triangle Triangle points.
|
|
256
|
+
* @param {object[]} areas Filled copper areas.
|
|
257
|
+
* @returns {'none' | 'partial' | 'full'}
|
|
258
|
+
*/
|
|
259
|
+
static #resolveTriangleCoverage(triangle, areas) {
|
|
260
|
+
const samples = [
|
|
261
|
+
...triangle,
|
|
262
|
+
PcbScene3dCopperFillAreaClipper.#centroid(triangle)
|
|
263
|
+
]
|
|
264
|
+
const coveredSamples = samples.filter((point) =>
|
|
265
|
+
PcbScene3dCopperFillAreaClipper.#isPointInAnyArea(point, areas)
|
|
266
|
+
).length
|
|
267
|
+
const crosses =
|
|
268
|
+
PcbScene3dCopperFillAreaClipper.#triangleCrossesAnyBoundary(
|
|
269
|
+
triangle,
|
|
270
|
+
areas
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
if (coveredSamples === 0 && !crosses) {
|
|
274
|
+
return 'none'
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (coveredSamples === samples.length && !crosses) {
|
|
278
|
+
return 'full'
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return 'partial'
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Returns true when one point is in any filled area.
|
|
286
|
+
* @param {{ x: number, y: number }} point Candidate point.
|
|
287
|
+
* @param {object[]} areas Filled copper areas.
|
|
288
|
+
* @returns {boolean}
|
|
289
|
+
*/
|
|
290
|
+
static #isPointInAnyArea(point, areas) {
|
|
291
|
+
return areas.some((area) =>
|
|
292
|
+
PcbScene3dCopperFillAreaClipper.#isPointInArea(point, area)
|
|
293
|
+
)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Returns true when one point is inside a fill outer and outside holes.
|
|
298
|
+
* @param {{ x: number, y: number }} point Candidate point.
|
|
299
|
+
* @param {{ outer: { x: number, y: number }[], holes: object[], bounds: object }} area Filled copper area.
|
|
300
|
+
* @returns {boolean}
|
|
301
|
+
*/
|
|
302
|
+
static #isPointInArea(point, area) {
|
|
303
|
+
if (
|
|
304
|
+
!PcbScene3dCopperFillAreaClipper.#boundsContainPoint(
|
|
305
|
+
area.bounds,
|
|
306
|
+
point
|
|
307
|
+
) ||
|
|
308
|
+
!PcbScene3dCopperFillAreaClipper.#pointInPolygon(point, area.outer)
|
|
309
|
+
) {
|
|
310
|
+
return false
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return !area.holes.some(
|
|
314
|
+
(hole) =>
|
|
315
|
+
PcbScene3dCopperFillAreaClipper.#boundsContainPoint(
|
|
316
|
+
hole.bounds,
|
|
317
|
+
point
|
|
318
|
+
) &&
|
|
319
|
+
PcbScene3dCopperFillAreaClipper.#pointInPolygon(
|
|
320
|
+
point,
|
|
321
|
+
hole.points
|
|
322
|
+
)
|
|
323
|
+
)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Returns true when a triangle crosses a fill or hole boundary.
|
|
328
|
+
* @param {{ x: number, y: number }[]} triangle Triangle points.
|
|
329
|
+
* @param {object[]} areas Filled copper areas.
|
|
330
|
+
* @returns {boolean}
|
|
331
|
+
*/
|
|
332
|
+
static #triangleCrossesAnyBoundary(triangle, areas) {
|
|
333
|
+
const triangleBounds =
|
|
334
|
+
PcbScene3dCopperFillAreaClipper.#resolveBounds(triangle)
|
|
335
|
+
|
|
336
|
+
return areas.some(
|
|
337
|
+
(area) =>
|
|
338
|
+
PcbScene3dCopperFillAreaClipper.#boundsOverlap(
|
|
339
|
+
triangleBounds,
|
|
340
|
+
area.bounds
|
|
341
|
+
) &&
|
|
342
|
+
PcbScene3dCopperFillAreaClipper.#triangleCrossesAreaBoundary(
|
|
343
|
+
triangle,
|
|
344
|
+
area
|
|
345
|
+
)
|
|
346
|
+
)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Returns true when a triangle crosses one filled area's boundaries.
|
|
351
|
+
* @param {{ x: number, y: number }[]} triangle Triangle points.
|
|
352
|
+
* @param {{ outer: { x: number, y: number }[], holes: object[], segments: object[] }} area Filled copper area.
|
|
353
|
+
* @returns {boolean}
|
|
354
|
+
*/
|
|
355
|
+
static #triangleCrossesAreaBoundary(triangle, area) {
|
|
356
|
+
return [area, ...area.holes].some((loop) =>
|
|
357
|
+
PcbScene3dCopperFillAreaClipper.#triangleCrossesLoopBoundary(
|
|
358
|
+
triangle,
|
|
359
|
+
loop.points || loop.outer,
|
|
360
|
+
loop.segments
|
|
361
|
+
)
|
|
362
|
+
)
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Returns true when triangle edges cross one loop.
|
|
367
|
+
* @param {{ x: number, y: number }[]} triangle Triangle points.
|
|
368
|
+
* @param {{ x: number, y: number }[]} loop Loop points.
|
|
369
|
+
* @param {{ start: object, end: object, bounds: object }[]} segments Loop segments.
|
|
370
|
+
* @returns {boolean}
|
|
371
|
+
*/
|
|
372
|
+
static #triangleCrossesLoopBoundary(triangle, loop, segments) {
|
|
373
|
+
for (let index = 0; index < triangle.length; index += 1) {
|
|
374
|
+
const start = triangle[index]
|
|
375
|
+
const end = triangle[(index + 1) % triangle.length]
|
|
376
|
+
const edgeBounds = PcbScene3dCopperFillAreaClipper.#resolveBounds([
|
|
377
|
+
start,
|
|
378
|
+
end
|
|
379
|
+
])
|
|
380
|
+
if (
|
|
381
|
+
segments.some(
|
|
382
|
+
(segment) =>
|
|
383
|
+
PcbScene3dCopperFillAreaClipper.#boundsOverlap(
|
|
384
|
+
edgeBounds,
|
|
385
|
+
segment.bounds
|
|
386
|
+
) &&
|
|
387
|
+
PcbScene3dCopperFillAreaClipper.#segmentsIntersect(
|
|
388
|
+
start,
|
|
389
|
+
end,
|
|
390
|
+
segment.start,
|
|
391
|
+
segment.end
|
|
392
|
+
)
|
|
393
|
+
)
|
|
394
|
+
) {
|
|
395
|
+
return true
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return loop.some((point) =>
|
|
400
|
+
PcbScene3dCopperFillAreaClipper.#isPointInsideTriangle(
|
|
401
|
+
point,
|
|
402
|
+
triangle
|
|
403
|
+
)
|
|
404
|
+
)
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Resolves one triangle from a Three position attribute.
|
|
409
|
+
* @param {any} position Position attribute.
|
|
410
|
+
* @param {number} startIndex Triangle start index.
|
|
411
|
+
* @returns {{ x: number, y: number, z: number }[]}
|
|
412
|
+
*/
|
|
413
|
+
static #resolveTriangle(position, startIndex) {
|
|
414
|
+
return [0, 1, 2].map((offset) => ({
|
|
415
|
+
x: Number(position.getX(startIndex + offset)),
|
|
416
|
+
y: Number(position.getY(startIndex + offset)),
|
|
417
|
+
z: Number(position.getZ(startIndex + offset))
|
|
418
|
+
}))
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Builds loop segments with cached bounds.
|
|
423
|
+
* @param {{ x: number, y: number }[]} points Loop points.
|
|
424
|
+
* @returns {{ start: object, end: object, bounds: object }[]}
|
|
425
|
+
*/
|
|
426
|
+
static #segments(points) {
|
|
427
|
+
return points.map((start, index) => {
|
|
428
|
+
const end = points[(index + 1) % points.length]
|
|
429
|
+
return {
|
|
430
|
+
start,
|
|
431
|
+
end,
|
|
432
|
+
bounds: PcbScene3dCopperFillAreaClipper.#resolveBounds([
|
|
433
|
+
start,
|
|
434
|
+
end
|
|
435
|
+
])
|
|
436
|
+
}
|
|
437
|
+
})
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Subdivides one triangle into four children.
|
|
442
|
+
* @param {{ x: number, y: number, z: number }[]} triangle Source triangle.
|
|
443
|
+
* @returns {{ x: number, y: number, z: number }[][]}
|
|
444
|
+
*/
|
|
445
|
+
static #subdivideTriangle(triangle) {
|
|
446
|
+
const [a, b, c] = triangle
|
|
447
|
+
const ab = PcbScene3dCopperFillAreaClipper.#midpoint(a, b)
|
|
448
|
+
const bc = PcbScene3dCopperFillAreaClipper.#midpoint(b, c)
|
|
449
|
+
const ca = PcbScene3dCopperFillAreaClipper.#midpoint(c, a)
|
|
450
|
+
|
|
451
|
+
return [
|
|
452
|
+
[a, ab, ca],
|
|
453
|
+
[ab, b, bc],
|
|
454
|
+
[ca, bc, c],
|
|
455
|
+
[ab, bc, ca]
|
|
456
|
+
]
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Resolves a midpoint.
|
|
461
|
+
* @param {{ x: number, y: number, z: number }} a First point.
|
|
462
|
+
* @param {{ x: number, y: number, z: number }} b Second point.
|
|
463
|
+
* @returns {{ x: number, y: number, z: number }}
|
|
464
|
+
*/
|
|
465
|
+
static #midpoint(a, b) {
|
|
466
|
+
return {
|
|
467
|
+
x: (a.x + b.x) / 2,
|
|
468
|
+
y: (a.y + b.y) / 2,
|
|
469
|
+
z: (a.z + b.z) / 2
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Resolves triangle centroid.
|
|
475
|
+
* @param {{ x: number, y: number, z?: number }[]} triangle Triangle points.
|
|
476
|
+
* @returns {{ x: number, y: number, z: number }}
|
|
477
|
+
*/
|
|
478
|
+
static #centroid(triangle) {
|
|
479
|
+
return {
|
|
480
|
+
x: (triangle[0].x + triangle[1].x + triangle[2].x) / 3,
|
|
481
|
+
y: (triangle[0].y + triangle[1].y + triangle[2].y) / 3,
|
|
482
|
+
z:
|
|
483
|
+
(Number(triangle[0].z || 0) +
|
|
484
|
+
Number(triangle[1].z || 0) +
|
|
485
|
+
Number(triangle[2].z || 0)) /
|
|
486
|
+
3
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Appends one triangle to a position buffer.
|
|
492
|
+
* @param {number[]} positions Output position buffer.
|
|
493
|
+
* @param {{ x: number, y: number, z: number }[]} triangle Triangle points.
|
|
494
|
+
* @returns {void}
|
|
495
|
+
*/
|
|
496
|
+
static #appendTriangle(positions, triangle) {
|
|
497
|
+
for (const point of triangle) {
|
|
498
|
+
positions.push(point.x, point.y, point.z)
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Resolves maximum squared edge length.
|
|
504
|
+
* @param {{ x: number, y: number }[]} triangle Triangle points.
|
|
505
|
+
* @returns {number}
|
|
506
|
+
*/
|
|
507
|
+
static #maxEdgeLengthSquared(triangle) {
|
|
508
|
+
let maximum = 0
|
|
509
|
+
for (let index = 0; index < triangle.length; index += 1) {
|
|
510
|
+
const start = triangle[index]
|
|
511
|
+
const end = triangle[(index + 1) % triangle.length]
|
|
512
|
+
maximum = Math.max(
|
|
513
|
+
maximum,
|
|
514
|
+
(end.x - start.x) ** 2 + (end.y - start.y) ** 2
|
|
515
|
+
)
|
|
516
|
+
}
|
|
517
|
+
return maximum
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Returns true when one point is inside a polygon.
|
|
522
|
+
* @param {{ x: number, y: number }} point Candidate point.
|
|
523
|
+
* @param {{ x: number, y: number }[]} polygon Polygon points.
|
|
524
|
+
* @returns {boolean}
|
|
525
|
+
*/
|
|
526
|
+
static #pointInPolygon(point, polygon) {
|
|
527
|
+
let inside = false
|
|
528
|
+
for (
|
|
529
|
+
let index = 0, previousIndex = polygon.length - 1;
|
|
530
|
+
index < polygon.length;
|
|
531
|
+
previousIndex = index, index += 1
|
|
532
|
+
) {
|
|
533
|
+
const current = polygon[index]
|
|
534
|
+
const previous = polygon[previousIndex]
|
|
535
|
+
const intersects =
|
|
536
|
+
current.y > point.y !== previous.y > point.y &&
|
|
537
|
+
point.x <
|
|
538
|
+
((previous.x - current.x) * (point.y - current.y)) /
|
|
539
|
+
(previous.y - current.y) +
|
|
540
|
+
current.x
|
|
541
|
+
if (intersects) {
|
|
542
|
+
inside = !inside
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
return inside
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Returns true when one point is inside or on a triangle.
|
|
550
|
+
* @param {{ x: number, y: number }} point Candidate point.
|
|
551
|
+
* @param {{ x: number, y: number }[]} triangle Triangle points.
|
|
552
|
+
* @returns {boolean}
|
|
553
|
+
*/
|
|
554
|
+
static #isPointInsideTriangle(point, triangle) {
|
|
555
|
+
const signs = triangle.map((current, index) => {
|
|
556
|
+
const next = triangle[(index + 1) % triangle.length]
|
|
557
|
+
return (
|
|
558
|
+
(point.x - next.x) * (current.y - next.y) -
|
|
559
|
+
(current.x - next.x) * (point.y - next.y)
|
|
560
|
+
)
|
|
561
|
+
})
|
|
562
|
+
const hasNegative = signs.some(
|
|
563
|
+
(sign) => sign < -PcbScene3dCopperFillAreaClipper.#GEOMETRY_EPSILON
|
|
564
|
+
)
|
|
565
|
+
const hasPositive = signs.some(
|
|
566
|
+
(sign) => sign > PcbScene3dCopperFillAreaClipper.#GEOMETRY_EPSILON
|
|
567
|
+
)
|
|
568
|
+
return !(hasNegative && hasPositive)
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Returns true when two segments intersect.
|
|
573
|
+
* @param {{ x: number, y: number }} a First segment start.
|
|
574
|
+
* @param {{ x: number, y: number }} b First segment end.
|
|
575
|
+
* @param {{ x: number, y: number }} c Second segment start.
|
|
576
|
+
* @param {{ x: number, y: number }} d Second segment end.
|
|
577
|
+
* @returns {boolean}
|
|
578
|
+
*/
|
|
579
|
+
static #segmentsIntersect(a, b, c, d) {
|
|
580
|
+
const abC = PcbScene3dCopperFillAreaClipper.#orientation(a, b, c)
|
|
581
|
+
const abD = PcbScene3dCopperFillAreaClipper.#orientation(a, b, d)
|
|
582
|
+
const cdA = PcbScene3dCopperFillAreaClipper.#orientation(c, d, a)
|
|
583
|
+
const cdB = PcbScene3dCopperFillAreaClipper.#orientation(c, d, b)
|
|
584
|
+
|
|
585
|
+
return (
|
|
586
|
+
abC * abD <= PcbScene3dCopperFillAreaClipper.#GEOMETRY_EPSILON &&
|
|
587
|
+
cdA * cdB <= PcbScene3dCopperFillAreaClipper.#GEOMETRY_EPSILON
|
|
588
|
+
)
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Resolves oriented segment side.
|
|
593
|
+
* @param {{ x: number, y: number }} a Segment start.
|
|
594
|
+
* @param {{ x: number, y: number }} b Segment end.
|
|
595
|
+
* @param {{ x: number, y: number }} point Candidate point.
|
|
596
|
+
* @returns {number}
|
|
597
|
+
*/
|
|
598
|
+
static #orientation(a, b, point) {
|
|
599
|
+
return (b.x - a.x) * (point.y - a.y) - (b.y - a.y) * (point.x - a.x)
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Removes duplicate and closing points.
|
|
604
|
+
* @param {{ x: number, y: number }[]} points Candidate points.
|
|
605
|
+
* @returns {{ x: number, y: number }[]}
|
|
606
|
+
*/
|
|
607
|
+
static #cleanLoop(points) {
|
|
608
|
+
const output = []
|
|
609
|
+
for (const point of points || []) {
|
|
610
|
+
const previous = output[output.length - 1]
|
|
611
|
+
if (
|
|
612
|
+
previous &&
|
|
613
|
+
Math.abs(previous.x - point.x) <
|
|
614
|
+
PcbScene3dCopperFillAreaClipper.#GEOMETRY_EPSILON &&
|
|
615
|
+
Math.abs(previous.y - point.y) <
|
|
616
|
+
PcbScene3dCopperFillAreaClipper.#GEOMETRY_EPSILON
|
|
617
|
+
) {
|
|
618
|
+
continue
|
|
619
|
+
}
|
|
620
|
+
output.push(point)
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const first = output[0]
|
|
624
|
+
const last = output[output.length - 1]
|
|
625
|
+
if (
|
|
626
|
+
first &&
|
|
627
|
+
last &&
|
|
628
|
+
Math.abs(first.x - last.x) <
|
|
629
|
+
PcbScene3dCopperFillAreaClipper.#GEOMETRY_EPSILON &&
|
|
630
|
+
Math.abs(first.y - last.y) <
|
|
631
|
+
PcbScene3dCopperFillAreaClipper.#GEOMETRY_EPSILON
|
|
632
|
+
) {
|
|
633
|
+
output.pop()
|
|
634
|
+
}
|
|
635
|
+
return output
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Checks whether one loop has enough area.
|
|
640
|
+
* @param {{ x: number, y: number }[]} loop Candidate loop.
|
|
641
|
+
* @returns {boolean}
|
|
642
|
+
*/
|
|
643
|
+
static #isValidLoop(loop) {
|
|
644
|
+
return (
|
|
645
|
+
loop.length >= 3 &&
|
|
646
|
+
Math.abs(PcbScene3dCopperFillAreaClipper.#signedArea(loop)) >
|
|
647
|
+
PcbScene3dCopperFillAreaClipper.#AREA_EPSILON
|
|
648
|
+
)
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Computes signed loop area.
|
|
653
|
+
* @param {{ x: number, y: number }[]} loop Candidate loop.
|
|
654
|
+
* @returns {number}
|
|
655
|
+
*/
|
|
656
|
+
static #signedArea(loop) {
|
|
657
|
+
let area = 0
|
|
658
|
+
for (let index = 0; index < loop.length; index += 1) {
|
|
659
|
+
const current = loop[index]
|
|
660
|
+
const next = loop[(index + 1) % loop.length]
|
|
661
|
+
area += current.x * next.y - next.x * current.y
|
|
662
|
+
}
|
|
663
|
+
return area / 2
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Resolves bounds for points.
|
|
668
|
+
* @param {{ x: number, y: number }[]} points Candidate points.
|
|
669
|
+
* @returns {{ minX: number, maxX: number, minY: number, maxY: number }}
|
|
670
|
+
*/
|
|
671
|
+
static #resolveBounds(points) {
|
|
672
|
+
return points.reduce(
|
|
673
|
+
(bounds, point) => ({
|
|
674
|
+
minX: Math.min(bounds.minX, point.x),
|
|
675
|
+
maxX: Math.max(bounds.maxX, point.x),
|
|
676
|
+
minY: Math.min(bounds.minY, point.y),
|
|
677
|
+
maxY: Math.max(bounds.maxY, point.y)
|
|
678
|
+
}),
|
|
679
|
+
{ minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity }
|
|
680
|
+
)
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Returns true when bounds include one point.
|
|
685
|
+
* @param {{ minX: number, maxX: number, minY: number, maxY: number }} bounds Bounds.
|
|
686
|
+
* @param {{ x: number, y: number }} point Candidate point.
|
|
687
|
+
* @returns {boolean}
|
|
688
|
+
*/
|
|
689
|
+
static #boundsContainPoint(bounds, point) {
|
|
690
|
+
return (
|
|
691
|
+
point.x >=
|
|
692
|
+
bounds.minX -
|
|
693
|
+
PcbScene3dCopperFillAreaClipper.#GEOMETRY_EPSILON &&
|
|
694
|
+
point.x <=
|
|
695
|
+
bounds.maxX +
|
|
696
|
+
PcbScene3dCopperFillAreaClipper.#GEOMETRY_EPSILON &&
|
|
697
|
+
point.y >=
|
|
698
|
+
bounds.minY -
|
|
699
|
+
PcbScene3dCopperFillAreaClipper.#GEOMETRY_EPSILON &&
|
|
700
|
+
point.y <=
|
|
701
|
+
bounds.maxY + PcbScene3dCopperFillAreaClipper.#GEOMETRY_EPSILON
|
|
702
|
+
)
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Returns true when two bounds overlap.
|
|
707
|
+
* @param {{ minX: number, maxX: number, minY: number, maxY: number }} left Left bounds.
|
|
708
|
+
* @param {{ minX: number, maxX: number, minY: number, maxY: number }} right Right bounds.
|
|
709
|
+
* @returns {boolean}
|
|
710
|
+
*/
|
|
711
|
+
static #boundsOverlap(left, right) {
|
|
712
|
+
return !(
|
|
713
|
+
left.maxX <
|
|
714
|
+
right.minX -
|
|
715
|
+
PcbScene3dCopperFillAreaClipper.#GEOMETRY_EPSILON ||
|
|
716
|
+
left.minX >
|
|
717
|
+
right.maxX +
|
|
718
|
+
PcbScene3dCopperFillAreaClipper.#GEOMETRY_EPSILON ||
|
|
719
|
+
left.maxY <
|
|
720
|
+
right.minY -
|
|
721
|
+
PcbScene3dCopperFillAreaClipper.#GEOMETRY_EPSILON ||
|
|
722
|
+
left.minY >
|
|
723
|
+
right.maxY + PcbScene3dCopperFillAreaClipper.#GEOMETRY_EPSILON
|
|
724
|
+
)
|
|
725
|
+
}
|
|
726
|
+
}
|