@call-me-sensei/toonlab 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +127 -4
- package/ATTRIBUTION.md +41 -3
- package/README.md +231 -34
- package/docs/characters.md +144 -0
- package/docs/debug-panel.md +126 -0
- package/docs/docs.css +589 -0
- package/docs/environment.md +186 -0
- package/docs/getting-started.md +180 -0
- package/docs/index.html +14 -0
- package/docs/lab-architecture.md +100 -0
- package/docs/lighting.md +825 -0
- package/docs/main.jsx +752 -0
- package/docs/mcp.md +97 -0
- package/docs/post-processing.md +98 -0
- package/docs/settings-reference.md +1878 -0
- package/docs/shader-constants.md +77 -0
- package/docs/sky.md +182 -0
- package/docs/style-labs.md +309 -0
- package/docs/texture-lab.md +135 -0
- package/docs/toon-shading.md +183 -0
- package/docs/tsl-conventions.md +167 -0
- package/docs/vegetation-sky.md +275 -0
- package/docs/water.md +430 -0
- package/docs/weather.md +200 -0
- package/docs/world-scale.md +111 -0
- package/mcp/server.mjs +610 -0
- package/mcp/style-lab-tools.mjs +371 -0
- package/mcp/vite-plugin.mjs +174 -0
- package/mcp/workspace.mjs +397 -0
- package/package.json +64 -5
- package/src/ambientfx/INTEGRATION.md +164 -0
- package/src/ambientfx/ambientFxPresets.js +83 -0
- package/src/ambientfx/ambientFxSettings.js +368 -0
- package/src/ambientfx/emitters.js +169 -0
- package/src/ambientfx/index.js +5 -0
- package/src/ambientfx/particleBackbone.js +493 -0
- package/src/ambientfx/stylizedAmbientFx.js +450 -0
- package/src/assetlib/ambientcg.js +117 -0
- package/src/assetlib/assetRef.js +91 -0
- package/src/assetlib/importedEntry.js +40 -0
- package/src/assetlib/index.js +23 -0
- package/src/assetlib/kaykit.js +192 -0
- package/src/assetlib/kaykitStaticIndex.js +700 -0
- package/src/assetlib/loadImported.js +143 -0
- package/src/assetlib/opensource3d.js +113 -0
- package/src/assetlib/polyhaven.js +182 -0
- package/src/assetlib/polypizza.js +115 -0
- package/src/assetlib/smithsonian.js +214 -0
- package/src/assetlib/sources.js +279 -0
- package/src/assetlib/zip.js +58 -0
- package/src/biome/biomeGenerator.js +385 -0
- package/src/biome/biomeRuntime.js +299 -0
- package/src/biome/index.js +2 -0
- package/src/buildinggen/buildingAsset.js +44 -0
- package/src/buildinggen/buildingGrammar.js +311 -0
- package/src/buildinggen/buildingMesh.js +451 -0
- package/src/buildinggen/buildingPresets.js +46 -0
- package/src/buildinggen/buildingRecipe.js +100 -0
- package/src/buildinggen/buildingSettings.js +238 -0
- package/src/buildinggen/index.js +6 -0
- package/src/camera/cameraDirector.js +157 -0
- package/src/camera/cameraGenerator.js +367 -0
- package/src/camera/cameraRig.js +570 -0
- package/src/camera/cameraSettings.js +236 -0
- package/src/camera/index.js +7 -0
- package/src/catalog/builtinEntries.js +245 -0
- package/src/catalog/catalog.js +210 -0
- package/src/catalog/index.js +3 -0
- package/src/catalog/manifest.js +84 -0
- package/src/core/generation.js +529 -0
- package/src/environment/environmentRigs.js +21 -1
- package/src/environment/environmentSettings.js +4 -0
- package/src/environment/environmentSunShadowPass.js +8 -0
- package/src/fauna/INTEGRATION.md +174 -0
- package/src/fauna/boids.js +861 -0
- package/src/fauna/faunaBodies.js +492 -0
- package/src/fauna/faunaPresets.js +52 -0
- package/src/fauna/faunaSettings.js +525 -0
- package/src/fauna/index.js +5 -0
- package/src/fauna/stylizedFauna.js +395 -0
- package/src/game-feel/gameFeelGenerator.js +402 -0
- package/src/game-feel/gameFeelRuntime.js +549 -0
- package/src/game-feel/index.js +2 -0
- package/src/index.js +17 -6
- package/src/lighting/colorIntensity.js +177 -0
- package/src/lighting/index.js +161 -0
- package/src/lighting/lightDescriptors.js +249 -0
- package/src/lighting/lightingCapabilities.js +79 -0
- package/src/lighting/lightingDocuments.js +247 -0
- package/src/lighting/lightingFixtures.js +446 -0
- package/src/lighting/lightingGenerator.js +449 -0
- package/src/lighting/lightingPresets.js +319 -0
- package/src/lighting/lightingRuntime.js +723 -0
- package/src/lighting/lightingStyle.js +386 -0
- package/src/lighting/lightingSystem.js +774 -0
- package/src/lighting/unrealExport.js +186 -0
- package/src/lighting/utils.js +87 -0
- package/src/motion/index.js +5 -0
- package/src/motion/motionClip.js +441 -0
- package/src/motion/motionController.js +628 -0
- package/src/motion/motionDocuments.js +225 -0
- package/src/motion/motionGraph.js +307 -0
- package/src/motion/motionSettings.js +222 -0
- package/src/pathgen/index.js +7 -0
- package/src/pathgen/pathBridge.js +232 -0
- package/src/pathgen/pathPresets.js +35 -0
- package/src/pathgen/pathRibbon.js +410 -0
- package/src/pathgen/pathRouter.js +380 -0
- package/src/pathgen/pathSettings.js +335 -0
- package/src/pathgen/pathTextures.js +123 -0
- package/src/pathgen/stylizedPaths.js +453 -0
- package/src/post/index.js +1 -0
- package/src/post/postGenerator.js +177 -0
- package/src/post/postProcessing.js +41 -0
- package/src/propgen/generatorsWave1.js +379 -0
- package/src/propgen/generatorsWave2.js +462 -0
- package/src/propgen/index.js +5 -0
- package/src/propgen/propAsset.js +323 -0
- package/src/propgen/propParts.js +170 -0
- package/src/propgen/propPlacement.js +459 -0
- package/src/propgen/propPresets.js +82 -0
- package/src/propgen/propSettings.js +395 -0
- package/src/shaders-tsl/chunks/projected-water-caustics.js +242 -0
- package/src/shaders-tsl/chunks/vegetation-style.js +360 -0
- package/src/shaders-tsl/chunks/water-shore-state.js +31 -0
- package/src/shaders-tsl/chunks/water-waves.js +90 -11
- package/src/shaders-tsl/environment.js +15 -1
- package/src/shaders-tsl/flower.js +279 -30
- package/src/shaders-tsl/grass.js +60 -33
- package/src/shaders-tsl/sky.js +125 -31
- package/src/shaders-tsl/tree-leaf.js +61 -25
- package/src/shaders-tsl/water-breaker.js +7 -4
- package/src/shaders-tsl/water-shore-state-simulation.js +523 -0
- package/src/shaders-tsl/water.js +439 -49
- package/src/shaders-tsl/woody-surface.js +154 -0
- package/src/sky/sceneOverrideLayers.js +10 -0
- package/src/sky/skyQuality.js +26 -0
- package/src/sky/stylizedSky.js +753 -45
- package/src/soundscape/index.js +4 -0
- package/src/soundscape/soundscapeGenerator.js +179 -0
- package/src/soundscape/soundscapeRuntime.js +806 -0
- package/src/soundscape/soundscapeSettings.js +292 -0
- package/src/styles/index.js +13 -0
- package/src/styles/styleBundle.js +325 -0
- package/src/stylizedTerrain.js +32 -2
- package/src/stylizedWorld.js +423 -20
- package/src/texgen/evaluateTexture.js +675 -0
- package/src/texgen/index.js +60 -0
- package/src/texgen/noise2.js +210 -0
- package/src/texgen/textureAi.js +436 -0
- package/src/texgen/textureGenerators.js +516 -0
- package/src/texgen/texturePresets.js +490 -0
- package/src/texgen/textureSettings.js +342 -0
- package/src/texgen/textureThree.js +59 -0
- package/src/vegetation/flowerSpecies.js +15 -3
- package/src/vegetation/grassPalettes.js +153 -0
- package/src/vegetation/index.js +6 -0
- package/src/vegetation/stylizedBush.js +2 -0
- package/src/vegetation/stylizedFlower.js +82 -0
- package/src/vegetation/stylizedFlowers.js +48 -7
- package/src/vegetation/stylizedForest.js +29 -1
- package/src/vegetation/stylizedGrass.js +291 -56
- package/src/vegetation/stylizedTree.js +38 -2
- package/src/vegetation/stylizedTreeFoliage.js +2 -1
- package/src/vegetation/vegetationShaders.js +1110 -0
- package/src/vfxgen/INTEGRATION.md +145 -0
- package/src/vfxgen/core/burstBackbone.js +380 -0
- package/src/vfxgen/core/projectileCore.js +92 -0
- package/src/vfxgen/core/spriteShapes.js +98 -0
- package/src/vfxgen/core/trailRibbon.js +272 -0
- package/src/vfxgen/core/vfxRandom.js +29 -0
- package/src/vfxgen/effects/emitHelpers.js +37 -0
- package/src/vfxgen/effects/magicEffects.js +162 -0
- package/src/vfxgen/effects/movementEffects.js +87 -0
- package/src/vfxgen/effects/weaponEffects.js +118 -0
- package/src/vfxgen/index.js +18 -0
- package/src/vfxgen/moves/moveController.js +146 -0
- package/src/vfxgen/moves/moveLibrary.js +335 -0
- package/src/vfxgen/vfxPresets.js +98 -0
- package/src/vfxgen/vfxSettings.js +384 -0
- package/src/vfxgen/vfxSystem.js +449 -0
- package/src/vfxgen/weapons/stylizedWeapons.js +137 -0
- package/src/villagegen/index.js +4 -0
- package/src/villagegen/stylizedVillage.js +490 -0
- package/src/villagegen/villageArchetypes.js +160 -0
- package/src/villagegen/villageNames.js +40 -0
- package/src/villagegen/villageSites.js +105 -0
- package/src/water/sceneOverrideLayers.js +23 -0
- package/src/water/water.js +5 -0
- package/src/water/waterBreakerSystem.js +15 -1
- package/src/water/waterCurrentField.js +447 -0
- package/src/water/waterMaterial.js +12 -0
- package/src/water/waterNearshorePhase.js +320 -0
- package/src/water/waterScenePasses.js +83 -30
- package/src/water/waterSettings.js +325 -13
- package/src/water/waterShoreMaterial.js +322 -0
- package/src/water/waterShoreStateField.js +605 -0
- package/src/water/waterSurface.js +797 -28
- package/src/weather/index.js +6 -0
- package/src/weather/weatherPrecipitation.js +221 -0
- package/src/weather/weatherPresets.js +258 -0
- package/src/weather/weatherSettings.js +269 -0
- package/src/weather/weatherSystem.js +871 -0
- package/src/worldMinimap.js +62 -0
- package/src/worldPresets.js +4 -1
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import {
|
|
2
|
+
LIGHT_TYPES,
|
|
3
|
+
createLightDescriptor,
|
|
4
|
+
validateLightDescriptor,
|
|
5
|
+
} from './lightDescriptors.js';
|
|
6
|
+
import {
|
|
7
|
+
clamp,
|
|
8
|
+
cloneJson,
|
|
9
|
+
createValidationResult,
|
|
10
|
+
finite,
|
|
11
|
+
formatValidationErrors,
|
|
12
|
+
isPlainObject,
|
|
13
|
+
slug,
|
|
14
|
+
uniqueId,
|
|
15
|
+
} from './utils.js';
|
|
16
|
+
|
|
17
|
+
/** Type and schema stamps for saved LightingRecipe documents. */
|
|
18
|
+
export const LIGHTING_RECIPE_DOCUMENT_TYPE = 'toonlab/lighting-recipe';
|
|
19
|
+
export const LIGHTING_RECIPE_SCHEMA_VERSION = 1;
|
|
20
|
+
|
|
21
|
+
/** Type and schema stamps for saved, reusable lighting-look documents. */
|
|
22
|
+
export const LIGHTING_LOOK_DOCUMENT_TYPE = 'toonlab/lighting-look';
|
|
23
|
+
export const LIGHTING_LOOK_SCHEMA_VERSION = 1;
|
|
24
|
+
|
|
25
|
+
export const SHADOW_POLICY_MODES = Object.freeze(['budgeted', 'manual', 'disabled']);
|
|
26
|
+
export const SHADOW_UPDATE_MODES = Object.freeze(['everyFrame', 'auto', 'manual']);
|
|
27
|
+
|
|
28
|
+
/** Creates a serializable recipe-level shadow policy. */
|
|
29
|
+
export function createShadowPolicy(value = null) {
|
|
30
|
+
const source = isPlainObject(value) ? value : {};
|
|
31
|
+
const allowedTypes = Array.isArray(source.allowedTypes)
|
|
32
|
+
? source.allowedTypes.filter((type) => ['directional', 'point', 'spot'].includes(type))
|
|
33
|
+
: ['directional', 'point', 'spot'];
|
|
34
|
+
return {
|
|
35
|
+
allowedTypes: [...new Set(allowedTypes)],
|
|
36
|
+
directionalCascades: Math.round(clamp(finite(source.directionalCascades, 1), 1, 4)),
|
|
37
|
+
maxShadowedLights: Math.round(clamp(finite(source.maxShadowedLights, 8), 0, 128)),
|
|
38
|
+
maxShadowMapPixels: Math.round(Math.max(finite(source.maxShadowMapPixels, 16_777_216), 0)),
|
|
39
|
+
mode: SHADOW_POLICY_MODES.includes(source.mode) ? source.mode : 'budgeted',
|
|
40
|
+
updateMode: SHADOW_UPDATE_MODES.includes(source.updateMode) ? source.updateMode : 'auto',
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Creates a normalized, mutable LightingRecipe document.
|
|
46
|
+
* All positions and distances are expressed in meters.
|
|
47
|
+
*/
|
|
48
|
+
export function createLightingRecipe(options = {}) {
|
|
49
|
+
const source = isPlainObject(options) ? options : {};
|
|
50
|
+
const usedIds = new Set();
|
|
51
|
+
const lights = (Array.isArray(source.lights) ? source.lights : [])
|
|
52
|
+
.map((entry, index) => {
|
|
53
|
+
const light = createLightDescriptor(entry);
|
|
54
|
+
light.id = uniqueId(light.id || `light-${index + 1}`, usedIds, 'light');
|
|
55
|
+
return light;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
id: slug(source.id ?? source.name, 'lighting-recipe'),
|
|
60
|
+
lights,
|
|
61
|
+
metadata: isPlainObject(source.metadata) ? cloneJson(source.metadata) : {},
|
|
62
|
+
name: String(source.name ?? 'Untitled Lighting Recipe'),
|
|
63
|
+
schemaVersion: LIGHTING_RECIPE_SCHEMA_VERSION,
|
|
64
|
+
shadowPolicy: createShadowPolicy(source.shadowPolicy),
|
|
65
|
+
type: LIGHTING_RECIPE_DOCUMENT_TYPE,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Structural validation for untrusted or hand-edited LightingRecipe JSON. */
|
|
70
|
+
export function validateLightingRecipe(value) {
|
|
71
|
+
const errors = [];
|
|
72
|
+
const warnings = [];
|
|
73
|
+
if (!isPlainObject(value)) {
|
|
74
|
+
errors.push({ message: 'must be a JSON object', path: 'recipe' });
|
|
75
|
+
return createValidationResult(errors, warnings);
|
|
76
|
+
}
|
|
77
|
+
if (value.type !== LIGHTING_RECIPE_DOCUMENT_TYPE) {
|
|
78
|
+
errors.push({ message: `must equal "${LIGHTING_RECIPE_DOCUMENT_TYPE}"`, path: 'recipe.type' });
|
|
79
|
+
}
|
|
80
|
+
if (!Number.isInteger(Number(value.schemaVersion))) {
|
|
81
|
+
errors.push({ message: 'must be an integer', path: 'recipe.schemaVersion' });
|
|
82
|
+
} else if (Number(value.schemaVersion) > LIGHTING_RECIPE_SCHEMA_VERSION) {
|
|
83
|
+
errors.push({
|
|
84
|
+
message: `version ${value.schemaVersion} is newer than supported version ${LIGHTING_RECIPE_SCHEMA_VERSION}`,
|
|
85
|
+
path: 'recipe.schemaVersion',
|
|
86
|
+
});
|
|
87
|
+
} else if (Number(value.schemaVersion) < 1) {
|
|
88
|
+
errors.push({ message: 'must be at least 1', path: 'recipe.schemaVersion' });
|
|
89
|
+
}
|
|
90
|
+
if (typeof value.id !== 'string' || !value.id.trim()) {
|
|
91
|
+
errors.push({ message: 'must be a non-empty string', path: 'recipe.id' });
|
|
92
|
+
}
|
|
93
|
+
if (!Array.isArray(value.lights)) {
|
|
94
|
+
errors.push({ message: 'must be an array', path: 'recipe.lights' });
|
|
95
|
+
} else {
|
|
96
|
+
const ids = new Set();
|
|
97
|
+
value.lights.forEach((light, index) => {
|
|
98
|
+
const result = validateLightDescriptor(light, { path: `recipe.lights[${index}]` });
|
|
99
|
+
errors.push(...result.errors);
|
|
100
|
+
warnings.push(...result.warnings);
|
|
101
|
+
if (typeof light?.id === 'string') {
|
|
102
|
+
if (ids.has(light.id)) errors.push({ message: `duplicate id "${light.id}"`, path: `recipe.lights[${index}].id` });
|
|
103
|
+
ids.add(light.id);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
if (value.shadowPolicy !== undefined && !isPlainObject(value.shadowPolicy)) {
|
|
108
|
+
errors.push({ message: 'must be an object', path: 'recipe.shadowPolicy' });
|
|
109
|
+
}
|
|
110
|
+
if (isPlainObject(value.shadowPolicy)) {
|
|
111
|
+
if (value.shadowPolicy.mode !== undefined && !SHADOW_POLICY_MODES.includes(value.shadowPolicy.mode)) {
|
|
112
|
+
errors.push({ message: `must be one of ${SHADOW_POLICY_MODES.join(', ')}`, path: 'recipe.shadowPolicy.mode' });
|
|
113
|
+
}
|
|
114
|
+
if (Array.isArray(value.shadowPolicy.allowedTypes)) {
|
|
115
|
+
const unsupported = value.shadowPolicy.allowedTypes.filter((type) => !LIGHT_TYPES.includes(type));
|
|
116
|
+
if (unsupported.length > 0) warnings.push({
|
|
117
|
+
message: `unsupported types are ignored: ${unsupported.join(', ')}`,
|
|
118
|
+
path: 'recipe.shadowPolicy.allowedTypes',
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return createValidationResult(errors, warnings);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Throws when a LightingRecipe is structurally invalid, otherwise returns it. */
|
|
126
|
+
export function assertLightingRecipe(value) {
|
|
127
|
+
const result = validateLightingRecipe(value);
|
|
128
|
+
if (!result.valid) throw new Error(formatValidationErrors('Lighting recipe', result));
|
|
129
|
+
return value;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Serializes a validated LightingRecipe document. */
|
|
133
|
+
export function serializeLightingRecipe(recipe, { pretty = false } = {}) {
|
|
134
|
+
assertLightingRecipe(recipe);
|
|
135
|
+
return JSON.stringify(recipe, null, pretty ? 2 : undefined);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Parses, validates, and normalizes a LightingRecipe JSON string or object. */
|
|
139
|
+
export function deserializeLightingRecipe(jsonOrObject) {
|
|
140
|
+
let source = jsonOrObject;
|
|
141
|
+
if (typeof source === 'string') {
|
|
142
|
+
try {
|
|
143
|
+
source = JSON.parse(source);
|
|
144
|
+
} catch (error) {
|
|
145
|
+
throw new Error(`Invalid lighting recipe JSON: ${error.message}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
assertLightingRecipe(source);
|
|
149
|
+
return createLightingRecipe(source);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Creates a versioned look document. A look owns a recipe plus portable
|
|
154
|
+
* environment/post hints and a quality profile reference or inline profile.
|
|
155
|
+
*/
|
|
156
|
+
export function createLightingLook(options = {}) {
|
|
157
|
+
const source = isPlainObject(options) ? options : {};
|
|
158
|
+
const recipe = typeof source.recipe === 'string'
|
|
159
|
+
? source.recipe
|
|
160
|
+
: createLightingRecipe(isPlainObject(source.recipe) ? source.recipe : {});
|
|
161
|
+
const quality = typeof source.quality === 'string'
|
|
162
|
+
? source.quality
|
|
163
|
+
: isPlainObject(source.quality) ? cloneJson(source.quality) : 'balanced';
|
|
164
|
+
return {
|
|
165
|
+
environment: isPlainObject(source.environment) ? cloneJson(source.environment) : {},
|
|
166
|
+
id: slug(source.id ?? source.name, 'lighting-look'),
|
|
167
|
+
metadata: isPlainObject(source.metadata) ? cloneJson(source.metadata) : {},
|
|
168
|
+
name: String(source.name ?? 'Untitled Lighting Look'),
|
|
169
|
+
post: isPlainObject(source.post) ? cloneJson(source.post) : {},
|
|
170
|
+
quality,
|
|
171
|
+
recipe,
|
|
172
|
+
schemaVersion: LIGHTING_LOOK_SCHEMA_VERSION,
|
|
173
|
+
type: LIGHTING_LOOK_DOCUMENT_TYPE,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Structural validation for reusable lighting-look documents. */
|
|
178
|
+
export function validateLightingLook(value) {
|
|
179
|
+
const errors = [];
|
|
180
|
+
const warnings = [];
|
|
181
|
+
if (!isPlainObject(value)) {
|
|
182
|
+
errors.push({ message: 'must be a JSON object', path: 'look' });
|
|
183
|
+
return createValidationResult(errors, warnings);
|
|
184
|
+
}
|
|
185
|
+
if (value.type !== LIGHTING_LOOK_DOCUMENT_TYPE) {
|
|
186
|
+
errors.push({ message: `must equal "${LIGHTING_LOOK_DOCUMENT_TYPE}"`, path: 'look.type' });
|
|
187
|
+
}
|
|
188
|
+
if (!Number.isInteger(Number(value.schemaVersion))) {
|
|
189
|
+
errors.push({ message: 'must be an integer', path: 'look.schemaVersion' });
|
|
190
|
+
} else if (Number(value.schemaVersion) > LIGHTING_LOOK_SCHEMA_VERSION) {
|
|
191
|
+
errors.push({
|
|
192
|
+
message: `version ${value.schemaVersion} is newer than supported version ${LIGHTING_LOOK_SCHEMA_VERSION}`,
|
|
193
|
+
path: 'look.schemaVersion',
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
if (typeof value.id !== 'string' || !value.id.trim()) {
|
|
197
|
+
errors.push({ message: 'must be a non-empty string', path: 'look.id' });
|
|
198
|
+
}
|
|
199
|
+
if (typeof value.recipe !== 'string') {
|
|
200
|
+
const recipeResult = validateLightingRecipe(value.recipe);
|
|
201
|
+
errors.push(...recipeResult.errors.map((entry) => ({ ...entry, path: `look.${entry.path}` })));
|
|
202
|
+
warnings.push(...recipeResult.warnings.map((entry) => ({ ...entry, path: `look.${entry.path}` })));
|
|
203
|
+
} else if (!value.recipe.trim()) {
|
|
204
|
+
errors.push({ message: 'preset reference must not be empty', path: 'look.recipe' });
|
|
205
|
+
}
|
|
206
|
+
if (typeof value.quality !== 'string' && !isPlainObject(value.quality)) {
|
|
207
|
+
errors.push({ message: 'must be a quality preset id or inline profile', path: 'look.quality' });
|
|
208
|
+
}
|
|
209
|
+
if (value.environment !== undefined && !isPlainObject(value.environment)) {
|
|
210
|
+
errors.push({ message: 'must be an object', path: 'look.environment' });
|
|
211
|
+
}
|
|
212
|
+
if (value.post !== undefined && !isPlainObject(value.post)) {
|
|
213
|
+
errors.push({ message: 'must be an object', path: 'look.post' });
|
|
214
|
+
}
|
|
215
|
+
return createValidationResult(errors, warnings);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function assertLightingLook(value) {
|
|
219
|
+
const result = validateLightingLook(value);
|
|
220
|
+
if (!result.valid) throw new Error(formatValidationErrors('Lighting look', result));
|
|
221
|
+
return value;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function serializeLightingLook(look, { pretty = false } = {}) {
|
|
225
|
+
assertLightingLook(look);
|
|
226
|
+
return JSON.stringify(look, null, pretty ? 2 : undefined);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function deserializeLightingLook(jsonOrObject) {
|
|
230
|
+
let source = jsonOrObject;
|
|
231
|
+
if (typeof source === 'string') {
|
|
232
|
+
try {
|
|
233
|
+
source = JSON.parse(source);
|
|
234
|
+
} catch (error) {
|
|
235
|
+
throw new Error(`Invalid lighting look JSON: ${error.message}`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
assertLightingLook(source);
|
|
239
|
+
return createLightingLook(source);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Explicit aliases make the saved-preset intent discoverable without creating
|
|
243
|
+
// a second schema for the exact same document.
|
|
244
|
+
export const createLightingLookPreset = createLightingLook;
|
|
245
|
+
export const validateLightingLookPreset = validateLightingLook;
|
|
246
|
+
export const serializeLightingLookPreset = serializeLightingLook;
|
|
247
|
+
export const deserializeLightingLookPreset = deserializeLightingLook;
|
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
// Reusable light fixtures — the game's lighting vocabulary.
|
|
2
|
+
//
|
|
3
|
+
// A fixture defines one *kind* of practical light ("street lamp", "paper
|
|
4
|
+
// lantern") once: a base light descriptor, seeded variation domains so many
|
|
5
|
+
// placements feel hand-tuned rather than cloned, flicker behavior, and a
|
|
6
|
+
// day/night schedule. Scenes then reference fixtures by id and position;
|
|
7
|
+
// resolveFixturePlacement turns (fixture, seed) into a concrete descriptor
|
|
8
|
+
// deterministically, so the same placement renders identically in a lab,
|
|
9
|
+
// through MCP, and in a shipped game.
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
cloneSerializable,
|
|
13
|
+
createSeededRandom,
|
|
14
|
+
deepMerge,
|
|
15
|
+
deriveSeed,
|
|
16
|
+
generateDomainValues,
|
|
17
|
+
hashSeed,
|
|
18
|
+
validateGeneratorDomains,
|
|
19
|
+
} from '../core/generation.js';
|
|
20
|
+
import {
|
|
21
|
+
createSettingsPresetDocument,
|
|
22
|
+
parsePresetDocument,
|
|
23
|
+
serializePresetDocument,
|
|
24
|
+
validateSettingsPresetDocument,
|
|
25
|
+
} from '../core/presetDocuments.js';
|
|
26
|
+
import { colorTemperatureToRgb } from './colorIntensity.js';
|
|
27
|
+
import { createLightDescriptor } from './lightDescriptors.js';
|
|
28
|
+
import { clamp, cloneJson, finite, isPlainObject, slug } from './utils.js';
|
|
29
|
+
|
|
30
|
+
export const LIGHT_FIXTURE_DOCUMENT_TYPE = 'toonlab/light-fixture';
|
|
31
|
+
export const LIGHT_FIXTURE_SCHEMA_VERSION = 1;
|
|
32
|
+
|
|
33
|
+
export const FIXTURE_SCHEDULE_MODES = Object.freeze(['always', 'night', 'day']);
|
|
34
|
+
|
|
35
|
+
/** Only a base light-type change forces recreating placed lights. */
|
|
36
|
+
export const LIGHT_FIXTURE_APPLY_METADATA = Object.freeze({ '*': 'hot', 'base.type': 'rebuild' });
|
|
37
|
+
|
|
38
|
+
function normalizeFlicker(source = {}) {
|
|
39
|
+
const input = isPlainObject(source) ? source : {};
|
|
40
|
+
return {
|
|
41
|
+
amount: clamp(finite(input.amount, 0), 0, 1),
|
|
42
|
+
speed: clamp(finite(input.speed, 6), 0.05, 60),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function normalizeSchedule(source = {}) {
|
|
47
|
+
const input = isPlainObject(source) ? source : {};
|
|
48
|
+
return {
|
|
49
|
+
minimum: clamp(finite(input.minimum, 0), 0, 1),
|
|
50
|
+
mode: FIXTURE_SCHEDULE_MODES.includes(input.mode) ? input.mode : 'night',
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Normalizes fixture settings; `variation` keeps the shared domain grammar. */
|
|
55
|
+
export function createLightFixtureSettings(source = {}) {
|
|
56
|
+
const input = isPlainObject(source) ? source : {};
|
|
57
|
+
const variation = isPlainObject(input.variation) ? cloneSerializable(input.variation) : {};
|
|
58
|
+
const domainCheck = validateGeneratorDomains(variation);
|
|
59
|
+
if (!domainCheck.ok) {
|
|
60
|
+
throw new Error(`Invalid fixture variation domains: ${domainCheck.errors.join(' ')}`);
|
|
61
|
+
}
|
|
62
|
+
const emissive = isPlainObject(input.emissive) ? input.emissive : {};
|
|
63
|
+
return {
|
|
64
|
+
// Kept as an authoring partial (not a full descriptor) so unset fields
|
|
65
|
+
// keep tracking descriptor defaults; placements run createLightDescriptor.
|
|
66
|
+
base: isPlainObject(input.base) ? cloneSerializable(input.base) : { type: 'point' },
|
|
67
|
+
category: String(input.category ?? 'practical'),
|
|
68
|
+
emissive: {
|
|
69
|
+
meshPattern: emissive.meshPattern ? String(emissive.meshPattern) : null,
|
|
70
|
+
scale: clamp(finite(emissive.scale, 1), 0, 8),
|
|
71
|
+
},
|
|
72
|
+
flicker: normalizeFlicker(input.flicker),
|
|
73
|
+
schedule: normalizeSchedule(input.schedule),
|
|
74
|
+
variation,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const sanitizeLightFixtureSettings = createLightFixtureSettings;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Applies sampled variation onto a fixture's base descriptor source.
|
|
82
|
+
* `kelvin` is a first-class variation key: it resolves through the shared
|
|
83
|
+
* black-body fit into `color`, so warmth can vary as one number.
|
|
84
|
+
*/
|
|
85
|
+
function applyVariation(base, sampled) {
|
|
86
|
+
const variation = isPlainObject(sampled) ? { ...sampled } : {};
|
|
87
|
+
if (variation.kelvin !== undefined) {
|
|
88
|
+
variation.color = colorTemperatureToRgb(variation.kelvin);
|
|
89
|
+
delete variation.kelvin;
|
|
90
|
+
}
|
|
91
|
+
if (variation.intensityScale !== undefined) {
|
|
92
|
+
const scale = Math.max(finite(variation.intensityScale, 1), 0);
|
|
93
|
+
const intensity = isPlainObject(base.intensity) ? { ...base.intensity } : { value: finite(base.intensity, 1) };
|
|
94
|
+
intensity.artisticMultiplier = Math.max(finite(intensity.artisticMultiplier, 1), 0) * scale;
|
|
95
|
+
variation.intensity = intensity;
|
|
96
|
+
delete variation.intensityScale;
|
|
97
|
+
}
|
|
98
|
+
return deepMerge(base, variation);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Deterministically resolves one placement of a fixture into a complete
|
|
103
|
+
* light descriptor plus runtime hints (flicker phase, schedule).
|
|
104
|
+
*/
|
|
105
|
+
export function resolveFixturePlacement(fixtureSettings, {
|
|
106
|
+
id = null,
|
|
107
|
+
overrides = null,
|
|
108
|
+
position = null,
|
|
109
|
+
seed = 1,
|
|
110
|
+
target = null,
|
|
111
|
+
} = {}) {
|
|
112
|
+
const fixture = createLightFixtureSettings(fixtureSettings);
|
|
113
|
+
const placementSeed = hashSeed(seed);
|
|
114
|
+
const sampled = generateDomainValues(fixture.variation, { seed: placementSeed });
|
|
115
|
+
let descriptorSource = applyVariation(fixture.base, sampled);
|
|
116
|
+
if (isPlainObject(overrides)) descriptorSource = deepMerge(descriptorSource, overrides);
|
|
117
|
+
if (Array.isArray(position)) descriptorSource.position = position.slice(0, 3).map((value) => finite(value, 0));
|
|
118
|
+
if (Array.isArray(target)) descriptorSource.target = target.slice(0, 3).map((value) => finite(value, 0));
|
|
119
|
+
if (id) descriptorSource.id = String(id);
|
|
120
|
+
const descriptor = createLightDescriptor(descriptorSource);
|
|
121
|
+
return {
|
|
122
|
+
descriptor,
|
|
123
|
+
flicker: fixture.flicker,
|
|
124
|
+
// Deterministic per-placement phase so synchronized flicker never happens.
|
|
125
|
+
flickerPhase: createSeededRandom(deriveSeed(placementSeed, 'flicker')).float(0, Math.PI * 2),
|
|
126
|
+
schedule: fixture.schedule,
|
|
127
|
+
seed: placementSeed,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Registry — starting vocabulary, never a boundary.
|
|
133
|
+
|
|
134
|
+
const FIXTURE_PRESETS = new Map();
|
|
135
|
+
|
|
136
|
+
export function registerLightFixture(id, definition = {}, { overwrite = false } = {}) {
|
|
137
|
+
const key = slug(id, 'fixture');
|
|
138
|
+
if (!overwrite && FIXTURE_PRESETS.has(key)) {
|
|
139
|
+
throw new Error(`Light fixture "${key}" already exists. Pass { overwrite: true } to replace it.`);
|
|
140
|
+
}
|
|
141
|
+
const source = isPlainObject(definition) ? definition : {};
|
|
142
|
+
FIXTURE_PRESETS.set(key, {
|
|
143
|
+
description: String(source.description ?? ''),
|
|
144
|
+
label: String(source.label ?? source.name ?? key),
|
|
145
|
+
settings: createLightFixtureSettings(source.settings ?? source),
|
|
146
|
+
});
|
|
147
|
+
return key;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function getLightFixtureOptions() {
|
|
151
|
+
return [...FIXTURE_PRESETS.entries()].map(([id, entry]) => ({
|
|
152
|
+
category: entry.settings.category, description: entry.description, id, label: entry.label,
|
|
153
|
+
}));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function resolveLightFixture(value) {
|
|
157
|
+
if (typeof value === 'string') {
|
|
158
|
+
const entry = FIXTURE_PRESETS.get(value) ?? FIXTURE_PRESETS.get(slug(value, 'fixture'));
|
|
159
|
+
if (!entry) {
|
|
160
|
+
throw new Error(`Unknown light fixture "${value}". Registered: ${[...FIXTURE_PRESETS.keys()].join(', ')}.`);
|
|
161
|
+
}
|
|
162
|
+
return cloneJson(entry.settings);
|
|
163
|
+
}
|
|
164
|
+
if (isPlainObject(value) && value.type === LIGHT_FIXTURE_DOCUMENT_TYPE) {
|
|
165
|
+
return createLightFixtureSettings(value.settings);
|
|
166
|
+
}
|
|
167
|
+
return createLightFixtureSettings(value);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// Versioned documents (shared core envelope).
|
|
172
|
+
|
|
173
|
+
function migrateFixtureDocument(input) {
|
|
174
|
+
return {
|
|
175
|
+
...input,
|
|
176
|
+
id: input.id ?? input.name,
|
|
177
|
+
label: input.label ?? input.title ?? input.name,
|
|
178
|
+
settings: input.settings ?? input.fixture ?? {},
|
|
179
|
+
type: LIGHT_FIXTURE_DOCUMENT_TYPE,
|
|
180
|
+
version: Number(input.version ?? input.schemaVersion ?? 1),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function validateLightFixtureDocument(input) {
|
|
185
|
+
return validateSettingsPresetDocument(input, {
|
|
186
|
+
documentType: LIGHT_FIXTURE_DOCUMENT_TYPE,
|
|
187
|
+
migrateDocument: migrateFixtureDocument,
|
|
188
|
+
normalizeId: (value) => slug(value, ''),
|
|
189
|
+
sanitizeSettings: createLightFixtureSettings,
|
|
190
|
+
schemaVersion: LIGHT_FIXTURE_SCHEMA_VERSION,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function createLightFixtureDocument(id, definition = {}) {
|
|
195
|
+
return createSettingsPresetDocument(id, definition, {
|
|
196
|
+
collectSettings: (source) => createLightFixtureSettings(source.settings ?? source),
|
|
197
|
+
documentType: LIGHT_FIXTURE_DOCUMENT_TYPE,
|
|
198
|
+
schemaVersion: LIGHT_FIXTURE_SCHEMA_VERSION,
|
|
199
|
+
validateDocument: validateLightFixtureDocument,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function parseLightFixtureDocument(input) {
|
|
204
|
+
return parsePresetDocument(input, validateLightFixtureDocument, {
|
|
205
|
+
invalidJsonLabel: 'light fixture',
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function serializeLightFixtureDocument(idOrDocument, definition = {}, { pretty = true } = {}) {
|
|
210
|
+
return serializePresetDocument(idOrDocument, definition, {
|
|
211
|
+
createDocument: createLightFixtureDocument,
|
|
212
|
+
pretty,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function registerLightFixtureDocument(document, { overwrite = true } = {}) {
|
|
217
|
+
const result = validateLightFixtureDocument(document);
|
|
218
|
+
if (!result.ok) throw new Error(result.errors.join(' '));
|
|
219
|
+
return registerLightFixture(result.value.id, {
|
|
220
|
+
description: result.value.description,
|
|
221
|
+
label: result.value.label,
|
|
222
|
+
settings: result.value.settings,
|
|
223
|
+
}, { overwrite });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
// Built-in vocabulary.
|
|
228
|
+
|
|
229
|
+
const range = (min, max, step = 0.01) => ({ $type: 'range', max, min, step });
|
|
230
|
+
|
|
231
|
+
registerLightFixture('street-lamp', {
|
|
232
|
+
description: 'Warm sodium pole lamp with a wide soft pool and slight per-lamp warmth drift.',
|
|
233
|
+
label: 'Street Lamp',
|
|
234
|
+
settings: {
|
|
235
|
+
base: {
|
|
236
|
+
castShadow: false,
|
|
237
|
+
decay: 2,
|
|
238
|
+
distance: 14,
|
|
239
|
+
intensity: { unit: 'lumens', value: 900 },
|
|
240
|
+
position: [0, 4.2, 0],
|
|
241
|
+
type: 'point',
|
|
242
|
+
},
|
|
243
|
+
category: 'architectural',
|
|
244
|
+
emissive: { meshPattern: 'lamp', scale: 1 },
|
|
245
|
+
flicker: { amount: 0.03, speed: 8 },
|
|
246
|
+
schedule: { mode: 'night' },
|
|
247
|
+
variation: {
|
|
248
|
+
intensityScale: range(0.85, 1.15),
|
|
249
|
+
kelvin: range(1900, 2400, 10),
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
registerLightFixture('paper-lantern', {
|
|
255
|
+
description: 'Small warm lantern with gentle candle flicker; hangs low, fades in at dusk.',
|
|
256
|
+
label: 'Paper Lantern',
|
|
257
|
+
settings: {
|
|
258
|
+
base: {
|
|
259
|
+
decay: 2,
|
|
260
|
+
distance: 7,
|
|
261
|
+
intensity: { unit: 'lumens', value: 260 },
|
|
262
|
+
position: [0, 2.2, 0],
|
|
263
|
+
type: 'point',
|
|
264
|
+
},
|
|
265
|
+
category: 'practical',
|
|
266
|
+
emissive: { meshPattern: 'lantern', scale: 1.2 },
|
|
267
|
+
flicker: { amount: 0.12, speed: 5 },
|
|
268
|
+
schedule: { mode: 'night' },
|
|
269
|
+
variation: {
|
|
270
|
+
distance: range(6, 9, 0.1),
|
|
271
|
+
intensityScale: range(0.75, 1.25),
|
|
272
|
+
kelvin: range(1700, 2200, 10),
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
registerLightFixture('window-glow', {
|
|
278
|
+
description: 'Interior spill through a window; rect-area softbox, appears at night.',
|
|
279
|
+
label: 'Window Glow',
|
|
280
|
+
settings: {
|
|
281
|
+
base: {
|
|
282
|
+
height: 1.4,
|
|
283
|
+
intensity: { unit: 'nits', value: 9 },
|
|
284
|
+
position: [0, 1.8, 0],
|
|
285
|
+
type: 'rectArea',
|
|
286
|
+
width: 1.1,
|
|
287
|
+
},
|
|
288
|
+
category: 'architectural',
|
|
289
|
+
schedule: { mode: 'night', minimum: 0.05 },
|
|
290
|
+
variation: {
|
|
291
|
+
intensityScale: range(0.7, 1.3),
|
|
292
|
+
kelvin: range(2300, 3600, 25),
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
registerLightFixture('neon-sign', {
|
|
298
|
+
description: 'Saturated tube glow with electrical stutter; picks a hue per placement.',
|
|
299
|
+
label: 'Neon Sign',
|
|
300
|
+
settings: {
|
|
301
|
+
base: {
|
|
302
|
+
height: 0.18,
|
|
303
|
+
intensity: { unit: 'nits', value: 22 },
|
|
304
|
+
position: [0, 2.6, 0],
|
|
305
|
+
type: 'tubeArea',
|
|
306
|
+
width: 1.6,
|
|
307
|
+
},
|
|
308
|
+
category: 'signage',
|
|
309
|
+
flicker: { amount: 0.22, speed: 24 },
|
|
310
|
+
schedule: { mode: 'night', minimum: 0.35 },
|
|
311
|
+
variation: {
|
|
312
|
+
color: {
|
|
313
|
+
$type: 'choice',
|
|
314
|
+
options: [
|
|
315
|
+
{ value: [1, 0.18, 0.5], weight: 3 },
|
|
316
|
+
{ value: [0.2, 0.85, 1], weight: 3 },
|
|
317
|
+
{ value: [0.62, 0.3, 1], weight: 2 },
|
|
318
|
+
{ value: [0.25, 1, 0.6], weight: 2 },
|
|
319
|
+
{ value: [1, 0.62, 0.12], weight: 1 },
|
|
320
|
+
],
|
|
321
|
+
},
|
|
322
|
+
intensityScale: range(0.8, 1.3),
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
registerLightFixture('campfire', {
|
|
328
|
+
description: 'Strong warm flame with heavy organic flicker; always lit.',
|
|
329
|
+
label: 'Campfire',
|
|
330
|
+
settings: {
|
|
331
|
+
base: {
|
|
332
|
+
castShadow: true,
|
|
333
|
+
decay: 2,
|
|
334
|
+
distance: 12,
|
|
335
|
+
intensity: { unit: 'lumens', value: 1500 },
|
|
336
|
+
position: [0, 0.7, 0],
|
|
337
|
+
shadow: { enabled: true, mapSize: 512 },
|
|
338
|
+
type: 'point',
|
|
339
|
+
},
|
|
340
|
+
category: 'effect',
|
|
341
|
+
flicker: { amount: 0.35, speed: 11 },
|
|
342
|
+
schedule: { mode: 'always' },
|
|
343
|
+
variation: {
|
|
344
|
+
intensityScale: range(0.9, 1.1),
|
|
345
|
+
kelvin: range(1500, 2000, 10),
|
|
346
|
+
},
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
registerLightFixture('cms-lantern', {
|
|
351
|
+
description: 'Call Me Sensei signature lantern: saturated amber glow with a wide halo, tuned to read against vivid blue nights.',
|
|
352
|
+
label: 'CMS Lantern',
|
|
353
|
+
settings: {
|
|
354
|
+
base: {
|
|
355
|
+
decay: 2,
|
|
356
|
+
distance: 10,
|
|
357
|
+
intensity: { unit: 'lumens', value: 420 },
|
|
358
|
+
position: [0, 2.4, 0],
|
|
359
|
+
type: 'point',
|
|
360
|
+
},
|
|
361
|
+
category: 'practical',
|
|
362
|
+
emissive: { meshPattern: 'lantern', scale: 1.5 },
|
|
363
|
+
flicker: { amount: 0.09, speed: 5 },
|
|
364
|
+
schedule: { mode: 'night', minimum: 0.08 },
|
|
365
|
+
variation: {
|
|
366
|
+
distance: range(8.5, 12, 0.1),
|
|
367
|
+
intensityScale: range(0.85, 1.3),
|
|
368
|
+
kelvin: range(1850, 2150, 10),
|
|
369
|
+
},
|
|
370
|
+
},
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
registerLightFixture('cms-city-neon', {
|
|
374
|
+
description: 'Call Me Sensei city signage: acid ZZZ-palette tube glow with confident stutter for night streets.',
|
|
375
|
+
label: 'CMS City Neon',
|
|
376
|
+
settings: {
|
|
377
|
+
base: {
|
|
378
|
+
height: 0.2,
|
|
379
|
+
intensity: { unit: 'nits', value: 30 },
|
|
380
|
+
position: [0, 3, 0],
|
|
381
|
+
type: 'tubeArea',
|
|
382
|
+
width: 1.8,
|
|
383
|
+
},
|
|
384
|
+
category: 'signage',
|
|
385
|
+
flicker: { amount: 0.18, speed: 26 },
|
|
386
|
+
schedule: { mode: 'night', minimum: 0.45 },
|
|
387
|
+
variation: {
|
|
388
|
+
color: {
|
|
389
|
+
$type: 'choice',
|
|
390
|
+
options: [
|
|
391
|
+
{ value: [1, 0.12, 0.55], weight: 3 },
|
|
392
|
+
{ value: [0.1, 0.9, 1], weight: 3 },
|
|
393
|
+
{ value: [0.75, 0.25, 1], weight: 2 },
|
|
394
|
+
{ value: [1, 0.85, 0.1], weight: 2 },
|
|
395
|
+
{ value: [0.2, 1, 0.5], weight: 1 },
|
|
396
|
+
],
|
|
397
|
+
},
|
|
398
|
+
intensityScale: range(0.9, 1.45),
|
|
399
|
+
width: range(1.2, 2.6, 0.1),
|
|
400
|
+
},
|
|
401
|
+
},
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
registerLightFixture('cms-street-lamp', {
|
|
405
|
+
description: 'Call Me Sensei street lamp: brighter, warmer pole light with a saturated pool for vivid night streets.',
|
|
406
|
+
label: 'CMS Street Lamp',
|
|
407
|
+
settings: {
|
|
408
|
+
base: {
|
|
409
|
+
castShadow: false,
|
|
410
|
+
decay: 2,
|
|
411
|
+
distance: 17,
|
|
412
|
+
intensity: { unit: 'lumens', value: 1250 },
|
|
413
|
+
position: [0, 4.6, 0],
|
|
414
|
+
type: 'point',
|
|
415
|
+
},
|
|
416
|
+
category: 'architectural',
|
|
417
|
+
emissive: { meshPattern: 'lamp', scale: 1.3 },
|
|
418
|
+
flicker: { amount: 0.02, speed: 9 },
|
|
419
|
+
schedule: { mode: 'night' },
|
|
420
|
+
variation: {
|
|
421
|
+
intensityScale: range(0.9, 1.2),
|
|
422
|
+
kelvin: range(2000, 2500, 10),
|
|
423
|
+
},
|
|
424
|
+
},
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
registerLightFixture('shrine-candle', {
|
|
428
|
+
description: 'Tiny devotional flame; near-pure candlelight with soft flicker.',
|
|
429
|
+
label: 'Shrine Candle',
|
|
430
|
+
settings: {
|
|
431
|
+
base: {
|
|
432
|
+
decay: 2,
|
|
433
|
+
distance: 3.5,
|
|
434
|
+
intensity: { unit: 'lumens', value: 90 },
|
|
435
|
+
position: [0, 0.9, 0],
|
|
436
|
+
type: 'point',
|
|
437
|
+
},
|
|
438
|
+
category: 'practical',
|
|
439
|
+
flicker: { amount: 0.2, speed: 7 },
|
|
440
|
+
schedule: { mode: 'always' },
|
|
441
|
+
variation: {
|
|
442
|
+
intensityScale: range(0.7, 1.2),
|
|
443
|
+
kelvin: range(1550, 1900, 10),
|
|
444
|
+
},
|
|
445
|
+
},
|
|
446
|
+
});
|