Editor/TranslateOperation.cs

Editor modal operation that implements Blender-style translation for selected scene objects. It tracks initial positions, converts mouse movement to world-space deltas using camera projection inversion, supports axis/plane constraints, numeric distance input, vertex snapping, undo/redo and restores/cleans state.

NetworkingFile Access
#nullable enable
using Editor;
using Sandbox;
using System;

namespace BlenderActions;

/// <summary>Implements Blender-style modal translation for selected scene objects.</summary>
public sealed class TranslateOperation : ModalTransformOperation
{
    /// <summary>Defines the world-space camera-plane probe used to invert screen projection.</summary>
    private const float ProjectionProbeDistance = 100f;
    /// <summary>Defines the minimum stable determinant accepted for projection inversion.</summary>
    private const float ProjectionEpsilon = 0.000001f;

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

    /// <summary>Stores the world-space pivot used by the current operation.</summary>
    private Vector3 _selectionPivot;
    /// <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 _accumulatedMouseDelta;
    /// <summary>Stores the world-space displacement represented by one camera pixel on screen X.</summary>
    private Vector3 _screenXWorldDelta;
    /// <summary>Stores the world-space displacement represented by one camera pixel on screen Y.</summary>
    private Vector3 _screenYWorldDelta;
    /// <summary>Stores the translation currently applied to selected objects.</summary>
    private Vector3 _appliedWorldDelta;
    /// <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.Translate;

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

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

        _selectionPivot /= _states.Length;
        _lastMousePosition = SceneViewportWidget.MousePosition;
        _accumulatedMouseDelta = Vector2.Zero;
        _appliedWorldDelta = Vector3.Zero;
        _lockedTargetVertex = Vector3.Zero;
        _hasLockedTarget = false;

        var cameraRotation = Camera.GameObject.WorldRotation;
        var cameraRight = cameraRotation.Right;
        var cameraUp = cameraRotation.Up;
        var pivotScreen = Camera.PointToScreenPixels(_selectionPivot);
        var rightScreen = Camera.PointToScreenPixels(
            _selectionPivot + cameraRight * ProjectionProbeDistance);
        var upScreen = Camera.PointToScreenPixels(
            _selectionPivot + cameraUp * ProjectionProbeDistance);

        var rightPixelsPerUnit =
            (rightScreen - pivotScreen) / ProjectionProbeDistance;
        var upPixelsPerUnit =
            (upScreen - pivotScreen) / ProjectionProbeDistance;
        var determinant =
            rightPixelsPerUnit.x * upPixelsPerUnit.y -
            rightPixelsPerUnit.y * upPixelsPerUnit.x;

        if(MathF.Abs(determinant) < ProjectionEpsilon)
            throw new InvalidOperationException("Camera projection cannot be inverted.");

        _screenXWorldDelta =
            cameraRight * (upPixelsPerUnit.y / determinant) -
            cameraUp * (rightPixelsPerUnit.y / determinant);
        _screenYWorldDelta =
            cameraRight * (-upPixelsPerUnit.x / determinant) +
            cameraUp * (rightPixelsPerUnit.x / determinant);
    }

    /// <summary>Updates the operation from current editor input.</summary>
    protected override void OnUpdate()
    {
        var currentMousePosition = SceneViewportWidget.MousePosition;
        var frameMouseDelta = currentMousePosition - _lastMousePosition;
        _lastMousePosition = currentMousePosition;

        var precision =
            (Editor.Application.KeyboardModifiers & KeyboardModifiers.Shift) != 0;
        _accumulatedMouseDelta += frameMouseDelta *
            (precision ? PrecisionMultiplier : 1f);

        var pixelDelta = InputPixelsToCameraPixels(_accumulatedMouseDelta);
        var worldDelta =
            _screenXWorldDelta * pixelDelta.x +
            _screenYWorldDelta * pixelDelta.y;

        if(NumericInput.TryGetValue(out var numericDistance))
            worldDelta = ApplyNumericDistance(worldDelta, numericDistance);
        else
            worldDelta = ApplyConstraint(worldDelta);

        var snapEnabled =
            (Editor.Application.KeyboardModifiers & KeyboardModifiers.Ctrl) != 0 &&
            !NumericInput.HasValue;

        if(!snapEnabled)
        {
            _appliedWorldDelta = worldDelta;
            _lockedTargetVertex = Vector3.Zero;
            _hasLockedTarget = false;
            ApplyTranslation(_appliedWorldDelta);
            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.TryFindClosestTranslatedSource(
            _snapSource,
            Camera,
            target.Vertex,
            Vector3.Zero,
            out var sourceVertex))
        {
            return;
        }

        var correction = ApplyConstraint(target.Vertex - sourceVertex);
        _appliedWorldDelta += correction;
        _lockedTargetVertex = target.Vertex;
        _hasLockedTarget = true;
        ApplyTranslation(_appliedWorldDelta);
    }

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

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

        Session.AddUndo(
            "Blender Translate",
            () => ApplyPositions(before),
            () => ApplyPositions(after));
    }

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

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

    /// <summary>Converts numeric input into a constrained world-space translation.</summary>
    private Vector3 ApplyNumericDistance(Vector3 worldDelta, float distance)
    {
        if(IsSingleAxis(Constraint))
            return GetSingleAxis(Constraint) * distance;

        var constrained = ApplyConstraint(worldDelta);
        return constrained.Length > 0.0001f
            ? constrained.Normal * distance
            : Vector3.Zero;
    }

    /// <summary>Projects a world delta onto the active axis or plane constraint.</summary>
    private Vector3 ApplyConstraint(Vector3 worldDelta)
    {
        if(Constraint == AxisConstraint.None)
            return worldDelta;

        var result = Vector3.Zero;

        if((Constraint & AxisConstraint.X) != 0)
            result += Vector3.Forward * Vector3.Dot(worldDelta, Vector3.Forward);
        if((Constraint & AxisConstraint.Y) != 0)
            result += Vector3.Left * Vector3.Dot(worldDelta, Vector3.Left);
        if((Constraint & AxisConstraint.Z) != 0)
            result += Vector3.Up * Vector3.Dot(worldDelta, Vector3.Up);

        return result;
    }

    /// <summary>Applies a world-space translation to all captured objects.</summary>
    private void ApplyTranslation(Vector3 delta)
    {
        for(var index = 0; index < _states.Length; index++)
        {
            var state = _states[index];

            if(state.Object.IsValid())
                state.Object.WorldPosition = state.Position + delta;
        }
    }

    /// <summary>Returns whether a constraint represents exactly one world axis.</summary>
    private static bool IsSingleAxis(AxisConstraint constraint)
    {
        return constraint == AxisConstraint.X ||
            constraint == AxisConstraint.Y ||
            constraint == AxisConstraint.Z;
    }

    /// <summary>Returns the world direction represented by a single-axis constraint.</summary>
    private static Vector3 GetSingleAxis(AxisConstraint constraint)
    {
        return constraint switch
        {
            AxisConstraint.X => Vector3.Forward,
            AxisConstraint.Y => Vector3.Left,
            AxisConstraint.Z => Vector3.Up,
            _ => Vector3.Zero
        };
    }

    /// <summary>Captures current object positions for undo or redo.</summary>
    private static PositionState[] CaptureCurrentPositions(PositionState[] source)
    {
        var result = new PositionState[source.Length];

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

        return result;
    }

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

            if(state.Object.IsValid())
                state.Object.WorldPosition = state.Position;
        }
    }
}