@call-me-sensei/toonlab 0.2.0 → 0.3.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 (205) hide show
  1. package/AGENTS.md +127 -4
  2. package/ATTRIBUTION.md +41 -3
  3. package/README.md +234 -34
  4. package/docs/characters.md +144 -0
  5. package/docs/debug-panel.md +126 -0
  6. package/docs/docs.css +589 -0
  7. package/docs/environment.md +186 -0
  8. package/docs/getting-started.md +180 -0
  9. package/docs/index.html +14 -0
  10. package/docs/lab-architecture.md +100 -0
  11. package/docs/lighting.md +825 -0
  12. package/docs/main.jsx +739 -0
  13. package/docs/mcp.md +97 -0
  14. package/docs/post-processing.md +98 -0
  15. package/docs/settings-reference.md +1878 -0
  16. package/docs/shader-constants.md +77 -0
  17. package/docs/sky.md +182 -0
  18. package/docs/style-labs.md +309 -0
  19. package/docs/texture-lab.md +135 -0
  20. package/docs/toon-shading.md +183 -0
  21. package/docs/tsl-conventions.md +167 -0
  22. package/docs/vegetation-sky.md +275 -0
  23. package/docs/water.md +430 -0
  24. package/docs/weather.md +200 -0
  25. package/docs/world-scale.md +111 -0
  26. package/mcp/server.mjs +610 -0
  27. package/mcp/style-lab-tools.mjs +371 -0
  28. package/mcp/vite-plugin.mjs +174 -0
  29. package/mcp/workspace.mjs +397 -0
  30. package/package.json +64 -5
  31. package/src/ambientfx/INTEGRATION.md +164 -0
  32. package/src/ambientfx/ambientFxPresets.js +83 -0
  33. package/src/ambientfx/ambientFxSettings.js +368 -0
  34. package/src/ambientfx/emitters.js +169 -0
  35. package/src/ambientfx/index.js +5 -0
  36. package/src/ambientfx/particleBackbone.js +493 -0
  37. package/src/ambientfx/stylizedAmbientFx.js +450 -0
  38. package/src/assetlib/ambientcg.js +117 -0
  39. package/src/assetlib/assetRef.js +91 -0
  40. package/src/assetlib/importedEntry.js +40 -0
  41. package/src/assetlib/index.js +23 -0
  42. package/src/assetlib/kaykit.js +192 -0
  43. package/src/assetlib/kaykitStaticIndex.js +700 -0
  44. package/src/assetlib/loadImported.js +143 -0
  45. package/src/assetlib/opensource3d.js +113 -0
  46. package/src/assetlib/polyhaven.js +182 -0
  47. package/src/assetlib/polypizza.js +115 -0
  48. package/src/assetlib/smithsonian.js +214 -0
  49. package/src/assetlib/sources.js +279 -0
  50. package/src/assetlib/zip.js +58 -0
  51. package/src/biome/biomeGenerator.js +385 -0
  52. package/src/biome/biomeRuntime.js +299 -0
  53. package/src/biome/index.js +2 -0
  54. package/src/buildinggen/buildingAsset.js +44 -0
  55. package/src/buildinggen/buildingGrammar.js +311 -0
  56. package/src/buildinggen/buildingMesh.js +451 -0
  57. package/src/buildinggen/buildingPresets.js +46 -0
  58. package/src/buildinggen/buildingRecipe.js +100 -0
  59. package/src/buildinggen/buildingSettings.js +238 -0
  60. package/src/buildinggen/index.js +6 -0
  61. package/src/camera/cameraDirector.js +157 -0
  62. package/src/camera/cameraGenerator.js +367 -0
  63. package/src/camera/cameraRig.js +570 -0
  64. package/src/camera/cameraSettings.js +236 -0
  65. package/src/camera/index.js +7 -0
  66. package/src/catalog/builtinEntries.js +245 -0
  67. package/src/catalog/catalog.js +210 -0
  68. package/src/catalog/index.js +3 -0
  69. package/src/catalog/manifest.js +84 -0
  70. package/src/core/generation.js +529 -0
  71. package/src/environment/environmentRigs.js +21 -1
  72. package/src/environment/environmentSettings.js +4 -0
  73. package/src/environment/environmentSunShadowPass.js +8 -0
  74. package/src/fauna/INTEGRATION.md +174 -0
  75. package/src/fauna/boids.js +861 -0
  76. package/src/fauna/faunaBodies.js +492 -0
  77. package/src/fauna/faunaPresets.js +52 -0
  78. package/src/fauna/faunaSettings.js +525 -0
  79. package/src/fauna/index.js +5 -0
  80. package/src/fauna/stylizedFauna.js +395 -0
  81. package/src/game-feel/gameFeelGenerator.js +402 -0
  82. package/src/game-feel/gameFeelRuntime.js +549 -0
  83. package/src/game-feel/index.js +2 -0
  84. package/src/index.js +17 -6
  85. package/src/lighting/colorIntensity.js +177 -0
  86. package/src/lighting/index.js +161 -0
  87. package/src/lighting/lightDescriptors.js +249 -0
  88. package/src/lighting/lightingCapabilities.js +79 -0
  89. package/src/lighting/lightingDocuments.js +247 -0
  90. package/src/lighting/lightingFixtures.js +446 -0
  91. package/src/lighting/lightingGenerator.js +449 -0
  92. package/src/lighting/lightingPresets.js +319 -0
  93. package/src/lighting/lightingRuntime.js +723 -0
  94. package/src/lighting/lightingStyle.js +386 -0
  95. package/src/lighting/lightingSystem.js +774 -0
  96. package/src/lighting/unrealExport.js +186 -0
  97. package/src/lighting/utils.js +87 -0
  98. package/src/motion/index.js +5 -0
  99. package/src/motion/motionClip.js +441 -0
  100. package/src/motion/motionController.js +628 -0
  101. package/src/motion/motionDocuments.js +225 -0
  102. package/src/motion/motionGraph.js +307 -0
  103. package/src/motion/motionSettings.js +222 -0
  104. package/src/pathgen/index.js +7 -0
  105. package/src/pathgen/pathBridge.js +232 -0
  106. package/src/pathgen/pathPresets.js +35 -0
  107. package/src/pathgen/pathRibbon.js +410 -0
  108. package/src/pathgen/pathRouter.js +380 -0
  109. package/src/pathgen/pathSettings.js +335 -0
  110. package/src/pathgen/pathTextures.js +123 -0
  111. package/src/pathgen/stylizedPaths.js +453 -0
  112. package/src/post/index.js +1 -0
  113. package/src/post/postGenerator.js +177 -0
  114. package/src/post/postProcessing.js +41 -0
  115. package/src/propgen/generatorsWave1.js +379 -0
  116. package/src/propgen/generatorsWave2.js +462 -0
  117. package/src/propgen/index.js +5 -0
  118. package/src/propgen/propAsset.js +323 -0
  119. package/src/propgen/propParts.js +170 -0
  120. package/src/propgen/propPlacement.js +459 -0
  121. package/src/propgen/propPresets.js +82 -0
  122. package/src/propgen/propSettings.js +395 -0
  123. package/src/shaders-tsl/chunks/projected-water-caustics.js +242 -0
  124. package/src/shaders-tsl/chunks/vegetation-style.js +360 -0
  125. package/src/shaders-tsl/chunks/water-shore-state.js +31 -0
  126. package/src/shaders-tsl/chunks/water-waves.js +90 -11
  127. package/src/shaders-tsl/environment.js +15 -1
  128. package/src/shaders-tsl/flower.js +279 -30
  129. package/src/shaders-tsl/grass.js +60 -33
  130. package/src/shaders-tsl/sky.js +125 -31
  131. package/src/shaders-tsl/tree-leaf.js +61 -25
  132. package/src/shaders-tsl/water-breaker.js +7 -4
  133. package/src/shaders-tsl/water-shore-state-simulation.js +523 -0
  134. package/src/shaders-tsl/water.js +439 -49
  135. package/src/shaders-tsl/woody-surface.js +154 -0
  136. package/src/sky/sceneOverrideLayers.js +10 -0
  137. package/src/sky/skyQuality.js +26 -0
  138. package/src/sky/stylizedSky.js +753 -45
  139. package/src/soundscape/index.js +4 -0
  140. package/src/soundscape/soundscapeGenerator.js +179 -0
  141. package/src/soundscape/soundscapeRuntime.js +806 -0
  142. package/src/soundscape/soundscapeSettings.js +292 -0
  143. package/src/styles/index.js +13 -0
  144. package/src/styles/styleBundle.js +325 -0
  145. package/src/stylizedTerrain.js +32 -2
  146. package/src/stylizedWorld.js +423 -20
  147. package/src/texgen/evaluateTexture.js +675 -0
  148. package/src/texgen/index.js +60 -0
  149. package/src/texgen/noise2.js +210 -0
  150. package/src/texgen/textureAi.js +436 -0
  151. package/src/texgen/textureGenerators.js +516 -0
  152. package/src/texgen/texturePresets.js +490 -0
  153. package/src/texgen/textureSettings.js +342 -0
  154. package/src/texgen/textureThree.js +59 -0
  155. package/src/vegetation/flowerSpecies.js +15 -3
  156. package/src/vegetation/grassPalettes.js +153 -0
  157. package/src/vegetation/index.js +6 -0
  158. package/src/vegetation/stylizedBush.js +2 -0
  159. package/src/vegetation/stylizedFlower.js +82 -0
  160. package/src/vegetation/stylizedFlowers.js +48 -7
  161. package/src/vegetation/stylizedForest.js +29 -1
  162. package/src/vegetation/stylizedGrass.js +291 -56
  163. package/src/vegetation/stylizedTree.js +38 -2
  164. package/src/vegetation/stylizedTreeFoliage.js +2 -1
  165. package/src/vegetation/vegetationShaders.js +1110 -0
  166. package/src/vfxgen/INTEGRATION.md +145 -0
  167. package/src/vfxgen/core/burstBackbone.js +380 -0
  168. package/src/vfxgen/core/projectileCore.js +92 -0
  169. package/src/vfxgen/core/spriteShapes.js +98 -0
  170. package/src/vfxgen/core/trailRibbon.js +272 -0
  171. package/src/vfxgen/core/vfxRandom.js +29 -0
  172. package/src/vfxgen/effects/emitHelpers.js +37 -0
  173. package/src/vfxgen/effects/magicEffects.js +162 -0
  174. package/src/vfxgen/effects/movementEffects.js +87 -0
  175. package/src/vfxgen/effects/weaponEffects.js +118 -0
  176. package/src/vfxgen/index.js +18 -0
  177. package/src/vfxgen/moves/moveController.js +146 -0
  178. package/src/vfxgen/moves/moveLibrary.js +335 -0
  179. package/src/vfxgen/vfxPresets.js +98 -0
  180. package/src/vfxgen/vfxSettings.js +384 -0
  181. package/src/vfxgen/vfxSystem.js +449 -0
  182. package/src/vfxgen/weapons/stylizedWeapons.js +137 -0
  183. package/src/villagegen/index.js +4 -0
  184. package/src/villagegen/stylizedVillage.js +490 -0
  185. package/src/villagegen/villageArchetypes.js +160 -0
  186. package/src/villagegen/villageNames.js +40 -0
  187. package/src/villagegen/villageSites.js +105 -0
  188. package/src/water/sceneOverrideLayers.js +23 -0
  189. package/src/water/water.js +5 -0
  190. package/src/water/waterBreakerSystem.js +15 -1
  191. package/src/water/waterCurrentField.js +447 -0
  192. package/src/water/waterMaterial.js +12 -0
  193. package/src/water/waterNearshorePhase.js +320 -0
  194. package/src/water/waterScenePasses.js +83 -30
  195. package/src/water/waterSettings.js +325 -13
  196. package/src/water/waterShoreMaterial.js +322 -0
  197. package/src/water/waterShoreStateField.js +605 -0
  198. package/src/water/waterSurface.js +797 -28
  199. package/src/weather/index.js +6 -0
  200. package/src/weather/weatherPrecipitation.js +221 -0
  201. package/src/weather/weatherPresets.js +258 -0
  202. package/src/weather/weatherSettings.js +269 -0
  203. package/src/weather/weatherSystem.js +871 -0
  204. package/src/worldMinimap.js +62 -0
  205. package/src/worldPresets.js +4 -1
@@ -0,0 +1,436 @@
1
+ // Natural-language -> texture-recipe mapping. Two paths share one output
2
+ // contract:
3
+ // - buildTextureAiPrompt / compileTextureAiRecipe: prompt + response
4
+ // handling for a small hosted LLM (Gemini Flash-Lite, GPT mini tiers).
5
+ // The prompt is generated from the live field schema and preset list,
6
+ // so the parameter space the model sees can never drift from the code.
7
+ // - keywordTextureRecipe: a deterministic offline fallback (preset
8
+ // scoring + adjective modifiers) so the prompt box works with no key.
9
+ //
10
+ // Network calls live in the lab (labs/texture-lab/ai/); this module is
11
+ // pure string/JSON work and stays headless-safe.
12
+
13
+ import {
14
+ applyTextureSettingsPatch,
15
+ createTextureSettings,
16
+ flattenTextureSettings,
17
+ hexToRgb01,
18
+ TEXTURE_SETTING_FIELD_SCHEMA,
19
+ } from './textureSettings.js';
20
+ import { BUILT_IN_TEXTURE_PRESETS, findTexturePreset } from './texturePresets.js';
21
+ import { srgbToLinear, linearToSrgb } from './evaluateTexture.js';
22
+
23
+ // --- prompt ------------------------------------------------------------------
24
+
25
+ function fieldLine(field) {
26
+ if (field.type === 'number') {
27
+ return `${field.group}.${field.key} number ${field.range.min}..${field.range.max}`;
28
+ }
29
+ if (field.type === 'boolean') return `${field.group}.${field.key} boolean`;
30
+ if (field.type === 'select') return `${field.group}.${field.key} one of [${field.options.join('|')}]`;
31
+ if (field.type === 'color') return `${field.group}.${field.key} hex color`;
32
+ return `${field.group}.${field.key}`;
33
+ }
34
+
35
+ function schemaCatalog() {
36
+ const lines = [];
37
+ for (const fields of Object.values(TEXTURE_SETTING_FIELD_SCHEMA)) {
38
+ for (const field of Object.values(fields)) lines.push(fieldLine(field));
39
+ }
40
+ return lines.join('\n');
41
+ }
42
+
43
+ function presetCatalog() {
44
+ return BUILT_IN_TEXTURE_PRESETS
45
+ .map((preset) => `${preset.id}: ${preset.label} (${preset.tags.join(', ')})`)
46
+ .join('\n');
47
+ }
48
+
49
+ const RESPONSE_CONTRACT = `Respond with ONLY a JSON object (no markdown fences, no prose):
50
+ {
51
+ "name": "short display name for the texture",
52
+ "basePreset": "<preset id from the catalog that is closest, or null>",
53
+ "palette": ["#hex", ...] | null, // 2-5 albedo ramp colors, DARKEST first -> LIGHTEST last
54
+ "patch": { "<group.key>": value, ... } | null, // only keys from the parameter table
55
+ "notes": "one short sentence about the interpretation"
56
+ }
57
+ Rules:
58
+ - Prefer starting from the closest basePreset, then override only what the request changes.
59
+ - palette drives the 5-stop height ramp (crevices -> ridges). Keep a believable dark-to-light ladder.
60
+ - Layer generators drive height AND color banding. Overlays (accentA/accentB) are masked colored effects: moss, rust, grime, snow, patina, stains.
61
+ - surface.roughness: 0 mirror gloss, 1 matte. surface.metalness only for metals. emissive.* only when it should glow.
62
+ - Numbers are clamped to their documented range; unknown keys are ignored.`;
63
+
64
+ const EXAMPLES = `Example request: "old leather jacket"
65
+ Example response: {"name":"Old Leather Jacket","basePreset":"worn-leather","palette":["#170c06","#2e1a0c","#432712","#573619","#684522"],"patch":{"wear.damage":0.55,"wear.dirt":0.35,"color.saturation":0.8,"color.sheen":0.5,"surface.roughness":0.65},"notes":"Cracked grain, desaturated, scuffed with worn sheen on ridges."}
66
+
67
+ Example request: "glowing blue crystal cave wall"
68
+ Example response: {"name":"Crystal Cave Wall","basePreset":"cliff-rock","palette":["#0b1026","#15204a","#233a78","#3c62b0","#7ea6e0"],"patch":{"base.generator":"ridged","base.scale":7,"surface.roughness":0.2,"surface.roughnessContrast":-0.4,"emissive.enabled":true,"emissive.source":"crevices","emissive.color":"#3f8dff","emissive.intensity":3,"color.sheen":0.45},"notes":"Ridged rock with glowing blue crevices and glassy facets."}`;
69
+
70
+ /**
71
+ * Builds { system, user } for a JSON-mode chat call. `mode` is 'new' or
72
+ * 'refine'; refine embeds the current flattened settings so the model
73
+ * patches relative to what the user already sees.
74
+ */
75
+ export function buildTextureAiPrompt({ prompt, mode = 'new', settings = null } = {}) {
76
+ const system = [
77
+ 'You translate material descriptions into parameters for ToonLab\'s seamless procedural texture generator (stylized/anime look).',
78
+ '',
79
+ 'PRESET CATALOG (id: label (tags)):',
80
+ presetCatalog(),
81
+ '',
82
+ 'PARAMETER TABLE (path type range):',
83
+ schemaCatalog(),
84
+ '',
85
+ RESPONSE_CONTRACT,
86
+ '',
87
+ EXAMPLES,
88
+ ].join('\n');
89
+
90
+ let user = `Request: ${String(prompt ?? '').trim()}`;
91
+ if (mode === 'refine' && settings) {
92
+ user += `\n\nThe user is REFINING this existing texture — keep its character, change only what the request asks, and set basePreset to null:\n${JSON.stringify(flattenTextureSettings(settings))}`;
93
+ }
94
+ return { system, user };
95
+ }
96
+
97
+ // --- response handling --------------------------------------------------------
98
+
99
+ /** Parses model text into a recipe object; tolerates markdown fences. */
100
+ export function parseTextureAiResponse(text) {
101
+ let body = String(text ?? '').trim();
102
+ const fence = body.match(/```(?:json)?\s*([\s\S]*?)```/i);
103
+ if (fence) body = fence[1].trim();
104
+ const start = body.indexOf('{');
105
+ const end = body.lastIndexOf('}');
106
+ if (start === -1 || end === -1 || end <= start) throw new Error('The model returned no JSON object.');
107
+ const recipe = JSON.parse(body.slice(start, end + 1));
108
+ if (typeof recipe !== 'object' || recipe === null) throw new Error('The model returned invalid JSON.');
109
+ return recipe;
110
+ }
111
+
112
+ /** Expands a 2..5 color palette to exactly five ramp stops (linear lerp). */
113
+ function paletteToRampPatch(palette) {
114
+ const stops = (Array.isArray(palette) ? palette : [])
115
+ .map((hex) => hexToRgb01(hex))
116
+ .filter(Boolean);
117
+ if (stops.length < 2) return null;
118
+ const linear = stops.map((rgb) => rgb.map(srgbToLinear));
119
+ const sample = (t) => {
120
+ const f = t * (linear.length - 1);
121
+ const i = Math.min(linear.length - 2, Math.floor(f));
122
+ const k = f - i;
123
+ return linear[i].map((c, ch) => linearToSrgb(c + (linear[i + 1][ch] - c) * k));
124
+ };
125
+ const patch = {};
126
+ for (let s = 0; s < 5; s += 1) patch[`color.color${s}`] = sample(s / 4);
127
+ return patch;
128
+ }
129
+
130
+ /**
131
+ * Compiles a parsed AI recipe into clamped settings.
132
+ * mode 'new' starts from recipe.basePreset (or defaults); mode 'refine'
133
+ * starts from `currentSettings`. Returns { settings, name, notes, applied,
134
+ * ignored, presetId }.
135
+ */
136
+ export function compileTextureAiRecipe(recipe, { mode = 'new', currentSettings = null } = {}) {
137
+ const preset = mode === 'new' && recipe.basePreset ? findTexturePreset(String(recipe.basePreset)) : null;
138
+ const start = mode === 'refine' && currentSettings
139
+ ? createTextureSettings(currentSettings)
140
+ : createTextureSettings(preset?.settings ?? {});
141
+
142
+ const patch = { ...(recipe.patch && typeof recipe.patch === 'object' ? recipe.patch : {}) };
143
+ const rampPatch = paletteToRampPatch(recipe.palette);
144
+ const merged = { ...(rampPatch ?? {}), ...patch };
145
+ const { settings, applied, ignored } = applyTextureSettingsPatch(start, merged);
146
+ return {
147
+ applied,
148
+ ignored,
149
+ name: typeof recipe.name === 'string' && recipe.name.trim() ? recipe.name.trim() : 'AI texture',
150
+ notes: typeof recipe.notes === 'string' ? recipe.notes : '',
151
+ presetId: preset?.id ?? null,
152
+ settings,
153
+ };
154
+ }
155
+
156
+ // --- offline keyword mapper ----------------------------------------------------
157
+
158
+ function hslToRgb(h, s, l) {
159
+ const hue = ((h % 1) + 1) % 1;
160
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
161
+ const p = 2 * l - q;
162
+ const channel = (t0) => {
163
+ let t = ((t0 % 1) + 1) % 1;
164
+ if (t < 1 / 6) return p + (q - p) * 6 * t;
165
+ if (t < 1 / 2) return q;
166
+ if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
167
+ return p;
168
+ };
169
+ return [channel(hue + 1 / 3), channel(hue), channel(hue - 1 / 3)];
170
+ }
171
+
172
+ function hueLadder(h, s = 0.45) {
173
+ const lights = [0.13, 0.28, 0.44, 0.6, 0.78];
174
+ const patch = {};
175
+ lights.forEach((l, i) => { patch[`color.color${i}`] = hslToRgb(h, s, l); });
176
+ return patch;
177
+ }
178
+
179
+ const COLOR_WORDS = {
180
+ black: () => hueLadder(0.6, 0.04),
181
+ blue: () => hueLadder(0.6),
182
+ brown: () => hueLadder(0.07, 0.42),
183
+ crimson: () => hueLadder(0.98, 0.55),
184
+ cyan: () => hueLadder(0.5),
185
+ gold: () => ({ ...hueLadder(0.12, 0.62), 'surface.metalness': 1, 'surface.roughness': 0.25 }),
186
+ gray: () => hueLadder(0.6, 0.05),
187
+ green: () => hueLadder(0.33),
188
+ grey: () => hueLadder(0.6, 0.05),
189
+ magenta: () => hueLadder(0.87),
190
+ orange: () => hueLadder(0.07, 0.6),
191
+ pink: () => hueLadder(0.93, 0.5),
192
+ purple: () => hueLadder(0.76),
193
+ red: () => hueLadder(0.01, 0.55),
194
+ silver: () => ({ ...hueLadder(0.58, 0.06), 'surface.metalness': 1, 'surface.roughness': 0.3 }),
195
+ teal: () => hueLadder(0.46),
196
+ violet: () => hueLadder(0.8),
197
+ white: () => hueLadder(0.58, 0.05),
198
+ yellow: () => hueLadder(0.14, 0.6),
199
+ };
200
+
201
+ const MODIFIERS = [
202
+ {
203
+ words: ['old', 'worn', 'aged', 'ancient', 'weathered', 'vintage', 'antique', 'ruined'],
204
+ note: 'aged',
205
+ patch: () => ({
206
+ 'wear.damage': 0.5, 'wear.dirt': 0.4,
207
+ 'color.saturation': 0.85, 'color.jitterValue': 0.14, 'color.sheen': 0.35, 'surface.aoStrength': 0.7,
208
+ }),
209
+ },
210
+ {
211
+ words: ['scratched', 'scuffed', 'damaged', 'battle', 'beaten', 'chipped'],
212
+ note: 'damaged',
213
+ patch: () => ({ 'wear.damage': 0.65 }),
214
+ },
215
+ {
216
+ words: ['wet', 'glossy', 'shiny', 'polished', 'slick', 'lacquered'],
217
+ note: 'glossy',
218
+ patch: () => ({ 'surface.roughness': 0.18, 'surface.roughnessContrast': 0.3, 'color.sheen': 0.4 }),
219
+ },
220
+ {
221
+ words: ['rough', 'matte', 'dry', 'dusty', 'chalky'],
222
+ note: 'matte',
223
+ patch: () => ({ 'surface.roughness': 0.95, 'color.sheen': 0.1 }),
224
+ },
225
+ {
226
+ words: ['mossy', 'overgrown', 'lichen', 'jungle'],
227
+ note: 'mossy',
228
+ patch: () => ({
229
+ 'accentA.enabled': true, 'accentA.generator': 'fbm', 'accentA.color': '#3f5a26', 'accentA.colorB': '#6c8a38',
230
+ 'accentA.coverage': 0.45, 'accentA.creviceBias': 0.7, 'accentA.warp': 0.4, 'accentA.roughnessShift': 0.2,
231
+ }),
232
+ },
233
+ {
234
+ words: ['rusty', 'rusted', 'corroded', 'oxidized'],
235
+ note: 'rusted',
236
+ patch: () => ({
237
+ 'accentA.enabled': true, 'accentA.generator': 'turbulence', 'accentA.color': '#6e3312', 'accentA.colorB': '#b06a2a',
238
+ 'accentA.coverage': 0.5, 'accentA.creviceBias': 0.45, 'accentA.roughnessShift': 0.45, 'accentA.metalShift': -0.8,
239
+ 'accentA.heightShift': 0.04, 'accentA.warp': 0.5,
240
+ }),
241
+ },
242
+ {
243
+ words: ['snowy', 'snow', 'frosted', 'frozen', 'icy'],
244
+ note: 'frosted',
245
+ patch: () => ({
246
+ 'accentA.enabled': true, 'accentA.generator': 'fbm', 'accentA.color': '#e8f2fb', 'accentA.colorB': '#ffffff',
247
+ 'accentA.coverage': 0.45, 'accentA.creviceBias': -0.6, 'accentA.roughnessShift': -0.2, 'accentA.heightShift': 0.06,
248
+ }),
249
+ },
250
+ {
251
+ words: ['dirty', 'grimy', 'muddy', 'stained', 'sooty', 'greasy'],
252
+ note: 'grimy',
253
+ patch: () => ({ 'wear.dirt': 0.6 }),
254
+ },
255
+ {
256
+ words: ['cracked', 'shattered', 'fractured', 'broken'],
257
+ note: 'cracked',
258
+ patch: (settings) => ({
259
+ 'detailB.enabled': true, 'detailB.generator': 'cracks', 'detailB.blend': 'min', 'detailB.amount': 0.45,
260
+ 'detailB.scale': Math.max(4, Math.round(settings.base.scale * 0.9)), 'detailB.edgeWidth': 0.06,
261
+ }),
262
+ },
263
+ {
264
+ words: ['glowing', 'glow', 'neon', 'radioactive', 'magical', 'luminous'],
265
+ note: 'glowing',
266
+ patch: () => ({
267
+ 'emissive.enabled': true, 'emissive.source': 'crevices', 'emissive.intensity': 3, 'emissive.width': 0.3,
268
+ }),
269
+ },
270
+ {
271
+ words: ['chunky', 'large', 'big', 'huge', 'coarse'],
272
+ note: 'coarser',
273
+ patch: (settings) => ({ 'base.scale': Math.max(1, Math.round(settings.base.scale * 0.55)) }),
274
+ },
275
+ {
276
+ words: ['fine', 'tiny', 'small', 'dense', 'micro'],
277
+ note: 'finer',
278
+ patch: (settings) => ({ 'base.scale': Math.min(64, Math.round(settings.base.scale * 1.8)) }),
279
+ },
280
+ {
281
+ words: ['dark', 'gloomy', 'shadowy'],
282
+ note: 'darker',
283
+ patch: () => ({ 'color.brightness': 0.72 }),
284
+ },
285
+ {
286
+ words: ['bright', 'sunny', 'vivid', 'vibrant', 'saturated'],
287
+ note: 'brighter',
288
+ patch: () => ({ 'color.brightness': 1.18, 'color.saturation': 1.25 }),
289
+ },
290
+ ];
291
+
292
+ function tokenize(prompt) {
293
+ return String(prompt ?? '')
294
+ .toLowerCase()
295
+ .split(/[^a-z0-9]+/)
296
+ .filter(Boolean);
297
+ }
298
+
299
+ /** Scores a preset against the prompt via tags + label words. */
300
+ function scorePreset(preset, promptText, tokens) {
301
+ let score = 0;
302
+ for (const tag of preset.tags) {
303
+ if (promptText.includes(tag)) score += tag.split(' ').length * 3;
304
+ // Stem credit: "mossy" should still light up the "moss" tag.
305
+ else if (tokens.some((token) => token.length > 3 && token.startsWith(tag))) score += 2;
306
+ }
307
+ for (const word of preset.label.toLowerCase().split(/[^a-z0-9]+/)) {
308
+ if (word.length > 2 && tokens.includes(word)) score += 2;
309
+ }
310
+ if (tokens.includes(preset.id)) score += 4;
311
+ return score;
312
+ }
313
+
314
+ // Ramp stops per preset, normalized once (hex -> triplets) and cached.
315
+ const presetStopsCache = new Map();
316
+ function presetRampStops(preset) {
317
+ let stops = presetStopsCache.get(preset.id);
318
+ if (!stops) {
319
+ const color = createTextureSettings(preset.settings).color;
320
+ stops = [color.color0, color.color1, color.color2, color.color3, color.color4];
321
+ presetStopsCache.set(preset.id, stops);
322
+ }
323
+ return stops;
324
+ }
325
+
326
+ function luma(rgb) {
327
+ return 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2];
328
+ }
329
+
330
+ /** Perceptually-weighted distance between two palettes (dark->light). */
331
+ function paletteDistance(a, b) {
332
+ let sum = 0;
333
+ for (let i = 0; i < 5; i += 1) {
334
+ const dr = a[i][0] - b[i][0];
335
+ const dg = a[i][1] - b[i][1];
336
+ const db = a[i][2] - b[i][2];
337
+ sum += Math.sqrt(dr * dr * 0.3 + dg * dg * 0.59 + db * db * 0.11);
338
+ }
339
+ return sum / 5;
340
+ }
341
+
342
+ /** Expands 2..N colors to 5 stops sorted dark -> light. */
343
+ function normalizePalette(palette) {
344
+ const colors = (Array.isArray(palette) ? palette : [])
345
+ .map((entry) => (Array.isArray(entry) ? entry : hexToRgb01(entry)))
346
+ .filter(Boolean)
347
+ .sort((a, b) => luma(a) - luma(b));
348
+ if (colors.length < 2) return null;
349
+ return [0, 1, 2, 3, 4].map((i) => {
350
+ const f = (i / 4) * (colors.length - 1);
351
+ const lo = Math.min(colors.length - 2, Math.floor(f));
352
+ const k = f - lo;
353
+ return colors[lo].map((c, ch) => c + (colors[lo + 1][ch] - c) * k);
354
+ });
355
+ }
356
+
357
+ /**
358
+ * Ranks presets against a description and/or a palette — the "existing
359
+ * texture you could use instead" seam (plan 09). `palette` accepts hex
360
+ * strings or [r, g, b] triplets in any order; `presets` defaults to the
361
+ * built-ins but takes any preset-shaped list (user library, catalog).
362
+ * Returns [{ preset, score }] best-first, empty when nothing relates.
363
+ */
364
+ export function matchTexturePresets({ text = '', palette = null, presets = BUILT_IN_TEXTURE_PRESETS, limit = 5 } = {}) {
365
+ const promptText = String(text ?? '').toLowerCase();
366
+ const tokens = tokenize(promptText);
367
+ const query = normalizePalette(palette);
368
+ const scored = [];
369
+ for (const preset of presets) {
370
+ let score = text ? scorePreset(preset, promptText, tokens) : 0;
371
+ if (query) {
372
+ const stops = [...presetRampStops(preset)].sort((a, b) => luma(a) - luma(b));
373
+ score += Math.max(0, 1 - paletteDistance(query, stops) * 2.4) * 6;
374
+ }
375
+ if (score > 0.5) scored.push({ preset, score });
376
+ }
377
+ return scored.sort((a, b) => b.score - a.score).slice(0, limit);
378
+ }
379
+
380
+ /**
381
+ * Deterministic offline prompt mapper: best-matching preset + adjective
382
+ * modifiers + color words. Same result shape as compileTextureAiRecipe.
383
+ * mode 'refine' skips preset matching and only applies modifiers/colors
384
+ * on top of `currentSettings`.
385
+ */
386
+ export function keywordTextureRecipe(prompt, { mode = 'new', currentSettings = null } = {}) {
387
+ const promptText = String(prompt ?? '').toLowerCase();
388
+ const tokens = tokenize(promptText);
389
+ const notes = [];
390
+
391
+ let start;
392
+ let presetId = null;
393
+ if (mode === 'refine' && currentSettings) {
394
+ start = createTextureSettings(currentSettings);
395
+ notes.push('refined current texture');
396
+ } else {
397
+ let best = null;
398
+ let bestScore = 0;
399
+ for (const preset of BUILT_IN_TEXTURE_PRESETS) {
400
+ const score = scorePreset(preset, promptText, tokens);
401
+ if (score > bestScore) {
402
+ best = preset;
403
+ bestScore = score;
404
+ }
405
+ }
406
+ start = createTextureSettings(best?.settings ?? {});
407
+ presetId = best?.id ?? null;
408
+ notes.push(best ? `matched “${best.label}”` : 'no preset matched — started from defaults');
409
+ }
410
+
411
+ let patch = {};
412
+ for (const modifier of MODIFIERS) {
413
+ if (modifier.words.some((word) => tokens.includes(word))) {
414
+ patch = { ...patch, ...modifier.patch(start) };
415
+ notes.push(modifier.note);
416
+ }
417
+ }
418
+ for (const [word, make] of Object.entries(COLOR_WORDS)) {
419
+ if (tokens.includes(word)) {
420
+ patch = { ...patch, ...make() };
421
+ notes.push(word);
422
+ break;
423
+ }
424
+ }
425
+
426
+ const { settings, applied, ignored } = applyTextureSettingsPatch(start, patch);
427
+ const name = String(prompt ?? '').trim().slice(0, 48) || 'Untitled texture';
428
+ return {
429
+ applied,
430
+ ignored,
431
+ name: name.charAt(0).toUpperCase() + name.slice(1),
432
+ notes: `Offline mapper: ${notes.join(', ')}.`,
433
+ presetId,
434
+ settings,
435
+ };
436
+ }