🔍 s&box Package Code Search

Search C# source code, UI razor templates, shaders, and configs across s&box packages.

Showing code results for query: * (13 total matches found)
mikekotys.blender_actions / Editor/TranslateOperation.cs
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;
        }
    }
}
mikekotys.blender_actions / Editor/ViewportInputLock.cs
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;
        }
    }
}
mikekotys.blender_actions / Editor/RotateOperation.cs
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;
        }
    }
}
mikekotys.blender_actions / Editor/TransformShortcuts.cs
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();
    }
}
mikekotys.blender_actions / .obj/__compiler_extra.cs
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")]
mikekotys.blender_actions / Editor/ModalTransformOperation.cs
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>();
    }
}
mikekotys.blender_actions / Editor/NumericInputSession.cs
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);
    }
}
mikekotys.blender_actions / Editor/VertexSnapService.cs
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);
}
mikekotys.blender_actions / Editor/ThreeDCursor.cs
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);
            }
        }
    }
}
mikekotys.blender_actions / Editor/ModalOperationArbiter.cs
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; }
    }
}
mikekotys.blender_actions / Editor/TransformStates.cs
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);
mikekotys.blender_actions / Editor/Vector3Extensions.cs
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);
    }
}
mikekotys.blender_actions / Editor/ScaleOperation.cs
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"
        }
    ]
}