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,537 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds rounded outer-contour notches for drills that intersect board edges.
|
|
3
|
+
*/
|
|
4
|
+
export class PcbScene3dBoardEdgeCutoutBuilder {
|
|
5
|
+
static #OUTER_SAMPLE_POINTS = 160
|
|
6
|
+
static #EDGE_CUTOUT_SAMPLE_POINTS = 72
|
|
7
|
+
static #GEOMETRY_EPSILON = 0.001
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Resolves sampled points from one shape outline.
|
|
11
|
+
* @param {{ getPoints?: (segments: number) => { x: number, y: number }[] }} shape
|
|
12
|
+
* @returns {{ x: number, y: number }[]}
|
|
13
|
+
*/
|
|
14
|
+
static resolveShapePoints(shape) {
|
|
15
|
+
return PcbScene3dBoardEdgeCutoutBuilder.#dedupeAdjacentPoints(
|
|
16
|
+
(
|
|
17
|
+
shape?.getPoints?.(
|
|
18
|
+
PcbScene3dBoardEdgeCutoutBuilder.#OUTER_SAMPLE_POINTS
|
|
19
|
+
) || []
|
|
20
|
+
).map((point) => ({
|
|
21
|
+
x: Number(point.x || 0),
|
|
22
|
+
y: Number(point.y || 0)
|
|
23
|
+
}))
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Builds one closed shape from sampled outline points.
|
|
29
|
+
* @param {any} THREE
|
|
30
|
+
* @param {{ x: number, y: number }[]} points
|
|
31
|
+
* @returns {any}
|
|
32
|
+
*/
|
|
33
|
+
static buildShapeFromPoints(THREE, points) {
|
|
34
|
+
const shape = new THREE.Shape()
|
|
35
|
+
const dedupedPoints =
|
|
36
|
+
PcbScene3dBoardEdgeCutoutBuilder.#dedupeAdjacentPoints(points)
|
|
37
|
+
|
|
38
|
+
if (!dedupedPoints.length) {
|
|
39
|
+
return shape
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
shape.moveTo(dedupedPoints[0].x, dedupedPoints[0].y)
|
|
43
|
+
for (let index = 1; index < dedupedPoints.length; index += 1) {
|
|
44
|
+
shape.lineTo(dedupedPoints[index].x, dedupedPoints[index].y)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
shape.closePath()
|
|
48
|
+
return shape
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Builds uniformly sampled points for a circular drill cutout.
|
|
53
|
+
* @param {number} centerX Drill center X.
|
|
54
|
+
* @param {number} centerY Drill center Y.
|
|
55
|
+
* @param {number} radius Drill radius.
|
|
56
|
+
* @returns {{ x: number, y: number }[]}
|
|
57
|
+
*/
|
|
58
|
+
static buildCircularCutoutPoints(centerX, centerY, radius) {
|
|
59
|
+
return Array.from(
|
|
60
|
+
{
|
|
61
|
+
length: PcbScene3dBoardEdgeCutoutBuilder
|
|
62
|
+
.#EDGE_CUTOUT_SAMPLE_POINTS
|
|
63
|
+
},
|
|
64
|
+
(_, index) => {
|
|
65
|
+
const angle =
|
|
66
|
+
(Math.PI * 2 * index) /
|
|
67
|
+
PcbScene3dBoardEdgeCutoutBuilder.#EDGE_CUTOUT_SAMPLE_POINTS
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
x: centerX + Math.cos(angle) * radius,
|
|
71
|
+
y: centerY + Math.sin(angle) * radius
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Applies circular cutouts that cross the board outline as rounded notches.
|
|
79
|
+
* @param {{ x: number, y: number }[]} contourPoints
|
|
80
|
+
* @param {{ centerX: number, centerY: number, radius: number }[]} cutouts
|
|
81
|
+
* @returns {{ x: number, y: number }[]}
|
|
82
|
+
*/
|
|
83
|
+
static applyCircularEdgeCutouts(contourPoints, cutouts) {
|
|
84
|
+
return cutouts.reduce(
|
|
85
|
+
(points, cutout) =>
|
|
86
|
+
PcbScene3dBoardEdgeCutoutBuilder.#applyCircularEdgeCutout(
|
|
87
|
+
points,
|
|
88
|
+
cutout
|
|
89
|
+
),
|
|
90
|
+
contourPoints
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Returns true when a cutout can safely be added as a shape hole.
|
|
96
|
+
* @param {{ x: number, y: number }[]} hole
|
|
97
|
+
* @param {{ x: number, y: number }[]} contour
|
|
98
|
+
* @returns {boolean}
|
|
99
|
+
*/
|
|
100
|
+
static isHoleInsideContour(hole, contour) {
|
|
101
|
+
return (
|
|
102
|
+
Array.isArray(hole) &&
|
|
103
|
+
Array.isArray(contour) &&
|
|
104
|
+
hole.length >= 3 &&
|
|
105
|
+
contour.length >= 3 &&
|
|
106
|
+
hole.every((point) =>
|
|
107
|
+
PcbScene3dBoardEdgeCutoutBuilder.#isPointStrictlyInsidePolygon(
|
|
108
|
+
point,
|
|
109
|
+
contour
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Applies one circular cutout that intersects the board outline.
|
|
117
|
+
* @param {{ x: number, y: number }[]} contourPoints
|
|
118
|
+
* @param {{ centerX: number, centerY: number, radius: number }} cutout
|
|
119
|
+
* @returns {{ x: number, y: number }[]}
|
|
120
|
+
*/
|
|
121
|
+
static #applyCircularEdgeCutout(contourPoints, cutout) {
|
|
122
|
+
const contour =
|
|
123
|
+
PcbScene3dBoardEdgeCutoutBuilder.#dedupeAdjacentPoints(
|
|
124
|
+
contourPoints
|
|
125
|
+
)
|
|
126
|
+
const intersections =
|
|
127
|
+
PcbScene3dBoardEdgeCutoutBuilder.#resolveContourCircleIntersections(
|
|
128
|
+
contour,
|
|
129
|
+
cutout
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
if (intersections.length !== 2) {
|
|
133
|
+
return contour
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const [first, second] = intersections
|
|
137
|
+
const firstSpanInside =
|
|
138
|
+
PcbScene3dBoardEdgeCutoutBuilder.#isContourSpanInsideCircle(
|
|
139
|
+
contour,
|
|
140
|
+
first,
|
|
141
|
+
second,
|
|
142
|
+
cutout
|
|
143
|
+
)
|
|
144
|
+
const insideStart = firstSpanInside ? first : second
|
|
145
|
+
const insideEnd = firstSpanInside ? second : first
|
|
146
|
+
const outsidePath =
|
|
147
|
+
PcbScene3dBoardEdgeCutoutBuilder.#collectContourSpan(
|
|
148
|
+
contour,
|
|
149
|
+
insideEnd,
|
|
150
|
+
insideStart
|
|
151
|
+
)
|
|
152
|
+
const arcPoints =
|
|
153
|
+
PcbScene3dBoardEdgeCutoutBuilder.#buildInteriorArcPoints(
|
|
154
|
+
contour,
|
|
155
|
+
cutout,
|
|
156
|
+
insideStart.point,
|
|
157
|
+
insideEnd.point
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
return PcbScene3dBoardEdgeCutoutBuilder.#dedupeAdjacentPoints([
|
|
161
|
+
...outsidePath,
|
|
162
|
+
...arcPoints.slice(1)
|
|
163
|
+
])
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Resolves circle intersections along one contour.
|
|
168
|
+
* @param {{ x: number, y: number }[]} contour
|
|
169
|
+
* @param {{ centerX: number, centerY: number, radius: number }} cutout
|
|
170
|
+
* @returns {{ segmentIndex: number, t: number, point: { x: number, y: number } }[]}
|
|
171
|
+
*/
|
|
172
|
+
static #resolveContourCircleIntersections(contour, cutout) {
|
|
173
|
+
const intersections = []
|
|
174
|
+
|
|
175
|
+
for (let index = 0; index < contour.length; index += 1) {
|
|
176
|
+
const start = contour[index]
|
|
177
|
+
const end = contour[(index + 1) % contour.length]
|
|
178
|
+
PcbScene3dBoardEdgeCutoutBuilder.#resolveSegmentCircleIntersections(
|
|
179
|
+
start,
|
|
180
|
+
end,
|
|
181
|
+
cutout
|
|
182
|
+
).forEach((intersection) => {
|
|
183
|
+
intersections.push({
|
|
184
|
+
segmentIndex: index,
|
|
185
|
+
t: intersection.t,
|
|
186
|
+
point: intersection.point
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return PcbScene3dBoardEdgeCutoutBuilder.#dedupeIntersections(
|
|
192
|
+
intersections
|
|
193
|
+
)
|
|
194
|
+
.sort(
|
|
195
|
+
(first, second) =>
|
|
196
|
+
first.segmentIndex - second.segmentIndex ||
|
|
197
|
+
first.t - second.t
|
|
198
|
+
)
|
|
199
|
+
.slice(0, 2)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Resolves circle intersections on one segment.
|
|
204
|
+
* @param {{ x: number, y: number }} start
|
|
205
|
+
* @param {{ x: number, y: number }} end
|
|
206
|
+
* @param {{ centerX: number, centerY: number, radius: number }} cutout
|
|
207
|
+
* @returns {{ t: number, point: { x: number, y: number } }[]}
|
|
208
|
+
*/
|
|
209
|
+
static #resolveSegmentCircleIntersections(start, end, cutout) {
|
|
210
|
+
const dx = end.x - start.x
|
|
211
|
+
const dy = end.y - start.y
|
|
212
|
+
const fx = start.x - cutout.centerX
|
|
213
|
+
const fy = start.y - cutout.centerY
|
|
214
|
+
const a = dx * dx + dy * dy
|
|
215
|
+
const b = 2 * (fx * dx + fy * dy)
|
|
216
|
+
const c = fx * fx + fy * fy - cutout.radius * cutout.radius
|
|
217
|
+
const discriminant = b * b - 4 * a * c
|
|
218
|
+
|
|
219
|
+
if (
|
|
220
|
+
a <= PcbScene3dBoardEdgeCutoutBuilder.#GEOMETRY_EPSILON ||
|
|
221
|
+
discriminant < 0
|
|
222
|
+
) {
|
|
223
|
+
return []
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const root = Math.sqrt(discriminant)
|
|
227
|
+
return [(-b - root) / (2 * a), (-b + root) / (2 * a)]
|
|
228
|
+
.filter(
|
|
229
|
+
(t) =>
|
|
230
|
+
t >= -PcbScene3dBoardEdgeCutoutBuilder.#GEOMETRY_EPSILON &&
|
|
231
|
+
t <= 1 + PcbScene3dBoardEdgeCutoutBuilder.#GEOMETRY_EPSILON
|
|
232
|
+
)
|
|
233
|
+
.map((t) => ({
|
|
234
|
+
t: Math.max(0, Math.min(1, t)),
|
|
235
|
+
point: {
|
|
236
|
+
x: start.x + dx * t,
|
|
237
|
+
y: start.y + dy * t
|
|
238
|
+
}
|
|
239
|
+
}))
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Removes duplicated circle intersections.
|
|
244
|
+
* @param {{ segmentIndex: number, t: number, point: { x: number, y: number } }[]} intersections
|
|
245
|
+
* @returns {{ segmentIndex: number, t: number, point: { x: number, y: number } }[]}
|
|
246
|
+
*/
|
|
247
|
+
static #dedupeIntersections(intersections) {
|
|
248
|
+
return intersections.filter(
|
|
249
|
+
(intersection, index) =>
|
|
250
|
+
intersections.findIndex(
|
|
251
|
+
(candidate) =>
|
|
252
|
+
Math.hypot(
|
|
253
|
+
candidate.point.x - intersection.point.x,
|
|
254
|
+
candidate.point.y - intersection.point.y
|
|
255
|
+
) <= PcbScene3dBoardEdgeCutoutBuilder.#GEOMETRY_EPSILON
|
|
256
|
+
) === index
|
|
257
|
+
)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Checks whether a contour span lies inside one circular cutout.
|
|
262
|
+
* @param {{ x: number, y: number }[]} contour
|
|
263
|
+
* @param {{ segmentIndex: number, t: number, point: { x: number, y: number } }} start
|
|
264
|
+
* @param {{ segmentIndex: number, t: number, point: { x: number, y: number } }} end
|
|
265
|
+
* @param {{ centerX: number, centerY: number, radius: number }} cutout
|
|
266
|
+
* @returns {boolean}
|
|
267
|
+
*/
|
|
268
|
+
static #isContourSpanInsideCircle(contour, start, end, cutout) {
|
|
269
|
+
const span = PcbScene3dBoardEdgeCutoutBuilder.#collectContourSpan(
|
|
270
|
+
contour,
|
|
271
|
+
start,
|
|
272
|
+
end
|
|
273
|
+
)
|
|
274
|
+
const sampleStart = span[0]
|
|
275
|
+
const sampleEnd = span[1] || end.point
|
|
276
|
+
|
|
277
|
+
return PcbScene3dBoardEdgeCutoutBuilder.#isPointInsideCircle(
|
|
278
|
+
{
|
|
279
|
+
x: (sampleStart.x + sampleEnd.x) / 2,
|
|
280
|
+
y: (sampleStart.y + sampleEnd.y) / 2
|
|
281
|
+
},
|
|
282
|
+
cutout
|
|
283
|
+
)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Collects one directed contour span from start intersection to end.
|
|
288
|
+
* @param {{ x: number, y: number }[]} contour
|
|
289
|
+
* @param {{ segmentIndex: number, t: number, point: { x: number, y: number } }} start
|
|
290
|
+
* @param {{ segmentIndex: number, t: number, point: { x: number, y: number } }} end
|
|
291
|
+
* @returns {{ x: number, y: number }[]}
|
|
292
|
+
*/
|
|
293
|
+
static #collectContourSpan(contour, start, end) {
|
|
294
|
+
const points = [start.point]
|
|
295
|
+
const wraps =
|
|
296
|
+
start.segmentIndex > end.segmentIndex ||
|
|
297
|
+
(start.segmentIndex === end.segmentIndex && start.t > end.t)
|
|
298
|
+
const limit = wraps
|
|
299
|
+
? end.segmentIndex + contour.length
|
|
300
|
+
: end.segmentIndex
|
|
301
|
+
|
|
302
|
+
for (let index = start.segmentIndex + 1; index <= limit; index += 1) {
|
|
303
|
+
points.push(contour[index % contour.length])
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
points.push(end.point)
|
|
307
|
+
return PcbScene3dBoardEdgeCutoutBuilder.#dedupeAdjacentPoints(points)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Builds the circular arc that stays within the board contour.
|
|
312
|
+
* @param {{ x: number, y: number }[]} contour
|
|
313
|
+
* @param {{ centerX: number, centerY: number, radius: number }} cutout
|
|
314
|
+
* @param {{ x: number, y: number }} start
|
|
315
|
+
* @param {{ x: number, y: number }} end
|
|
316
|
+
* @returns {{ x: number, y: number }[]}
|
|
317
|
+
*/
|
|
318
|
+
static #buildInteriorArcPoints(contour, cutout, start, end) {
|
|
319
|
+
const startAngle = Math.atan2(
|
|
320
|
+
start.y - cutout.centerY,
|
|
321
|
+
start.x - cutout.centerX
|
|
322
|
+
)
|
|
323
|
+
const endAngle = Math.atan2(
|
|
324
|
+
end.y - cutout.centerY,
|
|
325
|
+
end.x - cutout.centerX
|
|
326
|
+
)
|
|
327
|
+
const ccwDelta =
|
|
328
|
+
PcbScene3dBoardEdgeCutoutBuilder.#normalizePositiveRadians(
|
|
329
|
+
endAngle - startAngle
|
|
330
|
+
)
|
|
331
|
+
const cwDelta = ccwDelta - Math.PI * 2
|
|
332
|
+
const selectedDelta =
|
|
333
|
+
PcbScene3dBoardEdgeCutoutBuilder.#isArcMidpointInsideContour(
|
|
334
|
+
contour,
|
|
335
|
+
cutout,
|
|
336
|
+
startAngle,
|
|
337
|
+
ccwDelta
|
|
338
|
+
)
|
|
339
|
+
? ccwDelta
|
|
340
|
+
: cwDelta
|
|
341
|
+
const segments = Math.max(
|
|
342
|
+
8,
|
|
343
|
+
Math.ceil(
|
|
344
|
+
(Math.abs(selectedDelta) / (Math.PI * 2)) *
|
|
345
|
+
PcbScene3dBoardEdgeCutoutBuilder.#EDGE_CUTOUT_SAMPLE_POINTS
|
|
346
|
+
)
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
return Array.from({ length: segments + 1 }, (_, index) => {
|
|
350
|
+
const angle = startAngle + (selectedDelta * index) / segments
|
|
351
|
+
|
|
352
|
+
return {
|
|
353
|
+
x: cutout.centerX + Math.cos(angle) * cutout.radius,
|
|
354
|
+
y: cutout.centerY + Math.sin(angle) * cutout.radius
|
|
355
|
+
}
|
|
356
|
+
})
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Checks whether one candidate arc midpoint is inside the board contour.
|
|
361
|
+
* @param {{ x: number, y: number }[]} contour
|
|
362
|
+
* @param {{ centerX: number, centerY: number, radius: number }} cutout
|
|
363
|
+
* @param {number} startAngle
|
|
364
|
+
* @param {number} delta
|
|
365
|
+
* @returns {boolean}
|
|
366
|
+
*/
|
|
367
|
+
static #isArcMidpointInsideContour(contour, cutout, startAngle, delta) {
|
|
368
|
+
const angle = startAngle + delta / 2
|
|
369
|
+
|
|
370
|
+
return PcbScene3dBoardEdgeCutoutBuilder.#isPointStrictlyInsidePolygon(
|
|
371
|
+
{
|
|
372
|
+
x: cutout.centerX + Math.cos(angle) * cutout.radius,
|
|
373
|
+
y: cutout.centerY + Math.sin(angle) * cutout.radius
|
|
374
|
+
},
|
|
375
|
+
contour
|
|
376
|
+
)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Normalizes one angle into `[0, 2π)`.
|
|
381
|
+
* @param {number} angle
|
|
382
|
+
* @returns {number}
|
|
383
|
+
*/
|
|
384
|
+
static #normalizePositiveRadians(angle) {
|
|
385
|
+
const fullCircle = Math.PI * 2
|
|
386
|
+
const normalized = angle % fullCircle
|
|
387
|
+
|
|
388
|
+
return normalized < 0 ? normalized + fullCircle : normalized
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Returns true when a point lies inside a circular cutout.
|
|
393
|
+
* @param {{ x: number, y: number }} point
|
|
394
|
+
* @param {{ centerX: number, centerY: number, radius: number }} cutout
|
|
395
|
+
* @returns {boolean}
|
|
396
|
+
*/
|
|
397
|
+
static #isPointInsideCircle(point, cutout) {
|
|
398
|
+
return (
|
|
399
|
+
Math.hypot(point.x - cutout.centerX, point.y - cutout.centerY) <
|
|
400
|
+
cutout.radius - PcbScene3dBoardEdgeCutoutBuilder.#GEOMETRY_EPSILON
|
|
401
|
+
)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Returns true when a point lies inside a polygon and away from its border.
|
|
406
|
+
* @param {{ x: number, y: number }} point
|
|
407
|
+
* @param {{ x: number, y: number }[]} polygon
|
|
408
|
+
* @returns {boolean}
|
|
409
|
+
*/
|
|
410
|
+
static #isPointStrictlyInsidePolygon(point, polygon) {
|
|
411
|
+
if (
|
|
412
|
+
PcbScene3dBoardEdgeCutoutBuilder.#isPointOnPolygonBoundary(
|
|
413
|
+
point,
|
|
414
|
+
polygon
|
|
415
|
+
)
|
|
416
|
+
) {
|
|
417
|
+
return false
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
let inside = false
|
|
421
|
+
for (
|
|
422
|
+
let index = 0, previousIndex = polygon.length - 1;
|
|
423
|
+
index < polygon.length;
|
|
424
|
+
previousIndex = index, index += 1
|
|
425
|
+
) {
|
|
426
|
+
const current = polygon[index]
|
|
427
|
+
const previous = polygon[previousIndex]
|
|
428
|
+
const intersects =
|
|
429
|
+
current.y > point.y !== previous.y > point.y &&
|
|
430
|
+
point.x <
|
|
431
|
+
((previous.x - current.x) * (point.y - current.y)) /
|
|
432
|
+
(previous.y - current.y) +
|
|
433
|
+
current.x
|
|
434
|
+
|
|
435
|
+
if (intersects) {
|
|
436
|
+
inside = !inside
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
return inside
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Returns true when a point lies on any polygon edge.
|
|
445
|
+
* @param {{ x: number, y: number }} point
|
|
446
|
+
* @param {{ x: number, y: number }[]} polygon
|
|
447
|
+
* @returns {boolean}
|
|
448
|
+
*/
|
|
449
|
+
static #isPointOnPolygonBoundary(point, polygon) {
|
|
450
|
+
return polygon.some((start, index) =>
|
|
451
|
+
PcbScene3dBoardEdgeCutoutBuilder.#isPointOnSegment(
|
|
452
|
+
point,
|
|
453
|
+
start,
|
|
454
|
+
polygon[(index + 1) % polygon.length]
|
|
455
|
+
)
|
|
456
|
+
)
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Returns true when a point lies on one line segment.
|
|
461
|
+
* @param {{ x: number, y: number }} point
|
|
462
|
+
* @param {{ x: number, y: number }} start
|
|
463
|
+
* @param {{ x: number, y: number }} end
|
|
464
|
+
* @returns {boolean}
|
|
465
|
+
*/
|
|
466
|
+
static #isPointOnSegment(point, start, end) {
|
|
467
|
+
const lengthSquared =
|
|
468
|
+
(end.x - start.x) * (end.x - start.x) +
|
|
469
|
+
(end.y - start.y) * (end.y - start.y)
|
|
470
|
+
|
|
471
|
+
if (
|
|
472
|
+
lengthSquared < PcbScene3dBoardEdgeCutoutBuilder.#GEOMETRY_EPSILON
|
|
473
|
+
) {
|
|
474
|
+
return (
|
|
475
|
+
Math.hypot(point.x - start.x, point.y - start.y) <
|
|
476
|
+
PcbScene3dBoardEdgeCutoutBuilder.#GEOMETRY_EPSILON
|
|
477
|
+
)
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const cross =
|
|
481
|
+
(point.y - start.y) * (end.x - start.x) -
|
|
482
|
+
(point.x - start.x) * (end.y - start.y)
|
|
483
|
+
if (
|
|
484
|
+
Math.abs(cross) > PcbScene3dBoardEdgeCutoutBuilder.#GEOMETRY_EPSILON
|
|
485
|
+
) {
|
|
486
|
+
return false
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const dot =
|
|
490
|
+
(point.x - start.x) * (end.x - start.x) +
|
|
491
|
+
(point.y - start.y) * (end.y - start.y)
|
|
492
|
+
if (dot < -PcbScene3dBoardEdgeCutoutBuilder.#GEOMETRY_EPSILON) {
|
|
493
|
+
return false
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
return (
|
|
497
|
+
dot <=
|
|
498
|
+
lengthSquared + PcbScene3dBoardEdgeCutoutBuilder.#GEOMETRY_EPSILON
|
|
499
|
+
)
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Removes duplicate adjacent points and an optional repeated closing point.
|
|
504
|
+
* @param {{ x: number, y: number }[]} points
|
|
505
|
+
* @returns {{ x: number, y: number }[]}
|
|
506
|
+
*/
|
|
507
|
+
static #dedupeAdjacentPoints(points) {
|
|
508
|
+
const deduped = []
|
|
509
|
+
|
|
510
|
+
for (const point of Array.isArray(points) ? points : []) {
|
|
511
|
+
const previous = deduped[deduped.length - 1]
|
|
512
|
+
if (
|
|
513
|
+
previous &&
|
|
514
|
+
Math.hypot(previous.x - point.x, previous.y - point.y) <=
|
|
515
|
+
PcbScene3dBoardEdgeCutoutBuilder.#GEOMETRY_EPSILON
|
|
516
|
+
) {
|
|
517
|
+
continue
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
deduped.push(point)
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const first = deduped[0]
|
|
524
|
+
const last = deduped[deduped.length - 1]
|
|
525
|
+
if (
|
|
526
|
+
first &&
|
|
527
|
+
last &&
|
|
528
|
+
deduped.length > 1 &&
|
|
529
|
+
Math.hypot(first.x - last.x, first.y - last.y) <=
|
|
530
|
+
PcbScene3dBoardEdgeCutoutBuilder.#GEOMETRY_EPSILON
|
|
531
|
+
) {
|
|
532
|
+
deduped.pop()
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
return deduped
|
|
536
|
+
}
|
|
537
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolves PCB substrate material colors for the 3D runtime.
|
|
3
|
+
*/
|
|
4
|
+
export class PcbScene3dBoardMaterialPalette {
|
|
5
|
+
static #DEFAULT_SURFACE_COLOR = 0x2a5f27
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Resolves the solder-mask face color for the generated board shell.
|
|
9
|
+
* @param {{ surfaceColor?: number } | undefined} board Board metadata.
|
|
10
|
+
* @param {{ hasBoardAssemblyModel?: boolean }} [options] Scene options.
|
|
11
|
+
* @returns {number}
|
|
12
|
+
*/
|
|
13
|
+
static resolveSurfaceColor(board, options = {}) {
|
|
14
|
+
if (options.hasBoardAssemblyModel) {
|
|
15
|
+
return PcbScene3dBoardMaterialPalette.#DEFAULT_SURFACE_COLOR
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return Number.isInteger(board?.surfaceColor)
|
|
19
|
+
? board.surfaceColor
|
|
20
|
+
: PcbScene3dBoardMaterialPalette.#DEFAULT_SURFACE_COLOR
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Resolves whether the generated board face should render.
|
|
25
|
+
* @param {{ hasBoardAssemblyModel?: boolean }} [options] Scene options.
|
|
26
|
+
* @returns {boolean}
|
|
27
|
+
*/
|
|
28
|
+
static isGeneratedSurfaceVisible(options = {}) {
|
|
29
|
+
return PcbScene3dBoardMaterialPalette.isGeneratedBodyVisible(options)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolves whether the generated board body should render.
|
|
34
|
+
* @param {{ hasBoardAssemblyModel?: boolean }} [options] Scene options.
|
|
35
|
+
* @returns {boolean}
|
|
36
|
+
*/
|
|
37
|
+
static isGeneratedBodyVisible(options = {}) {
|
|
38
|
+
return true
|
|
39
|
+
}
|
|
40
|
+
}
|