pcb-scene3d-viewer 1.3.0 → 1.3.1

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,12 @@ 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
+
31
37
  ## CircuitJSON 1.1 convergence
32
38
 
33
39
  Version 1.2.2 accepts the common document and prepared-context shapes returned
@@ -136,6 +142,7 @@ const controller = new PcbScene3dController(viewportNode, document)
136
142
 
137
143
  - [API](docs/api.md)
138
144
  - [CircuitJSON usage](docs/circuitjson.md)
145
+ - [1.3.1 release notes](docs/release-notes-v1.3.1.md)
139
146
  - [1.2.2 release notes](docs/release-notes-v1.2.2.md)
140
147
  - [1.3.0 release notes](docs/release-notes-v1.3.0.md)
141
148
  - [1.2.1 release notes](docs/release-notes-v1.2.1.md)
@@ -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.
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.1",
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,7 @@
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",
40
41
  "docs/model-format.md",
41
42
  "docs/testing.md",
42
43
  "spec",
@@ -266,24 +266,37 @@ export class PcbScene3dCopperDetailFilter {
266
266
  * @returns {any[]}
267
267
  */
268
268
  static #filterExposedVias(vias, sceneDescription) {
269
+ if (PcbScene3dCopperDetailFilter.#isGerberScene(sceneDescription)) {
270
+ return (vias || []).filter(
271
+ (via) =>
272
+ via?.isTentingTop === false &&
273
+ via?.isTentingBottom === false
274
+ )
275
+ }
276
+
269
277
  return (vias || []).filter((via) =>
270
278
  PcbScene3dCopperDetailFilter.#isViaExplicitlyOpen(via)
271
279
  )
272
280
  }
273
281
 
274
282
  /**
275
- * Keeps KiCad via annuli that are covered by solder mask.
283
+ * Keeps KiCad and Gerber via annuli that are covered by solder mask.
276
284
  * @param {any[] | undefined} vias Via list.
277
285
  * @param {object} sceneDescription 3D scene description.
278
286
  * @returns {any[]}
279
287
  */
280
288
  static #filterMaskCoveredVias(vias, sceneDescription) {
281
- if (!PcbScene3dCopperDetailFilter.#isKiCadScene(sceneDescription)) {
289
+ if (
290
+ !PcbScene3dCopperDetailFilter.#isKiCadScene(sceneDescription) &&
291
+ !PcbScene3dCopperDetailFilter.#isGerberScene(sceneDescription)
292
+ ) {
282
293
  return []
283
294
  }
284
295
 
285
- return (vias || []).filter(
286
- (via) => !PcbScene3dCopperDetailFilter.#isViaExplicitlyOpen(via)
296
+ return (vias || []).filter((via) =>
297
+ PcbScene3dCopperDetailFilter.#isGerberScene(sceneDescription)
298
+ ? PcbScene3dCopperDetailFilter.#isViaExplicitlyTented(via)
299
+ : !PcbScene3dCopperDetailFilter.#isViaExplicitlyOpen(via)
287
300
  )
288
301
  }
289
302
 
@@ -296,6 +309,15 @@ export class PcbScene3dCopperDetailFilter {
296
309
  return via?.isTentingTop === false || via?.isTentingBottom === false
297
310
  }
298
311
 
312
+ /**
313
+ * Checks whether one via explicitly carries mask on either board side.
314
+ * @param {object} via Via primitive.
315
+ * @returns {boolean}
316
+ */
317
+ static #isViaExplicitlyTented(via) {
318
+ return via?.isTentingTop === true || via?.isTentingBottom === true
319
+ }
320
+
299
321
  /**
300
322
  * Appends copper barrels for through-hole pads with copper annuli.
301
323
  * @param {any[]} vias Visible via list.
@@ -487,6 +509,19 @@ export class PcbScene3dCopperDetailFilter {
487
509
  )
488
510
  }
489
511
 
512
+ /**
513
+ * Checks whether one scene was built from Gerber fabrication artwork.
514
+ * @param {object} sceneDescription Scene description.
515
+ * @returns {boolean}
516
+ */
517
+ static #isGerberScene(sceneDescription) {
518
+ return (
519
+ String(sceneDescription?.sourceFormat || '')
520
+ .trim()
521
+ .toLowerCase() === 'gerber'
522
+ )
523
+ }
524
+
490
525
  /**
491
526
  * Checks whether one text primitive belongs to a solder-mask layer.
492
527
  * @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
@@ -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,8 +28,12 @@ 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)
@@ -41,7 +46,7 @@ export class PcbScene3dViaFactory {
41
46
  thicknessMil,
42
47
  renderMode
43
48
  )
44
- const mesh = new THREE.Mesh(geometry, material)
49
+ const mesh = new THREE.Mesh(geometry, copperMaterial)
45
50
  const point = normalizeBoardPoint(
46
51
  Number(via?.x || 0),
47
52
  Number(via?.y || 0)
@@ -55,6 +60,16 @@ export class PcbScene3dViaFactory {
55
60
  mesh.rotation.x = Math.PI / 2
56
61
  }
57
62
  group.add(mesh)
63
+ PcbScene3dViaFactory.#appendMaskSurfaceMeshes(
64
+ THREE,
65
+ group,
66
+ surfaceGeometryCache,
67
+ via,
68
+ renderMode,
69
+ thicknessMil,
70
+ point,
71
+ options?.surfaceMaterial
72
+ )
58
73
  })
59
74
 
60
75
  return group
@@ -78,6 +93,130 @@ export class PcbScene3dViaFactory {
78
93
  )
79
94
  }
80
95
 
96
+ /**
97
+ * Adds side-specific solder-mask rings above the copper via surface.
98
+ * @param {any} THREE Three.js namespace.
99
+ * @param {any} group Output group.
100
+ * @param {Map<string, any>} geometryCache Surface geometry cache.
101
+ * @param {object} via Via primitive.
102
+ * @param {'through' | 'top' | 'bottom'} renderMode Via geometry mode.
103
+ * @param {number} thicknessMil Board thickness in mil.
104
+ * @param {{ x: number, y: number }} point Normalized board point.
105
+ * @param {any | undefined} material Solder-mask material.
106
+ * @returns {void}
107
+ */
108
+ static #appendMaskSurfaceMeshes(
109
+ THREE,
110
+ group,
111
+ geometryCache,
112
+ via,
113
+ renderMode,
114
+ thicknessMil,
115
+ point,
116
+ material
117
+ ) {
118
+ if (!material) {
119
+ return
120
+ }
121
+
122
+ const geometry = PcbScene3dViaFactory.#resolveSurfaceGeometry(
123
+ THREE,
124
+ geometryCache,
125
+ via
126
+ )
127
+ if (!geometry) {
128
+ return
129
+ }
130
+
131
+ for (const side of ['top', 'bottom']) {
132
+ if (
133
+ !PcbScene3dViaFactory.#renderModeTouchesSide(
134
+ renderMode,
135
+ side
136
+ ) ||
137
+ !PcbScene3dViaFactory.#isSideTented(via, side)
138
+ ) {
139
+ continue
140
+ }
141
+
142
+ const mesh = new THREE.Mesh(geometry, material)
143
+ mesh.position.set(
144
+ point.x,
145
+ point.y,
146
+ PcbScene3dViaFactory.#surfaceZ(side, thicknessMil)
147
+ )
148
+ group.add(mesh)
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Resolves one reusable annular surface geometry.
154
+ * @param {any} THREE Three.js namespace.
155
+ * @param {Map<string, any>} geometryCache Surface geometry cache.
156
+ * @param {object} via Via primitive.
157
+ * @returns {any | null}
158
+ */
159
+ static #resolveSurfaceGeometry(THREE, geometryCache, via) {
160
+ const diameter = Number(via?.diameter || 0)
161
+ const holeDiameter = Number(via?.holeDiameter || 0)
162
+ if (diameter <= 0 || holeDiameter < 0 || diameter <= holeDiameter) {
163
+ return null
164
+ }
165
+
166
+ const key = `${diameter.toFixed(4)}:${holeDiameter.toFixed(4)}`
167
+ const cached = geometryCache.get(key)
168
+ if (cached) {
169
+ return cached
170
+ }
171
+
172
+ const shape = PcbScene3dViaFactory.#buildCircleShape(
173
+ THREE,
174
+ diameter / 2
175
+ )
176
+ if (holeDiameter > 0) {
177
+ shape.holes.push(
178
+ PcbScene3dViaFactory.#buildCirclePath(THREE, holeDiameter / 2)
179
+ )
180
+ }
181
+ const geometry = new THREE.ShapeGeometry(shape, 24)
182
+ geometryCache.set(key, geometry)
183
+ return geometry
184
+ }
185
+
186
+ /**
187
+ * Checks whether one rendered via span reaches a board side.
188
+ * @param {'through' | 'top' | 'bottom'} renderMode Via geometry mode.
189
+ * @param {'top' | 'bottom'} side Board side.
190
+ * @returns {boolean}
191
+ */
192
+ static #renderModeTouchesSide(renderMode, side) {
193
+ return renderMode === 'through' || renderMode === side
194
+ }
195
+
196
+ /**
197
+ * Checks whether one via surface is tented on a board side.
198
+ * @param {object} via Via primitive.
199
+ * @param {'top' | 'bottom'} side Board side.
200
+ * @returns {boolean}
201
+ */
202
+ static #isSideTented(via, side) {
203
+ const fieldName = side === 'bottom' ? 'isTentingBottom' : 'isTentingTop'
204
+ return via?.[fieldName] !== false
205
+ }
206
+
207
+ /**
208
+ * Resolves the solder-mask surface Z above the exposed copper stack.
209
+ * @param {'top' | 'bottom'} side Board side.
210
+ * @param {number} thicknessMil Board thickness in mil.
211
+ * @returns {number}
212
+ */
213
+ static #surfaceZ(side, thicknessMil) {
214
+ const distance =
215
+ Math.max(Number(thicknessMil) || 0, 0) / 2 +
216
+ PcbScene3dViaFactory.#SURFACE_MASK_Z_OFFSET_MIL
217
+ return side === 'bottom' ? -distance : distance
218
+ }
219
+
81
220
  /**
82
221
  * Resolves one reusable via geometry from the via drill spec.
83
222
  * @param {any} THREE