pcb-scene3d-viewer 1.1.19 → 1.1.21

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.1.19",
3
+ "version": "1.1.21",
4
4
  "description": "Reusable Three.js PCB 3D scene viewer for normalized ECAD and CircuitJSON scene descriptions",
5
5
  "keywords": [
6
6
  "pcb",
@@ -51,9 +51,10 @@
51
51
  "check:format": "prettier --check ."
52
52
  },
53
53
  "dependencies": {
54
+ "@sunbox/occt-import-js": "^0.0.25",
54
55
  "circuitjson-toolkit": "^1.0.1",
56
+ "earcut": "^3.0.2",
55
57
  "fflate": "^0.8.2",
56
- "@sunbox/occt-import-js": "^0.0.25",
57
58
  "three": "^0.183.2"
58
59
  },
59
60
  "devDependencies": {
@@ -0,0 +1,467 @@
1
+ import earcut from 'earcut'
2
+ import { PcbAssemblyMeshUtils } from './PcbAssemblyMeshUtils.mjs'
3
+
4
+ const MAX_HOLE_POINTS = 48
5
+ const MIN_HOLE_POINTS = 8
6
+ const GEOMETRY_EPSILON = 0.001
7
+
8
+ /**
9
+ * Builds PCB substrate meshes, including through-board drill cutouts.
10
+ */
11
+ export class PcbAssemblyBoardSubstrateBuilder {
12
+ /**
13
+ * Builds a board prism from an outer outline and available drill holes.
14
+ * @param {string} name Mesh name.
15
+ * @param {number[][]} outlinePoints Board outline points in mils.
16
+ * @param {object} sceneDescription Prepared scene description.
17
+ * @param {number} thickness Board thickness in mils.
18
+ * @param {number[]} color Board RGB color.
19
+ * @returns {object | null}
20
+ */
21
+ static build(name, outlinePoints, sceneDescription, thickness, color) {
22
+ const outer = PcbAssemblyMeshUtils.cleanLoop(outlinePoints)
23
+ if (outer.length < 3) {
24
+ return null
25
+ }
26
+
27
+ const holes = PcbAssemblyBoardSubstrateBuilder.#boardHoleLoops(
28
+ sceneDescription,
29
+ outer
30
+ )
31
+ if (!holes.length) {
32
+ return PcbAssemblyMeshUtils.prism(name, outer, 0, thickness, color)
33
+ }
34
+
35
+ return PcbAssemblyBoardSubstrateBuilder.#prismWithHoles(
36
+ name,
37
+ outer,
38
+ holes,
39
+ thickness,
40
+ color
41
+ )
42
+ }
43
+
44
+ /**
45
+ * Builds an extruded board polygon with inner drill walls.
46
+ * @param {string} name Mesh name.
47
+ * @param {number[][]} outer Outer board loop.
48
+ * @param {number[][][]} holes Inner hole loops.
49
+ * @param {number} thickness Board thickness in mils.
50
+ * @param {number[]} color Board RGB color.
51
+ * @returns {object | null}
52
+ */
53
+ static #prismWithHoles(name, outer, holes, thickness, color) {
54
+ const loops = [outer, ...holes]
55
+ const holeIndexes = []
56
+ const points = []
57
+ const flat = []
58
+
59
+ for (const loop of loops) {
60
+ if (points.length) {
61
+ holeIndexes.push(points.length)
62
+ }
63
+ for (const point of loop) {
64
+ points.push(point)
65
+ flat.push(point[0], point[1])
66
+ }
67
+ }
68
+
69
+ const triangles = earcut(flat, holeIndexes, 2)
70
+ if (!triangles.length) {
71
+ return PcbAssemblyMeshUtils.prism(name, outer, 0, thickness, color)
72
+ }
73
+
74
+ const halfThickness = Math.max(Number(thickness || 0), 0.001) / 2
75
+ const bottomZ = -halfThickness
76
+ const topZ = halfThickness
77
+ const topOffset = points.length
78
+ const vertices = [
79
+ ...points.map((point) => [point[0], point[1], bottomZ]),
80
+ ...points.map((point) => [point[0], point[1], topZ])
81
+ ]
82
+ const faces = []
83
+
84
+ for (let index = 0; index + 2 < triangles.length; index += 3) {
85
+ const a = triangles[index]
86
+ const b = triangles[index + 1]
87
+ const c = triangles[index + 2]
88
+ faces.push([c, b, a])
89
+ faces.push([a + topOffset, b + topOffset, c + topOffset])
90
+ }
91
+
92
+ let loopStart = 0
93
+ PcbAssemblyBoardSubstrateBuilder.#appendLoopWalls(
94
+ faces,
95
+ loopStart,
96
+ outer.length,
97
+ topOffset,
98
+ false
99
+ )
100
+ loopStart += outer.length
101
+ for (const hole of holes) {
102
+ PcbAssemblyBoardSubstrateBuilder.#appendLoopWalls(
103
+ faces,
104
+ loopStart,
105
+ hole.length,
106
+ topOffset,
107
+ true
108
+ )
109
+ loopStart += hole.length
110
+ }
111
+
112
+ return {
113
+ name,
114
+ vertices,
115
+ faces,
116
+ ...(Array.isArray(color) ? { color } : {})
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Appends side-wall faces for one polygon loop.
122
+ * @param {number[][]} faces Mutable face list.
123
+ * @param {number} start Loop start index.
124
+ * @param {number} length Loop length.
125
+ * @param {number} topOffset Top vertex offset.
126
+ * @param {boolean} reverse Whether to reverse wall orientation.
127
+ * @returns {void}
128
+ */
129
+ static #appendLoopWalls(faces, start, length, topOffset, reverse) {
130
+ for (let index = 0; index < length; index += 1) {
131
+ const current = start + index
132
+ const next = start + ((index + 1) % length)
133
+ faces.push(
134
+ reverse
135
+ ? [next, current, current + topOffset, next + topOffset]
136
+ : [current, next, next + topOffset, current + topOffset]
137
+ )
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Resolves all usable board hole loops.
143
+ * @param {object} sceneDescription Prepared scene description.
144
+ * @param {number[][]} outer Board outline loop.
145
+ * @returns {number[][][]}
146
+ */
147
+ static #boardHoleLoops(sceneDescription, outer) {
148
+ const contourLoops =
149
+ PcbAssemblyBoardSubstrateBuilder.#drillCutoutLoops(sceneDescription)
150
+ const rawLoops = contourLoops.length
151
+ ? contourLoops
152
+ : PcbAssemblyBoardSubstrateBuilder.#primitiveHoleLoops(
153
+ sceneDescription
154
+ )
155
+ const seen = new Set()
156
+ const holes = []
157
+
158
+ for (const loop of rawLoops) {
159
+ const normalized =
160
+ PcbAssemblyBoardSubstrateBuilder.#normalizeHoleLoop(loop)
161
+ if (
162
+ normalized.length < 3 ||
163
+ Math.abs(
164
+ PcbAssemblyBoardSubstrateBuilder.#signedArea(normalized)
165
+ ) <= GEOMETRY_EPSILON
166
+ ) {
167
+ continue
168
+ }
169
+
170
+ const center =
171
+ PcbAssemblyBoardSubstrateBuilder.#centroid(normalized)
172
+ if (
173
+ !PcbAssemblyBoardSubstrateBuilder.#pointInPolygon(center, outer)
174
+ ) {
175
+ continue
176
+ }
177
+
178
+ const signature =
179
+ PcbAssemblyBoardSubstrateBuilder.#holeSignature(normalized)
180
+ if (seen.has(signature)) {
181
+ continue
182
+ }
183
+ seen.add(signature)
184
+ holes.push(normalized)
185
+ }
186
+
187
+ return holes
188
+ }
189
+
190
+ /**
191
+ * Reads existing drill cutout contours from scene silkscreen details.
192
+ * @param {object} sceneDescription Prepared scene description.
193
+ * @returns {Array<Array<object | number[]>>}
194
+ */
195
+ static #drillCutoutLoops(sceneDescription) {
196
+ const silkscreen = sceneDescription?.detail?.silkscreen || {}
197
+ return ['top', 'bottom'].flatMap((side) =>
198
+ Array.isArray(silkscreen?.[side]?.drillCutouts)
199
+ ? silkscreen[side].drillCutouts
200
+ : []
201
+ )
202
+ }
203
+
204
+ /**
205
+ * Builds fallback hole contours from pads and vias.
206
+ * @param {object} sceneDescription Prepared scene description.
207
+ * @returns {number[][][]}
208
+ */
209
+ static #primitiveHoleLoops(sceneDescription) {
210
+ const detail = sceneDescription?.detail || {}
211
+ return [
212
+ ...PcbAssemblyBoardSubstrateBuilder.#array(detail.pads).map((pad) =>
213
+ PcbAssemblyBoardSubstrateBuilder.#padHoleLoop(pad)
214
+ ),
215
+ ...PcbAssemblyBoardSubstrateBuilder.#array(detail.vias).map((via) =>
216
+ PcbAssemblyBoardSubstrateBuilder.#viaHoleLoop(via)
217
+ )
218
+ ].filter(Boolean)
219
+ }
220
+
221
+ /**
222
+ * Builds a fallback pad drill loop.
223
+ * @param {object} pad Pad primitive.
224
+ * @returns {number[][] | null}
225
+ */
226
+ static #padHoleLoop(pad) {
227
+ const diameter = PcbAssemblyBoardSubstrateBuilder.#firstPositive([
228
+ pad?.holeDiameter,
229
+ pad?.drillDiameter,
230
+ pad?.holeSize,
231
+ pad?.drill,
232
+ pad?.holeGeometry?.diameter,
233
+ pad?.holeGeometry?.width
234
+ ])
235
+ if (!diameter) {
236
+ return null
237
+ }
238
+
239
+ const x = Number(pad?.x)
240
+ const y = Number(pad?.y)
241
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
242
+ return null
243
+ }
244
+
245
+ const slotLength = PcbAssemblyBoardSubstrateBuilder.#firstPositive([
246
+ pad?.holeSlotLength,
247
+ pad?.slotLength,
248
+ pad?.holeGeometry?.slotLength,
249
+ pad?.holeGeometry?.length
250
+ ])
251
+ if (slotLength > diameter) {
252
+ return PcbAssemblyBoardSubstrateBuilder.#rotatedPoints(
253
+ PcbAssemblyMeshUtils.capsulePoints(
254
+ x - (slotLength - diameter) / 2,
255
+ y,
256
+ x + (slotLength - diameter) / 2,
257
+ y,
258
+ diameter / 2
259
+ ),
260
+ x,
261
+ y,
262
+ Number(pad?.holeRotation ?? pad?.rotation ?? 0)
263
+ )
264
+ }
265
+
266
+ return PcbAssemblyMeshUtils.circlePoints(x, y, diameter / 2, 24)
267
+ }
268
+
269
+ /**
270
+ * Builds a fallback via drill loop.
271
+ * @param {object} via Via primitive.
272
+ * @returns {number[][] | null}
273
+ */
274
+ static #viaHoleLoop(via) {
275
+ const diameter = PcbAssemblyBoardSubstrateBuilder.#firstPositive([
276
+ via?.holeDiameter,
277
+ via?.drillDiameter,
278
+ via?.holeSize,
279
+ via?.drill
280
+ ])
281
+ const x = Number(via?.x)
282
+ const y = Number(via?.y)
283
+
284
+ return diameter && Number.isFinite(x) && Number.isFinite(y)
285
+ ? PcbAssemblyMeshUtils.circlePoints(x, y, diameter / 2, 24)
286
+ : null
287
+ }
288
+
289
+ /**
290
+ * Normalizes one candidate hole contour.
291
+ * @param {Array<object | number[]>} loop Candidate loop.
292
+ * @returns {number[][]}
293
+ */
294
+ static #normalizeHoleLoop(loop) {
295
+ const points = PcbAssemblyMeshUtils.cleanLoop(
296
+ PcbAssemblyBoardSubstrateBuilder.#array(loop).map((point) => [
297
+ Number(point?.x ?? point?.[0]),
298
+ Number(point?.y ?? point?.[1])
299
+ ])
300
+ )
301
+
302
+ return PcbAssemblyBoardSubstrateBuilder.#simplifyLoop(points)
303
+ }
304
+
305
+ /**
306
+ * Reduces very dense circular cutouts to export-friendly loops.
307
+ * @param {number[][]} loop Source loop.
308
+ * @returns {number[][]}
309
+ */
310
+ static #simplifyLoop(loop) {
311
+ if (loop.length <= MAX_HOLE_POINTS) {
312
+ return loop
313
+ }
314
+
315
+ const target = Math.max(MAX_HOLE_POINTS, MIN_HOLE_POINTS)
316
+ const step = loop.length / target
317
+ return PcbAssemblyMeshUtils.cleanLoop(
318
+ Array.from({ length: target }, (_entry, index) => {
319
+ return loop[Math.floor(index * step)]
320
+ })
321
+ )
322
+ }
323
+
324
+ /**
325
+ * Rotates points around a center.
326
+ * @param {number[][]} points Source points.
327
+ * @param {number} centerX Center X.
328
+ * @param {number} centerY Center Y.
329
+ * @param {number} rotationDeg Rotation in degrees.
330
+ * @returns {number[][]}
331
+ */
332
+ static #rotatedPoints(points, centerX, centerY, rotationDeg) {
333
+ if (Math.abs(Number(rotationDeg || 0)) < GEOMETRY_EPSILON) {
334
+ return points
335
+ }
336
+
337
+ const angle = (Number(rotationDeg || 0) * Math.PI) / 180
338
+ const cos = Math.cos(angle)
339
+ const sin = Math.sin(angle)
340
+ return points.map((point) => {
341
+ const dx = point[0] - centerX
342
+ const dy = point[1] - centerY
343
+ return [
344
+ centerX + dx * cos - dy * sin,
345
+ centerY + dx * sin + dy * cos
346
+ ]
347
+ })
348
+ }
349
+
350
+ /**
351
+ * Builds a stable duplicate key for one hole loop.
352
+ * @param {number[][]} loop Hole loop.
353
+ * @returns {string}
354
+ */
355
+ static #holeSignature(loop) {
356
+ const bounds = PcbAssemblyBoardSubstrateBuilder.#bounds(loop)
357
+ return [bounds.minX, bounds.minY, bounds.maxX, bounds.maxY]
358
+ .map((value) => String(Math.round(value * 10) / 10))
359
+ .join(':')
360
+ }
361
+
362
+ /**
363
+ * Computes point bounds.
364
+ * @param {number[][]} points Points.
365
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number }}
366
+ */
367
+ static #bounds(points) {
368
+ return points.reduce(
369
+ (bounds, point) => ({
370
+ minX: Math.min(bounds.minX, point[0]),
371
+ minY: Math.min(bounds.minY, point[1]),
372
+ maxX: Math.max(bounds.maxX, point[0]),
373
+ maxY: Math.max(bounds.maxY, point[1])
374
+ }),
375
+ {
376
+ minX: Infinity,
377
+ minY: Infinity,
378
+ maxX: -Infinity,
379
+ maxY: -Infinity
380
+ }
381
+ )
382
+ }
383
+
384
+ /**
385
+ * Computes a polygon centroid approximation.
386
+ * @param {number[][]} points Polygon points.
387
+ * @returns {number[]}
388
+ */
389
+ static #centroid(points) {
390
+ return [
391
+ points.reduce((sum, point) => sum + point[0], 0) / points.length,
392
+ points.reduce((sum, point) => sum + point[1], 0) / points.length
393
+ ]
394
+ }
395
+
396
+ /**
397
+ * Computes signed polygon area.
398
+ * @param {number[][]} points Polygon points.
399
+ * @returns {number}
400
+ */
401
+ static #signedArea(points) {
402
+ let area = 0
403
+ for (let index = 0; index < points.length; index += 1) {
404
+ const current = points[index]
405
+ const next = points[(index + 1) % points.length]
406
+ area += current[0] * next[1] - next[0] * current[1]
407
+ }
408
+ return area / 2
409
+ }
410
+
411
+ /**
412
+ * Tests whether a point lies inside a polygon.
413
+ * @param {number[]} point Candidate point.
414
+ * @param {number[][]} polygon Polygon points.
415
+ * @returns {boolean}
416
+ */
417
+ static #pointInPolygon(point, polygon) {
418
+ let inside = false
419
+
420
+ for (
421
+ let index = 0, previous = polygon.length - 1;
422
+ index < polygon.length;
423
+ previous = index, index += 1
424
+ ) {
425
+ const currentPoint = polygon[index]
426
+ const previousPoint = polygon[previous]
427
+ const crosses =
428
+ currentPoint[1] > point[1] !== previousPoint[1] > point[1]
429
+ const xAtY =
430
+ ((previousPoint[0] - currentPoint[0]) *
431
+ (point[1] - currentPoint[1])) /
432
+ (previousPoint[1] - currentPoint[1]) +
433
+ currentPoint[0]
434
+
435
+ if (crosses && point[0] < xAtY) {
436
+ inside = !inside
437
+ }
438
+ }
439
+
440
+ return inside
441
+ }
442
+
443
+ /**
444
+ * Returns the first positive finite number.
445
+ * @param {unknown[]} values Candidate values.
446
+ * @returns {number}
447
+ */
448
+ static #firstPositive(values) {
449
+ for (const value of values) {
450
+ const number = Number(value)
451
+ if (Number.isFinite(number) && number > 0) {
452
+ return number
453
+ }
454
+ }
455
+
456
+ return 0
457
+ }
458
+
459
+ /**
460
+ * Normalizes a value to an array.
461
+ * @param {unknown} value Candidate value.
462
+ * @returns {any[]}
463
+ */
464
+ static #array(value) {
465
+ return Array.isArray(value) ? value : []
466
+ }
467
+ }
@@ -0,0 +1,19 @@
1
+ const MIL_TO_MM = 0.0254
2
+
3
+ /**
4
+ * Maps internal PCB Z-up mesh coordinates into the exported viewer frame.
5
+ */
6
+ export class PcbAssemblyExportCoordinateFrame {
7
+ /**
8
+ * Converts one internal vertex from mils into exported millimetres.
9
+ * @param {number[]} vertex Internal mesh vertex in mils.
10
+ * @returns {number[]}
11
+ */
12
+ static vertexMilToMm(vertex) {
13
+ const x = Number(vertex?.[0] || 0)
14
+ const y = Number(vertex?.[1] || 0)
15
+ const z = Number(vertex?.[2] || 0)
16
+
17
+ return [x * MIL_TO_MM, z * MIL_TO_MM, -y * MIL_TO_MM]
18
+ }
19
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Tracks measured progress while PCB assembly meshes are built.
3
+ */
4
+ export class PcbAssemblyGeometryBuildProgress {
5
+ /** @type {((progress: { value: number, message: string }) => void) | null} */
6
+ #onProgress
7
+
8
+ /** @type {number} */
9
+ #totalUnits
10
+
11
+ /** @type {number} */
12
+ #completedUnits
13
+
14
+ /** @type {number} */
15
+ #lastValue
16
+
17
+ /** @type {number} */
18
+ #startValue
19
+
20
+ /** @type {number} */
21
+ #endValue
22
+
23
+ /**
24
+ * @param {{ totalUnits?: number, onProgress?: (progress: { value: number, message: string }) => void, startValue?: number, endValue?: number }} options Progress options.
25
+ */
26
+ constructor(options = {}) {
27
+ this.#onProgress =
28
+ typeof options.onProgress === 'function' ? options.onProgress : null
29
+ this.#totalUnits = Math.max(Number(options.totalUnits || 1), 1)
30
+ this.#completedUnits = 0
31
+ this.#lastValue = -1
32
+ this.#startValue = PcbAssemblyGeometryBuildProgress.#clampPercent(
33
+ options.startValue ?? 10
34
+ )
35
+ this.#endValue = Math.max(
36
+ this.#startValue,
37
+ PcbAssemblyGeometryBuildProgress.#clampPercent(
38
+ options.endValue ?? 75
39
+ )
40
+ )
41
+ }
42
+
43
+ /**
44
+ * Creates a progress tracker sized to the prepared scene detail.
45
+ * @param {{ detail?: object, externalPlacements?: object[] }} sceneDescription Prepared scene description.
46
+ * @param {((progress: { value: number, message: string }) => void) | undefined} onProgress Progress callback.
47
+ * @param {{ startValue?: number, endValue?: number }} [options] Progress range options.
48
+ * @returns {PcbAssemblyGeometryBuildProgress}
49
+ */
50
+ static create(sceneDescription, onProgress, options = {}) {
51
+ return new PcbAssemblyGeometryBuildProgress({
52
+ onProgress,
53
+ startValue: options.startValue,
54
+ endValue: options.endValue,
55
+ totalUnits:
56
+ 1 +
57
+ PcbAssemblyGeometryBuildProgress.#countCopperUnits(
58
+ sceneDescription?.detail || {}
59
+ ) +
60
+ PcbAssemblyGeometryBuildProgress.#countSilkscreenUnits(
61
+ sceneDescription?.detail?.silkscreen || {}
62
+ ) +
63
+ PcbAssemblyGeometryBuildProgress.#countComponentUnits(
64
+ sceneDescription
65
+ )
66
+ })
67
+ }
68
+
69
+ /**
70
+ * Advances progress by measured build units.
71
+ * @param {number} units Completed units.
72
+ * @param {string} message Progress message.
73
+ * @returns {Promise<void>}
74
+ */
75
+ async advance(units, message) {
76
+ this.#completedUnits += Math.max(Number(units || 0), 0)
77
+ await this.#emit(
78
+ Math.min(
79
+ this.#scaledValue(this.#completedUnits / this.#totalUnits),
80
+ this.#endValue - 1
81
+ ),
82
+ message
83
+ )
84
+ }
85
+
86
+ /**
87
+ * Marks geometry building complete.
88
+ * @param {string} message Completion message.
89
+ * @returns {Promise<void>}
90
+ */
91
+ async finish(message) {
92
+ this.#completedUnits = this.#totalUnits
93
+ await this.#emit(this.#endValue, message)
94
+ }
95
+
96
+ /**
97
+ * Emits progress when the visible integer value changes.
98
+ * @param {number} value Progress value.
99
+ * @param {string} message Progress message.
100
+ * @returns {Promise<void>}
101
+ */
102
+ async #emit(value, message) {
103
+ if (!this.#onProgress || value === this.#lastValue) {
104
+ return
105
+ }
106
+
107
+ this.#lastValue = value
108
+ this.#onProgress({
109
+ value,
110
+ message
111
+ })
112
+ await PcbAssemblyGeometryBuildProgress.#yieldToBrowser()
113
+ }
114
+
115
+ /**
116
+ * Maps a unit completion ratio into the configured progress range.
117
+ * @param {number} ratio Completion ratio.
118
+ * @returns {number}
119
+ */
120
+ #scaledValue(ratio) {
121
+ const clampedRatio = Math.max(Math.min(Number(ratio || 0), 1), 0)
122
+ return Math.round(
123
+ this.#startValue +
124
+ clampedRatio * (this.#endValue - this.#startValue)
125
+ )
126
+ }
127
+
128
+ /**
129
+ * Counts measured copper work units.
130
+ * @param {object} detail Scene detail.
131
+ * @returns {number}
132
+ */
133
+ static #countCopperUnits(detail) {
134
+ return (
135
+ PcbAssemblyGeometryBuildProgress.#array(detail.tracks).length +
136
+ PcbAssemblyGeometryBuildProgress.#array(detail.arcs).length +
137
+ PcbAssemblyGeometryBuildProgress.#array(detail.fills).length +
138
+ PcbAssemblyGeometryBuildProgress.#array(detail.polygons).length +
139
+ PcbAssemblyGeometryBuildProgress.#array(detail.pads).length * 2 +
140
+ PcbAssemblyGeometryBuildProgress.#array(detail.vias).length +
141
+ PcbAssemblyGeometryBuildProgress.#array(detail.copperTexts).length
142
+ )
143
+ }
144
+
145
+ /**
146
+ * Counts measured silkscreen work units.
147
+ * @param {object} silkscreen Scene silkscreen detail.
148
+ * @returns {number}
149
+ */
150
+ static #countSilkscreenUnits(silkscreen) {
151
+ return ['top', 'bottom'].reduce((total, side) => {
152
+ const detail = silkscreen?.[side] || {}
153
+ return (
154
+ total +
155
+ PcbAssemblyGeometryBuildProgress.#array(detail.tracks).length +
156
+ PcbAssemblyGeometryBuildProgress.#array(detail.arcs).length +
157
+ PcbAssemblyGeometryBuildProgress.#array(detail.fills).length +
158
+ PcbAssemblyGeometryBuildProgress.#array(detail.texts).length
159
+ )
160
+ }, 0)
161
+ }
162
+
163
+ /**
164
+ * Counts component model loading work units.
165
+ * @param {{ externalPlacements?: object[] }} sceneDescription Prepared scene description.
166
+ * @returns {number}
167
+ */
168
+ static #countComponentUnits(sceneDescription) {
169
+ return PcbAssemblyGeometryBuildProgress.#array(
170
+ sceneDescription?.externalPlacements
171
+ ).filter((placement) => placement?.externalModel).length
172
+ }
173
+
174
+ /**
175
+ * Normalizes a value to an array.
176
+ * @param {unknown} value Candidate value.
177
+ * @returns {any[]}
178
+ */
179
+ static #array(value) {
180
+ return Array.isArray(value) ? value : []
181
+ }
182
+
183
+ /**
184
+ * Clamps a progress percentage.
185
+ * @param {number} value Candidate percentage.
186
+ * @returns {number}
187
+ */
188
+ static #clampPercent(value) {
189
+ return Math.max(Math.min(Number(value || 0), 100), 0)
190
+ }
191
+
192
+ /**
193
+ * Yields control so browsers can paint the progress dialog.
194
+ * @returns {Promise<void>}
195
+ */
196
+ static #yieldToBrowser() {
197
+ return new Promise((resolve) => {
198
+ globalThis.setTimeout?.(resolve, 0) || resolve()
199
+ })
200
+ }
201
+ }