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