pcb-scene3d-viewer 1.3.0 → 1.3.2

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
@@ -28,6 +28,20 @@ surface-aware blind/buried vias, collision-safe model assets, and a consistent
28
28
  light FR-4 substrate edge. See the
29
29
  [1.3.0 release notes](docs/release-notes-v1.3.0.md).
30
30
 
31
+ Version 1.3.1 renders side-specific tented Gerber vias without recoloring the
32
+ plated drill wall. Covered surfaces receive a solder-mask ring above the copper
33
+ annulus, mixed top/bottom states remain independent, and fully open Gerber vias
34
+ retain the exposed-copper path. See the
35
+ [1.3.1 release notes](docs/release-notes-v1.3.1.md).
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
+
31
45
  ## CircuitJSON 1.1 convergence
32
46
 
33
47
  Version 1.2.2 accepts the common document and prepared-context shapes returned
@@ -136,6 +150,8 @@ const controller = new PcbScene3dController(viewportNode, document)
136
150
 
137
151
  - [API](docs/api.md)
138
152
  - [CircuitJSON usage](docs/circuitjson.md)
153
+ - [1.3.2 release notes](docs/release-notes-v1.3.2.md)
154
+ - [1.3.1 release notes](docs/release-notes-v1.3.1.md)
139
155
  - [1.2.2 release notes](docs/release-notes-v1.2.2.md)
140
156
  - [1.3.0 release notes](docs/release-notes-v1.3.0.md)
141
157
  - [1.2.1 release notes](docs/release-notes-v1.2.1.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.
@@ -220,3 +220,9 @@ Rounded SMT pads set `hasRoundedRect`, the side-specific
220
220
  Route-derived vias use the same `detail.vias` shape as standalone vias, and
221
221
  surface route segments use `layerId: 1` for top copper or `layerId: 32` for
222
222
  bottom copper.
223
+
224
+ Via surfaces can set `isTentingTop` and `isTentingBottom` independently. A
225
+ truthy field adds a solder-mask ring on that board surface while the plated
226
+ through-hole barrel remains copper. A mixed via therefore renders one covered
227
+ annulus and one exposed annulus; a via with both fields explicitly false stays
228
+ on the exposed-copper path. The source toolkit owns this classification.
@@ -0,0 +1,23 @@
1
+ # pcb-scene3d-viewer 1.3.1
2
+
3
+ Version 1.3.1 renders source-classified via solder mask as a surface treatment
4
+ without replacing the plated copper barrel material.
5
+
6
+ ## Via rendering
7
+
8
+ - Gerber vias with either `isTentingTop` or `isTentingBottom` use the covered
9
+ via rendering path; both fields explicitly false retain the exposed path.
10
+ - Each tented board surface receives its own solder-mask ring above the copper
11
+ annulus. Mixed top/bottom tenting is preserved.
12
+ - The mask ring keeps the authored drill opening clear and leaves the plated
13
+ through-hole wall copper-colored.
14
+ - Blind and buried surface reachability remains authoritative, so mask geometry
15
+ is added only where a via actually reaches the corresponding board surface.
16
+
17
+ ## Compatibility and verification
18
+
19
+ - Existing CircuitJSON default-tenting and explicit-opening behavior is
20
+ unchanged.
21
+ - Scene, controller, runtime, export, and package entrypoints are unchanged.
22
+ - Tests cover fully tented, mixed, and fully open Gerber classification plus
23
+ copper-barrel and one-sided mask-ring material routing.
@@ -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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pcb-scene3d-viewer",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
4
4
  "description": "Reusable Three.js PCB 3D scene viewer for normalized ECAD and CircuitJSON scene descriptions",
5
5
  "keywords": [
6
6
  "pcb",
@@ -37,6 +37,8 @@
37
37
  "docs/release-notes-v1.2.1.md",
38
38
  "docs/release-notes-v1.2.2.md",
39
39
  "docs/release-notes-v1.3.0.md",
40
+ "docs/release-notes-v1.3.1.md",
41
+ "docs/release-notes-v1.3.2.md",
40
42
  "docs/model-format.md",
41
43
  "docs/testing.md",
42
44
  "spec",
@@ -58,7 +60,7 @@
58
60
  },
59
61
  "dependencies": {
60
62
  "@sunbox/occt-import-js": "^0.0.28",
61
- "circuitjson-toolkit": "^1.2.0",
63
+ "circuitjson-toolkit": "^1.4.1",
62
64
  "earcut": "3.0.2",
63
65
  "fflate": "^0.8.2",
64
66
  "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
 
@@ -266,24 +267,37 @@ export class PcbScene3dCopperDetailFilter {
266
267
  * @returns {any[]}
267
268
  */
268
269
  static #filterExposedVias(vias, sceneDescription) {
270
+ if (PcbScene3dCopperDetailFilter.#isGerberScene(sceneDescription)) {
271
+ return (vias || []).filter(
272
+ (via) =>
273
+ via?.isTentingTop === false &&
274
+ via?.isTentingBottom === false
275
+ )
276
+ }
277
+
269
278
  return (vias || []).filter((via) =>
270
279
  PcbScene3dCopperDetailFilter.#isViaExplicitlyOpen(via)
271
280
  )
272
281
  }
273
282
 
274
283
  /**
275
- * Keeps KiCad via annuli that are covered by solder mask.
284
+ * Keeps KiCad and Gerber via annuli that are covered by solder mask.
276
285
  * @param {any[] | undefined} vias Via list.
277
286
  * @param {object} sceneDescription 3D scene description.
278
287
  * @returns {any[]}
279
288
  */
280
289
  static #filterMaskCoveredVias(vias, sceneDescription) {
281
- if (!PcbScene3dCopperDetailFilter.#isKiCadScene(sceneDescription)) {
290
+ if (
291
+ !PcbScene3dCopperDetailFilter.#isKiCadScene(sceneDescription) &&
292
+ !PcbScene3dCopperDetailFilter.#isGerberScene(sceneDescription)
293
+ ) {
282
294
  return []
283
295
  }
284
296
 
285
- return (vias || []).filter(
286
- (via) => !PcbScene3dCopperDetailFilter.#isViaExplicitlyOpen(via)
297
+ return (vias || []).filter((via) =>
298
+ PcbScene3dCopperDetailFilter.#isGerberScene(sceneDescription)
299
+ ? PcbScene3dCopperDetailFilter.#isViaExplicitlyTented(via)
300
+ : !PcbScene3dCopperDetailFilter.#isViaExplicitlyOpen(via)
287
301
  )
288
302
  }
289
303
 
@@ -296,16 +310,26 @@ export class PcbScene3dCopperDetailFilter {
296
310
  return via?.isTentingTop === false || via?.isTentingBottom === false
297
311
  }
298
312
 
313
+ /**
314
+ * Checks whether one via explicitly carries mask on either board side.
315
+ * @param {object} via Via primitive.
316
+ * @returns {boolean}
317
+ */
318
+ static #isViaExplicitlyTented(via) {
319
+ return via?.isTentingTop === true || via?.isTentingBottom === true
320
+ }
321
+
299
322
  /**
300
323
  * Appends copper barrels for through-hole pads with copper annuli.
301
324
  * @param {any[]} vias Visible via list.
302
325
  * @param {any[] | undefined} pads Pad list.
326
+ * @param {any[] | undefined} sourceVias All parsed vias, including tented vias.
303
327
  * @returns {any[]}
304
328
  */
305
- static #appendPadBarrelSpecs(vias, pads) {
329
+ static #appendPadBarrelSpecs(vias, pads, sourceVias) {
306
330
  const output = [...(vias || [])]
307
331
  const seen = new Set(
308
- output.map((via) =>
332
+ [...(sourceVias || []), ...output].map((via) =>
309
333
  PcbScene3dCopperDetailFilter.#platedHoleKey(via)
310
334
  )
311
335
  )
@@ -487,6 +511,19 @@ export class PcbScene3dCopperDetailFilter {
487
511
  )
488
512
  }
489
513
 
514
+ /**
515
+ * Checks whether one scene was built from Gerber fabrication artwork.
516
+ * @param {object} sceneDescription Scene description.
517
+ * @returns {boolean}
518
+ */
519
+ static #isGerberScene(sceneDescription) {
520
+ return (
521
+ String(sceneDescription?.sourceFormat || '')
522
+ .trim()
523
+ .toLowerCase() === 'gerber'
524
+ )
525
+ }
526
+
490
527
  /**
491
528
  * Checks whether one text primitive belongs to a solder-mask layer.
492
529
  * @param {object} text Text primitive.
@@ -130,7 +130,7 @@ export class PcbScene3dCopperDetailGroupBuilder {
130
130
  sceneDescription?.board?.thicknessMil,
131
131
  normalizePoint,
132
132
  {
133
- material: PcbScene3dMaskCoveredCopperMaterial.build(
133
+ surfaceMaterial: PcbScene3dMaskCoveredCopperMaterial.build(
134
134
  THREE,
135
135
  PcbScene3dCopperDetailGroupBuilder.#coveredCopperMaterialOptions(
136
136
  sceneDescription
@@ -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.
@@ -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
  }
@@ -9,6 +9,7 @@ export class PcbScene3dViaFactory {
9
9
  static #PAD_BARREL_MIN_WALL_MIL = 1.2
10
10
  static #PAD_BARREL_WALL_FRACTION = 0.09
11
11
  static #SURFACE_COPPER_DEPTH_MIL = 2
12
+ static #SURFACE_MASK_Z_OFFSET_MIL = 1.3
12
13
 
13
14
  /**
14
15
  * Builds the via mesh group for one scene.
@@ -16,7 +17,7 @@ export class PcbScene3dViaFactory {
16
17
  * @param {{ diameter?: number, holeDiameter?: number, x?: number, y?: number, barrelOnly?: boolean, layers?: unknown[], fromLayer?: unknown, toLayer?: unknown, from_layer?: unknown, to_layer?: unknown }[]} vias
17
18
  * @param {number} thicknessMil
18
19
  * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint
19
- * @param {{ material?: any }} [options]
20
+ * @param {{ material?: any, surfaceMaterial?: any }} [options]
20
21
  * @returns {any}
21
22
  */
22
23
  static buildGroup(
@@ -27,39 +28,107 @@ export class PcbScene3dViaFactory {
27
28
  options = {}
28
29
  ) {
29
30
  const group = new THREE.Group()
30
- const material = PcbScene3dViaFactory.#resolveMaterial(THREE, options)
31
+ const copperMaterial = PcbScene3dViaFactory.#resolveMaterial(
32
+ THREE,
33
+ options
34
+ )
31
35
  const geometryCache = new Map()
36
+ const surfaceGeometryCache = new Map()
32
37
 
33
38
  ;(vias || []).forEach((via) => {
34
39
  const renderMode = PcbScene3dViaLayerSpan.renderMode(via)
35
40
  if (!renderMode) return
36
41
 
37
- const geometry = PcbScene3dViaFactory.#resolveGeometry(
38
- THREE,
39
- geometryCache,
40
- via,
41
- thicknessMil,
42
- renderMode
43
- )
44
- const mesh = new THREE.Mesh(geometry, material)
45
42
  const point = normalizeBoardPoint(
46
43
  Number(via?.x || 0),
47
44
  Number(via?.y || 0)
48
45
  )
49
- mesh.position.set(
50
- point.x,
51
- point.y,
52
- PcbScene3dViaFactory.#centerZ(renderMode, thicknessMil)
46
+ const copperSpans = PcbScene3dViaFactory.#resolveCopperSpans(
47
+ via,
48
+ renderMode,
49
+ thicknessMil,
50
+ Boolean(options?.surfaceMaterial)
53
51
  )
54
- if (geometry.type === 'CylinderGeometry') {
55
- 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)
56
65
  }
57
- group.add(mesh)
66
+ PcbScene3dViaFactory.#appendMaskSurfaceMeshes(
67
+ THREE,
68
+ group,
69
+ surfaceGeometryCache,
70
+ via,
71
+ renderMode,
72
+ thicknessMil,
73
+ point,
74
+ options?.surfaceMaterial
75
+ )
58
76
  })
59
77
 
60
78
  return group
61
79
  }
62
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
+
63
132
  /**
64
133
  * Resolves the via material.
65
134
  * @param {any} THREE
@@ -79,27 +148,140 @@ export class PcbScene3dViaFactory {
79
148
  }
80
149
 
81
150
  /**
82
- * Resolves one reusable via geometry from the via drill spec.
83
- * @param {any} THREE
84
- * @param {Map<string, any>} geometryCache
85
- * @param {{ diameter?: number, holeDiameter?: number, barrelOnly?: boolean }} via
86
- * @param {number} thicknessMil
151
+ * Adds side-specific solder-mask rings above the copper via surface.
152
+ * @param {any} THREE Three.js namespace.
153
+ * @param {any} group Output group.
154
+ * @param {Map<string, any>} geometryCache Surface geometry cache.
155
+ * @param {object} via Via primitive.
87
156
  * @param {'through' | 'top' | 'bottom'} renderMode Via geometry mode.
88
- * @returns {any}
157
+ * @param {number} thicknessMil Board thickness in mil.
158
+ * @param {{ x: number, y: number }} point Normalized board point.
159
+ * @param {any | undefined} material Solder-mask material.
160
+ * @returns {void}
89
161
  */
90
- static #resolveGeometry(
162
+ static #appendMaskSurfaceMeshes(
91
163
  THREE,
164
+ group,
92
165
  geometryCache,
93
166
  via,
167
+ renderMode,
94
168
  thicknessMil,
95
- renderMode
169
+ point,
170
+ material
96
171
  ) {
172
+ if (!material) {
173
+ return
174
+ }
175
+
176
+ const geometry = PcbScene3dViaFactory.#resolveSurfaceGeometry(
177
+ THREE,
178
+ geometryCache,
179
+ via
180
+ )
181
+ if (!geometry) {
182
+ return
183
+ }
184
+
185
+ for (const side of ['top', 'bottom']) {
186
+ if (
187
+ !PcbScene3dViaFactory.#renderModeTouchesSide(
188
+ renderMode,
189
+ side
190
+ ) ||
191
+ !PcbScene3dViaFactory.#isSideTented(via, side)
192
+ ) {
193
+ continue
194
+ }
195
+
196
+ const mesh = new THREE.Mesh(geometry, material)
197
+ mesh.position.set(
198
+ point.x,
199
+ point.y,
200
+ PcbScene3dViaFactory.#surfaceZ(side, thicknessMil)
201
+ )
202
+ group.add(mesh)
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Resolves one reusable annular surface geometry.
208
+ * @param {any} THREE Three.js namespace.
209
+ * @param {Map<string, any>} geometryCache Surface geometry cache.
210
+ * @param {object} via Via primitive.
211
+ * @returns {any | null}
212
+ */
213
+ static #resolveSurfaceGeometry(THREE, geometryCache, via) {
214
+ const diameter = Number(via?.diameter || 0)
215
+ const holeDiameter = Number(via?.holeDiameter || 0)
216
+ if (diameter <= 0 || holeDiameter < 0 || diameter <= holeDiameter) {
217
+ return null
218
+ }
219
+
220
+ const key = `${diameter.toFixed(4)}:${holeDiameter.toFixed(4)}`
221
+ const cached = geometryCache.get(key)
222
+ if (cached) {
223
+ return cached
224
+ }
225
+
226
+ const shape = PcbScene3dViaFactory.#buildCircleShape(
227
+ THREE,
228
+ diameter / 2
229
+ )
230
+ if (holeDiameter > 0) {
231
+ shape.holes.push(
232
+ PcbScene3dViaFactory.#buildCirclePath(THREE, holeDiameter / 2)
233
+ )
234
+ }
235
+ const geometry = new THREE.ShapeGeometry(shape, 24)
236
+ geometryCache.set(key, geometry)
237
+ return geometry
238
+ }
239
+
240
+ /**
241
+ * Checks whether one rendered via span reaches a board side.
242
+ * @param {'through' | 'top' | 'bottom'} renderMode Via geometry mode.
243
+ * @param {'top' | 'bottom'} side Board side.
244
+ * @returns {boolean}
245
+ */
246
+ static #renderModeTouchesSide(renderMode, side) {
247
+ return renderMode === 'through' || renderMode === side
248
+ }
249
+
250
+ /**
251
+ * Checks whether one via surface is tented on a board side.
252
+ * @param {object} via Via primitive.
253
+ * @param {'top' | 'bottom'} side Board side.
254
+ * @returns {boolean}
255
+ */
256
+ static #isSideTented(via, side) {
257
+ const fieldName = side === 'bottom' ? 'isTentingBottom' : 'isTentingTop'
258
+ return via?.[fieldName] !== false
259
+ }
260
+
261
+ /**
262
+ * Resolves the solder-mask surface Z above the exposed copper stack.
263
+ * @param {'top' | 'bottom'} side Board side.
264
+ * @param {number} thicknessMil Board thickness in mil.
265
+ * @returns {number}
266
+ */
267
+ static #surfaceZ(side, thicknessMil) {
268
+ const distance =
269
+ Math.max(Number(thicknessMil) || 0, 0) / 2 +
270
+ PcbScene3dViaFactory.#SURFACE_MASK_Z_OFFSET_MIL
271
+ return side === 'bottom' ? -distance : distance
272
+ }
273
+
274
+ /**
275
+ * Resolves one reusable via geometry from the via drill spec.
276
+ * @param {any} THREE
277
+ * @param {Map<string, any>} geometryCache
278
+ * @param {{ diameter?: number, holeDiameter?: number, barrelOnly?: boolean }} via
279
+ * @param {number} depth Copper span depth in mil.
280
+ * @returns {any}
281
+ */
282
+ static #resolveGeometry(THREE, geometryCache, via, depth) {
97
283
  const outerRadius = Math.max(Number(via?.diameter || 0) / 2, 1.2)
98
284
  const holeDiameter = Math.max(Number(via?.holeDiameter || 0), 0)
99
- const depth = PcbScene3dViaFactory.#geometryDepth(
100
- renderMode,
101
- thicknessMil
102
- )
103
285
  const isBarrelOnly = Boolean(via?.barrelOnly)
104
286
  const cacheKey = [
105
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'