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,320 @@
|
|
|
1
|
+
import { zipSync } from 'fflate'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Builds browser-downloadable ZIP archives from resolved PCB component model
|
|
5
|
+
* data.
|
|
6
|
+
*/
|
|
7
|
+
export class PcbModelArchiveExporter {
|
|
8
|
+
/**
|
|
9
|
+
* Builds one ZIP archive from the resolved scene description.
|
|
10
|
+
* @param {{ archiveBaseName?: string, sceneDescription?: { components?: any[], externalPlacements?: any[] } }} [options]
|
|
11
|
+
* @returns {Promise<{ archiveName: string, archiveBytes: Uint8Array, exportedEntries: { archivePath: string, pattern: string, designator: string, format: string, modelName: string }[], skippedEntries: { designator: string, reason: string }[] }>}
|
|
12
|
+
*/
|
|
13
|
+
static async buildArchive(options = {}) {
|
|
14
|
+
const archiveName = PcbModelArchiveExporter.#resolveArchiveName(
|
|
15
|
+
options.archiveBaseName
|
|
16
|
+
)
|
|
17
|
+
const resolvedEntries = PcbModelArchiveExporter.#collectResolvedEntries(
|
|
18
|
+
options.sceneDescription
|
|
19
|
+
)
|
|
20
|
+
const exportedEntries = []
|
|
21
|
+
const skippedEntries = []
|
|
22
|
+
const archiveInput = {}
|
|
23
|
+
|
|
24
|
+
for (const entry of resolvedEntries) {
|
|
25
|
+
try {
|
|
26
|
+
const archivePath = PcbModelArchiveExporter.#resolveArchivePath(
|
|
27
|
+
entry,
|
|
28
|
+
exportedEntries
|
|
29
|
+
)
|
|
30
|
+
const modelBytes =
|
|
31
|
+
await PcbModelArchiveExporter.#readModelBytes(entry.model)
|
|
32
|
+
archiveInput[archivePath] = modelBytes
|
|
33
|
+
exportedEntries.push({
|
|
34
|
+
archivePath,
|
|
35
|
+
pattern: entry.pattern,
|
|
36
|
+
designator: entry.designator,
|
|
37
|
+
format: entry.model.format,
|
|
38
|
+
modelName: entry.model.name
|
|
39
|
+
})
|
|
40
|
+
} catch (error) {
|
|
41
|
+
skippedEntries.push({
|
|
42
|
+
designator: entry.designator,
|
|
43
|
+
reason: String(
|
|
44
|
+
error?.message || error || 'Model export failed.'
|
|
45
|
+
)
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
archiveName,
|
|
52
|
+
archiveBytes: zipSync(archiveInput),
|
|
53
|
+
exportedEntries,
|
|
54
|
+
skippedEntries
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Collects one deduplicated list of exportable resolved models from the
|
|
60
|
+
* same scene description shape used by the 3D runtime.
|
|
61
|
+
* @param {{ components?: any[], externalPlacements?: any[] } | undefined} sceneDescription
|
|
62
|
+
* @returns {{ pattern: string, designator: string, model: any, identityKey: string }[]}
|
|
63
|
+
*/
|
|
64
|
+
static #collectResolvedEntries(sceneDescription) {
|
|
65
|
+
const components = Array.isArray(sceneDescription?.components)
|
|
66
|
+
? sceneDescription.components
|
|
67
|
+
: []
|
|
68
|
+
const explicitPlacements = Array.isArray(
|
|
69
|
+
sceneDescription?.externalPlacements
|
|
70
|
+
)
|
|
71
|
+
? sceneDescription.externalPlacements
|
|
72
|
+
: []
|
|
73
|
+
const componentByDesignator = new Map()
|
|
74
|
+
const explicitDesignators = new Set()
|
|
75
|
+
|
|
76
|
+
components.forEach((component) => {
|
|
77
|
+
const designator = String(component?.designator || '').trim()
|
|
78
|
+
if (!designator) {
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
componentByDesignator.set(designator, component)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
const collectedEntries = []
|
|
86
|
+
explicitPlacements.forEach((placement) => {
|
|
87
|
+
const designator = String(placement?.designator || '').trim()
|
|
88
|
+
if (!designator || !placement?.externalModel) {
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
explicitDesignators.add(designator)
|
|
93
|
+
const component = componentByDesignator.get(designator) || null
|
|
94
|
+
collectedEntries.push({
|
|
95
|
+
pattern: PcbModelArchiveExporter.#resolvePattern(
|
|
96
|
+
component,
|
|
97
|
+
placement
|
|
98
|
+
),
|
|
99
|
+
designator,
|
|
100
|
+
model: placement.externalModel,
|
|
101
|
+
identityKey: PcbModelArchiveExporter.#resolveModelIdentity(
|
|
102
|
+
placement.externalModel
|
|
103
|
+
)
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
components.forEach((component) => {
|
|
108
|
+
const designator = String(component?.designator || '').trim()
|
|
109
|
+
if (
|
|
110
|
+
!designator ||
|
|
111
|
+
explicitDesignators.has(designator) ||
|
|
112
|
+
!component?.externalModel
|
|
113
|
+
) {
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
collectedEntries.push({
|
|
118
|
+
pattern: PcbModelArchiveExporter.#resolvePattern(
|
|
119
|
+
component,
|
|
120
|
+
null
|
|
121
|
+
),
|
|
122
|
+
designator,
|
|
123
|
+
model: component.externalModel,
|
|
124
|
+
identityKey: PcbModelArchiveExporter.#resolveModelIdentity(
|
|
125
|
+
component.externalModel
|
|
126
|
+
)
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
const dedupedEntries = []
|
|
131
|
+
const seenKeys = new Set()
|
|
132
|
+
collectedEntries.forEach((entry) => {
|
|
133
|
+
const dedupeKey =
|
|
134
|
+
PcbModelArchiveExporter.#normalizeToken(entry.pattern) +
|
|
135
|
+
'::' +
|
|
136
|
+
entry.identityKey
|
|
137
|
+
if (seenKeys.has(dedupeKey)) {
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
seenKeys.add(dedupeKey)
|
|
142
|
+
dedupedEntries.push(entry)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
return dedupedEntries
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Resolves one archive file name for the exported model.
|
|
150
|
+
* @param {{ pattern: string, model: { format?: string } }} entry
|
|
151
|
+
* @param {{ archivePath: string }[]} exportedEntries
|
|
152
|
+
* @returns {string}
|
|
153
|
+
*/
|
|
154
|
+
static #resolveArchivePath(entry, exportedEntries) {
|
|
155
|
+
const sanitizedPattern = PcbModelArchiveExporter.#sanitizeFileToken(
|
|
156
|
+
entry.pattern
|
|
157
|
+
)
|
|
158
|
+
const extension = PcbModelArchiveExporter.#resolveExtension(
|
|
159
|
+
entry.model?.format
|
|
160
|
+
)
|
|
161
|
+
const basePath = sanitizedPattern + '.' + extension
|
|
162
|
+
let archivePath = basePath
|
|
163
|
+
let duplicateIndex = 2
|
|
164
|
+
|
|
165
|
+
while (
|
|
166
|
+
exportedEntries.some(
|
|
167
|
+
(exportedEntry) => exportedEntry.archivePath === archivePath
|
|
168
|
+
)
|
|
169
|
+
) {
|
|
170
|
+
archivePath =
|
|
171
|
+
sanitizedPattern + '--' + duplicateIndex + '.' + extension
|
|
172
|
+
duplicateIndex += 1
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return archivePath
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Reads one resolved model into ZIP-ready bytes.
|
|
180
|
+
* @param {{ origin?: string, payloadText?: string, file?: Blob | File | null }} model
|
|
181
|
+
* @returns {Promise<Uint8Array>}
|
|
182
|
+
*/
|
|
183
|
+
static async #readModelBytes(model) {
|
|
184
|
+
if (model?.origin === 'embedded') {
|
|
185
|
+
const payloadText = String(model?.payloadText || '')
|
|
186
|
+
if (!payloadText) {
|
|
187
|
+
throw new Error('Embedded STEP payload is unavailable.')
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return new TextEncoder().encode(payloadText)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (typeof model?.file?.arrayBuffer === 'function') {
|
|
194
|
+
return new Uint8Array(await model.file.arrayBuffer())
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
throw new Error('Session model bytes are unavailable.')
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Resolves one user-facing archive name from the provided base name.
|
|
202
|
+
* @param {string | undefined} archiveBaseName
|
|
203
|
+
* @returns {string}
|
|
204
|
+
*/
|
|
205
|
+
static #resolveArchiveName(archiveBaseName) {
|
|
206
|
+
const sanitizedBaseName = PcbModelArchiveExporter.#sanitizeFileToken(
|
|
207
|
+
archiveBaseName || 'pcb-models'
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
return sanitizedBaseName + '-models.zip'
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Resolves the preferred export label for one model placement.
|
|
215
|
+
* @param {{ pattern?: string, designator?: string, externalModel?: any } | null} component
|
|
216
|
+
* @param {{ designator?: string, externalModel?: any } | null} placement
|
|
217
|
+
* @returns {string}
|
|
218
|
+
*/
|
|
219
|
+
static #resolvePattern(component, placement) {
|
|
220
|
+
const componentPattern = String(component?.pattern || '').trim()
|
|
221
|
+
if (componentPattern) {
|
|
222
|
+
return componentPattern
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const designator = String(
|
|
226
|
+
component?.designator || placement?.designator || ''
|
|
227
|
+
).trim()
|
|
228
|
+
if (designator) {
|
|
229
|
+
return designator
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return PcbModelArchiveExporter.#stripExtension(
|
|
233
|
+
String(
|
|
234
|
+
component?.externalModel?.name ||
|
|
235
|
+
placement?.externalModel?.name ||
|
|
236
|
+
'model'
|
|
237
|
+
)
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Resolves a stable identity key for one model so repeated placements can
|
|
243
|
+
* be deduplicated.
|
|
244
|
+
* @param {{ origin?: string, relativePath?: string, sourceStream?: string, name?: string, checksum?: number | null, format?: string }} model
|
|
245
|
+
* @returns {string}
|
|
246
|
+
*/
|
|
247
|
+
static #resolveModelIdentity(model) {
|
|
248
|
+
if (String(model?.origin || '') === 'embedded') {
|
|
249
|
+
return [
|
|
250
|
+
'embedded',
|
|
251
|
+
String(model?.sourceStream || ''),
|
|
252
|
+
String(model?.name || ''),
|
|
253
|
+
String(model?.checksum || ''),
|
|
254
|
+
String(model?.format || '')
|
|
255
|
+
].join('::')
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return [
|
|
259
|
+
'session',
|
|
260
|
+
String(model?.relativePath || ''),
|
|
261
|
+
String(model?.name || ''),
|
|
262
|
+
String(model?.format || '')
|
|
263
|
+
].join('::')
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Resolves a safe archive extension from the model format.
|
|
268
|
+
* @param {string | undefined} format
|
|
269
|
+
* @returns {string}
|
|
270
|
+
*/
|
|
271
|
+
static #resolveExtension(format) {
|
|
272
|
+
const normalizedFormat = String(format || '')
|
|
273
|
+
.trim()
|
|
274
|
+
.toLowerCase()
|
|
275
|
+
if (normalizedFormat === 'wrl') {
|
|
276
|
+
return 'wrl'
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return 'step'
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Removes the trailing extension from one file name.
|
|
284
|
+
* @param {string} value
|
|
285
|
+
* @returns {string}
|
|
286
|
+
*/
|
|
287
|
+
static #stripExtension(value) {
|
|
288
|
+
return String(value || '').replace(/\.[^.]+$/, '')
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Normalizes one token for internal dedupe keys.
|
|
293
|
+
* @param {string} value
|
|
294
|
+
* @returns {string}
|
|
295
|
+
*/
|
|
296
|
+
static #normalizeToken(value) {
|
|
297
|
+
return String(value || '')
|
|
298
|
+
.trim()
|
|
299
|
+
.toLowerCase()
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Sanitizes one archive token so pattern-derived names stay readable and
|
|
304
|
+
* cannot introduce nested paths.
|
|
305
|
+
* @param {string} value
|
|
306
|
+
* @returns {string}
|
|
307
|
+
*/
|
|
308
|
+
static #sanitizeFileToken(value) {
|
|
309
|
+
const sanitizedValue = String(value || '')
|
|
310
|
+
.replace(/[<>:"/\\|?*\u0000-\u001f]+/g, '-')
|
|
311
|
+
.replace(/\s+/g, ' ')
|
|
312
|
+
.trim()
|
|
313
|
+
|
|
314
|
+
if (sanitizedValue) {
|
|
315
|
+
return sanitizedValue
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return 'model'
|
|
319
|
+
}
|
|
320
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Geometry helpers for PCB arc sweep calculations.
|
|
3
|
+
*/
|
|
4
|
+
export class PcbScene3dArcUtils {
|
|
5
|
+
static #FULL_CIRCLE_EPSILON = 0.001
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Normalizes one PCB arc delta to the intended short wrapped sweep.
|
|
9
|
+
* @param {number} startAngle Start angle in degrees.
|
|
10
|
+
* @param {number} endAngle End angle in degrees.
|
|
11
|
+
* @returns {number}
|
|
12
|
+
*/
|
|
13
|
+
static resolveSweepDelta(startAngle, endAngle) {
|
|
14
|
+
const rawDelta = Number(endAngle || 0) - Number(startAngle || 0)
|
|
15
|
+
let normalizedDelta = ((rawDelta + 540) % 360) - 180
|
|
16
|
+
|
|
17
|
+
if (
|
|
18
|
+
Math.abs(normalizedDelta + 180) <=
|
|
19
|
+
PcbScene3dArcUtils.#FULL_CIRCLE_EPSILON &&
|
|
20
|
+
rawDelta > 0
|
|
21
|
+
) {
|
|
22
|
+
normalizedDelta = 180
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return normalizedDelta
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds the synthetic external placement for a full board assembly model.
|
|
3
|
+
*/
|
|
4
|
+
export class PcbScene3dBoardAssemblyPlacement {
|
|
5
|
+
/**
|
|
6
|
+
* Builds the synthetic placement for a full board assembly model.
|
|
7
|
+
* @param {{ board?: { widthMil?: number, heightMil?: number, thicknessMil?: number, surfaceColor?: number }, boardAssemblyModel?: any }} sceneDescription
|
|
8
|
+
* @returns {any | null}
|
|
9
|
+
*/
|
|
10
|
+
static build(sceneDescription) {
|
|
11
|
+
const model = sceneDescription?.boardAssemblyModel || null
|
|
12
|
+
if (!model) {
|
|
13
|
+
return null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const board = sceneDescription?.board || {}
|
|
17
|
+
return {
|
|
18
|
+
designator: 'Board assembly',
|
|
19
|
+
sourceType: 'board-assembly',
|
|
20
|
+
mountSide: 'board-assembly',
|
|
21
|
+
rotationDeg: 0,
|
|
22
|
+
positionMil: {
|
|
23
|
+
x: -Number(board.widthMil || 0) / 2,
|
|
24
|
+
y: -Number(board.heightMil || 0) / 2,
|
|
25
|
+
z: 0
|
|
26
|
+
},
|
|
27
|
+
board: {
|
|
28
|
+
widthMil: Number(board.widthMil || 0),
|
|
29
|
+
heightMil: Number(board.heightMil || 0),
|
|
30
|
+
thicknessMil: Number(board.thicknessMil || 0),
|
|
31
|
+
surfaceColor: Number(board.surfaceColor)
|
|
32
|
+
},
|
|
33
|
+
externalModel: model
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|