Editor/EffigyEditor/EffigyViewport.Sculpting.cs
using Editor;
using Effigy;
using Sandbox;
using System;

namespace Marionette.EditorTools;

/// <summary>
/// Sculpt mode in the viewport: rays in, brush strokes out.
///
/// DELIBERATELY THE THINNEST PART OF THE SCULPT TOOL. Everything with a decision in it — where the
/// cursor sits on the surface, whether the pointer has travelled far enough to earn a sample, how a
/// fast drag gets filled in, what a stroke commits, undo — lives in <see cref="SculptSession"/> in
/// the kernel, where a test can see it. The editor cannot be compiled outside s&box, and reading
/// editor code is how a bug that made every parameter edit a no-op survived long enough to look
/// like three unrelated UI faults. So this file converts Vector3 to Vec3, calls four methods, and
/// draws a circle.
///
/// Anything that starts to look like logic here belongs one floor down.
/// </summary>
internal sealed partial class EffigyViewport
{
	/// <summary>The live sculpt, or null when not in sculpt mode.</summary>
	public SculptSession SculptSession { get; private set; }

	public bool IsSculpting => SculptSession is not null;

	/// <summary>Raised after a stroke commits, so the window can mark the feature dirty and
	/// rebuild. Not raised per sample — a stroke is one edit.</summary>
	public Action SculptStrokeFinished { get; set; }

	/// <summary>Raised when the viewport itself changes a brush setting — the X and M shortcuts —
	/// so the strip's ticks and the bar's readout can catch up with it.</summary>
	public Action SculptSettingsChanged { get; set; }

	/// <summary>The mesh the preview was last built from, so a frame that changed nothing does not
	/// rebuild a model.</summary>
	private bool _sculptPreviewStale;

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

	// The floating number bar, held for the same reason the result strip is: the frame loop has to
	// keep camera drags out of it, or dragging the radius slider also flies the view.
	private Widget _sculptBarOverlay;

	/// <summary>
	/// Put the sculpt number bar on the canvas.
	///
	/// The brushes themselves are stages on the tool bar now. This is the one sculpt control that
	/// stayed floating, because it is about the STROKE - radius, strength, the level you are on -
	/// rather than about which tool is armed, and it wants to be near the thing being brushed.
	/// </summary>
	public void AddSculptOverlay( Widget bar )
	{
		_sculptBarOverlay = bar;
		bar.Position = OverlayMargin + new Vector2( 0f, 46f );
		bar.Visible = false;
	}

	public void BeginSculpt( SculptSession session )
	{
		SculptSession = session ?? throw new ArgumentNullException( nameof( session ) );
		_sculptPreviewStale = true;
	}

	public void EndSculpt()
	{
		// A stroke left running when the mode ends would hold a working mesh nobody will ever
		// commit. Cancel rather than commit: leaving the mode is not a way to finish a stroke.
		if ( SculptSession is { IsStroking: true } )
			SculptSession.CancelStroke();

		SculptSession = null;
		_sculptCursor = null;
	}

	/// <summary>Push the sculpted surface into the viewport, replacing the model in place.</summary>
	public void RefreshSculptPreview()
	{
		if ( SculptSession is null )
			return;

		var model = EffigyPreview.Build( SculptSession.DisplayMesh );

		if ( model is null )
			return;

		// The renderer's model is swapped rather than SetModel called: SetModel destroys and rebuilds
		// the GameObject, which is fine once per feature edit and not fine several times a second
		// during a stroke.
		if ( _renderer is not null )
			_renderer.Model = model;
		else
			SetModel( model, frameCamera: false );
	}

	private void SculptFrame()
	{
		if ( SculptSession is null )
			return;

		_sculptCursor = null;

		var stroking = SculptSession.IsStroking;

		// The pointer leaving the canvas does NOT end a stroke — see SculptSession.MoveTo on why
		// dragging off the model and back has to keep working. It only stops new samples.
		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 );

			_sculptCursor = SculptSession.Hover( origin, direction );

			if ( !stroking && Gizmo.WasLeftMousePressed )
			{
				if ( SculptSession.BeginStroke( origin, direction ) )
				{
					stroking = true;
					_sculptPreviewStale = true;
				}
			}
			else if ( stroking && Gizmo.IsLeftMouseDown )
			{
				if ( SculptSession.MoveTo( origin, direction ) > 0 )
					_sculptPreviewStale = true;
			}
		}

		// Released. There is no WasLeftMouseReleased in the Gizmo input this editor uses, so the
		// end of a stroke is the frame the button is no longer down — which is the same thing and
		// needs no API that might not be there.
		if ( stroking && !Gizmo.IsLeftMouseDown )
		{
			SculptSession.EndStroke();
			_sculptPreviewStale = true;
			SculptStrokeFinished?.Invoke();
		}

		if ( _sculptPreviewStale )
		{
			RefreshSculptPreview();
			_sculptPreviewStale = false;
		}

		DrawBrushCursor();
	}

	/// <summary>
	/// The ring on the surface, lying in the surface's own plane rather than facing the camera.
	///
	/// A camera-facing ring is easier to draw and lies about what the brush will do: the radius is
	/// in world units along the surface, so on a face turned away from the viewer a screen-facing
	/// circle covers far more of the model than it claims. Drawn flat on the surface it reads as the
	/// footprint it actually is.
	/// </summary>
	private void DrawBrushCursor()
	{
		if ( _sculptCursor 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 );

		// Any two perpendiculars will do; the ring has no orientation to get wrong.
		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 = SculptSession.Radius;

		Gizmo.Draw.IgnoreDepth = true;
		Gizmo.Draw.LineThickness = 1.5f;
		Gizmo.Draw.Color = SculptSession.Masking ? MaskCursorColor : BrushCursorColor;

		// Lifted off the surface by a whisker so it is not z-fighting the face it sits on.
		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;
		}

		// A stub along the normal, so the brush reads as sitting ON the surface rather than floating
		// somewhere near it — the one thing a flat ring on a curved model is genuinely ambiguous
		// about.
		Gizmo.Draw.Line( centre, centre + normal * (radius * 0.35f) );
	}

	/// <summary>Ordinary brush: the same blue the rest of this editor uses for "you can act here".
	/// </summary>
	private static readonly Color BrushCursorColor = new( 0.35f, 0.75f, 1f, 0.9f );

	/// <summary>Masking is a different job and gets a different colour, or a stroke that protects
	/// looks exactly like one that sculpts.</summary>
	private static readonly Color MaskCursorColor = new( 1f, 0.85f, 0.3f, 0.9f );

	/// <summary>
	/// The two toggles worth reaching for without leaving the model: X for symmetry, M for masking.
	///
	/// LETTERS ONLY, AND THAT IS DELIBERATE. Every sculpting tool in the world puts brush radius on
	/// the bracket keys, and this one does not, because nothing in this editor has ever named a
	/// KeyCode outside letters, Escape, Enter, Delete and Backspace — so the bracket names are a
	/// guess, and a guessed enum member is a compile error at best and a dead key at worst. Radius
	/// and strength live on the sculpt bar instead, where they are also more discoverable. Put the
	/// brackets back once somebody has read the real KeyCode enum out of the shipped assembly.
	///
	/// X and M follow the convention every other sculpting tool uses, and the W/E/R bone shortcuts
	/// in this same viewport already prove letters work.
	/// </summary>
	public bool HandleSculptKey( KeyEvent e )
	{
		if ( SculptSession is null )
			return false;

		switch ( e.Key )
		{
			case KeyCode.X:
				SculptSession.MirrorX = !SculptSession.MirrorX;
				break;

			case KeyCode.M:
				SculptSession.Masking = !SculptSession.Masking;
				break;

			default:
				return false;
		}

		SculptSettingsChanged?.Invoke();
		e.Accepted = true;

		return true;
	}
}