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.
@@ -0,0 +1,994 @@
1
+ import { PcbAssemblyBoardSubstrateBuilder } from './PcbAssemblyBoardSubstrateBuilder.mjs'
2
+ import { PcbAssemblyMeshUtils } from './PcbAssemblyMeshUtils.mjs'
3
+
4
+ const COPPER_THICKNESS_MIL = 2.2
5
+ const SILKSCREEN_THICKNESS_MIL = 0.8
6
+ const BOARD_COLOR = [0.05, 0.32, 0.18]
7
+ const COPPER_COLOR = [0.85, 0.62, 0.12]
8
+ const SILKSCREEN_COLOR = [0.96, 0.95, 0.9]
9
+ const COMPONENT_COLOR = [0.55, 0.56, 0.58]
10
+
11
+ /**
12
+ * Converts a prepared 3D scene description into exportable faceted meshes.
13
+ */
14
+ export class PcbAssemblyGeometryBuilder {
15
+ /**
16
+ * Builds assembly meshes and diagnostics.
17
+ * @param {{ board?: object, detail?: object, components?: object[], externalPlacements?: object[] }} sceneDescription Prepared scene description.
18
+ * @param {{ modelMeshLoader?: (placement: object) => Promise<object | object[]>, progress?: { advance?: (units: number, message: string) => Promise<void>, finish?: (message: string) => Promise<void> } }} [options] Build options.
19
+ * @returns {Promise<{ meshes: object[], diagnostics: object[] }>}
20
+ */
21
+ static async build(sceneDescription, options = {}) {
22
+ const diagnostics = []
23
+ const progress = options.progress || null
24
+ const boardMeshes =
25
+ PcbAssemblyGeometryBuilder.#buildBoardMeshes(sceneDescription)
26
+ await progress?.advance?.(1, 'Building board substrate')
27
+ const copperMeshes =
28
+ await PcbAssemblyGeometryBuilder.#buildCopperMeshes(
29
+ sceneDescription,
30
+ progress
31
+ )
32
+ const silkscreenMeshes =
33
+ await PcbAssemblyGeometryBuilder.#buildSilkscreenMeshes(
34
+ sceneDescription,
35
+ progress
36
+ )
37
+ const pcbMeshes = [...boardMeshes, ...copperMeshes, ...silkscreenMeshes]
38
+ const meshes = [
39
+ ...PcbAssemblyGeometryBuilder.#translateMeshes(
40
+ pcbMeshes,
41
+ PcbAssemblyGeometryBuilder.#boardLocalOffset(sceneDescription)
42
+ ),
43
+ ...(await PcbAssemblyGeometryBuilder.#buildComponentMeshes(
44
+ sceneDescription,
45
+ options,
46
+ diagnostics,
47
+ progress
48
+ ))
49
+ ]
50
+
51
+ PcbAssemblyGeometryBuilder.#appendMissingModelDiagnostics(
52
+ sceneDescription,
53
+ diagnostics
54
+ )
55
+ await progress?.finish?.('Geometry meshes ready')
56
+ return {
57
+ meshes: meshes.filter((mesh) => mesh?.vertices?.length),
58
+ diagnostics
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Builds substrate meshes.
64
+ * @param {{ board?: object }} sceneDescription Prepared scene description.
65
+ * @returns {object[]}
66
+ */
67
+ static #buildBoardMeshes(sceneDescription) {
68
+ const board = sceneDescription?.board || {}
69
+ const width = Math.max(Number(board.widthMil || 0), 1)
70
+ const height = Math.max(Number(board.heightMil || 0), 1)
71
+ const thickness = Math.max(Number(board.thicknessMil || 63), 1)
72
+ const outlinePoints =
73
+ PcbAssemblyGeometryBuilder.#boardOutlinePoints(board)
74
+ const outlineMesh = PcbAssemblyBoardSubstrateBuilder.build(
75
+ 'board',
76
+ outlinePoints,
77
+ sceneDescription,
78
+ thickness,
79
+ BOARD_COLOR
80
+ )
81
+ if (outlineMesh) {
82
+ return [outlineMesh]
83
+ }
84
+
85
+ return [
86
+ PcbAssemblyMeshUtils.box('board', {
87
+ width,
88
+ depth: height,
89
+ height: thickness,
90
+ color: BOARD_COLOR
91
+ })
92
+ ]
93
+ }
94
+
95
+ /**
96
+ * Builds copper, pads, and via meshes.
97
+ * @param {{ board?: object, detail?: object }} sceneDescription Prepared scene description.
98
+ * @param {{ advance?: (units: number, message: string) => Promise<void> } | null} progress Progress tracker.
99
+ * @returns {Promise<object[]>}
100
+ */
101
+ static async #buildCopperMeshes(sceneDescription, progress = null) {
102
+ const detail = sceneDescription?.detail || {}
103
+ const board = sceneDescription?.board || {}
104
+ const topZ =
105
+ Number(board.thicknessMil || 63) / 2 + COPPER_THICKNESS_MIL / 2
106
+ const bottomZ = -topZ
107
+ const meshes = []
108
+
109
+ const tracks = PcbAssemblyGeometryBuilder.#array(detail.tracks)
110
+ for (let index = 0; index < tracks.length; index += 1) {
111
+ const track = tracks[index]
112
+ const side = PcbAssemblyGeometryBuilder.#resolveSide(track)
113
+ const mesh = PcbAssemblyGeometryBuilder.#trackMesh(
114
+ 'copper-' + side + '-track-' + (index + 1),
115
+ track,
116
+ side === 'bottom' ? bottomZ : topZ,
117
+ COPPER_THICKNESS_MIL,
118
+ COPPER_COLOR
119
+ )
120
+ if (mesh) meshes.push(mesh)
121
+ await progress?.advance?.(
122
+ 1,
123
+ 'Building copper tracks ' + (index + 1) + '/' + tracks.length
124
+ )
125
+ }
126
+
127
+ const arcs = PcbAssemblyGeometryBuilder.#array(detail.arcs)
128
+ for (let index = 0; index < arcs.length; index += 1) {
129
+ const arc = arcs[index]
130
+ const side = PcbAssemblyGeometryBuilder.#resolveSide(arc)
131
+ const mesh = PcbAssemblyMeshUtils.prism(
132
+ 'copper-' + side + '-arc-' + (index + 1),
133
+ PcbAssemblyMeshUtils.arcBandPoints(arc),
134
+ side === 'bottom' ? bottomZ : topZ,
135
+ COPPER_THICKNESS_MIL,
136
+ COPPER_COLOR
137
+ )
138
+ if (mesh) meshes.push(mesh)
139
+ await progress?.advance?.(
140
+ 1,
141
+ 'Building copper arcs ' + (index + 1) + '/' + arcs.length
142
+ )
143
+ }
144
+
145
+ const fills = [
146
+ ...PcbAssemblyGeometryBuilder.#array(detail.fills),
147
+ ...PcbAssemblyGeometryBuilder.#array(detail.polygons)
148
+ ]
149
+ for (let index = 0; index < fills.length; index += 1) {
150
+ const fill = fills[index]
151
+ const side = PcbAssemblyGeometryBuilder.#resolveSide(fill)
152
+ const mesh = PcbAssemblyMeshUtils.prism(
153
+ 'copper-' + side + '-fill-' + (index + 1),
154
+ PcbAssemblyGeometryBuilder.#points(fill),
155
+ side === 'bottom' ? bottomZ : topZ,
156
+ COPPER_THICKNESS_MIL,
157
+ COPPER_COLOR
158
+ )
159
+ if (mesh) meshes.push(mesh)
160
+ await progress?.advance?.(
161
+ 1,
162
+ 'Building copper fills ' + (index + 1) + '/' + fills.length
163
+ )
164
+ }
165
+
166
+ const pads = PcbAssemblyGeometryBuilder.#array(detail.pads)
167
+ for (let index = 0; index < pads.length; index += 1) {
168
+ const pad = pads[index]
169
+ const topMesh = PcbAssemblyGeometryBuilder.#padMesh(
170
+ 'pad-top-' + (index + 1),
171
+ pad,
172
+ 'top',
173
+ topZ,
174
+ COPPER_THICKNESS_MIL
175
+ )
176
+ const bottomMesh = PcbAssemblyGeometryBuilder.#padMesh(
177
+ 'pad-bottom-' + (index + 1),
178
+ pad,
179
+ 'bottom',
180
+ bottomZ,
181
+ COPPER_THICKNESS_MIL
182
+ )
183
+ if (topMesh) meshes.push(topMesh)
184
+ await progress?.advance?.(
185
+ 1,
186
+ 'Building pads ' + (index + 1) + '/' + pads.length
187
+ )
188
+ if (bottomMesh) meshes.push(bottomMesh)
189
+ await progress?.advance?.(
190
+ 1,
191
+ 'Building pads ' + (index + 1) + '/' + pads.length
192
+ )
193
+ }
194
+
195
+ const vias = PcbAssemblyGeometryBuilder.#array(detail.vias)
196
+ for (let index = 0; index < vias.length; index += 1) {
197
+ const via = vias[index]
198
+ const mesh = PcbAssemblyGeometryBuilder.#viaMesh(
199
+ 'via-' + (index + 1),
200
+ via,
201
+ Number(board.thicknessMil || 63) + COPPER_THICKNESS_MIL
202
+ )
203
+ if (mesh) meshes.push(mesh)
204
+ await progress?.advance?.(
205
+ 1,
206
+ 'Building vias ' + (index + 1) + '/' + vias.length
207
+ )
208
+ }
209
+
210
+ const copperTexts = PcbAssemblyGeometryBuilder.#array(
211
+ detail.copperTexts
212
+ )
213
+ for (let index = 0; index < copperTexts.length; index += 1) {
214
+ const text = copperTexts[index]
215
+ const side = PcbAssemblyGeometryBuilder.#resolveSide(text)
216
+ const mesh = PcbAssemblyGeometryBuilder.#textMesh(
217
+ 'copper-' + side + '-text-' + (index + 1),
218
+ text,
219
+ side === 'bottom' ? bottomZ : topZ,
220
+ COPPER_COLOR,
221
+ COPPER_THICKNESS_MIL
222
+ )
223
+ if (mesh) meshes.push(mesh)
224
+ await progress?.advance?.(
225
+ 1,
226
+ 'Building copper labels ' +
227
+ (index + 1) +
228
+ '/' +
229
+ copperTexts.length
230
+ )
231
+ }
232
+
233
+ return meshes
234
+ }
235
+
236
+ /**
237
+ * Builds silkscreen meshes.
238
+ * @param {{ board?: object, detail?: object }} sceneDescription Prepared scene description.
239
+ * @param {{ advance?: (units: number, message: string) => Promise<void> } | null} progress Progress tracker.
240
+ * @returns {Promise<object[]>}
241
+ */
242
+ static async #buildSilkscreenMeshes(sceneDescription, progress = null) {
243
+ const detail = sceneDescription?.detail || {}
244
+ const board = sceneDescription?.board || {}
245
+ const topZ =
246
+ Number(board.thicknessMil || 63) / 2 +
247
+ COPPER_THICKNESS_MIL +
248
+ SILKSCREEN_THICKNESS_MIL / 2
249
+ const bottomZ = -topZ
250
+ const meshes = []
251
+ const sides = [
252
+ ['top', detail?.silkscreen?.top || {}, topZ],
253
+ ['bottom', detail?.silkscreen?.bottom || {}, bottomZ]
254
+ ]
255
+
256
+ for (const [side, silkscreen, z] of sides) {
257
+ const tracks = PcbAssemblyGeometryBuilder.#array(silkscreen.tracks)
258
+ for (let index = 0; index < tracks.length; index += 1) {
259
+ const mesh = PcbAssemblyGeometryBuilder.#trackMesh(
260
+ 'silkscreen-' + side + '-track-' + (index + 1),
261
+ tracks[index],
262
+ z,
263
+ SILKSCREEN_THICKNESS_MIL,
264
+ SILKSCREEN_COLOR
265
+ )
266
+ if (mesh) meshes.push(mesh)
267
+ await progress?.advance?.(
268
+ 1,
269
+ 'Building silkscreen tracks ' +
270
+ (index + 1) +
271
+ '/' +
272
+ tracks.length
273
+ )
274
+ }
275
+
276
+ const arcs = PcbAssemblyGeometryBuilder.#array(silkscreen.arcs)
277
+ for (let index = 0; index < arcs.length; index += 1) {
278
+ const mesh = PcbAssemblyMeshUtils.prism(
279
+ 'silkscreen-' + side + '-arc-' + (index + 1),
280
+ PcbAssemblyMeshUtils.arcBandPoints(arcs[index]),
281
+ z,
282
+ SILKSCREEN_THICKNESS_MIL,
283
+ SILKSCREEN_COLOR
284
+ )
285
+ if (mesh) meshes.push(mesh)
286
+ await progress?.advance?.(
287
+ 1,
288
+ 'Building silkscreen arcs ' +
289
+ (index + 1) +
290
+ '/' +
291
+ arcs.length
292
+ )
293
+ }
294
+
295
+ const fills = PcbAssemblyGeometryBuilder.#array(silkscreen.fills)
296
+ for (let index = 0; index < fills.length; index += 1) {
297
+ const mesh = PcbAssemblyMeshUtils.prism(
298
+ 'silkscreen-' + side + '-fill-' + (index + 1),
299
+ PcbAssemblyGeometryBuilder.#points(fills[index]),
300
+ z,
301
+ SILKSCREEN_THICKNESS_MIL,
302
+ SILKSCREEN_COLOR
303
+ )
304
+ if (mesh) meshes.push(mesh)
305
+ await progress?.advance?.(
306
+ 1,
307
+ 'Building silkscreen fills ' +
308
+ (index + 1) +
309
+ '/' +
310
+ fills.length
311
+ )
312
+ }
313
+
314
+ const texts = PcbAssemblyGeometryBuilder.#array(silkscreen.texts)
315
+ for (let index = 0; index < texts.length; index += 1) {
316
+ meshes.push(
317
+ PcbAssemblyGeometryBuilder.#textMesh(
318
+ 'silkscreen-' + side + '-text-' + (index + 1),
319
+ texts[index],
320
+ z,
321
+ SILKSCREEN_COLOR,
322
+ SILKSCREEN_THICKNESS_MIL
323
+ )
324
+ )
325
+ await progress?.advance?.(
326
+ 1,
327
+ 'Building silkscreen labels ' +
328
+ (index + 1) +
329
+ '/' +
330
+ texts.length
331
+ )
332
+ }
333
+ }
334
+
335
+ return meshes.filter(Boolean)
336
+ }
337
+
338
+ /**
339
+ * Resolves the PCB source-coordinate to board-local export offset.
340
+ * @param {{ board?: object }} sceneDescription Prepared scene description.
341
+ * @returns {{ x: number, y: number }}
342
+ */
343
+ static #boardLocalOffset(sceneDescription) {
344
+ const board = sceneDescription?.board || {}
345
+ const centerX = Number(board?.centerX)
346
+ const centerY = Number(board?.centerY)
347
+
348
+ return {
349
+ x: Number.isFinite(centerX) ? -centerX : 0,
350
+ y: Number.isFinite(centerY) ? -centerY : 0
351
+ }
352
+ }
353
+
354
+ /**
355
+ * Translates PCB-generated meshes into the component placement origin.
356
+ * @param {object[]} meshes Source meshes.
357
+ * @param {{ x: number, y: number }} offset Translation offset in mils.
358
+ * @returns {object[]}
359
+ */
360
+ static #translateMeshes(meshes, offset) {
361
+ if (
362
+ Math.abs(Number(offset?.x || 0)) < 0.001 &&
363
+ Math.abs(Number(offset?.y || 0)) < 0.001
364
+ ) {
365
+ return meshes
366
+ }
367
+
368
+ return meshes.map((mesh) =>
369
+ PcbAssemblyGeometryBuilder.#translateMesh(mesh, offset)
370
+ )
371
+ }
372
+
373
+ /**
374
+ * Translates one mesh in XY without changing Z.
375
+ * @param {object} mesh Source mesh.
376
+ * @param {{ x: number, y: number }} offset Translation offset in mils.
377
+ * @returns {object}
378
+ */
379
+ static #translateMesh(mesh, offset) {
380
+ return {
381
+ ...mesh,
382
+ vertices: PcbAssemblyGeometryBuilder.#array(mesh?.vertices).map(
383
+ (vertex) => [
384
+ Number(vertex?.[0] || 0) + Number(offset?.x || 0),
385
+ Number(vertex?.[1] || 0) + Number(offset?.y || 0),
386
+ Number(vertex?.[2] || 0)
387
+ ]
388
+ )
389
+ }
390
+ }
391
+
392
+ /**
393
+ * Builds component model meshes.
394
+ * @param {{ externalPlacements?: object[] }} sceneDescription Prepared scene description.
395
+ * @param {{ modelMeshLoader?: (placement: object) => Promise<object | object[]> }} options Build options.
396
+ * @param {object[]} diagnostics Mutable diagnostics list.
397
+ * @param {{ advance?: (units: number, message: string) => Promise<void> } | null} progress Progress tracker.
398
+ * @returns {Promise<object[]>}
399
+ */
400
+ static async #buildComponentMeshes(
401
+ sceneDescription,
402
+ options,
403
+ diagnostics,
404
+ progress = null
405
+ ) {
406
+ const loader =
407
+ typeof options.modelMeshLoader === 'function'
408
+ ? options.modelMeshLoader
409
+ : null
410
+ if (!loader) {
411
+ return []
412
+ }
413
+
414
+ const meshes = []
415
+ const placements = PcbAssemblyGeometryBuilder.#array(
416
+ sceneDescription?.externalPlacements
417
+ ).filter((placement) => placement?.externalModel)
418
+ for (
419
+ let placementIndex = 0;
420
+ placementIndex < placements.length;
421
+ placementIndex += 1
422
+ ) {
423
+ const placement = placements[placementIndex]
424
+
425
+ try {
426
+ const loaded = await loader(placement)
427
+ const loadedMeshes = Array.isArray(loaded) ? loaded : [loaded]
428
+ loadedMeshes.filter(Boolean).forEach((mesh, index) => {
429
+ const designator = PcbAssemblyMeshUtils.safeName(
430
+ placement?.designator || 'component'
431
+ )
432
+ const transformed = PcbAssemblyMeshUtils.transformMesh(
433
+ {
434
+ ...mesh,
435
+ name:
436
+ 'component-' +
437
+ designator +
438
+ (loadedMeshes.length > 1
439
+ ? '-' + (index + 1)
440
+ : ''),
441
+ color: mesh.color || COMPONENT_COLOR
442
+ },
443
+ placement
444
+ )
445
+ meshes.push(transformed)
446
+ })
447
+ PcbAssemblyGeometryBuilder.#appendModelDiagnostic(
448
+ placement,
449
+ diagnostics
450
+ )
451
+ } catch (error) {
452
+ diagnostics.push(
453
+ PcbAssemblyGeometryBuilder.#diagnostic(
454
+ 'warning',
455
+ 'component_model_conversion_failed',
456
+ 'Could not convert 3D model for ' +
457
+ String(placement?.designator || 'component') +
458
+ ': ' +
459
+ String(error?.message || error)
460
+ )
461
+ )
462
+ }
463
+ await progress?.advance?.(
464
+ 1,
465
+ 'Loading component models ' +
466
+ (placementIndex + 1) +
467
+ '/' +
468
+ placements.length
469
+ )
470
+ }
471
+
472
+ return meshes
473
+ }
474
+
475
+ /**
476
+ * Adds a format-specific component model diagnostic.
477
+ * @param {object} placement Component placement.
478
+ * @param {object[]} diagnostics Mutable diagnostics list.
479
+ * @returns {void}
480
+ */
481
+ static #appendModelDiagnostic(placement, diagnostics) {
482
+ const format = String(
483
+ placement?.externalModel?.format || ''
484
+ ).toLowerCase()
485
+ if (format === 'wrl' || format === 'vrml') {
486
+ diagnostics.push(
487
+ PcbAssemblyGeometryBuilder.#diagnostic(
488
+ 'info',
489
+ 'component_wrl_faceted_step',
490
+ 'Converted WRL model for ' +
491
+ String(placement?.designator || 'component') +
492
+ ' as faceted geometry.'
493
+ )
494
+ )
495
+ return
496
+ }
497
+
498
+ if (format === 'step' || format === 'stp') {
499
+ diagnostics.push(
500
+ PcbAssemblyGeometryBuilder.#diagnostic(
501
+ 'info',
502
+ 'component_step_faceted_export',
503
+ 'Included STEP model for ' +
504
+ String(placement?.designator || 'component') +
505
+ ' through export mesh geometry.'
506
+ )
507
+ )
508
+ }
509
+ }
510
+
511
+ /**
512
+ * Adds diagnostics for components without resolved external models.
513
+ * @param {{ components?: object[], externalPlacements?: object[] }} sceneDescription Scene description.
514
+ * @param {object[]} diagnostics Mutable diagnostics list.
515
+ * @returns {void}
516
+ */
517
+ static #appendMissingModelDiagnostics(sceneDescription, diagnostics) {
518
+ const placementDesignators = new Set(
519
+ PcbAssemblyGeometryBuilder.#array(
520
+ sceneDescription?.externalPlacements
521
+ )
522
+ .map((placement) => String(placement?.designator || '').trim())
523
+ .filter(Boolean)
524
+ )
525
+
526
+ PcbAssemblyGeometryBuilder.#array(sceneDescription?.components).forEach(
527
+ (component) => {
528
+ const designator = String(component?.designator || '').trim()
529
+ if (
530
+ !designator ||
531
+ component?.externalModel ||
532
+ placementDesignators.has(designator)
533
+ ) {
534
+ return
535
+ }
536
+
537
+ diagnostics.push(
538
+ PcbAssemblyGeometryBuilder.#diagnostic(
539
+ 'warning',
540
+ 'component_model_missing',
541
+ 'No resolved 3D model was available for ' +
542
+ designator +
543
+ '.'
544
+ )
545
+ )
546
+ }
547
+ )
548
+ }
549
+
550
+ /**
551
+ * Builds one widened track mesh.
552
+ * @param {string} name Mesh name.
553
+ * @param {object} track Track primitive.
554
+ * @param {number} z Center Z.
555
+ * @param {number} thickness Extrusion thickness.
556
+ * @param {number[]} color Mesh color.
557
+ * @returns {object | null}
558
+ */
559
+ static #trackMesh(name, track, z, thickness, color) {
560
+ const x1 = Number(track?.x1 ?? track?.startX ?? 0)
561
+ const y1 = Number(track?.y1 ?? track?.startY ?? 0)
562
+ const x2 = Number(track?.x2 ?? track?.endX ?? x1)
563
+ const y2 = Number(track?.y2 ?? track?.endY ?? y1)
564
+ const width = Math.max(Number(track?.width || 1), 0.001)
565
+ const length = Math.hypot(x2 - x1, y2 - y1)
566
+ if (length <= 0.001) {
567
+ return PcbAssemblyMeshUtils.cylinder(name, {
568
+ x: x1,
569
+ y: y1,
570
+ z,
571
+ radius: width / 2,
572
+ height: thickness,
573
+ color
574
+ })
575
+ }
576
+
577
+ return PcbAssemblyMeshUtils.prism(
578
+ name,
579
+ PcbAssemblyMeshUtils.capsulePoints(x1, y1, x2, y2, width / 2),
580
+ z,
581
+ thickness,
582
+ color
583
+ )
584
+ }
585
+
586
+ /**
587
+ * Builds one pad mesh.
588
+ * @param {string} name Mesh name.
589
+ * @param {object} pad Pad primitive.
590
+ * @param {'top' | 'bottom'} side Pad side.
591
+ * @param {number} z Center Z.
592
+ * @param {number} thickness Extrusion thickness.
593
+ * @returns {object | null}
594
+ */
595
+ static #padMesh(name, pad, side, z, thickness) {
596
+ const size = PcbAssemblyGeometryBuilder.#padSize(pad, side)
597
+ if (!size) {
598
+ return null
599
+ }
600
+
601
+ const offset = PcbAssemblyGeometryBuilder.#padOffset(pad, side)
602
+ const x = Number(pad?.x || 0) + offset.x
603
+ const y = Number(pad?.y || 0) + offset.y
604
+ const shape = PcbAssemblyGeometryBuilder.#padShape(pad, side)
605
+ const isCircle =
606
+ Math.abs(size.width - size.height) < 0.001 && shape !== 2
607
+ const mesh = isCircle
608
+ ? PcbAssemblyMeshUtils.cylinder(name, {
609
+ x,
610
+ y,
611
+ z,
612
+ radius: size.width / 2,
613
+ height: thickness,
614
+ color: COPPER_COLOR
615
+ })
616
+ : PcbAssemblyMeshUtils.box(name, {
617
+ x,
618
+ y,
619
+ z,
620
+ width: size.width,
621
+ depth: size.height,
622
+ height: thickness,
623
+ color: COPPER_COLOR
624
+ })
625
+
626
+ return PcbAssemblyGeometryBuilder.#rotateMeshAroundZ(
627
+ mesh,
628
+ Number(pad?.rotation || 0),
629
+ x,
630
+ y
631
+ )
632
+ }
633
+
634
+ /**
635
+ * Resolves side-specific pad dimensions.
636
+ * @param {object} pad Pad primitive.
637
+ * @param {'top' | 'bottom'} side Pad side.
638
+ * @returns {{ width: number, height: number } | null}
639
+ */
640
+ static #padSize(pad, side) {
641
+ const prefix = side === 'bottom' ? 'Bottom' : 'Top'
642
+ let width = PcbAssemblyGeometryBuilder.#firstPositive([
643
+ pad?.['size' + prefix + 'X']
644
+ ])
645
+ let height = PcbAssemblyGeometryBuilder.#firstPositive([
646
+ pad?.['size' + prefix + 'Y']
647
+ ])
648
+
649
+ if (side === 'top' && !width) {
650
+ width = PcbAssemblyGeometryBuilder.#firstPositive([
651
+ pad?.width,
652
+ pad?.sizeX,
653
+ pad?.diameter,
654
+ pad?.sizeMidX,
655
+ pad?.sizeBottomX
656
+ ])
657
+ }
658
+ if (side === 'top' && !height) {
659
+ height = PcbAssemblyGeometryBuilder.#firstPositive([
660
+ pad?.height,
661
+ pad?.sizeY,
662
+ pad?.diameter,
663
+ pad?.sizeMidY,
664
+ pad?.sizeBottomY,
665
+ width
666
+ ])
667
+ }
668
+ if (width && !height) {
669
+ height = width
670
+ }
671
+
672
+ return width && height
673
+ ? {
674
+ width: Math.max(width, 1),
675
+ height: Math.max(height, 1)
676
+ }
677
+ : null
678
+ }
679
+
680
+ /**
681
+ * Resolves side-specific pad center offset.
682
+ * @param {object} pad Pad primitive.
683
+ * @param {'top' | 'bottom'} side Pad side.
684
+ * @returns {{ x: number, y: number }}
685
+ */
686
+ static #padOffset(pad, side) {
687
+ const prefix = side === 'bottom' ? 'Bottom' : 'Top'
688
+
689
+ return {
690
+ x: Number(pad?.['offset' + prefix + 'X'] || 0),
691
+ y: Number(pad?.['offset' + prefix + 'Y'] || 0)
692
+ }
693
+ }
694
+
695
+ /**
696
+ * Resolves the renderer pad shape code for one side.
697
+ * @param {object} pad Pad primitive.
698
+ * @param {'top' | 'bottom'} side Pad side.
699
+ * @returns {number}
700
+ */
701
+ static #padShape(pad, side) {
702
+ const prefix = side === 'bottom' ? 'Bottom' : 'Top'
703
+ const rounded = pad?.['roundedRectShape' + prefix]
704
+ if (Number.isInteger(Number(rounded))) {
705
+ return Number(rounded)
706
+ }
707
+
708
+ return Number(
709
+ pad?.['shape' + prefix] ||
710
+ pad?.shapeMid ||
711
+ (side === 'top' ? pad?.shapeBottom : pad?.shapeTop) ||
712
+ 0
713
+ )
714
+ }
715
+
716
+ /**
717
+ * Builds one via mesh.
718
+ * @param {string} name Mesh name.
719
+ * @param {object} via Via primitive.
720
+ * @param {number} height Via height.
721
+ * @returns {object}
722
+ */
723
+ static #viaMesh(name, via, height) {
724
+ const outerRadius = Math.max(Number(via?.diameter || 0) / 2, 1)
725
+ const innerRadius = Math.max(Number(via?.holeDiameter || 0) / 2, 0)
726
+ const options = {
727
+ x: Number(via?.x || 0),
728
+ y: Number(via?.y || 0),
729
+ z: 0,
730
+ outerRadius,
731
+ innerRadius,
732
+ height,
733
+ color: COPPER_COLOR
734
+ }
735
+
736
+ return innerRadius > 0 && innerRadius < outerRadius
737
+ ? PcbAssemblyMeshUtils.ringCylinder(name, options)
738
+ : PcbAssemblyMeshUtils.cylinder(name, {
739
+ x: options.x,
740
+ y: options.y,
741
+ z: 0,
742
+ radius: outerRadius,
743
+ height,
744
+ color: COPPER_COLOR
745
+ })
746
+ }
747
+
748
+ /**
749
+ * Builds a simple text placeholder prism for recoverable text labels.
750
+ * @param {string} name Mesh name.
751
+ * @param {object} text Text primitive.
752
+ * @param {number} z Center Z.
753
+ * @param {number[]} color Mesh color.
754
+ * @param {number} thickness Mesh thickness.
755
+ * @returns {object}
756
+ */
757
+ static #textMesh(name, text, z, color, thickness) {
758
+ const value = String(text?.text || text?.value || text?.string || '')
759
+ const height = Math.max(
760
+ Number(text?.height || text?.size || text?.sizeY || 25),
761
+ 1
762
+ )
763
+ const width = Math.max(
764
+ Number(text?.width || text?.sizeX || value.length * height * 0.62),
765
+ height
766
+ )
767
+ const mesh = PcbAssemblyMeshUtils.box(name, {
768
+ x: Number(text?.x || 0) + width / 2,
769
+ y: Number(text?.y || 0) + height / 2,
770
+ z,
771
+ width,
772
+ depth: height,
773
+ height: thickness,
774
+ color
775
+ })
776
+
777
+ return PcbAssemblyGeometryBuilder.#rotateMeshAroundZ(
778
+ mesh,
779
+ Number(text?.rotation || 0),
780
+ Number(text?.x || 0),
781
+ Number(text?.y || 0)
782
+ )
783
+ }
784
+
785
+ /**
786
+ * Rotates one mesh around a Z-axis origin.
787
+ * @param {object} mesh Mesh to rotate.
788
+ * @param {number} rotationDeg Rotation angle.
789
+ * @param {number} originX Origin X.
790
+ * @param {number} originY Origin Y.
791
+ * @returns {object}
792
+ */
793
+ static #rotateMeshAroundZ(mesh, rotationDeg, originX, originY) {
794
+ if (!mesh || Math.abs(Number(rotationDeg || 0)) < 0.001) {
795
+ return mesh
796
+ }
797
+
798
+ const rotationRad = (Number(rotationDeg || 0) * Math.PI) / 180
799
+ const cos = Math.cos(rotationRad)
800
+ const sin = Math.sin(rotationRad)
801
+
802
+ return {
803
+ ...mesh,
804
+ vertices: mesh.vertices.map((vertex) => {
805
+ const dx = Number(vertex[0] || 0) - originX
806
+ const dy = Number(vertex[1] || 0) - originY
807
+ return [
808
+ originX + dx * cos - dy * sin,
809
+ originY + dx * sin + dy * cos,
810
+ Number(vertex[2] || 0)
811
+ ]
812
+ })
813
+ }
814
+ }
815
+
816
+ /**
817
+ * Resolves a copper side from layer metadata.
818
+ * @param {object} item Detail primitive.
819
+ * @returns {'top' | 'bottom'}
820
+ */
821
+ static #resolveSide(item) {
822
+ const raw = String(
823
+ item?.side ||
824
+ item?.layerSide ||
825
+ item?.layer ||
826
+ item?.layerName ||
827
+ ''
828
+ ).toLowerCase()
829
+
830
+ return raw.includes('bottom') ||
831
+ raw.includes('back') ||
832
+ raw.includes('b.')
833
+ ? 'bottom'
834
+ : 'top'
835
+ }
836
+
837
+ /**
838
+ * Extracts polygon points from a primitive.
839
+ * @param {object} source Primitive with point data.
840
+ * @returns {number[][]}
841
+ */
842
+ static #points(source) {
843
+ return PcbAssemblyGeometryBuilder.#array(
844
+ source?.points || source?.vertices || source?.polygon || []
845
+ ).map((point) => [
846
+ Number(point?.x || point?.[0] || 0),
847
+ Number(point?.y || point?.[1] || 0)
848
+ ])
849
+ }
850
+
851
+ /**
852
+ * Builds an ordered board outline loop from explicit points or segments.
853
+ * @param {object} board Board metadata.
854
+ * @returns {number[][]}
855
+ */
856
+ static #boardOutlinePoints(board) {
857
+ const explicitPoints = PcbAssemblyGeometryBuilder.#points({
858
+ points: board?.points || board?.outlinePoints || board?.vertices
859
+ })
860
+ if (explicitPoints.length >= 3) {
861
+ return explicitPoints
862
+ }
863
+
864
+ const segments = PcbAssemblyGeometryBuilder.#array(board?.segments)
865
+ const points = []
866
+ segments.forEach((segment, index) => {
867
+ if (index === 0) {
868
+ const start = PcbAssemblyGeometryBuilder.#segmentStart(segment)
869
+ if (start) points.push(start)
870
+ }
871
+ points.push(
872
+ ...PcbAssemblyGeometryBuilder.#segmentTailPoints(segment)
873
+ )
874
+ })
875
+
876
+ return PcbAssemblyMeshUtils.cleanLoop(points)
877
+ }
878
+
879
+ /**
880
+ * Extracts a segment start point.
881
+ * @param {object} segment Segment primitive.
882
+ * @returns {number[] | null}
883
+ */
884
+ static #segmentStart(segment) {
885
+ return PcbAssemblyGeometryBuilder.#pointFromFields(segment, [
886
+ ['x1', 'y1'],
887
+ ['startX', 'startY']
888
+ ])
889
+ }
890
+
891
+ /**
892
+ * Extracts line or sampled arc tail points from one segment.
893
+ * @param {object} segment Segment primitive.
894
+ * @returns {number[][]}
895
+ */
896
+ static #segmentTailPoints(segment) {
897
+ if (
898
+ String(segment?.type || '').toLowerCase() === 'arc' ||
899
+ Number.isFinite(Number(segment?.radius))
900
+ ) {
901
+ return PcbAssemblyGeometryBuilder.#arcOutlinePoints(segment)
902
+ }
903
+
904
+ const end = PcbAssemblyGeometryBuilder.#pointFromFields(segment, [
905
+ ['x2', 'y2'],
906
+ ['endX', 'endY']
907
+ ])
908
+
909
+ return end ? [end] : []
910
+ }
911
+
912
+ /**
913
+ * Samples a board outline arc.
914
+ * @param {object} arc Arc segment.
915
+ * @returns {number[][]}
916
+ */
917
+ static #arcOutlinePoints(arc) {
918
+ const centerX = Number(arc?.x || arc?.cx || arc?.centerX || 0)
919
+ const centerY = Number(arc?.y || arc?.cy || arc?.centerY || 0)
920
+ const radius = Number(arc?.radius || 0)
921
+ if (!Number.isFinite(radius) || radius <= 0) {
922
+ return []
923
+ }
924
+
925
+ const start = Number(arc?.startAngle || 0)
926
+ const sweep = PcbAssemblyMeshUtils.resolveSweep(arc)
927
+ const segments = Math.max(Math.ceil(Math.abs(sweep) / 8), 4)
928
+ const points = []
929
+
930
+ for (let index = 1; index <= segments; index += 1) {
931
+ const angle = ((start + (sweep * index) / segments) * Math.PI) / 180
932
+ points.push([
933
+ centerX + Math.cos(angle) * radius,
934
+ centerY + Math.sin(angle) * radius
935
+ ])
936
+ }
937
+
938
+ return points
939
+ }
940
+
941
+ /**
942
+ * Reads a point from the first complete field pair.
943
+ * @param {object} source Source object.
944
+ * @param {string[][]} fieldPairs Candidate field pairs.
945
+ * @returns {number[] | null}
946
+ */
947
+ static #pointFromFields(source, fieldPairs) {
948
+ for (const [xField, yField] of fieldPairs) {
949
+ const x = Number(source?.[xField])
950
+ const y = Number(source?.[yField])
951
+ if (Number.isFinite(x) && Number.isFinite(y)) {
952
+ return [x, y]
953
+ }
954
+ }
955
+
956
+ return null
957
+ }
958
+
959
+ /**
960
+ * Returns the first positive finite number.
961
+ * @param {unknown[]} values Candidate values.
962
+ * @returns {number}
963
+ */
964
+ static #firstPositive(values) {
965
+ for (const value of values) {
966
+ const number = Number(value)
967
+ if (Number.isFinite(number) && number > 0) {
968
+ return number
969
+ }
970
+ }
971
+
972
+ return 0
973
+ }
974
+
975
+ /**
976
+ * Creates a normalized diagnostic object.
977
+ * @param {string} severity Diagnostic severity.
978
+ * @param {string} code Diagnostic code.
979
+ * @param {string} message User-facing message.
980
+ * @returns {object}
981
+ */
982
+ static #diagnostic(severity, code, message) {
983
+ return { severity, code, message }
984
+ }
985
+
986
+ /**
987
+ * Normalizes a value to an array.
988
+ * @param {unknown} value Candidate value.
989
+ * @returns {any[]}
990
+ */
991
+ static #array(value) {
992
+ return Array.isArray(value) ? value : []
993
+ }
994
+ }