@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,774 @@
1
+ // Developer-facing lighting runtime: one style + a fixture vocabulary +
2
+ // tiny scene overlays, instead of per-scene light configuration.
3
+ //
4
+ // const lighting = createLightingSystem({ scene, renderer, style: 'storybook' });
5
+ // lighting.attachWorld(world); // binds sun rig, fog (once per world)
6
+ // lighting.setTimeOfDay(18.5); // whole look follows the style's day cycle
7
+ // lighting.place('street-lamp', [4, 0, 2]); // semantic fixture, seeded variation
8
+ // lighting.applyOverlay(tavernOverlay); // small per-scene adjustments
9
+ // lighting.update(dt, camera); // flicker, blends, light budgets
10
+ //
11
+ // Ownership contract (one writer per property): while attached, the system
12
+ // owns sun direction/color/intensity + accents, coordinated Sky/Water/
13
+ // vegetation scene inputs, environment tints, fog color, tone-mapping
14
+ // exposure, its own ambient light, and every placed fixture. A composed world
15
+ // keeps owning the physical shadow-follow translation while Lighting supplies
16
+ // the true direction through world.setSun(). Weather modulates through
17
+ // setWeatherModulation() rather than writing the same targets.
18
+
19
+ import * as THREE from 'three';
20
+
21
+ import { deriveSeed } from '../core/generation.js';
22
+ import { createLightingManager } from './lightingRuntime.js';
23
+ import { resolveFixturePlacement, resolveLightFixture } from './lightingFixtures.js';
24
+ import { resolveLightingStylePreset, sampleLightingStyle } from './lightingStyle.js';
25
+ import { clamp, cloneJson, finite, isPlainObject, slug } from './utils.js';
26
+ import { SKY_SCENE_OVERRIDE_PRIORITIES } from '../sky/sceneOverrideLayers.js';
27
+ import { WATER_SCENE_OVERRIDE_PRIORITIES } from '../water/sceneOverrideLayers.js';
28
+
29
+ const DEFAULT_MODULATION = Object.freeze({
30
+ ambientScale: 1,
31
+ exposureScale: 1,
32
+ fixtureScale: 1,
33
+ fogColorOverride: null,
34
+ fogColorTint: null,
35
+ sunColorTint: null,
36
+ sunIntensityScale: 1,
37
+ });
38
+
39
+ function normalizeModulation(source = {}) {
40
+ const input = isPlainObject(source) ? source : {};
41
+ return {
42
+ ambientScale: Math.max(finite(input.ambientScale, 1), 0),
43
+ exposureScale: Math.max(finite(input.exposureScale, 1), 0),
44
+ fixtureScale: Math.max(finite(input.fixtureScale, 1), 0),
45
+ fogColorOverride: Array.isArray(input.fogColorOverride)
46
+ ? input.fogColorOverride.slice(0, 3).map((v) => clamp(finite(v, 0), 0, 1))
47
+ : null,
48
+ fogColorTint: Array.isArray(input.fogColorTint) ? input.fogColorTint.slice(0, 3).map((v) => Math.max(finite(v, 1), 0)) : null,
49
+ sunColorTint: Array.isArray(input.sunColorTint) ? input.sunColorTint.slice(0, 3).map((v) => Math.max(finite(v, 1), 0)) : null,
50
+ sunIntensityScale: Math.max(finite(input.sunIntensityScale, 1), 0),
51
+ };
52
+ }
53
+
54
+ function normalizeOverlay(source = {}) {
55
+ const input = isPlainObject(source) ? source : {};
56
+ const adjustments = isPlainObject(input.adjustments) ? input.adjustments : input;
57
+ return {
58
+ adjustments: {
59
+ ambientScale: Math.max(finite(adjustments.ambientScale, 1), 0),
60
+ exposureScale: Math.max(finite(adjustments.exposureScale, 1), 0),
61
+ fixtureScale: Math.max(finite(adjustments.fixtureScale, 1), 0),
62
+ fogColor: Array.isArray(adjustments.fogColor) ? adjustments.fogColor.slice(0, 3).map((v) => clamp(finite(v, 0), 0, 1)) : null,
63
+ sunIntensityScale: Math.max(finite(adjustments.sunIntensityScale, 1), 0),
64
+ },
65
+ fixtures: Array.isArray(input.fixtures)
66
+ ? input.fixtures.filter(isPlainObject).map((entry) => ({
67
+ fixture: entry.fixture ?? entry.id,
68
+ overrides: isPlainObject(entry.overrides) ? entry.overrides : null,
69
+ position: Array.isArray(entry.position) ? entry.position.slice(0, 3) : null,
70
+ seed: entry.seed,
71
+ target: Array.isArray(entry.target) ? entry.target.slice(0, 3) : null,
72
+ }))
73
+ : [],
74
+ id: slug(input.id ?? input.name, 'overlay'),
75
+ };
76
+ }
77
+
78
+ function scheduleScale(schedule, frame) {
79
+ if (schedule.mode === 'always') return 1;
80
+ if (schedule.mode === 'day') {
81
+ // fixtureScale is high at night; day fixtures invert it.
82
+ return Math.max(clamp(1 - frame.fixtureScale, 0, 1), schedule.minimum);
83
+ }
84
+ return Math.max(frame.fixtureScale, schedule.minimum);
85
+ }
86
+
87
+ const ENVIRONMENT_LIGHTING_UNIFORM_KEYS = Object.freeze([
88
+ 'heightFogColor',
89
+ 'skyGroundTint',
90
+ 'skyTopTint',
91
+ ]);
92
+
93
+ function captureEnvironmentLighting(root) {
94
+ if (!root?.traverse) return [];
95
+ const seen = new Set();
96
+ const snapshots = [];
97
+ root.traverse((object) => {
98
+ if (!object.isMesh || !object.material) return;
99
+ const materials = Array.isArray(object.material) ? object.material : [object.material];
100
+ for (const material of materials) {
101
+ if (seen.has(material) || !material?.userData?.environmentMaterial || !material.uniforms) continue;
102
+ seen.add(material);
103
+ const values = {};
104
+ for (const key of ENVIRONMENT_LIGHTING_UNIFORM_KEYS) {
105
+ const value = material.uniforms[key]?.value;
106
+ if (value?.clone) values[key] = value.clone();
107
+ }
108
+ snapshots.push({ material, values });
109
+ }
110
+ });
111
+ return snapshots;
112
+ }
113
+
114
+ function restoreEnvironmentLighting(snapshots = []) {
115
+ for (const { material, values } of snapshots) {
116
+ for (const [key, value] of Object.entries(values)) {
117
+ material?.uniforms?.[key]?.value?.copy?.(value);
118
+ }
119
+ }
120
+ }
121
+
122
+ function captureSunRigState(sunRig) {
123
+ const light = sunRig?.light;
124
+ if (!light) return null;
125
+ const opacity = (mesh) => mesh?.material?.uniforms?.opacity?.value ?? null;
126
+ return {
127
+ beamOpacity: opacity(sunRig.beam),
128
+ color: light.color.toArray(),
129
+ diskOpacity: opacity(sunRig.disk),
130
+ intensity: light.intensity,
131
+ shaftOpacity: opacity(sunRig.shaft),
132
+ spillOpacity: opacity(sunRig.spill),
133
+ };
134
+ }
135
+
136
+ function restoreSunRigState(sunRig, state) {
137
+ if (!sunRig?.light || !state) return;
138
+ const color = Array.isArray(state.color)
139
+ ? new THREE.Color().fromArray(state.color)
140
+ : state.color;
141
+ if (sunRig.setState) {
142
+ sunRig.setState({
143
+ beamOpacity: state.beamOpacity ?? undefined,
144
+ color,
145
+ diskOpacity: state.diskOpacity ?? undefined,
146
+ intensity: state.intensity,
147
+ shaftOpacity: state.shaftOpacity ?? undefined,
148
+ spillOpacity: state.spillOpacity ?? undefined,
149
+ });
150
+ } else {
151
+ if (color) sunRig.light.color.copy(color);
152
+ if (state.intensity !== null && state.intensity !== undefined) sunRig.light.intensity = state.intensity;
153
+ }
154
+ }
155
+
156
+ export function createLightingSystem({
157
+ camera = null,
158
+ capabilities = null,
159
+ quality = null,
160
+ renderer = null,
161
+ scene = null,
162
+ seed = 1,
163
+ style = 'storybook',
164
+ textureResolver = null,
165
+ timeOfDay = 12,
166
+ } = {}) {
167
+ let styleSettings = resolveLightingStylePreset(style);
168
+ let hour = clamp(finite(timeOfDay, 12), 0, 24);
169
+ let frame = sampleLightingStyle(styleSettings, hour);
170
+ let modulation = { ...DEFAULT_MODULATION };
171
+ let clock = 0;
172
+ let placementCounter = 0;
173
+ let disposed = false;
174
+
175
+ const manager = createLightingManager({
176
+ camera,
177
+ capabilities,
178
+ quality: quality ?? styleSettings.quality,
179
+ recipe: { id: 'lighting-system', lights: [], name: 'Lighting System', shadowPolicy: styleSettings.shadowPolicy },
180
+ renderer,
181
+ scene,
182
+ textureResolver,
183
+ });
184
+
185
+ // The system owns exactly one ambient light so scene ambience has a single
186
+ // writer; it lives in the manager group so scene attachment follows along.
187
+ const ambientLight = new THREE.AmbientLight(0xffffff, 0);
188
+ ambientLight.name = 'Lighting System Ambient';
189
+ manager.group.add(ambientLight);
190
+
191
+ const placements = new Map();
192
+ const overlays = new Map();
193
+ const skyOverrideLayer = Symbol('ToonLab LightingSystem sky layer');
194
+ const waterOverrideLayer = Symbol('ToonLab LightingSystem water layer');
195
+
196
+ // Attach targets + the state to restore on detach/dispose.
197
+ const attachment = {
198
+ driveSunPosition: false,
199
+ environmentRoot: null,
200
+ fog: null,
201
+ getSun: null,
202
+ getSunDirection: null,
203
+ lampRig: null,
204
+ ownSun: null,
205
+ restore: null,
206
+ sky: null,
207
+ sunDistance: 60,
208
+ setSunDirection: null,
209
+ setSun: null,
210
+ sunRig: null,
211
+ weather: null,
212
+ weatherLighting: null,
213
+ water: null,
214
+ };
215
+
216
+ const SKY_DRIVEN_KEYS = ['horizonColor', 'starsStrength', 'sunColor', 'sunDirection', 'zenithColor'];
217
+ const WATER_DRIVEN_KEYS = ['skyHorizonColor', 'skyZenithColor', 'sunColor', 'sunDirection'];
218
+
219
+ function captureRestoreState() {
220
+ const sunLight = attachment.sunRig?.light ?? null;
221
+ const skySettings = attachment.sky?.setSceneOverrideLayer
222
+ ? null
223
+ : attachment.sky?.settings ?? null;
224
+ const waterSettings = attachment.water?.setSceneOverrideLayer
225
+ ? null
226
+ : attachment.water?.settings ?? null;
227
+ attachment.restore = {
228
+ environmentLighting: captureEnvironmentLighting(attachment.environmentRoot),
229
+ exposure: renderer ? renderer.toneMappingExposure : null,
230
+ fogColor: attachment.fog?.color?.clone?.() ?? null,
231
+ sky: skySettings
232
+ ? Object.fromEntries(SKY_DRIVEN_KEYS.map((key) => [key, cloneJson(skySettings[key])]))
233
+ : null,
234
+ sunColor: sunLight?.color?.clone?.() ?? null,
235
+ sunState: attachment.getSun ? cloneJson(attachment.getSun()) : null,
236
+ sunDirection: attachment.getSunDirection
237
+ ? cloneJson(attachment.getSunDirection())
238
+ : null,
239
+ sunIntensity: sunLight?.intensity ?? null,
240
+ sunPosition: sunLight?.position?.clone?.() ?? null,
241
+ sunRigState: captureSunRigState(attachment.sunRig),
242
+ water: waterSettings
243
+ ? Object.fromEntries(WATER_DRIVEN_KEYS.map((key) => [key, cloneJson(waterSettings[key])]))
244
+ : null,
245
+ weatherSunBaseline: attachment.weather?.sunBaseline ?? null,
246
+ weatherSunRigBaseline: attachment.weather?.sunRigBaseline ?? null,
247
+ };
248
+ }
249
+
250
+ function restoreAttachedState() {
251
+ const restore = attachment.restore;
252
+ const weatherWasDisposed = Boolean(attachment.weather?.disposed);
253
+ const sunStateToRestore = weatherWasDisposed && restore?.weatherSunBaseline
254
+ ? restore.weatherSunBaseline
255
+ : restore?.sunState;
256
+ const sunRigStateToRestore = weatherWasDisposed && restore?.weatherSunRigBaseline
257
+ ? restore.weatherSunRigBaseline
258
+ : restore?.sunRigState;
259
+ if (restore) {
260
+ if (renderer && restore.exposure !== null) renderer.toneMappingExposure = restore.exposure;
261
+ if (attachment.fog && restore.fogColor) attachment.fog.color.copy(restore.fogColor);
262
+ const sunLight = attachment.sunRig?.light ?? null;
263
+ if (sunLight) {
264
+ restoreSunRigState(attachment.sunRig, sunRigStateToRestore);
265
+ if (restore.sunPosition && attachment.driveSunPosition) sunLight.position.copy(restore.sunPosition);
266
+ }
267
+ if (sunStateToRestore && attachment.setSun) attachment.setSun(sunStateToRestore);
268
+ }
269
+ if (attachment.weather?.setLightingSystem
270
+ && attachment.weather.lightingSystem === api) {
271
+ attachment.weather.setLightingSystem(attachment.weatherLighting);
272
+ }
273
+ // Resetting Weather modulation refreshes the Lighting frame once, so the
274
+ // world-owned direction must be restored after that refresh.
275
+ const directionToRestore = sunStateToRestore?.direction ?? restore?.sunDirection;
276
+ if (directionToRestore && attachment.setSunDirection) {
277
+ attachment.setSunDirection(directionToRestore);
278
+ }
279
+ // Unbinding Weather resets Lighting modulation, which refreshes its Sky
280
+ // layer once. Remove our layer afterwards so only Weather remains.
281
+ if (attachment.sky?.clearSceneOverrideLayer) {
282
+ attachment.sky.clearSceneOverrideLayer(skyOverrideLayer);
283
+ } else if (attachment.sky && restore?.sky) {
284
+ attachment.sky.applySettings(restore.sky);
285
+ }
286
+ if (attachment.water?.clearSceneOverrideLayer) {
287
+ attachment.water.clearSceneOverrideLayer(waterOverrideLayer);
288
+ } else if (attachment.water?.clearSceneOverrides) {
289
+ attachment.water.clearSceneOverrides();
290
+ } else if (attachment.water && restore?.water) {
291
+ attachment.water.applySettings?.(restore.water);
292
+ }
293
+ restoreEnvironmentLighting(restore?.environmentLighting);
294
+ attachment.restore = null;
295
+ attachment.weather = null;
296
+ attachment.weatherLighting = null;
297
+ }
298
+
299
+ function overlayFactor(key) {
300
+ let factor = 1;
301
+ for (const overlay of overlays.values()) {
302
+ const value = overlay.adjustments[key];
303
+ factor *= 1 + (value - 1) * overlay.weight;
304
+ }
305
+ return factor;
306
+ }
307
+
308
+ function overlayFogColor() {
309
+ let color = null;
310
+ let weight = 0;
311
+ for (const overlay of overlays.values()) {
312
+ if (overlay.adjustments.fogColor && overlay.weight > weight) {
313
+ color = overlay.adjustments.fogColor;
314
+ weight = overlay.weight;
315
+ }
316
+ }
317
+ return color ? { color, weight } : null;
318
+ }
319
+
320
+ const workColor = new THREE.Color();
321
+
322
+ function applyFrame() {
323
+ const sunScale = modulation.sunIntensityScale * overlayFactor('sunIntensityScale');
324
+ const sunColor = [...frame.sunColor];
325
+ if (modulation.sunColorTint) {
326
+ for (let i = 0; i < 3; i += 1) sunColor[i] = clamp(sunColor[i] * modulation.sunColorTint[i], 0, 4);
327
+ }
328
+ const sunDirection = [frame.sunSourceRatios.x, frame.sunSourceRatios.y, frame.sunSourceRatios.z];
329
+
330
+ if (attachment.sunRig?.setState) {
331
+ attachment.sunRig.setState({
332
+ beamOpacity: 0.28 * frame.accentScale * sunScale,
333
+ color: workColor.setRGB(sunColor[0], sunColor[1], sunColor[2]),
334
+ diskOpacity: 0.62 * frame.accentScale * sunScale,
335
+ intensity: frame.sunIntensity * sunScale,
336
+ shaftOpacity: 0.1 * frame.accentScale * sunScale,
337
+ spillOpacity: 0.3 * frame.accentScale * sunScale,
338
+ ...(attachment.driveSunPosition ? { sourceRatios: frame.sunSourceRatios } : {}),
339
+ });
340
+ } else if (attachment.ownSun) {
341
+ attachment.ownSun.color.setRGB(sunColor[0], sunColor[1], sunColor[2]);
342
+ attachment.ownSun.intensity = frame.sunIntensity * sunScale;
343
+ attachment.ownSun.position.set(
344
+ frame.sunSourceRatios.x,
345
+ frame.sunSourceRatios.y,
346
+ frame.sunSourceRatios.z,
347
+ ).multiplyScalar(attachment.sunDistance);
348
+ attachment.ownSun.target.position.set(0, 0, 0);
349
+ attachment.ownSun.target.updateMatrixWorld();
350
+ }
351
+
352
+ const style = styleSettings;
353
+ ambientLight.visible = style.ambientLight.enabled;
354
+ ambientLight.color.setRGB(...style.ambientLight.color);
355
+ ambientLight.intensity = style.ambientLight.intensity
356
+ * frame.ambientScale
357
+ * modulation.ambientScale
358
+ * overlayFactor('ambientScale');
359
+
360
+ const effectiveFogColor = [...frame.fogColor];
361
+ if (modulation.fogColorTint) {
362
+ for (let i = 0; i < 3; i += 1) effectiveFogColor[i] = clamp(effectiveFogColor[i] * modulation.fogColorTint[i], 0, 1);
363
+ }
364
+ if (modulation.fogColorOverride) {
365
+ for (let i = 0; i < 3; i += 1) effectiveFogColor[i] = modulation.fogColorOverride[i];
366
+ }
367
+ const overlayFog = overlayFogColor();
368
+ if (overlayFog) {
369
+ for (let i = 0; i < 3; i += 1) {
370
+ effectiveFogColor[i] += (overlayFog.color[i] - effectiveFogColor[i]) * overlayFog.weight;
371
+ }
372
+ }
373
+ if (attachment.fog?.color) {
374
+ attachment.fog.color.setRGB(...effectiveFogColor);
375
+ }
376
+
377
+ if (renderer && style.exposure.enabled) {
378
+ renderer.toneMappingExposure = frame.exposure
379
+ * modulation.exposureScale
380
+ * overlayFactor('exposureScale');
381
+ }
382
+
383
+ attachment.lampRig?.setIntensity?.(frame.fixtureScale);
384
+
385
+ // The house sky dome follows the style's day cycle: zenith/horizon
386
+ // colors, star strength, and the sun disk color/direction all move with
387
+ // the hour, so nights are actually dark and starry.
388
+ if (attachment.sky) {
389
+ const skyFrame = {
390
+ horizonColor: frame.skyHorizonColor,
391
+ starsStrength: frame.starsStrength,
392
+ sunColor: sunColor,
393
+ sunDirection,
394
+ zenithColor: frame.skyZenithColor,
395
+ };
396
+ if (attachment.sky.setSceneOverrideLayer) {
397
+ attachment.sky.setSceneOverrideLayer(skyOverrideLayer, skyFrame, {
398
+ priority: SKY_SCENE_OVERRIDE_PRIORITIES.lighting,
399
+ });
400
+ } else if (attachment.sky.setSceneOverrides) {
401
+ attachment.sky.setSceneOverrides(skyFrame);
402
+ } else if (attachment.sky.applySettings) {
403
+ attachment.sky.applySettings(skyFrame);
404
+ }
405
+ }
406
+
407
+ const renderedSky = attachment.sky?.renderedSettings;
408
+ const skyFillColor = renderedSky?.horizonColor ?? frame.skyHorizonColor;
409
+ if (attachment.setSun) {
410
+ attachment.setSun({ color: sunColor, direction: sunDirection, sky: skyFillColor });
411
+ } else {
412
+ attachment.setSunDirection?.(sunDirection);
413
+ }
414
+
415
+ if (attachment.water) {
416
+ const waterFrame = {
417
+ skyHorizonColor: renderedSky?.horizonColor ?? frame.skyHorizonColor,
418
+ skyZenithColor: renderedSky?.zenithColor ?? frame.skyZenithColor,
419
+ sunColor,
420
+ sunDirection,
421
+ };
422
+ if (attachment.water.setSceneOverrideLayer) {
423
+ attachment.water.setSceneOverrideLayer(waterOverrideLayer, waterFrame, {
424
+ priority: WATER_SCENE_OVERRIDE_PRIORITIES.lighting,
425
+ });
426
+ } else if (attachment.water.setSceneOverrides) {
427
+ attachment.water.setSceneOverrides(waterFrame);
428
+ } else if (attachment.water.applySettings) {
429
+ attachment.water.applySettings(waterFrame);
430
+ }
431
+ }
432
+
433
+ if (attachment.environmentRoot?.traverse) {
434
+ attachment.environmentRoot.traverse((object) => {
435
+ if (!object.isMesh || !object.material) return;
436
+ const materials = Array.isArray(object.material) ? object.material : [object.material];
437
+ for (const material of materials) {
438
+ if (!material?.userData?.environmentMaterial || !material.uniforms) continue;
439
+ material.uniforms.skyGroundTint?.value?.setRGB?.(...frame.skyGroundTint);
440
+ material.uniforms.skyTopTint?.value?.setRGB?.(...frame.skyTopTint);
441
+ material.uniforms.heightFogColor?.value?.setRGB?.(...effectiveFogColor);
442
+ }
443
+ });
444
+ }
445
+ }
446
+
447
+ function applyPlacementIntensities() {
448
+ if (placements.size === 0) return;
449
+ const styleScale = styleSettings.fixtures.intensityScale
450
+ * modulation.fixtureScale
451
+ * overlayFactor('fixtureScale');
452
+ for (const placement of placements.values()) {
453
+ const light = manager.getLight(placement.lightId);
454
+ if (!light) continue;
455
+ let intensity = placement.baseIntensity * styleScale * scheduleScale(placement.schedule, frame);
456
+ if (placement.flicker.amount > 0) {
457
+ const t = clock * placement.flicker.speed * Math.PI * 2 + placement.flickerPhase;
458
+ // Two detuned sines make organic, deterministic flicker without a
459
+ // noise table; amplitude stays within ±amount.
460
+ const wave = Math.sin(t) * 0.6 + Math.sin(t * 1.7 + placement.flickerPhase) * 0.4;
461
+ intensity *= 1 + placement.flicker.amount * wave;
462
+ }
463
+ light.intensity = Math.max(intensity, 0);
464
+ }
465
+ }
466
+
467
+ function refreshFrame() {
468
+ frame = sampleLightingStyle(styleSettings, hour);
469
+ applyFrame();
470
+ applyPlacementIntensities();
471
+ }
472
+
473
+ const api = {
474
+ /** Binds scene handles. Call with a stylized world or explicit targets. */
475
+ attach({
476
+ driveSunPosition = false,
477
+ environmentRoot = null,
478
+ fog = null,
479
+ getSun = null,
480
+ getSunDirection = null,
481
+ lampRig = null,
482
+ sky = null,
483
+ sunDistance = 60,
484
+ setSunDirection = null,
485
+ setSun = null,
486
+ sunRig = null,
487
+ weather = null,
488
+ water = null,
489
+ } = {}) {
490
+ if (disposed) throw new Error('Cannot attach a disposed lighting system.');
491
+ restoreAttachedState();
492
+ attachment.driveSunPosition = Boolean(driveSunPosition);
493
+ attachment.environmentRoot = environmentRoot;
494
+ attachment.fog = fog ?? null;
495
+ attachment.getSun = typeof getSun === 'function' ? getSun : null;
496
+ attachment.getSunDirection = typeof getSunDirection === 'function' ? getSunDirection : null;
497
+ attachment.lampRig = lampRig ?? null;
498
+ attachment.sky = sky?.setSceneOverrideLayer || sky?.setSceneOverrides || sky?.applySettings
499
+ ? sky
500
+ : null;
501
+ attachment.sunDistance = Math.max(finite(sunDistance, 60), 1);
502
+ attachment.setSunDirection = typeof setSunDirection === 'function' ? setSunDirection : null;
503
+ attachment.setSun = typeof setSun === 'function' ? setSun : null;
504
+ attachment.sunRig = sunRig?.light ? sunRig : null;
505
+ attachment.weather = weather?.setLightingSystem ? weather : null;
506
+ attachment.weatherLighting = attachment.weather?.lightingSystem ?? null;
507
+ attachment.water = water?.setSceneOverrideLayer || water?.setSceneOverrides || water?.applySettings
508
+ ? water
509
+ : null;
510
+ if (!attachment.sunRig && scene && !attachment.ownSun) {
511
+ const sun = new THREE.DirectionalLight(0xffffff, 1);
512
+ sun.name = 'Lighting System Sun';
513
+ sun.castShadow = true;
514
+ manager.group.add(sun);
515
+ manager.group.add(sun.target);
516
+ attachment.ownSun = sun;
517
+ }
518
+ captureRestoreState();
519
+ refreshFrame();
520
+ attachment.weather?.setLightingSystem(api);
521
+ return api;
522
+ },
523
+
524
+ /** Convenience: binds the handles a createStylizedWorld result exposes. */
525
+ attachWorld(world, options = {}) {
526
+ return api.attach({
527
+ environmentRoot: world?.environmentRoot ?? null,
528
+ fog: world?.fog ?? null,
529
+ getSun: world ? () => world.sunState : null,
530
+ getSunDirection: world ? () => world.sunDirection : null,
531
+ sky: world?.sky ?? null,
532
+ sunRig: world?.sunRig ?? null,
533
+ setSun: world?.setSun ?? null,
534
+ setSunDirection: world?.setSunDirection ?? null,
535
+ weather: world?.weather ?? null,
536
+ water: world?.water ?? null,
537
+ // Worlds translate the physical light every frame for shadow-follow;
538
+ // the style drives its true direction, color, and intensity. Sky uses
539
+ // a lower-priority Lighting layer so Weather composes above it without
540
+ // either system mutating the authored Sky preset.
541
+ driveSunPosition: false,
542
+ ...options,
543
+ });
544
+ },
545
+
546
+ detach() {
547
+ restoreAttachedState();
548
+ attachment.driveSunPosition = false;
549
+ attachment.environmentRoot = null;
550
+ attachment.fog = null;
551
+ attachment.getSun = null;
552
+ attachment.getSunDirection = null;
553
+ attachment.lampRig = null;
554
+ attachment.sky = null;
555
+ attachment.sunRig = null;
556
+ attachment.setSunDirection = null;
557
+ attachment.setSun = null;
558
+ attachment.weather = null;
559
+ attachment.weatherLighting = null;
560
+ attachment.water = null;
561
+ return api;
562
+ },
563
+
564
+ /** Swaps the whole lighting identity. Placements survive. */
565
+ setStyle(nextStyle) {
566
+ if (disposed) throw new Error('Cannot update a disposed lighting system.');
567
+ styleSettings = resolveLightingStylePreset(nextStyle);
568
+ if (!quality) manager.setQuality(styleSettings.quality);
569
+ refreshFrame();
570
+ return api;
571
+ },
572
+
573
+ setQuality(nextQuality) {
574
+ quality = nextQuality;
575
+ manager.setQuality(nextQuality);
576
+ return api;
577
+ },
578
+
579
+ /** Sets the hour (0-24). The entire look follows the style's day cycle. */
580
+ setTimeOfDay(nextHour) {
581
+ hour = ((finite(nextHour, 12) % 24) + 24) % 24;
582
+ refreshFrame();
583
+ return frame;
584
+ },
585
+
586
+ /** Advances the day cycle; use from update loops for live cycles. */
587
+ advanceTime(hoursDelta) {
588
+ return api.setTimeOfDay(hour + finite(hoursDelta, 0));
589
+ },
590
+
591
+ /**
592
+ * Weather integration point: one multiplicative layer instead of a second
593
+ * writer on the same lights/fog/exposure.
594
+ */
595
+ setWeatherModulation(nextModulation) {
596
+ modulation = normalizeModulation(nextModulation);
597
+ refreshFrame();
598
+ return api;
599
+ },
600
+
601
+ /** Places a fixture. Returns a handle: { id, descriptor, light, remove, set }. */
602
+ place(fixture, position = null, { id = null, overrides = null, seed: placementSeed = null, target = null } = {}) {
603
+ if (disposed) throw new Error('Cannot update a disposed lighting system.');
604
+ const fixtureId = typeof fixture === 'string' ? fixture : slug(fixture?.id ?? 'fixture', 'fixture');
605
+ const fixtureSettings = resolveLightFixture(fixture);
606
+ placementCounter += 1;
607
+ const resolvedSeed = placementSeed ?? deriveSeed(seed, `${fixtureId}:${placementCounter}`);
608
+ const placement = resolveFixturePlacement(fixtureSettings, {
609
+ id: id ?? `${fixtureId}-${placementCounter}`,
610
+ overrides,
611
+ position,
612
+ seed: resolvedSeed,
613
+ target,
614
+ });
615
+ const lightId = manager.addLight(placement.descriptor);
616
+ const light = manager.getLight(lightId);
617
+ const record = {
618
+ baseIntensity: light?.intensity ?? 1,
619
+ fixtureId,
620
+ flicker: placement.flicker,
621
+ flickerPhase: placement.flickerPhase,
622
+ input: { fixture: fixtureId, id, overrides: cloneJson(overrides), position, seed: resolvedSeed, target },
623
+ lightId,
624
+ overlayId: null,
625
+ schedule: placement.schedule,
626
+ };
627
+ placements.set(lightId, record);
628
+ applyPlacementIntensities();
629
+ return {
630
+ descriptor: placement.descriptor,
631
+ id: lightId,
632
+ light,
633
+ remove: () => api.removePlacement(lightId),
634
+ set: (nextOverrides) => {
635
+ const next = manager.updateLight(lightId, nextOverrides);
636
+ if (next) record.baseIntensity = manager.getLight(lightId)?.intensity ?? record.baseIntensity;
637
+ applyPlacementIntensities();
638
+ return next;
639
+ },
640
+ };
641
+ },
642
+
643
+ removePlacement(idOrHandle) {
644
+ const lightId = typeof idOrHandle === 'string' ? idOrHandle : idOrHandle?.id;
645
+ if (!placements.has(lightId)) return false;
646
+ placements.delete(lightId);
647
+ return manager.removeLight(lightId);
648
+ },
649
+
650
+ /** Applies a scene overlay; adjustments blend in over blendSeconds. */
651
+ applyOverlay(overlay, { blendSeconds = 0.4 } = {}) {
652
+ if (disposed) throw new Error('Cannot update a disposed lighting system.');
653
+ const normalized = normalizeOverlay(overlay);
654
+ api.removeOverlay(normalized.id, { blendSeconds: 0 });
655
+ const placementIds = [];
656
+ for (const entry of normalized.fixtures) {
657
+ if (!entry.fixture) continue;
658
+ const handle = api.place(entry.fixture, entry.position, {
659
+ overrides: entry.overrides,
660
+ seed: entry.seed,
661
+ target: entry.target,
662
+ });
663
+ placements.get(handle.id).overlayId = normalized.id;
664
+ placementIds.push(handle.id);
665
+ }
666
+ overlays.set(normalized.id, {
667
+ adjustments: normalized.adjustments,
668
+ blendSeconds: Math.max(finite(blendSeconds, 0.4), 0),
669
+ id: normalized.id,
670
+ placementIds,
671
+ target: 1,
672
+ weight: blendSeconds > 0 ? 0 : 1,
673
+ });
674
+ refreshFrame();
675
+ return normalized.id;
676
+ },
677
+
678
+ removeOverlay(id, { blendSeconds = 0.4 } = {}) {
679
+ const overlay = overlays.get(id);
680
+ if (!overlay) return false;
681
+ overlay.target = 0;
682
+ overlay.blendSeconds = Math.max(finite(blendSeconds, 0.4), 0);
683
+ if (overlay.blendSeconds === 0) {
684
+ for (const placementId of overlay.placementIds) api.removePlacement(placementId);
685
+ overlays.delete(id);
686
+ refreshFrame();
687
+ }
688
+ return true;
689
+ },
690
+
691
+ /** Per-frame: overlay blends, fixture flicker, light budget selection. */
692
+ update(delta = 0.016, nextCamera = null) {
693
+ if (disposed) return;
694
+ const dt = clamp(finite(delta, 0.016), 0, 0.1);
695
+ clock += dt;
696
+ let overlaysChanged = false;
697
+ for (const overlay of [...overlays.values()]) {
698
+ if (overlay.weight !== overlay.target) {
699
+ const step = overlay.blendSeconds > 0 ? dt / overlay.blendSeconds : 1;
700
+ overlay.weight = overlay.target > overlay.weight
701
+ ? Math.min(overlay.weight + step, overlay.target)
702
+ : Math.max(overlay.weight - step, overlay.target);
703
+ overlaysChanged = true;
704
+ if (overlay.weight === 0 && overlay.target === 0) {
705
+ for (const placementId of overlay.placementIds) api.removePlacement(placementId);
706
+ overlays.delete(overlay.id);
707
+ }
708
+ }
709
+ }
710
+ if (overlaysChanged) applyFrame();
711
+ applyPlacementIntensities();
712
+ manager.update(nextCamera ? { camera: nextCamera } : {});
713
+ },
714
+
715
+ stats() {
716
+ const diagnostics = manager.getDiagnostics();
717
+ const byCategory = {};
718
+ for (const placement of placements.values()) {
719
+ byCategory[placement.fixtureId] = (byCategory[placement.fixtureId] ?? 0) + 1;
720
+ }
721
+ return {
722
+ activeLightCount: diagnostics.activeLightCount,
723
+ backend: diagnostics.backend,
724
+ hour,
725
+ overlayCount: overlays.size,
726
+ placementCount: placements.size,
727
+ placementsByFixture: byCategory,
728
+ shadowedLightCount: diagnostics.shadowedLightCount,
729
+ totalLightCount: diagnostics.totalLightCount,
730
+ warnings: diagnostics.warnings,
731
+ };
732
+ },
733
+
734
+ toJSON() {
735
+ return {
736
+ placements: [...placements.values()]
737
+ .filter((placement) => placement.overlayId === null)
738
+ .map((placement) => cloneJson(placement.input)),
739
+ style: cloneJson(styleSettings),
740
+ timeOfDay: hour,
741
+ };
742
+ },
743
+
744
+ /** Removes placements/overlays/modulation and re-applies the style. */
745
+ reset() {
746
+ for (const id of [...placements.keys()]) api.removePlacement(id);
747
+ overlays.clear();
748
+ modulation = { ...DEFAULT_MODULATION };
749
+ placementCounter = 0;
750
+ refreshFrame();
751
+ return api;
752
+ },
753
+
754
+ dispose() {
755
+ if (disposed) return;
756
+ api.reset();
757
+ restoreAttachedState();
758
+ attachment.ownSun?.shadow?.map?.dispose?.();
759
+ manager.dispose();
760
+ disposed = true;
761
+ },
762
+
763
+ get frame() { return frame; },
764
+ get manager() { return manager; },
765
+ get style() { return styleSettings; },
766
+ get timeOfDay() { return hour; },
767
+ getDiagnostics: () => manager.getDiagnostics(),
768
+ /** Toon-response metadata for material adapters (band softness, tint). */
769
+ getToonResponse: () => cloneJson(styleSettings.toonResponse),
770
+ };
771
+
772
+ refreshFrame();
773
+ return api;
774
+ }