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,854 @@
1
+ import { PcbScene3dCopperTextFactory } from './PcbScene3dCopperTextFactory.mjs'
2
+ import { PcbScene3dCutoutGeometryFilter } from './PcbScene3dCutoutGeometryFilter.mjs'
3
+ import { PcbScene3dDrillCutoutFilter } from './PcbScene3dDrillCutoutFilter.mjs'
4
+ import { PcbScene3dShapePathFactory } from './PcbScene3dShapePathFactory.mjs'
5
+ import { PcbScene3dSilkscreenStrokeWidthResolver } from './PcbScene3dSilkscreenStrokeWidthResolver.mjs'
6
+ import { PcbScene3dStrokeGeometryBuilder } from './PcbScene3dStrokeGeometryBuilder.mjs'
7
+ import { PcbScene3dTrueTypeTextFactory } from './PcbScene3dTrueTypeTextFactory.mjs'
8
+
9
+ /**
10
+ * Builds documentation-layer silkscreen meshes for the 3D PCB view.
11
+ */
12
+ export class PcbScene3dSilkscreenFactory {
13
+ static #DEFAULT_SILKSCREEN_COLOR = 0xf8f6ef
14
+ static #FILL_THICKNESS_MIL = 0.8
15
+ static #GEOMETRY_EPSILON = 0.001
16
+ static #FULL_CIRCLE_EPSILON = 0.001
17
+ static #MIN_STROKE_WIDTH_MIL = 0.04
18
+ static #STROKE_Z_OFFSET = 0.04
19
+
20
+ /**
21
+ * Builds the combined top and bottom silkscreen group.
22
+ * @param {any} THREE
23
+ * @param {{ top?: { fills?: any[], tracks?: any[], arcs?: any[], texts?: any[], drillCutouts?: { x: number, y: number }[][], fillColor?: number, strokeColor?: number, knockoutColor?: number, nativeTextKnockouts?: boolean }, bottom?: { fills?: any[], tracks?: any[], arcs?: any[], texts?: any[], drillCutouts?: { x: number, y: number }[][], fillColor?: number, strokeColor?: number, knockoutColor?: number, nativeTextKnockouts?: boolean } }} silkscreen
24
+ * @param {number} topZ
25
+ * @param {number} bottomZ
26
+ * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint
27
+ * @returns {any}
28
+ */
29
+ static buildGroup(THREE, silkscreen, topZ, bottomZ, normalizeBoardPoint) {
30
+ const group = new THREE.Group()
31
+ const topGroup = PcbScene3dSilkscreenFactory.#buildSideGroup(
32
+ THREE,
33
+ silkscreen?.top,
34
+ Math.abs(Number(topZ || 0)),
35
+ normalizeBoardPoint,
36
+ false
37
+ )
38
+ const bottomGroup = PcbScene3dSilkscreenFactory.#buildSideGroup(
39
+ THREE,
40
+ silkscreen?.bottom,
41
+ Math.abs(Number(bottomZ || 0)),
42
+ normalizeBoardPoint,
43
+ true
44
+ )
45
+
46
+ if (topGroup.children.length) {
47
+ group.add(topGroup)
48
+ }
49
+ if (bottomGroup.children.length) {
50
+ group.add(bottomGroup)
51
+ }
52
+
53
+ return group
54
+ }
55
+
56
+ /**
57
+ * Builds one side-specific silkscreen group.
58
+ * @param {any} THREE
59
+ * @param {{ fills?: any[], tracks?: any[], arcs?: any[], texts?: any[], drillCutouts?: { x: number, y: number }[][], fillColor?: number, strokeColor?: number, knockoutColor?: number, nativeTextKnockouts?: boolean } | undefined} silkscreen
60
+ * @param {number} z
61
+ * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint
62
+ * @param {boolean} mirrorY
63
+ * @returns {any}
64
+ */
65
+ static #buildSideGroup(THREE, silkscreen, z, normalizeBoardPoint, mirrorY) {
66
+ const group = new THREE.Group()
67
+ const strokeColor = PcbScene3dSilkscreenFactory.#resolveMaterialColor(
68
+ silkscreen?.strokeColor
69
+ )
70
+ const fillColor = PcbScene3dSilkscreenFactory.#resolveMaterialColor(
71
+ silkscreen?.fillColor
72
+ )
73
+ const hasExplicitFillColor = Number.isInteger(silkscreen?.fillColor)
74
+ const textMaterialColor = strokeColor
75
+ const invertedTextMaterialColor = Number.isInteger(
76
+ silkscreen?.knockoutColor
77
+ )
78
+ ? PcbScene3dSilkscreenFactory.#resolveMaterialColor(
79
+ silkscreen.knockoutColor
80
+ )
81
+ : hasExplicitFillColor
82
+ ? fillColor
83
+ : textMaterialColor
84
+ const strokeZ = z + PcbScene3dSilkscreenFactory.#STROKE_Z_OFFSET
85
+ const drillCutouts = PcbScene3dSilkscreenFactory.#normalizeDrillCutouts(
86
+ silkscreen?.drillCutouts,
87
+ normalizeBoardPoint,
88
+ mirrorY
89
+ )
90
+ const strokeMaterial = PcbScene3dSilkscreenFactory.#buildMaterial(
91
+ THREE,
92
+ strokeColor
93
+ )
94
+ const fillMaterial = PcbScene3dSilkscreenFactory.#buildMaterial(
95
+ THREE,
96
+ fillColor
97
+ )
98
+ const trackMesh = PcbScene3dSilkscreenFactory.#buildTrackMesh(
99
+ THREE,
100
+ silkscreen?.tracks || [],
101
+ strokeZ,
102
+ normalizeBoardPoint,
103
+ mirrorY,
104
+ strokeMaterial,
105
+ drillCutouts
106
+ )
107
+ const arcMesh = PcbScene3dSilkscreenFactory.#buildArcMesh(
108
+ THREE,
109
+ silkscreen?.arcs || [],
110
+ strokeZ,
111
+ normalizeBoardPoint,
112
+ mirrorY,
113
+ strokeMaterial,
114
+ drillCutouts
115
+ )
116
+ const fillMeshes = PcbScene3dSilkscreenFactory.#buildFillMeshes(
117
+ THREE,
118
+ silkscreen?.fills || [],
119
+ z,
120
+ normalizeBoardPoint,
121
+ mirrorY,
122
+ fillMaterial,
123
+ drillCutouts
124
+ )
125
+ const texts = Array.isArray(silkscreen?.texts) ? silkscreen.texts : []
126
+ const renderableTexts = texts.filter(
127
+ (text) =>
128
+ !PcbScene3dSilkscreenFactory.#shouldSkipSourceText(
129
+ text,
130
+ silkscreen
131
+ )
132
+ )
133
+ const textGroup = PcbScene3dCopperTextFactory.buildGroup(
134
+ THREE,
135
+ renderableTexts.filter(
136
+ (text) => !PcbScene3dTrueTypeTextFactory.isTrueTypeText(text)
137
+ ),
138
+ strokeZ,
139
+ normalizeBoardPoint,
140
+ {
141
+ drillCutouts,
142
+ filterSide: false,
143
+ materialColor: strokeColor,
144
+ mirrorY,
145
+ side: mirrorY ? 'bottom' : 'top'
146
+ }
147
+ )
148
+ const trueTypeTextGroup = PcbScene3dTrueTypeTextFactory.buildGroup(
149
+ THREE,
150
+ renderableTexts,
151
+ strokeZ,
152
+ normalizeBoardPoint,
153
+ {
154
+ invertedMaterialColor: invertedTextMaterialColor,
155
+ materialColor: textMaterialColor,
156
+ mirrorY
157
+ }
158
+ )
159
+
160
+ if (trackMesh) {
161
+ group.add(trackMesh)
162
+ }
163
+ if (arcMesh) {
164
+ group.add(arcMesh)
165
+ }
166
+ if (fillMeshes.length) {
167
+ group.add(...fillMeshes)
168
+ }
169
+ if (textGroup.children.length) {
170
+ group.add(textGroup)
171
+ }
172
+ if (trueTypeTextGroup.children.length) {
173
+ group.add(trueTypeTextGroup)
174
+ }
175
+ if (mirrorY && group.children.length) {
176
+ group.rotation.x = Math.PI
177
+ }
178
+
179
+ return group
180
+ }
181
+
182
+ /**
183
+ * Skips source text when recovered Altium fills already contain
184
+ * the corresponding inverted-text holes.
185
+ * @param {object} text
186
+ * @param {{ nativeTextKnockouts?: boolean } | undefined} silkscreen
187
+ * @returns {boolean}
188
+ */
189
+ static #shouldSkipSourceText(text, silkscreen) {
190
+ return (
191
+ Boolean(silkscreen?.nativeTextKnockouts) &&
192
+ Boolean(text?.isInverted)
193
+ )
194
+ }
195
+
196
+ /**
197
+ * Builds one filled mesh for all stroke-style silkscreen tracks.
198
+ * @param {any} THREE
199
+ * @param {{ x1: number, y1: number, x2: number, y2: number, width?: number }[]} tracks
200
+ * @param {number} z
201
+ * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint
202
+ * @param {boolean} mirrorY
203
+ * @param {any} material
204
+ * @param {{ x: number, y: number }[][]} drillCutouts
205
+ * @returns {any | null}
206
+ */
207
+ static #buildTrackMesh(
208
+ THREE,
209
+ tracks,
210
+ z,
211
+ normalizeBoardPoint,
212
+ mirrorY,
213
+ material,
214
+ drillCutouts
215
+ ) {
216
+ const positions = []
217
+ const denseHairline =
218
+ PcbScene3dSilkscreenStrokeWidthResolver.resolveDenseHairline(tracks)
219
+
220
+ for (const track of tracks) {
221
+ const start = PcbScene3dSilkscreenFactory.#normalizePoint(
222
+ normalizeBoardPoint,
223
+ Number(track.x1 || 0),
224
+ Number(track.y1 || 0),
225
+ mirrorY
226
+ )
227
+ const end = PcbScene3dSilkscreenFactory.#normalizePoint(
228
+ normalizeBoardPoint,
229
+ Number(track.x2 || 0),
230
+ Number(track.y2 || 0),
231
+ mirrorY
232
+ )
233
+ PcbScene3dStrokeGeometryBuilder.appendTrack(
234
+ positions,
235
+ start,
236
+ end,
237
+ PcbScene3dSilkscreenStrokeWidthResolver.resolveTrackWidth(
238
+ Number(track.width || 0),
239
+ denseHairline
240
+ ),
241
+ z,
242
+ {
243
+ minWidth: PcbScene3dSilkscreenFactory.#MIN_STROKE_WIDTH_MIL
244
+ }
245
+ )
246
+ }
247
+
248
+ return PcbScene3dSilkscreenFactory.#buildStrokeMesh(
249
+ THREE,
250
+ positions,
251
+ material,
252
+ drillCutouts
253
+ )
254
+ }
255
+
256
+ /**
257
+ * Builds one filled mesh for all stroke-style silkscreen arcs.
258
+ * @param {any} THREE
259
+ * @param {{ x: number, y: number, radius: number, startAngle: number, endAngle: number, width?: number }[]} arcs
260
+ * @param {number} z
261
+ * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint
262
+ * @param {boolean} mirrorY
263
+ * @param {any} material
264
+ * @param {{ x: number, y: number }[][]} drillCutouts
265
+ * @returns {any | null}
266
+ */
267
+ static #buildArcMesh(
268
+ THREE,
269
+ arcs,
270
+ z,
271
+ normalizeBoardPoint,
272
+ mirrorY,
273
+ material,
274
+ drillCutouts
275
+ ) {
276
+ const positions = []
277
+
278
+ for (const arc of arcs) {
279
+ const center = PcbScene3dSilkscreenFactory.#normalizePoint(
280
+ normalizeBoardPoint,
281
+ Number(arc.x || 0),
282
+ Number(arc.y || 0),
283
+ mirrorY
284
+ )
285
+ PcbScene3dStrokeGeometryBuilder.appendArc(
286
+ positions,
287
+ center,
288
+ arc,
289
+ z,
290
+ mirrorY,
291
+ {
292
+ fullCircleEpsilon:
293
+ PcbScene3dSilkscreenFactory.#FULL_CIRCLE_EPSILON,
294
+ minWidth: PcbScene3dSilkscreenFactory.#MIN_STROKE_WIDTH_MIL
295
+ }
296
+ )
297
+ }
298
+
299
+ return PcbScene3dSilkscreenFactory.#buildStrokeMesh(
300
+ THREE,
301
+ positions,
302
+ material,
303
+ drillCutouts
304
+ )
305
+ }
306
+
307
+ /**
308
+ * Builds thin fill meshes for silkscreen solids.
309
+ * @param {any} THREE
310
+ * @param {{ x1?: number, y1?: number, x2?: number, y2?: number, points?: { x: number, y: number }[], holes?: { x: number, y: number }[][] }[]} fills
311
+ * @param {number} z
312
+ * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint
313
+ * @param {boolean} mirrorY
314
+ * @param {any} material
315
+ * @param {{ x: number, y: number }[][]} drillCutouts
316
+ * @returns {any[]}
317
+ */
318
+ static #buildFillMeshes(
319
+ THREE,
320
+ fills,
321
+ z,
322
+ normalizeBoardPoint,
323
+ mirrorY,
324
+ material,
325
+ drillCutouts
326
+ ) {
327
+ return fills.map((fill) => {
328
+ const points = PcbScene3dSilkscreenFactory.#normalizeFillPoints(
329
+ fill,
330
+ normalizeBoardPoint,
331
+ mirrorY,
332
+ Boolean(drillCutouts.length)
333
+ )
334
+ const fillHoles = PcbScene3dSilkscreenFactory.#normalizeFillHoles(
335
+ fill,
336
+ normalizeBoardPoint,
337
+ mirrorY
338
+ )
339
+
340
+ if (
341
+ points.length >= 3 &&
342
+ THREE.Shape &&
343
+ THREE.Path &&
344
+ THREE.ShapeGeometry
345
+ ) {
346
+ return PcbScene3dSilkscreenFactory.#buildShapeFillMesh(
347
+ THREE,
348
+ points,
349
+ fillHoles,
350
+ drillCutouts,
351
+ z,
352
+ material
353
+ )
354
+ }
355
+
356
+ return PcbScene3dSilkscreenFactory.#buildBoxFillMesh(
357
+ THREE,
358
+ fill,
359
+ z,
360
+ normalizeBoardPoint,
361
+ mirrorY,
362
+ material
363
+ )
364
+ })
365
+ }
366
+
367
+ /**
368
+ * Builds one polygon fill mesh from authored silkscreen points.
369
+ * @param {any} THREE
370
+ * @param {{ x: number, y: number }[]} points Normalized polygon points.
371
+ * @param {{ x: number, y: number }[][]} fillHoles Normalized authored polygon holes.
372
+ * @param {{ x: number, y: number }[][]} drillCutouts Normalized drill cutouts.
373
+ * @param {number} z
374
+ * @param {any} material
375
+ * @returns {any}
376
+ */
377
+ static #buildShapeFillMesh(
378
+ THREE,
379
+ points,
380
+ fillHoles,
381
+ drillCutouts,
382
+ z,
383
+ material
384
+ ) {
385
+ const shape = PcbScene3dShapePathFactory.buildShape(THREE, points)
386
+ const { authoredHoles, drillHoles, uncoveredCutouts } =
387
+ PcbScene3dDrillCutoutFilter.partitionFillHoles(
388
+ drillCutouts,
389
+ fillHoles
390
+ )
391
+ const { shapeHoles, clippingHoles } =
392
+ PcbScene3dSilkscreenFactory.#partitionDrillCutouts(
393
+ uncoveredCutouts,
394
+ points
395
+ )
396
+ PcbScene3dSilkscreenFactory.#appendShapeHoles(
397
+ THREE,
398
+ shape,
399
+ authoredHoles,
400
+ points
401
+ )
402
+ PcbScene3dSilkscreenFactory.#appendShapeHoles(
403
+ THREE,
404
+ shape,
405
+ shapeHoles,
406
+ points
407
+ )
408
+
409
+ const geometry = PcbScene3dCutoutGeometryFilter.filter(
410
+ THREE,
411
+ new THREE.ShapeGeometry(shape),
412
+ authoredHoles.concat(shapeHoles, drillHoles, clippingHoles),
413
+ { maxDepth: 12, maxEdgeLength: 2 }
414
+ )
415
+ const mesh = new THREE.Mesh(geometry, material)
416
+ mesh.position.set(0, 0, z)
417
+ return mesh
418
+ }
419
+
420
+ /**
421
+ * Splits drill cutouts into safe shape holes and fallback clip polygons.
422
+ * @param {{ x: number, y: number }[][]} drillCutouts
423
+ * @param {{ x: number, y: number }[]} contourPoints
424
+ * @returns {{ shapeHoles: { x: number, y: number }[][], clippingHoles: { x: number, y: number }[][] }}
425
+ */
426
+ static #partitionDrillCutouts(drillCutouts, contourPoints) {
427
+ const shapeHoles = []
428
+ const clippingHoles = []
429
+
430
+ for (const cutout of Array.isArray(drillCutouts) ? drillCutouts : []) {
431
+ if (
432
+ PcbScene3dSilkscreenFactory.#isHoleInsideContour(
433
+ cutout,
434
+ contourPoints
435
+ )
436
+ ) {
437
+ shapeHoles.push(cutout)
438
+ continue
439
+ }
440
+
441
+ clippingHoles.push(cutout)
442
+ }
443
+
444
+ return { shapeHoles, clippingHoles }
445
+ }
446
+
447
+ /**
448
+ * Appends normalized cutout paths to one shape fill.
449
+ * @param {any} THREE
450
+ * @param {{ holes: any[] }} shape
451
+ * @param {{ x: number, y: number }[][]} holes
452
+ * @param {{ x: number, y: number }[]} contourPoints
453
+ * @returns {void}
454
+ */
455
+ static #appendShapeHoles(THREE, shape, holes, contourPoints) {
456
+ if (!Array.isArray(holes) || !Array.isArray(shape.holes)) {
457
+ return
458
+ }
459
+
460
+ for (const points of holes) {
461
+ if (
462
+ !PcbScene3dSilkscreenFactory.#isHoleInsideContour(
463
+ points,
464
+ contourPoints
465
+ )
466
+ ) {
467
+ continue
468
+ }
469
+
470
+ shape.holes.push(
471
+ PcbScene3dShapePathFactory.buildPath(THREE, points)
472
+ )
473
+ }
474
+ }
475
+
476
+ /**
477
+ * Returns true when a cutout can safely be added as a shape hole.
478
+ * @param {{ x: number, y: number }[]} hole
479
+ * @param {{ x: number, y: number }[]} contour
480
+ * @returns {boolean}
481
+ */
482
+ static #isHoleInsideContour(hole, contour) {
483
+ return (
484
+ Array.isArray(hole) &&
485
+ Array.isArray(contour) &&
486
+ hole.length >= 3 &&
487
+ contour.length >= 3 &&
488
+ hole.every((point) =>
489
+ PcbScene3dSilkscreenFactory.#isPointStrictlyInsidePolygon(
490
+ point,
491
+ contour
492
+ )
493
+ )
494
+ )
495
+ }
496
+
497
+ /**
498
+ * Returns true when a point lies inside a polygon and away from its border.
499
+ * @param {{ x: number, y: number }} point
500
+ * @param {{ x: number, y: number }[]} polygon
501
+ * @returns {boolean}
502
+ */
503
+ static #isPointStrictlyInsidePolygon(point, polygon) {
504
+ if (
505
+ PcbScene3dSilkscreenFactory.#isPointOnPolygonBoundary(
506
+ point,
507
+ polygon
508
+ )
509
+ ) {
510
+ return false
511
+ }
512
+
513
+ let inside = false
514
+
515
+ for (
516
+ let index = 0, previousIndex = polygon.length - 1;
517
+ index < polygon.length;
518
+ previousIndex = index, index += 1
519
+ ) {
520
+ const current = polygon[index]
521
+ const previous = polygon[previousIndex]
522
+ const intersects =
523
+ current.y > point.y !== previous.y > point.y &&
524
+ point.x <
525
+ ((previous.x - current.x) * (point.y - current.y)) /
526
+ (previous.y - current.y) +
527
+ current.x
528
+
529
+ if (intersects) {
530
+ inside = !inside
531
+ }
532
+ }
533
+
534
+ return inside
535
+ }
536
+
537
+ /**
538
+ * Returns true when a point lies on a polygon edge.
539
+ * @param {{ x: number, y: number }} point
540
+ * @param {{ x: number, y: number }[]} polygon
541
+ * @returns {boolean}
542
+ */
543
+ static #isPointOnPolygonBoundary(point, polygon) {
544
+ return polygon.some((start, index) =>
545
+ PcbScene3dSilkscreenFactory.#isPointOnSegment(
546
+ point,
547
+ start,
548
+ polygon[(index + 1) % polygon.length]
549
+ )
550
+ )
551
+ }
552
+
553
+ /**
554
+ * Returns true when a point lies on a segment within geometry tolerance.
555
+ * @param {{ x: number, y: number }} point
556
+ * @param {{ x: number, y: number }} start
557
+ * @param {{ x: number, y: number }} end
558
+ * @returns {boolean}
559
+ */
560
+ static #isPointOnSegment(point, start, end) {
561
+ const cross =
562
+ (point.y - start.y) * (end.x - start.x) -
563
+ (point.x - start.x) * (end.y - start.y)
564
+
565
+ if (Math.abs(cross) > PcbScene3dSilkscreenFactory.#GEOMETRY_EPSILON) {
566
+ return false
567
+ }
568
+
569
+ const dot =
570
+ (point.x - start.x) * (end.x - start.x) +
571
+ (point.y - start.y) * (end.y - start.y)
572
+
573
+ if (dot < -PcbScene3dSilkscreenFactory.#GEOMETRY_EPSILON) {
574
+ return false
575
+ }
576
+
577
+ const lengthSquared = (end.x - start.x) ** 2 + (end.y - start.y) ** 2
578
+
579
+ return (
580
+ dot <= lengthSquared + PcbScene3dSilkscreenFactory.#GEOMETRY_EPSILON
581
+ )
582
+ }
583
+
584
+ /**
585
+ * Builds one rectangular fallback fill mesh.
586
+ * @param {any} THREE
587
+ * @param {{ x1?: number, y1?: number, x2?: number, y2?: number }} fill
588
+ * @param {number} z
589
+ * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint
590
+ * @param {boolean} mirrorY
591
+ * @param {any} material
592
+ * @returns {any}
593
+ */
594
+ static #buildBoxFillMesh(
595
+ THREE,
596
+ fill,
597
+ z,
598
+ normalizeBoardPoint,
599
+ mirrorY,
600
+ material
601
+ ) {
602
+ const center = PcbScene3dSilkscreenFactory.#normalizePoint(
603
+ normalizeBoardPoint,
604
+ (Number(fill.x1 || 0) + Number(fill.x2 || 0)) / 2,
605
+ (Number(fill.y1 || 0) + Number(fill.y2 || 0)) / 2,
606
+ mirrorY
607
+ )
608
+ const mesh = new THREE.Mesh(
609
+ new THREE.BoxGeometry(
610
+ Math.max(
611
+ Math.abs(Number(fill.x2 || 0) - Number(fill.x1 || 0)),
612
+ 1
613
+ ),
614
+ Math.max(
615
+ Math.abs(Number(fill.y2 || 0) - Number(fill.y1 || 0)),
616
+ 1
617
+ ),
618
+ PcbScene3dSilkscreenFactory.#FILL_THICKNESS_MIL
619
+ ),
620
+ material
621
+ )
622
+ mesh.position.set(center.x, center.y, z)
623
+ return mesh
624
+ }
625
+
626
+ /**
627
+ * Normalizes authored polygon fill points.
628
+ * @param {{ points?: { x: number, y: number }[], holes?: { x: number, y: number }[][] }} fill
629
+ * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint
630
+ * @param {boolean} mirrorY
631
+ * @param {boolean} forcePolygon
632
+ * @returns {{ x: number, y: number }[]}
633
+ */
634
+ static #normalizeFillPoints(
635
+ fill,
636
+ normalizeBoardPoint,
637
+ mirrorY,
638
+ forcePolygon
639
+ ) {
640
+ if (!Array.isArray(fill?.points)) {
641
+ return (Array.isArray(fill?.holes) && fill.holes.length) ||
642
+ forcePolygon
643
+ ? PcbScene3dSilkscreenFactory.#normalizeRectangleFillPoints(
644
+ fill,
645
+ normalizeBoardPoint,
646
+ mirrorY
647
+ )
648
+ : []
649
+ }
650
+
651
+ return PcbScene3dShapePathFactory.normalizeShapePoints(
652
+ fill.points,
653
+ normalizeBoardPoint,
654
+ mirrorY
655
+ )
656
+ }
657
+
658
+ /**
659
+ * Normalizes one rectangular fill into polygon points when it needs holes.
660
+ * @param {{ x1?: number, y1?: number, x2?: number, y2?: number }} fill
661
+ * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint
662
+ * @param {boolean} mirrorY
663
+ * @returns {{ x: number, y: number }[]}
664
+ */
665
+ static #normalizeRectangleFillPoints(fill, normalizeBoardPoint, mirrorY) {
666
+ const x1 = Number(fill?.x1)
667
+ const y1 = Number(fill?.y1)
668
+ const x2 = Number(fill?.x2)
669
+ const y2 = Number(fill?.y2)
670
+
671
+ if (
672
+ !Number.isFinite(x1) ||
673
+ !Number.isFinite(y1) ||
674
+ !Number.isFinite(x2) ||
675
+ !Number.isFinite(y2)
676
+ ) {
677
+ return []
678
+ }
679
+
680
+ return [
681
+ PcbScene3dSilkscreenFactory.#normalizePoint(
682
+ normalizeBoardPoint,
683
+ x1,
684
+ y1,
685
+ mirrorY
686
+ ),
687
+ PcbScene3dSilkscreenFactory.#normalizePoint(
688
+ normalizeBoardPoint,
689
+ x2,
690
+ y1,
691
+ mirrorY
692
+ ),
693
+ PcbScene3dSilkscreenFactory.#normalizePoint(
694
+ normalizeBoardPoint,
695
+ x2,
696
+ y2,
697
+ mirrorY
698
+ ),
699
+ PcbScene3dSilkscreenFactory.#normalizePoint(
700
+ normalizeBoardPoint,
701
+ x1,
702
+ y2,
703
+ mirrorY
704
+ )
705
+ ]
706
+ }
707
+
708
+ /**
709
+ * Normalizes authored polygon fill holes.
710
+ * @param {{ holes?: { x: number, y: number }[][] }} fill
711
+ * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint
712
+ * @param {boolean} mirrorY
713
+ * @returns {{ x: number, y: number }[][]}
714
+ */
715
+ static #normalizeFillHoles(fill, normalizeBoardPoint, mirrorY) {
716
+ if (!Array.isArray(fill?.holes)) {
717
+ return []
718
+ }
719
+
720
+ return fill.holes
721
+ .map((hole) =>
722
+ PcbScene3dSilkscreenFactory.#normalizePointList(
723
+ hole,
724
+ normalizeBoardPoint,
725
+ mirrorY
726
+ )
727
+ )
728
+ .filter((hole) => hole.length >= 3)
729
+ }
730
+
731
+ /**
732
+ * Normalizes side-level drill cutouts into local silkscreen polygons.
733
+ * @param {{ x?: number, y?: number }[][] | undefined} drillCutouts
734
+ * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint
735
+ * @param {boolean} mirrorY
736
+ * @returns {{ x: number, y: number }[][]}
737
+ */
738
+ static #normalizeDrillCutouts(drillCutouts, normalizeBoardPoint, mirrorY) {
739
+ if (!Array.isArray(drillCutouts)) {
740
+ return []
741
+ }
742
+
743
+ return drillCutouts
744
+ .map((cutout) =>
745
+ PcbScene3dSilkscreenFactory.#normalizePointList(
746
+ cutout,
747
+ normalizeBoardPoint,
748
+ mirrorY
749
+ )
750
+ )
751
+ .filter((cutout) => cutout.length >= 3)
752
+ }
753
+
754
+ /**
755
+ * Normalizes one authored point list.
756
+ * @param {{ x?: number, y?: number }[]} points
757
+ * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint
758
+ * @param {boolean} mirrorY
759
+ * @returns {{ x: number, y: number }[]}
760
+ */
761
+ static #normalizePointList(points, normalizeBoardPoint, mirrorY) {
762
+ return (Array.isArray(points) ? points : [])
763
+ .map((point) =>
764
+ PcbScene3dSilkscreenFactory.#normalizePoint(
765
+ normalizeBoardPoint,
766
+ Number(point?.x || 0),
767
+ Number(point?.y || 0),
768
+ mirrorY
769
+ )
770
+ )
771
+ .filter(
772
+ (point) => Number.isFinite(point.x) && Number.isFinite(point.y)
773
+ )
774
+ }
775
+
776
+ /**
777
+ * Builds one configured stroke mesh from triangle positions.
778
+ * @param {any} THREE
779
+ * @param {number[]} positions
780
+ * @param {any} material
781
+ * @param {{ x: number, y: number }[][]} [drillCutouts]
782
+ * @returns {any | null}
783
+ */
784
+ static #buildStrokeMesh(THREE, positions, material, drillCutouts = []) {
785
+ if (!positions.length) {
786
+ return null
787
+ }
788
+
789
+ const geometry = new THREE.BufferGeometry()
790
+ geometry.setAttribute(
791
+ 'position',
792
+ new THREE.Float32BufferAttribute(positions, 3)
793
+ )
794
+
795
+ return new THREE.Mesh(
796
+ PcbScene3dCutoutGeometryFilter.filter(
797
+ THREE,
798
+ geometry,
799
+ drillCutouts,
800
+ { maxDepth: 12, maxEdgeLength: 2 }
801
+ ),
802
+ material
803
+ )
804
+ }
805
+
806
+ /**
807
+ * Builds one shared silkscreen material.
808
+ * @param {any} THREE
809
+ * @param {number} [color]
810
+ * @returns {any}
811
+ */
812
+ static #buildMaterial(
813
+ THREE,
814
+ color = PcbScene3dSilkscreenFactory.#DEFAULT_SILKSCREEN_COLOR
815
+ ) {
816
+ return new THREE.MeshBasicMaterial({
817
+ color,
818
+ transparent: false,
819
+ opacity: 1,
820
+ toneMapped: false,
821
+ fog: false,
822
+ side: THREE.DoubleSide
823
+ })
824
+ }
825
+
826
+ /**
827
+ * Resolves a safe RGB material color.
828
+ * @param {unknown} color
829
+ * @returns {number}
830
+ */
831
+ static #resolveMaterialColor(color) {
832
+ const numericColor = Number(color)
833
+
834
+ return Number.isInteger(numericColor) &&
835
+ numericColor >= 0 &&
836
+ numericColor <= 0xffffff
837
+ ? numericColor
838
+ : PcbScene3dSilkscreenFactory.#DEFAULT_SILKSCREEN_COLOR
839
+ }
840
+
841
+ /**
842
+ * Normalizes one board point and optionally mirrors underside primitives.
843
+ * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint
844
+ * @param {number} x
845
+ * @param {number} y
846
+ * @param {boolean} mirrorY
847
+ * @returns {{ x: number, y: number }}
848
+ */
849
+ static #normalizePoint(normalizeBoardPoint, x, y, mirrorY) {
850
+ const point = normalizeBoardPoint(x, y)
851
+
852
+ return { x: point.x, y: mirrorY ? -point.y : point.y }
853
+ }
854
+ }