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,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
+ }