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.
- package/AGENTS.md +67 -0
- package/COMMERCIAL-LICENSE.md +15 -0
- package/CONTRIBUTING.md +14 -0
- package/LICENSE +19 -0
- package/LICENSES/AGPL-3.0-or-later.txt +235 -0
- package/LICENSES/CC-BY-SA-4.0.txt +170 -0
- package/LICENSES/LGPL-2.1-or-later.txt +176 -0
- package/LICENSES/LicenseRef-PolyForm-Noncommercial-1.0.0.txt +131 -0
- package/NOTICE.md +36 -0
- package/README.md +128 -0
- package/REUSE.toml +16 -0
- package/docs/api.md +148 -0
- package/docs/circuitjson.md +190 -0
- package/docs/model-format.md +117 -0
- package/docs/testing.md +23 -0
- package/package.json +65 -0
- package/spec/library-scope.md +36 -0
- package/src/PcbModelArchiveExporter.mjs +320 -0
- package/src/PcbScene3dArcUtils.mjs +27 -0
- package/src/PcbScene3dBoardAssemblyPlacement.mjs +36 -0
- package/src/PcbScene3dBoardAssemblyPresentation.mjs +859 -0
- package/src/PcbScene3dBoardEdgeCutoutBuilder.mjs +537 -0
- package/src/PcbScene3dBoardMaterialPalette.mjs +40 -0
- package/src/PcbScene3dBoardShapeFactory.mjs +895 -0
- package/src/PcbScene3dBoardSolderMaskFactory.mjs +613 -0
- package/src/PcbScene3dCameraRig.mjs +168 -0
- package/src/PcbScene3dCircuitJsonAdapter.mjs +545 -0
- package/src/PcbScene3dController.mjs +956 -0
- package/src/PcbScene3dCopperDetailFilter.mjs +490 -0
- package/src/PcbScene3dCopperFactory.mjs +559 -0
- package/src/PcbScene3dCopperTextFactory.mjs +534 -0
- package/src/PcbScene3dCutoutGeometryFilter.mjs +873 -0
- package/src/PcbScene3dDetailCoordinateNormalizer.mjs +65 -0
- package/src/PcbScene3dDrillCutoutFilter.mjs +224 -0
- package/src/PcbScene3dDrillPathFactory.mjs +362 -0
- package/src/PcbScene3dDrillVoidFactory.mjs +268 -0
- package/src/PcbScene3dExternalModelLoadOrder.mjs +54 -0
- package/src/PcbScene3dExternalModels.mjs +968 -0
- package/src/PcbScene3dFallbackVisibility.mjs +82 -0
- package/src/PcbScene3dInteractionHints.mjs +56 -0
- package/src/PcbScene3dMountRig.mjs +53 -0
- package/src/PcbScene3dOutlineBuilder.mjs +210 -0
- package/src/PcbScene3dPadFactory.mjs +553 -0
- package/src/PcbScene3dPresetState.mjs +48 -0
- package/src/PcbScene3dRenderGroupVisibility.mjs +134 -0
- package/src/PcbScene3dRuntime.mjs +996 -0
- package/src/PcbScene3dRuntimeBoardMeshes.mjs +99 -0
- package/src/PcbScene3dSelectionStyler.mjs +252 -0
- package/src/PcbScene3dShapePathFactory.mjs +220 -0
- package/src/PcbScene3dShellRenderer.mjs +131 -0
- package/src/PcbScene3dSilkscreenFactory.mjs +854 -0
- package/src/PcbScene3dSilkscreenStrokeWidthResolver.mjs +81 -0
- package/src/PcbScene3dStepLoader.mjs +611 -0
- package/src/PcbScene3dStrokeFont.mjs +671 -0
- package/src/PcbScene3dStrokeGeometryBuilder.mjs +322 -0
- package/src/PcbScene3dText.mjs +99 -0
- package/src/PcbScene3dTrueTypeTextFactory.mjs +885 -0
- package/src/PcbScene3dViaFactory.mjs +176 -0
- package/src/PcbScene3dViewCompensation.mjs +109 -0
- package/src/PcbScene3dViewScale.mjs +24 -0
- package/src/PcbScene3dViewportResize.mjs +35 -0
- package/src/PcbScene3dWorkerClient.mjs +123 -0
- package/src/index.mjs +1 -0
- package/src/scene3d.mjs +44 -0
- 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('&', '&')
|
|
125
|
+
.replaceAll('<', '<')
|
|
126
|
+
.replaceAll('>', '>')
|
|
127
|
+
.replaceAll('"', '"')
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export { PcbScene3dShellRenderer as Scene3dRenderer }
|