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.
#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;
}
}
}