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,65 @@
1
+ /**
2
+ * Normalizes PCB detail primitive coordinates into the runtime board plane.
3
+ */
4
+ export class PcbScene3dDetailCoordinateNormalizer {
5
+ /**
6
+ * Creates a reusable normalizer for one scene description.
7
+ * @param {{ board?: { centerX?: number, centerY?: number }, boardAssemblyModel?: any, coordinateSystem?: string, sourceFormat?: string } | null} sceneDescription Scene metadata.
8
+ * @returns {(x: number, y: number) => { x: number, y: number }}
9
+ */
10
+ static create(sceneDescription) {
11
+ return (x, y) =>
12
+ PcbScene3dDetailCoordinateNormalizer.normalize(
13
+ sceneDescription,
14
+ x,
15
+ y
16
+ )
17
+ }
18
+
19
+ /**
20
+ * Normalizes one source detail coordinate into centered scene space.
21
+ * @param {{ board?: { centerX?: number, centerY?: number }, boardAssemblyModel?: any, coordinateSystem?: string, sourceFormat?: string } | null} sceneDescription Scene metadata.
22
+ * @param {number} x Source X coordinate.
23
+ * @param {number} y Source Y coordinate.
24
+ * @returns {{ x: number, y: number }}
25
+ */
26
+ static normalize(sceneDescription, x, y) {
27
+ const board = sceneDescription?.board || {}
28
+ const centerX = Number(board.centerX || 0)
29
+ const centerY = Number(board.centerY || 0)
30
+ const sourceX = Number(x || 0)
31
+ const sourceY = Number(y || 0)
32
+
33
+ return {
34
+ x: sourceX - centerX,
35
+ y: PcbScene3dDetailCoordinateNormalizer.#usesAssemblyModelPcbY(
36
+ sceneDescription
37
+ )
38
+ ? centerY - sourceY
39
+ : sourceY - centerY
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Checks whether detail primitives are being aligned to an assembly model
45
+ * that still uses the source PCB Y axis.
46
+ * @param {{ boardAssemblyModel?: any, coordinateSystem?: string, sourceFormat?: string } | null} sceneDescription Scene metadata.
47
+ * @returns {boolean}
48
+ */
49
+ static #usesAssemblyModelPcbY(sceneDescription) {
50
+ if (
51
+ String(sceneDescription?.coordinateSystem || '') === 'kicad-3d-y-up'
52
+ ) {
53
+ return false
54
+ }
55
+
56
+ const sourceFormat = String(
57
+ sceneDescription?.sourceFormat || ''
58
+ ).toLowerCase()
59
+
60
+ return (
61
+ sourceFormat === 'altium' &&
62
+ Boolean(sceneDescription?.boardAssemblyModel)
63
+ )
64
+ }
65
+ }
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Filters drill cutout polygons before they are applied to filled artwork.
3
+ */
4
+ export class PcbScene3dDrillCutoutFilter {
5
+ static #GEOMETRY_EPSILON = 0.001
6
+
7
+ /**
8
+ * Removes drill cutouts that are already covered by authored fill holes.
9
+ * @param {{ x: number, y: number }[][]} drillCutouts
10
+ * @param {{ x: number, y: number }[][]} fillHoles
11
+ * @returns {{ x: number, y: number }[][]}
12
+ */
13
+ static removeCoveredCutouts(drillCutouts, fillHoles) {
14
+ return PcbScene3dDrillCutoutFilter.partitionFillHoles(
15
+ drillCutouts,
16
+ fillHoles
17
+ ).uncoveredCutouts
18
+ }
19
+
20
+ /**
21
+ * Splits authored holes from physical drill holes already copied to a fill.
22
+ * @param {{ x: number, y: number }[][]} drillCutouts
23
+ * @param {{ x: number, y: number }[][]} fillHoles
24
+ * @returns {{ authoredHoles: { x: number, y: number }[][], drillHoles: { x: number, y: number }[][], uncoveredCutouts: { x: number, y: number }[][] }}
25
+ */
26
+ static partitionFillHoles(drillCutouts, fillHoles) {
27
+ const cutouts = Array.isArray(drillCutouts) ? drillCutouts : []
28
+ const holes = Array.isArray(fillHoles) ? fillHoles : []
29
+
30
+ if (!holes.length) {
31
+ return {
32
+ authoredHoles: [],
33
+ drillHoles: [],
34
+ uncoveredCutouts: cutouts
35
+ }
36
+ }
37
+
38
+ if (!cutouts.length) {
39
+ return {
40
+ authoredHoles: holes,
41
+ drillHoles: [],
42
+ uncoveredCutouts: []
43
+ }
44
+ }
45
+
46
+ const authoredHoles = []
47
+ const drillHoles = []
48
+ for (const hole of holes) {
49
+ if (PcbScene3dDrillCutoutFilter.#isDrillHole(hole, cutouts)) {
50
+ drillHoles.push(hole)
51
+ } else {
52
+ authoredHoles.push(hole)
53
+ }
54
+ }
55
+
56
+ return {
57
+ authoredHoles,
58
+ drillHoles,
59
+ uncoveredCutouts: cutouts.filter(
60
+ (cutout) =>
61
+ !holes.some((hole) =>
62
+ PcbScene3dDrillCutoutFilter.#doesHoleCoverCutout(
63
+ hole,
64
+ cutout
65
+ )
66
+ )
67
+ )
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Returns true when a fill hole represents one known drill cutout.
73
+ * @param {{ x: number, y: number }[]} hole
74
+ * @param {{ x: number, y: number }[][]} drillCutouts
75
+ * @returns {boolean}
76
+ */
77
+ static #isDrillHole(hole, drillCutouts) {
78
+ return drillCutouts.some((cutout) =>
79
+ PcbScene3dDrillCutoutFilter.#doesHoleCoverCutout(hole, cutout)
80
+ )
81
+ }
82
+
83
+ /**
84
+ * Returns true when an authored hole already clears one drill cutout.
85
+ * @param {{ x: number, y: number }[]} hole
86
+ * @param {{ x: number, y: number }[]} cutout
87
+ * @returns {boolean}
88
+ */
89
+ static #doesHoleCoverCutout(hole, cutout) {
90
+ return (
91
+ Array.isArray(hole) &&
92
+ hole.length >= 3 &&
93
+ Array.isArray(cutout) &&
94
+ cutout.length >= 3 &&
95
+ PcbScene3dDrillCutoutFilter.#isPointInsideOrOnPolygon(
96
+ PcbScene3dDrillCutoutFilter.#resolvePolygonCentroid(cutout),
97
+ hole
98
+ ) &&
99
+ cutout.every((point) =>
100
+ PcbScene3dDrillCutoutFilter.#isPointInsideOrOnPolygon(
101
+ point,
102
+ hole
103
+ )
104
+ )
105
+ )
106
+ }
107
+
108
+ /**
109
+ * Resolves the average center of one polygon.
110
+ * @param {{ x: number, y: number }[]} points
111
+ * @returns {{ x: number, y: number }}
112
+ */
113
+ static #resolvePolygonCentroid(points) {
114
+ const totals = points.reduce(
115
+ (accumulator, point) => ({
116
+ x: accumulator.x + Number(point.x || 0),
117
+ y: accumulator.y + Number(point.y || 0)
118
+ }),
119
+ { x: 0, y: 0 }
120
+ )
121
+ const count = Math.max(points.length, 1)
122
+
123
+ return {
124
+ x: totals.x / count,
125
+ y: totals.y / count
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Returns true when a point is inside or on one polygon.
131
+ * @param {{ x: number, y: number }} point
132
+ * @param {{ x: number, y: number }[]} polygon
133
+ * @returns {boolean}
134
+ */
135
+ static #isPointInsideOrOnPolygon(point, polygon) {
136
+ return (
137
+ PcbScene3dDrillCutoutFilter.#isPointOnPolygonBoundary(
138
+ point,
139
+ polygon
140
+ ) ||
141
+ PcbScene3dDrillCutoutFilter.#isPointStrictlyInsidePolygon(
142
+ point,
143
+ polygon
144
+ )
145
+ )
146
+ }
147
+
148
+ /**
149
+ * Returns true when a point lies inside a polygon and away from its border.
150
+ * @param {{ x: number, y: number }} point
151
+ * @param {{ x: number, y: number }[]} polygon
152
+ * @returns {boolean}
153
+ */
154
+ static #isPointStrictlyInsidePolygon(point, polygon) {
155
+ let inside = false
156
+ for (
157
+ let index = 0, previousIndex = polygon.length - 1;
158
+ index < polygon.length;
159
+ previousIndex = index, index += 1
160
+ ) {
161
+ const current = polygon[index]
162
+ const previous = polygon[previousIndex]
163
+ const intersects =
164
+ current.y > point.y !== previous.y > point.y &&
165
+ point.x <
166
+ ((previous.x - current.x) * (point.y - current.y)) /
167
+ (previous.y - current.y) +
168
+ current.x
169
+
170
+ if (intersects) {
171
+ inside = !inside
172
+ }
173
+ }
174
+
175
+ return inside
176
+ }
177
+
178
+ /**
179
+ * Returns true when a point lies on any polygon edge.
180
+ * @param {{ x: number, y: number }} point
181
+ * @param {{ x: number, y: number }[]} polygon
182
+ * @returns {boolean}
183
+ */
184
+ static #isPointOnPolygonBoundary(point, polygon) {
185
+ return polygon.some((start, index) =>
186
+ PcbScene3dDrillCutoutFilter.#isPointOnSegment(
187
+ point,
188
+ start,
189
+ polygon[(index + 1) % polygon.length]
190
+ )
191
+ )
192
+ }
193
+
194
+ /**
195
+ * Returns true when a point lies on one finite segment.
196
+ * @param {{ x: number, y: number }} point
197
+ * @param {{ x: number, y: number }} start
198
+ * @param {{ x: number, y: number }} end
199
+ * @returns {boolean}
200
+ */
201
+ static #isPointOnSegment(point, start, end) {
202
+ const cross =
203
+ (point.y - start.y) * (end.x - start.x) -
204
+ (point.x - start.x) * (end.y - start.y)
205
+
206
+ if (Math.abs(cross) > PcbScene3dDrillCutoutFilter.#GEOMETRY_EPSILON) {
207
+ return false
208
+ }
209
+
210
+ const dot =
211
+ (point.x - start.x) * (end.x - start.x) +
212
+ (point.y - start.y) * (end.y - start.y)
213
+
214
+ if (dot < -PcbScene3dDrillCutoutFilter.#GEOMETRY_EPSILON) {
215
+ return false
216
+ }
217
+
218
+ const lengthSquared = (end.x - start.x) ** 2 + (end.y - start.y) ** 2
219
+
220
+ return (
221
+ dot <= lengthSquared + PcbScene3dDrillCutoutFilter.#GEOMETRY_EPSILON
222
+ )
223
+ }
224
+ }
@@ -0,0 +1,362 @@
1
+ /**
2
+ * Builds reusable circular and slotted drill paths for the 3D PCB scene.
3
+ */
4
+ export class PcbScene3dDrillPathFactory {
5
+ static #PAD_HOLE_SHAPE_SLOT = 2
6
+ static #ARC_SEGMENTS = 12
7
+
8
+ /**
9
+ * Appends all board drill holes from pads and vias to one board shape.
10
+ * @param {any} THREE
11
+ * @param {any} shape
12
+ * @param {{ pads?: any[], vias?: any[] }} detail
13
+ * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint
14
+ * @returns {void}
15
+ */
16
+ static appendBoardDrills(THREE, shape, detail, normalizeBoardPoint) {
17
+ for (const drillSpec of PcbScene3dDrillPathFactory.resolveBoardDrillSpecs(
18
+ detail
19
+ )) {
20
+ const point = normalizeBoardPoint(drillSpec.x, drillSpec.y)
21
+ const holePath = PcbScene3dDrillPathFactory.buildDrillPath(THREE, {
22
+ ...drillSpec,
23
+ x: point.x,
24
+ y: point.y
25
+ })
26
+ if (!holePath) {
27
+ continue
28
+ }
29
+
30
+ shape.holes.push(holePath)
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Resolves deduped board-space drill specs from pads and vias.
36
+ * @param {{ pads?: any[], vias?: any[] }} detail
37
+ * @returns {{ x: number, y: number, diameter: number, slotLength?: number | null, rotationDeg?: number | null }[]}
38
+ */
39
+ static resolveBoardDrillSpecs(detail) {
40
+ const seen = new Set()
41
+ const drillSpecs = [
42
+ ...PcbScene3dDrillPathFactory.#resolveViaDrillSpecs(
43
+ detail?.vias || []
44
+ ),
45
+ ...PcbScene3dDrillPathFactory.#resolvePadDrillSpecs(
46
+ detail?.pads || []
47
+ )
48
+ ]
49
+ const output = []
50
+
51
+ for (const drillSpec of drillSpecs) {
52
+ const key = PcbScene3dDrillPathFactory.#buildCacheKey(drillSpec)
53
+ if (seen.has(key)) {
54
+ continue
55
+ }
56
+
57
+ seen.add(key)
58
+ output.push(drillSpec)
59
+ }
60
+
61
+ return output
62
+ }
63
+
64
+ /**
65
+ * Builds one local pad drill path centered on the pad origin.
66
+ * @param {any} THREE
67
+ * @param {{ holeDiameter?: number, holeShape?: number | null, holeSlotLength?: number | null, holeRotation?: number | null, rotation?: number | null }} pad
68
+ * @returns {any | null}
69
+ */
70
+ static buildPadHolePath(THREE, pad) {
71
+ const drillSpec = PcbScene3dDrillPathFactory.#resolvePadDrillSpec(pad)
72
+ return drillSpec
73
+ ? PcbScene3dDrillPathFactory.buildDrillPath(THREE, {
74
+ ...drillSpec,
75
+ x: 0,
76
+ y: 0
77
+ })
78
+ : null
79
+ }
80
+
81
+ /**
82
+ * Builds one local via drill path centered on the via origin.
83
+ * @param {any} THREE
84
+ * @param {{ holeDiameter?: number }} via
85
+ * @returns {any | null}
86
+ */
87
+ static buildViaHolePath(THREE, via) {
88
+ const holeDiameter = Number(via?.holeDiameter || 0)
89
+ if (holeDiameter <= 0) {
90
+ return null
91
+ }
92
+
93
+ return PcbScene3dDrillPathFactory.buildDrillPath(THREE, {
94
+ x: 0,
95
+ y: 0,
96
+ diameter: holeDiameter,
97
+ slotLength: null,
98
+ rotationDeg: 0
99
+ })
100
+ }
101
+
102
+ /**
103
+ * Builds one drill path from a normalized drill descriptor.
104
+ * @param {any} THREE
105
+ * @param {{ x: number, y: number, diameter: number, slotLength?: number | null, rotationDeg?: number | null }} drillSpec
106
+ * @returns {any | null}
107
+ */
108
+ static buildDrillPath(THREE, drillSpec) {
109
+ const diameter = Number(drillSpec?.diameter || 0)
110
+ if (diameter <= 0) {
111
+ return null
112
+ }
113
+
114
+ if (Number(drillSpec?.slotLength || 0) > diameter + 0.001) {
115
+ return PcbScene3dDrillPathFactory.#buildSlotPath(THREE, drillSpec)
116
+ }
117
+
118
+ return PcbScene3dDrillPathFactory.#buildCirclePath(THREE, drillSpec)
119
+ }
120
+
121
+ /**
122
+ * Resolves board-space via drill specs from normalized via detail.
123
+ * @param {{ x?: number, y?: number, holeDiameter?: number }[]} vias
124
+ * @returns {{ x: number, y: number, diameter: number, slotLength: null, rotationDeg: 0 }[]}
125
+ */
126
+ static #resolveViaDrillSpecs(vias) {
127
+ return (vias || [])
128
+ .map((via) => {
129
+ const diameter = Number(via?.holeDiameter || 0)
130
+ if (diameter <= 0) {
131
+ return null
132
+ }
133
+
134
+ return {
135
+ x: Number(via?.x || 0),
136
+ y: Number(via?.y || 0),
137
+ diameter,
138
+ slotLength: null,
139
+ rotationDeg: 0
140
+ }
141
+ })
142
+ .filter(Boolean)
143
+ }
144
+
145
+ /**
146
+ * Resolves board-space pad drill specs from normalized pad detail.
147
+ * @param {any[]} pads
148
+ * @returns {{ x: number, y: number, diameter: number, slotLength?: number | null, rotationDeg?: number | null }[]}
149
+ */
150
+ static #resolvePadDrillSpecs(pads) {
151
+ return (pads || [])
152
+ .map((pad) => {
153
+ const drillSpec =
154
+ PcbScene3dDrillPathFactory.#resolvePadDrillSpec(pad)
155
+ if (!drillSpec) {
156
+ return null
157
+ }
158
+
159
+ return {
160
+ ...drillSpec,
161
+ x: Number(pad?.x || 0),
162
+ y: Number(pad?.y || 0),
163
+ rotationDeg:
164
+ drillSpec.slotLength === null
165
+ ? 0
166
+ : PcbScene3dDrillPathFactory.#normalizeAngle(
167
+ Number(pad?.rotation || 0) +
168
+ Number(drillSpec.rotationDeg || 0)
169
+ )
170
+ }
171
+ })
172
+ .filter(Boolean)
173
+ }
174
+
175
+ /**
176
+ * Resolves one pad-local drill spec when the pad is through-hole.
177
+ * @param {{ holeDiameter?: number, holeShape?: number | null, holeSlotLength?: number | null, holeRotation?: number | null }} pad
178
+ * @returns {{ diameter: number, slotLength?: number | null, rotationDeg?: number | null } | null}
179
+ */
180
+ static #resolvePadDrillSpec(pad) {
181
+ const diameter = Number(pad?.holeDiameter || 0)
182
+ if (diameter <= 0) {
183
+ return null
184
+ }
185
+
186
+ const slotLength =
187
+ Number(pad?.holeShape) ===
188
+ PcbScene3dDrillPathFactory.#PAD_HOLE_SHAPE_SLOT &&
189
+ Number(pad?.holeSlotLength || 0) > diameter
190
+ ? Number(pad?.holeSlotLength || 0)
191
+ : null
192
+
193
+ return {
194
+ diameter,
195
+ slotLength,
196
+ rotationDeg: Number(pad?.holeRotation || 0)
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Builds one circular drill path.
202
+ * @param {any} THREE
203
+ * @param {{ x: number, y: number, diameter: number }} drillSpec
204
+ * @returns {any}
205
+ */
206
+ static #buildCirclePath(THREE, drillSpec) {
207
+ const radius = Math.max(Number(drillSpec.diameter || 0) / 2, 0.6)
208
+ const path = new THREE.Path()
209
+ const centerX = Number(drillSpec.x || 0)
210
+ const centerY = Number(drillSpec.y || 0)
211
+
212
+ path.moveTo(centerX + radius, centerY)
213
+ path.absarc(centerX, centerY, radius, 0, Math.PI, false)
214
+ path.absarc(centerX, centerY, radius, Math.PI, Math.PI * 2, false)
215
+ path.closePath()
216
+ return path
217
+ }
218
+
219
+ /**
220
+ * Builds one slotted drill path as a rotated rounded rectangle.
221
+ * @param {any} THREE
222
+ * @param {{ x: number, y: number, diameter: number, slotLength?: number | null, rotationDeg?: number | null }} drillSpec
223
+ * @returns {any}
224
+ */
225
+ static #buildSlotPath(THREE, drillSpec) {
226
+ const points = PcbScene3dDrillPathFactory.#buildSlotPoints(drillSpec)
227
+ const path = new THREE.Path()
228
+
229
+ if (!points.length) {
230
+ return path
231
+ }
232
+
233
+ path.moveTo(points[0].x, points[0].y)
234
+ for (let index = 1; index < points.length; index += 1) {
235
+ path.lineTo(points[index].x, points[index].y)
236
+ }
237
+ path.closePath()
238
+ return path
239
+ }
240
+
241
+ /**
242
+ * Builds one rotated slot outline as sampled points.
243
+ * @param {{ x: number, y: number, diameter: number, slotLength?: number | null, rotationDeg?: number | null }} drillSpec
244
+ * @returns {{ x: number, y: number }[]}
245
+ */
246
+ static #buildSlotPoints(drillSpec) {
247
+ const diameter = Math.max(Number(drillSpec.diameter || 0), 1.2)
248
+ const radius = Math.max(diameter / 2, 0.6)
249
+ const slotLength = Math.max(Number(drillSpec.slotLength || 0), diameter)
250
+ const straightHalf = Math.max(slotLength / 2 - radius, 0)
251
+ const rotationRad = (Number(drillSpec.rotationDeg || 0) * Math.PI) / 180
252
+ const sampledPoints = []
253
+
254
+ PcbScene3dDrillPathFactory.#appendArcPoints(
255
+ sampledPoints,
256
+ straightHalf,
257
+ 0,
258
+ radius,
259
+ -Math.PI / 2,
260
+ Math.PI / 2
261
+ )
262
+ PcbScene3dDrillPathFactory.#appendArcPoints(
263
+ sampledPoints,
264
+ -straightHalf,
265
+ 0,
266
+ radius,
267
+ Math.PI / 2,
268
+ (Math.PI * 3) / 2,
269
+ true
270
+ )
271
+
272
+ return sampledPoints.map((point) =>
273
+ PcbScene3dDrillPathFactory.#rotateAndTranslatePoint(
274
+ point,
275
+ rotationRad,
276
+ Number(drillSpec.x || 0),
277
+ Number(drillSpec.y || 0)
278
+ )
279
+ )
280
+ }
281
+
282
+ /**
283
+ * Appends sampled points for one drill-cap arc.
284
+ * @param {{ x: number, y: number }[]} points
285
+ * @param {number} cx
286
+ * @param {number} cy
287
+ * @param {number} radius
288
+ * @param {number} startAngle
289
+ * @param {number} endAngle
290
+ * @param {boolean} [skipFirst]
291
+ * @returns {void}
292
+ */
293
+ static #appendArcPoints(
294
+ points,
295
+ cx,
296
+ cy,
297
+ radius,
298
+ startAngle,
299
+ endAngle,
300
+ skipFirst = false
301
+ ) {
302
+ for (
303
+ let index = 0;
304
+ index <= PcbScene3dDrillPathFactory.#ARC_SEGMENTS;
305
+ index += 1
306
+ ) {
307
+ if (skipFirst && index === 0) {
308
+ continue
309
+ }
310
+
311
+ const t = index / PcbScene3dDrillPathFactory.#ARC_SEGMENTS
312
+ const angle = startAngle + (endAngle - startAngle) * t
313
+ points.push({
314
+ x: cx + Math.cos(angle) * radius,
315
+ y: cy + Math.sin(angle) * radius
316
+ })
317
+ }
318
+ }
319
+
320
+ /**
321
+ * Rotates one local point around the origin and translates it into place.
322
+ * @param {{ x: number, y: number }} point
323
+ * @param {number} rotationRad
324
+ * @param {number} dx
325
+ * @param {number} dy
326
+ * @returns {{ x: number, y: number }}
327
+ */
328
+ static #rotateAndTranslatePoint(point, rotationRad, dx, dy) {
329
+ const cos = Math.cos(rotationRad)
330
+ const sin = Math.sin(rotationRad)
331
+
332
+ return {
333
+ x: point.x * cos - point.y * sin + dx,
334
+ y: point.x * sin + point.y * cos + dy
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Builds one stable dedupe key for a board drill.
340
+ * @param {{ x: number, y: number, diameter: number, slotLength?: number | null, rotationDeg?: number | null }} drillSpec
341
+ * @returns {string}
342
+ */
343
+ static #buildCacheKey(drillSpec) {
344
+ return [
345
+ Number(drillSpec.x || 0).toFixed(4),
346
+ Number(drillSpec.y || 0).toFixed(4),
347
+ Number(drillSpec.diameter || 0).toFixed(4),
348
+ Number(drillSpec.slotLength || 0).toFixed(4),
349
+ Number(drillSpec.rotationDeg || 0).toFixed(4)
350
+ ].join(':')
351
+ }
352
+
353
+ /**
354
+ * Normalizes one angle into the inclusive `[0, 360)` range.
355
+ * @param {number} angleDeg
356
+ * @returns {number}
357
+ */
358
+ static #normalizeAngle(angleDeg) {
359
+ const normalized = Number(angleDeg || 0) % 360
360
+ return normalized < 0 ? normalized + 360 : normalized
361
+ }
362
+ }