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