pcb-scene3d-viewer 1.3.2 → 1.3.4

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.
@@ -1,15 +1,14 @@
1
- import { PcbScene3dBoardSolderMaskFactory } from './PcbScene3dBoardSolderMaskFactory.mjs'
2
1
  import { PcbScene3dCameraRig } from './PcbScene3dCameraRig.mjs'
3
2
  import { PcbScene3dCircuitJsonAdapter } from './PcbScene3dCircuitJsonAdapter.mjs'
4
3
  import { PcbScene3dComponentVisibility } from './PcbScene3dComponentVisibility.mjs'
5
4
  import { PcbScene3dComponentAdjustment } from './PcbScene3dComponentAdjustment.mjs'
6
5
  import { PcbScene3dComponentAdjustmentRegistry } from './PcbScene3dComponentAdjustmentRegistry.mjs'
7
6
  import { PcbScene3dCompanionBasePlacementAdjuster } from './PcbScene3dCompanionBasePlacementAdjuster.mjs'
8
- import { PcbScene3dCopperDetailGroupBuilder } from './PcbScene3dCopperDetailGroupBuilder.mjs'
9
7
  import { PcbScene3dCopperFactory } from './PcbScene3dCopperFactory.mjs'
10
8
  import { PcbScene3dDetailCoordinateNormalizer } from './PcbScene3dDetailCoordinateNormalizer.mjs'
11
9
  import { PcbScene3dDeferredModelFinalizer } from './PcbScene3dDeferredModelFinalizer.mjs'
12
- import { PcbScene3dDrillVoidFactory } from './PcbScene3dDrillVoidFactory.mjs'
10
+ import { PcbScene3dGeometryTransfer } from './PcbScene3dGeometryTransfer.mjs'
11
+ import { PcbScene3dGeometryWorkerClient } from './PcbScene3dGeometryWorkerClient.mjs'
13
12
  import { PcbScene3dExternalCompanionFallback } from './PcbScene3dExternalCompanionFallback.mjs'
14
13
  import { PcbScene3dExternalModels } from './PcbScene3dExternalModels.mjs'
15
14
  import { PcbScene3dExternalPlacementDefaults } from './PcbScene3dExternalPlacementDefaults.mjs'
@@ -75,10 +74,11 @@ export class PcbScene3dRuntime {
75
74
  #hasSettledReady
76
75
  #renderScheduler
77
76
  #visibilityGraph
77
+ #geometryWorker
78
78
  /**
79
79
  * @param {HTMLElement} viewportNode
80
80
  * @param {any} sceneDescription Scene description or CircuitJSON model.
81
- * @param {{ setDiagnostics?: (messages: string[]) => void, setSelection?: (selection: any | null) => void, loadRuntimeModules?: () => Promise<{ THREE: any, OrbitControls: any }>, translate?: ((key: string) => string) | null, modelLoaderOptions?: object }} [hooks]
81
+ * @param {{ setDiagnostics?: (messages: string[]) => void, setSelection?: (selection: any | null) => void, loadRuntimeModules?: () => Promise<{ THREE: any, OrbitControls: any }>, translate?: ((key: string) => string) | null, modelLoaderOptions?: object, geometryWorkerOptions?: object }} [hooks]
82
82
  */
83
83
  constructor(viewportNode, sceneDescription, hooks = {}) {
84
84
  const renderModel =
@@ -129,6 +129,9 @@ export class PcbScene3dRuntime {
129
129
  }
130
130
  })
131
131
  this.#visibilityGraph = new PcbScene3dVisibilityGraph()
132
+ this.#geometryWorker = new PcbScene3dGeometryWorkerClient(
133
+ hooks.geometryWorkerOptions
134
+ )
132
135
  this.#resolveReadyPromise = null
133
136
  this.#readyPromise = new Promise((resolve) => {
134
137
  this.#resolveReadyPromise = resolve
@@ -218,6 +221,10 @@ export class PcbScene3dRuntime {
218
221
  /** @returns {void} */
219
222
  dispose() {
220
223
  this.#isDisposed = true
224
+ this.#geometryWorker.dispose()
225
+ ;['board', 'copper'].forEach((kind) =>
226
+ PcbScene3dGeometryTransfer.dispose(this.#groups.get(kind))
227
+ )
221
228
  this.#renderScheduler.cancel()
222
229
  this.#listeners.forEach(({ node, type, listener }) => {
223
230
  node.removeEventListener?.(type, listener)
@@ -283,9 +290,13 @@ export class PcbScene3dRuntime {
283
290
  this.#bindSelectionInteraction()
284
291
  this.#applyToggleVisibility()
285
292
  this.#render()
293
+ await this.#loadGeneratedGeometry('board')
294
+ if (this.#isDisposed) return
295
+ this.#render()
286
296
  await this.#loadDeferredDetail()
287
297
  this.#settleReady()
288
298
  } catch (error) {
299
+ if (this.#isDisposed) return
289
300
  this.#hooks.setDiagnostics?.([
290
301
  '3D preview could not start: ' +
291
302
  String(error?.message || error || 'Unknown error.')
@@ -342,44 +353,6 @@ export class PcbScene3dRuntime {
342
353
  .multiplyScalar(this.#initialRadius * 0.8)
343
354
  this.#scene.add(ambientLight, keyLight, fillLight)
344
355
  const boardGroup = new THREE.Group()
345
- boardGroup.add(
346
- PcbScene3dRuntimeBoardMeshes.buildBoardMesh(
347
- THREE,
348
- this.#sceneDescription,
349
- (x, y) => this.#normalizeDetailPoint(x, y)
350
- )
351
- )
352
- boardGroup.add(
353
- PcbScene3dBoardSolderMaskFactory.buildGroup(
354
- THREE,
355
- this.#sceneDescription,
356
- (x, y) => this.#normalizeDetailPoint(x, y)
357
- )
358
- )
359
- boardGroup.add(
360
- PcbScene3dDrillVoidFactory.buildGroup(
361
- THREE,
362
- this.#sceneDescription.detail,
363
- board.thicknessMil / 2,
364
- -board.thicknessMil / 2,
365
- (x, y) => this.#normalizeDetailPoint(x, y),
366
- {
367
- enabled: true,
368
- board,
369
- hasBoardAssemblyModel: Boolean(
370
- this.#sceneDescription.boardAssemblyModel
371
- ),
372
- sourceFormat: this.#sceneDescription.sourceFormat
373
- }
374
- )
375
- )
376
- boardGroup.add(
377
- PcbScene3dRuntimeBoardMeshes.buildBoardOutline(
378
- THREE,
379
- this.#sceneDescription,
380
- (x, y) => this.#normalizeDetailPoint(x, y)
381
- )
382
- )
383
356
  this.#groups.set('board', boardGroup)
384
357
  PcbScene3dRuntimeBoardMeshes.applyBoardFaceSide(
385
358
  this.#three,
@@ -536,7 +509,8 @@ export class PcbScene3dRuntime {
536
509
  if (this.#isDisposed) {
537
510
  return
538
511
  }
539
- this.#loadDeferredCopper()
512
+ await this.#loadGeneratedGeometry('copper')
513
+ if (this.#isDisposed) return
540
514
  this.#applyToggleVisibility()
541
515
  this.#render()
542
516
  this.#settleReady()
@@ -596,24 +570,29 @@ export class PcbScene3dRuntime {
596
570
  }
597
571
 
598
572
  /**
599
- * Builds and attaches copper and via detail once after the first frame.
600
- * @returns {void}
573
+ * Attaches exact worker-built geometry only while its runtime is alive.
574
+ * @param {'board' | 'copper'} kind Generated detail stage.
575
+ * @returns {Promise<void>}
601
576
  */
602
- #loadDeferredCopper() {
603
- const copperGroup = this.#groups.get('copper')
604
- if (!copperGroup || copperGroup.children.length) {
577
+ async #loadGeneratedGeometry(kind) {
578
+ const group = this.#groups.get(kind)
579
+ if (!group || group.children.length || this.#isDisposed) {
605
580
  return
606
581
  }
607
- const topZ = this.#sceneDescription.board.thicknessMil / 2 + 0.05
608
- const detailGroup = PcbScene3dCopperDetailGroupBuilder.build(
582
+ const detailGroup = await this.#geometryWorker.build(
609
583
  this.#three,
610
- this.#sceneDescription,
611
- topZ,
612
- (x, y) => this.#normalizeDetailPoint(x, y)
584
+ kind,
585
+ this.#sceneDescription
613
586
  )
587
+ if (this.#isDisposed) {
588
+ PcbScene3dGeometryTransfer.dispose(detailGroup)
589
+ return
590
+ }
614
591
  if (detailGroup.children.length) {
615
- copperGroup.add(detailGroup)
592
+ if (kind === 'board') group.add(...[...detailGroup.children])
593
+ else group.add(detailGroup)
616
594
  this.#applyViewScale(this.#presetState.get())
595
+ this.#applyToggleVisibility()
617
596
  }
618
597
  }
619
598
  /**
@@ -41,6 +41,7 @@ export class PcbScene3dSilkscreenChunkedFactory {
41
41
  }
42
42
 
43
43
  const group = new THREE.Group()
44
+ const cutoutContexts = {}
44
45
  for (const side of ['top', 'bottom']) {
45
46
  for (const recordKey of PcbScene3dSilkscreenChunkedFactory
46
47
  .#RECORD_KEYS) {
@@ -53,7 +54,8 @@ export class PcbScene3dSilkscreenChunkedFactory {
53
54
  topZ,
54
55
  bottomZ,
55
56
  normalizeBoardPoint,
56
- options
57
+ options,
58
+ cutoutContexts
57
59
  )
58
60
  }
59
61
  }
@@ -72,6 +74,7 @@ export class PcbScene3dSilkscreenChunkedFactory {
72
74
  * @param {number} bottomZ
73
75
  * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint
74
76
  * @param {{ yieldToMain?: () => Promise<void> | void, shouldContinue?: () => boolean }} options
77
+ * @param {object} cutoutContexts Build-scoped side preparations.
75
78
  * @returns {Promise<void>}
76
79
  */
77
80
  static async #appendRecordChunks(
@@ -83,7 +86,8 @@ export class PcbScene3dSilkscreenChunkedFactory {
83
86
  topZ,
84
87
  bottomZ,
85
88
  normalizeBoardPoint,
86
- options
89
+ options,
90
+ cutoutContexts
87
91
  ) {
88
92
  const sideSilkscreen = silkscreen?.[side] || {}
89
93
  const records = Array.isArray(sideSilkscreen[recordKey])
@@ -98,6 +102,13 @@ export class PcbScene3dSilkscreenChunkedFactory {
98
102
  PcbScene3dSilkscreenChunkedFactory.#shouldContinue(options);
99
103
  index += batchSize
100
104
  ) {
105
+ // Prepare lazily so cancelled or empty sides incur no cutout work.
106
+ cutoutContexts[side] ||=
107
+ PcbScene3dSilkscreenFactory.prepareCutoutContext(
108
+ sideSilkscreen,
109
+ normalizeBoardPoint,
110
+ side === 'bottom'
111
+ )
101
112
  const chunkGroup = PcbScene3dSilkscreenFactory.buildGroup(
102
113
  THREE,
103
114
  PcbScene3dSilkscreenChunkedFactory.#buildChunkSilkscreen(
@@ -108,7 +119,8 @@ export class PcbScene3dSilkscreenChunkedFactory {
108
119
  ),
109
120
  topZ,
110
121
  bottomZ,
111
- normalizeBoardPoint
122
+ normalizeBoardPoint,
123
+ cutoutContexts
112
124
  )
113
125
  if (chunkGroup.children.length) {
114
126
  group.add(chunkGroup)
@@ -66,6 +66,41 @@ export class PcbScene3dSilkscreenCutoutContext {
66
66
  this.#prepareNormalizedCutout(cutout)
67
67
  )
68
68
 
69
+ /** @type {{ x: number, y: number }[][]} */
70
+ #surfaceCutouts
71
+
72
+ /** @type {WeakMap<any[], object>} */
73
+ #preparedCutoutCache = new WeakMap()
74
+
75
+ /**
76
+ * Owns detached normalized cutouts for one side of a build. Callers must
77
+ * keep these coordinates unchanged for the lifetime of this context.
78
+ * @param {{ x: number, y: number }[][]} [surfaceCutouts] Normalized cutouts.
79
+ */
80
+ constructor(surfaceCutouts = []) {
81
+ this.#surfaceCutouts = surfaceCutouts
82
+ }
83
+
84
+ /**
85
+ * Returns the normalized surface cutouts shared by every side batch.
86
+ * @returns {{ x: number, y: number }[][]}
87
+ */
88
+ get surfaceCutouts() {
89
+ return this.#surfaceCutouts
90
+ }
91
+
92
+ /**
93
+ * Returns build-scoped caches for exact clipping consumers. Weak keys
94
+ * allow temporary fill-specific cutout lists to be released after use.
95
+ * @returns {{ preparedPolygonCache: Map, preparedCutoutCache: WeakMap }}
96
+ */
97
+ get geometryFilterOptions() {
98
+ return {
99
+ preparedPolygonCache: this.#preparedPolygonCache,
100
+ preparedCutoutCache: this.#preparedCutoutCache
101
+ }
102
+ }
103
+
69
104
  /**
70
105
  * Returns the lazy cache shared by exact geometry consumers.
71
106
  * @returns {Map<any, PcbScene3dPreparedPolygon>}
@@ -31,23 +31,33 @@ export class PcbScene3dSilkscreenFactory {
31
31
  * @param {number} topZ
32
32
  * @param {number} bottomZ
33
33
  * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint
34
+ * @param {{ top?: PcbScene3dSilkscreenCutoutContext, bottom?: PcbScene3dSilkscreenCutoutContext }} [cutoutContexts] Internal build-scoped side preparations.
34
35
  * @returns {any}
35
36
  */
36
- static buildGroup(THREE, silkscreen, topZ, bottomZ, normalizeBoardPoint) {
37
+ static buildGroup(
38
+ THREE,
39
+ silkscreen,
40
+ topZ,
41
+ bottomZ,
42
+ normalizeBoardPoint,
43
+ cutoutContexts = {}
44
+ ) {
37
45
  const group = new THREE.Group()
38
46
  const topGroup = PcbScene3dSilkscreenFactory.#buildSideGroup(
39
47
  THREE,
40
48
  silkscreen?.top,
41
49
  Math.abs(Number(topZ || 0)),
42
50
  normalizeBoardPoint,
43
- false
51
+ false,
52
+ cutoutContexts.top
44
53
  )
45
54
  const bottomGroup = PcbScene3dSilkscreenFactory.#buildSideGroup(
46
55
  THREE,
47
56
  silkscreen?.bottom,
48
57
  Math.abs(Number(bottomZ || 0)),
49
58
  normalizeBoardPoint,
50
- true
59
+ true,
60
+ cutoutContexts.bottom
51
61
  )
52
62
 
53
63
  if (topGroup.children.length) {
@@ -60,6 +70,30 @@ export class PcbScene3dSilkscreenFactory {
60
70
  return group
61
71
  }
62
72
 
73
+ /**
74
+ * Normalizes detached cutouts once for all batches on one board side.
75
+ * The returned context must not be reused across separate scene builds.
76
+ * @param {{ drillCutouts?: object[][], copperCutouts?: object[][] } | undefined} silkscreen Side artwork.
77
+ * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint Board normalizer.
78
+ * @param {boolean} mirrorY Whether the side is mirrored.
79
+ * @returns {PcbScene3dSilkscreenCutoutContext}
80
+ */
81
+ static prepareCutoutContext(silkscreen, normalizeBoardPoint, mirrorY) {
82
+ const drillCutouts = PcbScene3dSilkscreenFactory.#normalizeCutouts(
83
+ silkscreen?.drillCutouts,
84
+ normalizeBoardPoint,
85
+ mirrorY
86
+ )
87
+ const copperCutouts = PcbScene3dSilkscreenFactory.#normalizeCutouts(
88
+ silkscreen?.copperCutouts,
89
+ normalizeBoardPoint,
90
+ mirrorY
91
+ )
92
+ return new PcbScene3dSilkscreenCutoutContext(
93
+ drillCutouts.concat(copperCutouts)
94
+ )
95
+ }
96
+
63
97
  /**
64
98
  * Builds one side-specific silkscreen group.
65
99
  * @param {any} THREE
@@ -67,9 +101,17 @@ export class PcbScene3dSilkscreenFactory {
67
101
  * @param {number} z
68
102
  * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint
69
103
  * @param {boolean} mirrorY
104
+ * @param {PcbScene3dSilkscreenCutoutContext} [preparedCutoutContext] Shared side preparation.
70
105
  * @returns {any}
71
106
  */
72
- static #buildSideGroup(THREE, silkscreen, z, normalizeBoardPoint, mirrorY) {
107
+ static #buildSideGroup(
108
+ THREE,
109
+ silkscreen,
110
+ z,
111
+ normalizeBoardPoint,
112
+ mirrorY,
113
+ preparedCutoutContext
114
+ ) {
73
115
  const group = new THREE.Group()
74
116
  const strokeColor = PcbScene3dSilkscreenFactory.#resolveMaterialColor(
75
117
  silkscreen?.strokeColor
@@ -89,18 +131,14 @@ export class PcbScene3dSilkscreenFactory {
89
131
  ? fillColor
90
132
  : textMaterialColor
91
133
  const strokeZ = z + PcbScene3dSilkscreenFactory.#STROKE_Z_OFFSET
92
- const drillCutouts = PcbScene3dSilkscreenFactory.#normalizeCutouts(
93
- silkscreen?.drillCutouts,
94
- normalizeBoardPoint,
95
- mirrorY
96
- )
97
- const copperCutouts = PcbScene3dSilkscreenFactory.#normalizeCutouts(
98
- silkscreen?.copperCutouts,
99
- normalizeBoardPoint,
100
- mirrorY
101
- )
102
- const surfaceCutouts = drillCutouts.concat(copperCutouts)
103
- const cutoutContext = new PcbScene3dSilkscreenCutoutContext()
134
+ const cutoutContext =
135
+ preparedCutoutContext ||
136
+ PcbScene3dSilkscreenFactory.prepareCutoutContext(
137
+ silkscreen,
138
+ normalizeBoardPoint,
139
+ mirrorY
140
+ )
141
+ const surfaceCutouts = cutoutContext.surfaceCutouts
104
142
  const strokeMaterial = PcbScene3dSilkscreenFactory.#buildMaterial(
105
143
  THREE,
106
144
  strokeColor
@@ -147,9 +185,7 @@ export class PcbScene3dSilkscreenFactory {
147
185
  mirrorY,
148
186
  fillMaterial,
149
187
  surfaceCutouts,
150
- {
151
- preparedPolygonCache: cutoutContext.preparedPolygonCache
152
- }
188
+ cutoutContext.geometryFilterOptions
153
189
  )
154
190
  const texts = Array.isArray(silkscreen?.texts) ? silkscreen.texts : []
155
191
  const renderableTexts = texts.filter(
@@ -179,7 +215,7 @@ export class PcbScene3dSilkscreenFactory {
179
215
  fog: false
180
216
  },
181
217
  mirrorY,
182
- preparedPolygonCache: cutoutContext.preparedPolygonCache,
218
+ ...cutoutContext.geometryFilterOptions,
183
219
  side: mirrorY ? 'bottom' : 'top'
184
220
  }
185
221
  )
@@ -534,7 +570,7 @@ export class PcbScene3dSilkscreenFactory {
534
570
  maxDepth: 12,
535
571
  maxEdgeLength:
536
572
  PcbScene3dSilkscreenFactory.#CUTOUT_MAX_EDGE_LENGTH,
537
- preparedPolygonCache: cutoutContext.preparedPolygonCache
573
+ ...cutoutContext.geometryFilterOptions
538
574
  }
539
575
  )
540
576
  const mesh = new THREE.Mesh(geometry, material)
@@ -823,7 +859,7 @@ export class PcbScene3dSilkscreenFactory {
823
859
  maxDepth: 12,
824
860
  maxEdgeLength:
825
861
  PcbScene3dSilkscreenFactory.#CUTOUT_MAX_EDGE_LENGTH,
826
- preparedPolygonCache: cutoutContext?.preparedPolygonCache
862
+ ...cutoutContext?.geometryFilterOptions
827
863
  }
828
864
  ),
829
865
  material
@@ -22,7 +22,7 @@ export class PcbScene3dSilkscreenFillSeamBuilder {
22
22
  * @param {boolean} mirrorY Whether the side is mirrored.
23
23
  * @param {any} material Shared fill material.
24
24
  * @param {{ x: number, y: number }[][]} [cutouts] Cutouts that should remain uncovered.
25
- * @param {{ preparedPolygonCache?: Map }} [options] Request-scoped options.
25
+ * @param {{ preparedPolygonCache?: Map, preparedCutoutCache?: WeakMap }} [options] Request-scoped options.
26
26
  * @returns {any[]}
27
27
  */
28
28
  static buildMeshes(
@@ -117,7 +117,7 @@ export class PcbScene3dSilkscreenFillSeamBuilder {
117
117
  * @param {number[]} positions Position buffer.
118
118
  * @param {any} material Shared material.
119
119
  * @param {{ x: number, y: number }[][]} cutouts Cutout polygons.
120
- * @param {{ preparedPolygonCache?: Map }} options Request-scoped options.
120
+ * @param {{ preparedPolygonCache?: Map, preparedCutoutCache?: WeakMap }} options Request-scoped options.
121
121
  * @returns {void}
122
122
  */
123
123
  static #appendMesh(meshes, THREE, positions, material, cutouts, options) {
@@ -139,7 +139,8 @@ export class PcbScene3dSilkscreenFillSeamBuilder {
139
139
  PcbScene3dCutoutGeometryFilter.filter(THREE, geometry, cutouts, {
140
140
  maxDepth: 12,
141
141
  maxEdgeLength: 2,
142
- preparedPolygonCache: options?.preparedPolygonCache
142
+ preparedPolygonCache: options?.preparedPolygonCache,
143
+ preparedCutoutCache: options?.preparedCutoutCache
143
144
  }),
144
145
  material
145
146
  )
@@ -40,12 +40,18 @@ export class PcbScene3dWorkerClient {
40
40
 
41
41
  const requestId = 'scene3d-request-' + this.#requestSequence++
42
42
  this.#pendingRequests.set(requestId, { resolve, reject })
43
- this.#worker.postMessage({
44
- type: 'scene3d:prepare',
45
- requestId,
46
- documentModel,
47
- sessionAssets
48
- })
43
+ try {
44
+ this.#worker.postMessage({
45
+ type: 'scene3d:prepare',
46
+ requestId,
47
+ documentModel,
48
+ sessionAssets
49
+ })
50
+ } catch (error) {
51
+ // A synchronous clone failure will never receive a response.
52
+ this.#pendingRequests.delete(requestId)
53
+ reject(error)
54
+ }
49
55
  })
50
56
  }
51
57