From 20e0f8c53f02771ee85bbcbb5d5a90713db62b69 Mon Sep 17 00:00:00 2001 From: Emil Date: Wed, 2 Sep 2026 16:21:26 +0300 Subject: [PATCH] =?UTF-8?q?M3:=20reflection-based=20Inspector=20=E2=80=94?= =?UTF-8?q?=20edit=20any=20component's=20fields=20live?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InspectorPanel shows EditorState.Selected's Transform (Position/Scale as draggable Vector3 widgets, Rotation shown read-only as a quaternion — a real rotate control needs Euler conversion or a gizmo, out of scope for just the Inspector) and, below it, every attached Component's public fields via reflection. No per-component-type drawer code exists anywhere: int/float/bool/string/Vector3 fields get a live-editable widget, anything else falls back to a read-only ToString(), and a brand new component type in any plugin gets an Inspector for free the moment it's attached — that genericity is the entire reason to do this by reflection instead of a registry. Field edits write straight back through FieldInfo.SetValue, same gap GameObject.AddComponent's doc comment already names: components are plain fields with nothing to intercept a direct mutation, so this needs no SystemAccessScope declaration any more than a hand-written `component. Value = 5` would. EditorPlugin now defaults EditorState.Selected to the first root once, if nothing's been clicked yet — an empty Inspector on every fresh launch had nothing useful to show. Verified by screenshot: Inspector displays "Quad"'s real Position (0,0,0) and Scale (2,2,1), matching scene.json exactly, plus a QuadRenderer header correctly showing "(no fields)" since it has none yet. Full suite still green: 65 tests. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N --- .../Engine.Editor/EditorPlugin.cs | 9 ++ .../Engine.Editor/InspectorPanel.cs | 108 ++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 plugins/engine.editor/Engine.Editor/InspectorPanel.cs diff --git a/plugins/engine.editor/Engine.Editor/EditorPlugin.cs b/plugins/engine.editor/Engine.Editor/EditorPlugin.cs index 4604cd1..278a8b1 100644 --- a/plugins/engine.editor/Engine.Editor/EditorPlugin.cs +++ b/plugins/engine.editor/Engine.Editor/EditorPlugin.cs @@ -60,12 +60,21 @@ public sealed class EditorPlugin : IPlugin { _controller!.Update(_time!.DeltaTime); + // Nothing selected yet and there's something to select: default to + // the first root rather than opening on an empty, useless + // Inspector. Only fires once — any real click overwrites it, and + // it never fights a deliberate deselect because there's no way to + // deselect yet. + if (_state.Selected is null && world.Roots.Count > 0) + _state.Selected = world.Roots[0]; + ImGui.Begin("Lingua Editor"); ImGui.Text($"FPS: {1f / MathF.Max(_time.DeltaTime, 0.0001f):F0}"); ImGui.Text($"Frame: {_time.FrameCount}"); ImGui.End(); HierarchyPanel.Draw(world, _state); + InspectorPanel.Draw(_state); _controller.Render(); } diff --git a/plugins/engine.editor/Engine.Editor/InspectorPanel.cs b/plugins/engine.editor/Engine.Editor/InspectorPanel.cs new file mode 100644 index 0000000..b48dd72 --- /dev/null +++ b/plugins/engine.editor/Engine.Editor/InspectorPanel.cs @@ -0,0 +1,108 @@ +using System.Numerics; +using System.Reflection; +using Engine.Kernel.World; +using ImGuiNET; + +namespace Engine.Editor; + +/// +/// Shows EditorState.Selected's Transform (direct field access — Transform +/// is a known struct, not something to reflect over) and, below it, every +/// Component's public fields via reflection: there's no per-component-type +/// editor code anywhere in engine.editor, on purpose — a new component +/// type in any plugin gets an Inspector for free, which is the entire +/// point of doing this by reflection instead of a registry of per-type +/// drawers. Only int/float/bool/string/Vector3 fields are editable; +/// anything else falls back to a read-only ToString() so an unsupported +/// field type degrades to "visible but not editable" instead of being +/// silently hidden. +/// +internal static class InspectorPanel +{ + public static void Draw(EditorState state) + { + ImGui.Begin("Inspector"); + + var go = state.Selected; + if (go is null) + { + ImGui.TextDisabled("Nothing selected."); + ImGui.End(); + return; + } + + ImGui.Text(go.Name); + ImGui.Separator(); + + if (ImGui.CollapsingHeader("Transform", ImGuiTreeNodeFlags.DefaultOpen)) + { + var t = go.Transform; + var changed = ImGui.DragFloat3("Position", ref t.LocalPosition, 0.1f); + changed |= ImGui.DragFloat3("Scale", ref t.LocalScale, 0.1f); + ImGui.Text($"Rotation (quat): {t.LocalRotation}"); + + if (changed) + go.Transform = t; + } + + foreach (var component in go.Components) + DrawComponent(component); + + ImGui.End(); + } + + private static void DrawComponent(Component component) + { + var type = component.GetType(); + if (!ImGui.CollapsingHeader(type.Name, ImGuiTreeNodeFlags.DefaultOpen)) + return; + + ImGui.PushID(component.GetHashCode()); + + var fields = type.GetFields(BindingFlags.Public | BindingFlags.Instance); + if (fields.Length == 0) + ImGui.TextDisabled("(no fields)"); + + foreach (var field in fields) + DrawField(component, field); + + ImGui.PopID(); + } + + private static void DrawField(Component component, FieldInfo field) + { + var value = field.GetValue(component); + + switch (value) + { + case int i: + if (ImGui.DragInt(field.Name, ref i)) + field.SetValue(component, i); + break; + + case float f: + if (ImGui.DragFloat(field.Name, ref f)) + field.SetValue(component, f); + break; + + case bool b: + if (ImGui.Checkbox(field.Name, ref b)) + field.SetValue(component, b); + break; + + case string s: + if (ImGui.InputText(field.Name, ref s, 256)) + field.SetValue(component, s); + break; + + case Vector3 v: + if (ImGui.DragFloat3(field.Name, ref v, 0.1f)) + field.SetValue(component, v); + break; + + default: + ImGui.Text($"{field.Name}: {value}"); + break; + } + } +}