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"); +});