Editor/RotateOperation.cs
#nullable enable
using Editor;
using Sandbox;
using System;

namespace BlenderActions;

/// <summary>Implements Blender-style modal rotation for selected scene objects.</summary>
public sealed class RotateOperation : ModalTransformOperation
{
    /// <summary>Defines the minimum pointer radius used to calculate a stable rotation angle.</summary>
    private const float DirectionEpsilon = 2f;

    /// <summary>Handles new.</summary>
    private readonly VertexSnapSource _snapSource = new();
    /// <summary>Handles states.</summary>
    private RotationState[] _states = Array.Empty<RotationState>();

    /// <summary>Stores the world-space pivot used by the current operation.</summary>
    private Vector3 _selectionPivot;
    /// <summary>Stores the initial orientation that defines object-local constraint axes.</summary>
    private Rotation _localOrientation;
    /// <summary>Stores the pivot projected into viewport input pixels.</summary>
    private Vector2 _pivotInputPosition;
    /// <summary>Stores the pointer position observed on the previous frame.</summary>
    private Vector2 _lastMousePosition;
    /// <summary>Stores the unsnapped angle accumulated from pointer movement.</summary>
    private float _accumulatedAngle;
    /// <summary>Stores the world-space axis used by the current rotation.</summary>
    private Vector3 _rotationAxis;
    /// <summary>Stores the angle currently applied to selected objects.</summary>
    private float _appliedAngle;
    /// <summary>Stores the vertex currently locking a snapped transform.</summary>
    private Vector3 _lockedTargetVertex;
    /// <summary>Tracks whether a snapped target vertex is currently locked.</summary>
    private bool _hasLockedTarget;

    /// <summary>Gets the operation kind.</summary>
    public override TransformOperationKind Kind => TransformOperationKind.Rotate;

    /// <summary>Captures operation-specific initial state.</summary>
    protected override void OnBegin()
    {
        _states = new RotationState[SelectedObjects.Length];
        _selectionPivot = Vector3.Zero;

        for(var index = 0; index < SelectedObjects.Length; index++)
        {
            var gameObject = SelectedObjects[index];
            _states[index] = new RotationState(
                gameObject,
                gameObject.WorldPosition,
                gameObject.WorldRotation);
            _selectionPivot += gameObject.WorldPosition;
        }

        _selectionPivot /= _states.Length;
        _localOrientation = _states[0].Rotation;

        if(ThreeDCursor.UseAsTransformPivot)
            _selectionPivot = ThreeDCursor.Position;

        _pivotInputPosition = CameraPixelsToInputPixels(
            Camera.PointToScreenPixels(_selectionPivot));
        _lastMousePosition = SceneViewportWidget.MousePosition;
        _accumulatedAngle = 0f;
        _appliedAngle = 0f;
        _lockedTargetVertex = Vector3.Zero;
        _hasLockedTarget = false;
        CaptureRotationAxis();
    }

    /// <summary>Updates the operation from current editor input.</summary>
    protected override void OnUpdate()
    {
        var snapEnabled =
            (Editor.Application.KeyboardModifiers & KeyboardModifiers.Ctrl) != 0 &&
            !NumericInput.HasValue;

        var currentMousePosition = SceneViewportWidget.MousePosition;
        var previousDirection = _lastMousePosition - _pivotInputPosition;
        var currentDirection = currentMousePosition - _pivotInputPosition;
        _lastMousePosition = currentMousePosition;

        if(!snapEnabled &&
            previousDirection.Length >= DirectionEpsilon &&
            currentDirection.Length >= DirectionEpsilon)
        {
            previousDirection = previousDirection.Normal;
            currentDirection = currentDirection.Normal;

            var cross =
                previousDirection.x * currentDirection.y -
                previousDirection.y * currentDirection.x;
            var dot =
                previousDirection.x * currentDirection.x +
                previousDirection.y * currentDirection.y;
            var frameAngle = -MathF.Atan2(cross, dot) * (180f / MathF.PI);
            var precision =
                (Editor.Application.KeyboardModifiers & KeyboardModifiers.Shift) != 0;

            _accumulatedAngle += frameAngle *
                (precision ? PrecisionMultiplier : 1f);
        }

        var angle = NumericInput.TryGetValue(out var numericAngle)
            ? numericAngle
            : _accumulatedAngle;

        if(!snapEnabled)
        {
            _appliedAngle = angle;
            _lockedTargetVertex = Vector3.Zero;
            _hasLockedTarget = false;
            ApplyRotation(Rotation.FromAxis(_rotationAxis, _appliedAngle));
            return;
        }

        var target = VertexSnapService.FindTargetVertex(
            Session.Scene,
            Camera,
            Viewport,
            SelectedObjects);

        var targetChanged = target.Found &&
            (!_hasLockedTarget ||
             (target.Vertex - _lockedTargetVertex).Length > 0.001f);

        if(!targetChanged)
            return;

        VertexSnapService.CaptureSourceSnapshot(_snapSource, SelectedObjects);

        if(!VertexSnapService.TryFindClosestRotatedSource(
            _snapSource,
            Camera,
            target.Vertex,
            _selectionPivot,
            Rotation.Identity,
            out var sourceVertex) ||
            !TryGetSnapAngle(
                sourceVertex,
                target.Vertex,
                out var correctionAngle))
        {
            return;
        }

        _appliedAngle += correctionAngle;
        _lockedTargetVertex = target.Vertex;
        _hasLockedTarget = true;
        ApplyRotation(Rotation.FromAxis(_rotationAxis, _appliedAngle));
    }

    /// <summary>Restores every transformed object to its captured initial state.</summary>
    protected override void RestoreInitialState()
    {
        ApplyStates(_states);
    }

    /// <summary>Registers undo and redo callbacks for the completed operation.</summary>
    protected override void RegisterUndo()
    {
        var before = (RotationState[])_states.Clone();
        var after = CaptureCurrentStates(_states);

        Session.AddUndo(
            "Blender Rotate",
            () => ApplyStates(before),
            () => ApplyStates(after));
    }

    /// <summary>Resets operation-specific state after the active constraint changes.</summary>
    protected override void OnConstraintChanged()
    {
        _accumulatedAngle = 0f;
        _lastMousePosition = SceneViewportWidget.MousePosition;
        CaptureRotationAxis();
        _appliedAngle = 0f;
        _lockedTargetVertex = Vector3.Zero;
        _hasLockedTarget = false;
    }

    /// <summary>Releases operation-specific state during cleanup.</summary>
    protected override void OnCleanup()
    {
        _states = Array.Empty<RotationState>();
        _snapSource.Clear();
        _appliedAngle = 0f;
        _lockedTargetVertex = Vector3.Zero;
        _hasLockedTarget = false;
    }

    /// <summary>Captures the world or camera-facing axis used by the current rotation.</summary>
    private void CaptureRotationAxis()
    {
        var toCamera = Camera.GameObject.WorldPosition - _selectionPivot;

        if(Constraint == AxisConstraint.None)
        {
            _rotationAxis = toCamera.Length > 0.0001f
                ? toCamera.Normal
                : -Camera.GameObject.WorldRotation.Forward;
            return;
        }

        var worldAxis = Constraint switch
        {
            AxisConstraint.X or AxisConstraint.YZ => Vector3.Forward,
            AxisConstraint.Y or AxisConstraint.XZ => Vector3.Left,
            AxisConstraint.Z or AxisConstraint.XY => Vector3.Up,
            _ => Vector3.Up
        };

        _rotationAxis = ConstraintSpace == TransformConstraintSpace.Local
            ? _localOrientation * worldAxis
            : worldAxis;
    }

    /// <summary>Attempts to calculate the angular correction from a source vertex to a target vertex.</summary>
    private bool TryGetSnapAngle(
        Vector3 sourceVertex,
        Vector3 targetVertex,
        out float angle)
    {
        angle = 0f;
        var sourceOffset = sourceVertex - _selectionPivot;
        var targetOffset = targetVertex - _selectionPivot;
        var sourcePlanar = sourceOffset -
            _rotationAxis * Vector3.Dot(sourceOffset, _rotationAxis);
        var targetPlanar = targetOffset -
            _rotationAxis * Vector3.Dot(targetOffset, _rotationAxis);

        if(sourcePlanar.Length < 0.0001f || targetPlanar.Length < 0.0001f)
            return false;

        sourcePlanar = sourcePlanar.Normal;
        targetPlanar = targetPlanar.Normal;

        var cross = Vector3.Cross(sourcePlanar, targetPlanar);
        var dot = Vector3.Dot(sourcePlanar, targetPlanar);
        angle = MathF.Atan2(
            Vector3.Dot(_rotationAxis, cross),
            dot) * (180f / MathF.PI);

        return !float.IsNaN(angle) && !float.IsInfinity(angle);
    }

    /// <summary>Applies a rotation around the active pivot to all captured objects.</summary>
    private void ApplyRotation(Rotation rotation)
    {
        for(var index = 0; index < _states.Length; index++)
        {
            var state = _states[index];

            if(!state.Object.IsValid())
                continue;

            var offset = state.Position - _selectionPivot;
            state.Object.WorldPosition = _selectionPivot + rotation * offset;
            state.Object.WorldRotation = rotation * state.Rotation;
        }
    }

    /// <summary>Captures current position and rotation values for undo or redo.</summary>
    private static RotationState[] CaptureCurrentStates(RotationState[] source)
    {
        var result = new RotationState[source.Length];

        for(var index = 0; index < source.Length; index++)
        {
            var state = source[index];
            result[index] = state.Object.IsValid()
                ? new RotationState(
                    state.Object,
                    state.Object.WorldPosition,
                    state.Object.WorldRotation)
                : state;
        }

        return result;
    }

    /// <summary>Applies captured transform states to valid game objects.</summary>
    private static void ApplyStates(RotationState[] states)
    {
        for(var index = 0; index < states.Length; index++)
        {
            var state = states[index];

            if(!state.Object.IsValid())
                continue;

            state.Object.WorldPosition = state.Position;
            state.Object.WorldRotation = state.Rotation;
        }
    }
}