meshwriter-cudu 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/LICENSE.md +11 -0
  2. package/README.md +349 -0
  3. package/dist/fonts/comic-sans.d.ts +1105 -0
  4. package/dist/fonts/helvetica.d.ts +1208 -0
  5. package/dist/fonts/hiruko-pro.d.ts +658 -0
  6. package/dist/fonts/jura.d.ts +750 -0
  7. package/dist/fonts/webgl-dings.d.ts +109 -0
  8. package/dist/index.d.ts +295 -0
  9. package/dist/meshwriter.cjs.js +2645 -0
  10. package/dist/meshwriter.cjs.js.map +1 -0
  11. package/dist/meshwriter.esm.js +2606 -0
  12. package/dist/meshwriter.esm.js.map +1 -0
  13. package/dist/meshwriter.min.js +2 -0
  14. package/dist/meshwriter.min.js.map +1 -0
  15. package/dist/meshwriter.umd.js +7146 -0
  16. package/dist/meshwriter.umd.js.map +1 -0
  17. package/dist/src/babylonImports.d.ts +11 -0
  18. package/dist/src/bakedFontLoader.d.ts +43 -0
  19. package/dist/src/colorContrast.d.ts +117 -0
  20. package/dist/src/csg.d.ts +55 -0
  21. package/dist/src/curves.d.ts +20 -0
  22. package/dist/src/fogPlugin.d.ts +32 -0
  23. package/dist/src/fontCompression.d.ts +12 -0
  24. package/dist/src/fontRegistry.d.ts +54 -0
  25. package/dist/src/index.d.ts +47 -0
  26. package/dist/src/letterMesh.d.ts +46 -0
  27. package/dist/src/material.d.ts +34 -0
  28. package/dist/src/meshSplitter.d.ts +10 -0
  29. package/dist/src/meshwriter.d.ts +46 -0
  30. package/dist/src/sps.d.ts +27 -0
  31. package/dist/src/umd-entry.d.ts +3 -0
  32. package/dist/src/utils.d.ts +12 -0
  33. package/dist/src/variableFontCache.d.ts +56 -0
  34. package/dist/src/variableFontConverter.d.ts +21 -0
  35. package/dist/src/variableFontLoader.d.ts +99 -0
  36. package/fonts/Figure1.png +0 -0
  37. package/fonts/LICENSE-OFL.txt +93 -0
  38. package/fonts/README.md +174 -0
  39. package/fonts/atkinson-hyperlegible-next.d.ts +8 -0
  40. package/fonts/atkinson-hyperlegible-next.js +6576 -0
  41. package/fonts/atkinson-hyperlegible.js +3668 -0
  42. package/fonts/baked/atkinson-hyperlegible-next-200.json +1 -0
  43. package/fonts/baked/atkinson-hyperlegible-next-250.json +1 -0
  44. package/fonts/baked/atkinson-hyperlegible-next-300.json +1 -0
  45. package/fonts/baked/atkinson-hyperlegible-next-350.json +1 -0
  46. package/fonts/baked/atkinson-hyperlegible-next-400.json +1 -0
  47. package/fonts/baked/atkinson-hyperlegible-next-450.json +1 -0
  48. package/fonts/baked/atkinson-hyperlegible-next-500.json +1 -0
  49. package/fonts/baked/atkinson-hyperlegible-next-550.json +1 -0
  50. package/fonts/baked/atkinson-hyperlegible-next-600.json +1 -0
  51. package/fonts/baked/atkinson-hyperlegible-next-650.json +1 -0
  52. package/fonts/baked/atkinson-hyperlegible-next-700.json +1 -0
  53. package/fonts/baked/atkinson-hyperlegible-next-750.json +1 -0
  54. package/fonts/baked/atkinson-hyperlegible-next-800.json +1 -0
  55. package/fonts/baked/manifest.json +41 -0
  56. package/fonts/comic-sans.js +1532 -0
  57. package/fonts/helvetica.js +1695 -0
  58. package/fonts/hiruko-pro.js +838 -0
  59. package/fonts/index.js +16 -0
  60. package/fonts/jura.js +994 -0
  61. package/fonts/variable/atkinson-hyperlegible-next-variable.ttf +0 -0
  62. package/fonts/webgl-dings.js +113 -0
  63. package/package.json +76 -0
  64. package/src/babylonImports.js +29 -0
  65. package/src/bakedFontLoader.js +125 -0
  66. package/src/colorContrast.js +528 -0
  67. package/src/csg.js +220 -0
  68. package/src/curves.js +67 -0
  69. package/src/fogPlugin.js +98 -0
  70. package/src/fontCompression.js +141 -0
  71. package/src/fontRegistry.js +98 -0
  72. package/src/globals.d.ts +20 -0
  73. package/src/index.js +136 -0
  74. package/src/letterMesh.js +417 -0
  75. package/src/material.js +103 -0
  76. package/src/meshSplitter.js +337 -0
  77. package/src/meshwriter.js +303 -0
  78. package/src/sps.js +106 -0
  79. package/src/types.d.ts +551 -0
  80. package/src/umd-entry.js +130 -0
  81. package/src/utils.js +57 -0
@@ -0,0 +1,528 @@
1
+ /**
2
+ * Color Contrast Utilities for WCAG Compliance
3
+ * Provides color manipulation for dyslexia accessibility
4
+ */
5
+
6
+ // ============================================
7
+ // Color Conversion Utilities
8
+ // ============================================
9
+
10
+ /**
11
+ * Convert hex color string to RGB object (0-1 range)
12
+ * @param {string} hex - Hex color string (e.g., "#FF0000" or "FF0000")
13
+ * @returns {{r: number, g: number, b: number}}
14
+ */
15
+ export function hexToRgb(hex) {
16
+ hex = hex.replace("#", "");
17
+ return {
18
+ r: parseInt(hex.substring(0, 2), 16) / 255,
19
+ g: parseInt(hex.substring(2, 4), 16) / 255,
20
+ b: parseInt(hex.substring(4, 6), 16) / 255
21
+ };
22
+ }
23
+
24
+ /**
25
+ * Convert RGB object (0-1 range) to hex color string
26
+ * @param {{r: number, g: number, b: number}} rgb
27
+ * @returns {string}
28
+ */
29
+ export function rgbToHex(rgb) {
30
+ var r = Math.round(Math.max(0, Math.min(1, rgb.r)) * 255);
31
+ var g = Math.round(Math.max(0, Math.min(1, rgb.g)) * 255);
32
+ var b = Math.round(Math.max(0, Math.min(1, rgb.b)) * 255);
33
+ return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase();
34
+ }
35
+
36
+ /**
37
+ * Convert RGB to HSL
38
+ * @param {number} r - Red (0-1)
39
+ * @param {number} g - Green (0-1)
40
+ * @param {number} b - Blue (0-1)
41
+ * @returns {{h: number, s: number, l: number}} - h in degrees (0-360), s and l in 0-1
42
+ */
43
+ export function rgbToHsl(r, g, b) {
44
+ var max = Math.max(r, g, b);
45
+ var min = Math.min(r, g, b);
46
+ var l = (max + min) / 2;
47
+ var h, s;
48
+
49
+ if (max === min) {
50
+ h = s = 0; // achromatic
51
+ } else {
52
+ var d = max - min;
53
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
54
+
55
+ switch (max) {
56
+ case r:
57
+ h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
58
+ break;
59
+ case g:
60
+ h = ((b - r) / d + 2) / 6;
61
+ break;
62
+ case b:
63
+ h = ((r - g) / d + 4) / 6;
64
+ break;
65
+ }
66
+ h *= 360;
67
+ }
68
+
69
+ return { h: h, s: s, l: l };
70
+ }
71
+
72
+ /**
73
+ * Convert HSL to RGB
74
+ * @param {number} h - Hue in degrees (0-360)
75
+ * @param {number} s - Saturation (0-1)
76
+ * @param {number} l - Lightness (0-1)
77
+ * @returns {{r: number, g: number, b: number}}
78
+ */
79
+ export function hslToRgb(h, s, l) {
80
+ var r, g, b;
81
+
82
+ if (s === 0) {
83
+ r = g = b = l; // achromatic
84
+ } else {
85
+ function hue2rgb(p, q, t) {
86
+ if (t < 0) t += 1;
87
+ if (t > 1) t -= 1;
88
+ if (t < 1 / 6) return p + (q - p) * 6 * t;
89
+ if (t < 1 / 2) return q;
90
+ if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
91
+ return p;
92
+ }
93
+
94
+ var q = l < 0.5 ? l * (1 + s) : l + s - l * s;
95
+ var p = 2 * l - q;
96
+ var hNorm = h / 360;
97
+
98
+ r = hue2rgb(p, q, hNorm + 1 / 3);
99
+ g = hue2rgb(p, q, hNorm);
100
+ b = hue2rgb(p, q, hNorm - 1 / 3);
101
+ }
102
+
103
+ return { r: r, g: g, b: b };
104
+ }
105
+
106
+ // ============================================
107
+ // WCAG Luminance Calculations
108
+ // ============================================
109
+
110
+ /**
111
+ * Linearize an sRGB channel value
112
+ * @param {number} c - Channel value (0-1)
113
+ * @returns {number} - Linearized value
114
+ */
115
+ function linearize(c) {
116
+ return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
117
+ }
118
+
119
+ /**
120
+ * Calculate relative luminance per WCAG 2.1
121
+ * @param {number} r - Red (0-1)
122
+ * @param {number} g - Green (0-1)
123
+ * @param {number} b - Blue (0-1)
124
+ * @returns {number} - Relative luminance (0-1)
125
+ */
126
+ export function relativeLuminance(r, g, b) {
127
+ var rLin = linearize(r);
128
+ var gLin = linearize(g);
129
+ var bLin = linearize(b);
130
+ return 0.2126 * rLin + 0.7152 * gLin + 0.0722 * bLin;
131
+ }
132
+
133
+ /**
134
+ * Calculate WCAG contrast ratio between two luminance values
135
+ * @param {number} L1 - Luminance of first color (0-1)
136
+ * @param {number} L2 - Luminance of second color (0-1)
137
+ * @returns {number} - Contrast ratio (1-21)
138
+ */
139
+ export function contrastRatio(L1, L2) {
140
+ var lighter = Math.max(L1, L2);
141
+ var darker = Math.min(L1, L2);
142
+ return (lighter + 0.05) / (darker + 0.05);
143
+ }
144
+
145
+ /**
146
+ * Check if a color is essentially gray (no saturation)
147
+ * @param {{r: number, g: number, b: number}} rgb
148
+ * @param {number} [tolerance=0.02]
149
+ * @returns {boolean}
150
+ */
151
+ function isGray(rgb, tolerance) {
152
+ tolerance = tolerance || 0.02;
153
+ var max = Math.max(rgb.r, rgb.g, rgb.b);
154
+ var min = Math.min(rgb.r, rgb.g, rgb.b);
155
+ return (max - min) < tolerance;
156
+ }
157
+
158
+ // ============================================
159
+ // Luminance Adjustment
160
+ // ============================================
161
+
162
+ /**
163
+ * Adjust color to target luminance while preserving hue
164
+ * Uses binary search in HSL space
165
+ * Desaturates significantly at low lightness for better visual contrast
166
+ * @param {{r: number, g: number, b: number}} rgb
167
+ * @param {number} targetLum - Target relative luminance (0-1)
168
+ * @returns {{r: number, g: number, b: number}}
169
+ */
170
+ function adjustToLuminance(rgb, targetLum) {
171
+ var hsl = rgbToHsl(rgb.r, rgb.g, rgb.b);
172
+
173
+ // Binary search to find lightness that achieves target luminance
174
+ var minL = 0;
175
+ var maxL = 1;
176
+ var iterations = 20;
177
+ var finalL;
178
+
179
+ for (var i = 0; i < iterations; i++) {
180
+ var midL = (minL + maxL) / 2;
181
+ var testRgb = hslToRgb(hsl.h, hsl.s, midL);
182
+ var testLum = relativeLuminance(testRgb.r, testRgb.g, testRgb.b);
183
+
184
+ if (testLum < targetLum) {
185
+ minL = midL;
186
+ } else {
187
+ maxL = midL;
188
+ }
189
+ }
190
+
191
+ finalL = (minL + maxL) / 2;
192
+
193
+ // Desaturate significantly at low lightness for better visual contrast
194
+ // Dark saturated colors (e.g., dark yellow) don't look distinct enough
195
+ // Scale saturation based on lightness: below 0.3 lightness, reduce saturation
196
+ var finalS = hsl.s;
197
+ if (finalL < 0.3) {
198
+ // Linear ramp: at L=0.3, keep 100% saturation; at L=0, keep 20% saturation
199
+ var saturationScale = 0.2 + (finalL / 0.3) * 0.8;
200
+ finalS = hsl.s * saturationScale;
201
+ }
202
+
203
+ return hslToRgb(hsl.h, finalS, finalL);
204
+ }
205
+
206
+ // ============================================
207
+ // Auto-Derive Edge Colors
208
+ // ============================================
209
+
210
+ /**
211
+ * Auto-derive edge colors (diffuse/ambient) from emissive color
212
+ * Creates high-contrast edges for text legibility
213
+ *
214
+ * INVERTED APPROACH: Since emissive adds to all surfaces equally,
215
+ * we flip the strategy - put bright color in diffuse (shows on lit surfaces)
216
+ * and dark color in emissive (base for unlit surfaces).
217
+ * Returns modified emissive along with diffuse/ambient.
218
+ *
219
+ * @param {string} emissiveHex - Hex color string for desired face color
220
+ * @param {number} [targetContrast=4.5] - Target WCAG contrast ratio
221
+ * @returns {{diffuse: string, ambient: string, emissive: string}}
222
+ */
223
+ export function deriveEdgeColors(emissiveHex, targetContrast) {
224
+ targetContrast = targetContrast || 4.5;
225
+
226
+ var rgb = hexToRgb(emissiveHex);
227
+ var faceLum = relativeLuminance(rgb.r, rgb.g, rgb.b);
228
+
229
+ // Calculate target luminance for dark areas to achieve contrast
230
+ var darkLum;
231
+ if (faceLum > 0.5) {
232
+ // Bright face needs dark edges
233
+ darkLum = (faceLum + 0.05) / targetContrast - 0.05;
234
+ darkLum = Math.max(darkLum, 0.0);
235
+ } else {
236
+ // Dark face needs light edges (invert the logic)
237
+ darkLum = targetContrast * (faceLum + 0.05) - 0.05;
238
+ darkLum = Math.min(darkLum, 1.0);
239
+ }
240
+
241
+ // Handle edge cases
242
+ if (faceLum > 0.95) {
243
+ darkLum = Math.min(0.1, darkLum);
244
+ } else if (faceLum < 0.05) {
245
+ darkLum = Math.max(0.5, darkLum);
246
+ }
247
+
248
+ // Generate dark color (desaturated at low lightness)
249
+ var darkRgb = adjustToLuminance(rgb, darkLum);
250
+
251
+ // INVERTED APPROACH:
252
+ // - diffuse = bright (the user's desired face color) - shows on lit surfaces
253
+ // - emissive = dark - base color for all surfaces (unlit areas show this)
254
+ // - ambient = very dark - shadowed areas
255
+ var ambientLum = darkLum * 0.5;
256
+ var ambientRgb = adjustToLuminance(rgb, Math.max(0, ambientLum));
257
+
258
+ return {
259
+ diffuse: emissiveHex, // Bright color for lit surfaces
260
+ ambient: rgbToHex(ambientRgb), // Very dark for shadows
261
+ emissive: rgbToHex(darkRgb) // Dark base for unlit areas
262
+ };
263
+ }
264
+
265
+ // ============================================
266
+ // High-Contrast Adjustment Algorithm
267
+ // ============================================
268
+
269
+ /**
270
+ * Adjust color by a factor (lightness change with optional hue shift)
271
+ * @param {{r: number, g: number, b: number}} rgb
272
+ * @param {number} factor - Adjustment factor (-1 to 1, negative = darken)
273
+ * @param {boolean} allowHueShift
274
+ * @returns {{r: number, g: number, b: number}}
275
+ */
276
+ function adjustColorByFactor(rgb, factor, allowHueShift) {
277
+ var hsl = rgbToHsl(rgb.r, rgb.g, rgb.b);
278
+
279
+ // Adjust lightness
280
+ var newL = hsl.l + factor;
281
+ newL = Math.max(0, Math.min(1, newL));
282
+
283
+ // Optionally shift hue for extreme adjustments
284
+ var newH = hsl.h;
285
+ if (allowHueShift && Math.abs(factor) > 0.2) {
286
+ // Slight hue shift toward yellow (high luminance) or blue (low luminance)
287
+ var hueTarget = factor > 0 ? 60 : 240;
288
+ newH = hsl.h + (hueTarget - hsl.h) * Math.abs(factor) * 0.1;
289
+ newH = ((newH % 360) + 360) % 360;
290
+ }
291
+
292
+ // Reduce saturation at extreme lightness for natural look
293
+ var newS = hsl.s;
294
+ if (newL > 0.9 || newL < 0.1) {
295
+ newS *= 0.5;
296
+ }
297
+
298
+ return hslToRgb(newH, newS, newL);
299
+ }
300
+
301
+ /**
302
+ * Oscillate edge colors to find best contrast
303
+ * @param {{r: number, g: number, b: number}} emissive
304
+ * @param {{r: number, g: number, b: number}} diffuse
305
+ * @param {{r: number, g: number, b: number}} ambient
306
+ * @param {object} options
307
+ * @returns {{diffuse: object, ambient: object, achieved: number}}
308
+ */
309
+ function oscillateEdges(emissive, diffuse, ambient, options) {
310
+ var emissiveLum = relativeLuminance(emissive.r, emissive.g, emissive.b);
311
+ var diffuseLum = relativeLuminance(diffuse.r, diffuse.g, diffuse.b);
312
+ var currentContrast = contrastRatio(emissiveLum, diffuseLum);
313
+
314
+ var bestResult = { diffuse: diffuse, ambient: ambient, achieved: currentContrast };
315
+
316
+ // Determine direction: edges should go opposite to emissive luminance
317
+ var direction = emissiveLum > 0.5 ? -1 : 1;
318
+
319
+ var steps = 10;
320
+ for (var i = 1; i <= steps; i++) {
321
+ var factor = (i / steps) * options.range;
322
+
323
+ // Primary direction
324
+ var testDiffuse = adjustColorByFactor(diffuse, direction * factor, options.allowHueShift);
325
+ var testAmbient = adjustColorByFactor(ambient, direction * factor * 0.8, options.allowHueShift);
326
+
327
+ var testLum = relativeLuminance(testDiffuse.r, testDiffuse.g, testDiffuse.b);
328
+ var contrast = contrastRatio(emissiveLum, testLum);
329
+
330
+ if (contrast > bestResult.achieved) {
331
+ bestResult = { diffuse: testDiffuse, ambient: testAmbient, achieved: contrast };
332
+ }
333
+
334
+ if (contrast >= options.targetContrast) break;
335
+
336
+ // Try opposite direction for edge cases
337
+ testDiffuse = adjustColorByFactor(diffuse, -direction * factor, options.allowHueShift);
338
+ testAmbient = adjustColorByFactor(ambient, -direction * factor * 0.8, options.allowHueShift);
339
+
340
+ testLum = relativeLuminance(testDiffuse.r, testDiffuse.g, testDiffuse.b);
341
+ contrast = contrastRatio(emissiveLum, testLum);
342
+
343
+ if (contrast > bestResult.achieved) {
344
+ bestResult = { diffuse: testDiffuse, ambient: testAmbient, achieved: contrast };
345
+ }
346
+ }
347
+
348
+ return bestResult;
349
+ }
350
+
351
+ /**
352
+ * Oscillate face color to find better contrast
353
+ * @param {{r: number, g: number, b: number}} emissive
354
+ * @param {{r: number, g: number, b: number}} diffuse
355
+ * @param {object} options
356
+ * @returns {{emissive: object, achieved: number}}
357
+ */
358
+ function oscillateFace(emissive, diffuse, options) {
359
+ var diffuseLum = relativeLuminance(diffuse.r, diffuse.g, diffuse.b);
360
+ var emissiveLum = relativeLuminance(emissive.r, emissive.g, emissive.b);
361
+ var currentContrast = contrastRatio(emissiveLum, diffuseLum);
362
+
363
+ var bestResult = { emissive: emissive, achieved: currentContrast };
364
+
365
+ // Face should move opposite to edges
366
+ var direction = diffuseLum > 0.5 ? -1 : 1;
367
+
368
+ var steps = 10;
369
+ for (var i = 1; i <= steps; i++) {
370
+ var factor = (i / steps) * options.range;
371
+
372
+ var testEmissive = adjustColorByFactor(emissive, direction * factor, options.allowHueShift);
373
+ var testLum = relativeLuminance(testEmissive.r, testEmissive.g, testEmissive.b);
374
+ var contrast = contrastRatio(testLum, diffuseLum);
375
+
376
+ if (contrast > bestResult.achieved) {
377
+ bestResult = { emissive: testEmissive, achieved: contrast };
378
+ }
379
+
380
+ if (contrast >= options.targetContrast) break;
381
+ }
382
+
383
+ return bestResult;
384
+ }
385
+
386
+ /**
387
+ * Adjust colors to achieve WCAG contrast while preserving user intent
388
+ * Priority: prefer edge modifications over face modifications
389
+ *
390
+ * @param {object} colors - User-provided colors
391
+ * @param {string} colors.emissive - Face color (hex)
392
+ * @param {string} colors.diffuse - Edge lit color (hex)
393
+ * @param {string} [colors.ambient] - Edge shadow color (hex)
394
+ * @param {object} [options]
395
+ * @param {number} [options.targetContrast=4.5] - Target contrast ratio
396
+ * @param {number} [options.edgeRange=0.4] - Max edge modification (0-1)
397
+ * @param {number} [options.faceRange=0.1] - Max face modification (0-1)
398
+ * @param {boolean} [options.allowHueShift=true] - Allow hue modifications
399
+ * @returns {{emissive: string, diffuse: string, ambient: string, achieved: number}}
400
+ */
401
+ export function adjustForContrast(colors, options) {
402
+ options = options || {};
403
+ var targetContrast = options.targetContrast || 4.5;
404
+ var edgeRange = options.edgeRange || 0.4;
405
+ var faceRange = options.faceRange || 0.1;
406
+ var allowHueShift = options.allowHueShift !== false;
407
+
408
+ var emissive = hexToRgb(colors.emissive);
409
+ var diffuse = hexToRgb(colors.diffuse);
410
+ var ambient = colors.ambient ? hexToRgb(colors.ambient) : { r: diffuse.r * 0.5, g: diffuse.g * 0.5, b: diffuse.b * 0.5 };
411
+
412
+ var emissiveLum = relativeLuminance(emissive.r, emissive.g, emissive.b);
413
+ var diffuseLum = relativeLuminance(diffuse.r, diffuse.g, diffuse.b);
414
+ var currentContrast = contrastRatio(emissiveLum, diffuseLum);
415
+
416
+ // Already meets target?
417
+ if (currentContrast >= targetContrast) {
418
+ return {
419
+ emissive: colors.emissive,
420
+ diffuse: colors.diffuse,
421
+ ambient: colors.ambient || rgbToHex(ambient),
422
+ achieved: currentContrast
423
+ };
424
+ }
425
+
426
+ // Phase 1: Try edge modification only
427
+ var edgeResult = oscillateEdges(emissive, diffuse, ambient, {
428
+ targetContrast: targetContrast,
429
+ range: edgeRange,
430
+ allowHueShift: allowHueShift
431
+ });
432
+
433
+ if (edgeResult.achieved >= targetContrast) {
434
+ return {
435
+ emissive: colors.emissive,
436
+ diffuse: rgbToHex(edgeResult.diffuse),
437
+ ambient: rgbToHex(edgeResult.ambient),
438
+ achieved: edgeResult.achieved
439
+ };
440
+ }
441
+
442
+ // Phase 2: Try face modification
443
+ var faceResult = oscillateFace(emissive, edgeResult.diffuse, {
444
+ targetContrast: targetContrast,
445
+ range: faceRange,
446
+ allowHueShift: allowHueShift
447
+ });
448
+
449
+ if (faceResult.achieved >= targetContrast) {
450
+ return {
451
+ emissive: rgbToHex(faceResult.emissive),
452
+ diffuse: rgbToHex(edgeResult.diffuse),
453
+ ambient: rgbToHex(edgeResult.ambient),
454
+ achieved: faceResult.achieved
455
+ };
456
+ }
457
+
458
+ // Phase 3: Oscillate both until convergence
459
+ var maxIterations = 5;
460
+ var currentEmissive = faceResult.emissive;
461
+ var currentDiffuse = edgeResult.diffuse;
462
+ var currentAmbient = edgeResult.ambient;
463
+ var bestAchieved = faceResult.achieved;
464
+
465
+ for (var iter = 0; iter < maxIterations; iter++) {
466
+ // Try more edge adjustment
467
+ var newEdgeResult = oscillateEdges(currentEmissive, currentDiffuse, currentAmbient, {
468
+ targetContrast: targetContrast,
469
+ range: edgeRange * 0.5,
470
+ allowHueShift: allowHueShift
471
+ });
472
+
473
+ if (newEdgeResult.achieved >= targetContrast) {
474
+ return {
475
+ emissive: rgbToHex(currentEmissive),
476
+ diffuse: rgbToHex(newEdgeResult.diffuse),
477
+ ambient: rgbToHex(newEdgeResult.ambient),
478
+ achieved: newEdgeResult.achieved
479
+ };
480
+ }
481
+
482
+ // Try more face adjustment
483
+ var newFaceResult = oscillateFace(currentEmissive, newEdgeResult.diffuse, {
484
+ targetContrast: targetContrast,
485
+ range: faceRange * 0.5,
486
+ allowHueShift: allowHueShift
487
+ });
488
+
489
+ if (newFaceResult.achieved >= targetContrast) {
490
+ return {
491
+ emissive: rgbToHex(newFaceResult.emissive),
492
+ diffuse: rgbToHex(newEdgeResult.diffuse),
493
+ ambient: rgbToHex(newEdgeResult.ambient),
494
+ achieved: newFaceResult.achieved
495
+ };
496
+ }
497
+
498
+ // Update for next iteration
499
+ if (newFaceResult.achieved > bestAchieved) {
500
+ bestAchieved = newFaceResult.achieved;
501
+ currentEmissive = newFaceResult.emissive;
502
+ currentDiffuse = newEdgeResult.diffuse;
503
+ currentAmbient = newEdgeResult.ambient;
504
+ } else {
505
+ // No improvement, stop
506
+ break;
507
+ }
508
+ }
509
+
510
+ // Return best result even if target not achieved
511
+ return {
512
+ emissive: rgbToHex(currentEmissive),
513
+ diffuse: rgbToHex(currentDiffuse),
514
+ ambient: rgbToHex(currentAmbient),
515
+ achieved: bestAchieved
516
+ };
517
+ }
518
+
519
+ // ============================================
520
+ // Constants
521
+ // ============================================
522
+
523
+ export var CONTRAST_LEVELS = {
524
+ AA_NORMAL: 4.5,
525
+ AA_LARGE: 3.0,
526
+ AAA_NORMAL: 7.0,
527
+ AAA_LARGE: 4.5
528
+ };