🔍 s&box Package Code Search
Search C# source code, UI razor templates, shaders, and configs across s&box packages.
🔗 Raw Facepunch API Link: https://public.facepunch.com/sbox/code/search/1/?ident=mikekotys.blender_actions&take=20
Showing code results for query:
*
(13 total matches found)
Editor
library
#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;
}
}
}
Editor
library
#nullable enable
using Editor;
using Sandbox;
using System;
using System.Collections.Generic;
namespace BlenderActions;
/// <summary>Temporarily suppresses viewport selection and context-menu input during modal operations.</summary>
internal sealed class ViewportInputLock : IDisposable
{
/// <summary>Handles new.</summary>
private static readonly HashSet<ViewportInputLock> ActiveLocks = new();
/// <summary>References the scene view bound to the active operation.</summary>
private readonly SceneViewWidget _sceneView;
/// <summary>References the scene viewport bound to the active operation.</summary>
private readonly SceneViewportWidget _viewport;
/// <summary>References the optional tool.</summary>
private readonly EditorTool? _tool;
/// <summary>References the optional sub tool.</summary>
private readonly EditorTool? _subTool;
/// <summary>Tracks whether scene read only.</summary>
private readonly bool _sceneReadOnly;
/// <summary>Tracks whether scene context menu.</summary>
private readonly bool _sceneContextMenu;
/// <summary>Tracks whether viewport read only.</summary>
private readonly bool _viewportReadOnly;
/// <summary>Tracks whether viewport context menu.</summary>
private readonly bool _viewportContextMenu;
/// <summary>Tracks whether tool selection.</summary>
private readonly bool _toolSelection;
/// <summary>Tracks whether tool context menu.</summary>
private readonly bool _toolContextMenu;
/// <summary>Tracks whether sub tool selection.</summary>
private readonly bool _subToolSelection;
/// <summary>Tracks whether sub tool context menu.</summary>
private readonly bool _subToolContextMenu;
/// <summary>Tracks whether this input lock has already restored its state.</summary>
private bool _disposed;
/// <summary>Initializes a new viewport input lock instance.</summary>
public ViewportInputLock(
SceneViewWidget sceneView,
SceneViewportWidget viewport,
EditorTool? tool,
EditorTool? subTool)
{
_sceneView = sceneView;
_viewport = viewport;
_tool = tool;
_subTool = subTool;
_sceneReadOnly = sceneView.ReadOnly;
_sceneContextMenu = sceneView.ContextMenuEnabled;
_viewportReadOnly = viewport.ReadOnly;
_viewportContextMenu = viewport.ContextMenuEnabled;
sceneView.ReadOnly = true;
sceneView.ContextMenuEnabled = false;
viewport.ReadOnly = true;
viewport.ContextMenuEnabled = false;
if(tool != null)
{
_toolSelection = tool.AllowGameObjectSelection;
_toolContextMenu = tool.AllowContextMenu;
tool.AllowGameObjectSelection = false;
tool.AllowContextMenu = false;
}
if(subTool != null && subTool != tool)
{
_subToolSelection = subTool.AllowGameObjectSelection;
_subToolContextMenu = subTool.AllowContextMenu;
subTool.AllowGameObjectSelection = false;
subTool.AllowContextMenu = false;
}
ActiveLocks.Add(this);
}
/// <summary>Restores captured viewport and tool input state exactly once.</summary>
public void Dispose()
{
if(_disposed)
return;
_disposed = true;
ActiveLocks.Remove(this);
Exception? restoreError = null;
Restore(() =>
{
if(_sceneView.IsValid)
{
_sceneView.ReadOnly = _sceneReadOnly;
_sceneView.ContextMenuEnabled = _sceneContextMenu;
}
}, ref restoreError);
Restore(() =>
{
if(_viewport.IsValid)
{
_viewport.ReadOnly = _viewportReadOnly;
_viewport.ContextMenuEnabled = _viewportContextMenu;
}
}, ref restoreError);
Restore(() =>
{
if(_tool != null)
{
_tool.AllowGameObjectSelection = _toolSelection;
_tool.AllowContextMenu = _toolContextMenu;
}
}, ref restoreError);
Restore(() =>
{
if(_subTool != null && _subTool != _tool)
{
_subTool.AllowGameObjectSelection = _subToolSelection;
_subTool.AllowContextMenu = _subToolContextMenu;
}
}, ref restoreError);
if(restoreError != null)
throw restoreError;
}
/// <summary>Restores every active input lock during editor hotload.</summary>
[EditorEvent.Hotload]
private static void RestoreAll()
{
var locks = new ViewportInputLock[ActiveLocks.Count];
ActiveLocks.CopyTo(locks);
foreach(var inputLock in locks)
{
try
{
inputLock.Dispose();
}
catch
{
}
}
ActiveLocks.Clear();
}
/// <summary>Executes one restoration step while retaining the first failure.</summary>
private static void Restore(Action action, ref Exception? firstError)
{
try
{
action();
}
catch(Exception exception)
{
firstError ??= exception;
}
}
}
Editor
library
#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 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;
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;
}
_rotationAxis = 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
};
}
/// <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;
}
}
}
Editor
library
#nullable enable
using Editor;
namespace BlenderActions;
/// <summary>Registers viewport shortcuts for transform operations and axis constraints.</summary>
public static class TransformShortcuts
{
/// <summary>Starts a modal translation operation.</summary>
[Shortcut("blender_actions.translate", "U", typeof(SceneViewportWidget), ShortcutType.Widget)]
private static void Translate()
{
ModalOperationArbiter.Start(new TranslateOperation());
}
/// <summary>Starts a modal rotation operation.</summary>
[Shortcut("blender_actions.rotate", "R", typeof(SceneViewportWidget), ShortcutType.Widget)]
private static void Rotate()
{
ModalOperationArbiter.Start(new RotateOperation());
}
/// <summary>Starts a modal scale operation.</summary>
[Shortcut("blender_actions.scale", "S", typeof(SceneViewportWidget), ShortcutType.Widget)]
private static void Scale()
{
ModalOperationArbiter.Start(new ScaleOperation());
}
/// <summary>Constrains the active operation to the world X axis.</summary>
[Shortcut("blender_actions.constraint_x", "X", typeof(SceneViewportWidget), ShortcutType.Widget)]
private static void X()
{
ModalOperationArbiter.SetConstraint(AxisConstraint.X);
}
/// <summary>Constrains the active operation to the world Y axis.</summary>
[Shortcut("blender_actions.constraint_y", "Y", typeof(SceneViewportWidget), ShortcutType.Widget)]
private static void Y()
{
ModalOperationArbiter.SetConstraint(AxisConstraint.Y);
}
/// <summary>Constrains the active operation to the world Z axis.</summary>
[Shortcut("blender_actions.constraint_z", "Z", typeof(SceneViewportWidget), ShortcutType.Widget)]
private static void Z()
{
ModalOperationArbiter.SetConstraint(AxisConstraint.Z);
}
/// <summary>Constrains the active operation to the world YZ plane.</summary>
[Shortcut("blender_actions.constraint_yz", "SHIFT+X", typeof(SceneViewportWidget), ShortcutType.Widget)]
private static void YZ()
{
ModalOperationArbiter.SetConstraint(AxisConstraint.YZ);
}
/// <summary>Constrains the active operation to the world XZ plane.</summary>
[Shortcut("blender_actions.constraint_xz", "SHIFT+Y", typeof(SceneViewportWidget), ShortcutType.Widget)]
private static void XZ()
{
ModalOperationArbiter.SetConstraint(AxisConstraint.XZ);
}
/// <summary>Constrains the active operation to the world XY plane.</summary>
[Shortcut("blender_actions.constraint_xy", "SHIFT+Z", typeof(SceneViewportWidget), ShortcutType.Widget)]
private static void XY()
{
ModalOperationArbiter.SetConstraint(AxisConstraint.XY);
}
/// <summary>Cancels the active operation for the current editor session.</summary>
[Shortcut("blender_actions.cancel", "ESCAPE", typeof(SceneViewportWidget), ShortcutType.Widget)]
private static void Cancel()
{
ModalOperationArbiter.Cancel();
}
}
Game
library
global using static Sandbox.Internal.GlobalGameNamespace;
global using Microsoft.AspNetCore.Components;
global using Microsoft.AspNetCore.Components.Rendering;
[assembly: global::System.Reflection.AssemblyMetadata( "AddonTitle", "Blender Actions" )]
[assembly: global::System.Reflection.AssemblyMetadata( "AddonIdent", "blender_actions" )]
[assembly: global::System.Reflection.AssemblyMetadata( "OrgIdent", "mikekotys" )]
[assembly: global::System.Reflection.AssemblyMetadata( "Ident", "mikekotys.blender_actions" )]
[assembly: global::System.Reflection.AssemblyMetadata( "EngineVersion", "28" )]
[assembly: global::System.Reflection.AssemblyMetadata( "EngineMinorVersion", "1" )]
[assembly: System.Runtime.Versioning.TargetFramework( ".NETCoreApp,Version=v9.0", FrameworkDisplayName = ".NET 9.0" )]
[assembly: global::System.Reflection.AssemblyMetadata( "CompileTime", "2026-08-12T17:40:59.9712817Z" )]
[assembly: global::System.Reflection.AssemblyVersion("0.0.113.0")]
[assembly: global::System.Reflection.AssemblyFileVersion("0.0.113.0")]
Editor
library
#nullable enable
using Editor;
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
namespace BlenderActions;
/// <summary>Defines world-axis and world-plane transform constraints.</summary>
[Flags]
public enum AxisConstraint
{
None = 0,
X = 1,
Y = 2,
Z = 4,
XY = X | Y,
XZ = X | Z,
YZ = Y | Z
}
/// <summary>Identifies the supported modal transform operation types.</summary>
public enum TransformOperationKind
{
Translate,
Rotate,
Scale
}
/// <summary>Provides the shared lifecycle, input handling, and cleanup for modal transforms.</summary>
public abstract class ModalTransformOperation
{
/// <summary>Defines the frame delay that prevents the completing click from reaching default viewport input.</summary>
private const int PostClickGuardFrames = 2;
/// <summary>Defines pointer-motion scaling while the precision modifier is held.</summary>
protected const float PrecisionMultiplier = 0.1f;
/// <summary>Owns temporary viewport input suppression for this operation.</summary>
private ViewportInputLock? _inputLock;
/// <summary>References the scene view bound to the active operation.</summary>
private SceneViewWidget? _sceneView;
/// <summary>References the scene viewport bound to the active operation.</summary>
private SceneViewportWidget? _viewport;
/// <summary>References the editor session bound to the active operation.</summary>
private SceneEditorSession? _session;
/// <summary>References the editor camera bound to the active operation.</summary>
private CameraComponent? _camera;
/// <summary>Handles selection snapshot.</summary>
private GameObject[] _selectionSnapshot = Array.Empty<GameObject>();
/// <summary>Tracks whether the operation is waiting to finish.</summary>
private bool _finishRequested;
/// <summary>Tracks whether numeric or modal confirmation has been requested.</summary>
private bool _confirmRequested;
/// <summary>Tracks whether the modal operation has already terminated.</summary>
private bool _finished;
/// <summary>Stores remaining frames used to guard the confirming or cancelling click.</summary>
private int _finishDelayFrames;
/// <summary>Gets the bound scene view or throws when unavailable.</summary>
protected SceneViewWidget SceneView =>
_sceneView ?? throw new InvalidOperationException("No active Scene View.");
/// <summary>Gets the bound scene viewport or throws when unavailable.</summary>
protected SceneViewportWidget Viewport =>
_viewport ?? throw new InvalidOperationException("No active Scene Viewport.");
/// <summary>Gets the bound scene editor session or throws when unavailable.</summary>
protected SceneEditorSession Session =>
_session ?? throw new InvalidOperationException("No active Scene Editor session.");
/// <summary>Gets the bound editor camera or throws when unavailable.</summary>
protected CameraComponent Camera =>
_camera ?? throw new InvalidOperationException("No active Scene camera.");
/// <summary>Handles selected objects.</summary>
protected GameObject[] SelectedObjects { get; private set; } = Array.Empty<GameObject>();
/// <summary>Handles new.</summary>
protected internal NumericInputSession NumericInput { get; } = new();
/// <summary>Gets the active world-axis or world-plane constraint.</summary>
protected AxisConstraint Constraint { get; private set; }
/// <summary>Gets the editor session currently bound to this operation.</summary>
internal SceneEditorSession? BoundSession => _session;
/// <summary>Gets the operation kind.</summary>
public abstract TransformOperationKind Kind { get; }
/// <summary>Binds the operation to the active scene context and starts its modal lifecycle.</summary>
internal bool Begin(SceneEditorSession expectedSession)
{
var sceneView = SceneViewWidget.Current;
var viewport = sceneView?.LastSelectedViewportWidget;
var session = SceneEditorSession.Active;
if(sceneView == null ||
viewport == null ||
session == null ||
session.IsPlaying ||
!ReferenceEquals(session, expectedSession))
{
return false;
}
var activeTool = sceneView.Tools.CurrentTool;
var activeSubTool = sceneView.Tools.CurrentSubTool;
var camera = activeSubTool?.Camera ?? activeTool?.Camera;
if(camera == null)
return false;
_sceneView = sceneView;
_viewport = viewport;
_session = session;
_camera = camera;
_selectionSnapshot = session.GetSelection()
.OfType<GameObject>()
.Where(gameObject => gameObject.IsValid())
.ToArray();
var selectedSet = _selectionSnapshot.ToHashSet();
SelectedObjects = _selectionSnapshot
.Where(gameObject => !HasSelectedAncestor(gameObject, selectedSet))
.ToArray();
if(SelectedObjects.Length == 0)
{
ClearContext();
return false;
}
Constraint = AxisConstraint.None;
try
{
OnBegin();
_inputLock = new ViewportInputLock(
SceneView,
Viewport,
activeTool,
activeSubTool);
SceneView.MouseClick += RequestConfirm;
SceneView.MouseRightClick += RequestCancel;
NumericInput.Begin();
return true;
}
catch
{
Cleanup();
throw;
}
}
/// <summary>Advances input, completion, and operation-specific update logic.</summary>
internal void Tick()
{
if(_finished)
return;
if(!HasValidContext())
{
Abort();
return;
}
if(NumericInput.ConsumeConfirmRequest())
RequestConfirm();
if(_finishRequested)
{
if(_finishDelayFrames > 0)
{
_finishDelayFrames--;
return;
}
Finish(_confirmRequested);
return;
}
try
{
OnUpdate();
}
catch
{
Abort();
throw;
}
}
/// <summary>Applies or toggles an axis constraint on the active operation.</summary>
internal void SetConstraint(AxisConstraint constraint)
{
if(_finished || _finishRequested)
return;
Constraint = Constraint == constraint
? AxisConstraint.None
: constraint;
OnConstraintChanged();
}
/// <summary>Queues confirmation after the viewport click guard interval.</summary>
internal void RequestConfirm()
{
if(_finished || _finishRequested)
return;
_confirmRequested = true;
_finishRequested = true;
_finishDelayFrames = PostClickGuardFrames;
}
/// <summary>Restores initial state and queues cancellation after the click guard interval.</summary>
internal void RequestCancel()
{
if(_finished || _finishRequested)
return;
RestoreInitialState();
_confirmRequested = false;
_finishRequested = true;
_finishDelayFrames = PostClickGuardFrames;
}
/// <summary>Immediately restores initial state and terminates the operation.</summary>
internal void Abort()
{
if(_finished)
return;
try
{
RestoreInitialState();
}
finally
{
Finish(false);
}
}
/// <summary>Captures operation-specific initial state.</summary>
protected abstract void OnBegin();
/// <summary>Updates the operation from current editor input.</summary>
protected abstract void OnUpdate();
/// <summary>Restores every transformed object to its captured initial state.</summary>
protected abstract void RestoreInitialState();
/// <summary>Registers undo and redo callbacks for the completed operation.</summary>
protected abstract void RegisterUndo();
/// <summary>Resets operation-specific state after the active constraint changes.</summary>
protected virtual void OnConstraintChanged() { }
/// <summary>Releases operation-specific state during cleanup.</summary>
protected virtual void OnCleanup() { }
/// <summary>Restores the scene selection captured when the operation began.</summary>
protected void RestoreSelection()
{
var session = _session;
if(session == null)
return;
session.Selection.Clear();
foreach(var gameObject in _selectionSnapshot)
{
if(gameObject.IsValid())
session.Selection.Add(gameObject);
}
}
/// <summary>Converts camera-render pixels to viewport input pixels.</summary>
protected Vector2 CameraPixelsToInputPixels(Vector2 cameraPixels)
{
var renderSize = Camera.CustomSize;
var inputSize = Viewport.Size * Viewport.DpiScale;
if(!renderSize.HasValue || renderSize.Value.x <= 0f || renderSize.Value.y <= 0f)
return cameraPixels;
return new Vector2(
cameraPixels.x * inputSize.x / renderSize.Value.x,
cameraPixels.y * inputSize.y / renderSize.Value.y);
}
/// <summary>Converts viewport input pixels to camera-render pixels.</summary>
protected Vector2 InputPixelsToCameraPixels(Vector2 inputPixels)
{
var renderSize = Camera.CustomSize;
var inputSize = Viewport.Size * Viewport.DpiScale;
if(!renderSize.HasValue || inputSize.x <= 0f || inputSize.y <= 0f)
return inputPixels;
return new Vector2(
inputPixels.x * renderSize.Value.x / inputSize.x,
inputPixels.y * renderSize.Value.y / inputSize.y);
}
/// <summary>Returns whether a selected ancestor already represents this object.</summary>
private static bool HasSelectedAncestor(
GameObject gameObject,
HashSet<GameObject> selected)
{
var parent = gameObject.Parent;
while(parent != null)
{
if(selected.Contains(parent))
return true;
parent = parent.Parent;
}
return false;
}
/// <summary>Returns whether the bound scene, viewport, session, and camera remain valid.</summary>
private bool HasValidContext()
{
return _sceneView != null &&
_sceneView.IsValid &&
_viewport != null &&
_viewport.IsValid &&
_session != null &&
!_session.IsPlaying &&
SceneEditorSession.Active == _session &&
_camera != null &&
_camera.GameObject != null &&
_camera.GameObject.IsValid();
}
/// <summary>Commits or cancels the operation and always releases modal resources.</summary>
private void Finish(bool confirmed)
{
if(_finished)
return;
_finished = true;
try
{
if(confirmed)
{
try
{
RegisterUndo();
Session.HasUnsavedChanges = true;
}
catch
{
RestoreInitialState();
throw;
}
}
RestoreSelection();
}
finally
{
ModalOperationArbiter.Release(this);
Cleanup();
}
}
/// <summary>Unsubscribes input handlers, restores viewport state, and clears context.</summary>
private void Cleanup()
{
Exception? cleanupError = null;
try
{
if(_sceneView != null)
{
_sceneView.MouseClick -= RequestConfirm;
_sceneView.MouseRightClick -= RequestCancel;
}
}
catch(Exception exception)
{
cleanupError ??= exception;
}
NumericInput.End();
try
{
_inputLock?.Dispose();
}
catch(Exception exception)
{
cleanupError ??= exception;
}
_inputLock = null;
try
{
OnCleanup();
}
catch(Exception exception)
{
cleanupError ??= exception;
}
ClearContext();
if(cleanupError != null)
throw cleanupError;
}
/// <summary>Clears references to the active editor context and selection.</summary>
private void ClearContext()
{
_sceneView = null;
_viewport = null;
_session = null;
_camera = null;
SelectedObjects = Array.Empty<GameObject>();
_selectionSnapshot = Array.Empty<GameObject>();
}
}
Editor
library
#nullable enable
using Editor;
using System.Globalization;
namespace BlenderActions;
/// <summary>Stores and parses numeric input for one modal transform operation.</summary>
public sealed class NumericInputSession
{
/// <summary>Stores buffered numeric input characters.</summary>
private string _text = string.Empty;
/// <summary>Tracks whether numeric or modal confirmation has been requested.</summary>
private bool _confirmRequested;
/// <summary>Gets whether this numeric input session is accepting input.</summary>
public bool IsActive { get; private set; }
/// <summary>Attempts to parse the buffered text as an invariant floating-point value.</summary>
public bool HasValue => TryGetValue(out _);
/// <summary>Handles is null or empty.</summary>
public string DisplayText => string.IsNullOrEmpty(_text) ? "0" : _text;
/// <summary>Binds the operation to the active scene context and starts its modal lifecycle.</summary>
public void Begin()
{
_text = string.Empty;
_confirmRequested = false;
IsActive = true;
}
/// <summary>Ends numeric input and clears its buffered state.</summary>
public void End()
{
_text = string.Empty;
_confirmRequested = false;
IsActive = false;
}
/// <summary>Attempts to parse the buffered text as an invariant floating-point value.</summary>
public bool TryGetValue(out float value)
{
if(!IsActive)
{
value = 0f;
return false;
}
return float.TryParse(
_text,
NumberStyles.Float,
CultureInfo.InvariantCulture,
out value);
}
/// <summary>Consumes and clears a pending numeric confirmation request.</summary>
public bool ConsumeConfirmRequest()
{
if(!_confirmRequested)
return false;
_confirmRequested = false;
return true;
}
/// <summary>Appends one digit while numeric input is active.</summary>
public void AppendDigit(char digit)
{
if(IsActive)
_text += digit;
}
/// <summary>Appends a decimal separator when one is not already present.</summary>
public void EnterDecimal()
{
if(!IsActive || _text.Contains('.'))
return;
_text = string.IsNullOrEmpty(_text)
? "0."
: _text == "-"
? "-0."
: _text + '.';
}
/// <summary>Toggles the sign of the buffered numeric value.</summary>
public void ToggleNegative()
{
if(!IsActive)
return;
_text = _text.StartsWith("-")
? _text[1..]
: "-" + _text;
}
/// <summary>Removes the last buffered numeric character.</summary>
public void Backspace()
{
if(IsActive && _text.Length > 0)
_text = _text[..^1];
}
/// <summary>Requests confirmation when the buffered numeric value is valid.</summary>
public void Confirm()
{
if(HasValue)
_confirmRequested = true;
}
}
/// <summary>Routes numeric keyboard shortcuts to the active modal operation.</summary>
public static class NumericInputShortcuts
{
/// <summary>Appends the digit zero to active numeric input.</summary>
[Shortcut("blender_actions.numeric_0", "0", typeof(SceneViewportWidget), ShortcutType.Widget)]
private static void Zero() => Append('0');
/// <summary>Appends the digit one to active numeric input.</summary>
[Shortcut("blender_actions.numeric_1", "1", typeof(SceneViewportWidget), ShortcutType.Widget)]
private static void One() => Append('1');
/// <summary>Appends the digit two to active numeric input.</summary>
[Shortcut("blender_actions.numeric_2", "2", typeof(SceneViewportWidget), ShortcutType.Widget)]
private static void Two() => Append('2');
/// <summary>Appends the digit three to active numeric input.</summary>
[Shortcut("blender_actions.numeric_3", "3", typeof(SceneViewportWidget), ShortcutType.Widget)]
private static void Three() => Append('3');
/// <summary>Appends the digit four to active numeric input.</summary>
[Shortcut("blender_actions.numeric_4", "4", typeof(SceneViewportWidget), ShortcutType.Widget)]
private static void Four() => Append('4');
/// <summary>Appends the digit five to active numeric input.</summary>
[Shortcut("blender_actions.numeric_5", "5", typeof(SceneViewportWidget), ShortcutType.Widget)]
private static void Five() => Append('5');
/// <summary>Appends the digit six to active numeric input.</summary>
[Shortcut("blender_actions.numeric_6", "6", typeof(SceneViewportWidget), ShortcutType.Widget)]
private static void Six() => Append('6');
/// <summary>Appends the digit seven to active numeric input.</summary>
[Shortcut("blender_actions.numeric_7", "7", typeof(SceneViewportWidget), ShortcutType.Widget)]
private static void Seven() => Append('7');
/// <summary>Appends the digit eight to active numeric input.</summary>
[Shortcut("blender_actions.numeric_8", "8", typeof(SceneViewportWidget), ShortcutType.Widget)]
private static void Eight() => Append('8');
/// <summary>Appends the digit nine to active numeric input.</summary>
[Shortcut("blender_actions.numeric_9", "9", typeof(SceneViewportWidget), ShortcutType.Widget)]
private static void Nine() => Append('9');
/// <summary>Routes decimal input to the active numeric session.</summary>
[Shortcut("blender_actions.numeric_decimal", ".", typeof(SceneViewportWidget), ShortcutType.Widget)]
private static void Decimal() => ModalOperationArbiter.NumericInput?.EnterDecimal();
/// <summary>Routes sign toggling to the active numeric session.</summary>
[Shortcut("blender_actions.numeric_negative", "-", typeof(SceneViewportWidget), ShortcutType.Widget)]
private static void Negative() => ModalOperationArbiter.NumericInput?.ToggleNegative();
/// <summary>Removes the last buffered numeric character.</summary>
[Shortcut("blender_actions.numeric_backspace", "BACKSPACE", typeof(SceneViewportWidget), ShortcutType.Widget)]
private static void Backspace() => ModalOperationArbiter.NumericInput?.Backspace();
/// <summary>Requests confirmation when the buffered numeric value is valid.</summary>
[Shortcut("blender_actions.numeric_confirm", "ENTER", typeof(SceneViewportWidget), ShortcutType.Widget)]
private static void Confirm() => ModalOperationArbiter.NumericInput?.Confirm();
/// <summary>Routes a digit to the active numeric session.</summary>
private static void Append(char digit)
{
ModalOperationArbiter.NumericInput?.AppendDigit(digit);
}
}
Editor
library
#nullable enable
using Editor;
using Sandbox;
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
namespace BlenderActions;
/// <summary>Represents the result of locating a target vertex.</summary>
public readonly record struct VertexSnapResult(bool Found, Vector3 Vertex);
/// <summary>Stores reusable world-space source vertices for vertex snapping.</summary>
public sealed class VertexSnapSource
{
/// <summary>Handles new.</summary>
internal List<Vector3> Vertices { get; } = new(4096);
/// <summary>Handles new.</summary>
internal HashSet<ModelRenderer> VisitedRenderers { get; } = new();
/// <summary>Clears reusable source-vertex collections.</summary>
internal void Clear()
{
Vertices.Clear();
VisitedRenderers.Clear();
}
}
/// <summary>Provides cached screen-space vertex snapping for modal transforms.</summary>
public static class VertexSnapService
{
/// <summary>Defines the maximum world distance used to trace a target renderer.</summary>
private const float TraceLength = 100000f;
/// <summary>Defines the logical screen-space radius used to acquire target vertices.</summary>
private const float SnapRadiusPixels = 16f;
/// <summary>Defines the projected-vertex spatial hash cell size in pixels.</summary>
private const float ProjectionCellSize = 32f;
/// <summary>Defines local-vertex quantization used for model vertex deduplication.</summary>
private const float Quantization = 10000f;
/// <summary>Handles new.</summary>
private static ConditionalWeakTable<Model, CachedVertices> _vertexCache = new();
/// <summary>Handles new.</summary>
private static ConditionalWeakTable<ModelRenderer, ProjectedVertexIndex> _targetIndices = new();
/// <summary>Captures current world-space vertices from selected model renderers.</summary>
public static void CaptureSourceSnapshot(
VertexSnapSource destination,
IReadOnlyCollection<GameObject> selectedObjects)
{
destination.Clear();
foreach(var selectedObject in selectedObjects)
{
if(!selectedObject.IsValid())
continue;
foreach(var renderer in selectedObject.GetComponentsInChildren<ModelRenderer>(
includeDisabled: true,
includeSelf: true))
{
if(renderer == null ||
!destination.VisitedRenderers.Add(renderer) ||
renderer.Model == null ||
!renderer.Model.IsValid)
{
continue;
}
AppendWorldVertices(renderer, destination.Vertices);
}
}
}
/// <summary>Finds the nearest target vertex under the pointer on the traced renderer.</summary>
public static VertexSnapResult FindTargetVertex(
Scene scene,
CameraComponent camera,
SceneViewportWidget viewport,
IReadOnlyCollection<GameObject> ignoredObjects)
{
var mousePosition = SceneViewportWidget.MousePosition;
var ray = camera.ScreenPixelToRay(mousePosition);
var trace = scene.Trace
.Ray(ray, TraceLength)
.UseRenderMeshes(true, true)
.UseHitPosition(true);
foreach(var gameObject in ignoredObjects)
{
if(gameObject.IsValid())
trace = trace.IgnoreGameObjectHierarchy(gameObject);
}
var hit = trace.Run();
if(!hit.Hit || hit.GameObject == null)
return default;
var renderer = hit.Component as ModelRenderer ??
hit.GameObject.GetComponent<ModelRenderer>(true);
if(renderer == null || renderer.Model == null || !renderer.Model.IsValid)
return default;
var threshold = SnapRadiusPixels * MathF.Max(viewport.DpiScale, 1f);
var index = _targetIndices.GetValue(renderer, _ => new ProjectedVertexIndex());
index.Update(renderer, camera);
return index.TryFindNearest(mousePosition, threshold, out var vertex)
? new VertexSnapResult(true, vertex)
: default;
}
/// <summary>Finds the source vertex closest on screen after translation.</summary>
public static bool TryFindClosestTranslatedSource(
VertexSnapSource source,
CameraComponent camera,
Vector3 target,
Vector3 translation,
out Vector3 sourceVertex)
{
return TryFindClosestSource(
source,
camera,
target,
SourceTransform.Translate,
translation,
Vector3.Zero,
Rotation.Identity,
Vector3.One,
out sourceVertex);
}
/// <summary>Finds the source vertex closest on screen after rotation.</summary>
public static bool TryFindClosestRotatedSource(
VertexSnapSource source,
CameraComponent camera,
Vector3 target,
Vector3 pivot,
Rotation rotation,
out Vector3 sourceVertex)
{
return TryFindClosestSource(
source,
camera,
target,
SourceTransform.Rotate,
Vector3.Zero,
pivot,
rotation,
Vector3.One,
out sourceVertex);
}
/// <summary>Finds the source vertex closest on screen after scaling.</summary>
public static bool TryFindClosestScaledSource(
VertexSnapSource source,
CameraComponent camera,
Vector3 target,
Vector3 pivot,
Vector3 multiplier,
out Vector3 sourceVertex)
{
return TryFindClosestSource(
source,
camera,
target,
SourceTransform.Scale,
Vector3.Zero,
pivot,
Rotation.Identity,
multiplier,
out sourceVertex);
}
/// <summary>Clears model and projected-vertex caches after hotload.</summary>
[EditorEvent.Hotload]
private static void ClearCaches()
{
_vertexCache = new ConditionalWeakTable<Model, CachedVertices>();
_targetIndices = new ConditionalWeakTable<ModelRenderer, ProjectedVertexIndex>();
}
/// <summary>Finds the screen-space closest source vertex after a supplied transform.</summary>
private static bool TryFindClosestSource(
VertexSnapSource source,
CameraComponent camera,
Vector3 target,
SourceTransform transform,
Vector3 translation,
Vector3 pivot,
Rotation rotation,
Vector3 multiplier,
out Vector3 sourceVertex)
{
sourceVertex = Vector3.Zero;
if(source.Vertices.Count == 0)
return false;
var targetScreen = camera.PointToScreenPixels(target, out var targetBehind);
if(targetBehind)
return false;
var bestDistance = float.MaxValue;
var found = false;
for(var index = 0; index < source.Vertices.Count; index++)
{
var original = source.Vertices[index];
var candidate = transform switch
{
SourceTransform.Translate => original + translation,
SourceTransform.Rotate => pivot + rotation * (original - pivot),
SourceTransform.Scale => pivot + (original - pivot).MultiplyComponents(multiplier),
_ => original
};
var screen = camera.PointToScreenPixels(candidate, out var isBehind);
if(isBehind)
continue;
var distance = (screen - targetScreen).Length;
if(distance >= bestDistance)
continue;
bestDistance = distance;
sourceVertex = candidate;
found = true;
}
return found;
}
/// <summary>Appends one renderer's transformed model vertices to a reusable destination.</summary>
private static void AppendWorldVertices(
ModelRenderer renderer,
List<Vector3> destination)
{
var vertices = GetVertices(renderer.Model);
for(var index = 0; index < vertices.Length; index++)
destination.Add(ToWorld(renderer.GameObject, vertices[index]));
}
/// <summary>Transforms a local model vertex into world space.</summary>
private static Vector3 ToWorld(GameObject gameObject, Vector3 localVertex)
{
var scaled = localVertex.MultiplyComponents(gameObject.WorldScale);
return gameObject.WorldPosition + gameObject.WorldRotation * scaled;
}
/// <summary>Returns cached deduplicated local-space vertices for a model.</summary>
private static Vector3[] GetVertices(Model model)
{
return _vertexCache.GetValue(model, CreateCache).Vertices;
}
/// <summary>Creates a deduplicated local-space vertex cache for a model.</summary>
private static CachedVertices CreateCache(Model model)
{
var unique = new Dictionary<QuantizedVertex, Vector3>();
foreach(var vertex in model.GetVertices())
{
var position = vertex.Position;
var key = new QuantizedVertex(
(int)MathF.Round(position.x * Quantization),
(int)MathF.Round(position.y * Quantization),
(int)MathF.Round(position.z * Quantization));
if(!unique.ContainsKey(key))
unique.Add(key, position);
}
var vertices = new Vector3[unique.Count];
unique.Values.CopyTo(vertices, 0);
return new CachedVertices(vertices);
}
/// <summary>Combines two screen-space cell coordinates into one dictionary key.</summary>
private static long CellKey(int x, int y)
{
return ((long)x << 32) ^ (uint)y;
}
/// <summary>Identifies the transform applied while evaluating source vertices.</summary>
private enum SourceTransform
{
Translate,
Rotate,
Scale
}
/// <summary>Stores deduplicated local-space vertices for one model.</summary>
private sealed class CachedVertices
{
/// <summary>Initializes a new cached vertices instance.</summary>
public CachedVertices(Vector3[] vertices)
{
Vertices = vertices;
}
/// <summary>Gets the reusable captured world-space vertex list.</summary>
public Vector3[] Vertices { get; }
}
/// <summary>Indexes one renderer's projected vertices in screen-space cells.</summary>
private sealed class ProjectedVertexIndex
{
/// <summary>Handles new.</summary>
private readonly Dictionary<long, List<ProjectedVertex>> _cells = new();
/// <summary>Handles new.</summary>
private readonly Stack<List<ProjectedVertex>> _bucketPool = new();
/// <summary>Stores the model represented by the current projected index.</summary>
private Model? _model;
/// <summary>Stores the indexed renderer world position.</summary>
private Vector3 _objectPosition;
/// <summary>Stores the indexed renderer world rotation.</summary>
private Rotation _objectRotation;
/// <summary>Stores the indexed renderer world scale.</summary>
private Vector3 _objectScale;
/// <summary>Stores the camera position used to build the index.</summary>
private Vector3 _cameraPosition;
/// <summary>Stores the camera rotation used to build the index.</summary>
private Rotation _cameraRotation;
/// <summary>Stores the camera render size used to build the index.</summary>
private Vector2? _cameraSize;
/// <summary>Stores the perspective field of view used to build the index.</summary>
private float _fieldOfView;
/// <summary>Stores the orthographic height used to build the index.</summary>
private float _orthographicHeight;
/// <summary>Tracks whether the indexed camera uses orthographic projection.</summary>
private bool _orthographic;
/// <summary>Rebuilds the projected vertex index when renderer or camera state changes.</summary>
public void Update(ModelRenderer renderer, CameraComponent camera)
{
var gameObject = renderer.GameObject;
var cameraObject = camera.GameObject;
if(ReferenceEquals(_model, renderer.Model) &&
_objectPosition.Equals(gameObject.WorldPosition) &&
_objectRotation.Equals(gameObject.WorldRotation) &&
_objectScale.Equals(gameObject.WorldScale) &&
_cameraPosition.Equals(cameraObject.WorldPosition) &&
_cameraRotation.Equals(cameraObject.WorldRotation) &&
_cameraSize.Equals(camera.CustomSize) &&
_fieldOfView.Equals(camera.FieldOfView) &&
_orthographicHeight.Equals(camera.OrthographicHeight) &&
_orthographic == camera.Orthographic)
{
return;
}
RecycleCells();
_model = renderer.Model;
_objectPosition = gameObject.WorldPosition;
_objectRotation = gameObject.WorldRotation;
_objectScale = gameObject.WorldScale;
_cameraPosition = cameraObject.WorldPosition;
_cameraRotation = cameraObject.WorldRotation;
_cameraSize = camera.CustomSize;
_fieldOfView = camera.FieldOfView;
_orthographicHeight = camera.OrthographicHeight;
_orthographic = camera.Orthographic;
var vertices = GetVertices(renderer.Model);
for(var index = 0; index < vertices.Length; index++)
{
var world = ToWorld(gameObject, vertices[index]);
var screen = camera.PointToScreenPixels(world, out var isBehind);
if(isBehind)
continue;
var cellX = (int)MathF.Floor(screen.x / ProjectionCellSize);
var cellY = (int)MathF.Floor(screen.y / ProjectionCellSize);
var key = CellKey(cellX, cellY);
if(!_cells.TryGetValue(key, out var bucket))
{
bucket = _bucketPool.Count > 0
? _bucketPool.Pop()
: new List<ProjectedVertex>();
_cells.Add(key, bucket);
}
bucket.Add(new ProjectedVertex(screen, world));
}
}
/// <summary>Finds the nearest indexed vertex within a screen-space radius.</summary>
public bool TryFindNearest(
Vector2 screenPosition,
float radius,
out Vector3 worldVertex)
{
worldVertex = Vector3.Zero;
var centerX = (int)MathF.Floor(screenPosition.x / ProjectionCellSize);
var centerY = (int)MathF.Floor(screenPosition.y / ProjectionCellSize);
var cellRadius = Math.Max(1, (int)MathF.Ceiling(radius / ProjectionCellSize));
var bestSquaredDistance = radius * radius;
var found = false;
for(var x = centerX - cellRadius; x <= centerX + cellRadius; x++)
{
for(var y = centerY - cellRadius; y <= centerY + cellRadius; y++)
{
if(!_cells.TryGetValue(CellKey(x, y), out var bucket))
continue;
for(var index = 0; index < bucket.Count; index++)
{
var candidate = bucket[index];
var delta = candidate.Screen - screenPosition;
var squaredDistance = delta.x * delta.x + delta.y * delta.y;
if(squaredDistance > bestSquaredDistance)
continue;
bestSquaredDistance = squaredDistance;
worldVertex = candidate.World;
found = true;
}
}
}
return found;
}
/// <summary>Clears projected cells and returns their lists to the bucket pool.</summary>
private void RecycleCells()
{
foreach(var bucket in _cells.Values)
{
bucket.Clear();
_bucketPool.Push(bucket);
}
_cells.Clear();
}
}
/// <summary>Pairs a projected screen position with its world-space vertex.</summary>
private readonly record struct ProjectedVertex(Vector2 Screen, Vector3 World);
/// <summary>Provides a quantized key for deduplicating model vertices.</summary>
private readonly record struct QuantizedVertex(int X, int Y, int Z);
}
Editor
library
#nullable enable
using Editor;
using Sandbox;
using System;
using System.Reflection;
using System.Runtime.CompilerServices;
namespace BlenderActions;
/// <summary>Stores, positions, and renders the per-session Blender-style 3D cursor.</summary>
public static class ThreeDCursor
{
/// <summary>Handles new.</summary>
private static readonly CursorGizmoBridge GizmoBridge = new();
/// <summary>Handles new.</summary>
private static ConditionalWeakTable<SceneEditorSession, CursorState> _states = new();
/// <summary>Returns state for the active editor session and optionally creates it.</summary>
public static Vector3 Position => GetCurrentState(false)?.Position ?? Vector3.Zero;
/// <summary>Returns state for the active editor session and optionally creates it.</summary>
public static bool UseAsTransformPivot => GetCurrentState(false)?.UseAsTransformPivot ?? false;
/// <summary>Resets the active session cursor to the world origin.</summary>
[Shortcut("blender_actions.cursor_reset", "SHIFT+C", typeof(SceneViewportWidget), ShortcutType.Widget)]
private static void ResetPosition()
{
var state = GetCurrentState(true);
if(state != null)
state.Position = Vector3.Zero;
}
/// <summary>Toggles use of the 3D cursor as the transform pivot.</summary>
[Shortcut("blender_actions.cursor_toggle_pivot", "ALT+C", typeof(SceneViewportWidget), ShortcutType.Widget)]
private static void ToggleTransformPivot()
{
var state = GetCurrentState(true);
if(state != null)
state.UseAsTransformPivot = !state.UseAsTransformPivot;
}
/// <summary>Advances active modal operations once per editor tool frame.</summary>
[Event("tool.frame")]
private static void OnToolFrame()
{
CheckSetCursorChord();
DrawCursor();
}
/// <summary>Aborts active operations and resets session state after hotload.</summary>
[EditorEvent.Hotload]
private static void OnHotload()
{
_states = new ConditionalWeakTable<SceneEditorSession, CursorState>();
}
/// <summary>Detects the cursor-placement modifier chord without interfering with modal operations.</summary>
private static void CheckSetCursorChord()
{
var state = GetCurrentState(true);
if(state == null)
return;
var modifiers = Editor.Application.KeyboardModifiers;
var required =
KeyboardModifiers.Alt |
KeyboardModifiers.Ctrl |
KeyboardModifiers.Shift;
var chordDown = (modifiers & required) == required;
if(ModalOperationArbiter.Active != null)
{
state.SetCursorChordDown = chordDown;
return;
}
if(chordDown && !state.SetCursorChordDown)
SetAtNearestVertex(state);
state.SetCursorChordDown = chordDown;
}
/// <summary>Moves the cursor to the nearest target vertex under the pointer.</summary>
private static void SetAtNearestVertex(CursorState state)
{
var sceneView = SceneViewWidget.Current;
var viewport = sceneView?.LastSelectedViewportWidget;
var session = SceneEditorSession.Active;
var camera =
sceneView?.Tools.CurrentSubTool?.Camera ??
sceneView?.Tools.CurrentTool?.Camera;
if(viewport == null || session == null || camera == null)
return;
var result = VertexSnapService.FindTargetVertex(
session.Scene,
camera,
viewport,
Array.Empty<GameObject>());
if(result.Found)
state.Position = result.Vertex;
}
/// <summary>Draws the active session cursor in the current scene viewport.</summary>
private static void DrawCursor()
{
var state = GetCurrentState(false);
if(state == null)
return;
var sceneView = SceneViewWidget.Current;
var viewport = sceneView?.LastSelectedViewportWidget;
var camera =
sceneView?.Tools.CurrentSubTool?.Camera ??
sceneView?.Tools.CurrentTool?.Camera;
if(viewport == null || !viewport.IsValid || camera == null)
return;
GizmoBridge.Draw(viewport, camera, state.Position);
}
/// <summary>Returns state for the active editor session and optionally creates it.</summary>
private static CursorState? GetCurrentState(bool create)
{
var session = SceneEditorSession.Active;
if(session == null)
return null;
if(create)
return _states.GetValue(session, _ => new CursorState());
return _states.TryGetValue(session, out var state)
? state
: null;
}
/// <summary>Stores 3D cursor state associated with one editor session.</summary>
private sealed class CursorState
{
/// <summary>Gets the active editor session's 3D cursor position.</summary>
public Vector3 Position { get; set; }
/// <summary>Gets whether the active session uses the 3D cursor as transform pivot.</summary>
public bool UseAsTransformPivot { get; set; }
/// <summary>Gets set cursor chord down.</summary>
public bool SetCursorChordDown { get; set; }
}
/// <summary>Renders the 3D cursor through an isolated gizmo instance.</summary>
private sealed class CursorGizmoBridge
{
/// <summary>Handles new.</summary>
private readonly Gizmo.Instance _instance = new();
/// <summary>Handles typeof.</summary>
private readonly FieldInfo? _worldField = typeof(Gizmo.Instance).GetField(
"_world",
BindingFlags.Instance | BindingFlags.NonPublic);
/// <summary>Draws a camera-facing cursor ring through the isolated gizmo bridge.</summary>
public void Draw(
SceneViewportWidget viewport,
CameraComponent camera,
Vector3 position)
{
var world = viewport.GizmoInstance.World;
if(world == null || _worldField == null)
return;
if(_instance.World != world)
_worldField.SetValue(_instance, world);
_instance.Settings = viewport.GizmoInstance.Settings;
var toCamera = camera.GameObject.WorldPosition - position;
if(toCamera.Length < 0.001f)
return;
var rotation = Rotation.LookAt(toCamera.Normal);
var radius = MathF.Max(toCamera.Length * 0.015f, 2f);
using(_instance.Push())
using(Gizmo.Scope(
"blender-actions-3d-cursor",
position,
rotation,
1f))
{
Gizmo.Draw.LineCircle(0, radius);
}
}
}
}
Editor
library
#nullable enable
using Editor;
using Sandbox;
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
namespace BlenderActions;
/// <summary>Coordinates one active modal transform operation per editor session.</summary>
public static class ModalOperationArbiter
{
/// <summary>Handles new.</summary>
private static ConditionalWeakTable<SceneEditorSession, SessionState> _states = new();
/// <summary>Handles new.</summary>
private static readonly List<WeakReference<SessionState>> StateReferences = new();
/// <summary>Returns state for the active editor session and optionally creates it.</summary>
public static ModalTransformOperation? Active => GetCurrentState(false)?.Active;
/// <summary>Gets numeric input associated with the current modal operation.</summary>
public static NumericInputSession? NumericInput => Active?.NumericInput;
/// <summary>Starts an operation for the active editor session when no operation is already running.</summary>
public static void Start(ModalTransformOperation operation)
{
var session = SceneEditorSession.Active;
if(session == null)
return;
var state = GetState(session);
if(state.Active != null)
return;
state.Active = operation;
try
{
if(!operation.Begin(session))
state.Active = null;
}
catch
{
state.Active = null;
throw;
}
}
/// <summary>Applies or toggles an axis constraint on the active operation.</summary>
public static void SetConstraint(AxisConstraint constraint)
{
GetCurrentState(false)?.Active?.SetConstraint(constraint);
}
/// <summary>Cancels the active operation for the current editor session.</summary>
public static void Cancel()
{
GetCurrentState(false)?.Active?.Abort();
}
/// <summary>Releases an operation from its bound session state.</summary>
internal static void Release(ModalTransformOperation operation)
{
var session = operation.BoundSession;
if(session == null || !_states.TryGetValue(session, out var state))
return;
if(ReferenceEquals(state.Active, operation))
state.Active = null;
}
/// <summary>Advances active modal operations once per editor tool frame.</summary>
[Event("tool.frame")]
private static void OnToolFrame()
{
for(var index = StateReferences.Count - 1; index >= 0; index--)
{
if(!StateReferences[index].TryGetTarget(out var state))
{
StateReferences.RemoveAt(index);
continue;
}
state.Active?.Tick();
}
}
/// <summary>Aborts active operations and resets session state after hotload.</summary>
[EditorEvent.Hotload]
private static void OnHotload()
{
for(var index = StateReferences.Count - 1; index >= 0; index--)
{
if(StateReferences[index].TryGetTarget(out var state))
state.Active?.Abort();
}
StateReferences.Clear();
_states = new ConditionalWeakTable<SceneEditorSession, SessionState>();
}
/// <summary>Returns state for the active editor session and optionally creates it.</summary>
private static SessionState? GetCurrentState(bool create)
{
var session = SceneEditorSession.Active;
if(session == null)
return null;
if(create)
return GetState(session);
return _states.TryGetValue(session, out var state)
? state
: null;
}
/// <summary>Returns or creates modal state for a specific editor session.</summary>
private static SessionState GetState(SceneEditorSession session)
{
if(_states.TryGetValue(session, out var state))
return state;
state = new SessionState();
_states.Add(session, state);
StateReferences.Add(new WeakReference<SessionState>(state));
return state;
}
/// <summary>Stores modal operation state associated with one editor session.</summary>
private sealed class SessionState
{
/// <summary>Gets or sets the active modal operation for this session.</summary>
public ModalTransformOperation? Active { get; set; }
}
}
Editor
library
#nullable enable using Sandbox; namespace BlenderActions; /// <summary>Captures one game object and its world position.</summary> internal readonly record struct PositionState(GameObject Object, Vector3 Position); /// <summary>Captures one game object, world position, and world rotation.</summary> internal readonly record struct RotationState(GameObject Object, Vector3 Position, Rotation Rotation); /// <summary>Captures one game object, world position, and world scale.</summary> internal readonly record struct ScaleState(GameObject Object, Vector3 Position, Vector3 Scale);
Editor
library
#nullable enable
using Sandbox;
namespace BlenderActions;
/// <summary>Provides component-wise vector helpers used by transform operations.</summary>
internal static class Vector3Extensions
{
/// <summary>Returns the component-wise product of two vectors.</summary>
public static Vector3 MultiplyComponents(this Vector3 left, Vector3 right)
{
return new Vector3(
left.x * right.x,
left.y * right.y,
left.z * right.z);
}
}
Editor
library
#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.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;
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.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;
}
}
}
Debug: View Raw JSON Response
{
"TotalCount": 13,
"Files": [
{
"Ident": "mikekotys.blender_actions",
"Path": "Editor/TranslateOperation.cs",
"FileName": "TranslateOperation.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 341223,
"Code": "#nullable enable\nusing Editor;\nusing Sandbox;\nusing System;\n\nnamespace BlenderActions;\n\n/// <summary>Implements Blender-style modal translation for selected scene objects.</summary>\npublic sealed class TranslateOperation : ModalTransformOperation\n{\n /// <summary>Defines the world-space camera-plane probe used to invert screen projection.</summary>\n private const float ProjectionProbeDistance = 100f;\n /// <summary>Defines the minimum stable determinant accepted for projection inversion.</summary>\n private const float ProjectionEpsilon = 0.000001f;\n\n /// <summary>Handles new.</summary>\n private readonly VertexSnapSource _snapSource = new();\n /// <summary>Handles states.</summary>\n private PositionState[] _states = Array.Empty<PositionState>();\n\n /// <summary>Stores the world-space pivot used by the current operation.</summary>\n private Vector3 _selectionPivot;\n /// <summary>Stores the pointer position observed on the previous frame.</summary>\n private Vector2 _lastMousePosition;\n /// <summary>Stores pointer movement accumulated with precision scaling.</summary>\n private Vector2 _accumulatedMouseDelta;\n /// <summary>Stores the world-space displacement represented by one camera pixel on screen X.</summary>\n private Vector3 _screenXWorldDelta;\n /// <summary>Stores the world-space displacement represented by one camera pixel on screen Y.</summary>\n private Vector3 _screenYWorldDelta;\n /// <summary>Stores the translation currently applied to selected objects.</summary>\n private Vector3 _appliedWorldDelta;\n /// <summary>Stores the vertex currently locking a snapped transform.</summary>\n private Vector3 _lockedTargetVertex;\n /// <summary>Tracks whether a snapped target vertex is currently locked.</summary>\n private bool _hasLockedTarget;\n\n /// <summary>Gets the operation kind.</summary>\n public override TransformOperationKind Kind => TransformOperationKind.Translate;\n\n /// <summary>Captures operation-specific initial state.</summary>\n protected override void OnBegin()\n {\n _states = new PositionState[SelectedObjects.Length];\n _selectionPivot = Vector3.Zero;\n\n for(var index = 0; index < SelectedObjects.Length; index++)\n {\n var gameObject = SelectedObjects[index];\n _states[index] = new PositionState(gameObject, gameObject.WorldPosition);\n _selectionPivot += gameObject.WorldPosition;\n }\n\n _selectionPivot /= _states.Length;\n _lastMousePosition = SceneViewportWidget.MousePosition;\n _accumulatedMouseDelta = Vector2.Zero;\n _appliedWorldDelta = Vector3.Zero;\n _lockedTargetVertex = Vector3.Zero;\n _hasLockedTarget = false;\n\n var cameraRotation = Camera.GameObject.WorldRotation;\n var cameraRight = cameraRotation.Right;\n var cameraUp = cameraRotation.Up;\n var pivotScreen = Camera.PointToScreenPixels(_selectionPivot);\n var rightScreen = Camera.PointToScreenPixels(\n _selectionPivot + cameraRight * ProjectionProbeDistance);\n var upScreen = Camera.PointToScreenPixels(\n _selectionPivot + cameraUp * ProjectionProbeDistance);\n\n var rightPixelsPerUnit =\n (rightScreen - pivotScreen) / ProjectionProbeDistance;\n var upPixelsPerUnit =\n (upScreen - pivotScreen) / ProjectionProbeDistance;\n var determinant =\n rightPixelsPerUnit.x * upPixelsPerUnit.y -\n rightPixelsPerUnit.y * upPixelsPerUnit.x;\n\n if(MathF.Abs(determinant) < ProjectionEpsilon)\n throw new InvalidOperationException(\"Camera projection cannot be inverted.\");\n\n _screenXWorldDelta =\n cameraRight * (upPixelsPerUnit.y / determinant) -\n cameraUp * (rightPixelsPerUnit.y / determinant);\n _screenYWorldDelta =\n cameraRight * (-upPixelsPerUnit.x / determinant) +\n cameraUp * (rightPixelsPerUnit.x / determinant);\n }\n\n /// <summary>Updates the operation from current editor input.</summary>\n protected override void OnUpdate()\n {\n var currentMousePosition = SceneViewportWidget.MousePosition;\n var frameMouseDelta = currentMousePosition - _lastMousePosition;\n _lastMousePosition = currentMousePosition;\n\n var precision =\n (Editor.Application.KeyboardModifiers & KeyboardModifiers.Shift) != 0;\n _accumulatedMouseDelta += frameMouseDelta *\n (precision ? PrecisionMultiplier : 1f);\n\n var pixelDelta = InputPixelsToCameraPixels(_accumulatedMouseDelta);\n var worldDelta =\n _screenXWorldDelta * pixelDelta.x +\n _screenYWorldDelta * pixelDelta.y;\n\n if(NumericInput.TryGetValue(out var numericDistance))\n worldDelta = ApplyNumericDistance(worldDelta, numericDistance);\n else\n worldDelta = ApplyConstraint(worldDelta);\n\n var snapEnabled =\n (Editor.Application.KeyboardModifiers & KeyboardModifiers.Ctrl) != 0 &&\n !NumericInput.HasValue;\n\n if(!snapEnabled)\n {\n _appliedWorldDelta = worldDelta;\n _lockedTargetVertex = Vector3.Zero;\n _hasLockedTarget = false;\n ApplyTranslation(_appliedWorldDelta);\n return;\n }\n\n var target = VertexSnapService.FindTargetVertex(\n Session.Scene,\n Camera,\n Viewport,\n SelectedObjects);\n\n var targetChanged = target.Found &&\n (!_hasLockedTarget ||\n (target.Vertex - _lockedTargetVertex).Length > 0.001f);\n\n if(!targetChanged)\n return;\n\n VertexSnapService.CaptureSourceSnapshot(_snapSource, SelectedObjects);\n\n if(!VertexSnapService.TryFindClosestTranslatedSource(\n _snapSource,\n Camera,\n target.Vertex,\n Vector3.Zero,\n out var sourceVertex))\n {\n return;\n }\n\n var correction = ApplyConstraint(target.Vertex - sourceVertex);\n _appliedWorldDelta += correction;\n _lockedTargetVertex = target.Vertex;\n _hasLockedTarget = true;\n ApplyTranslation(_appliedWorldDelta);\n }\n\n /// <summary>Restores every transformed object to its captured initial state.</summary>\n protected override void RestoreInitialState()\n {\n ApplyPositions(_states);\n }\n\n /// <summary>Registers undo and redo callbacks for the completed operation.</summary>\n protected override void RegisterUndo()\n {\n var before = (PositionState[])_states.Clone();\n var after = CaptureCurrentPositions(_states);\n\n Session.AddUndo(\n \"Blender Translate\",\n () => ApplyPositions(before),\n () => ApplyPositions(after));\n }\n\n /// <summary>Resets operation-specific state after the active constraint changes.</summary>\n protected override void OnConstraintChanged()\n {\n _lockedTargetVertex = Vector3.Zero;\n _hasLockedTarget = false;\n }\n\n /// <summary>Releases operation-specific state during cleanup.</summary>\n protected override void OnCleanup()\n {\n _states = Array.Empty<PositionState>();\n _snapSource.Clear();\n _appliedWorldDelta = Vector3.Zero;\n _lockedTargetVertex = Vector3.Zero;\n _hasLockedTarget = false;\n }\n\n /// <summary>Converts numeric input into a constrained world-space translation.</summary>\n private Vector3 ApplyNumericDistance(Vector3 worldDelta, float distance)\n {\n if(IsSingleAxis(Constraint))\n return GetSingleAxis(Constraint) * distance;\n\n var constrained = ApplyConstraint(worldDelta);\n return constrained.Length > 0.0001f\n ? constrained.Normal * distance\n : Vector3.Zero;\n }\n\n /// <summary>Projects a world delta onto the active axis or plane constraint.</summary>\n private Vector3 ApplyConstraint(Vector3 worldDelta)\n {\n if(Constraint == AxisConstraint.None)\n return worldDelta;\n\n var result = Vector3.Zero;\n\n if((Constraint & AxisConstraint.X) != 0)\n result += Vector3.Forward * Vector3.Dot(worldDelta, Vector3.Forward);\n if((Constraint & AxisConstraint.Y) != 0)\n result += Vector3.Left * Vector3.Dot(worldDelta, Vector3.Left);\n if((Constraint & AxisConstraint.Z) != 0)\n result += Vector3.Up * Vector3.Dot(worldDelta, Vector3.Up);\n\n return result;\n }\n\n /// <summary>Applies a world-space translation to all captured objects.</summary>\n private void ApplyTranslation(Vector3 delta)\n {\n for(var index = 0; index < _states.Length; index++)\n {\n var state = _states[index];\n\n if(state.Object.IsValid())\n state.Object.WorldPosition = state.Position + delta;\n }\n }\n\n /// <summary>Returns whether a constraint represents exactly one world axis.</summary>\n private static bool IsSingleAxis(AxisConstraint constraint)\n {\n return constraint == AxisConstraint.X ||\n constraint == AxisConstraint.Y ||\n constraint == AxisConstraint.Z;\n }\n\n /// <summary>Returns the world direction represented by a single-axis constraint.</summary>\n private static Vector3 GetSingleAxis(AxisConstraint constraint)\n {\n return constraint switch\n {\n AxisConstraint.X => Vector3.Forward,\n AxisConstraint.Y => Vector3.Left,\n AxisConstraint.Z => Vector3.Up,\n _ => Vector3.Zero\n };\n }\n\n /// <summary>Captures current object positions for undo or redo.</summary>\n private static PositionState[] CaptureCurrentPositions(PositionState[] source)\n {\n var result = new PositionState[source.Length];\n\n for(var index = 0; index < source.Length; index++)\n {\n var state = source[index];\n var position = state.Object.IsValid()\n ? state.Object.WorldPosition\n : state.Position;\n result[index] = new PositionState(state.Object, position);\n }\n\n return result;\n }\n\n /// <summary>Applies captured world positions to valid game objects.</summary>\n private static void ApplyPositions(PositionState[] states)\n {\n for(var index = 0; index < states.Length; index++)\n {\n var state = states[index];\n\n if(state.Object.IsValid())\n state.Object.WorldPosition = state.Position;\n }\n }\n}\n"
},
{
"Ident": "mikekotys.blender_actions",
"Path": "Editor/ViewportInputLock.cs",
"FileName": "ViewportInputLock.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 341223,
"Code": "#nullable enable\nusing Editor;\nusing Sandbox;\nusing System;\nusing System.Collections.Generic;\n\nnamespace BlenderActions;\n\n/// <summary>Temporarily suppresses viewport selection and context-menu input during modal operations.</summary>\ninternal sealed class ViewportInputLock : IDisposable\n{\n /// <summary>Handles new.</summary>\n private static readonly HashSet<ViewportInputLock> ActiveLocks = new();\n\n /// <summary>References the scene view bound to the active operation.</summary>\n private readonly SceneViewWidget _sceneView;\n /// <summary>References the scene viewport bound to the active operation.</summary>\n private readonly SceneViewportWidget _viewport;\n /// <summary>References the optional tool.</summary>\n private readonly EditorTool? _tool;\n /// <summary>References the optional sub tool.</summary>\n private readonly EditorTool? _subTool;\n\n /// <summary>Tracks whether scene read only.</summary>\n private readonly bool _sceneReadOnly;\n /// <summary>Tracks whether scene context menu.</summary>\n private readonly bool _sceneContextMenu;\n /// <summary>Tracks whether viewport read only.</summary>\n private readonly bool _viewportReadOnly;\n /// <summary>Tracks whether viewport context menu.</summary>\n private readonly bool _viewportContextMenu;\n /// <summary>Tracks whether tool selection.</summary>\n private readonly bool _toolSelection;\n /// <summary>Tracks whether tool context menu.</summary>\n private readonly bool _toolContextMenu;\n /// <summary>Tracks whether sub tool selection.</summary>\n private readonly bool _subToolSelection;\n /// <summary>Tracks whether sub tool context menu.</summary>\n private readonly bool _subToolContextMenu;\n\n /// <summary>Tracks whether this input lock has already restored its state.</summary>\n private bool _disposed;\n\n /// <summary>Initializes a new viewport input lock instance.</summary>\n public ViewportInputLock(\n SceneViewWidget sceneView,\n SceneViewportWidget viewport,\n EditorTool? tool,\n EditorTool? subTool)\n {\n _sceneView = sceneView;\n _viewport = viewport;\n _tool = tool;\n _subTool = subTool;\n\n _sceneReadOnly = sceneView.ReadOnly;\n _sceneContextMenu = sceneView.ContextMenuEnabled;\n _viewportReadOnly = viewport.ReadOnly;\n _viewportContextMenu = viewport.ContextMenuEnabled;\n\n sceneView.ReadOnly = true;\n sceneView.ContextMenuEnabled = false;\n viewport.ReadOnly = true;\n viewport.ContextMenuEnabled = false;\n\n if(tool != null)\n {\n _toolSelection = tool.AllowGameObjectSelection;\n _toolContextMenu = tool.AllowContextMenu;\n tool.AllowGameObjectSelection = false;\n tool.AllowContextMenu = false;\n }\n\n if(subTool != null && subTool != tool)\n {\n _subToolSelection = subTool.AllowGameObjectSelection;\n _subToolContextMenu = subTool.AllowContextMenu;\n subTool.AllowGameObjectSelection = false;\n subTool.AllowContextMenu = false;\n }\n\n ActiveLocks.Add(this);\n }\n\n /// <summary>Restores captured viewport and tool input state exactly once.</summary>\n public void Dispose()\n {\n if(_disposed)\n return;\n\n _disposed = true;\n ActiveLocks.Remove(this);\n\n Exception? restoreError = null;\n Restore(() =>\n {\n if(_sceneView.IsValid)\n {\n _sceneView.ReadOnly = _sceneReadOnly;\n _sceneView.ContextMenuEnabled = _sceneContextMenu;\n }\n }, ref restoreError);\n\n Restore(() =>\n {\n if(_viewport.IsValid)\n {\n _viewport.ReadOnly = _viewportReadOnly;\n _viewport.ContextMenuEnabled = _viewportContextMenu;\n }\n }, ref restoreError);\n\n Restore(() =>\n {\n if(_tool != null)\n {\n _tool.AllowGameObjectSelection = _toolSelection;\n _tool.AllowContextMenu = _toolContextMenu;\n }\n }, ref restoreError);\n\n Restore(() =>\n {\n if(_subTool != null && _subTool != _tool)\n {\n _subTool.AllowGameObjectSelection = _subToolSelection;\n _subTool.AllowContextMenu = _subToolContextMenu;\n }\n }, ref restoreError);\n\n if(restoreError != null)\n throw restoreError;\n }\n\n /// <summary>Restores every active input lock during editor hotload.</summary>\n [EditorEvent.Hotload]\n private static void RestoreAll()\n {\n var locks = new ViewportInputLock[ActiveLocks.Count];\n ActiveLocks.CopyTo(locks);\n\n foreach(var inputLock in locks)\n {\n try\n {\n inputLock.Dispose();\n }\n catch\n {\n }\n }\n\n ActiveLocks.Clear();\n }\n\n /// <summary>Executes one restoration step while retaining the first failure.</summary>\n private static void Restore(Action action, ref Exception? firstError)\n {\n try\n {\n action();\n }\n catch(Exception exception)\n {\n firstError ??= exception;\n }\n }\n}\n"
},
{
"Ident": "mikekotys.blender_actions",
"Path": "Editor/RotateOperation.cs",
"FileName": "RotateOperation.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 341223,
"Code": "#nullable enable\nusing Editor;\nusing Sandbox;\nusing System;\n\nnamespace BlenderActions;\n\n/// <summary>Implements Blender-style modal rotation for selected scene objects.</summary>\npublic sealed class RotateOperation : ModalTransformOperation\n{\n /// <summary>Defines the minimum pointer radius used to calculate a stable rotation angle.</summary>\n private const float DirectionEpsilon = 2f;\n\n /// <summary>Handles new.</summary>\n private readonly VertexSnapSource _snapSource = new();\n /// <summary>Handles states.</summary>\n private RotationState[] _states = Array.Empty<RotationState>();\n\n /// <summary>Stores the world-space pivot used by the current operation.</summary>\n private Vector3 _selectionPivot;\n /// <summary>Stores the pivot projected into viewport input pixels.</summary>\n private Vector2 _pivotInputPosition;\n /// <summary>Stores the pointer position observed on the previous frame.</summary>\n private Vector2 _lastMousePosition;\n /// <summary>Stores the unsnapped angle accumulated from pointer movement.</summary>\n private float _accumulatedAngle;\n /// <summary>Stores the world-space axis used by the current rotation.</summary>\n private Vector3 _rotationAxis;\n /// <summary>Stores the angle currently applied to selected objects.</summary>\n private float _appliedAngle;\n /// <summary>Stores the vertex currently locking a snapped transform.</summary>\n private Vector3 _lockedTargetVertex;\n /// <summary>Tracks whether a snapped target vertex is currently locked.</summary>\n private bool _hasLockedTarget;\n\n /// <summary>Gets the operation kind.</summary>\n public override TransformOperationKind Kind => TransformOperationKind.Rotate;\n\n /// <summary>Captures operation-specific initial state.</summary>\n protected override void OnBegin()\n {\n _states = new RotationState[SelectedObjects.Length];\n _selectionPivot = Vector3.Zero;\n\n for(var index = 0; index < SelectedObjects.Length; index++)\n {\n var gameObject = SelectedObjects[index];\n _states[index] = new RotationState(\n gameObject,\n gameObject.WorldPosition,\n gameObject.WorldRotation);\n _selectionPivot += gameObject.WorldPosition;\n }\n\n _selectionPivot /= _states.Length;\n\n if(ThreeDCursor.UseAsTransformPivot)\n _selectionPivot = ThreeDCursor.Position;\n\n _pivotInputPosition = CameraPixelsToInputPixels(\n Camera.PointToScreenPixels(_selectionPivot));\n _lastMousePosition = SceneViewportWidget.MousePosition;\n _accumulatedAngle = 0f;\n _appliedAngle = 0f;\n _lockedTargetVertex = Vector3.Zero;\n _hasLockedTarget = false;\n CaptureRotationAxis();\n }\n\n /// <summary>Updates the operation from current editor input.</summary>\n protected override void OnUpdate()\n {\n var snapEnabled =\n (Editor.Application.KeyboardModifiers & KeyboardModifiers.Ctrl) != 0 &&\n !NumericInput.HasValue;\n\n var currentMousePosition = SceneViewportWidget.MousePosition;\n var previousDirection = _lastMousePosition - _pivotInputPosition;\n var currentDirection = currentMousePosition - _pivotInputPosition;\n _lastMousePosition = currentMousePosition;\n\n if(!snapEnabled &&\n previousDirection.Length >= DirectionEpsilon &&\n currentDirection.Length >= DirectionEpsilon)\n {\n previousDirection = previousDirection.Normal;\n currentDirection = currentDirection.Normal;\n\n var cross =\n previousDirection.x * currentDirection.y -\n previousDirection.y * currentDirection.x;\n var dot =\n previousDirection.x * currentDirection.x +\n previousDirection.y * currentDirection.y;\n var frameAngle = -MathF.Atan2(cross, dot) * (180f / MathF.PI);\n var precision =\n (Editor.Application.KeyboardModifiers & KeyboardModifiers.Shift) != 0;\n\n _accumulatedAngle += frameAngle *\n (precision ? PrecisionMultiplier : 1f);\n }\n\n var angle = NumericInput.TryGetValue(out var numericAngle)\n ? numericAngle\n : _accumulatedAngle;\n\n if(!snapEnabled)\n {\n _appliedAngle = angle;\n _lockedTargetVertex = Vector3.Zero;\n _hasLockedTarget = false;\n ApplyRotation(Rotation.FromAxis(_rotationAxis, _appliedAngle));\n return;\n }\n\n var target = VertexSnapService.FindTargetVertex(\n Session.Scene,\n Camera,\n Viewport,\n SelectedObjects);\n\n var targetChanged = target.Found &&\n (!_hasLockedTarget ||\n (target.Vertex - _lockedTargetVertex).Length > 0.001f);\n\n if(!targetChanged)\n return;\n\n VertexSnapService.CaptureSourceSnapshot(_snapSource, SelectedObjects);\n\n if(!VertexSnapService.TryFindClosestRotatedSource(\n _snapSource,\n Camera,\n target.Vertex,\n _selectionPivot,\n Rotation.Identity,\n out var sourceVertex) ||\n !TryGetSnapAngle(\n sourceVertex,\n target.Vertex,\n out var correctionAngle))\n {\n return;\n }\n\n _appliedAngle += correctionAngle;\n _lockedTargetVertex = target.Vertex;\n _hasLockedTarget = true;\n ApplyRotation(Rotation.FromAxis(_rotationAxis, _appliedAngle));\n }\n\n /// <summary>Restores every transformed object to its captured initial state.</summary>\n protected override void RestoreInitialState()\n {\n ApplyStates(_states);\n }\n\n /// <summary>Registers undo and redo callbacks for the completed operation.</summary>\n protected override void RegisterUndo()\n {\n var before = (RotationState[])_states.Clone();\n var after = CaptureCurrentStates(_states);\n\n Session.AddUndo(\n \"Blender Rotate\",\n () => ApplyStates(before),\n () => ApplyStates(after));\n }\n\n /// <summary>Resets operation-specific state after the active constraint changes.</summary>\n protected override void OnConstraintChanged()\n {\n _accumulatedAngle = 0f;\n _lastMousePosition = SceneViewportWidget.MousePosition;\n CaptureRotationAxis();\n _appliedAngle = 0f;\n _lockedTargetVertex = Vector3.Zero;\n _hasLockedTarget = false;\n }\n\n /// <summary>Releases operation-specific state during cleanup.</summary>\n protected override void OnCleanup()\n {\n _states = Array.Empty<RotationState>();\n _snapSource.Clear();\n _appliedAngle = 0f;\n _lockedTargetVertex = Vector3.Zero;\n _hasLockedTarget = false;\n }\n\n /// <summary>Captures the world or camera-facing axis used by the current rotation.</summary>\n private void CaptureRotationAxis()\n {\n var toCamera = Camera.GameObject.WorldPosition - _selectionPivot;\n\n if(Constraint == AxisConstraint.None)\n {\n _rotationAxis = toCamera.Length > 0.0001f\n ? toCamera.Normal\n : -Camera.GameObject.WorldRotation.Forward;\n return;\n }\n\n _rotationAxis = Constraint switch\n {\n AxisConstraint.X or AxisConstraint.YZ => Vector3.Forward,\n AxisConstraint.Y or AxisConstraint.XZ => Vector3.Left,\n AxisConstraint.Z or AxisConstraint.XY => Vector3.Up,\n _ => Vector3.Up\n };\n }\n\n /// <summary>Attempts to calculate the angular correction from a source vertex to a target vertex.</summary>\n private bool TryGetSnapAngle(\n Vector3 sourceVertex,\n Vector3 targetVertex,\n out float angle)\n {\n angle = 0f;\n var sourceOffset = sourceVertex - _selectionPivot;\n var targetOffset = targetVertex - _selectionPivot;\n var sourcePlanar = sourceOffset -\n _rotationAxis * Vector3.Dot(sourceOffset, _rotationAxis);\n var targetPlanar = targetOffset -\n _rotationAxis * Vector3.Dot(targetOffset, _rotationAxis);\n\n if(sourcePlanar.Length < 0.0001f || targetPlanar.Length < 0.0001f)\n return false;\n\n sourcePlanar = sourcePlanar.Normal;\n targetPlanar = targetPlanar.Normal;\n\n var cross = Vector3.Cross(sourcePlanar, targetPlanar);\n var dot = Vector3.Dot(sourcePlanar, targetPlanar);\n angle = MathF.Atan2(\n Vector3.Dot(_rotationAxis, cross),\n dot) * (180f / MathF.PI);\n\n return !float.IsNaN(angle) && !float.IsInfinity(angle);\n }\n\n /// <summary>Applies a rotation around the active pivot to all captured objects.</summary>\n private void ApplyRotation(Rotation rotation)\n {\n for(var index = 0; index < _states.Length; index++)\n {\n var state = _states[index];\n\n if(!state.Object.IsValid())\n continue;\n\n var offset = state.Position - _selectionPivot;\n state.Object.WorldPosition = _selectionPivot + rotation * offset;\n state.Object.WorldRotation = rotation * state.Rotation;\n }\n }\n\n /// <summary>Captures current position and rotation values for undo or redo.</summary>\n private static RotationState[] CaptureCurrentStates(RotationState[] source)\n {\n var result = new RotationState[source.Length];\n\n for(var index = 0; index < source.Length; index++)\n {\n var state = source[index];\n result[index] = state.Object.IsValid()\n ? new RotationState(\n state.Object,\n state.Object.WorldPosition,\n state.Object.WorldRotation)\n : state;\n }\n\n return result;\n }\n\n /// <summary>Applies captured transform states to valid game objects.</summary>\n private static void ApplyStates(RotationState[] states)\n {\n for(var index = 0; index < states.Length; index++)\n {\n var state = states[index];\n\n if(!state.Object.IsValid())\n continue;\n\n state.Object.WorldPosition = state.Position;\n state.Object.WorldRotation = state.Rotation;\n }\n }\n}\n"
},
{
"Ident": "mikekotys.blender_actions",
"Path": "Editor/TransformShortcuts.cs",
"FileName": "TransformShortcuts.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 341223,
"Code": "#nullable enable\nusing Editor;\n\nnamespace BlenderActions;\n\n/// <summary>Registers viewport shortcuts for transform operations and axis constraints.</summary>\npublic static class TransformShortcuts\n{\n /// <summary>Starts a modal translation operation.</summary>\n [Shortcut(\"blender_actions.translate\", \"U\", typeof(SceneViewportWidget), ShortcutType.Widget)]\n private static void Translate()\n {\n ModalOperationArbiter.Start(new TranslateOperation());\n }\n\n /// <summary>Starts a modal rotation operation.</summary>\n [Shortcut(\"blender_actions.rotate\", \"R\", typeof(SceneViewportWidget), ShortcutType.Widget)]\n private static void Rotate()\n {\n ModalOperationArbiter.Start(new RotateOperation());\n }\n\n /// <summary>Starts a modal scale operation.</summary>\n [Shortcut(\"blender_actions.scale\", \"S\", typeof(SceneViewportWidget), ShortcutType.Widget)]\n private static void Scale()\n {\n ModalOperationArbiter.Start(new ScaleOperation());\n }\n\n /// <summary>Constrains the active operation to the world X axis.</summary>\n [Shortcut(\"blender_actions.constraint_x\", \"X\", typeof(SceneViewportWidget), ShortcutType.Widget)]\n private static void X()\n {\n ModalOperationArbiter.SetConstraint(AxisConstraint.X);\n }\n\n /// <summary>Constrains the active operation to the world Y axis.</summary>\n [Shortcut(\"blender_actions.constraint_y\", \"Y\", typeof(SceneViewportWidget), ShortcutType.Widget)]\n private static void Y()\n {\n ModalOperationArbiter.SetConstraint(AxisConstraint.Y);\n }\n\n /// <summary>Constrains the active operation to the world Z axis.</summary>\n [Shortcut(\"blender_actions.constraint_z\", \"Z\", typeof(SceneViewportWidget), ShortcutType.Widget)]\n private static void Z()\n {\n ModalOperationArbiter.SetConstraint(AxisConstraint.Z);\n }\n\n /// <summary>Constrains the active operation to the world YZ plane.</summary>\n [Shortcut(\"blender_actions.constraint_yz\", \"SHIFT+X\", typeof(SceneViewportWidget), ShortcutType.Widget)]\n private static void YZ()\n {\n ModalOperationArbiter.SetConstraint(AxisConstraint.YZ);\n }\n\n /// <summary>Constrains the active operation to the world XZ plane.</summary>\n [Shortcut(\"blender_actions.constraint_xz\", \"SHIFT+Y\", typeof(SceneViewportWidget), ShortcutType.Widget)]\n private static void XZ()\n {\n ModalOperationArbiter.SetConstraint(AxisConstraint.XZ);\n }\n\n /// <summary>Constrains the active operation to the world XY plane.</summary>\n [Shortcut(\"blender_actions.constraint_xy\", \"SHIFT+Z\", typeof(SceneViewportWidget), ShortcutType.Widget)]\n private static void XY()\n {\n ModalOperationArbiter.SetConstraint(AxisConstraint.XY);\n }\n\n /// <summary>Cancels the active operation for the current editor session.</summary>\n [Shortcut(\"blender_actions.cancel\", \"ESCAPE\", typeof(SceneViewportWidget), ShortcutType.Widget)]\n private static void Cancel()\n {\n ModalOperationArbiter.Cancel();\n }\n}\n"
},
{
"Ident": "mikekotys.blender_actions",
"Path": ".obj/__compiler_extra.cs",
"FileName": "__compiler_extra.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 341223,
"Code": "global using static Sandbox.Internal.GlobalGameNamespace;\r\nglobal using Microsoft.AspNetCore.Components;\r\nglobal using Microsoft.AspNetCore.Components.Rendering;\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"AddonTitle\", \"Blender Actions\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"AddonIdent\", \"blender_actions\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"OrgIdent\", \"mikekotys\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"Ident\", \"mikekotys.blender_actions\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"EngineVersion\", \"28\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"EngineMinorVersion\", \"1\" )]\r\n\r\n[assembly: System.Runtime.Versioning.TargetFramework( \".NETCoreApp,Version=v9.0\", FrameworkDisplayName = \".NET 9.0\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"CompileTime\", \"2026-08-12T17:40:59.9712817Z\" )]\r\n[assembly: global::System.Reflection.AssemblyVersion(\"0.0.113.0\")]\r\n[assembly: global::System.Reflection.AssemblyFileVersion(\"0.0.113.0\")]"
},
{
"Ident": "mikekotys.blender_actions",
"Path": "Editor/ModalTransformOperation.cs",
"FileName": "ModalTransformOperation.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 341223,
"Code": "#nullable enable\nusing Editor;\nusing Sandbox;\nusing System;\nusing System.Collections.Generic;\nusing System.Linq;\n\nnamespace BlenderActions;\n\n/// <summary>Defines world-axis and world-plane transform constraints.</summary>\n[Flags]\npublic enum AxisConstraint\n{\n None = 0,\n X = 1,\n Y = 2,\n Z = 4,\n XY = X | Y,\n XZ = X | Z,\n YZ = Y | Z\n}\n\n/// <summary>Identifies the supported modal transform operation types.</summary>\npublic enum TransformOperationKind\n{\n Translate,\n Rotate,\n Scale\n}\n\n/// <summary>Provides the shared lifecycle, input handling, and cleanup for modal transforms.</summary>\npublic abstract class ModalTransformOperation\n{\n /// <summary>Defines the frame delay that prevents the completing click from reaching default viewport input.</summary>\n private const int PostClickGuardFrames = 2;\n /// <summary>Defines pointer-motion scaling while the precision modifier is held.</summary>\n protected const float PrecisionMultiplier = 0.1f;\n\n /// <summary>Owns temporary viewport input suppression for this operation.</summary>\n private ViewportInputLock? _inputLock;\n /// <summary>References the scene view bound to the active operation.</summary>\n private SceneViewWidget? _sceneView;\n /// <summary>References the scene viewport bound to the active operation.</summary>\n private SceneViewportWidget? _viewport;\n /// <summary>References the editor session bound to the active operation.</summary>\n private SceneEditorSession? _session;\n /// <summary>References the editor camera bound to the active operation.</summary>\n private CameraComponent? _camera;\n /// <summary>Handles selection snapshot.</summary>\n private GameObject[] _selectionSnapshot = Array.Empty<GameObject>();\n /// <summary>Tracks whether the operation is waiting to finish.</summary>\n private bool _finishRequested;\n /// <summary>Tracks whether numeric or modal confirmation has been requested.</summary>\n private bool _confirmRequested;\n /// <summary>Tracks whether the modal operation has already terminated.</summary>\n private bool _finished;\n /// <summary>Stores remaining frames used to guard the confirming or cancelling click.</summary>\n private int _finishDelayFrames;\n\n /// <summary>Gets the bound scene view or throws when unavailable.</summary>\n protected SceneViewWidget SceneView =>\n _sceneView ?? throw new InvalidOperationException(\"No active Scene View.\");\n /// <summary>Gets the bound scene viewport or throws when unavailable.</summary>\n protected SceneViewportWidget Viewport =>\n _viewport ?? throw new InvalidOperationException(\"No active Scene Viewport.\");\n /// <summary>Gets the bound scene editor session or throws when unavailable.</summary>\n protected SceneEditorSession Session =>\n _session ?? throw new InvalidOperationException(\"No active Scene Editor session.\");\n /// <summary>Gets the bound editor camera or throws when unavailable.</summary>\n protected CameraComponent Camera =>\n _camera ?? throw new InvalidOperationException(\"No active Scene camera.\");\n\n /// <summary>Handles selected objects.</summary>\n protected GameObject[] SelectedObjects { get; private set; } = Array.Empty<GameObject>();\n /// <summary>Handles new.</summary>\n protected internal NumericInputSession NumericInput { get; } = new();\n /// <summary>Gets the active world-axis or world-plane constraint.</summary>\n protected AxisConstraint Constraint { get; private set; }\n\n /// <summary>Gets the editor session currently bound to this operation.</summary>\n internal SceneEditorSession? BoundSession => _session;\n /// <summary>Gets the operation kind.</summary>\n public abstract TransformOperationKind Kind { get; }\n\n /// <summary>Binds the operation to the active scene context and starts its modal lifecycle.</summary>\n internal bool Begin(SceneEditorSession expectedSession)\n {\n var sceneView = SceneViewWidget.Current;\n var viewport = sceneView?.LastSelectedViewportWidget;\n var session = SceneEditorSession.Active;\n\n if(sceneView == null ||\n viewport == null ||\n session == null ||\n session.IsPlaying ||\n !ReferenceEquals(session, expectedSession))\n {\n return false;\n }\n\n var activeTool = sceneView.Tools.CurrentTool;\n var activeSubTool = sceneView.Tools.CurrentSubTool;\n var camera = activeSubTool?.Camera ?? activeTool?.Camera;\n\n if(camera == null)\n return false;\n\n _sceneView = sceneView;\n _viewport = viewport;\n _session = session;\n _camera = camera;\n\n _selectionSnapshot = session.GetSelection()\n .OfType<GameObject>()\n .Where(gameObject => gameObject.IsValid())\n .ToArray();\n\n var selectedSet = _selectionSnapshot.ToHashSet();\n SelectedObjects = _selectionSnapshot\n .Where(gameObject => !HasSelectedAncestor(gameObject, selectedSet))\n .ToArray();\n\n if(SelectedObjects.Length == 0)\n {\n ClearContext();\n return false;\n }\n\n Constraint = AxisConstraint.None;\n\n try\n {\n OnBegin();\n _inputLock = new ViewportInputLock(\n SceneView,\n Viewport,\n activeTool,\n activeSubTool);\n SceneView.MouseClick += RequestConfirm;\n SceneView.MouseRightClick += RequestCancel;\n NumericInput.Begin();\n return true;\n }\n catch\n {\n Cleanup();\n throw;\n }\n }\n\n /// <summary>Advances input, completion, and operation-specific update logic.</summary>\n internal void Tick()\n {\n if(_finished)\n return;\n\n if(!HasValidContext())\n {\n Abort();\n return;\n }\n\n if(NumericInput.ConsumeConfirmRequest())\n RequestConfirm();\n\n if(_finishRequested)\n {\n if(_finishDelayFrames > 0)\n {\n _finishDelayFrames--;\n return;\n }\n\n Finish(_confirmRequested);\n return;\n }\n\n try\n {\n OnUpdate();\n }\n catch\n {\n Abort();\n throw;\n }\n }\n\n /// <summary>Applies or toggles an axis constraint on the active operation.</summary>\n internal void SetConstraint(AxisConstraint constraint)\n {\n if(_finished || _finishRequested)\n return;\n\n Constraint = Constraint == constraint\n ? AxisConstraint.None\n : constraint;\n\n OnConstraintChanged();\n }\n\n /// <summary>Queues confirmation after the viewport click guard interval.</summary>\n internal void RequestConfirm()\n {\n if(_finished || _finishRequested)\n return;\n\n _confirmRequested = true;\n _finishRequested = true;\n _finishDelayFrames = PostClickGuardFrames;\n }\n\n /// <summary>Restores initial state and queues cancellation after the click guard interval.</summary>\n internal void RequestCancel()\n {\n if(_finished || _finishRequested)\n return;\n\n RestoreInitialState();\n _confirmRequested = false;\n _finishRequested = true;\n _finishDelayFrames = PostClickGuardFrames;\n }\n\n /// <summary>Immediately restores initial state and terminates the operation.</summary>\n internal void Abort()\n {\n if(_finished)\n return;\n\n try\n {\n RestoreInitialState();\n }\n finally\n {\n Finish(false);\n }\n }\n\n /// <summary>Captures operation-specific initial state.</summary>\n protected abstract void OnBegin();\n /// <summary>Updates the operation from current editor input.</summary>\n protected abstract void OnUpdate();\n /// <summary>Restores every transformed object to its captured initial state.</summary>\n protected abstract void RestoreInitialState();\n /// <summary>Registers undo and redo callbacks for the completed operation.</summary>\n protected abstract void RegisterUndo();\n /// <summary>Resets operation-specific state after the active constraint changes.</summary>\n protected virtual void OnConstraintChanged() { }\n /// <summary>Releases operation-specific state during cleanup.</summary>\n protected virtual void OnCleanup() { }\n\n /// <summary>Restores the scene selection captured when the operation began.</summary>\n protected void RestoreSelection()\n {\n var session = _session;\n\n if(session == null)\n return;\n\n session.Selection.Clear();\n\n foreach(var gameObject in _selectionSnapshot)\n {\n if(gameObject.IsValid())\n session.Selection.Add(gameObject);\n }\n }\n\n /// <summary>Converts camera-render pixels to viewport input pixels.</summary>\n protected Vector2 CameraPixelsToInputPixels(Vector2 cameraPixels)\n {\n var renderSize = Camera.CustomSize;\n var inputSize = Viewport.Size * Viewport.DpiScale;\n\n if(!renderSize.HasValue || renderSize.Value.x <= 0f || renderSize.Value.y <= 0f)\n return cameraPixels;\n\n return new Vector2(\n cameraPixels.x * inputSize.x / renderSize.Value.x,\n cameraPixels.y * inputSize.y / renderSize.Value.y);\n }\n\n /// <summary>Converts viewport input pixels to camera-render pixels.</summary>\n protected Vector2 InputPixelsToCameraPixels(Vector2 inputPixels)\n {\n var renderSize = Camera.CustomSize;\n var inputSize = Viewport.Size * Viewport.DpiScale;\n\n if(!renderSize.HasValue || inputSize.x <= 0f || inputSize.y <= 0f)\n return inputPixels;\n\n return new Vector2(\n inputPixels.x * renderSize.Value.x / inputSize.x,\n inputPixels.y * renderSize.Value.y / inputSize.y);\n }\n\n /// <summary>Returns whether a selected ancestor already represents this object.</summary>\n private static bool HasSelectedAncestor(\n GameObject gameObject,\n HashSet<GameObject> selected)\n {\n var parent = gameObject.Parent;\n\n while(parent != null)\n {\n if(selected.Contains(parent))\n return true;\n\n parent = parent.Parent;\n }\n\n return false;\n }\n\n /// <summary>Returns whether the bound scene, viewport, session, and camera remain valid.</summary>\n private bool HasValidContext()\n {\n return _sceneView != null &&\n _sceneView.IsValid &&\n _viewport != null &&\n _viewport.IsValid &&\n _session != null &&\n !_session.IsPlaying &&\n SceneEditorSession.Active == _session &&\n _camera != null &&\n _camera.GameObject != null &&\n _camera.GameObject.IsValid();\n }\n\n /// <summary>Commits or cancels the operation and always releases modal resources.</summary>\n private void Finish(bool confirmed)\n {\n if(_finished)\n return;\n\n _finished = true;\n\n try\n {\n if(confirmed)\n {\n try\n {\n RegisterUndo();\n Session.HasUnsavedChanges = true;\n }\n catch\n {\n RestoreInitialState();\n throw;\n }\n }\n\n RestoreSelection();\n }\n finally\n {\n ModalOperationArbiter.Release(this);\n Cleanup();\n }\n }\n\n /// <summary>Unsubscribes input handlers, restores viewport state, and clears context.</summary>\n private void Cleanup()\n {\n Exception? cleanupError = null;\n\n try\n {\n if(_sceneView != null)\n {\n _sceneView.MouseClick -= RequestConfirm;\n _sceneView.MouseRightClick -= RequestCancel;\n }\n }\n catch(Exception exception)\n {\n cleanupError ??= exception;\n }\n\n NumericInput.End();\n\n try\n {\n _inputLock?.Dispose();\n }\n catch(Exception exception)\n {\n cleanupError ??= exception;\n }\n\n _inputLock = null;\n\n try\n {\n OnCleanup();\n }\n catch(Exception exception)\n {\n cleanupError ??= exception;\n }\n\n ClearContext();\n\n if(cleanupError != null)\n throw cleanupError;\n }\n\n /// <summary>Clears references to the active editor context and selection.</summary>\n private void ClearContext()\n {\n _sceneView = null;\n _viewport = null;\n _session = null;\n _camera = null;\n SelectedObjects = Array.Empty<GameObject>();\n _selectionSnapshot = Array.Empty<GameObject>();\n }\n}\n"
},
{
"Ident": "mikekotys.blender_actions",
"Path": "Editor/NumericInputSession.cs",
"FileName": "NumericInputSession.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 341223,
"Code": "#nullable enable\nusing Editor;\nusing System.Globalization;\n\nnamespace BlenderActions;\n\n/// <summary>Stores and parses numeric input for one modal transform operation.</summary>\npublic sealed class NumericInputSession\n{\n /// <summary>Stores buffered numeric input characters.</summary>\n private string _text = string.Empty;\n /// <summary>Tracks whether numeric or modal confirmation has been requested.</summary>\n private bool _confirmRequested;\n\n /// <summary>Gets whether this numeric input session is accepting input.</summary>\n public bool IsActive { get; private set; }\n /// <summary>Attempts to parse the buffered text as an invariant floating-point value.</summary>\n public bool HasValue => TryGetValue(out _);\n /// <summary>Handles is null or empty.</summary>\n public string DisplayText => string.IsNullOrEmpty(_text) ? \"0\" : _text;\n\n /// <summary>Binds the operation to the active scene context and starts its modal lifecycle.</summary>\n public void Begin()\n {\n _text = string.Empty;\n _confirmRequested = false;\n IsActive = true;\n }\n\n /// <summary>Ends numeric input and clears its buffered state.</summary>\n public void End()\n {\n _text = string.Empty;\n _confirmRequested = false;\n IsActive = false;\n }\n\n /// <summary>Attempts to parse the buffered text as an invariant floating-point value.</summary>\n public bool TryGetValue(out float value)\n {\n if(!IsActive)\n {\n value = 0f;\n return false;\n }\n\n return float.TryParse(\n _text,\n NumberStyles.Float,\n CultureInfo.InvariantCulture,\n out value);\n }\n\n /// <summary>Consumes and clears a pending numeric confirmation request.</summary>\n public bool ConsumeConfirmRequest()\n {\n if(!_confirmRequested)\n return false;\n\n _confirmRequested = false;\n return true;\n }\n\n /// <summary>Appends one digit while numeric input is active.</summary>\n public void AppendDigit(char digit)\n {\n if(IsActive)\n _text += digit;\n }\n\n /// <summary>Appends a decimal separator when one is not already present.</summary>\n public void EnterDecimal()\n {\n if(!IsActive || _text.Contains('.'))\n return;\n\n _text = string.IsNullOrEmpty(_text)\n ? \"0.\"\n : _text == \"-\"\n ? \"-0.\"\n : _text + '.';\n }\n\n /// <summary>Toggles the sign of the buffered numeric value.</summary>\n public void ToggleNegative()\n {\n if(!IsActive)\n return;\n\n _text = _text.StartsWith(\"-\")\n ? _text[1..]\n : \"-\" + _text;\n }\n\n /// <summary>Removes the last buffered numeric character.</summary>\n public void Backspace()\n {\n if(IsActive && _text.Length > 0)\n _text = _text[..^1];\n }\n\n /// <summary>Requests confirmation when the buffered numeric value is valid.</summary>\n public void Confirm()\n {\n if(HasValue)\n _confirmRequested = true;\n }\n}\n\n/// <summary>Routes numeric keyboard shortcuts to the active modal operation.</summary>\npublic static class NumericInputShortcuts\n{\n /// <summary>Appends the digit zero to active numeric input.</summary>\n [Shortcut(\"blender_actions.numeric_0\", \"0\", typeof(SceneViewportWidget), ShortcutType.Widget)]\n private static void Zero() => Append('0');\n /// <summary>Appends the digit one to active numeric input.</summary>\n [Shortcut(\"blender_actions.numeric_1\", \"1\", typeof(SceneViewportWidget), ShortcutType.Widget)]\n private static void One() => Append('1');\n /// <summary>Appends the digit two to active numeric input.</summary>\n [Shortcut(\"blender_actions.numeric_2\", \"2\", typeof(SceneViewportWidget), ShortcutType.Widget)]\n private static void Two() => Append('2');\n /// <summary>Appends the digit three to active numeric input.</summary>\n [Shortcut(\"blender_actions.numeric_3\", \"3\", typeof(SceneViewportWidget), ShortcutType.Widget)]\n private static void Three() => Append('3');\n /// <summary>Appends the digit four to active numeric input.</summary>\n [Shortcut(\"blender_actions.numeric_4\", \"4\", typeof(SceneViewportWidget), ShortcutType.Widget)]\n private static void Four() => Append('4');\n /// <summary>Appends the digit five to active numeric input.</summary>\n [Shortcut(\"blender_actions.numeric_5\", \"5\", typeof(SceneViewportWidget), ShortcutType.Widget)]\n private static void Five() => Append('5');\n /// <summary>Appends the digit six to active numeric input.</summary>\n [Shortcut(\"blender_actions.numeric_6\", \"6\", typeof(SceneViewportWidget), ShortcutType.Widget)]\n private static void Six() => Append('6');\n /// <summary>Appends the digit seven to active numeric input.</summary>\n [Shortcut(\"blender_actions.numeric_7\", \"7\", typeof(SceneViewportWidget), ShortcutType.Widget)]\n private static void Seven() => Append('7');\n /// <summary>Appends the digit eight to active numeric input.</summary>\n [Shortcut(\"blender_actions.numeric_8\", \"8\", typeof(SceneViewportWidget), ShortcutType.Widget)]\n private static void Eight() => Append('8');\n /// <summary>Appends the digit nine to active numeric input.</summary>\n [Shortcut(\"blender_actions.numeric_9\", \"9\", typeof(SceneViewportWidget), ShortcutType.Widget)]\n private static void Nine() => Append('9');\n\n /// <summary>Routes decimal input to the active numeric session.</summary>\n [Shortcut(\"blender_actions.numeric_decimal\", \".\", typeof(SceneViewportWidget), ShortcutType.Widget)]\n private static void Decimal() => ModalOperationArbiter.NumericInput?.EnterDecimal();\n\n /// <summary>Routes sign toggling to the active numeric session.</summary>\n [Shortcut(\"blender_actions.numeric_negative\", \"-\", typeof(SceneViewportWidget), ShortcutType.Widget)]\n private static void Negative() => ModalOperationArbiter.NumericInput?.ToggleNegative();\n\n /// <summary>Removes the last buffered numeric character.</summary>\n [Shortcut(\"blender_actions.numeric_backspace\", \"BACKSPACE\", typeof(SceneViewportWidget), ShortcutType.Widget)]\n private static void Backspace() => ModalOperationArbiter.NumericInput?.Backspace();\n\n /// <summary>Requests confirmation when the buffered numeric value is valid.</summary>\n [Shortcut(\"blender_actions.numeric_confirm\", \"ENTER\", typeof(SceneViewportWidget), ShortcutType.Widget)]\n private static void Confirm() => ModalOperationArbiter.NumericInput?.Confirm();\n\n /// <summary>Routes a digit to the active numeric session.</summary>\n private static void Append(char digit)\n {\n ModalOperationArbiter.NumericInput?.AppendDigit(digit);\n }\n}\n"
},
{
"Ident": "mikekotys.blender_actions",
"Path": "Editor/VertexSnapService.cs",
"FileName": "VertexSnapService.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 341223,
"Code": "#nullable enable\nusing Editor;\nusing Sandbox;\nusing System;\nusing System.Collections.Generic;\nusing System.Runtime.CompilerServices;\n\nnamespace BlenderActions;\n\n/// <summary>Represents the result of locating a target vertex.</summary>\npublic readonly record struct VertexSnapResult(bool Found, Vector3 Vertex);\n\n/// <summary>Stores reusable world-space source vertices for vertex snapping.</summary>\npublic sealed class VertexSnapSource\n{\n /// <summary>Handles new.</summary>\n internal List<Vector3> Vertices { get; } = new(4096);\n /// <summary>Handles new.</summary>\n internal HashSet<ModelRenderer> VisitedRenderers { get; } = new();\n\n /// <summary>Clears reusable source-vertex collections.</summary>\n internal void Clear()\n {\n Vertices.Clear();\n VisitedRenderers.Clear();\n }\n}\n\n/// <summary>Provides cached screen-space vertex snapping for modal transforms.</summary>\npublic static class VertexSnapService\n{\n /// <summary>Defines the maximum world distance used to trace a target renderer.</summary>\n private const float TraceLength = 100000f;\n /// <summary>Defines the logical screen-space radius used to acquire target vertices.</summary>\n private const float SnapRadiusPixels = 16f;\n /// <summary>Defines the projected-vertex spatial hash cell size in pixels.</summary>\n private const float ProjectionCellSize = 32f;\n /// <summary>Defines local-vertex quantization used for model vertex deduplication.</summary>\n private const float Quantization = 10000f;\n\n /// <summary>Handles new.</summary>\n private static ConditionalWeakTable<Model, CachedVertices> _vertexCache = new();\n /// <summary>Handles new.</summary>\n private static ConditionalWeakTable<ModelRenderer, ProjectedVertexIndex> _targetIndices = new();\n\n /// <summary>Captures current world-space vertices from selected model renderers.</summary>\n public static void CaptureSourceSnapshot(\n VertexSnapSource destination,\n IReadOnlyCollection<GameObject> selectedObjects)\n {\n destination.Clear();\n\n foreach(var selectedObject in selectedObjects)\n {\n if(!selectedObject.IsValid())\n continue;\n\n foreach(var renderer in selectedObject.GetComponentsInChildren<ModelRenderer>(\n includeDisabled: true,\n includeSelf: true))\n {\n if(renderer == null ||\n !destination.VisitedRenderers.Add(renderer) ||\n renderer.Model == null ||\n !renderer.Model.IsValid)\n {\n continue;\n }\n\n AppendWorldVertices(renderer, destination.Vertices);\n }\n }\n }\n\n /// <summary>Finds the nearest target vertex under the pointer on the traced renderer.</summary>\n public static VertexSnapResult FindTargetVertex(\n Scene scene,\n CameraComponent camera,\n SceneViewportWidget viewport,\n IReadOnlyCollection<GameObject> ignoredObjects)\n {\n var mousePosition = SceneViewportWidget.MousePosition;\n var ray = camera.ScreenPixelToRay(mousePosition);\n var trace = scene.Trace\n .Ray(ray, TraceLength)\n .UseRenderMeshes(true, true)\n .UseHitPosition(true);\n\n foreach(var gameObject in ignoredObjects)\n {\n if(gameObject.IsValid())\n trace = trace.IgnoreGameObjectHierarchy(gameObject);\n }\n\n var hit = trace.Run();\n\n if(!hit.Hit || hit.GameObject == null)\n return default;\n\n var renderer = hit.Component as ModelRenderer ??\n hit.GameObject.GetComponent<ModelRenderer>(true);\n\n if(renderer == null || renderer.Model == null || !renderer.Model.IsValid)\n return default;\n\n var threshold = SnapRadiusPixels * MathF.Max(viewport.DpiScale, 1f);\n var index = _targetIndices.GetValue(renderer, _ => new ProjectedVertexIndex());\n index.Update(renderer, camera);\n\n return index.TryFindNearest(mousePosition, threshold, out var vertex)\n ? new VertexSnapResult(true, vertex)\n : default;\n }\n\n /// <summary>Finds the source vertex closest on screen after translation.</summary>\n public static bool TryFindClosestTranslatedSource(\n VertexSnapSource source,\n CameraComponent camera,\n Vector3 target,\n Vector3 translation,\n out Vector3 sourceVertex)\n {\n return TryFindClosestSource(\n source,\n camera,\n target,\n SourceTransform.Translate,\n translation,\n Vector3.Zero,\n Rotation.Identity,\n Vector3.One,\n out sourceVertex);\n }\n\n /// <summary>Finds the source vertex closest on screen after rotation.</summary>\n public static bool TryFindClosestRotatedSource(\n VertexSnapSource source,\n CameraComponent camera,\n Vector3 target,\n Vector3 pivot,\n Rotation rotation,\n out Vector3 sourceVertex)\n {\n return TryFindClosestSource(\n source,\n camera,\n target,\n SourceTransform.Rotate,\n Vector3.Zero,\n pivot,\n rotation,\n Vector3.One,\n out sourceVertex);\n }\n\n /// <summary>Finds the source vertex closest on screen after scaling.</summary>\n public static bool TryFindClosestScaledSource(\n VertexSnapSource source,\n CameraComponent camera,\n Vector3 target,\n Vector3 pivot,\n Vector3 multiplier,\n out Vector3 sourceVertex)\n {\n return TryFindClosestSource(\n source,\n camera,\n target,\n SourceTransform.Scale,\n Vector3.Zero,\n pivot,\n Rotation.Identity,\n multiplier,\n out sourceVertex);\n }\n\n /// <summary>Clears model and projected-vertex caches after hotload.</summary>\n [EditorEvent.Hotload]\n private static void ClearCaches()\n {\n _vertexCache = new ConditionalWeakTable<Model, CachedVertices>();\n _targetIndices = new ConditionalWeakTable<ModelRenderer, ProjectedVertexIndex>();\n }\n\n /// <summary>Finds the screen-space closest source vertex after a supplied transform.</summary>\n private static bool TryFindClosestSource(\n VertexSnapSource source,\n CameraComponent camera,\n Vector3 target,\n SourceTransform transform,\n Vector3 translation,\n Vector3 pivot,\n Rotation rotation,\n Vector3 multiplier,\n out Vector3 sourceVertex)\n {\n sourceVertex = Vector3.Zero;\n\n if(source.Vertices.Count == 0)\n return false;\n\n var targetScreen = camera.PointToScreenPixels(target, out var targetBehind);\n\n if(targetBehind)\n return false;\n\n var bestDistance = float.MaxValue;\n var found = false;\n\n for(var index = 0; index < source.Vertices.Count; index++)\n {\n var original = source.Vertices[index];\n var candidate = transform switch\n {\n SourceTransform.Translate => original + translation,\n SourceTransform.Rotate => pivot + rotation * (original - pivot),\n SourceTransform.Scale => pivot + (original - pivot).MultiplyComponents(multiplier),\n _ => original\n };\n\n var screen = camera.PointToScreenPixels(candidate, out var isBehind);\n\n if(isBehind)\n continue;\n\n var distance = (screen - targetScreen).Length;\n\n if(distance >= bestDistance)\n continue;\n\n bestDistance = distance;\n sourceVertex = candidate;\n found = true;\n }\n\n return found;\n }\n\n /// <summary>Appends one renderer's transformed model vertices to a reusable destination.</summary>\n private static void AppendWorldVertices(\n ModelRenderer renderer,\n List<Vector3> destination)\n {\n var vertices = GetVertices(renderer.Model);\n\n for(var index = 0; index < vertices.Length; index++)\n destination.Add(ToWorld(renderer.GameObject, vertices[index]));\n }\n\n /// <summary>Transforms a local model vertex into world space.</summary>\n private static Vector3 ToWorld(GameObject gameObject, Vector3 localVertex)\n {\n var scaled = localVertex.MultiplyComponents(gameObject.WorldScale);\n return gameObject.WorldPosition + gameObject.WorldRotation * scaled;\n }\n\n /// <summary>Returns cached deduplicated local-space vertices for a model.</summary>\n private static Vector3[] GetVertices(Model model)\n {\n return _vertexCache.GetValue(model, CreateCache).Vertices;\n }\n\n /// <summary>Creates a deduplicated local-space vertex cache for a model.</summary>\n private static CachedVertices CreateCache(Model model)\n {\n var unique = new Dictionary<QuantizedVertex, Vector3>();\n\n foreach(var vertex in model.GetVertices())\n {\n var position = vertex.Position;\n var key = new QuantizedVertex(\n (int)MathF.Round(position.x * Quantization),\n (int)MathF.Round(position.y * Quantization),\n (int)MathF.Round(position.z * Quantization));\n\n if(!unique.ContainsKey(key))\n unique.Add(key, position);\n }\n\n var vertices = new Vector3[unique.Count];\n unique.Values.CopyTo(vertices, 0);\n return new CachedVertices(vertices);\n }\n\n /// <summary>Combines two screen-space cell coordinates into one dictionary key.</summary>\n private static long CellKey(int x, int y)\n {\n return ((long)x << 32) ^ (uint)y;\n }\n\n /// <summary>Identifies the transform applied while evaluating source vertices.</summary>\n private enum SourceTransform\n {\n Translate,\n Rotate,\n Scale\n }\n\n /// <summary>Stores deduplicated local-space vertices for one model.</summary>\n private sealed class CachedVertices\n {\n /// <summary>Initializes a new cached vertices instance.</summary>\n public CachedVertices(Vector3[] vertices)\n {\n Vertices = vertices;\n }\n\n /// <summary>Gets the reusable captured world-space vertex list.</summary>\n public Vector3[] Vertices { get; }\n }\n\n /// <summary>Indexes one renderer's projected vertices in screen-space cells.</summary>\n private sealed class ProjectedVertexIndex\n {\n /// <summary>Handles new.</summary>\n private readonly Dictionary<long, List<ProjectedVertex>> _cells = new();\n /// <summary>Handles new.</summary>\n private readonly Stack<List<ProjectedVertex>> _bucketPool = new();\n\n /// <summary>Stores the model represented by the current projected index.</summary>\n private Model? _model;\n /// <summary>Stores the indexed renderer world position.</summary>\n private Vector3 _objectPosition;\n /// <summary>Stores the indexed renderer world rotation.</summary>\n private Rotation _objectRotation;\n /// <summary>Stores the indexed renderer world scale.</summary>\n private Vector3 _objectScale;\n /// <summary>Stores the camera position used to build the index.</summary>\n private Vector3 _cameraPosition;\n /// <summary>Stores the camera rotation used to build the index.</summary>\n private Rotation _cameraRotation;\n /// <summary>Stores the camera render size used to build the index.</summary>\n private Vector2? _cameraSize;\n /// <summary>Stores the perspective field of view used to build the index.</summary>\n private float _fieldOfView;\n /// <summary>Stores the orthographic height used to build the index.</summary>\n private float _orthographicHeight;\n /// <summary>Tracks whether the indexed camera uses orthographic projection.</summary>\n private bool _orthographic;\n\n /// <summary>Rebuilds the projected vertex index when renderer or camera state changes.</summary>\n public void Update(ModelRenderer renderer, CameraComponent camera)\n {\n var gameObject = renderer.GameObject;\n var cameraObject = camera.GameObject;\n\n if(ReferenceEquals(_model, renderer.Model) &&\n _objectPosition.Equals(gameObject.WorldPosition) &&\n _objectRotation.Equals(gameObject.WorldRotation) &&\n _objectScale.Equals(gameObject.WorldScale) &&\n _cameraPosition.Equals(cameraObject.WorldPosition) &&\n _cameraRotation.Equals(cameraObject.WorldRotation) &&\n _cameraSize.Equals(camera.CustomSize) &&\n _fieldOfView.Equals(camera.FieldOfView) &&\n _orthographicHeight.Equals(camera.OrthographicHeight) &&\n _orthographic == camera.Orthographic)\n {\n return;\n }\n\n RecycleCells();\n _model = renderer.Model;\n _objectPosition = gameObject.WorldPosition;\n _objectRotation = gameObject.WorldRotation;\n _objectScale = gameObject.WorldScale;\n _cameraPosition = cameraObject.WorldPosition;\n _cameraRotation = cameraObject.WorldRotation;\n _cameraSize = camera.CustomSize;\n _fieldOfView = camera.FieldOfView;\n _orthographicHeight = camera.OrthographicHeight;\n _orthographic = camera.Orthographic;\n\n var vertices = GetVertices(renderer.Model);\n\n for(var index = 0; index < vertices.Length; index++)\n {\n var world = ToWorld(gameObject, vertices[index]);\n var screen = camera.PointToScreenPixels(world, out var isBehind);\n\n if(isBehind)\n continue;\n\n var cellX = (int)MathF.Floor(screen.x / ProjectionCellSize);\n var cellY = (int)MathF.Floor(screen.y / ProjectionCellSize);\n var key = CellKey(cellX, cellY);\n\n if(!_cells.TryGetValue(key, out var bucket))\n {\n bucket = _bucketPool.Count > 0\n ? _bucketPool.Pop()\n : new List<ProjectedVertex>();\n _cells.Add(key, bucket);\n }\n\n bucket.Add(new ProjectedVertex(screen, world));\n }\n }\n\n /// <summary>Finds the nearest indexed vertex within a screen-space radius.</summary>\n public bool TryFindNearest(\n Vector2 screenPosition,\n float radius,\n out Vector3 worldVertex)\n {\n worldVertex = Vector3.Zero;\n var centerX = (int)MathF.Floor(screenPosition.x / ProjectionCellSize);\n var centerY = (int)MathF.Floor(screenPosition.y / ProjectionCellSize);\n var cellRadius = Math.Max(1, (int)MathF.Ceiling(radius / ProjectionCellSize));\n var bestSquaredDistance = radius * radius;\n var found = false;\n\n for(var x = centerX - cellRadius; x <= centerX + cellRadius; x++)\n {\n for(var y = centerY - cellRadius; y <= centerY + cellRadius; y++)\n {\n if(!_cells.TryGetValue(CellKey(x, y), out var bucket))\n continue;\n\n for(var index = 0; index < bucket.Count; index++)\n {\n var candidate = bucket[index];\n var delta = candidate.Screen - screenPosition;\n var squaredDistance = delta.x * delta.x + delta.y * delta.y;\n\n if(squaredDistance > bestSquaredDistance)\n continue;\n\n bestSquaredDistance = squaredDistance;\n worldVertex = candidate.World;\n found = true;\n }\n }\n }\n\n return found;\n }\n\n /// <summary>Clears projected cells and returns their lists to the bucket pool.</summary>\n private void RecycleCells()\n {\n foreach(var bucket in _cells.Values)\n {\n bucket.Clear();\n _bucketPool.Push(bucket);\n }\n\n _cells.Clear();\n }\n }\n\n /// <summary>Pairs a projected screen position with its world-space vertex.</summary>\n private readonly record struct ProjectedVertex(Vector2 Screen, Vector3 World);\n /// <summary>Provides a quantized key for deduplicating model vertices.</summary>\n private readonly record struct QuantizedVertex(int X, int Y, int Z);\n}\n"
},
{
"Ident": "mikekotys.blender_actions",
"Path": "Editor/ThreeDCursor.cs",
"FileName": "ThreeDCursor.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 341223,
"Code": "#nullable enable\nusing Editor;\nusing Sandbox;\nusing System;\nusing System.Reflection;\nusing System.Runtime.CompilerServices;\n\nnamespace BlenderActions;\n\n/// <summary>Stores, positions, and renders the per-session Blender-style 3D cursor.</summary>\npublic static class ThreeDCursor\n{\n /// <summary>Handles new.</summary>\n private static readonly CursorGizmoBridge GizmoBridge = new();\n /// <summary>Handles new.</summary>\n private static ConditionalWeakTable<SceneEditorSession, CursorState> _states = new();\n\n /// <summary>Returns state for the active editor session and optionally creates it.</summary>\n public static Vector3 Position => GetCurrentState(false)?.Position ?? Vector3.Zero;\n /// <summary>Returns state for the active editor session and optionally creates it.</summary>\n public static bool UseAsTransformPivot => GetCurrentState(false)?.UseAsTransformPivot ?? false;\n\n /// <summary>Resets the active session cursor to the world origin.</summary>\n [Shortcut(\"blender_actions.cursor_reset\", \"SHIFT+C\", typeof(SceneViewportWidget), ShortcutType.Widget)]\n private static void ResetPosition()\n {\n var state = GetCurrentState(true);\n\n if(state != null)\n state.Position = Vector3.Zero;\n }\n\n /// <summary>Toggles use of the 3D cursor as the transform pivot.</summary>\n [Shortcut(\"blender_actions.cursor_toggle_pivot\", \"ALT+C\", typeof(SceneViewportWidget), ShortcutType.Widget)]\n private static void ToggleTransformPivot()\n {\n var state = GetCurrentState(true);\n\n if(state != null)\n state.UseAsTransformPivot = !state.UseAsTransformPivot;\n }\n\n /// <summary>Advances active modal operations once per editor tool frame.</summary>\n [Event(\"tool.frame\")]\n private static void OnToolFrame()\n {\n CheckSetCursorChord();\n DrawCursor();\n }\n\n /// <summary>Aborts active operations and resets session state after hotload.</summary>\n [EditorEvent.Hotload]\n private static void OnHotload()\n {\n _states = new ConditionalWeakTable<SceneEditorSession, CursorState>();\n }\n\n /// <summary>Detects the cursor-placement modifier chord without interfering with modal operations.</summary>\n private static void CheckSetCursorChord()\n {\n var state = GetCurrentState(true);\n\n if(state == null)\n return;\n\n var modifiers = Editor.Application.KeyboardModifiers;\n var required =\n KeyboardModifiers.Alt |\n KeyboardModifiers.Ctrl |\n KeyboardModifiers.Shift;\n var chordDown = (modifiers & required) == required;\n\n if(ModalOperationArbiter.Active != null)\n {\n state.SetCursorChordDown = chordDown;\n return;\n }\n\n if(chordDown && !state.SetCursorChordDown)\n SetAtNearestVertex(state);\n\n state.SetCursorChordDown = chordDown;\n }\n\n /// <summary>Moves the cursor to the nearest target vertex under the pointer.</summary>\n private static void SetAtNearestVertex(CursorState state)\n {\n var sceneView = SceneViewWidget.Current;\n var viewport = sceneView?.LastSelectedViewportWidget;\n var session = SceneEditorSession.Active;\n var camera =\n sceneView?.Tools.CurrentSubTool?.Camera ??\n sceneView?.Tools.CurrentTool?.Camera;\n\n if(viewport == null || session == null || camera == null)\n return;\n\n var result = VertexSnapService.FindTargetVertex(\n session.Scene,\n camera,\n viewport,\n Array.Empty<GameObject>());\n\n if(result.Found)\n state.Position = result.Vertex;\n }\n\n /// <summary>Draws the active session cursor in the current scene viewport.</summary>\n private static void DrawCursor()\n {\n var state = GetCurrentState(false);\n\n if(state == null)\n return;\n\n var sceneView = SceneViewWidget.Current;\n var viewport = sceneView?.LastSelectedViewportWidget;\n var camera =\n sceneView?.Tools.CurrentSubTool?.Camera ??\n sceneView?.Tools.CurrentTool?.Camera;\n\n if(viewport == null || !viewport.IsValid || camera == null)\n return;\n\n GizmoBridge.Draw(viewport, camera, state.Position);\n }\n\n /// <summary>Returns state for the active editor session and optionally creates it.</summary>\n private static CursorState? GetCurrentState(bool create)\n {\n var session = SceneEditorSession.Active;\n\n if(session == null)\n return null;\n\n if(create)\n return _states.GetValue(session, _ => new CursorState());\n\n return _states.TryGetValue(session, out var state)\n ? state\n : null;\n }\n\n /// <summary>Stores 3D cursor state associated with one editor session.</summary>\n private sealed class CursorState\n {\n /// <summary>Gets the active editor session's 3D cursor position.</summary>\n public Vector3 Position { get; set; }\n /// <summary>Gets whether the active session uses the 3D cursor as transform pivot.</summary>\n public bool UseAsTransformPivot { get; set; }\n /// <summary>Gets set cursor chord down.</summary>\n public bool SetCursorChordDown { get; set; }\n }\n\n /// <summary>Renders the 3D cursor through an isolated gizmo instance.</summary>\n private sealed class CursorGizmoBridge\n {\n /// <summary>Handles new.</summary>\n private readonly Gizmo.Instance _instance = new();\n /// <summary>Handles typeof.</summary>\n private readonly FieldInfo? _worldField = typeof(Gizmo.Instance).GetField(\n \"_world\",\n BindingFlags.Instance | BindingFlags.NonPublic);\n\n /// <summary>Draws a camera-facing cursor ring through the isolated gizmo bridge.</summary>\n public void Draw(\n SceneViewportWidget viewport,\n CameraComponent camera,\n Vector3 position)\n {\n var world = viewport.GizmoInstance.World;\n\n if(world == null || _worldField == null)\n return;\n\n if(_instance.World != world)\n _worldField.SetValue(_instance, world);\n\n _instance.Settings = viewport.GizmoInstance.Settings;\n\n var toCamera = camera.GameObject.WorldPosition - position;\n\n if(toCamera.Length < 0.001f)\n return;\n\n var rotation = Rotation.LookAt(toCamera.Normal);\n var radius = MathF.Max(toCamera.Length * 0.015f, 2f);\n\n using(_instance.Push())\n using(Gizmo.Scope(\n \"blender-actions-3d-cursor\",\n position,\n rotation,\n 1f))\n {\n Gizmo.Draw.LineCircle(0, radius);\n }\n }\n }\n}\n"
},
{
"Ident": "mikekotys.blender_actions",
"Path": "Editor/ModalOperationArbiter.cs",
"FileName": "ModalOperationArbiter.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 341223,
"Code": "#nullable enable\nusing Editor;\nusing Sandbox;\nusing System;\nusing System.Collections.Generic;\nusing System.Runtime.CompilerServices;\n\nnamespace BlenderActions;\n\n/// <summary>Coordinates one active modal transform operation per editor session.</summary>\npublic static class ModalOperationArbiter\n{\n /// <summary>Handles new.</summary>\n private static ConditionalWeakTable<SceneEditorSession, SessionState> _states = new();\n /// <summary>Handles new.</summary>\n private static readonly List<WeakReference<SessionState>> StateReferences = new();\n\n /// <summary>Returns state for the active editor session and optionally creates it.</summary>\n public static ModalTransformOperation? Active => GetCurrentState(false)?.Active;\n /// <summary>Gets numeric input associated with the current modal operation.</summary>\n public static NumericInputSession? NumericInput => Active?.NumericInput;\n\n /// <summary>Starts an operation for the active editor session when no operation is already running.</summary>\n public static void Start(ModalTransformOperation operation)\n {\n var session = SceneEditorSession.Active;\n\n if(session == null)\n return;\n\n var state = GetState(session);\n\n if(state.Active != null)\n return;\n\n state.Active = operation;\n\n try\n {\n if(!operation.Begin(session))\n state.Active = null;\n }\n catch\n {\n state.Active = null;\n throw;\n }\n }\n\n /// <summary>Applies or toggles an axis constraint on the active operation.</summary>\n public static void SetConstraint(AxisConstraint constraint)\n {\n GetCurrentState(false)?.Active?.SetConstraint(constraint);\n }\n\n /// <summary>Cancels the active operation for the current editor session.</summary>\n public static void Cancel()\n {\n GetCurrentState(false)?.Active?.Abort();\n }\n\n /// <summary>Releases an operation from its bound session state.</summary>\n internal static void Release(ModalTransformOperation operation)\n {\n var session = operation.BoundSession;\n\n if(session == null || !_states.TryGetValue(session, out var state))\n return;\n\n if(ReferenceEquals(state.Active, operation))\n state.Active = null;\n }\n\n /// <summary>Advances active modal operations once per editor tool frame.</summary>\n [Event(\"tool.frame\")]\n private static void OnToolFrame()\n {\n for(var index = StateReferences.Count - 1; index >= 0; index--)\n {\n if(!StateReferences[index].TryGetTarget(out var state))\n {\n StateReferences.RemoveAt(index);\n continue;\n }\n\n state.Active?.Tick();\n }\n }\n\n /// <summary>Aborts active operations and resets session state after hotload.</summary>\n [EditorEvent.Hotload]\n private static void OnHotload()\n {\n for(var index = StateReferences.Count - 1; index >= 0; index--)\n {\n if(StateReferences[index].TryGetTarget(out var state))\n state.Active?.Abort();\n }\n\n StateReferences.Clear();\n _states = new ConditionalWeakTable<SceneEditorSession, SessionState>();\n }\n\n /// <summary>Returns state for the active editor session and optionally creates it.</summary>\n private static SessionState? GetCurrentState(bool create)\n {\n var session = SceneEditorSession.Active;\n\n if(session == null)\n return null;\n\n if(create)\n return GetState(session);\n\n return _states.TryGetValue(session, out var state)\n ? state\n : null;\n }\n\n /// <summary>Returns or creates modal state for a specific editor session.</summary>\n private static SessionState GetState(SceneEditorSession session)\n {\n if(_states.TryGetValue(session, out var state))\n return state;\n\n state = new SessionState();\n _states.Add(session, state);\n StateReferences.Add(new WeakReference<SessionState>(state));\n return state;\n }\n\n /// <summary>Stores modal operation state associated with one editor session.</summary>\n private sealed class SessionState\n {\n /// <summary>Gets or sets the active modal operation for this session.</summary>\n public ModalTransformOperation? Active { get; set; }\n }\n}\n"
},
{
"Ident": "mikekotys.blender_actions",
"Path": "Editor/TransformStates.cs",
"FileName": "TransformStates.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 341223,
"Code": "#nullable enable\nusing Sandbox;\n\nnamespace BlenderActions;\n\n/// <summary>Captures one game object and its world position.</summary>\ninternal readonly record struct PositionState(GameObject Object, Vector3 Position);\n/// <summary>Captures one game object, world position, and world rotation.</summary>\ninternal readonly record struct RotationState(GameObject Object, Vector3 Position, Rotation Rotation);\n/// <summary>Captures one game object, world position, and world scale.</summary>\ninternal readonly record struct ScaleState(GameObject Object, Vector3 Position, Vector3 Scale);\n"
},
{
"Ident": "mikekotys.blender_actions",
"Path": "Editor/Vector3Extensions.cs",
"FileName": "Vector3Extensions.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 341223,
"Code": "#nullable enable\nusing Sandbox;\n\nnamespace BlenderActions;\n\n/// <summary>Provides component-wise vector helpers used by transform operations.</summary>\ninternal static class Vector3Extensions\n{\n /// <summary>Returns the component-wise product of two vectors.</summary>\n public static Vector3 MultiplyComponents(this Vector3 left, Vector3 right)\n {\n return new Vector3(\n left.x * right.x,\n left.y * right.y,\n left.z * right.z);\n }\n}\n"
},
{
"Ident": "mikekotys.blender_actions",
"Path": "Editor/ScaleOperation.cs",
"FileName": "ScaleOperation.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 341223,
"Code": "#nullable enable\nusing Editor;\nusing Sandbox;\nusing System;\n\nnamespace BlenderActions;\n\n/// <summary>Implements Blender-style modal scaling for selected scene objects.</summary>\npublic sealed class ScaleOperation : ModalTransformOperation\n{\n /// <summary>Defines the smallest scale produced by interactive pointer input.</summary>\n private const float MinimumInteractiveScale = 0.001f;\n /// <summary>Defines the smallest scale accepted from vertex snapping.</summary>\n private const float MinimumSnapScale = 0.001f;\n /// <summary>Defines the near-zero threshold used by component-wise division.</summary>\n private const float SafeDivisionEpsilon = 0.001f;\n\n /// <summary>Handles new.</summary>\n private readonly VertexSnapSource _snapSource = new();\n /// <summary>Handles states.</summary>\n private ScaleState[] _states = Array.Empty<ScaleState>();\n\n /// <summary>Stores the world-space pivot used by the current operation.</summary>\n private Vector3 _selectionPivot;\n /// <summary>Stores the pivot projected into viewport input pixels.</summary>\n private Vector2 _pivotInputPosition;\n /// <summary>Stores the pointer position observed on the previous frame.</summary>\n private Vector2 _lastMousePosition;\n /// <summary>Stores pointer movement accumulated with precision scaling.</summary>\n private Vector2 _effectiveMousePosition;\n /// <summary>Stores the initial pointer distance from the scale pivot.</summary>\n private float _originalDistance;\n /// <summary>Stores the scale multiplier currently applied to selected objects.</summary>\n private Vector3 _appliedMultiplier = Vector3.One;\n /// <summary>Stores the vertex currently locking a snapped transform.</summary>\n private Vector3 _lockedTargetVertex;\n /// <summary>Tracks whether a snapped target vertex is currently locked.</summary>\n private bool _hasLockedTarget;\n\n /// <summary>Gets the operation kind.</summary>\n public override TransformOperationKind Kind => TransformOperationKind.Scale;\n\n /// <summary>Captures operation-specific initial state.</summary>\n protected override void OnBegin()\n {\n _states = new ScaleState[SelectedObjects.Length];\n _selectionPivot = Vector3.Zero;\n\n for(var index = 0; index < SelectedObjects.Length; index++)\n {\n var gameObject = SelectedObjects[index];\n _states[index] = new ScaleState(\n gameObject,\n gameObject.WorldPosition,\n gameObject.WorldScale);\n _selectionPivot += gameObject.WorldPosition;\n }\n\n _selectionPivot /= _states.Length;\n\n if(ThreeDCursor.UseAsTransformPivot)\n _selectionPivot = ThreeDCursor.Position;\n\n _pivotInputPosition = CameraPixelsToInputPixels(\n Camera.PointToScreenPixels(_selectionPivot));\n _lastMousePosition = SceneViewportWidget.MousePosition;\n _effectiveMousePosition = _lastMousePosition;\n _originalDistance = (_lastMousePosition - _pivotInputPosition).Length;\n\n if(_originalDistance < 1f ||\n float.IsNaN(_originalDistance) ||\n float.IsInfinity(_originalDistance))\n {\n _originalDistance = 1f;\n }\n\n _appliedMultiplier = Vector3.One;\n _lockedTargetVertex = Vector3.Zero;\n _hasLockedTarget = false;\n }\n\n /// <summary>Updates the operation from current editor input.</summary>\n protected override void OnUpdate()\n {\n var snapEnabled =\n (Editor.Application.KeyboardModifiers & KeyboardModifiers.Ctrl) != 0 &&\n !NumericInput.HasValue;\n\n var currentMousePosition = SceneViewportWidget.MousePosition;\n var frameMouseDelta = currentMousePosition - _lastMousePosition;\n _lastMousePosition = currentMousePosition;\n\n if(!snapEnabled)\n {\n var precision =\n (Editor.Application.KeyboardModifiers & KeyboardModifiers.Shift) != 0;\n _effectiveMousePosition += frameMouseDelta *\n (precision ? PrecisionMultiplier : 1f);\n }\n\n var scaleFactor = NumericInput.TryGetValue(out var numericScale)\n ? numericScale\n : (_effectiveMousePosition - _pivotInputPosition).Length / _originalDistance;\n\n if(float.IsNaN(scaleFactor) || float.IsInfinity(scaleFactor))\n return;\n\n if(!NumericInput.HasValue)\n scaleFactor = MathF.Max(scaleFactor, MinimumInteractiveScale);\n\n var multiplier = GetScaleMultiplier(scaleFactor);\n\n if(!snapEnabled)\n {\n _appliedMultiplier = multiplier;\n _lockedTargetVertex = Vector3.Zero;\n _hasLockedTarget = false;\n ApplyScale(_appliedMultiplier);\n return;\n }\n\n var target = VertexSnapService.FindTargetVertex(\n Session.Scene,\n Camera,\n Viewport,\n SelectedObjects);\n\n var targetChanged = target.Found &&\n (!_hasLockedTarget ||\n (target.Vertex - _lockedTargetVertex).Length > 0.001f);\n\n if(!targetChanged)\n return;\n\n VertexSnapService.CaptureSourceSnapshot(_snapSource, SelectedObjects);\n\n if(!VertexSnapService.TryFindClosestScaledSource(\n _snapSource,\n Camera,\n target.Vertex,\n _selectionPivot,\n Vector3.One,\n out var sourceVertex) ||\n !TryGetAbsoluteSnapMultiplier(\n sourceVertex,\n target.Vertex,\n _appliedMultiplier,\n out var snapMultiplier))\n {\n return;\n }\n\n _appliedMultiplier = snapMultiplier;\n _lockedTargetVertex = target.Vertex;\n _hasLockedTarget = true;\n ApplyScale(_appliedMultiplier);\n }\n\n /// <summary>Restores every transformed object to its captured initial state.</summary>\n protected override void RestoreInitialState()\n {\n ApplyStates(_states);\n }\n\n /// <summary>Registers undo and redo callbacks for the completed operation.</summary>\n protected override void RegisterUndo()\n {\n var before = (ScaleState[])_states.Clone();\n var after = CaptureCurrentStates(_states);\n\n Session.AddUndo(\n \"Blender Scale\",\n () => ApplyStates(before),\n () => ApplyStates(after));\n }\n\n /// <summary>Resets operation-specific state after the active constraint changes.</summary>\n protected override void OnConstraintChanged()\n {\n _appliedMultiplier = Vector3.One;\n _lockedTargetVertex = Vector3.Zero;\n _hasLockedTarget = false;\n }\n\n /// <summary>Releases operation-specific state during cleanup.</summary>\n protected override void OnCleanup()\n {\n _states = Array.Empty<ScaleState>();\n _snapSource.Clear();\n _appliedMultiplier = Vector3.One;\n _lockedTargetVertex = Vector3.Zero;\n _hasLockedTarget = false;\n }\n\n /// <summary>Returns component scale multipliers for the active constraint.</summary>\n private Vector3 GetScaleMultiplier(float factor)\n {\n return Constraint switch\n {\n AxisConstraint.X => new Vector3(factor, 1f, 1f),\n AxisConstraint.Y => new Vector3(1f, factor, 1f),\n AxisConstraint.Z => new Vector3(1f, 1f, factor),\n AxisConstraint.YZ => new Vector3(1f, factor, factor),\n AxisConstraint.XZ => new Vector3(factor, 1f, factor),\n AxisConstraint.XY => new Vector3(factor, factor, 1f),\n _ => new Vector3(factor, factor, factor)\n };\n }\n\n /// <summary>Attempts to calculate an absolute scale multiplier that aligns two vertices.</summary>\n private bool TryGetAbsoluteSnapMultiplier(\n Vector3 transformedSource,\n Vector3 target,\n Vector3 currentMultiplier,\n out Vector3 multiplier)\n {\n multiplier = Vector3.One;\n var currentSourceOffset = transformedSource - _selectionPivot;\n var originalSourceOffset = DivideSafe(currentSourceOffset, currentMultiplier);\n var targetOffset = target - _selectionPivot;\n var mask = GetConstraintMask();\n var fixedOffset = originalSourceOffset.MultiplyComponents(Vector3.One - mask);\n var scalableOffset = originalSourceOffset.MultiplyComponents(mask);\n var denominator = Vector3.Dot(scalableOffset, scalableOffset);\n\n if(denominator < 0.0000001f)\n return false;\n\n var factor = Vector3.Dot(\n scalableOffset,\n targetOffset - fixedOffset) / denominator;\n\n if(float.IsNaN(factor) || float.IsInfinity(factor))\n return false;\n\n if(factor < MinimumSnapScale)\n return false;\n\n multiplier = Vector3.One - mask + mask * factor;\n return true;\n }\n\n /// <summary>Returns the component mask represented by the active scale constraint.</summary>\n private Vector3 GetConstraintMask()\n {\n if(Constraint == AxisConstraint.None)\n return Vector3.One;\n\n return new Vector3(\n (Constraint & AxisConstraint.X) != 0 ? 1f : 0f,\n (Constraint & AxisConstraint.Y) != 0 ? 1f : 0f,\n (Constraint & AxisConstraint.Z) != 0 ? 1f : 0f);\n }\n\n /// <summary>Applies world scale and pivot-relative position changes to captured objects.</summary>\n private void ApplyScale(Vector3 multiplier)\n {\n for(var index = 0; index < _states.Length; index++)\n {\n var state = _states[index];\n\n if(!state.Object.IsValid())\n continue;\n\n state.Object.WorldScale = state.Scale.MultiplyComponents(multiplier);\n var offset = state.Position - _selectionPivot;\n state.Object.WorldPosition =\n _selectionPivot + offset.MultiplyComponents(multiplier);\n }\n }\n\n /// <summary>Divides vector components while guarding near-zero divisors.</summary>\n private static Vector3 DivideSafe(Vector3 value, Vector3 divisor)\n {\n return new Vector3(\n MathF.Abs(divisor.x) >= SafeDivisionEpsilon ? value.x / divisor.x : 0f,\n MathF.Abs(divisor.y) >= SafeDivisionEpsilon ? value.y / divisor.y : 0f,\n MathF.Abs(divisor.z) >= SafeDivisionEpsilon ? value.z / divisor.z : 0f);\n }\n\n /// <summary>Captures current position and rotation values for undo or redo.</summary>\n private static ScaleState[] CaptureCurrentStates(ScaleState[] source)\n {\n var result = new ScaleState[source.Length];\n\n for(var index = 0; index < source.Length; index++)\n {\n var state = source[index];\n result[index] = state.Object.IsValid()\n ? new ScaleState(\n state.Object,\n state.Object.WorldPosition,\n state.Object.WorldScale)\n : state;\n }\n\n return result;\n }\n\n /// <summary>Applies captured transform states to valid game objects.</summary>\n private static void ApplyStates(ScaleState[] states)\n {\n for(var index = 0; index < states.Length; index++)\n {\n var state = states[index];\n\n if(!state.Object.IsValid())\n continue;\n\n state.Object.WorldPosition = state.Position;\n state.Object.WorldScale = state.Scale;\n }\n }\n}\n"
}
]
}