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

namespace Marionette.EditorTools;

/// <summary>What a right-click landed on: the body, which face of it, a FaceRef captured at the
/// click point, and the material slot that face is on right now. The slot is carried along because
/// the menu has to tick the current one, and by the time the menu is built the raycast is gone.</summary>
internal readonly struct EffigyFaceHit
{
	public EffigyFaceHit( Body body, int faceIndex, FaceRef reference, int material )
	{
		Body = body;
		FaceIndex = faceIndex;
		Reference = reference;
		Material = material;
	}

	public Body Body { get; }
	public int FaceIndex { get; }
	public FaceRef Reference { get; }
	public int Material { get; }
}

/// <summary>
/// Right-clicking a face of the model.
///
/// Separate from the face picking in EffigyViewport.Sketching.cs, and deliberately so: that pass
/// only runs while FacePickMode is armed by a dialog, and is scoped to the bodies THAT feature may
/// act on. This one is always live and works on whatever is on screen, because "right-click the
/// thing you can see" is not a mode you enter.
/// </summary>
internal sealed partial class EffigyViewport
{
	/// <summary>Raised with the face under the cursor when the right button opens a menu over one.
	/// The viewport resolves the face and stops there — what the menu OFFERS is a question about
	/// the studio, which lives in the window.</summary>
	public Action<EffigyFaceHit> FaceContextMenuRequested { get; set; }

	// --- the cursor ray -------------------------------------------------------------------------
	//
	// Kept from the last frame rather than built on demand. Gizmo.CurrentRay only means anything
	// inside the scene's frame — a menu callback is nowhere near one — and a click does not move
	// the mouse, so last frame's ray is aimed exactly where the click landed.

	private Vec3 _cursorRayOrigin;
	private Vec3 _cursorRayDirection;
	private bool _cursorRayValid;

	private void CaptureCursorRay()
	{
		// HELD, not cleared, when the cursor leaves the canvas. Right-dragging turns _canvasHasCursor
		// off (that is how the fly camera stops sketch clicks landing), so clearing here would throw
		// the ray away on the very press that wants it.
		if ( !_canvasHasCursor )
			return;

		var ray = Gizmo.CurrentRay;

		// Vector3 -> Vec3 is a straight re-type, not a transform: the kernel's axes and s&box's line
		// up exactly. Same conversion FacePickFrame does.
		_cursorRayOrigin = new Vec3( ray.Position.x, ray.Position.y, ray.Position.z );
		_cursorRayDirection = new Vec3( ray.Forward.x, ray.Forward.y, ray.Forward.z );
		_cursorRayValid = true;
	}

	// --- telling a right-click apart from a right-drag -------------------------------------------
	//
	// Right-drag flies the camera, and the context-menu event arrives on the button RELEASE. Without
	// this, every orbit that ended over the model — which is most of them, the model is what you are
	// orbiting around — would pop a menu over the thing you were trying to look at.
	//
	// The test is whether the camera actually MOVED during the press, not whether the button was
	// held: a press that flew nowhere is a click, however long it was held down.

	private bool _flyingCamera;
	private Vector3 _flightPosition;
	private Vector3 _flightForward;
	private float _cameraMovedAt = float.NegativeInfinity;

	private void NoteCameraFlight( bool flying )
	{
		if ( flying && !_flyingCamera )
		{
			// First frame of this press — remember where it started from, so the comparison below is
			// against this press and not against wherever the last one left off.
			_flightPosition = _camera.WorldPosition;
			_flightForward = _camera.WorldRotation.Forward;
		}
		else if ( flying )
		{
			if ( Moved( _camera.WorldPosition, _flightPosition, 0.01f )
				|| Moved( _camera.WorldRotation.Forward, _flightForward, 0.002f ) )
				_cameraMovedAt = RealTime.Now;
		}

		_flyingCamera = flying;
	}

	private new static bool Moved( Vector3 a, Vector3 b, float epsilon ) =>
		MathF.Abs( a.x - b.x ) > epsilon
		|| MathF.Abs( a.y - b.y ) > epsilon
		|| MathF.Abs( a.z - b.z ) > epsilon;

	// --- the pick ---------------------------------------------------------------------------------

	/// <summary>The face under the cursor, against everything currently on screen.</summary>
	public bool TryPickFaceUnderCursor( out EffigyFaceHit hit )
	{
		hit = default;

		return _cursorRayValid && TryPickFace( _cursorRayOrigin, _cursorRayDirection, out hit );
	}

	/// <summary>
	/// The face some ray hits, against everything currently on screen.
	///
	/// Split from the cursor version for the material drop, which aims with a ray built from the
	/// drop position rather than from the hover — see EffigyViewport.MaterialDrop.cs for why it
	/// cannot use the cursor ray. Everything below the ray is identical for both and was worth
	/// having in one place rather than two that drift.
	///
	/// Visible bodies only. A hidden body is not something you can point at, and letting a pick land
	/// on one would put a material on a face nobody can see and cannot click again to change.
	/// </summary>
	private bool TryPickFace( Vec3 origin, Vec3 direction, out EffigyFaceHit hit )
	{
		hit = default;

		var visible = _displayBodies.Where( b => b?.Mesh is not null && b.Visible ).ToList();

		if ( visible.Count == 0 )
			return false;

		if ( MeshRaycast.Raycast( visible, origin, direction ) is not { } result )
			return false;

		var mesh = result.Body.Mesh;
		var index = result.Hit.FaceIndex;

		if ( index < 0 || index >= mesh.Faces.Count )
			return false;

		// Capture rather than the raw constructor, same as the sketch-plane pick: it records where on
		// the face the click landed, which is what lets the reference survive the face being remade
		// at a different size.
		hit = new EffigyFaceHit( result.Body, index,
			FacePlane.Capture( result.Body, index, result.Hit.Point ),
			mesh.Faces[index].Material );

		return true;
	}

	protected override void OnContextMenu( ContextMenuEvent e )
	{
		// A quarter second is long enough to cover the frame the release lands on and short enough
		// that letting go and right-clicking again works immediately.
		if ( RealTime.Now - _cameraMovedAt < 0.25f )
			return;

		// Inside a sketch the right button means something else. The bodies are not what you are
		// pointing at in there, and a material menu over a half-drawn profile would be answering a
		// question nobody asked. What it means instead, most-live thing first:
		//
		// 1. BACK OUT of the entity being drawn. Right-click to stop the line you are dragging out is
		//    the reflex every CAD sketcher trains, and until now the only way to break a chain was a
		//    key press — with both hands already on the mouse.
		// 2. CONSTRAIN the selection, when there is one and nothing is half-drawn.
		// 3. STAND THE TOOL DOWN, so a second right-click gets back to Select the way a second Escape
		//    does. Harmless in Select with nothing selected, which is the only other case reaching it.
		//
		// Note this is NOT Escape's order — Escape drops the selection before it touches the half-drawn
		// entity. It cannot be, because the two buttons want opposite things from a selection: Escape
		// is there to get rid of it, and the right button is how you act on it. So the half-drawn
		// entity, which is the thing actually moving under the cursor, goes first here.
		if ( IsSketching )
		{
			if ( CancelHalfDrawnSketchEntity() )
				return;

			if ( HasSketchSelection )
				SketchConstraintMenuRequested?.Invoke();
			else
				CancelSketchTool();

			return;
		}

		// Anything else with a click of its own owns the mouse while it is armed. Opening a material
		// menu in the middle of choosing a sketch plane would act on a body the dialog may not even
		// be allowed to touch.
		if ( PlanePickMode || SketchPickMode || FacePickMode || BodyPickMode || BoneToolActive )
			return;

		if ( FaceContextMenuRequested is null )
			return;

		if ( !TryPickFaceUnderCursor( out var hit ) )
			return;

		FaceContextMenuRequested.Invoke( hit );
	}
}