@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,292 @@
1
+ import { cloneSerializable, deepMerge, stableStringify } from '../core/generation.js';
2
+ import { parsePresetDocument } from '../core/presetDocuments.js';
3
+
4
+ export const SOUNDSCAPE_PRESET_TYPE = 'toonlab/soundscape-preset';
5
+ export const SOUNDSCAPE_PRESET_VERSION = 1;
6
+
7
+ export const SOUNDSCAPE_QUALITY_BUDGETS = Object.freeze({
8
+ mobile: Object.freeze({ maxLayers: 4, maxNodes: 32, maxVoices: 10 }),
9
+ balanced: Object.freeze({ maxLayers: 8, maxNodes: 64, maxVoices: 24 }),
10
+ cinematic: Object.freeze({ maxLayers: 16, maxNodes: 128, maxVoices: 48 }),
11
+ });
12
+
13
+ const DEFAULT_SETTINGS = Object.freeze({
14
+ adaptive: Object.freeze({
15
+ mappings: Object.freeze({
16
+ intensity: Object.freeze([
17
+ Object.freeze({ from: 0.62, mode: 'multiply', path: 'layers.air.gain', to: 1.18 }),
18
+ Object.freeze({ from: 0.35, mode: 'multiply', path: 'layers.wildlife.gain', to: 1.3 }),
19
+ ]),
20
+ tension: Object.freeze([
21
+ Object.freeze({ from: 0.72, mode: 'multiply', path: 'layers.drone.gain', to: 1.22 }),
22
+ ]),
23
+ }),
24
+ smoothing: 0.35,
25
+ values: Object.freeze({ intensity: 0.45, tension: 0.15, weather: 0.25 }),
26
+ }),
27
+ // The source preset carries the largest supported envelope. The resolver
28
+ // applies mobile/balanced/cinematic caps, so raising quality can actually
29
+ // make additional graph budget available without mutating the recipe.
30
+ budget: Object.freeze({ maxLayers: 16, maxNodes: 128, maxVoices: 48 }),
31
+ buses: Object.freeze({
32
+ ambience: Object.freeze({ gain: 0.78, mute: false }),
33
+ detail: Object.freeze({ gain: 0.62, mute: false }),
34
+ music: Object.freeze({ gain: 0.4, mute: false }),
35
+ }),
36
+ layers: Object.freeze({
37
+ air: Object.freeze({
38
+ bus: 'ambience',
39
+ enabled: true,
40
+ gain: 0.26,
41
+ pan: -0.08,
42
+ params: Object.freeze({ color: 'brown', highpass: 72, lowpass: 2800, movement: 0.18 }),
43
+ type: 'noise',
44
+ voiceLimit: 1,
45
+ }),
46
+ drone: Object.freeze({
47
+ bus: 'music',
48
+ enabled: true,
49
+ gain: 0.08,
50
+ pan: 0.04,
51
+ params: Object.freeze({ detune: 4, frequency: 82.41, movement: 0.05, waveform: 'sine' }),
52
+ type: 'oscillator',
53
+ voiceLimit: 1,
54
+ }),
55
+ rustle: Object.freeze({
56
+ bus: 'detail',
57
+ enabled: true,
58
+ gain: 0.11,
59
+ pan: 0.2,
60
+ params: Object.freeze({ color: 'pink', highpass: 900, lowpass: 6200, movement: 0.5 }),
61
+ type: 'noise',
62
+ voiceLimit: 1,
63
+ }),
64
+ wildlife: Object.freeze({
65
+ bus: 'detail',
66
+ enabled: true,
67
+ gain: 0.12,
68
+ pan: -0.16,
69
+ params: Object.freeze({ density: 0.22, duration: 0.18, frequency: 1550, pitchSpread: 0.38, waveform: 'sine' }),
70
+ type: 'procedural-events',
71
+ voiceLimit: 4,
72
+ }),
73
+ }),
74
+ master: Object.freeze({ gain: 0.72, limiter: true, transitionSeconds: 1.8 }),
75
+ snapshots: Object.freeze({
76
+ calm: Object.freeze({
77
+ buses: Object.freeze({ detail: Object.freeze({ gain: 0.42 }), music: Object.freeze({ gain: 0.28 }) }),
78
+ layers: Object.freeze({ drone: Object.freeze({ gain: 0.055 }), wildlife: Object.freeze({ gain: 0.08 }) }),
79
+ }),
80
+ dramatic: Object.freeze({
81
+ buses: Object.freeze({ detail: Object.freeze({ gain: 0.76 }), music: Object.freeze({ gain: 0.62 }) }),
82
+ layers: Object.freeze({ drone: Object.freeze({ gain: 0.16 }), wildlife: Object.freeze({ gain: 0.18 }) }),
83
+ }),
84
+ }),
85
+ transport: Object.freeze({ bpm: 88 }),
86
+ });
87
+
88
+ const BASE_PRESETS = new Map();
89
+
90
+ function clamp(value, min, max, fallback) {
91
+ const number = Number(value);
92
+ return Number.isFinite(number) ? Math.min(Math.max(number, min), max) : fallback;
93
+ }
94
+
95
+ function integer(value, min, max, fallback) {
96
+ return Math.round(clamp(value, min, max, fallback));
97
+ }
98
+
99
+ function plain(value) {
100
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
101
+ }
102
+
103
+ function normalizeId(value) {
104
+ return String(value ?? '').trim().replace(/[^a-zA-Z0-9._/-]+/g, '_');
105
+ }
106
+
107
+ function sanitizeBus(input = {}) {
108
+ const source = plain(input);
109
+ return {
110
+ ...cloneSerializable(source),
111
+ gain: clamp(source.gain, 0, 2, 1),
112
+ mute: Boolean(source.mute),
113
+ };
114
+ }
115
+
116
+ function sanitizeLayer(input = {}, id = 'layer') {
117
+ const source = plain(input);
118
+ return {
119
+ ...cloneSerializable(source),
120
+ bus: String(source.bus || 'ambience'),
121
+ enabled: source.enabled !== false,
122
+ gain: clamp(source.gain, 0, 2, 0.25),
123
+ id,
124
+ pan: clamp(source.pan, -1, 1, 0),
125
+ params: cloneSerializable(plain(source.params)),
126
+ playbackRate: clamp(source.playbackRate, 0.05, 8, 1),
127
+ type: String(source.type || 'noise'),
128
+ voiceLimit: integer(source.voiceLimit, 1, 128, 1),
129
+ };
130
+ }
131
+
132
+ function sanitizeAdaptive(input = {}) {
133
+ const source = plain(input);
134
+ const values = Object.fromEntries(Object.entries(plain(source.values)).map(([id, value]) => (
135
+ [id, clamp(value, 0, 1, 0)]
136
+ )));
137
+ const mappings = {};
138
+ for (const [parameter, entries] of Object.entries(plain(source.mappings))) {
139
+ if (!Array.isArray(entries)) continue;
140
+ mappings[parameter] = entries
141
+ .filter((entry) => plain(entry).path)
142
+ .map((entry) => ({
143
+ curve: clamp(entry.curve, 0.05, 8, 1),
144
+ from: clamp(entry.from, -16, 16, 0),
145
+ mode: ['add', 'multiply', 'replace'].includes(entry.mode) ? entry.mode : 'replace',
146
+ path: String(entry.path),
147
+ to: clamp(entry.to, -16, 16, 1),
148
+ }));
149
+ }
150
+ return {
151
+ ...cloneSerializable(source),
152
+ mappings,
153
+ smoothing: clamp(source.smoothing, 0, 10, 0.35),
154
+ values,
155
+ };
156
+ }
157
+
158
+ export function applySoundscapeQualityBudget(settings, quality = 'balanced', override = {}) {
159
+ const tier = SOUNDSCAPE_QUALITY_BUDGETS[quality] ?? SOUNDSCAPE_QUALITY_BUDGETS.balanced;
160
+ const requested = plain(settings?.budget);
161
+ const explicit = plain(override);
162
+ const maxLayers = Math.min(integer(requested.maxLayers, 1, 1024, tier.maxLayers), tier.maxLayers, integer(explicit.maxLayers, 1, 1024, tier.maxLayers));
163
+ const maxNodes = Math.min(integer(requested.maxNodes, 2, 8192, tier.maxNodes), tier.maxNodes, integer(explicit.maxNodes, 2, 8192, tier.maxNodes));
164
+ const maxVoices = Math.min(integer(requested.maxVoices, 1, 2048, tier.maxVoices), tier.maxVoices, integer(explicit.maxVoices, 1, 2048, tier.maxVoices));
165
+ const layers = {};
166
+ let accepted = 0;
167
+ for (const [id, layer] of Object.entries(plain(settings?.layers))) {
168
+ if (accepted >= maxLayers) break;
169
+ layers[id] = { ...layer, voiceLimit: Math.min(layer.voiceLimit, maxVoices) };
170
+ accepted += 1;
171
+ }
172
+ return {
173
+ ...settings,
174
+ budget: { maxLayers, maxNodes, maxVoices },
175
+ layers,
176
+ quality: SOUNDSCAPE_QUALITY_BUDGETS[quality] ? quality : 'balanced',
177
+ };
178
+ }
179
+
180
+ export function sanitizeSoundscapeSettings(input = {}, { budget, quality } = {}) {
181
+ const source = input?.settings ? plain(input.settings) : plain(input);
182
+ const buses = Object.fromEntries(Object.entries(plain(source.buses)).map(([id, value]) => [id, sanitizeBus(value)]));
183
+ const layers = Object.fromEntries(Object.entries(plain(source.layers)).map(([id, value]) => [id, sanitizeLayer(value, id)]));
184
+ const settings = {
185
+ ...cloneSerializable(source),
186
+ adaptive: sanitizeAdaptive(source.adaptive),
187
+ budget: {
188
+ maxLayers: integer(source.budget?.maxLayers, 1, 1024, 8),
189
+ maxNodes: integer(source.budget?.maxNodes, 2, 8192, 64),
190
+ maxVoices: integer(source.budget?.maxVoices, 1, 2048, 24),
191
+ },
192
+ buses,
193
+ layers,
194
+ master: {
195
+ ...cloneSerializable(plain(source.master)),
196
+ gain: clamp(source.master?.gain, 0, 2, 0.72),
197
+ limiter: source.master?.limiter !== false,
198
+ transitionSeconds: clamp(source.master?.transitionSeconds, 0, 60, 1.8),
199
+ },
200
+ snapshots: cloneSerializable(plain(source.snapshots)),
201
+ transport: {
202
+ ...cloneSerializable(plain(source.transport)),
203
+ bpm: clamp(source.transport?.bpm, 20, 400, 88),
204
+ },
205
+ };
206
+ return quality || budget ? applySoundscapeQualityBudget(settings, quality ?? 'balanced', budget) : settings;
207
+ }
208
+
209
+ export function registerSoundscapeBasePreset(id, settings, { overwrite = false } = {}) {
210
+ const key = normalizeId(id);
211
+ if (!key) throw new Error('Soundscape base preset id is required.');
212
+ if (!overwrite && BASE_PRESETS.has(key)) throw new Error(`Soundscape base preset "${key}" already exists.`);
213
+ BASE_PRESETS.set(key, sanitizeSoundscapeSettings(deepMerge(DEFAULT_SETTINGS, settings)));
214
+ return key;
215
+ }
216
+
217
+ export function getSoundscapeBasePresetOptions() {
218
+ return [...BASE_PRESETS.keys()];
219
+ }
220
+
221
+ export function createSoundscapeSettings(input = {}, options = {}) {
222
+ const source = typeof input === 'string' ? { preset: input } : plain(input);
223
+ const presetId = source.preset ?? source.basePreset ?? 'living-landscape';
224
+ const base = BASE_PRESETS.get(presetId) ?? BASE_PRESETS.get('living-landscape') ?? DEFAULT_SETTINGS;
225
+ const override = source.settings ? source.settings : source;
226
+ const merged = deepMerge(base, override);
227
+ delete merged.preset;
228
+ delete merged.basePreset;
229
+ return sanitizeSoundscapeSettings(merged, options);
230
+ }
231
+
232
+ export function validateSoundscapePresetDocument(input) {
233
+ const errors = [];
234
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
235
+ return { errors: ['Soundscape preset must be a JSON object.'], ok: false, value: null, warnings: [] };
236
+ }
237
+ if (input.type !== SOUNDSCAPE_PRESET_TYPE) errors.push(`Soundscape preset type must be "${SOUNDSCAPE_PRESET_TYPE}".`);
238
+ const version = Number(input.version ?? 1);
239
+ if (!Number.isInteger(version) || version < 1 || version > SOUNDSCAPE_PRESET_VERSION) errors.push(`Unsupported soundscape preset version ${input.version}.`);
240
+ const id = normalizeId(input.id);
241
+ if (!id) errors.push('Soundscape preset id is required.');
242
+ return {
243
+ errors,
244
+ ok: errors.length === 0,
245
+ value: errors.length ? null : {
246
+ description: String(input.description ?? ''),
247
+ id,
248
+ label: String(input.label || id),
249
+ settings: sanitizeSoundscapeSettings(input.settings),
250
+ type: SOUNDSCAPE_PRESET_TYPE,
251
+ version: SOUNDSCAPE_PRESET_VERSION,
252
+ },
253
+ warnings: [],
254
+ };
255
+ }
256
+
257
+ export function createSoundscapePresetDocument(id, definition = {}) {
258
+ const result = validateSoundscapePresetDocument({
259
+ description: definition.description ?? '',
260
+ id,
261
+ label: definition.label ?? id,
262
+ settings: definition.settings ?? definition,
263
+ type: SOUNDSCAPE_PRESET_TYPE,
264
+ version: SOUNDSCAPE_PRESET_VERSION,
265
+ });
266
+ if (!result.ok) throw new Error(result.errors.join(' '));
267
+ return result.value;
268
+ }
269
+
270
+ export function parseSoundscapePresetDocument(input) {
271
+ return parsePresetDocument(input, validateSoundscapePresetDocument, { invalidJsonLabel: 'soundscape preset' });
272
+ }
273
+
274
+ export function serializeSoundscapePresetDocument(idOrDocument, definition = {}, { pretty = true } = {}) {
275
+ const document = idOrDocument && typeof idOrDocument === 'object'
276
+ ? createSoundscapePresetDocument(idOrDocument.id, idOrDocument)
277
+ : createSoundscapePresetDocument(idOrDocument, definition);
278
+ return stableStringify(document, pretty ? 2 : 0);
279
+ }
280
+
281
+ registerSoundscapeBasePreset('living-landscape', DEFAULT_SETTINGS);
282
+ registerSoundscapeBasePreset('minimal-focus', deepMerge(DEFAULT_SETTINGS, {
283
+ adaptive: { values: { intensity: 0.25, tension: 0.05, weather: 0 } },
284
+ buses: { detail: { gain: 0.24 }, music: { gain: 0.2 } },
285
+ layers: {
286
+ air: { gain: 0.16 },
287
+ drone: { gain: 0.035 },
288
+ rustle: { enabled: false },
289
+ wildlife: { gain: 0.035, params: { density: 0.08 } },
290
+ },
291
+ master: { gain: 0.58 },
292
+ }));
@@ -0,0 +1,13 @@
1
+ export {
2
+ createStyleBundleDocument,
3
+ DEFAULT_STYLE_BUNDLE_BASE_URL,
4
+ fetchStyleBundle,
5
+ parseStyleBundleDocument,
6
+ resolveStyleBundleSettings,
7
+ serializeStyleBundle,
8
+ STYLE_BUNDLE_DOCUMENT_TYPE,
9
+ STYLE_BUNDLE_SCHEMA_VERSION,
10
+ STYLE_BUNDLE_SLOT_IDS,
11
+ STYLE_BUNDLE_SLOTS,
12
+ validateStyleBundleDocument,
13
+ } from './styleBundle.js';
@@ -0,0 +1,325 @@
1
+ // Style bundles — ONE named document that mixes and matches a preset per
2
+ // system ("slot"): the toon look from one author, trees, grass, flowers and
3
+ // water from others, sky, weather, environment, lighting and post on top.
4
+ // Labs and games reference a bundle
5
+ // (a local JSON or a published toonlab.io slug) and get every system's
6
+ // resolved settings in one call:
7
+ //
8
+ // import { fetchStyleBundle } from '@call-me-sensei/toonlab/styles';
9
+ // const { settings } = await fetchStyleBundle('sakura-dusk');
10
+ // applyToonShader(character, { settings: settings.toon });
11
+ // createWaterSurface({ settings: settings.water, ... });
12
+ //
13
+ // Each slot holds either { preset: '<built-in id>' } or { document: {…} } (a
14
+ // full inline preset document of that system, self-contained). Publishing on
15
+ // toonlab.io resolves any by-reference slots into inline documents, so a
16
+ // fetched bundle always "has all the right ones" with zero further lookups.
17
+
18
+ import {
19
+ createToonSettings,
20
+ parseToonPresetDocument,
21
+ TOON_PRESET_DOCUMENT_TYPE,
22
+ } from '../toon/toonSettings.js';
23
+ import {
24
+ createWaterSettings,
25
+ parseWaterPresetDocument,
26
+ WATER_PRESET_DOCUMENT_TYPE,
27
+ } from '../water/waterSettings.js';
28
+ import { createWeatherSettings } from '../weather/weatherSettings.js';
29
+ import {
30
+ parseWeatherPresetDocument,
31
+ WEATHER_PRESET_DOCUMENT_TYPE,
32
+ } from '../weather/weatherPresets.js';
33
+ import { createEnvironmentSettings } from '../environment/environmentMaterialAdapter.js';
34
+ import {
35
+ ENVIRONMENT_PRESET_DOCUMENT_TYPE,
36
+ resolveEnvironmentPreset,
37
+ validateEnvironmentPresetDocument,
38
+ } from '../environment/environmentPresets.js';
39
+ import { createPostProcessingSettings } from '../post/postProcessing.js';
40
+ import {
41
+ createSkySettings,
42
+ parseSkyPresetDocument,
43
+ SKY_PRESET_DOCUMENT_TYPE,
44
+ } from '../sky/stylizedSky.js';
45
+ import {
46
+ createGrassSettings,
47
+ GRASS_PRESET_DOCUMENT_TYPE,
48
+ parseGrassPresetDocument,
49
+ } from '../vegetation/stylizedGrass.js';
50
+ import { createFlowerSettings } from '../vegetation/stylizedFlowers.js';
51
+ import {
52
+ createVegetationShaderSettings,
53
+ parseVegetationShaderPresetDocument,
54
+ VEGETATION_SHADER_DOCUMENT_TYPE,
55
+ } from '../vegetation/vegetationShaders.js';
56
+ import {
57
+ TREE_RECIPE_SCHEMA,
58
+ validateTreeRecipeDocument,
59
+ } from '../vegetation/treeRecipe.js';
60
+ import { resolveLightingLookPreset } from '../lighting/lightingPresets.js';
61
+
62
+ export const STYLE_BUNDLE_DOCUMENT_TYPE = 'toonlab/style-bundle';
63
+ export const STYLE_BUNDLE_SCHEMA_VERSION = 1;
64
+ export const DEFAULT_STYLE_BUNDLE_BASE_URL = 'https://toonlab.io';
65
+
66
+ /**
67
+ * The bundle's slots: one per system, each resolving to that system's
68
+ * settings object. `documentType` is the inline preset document type the
69
+ * slot accepts (null = preset-id only), `resolve` turns the slot payload
70
+ * into ready settings.
71
+ */
72
+ export const STYLE_BUNDLE_SLOTS = Object.freeze({
73
+ toon: Object.freeze({
74
+ documentType: TOON_PRESET_DOCUMENT_TYPE,
75
+ label: 'Character toon shading',
76
+ parseDocument: parseToonPresetDocument,
77
+ resolve: (payload) => createToonSettings(payload),
78
+ }),
79
+ tree: Object.freeze({
80
+ documentType: TREE_RECIPE_SCHEMA,
81
+ label: 'Trees',
82
+ // Tree recipes carry { type, options } (not .settings) and resolve to
83
+ // the recipe document itself — hand it to createPlantFromRecipe(
84
+ // settings.tree). No built-in preset ids: fill by document or creation.
85
+ parseDocument: validateTreeRecipeDocument,
86
+ resolve: (payload) => {
87
+ if (payload.document) return payload.document;
88
+ throw new Error(
89
+ `Style bundle slot "tree" has no built-in preset "${payload.preset}" — inline a tree recipe document or reference a saved recipe.`,
90
+ );
91
+ },
92
+ }),
93
+ grass: Object.freeze({
94
+ documentType: GRASS_PRESET_DOCUMENT_TYPE,
95
+ label: 'Grass',
96
+ parseDocument: parseGrassPresetDocument,
97
+ resolve: (payload) => createGrassSettings(payload.document?.settings ?? payload),
98
+ }),
99
+ flowers: Object.freeze({
100
+ documentType: null,
101
+ label: 'Flowers',
102
+ parseDocument: null,
103
+ resolve: (payload) => createFlowerSettings(payload),
104
+ }),
105
+ vegetationShader: Object.freeze({
106
+ documentType: VEGETATION_SHADER_DOCUMENT_TYPE,
107
+ label: 'Vegetation shader',
108
+ parseDocument: parseVegetationShaderPresetDocument,
109
+ resolve: (payload) => createVegetationShaderSettings(
110
+ payload.document?.settings ?? payload,
111
+ ),
112
+ }),
113
+ water: Object.freeze({
114
+ documentType: WATER_PRESET_DOCUMENT_TYPE,
115
+ label: 'Water',
116
+ parseDocument: parseWaterPresetDocument,
117
+ resolve: (payload) => createWaterSettings(payload.document?.settings ?? payload),
118
+ }),
119
+ sky: Object.freeze({
120
+ documentType: SKY_PRESET_DOCUMENT_TYPE,
121
+ label: 'Sky',
122
+ parseDocument: parseSkyPresetDocument,
123
+ resolve: (payload) => createSkySettings(payload.document?.settings ?? payload),
124
+ }),
125
+ weather: Object.freeze({
126
+ documentType: WEATHER_PRESET_DOCUMENT_TYPE,
127
+ label: 'Weather',
128
+ parseDocument: parseWeatherPresetDocument,
129
+ resolve: (payload) => createWeatherSettings(payload.document?.settings ?? payload),
130
+ }),
131
+ environment: Object.freeze({
132
+ documentType: ENVIRONMENT_PRESET_DOCUMENT_TYPE,
133
+ label: 'Environment',
134
+ // Environment preset documents carry { features, parameters } at the
135
+ // validated top level (not .settings like toon/water/weather).
136
+ parseDocument: (input) => validateEnvironmentPresetDocument(input),
137
+ resolve: (payload) => {
138
+ if (payload.document) {
139
+ const parsed = validateEnvironmentPresetDocument(payload.document);
140
+ if (!parsed.ok) throw new Error(parsed.errors.join(' '));
141
+ return createEnvironmentSettings({
142
+ features: parsed.value.features,
143
+ parameters: parsed.value.parameters,
144
+ });
145
+ }
146
+ const preset = resolveEnvironmentPreset(payload.preset);
147
+ return createEnvironmentSettings({ features: preset.features, parameters: preset.parameters });
148
+ },
149
+ }),
150
+ lighting: Object.freeze({
151
+ documentType: null,
152
+ label: 'Lighting',
153
+ parseDocument: null,
154
+ // Resolves to a lighting-look document (rig recipe + quality +
155
+ // environment/post hints) — apply through the lighting runtime.
156
+ resolve: (payload) => resolveLightingLookPreset(payload.preset),
157
+ }),
158
+ post: Object.freeze({
159
+ documentType: null,
160
+ label: 'Post processing',
161
+ parseDocument: null,
162
+ resolve: (payload) => createPostProcessingSettings(payload),
163
+ }),
164
+ });
165
+
166
+ export const STYLE_BUNDLE_SLOT_IDS = Object.freeze(Object.keys(STYLE_BUNDLE_SLOTS));
167
+
168
+ function isPlainObject(value) {
169
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
170
+ }
171
+
172
+ /**
173
+ * Validate a style-bundle document. Returns { ok: true, value } with a
174
+ * normalized copy (unknown slots dropped, empty slots removed) or
175
+ * { ok: false, errors }.
176
+ */
177
+ export function validateStyleBundleDocument(input) {
178
+ const errors = [];
179
+ const document = typeof input === 'string' ? tryParseJson(input, errors) : input;
180
+ if (!isPlainObject(document)) {
181
+ return { errors: errors.length ? errors : ['Style bundle must be a JSON object.'], ok: false };
182
+ }
183
+ if (document.schema !== STYLE_BUNDLE_DOCUMENT_TYPE) {
184
+ errors.push(`Expected schema "${STYLE_BUNDLE_DOCUMENT_TYPE}".`);
185
+ }
186
+ if (document.version !== STYLE_BUNDLE_SCHEMA_VERSION) {
187
+ errors.push(`Unsupported style bundle version ${document.version}.`);
188
+ }
189
+ const label = typeof document.label === 'string' ? document.label.trim() : '';
190
+ if (!label) errors.push('Style bundle needs a label.');
191
+
192
+ const slots = {};
193
+ const slotsInput = isPlainObject(document.slots) ? document.slots : {};
194
+ for (const [slotId, payload] of Object.entries(slotsInput)) {
195
+ const slot = STYLE_BUNDLE_SLOTS[slotId];
196
+ if (!slot) continue; // forward compatibility: ignore unknown slots
197
+ if (payload == null) continue;
198
+ if (!isPlainObject(payload)) {
199
+ errors.push(`Slot "${slotId}" must be an object.`);
200
+ continue;
201
+ }
202
+ const hasPreset = typeof payload.preset === 'string' && payload.preset.trim() !== '';
203
+ const hasDocument = isPlainObject(payload.document);
204
+ const hasCreation = typeof payload.creation === 'string' && payload.creation.trim() !== '';
205
+ if (!hasPreset && !hasDocument && !hasCreation) {
206
+ errors.push(`Slot "${slotId}" needs { preset }, { document } or { creation }.`);
207
+ continue;
208
+ }
209
+ if (hasDocument && slot.parseDocument) {
210
+ const parsed = slot.parseDocument(payload.document);
211
+ if (!parsed.ok) {
212
+ errors.push(`Slot "${slotId}" document: ${parsed.errors.join(' ')}`);
213
+ continue;
214
+ }
215
+ // Keep the RAW document — validation stays idempotent and serialized
216
+ // bundles round-trip byte-identical slot payloads.
217
+ slots[slotId] = { document: payload.document };
218
+ continue;
219
+ }
220
+ if (hasDocument && !slot.parseDocument) {
221
+ errors.push(`Slot "${slotId}" only accepts { preset } payloads.`);
222
+ continue;
223
+ }
224
+ slots[slotId] = hasPreset
225
+ ? { preset: payload.preset.trim() }
226
+ : { creation: payload.creation.trim() };
227
+ }
228
+
229
+ if (errors.length) return { errors, ok: false };
230
+ return {
231
+ ok: true,
232
+ value: {
233
+ description: typeof document.description === 'string' ? document.description : '',
234
+ id: typeof document.id === 'string' && document.id ? document.id : slugify(label),
235
+ label,
236
+ schema: STYLE_BUNDLE_DOCUMENT_TYPE,
237
+ slots,
238
+ version: STYLE_BUNDLE_SCHEMA_VERSION,
239
+ },
240
+ };
241
+ }
242
+
243
+ function tryParseJson(text, errors) {
244
+ try {
245
+ return JSON.parse(text);
246
+ } catch {
247
+ errors.push('Style bundle is not valid JSON.');
248
+ return null;
249
+ }
250
+ }
251
+
252
+ function slugify(value) {
253
+ return String(value ?? '')
254
+ .toLowerCase()
255
+ .replace(/[^a-z0-9]+/g, '-')
256
+ .replace(/^-+|-+$/g, '') || 'style-bundle';
257
+ }
258
+
259
+ export function createStyleBundleDocument(id, { description = '', label, slots = {} } = {}) {
260
+ const result = validateStyleBundleDocument({
261
+ description,
262
+ id,
263
+ label: label ?? id,
264
+ schema: STYLE_BUNDLE_DOCUMENT_TYPE,
265
+ slots,
266
+ version: STYLE_BUNDLE_SCHEMA_VERSION,
267
+ });
268
+ if (!result.ok) throw new Error(result.errors.join(' '));
269
+ return result.value;
270
+ }
271
+
272
+ export function serializeStyleBundle(document, { pretty = true } = {}) {
273
+ const result = validateStyleBundleDocument(document);
274
+ if (!result.ok) throw new Error(result.errors.join(' '));
275
+ return JSON.stringify(result.value, null, pretty ? 2 : 0);
276
+ }
277
+
278
+ export function parseStyleBundleDocument(input) {
279
+ return validateStyleBundleDocument(input);
280
+ }
281
+
282
+ /**
283
+ * Resolve a validated bundle into per-system settings objects:
284
+ * { toon?, tree?, grass?, flowers?, vegetationShader?, water?, sky?, weather?,
285
+ * environment?, lighting?, post? } — each ready to hand to the matching apply/create call
286
+ * (settings.tree is a recipe for createPlantFromRecipe, settings.lighting a
287
+ * lighting-look document). Unresolved by-reference slots
288
+ * ({ creation }) throw: fetch the bundle from toonlab.io (which inlines
289
+ * references) or inline the documents first.
290
+ */
291
+ export function resolveStyleBundleSettings(document) {
292
+ const result = validateStyleBundleDocument(document);
293
+ if (!result.ok) throw new Error(result.errors.join(' '));
294
+ const settings = {};
295
+ for (const [slotId, payload] of Object.entries(result.value.slots)) {
296
+ const slot = STYLE_BUNDLE_SLOTS[slotId];
297
+ if (payload.creation) {
298
+ throw new Error(
299
+ `Style bundle slot "${slotId}" references creation "${payload.creation}" — fetch the published bundle (references resolve server-side) or inline the document.`,
300
+ );
301
+ }
302
+ settings[slotId] = slot.resolve(payload);
303
+ }
304
+ return settings;
305
+ }
306
+
307
+ /**
308
+ * Load a bundle by published slug (toonlab.io) or URL and resolve it.
309
+ * const { document, settings } = await fetchStyleBundle('sakura-dusk');
310
+ */
311
+ export async function fetchStyleBundle(ref, {
312
+ baseUrl = DEFAULT_STYLE_BUNDLE_BASE_URL,
313
+ fetchImpl = globalThis.fetch,
314
+ } = {}) {
315
+ const url = /^https?:\/\//.test(ref)
316
+ ? ref
317
+ : `${baseUrl.replace(/\/+$/, '')}/api/v1/bundles/${encodeURIComponent(ref)}`;
318
+ const response = await fetchImpl(url, { headers: { accept: 'application/json' } });
319
+ if (!response.ok) throw new Error(`Style bundle "${ref}" → HTTP ${response.status}`);
320
+ const body = await response.json();
321
+ const documentInput = body.bundle ?? body;
322
+ const result = validateStyleBundleDocument(documentInput);
323
+ if (!result.ok) throw new Error(`Style bundle "${ref}": ${result.errors.join(' ')}`);
324
+ return { document: result.value, settings: resolveStyleBundleSettings(result.value) };
325
+ }
@@ -1,5 +1,7 @@
1
1
  import * as THREE from 'three';
2
2
 
3
+ import { deepMerge } from './core/generation.js';
4
+
3
5
  // Seeded stylized-terrain generator. Import from '@call-me-sensei/toonlab'.
4
6
  //
5
7
  // The randomization contract: ANY seed yields a valid, playable world.
@@ -104,6 +106,27 @@ export function getTerrainArchetypeOptions() {
104
106
  return Array.from(TERRAIN_ARCHETYPES.entries()).map(([id, a]) => ({ id, label: a.label }));
105
107
  }
106
108
 
109
+ /**
110
+ * Registers an extensible terrain starting point. The definition is merged
111
+ * over `options.extends` (rollingPlains by default), so plugins can add new
112
+ * morphology families without modifying ToonLab or choosing from a fixed
113
+ * built-in catalog.
114
+ */
115
+ export function registerTerrainArchetype(id, definition = {}, options = {}) {
116
+ const key = String(id ?? '').trim();
117
+ if (!key) throw new Error('Terrain archetype id is required.');
118
+ if (!options.overwrite && TERRAIN_ARCHETYPES.has(key)) {
119
+ throw new Error(`Terrain archetype "${key}" already exists.`);
120
+ }
121
+ const parentId = options.extends ?? 'rollingPlains';
122
+ const parent = TERRAIN_ARCHETYPES.get(parentId);
123
+ if (!parent) throw new Error(`Unknown parent terrain archetype "${parentId}".`);
124
+ const next = deepMerge(parent, definition);
125
+ next.label = String(definition.label || key);
126
+ TERRAIN_ARCHETYPES.set(key, next);
127
+ return { id: key, label: next.label };
128
+ }
129
+
107
130
  const DEFAULT_PALETTE = {
108
131
  golden: 0xd2b24c,
109
132
  haze: 0xa9c6e8,
@@ -206,6 +229,10 @@ export function createStylizedTerrain({
206
229
  seed = 1,
207
230
  size = 1000,
208
231
  archetype = 'terracedKarst',
232
+ // Continuous morphology overrides turn archetypes into starting points,
233
+ // not a closed catalog. Any nested continent/mountains/rolling/rim/
234
+ // terraces value may be replaced by a generated biome recipe.
235
+ morphology = null,
209
236
  waterCoverage = null, // 0..0.6; default from the archetype
210
237
  height = null, // H: mountain amplitude override (m)
211
238
  depth = null, // D: basin depth below the ground datum (m)
@@ -220,9 +247,12 @@ export function createStylizedTerrain({
220
247
  if (!base) {
221
248
  throw new Error(`Unknown terrain archetype "${archetype}" (have: ${[...TERRAIN_ARCHETYPES.keys()].join(', ')}).`);
222
249
  }
223
- const spec = Number.isFinite(height)
224
- ? { ...base, mountains: { ...base.mountains, amp: Math.max(height, 1) } }
250
+ const customized = morphology && typeof morphology === 'object'
251
+ ? deepMerge(base, morphology)
225
252
  : base;
253
+ const spec = Number.isFinite(height)
254
+ ? { ...customized, mountains: { ...customized.mountains, amp: Math.max(height, 1) } }
255
+ : customized;
226
256
  const coverage = Math.min(Math.max(
227
257
  Number.isFinite(waterCoverage) ? waterCoverage : spec.waterCoverage, 0), 0.6);
228
258
  const sizeX = Number.isFinite(size?.x) ? size.x : Number(size) || 1000;