OMRVisualFoundation.Tonemapping.cs
using Sandbox;

/// <summary>
/// Phase 8F3F4: native S&box tonemapping A/B controller for the gameplay world.
///
/// Tonemapping is deliberately kept as the engine-native post-process. It runs
/// at Stage.Tonemapping, after OMR's current world Bloom / Adaptive Pixelation /
/// Edge Cues chain. Auto exposure is OFF by default so comparisons between
/// curves are stable and reproducible.
///
/// This first pass affects the gameplay/world camera only. OMR's independent
/// viewmodel capture/composite remains untouched so we can evaluate the world
/// curve in isolation before deciding whether presentation layers should share
/// a matching final transform.
/// </summary>
public sealed partial class OMRVisualFoundation
{
    [Property, Group( "World Look/Tonemapping" ), Title( "Enable Tonemapping" )]
    [Description( "Native HDR-to-display tonemapping for the gameplay world. Keep Auto Exposure off while comparing operators." )]
    public bool EnableTonemapping { get; set; } = false;

    [Property, Group( "World Look/Tonemapping" ), Title( "Mode" )]
    public Sandbox.Tonemapping.TonemappingMode TonemappingMode { get; set; } =
        Sandbox.Tonemapping.TonemappingMode.HableFilmic;

    [Property, Group( "World Look/Tonemapping" ), Title( "Exposure Method" )]
    [Description( "Primarily relevant to Hable Filmic. RGB is S&box's default." )]
    public Sandbox.Tonemapping.ExposureColorSpaceEnum TonemappingExposureMethod { get; set; } =
        Sandbox.Tonemapping.ExposureColorSpaceEnum.RGB;

    [Property, Group( "World Look/Tonemapping/Auto Exposure" ), Title( "Enable Auto Exposure" )]
    [Description( "OFF is recommended for visual A/B testing. When enabled, the camera adapts exposure over time and can obscure differences between tonemapping curves." )]
    public bool TonemappingAutoExposure { get; set; } = false;

    [Property, Group( "World Look/Tonemapping/Auto Exposure" ), Range( 0f, 3f ), Title( "Minimum Exposure" )]
    public float TonemappingMinimumExposure { get; set; } = 1f;

    [Property, Group( "World Look/Tonemapping/Auto Exposure" ), Range( 0f, 5f ), Title( "Maximum Exposure" )]
    public float TonemappingMaximumExposure { get; set; } = 3f;

    [Property, Group( "World Look/Tonemapping/Auto Exposure" ), Range( -5f, 5f ), Title( "Exposure Compensation" )]
    public float TonemappingExposureCompensation { get; set; } = 0f;

    [Property, Group( "World Look/Tonemapping/Auto Exposure" ), Range( 1f, 10f ), Title( "Adaptation Rate" )]
    public float TonemappingRate { get; set; } = 1f;

    [Button, Group( "World Look/Tonemapping/A-B" ), Title( "Hable Filmic" )]
    public void UseHableTonemapping()
    {
        EnableTonemapping = true;
        TonemappingMode = Sandbox.Tonemapping.TonemappingMode.HableFilmic;
        RebuildTonemapping();
    }

    [Button, Group( "World Look/Tonemapping/A-B" ), Title( "AgX" )]
    public void UseAgXTonemapping()
    {
        EnableTonemapping = true;
        TonemappingMode = Sandbox.Tonemapping.TonemappingMode.AgX;
        RebuildTonemapping();
    }

    [Button, Group( "World Look/Tonemapping/A-B" ), Title( "Reinhard-Jodie" )]
    public void UseReinhardJodieTonemapping()
    {
        EnableTonemapping = true;
        TonemappingMode = Sandbox.Tonemapping.TonemappingMode.ReinhardJodie;
        RebuildTonemapping();
    }

    [Button, Group( "World Look/Tonemapping/A-B" ), Title( "ACES" )]
    public void UseACESTonemapping()
    {
        EnableTonemapping = true;
        TonemappingMode = Sandbox.Tonemapping.TonemappingMode.ACES;
        RebuildTonemapping();
    }

    [Button, Group( "World Look/Tonemapping/A-B" ), Title( "Linear Reference" )]
    public void UseLinearTonemapping()
    {
        EnableTonemapping = true;
        TonemappingMode = Sandbox.Tonemapping.TonemappingMode.Linear;
        RebuildTonemapping();
    }

    [Button, Group( "World Look/Tonemapping/A-B" ), Title( "Disable Tonemapping" )]
    public void DisableTonemapping()
    {
        EnableTonemapping = false;
        RebuildTonemapping();
    }

    [Button, Group( "World Look/Tonemapping" ), Title( "Setup Tonemapping Preview" )]
    [Description( "Adds/reuses Sandbox.Tonemapping on the gameplay camera for editor preview and runtime world tonemapping." )]
    public void SetupTonemappingPreview()
    {
        CameraComponent camera = GetComponent<CameraComponent>( true );
        if ( camera is null || !camera.IsValid )
        {
            Log.Warning(
                "[OMR VISUAL] Tonemapping preview setup failed: OMRVisualFoundation must share a GameObject with the gameplay CameraComponent."
            );
            return;
        }

        ResolveTonemappingComponent();

        if ( _tonemapping is null || !_tonemapping.IsValid )
        {
            _tonemapping = GameObject.AddComponent<Sandbox.Tonemapping>( false );
            _tonemappingCreatedByFoundation = false;
        }

        ApplyTonemappingSettings();

        Log.Info(
            $"[OMR VISUAL] Native Tonemapping ready on '{GameObject.Name}' | " +
            $"Enabled:{_tonemapping.Enabled} | Mode:{_tonemapping.Mode} | " +
            $"AutoExposure:{_tonemapping.AutoExposureEnabled}"
        );
    }

    private Sandbox.Tonemapping _tonemapping;
    private bool _tonemappingCreatedByFoundation;
    private bool _tonemappingReadyLogged;

    private void ResetTonemappingSettings()
    {
        EnableTonemapping = false;
        TonemappingMode = Sandbox.Tonemapping.TonemappingMode.HableFilmic;
        TonemappingExposureMethod = Sandbox.Tonemapping.ExposureColorSpaceEnum.RGB;
        TonemappingMinimumExposure = 1f;
        TonemappingMaximumExposure = 3f;
        TonemappingExposureCompensation = 0f;
        TonemappingRate = 1f;
    }

    private void ResolveTonemappingComponent()
    {
        if ( _tonemapping is not null && _tonemapping.IsValid )
            return;

        _tonemapping = GetComponent<Sandbox.Tonemapping>( true );
        _tonemappingCreatedByFoundation = false;
    }

    private void RebuildTonemapping()
    {
        CameraComponent camera = GetComponent<CameraComponent>( true );
        if ( camera is null || !camera.IsValid )
            return;

        ResolveTonemappingComponent();

        // Do not silently author a component in edit mode. The phase patch adds
        // one to main.scene and the explicit Setup button remains available for
        // other scenes/cameras.
        if ( !Game.IsPlaying )
        {
            if ( _tonemapping is not null && _tonemapping.IsValid )
                ApplyTonemappingSettings();

            return;
        }

        if (
            EnableTonemapping &&
            (_tonemapping is null || !_tonemapping.IsValid)
        )
        {
            _tonemapping = GameObject.AddComponent<Sandbox.Tonemapping>( false );
            _tonemappingCreatedByFoundation = true;
        }

        if ( _tonemapping is null || !_tonemapping.IsValid )
            return;

        ApplyTonemappingSettings();

        if ( _tonemapping.Enabled && LogSetup && !_tonemappingReadyLogged )
        {
            _tonemappingReadyLogged = true;
            Log.Info(
                $"[OMR VISUAL] Native world Tonemapping active on '{GameObject.Name}' | " +
                $"Mode:{_tonemapping.Mode} | AutoExposure:{_tonemapping.AutoExposureEnabled} | " +
                $"CameraPostProcessing:{camera.EnablePostProcessing}"
            );
        }

        if ( !_tonemapping.Enabled )
            _tonemappingReadyLogged = false;
    }

    private void ApplyTonemappingSettings()
    {
        if ( _tonemapping is null || !_tonemapping.IsValid )
            return;

        _tonemapping.Mode = TonemappingMode;
        _tonemapping.ExposureMethod = TonemappingExposureMethod;
        _tonemapping.AutoExposureEnabled = TonemappingAutoExposure;

        float minimum = MathX.Clamp( TonemappingMinimumExposure, 0f, 3f );
        float maximum = MathX.Clamp( TonemappingMaximumExposure, 0f, 5f );
        if ( maximum < minimum )
            maximum = minimum;

        _tonemapping.MinimumExposure = minimum;
        _tonemapping.MaximumExposure = maximum;
        _tonemapping.ExposureCompensation = MathX.Clamp( TonemappingExposureCompensation, -5f, 5f );
        _tonemapping.Rate = MathX.Clamp( TonemappingRate, 1f, 10f );
        _tonemapping.Enabled = EnableWorldLook && EnableTonemapping;
    }

    private void ReleaseTonemapping()
    {
        if ( _tonemapping is not null && _tonemapping.IsValid )
        {
            _tonemapping.Enabled = false;

            if ( _tonemappingCreatedByFoundation )
                _tonemapping.Destroy();
        }

        _tonemapping = null;
        _tonemappingCreatedByFoundation = false;
        _tonemappingReadyLogged = false;
    }
}