pcb-scene3d-viewer 1.0.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.
Files changed (65) hide show
  1. package/AGENTS.md +67 -0
  2. package/COMMERCIAL-LICENSE.md +15 -0
  3. package/CONTRIBUTING.md +14 -0
  4. package/LICENSE +19 -0
  5. package/LICENSES/AGPL-3.0-or-later.txt +235 -0
  6. package/LICENSES/CC-BY-SA-4.0.txt +170 -0
  7. package/LICENSES/LGPL-2.1-or-later.txt +176 -0
  8. package/LICENSES/LicenseRef-PolyForm-Noncommercial-1.0.0.txt +131 -0
  9. package/NOTICE.md +36 -0
  10. package/README.md +128 -0
  11. package/REUSE.toml +16 -0
  12. package/docs/api.md +148 -0
  13. package/docs/circuitjson.md +190 -0
  14. package/docs/model-format.md +117 -0
  15. package/docs/testing.md +23 -0
  16. package/package.json +65 -0
  17. package/spec/library-scope.md +36 -0
  18. package/src/PcbModelArchiveExporter.mjs +320 -0
  19. package/src/PcbScene3dArcUtils.mjs +27 -0
  20. package/src/PcbScene3dBoardAssemblyPlacement.mjs +36 -0
  21. package/src/PcbScene3dBoardAssemblyPresentation.mjs +859 -0
  22. package/src/PcbScene3dBoardEdgeCutoutBuilder.mjs +537 -0
  23. package/src/PcbScene3dBoardMaterialPalette.mjs +40 -0
  24. package/src/PcbScene3dBoardShapeFactory.mjs +895 -0
  25. package/src/PcbScene3dBoardSolderMaskFactory.mjs +613 -0
  26. package/src/PcbScene3dCameraRig.mjs +168 -0
  27. package/src/PcbScene3dCircuitJsonAdapter.mjs +545 -0
  28. package/src/PcbScene3dController.mjs +956 -0
  29. package/src/PcbScene3dCopperDetailFilter.mjs +490 -0
  30. package/src/PcbScene3dCopperFactory.mjs +559 -0
  31. package/src/PcbScene3dCopperTextFactory.mjs +534 -0
  32. package/src/PcbScene3dCutoutGeometryFilter.mjs +873 -0
  33. package/src/PcbScene3dDetailCoordinateNormalizer.mjs +65 -0
  34. package/src/PcbScene3dDrillCutoutFilter.mjs +224 -0
  35. package/src/PcbScene3dDrillPathFactory.mjs +362 -0
  36. package/src/PcbScene3dDrillVoidFactory.mjs +268 -0
  37. package/src/PcbScene3dExternalModelLoadOrder.mjs +54 -0
  38. package/src/PcbScene3dExternalModels.mjs +968 -0
  39. package/src/PcbScene3dFallbackVisibility.mjs +82 -0
  40. package/src/PcbScene3dInteractionHints.mjs +56 -0
  41. package/src/PcbScene3dMountRig.mjs +53 -0
  42. package/src/PcbScene3dOutlineBuilder.mjs +210 -0
  43. package/src/PcbScene3dPadFactory.mjs +553 -0
  44. package/src/PcbScene3dPresetState.mjs +48 -0
  45. package/src/PcbScene3dRenderGroupVisibility.mjs +134 -0
  46. package/src/PcbScene3dRuntime.mjs +996 -0
  47. package/src/PcbScene3dRuntimeBoardMeshes.mjs +99 -0
  48. package/src/PcbScene3dSelectionStyler.mjs +252 -0
  49. package/src/PcbScene3dShapePathFactory.mjs +220 -0
  50. package/src/PcbScene3dShellRenderer.mjs +131 -0
  51. package/src/PcbScene3dSilkscreenFactory.mjs +854 -0
  52. package/src/PcbScene3dSilkscreenStrokeWidthResolver.mjs +81 -0
  53. package/src/PcbScene3dStepLoader.mjs +611 -0
  54. package/src/PcbScene3dStrokeFont.mjs +671 -0
  55. package/src/PcbScene3dStrokeGeometryBuilder.mjs +322 -0
  56. package/src/PcbScene3dText.mjs +99 -0
  57. package/src/PcbScene3dTrueTypeTextFactory.mjs +885 -0
  58. package/src/PcbScene3dViaFactory.mjs +176 -0
  59. package/src/PcbScene3dViewCompensation.mjs +109 -0
  60. package/src/PcbScene3dViewScale.mjs +24 -0
  61. package/src/PcbScene3dViewportResize.mjs +35 -0
  62. package/src/PcbScene3dWorkerClient.mjs +123 -0
  63. package/src/index.mjs +1 -0
  64. package/src/scene3d.mjs +44 -0
  65. package/src/styles/scene3d.css +295 -0
@@ -0,0 +1,99 @@
1
+ import { PcbScene3dBoardMaterialPalette } from './PcbScene3dBoardMaterialPalette.mjs'
2
+ import { PcbScene3dBoardShapeFactory } from './PcbScene3dBoardShapeFactory.mjs'
3
+
4
+ /**
5
+ * Builds generated board meshes used by the interactive 3D runtime.
6
+ */
7
+ export class PcbScene3dRuntimeBoardMeshes {
8
+ /**
9
+ * Builds the extruded board body mesh.
10
+ * @param {any} THREE Three.js namespace.
11
+ * @param {{ board?: any, detail?: any, boardAssemblyModel?: any }} sceneDescription Scene metadata.
12
+ * @param {(x: number, y: number) => { x: number, y: number }} normalizeDetailPoint Detail coordinate normalizer.
13
+ * @returns {any}
14
+ */
15
+ static buildBoardMesh(THREE, sceneDescription, normalizeDetailPoint) {
16
+ const board = sceneDescription.board
17
+ const geometry = PcbScene3dBoardShapeFactory.buildGeometry(
18
+ THREE,
19
+ board,
20
+ sceneDescription.detail,
21
+ normalizeDetailPoint
22
+ )
23
+ const hasBoardAssemblyModel = Boolean(
24
+ sceneDescription.boardAssemblyModel
25
+ )
26
+ const generatedBodyVisible =
27
+ PcbScene3dBoardMaterialPalette.isGeneratedBodyVisible({
28
+ hasBoardAssemblyModel
29
+ })
30
+ const materialOptions = {
31
+ roughness: 0.68,
32
+ metalness: 0.08,
33
+ visible: generatedBodyVisible
34
+ }
35
+ const edgeColor = Number(board.edgeColor)
36
+ const resolvedEdgeColor = Number.isInteger(edgeColor)
37
+ ? edgeColor
38
+ : 0xc9ca78
39
+ const surfaceColor = hasBoardAssemblyModel
40
+ ? resolvedEdgeColor
41
+ : PcbScene3dBoardMaterialPalette.resolveSurfaceColor(board, {
42
+ hasBoardAssemblyModel
43
+ })
44
+
45
+ return new THREE.Mesh(geometry, [
46
+ new THREE.MeshStandardMaterial({
47
+ ...materialOptions,
48
+ color: surfaceColor,
49
+ side: THREE.FrontSide
50
+ }),
51
+ new THREE.MeshStandardMaterial({
52
+ ...materialOptions,
53
+ color: resolvedEdgeColor,
54
+ side: THREE.DoubleSide
55
+ }),
56
+ new THREE.MeshStandardMaterial({
57
+ color: 0xd9a61d,
58
+ roughness: 0.38,
59
+ metalness: 0.55,
60
+ visible: generatedBodyVisible,
61
+ side: THREE.DoubleSide
62
+ })
63
+ ])
64
+ }
65
+
66
+ /**
67
+ * Builds a line outline around the board edge.
68
+ * @param {any} THREE Three.js namespace.
69
+ * @param {{ board?: any, detail?: any }} sceneDescription Scene metadata.
70
+ * @param {(x: number, y: number) => { x: number, y: number }} normalizeDetailPoint Detail coordinate normalizer.
71
+ * @returns {any}
72
+ */
73
+ static buildBoardOutline(THREE, sceneDescription, normalizeDetailPoint) {
74
+ const shape = PcbScene3dBoardShapeFactory.buildShape(
75
+ THREE,
76
+ sceneDescription.board,
77
+ sceneDescription.detail,
78
+ normalizeDetailPoint
79
+ )
80
+ const points = shape.getPoints(120)
81
+ const positions = []
82
+ const topZ = sceneDescription.board.thicknessMil / 2 + 0.8
83
+
84
+ points.forEach((point) => {
85
+ positions.push(point.x, point.y, topZ)
86
+ })
87
+
88
+ const geometry = new THREE.BufferGeometry()
89
+ geometry.setAttribute(
90
+ 'position',
91
+ new THREE.Float32BufferAttribute(positions, 3)
92
+ )
93
+
94
+ return new THREE.LineLoop(
95
+ geometry,
96
+ new THREE.LineBasicMaterial({ color: 0xc9ca78, transparent: true })
97
+ )
98
+ }
99
+ }
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Applies shared highlight state to all rendered roots for one selected
3
+ * component designator.
4
+ */
5
+ export class PcbScene3dSelectionStyler {
6
+ /**
7
+ * Registers one rendered root object under a component designator.
8
+ * @param {Map<string, Set<any>>} selectionRoots
9
+ * @param {string} designator
10
+ * @param {any} rootObject
11
+ * @returns {void}
12
+ */
13
+ static registerSelectionRoot(selectionRoots, designator, rootObject) {
14
+ if (!(selectionRoots instanceof Map) || !rootObject) {
15
+ return
16
+ }
17
+
18
+ const normalizedDesignator = String(designator || '').trim()
19
+ if (!normalizedDesignator) {
20
+ return
21
+ }
22
+
23
+ if (!selectionRoots.has(normalizedDesignator)) {
24
+ selectionRoots.set(normalizedDesignator, new Set())
25
+ }
26
+
27
+ selectionRoots.get(normalizedDesignator)?.add(rootObject)
28
+ }
29
+
30
+ /**
31
+ * Applies the current highlighted designator, restoring the previous one.
32
+ * @param {Map<string, Set<any>>} selectionRoots
33
+ * @param {string} previousDesignator
34
+ * @param {string} nextDesignator
35
+ * @param {number} highlightColor
36
+ * @returns {void}
37
+ */
38
+ static applySelection(
39
+ selectionRoots,
40
+ previousDesignator,
41
+ nextDesignator,
42
+ highlightColor
43
+ ) {
44
+ PcbScene3dSelectionStyler.#setDesignatorHighlighted(
45
+ selectionRoots,
46
+ previousDesignator,
47
+ false,
48
+ highlightColor
49
+ )
50
+ PcbScene3dSelectionStyler.#setDesignatorHighlighted(
51
+ selectionRoots,
52
+ nextDesignator,
53
+ true,
54
+ highlightColor
55
+ )
56
+ }
57
+
58
+ /**
59
+ * Highlights or restores every root registered for one designator.
60
+ * @param {Map<string, Set<any>>} selectionRoots
61
+ * @param {string} designator
62
+ * @param {boolean} highlighted
63
+ * @param {number} highlightColor
64
+ * @returns {void}
65
+ */
66
+ static #setDesignatorHighlighted(
67
+ selectionRoots,
68
+ designator,
69
+ highlighted,
70
+ highlightColor
71
+ ) {
72
+ if (!(selectionRoots instanceof Map)) {
73
+ return
74
+ }
75
+
76
+ const normalizedDesignator = String(designator || '').trim()
77
+ if (!normalizedDesignator) {
78
+ return
79
+ }
80
+
81
+ const roots = selectionRoots.get(normalizedDesignator)
82
+ if (!roots) {
83
+ return
84
+ }
85
+
86
+ roots.forEach((rootObject) => {
87
+ PcbScene3dSelectionStyler.#visitMaterialNodes(
88
+ rootObject,
89
+ (material) =>
90
+ PcbScene3dSelectionStyler.#setMaterialHighlighted(
91
+ material,
92
+ highlighted,
93
+ highlightColor
94
+ )
95
+ )
96
+ })
97
+ }
98
+
99
+ /**
100
+ * Visits every material-bearing node in one rendered object tree.
101
+ * @param {any} rootObject
102
+ * @param {(material: any) => void} visitor
103
+ * @returns {void}
104
+ */
105
+ static #visitMaterialNodes(rootObject, visitor) {
106
+ if (!rootObject) {
107
+ return
108
+ }
109
+
110
+ PcbScene3dSelectionStyler.#normalizeMaterials(
111
+ rootObject.material
112
+ ).forEach((material) => visitor(material))
113
+
114
+ const children = Array.isArray(rootObject.children)
115
+ ? rootObject.children
116
+ : []
117
+ children.forEach((child) =>
118
+ PcbScene3dSelectionStyler.#visitMaterialNodes(child, visitor)
119
+ )
120
+ }
121
+
122
+ /**
123
+ * Applies or restores one material's highlight state.
124
+ * @param {any} material
125
+ * @param {boolean} highlighted
126
+ * @param {number} highlightColor
127
+ * @returns {void}
128
+ */
129
+ static #setMaterialHighlighted(material, highlighted, highlightColor) {
130
+ if (!material) {
131
+ return
132
+ }
133
+
134
+ const baseState =
135
+ PcbScene3dSelectionStyler.#ensureMaterialBaseState(material)
136
+
137
+ if (PcbScene3dSelectionStyler.#canWriteColor(material.emissive)) {
138
+ if (PcbScene3dSelectionStyler.#canWriteColor(material.color)) {
139
+ PcbScene3dSelectionStyler.#writeColor(
140
+ material.color,
141
+ highlighted ? 0x000000 : baseState.colorHex
142
+ )
143
+ }
144
+ PcbScene3dSelectionStyler.#writeColor(
145
+ material.emissive,
146
+ highlighted ? highlightColor : baseState.emissiveHex
147
+ )
148
+ material.emissiveIntensity = highlighted
149
+ ? 1
150
+ : baseState.emissiveIntensity
151
+ material.needsUpdate = true
152
+ return
153
+ }
154
+
155
+ if (PcbScene3dSelectionStyler.#canWriteColor(material.color)) {
156
+ PcbScene3dSelectionStyler.#writeColor(
157
+ material.color,
158
+ highlighted ? highlightColor : baseState.colorHex
159
+ )
160
+ material.needsUpdate = true
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Stores the original material state the first time a material is seen.
166
+ * @param {any} material
167
+ * @returns {{ colorHex: number | null, emissiveHex: number | null, emissiveIntensity: number }}
168
+ */
169
+ static #ensureMaterialBaseState(material) {
170
+ if (!material.userData) {
171
+ material.userData = {}
172
+ }
173
+
174
+ if (!material.userData.scene3dHighlightBase) {
175
+ material.userData.scene3dHighlightBase = {
176
+ colorHex: PcbScene3dSelectionStyler.#readColor(material.color),
177
+ emissiveHex: PcbScene3dSelectionStyler.#readColor(
178
+ material.emissive
179
+ ),
180
+ emissiveIntensity: Number(material.emissiveIntensity || 0)
181
+ }
182
+ }
183
+
184
+ return material.userData.scene3dHighlightBase
185
+ }
186
+
187
+ /**
188
+ * Normalizes a Three material field into an array.
189
+ * @param {any} materials
190
+ * @returns {any[]}
191
+ */
192
+ static #normalizeMaterials(materials) {
193
+ if (Array.isArray(materials)) {
194
+ return materials.filter(Boolean)
195
+ }
196
+
197
+ return materials ? [materials] : []
198
+ }
199
+
200
+ /**
201
+ * Reads one color-like object into a hex value.
202
+ * @param {any} color
203
+ * @returns {number | null}
204
+ */
205
+ static #readColor(color) {
206
+ if (!color) {
207
+ return null
208
+ }
209
+
210
+ if (typeof color.getHex === 'function') {
211
+ return color.getHex()
212
+ }
213
+
214
+ if (Number.isFinite(Number(color.hex))) {
215
+ return Number(color.hex)
216
+ }
217
+
218
+ return null
219
+ }
220
+
221
+ /**
222
+ * Writes one hex value into a color-like object.
223
+ * @param {any} color
224
+ * @param {number | null} hex
225
+ * @returns {void}
226
+ */
227
+ static #writeColor(color, hex) {
228
+ if (!color || !Number.isFinite(Number(hex))) {
229
+ return
230
+ }
231
+
232
+ if (typeof color.setHex === 'function') {
233
+ color.setHex(Number(hex))
234
+ return
235
+ }
236
+
237
+ if ('hex' in color) {
238
+ color.hex = Number(hex)
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Returns true when the color-like object supports hex writes.
244
+ * @param {any} color
245
+ * @returns {boolean}
246
+ */
247
+ static #canWriteColor(color) {
248
+ return Boolean(
249
+ color && (typeof color.setHex === 'function' || 'hex' in color)
250
+ )
251
+ }
252
+ }
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Builds Three shape paths from Altium shape-based region contours.
3
+ */
4
+ export class PcbScene3dShapePathFactory {
5
+ static #ARC_SEGMENT_DEGREES = 12
6
+ static #GEOMETRY_EPSILON = 0.001
7
+
8
+ /**
9
+ * Normalizes authored contour points while preserving arc metadata.
10
+ * @param {{ x?: number, y?: number, isArc?: boolean, centerX?: number, centerY?: number, radius?: number, startAngle?: number, endAngle?: number }[]} points
11
+ * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint
12
+ * @param {boolean} mirrorY
13
+ * @returns {{ x: number, y: number, isArc?: boolean, centerX?: number, centerY?: number, radius?: number, startAngle?: number, endAngle?: number, arcYDirection?: number }[]}
14
+ */
15
+ static normalizeShapePoints(points, normalizeBoardPoint, mirrorY) {
16
+ return (Array.isArray(points) ? points : [])
17
+ .map((point) =>
18
+ PcbScene3dShapePathFactory.#normalizeShapePoint(
19
+ point,
20
+ normalizeBoardPoint,
21
+ mirrorY
22
+ )
23
+ )
24
+ .filter(
25
+ (point) => Number.isFinite(point.x) && Number.isFinite(point.y)
26
+ )
27
+ }
28
+
29
+ /**
30
+ * Builds one filled shape from normalized contour points.
31
+ * @param {any} THREE
32
+ * @param {object[]} points
33
+ * @returns {any}
34
+ */
35
+ static buildShape(THREE, points) {
36
+ const shape = new THREE.Shape()
37
+ PcbScene3dShapePathFactory.#appendClosedContour(shape, points)
38
+ return shape
39
+ }
40
+
41
+ /**
42
+ * Builds one hole path from normalized contour points.
43
+ * @param {any} THREE
44
+ * @param {object[]} points
45
+ * @returns {any}
46
+ */
47
+ static buildPath(THREE, points) {
48
+ const path = new THREE.Path()
49
+ PcbScene3dShapePathFactory.#appendClosedContour(path, points)
50
+ return path
51
+ }
52
+
53
+ /**
54
+ * Normalizes one point and optional arc center.
55
+ * @param {object} point
56
+ * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint
57
+ * @param {boolean} mirrorY
58
+ * @returns {object}
59
+ */
60
+ static #normalizeShapePoint(point, normalizeBoardPoint, mirrorY) {
61
+ const normalized = PcbScene3dShapePathFactory.#normalizePoint(
62
+ normalizeBoardPoint,
63
+ Number(point?.x || 0),
64
+ Number(point?.y || 0),
65
+ mirrorY
66
+ )
67
+ const output = {
68
+ ...point,
69
+ x: normalized.x,
70
+ y: normalized.y
71
+ }
72
+
73
+ if (point?.isArc) {
74
+ const center = PcbScene3dShapePathFactory.#normalizePoint(
75
+ normalizeBoardPoint,
76
+ Number(point.centerX || 0),
77
+ Number(point.centerY || 0),
78
+ mirrorY
79
+ )
80
+ output.centerX = center.x
81
+ output.centerY = center.y
82
+ output.arcYDirection = mirrorY ? -1 : 1
83
+ }
84
+
85
+ return output
86
+ }
87
+
88
+ /**
89
+ * Appends one closed contour to a shape or path.
90
+ * @param {{ moveTo: Function, lineTo: Function, closePath: Function }} path
91
+ * @param {object[]} points
92
+ * @returns {void}
93
+ */
94
+ static #appendClosedContour(path, points) {
95
+ const vertices =
96
+ PcbScene3dShapePathFactory.#withoutClosingDuplicate(points)
97
+
98
+ if (!vertices.length) {
99
+ return
100
+ }
101
+
102
+ path.moveTo(vertices[0].x, vertices[0].y)
103
+ for (let index = 0; index < vertices.length - 1; index += 1) {
104
+ PcbScene3dShapePathFactory.#appendSegment(
105
+ path,
106
+ vertices[index],
107
+ vertices[index + 1]
108
+ )
109
+ }
110
+ if (vertices.at(-1)?.isArc) {
111
+ PcbScene3dShapePathFactory.#appendArcSegment(
112
+ path,
113
+ vertices.at(-1),
114
+ vertices[0]
115
+ )
116
+ }
117
+ path.closePath()
118
+ }
119
+
120
+ /**
121
+ * Trims a duplicated closing point emitted by shape-based region streams.
122
+ * @param {object[]} points
123
+ * @returns {object[]}
124
+ */
125
+ static #withoutClosingDuplicate(points) {
126
+ if (!Array.isArray(points) || points.length < 2) {
127
+ return Array.isArray(points) ? points : []
128
+ }
129
+
130
+ const first = points[0]
131
+ const last = points[points.length - 1]
132
+
133
+ return PcbScene3dShapePathFactory.#samePoint(first, last)
134
+ ? points.slice(0, -1)
135
+ : points
136
+ }
137
+
138
+ /**
139
+ * Appends one line or arc segment.
140
+ * @param {{ lineTo: Function }} path
141
+ * @param {object} current
142
+ * @param {object} next
143
+ * @returns {void}
144
+ */
145
+ static #appendSegment(path, current, next) {
146
+ if (current?.isArc && Number(current.radius) > 0) {
147
+ PcbScene3dShapePathFactory.#appendArcSegment(path, current, next)
148
+ return
149
+ }
150
+
151
+ path.lineTo(next.x, next.y)
152
+ }
153
+
154
+ /**
155
+ * Appends a sampled circular arc matching Altium shape-region metadata.
156
+ * @param {{ lineTo: Function }} path
157
+ * @param {object} current
158
+ * @param {object} next
159
+ * @returns {void}
160
+ */
161
+ static #appendArcSegment(path, current, next) {
162
+ const startAngle = Number(current.startAngle || 0)
163
+ const deltaAngle = Number(current.endAngle || 0) - startAngle
164
+
165
+ if (!Number.isFinite(deltaAngle) || Math.abs(deltaAngle) < 0.001) {
166
+ path.lineTo(next.x, next.y)
167
+ return
168
+ }
169
+
170
+ const segments = Math.max(
171
+ 4,
172
+ Math.ceil(
173
+ Math.abs(deltaAngle) /
174
+ PcbScene3dShapePathFactory.#ARC_SEGMENT_DEGREES
175
+ )
176
+ )
177
+
178
+ for (let index = 1; index <= segments; index += 1) {
179
+ const angle =
180
+ ((startAngle + (deltaAngle * index) / segments) * Math.PI) / 180
181
+ path.lineTo(
182
+ Number(current.centerX) +
183
+ Number(current.radius) * Math.cos(angle),
184
+ Number(current.centerY) +
185
+ Number(current.arcYDirection || 1) *
186
+ Number(current.radius) *
187
+ Math.sin(angle)
188
+ )
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Returns true when two points share the same coordinate.
194
+ * @param {{ x?: number, y?: number }} first
195
+ * @param {{ x?: number, y?: number }} second
196
+ * @returns {boolean}
197
+ */
198
+ static #samePoint(first, second) {
199
+ return (
200
+ Math.abs(Number(first?.x) - Number(second?.x)) <=
201
+ PcbScene3dShapePathFactory.#GEOMETRY_EPSILON &&
202
+ Math.abs(Number(first?.y) - Number(second?.y)) <=
203
+ PcbScene3dShapePathFactory.#GEOMETRY_EPSILON
204
+ )
205
+ }
206
+
207
+ /**
208
+ * Normalizes one board point and optionally mirrors underside primitives.
209
+ * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint
210
+ * @param {number} x
211
+ * @param {number} y
212
+ * @param {boolean} mirrorY
213
+ * @returns {{ x: number, y: number }}
214
+ */
215
+ static #normalizePoint(normalizeBoardPoint, x, y, mirrorY) {
216
+ const point = normalizeBoardPoint(x, y)
217
+
218
+ return { x: point.x, y: mirrorY ? -point.y : point.y }
219
+ }
220
+ }
@@ -0,0 +1,131 @@
1
+ import { PcbScene3dText } from './PcbScene3dText.mjs'
2
+
3
+ /**
4
+ * Renders the interactive 3D scene shell.
5
+ */
6
+ export class PcbScene3dShellRenderer {
7
+ /**
8
+ * Renders the interactive 3D scene shell.
9
+ * @param {{ pcb?: { boardOutline: { widthMil: number, heightMil: number }, components: { designator: string }[] }, bom: { quantity: number }[] }} documentModel
10
+ * @param {((key: string) => string) | null} [translate] Translation lookup.
11
+ * @returns {string}
12
+ */
13
+ static render(documentModel, translate = null) {
14
+ const t = PcbScene3dText.createTranslator(translate)
15
+ const pcb = documentModel?.pcb
16
+ if (!pcb) {
17
+ return (
18
+ '<section class="viewer-empty">' +
19
+ PcbScene3dShellRenderer.#escapeHtml(t('scene3d.noPcb')) +
20
+ '</section>'
21
+ )
22
+ }
23
+
24
+ const widthMil = Math.round(pcb.boardOutline.widthMil || 0)
25
+ const heightMil = Math.round(pcb.boardOutline.heightMil || 0)
26
+ const componentCount = pcb.components.length
27
+ const bomRows = documentModel?.bom?.length || 0
28
+
29
+ return (
30
+ '<section class="scene-3d"><header class="svg-panel__header"><h3>' +
31
+ PcbScene3dShellRenderer.#escapeHtml(t('scene3d.title')) +
32
+ '</h3><p>' +
33
+ widthMil +
34
+ ' x ' +
35
+ heightMil +
36
+ ' ' +
37
+ PcbScene3dShellRenderer.#escapeHtml(
38
+ t('scene3d.boardEnvelopeSuffix')
39
+ ) +
40
+ '</p></header>' +
41
+ '<div class="scene-3d__toolbar" aria-label="' +
42
+ PcbScene3dShellRenderer.#escapeHtml(t('scene3d.toolbarAria')) +
43
+ '">' +
44
+ '<button class="scene-3d__preset" type="button" data-scene-3d-preset="top">' +
45
+ PcbScene3dShellRenderer.#escapeHtml(t('scene3d.top')) +
46
+ '</button>' +
47
+ '<button class="scene-3d__preset" type="button" data-scene-3d-preset="bottom">' +
48
+ PcbScene3dShellRenderer.#escapeHtml(t('scene3d.bottom')) +
49
+ '</button>' +
50
+ '<button class="scene-3d__preset" type="button" data-scene-3d-preset="isometric">' +
51
+ PcbScene3dShellRenderer.#escapeHtml(t('scene3d.isometric')) +
52
+ '</button>' +
53
+ '<button class="scene-3d__preset scene-3d__action" type="button" data-scene-3d-export="models-zip">' +
54
+ PcbScene3dShellRenderer.#escapeHtml(
55
+ t('scene3d.downloadModelsZip')
56
+ ) +
57
+ '</button>' +
58
+ '</div>' +
59
+ '<div class="scene-3d__stage">' +
60
+ '<div class="scene-3d__viewport" aria-label="' +
61
+ PcbScene3dShellRenderer.#escapeHtml(
62
+ t('scene3d.interactiveViewAria')
63
+ ) +
64
+ '">' +
65
+ '<div class="scene-3d__canvas-mount" data-scene-3d-viewport></div>' +
66
+ '<div class="scene-3d__loading" data-scene-3d-loading aria-live="polite">' +
67
+ '<div class="scene-3d__loading-content"><div class="viewer-loading__pulse"></div><p>' +
68
+ PcbScene3dShellRenderer.#escapeHtml(t('scene3d.loading')) +
69
+ '</p></div></div>' +
70
+ '</div>' +
71
+ '<aside class="scene-3d__controls" aria-label="' +
72
+ PcbScene3dShellRenderer.#escapeHtml(t('scene3d.controlsAria')) +
73
+ '">' +
74
+ '<label class="scene-3d__toggle"><input type="checkbox" checked data-scene-3d-toggle="external-models" />' +
75
+ PcbScene3dShellRenderer.#escapeHtml(t('scene3d.externalModels')) +
76
+ '</label>' +
77
+ '<label class="scene-3d__toggle"><input type="checkbox" data-scene-3d-toggle="fallback-bodies" />' +
78
+ PcbScene3dShellRenderer.#escapeHtml(t('scene3d.fallbackBodies')) +
79
+ '</label>' +
80
+ '<label class="scene-3d__toggle"><input type="checkbox" checked data-scene-3d-toggle="copper" />' +
81
+ PcbScene3dShellRenderer.#escapeHtml(t('scene3d.copperDetail')) +
82
+ '</label>' +
83
+ '<section class="scene-3d__selection" aria-live="polite"><h4 class="scene-3d__selection-title">' +
84
+ PcbScene3dShellRenderer.#escapeHtml(
85
+ t('scene3d.componentInspector')
86
+ ) +
87
+ '</h4><p class="scene-3d__selection-empty">' +
88
+ PcbScene3dShellRenderer.#escapeHtml(t('scene3d.inspectPrompt')) +
89
+ '</p></section>' +
90
+ '</aside>' +
91
+ '</div>' +
92
+ '<div class="scene-3d__diagnostics" aria-live="polite">' +
93
+ PcbScene3dShellRenderer.#escapeHtml(
94
+ t('scene3d.companionModelsHint')
95
+ ) +
96
+ '</div>' +
97
+ '<dl class="scene-3d__stats"><div><dt>' +
98
+ PcbScene3dShellRenderer.#escapeHtml(t('scene3d.footprint')) +
99
+ '</dt><dd>' +
100
+ widthMil +
101
+ ' x ' +
102
+ heightMil +
103
+ ' mil</dd></div><div><dt>' +
104
+ PcbScene3dShellRenderer.#escapeHtml(t('scene3d.placements')) +
105
+ '</dt><dd>' +
106
+ componentCount +
107
+ ' ' +
108
+ PcbScene3dShellRenderer.#escapeHtml(t('scene3d.componentsSuffix')) +
109
+ '</dd></div><div><dt>' +
110
+ PcbScene3dShellRenderer.#escapeHtml(t('scene3d.bomGroups')) +
111
+ '</dt><dd>' +
112
+ bomRows +
113
+ '</dd></div></dl></section>'
114
+ )
115
+ }
116
+
117
+ /**
118
+ * Escapes markup text.
119
+ * @param {string} value Raw text.
120
+ * @returns {string}
121
+ */
122
+ static #escapeHtml(value) {
123
+ return String(value)
124
+ .replaceAll('&', '&amp;')
125
+ .replaceAll('<', '&lt;')
126
+ .replaceAll('>', '&gt;')
127
+ .replaceAll('"', '&quot;')
128
+ }
129
+ }
130
+
131
+ export { PcbScene3dShellRenderer as Scene3dRenderer }