@call-me-sensei/toonlab 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/ATTRIBUTION.md +40 -0
  2. package/LICENSE +21 -0
  3. package/README.md +125 -0
  4. package/package.json +97 -0
  5. package/src/character/characterRig.js +353 -0
  6. package/src/character/freestyleSwimClip.js +541 -0
  7. package/src/character/index.js +4 -0
  8. package/src/character/modelLoader.js +433 -0
  9. package/src/core/materialRoles.js +438 -0
  10. package/src/core/presetDocuments.js +110 -0
  11. package/src/core/shaderBackend.js +24 -0
  12. package/src/debrisgen/debrisFields.js +544 -0
  13. package/src/debrisgen/debrisGenerator.js +1929 -0
  14. package/src/debrisgen/debrisPalettes.js +71 -0
  15. package/src/debrisgen/debrisPhysics.js +198 -0
  16. package/src/debrisgen/debrisPresets.js +155 -0
  17. package/src/debrisgen/debrisSettings.js +333 -0
  18. package/src/debrisgen/debrisTextures.js +380 -0
  19. package/src/debrisgen/index.js +5 -0
  20. package/src/debug/fieldValues.js +95 -0
  21. package/src/debug/index.js +7 -0
  22. package/src/debug/settingsPanel.js +170 -0
  23. package/src/environment/environmentAmbientProbe.js +160 -0
  24. package/src/environment/environmentMaterialAdapter.js +336 -0
  25. package/src/environment/environmentMaterialClassifier.js +178 -0
  26. package/src/environment/environmentPlanarReflection.js +201 -0
  27. package/src/environment/environmentPresets.js +443 -0
  28. package/src/environment/environmentRigs.js +684 -0
  29. package/src/environment/environmentSettings.js +504 -0
  30. package/src/environment/environmentShaderMaterials.js +318 -0
  31. package/src/environment/environmentSunShadowPass.js +268 -0
  32. package/src/environment/environmentTextureResolver.js +161 -0
  33. package/src/environment/environmentTimeOfDay.js +167 -0
  34. package/src/environment/environmentVertexAo.js +188 -0
  35. package/src/environment/index.js +8 -0
  36. package/src/environment/scanAssetStylize.js +103 -0
  37. package/src/index.js +21 -0
  38. package/src/loaders/index.js +1 -0
  39. package/src/post/index.js +2 -0
  40. package/src/post/postProcessing.js +1087 -0
  41. package/src/rockgen/export/glbExport.js +177 -0
  42. package/src/rockgen/heightfield/heightfieldErosion.js +7 -0
  43. package/src/rockgen/heightfield/heightfieldPatch.js +183 -0
  44. package/src/rockgen/heightfield/stylizedErosionSim.js +356 -0
  45. package/src/rockgen/index.js +14 -0
  46. package/src/rockgen/mesh/meshAttributes.js +374 -0
  47. package/src/rockgen/mesh/meshDocument.js +158 -0
  48. package/src/rockgen/mesh/surfaceNets.js +329 -0
  49. package/src/rockgen/noise/cellularNoise3.js +92 -0
  50. package/src/rockgen/noise/prng.js +62 -0
  51. package/src/rockgen/noise/simplexNoise3.js +89 -0
  52. package/src/rockgen/noise/valueNoise3.js +87 -0
  53. package/src/rockgen/rockDocument.js +255 -0
  54. package/src/rockgen/rockHelpers.js +8 -0
  55. package/src/rockgen/rockgenPresets.js +559 -0
  56. package/src/rockgen/rockgenSettings.js +756 -0
  57. package/src/rockgen/sdf/fieldCompiler.js +544 -0
  58. package/src/rockgen/sdf/sculptEdits.js +66 -0
  59. package/src/rockgen/sdf/sdfModifiers.js +45 -0
  60. package/src/rockgen/sdf/sdfOps.js +37 -0
  61. package/src/rockgen/sdf/sdfPrimitives.js +88 -0
  62. package/src/shaders-tsl/anime.js +963 -0
  63. package/src/shaders-tsl/chunks/character-color.js +64 -0
  64. package/src/shaders-tsl/chunks/character-highlights.js +174 -0
  65. package/src/shaders-tsl/chunks/character-lighting.js +309 -0
  66. package/src/shaders-tsl/chunks/character-material-maps.js +146 -0
  67. package/src/shaders-tsl/chunks/character-roles.js +52 -0
  68. package/src/shaders-tsl/chunks/character-scene-lights.js +270 -0
  69. package/src/shaders-tsl/chunks/character-shadow-color.js +61 -0
  70. package/src/shaders-tsl/chunks/character-skinning.js +140 -0
  71. package/src/shaders-tsl/chunks/environment-color.js +47 -0
  72. package/src/shaders-tsl/chunks/environment-debug.js +79 -0
  73. package/src/shaders-tsl/chunks/environment-lighting.js +260 -0
  74. package/src/shaders-tsl/chunks/environment-sun-shadow.js +87 -0
  75. package/src/shaders-tsl/chunks/foliage-fog.js +60 -0
  76. package/src/shaders-tsl/chunks/pass-depth-color.js +89 -0
  77. package/src/shaders-tsl/chunks/stylized-cloud-shadow.js +70 -0
  78. package/src/shaders-tsl/chunks/water-color.js +190 -0
  79. package/src/shaders-tsl/chunks/water-common.js +124 -0
  80. package/src/shaders-tsl/chunks/water-foam.js +93 -0
  81. package/src/shaders-tsl/chunks/water-lighting.js +116 -0
  82. package/src/shaders-tsl/chunks/water-ripple.js +64 -0
  83. package/src/shaders-tsl/chunks/water-waves.js +100 -0
  84. package/src/shaders-tsl/environment-ao-overlay.js +60 -0
  85. package/src/shaders-tsl/environment.js +680 -0
  86. package/src/shaders-tsl/flower.js +267 -0
  87. package/src/shaders-tsl/grass.js +193 -0
  88. package/src/shaders-tsl/post-composite.js +461 -0
  89. package/src/shaders-tsl/sky.js +175 -0
  90. package/src/shaders-tsl/tree-leaf.js +264 -0
  91. package/src/shaders-tsl/water-breaker.js +423 -0
  92. package/src/shaders-tsl/water-kelp.js +171 -0
  93. package/src/shaders-tsl/water-rain.js +107 -0
  94. package/src/shaders-tsl/water-simulation.js +140 -0
  95. package/src/shaders-tsl/water-splash.js +298 -0
  96. package/src/shaders-tsl/water.js +509 -0
  97. package/src/sky/index.js +2 -0
  98. package/src/sky/stylizedSky.js +341 -0
  99. package/src/toon/characterRenderPasses.js +646 -0
  100. package/src/toon/index.js +4 -0
  101. package/src/toon/settings/alphaSettings.js +170 -0
  102. package/src/toon/settings/averageShadowSettings.js +181 -0
  103. package/src/toon/settings/baseTextureSettings.js +133 -0
  104. package/src/toon/settings/celShadeSettings.js +96 -0
  105. package/src/toon/settings/contactShadowSettings.js +114 -0
  106. package/src/toon/settings/eyeHighlightSettings.js +187 -0
  107. package/src/toon/settings/faceLightingSettings.js +135 -0
  108. package/src/toon/settings/furSettings.js +112 -0
  109. package/src/toon/settings/glitterSettings.js +102 -0
  110. package/src/toon/settings/hairHighlightSettings.js +325 -0
  111. package/src/toon/settings/indirectLightSettings.js +244 -0
  112. package/src/toon/settings/localLightSettings.js +176 -0
  113. package/src/toon/settings/materialMapSettings.js +299 -0
  114. package/src/toon/settings/outlineSettings.js +263 -0
  115. package/src/toon/settings/perspectiveRemovalSettings.js +60 -0
  116. package/src/toon/settings/rimLightSettings.js +268 -0
  117. package/src/toon/settings/sceneShadowSettings.js +147 -0
  118. package/src/toon/settings/selfShadowSettings.js +221 -0
  119. package/src/toon/settings/shadowColorSettings.js +238 -0
  120. package/src/toon/settings/skinToneSettings.js +150 -0
  121. package/src/toon/settings/specularSettings.js +314 -0
  122. package/src/toon/settings/stickerSettings.js +102 -0
  123. package/src/toon/toonMaterialAdapter.js +2235 -0
  124. package/src/toon/toonSettings.js +860 -0
  125. package/src/vegetation/flowerSpecies.js +260 -0
  126. package/src/vegetation/index.js +10 -0
  127. package/src/vegetation/stylizedBush.js +136 -0
  128. package/src/vegetation/stylizedFlower.js +213 -0
  129. package/src/vegetation/stylizedFlowers.js +297 -0
  130. package/src/vegetation/stylizedGrass.js +491 -0
  131. package/src/vegetation/stylizedTree.js +2449 -0
  132. package/src/vegetation/stylizedTreeFoliage.js +691 -0
  133. package/src/vegetation/treeExport.js +300 -0
  134. package/src/vegetation/treeRecipe.js +647 -0
  135. package/src/water/index.js +2 -0
  136. package/src/water/water.js +20 -0
  137. package/src/water/waterBreakerSystem.js +591 -0
  138. package/src/water/waterInteraction.js +193 -0
  139. package/src/water/waterMaterial.js +263 -0
  140. package/src/water/waterRain.js +136 -0
  141. package/src/water/waterRippleSimulation.js +227 -0
  142. package/src/water/waterScenePasses.js +415 -0
  143. package/src/water/waterSettings.js +1138 -0
  144. package/src/water/waterSplashSystem.js +251 -0
  145. package/src/water/waterSurface.js +475 -0
  146. package/src/water/waterVegetation.js +83 -0
@@ -0,0 +1,201 @@
1
+ import * as THREE from 'three';
2
+
3
+ import { setEnvironmentPlanarReflection } from './environmentShaderMaterials.js';
4
+
5
+ // One planar reflection pass for the dominant floor plane — the glossy-lobby
6
+ // look. Renders the scene mirrored across the floor into a half-res target
7
+ // each frame and publishes it to every glossFloor-role environment material
8
+ // through the shared planarReflectionMap/planarReflectionMatrix uniforms.
9
+ //
10
+ // Costs a second scene render while enabled; keep it preset/opt-in driven.
11
+
12
+ // Finds the world Y of the largest up-facing surface near the bottom of the
13
+ // environment — good enough to auto-place the reflection plane when the
14
+ // integrator does not pass floorY explicitly.
15
+ export function detectEnvironmentFloorY(root, environmentBox = null) {
16
+ const box = environmentBox ?? new THREE.Box3().setFromObject(root);
17
+ return box.min.y;
18
+ }
19
+
20
+ export function createEnvironmentPlanarReflection({
21
+ renderer,
22
+ scene,
23
+ camera,
24
+ floorY = 0,
25
+ normal = new THREE.Vector3(0, 1, 0),
26
+ resolutionScale = 0.5,
27
+ clipBias = 0.003,
28
+ } = {}) {
29
+ const planeNormal = normal.clone().normalize();
30
+ const planePoint = new THREE.Vector3(0, floorY, 0);
31
+ const reflectorPlane = new THREE.Plane();
32
+ const virtualCamera = new THREE.PerspectiveCamera();
33
+ const textureMatrix = new THREE.Matrix4();
34
+
35
+ let renderTarget = new THREE.WebGLRenderTarget(2, 2, {
36
+ minFilter: THREE.LinearFilter,
37
+ magFilter: THREE.LinearFilter,
38
+ });
39
+ let enabled = true;
40
+
41
+ const reflectorWorldPosition = new THREE.Vector3();
42
+ const cameraWorldPosition = new THREE.Vector3();
43
+ const lookAtPosition = new THREE.Vector3();
44
+ const view = new THREE.Vector3();
45
+ const target = new THREE.Vector3();
46
+ const clipPlane = new THREE.Vector4();
47
+ const q = new THREE.Vector4();
48
+
49
+ function resizeToRenderer() {
50
+ const size = renderer.getSize(new THREE.Vector2());
51
+ const width = Math.max(2, Math.floor(size.x * renderer.getPixelRatio() * resolutionScale));
52
+ const height = Math.max(2, Math.floor(size.y * renderer.getPixelRatio() * resolutionScale));
53
+ if (renderTarget.width !== width || renderTarget.height !== height) {
54
+ renderTarget.setSize(width, height);
55
+ }
56
+ }
57
+
58
+ // Meshes that consume the reflection must not draw while it renders (WebGL
59
+ // feedback loop); the oblique clip plane would discard their fragments but
60
+ // the texture would still be bound.
61
+ function collectConsumerMeshes() {
62
+ const consumers = [];
63
+ scene.traverse((obj) => {
64
+ if (!obj.isMesh || !obj.material) return;
65
+ const materials = Array.isArray(obj.material) ? obj.material : [obj.material];
66
+ if (materials.some((mat) => mat?.defines?.USE_ENV_PLANAR_REFLECTION
67
+ || mat?.userData?.environmentRole === 'glossFloor')) {
68
+ consumers.push(obj);
69
+ }
70
+ if (obj.userData?.environmentReflectionExclude) consumers.push(obj);
71
+ });
72
+ return consumers;
73
+ }
74
+
75
+ function update() {
76
+ if (!enabled) return;
77
+
78
+ camera.getWorldPosition(cameraWorldPosition);
79
+ reflectorWorldPosition.copy(planePoint);
80
+ view.subVectors(reflectorWorldPosition, cameraWorldPosition);
81
+ // Camera below the floor plane: nothing sensible to mirror.
82
+ if (view.dot(planeNormal) > 0) return;
83
+
84
+ resizeToRenderer();
85
+
86
+ view.reflect(planeNormal).negate();
87
+ view.add(reflectorWorldPosition);
88
+
89
+ lookAtPosition.set(0, 0, -1).applyQuaternion(camera.quaternion).add(cameraWorldPosition);
90
+ target.subVectors(reflectorWorldPosition, lookAtPosition);
91
+ target.reflect(planeNormal).negate();
92
+ target.add(reflectorWorldPosition);
93
+
94
+ virtualCamera.coordinateSystem = camera.coordinateSystem;
95
+ virtualCamera.position.copy(view);
96
+ virtualCamera.up.set(0, 1, 0).applyQuaternion(camera.quaternion).reflect(planeNormal);
97
+ virtualCamera.lookAt(target);
98
+ virtualCamera.near = camera.near;
99
+ virtualCamera.far = camera.far;
100
+ virtualCamera.updateMatrixWorld();
101
+ virtualCamera.projectionMatrix.copy(camera.projectionMatrix);
102
+
103
+ // The classic pipeline samples render targets in GL orientation; the node
104
+ // backends sample them with the origin flipped (same convention gap the
105
+ // shared sun-shadow matrix adjusts for), so fold uv.y -> 1-uv.y into the
106
+ // bias matrix there. Projective form: y' = -y + w.
107
+ if (renderer.isWebGPURenderer) {
108
+ textureMatrix.set(
109
+ 0.5, 0.0, 0.0, 0.5,
110
+ 0.0, -0.5, 0.0, 0.5,
111
+ 0.0, 0.0, 0.5, 0.5,
112
+ 0.0, 0.0, 0.0, 1.0,
113
+ );
114
+ } else {
115
+ textureMatrix.set(
116
+ 0.5, 0.0, 0.0, 0.5,
117
+ 0.0, 0.5, 0.0, 0.5,
118
+ 0.0, 0.0, 0.5, 0.5,
119
+ 0.0, 0.0, 0.0, 1.0,
120
+ );
121
+ }
122
+ textureMatrix.multiply(virtualCamera.projectionMatrix);
123
+ textureMatrix.multiply(virtualCamera.matrixWorldInverse);
124
+
125
+ // Oblique near-plane clip against the reflection plane so geometry below
126
+ // the floor never leaks into the mirror image. The node renderer needs
127
+ // three's ReflectorNode variant of the Lengyel formula: 1/dot scaling and
128
+ // a coordinate-system-dependent third row (WebGPU clip z is 0..1, so the
129
+ // GL +1.0 row-4 fold-in must not be applied there).
130
+ reflectorPlane.setFromNormalAndCoplanarPoint(planeNormal, reflectorWorldPosition);
131
+ reflectorPlane.applyMatrix4(virtualCamera.matrixWorldInverse);
132
+ clipPlane.set(reflectorPlane.normal.x, reflectorPlane.normal.y, reflectorPlane.normal.z, reflectorPlane.constant);
133
+ const projectionMatrix = virtualCamera.projectionMatrix;
134
+ q.x = (Math.sign(clipPlane.x) + projectionMatrix.elements[8]) / projectionMatrix.elements[0];
135
+ q.y = (Math.sign(clipPlane.y) + projectionMatrix.elements[9]) / projectionMatrix.elements[5];
136
+ q.z = -1.0;
137
+ q.w = (1.0 + projectionMatrix.elements[10]) / projectionMatrix.elements[14];
138
+ if (renderer.isWebGPURenderer) {
139
+ clipPlane.multiplyScalar(1.0 / clipPlane.dot(q));
140
+ projectionMatrix.elements[10] = renderer.coordinateSystem === THREE.WebGPUCoordinateSystem
141
+ ? clipPlane.z - clipBias
142
+ : clipPlane.z + 1.0 - clipBias;
143
+ } else {
144
+ clipPlane.multiplyScalar(2.0 / clipPlane.dot(q));
145
+ projectionMatrix.elements[10] = clipPlane.z + 1.0 - clipBias;
146
+ }
147
+ projectionMatrix.elements[2] = clipPlane.x;
148
+ projectionMatrix.elements[6] = clipPlane.y;
149
+ projectionMatrix.elements[14] = clipPlane.w;
150
+
151
+ const consumers = collectConsumerMeshes();
152
+ const previousVisibility = consumers.map((mesh) => mesh.visible);
153
+ consumers.forEach((mesh) => { mesh.visible = false; });
154
+
155
+ const currentRenderTarget = renderer.getRenderTarget();
156
+ const currentXrEnabled = renderer.xr.enabled;
157
+ const currentShadowAutoUpdate = renderer.shadowMap.autoUpdate;
158
+ renderer.xr.enabled = false;
159
+ renderer.shadowMap.autoUpdate = false;
160
+ renderer.setRenderTarget(renderTarget);
161
+ // renderer.state is a classic-WebGL-only surface; the node renderer
162
+ // clears through autoClear inside render() instead.
163
+ if (renderer.state) {
164
+ renderer.state.buffers.depth.setMask(true);
165
+ renderer.clear();
166
+ }
167
+ renderer.render(scene, virtualCamera);
168
+ renderer.xr.enabled = currentXrEnabled;
169
+ renderer.shadowMap.autoUpdate = currentShadowAutoUpdate;
170
+ renderer.setRenderTarget(currentRenderTarget);
171
+
172
+ consumers.forEach((mesh, index) => { mesh.visible = previousVisibility[index]; });
173
+
174
+ setEnvironmentPlanarReflection({ texture: renderTarget.texture, matrix: textureMatrix });
175
+ }
176
+
177
+ function setEnabled(value) {
178
+ enabled = Boolean(value);
179
+ if (!enabled) setEnvironmentPlanarReflection({ texture: null });
180
+ }
181
+
182
+ function setFloorY(value) {
183
+ if (Number.isFinite(value)) planePoint.y = value;
184
+ }
185
+
186
+ function dispose() {
187
+ setEnvironmentPlanarReflection({ texture: null });
188
+ renderTarget.dispose();
189
+ }
190
+
191
+ return {
192
+ dispose,
193
+ get enabled() { return enabled; },
194
+ renderTarget: () => renderTarget,
195
+ setEnabled,
196
+ setFloorY,
197
+ texture: () => renderTarget.texture,
198
+ textureMatrix,
199
+ update,
200
+ };
201
+ }
@@ -0,0 +1,443 @@
1
+ // Named environment presets: one string selects a coherent, reference-checked
2
+ // look — shader features/parameters plus rig hints (sun, lamps, reflection,
3
+ // probe, motes) that scene-level integrations consume. Registry mirrors the
4
+ // character preset helpers in toonSettings.js.
5
+ //
6
+ // 'default' stays an empty override layer so the shipped baseline (the
7
+ // approved Liyue room look) never shifts underneath integrators.
8
+
9
+ import { ENVIRONMENT_SETTING_FIELD_SCHEMA } from './environmentSettings.js';
10
+
11
+ /** Document type tag stamped on shareable environment preset JSON documents. */
12
+ export const ENVIRONMENT_PRESET_DOCUMENT_TYPE = 'toonlab/environment-preset';
13
+
14
+ /** Current schema version for environment preset documents. */
15
+ export const ENVIRONMENT_PRESET_SCHEMA_VERSION = 1;
16
+
17
+ // Rig keys consumed by the shipped scene integrations. Unknown rig keys are
18
+ // kept (integrations may carry custom hints) but flagged with a warning.
19
+ const KNOWN_ENVIRONMENT_RIG_KEYS = Object.freeze(new Set([
20
+ 'bakeVertexAo',
21
+ 'dustMotes',
22
+ 'lampIntensity',
23
+ 'planarReflection',
24
+ 'probe',
25
+ 'spotShadows',
26
+ 'sun',
27
+ 'timeOfDayHour',
28
+ ]));
29
+
30
+ const ENVIRONMENT_PRESETS = new Map();
31
+
32
+ export function registerEnvironmentPreset(name, preset, { overwrite = false } = {}) {
33
+ const key = String(name ?? '').trim();
34
+ if (!key) throw new Error('Environment preset name is required.');
35
+ if (!overwrite && ENVIRONMENT_PRESETS.has(key)) {
36
+ throw new Error(`Environment preset "${key}" is already registered.`);
37
+ }
38
+ ENVIRONMENT_PRESETS.set(key, {
39
+ features: {},
40
+ label: key,
41
+ parameters: {},
42
+ rig: {},
43
+ ...preset,
44
+ });
45
+ return key;
46
+ }
47
+
48
+ export function normalizeEnvironmentPresetName(name) {
49
+ const key = String(name ?? 'default').trim();
50
+ return ENVIRONMENT_PRESETS.has(key) ? key : 'default';
51
+ }
52
+
53
+ export function getEnvironmentPresetOptions() {
54
+ return Array.from(ENVIRONMENT_PRESETS.entries())
55
+ .map(([value, preset]) => ({ label: preset.label, value }));
56
+ }
57
+
58
+ // Returns { features, parameters, rig } ready to spread into
59
+ // applyEnvironmentShader options and the rig constructors.
60
+ export function resolveEnvironmentPreset(name) {
61
+ const preset = ENVIRONMENT_PRESETS.get(normalizeEnvironmentPresetName(name));
62
+ return {
63
+ features: { ...preset.features },
64
+ parameters: { ...preset.parameters },
65
+ rig: { ...preset.rig },
66
+ };
67
+ }
68
+
69
+ function isPlainObject(value) {
70
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
71
+ }
72
+
73
+ function cleanObject(value) {
74
+ return isPlainObject(value) ? value : {};
75
+ }
76
+
77
+ function booleanFromValue(value) {
78
+ if (typeof value === 'boolean') return value;
79
+ if (typeof value === 'string') {
80
+ const normalized = value.trim().toLowerCase();
81
+ if (['1', 'true', 'on', 'yes'].includes(normalized)) return true;
82
+ if (['0', 'false', 'off', 'no'].includes(normalized)) return false;
83
+ }
84
+ if (value === 0 || value === 1) return value === 1;
85
+ return null;
86
+ }
87
+
88
+ function colorArrayFromValue(value) {
89
+ const source = Array.isArray(value)
90
+ ? value
91
+ : isPlainObject(value)
92
+ ? [value.r, value.g, value.b]
93
+ : null;
94
+ if (!source || source.length < 3) return null;
95
+ const channels = source.slice(0, 3).map(Number);
96
+ return channels.every(Number.isFinite) ? channels : null;
97
+ }
98
+
99
+ /**
100
+ * Validates and coerces an environment preset definition against the
101
+ * environment settings schema. Feature values are coerced to booleans and
102
+ * must name known feature toggles; parameter values must be finite numbers
103
+ * (or `[r, g, b]` arrays for color parameters, or `null` for auto) and must
104
+ * name known shader parameters; rig hints must be JSON scalars (boolean,
105
+ * finite number, or string) — unknown rig keys are kept but produce a warning.
106
+ *
107
+ * @param {object} preset Preset definition ({ label, description, features, parameters, rig }).
108
+ * @returns {{ ok: boolean, errors: string[], warnings: string[], value: object | null }}
109
+ * `value` is the sanitized `{ label, description, features, parameters, rig }` when `ok`.
110
+ */
111
+ export function sanitizeEnvironmentPreset(preset) {
112
+ const errors = [];
113
+ const warnings = [];
114
+ const source = cleanObject(preset);
115
+
116
+ const features = {};
117
+ for (const [key, entry] of Object.entries(cleanObject(source.features))) {
118
+ if (!ENVIRONMENT_SETTING_FIELD_SCHEMA.features[key]) {
119
+ errors.push(`Unknown environment feature "${key}".`);
120
+ continue;
121
+ }
122
+ const coerced = booleanFromValue(entry);
123
+ if (coerced === null) {
124
+ errors.push(`Environment feature "${key}" must be a boolean.`);
125
+ continue;
126
+ }
127
+ features[key] = coerced;
128
+ }
129
+
130
+ const parameters = {};
131
+ for (const [key, entry] of Object.entries(cleanObject(source.parameters))) {
132
+ const field = ENVIRONMENT_SETTING_FIELD_SCHEMA.parameters[key];
133
+ if (!field) {
134
+ errors.push(`Unknown environment parameter "${key}".`);
135
+ continue;
136
+ }
137
+ if (entry === null) {
138
+ parameters[key] = null;
139
+ continue;
140
+ }
141
+ if (field.type === 'color') {
142
+ const color = colorArrayFromValue(entry);
143
+ if (!color) {
144
+ errors.push(`Environment parameter "${key}" must be an [r, g, b] color array.`);
145
+ continue;
146
+ }
147
+ parameters[key] = color;
148
+ continue;
149
+ }
150
+ const number = typeof entry === 'boolean' ? Number.NaN : Number(entry);
151
+ if (!Number.isFinite(number)) {
152
+ errors.push(`Environment parameter "${key}" must be a finite number.`);
153
+ continue;
154
+ }
155
+ parameters[key] = number;
156
+ }
157
+
158
+ const rig = {};
159
+ for (const [key, entry] of Object.entries(cleanObject(source.rig))) {
160
+ const type = typeof entry;
161
+ const isScalar = type === 'boolean'
162
+ || type === 'string'
163
+ || (type === 'number' && Number.isFinite(entry));
164
+ if (!isScalar) {
165
+ errors.push(`Environment rig value "${key}" must be a boolean, finite number, or string.`);
166
+ continue;
167
+ }
168
+ if (!KNOWN_ENVIRONMENT_RIG_KEYS.has(key)) {
169
+ warnings.push(`Unknown environment rig key "${key}" was kept as-is.`);
170
+ }
171
+ rig[key] = entry;
172
+ }
173
+
174
+ const ok = errors.length === 0;
175
+ return {
176
+ errors,
177
+ ok,
178
+ value: ok
179
+ ? {
180
+ description: String(source.description ?? ''),
181
+ features,
182
+ label: String(source.label ?? ''),
183
+ parameters,
184
+ rig,
185
+ }
186
+ : null,
187
+ warnings,
188
+ };
189
+ }
190
+
191
+ /**
192
+ * Serializes a registered environment preset into a shareable JSON document
193
+ * (`{ type, schemaVersion, id, label, description, preset }`). The registered
194
+ * preset itself is untouched; `label`/`description` overrides only affect the
195
+ * emitted document.
196
+ *
197
+ * @param {string} name Registered preset name (e.g. 'interiorNight').
198
+ * @param {{ label?: string, description?: string }} [overrides] Optional label/description overrides.
199
+ * @returns {object} Environment preset document ready for `JSON.stringify`.
200
+ * @throws {Error} If the preset is not registered or fails sanitization.
201
+ */
202
+ export function createEnvironmentPresetDocument(name, { description, label } = {}) {
203
+ const key = String(name ?? '').trim();
204
+ const preset = ENVIRONMENT_PRESETS.get(key);
205
+ if (!preset) throw new Error(`Environment preset "${key}" is not registered.`);
206
+
207
+ const sanitized = sanitizeEnvironmentPreset({
208
+ ...preset,
209
+ description: description ?? preset.description ?? '',
210
+ label: label ?? preset.label ?? key,
211
+ });
212
+ if (!sanitized.ok) throw new Error(sanitized.errors.join(' '));
213
+
214
+ return {
215
+ description: sanitized.value.description,
216
+ id: key,
217
+ label: sanitized.value.label,
218
+ preset: {
219
+ features: sanitized.value.features,
220
+ parameters: sanitized.value.parameters,
221
+ rig: sanitized.value.rig,
222
+ },
223
+ schemaVersion: ENVIRONMENT_PRESET_SCHEMA_VERSION,
224
+ type: ENVIRONMENT_PRESET_DOCUMENT_TYPE,
225
+ };
226
+ }
227
+
228
+ /**
229
+ * Validates an environment preset document (a parsed object or a JSON string)
230
+ * without registering it. Mirrors `validateToonPresetDocument` in
231
+ * toonSettings.js.
232
+ *
233
+ * @param {object | string} input Document object or JSON string.
234
+ * @returns {{ ok: boolean, errors: string[], warnings: string[], value: object | null }}
235
+ * On `ok`, `value` is `{ id, label, description, features, parameters, rig }`,
236
+ * ready to pass to `registerEnvironmentPreset(value.id, value)`.
237
+ */
238
+ export function validateEnvironmentPresetDocument(input) {
239
+ const errors = [];
240
+ const warnings = [];
241
+
242
+ let source = input;
243
+ if (typeof source === 'string') {
244
+ try {
245
+ source = JSON.parse(source);
246
+ } catch (error) {
247
+ return {
248
+ errors: [`Invalid environment preset JSON: ${error.message}`],
249
+ ok: false,
250
+ value: null,
251
+ warnings,
252
+ };
253
+ }
254
+ }
255
+
256
+ if (!isPlainObject(source)) {
257
+ return {
258
+ errors: ['Environment preset document must be a JSON object.'],
259
+ ok: false,
260
+ value: null,
261
+ warnings,
262
+ };
263
+ }
264
+
265
+ if (source.type !== ENVIRONMENT_PRESET_DOCUMENT_TYPE) {
266
+ errors.push(`Environment preset document type must be "${ENVIRONMENT_PRESET_DOCUMENT_TYPE}".`);
267
+ }
268
+
269
+ if (source.schemaVersion === undefined) {
270
+ warnings.push(`Environment preset schemaVersion was missing and defaulted to ${ENVIRONMENT_PRESET_SCHEMA_VERSION}.`);
271
+ } else if (!Number.isFinite(source.schemaVersion)) {
272
+ errors.push('Environment preset schemaVersion must be a number.');
273
+ } else if (source.schemaVersion > ENVIRONMENT_PRESET_SCHEMA_VERSION) {
274
+ errors.push(`Environment preset schema version ${source.schemaVersion} is newer than supported version ${ENVIRONMENT_PRESET_SCHEMA_VERSION}.`);
275
+ }
276
+
277
+ const id = String(source.id ?? '').trim();
278
+ if (!id) errors.push('Environment preset id is required.');
279
+
280
+ const presetSource = cleanObject(source.preset);
281
+ const sanitized = sanitizeEnvironmentPreset({
282
+ ...presetSource,
283
+ description: source.description ?? presetSource.description ?? '',
284
+ label: source.label ?? presetSource.label ?? id,
285
+ });
286
+ errors.push(...sanitized.errors);
287
+ warnings.push(...sanitized.warnings);
288
+
289
+ const ok = errors.length === 0;
290
+ return {
291
+ errors,
292
+ ok,
293
+ value: ok
294
+ ? {
295
+ description: sanitized.value.description,
296
+ features: sanitized.value.features,
297
+ id,
298
+ label: sanitized.value.label,
299
+ parameters: sanitized.value.parameters,
300
+ rig: sanitized.value.rig,
301
+ }
302
+ : null,
303
+ warnings,
304
+ };
305
+ }
306
+
307
+ /**
308
+ * Validates an environment preset document and registers it in one call.
309
+ *
310
+ * @param {object | string} document Document object or JSON string.
311
+ * @param {{ overwrite?: boolean }} [options] Pass `overwrite: true` to replace an existing preset.
312
+ * @returns {string} The registered preset name.
313
+ * @throws {Error} If the document fails validation or the name is taken and `overwrite` is false.
314
+ */
315
+ export function registerEnvironmentPresetDocument(document, { overwrite = false } = {}) {
316
+ const result = validateEnvironmentPresetDocument(document);
317
+ if (!result.ok) throw new Error(result.errors.join(' '));
318
+ return registerEnvironmentPreset(result.value.id, result.value, { overwrite });
319
+ }
320
+
321
+ registerEnvironmentPreset('default', {
322
+ label: 'Default (Baseline)',
323
+ });
324
+
325
+ registerEnvironmentPreset('interiorDay', {
326
+ label: 'Interior Day',
327
+ parameters: {
328
+ ambientProbeBlend: 0.35,
329
+ ambientStrength: 0.22,
330
+ aoWarmth: 0.6,
331
+ interiorOcclusionStrength: 0.5,
332
+ },
333
+ rig: {
334
+ dustMotes: true,
335
+ probe: true,
336
+ sun: true,
337
+ timeOfDayHour: 12,
338
+ },
339
+ });
340
+
341
+ registerEnvironmentPreset('interiorEvening', {
342
+ label: 'Interior Evening',
343
+ parameters: {
344
+ ambientProbeBlend: 0.4,
345
+ ambientStrength: 0.42,
346
+ directLightStrength: 0.85,
347
+ interiorOcclusionStrength: 0.42,
348
+ shadowLift: 0.3,
349
+ spotLightStrength: 0.55,
350
+ },
351
+ rig: {
352
+ dustMotes: true,
353
+ lampIntensity: 0.8,
354
+ probe: true,
355
+ spotShadows: true,
356
+ sun: true,
357
+ timeOfDayHour: 18,
358
+ },
359
+ });
360
+
361
+ registerEnvironmentPreset('interiorNight', {
362
+ label: 'Interior Night (Lamp Lit)',
363
+ parameters: {
364
+ ambientProbeBlend: 0.4,
365
+ emissiveStrength: 0.7,
366
+ pointLightStrength: 0.36,
367
+ shadowLift: 0.56,
368
+ spotLightStrength: 0.85,
369
+ },
370
+ rig: {
371
+ lampIntensity: 1.3,
372
+ probe: true,
373
+ spotShadows: true,
374
+ sun: false,
375
+ timeOfDayHour: 22,
376
+ },
377
+ });
378
+
379
+ // Flat-color / untextured scenes: strong grounding, gentle gradient interest,
380
+ // no room-specific darkening.
381
+ registerEnvironmentPreset('interiorStudio', {
382
+ label: 'Interior Studio (Untextured)',
383
+ features: {
384
+ leftSideShadow: false,
385
+ },
386
+ parameters: {
387
+ ambientProbeBlend: 0.3,
388
+ ambientStrength: 0.9,
389
+ aoWarmth: 0.45,
390
+ lightingInfluence: 0.6,
391
+ shadowLift: 0.5,
392
+ specularStrength: 0.1,
393
+ untexturedGradientStrength: 0.5,
394
+ vertexAoStrength: 1.0,
395
+ },
396
+ rig: {
397
+ bakeVertexAo: true,
398
+ probe: true,
399
+ spotShadows: true,
400
+ sun: true,
401
+ },
402
+ });
403
+
404
+ registerEnvironmentPreset('exteriorDay', {
405
+ label: 'Exterior Day',
406
+ features: {
407
+ leftSideShadow: false,
408
+ windowCutout: false,
409
+ },
410
+ parameters: {
411
+ cloudShadowStrength: 0.35,
412
+ heightFogDensity: 0.012,
413
+ heightFogFalloff: 9,
414
+ skyTintStrength: 0.4,
415
+ },
416
+ rig: {
417
+ sun: true,
418
+ timeOfDayHour: 12,
419
+ },
420
+ });
421
+
422
+ // Everything on with demo values: a feature tour, not a production look
423
+ // (pairs with the character 'showcase' preset).
424
+ registerEnvironmentPreset('showcase', {
425
+ label: 'Showcase (All Features)',
426
+ parameters: {
427
+ ambientProbeBlend: 0.45,
428
+ aoWarmth: 0.6,
429
+ heightFogDensity: 0.006,
430
+ interiorOcclusionStrength: 0.35,
431
+ planarReflectionStrength: 0.32,
432
+ specularStrength: 0.2,
433
+ spotLightStrength: 0.6,
434
+ },
435
+ rig: {
436
+ dustMotes: true,
437
+ planarReflection: true,
438
+ probe: true,
439
+ spotShadows: true,
440
+ sun: true,
441
+ timeOfDayHour: 15,
442
+ },
443
+ });