@call-me-sensei/toonlab 0.2.0 → 0.3.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.
- package/AGENTS.md +127 -4
- package/ATTRIBUTION.md +41 -3
- package/README.md +231 -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 +752 -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,529 @@
|
|
|
1
|
+
// Shared deterministic generation primitives used by ToonLab runtimes and
|
|
2
|
+
// design-time labs. The package owns these algorithms so a recipe produces
|
|
3
|
+
// the same result in a lab, through MCP, and inside a shipped game.
|
|
4
|
+
|
|
5
|
+
import { parsePresetDocument } from './presetDocuments.js';
|
|
6
|
+
|
|
7
|
+
export const GENERATOR_RECIPE_SCHEMA_VERSION = 1;
|
|
8
|
+
|
|
9
|
+
function isPlainObject(value) {
|
|
10
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
11
|
+
const prototype = Object.getPrototypeOf(value);
|
|
12
|
+
return prototype === Object.prototype || prototype === null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function cloneSerializable(value) {
|
|
16
|
+
if (value === undefined) return undefined;
|
|
17
|
+
// Runtime settings may contain Three.js value objects (Color, Vector*,
|
|
18
|
+
// Matrix*) whose prototypes structuredClone would erase. Preserve their
|
|
19
|
+
// public clone contract while recursively copying JSON-shaped documents.
|
|
20
|
+
if (value && typeof value.clone === 'function') return value.clone();
|
|
21
|
+
if (Array.isArray(value)) return value.map(cloneSerializable);
|
|
22
|
+
if (isPlainObject(value)) {
|
|
23
|
+
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, cloneSerializable(entry)]));
|
|
24
|
+
}
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Stable FNV-1a hash. Strings and numbers are accepted as public seeds. */
|
|
29
|
+
export function hashSeed(value) {
|
|
30
|
+
if (typeof value === 'number' && Number.isFinite(value)) return (value >>> 0) || 1;
|
|
31
|
+
const source = typeof value === 'string' ? value : JSON.stringify(value ?? 1);
|
|
32
|
+
let hash = 0x811c9dc5;
|
|
33
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
34
|
+
hash ^= source.charCodeAt(index);
|
|
35
|
+
hash = Math.imul(hash, 0x01000193);
|
|
36
|
+
}
|
|
37
|
+
return hash >>> 0 || 1;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function deriveSeed(seed, namespace) {
|
|
41
|
+
return hashSeed(`${hashSeed(seed)}:${String(namespace ?? '')}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function mulberry32(seed) {
|
|
45
|
+
let state = seed >>> 0;
|
|
46
|
+
return () => {
|
|
47
|
+
state = (state + 0x6d2b79f5) | 0;
|
|
48
|
+
let value = state;
|
|
49
|
+
value = Math.imul(value ^ (value >>> 15), value | 1);
|
|
50
|
+
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
|
|
51
|
+
return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Creates a deterministic stream. `fork(label)` is derived from the stream's
|
|
57
|
+
* root seed rather than its current cursor, so adding an unrelated field does
|
|
58
|
+
* not perturb existing generated values.
|
|
59
|
+
*/
|
|
60
|
+
export function createSeededRandom(seed = 1, namespace = 'root') {
|
|
61
|
+
const rootSeed = deriveSeed(seed, namespace);
|
|
62
|
+
const nextValue = mulberry32(rootSeed);
|
|
63
|
+
let spareNormal = null;
|
|
64
|
+
|
|
65
|
+
const api = {
|
|
66
|
+
seed: rootSeed,
|
|
67
|
+
next() {
|
|
68
|
+
return nextValue();
|
|
69
|
+
},
|
|
70
|
+
float(min = 0, max = 1) {
|
|
71
|
+
const lo = Number(min);
|
|
72
|
+
const hi = Number(max);
|
|
73
|
+
return lo + (hi - lo) * nextValue();
|
|
74
|
+
},
|
|
75
|
+
int(min = 0, max = 1) {
|
|
76
|
+
const lo = Math.ceil(Number(min));
|
|
77
|
+
const hi = Math.floor(Number(max));
|
|
78
|
+
if (hi <= lo) return lo;
|
|
79
|
+
return lo + Math.floor(nextValue() * (hi - lo + 1));
|
|
80
|
+
},
|
|
81
|
+
bool(probability = 0.5) {
|
|
82
|
+
return nextValue() < Math.min(Math.max(Number(probability), 0), 1);
|
|
83
|
+
},
|
|
84
|
+
normal(mean = 0, deviation = 1) {
|
|
85
|
+
if (spareNormal !== null) {
|
|
86
|
+
const value = spareNormal;
|
|
87
|
+
spareNormal = null;
|
|
88
|
+
return Number(mean) + value * Number(deviation);
|
|
89
|
+
}
|
|
90
|
+
let u = 0;
|
|
91
|
+
let v = 0;
|
|
92
|
+
while (u === 0) u = nextValue();
|
|
93
|
+
while (v === 0) v = nextValue();
|
|
94
|
+
const magnitude = Math.sqrt(-2 * Math.log(u));
|
|
95
|
+
const angle = 2 * Math.PI * v;
|
|
96
|
+
spareNormal = magnitude * Math.sin(angle);
|
|
97
|
+
return Number(mean) + magnitude * Math.cos(angle) * Number(deviation);
|
|
98
|
+
},
|
|
99
|
+
pick(values = []) {
|
|
100
|
+
return values.length > 0 ? values[Math.floor(nextValue() * values.length)] : undefined;
|
|
101
|
+
},
|
|
102
|
+
weighted(options = []) {
|
|
103
|
+
const entries = options.map((entry) => (
|
|
104
|
+
isPlainObject(entry) && Object.hasOwn(entry, 'value')
|
|
105
|
+
? { value: entry.value, weight: Math.max(Number(entry.weight) || 0, 0) }
|
|
106
|
+
: { value: entry, weight: 1 }
|
|
107
|
+
));
|
|
108
|
+
const total = entries.reduce((sum, entry) => sum + entry.weight, 0);
|
|
109
|
+
if (entries.length === 0) return undefined;
|
|
110
|
+
if (total <= 0) return entries[0].value;
|
|
111
|
+
let cursor = nextValue() * total;
|
|
112
|
+
for (const entry of entries) {
|
|
113
|
+
cursor -= entry.weight;
|
|
114
|
+
if (cursor <= 0) return cloneSerializable(entry.value);
|
|
115
|
+
}
|
|
116
|
+
return cloneSerializable(entries.at(-1).value);
|
|
117
|
+
},
|
|
118
|
+
fork(label) {
|
|
119
|
+
return createSeededRandom(rootSeed, label);
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
return api;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function clamp(value, min, max) {
|
|
126
|
+
return Math.min(Math.max(value, min), max);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function quantize(value, step, origin = 0) {
|
|
130
|
+
if (!(Number(step) > 0)) return value;
|
|
131
|
+
return origin + Math.round((value - origin) / step) * step;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function sampleColor(domain, random) {
|
|
135
|
+
const from = Array.isArray(domain.from) ? domain.from : [0, 0, 0];
|
|
136
|
+
const to = Array.isArray(domain.to) ? domain.to : from;
|
|
137
|
+
const shared = domain.linked === true ? random.next() : null;
|
|
138
|
+
return from.map((channel, index) => {
|
|
139
|
+
const amount = shared ?? random.fork(index).next();
|
|
140
|
+
return Number(channel) + (Number(to[index] ?? channel) - Number(channel)) * amount;
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Samples a domain leaf. Domain leaves are explicitly tagged with `$type`. */
|
|
145
|
+
export function sampleDomain(domain, random = createSeededRandom(1)) {
|
|
146
|
+
if (!isPlainObject(domain) || !domain.$type) return cloneSerializable(domain);
|
|
147
|
+
switch (domain.$type) {
|
|
148
|
+
case 'constant':
|
|
149
|
+
return cloneSerializable(domain.value);
|
|
150
|
+
case 'boolean':
|
|
151
|
+
return random.bool(domain.probability ?? 0.5);
|
|
152
|
+
case 'choice':
|
|
153
|
+
return random.weighted(domain.options ?? domain.values ?? []);
|
|
154
|
+
case 'color':
|
|
155
|
+
return sampleColor(domain, random);
|
|
156
|
+
case 'range': {
|
|
157
|
+
const min = Number(domain.min ?? 0);
|
|
158
|
+
const max = Number(domain.max ?? 1);
|
|
159
|
+
let value;
|
|
160
|
+
if (domain.distribution === 'normal') {
|
|
161
|
+
const mean = Number(domain.mean ?? ((min + max) / 2));
|
|
162
|
+
const deviation = Number(domain.deviation ?? ((max - min) / 6));
|
|
163
|
+
value = clamp(random.normal(mean, deviation), min, max);
|
|
164
|
+
} else if (domain.distribution === 'log') {
|
|
165
|
+
const safeMin = Math.max(min, Number.EPSILON);
|
|
166
|
+
const safeMax = Math.max(max, safeMin);
|
|
167
|
+
value = Math.exp(random.float(Math.log(safeMin), Math.log(safeMax)));
|
|
168
|
+
} else {
|
|
169
|
+
value = random.float(min, max);
|
|
170
|
+
}
|
|
171
|
+
if (domain.integer) value = Math.round(value);
|
|
172
|
+
return clamp(quantize(value, domain.step, min), min, max);
|
|
173
|
+
}
|
|
174
|
+
default:
|
|
175
|
+
throw new Error(`Unknown generator domain type "${domain.$type}".`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function getPath(source, path) {
|
|
180
|
+
let value = source;
|
|
181
|
+
for (const key of path) {
|
|
182
|
+
if (!value || typeof value !== 'object') return undefined;
|
|
183
|
+
value = value[key];
|
|
184
|
+
}
|
|
185
|
+
return value;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function isLocked(locks, path) {
|
|
189
|
+
const id = path.join('.');
|
|
190
|
+
return locks.has(id) || [...locks].some((lock) => id.startsWith(`${lock}.`));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Resolves a nested domain tree. Each leaf receives a named path stream, so
|
|
195
|
+
* schema additions are deterministic and backward-friendly.
|
|
196
|
+
*/
|
|
197
|
+
export function generateDomainValues(domains = {}, {
|
|
198
|
+
current = {},
|
|
199
|
+
locks = [],
|
|
200
|
+
seed = 1,
|
|
201
|
+
} = {}) {
|
|
202
|
+
const lockSet = new Set(Array.isArray(locks) ? locks.map(String) : []);
|
|
203
|
+
const rootRandom = createSeededRandom(seed, 'domains');
|
|
204
|
+
|
|
205
|
+
function visit(node, path) {
|
|
206
|
+
if (isPlainObject(node) && node.$type) {
|
|
207
|
+
const existing = getPath(current, path);
|
|
208
|
+
if (isLocked(lockSet, path) && existing !== undefined) return cloneSerializable(existing);
|
|
209
|
+
return sampleDomain(node, rootRandom.fork(path.join('.')));
|
|
210
|
+
}
|
|
211
|
+
if (Array.isArray(node)) return cloneSerializable(node);
|
|
212
|
+
if (!isPlainObject(node)) return cloneSerializable(node);
|
|
213
|
+
return Object.fromEntries(Object.entries(node).map(([key, value]) => [key, visit(value, [...path, key])]));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return visit(domains, []);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function deepMerge(...sources) {
|
|
220
|
+
const output = {};
|
|
221
|
+
for (const source of sources) {
|
|
222
|
+
if (!isPlainObject(source)) continue;
|
|
223
|
+
for (const [key, value] of Object.entries(source)) {
|
|
224
|
+
if (isPlainObject(value) && !value.$type) {
|
|
225
|
+
output[key] = deepMerge(isPlainObject(output[key]) ? output[key] : {}, value);
|
|
226
|
+
} else {
|
|
227
|
+
output[key] = cloneSerializable(value);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return output;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function normalizeForStableJson(value) {
|
|
235
|
+
if (Array.isArray(value)) return value.map(normalizeForStableJson);
|
|
236
|
+
if (!isPlainObject(value)) return value;
|
|
237
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, normalizeForStableJson(value[key])]));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function stableStringify(value, space = 0) {
|
|
241
|
+
return JSON.stringify(normalizeForStableJson(value), null, space);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function hashValue(value) {
|
|
245
|
+
return hashSeed(stableStringify(value)).toString(16).padStart(8, '0');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function normalizeGeneratorId(value) {
|
|
249
|
+
return String(value ?? '').trim().replace(/[^a-zA-Z0-9._/-]+/g, '_');
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function generatorDocumentType(domain) {
|
|
253
|
+
return `toonlab/${String(domain || 'style')}-generator`;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const GENERATOR_DOMAIN_TYPES = new Set(['boolean', 'choice', 'color', 'constant', 'range']);
|
|
257
|
+
|
|
258
|
+
function domainPath(path) {
|
|
259
|
+
return path.length > 0 ? `domains.${path.join('.')}` : 'domains';
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function inspectSerializableLiteral(value, path, errors, stack = new WeakSet()) {
|
|
263
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') return;
|
|
264
|
+
if (typeof value === 'number') {
|
|
265
|
+
if (!Number.isFinite(value)) errors.push(`${path} must contain only finite numbers.`);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (typeof value === 'undefined' || typeof value === 'function'
|
|
269
|
+
|| typeof value === 'symbol' || typeof value === 'bigint') {
|
|
270
|
+
errors.push(`${path} must be JSON-serializable.`);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (typeof value !== 'object') return;
|
|
274
|
+
if (stack.has(value)) {
|
|
275
|
+
errors.push(`${path} cannot contain a circular reference.`);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (!Array.isArray(value) && !isPlainObject(value)) {
|
|
279
|
+
errors.push(`${path} must contain only JSON objects and arrays.`);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
stack.add(value);
|
|
283
|
+
if (Array.isArray(value)) {
|
|
284
|
+
value.forEach((entry, index) => inspectSerializableLiteral(entry, `${path}[${index}]`, errors, stack));
|
|
285
|
+
} else {
|
|
286
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
287
|
+
inspectSerializableLiteral(entry, `${path}.${key}`, errors, stack);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
stack.delete(value);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Validates the shared open-domain grammar before a recipe reaches sampling.
|
|
295
|
+
* This keeps malformed MCP/imported recipes from validating successfully and
|
|
296
|
+
* then failing later inside a lab or runtime generation call.
|
|
297
|
+
*/
|
|
298
|
+
export function validateGeneratorDomains(input) {
|
|
299
|
+
const errors = [];
|
|
300
|
+
const warnings = [];
|
|
301
|
+
if (!isPlainObject(input)) {
|
|
302
|
+
return { errors: ['domains must be a JSON object.'], ok: false, warnings };
|
|
303
|
+
}
|
|
304
|
+
const stack = new WeakSet();
|
|
305
|
+
|
|
306
|
+
function finite(value, path, { optional = false } = {}) {
|
|
307
|
+
if (optional && value === undefined) return null;
|
|
308
|
+
const parsed = Number(value);
|
|
309
|
+
if (!Number.isFinite(parsed)) errors.push(`${path} must be a finite number.`);
|
|
310
|
+
return parsed;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function inspectLeaf(leaf, path) {
|
|
314
|
+
const label = domainPath(path);
|
|
315
|
+
const type = leaf.$type;
|
|
316
|
+
if (typeof type !== 'string' || !GENERATOR_DOMAIN_TYPES.has(type)) {
|
|
317
|
+
errors.push(`${label} has unknown generator domain type "${String(type)}".`);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (type === 'constant') {
|
|
321
|
+
if (!Object.hasOwn(leaf, 'value')) errors.push(`${label}.value is required for a constant domain.`);
|
|
322
|
+
else inspectSerializableLiteral(leaf.value, `${label}.value`, errors);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (type === 'boolean') {
|
|
326
|
+
if (leaf.probability !== undefined) {
|
|
327
|
+
const probability = finite(leaf.probability, `${label}.probability`);
|
|
328
|
+
if (Number.isFinite(probability) && (probability < 0 || probability > 1)) {
|
|
329
|
+
errors.push(`${label}.probability must be between 0 and 1.`);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (type === 'choice') {
|
|
335
|
+
const options = leaf.options ?? leaf.values;
|
|
336
|
+
if (!Array.isArray(options) || options.length === 0) {
|
|
337
|
+
errors.push(`${label}.options must be a non-empty array.`);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
let weightedCount = 0;
|
|
341
|
+
let positiveWeightCount = 0;
|
|
342
|
+
options.forEach((entry, index) => {
|
|
343
|
+
const optionPath = `${label}.options[${index}]`;
|
|
344
|
+
if (isPlainObject(entry) && Object.hasOwn(entry, 'value')) {
|
|
345
|
+
weightedCount += 1;
|
|
346
|
+
inspectSerializableLiteral(entry.value, `${optionPath}.value`, errors);
|
|
347
|
+
if (entry.weight !== undefined) {
|
|
348
|
+
const weight = finite(entry.weight, `${optionPath}.weight`);
|
|
349
|
+
if (Number.isFinite(weight) && weight < 0) errors.push(`${optionPath}.weight cannot be negative.`);
|
|
350
|
+
if (weight > 0) positiveWeightCount += 1;
|
|
351
|
+
} else {
|
|
352
|
+
positiveWeightCount += 1;
|
|
353
|
+
}
|
|
354
|
+
} else {
|
|
355
|
+
positiveWeightCount += 1;
|
|
356
|
+
inspectSerializableLiteral(entry, optionPath, errors);
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
if (weightedCount > 0 && positiveWeightCount === 0) {
|
|
360
|
+
warnings.push(`${label} has no positive weights and will always choose its first option.`);
|
|
361
|
+
}
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
if (type === 'color') {
|
|
365
|
+
for (const key of ['from', 'to']) {
|
|
366
|
+
const value = leaf[key];
|
|
367
|
+
if (key === 'to' && value === undefined) continue;
|
|
368
|
+
if (!Array.isArray(value) || value.length < 3 || value.length > 4) {
|
|
369
|
+
errors.push(`${label}.${key} must be an RGB or RGBA array.`);
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
value.forEach((channel, index) => finite(channel, `${label}.${key}[${index}]`));
|
|
373
|
+
}
|
|
374
|
+
if (leaf.linked !== undefined && typeof leaf.linked !== 'boolean') {
|
|
375
|
+
errors.push(`${label}.linked must be a boolean.`);
|
|
376
|
+
}
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const min = finite(leaf.min, `${label}.min`);
|
|
381
|
+
const max = finite(leaf.max, `${label}.max`);
|
|
382
|
+
if (Number.isFinite(min) && Number.isFinite(max) && max < min) {
|
|
383
|
+
errors.push(`${label}.max must be greater than or equal to min.`);
|
|
384
|
+
}
|
|
385
|
+
if (leaf.step !== undefined) {
|
|
386
|
+
const step = finite(leaf.step, `${label}.step`);
|
|
387
|
+
if (Number.isFinite(step) && step <= 0) errors.push(`${label}.step must be greater than zero.`);
|
|
388
|
+
}
|
|
389
|
+
if (leaf.integer !== undefined && typeof leaf.integer !== 'boolean') {
|
|
390
|
+
errors.push(`${label}.integer must be a boolean.`);
|
|
391
|
+
}
|
|
392
|
+
const distribution = leaf.distribution ?? 'uniform';
|
|
393
|
+
if (!['uniform', 'normal', 'log'].includes(distribution)) {
|
|
394
|
+
errors.push(`${label}.distribution must be uniform, normal, or log.`);
|
|
395
|
+
}
|
|
396
|
+
if (distribution === 'normal') {
|
|
397
|
+
finite(leaf.mean, `${label}.mean`, { optional: true });
|
|
398
|
+
const deviation = finite(leaf.deviation, `${label}.deviation`, { optional: true });
|
|
399
|
+
if (Number.isFinite(deviation) && deviation < 0) errors.push(`${label}.deviation cannot be negative.`);
|
|
400
|
+
}
|
|
401
|
+
if (distribution === 'log' && Number.isFinite(min) && min <= 0) {
|
|
402
|
+
errors.push(`${label}.min must be greater than zero for a log distribution.`);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function visit(node, path) {
|
|
407
|
+
if (isPlainObject(node) && Object.hasOwn(node, '$type')) {
|
|
408
|
+
inspectLeaf(node, path);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
if (Array.isArray(node) || !isPlainObject(node)) {
|
|
412
|
+
inspectSerializableLiteral(node, domainPath(path), errors);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
if (stack.has(node)) {
|
|
416
|
+
errors.push(`${domainPath(path)} cannot contain a circular reference.`);
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
stack.add(node);
|
|
420
|
+
for (const [key, value] of Object.entries(node)) visit(value, [...path, key]);
|
|
421
|
+
stack.delete(node);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
visit(input, []);
|
|
425
|
+
return { errors, ok: errors.length === 0, warnings };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export function validateGeneratorRecipeDocument(input, {
|
|
429
|
+
domain,
|
|
430
|
+
sanitizeConfiguration = (value) => cloneSerializable(value),
|
|
431
|
+
} = {}) {
|
|
432
|
+
const errors = [];
|
|
433
|
+
const warnings = [];
|
|
434
|
+
if (!isPlainObject(input)) {
|
|
435
|
+
return { errors: ['Generator recipe must be a JSON object.'], ok: false, value: null, warnings };
|
|
436
|
+
}
|
|
437
|
+
const expectedType = generatorDocumentType(domain);
|
|
438
|
+
if (input.type !== expectedType) errors.push(`Generator recipe type must be "${expectedType}".`);
|
|
439
|
+
const version = Number(input.version ?? 1);
|
|
440
|
+
if (!Number.isInteger(version) || version < 1) errors.push('Generator recipe version must be a positive integer.');
|
|
441
|
+
if (version > GENERATOR_RECIPE_SCHEMA_VERSION) {
|
|
442
|
+
errors.push(`Generator recipe version ${version} is newer than supported version ${GENERATOR_RECIPE_SCHEMA_VERSION}.`);
|
|
443
|
+
}
|
|
444
|
+
const id = normalizeGeneratorId(input.id);
|
|
445
|
+
if (!id) errors.push('Generator recipe id is required.');
|
|
446
|
+
const seed = hashSeed(input.seed ?? 1);
|
|
447
|
+
let domains = {};
|
|
448
|
+
if (input.domains !== undefined && !isPlainObject(input.domains)) {
|
|
449
|
+
errors.push('domains must be a JSON object.');
|
|
450
|
+
} else {
|
|
451
|
+
const domainResult = validateGeneratorDomains(input.domains ?? {});
|
|
452
|
+
errors.push(...domainResult.errors);
|
|
453
|
+
warnings.push(...domainResult.warnings);
|
|
454
|
+
if (domainResult.ok) domains = cloneSerializable(input.domains ?? {});
|
|
455
|
+
}
|
|
456
|
+
const locks = Array.isArray(input.locks) ? [...new Set(input.locks.map(String))] : [];
|
|
457
|
+
let configuration = {};
|
|
458
|
+
try {
|
|
459
|
+
configuration = sanitizeConfiguration(isPlainObject(input.configuration) ? input.configuration : {});
|
|
460
|
+
} catch (error) {
|
|
461
|
+
errors.push(error.message);
|
|
462
|
+
}
|
|
463
|
+
return {
|
|
464
|
+
errors,
|
|
465
|
+
ok: errors.length === 0,
|
|
466
|
+
value: errors.length > 0 ? null : {
|
|
467
|
+
basePreset: input.basePreset == null ? null : String(input.basePreset),
|
|
468
|
+
configuration,
|
|
469
|
+
description: String(input.description ?? ''),
|
|
470
|
+
domains,
|
|
471
|
+
id,
|
|
472
|
+
label: String(input.label || id),
|
|
473
|
+
locks,
|
|
474
|
+
seed,
|
|
475
|
+
type: expectedType,
|
|
476
|
+
version: GENERATOR_RECIPE_SCHEMA_VERSION,
|
|
477
|
+
},
|
|
478
|
+
warnings,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
export function createGeneratorRecipeDocument(domain, id, definition = {}, options = {}) {
|
|
483
|
+
const source = isPlainObject(definition) ? definition : {};
|
|
484
|
+
const result = validateGeneratorRecipeDocument({
|
|
485
|
+
basePreset: source.basePreset ?? null,
|
|
486
|
+
configuration: source.configuration ?? source.settings ?? {},
|
|
487
|
+
description: source.description ?? '',
|
|
488
|
+
domains: source.domains ?? {},
|
|
489
|
+
id: id ?? source.id,
|
|
490
|
+
label: source.label ?? source.name ?? id,
|
|
491
|
+
locks: source.locks ?? [],
|
|
492
|
+
seed: source.seed ?? 1,
|
|
493
|
+
type: generatorDocumentType(domain),
|
|
494
|
+
version: GENERATOR_RECIPE_SCHEMA_VERSION,
|
|
495
|
+
}, { domain, ...options });
|
|
496
|
+
if (!result.ok) throw new Error(result.errors.join(' '));
|
|
497
|
+
return result.value;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
export function parseGeneratorRecipeDocument(input, options = {}) {
|
|
501
|
+
return parsePresetDocument(
|
|
502
|
+
input,
|
|
503
|
+
(source) => validateGeneratorRecipeDocument(source, options),
|
|
504
|
+
{ invalidJsonLabel: `${options.domain || 'style'} generator recipe` },
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
export function serializeGeneratorRecipeDocument(domain, idOrDocument, definition = {}, {
|
|
509
|
+
pretty = true,
|
|
510
|
+
...options
|
|
511
|
+
} = {}) {
|
|
512
|
+
const document = isPlainObject(idOrDocument)
|
|
513
|
+
? createGeneratorRecipeDocument(domain, idOrDocument.id, idOrDocument, options)
|
|
514
|
+
: createGeneratorRecipeDocument(domain, idOrDocument, definition, options);
|
|
515
|
+
return stableStringify(document, pretty ? 2 : 0);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** Resolves a recipe into flat settings ready for a runtime normalizer. */
|
|
519
|
+
export function resolveGeneratorRecipe(recipe, {
|
|
520
|
+
baseSettings = {},
|
|
521
|
+
sanitizeSettings = (value) => value,
|
|
522
|
+
} = {}) {
|
|
523
|
+
const generated = generateDomainValues(recipe?.domains ?? {}, {
|
|
524
|
+
current: recipe?.configuration ?? {},
|
|
525
|
+
locks: recipe?.locks ?? [],
|
|
526
|
+
seed: recipe?.seed ?? 1,
|
|
527
|
+
});
|
|
528
|
+
return sanitizeSettings(deepMerge(baseSettings, recipe?.configuration, generated));
|
|
529
|
+
}
|
|
@@ -171,6 +171,7 @@ export function createEnvironmentSunRig({
|
|
|
171
171
|
light.shadow.camera.bottom = -shadowExtent;
|
|
172
172
|
light.position.copy(environmentRelativePoint(environmentBox, sourceRatios));
|
|
173
173
|
light.target.position.copy(environmentRelativePoint(environmentBox, targetRatios));
|
|
174
|
+
const sourceDistance = Math.max(light.position.distanceTo(light.target.position), 1);
|
|
174
175
|
light.shadow.camera.updateProjectionMatrix();
|
|
175
176
|
group.add(light);
|
|
176
177
|
group.add(light.target);
|
|
@@ -265,6 +266,25 @@ export function createEnvironmentSunRig({
|
|
|
265
266
|
if (nextColor && disk) disk.material.uniforms.color.value.set(nextColor);
|
|
266
267
|
}
|
|
267
268
|
|
|
269
|
+
// Places the directional light from a real world-space direction instead
|
|
270
|
+
// of interpreting direction components as environment-box ratios. The two
|
|
271
|
+
// are only equivalent in a cube centered at the origin; wide worlds would
|
|
272
|
+
// otherwise skew low-elevation sun paths badly when shadows are disabled.
|
|
273
|
+
function setDirection(value, { distance = sourceDistance } = {}) {
|
|
274
|
+
const direction = value?.isVector3
|
|
275
|
+
? value.clone()
|
|
276
|
+
: new THREE.Vector3(...(Array.isArray(value) ? value.slice(0, 3) : []));
|
|
277
|
+
if (![direction.x, direction.y, direction.z].every(Number.isFinite)) return null;
|
|
278
|
+
if (direction.lengthSq() < 1e-8) direction.set(0.35, 0.8, 0.45);
|
|
279
|
+
direction.normalize();
|
|
280
|
+
light.position.copy(light.target.position).addScaledVector(
|
|
281
|
+
direction,
|
|
282
|
+
Math.max(Number(distance) || sourceDistance, 1),
|
|
283
|
+
);
|
|
284
|
+
disk?.position.copy(light.position);
|
|
285
|
+
return direction;
|
|
286
|
+
}
|
|
287
|
+
|
|
268
288
|
function setEnabled(value) {
|
|
269
289
|
group.visible = Boolean(value);
|
|
270
290
|
light.visible = Boolean(value);
|
|
@@ -278,7 +298,7 @@ export function createEnvironmentSunRig({
|
|
|
278
298
|
});
|
|
279
299
|
}
|
|
280
300
|
|
|
281
|
-
return { beam, disk, dispose, group, light, setEnabled, setState, shaft, spill };
|
|
301
|
+
return { beam, disk, dispose, group, light, setDirection, setEnabled, setState, shaft, spill };
|
|
282
302
|
}
|
|
283
303
|
|
|
284
304
|
function detectLampPositions(root, environmentBox, pattern) {
|
|
@@ -110,6 +110,7 @@ export const DEFAULT_ENVIRONMENT_PARAMETERS = Object.freeze({
|
|
|
110
110
|
shadeSoftness: null,
|
|
111
111
|
shadeStrength: null,
|
|
112
112
|
shadowLift: null,
|
|
113
|
+
sunShadowStrength: null,
|
|
113
114
|
shadowTintColor: null,
|
|
114
115
|
skyGroundTint: null,
|
|
115
116
|
skyTintStrength: null,
|
|
@@ -211,6 +212,7 @@ const FIELD_LABEL_OVERRIDES = Object.freeze({
|
|
|
211
212
|
shadeSoftness: 'Shade Softness',
|
|
212
213
|
shadeStrength: 'Shade Strength',
|
|
213
214
|
shadowLift: 'Shadow Lift',
|
|
215
|
+
sunShadowStrength: 'Sun Shadow Strength',
|
|
214
216
|
shadowMask: 'Shadow Mask',
|
|
215
217
|
shadowMesh: 'Shadow Mesh',
|
|
216
218
|
shadowTintColor: 'Shadow Tint',
|
|
@@ -258,6 +260,7 @@ function rangeForParameter(key) {
|
|
|
258
260
|
if (key === 'shadeStrength') return { max: 2, min: 0, step: 0.01 };
|
|
259
261
|
if (key === 'shadeSoftness') return { max: 1, min: 0, step: 0.001 };
|
|
260
262
|
if (key === 'shadowLift') return { max: 1, min: 0, step: 0.01 };
|
|
263
|
+
if (key === 'sunShadowStrength') return { max: 1, min: 0, step: 0.01 };
|
|
261
264
|
if (key === 'sunBoost') return { max: 1, min: 0, step: 0.01 };
|
|
262
265
|
return { max: 1, min: 0, step: 0.01 };
|
|
263
266
|
}
|
|
@@ -484,6 +487,7 @@ export function applyEnvironmentSettingsToMaterial(material, settingsInput = {})
|
|
|
484
487
|
setNumberUniform(uniforms, 'shadeSoftness', parameters.shadeSoftness);
|
|
485
488
|
setNumberUniform(uniforms, 'shadeStrength', parameters.shadeStrength);
|
|
486
489
|
setNumberUniform(uniforms, 'shadowLift', parameters.shadowLift);
|
|
490
|
+
setNumberUniform(uniforms, 'sunShadowStrength', parameters.sunShadowStrength);
|
|
487
491
|
setNumberUniform(uniforms, 'skyTintStrength', parameters.skyTintStrength);
|
|
488
492
|
setNumberUniform(uniforms, 'spotLightStrength', parameters.spotLightStrength);
|
|
489
493
|
setNumberUniform(uniforms, 'sunBoost', parameters.sunBoost);
|
|
@@ -249,6 +249,13 @@ export function createEnvironmentSunShadowPass({ renderer, scene } = {}) {
|
|
|
249
249
|
environmentSunShadow.ready.value = true;
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
+
// The static-scene signature only tracks the sun pose and child count —
|
|
253
|
+
// swapping a subject for another with the same bounds (or retexturing a
|
|
254
|
+
// cutout material) changes the casters without changing the signature.
|
|
255
|
+
function invalidate() {
|
|
256
|
+
lastRenderSignature = '';
|
|
257
|
+
}
|
|
258
|
+
|
|
252
259
|
function dispose() {
|
|
253
260
|
shadowTarget?.dispose();
|
|
254
261
|
shadowTarget = null;
|
|
@@ -263,6 +270,7 @@ export function createEnvironmentSunShadowPass({ renderer, scene } = {}) {
|
|
|
263
270
|
return shadowMatrix;
|
|
264
271
|
},
|
|
265
272
|
dispose,
|
|
273
|
+
invalidate,
|
|
266
274
|
update,
|
|
267
275
|
};
|
|
268
276
|
}
|