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