pcb-scene3d-viewer 1.0.1 → 1.1.0

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.
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Detects sampled circular drill cutouts for faster geometry clipping.
3
+ */
4
+ export class PcbScene3dCutoutCircleDetector {
5
+ static #DEFAULT_EPSILON = 0.001
6
+ static #MAX_RELATIVE_ERROR = 0.025
7
+ static #MIN_POINT_COUNT = 8
8
+
9
+ /**
10
+ * Resolves circle metadata for sampled circular drill cutouts.
11
+ * @param {{ x: number, y: number }[]} points
12
+ * @param {number} [epsilon]
13
+ * @returns {{ isCircular: true, centerX: number, centerY: number, radius: number } | null}
14
+ */
15
+ static resolve(points, epsilon = this.#DEFAULT_EPSILON) {
16
+ if (
17
+ !Array.isArray(points) ||
18
+ points.length < PcbScene3dCutoutCircleDetector.#MIN_POINT_COUNT
19
+ ) {
20
+ return null
21
+ }
22
+
23
+ const center = PcbScene3dCutoutCircleDetector.#resolveCentroid(points)
24
+ const radii = points.map((point) =>
25
+ Math.hypot(point.x - center.x, point.y - center.y)
26
+ )
27
+ const radius =
28
+ radii.reduce((sum, value) => sum + value, 0) / radii.length
29
+ const maxError = Math.max(
30
+ ...radii.map((value) => Math.abs(value - radius))
31
+ )
32
+ const tolerance = Math.max(
33
+ Number(epsilon || 0),
34
+ radius * PcbScene3dCutoutCircleDetector.#MAX_RELATIVE_ERROR
35
+ )
36
+
37
+ if (
38
+ !Number.isFinite(radius) ||
39
+ radius <= Number(epsilon || 0) ||
40
+ maxError > tolerance
41
+ ) {
42
+ return null
43
+ }
44
+
45
+ return {
46
+ isCircular: true,
47
+ centerX: center.x,
48
+ centerY: center.y,
49
+ radius
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Resolves squared distance from a point to circular cutout center.
55
+ * @param {{ x: number, y: number }} point
56
+ * @param {{ centerX?: number, centerY?: number }} cutout
57
+ * @returns {number}
58
+ */
59
+ static distanceSquared(point, cutout) {
60
+ const dx = point.x - Number(cutout.centerX || 0)
61
+ const dy = point.y - Number(cutout.centerY || 0)
62
+ return dx * dx + dy * dy
63
+ }
64
+
65
+ /**
66
+ * Resolves the centroid of one point list.
67
+ * @param {{ x: number, y: number }[]} points
68
+ * @returns {{ x: number, y: number }}
69
+ */
70
+ static #resolveCentroid(points) {
71
+ const sum = points.reduce(
72
+ (accumulator, point) => ({
73
+ x: accumulator.x + Number(point.x || 0),
74
+ y: accumulator.y + Number(point.y || 0)
75
+ }),
76
+ { x: 0, y: 0 }
77
+ )
78
+
79
+ return {
80
+ x: sum.x / points.length,
81
+ y: sum.y / points.length
82
+ }
83
+ }
84
+ }
@@ -1,3 +1,5 @@
1
+ import { PcbScene3dCutoutCircleDetector } from './PcbScene3dCutoutCircleDetector.mjs'
2
+
1
3
  /**
2
4
  * Clips filled 2D geometry against drill-cutout polygons.
3
5
  */
@@ -79,7 +81,7 @@ export class PcbScene3dCutoutGeometryFilter {
79
81
  /**
80
82
  * Resolves clipping settings.
81
83
  * @param {{ maxDepth?: number, maxEdgeLength?: number }} options
82
- * @returns {{ maxDepth: number, maxEdgeLength: number }}
84
+ * @returns {{ maxDepth: number, maxEdgeLength: number, maxEdgeLengthSquared: number }}
83
85
  */
84
86
  static #resolveSettings(options) {
85
87
  const maxEdgeLength = Math.max(
@@ -94,24 +96,35 @@ export class PcbScene3dCutoutGeometryFilter {
94
96
  PcbScene3dCutoutGeometryFilter.#DEFAULT_MAX_DEPTH,
95
97
  0
96
98
  ),
97
- maxEdgeLength
99
+ maxEdgeLength,
100
+ maxEdgeLengthSquared: maxEdgeLength * maxEdgeLength
98
101
  }
99
102
  }
100
103
 
101
104
  /**
102
105
  * Prepares cutout polygons with bounds for fast overlap checks.
103
106
  * @param {{ x: number, y: number }[][]} cutouts
104
- * @returns {{ points: { x: number, y: number }[], segments: { start: { x: number, y: number }, end: { x: number, y: number }, dx: number, dy: number, lengthSquared: number, bounds: { minX: number, maxX: number, minY: number, maxY: number } }[], bounds: { minX: number, maxX: number, minY: number, maxY: number } }[]}
107
+ * @returns {{ points: { x: number, y: number }[], segments: { start: { x: number, y: number }, end: { x: number, y: number }, dx: number, dy: number, lengthSquared: number, bounds: { minX: number, maxX: number, minY: number, maxY: number } }[], bounds: { minX: number, maxX: number, minY: number, maxY: number }, isCircular?: boolean, centerX?: number, centerY?: number, radius?: number }[]}
105
108
  */
106
109
  static #prepareCutouts(cutouts) {
107
110
  return cutouts
108
111
  .filter((cutout) => Array.isArray(cutout) && cutout.length >= 3)
109
- .map((cutout) => ({
110
- points: cutout,
111
- segments:
112
- PcbScene3dCutoutGeometryFilter.#buildCutoutSegments(cutout),
113
- bounds: PcbScene3dCutoutGeometryFilter.#resolveBounds(cutout)
114
- }))
112
+ .map((cutout) => {
113
+ const circularCutout =
114
+ PcbScene3dCutoutCircleDetector.resolve(cutout)
115
+
116
+ return {
117
+ points: cutout,
118
+ segments:
119
+ PcbScene3dCutoutGeometryFilter.#buildCutoutSegments(
120
+ cutout
121
+ ),
122
+ bounds: PcbScene3dCutoutGeometryFilter.#resolveBounds(
123
+ cutout
124
+ ),
125
+ ...circularCutout
126
+ }
127
+ })
115
128
  }
116
129
 
117
130
  /**
@@ -151,7 +164,7 @@ export class PcbScene3dCutoutGeometryFilter {
151
164
  * @param {number[]} positions
152
165
  * @param {{ x: number, y: number, z: number }[]} triangle
153
166
  * @param {{ points: { x: number, y: number }[], segments: { start: { x: number, y: number }, end: { x: number, y: number }, dx: number, dy: number, lengthSquared: number, bounds: { minX: number, maxX: number, minY: number, maxY: number } }[], bounds: { minX: number, maxX: number, minY: number, maxY: number } }[]} cutouts
154
- * @param {{ maxDepth: number, maxEdgeLength: number }} settings
167
+ * @param {{ maxDepth: number, maxEdgeLength: number, maxEdgeLengthSquared: number }} settings
155
168
  * @param {number} depth
156
169
  * @param {{ changed: boolean }} state
157
170
  * @param {object | null} [cutoutIndex]
@@ -199,8 +212,8 @@ export class PcbScene3dCutoutGeometryFilter {
199
212
 
200
213
  if (
201
214
  depth >= settings.maxDepth ||
202
- PcbScene3dCutoutGeometryFilter.#maxEdgeLength(triangle) <=
203
- settings.maxEdgeLength
215
+ PcbScene3dCutoutGeometryFilter.#maxEdgeLengthSquared(triangle) <=
216
+ settings.maxEdgeLengthSquared
204
217
  ) {
205
218
  return
206
219
  }
@@ -443,23 +456,22 @@ export class PcbScene3dCutoutGeometryFilter {
443
456
  }
444
457
 
445
458
  /**
446
- * Resolves the longest edge in one triangle.
459
+ * Resolves the longest squared edge length in one triangle.
447
460
  * @param {{ x: number, y: number }[]} triangle
448
461
  * @returns {number}
449
462
  */
450
- static #maxEdgeLength(triangle) {
451
- let maxLength = 0
463
+ static #maxEdgeLengthSquared(triangle) {
464
+ let maxLengthSquared = 0
452
465
 
453
466
  for (let index = 0; index < triangle.length; index += 1) {
454
467
  const point = triangle[index]
455
468
  const next = triangle[(index + 1) % triangle.length]
456
- maxLength = Math.max(
457
- maxLength,
458
- Math.hypot(point.x - next.x, point.y - next.y)
459
- )
469
+ const dx = point.x - next.x
470
+ const dy = point.y - next.y
471
+ maxLengthSquared = Math.max(maxLengthSquared, dx * dx + dy * dy)
460
472
  }
461
473
 
462
- return maxLength
474
+ return maxLengthSquared
463
475
  }
464
476
 
465
477
  /**
@@ -496,10 +508,17 @@ export class PcbScene3dCutoutGeometryFilter {
496
508
  */
497
509
  static #boundsOverlap(first, second) {
498
510
  return (
499
- first.minX <= second.maxX &&
500
- first.maxX >= second.minX &&
501
- first.minY <= second.maxY &&
502
- first.maxY >= second.minY
511
+ first.minX <=
512
+ second.maxX +
513
+ PcbScene3dCutoutGeometryFilter.#GEOMETRY_EPSILON &&
514
+ first.maxX >=
515
+ second.minX -
516
+ PcbScene3dCutoutGeometryFilter.#GEOMETRY_EPSILON &&
517
+ first.minY <=
518
+ second.maxY +
519
+ PcbScene3dCutoutGeometryFilter.#GEOMETRY_EPSILON &&
520
+ first.maxY >=
521
+ second.minY - PcbScene3dCutoutGeometryFilter.#GEOMETRY_EPSILON
503
522
  )
504
523
  }
505
524
 
@@ -543,7 +562,7 @@ export class PcbScene3dCutoutGeometryFilter {
543
562
  /**
544
563
  * Returns true when one triangle intersects or covers a cutout.
545
564
  * @param {{ x: number, y: number }[]} triangle
546
- * @param {{ points: { x: number, y: number }[], segments: { start: { x: number, y: number }, end: { x: number, y: number }, dx: number, dy: number, lengthSquared: number, bounds: { minX: number, maxX: number, minY: number, maxY: number } }[], bounds: { minX: number, maxX: number, minY: number, maxY: number } }} cutout
565
+ * @param {{ points: { x: number, y: number }[], segments: { start: { x: number, y: number }, end: { x: number, y: number }, dx: number, dy: number, lengthSquared: number, bounds: { minX: number, maxX: number, minY: number, maxY: number } }[], bounds: { minX: number, maxX: number, minY: number, maxY: number }, isCircular?: boolean, centerX?: number, centerY?: number, radius?: number }} cutout
547
566
  * @returns {boolean}
548
567
  */
549
568
  static #doesTriangleOverlapCutout(triangle, cutout) {
@@ -587,10 +606,28 @@ export class PcbScene3dCutoutGeometryFilter {
587
606
  /**
588
607
  * Returns true when a point is inside or on a cutout.
589
608
  * @param {{ x: number, y: number }} point
590
- * @param {{ points: { x: number, y: number }[], segments: { start: { x: number, y: number }, end: { x: number, y: number }, dx: number, dy: number, lengthSquared: number, bounds: { minX: number, maxX: number, minY: number, maxY: number } }[], bounds: { minX: number, maxX: number, minY: number, maxY: number } }} cutout
609
+ * @param {{ points: { x: number, y: number }[], segments: { start: { x: number, y: number }, end: { x: number, y: number }, dx: number, dy: number, lengthSquared: number, bounds: { minX: number, maxX: number, minY: number, maxY: number } }[], bounds: { minX: number, maxX: number, minY: number, maxY: number }, isCircular?: boolean, centerX?: number, centerY?: number, radius?: number }} cutout
591
610
  * @returns {boolean}
592
611
  */
593
612
  static #isPointInsideOrOnCutout(point, cutout) {
613
+ if (
614
+ !PcbScene3dCutoutGeometryFilter.#pointOverlapsBounds(
615
+ point,
616
+ cutout.bounds
617
+ )
618
+ ) {
619
+ return false
620
+ }
621
+
622
+ if (cutout.isCircular) {
623
+ return (
624
+ PcbScene3dCutoutCircleDetector.distanceSquared(point, cutout) <=
625
+ (Number(cutout.radius || 0) +
626
+ PcbScene3dCutoutGeometryFilter.#GEOMETRY_EPSILON) **
627
+ 2
628
+ )
629
+ }
630
+
594
631
  return (
595
632
  PcbScene3dCutoutGeometryFilter.#isPointOnCutoutBoundary(
596
633
  point,
@@ -603,13 +640,47 @@ export class PcbScene3dCutoutGeometryFilter {
603
640
  )
604
641
  }
605
642
 
643
+ /**
644
+ * Returns true when a point lies within a bounding box tolerance.
645
+ * @param {{ x: number, y: number }} point
646
+ * @param {{ minX: number, maxX: number, minY: number, maxY: number }} bounds
647
+ * @returns {boolean}
648
+ */
649
+ static #pointOverlapsBounds(point, bounds) {
650
+ return (
651
+ point.x >=
652
+ bounds.minX -
653
+ PcbScene3dCutoutGeometryFilter.#GEOMETRY_EPSILON &&
654
+ point.x <=
655
+ bounds.maxX +
656
+ PcbScene3dCutoutGeometryFilter.#GEOMETRY_EPSILON &&
657
+ point.y >=
658
+ bounds.minY -
659
+ PcbScene3dCutoutGeometryFilter.#GEOMETRY_EPSILON &&
660
+ point.y <=
661
+ bounds.maxY + PcbScene3dCutoutGeometryFilter.#GEOMETRY_EPSILON
662
+ )
663
+ }
664
+
606
665
  /**
607
666
  * Returns true when a point lies inside a cutout and away from its border.
608
667
  * @param {{ x: number, y: number }} point
609
- * @param {{ points: { x: number, y: number }[], bounds: { minX: number, maxX: number, minY: number, maxY: number } }} cutout
668
+ * @param {{ points: { x: number, y: number }[], bounds: { minX: number, maxX: number, minY: number, maxY: number }, isCircular?: boolean, centerX?: number, centerY?: number, radius?: number }} cutout
610
669
  * @returns {boolean}
611
670
  */
612
671
  static #isPointStrictlyInsideCutout(point, cutout) {
672
+ if (cutout.isCircular) {
673
+ const radius = Math.max(
674
+ 0,
675
+ Number(cutout.radius || 0) -
676
+ PcbScene3dCutoutGeometryFilter.#GEOMETRY_EPSILON
677
+ )
678
+ return (
679
+ PcbScene3dCutoutCircleDetector.distanceSquared(point, cutout) <
680
+ radius * radius
681
+ )
682
+ }
683
+
613
684
  const polygon = cutout.points
614
685
 
615
686
  let inside = false
@@ -638,18 +709,33 @@ export class PcbScene3dCutoutGeometryFilter {
638
709
  /**
639
710
  * Returns true when a point lies on a cutout edge.
640
711
  * @param {{ x: number, y: number }} point
641
- * @param {{ points: { x: number, y: number }[] }} cutout
712
+ * @param {{ segments: { start: { x: number, y: number }, end: { x: number, y: number }, bounds: { minX: number, maxX: number, minY: number, maxY: number } }[], isCircular?: boolean, centerX?: number, centerY?: number, radius?: number }} cutout
642
713
  * @returns {boolean}
643
714
  */
644
715
  static #isPointOnCutoutBoundary(point, cutout) {
645
- const polygon = cutout.points
716
+ if (cutout.isCircular) {
717
+ return (
718
+ Math.abs(
719
+ Math.sqrt(
720
+ PcbScene3dCutoutCircleDetector.distanceSquared(
721
+ point,
722
+ cutout
723
+ )
724
+ ) - Number(cutout.radius || 0)
725
+ ) <= PcbScene3dCutoutGeometryFilter.#GEOMETRY_EPSILON
726
+ )
727
+ }
646
728
 
647
- for (let index = 0; index < polygon.length; index += 1) {
729
+ for (const segment of cutout.segments) {
648
730
  if (
731
+ PcbScene3dCutoutGeometryFilter.#pointOverlapsBounds(
732
+ point,
733
+ segment.bounds
734
+ ) &&
649
735
  PcbScene3dCutoutGeometryFilter.#isPointOnSegment(
650
736
  point,
651
- polygon[index],
652
- polygon[(index + 1) % polygon.length]
737
+ segment.start,
738
+ segment.end
653
739
  )
654
740
  ) {
655
741
  return true
@@ -703,9 +789,18 @@ export class PcbScene3dCutoutGeometryFilter {
703
789
  ) {
704
790
  const triangleStart = triangle[triangleIndex]
705
791
  const triangleEnd = triangle[(triangleIndex + 1) % triangle.length]
792
+ const triangleSegmentBounds =
793
+ PcbScene3dCutoutGeometryFilter.#resolveSegmentBounds(
794
+ triangleStart,
795
+ triangleEnd
796
+ )
706
797
 
707
798
  for (const segment of cutout.segments) {
708
799
  if (
800
+ PcbScene3dCutoutGeometryFilter.#boundsOverlap(
801
+ triangleSegmentBounds,
802
+ segment.bounds
803
+ ) &&
709
804
  PcbScene3dCutoutGeometryFilter.#segmentsIntersect(
710
805
  triangleStart,
711
806
  triangleEnd,
@@ -721,6 +816,21 @@ export class PcbScene3dCutoutGeometryFilter {
721
816
  return false
722
817
  }
723
818
 
819
+ /**
820
+ * Resolves one finite segment bounding box.
821
+ * @param {{ x: number, y: number }} firstStart
822
+ * @param {{ x: number, y: number }} firstEnd
823
+ * @returns {{ minX: number, maxX: number, minY: number, maxY: number }}
824
+ */
825
+ static #resolveSegmentBounds(firstStart, firstEnd) {
826
+ return {
827
+ minX: Math.min(firstStart.x, firstEnd.x),
828
+ maxX: Math.max(firstStart.x, firstEnd.x),
829
+ minY: Math.min(firstStart.y, firstEnd.y),
830
+ maxY: Math.max(firstStart.y, firstEnd.y)
831
+ }
832
+ }
833
+
724
834
  /**
725
835
  * Returns true when two finite line segments intersect.
726
836
  * @param {{ x: number, y: number }} firstStart
@@ -4,7 +4,7 @@
4
4
  export class PcbScene3dDetailCoordinateNormalizer {
5
5
  /**
6
6
  * Creates a reusable normalizer for one scene description.
7
- * @param {{ board?: { centerX?: number, centerY?: number }, boardAssemblyModel?: any, coordinateSystem?: string, sourceFormat?: string } | null} sceneDescription Scene metadata.
7
+ * @param {{ board?: { centerX?: number, centerY?: number }, coordinateSystem?: string, sourceFormat?: string } | null} sceneDescription Scene metadata.
8
8
  * @returns {(x: number, y: number) => { x: number, y: number }}
9
9
  */
10
10
  static create(sceneDescription) {
@@ -18,7 +18,7 @@ export class PcbScene3dDetailCoordinateNormalizer {
18
18
 
19
19
  /**
20
20
  * Normalizes one source detail coordinate into centered scene space.
21
- * @param {{ board?: { centerX?: number, centerY?: number }, boardAssemblyModel?: any, coordinateSystem?: string, sourceFormat?: string } | null} sceneDescription Scene metadata.
21
+ * @param {{ board?: { centerX?: number, centerY?: number }, coordinateSystem?: string, sourceFormat?: string } | null} sceneDescription Scene metadata.
22
22
  * @param {number} x Source X coordinate.
23
23
  * @param {number} y Source Y coordinate.
24
24
  * @returns {{ x: number, y: number }}
@@ -32,34 +32,7 @@ export class PcbScene3dDetailCoordinateNormalizer {
32
32
 
33
33
  return {
34
34
  x: sourceX - centerX,
35
- y: PcbScene3dDetailCoordinateNormalizer.#usesAssemblyModelPcbY(
36
- sceneDescription
37
- )
38
- ? centerY - sourceY
39
- : sourceY - centerY
40
- }
41
- }
42
-
43
- /**
44
- * Checks whether detail primitives are being aligned to an assembly model
45
- * that still uses the source PCB Y axis.
46
- * @param {{ boardAssemblyModel?: any, coordinateSystem?: string, sourceFormat?: string } | null} sceneDescription Scene metadata.
47
- * @returns {boolean}
48
- */
49
- static #usesAssemblyModelPcbY(sceneDescription) {
50
- if (
51
- String(sceneDescription?.coordinateSystem || '') === 'kicad-3d-y-up'
52
- ) {
53
- return false
35
+ y: sourceY - centerY
54
36
  }
55
-
56
- const sourceFormat = String(
57
- sceneDescription?.sourceFormat || ''
58
- ).toLowerCase()
59
-
60
- return (
61
- sourceFormat === 'altium' &&
62
- Boolean(sceneDescription?.boardAssemblyModel)
63
- )
64
37
  }
65
38
  }
@@ -1,5 +1,7 @@
1
1
  import { PcbScene3dBoardAssemblyPresentation } from './PcbScene3dBoardAssemblyPresentation.mjs'
2
2
  import { PcbScene3dBoardAssemblyPlacement } from './PcbScene3dBoardAssemblyPlacement.mjs'
3
+ import { PcbScene3dBoardAssemblyTransform } from './PcbScene3dBoardAssemblyTransform.mjs'
4
+ import { PcbScene3dBufferAttributeFactory } from './PcbScene3dBufferAttributeFactory.mjs'
3
5
  import { PcbScene3dExternalModelLoadOrder } from './PcbScene3dExternalModelLoadOrder.mjs'
4
6
  import { PcbScene3dMountRig } from './PcbScene3dMountRig.mjs'
5
7
  import { PcbScene3dStepLoader } from './PcbScene3dStepLoader.mjs'
@@ -329,7 +331,7 @@ export class PcbScene3dExternalModels {
329
331
  /**
330
332
  * Wraps a full board assembly model in board-local scene coordinates.
331
333
  * @param {any} THREE
332
- * @param {{ positionMil?: { x?: number, y?: number, z?: number }, board?: { widthMil?: number, heightMil?: number, thicknessMil?: number } }} placement
334
+ * @param {{ positionMil?: { x?: number, y?: number, z?: number }, board?: { widthMil?: number, heightMil?: number, thicknessMil?: number }, sourceFrameScale?: { y?: number } }} placement
333
335
  * @param {any} modelGroup
334
336
  * @returns {any}
335
337
  */
@@ -338,6 +340,7 @@ export class PcbScene3dExternalModels {
338
340
  const positionMil = placement?.positionMil || {}
339
341
 
340
342
  PcbScene3dBoardAssemblyPresentation.apply(modelGroup, placement?.board)
343
+ PcbScene3dBoardAssemblyTransform.apply(modelGroup, placement)
341
344
  wrapperGroup.position.set(
342
345
  Number(positionMil.x || 0),
343
346
  Number(positionMil.y || 0),
@@ -720,14 +723,28 @@ export class PcbScene3dExternalModels {
720
723
  const geometry = new THREE.BufferGeometry()
721
724
  geometry.setAttribute(
722
725
  'position',
723
- new THREE.Float32BufferAttribute(meshPayload.positions, 3)
726
+ PcbScene3dBufferAttributeFactory.createFloat32(
727
+ THREE,
728
+ meshPayload.positions,
729
+ 3
730
+ )
731
+ )
732
+ geometry.setIndex(
733
+ PcbScene3dBufferAttributeFactory.createUint32(
734
+ THREE,
735
+ meshPayload.indices,
736
+ 1
737
+ )
724
738
  )
725
- geometry.setIndex(meshPayload.indices)
726
739
 
727
740
  if (meshPayload.normals.length) {
728
741
  geometry.setAttribute(
729
742
  'normal',
730
- new THREE.Float32BufferAttribute(meshPayload.normals, 3)
743
+ PcbScene3dBufferAttributeFactory.createFloat32(
744
+ THREE,
745
+ meshPayload.normals,
746
+ 3
747
+ )
731
748
  )
732
749
  } else {
733
750
  geometry.computeVertexNormals()
@@ -752,7 +769,7 @@ export class PcbScene3dExternalModels {
752
769
  /**
753
770
  * Measures raw STEP mesh bounds in mil before the group-level unit scale is
754
771
  * applied.
755
- * @param {{ positions?: number[] }[]} meshPayloads STEP mesh payloads.
772
+ * @param {{ positions?: ArrayLike<number> }[]} meshPayloads STEP mesh payloads.
756
773
  * @returns {{ minX: number, minY: number, minZ: number, maxX: number, maxY: number, maxZ: number, centerX: number, centerY: number, centerZ: number, sizeX: number, sizeY: number, sizeZ: number } | null}
757
774
  */
758
775
  static #measureSourceBoundsMil(meshPayloads) {
@@ -765,7 +782,9 @@ export class PcbScene3dExternalModels {
765
782
 
766
783
  ;(Array.isArray(meshPayloads) ? meshPayloads : []).forEach(
767
784
  (meshPayload) => {
768
- ;(Array.isArray(meshPayload?.positions)
785
+ ;(PcbScene3dBufferAttributeFactory.isNumberSequence(
786
+ meshPayload?.positions
787
+ )
769
788
  ? meshPayload.positions
770
789
  : []
771
790
  ).forEach((value, index) => {
@@ -817,7 +836,7 @@ export class PcbScene3dExternalModels {
817
836
  * when the importer exposes them.
818
837
  * @param {any} THREE
819
838
  * @param {any} geometry
820
- * @param {{ color?: number[] | null, indices?: number[], faceColors?: { first: number, last: number, color: number[] | null }[] }} meshPayload
839
+ * @param {{ color?: number[] | null, indices?: ArrayLike<number>, faceColors?: { first: number, last: number, color: number[] | null }[] }} meshPayload
821
840
  * @returns {any[]}
822
841
  */
823
842
  static #buildStepMeshMaterials(THREE, geometry, meshPayload) {
@@ -886,14 +905,12 @@ export class PcbScene3dExternalModels {
886
905
  /**
887
906
  * Applies grouped material ranges for face-colored STEP triangles.
888
907
  * @param {any} geometry
889
- * @param {number[]} indices
908
+ * @param {ArrayLike<number>} indices
890
909
  * @param {{ first: number, last: number }[]} faceColors
891
910
  * @returns {void}
892
911
  */
893
912
  static #applyFaceColorGroups(geometry, indices, faceColors) {
894
- const triangleCount = Math.floor(
895
- (Array.isArray(indices) ? indices.length : 0) / 3
896
- )
913
+ const triangleCount = Math.floor(Number(indices?.length || 0) / 3)
897
914
  let triangleIndex = 0
898
915
  let faceColorIndex = 0
899
916
 
@@ -929,15 +946,13 @@ export class PcbScene3dExternalModels {
929
946
  /**
930
947
  * Returns true when one face-color range overlaps valid triangle indices.
931
948
  * @param {{ first?: number, last?: number }} faceColor
932
- * @param {number[] | undefined} indices
949
+ * @param {ArrayLike<number> | undefined} indices
933
950
  * @returns {boolean}
934
951
  */
935
952
  static #isValidFaceColorRange(faceColor, indices) {
936
953
  const first = Number(faceColor?.first)
937
954
  const last = Number(faceColor?.last)
938
- const triangleCount = Math.floor(
939
- (Array.isArray(indices) ? indices.length : 0) / 3
940
- )
955
+ const triangleCount = Math.floor(Number(indices?.length || 0) / 3)
941
956
 
942
957
  return (
943
958
  Number.isInteger(first) &&
@@ -1,4 +1,5 @@
1
1
  import { PcbScene3dBoardSolderMaskFactory } from './PcbScene3dBoardSolderMaskFactory.mjs'
2
+ import { PcbScene3dBodyColor } from './PcbScene3dBodyColor.mjs'
2
3
  import { PcbScene3dCameraRig } from './PcbScene3dCameraRig.mjs'
3
4
  import { PcbScene3dCircuitJsonAdapter } from './PcbScene3dCircuitJsonAdapter.mjs'
4
5
  import { PcbScene3dCopperFactory } from './PcbScene3dCopperFactory.mjs'
@@ -12,7 +13,7 @@ import { PcbScene3dMountRig } from './PcbScene3dMountRig.mjs'
12
13
  import { PcbScene3dPresetState } from './PcbScene3dPresetState.mjs'
13
14
  import { PcbScene3dRenderGroupVisibility } from './PcbScene3dRenderGroupVisibility.mjs'
14
15
  import { PcbScene3dRuntimeBoardMeshes } from './PcbScene3dRuntimeBoardMeshes.mjs'
15
- import { PcbScene3dSilkscreenFactory } from './PcbScene3dSilkscreenFactory.mjs'
16
+ import { PcbScene3dSilkscreenChunkedFactory } from './PcbScene3dSilkscreenChunkedFactory.mjs'
16
17
  import { PcbScene3dTrueTypeTextFactory } from './PcbScene3dTrueTypeTextFactory.mjs'
17
18
  import { PcbScene3dSelectionStyler } from './PcbScene3dSelectionStyler.mjs'
18
19
  import { PcbScene3dViaFactory } from './PcbScene3dViaFactory.mjs'
@@ -340,6 +341,12 @@ export class PcbScene3dRuntime {
340
341
  )
341
342
  )
342
343
  this.#groups.set('board', boardGroup)
344
+ PcbScene3dRuntimeBoardMeshes.applyBoardFaceSide(
345
+ this.#three,
346
+ boardGroup,
347
+ this.#presetState.get(),
348
+ this.#sceneDescription
349
+ )
343
350
  this.#rootGroup.add(boardGroup)
344
351
  const silkscreenGroup = new THREE.Group()
345
352
  this.#groups.set('silkscreen', silkscreenGroup)
@@ -376,14 +383,18 @@ export class PcbScene3dRuntime {
376
383
  * @returns {void}
377
384
  */
378
385
  #applyViewScale(preset) {
379
- if (!this.#viewOrientationGroup) {
380
- return
381
- }
386
+ if (!this.#viewOrientationGroup) return
382
387
  const scale = PcbScene3dRuntime.resolveViewScale(
383
388
  preset,
384
389
  this.#sceneDescription
385
390
  )
386
391
  this.#viewOrientationGroup.scale.set(scale.x, scale.y, scale.z)
392
+ PcbScene3dRuntimeBoardMeshes.applyBoardFaceSide(
393
+ this.#three,
394
+ this.#groups.get('board'),
395
+ preset,
396
+ this.#sceneDescription
397
+ )
387
398
  PcbScene3dExternalModels.applyViewCompensation(
388
399
  this.#groups.get('silkscreen'),
389
400
  scale
@@ -448,22 +459,24 @@ export class PcbScene3dRuntime {
448
459
  */
449
460
  async #loadDeferredSilkscreen() {
450
461
  const silkscreenGroup = this.#groups.get('silkscreen')
451
- if (!silkscreenGroup || silkscreenGroup.children.length) {
452
- return
453
- }
462
+ if (!silkscreenGroup || silkscreenGroup.children.length) return
454
463
 
455
464
  await PcbScene3dTrueTypeTextFactory.prepareEmbeddedFonts(
456
465
  this.#sceneDescription.detail.embeddedFonts || []
457
466
  )
458
-
459
- const detailGroup = PcbScene3dSilkscreenFactory.buildGroup(
467
+ const detailGroup = await PcbScene3dSilkscreenChunkedFactory.buildGroup(
460
468
  this.#three,
461
469
  this.#sceneDescription.detail.silkscreen || {},
462
470
  this.#sceneDescription.board.thicknessMil / 2 + 1.18,
463
471
  -(this.#sceneDescription.board.thicknessMil / 2 + 1.18),
464
- (x, y) => this.#normalizeDetailPoint(x, y)
472
+ (x, y) => this.#normalizeDetailPoint(x, y),
473
+ {
474
+ shouldContinue: () => !this.#isDisposed,
475
+ yieldToMain: () => PcbScene3dRuntime.#yieldToNextFrame()
476
+ }
465
477
  )
466
478
 
479
+ if (this.#isDisposed) return
467
480
  if (detailGroup.children.length) {
468
481
  silkscreenGroup.add(detailGroup)
469
482
  this.#applyViewScale(this.#presetState.get())
@@ -526,7 +539,7 @@ export class PcbScene3dRuntime {
526
539
  const family = component.body.family
527
540
  const size = component.body.sizeMil
528
541
  const material = new THREE.MeshStandardMaterial({
529
- color: PcbScene3dRuntime.#resolveBodyColor(family),
542
+ color: PcbScene3dBodyColor.resolve(family),
530
543
  roughness: 0.72,
531
544
  metalness: family === 'chip' ? 0.12 : 0.08
532
545
  })
@@ -971,26 +984,4 @@ export class PcbScene3dRuntime {
971
984
  const dy = Number(end?.y || 0) - Number(start?.y || 0)
972
985
  return Math.hypot(dx, dy)
973
986
  }
974
-
975
- /**
976
- * Resolves a simple body color by package family.
977
- * @param {string} family
978
- * @returns {number}
979
- */
980
- static #resolveBodyColor(family) {
981
- if (family === 'radial-capacitor') {
982
- return 0xa60f10
983
- }
984
- if (family === 'connector-block') {
985
- return 0xd5d6da
986
- }
987
- if (family === 'test-point') {
988
- return 0x0ea5a8
989
- }
990
- if (family === 'chip') {
991
- return 0xf5f5ef
992
- }
993
-
994
- return 0x232428
995
- }
996
987
  }