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,885 @@
1
+ /**
2
+ * Builds browser-font-backed text planes for Altium TrueType silkscreen labels.
3
+ */
4
+ export class PcbScene3dTrueTypeTextFactory {
5
+ static #CANVAS_SCALE = 2
6
+ static #DEFAULT_FONT_FAMILY = 'Arial'
7
+ static #DEFAULT_MATERIAL_COLOR = 0xf8f6ef
8
+ static #LINE_HEIGHT_RATIO = 1.16
9
+ static #DEFAULT_TRUETYPE_EM_SCALE = 0.895
10
+ static #MIN_CANVAS_PADDING = 2
11
+ static #TEXTURE_PADDING_RATIO = 0.14
12
+ static #loadedFontKeys = new Set()
13
+
14
+ /**
15
+ * Loads embedded Altium font payloads into the browser font set.
16
+ * @param {{ name?: string, style?: string, format?: string, mimeType?: string, payloadBase64?: string, metrics?: { weightClass?: number } }[]} embeddedFonts
17
+ * @returns {Promise<void>}
18
+ */
19
+ static async prepareEmbeddedFonts(embeddedFonts) {
20
+ if (
21
+ typeof globalThis.FontFace !== 'function' ||
22
+ !globalThis.document?.fonts?.add
23
+ ) {
24
+ return
25
+ }
26
+
27
+ const pendingLoads = (Array.isArray(embeddedFonts) ? embeddedFonts : [])
28
+ .filter((font) => font?.name && font?.payloadBase64)
29
+ .filter((font) =>
30
+ PcbScene3dTrueTypeTextFactory.#markFontForLoading(font)
31
+ )
32
+ .map((font) =>
33
+ PcbScene3dTrueTypeTextFactory.#loadEmbeddedFont(font)
34
+ )
35
+
36
+ await Promise.all(pendingLoads.map((load) => load.catch(() => null)))
37
+ }
38
+
39
+ /**
40
+ * Checks whether a normalized text primitive requests TrueType rendering.
41
+ * @param {object} text
42
+ * @returns {boolean}
43
+ */
44
+ static isTrueTypeText(text) {
45
+ const fontTypeName = String(text?.fontTypeName || '').toUpperCase()
46
+
47
+ return (
48
+ text?.isTrueType === true ||
49
+ Number(text?.fontType) === 1 ||
50
+ fontTypeName.includes('TRUETYPE')
51
+ )
52
+ }
53
+
54
+ /**
55
+ * Builds one group containing texture-backed text meshes.
56
+ * @param {any} THREE
57
+ * @param {object[]} texts
58
+ * @param {number} z
59
+ * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint
60
+ * @param {{ materialColor?: number, invertedMaterialColor?: number, mirrorY?: boolean }} [options]
61
+ * @returns {any}
62
+ */
63
+ static buildGroup(THREE, texts, z, normalizeBoardPoint, options = {}) {
64
+ const group = new THREE.Group()
65
+ group.name = 'true-type-texts'
66
+ ;(Array.isArray(texts) ? texts : [])
67
+ .filter((text) =>
68
+ PcbScene3dTrueTypeTextFactory.isTrueTypeText(text)
69
+ )
70
+ .map((text) =>
71
+ PcbScene3dTrueTypeTextFactory.#buildTextMesh(
72
+ THREE,
73
+ text,
74
+ z,
75
+ normalizeBoardPoint,
76
+ options
77
+ )
78
+ )
79
+ .filter(Boolean)
80
+ .forEach((mesh) => group.add(mesh))
81
+
82
+ return group
83
+ }
84
+
85
+ /**
86
+ * Builds one texture-backed text mesh.
87
+ * @param {any} THREE
88
+ * @param {object} text
89
+ * @param {number} z
90
+ * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint
91
+ * @param {{ materialColor?: number, invertedMaterialColor?: number, mirrorY?: boolean }} options
92
+ * @returns {any | null}
93
+ */
94
+ static #buildTextMesh(THREE, text, z, normalizeBoardPoint, options) {
95
+ if (
96
+ !THREE?.CanvasTexture ||
97
+ !THREE?.Mesh ||
98
+ !THREE?.MeshBasicMaterial ||
99
+ !THREE?.PlaneGeometry
100
+ ) {
101
+ return null
102
+ }
103
+
104
+ const mirrorY = Boolean(options?.mirrorY)
105
+ const textureInfo = PcbScene3dTrueTypeTextFactory.#buildTextureInfo(
106
+ THREE,
107
+ text,
108
+ PcbScene3dTrueTypeTextFactory.#resolveTextPaint(text, options)
109
+ )
110
+
111
+ if (!textureInfo) {
112
+ return null
113
+ }
114
+
115
+ const geometry = PcbScene3dTrueTypeTextFactory.#buildPlaneGeometry(
116
+ THREE,
117
+ textureInfo.width,
118
+ textureInfo.height,
119
+ textureInfo.anchorX,
120
+ textureInfo.anchorY,
121
+ Boolean(text?.mirrored)
122
+ )
123
+ const mesh = new THREE.Mesh(
124
+ geometry,
125
+ PcbScene3dTrueTypeTextFactory.#buildMaterial(THREE, textureInfo)
126
+ )
127
+ const position = PcbScene3dTrueTypeTextFactory.#normalizePoint(
128
+ normalizeBoardPoint,
129
+ Number(text?.x || 0),
130
+ Number(text?.y || 0),
131
+ mirrorY
132
+ )
133
+
134
+ mesh.name = 'true-type-text'
135
+ mesh.userData = mesh.userData || {}
136
+ mesh.userData.scene3dViewCompensation = true
137
+ mesh.userData.scene3dViewCompensationAxes = {
138
+ x: false,
139
+ y: !mirrorY,
140
+ z: false
141
+ }
142
+ if (!mirrorY) {
143
+ mesh.userData.scene3dViewCompensationAxisSources = {
144
+ y: 'board-mirror'
145
+ }
146
+ }
147
+ mesh.position.set(position.x, position.y, z)
148
+ mesh.rotation.z = (Number(text?.rotation || 0) * Math.PI) / 180
149
+
150
+ return mesh
151
+ }
152
+
153
+ /**
154
+ * Builds plane geometry anchored at the text insertion point.
155
+ * @param {any} THREE
156
+ * @param {number} width
157
+ * @param {number} height
158
+ * @param {number} anchorX
159
+ * @param {number} anchorY
160
+ * @param {boolean} mirrored
161
+ * @returns {any}
162
+ */
163
+ static #buildPlaneGeometry(
164
+ THREE,
165
+ width,
166
+ height,
167
+ anchorX,
168
+ anchorY,
169
+ mirrored
170
+ ) {
171
+ const geometry = new THREE.PlaneGeometry(width, height)
172
+
173
+ geometry.translate?.(width / 2 - anchorX, anchorY - height / 2, 0)
174
+ if (mirrored) {
175
+ geometry.scale?.(-1, 1, 1)
176
+ }
177
+
178
+ return geometry
179
+ }
180
+
181
+ /**
182
+ * Builds a transparent canvas texture and its board-space dimensions.
183
+ * @param {any} THREE
184
+ * @param {object} text
185
+ * @param {{ color: number, textColor: number, knockout: boolean }} paint
186
+ * @returns {{ texture: any, width: number, height: number, baselineX: number, baselineY: number, anchorX: number, anchorY: number } | null}
187
+ */
188
+ static #buildTextureInfo(THREE, text, paint) {
189
+ const canvas = PcbScene3dTrueTypeTextFactory.#createCanvas()
190
+ const context = canvas?.getContext?.('2d')
191
+
192
+ if (!canvas || !context) {
193
+ return null
194
+ }
195
+
196
+ const lines = PcbScene3dTrueTypeTextFactory.#textLines(text)
197
+ const fontSize = PcbScene3dTrueTypeTextFactory.#textHeight(text)
198
+ const font = PcbScene3dTrueTypeTextFactory.#buildCanvasFont(
199
+ text,
200
+ fontSize
201
+ )
202
+ const metrics = PcbScene3dTrueTypeTextFactory.#measureLines(
203
+ context,
204
+ lines,
205
+ font,
206
+ fontSize
207
+ )
208
+ const padding = PcbScene3dTrueTypeTextFactory.#resolveTexturePadding(
209
+ text,
210
+ fontSize,
211
+ paint.knockout
212
+ )
213
+ const layout = PcbScene3dTrueTypeTextFactory.#resolveTextureLayout(
214
+ text,
215
+ metrics,
216
+ padding,
217
+ paint.knockout
218
+ )
219
+
220
+ PcbScene3dTrueTypeTextFactory.#sizeCanvas(
221
+ canvas,
222
+ layout.width,
223
+ layout.height
224
+ )
225
+ PcbScene3dTrueTypeTextFactory.#drawText(
226
+ canvas,
227
+ lines,
228
+ font,
229
+ paint,
230
+ metrics,
231
+ layout.baselineX,
232
+ layout.baselineY,
233
+ layout.width,
234
+ layout.height
235
+ )
236
+
237
+ const texture = new THREE.CanvasTexture(canvas)
238
+ texture.needsUpdate = true
239
+ if ('colorSpace' in texture && THREE.SRGBColorSpace) {
240
+ texture.colorSpace = THREE.SRGBColorSpace
241
+ }
242
+
243
+ return {
244
+ texture,
245
+ width: layout.width,
246
+ height: layout.height,
247
+ baselineX: layout.baselineX,
248
+ baselineY: layout.baselineY,
249
+ anchorX: layout.anchorX,
250
+ anchorY: layout.anchorY
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Creates a canvas when the runtime has a browser document.
256
+ * @returns {HTMLCanvasElement | null}
257
+ */
258
+ static #createCanvas() {
259
+ return globalThis.document?.createElement?.('canvas') || null
260
+ }
261
+
262
+ /**
263
+ * Splits one text primitive into drawable lines.
264
+ * @param {object} text
265
+ * @returns {string[]}
266
+ */
267
+ static #textLines(text) {
268
+ const value = String(text?.value ?? text?.text ?? '')
269
+ const lines = value.split(/\r?\n/u)
270
+
271
+ return lines.length ? lines : ['']
272
+ }
273
+
274
+ /**
275
+ * Resolves the requested text height in mil.
276
+ * @param {object} text
277
+ * @returns {number}
278
+ */
279
+ static #textHeight(text) {
280
+ const height = Math.max(
281
+ Number(text?.sizeX) || Number(text?.height) || Number(text?.sizeY),
282
+ 1
283
+ )
284
+
285
+ return height * PcbScene3dTrueTypeTextFactory.#fontScale(text)
286
+ }
287
+
288
+ /**
289
+ * Resolves the imported TrueType scale used for browser outline fonts.
290
+ * @param {object} text
291
+ * @returns {number}
292
+ */
293
+ static #fontScale(text) {
294
+ if (Number.isFinite(Number(text?.trueTypeFontScale))) {
295
+ return Math.max(Number(text.trueTypeFontScale), 0.01)
296
+ }
297
+
298
+ if (Number.isFinite(Number(text?.fontMetrics?.emScaleFromPcbHeight))) {
299
+ return Math.max(Number(text.fontMetrics.emScaleFromPcbHeight), 0.01)
300
+ }
301
+
302
+ return PcbScene3dTrueTypeTextFactory.#DEFAULT_TRUETYPE_EM_SCALE
303
+ }
304
+
305
+ /**
306
+ * Marks a font as loaded or pending so repeated renders do not re-register it.
307
+ * @param {{ name?: string, style?: string, payloadBase64?: string, metrics?: { weightClass?: number } }} font
308
+ * @returns {boolean}
309
+ */
310
+ static #markFontForLoading(font) {
311
+ const key = [
312
+ PcbScene3dTrueTypeTextFactory.#cleanFontFamily(font?.name),
313
+ PcbScene3dTrueTypeTextFactory.#fontStyleForFont(font),
314
+ PcbScene3dTrueTypeTextFactory.#fontWeightForFont(font),
315
+ String(font?.payloadBase64 || '').length
316
+ ].join('\0')
317
+
318
+ if (PcbScene3dTrueTypeTextFactory.#loadedFontKeys.has(key)) {
319
+ return false
320
+ }
321
+
322
+ PcbScene3dTrueTypeTextFactory.#loadedFontKeys.add(key)
323
+ return true
324
+ }
325
+
326
+ /**
327
+ * Loads one embedded font face and registers it with document.fonts.
328
+ * @param {{ name?: string, style?: string, format?: string, mimeType?: string, payloadBase64?: string, metrics?: { weightClass?: number } }} font
329
+ * @returns {Promise<void>}
330
+ */
331
+ static async #loadEmbeddedFont(font) {
332
+ const face = new globalThis.FontFace(
333
+ PcbScene3dTrueTypeTextFactory.#cleanFontFamily(font?.name),
334
+ PcbScene3dTrueTypeTextFactory.#fontFaceSource(font),
335
+ {
336
+ style: PcbScene3dTrueTypeTextFactory.#fontStyleForFont(font),
337
+ weight: String(
338
+ PcbScene3dTrueTypeTextFactory.#fontWeightForFont(font)
339
+ )
340
+ }
341
+ )
342
+ const loadedFace = await face.load()
343
+
344
+ globalThis.document.fonts.add(loadedFace)
345
+ }
346
+
347
+ /**
348
+ * Builds the CSS source string for one embedded font payload.
349
+ * @param {{ format?: string, mimeType?: string, payloadBase64?: string }} font
350
+ * @returns {string}
351
+ */
352
+ static #fontFaceSource(font) {
353
+ return (
354
+ 'url(data:' +
355
+ PcbScene3dTrueTypeTextFactory.#fontMimeType(font) +
356
+ ';base64,' +
357
+ PcbScene3dTrueTypeTextFactory.#sanitizeBase64(font?.payloadBase64) +
358
+ ") format('" +
359
+ PcbScene3dTrueTypeTextFactory.#fontFormat(font) +
360
+ "')"
361
+ )
362
+ }
363
+
364
+ /**
365
+ * Resolves a CSS font-style value from embedded font metadata.
366
+ * @param {{ style?: string }} font
367
+ * @returns {'normal' | 'italic'}
368
+ */
369
+ static #fontStyleForFont(font) {
370
+ return /italic|oblique/iu.test(String(font?.style || ''))
371
+ ? 'italic'
372
+ : 'normal'
373
+ }
374
+
375
+ /**
376
+ * Resolves a CSS font-weight value from embedded font metadata.
377
+ * @param {{ style?: string, metrics?: { weightClass?: number } }} font
378
+ * @returns {number}
379
+ */
380
+ static #fontWeightForFont(font) {
381
+ if (Number(font?.metrics?.weightClass) >= 100) {
382
+ return Number(font.metrics.weightClass)
383
+ }
384
+
385
+ return /bold/iu.test(String(font?.style || '')) ? 700 : 400
386
+ }
387
+
388
+ /**
389
+ * Resolves a browser font MIME type.
390
+ * @param {{ mimeType?: string, format?: string }} font
391
+ * @returns {string}
392
+ */
393
+ static #fontMimeType(font) {
394
+ if (font?.mimeType) {
395
+ return PcbScene3dTrueTypeTextFactory.#escapeCssUrlToken(
396
+ font.mimeType
397
+ )
398
+ }
399
+
400
+ return font?.format === 'opentype' ? 'font/otf' : 'font/ttf'
401
+ }
402
+
403
+ /**
404
+ * Resolves a CSS font format label.
405
+ * @param {{ format?: string }} font
406
+ * @returns {'opentype' | 'truetype'}
407
+ */
408
+ static #fontFormat(font) {
409
+ return font?.format === 'opentype' ? 'opentype' : 'truetype'
410
+ }
411
+
412
+ /**
413
+ * Keeps a base64 font payload constrained to data-URI-safe characters.
414
+ * @param {string | undefined} value
415
+ * @returns {string}
416
+ */
417
+ static #sanitizeBase64(value) {
418
+ return String(value || '').replace(/[^A-Za-z0-9+/=]/gu, '')
419
+ }
420
+
421
+ /**
422
+ * Escapes a short CSS URL token.
423
+ * @param {string | undefined} value
424
+ * @returns {string}
425
+ */
426
+ static #escapeCssUrlToken(value) {
427
+ return String(value || '').replace(/[^A-Za-z0-9./+-]/gu, '')
428
+ }
429
+
430
+ /**
431
+ * Builds the CSS font string used for canvas rendering.
432
+ * @param {object} text
433
+ * @param {number} fontSize
434
+ * @returns {string}
435
+ */
436
+ static #buildCanvasFont(text, fontSize) {
437
+ const weight =
438
+ text?.isBold || Number(text?.fontWeight) >= 600 ? '700' : '400'
439
+ const style = text?.isItalic ? 'italic' : 'normal'
440
+ const family = PcbScene3dTrueTypeTextFactory.#buildCanvasFontFamily(
441
+ text?.fontFamily || text?.fontName
442
+ )
443
+
444
+ return `${style} ${weight} ${fontSize}px ${family}`
445
+ }
446
+
447
+ /**
448
+ * Resolves transparent texture padding around one text run.
449
+ * @param {object} text
450
+ * @param {number} fontSize
451
+ * @param {boolean} knockout
452
+ * @returns {number}
453
+ */
454
+ static #resolveTexturePadding(text, fontSize, knockout) {
455
+ const basePadding = Math.max(
456
+ fontSize * PcbScene3dTrueTypeTextFactory.#TEXTURE_PADDING_RATIO,
457
+ PcbScene3dTrueTypeTextFactory.#MIN_CANVAS_PADDING
458
+ )
459
+
460
+ if (!knockout) {
461
+ return basePadding
462
+ }
463
+
464
+ const marginBorderWidth = Number(text?.marginBorderWidth)
465
+
466
+ return Number.isFinite(marginBorderWidth) && marginBorderWidth >= 0
467
+ ? marginBorderWidth
468
+ : basePadding
469
+ }
470
+
471
+ /**
472
+ * Builds a CSS family stack for one Altium TrueType font name.
473
+ * @param {unknown} family
474
+ * @returns {string}
475
+ */
476
+ static #buildCanvasFontFamily(family) {
477
+ const cleaned = PcbScene3dTrueTypeTextFactory.#cleanFontFamily(family)
478
+ const quoted = PcbScene3dTrueTypeTextFactory.#quoteFontFamily(cleaned)
479
+
480
+ if (PcbScene3dTrueTypeTextFactory.#isMonospaceFamily(cleaned)) {
481
+ return [
482
+ quoted,
483
+ '"Menlo"',
484
+ '"Monaco"',
485
+ '"Liberation Mono"',
486
+ '"Courier New"',
487
+ 'monospace'
488
+ ].join(', ')
489
+ }
490
+
491
+ if (PcbScene3dTrueTypeTextFactory.#isArialFamily(cleaned)) {
492
+ return [
493
+ quoted,
494
+ '"Helvetica Neue"',
495
+ 'Helvetica',
496
+ 'Arial',
497
+ 'sans-serif'
498
+ ].join(', ')
499
+ }
500
+
501
+ return [quoted, 'Arial', 'sans-serif'].join(', ')
502
+ }
503
+
504
+ /**
505
+ * Removes Altium fixed-field padding from one font family name.
506
+ * @param {unknown} family
507
+ * @returns {string}
508
+ */
509
+ static #cleanFontFamily(family) {
510
+ const raw = String(
511
+ family || PcbScene3dTrueTypeTextFactory.#DEFAULT_FONT_FAMILY
512
+ )
513
+ const cleaned = raw.split('\0')[0]?.trim() || ''
514
+
515
+ return cleaned || PcbScene3dTrueTypeTextFactory.#DEFAULT_FONT_FAMILY
516
+ }
517
+
518
+ /**
519
+ * Quotes one CSS font family safely.
520
+ * @param {string} family
521
+ * @returns {string}
522
+ */
523
+ static #quoteFontFamily(family) {
524
+ return `"${family.replace(/["\\]/gu, '\\$&')}"`
525
+ }
526
+
527
+ /**
528
+ * Checks whether an imported family is Arial-like.
529
+ * @param {unknown} family
530
+ * @returns {boolean}
531
+ */
532
+ static #isArialFamily(family) {
533
+ return /^arial(?:\b|$)/iu.test(
534
+ PcbScene3dTrueTypeTextFactory.#cleanFontFamily(family)
535
+ )
536
+ }
537
+
538
+ /**
539
+ * Checks whether an imported family is a common PCB monospace font.
540
+ * @param {unknown} family
541
+ * @returns {boolean}
542
+ */
543
+ static #isMonospaceFamily(family) {
544
+ return /^(consolas|courier|courier new|menlo|monaco|liberation mono)$/iu.test(
545
+ PcbScene3dTrueTypeTextFactory.#cleanFontFamily(family)
546
+ )
547
+ }
548
+
549
+ /**
550
+ * Measures text lines with browser font metrics and stable fallbacks.
551
+ * @param {CanvasRenderingContext2D} context
552
+ * @param {string[]} lines
553
+ * @param {string} font
554
+ * @param {number} fontSize
555
+ * @returns {{ width: number, height: number, ascent: number, descent: number, lineHeight: number }}
556
+ */
557
+ static #measureLines(context, lines, font, fontSize) {
558
+ context.font = font
559
+
560
+ const measured = lines.map((line) => context.measureText(line || ' '))
561
+ const ascent = PcbScene3dTrueTypeTextFactory.#resolveMeasuredExtent(
562
+ measured,
563
+ 'actualBoundingBoxAscent',
564
+ fontSize * 0.82
565
+ )
566
+ const descent = PcbScene3dTrueTypeTextFactory.#resolveMeasuredExtent(
567
+ measured,
568
+ 'actualBoundingBoxDescent',
569
+ fontSize * 0.18
570
+ )
571
+ const glyphHeight = Math.max(ascent + descent, 1)
572
+ const lineHeight = Math.max(
573
+ glyphHeight * PcbScene3dTrueTypeTextFactory.#LINE_HEIGHT_RATIO,
574
+ glyphHeight
575
+ )
576
+
577
+ return {
578
+ width: Math.max(...measured.map((metric) => Number(metric.width))),
579
+ height: glyphHeight + lineHeight * (lines.length - 1),
580
+ ascent,
581
+ descent,
582
+ lineHeight
583
+ }
584
+ }
585
+
586
+ /** @param {TextMetrics[]} measured @param {'actualBoundingBoxAscent' | 'actualBoundingBoxDescent'} field @param {number} fallback @returns {number} */
587
+ static #resolveMeasuredExtent(measured, field, fallback) {
588
+ const values = measured
589
+ .map((metric) => Number(metric?.[field]))
590
+ .filter((value) => Number.isFinite(value) && value > 0)
591
+
592
+ return values.length ? Math.max(...values) : fallback
593
+ }
594
+
595
+ /** @param {object} text @param {{ width: number, height: number, ascent: number }} metrics @param {number} padding @param {boolean} knockout @returns {{ width: number, height: number, baselineX: number, baselineY: number, anchorX: number, anchorY: number }} */
596
+ static #resolveTextureLayout(text, metrics, padding, knockout) {
597
+ const authoredRectangle =
598
+ PcbScene3dTrueTypeTextFactory.#resolveAuthoredRectangle(
599
+ text,
600
+ knockout
601
+ )
602
+ const width =
603
+ authoredRectangle?.width || Math.max(metrics.width, 1) + padding * 2
604
+ const height =
605
+ authoredRectangle?.height ||
606
+ Math.max(metrics.height, 1) + padding * 2
607
+
608
+ if (!authoredRectangle) {
609
+ return {
610
+ width,
611
+ height,
612
+ baselineX: padding,
613
+ baselineY: padding + Number(metrics.ascent || 0),
614
+ anchorX: padding,
615
+ anchorY: padding + Number(metrics.ascent || 0)
616
+ }
617
+ }
618
+ const baselineX = PcbScene3dTrueTypeTextFactory.#authoredBaselineX(
619
+ text,
620
+ metrics,
621
+ authoredRectangle.width,
622
+ padding
623
+ )
624
+ const baselineY = PcbScene3dTrueTypeTextFactory.#authoredBaselineY(
625
+ text,
626
+ metrics,
627
+ authoredRectangle.height,
628
+ padding
629
+ )
630
+
631
+ return {
632
+ width,
633
+ height,
634
+ baselineX,
635
+ baselineY,
636
+ anchorX: baselineX,
637
+ anchorY: baselineY
638
+ }
639
+ }
640
+
641
+ /**
642
+ * Resolves authored inverted rectangle dimensions from source metadata.
643
+ * @param {object} text
644
+ * @param {boolean} knockout
645
+ * @returns {{ width: number, height: number } | null}
646
+ */
647
+ static #resolveAuthoredRectangle(text, knockout) {
648
+ const width = Number(text?.textboxRectWidth)
649
+ const height = Number(text?.textboxRectHeight)
650
+
651
+ if (
652
+ !knockout ||
653
+ !Boolean(text?.useInvertedRectangle) ||
654
+ !Number.isFinite(width) ||
655
+ !Number.isFinite(height) ||
656
+ width <= 0 ||
657
+ height <= 0
658
+ ) {
659
+ return null
660
+ }
661
+
662
+ return { width, height }
663
+ }
664
+
665
+ /**
666
+ * Resolves horizontal baseline inside an authored rectangle.
667
+ * @param {object} text
668
+ * @param {{ width: number }} metrics
669
+ * @param {number} width
670
+ * @param {number} padding
671
+ * @returns {number}
672
+ */
673
+ static #authoredBaselineX(text, metrics, width, padding) {
674
+ const column = PcbScene3dTrueTypeTextFactory.#justificationColumn(text)
675
+ const remainingWidth = Math.max(width - Number(metrics.width || 0), 0)
676
+
677
+ if (column === 1) {
678
+ return remainingWidth / 2
679
+ }
680
+
681
+ if (column === 2) {
682
+ return remainingWidth
683
+ }
684
+
685
+ return Math.min(padding, remainingWidth)
686
+ }
687
+
688
+ /**
689
+ * Resolves vertical baseline inside an authored rectangle.
690
+ * @param {object} text
691
+ * @param {{ height: number, ascent: number }} metrics
692
+ * @param {number} height
693
+ * @param {number} padding
694
+ * @returns {number}
695
+ */
696
+ static #authoredBaselineY(text, metrics, height, padding) {
697
+ const row = PcbScene3dTrueTypeTextFactory.#justificationRow(text)
698
+ const remainingHeight = Math.max(
699
+ height - Number(metrics.height || 0),
700
+ 0
701
+ )
702
+
703
+ if (row === 1) {
704
+ return remainingHeight / 2 + Number(metrics.ascent || 0)
705
+ }
706
+
707
+ if (row === 2) {
708
+ return remainingHeight + Number(metrics.ascent || 0)
709
+ }
710
+
711
+ return Math.min(padding, remainingHeight) + Number(metrics.ascent || 0)
712
+ }
713
+
714
+ /** @param {object} text @returns {0 | 1 | 2 | null} */
715
+ static #justificationColumn(text) {
716
+ const justification = Number(text?.textboxRectJustification)
717
+
718
+ return Number.isInteger(justification) && justification > 0
719
+ ? Math.max(0, Math.min(2, Math.floor((justification - 1) / 3)))
720
+ : null
721
+ }
722
+
723
+ /** @param {object} text @returns {0 | 1 | 2 | null} */
724
+ static #justificationRow(text) {
725
+ const justification = Number(text?.textboxRectJustification)
726
+
727
+ return Number.isInteger(justification) && justification > 0
728
+ ? (justification - 1) % 3
729
+ : null
730
+ }
731
+
732
+ /**
733
+ * Resizes one canvas for high-DPI text rasterization.
734
+ * @param {HTMLCanvasElement} canvas
735
+ * @param {number} width
736
+ * @param {number} height
737
+ * @returns {void}
738
+ */
739
+ static #sizeCanvas(canvas, width, height) {
740
+ const scale = PcbScene3dTrueTypeTextFactory.#CANVAS_SCALE
741
+
742
+ canvas.width = Math.ceil(width * scale)
743
+ canvas.height = Math.ceil(height * scale)
744
+ }
745
+
746
+ /**
747
+ * Draws text into one transparent canvas texture.
748
+ * @param {HTMLCanvasElement} canvas
749
+ * @param {string[]} lines
750
+ * @param {string} font
751
+ * @param {{ color: number, textColor: number, knockout: boolean }} paint
752
+ * @param {{ ascent: number, lineHeight: number }} metrics
753
+ * @param {number} baselineX
754
+ * @param {number} baselineY
755
+ * @param {number} width
756
+ * @param {number} height
757
+ * @returns {void}
758
+ */
759
+ static #drawText(
760
+ canvas,
761
+ lines,
762
+ font,
763
+ paint,
764
+ metrics,
765
+ baselineX,
766
+ baselineY,
767
+ width,
768
+ height
769
+ ) {
770
+ const context = canvas.getContext('2d')
771
+ const scale = PcbScene3dTrueTypeTextFactory.#CANVAS_SCALE
772
+
773
+ context.clearRect(0, 0, canvas.width, canvas.height)
774
+ context.scale?.(scale, scale)
775
+ context.font = font
776
+ context.textAlign = 'left'
777
+ context.textBaseline = 'alphabetic'
778
+
779
+ if (paint.knockout) {
780
+ context.fillStyle = PcbScene3dTrueTypeTextFactory.#colorToCss(
781
+ paint.color
782
+ )
783
+ context.fillRect?.(0, 0, width, height)
784
+ context.globalCompositeOperation = 'destination-out'
785
+ context.fillStyle = '#000000'
786
+ } else {
787
+ context.fillStyle = PcbScene3dTrueTypeTextFactory.#colorToCss(
788
+ paint.textColor
789
+ )
790
+ }
791
+
792
+ lines.forEach((line, index) => {
793
+ context.fillText(
794
+ line,
795
+ baselineX,
796
+ baselineY + metrics.lineHeight * index
797
+ )
798
+ })
799
+ context.globalCompositeOperation = 'source-over'
800
+ }
801
+
802
+ /**
803
+ * Builds a transparent unlit material for one text texture.
804
+ * @param {any} THREE
805
+ * @param {{ texture: any }} textureInfo
806
+ * @returns {any}
807
+ */
808
+ static #buildMaterial(THREE, textureInfo) {
809
+ return new THREE.MeshBasicMaterial({
810
+ map: textureInfo.texture,
811
+ transparent: true,
812
+ opacity: 0.96,
813
+ alphaTest: 0.02,
814
+ depthWrite: false,
815
+ toneMapped: false,
816
+ fog: false,
817
+ side: THREE.DoubleSide
818
+ })
819
+ }
820
+
821
+ /**
822
+ * Normalizes one board point and optionally mirrors it for underside use.
823
+ * @param {(x: number, y: number) => { x: number, y: number }} normalizeBoardPoint
824
+ * @param {number} x
825
+ * @param {number} y
826
+ * @param {boolean} mirrorY
827
+ * @returns {{ x: number, y: number }}
828
+ */
829
+ static #normalizePoint(normalizeBoardPoint, x, y, mirrorY) {
830
+ const point = normalizeBoardPoint(x, y)
831
+
832
+ return {
833
+ x: point.x,
834
+ y: mirrorY ? -point.y : point.y
835
+ }
836
+ }
837
+
838
+ /**
839
+ * Converts a numeric RGB color into CSS hex syntax.
840
+ * @param {number} color
841
+ * @returns {string}
842
+ */
843
+ static #colorToCss(color) {
844
+ return `#${Number(color).toString(16).padStart(6, '0')}`
845
+ }
846
+
847
+ /**
848
+ * Resolves a safe RGB material color.
849
+ * @param {unknown} color
850
+ * @returns {number}
851
+ */
852
+ static #resolveMaterialColor(color) {
853
+ const numericColor = Number(color)
854
+
855
+ return Number.isInteger(numericColor) &&
856
+ numericColor >= 0 &&
857
+ numericColor <= 0xffffff
858
+ ? numericColor
859
+ : PcbScene3dTrueTypeTextFactory.#DEFAULT_MATERIAL_COLOR
860
+ }
861
+
862
+ /**
863
+ * Resolves how one TrueType text primitive should be painted.
864
+ * @param {object} text
865
+ * @param {{ materialColor?: number, invertedMaterialColor?: number }} options
866
+ * @returns {{ color: number, textColor: number, knockout: boolean }}
867
+ */
868
+ static #resolveTextPaint(text, options) {
869
+ const materialColor =
870
+ PcbScene3dTrueTypeTextFactory.#resolveMaterialColor(
871
+ options?.materialColor
872
+ )
873
+ const invertedMaterialColor =
874
+ PcbScene3dTrueTypeTextFactory.#resolveMaterialColor(
875
+ options?.invertedMaterialColor
876
+ )
877
+ const usesInvertedText = Boolean(text?.isInverted)
878
+
879
+ return {
880
+ color: materialColor,
881
+ textColor: usesInvertedText ? invertedMaterialColor : materialColor,
882
+ knockout: usesInvertedText
883
+ }
884
+ }
885
+ }