Editor utility that stores and renders a per-session Blender-style 3D cursor for the scene editor. It tracks cursor position and pivot usage per SceneEditorSession, handles keyboard shortcuts to reset or toggle pivot, snaps to nearest vertex on a modifier chord, and draws a camera-facing gizmo ring via an isolated Gizmo.Instance.
#nullable enable
using Editor;
using Sandbox;
using System;
using System.Reflection;
using System.Runtime.CompilerServices;
namespace BlenderActions;
/// <summary>Stores, positions, and renders the per-session Blender-style 3D cursor.</summary>
public static class ThreeDCursor
{
/// <summary>Handles new.</summary>
private static readonly CursorGizmoBridge GizmoBridge = new();
/// <summary>Handles new.</summary>
private static ConditionalWeakTable<SceneEditorSession, CursorState> _states = new();
/// <summary>Returns state for the active editor session and optionally creates it.</summary>
public static Vector3 Position => GetCurrentState(false)?.Position ?? Vector3.Zero;
/// <summary>Returns state for the active editor session and optionally creates it.</summary>
public static bool UseAsTransformPivot => GetCurrentState(false)?.UseAsTransformPivot ?? false;
/// <summary>Resets the active session cursor to the world origin.</summary>
[Shortcut("blender_actions.cursor_reset", "SHIFT+C", typeof(SceneViewportWidget), ShortcutType.Widget)]
private static void ResetPosition()
{
var state = GetCurrentState(true);
if(state != null)
state.Position = Vector3.Zero;
}
/// <summary>Toggles use of the 3D cursor as the transform pivot.</summary>
[Shortcut("blender_actions.cursor_toggle_pivot", "ALT+C", typeof(SceneViewportWidget), ShortcutType.Widget)]
private static void ToggleTransformPivot()
{
var state = GetCurrentState(true);
if(state != null)
state.UseAsTransformPivot = !state.UseAsTransformPivot;
}
/// <summary>Advances active modal operations once per editor tool frame.</summary>
[Event("tool.frame")]
private static void OnToolFrame()
{
CheckSetCursorChord();
DrawCursor();
}
/// <summary>Aborts active operations and resets session state after hotload.</summary>
[EditorEvent.Hotload]
private static void OnHotload()
{
_states = new ConditionalWeakTable<SceneEditorSession, CursorState>();
}
/// <summary>Detects the cursor-placement modifier chord without interfering with modal operations.</summary>
private static void CheckSetCursorChord()
{
var state = GetCurrentState(true);
if(state == null)
return;
var modifiers = Editor.Application.KeyboardModifiers;
var required =
KeyboardModifiers.Alt |
KeyboardModifiers.Ctrl |
KeyboardModifiers.Shift;
var chordDown = (modifiers & required) == required;
if(ModalOperationArbiter.Active != null)
{
state.SetCursorChordDown = chordDown;
return;
}
if(chordDown && !state.SetCursorChordDown)
SetAtNearestVertex(state);
state.SetCursorChordDown = chordDown;
}
/// <summary>Moves the cursor to the nearest target vertex under the pointer.</summary>
private static void SetAtNearestVertex(CursorState state)
{
var sceneView = SceneViewWidget.Current;
var viewport = sceneView?.LastSelectedViewportWidget;
var session = SceneEditorSession.Active;
var camera =
sceneView?.Tools.CurrentSubTool?.Camera ??
sceneView?.Tools.CurrentTool?.Camera;
if(viewport == null || session == null || camera == null)
return;
var result = VertexSnapService.FindTargetVertex(
session.Scene,
camera,
viewport,
Array.Empty<GameObject>());
if(result.Found)
state.Position = result.Vertex;
}
/// <summary>Draws the active session cursor in the current scene viewport.</summary>
private static void DrawCursor()
{
var state = GetCurrentState(false);
if(state == null)
return;
var sceneView = SceneViewWidget.Current;
var viewport = sceneView?.LastSelectedViewportWidget;
var camera =
sceneView?.Tools.CurrentSubTool?.Camera ??
sceneView?.Tools.CurrentTool?.Camera;
if(viewport == null || !viewport.IsValid || camera == null)
return;
GizmoBridge.Draw(viewport, camera, state.Position);
}
/// <summary>Returns state for the active editor session and optionally creates it.</summary>
private static CursorState? GetCurrentState(bool create)
{
var session = SceneEditorSession.Active;
if(session == null)
return null;
if(create)
return _states.GetValue(session, _ => new CursorState());
return _states.TryGetValue(session, out var state)
? state
: null;
}
/// <summary>Stores 3D cursor state associated with one editor session.</summary>
private sealed class CursorState
{
/// <summary>Gets the active editor session's 3D cursor position.</summary>
public Vector3 Position { get; set; }
/// <summary>Gets whether the active session uses the 3D cursor as transform pivot.</summary>
public bool UseAsTransformPivot { get; set; }
/// <summary>Gets set cursor chord down.</summary>
public bool SetCursorChordDown { get; set; }
}
/// <summary>Renders the 3D cursor through an isolated gizmo instance.</summary>
private sealed class CursorGizmoBridge
{
/// <summary>Handles new.</summary>
private readonly Gizmo.Instance _instance = new();
/// <summary>Handles typeof.</summary>
private readonly FieldInfo? _worldField = typeof(Gizmo.Instance).GetField(
"_world",
BindingFlags.Instance | BindingFlags.NonPublic);
/// <summary>Draws a camera-facing cursor ring through the isolated gizmo bridge.</summary>
public void Draw(
SceneViewportWidget viewport,
CameraComponent camera,
Vector3 position)
{
var world = viewport.GizmoInstance.World;
if(world == null || _worldField == null)
return;
if(_instance.World != world)
_worldField.SetValue(_instance, world);
_instance.Settings = viewport.GizmoInstance.Settings;
var toCamera = camera.GameObject.WorldPosition - position;
if(toCamera.Length < 0.001f)
return;
var rotation = Rotation.LookAt(toCamera.Normal);
var radius = MathF.Max(toCamera.Length * 0.015f, 2f);
using(_instance.Push())
using(Gizmo.Scope(
"blender-actions-3d-cursor",
position,
rotation,
1f))
{
Gizmo.Draw.LineCircle(0, radius);
}
}
}
}