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

namespace Marionette.EditorTools;

/// <summary>
/// Datum planes in the viewport: the ones a PlaneFeature put there, as opposed to the three global
/// ones the origin owns.
///
/// A SEPARATE PARTIAL BECAUSE THEY ARE A SEPARATE KIND OF THING. The three reference planes are
/// fixtures — always three, always square to the world, addressed by an index 0..2, resizable by
/// their corners — and every one of those facts is baked into the code that draws and picks them.
/// A datum plane is none of them: there can be none or twenty, it can lean any way it likes, and it
/// is identified by a feature id. Bolting that onto the index-based code would have meant every
/// method there growing a "unless it is one of the other ones" branch, and the corner-resize
/// machinery would have been the first thing to break.
///
/// WHAT THEY SHARE IS THE CLICK. Both are answers to "which plane?", and the selection box asks that
/// question once — so both register hitboxes in the same frame, both compare against the nearest
/// solid the same way, and a click resolves to whichever the cursor was actually over. There is no
/// mode to switch between them, for the same reason picking a plane and picking a face is not two
/// modes either.
/// </summary>
internal sealed partial class EffigyViewport
{
	/// <summary>
	/// A plane published by a PlaneFeature, ready to be drawn and clicked.
	///
	/// Handed in whole every rebuild rather than the viewport reaching into the studio: the plane a
	/// feature published is a rebuild's output, and a viewport holding the feature would be drawing
	/// whatever the parameters say right now, which during a drag is not what the model is.
	/// </summary>
	internal sealed class PickablePlane
	{
		public string FeatureId;
		public string Name;
		public SketchPlane Plane;

		/// <summary>Drawn at all. Hidden planes are invisible and unclickable — a document with a
		/// dozen of them is otherwise a fog.</summary>
		public bool Visible = true;

		/// <summary>
		/// Offerable as an answer to "which plane?", which is NOT the same as being drawn.
		///
		/// The plane whose dialog is open is the case that forced the split. It cannot be built on
		/// itself and neither can anything at or below it in the tree, so it must not be pickable —
		/// and it is the one plane you most need to LOOK at while you drag its offset. Filtering the
		/// list by tree position did both at once and made the plane you were editing disappear the
		/// moment you opened it.
		/// </summary>
		public bool Pickable = true;

		/// <summary>
		/// Which way this plane's Offset moves it — <see cref="PlaneFeature.OffsetAxis"/>, worked out
		/// by the window and handed over with everything else.
		///
		/// HANDED IN RATHER THAN DERIVED HERE, for the reason the plane itself is: the viewport does
		/// not hold features. Recovering this needs the Angle and the Hinge, which live on the
		/// feature, and a viewport that reached for them would be reading parameters that a drag in
		/// progress has already changed — the exact staleness this class exists to avoid.
		/// </summary>
		public Vec3 OffsetAxis;

		public PickablePlane( string featureId, string name, SketchPlane plane, bool visible, bool pickable,
			Vec3 offsetAxis )
		{
			FeatureId = featureId;
			Name = name;
			OffsetAxis = offsetAxis;
			Plane = plane;
			Visible = visible;
			Pickable = pickable;
		}
	}

	/// <summary>
	/// How far a datum plane reaches from its own origin.
	///
	/// SMALLER THAN THE REFERENCE PLANES on purpose. Those are the world's frame and are meant to
	/// span it; a datum plane is a local decision — 2 units off this face, 30° round that axis — and
	/// several of them at 128 units across would bury the part they were made for. This is large
	/// enough to read as a plane and small enough that three of them are still three things.
	/// </summary>
	private const float DatumPlaneHalfSize = PlaneSize * 0.55f;

	/// <summary>Half-thickness of the pick slab, matching the reference planes' so a datum plane is
	/// no harder to hit than a global one.</summary>
	private const float DatumPlanePickThickness = PlanePickThickness;

	/// <summary>Lilac, and deliberately none of the three axis colours. Orange, blue and green mean
	/// Top, Front and Right everywhere in this editor, and a fourth plane wearing one of them would
	/// be read as one of those three seen from an angle.</summary>
	private static readonly Color DatumPlaneColour = new( 0.68f, 0.55f, 0.9f, 0.6f );

	private readonly List<PickablePlane> _datumPlanes = new();

	/// <summary>Feature id of the datum plane under the cursor this frame, or null.</summary>
	private string _hoveredDatumPlane;

	/// <summary>Fires with the picked plane's feature id. Set by the selection box alongside
	/// PlanePicked, so one armed box takes either kind of answer.</summary>
	public Action<string> DatumPlanePicked { get; set; }

	/// <summary>
	/// A plane to draw its own two in-plane axes on, or null.
	///
	/// SET WHILE A PLANE'S DIALOG IS OPEN, because that dialog asks which of those axes to hinge on
	/// and the answer is not guessable. On the three global planes the axes are world axes and
	/// "its X axis" reads as it sounds; on a plane derived from a face they come from the normal
	/// alone (FacePlane.FromPointAndNormal), so the only honest way to say which is which is to
	/// draw them.
	/// </summary>
	public string PlaneAxesShownOn { get; set; }

	public IReadOnlyList<PickablePlane> DatumPlanes => _datumPlanes;

	/// <summary>Replace the datum planes wholesale. Same shape as SetPickableSketches, and called
	/// from the same place for the same reason.</summary>
	public void SetDatumPlanes( IEnumerable<PickablePlane> planes )
	{
		_datumPlanes.Clear();

		if ( planes is null )
			return;

		foreach ( var plane in planes )
		{
			if ( plane?.Plane is not null )
				_datumPlanes.Add( plane );
		}
	}

	/// <summary>Where a datum plane sits in the viewport. Datum planes are built in model space and
	/// the model is drawn relative to the origin handle, exactly like the active sketch plane a few
	/// lines away in DrawReferencePlanes.</summary>
	private Vector3 DatumOrigin( PickablePlane plane ) =>
		OriginPosition + new Vector3( plane.Plane.Origin.x, plane.Plane.Origin.y, plane.Plane.Origin.z );

	private static Vector3 DatumRight( PickablePlane plane ) =>
		new( plane.Plane.XAxis.x, plane.Plane.XAxis.y, plane.Plane.XAxis.z );

	private static Vector3 DatumUp( PickablePlane plane ) =>
		new( plane.Plane.YAxis.x, plane.Plane.YAxis.y, plane.Plane.YAxis.z );

	/// <summary>Which way the plane faces. Cross( X, Y ) is SketchPlane.Normal's own definition, so
	/// this says what the kernel says rather than something close to it — which matters where the
	/// sign is load-bearing, as it is for the offset handle's arrow.</summary>
	private static Vector3 DatumNormal( PickablePlane plane ) =>
		Vector3.Cross( DatumRight( plane ), DatumUp( plane ) ).Normal;

	/// <summary>
	/// Outline, name, and a hover wash. Called from DrawReferencePlanes so datum planes are drawn in
	/// the same pass and under the same depth rules as the planes they sit among.
	/// </summary>
	private void DrawDatumPlanes()
	{
		foreach ( var plane in _datumPlanes )
		{
			if ( !plane.Visible )
				continue;

			var centre = DatumOrigin( plane );
			var right = DatumRight( plane );
			var up = DatumUp( plane );
			var hovered = _hoveredDatumPlane == plane.FeatureId;

			DrawPlaneOutline( centre, right, up, DatumPlaneHalfSize,
				hovered ? DatumPlaneColour.WithAlpha( 0.95f ) : DatumPlaneColour );

			if ( hovered )
			{
				// The same wash the reference planes get, so the thing about to be picked fills in
				// whichever kind of plane it is.
				Gizmo.Draw.IgnoreDepth = true;
				Gizmo.Draw.Color = DatumPlaneColour.WithAlpha( 0.18f );

				var a = centre + right * DatumPlaneHalfSize + up * DatumPlaneHalfSize;
				var b = centre - right * DatumPlaneHalfSize + up * DatumPlaneHalfSize;
				var c = centre - right * DatumPlaneHalfSize - up * DatumPlaneHalfSize;
				var d = centre + right * DatumPlaneHalfSize - up * DatumPlaneHalfSize;

				Gizmo.Draw.SolidTriangle( new Triangle( a, b, c ) );
				Gizmo.Draw.SolidTriangle( new Triangle( a, c, d ) );
				Gizmo.Draw.IgnoreDepth = false;
			}

			if ( plane.FeatureId == PlaneAxesShownOn )
				DrawPlaneAxes( centre, right, up );

			if ( string.IsNullOrEmpty( plane.Name ) )
				continue;

			// NAMED, unlike the three global planes, because those are three and these are however
			// many somebody made. "Which of these is Roof line?" has no answer from the geometry —
			// two parallel planes a unit apart look identical — and the feature tree cannot say
			// either, since a row in a list does not point at anything in space.
			Gizmo.Draw.Color = DatumPlaneColour.WithAlpha( hovered ? 0.95f : 0.6f );
			Gizmo.Draw.WorldText( plane.Name,
				new Transform( centre - right * DatumPlaneHalfSize + up * (DatumPlaneHalfSize + 4f) ),
				"Roboto", 9f, TextFlag.LeftBottom );
		}
	}

	/// <summary>The two in-plane axes, named, so the Tilt about dropdown is a thing you can read off
	/// the model rather than find by trying both.</summary>
	private static void DrawPlaneAxes( Vector3 centre, Vector3 right, Vector3 up )
	{
		var length = DatumPlaneHalfSize * 0.8f;

		Gizmo.Draw.IgnoreDepth = true;
		Gizmo.Draw.LineThickness = 2f;

		Gizmo.Draw.Color = DatumPlaneColour.WithAlpha( 0.9f );
		Gizmo.Draw.Line( centre, centre + right * length );
		Gizmo.Draw.WorldText( "X", new Transform( centre + right * (length + 5f) ), "Roboto", 10f, TextFlag.Center );

		Gizmo.Draw.Line( centre, centre + up * length );
		Gizmo.Draw.WorldText( "Y", new Transform( centre + up * (length + 5f) ), "Roboto", 10f, TextFlag.Center );

		Gizmo.Draw.LineThickness = 1f;
		Gizmo.Draw.IgnoreDepth = false;
	}

	/// <summary>
	/// A clickable slab per datum plane, only while picking is armed. Called from DrawPlaneHitboxes
	/// so the two kinds of plane are registered in one place and resolve against the same solid.
	/// </summary>
	private void DrawDatumPlaneHitboxes()
	{
		_hoveredDatumPlane = null;

		if ( !PlanePickMode )
			return;

		foreach ( var plane in _datumPlanes )
		{
			if ( !plane.Visible || !plane.Pickable )
				continue;

			// THE NEARER TARGET WINS, exactly as it does for a reference plane: a solid in front of
			// this plane takes the click and the plane does not so much as light up.
			if ( DatumRayDistance( plane, out var distance ) && FacePickDistance < distance )
				continue;

			var normal = DatumNormal( plane );

			using var scope = Gizmo.Scope( $"datum-plane-{plane.FeatureId}",
				new Transform( DatumOrigin( plane ), Rotation.LookAt( normal, DatumUp( plane ) ) ) );

			// Local x is the rotation's forward, which is the plane normal — so the slab is thin
			// along the normal and full size across the plane.
			Gizmo.Hitbox.BBox( BBox.FromPositionAndSize( Vector3.Zero,
				new Vector3( DatumPlanePickThickness, DatumPlaneHalfSize * 2f, DatumPlaneHalfSize * 2f ) ) );

			if ( !Gizmo.IsHovered )
				continue;

			_hoveredDatumPlane = plane.FeatureId;

			if ( Gizmo.WasLeftMousePressed )
				DatumPlanePicked?.Invoke( plane.FeatureId );
		}
	}

	/// <summary>How far along the cursor ray this datum plane sits, or false when the ray runs
	/// parallel to it or meets it behind the camera. The arbitrary-orientation twin of
	/// PlaneRayDistance.</summary>
	private bool DatumRayDistance( PickablePlane plane, out float distance )
	{
		distance = float.PositiveInfinity;

		var normal = DatumNormal( plane );
		var ray = Gizmo.CurrentRay;
		var denominator = Vector3.Dot( ray.Forward, normal );

		if ( MathF.Abs( denominator ) < 1e-5f )
			return false;

		var t = Vector3.Dot( DatumOrigin( plane ) - ray.Position, normal ) / denominator;

		if ( t <= 0f )
			return false;

		distance = t;
		return true;
	}

	// --- the offset handle -------------------------------------------------------------------

	/// <summary>Raised once when the offset handle is grabbed, before it has moved.</summary>
	public Action PlaneOffsetDragBegan { get; set; }

	/// <summary>Raised every frame the offset handle moves, with how far it has been dragged SINCE it
	/// was grabbed — the total, not the frame's delta.
	///
	/// The same shape FaceDragMoved has and for the same reason: what reads this sets a parameter
	/// from it, and a parameter is a value rather than something to integrate.
	/// </summary>
	public Action<float> PlaneOffsetDragged { get; set; }

	/// <summary>Raised when the button comes up, so the window can run the one full rebuild the
	/// light per-frame path deferred.</summary>
	public Action PlaneOffsetDragEnded { get; set; }

	/// <summary>True while the offset handle is being dragged, so the window can give the drag the
	/// same light rebuild path the body and face drags get.</summary>
	public bool IsDraggingPlaneOffset => _draggingPlaneOffset;

	private bool _draggingPlaneOffset;
	private Vector3 _planeOffsetAnchor;
	private Vector3 _planeOffsetAxis = Vector3.Up;
	private float _planeOffsetDistance;

	/// <summary>
	/// One arrow, along the direction the open plane's Offset actually travels.
	///
	/// WHY A PLANE NEEDS ITS OWN HANDLE AT ALL when Offset is a field you can type in: the number is
	/// only meaningful against something you can see. "Two units off that face" is a decision about
	/// where the rib goes, and typing 2 to find out whether 2 was right is the interaction Move Face
	/// and the extrude already replaced with an arrow — see EffigyViewport.FaceDrag, whose reasoning
	/// this follows down to the single axis.
	///
	/// ONLY WHILE THE PLANE'S OWN DIALOG IS OPEN, which is the same gate the in-plane axes use and is
	/// why it reads PlaneAxesShownOn rather than carrying a second flag that could drift out of step
	/// with it. Both mean "the plane being edited", and a datum plane that grew a permanent arrow
	/// would put a grabbable control on every plane in a document that has twenty.
	///
	/// ALONG THE OFFSET AXIS, NOT THE PLANE'S NORMAL. On a tilted plane those are different
	/// directions — Execute offsets and then tilts about the origin it landed on — so the arrow
	/// points where the plane MOVES rather than where it faces. PlaneFeature.OffsetAxis explains the
	/// difference; the window works it out and hands it over on the PickablePlane.
	/// </summary>
	private void PlaneOffsetHandleFrame()
	{
		if ( PlaneOffsetDragged is null || string.IsNullOrEmpty( PlaneAxesShownOn ) )
		{
			EndPlaneOffsetDrag();
			return;
		}

		// The tools that own the mouse. A sketch being drawn, the sculpt and paint brushes and the
		// bone tool all have a click of their own, and an arrow floating over the model while one of
		// them is armed invites a click that will not do what it looks like. Not while the drag is
		// already running: it has the button, so nothing else can have started.
		if ( (IsSketching || IsSculpting || IsPainting || IsMaterialBrushing || IsNoting || BoneToolActive)
			&& !_draggingPlaneOffset )
		{
			EndPlaneOffsetDrag();
			return;
		}

		// MID-DRAG THE PLANE IS MOVING, which is the whole point of the drag and is exactly why the
		// handle cannot be re-read from it. Each frame writes Offset, the studio rebuilds, and the
		// plane this was drawn from comes back somewhere new — so anchoring to its live origin would
		// add the movement a second time and the arrow would run away from the cursor at double
		// speed. Anchor where the grab happened and add what has been dragged since.
		if ( _draggingPlaneOffset )
		{
			DrawPlaneOffsetHandle( _planeOffsetAnchor + _planeOffsetAxis * _planeOffsetDistance, _planeOffsetAxis );
			return;
		}

		foreach ( var plane in _datumPlanes )
		{
			if ( plane.FeatureId != PlaneAxesShownOn )
				continue;

			// A plane hidden by its eye in the feature tree is not there to be grabbed. The dialog
			// can still be open on it — hiding one does not close it — and an arrow hanging in space
			// with no plane under it is a control for something invisible.
			if ( !plane.Visible )
				return;

			DrawPlaneOffsetHandle( DatumOrigin( plane ),
				new Vector3( plane.OffsetAxis.x, plane.OffsetAxis.y, plane.OffsetAxis.z ) );
			return;
		}
	}

	/// <summary>
	/// The arrow, and the drag it reports.
	///
	/// ONE ARROW PUSHES BOTH WAYS: dragging back past the tail gives a negative distance and the
	/// plane goes the other way, which is what a signed offset means and what the face handle
	/// already does with the same single arrow.
	///
	/// THE ENGINE HIDES IT NEAR HEAD-ON, within ten degrees of the view direction, because screen
	/// movement stops mapping to axis movement there. Looking straight down a plane's normal
	/// therefore leaves no handle — correct, since the drag would be meaningless at that angle, but
	/// worth knowing before concluding it broke. Orbit a few degrees.
	/// </summary>
	private void DrawPlaneOffsetHandle( Vector3 origin, Vector3 axis )
	{
		using var scope = Gizmo.Scope( "plane-offset-drag", new Transform( origin ) );

		Gizmo.Hitbox.DepthBias = 0.01f;

		if ( Gizmo.Control.Arrow( "plane-offset", axis, out var distance ) )
		{
			if ( !_draggingPlaneOffset )
			{
				_draggingPlaneOffset = true;
				_planeOffsetAnchor = origin;
				_planeOffsetAxis = axis;
				_planeOffsetDistance = 0f;
				PlaneOffsetDragBegan?.Invoke();
			}

			// Accumulated rather than assigned: these controls report the change since the last frame
			// and return false on any frame the value did not move. The axis is the one the drag
			// STARTED with, held for the same reason the anchor is.
			_planeOffsetDistance += distance;

			PlaneOffsetDragged.Invoke( _planeOffsetDistance );
			return;
		}

		EndPlaneOffsetDrag();
	}

	private void EndPlaneOffsetDrag()
	{
		if ( !_draggingPlaneOffset )
			return;

		_draggingPlaneOffset = false;
		_planeOffsetDistance = 0f;
		PlaneOffsetDragEnded?.Invoke();
	}
}