pcb-scene3d-viewer 1.1.47 → 1.1.48

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pcb-scene3d-viewer",
3
- "version": "1.1.47",
3
+ "version": "1.1.48",
4
4
  "description": "Reusable Three.js PCB 3D scene viewer for normalized ECAD and CircuitJSON scene descriptions",
5
5
  "keywords": [
6
6
  "pcb",
@@ -19,9 +19,10 @@ export class PcbScene3dBoardAssemblyPresentation {
19
19
  * PCB-derived substrate, copper, and silkscreen remain the visual source.
20
20
  * @param {any} modelGroup Loaded board assembly group.
21
21
  * @param {{ widthMil?: number, heightMil?: number, thicknessMil?: number } | null | undefined} board Board dimensions.
22
+ * @param {{ sourceFormat?: string }} [options] Presentation options.
22
23
  * @returns {void}
23
24
  */
24
- static apply(modelGroup, board) {
25
+ static apply(modelGroup, board, options = {}) {
25
26
  const meshRecords =
26
27
  PcbScene3dBoardAssemblyPresentation.#collectMeshRecords(modelGroup)
27
28
  const boardBounds =
@@ -30,7 +31,10 @@ export class PcbScene3dBoardAssemblyPresentation {
30
31
  board
31
32
  )
32
33
  const surfaceColor =
33
- PcbScene3dBoardAssemblyPresentation.#resolveSurfaceColor(board)
34
+ PcbScene3dBoardAssemblyPresentation.#resolveSurfaceColor(
35
+ board,
36
+ options
37
+ )
34
38
  const edgeColor =
35
39
  PcbScene3dBoardAssemblyPresentation.#resolveEdgeColor(board)
36
40
  const importedSurfaceColor =
@@ -412,11 +416,13 @@ export class PcbScene3dBoardAssemblyPresentation {
412
416
  /**
413
417
  * Resolves the app-level substrate color for board assembly meshes.
414
418
  * @param {{ surfaceColor?: number } | null | undefined} board Board dimensions.
419
+ * @param {{ sourceFormat?: string }} options Presentation options.
415
420
  * @returns {number}
416
421
  */
417
- static #resolveSurfaceColor(board) {
422
+ static #resolveSurfaceColor(board, options) {
418
423
  return PcbScene3dBoardMaterialPalette.resolveSurfaceColor(board, {
419
- hasBoardAssemblyModel: true
424
+ hasBoardAssemblyModel: true,
425
+ sourceFormat: options?.sourceFormat
420
426
  })
421
427
  }
422
428
 
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Builds normalized Three.js paths for explicit board cutouts.
3
+ */
4
+ export class PcbScene3dBoardCutoutPathFactory {
5
+ /**
6
+ * Resolves explicit board cutout paths.
7
+ * @param {any} THREE Three.js namespace.
8
+ * @param {{ cutouts?: any[] }} board Board metadata.
9
+ * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint
10
+ * @returns {{ path: any, points: { x: number, y: number }[] }[]}
11
+ */
12
+ static resolve(THREE, board, normalizeBoardPoint) {
13
+ return (Array.isArray(board?.cutouts) ? board.cutouts : [])
14
+ .map((cutout) =>
15
+ PcbScene3dBoardCutoutPathFactory.#buildPath(
16
+ THREE,
17
+ cutout,
18
+ normalizeBoardPoint
19
+ )
20
+ )
21
+ .filter(Boolean)
22
+ }
23
+
24
+ /**
25
+ * Builds one explicit board cutout path.
26
+ * @param {any} THREE Three.js namespace.
27
+ * @param {any} cutout Source cutout.
28
+ * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint
29
+ * @returns {{ path: any, points: { x: number, y: number }[] } | null}
30
+ */
31
+ static #buildPath(THREE, cutout, normalizeBoardPoint) {
32
+ const points = PcbScene3dBoardCutoutPathFactory.#sourcePoints(cutout)
33
+ .map((point) =>
34
+ PcbScene3dBoardCutoutPathFactory.#normalizePoint(
35
+ point,
36
+ normalizeBoardPoint
37
+ )
38
+ )
39
+ .filter(Boolean)
40
+
41
+ if (points.length < 3) {
42
+ return null
43
+ }
44
+
45
+ const path = new THREE.Path()
46
+ path.moveTo(points[0].x, points[0].y)
47
+ for (let index = 1; index < points.length; index += 1) {
48
+ path.lineTo(points[index].x, points[index].y)
49
+ }
50
+ path.closePath()
51
+
52
+ return { path, points }
53
+ }
54
+
55
+ /**
56
+ * Resolves one cutout's point list.
57
+ * @param {any} cutout Source cutout.
58
+ * @returns {any[]}
59
+ */
60
+ static #sourcePoints(cutout) {
61
+ if (Array.isArray(cutout?.points)) {
62
+ return cutout.points
63
+ }
64
+
65
+ if (Array.isArray(cutout?.vertices)) {
66
+ return cutout.vertices
67
+ }
68
+
69
+ return Array.isArray(cutout) ? cutout : []
70
+ }
71
+
72
+ /**
73
+ * Normalizes one source point into local board shape coordinates.
74
+ * @param {any} point Source point.
75
+ * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint
76
+ * @returns {{ x: number, y: number } | null}
77
+ */
78
+ static #normalizePoint(point, normalizeBoardPoint) {
79
+ const x = Number(point?.x ?? point?.[0])
80
+ const y = Number(point?.y ?? point?.[1])
81
+ return Number.isFinite(x + y) ? normalizeBoardPoint(x, y) : null
82
+ }
83
+ }
@@ -7,11 +7,16 @@ export class PcbScene3dBoardMaterialPalette {
7
7
  /**
8
8
  * Resolves the solder-mask face color for the generated board shell.
9
9
  * @param {{ surfaceColor?: number } | undefined} board Board metadata.
10
- * @param {{ hasBoardAssemblyModel?: boolean }} [options] Scene options.
10
+ * @param {{ hasBoardAssemblyModel?: boolean, sourceFormat?: string }} [options] Scene options.
11
11
  * @returns {number}
12
12
  */
13
13
  static resolveSurfaceColor(board, options = {}) {
14
- if (options.hasBoardAssemblyModel) {
14
+ if (
15
+ options.hasBoardAssemblyModel &&
16
+ !PcbScene3dBoardMaterialPalette.#shouldPreserveAuthoredSurfaceColor(
17
+ options
18
+ )
19
+ ) {
15
20
  return PcbScene3dBoardMaterialPalette.#DEFAULT_SURFACE_COLOR
16
21
  }
17
22
 
@@ -20,6 +25,16 @@ export class PcbScene3dBoardMaterialPalette {
20
25
  : PcbScene3dBoardMaterialPalette.#DEFAULT_SURFACE_COLOR
21
26
  }
22
27
 
28
+ /**
29
+ * Returns true when the source format supplies display-stable board colors.
30
+ * @param {{ sourceFormat?: string }} options Scene options.
31
+ * @returns {boolean}
32
+ */
33
+ static #shouldPreserveAuthoredSurfaceColor(options) {
34
+ const sourceFormat = String(options?.sourceFormat || '').toLowerCase()
35
+ return sourceFormat === 'altium' || sourceFormat.startsWith('altium-')
36
+ }
37
+
23
38
  /**
24
39
  * Resolves whether the generated board face should render.
25
40
  * @param {{ hasBoardAssemblyModel?: boolean }} [options] Scene options.
@@ -1,4 +1,5 @@
1
1
  import { PcbScene3dBoardEdgeCutoutBuilder } from './PcbScene3dBoardEdgeCutoutBuilder.mjs'
2
+ import { PcbScene3dBoardCutoutPathFactory } from './PcbScene3dBoardCutoutPathFactory.mjs'
2
3
  import { PcbScene3dDrillPathFactory } from './PcbScene3dDrillPathFactory.mjs'
3
4
  import { PcbScene3dOutlineBuilder } from './PcbScene3dOutlineBuilder.mjs'
4
5
 
@@ -36,6 +37,11 @@ export class PcbScene3dBoardShapeFactory {
36
37
  )
37
38
  const contourPoints =
38
39
  PcbScene3dBoardEdgeCutoutBuilder.resolveShapePoints(baseShape)
40
+ const boardCutouts = PcbScene3dBoardCutoutPathFactory.resolve(
41
+ THREE,
42
+ board,
43
+ normalizeBoardPoint
44
+ )
39
45
  const drillCutouts = PcbScene3dBoardShapeFactory.#resolveDrillCutouts(
40
46
  THREE,
41
47
  detail,
@@ -61,12 +67,20 @@ export class PcbScene3dBoardShapeFactory {
61
67
  const finalContourPoints = edgeCutouts.length
62
68
  ? PcbScene3dBoardEdgeCutoutBuilder.resolveShapePoints(shape)
63
69
  : contourPoints
64
-
70
+ for (const cutout of boardCutouts) {
71
+ if (
72
+ PcbScene3dBoardEdgeCutoutBuilder.isHoleInsideContour(
73
+ cutout.points,
74
+ finalContourPoints
75
+ )
76
+ ) {
77
+ shape.holes.push(cutout.path)
78
+ }
79
+ }
65
80
  for (const cutout of drillCutouts) {
66
81
  if (edgeCutouts.includes(cutout)) {
67
82
  continue
68
83
  }
69
-
70
84
  if (
71
85
  !cutout.isCircular ||
72
86
  PcbScene3dBoardEdgeCutoutBuilder.isHoleInsideContour(
@@ -77,7 +91,6 @@ export class PcbScene3dBoardShapeFactory {
77
91
  shape.holes.push(cutout.path)
78
92
  }
79
93
  }
80
-
81
94
  return shape
82
95
  }
83
96
 
@@ -1,4 +1,5 @@
1
1
  import { PcbScene3dBoardMaterialPalette } from './PcbScene3dBoardMaterialPalette.mjs'
2
+ import { PcbScene3dBoardCutoutPathFactory } from './PcbScene3dBoardCutoutPathFactory.mjs'
2
3
  import { PcbScene3dCutoutGeometryFilter } from './PcbScene3dCutoutGeometryFilter.mjs'
3
4
  import { PcbScene3dDrillPathFactory } from './PcbScene3dDrillPathFactory.mjs'
4
5
  import { PcbScene3dMaterialFinish } from './PcbScene3dMaterialFinish.mjs'
@@ -51,12 +52,14 @@ export class PcbScene3dBoardSolderMaskFactory {
51
52
  const topMaterial = PcbScene3dBoardSolderMaskFactory.#buildMaterial(
52
53
  THREE,
53
54
  board,
54
- THREE.FrontSide
55
+ THREE.FrontSide,
56
+ sceneDescription?.sourceFormat
55
57
  )
56
58
  const bottomMaterial = PcbScene3dBoardSolderMaskFactory.#buildMaterial(
57
59
  THREE,
58
60
  board,
59
- THREE.BackSide
61
+ THREE.BackSide,
62
+ sceneDescription?.sourceFormat
60
63
  )
61
64
  const z = Number(board?.thicknessMil || 0) / 2
62
65
 
@@ -148,6 +151,11 @@ export class PcbScene3dBoardSolderMaskFactory {
148
151
  )
149
152
  const contourPoints =
150
153
  PcbScene3dBoardSolderMaskFactory.#resolveShapePoints(shape)
154
+ const boardCutouts = PcbScene3dBoardCutoutPathFactory.resolve(
155
+ THREE,
156
+ board,
157
+ normalizeBoardPoint
158
+ )
151
159
  const drillCutouts =
152
160
  PcbScene3dBoardSolderMaskFactory.#resolveDrillCutouts(
153
161
  THREE,
@@ -155,8 +163,8 @@ export class PcbScene3dBoardSolderMaskFactory {
155
163
  normalizeBoardPoint
156
164
  )
157
165
  const { shapeHoles, clippingCutouts } =
158
- PcbScene3dBoardSolderMaskFactory.#partitionDrillCutouts(
159
- drillCutouts,
166
+ PcbScene3dBoardSolderMaskFactory.#partitionCutouts(
167
+ [...boardCutouts, ...drillCutouts],
160
168
  contourPoints
161
169
  )
162
170
 
@@ -395,16 +403,16 @@ export class PcbScene3dBoardSolderMaskFactory {
395
403
  }
396
404
 
397
405
  /**
398
- * Splits drill cutouts into safe shape holes and fallback clip polygons.
399
- * @param {{ path: any, points: { x: number, y: number }[] }[]} drillCutouts
406
+ * Splits cutouts into safe shape holes and fallback clip polygons.
407
+ * @param {{ path: any, points: { x: number, y: number }[] }[]} cutouts
400
408
  * @param {{ x: number, y: number }[]} contourPoints
401
409
  * @returns {{ shapeHoles: { path: any, points: { x: number, y: number }[] }[], clippingCutouts: { path: any, points: { x: number, y: number }[] }[] }}
402
410
  */
403
- static #partitionDrillCutouts(drillCutouts, contourPoints) {
411
+ static #partitionCutouts(cutouts, contourPoints) {
404
412
  const shapeHoles = []
405
413
  const clippingCutouts = []
406
414
 
407
- for (const cutout of Array.isArray(drillCutouts) ? drillCutouts : []) {
415
+ for (const cutout of Array.isArray(cutouts) ? cutouts : []) {
408
416
  if (
409
417
  PcbScene3dBoardSolderMaskFactory.#isHoleInsideContour(
410
418
  cutout.points,
@@ -596,12 +604,14 @@ export class PcbScene3dBoardSolderMaskFactory {
596
604
  * @param {any} THREE
597
605
  * @param {{ surfaceColor?: number }} board Board metadata.
598
606
  * @param {number} side Rendered material side.
607
+ * @param {string | undefined} sourceFormat Scene source format.
599
608
  * @returns {any}
600
609
  */
601
- static #buildMaterial(THREE, board, side) {
610
+ static #buildMaterial(THREE, board, side, sourceFormat) {
602
611
  return new THREE.MeshStandardMaterial({
603
612
  color: PcbScene3dBoardMaterialPalette.resolveSurfaceColor(board, {
604
- hasBoardAssemblyModel: true
613
+ hasBoardAssemblyModel: true,
614
+ sourceFormat
605
615
  }),
606
616
  ...PcbScene3dMaterialFinish.semiMatteSolderMaskProperties(),
607
617
  polygonOffset: true,
@@ -474,6 +474,7 @@ export class PcbScene3dController {
474
474
  this.#handleRuntimeSelection(selection),
475
475
  translate: this.#translate
476
476
  })
477
+ this.#applyInitialToggles()
477
478
  if (!this.#autoSearchMissingModels) {
478
479
  this.#runtime?.setToggle?.('model-search-models', false)
479
480
  }
@@ -562,6 +563,25 @@ export class PcbScene3dController {
562
563
  })
563
564
  }
564
565
 
566
+ /**
567
+ * Synchronizes runtime visibility with the shell's initial checkbox state.
568
+ * @returns {void}
569
+ */
570
+ #applyInitialToggles() {
571
+ const toggles =
572
+ this.#rootNode?.querySelectorAll('[data-scene-3d-toggle]') || []
573
+
574
+ toggles.forEach((toggle) => {
575
+ const toggleName =
576
+ toggle?.getAttribute?.('data-scene-3d-toggle') || ''
577
+ if (!toggleName) {
578
+ return
579
+ }
580
+
581
+ this.#runtime?.setToggle?.(toggleName, Boolean(toggle.checked))
582
+ })
583
+ }
584
+
565
585
  /**
566
586
  * Binds the model archive export action.
567
587
  * @returns {void}
@@ -25,9 +25,14 @@ export class PcbScene3dCopperDetailGroupBuilder {
25
25
  */
26
26
  static build(THREE, sceneDescription, topZ, normalizePoint) {
27
27
  const group = new THREE.Group()
28
- const drillCutouts = PcbScene3dCopperDrillCutoutBuilder.resolve(
29
- sceneDescription?.detail
30
- )
28
+ const drillCutouts = [
29
+ ...PcbScene3dCopperDrillCutoutBuilder.resolve(
30
+ sceneDescription?.detail
31
+ ),
32
+ ...PcbScene3dCopperDetailGroupBuilder.#resolveBoardCutouts(
33
+ sceneDescription?.board
34
+ )
35
+ ]
31
36
  const coveredGroup =
32
37
  PcbScene3dCopperDetailGroupBuilder.#buildCoveredGroup(
33
38
  THREE,
@@ -187,6 +192,43 @@ export class PcbScene3dCopperDetailGroupBuilder {
187
192
  )
188
193
  }
189
194
 
195
+ /**
196
+ * Resolves explicit through-board cutouts that copper detail must not cover.
197
+ * @param {{ cutouts?: any[] } | undefined} board Board metadata.
198
+ * @returns {{ x: number, y: number }[][]}
199
+ */
200
+ static #resolveBoardCutouts(board) {
201
+ return (Array.isArray(board?.cutouts) ? board.cutouts : [])
202
+ .map((cutout) =>
203
+ PcbScene3dCopperDetailGroupBuilder.#resolveBoardCutoutPoints(
204
+ cutout
205
+ )
206
+ )
207
+ .filter((points) => points.length >= 3)
208
+ }
209
+
210
+ /**
211
+ * Resolves one explicit board cutout loop in source board coordinates.
212
+ * @param {any} cutout Cutout source.
213
+ * @returns {{ x: number, y: number }[]}
214
+ */
215
+ static #resolveBoardCutoutPoints(cutout) {
216
+ const points = Array.isArray(cutout?.points)
217
+ ? cutout.points
218
+ : Array.isArray(cutout?.vertices)
219
+ ? cutout.vertices
220
+ : Array.isArray(cutout)
221
+ ? cutout
222
+ : []
223
+
224
+ return points
225
+ .map((point) => ({
226
+ x: Number(point?.x ?? point?.[0]),
227
+ y: Number(point?.y ?? point?.[1])
228
+ }))
229
+ .filter((point) => Number.isFinite(point.x + point.y))
230
+ }
231
+
190
232
  /**
191
233
  * Resolves solder-mask material options for covered copper relief.
192
234
  * @param {object} sceneDescription Scene description.
@@ -199,7 +241,8 @@ export class PcbScene3dCopperDetailGroupBuilder {
199
241
  {
200
242
  hasBoardAssemblyModel: Boolean(
201
243
  sceneDescription?.boardAssemblyModel
202
- )
244
+ ),
245
+ sourceFormat: sceneDescription?.sourceFormat
203
246
  }
204
247
  )
205
248
  }
@@ -33,15 +33,13 @@ export class PcbScene3dDrillVoidFactory {
33
33
  return group
34
34
  }
35
35
 
36
- const material = PcbScene3dDrillVoidFactory.#buildMaterial(
36
+ const material = PcbScene3dDrillVoidFactory.#buildInteriorMaterial(
37
37
  THREE,
38
38
  options
39
39
  )
40
40
  const geometryCache = new Map()
41
41
  const depth = Math.max(Math.abs(Number(topZ) - Number(bottomZ)), 1)
42
42
  const centerZ = (Number(topZ || 0) + Number(bottomZ || 0)) / 2
43
- const platedDrillKeys =
44
- PcbScene3dDrillVoidFactory.#buildPlatedDrillKeySet(detail)
45
43
  const edgeDrillKeys = PcbScene3dDrillVoidFactory.#buildEdgeDrillKeySet(
46
44
  THREE,
47
45
  detail,
@@ -52,9 +50,7 @@ export class PcbScene3dDrillVoidFactory {
52
50
  PcbScene3dDrillPathFactory.resolveBoardDrillSpecs(detail).forEach(
53
51
  (drillSpec) => {
54
52
  if (
55
- platedDrillKeys.has(
56
- PcbScene3dDrillVoidFactory.#drillKey(drillSpec)
57
- ) ||
53
+ PcbScene3dDrillVoidFactory.#isSlottedDrill(drillSpec) ||
58
54
  edgeDrillKeys.has(
59
55
  PcbScene3dDrillVoidFactory.#drillKey(drillSpec)
60
56
  )
@@ -101,63 +97,6 @@ export class PcbScene3dDrillVoidFactory {
101
97
  )
102
98
  }
103
99
 
104
- /**
105
- * Builds a lookup for drill holes that already have copper barrel geometry.
106
- * @param {{ pads?: any[], vias?: any[] }} detail
107
- * @returns {Set<string>}
108
- */
109
- static #buildPlatedDrillKeySet(detail) {
110
- const platedDrills = new Set()
111
-
112
- for (const via of detail?.vias || []) {
113
- if (PcbScene3dDrillVoidFactory.#hasViaCopperAnnulus(via)) {
114
- platedDrills.add(PcbScene3dDrillVoidFactory.#drillKey(via))
115
- }
116
- }
117
-
118
- for (const pad of detail?.pads || []) {
119
- if (PcbScene3dDrillVoidFactory.#hasPadCopperAnnulus(pad)) {
120
- platedDrills.add(PcbScene3dDrillVoidFactory.#drillKey(pad))
121
- }
122
- }
123
-
124
- return platedDrills
125
- }
126
-
127
- /**
128
- * Checks whether one via has a copper annulus around its drill.
129
- * @param {any} via Via primitive.
130
- * @returns {boolean}
131
- */
132
- static #hasViaCopperAnnulus(via) {
133
- const holeDiameter = Number(via?.holeDiameter || 0)
134
- return (
135
- holeDiameter > 0 &&
136
- Number(via?.diameter || 0) > holeDiameter + 0.001
137
- )
138
- }
139
-
140
- /**
141
- * Checks whether one pad has copper larger than its drill aperture.
142
- * @param {any} pad Pad primitive.
143
- * @returns {boolean}
144
- */
145
- static #hasPadCopperAnnulus(pad) {
146
- const holeDiameter = Number(pad?.holeDiameter || 0)
147
- if (holeDiameter <= 0) {
148
- return false
149
- }
150
-
151
- return [
152
- pad?.sizeTopX,
153
- pad?.sizeTopY,
154
- pad?.sizeMidX,
155
- pad?.sizeMidY,
156
- pad?.sizeBottomX,
157
- pad?.sizeBottomY
158
- ].some((size) => Number(size || 0) > holeDiameter + 0.001)
159
- }
160
-
161
100
  /**
162
101
  * Builds a stable lookup key for one circular drill.
163
102
  * @param {{ x?: number, y?: number, holeDiameter?: number, diameter?: number }} drill
@@ -177,7 +116,7 @@ export class PcbScene3dDrillVoidFactory {
177
116
  * @param {{ color?: number }} options
178
117
  * @returns {any}
179
118
  */
180
- static #buildMaterial(THREE, options) {
119
+ static #buildInteriorMaterial(THREE, options) {
181
120
  return new THREE.MeshStandardMaterial({
182
121
  color: Number.isInteger(options?.color)
183
122
  ? options.color
@@ -188,6 +127,18 @@ export class PcbScene3dDrillVoidFactory {
188
127
  })
189
128
  }
190
129
 
130
+ /**
131
+ * Checks whether one drill is a routed slot.
132
+ * @param {{ diameter?: number, slotLength?: number | null }} drillSpec Drill spec.
133
+ * @returns {boolean}
134
+ */
135
+ static #isSlottedDrill(drillSpec) {
136
+ return (
137
+ Number(drillSpec?.slotLength || 0) >
138
+ Number(drillSpec?.diameter || 0) + 0.001
139
+ )
140
+ }
141
+
191
142
  /**
192
143
  * Builds one open circular drill-interior mesh.
193
144
  * @param {any} THREE
@@ -208,13 +159,6 @@ export class PcbScene3dDrillVoidFactory {
208
159
  material,
209
160
  normalizeBoardPoint
210
161
  ) {
211
- if (
212
- Number(drillSpec?.slotLength || 0) >
213
- Number(drillSpec?.diameter || 0) + 0.001
214
- ) {
215
- return null
216
- }
217
-
218
162
  const point = normalizeBoardPoint(
219
163
  Number(drillSpec?.x || 0),
220
164
  Number(drillSpec?.y || 0)
@@ -281,7 +281,8 @@ export class PcbScene3dExternalModels {
281
281
  return PcbScene3dExternalModels.#buildBoardAssemblyWrapper(
282
282
  THREE,
283
283
  placement,
284
- modelGroup
284
+ modelGroup,
285
+ sceneDescription
285
286
  )
286
287
  }
287
288
 
@@ -371,13 +372,25 @@ export class PcbScene3dExternalModels {
371
372
  * @param {any} THREE
372
373
  * @param {{ positionMil?: { x?: number, y?: number, z?: number }, board?: { widthMil?: number, heightMil?: number, thicknessMil?: number }, sourceFrameScale?: { y?: number } }} placement
373
374
  * @param {any} modelGroup
375
+ * @param {{ sourceFormat?: string } | null | undefined} sceneDescription Scene description.
374
376
  * @returns {any}
375
377
  */
376
- static #buildBoardAssemblyWrapper(THREE, placement, modelGroup) {
378
+ static #buildBoardAssemblyWrapper(
379
+ THREE,
380
+ placement,
381
+ modelGroup,
382
+ sceneDescription
383
+ ) {
377
384
  const wrapperGroup = new THREE.Group()
378
385
  const positionMil = placement?.positionMil || {}
379
386
 
380
- PcbScene3dBoardAssemblyPresentation.apply(modelGroup, placement?.board)
387
+ PcbScene3dBoardAssemblyPresentation.apply(
388
+ modelGroup,
389
+ placement?.board,
390
+ {
391
+ sourceFormat: sceneDescription?.sourceFormat
392
+ }
393
+ )
381
394
  PcbScene3dBoardAssemblyTransform.apply(modelGroup, placement)
382
395
  wrapperGroup.position.set(
383
396
  Number(positionMil.x || 0),
@@ -9,13 +9,16 @@ export class PcbScene3dGeometryZCompressor {
9
9
  * Rewrites mask-covered copper relief below exposed copper height.
10
10
  * @param {any | null} mesh Mesh with a position attribute.
11
11
  * @param {number} sourceCenterZ Original geometry center Z.
12
+ * @param {{ centerOffsetMil?: number }} [options] Compression options.
12
13
  * @returns {void}
13
14
  */
14
- static compressMaskCoveredCopperMesh(mesh, sourceCenterZ) {
15
+ static compressMaskCoveredCopperMesh(mesh, sourceCenterZ, options = {}) {
15
16
  PcbScene3dGeometryZCompressor.compressMesh(
16
17
  mesh,
17
18
  sourceCenterZ,
18
- sourceCenterZ + MASK_COVERED_CENTER_OFFSET_MIL,
19
+ sourceCenterZ +
20
+ MASK_COVERED_CENTER_OFFSET_MIL +
21
+ Number(options?.centerOffsetMil || 0),
19
22
  MASK_COVERED_THICKNESS_MIL
20
23
  )
21
24
  }
@@ -6,7 +6,7 @@ import { PcbScene3dMaterialFinish } from './PcbScene3dMaterialFinish.mjs'
6
6
  export class PcbScene3dMaskCoveredCopperMaterial {
7
7
  static #COPPER_COLOR = 0xd9a61d
8
8
  static #DEFAULT_SOLDER_MASK_COLOR = 0x2a5f27
9
- static #COPPER_BLEND = 0.2
9
+ static #COPPER_BLEND = 0.04
10
10
 
11
11
  /**
12
12
  * Builds a mask-covered copper material.
@@ -4,6 +4,13 @@ import { PcbScene3dGeometryZCompressor } from './PcbScene3dGeometryZCompressor.m
4
4
  * Builds side-specific copper relief groups that sit below solder mask.
5
5
  */
6
6
  export class PcbScene3dMaskCoveredCopperSideGroupBuilder {
7
+ static #GREEN_MASKED_COPPER_TINT = 0x1f7a34
8
+ static #FILL_COPPER_BLEND = 0.45
9
+ static #TRACK_COPPER_BLEND = 0.7
10
+ static #FILL_RENDER_ORDER = 10
11
+ static #TRACK_RENDER_ORDER = 12
12
+ static #TRACK_CENTER_OFFSET_MIL = 0.16
13
+
7
14
  /**
8
15
  * Builds one side group from prepared mask-covered copper meshes.
9
16
  * @param {any} THREE Three.js namespace.
@@ -26,19 +33,49 @@ export class PcbScene3dMaskCoveredCopperSideGroupBuilder {
26
33
  group,
27
34
  trackMesh,
28
35
  'mask-covered-copper-tracks',
29
- z
36
+ z,
37
+ {
38
+ centerOffsetMil:
39
+ PcbScene3dMaskCoveredCopperSideGroupBuilder
40
+ .#TRACK_CENTER_OFFSET_MIL,
41
+ copperBlend:
42
+ PcbScene3dMaskCoveredCopperSideGroupBuilder
43
+ .#TRACK_COPPER_BLEND,
44
+ renderOrder:
45
+ PcbScene3dMaskCoveredCopperSideGroupBuilder
46
+ .#TRACK_RENDER_ORDER
47
+ }
30
48
  )
31
49
  PcbScene3dMaskCoveredCopperSideGroupBuilder.#addCompressedMesh(
32
50
  group,
33
51
  arcMesh,
34
52
  'mask-covered-copper-arcs',
35
- z
53
+ z,
54
+ {
55
+ centerOffsetMil:
56
+ PcbScene3dMaskCoveredCopperSideGroupBuilder
57
+ .#TRACK_CENTER_OFFSET_MIL,
58
+ copperBlend:
59
+ PcbScene3dMaskCoveredCopperSideGroupBuilder
60
+ .#TRACK_COPPER_BLEND,
61
+ renderOrder:
62
+ PcbScene3dMaskCoveredCopperSideGroupBuilder
63
+ .#TRACK_RENDER_ORDER
64
+ }
36
65
  )
37
66
  PcbScene3dMaskCoveredCopperSideGroupBuilder.#addCompressedMesh(
38
67
  group,
39
68
  fillMesh,
40
69
  'mask-covered-copper-fills',
41
- z
70
+ z,
71
+ {
72
+ copperBlend:
73
+ PcbScene3dMaskCoveredCopperSideGroupBuilder
74
+ .#FILL_COPPER_BLEND,
75
+ renderOrder:
76
+ PcbScene3dMaskCoveredCopperSideGroupBuilder
77
+ .#FILL_RENDER_ORDER
78
+ }
42
79
  )
43
80
 
44
81
  if (mirrorY && group.children.length) {
@@ -54,15 +91,145 @@ export class PcbScene3dMaskCoveredCopperSideGroupBuilder {
54
91
  * @param {any | null} mesh Mesh to add.
55
92
  * @param {string} name Scene object name.
56
93
  * @param {number} z Source center Z.
94
+ * @param {{ centerOffsetMil?: number, copperBlend?: number, renderOrder?: number }} [options] Presentation options.
57
95
  * @returns {void}
58
96
  */
59
- static #addCompressedMesh(group, mesh, name, z) {
97
+ static #addCompressedMesh(group, mesh, name, z, options = {}) {
60
98
  if (!mesh) {
61
99
  return
62
100
  }
63
101
 
64
- PcbScene3dGeometryZCompressor.compressMaskCoveredCopperMesh(mesh, z)
102
+ PcbScene3dGeometryZCompressor.compressMaskCoveredCopperMesh(mesh, z, {
103
+ centerOffsetMil: options.centerOffsetMil
104
+ })
65
105
  mesh.name = name
106
+ mesh.renderOrder = Number(options.renderOrder || 0)
107
+ PcbScene3dMaskCoveredCopperSideGroupBuilder.#applyCopperTint(
108
+ mesh,
109
+ options.copperBlend
110
+ )
66
111
  group.add(mesh)
67
112
  }
113
+
114
+ /**
115
+ * Applies a role-specific covered-copper tint without mutating sibling meshes.
116
+ * @param {any} mesh Source mesh.
117
+ * @param {number | undefined} copperBlend Blend ratio.
118
+ * @returns {void}
119
+ */
120
+ static #applyCopperTint(mesh, copperBlend) {
121
+ const material = mesh?.material?.clone?.() || mesh?.material
122
+ const baseColor = material?.color?.getHex?.()
123
+ if (!material || !Number.isInteger(baseColor)) {
124
+ return
125
+ }
126
+
127
+ material.color.setHex(
128
+ PcbScene3dMaskCoveredCopperSideGroupBuilder.#blendHexColor(
129
+ baseColor,
130
+ PcbScene3dMaskCoveredCopperSideGroupBuilder.#resolveMaskedCopperTint(
131
+ baseColor
132
+ ),
133
+ copperBlend
134
+ )
135
+ )
136
+ mesh.material = material
137
+ }
138
+
139
+ /**
140
+ * Resolves a mask-covered copper tint that stays in the solder-mask hue.
141
+ * @param {number} baseColor Base mask-covered copper color.
142
+ * @returns {number}
143
+ */
144
+ static #resolveMaskedCopperTint(baseColor) {
145
+ const channels =
146
+ PcbScene3dMaskCoveredCopperSideGroupBuilder.#rgbChannels(baseColor)
147
+ const dominant =
148
+ PcbScene3dMaskCoveredCopperSideGroupBuilder.#dominantChannel(
149
+ channels
150
+ )
151
+ if (dominant === 'green') {
152
+ return PcbScene3dMaskCoveredCopperSideGroupBuilder
153
+ .#GREEN_MASKED_COPPER_TINT
154
+ }
155
+
156
+ return PcbScene3dMaskCoveredCopperSideGroupBuilder.#rgbToHex({
157
+ red: PcbScene3dMaskCoveredCopperSideGroupBuilder.#tintChannel(
158
+ channels.red,
159
+ dominant === 'red'
160
+ ),
161
+ green: PcbScene3dMaskCoveredCopperSideGroupBuilder.#tintChannel(
162
+ channels.green,
163
+ dominant === 'green'
164
+ ),
165
+ blue: PcbScene3dMaskCoveredCopperSideGroupBuilder.#tintChannel(
166
+ channels.blue,
167
+ dominant === 'blue'
168
+ )
169
+ })
170
+ }
171
+
172
+ /**
173
+ * Resolves one RGB channel from a packed color.
174
+ * @param {number} color Packed hex color.
175
+ * @returns {{ red: number, green: number, blue: number }}
176
+ */
177
+ static #rgbChannels(color) {
178
+ return {
179
+ red: (color >> 16) & 255,
180
+ green: (color >> 8) & 255,
181
+ blue: color & 255
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Resolves the dominant color channel.
187
+ * @param {{ red: number, green: number, blue: number }} channels RGB channels.
188
+ * @returns {'red' | 'green' | 'blue'}
189
+ */
190
+ static #dominantChannel(channels) {
191
+ return Object.entries(channels).sort(
192
+ (left, right) => right[1] - left[1]
193
+ )[0][0]
194
+ }
195
+
196
+ /**
197
+ * Builds a tint channel that preserves the source mask hue.
198
+ * @param {number} channel Source channel value.
199
+ * @param {boolean} dominant Whether this channel is dominant.
200
+ * @returns {number}
201
+ */
202
+ static #tintChannel(channel, dominant) {
203
+ const value = dominant ? channel * 1.25 + 16 : channel * 0.84
204
+ return Math.min(255, Math.max(0, Math.round(value)))
205
+ }
206
+
207
+ /**
208
+ * Packs RGB channels into a hex color.
209
+ * @param {{ red: number, green: number, blue: number }} channels RGB channels.
210
+ * @returns {number}
211
+ */
212
+ static #rgbToHex(channels) {
213
+ return (channels.red << 16) | (channels.green << 8) | channels.blue
214
+ }
215
+
216
+ /**
217
+ * Blends two integer RGB colors.
218
+ * @param {number} baseColor Base color.
219
+ * @param {number} overlayColor Overlay color.
220
+ * @param {number | undefined} overlayRatio Overlay ratio.
221
+ * @returns {number}
222
+ */
223
+ static #blendHexColor(baseColor, overlayColor, overlayRatio) {
224
+ const ratio = Math.min(Math.max(Number(overlayRatio || 0), 0), 1)
225
+ const inverse = 1 - ratio
226
+
227
+ return [16, 8, 0].reduce((output, shift) => {
228
+ const channel = Math.round(
229
+ ((baseColor >> shift) & 255) * inverse +
230
+ ((overlayColor >> shift) & 255) * ratio
231
+ )
232
+ return output | (channel << shift)
233
+ }, 0)
234
+ }
68
235
  }
@@ -30,8 +30,29 @@ export class PcbScene3dOutlineBuilder {
30
30
  y: firstPoint.y
31
31
  }
32
32
  ]
33
+ let currentPoint = firstPoint
33
34
 
34
35
  for (const segment of segments) {
36
+ const startPoint = PcbScene3dOutlineBuilder.#segmentStartPoint(
37
+ segment,
38
+ centerX,
39
+ centerY
40
+ )
41
+ if (
42
+ startPoint &&
43
+ PcbScene3dOutlineBuilder.#distanceBetween(
44
+ currentPoint,
45
+ startPoint
46
+ ) >= PcbScene3dOutlineBuilder.#DEGENERATE_LINE_EPSILON
47
+ ) {
48
+ commands.push({
49
+ type: 'move',
50
+ x: startPoint.x,
51
+ y: startPoint.y
52
+ })
53
+ currentPoint = startPoint
54
+ }
55
+
35
56
  if (segment.type === 'arc') {
36
57
  const arcCommand = PcbScene3dOutlineBuilder.#buildArcCommand(
37
58
  segment,
@@ -40,6 +61,8 @@ export class PcbScene3dOutlineBuilder {
40
61
  )
41
62
  if (arcCommand) {
42
63
  commands.push(arcCommand)
64
+ currentPoint =
65
+ PcbScene3dOutlineBuilder.#commandEndPoint(arcCommand)
43
66
  }
44
67
  continue
45
68
  }
@@ -51,12 +74,43 @@ export class PcbScene3dOutlineBuilder {
51
74
  )
52
75
  if (lineCommand) {
53
76
  commands.push(lineCommand)
77
+ currentPoint =
78
+ PcbScene3dOutlineBuilder.#commandEndPoint(lineCommand)
54
79
  }
55
80
  }
56
81
 
57
82
  return commands
58
83
  }
59
84
 
85
+ /**
86
+ * Converts one segment start into local centered scene coordinates.
87
+ * @param {Record<string, number | string>} segment Source segment.
88
+ * @param {number} centerX Board center X.
89
+ * @param {number} centerY Board center Y.
90
+ * @returns {{ x: number, y: number } | null}
91
+ */
92
+ static #segmentStartPoint(segment, centerX, centerY) {
93
+ const x = Number(segment?.x1)
94
+ const y = Number(segment?.y1)
95
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
96
+ return null
97
+ }
98
+
99
+ return PcbScene3dOutlineBuilder.#toLocalPoint(x, y, centerX, centerY)
100
+ }
101
+
102
+ /**
103
+ * Resolves one generated command's endpoint.
104
+ * @param {Record<string, number | boolean>} command Outline command.
105
+ * @returns {{ x: number, y: number }}
106
+ */
107
+ static #commandEndPoint(command) {
108
+ return {
109
+ x: Number(command.endX ?? command.x ?? 0),
110
+ y: Number(command.endY ?? command.y ?? 0)
111
+ }
112
+ }
113
+
60
114
  /**
61
115
  * Converts one outline line segment into a local line command.
62
116
  * @param {Record<string, number | string>} segment
@@ -367,7 +367,7 @@ export class PcbScene3dRuntime {
367
367
  -board.thicknessMil / 2,
368
368
  (x, y) => this.#normalizeDetailPoint(x, y),
369
369
  {
370
- enabled: Boolean(this.#sceneDescription.boardAssemblyModel),
370
+ enabled: true,
371
371
  board
372
372
  }
373
373
  )
@@ -8,9 +8,10 @@ export class PcbScene3dShellRenderer {
8
8
  * Renders the interactive 3D scene shell.
9
9
  * @param {{ pcb?: { boardOutline: { widthMil: number, heightMil: number }, components: { designator: string }[] }, bom: { quantity: number }[] }} documentModel
10
10
  * @param {((key: string) => string) | null} [translate] Translation lookup.
11
+ * @param {{ initialToggles?: { 'external-models'?: boolean, 'fallback-bodies'?: boolean, copper?: boolean } }} [options] Rendering options.
11
12
  * @returns {string}
12
13
  */
13
- static render(documentModel, translate = null) {
14
+ static render(documentModel, translate = null, options = {}) {
14
15
  const t = PcbScene3dText.createTranslator(translate)
15
16
  const pcb = documentModel?.pcb
16
17
  if (!pcb) {
@@ -71,13 +72,27 @@ export class PcbScene3dShellRenderer {
71
72
  '<aside class="scene-3d__controls" aria-label="' +
72
73
  PcbScene3dShellRenderer.#escapeHtml(t('scene3d.controlsAria')) +
73
74
  '">' +
74
- '<label class="scene-3d__toggle"><input type="checkbox" checked data-scene-3d-toggle="external-models" />' +
75
+ '<label class="scene-3d__toggle"><input type="checkbox"' +
76
+ PcbScene3dShellRenderer.#checkedAttribute(
77
+ options,
78
+ 'external-models',
79
+ true
80
+ ) +
81
+ ' data-scene-3d-toggle="external-models" />' +
75
82
  PcbScene3dShellRenderer.#escapeHtml(t('scene3d.externalModels')) +
76
83
  '</label>' +
77
- '<label class="scene-3d__toggle"><input type="checkbox" data-scene-3d-toggle="fallback-bodies" />' +
84
+ '<label class="scene-3d__toggle"><input type="checkbox"' +
85
+ PcbScene3dShellRenderer.#checkedAttribute(
86
+ options,
87
+ 'fallback-bodies',
88
+ false
89
+ ) +
90
+ ' data-scene-3d-toggle="fallback-bodies" />' +
78
91
  PcbScene3dShellRenderer.#escapeHtml(t('scene3d.fallbackBodies')) +
79
92
  '</label>' +
80
- '<label class="scene-3d__toggle"><input type="checkbox" checked data-scene-3d-toggle="copper" />' +
93
+ '<label class="scene-3d__toggle"><input type="checkbox"' +
94
+ PcbScene3dShellRenderer.#checkedAttribute(options, 'copper', true) +
95
+ ' data-scene-3d-toggle="copper" />' +
81
96
  PcbScene3dShellRenderer.#escapeHtml(t('scene3d.copperDetail')) +
82
97
  '</label>' +
83
98
  '<section class="scene-3d__selection" aria-live="polite"><h4 class="scene-3d__selection-title">' +
@@ -114,6 +129,25 @@ export class PcbScene3dShellRenderer {
114
129
  )
115
130
  }
116
131
 
132
+ /**
133
+ * Returns the checkbox checked attribute for one scene toggle.
134
+ * @param {{ initialToggles?: Record<string, boolean> }} options Renderer options.
135
+ * @param {string} toggleName Toggle identifier.
136
+ * @param {boolean} defaultChecked Package default state.
137
+ * @returns {string}
138
+ */
139
+ static #checkedAttribute(options, toggleName, defaultChecked) {
140
+ const initialToggles = options?.initialToggles || {}
141
+ const checked = Object.prototype.hasOwnProperty.call(
142
+ initialToggles,
143
+ toggleName
144
+ )
145
+ ? initialToggles[toggleName] === true
146
+ : defaultChecked
147
+
148
+ return checked ? ' checked' : ''
149
+ }
150
+
117
151
  /**
118
152
  * Escapes markup text.
119
153
  * @param {string} value Raw text.