Editor/EffigyEditor/EffigyViewport.Painting.cs
using Editor;
using Effigy;
using Sandbox;
using System;
using System.Collections.Generic;

namespace Marionette.EditorTools;

/// <summary>
/// Paint mode in the viewport: rays in, a live texture out.
///
/// AS THIN AS EffigyViewport.Sculpting.cs, AND FOR THE SAME REASON. Everything with a decision in it —
/// where the cursor sits, whether the pointer has travelled far enough to earn a sample, the dab
/// itself, the falloff — lives in <see cref="PaintSession"/> and <see cref="PaintReplay"/> in the
/// kernel, where a test can see it. This file converts Vector3 to Vec3, calls four methods, uploads
/// the canvas's dirty rect to a texture, and draws a ring.
///
/// CTRL ERASES, and it is the same stroke machinery — the session is told which kind the stroke is
/// at the press and the kernel does the rest. Nothing here knows how an erase composites; this file
/// reads one modifier and picks a ring colour.
///
/// THE PAINT IS A TEXTURE, NOT VERTEX COLOURS. The session holds a live canvas whose dirty rect is
/// exactly what a dab touched, so a mouse-move re-uploads only that region rather than the whole
/// 1024² image — the four-megabytes-per-dab cost the old paint project paid. The canvas is baked
/// opaque over white before it reaches the GPU, because an ordinary material's colour map has nothing
/// sensible to show in a transparent texel.
/// </summary>
internal sealed partial class EffigyViewport
{
	/// <summary>The live paint, or null when not in paint mode.</summary>
	public PaintSession PaintSession { get; private set; }

	public bool IsPainting => PaintSession is not null;

	/// <summary>Raised after a stroke commits, carrying the committed stroke so the window can append
	/// it to the feature. Not raised per sample — a stroke is one edit.</summary>
	public Action<PaintStroke> PaintStrokeFinished { get; set; }

	/// <summary>Where the brush ring is drawn this frame, or null when the cursor is off the model.</summary>
	private MeshHit? _paintCursor;

	/// <summary>The floating bars that belong to a brush — the colour one and the material one.
	/// A list rather than a field because there are two now and both have to be kept off the
	/// canvas hit test: a click meant for a control must never also land a dab.</summary>
	private readonly List<Widget> _paintBarOverlays = new();

	private bool _paintPreviewStale;

	/// <summary>The bar's Erase mode at the moment the current stroke began, so Ctrl can invert
	/// it for one mark without leaving the mode flipped afterwards.</summary>
	private bool _paintEraseMode;

	// The dynamic texture and its material, held for the life of a paint session and re-uploaded in
	// place. Held rather than recreated per dab — a fresh texture + material copy per mouse-move is
	// exactly the garbage a held drag would make thousands of.
	private Texture _paintTexture;
	private Material _paintMaterial;

	public void AddPaintOverlay( Widget bar )
	{
		_paintBarOverlays.Add( bar );
		bar.Position = OverlayMargin + new Vector2( 0f, 46f );
		bar.Visible = false;
	}

	public void BeginPaint( PaintSession session )
	{
		PaintSession = session ?? throw new ArgumentNullException( nameof( session ) );

		CreatePaintTexture( session );
		_paintPreviewStale = true;
	}

	public void EndPaint()
	{
		// A stroke left running when the mode ends would be a half-finished mark nobody committed.
		// Cancel rather than commit, the same choice sculpt makes.
		if ( PaintSession is { IsStroking: true } )
			PaintSession.CancelStroke();

		PaintSession = null;
		_paintCursor = null;

		_paintMaterial = null;
		_paintTexture = null;
	}

	/// <summary>Create the texture and material the whole session will draw with. The canvas is baked
	/// opaque over white in full, so the material's colour map is opaque from the first frame — which
	/// is also why the canvas's dirty rect is cleared afterwards, since that full bake IS the upload.</summary>
	private void CreatePaintTexture( PaintSession session )
	{
		var res = session.Resolution;

		var opaque = new byte[res * res * 4];
		session.Canvas.BakeOpaque( opaque, PaintMaterial.DefaultBaseR, PaintMaterial.DefaultBaseG, PaintMaterial.DefaultBaseB );

		_paintTexture = Texture.Create( res, res, ImageFormat.RGBA8888 )
			.WithDynamicUsage()
			.WithData( opaque )
			.Finish();

		// The paint is the surface colour, so the base is any ordinary lit material — its own colour
		// map is overridden with the atlas below. default.vmat is the placeholder the rest of the
		// preview already falls back to.
		_paintMaterial = Material.Load( "materials/default.vmat" )?.CreateCopy( "effigy_paint" );
		_paintMaterial?.Set( "g_tColor", _paintTexture );

		session.Canvas.ClearDirty();
	}

	/// <summary>Upload whatever the canvas's dirty rect names, then push the painted surface into the
	/// viewport. The dirty rect is what makes a mouse-move cheap: only the texels a dab touched are
	/// baked and uploaded, not the whole image.</summary>
	public void RefreshPaintPreview()
	{
		if ( PaintSession is null || _paintMaterial is null || _paintTexture is null )
			return;

		var canvas = PaintSession.Canvas;

		if ( canvas.HasDirty )
		{
			var w = canvas.MaxX - canvas.MinX + 1;
			var h = canvas.MaxY - canvas.MinY + 1;
			var sub = new byte[w * h * 4];

			canvas.BakeOpaque( sub, PaintMaterial.DefaultBaseR, PaintMaterial.DefaultBaseG, PaintMaterial.DefaultBaseB,
				canvas.MinX, canvas.MinY, w, h );
			_paintTexture.Update( sub, canvas.MinX, canvas.MinY, w, h );

			canvas.ClearDirty();
		}

		var model = EffigyPreview.Build( PaintSession.Mesh, _paintMaterial );

		if ( model is null )
			return;

		// Swapped rather than SetModel, for the same reason sculpt swaps: SetModel destroys and
		// rebuilds the GameObject, which is fine once and not fine on every rebuild.
		if ( _renderer is not null )
			_renderer.Model = model;
		else
			SetModel( model, frameCamera: false );
	}

	private void PaintFrame()
	{
		if ( PaintSession is null )
			return;

		_paintCursor = null;

		var stroking = PaintSession.IsStroking;

		// The pointer leaving the canvas does NOT end a stroke, the same rule sculpt keeps: dragging
		// off the model and back on is ordinary, and ending there would make the tool drop the gesture.
		if ( _canvasHasCursor )
		{
			var ray = Gizmo.CurrentRay;
			var origin = new Vec3( ray.Position.x, ray.Position.y, ray.Position.z );
			var direction = new Vec3( ray.Forward.x, ray.Forward.y, ray.Forward.z );

			_paintCursor = PaintSession.Hover( origin, direction );

			if ( !stroking && Gizmo.WasLeftMousePressed )
			{
				// The bar's Erase toggle is the mode. Ctrl inverts it for this stroke only, the
				// same "read once at the press" rule sculpt uses, so releasing mid-drag cannot
				// split one mark into two kinds.
				_paintEraseMode = PaintSession.Erasing;
				var hold = Editor.Application.IsKeyDown( KeyCode.Control );
				PaintSession.Erasing = hold ? !_paintEraseMode : _paintEraseMode;

				if ( PaintSession.BeginStroke( origin, direction ) )
				{
					stroking = true;
					_paintPreviewStale = true;
				}
			}
			else if ( stroking && Gizmo.IsLeftMouseDown )
			{
				if ( PaintSession.MoveTo( origin, direction ) > 0 )
					_paintPreviewStale = true;
			}
		}

		// Released. Same inference as sculpt: the end of a stroke is the frame the button is no
		// longer down.
		if ( stroking && !Gizmo.IsLeftMouseDown )
		{
			var stroke = PaintSession.EndStroke();

			if ( stroke is not null )
				PaintStrokeFinished?.Invoke( stroke );

			PaintSession.Erasing = _paintEraseMode;
		}

		if ( _paintPreviewStale )
		{
			RefreshPaintPreview();
			_paintPreviewStale = false;
		}

		DrawPaintCursor();
	}

	/// <summary>The brush ring on the surface, lying in the surface's own plane — the same footprint
	/// the dab will paint, and the same reason sculpt draws it flat rather than camera-facing.</summary>
	private void DrawPaintCursor()
	{
		if ( _paintCursor is not { } hit )
			return;

		var normal = new Vector3( hit.Normal.x, hit.Normal.y, hit.Normal.z ).Normal;
		var centre = new Vector3( hit.Point.x, hit.Point.y, hit.Point.z );

		var reference = MathF.Abs( normal.z ) > 0.9f ? new Vector3( 1f, 0f, 0f ) : new Vector3( 0f, 0f, 1f );
		var right = Vector3.Cross( normal, reference ).Normal;
		var up = Vector3.Cross( normal, right ).Normal;

		var radius = PaintSession.Radius;

		// While a stroke runs the ring shows what THAT stroke is; between strokes it shows what the
		// next one would be, read live off the modifier. Reading only the session flag would make the
		// ring tell the truth one stroke late — the "visible after the click rather than before it"
		// complaint, which is the whole reason the ring is coloured at all.
		var erasing = PaintSession.IsStroking
			? PaintSession.Erasing
			: Editor.Application.IsKeyDown( KeyCode.Control );

		Gizmo.Draw.IgnoreDepth = true;
		Gizmo.Draw.LineThickness = 1.5f;
		Gizmo.Draw.Color = erasing ? PaintEraseCursorColor : PaintCursorColor;

		var lift = normal * (radius * 0.01f);
		const int Segments = 40;
		var previous = centre + right * radius + lift;

		for ( var i = 1; i <= Segments; i++ )
		{
			var angle = i / (float)Segments * MathF.PI * 2f;
			var point = centre + (right * MathF.Cos( angle ) + up * MathF.Sin( angle )) * radius + lift;

			Gizmo.Draw.Line( previous, point );
			previous = point;
		}

		Gizmo.Draw.Line( centre, centre + normal * (radius * 0.35f) );
	}

	/// <summary>A paint ring is a paint ring — a colour distinct from sculpt's blue, so the two
	/// modes are never confused when a brush is armed.</summary>
	private static readonly Color PaintCursorColor = new( 1f, 0.45f, 0.75f, 0.9f );

	/// <summary>Erasing, shown while Ctrl is held. The same red the note eraser and the inverted
	/// sculpt brush use, because it means the same thing in all three: this stroke takes away.</summary>
	private static readonly Color PaintEraseCursorColor = new( 1f, 0.35f, 0.32f, 0.9f );
}