@call-me-sensei/toonlab 0.1.1

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 (146) hide show
  1. package/ATTRIBUTION.md +40 -0
  2. package/LICENSE +21 -0
  3. package/README.md +125 -0
  4. package/package.json +97 -0
  5. package/src/character/characterRig.js +353 -0
  6. package/src/character/freestyleSwimClip.js +541 -0
  7. package/src/character/index.js +4 -0
  8. package/src/character/modelLoader.js +433 -0
  9. package/src/core/materialRoles.js +438 -0
  10. package/src/core/presetDocuments.js +110 -0
  11. package/src/core/shaderBackend.js +24 -0
  12. package/src/debrisgen/debrisFields.js +544 -0
  13. package/src/debrisgen/debrisGenerator.js +1929 -0
  14. package/src/debrisgen/debrisPalettes.js +71 -0
  15. package/src/debrisgen/debrisPhysics.js +198 -0
  16. package/src/debrisgen/debrisPresets.js +155 -0
  17. package/src/debrisgen/debrisSettings.js +333 -0
  18. package/src/debrisgen/debrisTextures.js +380 -0
  19. package/src/debrisgen/index.js +5 -0
  20. package/src/debug/fieldValues.js +95 -0
  21. package/src/debug/index.js +7 -0
  22. package/src/debug/settingsPanel.js +170 -0
  23. package/src/environment/environmentAmbientProbe.js +160 -0
  24. package/src/environment/environmentMaterialAdapter.js +336 -0
  25. package/src/environment/environmentMaterialClassifier.js +178 -0
  26. package/src/environment/environmentPlanarReflection.js +201 -0
  27. package/src/environment/environmentPresets.js +443 -0
  28. package/src/environment/environmentRigs.js +684 -0
  29. package/src/environment/environmentSettings.js +504 -0
  30. package/src/environment/environmentShaderMaterials.js +318 -0
  31. package/src/environment/environmentSunShadowPass.js +268 -0
  32. package/src/environment/environmentTextureResolver.js +161 -0
  33. package/src/environment/environmentTimeOfDay.js +167 -0
  34. package/src/environment/environmentVertexAo.js +188 -0
  35. package/src/environment/index.js +8 -0
  36. package/src/environment/scanAssetStylize.js +103 -0
  37. package/src/index.js +21 -0
  38. package/src/loaders/index.js +1 -0
  39. package/src/post/index.js +2 -0
  40. package/src/post/postProcessing.js +1087 -0
  41. package/src/rockgen/export/glbExport.js +177 -0
  42. package/src/rockgen/heightfield/heightfieldErosion.js +7 -0
  43. package/src/rockgen/heightfield/heightfieldPatch.js +183 -0
  44. package/src/rockgen/heightfield/stylizedErosionSim.js +356 -0
  45. package/src/rockgen/index.js +14 -0
  46. package/src/rockgen/mesh/meshAttributes.js +374 -0
  47. package/src/rockgen/mesh/meshDocument.js +158 -0
  48. package/src/rockgen/mesh/surfaceNets.js +329 -0
  49. package/src/rockgen/noise/cellularNoise3.js +92 -0
  50. package/src/rockgen/noise/prng.js +62 -0
  51. package/src/rockgen/noise/simplexNoise3.js +89 -0
  52. package/src/rockgen/noise/valueNoise3.js +87 -0
  53. package/src/rockgen/rockDocument.js +255 -0
  54. package/src/rockgen/rockHelpers.js +8 -0
  55. package/src/rockgen/rockgenPresets.js +559 -0
  56. package/src/rockgen/rockgenSettings.js +756 -0
  57. package/src/rockgen/sdf/fieldCompiler.js +544 -0
  58. package/src/rockgen/sdf/sculptEdits.js +66 -0
  59. package/src/rockgen/sdf/sdfModifiers.js +45 -0
  60. package/src/rockgen/sdf/sdfOps.js +37 -0
  61. package/src/rockgen/sdf/sdfPrimitives.js +88 -0
  62. package/src/shaders-tsl/anime.js +963 -0
  63. package/src/shaders-tsl/chunks/character-color.js +64 -0
  64. package/src/shaders-tsl/chunks/character-highlights.js +174 -0
  65. package/src/shaders-tsl/chunks/character-lighting.js +309 -0
  66. package/src/shaders-tsl/chunks/character-material-maps.js +146 -0
  67. package/src/shaders-tsl/chunks/character-roles.js +52 -0
  68. package/src/shaders-tsl/chunks/character-scene-lights.js +270 -0
  69. package/src/shaders-tsl/chunks/character-shadow-color.js +61 -0
  70. package/src/shaders-tsl/chunks/character-skinning.js +140 -0
  71. package/src/shaders-tsl/chunks/environment-color.js +47 -0
  72. package/src/shaders-tsl/chunks/environment-debug.js +79 -0
  73. package/src/shaders-tsl/chunks/environment-lighting.js +260 -0
  74. package/src/shaders-tsl/chunks/environment-sun-shadow.js +87 -0
  75. package/src/shaders-tsl/chunks/foliage-fog.js +60 -0
  76. package/src/shaders-tsl/chunks/pass-depth-color.js +89 -0
  77. package/src/shaders-tsl/chunks/stylized-cloud-shadow.js +70 -0
  78. package/src/shaders-tsl/chunks/water-color.js +190 -0
  79. package/src/shaders-tsl/chunks/water-common.js +124 -0
  80. package/src/shaders-tsl/chunks/water-foam.js +93 -0
  81. package/src/shaders-tsl/chunks/water-lighting.js +116 -0
  82. package/src/shaders-tsl/chunks/water-ripple.js +64 -0
  83. package/src/shaders-tsl/chunks/water-waves.js +100 -0
  84. package/src/shaders-tsl/environment-ao-overlay.js +60 -0
  85. package/src/shaders-tsl/environment.js +680 -0
  86. package/src/shaders-tsl/flower.js +267 -0
  87. package/src/shaders-tsl/grass.js +193 -0
  88. package/src/shaders-tsl/post-composite.js +461 -0
  89. package/src/shaders-tsl/sky.js +175 -0
  90. package/src/shaders-tsl/tree-leaf.js +264 -0
  91. package/src/shaders-tsl/water-breaker.js +423 -0
  92. package/src/shaders-tsl/water-kelp.js +171 -0
  93. package/src/shaders-tsl/water-rain.js +107 -0
  94. package/src/shaders-tsl/water-simulation.js +140 -0
  95. package/src/shaders-tsl/water-splash.js +298 -0
  96. package/src/shaders-tsl/water.js +509 -0
  97. package/src/sky/index.js +2 -0
  98. package/src/sky/stylizedSky.js +341 -0
  99. package/src/toon/characterRenderPasses.js +646 -0
  100. package/src/toon/index.js +4 -0
  101. package/src/toon/settings/alphaSettings.js +170 -0
  102. package/src/toon/settings/averageShadowSettings.js +181 -0
  103. package/src/toon/settings/baseTextureSettings.js +133 -0
  104. package/src/toon/settings/celShadeSettings.js +96 -0
  105. package/src/toon/settings/contactShadowSettings.js +114 -0
  106. package/src/toon/settings/eyeHighlightSettings.js +187 -0
  107. package/src/toon/settings/faceLightingSettings.js +135 -0
  108. package/src/toon/settings/furSettings.js +112 -0
  109. package/src/toon/settings/glitterSettings.js +102 -0
  110. package/src/toon/settings/hairHighlightSettings.js +325 -0
  111. package/src/toon/settings/indirectLightSettings.js +244 -0
  112. package/src/toon/settings/localLightSettings.js +176 -0
  113. package/src/toon/settings/materialMapSettings.js +299 -0
  114. package/src/toon/settings/outlineSettings.js +263 -0
  115. package/src/toon/settings/perspectiveRemovalSettings.js +60 -0
  116. package/src/toon/settings/rimLightSettings.js +268 -0
  117. package/src/toon/settings/sceneShadowSettings.js +147 -0
  118. package/src/toon/settings/selfShadowSettings.js +221 -0
  119. package/src/toon/settings/shadowColorSettings.js +238 -0
  120. package/src/toon/settings/skinToneSettings.js +150 -0
  121. package/src/toon/settings/specularSettings.js +314 -0
  122. package/src/toon/settings/stickerSettings.js +102 -0
  123. package/src/toon/toonMaterialAdapter.js +2235 -0
  124. package/src/toon/toonSettings.js +860 -0
  125. package/src/vegetation/flowerSpecies.js +260 -0
  126. package/src/vegetation/index.js +10 -0
  127. package/src/vegetation/stylizedBush.js +136 -0
  128. package/src/vegetation/stylizedFlower.js +213 -0
  129. package/src/vegetation/stylizedFlowers.js +297 -0
  130. package/src/vegetation/stylizedGrass.js +491 -0
  131. package/src/vegetation/stylizedTree.js +2449 -0
  132. package/src/vegetation/stylizedTreeFoliage.js +691 -0
  133. package/src/vegetation/treeExport.js +300 -0
  134. package/src/vegetation/treeRecipe.js +647 -0
  135. package/src/water/index.js +2 -0
  136. package/src/water/water.js +20 -0
  137. package/src/water/waterBreakerSystem.js +591 -0
  138. package/src/water/waterInteraction.js +193 -0
  139. package/src/water/waterMaterial.js +263 -0
  140. package/src/water/waterRain.js +136 -0
  141. package/src/water/waterRippleSimulation.js +227 -0
  142. package/src/water/waterScenePasses.js +415 -0
  143. package/src/water/waterSettings.js +1138 -0
  144. package/src/water/waterSplashSystem.js +251 -0
  145. package/src/water/waterSurface.js +475 -0
  146. package/src/water/waterVegetation.js +83 -0
@@ -0,0 +1,1138 @@
1
+ import * as THREE from 'three';
2
+ import {
3
+ createSettingsPresetDocument,
4
+ parsePresetDocument,
5
+ serializePresetDocument,
6
+ validateSettingsPresetDocument,
7
+ } from '../core/presetDocuments.js';
8
+
9
+ // Public settings for the stylized water system. Mirrors the toon/environment
10
+ // settings conventions: normalized flat settings objects, named presets, and a
11
+ // field schema that HUDs or editors can generate controls from.
12
+
13
+ export const WATER_PRESET_NAMES = Object.freeze([
14
+ 'mirror',
15
+ 'calm',
16
+ 'lake',
17
+ 'river',
18
+ 'coast',
19
+ 'ocean',
20
+ 'storm',
21
+ ]);
22
+
23
+ export const WATER_QUALITY_LEVELS = Object.freeze(['low', 'medium', 'high']);
24
+
25
+ // Named body-color palettes, independent of the wave presets: pick a mode
26
+ // (lake/ocean/...) for motion and a tone for color. A non-'classic' tone
27
+ // forces its palette over preset, environment, and per-key color overrides,
28
+ // so switching tones always takes effect; 'classic' returns full control to
29
+ // the preset/explicit colors.
30
+ export const WATER_COLOR_TONES = Object.freeze({
31
+ // Preset/default palette untouched.
32
+ classic: Object.freeze({}),
33
+ // Matched against modern anime open-world reference shots (coastal scenes):
34
+ // bright milky pastel water. Pale luminous turquoise shallows so clear the
35
+ // bed reads through for meters, azure — never navy — mids and deeps, a high
36
+ // sky-tint floor, strong-but-soft reflections (clouds smear milky white),
37
+ // and pronounced caustic dappling on the bed.
38
+ anime: Object.freeze({
39
+ // Swim-depth reference water is a saturated green-leaning turquoise, not
40
+ // pale azure; the beach reference gets its blue from grazing-angle sky
41
+ // reflection, not from the body color.
42
+ shallowColor: [0.52, 0.94, 0.85],
43
+ midColor: [0.19, 0.72, 0.74],
44
+ deepColor: [0.07, 0.47, 0.55],
45
+ depthFadeDistance: 2.4,
46
+ deepFadeDistance: 5.5,
47
+ fresnelColor: [0.68, 0.93, 1.0],
48
+ // Low sky-tint floor: looking straight down while swimming, the reference shows
49
+ // nearly pure body color — sky/cloud reflections belong to grazing angles.
50
+ fresnelBias: 0.07,
51
+ reflectionStrength: 0.62,
52
+ reflectionSoftness: 0.7,
53
+ // Subtle: the long clarity distances above widen the caustic depth window
54
+ // considerably, and the reference dappling is a soft near-shore accent, not a
55
+ // surface web.
56
+ causticsStrength: 0.3,
57
+ // Glassier surface: the reference water carries long smooth undulations,
58
+ // not constant high-frequency wobble.
59
+ detailNormalStrength: 0.12,
60
+ }),
61
+ // Anime-style lake teal: green pulled above blue in the mid band, long
62
+ // glassy falloff before the deeps.
63
+ teal: Object.freeze({
64
+ shallowColor: [0.36, 0.87, 0.78],
65
+ midColor: [0.1, 0.63, 0.62],
66
+ deepColor: [0.03, 0.35, 0.44],
67
+ depthFadeDistance: 1.4,
68
+ deepFadeDistance: 3.0,
69
+ fresnelColor: [0.62, 0.92, 0.9],
70
+ }),
71
+ // Caribbean shelf: vivid turquoise shallows over white sand, emerald mid,
72
+ // deep water still saturated instead of navy.
73
+ caribbean: Object.freeze({
74
+ shallowColor: [0.38, 0.96, 0.85],
75
+ midColor: [0.04, 0.73, 0.71],
76
+ deepColor: [0.01, 0.4, 0.52],
77
+ depthFadeDistance: 1.9,
78
+ deepFadeDistance: 4.2,
79
+ fresnelColor: [0.6, 0.95, 0.95],
80
+ }),
81
+ // Emerald lagoon: green-dominant all the way down.
82
+ lagoon: Object.freeze({
83
+ shallowColor: [0.47, 0.93, 0.71],
84
+ midColor: [0.13, 0.68, 0.5],
85
+ deepColor: [0.03, 0.35, 0.32],
86
+ depthFadeDistance: 1.5,
87
+ deepFadeDistance: 3.2,
88
+ fresnelColor: [0.66, 0.93, 0.85],
89
+ }),
90
+ // Open-ocean indigo: short falloff into a saturated deep blue.
91
+ deepOcean: Object.freeze({
92
+ shallowColor: [0.3, 0.74, 0.86],
93
+ midColor: [0.08, 0.4, 0.7],
94
+ deepColor: [0.015, 0.15, 0.4],
95
+ depthFadeDistance: 0.9,
96
+ deepFadeDistance: 2.0,
97
+ fresnelColor: [0.6, 0.82, 1.0],
98
+ }),
99
+ });
100
+
101
+ export const WATER_COLOR_TONE_NAMES = Object.freeze(Object.keys(WATER_COLOR_TONES));
102
+
103
+ export function resolveWaterColorToneName(requested) {
104
+ const name = String(requested ?? '').trim();
105
+ return WATER_COLOR_TONE_NAMES.includes(name) ? name : 'classic';
106
+ }
107
+
108
+ export const WATER_DEBUG_MODES = Object.freeze({
109
+ off: 0,
110
+ depth: 1,
111
+ foam: 2,
112
+ normal: 3,
113
+ ripple: 4,
114
+ reflection: 5,
115
+ caustics: 6,
116
+ specular: 7,
117
+ fresnel: 8,
118
+ crest: 9,
119
+ });
120
+
121
+ // The number of Gerstner components evaluated by the shader and the CPU
122
+ // mirror. Fixed so buoyancy queries always match the rendered surface.
123
+ export const WATER_GERSTNER_WAVE_COUNT = 8;
124
+
125
+ export const DEFAULT_WATER_SETTINGS = Object.freeze({
126
+ preset: 'lake',
127
+ colorTone: 'classic',
128
+
129
+ // Master dial: 0 = glassy mirror, 1 = storm swell. Scales wave amplitude,
130
+ // steepness, and phase speed before the per-wave spectrum is built.
131
+ waveIntensity: 0.25,
132
+ waterLevel: 0.36,
133
+
134
+ // Gerstner wave spectrum.
135
+ waveAmplitude: 0.3,
136
+ waveLength: 7.5,
137
+ waveSteepness: 0.75,
138
+ waveSpeed: 1.0,
139
+ waveDirection: [1.0, 0.35],
140
+ waveDirectionSpread: 0.65,
141
+ // Water column depth (m) at which waves reach full amplitude. Shallower
142
+ // water shrinks the swell so crests never poke through a shore or island.
143
+ // Only active when the surface is given a bed height sampler.
144
+ shoalingDepth: 1.4,
145
+ // Fraction of wave height that survives into the shallows as rolling surf.
146
+ // Waves only die completely in the last few swash centimeters at the
147
+ // waterline; 0 restores the old flatten-to-nothing behavior.
148
+ shorelineWaves: 0.35,
149
+ // Swash run-up: how far incoming waves push a thin foamy film up the beach
150
+ // face above the rest waterline. Scales with total wave energy, so storms
151
+ // reach visibly further up the sand. 0 pins the waterline in place.
152
+ shorelineRunup: 0.6,
153
+
154
+ // Master switch for the breaker system: false removes the mesh and skips
155
+ // all rebuild work entirely (handy for A/B perf comparisons).
156
+ breakerEnabled: true,
157
+ // Dedicated plunging-breaker geometry along the break line (needs a bed
158
+ // height sampler). 0 disables the system entirely; above 0 curl shells
159
+ // spawn where the swell reaches its collapse depth and barrel with each
160
+ // arriving wave. Purely visual — height queries stay on the heightfield.
161
+ breakerAmount: 0,
162
+ // How far the lip curls over: 0 spills down the face, 1 wraps a tunnel.
163
+ breakerCurl: 0.8,
164
+ // Shell height multiplier over the physical breaking height (0.72 x depth).
165
+ breakerScale: 1.0,
166
+ // Along-crest peel rate; higher makes the barrel travel faster down the
167
+ // line (0 breaks the whole line at once wherever the swell hits square).
168
+ breakerPeel: 1.0,
169
+
170
+ // Wave sets: real swell arrives in groups — the big waves come minutes
171
+ // apart with lulls between, they don't repeat every period. The primary
172
+ // swell is split into a beat pair whose interference modulates its
173
+ // amplitude with this period (seconds between set peaks at a fixed point).
174
+ waveSetPeriod: 60,
175
+ // Modulation depth: 0 = constant swell every period, 1 = the swell dies
176
+ // completely between sets.
177
+ waveSetStrength: 0.5,
178
+
179
+ // Procedural detail ripple normals.
180
+ detailNormalStrength: 0.32,
181
+ detailScale: 1.15,
182
+ flowDirection: [0.72, -0.18],
183
+ flowSpeed: 0.3,
184
+
185
+ // Water body color and refraction. The palette is deliberately high-key
186
+ // saturated cyan (anime style): deep water reads as milky blue, not navy.
187
+ shallowColor: [0.42, 0.85, 0.88],
188
+ midColor: [0.2, 0.62, 0.8],
189
+ deepColor: [0.1, 0.38, 0.6],
190
+ depthFadeDistance: 1.0,
191
+ deepFadeDistance: 2.2,
192
+ opacity: 0.8,
193
+ refractionStrength: 0.35,
194
+ causticsStrength: 0.55,
195
+ causticsScale: 0.8,
196
+ causticsSpeed: 0.6,
197
+
198
+ // Foam.
199
+ foamColor: [0.94, 1.0, 0.99],
200
+ foamAmount: 1.0,
201
+ foamContactDistance: 0.4,
202
+ foamLineSpacing: 0.55,
203
+ foamNoiseScale: 0.6,
204
+ whitecapAmount: 0.05,
205
+ rippleFoamStrength: 0.8,
206
+
207
+ // Lighting.
208
+ sunDirection: [0.35, 0.8, 0.45],
209
+ sunColor: [1.0, 0.96, 0.86],
210
+ specularStrength: 0.8,
211
+ specularShininess: 150,
212
+ specularStretch: 0.35,
213
+ sparkleStrength: 0.5,
214
+ sparkleScale: 1.5,
215
+ sparkleSpeed: 1.0,
216
+ sunGlowStrength: 0.85,
217
+ // How strongly scene shadows (rocks, trees, the character) darken the
218
+ // surface and mute its sun glints/caustics.
219
+ sceneShadowStrength: 0.6,
220
+ fresnelStrength: 0.9,
221
+ fresnelPower: 4.5,
222
+ // Sky-tint floor: keeps the surface carrying reflected sky color even at
223
+ // steep viewing angles, which is what makes anime water read "blue".
224
+ fresnelBias: 0.16,
225
+ fresnelColor: [0.68, 0.9, 1.0],
226
+ skyZenithColor: [0.5, 0.74, 0.98],
227
+ skyHorizonColor: [0.86, 0.95, 1.0],
228
+ reflectionStrength: 0.62,
229
+ reflectionDistortion: 0.04,
230
+ // Blends the sharp planar reflection toward the soft procedural sky for the
231
+ // milky painterly reflections of stylized water (0 = mirror, 1 = fully soft).
232
+ reflectionSoftness: 0.55,
233
+
234
+ // Interactive ripple simulation.
235
+ rippleStrength: 1.0,
236
+ rippleDamping: 0.985,
237
+ ripplePropagation: 11.0,
238
+ rippleHeightScale: 1.0,
239
+ rippleFoamDecay: 0.94,
240
+ rippleFoamGain: 2.4,
241
+
242
+ // Splash particles (droplets, spray crown, expanding foam rings).
243
+ splashStrength: 1.0,
244
+ splashScale: 1.0,
245
+ splashDropletCount: 26,
246
+ splashRingCount: 2,
247
+ splashColor: [0.97, 1.0, 1.0],
248
+ splashShadeColor: [0.62, 0.86, 0.95],
249
+
250
+ quality: 'high',
251
+ });
252
+
253
+ const WATER_PRESETS = Object.freeze({
254
+ mirror: Object.freeze({
255
+ waveIntensity: 0.03,
256
+ waveAmplitude: 0.12,
257
+ waveSteepness: 0.4,
258
+ detailNormalStrength: 0.1,
259
+ flowSpeed: 0.12,
260
+ whitecapAmount: 0,
261
+ foamAmount: 0.7,
262
+ sparkleStrength: 0.35,
263
+ reflectionStrength: 0.85,
264
+ reflectionDistortion: 0.015,
265
+ reflectionSoftness: 0.3,
266
+ }),
267
+ calm: Object.freeze({
268
+ waveIntensity: 0.12,
269
+ waveAmplitude: 0.2,
270
+ waveSteepness: 0.55,
271
+ detailNormalStrength: 0.18,
272
+ flowSpeed: 0.2,
273
+ whitecapAmount: 0,
274
+ reflectionStrength: 0.7,
275
+ reflectionDistortion: 0.03,
276
+ reflectionSoftness: 0.6,
277
+ sparkleStrength: 0.45,
278
+ }),
279
+ lake: Object.freeze({}),
280
+ river: Object.freeze({
281
+ waveIntensity: 0.3,
282
+ waveLength: 4.2,
283
+ waveDirection: [1.0, -0.15],
284
+ waveDirectionSpread: 0.12,
285
+ waveSpeed: 1.25,
286
+ flowDirection: [1.0, -0.15],
287
+ flowSpeed: 1.15,
288
+ detailScale: 1.6,
289
+ foamAmount: 1.25,
290
+ foamLineSpacing: 0.4,
291
+ causticsStrength: 0.7,
292
+ sparkleStrength: 0.4,
293
+ whitecapAmount: 0.08,
294
+ }),
295
+ coast: Object.freeze({
296
+ waveIntensity: 0.5,
297
+ waveAmplitude: 0.42,
298
+ waveLength: 9.0,
299
+ // Onshore swell: surf only exists when waves travel toward the shallows.
300
+ waveDirection: [0.15, -1.0],
301
+ waveDirectionSpread: 0.5,
302
+ flowDirection: [0.15, -1.0],
303
+ flowSpeed: 0.45,
304
+ whitecapAmount: 0.22,
305
+ foamContactDistance: 0.6,
306
+ foamAmount: 1.3,
307
+ rippleFoamStrength: 0.9,
308
+ breakerAmount: 0.55,
309
+ waveSetPeriod: 50,
310
+ waveSetStrength: 0.6,
311
+ }),
312
+ ocean: Object.freeze({
313
+ waveIntensity: 0.7,
314
+ waveAmplitude: 0.55,
315
+ waveLength: 13.0,
316
+ waveSteepness: 0.95,
317
+ // Onshore swell with moderate spread so sets march at the beach and the
318
+ // break line actually breaks (surf needs shoreward wave travel).
319
+ waveDirection: [0.3, -1.0],
320
+ waveDirectionSpread: 0.55,
321
+ flowDirection: [0.3, -1.0],
322
+ waterLevel: 0.42,
323
+ shallowColor: [0.26, 0.7, 0.84],
324
+ midColor: [0.14, 0.5, 0.72],
325
+ deepColor: [0.06, 0.3, 0.52],
326
+ depthFadeDistance: 1.3,
327
+ deepFadeDistance: 3.0,
328
+ reflectionSoftness: 0.35,
329
+ whitecapAmount: 0.4,
330
+ foamAmount: 1.2,
331
+ specularStretch: 0.5,
332
+ sparkleStrength: 0.65,
333
+ reflectionStrength: 0.6,
334
+ flowSpeed: 0.5,
335
+ splashStrength: 1.2,
336
+ breakerAmount: 0.85,
337
+ breakerCurl: 0.85,
338
+ waveSetPeriod: 75,
339
+ waveSetStrength: 0.7,
340
+ }),
341
+ storm: Object.freeze({
342
+ waveIntensity: 1.0,
343
+ waveAmplitude: 0.5,
344
+ waveLength: 15.0,
345
+ waveSteepness: 1.05,
346
+ waveSpeed: 1.25,
347
+ waveDirection: [0.45, -1.0],
348
+ waveDirectionSpread: 0.8,
349
+ waterLevel: 0.42,
350
+ detailNormalStrength: 0.55,
351
+ flowSpeed: 0.55,
352
+ shallowColor: [0.2, 0.66, 0.78],
353
+ midColor: [0.1, 0.4, 0.56],
354
+ deepColor: [0.05, 0.22, 0.36],
355
+ depthFadeDistance: 1.4,
356
+ deepFadeDistance: 3.2,
357
+ fresnelBias: 0.1,
358
+ reflectionSoftness: 0.3,
359
+ causticsStrength: 0.08,
360
+ foamAmount: 1.25,
361
+ whitecapAmount: 0.55,
362
+ rippleFoamStrength: 1.0,
363
+ sunColor: [0.9, 0.88, 0.85],
364
+ sparkleStrength: 0.3,
365
+ specularStrength: 0.6,
366
+ fresnelStrength: 1.0,
367
+ skyZenithColor: [0.34, 0.46, 0.62],
368
+ skyHorizonColor: [0.62, 0.7, 0.78],
369
+ reflectionStrength: 0.42,
370
+ reflectionDistortion: 0.08,
371
+ sunGlowStrength: 0.4,
372
+ splashStrength: 1.45,
373
+ splashShadeColor: [0.55, 0.74, 0.82],
374
+ // Storm surf closes out: tall messy walls, less clean tunnel. The monster
375
+ // sets arrive minutes apart.
376
+ breakerAmount: 1.0,
377
+ breakerCurl: 0.6,
378
+ breakerPeel: 1.6,
379
+ waveSetPeriod: 150,
380
+ waveSetStrength: 0.85,
381
+ }),
382
+ });
383
+
384
+ const WATER_PRESET_ALIASES = Object.freeze({
385
+ '': 'lake',
386
+ default: 'lake',
387
+ pond: 'calm',
388
+ sea: 'ocean',
389
+ stormy: 'storm',
390
+ });
391
+
392
+ // Label + one-line description for each built-in preset, surfaced through
393
+ // getWaterPresetOptions() so HUDs can build preset pickers.
394
+ const BUILT_IN_WATER_PRESET_METADATA = Object.freeze({
395
+ mirror: Object.freeze({ label: 'Mirror', description: 'Glassy still water: near-perfect reflections and almost no swell.' }),
396
+ calm: Object.freeze({ label: 'Calm', description: 'Gentle pond ripples with soft reflections and no whitecaps.' }),
397
+ lake: Object.freeze({ label: 'Lake', description: 'Default balanced lake water: light swell and the anime-blue palette.' }),
398
+ river: Object.freeze({ label: 'River', description: 'Fast aligned current with fine chop, streaming foam lines, and strong caustics.' }),
399
+ coast: Object.freeze({ label: 'Coast', description: 'Onshore swell with rolling surf, wide contact foam, and moderate breakers.' }),
400
+ ocean: Object.freeze({ label: 'Ocean', description: 'Big open-water swell arriving in sets, with whitecaps and curling breakers.' }),
401
+ storm: Object.freeze({ label: 'Storm', description: 'Maximum swell under a grey sky: close-out surf, heavy foam, and spray.' }),
402
+ });
403
+
404
+ // Runtime preset registry: seeded with the built-ins and extended by
405
+ // registerWaterPreset(). createWaterSettings resolves preset names through
406
+ // this map, so user-registered presets behave exactly like built-ins.
407
+ const waterPresetRegistry = new Map(
408
+ WATER_PRESET_NAMES.map((name) => [name, {
409
+ description: BUILT_IN_WATER_PRESET_METADATA[name].description,
410
+ label: BUILT_IN_WATER_PRESET_METADATA[name].label,
411
+ settings: WATER_PRESETS[name],
412
+ }]),
413
+ );
414
+
415
+ function normalizeWaterPresetId(value) {
416
+ return String(value ?? '')
417
+ .trim()
418
+ .replace(/[\s-]+/g, '_')
419
+ .toLowerCase();
420
+ }
421
+
422
+ // Old public keys accepted for compatibility with existing URLs and presets.
423
+ const LEGACY_KEY_ALIASES = Object.freeze({
424
+ impulseStrength: 'rippleStrength',
425
+ intersectionFoamDistance: 'foamContactDistance',
426
+ normalStrength: 'detailNormalStrength',
427
+ simulationDamping: 'rippleDamping',
428
+ simulationSpeed: 'ripplePropagation',
429
+ vertexWaveAmount: 'rippleHeightScale',
430
+ });
431
+
432
+ export function resolveWaterPresetName(name) {
433
+ const requested = String(name ?? '').toLowerCase();
434
+ if (waterPresetRegistry.has(requested)) return requested;
435
+ const normalized = normalizeWaterPresetId(requested);
436
+ if (waterPresetRegistry.has(normalized)) return normalized;
437
+ return WATER_PRESET_ALIASES[requested] ?? 'lake';
438
+ }
439
+
440
+ function finiteNumber(value, fallback) {
441
+ const number = Number(value);
442
+ return Number.isFinite(number) ? number : fallback;
443
+ }
444
+
445
+ function clampedNumber(value, fallback, min, max) {
446
+ return Math.min(max, Math.max(min, finiteNumber(value, fallback)));
447
+ }
448
+
449
+ function colorArray(value, fallback) {
450
+ if (value?.isColor) return [value.r, value.g, value.b];
451
+ if (Array.isArray(value) && value.length >= 3) {
452
+ const next = value.slice(0, 3).map(Number);
453
+ return next.every(Number.isFinite) ? next : fallback.slice();
454
+ }
455
+ if (typeof value === 'number' || typeof value === 'string') {
456
+ try {
457
+ const color = new THREE.Color(value);
458
+ return [color.r, color.g, color.b];
459
+ } catch {
460
+ return fallback.slice();
461
+ }
462
+ }
463
+ if (value && typeof value === 'object') {
464
+ const next = [value.r, value.g, value.b].map(Number);
465
+ return next.every(Number.isFinite) ? next : fallback.slice();
466
+ }
467
+ return fallback.slice();
468
+ }
469
+
470
+ function vector2Array(value, fallback) {
471
+ if (value?.isVector2) return [value.x, value.y];
472
+ if (Array.isArray(value) && value.length >= 2) {
473
+ const next = value.slice(0, 2).map(Number);
474
+ return next.every(Number.isFinite) ? next : fallback.slice();
475
+ }
476
+ if (value && typeof value === 'object') {
477
+ const next = [value.x, value.y].map(Number);
478
+ return next.every(Number.isFinite) ? next : fallback.slice();
479
+ }
480
+ return fallback.slice();
481
+ }
482
+
483
+ function vector3Array(value, fallback) {
484
+ if (value?.isVector3) return [value.x, value.y, value.z];
485
+ if (Array.isArray(value) && value.length >= 3) {
486
+ const next = value.slice(0, 3).map(Number);
487
+ return next.every(Number.isFinite) ? next : fallback.slice();
488
+ }
489
+ if (value && typeof value === 'object') {
490
+ const next = [value.x, value.y, value.z].map(Number);
491
+ return next.every(Number.isFinite) ? next : fallback.slice();
492
+ }
493
+ return fallback.slice();
494
+ }
495
+
496
+ function normalizeLegacyKeys(source) {
497
+ const normalized = { ...source };
498
+ for (const [legacyKey, canonicalKey] of Object.entries(LEGACY_KEY_ALIASES)) {
499
+ if (normalized[legacyKey] !== undefined && normalized[canonicalKey] === undefined) {
500
+ normalized[canonicalKey] = normalized[legacyKey];
501
+ }
502
+ }
503
+ // The old shader exposed waveStrength around 0.045..0.085; map it onto the
504
+ // 0..1 intensity dial so historical URLs keep a comparable feel.
505
+ if (normalized.waveStrength !== undefined && normalized.waveIntensity === undefined) {
506
+ const legacyStrength = Number(normalized.waveStrength);
507
+ if (Number.isFinite(legacyStrength)) {
508
+ normalized.waveIntensity = Math.min(1, Math.max(0, legacyStrength * 8));
509
+ }
510
+ }
511
+ return normalized;
512
+ }
513
+
514
+ export function createWaterSettings(options = {}) {
515
+ const rawSource = options && typeof options === 'object' ? options : {};
516
+ const source = normalizeLegacyKeys(rawSource);
517
+ const preset = resolveWaterPresetName(source.preset ?? source.mode);
518
+ const presetSettings = waterPresetRegistry.get(preset)?.settings ?? WATER_PRESETS[preset] ?? {};
519
+ const base = { ...DEFAULT_WATER_SETTINGS, ...presetSettings };
520
+ const colorTone = resolveWaterColorToneName(source.colorTone ?? base.colorTone);
521
+ // A chosen tone forces its palette (see WATER_COLOR_TONES).
522
+ const tone = WATER_COLOR_TONES[colorTone];
523
+
524
+ const quality = WATER_QUALITY_LEVELS.includes(String(source.quality).toLowerCase())
525
+ ? String(source.quality).toLowerCase()
526
+ : base.quality;
527
+
528
+ return {
529
+ preset,
530
+ mode: preset,
531
+ colorTone,
532
+ quality,
533
+
534
+ waveIntensity: clampedNumber(source.waveIntensity, base.waveIntensity, 0, 1),
535
+ waterLevel: finiteNumber(source.waterLevel, base.waterLevel),
536
+
537
+ // Up to 5 m amplitude = 10 m crest-to-trough wave height at full intensity.
538
+ waveAmplitude: clampedNumber(source.waveAmplitude, base.waveAmplitude, 0, 5),
539
+ waveLength: clampedNumber(source.waveLength, base.waveLength, 0.5, 200),
540
+ waveSteepness: clampedNumber(source.waveSteepness, base.waveSteepness, 0, 1.4),
541
+ waveSpeed: clampedNumber(source.waveSpeed, base.waveSpeed, 0, 8),
542
+ waveDirection: vector2Array(source.waveDirection, base.waveDirection),
543
+ waveDirectionSpread: clampedNumber(source.waveDirectionSpread, base.waveDirectionSpread, 0, 1),
544
+ shoalingDepth: clampedNumber(source.shoalingDepth, base.shoalingDepth, 0.05, 12),
545
+ shorelineWaves: clampedNumber(source.shorelineWaves, base.shorelineWaves, 0, 1),
546
+ shorelineRunup: clampedNumber(source.shorelineRunup, base.shorelineRunup, 0, 3),
547
+ breakerEnabled: typeof source.breakerEnabled === 'boolean'
548
+ ? source.breakerEnabled
549
+ : base.breakerEnabled !== false,
550
+ breakerAmount: clampedNumber(source.breakerAmount, base.breakerAmount, 0, 1),
551
+ breakerCurl: clampedNumber(source.breakerCurl, base.breakerCurl, 0, 1),
552
+ breakerScale: clampedNumber(source.breakerScale, base.breakerScale, 0.25, 3),
553
+ breakerPeel: clampedNumber(source.breakerPeel, base.breakerPeel, 0, 4),
554
+ waveSetPeriod: clampedNumber(source.waveSetPeriod, base.waveSetPeriod, 8, 600),
555
+ waveSetStrength: clampedNumber(source.waveSetStrength, base.waveSetStrength, 0, 1),
556
+
557
+ detailNormalStrength: clampedNumber(tone.detailNormalStrength ?? source.detailNormalStrength, base.detailNormalStrength, 0, 2),
558
+ detailScale: clampedNumber(source.detailScale, base.detailScale, 0.05, 12),
559
+ flowDirection: vector2Array(source.flowDirection, base.flowDirection),
560
+ flowSpeed: clampedNumber(source.flowSpeed, base.flowSpeed, 0, 8),
561
+
562
+ shallowColor: colorArray(tone.shallowColor ?? source.shallowColor, base.shallowColor),
563
+ midColor: colorArray(tone.midColor ?? source.midColor, base.midColor),
564
+ deepColor: colorArray(tone.deepColor ?? source.deepColor, base.deepColor),
565
+ depthFadeDistance: clampedNumber(tone.depthFadeDistance ?? source.depthFadeDistance, base.depthFadeDistance, 0.01, 60),
566
+ deepFadeDistance: clampedNumber(tone.deepFadeDistance ?? source.deepFadeDistance, base.deepFadeDistance, 0.01, 120),
567
+ opacity: clampedNumber(source.opacity, base.opacity, 0, 1),
568
+ refractionStrength: clampedNumber(source.refractionStrength, base.refractionStrength, 0, 3),
569
+ causticsStrength: clampedNumber(tone.causticsStrength ?? source.causticsStrength, base.causticsStrength, 0, 4),
570
+ causticsScale: clampedNumber(source.causticsScale, base.causticsScale, 0.02, 12),
571
+ causticsSpeed: clampedNumber(source.causticsSpeed, base.causticsSpeed, 0, 8),
572
+
573
+ foamColor: colorArray(source.foamColor, base.foamColor),
574
+ foamAmount: clampedNumber(source.foamAmount, base.foamAmount, 0, 2),
575
+ foamContactDistance: clampedNumber(source.foamContactDistance, base.foamContactDistance, 0.01, 8),
576
+ foamLineSpacing: clampedNumber(source.foamLineSpacing, base.foamLineSpacing, 0.05, 8),
577
+ foamNoiseScale: clampedNumber(source.foamNoiseScale, base.foamNoiseScale, 0.02, 12),
578
+ whitecapAmount: clampedNumber(source.whitecapAmount, base.whitecapAmount, 0, 1),
579
+ rippleFoamStrength: clampedNumber(source.rippleFoamStrength, base.rippleFoamStrength, 0, 4),
580
+
581
+ sunDirection: vector3Array(source.sunDirection, base.sunDirection),
582
+ sunColor: colorArray(source.sunColor, base.sunColor),
583
+ specularStrength: clampedNumber(source.specularStrength, base.specularStrength, 0, 4),
584
+ specularShininess: clampedNumber(source.specularShininess, base.specularShininess, 2, 4000),
585
+ specularStretch: clampedNumber(source.specularStretch, base.specularStretch, 0, 0.95),
586
+ sparkleStrength: clampedNumber(source.sparkleStrength, base.sparkleStrength, 0, 4),
587
+ sparkleScale: clampedNumber(source.sparkleScale, base.sparkleScale, 0.05, 24),
588
+ sparkleSpeed: clampedNumber(source.sparkleSpeed, base.sparkleSpeed, 0, 8),
589
+ sunGlowStrength: clampedNumber(source.sunGlowStrength, base.sunGlowStrength, 0, 4),
590
+ sceneShadowStrength: clampedNumber(source.sceneShadowStrength, base.sceneShadowStrength, 0, 1),
591
+ fresnelStrength: clampedNumber(source.fresnelStrength, base.fresnelStrength, 0, 2),
592
+ fresnelPower: clampedNumber(source.fresnelPower, base.fresnelPower, 0.5, 16),
593
+ fresnelBias: clampedNumber(tone.fresnelBias ?? source.fresnelBias, base.fresnelBias, 0, 0.6),
594
+ fresnelColor: colorArray(tone.fresnelColor ?? source.fresnelColor, base.fresnelColor),
595
+ skyZenithColor: colorArray(source.skyZenithColor, base.skyZenithColor),
596
+ skyHorizonColor: colorArray(source.skyHorizonColor, base.skyHorizonColor),
597
+ reflectionStrength: clampedNumber(tone.reflectionStrength ?? source.reflectionStrength, base.reflectionStrength, 0, 1.5),
598
+ reflectionDistortion: clampedNumber(source.reflectionDistortion, base.reflectionDistortion, 0, 0.5),
599
+ reflectionSoftness: clampedNumber(tone.reflectionSoftness ?? source.reflectionSoftness, base.reflectionSoftness, 0, 1),
600
+
601
+ rippleStrength: clampedNumber(source.rippleStrength, base.rippleStrength, 0, 8),
602
+ rippleDamping: clampedNumber(source.rippleDamping, base.rippleDamping, 0.8, 0.9995),
603
+ ripplePropagation: clampedNumber(source.ripplePropagation, base.ripplePropagation, 0.5, 80),
604
+ rippleHeightScale: clampedNumber(source.rippleHeightScale, base.rippleHeightScale, 0, 6),
605
+ rippleFoamDecay: clampedNumber(source.rippleFoamDecay, base.rippleFoamDecay, 0.5, 0.999),
606
+ rippleFoamGain: clampedNumber(source.rippleFoamGain, base.rippleFoamGain, 0, 20),
607
+
608
+ splashStrength: clampedNumber(source.splashStrength, base.splashStrength, 0, 4),
609
+ splashScale: clampedNumber(source.splashScale, base.splashScale, 0.1, 6),
610
+ splashDropletCount: Math.round(clampedNumber(source.splashDropletCount, base.splashDropletCount, 0, 160)),
611
+ splashRingCount: Math.round(clampedNumber(source.splashRingCount, base.splashRingCount, 0, 5)),
612
+ splashColor: colorArray(source.splashColor, base.splashColor),
613
+ splashShadeColor: colorArray(source.splashShadeColor, base.splashShadeColor),
614
+ };
615
+ }
616
+
617
+ // --- Gerstner wave spectrum -------------------------------------------------
618
+
619
+ // Deterministic per-wave pseudo random values so the CPU mirror, the shader
620
+ // uniforms, and reloads always agree.
621
+ function waveRandom(index, salt) {
622
+ const value = Math.sin(index * 127.1 + salt * 311.7) * 43758.5453;
623
+ return value - Math.floor(value);
624
+ }
625
+
626
+ // Builds the Gerstner spectrum evaluated by both the shader and
627
+ // sampleGerstnerHeight. Keep in sync with chunks/water-waves.glsl.
628
+ export function buildGerstnerWaves(settings) {
629
+ const resolved = settings?.waveDirection ? settings : createWaterSettings(settings);
630
+ const mainAngle = Math.atan2(resolved.waveDirection[1], resolved.waveDirection[0]);
631
+ const intensity = Math.pow(resolved.waveIntensity, 1.35);
632
+ const baseAmplitude = resolved.waveAmplitude * intensity;
633
+ const spread = resolved.waveDirectionSpread * Math.PI * 0.85;
634
+
635
+ const waves = [];
636
+ for (let i = 0; i < WATER_GERSTNER_WAVE_COUNT; i += 1) {
637
+ const wavelength = Math.max(resolved.waveLength * Math.pow(0.68, i), 0.05);
638
+ let waveNumber = (2 * Math.PI) / wavelength;
639
+ const amplitude = baseAmplitude * Math.pow(wavelength / resolved.waveLength, 0.85);
640
+ // Deep-water waves are slope-limited: they break beyond ka ~ 0.44, so a
641
+ // big amplitude forces a proportionally longer wavelength — exactly like
642
+ // real swell (a 10 m sea is hundreds of meters long, not 13). Without
643
+ // this cap a big Height dial turns the surface into jagged spikes.
644
+ if (amplitude * waveNumber > 0.4) {
645
+ waveNumber = 0.4 / amplitude;
646
+ }
647
+ // The primary swell travels exactly along waveDirection; spread only
648
+ // scatters the secondary components. Keeps the dominant crest lines
649
+ // long and parallel (what breakers phase-lock to), like real swell.
650
+ const angle = i === 0 ? mainAngle : mainAngle + (waveRandom(i, 1.0) * 2 - 1) * spread;
651
+ const omega = Math.sqrt(9.81 * waveNumber) * resolved.waveSpeed;
652
+ waves.push({
653
+ dirX: Math.cos(angle),
654
+ dirZ: Math.sin(angle),
655
+ omega,
656
+ waveNumber,
657
+ amplitude,
658
+ phase: waveRandom(i, 7.0) * Math.PI * 2,
659
+ steepness: resolved.waveSteepness * (0.35 + 0.65 * resolved.waveIntensity),
660
+ crestWeight: Math.sqrt(wavelength / resolved.waveLength),
661
+ });
662
+ }
663
+
664
+ // Gerstner surfaces self-intersect when the summed steepness exceeds 1.
665
+ // Normalize so the whole stack stays stable at any dial position.
666
+ const steepnessSum = waves.reduce(
667
+ (sum, wave) => sum + wave.steepness * wave.waveNumber * wave.amplitude, 0);
668
+ if (steepnessSum > 0.9) {
669
+ const scale = 0.9 / steepnessSum;
670
+ for (const wave of waves) wave.steepness *= scale;
671
+ }
672
+
673
+ // Wave sets: real swell arrives in groups — the big waves come minutes
674
+ // apart, not every period. Splitting the primary component into a beat
675
+ // pair (slots 0 and 1: same direction, slightly different frequency) makes
676
+ // their interference modulate the swell amplitude with period
677
+ // waveSetPeriod. The pair is dispersion-consistent, so set envelopes march
678
+ // in from open water at group velocity instead of pulsing in place.
679
+ // Everything downstream — heightfield, CPU buoyancy mirror, breakers —
680
+ // inherits the grouping because it lives inside the same wave stack.
681
+ const setStrength = resolved.waveSetStrength;
682
+ if (setStrength > 0.001 && waves.length >= 2) {
683
+ const primary = waves[0];
684
+ const beatOmega = primary.omega + (2 * Math.PI) / Math.max(resolved.waveSetPeriod, 1);
685
+ const speedScale = Math.max(resolved.waveSpeed, 1e-3);
686
+ const beat = {
687
+ ...primary,
688
+ amplitude: primary.amplitude * setStrength * 0.5,
689
+ omega: beatOmega,
690
+ waveNumber: (beatOmega / speedScale) ** 2 / 9.81,
691
+ phase: primary.phase + 2.39,
692
+ };
693
+ primary.amplitude *= 1 - setStrength * 0.5;
694
+ waves.pop(); // drop the smallest tail component to keep the count fixed
695
+ waves.splice(1, 0, beat);
696
+ }
697
+ return waves;
698
+ }
699
+
700
+ // CPU mirror of the shader's vertical Gerstner displacement. Horizontal
701
+ // choppiness is intentionally ignored, which keeps queries cheap and is
702
+ // accurate enough for buoyancy and interaction tests. chopWeight mirrors the
703
+ // shader's shallow-water spectrum filter: slots 0/1 (the dominant swell and
704
+ // its set beat partner) always pass at full strength, shorter cross chop
705
+ // fades toward the surf zone.
706
+ export function sampleGerstnerHeight(waves, x, z, time, chopWeight = 1) {
707
+ let height = 0;
708
+ for (let i = 0; i < waves.length; i += 1) {
709
+ const wave = waves[i];
710
+ const theta = wave.waveNumber * (wave.dirX * x + wave.dirZ * z) -
711
+ wave.omega * time + wave.phase;
712
+ height += wave.amplitude * (i < 2 ? 1 : chopWeight) * Math.sin(theta);
713
+ }
714
+ return height;
715
+ }
716
+
717
+ // --- Field schema -----------------------------------------------------------
718
+
719
+ export const WATER_SETTING_GROUPS = Object.freeze([
720
+ Object.freeze({ id: 'waves', label: 'Waves', description: 'Gerstner swell and detail ripple shaping.' }),
721
+ Object.freeze({ id: 'surface', label: 'Surface', description: 'Water body color, refraction, and caustics.' }),
722
+ Object.freeze({ id: 'foam', label: 'Foam', description: 'Shoreline foam, whitecaps, and wake foam.' }),
723
+ Object.freeze({ id: 'lighting', label: 'Lighting', description: 'Sun glints, sparkles, fresnel, and reflections.' }),
724
+ Object.freeze({ id: 'ripples', label: 'Ripples', description: 'Interactive ripple simulation response.' }),
725
+ Object.freeze({ id: 'splashes', label: 'Splashes', description: 'Procedural splash droplets, spray, and rings.' }),
726
+ Object.freeze({ id: 'quality', label: 'Quality', description: 'Shader quality tier gating caustics, sparkles, and noise octaves.' }),
727
+ ]);
728
+
729
+ const FIELD_METADATA = {
730
+ waveIntensity: { group: 'waves', label: 'Wave Intensity', min: 0, max: 1, step: 0.01, description: 'Master dial from glassy mirror (0) to storm swell (1).' },
731
+ waterLevel: { group: 'waves', label: 'Water Level', min: 0, max: 4, step: 0.01, description: 'World-space rest height of the surface; waves and run-up displace around it.' },
732
+ waveAmplitude: { group: 'waves', label: 'Wave Amplitude', min: 0, max: 5, step: 0.01, description: 'Largest wave amplitude in meters at full intensity; 5 gives a 10 m crest-to-trough swell.' },
733
+ shoalingDepth: { group: 'waves', label: 'Shoaling Depth', min: 0.05, max: 12, step: 0.05, description: 'Column depth in meters at which waves reach full height; shallower water shrinks them (needs a bed height sampler).' },
734
+ shorelineWaves: { group: 'waves', label: 'Shoreline Waves', min: 0, max: 1, step: 0.01, description: 'Fraction of wave height that keeps rolling through the shallows as surf before dying at the waterline.' },
735
+ shorelineRunup: { group: 'waves', label: 'Shoreline Run-up', min: 0, max: 3, step: 0.05, description: 'How far incoming waves wash a thin foam film up the beach; reach scales with wave energy.' },
736
+ breakerEnabled: { group: 'waves', label: 'Breakers On', type: 'boolean', description: 'Master switch for the breaker system; off removes the mesh and skips all breaker work (for perf A/B).' },
737
+ breakerAmount: { group: 'waves', label: 'Surf Breakers', min: 0, max: 1, step: 0.01, description: 'Dedicated curling breaker shells along the break line; 0 disables the system (needs a bed height sampler).' },
738
+ breakerCurl: { group: 'waves', label: 'Breaker Curl', min: 0, max: 1, step: 0.01, description: 'Lip pitch: 0 spills down the face, 1 curls a full surfable tunnel.' },
739
+ breakerScale: { group: 'waves', label: 'Breaker Height', min: 0.25, max: 3, step: 0.05, description: 'Shell height multiplier over the physical breaking height (0.72x column depth).' },
740
+ breakerPeel: { group: 'waves', label: 'Breaker Peel', min: 0, max: 4, step: 0.05, description: 'How fast the barrel section travels sideways along the crest line.' },
741
+ waveLength: { group: 'waves', label: 'Wave Length', min: 1, max: 120, step: 0.1, description: 'Longest wavelength in meters; smaller waves are derived from it. Big swells need long wavelengths to stay stable.' },
742
+ waveSteepness: { group: 'waves', label: 'Wave Steepness', min: 0, max: 1.4, step: 0.01, description: 'Gerstner chop; higher values pinch crests sharper.' },
743
+ waveSpeed: { group: 'waves', label: 'Wave Speed', min: 0, max: 4, step: 0.01, description: 'Phase speed multiplier over the deep-water dispersion.' },
744
+ waveDirection: { group: 'waves', label: 'Wave Direction', type: 'vector2', description: 'Main travel direction of the swell in the XZ plane.' },
745
+ waveDirectionSpread: { group: 'waves', label: 'Direction Spread', min: 0, max: 1, step: 0.01, description: '0 keeps all waves aligned (river); 1 spreads them omnidirectionally (open sea). The primary swell always follows Wave Direction exactly.' },
746
+ waveSetPeriod: { group: 'waves', label: 'Set Period (s)', min: 8, max: 600, step: 1, description: 'Seconds between wave-set peaks at a fixed point; big waves arrive in groups, not every period.' },
747
+ waveSetStrength: { group: 'waves', label: 'Set Strength', min: 0, max: 1, step: 0.01, description: 'Depth of the set/lull cycle: 0 = constant swell, 1 = the swell dies completely between sets.' },
748
+ detailNormalStrength: { group: 'waves', label: 'Detail Normals', min: 0, max: 2, step: 0.01, description: 'Strength of the procedural micro-ripple normal detail.' },
749
+ detailScale: { group: 'waves', label: 'Detail Scale', min: 0.05, max: 8, step: 0.05, description: 'Spatial frequency of the micro-ripple detail.' },
750
+ flowDirection: { group: 'waves', label: 'Flow Direction', type: 'vector2', description: 'Scroll direction for detail ripples, foam noise, and sparkles.' },
751
+ flowSpeed: { group: 'waves', label: 'Flow Speed', min: 0, max: 4, step: 0.01, description: 'Scroll speed for surface detail; high values read as a river current.' },
752
+
753
+ colorTone: {
754
+ group: 'surface',
755
+ label: 'Color Tone',
756
+ type: 'select',
757
+ options: WATER_COLOR_TONE_NAMES,
758
+ optionLabels: Object.freeze({
759
+ caribbean: 'Caribbean',
760
+ classic: 'Classic',
761
+ deepOcean: 'Deep Ocean',
762
+ anime: 'Anime',
763
+ lagoon: 'Lagoon',
764
+ teal: 'Teal',
765
+ }),
766
+ description: 'Named body-color palette forced over the preset colors; classic returns control to the preset.',
767
+ },
768
+ shallowColor: { group: 'surface', label: 'Shallow Color', type: 'color', description: 'Water tint right at the shoreline.' },
769
+ midColor: { group: 'surface', label: 'Mid Color', type: 'color', description: 'Water tint at moderate depth.' },
770
+ deepColor: { group: 'surface', label: 'Deep Color', type: 'color', description: 'Water tint where the bottom is no longer visible.' },
771
+ depthFadeDistance: { group: 'surface', label: 'Depth Fade', min: 0.05, max: 12, step: 0.05, description: 'Water column depth where the shallow tint gives way to mid.' },
772
+ deepFadeDistance: { group: 'surface', label: 'Deep Fade', min: 0.05, max: 24, step: 0.05, description: 'Additional depth where mid fades to the deep tint.' },
773
+ opacity: { group: 'surface', label: 'Opacity', min: 0, max: 1, step: 0.01, description: 'Base transparency when no scene color grab pass is bound.' },
774
+ refractionStrength: { group: 'surface', label: 'Refraction', min: 0, max: 2, step: 0.01, description: 'Screen-space distortion of the underwater scene.' },
775
+ causticsStrength: { group: 'surface', label: 'Caustics', min: 0, max: 3, step: 0.01, description: 'Brightness of the procedural voronoi caustics on the bottom.' },
776
+ causticsScale: { group: 'surface', label: 'Caustics Scale', min: 0.05, max: 8, step: 0.05, description: 'Spatial frequency of the caustic web.' },
777
+ causticsSpeed: { group: 'surface', label: 'Caustics Speed', min: 0, max: 4, step: 0.01, description: 'Animation speed of the caustic web.' },
778
+
779
+ foamColor: { group: 'foam', label: 'Foam Color', type: 'color', description: 'Color of all foam: shoreline, whitecaps, wakes, and splashes.' },
780
+ foamAmount: { group: 'foam', label: 'Foam Amount', min: 0, max: 2, step: 0.01, description: 'Global foam gain.' },
781
+ foamContactDistance: { group: 'foam', label: 'Contact Distance', min: 0.02, max: 4, step: 0.01, description: 'Depth difference covered by the solid contact foam band.' },
782
+ foamLineSpacing: { group: 'foam', label: 'Line Spacing', min: 0.05, max: 4, step: 0.01, description: 'Spacing of the animated lapping foam lines off the shore.' },
783
+ foamNoiseScale: { group: 'foam', label: 'Foam Noise Scale', min: 0.05, max: 8, step: 0.05, description: 'Breakup noise frequency for foam edges.' },
784
+ whitecapAmount: { group: 'foam', label: 'Whitecaps', min: 0, max: 1, step: 0.01, description: 'Coverage of breaking crests on open water.' },
785
+ rippleFoamStrength: { group: 'foam', label: 'Wake Foam', min: 0, max: 3, step: 0.01, description: 'Foam intensity left behind by interactive ripples and wakes.' },
786
+
787
+ sunDirection: { group: 'lighting', label: 'Sun Direction', type: 'vector3', description: 'World-space direction toward the sun.' },
788
+ sunColor: { group: 'lighting', label: 'Sun Color', type: 'color', description: 'Sun tint used by glints, sparkles, and caustics.' },
789
+ specularStrength: { group: 'lighting', label: 'Specular', min: 0, max: 3, step: 0.01, description: 'Toon sun-glint intensity.' },
790
+ specularShininess: { group: 'lighting', label: 'Shininess', min: 4, max: 2000, step: 1, description: 'Glint tightness; higher is smaller and sharper.' },
791
+ specularStretch: { group: 'lighting', label: 'Glint Stretch', min: 0, max: 0.95, step: 0.01, description: 'Elongates glints along the sun azimuth into a sparkling sun path.' },
792
+ sparkleStrength: { group: 'lighting', label: 'Sparkles', min: 0, max: 3, step: 0.01, description: 'Twinkling star-glint intensity.' },
793
+ sparkleScale: { group: 'lighting', label: 'Sparkle Scale', min: 0.1, max: 16, step: 0.1, description: 'Density of the sparkle field.' },
794
+ sparkleSpeed: { group: 'lighting', label: 'Sparkle Speed', min: 0, max: 6, step: 0.05, description: 'How quickly sparkles twinkle in and out.' },
795
+ sunGlowStrength: { group: 'lighting', label: 'Sun Glow', min: 0, max: 3, step: 0.01, description: 'Sun disk glow in the procedural sky reflection.' },
796
+ sceneShadowStrength: { group: 'lighting', label: 'Scene Shadows', min: 0, max: 1, step: 0.01, description: 'How strongly cast shadows from rocks, trees, and the character darken the surface.' },
797
+ fresnelStrength: { group: 'lighting', label: 'Fresnel', min: 0, max: 2, step: 0.01, description: 'Grazing-angle reflectivity boost.' },
798
+ fresnelPower: { group: 'lighting', label: 'Fresnel Power', min: 0.5, max: 12, step: 0.1, description: 'Falloff of the fresnel band toward the horizon.' },
799
+ fresnelBias: { group: 'lighting', label: 'Fresnel Bias', min: 0, max: 0.6, step: 0.01, description: 'Sky-tint floor at steep angles; higher reads more anime-blue.' },
800
+ fresnelColor: { group: 'lighting', label: 'Fresnel Color', type: 'color', description: 'Additive rim tint at grazing angles.' },
801
+ skyZenithColor: { group: 'lighting', label: 'Sky Zenith', type: 'color', description: 'Procedural sky reflection color overhead.' },
802
+ skyHorizonColor: { group: 'lighting', label: 'Sky Horizon', type: 'color', description: 'Procedural sky reflection color at the horizon.' },
803
+ reflectionStrength: { group: 'lighting', label: 'Reflection', min: 0, max: 1.5, step: 0.01, description: 'Planar/sky reflection mix, weighted by fresnel.' },
804
+ reflectionDistortion: { group: 'lighting', label: 'Reflection Ripple', min: 0, max: 0.3, step: 0.005, description: 'How much waves shatter the reflection.' },
805
+ reflectionSoftness: { group: 'lighting', label: 'Reflection Softness', min: 0, max: 1, step: 0.01, description: 'Blends sharp planar reflections toward the soft procedural sky (milky anime look).' },
806
+
807
+ rippleStrength: { group: 'ripples', label: 'Ripple Strength', min: 0, max: 6, step: 0.05, description: 'Global multiplier for splash and wake impulses.' },
808
+ rippleDamping: { group: 'ripples', label: 'Ripple Damping', min: 0.9, max: 0.999, step: 0.001, description: 'Energy retained per frame; higher rings travel farther.' },
809
+ ripplePropagation: { group: 'ripples', label: 'Ripple Speed', min: 1, max: 40, step: 0.5, description: 'Travel speed of interactive rings across the surface.' },
810
+ rippleHeightScale: { group: 'ripples', label: 'Ripple Height', min: 0, max: 4, step: 0.05, description: 'Vertical displacement of the interactive ripples.' },
811
+ rippleFoamDecay: { group: 'ripples', label: 'Wake Foam Decay', min: 0.5, max: 0.999, step: 0.001, description: 'How long wake foam lingers.' },
812
+ rippleFoamGain: { group: 'ripples', label: 'Wake Foam Gain', min: 0, max: 12, step: 0.1, description: 'How quickly motion generates wake foam.' },
813
+
814
+ splashStrength: { group: 'splashes', label: 'Splash Strength', min: 0, max: 3, step: 0.05, description: 'Global multiplier for splash particle counts and energy.' },
815
+ splashScale: { group: 'splashes', label: 'Splash Scale', min: 0.1, max: 4, step: 0.05, description: 'Physical size multiplier for droplets, spray, and rings.' },
816
+ splashDropletCount: { group: 'splashes', label: 'Droplet Count', min: 0, max: 120, step: 1, description: 'Droplets emitted by a strength-1 splash.' },
817
+ splashRingCount: { group: 'splashes', label: 'Ring Count', min: 0, max: 4, step: 1, description: 'Expanding foam rings emitted per splash.' },
818
+ splashColor: { group: 'splashes', label: 'Splash Color', type: 'color', description: 'Bright tone of droplets and spray.' },
819
+ splashShadeColor: { group: 'splashes', label: 'Splash Shade', type: 'color', description: 'Shadow tone of the two-tone splash shading.' },
820
+
821
+ quality: {
822
+ group: 'quality',
823
+ label: 'Quality Tier',
824
+ type: 'select',
825
+ options: WATER_QUALITY_LEVELS,
826
+ optionLabels: Object.freeze({ high: 'High', low: 'Low', medium: 'Medium' }),
827
+ description: 'Named quality tier: low drops caustics and sparkles, high adds chromatic caustics and extra detail octaves.',
828
+ },
829
+ };
830
+
831
+ function inferFieldType(key, value) {
832
+ const metadata = FIELD_METADATA[key];
833
+ if (metadata?.type) return metadata.type;
834
+ if (Array.isArray(value)) return value.length === 2 ? 'vector2' : 'color';
835
+ return 'number';
836
+ }
837
+
838
+ export const WATER_SETTING_FIELD_SCHEMA = Object.freeze(
839
+ Object.fromEntries(
840
+ Object.entries(FIELD_METADATA).map(([key, metadata]) => {
841
+ const defaultValue = DEFAULT_WATER_SETTINGS[key];
842
+ const type = inferFieldType(key, defaultValue);
843
+ return [key, Object.freeze({
844
+ key,
845
+ id: `${metadata.group}.${key}`,
846
+ group: metadata.group,
847
+ label: metadata.label,
848
+ type,
849
+ min: metadata.min,
850
+ max: metadata.max,
851
+ step: metadata.step,
852
+ range: type === 'number'
853
+ ? Object.freeze({ min: metadata.min ?? 0, max: metadata.max ?? 1, step: metadata.step ?? 0.01 })
854
+ : null,
855
+ options: metadata.options ? Object.freeze([...metadata.options]) : null,
856
+ optionLabels: metadata.optionLabels ?? null,
857
+ description: metadata.description,
858
+ defaultValue,
859
+ serializable: true,
860
+ })];
861
+ }),
862
+ ),
863
+ );
864
+
865
+ /**
866
+ * WATER_SETTING_FIELD_SCHEMA regrouped by setting-group id so it plugs
867
+ * straight into the schema-driven debug panel, which looks fields up per
868
+ * group (mirrors the nested shape of TOON_SETTING_FIELD_SCHEMA):
869
+ *
870
+ * createSettingsPanel({
871
+ * groups: WATER_SETTING_GROUPS,
872
+ * fieldSchema: WATER_SETTING_FIELD_SCHEMA_BY_GROUP,
873
+ * ...
874
+ * });
875
+ *
876
+ * Water settings are flat (no per-group nesting), so hosts read values with
877
+ * `settings[field.key]` rather than `settings[field.group][field.key]`.
878
+ */
879
+ export const WATER_SETTING_FIELD_SCHEMA_BY_GROUP = Object.freeze(
880
+ Object.fromEntries(
881
+ WATER_SETTING_GROUPS.map((group) => [
882
+ group.id,
883
+ Object.freeze(
884
+ Object.fromEntries(
885
+ Object.entries(WATER_SETTING_FIELD_SCHEMA).filter(([, field]) => field.group === group.id),
886
+ ),
887
+ ),
888
+ ]),
889
+ ),
890
+ );
891
+
892
+ // --- Preset documents & registration -----------------------------------------
893
+ //
894
+ // Mirrors the toon preset document conventions: a preset document is a plain
895
+ // JSON object { type, version, id, label, description, settings } that can be
896
+ // saved to disk, shared, and re-registered on another machine.
897
+
898
+ /** Document `type` discriminator for serialized water presets. */
899
+ export const WATER_PRESET_DOCUMENT_TYPE = 'toonlab/water-preset';
900
+
901
+ /** Current water preset document schema version. */
902
+ export const WATER_PRESET_SCHEMA_VERSION = 1;
903
+
904
+ function cleanObject(value) {
905
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
906
+ }
907
+
908
+ function isPlainObject(value) {
909
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
910
+ }
911
+
912
+ function booleanFromValue(value, fallback) {
913
+ if (typeof value === 'boolean') return value;
914
+ if (typeof value === 'string') {
915
+ const normalized = value.trim().toLowerCase();
916
+ if (['1', 'true', 'on', 'yes'].includes(normalized)) return true;
917
+ if (['0', 'false', 'off', 'no'].includes(normalized)) return false;
918
+ }
919
+ if (Number.isFinite(value)) return value !== 0;
920
+ return fallback;
921
+ }
922
+
923
+ function coerceWaterFieldValue(value, field) {
924
+ const fallback = field.defaultValue;
925
+ switch (field.type) {
926
+ case 'boolean':
927
+ return booleanFromValue(value, fallback);
928
+ case 'number':
929
+ return finiteNumber(value, fallback);
930
+ case 'select': {
931
+ const normalized = String(value ?? '').trim();
932
+ return field.options?.includes(normalized) ? normalized : fallback;
933
+ }
934
+ case 'color':
935
+ return colorArray(value, fallback);
936
+ case 'vector2':
937
+ return vector2Array(value, fallback);
938
+ case 'vector3':
939
+ return vector3Array(value, fallback);
940
+ default:
941
+ return String(value ?? fallback);
942
+ }
943
+ }
944
+
945
+ // Picks the known (schema-covered) setting keys out of a flat definition
946
+ // object, resolving legacy key aliases first.
947
+ function collectTopLevelWaterSettings(source) {
948
+ const normalized = normalizeLegacyKeys(cleanObject(source));
949
+ return Object.fromEntries(
950
+ Object.entries(normalized).filter(([key]) => WATER_SETTING_FIELD_SCHEMA[key]),
951
+ );
952
+ }
953
+
954
+ function collectUnknownWaterSettingKeys(settings = {}) {
955
+ const warnings = [];
956
+ for (const key of Object.keys(cleanObject(settings))) {
957
+ if (WATER_SETTING_FIELD_SCHEMA[key] || LEGACY_KEY_ALIASES[key] || key === 'waveStrength') continue;
958
+ // The preset/mode selection is carried by the document id, not settings.
959
+ if (key === 'preset' || key === 'mode') continue;
960
+ warnings.push(`Unknown water setting "${key}" was ignored.`);
961
+ }
962
+ return warnings;
963
+ }
964
+
965
+ /**
966
+ * Coerces a flat water settings object down to the serializable schema:
967
+ * numbers stay finite numbers, booleans/selects are validated, and colors and
968
+ * direction vectors are normalized to plain [r, g, b] / [x, y] / [x, y, z]
969
+ * arrays (THREE.Color / Vector inputs included). Unknown and runtime-only
970
+ * keys are dropped; legacy key aliases (e.g. `normalStrength`) are resolved
971
+ * to their canonical names first.
972
+ *
973
+ * Note: presets cannot change the Gerstner wave count —
974
+ * WATER_GERSTNER_WAVE_COUNT is baked into the shader and the CPU mirror.
975
+ *
976
+ * @param {object} [settings] Flat settings overrides keyed like DEFAULT_WATER_SETTINGS.
977
+ * @returns {object} Sanitized JSON-safe settings object.
978
+ */
979
+ export function sanitizeWaterPresetSettings(settings = {}) {
980
+ const source = normalizeLegacyKeys(cleanObject(settings));
981
+ const sanitized = {};
982
+ for (const [key, value] of Object.entries(source)) {
983
+ const field = WATER_SETTING_FIELD_SCHEMA[key];
984
+ if (!field?.serializable || value === undefined) continue;
985
+ sanitized[key] = coerceWaterFieldValue(value, field);
986
+ }
987
+ return sanitized;
988
+ }
989
+
990
+ function migrateWaterPresetDocument(input) {
991
+ const source = cleanObject(input);
992
+ const version = Number.isFinite(source.version) ? Math.round(source.version) : 0;
993
+ const settings = cleanObject(source.settings);
994
+
995
+ if (version <= 1) {
996
+ return {
997
+ description: source.description ?? '',
998
+ id: source.id ?? source.name ?? source.preset ?? '',
999
+ label: source.label ?? source.title ?? source.name ?? source.id ?? '',
1000
+ settings: Object.keys(settings).length > 0 ? settings : collectTopLevelWaterSettings(source),
1001
+ type: source.type ?? WATER_PRESET_DOCUMENT_TYPE,
1002
+ version: WATER_PRESET_SCHEMA_VERSION,
1003
+ };
1004
+ }
1005
+
1006
+ return source;
1007
+ }
1008
+
1009
+ /**
1010
+ * Validates (and migrates) a water preset document. Never throws.
1011
+ *
1012
+ * @param {object} input Parsed preset document (or a loose legacy shape).
1013
+ * @returns {{ok: boolean, errors: string[], warnings: string[], value: object|null}}
1014
+ * `value` is the normalized document (sanitized settings, canonical type
1015
+ * and version) when `ok` is true, otherwise null.
1016
+ */
1017
+ export function validateWaterPresetDocument(input) {
1018
+ return validateSettingsPresetDocument(input, {
1019
+ collectWarnings: collectUnknownWaterSettingKeys,
1020
+ documentType: WATER_PRESET_DOCUMENT_TYPE,
1021
+ migrateDocument: migrateWaterPresetDocument,
1022
+ normalizeId: normalizeWaterPresetId,
1023
+ sanitizeSettings: sanitizeWaterPresetSettings,
1024
+ schemaVersion: WATER_PRESET_SCHEMA_VERSION,
1025
+ });
1026
+ }
1027
+
1028
+ /**
1029
+ * Parses a JSON string (or already-parsed object) into a validated water
1030
+ * preset document. Never throws; JSON errors are reported in `errors`.
1031
+ *
1032
+ * @param {string|object} input Preset JSON text or object.
1033
+ * @returns {{ok: boolean, errors: string[], warnings: string[], value: object|null}}
1034
+ */
1035
+ export function parseWaterPresetDocument(input) {
1036
+ return parsePresetDocument(input, validateWaterPresetDocument);
1037
+ }
1038
+
1039
+ /**
1040
+ * Builds a normalized water preset document from a preset id and definition.
1041
+ * The definition may carry a nested `settings` object or flat setting keys
1042
+ * (e.g. `{ waveIntensity: 0.5 }`); either way the result is validated and
1043
+ * sanitized. Throws when the document is invalid (e.g. missing id).
1044
+ *
1045
+ * @param {string} id Preset id (normalized to snake_case lowercase).
1046
+ * @param {object} [definition] `{ label?, description?, settings? }` or flat settings.
1047
+ * @returns {object} `{ type, version, id, label, description, settings }`.
1048
+ */
1049
+ export function createWaterPresetDocument(id, definition = {}) {
1050
+ return createSettingsPresetDocument(id, definition, {
1051
+ collectSettings: (source) => source.settings ?? collectTopLevelWaterSettings(source),
1052
+ documentType: WATER_PRESET_DOCUMENT_TYPE,
1053
+ schemaVersion: WATER_PRESET_SCHEMA_VERSION,
1054
+ validateDocument: validateWaterPresetDocument,
1055
+ });
1056
+ }
1057
+
1058
+ /**
1059
+ * Serializes a water preset to a JSON string. Accepts either
1060
+ * `serializeWaterPreset(id, definition)` or a single document-like object.
1061
+ *
1062
+ * @param {string|object} idOrDocument Preset id, or a document-like object.
1063
+ * @param {object} [definition] Preset definition when the first argument is an id.
1064
+ * @param {{pretty?: boolean}} [options] `pretty` (default true) pretty-prints the JSON.
1065
+ * @returns {string} Preset document JSON.
1066
+ */
1067
+ export function serializeWaterPreset(idOrDocument, definition = {}, { pretty = true } = {}) {
1068
+ return serializePresetDocument(idOrDocument, definition, {
1069
+ argumentCount: arguments.length,
1070
+ createDocument: createWaterPresetDocument,
1071
+ pretty,
1072
+ });
1073
+ }
1074
+
1075
+ /**
1076
+ * Registers a named water preset so it resolves in createWaterSettings /
1077
+ * createWaterMaterial exactly like the built-ins:
1078
+ *
1079
+ * registerWaterPreset('bioluminescent_bay', {
1080
+ * label: 'Bioluminescent Bay',
1081
+ * waveIntensity: 0.1,
1082
+ * deepColor: [0.01, 0.09, 0.2],
1083
+ * });
1084
+ * const settings = createWaterSettings({ preset: 'bioluminescent_bay' });
1085
+ *
1086
+ * Settings are sanitized to serializable values (see
1087
+ * sanitizeWaterPresetSettings). Presets cannot change the Gerstner wave
1088
+ * count — WATER_GERSTNER_WAVE_COUNT is baked into the shader.
1089
+ *
1090
+ * @param {string} name Preset id (normalized to snake_case lowercase).
1091
+ * @param {object} [preset] `{ label?, description?, settings? }` or flat settings.
1092
+ * @param {{overwrite?: boolean}} [options] Set `overwrite: true` to replace an
1093
+ * existing preset (including built-ins); otherwise re-registering throws.
1094
+ * @returns {{id: string, label: string, description: string}} Registered preset metadata.
1095
+ */
1096
+ export function registerWaterPreset(name, preset = {}, { overwrite = false } = {}) {
1097
+ const document = createWaterPresetDocument(name, preset);
1098
+ const presetId = document.id;
1099
+ if (!overwrite && waterPresetRegistry.has(presetId)) {
1100
+ throw new Error(`Water preset "${presetId}" already exists.`);
1101
+ }
1102
+
1103
+ waterPresetRegistry.set(presetId, {
1104
+ description: document.description,
1105
+ label: document.label,
1106
+ settings: Object.freeze({ ...document.settings }),
1107
+ });
1108
+ return { description: document.description, id: presetId, label: document.label };
1109
+ }
1110
+
1111
+ /**
1112
+ * Registers a preset from serialized JSON (string or parsed document), as
1113
+ * produced by serializeWaterPreset. Overwrites by default so re-importing a
1114
+ * saved preset always takes effect. Throws when the document is invalid.
1115
+ *
1116
+ * @param {string|object} input Preset document JSON or object.
1117
+ * @param {{overwrite?: boolean}} [options]
1118
+ * @returns {{id: string, label: string, description: string}} Registered preset metadata.
1119
+ */
1120
+ export function registerSerializedWaterPreset(input, options = {}) {
1121
+ const result = parseWaterPresetDocument(input);
1122
+ if (!result.ok) throw new Error(result.errors.join(' '));
1123
+ return registerWaterPreset(result.value.id, result.value, { overwrite: options.overwrite ?? true });
1124
+ }
1125
+
1126
+ /**
1127
+ * Lists every registered water preset (built-ins first, then user-registered)
1128
+ * as `{ id, label, description }` entries — ready for a preset picker.
1129
+ *
1130
+ * @returns {Array<{id: string, label: string, description: string}>}
1131
+ */
1132
+ export function getWaterPresetOptions() {
1133
+ return Array.from(waterPresetRegistry.entries()).map(([id, preset]) => ({
1134
+ description: preset.description ?? '',
1135
+ id,
1136
+ label: preset.label ?? id,
1137
+ }));
1138
+ }