3D-гизмо: ручка длины и Undo/Redo
Полный C#-плагин: локальная ось, перетаскивание, отмена и история.
Гизмо — вспомогательная геометрия редактора: линии, точки и ручки, связанные с выбранной Entity3D. Здесь одна ручка меняет Length вдоль локальной оси +X. Полный пример поддерживает отмену перетаскивания и Undo/Redo; он не создаёт игровую геометрию или физический коллайдер.
Нужен C#-редактор Reckfell 1.0.0 и собирающийся проект. Если ещё не включали плагины, начните с плагина редактора.
1. Создайте четыре файла
Все файлы положите в asset://addons/docs_length/. Имена файлов C# должны совпадать с классами.
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 хранит редактируемое значение. Подсказка [Export] задаёт интерфейс Inspector, а setter действительно ограничивает диапазон и отклоняет NaN/Infinity. Вызов UpdateGizmos() просит редактор обновить визуализацию после изменения свойства.
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 регистрирует обработчик и передаёт ему настоящий менеджер Undo/Redo редактора. Для отмены действий здесь не создаётся отдельный UndoRedo: изменения должны попасть в историю редактируемой сцены.
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 рисует отрезок, добавляет одну ручку с ID 0 и обрабатывает изменение:
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();
}
}Ссылка на менеджер Undo/Redo заимствована у редактора; не вызывайте для неё Dispose(). Сам обработчик гизмо снимается с регистрации и освобождается владельцем при выключении плагина.
2. Проверьте в редакторе
- Соберите C#-проект. В Project → Project Settings → Plugins включите Length Marker Gizmo.
- Создайте сцену с корнем
Entity3D, добавьте дочернююLengthMarker3Dчерез создание Entity либо прикрепитеLengthMarker3D.csк дочернейEntity3D. Сохраните сцену как.rscene. - Если сцена уже была открыта до регистрации плагина, сохраните и переоткройте её. В 1.0.0 само добавление обработчика не гарантирует пересоздание гизмо всех уже открытых Entity.
- Откройте рабочую область 3D и выберите маркер. Включите гизмо в меню View → Gizmos, если они скрыты. Должен появиться отрезок вдоль локальной +X с ручкой на конце.
- Перетащите ручку:
Lengthв Inspector меняется в пределах0.1…100. Отпустите кнопку, затем выполните Undo и Redo — должны восстановиться начальная и конечная длина. - Начните другое перетаскивание и нажмите Esc либо правую кнопку мыши, пока ручка активна. Начальное значение восстанавливается без новой записи в истории.
- Измените
Lengthчерез Inspector. Setter запросит перерисовку гизмо; исходник сцены меняется только после сохранения.
Код не подключает шаг сетки или клавиши snapping редактора. Не ожидайте привязку к сетке: ограничивается только диапазон. При взгляде почти строго вдоль редактируемой оси длина неоднозначна, поэтому обработчик оставляет прежнее значение; поверните камеру.
Координаты и масштаб
AddLines() и AddHandles() получают локальные координаты маркера. Точки (0,0,0) и (Length,0,0) задают отрезок; Transform Entity переводит его в мир. Поэтому Length = 2 при масштабе X = 3 даёт мировую длину 6, а не 2.
В _SetHandle() экранная позиция преобразуется камерой в луч. Оба конца луча переводятся через ToLocal(), после чего ищутся ближайшие точки луча и локальной оси. Это сохраняет правильное значение при перемещении, повороте и обычном ненулевом масштабе Entity. Нельзя напрямую записывать мировую координату X в локальное свойство.
При почти вырожденном базисе изменение отвергается. Это не универсальный инструмент для нулевого масштаба или любой величины мира: пример использует луч длиной 10 000 мировых единиц и диапазон длины до 100 локальных единиц. Для других масштабов проекта выберите подходящие пределы и проверьте точность отдельно.
Жизненный цикл ручки
| Метод | Назначение |
|---|---|
_HasGizmo() | Применяет обработчик только к LengthMarker3D. Обычная Entity3D его не получает. |
_Redraw() | Сначала очищает прежнюю геометрию, затем добавляет линии, область выбора и ручку. Без Clear() повторное обновление накапливало бы геометрию. |
_GetHandleValue() | Возвращает значение до перетаскивания; редактор передаёт его в restore при завершении. |
_SetHandle() | Меняет текущее значение во время движения; не создаёт отдельное действие на каждый кадр. |
_CommitHandle() | При отмене сразу восстанавливает restore; при подтверждении записывает одну пару Do/Undo. Если длина не изменилась, история не пополняется. |
AddCollisionSegments() задаёт геометрию выбора гизмо в редакторе. Она не добавляет столкновения в физический мир. AddHandles() задаёт интерактивные точки; его вызов имеет смысл для выбранного и доступного для редактирования гизмо. ID ручки должен совпадать с ID, который обрабатывают callback-методы.
GetMaterial("line", gizmo) выбирает вариант материала с учётом выбранности и редактируемости объекта. Не создавайте новый материал при каждом _Redraw() и не освобождайте используемый материал внутри этого метода.
Когда нужен отдельный класс гизмо
Для одной длины достаточно EditorEntity3DGizmoPlugin. Если нужно отдельное состояние на каждый объект или собственная логика subgizmo, можно переопределить _CreateGizmo() и вернуть наследника EditorEntity3DGizmo. Возвращайте null для неподходящих Entity. В таком варианте redraw/handle-методы можно реализовать у самого экземпляра; пример выше намеренно использует один подход целиком, без смешивания двух наборов callback.
Диагностика
| Проблема | Что проверить |
|---|---|
| Нет отрезка | C#-сборка, включённый плагин, скрипт LengthMarker3D на Entity, открытая редактируемая сцена, меню гизмо. После включения сохраните и переоткройте сцену. |
| Есть линия, нет ручки | Выбран ли сам маркер? Доступен ли объект для редактирования? AddHandles() не добавляет ручки невыбранному или нередактируемому гизмо. |
| Ручка не меняет значение | Сверьте ID 0, флаг secondary, угол камеры и ненулевой масштаб. |
| Длина прыгает после поворота | Не смешивайте мировой луч с локальной осью; переводите обе точки луча в одну систему координат. |
| Undo возвращает не туда | Сохраняйте restore, полученный при начале перетаскивания, а не значение последнего кадра. Используйте GetUndoRedo() редактора. |
| Сцена заполнена копиями гизмо | Не регистрируйте один обработчик несколько раз; при выключении снимайте тот же экземпляр. Начинайте redraw с Clear(). |
| В игре нет отрезка | Это ожидаемо: гизмо визуализирует данные в редакторе. Для игрового эффекта нужна отдельная игровая геометрия/логика. |
Границы проверки
В установленном headless-редакторе 1.0.0 проверены регистрация и выбор гизмо, повторный redraw без ошибок API, перспективная и ортографическая проекция ручки, локальная ось повёрнутой/масштабированной Entity, диапазон и неверные входы, реальная история Undo/Redo, отмена без записи, выключение и повторное включение с переоткрытием сцены. События перетаскивания вызываются программно с координатами камеры: физический выбор ручки мышью, экранное изображение, snapping, subgizmo, вырожденные Transform, крайние масштабы мира и экспорт этим тестом не подтверждены.