meshwriter-cudu 3.0.0

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 (81) hide show
  1. package/LICENSE.md +11 -0
  2. package/README.md +349 -0
  3. package/dist/fonts/comic-sans.d.ts +1105 -0
  4. package/dist/fonts/helvetica.d.ts +1208 -0
  5. package/dist/fonts/hiruko-pro.d.ts +658 -0
  6. package/dist/fonts/jura.d.ts +750 -0
  7. package/dist/fonts/webgl-dings.d.ts +109 -0
  8. package/dist/index.d.ts +295 -0
  9. package/dist/meshwriter.cjs.js +2645 -0
  10. package/dist/meshwriter.cjs.js.map +1 -0
  11. package/dist/meshwriter.esm.js +2606 -0
  12. package/dist/meshwriter.esm.js.map +1 -0
  13. package/dist/meshwriter.min.js +2 -0
  14. package/dist/meshwriter.min.js.map +1 -0
  15. package/dist/meshwriter.umd.js +7146 -0
  16. package/dist/meshwriter.umd.js.map +1 -0
  17. package/dist/src/babylonImports.d.ts +11 -0
  18. package/dist/src/bakedFontLoader.d.ts +43 -0
  19. package/dist/src/colorContrast.d.ts +117 -0
  20. package/dist/src/csg.d.ts +55 -0
  21. package/dist/src/curves.d.ts +20 -0
  22. package/dist/src/fogPlugin.d.ts +32 -0
  23. package/dist/src/fontCompression.d.ts +12 -0
  24. package/dist/src/fontRegistry.d.ts +54 -0
  25. package/dist/src/index.d.ts +47 -0
  26. package/dist/src/letterMesh.d.ts +46 -0
  27. package/dist/src/material.d.ts +34 -0
  28. package/dist/src/meshSplitter.d.ts +10 -0
  29. package/dist/src/meshwriter.d.ts +46 -0
  30. package/dist/src/sps.d.ts +27 -0
  31. package/dist/src/umd-entry.d.ts +3 -0
  32. package/dist/src/utils.d.ts +12 -0
  33. package/dist/src/variableFontCache.d.ts +56 -0
  34. package/dist/src/variableFontConverter.d.ts +21 -0
  35. package/dist/src/variableFontLoader.d.ts +99 -0
  36. package/fonts/Figure1.png +0 -0
  37. package/fonts/LICENSE-OFL.txt +93 -0
  38. package/fonts/README.md +174 -0
  39. package/fonts/atkinson-hyperlegible-next.d.ts +8 -0
  40. package/fonts/atkinson-hyperlegible-next.js +6576 -0
  41. package/fonts/atkinson-hyperlegible.js +3668 -0
  42. package/fonts/baked/atkinson-hyperlegible-next-200.json +1 -0
  43. package/fonts/baked/atkinson-hyperlegible-next-250.json +1 -0
  44. package/fonts/baked/atkinson-hyperlegible-next-300.json +1 -0
  45. package/fonts/baked/atkinson-hyperlegible-next-350.json +1 -0
  46. package/fonts/baked/atkinson-hyperlegible-next-400.json +1 -0
  47. package/fonts/baked/atkinson-hyperlegible-next-450.json +1 -0
  48. package/fonts/baked/atkinson-hyperlegible-next-500.json +1 -0
  49. package/fonts/baked/atkinson-hyperlegible-next-550.json +1 -0
  50. package/fonts/baked/atkinson-hyperlegible-next-600.json +1 -0
  51. package/fonts/baked/atkinson-hyperlegible-next-650.json +1 -0
  52. package/fonts/baked/atkinson-hyperlegible-next-700.json +1 -0
  53. package/fonts/baked/atkinson-hyperlegible-next-750.json +1 -0
  54. package/fonts/baked/atkinson-hyperlegible-next-800.json +1 -0
  55. package/fonts/baked/manifest.json +41 -0
  56. package/fonts/comic-sans.js +1532 -0
  57. package/fonts/helvetica.js +1695 -0
  58. package/fonts/hiruko-pro.js +838 -0
  59. package/fonts/index.js +16 -0
  60. package/fonts/jura.js +994 -0
  61. package/fonts/variable/atkinson-hyperlegible-next-variable.ttf +0 -0
  62. package/fonts/webgl-dings.js +113 -0
  63. package/package.json +76 -0
  64. package/src/babylonImports.js +29 -0
  65. package/src/bakedFontLoader.js +125 -0
  66. package/src/colorContrast.js +528 -0
  67. package/src/csg.js +220 -0
  68. package/src/curves.js +67 -0
  69. package/src/fogPlugin.js +98 -0
  70. package/src/fontCompression.js +141 -0
  71. package/src/fontRegistry.js +98 -0
  72. package/src/globals.d.ts +20 -0
  73. package/src/index.js +136 -0
  74. package/src/letterMesh.js +417 -0
  75. package/src/material.js +103 -0
  76. package/src/meshSplitter.js +337 -0
  77. package/src/meshwriter.js +303 -0
  78. package/src/sps.js +106 -0
  79. package/src/types.d.ts +551 -0
  80. package/src/umd-entry.js +130 -0
  81. package/src/utils.js +57 -0
@@ -0,0 +1,2606 @@
1
+ import { Vector2, Vector3 } from '@babylonjs/core/Maths/math.vector';
2
+ import { Color3 } from '@babylonjs/core/Maths/math.color';
3
+ import { Path2, Curve3 } from '@babylonjs/core/Maths/math.path';
4
+ import { Mesh } from '@babylonjs/core/Meshes/mesh';
5
+ import { VertexData } from '@babylonjs/core/Meshes/mesh.vertexData';
6
+ import { PolygonMeshBuilder } from '@babylonjs/core/Meshes/polygonMesh';
7
+ import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial';
8
+ import { MaterialPluginBase } from '@babylonjs/core/Materials/materialPluginBase';
9
+ import { SolidParticleSystem } from '@babylonjs/core/Particles/solidParticleSystem';
10
+ import { CSG as CSG$1 } from '@babylonjs/core/Meshes/csg';
11
+ import { IsCSG2Ready as IsCSG2Ready$1, InitializeCSG2Async as InitializeCSG2Async$1, CSG2 as CSG2$1 } from '@babylonjs/core/Meshes/csg2';
12
+ import earcutModule from 'earcut';
13
+
14
+ /**
15
+ * MeshWriter Utility Functions
16
+ * Pure helper functions with no external dependencies
17
+ */
18
+
19
+ const floor$2 = Math.floor;
20
+
21
+ // Type checking functions
22
+ function isPositiveNumber(mn) {
23
+ return typeof mn === "number" && !isNaN(mn) ? 0 < mn : false;
24
+ }
25
+
26
+ function isNumber(mn) {
27
+ return typeof mn === "number";
28
+ }
29
+
30
+ function isBoolean(mn) {
31
+ return typeof mn === "boolean";
32
+ }
33
+
34
+ function isAmplitude(ma) {
35
+ return typeof ma === "number" && !isNaN(ma) ? 0 <= ma && ma <= 1 : false;
36
+ }
37
+
38
+ function isObject(mo) {
39
+ return mo != null && typeof mo === "object" || typeof mo === "function";
40
+ }
41
+
42
+ function isPromiseLike(mo) {
43
+ return isObject(mo) && typeof mo.then === "function";
44
+ }
45
+
46
+ function isArray(ma) {
47
+ return ma != null && typeof ma === "object" && ma.constructor === Array;
48
+ }
49
+
50
+ function isString(ms) {
51
+ return typeof ms === "string" ? ms.length > 0 : false;
52
+ }
53
+
54
+ function isRelativeLength(l) {
55
+ return l === 3 || l === 5 || l === 7;
56
+ }
57
+
58
+ // Math utilities
59
+ function round(n) {
60
+ return floor$2(0.3 + n * 1000000) / 1000000;
61
+ }
62
+
63
+ function weeid() {
64
+ return Math.floor(Math.random() * 1000000);
65
+ }
66
+
67
+ // Option handling
68
+ function setOption(opts, field, tst, defalt) {
69
+ return tst(opts[field]) ? opts[field] : defalt;
70
+ }
71
+
72
+ /**
73
+ * MeshWriter Font Compression/Decompression
74
+ * Base-128 encoding for font data compression (~50% size reduction)
75
+ */
76
+
77
+
78
+ const floor$1 = Math.floor;
79
+
80
+ // Encoding arrays (initialized once at module load)
81
+ let b128back;
82
+ let b128digits;
83
+
84
+ /**
85
+ * Initialize encoding arrays for base-128 conversion
86
+ * Called once when module loads
87
+ */
88
+ function prepArray() {
89
+ let pntr = -1;
90
+ let n;
91
+ b128back = new Uint8Array(256);
92
+ b128digits = new Array(128);
93
+
94
+ while (160 > pntr++) {
95
+ if (pntr < 128) {
96
+ n = fr128to256(pntr);
97
+ b128digits[pntr] = String.fromCharCode(n);
98
+ b128back[n] = pntr;
99
+ } else {
100
+ if (pntr === 128) {
101
+ b128back[32] = pntr;
102
+ } else {
103
+ b128back[pntr + 71] = pntr;
104
+ }
105
+ }
106
+ }
107
+
108
+ function fr128to256(n) {
109
+ if (n < 92) {
110
+ return n < 58 ? n < 6 ? n + 33 : n + 34 : n + 35;
111
+ } else {
112
+ return n + 69;
113
+ }
114
+ }
115
+ }
116
+
117
+ // Initialize on module load
118
+ prepArray();
119
+
120
+ /**
121
+ * Convert base-128 encoded string to number
122
+ */
123
+ function frB128(s) {
124
+ let result = 0;
125
+ let i = -1;
126
+ const l = s.length - 1;
127
+ while (i++ < l) {
128
+ result = result * 128 + b128back[s.charCodeAt(i)];
129
+ }
130
+ return result;
131
+ }
132
+
133
+ /**
134
+ * Convert number to base-128 encoded string
135
+ */
136
+ function toB128(i) {
137
+ let s = b128digits[(i % 128)];
138
+ i = floor$1(i / 128);
139
+ while (i > 0) {
140
+ s = b128digits[(i % 128)] + s;
141
+ i = floor$1(i / 128);
142
+ }
143
+ return s;
144
+ }
145
+
146
+ /**
147
+ * Decode a compressed command list string
148
+ * @param {string} str - Compressed string (space-separated encoded commands)
149
+ * @returns {Array} - Array of decoded command arrays
150
+ */
151
+ function decodeList(str) {
152
+ const split = str.split(" ");
153
+ const list = [];
154
+
155
+ split.forEach(function(cmds) {
156
+ if (cmds.length === 12) { list.push(decode6(cmds)); }
157
+ if (cmds.length === 8) { list.push(decode4(cmds)); }
158
+ if (cmds.length === 4) { list.push(decode2(cmds)); }
159
+ });
160
+
161
+ return list;
162
+
163
+ function decode6(s) {
164
+ return [
165
+ decode1(s, 0, 2), decode1(s, 2, 4), decode1(s, 4, 6),
166
+ decode1(s, 6, 8), decode1(s, 8, 10), decode1(s, 10, 12)
167
+ ];
168
+ }
169
+ function decode4(s) {
170
+ return [decode1(s, 0, 2), decode1(s, 2, 4), decode1(s, 4, 6), decode1(s, 6, 8)];
171
+ }
172
+ function decode2(s) {
173
+ return [decode1(s, 0, 2), decode1(s, 2, 4)];
174
+ }
175
+ function decode1(s, start, end) {
176
+ return (frB128(s.substring(start, end)) - 4000) / 2;
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Encode a command list to compressed string
182
+ * @param {Array} list - Array of command arrays
183
+ * @returns {string} - Compressed string
184
+ */
185
+ function codeList(list) {
186
+ let str = "";
187
+ let xtra = "";
188
+
189
+ if (isArray(list)) {
190
+ list.forEach(function(cmds) {
191
+ if (cmds.length === 6) { str += xtra + code6(cmds); xtra = " "; }
192
+ if (cmds.length === 4) { str += xtra + code4(cmds); xtra = " "; }
193
+ if (cmds.length === 2) { str += xtra + code2(cmds); xtra = " "; }
194
+ });
195
+ }
196
+
197
+ return str;
198
+
199
+ function code6(a) {
200
+ return code1(a[0]) + code1(a[1]) + code1(a[2]) + code1(a[3]) + code1(a[4]) + code1(a[5]);
201
+ }
202
+ function code4(a) {
203
+ return code1(a[0]) + code1(a[1]) + code1(a[2]) + code1(a[3]);
204
+ }
205
+ function code2(a) {
206
+ return code1(a[0]) + code1(a[1]);
207
+ }
208
+ function code1(n) {
209
+ return toB128((n + n) + 4000);
210
+ }
211
+ }
212
+
213
+ /**
214
+ * MeshWriter Font Registry
215
+ * Manages font registration and lookup
216
+ */
217
+
218
+
219
+ // Private font storage
220
+ const FONTS = {};
221
+
222
+ /**
223
+ * Register a font for use with MeshWriter
224
+ * @param {string} name - Font name (case-sensitive, used in "font-family" option)
225
+ * @param {Function|Object} fontData - Font factory function or pre-initialized font object
226
+ *
227
+ * @example
228
+ * // Register a font factory (receives codeList for encoding)
229
+ * import helvetica from 'meshwriter/fonts/helvetica';
230
+ * registerFont('Helvetica', helvetica);
231
+ *
232
+ * // Register with aliases
233
+ * registerFont('Arial', helvetica);
234
+ * registerFont('sans-serif', helvetica);
235
+ */
236
+ function registerFont(name, fontData) {
237
+ if (typeof fontData === 'function') {
238
+ // Font is a factory function expecting codeList
239
+ FONTS[name] = fontData(codeList);
240
+ } else if (isObject(fontData)) {
241
+ // Font is already initialized
242
+ FONTS[name] = fontData;
243
+ } else {
244
+ throw new Error(`MeshWriter: Invalid font data for "${name}"`);
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Register multiple font aliases pointing to the same font
250
+ * @param {string} targetName - Name of already-registered font
251
+ * @param {...string} aliases - Alias names to register
252
+ *
253
+ * @example
254
+ * registerFont('Helvetica', helveticaData);
255
+ * registerFontAliases('Helvetica', 'Arial', 'sans-serif');
256
+ */
257
+ function registerFontAliases(targetName, ...aliases) {
258
+ if (!FONTS[targetName]) {
259
+ throw new Error(`MeshWriter: Cannot create aliases: font "${targetName}" not registered`);
260
+ }
261
+ aliases.forEach(alias => {
262
+ FONTS[alias] = FONTS[targetName];
263
+ });
264
+ }
265
+
266
+ /**
267
+ * Get a registered font by name
268
+ * @param {string} name - Font name
269
+ * @returns {Object|undefined} - Font object or undefined if not found
270
+ */
271
+ function getFont(name) {
272
+ return FONTS[name];
273
+ }
274
+
275
+ /**
276
+ * Check if a font is registered
277
+ * @param {string} name - Font name
278
+ * @returns {boolean}
279
+ */
280
+ function isFontRegistered(name) {
281
+ return isObject(FONTS[name]);
282
+ }
283
+
284
+ /**
285
+ * Get list of all registered font names
286
+ * @returns {string[]}
287
+ */
288
+ function getRegisteredFonts() {
289
+ return Object.keys(FONTS);
290
+ }
291
+
292
+ /**
293
+ * Unregister a font (mainly for testing)
294
+ * @param {string} name - Font name to remove
295
+ */
296
+ function unregisterFont(name) {
297
+ delete FONTS[name];
298
+ }
299
+
300
+ /**
301
+ * Clear all registered fonts (mainly for testing)
302
+ */
303
+ function clearFonts() {
304
+ Object.keys(FONTS).forEach(key => delete FONTS[key]);
305
+ }
306
+
307
+ /**
308
+ * MeshWriter CSG Module
309
+ * Handles CSG version detection, async initialization, and ready state management
310
+ */
311
+
312
+
313
+ // CSG libraries - initialized from Babylon imports or external source
314
+ let CSG = CSG$1;
315
+ let CSG2 = CSG2$1;
316
+ let InitializeCSG2Async = InitializeCSG2Async$1;
317
+ let IsCSG2Ready = IsCSG2Ready$1;
318
+
319
+ // State
320
+ let csgVersion = null; // 'CSG2', 'CSG', or null
321
+ let csgReady = false;
322
+ const csgReadyListeners = [];
323
+ let externalCSGInitializer = null;
324
+ let externalCSGReadyCheck = null;
325
+
326
+ /**
327
+ * Initialize CSG module with Babylon.js CSG classes
328
+ * Must be called before using any CSG functionality
329
+ * @param {Object} babylon - Object containing CSG, CSG2, InitializeCSG2Async, IsCSG2Ready
330
+ */
331
+ function initCSGModule(babylon) {
332
+ if (isObject(babylon)) {
333
+ CSG = babylon.CSG || null;
334
+ CSG2 = babylon.CSG2 || null;
335
+ InitializeCSG2Async = babylon.InitializeCSG2Async || null;
336
+ IsCSG2Ready = babylon.IsCSG2Ready || null;
337
+ }
338
+ csgVersion = detectCSGVersion();
339
+
340
+ // Legacy CSG is immediately ready
341
+ if (csgVersion === 'CSG') {
342
+ markCSGInitialized();
343
+ } else if (csgVersion === 'CSG2') {
344
+ // Check if CSG2 is already initialized
345
+ csgReady = false;
346
+ if (runCSGReadyCheck()) {
347
+ markCSGInitialized();
348
+ }
349
+ }
350
+ }
351
+
352
+ /**
353
+ * Detect which CSG implementation is available
354
+ * @returns {'CSG2'|'CSG'|null}
355
+ */
356
+ function detectCSGVersion() {
357
+ // Prefer CSG2 (Babylon 7.31+)
358
+ if (isObject(CSG2) && typeof InitializeCSG2Async === 'function') {
359
+ return 'CSG2';
360
+ }
361
+ // Fall back to legacy CSG
362
+ if (isObject(CSG) && typeof CSG.FromMesh === 'function') {
363
+ return 'CSG';
364
+ }
365
+ return null;
366
+ }
367
+
368
+ /**
369
+ * Mark CSG as ready and notify all listeners
370
+ */
371
+ function markCSGInitialized() {
372
+ if (csgReady) {
373
+ return;
374
+ }
375
+ csgReady = true;
376
+
377
+ // Notify all waiting listeners
378
+ if (csgReadyListeners.length) {
379
+ csgReadyListeners.splice(0).forEach(function(listener) {
380
+ try {
381
+ listener();
382
+ } catch (err) {
383
+ console.error("MeshWriter: onCSGReady listener failed", err);
384
+ }
385
+ });
386
+ }
387
+ }
388
+
389
+ /**
390
+ * Check if CSG is ready for use
391
+ * @returns {boolean}
392
+ */
393
+ function isCSGReady() {
394
+ if (csgVersion === 'CSG2') {
395
+ refreshCSGReadyState();
396
+ return csgReady;
397
+ }
398
+ return csgVersion === 'CSG';
399
+ }
400
+
401
+ /**
402
+ * Refresh CSG2 ready state from external checks
403
+ */
404
+ function refreshCSGReadyState() {
405
+ if (csgVersion !== 'CSG2' || csgReady) {
406
+ return;
407
+ }
408
+ if (runCSGReadyCheck()) {
409
+ markCSGInitialized();
410
+ }
411
+ }
412
+
413
+ /**
414
+ * Run CSG ready check (external or native)
415
+ */
416
+ function runCSGReadyCheck() {
417
+ // Try external ready check first
418
+ if (typeof externalCSGReadyCheck === "function") {
419
+ try {
420
+ if (externalCSGReadyCheck()) {
421
+ return true;
422
+ }
423
+ } catch (err) {
424
+ console.warn("MeshWriter: external CSG ready check failed", err);
425
+ }
426
+ }
427
+
428
+ // Check native IsCSG2Ready
429
+ if (typeof IsCSG2Ready === "function" && IsCSG2Ready()) {
430
+ return true;
431
+ }
432
+
433
+ return false;
434
+ }
435
+
436
+ /**
437
+ * Initialize CSG2 asynchronously
438
+ * @returns {Promise<void>}
439
+ */
440
+ async function initializeCSG2() {
441
+ if (csgVersion !== 'CSG2') {
442
+ return;
443
+ }
444
+ if (csgReady) {
445
+ return;
446
+ }
447
+
448
+ const initializer = externalCSGInitializer || InitializeCSG2Async;
449
+ if (typeof initializer !== "function") {
450
+ throw new Error(
451
+ "MeshWriter: No CSG2 initializer available. " +
452
+ "Use MeshWriter.setCSGInitializer() or ensure BABYLON.InitializeCSG2Async is available."
453
+ );
454
+ }
455
+
456
+ const result = initializer();
457
+ if (isPromiseLike(result)) {
458
+ await result;
459
+ }
460
+ markCSGInitialized();
461
+ }
462
+
463
+ // Public API
464
+
465
+ /**
466
+ * Get the current CSG version being used
467
+ * @returns {'CSG2'|'CSG'|null}
468
+ */
469
+ function getCSGVersion() {
470
+ return csgVersion;
471
+ }
472
+
473
+ /**
474
+ * Set an external CSG2 initializer function
475
+ * @param {Function} fn - Async function that initializes CSG2
476
+ */
477
+ function setCSGInitializer(fn) {
478
+ if (typeof fn === "function") {
479
+ externalCSGInitializer = fn;
480
+ }
481
+ }
482
+
483
+ /**
484
+ * Set an external CSG2 ready check function
485
+ * @param {Function} fn - Function that returns true when CSG2 is ready
486
+ */
487
+ function setCSGReadyCheck(fn) {
488
+ if (typeof fn === "function") {
489
+ externalCSGReadyCheck = fn;
490
+ refreshCSGReadyState();
491
+ }
492
+ }
493
+
494
+ /**
495
+ * Register a callback to be called when CSG is ready
496
+ * If CSG is already ready, callback is called immediately
497
+ * @param {Function} listener
498
+ */
499
+ function onCSGReady(listener) {
500
+ if (typeof listener !== "function") {
501
+ return;
502
+ }
503
+ if (isCSGReady()) {
504
+ listener();
505
+ } else {
506
+ csgReadyListeners.push(listener);
507
+ }
508
+ }
509
+
510
+ /**
511
+ * Get the CSG library to use for operations
512
+ * @returns {Object} - CSG or CSG2 class
513
+ */
514
+ function getCSGLib() {
515
+ return csgVersion === 'CSG2' ? CSG2 : CSG;
516
+ }
517
+
518
+ /**
519
+ * TextFogPlugin - MaterialPluginBase that applies fog to emissive color
520
+ *
521
+ * Babylon's standard fog only affects diffuse/ambient channels.
522
+ * This plugin recalculates fog blending for the final color output,
523
+ * ensuring emissive text fades properly with distance fog.
524
+ */
525
+
526
+
527
+ /**
528
+ * Plugin that applies scene fog to text materials by modifying
529
+ * the final fragment color before output.
530
+ */
531
+ class TextFogPlugin extends MaterialPluginBase {
532
+ /**
533
+ * @param {import('@babylonjs/core/Materials/material').Material} material
534
+ */
535
+ constructor(material) {
536
+ var priority = 300; // Run after standard material processing
537
+ var defines = { 'MESHWRITER_TEXT_FOG': false };
538
+ super(material, 'TextFogPlugin', priority, defines);
539
+ this._enable(true);
540
+ }
541
+
542
+ /**
543
+ * Set the define based on whether scene fog is enabled
544
+ * @param {object} defines
545
+ * @param {import('@babylonjs/core/scene').Scene} scene
546
+ * @param {import('@babylonjs/core/Meshes/mesh').Mesh} mesh
547
+ */
548
+ prepareDefines(defines, scene, mesh) {
549
+ // Enable when scene has any fog mode set (1=LINEAR, 2=EXP, 3=EXP2)
550
+ defines['MESHWRITER_TEXT_FOG'] = scene.fogMode !== 0;
551
+ }
552
+
553
+ getClassName() {
554
+ return 'TextFogPlugin';
555
+ }
556
+
557
+ getUniforms() {
558
+ // We use Babylon's built-in fog uniforms (vFogInfos, vFogColor)
559
+ // which are already available in the standard material
560
+ return { ubo: [] };
561
+ }
562
+
563
+ /**
564
+ * Clean up the plugin
565
+ */
566
+ dispose() {
567
+ super.dispose();
568
+ }
569
+
570
+ /**
571
+ * Inject shader code to apply fog to emissive color
572
+ * @param {string} shaderType - 'vertex' or 'fragment'
573
+ */
574
+ getCustomCode(shaderType) {
575
+ if (shaderType === 'fragment') {
576
+ return {
577
+ // This injection point runs just before gl_FragColor is finalized
578
+ // At this point, standard fog has been applied to diffuse/ambient
579
+ // but emissive contribution bypasses fog, so we re-apply fog
580
+ // to the entire output to properly fade text into fog
581
+ 'CUSTOM_FRAGMENT_BEFORE_FRAGCOLOR': `
582
+ #ifdef MESHWRITER_TEXT_FOG
583
+ #ifdef FOG
584
+ // Recalculate fog for the full fragment color including emissive
585
+ // vFogInfos: x=fogMode, y=fogStart, z=fogEnd, w=fogDensity
586
+ // vFogColor: fog RGB color
587
+ // vFogDistance: vec3 distance from camera (set by vertex shader)
588
+
589
+ float textFogFactor = 1.0;
590
+ float textFogDist = length(vFogDistance);
591
+
592
+ if (FOGMODE_LINEAR == vFogInfos.x) {
593
+ // Linear fog: factor = (end - dist) / (end - start)
594
+ textFogFactor = clamp((vFogInfos.z - textFogDist) / (vFogInfos.z - vFogInfos.y), 0.0, 1.0);
595
+ } else if (FOGMODE_EXP == vFogInfos.x) {
596
+ // Exponential fog: factor = exp(-dist * density)
597
+ textFogFactor = clamp(exp(-textFogDist * vFogInfos.w), 0.0, 1.0);
598
+ } else if (FOGMODE_EXP2 == vFogInfos.x) {
599
+ // Exponential squared fog: factor = exp(-(dist * density)^2)
600
+ float fogDistDensity = textFogDist * vFogInfos.w;
601
+ textFogFactor = clamp(exp(-fogDistDensity * fogDistDensity), 0.0, 1.0);
602
+ }
603
+
604
+ // Blend the entire fragment (including emissive) toward fog color
605
+ // textFogFactor: 1.0 = no fog (full color), 0.0 = full fog
606
+ color.rgb = mix(vFogColor, color.rgb, textFogFactor);
607
+ #endif
608
+ #endif
609
+ `,
610
+ };
611
+ }
612
+ return null;
613
+ }
614
+ }
615
+
616
+ /**
617
+ * MeshWriter Material Creation
618
+ * Creates StandardMaterial for text rendering
619
+ */
620
+
621
+
622
+ /** @typedef {import('@babylonjs/core/scene').Scene} Scene */
623
+
624
+ const floor = Math.floor;
625
+
626
+ /**
627
+ * Convert hex color string to Babylon Color3
628
+ * @param {string} rgb - Hex color string (e.g., "#FF0000" or "FF0000")
629
+ * @returns {Color3}
630
+ */
631
+ function rgb2Color3(rgb) {
632
+ rgb = rgb.replace("#", "");
633
+ return new Color3(
634
+ convert(rgb.substring(0, 2)),
635
+ convert(rgb.substring(2, 4)),
636
+ convert(rgb.substring(4, 6))
637
+ );
638
+
639
+ function convert(x) {
640
+ const parsed = parseInt(x, 16);
641
+ const val = isNaN(parsed) ? 0 : parsed;
642
+ return floor(1000 * Math.max(0, Math.min(val / 255, 1))) / 1000;
643
+ }
644
+ }
645
+
646
+ /**
647
+ * Create a StandardMaterial for text
648
+ * @param {Scene} scene - Babylon scene
649
+ * @param {string} letters - Text string (used for material naming)
650
+ * @param {string} emissive - Hex color for emissive
651
+ * @param {string} ambient - Hex color for ambient
652
+ * @param {string} specular - Hex color for specular
653
+ * @param {string} diffuse - Hex color for diffuse
654
+ * @param {number} opac - Opacity (0-1)
655
+ * @param {boolean} [emissiveOnly=false] - If true, disables lighting (only emissive color shows)
656
+ * @param {boolean} [fogEnabled=true] - If true, the material is affected by scene fog
657
+ * @returns {StandardMaterial}
658
+ */
659
+ function makeMaterial(scene, letters, emissive, ambient, specular, diffuse, opac, emissiveOnly = false, fogEnabled = true) {
660
+ const material = new StandardMaterial("mw-matl-" + letters + "-" + weeid(), scene);
661
+ material.diffuseColor = rgb2Color3(diffuse);
662
+ material.specularColor = rgb2Color3(specular);
663
+ material.ambientColor = rgb2Color3(ambient);
664
+ material.emissiveColor = rgb2Color3(emissive);
665
+ material.alpha = opac;
666
+
667
+ // When emissiveOnly is true, disable lighting so only emissive color shows
668
+ // This gives a "self-lit" appearance that ignores scene lights
669
+ if (emissiveOnly) {
670
+ material.disableLighting = true;
671
+ }
672
+
673
+ // Emissive-only materials should be self-lit and not affected by fog
674
+ if (emissiveOnly) {
675
+ material.fogEnabled = false;
676
+ } else {
677
+ material.fogEnabled = fogEnabled;
678
+ // Attach fog plugin to properly blend emissive color with fog
679
+ // Babylon's standard fog only affects diffuse/ambient, not emissive.
680
+ // The plugin re-fogs the entire fragment output so emissive fades properly.
681
+ // (The slight double-fog on diffuse/ambient is negligible since text is primarily emissive)
682
+ if (fogEnabled) {
683
+ material._textFogPlugin = new TextFogPlugin(material);
684
+ }
685
+ }
686
+
687
+ return material;
688
+ }
689
+
690
+ /**
691
+ * Create a dedicated emissive material for front faces.
692
+ * This keeps the face self-lit while still respecting fog settings.
693
+ * @param {Scene} scene
694
+ * @param {string} letters
695
+ * @param {string} emissive
696
+ * @param {number} opac
697
+ * @param {boolean} fogEnabled
698
+ * @returns {StandardMaterial}
699
+ */
700
+ function makeFaceMaterial(scene, letters, emissive, opac, fogEnabled = true) {
701
+ const material = new StandardMaterial("mw-face-matl-" + letters + "-" + weeid(), scene);
702
+ const black = rgb2Color3("#000000");
703
+ material.diffuseColor = black;
704
+ material.specularColor = black;
705
+ material.ambientColor = black;
706
+ material.emissiveColor = rgb2Color3(emissive);
707
+ material.disableLighting = true;
708
+ material.alpha = opac;
709
+ material.backFaceCulling = false;
710
+ material.fogEnabled = fogEnabled;
711
+ if (fogEnabled) {
712
+ material._textFogPlugin = new TextFogPlugin(material);
713
+ }
714
+ return material;
715
+ }
716
+
717
+ /**
718
+ * MeshWriter SPS (Solid Particle System) Helpers
719
+ * Converts letter meshes into an efficient SPS
720
+ */
721
+
722
+
723
+ /** @typedef {import('@babylonjs/core/scene').Scene} Scene */
724
+ /** @typedef {import('@babylonjs/core/Materials/material').Material} Material */
725
+ /** @typedef {import('@babylonjs/core/Meshes/mesh').Mesh} BabylonMesh */
726
+ /** @typedef {(any[] & { faceMeshes?: BabylonMesh[] })} MeshCollection */
727
+ /**
728
+ * @typedef {[SolidParticleSystem | undefined, BabylonMesh | undefined] & {
729
+ * face: [SolidParticleSystem | undefined, BabylonMesh | undefined];
730
+ * }} SPSCombo
731
+ */
732
+
733
+ /**
734
+ * Create an SPS from letter meshes
735
+ * @param {Scene} scene - Babylon scene
736
+ * @param {MeshCollection} meshesAndBoxes - [meshes, boxes, origins] with optional face geometry
737
+ * @param {Material} material - Material to apply to SPS mesh
738
+ * @returns {SPSCombo} - Combined SPS + emissive face SPS
739
+ */
740
+ function makeSPS(scene, meshesAndBoxes, material) {
741
+ const rimMeshes = meshesAndBoxes[0] || [];
742
+ const faceMeshes = meshesAndBoxes.faceMeshes || [];
743
+ const lettersOrigins = meshesAndBoxes[2] || [];
744
+
745
+ const rim = buildSystem("sps_rim", rimMeshes, lettersOrigins, scene, material);
746
+
747
+ // Use Mesh.MergeMeshes for face instead of SPS - SPS has issues with face geometry
748
+ const face = buildFaceMesh("sps_face", faceMeshes, lettersOrigins);
749
+
750
+ /** @type {SPSCombo} */
751
+ const combo = /** @type {any} */ ([rim.sps, rim.mesh]);
752
+ combo.face = [undefined, face.mesh]; // No SPS for face, just merged mesh
753
+ return combo;
754
+ }
755
+
756
+ /**
757
+ * Build face mesh using Mesh.MergeMeshes instead of SPS
758
+ * @param {string} name - Mesh name
759
+ * @param {BabylonMesh[]} meshes - Face meshes to merge
760
+ * @param {Array} lettersOrigins - Letter origin positions
761
+ * @param {Scene} scene - Babylon scene
762
+ * @returns {{ mesh: BabylonMesh | undefined }}
763
+ */
764
+ function buildFaceMesh(name, meshes, lettersOrigins, scene) {
765
+ const validMeshes = meshes.filter(m => m != null);
766
+ if (!validMeshes.length) {
767
+ return { mesh: undefined };
768
+ }
769
+
770
+ // Position each mesh according to letter origins before merging
771
+ validMeshes.forEach((mesh, ix) => {
772
+ if (lettersOrigins[ix]) {
773
+ mesh.position.x = lettersOrigins[ix][0] + lettersOrigins[ix][1];
774
+ mesh.position.z = lettersOrigins[ix][2];
775
+ }
776
+ });
777
+
778
+ // Merge all face meshes into one
779
+ const merged = Mesh.MergeMeshes(validMeshes, true, true, undefined, false, true);
780
+ if (merged) {
781
+ merged.name = name;
782
+ }
783
+
784
+ return { mesh: merged };
785
+ }
786
+
787
+ function buildSystem(name, meshes, lettersOrigins, scene, material) {
788
+ // Pre-filter null meshes to avoid repeated null checks in hot loop
789
+ const validMeshes = meshes
790
+ .map((mesh, ix) => mesh ? { mesh, origins: lettersOrigins[ix] } : null)
791
+ .filter(item => item !== null);
792
+
793
+ if (!validMeshes.length) {
794
+ return { sps: undefined, mesh: undefined };
795
+ }
796
+
797
+ const sps = new SolidParticleSystem(name, scene, {});
798
+ validMeshes.forEach(function(item) {
799
+ sps.addShape(item.mesh, 1, {
800
+ positionFunction: makePositionParticle(item.origins)
801
+ });
802
+ item.mesh.dispose();
803
+ });
804
+
805
+ const spsMesh = sps.buildMesh();
806
+
807
+ if (spsMesh && material) {
808
+ spsMesh.material = material;
809
+ }
810
+ sps.setParticles();
811
+
812
+ return { sps, mesh: spsMesh };
813
+ }
814
+
815
+ function makePositionParticle(letterOrigins) {
816
+ return function positionParticle(particle) {
817
+ if (!letterOrigins) return;
818
+ particle.position.x = letterOrigins[0] + letterOrigins[1];
819
+ particle.position.z = letterOrigins[2];
820
+ };
821
+ }
822
+
823
+ /**
824
+ * Split a mesh into two meshes: emissive face geometry and lit rim geometry.
825
+ * @param {import('@babylonjs/core').Mesh} mesh
826
+ * @param {import('@babylonjs/core').Scene} scene
827
+ * @returns {{ rimMesh: import('@babylonjs/core').Mesh, faceMesh: import('@babylonjs/core').Mesh | null }}
828
+ */
829
+ function splitMeshByFaceNormals(mesh, scene) {
830
+ var geometry = mesh.geometry;
831
+ if (!geometry) {
832
+ return { rimMesh: mesh, faceMesh: null }
833
+ }
834
+
835
+ var positions = geometry.getVerticesData('position');
836
+ var normals = geometry.getVerticesData('normal');
837
+ var uvs = geometry.getVerticesData('uv');
838
+ var indices = geometry.getIndices();
839
+
840
+ if (!positions || !normals || !indices || positions.length === 0) {
841
+ return { rimMesh: mesh, faceMesh: null }
842
+ }
843
+
844
+ var faceData = createEmptyData();
845
+ var rimData = createEmptyData();
846
+
847
+ var axisInfo = detectFaceAxisFromGeometry(positions, normals, indices);
848
+ if (!axisInfo) {
849
+ axisInfo = detectFaceAxis(normals);
850
+ }
851
+ if (!axisInfo) {
852
+ axisInfo = detectExtrudeAxisFromPositions(positions);
853
+ }
854
+
855
+ for (var i = 0; i < indices.length; i += 3) {
856
+ var i0 = indices[i];
857
+ var i1 = indices[i + 1];
858
+ var i2 = indices[i + 2];
859
+ var isFace = triangleIsFrontFace(i0, i1, i2, positions, normals, axisInfo);
860
+ var target = isFace ? faceData : rimData;
861
+ var v0 = appendVertex(target, i0, positions, normals, uvs);
862
+ var v1 = appendVertex(target, i1, positions, normals, uvs);
863
+ var v2 = appendVertex(target, i2, positions, normals, uvs);
864
+ target.indices.push(v0, v1, v2);
865
+ }
866
+
867
+ mesh.dispose();
868
+
869
+ var rimMesh = buildMesh(mesh.name + '_rim', rimData, scene);
870
+ var faceMesh = buildMesh(mesh.name + '_face', faceData, scene);
871
+
872
+ if (!faceMesh) {
873
+ return { rimMesh: rimMesh || mesh, faceMesh: null }
874
+ }
875
+ if (!rimMesh) {
876
+ return { rimMesh: faceMesh, faceMesh: null }
877
+ }
878
+ return { rimMesh, faceMesh }
879
+ }
880
+
881
+ function detectFaceAxisFromGeometry(positions, normals, indices) {
882
+ if (!positions || !indices) return null
883
+
884
+ var min = [Infinity, Infinity, Infinity];
885
+ var max = [-Infinity, -Infinity, -Infinity];
886
+ for (var i = 0; i < positions.length; i += 3) {
887
+ var x = positions[i];
888
+ var y = positions[i + 1];
889
+ var z = positions[i + 2];
890
+ if (x < min[0]) min[0] = x;
891
+ if (x > max[0]) max[0] = x;
892
+ if (y < min[1]) min[1] = y;
893
+ if (y > max[1]) max[1] = y;
894
+ if (z < min[2]) min[2] = z;
895
+ if (z > max[2]) max[2] = z;
896
+ }
897
+
898
+ var epsilons = [
899
+ Math.max((max[0] - min[0]) * 0.15, 0.001),
900
+ Math.max((max[1] - min[1]) * 0.15, 0.001),
901
+ Math.max((max[2] - min[2]) * 0.15, 0.001)
902
+ ];
903
+
904
+ var counts = [
905
+ { min: 0, max: 0, sumMin: 0, sumMax: 0 },
906
+ { min: 0, max: 0, sumMin: 0, sumMax: 0 },
907
+ { min: 0, max: 0, sumMin: 0, sumMax: 0 }
908
+ ];
909
+
910
+ for (var idx = 0; idx < indices.length; idx += 3) {
911
+ var i0 = indices[idx];
912
+ var i1 = indices[idx + 1];
913
+ var i2 = indices[idx + 2];
914
+
915
+ for (var axis = 0; axis < 3; axis++) {
916
+ var epsilon = epsilons[axis];
917
+ if (epsilon <= 0) continue
918
+ var minVal = min[axis];
919
+ var maxVal = max[axis];
920
+
921
+ var c0 = positions[i0 * 3 + axis];
922
+ var c1 = positions[i1 * 3 + axis];
923
+ var c2 = positions[i2 * 3 + axis];
924
+
925
+ var nearMin = Math.abs(c0 - minVal) < epsilon &&
926
+ Math.abs(c1 - minVal) < epsilon &&
927
+ Math.abs(c2 - minVal) < epsilon;
928
+ var nearMax = Math.abs(c0 - maxVal) < epsilon &&
929
+ Math.abs(c1 - maxVal) < epsilon &&
930
+ Math.abs(c2 - maxVal) < epsilon;
931
+
932
+ if (nearMin) {
933
+ counts[axis].min++;
934
+ if (normals) {
935
+ counts[axis].sumMin += normals[i0 * 3 + axis] + normals[i1 * 3 + axis] + normals[i2 * 3 + axis];
936
+ }
937
+ } else if (nearMax) {
938
+ counts[axis].max++;
939
+ if (normals) {
940
+ counts[axis].sumMax += normals[i0 * 3 + axis] + normals[i1 * 3 + axis] + normals[i2 * 3 + axis];
941
+ }
942
+ }
943
+ }
944
+ }
945
+
946
+ var bestAxis = -1;
947
+ var bestCount = 0;
948
+ for (var axisIdx = 0; axisIdx < 3; axisIdx++) {
949
+ var total = counts[axisIdx].min + counts[axisIdx].max;
950
+ if (total > bestCount) {
951
+ bestCount = total;
952
+ bestAxis = axisIdx;
953
+ }
954
+ }
955
+
956
+ if (bestAxis === -1 || bestCount === 0) {
957
+ return null
958
+ }
959
+
960
+ // For 3D text, extrusion is typically along Y axis (axis=1).
961
+ // If Y axis has any face triangles and the detected axis is different,
962
+ // prefer Y unless the detected axis has significantly more triangles.
963
+ var yTotal = counts[1].min + counts[1].max;
964
+ if (bestAxis !== 1 && yTotal > 0) {
965
+ // Only switch away from Y if another axis has 2x more triangles
966
+ if (bestCount < yTotal * 2) {
967
+ bestAxis = 1;
968
+ bestCount = yTotal;
969
+ }
970
+ }
971
+
972
+ var chosen = counts[bestAxis];
973
+ var frontSide;
974
+ // For PolygonMeshBuilder extrusions: geometry goes from Y=0 (base) to Y=-depth (extruded end)
975
+ // After rotation -PI/2 around X: Y=0 → Z=0 (front, facing camera), Y=-depth → Z=+depth (back)
976
+ // So Y=max (Y=0) is the FRONT face, Y=min (Y=-depth) is the BACK face
977
+ var maxHasOutwardNormals = chosen.sumMax > 0;
978
+ var minHasOutwardNormals = chosen.sumMin < 0;
979
+
980
+ // Select MAX as front (the base at Y=0 which faces camera after rotation)
981
+ if (maxHasOutwardNormals) {
982
+ frontSide = 'max';
983
+ } else if (minHasOutwardNormals) {
984
+ frontSide = 'min';
985
+ } else {
986
+ // Fallback to max (the base)
987
+ frontSide = 'max';
988
+ }
989
+
990
+ return {
991
+ axis: bestAxis,
992
+ strategy: 'positions',
993
+ min: min[bestAxis],
994
+ max: max[bestAxis],
995
+ epsilon: epsilons[bestAxis],
996
+ frontSide
997
+ }
998
+ }
999
+
1000
+ function detectFaceAxis(normals) {
1001
+ if (!normals || normals.length === 0) {
1002
+ return null
1003
+ }
1004
+ var sumsAbs = [0, 0, 0];
1005
+ var sumsSigned = [0, 0, 0];
1006
+ for (var i = 0; i < normals.length; i += 3) {
1007
+ var nx = normals[i];
1008
+ var ny = normals[i + 1];
1009
+ var nz = normals[i + 2];
1010
+ sumsAbs[0] += Math.abs(nx);
1011
+ sumsAbs[1] += Math.abs(ny);
1012
+ sumsAbs[2] += Math.abs(nz);
1013
+ sumsSigned[0] += nx;
1014
+ sumsSigned[1] += ny;
1015
+ sumsSigned[2] += nz;
1016
+ }
1017
+ var axis = 0;
1018
+ var maxSum = sumsAbs[0];
1019
+ for (var j = 1; j < 3; j++) {
1020
+ if (sumsAbs[j] > maxSum) {
1021
+ maxSum = sumsAbs[j];
1022
+ axis = j;
1023
+ }
1024
+ }
1025
+ if (maxSum === 0) {
1026
+ return null
1027
+ }
1028
+ var frontSign = sumsSigned[axis] >= 0 ? 1 : -1;
1029
+ return {
1030
+ axis,
1031
+ frontSign,
1032
+ strategy: 'normals',
1033
+ min: 0,
1034
+ max: 0,
1035
+ epsilon: 0,
1036
+ frontSide: 'max'
1037
+ }
1038
+ }
1039
+
1040
+ function detectExtrudeAxisFromPositions(positions) {
1041
+ if (!positions || positions.length === 0) {
1042
+ return null
1043
+ }
1044
+ var min = [Infinity, Infinity, Infinity];
1045
+ var max = [-Infinity, -Infinity, -Infinity];
1046
+ for (var i = 0; i < positions.length; i += 3) {
1047
+ var x = positions[i];
1048
+ var y = positions[i + 1];
1049
+ var z = positions[i + 2];
1050
+ if (x < min[0]) min[0] = x;
1051
+ if (x > max[0]) max[0] = x;
1052
+ if (y < min[1]) min[1] = y;
1053
+ if (y > max[1]) max[1] = y;
1054
+ if (z < min[2]) min[2] = z;
1055
+ if (z > max[2]) max[2] = z;
1056
+ }
1057
+ var ranges = [
1058
+ max[0] - min[0],
1059
+ max[1] - min[1],
1060
+ max[2] - min[2]
1061
+ ];
1062
+ var axis = 0;
1063
+ var minRange = ranges[0];
1064
+ for (var j = 1; j < 3; j++) {
1065
+ if (ranges[j] < minRange) {
1066
+ minRange = ranges[j];
1067
+ axis = j;
1068
+ }
1069
+ }
1070
+ var epsilon = Math.max(minRange * 0.05, 0.0001);
1071
+ return {
1072
+ axis,
1073
+ strategy: 'positions',
1074
+ min: min[axis],
1075
+ max: max[axis],
1076
+ epsilon,
1077
+ frontSide: 'max'
1078
+ }
1079
+ }
1080
+
1081
+ function triangleIsFrontFace(i0, i1, i2, positions, normals, axisInfo) {
1082
+ if (!axisInfo) return false
1083
+ var axis = axisInfo.axis || 1;
1084
+
1085
+ if (axisInfo.strategy === 'normals' && normals) {
1086
+ var frontSign = axisInfo.frontSign || 1;
1087
+ var threshold = 0.5;
1088
+ var n0 = normals[i0 * 3 + axis] * frontSign;
1089
+ var n1 = normals[i1 * 3 + axis] * frontSign;
1090
+ var n2 = normals[i2 * 3 + axis] * frontSign;
1091
+ return (n0 > threshold && n1 > threshold && n2 > threshold)
1092
+ }
1093
+
1094
+ if (axisInfo.strategy === 'positions' && positions) {
1095
+ var epsilon = axisInfo.epsilon;
1096
+ var limitVal = axisInfo.frontSide === 'min' ? axisInfo.min : axisInfo.max;
1097
+ var c0 = positions[i0 * 3 + axis];
1098
+ var c1 = positions[i1 * 3 + axis];
1099
+ var c2 = positions[i2 * 3 + axis];
1100
+ return (
1101
+ Math.abs(c0 - limitVal) < epsilon &&
1102
+ Math.abs(c1 - limitVal) < epsilon &&
1103
+ Math.abs(c2 - limitVal) < epsilon
1104
+ )
1105
+ }
1106
+
1107
+ return false
1108
+ }
1109
+
1110
+ function createEmptyData() {
1111
+ return {
1112
+ positions: [],
1113
+ normals: [],
1114
+ uvs: [],
1115
+ indices: [],
1116
+ nextIndex: 0
1117
+ }
1118
+ }
1119
+
1120
+ function appendVertex(target, originalIndex, positions, normals, uvs) {
1121
+ var posOffset = originalIndex * 3;
1122
+ var uvOffset = originalIndex * 2;
1123
+
1124
+ target.positions.push(
1125
+ positions[posOffset],
1126
+ positions[posOffset + 1],
1127
+ positions[posOffset + 2]
1128
+ );
1129
+ target.normals.push(
1130
+ normals[posOffset],
1131
+ normals[posOffset + 1],
1132
+ normals[posOffset + 2]
1133
+ );
1134
+
1135
+ if (uvs && uvs.length) {
1136
+ target.uvs.push(uvs[uvOffset], uvs[uvOffset + 1]);
1137
+ } else {
1138
+ target.uvs.push(0, 0);
1139
+ }
1140
+
1141
+ var newIndex = target.nextIndex;
1142
+ target.nextIndex += 1;
1143
+ return newIndex
1144
+ }
1145
+
1146
+ function buildMesh(name, data, scene) {
1147
+ if (!data.positions.length) return null
1148
+ var newMesh = new Mesh(name, scene);
1149
+ var vertexData = new VertexData();
1150
+ vertexData.positions = data.positions;
1151
+ vertexData.normals = data.normals;
1152
+ vertexData.indices = data.indices;
1153
+ vertexData.uvs = data.uvs;
1154
+ vertexData.applyToMesh(newMesh, true);
1155
+ newMesh.refreshBoundingInfo();
1156
+ return newMesh
1157
+ }
1158
+
1159
+ /**
1160
+ * MeshWriter Letter Mesh Construction
1161
+ * Builds 3D letter meshes from font specifications
1162
+ */
1163
+
1164
+
1165
+ /** @typedef {import('@babylonjs/core/scene').Scene} Scene */
1166
+ /** @typedef {import('@babylonjs/core/Materials/material').Material} Material */
1167
+
1168
+ // Handle both CJS default export and ESM module export
1169
+ const earcut = earcutModule.default || earcutModule;
1170
+
1171
+ // Constants
1172
+ const naturalLetterHeight = 1000;
1173
+
1174
+ /**
1175
+ * Get kerning value between two characters
1176
+ * @param {Object} fontSpec - Font specification object
1177
+ * @param {string} left - Left character
1178
+ * @param {string} right - Right character
1179
+ * @returns {number} - Kerning adjustment value (0 if none)
1180
+ */
1181
+ function getKerning(fontSpec, left, right) {
1182
+ if (!fontSpec.kern) return 0;
1183
+ const key = `${left},${right}`;
1184
+ return fontSpec.kern[key] || 0;
1185
+ }
1186
+
1187
+ /**
1188
+ * Decompress font letter on first use (lazy decompression)
1189
+ * @param {Object} fontSpec - Font specification object
1190
+ * @param {string} letter - Character to get spec for
1191
+ * @returns {Object|undefined} - Letter specification
1192
+ */
1193
+ function makeLetterSpec(fontSpec, letter) {
1194
+ const letterSpec = fontSpec[letter];
1195
+
1196
+ if (isObject(letterSpec)) {
1197
+ // Decompress shape commands if compressed
1198
+ if (!isArray(letterSpec.shapeCmds) && isArray(letterSpec.sC)) {
1199
+ letterSpec.shapeCmds = letterSpec.sC.map(cmds => decodeList(cmds));
1200
+ letterSpec.sC = null;
1201
+ }
1202
+ // Decompress hole commands if compressed
1203
+ if (!isArray(letterSpec.holeCmds) && isArray(letterSpec.hC)) {
1204
+ letterSpec.holeCmds = letterSpec.hC.map(cmdslists =>
1205
+ isArray(cmdslists) ? cmdslists.map(cmds => decodeList(cmds)) : cmdslists
1206
+ );
1207
+ letterSpec.hC = null;
1208
+ }
1209
+ }
1210
+ return letterSpec;
1211
+ }
1212
+
1213
+ /**
1214
+ * Convert point to Vector2
1215
+ */
1216
+ function point2Vector(point) {
1217
+ return new Vector2(round(point.x), round(point.y));
1218
+ }
1219
+
1220
+ /**
1221
+ * Merge array of meshes
1222
+ */
1223
+ function merge(arrayOfMeshes) {
1224
+ return arrayOfMeshes.length === 1
1225
+ ? arrayOfMeshes[0]
1226
+ : Mesh.MergeMeshes(arrayOfMeshes, true);
1227
+ }
1228
+
1229
+ /**
1230
+ * @typedef {Object} LetterPolygonsResult
1231
+ * @property {number} xWidth - Total width of all letters
1232
+ * @property {number} count - Number of valid letter meshes
1233
+ */
1234
+ /**
1235
+ * @typedef {(any[] & LetterPolygonsResult) & { faceMeshes: Mesh[] }} LetterPolygonsCollection
1236
+ */
1237
+
1238
+ /**
1239
+ * Construct meshes for all letters in a string
1240
+ * @param {string} letters - Text string
1241
+ * @param {Object} fontSpec - Font specification
1242
+ * @param {number} xOffset - X offset
1243
+ * @param {number} yOffset - Y offset (unused, kept for API compatibility)
1244
+ * @param {number} zOffset - Z offset
1245
+ * @param {number} letterScale - Scale factor for letters
1246
+ * @param {number} thickness - Letter thickness (depth)
1247
+ * @param {Material} material - Material (unused in this function)
1248
+ * @param {string} meshOrigin - "letterCenter" or "fontOrigin"
1249
+ * @param {Scene} scene - Babylon scene
1250
+ * @param {Object} [spacingOpts] - Optional spacing options
1251
+ * @param {number} [spacingOpts.letterSpacing=0] - Extra spacing between letters (world units, added after kerning)
1252
+ * @param {number} [spacingOpts.wordSpacing=0] - Extra spacing for spaces (world units, added to space width)
1253
+ * @returns {any[] & LetterPolygonsResult} - [meshes, boxes, origins] with xWidth and count properties
1254
+ */
1255
+ function constructLetterPolygons(
1256
+ letters, fontSpec, xOffset, yOffset, zOffset,
1257
+ letterScale, thickness, material, meshOrigin, scene, spacingOpts = {}
1258
+ ) {
1259
+ // Extract spacing options (already in world units)
1260
+ const letterSpacing = isNumber(spacingOpts.letterSpacing) ? spacingOpts.letterSpacing : 0;
1261
+ const wordSpacing = isNumber(spacingOpts.wordSpacing) ? spacingOpts.wordSpacing : 0;
1262
+
1263
+ let letterOffsetX = 0;
1264
+ const lettersOrigins = new Array(letters.length);
1265
+ const lettersBoxes = new Array(letters.length);
1266
+ const lettersMeshes = new Array(letters.length);
1267
+ const faceMeshes = new Array(letters.length);
1268
+ let ix = 0;
1269
+
1270
+ for (let i = 0; i < letters.length; i++) {
1271
+ const letter = letters[i];
1272
+ const letterSpec = makeLetterSpec(fontSpec, letter);
1273
+
1274
+ if (isObject(letterSpec)) {
1275
+ const lists = buildLetterMeshes(
1276
+ letter, i, letterSpec, fontSpec.reverseShapes, fontSpec.reverseHoles,
1277
+ meshOrigin, letterScale, xOffset, zOffset, letterOffsetX, thickness, scene
1278
+ );
1279
+
1280
+ const shapesList = lists[0];
1281
+ const holesList = lists[1];
1282
+ const letterBox = lists[2];
1283
+ const letterOrigins = lists[3];
1284
+ let newOffsetX = lists[4];
1285
+
1286
+ // Apply kerning with next letter (if any)
1287
+ if (i < letters.length - 1) {
1288
+ const nextLetter = letters[i + 1];
1289
+ const kernValue = getKerning(fontSpec, letter, nextLetter);
1290
+ if (kernValue !== 0) {
1291
+ newOffsetX += kernValue * letterScale;
1292
+ }
1293
+ // Apply letter spacing after kerning (not after last letter)
1294
+ if (letterSpacing !== 0) {
1295
+ newOffsetX += letterSpacing;
1296
+ }
1297
+ }
1298
+
1299
+ // Apply extra word spacing for space characters
1300
+ if (letter === ' ' && wordSpacing !== 0) {
1301
+ newOffsetX += wordSpacing;
1302
+ }
1303
+
1304
+ letterOffsetX = newOffsetX;
1305
+
1306
+ const letterMeshes = punchHolesInShapes(shapesList, holesList, letter, i, scene);
1307
+
1308
+ if (letterMeshes.length) {
1309
+ const merged = merge(letterMeshes);
1310
+ const split = splitMeshByFaceNormals(merged, scene);
1311
+
1312
+ lettersMeshes[ix] = split.rimMesh;
1313
+ faceMeshes[ix] = split.faceMesh;
1314
+ lettersOrigins[ix] = letterOrigins;
1315
+ lettersBoxes[ix] = letterBox;
1316
+ ix++;
1317
+ }
1318
+ }
1319
+ }
1320
+
1321
+ /** @type {LetterPolygonsCollection} */
1322
+ const meshesAndBoxes = /** @type {any} */ ([lettersMeshes, lettersBoxes, lettersOrigins]);
1323
+ meshesAndBoxes.faceMeshes = faceMeshes;
1324
+ meshesAndBoxes.xWidth = round(letterOffsetX);
1325
+ meshesAndBoxes.count = ix;
1326
+ return meshesAndBoxes;
1327
+ }
1328
+
1329
+ /**
1330
+ * Build meshes for a single letter
1331
+ * @returns {Array} - [shapesList, holesList, letterBox, letterOrigins, newOffsetX]
1332
+ */
1333
+ function buildLetterMeshes(
1334
+ letter, index, spec, reverseShapes, reverseHoles,
1335
+ meshOrigin, letterScale, xOffset, zOffset, letterOffsetX, thickness, scene
1336
+ ) {
1337
+ // Offset calculations
1338
+ const balanced = meshOrigin === "letterCenter";
1339
+ const centerX = (spec.xMin + spec.xMax) / 2;
1340
+ const centerZ = (spec.yMin + spec.yMax) / 2;
1341
+ const xFactor = isNumber(spec.xFactor) ? spec.xFactor : 1;
1342
+ const zFactor = isNumber(spec.yFactor) ? spec.yFactor : 1;
1343
+ const xShift = isNumber(spec.xShift) ? spec.xShift : 0;
1344
+ const zShift = isNumber(spec.yShift) ? spec.yShift : 0;
1345
+ const reverseShape = isBoolean(spec.reverseShape) ? spec.reverseShape : reverseShapes;
1346
+ const reverseHole = isBoolean(spec.reverseHole) ? spec.reverseHole : reverseHoles;
1347
+ const offX = xOffset - (balanced ? centerX : 0);
1348
+ const offZ = zOffset - (balanced ? centerZ : 0);
1349
+ const shapeCmdsLists = isArray(spec.shapeCmds) ? spec.shapeCmds : [];
1350
+ const holeCmdsListsArray = isArray(spec.holeCmds) ? spec.holeCmds : [];
1351
+
1352
+ // Tracking for relative coordinates
1353
+ let thisX, lastX, thisZ, lastZ;
1354
+
1355
+ // Scaling functions
1356
+ const adjX = makeAdjust(letterScale, xFactor, offX, 0, false, true);
1357
+ const adjZ = makeAdjust(letterScale, zFactor, offZ, 0, false, false);
1358
+ const adjXfix = makeAdjust(letterScale, xFactor, offX, xShift, false, true);
1359
+ const adjZfix = makeAdjust(letterScale, zFactor, offZ, zShift, false, false);
1360
+ const adjXrel = makeAdjust(letterScale, xFactor, offX, xShift, true, true);
1361
+ const adjZrel = makeAdjust(letterScale, zFactor, offZ, zShift, true, false);
1362
+
1363
+ const letterBox = [adjX(spec.xMin), adjX(spec.xMax), adjZ(spec.yMin), adjZ(spec.yMax)];
1364
+ const letterOrigins = [round(letterOffsetX), -1 * adjX(0), -1 * adjZ(0)];
1365
+
1366
+ // Update letterOffsetX for next letter
1367
+ const newOffsetX = letterOffsetX + spec.wdth * letterScale;
1368
+
1369
+ const shapesList = shapeCmdsLists.map(makeCmdsToMesh(reverseShape));
1370
+ const holesList = holeCmdsListsArray.map(meshesFromCmdsListArray);
1371
+
1372
+ return [shapesList, holesList, letterBox, letterOrigins, newOffsetX];
1373
+
1374
+ function meshesFromCmdsListArray(cmdsListArray) {
1375
+ return cmdsListArray.map(makeCmdsToMesh(reverseHole));
1376
+ }
1377
+
1378
+ function makeCmdsToMesh(reverse) {
1379
+ return function cmdsToMesh(cmdsList) {
1380
+ let cmd = getCmd(cmdsList, 0);
1381
+ /** @type {any} */
1382
+ const path = new Path2(adjXfix(cmd[0]), adjZfix(cmd[1]));
1383
+
1384
+ // Process path commands
1385
+ for (let j = 1; j < cmdsList.length; j++) {
1386
+ cmd = getCmd(cmdsList, j);
1387
+
1388
+ // Line (2 coords = absolute, 3 = relative)
1389
+ if (cmd.length === 2) {
1390
+ path.addLineTo(adjXfix(cmd[0]), adjZfix(cmd[1]));
1391
+ }
1392
+ if (cmd.length === 3) {
1393
+ path.addLineTo(adjXrel(cmd[1]), adjZrel(cmd[2]));
1394
+ }
1395
+
1396
+ // Quadratic curve (4 = absolute, 5 = relative)
1397
+ if (cmd.length === 4) {
1398
+ path.addQuadraticCurveTo(
1399
+ adjXfix(cmd[0]), adjZfix(cmd[1]),
1400
+ adjXfix(cmd[2]), adjZfix(cmd[3])
1401
+ );
1402
+ }
1403
+ if (cmd.length === 5) {
1404
+ path.addQuadraticCurveTo(
1405
+ adjXrel(cmd[1]), adjZrel(cmd[2]),
1406
+ adjXrel(cmd[3]), adjZrel(cmd[4])
1407
+ );
1408
+ }
1409
+
1410
+ // Cubic curve (6 = absolute, 7 = relative)
1411
+ if (cmd.length === 6) {
1412
+ path.addCubicCurveTo(
1413
+ adjXfix(cmd[0]), adjZfix(cmd[1]),
1414
+ adjXfix(cmd[2]), adjZfix(cmd[3]),
1415
+ adjXfix(cmd[4]), adjZfix(cmd[5])
1416
+ );
1417
+ }
1418
+ if (cmd.length === 7) {
1419
+ path.addCubicCurveTo(
1420
+ adjXrel(cmd[1]), adjZrel(cmd[2]),
1421
+ adjXrel(cmd[3]), adjZrel(cmd[4]),
1422
+ adjXrel(cmd[5]), adjZrel(cmd[6])
1423
+ );
1424
+ }
1425
+ }
1426
+
1427
+ // Convert path to array and process
1428
+ let array = path.getPoints().map(point2Vector);
1429
+
1430
+ // Remove redundant start/end points
1431
+ const first = 0;
1432
+ const last = array.length - 1;
1433
+ if (array[first].x === array[last].x && array[first].y === array[last].y) {
1434
+ array = array.slice(1);
1435
+ }
1436
+ if (reverse) {
1437
+ array.reverse();
1438
+ }
1439
+
1440
+ const meshBuilder = new PolygonMeshBuilder(
1441
+ "MeshWriter-" + letter + index + "-" + weeid(),
1442
+ array,
1443
+ scene,
1444
+ earcut
1445
+ );
1446
+ return meshBuilder.build(true, thickness);
1447
+ };
1448
+ }
1449
+
1450
+ function getCmd(list, ix) {
1451
+ lastX = thisX;
1452
+ lastZ = thisZ;
1453
+ const cmd = list[ix];
1454
+ const len = cmd.length;
1455
+ thisX = isRelativeLength(len)
1456
+ ? round((cmd[len - 2] * xFactor) + thisX)
1457
+ : round(cmd[len - 2] * xFactor);
1458
+ thisZ = isRelativeLength(len)
1459
+ ? round((cmd[len - 1] * zFactor) + thisZ)
1460
+ : round(cmd[len - 1] * zFactor);
1461
+ return cmd;
1462
+ }
1463
+
1464
+ function makeAdjust(letterScale, factor, off, shift, relative, xAxis) {
1465
+ if (relative) {
1466
+ if (xAxis) {
1467
+ return val => round(letterScale * ((val * factor) + shift + lastX + off));
1468
+ } else {
1469
+ return val => round(letterScale * ((val * factor) + shift + lastZ + off));
1470
+ }
1471
+ } else {
1472
+ return val => round(letterScale * ((val * factor) + shift + off));
1473
+ }
1474
+ }
1475
+ }
1476
+
1477
+ /**
1478
+ * Punch holes in letter shapes using CSG operations
1479
+ * @param {Array} shapesList - Array of shape meshes
1480
+ * @param {Array} holesList - Array of arrays of hole meshes
1481
+ * @param {string} letter - Letter character (for naming)
1482
+ * @param {number} letterIndex - Index of letter (for naming)
1483
+ * @param {Scene} scene - Babylon scene
1484
+ * @returns {Array} - Array of final letter meshes
1485
+ */
1486
+ function punchHolesInShapes(shapesList, holesList, letter, letterIndex, scene) {
1487
+ const csgVersion = getCSGVersion();
1488
+
1489
+ // Validate CSG is available and initialized
1490
+ if (csgVersion === 'CSG2' && !isCSGReady()) {
1491
+ throw new Error(
1492
+ "MeshWriter: CSG2 not initialized. " +
1493
+ "Use 'await MeshWriter.createAsync(scene, prefs)', call " +
1494
+ "'await BABYLON.InitializeCSG2Async()', or configure " +
1495
+ "MeshWriter.setCSGInitializer before creating MeshWriter."
1496
+ );
1497
+ }
1498
+ if (csgVersion === null) {
1499
+ throw new Error(
1500
+ "MeshWriter: No CSG implementation found. " +
1501
+ "Ensure BABYLON.CSG or BABYLON.CSG2 is available."
1502
+ );
1503
+ }
1504
+
1505
+ const letterMeshes = [];
1506
+ const csgLib = getCSGLib();
1507
+
1508
+ // Handle special case: single shape with multiple hole arrays
1509
+ // (e.g., "B" has 1 shape but 2 holes - top and bottom)
1510
+ // In this case, all holes should be punched into the single shape
1511
+ if (shapesList.length === 1 && holesList.length > 1) {
1512
+ const shape = shapesList[0];
1513
+ // Flatten all hole arrays into a single array
1514
+ const allHoles = holesList.flat();
1515
+
1516
+ if (allHoles.length > 0) {
1517
+ letterMeshes.push(punchHolesInShape(shape, allHoles, letter, letterIndex, csgLib, csgVersion, scene));
1518
+ } else {
1519
+ letterMeshes.push(shape);
1520
+ }
1521
+ return letterMeshes;
1522
+ }
1523
+
1524
+ // Standard case: 1:1 correspondence between shapes and hole arrays
1525
+ for (let j = 0; j < shapesList.length; j++) {
1526
+ const shape = shapesList[j];
1527
+ const holes = holesList[j];
1528
+
1529
+ if (isArray(holes) && holes.length) {
1530
+ letterMeshes.push(punchHolesInShape(shape, holes, letter, letterIndex, csgLib, csgVersion, scene));
1531
+ } else {
1532
+ // PolygonMeshBuilder creates meshes with correct normals by default
1533
+ // No flipFaces needed for shapes without holes
1534
+ letterMeshes.push(shape);
1535
+ }
1536
+ }
1537
+
1538
+ return letterMeshes;
1539
+ }
1540
+
1541
+ /**
1542
+ * Punch holes in a single shape
1543
+ */
1544
+ function punchHolesInShape(shape, holes, letter, letterIndex, csgLib, csgVersion, scene) {
1545
+ const meshName = "Net-" + letter + letterIndex + "-" + weeid();
1546
+
1547
+ let csgShape = csgLib.FromMesh(shape);
1548
+ for (let k = 0; k < holes.length; k++) {
1549
+ csgShape = csgShape.subtract(csgLib.FromMesh(holes[k]));
1550
+ }
1551
+
1552
+ const resultMesh = csgVersion === 'CSG2'
1553
+ ? csgShape.toMesh(meshName, scene, { centerMesh: false, rebuildNormals: true })
1554
+ : csgShape.toMesh(meshName, null, scene);
1555
+
1556
+ // CSG2 with rebuildNormals: true produces correct normals
1557
+ // No flipFaces needed
1558
+
1559
+ // Cleanup
1560
+ holes.forEach(h => h.dispose());
1561
+ shape.dispose();
1562
+
1563
+ return resultMesh;
1564
+ }
1565
+
1566
+ /**
1567
+ * MeshWriter Curve Extensions
1568
+ * Optimized Path2 curve methods for better performance
1569
+ */
1570
+
1571
+
1572
+ /**
1573
+ * Extended Path2 interface with MeshWriter curve methods
1574
+ * @typedef {Object} Path2Extensions
1575
+ * @property {(redX: number, redY: number, blueX: number, blueY: number) => Path2} addQuadraticCurveTo
1576
+ * @property {(redX: number, redY: number, greenX: number, greenY: number, blueX: number, blueY: number) => Path2} addCubicCurveTo
1577
+ */
1578
+
1579
+ // Optimized segment count for curves
1580
+ // Native Babylon 6+ uses 36 segments which causes slowdown
1581
+ // MeshWriter uses 6 for better performance
1582
+ const curveSampleSize = 6;
1583
+
1584
+ /**
1585
+ * Install optimized curve methods on Path2 prototype
1586
+ * Must be called after Babylon.js is loaded
1587
+ */
1588
+ function installCurveExtensions() {
1589
+ if (!Path2 || !Path2.prototype) {
1590
+ return;
1591
+ }
1592
+
1593
+ /** @type {any} */
1594
+ const proto = Path2.prototype;
1595
+
1596
+ // Quadratic Bezier with optimized segment count
1597
+ proto.addQuadraticCurveTo = function(redX, redY, blueX, blueY) {
1598
+ const points = this.getPoints();
1599
+ const lastPoint = points[points.length - 1];
1600
+ const origin = new Vector3(lastPoint.x, lastPoint.y, 0);
1601
+ const control = new Vector3(redX, redY, 0);
1602
+ const destination = new Vector3(blueX, blueY, 0);
1603
+
1604
+ const curve = Curve3.CreateQuadraticBezier(origin, control, destination, curveSampleSize);
1605
+ const curvePoints = curve.getPoints();
1606
+
1607
+ for (let i = 1; i < curvePoints.length; i++) {
1608
+ this.addLineTo(curvePoints[i].x, curvePoints[i].y);
1609
+ }
1610
+ return this; // Return this for method chaining
1611
+ };
1612
+
1613
+ // Cubic Bezier with optimized segment count
1614
+ proto.addCubicCurveTo = function(redX, redY, greenX, greenY, blueX, blueY) {
1615
+ const points = this.getPoints();
1616
+ const lastPoint = points[points.length - 1];
1617
+ const origin = new Vector3(lastPoint.x, lastPoint.y, 0);
1618
+ const control1 = new Vector3(redX, redY, 0);
1619
+ const control2 = new Vector3(greenX, greenY, 0);
1620
+ const destination = new Vector3(blueX, blueY, 0);
1621
+
1622
+ const nbPoints = Math.floor(0.3 + curveSampleSize * 1.5);
1623
+ const curve = Curve3.CreateCubicBezier(origin, control1, control2, destination, nbPoints);
1624
+ const curvePoints = curve.getPoints();
1625
+
1626
+ for (let i = 1; i < curvePoints.length; i++) {
1627
+ this.addLineTo(curvePoints[i].x, curvePoints[i].y);
1628
+ }
1629
+ return this; // Return this for method chaining
1630
+ };
1631
+ }
1632
+
1633
+ /**
1634
+ * MeshWriter Core Class
1635
+ * Main MeshWriter implementation for 3D text rendering in Babylon.js
1636
+ */
1637
+
1638
+
1639
+ /** @typedef {import('@babylonjs/core/scene').Scene} Scene */
1640
+
1641
+ /**
1642
+ * @typedef {Object} MeshWriterPreferences
1643
+ * @property {string} [defaultFont] - Default font family
1644
+ * @property {number} [scale=1] - Scale factor
1645
+ * @property {string} [meshOrigin="letterCenter"] - "letterCenter" or "fontOrigin"
1646
+ * @property {boolean} [debug=false] - Enable debug logging
1647
+ * @property {Object} [babylon] - Babylon.js namespace object with CSG classes (for ES module builds)
1648
+ */
1649
+
1650
+ // Constants
1651
+ const defaultColor = "#808080";
1652
+ const defaultOpac = 1;
1653
+
1654
+ /**
1655
+ * Create a MeshWriter factory configured for a scene
1656
+ * @param {Scene} scene - Babylon.js scene
1657
+ * @param {MeshWriterPreferences} [preferences={}] - Configuration options
1658
+ * @returns {Function} - MeshWriter constructor
1659
+ */
1660
+ function createMeshWriter(scene, preferences = {}) {
1661
+ // Install curve extensions for Path2
1662
+ installCurveExtensions();
1663
+
1664
+ const defaultFont = isFontRegistered(preferences.defaultFont)
1665
+ ? preferences.defaultFont
1666
+ : (isFontRegistered("Helvetica") ? "Helvetica" : "HelveticaNeue-Medium");
1667
+ const meshOrigin = preferences.meshOrigin === "fontOrigin"
1668
+ ? "fontOrigin"
1669
+ : "letterCenter";
1670
+ const scale = isNumber(preferences.scale) ? preferences.scale : 1;
1671
+ isBoolean(preferences.debug) ? preferences.debug : false;
1672
+
1673
+ /**
1674
+ * MeshWriter constructor - creates 3D text
1675
+ * @param {string} lttrs - Text to render
1676
+ * @param {Object} opt - Options
1677
+ */
1678
+ function MeshWriter(lttrs, opt) {
1679
+ const options = isObject(opt) ? opt : {};
1680
+ const position = setOption(options, "position", isObject, {});
1681
+ const colors = setOption(options, "colors", isObject, {});
1682
+ const fontFamily = setOption(options, "font-family", isSupportedFont, defaultFont);
1683
+ const anchor = setOption(options, "anchor", isSupportedAnchor, "left");
1684
+ const rawheight = setOption(options, "letter-height", isPositiveNumber, 100);
1685
+ const rawThickness = setOption(options, "letter-thickness", isPositiveNumber, 1);
1686
+ const basicColor = setOption(options, "color", isString, defaultColor);
1687
+ const opac = setOption(options, "alpha", isAmplitude, defaultOpac);
1688
+ const y = setOption(position, "y", isNumber, 0);
1689
+ const x = setOption(position, "x", isNumber, 0);
1690
+ const z = setOption(position, "z", isNumber, 0);
1691
+ const diffuse = setOption(colors, "diffuse", isString, "#404040"); // Dark gray - lets emissive show
1692
+ const specular = setOption(colors, "specular", isString, "#000000");
1693
+ const ambient = setOption(colors, "ambient", isString, "#202020"); // Very dark - minimal ambient response
1694
+ const emissive = setOption(colors, "emissive", isString, basicColor);
1695
+ const emissiveOnly = setOption(options, "emissive-only", isBoolean, false);
1696
+ const fogEnabled = setOption(options, "fog-enabled", isBoolean, true);
1697
+ const letterSpacingRaw = setOption(options, "letter-spacing", isNumber, 0);
1698
+ const wordSpacingRaw = setOption(options, "word-spacing", isNumber, 0);
1699
+ const fontSpec = getFont(fontFamily);
1700
+ const letterScale = round(scale * rawheight / naturalLetterHeight);
1701
+ const thickness = round(scale * rawThickness);
1702
+ const letters = isString(lttrs) ? lttrs : "";
1703
+ // Scale spacing values to match letter scale
1704
+ const letterSpacing = letterSpacingRaw * scale;
1705
+ const wordSpacing = wordSpacingRaw * scale;
1706
+
1707
+ // Create material
1708
+ const material = makeMaterial(scene, letters, emissive, ambient, specular, diffuse, opac, emissiveOnly, fogEnabled);
1709
+
1710
+ // Create letter meshes
1711
+ const meshesAndBoxes = constructLetterPolygons(
1712
+ letters, fontSpec, 0, 0, 0, letterScale, thickness, material, meshOrigin, scene,
1713
+ { letterSpacing, wordSpacing }
1714
+ );
1715
+ meshesAndBoxes[0];
1716
+ const lettersBoxes = meshesAndBoxes[1];
1717
+ const lettersOrigins = meshesAndBoxes[2];
1718
+ const xWidth = meshesAndBoxes.xWidth;
1719
+
1720
+ // Convert to SPS
1721
+ const combo = makeSPS(scene, meshesAndBoxes, material);
1722
+ const sps = combo[0];
1723
+ const mesh = combo[1];
1724
+ const faceCombo = combo.face || [];
1725
+ const faceSps = faceCombo[0];
1726
+ const faceMesh = faceCombo[1];
1727
+ let faceMaterial;
1728
+
1729
+ if (faceMesh) {
1730
+ faceMaterial = makeFaceMaterial(scene, letters, emissive, opac, fogEnabled);
1731
+ faceMesh.material = faceMaterial;
1732
+ if (mesh) {
1733
+ faceMesh.parent = mesh;
1734
+ faceMesh.layerMask = mesh.layerMask;
1735
+ faceMesh.renderingGroupId = mesh.renderingGroupId;
1736
+ }
1737
+ // Tiny offset to prevent z-fighting without leaving a visible gap
1738
+ // rotation.x=-PI/2 maps: +Y → -Z (toward camera), -Y → +Z (away)
1739
+ // We want face IN FRONT of rim, so use POSITIVE Y
1740
+ faceMesh.position.y = 0.001;
1741
+ faceMesh.isPickable = false;
1742
+ }
1743
+
1744
+ // Position mesh based on anchor
1745
+ const offsetX = anchor === "right"
1746
+ ? (0 - xWidth)
1747
+ : (anchor === "center" ? (0 - xWidth / 2) : 0);
1748
+
1749
+ if (mesh) {
1750
+ mesh.position.x = scale * x + offsetX;
1751
+ mesh.position.y = scale * y;
1752
+ mesh.position.z = scale * z;
1753
+ }
1754
+
1755
+ // Instance methods
1756
+ let color = basicColor;
1757
+ this.getSPS = () => sps;
1758
+ this.getMesh = () => mesh;
1759
+ this.getMaterial = () => material;
1760
+ this.getFaceMesh = () => faceMesh;
1761
+ this.getFaceMaterial = () => faceMaterial;
1762
+ this.getFaceSPS = () => faceSps;
1763
+ this.getOffsetX = () => offsetX;
1764
+ this.getLettersBoxes = () => lettersBoxes;
1765
+ this.getLettersOrigins = () => lettersOrigins;
1766
+ this.color = c => isString(c) ? color = c : color;
1767
+ this.alpha = o => isAmplitude(o) ? opac : opac;
1768
+ // Track disposed state to prevent double-disposal
1769
+ let _disposed = false;
1770
+
1771
+ this.clearall = function() {
1772
+ // Mark as disposed - getters will return null after this
1773
+ _disposed = true;
1774
+ };
1775
+
1776
+ this.isDisposed = function() {
1777
+ return _disposed;
1778
+ };
1779
+ }
1780
+
1781
+ // Prototype methods
1782
+ MeshWriter.prototype.setColor = function(color) {
1783
+ const material = this.getMaterial();
1784
+ if (material && isString(color)) {
1785
+ const next = rgb2Color3(this.color(color));
1786
+ material.emissiveColor = next;
1787
+ const faceMaterial = this.getFaceMaterial && this.getFaceMaterial();
1788
+ if (faceMaterial) {
1789
+ faceMaterial.emissiveColor = next;
1790
+ }
1791
+ }
1792
+ };
1793
+
1794
+ MeshWriter.prototype.setAlpha = function(alpha) {
1795
+ const material = this.getMaterial();
1796
+ if (material && isAmplitude(alpha)) {
1797
+ const next = this.alpha(alpha);
1798
+ material.alpha = next;
1799
+ const faceMaterial = this.getFaceMaterial && this.getFaceMaterial();
1800
+ if (faceMaterial) {
1801
+ faceMaterial.alpha = next;
1802
+ }
1803
+ }
1804
+ };
1805
+
1806
+ MeshWriter.prototype.overrideAlpha = function(alpha) {
1807
+ const material = this.getMaterial();
1808
+ if (material && isAmplitude(alpha)) {
1809
+ material.alpha = alpha;
1810
+ const faceMaterial = this.getFaceMaterial && this.getFaceMaterial();
1811
+ if (faceMaterial) {
1812
+ faceMaterial.alpha = alpha;
1813
+ }
1814
+ }
1815
+ };
1816
+
1817
+ MeshWriter.prototype.resetAlpha = function() {
1818
+ const material = this.getMaterial();
1819
+ const alpha = this.alpha();
1820
+ if (material) {
1821
+ material.alpha = alpha;
1822
+ }
1823
+ const faceMaterial = this.getFaceMaterial && this.getFaceMaterial();
1824
+ if (faceMaterial) {
1825
+ faceMaterial.alpha = alpha;
1826
+ }
1827
+ };
1828
+
1829
+ MeshWriter.prototype.getLetterCenter = function(ix) {
1830
+ return new Vector2(0, 0);
1831
+ };
1832
+
1833
+ MeshWriter.prototype.dispose = function() {
1834
+ // Prevent double-disposal
1835
+ if (this.isDisposed && this.isDisposed()) {
1836
+ return;
1837
+ }
1838
+
1839
+ // Dispose TextFogPlugin before materials to break circular references
1840
+ const material = this.getMaterial();
1841
+ if (material) {
1842
+ if (material._textFogPlugin && typeof material._textFogPlugin.dispose === 'function') {
1843
+ material._textFogPlugin.dispose();
1844
+ }
1845
+ material.dispose();
1846
+ }
1847
+
1848
+ const faceMaterial = this.getFaceMaterial && this.getFaceMaterial();
1849
+ if (faceMaterial) {
1850
+ if (faceMaterial._textFogPlugin && typeof faceMaterial._textFogPlugin.dispose === 'function') {
1851
+ faceMaterial._textFogPlugin.dispose();
1852
+ }
1853
+ faceMaterial.dispose();
1854
+ }
1855
+
1856
+ // Dispose SolidParticleSystem (which also disposes its mesh)
1857
+ const sps = this.getSPS();
1858
+ if (sps) {
1859
+ sps.dispose();
1860
+ }
1861
+
1862
+ // Dispose face mesh (merged mesh, not SPS-based)
1863
+ const faceMesh = this.getFaceMesh && this.getFaceMesh();
1864
+ if (faceMesh && typeof faceMesh.dispose === 'function') {
1865
+ faceMesh.dispose();
1866
+ }
1867
+
1868
+ // Mark as disposed
1869
+ this.clearall();
1870
+ };
1871
+
1872
+ return MeshWriter;
1873
+
1874
+ // Helper functions
1875
+ function isSupportedFont(ff) {
1876
+ return isFontRegistered(ff);
1877
+ }
1878
+
1879
+ function isSupportedAnchor(a) {
1880
+ return a === "left" || a === "right" || a === "center";
1881
+ }
1882
+ }
1883
+
1884
+ /**
1885
+ * Async factory for Babylon 8+ with CSG2
1886
+ * Handles CSG2 initialization automatically
1887
+ * @param {Scene} scene - Babylon.js scene
1888
+ * @param {MeshWriterPreferences} [preferences={}] - Configuration options
1889
+ * @returns {Promise<Function>} - MeshWriter constructor
1890
+ */
1891
+ async function createMeshWriterAsync(scene, preferences = {}) {
1892
+ // Initialize CSG module with Babylon methods
1893
+ if (preferences.babylon) {
1894
+ initCSGModule(preferences.babylon);
1895
+ } else {
1896
+ // Check for BABYLON global (UMD bundle usage)
1897
+ /** @type {any} */
1898
+ const globalBabylon = typeof globalThis !== 'undefined' ? globalThis.BABYLON : undefined;
1899
+ if (globalBabylon) {
1900
+ initCSGModule(globalBabylon);
1901
+ }
1902
+ }
1903
+
1904
+ // Initialize CSG2 if needed
1905
+ if (getCSGVersion() === 'CSG2' && !isCSGReady()) {
1906
+ await initializeCSG2();
1907
+ }
1908
+
1909
+ return createMeshWriter(scene, preferences);
1910
+ }
1911
+
1912
+ /**
1913
+ * MeshWriter Baked Font Loader
1914
+ * Loads pre-baked FontSpec JSON files at runtime.
1915
+ * Zero dependencies - just fetch and use.
1916
+ */
1917
+
1918
+ /**
1919
+ * Load a pre-baked font from a JSON file
1920
+ * @param {string} url - URL to the baked FontSpec JSON file
1921
+ * @returns {Promise<object>} - FontSpec object ready for use with MeshWriter
1922
+ *
1923
+ * @example
1924
+ * const fontSpec = await loadBakedFont('/fonts/baked/atkinson-hyperlegible-next-400.json');
1925
+ * registerFont('Atkinson400', fontSpec);
1926
+ */
1927
+ async function loadBakedFont(url) {
1928
+ const response = await fetch(url);
1929
+
1930
+ if (!response.ok) {
1931
+ throw new Error(`MeshWriter: Failed to load baked font from ${url} (HTTP ${response.status})`);
1932
+ }
1933
+
1934
+ const fontSpec = await response.json();
1935
+
1936
+ // Validate it looks like a FontSpec
1937
+ if (typeof fontSpec !== 'object' || fontSpec === null) {
1938
+ throw new Error(`MeshWriter: Invalid baked font data from ${url}`);
1939
+ }
1940
+
1941
+ return fontSpec;
1942
+ }
1943
+
1944
+ /**
1945
+ * Load multiple pre-baked weights from a manifest
1946
+ * @param {string} manifestUrl - URL to the manifest.json file
1947
+ * @param {number[]} [weights] - Specific weights to load (loads all if omitted)
1948
+ * @returns {Promise<Map<number, object>>} - Map of weight -> FontSpec
1949
+ *
1950
+ * @example
1951
+ * const fonts = await loadBakedFontsFromManifest('/fonts/baked/manifest.json', [400, 450]);
1952
+ * const fontSpec = fonts.get(400);
1953
+ */
1954
+ async function loadBakedFontsFromManifest(manifestUrl, weights) {
1955
+ const response = await fetch(manifestUrl);
1956
+
1957
+ if (!response.ok) {
1958
+ throw new Error(`MeshWriter: Failed to load manifest from ${manifestUrl} (HTTP ${response.status})`);
1959
+ }
1960
+
1961
+ const manifest = await response.json();
1962
+ const baseUrl = manifestUrl.substring(0, manifestUrl.lastIndexOf('/') + 1);
1963
+
1964
+ // Determine which weights to load
1965
+ const weightsToLoad = weights
1966
+ ? weights.filter(w => manifest.weights.includes(w))
1967
+ : manifest.weights;
1968
+
1969
+ if (weights && weightsToLoad.length !== weights.length) {
1970
+ const missing = weights.filter(w => !manifest.weights.includes(w));
1971
+ console.warn(`MeshWriter: Requested weights not available: ${missing.join(', ')}`);
1972
+ console.warn(`MeshWriter: Available weights: ${manifest.weights.join(', ')}`);
1973
+ }
1974
+
1975
+ // Load all requested weights in parallel
1976
+ const results = await Promise.all(
1977
+ weightsToLoad.map(async (weight) => {
1978
+ const idx = manifest.weights.indexOf(weight);
1979
+ const file = manifest.files[idx];
1980
+ const fontSpec = await loadBakedFont(baseUrl + file);
1981
+ return { weight, fontSpec };
1982
+ })
1983
+ );
1984
+
1985
+ // Build map
1986
+ const fontMap = new Map();
1987
+ for (const { weight, fontSpec } of results) {
1988
+ fontMap.set(weight, fontSpec);
1989
+ }
1990
+
1991
+ return fontMap;
1992
+ }
1993
+
1994
+ /**
1995
+ * Find the nearest available weight from a set of baked weights
1996
+ * @param {number} targetWeight - Desired weight
1997
+ * @param {number[]} availableWeights - Array of available weights
1998
+ * @returns {number} - Nearest available weight
1999
+ *
2000
+ * @example
2001
+ * const nearest = findNearestWeight(425, [400, 450, 500]);
2002
+ * // Returns 450 (closest to 425)
2003
+ */
2004
+ function findNearestWeight(targetWeight, availableWeights) {
2005
+ if (!availableWeights || availableWeights.length === 0) {
2006
+ throw new Error('MeshWriter: No available weights provided');
2007
+ }
2008
+
2009
+ let nearest = availableWeights[0];
2010
+ let minDiff = Math.abs(targetWeight - nearest);
2011
+
2012
+ for (const weight of availableWeights) {
2013
+ const diff = Math.abs(targetWeight - weight);
2014
+ if (diff < minDiff) {
2015
+ minDiff = diff;
2016
+ nearest = weight;
2017
+ }
2018
+ }
2019
+
2020
+ return nearest;
2021
+ }
2022
+
2023
+ /**
2024
+ * Get manifest info without loading fonts
2025
+ * @param {string} manifestUrl - URL to the manifest.json file
2026
+ * @returns {Promise<object>} - Manifest object with fontName, weights, etc.
2027
+ */
2028
+ async function getBakedFontManifest(manifestUrl) {
2029
+ const response = await fetch(manifestUrl);
2030
+
2031
+ if (!response.ok) {
2032
+ throw new Error(`MeshWriter: Failed to load manifest from ${manifestUrl} (HTTP ${response.status})`);
2033
+ }
2034
+
2035
+ return response.json();
2036
+ }
2037
+
2038
+ /**
2039
+ * Color Contrast Utilities for WCAG Compliance
2040
+ * Provides color manipulation for dyslexia accessibility
2041
+ */
2042
+
2043
+ // ============================================
2044
+ // Color Conversion Utilities
2045
+ // ============================================
2046
+
2047
+ /**
2048
+ * Convert hex color string to RGB object (0-1 range)
2049
+ * @param {string} hex - Hex color string (e.g., "#FF0000" or "FF0000")
2050
+ * @returns {{r: number, g: number, b: number}}
2051
+ */
2052
+ function hexToRgb(hex) {
2053
+ hex = hex.replace("#", "");
2054
+ return {
2055
+ r: parseInt(hex.substring(0, 2), 16) / 255,
2056
+ g: parseInt(hex.substring(2, 4), 16) / 255,
2057
+ b: parseInt(hex.substring(4, 6), 16) / 255
2058
+ };
2059
+ }
2060
+
2061
+ /**
2062
+ * Convert RGB object (0-1 range) to hex color string
2063
+ * @param {{r: number, g: number, b: number}} rgb
2064
+ * @returns {string}
2065
+ */
2066
+ function rgbToHex(rgb) {
2067
+ var r = Math.round(Math.max(0, Math.min(1, rgb.r)) * 255);
2068
+ var g = Math.round(Math.max(0, Math.min(1, rgb.g)) * 255);
2069
+ var b = Math.round(Math.max(0, Math.min(1, rgb.b)) * 255);
2070
+ return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase();
2071
+ }
2072
+
2073
+ /**
2074
+ * Convert RGB to HSL
2075
+ * @param {number} r - Red (0-1)
2076
+ * @param {number} g - Green (0-1)
2077
+ * @param {number} b - Blue (0-1)
2078
+ * @returns {{h: number, s: number, l: number}} - h in degrees (0-360), s and l in 0-1
2079
+ */
2080
+ function rgbToHsl(r, g, b) {
2081
+ var max = Math.max(r, g, b);
2082
+ var min = Math.min(r, g, b);
2083
+ var l = (max + min) / 2;
2084
+ var h, s;
2085
+
2086
+ if (max === min) {
2087
+ h = s = 0; // achromatic
2088
+ } else {
2089
+ var d = max - min;
2090
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
2091
+
2092
+ switch (max) {
2093
+ case r:
2094
+ h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
2095
+ break;
2096
+ case g:
2097
+ h = ((b - r) / d + 2) / 6;
2098
+ break;
2099
+ case b:
2100
+ h = ((r - g) / d + 4) / 6;
2101
+ break;
2102
+ }
2103
+ h *= 360;
2104
+ }
2105
+
2106
+ return { h: h, s: s, l: l };
2107
+ }
2108
+
2109
+ /**
2110
+ * Convert HSL to RGB
2111
+ * @param {number} h - Hue in degrees (0-360)
2112
+ * @param {number} s - Saturation (0-1)
2113
+ * @param {number} l - Lightness (0-1)
2114
+ * @returns {{r: number, g: number, b: number}}
2115
+ */
2116
+ function hslToRgb(h, s, l) {
2117
+ var r, g, b;
2118
+
2119
+ if (s === 0) {
2120
+ r = g = b = l; // achromatic
2121
+ } else {
2122
+ function hue2rgb(p, q, t) {
2123
+ if (t < 0) t += 1;
2124
+ if (t > 1) t -= 1;
2125
+ if (t < 1 / 6) return p + (q - p) * 6 * t;
2126
+ if (t < 1 / 2) return q;
2127
+ if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
2128
+ return p;
2129
+ }
2130
+
2131
+ var q = l < 0.5 ? l * (1 + s) : l + s - l * s;
2132
+ var p = 2 * l - q;
2133
+ var hNorm = h / 360;
2134
+
2135
+ r = hue2rgb(p, q, hNorm + 1 / 3);
2136
+ g = hue2rgb(p, q, hNorm);
2137
+ b = hue2rgb(p, q, hNorm - 1 / 3);
2138
+ }
2139
+
2140
+ return { r: r, g: g, b: b };
2141
+ }
2142
+
2143
+ // ============================================
2144
+ // WCAG Luminance Calculations
2145
+ // ============================================
2146
+
2147
+ /**
2148
+ * Linearize an sRGB channel value
2149
+ * @param {number} c - Channel value (0-1)
2150
+ * @returns {number} - Linearized value
2151
+ */
2152
+ function linearize(c) {
2153
+ return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
2154
+ }
2155
+
2156
+ /**
2157
+ * Calculate relative luminance per WCAG 2.1
2158
+ * @param {number} r - Red (0-1)
2159
+ * @param {number} g - Green (0-1)
2160
+ * @param {number} b - Blue (0-1)
2161
+ * @returns {number} - Relative luminance (0-1)
2162
+ */
2163
+ function relativeLuminance(r, g, b) {
2164
+ var rLin = linearize(r);
2165
+ var gLin = linearize(g);
2166
+ var bLin = linearize(b);
2167
+ return 0.2126 * rLin + 0.7152 * gLin + 0.0722 * bLin;
2168
+ }
2169
+
2170
+ /**
2171
+ * Calculate WCAG contrast ratio between two luminance values
2172
+ * @param {number} L1 - Luminance of first color (0-1)
2173
+ * @param {number} L2 - Luminance of second color (0-1)
2174
+ * @returns {number} - Contrast ratio (1-21)
2175
+ */
2176
+ function contrastRatio(L1, L2) {
2177
+ var lighter = Math.max(L1, L2);
2178
+ var darker = Math.min(L1, L2);
2179
+ return (lighter + 0.05) / (darker + 0.05);
2180
+ }
2181
+
2182
+ // ============================================
2183
+ // Luminance Adjustment
2184
+ // ============================================
2185
+
2186
+ /**
2187
+ * Adjust color to target luminance while preserving hue
2188
+ * Uses binary search in HSL space
2189
+ * Desaturates significantly at low lightness for better visual contrast
2190
+ * @param {{r: number, g: number, b: number}} rgb
2191
+ * @param {number} targetLum - Target relative luminance (0-1)
2192
+ * @returns {{r: number, g: number, b: number}}
2193
+ */
2194
+ function adjustToLuminance(rgb, targetLum) {
2195
+ var hsl = rgbToHsl(rgb.r, rgb.g, rgb.b);
2196
+
2197
+ // Binary search to find lightness that achieves target luminance
2198
+ var minL = 0;
2199
+ var maxL = 1;
2200
+ var iterations = 20;
2201
+ var finalL;
2202
+
2203
+ for (var i = 0; i < iterations; i++) {
2204
+ var midL = (minL + maxL) / 2;
2205
+ var testRgb = hslToRgb(hsl.h, hsl.s, midL);
2206
+ var testLum = relativeLuminance(testRgb.r, testRgb.g, testRgb.b);
2207
+
2208
+ if (testLum < targetLum) {
2209
+ minL = midL;
2210
+ } else {
2211
+ maxL = midL;
2212
+ }
2213
+ }
2214
+
2215
+ finalL = (minL + maxL) / 2;
2216
+
2217
+ // Desaturate significantly at low lightness for better visual contrast
2218
+ // Dark saturated colors (e.g., dark yellow) don't look distinct enough
2219
+ // Scale saturation based on lightness: below 0.3 lightness, reduce saturation
2220
+ var finalS = hsl.s;
2221
+ if (finalL < 0.3) {
2222
+ // Linear ramp: at L=0.3, keep 100% saturation; at L=0, keep 20% saturation
2223
+ var saturationScale = 0.2 + (finalL / 0.3) * 0.8;
2224
+ finalS = hsl.s * saturationScale;
2225
+ }
2226
+
2227
+ return hslToRgb(hsl.h, finalS, finalL);
2228
+ }
2229
+
2230
+ // ============================================
2231
+ // Auto-Derive Edge Colors
2232
+ // ============================================
2233
+
2234
+ /**
2235
+ * Auto-derive edge colors (diffuse/ambient) from emissive color
2236
+ * Creates high-contrast edges for text legibility
2237
+ *
2238
+ * INVERTED APPROACH: Since emissive adds to all surfaces equally,
2239
+ * we flip the strategy - put bright color in diffuse (shows on lit surfaces)
2240
+ * and dark color in emissive (base for unlit surfaces).
2241
+ * Returns modified emissive along with diffuse/ambient.
2242
+ *
2243
+ * @param {string} emissiveHex - Hex color string for desired face color
2244
+ * @param {number} [targetContrast=4.5] - Target WCAG contrast ratio
2245
+ * @returns {{diffuse: string, ambient: string, emissive: string}}
2246
+ */
2247
+ function deriveEdgeColors(emissiveHex, targetContrast) {
2248
+ targetContrast = targetContrast || 4.5;
2249
+
2250
+ var rgb = hexToRgb(emissiveHex);
2251
+ var faceLum = relativeLuminance(rgb.r, rgb.g, rgb.b);
2252
+
2253
+ // Calculate target luminance for dark areas to achieve contrast
2254
+ var darkLum;
2255
+ if (faceLum > 0.5) {
2256
+ // Bright face needs dark edges
2257
+ darkLum = (faceLum + 0.05) / targetContrast - 0.05;
2258
+ darkLum = Math.max(darkLum, 0.0);
2259
+ } else {
2260
+ // Dark face needs light edges (invert the logic)
2261
+ darkLum = targetContrast * (faceLum + 0.05) - 0.05;
2262
+ darkLum = Math.min(darkLum, 1.0);
2263
+ }
2264
+
2265
+ // Handle edge cases
2266
+ if (faceLum > 0.95) {
2267
+ darkLum = Math.min(0.1, darkLum);
2268
+ } else if (faceLum < 0.05) {
2269
+ darkLum = Math.max(0.5, darkLum);
2270
+ }
2271
+
2272
+ // Generate dark color (desaturated at low lightness)
2273
+ var darkRgb = adjustToLuminance(rgb, darkLum);
2274
+
2275
+ // INVERTED APPROACH:
2276
+ // - diffuse = bright (the user's desired face color) - shows on lit surfaces
2277
+ // - emissive = dark - base color for all surfaces (unlit areas show this)
2278
+ // - ambient = very dark - shadowed areas
2279
+ var ambientLum = darkLum * 0.5;
2280
+ var ambientRgb = adjustToLuminance(rgb, Math.max(0, ambientLum));
2281
+
2282
+ return {
2283
+ diffuse: emissiveHex, // Bright color for lit surfaces
2284
+ ambient: rgbToHex(ambientRgb), // Very dark for shadows
2285
+ emissive: rgbToHex(darkRgb) // Dark base for unlit areas
2286
+ };
2287
+ }
2288
+
2289
+ // ============================================
2290
+ // High-Contrast Adjustment Algorithm
2291
+ // ============================================
2292
+
2293
+ /**
2294
+ * Adjust color by a factor (lightness change with optional hue shift)
2295
+ * @param {{r: number, g: number, b: number}} rgb
2296
+ * @param {number} factor - Adjustment factor (-1 to 1, negative = darken)
2297
+ * @param {boolean} allowHueShift
2298
+ * @returns {{r: number, g: number, b: number}}
2299
+ */
2300
+ function adjustColorByFactor(rgb, factor, allowHueShift) {
2301
+ var hsl = rgbToHsl(rgb.r, rgb.g, rgb.b);
2302
+
2303
+ // Adjust lightness
2304
+ var newL = hsl.l + factor;
2305
+ newL = Math.max(0, Math.min(1, newL));
2306
+
2307
+ // Optionally shift hue for extreme adjustments
2308
+ var newH = hsl.h;
2309
+ if (allowHueShift && Math.abs(factor) > 0.2) {
2310
+ // Slight hue shift toward yellow (high luminance) or blue (low luminance)
2311
+ var hueTarget = factor > 0 ? 60 : 240;
2312
+ newH = hsl.h + (hueTarget - hsl.h) * Math.abs(factor) * 0.1;
2313
+ newH = ((newH % 360) + 360) % 360;
2314
+ }
2315
+
2316
+ // Reduce saturation at extreme lightness for natural look
2317
+ var newS = hsl.s;
2318
+ if (newL > 0.9 || newL < 0.1) {
2319
+ newS *= 0.5;
2320
+ }
2321
+
2322
+ return hslToRgb(newH, newS, newL);
2323
+ }
2324
+
2325
+ /**
2326
+ * Oscillate edge colors to find best contrast
2327
+ * @param {{r: number, g: number, b: number}} emissive
2328
+ * @param {{r: number, g: number, b: number}} diffuse
2329
+ * @param {{r: number, g: number, b: number}} ambient
2330
+ * @param {object} options
2331
+ * @returns {{diffuse: object, ambient: object, achieved: number}}
2332
+ */
2333
+ function oscillateEdges(emissive, diffuse, ambient, options) {
2334
+ var emissiveLum = relativeLuminance(emissive.r, emissive.g, emissive.b);
2335
+ var diffuseLum = relativeLuminance(diffuse.r, diffuse.g, diffuse.b);
2336
+ var currentContrast = contrastRatio(emissiveLum, diffuseLum);
2337
+
2338
+ var bestResult = { diffuse: diffuse, ambient: ambient, achieved: currentContrast };
2339
+
2340
+ // Determine direction: edges should go opposite to emissive luminance
2341
+ var direction = emissiveLum > 0.5 ? -1 : 1;
2342
+
2343
+ var steps = 10;
2344
+ for (var i = 1; i <= steps; i++) {
2345
+ var factor = (i / steps) * options.range;
2346
+
2347
+ // Primary direction
2348
+ var testDiffuse = adjustColorByFactor(diffuse, direction * factor, options.allowHueShift);
2349
+ var testAmbient = adjustColorByFactor(ambient, direction * factor * 0.8, options.allowHueShift);
2350
+
2351
+ var testLum = relativeLuminance(testDiffuse.r, testDiffuse.g, testDiffuse.b);
2352
+ var contrast = contrastRatio(emissiveLum, testLum);
2353
+
2354
+ if (contrast > bestResult.achieved) {
2355
+ bestResult = { diffuse: testDiffuse, ambient: testAmbient, achieved: contrast };
2356
+ }
2357
+
2358
+ if (contrast >= options.targetContrast) break;
2359
+
2360
+ // Try opposite direction for edge cases
2361
+ testDiffuse = adjustColorByFactor(diffuse, -direction * factor, options.allowHueShift);
2362
+ testAmbient = adjustColorByFactor(ambient, -direction * factor * 0.8, options.allowHueShift);
2363
+
2364
+ testLum = relativeLuminance(testDiffuse.r, testDiffuse.g, testDiffuse.b);
2365
+ contrast = contrastRatio(emissiveLum, testLum);
2366
+
2367
+ if (contrast > bestResult.achieved) {
2368
+ bestResult = { diffuse: testDiffuse, ambient: testAmbient, achieved: contrast };
2369
+ }
2370
+ }
2371
+
2372
+ return bestResult;
2373
+ }
2374
+
2375
+ /**
2376
+ * Oscillate face color to find better contrast
2377
+ * @param {{r: number, g: number, b: number}} emissive
2378
+ * @param {{r: number, g: number, b: number}} diffuse
2379
+ * @param {object} options
2380
+ * @returns {{emissive: object, achieved: number}}
2381
+ */
2382
+ function oscillateFace(emissive, diffuse, options) {
2383
+ var diffuseLum = relativeLuminance(diffuse.r, diffuse.g, diffuse.b);
2384
+ var emissiveLum = relativeLuminance(emissive.r, emissive.g, emissive.b);
2385
+ var currentContrast = contrastRatio(emissiveLum, diffuseLum);
2386
+
2387
+ var bestResult = { emissive: emissive, achieved: currentContrast };
2388
+
2389
+ // Face should move opposite to edges
2390
+ var direction = diffuseLum > 0.5 ? -1 : 1;
2391
+
2392
+ var steps = 10;
2393
+ for (var i = 1; i <= steps; i++) {
2394
+ var factor = (i / steps) * options.range;
2395
+
2396
+ var testEmissive = adjustColorByFactor(emissive, direction * factor, options.allowHueShift);
2397
+ var testLum = relativeLuminance(testEmissive.r, testEmissive.g, testEmissive.b);
2398
+ var contrast = contrastRatio(testLum, diffuseLum);
2399
+
2400
+ if (contrast > bestResult.achieved) {
2401
+ bestResult = { emissive: testEmissive, achieved: contrast };
2402
+ }
2403
+
2404
+ if (contrast >= options.targetContrast) break;
2405
+ }
2406
+
2407
+ return bestResult;
2408
+ }
2409
+
2410
+ /**
2411
+ * Adjust colors to achieve WCAG contrast while preserving user intent
2412
+ * Priority: prefer edge modifications over face modifications
2413
+ *
2414
+ * @param {object} colors - User-provided colors
2415
+ * @param {string} colors.emissive - Face color (hex)
2416
+ * @param {string} colors.diffuse - Edge lit color (hex)
2417
+ * @param {string} [colors.ambient] - Edge shadow color (hex)
2418
+ * @param {object} [options]
2419
+ * @param {number} [options.targetContrast=4.5] - Target contrast ratio
2420
+ * @param {number} [options.edgeRange=0.4] - Max edge modification (0-1)
2421
+ * @param {number} [options.faceRange=0.1] - Max face modification (0-1)
2422
+ * @param {boolean} [options.allowHueShift=true] - Allow hue modifications
2423
+ * @returns {{emissive: string, diffuse: string, ambient: string, achieved: number}}
2424
+ */
2425
+ function adjustForContrast(colors, options) {
2426
+ options = options || {};
2427
+ var targetContrast = options.targetContrast || 4.5;
2428
+ var edgeRange = options.edgeRange || 0.4;
2429
+ var faceRange = options.faceRange || 0.1;
2430
+ var allowHueShift = options.allowHueShift !== false;
2431
+
2432
+ var emissive = hexToRgb(colors.emissive);
2433
+ var diffuse = hexToRgb(colors.diffuse);
2434
+ var ambient = colors.ambient ? hexToRgb(colors.ambient) : { r: diffuse.r * 0.5, g: diffuse.g * 0.5, b: diffuse.b * 0.5 };
2435
+
2436
+ var emissiveLum = relativeLuminance(emissive.r, emissive.g, emissive.b);
2437
+ var diffuseLum = relativeLuminance(diffuse.r, diffuse.g, diffuse.b);
2438
+ var currentContrast = contrastRatio(emissiveLum, diffuseLum);
2439
+
2440
+ // Already meets target?
2441
+ if (currentContrast >= targetContrast) {
2442
+ return {
2443
+ emissive: colors.emissive,
2444
+ diffuse: colors.diffuse,
2445
+ ambient: colors.ambient || rgbToHex(ambient),
2446
+ achieved: currentContrast
2447
+ };
2448
+ }
2449
+
2450
+ // Phase 1: Try edge modification only
2451
+ var edgeResult = oscillateEdges(emissive, diffuse, ambient, {
2452
+ targetContrast: targetContrast,
2453
+ range: edgeRange,
2454
+ allowHueShift: allowHueShift
2455
+ });
2456
+
2457
+ if (edgeResult.achieved >= targetContrast) {
2458
+ return {
2459
+ emissive: colors.emissive,
2460
+ diffuse: rgbToHex(edgeResult.diffuse),
2461
+ ambient: rgbToHex(edgeResult.ambient),
2462
+ achieved: edgeResult.achieved
2463
+ };
2464
+ }
2465
+
2466
+ // Phase 2: Try face modification
2467
+ var faceResult = oscillateFace(emissive, edgeResult.diffuse, {
2468
+ targetContrast: targetContrast,
2469
+ range: faceRange,
2470
+ allowHueShift: allowHueShift
2471
+ });
2472
+
2473
+ if (faceResult.achieved >= targetContrast) {
2474
+ return {
2475
+ emissive: rgbToHex(faceResult.emissive),
2476
+ diffuse: rgbToHex(edgeResult.diffuse),
2477
+ ambient: rgbToHex(edgeResult.ambient),
2478
+ achieved: faceResult.achieved
2479
+ };
2480
+ }
2481
+
2482
+ // Phase 3: Oscillate both until convergence
2483
+ var maxIterations = 5;
2484
+ var currentEmissive = faceResult.emissive;
2485
+ var currentDiffuse = edgeResult.diffuse;
2486
+ var currentAmbient = edgeResult.ambient;
2487
+ var bestAchieved = faceResult.achieved;
2488
+
2489
+ for (var iter = 0; iter < maxIterations; iter++) {
2490
+ // Try more edge adjustment
2491
+ var newEdgeResult = oscillateEdges(currentEmissive, currentDiffuse, currentAmbient, {
2492
+ targetContrast: targetContrast,
2493
+ range: edgeRange * 0.5,
2494
+ allowHueShift: allowHueShift
2495
+ });
2496
+
2497
+ if (newEdgeResult.achieved >= targetContrast) {
2498
+ return {
2499
+ emissive: rgbToHex(currentEmissive),
2500
+ diffuse: rgbToHex(newEdgeResult.diffuse),
2501
+ ambient: rgbToHex(newEdgeResult.ambient),
2502
+ achieved: newEdgeResult.achieved
2503
+ };
2504
+ }
2505
+
2506
+ // Try more face adjustment
2507
+ var newFaceResult = oscillateFace(currentEmissive, newEdgeResult.diffuse, {
2508
+ targetContrast: targetContrast,
2509
+ range: faceRange * 0.5,
2510
+ allowHueShift: allowHueShift
2511
+ });
2512
+
2513
+ if (newFaceResult.achieved >= targetContrast) {
2514
+ return {
2515
+ emissive: rgbToHex(newFaceResult.emissive),
2516
+ diffuse: rgbToHex(newEdgeResult.diffuse),
2517
+ ambient: rgbToHex(newEdgeResult.ambient),
2518
+ achieved: newFaceResult.achieved
2519
+ };
2520
+ }
2521
+
2522
+ // Update for next iteration
2523
+ if (newFaceResult.achieved > bestAchieved) {
2524
+ bestAchieved = newFaceResult.achieved;
2525
+ currentEmissive = newFaceResult.emissive;
2526
+ currentDiffuse = newEdgeResult.diffuse;
2527
+ currentAmbient = newEdgeResult.ambient;
2528
+ } else {
2529
+ // No improvement, stop
2530
+ break;
2531
+ }
2532
+ }
2533
+
2534
+ // Return best result even if target not achieved
2535
+ return {
2536
+ emissive: rgbToHex(currentEmissive),
2537
+ diffuse: rgbToHex(currentDiffuse),
2538
+ ambient: rgbToHex(currentAmbient),
2539
+ achieved: bestAchieved
2540
+ };
2541
+ }
2542
+
2543
+ // ============================================
2544
+ // Constants
2545
+ // ============================================
2546
+
2547
+ var CONTRAST_LEVELS = {
2548
+ AA_NORMAL: 4.5,
2549
+ AA_LARGE: 3.0,
2550
+ AAA_NORMAL: 7.0,
2551
+ AAA_LARGE: 4.5
2552
+ };
2553
+
2554
+ /**
2555
+ * MeshWriter - 3D Text Rendering for Babylon.js
2556
+ *
2557
+ * @example
2558
+ * // ES Module usage
2559
+ * import { MeshWriter, registerFont } from 'meshwriter';
2560
+ * import helvetica from 'meshwriter/fonts/helvetica';
2561
+ *
2562
+ * registerFont('Helvetica', helvetica);
2563
+ * const Writer = await MeshWriter.createAsync(scene);
2564
+ * const text = new Writer("Hello World", { "letter-height": 20 });
2565
+ */
2566
+
2567
+
2568
+ const MeshWriter = {
2569
+ /**
2570
+ * Create MeshWriter async (recommended for Babylon 8+)
2571
+ */
2572
+ createAsync: createMeshWriterAsync,
2573
+
2574
+ /**
2575
+ * Create MeshWriter sync (for Babylon < 8 or when CSG2 is pre-initialized)
2576
+ */
2577
+ create: createMeshWriter,
2578
+
2579
+ // Static CSG methods
2580
+ isReady: isCSGReady,
2581
+ getCSGVersion,
2582
+ setCSGInitializer,
2583
+ setCSGReadyCheck,
2584
+ onCSGReady,
2585
+ markCSGReady: markCSGInitialized,
2586
+ initCSGModule,
2587
+
2588
+ // Font methods
2589
+ registerFont,
2590
+ registerFontAliases,
2591
+ getFont,
2592
+ isFontRegistered,
2593
+
2594
+ // Encoding utilities
2595
+ codeList,
2596
+ decodeList,
2597
+
2598
+ // Baked font methods (lightweight alternative to variable fonts)
2599
+ loadBakedFont,
2600
+ loadBakedFontsFromManifest,
2601
+ findNearestWeight,
2602
+ getBakedFontManifest
2603
+ };
2604
+
2605
+ export { CONTRAST_LEVELS, MeshWriter, TextFogPlugin, adjustForContrast, clearFonts, codeList, contrastRatio, createMeshWriter, createMeshWriterAsync, decodeList as decodeFontData, decodeList, MeshWriter as default, deriveEdgeColors, codeList as encodeFontData, findNearestWeight, getBakedFontManifest, getCSGVersion, getFont, getRegisteredFonts, hexToRgb, hslToRgb, initCSGModule, isCSGReady, isFontRegistered, loadBakedFont, loadBakedFontsFromManifest, markCSGInitialized as markCSGReady, onCSGReady, registerFont, registerFontAliases, relativeLuminance, rgbToHex, rgbToHsl, setCSGInitializer, setCSGReadyCheck, unregisterFont };
2606
+ //# sourceMappingURL=meshwriter.esm.js.map