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

namespace BlenderActions;

/// <summary>Implements Blender-style modal scaling for selected scene objects.</summary>
public sealed class ScaleOperation : ModalTransformOperation
{
    /// <summary>Defines the smallest scale produced by interactive pointer input.</summary>
    private const float MinimumInteractiveScale = 0.001f;
    /// <summary>Defines the smallest scale accepted from vertex snapping.</summary>
    private const float MinimumSnapScale = 0.001f;
    /// <summary>Defines the near-zero threshold used by component-wise division.</summary>
    private const float SafeDivisionEpsilon = 0.001f;

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

    /// <summary>Stores the world-space pivot used by the current operation.</summary>
    private Vector3 _selectionPivot;
    /// <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 pointer movement accumulated with precision scaling.</summary>
    private Vector2 _effectiveMousePosition;
    /// <summary>Stores the initial pointer distance from the scale pivot.</summary>
    private float _originalDistance;
    /// <summary>Stores the scale multiplier currently applied to selected objects.</summary>
    private Vector3 _appliedMultiplier = Vector3.One;
    /// <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.Scale;

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

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

        _selectionPivot /= _states.Length;

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

        _pivotInputPosition = CameraPixelsToInputPixels(
            Camera.PointToScreenPixels(_selectionPivot));
        _lastMousePosition = SceneViewportWidget.MousePosition;
        _effectiveMousePosition = _lastMousePosition;
        _originalDistance = (_lastMousePosition - _pivotInputPosition).Length;

        if(_originalDistance < 1f ||
            float.IsNaN(_originalDistance) ||
            float.IsInfinity(_originalDistance))
        {
            _originalDistance = 1f;
        }

        _appliedMultiplier = Vector3.One;
        _lockedTargetVertex = Vector3.Zero;
        _hasLockedTarget = false;
    }

    /// <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 frameMouseDelta = currentMousePosition - _lastMousePosition;
        _lastMousePosition = currentMousePosition;

        if(!snapEnabled)
        {
            var precision =
                (Editor.Application.KeyboardModifiers & KeyboardModifiers.Shift) != 0;
            _effectiveMousePosition += frameMouseDelta *
                (precision ? PrecisionMultiplier : 1f);
        }

        var scaleFactor = NumericInput.TryGetValue(out var numericScale)
            ? numericScale
            : (_effectiveMousePosition - _pivotInputPosition).Length / _originalDistance;

        if(float.IsNaN(scaleFactor) || float.IsInfinity(scaleFactor))
            return;

        if(!NumericInput.HasValue)
            scaleFactor = MathF.Max(scaleFactor, MinimumInteractiveScale);

        var multiplier = GetScaleMultiplier(scaleFactor);

        if(!snapEnabled)
        {
            _appliedMultiplier = multiplier;
            _lockedTargetVertex = Vector3.Zero;
            _hasLockedTarget = false;
            ApplyScale(_appliedMultiplier);
            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.TryFindClosestScaledSource(
            _snapSource,
            Camera,
            target.Vertex,
            _selectionPivot,
            Vector3.One,
            out var sourceVertex) ||
            !TryGetAbsoluteSnapMultiplier(
                sourceVertex,
                target.Vertex,
                _appliedMultiplier,
                out var snapMultiplier))
        {
            return;
        }

        _appliedMultiplier = snapMultiplier;
        _lockedTargetVertex = target.Vertex;
        _hasLockedTarget = true;
        ApplyScale(_appliedMultiplier);
    }

    /// <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 = (ScaleState[])_states.Clone();
        var after = CaptureCurrentStates(_states);

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

    /// <summary>Resets operation-specific state after the active constraint changes.</summary>
    protected override void OnConstraintChanged()
    {
        _appliedMultiplier = Vector3.One;
        _lockedTargetVertex = Vector3.Zero;
        _hasLockedTarget = false;
    }

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

    /// <summary>Returns component scale multipliers for the active constraint.</summary>
    private Vector3 GetScaleMultiplier(float factor)
    {
        return Constraint switch
        {
            AxisConstraint.X => new Vector3(factor, 1f, 1f),
            AxisConstraint.Y => new Vector3(1f, factor, 1f),
            AxisConstraint.Z => new Vector3(1f, 1f, factor),
            AxisConstraint.YZ => new Vector3(1f, factor, factor),
            AxisConstraint.XZ => new Vector3(factor, 1f, factor),
            AxisConstraint.XY => new Vector3(factor, factor, 1f),
            _ => new Vector3(factor, factor, factor)
        };
    }

    /// <summary>Attempts to calculate an absolute scale multiplier that aligns two vertices.</summary>
    private bool TryGetAbsoluteSnapMultiplier(
        Vector3 transformedSource,
        Vector3 target,
        Vector3 currentMultiplier,
        out Vector3 multiplier)
    {
        multiplier = Vector3.One;
        var currentSourceOffset = transformedSource - _selectionPivot;
        var originalSourceOffset = DivideSafe(currentSourceOffset, currentMultiplier);
        var targetOffset = target - _selectionPivot;
        var mask = GetConstraintMask();
        var fixedOffset = originalSourceOffset.MultiplyComponents(Vector3.One - mask);
        var scalableOffset = originalSourceOffset.MultiplyComponents(mask);
        var denominator = Vector3.Dot(scalableOffset, scalableOffset);

        if(denominator < 0.0000001f)
            return false;

        var factor = Vector3.Dot(
            scalableOffset,
            targetOffset - fixedOffset) / denominator;

        if(float.IsNaN(factor) || float.IsInfinity(factor))
            return false;

        if(factor < MinimumSnapScale)
            return false;

        multiplier = Vector3.One - mask + mask * factor;
        return true;
    }

    /// <summary>Returns the component mask represented by the active scale constraint.</summary>
    private Vector3 GetConstraintMask()
    {
        if(Constraint == AxisConstraint.None)
            return Vector3.One;

        return new Vector3(
            (Constraint & AxisConstraint.X) != 0 ? 1f : 0f,
            (Constraint & AxisConstraint.Y) != 0 ? 1f : 0f,
            (Constraint & AxisConstraint.Z) != 0 ? 1f : 0f);
    }

    /// <summary>Applies world scale and pivot-relative position changes to captured objects.</summary>
    private void ApplyScale(Vector3 multiplier)
    {
        for(var index = 0; index < _states.Length; index++)
        {
            var state = _states[index];

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

            state.Object.WorldScale = state.Scale.MultiplyComponents(multiplier);
            var offset = state.Position - _selectionPivot;

            if(ConstraintSpace == TransformConstraintSpace.Local &&
                Constraint != AxisConstraint.None)
            {
                var axisX = state.Rotation * Vector3.Forward;
                var axisY = state.Rotation * Vector3.Left;
                var axisZ = state.Rotation * Vector3.Up;
                var localOffset = new Vector3(
                    Vector3.Dot(offset, axisX),
                    Vector3.Dot(offset, axisY),
                    Vector3.Dot(offset, axisZ));
                var scaledLocalOffset = localOffset.MultiplyComponents(multiplier);

                state.Object.WorldPosition = _selectionPivot +
                    axisX * scaledLocalOffset.x +
                    axisY * scaledLocalOffset.y +
                    axisZ * scaledLocalOffset.z;
            }
            else
            {
                state.Object.WorldPosition =
                    _selectionPivot + offset.MultiplyComponents(multiplier);
            }
        }
    }

    /// <summary>Divides vector components while guarding near-zero divisors.</summary>
    private static Vector3 DivideSafe(Vector3 value, Vector3 divisor)
    {
        return new Vector3(
            MathF.Abs(divisor.x) >= SafeDivisionEpsilon ? value.x / divisor.x : 0f,
            MathF.Abs(divisor.y) >= SafeDivisionEpsilon ? value.y / divisor.y : 0f,
            MathF.Abs(divisor.z) >= SafeDivisionEpsilon ? value.z / divisor.z : 0f);
    }

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

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

        return result;
    }

    /// <summary>Applies captured transform states to valid game objects.</summary>
    private static void ApplyStates(ScaleState[] 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.WorldScale = state.Scale;
        }
    }
}