pcb-scene3d-viewer 1.1.21 → 1.1.31

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 (55) hide show
  1. package/README.md +3 -2
  2. package/docs/api.md +27 -1
  3. package/docs/circuitjson.md +63 -18
  4. package/docs/model-format.md +13 -2
  5. package/package.json +1 -1
  6. package/spec/library-scope.md +2 -2
  7. package/src/PcbAssemblyBoardSubstrateBuilder.mjs +25 -5
  8. package/src/PcbAssemblyComponentMeshBuilder.mjs +762 -0
  9. package/src/PcbAssemblyFillGeometryResolver.mjs +579 -0
  10. package/src/PcbAssemblyFillRingNormalizer.mjs +209 -0
  11. package/src/PcbAssemblyGeometryBuilder.mjs +41 -301
  12. package/src/PcbAssemblyGltfModelMeshParser.mjs +912 -0
  13. package/src/PcbAssemblyGltfValidator.mjs +460 -0
  14. package/src/PcbAssemblyGltfWriter.mjs +801 -0
  15. package/src/PcbAssemblyMeshUtils.mjs +117 -0
  16. package/src/PcbAssemblyModelMeshLoader.mjs +18 -0
  17. package/src/PcbAssemblyPadMeshBuilder.mjs +394 -0
  18. package/src/PcbAssemblyStepWriter.mjs +24 -2
  19. package/src/PcbAssemblyTextModelMeshParser.mjs +602 -0
  20. package/src/PcbModelArchiveExporter.mjs +521 -7
  21. package/src/PcbScene3dCircuitJsonAdapter.mjs +409 -7
  22. package/src/PcbScene3dCircuitJsonModelTransform.mjs +232 -0
  23. package/src/PcbScene3dCompanionBasePlacementAdjuster.mjs +242 -0
  24. package/src/PcbScene3dComponentVisibility.mjs +100 -5
  25. package/src/PcbScene3dController.mjs +25 -55
  26. package/src/PcbScene3dCopperDetailFilter.mjs +86 -9
  27. package/src/PcbScene3dCopperDetailGroupBuilder.mjs +186 -15
  28. package/src/PcbScene3dCopperDrillCutoutBuilder.mjs +258 -0
  29. package/src/PcbScene3dCopperFactory.mjs +99 -85
  30. package/src/PcbScene3dCopperFillMeshBuilder.mjs +393 -0
  31. package/src/PcbScene3dCopperLayerFilter.mjs +32 -3
  32. package/src/PcbScene3dCutoutGeometryFilter.mjs +17 -14
  33. package/src/PcbScene3dExternalCompanionFallback.mjs +202 -0
  34. package/src/PcbScene3dExternalModelCenteringPolicy.mjs +21 -0
  35. package/src/PcbScene3dExternalModelOpacity.mjs +51 -0
  36. package/src/PcbScene3dExternalModelPlacementRepair.mjs +5 -2
  37. package/src/PcbScene3dExternalModelSourceOriginPolicy.mjs +41 -0
  38. package/src/PcbScene3dExternalModels.mjs +16 -7
  39. package/src/PcbScene3dFallbackBodyFactory.mjs +105 -0
  40. package/src/PcbScene3dFallbackVisibility.mjs +58 -1
  41. package/src/PcbScene3dMaskCoveredCopperSideGroupBuilder.mjs +68 -0
  42. package/src/PcbScene3dPadFactory.mjs +35 -2
  43. package/src/PcbScene3dRenderGroupVisibility.mjs +8 -1
  44. package/src/PcbScene3dRuntime.mjs +94 -100
  45. package/src/PcbScene3dRuntimeHelpers.mjs +39 -0
  46. package/src/PcbScene3dSelectionIndexBuilder.mjs +87 -0
  47. package/src/PcbScene3dSelectionInspectorRenderer.mjs +50 -11
  48. package/src/PcbScene3dSelectionMarkerFactory.mjs +333 -0
  49. package/src/PcbScene3dSelectionMarkerOverlay.mjs +70 -0
  50. package/src/PcbScene3dSelectionStyler.mjs +52 -2
  51. package/src/PcbScene3dStaticBodyFactory.mjs +263 -0
  52. package/src/PcbScene3dText.mjs +1 -0
  53. package/src/PcbScene3dTransparentMeshSplitter.mjs +623 -0
  54. package/src/PcbScene3dViaFactory.mjs +27 -7
  55. package/src/scene3d.mjs +3 -0
@@ -0,0 +1,333 @@
1
+ /**
2
+ * Builds flat board-surface markers for the currently selected component.
3
+ */
4
+ export class PcbScene3dSelectionMarkerFactory {
5
+ static #MARKER_PADDING_MIL = 8
6
+ static #MARKER_Z_OFFSET_MIL = 8
7
+ static #MIN_MARKER_SPAN_MIL = 24
8
+
9
+ /**
10
+ * Builds one selected-component outline marker.
11
+ * @param {any} THREE Three.js namespace.
12
+ * @param {object} sceneDescription Normalized scene description.
13
+ * @param {string} designator Selected component designator.
14
+ * @param {(x: number, y: number) => { x: number, y: number }} normalizePoint Board/detail coordinate normalizer.
15
+ * @param {{ color?: number }} [options] Marker options.
16
+ * @returns {any | null}
17
+ */
18
+ static build(
19
+ THREE,
20
+ sceneDescription,
21
+ designator,
22
+ normalizePoint,
23
+ options = {}
24
+ ) {
25
+ if (!THREE || typeof normalizePoint !== 'function') {
26
+ return null
27
+ }
28
+
29
+ const component =
30
+ PcbScene3dSelectionMarkerFactory.#componentByDesignator(
31
+ sceneDescription,
32
+ designator
33
+ )
34
+ if (!component) {
35
+ return null
36
+ }
37
+
38
+ const bounds =
39
+ PcbScene3dSelectionMarkerFactory.#boundsFromPads(
40
+ PcbScene3dSelectionMarkerFactory.#componentPads(
41
+ sceneDescription,
42
+ component
43
+ )
44
+ ) ||
45
+ PcbScene3dSelectionMarkerFactory.#boundsFromComponentBody(
46
+ component,
47
+ sceneDescription?.board
48
+ )
49
+ const paddedBounds =
50
+ PcbScene3dSelectionMarkerFactory.#expandBounds(bounds)
51
+ if (!paddedBounds) {
52
+ return null
53
+ }
54
+
55
+ const z = PcbScene3dSelectionMarkerFactory.#markerZ(
56
+ sceneDescription?.board,
57
+ component
58
+ )
59
+ const points = PcbScene3dSelectionMarkerFactory.#markerPoints(
60
+ paddedBounds,
61
+ z,
62
+ normalizePoint
63
+ )
64
+ const geometry = new THREE.BufferGeometry()
65
+ geometry.setAttribute(
66
+ 'position',
67
+ new THREE.Float32BufferAttribute(points.flat(), 3)
68
+ )
69
+ const material = new THREE.LineBasicMaterial({
70
+ color: Number(options?.color || 0x14c5e6),
71
+ transparent: true,
72
+ opacity: 1,
73
+ depthTest: false,
74
+ depthWrite: false
75
+ })
76
+ const line = new THREE.LineLoop(geometry, material)
77
+ line.renderOrder = 1000
78
+
79
+ const root = new THREE.Group()
80
+ root.userData.scene3dSelectionMarker = {
81
+ designator: String(component.designator || designator)
82
+ }
83
+ root.add(line)
84
+ return root
85
+ }
86
+
87
+ /**
88
+ * Finds one component by designator.
89
+ * @param {object} sceneDescription Normalized scene description.
90
+ * @param {string} designator Component designator.
91
+ * @returns {object | null}
92
+ */
93
+ static #componentByDesignator(sceneDescription, designator) {
94
+ const selectedDesignator = String(designator || '').trim()
95
+ if (!selectedDesignator) {
96
+ return null
97
+ }
98
+
99
+ return (
100
+ (Array.isArray(sceneDescription?.components)
101
+ ? sceneDescription.components
102
+ : []
103
+ ).find(
104
+ (component) =>
105
+ String(component?.designator || '').trim() ===
106
+ selectedDesignator
107
+ ) || null
108
+ )
109
+ }
110
+
111
+ /**
112
+ * Resolves pads owned by one scene component.
113
+ * @param {object} sceneDescription Normalized scene description.
114
+ * @param {{ componentIndex?: number | null }} component Scene component.
115
+ * @returns {object[]}
116
+ */
117
+ static #componentPads(sceneDescription, component) {
118
+ const componentIndex = Number(component?.componentIndex)
119
+ if (!Number.isFinite(componentIndex)) {
120
+ return []
121
+ }
122
+
123
+ return (
124
+ Array.isArray(sceneDescription?.detail?.pads)
125
+ ? sceneDescription.detail.pads
126
+ : []
127
+ ).filter((pad) => Number(pad?.componentIndex) === componentIndex)
128
+ }
129
+
130
+ /**
131
+ * Resolves an axis-aligned board-space bounds box from owned pad surfaces.
132
+ * @param {object[]} pads Pad records.
133
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number } | null}
134
+ */
135
+ static #boundsFromPads(pads) {
136
+ const points = []
137
+ ;(pads || []).forEach((pad) => {
138
+ points.push(...PcbScene3dSelectionMarkerFactory.#padCorners(pad))
139
+ })
140
+
141
+ return PcbScene3dSelectionMarkerFactory.#boundsFromPoints(points)
142
+ }
143
+
144
+ /**
145
+ * Resolves conservative board-space corners for one pad.
146
+ * @param {object} pad Pad record.
147
+ * @returns {{ x: number, y: number }[]}
148
+ */
149
+ static #padCorners(pad) {
150
+ const x = Number(pad?.x)
151
+ const y = Number(pad?.y)
152
+ const width = PcbScene3dSelectionMarkerFactory.#maxPositive([
153
+ pad?.sizeTopX,
154
+ pad?.sizeMidX,
155
+ pad?.sizeBottomX,
156
+ pad?.holeDiameter
157
+ ])
158
+ const height = PcbScene3dSelectionMarkerFactory.#maxPositive([
159
+ pad?.sizeTopY,
160
+ pad?.sizeMidY,
161
+ pad?.sizeBottomY,
162
+ pad?.holeDiameter
163
+ ])
164
+ if (
165
+ !Number.isFinite(x) ||
166
+ !Number.isFinite(y) ||
167
+ !(width > 0) ||
168
+ !(height > 0)
169
+ ) {
170
+ return []
171
+ }
172
+
173
+ const offsetX = Number(pad?.offsetTopX ?? pad?.offsetBottomX ?? 0) || 0
174
+ const offsetY = Number(pad?.offsetTopY ?? pad?.offsetBottomY ?? 0) || 0
175
+ const angle = (Number(pad?.rotation || 0) * Math.PI) / 180
176
+ const cos = Math.cos(angle)
177
+ const sin = Math.sin(angle)
178
+
179
+ return [
180
+ { x: -width / 2 + offsetX, y: -height / 2 + offsetY },
181
+ { x: width / 2 + offsetX, y: -height / 2 + offsetY },
182
+ { x: width / 2 + offsetX, y: height / 2 + offsetY },
183
+ { x: -width / 2 + offsetX, y: height / 2 + offsetY }
184
+ ].map((corner) => ({
185
+ x: x + corner.x * cos - corner.y * sin,
186
+ y: y + corner.x * sin + corner.y * cos
187
+ }))
188
+ }
189
+
190
+ /**
191
+ * Resolves an axis-aligned board-space bounds box from component body data.
192
+ * @param {object} component Scene component.
193
+ * @param {object | undefined} board Board metadata.
194
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number } | null}
195
+ */
196
+ static #boundsFromComponentBody(component, board) {
197
+ const centerX =
198
+ Number(component?.boardPositionMil?.x) ||
199
+ Number(component?.positionMil?.x || 0) + Number(board?.centerX || 0)
200
+ const centerY =
201
+ Number(component?.boardPositionMil?.y) ||
202
+ Number(component?.positionMil?.y || 0) + Number(board?.centerY || 0)
203
+ const width = Number(component?.body?.sizeMil?.width || 0)
204
+ const depth = Number(component?.body?.sizeMil?.depth || 0)
205
+ if (
206
+ !Number.isFinite(centerX) ||
207
+ !Number.isFinite(centerY) ||
208
+ !(width > 0) ||
209
+ !(depth > 0)
210
+ ) {
211
+ return null
212
+ }
213
+
214
+ const angle = (Number(component?.rotationDeg || 0) * Math.PI) / 180
215
+ const cos = Math.cos(angle)
216
+ const sin = Math.sin(angle)
217
+ return PcbScene3dSelectionMarkerFactory.#boundsFromPoints(
218
+ [
219
+ { x: -width / 2, y: -depth / 2 },
220
+ { x: width / 2, y: -depth / 2 },
221
+ { x: width / 2, y: depth / 2 },
222
+ { x: -width / 2, y: depth / 2 }
223
+ ].map((corner) => ({
224
+ x: centerX + corner.x * cos - corner.y * sin,
225
+ y: centerY + corner.x * sin + corner.y * cos
226
+ }))
227
+ )
228
+ }
229
+
230
+ /**
231
+ * Resolves a bounds box from finite points.
232
+ * @param {{ x: number, y: number }[]} points Candidate points.
233
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number } | null}
234
+ */
235
+ static #boundsFromPoints(points) {
236
+ const finitePoints = (points || []).filter(
237
+ (point) => Number.isFinite(point.x) && Number.isFinite(point.y)
238
+ )
239
+ if (!finitePoints.length) {
240
+ return null
241
+ }
242
+
243
+ const xs = finitePoints.map((point) => point.x)
244
+ const ys = finitePoints.map((point) => point.y)
245
+ return {
246
+ minX: Math.min(...xs),
247
+ minY: Math.min(...ys),
248
+ maxX: Math.max(...xs),
249
+ maxY: Math.max(...ys)
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Adds marker padding and a minimum clickable/visible span.
255
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number } | null} bounds Bounds.
256
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number } | null}
257
+ */
258
+ static #expandBounds(bounds) {
259
+ if (!bounds) {
260
+ return null
261
+ }
262
+
263
+ const centerX = (bounds.minX + bounds.maxX) / 2
264
+ const centerY = (bounds.minY + bounds.maxY) / 2
265
+ const width = Math.max(
266
+ bounds.maxX -
267
+ bounds.minX +
268
+ PcbScene3dSelectionMarkerFactory.#MARKER_PADDING_MIL * 2,
269
+ PcbScene3dSelectionMarkerFactory.#MIN_MARKER_SPAN_MIL
270
+ )
271
+ const height = Math.max(
272
+ bounds.maxY -
273
+ bounds.minY +
274
+ PcbScene3dSelectionMarkerFactory.#MARKER_PADDING_MIL * 2,
275
+ PcbScene3dSelectionMarkerFactory.#MIN_MARKER_SPAN_MIL
276
+ )
277
+
278
+ return {
279
+ minX: centerX - width / 2,
280
+ minY: centerY - height / 2,
281
+ maxX: centerX + width / 2,
282
+ maxY: centerY + height / 2
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Builds normalized marker line points.
288
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number }} bounds Board-space bounds.
289
+ * @param {number} z Marker z position.
290
+ * @param {(x: number, y: number) => { x: number, y: number }} normalizePoint Coordinate normalizer.
291
+ * @returns {number[][]}
292
+ */
293
+ static #markerPoints(bounds, z, normalizePoint) {
294
+ return [
295
+ [bounds.minX, bounds.minY],
296
+ [bounds.maxX, bounds.minY],
297
+ [bounds.maxX, bounds.maxY],
298
+ [bounds.minX, bounds.maxY]
299
+ ].map(([x, y]) => {
300
+ const point = normalizePoint(x, y)
301
+ return [point.x, point.y, z]
302
+ })
303
+ }
304
+
305
+ /**
306
+ * Resolves marker z above the mounted board face.
307
+ * @param {{ thicknessMil?: number } | undefined} board Board metadata.
308
+ * @param {{ mountSide?: string }} component Scene component.
309
+ * @returns {number}
310
+ */
311
+ static #markerZ(board, component) {
312
+ const topZ =
313
+ Number(board?.thicknessMil || 0) / 2 +
314
+ PcbScene3dSelectionMarkerFactory.#MARKER_Z_OFFSET_MIL
315
+ return String(component?.mountSide || 'top').toLowerCase() === 'bottom'
316
+ ? -topZ
317
+ : topZ
318
+ }
319
+
320
+ /**
321
+ * Resolves the maximum positive finite value from a candidate list.
322
+ * @param {unknown[]} values Candidate values.
323
+ * @returns {number}
324
+ */
325
+ static #maxPositive(values) {
326
+ return Math.max(
327
+ 0,
328
+ ...(values || [])
329
+ .map((value) => Number(value))
330
+ .filter((value) => Number.isFinite(value) && value > 0)
331
+ )
332
+ }
333
+ }
@@ -0,0 +1,70 @@
1
+ import { PcbScene3dSelectionMarkerFactory } from './PcbScene3dSelectionMarkerFactory.mjs'
2
+
3
+ /**
4
+ * Owns the render group used for the current selected-component marker.
5
+ */
6
+ export class PcbScene3dSelectionMarkerOverlay {
7
+ #markerGroup
8
+
9
+ /**
10
+ * @param {any} markerGroup Three group that hosts selection marker meshes.
11
+ */
12
+ constructor(markerGroup) {
13
+ this.#markerGroup = markerGroup
14
+ }
15
+
16
+ /**
17
+ * Clears and rebuilds the selected-component marker.
18
+ * @param {any} THREE Three.js namespace.
19
+ * @param {object | null} sceneDescription Normalized scene description.
20
+ * @param {string} selectedDesignator Selected component designator.
21
+ * @param {boolean} selectedComponentHidden Whether the component is hidden.
22
+ * @param {(x: number, y: number) => { x: number, y: number }} normalizePoint Board/detail coordinate normalizer.
23
+ * @param {{ color?: number }} [options] Marker options.
24
+ * @returns {void}
25
+ */
26
+ update(
27
+ THREE,
28
+ sceneDescription,
29
+ selectedDesignator,
30
+ selectedComponentHidden,
31
+ normalizePoint,
32
+ options = {}
33
+ ) {
34
+ if (!this.#markerGroup) {
35
+ return
36
+ }
37
+
38
+ PcbScene3dSelectionMarkerOverlay.#clearGroup(this.#markerGroup)
39
+ if (!THREE || !selectedDesignator || selectedComponentHidden) {
40
+ return
41
+ }
42
+
43
+ const marker = PcbScene3dSelectionMarkerFactory.build(
44
+ THREE,
45
+ sceneDescription,
46
+ selectedDesignator,
47
+ normalizePoint,
48
+ options
49
+ )
50
+ if (marker) {
51
+ this.#markerGroup.add(marker)
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Clears a Three group while keeping tests compatible with tiny fakes.
57
+ * @param {any} group Render group.
58
+ * @returns {void}
59
+ */
60
+ static #clearGroup(group) {
61
+ if (typeof group?.clear === 'function') {
62
+ group.clear()
63
+ return
64
+ }
65
+
66
+ if (Array.isArray(group?.children)) {
67
+ group.children.length = 0
68
+ }
69
+ }
70
+ }
@@ -138,7 +138,12 @@ export class PcbScene3dSelectionStyler {
138
138
  if (PcbScene3dSelectionStyler.#canWriteColor(material.color)) {
139
139
  PcbScene3dSelectionStyler.#writeColor(
140
140
  material.color,
141
- highlighted ? 0x000000 : baseState.colorHex
141
+ highlighted
142
+ ? PcbScene3dSelectionStyler.#highlightedColorHex(
143
+ material,
144
+ baseState
145
+ )
146
+ : baseState.colorHex
142
147
  )
143
148
  }
144
149
  PcbScene3dSelectionStyler.#writeColor(
@@ -146,7 +151,9 @@ export class PcbScene3dSelectionStyler {
146
151
  highlighted ? highlightColor : baseState.emissiveHex
147
152
  )
148
153
  material.emissiveIntensity = highlighted
149
- ? 1
154
+ ? PcbScene3dSelectionStyler.#highlightedEmissiveIntensity(
155
+ material
156
+ )
150
157
  : baseState.emissiveIntensity
151
158
  material.needsUpdate = true
152
159
  return
@@ -184,6 +191,49 @@ export class PcbScene3dSelectionStyler {
184
191
  return material.userData.scene3dHighlightBase
185
192
  }
186
193
 
194
+ /**
195
+ * Resolves the selected-state base color for one material.
196
+ * @param {any} material Material to highlight.
197
+ * @param {{ colorHex: number | null }} baseState Stored base state.
198
+ * @returns {number | null}
199
+ */
200
+ static #highlightedColorHex(material, baseState) {
201
+ if (PcbScene3dSelectionStyler.#isTransparentMaterial(material)) {
202
+ return baseState.colorHex
203
+ }
204
+
205
+ return 0x000000
206
+ }
207
+
208
+ /**
209
+ * Returns true when selection should preserve the source color for stacking.
210
+ * @param {any} material Material to inspect.
211
+ * @returns {boolean}
212
+ */
213
+ static #isTransparentMaterial(material) {
214
+ return (
215
+ material?.transparent === true && Number(material?.opacity ?? 1) < 1
216
+ )
217
+ }
218
+
219
+ /**
220
+ * Resolves the selected-state emissive strength for one material.
221
+ * @param {any} material Material to highlight.
222
+ * @returns {number}
223
+ */
224
+ static #highlightedEmissiveIntensity(material) {
225
+ if (!PcbScene3dSelectionStyler.#isTransparentMaterial(material)) {
226
+ return 1
227
+ }
228
+
229
+ const opacity = Number(material?.opacity)
230
+ if (!Number.isFinite(opacity)) {
231
+ return 1
232
+ }
233
+
234
+ return Math.max(0, Math.min(1, opacity))
235
+ }
236
+
187
237
  /**
188
238
  * Normalizes a Three material field into an array.
189
239
  * @param {any} materials