Editor/Effigy/NoteSession.cs
using System;
using System.Collections.Generic;
using System.Linq;

namespace Effigy;

/// <summary>
/// Drawing, erasing and undoing grease-pencil notes.
///
/// SAME SPLIT AS SculptSession, FOR THE SAME REASON. Everything with a decision in it lives here,
/// in the kernel, where a test can run it without an engine: where a sample lands in depth, whether
/// the pointer has moved far enough to earn one, which note an erase click is aiming at, what undo
/// means. The editor half converts Vector3 to Vec3 and draws lines. Sculpting learned this the hard
/// way — a bug that made every parameter edit a no-op read as three unrelated UI faults, because
/// the logic was somewhere no test could reach.
///
/// THE SESSION DOES NOT OWN THE LIST. It is handed <see cref="PartStudio.Notes"/> and edits it in
/// place, so a note exists in the document the moment the stroke ends and there is no commit step
/// to forget. That also means undo here is undo of the document, which is what the user means by
/// it.
/// </summary>
public sealed class NoteSession
{
	/// <summary>The document's own list, edited in place.</summary>
	private readonly List<Note> _notes;

	/// <summary>What the ray is tested against, refreshed by the editor after every rebuild. Notes
	/// stick to nothing, but they still LAND on the surface under the cursor, which is the
	/// difference between drawing on the model and drawing in front of it.</summary>
	private List<Body> _bodies = new();

	/// <summary>The point the pivot sits at, used as the depth of the fallback plane. Reads as
	/// "the middle of the part" for the purposes of drawing in mid-air.</summary>
	public Vec3 Pivot;

	private Note _working;

	/// <summary>Origin and normal of the plane a mid-air stroke is drawn on, fixed at the moment
	/// the stroke begins.
	///
	/// FIXED, not recomputed per sample, and that is the whole trick. Recomputing it against the
	/// current ray each sample puts every point at the same distance from the camera, which sounds
	/// identical and is not: on an orbiting or even slightly drifting view the stroke bends into a
	/// shell around the viewer. One plane per stroke means what you drew is flat, which is what a
	/// note written in the air is supposed to be.</summary>
	private Vec3 _planeOrigin, _planeNormal;

	private readonly Stack<NoteEdit> _done = new();
	private readonly Stack<NoteEdit> _undone = new();

	public NoteSession( List<Note> notes )
	{
		_notes = notes ?? throw new ArgumentNullException( nameof( notes ) );
	}

	/// <summary>Colour and thickness the next stroke is drawn with. Live settings rather than
	/// per-note arguments, because that is how the toolbar presents them.</summary>
	public int Color;

	/// <summary>Line thickness in SCREEN PIXELS, not world units - see Note.Width. Unlike the three
	/// distances below it therefore does not scale with the model, which is right: a note is
	/// something you read, and handwriting that gets thinner as you zoom out is handwriting you
	/// cannot read.</summary>
	public float Width = 2f;

	/// <summary>
	/// How far apart two samples have to be, in world units, before the second is kept.
	///
	/// A mouse reports far more positions than a stroke needs, and keeping them all costs both the
	/// file and the draw loop for a line nobody can see the difference in. Big enough to thin a
	/// slow careful stroke, small enough that a tight circle still reads as round.
	///
	/// The value here is only the fallback for an empty studio - see <see cref="ScaleTo"/>, which
	/// is what actually sets it.
	/// </summary>
	public float Spacing = 0.015f;

	/// <summary>How far off the surface a sample sits, in world units. Without it a stroke drawn on
	/// a face z-fights the face and reads as a dotted line that flickers when the camera moves.
	/// Set by <see cref="ScaleTo"/>.</summary>
	public float Lift = 0.006f;

	/// <summary>How near the cursor has to be for a note to be picked or erased, in world units.
	/// Generous relative to the stroke: a thin ribbon is a small target and an erase that misses is
	/// more annoying than one that is easy to aim. Set by <see cref="ScaleTo"/>.</summary>
	public float PickRadius = 0.05f;

	/// <summary>
	/// Size the three distances above to the model, rather than to a number somebody guessed.
	///
	/// EFFIGY'S UNITS ARE DIMENSIONLESS - PolyMesh.BoundsDiagonal says so at length: a default
	/// primitive is one unit across and a room is hundreds. A constant that feels right on one of
	/// those is unusable on the other, and this class shipped with three of them tuned for a part
	/// tens of units wide. On a default one-unit box that made Spacing a quarter of the whole part
	/// (four samples across a face), Lift a visible fraction of it (handwriting floating off the
	/// model), and PickRadius wider than the entire model - so every click after the first note
	/// landed on that note and opened its caption box instead of drawing.
	///
	/// The fractions are the sculpt brush's rule (SculptSession.SuggestedRadius) applied to a
	/// different job: a fraction of the diagonal, with a fallback for the empty studio where there
	/// is no diagonal to take a fraction of.
	/// </summary>
	public void ScaleTo( float diagonal )
	{
		// An empty studio still has to be drawable - a note on nothing is a legitimate first act -
		// and it has no bounds to measure. One unit is the default primitive, which is the part
		// about to be made.
		if ( !(diagonal > 1e-6f) )
			diagonal = 1f;

		// ~65 samples across the part. Fine enough that a tight circle reads as round, coarse
		// enough that a slow hand does not write a thousand points into the document.
		Spacing = diagonal / 65f;

		// Just off the surface. Big enough to beat depth precision, small enough that the note
		// still reads as being ON the face rather than hovering over it.
		Lift = diagonal / 160f;

		// A comfortably bigger target than the line is thick, and still a small fraction of the
		// part, so notes on opposite sides of a model are never both under the cursor.
		PickRadius = diagonal / 20f;
	}

	public bool IsStroking => _working is not null;

	public bool CanUndo => _done.Count > 0;

	public bool CanRedo => _undone.Count > 0;

	public IReadOnlyList<Note> Notes => _notes;

	/// <summary>The stroke being drawn right now, so the editor can draw it before it is
	/// committed. Null between strokes.</summary>
	public Note Working => _working;

	public void SetBodies( IEnumerable<Body> bodies ) =>
		_bodies = bodies?.Where( b => b?.Mesh is not null ).ToList() ?? new List<Body>();

	// --- where a sample lands ------------------------------------------------------------------

	/// <summary>
	/// Depth for a ray: on the surface if it hits one, otherwise on the stroke's plane.
	///
	/// The lift is along the SURFACE normal rather than back along the ray, so a stroke keeps the
	/// same clearance from the face when the camera swings round. Backing it along the ray instead
	/// looks identical from where you drew it and sinks into the model from anywhere else.
	/// </summary>
	public Vec3? Project( Vec3 origin, Vec3 direction )
	{
		var dir = direction.Normal;

		if ( dir.LengthSquared < 0.5f )
			return null;

		if ( MeshRaycast.Raycast( _bodies, origin, dir ) is { } surface )
			return surface.Hit.Point + surface.Hit.Normal.Normal * Lift;

		var normal = IsStroking ? _planeNormal : -dir;
		var planeOrigin = IsStroking ? _planeOrigin : Pivot;
		var facing = Vec3.Dot( dir, normal );

		// Edge-on to the plane there is no answer, and inventing one puts a point at infinity. This
		// only happens mid-stroke on a plane the view has since rotated almost parallel to, and
		// dropping the sample is right: the stroke pauses rather than shooting off.
		if ( MathF.Abs( facing ) < 1e-4f )
			return null;

		var t = Vec3.Dot( planeOrigin - origin, normal ) / facing;

		return t > 0f ? origin + dir * t : null;
	}

	// --- drawing -------------------------------------------------------------------------------

	/// <summary>
	/// Start a stroke. Returns false only when the ray is unusable — unlike a sculpt stroke, a note
	/// does NOT need to hit the model, because writing in the space beside a part is most of what
	/// this is for.
	/// </summary>
	public bool BeginStroke( Vec3 origin, Vec3 direction )
	{
		if ( IsStroking )
			throw new InvalidOperationException( "A stroke is already running; end it before starting another." );

		var dir = direction.Normal;

		if ( dir.LengthSquared < 0.5f )
			return false;

		// Set BEFORE the first Project, so that call already has the plane it will keep. The plane
		// passes through whatever is under the cursor when the stroke starts — the surface if there
		// is one, the pivot's depth if not — so a stroke that begins on the model and wanders off
		// its edge carries on at the depth it was at rather than jumping back to the pivot.
		_planeNormal = -dir;
		_planeOrigin = MeshRaycast.Raycast( _bodies, origin, dir ) is { } hit ? hit.Hit.Point : Pivot;
		_working = new Note { Color = Color, Width = Width };

		if ( Project( origin, dir ) is { } point )
			_working.Points.Add( point );

		return true;
	}

	/// <summary>Add a sample if the pointer has travelled far enough. Returns whether it did, so
	/// the editor can skip a redraw that would change nothing.</summary>
	public bool MoveTo( Vec3 origin, Vec3 direction )
	{
		if ( _working is null || Project( origin, direction ) is not { } point )
			return false;

		if ( _working.Points.Count > 0 && (point - _working.Points[^1]).Length < Spacing )
			return false;

		_working.Points.Add( point );

		return true;
	}

	/// <summary>
	/// Commit the stroke, or drop it if there is nothing in it. Returns the committed note, or null.
	///
	/// A CLICK IS NOT A STROKE. Every left-press in note mode begins one, including the click that
	/// was reaching for something else, and committing a one-point mark for each of those leaves a
	/// scatter of near-invisible pins the user cannot see well enough to erase. Two points is the
	/// shortest thing that is visibly a line and was therefore visibly intended.
	/// </summary>
	public Note EndStroke()
	{
		var stroke = _working;

		_working = null;

		if ( stroke is null || stroke.Points.Count < 2 )
			return null;

		_notes.Add( stroke );
		_done.Push( NoteEdit.Added( stroke, _notes.Count - 1 ) );
		_undone.Clear();

		return stroke;
	}

	public void CancelStroke() => _working = null;

	// --- picking and erasing -------------------------------------------------------------------

	/// <summary>The note under the cursor, or null. Ties break towards the nearest, so a scribble
	/// crossing another is picked where you are actually pointing.</summary>
	public Note Pick( Vec3 origin, Vec3 direction )
	{
		Note best = null;
		var bestDistance = PickRadius;

		foreach ( var note in _notes )
		{
			var d = note.DistanceToRay( origin, direction );

			if ( d < bestDistance )
			{
				best = note;
				bestDistance = d;
			}
		}

		return best;
	}

	public bool Erase( Vec3 origin, Vec3 direction ) => Remove( Pick( origin, direction ) );

	public bool Remove( Note note )
	{
		if ( note is null )
			return false;

		var index = _notes.IndexOf( note );

		if ( index < 0 )
			return false;

		_notes.RemoveAt( index );
		_done.Push( NoteEdit.Removed( note, index ) );
		_undone.Clear();

		return true;
	}

	/// <summary>
	/// Retype a note's caption. Recorded as an edit like any other, because the thing people most
	/// want back after a typo is what they typed before it.
	/// </summary>
	public bool SetText( Note note, string text )
	{
		if ( note is null || note.Text == text )
			return false;

		_done.Push( NoteEdit.Retitled( note, note.Text ) );
		_undone.Clear();
		note.Text = text;

		return true;
	}

	// --- undo ----------------------------------------------------------------------------------

	public bool Undo() => Step( _done, _undone );

	public bool Redo() => Step( _undone, _done );

	private bool Step( Stack<NoteEdit> from, Stack<NoteEdit> to )
	{
		if ( from.Count == 0 )
			return false;

		// A stroke left running would be committed on top of a document that has just moved under
		// it, so undo cancels it first. Same rule as leaving sculpt mode mid-stroke.
		CancelStroke();

		to.Push( from.Pop().Apply( _notes ) );

		return true;
	}

	/// <summary>Forget the history without touching the notes. For leaving note mode: the marks
	/// stay in the document, the ability to undo them from a session that has ended does not.
	/// </summary>
	public void ClearHistory()
	{
		_done.Clear();
		_undone.Clear();
	}
}

/// <summary>
/// One reversible change to the note list.
///
/// APPLY RETURNS ITS OWN INVERSE, so undo and redo are the same code path pushing onto opposite
/// stacks rather than two switch statements that have to agree with each other. The second of those
/// is where the asymmetry bug lives in every implementation that has one.
/// </summary>
internal sealed class NoteEdit
{
	private enum Kind { Add, Remove, Text }

	private readonly Kind _kind;
	private readonly Note _note;
	private readonly int _index;
	private readonly string _text;

	private NoteEdit( Kind kind, Note note, int index, string text )
	{
		_kind = kind;
		_note = note;
		_index = index;
		_text = text;
	}

	public static NoteEdit Added( Note note, int index ) => new( Kind.Add, note, index, null );

	public static NoteEdit Removed( Note note, int index ) => new( Kind.Remove, note, index, null );

	public static NoteEdit Retitled( Note note, string previous ) => new( Kind.Text, note, -1, previous );

	public NoteEdit Apply( List<Note> notes )
	{
		switch ( _kind )
		{
			case Kind.Add:
			{
				// By identity, not by the stored index: an erase since this note was drawn has
				// shifted everything after it, and removing at the remembered slot would delete
				// somebody else's note.
				var at = notes.IndexOf( _note );

				if ( at >= 0 )
					notes.RemoveAt( at );

				return Removed( _note, at < 0 ? _index : at );
			}

			case Kind.Remove:
			{
				var at = Math.Clamp( _index, 0, notes.Count );

				notes.Insert( at, _note );

				return Added( _note, at );
			}

			default:
			{
				var previous = _note.Text;

				_note.Text = _text;

				return Retitled( _note, previous );
			}
		}
	}
}