3D gizmo: a length handle and Undo/Redo
A complete C# plugin: local axis, dragging, cancellation and history.
A gizmo is editor helper geometry: lines, points and handles associated with an Entity3D. Here one handle changes Length along the local +X axis. The complete example supports drag cancellation and Undo/Redo; it does not create game geometry or a physics collider.
Use the Reckfell 1.0.0 C# editor and a project that builds. If you have not enabled project plugins, start with making an editor plugin.
1. Create four files
Put all files in asset://addons/docs_length/. Each C# filename must match its class name.
plugin.cfg:
[plugin]
name="Length Marker Gizmo"
description="Edit a local X-axis length with Undo/Redo."
author="Reckfell"
version="1.0.0"
script="LengthEditorPlugin.cs"LengthMarker3D.cs stores the edited value. The [Export] hint configures the Inspector UI, while the setter actually clamps the range and rejects NaN/Infinity. UpdateGizmos() asks the editor to refresh visualization after a property change.
using Reckfell;
[Tool, GlobalClass]
public partial class LengthMarker3D : Entity3D
{
public const float MinimumLength = 0.1f;
public const float MaximumLength = 100.0f;
private float _length = 2.0f;
[Export(PropertyHint.Range, "0.1,100,0.1")]
public float Length
{
get => _length;
set
{
if (!float.IsFinite(value)) return;
_length = Mathf.Clamp(value, MinimumLength, MaximumLength);
if (Engine.IsEditorHint() && IsInsideTree()) UpdateGizmos();
}
}
}LengthEditorPlugin.cs registers the handler and passes the actual editor Undo/Redo manager. Do not create a separate UndoRedo here: changes belong in the edited scene's history.
using Reckfell;
[Tool]
public partial class LengthEditorPlugin : EditorPlugin
{
private LengthGizmoPlugin? _gizmoPlugin;
public override void _EnterTree()
{
_gizmoPlugin = new LengthGizmoPlugin(GetUndoRedo());
AddEntity3DGizmoPlugin(_gizmoPlugin);
}
public override void _ExitTree()
{
if (_gizmoPlugin is null) return;
RemoveEntity3DGizmoPlugin(_gizmoPlugin);
_gizmoPlugin.Dispose();
_gizmoPlugin = null;
}
}LengthGizmoPlugin.cs draws a segment, adds one handle with ID 0 and handles changes:
using Reckfell;
[Tool]
public partial class LengthGizmoPlugin : EditorEntity3DGizmoPlugin
{
private readonly EditorUndoRedoManager _undo;
public LengthGizmoPlugin(EditorUndoRedoManager undo)
{
_undo = undo;
CreateMaterial("line", new Color(0.25f, 0.45f, 1.0f));
CreateHandleMaterial("handle");
}
public override string _GetGizmoName() => "LengthMarker3D";
public override bool _HasGizmo(Entity3D forEntity3D) => forEntity3D is LengthMarker3D;
public override void _Redraw(EditorEntity3DGizmo gizmo)
{
gizmo.Clear();
if (gizmo.GetEntity3D() is not LengthMarker3D marker) return;
Vector3 end = Vector3.Right * marker.Length;
Vector3[] line = [Vector3.Zero, end];
gizmo.AddLines(line, GetMaterial("line", gizmo));
gizmo.AddCollisionSegments(line);
gizmo.AddHandles([end], GetMaterial("handle", gizmo), [0]);
}
public override string _GetHandleName(EditorEntity3DGizmo gizmo, int handleId, bool secondary) =>
handleId == 0 && !secondary ? "Length" : "";
public override Variant _GetHandleValue(EditorEntity3DGizmo gizmo, int handleId, bool secondary) =>
handleId == 0 && !secondary && gizmo.GetEntity3D() is LengthMarker3D marker ? marker.Length : default;
public override void _SetHandle(EditorEntity3DGizmo gizmo, int handleId, bool secondary,
Camera3D camera, Vector2 screenPos)
{
if (handleId != 0 || secondary || !screenPos.IsFinite() ||
gizmo.GetEntity3D() is not LengthMarker3D marker) return;
if (Mathf.Abs(marker.GlobalBasis.Determinant()) < 0.000001f) return;
Vector3 origin = camera.ProjectRayOrigin(screenPos);
Vector3 from = marker.ToLocal(origin);
Vector3 to = marker.ToLocal(origin + camera.ProjectRayNormal(screenPos) * 10000);
Vector3 direction = (to - from).Normalized();
// Looking along the axis makes its length ambiguous; preserve the old value.
if (Mathf.Abs(direction.Dot(Vector3.Right)) > 0.9999f) return;
Vector3[] closest = Geometry3D.GetClosestPointsBetweenSegments(
Vector3.Zero, Vector3.Right * LengthMarker3D.MaximumLength, from, to);
marker.Length = closest[0].X;
}
public override void _CommitHandle(EditorEntity3DGizmo gizmo, int handleId, bool secondary,
Variant restore, bool cancel)
{
if (handleId != 0 || secondary || gizmo.GetEntity3D() is not LengthMarker3D marker) return;
float previous = restore.AsSingle();
if (cancel) { marker.Length = previous; return; }
if (Mathf.IsEqualApprox(marker.Length, previous)) return;
_undo.CreateAction("Change marker length", UndoRedo.MergeMode.Disable, marker);
_undo.AddDoProperty(marker, LengthMarker3D.PropertyName.Length, marker.Length);
_undo.AddUndoProperty(marker, LengthMarker3D.PropertyName.Length, previous);
_undo.CommitAction();
}
}The Undo/Redo manager reference is borrowed from the editor; do not Dispose() it. The owner unregisters and disposes its gizmo handler when disabling the plugin.
2. Check it in the editor
- Build the C# project. Enable Length Marker Gizmo under Project → Project Settings → Plugins.
- Create a scene with an
Entity3Droot. Add a childLengthMarker3Dthrough entity creation, or attachLengthMarker3D.csto a childEntity3D. Save the scene as.rscene. - If the scene was open before plugin registration, save and reopen it. In 1.0.0, adding a handler alone does not guarantee rebuilding every already open Entity's gizmos.
- Open the 3D workspace and select the marker. Enable gizmos under View → Gizmos if hidden. A segment should appear along local +X with a handle at its end.
- Drag the handle: Inspector
Lengthchanges within0.1…100. Release, then Undo and Redo; the initial and final lengths should be restored. - Start another drag and press Esc or the right mouse button while the handle is active. The initial value returns without creating a new history entry.
- Change
Lengthin the Inspector. The setter requests a gizmo redraw; the scene source changes only after saving.
The code does not implement editor grid steps or snapping modifiers. Only the range is constrained. Looking almost directly along the edited axis makes length ambiguous, so the handler keeps the old value; rotate the camera.
Coordinates and scale
AddLines() and AddHandles() receive marker local coordinates. (0,0,0) and (Length,0,0) define the segment; the Entity Transform maps it into the world. Thus Length = 2 with X scale 3 gives world length 6, not 2.
In _SetHandle(), the camera converts a screen position to a ray. Both ray endpoints pass through ToLocal(), then the closest points between the ray and the local axis are found. This preserves the intended value under Entity translation, rotation and ordinary nonzero scale. Do not write world X directly into a local property.
A nearly singular basis refuses editing. This is not a universal tool for zero scale or any world size: it uses a ray 10,000 world units long and lengths up to 100 local units. Choose appropriate limits and separately check precision for different project scales.
Handle lifecycle
| Method | Purpose |
|---|---|
_HasGizmo() | Restricts the handler to LengthMarker3D; ordinary Entity3D objects do not receive it. |
_Redraw() | Clears old geometry, then adds lines, picking geometry and a handle. Without Clear(), repeated redraw would accumulate geometry. |
_GetHandleValue() | Returns the value before dragging; the editor passes it as restore on completion. |
_SetHandle() | Updates the current value while moving; it does not create an action every frame. |
_CommitHandle() | On cancel, restores restore directly. On acceptance, records one Do/Undo pair. No history entry is added when length is unchanged. |
AddCollisionSegments() supplies editor gizmo picking geometry. It does not create physics-world collisions. AddHandles() supplies interactive points and applies to selected, editable gizmos. The handle ID must match the IDs handled by callbacks.
GetMaterial("line", gizmo) selects a material variant for the object's selected/editable state. Do not create a new material on every _Redraw() or dispose the active material inside that callback.
When to use a separate gizmo class
One length needs only EditorEntity3DGizmoPlugin. For per-object state or custom subgizmo logic, override _CreateGizmo() and return a subclass of EditorEntity3DGizmo. Return null for unrelated Entities. The instance can implement its own redraw/handle callbacks; the example above deliberately uses one complete approach without mixing two callback sets.
Diagnose failures
| Problem | Check |
|---|---|
| Segment missing | Check the C# build, enabled plugin, attached LengthMarker3D script, edited scene and gizmo menu. Save and reopen the scene after enabling. |
| Line visible, handle missing | Is the marker itself selected and editable? AddHandles() does not add handles to unselected or uneditable gizmos. |
| Handle does not change the value | Check ID 0, secondary, camera angle and nonzero scale. |
| Length jumps after rotation | Do not mix a world ray and local axis; convert both ray points into one coordinate system. |
| Undo restores the wrong value | Use restore captured at drag start, not the previous frame's value. Use the editor's GetUndoRedo(). |
| Duplicate gizmos | Do not register a handler repeatedly; remove the same instance on disable. Start redraw with Clear(). |
| Segment missing in the game | Expected: the gizmo visualizes editor data. A gameplay effect needs separate game geometry/logic. |
Verification limits
The installed headless 1.0.0 editor verified gizmo registration/selection, repeated redraw API calls, perspective and orthographic handle projections, a rotated/scaled Entity's local axis, range and invalid-input handling, actual Undo/Redo history, cancellation without an entry, disable and re-enable with scene reopening. Drag callbacks are invoked programmatically with camera coordinates: physical mouse picking, rendered pixels, snapping, subgizmos, singular transforms, extreme world scales and game exports are not established by this test.