Editor/Prism/Preview/PreviewSettings.cs

PreviewSettings is an editor-side settings wrapper for the shader preview system. It exposes properties that either read/write the document-backed PreviewState (undoable, saved in the graph) or editor-wide preferences stored in cookies, provides Load/Save for preferences, ApplyTo to push settings into a PreviewViewport, and binding to a PrismGraph/GraphMutations for undoable edits.

File Access
using Editor.Prism.Core;
using Editor.Prism.Model;
using Editor.Prism.Undo;

namespace Editor.Prism.Preview;

/// <summary>
/// The preview's settings surface: one bindable object the settings popovers build a
/// <c>ControlSheet</c> over, backed by two very different stores.
/// <para>
/// Anything that describes <em>this shader</em> — the mesh, the environment, the tint, the background,
/// the debug channel, the camera — lives in the document's <see cref="PreviewState"/> and is written
/// through <see cref="GraphMutations.UpdatePreview"/>, so it is undoable and it is saved with the
/// graph. Reopening a document restores exactly the framing its author left it in.
/// </para>
/// <para>
/// Anything that describes <em>how this user likes to look at shaders</em> — shadows, fill lights,
/// tonemapping, auto-rotation, thumbnail rendering — is an editor preference stored in cookies and
/// shared by every document, because nobody wants their lighting rig to change when they open someone
/// else's graph.
/// </para>
/// <para>
/// Getters read straight through to the live state, so nothing can drift out of sync, and setters are
/// inert while <see cref="Load"/> is applying, so restoring a document never writes an undo entry.
/// </para>
/// </summary>
public sealed class PreviewSettings
{
	/// <summary>Cookie prefix for the editor-wide half of these settings.</summary>
	public const string CookiePrefix = "prism.preview.";

	readonly PreviewState _detached = new();

	bool _suspend;

	bool _enableShadows = true;
	bool _enableFillLights = true;
	bool _enableTonemapping = true;
	bool _renderBackfaces;
	bool _autoRotate;
	bool _nodeThumbnails;
	bool _nodePreview;
	Angles _sunAngle = new( 48f, 40f, 0f );
	Color _sunColor = new( 0.98f, 0.97f, 0.94f );
	float _sunBrightness = 2.2f;
	float _ambientStrength = 0.08f;
	float _rotateSpeed = 24f;
	float _fieldOfView = 45f;

	/// <summary>Create an unbound settings object backed by a detached preview state.</summary>
	public PreviewSettings()
	{
		Load();
	}

	/// <summary>The document these settings edit, or null when unbound.</summary>
	public PrismGraph Graph { get; private set; }

	/// <summary>The mutation API document edits go through. Null means edits are not undoable.</summary>
	public GraphMutations Mutations { get; private set; }

	/// <summary>The state being read and written. Falls back to a detached instance when unbound.</summary>
	public PreviewState State => Graph?.Preview ?? _detached;

	/// <summary>Raised after any setting changes, from either store.</summary>
	public event Action Changed;

	/// <summary>Bind to a document. Pass nulls to unbind.</summary>
	public void Bind( PrismGraph graph, GraphMutations mutations )
	{
		Graph = graph;
		Mutations = mutations;

		Changed?.Invoke();
	}

	/// <summary>Run an action without any of its property writes reaching the document or the cookies.</summary>
	public IDisposable Suspend() => new SuspendScope( this );

	// ---- document-backed ---------------------------------------------------

	/// <summary>Built-in mesh name. Ignored while <see cref="ModelPath"/> is set.</summary>
	public string Mesh
	{
		get => string.IsNullOrWhiteSpace( State.Mesh ) ? "Sphere" : State.Mesh;
		set => Edit( x => x.Mesh = value, "Change Preview Mesh" );
	}

	/// <summary>Custom model asset path. Takes precedence over <see cref="Mesh"/>.</summary>
	public string ModelPath
	{
		get => State.Model;
		set => Edit( x => x.Model = value, "Change Preview Model" );
	}

	/// <summary>Environment asset: a skybox <c>.vmat</c> or a cubemap texture.</summary>
	public string Envmap
	{
		get => State.Envmap;
		set => Edit( x => x.Envmap = value, "Change Preview Environment" );
	}

	/// <summary>Whether the ground plane is drawn.</summary>
	public bool ShowGround
	{
		get => State.ShowGround;
		set => Edit( x => x.ShowGround = value, "Toggle Preview Ground" );
	}

	/// <summary>Whether the skybox is drawn.</summary>
	public bool ShowSkybox
	{
		get => State.ShowSkybox;
		set => Edit( x => x.ShowSkybox = value, "Toggle Preview Skybox" );
	}

	/// <summary>Per-instance tint multiplied into the subject.</summary>
	public Color Tint
	{
		get => State.Tint;
		set => Edit( x => x.Tint = value, "Change Preview Tint" );
	}

	/// <summary>Viewport clear colour.</summary>
	public Color Background
	{
		get => State.Background;
		set => Edit( x => x.Background = value, "Change Preview Background" );
	}

	/// <summary>The debug channel currently shown.</summary>
	public PreviewChannel Channel
	{
		get => PreviewChannels.Parse( State.Channel );
		set => Edit( x => x.Channel = PreviewChannels.Id( value ), "Change Preview Channel" );
	}

	/// <summary>The saved orbit state.</summary>
	public PreviewCamera Camera => State.Camera ??= new PreviewCamera();

	/// <summary>
	/// Record the camera the user just moved to, as a single undo entry. Called once when a drag ends,
	/// never per frame — an orbit that produced two hundred history entries would be unusable.
	/// </summary>
	public void CommitCamera( float yaw, float pitch, float distance )
	{
		Edit( x =>
		{
			x.Camera ??= new PreviewCamera();
			x.Camera.Yaw = yaw;
			x.Camera.Pitch = pitch;
			x.Camera.Distance = distance;
		}, "Move Preview Camera" );
	}

	// ---- editor preferences ------------------------------------------------

	/// <summary>Whether the key light casts shadows.</summary>
	public bool EnableShadows
	{
		get => _enableShadows;
		set => Prefer( ref _enableShadows, value, "shadows" );
	}

	/// <summary>Whether the warm and cool rim lights are on.</summary>
	public bool EnableFillLights
	{
		get => _enableFillLights;
		set => Prefer( ref _enableFillLights, value, "fillLights" );
	}

	/// <summary>Whether the tonemapping post-process runs.</summary>
	public bool EnableTonemapping
	{
		get => _enableTonemapping;
		set => Prefer( ref _enableTonemapping, value, "tonemapping" );
	}

	/// <summary>Whether back faces are drawn.</summary>
	public bool RenderBackfaces
	{
		get => _renderBackfaces;
		set => Prefer( ref _renderBackfaces, value, "backfaces" );
	}

	/// <summary>Whether the subject spins on its own.</summary>
	public bool AutoRotate
	{
		get => _autoRotate;
		set => Prefer( ref _autoRotate, value, "autoRotate" );
	}

	/// <summary>Auto-rotation rate in degrees per second.</summary>
	public float RotateSpeed
	{
		get => _rotateSpeed;
		set => Prefer( ref _rotateSpeed, Math.Clamp( value, -360f, 360f ), "rotateSpeed" );
	}

	/// <summary>Whether per-node thumbnails are rendered into the graph view.</summary>
	public bool NodeThumbnails
	{
		get => _nodeThumbnails;
		set => Prefer( ref _nodeThumbnails, value, PrismConstants.CookieNodePreviews, absolute: true );
	}

	/// <summary>Whether the selected node's value replaces the shader output in the main viewport.</summary>
	public bool NodePreview
	{
		get => _nodePreview;
		set => Prefer( ref _nodePreview, value, "nodePreview" );
	}

	/// <summary>Key-light direction.</summary>
	public Angles SunAngle
	{
		get => _sunAngle;
		set => Prefer( ref _sunAngle, value, "sunAngle" );
	}

	/// <summary>Key-light colour.</summary>
	public Color SunColor
	{
		get => _sunColor;
		set => Prefer( ref _sunColor, value, "sunColor" );
	}

	/// <summary>Key-light intensity.</summary>
	public float SunBrightness
	{
		get => _sunBrightness;
		set => Prefer( ref _sunBrightness, Math.Clamp( value, 0f, 32f ), "sunBrightness" );
	}

	/// <summary>Flat ambient term on top of the environment probe.</summary>
	public float AmbientStrength
	{
		get => _ambientStrength;
		set => Prefer( ref _ambientStrength, Math.Clamp( value, 0f, 4f ), "ambient" );
	}

	/// <summary>Camera vertical field of view, in degrees.</summary>
	public float FieldOfView
	{
		get => _fieldOfView;
		set => Prefer( ref _fieldOfView, Math.Clamp( value, 5f, 140f ), "fov" );
	}

	// ---- persistence -------------------------------------------------------

	/// <summary>Read the editor-wide half back out of the cookie store.</summary>
	public void Load()
	{
		using ( Suspend() )
		{
			_enableShadows = Cookie( "shadows", _enableShadows );
			_enableFillLights = Cookie( "fillLights", _enableFillLights );
			_enableTonemapping = Cookie( "tonemapping", _enableTonemapping );
			_renderBackfaces = Cookie( "backfaces", _renderBackfaces );
			_autoRotate = Cookie( "autoRotate", _autoRotate );
			_rotateSpeed = Cookie( "rotateSpeed", _rotateSpeed );
			_nodePreview = Cookie( "nodePreview", _nodePreview );
			_sunAngle = Cookie( "sunAngle", _sunAngle );
			_sunColor = Cookie( "sunColor", _sunColor );
			_sunBrightness = Cookie( "sunBrightness", _sunBrightness );
			_ambientStrength = Cookie( "ambient", _ambientStrength );
			_fieldOfView = Cookie( "fov", _fieldOfView );

			_nodeThumbnails = PrismLog.Guard( "Reading a preview preference",
				() => EditorCookie.Get( PrismConstants.CookieNodePreviews, _nodeThumbnails ), _nodeThumbnails );
		}
	}

	/// <summary>Write the editor-wide half back to the cookie store.</summary>
	public void Save()
	{
		Store( "shadows", _enableShadows );
		Store( "fillLights", _enableFillLights );
		Store( "tonemapping", _enableTonemapping );
		Store( "backfaces", _renderBackfaces );
		Store( "autoRotate", _autoRotate );
		Store( "rotateSpeed", _rotateSpeed );
		Store( "nodePreview", _nodePreview );
		Store( "sunAngle", _sunAngle );
		Store( "sunColor", _sunColor );
		Store( "sunBrightness", _sunBrightness );
		Store( "ambient", _ambientStrength );
		Store( "fov", _fieldOfView );

		// Shared with the View menu and the preferences page, which read it through PrismCookies. That
		// class memoises, so it drops its cache when the preferences page opens rather than this layer
		// reaching upwards into Integration to tell it to.
		PrismLog.Guard( "Writing a preview preference",
			() => EditorCookie.Set( PrismConstants.CookieNodePreviews, _nodeThumbnails ) );
	}

	/// <summary>Push every setting onto a live viewport in one pass.</summary>
	public void ApplyTo( PreviewViewport viewport )
	{
		if ( viewport is null ) return;

		var scene = viewport.PreviewScene;

		if ( scene is not null )
		{
			scene.ShowGround = ShowGround;
			scene.ShowSkybox = ShowSkybox;
			scene.EnableShadows = EnableShadows;
			scene.EnableFillLights = EnableFillLights;
			scene.EnableTonemapping = EnableTonemapping;
			scene.RenderBackfaces = RenderBackfaces;
			scene.BackgroundColor = Background;
			scene.Tint = Tint;
			scene.SunAngles = SunAngle;
			scene.SunColor = SunColor;
			scene.SunBrightness = SunBrightness;
			scene.AmbientStrength = AmbientStrength;
			scene.EnvmapPath = Envmap;
		}

		viewport.SetMesh( Mesh, ModelPath );
		viewport.Channel = Channel;
		viewport.AutoRotate = AutoRotate;
		viewport.RotateSpeed = RotateSpeed;
		viewport.NodePreviewEnabled = NodePreview;
		viewport.FieldOfView = FieldOfView;
		viewport.ApplyCameraState( Camera );
	}

	// ---- plumbing ----------------------------------------------------------

	void Edit( Action<PreviewState> edit, string label )
	{
		if ( _suspend || edit is null ) return;

		if ( Mutations is not null && Graph?.Preview is not null )
		{
			Mutations.UpdatePreview( edit, label );
		}
		else
		{
			PrismLog.Guard( label, () => edit( State ) );
		}

		Changed?.Invoke();
	}

	void Prefer<T>( ref T field, T value, string key, bool absolute = false )
	{
		if ( EqualityComparer<T>.Default.Equals( field, value ) ) return;

		field = value;

		if ( _suspend ) return;

		var cookie = absolute ? key : CookiePrefix + key;
		var stored = value;

		PrismLog.Guard( $"Writing preview preference '{cookie}'", () => EditorCookie.Set( cookie, stored ) );

		Changed?.Invoke();
	}

	static T Cookie<T>( string key, T fallback ) => PrismLog.Guard( $"Reading preview preference '{key}'",
		() => EditorCookie.Get( CookiePrefix + key, fallback ), fallback );

	static void Store<T>( string key, T value ) => PrismLog.Guard( $"Writing preview preference '{key}'",
		() => EditorCookie.Set( CookiePrefix + key, value ) );

	sealed class SuspendScope : IDisposable
	{
		readonly PreviewSettings _owner;
		readonly bool _previous;

		public SuspendScope( PreviewSettings owner )
		{
			_owner = owner;
			_previous = owner._suspend;
			owner._suspend = true;
		}

		public void Dispose() => _owner._suspend = _previous;
	}
}