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,709 @@
|
|
|
1
|
+
import { PcbScene3dAabbIndex } from './PcbScene3dAabbIndex.mjs'
|
|
2
|
+
import { PcbScene3dCutoutCircleDetector } from './PcbScene3dCutoutCircleDetector.mjs'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @typedef {object} PcbScene3dPreparedPolygonBounds
|
|
6
|
+
* @property {number} minX
|
|
7
|
+
* @property {number} maxX
|
|
8
|
+
* @property {number} minY
|
|
9
|
+
* @property {number} maxY
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @typedef {object} PcbScene3dPreparedPolygonSegment
|
|
14
|
+
* @property {{ x: number, y: number }} start
|
|
15
|
+
* @property {{ x: number, y: number }} end
|
|
16
|
+
* @property {number} dx
|
|
17
|
+
* @property {number} dy
|
|
18
|
+
* @property {number} lengthSquared
|
|
19
|
+
* @property {PcbScene3dPreparedPolygonBounds} bounds
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Request-scoped polygon metadata and exact-query acceleration.
|
|
24
|
+
*/
|
|
25
|
+
export class PcbScene3dPreparedPolygon {
|
|
26
|
+
static #DEFAULT_EPSILON = 0.001
|
|
27
|
+
static #LINEAR_QUERY_LIMIT = 64
|
|
28
|
+
static #SEGMENT_INDEX_CONSTRUCTION_BUDGET = 1024
|
|
29
|
+
|
|
30
|
+
/** @type {*} */
|
|
31
|
+
#source
|
|
32
|
+
|
|
33
|
+
/** @type {number} */
|
|
34
|
+
#sourceIndex
|
|
35
|
+
|
|
36
|
+
/** @type {{ x: number, y: number }[]} */
|
|
37
|
+
#points
|
|
38
|
+
|
|
39
|
+
/** @type {PcbScene3dPreparedPolygonSegment[]} */
|
|
40
|
+
#segments
|
|
41
|
+
|
|
42
|
+
/** @type {PcbScene3dPreparedPolygonBounds} */
|
|
43
|
+
#bounds
|
|
44
|
+
|
|
45
|
+
/** @type {{ x: number, y: number }} */
|
|
46
|
+
#centroid
|
|
47
|
+
|
|
48
|
+
/** @type {number} */
|
|
49
|
+
#signedArea
|
|
50
|
+
|
|
51
|
+
/** @type {number} */
|
|
52
|
+
#epsilon
|
|
53
|
+
|
|
54
|
+
/** @type {{ isCircular: true, centerX: number, centerY: number, radius: number } | null} */
|
|
55
|
+
#circle
|
|
56
|
+
|
|
57
|
+
/** @type {boolean} */
|
|
58
|
+
#circleDetectionEnabled
|
|
59
|
+
|
|
60
|
+
/** @type {'raw' | 'numeric' | 'raw-numeric' | null} */
|
|
61
|
+
#pointRepresentation
|
|
62
|
+
|
|
63
|
+
/** @type {PcbScene3dAabbIndex | null} */
|
|
64
|
+
#segmentIndex
|
|
65
|
+
|
|
66
|
+
/** @type {number} */
|
|
67
|
+
#segmentQueryWork
|
|
68
|
+
|
|
69
|
+
/** @type {PcbScene3dAabbIndex | null} */
|
|
70
|
+
#vertexIndex
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Prepares reusable polygon metadata without mutating source points.
|
|
74
|
+
* @param {{ x: number, y: number }[]} points
|
|
75
|
+
* @param {{ source?: *, sourceIndex?: number, epsilon?: number, detectCircle?: boolean, metadataPoints?: { x: number, y: number }[], pointRepresentation?: 'raw' | 'numeric' | 'raw-numeric' }} [options]
|
|
76
|
+
*/
|
|
77
|
+
constructor(points, options = {}) {
|
|
78
|
+
this.#points = Array.isArray(points) ? points : []
|
|
79
|
+
this.#source = Object.prototype.hasOwnProperty.call(options, 'source')
|
|
80
|
+
? options.source
|
|
81
|
+
: points
|
|
82
|
+
this.#sourceIndex = options.sourceIndex ?? 0
|
|
83
|
+
this.#epsilon = Number(
|
|
84
|
+
options.epsilon ?? PcbScene3dPreparedPolygon.#DEFAULT_EPSILON
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
const metadataPoints = Array.isArray(options.metadataPoints)
|
|
88
|
+
? options.metadataPoints
|
|
89
|
+
: this.#points
|
|
90
|
+
const metadata =
|
|
91
|
+
PcbScene3dPreparedPolygon.#resolveMetadata(metadataPoints)
|
|
92
|
+
this.#bounds = metadata.bounds
|
|
93
|
+
this.#centroid = metadata.centroid
|
|
94
|
+
this.#signedArea = metadata.signedArea
|
|
95
|
+
this.#pointRepresentation = ['raw', 'numeric', 'raw-numeric'].includes(
|
|
96
|
+
options.pointRepresentation
|
|
97
|
+
)
|
|
98
|
+
? options.pointRepresentation
|
|
99
|
+
: null
|
|
100
|
+
this.#segments = PcbScene3dPreparedPolygon.#buildSegments(this.#points)
|
|
101
|
+
this.#segmentIndex = null
|
|
102
|
+
this.#segmentQueryWork = 0
|
|
103
|
+
this.#vertexIndex = null
|
|
104
|
+
this.#circleDetectionEnabled = options.detectCircle === true
|
|
105
|
+
this.#circle = this.#circleDetectionEnabled
|
|
106
|
+
? PcbScene3dCutoutCircleDetector.resolve(
|
|
107
|
+
this.#points,
|
|
108
|
+
this.#epsilon
|
|
109
|
+
)
|
|
110
|
+
: null
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Returns the optional original source identity.
|
|
115
|
+
* @returns {*}
|
|
116
|
+
*/
|
|
117
|
+
get source() {
|
|
118
|
+
return this.#source
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Returns the source position supplied by the preparing caller.
|
|
123
|
+
* @returns {number}
|
|
124
|
+
*/
|
|
125
|
+
get sourceIndex() {
|
|
126
|
+
return this.#sourceIndex
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Returns the exact points supplied by the caller.
|
|
131
|
+
* @returns {{ x: number, y: number }[]}
|
|
132
|
+
*/
|
|
133
|
+
get points() {
|
|
134
|
+
return this.#points
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Returns the producer-declared exact-point representation for cache reuse.
|
|
139
|
+
* @returns {'raw' | 'numeric' | 'raw-numeric' | null}
|
|
140
|
+
*/
|
|
141
|
+
get pointRepresentation() {
|
|
142
|
+
return this.#pointRepresentation
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Returns source-order polygon segments with cached arithmetic.
|
|
147
|
+
* @returns {PcbScene3dPreparedPolygonSegment[]}
|
|
148
|
+
*/
|
|
149
|
+
get segments() {
|
|
150
|
+
return this.#segments
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Returns polygon axis-aligned bounds.
|
|
155
|
+
* @returns {PcbScene3dPreparedPolygonBounds}
|
|
156
|
+
*/
|
|
157
|
+
get bounds() {
|
|
158
|
+
return this.#bounds
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Returns the source-order arithmetic-mean centroid.
|
|
163
|
+
* @returns {{ x: number, y: number }}
|
|
164
|
+
*/
|
|
165
|
+
get centroid() {
|
|
166
|
+
return this.#centroid
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Returns the signed shoelace area.
|
|
171
|
+
* @returns {number}
|
|
172
|
+
*/
|
|
173
|
+
get signedArea() {
|
|
174
|
+
return this.#signedArea
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Returns the absolute shoelace area.
|
|
179
|
+
* @returns {number}
|
|
180
|
+
*/
|
|
181
|
+
get area() {
|
|
182
|
+
return Math.abs(this.#signedArea)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Returns whether sampled-circle detection was performed during preparation.
|
|
187
|
+
* @returns {boolean}
|
|
188
|
+
*/
|
|
189
|
+
get circleDetectionEnabled() {
|
|
190
|
+
return this.#circleDetectionEnabled
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Returns cached sampled-circle metadata when detection was requested.
|
|
195
|
+
* @returns {{ isCircular: true, centerX: number, centerY: number, radius: number } | null}
|
|
196
|
+
*/
|
|
197
|
+
get circle() {
|
|
198
|
+
return this.#circle
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Returns whether sampled-circle metadata was detected.
|
|
203
|
+
* @returns {boolean}
|
|
204
|
+
*/
|
|
205
|
+
get isCircular() {
|
|
206
|
+
return this.#circle?.isCircular === true
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Returns the cached circle center X coordinate when available.
|
|
211
|
+
* @returns {number | undefined}
|
|
212
|
+
*/
|
|
213
|
+
get centerX() {
|
|
214
|
+
return this.#circle?.centerX
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Returns the cached circle center Y coordinate when available.
|
|
219
|
+
* @returns {number | undefined}
|
|
220
|
+
*/
|
|
221
|
+
get centerY() {
|
|
222
|
+
return this.#circle?.centerY
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Returns the cached circle radius when available.
|
|
227
|
+
* @returns {number | undefined}
|
|
228
|
+
*/
|
|
229
|
+
get radius() {
|
|
230
|
+
return this.#circle?.radius
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Returns true when a point lies strictly inside the polygon.
|
|
235
|
+
* @param {{ x: number, y: number }} point
|
|
236
|
+
* @param {{ segmentBoundsEpsilon?: number }} [options]
|
|
237
|
+
* @returns {boolean}
|
|
238
|
+
*/
|
|
239
|
+
containsPointStrict(point, options = {}) {
|
|
240
|
+
if (this.isPointOnBoundary(point, options)) {
|
|
241
|
+
return false
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return (
|
|
245
|
+
PcbScene3dPreparedPolygon.#pointOverlapsBounds(
|
|
246
|
+
point,
|
|
247
|
+
this.#bounds
|
|
248
|
+
) && this.#containsPointByHorizontalRay(point)
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Returns true when a point lies inside or on the polygon boundary.
|
|
254
|
+
* @param {{ x: number, y: number }} point
|
|
255
|
+
* @param {{ segmentBoundsEpsilon?: number }} [options]
|
|
256
|
+
* @returns {boolean}
|
|
257
|
+
*/
|
|
258
|
+
containsPointOrBoundary(point, options = {}) {
|
|
259
|
+
if (this.isPointOnBoundary(point, options)) {
|
|
260
|
+
return true
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return (
|
|
264
|
+
PcbScene3dPreparedPolygon.#pointOverlapsBounds(
|
|
265
|
+
point,
|
|
266
|
+
this.#bounds
|
|
267
|
+
) && this.#containsPointByHorizontalRay(point)
|
|
268
|
+
)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Returns true when a point lies on a polygon segment within tolerance.
|
|
273
|
+
* @param {{ x: number, y: number }} point
|
|
274
|
+
* @param {{ segmentBoundsEpsilon?: number }} [options]
|
|
275
|
+
* @returns {boolean}
|
|
276
|
+
*/
|
|
277
|
+
isPointOnBoundary(point, options = {}) {
|
|
278
|
+
const candidates = this.querySegments(
|
|
279
|
+
{
|
|
280
|
+
minX: point.x,
|
|
281
|
+
maxX: point.x,
|
|
282
|
+
minY: point.y,
|
|
283
|
+
maxY: point.y
|
|
284
|
+
},
|
|
285
|
+
[]
|
|
286
|
+
)
|
|
287
|
+
const segmentBoundsEpsilon = Number(options.segmentBoundsEpsilon)
|
|
288
|
+
const requireSegmentBoundsOverlap =
|
|
289
|
+
Number.isFinite(segmentBoundsEpsilon)
|
|
290
|
+
|
|
291
|
+
return candidates.some(
|
|
292
|
+
(segment) =>
|
|
293
|
+
(!requireSegmentBoundsOverlap ||
|
|
294
|
+
PcbScene3dPreparedPolygon.#pointOverlapsBounds(
|
|
295
|
+
point,
|
|
296
|
+
segment.bounds,
|
|
297
|
+
Math.max(0, segmentBoundsEpsilon)
|
|
298
|
+
)) &&
|
|
299
|
+
this.#isPointOnSegment(point, segment)
|
|
300
|
+
)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Appends segment broad-phase candidates to a target.
|
|
305
|
+
* @param {PcbScene3dPreparedPolygonBounds} bounds
|
|
306
|
+
* @param {PcbScene3dPreparedPolygonSegment[]} [target]
|
|
307
|
+
* @returns {PcbScene3dPreparedPolygonSegment[]}
|
|
308
|
+
*/
|
|
309
|
+
querySegments(bounds, target = []) {
|
|
310
|
+
const segmentCount = this.#segments.length
|
|
311
|
+
if (segmentCount > PcbScene3dPreparedPolygon.#LINEAR_QUERY_LIMIT) {
|
|
312
|
+
return this.#resolveSegmentIndex().queryInto(bounds, target)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (
|
|
316
|
+
this.#segmentIndex ||
|
|
317
|
+
this.#segmentQueryWork + segmentCount >=
|
|
318
|
+
PcbScene3dPreparedPolygon.#SEGMENT_INDEX_CONSTRUCTION_BUDGET
|
|
319
|
+
) {
|
|
320
|
+
return this.#resolveSegmentIndex().queryInto(bounds, target, {
|
|
321
|
+
stable: true
|
|
322
|
+
})
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
this.#segmentQueryWork += segmentCount
|
|
326
|
+
return this.#querySegmentsLinear(bounds, target)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Appends vertex broad-phase candidates to a target.
|
|
331
|
+
* @param {PcbScene3dPreparedPolygonBounds} bounds
|
|
332
|
+
* @param {{ x: number, y: number }[]} [target]
|
|
333
|
+
* @returns {{ x: number, y: number }[]}
|
|
334
|
+
*/
|
|
335
|
+
queryVertices(bounds, target = []) {
|
|
336
|
+
if (
|
|
337
|
+
this.#points.length <= PcbScene3dPreparedPolygon.#LINEAR_QUERY_LIMIT
|
|
338
|
+
) {
|
|
339
|
+
return this.#queryVerticesLinear(bounds, target)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return this.#resolveVertexIndex().queryInto(bounds, target, {
|
|
343
|
+
epsilon: this.#epsilon
|
|
344
|
+
})
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Resolves and caches the segment AABB index on its first large query.
|
|
349
|
+
* @returns {PcbScene3dAabbIndex}
|
|
350
|
+
*/
|
|
351
|
+
#resolveSegmentIndex() {
|
|
352
|
+
if (!this.#segmentIndex) {
|
|
353
|
+
this.#segmentIndex = new PcbScene3dAabbIndex(this.#segments, {
|
|
354
|
+
resolveBounds: (segment) =>
|
|
355
|
+
PcbScene3dPreparedPolygon.#resolveSegmentIndexBounds(
|
|
356
|
+
segment,
|
|
357
|
+
this.#epsilon
|
|
358
|
+
)
|
|
359
|
+
})
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return this.#segmentIndex
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Resolves and caches the vertex AABB index on its first large query.
|
|
367
|
+
* @returns {PcbScene3dAabbIndex}
|
|
368
|
+
*/
|
|
369
|
+
#resolveVertexIndex() {
|
|
370
|
+
if (!this.#vertexIndex) {
|
|
371
|
+
this.#vertexIndex = new PcbScene3dAabbIndex(this.#points, {
|
|
372
|
+
resolveBounds: PcbScene3dPreparedPolygon.#resolvePointBounds
|
|
373
|
+
})
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return this.#vertexIndex
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Appends exact segment candidates without constructing a small index.
|
|
381
|
+
* @param {PcbScene3dPreparedPolygonBounds} bounds Query bounds.
|
|
382
|
+
* @param {PcbScene3dPreparedPolygonSegment[]} target Candidate target.
|
|
383
|
+
* @returns {PcbScene3dPreparedPolygonSegment[]}
|
|
384
|
+
*/
|
|
385
|
+
#querySegmentsLinear(bounds, target) {
|
|
386
|
+
const segmentCount = this.#segments.length
|
|
387
|
+
for (let index = 0; index < segmentCount; index += 1) {
|
|
388
|
+
const segment = this.#segments[index]
|
|
389
|
+
if (
|
|
390
|
+
PcbScene3dPreparedPolygon.#segmentOverlapsQuery(
|
|
391
|
+
segment,
|
|
392
|
+
bounds,
|
|
393
|
+
this.#epsilon
|
|
394
|
+
)
|
|
395
|
+
) {
|
|
396
|
+
target.push(segment)
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return target
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Appends exact vertex candidates without constructing a small index.
|
|
405
|
+
* @param {PcbScene3dPreparedPolygonBounds} bounds Query bounds.
|
|
406
|
+
* @param {{ x: number, y: number }[]} target Candidate target.
|
|
407
|
+
* @returns {{ x: number, y: number }[]}
|
|
408
|
+
*/
|
|
409
|
+
#queryVerticesLinear(bounds, target) {
|
|
410
|
+
const pointCount = this.#points.length
|
|
411
|
+
for (let index = 0; index < pointCount; index += 1) {
|
|
412
|
+
const point = this.#points[index]
|
|
413
|
+
if (
|
|
414
|
+
PcbScene3dPreparedPolygon.#pointOverlapsQuery(
|
|
415
|
+
point,
|
|
416
|
+
bounds,
|
|
417
|
+
this.#epsilon
|
|
418
|
+
)
|
|
419
|
+
) {
|
|
420
|
+
target.push(point)
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return target
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Applies the existing horizontal ray expression to crossing candidates.
|
|
429
|
+
* @param {{ x: number, y: number }} point
|
|
430
|
+
* @returns {boolean}
|
|
431
|
+
*/
|
|
432
|
+
#containsPointByHorizontalRay(point) {
|
|
433
|
+
const candidates = this.querySegments(
|
|
434
|
+
{
|
|
435
|
+
minX: point.x,
|
|
436
|
+
maxX: this.#bounds.maxX,
|
|
437
|
+
minY: point.y,
|
|
438
|
+
maxY: point.y
|
|
439
|
+
},
|
|
440
|
+
[]
|
|
441
|
+
)
|
|
442
|
+
let inside = false
|
|
443
|
+
|
|
444
|
+
for (const segment of candidates) {
|
|
445
|
+
const current = segment.end
|
|
446
|
+
const previous = segment.start
|
|
447
|
+
const intersects =
|
|
448
|
+
current.y > point.y !== previous.y > point.y &&
|
|
449
|
+
point.x <
|
|
450
|
+
((previous.x - current.x) * (point.y - current.y)) /
|
|
451
|
+
(previous.y - current.y) +
|
|
452
|
+
current.x
|
|
453
|
+
|
|
454
|
+
if (intersects) {
|
|
455
|
+
inside = !inside
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return inside
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Returns true when a point passes current cross, dot, and length checks.
|
|
464
|
+
* @param {{ x: number, y: number }} point
|
|
465
|
+
* @param {PcbScene3dPreparedPolygonSegment} segment
|
|
466
|
+
* @returns {boolean}
|
|
467
|
+
*/
|
|
468
|
+
#isPointOnSegment(point, segment) {
|
|
469
|
+
const cross =
|
|
470
|
+
(point.y - segment.start.y) * segment.dx -
|
|
471
|
+
(point.x - segment.start.x) * segment.dy
|
|
472
|
+
|
|
473
|
+
if (Math.abs(cross) > this.#epsilon) {
|
|
474
|
+
return false
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const dot =
|
|
478
|
+
(point.x - segment.start.x) * segment.dx +
|
|
479
|
+
(point.y - segment.start.y) * segment.dy
|
|
480
|
+
|
|
481
|
+
if (dot < -this.#epsilon) {
|
|
482
|
+
return false
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
return dot <= segment.lengthSquared + this.#epsilon
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Builds source-order segments and their reusable arithmetic.
|
|
490
|
+
* @param {{ x: number, y: number }[]} points
|
|
491
|
+
* @returns {PcbScene3dPreparedPolygonSegment[]}
|
|
492
|
+
*/
|
|
493
|
+
static #buildSegments(points) {
|
|
494
|
+
return points.map((start, index) => {
|
|
495
|
+
const end = points[(index + 1) % points.length]
|
|
496
|
+
const dx = end.x - start.x
|
|
497
|
+
const dy = end.y - start.y
|
|
498
|
+
|
|
499
|
+
return {
|
|
500
|
+
start,
|
|
501
|
+
end,
|
|
502
|
+
dx,
|
|
503
|
+
dy,
|
|
504
|
+
lengthSquared: dx * dx + dy * dy,
|
|
505
|
+
bounds: {
|
|
506
|
+
minX: Math.min(start.x, end.x),
|
|
507
|
+
maxX: Math.max(start.x, end.x),
|
|
508
|
+
minY: Math.min(start.y, end.y),
|
|
509
|
+
maxY: Math.max(start.y, end.y)
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
})
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Resolves source-order bounds, centroid, and signed area.
|
|
517
|
+
* @param {{ x: number, y: number }[]} points
|
|
518
|
+
* @returns {{ bounds: PcbScene3dPreparedPolygonBounds, centroid: { x: number, y: number }, signedArea: number }}
|
|
519
|
+
*/
|
|
520
|
+
static #resolveMetadata(points) {
|
|
521
|
+
const bounds = {
|
|
522
|
+
minX: Infinity,
|
|
523
|
+
maxX: -Infinity,
|
|
524
|
+
minY: Infinity,
|
|
525
|
+
maxY: -Infinity
|
|
526
|
+
}
|
|
527
|
+
let totalX = 0
|
|
528
|
+
let totalY = 0
|
|
529
|
+
let doubledArea = 0
|
|
530
|
+
|
|
531
|
+
for (let index = 0; index < points.length; index += 1) {
|
|
532
|
+
const point = points[index]
|
|
533
|
+
const next = points[(index + 1) % points.length]
|
|
534
|
+
|
|
535
|
+
bounds.minX = Math.min(bounds.minX, point.x)
|
|
536
|
+
bounds.maxX = Math.max(bounds.maxX, point.x)
|
|
537
|
+
bounds.minY = Math.min(bounds.minY, point.y)
|
|
538
|
+
bounds.maxY = Math.max(bounds.maxY, point.y)
|
|
539
|
+
totalX += point.x
|
|
540
|
+
totalY += point.y
|
|
541
|
+
doubledArea += point.x * next.y - next.x * point.y
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const count = Math.max(points.length, 1)
|
|
545
|
+
return {
|
|
546
|
+
bounds,
|
|
547
|
+
centroid: { x: totalX / count, y: totalY / count },
|
|
548
|
+
signedArea: doubledArea / 2
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Expands index bounds enough to retain all tolerance predicate matches.
|
|
554
|
+
* @param {PcbScene3dPreparedPolygonSegment} segment
|
|
555
|
+
* @param {number} epsilon
|
|
556
|
+
* @returns {PcbScene3dPreparedPolygonBounds}
|
|
557
|
+
*/
|
|
558
|
+
static #resolveSegmentIndexBounds(segment, epsilon) {
|
|
559
|
+
if (
|
|
560
|
+
segment.lengthSquared === 0 ||
|
|
561
|
+
!Number.isFinite(segment.lengthSquared) ||
|
|
562
|
+
!PcbScene3dPreparedPolygon.#hasFiniteBounds(segment.bounds)
|
|
563
|
+
) {
|
|
564
|
+
return PcbScene3dPreparedPolygon.#resolveAllSpaceBounds()
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const margin = Math.max(
|
|
568
|
+
epsilon,
|
|
569
|
+
(Math.SQRT2 * epsilon) / Math.sqrt(segment.lengthSquared)
|
|
570
|
+
)
|
|
571
|
+
return {
|
|
572
|
+
minX: segment.bounds.minX - margin,
|
|
573
|
+
maxX: segment.bounds.maxX + margin,
|
|
574
|
+
minY: segment.bounds.minY - margin,
|
|
575
|
+
maxY: segment.bounds.maxY + margin
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Resolves a point as zero-area AABB index bounds.
|
|
581
|
+
* @param {{ x: number, y: number }} point
|
|
582
|
+
* @returns {PcbScene3dPreparedPolygonBounds}
|
|
583
|
+
*/
|
|
584
|
+
static #resolvePointBounds(point) {
|
|
585
|
+
const x = Number(point?.x)
|
|
586
|
+
const y = Number(point?.y)
|
|
587
|
+
|
|
588
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
|
589
|
+
return PcbScene3dPreparedPolygon.#resolveAllSpaceBounds()
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
return {
|
|
593
|
+
minX: x,
|
|
594
|
+
maxX: x,
|
|
595
|
+
minY: y,
|
|
596
|
+
maxY: y
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Returns whether a segment's conservative index envelope overlaps a query.
|
|
602
|
+
* @param {PcbScene3dPreparedPolygonSegment} segment Segment metadata.
|
|
603
|
+
* @param {PcbScene3dPreparedPolygonBounds} queryBounds Query bounds.
|
|
604
|
+
* @param {number} epsilon Exact predicate tolerance.
|
|
605
|
+
* @returns {boolean}
|
|
606
|
+
*/
|
|
607
|
+
static #segmentOverlapsQuery(segment, queryBounds, epsilon) {
|
|
608
|
+
if (
|
|
609
|
+
!PcbScene3dPreparedPolygon.#hasFiniteBounds(queryBounds) ||
|
|
610
|
+
segment.lengthSquared === 0 ||
|
|
611
|
+
!Number.isFinite(segment.lengthSquared) ||
|
|
612
|
+
!PcbScene3dPreparedPolygon.#hasFiniteBounds(segment.bounds)
|
|
613
|
+
) {
|
|
614
|
+
return true
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const margin = Math.max(
|
|
618
|
+
epsilon,
|
|
619
|
+
(Math.SQRT2 * epsilon) / Math.sqrt(segment.lengthSquared)
|
|
620
|
+
)
|
|
621
|
+
const minX = segment.bounds.minX - margin
|
|
622
|
+
const maxX = segment.bounds.maxX + margin
|
|
623
|
+
const minY = segment.bounds.minY - margin
|
|
624
|
+
const maxY = segment.bounds.maxY + margin
|
|
625
|
+
|
|
626
|
+
return (
|
|
627
|
+
!Number.isFinite(minX) ||
|
|
628
|
+
!Number.isFinite(maxX) ||
|
|
629
|
+
!Number.isFinite(minY) ||
|
|
630
|
+
!Number.isFinite(maxY) ||
|
|
631
|
+
!(
|
|
632
|
+
maxX < queryBounds.minX ||
|
|
633
|
+
minX > queryBounds.maxX ||
|
|
634
|
+
maxY < queryBounds.minY ||
|
|
635
|
+
minY > queryBounds.maxY
|
|
636
|
+
)
|
|
637
|
+
)
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Returns whether a vertex's conservative index envelope overlaps a query.
|
|
642
|
+
* @param {{ x: number, y: number }} point Vertex.
|
|
643
|
+
* @param {PcbScene3dPreparedPolygonBounds} queryBounds Query bounds.
|
|
644
|
+
* @param {number} epsilon Exact predicate tolerance.
|
|
645
|
+
* @returns {boolean}
|
|
646
|
+
*/
|
|
647
|
+
static #pointOverlapsQuery(point, queryBounds, epsilon) {
|
|
648
|
+
if (!PcbScene3dPreparedPolygon.#hasFiniteBounds(queryBounds)) {
|
|
649
|
+
return true
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const x = Number(point?.x)
|
|
653
|
+
const y = Number(point?.y)
|
|
654
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
|
655
|
+
return true
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
return !(
|
|
659
|
+
x < queryBounds.minX - epsilon ||
|
|
660
|
+
x > queryBounds.maxX + epsilon ||
|
|
661
|
+
y < queryBounds.minY - epsilon ||
|
|
662
|
+
y > queryBounds.maxY + epsilon
|
|
663
|
+
)
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Returns true when every bounds coordinate is finite.
|
|
668
|
+
* @param {PcbScene3dPreparedPolygonBounds} bounds
|
|
669
|
+
* @returns {boolean}
|
|
670
|
+
*/
|
|
671
|
+
static #hasFiniteBounds(bounds) {
|
|
672
|
+
return Boolean(
|
|
673
|
+
bounds &&
|
|
674
|
+
Number.isFinite(bounds.minX) &&
|
|
675
|
+
Number.isFinite(bounds.maxX) &&
|
|
676
|
+
Number.isFinite(bounds.minY) &&
|
|
677
|
+
Number.isFinite(bounds.maxY)
|
|
678
|
+
)
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Resolves conservative all-space bounds for non-finite exact geometry.
|
|
683
|
+
* @returns {PcbScene3dPreparedPolygonBounds}
|
|
684
|
+
*/
|
|
685
|
+
static #resolveAllSpaceBounds() {
|
|
686
|
+
return {
|
|
687
|
+
minX: -Infinity,
|
|
688
|
+
maxX: Infinity,
|
|
689
|
+
minY: -Infinity,
|
|
690
|
+
maxY: Infinity
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Returns true when a point falls inside finite polygon bounds.
|
|
696
|
+
* @param {{ x: number, y: number }} point
|
|
697
|
+
* @param {PcbScene3dPreparedPolygonBounds} bounds
|
|
698
|
+
* @param {number} [epsilon]
|
|
699
|
+
* @returns {boolean}
|
|
700
|
+
*/
|
|
701
|
+
static #pointOverlapsBounds(point, bounds, epsilon = 0) {
|
|
702
|
+
return (
|
|
703
|
+
point.x >= bounds.minX - epsilon &&
|
|
704
|
+
point.x <= bounds.maxX + epsilon &&
|
|
705
|
+
point.y >= bounds.minY - epsilon &&
|
|
706
|
+
point.y <= bounds.maxY + epsilon
|
|
707
|
+
)
|
|
708
|
+
}
|
|
709
|
+
}
|