pcb-scene3d-viewer 1.3.1 → 1.3.3

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/README.md CHANGED
@@ -34,6 +34,19 @@ annulus, mixed top/bottom states remain independent, and fully open Gerber vias
34
34
  retain the exposed-copper path. See the
35
35
  [1.3.1 release notes](docs/release-notes-v1.3.1.md).
36
36
 
37
+ Version 1.3.2 applies render-group and component visibility through a
38
+ self-adjusting dependency graph, so persistent runtimes repair only the stage
39
+ affected by a known toggle or structural revision. It also keeps tented copper
40
+ below mask surfaces, avoids duplicate pad barrels for filtered vias, and
41
+ short-circuits non-subdividing copper-fill clipping without changing its
42
+ observable geometry. See the
43
+ [1.3.2 release notes](docs/release-notes-v1.3.2.md).
44
+
45
+ Version 1.3.3 preserves validated STEP source anchors and avoids applying a
46
+ second quarter-turn when the imported assembly envelope proves that its depth
47
+ and height axes are already exchanged. See the
48
+ [1.3.3 release notes](docs/release-notes-v1.3.3.md).
49
+
37
50
  ## CircuitJSON 1.1 convergence
38
51
 
39
52
  Version 1.2.2 accepts the common document and prepared-context shapes returned
@@ -142,6 +155,8 @@ const controller = new PcbScene3dController(viewportNode, document)
142
155
 
143
156
  - [API](docs/api.md)
144
157
  - [CircuitJSON usage](docs/circuitjson.md)
158
+ - [1.3.3 release notes](docs/release-notes-v1.3.3.md)
159
+ - [1.3.2 release notes](docs/release-notes-v1.3.2.md)
145
160
  - [1.3.1 release notes](docs/release-notes-v1.3.1.md)
146
161
  - [1.2.2 release notes](docs/release-notes-v1.2.2.md)
147
162
  - [1.3.0 release notes](docs/release-notes-v1.3.0.md)
package/docs/api.md CHANGED
@@ -165,10 +165,26 @@ traversal, absolute paths, and URL schemes are never attached implicitly.
165
165
 
166
166
  ## Runtime
167
167
 
168
+ ### `PcbScene3dVisibilityGraph`
169
+
170
+ Applies render-group visibility and per-component visibility as two ordered
171
+ self-adjusting computations. `apply(state, changedPaths)` starts at readers of
172
+ the supplied toggle or revision roots, reuses unaffected effects, and returns
173
+ the stage result map with each stage's `recomputed` flag. A null change set is
174
+ conservative and repairs both stages. `clear()` reclaims stored traces.
175
+
176
+ The public root and `/scene3d` entrypoint also re-export the canonical
177
+ `SelfAdjustingComputation` identity from `circuitjson-toolkit`.
178
+
168
179
  ### `new PcbScene3dRuntime(viewportNode, sceneDescription, hooks?)`
169
180
 
170
181
  Creates the Three.js scene in a browser viewport.
171
182
 
183
+ The persistent runtime routes toggle, selection, hidden-component, and model
184
+ topology changes through `PcbScene3dVisibilityGraph`. Changes within mutable
185
+ maps and sets use explicit structural revision roots so identity-stable
186
+ containers cannot cause stale visibility reuse.
187
+
172
188
  Hooks:
173
189
 
174
190
  - `setDiagnostics(messages)`: receives user-facing diagnostics.
@@ -186,6 +186,13 @@ Embedded STEP models can use:
186
186
  }
187
187
  ```
188
188
 
189
+ For imported STEP assemblies, the runtime compares the fully assembled mesh
190
+ envelope with an authored model-bounds projection before applying a separate
191
+ quarter-turn around the model X axis. When the imported assembly has already
192
+ exchanged its depth and height axes, the duplicate tilt is omitted. Toolkits
193
+ can set `modelTransform.preserveSourceAnchor: true` when an authored body origin
194
+ must remain fixed without requesting component-center recovery.
195
+
189
196
  ## Detail Primitives
190
197
 
191
198
  The runtime expects pre-normalized primitive lists for:
@@ -0,0 +1,39 @@
1
+ # pcb-scene3d-viewer 1.3.2
2
+
3
+ Version 1.3.2 adds persistent self-adjusting visibility propagation and fixes
4
+ several general copper, via, and solder-mask interactions. The changes derive
5
+ from scene topology and authored surface state rather than source filenames or
6
+ project-specific rules.
7
+
8
+ ## Incremental visibility
9
+
10
+ - `PcbScene3dVisibilityGraph` applies render-group visibility and component
11
+ visibility as two ordered computations backed by the canonical
12
+ `SelfAdjustingComputation` runtime from `circuitjson-toolkit` 1.4.1.
13
+ - Known toggle paths repair only render-group effects. Selection,
14
+ hidden-component, and identity-stable map/set mutations advance an explicit
15
+ component revision and repair only component effects.
16
+ - Unknown topology changes conservatively repair both stages, and runtime
17
+ disposal explicitly reclaims retained traces and reverse-reader edges.
18
+ - The package root and `/scene3d` entrypoint export both the canonical runtime
19
+ identity and the visibility graph.
20
+
21
+ ## Copper and mask correctness
22
+
23
+ - Tented via faces no longer carry exposed copper geometry through the rendered
24
+ solder-mask surface. Fully open vias keep their full span, one-sided tenting
25
+ retains only the exposed half, and fully tented vias retain mask treatment
26
+ without a visible face-spanning copper annulus.
27
+ - Filtered tented vias still participate in plated-hole identity checks, so a
28
+ matching through-hole pad cannot create a duplicate copper barrel.
29
+ - Copper-fill clipping with partial subdivision disabled returns as soon as an
30
+ uncovered sample proves that a triangle must be kept, while fully sampled
31
+ triangles still use exact boundary crossing checks.
32
+
33
+ ## Verification
34
+
35
+ - Tests cover stage-level recomputation, conservative invalidation,
36
+ from-scratch visibility consistency, one- and two-sided tenting, duplicate
37
+ barrel prevention, and non-subdividing fill overlap behavior.
38
+ - Release gates include the complete package suite, exact-geometry and context
39
+ asset benchmarks, formatting, and npm package dry run.
@@ -0,0 +1,28 @@
1
+ # pcb-scene3d-viewer 1.3.3
2
+
3
+ Version 1.3.3 improves external STEP placement when source-authored anchors and
4
+ pre-oriented assemblies are present.
5
+
6
+ ## STEP placement fidelity
7
+
8
+ - An explicit `preserveSourceAnchor` transform keeps a validated source body
9
+ origin fixed without activating component-center recovery.
10
+ - Embedded STEP assemblies omit a duplicate quarter-turn only when their loaded
11
+ mesh envelope proves that the authored depth and height axes are already
12
+ exchanged.
13
+ - Board-space component yaw and the original external-model asset remain
14
+ unchanged.
15
+
16
+ ## Compatibility
17
+
18
+ - Detection uses source origin, projection metadata, mesh dimensions, and
19
+ bounded error thresholds; it does not inspect filenames, project identities,
20
+ component labels, or library names.
21
+ - Existing scene colors, board geometry, and public APIs remain unchanged.
22
+
23
+ ## Verification
24
+
25
+ - Repository-owned tests cover preserved source anchors and embedded-axis
26
+ normalization alongside the existing external-model placement suite.
27
+ - The complete package suite, formatting check, and npm package dry run are
28
+ required for release.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pcb-scene3d-viewer",
3
- "version": "1.3.1",
3
+ "version": "1.3.3",
4
4
  "description": "Reusable Three.js PCB 3D scene viewer for normalized ECAD and CircuitJSON scene descriptions",
5
5
  "keywords": [
6
6
  "pcb",
@@ -38,6 +38,8 @@
38
38
  "docs/release-notes-v1.2.2.md",
39
39
  "docs/release-notes-v1.3.0.md",
40
40
  "docs/release-notes-v1.3.1.md",
41
+ "docs/release-notes-v1.3.2.md",
42
+ "docs/release-notes-v1.3.3.md",
41
43
  "docs/model-format.md",
42
44
  "docs/testing.md",
43
45
  "spec",
@@ -59,7 +61,7 @@
59
61
  },
60
62
  "dependencies": {
61
63
  "@sunbox/occt-import-js": "^0.0.28",
62
- "circuitjson-toolkit": "^1.2.0",
64
+ "circuitjson-toolkit": "^1.4.1",
63
65
  "earcut": "3.0.2",
64
66
  "fflate": "^0.8.2",
65
67
  "polygon-clipping": "^0.15.7",
@@ -12,6 +12,8 @@ scene descriptions.
12
12
  from canonical bytes, session files, or explicitly enabled URLs.
13
13
  - Camera presets, view compensation, selection styling, picking, and visibility
14
14
  toggles.
15
+ - Ordered self-adjusting render-group and per-component visibility propagation
16
+ backed by the canonical `circuitjson-toolkit` runtime.
15
17
  - Optional DOM shell/controller helpers for hosts that want ready-made scene
16
18
  chrome.
17
19
  - ZIP export of resolved component model assets.
@@ -140,7 +140,8 @@ export class PcbScene3dCopperDetailFilter {
140
140
 
141
141
  return PcbScene3dCopperDetailFilter.#appendPadBarrelSpecs(
142
142
  vias,
143
- detail.pads
143
+ detail.pads,
144
+ detail.vias
144
145
  )
145
146
  }
146
147
 
@@ -322,12 +323,13 @@ export class PcbScene3dCopperDetailFilter {
322
323
  * Appends copper barrels for through-hole pads with copper annuli.
323
324
  * @param {any[]} vias Visible via list.
324
325
  * @param {any[] | undefined} pads Pad list.
326
+ * @param {any[] | undefined} sourceVias All parsed vias, including tented vias.
325
327
  * @returns {any[]}
326
328
  */
327
- static #appendPadBarrelSpecs(vias, pads) {
329
+ static #appendPadBarrelSpecs(vias, pads, sourceVias) {
328
330
  const output = [...(vias || [])]
329
331
  const seen = new Set(
330
- output.map((via) =>
332
+ [...(sourceVias || []), ...output].map((via) =>
331
333
  PcbScene3dCopperDetailFilter.#platedHoleKey(via)
332
334
  )
333
335
  )
@@ -127,10 +127,17 @@ export class PcbScene3dCopperFillAreaClipper {
127
127
  options
128
128
  )
129
129
  const coverage =
130
- PcbScene3dCopperFillAreaClipper.#resolveTriangleCoverage(
131
- triangle,
132
- areas
130
+ PcbScene3dCopperFillAreaClipper.#shouldSubdividePartialTriangles(
131
+ options
133
132
  )
133
+ ? PcbScene3dCopperFillAreaClipper.#resolveTriangleCoverage(
134
+ triangle,
135
+ areas
136
+ )
137
+ : PcbScene3dCopperFillAreaClipper.#resolveNonSubdividingCoverage(
138
+ triangle,
139
+ areas
140
+ )
134
141
 
135
142
  if (coverage === 'none') {
136
143
  PcbScene3dCopperFillAreaClipper.#appendTriangle(positions, triangle)
@@ -194,6 +201,39 @@ export class PcbScene3dCopperFillAreaClipper {
194
201
  return options?.subdividePartialTriangles !== false
195
202
  }
196
203
 
204
+ /**
205
+ * Resolves coverage when partial triangles are kept without subdivision.
206
+ * Partial and uncovered triangles have the same observable keep result in
207
+ * this mode, so the first uncovered sample can return without querying area
208
+ * boundaries. Fully sampled triangles still require the exact boundary
209
+ * predicate to distinguish complete coverage from a crossing.
210
+ * @param {{ x: number, y: number }[]} triangle Triangle points.
211
+ * @param {object[]} areas Filled copper areas.
212
+ * @returns {'none' | 'full'}
213
+ */
214
+ static #resolveNonSubdividingCoverage(triangle, areas) {
215
+ const samples = [
216
+ triangle[0],
217
+ triangle[1],
218
+ triangle[2],
219
+ PcbScene3dCopperFillAreaClipper.#centroid(triangle)
220
+ ]
221
+ for (const point of samples) {
222
+ if (
223
+ !PcbScene3dCopperFillAreaClipper.#isPointInAnyArea(point, areas)
224
+ ) {
225
+ return 'none'
226
+ }
227
+ }
228
+
229
+ return PcbScene3dCopperFillAreaClipper.#triangleCrossesAnyBoundary(
230
+ triangle,
231
+ areas
232
+ )
233
+ ? 'none'
234
+ : 'full'
235
+ }
236
+
197
237
  /**
198
238
  * Resolves whether a triangle is outside, inside, or crossing filled areas.
199
239
  * @param {{ x: number, y: number }[]} triangle Triangle points.
@@ -6,12 +6,13 @@ export class PcbScene3dExternalModelSourceOriginPolicy {
6
6
  * Checks whether an explicit owner anchor should keep source-origin repair
7
7
  * disabled. Translucent cover-sized bodies use corner-origin STEP data, so
8
8
  * they still need the embedded source-origin correction.
9
- * @param {{ bodyOpacity?: number | string, modelTransform?: { ownerAnchorOffsetMil?: object }, projection?: { boundsMil?: { width?: number, depth?: number } } }} placement Placement metadata.
9
+ * @param {{ bodyOpacity?: number | string, modelTransform?: { ownerAnchorOffsetMil?: object, preserveSourceAnchor?: boolean }, projection?: { boundsMil?: { width?: number, depth?: number } } }} placement Placement metadata.
10
10
  * @returns {boolean}
11
11
  */
12
12
  static shouldSkipOwnerAnchoredAdjustment(placement) {
13
13
  return (
14
- Boolean(placement?.modelTransform?.ownerAnchorOffsetMil) &&
14
+ (Boolean(placement?.modelTransform?.ownerAnchorOffsetMil) ||
15
+ placement?.modelTransform?.preserveSourceAnchor === true) &&
15
16
  !PcbScene3dExternalModelSourceOriginPolicy.#isTransparentCoverSizedPlacement(
16
17
  placement
17
18
  )
@@ -362,7 +362,16 @@ export class PcbScene3dExternalModels {
362
362
  viewCompensationGroup
363
363
  )
364
364
  const modelTransform = placement?.modelTransform || {}
365
- const modelRotation = modelTransform.rotationDeg || {}
365
+ const authoredModelRotation = modelTransform.rotationDeg || {}
366
+ const modelRotation =
367
+ PcbScene3dExternalModels.#resolveEmbeddedModelRotation(
368
+ placement,
369
+ modelGroup,
370
+ authoredModelRotation
371
+ )
372
+ if (modelRotation !== authoredModelRotation) {
373
+ modelGroup.userData.scene3dEmbeddedAxisTiltRepair = true
374
+ }
366
375
  const modelOffset =
367
376
  PcbScene3dExternalModels.#resolveModelOffset(modelTransform)
368
377
  const sourceOriginAdjustment =
@@ -595,6 +604,73 @@ export class PcbScene3dExternalModels {
595
604
  }
596
605
  }
597
606
 
607
+ /**
608
+ * Removes a duplicated quarter-turn when the imported STEP assembly has
609
+ * already exchanged its authored depth and height axes. Native STEP point
610
+ * envelopes describe pre-assembly coordinates, while OCCT returns the
611
+ * fully placed mesh, so comparing both frames is required before applying
612
+ * the separate Altium body tilt.
613
+ * @param {{ externalModel?: { origin?: string }, projection?: { source?: string, boundsMil?: { depth?: number, height?: number } } }} placement Placement metadata.
614
+ * @param {{ userData?: { scene3dSourceBoundsMil?: { sizeY?: number, sizeZ?: number } } }} modelGroup Loaded model group.
615
+ * @param {{ x?: number, y?: number, z?: number }} modelRotation Authored model rotation.
616
+ * @returns {{ x?: number, y?: number, z?: number }}
617
+ */
618
+ static #resolveEmbeddedModelRotation(placement, modelGroup, modelRotation) {
619
+ const sourceBounds = modelGroup?.userData?.scene3dSourceBoundsMil || {}
620
+ const projectionBounds = placement?.projection?.boundsMil || {}
621
+ const sourceY = Math.abs(Number(sourceBounds.sizeY || 0))
622
+ const sourceZ = Math.abs(Number(sourceBounds.sizeZ || 0))
623
+ const projectedDepth = Math.abs(Number(projectionBounds.depth || 0))
624
+ const projectedHeight = Math.abs(Number(projectionBounds.height || 0))
625
+ const tilt = PcbScene3dExternalModels.#normalizeAngle(modelRotation?.x)
626
+ const directError =
627
+ PcbScene3dExternalModels.#relativeDimensionError(
628
+ sourceY,
629
+ projectedDepth
630
+ ) +
631
+ PcbScene3dExternalModels.#relativeDimensionError(
632
+ sourceZ,
633
+ projectedHeight
634
+ )
635
+ const exchangedError =
636
+ PcbScene3dExternalModels.#relativeDimensionError(
637
+ sourceY,
638
+ projectedHeight
639
+ ) +
640
+ PcbScene3dExternalModels.#relativeDimensionError(
641
+ sourceZ,
642
+ projectedDepth
643
+ )
644
+
645
+ if (
646
+ String(placement?.externalModel?.origin || '').toLowerCase() !==
647
+ 'embedded' ||
648
+ String(placement?.projection?.source || '').toLowerCase() !==
649
+ 'model-bounds' ||
650
+ (tilt !== 90 && tilt !== 270) ||
651
+ Math.min(sourceY, sourceZ, projectedDepth, projectedHeight) <= 0 ||
652
+ Math.max(projectedDepth, projectedHeight) /
653
+ Math.min(projectedDepth, projectedHeight) <
654
+ 1.25 ||
655
+ exchangedError > 0.2 ||
656
+ directError - exchangedError < 0.5
657
+ ) {
658
+ return modelRotation
659
+ }
660
+
661
+ return { ...modelRotation, x: 0 }
662
+ }
663
+
664
+ /**
665
+ * Measures a scale-independent dimension mismatch.
666
+ * @param {number} actual Imported dimension.
667
+ * @param {number} expected Authored dimension.
668
+ * @returns {number}
669
+ */
670
+ static #relativeDimensionError(actual, expected) {
671
+ return Math.abs(actual - expected) / Math.max(actual, expected, 1)
672
+ }
673
+
598
674
  /**
599
675
  * Checks whether source Z is the model's intentional edge-extension axis
600
676
  * instead of a square-package source-origin bias.
@@ -18,7 +18,6 @@ import { PcbScene3dFallbackVisibility } from './PcbScene3dFallbackVisibility.mjs
18
18
  import { PcbScene3dInteractionHints } from './PcbScene3dInteractionHints.mjs'
19
19
  import { PcbScene3dModelSearchPlacement } from './PcbScene3dModelSearchPlacement.mjs'
20
20
  import { PcbScene3dPresetState } from './PcbScene3dPresetState.mjs'
21
- import { PcbScene3dRenderGroupVisibility } from './PcbScene3dRenderGroupVisibility.mjs'
22
21
  import { PcbScene3dRenderScheduler } from './PcbScene3dRenderScheduler.mjs'
23
22
  import { PcbScene3dRuntimeBoardMeshes } from './PcbScene3dRuntimeBoardMeshes.mjs'
24
23
  import { PcbScene3dRuntimeHelpers } from './PcbScene3dRuntimeHelpers.mjs'
@@ -29,6 +28,7 @@ import { PcbScene3dSelectionStyler } from './PcbScene3dSelectionStyler.mjs'
29
28
  import { PcbScene3dStaticBodyFactory } from './PcbScene3dStaticBodyFactory.mjs'
30
29
  import { PcbScene3dViewportResize } from './PcbScene3dViewportResize.mjs'
31
30
  import { PcbScene3dViewScale } from './PcbScene3dViewScale.mjs'
31
+ import { PcbScene3dVisibilityGraph } from './PcbScene3dVisibilityGraph.mjs'
32
32
  const COPPER_HALF_MIL = PcbScene3dCopperFactory.visualHalfThicknessMil()
33
33
  const Z_MIL = {
34
34
  paste: COPPER_HALF_MIL + 0.28,
@@ -74,6 +74,7 @@ export class PcbScene3dRuntime {
74
74
  #resolveReadyPromise
75
75
  #hasSettledReady
76
76
  #renderScheduler
77
+ #visibilityGraph
77
78
  /**
78
79
  * @param {HTMLElement} viewportNode
79
80
  * @param {any} sceneDescription Scene description or CircuitJSON model.
@@ -127,6 +128,7 @@ export class PcbScene3dRuntime {
127
128
  this.#render()
128
129
  }
129
130
  })
131
+ this.#visibilityGraph = new PcbScene3dVisibilityGraph()
130
132
  this.#resolveReadyPromise = null
131
133
  this.#readyPromise = new Promise((resolve) => {
132
134
  this.#resolveReadyPromise = resolve
@@ -165,7 +167,7 @@ export class PcbScene3dRuntime {
165
167
  return
166
168
  }
167
169
  this.#toggles[toggleName] = enabled
168
- this.#applyToggleVisibility()
170
+ this.#applyToggleVisibility([['toggles', toggleName]])
169
171
  this.#render()
170
172
  }
171
173
  /** @param {string} designator */
@@ -183,7 +185,7 @@ export class PcbScene3dRuntime {
183
185
  ) {
184
186
  return
185
187
  }
186
- this.#applyToggleVisibility()
188
+ this.#applyToggleVisibility([['componentRevision']])
187
189
  this.#updateSelectionMarker()
188
190
  this.#render()
189
191
  }
@@ -243,6 +245,7 @@ export class PcbScene3dRuntime {
243
245
  this.#selectionRoots.clear()
244
246
  this.#hiddenComponentDesignators.clear()
245
247
  this.#componentAdjustmentRegistry.clear()
248
+ this.#visibilityGraph.clear()
246
249
  this.#fallbackBodyRoots.clear()
247
250
  this.#loadedExternalModelDesignators.clear()
248
251
  this.#modelSearchExternalModelRoots.clear()
@@ -771,28 +774,24 @@ export class PcbScene3dRuntime {
771
774
  this.#render()
772
775
  }
773
776
 
774
- /** @returns {void} */
775
- #applyToggleVisibility() {
776
- PcbScene3dRenderGroupVisibility.apply({
777
- groups: this.#groups,
778
- toggles: this.#toggles,
779
- fallbackBodyRoots: this.#fallbackBodyRoots,
780
- loadedExternalModelDesignators:
781
- this.#loadedExternalModelDesignators,
782
- modelSearchExternalModelRoots: this.#modelSearchExternalModelRoots,
783
- hasLoadedBoardAssemblyModel: this.#hasLoadedBoardAssemblyModel
784
- })
785
- PcbScene3dComponentVisibility.apply({
786
- selectionRoots: this.#selectionRoots,
787
- selectedDesignator: this.#selectedDesignator,
788
- hiddenDesignators: this.#hiddenComponentDesignators,
789
- fallbackBodyRoots: this.#fallbackBodyRoots,
790
- loadedExternalModelDesignators:
791
- this.#loadedExternalModelDesignators,
792
- modelSearchExternalModelRoots: this.#modelSearchExternalModelRoots,
793
- toggles: this.#toggles,
794
- hasLoadedBoardAssemblyModel: this.#hasLoadedBoardAssemblyModel
795
- })
777
+ /** @param {PropertyKey[][] | null} [changedPaths] @returns {void} */
778
+ #applyToggleVisibility(changedPaths = null) {
779
+ this.#visibilityGraph.apply(
780
+ {
781
+ groups: this.#groups,
782
+ toggles: this.#toggles,
783
+ fallbackBodyRoots: this.#fallbackBodyRoots,
784
+ loadedExternalModelDesignators:
785
+ this.#loadedExternalModelDesignators,
786
+ modelSearchExternalModelRoots:
787
+ this.#modelSearchExternalModelRoots,
788
+ hasLoadedBoardAssemblyModel: this.#hasLoadedBoardAssemblyModel,
789
+ selectionRoots: this.#selectionRoots,
790
+ selectedDesignator: this.#selectedDesignator,
791
+ hiddenDesignators: this.#hiddenComponentDesignators
792
+ },
793
+ changedPaths
794
+ )
796
795
  }
797
796
 
798
797
  /**
@@ -949,7 +948,7 @@ export class PcbScene3dRuntime {
949
948
  SELECTION_HIGHLIGHT_COLOR
950
949
  )
951
950
  this.#selectedDesignator = normalizedDesignator
952
- this.#applyToggleVisibility()
951
+ this.#applyToggleVisibility([['componentRevision']])
953
952
  this.#updateSelectionMarker()
954
953
  this.#render()
955
954
  }
@@ -39,27 +39,30 @@ export class PcbScene3dViaFactory {
39
39
  const renderMode = PcbScene3dViaLayerSpan.renderMode(via)
40
40
  if (!renderMode) return
41
41
 
42
- const geometry = PcbScene3dViaFactory.#resolveGeometry(
43
- THREE,
44
- geometryCache,
45
- via,
46
- thicknessMil,
47
- renderMode
48
- )
49
- const mesh = new THREE.Mesh(geometry, copperMaterial)
50
42
  const point = normalizeBoardPoint(
51
43
  Number(via?.x || 0),
52
44
  Number(via?.y || 0)
53
45
  )
54
- mesh.position.set(
55
- point.x,
56
- point.y,
57
- PcbScene3dViaFactory.#centerZ(renderMode, thicknessMil)
46
+ const copperSpans = PcbScene3dViaFactory.#resolveCopperSpans(
47
+ via,
48
+ renderMode,
49
+ thicknessMil,
50
+ Boolean(options?.surfaceMaterial)
58
51
  )
59
- if (geometry.type === 'CylinderGeometry') {
60
- mesh.rotation.x = Math.PI / 2
52
+ for (const copperSpan of copperSpans) {
53
+ const geometry = PcbScene3dViaFactory.#resolveGeometry(
54
+ THREE,
55
+ geometryCache,
56
+ via,
57
+ copperSpan.depth
58
+ )
59
+ const mesh = new THREE.Mesh(geometry, copperMaterial)
60
+ mesh.position.set(point.x, point.y, copperSpan.centerZ)
61
+ if (geometry.type === 'CylinderGeometry') {
62
+ mesh.rotation.x = Math.PI / 2
63
+ }
64
+ group.add(mesh)
61
65
  }
62
- group.add(mesh)
63
66
  PcbScene3dViaFactory.#appendMaskSurfaceMeshes(
64
67
  THREE,
65
68
  group,
@@ -75,6 +78,57 @@ export class PcbScene3dViaFactory {
75
78
  return group
76
79
  }
77
80
 
81
+ /**
82
+ * Resolves visible copper spans without carrying an open-side barrel
83
+ * through a solder-mask-covered board face.
84
+ * @param {object} via Via primitive.
85
+ * @param {'through' | 'top' | 'bottom'} renderMode Via geometry mode.
86
+ * @param {number} thicknessMil Board thickness in mil.
87
+ * @param {boolean} hasSurfaceMask Whether mask surface geometry is rendered.
88
+ * @returns {{ depth: number, centerZ: number }[]}
89
+ */
90
+ static #resolveCopperSpans(via, renderMode, thicknessMil, hasSurfaceMask) {
91
+ const fullSpan = {
92
+ depth: PcbScene3dViaFactory.#geometryDepth(
93
+ renderMode,
94
+ thicknessMil
95
+ ),
96
+ centerZ: PcbScene3dViaFactory.#centerZ(renderMode, thicknessMil)
97
+ }
98
+ if (!hasSurfaceMask) {
99
+ return [fullSpan]
100
+ }
101
+
102
+ if (renderMode === 'top') {
103
+ return PcbScene3dViaFactory.#isSideTented(via, 'top')
104
+ ? []
105
+ : [fullSpan]
106
+ }
107
+ if (renderMode === 'bottom') {
108
+ return PcbScene3dViaFactory.#isSideTented(via, 'bottom')
109
+ ? []
110
+ : [fullSpan]
111
+ }
112
+
113
+ const isTopTented = PcbScene3dViaFactory.#isSideTented(via, 'top')
114
+ const isBottomTented = PcbScene3dViaFactory.#isSideTented(via, 'bottom')
115
+ if (isTopTented && isBottomTented) {
116
+ return []
117
+ }
118
+ if (!isTopTented && !isBottomTented) {
119
+ return [fullSpan]
120
+ }
121
+
122
+ const halfDepth = fullSpan.depth / 2
123
+ const centerDistance = halfDepth / 2
124
+ return [
125
+ {
126
+ depth: halfDepth,
127
+ centerZ: isTopTented ? -centerDistance : centerDistance
128
+ }
129
+ ]
130
+ }
131
+
78
132
  /**
79
133
  * Resolves the via material.
80
134
  * @param {any} THREE
@@ -222,23 +276,12 @@ export class PcbScene3dViaFactory {
222
276
  * @param {any} THREE
223
277
  * @param {Map<string, any>} geometryCache
224
278
  * @param {{ diameter?: number, holeDiameter?: number, barrelOnly?: boolean }} via
225
- * @param {number} thicknessMil
226
- * @param {'through' | 'top' | 'bottom'} renderMode Via geometry mode.
279
+ * @param {number} depth Copper span depth in mil.
227
280
  * @returns {any}
228
281
  */
229
- static #resolveGeometry(
230
- THREE,
231
- geometryCache,
232
- via,
233
- thicknessMil,
234
- renderMode
235
- ) {
282
+ static #resolveGeometry(THREE, geometryCache, via, depth) {
236
283
  const outerRadius = Math.max(Number(via?.diameter || 0) / 2, 1.2)
237
284
  const holeDiameter = Math.max(Number(via?.holeDiameter || 0), 0)
238
- const depth = PcbScene3dViaFactory.#geometryDepth(
239
- renderMode,
240
- thicknessMil
241
- )
242
285
  const isBarrelOnly = Boolean(via?.barrelOnly)
243
286
  const cacheKey = [
244
287
  isBarrelOnly ? 'barrel' : 'annulus',
@@ -0,0 +1,102 @@
1
+ import { SelfAdjustingComputation } from 'circuitjson-toolkit'
2
+ import { PcbScene3dComponentVisibility } from './PcbScene3dComponentVisibility.mjs'
3
+ import { PcbScene3dRenderGroupVisibility } from './PcbScene3dRenderGroupVisibility.mjs'
4
+
5
+ /**
6
+ * Repairs ordered PCB visibility effects from explicit mutable-state changes.
7
+ */
8
+ export class PcbScene3dVisibilityGraph {
9
+ /** @type {SelfAdjustingComputation} */
10
+ #computation
11
+
12
+ /** @type {number} */
13
+ #groupRevision
14
+
15
+ /** @type {number} */
16
+ #componentRevision
17
+
18
+ /**
19
+ * Creates an empty visibility trace graph.
20
+ */
21
+ constructor() {
22
+ this.#computation = new SelfAdjustingComputation()
23
+ this.#groupRevision = 0
24
+ this.#componentRevision = 0
25
+ }
26
+
27
+ /**
28
+ * Applies affected visibility computations in observable effect order.
29
+ * Revision paths represent mutations inside otherwise atomic maps and sets.
30
+ * A null change set conservatively advances both structural revisions.
31
+ * @param {object} state Current visibility state.
32
+ * @param {PropertyKey[][] | null} changedPaths Changed roots or null when unknown.
33
+ * @returns {Map<string, { value: any, recomputed: boolean }>} Stage results.
34
+ */
35
+ apply(state, changedPaths = null) {
36
+ const normalizedPaths = this.#advanceRevisions(changedPaths)
37
+ const input = {
38
+ ...state,
39
+ groupRevision: this.#groupRevision,
40
+ componentRevision: this.#componentRevision
41
+ }
42
+ return this.#computation.propagate(input, normalizedPaths, [
43
+ {
44
+ name: 'render-groups',
45
+ computation: (current) => {
46
+ void current.groupRevision
47
+ PcbScene3dRenderGroupVisibility.apply(current)
48
+ }
49
+ },
50
+ {
51
+ name: 'components',
52
+ computation: (current) => {
53
+ void current.componentRevision
54
+ PcbScene3dComponentVisibility.apply(current)
55
+ }
56
+ }
57
+ ])
58
+ }
59
+
60
+ /**
61
+ * Removes all stored visibility traces and reverse-reader edges.
62
+ * @returns {void}
63
+ */
64
+ clear() {
65
+ this.#computation.clear()
66
+ this.#groupRevision = 0
67
+ this.#componentRevision = 0
68
+ }
69
+
70
+ /**
71
+ * Advances revision roots named by a visibility change set.
72
+ * @param {PropertyKey[][] | null} changedPaths Changed roots or null.
73
+ * @returns {PropertyKey[][]} Conservative normalized change paths.
74
+ */
75
+ #advanceRevisions(changedPaths) {
76
+ const normalizedPaths =
77
+ changedPaths === null
78
+ ? [['groupRevision'], ['componentRevision']]
79
+ : changedPaths
80
+ if (!Array.isArray(normalizedPaths)) {
81
+ throw new TypeError(
82
+ 'Visibility change sets must be arrays of property paths.'
83
+ )
84
+ }
85
+ const revisionRoots = new Set()
86
+ normalizedPaths.forEach((path) => {
87
+ if (!Array.isArray(path)) {
88
+ throw new TypeError(
89
+ 'Visibility change-set entries must be property paths.'
90
+ )
91
+ }
92
+ revisionRoots.add(path[0])
93
+ })
94
+ if (revisionRoots.has('groupRevision')) {
95
+ this.#groupRevision += 1
96
+ }
97
+ if (revisionRoots.has('componentRevision')) {
98
+ this.#componentRevision += 1
99
+ }
100
+ return normalizedPaths
101
+ }
102
+ }
package/src/scene3d.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ export { SelfAdjustingComputation } from 'circuitjson-toolkit'
1
2
  export { PcbModelArchiveExporter } from './PcbModelArchiveExporter.mjs'
2
3
  export { PcbAssemblyBoardSubstrateBuilder } from './PcbAssemblyBoardSubstrateBuilder.mjs'
3
4
  export { PcbAssemblyExportCoordinateFrame } from './PcbAssemblyExportCoordinateFrame.mjs'
@@ -57,4 +58,5 @@ export { PcbScene3dViaFactory } from './PcbScene3dViaFactory.mjs'
57
58
  export { PcbScene3dViewCompensation } from './PcbScene3dViewCompensation.mjs'
58
59
  export { PcbScene3dViewScale } from './PcbScene3dViewScale.mjs'
59
60
  export { PcbScene3dViewportResize } from './PcbScene3dViewportResize.mjs'
61
+ export { PcbScene3dVisibilityGraph } from './PcbScene3dVisibilityGraph.mjs'
60
62
  export { PcbScene3dWorkerClient } from './PcbScene3dWorkerClient.mjs'