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,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
+ }