pcb-scene3d-viewer 1.2.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +16 -1
  2. package/docs/api.md +26 -0
  3. package/docs/circuitjson.md +21 -8
  4. package/docs/release-notes-v1.2.2.md +33 -0
  5. package/docs/release-notes-v1.3.0.md +38 -0
  6. package/package.json +4 -2
  7. package/src/CircuitJsonCadModelAssetResolver.mjs +24 -10
  8. package/src/PcbAssemblyGltfModelMeshParser.mjs +3 -1
  9. package/src/PcbAssemblyModelMeshLoader.mjs +1 -1
  10. package/src/PcbAssemblyTextModelMeshParser.mjs +3 -1
  11. package/src/PcbScene3dBoardAssemblyPresentation.mjs +1 -11
  12. package/src/PcbScene3dBoardMaterialPalette.mjs +12 -0
  13. package/src/PcbScene3dCircuitJsonAdapter.mjs +34 -86
  14. package/src/PcbScene3dCircuitJsonCopperPourBuilder.mjs +6 -1
  15. package/src/PcbScene3dCircuitJsonCopperTextBuilder.mjs +385 -0
  16. package/src/PcbScene3dCircuitJsonDocumentationArtworkBuilder.mjs +123 -22
  17. package/src/PcbScene3dCircuitJsonGeometry.mjs +12 -0
  18. package/src/PcbScene3dCircuitJsonPadCorner.mjs +72 -0
  19. package/src/PcbScene3dCircuitJsonSilkscreenBuilder.mjs +140 -25
  20. package/src/PcbScene3dCircuitJsonSilkscreenDetailBuilder.mjs +21 -0
  21. package/src/PcbScene3dCircuitJsonSourceLayer.mjs +124 -0
  22. package/src/PcbScene3dCircuitJsonTraceRouteBuilder.mjs +56 -17
  23. package/src/PcbScene3dCopperDetailFilter.mjs +5 -1
  24. package/src/PcbScene3dCopperFactory.mjs +23 -4
  25. package/src/PcbScene3dCopperTextFactory.mjs +105 -20
  26. package/src/PcbScene3dExternalModels.mjs +26 -0
  27. package/src/PcbScene3dMaskCoveredCopperSideGroupBuilder.mjs +16 -1
  28. package/src/PcbScene3dModelContent.mjs +32 -1
  29. package/src/PcbScene3dRuntimeBoardMeshes.mjs +2 -4
  30. package/src/PcbScene3dSilkscreenCopperCutoutBuilder.mjs +588 -0
  31. package/src/PcbScene3dStepLoader.mjs +2 -1
  32. package/src/PcbScene3dStrokeCutoutBuilder.mjs +98 -0
  33. package/src/PcbScene3dViaFactory.mjs +51 -5
  34. package/src/PcbScene3dViaLayerSpan.mjs +126 -0
@@ -0,0 +1,72 @@
1
+ import { CircuitJsonUnits } from 'circuitjson-toolkit'
2
+
3
+ const RECTANGULAR_PAD_SHAPE = 2
4
+
5
+ /**
6
+ * Normalizes CircuitJSON rounded-pad metadata for scene pad faces.
7
+ */
8
+ export class PcbScene3dCircuitJsonPadCorner {
9
+ /**
10
+ * Builds rounded-rectangle metadata for one or both pad faces.
11
+ * @param {object} pad Pad element.
12
+ * @param {{ width: number, height: number }} size Pad copper size in mils.
13
+ * @param {boolean | null} isBottom Bottom side, or null for both sides.
14
+ * @returns {object}
15
+ */
16
+ static metadata(pad, size, isBottom) {
17
+ if (
18
+ !String(pad?.shape || '').endsWith('pill') &&
19
+ !(PcbScene3dCircuitJsonPadCorner.#radius(pad) > 0)
20
+ ) {
21
+ return {}
22
+ }
23
+ const cornerRadius =
24
+ PcbScene3dCircuitJsonPadCorner.#cornerRadiusPercent(pad, size)
25
+ const hasTop = isBottom !== true
26
+ const hasBottom = isBottom !== false
27
+ return {
28
+ hasRoundedRect: true,
29
+ roundedRectShapeTop: hasTop ? RECTANGULAR_PAD_SHAPE : null,
30
+ roundedRectShapeBottom: hasBottom ? RECTANGULAR_PAD_SHAPE : null,
31
+ cornerRadiusTop: hasTop ? cornerRadius : null,
32
+ cornerRadiusBottom: hasBottom ? cornerRadius : null
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Resolves a corner radius as a percentage of the shortest pad side.
38
+ * @param {object} pad Pad element.
39
+ * @param {{ width: number, height: number }} size Pad copper size in mils.
40
+ * @returns {number}
41
+ */
42
+ static #cornerRadiusPercent(pad, size) {
43
+ const width = CircuitJsonUnits.optionalLength(pad?.width)
44
+ const height = CircuitJsonUnits.optionalLength(pad?.height)
45
+ const radius = PcbScene3dCircuitJsonPadCorner.#radius(pad)
46
+ if (width > 0 && height > 0 && radius > 0) {
47
+ return Math.min((radius / Math.min(width, height)) * 100, 50)
48
+ }
49
+
50
+ const shortestSide = Math.min(Number(size.width), Number(size.height))
51
+ const radiusMil = CircuitJsonUnits.mmToMil(radius, 0)
52
+ if (shortestSide > 0 && radiusMil > 0) {
53
+ return Math.min((radiusMil / shortestSide) * 100, 50)
54
+ }
55
+
56
+ return 50
57
+ }
58
+
59
+ /**
60
+ * Resolves canonical and compatibility corner-radius fields.
61
+ * @param {object} pad Pad element.
62
+ * @returns {number}
63
+ */
64
+ static #radius(pad) {
65
+ return CircuitJsonUnits.optionalLength(
66
+ pad?.radius ??
67
+ pad?.corner_radius ??
68
+ pad?.rect_border_radius ??
69
+ pad?.cornerRadius
70
+ )
71
+ }
72
+ }
@@ -1,6 +1,7 @@
1
1
  import { CircuitJsonUnits } from 'circuitjson-toolkit'
2
2
  import { PcbScene3dCircuitJsonLayer } from './PcbScene3dCircuitJsonLayer.mjs'
3
3
  import { PcbScene3dCircuitJsonDocumentationArtworkBuilder } from './PcbScene3dCircuitJsonDocumentationArtworkBuilder.mjs'
4
+ import { PcbScene3dCircuitJsonSourceLayer } from './PcbScene3dCircuitJsonSourceLayer.mjs'
4
5
 
5
6
  const CURVE_SEGMENTS = 32
6
7
  const CAP_SEGMENTS = 16
@@ -21,6 +22,12 @@ export class PcbScene3dCircuitJsonSilkscreenBuilder {
21
22
 
22
23
  PcbScene3dCircuitJsonSilkscreenBuilder.#appendLines(index, top, bottom)
23
24
  PcbScene3dCircuitJsonSilkscreenBuilder.#appendPaths(index, top, bottom)
25
+ PcbScene3dCircuitJsonDocumentationArtworkBuilder.append(
26
+ index,
27
+ top,
28
+ bottom,
29
+ { sourceLayerFilter: 'silkscreen' }
30
+ )
24
31
  PcbScene3dCircuitJsonSilkscreenBuilder.#appendCircles(
25
32
  index,
26
33
  top,
@@ -36,18 +43,32 @@ export class PcbScene3dCircuitJsonSilkscreenBuilder {
36
43
  top,
37
44
  bottom
38
45
  )
46
+ PcbScene3dCircuitJsonSilkscreenBuilder.#appendTexts(
47
+ (index.elementsByType.get('pcb_note_text') || []).filter(
48
+ PcbScene3dCircuitJsonSourceLayer.isSilkscreen
49
+ ),
50
+ top,
51
+ bottom
52
+ )
39
53
  if (options?.showPcbNotes === true) {
40
54
  PcbScene3dCircuitJsonDocumentationArtworkBuilder.append(
41
55
  index,
42
56
  top,
43
- bottom
57
+ bottom,
58
+ { sourceLayerFilter: 'non-silkscreen' }
44
59
  )
45
60
  PcbScene3dCircuitJsonSilkscreenBuilder.#appendTexts(
46
61
  [
47
62
  ...(index.elementsByType.get('pcb_note_text') || []),
48
63
  ...(index.elementsByType.get('pcb_fabrication_note_text') ||
49
64
  [])
50
- ],
65
+ ].filter(
66
+ (text) =>
67
+ !PcbScene3dCircuitJsonSourceLayer.isSilkscreen(text) &&
68
+ !PcbScene3dCircuitJsonSourceLayer.isCopperOrSolderMask(
69
+ text
70
+ )
71
+ ),
51
72
  top,
52
73
  bottom
53
74
  )
@@ -92,34 +113,107 @@ export class PcbScene3dCircuitJsonSilkscreenBuilder {
92
113
  */
93
114
  static #appendPaths(index, top, bottom) {
94
115
  ;(index.elementsByType.get('pcb_silkscreen_path') || []).forEach(
95
- (path) => {
116
+ (path, pathIndex) => {
96
117
  const target =
97
118
  PcbScene3dCircuitJsonSilkscreenBuilder.#sideDetail(
98
119
  path?.layer,
99
120
  top,
100
121
  bottom
101
122
  )
102
- const route = PcbScene3dCircuitJsonSilkscreenBuilder.#array(
103
- path?.route
123
+ PcbScene3dCircuitJsonSilkscreenBuilder.#appendPath(
124
+ target,
125
+ path,
126
+ PcbScene3dCircuitJsonSilkscreenBuilder.#sourceId(
127
+ path,
128
+ ['pcb_silkscreen_path_id', 'silkscreen_path_id'],
129
+ 'pcb_silkscreen_path',
130
+ pathIndex
131
+ )
104
132
  )
105
- for (let index = 0; index < route.length - 1; index += 1) {
106
- const start = route[index]
107
- const end = route[index + 1]
108
- target.tracks.push({
109
- x1: CircuitJsonUnits.mmToMil(start?.x, 0),
110
- y1: CircuitJsonUnits.mmToMil(start?.y, 0),
111
- x2: CircuitJsonUnits.mmToMil(end?.x, 0),
112
- y2: CircuitJsonUnits.mmToMil(end?.y, 0),
113
- width: CircuitJsonUnits.mmToMil(
114
- path?.stroke_width,
115
- 0.12
116
- )
117
- })
118
- }
119
133
  }
120
134
  )
121
135
  }
122
136
 
137
+ /**
138
+ * Appends one filled polygon or stroke path to a side detail container.
139
+ * @param {object} target Side-specific silkscreen detail.
140
+ * @param {object} path CircuitJSON path element.
141
+ * @param {string} sourceId Stable source ID.
142
+ * @returns {void}
143
+ */
144
+ static #appendPath(target, path, sourceId) {
145
+ const points = PcbScene3dCircuitJsonSilkscreenBuilder.#pathPoints(path)
146
+ if (points.length < 2) {
147
+ return
148
+ }
149
+
150
+ const fillPoints =
151
+ PcbScene3dCircuitJsonSilkscreenBuilder.#distinctPathPoints(points)
152
+ if (path?.fill === true && fillPoints.length >= 3) {
153
+ target.fills.push({
154
+ sourceId,
155
+ points: fillPoints
156
+ })
157
+ return
158
+ }
159
+
160
+ const width =
161
+ PcbScene3dCircuitJsonSilkscreenBuilder.#pathStrokeWidth(path)
162
+ for (let index = 0; index < points.length - 1; index += 1) {
163
+ const start = points[index]
164
+ const end = points[index + 1]
165
+ target.tracks.push({
166
+ sourceId,
167
+ x1: start.x,
168
+ y1: start.y,
169
+ x2: end.x,
170
+ y2: end.y,
171
+ width
172
+ })
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Converts one path route into valid millimeter-to-mil points.
178
+ * @param {object} path CircuitJSON path element.
179
+ * @returns {{ x: number, y: number }[]}
180
+ */
181
+ static #pathPoints(path) {
182
+ return PcbScene3dCircuitJsonSilkscreenBuilder.#array(
183
+ path?.route || path?.points
184
+ )
185
+ .map((point) =>
186
+ PcbScene3dCircuitJsonSilkscreenBuilder.#point(point)
187
+ )
188
+ .filter(Boolean)
189
+ }
190
+
191
+ /**
192
+ * Removes a duplicate closing point from one polygon route.
193
+ * @param {{ x: number, y: number }[]} points Route points.
194
+ * @returns {{ x: number, y: number }[]}
195
+ */
196
+ static #distinctPathPoints(points) {
197
+ if (points.length < 2) return points
198
+ const first = points[0]
199
+ const last = points[points.length - 1]
200
+ return first.x === last.x && first.y === last.y
201
+ ? points.slice(0, -1)
202
+ : points
203
+ }
204
+
205
+ /**
206
+ * Resolves a positive stroke width with the standard silkscreen fallback.
207
+ * @param {object} path CircuitJSON path element.
208
+ * @returns {number}
209
+ */
210
+ static #pathStrokeWidth(path) {
211
+ const width = Number(path?.stroke_width ?? path?.strokeWidth)
212
+ return Number.isFinite(width) && width > 0
213
+ ? CircuitJsonUnits.mmToMil(width, 0)
214
+ : CircuitJsonUnits.mmToMil(0.12, 0)
215
+ }
216
+
123
217
  /**
124
218
  * Appends circular silkscreen strokes as full-circle arcs.
125
219
  * @param {{ elementsByType: Map<string, object[]> }} index CircuitJSON index.
@@ -246,6 +340,7 @@ export class PcbScene3dCircuitJsonSilkscreenBuilder {
246
340
  */
247
341
  static #appendTexts(texts, top, bottom) {
248
342
  texts.forEach((text) => {
343
+ if (text?.is_hidden === true || text?.isHidden === true) return
249
344
  const target = PcbScene3dCircuitJsonSilkscreenBuilder.#sideDetail(
250
345
  text?.layer,
251
346
  top,
@@ -265,8 +360,22 @@ export class PcbScene3dCircuitJsonSilkscreenBuilder {
265
360
  static #textPrimitive(text) {
266
361
  const position =
267
362
  PcbScene3dCircuitJsonSilkscreenBuilder.#textPosition(text)
268
- const size = CircuitJsonUnits.mmToMil(
269
- text?.font_size ?? text?.fontSize ?? text?.height ?? text?.size,
363
+ const sizeX = CircuitJsonUnits.mmToMil(
364
+ text?.font_width ??
365
+ text?.fontWidth ??
366
+ text?.font_size ??
367
+ text?.fontSize ??
368
+ text?.height ??
369
+ text?.size,
370
+ 1
371
+ )
372
+ const sizeY = CircuitJsonUnits.mmToMil(
373
+ text?.font_height ??
374
+ text?.fontHeight ??
375
+ text?.font_size ??
376
+ text?.fontSize ??
377
+ text?.height ??
378
+ text?.size,
270
379
  1
271
380
  )
272
381
  const strokeWidth = CircuitJsonUnits.mmToMil(
@@ -283,13 +392,17 @@ export class PcbScene3dCircuitJsonSilkscreenBuilder {
283
392
  x: position.x,
284
393
  y: position.y,
285
394
  rotation: Number(text?.ccw_rotation ?? text?.rotation ?? 0),
286
- sizeX: size,
287
- sizeY: size,
395
+ sizeX,
396
+ sizeY,
288
397
  width: strokeWidth,
289
398
  strokeWidth,
290
399
  thickness: strokeWidth,
291
400
  hAlign: alignment.hAlign,
292
- vAlign: alignment.vAlign
401
+ vAlign: alignment.vAlign,
402
+ mirrored:
403
+ text?.is_mirrored === true ||
404
+ text?.is_mirrored_from_top_view === true ||
405
+ text?.mirrored === true
293
406
  }
294
407
  }
295
408
 
@@ -315,7 +428,9 @@ export class PcbScene3dCircuitJsonSilkscreenBuilder {
315
428
  * @returns {{ hAlign: 'left' | 'center' | 'right', vAlign: 'top' | 'center' | 'bottom' }}
316
429
  */
317
430
  static #textAlignment(text) {
318
- const value = String(text?.anchor_alignment || '')
431
+ const value = String(
432
+ text?.source_anchor_alignment || text?.anchor_alignment || ''
433
+ )
319
434
  .trim()
320
435
  .toLowerCase()
321
436
  .replaceAll('-', '_')
@@ -0,0 +1,21 @@
1
+ import { PcbScene3dCircuitJsonSilkscreenBuilder } from './PcbScene3dCircuitJsonSilkscreenBuilder.mjs'
2
+ import { PcbScene3dSilkscreenCopperCutoutBuilder } from './PcbScene3dSilkscreenCopperCutoutBuilder.mjs'
3
+
4
+ /**
5
+ * Builds complete CircuitJSON silkscreen detail with surface keepouts.
6
+ */
7
+ export class PcbScene3dCircuitJsonSilkscreenDetailBuilder {
8
+ /**
9
+ * Builds and clips side-specific silkscreen detail.
10
+ * @param {{ elementsByType: Map<string, object[]> }} index CircuitJSON index.
11
+ * @param {{ pads?: object[], vias?: object[], tracks?: object[], fills?: object[], polygons?: object[], copperTexts?: object[] }} detail Scene detail.
12
+ * @param {{ showPcbNotes?: boolean }} [options] Builder options.
13
+ * @returns {{ top: object, bottom: object }}
14
+ */
15
+ static build(index, detail, options = {}) {
16
+ return PcbScene3dSilkscreenCopperCutoutBuilder.apply(
17
+ PcbScene3dCircuitJsonSilkscreenBuilder.build(index, options),
18
+ detail
19
+ )
20
+ }
21
+ }
@@ -0,0 +1,124 @@
1
+ const OUTER_COPPER_LAYER_SIDES = new Map([
2
+ ['f.cu', 'top'],
3
+ ['front copper', 'top'],
4
+ ['top copper', 'top'],
5
+ ['top layer', 'top'],
6
+ ['b.cu', 'bottom'],
7
+ ['back copper', 'bottom'],
8
+ ['bottom copper', 'bottom'],
9
+ ['bottom layer', 'bottom']
10
+ ])
11
+
12
+ const SOLDER_MASK_LAYER_SIDES = new Map([
13
+ ['f.mask', 'top'],
14
+ ['front mask', 'top'],
15
+ ['top mask', 'top'],
16
+ ['front solder', 'top'],
17
+ ['top solder', 'top'],
18
+ ['front solder mask', 'top'],
19
+ ['top solder mask', 'top'],
20
+ ['b.mask', 'bottom'],
21
+ ['back mask', 'bottom'],
22
+ ['bottom mask', 'bottom'],
23
+ ['back solder', 'bottom'],
24
+ ['bottom solder', 'bottom'],
25
+ ['back solder mask', 'bottom'],
26
+ ['bottom solder mask', 'bottom']
27
+ ])
28
+
29
+ /**
30
+ * Classifies source-layer metadata retained beside canonical CircuitJSON rows.
31
+ */
32
+ export class PcbScene3dCircuitJsonSourceLayer {
33
+ /**
34
+ * Returns whether an element originated on a silkscreen layer.
35
+ * @param {object} element CircuitJSON element.
36
+ * @returns {boolean}
37
+ */
38
+ static isSilkscreen(element) {
39
+ return PcbScene3dCircuitJsonSourceLayer.#normalizedName(
40
+ element
41
+ ).includes('silk')
42
+ }
43
+
44
+ /**
45
+ * Resolves an outer-copper source layer to its board side.
46
+ * @param {object} element CircuitJSON element.
47
+ * @returns {'top' | 'bottom' | null}
48
+ */
49
+ static outerCopperSide(element) {
50
+ return (
51
+ OUTER_COPPER_LAYER_SIDES.get(
52
+ PcbScene3dCircuitJsonSourceLayer.#normalizedName(element)
53
+ ) || null
54
+ )
55
+ }
56
+
57
+ /**
58
+ * Returns whether an element originated on an outer copper layer.
59
+ * @param {object} element CircuitJSON element.
60
+ * @returns {boolean}
61
+ */
62
+ static isOuterCopper(element) {
63
+ return (
64
+ PcbScene3dCircuitJsonSourceLayer.outerCopperSide(element) !== null
65
+ )
66
+ }
67
+
68
+ /**
69
+ * Resolves a solder-mask source layer to its board side.
70
+ * @param {object} element CircuitJSON element.
71
+ * @returns {'top' | 'bottom' | null}
72
+ */
73
+ static solderMaskSide(element) {
74
+ return (
75
+ SOLDER_MASK_LAYER_SIDES.get(
76
+ PcbScene3dCircuitJsonSourceLayer.#normalizedName(element)
77
+ ) || null
78
+ )
79
+ }
80
+
81
+ /**
82
+ * Returns whether an element originated on a solder-mask layer.
83
+ * @param {object} element CircuitJSON element.
84
+ * @returns {boolean}
85
+ */
86
+ static isSolderMask(element) {
87
+ return PcbScene3dCircuitJsonSourceLayer.solderMaskSide(element) !== null
88
+ }
89
+
90
+ /**
91
+ * Returns whether an element belongs to copper or its mask aperture data.
92
+ * @param {object} element CircuitJSON element.
93
+ * @returns {boolean}
94
+ */
95
+ static isCopperOrSolderMask(element) {
96
+ return (
97
+ PcbScene3dCircuitJsonSourceLayer.isOuterCopper(element) ||
98
+ PcbScene3dCircuitJsonSourceLayer.isSolderMask(element)
99
+ )
100
+ }
101
+
102
+ /**
103
+ * Resolves a normalized source-layer name from one CircuitJSON element.
104
+ * @param {object} element CircuitJSON element.
105
+ * @returns {string}
106
+ */
107
+ static #normalizedName(element) {
108
+ return PcbScene3dCircuitJsonSourceLayer.#name(
109
+ element?.source_layer ?? element?.sourceLayer
110
+ ).toLowerCase()
111
+ }
112
+
113
+ /**
114
+ * Resolves a string layer name from scalar or object metadata.
115
+ * @param {unknown} layer Source-layer metadata.
116
+ * @returns {string}
117
+ */
118
+ static #name(layer) {
119
+ if (layer && typeof layer === 'object') {
120
+ return String(layer.name ?? layer.layer ?? '').trim()
121
+ }
122
+ return String(layer ?? '').trim()
123
+ }
124
+ }
@@ -1,5 +1,6 @@
1
1
  import { CircuitJsonUnits } from 'circuitjson-toolkit'
2
2
  import { PcbScene3dCircuitJsonLayer } from './PcbScene3dCircuitJsonLayer.mjs'
3
+ import { PcbScene3dViaLayerSpan } from './PcbScene3dViaLayerSpan.mjs'
3
4
 
4
5
  /**
5
6
  * Converts CircuitJSON trace routes into scene copper tracks and vias.
@@ -85,7 +86,11 @@ export class PcbScene3dCircuitJsonTraceRouteBuilder {
85
86
  0.1524
86
87
  ),
87
88
  layerId: PcbScene3dCircuitJsonLayer.layerId(side),
88
- solderMaskOpening: true
89
+ solderMaskOpening:
90
+ PcbScene3dCircuitJsonTraceRouteBuilder.#solderMaskOpening(
91
+ entry,
92
+ trace
93
+ )
89
94
  }
90
95
  }
91
96
 
@@ -110,7 +115,8 @@ export class PcbScene3dCircuitJsonTraceRouteBuilder {
110
115
  0
111
116
  ),
112
117
  isTentingTop: isTented,
113
- isTentingBottom: isTented
118
+ isTentingBottom: isTented,
119
+ ...PcbScene3dViaLayerSpan.fields(via)
114
120
  }
115
121
  })
116
122
  }
@@ -168,7 +174,8 @@ export class PcbScene3dCircuitJsonTraceRouteBuilder {
168
174
  diameter,
169
175
  holeDiameter,
170
176
  isTentingTop: isTented,
171
- isTentingBottom: isTented
177
+ isTentingBottom: isTented,
178
+ ...PcbScene3dViaLayerSpan.fields(via)
172
179
  }
173
180
  }
174
181
 
@@ -199,7 +206,12 @@ export class PcbScene3dCircuitJsonTraceRouteBuilder {
199
206
  0.1524
200
207
  ),
201
208
  layerId: PcbScene3dCircuitJsonLayer.layerId(side),
202
- solderMaskOpening: true
209
+ solderMaskOpening:
210
+ PcbScene3dCircuitJsonTraceRouteBuilder.#solderMaskOpening(
211
+ start,
212
+ end,
213
+ trace
214
+ )
203
215
  }
204
216
  }
205
217
 
@@ -281,11 +293,7 @@ export class PcbScene3dCircuitJsonTraceRouteBuilder {
281
293
  * @returns {boolean}
282
294
  */
283
295
  static #touchesSurface(via) {
284
- return Boolean(
285
- PcbScene3dCircuitJsonLayer.surfaceSide(via?.from_layer) ||
286
- PcbScene3dCircuitJsonLayer.surfaceSide(via?.to_layer) ||
287
- PcbScene3dCircuitJsonLayer.surfaceSide(via?.layer)
288
- )
296
+ return PcbScene3dViaLayerSpan.surfaceSides(via).length > 0
289
297
  }
290
298
 
291
299
  /**
@@ -303,19 +311,50 @@ export class PcbScene3dCircuitJsonTraceRouteBuilder {
303
311
  * @returns {boolean}
304
312
  */
305
313
  static #isSolderMaskCovered(via) {
306
- const value =
307
- via?.is_covered_with_solder_mask ?? via?.covered_with_solder_mask
308
- if (typeof value === 'boolean') {
309
- return value
314
+ if (typeof via?.is_tented === 'boolean') {
315
+ return via.is_tented
310
316
  }
311
-
312
317
  return (
313
- String(value || '')
314
- .trim()
315
- .toLowerCase() === 'true'
318
+ PcbScene3dCircuitJsonTraceRouteBuilder.#solderMaskCoveredValue(
319
+ via
320
+ ) ?? true
316
321
  )
317
322
  }
318
323
 
324
+ /**
325
+ * Resolves whether trace-like copper has an explicitly authored opening.
326
+ * @param {...object} elements Route entries followed by their trace.
327
+ * @returns {boolean}
328
+ */
329
+ static #solderMaskOpening(...elements) {
330
+ for (const element of elements) {
331
+ const covered =
332
+ PcbScene3dCircuitJsonTraceRouteBuilder.#solderMaskCoveredValue(
333
+ element
334
+ )
335
+ if (covered !== null) return !covered
336
+ }
337
+ return false
338
+ }
339
+
340
+ /**
341
+ * Reads one explicit solder-mask coverage value.
342
+ * @param {object} element CircuitJSON copper element.
343
+ * @returns {boolean | null}
344
+ */
345
+ static #solderMaskCoveredValue(element) {
346
+ const value =
347
+ element?.is_covered_with_solder_mask ??
348
+ element?.covered_with_solder_mask
349
+ if (typeof value === 'boolean') return value
350
+ if (value === undefined || value === null || value === '') return null
351
+
352
+ const text = String(value).trim().toLowerCase()
353
+ if (text === 'true') return true
354
+ if (text === 'false') return false
355
+ return null
356
+ }
357
+
319
358
  /**
320
359
  * Converts a route point from millimeters to mils.
321
360
  * @param {object | undefined} point Source point.
@@ -93,7 +93,11 @@ export class PcbScene3dCopperDetailFilter {
93
93
  detail.polygons,
94
94
  defaultCovered
95
95
  ),
96
- copperTexts: [],
96
+ copperTexts:
97
+ PcbScene3dCopperDetailFilter.#filterMaskCoveredPrimitives(
98
+ detail.copperTexts,
99
+ defaultCovered
100
+ ),
97
101
  vias: []
98
102
  }
99
103
  }
@@ -100,7 +100,7 @@ export class PcbScene3dCopperFactory {
100
100
  /**
101
101
  * Builds top and bottom copper detail that is covered by solder mask.
102
102
  * @param {any} THREE
103
- * @param {{ tracks?: any[], arcs?: any[], fills?: any[], polygons?: any[] }} detail Mask-covered detail.
103
+ * @param {{ tracks?: any[], arcs?: any[], fills?: any[], polygons?: any[], copperTexts?: any[] }} detail Mask-covered detail.
104
104
  * @param {number} topZ
105
105
  * @param {number} bottomZ
106
106
  * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint
@@ -146,7 +146,8 @@ export class PcbScene3dCopperFactory {
146
146
  fills: PcbScene3dCopperLayerFilter.fills(
147
147
  [...(detail?.fills || []), ...(detail?.polygons || [])],
148
148
  'top'
149
- )
149
+ ),
150
+ copperTexts: detail?.copperTexts || []
150
151
  },
151
152
  Math.abs(Number(topZ || 0)),
152
153
  normalizeBoardPoint,
@@ -166,7 +167,8 @@ export class PcbScene3dCopperFactory {
166
167
  fills: PcbScene3dCopperLayerFilter.fills(
167
168
  [...(detail?.fills || []), ...(detail?.polygons || [])],
168
169
  'bottom'
169
- )
170
+ ),
171
+ copperTexts: detail?.copperTexts || []
170
172
  },
171
173
  Math.abs(Number(bottomZ || 0)),
172
174
  normalizeBoardPoint,
@@ -267,7 +269,7 @@ export class PcbScene3dCopperFactory {
267
269
  /**
268
270
  * Builds one side of the mask-covered copper relief.
269
271
  * @param {any} THREE
270
- * @param {{ tracks?: any[], arcs?: any[], fills?: any[] }} detail Mask-covered detail.
272
+ * @param {{ tracks?: any[], arcs?: any[], fills?: any[], copperTexts?: any[] }} detail Mask-covered detail.
271
273
  * @param {number} z
272
274
  * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint
273
275
  * @param {boolean} mirrorY
@@ -353,10 +355,27 @@ export class PcbScene3dCopperFactory {
353
355
  coverageContext
354
356
  }
355
357
  )
358
+ const textGroup = PcbScene3dCopperTextFactory.buildGroup(
359
+ THREE,
360
+ detail?.copperTexts || [],
361
+ z,
362
+ normalizeBoardPoint,
363
+ {
364
+ glyphYUp: PcbScene3dCopperFactory.#usesYUpGlyphs(options),
365
+ side: mirrorY ? 'bottom' : 'top',
366
+ mirrorY,
367
+ materialColor: PcbScene3dMaskCoveredCopperMaterial.resolveColor(
368
+ options?.solderMaskColor
369
+ ),
370
+ drillCutouts: occlusionCutouts
371
+ }
372
+ )
373
+ const textMesh = textGroup.children[0] || null
356
374
  return PcbScene3dMaskCoveredCopperSideGroupBuilder.build(THREE, {
357
375
  trackMesh,
358
376
  arcMesh,
359
377
  fillMesh,
378
+ textMesh,
360
379
  z,
361
380
  mirrorY
362
381
  })