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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pcb-scene3d-viewer",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "Reusable Three.js PCB 3D scene viewer for normalized ECAD and CircuitJSON scene descriptions",
5
5
  "keywords": [
6
6
  "pcb",
@@ -4,7 +4,7 @@
4
4
  export class PcbScene3dBoardAssemblyPlacement {
5
5
  /**
6
6
  * Builds the synthetic placement for a full board assembly model.
7
- * @param {{ board?: { widthMil?: number, heightMil?: number, thicknessMil?: number, surfaceColor?: number }, boardAssemblyModel?: any }} sceneDescription
7
+ * @param {{ board?: { widthMil?: number, heightMil?: number, thicknessMil?: number, surfaceColor?: number }, boardAssemblyModel?: any, sourceFormat?: string }} sceneDescription
8
8
  * @returns {any | null}
9
9
  */
10
10
  static build(sceneDescription) {
@@ -14,6 +14,15 @@ export class PcbScene3dBoardAssemblyPlacement {
14
14
  }
15
15
 
16
16
  const board = sceneDescription?.board || {}
17
+ const mirrorsSourceY =
18
+ String(sceneDescription?.sourceFormat || '').toLowerCase() ===
19
+ 'altium'
20
+ const sourceFrameScale = {
21
+ x: 1,
22
+ y: mirrorsSourceY ? -1 : 1,
23
+ z: 1
24
+ }
25
+
17
26
  return {
18
27
  designator: 'Board assembly',
19
28
  sourceType: 'board-assembly',
@@ -21,9 +30,12 @@ export class PcbScene3dBoardAssemblyPlacement {
21
30
  rotationDeg: 0,
22
31
  positionMil: {
23
32
  x: -Number(board.widthMil || 0) / 2,
24
- y: -Number(board.heightMil || 0) / 2,
33
+ y:
34
+ (mirrorsSourceY ? 1 : -1) *
35
+ (Number(board.heightMil || 0) / 2),
25
36
  z: 0
26
37
  },
38
+ sourceFrameScale,
27
39
  board: {
28
40
  widthMil: Number(board.widthMil || 0),
29
41
  heightMil: Number(board.heightMil || 0),
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Applies source-frame transforms to full-board assembly STEP geometry.
3
+ */
4
+ export class PcbScene3dBoardAssemblyTransform {
5
+ /**
6
+ * Applies the source-frame transform encoded by a board assembly placement.
7
+ * @param {any} modelGroup Loaded board assembly group.
8
+ * @param {{ sourceFrameScale?: { y?: number } }} placement Placement metadata.
9
+ * @returns {void}
10
+ */
11
+ static apply(modelGroup, placement) {
12
+ if (!PcbScene3dBoardAssemblyTransform.#shouldMirrorY(placement)) {
13
+ return
14
+ }
15
+
16
+ PcbScene3dBoardAssemblyTransform.#traverseModelObjects(
17
+ modelGroup,
18
+ (object) =>
19
+ PcbScene3dBoardAssemblyTransform.#mirrorGeometryY(
20
+ object?.geometry
21
+ )
22
+ )
23
+ modelGroup.userData = modelGroup.userData || {}
24
+ modelGroup.userData.scene3dBoardAssemblyMirroredY = true
25
+ }
26
+
27
+ /**
28
+ * Checks whether the assembly source frame needs a Y mirror.
29
+ * @param {{ sourceFrameScale?: { y?: number } }} placement Placement metadata.
30
+ * @returns {boolean}
31
+ */
32
+ static #shouldMirrorY(placement) {
33
+ return Number(placement?.sourceFrameScale?.y ?? 1) < 0
34
+ }
35
+
36
+ /**
37
+ * Mirrors one geometry around the source Y origin.
38
+ * @param {any} geometry Buffer geometry.
39
+ * @returns {void}
40
+ */
41
+ static #mirrorGeometryY(geometry) {
42
+ const position = PcbScene3dBoardAssemblyTransform.#getAttribute(
43
+ geometry,
44
+ 'position'
45
+ )
46
+ if (!position?.array?.length) {
47
+ return
48
+ }
49
+
50
+ PcbScene3dBoardAssemblyTransform.#mirrorAttributeY(position)
51
+ const normal = PcbScene3dBoardAssemblyTransform.#getAttribute(
52
+ geometry,
53
+ 'normal'
54
+ )
55
+ PcbScene3dBoardAssemblyTransform.#mirrorAttributeY(normal)
56
+ PcbScene3dBoardAssemblyTransform.#reverseTriangleWinding(geometry)
57
+ geometry.computeBoundingBox?.()
58
+ geometry.computeBoundingSphere?.()
59
+ }
60
+
61
+ /**
62
+ * Mirrors the Y component of a vertex-like attribute.
63
+ * @param {{ array?: ArrayLike<number>, itemSize?: number, needsUpdate?: boolean } | null} attribute Geometry attribute.
64
+ * @returns {void}
65
+ */
66
+ static #mirrorAttributeY(attribute) {
67
+ const itemSize = Number(attribute?.itemSize || 0)
68
+ if (!attribute?.array?.length || itemSize < 2) {
69
+ return
70
+ }
71
+
72
+ for (let index = 1; index < attribute.array.length; index += itemSize) {
73
+ attribute.array[index] = -Number(attribute.array[index] || 0)
74
+ }
75
+ attribute.needsUpdate = true
76
+ }
77
+
78
+ /**
79
+ * Reverses triangle winding after a one-axis mirror.
80
+ * @param {any} geometry Buffer geometry.
81
+ * @returns {void}
82
+ */
83
+ static #reverseTriangleWinding(geometry) {
84
+ const index = geometry?.index
85
+ const indexArray = index?.array
86
+ if (!indexArray?.length) {
87
+ PcbScene3dBoardAssemblyTransform.#reverseUnindexedWinding(geometry)
88
+ return
89
+ }
90
+
91
+ for (let offset = 0; offset + 2 < indexArray.length; offset += 3) {
92
+ const next = indexArray[offset + 1]
93
+ indexArray[offset + 1] = indexArray[offset + 2]
94
+ indexArray[offset + 2] = next
95
+ }
96
+ index.needsUpdate = true
97
+ }
98
+
99
+ /**
100
+ * Reverses unindexed triangle winding across all geometry attributes.
101
+ * @param {any} geometry Buffer geometry.
102
+ * @returns {void}
103
+ */
104
+ static #reverseUnindexedWinding(geometry) {
105
+ Object.values(geometry?.attributes || {}).forEach((attribute) =>
106
+ PcbScene3dBoardAssemblyTransform.#swapTriangleVertices(
107
+ attribute,
108
+ 1,
109
+ 2
110
+ )
111
+ )
112
+ }
113
+
114
+ /**
115
+ * Swaps two vertices inside each unindexed triangle for one attribute.
116
+ * @param {{ array?: ArrayLike<number>, itemSize?: number, needsUpdate?: boolean }} attribute Geometry attribute.
117
+ * @param {number} leftVertex First vertex offset inside the triangle.
118
+ * @param {number} rightVertex Second vertex offset inside the triangle.
119
+ * @returns {void}
120
+ */
121
+ static #swapTriangleVertices(attribute, leftVertex, rightVertex) {
122
+ const itemSize = Number(attribute?.itemSize || 0)
123
+ const array = attribute?.array
124
+ if (!array?.length || itemSize <= 0) {
125
+ return
126
+ }
127
+
128
+ for (
129
+ let offset = 0;
130
+ offset + itemSize * 3 <= array.length;
131
+ offset += itemSize * 3
132
+ ) {
133
+ for (let item = 0; item < itemSize; item += 1) {
134
+ const left = offset + leftVertex * itemSize + item
135
+ const right = offset + rightVertex * itemSize + item
136
+ const value = array[left]
137
+ array[left] = array[right]
138
+ array[right] = value
139
+ }
140
+ }
141
+ attribute.needsUpdate = true
142
+ }
143
+
144
+ /**
145
+ * Returns a geometry attribute across Three.js-compatible shapes.
146
+ * @param {any} geometry Buffer geometry.
147
+ * @param {string} name Attribute name.
148
+ * @returns {any}
149
+ */
150
+ static #getAttribute(geometry, name) {
151
+ return typeof geometry?.getAttribute === 'function'
152
+ ? geometry.getAttribute(name)
153
+ : geometry?.attributes?.[name]
154
+ }
155
+
156
+ /**
157
+ * Traverses model objects.
158
+ * @param {any} root Root object.
159
+ * @param {(object: any) => void} visitor Visitor callback.
160
+ * @returns {void}
161
+ */
162
+ static #traverseModelObjects(root, visitor) {
163
+ if (!root) {
164
+ return
165
+ }
166
+
167
+ if (typeof root.traverse === 'function') {
168
+ root.traverse(visitor)
169
+ return
170
+ }
171
+
172
+ visitor(root)
173
+ ;(Array.isArray(root.children) ? root.children : []).forEach((child) =>
174
+ PcbScene3dBoardAssemblyTransform.#traverseModelObjects(
175
+ child,
176
+ visitor
177
+ )
178
+ )
179
+ }
180
+ }
@@ -1,3 +1,5 @@
1
+ import { PcbScene3dCutoutCircleDetector } from './PcbScene3dCutoutCircleDetector.mjs'
2
+
1
3
  /**
2
4
  * Builds rounded outer-contour notches for drills that intersect board edges.
3
5
  */
@@ -5,6 +7,7 @@ export class PcbScene3dBoardEdgeCutoutBuilder {
5
7
  static #OUTER_SAMPLE_POINTS = 160
6
8
  static #EDGE_CUTOUT_SAMPLE_POINTS = 72
7
9
  static #GEOMETRY_EPSILON = 0.001
10
+ static #CONTOUR_CACHE = new WeakMap()
8
11
 
9
12
  /**
10
13
  * Resolves sampled points from one shape outline.
@@ -98,18 +101,128 @@ export class PcbScene3dBoardEdgeCutoutBuilder {
98
101
  * @returns {boolean}
99
102
  */
100
103
  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
- )
104
+ if (
105
+ !Array.isArray(hole) ||
106
+ !Array.isArray(contour) ||
107
+ hole.length < 3 ||
108
+ contour.length < 3
109
+ ) {
110
+ return false
111
+ }
112
+
113
+ const circularHole = PcbScene3dCutoutCircleDetector.resolve(
114
+ hole,
115
+ PcbScene3dBoardEdgeCutoutBuilder.#GEOMETRY_EPSILON
116
+ )
117
+ if (circularHole) {
118
+ return PcbScene3dBoardEdgeCutoutBuilder.#isCircularHoleInsideContour(
119
+ circularHole,
120
+ contour
121
+ )
122
+ }
123
+
124
+ return hole.every((point) =>
125
+ PcbScene3dBoardEdgeCutoutBuilder.#isPointStrictlyInsidePolygon(
126
+ point,
127
+ contour
128
+ )
129
+ )
130
+ }
131
+
132
+ /**
133
+ * Returns true when a circular hole lies fully inside a contour.
134
+ * @param {{ centerX: number, centerY: number, radius: number }} hole
135
+ * @param {{ x: number, y: number }[]} contour
136
+ * @returns {boolean}
137
+ */
138
+ static #isCircularHoleInsideContour(hole, contour) {
139
+ const center = { x: hole.centerX, y: hole.centerY }
140
+ if (
141
+ !PcbScene3dBoardEdgeCutoutBuilder.#isPointStrictlyInsidePolygon(
142
+ center,
143
+ contour
144
+ )
145
+ ) {
146
+ return false
147
+ }
148
+
149
+ const preparedContour =
150
+ PcbScene3dBoardEdgeCutoutBuilder.#resolvePreparedContour(contour)
151
+ const radius =
152
+ Number(hole.radius || 0) +
153
+ PcbScene3dBoardEdgeCutoutBuilder.#GEOMETRY_EPSILON
154
+ const radiusSquared = radius * radius
155
+
156
+ return preparedContour.segments.every(
157
+ (segment) =>
158
+ PcbScene3dBoardEdgeCutoutBuilder.#distanceSquaredToSegment(
159
+ center,
160
+ segment
161
+ ) > radiusSquared
162
+ )
163
+ }
164
+
165
+ /**
166
+ * Resolves cached contour segment metadata.
167
+ * @param {{ x: number, y: number }[]} contour
168
+ * @returns {{ segments: { start: { x: number, y: number }, dx: number, dy: number, lengthSquared: number }[] }}
169
+ */
170
+ static #resolvePreparedContour(contour) {
171
+ const cached =
172
+ PcbScene3dBoardEdgeCutoutBuilder.#CONTOUR_CACHE.get(contour)
173
+ if (cached) {
174
+ return cached
175
+ }
176
+
177
+ const prepared = {
178
+ segments: contour.map((start, index) => {
179
+ const end = contour[(index + 1) % contour.length]
180
+ const dx = end.x - start.x
181
+ const dy = end.y - start.y
182
+
183
+ return {
184
+ start,
185
+ dx,
186
+ dy,
187
+ lengthSquared: dx * dx + dy * dy
188
+ }
189
+ })
190
+ }
191
+ PcbScene3dBoardEdgeCutoutBuilder.#CONTOUR_CACHE.set(contour, prepared)
192
+ return prepared
193
+ }
194
+
195
+ /**
196
+ * Resolves squared distance from a point to a segment.
197
+ * @param {{ x: number, y: number }} point
198
+ * @param {{ start: { x: number, y: number }, dx: number, dy: number, lengthSquared: number }} segment
199
+ * @returns {number}
200
+ */
201
+ static #distanceSquaredToSegment(point, segment) {
202
+ if (
203
+ segment.lengthSquared <=
204
+ PcbScene3dBoardEdgeCutoutBuilder.#GEOMETRY_EPSILON
205
+ ) {
206
+ const dx = point.x - segment.start.x
207
+ const dy = point.y - segment.start.y
208
+ return dx * dx + dy * dy
209
+ }
210
+
211
+ const t = Math.max(
212
+ 0,
213
+ Math.min(
214
+ 1,
215
+ ((point.x - segment.start.x) * segment.dx +
216
+ (point.y - segment.start.y) * segment.dy) /
217
+ segment.lengthSquared
111
218
  )
112
219
  )
220
+ const closestX = segment.start.x + segment.dx * t
221
+ const closestY = segment.start.y + segment.dy * t
222
+ const dx = point.x - closestX
223
+ const dy = point.y - closestY
224
+
225
+ return dx * dx + dy * dy
113
226
  }
114
227
 
115
228
  /**
@@ -6,7 +6,7 @@ import { PcbScene3dOutlineBuilder } from './PcbScene3dOutlineBuilder.mjs'
6
6
  * Builds the board solid profile, including drilled holes.
7
7
  */
8
8
  export class PcbScene3dBoardShapeFactory {
9
- static #CURVE_SEGMENTS = 20
9
+ static #CURVE_SEGMENTS = 12
10
10
  static #PAD_HOLE_SHAPE_SLOT = 2
11
11
  static #PLATED_WALL_MATERIAL_INDEX = 2
12
12
  static #EDGE_WALL_MATERIAL_INDEX = 1
@@ -322,16 +322,32 @@ export class PcbScene3dBoardShapeFactory {
322
322
  * @param {any} THREE
323
323
  * @param {{ pads?: any[], vias?: any[] }} detail
324
324
  * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint
325
- * @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 } }[]}
325
+ * @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 }[]}
326
326
  */
327
327
  static #resolvePlatedContours(THREE, detail, normalizeBoardPoint) {
328
328
  return PcbScene3dBoardShapeFactory.#resolvePlatedDrillSpecs(detail)
329
329
  .map((drillSpec) => {
330
330
  const point = normalizeBoardPoint(drillSpec.x, drillSpec.y)
331
- const path = PcbScene3dDrillPathFactory.buildDrillPath(THREE, {
331
+ const normalizedSpec = {
332
332
  ...drillSpec,
333
333
  x: point.x,
334
334
  y: point.y
335
+ }
336
+
337
+ if (
338
+ PcbScene3dBoardShapeFactory.#isCircularDrillSpec(
339
+ normalizedSpec
340
+ )
341
+ ) {
342
+ return PcbScene3dBoardShapeFactory.#buildCircularContour(
343
+ Number(normalizedSpec.x || 0),
344
+ Number(normalizedSpec.y || 0),
345
+ Number(normalizedSpec.diameter || 0) / 2
346
+ )
347
+ }
348
+
349
+ const path = PcbScene3dDrillPathFactory.buildDrillPath(THREE, {
350
+ ...normalizedSpec
335
351
  })
336
352
  const points =
337
353
  path?.getPoints?.(
@@ -342,6 +358,51 @@ export class PcbScene3dBoardShapeFactory {
342
358
  .filter(Boolean)
343
359
  }
344
360
 
361
+ /**
362
+ * Returns true when a drill aperture can be matched as a circle.
363
+ * @param {{ diameter?: number, slotLength?: number | null }} drillSpec
364
+ * @returns {boolean}
365
+ */
366
+ static #isCircularDrillSpec(drillSpec) {
367
+ const diameter = Number(drillSpec?.diameter || 0)
368
+ const slotLength = Number(drillSpec?.slotLength || 0)
369
+
370
+ return diameter > 0 && slotLength <= diameter + 0.001
371
+ }
372
+
373
+ /**
374
+ * Builds a contour descriptor for circular plated holes.
375
+ * @param {number} centerX
376
+ * @param {number} centerY
377
+ * @param {number} radius
378
+ * @returns {{ points: { x: number, y: number }[], segments: any[], bounds: { minX: number, maxX: number, minY: number, maxY: number }, isCircular: boolean, centerX: number, centerY: number, radius: number } | null}
379
+ */
380
+ static #buildCircularContour(centerX, centerY, radius) {
381
+ if (
382
+ !Number.isFinite(centerX) ||
383
+ !Number.isFinite(centerY) ||
384
+ !Number.isFinite(radius) ||
385
+ radius <= 0
386
+ ) {
387
+ return null
388
+ }
389
+
390
+ return {
391
+ points: [],
392
+ segments: [],
393
+ bounds: {
394
+ minX: centerX - radius,
395
+ maxX: centerX + radius,
396
+ minY: centerY - radius,
397
+ maxY: centerY + radius
398
+ },
399
+ isCircular: true,
400
+ centerX,
401
+ centerY,
402
+ radius
403
+ }
404
+ }
405
+
345
406
  /**
346
407
  * Resolves deduped drill specs that represent plated holes.
347
408
  * @param {{ pads?: any[], vias?: any[] }} detail
@@ -707,7 +768,7 @@ export class PcbScene3dBoardShapeFactory {
707
768
  /**
708
769
  * Returns true when all triangle vertices lie on one drill contour.
709
770
  * @param {{ x: number, y: number }[]} points
710
- * @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 } }} contour
771
+ * @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 }} contour
711
772
  * @returns {boolean}
712
773
  */
713
774
  static #matchesContourPoints(points, contour) {
@@ -774,7 +835,7 @@ export class PcbScene3dBoardShapeFactory {
774
835
  /**
775
836
  * Returns true when a point is close to one contour boundary.
776
837
  * @param {{ x: number, y: number }} point
777
- * @param {{ 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 } }} contour
838
+ * @param {{ 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 }} contour
778
839
  * @returns {boolean}
779
840
  */
780
841
  static #isPointNearContour(point, contour) {
@@ -790,6 +851,14 @@ export class PcbScene3dBoardShapeFactory {
790
851
  return false
791
852
  }
792
853
 
854
+ if (contour.isCircular) {
855
+ return PcbScene3dBoardShapeFactory.#isPointNearCircularContour(
856
+ point,
857
+ contour,
858
+ tolerance
859
+ )
860
+ }
861
+
793
862
  for (const segment of contour.segments) {
794
863
  if (
795
864
  point.x < segment.bounds.minX - tolerance ||
@@ -813,6 +882,27 @@ export class PcbScene3dBoardShapeFactory {
813
882
  return false
814
883
  }
815
884
 
885
+ /**
886
+ * Returns true when a point lies within the tolerance ring of a circle.
887
+ * @param {{ x: number, y: number }} point
888
+ * @param {{ centerX?: number, centerY?: number, radius?: number }} contour
889
+ * @param {number} tolerance
890
+ * @returns {boolean}
891
+ */
892
+ static #isPointNearCircularContour(point, contour, tolerance) {
893
+ const radius = Number(contour.radius || 0)
894
+ const minRadius = Math.max(0, radius - tolerance)
895
+ const maxRadius = radius + tolerance
896
+ const dx = point.x - Number(contour.centerX || 0)
897
+ const dy = point.y - Number(contour.centerY || 0)
898
+ const distanceSquared = dx * dx + dy * dy
899
+
900
+ return (
901
+ distanceSquared >= minRadius * minRadius &&
902
+ distanceSquared <= maxRadius * maxRadius
903
+ )
904
+ }
905
+
816
906
  /**
817
907
  * Appends or extends a contiguous material group.
818
908
  * @param {{ start: number, count: number, materialIndex: number }[]} groups
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Resolves package body colors for generated fallback meshes.
3
+ */
4
+ export class PcbScene3dBodyColor {
5
+ /**
6
+ * Resolves a simple body color by package family.
7
+ * @param {string} family Package family.
8
+ * @returns {number}
9
+ */
10
+ static resolve(family) {
11
+ if (family === 'radial-capacitor') {
12
+ return 0xa60f10
13
+ }
14
+ if (family === 'connector-block') {
15
+ return 0xd5d6da
16
+ }
17
+ if (family === 'test-point') {
18
+ return 0x0ea5a8
19
+ }
20
+ if (family === 'chip') {
21
+ return 0xf5f5ef
22
+ }
23
+
24
+ return 0x232428
25
+ }
26
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Creates Three.js buffer attributes from importer mesh arrays.
3
+ */
4
+ export class PcbScene3dBufferAttributeFactory {
5
+ /**
6
+ * Creates a floating-point attribute.
7
+ * @param {any} THREE Three.js namespace.
8
+ * @param {ArrayLike<number>} values Source values.
9
+ * @param {number} itemSize Attribute item size.
10
+ * @returns {any}
11
+ */
12
+ static createFloat32(THREE, values, itemSize) {
13
+ return PcbScene3dBufferAttributeFactory.#create(
14
+ THREE,
15
+ values,
16
+ itemSize,
17
+ Float32Array,
18
+ 'Float32BufferAttribute'
19
+ )
20
+ }
21
+
22
+ /**
23
+ * Creates an unsigned integer attribute.
24
+ * @param {any} THREE Three.js namespace.
25
+ * @param {ArrayLike<number>} values Source values.
26
+ * @param {number} itemSize Attribute item size.
27
+ * @returns {any}
28
+ */
29
+ static createUint32(THREE, values, itemSize) {
30
+ return PcbScene3dBufferAttributeFactory.#create(
31
+ THREE,
32
+ values,
33
+ itemSize,
34
+ Uint32Array,
35
+ 'Uint32BufferAttribute'
36
+ )
37
+ }
38
+
39
+ /**
40
+ * Returns true when a value is an array-like numeric sequence.
41
+ * @param {unknown} values Candidate values.
42
+ * @returns {boolean}
43
+ */
44
+ static isNumberSequence(values) {
45
+ return (
46
+ Array.isArray(values) ||
47
+ PcbScene3dBufferAttributeFactory.#isTypedNumberArray(values)
48
+ )
49
+ }
50
+
51
+ /**
52
+ * Creates a buffer attribute with the most efficient constructor available.
53
+ * @param {any} THREE Three.js namespace.
54
+ * @param {ArrayLike<number>} values Source values.
55
+ * @param {number} itemSize Attribute item size.
56
+ * @param {Float32ArrayConstructor | Uint32ArrayConstructor} TypedArrayConstructor Fallback typed array constructor.
57
+ * @param {string} fallbackConstructorName Legacy Three attribute constructor name.
58
+ * @returns {any}
59
+ */
60
+ static #create(
61
+ THREE,
62
+ values,
63
+ itemSize,
64
+ TypedArrayConstructor,
65
+ fallbackConstructorName
66
+ ) {
67
+ const array = PcbScene3dBufferAttributeFactory.#normalizeValues(
68
+ values,
69
+ TypedArrayConstructor
70
+ )
71
+
72
+ if (typeof THREE?.BufferAttribute === 'function') {
73
+ return new THREE.BufferAttribute(array, itemSize)
74
+ }
75
+
76
+ if (typeof THREE?.[fallbackConstructorName] === 'function') {
77
+ return new THREE[fallbackConstructorName](array, itemSize)
78
+ }
79
+
80
+ return {
81
+ array,
82
+ itemSize,
83
+ count: Math.floor(Number(array.length || 0) / itemSize),
84
+ isBufferAttribute: true
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Normalizes plain arrays while preserving importer-owned typed arrays.
90
+ * @param {ArrayLike<number> | undefined | null} values Source values.
91
+ * @param {Float32ArrayConstructor | Uint32ArrayConstructor} TypedArrayConstructor Fallback typed array constructor.
92
+ * @returns {ArrayLike<number>}
93
+ */
94
+ static #normalizeValues(values, TypedArrayConstructor) {
95
+ if (PcbScene3dBufferAttributeFactory.#isTypedNumberArray(values)) {
96
+ return values
97
+ }
98
+
99
+ return new TypedArrayConstructor(Array.from(values || []))
100
+ }
101
+
102
+ /**
103
+ * Returns true when a sequence is already backed by a typed numeric array.
104
+ * @param {unknown} values Candidate values.
105
+ * @returns {boolean}
106
+ */
107
+ static #isTypedNumberArray(values) {
108
+ return (
109
+ ArrayBuffer.isView(values) &&
110
+ !(values instanceof DataView) &&
111
+ !PcbScene3dBufferAttributeFactory.#isBigIntTypedArray(values)
112
+ )
113
+ }
114
+
115
+ /**
116
+ * Returns true when a typed-array view stores bigint values.
117
+ * @param {unknown} values Candidate values.
118
+ * @returns {boolean}
119
+ */
120
+ static #isBigIntTypedArray(values) {
121
+ const tag = Object.prototype.toString.call(values)
122
+
123
+ return (
124
+ tag === '[object BigInt64Array]' ||
125
+ tag === '[object BigUint64Array]'
126
+ )
127
+ }
128
+ }