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,613 @@
1
+ import { PcbScene3dBoardMaterialPalette } from './PcbScene3dBoardMaterialPalette.mjs'
2
+ import { PcbScene3dCutoutGeometryFilter } from './PcbScene3dCutoutGeometryFilter.mjs'
3
+ import { PcbScene3dDrillPathFactory } from './PcbScene3dDrillPathFactory.mjs'
4
+ import { PcbScene3dOutlineBuilder } from './PcbScene3dOutlineBuilder.mjs'
5
+
6
+ /**
7
+ * Builds separate solder-mask face sheets for board-assembly rendering.
8
+ */
9
+ export class PcbScene3dBoardSolderMaskFactory {
10
+ static #CURVE_SEGMENTS = 20
11
+ static #OUTER_SAMPLE_POINTS = 160
12
+ static #DRILL_SAMPLE_POINTS = 72
13
+ static #EDGE_CLEARANCE_MIL = 0
14
+ static #FACE_Z_OFFSET_MIL = 0
15
+ static #EDGE_CLIP_MAX_DEPTH = 10
16
+ static #EDGE_CLIP_MAX_EDGE_LENGTH_MIL = 2
17
+
18
+ /**
19
+ * Builds top and bottom solder-mask face meshes for generated board bodies.
20
+ * @param {any} THREE
21
+ * @param {{ board?: any, detail?: any, boardAssemblyModel?: any }} sceneDescription
22
+ * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint
23
+ * @returns {any}
24
+ */
25
+ static buildGroup(
26
+ THREE,
27
+ sceneDescription,
28
+ normalizeBoardPoint = (x, y) => ({ x, y })
29
+ ) {
30
+ const group = new THREE.Group()
31
+ group.name = 'board-solder-mask'
32
+ const board = sceneDescription?.board || {}
33
+ const hasBoardAssemblyModel = Boolean(
34
+ sceneDescription?.boardAssemblyModel
35
+ )
36
+ const visible = PcbScene3dBoardMaterialPalette.isGeneratedBodyVisible({
37
+ hasBoardAssemblyModel
38
+ })
39
+
40
+ if (!hasBoardAssemblyModel || !visible) {
41
+ return group
42
+ }
43
+
44
+ const geometry = PcbScene3dBoardSolderMaskFactory.#buildMaskGeometry(
45
+ THREE,
46
+ board,
47
+ sceneDescription?.detail || {},
48
+ normalizeBoardPoint
49
+ )
50
+ const topMaterial = PcbScene3dBoardSolderMaskFactory.#buildMaterial(
51
+ THREE,
52
+ board,
53
+ THREE.FrontSide
54
+ )
55
+ const bottomMaterial = PcbScene3dBoardSolderMaskFactory.#buildMaterial(
56
+ THREE,
57
+ board,
58
+ THREE.BackSide
59
+ )
60
+ const z = Number(board?.thicknessMil || 0) / 2
61
+
62
+ group.add(
63
+ PcbScene3dBoardSolderMaskFactory.#buildSurfaceMesh(
64
+ THREE,
65
+ geometry,
66
+ topMaterial,
67
+ z + PcbScene3dBoardSolderMaskFactory.#FACE_Z_OFFSET_MIL,
68
+ 'board-solder-mask-top'
69
+ )
70
+ )
71
+ group.add(
72
+ PcbScene3dBoardSolderMaskFactory.#buildSurfaceMesh(
73
+ THREE,
74
+ geometry,
75
+ bottomMaterial,
76
+ -z - PcbScene3dBoardSolderMaskFactory.#FACE_Z_OFFSET_MIL,
77
+ 'board-solder-mask-bottom'
78
+ )
79
+ )
80
+
81
+ return group
82
+ }
83
+
84
+ /**
85
+ * Builds one flat solder-mask face mesh.
86
+ * @param {any} THREE
87
+ * @param {any} geometry Board face geometry.
88
+ * @param {any} material Surface material.
89
+ * @param {number} z Face z coordinate.
90
+ * @param {string} name Mesh name.
91
+ * @returns {any}
92
+ */
93
+ static #buildSurfaceMesh(THREE, geometry, material, z, name) {
94
+ const mesh = new THREE.Mesh(geometry, material)
95
+ mesh.name = name
96
+ mesh.position.z = z
97
+
98
+ return mesh
99
+ }
100
+
101
+ /**
102
+ * Builds the solder-mask face geometry.
103
+ * @param {any} THREE
104
+ * @param {{ widthMil?: number, heightMil?: number, segments?: Array<Record<string, number | string>> }} board
105
+ * @param {{ pads?: any[], vias?: any[] }} detail
106
+ * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint
107
+ * @returns {any}
108
+ */
109
+ static #buildMaskGeometry(THREE, board, detail, normalizeBoardPoint) {
110
+ const { shape, clippingHoles } =
111
+ PcbScene3dBoardSolderMaskFactory.#buildMaskShape(
112
+ THREE,
113
+ board,
114
+ detail,
115
+ normalizeBoardPoint
116
+ )
117
+ const geometry = new THREE.ShapeGeometry(
118
+ shape,
119
+ PcbScene3dBoardSolderMaskFactory.#CURVE_SEGMENTS
120
+ )
121
+
122
+ return PcbScene3dCutoutGeometryFilter.filter(
123
+ THREE,
124
+ geometry,
125
+ clippingHoles,
126
+ {
127
+ maxDepth: PcbScene3dBoardSolderMaskFactory.#EDGE_CLIP_MAX_DEPTH,
128
+ maxEdgeLength:
129
+ PcbScene3dBoardSolderMaskFactory
130
+ .#EDGE_CLIP_MAX_EDGE_LENGTH_MIL
131
+ }
132
+ )
133
+ }
134
+
135
+ /**
136
+ * Builds the inset mask shape and drill cutouts requiring fallback clipping.
137
+ * @param {any} THREE
138
+ * @param {{ widthMil?: number, heightMil?: number, segments?: Array<Record<string, number | string>> }} board
139
+ * @param {{ pads?: any[], vias?: any[] }} detail
140
+ * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint
141
+ * @returns {{ shape: any, clippingHoles: { x: number, y: number }[][] }}
142
+ */
143
+ static #buildMaskShape(THREE, board, detail, normalizeBoardPoint) {
144
+ const shape = PcbScene3dBoardSolderMaskFactory.#buildInsetOuterShape(
145
+ THREE,
146
+ board
147
+ )
148
+ const contourPoints =
149
+ PcbScene3dBoardSolderMaskFactory.#resolveShapePoints(shape)
150
+ const drillCutouts =
151
+ PcbScene3dBoardSolderMaskFactory.#resolveDrillCutouts(
152
+ THREE,
153
+ detail,
154
+ normalizeBoardPoint
155
+ )
156
+ const { shapeHoles, clippingCutouts } =
157
+ PcbScene3dBoardSolderMaskFactory.#partitionDrillCutouts(
158
+ drillCutouts,
159
+ contourPoints
160
+ )
161
+
162
+ PcbScene3dBoardSolderMaskFactory.#appendShapeHoles(
163
+ THREE,
164
+ shape,
165
+ shapeHoles
166
+ )
167
+ return {
168
+ shape,
169
+ clippingHoles: clippingCutouts.map((cutout) => cutout.points)
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Builds the solder-mask outer boundary with only perimeter clearance.
175
+ * @param {any} THREE
176
+ * @param {{ widthMil?: number, heightMil?: number, segments?: Array<Record<string, number | string>> }} board
177
+ * @returns {any}
178
+ */
179
+ static #buildInsetOuterShape(THREE, board) {
180
+ const baseShape = PcbScene3dBoardSolderMaskFactory.#buildBaseOuterShape(
181
+ THREE,
182
+ board
183
+ )
184
+ const scale = PcbScene3dBoardSolderMaskFactory.#resolveOuterScale(board)
185
+
186
+ if (scale.x === 1 && scale.y === 1) {
187
+ return baseShape
188
+ }
189
+
190
+ const points = PcbScene3dBoardSolderMaskFactory.#resolveShapePoints(
191
+ baseShape
192
+ ).map((point) => ({
193
+ x: point.x * scale.x,
194
+ y: point.y * scale.y
195
+ }))
196
+
197
+ return PcbScene3dBoardSolderMaskFactory.#buildShapeFromPoints(
198
+ THREE,
199
+ points
200
+ )
201
+ }
202
+
203
+ /**
204
+ * Builds the original board outline without drill apertures.
205
+ * @param {any} THREE
206
+ * @param {{ widthMil?: number, heightMil?: number, segments?: Array<Record<string, number | string>> }} board
207
+ * @returns {any}
208
+ */
209
+ static #buildBaseOuterShape(THREE, board) {
210
+ const shape = new THREE.Shape()
211
+ const commands = PcbScene3dOutlineBuilder.buildCommands(board)
212
+
213
+ if (!commands.length) {
214
+ return PcbScene3dBoardSolderMaskFactory.#buildRectangleShape(
215
+ THREE,
216
+ board
217
+ )
218
+ }
219
+
220
+ for (const command of commands) {
221
+ if (command.type === 'move') {
222
+ shape.moveTo(Number(command.x || 0), Number(command.y || 0))
223
+ continue
224
+ }
225
+
226
+ if (command.type === 'arc') {
227
+ shape.absarc(
228
+ Number(command.cx || 0),
229
+ Number(command.cy || 0),
230
+ Number(command.radius || 0),
231
+ Number(command.startAngleRad || 0),
232
+ Number(command.endAngleRad || 0),
233
+ Boolean(command.clockwise)
234
+ )
235
+ continue
236
+ }
237
+
238
+ shape.lineTo(Number(command.x || 0), Number(command.y || 0))
239
+ }
240
+
241
+ shape.closePath()
242
+ return shape
243
+ }
244
+
245
+ /**
246
+ * Builds a rectangular fallback shape.
247
+ * @param {any} THREE
248
+ * @param {{ widthMil?: number, heightMil?: number }} board
249
+ * @returns {any}
250
+ */
251
+ static #buildRectangleShape(THREE, board) {
252
+ const halfWidth = Number(board?.widthMil || 0) / 2
253
+ const halfHeight = Number(board?.heightMil || 0) / 2
254
+ const shape = new THREE.Shape()
255
+
256
+ shape.moveTo(-halfWidth, -halfHeight)
257
+ shape.lineTo(halfWidth, -halfHeight)
258
+ shape.lineTo(halfWidth, halfHeight)
259
+ shape.lineTo(-halfWidth, halfHeight)
260
+ shape.lineTo(-halfWidth, -halfHeight)
261
+ shape.closePath()
262
+ return shape
263
+ }
264
+
265
+ /**
266
+ * Resolves sampled points from one shape outline.
267
+ * @param {{ getPoints?: (segments: number) => { x: number, y: number }[] }} shape
268
+ * @returns {{ x: number, y: number }[]}
269
+ */
270
+ static #resolveShapePoints(shape) {
271
+ return (
272
+ shape?.getPoints?.(
273
+ PcbScene3dBoardSolderMaskFactory.#OUTER_SAMPLE_POINTS
274
+ ) || []
275
+ ).map((point) => ({
276
+ x: Number(point.x || 0),
277
+ y: Number(point.y || 0)
278
+ }))
279
+ }
280
+
281
+ /**
282
+ * Builds one closed shape from sampled outline points.
283
+ * @param {any} THREE
284
+ * @param {{ x: number, y: number }[]} points
285
+ * @returns {any}
286
+ */
287
+ static #buildShapeFromPoints(THREE, points) {
288
+ const shape = new THREE.Shape()
289
+
290
+ if (!points.length) {
291
+ return shape
292
+ }
293
+
294
+ shape.moveTo(points[0].x, points[0].y)
295
+ for (let index = 1; index < points.length; index += 1) {
296
+ shape.lineTo(points[index].x, points[index].y)
297
+ }
298
+
299
+ shape.closePath()
300
+ return shape
301
+ }
302
+
303
+ /**
304
+ * Resolves normalized drill cutout polygons.
305
+ * @param {any} THREE
306
+ * @param {{ pads?: any[], vias?: any[] }} detail
307
+ * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint
308
+ * @returns {{ path: any, points: { x: number, y: number }[] }[]}
309
+ */
310
+ static #resolveDrillCutouts(THREE, detail, normalizeBoardPoint) {
311
+ return PcbScene3dDrillPathFactory.resolveBoardDrillSpecs(detail)
312
+ .map((drillSpec) => {
313
+ const point = normalizeBoardPoint(drillSpec.x, drillSpec.y)
314
+ const path = PcbScene3dDrillPathFactory.buildDrillPath(THREE, {
315
+ ...drillSpec,
316
+ x: point.x,
317
+ y: point.y
318
+ })
319
+
320
+ return {
321
+ path,
322
+ points: PcbScene3dBoardSolderMaskFactory.#resolveDrillCutoutPoints(
323
+ {
324
+ ...drillSpec,
325
+ x: point.x,
326
+ y: point.y
327
+ },
328
+ path
329
+ )
330
+ }
331
+ })
332
+ .filter((cutout) => cutout.path && cutout.points.length >= 3)
333
+ }
334
+
335
+ /**
336
+ * Resolves sampled points for drill cutout classification and clipping.
337
+ * @param {{ x: number, y: number, diameter?: number, slotLength?: number | null }} drillSpec
338
+ * @param {{ getPoints?: (segments: number) => { x: number, y: number }[] } | null} path
339
+ * @returns {{ x: number, y: number }[]}
340
+ */
341
+ static #resolveDrillCutoutPoints(drillSpec, path) {
342
+ const diameter = Number(drillSpec?.diameter || 0)
343
+ const slotLength = Number(drillSpec?.slotLength || 0)
344
+
345
+ if (diameter > 0 && slotLength <= diameter + 0.001) {
346
+ return PcbScene3dBoardSolderMaskFactory.#buildCircularCutoutPoints(
347
+ Number(drillSpec?.x || 0),
348
+ Number(drillSpec?.y || 0),
349
+ diameter / 2
350
+ )
351
+ }
352
+
353
+ return PcbScene3dBoardSolderMaskFactory.#resolvePathPoints(path)
354
+ }
355
+
356
+ /**
357
+ * Builds uniformly sampled points for a circular drill cutout.
358
+ * @param {number} centerX Drill center X.
359
+ * @param {number} centerY Drill center Y.
360
+ * @param {number} radius Drill radius.
361
+ * @returns {{ x: number, y: number }[]}
362
+ */
363
+ static #buildCircularCutoutPoints(centerX, centerY, radius) {
364
+ return Array.from(
365
+ {
366
+ length: PcbScene3dBoardSolderMaskFactory.#DRILL_SAMPLE_POINTS
367
+ },
368
+ (_, index) => {
369
+ const angle =
370
+ (Math.PI * 2 * index) /
371
+ PcbScene3dBoardSolderMaskFactory.#DRILL_SAMPLE_POINTS
372
+ return {
373
+ x: centerX + Math.cos(angle) * radius,
374
+ y: centerY + Math.sin(angle) * radius
375
+ }
376
+ }
377
+ )
378
+ }
379
+
380
+ /**
381
+ * Resolves sampled points from one path.
382
+ * @param {{ getPoints?: (segments: number) => { x: number, y: number }[] } | null} path
383
+ * @returns {{ x: number, y: number }[]}
384
+ */
385
+ static #resolvePathPoints(path) {
386
+ return (
387
+ path?.getPoints?.(
388
+ PcbScene3dBoardSolderMaskFactory.#DRILL_SAMPLE_POINTS
389
+ ) || []
390
+ ).map((point) => ({
391
+ x: Number(point.x || 0),
392
+ y: Number(point.y || 0)
393
+ }))
394
+ }
395
+
396
+ /**
397
+ * Splits drill cutouts into safe shape holes and fallback clip polygons.
398
+ * @param {{ path: any, points: { x: number, y: number }[] }[]} drillCutouts
399
+ * @param {{ x: number, y: number }[]} contourPoints
400
+ * @returns {{ shapeHoles: { path: any, points: { x: number, y: number }[] }[], clippingCutouts: { path: any, points: { x: number, y: number }[] }[] }}
401
+ */
402
+ static #partitionDrillCutouts(drillCutouts, contourPoints) {
403
+ const shapeHoles = []
404
+ const clippingCutouts = []
405
+
406
+ for (const cutout of Array.isArray(drillCutouts) ? drillCutouts : []) {
407
+ if (
408
+ PcbScene3dBoardSolderMaskFactory.#isHoleInsideContour(
409
+ cutout.points,
410
+ contourPoints
411
+ )
412
+ ) {
413
+ shapeHoles.push(cutout)
414
+ continue
415
+ }
416
+
417
+ clippingCutouts.push(cutout)
418
+ }
419
+
420
+ return { shapeHoles, clippingCutouts }
421
+ }
422
+
423
+ /**
424
+ * Appends normalized cutout paths to one shape fill.
425
+ * @param {any} THREE
426
+ * @param {{ holes: any[] }} shape
427
+ * @param {{ path: any, points: { x: number, y: number }[] }[]} holes
428
+ * @returns {void}
429
+ */
430
+ static #appendShapeHoles(THREE, shape, holes) {
431
+ for (const hole of Array.isArray(holes) ? holes : []) {
432
+ shape.holes.push(
433
+ hole.path ||
434
+ PcbScene3dBoardSolderMaskFactory.#buildPathFromPoints(
435
+ THREE,
436
+ hole.points
437
+ )
438
+ )
439
+ }
440
+ }
441
+
442
+ /**
443
+ * Builds one closed path from sampled points.
444
+ * @param {any} THREE
445
+ * @param {{ x: number, y: number }[]} points
446
+ * @returns {any}
447
+ */
448
+ static #buildPathFromPoints(THREE, points) {
449
+ const path = new THREE.Path()
450
+
451
+ if (!points.length) {
452
+ return path
453
+ }
454
+
455
+ path.moveTo(points[0].x, points[0].y)
456
+ for (let index = 1; index < points.length; index += 1) {
457
+ path.lineTo(points[index].x, points[index].y)
458
+ }
459
+
460
+ path.closePath()
461
+ return path
462
+ }
463
+
464
+ /**
465
+ * Returns true when a cutout can safely be added as a shape hole.
466
+ * @param {{ x: number, y: number }[]} hole
467
+ * @param {{ x: number, y: number }[]} contour
468
+ * @returns {boolean}
469
+ */
470
+ static #isHoleInsideContour(hole, contour) {
471
+ return (
472
+ Array.isArray(hole) &&
473
+ Array.isArray(contour) &&
474
+ hole.length >= 3 &&
475
+ contour.length >= 3 &&
476
+ hole.every((point) =>
477
+ PcbScene3dBoardSolderMaskFactory.#isPointStrictlyInsidePolygon(
478
+ point,
479
+ contour
480
+ )
481
+ )
482
+ )
483
+ }
484
+
485
+ /**
486
+ * Returns true when a point lies inside a polygon and away from its border.
487
+ * @param {{ x: number, y: number }} point
488
+ * @param {{ x: number, y: number }[]} polygon
489
+ * @returns {boolean}
490
+ */
491
+ static #isPointStrictlyInsidePolygon(point, polygon) {
492
+ if (
493
+ PcbScene3dBoardSolderMaskFactory.#isPointOnPolygonBoundary(
494
+ point,
495
+ polygon
496
+ )
497
+ ) {
498
+ return false
499
+ }
500
+
501
+ let inside = false
502
+ for (
503
+ let index = 0, previousIndex = polygon.length - 1;
504
+ index < polygon.length;
505
+ previousIndex = index, index += 1
506
+ ) {
507
+ const current = polygon[index]
508
+ const previous = polygon[previousIndex]
509
+ const intersects =
510
+ current.y > point.y !== previous.y > point.y &&
511
+ point.x <
512
+ ((previous.x - current.x) * (point.y - current.y)) /
513
+ (previous.y - current.y) +
514
+ current.x
515
+
516
+ if (intersects) {
517
+ inside = !inside
518
+ }
519
+ }
520
+
521
+ return inside
522
+ }
523
+
524
+ /**
525
+ * Returns true when a point lies on any polygon edge.
526
+ * @param {{ x: number, y: number }} point
527
+ * @param {{ x: number, y: number }[]} polygon
528
+ * @returns {boolean}
529
+ */
530
+ static #isPointOnPolygonBoundary(point, polygon) {
531
+ return polygon.some((start, index) =>
532
+ PcbScene3dBoardSolderMaskFactory.#isPointOnSegment(
533
+ point,
534
+ start,
535
+ polygon[(index + 1) % polygon.length]
536
+ )
537
+ )
538
+ }
539
+
540
+ /**
541
+ * Returns true when a point lies on one line segment.
542
+ * @param {{ x: number, y: number }} point
543
+ * @param {{ x: number, y: number }} start
544
+ * @param {{ x: number, y: number }} end
545
+ * @returns {boolean}
546
+ */
547
+ static #isPointOnSegment(point, start, end) {
548
+ const lengthSquared =
549
+ (end.x - start.x) * (end.x - start.x) +
550
+ (end.y - start.y) * (end.y - start.y)
551
+
552
+ if (lengthSquared < 0.001) {
553
+ return Math.hypot(point.x - start.x, point.y - start.y) < 0.001
554
+ }
555
+
556
+ const cross =
557
+ (point.y - start.y) * (end.x - start.x) -
558
+ (point.x - start.x) * (end.y - start.y)
559
+ if (Math.abs(cross) > 0.001) {
560
+ return false
561
+ }
562
+
563
+ const dot =
564
+ (point.x - start.x) * (end.x - start.x) +
565
+ (point.y - start.y) * (end.y - start.y)
566
+ if (dot < -0.001) {
567
+ return false
568
+ }
569
+
570
+ return dot <= lengthSquared + 0.001
571
+ }
572
+
573
+ /**
574
+ * Resolves the mask outer-boundary scale for edge clearance.
575
+ * @param {{ widthMil?: number, heightMil?: number }} board Board dimensions.
576
+ * @returns {{ x: number, y: number }}
577
+ */
578
+ static #resolveOuterScale(board) {
579
+ const width = Number(board?.widthMil || 0)
580
+ const height = Number(board?.heightMil || 0)
581
+
582
+ if (width <= 0 || height <= 0) {
583
+ return { x: 1, y: 1 }
584
+ }
585
+
586
+ const clearance = PcbScene3dBoardSolderMaskFactory.#EDGE_CLEARANCE_MIL
587
+ return {
588
+ x: Math.max((width - 2 * clearance) / width, 0.9),
589
+ y: Math.max((height - 2 * clearance) / height, 0.9)
590
+ }
591
+ }
592
+
593
+ /**
594
+ * Builds the solder-mask material.
595
+ * @param {any} THREE
596
+ * @param {{ surfaceColor?: number }} board Board metadata.
597
+ * @param {number} side Rendered material side.
598
+ * @returns {any}
599
+ */
600
+ static #buildMaterial(THREE, board, side) {
601
+ return new THREE.MeshStandardMaterial({
602
+ color: PcbScene3dBoardMaterialPalette.resolveSurfaceColor(board, {
603
+ hasBoardAssemblyModel: true
604
+ }),
605
+ roughness: 0.68,
606
+ metalness: 0.08,
607
+ polygonOffset: true,
608
+ polygonOffsetFactor: -1,
609
+ polygonOffsetUnits: -1,
610
+ side
611
+ })
612
+ }
613
+ }