pcb-scene3d-viewer 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +67 -0
- package/COMMERCIAL-LICENSE.md +15 -0
- package/CONTRIBUTING.md +14 -0
- package/LICENSE +19 -0
- package/LICENSES/AGPL-3.0-or-later.txt +235 -0
- package/LICENSES/CC-BY-SA-4.0.txt +170 -0
- package/LICENSES/LGPL-2.1-or-later.txt +176 -0
- package/LICENSES/LicenseRef-PolyForm-Noncommercial-1.0.0.txt +131 -0
- package/NOTICE.md +36 -0
- package/README.md +128 -0
- package/REUSE.toml +16 -0
- package/docs/api.md +148 -0
- package/docs/circuitjson.md +190 -0
- package/docs/model-format.md +117 -0
- package/docs/testing.md +23 -0
- package/package.json +65 -0
- package/spec/library-scope.md +36 -0
- package/src/PcbModelArchiveExporter.mjs +320 -0
- package/src/PcbScene3dArcUtils.mjs +27 -0
- package/src/PcbScene3dBoardAssemblyPlacement.mjs +36 -0
- package/src/PcbScene3dBoardAssemblyPresentation.mjs +859 -0
- package/src/PcbScene3dBoardEdgeCutoutBuilder.mjs +537 -0
- package/src/PcbScene3dBoardMaterialPalette.mjs +40 -0
- package/src/PcbScene3dBoardShapeFactory.mjs +895 -0
- package/src/PcbScene3dBoardSolderMaskFactory.mjs +613 -0
- package/src/PcbScene3dCameraRig.mjs +168 -0
- package/src/PcbScene3dCircuitJsonAdapter.mjs +545 -0
- package/src/PcbScene3dController.mjs +956 -0
- package/src/PcbScene3dCopperDetailFilter.mjs +490 -0
- package/src/PcbScene3dCopperFactory.mjs +559 -0
- package/src/PcbScene3dCopperTextFactory.mjs +534 -0
- package/src/PcbScene3dCutoutGeometryFilter.mjs +873 -0
- package/src/PcbScene3dDetailCoordinateNormalizer.mjs +65 -0
- package/src/PcbScene3dDrillCutoutFilter.mjs +224 -0
- package/src/PcbScene3dDrillPathFactory.mjs +362 -0
- package/src/PcbScene3dDrillVoidFactory.mjs +268 -0
- package/src/PcbScene3dExternalModelLoadOrder.mjs +54 -0
- package/src/PcbScene3dExternalModels.mjs +968 -0
- package/src/PcbScene3dFallbackVisibility.mjs +82 -0
- package/src/PcbScene3dInteractionHints.mjs +56 -0
- package/src/PcbScene3dMountRig.mjs +53 -0
- package/src/PcbScene3dOutlineBuilder.mjs +210 -0
- package/src/PcbScene3dPadFactory.mjs +553 -0
- package/src/PcbScene3dPresetState.mjs +48 -0
- package/src/PcbScene3dRenderGroupVisibility.mjs +134 -0
- package/src/PcbScene3dRuntime.mjs +996 -0
- package/src/PcbScene3dRuntimeBoardMeshes.mjs +99 -0
- package/src/PcbScene3dSelectionStyler.mjs +252 -0
- package/src/PcbScene3dShapePathFactory.mjs +220 -0
- package/src/PcbScene3dShellRenderer.mjs +131 -0
- package/src/PcbScene3dSilkscreenFactory.mjs +854 -0
- package/src/PcbScene3dSilkscreenStrokeWidthResolver.mjs +81 -0
- package/src/PcbScene3dStepLoader.mjs +611 -0
- package/src/PcbScene3dStrokeFont.mjs +671 -0
- package/src/PcbScene3dStrokeGeometryBuilder.mjs +322 -0
- package/src/PcbScene3dText.mjs +99 -0
- package/src/PcbScene3dTrueTypeTextFactory.mjs +885 -0
- package/src/PcbScene3dViaFactory.mjs +176 -0
- package/src/PcbScene3dViewCompensation.mjs +109 -0
- package/src/PcbScene3dViewScale.mjs +24 -0
- package/src/PcbScene3dViewportResize.mjs +35 -0
- package/src/PcbScene3dWorkerClient.mjs +123 -0
- package/src/index.mjs +1 -0
- package/src/scene3d.mjs +44 -0
- package/src/styles/scene3d.css +295 -0
|
@@ -0,0 +1,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
|
+
}
|