pcb-scene3d-viewer 1.1.49 → 1.2.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.
Files changed (63) hide show
  1. package/README.md +46 -21
  2. package/docs/api.md +109 -8
  3. package/docs/circuitjson.md +143 -29
  4. package/docs/model-format.md +50 -7
  5. package/docs/release-notes-v1.2.0.md +122 -0
  6. package/docs/testing.md +30 -1
  7. package/package.json +6 -3
  8. package/spec/library-scope.md +10 -2
  9. package/src/CircuitJsonCadModelAssetResolver.mjs +697 -83
  10. package/src/PcbAssemblyBoardSubstrateBuilder.mjs +43 -0
  11. package/src/PcbAssemblyGeometryBuilder.mjs +40 -18
  12. package/src/PcbAssemblyModelMeshLoader.mjs +101 -150
  13. package/src/PcbAssemblyPadMeshBuilder.mjs +22 -0
  14. package/src/PcbModelArchiveExporter.mjs +28 -112
  15. package/src/PcbModelArchiveSourceBundle.mjs +326 -0
  16. package/src/PcbScene3dAabbIndex.mjs +464 -0
  17. package/src/PcbScene3dBoardAssemblyPresentation.mjs +1 -1
  18. package/src/PcbScene3dBoardEdgeCutoutBuilder.mjs +9 -5
  19. package/src/PcbScene3dBoardMaterialPalette.mjs +29 -0
  20. package/src/PcbScene3dBoardShapeFactory.mjs +11 -118
  21. package/src/PcbScene3dBoardSolderMaskFactory.mjs +65 -39
  22. package/src/PcbScene3dCircuitJsonAdapter.mjs +151 -48
  23. package/src/PcbScene3dCircuitJsonDrillDetail.mjs +31 -0
  24. package/src/PcbScene3dCircuitJsonGeometry.mjs +184 -33
  25. package/src/PcbScene3dCircuitJsonInput.mjs +132 -0
  26. package/src/PcbScene3dCircuitJsonModelAsset.mjs +40 -0
  27. package/src/PcbScene3dController.mjs +40 -34
  28. package/src/PcbScene3dCopperFactory.mjs +36 -22
  29. package/src/PcbScene3dCopperFillAreaClipper.mjs +133 -235
  30. package/src/PcbScene3dCopperFillCoverageContext.mjs +204 -0
  31. package/src/PcbScene3dCopperFillLoopSetResolver.mjs +192 -0
  32. package/src/PcbScene3dCopperFillMeshBuilder.mjs +77 -295
  33. package/src/PcbScene3dCopperTextFactory.mjs +12 -4
  34. package/src/PcbScene3dCutoutCircleDetector.mjs +34 -17
  35. package/src/PcbScene3dCutoutGeometryFilter.mjs +104 -269
  36. package/src/PcbScene3dCutoutGridIndex.mjs +184 -0
  37. package/src/PcbScene3dDeferredModelFinalizer.mjs +52 -0
  38. package/src/PcbScene3dDescriptorSafeRecord.mjs +38 -0
  39. package/src/PcbScene3dDrillCutoutFilter.mjs +149 -143
  40. package/src/PcbScene3dDrillPathFactory.mjs +86 -16
  41. package/src/PcbScene3dDrillVoidFactory.mjs +35 -10
  42. package/src/PcbScene3dExternalModelGroupLoader.mjs +472 -31
  43. package/src/PcbScene3dExternalModels.mjs +23 -24
  44. package/src/PcbScene3dFacetedModelGroupBuilder.mjs +217 -0
  45. package/src/PcbScene3dGeometryZCompressor.mjs +4 -2
  46. package/src/PcbScene3dMaskCoveredCopperSideGroupBuilder.mjs +18 -4
  47. package/src/PcbScene3dMaskCoveredCopperSurfaceFilter.mjs +75 -10
  48. package/src/PcbScene3dModelContent.mjs +236 -0
  49. package/src/PcbScene3dModelFetchPolicy.mjs +304 -0
  50. package/src/PcbScene3dModelIdentity.mjs +106 -0
  51. package/src/PcbScene3dPlatedDrillSpecResolver.mjs +141 -0
  52. package/src/PcbScene3dPreparedPolygon.mjs +709 -0
  53. package/src/PcbScene3dPreparedPolygonSet.mjs +70 -0
  54. package/src/PcbScene3dRuntime.mjs +45 -45
  55. package/src/PcbScene3dRuntimeBoardMeshes.mjs +85 -1
  56. package/src/PcbScene3dShapeHoleGeometryCleaner.mjs +4 -2
  57. package/src/PcbScene3dShellRenderer.mjs +75 -6
  58. package/src/PcbScene3dSilkscreenCutoutContext.mjs +255 -0
  59. package/src/PcbScene3dSilkscreenFactory.mjs +87 -127
  60. package/src/PcbScene3dSilkscreenFillSeamBuilder.mjs +11 -5
  61. package/src/PcbScene3dStepLoader.mjs +11 -10
  62. package/src/PcbScene3dText.mjs +1 -1
  63. package/src/PcbScene3dTriangleVertexQueryBounds.mjs +302 -0
@@ -0,0 +1,70 @@
1
+ import { PcbScene3dAabbIndex } from './PcbScene3dAabbIndex.mjs'
2
+
3
+ /**
4
+ * Request-scoped broad-phase index for prepared polygons.
5
+ */
6
+ export class PcbScene3dPreparedPolygonSet {
7
+ /** @type {PcbScene3dAabbIndex} */
8
+ #index
9
+
10
+ /** @type {Map<*, *>} */
11
+ #sourceMap
12
+
13
+ /**
14
+ * Builds a set index while retaining supplied prepared object identities.
15
+ * @param {Iterable<{ source: *, bounds: { minX: number, maxX: number, minY: number, maxY: number } }>} polygons
16
+ */
17
+ constructor(polygons) {
18
+ const preparedPolygons = Array.from(polygons || [])
19
+
20
+ this.#index = new PcbScene3dAabbIndex(preparedPolygons, {
21
+ resolveBounds: PcbScene3dPreparedPolygonSet.#resolveBounds,
22
+ resolveSourceIndex: PcbScene3dPreparedPolygonSet.#resolveSetPosition
23
+ })
24
+ this.#sourceMap = new Map()
25
+
26
+ for (const polygon of preparedPolygons) {
27
+ if (!this.#sourceMap.has(polygon.source)) {
28
+ this.#sourceMap.set(polygon.source, polygon)
29
+ }
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Returns prepared polygons whose bounds overlap requested bounds.
35
+ * @param {{ minX: number, maxX: number, minY: number, maxY: number }} bounds
36
+ * @param {{ epsilon?: number, stable?: boolean }} [options]
37
+ * @returns {*[]}
38
+ */
39
+ query(bounds, options = {}) {
40
+ return this.#index.query(bounds, options)
41
+ }
42
+
43
+ /**
44
+ * Returns the earliest prepared polygon for one source identity.
45
+ * @param {*} source
46
+ * @returns {* | null}
47
+ */
48
+ resolveSource(source) {
49
+ return this.#sourceMap.get(source) ?? null
50
+ }
51
+
52
+ /**
53
+ * Resolves prepared polygon bounds for the top-level AABB index.
54
+ * @param {{ bounds: { minX: number, maxX: number, minY: number, maxY: number } }} polygon
55
+ * @returns {{ minX: number, maxX: number, minY: number, maxY: number }}
56
+ */
57
+ static #resolveBounds(polygon) {
58
+ return polygon.bounds
59
+ }
60
+
61
+ /**
62
+ * Resolves stable order from a polygon's position in this specific set.
63
+ * @param {*} _polygon
64
+ * @param {number} index
65
+ * @returns {number}
66
+ */
67
+ static #resolveSetPosition(_polygon, index) {
68
+ return index
69
+ }
70
+ }
@@ -8,6 +8,7 @@ import { PcbScene3dCompanionBasePlacementAdjuster } from './PcbScene3dCompanionB
8
8
  import { PcbScene3dCopperDetailGroupBuilder } from './PcbScene3dCopperDetailGroupBuilder.mjs'
9
9
  import { PcbScene3dCopperFactory } from './PcbScene3dCopperFactory.mjs'
10
10
  import { PcbScene3dDetailCoordinateNormalizer } from './PcbScene3dDetailCoordinateNormalizer.mjs'
11
+ import { PcbScene3dDeferredModelFinalizer } from './PcbScene3dDeferredModelFinalizer.mjs'
11
12
  import { PcbScene3dDrillVoidFactory } from './PcbScene3dDrillVoidFactory.mjs'
12
13
  import { PcbScene3dExternalCompanionFallback } from './PcbScene3dExternalCompanionFallback.mjs'
13
14
  import { PcbScene3dExternalModels } from './PcbScene3dExternalModels.mjs'
@@ -76,7 +77,7 @@ export class PcbScene3dRuntime {
76
77
  /**
77
78
  * @param {HTMLElement} viewportNode
78
79
  * @param {any} sceneDescription Scene description or CircuitJSON model.
79
- * @param {{ setDiagnostics?: (messages: string[]) => void, setSelection?: (selection: any | null) => void, loadRuntimeModules?: () => Promise<{ THREE: any, OrbitControls: any }>, translate?: ((key: string) => string) | null }} [hooks]
80
+ * @param {{ setDiagnostics?: (messages: string[]) => void, setSelection?: (selection: any | null) => void, loadRuntimeModules?: () => Promise<{ THREE: any, OrbitControls: any }>, translate?: ((key: string) => string) | null, modelLoaderOptions?: object }} [hooks]
80
81
  */
81
82
  constructor(viewportNode, sceneDescription, hooks = {}) {
82
83
  const renderModel =
@@ -264,7 +265,6 @@ export class PcbScene3dRuntime {
264
265
  this.#settleReady()
265
266
  return
266
267
  }
267
-
268
268
  try {
269
269
  const { THREE, OrbitControls } =
270
270
  (await this.#hooks.loadRuntimeModules?.()) ||
@@ -274,7 +274,6 @@ export class PcbScene3dRuntime {
274
274
  if (this.#isDisposed || !this.#viewportNode) {
275
275
  return
276
276
  }
277
-
278
277
  this.#createRenderer()
279
278
  this.#createSceneGraph()
280
279
  this.#createControls()
@@ -291,7 +290,6 @@ export class PcbScene3dRuntime {
291
290
  this.#settleReady()
292
291
  }
293
292
  }
294
-
295
293
  /**
296
294
  * Creates the Three.js renderer, camera, and scene.
297
295
  * @returns {void}
@@ -299,7 +297,6 @@ export class PcbScene3dRuntime {
299
297
  #createRenderer() {
300
298
  const THREE = this.#three
301
299
  const size = PcbScene3dViewportResize.resolveSize(this.#viewportNode)
302
-
303
300
  this.#renderer = new THREE.WebGLRenderer({
304
301
  antialias: true,
305
302
  alpha: true,
@@ -310,7 +307,6 @@ export class PcbScene3dRuntime {
310
307
  this.#renderer.domElement.className = 'scene-3d__canvas'
311
308
  this.#renderer.domElement.style.width = '100%'
312
309
  this.#renderer.domElement.style.height = '100%'
313
-
314
310
  this.#scene = new THREE.Scene()
315
311
  this.#camera = new THREE.PerspectiveCamera(
316
312
  38,
@@ -321,7 +317,6 @@ export class PcbScene3dRuntime {
321
317
  this.#camera.up.set(0, 0, 1)
322
318
  this.#viewportNode?.replaceChildren(this.#renderer.domElement)
323
319
  }
324
-
325
320
  /**
326
321
  * Creates the initial board shell, fallback bodies, and placeholder detail
327
322
  * groups used by the deferred loading stages.
@@ -330,7 +325,6 @@ export class PcbScene3dRuntime {
330
325
  #createSceneGraph() {
331
326
  const THREE = this.#three
332
327
  const board = this.#sceneDescription.board
333
-
334
328
  this.#viewOrientationGroup = new THREE.Group()
335
329
  this.#scene.add(this.#viewOrientationGroup)
336
330
  this.#rootGroup = new THREE.Group()
@@ -368,7 +362,11 @@ export class PcbScene3dRuntime {
368
362
  (x, y) => this.#normalizeDetailPoint(x, y),
369
363
  {
370
364
  enabled: true,
371
- board
365
+ board,
366
+ hasBoardAssemblyModel: Boolean(
367
+ this.#sceneDescription.boardAssemblyModel
368
+ ),
369
+ sourceFormat: this.#sceneDescription.sourceFormat
372
370
  }
373
371
  )
374
372
  )
@@ -400,7 +398,6 @@ export class PcbScene3dRuntime {
400
398
  if (component?.renderFallbackBody === false) {
401
399
  return
402
400
  }
403
-
404
401
  const isCompanionBase =
405
402
  PcbScene3dExternalCompanionFallback.shouldKeepFallback(
406
403
  this.#sceneDescription,
@@ -470,7 +467,6 @@ export class PcbScene3dRuntime {
470
467
  boardSpan * 7
471
468
  )
472
469
  }
473
-
474
470
  /**
475
471
  * Applies the active preset's scene-scale transform.
476
472
  * @param {string} preset
@@ -496,27 +492,47 @@ export class PcbScene3dRuntime {
496
492
  )
497
493
  )
498
494
  }
499
-
500
495
  /**
501
496
  * Loads silkscreen, copper, and external model detail after the initial
502
497
  * shell render and keeps readiness pending until settlement completes.
503
498
  * @returns {Promise<void>}
504
499
  */
505
500
  async #loadDeferredDetail() {
501
+ let externalModels = null
502
+ let hasDeferredStageFailure = false
506
503
  try {
507
504
  await PcbScene3dRuntimeHelpers.yieldToNextFrame(globalThis)
508
505
  if (this.#isDisposed) {
509
506
  return
510
507
  }
511
-
508
+ externalModels = new PcbScene3dDeferredModelFinalizer(
509
+ this.#loadExternalModels(),
510
+ {
511
+ isDisposed: () => this.#isDisposed,
512
+ onSuccess: (diagnostics) => {
513
+ if (!hasDeferredStageFailure && diagnostics.length) {
514
+ this.#hooks.setDiagnostics?.(diagnostics)
515
+ }
516
+ this.#applyViewScale(this.#presetState.get())
517
+ this.#render()
518
+ },
519
+ onError: (error) => {
520
+ this.#hooks.setDiagnostics?.([
521
+ 'Deferred 3D detail could not finish loading: ' +
522
+ String(
523
+ error?.message || error || 'Unknown error.'
524
+ )
525
+ ])
526
+ this.#render()
527
+ }
528
+ }
529
+ )
512
530
  await this.#loadDeferredSurfaceArtwork()
513
531
  this.#render()
514
-
515
532
  await PcbScene3dRuntimeHelpers.yieldToNextFrame(globalThis)
516
533
  if (this.#isDisposed) {
517
534
  return
518
535
  }
519
-
520
536
  this.#loadDeferredCopper()
521
537
  this.#applyToggleVisibility()
522
538
  this.#render()
@@ -525,18 +541,13 @@ export class PcbScene3dRuntime {
525
541
  if (this.#isDisposed) {
526
542
  return
527
543
  }
528
-
529
- await this.#loadExternalModels()
530
- if (this.#isDisposed) {
531
- return
532
- }
533
-
534
- this.#render()
544
+ await externalModels.release()
535
545
  } catch (error) {
536
546
  if (this.#isDisposed) {
537
547
  return
538
548
  }
539
-
549
+ hasDeferredStageFailure = true
550
+ externalModels?.release({ reportError: false }).catch(() => {})
540
551
  this.#hooks.setDiagnostics?.([
541
552
  'Deferred 3D detail could not finish loading: ' +
542
553
  String(error?.message || error || 'Unknown error.')
@@ -544,7 +555,6 @@ export class PcbScene3dRuntime {
544
555
  this.#render()
545
556
  }
546
557
  }
547
-
548
558
  /**
549
559
  * Builds and attaches surface artwork detail once after the first frame.
550
560
  * @returns {Promise<void>}
@@ -557,7 +567,6 @@ export class PcbScene3dRuntime {
557
567
  ['silkscreen', silkscreen, Z_MIL.silk, true],
558
568
  ['paste', this.#sceneDescription.detail.paste, Z_MIL.paste, false]
559
569
  ]
560
-
561
570
  for (const [groupName, artwork, z, prepareFonts] of overlays) {
562
571
  if (artwork == null) {
563
572
  continue
@@ -578,7 +587,6 @@ export class PcbScene3dRuntime {
578
587
  ? await loaded
579
588
  : loaded) || didLoad
580
589
  }
581
-
582
590
  if (didLoad) {
583
591
  this.#applyViewScale(this.#presetState.get())
584
592
  }
@@ -600,33 +608,39 @@ export class PcbScene3dRuntime {
600
608
  topZ,
601
609
  (x, y) => this.#normalizeDetailPoint(x, y)
602
610
  )
603
-
604
611
  if (detailGroup.children.length) {
605
612
  copperGroup.add(detailGroup)
606
613
  this.#applyViewScale(this.#presetState.get())
607
614
  }
608
615
  }
609
-
610
616
  /**
611
617
  * Attempts to load any resolved external 3D models.
612
- * @returns {Promise<void>}
618
+ * @returns {Promise<string[]>}
613
619
  */
614
620
  async #loadExternalModels() {
615
621
  const externalModelsGroup = this.#groups.get('external-models')
616
622
  if (!externalModelsGroup) {
617
- return
623
+ return []
618
624
  }
619
625
 
620
626
  const diagnostics = await PcbScene3dExternalModels.loadIntoScene({
621
627
  three: this.#three,
622
628
  sceneDescription: this.#placementSceneDescription,
623
629
  externalModelsGroup,
630
+ modelLoaderOptions: this.#hooks.modelLoaderOptions,
624
631
  modelViewScale: PcbScene3dRuntime.resolveViewScale(
625
632
  this.#presetState.get(),
626
633
  this.#sceneDescription
627
634
  ),
628
635
  isDisposed: () => this.#isDisposed,
629
636
  onPlacementGroup: (placement, placementGroup) => {
637
+ PcbScene3dExternalModels.applyViewCompensation(
638
+ placementGroup,
639
+ PcbScene3dRuntime.resolveViewScale(
640
+ this.#presetState.get(),
641
+ this.#sceneDescription
642
+ )
643
+ )
630
644
  this.#registerSelectionRoot(
631
645
  placement?.designator,
632
646
  placementGroup
@@ -658,12 +672,8 @@ export class PcbScene3dRuntime {
658
672
  this.#applyToggleVisibility()
659
673
  }
660
674
  })
661
-
662
- if (diagnostics.length) {
663
- this.#hooks.setDiagnostics?.(diagnostics)
664
- }
675
+ return diagnostics
665
676
  }
666
-
667
677
  /**
668
678
  * Creates and configures orbit/pan/zoom controls using Three's standard
669
679
  * OrbitControls implementation in the same z-up world as the PCB scene.
@@ -673,13 +683,11 @@ export class PcbScene3dRuntime {
673
683
  if (!this.#camera || !this.#renderer || !this.#orbitControlsClass) {
674
684
  return
675
685
  }
676
-
677
686
  const THREE = this.#three
678
687
  const domElement = this.#renderer?.domElement
679
688
  if (!domElement) {
680
689
  return
681
690
  }
682
-
683
691
  this.#controls = new this.#orbitControlsClass(this.#camera, domElement)
684
692
  this.#controls.enableDamping = false
685
693
  this.#controls.screenSpacePanning = true
@@ -691,14 +699,12 @@ export class PcbScene3dRuntime {
691
699
  THREE,
692
700
  this.#presetState.get()
693
701
  )
694
-
695
702
  PcbScene3dCameraRig.applyPreset(
696
703
  this.#camera,
697
704
  this.#controls,
698
705
  this.#presetState.get(),
699
706
  this.#sceneDescription
700
707
  )
701
-
702
708
  this.#bindListener(this.#controls, 'change', () => {
703
709
  this.#renderScheduler.schedule()
704
710
  })
@@ -712,22 +718,18 @@ export class PcbScene3dRuntime {
712
718
  () => this.#handleResize()
713
719
  )
714
720
  }
715
-
716
721
  /** @returns {void} */
717
722
  #bindSelectionInteraction() {
718
723
  if (!this.#renderer || !this.#camera || !this.#three) {
719
724
  return
720
725
  }
721
-
722
726
  this.#raycaster = new this.#three.Raycaster()
723
727
  this.#pointer = new this.#three.Vector2()
724
728
  const domElement = this.#renderer.domElement
725
-
726
729
  this.#bindListener(domElement, 'pointerdown', (event) => {
727
730
  if (Number(event?.button) !== 0) {
728
731
  return
729
732
  }
730
-
731
733
  this.#pointerDownPosition = {
732
734
  x: Number(event?.clientX || 0),
733
735
  y: Number(event?.clientY || 0)
@@ -737,7 +739,6 @@ export class PcbScene3dRuntime {
737
739
  if (Number(event?.button) !== 0) {
738
740
  return
739
741
  }
740
-
741
742
  if (
742
743
  !this.#pointerDownPosition ||
743
744
  PcbScene3dRuntimeHelpers.pointerTravel(
@@ -751,7 +752,6 @@ export class PcbScene3dRuntime {
751
752
  this.#pointerDownPosition = null
752
753
  return
753
754
  }
754
-
755
755
  this.#pointerDownPosition = null
756
756
  this.#handleSelectionPointer(event)
757
757
  })
@@ -68,6 +68,39 @@ export class PcbScene3dRuntimeBoardMeshes {
68
68
  * @returns {any}
69
69
  */
70
70
  static buildBoardMesh(THREE, sceneDescription, normalizeDetailPoint) {
71
+ const contours = PcbScene3dRuntimeBoardMeshes.#boardContours(
72
+ sceneDescription.board
73
+ )
74
+ if (contours.length === 1) {
75
+ return PcbScene3dRuntimeBoardMeshes.#buildBoardMesh(
76
+ THREE,
77
+ { ...sceneDescription, board: contours[0] },
78
+ normalizeDetailPoint
79
+ )
80
+ }
81
+
82
+ const group = new THREE.Group()
83
+ group.name = 'board-contours'
84
+ contours.forEach((board, index) => {
85
+ const mesh = PcbScene3dRuntimeBoardMeshes.#buildBoardMesh(
86
+ THREE,
87
+ { ...sceneDescription, board },
88
+ normalizeDetailPoint
89
+ )
90
+ mesh.name = 'board-contour-' + (index + 1)
91
+ group.add(mesh)
92
+ })
93
+ return group
94
+ }
95
+
96
+ /**
97
+ * Builds one independently extruded board contour.
98
+ * @param {any} THREE Three.js namespace.
99
+ * @param {{ board?: any, detail?: any, boardAssemblyModel?: any }} sceneDescription Scene metadata.
100
+ * @param {(x: number, y: number) => { x: number, y: number }} normalizeDetailPoint Detail coordinate normalizer.
101
+ * @returns {any}
102
+ */
103
+ static #buildBoardMesh(THREE, sceneDescription, normalizeDetailPoint) {
71
104
  const board = sceneDescription.board
72
105
  const geometry = PcbScene3dBoardShapeFactory.buildGeometry(
73
106
  THREE,
@@ -97,7 +130,7 @@ export class PcbScene3dRuntimeBoardMeshes {
97
130
  : 0xc9ca78
98
131
  const surfaceColor = hasBoardAssemblyModel
99
132
  ? resolvedEdgeColor
100
- : PcbScene3dBoardMaterialPalette.resolveSurfaceColor(board, {
133
+ : PcbScene3dBoardMaterialPalette.resolveBoardSurfaceColor(board, {
101
134
  hasBoardAssemblyModel
102
135
  })
103
136
 
@@ -140,6 +173,39 @@ export class PcbScene3dRuntimeBoardMeshes {
140
173
  * @returns {any}
141
174
  */
142
175
  static buildBoardOutline(THREE, sceneDescription, normalizeDetailPoint) {
176
+ const contours = PcbScene3dRuntimeBoardMeshes.#boardContours(
177
+ sceneDescription.board
178
+ )
179
+ if (contours.length === 1) {
180
+ return PcbScene3dRuntimeBoardMeshes.#buildBoardOutline(
181
+ THREE,
182
+ { ...sceneDescription, board: contours[0] },
183
+ normalizeDetailPoint
184
+ )
185
+ }
186
+
187
+ const group = new THREE.Group()
188
+ group.name = 'board-contour-outlines'
189
+ contours.forEach((board, index) => {
190
+ const outline = PcbScene3dRuntimeBoardMeshes.#buildBoardOutline(
191
+ THREE,
192
+ { ...sceneDescription, board },
193
+ normalizeDetailPoint
194
+ )
195
+ outline.name = 'board-contour-outline-' + (index + 1)
196
+ group.add(outline)
197
+ })
198
+ return group
199
+ }
200
+
201
+ /**
202
+ * Builds one independently rendered board contour outline.
203
+ * @param {any} THREE Three.js namespace.
204
+ * @param {{ board?: any, detail?: any }} sceneDescription Scene metadata.
205
+ * @param {(x: number, y: number) => { x: number, y: number }} normalizeDetailPoint Detail coordinate normalizer.
206
+ * @returns {any}
207
+ */
208
+ static #buildBoardOutline(THREE, sceneDescription, normalizeDetailPoint) {
143
209
  const shape = PcbScene3dBoardShapeFactory.buildShape(
144
210
  THREE,
145
211
  sceneDescription.board,
@@ -166,6 +232,24 @@ export class PcbScene3dRuntimeBoardMeshes {
166
232
  )
167
233
  }
168
234
 
235
+ /**
236
+ * Resolves independently renderable contours while preserving legacy boards.
237
+ * @param {object} board Aggregate board metadata.
238
+ * @returns {object[]}
239
+ */
240
+ static #boardContours(board) {
241
+ const contours = Array.isArray(board?.contours)
242
+ ? board.contours.filter(Boolean)
243
+ : []
244
+ if (!contours.length) return [board || {}]
245
+
246
+ return contours.map((contour) => ({
247
+ ...board,
248
+ ...contour,
249
+ contours: []
250
+ }))
251
+ }
252
+
169
253
  /**
170
254
  * Traverses the board render group.
171
255
  * @param {any} root Root group or mesh.
@@ -12,9 +12,10 @@ export class PcbScene3dShapeHoleGeometryCleaner {
12
12
  * @param {any} THREE Three.js namespace.
13
13
  * @param {any} geometry Shape geometry.
14
14
  * @param {{ x: number, y: number }[][]} holes Shape hole polygons.
15
+ * @param {{ preparedPolygonCache?: Map }} [options] Request-scoped options.
15
16
  * @returns {any}
16
17
  */
17
- static removeCoveredHoleCenters(THREE, geometry, holes) {
18
+ static removeCoveredHoleCenters(THREE, geometry, holes, options = {}) {
18
19
  const coveredHoles =
19
20
  PcbScene3dShapeHoleGeometryCleaner.resolveCoveredHoleCenters(
20
21
  geometry,
@@ -32,7 +33,8 @@ export class PcbScene3dShapeHoleGeometryCleaner {
32
33
  {
33
34
  maxDepth: 12,
34
35
  maxEdgeLength:
35
- PcbScene3dShapeHoleGeometryCleaner.#CUTOUT_MAX_EDGE_LENGTH
36
+ PcbScene3dShapeHoleGeometryCleaner.#CUTOUT_MAX_EDGE_LENGTH,
37
+ preparedPolygonCache: options?.preparedPolygonCache
36
38
  }
37
39
  )
38
40
  }
@@ -1,3 +1,7 @@
1
+ import { CircuitJsonDocumentContext } from 'circuitjson-toolkit'
2
+ import { CircuitJsonBomBuilder } from 'circuitjson-toolkit/extensions'
3
+ import { PcbScene3dCircuitJsonGeometry } from './PcbScene3dCircuitJsonGeometry.mjs'
4
+ import { PcbScene3dCircuitJsonInput } from './PcbScene3dCircuitJsonInput.mjs'
1
5
  import { PcbScene3dText } from './PcbScene3dText.mjs'
2
6
 
3
7
  /**
@@ -13,8 +17,8 @@ export class PcbScene3dShellRenderer {
13
17
  */
14
18
  static render(documentModel, translate = null, options = {}) {
15
19
  const t = PcbScene3dText.createTranslator(translate)
16
- const pcb = documentModel?.pcb
17
- if (!pcb) {
20
+ const summary = PcbScene3dShellRenderer.#summary(documentModel, options)
21
+ if (!summary) {
18
22
  return (
19
23
  '<section class="viewer-empty">' +
20
24
  PcbScene3dShellRenderer.#escapeHtml(t('scene3d.noPcb')) +
@@ -22,10 +26,10 @@ export class PcbScene3dShellRenderer {
22
26
  )
23
27
  }
24
28
 
25
- const widthMil = Math.round(pcb.boardOutline.widthMil || 0)
26
- const heightMil = Math.round(pcb.boardOutline.heightMil || 0)
27
- const componentCount = pcb.components.length
28
- const bomRows = documentModel?.bom?.length || 0
29
+ const widthMil = Math.round(summary.widthMil || 0)
30
+ const heightMil = Math.round(summary.heightMil || 0)
31
+ const componentCount = summary.componentCount
32
+ const bomRows = summary.bomRows
29
33
 
30
34
  return (
31
35
  '<section class="scene-3d"><header class="svg-panel__header"><h3>' +
@@ -129,6 +133,71 @@ export class PcbScene3dShellRenderer {
129
133
  )
130
134
  }
131
135
 
136
+ /**
137
+ * Resolves shell statistics from legacy or canonical CircuitJSON input.
138
+ * @param {unknown} documentModel Document input.
139
+ * @param {{ drawFauxBoard?: boolean }} options Shell and adapter options.
140
+ * @returns {{ widthMil: number, heightMil: number, componentCount: number, bomRows: number } | null}
141
+ */
142
+ static #summary(documentModel, options) {
143
+ const legacyPcb = PcbScene3dShellRenderer.#ownData(documentModel, 'pcb')
144
+ if (legacyPcb && typeof legacyPcb === 'object') {
145
+ const boardOutline = legacyPcb.boardOutline || {}
146
+ const components = Array.isArray(legacyPcb.components)
147
+ ? legacyPcb.components
148
+ : []
149
+ const bom = PcbScene3dShellRenderer.#ownData(documentModel, 'bom')
150
+ return {
151
+ widthMil: Number(boardOutline.widthMil || 0),
152
+ heightMil: Number(boardOutline.heightMil || 0),
153
+ componentCount: components.length,
154
+ bomRows: Array.isArray(bom) ? bom.length : 0
155
+ }
156
+ }
157
+ if (!PcbScene3dCircuitJsonInput.isModel(documentModel)) return null
158
+
159
+ const context = CircuitJsonDocumentContext.prepare(documentModel, {
160
+ indexes: ['elements']
161
+ })
162
+ const index = context.getIndex('elements')
163
+ if (
164
+ !index.elementsByType.get('pcb_board')?.length &&
165
+ !index.elementsByType.get('pcb_panel')?.length &&
166
+ !(
167
+ options?.drawFauxBoard === true &&
168
+ index.elementsByType.get('pcb_component')?.length
169
+ )
170
+ ) {
171
+ return null
172
+ }
173
+ const board = PcbScene3dCircuitJsonGeometry.buildBoard(index, options)
174
+ return {
175
+ widthMil: board.widthMil,
176
+ heightMil: board.heightMil,
177
+ componentCount: (index.elementsByType.get('pcb_component') || [])
178
+ .length,
179
+ bomRows: CircuitJsonBomBuilder.build(context.model).length
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Reads one own data property without invoking accessors.
185
+ * @param {unknown} value Record candidate.
186
+ * @param {PropertyKey} key Property key.
187
+ * @returns {unknown}
188
+ */
189
+ static #ownData(value, key) {
190
+ if (!value || typeof value !== 'object') return undefined
191
+ try {
192
+ const descriptor = Object.getOwnPropertyDescriptor(value, key)
193
+ return descriptor && Object.hasOwn(descriptor, 'value')
194
+ ? descriptor.value
195
+ : undefined
196
+ } catch {
197
+ return undefined
198
+ }
199
+ }
200
+
132
201
  /**
133
202
  * Returns the checkbox checked attribute for one scene toggle.
134
203
  * @param {{ initialToggles?: Record<string, boolean> }} options Renderer options.