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,490 @@
1
+ /**
2
+ * Filters 3D copper detail to match realistic solder-mask visibility.
3
+ */
4
+ export class PcbScene3dCopperDetailFilter {
5
+ /**
6
+ * Resolves the copper detail that should be visible in the 3D viewport.
7
+ * @param {object} sceneDescription 3D scene description.
8
+ * @returns {object}
9
+ */
10
+ static resolve(sceneDescription) {
11
+ const detail = sceneDescription?.detail || {}
12
+
13
+ if (
14
+ !PcbScene3dCopperDetailFilter.#usesRealisticMasking(
15
+ sceneDescription
16
+ )
17
+ ) {
18
+ return detail
19
+ }
20
+
21
+ const copperTextMaskMatcher =
22
+ PcbScene3dCopperDetailFilter.#buildCopperTextMaskMatcher(
23
+ sceneDescription
24
+ )
25
+
26
+ return {
27
+ ...detail,
28
+ tracks: PcbScene3dCopperDetailFilter.#filterMaskOpenPrimitives(
29
+ detail.tracks
30
+ ),
31
+ arcs: PcbScene3dCopperDetailFilter.#filterMaskOpenPrimitives(
32
+ detail.arcs
33
+ ),
34
+ copperTexts: PcbScene3dCopperDetailFilter.#filterMaskOpenPrimitives(
35
+ detail.copperTexts,
36
+ copperTextMaskMatcher
37
+ ),
38
+ vias: PcbScene3dCopperDetailFilter.#filterExposedVias(detail.vias)
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Checks whether standalone via annuli should be rendered.
44
+ * @param {object} sceneDescription 3D scene description.
45
+ * @returns {boolean}
46
+ */
47
+ static shouldRenderStandaloneVias(sceneDescription) {
48
+ if (
49
+ !PcbScene3dCopperDetailFilter.#usesRealisticMasking(
50
+ sceneDescription
51
+ )
52
+ ) {
53
+ return true
54
+ }
55
+
56
+ return (
57
+ PcbScene3dCopperDetailFilter.resolveStandaloneVias(sceneDescription)
58
+ .length > 0
59
+ )
60
+ }
61
+
62
+ /**
63
+ * Resolves through-board via barrels that should be visible in the scene.
64
+ * @param {object} sceneDescription 3D scene description.
65
+ * @returns {any[]}
66
+ */
67
+ static resolveStandaloneVias(sceneDescription) {
68
+ const detail = sceneDescription?.detail || {}
69
+ const vias = PcbScene3dCopperDetailFilter.#usesRealisticMasking(
70
+ sceneDescription
71
+ )
72
+ ? PcbScene3dCopperDetailFilter.#filterExposedVias(detail.vias)
73
+ : detail.vias || []
74
+
75
+ return PcbScene3dCopperDetailFilter.#appendPadBarrelSpecs(
76
+ vias,
77
+ detail.pads
78
+ )
79
+ }
80
+
81
+ /**
82
+ * Checks whether one scene should use solder-mask visibility.
83
+ * @param {object} sceneDescription 3D scene description.
84
+ * @returns {boolean}
85
+ */
86
+ static #usesRealisticMasking(sceneDescription) {
87
+ const sourceFormat = String(sceneDescription?.sourceFormat || '')
88
+ .trim()
89
+ .toLowerCase()
90
+
91
+ if (sourceFormat === 'altium' || sourceFormat === 'kicad') {
92
+ return true
93
+ }
94
+
95
+ if (sceneDescription?.coordinateSystem === 'kicad-3d-y-up') {
96
+ return true
97
+ }
98
+
99
+ return PcbScene3dCopperDetailFilter.#hasExplicitMaskMetadata(
100
+ sceneDescription?.detail
101
+ )
102
+ }
103
+
104
+ /**
105
+ * Checks whether parsed copper detail carries explicit solder-mask data.
106
+ * @param {object | undefined} detail Scene detail.
107
+ * @returns {boolean}
108
+ */
109
+ static #hasExplicitMaskMetadata(detail) {
110
+ return [
111
+ detail?.tracks,
112
+ detail?.arcs,
113
+ detail?.copperTexts,
114
+ detail?.vias,
115
+ detail?.pads
116
+ ].some((primitives) =>
117
+ (primitives || []).some((primitive) =>
118
+ PcbScene3dCopperDetailFilter.#hasMaskMetadata(primitive)
119
+ )
120
+ )
121
+ }
122
+
123
+ /**
124
+ * Keeps copper primitives only when the source declares a mask opening.
125
+ * @param {any[] | undefined} primitives Copper primitive list.
126
+ * @param {((primitive: object) => boolean) | null} [maskMatcher]
127
+ * @returns {any[]}
128
+ */
129
+ static #filterMaskOpenPrimitives(primitives, maskMatcher = null) {
130
+ return (primitives || []).filter((primitive) =>
131
+ PcbScene3dCopperDetailFilter.#hasMaskOpening(primitive, maskMatcher)
132
+ )
133
+ }
134
+
135
+ /**
136
+ * Keeps vias only when they are explicitly not tented.
137
+ * @param {any[] | undefined} vias Via list.
138
+ * @returns {any[]}
139
+ */
140
+ static #filterExposedVias(vias) {
141
+ return (vias || []).filter((via) => {
142
+ return via?.isTentingTop === false || via?.isTentingBottom === false
143
+ })
144
+ }
145
+
146
+ /**
147
+ * Appends copper barrels for through-hole pads with copper annuli.
148
+ * @param {any[]} vias Visible via list.
149
+ * @param {any[] | undefined} pads Pad list.
150
+ * @returns {any[]}
151
+ */
152
+ static #appendPadBarrelSpecs(vias, pads) {
153
+ const output = [...(vias || [])]
154
+ const seen = new Set(
155
+ output.map((via) =>
156
+ PcbScene3dCopperDetailFilter.#platedHoleKey(via)
157
+ )
158
+ )
159
+
160
+ for (const pad of pads || []) {
161
+ const barrelSpec =
162
+ PcbScene3dCopperDetailFilter.#resolvePadBarrelSpec(pad)
163
+ if (!barrelSpec) {
164
+ continue
165
+ }
166
+
167
+ const key = PcbScene3dCopperDetailFilter.#platedHoleKey(barrelSpec)
168
+ if (seen.has(key)) {
169
+ continue
170
+ }
171
+
172
+ seen.add(key)
173
+ output.push(barrelSpec)
174
+ }
175
+
176
+ return output
177
+ }
178
+
179
+ /**
180
+ * Resolves one visible through-hole pad barrel spec.
181
+ * @param {any} pad Pad primitive.
182
+ * @returns {{ x: number, y: number, holeDiameter: number, barrelOnly: true } | null}
183
+ */
184
+ static #resolvePadBarrelSpec(pad) {
185
+ const holeDiameter = Number(pad?.holeDiameter || 0)
186
+ const holeSlotLength = Number(pad?.holeSlotLength || 0)
187
+
188
+ if (
189
+ holeDiameter <= 0 ||
190
+ holeSlotLength > holeDiameter + 0.001 ||
191
+ !PcbScene3dCopperDetailFilter.#hasPadCopperAnnulus(
192
+ pad,
193
+ holeDiameter
194
+ ) ||
195
+ (pad?.hasTopSolderMaskOpening === false &&
196
+ pad?.hasBottomSolderMaskOpening === false)
197
+ ) {
198
+ return null
199
+ }
200
+
201
+ return {
202
+ x: Number(pad?.x || 0),
203
+ y: Number(pad?.y || 0),
204
+ holeDiameter,
205
+ barrelOnly: true
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Checks whether one pad has copper larger than its drill aperture.
211
+ * @param {any} pad Pad primitive.
212
+ * @param {number} holeDiameter Drill diameter.
213
+ * @returns {boolean}
214
+ */
215
+ static #hasPadCopperAnnulus(pad, holeDiameter) {
216
+ return [
217
+ pad?.sizeTopX,
218
+ pad?.sizeTopY,
219
+ pad?.sizeMidX,
220
+ pad?.sizeMidY,
221
+ pad?.sizeBottomX,
222
+ pad?.sizeBottomY
223
+ ].some((size) => Number(size || 0) > holeDiameter + 0.001)
224
+ }
225
+
226
+ /**
227
+ * Builds a stable dedupe key for one plated through-hole.
228
+ * @param {{ x?: number, y?: number, holeDiameter?: number }} primitive
229
+ * @returns {string}
230
+ */
231
+ static #platedHoleKey(primitive) {
232
+ return [
233
+ Number(primitive?.x || 0).toFixed(4),
234
+ Number(primitive?.y || 0).toFixed(4),
235
+ Number(primitive?.holeDiameter || 0).toFixed(4)
236
+ ].join(':')
237
+ }
238
+
239
+ /**
240
+ * Checks whether a copper primitive should break through solder mask.
241
+ * @param {object} primitive Copper primitive.
242
+ * @param {((primitive: object) => boolean) | null} [maskMatcher]
243
+ * @returns {boolean}
244
+ */
245
+ static #hasMaskOpening(primitive, maskMatcher = null) {
246
+ if (primitive?.hasSolderMask === true) {
247
+ return true
248
+ }
249
+
250
+ if (primitive?.solderMaskOpening === true) {
251
+ return true
252
+ }
253
+
254
+ if (
255
+ Number.isFinite(Number(primitive?.solderMaskExpansion)) &&
256
+ Number(primitive?.solderMaskExpansion) !== 0
257
+ ) {
258
+ return true
259
+ }
260
+
261
+ return typeof maskMatcher === 'function'
262
+ ? maskMatcher(primitive)
263
+ : false
264
+ }
265
+
266
+ /**
267
+ * Checks whether one primitive declares any solder-mask visibility field.
268
+ * @param {object} primitive Copper primitive.
269
+ * @returns {boolean}
270
+ */
271
+ static #hasMaskMetadata(primitive) {
272
+ return [
273
+ 'hasSolderMask',
274
+ 'solderMaskOpening',
275
+ 'solderMaskExpansion',
276
+ 'hasTopSolderMaskOpening',
277
+ 'hasBottomSolderMaskOpening',
278
+ 'isTentingTop',
279
+ 'isTentingBottom'
280
+ ].some((fieldName) =>
281
+ Object.prototype.hasOwnProperty.call(primitive || {}, fieldName)
282
+ )
283
+ }
284
+
285
+ /**
286
+ * Builds a same-position lookup for KiCad mask-layer text openings.
287
+ * @param {object} sceneDescription Scene description.
288
+ * @returns {((primitive: object) => boolean) | null}
289
+ */
290
+ static #buildCopperTextMaskMatcher(sceneDescription) {
291
+ if (
292
+ !PcbScene3dCopperDetailFilter.#isKiCadScene(sceneDescription) ||
293
+ !Array.isArray(sceneDescription?.texts)
294
+ ) {
295
+ return null
296
+ }
297
+
298
+ const maskTextKeys = new Set()
299
+ sceneDescription.texts
300
+ .filter((text) =>
301
+ PcbScene3dCopperDetailFilter.#isMaskLayerText(text)
302
+ )
303
+ .forEach((text) => {
304
+ PcbScene3dCopperDetailFilter.#textMatchKeys(
305
+ text,
306
+ sceneDescription
307
+ ).forEach((key) => maskTextKeys.add(key))
308
+ })
309
+
310
+ if (!maskTextKeys.size) {
311
+ return null
312
+ }
313
+
314
+ return (primitive) =>
315
+ maskTextKeys.has(
316
+ PcbScene3dCopperDetailFilter.#textMatchKey(
317
+ primitive,
318
+ primitive?.y
319
+ )
320
+ )
321
+ }
322
+
323
+ /**
324
+ * Checks whether one scene uses KiCad scene coordinate conventions.
325
+ * @param {object} sceneDescription Scene description.
326
+ * @returns {boolean}
327
+ */
328
+ static #isKiCadScene(sceneDescription) {
329
+ return (
330
+ String(sceneDescription?.sourceFormat || '')
331
+ .trim()
332
+ .toLowerCase() === 'kicad' ||
333
+ sceneDescription?.coordinateSystem === 'kicad-3d-y-up'
334
+ )
335
+ }
336
+
337
+ /**
338
+ * Checks whether one text primitive belongs to a solder-mask layer.
339
+ * @param {object} text Text primitive.
340
+ * @returns {boolean}
341
+ */
342
+ static #isMaskLayerText(text) {
343
+ const layer = String(text?.layer || '')
344
+ .trim()
345
+ .toUpperCase()
346
+ return layer === 'F.MASK' || layer === 'B.MASK'
347
+ }
348
+
349
+ /**
350
+ * Converts scene text Y back to source board Y for KiCad y-up scenes.
351
+ * @param {object} text Scene text primitive.
352
+ * @param {object} sceneDescription Scene description.
353
+ * @returns {number}
354
+ */
355
+ static #sourceYForSceneText(text, sceneDescription) {
356
+ const y = Number(text?.y || 0)
357
+ const centerY = Number(sceneDescription?.board?.centerY)
358
+
359
+ if (
360
+ sceneDescription?.coordinateSystem === 'kicad-3d-y-up' &&
361
+ Number.isFinite(centerY)
362
+ ) {
363
+ return centerY * 2 - y
364
+ }
365
+
366
+ return y
367
+ }
368
+
369
+ /**
370
+ * Builds tolerant keys for matching paired KiCad copper and mask text.
371
+ * @param {object} text Text primitive.
372
+ * @param {object} sceneDescription Scene description.
373
+ * @returns {string[]}
374
+ */
375
+ static #textMatchKeys(text, sceneDescription) {
376
+ return PcbScene3dCopperDetailFilter.#uniqueValues([
377
+ text?.y,
378
+ PcbScene3dCopperDetailFilter.#sourceYForSceneText(
379
+ text,
380
+ sceneDescription
381
+ )
382
+ ]).flatMap((y) =>
383
+ PcbScene3dCopperDetailFilter.#uniqueValues([
384
+ text?.rotation,
385
+ PcbScene3dCopperDetailFilter.#sourceRotationForSceneText(
386
+ text,
387
+ sceneDescription
388
+ )
389
+ ]).map((rotation) =>
390
+ PcbScene3dCopperDetailFilter.#textMatchKey(text, y, rotation)
391
+ )
392
+ )
393
+ }
394
+
395
+ /**
396
+ * Builds a tolerant key for matching paired KiCad copper and mask text.
397
+ * @param {object} text Text primitive.
398
+ * @param {number | string | undefined} y Candidate Y coordinate.
399
+ * @param {number | string | undefined} rotation Candidate rotation.
400
+ * @returns {string}
401
+ */
402
+ static #textMatchKey(text, y, rotation = text?.rotation) {
403
+ return [
404
+ PcbScene3dCopperDetailFilter.#textSide(text),
405
+ PcbScene3dCopperDetailFilter.#roundCoordinate(text?.x),
406
+ PcbScene3dCopperDetailFilter.#roundCoordinate(y),
407
+ PcbScene3dCopperDetailFilter.#roundCoordinate(rotation),
408
+ text?.mirrored ? 'mirrored' : 'normal',
409
+ String(text?.value ?? text?.text ?? '').trim()
410
+ ].join('|')
411
+ }
412
+
413
+ /**
414
+ * Converts scene text rotation back to source board rotation.
415
+ * @param {object} text Scene text primitive.
416
+ * @param {object} sceneDescription Scene description.
417
+ * @returns {number}
418
+ */
419
+ static #sourceRotationForSceneText(text, sceneDescription) {
420
+ const rotation = Number(text?.rotation || 0)
421
+
422
+ if (sceneDescription?.coordinateSystem === 'kicad-3d-y-up') {
423
+ return ((-rotation % 360) + 360) % 360
424
+ }
425
+
426
+ return rotation
427
+ }
428
+
429
+ /**
430
+ * Returns unique candidates after coordinate rounding.
431
+ * @param {Array<number | string | undefined>} values Candidate values.
432
+ * @returns {Array<number | string | undefined>}
433
+ */
434
+ static #uniqueValues(values) {
435
+ const seen = new Set()
436
+ const output = []
437
+
438
+ values.forEach((value) => {
439
+ const key = PcbScene3dCopperDetailFilter.#roundCoordinate(value)
440
+ if (seen.has(key)) {
441
+ return
442
+ }
443
+
444
+ seen.add(key)
445
+ output.push(value)
446
+ })
447
+
448
+ return output
449
+ }
450
+
451
+ /**
452
+ * Resolves one text primitive to the app's top/bottom side names.
453
+ * @param {object} text Text primitive.
454
+ * @returns {'top' | 'bottom' | ''}
455
+ */
456
+ static #textSide(text) {
457
+ const layer = String(text?.layer || '')
458
+ .trim()
459
+ .toUpperCase()
460
+ if (layer.startsWith('B.')) {
461
+ return 'bottom'
462
+ }
463
+
464
+ if (layer.startsWith('F.')) {
465
+ return 'top'
466
+ }
467
+
468
+ const side = String(text?.side || '')
469
+ .trim()
470
+ .toLowerCase()
471
+ if (side === 'back' || side === 'bottom') {
472
+ return 'bottom'
473
+ }
474
+
475
+ if (side === 'front' || side === 'top') {
476
+ return 'top'
477
+ }
478
+
479
+ return ''
480
+ }
481
+
482
+ /**
483
+ * Rounds coordinates to avoid float noise in KiCad mm-to-mil conversion.
484
+ * @param {number | string | undefined} value Coordinate value.
485
+ * @returns {number}
486
+ */
487
+ static #roundCoordinate(value) {
488
+ return Math.round(Number(value || 0) * 1000) / 1000
489
+ }
490
+ }