@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.
Files changed (205) hide show
  1. package/AGENTS.md +127 -4
  2. package/ATTRIBUTION.md +41 -3
  3. package/README.md +231 -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 +752 -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,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
  }