@call-me-sensei/toonlab 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. package/AGENTS.md +127 -4
  2. package/ATTRIBUTION.md +41 -3
  3. package/README.md +234 -34
  4. package/docs/characters.md +144 -0
  5. package/docs/debug-panel.md +126 -0
  6. package/docs/docs.css +589 -0
  7. package/docs/environment.md +186 -0
  8. package/docs/getting-started.md +180 -0
  9. package/docs/index.html +14 -0
  10. package/docs/lab-architecture.md +100 -0
  11. package/docs/lighting.md +825 -0
  12. package/docs/main.jsx +739 -0
  13. package/docs/mcp.md +97 -0
  14. package/docs/post-processing.md +98 -0
  15. package/docs/settings-reference.md +1878 -0
  16. package/docs/shader-constants.md +77 -0
  17. package/docs/sky.md +182 -0
  18. package/docs/style-labs.md +309 -0
  19. package/docs/texture-lab.md +135 -0
  20. package/docs/toon-shading.md +183 -0
  21. package/docs/tsl-conventions.md +167 -0
  22. package/docs/vegetation-sky.md +275 -0
  23. package/docs/water.md +430 -0
  24. package/docs/weather.md +200 -0
  25. package/docs/world-scale.md +111 -0
  26. package/mcp/server.mjs +610 -0
  27. package/mcp/style-lab-tools.mjs +371 -0
  28. package/mcp/vite-plugin.mjs +174 -0
  29. package/mcp/workspace.mjs +397 -0
  30. package/package.json +64 -5
  31. package/src/ambientfx/INTEGRATION.md +164 -0
  32. package/src/ambientfx/ambientFxPresets.js +83 -0
  33. package/src/ambientfx/ambientFxSettings.js +368 -0
  34. package/src/ambientfx/emitters.js +169 -0
  35. package/src/ambientfx/index.js +5 -0
  36. package/src/ambientfx/particleBackbone.js +493 -0
  37. package/src/ambientfx/stylizedAmbientFx.js +450 -0
  38. package/src/assetlib/ambientcg.js +117 -0
  39. package/src/assetlib/assetRef.js +91 -0
  40. package/src/assetlib/importedEntry.js +40 -0
  41. package/src/assetlib/index.js +23 -0
  42. package/src/assetlib/kaykit.js +192 -0
  43. package/src/assetlib/kaykitStaticIndex.js +700 -0
  44. package/src/assetlib/loadImported.js +143 -0
  45. package/src/assetlib/opensource3d.js +113 -0
  46. package/src/assetlib/polyhaven.js +182 -0
  47. package/src/assetlib/polypizza.js +115 -0
  48. package/src/assetlib/smithsonian.js +214 -0
  49. package/src/assetlib/sources.js +279 -0
  50. package/src/assetlib/zip.js +58 -0
  51. package/src/biome/biomeGenerator.js +385 -0
  52. package/src/biome/biomeRuntime.js +299 -0
  53. package/src/biome/index.js +2 -0
  54. package/src/buildinggen/buildingAsset.js +44 -0
  55. package/src/buildinggen/buildingGrammar.js +311 -0
  56. package/src/buildinggen/buildingMesh.js +451 -0
  57. package/src/buildinggen/buildingPresets.js +46 -0
  58. package/src/buildinggen/buildingRecipe.js +100 -0
  59. package/src/buildinggen/buildingSettings.js +238 -0
  60. package/src/buildinggen/index.js +6 -0
  61. package/src/camera/cameraDirector.js +157 -0
  62. package/src/camera/cameraGenerator.js +367 -0
  63. package/src/camera/cameraRig.js +570 -0
  64. package/src/camera/cameraSettings.js +236 -0
  65. package/src/camera/index.js +7 -0
  66. package/src/catalog/builtinEntries.js +245 -0
  67. package/src/catalog/catalog.js +210 -0
  68. package/src/catalog/index.js +3 -0
  69. package/src/catalog/manifest.js +84 -0
  70. package/src/core/generation.js +529 -0
  71. package/src/environment/environmentRigs.js +21 -1
  72. package/src/environment/environmentSettings.js +4 -0
  73. package/src/environment/environmentSunShadowPass.js +8 -0
  74. package/src/fauna/INTEGRATION.md +174 -0
  75. package/src/fauna/boids.js +861 -0
  76. package/src/fauna/faunaBodies.js +492 -0
  77. package/src/fauna/faunaPresets.js +52 -0
  78. package/src/fauna/faunaSettings.js +525 -0
  79. package/src/fauna/index.js +5 -0
  80. package/src/fauna/stylizedFauna.js +395 -0
  81. package/src/game-feel/gameFeelGenerator.js +402 -0
  82. package/src/game-feel/gameFeelRuntime.js +549 -0
  83. package/src/game-feel/index.js +2 -0
  84. package/src/index.js +17 -6
  85. package/src/lighting/colorIntensity.js +177 -0
  86. package/src/lighting/index.js +161 -0
  87. package/src/lighting/lightDescriptors.js +249 -0
  88. package/src/lighting/lightingCapabilities.js +79 -0
  89. package/src/lighting/lightingDocuments.js +247 -0
  90. package/src/lighting/lightingFixtures.js +446 -0
  91. package/src/lighting/lightingGenerator.js +449 -0
  92. package/src/lighting/lightingPresets.js +319 -0
  93. package/src/lighting/lightingRuntime.js +723 -0
  94. package/src/lighting/lightingStyle.js +386 -0
  95. package/src/lighting/lightingSystem.js +774 -0
  96. package/src/lighting/unrealExport.js +186 -0
  97. package/src/lighting/utils.js +87 -0
  98. package/src/motion/index.js +5 -0
  99. package/src/motion/motionClip.js +441 -0
  100. package/src/motion/motionController.js +628 -0
  101. package/src/motion/motionDocuments.js +225 -0
  102. package/src/motion/motionGraph.js +307 -0
  103. package/src/motion/motionSettings.js +222 -0
  104. package/src/pathgen/index.js +7 -0
  105. package/src/pathgen/pathBridge.js +232 -0
  106. package/src/pathgen/pathPresets.js +35 -0
  107. package/src/pathgen/pathRibbon.js +410 -0
  108. package/src/pathgen/pathRouter.js +380 -0
  109. package/src/pathgen/pathSettings.js +335 -0
  110. package/src/pathgen/pathTextures.js +123 -0
  111. package/src/pathgen/stylizedPaths.js +453 -0
  112. package/src/post/index.js +1 -0
  113. package/src/post/postGenerator.js +177 -0
  114. package/src/post/postProcessing.js +41 -0
  115. package/src/propgen/generatorsWave1.js +379 -0
  116. package/src/propgen/generatorsWave2.js +462 -0
  117. package/src/propgen/index.js +5 -0
  118. package/src/propgen/propAsset.js +323 -0
  119. package/src/propgen/propParts.js +170 -0
  120. package/src/propgen/propPlacement.js +459 -0
  121. package/src/propgen/propPresets.js +82 -0
  122. package/src/propgen/propSettings.js +395 -0
  123. package/src/shaders-tsl/chunks/projected-water-caustics.js +242 -0
  124. package/src/shaders-tsl/chunks/vegetation-style.js +360 -0
  125. package/src/shaders-tsl/chunks/water-shore-state.js +31 -0
  126. package/src/shaders-tsl/chunks/water-waves.js +90 -11
  127. package/src/shaders-tsl/environment.js +15 -1
  128. package/src/shaders-tsl/flower.js +279 -30
  129. package/src/shaders-tsl/grass.js +60 -33
  130. package/src/shaders-tsl/sky.js +125 -31
  131. package/src/shaders-tsl/tree-leaf.js +61 -25
  132. package/src/shaders-tsl/water-breaker.js +7 -4
  133. package/src/shaders-tsl/water-shore-state-simulation.js +523 -0
  134. package/src/shaders-tsl/water.js +439 -49
  135. package/src/shaders-tsl/woody-surface.js +154 -0
  136. package/src/sky/sceneOverrideLayers.js +10 -0
  137. package/src/sky/skyQuality.js +26 -0
  138. package/src/sky/stylizedSky.js +753 -45
  139. package/src/soundscape/index.js +4 -0
  140. package/src/soundscape/soundscapeGenerator.js +179 -0
  141. package/src/soundscape/soundscapeRuntime.js +806 -0
  142. package/src/soundscape/soundscapeSettings.js +292 -0
  143. package/src/styles/index.js +13 -0
  144. package/src/styles/styleBundle.js +325 -0
  145. package/src/stylizedTerrain.js +32 -2
  146. package/src/stylizedWorld.js +423 -20
  147. package/src/texgen/evaluateTexture.js +675 -0
  148. package/src/texgen/index.js +60 -0
  149. package/src/texgen/noise2.js +210 -0
  150. package/src/texgen/textureAi.js +436 -0
  151. package/src/texgen/textureGenerators.js +516 -0
  152. package/src/texgen/texturePresets.js +490 -0
  153. package/src/texgen/textureSettings.js +342 -0
  154. package/src/texgen/textureThree.js +59 -0
  155. package/src/vegetation/flowerSpecies.js +15 -3
  156. package/src/vegetation/grassPalettes.js +153 -0
  157. package/src/vegetation/index.js +6 -0
  158. package/src/vegetation/stylizedBush.js +2 -0
  159. package/src/vegetation/stylizedFlower.js +82 -0
  160. package/src/vegetation/stylizedFlowers.js +48 -7
  161. package/src/vegetation/stylizedForest.js +29 -1
  162. package/src/vegetation/stylizedGrass.js +291 -56
  163. package/src/vegetation/stylizedTree.js +38 -2
  164. package/src/vegetation/stylizedTreeFoliage.js +2 -1
  165. package/src/vegetation/vegetationShaders.js +1110 -0
  166. package/src/vfxgen/INTEGRATION.md +145 -0
  167. package/src/vfxgen/core/burstBackbone.js +380 -0
  168. package/src/vfxgen/core/projectileCore.js +92 -0
  169. package/src/vfxgen/core/spriteShapes.js +98 -0
  170. package/src/vfxgen/core/trailRibbon.js +272 -0
  171. package/src/vfxgen/core/vfxRandom.js +29 -0
  172. package/src/vfxgen/effects/emitHelpers.js +37 -0
  173. package/src/vfxgen/effects/magicEffects.js +162 -0
  174. package/src/vfxgen/effects/movementEffects.js +87 -0
  175. package/src/vfxgen/effects/weaponEffects.js +118 -0
  176. package/src/vfxgen/index.js +18 -0
  177. package/src/vfxgen/moves/moveController.js +146 -0
  178. package/src/vfxgen/moves/moveLibrary.js +335 -0
  179. package/src/vfxgen/vfxPresets.js +98 -0
  180. package/src/vfxgen/vfxSettings.js +384 -0
  181. package/src/vfxgen/vfxSystem.js +449 -0
  182. package/src/vfxgen/weapons/stylizedWeapons.js +137 -0
  183. package/src/villagegen/index.js +4 -0
  184. package/src/villagegen/stylizedVillage.js +490 -0
  185. package/src/villagegen/villageArchetypes.js +160 -0
  186. package/src/villagegen/villageNames.js +40 -0
  187. package/src/villagegen/villageSites.js +105 -0
  188. package/src/water/sceneOverrideLayers.js +23 -0
  189. package/src/water/water.js +5 -0
  190. package/src/water/waterBreakerSystem.js +15 -1
  191. package/src/water/waterCurrentField.js +447 -0
  192. package/src/water/waterMaterial.js +12 -0
  193. package/src/water/waterNearshorePhase.js +320 -0
  194. package/src/water/waterScenePasses.js +83 -30
  195. package/src/water/waterSettings.js +325 -13
  196. package/src/water/waterShoreMaterial.js +322 -0
  197. package/src/water/waterShoreStateField.js +605 -0
  198. package/src/water/waterSurface.js +797 -28
  199. package/src/weather/index.js +6 -0
  200. package/src/weather/weatherPrecipitation.js +221 -0
  201. package/src/weather/weatherPresets.js +258 -0
  202. package/src/weather/weatherSettings.js +269 -0
  203. package/src/weather/weatherSystem.js +871 -0
  204. package/src/worldMinimap.js +62 -0
  205. package/src/worldPresets.js +4 -1
@@ -0,0 +1,449 @@
1
+ // Deterministic lighting generation on the shared recipe grammar.
2
+ //
3
+ // Two generator domains ship here:
4
+ // - 'lighting-style' → samples a coherent day-cycle palette (sun kelvins,
5
+ // intensities, fog colors, ambient/exposure philosophy, schedule) and
6
+ // builds a complete lighting-style settings object from it.
7
+ // - 'light-fixture' → samples one fixture kind (emission, behavior) plus
8
+ // the *spreads* used for its per-placement variation domains, so a single
9
+ // seed yields a fixture that itself yields endless placement variety.
10
+ //
11
+ // Families are starting points, never a closed catalog: register new ones or
12
+ // override any domain leaf per recipe.
13
+
14
+ import {
15
+ createGeneratorRecipeDocument,
16
+ deepMerge,
17
+ parseGeneratorRecipeDocument,
18
+ resolveGeneratorRecipe,
19
+ serializeGeneratorRecipeDocument,
20
+ validateGeneratorRecipeDocument,
21
+ } from '../core/generation.js';
22
+ import {
23
+ createLightFixtureDocument,
24
+ createLightFixtureSettings,
25
+ } from './lightingFixtures.js';
26
+ import {
27
+ createLightingStylePresetDocument,
28
+ createLightingStyleSettings,
29
+ } from './lightingStyle.js';
30
+ import { clamp, finite, isPlainObject } from './utils.js';
31
+
32
+ export const LIGHTING_STYLE_GENERATOR_DOMAIN = 'lighting-style';
33
+ export const LIGHT_FIXTURE_GENERATOR_DOMAIN = 'light-fixture';
34
+
35
+ const range = (min, max, step = 0.01, extra = {}) => Object.freeze({
36
+ $type: 'range', max, min, step, ...extra,
37
+ });
38
+ const choice = (...options) => Object.freeze({ $type: 'choice', options });
39
+ const color = (from, to) => Object.freeze({ $type: 'color', from, to });
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Style generation.
43
+
44
+ export const DEFAULT_LIGHTING_STYLE_DOMAINS = Object.freeze({
45
+ ambient: Object.freeze({
46
+ dayScale: range(0.9, 1.25),
47
+ intensity: range(0.32, 0.62),
48
+ nightScale: range(0.45, 0.75),
49
+ }),
50
+ atmosphere: Object.freeze({
51
+ fogDay: color([0.7, 0.78, 0.86], [0.88, 0.9, 0.98]),
52
+ fogDusk: color([0.75, 0.5, 0.35], [0.98, 0.72, 0.55]),
53
+ fogNight: color([0.24, 0.28, 0.44], [0.42, 0.46, 0.64]),
54
+ }),
55
+ exposure: Object.freeze({
56
+ base: range(0.9, 1.12, 0.01, { distribution: 'normal', mean: 1.0, deviation: 0.05 }),
57
+ nightScale: range(0.86, 0.98),
58
+ }),
59
+ fixtures: Object.freeze({
60
+ intensityScale: range(0.9, 1.4),
61
+ nightScale: range(1.05, 1.6),
62
+ }),
63
+ schedule: Object.freeze({
64
+ sunriseHour: range(5.5, 7.5, 0.25),
65
+ sunsetHour: range(17, 19.5, 0.25),
66
+ }),
67
+ sun: Object.freeze({
68
+ accentScale: range(0.6, 1.2),
69
+ dayIntensity: range(0.8, 1.3),
70
+ dayKelvin: range(5000, 6800, 50),
71
+ duskIntensity: range(0.35, 0.7),
72
+ duskKelvin: range(1800, 3400, 50),
73
+ nightIntensity: range(0.06, 0.2),
74
+ nightKelvin: range(8000, 16000, 100),
75
+ }),
76
+ });
77
+
78
+ /** Builds complete style settings from a sampled high-level palette. */
79
+ export function buildLightingStyleFromSample(sampled = {}, configuration = {}) {
80
+ const sun = isPlainObject(sampled.sun) ? sampled.sun : {};
81
+ const ambient = isPlainObject(sampled.ambient) ? sampled.ambient : {};
82
+ const atmosphere = isPlainObject(sampled.atmosphere) ? sampled.atmosphere : {};
83
+ const exposure = isPlainObject(sampled.exposure) ? sampled.exposure : {};
84
+ const fixtures = isPlainObject(sampled.fixtures) ? sampled.fixtures : {};
85
+ const schedule = isPlainObject(sampled.schedule) ? sampled.schedule : {};
86
+
87
+ const sunrise = clamp(finite(schedule.sunriseHour, 6.5), 4, 10);
88
+ const sunset = clamp(finite(schedule.sunsetHour, 18.5), Math.max(sunrise + 4, 14), 22);
89
+ const midday = (sunrise + sunset) / 2;
90
+ const nightKelvin = finite(sun.nightKelvin, 11000);
91
+ const dayKelvin = finite(sun.dayKelvin, 5800);
92
+ const duskKelvin = finite(sun.duskKelvin, 2600);
93
+ const nightIntensity = finite(sun.nightIntensity, 0.12);
94
+ const nightAmbient = finite(ambient.nightScale, 0.6);
95
+ const nightFixture = finite(fixtures.nightScale, 1.3);
96
+ const nightExposure = finite(exposure.nightScale, 0.92);
97
+ const fogNight = atmosphere.fogNight ?? [0.32, 0.38, 0.55];
98
+ const accent = finite(sun.accentScale, 1);
99
+
100
+ // Sky palettes derive from the sampled fog palette so one seed stays one
101
+ // coherent atmosphere; night zenith darkens the fog hue, horizons brighten it.
102
+ const scaleColor = (rgb, factor, ceiling = 1) => rgb.map((channel) => clamp(channel * factor, 0, ceiling));
103
+ const fogDay = atmosphere.fogDay ?? [0.78, 0.85, 0.95];
104
+ const fogDusk = atmosphere.fogDusk ?? [0.85, 0.7, 0.55];
105
+ const skyNight = { horizon: scaleColor(fogNight, 0.95), stars: 0.9, zenith: scaleColor(fogNight, 0.32) };
106
+ const skyDay = {
107
+ horizon: scaleColor(fogDay, 1.08),
108
+ stars: 0,
109
+ zenith: [clamp(fogDay[0] * 0.35, 0, 1), clamp(fogDay[1] * 0.62, 0, 1), clamp(fogDay[2] * 1.05, 0, 1)],
110
+ };
111
+ const skyDusk = { horizon: scaleColor(fogDusk, 1.12), stars: 0.05, zenith: [0.34, 0.35, 0.7] };
112
+
113
+ const nightFrame = (hour) => ({
114
+ accentScale: 0.2 * accent,
115
+ ambientScale: nightAmbient,
116
+ exposureScale: nightExposure,
117
+ fixtureScale: nightFixture,
118
+ fogColor: fogNight,
119
+ hour,
120
+ sky: skyNight,
121
+ sunColor: { kelvin: nightKelvin },
122
+ sunIntensity: nightIntensity,
123
+ });
124
+
125
+ return createLightingStyleSettings(deepMerge({
126
+ ambientLight: { intensity: finite(ambient.intensity, 0.45) },
127
+ dayCycle: [
128
+ nightFrame(0),
129
+ {
130
+ accentScale: 0.9 * accent,
131
+ ambientScale: (nightAmbient + finite(ambient.dayScale, 1)) / 2,
132
+ exposureScale: 1,
133
+ fixtureScale: nightFixture * 0.5,
134
+ fogColor: atmosphere.fogDusk ?? [0.85, 0.7, 0.55],
135
+ hour: sunrise,
136
+ sky: skyDusk,
137
+ sunColor: { kelvin: duskKelvin },
138
+ sunIntensity: finite(sun.duskIntensity, 0.5),
139
+ },
140
+ {
141
+ accentScale: accent,
142
+ ambientScale: finite(ambient.dayScale, 1),
143
+ exposureScale: 1.02,
144
+ fixtureScale: 0,
145
+ fogColor: atmosphere.fogDay ?? [0.78, 0.85, 0.95],
146
+ hour: midday,
147
+ sky: skyDay,
148
+ sunColor: { kelvin: dayKelvin },
149
+ sunIntensity: finite(sun.dayIntensity, 1),
150
+ },
151
+ {
152
+ accentScale: 1.05 * accent,
153
+ ambientScale: (nightAmbient + finite(ambient.dayScale, 1)) / 2,
154
+ exposureScale: 1,
155
+ fixtureScale: nightFixture * 0.6,
156
+ fogColor: atmosphere.fogDusk ?? [0.82, 0.6, 0.45],
157
+ hour: sunset,
158
+ sky: skyDusk,
159
+ sunColor: { kelvin: duskKelvin },
160
+ sunIntensity: finite(sun.duskIntensity, 0.5),
161
+ },
162
+ nightFrame(Math.min(sunset + 2.5, 23.5)),
163
+ ],
164
+ exposure: { base: finite(exposure.base, 1) },
165
+ fixtures: { intensityScale: finite(fixtures.intensityScale, 1) },
166
+ }, configuration));
167
+ }
168
+
169
+ const STYLE_FAMILIES = new Map([
170
+ ['anime-day', {
171
+ description: 'Balanced storybook days: warm mornings, neutral noons, cool lamp-lit nights.',
172
+ domains: DEFAULT_LIGHTING_STYLE_DOMAINS,
173
+ label: 'Anime Day',
174
+ }],
175
+ ['call-me-sensei', {
176
+ description: 'Vivid anime-open-world cycles: saturated punchy daylight, luminous cool shadows, strong golden hours, rich blue fixture-lit nights.',
177
+ domains: deepMerge(DEFAULT_LIGHTING_STYLE_DOMAINS, {
178
+ ambient: { dayScale: range(1.0, 1.3), intensity: range(0.5, 0.68), nightScale: range(0.55, 0.75) },
179
+ atmosphere: {
180
+ fogDay: color([0.62, 0.8, 0.95], [0.8, 0.9, 1.05]),
181
+ fogDusk: color([0.95, 0.45, 0.3], [1.05, 0.72, 0.5]),
182
+ fogNight: color([0.12, 0.16, 0.42], [0.24, 0.28, 0.58]),
183
+ },
184
+ exposure: { base: range(1.02, 1.14) },
185
+ fixtures: { intensityScale: range(1.15, 1.5), nightScale: range(1.35, 1.7) },
186
+ sun: {
187
+ accentScale: range(1.0, 1.4),
188
+ dayIntensity: range(1.05, 1.4),
189
+ dayKelvin: range(5100, 5900, 50),
190
+ duskIntensity: range(0.55, 0.85),
191
+ duskKelvin: range(1900, 2700, 50),
192
+ nightIntensity: range(0.12, 0.22),
193
+ nightKelvin: range(10000, 13000, 100),
194
+ },
195
+ }),
196
+ label: 'Call Me Sensei',
197
+ }],
198
+ ['golden', {
199
+ description: 'Sun-drunk warm cycles with long amber golden hours.',
200
+ domains: deepMerge(DEFAULT_LIGHTING_STYLE_DOMAINS, {
201
+ exposure: { base: range(1.0, 1.15) },
202
+ sun: {
203
+ dayIntensity: range(1.0, 1.4),
204
+ dayKelvin: range(4600, 5800, 50),
205
+ duskKelvin: range(1700, 2600, 50),
206
+ },
207
+ }),
208
+ label: 'Golden',
209
+ }],
210
+ ['noir-neon', {
211
+ description: 'Dark cool cycles where fixtures carry the frame; saturated night fog.',
212
+ domains: deepMerge(DEFAULT_LIGHTING_STYLE_DOMAINS, {
213
+ ambient: { intensity: range(0.25, 0.45), nightScale: range(0.35, 0.55) },
214
+ atmosphere: { fogNight: color([0.18, 0.12, 0.35], [0.4, 0.24, 0.55]) },
215
+ exposure: { base: range(0.88, 1.0) },
216
+ fixtures: { intensityScale: range(1.2, 1.7), nightScale: range(1.3, 1.8) },
217
+ sun: { dayIntensity: range(0.5, 0.85), nightIntensity: range(0.04, 0.12), nightKelvin: range(10000, 20000, 100) },
218
+ }),
219
+ label: 'Noir Neon',
220
+ }],
221
+ ['pastel-overcast', {
222
+ description: 'Soft flat cycles: high ambient, muted sun, gentle pastel fog.',
223
+ domains: deepMerge(DEFAULT_LIGHTING_STYLE_DOMAINS, {
224
+ ambient: { dayScale: range(1.1, 1.4), intensity: range(0.5, 0.7) },
225
+ sun: { accentScale: range(0.15, 0.45), dayIntensity: range(0.4, 0.7), dayKelvin: range(6200, 7400, 50) },
226
+ }),
227
+ label: 'Pastel Overcast',
228
+ }],
229
+ ]);
230
+
231
+ export function registerLightingStyleGeneratorFamily(id, definition = {}, { overwrite = false } = {}) {
232
+ const key = String(id ?? '').trim();
233
+ if (!key) throw new Error('Lighting style generator family id is required.');
234
+ if (!overwrite && STYLE_FAMILIES.has(key)) throw new Error(`Lighting style generator family "${key}" already exists.`);
235
+ STYLE_FAMILIES.set(key, deepMerge(definition));
236
+ return key;
237
+ }
238
+
239
+ export function getLightingStyleGeneratorFamilyOptions() {
240
+ return [...STYLE_FAMILIES.entries()].map(([id, definition]) => ({
241
+ description: definition.description ?? '', id, label: definition.label ?? id,
242
+ }));
243
+ }
244
+
245
+ export function createLightingStyleGeneratorRecipe(id = 'generated-lighting-style', definition = {}) {
246
+ const family = STYLE_FAMILIES.get(definition.family ?? 'anime-day') ?? {};
247
+ return createGeneratorRecipeDocument(LIGHTING_STYLE_GENERATOR_DOMAIN, id, {
248
+ basePreset: definition.basePreset ?? family.basePreset ?? null,
249
+ configuration: deepMerge(family.configuration, definition.configuration),
250
+ description: definition.description ?? '',
251
+ domains: deepMerge(family.domains ?? DEFAULT_LIGHTING_STYLE_DOMAINS, definition.domains),
252
+ label: definition.label ?? id,
253
+ locks: definition.locks ?? [],
254
+ seed: definition.seed ?? 1,
255
+ });
256
+ }
257
+
258
+ export function validateLightingStyleGeneratorRecipe(input) {
259
+ return validateGeneratorRecipeDocument(input, { domain: LIGHTING_STYLE_GENERATOR_DOMAIN });
260
+ }
261
+
262
+ export function parseLightingStyleGeneratorRecipe(input) {
263
+ return parseGeneratorRecipeDocument(input, { domain: LIGHTING_STYLE_GENERATOR_DOMAIN });
264
+ }
265
+
266
+ export function serializeLightingStyleGeneratorRecipe(idOrDocument, definition = {}, { pretty = true } = {}) {
267
+ return serializeGeneratorRecipeDocument(LIGHTING_STYLE_GENERATOR_DOMAIN, idOrDocument, definition, { pretty });
268
+ }
269
+
270
+ /** Resolves a style generator recipe into normalized lighting-style settings. */
271
+ export function resolveLightingStyleGeneratorRecipe(recipeInput) {
272
+ const recipe = recipeInput?.type
273
+ ? validateLightingStyleGeneratorRecipe(recipeInput).value
274
+ : createLightingStyleGeneratorRecipe('generated-lighting-style', recipeInput);
275
+ if (!recipe) throw new Error('Invalid lighting style generator recipe.');
276
+ const sampled = resolveGeneratorRecipe(recipe, { sanitizeSettings: (value) => value });
277
+ // The sampled tree is the high-level palette; configuration entries that
278
+ // target the *style* schema pass through buildLightingStyleFromSample.
279
+ return buildLightingStyleFromSample(sampled, recipe.configuration?.style ?? {});
280
+ }
281
+
282
+ export function createGeneratedLightingStyleDocument(recipeInput, {
283
+ id = recipeInput?.id ? `${recipeInput.id}-resolved` : 'generated-lighting-style-resolved',
284
+ label = recipeInput?.label ? `${recipeInput.label} · resolved` : 'Generated Lighting Style · resolved',
285
+ } = {}) {
286
+ const settings = resolveLightingStyleGeneratorRecipe(recipeInput);
287
+ return createLightingStylePresetDocument(id, {
288
+ description: `Resolved from generator seed ${recipeInput?.seed ?? 1}.`,
289
+ label,
290
+ settings,
291
+ });
292
+ }
293
+
294
+ // ---------------------------------------------------------------------------
295
+ // Fixture generation.
296
+
297
+ export const DEFAULT_LIGHT_FIXTURE_DOMAINS = Object.freeze({
298
+ behavior: Object.freeze({
299
+ flickerAmount: range(0, 0.3),
300
+ flickerSpeed: range(3, 14, 0.5),
301
+ scheduleMode: choice({ value: 'night', weight: 3 }, { value: 'always', weight: 1 }),
302
+ }),
303
+ emission: Object.freeze({
304
+ distance: range(4, 16, 0.5),
305
+ height: range(0.8, 4.5, 0.1),
306
+ kelvin: range(1600, 3200, 25),
307
+ lumens: range(120, 1400, 10, { distribution: 'log' }),
308
+ }),
309
+ variation: Object.freeze({
310
+ intensitySpread: range(0.05, 0.3),
311
+ kelvinSpread: range(50, 400, 10),
312
+ }),
313
+ });
314
+
315
+ /** Builds complete fixture settings from a sampled definition. */
316
+ export function buildLightFixtureFromSample(sampled = {}, configuration = {}) {
317
+ const emission = isPlainObject(sampled.emission) ? sampled.emission : {};
318
+ const behavior = isPlainObject(sampled.behavior) ? sampled.behavior : {};
319
+ const variation = isPlainObject(sampled.variation) ? sampled.variation : {};
320
+ const kelvin = clamp(finite(emission.kelvin, 2200), 1000, 40000);
321
+ const kelvinSpread = Math.max(finite(variation.kelvinSpread, 200), 0);
322
+ const intensitySpread = clamp(finite(variation.intensitySpread, 0.15), 0, 1);
323
+ return createLightFixtureSettings(deepMerge({
324
+ base: {
325
+ decay: 2,
326
+ distance: Math.max(finite(emission.distance, 8), 0.5),
327
+ intensity: { unit: 'lumens', value: Math.max(finite(emission.lumens, 400), 1) },
328
+ position: [0, Math.max(finite(emission.height, 2.4), 0.05), 0],
329
+ type: 'point',
330
+ },
331
+ category: 'practical',
332
+ flicker: {
333
+ amount: clamp(finite(behavior.flickerAmount, 0.08), 0, 1),
334
+ speed: clamp(finite(behavior.flickerSpeed, 6), 0.05, 60),
335
+ },
336
+ schedule: { mode: behavior.scheduleMode === 'always' ? 'always' : 'night' },
337
+ variation: {
338
+ intensityScale: { $type: 'range', max: 1 + intensitySpread, min: Math.max(1 - intensitySpread, 0.05) },
339
+ kelvin: {
340
+ $type: 'range',
341
+ max: clamp(kelvin + kelvinSpread, 1000, 40000),
342
+ min: clamp(kelvin - kelvinSpread, 1000, 40000),
343
+ step: 10,
344
+ },
345
+ },
346
+ }, configuration));
347
+ }
348
+
349
+ const FIXTURE_FAMILIES = new Map([
350
+ ['warm-practical', {
351
+ description: 'Lanterns, lamps, and household practicals in the warm kelvin band.',
352
+ domains: DEFAULT_LIGHT_FIXTURE_DOMAINS,
353
+ label: 'Warm Practical',
354
+ }],
355
+ ['cms-practical', {
356
+ description: 'Call Me Sensei practicals: saturated warm lanterns and lamps tuned to read against vivid blue nights.',
357
+ domains: deepMerge(DEFAULT_LIGHT_FIXTURE_DOMAINS, {
358
+ behavior: { flickerAmount: range(0.02, 0.14) },
359
+ emission: { kelvin: range(1850, 2500, 25), lumens: range(300, 1400, 10, { distribution: 'log' }) },
360
+ variation: { intensitySpread: range(0.1, 0.25), kelvinSpread: range(100, 300, 10) },
361
+ }),
362
+ label: 'CMS Practical',
363
+ }],
364
+ ['flame', {
365
+ description: 'Fires, candles, and torches: very warm, heavy organic flicker.',
366
+ domains: deepMerge(DEFAULT_LIGHT_FIXTURE_DOMAINS, {
367
+ behavior: {
368
+ flickerAmount: range(0.18, 0.45),
369
+ flickerSpeed: range(6, 14, 0.5),
370
+ scheduleMode: choice({ value: 'always', weight: 1 }),
371
+ },
372
+ emission: { height: range(0.4, 1.4, 0.05), kelvin: range(1450, 2000, 25) },
373
+ }),
374
+ label: 'Flame',
375
+ }],
376
+ ['neon', {
377
+ description: 'Saturated signage: cool-to-acid hues, electrical stutter, night schedule.',
378
+ domains: deepMerge(DEFAULT_LIGHT_FIXTURE_DOMAINS, {
379
+ behavior: { flickerAmount: range(0.1, 0.35), flickerSpeed: range(16, 32, 1) },
380
+ emission: { kelvin: range(6000, 12000, 100) },
381
+ }),
382
+ configuration: {
383
+ base: { type: 'tubeArea', width: 1.6 },
384
+ category: 'signage',
385
+ },
386
+ label: 'Neon',
387
+ }],
388
+ ]);
389
+
390
+ export function registerLightFixtureGeneratorFamily(id, definition = {}, { overwrite = false } = {}) {
391
+ const key = String(id ?? '').trim();
392
+ if (!key) throw new Error('Light fixture generator family id is required.');
393
+ if (!overwrite && FIXTURE_FAMILIES.has(key)) throw new Error(`Light fixture generator family "${key}" already exists.`);
394
+ FIXTURE_FAMILIES.set(key, deepMerge(definition));
395
+ return key;
396
+ }
397
+
398
+ export function getLightFixtureGeneratorFamilyOptions() {
399
+ return [...FIXTURE_FAMILIES.entries()].map(([id, definition]) => ({
400
+ description: definition.description ?? '', id, label: definition.label ?? id,
401
+ }));
402
+ }
403
+
404
+ export function createLightFixtureGeneratorRecipe(id = 'generated-fixture', definition = {}) {
405
+ const family = FIXTURE_FAMILIES.get(definition.family ?? 'warm-practical') ?? {};
406
+ return createGeneratorRecipeDocument(LIGHT_FIXTURE_GENERATOR_DOMAIN, id, {
407
+ basePreset: definition.basePreset ?? family.basePreset ?? null,
408
+ configuration: deepMerge(family.configuration, definition.configuration),
409
+ description: definition.description ?? '',
410
+ domains: deepMerge(family.domains ?? DEFAULT_LIGHT_FIXTURE_DOMAINS, definition.domains),
411
+ label: definition.label ?? id,
412
+ locks: definition.locks ?? [],
413
+ seed: definition.seed ?? 1,
414
+ });
415
+ }
416
+
417
+ export function validateLightFixtureGeneratorRecipe(input) {
418
+ return validateGeneratorRecipeDocument(input, { domain: LIGHT_FIXTURE_GENERATOR_DOMAIN });
419
+ }
420
+
421
+ export function parseLightFixtureGeneratorRecipe(input) {
422
+ return parseGeneratorRecipeDocument(input, { domain: LIGHT_FIXTURE_GENERATOR_DOMAIN });
423
+ }
424
+
425
+ export function serializeLightFixtureGeneratorRecipe(idOrDocument, definition = {}, { pretty = true } = {}) {
426
+ return serializeGeneratorRecipeDocument(LIGHT_FIXTURE_GENERATOR_DOMAIN, idOrDocument, definition, { pretty });
427
+ }
428
+
429
+ /** Resolves a fixture generator recipe into normalized fixture settings. */
430
+ export function resolveLightFixtureGeneratorRecipe(recipeInput) {
431
+ const recipe = recipeInput?.type
432
+ ? validateLightFixtureGeneratorRecipe(recipeInput).value
433
+ : createLightFixtureGeneratorRecipe('generated-fixture', recipeInput);
434
+ if (!recipe) throw new Error('Invalid light fixture generator recipe.');
435
+ const sampled = resolveGeneratorRecipe(recipe, { sanitizeSettings: (value) => value });
436
+ return buildLightFixtureFromSample(sampled, recipe.configuration ?? {});
437
+ }
438
+
439
+ export function createGeneratedLightFixtureDocument(recipeInput, {
440
+ id = recipeInput?.id ? `${recipeInput.id}-resolved` : 'generated-fixture-resolved',
441
+ label = recipeInput?.label ? `${recipeInput.label} · resolved` : 'Generated Fixture · resolved',
442
+ } = {}) {
443
+ const settings = resolveLightFixtureGeneratorRecipe(recipeInput);
444
+ return createLightFixtureDocument(id, {
445
+ description: `Resolved from generator seed ${recipeInput?.seed ?? 1}.`,
446
+ label,
447
+ settings,
448
+ });
449
+ }