{"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/// \u003Csummary\u003EImplements Blender-style modal translation for selected scene objects.\u003C/summary\u003E\npublic sealed class TranslateOperation : ModalTransformOperation\n{\n    /// \u003Csummary\u003EDefines the world-space camera-plane probe used to invert screen projection.\u003C/summary\u003E\n    private const float ProjectionProbeDistance = 100f;\n    /// \u003Csummary\u003EDefines the minimum stable determinant accepted for projection inversion.\u003C/summary\u003E\n    private const float ProjectionEpsilon = 0.000001f;\n\n    /// \u003Csummary\u003EHandles new.\u003C/summary\u003E\n    private readonly VertexSnapSource _snapSource = new();\n    /// \u003Csummary\u003EHandles states.\u003C/summary\u003E\n    private PositionState[] _states = Array.Empty\u003CPositionState\u003E();\n\n    /// \u003Csummary\u003EStores the world-space pivot used by the current operation.\u003C/summary\u003E\n    private Vector3 _selectionPivot;\n    /// \u003Csummary\u003EStores the pointer position observed on the previous frame.\u003C/summary\u003E\n    private Vector2 _lastMousePosition;\n    /// \u003Csummary\u003EStores pointer movement accumulated with precision scaling.\u003C/summary\u003E\n    private Vector2 _accumulatedMouseDelta;\n    /// \u003Csummary\u003EStores the world-space displacement represented by one camera pixel on screen X.\u003C/summary\u003E\n    private Vector3 _screenXWorldDelta;\n    /// \u003Csummary\u003EStores the world-space displacement represented by one camera pixel on screen Y.\u003C/summary\u003E\n    private Vector3 _screenYWorldDelta;\n    /// \u003Csummary\u003EStores the translation currently applied to selected objects.\u003C/summary\u003E\n    private Vector3 _appliedWorldDelta;\n    /// \u003Csummary\u003EStores the vertex currently locking a snapped transform.\u003C/summary\u003E\n    private Vector3 _lockedTargetVertex;\n    /// \u003Csummary\u003ETracks whether a snapped target vertex is currently locked.\u003C/summary\u003E\n    private bool _hasLockedTarget;\n\n    /// \u003Csummary\u003EGets the operation kind.\u003C/summary\u003E\n    public override TransformOperationKind Kind =\u003E TransformOperationKind.Translate;\n\n    /// \u003Csummary\u003ECaptures operation-specific initial state.\u003C/summary\u003E\n    protected override void OnBegin()\n    {\n        _states = new PositionState[SelectedObjects.Length];\n        _selectionPivot = Vector3.Zero;\n\n        for(var index = 0; index \u003C SelectedObjects.Length; index\u002B\u002B)\n        {\n            var gameObject = SelectedObjects[index];\n            _states[index] = new PositionState(gameObject, gameObject.WorldPosition);\n            _selectionPivot \u002B= 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 \u002B cameraRight * ProjectionProbeDistance);\n        var upScreen = Camera.PointToScreenPixels(\n            _selectionPivot \u002B 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) \u003C ProjectionEpsilon)\n            throw new InvalidOperationException(\u0022Camera projection cannot be inverted.\u0022);\n\n        _screenXWorldDelta =\n            cameraRight * (upPixelsPerUnit.y / determinant) -\n            cameraUp * (rightPixelsPerUnit.y / determinant);\n        _screenYWorldDelta =\n            cameraRight * (-upPixelsPerUnit.x / determinant) \u002B\n            cameraUp * (rightPixelsPerUnit.x / determinant);\n    }\n\n    /// \u003Csummary\u003EUpdates the operation from current editor input.\u003C/summary\u003E\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 \u0026 KeyboardModifiers.Shift) != 0;\n        _accumulatedMouseDelta \u002B= frameMouseDelta *\n            (precision ? PrecisionMultiplier : 1f);\n\n        var pixelDelta = InputPixelsToCameraPixels(_accumulatedMouseDelta);\n        var worldDelta =\n            _screenXWorldDelta * pixelDelta.x \u002B\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 \u0026 KeyboardModifiers.Ctrl) != 0 \u0026\u0026\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 \u0026\u0026\n            (!_hasLockedTarget ||\n             (target.Vertex - _lockedTargetVertex).Length \u003E 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 \u002B= correction;\n        _lockedTargetVertex = target.Vertex;\n        _hasLockedTarget = true;\n        ApplyTranslation(_appliedWorldDelta);\n    }\n\n    /// \u003Csummary\u003ERestores every transformed object to its captured initial state.\u003C/summary\u003E\n    protected override void RestoreInitialState()\n    {\n        ApplyPositions(_states);\n    }\n\n    /// \u003Csummary\u003ERegisters undo and redo callbacks for the completed operation.\u003C/summary\u003E\n    protected override void RegisterUndo()\n    {\n        var before = (PositionState[])_states.Clone();\n        var after = CaptureCurrentPositions(_states);\n\n        Session.AddUndo(\n            \u0022Blender Translate\u0022,\n            () =\u003E ApplyPositions(before),\n            () =\u003E ApplyPositions(after));\n    }\n\n    /// \u003Csummary\u003EResets operation-specific state after the active constraint changes.\u003C/summary\u003E\n    protected override void OnConstraintChanged()\n    {\n        _lockedTargetVertex = Vector3.Zero;\n        _hasLockedTarget = false;\n    }\n\n    /// \u003Csummary\u003EReleases operation-specific state during cleanup.\u003C/summary\u003E\n    protected override void OnCleanup()\n    {\n        _states = Array.Empty\u003CPositionState\u003E();\n        _snapSource.Clear();\n        _appliedWorldDelta = Vector3.Zero;\n        _lockedTargetVertex = Vector3.Zero;\n        _hasLockedTarget = false;\n    }\n\n    /// \u003Csummary\u003EConverts numeric input into a constrained world-space translation.\u003C/summary\u003E\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 \u003E 0.0001f\n            ? constrained.Normal * distance\n            : Vector3.Zero;\n    }\n\n    /// \u003Csummary\u003EProjects a world delta onto the active axis or plane constraint.\u003C/summary\u003E\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 \u0026 AxisConstraint.X) != 0)\n            result \u002B= Vector3.Forward * Vector3.Dot(worldDelta, Vector3.Forward);\n        if((Constraint \u0026 AxisConstraint.Y) != 0)\n            result \u002B= Vector3.Left * Vector3.Dot(worldDelta, Vector3.Left);\n        if((Constraint \u0026 AxisConstraint.Z) != 0)\n            result \u002B= Vector3.Up * Vector3.Dot(worldDelta, Vector3.Up);\n\n        return result;\n    }\n\n    /// \u003Csummary\u003EApplies a world-space translation to all captured objects.\u003C/summary\u003E\n    private void ApplyTranslation(Vector3 delta)\n    {\n        for(var index = 0; index \u003C _states.Length; index\u002B\u002B)\n        {\n            var state = _states[index];\n\n            if(state.Object.IsValid())\n                state.Object.WorldPosition = state.Position \u002B delta;\n        }\n    }\n\n    /// \u003Csummary\u003EReturns whether a constraint represents exactly one world axis.\u003C/summary\u003E\n    private static bool IsSingleAxis(AxisConstraint constraint)\n    {\n        return constraint == AxisConstraint.X ||\n            constraint == AxisConstraint.Y ||\n            constraint == AxisConstraint.Z;\n    }\n\n    /// \u003Csummary\u003EReturns the world direction represented by a single-axis constraint.\u003C/summary\u003E\n    private static Vector3 GetSingleAxis(AxisConstraint constraint)\n    {\n        return constraint switch\n        {\n            AxisConstraint.X =\u003E Vector3.Forward,\n            AxisConstraint.Y =\u003E Vector3.Left,\n            AxisConstraint.Z =\u003E Vector3.Up,\n            _ =\u003E Vector3.Zero\n        };\n    }\n\n    /// \u003Csummary\u003ECaptures current object positions for undo or redo.\u003C/summary\u003E\n    private static PositionState[] CaptureCurrentPositions(PositionState[] source)\n    {\n        var result = new PositionState[source.Length];\n\n        for(var index = 0; index \u003C source.Length; index\u002B\u002B)\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    /// \u003Csummary\u003EApplies captured world positions to valid game objects.\u003C/summary\u003E\n    private static void ApplyPositions(PositionState[] states)\n    {\n        for(var index = 0; index \u003C states.Length; index\u002B\u002B)\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/// \u003Csummary\u003ETemporarily suppresses viewport selection and context-menu input during modal operations.\u003C/summary\u003E\ninternal sealed class ViewportInputLock : IDisposable\n{\n    /// \u003Csummary\u003EHandles new.\u003C/summary\u003E\n    private static readonly HashSet\u003CViewportInputLock\u003E ActiveLocks = new();\n\n    /// \u003Csummary\u003EReferences the scene view bound to the active operation.\u003C/summary\u003E\n    private readonly SceneViewWidget _sceneView;\n    /// \u003Csummary\u003EReferences the scene viewport bound to the active operation.\u003C/summary\u003E\n    private readonly SceneViewportWidget _viewport;\n    /// \u003Csummary\u003EReferences the optional tool.\u003C/summary\u003E\n    private readonly EditorTool? _tool;\n    /// \u003Csummary\u003EReferences the optional sub tool.\u003C/summary\u003E\n    private readonly EditorTool? _subTool;\n\n    /// \u003Csummary\u003ETracks whether scene read only.\u003C/summary\u003E\n    private readonly bool _sceneReadOnly;\n    /// \u003Csummary\u003ETracks whether scene context menu.\u003C/summary\u003E\n    private readonly bool _sceneContextMenu;\n    /// \u003Csummary\u003ETracks whether viewport read only.\u003C/summary\u003E\n    private readonly bool _viewportReadOnly;\n    /// \u003Csummary\u003ETracks whether viewport context menu.\u003C/summary\u003E\n    private readonly bool _viewportContextMenu;\n    /// \u003Csummary\u003ETracks whether tool selection.\u003C/summary\u003E\n    private readonly bool _toolSelection;\n    /// \u003Csummary\u003ETracks whether tool context menu.\u003C/summary\u003E\n    private readonly bool _toolContextMenu;\n    /// \u003Csummary\u003ETracks whether sub tool selection.\u003C/summary\u003E\n    private readonly bool _subToolSelection;\n    /// \u003Csummary\u003ETracks whether sub tool context menu.\u003C/summary\u003E\n    private readonly bool _subToolContextMenu;\n\n    /// \u003Csummary\u003ETracks whether this input lock has already restored its state.\u003C/summary\u003E\n    private bool _disposed;\n\n    /// \u003Csummary\u003EInitializes a new viewport input lock instance.\u003C/summary\u003E\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 \u0026\u0026 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    /// \u003Csummary\u003ERestores captured viewport and tool input state exactly once.\u003C/summary\u003E\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(() =\u003E\n        {\n            if(_sceneView.IsValid)\n            {\n                _sceneView.ReadOnly = _sceneReadOnly;\n                _sceneView.ContextMenuEnabled = _sceneContextMenu;\n            }\n        }, ref restoreError);\n\n        Restore(() =\u003E\n        {\n            if(_viewport.IsValid)\n            {\n                _viewport.ReadOnly = _viewportReadOnly;\n                _viewport.ContextMenuEnabled = _viewportContextMenu;\n            }\n        }, ref restoreError);\n\n        Restore(() =\u003E\n        {\n            if(_tool != null)\n            {\n                _tool.AllowGameObjectSelection = _toolSelection;\n                _tool.AllowContextMenu = _toolContextMenu;\n            }\n        }, ref restoreError);\n\n        Restore(() =\u003E\n        {\n            if(_subTool != null \u0026\u0026 _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    /// \u003Csummary\u003ERestores every active input lock during editor hotload.\u003C/summary\u003E\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    /// \u003Csummary\u003EExecutes one restoration step while retaining the first failure.\u003C/summary\u003E\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/// \u003Csummary\u003EImplements Blender-style modal rotation for selected scene objects.\u003C/summary\u003E\npublic sealed class RotateOperation : ModalTransformOperation\n{\n    /// \u003Csummary\u003EDefines the minimum pointer radius used to calculate a stable rotation angle.\u003C/summary\u003E\n    private const float DirectionEpsilon = 2f;\n\n    /// \u003Csummary\u003EHandles new.\u003C/summary\u003E\n    private readonly VertexSnapSource _snapSource = new();\n    /// \u003Csummary\u003EHandles states.\u003C/summary\u003E\n    private RotationState[] _states = Array.Empty\u003CRotationState\u003E();\n\n    /// \u003Csummary\u003EStores the world-space pivot used by the current operation.\u003C/summary\u003E\n    private Vector3 _selectionPivot;\n    /// \u003Csummary\u003EStores the pivot projected into viewport input pixels.\u003C/summary\u003E\n    private Vector2 _pivotInputPosition;\n    /// \u003Csummary\u003EStores the pointer position observed on the previous frame.\u003C/summary\u003E\n    private Vector2 _lastMousePosition;\n    /// \u003Csummary\u003EStores the unsnapped angle accumulated from pointer movement.\u003C/summary\u003E\n    private float _accumulatedAngle;\n    /// \u003Csummary\u003EStores the world-space axis used by the current rotation.\u003C/summary\u003E\n    private Vector3 _rotationAxis;\n    /// \u003Csummary\u003EStores the angle currently applied to selected objects.\u003C/summary\u003E\n    private float _appliedAngle;\n    /// \u003Csummary\u003EStores the vertex currently locking a snapped transform.\u003C/summary\u003E\n    private Vector3 _lockedTargetVertex;\n    /// \u003Csummary\u003ETracks whether a snapped target vertex is currently locked.\u003C/summary\u003E\n    private bool _hasLockedTarget;\n\n    /// \u003Csummary\u003EGets the operation kind.\u003C/summary\u003E\n    public override TransformOperationKind Kind =\u003E TransformOperationKind.Rotate;\n\n    /// \u003Csummary\u003ECaptures operation-specific initial state.\u003C/summary\u003E\n    protected override void OnBegin()\n    {\n        _states = new RotationState[SelectedObjects.Length];\n        _selectionPivot = Vector3.Zero;\n\n        for(var index = 0; index \u003C SelectedObjects.Length; index\u002B\u002B)\n        {\n            var gameObject = SelectedObjects[index];\n            _states[index] = new RotationState(\n                gameObject,\n                gameObject.WorldPosition,\n                gameObject.WorldRotation);\n            _selectionPivot \u002B= 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    /// \u003Csummary\u003EUpdates the operation from current editor input.\u003C/summary\u003E\n    protected override void OnUpdate()\n    {\n        var snapEnabled =\n            (Editor.Application.KeyboardModifiers \u0026 KeyboardModifiers.Ctrl) != 0 \u0026\u0026\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 \u0026\u0026\n            previousDirection.Length \u003E= DirectionEpsilon \u0026\u0026\n            currentDirection.Length \u003E= 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 \u002B\n                previousDirection.y * currentDirection.y;\n            var frameAngle = -MathF.Atan2(cross, dot) * (180f / MathF.PI);\n            var precision =\n                (Editor.Application.KeyboardModifiers \u0026 KeyboardModifiers.Shift) != 0;\n\n            _accumulatedAngle \u002B= 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 \u0026\u0026\n            (!_hasLockedTarget ||\n             (target.Vertex - _lockedTargetVertex).Length \u003E 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 \u002B= correctionAngle;\n        _lockedTargetVertex = target.Vertex;\n        _hasLockedTarget = true;\n        ApplyRotation(Rotation.FromAxis(_rotationAxis, _appliedAngle));\n    }\n\n    /// \u003Csummary\u003ERestores every transformed object to its captured initial state.\u003C/summary\u003E\n    protected override void RestoreInitialState()\n    {\n        ApplyStates(_states);\n    }\n\n    /// \u003Csummary\u003ERegisters undo and redo callbacks for the completed operation.\u003C/summary\u003E\n    protected override void RegisterUndo()\n    {\n        var before = (RotationState[])_states.Clone();\n        var after = CaptureCurrentStates(_states);\n\n        Session.AddUndo(\n            \u0022Blender Rotate\u0022,\n            () =\u003E ApplyStates(before),\n            () =\u003E ApplyStates(after));\n    }\n\n    /// \u003Csummary\u003EResets operation-specific state after the active constraint changes.\u003C/summary\u003E\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    /// \u003Csummary\u003EReleases operation-specific state during cleanup.\u003C/summary\u003E\n    protected override void OnCleanup()\n    {\n        _states = Array.Empty\u003CRotationState\u003E();\n        _snapSource.Clear();\n        _appliedAngle = 0f;\n        _lockedTargetVertex = Vector3.Zero;\n        _hasLockedTarget = false;\n    }\n\n    /// \u003Csummary\u003ECaptures the world or camera-facing axis used by the current rotation.\u003C/summary\u003E\n    private void CaptureRotationAxis()\n    {\n        var toCamera = Camera.GameObject.WorldPosition - _selectionPivot;\n\n        if(Constraint == AxisConstraint.None)\n        {\n            _rotationAxis = toCamera.Length \u003E 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 =\u003E Vector3.Forward,\n            AxisConstraint.Y or AxisConstraint.XZ =\u003E Vector3.Left,\n            AxisConstraint.Z or AxisConstraint.XY =\u003E Vector3.Up,\n            _ =\u003E Vector3.Up\n        };\n    }\n\n    /// \u003Csummary\u003EAttempts to calculate the angular correction from a source vertex to a target vertex.\u003C/summary\u003E\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 \u003C 0.0001f || targetPlanar.Length \u003C 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) \u0026\u0026 !float.IsInfinity(angle);\n    }\n\n    /// \u003Csummary\u003EApplies a rotation around the active pivot to all captured objects.\u003C/summary\u003E\n    private void ApplyRotation(Rotation rotation)\n    {\n        for(var index = 0; index \u003C _states.Length; index\u002B\u002B)\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 \u002B rotation * offset;\n            state.Object.WorldRotation = rotation * state.Rotation;\n        }\n    }\n\n    /// \u003Csummary\u003ECaptures current position and rotation values for undo or redo.\u003C/summary\u003E\n    private static RotationState[] CaptureCurrentStates(RotationState[] source)\n    {\n        var result = new RotationState[source.Length];\n\n        for(var index = 0; index \u003C source.Length; index\u002B\u002B)\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    /// \u003Csummary\u003EApplies captured transform states to valid game objects.\u003C/summary\u003E\n    private static void ApplyStates(RotationState[] states)\n    {\n        for(var index = 0; index \u003C states.Length; index\u002B\u002B)\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/// \u003Csummary\u003ERegisters viewport shortcuts for transform operations and axis constraints.\u003C/summary\u003E\npublic static class TransformShortcuts\n{\n    /// \u003Csummary\u003EStarts a modal translation operation.\u003C/summary\u003E\n    [Shortcut(\u0022blender_actions.translate\u0022, \u0022U\u0022, typeof(SceneViewportWidget), ShortcutType.Widget)]\n    private static void Translate()\n    {\n        ModalOperationArbiter.Start(new TranslateOperation());\n    }\n\n    /// \u003Csummary\u003EStarts a modal rotation operation.\u003C/summary\u003E\n    [Shortcut(\u0022blender_actions.rotate\u0022, \u0022R\u0022, typeof(SceneViewportWidget), ShortcutType.Widget)]\n    private static void Rotate()\n    {\n        ModalOperationArbiter.Start(new RotateOperation());\n    }\n\n    /// \u003Csummary\u003EStarts a modal scale operation.\u003C/summary\u003E\n    [Shortcut(\u0022blender_actions.scale\u0022, \u0022S\u0022, typeof(SceneViewportWidget), ShortcutType.Widget)]\n    private static void Scale()\n    {\n        ModalOperationArbiter.Start(new ScaleOperation());\n    }\n\n    /// \u003Csummary\u003EConstrains the active operation to the world X axis.\u003C/summary\u003E\n    [Shortcut(\u0022blender_actions.constraint_x\u0022, \u0022X\u0022, typeof(SceneViewportWidget), ShortcutType.Widget)]\n    private static void X()\n    {\n        ModalOperationArbiter.SetConstraint(AxisConstraint.X);\n    }\n\n    /// \u003Csummary\u003EConstrains the active operation to the world Y axis.\u003C/summary\u003E\n    [Shortcut(\u0022blender_actions.constraint_y\u0022, \u0022Y\u0022, typeof(SceneViewportWidget), ShortcutType.Widget)]\n    private static void Y()\n    {\n        ModalOperationArbiter.SetConstraint(AxisConstraint.Y);\n    }\n\n    /// \u003Csummary\u003EConstrains the active operation to the world Z axis.\u003C/summary\u003E\n    [Shortcut(\u0022blender_actions.constraint_z\u0022, \u0022Z\u0022, typeof(SceneViewportWidget), ShortcutType.Widget)]\n    private static void Z()\n    {\n        ModalOperationArbiter.SetConstraint(AxisConstraint.Z);\n    }\n\n    /// \u003Csummary\u003EConstrains the active operation to the world YZ plane.\u003C/summary\u003E\n    [Shortcut(\u0022blender_actions.constraint_yz\u0022, \u0022SHIFT\u002BX\u0022, typeof(SceneViewportWidget), ShortcutType.Widget)]\n    private static void YZ()\n    {\n        ModalOperationArbiter.SetConstraint(AxisConstraint.YZ);\n    }\n\n    /// \u003Csummary\u003EConstrains the active operation to the world XZ plane.\u003C/summary\u003E\n    [Shortcut(\u0022blender_actions.constraint_xz\u0022, \u0022SHIFT\u002BY\u0022, typeof(SceneViewportWidget), ShortcutType.Widget)]\n    private static void XZ()\n    {\n        ModalOperationArbiter.SetConstraint(AxisConstraint.XZ);\n    }\n\n    /// \u003Csummary\u003EConstrains the active operation to the world XY plane.\u003C/summary\u003E\n    [Shortcut(\u0022blender_actions.constraint_xy\u0022, \u0022SHIFT\u002BZ\u0022, typeof(SceneViewportWidget), ShortcutType.Widget)]\n    private static void XY()\n    {\n        ModalOperationArbiter.SetConstraint(AxisConstraint.XY);\n    }\n\n    /// \u003Csummary\u003ECancels the active operation for the current editor session.\u003C/summary\u003E\n    [Shortcut(\u0022blender_actions.cancel\u0022, \u0022ESCAPE\u0022, 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( \u0022AddonTitle\u0022, \u0022Blender Actions\u0022 )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \u0022AddonIdent\u0022, \u0022blender_actions\u0022 )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \u0022OrgIdent\u0022, \u0022mikekotys\u0022 )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \u0022Ident\u0022, \u0022mikekotys.blender_actions\u0022 )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \u0022EngineVersion\u0022, \u002228\u0022 )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \u0022EngineMinorVersion\u0022, \u00221\u0022 )]\r\n\r\n[assembly: System.Runtime.Versioning.TargetFramework( \u0022.NETCoreApp,Version=v9.0\u0022, FrameworkDisplayName = \u0022.NET 9.0\u0022 )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \u0022CompileTime\u0022, \u00222026-08-12T17:40:59.9712817Z\u0022 )]\r\n[assembly: global::System.Reflection.AssemblyVersion(\u00220.0.113.0\u0022)]\r\n[assembly: global::System.Reflection.AssemblyFileVersion(\u00220.0.113.0\u0022)]"},{"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/// \u003Csummary\u003EDefines world-axis and world-plane transform constraints.\u003C/summary\u003E\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/// \u003Csummary\u003EIdentifies the supported modal transform operation types.\u003C/summary\u003E\npublic enum TransformOperationKind\n{\n    Translate,\n    Rotate,\n    Scale\n}\n\n/// \u003Csummary\u003EProvides the shared lifecycle, input handling, and cleanup for modal transforms.\u003C/summary\u003E\npublic abstract class ModalTransformOperation\n{\n    /// \u003Csummary\u003EDefines the frame delay that prevents the completing click from reaching default viewport input.\u003C/summary\u003E\n    private const int PostClickGuardFrames = 2;\n    /// \u003Csummary\u003EDefines pointer-motion scaling while the precision modifier is held.\u003C/summary\u003E\n    protected const float PrecisionMultiplier = 0.1f;\n\n    /// \u003Csummary\u003EOwns temporary viewport input suppression for this operation.\u003C/summary\u003E\n    private ViewportInputLock? _inputLock;\n    /// \u003Csummary\u003EReferences the scene view bound to the active operation.\u003C/summary\u003E\n    private SceneViewWidget? _sceneView;\n    /// \u003Csummary\u003EReferences the scene viewport bound to the active operation.\u003C/summary\u003E\n    private SceneViewportWidget? _viewport;\n    /// \u003Csummary\u003EReferences the editor session bound to the active operation.\u003C/summary\u003E\n    private SceneEditorSession? _session;\n    /// \u003Csummary\u003EReferences the editor camera bound to the active operation.\u003C/summary\u003E\n    private CameraComponent? _camera;\n    /// \u003Csummary\u003EHandles selection snapshot.\u003C/summary\u003E\n    private GameObject[] _selectionSnapshot = Array.Empty\u003CGameObject\u003E();\n    /// \u003Csummary\u003ETracks whether the operation is waiting to finish.\u003C/summary\u003E\n    private bool _finishRequested;\n    /// \u003Csummary\u003ETracks whether numeric or modal confirmation has been requested.\u003C/summary\u003E\n    private bool _confirmRequested;\n    /// \u003Csummary\u003ETracks whether the modal operation has already terminated.\u003C/summary\u003E\n    private bool _finished;\n    /// \u003Csummary\u003EStores remaining frames used to guard the confirming or cancelling click.\u003C/summary\u003E\n    private int _finishDelayFrames;\n\n    /// \u003Csummary\u003EGets the bound scene view or throws when unavailable.\u003C/summary\u003E\n    protected SceneViewWidget SceneView =\u003E\n        _sceneView ?? throw new InvalidOperationException(\u0022No active Scene View.\u0022);\n    /// \u003Csummary\u003EGets the bound scene viewport or throws when unavailable.\u003C/summary\u003E\n    protected SceneViewportWidget Viewport =\u003E\n        _viewport ?? throw new InvalidOperationException(\u0022No active Scene Viewport.\u0022);\n    /// \u003Csummary\u003EGets the bound scene editor session or throws when unavailable.\u003C/summary\u003E\n    protected SceneEditorSession Session =\u003E\n        _session ?? throw new InvalidOperationException(\u0022No active Scene Editor session.\u0022);\n    /// \u003Csummary\u003EGets the bound editor camera or throws when unavailable.\u003C/summary\u003E\n    protected CameraComponent Camera =\u003E\n        _camera ?? throw new InvalidOperationException(\u0022No active Scene camera.\u0022);\n\n    /// \u003Csummary\u003EHandles selected objects.\u003C/summary\u003E\n    protected GameObject[] SelectedObjects { get; private set; } = Array.Empty\u003CGameObject\u003E();\n    /// \u003Csummary\u003EHandles new.\u003C/summary\u003E\n    protected internal NumericInputSession NumericInput { get; } = new();\n    /// \u003Csummary\u003EGets the active world-axis or world-plane constraint.\u003C/summary\u003E\n    protected AxisConstraint Constraint { get; private set; }\n\n    /// \u003Csummary\u003EGets the editor session currently bound to this operation.\u003C/summary\u003E\n    internal SceneEditorSession? BoundSession =\u003E _session;\n    /// \u003Csummary\u003EGets the operation kind.\u003C/summary\u003E\n    public abstract TransformOperationKind Kind { get; }\n\n    /// \u003Csummary\u003EBinds the operation to the active scene context and starts its modal lifecycle.\u003C/summary\u003E\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\u003CGameObject\u003E()\n            .Where(gameObject =\u003E gameObject.IsValid())\n            .ToArray();\n\n        var selectedSet = _selectionSnapshot.ToHashSet();\n        SelectedObjects = _selectionSnapshot\n            .Where(gameObject =\u003E !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 \u002B= RequestConfirm;\n            SceneView.MouseRightClick \u002B= RequestCancel;\n            NumericInput.Begin();\n            return true;\n        }\n        catch\n        {\n            Cleanup();\n            throw;\n        }\n    }\n\n    /// \u003Csummary\u003EAdvances input, completion, and operation-specific update logic.\u003C/summary\u003E\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 \u003E 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    /// \u003Csummary\u003EApplies or toggles an axis constraint on the active operation.\u003C/summary\u003E\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    /// \u003Csummary\u003EQueues confirmation after the viewport click guard interval.\u003C/summary\u003E\n    internal void RequestConfirm()\n    {\n        if(_finished || _finishRequested)\n            return;\n\n        _confirmRequested = true;\n        _finishRequested = true;\n        _finishDelayFrames = PostClickGuardFrames;\n    }\n\n    /// \u003Csummary\u003ERestores initial state and queues cancellation after the click guard interval.\u003C/summary\u003E\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    /// \u003Csummary\u003EImmediately restores initial state and terminates the operation.\u003C/summary\u003E\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    /// \u003Csummary\u003ECaptures operation-specific initial state.\u003C/summary\u003E\n    protected abstract void OnBegin();\n    /// \u003Csummary\u003EUpdates the operation from current editor input.\u003C/summary\u003E\n    protected abstract void OnUpdate();\n    /// \u003Csummary\u003ERestores every transformed object to its captured initial state.\u003C/summary\u003E\n    protected abstract void RestoreInitialState();\n    /// \u003Csummary\u003ERegisters undo and redo callbacks for the completed operation.\u003C/summary\u003E\n    protected abstract void RegisterUndo();\n    /// \u003Csummary\u003EResets operation-specific state after the active constraint changes.\u003C/summary\u003E\n    protected virtual void OnConstraintChanged() { }\n    /// \u003Csummary\u003EReleases operation-specific state during cleanup.\u003C/summary\u003E\n    protected virtual void OnCleanup() { }\n\n    /// \u003Csummary\u003ERestores the scene selection captured when the operation began.\u003C/summary\u003E\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    /// \u003Csummary\u003EConverts camera-render pixels to viewport input pixels.\u003C/summary\u003E\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 \u003C= 0f || renderSize.Value.y \u003C= 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    /// \u003Csummary\u003EConverts viewport input pixels to camera-render pixels.\u003C/summary\u003E\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 \u003C= 0f || inputSize.y \u003C= 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    /// \u003Csummary\u003EReturns whether a selected ancestor already represents this object.\u003C/summary\u003E\n    private static bool HasSelectedAncestor(\n        GameObject gameObject,\n        HashSet\u003CGameObject\u003E 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    /// \u003Csummary\u003EReturns whether the bound scene, viewport, session, and camera remain valid.\u003C/summary\u003E\n    private bool HasValidContext()\n    {\n        return _sceneView != null \u0026\u0026\n            _sceneView.IsValid \u0026\u0026\n            _viewport != null \u0026\u0026\n            _viewport.IsValid \u0026\u0026\n            _session != null \u0026\u0026\n            !_session.IsPlaying \u0026\u0026\n            SceneEditorSession.Active == _session \u0026\u0026\n            _camera != null \u0026\u0026\n            _camera.GameObject != null \u0026\u0026\n            _camera.GameObject.IsValid();\n    }\n\n    /// \u003Csummary\u003ECommits or cancels the operation and always releases modal resources.\u003C/summary\u003E\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    /// \u003Csummary\u003EUnsubscribes input handlers, restores viewport state, and clears context.\u003C/summary\u003E\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    /// \u003Csummary\u003EClears references to the active editor context and selection.\u003C/summary\u003E\n    private void ClearContext()\n    {\n        _sceneView = null;\n        _viewport = null;\n        _session = null;\n        _camera = null;\n        SelectedObjects = Array.Empty\u003CGameObject\u003E();\n        _selectionSnapshot = Array.Empty\u003CGameObject\u003E();\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/// \u003Csummary\u003EStores and parses numeric input for one modal transform operation.\u003C/summary\u003E\npublic sealed class NumericInputSession\n{\n    /// \u003Csummary\u003EStores buffered numeric input characters.\u003C/summary\u003E\n    private string _text = string.Empty;\n    /// \u003Csummary\u003ETracks whether numeric or modal confirmation has been requested.\u003C/summary\u003E\n    private bool _confirmRequested;\n\n    /// \u003Csummary\u003EGets whether this numeric input session is accepting input.\u003C/summary\u003E\n    public bool IsActive { get; private set; }\n    /// \u003Csummary\u003EAttempts to parse the buffered text as an invariant floating-point value.\u003C/summary\u003E\n    public bool HasValue =\u003E TryGetValue(out _);\n    /// \u003Csummary\u003EHandles is null or empty.\u003C/summary\u003E\n    public string DisplayText =\u003E string.IsNullOrEmpty(_text) ? \u00220\u0022 : _text;\n\n    /// \u003Csummary\u003EBinds the operation to the active scene context and starts its modal lifecycle.\u003C/summary\u003E\n    public void Begin()\n    {\n        _text = string.Empty;\n        _confirmRequested = false;\n        IsActive = true;\n    }\n\n    /// \u003Csummary\u003EEnds numeric input and clears its buffered state.\u003C/summary\u003E\n    public void End()\n    {\n        _text = string.Empty;\n        _confirmRequested = false;\n        IsActive = false;\n    }\n\n    /// \u003Csummary\u003EAttempts to parse the buffered text as an invariant floating-point value.\u003C/summary\u003E\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    /// \u003Csummary\u003EConsumes and clears a pending numeric confirmation request.\u003C/summary\u003E\n    public bool ConsumeConfirmRequest()\n    {\n        if(!_confirmRequested)\n            return false;\n\n        _confirmRequested = false;\n        return true;\n    }\n\n    /// \u003Csummary\u003EAppends one digit while numeric input is active.\u003C/summary\u003E\n    public void AppendDigit(char digit)\n    {\n        if(IsActive)\n            _text \u002B= digit;\n    }\n\n    /// \u003Csummary\u003EAppends a decimal separator when one is not already present.\u003C/summary\u003E\n    public void EnterDecimal()\n    {\n        if(!IsActive || _text.Contains(\u0027.\u0027))\n            return;\n\n        _text = string.IsNullOrEmpty(_text)\n            ? \u00220.\u0022\n            : _text == \u0022-\u0022\n                ? \u0022-0.\u0022\n                : _text \u002B \u0027.\u0027;\n    }\n\n    /// \u003Csummary\u003EToggles the sign of the buffered numeric value.\u003C/summary\u003E\n    public void ToggleNegative()\n    {\n        if(!IsActive)\n            return;\n\n        _text = _text.StartsWith(\u0022-\u0022)\n            ? _text[1..]\n            : \u0022-\u0022 \u002B _text;\n    }\n\n    /// \u003Csummary\u003ERemoves the last buffered numeric character.\u003C/summary\u003E\n    public void Backspace()\n    {\n        if(IsActive \u0026\u0026 _text.Length \u003E 0)\n            _text = _text[..^1];\n    }\n\n    /// \u003Csummary\u003ERequests confirmation when the buffered numeric value is valid.\u003C/summary\u003E\n    public void Confirm()\n    {\n        if(HasValue)\n            _confirmRequested = true;\n    }\n}\n\n/// \u003Csummary\u003ERoutes numeric keyboard shortcuts to the active modal operation.\u003C/summary\u003E\npublic static class NumericInputShortcuts\n{\n    /// \u003Csummary\u003EAppends the digit zero to active numeric input.\u003C/summary\u003E\n    [Shortcut(\u0022blender_actions.numeric_0\u0022, \u00220\u0022, typeof(SceneViewportWidget), ShortcutType.Widget)]\n    private static void Zero() =\u003E Append(\u00270\u0027);\n    /// \u003Csummary\u003EAppends the digit one to active numeric input.\u003C/summary\u003E\n    [Shortcut(\u0022blender_actions.numeric_1\u0022, \u00221\u0022, typeof(SceneViewportWidget), ShortcutType.Widget)]\n    private static void One() =\u003E Append(\u00271\u0027);\n    /// \u003Csummary\u003EAppends the digit two to active numeric input.\u003C/summary\u003E\n    [Shortcut(\u0022blender_actions.numeric_2\u0022, \u00222\u0022, typeof(SceneViewportWidget), ShortcutType.Widget)]\n    private static void Two() =\u003E Append(\u00272\u0027);\n    /// \u003Csummary\u003EAppends the digit three to active numeric input.\u003C/summary\u003E\n    [Shortcut(\u0022blender_actions.numeric_3\u0022, \u00223\u0022, typeof(SceneViewportWidget), ShortcutType.Widget)]\n    private static void Three() =\u003E Append(\u00273\u0027);\n    /// \u003Csummary\u003EAppends the digit four to active numeric input.\u003C/summary\u003E\n    [Shortcut(\u0022blender_actions.numeric_4\u0022, \u00224\u0022, typeof(SceneViewportWidget), ShortcutType.Widget)]\n    private static void Four() =\u003E Append(\u00274\u0027);\n    /// \u003Csummary\u003EAppends the digit five to active numeric input.\u003C/summary\u003E\n    [Shortcut(\u0022blender_actions.numeric_5\u0022, \u00225\u0022, typeof(SceneViewportWidget), ShortcutType.Widget)]\n    private static void Five() =\u003E Append(\u00275\u0027);\n    /// \u003Csummary\u003EAppends the digit six to active numeric input.\u003C/summary\u003E\n    [Shortcut(\u0022blender_actions.numeric_6\u0022, \u00226\u0022, typeof(SceneViewportWidget), ShortcutType.Widget)]\n    private static void Six() =\u003E Append(\u00276\u0027);\n    /// \u003Csummary\u003EAppends the digit seven to active numeric input.\u003C/summary\u003E\n    [Shortcut(\u0022blender_actions.numeric_7\u0022, \u00227\u0022, typeof(SceneViewportWidget), ShortcutType.Widget)]\n    private static void Seven() =\u003E Append(\u00277\u0027);\n    /// \u003Csummary\u003EAppends the digit eight to active numeric input.\u003C/summary\u003E\n    [Shortcut(\u0022blender_actions.numeric_8\u0022, \u00228\u0022, typeof(SceneViewportWidget), ShortcutType.Widget)]\n    private static void Eight() =\u003E Append(\u00278\u0027);\n    /// \u003Csummary\u003EAppends the digit nine to active numeric input.\u003C/summary\u003E\n    [Shortcut(\u0022blender_actions.numeric_9\u0022, \u00229\u0022, typeof(SceneViewportWidget), ShortcutType.Widget)]\n    private static void Nine() =\u003E Append(\u00279\u0027);\n\n    /// \u003Csummary\u003ERoutes decimal input to the active numeric session.\u003C/summary\u003E\n    [Shortcut(\u0022blender_actions.numeric_decimal\u0022, \u0022.\u0022, typeof(SceneViewportWidget), ShortcutType.Widget)]\n    private static void Decimal() =\u003E ModalOperationArbiter.NumericInput?.EnterDecimal();\n\n    /// \u003Csummary\u003ERoutes sign toggling to the active numeric session.\u003C/summary\u003E\n    [Shortcut(\u0022blender_actions.numeric_negative\u0022, \u0022-\u0022, typeof(SceneViewportWidget), ShortcutType.Widget)]\n    private static void Negative() =\u003E ModalOperationArbiter.NumericInput?.ToggleNegative();\n\n    /// \u003Csummary\u003ERemoves the last buffered numeric character.\u003C/summary\u003E\n    [Shortcut(\u0022blender_actions.numeric_backspace\u0022, \u0022BACKSPACE\u0022, typeof(SceneViewportWidget), ShortcutType.Widget)]\n    private static void Backspace() =\u003E ModalOperationArbiter.NumericInput?.Backspace();\n\n    /// \u003Csummary\u003ERequests confirmation when the buffered numeric value is valid.\u003C/summary\u003E\n    [Shortcut(\u0022blender_actions.numeric_confirm\u0022, \u0022ENTER\u0022, typeof(SceneViewportWidget), ShortcutType.Widget)]\n    private static void Confirm() =\u003E ModalOperationArbiter.NumericInput?.Confirm();\n\n    /// \u003Csummary\u003ERoutes a digit to the active numeric session.\u003C/summary\u003E\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/// \u003Csummary\u003ERepresents the result of locating a target vertex.\u003C/summary\u003E\npublic readonly record struct VertexSnapResult(bool Found, Vector3 Vertex);\n\n/// \u003Csummary\u003EStores reusable world-space source vertices for vertex snapping.\u003C/summary\u003E\npublic sealed class VertexSnapSource\n{\n    /// \u003Csummary\u003EHandles new.\u003C/summary\u003E\n    internal List\u003CVector3\u003E Vertices { get; } = new(4096);\n    /// \u003Csummary\u003EHandles new.\u003C/summary\u003E\n    internal HashSet\u003CModelRenderer\u003E VisitedRenderers { get; } = new();\n\n    /// \u003Csummary\u003EClears reusable source-vertex collections.\u003C/summary\u003E\n    internal void Clear()\n    {\n        Vertices.Clear();\n        VisitedRenderers.Clear();\n    }\n}\n\n/// \u003Csummary\u003EProvides cached screen-space vertex snapping for modal transforms.\u003C/summary\u003E\npublic static class VertexSnapService\n{\n    /// \u003Csummary\u003EDefines the maximum world distance used to trace a target renderer.\u003C/summary\u003E\n    private const float TraceLength = 100000f;\n    /// \u003Csummary\u003EDefines the logical screen-space radius used to acquire target vertices.\u003C/summary\u003E\n    private const float SnapRadiusPixels = 16f;\n    /// \u003Csummary\u003EDefines the projected-vertex spatial hash cell size in pixels.\u003C/summary\u003E\n    private const float ProjectionCellSize = 32f;\n    /// \u003Csummary\u003EDefines local-vertex quantization used for model vertex deduplication.\u003C/summary\u003E\n    private const float Quantization = 10000f;\n\n    /// \u003Csummary\u003EHandles new.\u003C/summary\u003E\n    private static ConditionalWeakTable\u003CModel, CachedVertices\u003E _vertexCache = new();\n    /// \u003Csummary\u003EHandles new.\u003C/summary\u003E\n    private static ConditionalWeakTable\u003CModelRenderer, ProjectedVertexIndex\u003E _targetIndices = new();\n\n    /// \u003Csummary\u003ECaptures current world-space vertices from selected model renderers.\u003C/summary\u003E\n    public static void CaptureSourceSnapshot(\n        VertexSnapSource destination,\n        IReadOnlyCollection\u003CGameObject\u003E 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\u003CModelRenderer\u003E(\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    /// \u003Csummary\u003EFinds the nearest target vertex under the pointer on the traced renderer.\u003C/summary\u003E\n    public static VertexSnapResult FindTargetVertex(\n        Scene scene,\n        CameraComponent camera,\n        SceneViewportWidget viewport,\n        IReadOnlyCollection\u003CGameObject\u003E 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\u003CModelRenderer\u003E(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, _ =\u003E 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    /// \u003Csummary\u003EFinds the source vertex closest on screen after translation.\u003C/summary\u003E\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    /// \u003Csummary\u003EFinds the source vertex closest on screen after rotation.\u003C/summary\u003E\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    /// \u003Csummary\u003EFinds the source vertex closest on screen after scaling.\u003C/summary\u003E\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    /// \u003Csummary\u003EClears model and projected-vertex caches after hotload.\u003C/summary\u003E\n    [EditorEvent.Hotload]\n    private static void ClearCaches()\n    {\n        _vertexCache = new ConditionalWeakTable\u003CModel, CachedVertices\u003E();\n        _targetIndices = new ConditionalWeakTable\u003CModelRenderer, ProjectedVertexIndex\u003E();\n    }\n\n    /// \u003Csummary\u003EFinds the screen-space closest source vertex after a supplied transform.\u003C/summary\u003E\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 \u003C source.Vertices.Count; index\u002B\u002B)\n        {\n            var original = source.Vertices[index];\n            var candidate = transform switch\n            {\n                SourceTransform.Translate =\u003E original \u002B translation,\n                SourceTransform.Rotate =\u003E pivot \u002B rotation * (original - pivot),\n                SourceTransform.Scale =\u003E pivot \u002B (original - pivot).MultiplyComponents(multiplier),\n                _ =\u003E 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 \u003E= bestDistance)\n                continue;\n\n            bestDistance = distance;\n            sourceVertex = candidate;\n            found = true;\n        }\n\n        return found;\n    }\n\n    /// \u003Csummary\u003EAppends one renderer\u0027s transformed model vertices to a reusable destination.\u003C/summary\u003E\n    private static void AppendWorldVertices(\n        ModelRenderer renderer,\n        List\u003CVector3\u003E destination)\n    {\n        var vertices = GetVertices(renderer.Model);\n\n        for(var index = 0; index \u003C vertices.Length; index\u002B\u002B)\n            destination.Add(ToWorld(renderer.GameObject, vertices[index]));\n    }\n\n    /// \u003Csummary\u003ETransforms a local model vertex into world space.\u003C/summary\u003E\n    private static Vector3 ToWorld(GameObject gameObject, Vector3 localVertex)\n    {\n        var scaled = localVertex.MultiplyComponents(gameObject.WorldScale);\n        return gameObject.WorldPosition \u002B gameObject.WorldRotation * scaled;\n    }\n\n    /// \u003Csummary\u003EReturns cached deduplicated local-space vertices for a model.\u003C/summary\u003E\n    private static Vector3[] GetVertices(Model model)\n    {\n        return _vertexCache.GetValue(model, CreateCache).Vertices;\n    }\n\n    /// \u003Csummary\u003ECreates a deduplicated local-space vertex cache for a model.\u003C/summary\u003E\n    private static CachedVertices CreateCache(Model model)\n    {\n        var unique = new Dictionary\u003CQuantizedVertex, Vector3\u003E();\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    /// \u003Csummary\u003ECombines two screen-space cell coordinates into one dictionary key.\u003C/summary\u003E\n    private static long CellKey(int x, int y)\n    {\n        return ((long)x \u003C\u003C 32) ^ (uint)y;\n    }\n\n    /// \u003Csummary\u003EIdentifies the transform applied while evaluating source vertices.\u003C/summary\u003E\n    private enum SourceTransform\n    {\n        Translate,\n        Rotate,\n        Scale\n    }\n\n    /// \u003Csummary\u003EStores deduplicated local-space vertices for one model.\u003C/summary\u003E\n    private sealed class CachedVertices\n    {\n        /// \u003Csummary\u003EInitializes a new cached vertices instance.\u003C/summary\u003E\n        public CachedVertices(Vector3[] vertices)\n        {\n            Vertices = vertices;\n        }\n\n        /// \u003Csummary\u003EGets the reusable captured world-space vertex list.\u003C/summary\u003E\n        public Vector3[] Vertices { get; }\n    }\n\n    /// \u003Csummary\u003EIndexes one renderer\u0027s projected vertices in screen-space cells.\u003C/summary\u003E\n    private sealed class ProjectedVertexIndex\n    {\n        /// \u003Csummary\u003EHandles new.\u003C/summary\u003E\n        private readonly Dictionary\u003Clong, List\u003CProjectedVertex\u003E\u003E _cells = new();\n        /// \u003Csummary\u003EHandles new.\u003C/summary\u003E\n        private readonly Stack\u003CList\u003CProjectedVertex\u003E\u003E _bucketPool = new();\n\n        /// \u003Csummary\u003EStores the model represented by the current projected index.\u003C/summary\u003E\n        private Model? _model;\n        /// \u003Csummary\u003EStores the indexed renderer world position.\u003C/summary\u003E\n        private Vector3 _objectPosition;\n        /// \u003Csummary\u003EStores the indexed renderer world rotation.\u003C/summary\u003E\n        private Rotation _objectRotation;\n        /// \u003Csummary\u003EStores the indexed renderer world scale.\u003C/summary\u003E\n        private Vector3 _objectScale;\n        /// \u003Csummary\u003EStores the camera position used to build the index.\u003C/summary\u003E\n        private Vector3 _cameraPosition;\n        /// \u003Csummary\u003EStores the camera rotation used to build the index.\u003C/summary\u003E\n        private Rotation _cameraRotation;\n        /// \u003Csummary\u003EStores the camera render size used to build the index.\u003C/summary\u003E\n        private Vector2? _cameraSize;\n        /// \u003Csummary\u003EStores the perspective field of view used to build the index.\u003C/summary\u003E\n        private float _fieldOfView;\n        /// \u003Csummary\u003EStores the orthographic height used to build the index.\u003C/summary\u003E\n        private float _orthographicHeight;\n        /// \u003Csummary\u003ETracks whether the indexed camera uses orthographic projection.\u003C/summary\u003E\n        private bool _orthographic;\n\n        /// \u003Csummary\u003ERebuilds the projected vertex index when renderer or camera state changes.\u003C/summary\u003E\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) \u0026\u0026\n                _objectPosition.Equals(gameObject.WorldPosition) \u0026\u0026\n                _objectRotation.Equals(gameObject.WorldRotation) \u0026\u0026\n                _objectScale.Equals(gameObject.WorldScale) \u0026\u0026\n                _cameraPosition.Equals(cameraObject.WorldPosition) \u0026\u0026\n                _cameraRotation.Equals(cameraObject.WorldRotation) \u0026\u0026\n                _cameraSize.Equals(camera.CustomSize) \u0026\u0026\n                _fieldOfView.Equals(camera.FieldOfView) \u0026\u0026\n                _orthographicHeight.Equals(camera.OrthographicHeight) \u0026\u0026\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 \u003C vertices.Length; index\u002B\u002B)\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 \u003E 0\n                        ? _bucketPool.Pop()\n                        : new List\u003CProjectedVertex\u003E();\n                    _cells.Add(key, bucket);\n                }\n\n                bucket.Add(new ProjectedVertex(screen, world));\n            }\n        }\n\n        /// \u003Csummary\u003EFinds the nearest indexed vertex within a screen-space radius.\u003C/summary\u003E\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 \u003C= centerX \u002B cellRadius; x\u002B\u002B)\n            {\n                for(var y = centerY - cellRadius; y \u003C= centerY \u002B cellRadius; y\u002B\u002B)\n                {\n                    if(!_cells.TryGetValue(CellKey(x, y), out var bucket))\n                        continue;\n\n                    for(var index = 0; index \u003C bucket.Count; index\u002B\u002B)\n                    {\n                        var candidate = bucket[index];\n                        var delta = candidate.Screen - screenPosition;\n                        var squaredDistance = delta.x * delta.x \u002B delta.y * delta.y;\n\n                        if(squaredDistance \u003E bestSquaredDistance)\n                            continue;\n\n                        bestSquaredDistance = squaredDistance;\n                        worldVertex = candidate.World;\n                        found = true;\n                    }\n                }\n            }\n\n            return found;\n        }\n\n        /// \u003Csummary\u003EClears projected cells and returns their lists to the bucket pool.\u003C/summary\u003E\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    /// \u003Csummary\u003EPairs a projected screen position with its world-space vertex.\u003C/summary\u003E\n    private readonly record struct ProjectedVertex(Vector2 Screen, Vector3 World);\n    /// \u003Csummary\u003EProvides a quantized key for deduplicating model vertices.\u003C/summary\u003E\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/// \u003Csummary\u003EStores, positions, and renders the per-session Blender-style 3D cursor.\u003C/summary\u003E\npublic static class ThreeDCursor\n{\n    /// \u003Csummary\u003EHandles new.\u003C/summary\u003E\n    private static readonly CursorGizmoBridge GizmoBridge = new();\n    /// \u003Csummary\u003EHandles new.\u003C/summary\u003E\n    private static ConditionalWeakTable\u003CSceneEditorSession, CursorState\u003E _states = new();\n\n    /// \u003Csummary\u003EReturns state for the active editor session and optionally creates it.\u003C/summary\u003E\n    public static Vector3 Position =\u003E GetCurrentState(false)?.Position ?? Vector3.Zero;\n    /// \u003Csummary\u003EReturns state for the active editor session and optionally creates it.\u003C/summary\u003E\n    public static bool UseAsTransformPivot =\u003E GetCurrentState(false)?.UseAsTransformPivot ?? false;\n\n    /// \u003Csummary\u003EResets the active session cursor to the world origin.\u003C/summary\u003E\n    [Shortcut(\u0022blender_actions.cursor_reset\u0022, \u0022SHIFT\u002BC\u0022, 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    /// \u003Csummary\u003EToggles use of the 3D cursor as the transform pivot.\u003C/summary\u003E\n    [Shortcut(\u0022blender_actions.cursor_toggle_pivot\u0022, \u0022ALT\u002BC\u0022, 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    /// \u003Csummary\u003EAdvances active modal operations once per editor tool frame.\u003C/summary\u003E\n    [Event(\u0022tool.frame\u0022)]\n    private static void OnToolFrame()\n    {\n        CheckSetCursorChord();\n        DrawCursor();\n    }\n\n    /// \u003Csummary\u003EAborts active operations and resets session state after hotload.\u003C/summary\u003E\n    [EditorEvent.Hotload]\n    private static void OnHotload()\n    {\n        _states = new ConditionalWeakTable\u003CSceneEditorSession, CursorState\u003E();\n    }\n\n    /// \u003Csummary\u003EDetects the cursor-placement modifier chord without interfering with modal operations.\u003C/summary\u003E\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 \u0026 required) == required;\n\n        if(ModalOperationArbiter.Active != null)\n        {\n            state.SetCursorChordDown = chordDown;\n            return;\n        }\n\n        if(chordDown \u0026\u0026 !state.SetCursorChordDown)\n            SetAtNearestVertex(state);\n\n        state.SetCursorChordDown = chordDown;\n    }\n\n    /// \u003Csummary\u003EMoves the cursor to the nearest target vertex under the pointer.\u003C/summary\u003E\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\u003CGameObject\u003E());\n\n        if(result.Found)\n            state.Position = result.Vertex;\n    }\n\n    /// \u003Csummary\u003EDraws the active session cursor in the current scene viewport.\u003C/summary\u003E\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    /// \u003Csummary\u003EReturns state for the active editor session and optionally creates it.\u003C/summary\u003E\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, _ =\u003E new CursorState());\n\n        return _states.TryGetValue(session, out var state)\n            ? state\n            : null;\n    }\n\n    /// \u003Csummary\u003EStores 3D cursor state associated with one editor session.\u003C/summary\u003E\n    private sealed class CursorState\n    {\n        /// \u003Csummary\u003EGets the active editor session\u0027s 3D cursor position.\u003C/summary\u003E\n        public Vector3 Position { get; set; }\n        /// \u003Csummary\u003EGets whether the active session uses the 3D cursor as transform pivot.\u003C/summary\u003E\n        public bool UseAsTransformPivot { get; set; }\n        /// \u003Csummary\u003EGets set cursor chord down.\u003C/summary\u003E\n        public bool SetCursorChordDown { get; set; }\n    }\n\n    /// \u003Csummary\u003ERenders the 3D cursor through an isolated gizmo instance.\u003C/summary\u003E\n    private sealed class CursorGizmoBridge\n    {\n        /// \u003Csummary\u003EHandles new.\u003C/summary\u003E\n        private readonly Gizmo.Instance _instance = new();\n        /// \u003Csummary\u003EHandles typeof.\u003C/summary\u003E\n        private readonly FieldInfo? _worldField = typeof(Gizmo.Instance).GetField(\n            \u0022_world\u0022,\n            BindingFlags.Instance | BindingFlags.NonPublic);\n\n        /// \u003Csummary\u003EDraws a camera-facing cursor ring through the isolated gizmo bridge.\u003C/summary\u003E\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 \u003C 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                \u0022blender-actions-3d-cursor\u0022,\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/// \u003Csummary\u003ECoordinates one active modal transform operation per editor session.\u003C/summary\u003E\npublic static class ModalOperationArbiter\n{\n    /// \u003Csummary\u003EHandles new.\u003C/summary\u003E\n    private static ConditionalWeakTable\u003CSceneEditorSession, SessionState\u003E _states = new();\n    /// \u003Csummary\u003EHandles new.\u003C/summary\u003E\n    private static readonly List\u003CWeakReference\u003CSessionState\u003E\u003E StateReferences = new();\n\n    /// \u003Csummary\u003EReturns state for the active editor session and optionally creates it.\u003C/summary\u003E\n    public static ModalTransformOperation? Active =\u003E GetCurrentState(false)?.Active;\n    /// \u003Csummary\u003EGets numeric input associated with the current modal operation.\u003C/summary\u003E\n    public static NumericInputSession? NumericInput =\u003E Active?.NumericInput;\n\n    /// \u003Csummary\u003EStarts an operation for the active editor session when no operation is already running.\u003C/summary\u003E\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    /// \u003Csummary\u003EApplies or toggles an axis constraint on the active operation.\u003C/summary\u003E\n    public static void SetConstraint(AxisConstraint constraint)\n    {\n        GetCurrentState(false)?.Active?.SetConstraint(constraint);\n    }\n\n    /// \u003Csummary\u003ECancels the active operation for the current editor session.\u003C/summary\u003E\n    public static void Cancel()\n    {\n        GetCurrentState(false)?.Active?.Abort();\n    }\n\n    /// \u003Csummary\u003EReleases an operation from its bound session state.\u003C/summary\u003E\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    /// \u003Csummary\u003EAdvances active modal operations once per editor tool frame.\u003C/summary\u003E\n    [Event(\u0022tool.frame\u0022)]\n    private static void OnToolFrame()\n    {\n        for(var index = StateReferences.Count - 1; index \u003E= 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    /// \u003Csummary\u003EAborts active operations and resets session state after hotload.\u003C/summary\u003E\n    [EditorEvent.Hotload]\n    private static void OnHotload()\n    {\n        for(var index = StateReferences.Count - 1; index \u003E= 0; index--)\n        {\n            if(StateReferences[index].TryGetTarget(out var state))\n                state.Active?.Abort();\n        }\n\n        StateReferences.Clear();\n        _states = new ConditionalWeakTable\u003CSceneEditorSession, SessionState\u003E();\n    }\n\n    /// \u003Csummary\u003EReturns state for the active editor session and optionally creates it.\u003C/summary\u003E\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    /// \u003Csummary\u003EReturns or creates modal state for a specific editor session.\u003C/summary\u003E\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\u003CSessionState\u003E(state));\n        return state;\n    }\n\n    /// \u003Csummary\u003EStores modal operation state associated with one editor session.\u003C/summary\u003E\n    private sealed class SessionState\n    {\n        /// \u003Csummary\u003EGets or sets the active modal operation for this session.\u003C/summary\u003E\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/// \u003Csummary\u003ECaptures one game object and its world position.\u003C/summary\u003E\ninternal readonly record struct PositionState(GameObject Object, Vector3 Position);\n/// \u003Csummary\u003ECaptures one game object, world position, and world rotation.\u003C/summary\u003E\ninternal readonly record struct RotationState(GameObject Object, Vector3 Position, Rotation Rotation);\n/// \u003Csummary\u003ECaptures one game object, world position, and world scale.\u003C/summary\u003E\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/// \u003Csummary\u003EProvides component-wise vector helpers used by transform operations.\u003C/summary\u003E\ninternal static class Vector3Extensions\n{\n    /// \u003Csummary\u003EReturns the component-wise product of two vectors.\u003C/summary\u003E\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/// \u003Csummary\u003EImplements Blender-style modal scaling for selected scene objects.\u003C/summary\u003E\npublic sealed class ScaleOperation : ModalTransformOperation\n{\n    /// \u003Csummary\u003EDefines the smallest scale produced by interactive pointer input.\u003C/summary\u003E\n    private const float MinimumInteractiveScale = 0.001f;\n    /// \u003Csummary\u003EDefines the smallest scale accepted from vertex snapping.\u003C/summary\u003E\n    private const float MinimumSnapScale = 0.001f;\n    /// \u003Csummary\u003EDefines the near-zero threshold used by component-wise division.\u003C/summary\u003E\n    private const float SafeDivisionEpsilon = 0.001f;\n\n    /// \u003Csummary\u003EHandles new.\u003C/summary\u003E\n    private readonly VertexSnapSource _snapSource = new();\n    /// \u003Csummary\u003EHandles states.\u003C/summary\u003E\n    private ScaleState[] _states = Array.Empty\u003CScaleState\u003E();\n\n    /// \u003Csummary\u003EStores the world-space pivot used by the current operation.\u003C/summary\u003E\n    private Vector3 _selectionPivot;\n    /// \u003Csummary\u003EStores the pivot projected into viewport input pixels.\u003C/summary\u003E\n    private Vector2 _pivotInputPosition;\n    /// \u003Csummary\u003EStores the pointer position observed on the previous frame.\u003C/summary\u003E\n    private Vector2 _lastMousePosition;\n    /// \u003Csummary\u003EStores pointer movement accumulated with precision scaling.\u003C/summary\u003E\n    private Vector2 _effectiveMousePosition;\n    /// \u003Csummary\u003EStores the initial pointer distance from the scale pivot.\u003C/summary\u003E\n    private float _originalDistance;\n    /// \u003Csummary\u003EStores the scale multiplier currently applied to selected objects.\u003C/summary\u003E\n    private Vector3 _appliedMultiplier = Vector3.One;\n    /// \u003Csummary\u003EStores the vertex currently locking a snapped transform.\u003C/summary\u003E\n    private Vector3 _lockedTargetVertex;\n    /// \u003Csummary\u003ETracks whether a snapped target vertex is currently locked.\u003C/summary\u003E\n    private bool _hasLockedTarget;\n\n    /// \u003Csummary\u003EGets the operation kind.\u003C/summary\u003E\n    public override TransformOperationKind Kind =\u003E TransformOperationKind.Scale;\n\n    /// \u003Csummary\u003ECaptures operation-specific initial state.\u003C/summary\u003E\n    protected override void OnBegin()\n    {\n        _states = new ScaleState[SelectedObjects.Length];\n        _selectionPivot = Vector3.Zero;\n\n        for(var index = 0; index \u003C SelectedObjects.Length; index\u002B\u002B)\n        {\n            var gameObject = SelectedObjects[index];\n            _states[index] = new ScaleState(\n                gameObject,\n                gameObject.WorldPosition,\n                gameObject.WorldScale);\n            _selectionPivot \u002B= 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 \u003C 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    /// \u003Csummary\u003EUpdates the operation from current editor input.\u003C/summary\u003E\n    protected override void OnUpdate()\n    {\n        var snapEnabled =\n            (Editor.Application.KeyboardModifiers \u0026 KeyboardModifiers.Ctrl) != 0 \u0026\u0026\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 \u0026 KeyboardModifiers.Shift) != 0;\n            _effectiveMousePosition \u002B= 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 \u0026\u0026\n            (!_hasLockedTarget ||\n             (target.Vertex - _lockedTargetVertex).Length \u003E 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    /// \u003Csummary\u003ERestores every transformed object to its captured initial state.\u003C/summary\u003E\n    protected override void RestoreInitialState()\n    {\n        ApplyStates(_states);\n    }\n\n    /// \u003Csummary\u003ERegisters undo and redo callbacks for the completed operation.\u003C/summary\u003E\n    protected override void RegisterUndo()\n    {\n        var before = (ScaleState[])_states.Clone();\n        var after = CaptureCurrentStates(_states);\n\n        Session.AddUndo(\n            \u0022Blender Scale\u0022,\n            () =\u003E ApplyStates(before),\n            () =\u003E ApplyStates(after));\n    }\n\n    /// \u003Csummary\u003EResets operation-specific state after the active constraint changes.\u003C/summary\u003E\n    protected override void OnConstraintChanged()\n    {\n        _appliedMultiplier = Vector3.One;\n        _lockedTargetVertex = Vector3.Zero;\n        _hasLockedTarget = false;\n    }\n\n    /// \u003Csummary\u003EReleases operation-specific state during cleanup.\u003C/summary\u003E\n    protected override void OnCleanup()\n    {\n        _states = Array.Empty\u003CScaleState\u003E();\n        _snapSource.Clear();\n        _appliedMultiplier = Vector3.One;\n        _lockedTargetVertex = Vector3.Zero;\n        _hasLockedTarget = false;\n    }\n\n    /// \u003Csummary\u003EReturns component scale multipliers for the active constraint.\u003C/summary\u003E\n    private Vector3 GetScaleMultiplier(float factor)\n    {\n        return Constraint switch\n        {\n            AxisConstraint.X =\u003E new Vector3(factor, 1f, 1f),\n            AxisConstraint.Y =\u003E new Vector3(1f, factor, 1f),\n            AxisConstraint.Z =\u003E new Vector3(1f, 1f, factor),\n            AxisConstraint.YZ =\u003E new Vector3(1f, factor, factor),\n            AxisConstraint.XZ =\u003E new Vector3(factor, 1f, factor),\n            AxisConstraint.XY =\u003E new Vector3(factor, factor, 1f),\n            _ =\u003E new Vector3(factor, factor, factor)\n        };\n    }\n\n    /// \u003Csummary\u003EAttempts to calculate an absolute scale multiplier that aligns two vertices.\u003C/summary\u003E\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 \u003C 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 \u003C MinimumSnapScale)\n            return false;\n\n        multiplier = Vector3.One - mask \u002B mask * factor;\n        return true;\n    }\n\n    /// \u003Csummary\u003EReturns the component mask represented by the active scale constraint.\u003C/summary\u003E\n    private Vector3 GetConstraintMask()\n    {\n        if(Constraint == AxisConstraint.None)\n            return Vector3.One;\n\n        return new Vector3(\n            (Constraint \u0026 AxisConstraint.X) != 0 ? 1f : 0f,\n            (Constraint \u0026 AxisConstraint.Y) != 0 ? 1f : 0f,\n            (Constraint \u0026 AxisConstraint.Z) != 0 ? 1f : 0f);\n    }\n\n    /// \u003Csummary\u003EApplies world scale and pivot-relative position changes to captured objects.\u003C/summary\u003E\n    private void ApplyScale(Vector3 multiplier)\n    {\n        for(var index = 0; index \u003C _states.Length; index\u002B\u002B)\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 \u002B offset.MultiplyComponents(multiplier);\n        }\n    }\n\n    /// \u003Csummary\u003EDivides vector components while guarding near-zero divisors.\u003C/summary\u003E\n    private static Vector3 DivideSafe(Vector3 value, Vector3 divisor)\n    {\n        return new Vector3(\n            MathF.Abs(divisor.x) \u003E= SafeDivisionEpsilon ? value.x / divisor.x : 0f,\n            MathF.Abs(divisor.y) \u003E= SafeDivisionEpsilon ? value.y / divisor.y : 0f,\n            MathF.Abs(divisor.z) \u003E= SafeDivisionEpsilon ? value.z / divisor.z : 0f);\n    }\n\n    /// \u003Csummary\u003ECaptures current position and rotation values for undo or redo.\u003C/summary\u003E\n    private static ScaleState[] CaptureCurrentStates(ScaleState[] source)\n    {\n        var result = new ScaleState[source.Length];\n\n        for(var index = 0; index \u003C source.Length; index\u002B\u002B)\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    /// \u003Csummary\u003EApplies captured transform states to valid game objects.\u003C/summary\u003E\n    private static void ApplyStates(ScaleState[] states)\n    {\n        for(var index = 0; index \u003C states.Length; index\u002B\u002B)\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"}]}