diff --git a/index.html b/index.html
index 3172b49..38050a2 100644
--- a/index.html
+++ b/index.html
@@ -95,9 +95,9 @@
diff --git a/src/app.js b/src/app.js
index 35cc1a8..1154481 100644
--- a/src/app.js
+++ b/src/app.js
@@ -2,7 +2,7 @@ import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { preloadStandardColormaps, getBiomeTint, getBiomeData } from "./modules/biome-engine.js";
import { BIOME_PRESETS, getBiomeSwatch, formatBiomeCoordinates, shouldApplyGrassTint, shouldCompositeSideOverlay, compositeGrassSideBuffers } from "./modules/biome-ui.js";
-import { LIGHTING_PRESETS, createLightingRig, applyLightingPreset } from "./modules/lighting-presets.js";
+import { LIGHTING_PRESETS, createLightingRig, applyLightingPreset, DEFAULT_LIGHTING_PRESET } from "./modules/lighting-presets.js";
import { applyPOM, clampDepthScale, POM_DEFAULT_DEPTH_SCALE } from "./modules/pbr-pom.js";
import { createLabPBRMaterial, applyLabPBRShader } from "./modules/pbr-material.js";
import { isPlantOrCrossBlock, createCrossQuadGeometry, cullMultiblockFaces } from "./modules/block-culling.js";
@@ -137,7 +137,7 @@ class TextureStudioApp {
this.viewports = [];
this.currentBiome = "plains";
- this.currentLightingPreset = "trailer-golden-hour";
+ this.currentLightingPreset = DEFAULT_LIGHTING_PRESET || "studio-neutral";
this.isPomEnabled = true;
this.pomDepthScale = POM_DEFAULT_DEPTH_SCALE;
@@ -397,6 +397,8 @@ class TextureStudioApp {
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: "high-performance" });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
+ renderer.toneMapping = THREE.ACESFilmicToneMapping;
+ renderer.toneMappingExposure = 1.0;
this.dom.canvasWrappers[i].appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
diff --git a/src/modules/lighting-presets.js b/src/modules/lighting-presets.js
index c46dd35..bdc21b0 100644
--- a/src/modules/lighting-presets.js
+++ b/src/modules/lighting-presets.js
@@ -65,7 +65,7 @@ export const LIGHTING_PRESETS = Object.freeze({
color: "#ffb366",
colorHex: 0xffb366,
hex: "#ffb366",
- intensity: 1.8,
+ intensity: 1.2,
azimuth: 55,
elevation: 28,
distance: 50,
@@ -75,7 +75,7 @@ export const LIGHTING_PRESETS = Object.freeze({
color: "#8ca0ba",
colorHex: 0x8ca0ba,
hex: "#8ca0ba",
- intensity: 0.6
+ intensity: 0.4
}),
fill: null,
rim: null,
@@ -110,12 +110,12 @@ export const LIGHTING_PRESETS = Object.freeze({
"studio-neutral": Object.freeze({
id: "studio-neutral",
name: "Studio Neutral",
- description: "Multi-angle diffuse 3-point lighting setup with key, fill, rim/bounce, and ambient illumination.",
+ description: "Multi-angle diffuse 3-point lighting setup with neutral key, fill, bounce, and ambient illumination calibrated for accurate texture inspection.",
sun: Object.freeze({
- color: "#fffaf0",
- colorHex: 0xfffaf0,
- hex: "#fffaf0",
- intensity: 1.5,
+ color: "#ffffff",
+ colorHex: 0xffffff,
+ hex: "#ffffff",
+ intensity: 1.0,
azimuth: 35.54,
elevation: 49.3,
distance: 13.19,
@@ -126,46 +126,69 @@ export const LIGHTING_PRESETS = Object.freeze({
color: "#ffffff",
colorHex: 0xffffff,
hex: "#ffffff",
- intensity: 0.5
+ intensity: 0.4
}),
fill: Object.freeze({
- color: "#7da4c7",
- colorHex: 0x7da4c7,
- hex: "#7da4c7",
- intensity: 0.5,
+ color: "#ffffff",
+ colorHex: 0xffffff,
+ hex: "#ffffff",
+ intensity: 0.3,
position: Object.freeze([-5, 2, -5])
}),
rim: Object.freeze({
- color: "#ffeedd",
- colorHex: 0xffeedd,
- hex: "#ffeedd",
- intensity: 0.4,
+ color: "#ffffff",
+ colorHex: 0xffffff,
+ hex: "#ffffff",
+ intensity: 0.2,
position: Object.freeze([0, -8, 0])
}),
extraLights: Object.freeze([
Object.freeze({
id: "fill",
type: "directional",
- color: "#7da4c7",
- colorHex: 0x7da4c7,
- hex: "#7da4c7",
- intensity: 0.5,
+ color: "#ffffff",
+ colorHex: 0xffffff,
+ hex: "#ffffff",
+ intensity: 0.3,
position: Object.freeze([-5, 2, -5])
}),
Object.freeze({
id: "rim",
type: "directional",
- color: "#ffeedd",
- colorHex: 0xffeedd,
- hex: "#ffeedd",
- intensity: 0.4,
+ color: "#ffffff",
+ colorHex: 0xffffff,
+ hex: "#ffffff",
+ intensity: 0.2,
position: Object.freeze([0, -8, 0])
})
])
})
});
-export const DEFAULT_LIGHTING_PRESET = "trailer-golden-hour";
+export const DEFAULT_LIGHTING_PRESET = "studio-neutral";
+
+export const DEFAULT_TONE_MAPPING = THREE_DEFAULT.ACESFilmicToneMapping;
+export const DEFAULT_TONE_MAPPING_EXPOSURE = 1.0;
+
+/**
+ * Configures ACES Filmic or AgX tone mapping and exposure on a Three.js WebGLRenderer.
+ *
+ * @param {object} renderer - WebGLRenderer instance
+ * @param {object} [options={}] - Options (toneMapping, exposure, THREE)
+ * @returns {object} The configured renderer
+ */
+export function configureToneMapping(renderer, options = {}) {
+ if (!renderer) {
+ throw new TypeError("configureToneMapping requires a valid WebGLRenderer instance");
+ }
+ const THREE = options?.THREE || (typeof globalThis !== "undefined" && globalThis.THREE) || THREE_DEFAULT;
+ const toneMapping = options.toneMapping ?? THREE.ACESFilmicToneMapping;
+ const exposure = typeof options.exposure === "number" ? options.exposure : (options.toneMappingExposure ?? DEFAULT_TONE_MAPPING_EXPOSURE);
+
+ renderer.toneMapping = toneMapping;
+ renderer.toneMappingExposure = exposure;
+ return renderer;
+}
/**
* Internal registry lookup map including aliases.
@@ -496,8 +519,8 @@ export class LightingRig {
this.group = new THREE.Group();
this.group.name = options.name || "LightingRig";
- // Primary directional sunlight
- this.sunLight = new THREE.DirectionalLight(0xffb366, 1.8);
+ // Primary directional sunlight / key light
+ this.sunLight = new THREE.DirectionalLight(0xffffff, 1.0);
this.sunLight.name = "SunLight";
this.sunTarget = new THREE.Object3D();
@@ -516,15 +539,15 @@ export class LightingRig {
this.sunLight.shadow.bias = options.shadowBias || -0.0005;
// Ambient light
- this.ambientLight = new THREE.AmbientLight(0x8ca0ba, 0.6);
+ this.ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
this.ambientLight.name = "AmbientLight";
// Secondary lights (used in 3-point lighting presets like Studio Neutral)
- this.fillLight = new THREE.DirectionalLight(0x7da4c7, 0.0);
+ this.fillLight = new THREE.DirectionalLight(0xffffff, 0.0);
this.fillLight.name = "FillLight";
this.fillLight.visible = false;
- this.rimLight = new THREE.DirectionalLight(0xffeedd, 0.0);
+ this.rimLight = new THREE.DirectionalLight(0xffffff, 0.0);
this.rimLight.name = "RimLight";
this.rimLight.visible = false;
@@ -538,14 +561,18 @@ export class LightingRig {
this.scene.add(this.group);
}
- this.azimuth = 55;
- this.elevation = 28;
- this.distance = 50;
+ this.azimuth = 35.54;
+ this.elevation = 49.3;
+ this.distance = 13.19;
this.currentPresetId = null;
this.currentPreset = null;
const initialPreset = options.preset || DEFAULT_LIGHTING_PRESET;
this.setPreset(initialPreset);
+
+ if (options.renderer) {
+ configureToneMapping(options.renderer, options);
+ }
}
/**
@@ -798,6 +825,10 @@ export function applyLightingPreset(target, presetIdOrConfig, options = {}) {
throw new TypeError("applyLightingPreset requires a valid target (LightingRig, THREE.Scene, or lighting object)");
}
+ if (options.renderer) {
+ configureToneMapping(options.renderer, options);
+ }
+
if (target instanceof LightingRig || typeof target.setPreset === "function") {
return target.setPreset(presetIdOrConfig);
}
diff --git a/tools/test-e2e.mjs b/tools/test-e2e.mjs
index 0be2d61..d39804f 100644
--- a/tools/test-e2e.mjs
+++ b/tools/test-e2e.mjs
@@ -51,6 +51,15 @@ import {
invalidateTextureCache
} from "../src/modules/live-sync.js";
+import {
+ LIGHTING_PRESETS,
+ DEFAULT_LIGHTING_PRESET,
+ DEFAULT_TONE_MAPPING,
+ DEFAULT_TONE_MAPPING_EXPOSURE,
+ configureToneMapping,
+ createLightingRig
+} from "../src/modules/lighting-presets.js";
+
/**
* Deterministic Mock EventSource for client-side SSE verification in Node.js.
*/
@@ -126,7 +135,8 @@ const verificationResults = {
req1_grassBiomeTintMatch: false,
req2_labPbrColorSpaceIntegrity: false,
req3_crossQuadMultiblockCulling: false,
- req4_liveSyncTouchReload: false
+ req4_liveSyncTouchReload: false,
+ req5_studioInspectionLightingToneMapping: false
};
const EPSILON = 1e-4;
@@ -787,6 +797,85 @@ test("Requirement 4: Live file reload triggers seamlessly on SVG touch", () => {
verificationResults.req4_liveSyncTouchReload = true;
});
+// ============================================================================
+// REQUIREMENT 5: Studio Inspection Lighting Calibration & ACES Tone Mapping
+// ============================================================================
+test("Requirement 5: Studio inspection lighting and ACES tone mapping prevent color blowout", async () => {
+ // 1. Default preset must be studio-neutral
+ assert.equal(
+ DEFAULT_LIGHTING_PRESET,
+ "studio-neutral",
+ "Default lighting preset must be studio-neutral for accurate inspection"
+ );
+
+ // 2. Studio Neutral preset inspection calibration
+ const studioPreset = LIGHTING_PRESETS["studio-neutral"];
+ assert.ok(studioPreset, "studio-neutral preset must exist");
+ assert.equal(studioPreset.sun.hex, "#ffffff", "Studio Neutral key light must be pure neutral white");
+ assert.equal(studioPreset.ambient.hex, "#ffffff", "Studio Neutral ambient light must be pure neutral white");
+ assert.equal(studioPreset.fill.hex, "#ffffff", "Studio Neutral fill light must be pure neutral white");
+ assert.equal(studioPreset.rim.hex, "#ffffff", "Studio Neutral rim light must be pure neutral white");
+
+ // Verify balanced intensities across 3-point inspection rig (combined illumination <= 1.5)
+ assertClose(studioPreset.sun.intensity, 1.0, "Key light intensity must be 1.0");
+ assertClose(studioPreset.ambient.intensity, 0.4, "Ambient intensity must be 0.4");
+ assertClose(studioPreset.fill.intensity, 0.3, "Fill intensity must be 0.3");
+ assertClose(studioPreset.rim.intensity, 0.2, "Rim intensity must be 0.2");
+ const studioTotal = studioPreset.sun.intensity + studioPreset.ambient.intensity;
+ assert.ok(
+ studioTotal <= 1.5,
+ `Studio Neutral direct + ambient (${studioTotal}) must prevent highlight blowout (<= 1.5)`
+ );
+
+ // 3. Trailer Golden Hour calibrated intensities (softened to prevent color burnout)
+ const goldenPreset = LIGHTING_PRESETS["trailer-golden-hour"];
+ assert.ok(goldenPreset, "trailer-golden-hour preset must exist");
+ assertClose(goldenPreset.sun.intensity, 1.2, "Golden Hour sun intensity must be calibrated to 1.2");
+ assertClose(goldenPreset.ambient.intensity, 0.4, "Golden Hour ambient intensity must be calibrated to 0.4");
+ const goldenTotal = goldenPreset.sun.intensity + goldenPreset.ambient.intensity;
+ assert.ok(
+ goldenTotal <= 1.7,
+ `Trailer Golden Hour combined illumination (${goldenTotal}) must not exceed 1.7 (softened from 2.4)`
+ );
+
+ // 4. Tone mapping and exposure configuration on renderer
+ const mockRenderer = {
+ toneMapping: THREE.NoToneMapping,
+ toneMappingExposure: 0.5
+ };
+ configureToneMapping(mockRenderer);
+ assert.equal(
+ mockRenderer.toneMapping,
+ THREE.ACESFilmicToneMapping,
+ "Renderer must be configured with ACESFilmicToneMapping"
+ );
+ assertClose(
+ mockRenderer.toneMappingExposure,
+ 1.0,
+ "Renderer toneMappingExposure must be set to 1.0"
+ );
+
+ // 5. LightingRig initialization defaults to studio-neutral and auto-configures renderer
+ const scene = new THREE.Scene();
+ const rigRenderer = {
+ toneMapping: THREE.NoToneMapping,
+ toneMappingExposure: 0.5
+ };
+ const rig = createLightingRig(scene, { renderer: rigRenderer });
+ assert.equal(rig.currentPresetId, "studio-neutral", "LightingRig must default to studio-neutral");
+ assert.equal(rigRenderer.toneMapping, THREE.ACESFilmicToneMapping, "Rig must configure tone mapping on renderer");
+ assertClose(rigRenderer.toneMappingExposure, 1.0, "Rig must configure exposure 1.0 on renderer");
+
+ // Verify pure neutral light colors applied to scene
+ assert.equal(rig.sunLight.color.getHexString(), "ffffff");
+ assert.equal(rig.ambientLight.color.getHexString(), "ffffff");
+ assert.equal(rig.fillLight.color.getHexString(), "ffffff");
+ assert.equal(rig.rimLight.color.getHexString(), "ffffff");
+ rig.dispose();
+
+ verificationResults.req5_studioInspectionLightingToneMapping = true;
+});
+
// ============================================================================
// SUITE SUMMARY REPORTING
// ============================================================================
@@ -795,12 +884,14 @@ after(() => {
const req2Status = verificationResults.req2_labPbrColorSpaceIntegrity ? "PASSED" : "FAILED";
const req3Status = verificationResults.req3_crossQuadMultiblockCulling ? "PASSED" : "FAILED";
const req4Status = verificationResults.req4_liveSyncTouchReload ? "PASSED" : "FAILED";
+ const req5Status = verificationResults.req5_studioInspectionLightingToneMapping ? "PASSED" : "FAILED";
const allPassed =
verificationResults.req1_grassBiomeTintMatch &&
verificationResults.req2_labPbrColorSpaceIntegrity &&
verificationResults.req3_crossQuadMultiblockCulling &&
- verificationResults.req4_liveSyncTouchReload;
+ verificationResults.req4_liveSyncTouchReload &&
+ verificationResults.req5_studioInspectionLightingToneMapping;
console.log("\n================================================================================");
console.log(" TEXTURESTUDIO END-TO-END VISUAL VERIFICATION SUITE ");
@@ -809,6 +900,7 @@ after(() => {
console.log(` [REQ 2] LabPBR Normals, Specular, AO & Color Space Integrity : ${req2Status}`);
console.log(` [REQ 3] Cross-Quad Plant Geometry & Multiblock Culling (No Z-Fight): ${req3Status}`);
console.log(` [REQ 4] Live Sync SSE Invalidation & Cache-Bust Dispatch : ${req4Status}`);
+ console.log(` [REQ 5] Studio Inspection Lighting Calibration & ACES Tone Mapping : ${req5Status}`);
console.log("================================================================================");
if (allPassed) {
console.log(" OVERALL VERIFICATION VERDICT: ALL E2E REQUIREMENTS VERIFIED SUCCESSFULLY ");
diff --git a/tools/test/lighting-presets.test.mjs b/tools/test/lighting-presets.test.mjs
index ce94432..e36e3cc 100644
--- a/tools/test/lighting-presets.test.mjs
+++ b/tools/test/lighting-presets.test.mjs
@@ -4,6 +4,9 @@ import * as THREE from "three";
import {
LIGHTING_PRESETS,
DEFAULT_LIGHTING_PRESET,
+ DEFAULT_TONE_MAPPING,
+ DEFAULT_TONE_MAPPING_EXPOSURE,
+ configureToneMapping,
LightingRig,
createLightingRig,
applyLightingPreset,
@@ -33,26 +36,26 @@ test("LIGHTING_PRESETS registry exposes canonical presets and default ID", () =>
assert.ok(LIGHTING_PRESETS["trailer-golden-hour"], "trailer-golden-hour must exist");
assert.ok(LIGHTING_PRESETS["noon-clear"], "noon-clear must exist");
assert.ok(LIGHTING_PRESETS["studio-neutral"], "studio-neutral must exist");
- assert.equal(DEFAULT_LIGHTING_PRESET, "trailer-golden-hour");
+ assert.equal(DEFAULT_LIGHTING_PRESET, "studio-neutral");
});
-test("Trailer Golden Hour preset defines authentic cinematic trailer lighting", () => {
+test("Trailer Golden Hour preset defines authentic cinematic trailer lighting with calibrated intensities", () => {
const preset = LIGHTING_PRESETS["trailer-golden-hour"];
assert.equal(preset.id, "trailer-golden-hour");
assert.equal(preset.name, "Trailer Golden Hour");
- // Warm directional amber sunlight (#ffb366, intensity 1.8, elevation ~25-30, azimuth ~45-60)
+ // Calibrated warm directional amber sunlight (#ffb366, intensity 1.2, elevation ~25-30, azimuth ~45-60)
assert.equal(preset.sun.hex, "#ffb366");
assert.equal(preset.sun.colorHex, 0xffb366);
- assert.equal(preset.sun.intensity, 1.8);
+ assert.equal(preset.sun.intensity, 1.2);
assert.ok(preset.sun.elevation >= 25 && preset.sun.elevation <= 30, "elevation should be ~25-30 deg");
assert.ok(preset.sun.azimuth >= 45 && preset.sun.azimuth <= 60, "azimuth should be ~45-60 deg");
assert.equal(preset.sun.castShadow, true);
- // Cool blue sky ambient fill (#8ca0ba, intensity 0.6)
+ // Calibrated cool blue sky ambient fill (#8ca0ba, intensity 0.4)
assert.equal(preset.ambient.hex, "#8ca0ba");
assert.equal(preset.ambient.colorHex, 0x8ca0ba);
- assert.equal(preset.ambient.intensity, 0.6);
+ assert.equal(preset.ambient.intensity, 0.4);
});
test("Noon Clear preset defines neutral sunlight and balanced ambient fill", () => {
@@ -73,34 +76,44 @@ test("Noon Clear preset defines neutral sunlight and balanced ambient fill", ()
assert.equal(preset.ambient.intensity, 0.8);
});
-test("Studio Neutral preset defines multi-angle diffuse 3-point lighting setup", () => {
+test("Studio Neutral preset defines multi-angle diffuse 3-point lighting setup for true neutral inspection", () => {
const preset = LIGHTING_PRESETS["studio-neutral"];
assert.equal(preset.id, "studio-neutral");
assert.equal(preset.name, "Studio Neutral");
- // Key light: directional light (warm white #fffaf0, intensity ~1.2-1.5, high front-right)
- assert.ok(preset.sun.intensity >= 1.2 && preset.sun.intensity <= 1.5);
+ // Key light: directional light (neutral white #ffffff, intensity 1.0, high front-right)
+ assert.equal(preset.sun.hex, "#ffffff");
+ assert.equal(preset.sun.colorHex, 0xffffff);
+ assertNear(preset.sun.intensity, 1.0);
assert.deepEqual(preset.sun.position, [5, 10, 7]);
- assert.equal(preset.sun.hex, "#fffaf0");
- // Ambient fill: ambient light (#ffffff, intensity ~0.4-0.6)
+ // Ambient fill: neutral white (#ffffff, intensity 0.4)
assert.equal(preset.ambient.hex, "#ffffff");
- assert.ok(preset.ambient.intensity >= 0.4 && preset.ambient.intensity <= 0.6);
+ assert.equal(preset.ambient.colorHex, 0xffffff);
+ assertNear(preset.ambient.intensity, 0.4);
- // Fill light: front-left (#7da4c7, intensity ~0.4-0.6)
+ // Fill light: front-left (#ffffff, intensity 0.3)
assert.ok(preset.fill, "fill light must be defined");
- assert.ok(preset.fill.intensity >= 0.4 && preset.fill.intensity <= 0.6);
+ assert.equal(preset.fill.hex, "#ffffff");
+ assert.equal(preset.fill.colorHex, 0xffffff);
+ assertNear(preset.fill.intensity, 0.3);
assert.deepEqual(preset.fill.position, [-5, 2, -5]);
- // Rim/back light or bottom bounce: directional light back/bottom (#ffeedd, intensity ~0.3-0.5)
+ // Rim/back light or bottom bounce: directional light back/bottom (#ffffff, intensity 0.2)
assert.ok(preset.rim, "rim light must be defined");
- assert.ok(preset.rim.intensity >= 0.3 && preset.rim.intensity <= 0.5);
+ assert.equal(preset.rim.hex, "#ffffff");
+ assert.equal(preset.rim.colorHex, 0xffffff);
+ assertNear(preset.rim.intensity, 0.2);
assert.deepEqual(preset.rim.position, [0, -8, 0]);
// extraLights registry contains fill and rim lights
assert.equal(preset.extraLights.length, 2);
assert.equal(preset.extraLights[0].id, "fill");
+ assert.equal(preset.extraLights[0].hex, "#ffffff");
+ assertNear(preset.extraLights[0].intensity, 0.3);
assert.equal(preset.extraLights[1].id, "rim");
+ assert.equal(preset.extraLights[1].hex, "#ffffff");
+ assertNear(preset.extraLights[1].intensity, 0.2);
});
test("getLightingPreset supports case-insensitive, whitespace-tolerant and alias lookup", () => {
@@ -354,7 +367,7 @@ test("setSunAngle updates DirectionalLight position and target cleanly", () => {
// 4. LightingRig Class & Factory API Tests
// -----------------------------------------------------------------------------
-test("createLightingRig instantiates rig with default Golden Hour preset and attaches to scene", () => {
+test("createLightingRig instantiates rig with default Studio Neutral preset and attaches to scene", () => {
const scene = new THREE.Scene();
const rig = createLightingRig(scene);
@@ -362,12 +375,12 @@ test("createLightingRig instantiates rig with default Golden Hour preset and att
assert.strictEqual(rig.scene, scene);
assert.ok(scene.children.includes(rig.group), "Rig group must be added to scene");
- // Default preset is Trailer Golden Hour
- assert.equal(rig.currentPresetId, "trailer-golden-hour");
- assert.equal(rig.sunLight.color.getHexString(), "ffb366");
- assertNear(rig.sunLight.intensity, 1.8);
- assert.equal(rig.ambientLight.color.getHexString(), "8ca0ba");
- assertNear(rig.ambientLight.intensity, 0.6);
+ // Default preset is Studio Neutral for accurate surface inspection
+ assert.equal(rig.currentPresetId, "studio-neutral");
+ assert.equal(rig.sunLight.color.getHexString(), "ffffff");
+ assertNear(rig.sunLight.intensity, 1.0);
+ assert.equal(rig.ambientLight.color.getHexString(), "ffffff");
+ assertNear(rig.ambientLight.intensity, 0.4);
// Directional sun properties
assert.equal(rig.sunLight.castShadow, true);
@@ -375,9 +388,13 @@ test("createLightingRig instantiates rig with default Golden Hour preset and att
assert.equal(rig.sunLight.shadow.mapSize.height, 2048);
assert.strictEqual(rig.keyLight, rig.sunLight, "keyLight should alias sunLight");
- // Secondary lights should be hidden in 2-point Golden Hour
- assert.equal(rig.fillLight.visible, false);
- assert.equal(rig.rimLight.visible, false);
+ // Secondary lights should be active in 3-point Studio Neutral
+ assert.equal(rig.fillLight.visible, true);
+ assert.equal(rig.fillLight.color.getHexString(), "ffffff");
+ assertNear(rig.fillLight.intensity, 0.3);
+ assert.equal(rig.rimLight.visible, true);
+ assert.equal(rig.rimLight.color.getHexString(), "ffffff");
+ assertNear(rig.rimLight.intensity, 0.2);
rig.dispose();
assert.ok(!scene.children.includes(rig.group), "Rig group should be removed on dispose");
@@ -412,25 +429,25 @@ test("LightingRig switches presets dynamically without light duplication or memo
rig.setPreset("studio-neutral");
assert.equal(rig.currentPresetId, "studio-neutral");
- // Key light: #fffaf0, intensity 1.5, position [5, 10, 7]
- assert.equal(rig.sunLight.color.getHexString(), "fffaf0");
- assertNear(rig.sunLight.intensity, 1.5);
+ // Key light: neutral white #ffffff, intensity 1.0, position [5, 10, 7]
+ assert.equal(rig.sunLight.color.getHexString(), "ffffff");
+ assertNear(rig.sunLight.intensity, 1.0);
assert.deepEqual([rig.sunLight.position.x, rig.sunLight.position.y, rig.sunLight.position.z], [5, 10, 7]);
- // Ambient: #ffffff, intensity 0.5
+ // Ambient: #ffffff, intensity 0.4
assert.equal(rig.ambientLight.color.getHexString(), "ffffff");
- assertNear(rig.ambientLight.intensity, 0.5);
+ assertNear(rig.ambientLight.intensity, 0.4);
- // Fill light: active, #7da4c7, intensity 0.5, position [-5, 2, -5]
+ // Fill light: active, #ffffff, intensity 0.3, position [-5, 2, -5]
assert.equal(rig.fillLight.visible, true);
- assert.equal(rig.fillLight.color.getHexString(), "7da4c7");
- assertNear(rig.fillLight.intensity, 0.5);
+ assert.equal(rig.fillLight.color.getHexString(), "ffffff");
+ assertNear(rig.fillLight.intensity, 0.3);
assert.deepEqual([rig.fillLight.position.x, rig.fillLight.position.y, rig.fillLight.position.z], [-5, 2, -5]);
- // Rim light: active, #ffeedd, intensity 0.4, position [0, -8, 0]
+ // Rim light: active, #ffffff, intensity 0.2, position [0, -8, 0]
assert.equal(rig.rimLight.visible, true);
- assert.equal(rig.rimLight.color.getHexString(), "ffeedd");
- assertNear(rig.rimLight.intensity, 0.4);
+ assert.equal(rig.rimLight.color.getHexString(), "ffffff");
+ assertNear(rig.rimLight.intensity, 0.2);
assert.deepEqual([rig.rimLight.position.x, rig.rimLight.position.y, rig.rimLight.position.z], [0, -8, 0]);
// 2. Switch to Noon Clear
@@ -454,9 +471,9 @@ test("LightingRig switches presets dynamically without light duplication or memo
rig.setPreset("trailer-golden-hour");
assert.equal(rig.currentPresetId, "trailer-golden-hour");
assert.equal(rig.sunLight.color.getHexString(), "ffb366");
- assertNear(rig.sunLight.intensity, 1.8);
+ assertNear(rig.sunLight.intensity, 1.2);
assert.equal(rig.ambientLight.color.getHexString(), "8ca0ba");
- assertNear(rig.ambientLight.intensity, 0.6);
+ assertNear(rig.ambientLight.intensity, 0.4);
assert.equal(rig.fillLight.visible, false);
assert.equal(rig.rimLight.visible, false);
@@ -522,7 +539,7 @@ test("applyLightingPreset configures scene, LightingRig, and light dictionaries"
};
applyLightingPreset(customLights, "studio-neutral");
- assert.equal(customLights.sunLight.color.getHexString(), "fffaf0");
+ assert.equal(customLights.sunLight.color.getHexString(), "ffffff");
assert.equal(customLights.ambientLight.color.getHexString(), "ffffff");
assert.equal(customLights.fillLight.visible, true);
assert.equal(customLights.rimLight.visible, true);
@@ -536,3 +553,69 @@ test("applyLightingPreset configures scene, LightingRig, and light dictionaries"
rig.dispose();
});
+
+// -----------------------------------------------------------------------------
+// 6. Tone Mapping & Calibrated Color Inspection Tests (Issue #49)
+// -----------------------------------------------------------------------------
+
+test("configureToneMapping sets ACESFilmicToneMapping and exposure 1.0 on renderer", () => {
+ const mockRenderer = {
+ toneMapping: THREE.NoToneMapping,
+ toneMappingExposure: 0.5
+ };
+
+ const configured = configureToneMapping(mockRenderer);
+ assert.strictEqual(configured, mockRenderer);
+ assert.equal(mockRenderer.toneMapping, THREE.ACESFilmicToneMapping);
+ assert.equal(mockRenderer.toneMappingExposure, 1.0);
+ assert.equal(DEFAULT_TONE_MAPPING, THREE.ACESFilmicToneMapping);
+ assert.equal(DEFAULT_TONE_MAPPING_EXPOSURE, 1.0);
+});
+
+test("configureToneMapping supports custom tone mapping and exposure options", () => {
+ const mockRenderer = {
+ toneMapping: THREE.NoToneMapping,
+ toneMappingExposure: 1.0
+ };
+
+ // AgX tone mapping test if available, or custom tone mapping enum
+ const targetToneMapping = THREE.AgXToneMapping ?? THREE.ACESFilmicToneMapping;
+ configureToneMapping(mockRenderer, {
+ toneMapping: targetToneMapping,
+ exposure: 1.25
+ });
+
+ assert.equal(mockRenderer.toneMapping, targetToneMapping);
+ assert.equal(mockRenderer.toneMappingExposure, 1.25);
+});
+
+test("LightingRig accepts renderer option and automatically configures tone mapping", () => {
+ const scene = new THREE.Scene();
+ const mockRenderer = {
+ toneMapping: THREE.NoToneMapping,
+ toneMappingExposure: 0.5
+ };
+
+ const rig = createLightingRig(scene, { renderer: mockRenderer });
+ assert.equal(mockRenderer.toneMapping, THREE.ACESFilmicToneMapping);
+ assert.equal(mockRenderer.toneMappingExposure, 1.0);
+ rig.dispose();
+});
+
+test("Calibrated light intensities prevent highlight blowout and preserve texture hue", () => {
+ // 1. Studio Neutral calibration: Key 1.0 + Ambient 0.4 = 1.4 combined illumination
+ const studioPreset = LIGHTING_PRESETS["studio-neutral"];
+ const studioTotal = studioPreset.sun.intensity + studioPreset.ambient.intensity;
+ assert.ok(studioTotal <= 1.5, `Studio Neutral combined illumination (${studioTotal}) should not exceed 1.5`);
+ assert.equal(studioPreset.sun.hex, "#ffffff", "Studio Neutral key light must be pure white for inspection");
+ assert.equal(studioPreset.ambient.hex, "#ffffff", "Studio Neutral ambient light must be pure white for inspection");
+ assert.equal(studioPreset.fill.hex, "#ffffff", "Studio Neutral fill light must be pure white to prevent blue hue shift");
+ assert.equal(studioPreset.rim.hex, "#ffffff", "Studio Neutral rim light must be pure white to prevent warm hue shift");
+
+ // 2. Trailer Golden Hour calibration: Sun 1.2 + Ambient 0.4 = 1.6 combined illumination (softened from 2.4)
+ const goldenPreset = LIGHTING_PRESETS["trailer-golden-hour"];
+ const goldenTotal = goldenPreset.sun.intensity + goldenPreset.ambient.intensity;
+ assert.ok(goldenTotal <= 1.7, `Trailer Golden Hour combined illumination (${goldenTotal}) should not exceed 1.7`);
+ assert.ok(goldenPreset.sun.intensity <= 1.3, "Sunlight intensity must be softened to prevent specular highlight burnout");
+ assert.ok(goldenPreset.ambient.intensity <= 0.5, "Ambient intensity must be softened to preserve shadow contrast");
+});