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,534 @@
1
+ import { PcbScene3dStrokeFont } from './PcbScene3dStrokeFont.mjs'
2
+ import { PcbScene3dCutoutGeometryFilter } from './PcbScene3dCutoutGeometryFilter.mjs'
3
+ import { PcbScene3dStrokeGeometryBuilder } from './PcbScene3dStrokeGeometryBuilder.mjs'
4
+
5
+ /**
6
+ * Builds KiCad copper text as widened stroke meshes for the 3D PCB scene.
7
+ */
8
+ export class PcbScene3dCopperTextFactory {
9
+ static #DEFAULT_MATERIAL_COLOR = 0xd9a61d
10
+ static #TEXT_LINE_SPACING_RATIO = 1.61
11
+ static #FIRST_LINE_HEIGHT_RATIO = 1.17
12
+ static #STROKE_BASELINE_FUDGE_RATIO = 0.052
13
+
14
+ /**
15
+ * Builds one side-specific copper text group.
16
+ * @param {any} THREE
17
+ * @param {any[]} texts
18
+ * @param {number} z
19
+ * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint
20
+ * @param {{ side?: 'top' | 'bottom', mirrorY?: boolean, materialColor?: number, filterSide?: boolean, glyphYUp?: boolean, drillCutouts?: { x: number, y: number }[][] }} [options]
21
+ * @returns {any}
22
+ */
23
+ static buildGroup(THREE, texts, z, normalizeBoardPoint, options = {}) {
24
+ const group = new THREE.Group()
25
+ const positions = []
26
+ const side = PcbScene3dCopperTextFactory.#normalizeSide(options?.side)
27
+ const mirrorY = Boolean(options?.mirrorY)
28
+ const glyphYUp = Boolean(options?.glyphYUp)
29
+ const shouldFilterSide = options?.filterSide !== false
30
+ group.name = 'copper-texts'
31
+ ;(texts || [])
32
+ .filter((text) =>
33
+ shouldFilterSide
34
+ ? PcbScene3dCopperTextFactory.#matchesSide(text, side)
35
+ : true
36
+ )
37
+ .forEach((text) => {
38
+ PcbScene3dCopperTextFactory.#appendTextTriangles(
39
+ positions,
40
+ text,
41
+ z,
42
+ normalizeBoardPoint,
43
+ mirrorY,
44
+ glyphYUp
45
+ )
46
+ })
47
+
48
+ if (!positions.length) {
49
+ return group
50
+ }
51
+
52
+ const geometry = new THREE.BufferGeometry()
53
+ geometry.setAttribute(
54
+ 'position',
55
+ new THREE.Float32BufferAttribute(positions, 3)
56
+ )
57
+ geometry.computeVertexNormals?.()
58
+ const filteredGeometry =
59
+ PcbScene3dCopperTextFactory.#filterDrillCutouts(
60
+ THREE,
61
+ geometry,
62
+ options?.drillCutouts
63
+ )
64
+
65
+ if (
66
+ !PcbScene3dCopperTextFactory.#hasGeometryPositions(filteredGeometry)
67
+ ) {
68
+ return group
69
+ }
70
+
71
+ const mesh = new THREE.Mesh(
72
+ filteredGeometry,
73
+ PcbScene3dCopperTextFactory.#buildMaterial(
74
+ THREE,
75
+ PcbScene3dCopperTextFactory.#resolveMaterialColor(
76
+ options?.materialColor
77
+ )
78
+ )
79
+ )
80
+ mesh.name = 'copper-text'
81
+ group.add(mesh)
82
+ return group
83
+ }
84
+
85
+ /**
86
+ * Removes text stroke triangles that overlap drill cutouts.
87
+ * @param {any} THREE
88
+ * @param {any} geometry
89
+ * @param {{ x: number, y: number }[][] | undefined} drillCutouts
90
+ * @returns {any}
91
+ */
92
+ static #filterDrillCutouts(THREE, geometry, drillCutouts) {
93
+ return Array.isArray(drillCutouts) && drillCutouts.length
94
+ ? PcbScene3dCutoutGeometryFilter.filter(
95
+ THREE,
96
+ geometry,
97
+ drillCutouts
98
+ )
99
+ : geometry
100
+ }
101
+
102
+ /**
103
+ * Returns true when a geometry still has triangle positions.
104
+ * @param {any} geometry
105
+ * @returns {boolean}
106
+ */
107
+ static #hasGeometryPositions(geometry) {
108
+ const position =
109
+ geometry?.getAttribute?.('position') ??
110
+ geometry?.attributes?.get?.('position')
111
+
112
+ return Boolean(position?.count || position?.array?.length)
113
+ }
114
+
115
+ /**
116
+ * Appends all widened stroke segments for one text primitive.
117
+ * @param {number[]} positions
118
+ * @param {object} text
119
+ * @param {number} z
120
+ * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint
121
+ * @param {boolean} mirrorY
122
+ * @param {boolean} glyphYUp
123
+ * @returns {void}
124
+ */
125
+ static #appendTextTriangles(
126
+ positions,
127
+ text,
128
+ z,
129
+ normalizeBoardPoint,
130
+ mirrorY,
131
+ glyphYUp
132
+ ) {
133
+ const width = PcbScene3dCopperTextFactory.#textStrokeWidth(text)
134
+
135
+ PcbScene3dCopperTextFactory.#textStrokes(text, glyphYUp).forEach(
136
+ (stroke) => {
137
+ for (let index = 1; index < stroke.length; index += 1) {
138
+ const start = PcbScene3dCopperTextFactory.#normalizePoint(
139
+ normalizeBoardPoint,
140
+ stroke[index - 1],
141
+ mirrorY
142
+ )
143
+ const end = PcbScene3dCopperTextFactory.#normalizePoint(
144
+ normalizeBoardPoint,
145
+ stroke[index],
146
+ mirrorY
147
+ )
148
+
149
+ PcbScene3dCopperTextFactory.#appendTrackTriangles(
150
+ positions,
151
+ start,
152
+ end,
153
+ width,
154
+ z
155
+ )
156
+ }
157
+ }
158
+ )
159
+ }
160
+
161
+ /**
162
+ * Builds all KiCad stroke-font point lists for one text primitive.
163
+ * @param {object} text
164
+ * @param {boolean} glyphYUp
165
+ * @returns {{ x: number, y: number }[][]}
166
+ */
167
+ static #textStrokes(text, glyphYUp) {
168
+ const lines = String(text?.value ?? text?.text ?? '').split('\n')
169
+ const lineSpacing = PcbScene3dCopperTextFactory.#textLineSpacing(text)
170
+
171
+ return lines.flatMap((line, index) =>
172
+ PcbScene3dCopperTextFactory.#textLineStrokes(
173
+ text,
174
+ line,
175
+ index,
176
+ lines.length,
177
+ lineSpacing,
178
+ glyphYUp
179
+ )
180
+ )
181
+ }
182
+
183
+ /**
184
+ * Builds transformed stroke point lists for one line.
185
+ * @param {object} text
186
+ * @param {string} line
187
+ * @param {number} index
188
+ * @param {number} lineCount
189
+ * @param {number} lineSpacing
190
+ * @param {boolean} glyphYUp
191
+ * @returns {{ x: number, y: number }[][]}
192
+ */
193
+ static #textLineStrokes(
194
+ text,
195
+ line,
196
+ index,
197
+ lineCount,
198
+ lineSpacing,
199
+ glyphYUp
200
+ ) {
201
+ const sizeX = PcbScene3dCopperTextFactory.#textWidth(text)
202
+ const sizeY = PcbScene3dCopperTextFactory.#textHeight(text)
203
+ const layout = PcbScene3dStrokeFont.layoutLine(line, {
204
+ x: 0,
205
+ y: 0,
206
+ sizeX,
207
+ sizeY
208
+ })
209
+ const x = PcbScene3dCopperTextFactory.#textLineX(text, layout.width)
210
+ const y = PcbScene3dCopperTextFactory.#textLineY(
211
+ text,
212
+ index,
213
+ lineCount,
214
+ lineSpacing
215
+ )
216
+
217
+ return layout.strokes.map((stroke) =>
218
+ stroke.map((point) =>
219
+ PcbScene3dCopperTextFactory.#transformTextPoint(
220
+ text,
221
+ { x: point.x + x, y: point.y + y },
222
+ glyphYUp
223
+ )
224
+ )
225
+ )
226
+ }
227
+
228
+ /**
229
+ * Resolves KiCad-like baseline spacing for multiline text.
230
+ * @param {object} text
231
+ * @returns {number}
232
+ */
233
+ static #textLineSpacing(text) {
234
+ return (
235
+ PcbScene3dCopperTextFactory.#textHeight(text) *
236
+ PcbScene3dCopperTextFactory.#TEXT_LINE_SPACING_RATIO
237
+ )
238
+ }
239
+
240
+ /**
241
+ * Resolves vertical text size.
242
+ * @param {object} text
243
+ * @returns {number}
244
+ */
245
+ static #textHeight(text) {
246
+ return PcbScene3dCopperTextFactory.#positiveTextSize(
247
+ text?.sizeX,
248
+ text?.sizeY,
249
+ text?.height
250
+ )
251
+ }
252
+
253
+ /**
254
+ * Resolves horizontal text size.
255
+ * @param {object} text
256
+ * @returns {number}
257
+ */
258
+ static #textWidth(text) {
259
+ return PcbScene3dCopperTextFactory.#positiveTextSize(
260
+ text?.sizeY,
261
+ text?.sizeX,
262
+ text?.height
263
+ )
264
+ }
265
+
266
+ /**
267
+ * Resolves a positive text metric.
268
+ * @param {number | undefined} primary
269
+ * @param {number | undefined} secondary
270
+ * @param {number | undefined} tertiary
271
+ * @returns {number}
272
+ */
273
+ static #positiveTextSize(primary, secondary, tertiary) {
274
+ return Math.max(
275
+ Number(primary) ||
276
+ Number(secondary) ||
277
+ Number(tertiary) ||
278
+ 39.37007874,
279
+ 0.001
280
+ )
281
+ }
282
+
283
+ /**
284
+ * Resolves line origin from KiCad horizontal justification.
285
+ * @param {object} text
286
+ * @param {number} lineWidth
287
+ * @returns {number}
288
+ */
289
+ static #textLineX(text, lineWidth) {
290
+ const fudge =
291
+ PcbScene3dCopperTextFactory.#textStrokeHorizontalFudge(text)
292
+
293
+ if (text?.hAlign === 'left') {
294
+ return Number(text?.x || 0) + fudge
295
+ }
296
+
297
+ if (text?.hAlign === 'right') {
298
+ return Number(text?.x || 0) - lineWidth - fudge
299
+ }
300
+
301
+ return Number(text?.x || 0) - lineWidth / 2
302
+ }
303
+
304
+ /**
305
+ * Resolves one line baseline from KiCad vertical justification.
306
+ * @param {object} text
307
+ * @param {number} index
308
+ * @param {number} lineCount
309
+ * @param {number} lineSpacing
310
+ * @returns {number}
311
+ */
312
+ static #textLineY(text, index, lineCount, lineSpacing) {
313
+ const height = PcbScene3dCopperTextFactory.#textHeight(text)
314
+ const blockHeight =
315
+ height * PcbScene3dCopperTextFactory.#FIRST_LINE_HEIGHT_RATIO +
316
+ lineSpacing * (lineCount - 1)
317
+ let baseline =
318
+ Number(text?.y || 0) +
319
+ height -
320
+ PcbScene3dCopperTextFactory.#textStrokeBaselineFudge(text)
321
+
322
+ if (text?.vAlign === 'bottom') {
323
+ baseline -= blockHeight
324
+ } else if (text?.vAlign === 'center') {
325
+ baseline -= blockHeight / 2
326
+ }
327
+
328
+ return baseline + lineSpacing * index
329
+ }
330
+
331
+ /**
332
+ * Resolves KiCad text stroke width.
333
+ * @param {object} text
334
+ * @returns {number}
335
+ */
336
+ static #textStrokeWidth(text) {
337
+ return Math.max(
338
+ Number(text?.thickness) ||
339
+ Number(text?.strokeWidth) ||
340
+ 4.7244094488,
341
+ 0.01
342
+ )
343
+ }
344
+
345
+ /**
346
+ * Resolves KiCad's small horizontal text adjustment.
347
+ * @param {object} text
348
+ * @returns {number}
349
+ */
350
+ static #textStrokeHorizontalFudge(text) {
351
+ return PcbScene3dCopperTextFactory.#textStrokeWidth(text) / 1.52
352
+ }
353
+
354
+ /**
355
+ * Resolves KiCad's small baseline text adjustment.
356
+ * @param {object} text
357
+ * @returns {number}
358
+ */
359
+ static #textStrokeBaselineFudge(text) {
360
+ return (
361
+ PcbScene3dCopperTextFactory.#textStrokeWidth(text) *
362
+ PcbScene3dCopperTextFactory.#STROKE_BASELINE_FUDGE_RATIO
363
+ )
364
+ }
365
+
366
+ /**
367
+ * Applies KiCad text rotation and mirrored text transforms.
368
+ * @param {object} text
369
+ * @param {{ x: number, y: number }} point
370
+ * @param {boolean} glyphYUp
371
+ * @returns {{ x: number, y: number }}
372
+ */
373
+ static #transformTextPoint(text, point, glyphYUp) {
374
+ const origin = {
375
+ x: Number(text?.x || 0),
376
+ y: Number(text?.y || 0)
377
+ }
378
+ const sourcePoint = glyphYUp
379
+ ? PcbScene3dCopperTextFactory.#mirrorPointY(point, origin)
380
+ : point
381
+
382
+ if (text?.mirrored) {
383
+ const rotated = PcbScene3dCopperTextFactory.#rotatePoint(
384
+ sourcePoint,
385
+ origin,
386
+ Number(text?.rotation || 0)
387
+ )
388
+ return {
389
+ x: origin.x - (rotated.x - origin.x),
390
+ y: rotated.y
391
+ }
392
+ }
393
+
394
+ return PcbScene3dCopperTextFactory.#rotatePoint(
395
+ sourcePoint,
396
+ origin,
397
+ -Number(text?.rotation || 0)
398
+ )
399
+ }
400
+
401
+ /**
402
+ * Mirrors one stroke-font point across the text anchor's local X axis.
403
+ * @param {{ x: number, y: number }} point
404
+ * @param {{ x: number, y: number }} origin
405
+ * @returns {{ x: number, y: number }}
406
+ */
407
+ static #mirrorPointY(point, origin) {
408
+ return {
409
+ x: Number(point?.x || 0),
410
+ y: origin.y - (Number(point?.y || 0) - origin.y)
411
+ }
412
+ }
413
+
414
+ /**
415
+ * Rotates one point around an origin.
416
+ * @param {{ x: number, y: number }} point
417
+ * @param {{ x: number, y: number }} origin
418
+ * @param {number} angleDeg
419
+ * @returns {{ x: number, y: number }}
420
+ */
421
+ static #rotatePoint(point, origin, angleDeg) {
422
+ const angle = (Number(angleDeg || 0) * Math.PI) / 180
423
+ const cos = Math.cos(angle)
424
+ const sin = Math.sin(angle)
425
+ const dx = Number(point?.x || 0) - origin.x
426
+ const dy = Number(point?.y || 0) - origin.y
427
+
428
+ return {
429
+ x: origin.x + dx * cos - dy * sin,
430
+ y: origin.y + dx * sin + dy * cos
431
+ }
432
+ }
433
+
434
+ /**
435
+ * Normalizes one board point and optionally mirrors it for underside use.
436
+ * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint
437
+ * @param {{ x: number, y: number }} point
438
+ * @param {boolean} mirrorY
439
+ * @returns {{ x: number, y: number }}
440
+ */
441
+ static #normalizePoint(normalizeBoardPoint, point, mirrorY) {
442
+ const normalizedPoint = normalizeBoardPoint(point.x, point.y)
443
+
444
+ return {
445
+ x: normalizedPoint.x,
446
+ y: mirrorY ? -normalizedPoint.y : normalizedPoint.y
447
+ }
448
+ }
449
+
450
+ /**
451
+ * Appends one widened stroke segment as two triangles.
452
+ * @param {number[]} positions
453
+ * @param {{ x: number, y: number }} start
454
+ * @param {{ x: number, y: number }} end
455
+ * @param {number} width
456
+ * @param {number} z
457
+ * @returns {void}
458
+ */
459
+ static #appendTrackTriangles(positions, start, end, width, z) {
460
+ PcbScene3dStrokeGeometryBuilder.appendTrack(
461
+ positions,
462
+ start,
463
+ end,
464
+ width,
465
+ z,
466
+ { minWidth: 1 }
467
+ )
468
+ }
469
+
470
+ /**
471
+ * Builds the shared copper text material.
472
+ * @param {any} THREE
473
+ * @param {number} color
474
+ * @returns {any}
475
+ */
476
+ static #buildMaterial(THREE, color) {
477
+ return new THREE.MeshStandardMaterial({
478
+ color,
479
+ roughness: 0.38,
480
+ metalness: 0.55,
481
+ side: THREE.DoubleSide
482
+ })
483
+ }
484
+
485
+ /**
486
+ * Resolves a safe RGB material color.
487
+ * @param {unknown} color
488
+ * @returns {number}
489
+ */
490
+ static #resolveMaterialColor(color) {
491
+ const numericColor = Number(color)
492
+
493
+ return Number.isInteger(numericColor) &&
494
+ numericColor >= 0 &&
495
+ numericColor <= 0xffffff
496
+ ? numericColor
497
+ : PcbScene3dCopperTextFactory.#DEFAULT_MATERIAL_COLOR
498
+ }
499
+
500
+ /**
501
+ * Normalizes a side option.
502
+ * @param {string | undefined} side
503
+ * @returns {'top' | 'bottom'}
504
+ */
505
+ static #normalizeSide(side) {
506
+ return String(side || '').toLowerCase() === 'bottom' ? 'bottom' : 'top'
507
+ }
508
+
509
+ /**
510
+ * Checks whether one text belongs to the requested copper side.
511
+ * @param {object} text
512
+ * @param {'top' | 'bottom'} side
513
+ * @returns {boolean}
514
+ */
515
+ static #matchesSide(text, side) {
516
+ const layer = String(text?.layer || '').toUpperCase()
517
+ const layerId = Number(text?.layerId ?? NaN)
518
+ const textSide = String(text?.side || '').toLowerCase()
519
+
520
+ if (layer) {
521
+ return side === 'bottom' ? layer === 'B.CU' : layer === 'F.CU'
522
+ }
523
+
524
+ if (Number.isFinite(layerId)) {
525
+ return side === 'bottom' ? layerId === 32 : layerId === 1
526
+ }
527
+
528
+ if (side === 'bottom') {
529
+ return textSide === 'back' || textSide === 'bottom'
530
+ }
531
+
532
+ return textSide === 'front' || textSide === 'top'
533
+ }
534
+ }