@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,1110 @@
1
+ // Vegetation shader authoring has two public layers:
2
+ //
3
+ // 1. VEGETATION_SHADER is the canonical, IP-wide treatment profile consumed by
4
+ // every vegetation material role. It deliberately excludes albedo/textures,
5
+ // current light/weather/wind values, construction settings, and interaction
6
+ // field geometry.
7
+ // 2. FOLIAGE_SHADER / GRASS_SHADER / FLOWER_SHADER / BARK_SHADER are the
8
+ // original family APIs. They remain as compatibility adapters for existing
9
+ // applications and saved documents, but new authoring should use one
10
+ // VegetationShaderProfile instead of four unrelated presets.
11
+
12
+ import * as THREE from 'three';
13
+
14
+ export const VEGETATION_SHADER_DOCUMENT_TYPE = 'toonlab/vegetation-shader-preset';
15
+ export const VEGETATION_SHADER_SCHEMA_VERSION = 1;
16
+ export const VEGETATION_MATERIAL_CONTRACT_VERSION = 1;
17
+
18
+ export const VEGETATION_MATERIAL_ROLES = Object.freeze({
19
+ foliageCard: 'foliageCard',
20
+ flowerCenter: 'flowerCenter',
21
+ flowerPetal: 'flowerPetal',
22
+ grassBlade: 'grassBlade',
23
+ herbaceousStem: 'herbaceousStem',
24
+ woodySurface: 'woodySurface',
25
+ });
26
+
27
+ export const VEGETATION_MATERIAL_VARIANTS = Object.freeze({
28
+ billboard: 'billboard',
29
+ cutout: 'cutout',
30
+ mesh: 'mesh',
31
+ procedural: 'procedural',
32
+ });
33
+
34
+ const ALL_ROLES = Object.freeze(Object.values(VEGETATION_MATERIAL_ROLES));
35
+ const THIN_SURFACE_ROLES = Object.freeze([
36
+ VEGETATION_MATERIAL_ROLES.grassBlade,
37
+ VEGETATION_MATERIAL_ROLES.foliageCard,
38
+ VEGETATION_MATERIAL_ROLES.flowerPetal,
39
+ ]);
40
+
41
+ function isPlainObject(value) {
42
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
43
+ }
44
+
45
+ function cleanObject(value) {
46
+ return isPlainObject(value) ? value : {};
47
+ }
48
+
49
+ function clampNumber(value, fallback, { max = Infinity, min = -Infinity } = {}) {
50
+ const parsed = Number(value);
51
+ if (!Number.isFinite(parsed)) return fallback;
52
+ return Math.min(Math.max(parsed, min), max);
53
+ }
54
+
55
+ function srgbTriplet(value, fallback) {
56
+ if (value?.isColor) return [value.r, value.g, value.b];
57
+ if (!Array.isArray(value) || value.length < 3) return [...fallback];
58
+ const channels = value.slice(0, 3).map(Number);
59
+ return channels.every(Number.isFinite) ? channels : [...fallback];
60
+ }
61
+
62
+ function cloneSetting(value) {
63
+ return Array.isArray(value) ? [...value] : value;
64
+ }
65
+
66
+ function pascalCase(value) {
67
+ return String(value).replace(/(^|[^a-zA-Z0-9]+)([a-zA-Z0-9])/g,
68
+ (_match, _separator, char) => char.toUpperCase());
69
+ }
70
+
71
+ function field({
72
+ defaultValue,
73
+ description,
74
+ label,
75
+ range = null,
76
+ roles = null,
77
+ type = 'number',
78
+ integer = false,
79
+ }) {
80
+ return Object.freeze({
81
+ defaultValue: Array.isArray(defaultValue) ? Object.freeze([...defaultValue]) : defaultValue,
82
+ description,
83
+ integer,
84
+ label,
85
+ range: range ? Object.freeze({ ...range }) : null,
86
+ roles: Object.freeze([...(roles ?? ALL_ROLES)]),
87
+ type,
88
+ });
89
+ }
90
+
91
+ // Every exposed field has a uniform contract, even before every material
92
+ // variant implements it. applyVegetationShader reports missing uniforms so a
93
+ // lab can never imply that a control changed a material when it did not.
94
+ const GROUP_DEFINITIONS = Object.freeze({
95
+ lighting: Object.freeze({
96
+ description: 'IP-wide light and shadow treatment shared by every vegetation surface.',
97
+ label: 'Shared Lighting',
98
+ fields: Object.freeze({
99
+ shadowTint: field({
100
+ defaultValue: [0.36, 0.4, 0.58],
101
+ description: 'Cool treatment tint mixed into shadowed vegetation without replacing its albedo.',
102
+ label: 'Shadow Tint',
103
+ type: 'color',
104
+ }),
105
+ shadowTintStrength: field({
106
+ defaultValue: 1,
107
+ description: 'Strength of the shared shadow tint treatment.',
108
+ label: 'Shadow Tint Strength',
109
+ range: { max: 1, min: 0, step: 0.01 },
110
+ }),
111
+ sunTintStrength: field({
112
+ defaultValue: 0.25,
113
+ description: 'How strongly the active sun color tints lit vegetation.',
114
+ label: 'Sun Tint Strength',
115
+ range: { max: 1, min: 0, step: 0.01 },
116
+ }),
117
+ skyFillStrength: field({
118
+ defaultValue: 0.08,
119
+ description: 'Shared sky-color fill in unlit vegetation regions.',
120
+ label: 'Sky Fill Strength',
121
+ range: { max: 0.5, min: 0, step: 0.005 },
122
+ }),
123
+ rimStrength: field({
124
+ defaultValue: 0.12,
125
+ description: 'View-dependent silhouette fill shared by vegetation surfaces.',
126
+ label: 'Rim Strength',
127
+ range: { max: 1, min: 0, step: 0.01 },
128
+ }),
129
+ rimPower: field({
130
+ defaultValue: 3,
131
+ description: 'Falloff exponent of the shared vegetation rim.',
132
+ label: 'Rim Power',
133
+ range: { max: 12, min: 0.5, step: 0.1 },
134
+ }),
135
+ }),
136
+ }),
137
+ thinSurface: Object.freeze({
138
+ description: 'Lighting shared by thin blades, leaf cards, and petals.',
139
+ label: 'Thin Surfaces',
140
+ fields: Object.freeze({
141
+ diffuseWrap: field({
142
+ defaultValue: 0.5,
143
+ description: 'Wraps direct light around thin surfaces so back faces remain readable.',
144
+ label: 'Diffuse Wrap',
145
+ range: { max: 1, min: 0, step: 0.01 },
146
+ roles: THIN_SURFACE_ROLES,
147
+ }),
148
+ transmissionStrength: field({
149
+ defaultValue: 0.35,
150
+ description: 'Shared sunlight transmission through blades, leaves, and petals.',
151
+ label: 'Transmission Strength',
152
+ range: { max: 2, min: 0, step: 0.01 },
153
+ roles: THIN_SURFACE_ROLES,
154
+ }),
155
+ transmissionPower: field({
156
+ defaultValue: 3.5,
157
+ description: 'Angular concentration of thin-surface transmission.',
158
+ label: 'Transmission Power',
159
+ range: { max: 12, min: 0.5, step: 0.1 },
160
+ roles: THIN_SURFACE_ROLES,
161
+ }),
162
+ transmissionShadowFloor: field({
163
+ defaultValue: 0.35,
164
+ description: 'Minimum transmission that remains inside cast or cloud shadow.',
165
+ label: 'Transmission Shadow Floor',
166
+ range: { max: 1, min: 0, step: 0.01 },
167
+ roles: THIN_SURFACE_ROLES,
168
+ }),
169
+ normalUpBias: field({
170
+ defaultValue: 0,
171
+ description: 'Biases thin-surface shading normals toward world up.',
172
+ label: 'Normal Up Bias',
173
+ range: { max: 1, min: 0, step: 0.01 },
174
+ roles: THIN_SURFACE_ROLES,
175
+ }),
176
+ twoSidedLighting: field({
177
+ defaultValue: 1,
178
+ description: 'Blends back-face normals into the shared thin-surface lighting model.',
179
+ label: 'Two-Sided Lighting',
180
+ range: { max: 1, min: 0, step: 0.01 },
181
+ roles: THIN_SURFACE_ROLES,
182
+ }),
183
+ }),
184
+ }),
185
+ weatherResponse: Object.freeze({
186
+ description: 'How the IP shades wetness and snow; current weather amounts remain scene-owned.',
187
+ label: 'Weather Response',
188
+ fields: Object.freeze({
189
+ wetDarkening: field({
190
+ defaultValue: 0.15,
191
+ description: 'Maximum albedo darkening applied by wetness.',
192
+ label: 'Wet Darkening',
193
+ range: { max: 1, min: 0, step: 0.01 },
194
+ }),
195
+ wetDesaturation: field({
196
+ defaultValue: 0.05,
197
+ description: 'Maximum desaturation applied by wetness.',
198
+ label: 'Wet Desaturation',
199
+ range: { max: 1, min: 0, step: 0.01 },
200
+ }),
201
+ wetHighlightStrength: field({
202
+ defaultValue: 0.2,
203
+ description: 'Stylized highlight added to wet vegetation.',
204
+ label: 'Wet Highlight Strength',
205
+ range: { max: 1, min: 0, step: 0.01 },
206
+ }),
207
+ snowTint: field({
208
+ defaultValue: [0.92, 0.96, 1],
209
+ description: 'IP snow tint blended over retained snow coverage.',
210
+ label: 'Snow Tint',
211
+ type: 'color',
212
+ }),
213
+ snowShadowStrength: field({
214
+ defaultValue: 0.65,
215
+ description: 'Shadow response retained by snow-covered vegetation.',
216
+ label: 'Snow Shadow Strength',
217
+ range: { max: 1, min: 0, step: 0.01 },
218
+ }),
219
+ snowEdgeSoftness: field({
220
+ defaultValue: 0.2,
221
+ description: 'Softness of snow coverage transitions.',
222
+ label: 'Snow Edge Softness',
223
+ range: { max: 1, min: 0, step: 0.01 },
224
+ }),
225
+ }),
226
+ }),
227
+ grass: Object.freeze({
228
+ description: 'Grass-only lighting, gradient, dense-field, gust, and bend treatment.',
229
+ label: 'Grass',
230
+ fields: Object.freeze({
231
+ backlitStrength: field({ defaultValue: 0.4, description: 'Grass transmission multiplier.', label: 'Backlit Strength', range: { max: 1.5, min: 0, step: 0.01 }, roles: [VEGETATION_MATERIAL_ROLES.grassBlade] }),
232
+ sceneShadowResponse: field({ defaultValue: 0.7, description: 'Grass response to renderer shadow visibility.', label: 'Scene Shadow Response', range: { max: 1, min: 0, step: 0.01 }, roles: [VEGETATION_MATERIAL_ROLES.grassBlade] }),
233
+ cloudShadowResponse: field({ defaultValue: 0.35, description: 'Grass response to the scene cloud-shadow field.', label: 'Cloud Shadow Response', range: { max: 1, min: 0, step: 0.01 }, roles: [VEGETATION_MATERIAL_ROLES.grassBlade] }),
234
+ bandThreshold: field({ defaultValue: 0.49, description: 'Center of the grass direct-light toon transition.', label: 'Band Threshold', range: { max: 1, min: 0, step: 0.01 }, roles: [VEGETATION_MATERIAL_ROLES.grassBlade] }),
235
+ bandSoftness: field({ defaultValue: 0.1, description: 'Width of the grass direct-light toon transition.', label: 'Band Softness', range: { max: 0.5, min: 0, step: 0.005 }, roles: [VEGETATION_MATERIAL_ROLES.grassBlade] }),
236
+ shadowFloor: field({ defaultValue: 0.35, description: 'Minimum grass brightness in full shadow.', label: 'Shadow Floor', range: { max: 1, min: 0, step: 0.01 }, roles: [VEGETATION_MATERIAL_ROLES.grassBlade] }),
237
+ rootOcclusionStrength: field({ defaultValue: 0.36, description: 'Dense-field darkening at blade roots.', label: 'Root Occlusion Strength', range: { max: 1, min: 0, step: 0.01 }, roles: [VEGETATION_MATERIAL_ROLES.grassBlade] }),
238
+ rootOcclusionHeight: field({ defaultValue: 0.62, description: 'Blade height over which root occlusion fades.', label: 'Root Occlusion Height', range: { max: 1, min: 0.01, step: 0.01 }, roles: [VEGETATION_MATERIAL_ROLES.grassBlade] }),
239
+ tipGradientStart: field({ defaultValue: 0.1, description: 'Blade fraction where the root-to-tip material gradient begins.', label: 'Tip Gradient Start', range: { max: 1, min: 0, step: 0.01 }, roles: [VEGETATION_MATERIAL_ROLES.grassBlade] }),
240
+ tipGradientEnd: field({ defaultValue: 0.95, description: 'Blade fraction where the root-to-tip material gradient completes.', label: 'Tip Gradient End', range: { max: 1, min: 0, step: 0.01 }, roles: [VEGETATION_MATERIAL_ROLES.grassBlade] }),
241
+ colorVariationStrength: field({ defaultValue: 0.2, description: 'Seeded blade luminance variation.', label: 'Color Variation Strength', range: { max: 1, min: 0, step: 0.01 }, roles: [VEGETATION_MATERIAL_ROLES.grassBlade] }),
242
+ gustSheenThreshold: field({ defaultValue: 0.78, description: 'Gust value where the blade-tip sheen begins.', label: 'Gust Sheen Threshold', range: { max: 1, min: 0, step: 0.01 }, roles: [VEGETATION_MATERIAL_ROLES.grassBlade] }),
243
+ gustSheenStrength: field({ defaultValue: 0.22, description: 'Strength of the moving gust sheen.', label: 'Gust Sheen Strength', range: { max: 1, min: 0, step: 0.01 }, roles: [VEGETATION_MATERIAL_ROLES.grassBlade] }),
244
+ bendExponent: field({ defaultValue: 2, description: 'Root-to-tip curve used by wind and interaction deformation.', label: 'Bend Exponent', range: { max: 6, min: 0.5, step: 0.05 }, roles: [VEGETATION_MATERIAL_ROLES.grassBlade] }),
245
+ interactionResponse: field({ defaultValue: 1, description: 'Grass deformation response to a scene-owned interaction field.', label: 'Interaction Response', range: { max: 2, min: 0, step: 0.01 }, roles: [VEGETATION_MATERIAL_ROLES.grassBlade] }),
246
+ }),
247
+ }),
248
+ foliage: Object.freeze({
249
+ description: 'Leaf-card and canopy-volume treatment.',
250
+ label: 'Foliage',
251
+ fields: Object.freeze({
252
+ backlitStrength: field({ defaultValue: 0.35, description: 'Foliage transmission multiplier.', label: 'Backlit Strength', range: { max: 1.5, min: 0, step: 0.01 }, roles: [VEGETATION_MATERIAL_ROLES.foliageCard] }),
253
+ sceneShadowResponse: field({ defaultValue: 0.55, description: 'Foliage response to renderer shadow visibility.', label: 'Scene Shadow Response', range: { max: 1, min: 0, step: 0.01 }, roles: [VEGETATION_MATERIAL_ROLES.foliageCard] }),
254
+ cloudShadowResponse: field({ defaultValue: 0, description: 'Foliage response to the scene cloud-shadow field.', label: 'Cloud Shadow Response', range: { max: 1, min: 0, step: 0.01 }, roles: [VEGETATION_MATERIAL_ROLES.foliageCard] }),
255
+ bandThreshold: field({ defaultValue: 0.47, description: 'Center of the foliage direct-light toon transition.', label: 'Band Threshold', range: { max: 1, min: 0, step: 0.01 }, roles: [VEGETATION_MATERIAL_ROLES.foliageCard] }),
256
+ bandSoftness: field({ defaultValue: 0.18, description: 'Width of the foliage direct-light toon transition.', label: 'Band Softness', range: { max: 0.5, min: 0, step: 0.005 }, roles: [VEGETATION_MATERIAL_ROLES.foliageCard] }),
257
+ crestThreshold: field({ defaultValue: 0.72, description: 'Center of the high crown-color crest band.', label: 'Crest Threshold', range: { max: 1, min: 0, step: 0.01 }, roles: [VEGETATION_MATERIAL_ROLES.foliageCard] }),
258
+ crestSoftness: field({ defaultValue: 0.12, description: 'Width of the high crown-color crest band.', label: 'Crest Softness', range: { max: 0.5, min: 0, step: 0.005 }, roles: [VEGETATION_MATERIAL_ROLES.foliageCard] }),
259
+ crownOcclusionStrength: field({ defaultValue: 0.2, description: 'Additional darkening inside renderer-shadowed crowns.', label: 'Crown Occlusion Strength', range: { max: 1, min: 0, step: 0.01 }, roles: [VEGETATION_MATERIAL_ROLES.foliageCard] }),
260
+ spriteLuminanceStrength: field({ defaultValue: 0.36, description: 'Influence of painted leaf-sprite luminance.', label: 'Sprite Luminance Strength', range: { max: 1, min: 0, step: 0.01 }, roles: [VEGETATION_MATERIAL_ROLES.foliageCard] }),
261
+ cardVariationStrength: field({ defaultValue: 0.16, description: 'Seeded per-card luminance variation.', label: 'Card Variation Strength', range: { max: 1, min: 0, step: 0.01 }, roles: [VEGETATION_MATERIAL_ROLES.foliageCard] }),
262
+ transmissionPowerMultiplier: field({ defaultValue: 1, description: 'Foliage multiplier over the shared thin-surface transmission concentration.', label: 'Transmission Power Multiplier', range: { max: 3, min: 0.25, step: 0.05 }, roles: [VEGETATION_MATERIAL_ROLES.foliageCard] }),
263
+ }),
264
+ }),
265
+ flower: Object.freeze({
266
+ description: 'Shared petal/center treatment across mesh, cutout, procedural, and billboard flowers.',
267
+ label: 'Flower',
268
+ fields: Object.freeze({
269
+ backlitStrength: field({ defaultValue: 0.35, description: 'Petal transmission multiplier.', label: 'Backlit Strength', range: { max: 1.5, min: 0, step: 0.01 }, roles: [VEGETATION_MATERIAL_ROLES.flowerPetal] }),
270
+ sceneShadowResponse: field({ defaultValue: 0.85, description: 'Flower response to renderer shadow visibility.', label: 'Scene Shadow Response', range: { max: 1, min: 0, step: 0.01 }, roles: [VEGETATION_MATERIAL_ROLES.flowerPetal, VEGETATION_MATERIAL_ROLES.flowerCenter] }),
271
+ bandThreshold: field({ defaultValue: 0.5, description: 'Center of the flower direct-light toon transition.', label: 'Band Threshold', range: { max: 1, min: 0, step: 0.01 }, roles: [VEGETATION_MATERIAL_ROLES.flowerPetal, VEGETATION_MATERIAL_ROLES.flowerCenter] }),
272
+ bandSoftness: field({ defaultValue: 0.1, description: 'Width of the flower direct-light toon transition.', label: 'Band Softness', range: { max: 0.5, min: 0, step: 0.005 }, roles: [VEGETATION_MATERIAL_ROLES.flowerPetal, VEGETATION_MATERIAL_ROLES.flowerCenter] }),
273
+ unlitPetalLift: field({ defaultValue: 0.35, description: 'Petal-tinted floor for unlit petal faces.', label: 'Unlit Petal Lift', range: { max: 1, min: 0, step: 0.01 }, roles: [VEGETATION_MATERIAL_ROLES.flowerPetal] }),
274
+ cupDarkeningStrength: field({ defaultValue: 0.1, description: 'Stylized darkening toward curved petal edges.', label: 'Cup Darkening Strength', range: { max: 1, min: 0, step: 0.01 }, roles: [VEGETATION_MATERIAL_ROLES.flowerPetal] }),
275
+ petalTransmissionMultiplier: field({ defaultValue: 1, description: 'Flower-family multiplier over shared thin-surface transmission.', label: 'Petal Transmission Multiplier', range: { max: 2, min: 0, step: 0.01 }, roles: [VEGETATION_MATERIAL_ROLES.flowerPetal] }),
276
+ centerLightResponse: field({ defaultValue: 0.8, description: 'Direct-light response of flower centers relative to petals.', label: 'Center Light Response', range: { max: 2, min: 0, step: 0.01 }, roles: [VEGETATION_MATERIAL_ROLES.flowerCenter] }),
277
+ centerShadowResponse: field({ defaultValue: 1, description: 'Shadow response of flower centers relative to petals.', label: 'Center Shadow Response', range: { max: 2, min: 0, step: 0.01 }, roles: [VEGETATION_MATERIAL_ROLES.flowerCenter] }),
278
+ }),
279
+ }),
280
+ bark: Object.freeze({
281
+ description: 'Opaque woody treatment for trunks, branches, and roots.',
282
+ label: 'Bark / Woody Surface',
283
+ fields: Object.freeze({
284
+ bandCount: field({ defaultValue: 3, description: 'Cel bands across the woody light-to-shadow ramp.', integer: true, label: 'Band Count', range: { max: 6, min: 2, step: 1 }, roles: [VEGETATION_MATERIAL_ROLES.woodySurface] }),
285
+ bandSoftness: field({ defaultValue: 0, description: 'Continuous softness of woody toon-band transitions.', label: 'Band Softness', range: { max: 1, min: 0, step: 0.01 }, roles: [VEGETATION_MATERIAL_ROLES.woodySurface] }),
286
+ shadowFloor: field({ defaultValue: 0.35, description: 'Minimum brightness of a fully shadowed woody surface.', label: 'Shadow Floor', range: { max: 0.9, min: 0, step: 0.01 }, roles: [VEGETATION_MATERIAL_ROLES.woodySurface] }),
287
+ sunTintStrength: field({ defaultValue: 0.15, description: 'Sun-color tint applied to lit bark.', label: 'Sun Tint Strength', range: { max: 1, min: 0, step: 0.01 }, roles: [VEGETATION_MATERIAL_ROLES.woodySurface] }),
288
+ skyFillStrength: field({ defaultValue: 0.04, description: 'Sky-color fill applied to shaded bark.', label: 'Sky Fill Strength', range: { max: 0.5, min: 0, step: 0.005 }, roles: [VEGETATION_MATERIAL_ROLES.woodySurface] }),
289
+ rimStrength: field({ defaultValue: 0, description: 'View-dependent woody silhouette fill.', label: 'Rim Strength', range: { max: 1, min: 0, step: 0.01 }, roles: [VEGETATION_MATERIAL_ROLES.woodySurface] }),
290
+ specularStrength: field({ defaultValue: 0, description: 'Stylized bark highlight strength.', label: 'Specular Strength', range: { max: 1, min: 0, step: 0.01 }, roles: [VEGETATION_MATERIAL_ROLES.woodySurface] }),
291
+ verticalShadeStrength: field({ defaultValue: 0, description: 'World-up gradient used to ground trunks without changing their albedo.', label: 'Vertical Shade Strength', range: { max: 1, min: 0, step: 0.01 }, roles: [VEGETATION_MATERIAL_ROLES.woodySurface] }),
292
+ }),
293
+ }),
294
+ stem: Object.freeze({
295
+ description: 'Smooth herbaceous stem treatment, intentionally separate from woody bark.',
296
+ label: 'Herbaceous Stem',
297
+ fields: Object.freeze({
298
+ bandCount: field({ defaultValue: 3, description: 'Cel bands across herbaceous stems.', integer: true, label: 'Band Count', range: { max: 6, min: 2, step: 1 }, roles: [VEGETATION_MATERIAL_ROLES.herbaceousStem] }),
299
+ bandSoftness: field({ defaultValue: 0.08, description: 'Softness of herbaceous stem toon bands.', label: 'Band Softness', range: { max: 1, min: 0, step: 0.01 }, roles: [VEGETATION_MATERIAL_ROLES.herbaceousStem] }),
300
+ shadowFloor: field({ defaultValue: 0.42, description: 'Minimum brightness of a fully shadowed stem.', label: 'Shadow Floor', range: { max: 0.9, min: 0, step: 0.01 }, roles: [VEGETATION_MATERIAL_ROLES.herbaceousStem] }),
301
+ transmissionStrength: field({ defaultValue: 0.08, description: 'Subtle light transmission through green stems.', label: 'Transmission Strength', range: { max: 1, min: 0, step: 0.01 }, roles: [VEGETATION_MATERIAL_ROLES.herbaceousStem] }),
302
+ skyFillStrength: field({ defaultValue: 0.06, description: 'Additional stem sky-color fill over the shared vegetation fill.', label: 'Additional Sky Fill', range: { max: 0.5, min: 0, step: 0.005 }, roles: [VEGETATION_MATERIAL_ROLES.herbaceousStem] }),
303
+ rimStrength: field({ defaultValue: 0.02, description: 'View-dependent stem silhouette fill.', label: 'Rim Strength', range: { max: 1, min: 0, step: 0.01 }, roles: [VEGETATION_MATERIAL_ROLES.herbaceousStem] }),
304
+ }),
305
+ }),
306
+ });
307
+
308
+ export const VEGETATION_SHADER_SETTING_GROUPS = Object.freeze(
309
+ Object.entries(GROUP_DEFINITIONS).map(([id, group]) => Object.freeze({
310
+ description: group.description,
311
+ id,
312
+ label: group.label,
313
+ })),
314
+ );
315
+
316
+ const fieldSchema = {};
317
+ const defaultSettings = {};
318
+ const uniformByField = {};
319
+ for (const [groupId, group] of Object.entries(GROUP_DEFINITIONS)) {
320
+ const fields = {};
321
+ const defaults = {};
322
+ for (const [key, definition] of Object.entries(group.fields)) {
323
+ const path = `${groupId}.${key}`;
324
+ const uniform = `uStyle${pascalCase(groupId)}${pascalCase(key)}`;
325
+ defaults[key] = cloneSetting(definition.defaultValue);
326
+ uniformByField[path] = uniform;
327
+ fields[key] = Object.freeze({
328
+ defaultValue: cloneSetting(definition.defaultValue),
329
+ description: definition.description,
330
+ group: groupId,
331
+ id: path,
332
+ key,
333
+ label: definition.label,
334
+ range: definition.range,
335
+ roles: definition.roles,
336
+ serializable: true,
337
+ type: definition.type,
338
+ uniform,
339
+ });
340
+ }
341
+ fieldSchema[groupId] = Object.freeze(fields);
342
+ defaultSettings[groupId] = Object.freeze(defaults);
343
+ }
344
+
345
+ export const VEGETATION_SHADER_FIELD_SCHEMA = Object.freeze(fieldSchema);
346
+ export const DEFAULT_VEGETATION_SHADER_SETTINGS = Object.freeze(defaultSettings);
347
+ export const VEGETATION_SHADER_UNIFORM_BY_FIELD = Object.freeze(uniformByField);
348
+
349
+ const vegetationShaderPresets = new Map();
350
+
351
+ function sanitizeVegetationField(value, definition, fallback) {
352
+ if (definition.type === 'color') return srgbTriplet(value, fallback);
353
+ const number = clampNumber(value, fallback, definition.range ?? {});
354
+ return definition.integer ? Math.round(number) : number;
355
+ }
356
+
357
+ function normalizePresetId(value) {
358
+ const id = String(value ?? '').trim();
359
+ return id || 'default';
360
+ }
361
+
362
+ /** Resolves one complete, plain IP-wide vegetation shader settings object. */
363
+ export function createVegetationShaderSettings(options = {}) {
364
+ const source = typeof options === 'string' ? { preset: options } : cleanObject(options);
365
+ const presetId = normalizePresetId(source.preset);
366
+ const preset = vegetationShaderPresets.get(presetId) ?? vegetationShaderPresets.get('default');
367
+ const base = preset?.settings ?? DEFAULT_VEGETATION_SHADER_SETTINGS;
368
+ const input = isPlainObject(source.settings) ? source.settings : source;
369
+ const settings = {};
370
+ for (const [groupId, group] of Object.entries(GROUP_DEFINITIONS)) {
371
+ const groupInput = cleanObject(input[groupId]);
372
+ const resolvedGroup = {};
373
+ for (const [key, definition] of Object.entries(group.fields)) {
374
+ const fallback = base[groupId]?.[key] ?? definition.defaultValue;
375
+ resolvedGroup[key] = sanitizeVegetationField(
376
+ groupInput[key] !== undefined ? groupInput[key] : fallback,
377
+ definition,
378
+ fallback,
379
+ );
380
+ }
381
+ settings[groupId] = resolvedGroup;
382
+ }
383
+ return settings;
384
+ }
385
+
386
+ function collectUnknownVegetationFields(settings) {
387
+ const warnings = [];
388
+ for (const [groupId, value] of Object.entries(cleanObject(settings))) {
389
+ if (!GROUP_DEFINITIONS[groupId]) {
390
+ warnings.push(`Unknown vegetation shader group "${groupId}" was ignored.`);
391
+ continue;
392
+ }
393
+ for (const key of Object.keys(cleanObject(value))) {
394
+ if (!GROUP_DEFINITIONS[groupId].fields[key]) {
395
+ warnings.push(`Unknown vegetation shader field "${groupId}.${key}" was ignored.`);
396
+ }
397
+ }
398
+ }
399
+ return warnings;
400
+ }
401
+
402
+ /** Validates a canonical VegetationShaderProfile document. */
403
+ export function validateVegetationShaderPresetDocument(input) {
404
+ let source = input;
405
+ if (typeof source === 'string') {
406
+ try {
407
+ source = JSON.parse(source);
408
+ } catch (error) {
409
+ return { errors: [`Invalid vegetation shader preset JSON: ${error.message}`], ok: false, value: null, warnings: [] };
410
+ }
411
+ }
412
+ if (!isPlainObject(source)) {
413
+ return { errors: ['Vegetation shader preset must be a JSON object.'], ok: false, value: null, warnings: [] };
414
+ }
415
+
416
+ const errors = [];
417
+ const warnings = [];
418
+ if (source.type !== VEGETATION_SHADER_DOCUMENT_TYPE) {
419
+ errors.push(`Vegetation shader preset type must be "${VEGETATION_SHADER_DOCUMENT_TYPE}".`);
420
+ }
421
+ const rawVersion = source.version ?? source.schemaVersion;
422
+ const version = rawVersion === undefined ? VEGETATION_SHADER_SCHEMA_VERSION : Math.round(Number(rawVersion));
423
+ if (rawVersion === undefined) warnings.push(`Vegetation shader preset version was missing and defaulted to ${VEGETATION_SHADER_SCHEMA_VERSION}.`);
424
+ if (!Number.isFinite(version)) errors.push('Vegetation shader preset version must be a number.');
425
+ else if (version > VEGETATION_SHADER_SCHEMA_VERSION) {
426
+ errors.push(`Vegetation shader preset version ${version} is newer than supported version ${VEGETATION_SHADER_SCHEMA_VERSION}.`);
427
+ }
428
+
429
+ const id = String(source.id ?? '').trim();
430
+ if (!id) errors.push('Vegetation shader preset id is required.');
431
+ const settingsInput = cleanObject(source.settings);
432
+ warnings.push(...collectUnknownVegetationFields(settingsInput));
433
+ const value = errors.length === 0 ? {
434
+ description: String(source.description ?? ''),
435
+ id,
436
+ label: String(source.label || id),
437
+ settings: createVegetationShaderSettings(settingsInput),
438
+ type: VEGETATION_SHADER_DOCUMENT_TYPE,
439
+ version: VEGETATION_SHADER_SCHEMA_VERSION,
440
+ } : null;
441
+ return { errors, ok: errors.length === 0, value, warnings };
442
+ }
443
+
444
+ export function parseVegetationShaderPresetDocument(input) {
445
+ return validateVegetationShaderPresetDocument(input);
446
+ }
447
+
448
+ export function createVegetationShaderPresetDocument(id, definition = {}) {
449
+ const source = cleanObject(definition);
450
+ const document = {
451
+ description: source.description ?? '',
452
+ id: id ?? source.id,
453
+ label: source.label ?? id ?? source.id,
454
+ settings: createVegetationShaderSettings(source.settings ?? source),
455
+ type: VEGETATION_SHADER_DOCUMENT_TYPE,
456
+ version: VEGETATION_SHADER_SCHEMA_VERSION,
457
+ };
458
+ const result = validateVegetationShaderPresetDocument(document);
459
+ if (!result.ok) throw new Error(result.errors.join(' '));
460
+ return result.value;
461
+ }
462
+
463
+ export function serializeVegetationShaderPreset(idOrDocument, definition = {}, { pretty = true } = {}) {
464
+ const document = isPlainObject(idOrDocument) && idOrDocument.type === VEGETATION_SHADER_DOCUMENT_TYPE
465
+ ? createVegetationShaderPresetDocument(idOrDocument.id, idOrDocument)
466
+ : createVegetationShaderPresetDocument(idOrDocument, definition);
467
+ return JSON.stringify(document, null, pretty ? 2 : 0);
468
+ }
469
+
470
+ export function registerVegetationShaderPreset(id, definition = {}, { overwrite = false } = {}) {
471
+ const document = createVegetationShaderPresetDocument(id, definition);
472
+ if (!overwrite && vegetationShaderPresets.has(document.id)) {
473
+ throw new Error(`Vegetation shader preset "${document.id}" already exists.`);
474
+ }
475
+ vegetationShaderPresets.set(document.id, {
476
+ description: document.description,
477
+ label: document.label,
478
+ settings: document.settings,
479
+ });
480
+ return { description: document.description, id: document.id, label: document.label, value: document.id };
481
+ }
482
+
483
+ export function registerSerializedVegetationShaderPreset(input, options = {}) {
484
+ const result = parseVegetationShaderPresetDocument(input);
485
+ if (!result.ok) throw new Error(result.errors.join(' '));
486
+ return registerVegetationShaderPreset(result.value.id, result.value, {
487
+ overwrite: options.overwrite ?? true,
488
+ });
489
+ }
490
+
491
+ export function getVegetationShaderPresetOptions() {
492
+ return [...vegetationShaderPresets.entries()].map(([id, preset]) => ({
493
+ description: preset.description,
494
+ id,
495
+ label: preset.label,
496
+ value: id,
497
+ }));
498
+ }
499
+
500
+ export function resolveVegetationShaderPreset(id = 'default', overrides = {}) {
501
+ return createVegetationShaderSettings({ ...cleanObject(overrides), preset: normalizePresetId(id) });
502
+ }
503
+
504
+ // Register the baseline before resolving any other preset.
505
+ registerVegetationShaderPreset('default', {
506
+ description: 'Complete baseline vegetation shader treatment.',
507
+ label: 'Default',
508
+ });
509
+ registerVegetationShaderPreset('call_me_sensei', {
510
+ description: 'Studio-managed signature vegetation treatment.',
511
+ label: 'Call Me Sensei',
512
+ settings: {
513
+ bark: { bandCount: 3, shadowFloor: 0.42 },
514
+ flower: { unlitPetalLift: 0.4 },
515
+ foliage: { backlitStrength: 0.45, cloudShadowResponse: 0.4, sceneShadowResponse: 0.6 },
516
+ grass: { backlitStrength: 0.5, cloudShadowResponse: 0.45 },
517
+ lighting: { shadowTint: [0.34, 0.36, 0.6] },
518
+ },
519
+ });
520
+
521
+ export const VEGETATION_SHADER = Object.freeze({
522
+ createDocument: createVegetationShaderPresetDocument,
523
+ createSettings: createVegetationShaderSettings,
524
+ defaults: DEFAULT_VEGETATION_SHADER_SETTINGS,
525
+ description: 'One IP-wide vegetation treatment composed by semantic material role.',
526
+ documentType: VEGETATION_SHADER_DOCUMENT_TYPE,
527
+ fieldSchema: VEGETATION_SHADER_FIELD_SCHEMA,
528
+ getPresetOptions: getVegetationShaderPresetOptions,
529
+ id: 'vegetation',
530
+ label: 'Vegetation Shader',
531
+ registerPreset: registerVegetationShaderPreset,
532
+ validateDocument: validateVegetationShaderPresetDocument,
533
+ });
534
+
535
+ /**
536
+ * Marks a material as a semantic vegetation consumer. Technical variants are
537
+ * metadata only; creative treatment is selected by role.
538
+ */
539
+ export function tagVegetationMaterial(material, {
540
+ role,
541
+ roles = null,
542
+ variant = VEGETATION_MATERIAL_VARIANTS.mesh,
543
+ } = {}) {
544
+ if (!material || typeof material !== 'object') throw new Error('tagVegetationMaterial needs a material.');
545
+ const resolvedRoles = [...new Set([
546
+ ...(role === undefined || role === null ? [] : [role]),
547
+ ...(Array.isArray(roles) ? roles : (roles === undefined || roles === null ? [] : [roles])),
548
+ ].map((entry) => String(entry ?? '').trim()).filter(Boolean))];
549
+ if (resolvedRoles.length === 0) throw new Error('Vegetation material role is required.');
550
+ const unknownRole = resolvedRoles.find((entry) => !ALL_ROLES.includes(entry));
551
+ if (unknownRole) throw new Error(`Unknown vegetation material role "${unknownRole}".`);
552
+ const resolvedVariant = String(variant ?? '').trim();
553
+ if (!resolvedVariant) throw new Error('Vegetation material variant is required.');
554
+ material.userData = material.userData ?? {};
555
+ material.userData.toonlabVegetation = {
556
+ // `role` remains the primary-role compatibility surface. `roles` is the
557
+ // canonical capability list for composite materials such as a flower head
558
+ // whose one draw call shades both petals and its center.
559
+ role: resolvedRoles[0],
560
+ roles: resolvedRoles,
561
+ variant: resolvedVariant,
562
+ version: VEGETATION_MATERIAL_CONTRACT_VERSION,
563
+ };
564
+ return material;
565
+ }
566
+
567
+ export function getVegetationMaterialContract(material) {
568
+ const contract = material?.userData?.toonlabVegetation;
569
+ if (!isPlainObject(contract)) return null;
570
+ const roles = [...new Set([
571
+ ...(contract.role === undefined || contract.role === null ? [] : [contract.role]),
572
+ ...(Array.isArray(contract.roles) ? contract.roles : []),
573
+ ].map((entry) => String(entry ?? '').trim()).filter(Boolean))];
574
+ return {
575
+ role: roles[0] ?? '',
576
+ roles,
577
+ variant: String(contract.variant ?? ''),
578
+ version: Number(contract.version),
579
+ };
580
+ }
581
+
582
+ function collectMaterials(target, output, seenObjects = new Set()) {
583
+ if (!target) return;
584
+ if (Array.isArray(target)) {
585
+ for (const entry of target) collectMaterials(entry, output, seenObjects);
586
+ return;
587
+ }
588
+ if (target.isMaterial || (target.uniforms && target.userData)) {
589
+ output.add(target);
590
+ return;
591
+ }
592
+ if (seenObjects.has(target)) return;
593
+ seenObjects.add(target);
594
+ const materials = Array.isArray(target.material) ? target.material : [target.material];
595
+ for (const material of materials) if (material) output.add(material);
596
+ if (typeof target.traverse === 'function') {
597
+ target.traverse((object) => {
598
+ if (object === target) return;
599
+ const nested = Array.isArray(object.material) ? object.material : [object.material];
600
+ for (const material of nested) if (material) output.add(material);
601
+ });
602
+ }
603
+ }
604
+
605
+ function writeStyleUniform(uniformNode, value) {
606
+ if (!uniformNode || !('value' in uniformNode)) return false;
607
+ if (Array.isArray(value)) {
608
+ if (uniformNode.value?.isColor) {
609
+ uniformNode.value.setRGB(value[0], value[1], value[2], THREE.SRGBColorSpace);
610
+ } else if (typeof uniformNode.value?.fromArray === 'function') {
611
+ uniformNode.value.fromArray(value);
612
+ } else {
613
+ uniformNode.value = [...value];
614
+ }
615
+ } else {
616
+ uniformNode.value = value;
617
+ }
618
+ return true;
619
+ }
620
+
621
+ function fieldsForRole(role) {
622
+ const fields = [];
623
+ for (const [groupId, group] of Object.entries(GROUP_DEFINITIONS)) {
624
+ for (const [key, definition] of Object.entries(group.fields)) {
625
+ if (!definition.roles.includes(role)) continue;
626
+ fields.push({
627
+ groupId,
628
+ key,
629
+ path: `${groupId}.${key}`,
630
+ uniform: VEGETATION_SHADER_UNIFORM_BY_FIELD[`${groupId}.${key}`],
631
+ });
632
+ }
633
+ }
634
+ return fields;
635
+ }
636
+
637
+ function fieldsForRoles(roles) {
638
+ const fields = new Map();
639
+ for (const role of roles) {
640
+ for (const descriptor of fieldsForRole(role)) {
641
+ const existing = fields.get(descriptor.path);
642
+ if (existing) existing.roles.push(role);
643
+ else fields.set(descriptor.path, { ...descriptor, roles: [role] });
644
+ }
645
+ }
646
+ return [...fields.values()];
647
+ }
648
+
649
+ /** Returns the profile slice a semantic material role is required to consume. */
650
+ export function resolveVegetationShaderRoleSettings(role, profile = {}) {
651
+ if (!ALL_ROLES.includes(role)) throw new Error(`Unknown vegetation material role "${role}".`);
652
+ const resolved = createVegetationShaderSettings(profile);
653
+ const result = {};
654
+ for (const descriptor of fieldsForRole(role)) {
655
+ result[descriptor.groupId] = result[descriptor.groupId] ?? {};
656
+ result[descriptor.groupId][descriptor.key] = cloneSetting(
657
+ resolved[descriptor.groupId][descriptor.key],
658
+ );
659
+ }
660
+ return result;
661
+ }
662
+
663
+ /**
664
+ * Applies one unified shader profile to tagged materials under Object3Ds or in
665
+ * material arrays. Unsupported fields are reported rather than silently
666
+ * pretending that a material consumed them.
667
+ */
668
+ export function applyVegetationShader(rootOrArray, profile = {}) {
669
+ const resolved = createVegetationShaderSettings(profile);
670
+ const materials = new Set();
671
+ collectMaterials(rootOrArray, materials);
672
+ const report = {
673
+ applied: 0,
674
+ matched: 0,
675
+ roles: {},
676
+ skipped: 0,
677
+ unsupported: [],
678
+ visited: materials.size,
679
+ warnings: [],
680
+ writes: 0,
681
+ };
682
+
683
+ for (const material of materials) {
684
+ const contract = getVegetationMaterialContract(material);
685
+ if (!contract) {
686
+ report.skipped += 1;
687
+ continue;
688
+ }
689
+ if (
690
+ contract.version !== VEGETATION_MATERIAL_CONTRACT_VERSION
691
+ || contract.roles.length === 0
692
+ || contract.roles.some((role) => !ALL_ROLES.includes(role))
693
+ ) {
694
+ report.skipped += 1;
695
+ report.warnings.push(`Material "${material.name || material.uuid || 'unnamed'}" has an invalid vegetation role contract.`);
696
+ continue;
697
+ }
698
+
699
+ report.matched += 1;
700
+ for (const role of contract.roles) {
701
+ const roleReport = report.roles[role] ?? {
702
+ applied: 0,
703
+ matched: 0,
704
+ variants: [],
705
+ writes: 0,
706
+ };
707
+ roleReport.matched += 1;
708
+ if (!roleReport.variants.includes(contract.variant)) roleReport.variants.push(contract.variant);
709
+ report.roles[role] = roleReport;
710
+ }
711
+
712
+ let materialWrites = 0;
713
+ const roleWrites = Object.fromEntries(contract.roles.map((role) => [role, 0]));
714
+ for (const descriptor of fieldsForRoles(contract.roles)) {
715
+ const uniformNode = material.uniforms?.[descriptor.uniform];
716
+ if (!writeStyleUniform(uniformNode, resolved[descriptor.groupId][descriptor.key])) {
717
+ report.unsupported.push({
718
+ field: descriptor.path,
719
+ material: material.name || material.uuid || 'unnamed',
720
+ role: descriptor.roles[0],
721
+ roles: descriptor.roles,
722
+ uniform: descriptor.uniform,
723
+ variant: contract.variant,
724
+ });
725
+ continue;
726
+ }
727
+ materialWrites += 1;
728
+ for (const role of descriptor.roles) {
729
+ roleWrites[role] += 1;
730
+ report.roles[role].writes += 1;
731
+ }
732
+ report.writes += 1;
733
+ }
734
+ if (materialWrites > 0) {
735
+ report.applied += 1;
736
+ for (const role of contract.roles) {
737
+ if (roleWrites[role] > 0) report.roles[role].applied += 1;
738
+ }
739
+ }
740
+ }
741
+
742
+ if (report.matched === 0) report.warnings.push('No tagged vegetation materials matched this profile.');
743
+ else if (report.applied === 0) report.warnings.push('Tagged vegetation materials matched, but none implement the canonical style uniforms.');
744
+ return report;
745
+ }
746
+
747
+ // ---------------------------------------------------------------------------
748
+ // Legacy family APIs. Kept stable for existing applications and documents.
749
+
750
+ /** Generic legacy master registry: schema + presets + settings/doc plumbing. */
751
+ function defineLegacyMaster({ documentType, fields, id, label, description }) {
752
+ const presets = new Map();
753
+ const defaults = Object.freeze(Object.fromEntries(
754
+ Object.entries(fields).map(([key, definition]) => [key, definition.defaultValue]),
755
+ ));
756
+
757
+ function createSettings(options = {}) {
758
+ const source = isPlainObject(options) ? options : {};
759
+ const presetName = typeof source.preset === 'string' ? source.preset : null;
760
+ const base = presetName && presets.has(presetName)
761
+ ? { ...defaults, ...presets.get(presetName).settings }
762
+ : { ...defaults };
763
+ const settings = {};
764
+ for (const [key, definition] of Object.entries(fields)) {
765
+ const raw = source[key] !== undefined ? source[key] : base[key];
766
+ settings[key] = definition.type === 'color'
767
+ ? srgbTriplet(raw, defaults[key])
768
+ : clampNumber(raw, base[key], definition.range ?? {});
769
+ }
770
+ return settings;
771
+ }
772
+
773
+ function registerPreset(name, { label: presetLabel, settings = {} } = {}, { overwrite = false } = {}) {
774
+ const key = String(name ?? '').trim();
775
+ if (!key) throw new Error(`${label} preset name is required.`);
776
+ if (!overwrite && presets.has(key)) throw new Error(`${label} preset "${key}" already exists.`);
777
+ presets.set(key, { label: String(presetLabel ?? key), settings: createSettings(settings) });
778
+ }
779
+
780
+ function getPresetOptions() {
781
+ return [...presets.entries()].map(([value, entry]) => ({ label: entry.label, value }));
782
+ }
783
+
784
+ function validateDocument(input) {
785
+ const source = typeof input === 'string'
786
+ ? (() => { try { return JSON.parse(input); } catch { return null; } })()
787
+ : input;
788
+ if (!isPlainObject(source)) return { errors: [`${label} shader preset must be a JSON object.`], ok: false };
789
+ if (source.type !== documentType) return { errors: [`Document type must be "${documentType}".`], ok: false };
790
+ const docId = String(source.id ?? '').trim();
791
+ const docLabel = String(source.label ?? '').trim();
792
+ if (!docId || !docLabel) return { errors: [`${label} shader preset needs an id and a label.`], ok: false };
793
+ return { ok: true, value: { id: docId, label: docLabel, settings: createSettings(source.settings ?? {}) } };
794
+ }
795
+
796
+ function createDocument(docId, { label: docLabel, settings = {} } = {}) {
797
+ return {
798
+ id: String(docId),
799
+ label: String(docLabel ?? docId),
800
+ schemaVersion: VEGETATION_SHADER_SCHEMA_VERSION,
801
+ settings: createSettings(settings),
802
+ type: documentType,
803
+ };
804
+ }
805
+
806
+ const legacyFieldSchema = Object.freeze(Object.fromEntries(
807
+ Object.entries(fields).map(([key, definition]) => [key, Object.freeze({
808
+ defaultValue: definition.defaultValue,
809
+ description: definition.description,
810
+ group: id,
811
+ id: `${id}.${key}`,
812
+ key,
813
+ label: definition.label,
814
+ range: definition.range ?? null,
815
+ type: definition.type ?? 'number',
816
+ })]),
817
+ ));
818
+
819
+ return Object.freeze({
820
+ createDocument,
821
+ createSettings,
822
+ defaults,
823
+ description,
824
+ documentType,
825
+ fieldSchema: legacyFieldSchema,
826
+ getPresetOptions,
827
+ id,
828
+ label,
829
+ registerPreset,
830
+ validateDocument,
831
+ });
832
+ }
833
+
834
+ /** @deprecated Use VEGETATION_SHADER and one VegetationShaderProfile. */
835
+ export const FOLIAGE_SHADER = defineLegacyMaster({
836
+ description: 'Compatibility foliage treatment adapter.',
837
+ documentType: 'toonlab/foliage-shader',
838
+ id: 'foliage',
839
+ label: 'Foliage',
840
+ fields: {
841
+ backlitStrength: { defaultValue: 0.35, description: 'Translucent leaf glow.', label: 'Backlit Strength', range: { max: 1.5, min: 0, step: 0.01 } },
842
+ sceneShadowStrength: { defaultValue: 0.55, description: 'Renderer shadow response.', label: 'Scene Shadow Strength', range: { max: 1, min: 0, step: 0.01 } },
843
+ cloudShadowStrength: { defaultValue: 0, description: 'Cloud-shadow response.', label: 'Cloud Shadow Response', range: { max: 1, min: 0, step: 0.01 } },
844
+ alphaCutoff: { defaultValue: 0.3, description: 'Leaf sprite cutout threshold.', label: 'Alpha Cutoff', range: { max: 0.9, min: 0.05, step: 0.01 } },
845
+ },
846
+ });
847
+
848
+ const FOLIAGE_UNIFORM_BY_FIELD = Object.freeze({
849
+ alphaCutoff: 'uAlphaCutoff',
850
+ backlitStrength: 'uBacklitStrength',
851
+ cloudShadowStrength: 'uCloudShadowStrength',
852
+ sceneShadowStrength: 'uSceneShadowStrength',
853
+ });
854
+
855
+ /** @deprecated Compatibility adapter; use applyVegetationShader. */
856
+ export function applyFoliageShader(root, settings) {
857
+ const resolved = FOLIAGE_SHADER.createSettings(settings);
858
+ let count = 0;
859
+ root?.traverse?.((object) => {
860
+ const materials = Array.isArray(object.material) ? object.material : [object.material];
861
+ for (const material of materials) {
862
+ const uniforms = material?.uniforms;
863
+ if (!uniforms?.uBacklitStrength || !uniforms?.uSceneShadowStrength) continue;
864
+ for (const [fieldName, uniformName] of Object.entries(FOLIAGE_UNIFORM_BY_FIELD)) {
865
+ if (uniforms[uniformName]) uniforms[uniformName].value = resolved[fieldName];
866
+ }
867
+ count += 1;
868
+ }
869
+ });
870
+ return count;
871
+ }
872
+
873
+ /** @deprecated Use VEGETATION_SHADER and one VegetationShaderProfile. */
874
+ export const GRASS_SHADER = defineLegacyMaster({
875
+ description: 'Compatibility grass treatment adapter.',
876
+ documentType: 'toonlab/grass-shader',
877
+ id: 'grass',
878
+ label: 'Grass',
879
+ fields: {
880
+ backlitStrength: { defaultValue: 0.4, description: 'Translucent blade glow.', label: 'Backlit Strength', range: { max: 1.5, min: 0, step: 0.01 } },
881
+ shadowStrength: { defaultValue: 0.7, description: 'Renderer shadow response.', label: 'Scene Shadow Strength', range: { max: 1, min: 0, step: 0.01 } },
882
+ shadowTint: { defaultValue: Object.freeze([0.36, 0.4, 0.58]), description: 'Treatment shadow tint.', label: 'Shadow Tint', type: 'color' },
883
+ cloudShadowStrength: { defaultValue: 0.35, description: 'Cloud-shadow response.', label: 'Cloud Shadow Response', range: { max: 1, min: 0, step: 0.01 } },
884
+ pushRadius: { defaultValue: 0.6, description: 'Compatibility interaction radius.', label: 'Push Radius', range: { max: 2, min: 0, step: 0.02 } },
885
+ },
886
+ });
887
+
888
+ /** @deprecated Compatibility adapter; use applyVegetationShader. */
889
+ export function applyGrassShader(fieldObject, settings) {
890
+ const resolved = GRASS_SHADER.createSettings(settings);
891
+ fieldObject?.applySettings?.(resolved);
892
+ return resolved;
893
+ }
894
+
895
+ /** @deprecated Use VEGETATION_SHADER and one VegetationShaderProfile. */
896
+ export const FLOWER_SHADER = defineLegacyMaster({
897
+ description: 'Compatibility 3D bloom treatment adapter.',
898
+ documentType: 'toonlab/flower-shader',
899
+ id: 'flower',
900
+ label: 'Flower',
901
+ fields: {
902
+ unlitPetalLift: { defaultValue: 0.35, description: 'Petal-tinted unlit floor.', label: 'Unlit Petal Lift', range: { max: 1, min: 0, step: 0.01 } },
903
+ },
904
+ });
905
+
906
+ /** @deprecated Compatibility adapter; use applyVegetationShader. */
907
+ export function applyFlowerShader(root, settings) {
908
+ const resolved = FLOWER_SHADER.createSettings(settings);
909
+ let count = 0;
910
+ root?.traverse?.((object) => {
911
+ const materials = Array.isArray(object.material) ? object.material : [object.material];
912
+ for (const material of materials) {
913
+ if (material?.uniforms?.uUnlitLift) {
914
+ material.uniforms.uUnlitLift.value = resolved.unlitPetalLift;
915
+ count += 1;
916
+ }
917
+ }
918
+ });
919
+ return count;
920
+ }
921
+
922
+ /** @deprecated Use VEGETATION_SHADER and one VegetationShaderProfile. */
923
+ export const BARK_SHADER = defineLegacyMaster({
924
+ description: 'Compatibility MeshToonMaterial bark treatment adapter.',
925
+ documentType: 'toonlab/bark-shader',
926
+ id: 'bark',
927
+ label: 'Bark',
928
+ fields: {
929
+ bandCount: { defaultValue: 3, description: 'Cel band count.', label: 'Band Count', range: { max: 6, min: 2, step: 1 } },
930
+ shadowFloor: { defaultValue: 0.35, description: 'Brightness of the darkest band.', label: 'Shadow Floor', range: { max: 0.9, min: 0, step: 0.01 } },
931
+ bandSoftness: { defaultValue: 0, description: 'Compatibility toon-ramp filtering.', label: 'Band Softness', range: { max: 1, min: 0, step: 0.05 } },
932
+ },
933
+ });
934
+
935
+ /** Builds the legacy MeshToonMaterial gradient ramp. */
936
+ export function createBarkGradientMap(settings) {
937
+ const resolved = BARK_SHADER.createSettings(settings);
938
+ const steps = Math.max(2, Math.round(resolved.bandCount));
939
+ const width = 64;
940
+ const data = new Uint8Array(width * 4);
941
+ for (let x = 0; x < width; x += 1) {
942
+ const t = x / (width - 1);
943
+ const stepped = Math.floor(t * (steps - 1) + 1e-6) / (steps - 1 || 1);
944
+ const ramp = THREE.MathUtils.lerp(stepped, t, resolved.bandSoftness);
945
+ const value = resolved.shadowFloor + (1 - resolved.shadowFloor) * ramp;
946
+ const level = Math.round(value * 255);
947
+ data.set([level, level, level, 255], x * 4);
948
+ }
949
+ const texture = new THREE.DataTexture(data, width, 1);
950
+ texture.colorSpace = THREE.NoColorSpace;
951
+ texture.minFilter = THREE.NearestFilter;
952
+ texture.magFilter = THREE.NearestFilter;
953
+ texture.needsUpdate = true;
954
+ return texture;
955
+ }
956
+
957
+ /** @deprecated Compatibility adapter; use applyVegetationShader. */
958
+ export function applyBarkShader(root, settings) {
959
+ const gradientMap = createBarkGradientMap(settings);
960
+ let count = 0;
961
+ root?.traverse?.((object) => {
962
+ const materials = Array.isArray(object.material) ? object.material : [object.material];
963
+ for (const material of materials) {
964
+ if (!material?.isMeshToonMaterial) continue;
965
+ material.gradientMap?.dispose?.();
966
+ material.gradientMap = gradientMap;
967
+ material.needsUpdate = true;
968
+ count += 1;
969
+ }
970
+ });
971
+ return count;
972
+ }
973
+
974
+ /** @deprecated The canonical registry is VEGETATION_SHADER. */
975
+ export const VEGETATION_SHADERS = Object.freeze([
976
+ FOLIAGE_SHADER,
977
+ GRASS_SHADER,
978
+ FLOWER_SHADER,
979
+ BARK_SHADER,
980
+ ]);
981
+
982
+ for (const master of VEGETATION_SHADERS) master.registerPreset('default', { label: 'Default' });
983
+ FOLIAGE_SHADER.registerPreset('call_me_sensei', {
984
+ label: 'Call Me Sensei',
985
+ settings: { backlitStrength: 0.45, cloudShadowStrength: 0.4, sceneShadowStrength: 0.6 },
986
+ });
987
+ GRASS_SHADER.registerPreset('call_me_sensei', {
988
+ label: 'Call Me Sensei',
989
+ settings: { backlitStrength: 0.5, cloudShadowStrength: 0.45, shadowTint: [0.34, 0.36, 0.6] },
990
+ });
991
+ FLOWER_SHADER.registerPreset('call_me_sensei', {
992
+ label: 'Call Me Sensei',
993
+ settings: { unlitPetalLift: 0.4 },
994
+ });
995
+ BARK_SHADER.registerPreset('call_me_sensei', {
996
+ label: 'Call Me Sensei',
997
+ settings: { bandCount: 3, shadowFloor: 0.42 },
998
+ });
999
+
1000
+ export const createFoliageShaderSettings = FOLIAGE_SHADER.createSettings;
1001
+ export const createGrassShaderSettings = GRASS_SHADER.createSettings;
1002
+ export const createFlowerShaderSettings = FLOWER_SHADER.createSettings;
1003
+ export const createBarkShaderSettings = BARK_SHADER.createSettings;
1004
+
1005
+ const LEGACY_MASTER_BY_KEY = Object.freeze({
1006
+ bark: BARK_SHADER,
1007
+ flower: FLOWER_SHADER,
1008
+ foliage: FOLIAGE_SHADER,
1009
+ grass: GRASS_SHADER,
1010
+ });
1011
+
1012
+ function parseLegacyDocument(value, key, errors) {
1013
+ let source = value;
1014
+ if (typeof source === 'string') {
1015
+ try {
1016
+ source = JSON.parse(source);
1017
+ } catch (error) {
1018
+ errors.push(`Invalid legacy ${key} shader JSON: ${error.message}`);
1019
+ return null;
1020
+ }
1021
+ }
1022
+ if (!isPlainObject(source)) {
1023
+ errors.push(`Legacy ${key} shader must be an object or JSON string.`);
1024
+ return null;
1025
+ }
1026
+ const master = LEGACY_MASTER_BY_KEY[key];
1027
+ if (source.type && source.type !== master.documentType) {
1028
+ errors.push(`Legacy ${key} shader type must be "${master.documentType}".`);
1029
+ return null;
1030
+ }
1031
+ return {
1032
+ id: String(source.id ?? ''),
1033
+ label: String(source.label ?? ''),
1034
+ settings: master.createSettings(source.settings ?? source),
1035
+ };
1036
+ }
1037
+
1038
+ /**
1039
+ * Combines one or all four legacy family documents into one canonical preset.
1040
+ * Material alpha cutoff and interaction radius intentionally remain outside
1041
+ * the canonical shader profile and produce migration warnings.
1042
+ */
1043
+ export function migrateLegacyVegetationShaderDocuments(input, options = {}) {
1044
+ let source = input;
1045
+ if (typeof source === 'string') {
1046
+ try {
1047
+ source = JSON.parse(source);
1048
+ } catch (error) {
1049
+ return { errors: [`Invalid legacy vegetation shader JSON: ${error.message}`], ok: false, value: null, warnings: [] };
1050
+ }
1051
+ }
1052
+ if (!isPlainObject(source)) {
1053
+ return { errors: ['Legacy vegetation shader input must be an object.'], ok: false, value: null, warnings: [] };
1054
+ }
1055
+ if (source.type === VEGETATION_SHADER_DOCUMENT_TYPE) return validateVegetationShaderPresetDocument(source);
1056
+
1057
+ const legacyKey = Object.entries(LEGACY_MASTER_BY_KEY)
1058
+ .find(([, master]) => source.type === master.documentType)?.[0];
1059
+ const documents = legacyKey ? { [legacyKey]: source } : source;
1060
+ const errors = [];
1061
+ const warnings = [];
1062
+ const settings = {};
1063
+ let firstDocument = null;
1064
+
1065
+ for (const key of Object.keys(LEGACY_MASTER_BY_KEY)) {
1066
+ if (documents[key] === undefined) continue;
1067
+ const document = parseLegacyDocument(documents[key], key, errors);
1068
+ if (!document) continue;
1069
+ firstDocument = firstDocument ?? document;
1070
+ const legacy = document.settings;
1071
+ if (key === 'foliage') {
1072
+ settings.foliage = {
1073
+ backlitStrength: legacy.backlitStrength,
1074
+ cloudShadowResponse: legacy.cloudShadowStrength,
1075
+ sceneShadowResponse: legacy.sceneShadowStrength,
1076
+ };
1077
+ warnings.push('Legacy foliage alphaCutoff remains a material/texture setting and was not migrated into the shader profile.');
1078
+ } else if (key === 'grass') {
1079
+ settings.grass = {
1080
+ backlitStrength: legacy.backlitStrength,
1081
+ cloudShadowResponse: legacy.cloudShadowStrength,
1082
+ sceneShadowResponse: legacy.shadowStrength,
1083
+ };
1084
+ settings.lighting = { shadowTint: legacy.shadowTint };
1085
+ warnings.push('Legacy grass pushRadius remains an interaction-field setting and was not migrated into the shader profile.');
1086
+ } else if (key === 'flower') {
1087
+ settings.flower = { unlitPetalLift: legacy.unlitPetalLift };
1088
+ } else if (key === 'bark') {
1089
+ settings.bark = {
1090
+ bandCount: legacy.bandCount,
1091
+ bandSoftness: legacy.bandSoftness,
1092
+ shadowFloor: legacy.shadowFloor,
1093
+ };
1094
+ }
1095
+ }
1096
+
1097
+ if (!firstDocument && errors.length === 0) {
1098
+ errors.push('No legacy foliage, grass, flower, or bark shader document was provided.');
1099
+ }
1100
+ if (errors.length > 0) return { errors, ok: false, value: null, warnings };
1101
+
1102
+ const id = String(options.id ?? firstDocument.id ?? 'migrated-vegetation').trim();
1103
+ const label = String(options.label ?? firstDocument.label ?? id).trim();
1104
+ const value = createVegetationShaderPresetDocument(id, {
1105
+ description: options.description ?? 'Migrated from legacy vegetation family shader presets.',
1106
+ label,
1107
+ settings,
1108
+ });
1109
+ return { errors: [], ok: true, value, warnings };
1110
+ }