@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,723 @@
1
+ import * as THREE from 'three';
2
+ import { resolveLightColor, resolveThreeLightIntensity } from './colorIntensity.js';
3
+ import { createLightingCapabilityReport } from './lightingCapabilities.js';
4
+ import { createLightingLook, createLightingRecipe } from './lightingDocuments.js';
5
+ import { createLightDescriptor, mergeLightDescriptor } from './lightDescriptors.js';
6
+ import {
7
+ createLightingQualityProfile,
8
+ resolveLightingLookPreset,
9
+ resolveLightingQualityPreset,
10
+ resolveLightingRigPreset,
11
+ } from './lightingPresets.js';
12
+ import { cloneJson, finite, isPlainObject, uniqueId } from './utils.js';
13
+
14
+ const LOCAL_LIGHT_TYPES = new Set(['point', 'spot', 'rectArea', 'discArea', 'tubeArea']);
15
+ const AREA_LIGHT_TYPES = new Set(['rectArea', 'discArea', 'tubeArea']);
16
+ const AREA_FALLBACKS = Object.freeze({
17
+ discArea: 'Disc area light approximated by a square Three.RectAreaLight.',
18
+ tubeArea: 'Tube area light approximated by a thin Three.RectAreaLight.',
19
+ });
20
+
21
+ // three's node backends (WebGPU and forced-WebGL) crash on the first visible
22
+ // RectAreaLight unless the LTC lookup textures were installed via
23
+ // RectAreaLightNode.setLTC. The classic WebGL path needs the parallel
24
+ // RectAreaLightUniformsLib. Both are loaded lazily the first time a manager
25
+ // realizes an area light; until then area entries stay culled as
26
+ // 'area-ltc-pending' instead of crashing the render loop.
27
+ let ltcState = 'uninitialized';
28
+ let ltcPromise = null;
29
+
30
+ export function ensureAreaLightSupport() {
31
+ if (ltcPromise) return ltcPromise;
32
+ ltcState = 'loading';
33
+ ltcPromise = Promise.all([
34
+ import('three/webgpu'),
35
+ import('three/addons/lights/RectAreaLightTexturesLib.js'),
36
+ import('three/addons/lights/RectAreaLightUniformsLib.js'),
37
+ ]).then(([webgpu, texturesLib, uniformsLib]) => {
38
+ webgpu.RectAreaLightNode.setLTC(texturesLib.RectAreaLightTexturesLib.init());
39
+ uniformsLib.RectAreaLightUniformsLib.init();
40
+ ltcState = 'ready';
41
+ return true;
42
+ }).catch(() => {
43
+ ltcState = 'unavailable';
44
+ return false;
45
+ });
46
+ return ltcPromise;
47
+ }
48
+
49
+ export function getAreaLightSupportState() {
50
+ return ltcState;
51
+ }
52
+
53
+ function threeColor(value) {
54
+ const [red, green, blue] = resolveLightColor(value);
55
+ const color = new THREE.Color();
56
+ color.setRGB(red, green, blue, THREE.SRGBColorSpace);
57
+ return color;
58
+ }
59
+
60
+ function setLayers(object, layers) {
61
+ if (!object?.layers || !Array.isArray(layers)) return;
62
+ object.layers.disableAll();
63
+ for (const layer of layers) object.layers.enable(layer);
64
+ }
65
+
66
+ function scaledShadowMapSize(descriptor, quality) {
67
+ const raw = descriptor.shadow.mapSize * quality.shadowMapSizeScale;
68
+ return 2 ** Math.round(Math.log2(Math.min(Math.max(raw, 128), 8192)));
69
+ }
70
+
71
+ function configureShadow(light, descriptor, quality, updateMode) {
72
+ if (!light.shadow) return 0;
73
+ const mapSize = scaledShadowMapSize(descriptor, quality);
74
+ light.shadow.mapSize.set(mapSize, mapSize);
75
+ light.shadow.bias = descriptor.shadow.bias;
76
+ light.shadow.normalBias = descriptor.shadow.normalBias;
77
+ light.shadow.radius = descriptor.shadow.radius;
78
+ light.shadow.camera.near = descriptor.shadow.near;
79
+ light.shadow.camera.far = Math.max(descriptor.shadow.far, descriptor.shadow.near + 0.001);
80
+ if (descriptor.type === 'directional') {
81
+ const extent = descriptor.shadow.extent;
82
+ light.shadow.camera.left = -extent;
83
+ light.shadow.camera.right = extent;
84
+ light.shadow.camera.top = extent;
85
+ light.shadow.camera.bottom = -extent;
86
+ }
87
+ light.shadow.camera.updateProjectionMatrix();
88
+ if ('autoUpdate' in light.shadow) light.shadow.autoUpdate = updateMode !== 'manual';
89
+ return mapSize * mapSize * (descriptor.type === 'point' ? 6 : 1);
90
+ }
91
+
92
+ function createThreeLight(descriptor) {
93
+ const intensity = resolveThreeLightIntensity(descriptor.type, descriptor.intensity, descriptor);
94
+ const color = threeColor(descriptor.color);
95
+ let light;
96
+ let fallback = null;
97
+
98
+ switch (descriptor.type) {
99
+ case 'ambient':
100
+ light = new THREE.AmbientLight(color, intensity);
101
+ break;
102
+ case 'hemisphere':
103
+ light = new THREE.HemisphereLight(color, threeColor(descriptor.groundColor), intensity);
104
+ break;
105
+ case 'directional':
106
+ light = new THREE.DirectionalLight(color, intensity);
107
+ break;
108
+ case 'point':
109
+ light = new THREE.PointLight(color, intensity, descriptor.distance, descriptor.decay);
110
+ break;
111
+ case 'spot':
112
+ light = new THREE.SpotLight(
113
+ color,
114
+ intensity,
115
+ descriptor.distance,
116
+ descriptor.angle,
117
+ descriptor.penumbra,
118
+ descriptor.decay,
119
+ );
120
+ break;
121
+ case 'discArea':
122
+ light = new THREE.RectAreaLight(color, intensity, descriptor.width, descriptor.width);
123
+ fallback = AREA_FALLBACKS.discArea;
124
+ break;
125
+ case 'tubeArea':
126
+ light = new THREE.RectAreaLight(color, intensity, descriptor.width, descriptor.height);
127
+ fallback = AREA_FALLBACKS.tubeArea;
128
+ break;
129
+ case 'rectArea':
130
+ default:
131
+ light = new THREE.RectAreaLight(color, intensity, descriptor.width, descriptor.height);
132
+ break;
133
+ }
134
+
135
+ light.name = descriptor.name;
136
+ light.position.fromArray(descriptor.position);
137
+ light.visible = false;
138
+ setLayers(light, descriptor.layers);
139
+ light.userData.toonlabLighting = {
140
+ artistic: cloneJson(descriptor.artistic),
141
+ cookie: cloneJson(descriptor.cookie),
142
+ fallback,
143
+ ies: cloneJson(descriptor.ies),
144
+ id: descriptor.id,
145
+ linking: cloneJson(descriptor.linking),
146
+ type: descriptor.type,
147
+ userData: cloneJson(descriptor.userData),
148
+ };
149
+
150
+ let target = null;
151
+ if (descriptor.type === 'directional' || descriptor.type === 'spot') {
152
+ target = light.target;
153
+ target.name = `${descriptor.name} Target`;
154
+ target.position.fromArray(descriptor.target);
155
+ } else if (AREA_LIGHT_TYPES.has(descriptor.type)) {
156
+ light.lookAt(new THREE.Vector3().fromArray(descriptor.target));
157
+ }
158
+ return { fallback, light, target };
159
+ }
160
+
161
+ function focusPosition(options, camera, target) {
162
+ const source = options?.focus ?? camera;
163
+ if (Array.isArray(source)) {
164
+ return target.set(finite(source[0], 0), finite(source[1], 0), finite(source[2], 0));
165
+ }
166
+ if (source?.isObject3D) return source.getWorldPosition(target);
167
+ if (source && Number.isFinite(source.x) && Number.isFinite(source.y) && Number.isFinite(source.z)) {
168
+ return target.copy(source);
169
+ }
170
+ return target.set(0, 0, 0);
171
+ }
172
+
173
+ function recipeSource(value) {
174
+ if (typeof value === 'string') return resolveLightingRigPreset(value);
175
+ return createLightingRecipe(value);
176
+ }
177
+
178
+ function qualitySource(value) {
179
+ if (typeof value === 'string' || value === null || value === undefined) {
180
+ return resolveLightingQualityPreset(value ?? 'balanced');
181
+ }
182
+ return createLightingQualityProfile(value);
183
+ }
184
+
185
+ function selectionSignature(entries) {
186
+ return entries.map((entry) => `${entry.descriptor.id}:${entry.active ? 1 : 0}:${entry.shadowed ? 1 : 0}`).join('|');
187
+ }
188
+
189
+ /**
190
+ * Realizes a LightingRecipe as Three.js lights and manages runtime budgets.
191
+ * The manager does not replace the renderer's lighting algorithm: it selects,
192
+ * configures, and diagnoses ordinary Three.js lights.
193
+ */
194
+ export function createLightingManager({
195
+ capabilities = null,
196
+ camera: initialCamera = null,
197
+ disposeCookieTextures = false,
198
+ onDiagnostics = null,
199
+ quality: initialQuality = 'balanced',
200
+ recipe: initialRecipe = null,
201
+ renderer = null,
202
+ scene: initialScene = null,
203
+ textureResolver = null,
204
+ } = {}) {
205
+ const group = new THREE.Group();
206
+ group.name = 'ToonLab Lighting Rig';
207
+ const listeners = new Set();
208
+ if (typeof onDiagnostics === 'function') listeners.add(onDiagnostics);
209
+ const worldPosition = new THREE.Vector3();
210
+ const selectionPoint = new THREE.Vector3();
211
+ let camera = initialCamera;
212
+ let scene = null;
213
+ let recipe = initialRecipe ? recipeSource(initialRecipe) : resolveLightingRigPreset('outdoor_sun');
214
+ let quality = qualitySource(initialQuality);
215
+ const detectedCapabilities = createLightingCapabilityReport({ renderer });
216
+ const capabilityReport = capabilities && isPlainObject(capabilities)
217
+ ? {
218
+ ...detectedCapabilities,
219
+ ...capabilities,
220
+ features: { ...detectedCapabilities.features, ...capabilities.features },
221
+ limits: { ...detectedCapabilities.limits, ...capabilities.limits },
222
+ warnings: Array.isArray(capabilities.warnings) ? capabilities.warnings : detectedCapabilities.warnings,
223
+ }
224
+ : detectedCapabilities;
225
+ let entries = [];
226
+ let disposed = false;
227
+ let lastSignature = '';
228
+ let lastDiagnostics = null;
229
+
230
+ function emit(type) {
231
+ if (listeners.size === 0) return;
232
+ const event = Object.freeze({ diagnostics: getDiagnostics(), manager: api, type });
233
+ for (const listener of listeners) listener(event);
234
+ }
235
+
236
+ function resolveCookie(entry) {
237
+ const cookie = entry.descriptor.cookie;
238
+ if (!cookie) {
239
+ entry.cookieStatus = 'none';
240
+ return;
241
+ }
242
+ if (!quality.allowCookies) {
243
+ entry.cookieStatus = 'quality-disabled';
244
+ return;
245
+ }
246
+ if (entry.descriptor.type !== 'spot') {
247
+ entry.cookieStatus = 'unsupported';
248
+ return;
249
+ }
250
+ if (typeof textureResolver !== 'function') {
251
+ entry.cookieStatus = 'unresolved';
252
+ return;
253
+ }
254
+ entry.cookieStatus = 'loading';
255
+ let resolved;
256
+ try {
257
+ resolved = textureResolver(cookie, entry.descriptor);
258
+ } catch (error) {
259
+ entry.cookieStatus = 'error';
260
+ entry.cookieError = error.message;
261
+ return;
262
+ }
263
+ Promise.resolve(resolved).then((texture) => {
264
+ if (disposed || !entries.includes(entry)) return;
265
+ if (!texture?.isTexture) {
266
+ entry.cookieStatus = 'invalid';
267
+ emit('cookie');
268
+ return;
269
+ }
270
+ entry.cookieTexture = texture;
271
+ if (quality.allowCookies) {
272
+ entry.light.map = texture;
273
+ entry.cookieStatus = 'ready';
274
+ } else {
275
+ entry.light.map = null;
276
+ entry.cookieStatus = 'quality-disabled';
277
+ }
278
+ emit('cookie');
279
+ }).catch((error) => {
280
+ if (disposed || !entries.includes(entry)) return;
281
+ entry.cookieStatus = 'error';
282
+ entry.cookieError = error.message;
283
+ emit('cookie');
284
+ });
285
+ }
286
+
287
+ function releaseEntry(entry) {
288
+ entry.light.removeFromParent();
289
+ entry.target?.removeFromParent();
290
+ entry.light.shadow?.map?.dispose?.();
291
+ if (disposeCookieTextures) entry.cookieTexture?.dispose?.();
292
+ }
293
+
294
+ function buildEntry(descriptor, index) {
295
+ const { fallback, light, target } = createThreeLight(descriptor);
296
+ group.add(light);
297
+ if (target) group.add(target);
298
+ if (AREA_LIGHT_TYPES.has(descriptor.type) && ltcState !== 'ready') {
299
+ ensureAreaLightSupport().then((ready) => {
300
+ if (ready && !disposed) update();
301
+ });
302
+ }
303
+ const entry = {
304
+ active: false,
305
+ cookieError: null,
306
+ cookieStatus: 'none',
307
+ cookieTexture: null,
308
+ cullReason: 'not-updated',
309
+ descriptor,
310
+ distance: 0,
311
+ fallback,
312
+ index,
313
+ light,
314
+ score: 0,
315
+ shadowPixels: configureShadow(light, descriptor, quality, recipe.shadowPolicy.updateMode),
316
+ shadowed: false,
317
+ target,
318
+ };
319
+ resolveCookie(entry);
320
+ return entry;
321
+ }
322
+
323
+ function rebuild() {
324
+ for (const entry of entries) releaseEntry(entry);
325
+ entries = recipe.lights.map((descriptor, index) => buildEntry(descriptor, index));
326
+ lastSignature = '';
327
+ update();
328
+ }
329
+
330
+ /** Applies a same-type descriptor change to a realized light in place. */
331
+ function applyDescriptorToEntry(entry, descriptor) {
332
+ entry.descriptor = descriptor;
333
+ const light = entry.light;
334
+ light.name = descriptor.name;
335
+ light.position.fromArray(descriptor.position);
336
+ setLayers(light, descriptor.layers);
337
+ light.color.copy(threeColor(descriptor.color));
338
+ light.intensity = resolveThreeLightIntensity(descriptor.type, descriptor.intensity, descriptor);
339
+ if (light.isHemisphereLight) light.groundColor.copy(threeColor(descriptor.groundColor));
340
+ if (light.isPointLight || light.isSpotLight) {
341
+ light.distance = descriptor.distance;
342
+ light.decay = descriptor.decay;
343
+ }
344
+ if (light.isSpotLight) {
345
+ light.angle = descriptor.angle;
346
+ light.penumbra = descriptor.penumbra;
347
+ }
348
+ if (light.isRectAreaLight) {
349
+ light.width = descriptor.width;
350
+ light.height = descriptor.type === 'discArea' ? descriptor.width : descriptor.height;
351
+ }
352
+ if (entry.target) {
353
+ entry.target.position.fromArray(descriptor.target);
354
+ } else if (AREA_LIGHT_TYPES.has(descriptor.type)) {
355
+ light.lookAt(new THREE.Vector3().fromArray(descriptor.target));
356
+ }
357
+ const previousMapSize = light.shadow?.mapSize?.x ?? 0;
358
+ entry.shadowPixels = configureShadow(light, descriptor, quality, recipe.shadowPolicy.updateMode);
359
+ if (light.shadow && light.shadow.mapSize.x !== previousMapSize) {
360
+ light.shadow.map?.dispose?.();
361
+ light.shadow.map = null;
362
+ }
363
+ light.userData.toonlabLighting = {
364
+ artistic: cloneJson(descriptor.artistic),
365
+ cookie: cloneJson(descriptor.cookie),
366
+ fallback: entry.fallback,
367
+ ies: cloneJson(descriptor.ies),
368
+ id: descriptor.id,
369
+ linking: cloneJson(descriptor.linking),
370
+ type: descriptor.type,
371
+ userData: cloneJson(descriptor.userData),
372
+ };
373
+ }
374
+
375
+ function selectLights(point) {
376
+ const candidates = [];
377
+ group.updateMatrixWorld(true);
378
+ for (const entry of entries) {
379
+ const descriptor = entry.descriptor;
380
+ entry.active = false;
381
+ entry.shadowed = false;
382
+ entry.light.castShadow = false;
383
+ entry.light.visible = false;
384
+ entry.cullReason = null;
385
+
386
+ if (!descriptor.enabled) {
387
+ entry.cullReason = 'disabled';
388
+ continue;
389
+ }
390
+ if (AREA_LIGHT_TYPES.has(descriptor.type)) {
391
+ if (!quality.allowAreaLights) {
392
+ entry.cullReason = 'area-lights-disabled';
393
+ continue;
394
+ }
395
+ if (ltcState !== 'ready') {
396
+ entry.cullReason = ltcState === 'unavailable' ? 'area-ltc-unavailable' : 'area-ltc-pending';
397
+ continue;
398
+ }
399
+ }
400
+ entry.distance = LOCAL_LIGHT_TYPES.has(descriptor.type)
401
+ ? entry.light.getWorldPosition(worldPosition).distanceTo(point)
402
+ : 0;
403
+ const descriptorLimit = descriptor.maxDistance > 0 ? descriptor.maxDistance : quality.maxDistance;
404
+ const distanceLimit = Math.min(
405
+ descriptorLimit > 0 ? descriptorLimit : Number.POSITIVE_INFINITY,
406
+ quality.maxDistance > 0 ? quality.maxDistance : Number.POSITIVE_INFINITY,
407
+ );
408
+ if (LOCAL_LIGHT_TYPES.has(descriptor.type) && entry.distance > distanceLimit) {
409
+ entry.cullReason = 'distance';
410
+ continue;
411
+ }
412
+ const globalBonus = LOCAL_LIGHT_TYPES.has(descriptor.type) ? 0 : 1_000_000;
413
+ entry.score = globalBonus + descriptor.priority * 1000 - entry.distance;
414
+ candidates.push(entry);
415
+ }
416
+
417
+ candidates.sort((a, b) => b.score - a.score || a.index - b.index);
418
+ const typeCounts = Object.create(null);
419
+ let selectedCount = 0;
420
+ for (const entry of candidates) {
421
+ const type = entry.descriptor.type;
422
+ const typeCap = quality.maxLightsByType[type] ?? quality.maxLights;
423
+ const typeCount = typeCounts[type] ?? 0;
424
+ if (typeCount >= typeCap) {
425
+ entry.cullReason = 'type-budget';
426
+ continue;
427
+ }
428
+ if (selectedCount >= quality.maxLights) {
429
+ entry.cullReason = 'total-budget';
430
+ continue;
431
+ }
432
+ entry.active = true;
433
+ entry.cullReason = null;
434
+ entry.light.visible = true;
435
+ typeCounts[type] = typeCount + 1;
436
+ selectedCount += 1;
437
+ }
438
+ }
439
+
440
+ function selectShadows() {
441
+ const policy = recipe.shadowPolicy;
442
+ if (policy.mode === 'disabled') return;
443
+ const maxCount = Math.min(policy.maxShadowedLights, quality.maxShadowedLights);
444
+ const maxPixels = Math.min(policy.maxShadowMapPixels, quality.maxShadowMapPixels);
445
+ const candidates = entries.filter((entry) => entry.active
446
+ && entry.descriptor.castShadow
447
+ && entry.descriptor.shadow.enabled
448
+ && policy.allowedTypes.includes(entry.descriptor.type)
449
+ && entry.light.shadow);
450
+ candidates.sort((a, b) => {
451
+ const scoreA = a.descriptor.shadow.priority + a.descriptor.priority;
452
+ const scoreB = b.descriptor.shadow.priority + b.descriptor.priority;
453
+ return scoreB - scoreA || a.distance - b.distance || a.index - b.index;
454
+ });
455
+
456
+ let count = 0;
457
+ let pixels = 0;
458
+ for (const entry of candidates) {
459
+ if (count >= maxCount) continue;
460
+ if (pixels + entry.shadowPixels > maxPixels) continue;
461
+ entry.shadowed = true;
462
+ entry.light.castShadow = true;
463
+ count += 1;
464
+ pixels += entry.shadowPixels;
465
+ }
466
+ }
467
+
468
+ function buildDiagnostics() {
469
+ const active = entries.filter((entry) => entry.active);
470
+ const shadowed = entries.filter((entry) => entry.shadowed);
471
+ const countsByType = {};
472
+ for (const entry of entries) {
473
+ countsByType[entry.descriptor.type] ??= { active: 0, authored: 0, shadowed: 0 };
474
+ countsByType[entry.descriptor.type].authored += 1;
475
+ if (entry.active) countsByType[entry.descriptor.type].active += 1;
476
+ if (entry.shadowed) countsByType[entry.descriptor.type].shadowed += 1;
477
+ }
478
+ const warnings = [...capabilityReport.warnings];
479
+ if (entries.some((entry) => entry.descriptor.ies)) {
480
+ warnings.push('IES profiles are preserved as metadata; the core Three.js runtime does not evaluate them.');
481
+ }
482
+ if (entries.some((entry) => entry.descriptor.linking.includeTags.length > 0
483
+ || entry.descriptor.linking.excludeTags.length > 0)) {
484
+ warnings.push('Tag-based light linking requires a host material/object adapter; Three.js layers are applied directly.');
485
+ }
486
+ for (const fallback of [...new Set(entries.map((entry) => entry.fallback).filter(Boolean))]) warnings.push(fallback);
487
+ if (entries.some((entry) => entry.cullReason === 'area-ltc-pending')) {
488
+ warnings.push('Area lights are initializing their LTC lookup textures and will activate shortly.');
489
+ }
490
+ if (entries.some((entry) => entry.cullReason === 'area-ltc-unavailable')) {
491
+ warnings.push('Area lights are unavailable: the LTC lookup textures failed to load.');
492
+ }
493
+ if (entries.some((entry) => entry.cookieStatus === 'unresolved')) {
494
+ warnings.push('One or more spot cookies need a textureResolver to be realized.');
495
+ }
496
+ if (active.length < entries.filter((entry) => entry.descriptor.enabled).length) {
497
+ warnings.push('One or more enabled lights were culled by distance, type, or total-light budgets.');
498
+ }
499
+
500
+ return {
501
+ activeLightCount: active.length,
502
+ backend: capabilityReport.backend,
503
+ countsByType,
504
+ entries: entries.map((entry) => ({
505
+ active: entry.active,
506
+ cookieStatus: entry.cookieStatus,
507
+ cullReason: entry.cullReason,
508
+ distance: entry.distance,
509
+ fallback: entry.fallback,
510
+ id: entry.descriptor.id,
511
+ iesStatus: entry.descriptor.ies ? 'metadata-only' : 'none',
512
+ score: entry.score,
513
+ shadowPixels: entry.shadowed ? entry.shadowPixels : 0,
514
+ shadowed: entry.shadowed,
515
+ type: entry.descriptor.type,
516
+ })),
517
+ qualityId: quality.id,
518
+ recipeId: recipe.id,
519
+ selectedIds: active.map((entry) => entry.descriptor.id),
520
+ shadowedIds: shadowed.map((entry) => entry.descriptor.id),
521
+ shadowedLightCount: shadowed.length,
522
+ totalLightCount: entries.length,
523
+ warnings: [...new Set(warnings)],
524
+ };
525
+ }
526
+
527
+ /** Re-runs distance, priority, type-cap, total-cap, and shadow selection. */
528
+ function update(options = {}, maybeCamera = null) {
529
+ if (disposed) return lastDiagnostics;
530
+ let updateOptions = isPlainObject(options) ? options : {};
531
+ if (options?.isCamera) updateOptions = { camera: options };
532
+ if (typeof options === 'number' && maybeCamera?.isCamera) updateOptions = { camera: maybeCamera };
533
+ if (updateOptions.camera) camera = updateOptions.camera;
534
+ focusPosition(updateOptions, camera, selectionPoint);
535
+ selectLights(selectionPoint);
536
+ selectShadows();
537
+ lastDiagnostics = buildDiagnostics();
538
+ const signature = selectionSignature(entries);
539
+ if (signature !== lastSignature) {
540
+ lastSignature = signature;
541
+ emit('selection');
542
+ }
543
+ return getDiagnostics();
544
+ }
545
+
546
+ function setRecipe(nextRecipe) {
547
+ if (disposed) throw new Error('Cannot update a disposed lighting manager.');
548
+ recipe = recipeSource(nextRecipe);
549
+ rebuild();
550
+ emit('recipe');
551
+ return api;
552
+ }
553
+
554
+ function setQuality(nextQuality) {
555
+ if (disposed) throw new Error('Cannot update a disposed lighting manager.');
556
+ quality = qualitySource(nextQuality);
557
+ for (const entry of entries) {
558
+ entry.shadowPixels = configureShadow(entry.light, entry.descriptor, quality, recipe.shadowPolicy.updateMode);
559
+ if (entry.descriptor.cookie && !quality.allowCookies) {
560
+ entry.light.map = null;
561
+ entry.cookieStatus = 'quality-disabled';
562
+ } else if (entry.descriptor.cookie && entry.cookieTexture) {
563
+ entry.light.map = entry.cookieTexture;
564
+ entry.cookieStatus = 'ready';
565
+ } else if (entry.descriptor.cookie && entry.cookieStatus === 'quality-disabled') {
566
+ resolveCookie(entry);
567
+ }
568
+ }
569
+ update();
570
+ emit('quality');
571
+ return api;
572
+ }
573
+
574
+ function updateLight(id, overrides) {
575
+ const index = recipe.lights.findIndex((light) => light.id === id);
576
+ if (index === -1) return null;
577
+ const next = mergeLightDescriptor(recipe.lights[index], overrides);
578
+ next.id = id;
579
+ recipe.lights[index] = next;
580
+ const entry = entries.find((candidate) => candidate.descriptor.id === id);
581
+ if (!entry || entry.descriptor.type !== next.type) {
582
+ // Only a light-type change requires recreating the THREE object.
583
+ rebuild();
584
+ } else {
585
+ const previousCookie = JSON.stringify(entry.descriptor.cookie ?? null);
586
+ applyDescriptorToEntry(entry, next);
587
+ if (JSON.stringify(next.cookie ?? null) !== previousCookie) resolveCookie(entry);
588
+ update();
589
+ }
590
+ emit('light');
591
+ return next;
592
+ }
593
+
594
+ /** Realizes one additional descriptor without touching existing lights. */
595
+ function addLight(descriptorSource) {
596
+ if (disposed) throw new Error('Cannot update a disposed lighting manager.');
597
+ const descriptor = createLightDescriptor(descriptorSource);
598
+ descriptor.id = uniqueId(descriptor.id, new Set(recipe.lights.map((light) => light.id)), descriptor.type);
599
+ recipe.lights.push(descriptor);
600
+ entries.push(buildEntry(descriptor, entries.length));
601
+ update();
602
+ emit('light');
603
+ return descriptor.id;
604
+ }
605
+
606
+ /** Releases one realized light without touching the others. */
607
+ function removeLight(id) {
608
+ const recipeIndex = recipe.lights.findIndex((light) => light.id === id);
609
+ if (recipeIndex !== -1) recipe.lights.splice(recipeIndex, 1);
610
+ const entryIndex = entries.findIndex((entry) => entry.descriptor.id === id);
611
+ if (entryIndex === -1) return false;
612
+ releaseEntry(entries[entryIndex]);
613
+ entries.splice(entryIndex, 1);
614
+ update();
615
+ emit('light');
616
+ return true;
617
+ }
618
+
619
+ function setLightEnabled(id, enabled) {
620
+ const descriptor = recipe.lights.find((light) => light.id === id);
621
+ if (!descriptor) return false;
622
+ descriptor.enabled = Boolean(enabled);
623
+ update();
624
+ emit('light');
625
+ return true;
626
+ }
627
+
628
+ function addToScene(nextScene) {
629
+ if (!nextScene?.isScene && !nextScene?.isObject3D) throw new Error('addToScene requires a Three.js scene or Object3D.');
630
+ if (scene === nextScene && group.parent === nextScene) return api;
631
+ group.removeFromParent();
632
+ scene = nextScene;
633
+ scene.add(group);
634
+ update();
635
+ return api;
636
+ }
637
+
638
+ function removeFromScene() {
639
+ group.removeFromParent();
640
+ scene = null;
641
+ return api;
642
+ }
643
+
644
+ function getDiagnostics() {
645
+ return cloneJson(buildDiagnostics());
646
+ }
647
+
648
+ function getLight(id) {
649
+ return entries.find((entry) => entry.descriptor.id === id)?.light ?? null;
650
+ }
651
+
652
+ function requestShadowUpdate(id = null) {
653
+ let count = 0;
654
+ for (const entry of entries) {
655
+ if (id !== null && entry.descriptor.id !== id) continue;
656
+ if (!entry.light.shadow) continue;
657
+ entry.light.shadow.needsUpdate = true;
658
+ count += 1;
659
+ }
660
+ return count;
661
+ }
662
+
663
+ function applyLook(lookOptions) {
664
+ const look = typeof lookOptions === 'string'
665
+ ? resolveLightingLookPreset(lookOptions)
666
+ : createLightingLook(lookOptions);
667
+ setRecipe(look.recipe);
668
+ setQuality(look.quality);
669
+ return { environment: cloneJson(look.environment), post: cloneJson(look.post) };
670
+ }
671
+
672
+ function subscribe(listener) {
673
+ if (typeof listener !== 'function') throw new TypeError('Lighting listener must be a function.');
674
+ listeners.add(listener);
675
+ return () => listeners.delete(listener);
676
+ }
677
+
678
+ function dispose() {
679
+ if (disposed) return;
680
+ removeFromScene();
681
+ for (const entry of entries) releaseEntry(entry);
682
+ entries = [];
683
+ listeners.clear();
684
+ disposed = true;
685
+ }
686
+
687
+ const api = {
688
+ addLight,
689
+ addToScene,
690
+ applyLook,
691
+ removeLight,
692
+ capabilities: capabilityReport,
693
+ dispose,
694
+ getDiagnostics,
695
+ getLight,
696
+ group,
697
+ removeFromScene,
698
+ requestShadowUpdate,
699
+ setLightEnabled,
700
+ setQuality,
701
+ setRecipe,
702
+ subscribe,
703
+ update,
704
+ updateLight,
705
+ get quality() { return quality; },
706
+ get recipe() { return recipe; },
707
+ get scene() { return scene; },
708
+ };
709
+
710
+ if (initialScene) addToScene(initialScene);
711
+ rebuild();
712
+ return api;
713
+ }
714
+
715
+ /** Alias emphasizing that a manager owns one realized rig. */
716
+ export function createLightingRig(options = {}) {
717
+ return createLightingManager(options);
718
+ }
719
+
720
+ /** Convenience recipe-first entry point for Three.js realization. */
721
+ export function realizeLightingRecipe(recipe, options = {}) {
722
+ return createLightingManager({ ...options, recipe });
723
+ }