@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.
- package/AGENTS.md +127 -4
- package/ATTRIBUTION.md +41 -3
- package/README.md +234 -34
- package/docs/characters.md +144 -0
- package/docs/debug-panel.md +126 -0
- package/docs/docs.css +589 -0
- package/docs/environment.md +186 -0
- package/docs/getting-started.md +180 -0
- package/docs/index.html +14 -0
- package/docs/lab-architecture.md +100 -0
- package/docs/lighting.md +825 -0
- package/docs/main.jsx +739 -0
- package/docs/mcp.md +97 -0
- package/docs/post-processing.md +98 -0
- package/docs/settings-reference.md +1878 -0
- package/docs/shader-constants.md +77 -0
- package/docs/sky.md +182 -0
- package/docs/style-labs.md +309 -0
- package/docs/texture-lab.md +135 -0
- package/docs/toon-shading.md +183 -0
- package/docs/tsl-conventions.md +167 -0
- package/docs/vegetation-sky.md +275 -0
- package/docs/water.md +430 -0
- package/docs/weather.md +200 -0
- package/docs/world-scale.md +111 -0
- package/mcp/server.mjs +610 -0
- package/mcp/style-lab-tools.mjs +371 -0
- package/mcp/vite-plugin.mjs +174 -0
- package/mcp/workspace.mjs +397 -0
- package/package.json +64 -5
- package/src/ambientfx/INTEGRATION.md +164 -0
- package/src/ambientfx/ambientFxPresets.js +83 -0
- package/src/ambientfx/ambientFxSettings.js +368 -0
- package/src/ambientfx/emitters.js +169 -0
- package/src/ambientfx/index.js +5 -0
- package/src/ambientfx/particleBackbone.js +493 -0
- package/src/ambientfx/stylizedAmbientFx.js +450 -0
- package/src/assetlib/ambientcg.js +117 -0
- package/src/assetlib/assetRef.js +91 -0
- package/src/assetlib/importedEntry.js +40 -0
- package/src/assetlib/index.js +23 -0
- package/src/assetlib/kaykit.js +192 -0
- package/src/assetlib/kaykitStaticIndex.js +700 -0
- package/src/assetlib/loadImported.js +143 -0
- package/src/assetlib/opensource3d.js +113 -0
- package/src/assetlib/polyhaven.js +182 -0
- package/src/assetlib/polypizza.js +115 -0
- package/src/assetlib/smithsonian.js +214 -0
- package/src/assetlib/sources.js +279 -0
- package/src/assetlib/zip.js +58 -0
- package/src/biome/biomeGenerator.js +385 -0
- package/src/biome/biomeRuntime.js +299 -0
- package/src/biome/index.js +2 -0
- package/src/buildinggen/buildingAsset.js +44 -0
- package/src/buildinggen/buildingGrammar.js +311 -0
- package/src/buildinggen/buildingMesh.js +451 -0
- package/src/buildinggen/buildingPresets.js +46 -0
- package/src/buildinggen/buildingRecipe.js +100 -0
- package/src/buildinggen/buildingSettings.js +238 -0
- package/src/buildinggen/index.js +6 -0
- package/src/camera/cameraDirector.js +157 -0
- package/src/camera/cameraGenerator.js +367 -0
- package/src/camera/cameraRig.js +570 -0
- package/src/camera/cameraSettings.js +236 -0
- package/src/camera/index.js +7 -0
- package/src/catalog/builtinEntries.js +245 -0
- package/src/catalog/catalog.js +210 -0
- package/src/catalog/index.js +3 -0
- package/src/catalog/manifest.js +84 -0
- package/src/core/generation.js +529 -0
- package/src/environment/environmentRigs.js +21 -1
- package/src/environment/environmentSettings.js +4 -0
- package/src/environment/environmentSunShadowPass.js +8 -0
- package/src/fauna/INTEGRATION.md +174 -0
- package/src/fauna/boids.js +861 -0
- package/src/fauna/faunaBodies.js +492 -0
- package/src/fauna/faunaPresets.js +52 -0
- package/src/fauna/faunaSettings.js +525 -0
- package/src/fauna/index.js +5 -0
- package/src/fauna/stylizedFauna.js +395 -0
- package/src/game-feel/gameFeelGenerator.js +402 -0
- package/src/game-feel/gameFeelRuntime.js +549 -0
- package/src/game-feel/index.js +2 -0
- package/src/index.js +17 -6
- package/src/lighting/colorIntensity.js +177 -0
- package/src/lighting/index.js +161 -0
- package/src/lighting/lightDescriptors.js +249 -0
- package/src/lighting/lightingCapabilities.js +79 -0
- package/src/lighting/lightingDocuments.js +247 -0
- package/src/lighting/lightingFixtures.js +446 -0
- package/src/lighting/lightingGenerator.js +449 -0
- package/src/lighting/lightingPresets.js +319 -0
- package/src/lighting/lightingRuntime.js +723 -0
- package/src/lighting/lightingStyle.js +386 -0
- package/src/lighting/lightingSystem.js +774 -0
- package/src/lighting/unrealExport.js +186 -0
- package/src/lighting/utils.js +87 -0
- package/src/motion/index.js +5 -0
- package/src/motion/motionClip.js +441 -0
- package/src/motion/motionController.js +628 -0
- package/src/motion/motionDocuments.js +225 -0
- package/src/motion/motionGraph.js +307 -0
- package/src/motion/motionSettings.js +222 -0
- package/src/pathgen/index.js +7 -0
- package/src/pathgen/pathBridge.js +232 -0
- package/src/pathgen/pathPresets.js +35 -0
- package/src/pathgen/pathRibbon.js +410 -0
- package/src/pathgen/pathRouter.js +380 -0
- package/src/pathgen/pathSettings.js +335 -0
- package/src/pathgen/pathTextures.js +123 -0
- package/src/pathgen/stylizedPaths.js +453 -0
- package/src/post/index.js +1 -0
- package/src/post/postGenerator.js +177 -0
- package/src/post/postProcessing.js +41 -0
- package/src/propgen/generatorsWave1.js +379 -0
- package/src/propgen/generatorsWave2.js +462 -0
- package/src/propgen/index.js +5 -0
- package/src/propgen/propAsset.js +323 -0
- package/src/propgen/propParts.js +170 -0
- package/src/propgen/propPlacement.js +459 -0
- package/src/propgen/propPresets.js +82 -0
- package/src/propgen/propSettings.js +395 -0
- package/src/shaders-tsl/chunks/projected-water-caustics.js +242 -0
- package/src/shaders-tsl/chunks/vegetation-style.js +360 -0
- package/src/shaders-tsl/chunks/water-shore-state.js +31 -0
- package/src/shaders-tsl/chunks/water-waves.js +90 -11
- package/src/shaders-tsl/environment.js +15 -1
- package/src/shaders-tsl/flower.js +279 -30
- package/src/shaders-tsl/grass.js +60 -33
- package/src/shaders-tsl/sky.js +125 -31
- package/src/shaders-tsl/tree-leaf.js +61 -25
- package/src/shaders-tsl/water-breaker.js +7 -4
- package/src/shaders-tsl/water-shore-state-simulation.js +523 -0
- package/src/shaders-tsl/water.js +439 -49
- package/src/shaders-tsl/woody-surface.js +154 -0
- package/src/sky/sceneOverrideLayers.js +10 -0
- package/src/sky/skyQuality.js +26 -0
- package/src/sky/stylizedSky.js +753 -45
- package/src/soundscape/index.js +4 -0
- package/src/soundscape/soundscapeGenerator.js +179 -0
- package/src/soundscape/soundscapeRuntime.js +806 -0
- package/src/soundscape/soundscapeSettings.js +292 -0
- package/src/styles/index.js +13 -0
- package/src/styles/styleBundle.js +325 -0
- package/src/stylizedTerrain.js +32 -2
- package/src/stylizedWorld.js +423 -20
- package/src/texgen/evaluateTexture.js +675 -0
- package/src/texgen/index.js +60 -0
- package/src/texgen/noise2.js +210 -0
- package/src/texgen/textureAi.js +436 -0
- package/src/texgen/textureGenerators.js +516 -0
- package/src/texgen/texturePresets.js +490 -0
- package/src/texgen/textureSettings.js +342 -0
- package/src/texgen/textureThree.js +59 -0
- package/src/vegetation/flowerSpecies.js +15 -3
- package/src/vegetation/grassPalettes.js +153 -0
- package/src/vegetation/index.js +6 -0
- package/src/vegetation/stylizedBush.js +2 -0
- package/src/vegetation/stylizedFlower.js +82 -0
- package/src/vegetation/stylizedFlowers.js +48 -7
- package/src/vegetation/stylizedForest.js +29 -1
- package/src/vegetation/stylizedGrass.js +291 -56
- package/src/vegetation/stylizedTree.js +38 -2
- package/src/vegetation/stylizedTreeFoliage.js +2 -1
- package/src/vegetation/vegetationShaders.js +1110 -0
- package/src/vfxgen/INTEGRATION.md +145 -0
- package/src/vfxgen/core/burstBackbone.js +380 -0
- package/src/vfxgen/core/projectileCore.js +92 -0
- package/src/vfxgen/core/spriteShapes.js +98 -0
- package/src/vfxgen/core/trailRibbon.js +272 -0
- package/src/vfxgen/core/vfxRandom.js +29 -0
- package/src/vfxgen/effects/emitHelpers.js +37 -0
- package/src/vfxgen/effects/magicEffects.js +162 -0
- package/src/vfxgen/effects/movementEffects.js +87 -0
- package/src/vfxgen/effects/weaponEffects.js +118 -0
- package/src/vfxgen/index.js +18 -0
- package/src/vfxgen/moves/moveController.js +146 -0
- package/src/vfxgen/moves/moveLibrary.js +335 -0
- package/src/vfxgen/vfxPresets.js +98 -0
- package/src/vfxgen/vfxSettings.js +384 -0
- package/src/vfxgen/vfxSystem.js +449 -0
- package/src/vfxgen/weapons/stylizedWeapons.js +137 -0
- package/src/villagegen/index.js +4 -0
- package/src/villagegen/stylizedVillage.js +490 -0
- package/src/villagegen/villageArchetypes.js +160 -0
- package/src/villagegen/villageNames.js +40 -0
- package/src/villagegen/villageSites.js +105 -0
- package/src/water/sceneOverrideLayers.js +23 -0
- package/src/water/water.js +5 -0
- package/src/water/waterBreakerSystem.js +15 -1
- package/src/water/waterCurrentField.js +447 -0
- package/src/water/waterMaterial.js +12 -0
- package/src/water/waterNearshorePhase.js +320 -0
- package/src/water/waterScenePasses.js +83 -30
- package/src/water/waterSettings.js +325 -13
- package/src/water/waterShoreMaterial.js +322 -0
- package/src/water/waterShoreStateField.js +605 -0
- package/src/water/waterSurface.js +797 -28
- package/src/weather/index.js +6 -0
- package/src/weather/weatherPrecipitation.js +221 -0
- package/src/weather/weatherPresets.js +258 -0
- package/src/weather/weatherSettings.js +269 -0
- package/src/weather/weatherSystem.js +871 -0
- package/src/worldMinimap.js +62 -0
- 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
|
+
}
|
package/src/stylizedTerrain.js
CHANGED
|
@@ -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
|
|
224
|
-
?
|
|
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;
|