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.
Files changed (65) hide show
  1. package/AGENTS.md +67 -0
  2. package/COMMERCIAL-LICENSE.md +15 -0
  3. package/CONTRIBUTING.md +14 -0
  4. package/LICENSE +19 -0
  5. package/LICENSES/AGPL-3.0-or-later.txt +235 -0
  6. package/LICENSES/CC-BY-SA-4.0.txt +170 -0
  7. package/LICENSES/LGPL-2.1-or-later.txt +176 -0
  8. package/LICENSES/LicenseRef-PolyForm-Noncommercial-1.0.0.txt +131 -0
  9. package/NOTICE.md +36 -0
  10. package/README.md +128 -0
  11. package/REUSE.toml +16 -0
  12. package/docs/api.md +148 -0
  13. package/docs/circuitjson.md +190 -0
  14. package/docs/model-format.md +117 -0
  15. package/docs/testing.md +23 -0
  16. package/package.json +65 -0
  17. package/spec/library-scope.md +36 -0
  18. package/src/PcbModelArchiveExporter.mjs +320 -0
  19. package/src/PcbScene3dArcUtils.mjs +27 -0
  20. package/src/PcbScene3dBoardAssemblyPlacement.mjs +36 -0
  21. package/src/PcbScene3dBoardAssemblyPresentation.mjs +859 -0
  22. package/src/PcbScene3dBoardEdgeCutoutBuilder.mjs +537 -0
  23. package/src/PcbScene3dBoardMaterialPalette.mjs +40 -0
  24. package/src/PcbScene3dBoardShapeFactory.mjs +895 -0
  25. package/src/PcbScene3dBoardSolderMaskFactory.mjs +613 -0
  26. package/src/PcbScene3dCameraRig.mjs +168 -0
  27. package/src/PcbScene3dCircuitJsonAdapter.mjs +545 -0
  28. package/src/PcbScene3dController.mjs +956 -0
  29. package/src/PcbScene3dCopperDetailFilter.mjs +490 -0
  30. package/src/PcbScene3dCopperFactory.mjs +559 -0
  31. package/src/PcbScene3dCopperTextFactory.mjs +534 -0
  32. package/src/PcbScene3dCutoutGeometryFilter.mjs +873 -0
  33. package/src/PcbScene3dDetailCoordinateNormalizer.mjs +65 -0
  34. package/src/PcbScene3dDrillCutoutFilter.mjs +224 -0
  35. package/src/PcbScene3dDrillPathFactory.mjs +362 -0
  36. package/src/PcbScene3dDrillVoidFactory.mjs +268 -0
  37. package/src/PcbScene3dExternalModelLoadOrder.mjs +54 -0
  38. package/src/PcbScene3dExternalModels.mjs +968 -0
  39. package/src/PcbScene3dFallbackVisibility.mjs +82 -0
  40. package/src/PcbScene3dInteractionHints.mjs +56 -0
  41. package/src/PcbScene3dMountRig.mjs +53 -0
  42. package/src/PcbScene3dOutlineBuilder.mjs +210 -0
  43. package/src/PcbScene3dPadFactory.mjs +553 -0
  44. package/src/PcbScene3dPresetState.mjs +48 -0
  45. package/src/PcbScene3dRenderGroupVisibility.mjs +134 -0
  46. package/src/PcbScene3dRuntime.mjs +996 -0
  47. package/src/PcbScene3dRuntimeBoardMeshes.mjs +99 -0
  48. package/src/PcbScene3dSelectionStyler.mjs +252 -0
  49. package/src/PcbScene3dShapePathFactory.mjs +220 -0
  50. package/src/PcbScene3dShellRenderer.mjs +131 -0
  51. package/src/PcbScene3dSilkscreenFactory.mjs +854 -0
  52. package/src/PcbScene3dSilkscreenStrokeWidthResolver.mjs +81 -0
  53. package/src/PcbScene3dStepLoader.mjs +611 -0
  54. package/src/PcbScene3dStrokeFont.mjs +671 -0
  55. package/src/PcbScene3dStrokeGeometryBuilder.mjs +322 -0
  56. package/src/PcbScene3dText.mjs +99 -0
  57. package/src/PcbScene3dTrueTypeTextFactory.mjs +885 -0
  58. package/src/PcbScene3dViaFactory.mjs +176 -0
  59. package/src/PcbScene3dViewCompensation.mjs +109 -0
  60. package/src/PcbScene3dViewScale.mjs +24 -0
  61. package/src/PcbScene3dViewportResize.mjs +35 -0
  62. package/src/PcbScene3dWorkerClient.mjs +123 -0
  63. package/src/index.mjs +1 -0
  64. package/src/scene3d.mjs +44 -0
  65. package/src/styles/scene3d.css +295 -0
@@ -0,0 +1,895 @@
1
+ import { PcbScene3dBoardEdgeCutoutBuilder } from './PcbScene3dBoardEdgeCutoutBuilder.mjs'
2
+ import { PcbScene3dDrillPathFactory } from './PcbScene3dDrillPathFactory.mjs'
3
+ import { PcbScene3dOutlineBuilder } from './PcbScene3dOutlineBuilder.mjs'
4
+
5
+ /**
6
+ * Builds the board solid profile, including drilled holes.
7
+ */
8
+ export class PcbScene3dBoardShapeFactory {
9
+ static #CURVE_SEGMENTS = 20
10
+ static #PAD_HOLE_SHAPE_SLOT = 2
11
+ static #PLATED_WALL_MATERIAL_INDEX = 2
12
+ static #EDGE_WALL_MATERIAL_INDEX = 1
13
+ static #CONTOUR_SAMPLE_POINTS = 64
14
+ static #GEOMETRY_EPSILON = 0.001
15
+ static #CONTOUR_MATCH_TOLERANCE_MIL = 0.25
16
+ static #CONTOUR_INDEX_MIN_CELL_SIZE = 32
17
+ static #CONTOUR_INDEX_MAX_CELLS_PER_CONTOUR = 128
18
+
19
+ /**
20
+ * Builds one board shape with drill holes.
21
+ * @param {any} THREE
22
+ * @param {{ widthMil?: number, heightMil?: number, segments?: Array<Record<string, number | string>> }} board
23
+ * @param {{ pads?: any[], vias?: any[] }} [detail]
24
+ * @param {(x: number, y: number) => { x: number, y: number }} [normalizeBoardPoint]
25
+ * @returns {any}
26
+ */
27
+ static buildShape(
28
+ THREE,
29
+ board,
30
+ detail = {},
31
+ normalizeBoardPoint = (x, y) => ({ x, y })
32
+ ) {
33
+ const baseShape = PcbScene3dBoardShapeFactory.#buildBaseShape(
34
+ THREE,
35
+ board
36
+ )
37
+ const contourPoints =
38
+ PcbScene3dBoardEdgeCutoutBuilder.resolveShapePoints(baseShape)
39
+ const drillCutouts = PcbScene3dBoardShapeFactory.#resolveDrillCutouts(
40
+ THREE,
41
+ detail,
42
+ normalizeBoardPoint
43
+ )
44
+ const edgeCutouts = drillCutouts.filter(
45
+ (cutout) =>
46
+ cutout.isCircular &&
47
+ !PcbScene3dBoardEdgeCutoutBuilder.isHoleInsideContour(
48
+ cutout.points,
49
+ contourPoints
50
+ )
51
+ )
52
+ const shape = edgeCutouts.length
53
+ ? PcbScene3dBoardEdgeCutoutBuilder.buildShapeFromPoints(
54
+ THREE,
55
+ PcbScene3dBoardEdgeCutoutBuilder.applyCircularEdgeCutouts(
56
+ contourPoints,
57
+ edgeCutouts
58
+ )
59
+ )
60
+ : baseShape
61
+ const finalContourPoints = edgeCutouts.length
62
+ ? PcbScene3dBoardEdgeCutoutBuilder.resolveShapePoints(shape)
63
+ : contourPoints
64
+
65
+ for (const cutout of drillCutouts) {
66
+ if (edgeCutouts.includes(cutout)) {
67
+ continue
68
+ }
69
+
70
+ if (
71
+ !cutout.isCircular ||
72
+ PcbScene3dBoardEdgeCutoutBuilder.isHoleInsideContour(
73
+ cutout.points,
74
+ finalContourPoints
75
+ )
76
+ ) {
77
+ shape.holes.push(cutout.path)
78
+ }
79
+ }
80
+
81
+ return shape
82
+ }
83
+
84
+ /**
85
+ * Resolves circular drills that intersect the board outline.
86
+ * @param {any} THREE
87
+ * @param {{ widthMil?: number, heightMil?: number, segments?: Array<Record<string, number | string>> }} board
88
+ * @param {{ pads?: any[], vias?: any[] }} [detail]
89
+ * @param {(x: number, y: number) => { x: number, y: number }} [normalizeBoardPoint]
90
+ * @returns {{ x: number, y: number, diameter: number, slotLength?: number | null, rotationDeg?: number | null }[]}
91
+ */
92
+ static resolveCircularEdgeDrills(
93
+ THREE,
94
+ board,
95
+ detail = {},
96
+ normalizeBoardPoint = (x, y) => ({ x, y })
97
+ ) {
98
+ if (!board) {
99
+ return []
100
+ }
101
+
102
+ const contourPoints =
103
+ PcbScene3dBoardEdgeCutoutBuilder.resolveShapePoints(
104
+ PcbScene3dBoardShapeFactory.#buildBaseShape(THREE, board)
105
+ )
106
+
107
+ return PcbScene3dBoardShapeFactory.#resolveDrillCutouts(
108
+ THREE,
109
+ detail,
110
+ normalizeBoardPoint
111
+ )
112
+ .filter(
113
+ (cutout) =>
114
+ cutout.isCircular &&
115
+ !PcbScene3dBoardEdgeCutoutBuilder.isHoleInsideContour(
116
+ cutout.points,
117
+ contourPoints
118
+ )
119
+ )
120
+ .map((cutout) => cutout.drillSpec)
121
+ }
122
+
123
+ /**
124
+ * Builds the outer board shape before drill holes are applied.
125
+ * @param {any} THREE
126
+ * @param {{ widthMil?: number, heightMil?: number, segments?: Array<Record<string, number | string>> }} board
127
+ * @returns {any}
128
+ */
129
+ static #buildBaseShape(THREE, board) {
130
+ const shape = new THREE.Shape()
131
+ const commands = PcbScene3dOutlineBuilder.buildCommands(board)
132
+
133
+ if (!commands.length) {
134
+ shape.moveTo(-board.widthMil / 2, -board.heightMil / 2)
135
+ shape.lineTo(board.widthMil / 2, -board.heightMil / 2)
136
+ shape.lineTo(board.widthMil / 2, board.heightMil / 2)
137
+ shape.lineTo(-board.widthMil / 2, board.heightMil / 2)
138
+ shape.lineTo(-board.widthMil / 2, -board.heightMil / 2)
139
+ } else {
140
+ for (const command of commands) {
141
+ if (command.type === 'move') {
142
+ shape.moveTo(Number(command.x || 0), Number(command.y || 0))
143
+ continue
144
+ }
145
+
146
+ if (command.type === 'arc') {
147
+ shape.absarc(
148
+ Number(command.cx || 0),
149
+ Number(command.cy || 0),
150
+ Number(command.radius || 0),
151
+ Number(command.startAngleRad || 0),
152
+ Number(command.endAngleRad || 0),
153
+ Boolean(command.clockwise)
154
+ )
155
+ continue
156
+ }
157
+
158
+ shape.lineTo(Number(command.x || 0), Number(command.y || 0))
159
+ }
160
+ shape.closePath()
161
+ }
162
+ return shape
163
+ }
164
+
165
+ /**
166
+ * Builds one extruded board body geometry with open drill apertures.
167
+ * @param {any} THREE
168
+ * @param {{ widthMil?: number, heightMil?: number, thicknessMil?: number, segments?: Array<Record<string, number | string>> }} board
169
+ * @param {{ pads?: any[], vias?: any[] }} [detail]
170
+ * @param {(x: number, y: number) => { x: number, y: number }} [normalizeBoardPoint]
171
+ * @returns {any}
172
+ */
173
+ static buildGeometry(
174
+ THREE,
175
+ board,
176
+ detail = {},
177
+ normalizeBoardPoint = (x, y) => ({ x, y })
178
+ ) {
179
+ const thicknessMil = Number(board?.thicknessMil || 0)
180
+ const shape = PcbScene3dBoardShapeFactory.buildShape(
181
+ THREE,
182
+ board,
183
+ detail,
184
+ normalizeBoardPoint
185
+ )
186
+ const geometry = new THREE.ExtrudeGeometry(shape, {
187
+ depth: thicknessMil,
188
+ bevelEnabled: false,
189
+ curveSegments: PcbScene3dBoardShapeFactory.#CURVE_SEGMENTS
190
+ })
191
+
192
+ geometry.translate?.(0, 0, -thicknessMil / 2)
193
+ PcbScene3dBoardShapeFactory.#applyPlatedDrillWallMaterials(
194
+ THREE,
195
+ geometry,
196
+ detail,
197
+ normalizeBoardPoint
198
+ )
199
+ return geometry
200
+ }
201
+
202
+ /**
203
+ * Resolves normalized drill cutout metadata.
204
+ * @param {any} THREE
205
+ * @param {{ pads?: any[], vias?: any[] }} detail
206
+ * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint
207
+ * @returns {{ path: any, points: { x: number, y: number }[], centerX: number, centerY: number, radius: number, isCircular: boolean, drillSpec: { x: number, y: number, diameter: number, slotLength?: number | null, rotationDeg?: number | null } }[]}
208
+ */
209
+ static #resolveDrillCutouts(THREE, detail, normalizeBoardPoint) {
210
+ return PcbScene3dDrillPathFactory.resolveBoardDrillSpecs(detail)
211
+ .map((drillSpec) => {
212
+ const point = normalizeBoardPoint(drillSpec.x, drillSpec.y)
213
+ const normalizedSpec = {
214
+ ...drillSpec,
215
+ x: point.x,
216
+ y: point.y
217
+ }
218
+ const path = PcbScene3dDrillPathFactory.buildDrillPath(
219
+ THREE,
220
+ normalizedSpec
221
+ )
222
+ const diameter = Number(normalizedSpec.diameter || 0)
223
+ const slotLength = Number(normalizedSpec.slotLength || 0)
224
+ const isCircular =
225
+ diameter > 0 && slotLength <= diameter + 0.001
226
+ const points = isCircular
227
+ ? PcbScene3dBoardEdgeCutoutBuilder.buildCircularCutoutPoints(
228
+ Number(normalizedSpec.x || 0),
229
+ Number(normalizedSpec.y || 0),
230
+ diameter / 2
231
+ )
232
+ : PcbScene3dBoardShapeFactory.#resolvePathPoints(path)
233
+
234
+ return {
235
+ path,
236
+ points,
237
+ centerX: Number(normalizedSpec.x || 0),
238
+ centerY: Number(normalizedSpec.y || 0),
239
+ radius: diameter / 2,
240
+ isCircular,
241
+ drillSpec
242
+ }
243
+ })
244
+ .filter((cutout) => cutout.path && cutout.points.length >= 3)
245
+ }
246
+
247
+ /**
248
+ * Resolves sampled points from one path.
249
+ * @param {{ getPoints?: (segments: number) => { x: number, y: number }[] } | null} path
250
+ * @returns {{ x: number, y: number }[]}
251
+ */
252
+ static #resolvePathPoints(path) {
253
+ return (
254
+ path?.getPoints?.(
255
+ PcbScene3dBoardShapeFactory.#CONTOUR_SAMPLE_POINTS
256
+ ) || []
257
+ ).map((point) => ({
258
+ x: Number(point.x || 0),
259
+ y: Number(point.y || 0)
260
+ }))
261
+ }
262
+
263
+ /**
264
+ * Assigns copper material to plated drill side-wall triangles only.
265
+ * @param {any} THREE
266
+ * @param {any} geometry
267
+ * @param {{ pads?: any[], vias?: any[] }} detail
268
+ * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint
269
+ * @returns {void}
270
+ */
271
+ static #applyPlatedDrillWallMaterials(
272
+ THREE,
273
+ geometry,
274
+ detail,
275
+ normalizeBoardPoint
276
+ ) {
277
+ const contours = PcbScene3dBoardShapeFactory.#resolvePlatedContours(
278
+ THREE,
279
+ detail,
280
+ normalizeBoardPoint
281
+ )
282
+ if (!contours.length || !geometry?.groups?.length) {
283
+ return
284
+ }
285
+
286
+ const contourIndex =
287
+ PcbScene3dBoardShapeFactory.#buildContourSpatialIndex(contours)
288
+ const nextGroups = []
289
+ for (const group of geometry.groups) {
290
+ const start = Number(group.start || 0)
291
+ const end = start + Number(group.count || 0)
292
+ for (let index = start; index < end; index += 3) {
293
+ const materialIndex =
294
+ Number(group.materialIndex) ===
295
+ PcbScene3dBoardShapeFactory.#EDGE_WALL_MATERIAL_INDEX &&
296
+ PcbScene3dBoardShapeFactory.#matchesAnyContour(
297
+ geometry,
298
+ index,
299
+ contourIndex
300
+ )
301
+ ? PcbScene3dBoardShapeFactory
302
+ .#PLATED_WALL_MATERIAL_INDEX
303
+ : Number(group.materialIndex || 0)
304
+
305
+ PcbScene3dBoardShapeFactory.#appendGeometryGroup(
306
+ nextGroups,
307
+ index,
308
+ Math.min(3, end - index),
309
+ materialIndex
310
+ )
311
+ }
312
+ }
313
+
314
+ geometry.clearGroups?.()
315
+ nextGroups.forEach((group) => {
316
+ geometry.addGroup(group.start, group.count, group.materialIndex)
317
+ })
318
+ }
319
+
320
+ /**
321
+ * Resolves normalized contours for plated board drills.
322
+ * @param {any} THREE
323
+ * @param {{ pads?: any[], vias?: any[] }} detail
324
+ * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint
325
+ * @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 } }[]}
326
+ */
327
+ static #resolvePlatedContours(THREE, detail, normalizeBoardPoint) {
328
+ return PcbScene3dBoardShapeFactory.#resolvePlatedDrillSpecs(detail)
329
+ .map((drillSpec) => {
330
+ const point = normalizeBoardPoint(drillSpec.x, drillSpec.y)
331
+ const path = PcbScene3dDrillPathFactory.buildDrillPath(THREE, {
332
+ ...drillSpec,
333
+ x: point.x,
334
+ y: point.y
335
+ })
336
+ const points =
337
+ path?.getPoints?.(
338
+ PcbScene3dBoardShapeFactory.#CONTOUR_SAMPLE_POINTS
339
+ ) || []
340
+ return PcbScene3dBoardShapeFactory.#buildContour(points)
341
+ })
342
+ .filter(Boolean)
343
+ }
344
+
345
+ /**
346
+ * Resolves deduped drill specs that represent plated holes.
347
+ * @param {{ pads?: any[], vias?: any[] }} detail
348
+ * @returns {{ x: number, y: number, diameter: number, slotLength?: number | null, rotationDeg?: number | null }[]}
349
+ */
350
+ static #resolvePlatedDrillSpecs(detail) {
351
+ const platedKeys = new Set()
352
+
353
+ for (const via of detail?.vias || []) {
354
+ const diameter = Number(via?.holeDiameter || 0)
355
+ if (diameter <= 0) {
356
+ continue
357
+ }
358
+
359
+ platedKeys.add(
360
+ PcbScene3dBoardShapeFactory.#buildDrillSpecKey({
361
+ x: Number(via?.x || 0),
362
+ y: Number(via?.y || 0),
363
+ diameter,
364
+ slotLength: null,
365
+ rotationDeg: 0
366
+ })
367
+ )
368
+ }
369
+
370
+ for (const pad of detail?.pads || []) {
371
+ const diameter = Number(pad?.holeDiameter || 0)
372
+ if (
373
+ diameter <= 0 ||
374
+ !PcbScene3dBoardShapeFactory.#hasPadCopperAnnulus(pad, diameter)
375
+ ) {
376
+ continue
377
+ }
378
+
379
+ const slotLength =
380
+ Number(pad?.holeShape) ===
381
+ PcbScene3dBoardShapeFactory.#PAD_HOLE_SHAPE_SLOT &&
382
+ Number(pad?.holeSlotLength || 0) > diameter
383
+ ? Number(pad?.holeSlotLength || 0)
384
+ : null
385
+ platedKeys.add(
386
+ PcbScene3dBoardShapeFactory.#buildDrillSpecKey({
387
+ x: Number(pad?.x || 0),
388
+ y: Number(pad?.y || 0),
389
+ diameter,
390
+ slotLength,
391
+ rotationDeg:
392
+ slotLength === null
393
+ ? 0
394
+ : PcbScene3dBoardShapeFactory.#normalizeAngle(
395
+ Number(pad?.rotation || 0) +
396
+ Number(pad?.holeRotation || 0)
397
+ )
398
+ })
399
+ )
400
+ }
401
+
402
+ return PcbScene3dDrillPathFactory.resolveBoardDrillSpecs(detail).filter(
403
+ (drillSpec) =>
404
+ platedKeys.has(
405
+ PcbScene3dBoardShapeFactory.#buildDrillSpecKey(drillSpec)
406
+ )
407
+ )
408
+ }
409
+
410
+ /**
411
+ * Checks whether one pad has copper larger than its drill aperture.
412
+ * @param {any} pad
413
+ * @param {number} diameter
414
+ * @returns {boolean}
415
+ */
416
+ static #hasPadCopperAnnulus(pad, diameter) {
417
+ const drillSpan = Math.max(diameter, Number(pad?.holeSlotLength || 0))
418
+
419
+ return [
420
+ pad?.sizeTopX,
421
+ pad?.sizeTopY,
422
+ pad?.sizeMidX,
423
+ pad?.sizeMidY,
424
+ pad?.sizeBottomX,
425
+ pad?.sizeBottomY
426
+ ].some(
427
+ (size) =>
428
+ Number(size || 0) >
429
+ drillSpan + PcbScene3dBoardShapeFactory.#GEOMETRY_EPSILON
430
+ )
431
+ }
432
+
433
+ /**
434
+ * Builds one closed contour and bounds from sampled path points.
435
+ * @param {any[]} points
436
+ * @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 } } | null}
437
+ */
438
+ static #buildContour(points) {
439
+ const contour = (points || [])
440
+ .map((point) => ({ x: Number(point?.x), y: Number(point?.y) }))
441
+ .filter(
442
+ (point) => Number.isFinite(point.x) && Number.isFinite(point.y)
443
+ )
444
+ const first = contour[0]
445
+ const last = contour[contour.length - 1]
446
+
447
+ if (contour.length < 3) {
448
+ return null
449
+ }
450
+
451
+ if (
452
+ Math.hypot(first.x - last.x, first.y - last.y) >
453
+ PcbScene3dBoardShapeFactory.#GEOMETRY_EPSILON
454
+ ) {
455
+ contour.push({ ...first })
456
+ }
457
+
458
+ return {
459
+ points: contour,
460
+ segments:
461
+ PcbScene3dBoardShapeFactory.#buildContourSegments(contour),
462
+ bounds: contour.reduce(
463
+ (bounds, point) => ({
464
+ minX: Math.min(bounds.minX, point.x),
465
+ maxX: Math.max(bounds.maxX, point.x),
466
+ minY: Math.min(bounds.minY, point.y),
467
+ maxY: Math.max(bounds.maxY, point.y)
468
+ }),
469
+ {
470
+ minX: Infinity,
471
+ maxX: -Infinity,
472
+ minY: Infinity,
473
+ maxY: -Infinity
474
+ }
475
+ )
476
+ }
477
+ }
478
+
479
+ /**
480
+ * Builds reusable segment metadata for one contour.
481
+ * @param {{ x: number, y: number }[]} points Closed contour points.
482
+ * @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 } }[]}
483
+ */
484
+ static #buildContourSegments(points) {
485
+ const segments = []
486
+
487
+ for (let index = 0; index < points.length - 1; index += 1) {
488
+ const start = points[index]
489
+ const end = points[index + 1]
490
+ const dx = end.x - start.x
491
+ const dy = end.y - start.y
492
+
493
+ segments.push({
494
+ start,
495
+ end,
496
+ dx,
497
+ dy,
498
+ lengthSquared: dx * dx + dy * dy,
499
+ bounds: {
500
+ minX: Math.min(start.x, end.x),
501
+ maxX: Math.max(start.x, end.x),
502
+ minY: Math.min(start.y, end.y),
503
+ maxY: Math.max(start.y, end.y)
504
+ }
505
+ })
506
+ }
507
+
508
+ return segments
509
+ }
510
+
511
+ /**
512
+ * Builds a spatial index for plated drill contour bounds.
513
+ * @param {{ bounds: { minX: number, maxX: number, minY: number, maxY: number } }[]} contours
514
+ * @returns {{ cellSize: number, cells: Map<string, number[]>, contours: any[], overflowIndexes: number[], marks: Uint32Array, mark: number }}
515
+ */
516
+ static #buildContourSpatialIndex(contours) {
517
+ const cellSize =
518
+ PcbScene3dBoardShapeFactory.#resolveContourIndexCellSize(contours)
519
+ const cells = new Map()
520
+ const overflowIndexes = []
521
+
522
+ contours.forEach((contour, index) => {
523
+ const range = PcbScene3dBoardShapeFactory.#resolveCellRange(
524
+ contour.bounds,
525
+ cellSize
526
+ )
527
+ const cellCount =
528
+ (range.maxX - range.minX + 1) * (range.maxY - range.minY + 1)
529
+
530
+ if (
531
+ cellCount >
532
+ PcbScene3dBoardShapeFactory.#CONTOUR_INDEX_MAX_CELLS_PER_CONTOUR
533
+ ) {
534
+ overflowIndexes.push(index)
535
+ return
536
+ }
537
+
538
+ for (let cellX = range.minX; cellX <= range.maxX; cellX += 1) {
539
+ for (let cellY = range.minY; cellY <= range.maxY; cellY += 1) {
540
+ const key = PcbScene3dBoardShapeFactory.#cellKey(
541
+ cellX,
542
+ cellY
543
+ )
544
+ const bucket = cells.get(key)
545
+
546
+ if (bucket) {
547
+ bucket.push(index)
548
+ } else {
549
+ cells.set(key, [index])
550
+ }
551
+ }
552
+ }
553
+ })
554
+
555
+ return {
556
+ cellSize,
557
+ cells,
558
+ contours,
559
+ overflowIndexes,
560
+ marks: new Uint32Array(contours.length),
561
+ mark: 0
562
+ }
563
+ }
564
+
565
+ /**
566
+ * Resolves a spatial cell size from typical contour spans.
567
+ * @param {{ bounds: { minX: number, maxX: number, minY: number, maxY: number } }[]} contours
568
+ * @returns {number}
569
+ */
570
+ static #resolveContourIndexCellSize(contours) {
571
+ const spans = contours
572
+ .map((contour) =>
573
+ Math.max(
574
+ Number(contour.bounds.maxX) - Number(contour.bounds.minX),
575
+ Number(contour.bounds.maxY) - Number(contour.bounds.minY),
576
+ 0
577
+ )
578
+ )
579
+ .filter((span) => Number.isFinite(span))
580
+ .sort((left, right) => left - right)
581
+ const medianSpan = spans[Math.floor(spans.length / 2)] || 0
582
+
583
+ return Math.max(
584
+ medianSpan * 4,
585
+ PcbScene3dBoardShapeFactory.#CONTOUR_INDEX_MIN_CELL_SIZE
586
+ )
587
+ }
588
+
589
+ /**
590
+ * Collects unique contour candidates that could touch one triangle.
591
+ * @param {{ minX: number, maxX: number, minY: number, maxY: number }} bounds
592
+ * @param {{ cellSize: number, cells: Map<string, number[]>, contours: any[], overflowIndexes: number[], marks: Uint32Array, mark: number }} contourIndex
593
+ * @returns {any[]}
594
+ */
595
+ static #collectCandidateContours(bounds, contourIndex) {
596
+ const candidates = []
597
+ const tolerance =
598
+ PcbScene3dBoardShapeFactory.#CONTOUR_MATCH_TOLERANCE_MIL
599
+ const range = PcbScene3dBoardShapeFactory.#resolveCellRange(
600
+ {
601
+ minX: bounds.minX - tolerance,
602
+ maxX: bounds.maxX + tolerance,
603
+ minY: bounds.minY - tolerance,
604
+ maxY: bounds.maxY + tolerance
605
+ },
606
+ contourIndex.cellSize
607
+ )
608
+
609
+ contourIndex.mark += 1
610
+ if (contourIndex.mark >= 0xffffffff) {
611
+ contourIndex.marks.fill(0)
612
+ contourIndex.mark = 1
613
+ }
614
+
615
+ for (let cellX = range.minX; cellX <= range.maxX; cellX += 1) {
616
+ for (let cellY = range.minY; cellY <= range.maxY; cellY += 1) {
617
+ const bucket = contourIndex.cells.get(
618
+ PcbScene3dBoardShapeFactory.#cellKey(cellX, cellY)
619
+ )
620
+
621
+ if (bucket) {
622
+ for (const index of bucket) {
623
+ PcbScene3dBoardShapeFactory.#appendContourCandidate(
624
+ candidates,
625
+ contourIndex,
626
+ index
627
+ )
628
+ }
629
+ }
630
+ }
631
+ }
632
+
633
+ for (const index of contourIndex.overflowIndexes) {
634
+ PcbScene3dBoardShapeFactory.#appendContourCandidate(
635
+ candidates,
636
+ contourIndex,
637
+ index
638
+ )
639
+ }
640
+ return candidates
641
+ }
642
+
643
+ /**
644
+ * Appends one unique contour candidate for the current collection mark.
645
+ * @param {any[]} candidates
646
+ * @param {{ contours: any[], marks: Uint32Array, mark: number }} contourIndex
647
+ * @param {number} index
648
+ * @returns {void}
649
+ */
650
+ static #appendContourCandidate(candidates, contourIndex, index) {
651
+ if (contourIndex.marks[index] === contourIndex.mark) {
652
+ return
653
+ }
654
+
655
+ contourIndex.marks[index] = contourIndex.mark
656
+ candidates.push(contourIndex.contours[index])
657
+ }
658
+
659
+ /**
660
+ * Resolves the inclusive grid-cell range for one bounds box.
661
+ * @param {{ minX: number, maxX: number, minY: number, maxY: number }} bounds
662
+ * @param {number} cellSize
663
+ * @returns {{ minX: number, maxX: number, minY: number, maxY: number }}
664
+ */
665
+ static #resolveCellRange(bounds, cellSize) {
666
+ return {
667
+ minX: Math.floor(Number(bounds.minX) / cellSize),
668
+ maxX: Math.floor(Number(bounds.maxX) / cellSize),
669
+ minY: Math.floor(Number(bounds.minY) / cellSize),
670
+ maxY: Math.floor(Number(bounds.maxY) / cellSize)
671
+ }
672
+ }
673
+
674
+ /**
675
+ * Builds a deterministic contour spatial index key.
676
+ * @param {number} cellX
677
+ * @param {number} cellY
678
+ * @returns {string}
679
+ */
680
+ static #cellKey(cellX, cellY) {
681
+ return `${cellX}:${cellY}`
682
+ }
683
+
684
+ /**
685
+ * Returns true when a triangle is on any plated drill contour.
686
+ * @param {any} geometry
687
+ * @param {number} triangleStart
688
+ * @param {{ cellSize: number, cells: Map<string, number[]>, contours: any[], overflowIndexes: number[], marks: Uint32Array, mark: number }} contourIndex
689
+ * @returns {boolean}
690
+ */
691
+ static #matchesAnyContour(geometry, triangleStart, contourIndex) {
692
+ const triangle = PcbScene3dBoardShapeFactory.#resolveTrianglePoints(
693
+ geometry,
694
+ triangleStart
695
+ )
696
+ return PcbScene3dBoardShapeFactory.#collectCandidateContours(
697
+ triangle.bounds,
698
+ contourIndex
699
+ ).some((contour) =>
700
+ PcbScene3dBoardShapeFactory.#matchesContourPoints(
701
+ triangle.points,
702
+ contour
703
+ )
704
+ )
705
+ }
706
+
707
+ /**
708
+ * Returns true when all triangle vertices lie on one drill contour.
709
+ * @param {{ x: number, y: number }[]} points
710
+ * @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 } }} contour
711
+ * @returns {boolean}
712
+ */
713
+ static #matchesContourPoints(points, contour) {
714
+ for (const point of points) {
715
+ if (
716
+ !PcbScene3dBoardShapeFactory.#isPointNearContour(point, contour)
717
+ ) {
718
+ return false
719
+ }
720
+ }
721
+
722
+ return true
723
+ }
724
+
725
+ /**
726
+ * Resolves one geometry triangle's XY points and bounds.
727
+ * @param {any} geometry
728
+ * @param {number} triangleStart
729
+ * @returns {{ points: { x: number, y: number }[], bounds: { minX: number, maxX: number, minY: number, maxY: number } }}
730
+ */
731
+ static #resolveTrianglePoints(geometry, triangleStart) {
732
+ const points = [0, 1, 2].map((offset) =>
733
+ PcbScene3dBoardShapeFactory.#resolveVertexPoint(
734
+ geometry,
735
+ triangleStart + offset
736
+ )
737
+ )
738
+
739
+ return {
740
+ points,
741
+ bounds: points.reduce(
742
+ (bounds, point) => ({
743
+ minX: Math.min(bounds.minX, point.x),
744
+ maxX: Math.max(bounds.maxX, point.x),
745
+ minY: Math.min(bounds.minY, point.y),
746
+ maxY: Math.max(bounds.maxY, point.y)
747
+ }),
748
+ {
749
+ minX: Infinity,
750
+ maxX: -Infinity,
751
+ minY: Infinity,
752
+ maxY: -Infinity
753
+ }
754
+ )
755
+ }
756
+ }
757
+
758
+ /**
759
+ * Resolves the XY point for one indexed or non-indexed geometry vertex.
760
+ * @param {any} geometry
761
+ * @param {number} index
762
+ * @returns {{ x: number, y: number }}
763
+ */
764
+ static #resolveVertexPoint(geometry, index) {
765
+ const position = geometry.getAttribute('position')
766
+ const vertexIndex = geometry.index?.getX?.(index) ?? index
767
+
768
+ return {
769
+ x: position.getX(vertexIndex),
770
+ y: position.getY(vertexIndex)
771
+ }
772
+ }
773
+
774
+ /**
775
+ * Returns true when a point is close to one contour boundary.
776
+ * @param {{ x: number, y: number }} point
777
+ * @param {{ 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 } }} contour
778
+ * @returns {boolean}
779
+ */
780
+ static #isPointNearContour(point, contour) {
781
+ const tolerance =
782
+ PcbScene3dBoardShapeFactory.#CONTOUR_MATCH_TOLERANCE_MIL
783
+ const toleranceSquared = tolerance * tolerance
784
+ if (
785
+ point.x < contour.bounds.minX - tolerance ||
786
+ point.x > contour.bounds.maxX + tolerance ||
787
+ point.y < contour.bounds.minY - tolerance ||
788
+ point.y > contour.bounds.maxY + tolerance
789
+ ) {
790
+ return false
791
+ }
792
+
793
+ for (const segment of contour.segments) {
794
+ if (
795
+ point.x < segment.bounds.minX - tolerance ||
796
+ point.x > segment.bounds.maxX + tolerance ||
797
+ point.y < segment.bounds.minY - tolerance ||
798
+ point.y > segment.bounds.maxY + tolerance
799
+ ) {
800
+ continue
801
+ }
802
+
803
+ if (
804
+ PcbScene3dBoardShapeFactory.#distanceToSegmentSquared(
805
+ point,
806
+ segment
807
+ ) <= toleranceSquared
808
+ ) {
809
+ return true
810
+ }
811
+ }
812
+
813
+ return false
814
+ }
815
+
816
+ /**
817
+ * Appends or extends a contiguous material group.
818
+ * @param {{ start: number, count: number, materialIndex: number }[]} groups
819
+ * @param {number} start
820
+ * @param {number} count
821
+ * @param {number} materialIndex
822
+ * @returns {void}
823
+ */
824
+ static #appendGeometryGroup(groups, start, count, materialIndex) {
825
+ const previous = groups[groups.length - 1]
826
+ if (
827
+ previous &&
828
+ previous.start + previous.count === start &&
829
+ previous.materialIndex === materialIndex
830
+ ) {
831
+ previous.count += count
832
+ return
833
+ }
834
+
835
+ groups.push({ start, count, materialIndex })
836
+ }
837
+
838
+ /**
839
+ * Computes the squared XY distance from a point to a finite segment.
840
+ * @param {{ x: number, y: number }} point
841
+ * @param {{ start: { x: number, y: number }, end: { x: number, y: number }, dx: number, dy: number, lengthSquared: number }} segment
842
+ * @returns {number}
843
+ */
844
+ static #distanceToSegmentSquared(point, segment) {
845
+ if (
846
+ segment.lengthSquared <=
847
+ PcbScene3dBoardShapeFactory.#GEOMETRY_EPSILON
848
+ ) {
849
+ const dx = point.x - segment.start.x
850
+ const dy = point.y - segment.start.y
851
+ return dx * dx + dy * dy
852
+ }
853
+
854
+ const ratio = Math.max(
855
+ 0,
856
+ Math.min(
857
+ 1,
858
+ ((point.x - segment.start.x) * segment.dx +
859
+ (point.y - segment.start.y) * segment.dy) /
860
+ segment.lengthSquared
861
+ )
862
+ )
863
+ const projectedX = segment.start.x + ratio * segment.dx
864
+ const projectedY = segment.start.y + ratio * segment.dy
865
+ const dx = point.x - projectedX
866
+ const dy = point.y - projectedY
867
+
868
+ return dx * dx + dy * dy
869
+ }
870
+
871
+ /**
872
+ * Builds a stable drill spec key.
873
+ * @param {{ x: number, y: number, diameter: number, slotLength?: number | null, rotationDeg?: number | null }} drillSpec
874
+ * @returns {string}
875
+ */
876
+ static #buildDrillSpecKey(drillSpec) {
877
+ return [
878
+ Number(drillSpec.x || 0).toFixed(4),
879
+ Number(drillSpec.y || 0).toFixed(4),
880
+ Number(drillSpec.diameter || 0).toFixed(4),
881
+ Number(drillSpec.slotLength || 0).toFixed(4),
882
+ Number(drillSpec.rotationDeg || 0).toFixed(4)
883
+ ].join(':')
884
+ }
885
+
886
+ /**
887
+ * Normalizes one angle into the inclusive `[0, 360)` range.
888
+ * @param {number} angleDeg
889
+ * @returns {number}
890
+ */
891
+ static #normalizeAngle(angleDeg) {
892
+ const normalized = Number(angleDeg || 0) % 360
893
+ return normalized < 0 ? normalized + 360 : normalized
894
+ }
895
+ }