gfx-playground

cross platform rendering playground

feat(editor): add rotate and scale gizmo tools

Arjun Choudhary contact@arjunchoudhary.com

commit: ebe6f9f parent: 82ff61b
MOD · src/editor/debug_draw.cpp +3 -0
--- a/src/editor/debug_draw.cpp
+++ b/src/editor/debug_draw.cpp
@@ -30,6 +30,9 @@ void apply_debug_settings(const Editor &e, DebugDraw &draw) {
     if (e.show_cat_ddgi) {
         mask |= DebugDraw::category_bit(DEBUG_CAT_DDGI);
     }
+    if (e.show_cat_gizmo) {
+        mask |= DebugDraw::category_bit(DEBUG_CAT_GIZMO);
+    }
     draw.category_mask = mask;
 }
MOD · src/editor/editor.cpp +1 -0
--- a/src/editor/editor.cpp
+++ b/src/editor/editor.cpp
@@ -121,6 +121,7 @@ void draw_menu_bar(Editor &editor, RenderData &rd) {
                 ImGui::MenuItem("Category: selection", nullptr, &editor.show_cat_selection);
                 ImGui::MenuItem("Category: light", nullptr, &editor.show_cat_light);
                 ImGui::MenuItem("Category: DDGI", nullptr, &editor.show_cat_ddgi);
+                ImGui::MenuItem("Category: gizmo", nullptr, &editor.show_cat_gizmo);
                 ImGui::EndMenu();
             }
             ImGui::EndMenu();
MOD · src/editor/editor.h +43 -9
--- a/src/editor/editor.h
+++ b/src/editor/editor.h
@@ -1,5 +1,6 @@
 #pragma once
 #include "backend/rhi.h"
+#include "core/math_rtm.h"
 #include "imgui.h"
 #include "passes/fg.h"
 #include "scene/camera.h"
@@ -27,6 +28,12 @@ namespace editor {
 
 constexpr ImGuiWindowFlags PANEL_FLAGS = ImGuiWindowFlags_NoCollapse;
 
+enum GizmoTool : int {
+    GIZMO_TOOL_MOVE = 0,
+    GIZMO_TOOL_ROTATE = 1,
+    GIZMO_TOOL_SCALE = 2,
+};
+
 struct TextureViewerDesc {
     rhi::Image *image = nullptr;
     rhi::Sampler *sampler = nullptr;
@@ -55,6 +62,15 @@ void init_log_viewer();
 
 void apply_style(f32 scale, ImGuiStyle &style);
 
+// just a throttle to apply the euler -> quat only once per frame
+// afaik translate/scale wont need something like this, because they're 1:1
+// whereas rot for euler -> quat is many:1
+struct EulerEdit {
+    int id = -1;
+    vec3 euler = vec3(0.0f);
+    quat sync;
+};
+
 struct Editor {
     bool dock_layout_built = false;
     ImFont *font_ui = nullptr;
@@ -94,25 +110,43 @@ struct Editor {
     bool show_cat_selection = true;
     bool show_cat_light = false;
     bool show_cat_ddgi = false;
+    bool show_cat_gizmo = true;
 
     // shader reload
     bool reload_requested_ = false;
 
-    bool show_translate_gizmo = true;
+    GizmoTool gizmo_tool = GIZMO_TOOL_MOVE; // active tool (Blender-like)
+    bool gizmo_world = true;                // true = world orientation, false = local
     bool gizmo_snap = false;
-    f32 gizmo_snap_step = 0.1f;
-    // drag runtime (owned by update_translate_gizmo; handles only)
+    f32 gizmo_snap_step = 0.1f;   // move, world units
+    f32 gizmo_snap_angle = 15.0f; // rotate, degrees
+    f32 gizmo_snap_scale = 0.1f;  // scale, factor step
+
+    // drag runtime (owned by update_*_gizmo; handles only)
     bool gizmo_dragging = false;
     int gizmo_grab_node = -1;           // node the active drag belongs to
-    u8 gizmo_drag_axis = 0;             // 0/1/2 = X/Y/Z axis, 3 = center free-move (handle resolved at grab)
-    int gizmo_hover_axis = -1;          // screen-space handle hover (-1 = none, 0..2 = axis, 3 = center)
+    u8 gizmo_drag_axis = 0;             // handle id gizmo axis id
+    int gizmo_hover_axis = -1;          // screen-space handle hover (-1 = none)
     vec3 gizmo_grab_point = vec3(0.0f); // world-space ref point at grab
     vec3 gizmo_base_local = vec3(0.0f); // node local translation at grab
     vec3 gizmo_base_world = vec3(0.0f); // node world origin at grab
-    vec3 gizmo_plane_n = vec3(0.0f);    // view-plane normal captured at center grab
-    // viewport rect cache (written by end_frame UI, read pre-execute next frame)
+    vec3 gizmo_plane_n = vec3(0.0f);    // drag plane normal captured at grab
+
+    // extended drag state for rotate/scale
+    mat4 gizmo_base_world_mat = identity(); // node world matrix at grab
+    mat4 gizmo_base_local_mat = identity(); // node local matrix at grab
+    vec3 gizmo_base_scale = vec3(1.0f);     // node local scale at grab
+    f32 gizmo_grab_angle = 0.0f;            // rotate: angle of grab point around axis
+    f32 gizmo_grab_dist = 0.0f;             // scale: radial/axial distance at grab
+    vec3 gizmo_drag_u = vec3(0.0f);         // rotate: basis u, perpendicular drag axis
+    vec3 gizmo_drag_v = vec3(0.0f);         // rotate: basis v, perpendicular drag axis
+    int gizmo_tool_grab = -1;               // tool active at grab (cancels cross-tool drags)
+    bool gizmo_world_grab = true;           // orientation at grab (rebase on toggle)
     ImVec2 gizmo_view_min = ImVec2(0, 0);
     ImVec2 gizmo_view_size = ImVec2(0, 0);
+    // inspector rotation edit-state (see EulerEdit above)
+    EulerEdit node_rot; // id = node index
+    EulerEdit inst_rot; // id = instance slot (base + k), single-active-instance cache
 };
 
 inline bool reload_requested(Editor &e) {
@@ -146,9 +180,9 @@ void draw_timings_window(
 );
 void draw_texture_viewer_window(rhi::Device &device, Editor &editor, RenderData &rd);
 void draw_scene_graph_window(Scene &scene, const AssetManager &mgr, int &selected_node);
-void draw_inspector_window(Scene &scene, const AssetManager &mgr, ImFont *mono_font, int &selected_node);
+void draw_inspector_window(Scene &scene, const AssetManager &mgr, ImFont *mono_font, Editor &e);
 
-void update_translate_gizmo(Editor &e, Scene &scene, DebugDraw &draw);
+void update_gizmo(Editor &e, Scene &scene, DebugDraw &draw);
 
 void handle_frame_begin(
     Editor &e,
MOD · src/editor/gizmo.cpp +923 -42
--- a/src/editor/gizmo.cpp
+++ b/src/editor/gizmo.cpp
@@ -1,6 +1,7 @@
 #ifdef ENABLE_EDITOR
 #include "editor.h"
-#include "gizmo_common.h"
+
+#include <cassert>
 
 #include "imgui.h"
 #include "passes/debug/debug_draw.h"
@@ -10,61 +11,794 @@
 
 namespace editor {
 
-void update_translate_gizmo(Editor &e, Scene &scene, DebugDraw &draw) {
-    GizmoFrame f;
-    if (!gizmo_begin_frame(e, scene, f)) {
+constexpr u8 GIZMO_AXIS_X = 0;
+constexpr u8 GIZMO_AXIS_Y = 1;
+constexpr u8 GIZMO_AXIS_Z = 2;
+constexpr u8 GIZMO_FREE_AXIS = 3; // center axis
+constexpr u8 GIZMO_PLANE_YZ = 4;  // normal = X, spans Y+Z
+constexpr u8 GIZMO_PLANE_XZ = 5;  // normal = Y, spans X+Z
+constexpr u8 GIZMO_PLANE_XY = 6;  // normal = Z, spans X+Y
+constexpr u8 GIZMO_VIEW_AXIS = 7; // outer view ring (rotate)
+
+constexpr f32 GIZMO_AXIS_HIT_PX = 10.0f;
+constexpr f32 GIZMO_CENTER_RADIUS_PX = 10.0f;
+constexpr f32 GIZMO_CENTER_HIT_PX = 12.0f;
+constexpr f32 GIZMO_HANDLE_LEN_PX = 90.0f;
+constexpr f32 GIZMO_PLANE_FRAC = 0.35f;
+constexpr f32 GIZMO_RING_HIT_PX = 6.0f;
+constexpr f32 GIZMO_RING_HIT_EDGE_PX = 4.0f; // edge-on rings project to a line through center
+constexpr f32 GIZMO_OUTER_HIT_PX = 7.0f;
+constexpr f32 GIZMO_RING_DEADZONE_PX = GIZMO_CENTER_HIT_PX + 6.0f;
+constexpr f32 GIZMO_OUTER_RING_SCALE = 1.2f;
+constexpr u32 GIZMO_RING_SEGS = 48;
+
+inline vec3 gizmo_axis_dir(u8 axis) {
+    if (axis == 0) {
+        return vec3(1.0f, 0.0f, 0.0f);
+    }
+    if (axis == 1) {
+        return vec3(0.0f, 1.0f, 0.0f);
+    }
+    return vec3(0.0f, 0.0f, 1.0f);
+}
+
+inline vec4 gizmo_axis_color(u8 axis) {
+    if (axis == 0) {
+        return vec4(10.0f, 0.4f, 0.4f, 1.0f);
+    }
+    if (axis == 1) {
+        return vec4(0.4f, 10.0f, 0.4f, 1.0f);
+    }
+    return vec4(0.5f, 0.5f, 10.0f, 1.0f);
+}
+
+// planar handle -> spanned axes + normal axis
+// map map for hit/draw/grab/scale.
+struct GizmoPlaneMap {
+    u8 ia;
+    u8 ib;
+    u8 n_axis;
+};
+
+inline GizmoPlaneMap gizmo_plane_map(u8 plane_id) {
+    if (plane_id == GIZMO_PLANE_YZ) {
+        return {1, 2, GIZMO_AXIS_X};
+    }
+    if (plane_id == GIZMO_PLANE_XZ) {
+        return {0, 2, GIZMO_AXIS_Y};
+    }
+    return {0, 1, GIZMO_AXIS_Z}; // GIZMO_PLANE_XY (and invalid fallback)
+}
+
+inline u8 plane_normal_axis(u8 plane_id) {
+    return gizmo_plane_map(plane_id).n_axis;
+}
+
+// scale mask: 1 on axes affected by the handle, 0 elsewhere (0,0,0) = invalid
+inline vec3 gizmo_scale_mask(u8 handle) {
+    if (handle == GIZMO_FREE_AXIS) {
+        return vec3(1.0f);
+    }
+    if (handle == GIZMO_AXIS_X) {
+        return vec3(1.0f, 0.0f, 0.0f);
+    }
+    if (handle == GIZMO_AXIS_Y) {
+        return vec3(0.0f, 1.0f, 0.0f);
+    }
+    if (handle == GIZMO_AXIS_Z) {
+        return vec3(0.0f, 0.0f, 1.0f);
+    }
+    if (handle == GIZMO_PLANE_YZ) {
+        return vec3(0.0f, 1.0f, 1.0f);
+    }
+    if (handle == GIZMO_PLANE_XZ) {
+        return vec3(1.0f, 0.0f, 1.0f);
+    }
+    if (handle == GIZMO_PLANE_XY) {
+        return vec3(1.0f, 1.0f, 0.0f);
+    }
+    return vec3(0.0f);
+}
+
+struct GizmoSelection {
+    vec3 origin;
+    mat4 base_mat;
+};
+
+inline GizmoSelection gizmo_selection(const Scene &scene, u32 node) {
+    const SceneGraph &sg = scene.scene_graph;
+    mat4 base = (sg.instance_count[node] > 0) ? scene.instance_world(node, 0) : sg.worlds[node];
+    vec4 h = transform_point(vec3(0.0f, 0.0f, 0.0f), base);
+    return {vec3(h.x, h.y, h.z), base};
+}
+
+inline bool project_to_view(vec3 p, const mat4 &view_proj, ImVec2 min, ImVec2 size, ImVec2 &out) {
+    vec4 c = transform_point(p, view_proj);
+    if (c.w <= 0.0f) { // behind camera
+        return false;
+    }
+    f32 nx = c.x / c.w;
+    f32 ny = c.y / c.w;
+    out = ImVec2(min.x + (nx * 0.5f + 0.5f) * size.x, min.y + (ny * 0.5f + 0.5f) * size.y);
+    return true;
+}
+
+inline f32 dist_point_seg(ImVec2 p, ImVec2 a, ImVec2 b) {
+    f32 dx = b.x - a.x;
+    f32 dy = b.y - a.y;
+    f32 len2 = dx * dx + dy * dy;
+    f32 t = (len2 > 1e-8f) ? ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2 : 0.0f;
+    if (t < 0.0f) {
+        t = 0.0f;
+    } else if (t > 1.0f) {
+        t = 1.0f;
+    }
+    f32 ex = a.x + dx * t - p.x;
+    f32 ey = a.y + dy * t - p.y;
+    return sqrtf(ex * ex + ey * ey);
+}
+
+// closest point on axis (origin + s*axis, |axis|==1) to ray (ray_o + t*ray_d).
+inline vec3 closest_point_on_axis(vec3 origin, vec3 axis, vec3 ray_o, vec3 ray_d) {
+    vec3 w0 = origin - ray_o;
+    f32 b = axis.dot(ray_d);
+    f32 d = axis.dot(w0);
+    f32 f = ray_d.dot(w0);
+    f32 denom = 1.0f - b * b;
+    f32 s = (fabsf(denom) < 1e-6f) ? 0.0f : (b * f - d) / denom;
+    return origin + axis * s;
+}
+
+// signed distance along axis from origin of the closest point to the ray.
+inline f32 axis_signed_distance(vec3 origin, vec3 axis, vec3 ray_o, vec3 ray_d) {
+    vec3 p = closest_point_on_axis(origin, axis, ray_o, ray_d);
+    return (p - origin).dot(axis);
+}
+
+// ray plane intersection (point + t*n, |n|==1). Falls back to `fallback` when the ray
+// runs parallel to the plane or points away.
+inline vec3 ray_plane_point(vec3 point, vec3 n, vec3 ray_o, vec3 ray_d, vec3 fallback) {
+    f32 denom = ray_d.dot(n);
+    if (fabsf(denom) < 1e-6f) {
+        return fallback;
+    }
+    f32 t = (point - ray_o).dot(n) / denom;
+    if (!(t > 0.0f) || !std::isfinite(t)) {
+        return fallback;
+    }
+    return ray_o + ray_d * t;
+}
+
+inline vec3 gizmo_quat_rotate(vec3 v, quat q) {
+    return vec3::from_vec4(rtm::quat_mul_vector3(v.to_vec4(), (rtm::quatf)q));
+}
+
+// orthonormal basis (u,v) spanning the plane perpendicular to axis
+inline void gizmo_ring_basis(vec3 axis, vec3 &u, vec3 &v) {
+    vec3 helper = (fabsf(axis.z) < 0.9f) ? vec3(0.0f, 0.0f, 1.0f) : vec3(0.0f, 1.0f, 0.0f);
+    u = cross(helper, axis);
+    f32 l = u.length();
+    if (!(l > 1e-6f)) {
+        u = vec3(1.0f, 0.0f, 0.0f);
+    } else {
+        u = u / l;
+    }
+    v = cross(axis, u);
+    l = v.length();
+    if (l > 1e-6f) {
+        v = v / l;
+    }
+}
+
+inline f32 gizmo_wrap_pi(f32 a) {
+    while (a > PI) {
+        a -= 2.0f * PI;
+    }
+    while (a < -PI) {
+        a += 2.0f * PI;
+    }
+    return a;
+}
+
+inline f32 gizmo_angle_on_plane(vec3 p, vec3 origin, vec3 u, vec3 v) {
+    vec3 d = p - origin;
+    return atan2f(d.dot(v), d.dot(u));
+}
+
+// per-frame gizmo context, computed once per tool update.
+struct GizmoFrame {
+    u32 node = 0;
+    SceneGraph *sg = nullptr;
+    const Camera *cam = nullptr;
+    vec3 origin = vec3(0.0f);
+    mat4 base_mat = identity();
+    vec3 axes[3] = {vec3(1.0f, 0.0f, 0.0f), vec3(0.0f, 1.0f, 0.0f), vec3(0.0f, 0.0f, 1.0f)};
+    f32 dist = 0.0f;
+    f32 len = 0.0f; // screen-constant handle length (world units)
+    ImVec2 mouse = ImVec2(0, 0);
+    bool hover_view = false;
+    ImVec2 o_px = ImVec2(0, 0);
+    bool o_visible = false;
+    DebugRay ray = {};
+};
+
+inline void gizmo_compute_axes(const Editor &e, const Scene &scene, u32 node, const mat4 &base_mat, vec3 *axes) {
+    if (e.gizmo_world) {
+        axes[0] = vec3(1.0f, 0.0f, 0.0f);
+        axes[1] = vec3(0.0f, 1.0f, 0.0f);
+        axes[2] = vec3(0.0f, 0.0f, 1.0f);
         return;
     }
+    (void)scene;
+    quat q = mat4_to_trs(base_mat).rotation;
+    axes[0] = gizmo_quat_rotate(vec3(1.0f, 0.0f, 0.0f), q).normalized();
+    axes[1] = gizmo_quat_rotate(vec3(0.0f, 1.0f, 0.0f), q).normalized();
+    axes[2] = gizmo_quat_rotate(vec3(0.0f, 0.0f, 1.0f), q).normalized();
+    for (int i = 0; i < 3; ++i) {
+        if (!debug_finite_vec3(axes[i]) || axes[i].length_squared() < 1e-8f) {
+            axes[i] = gizmo_axis_dir((u8)i);
+        }
+    }
+}
+
+// shared origin/base/axes/dist/len resolution for begin-frame + rebase
+struct GizmoFrameData {
+    vec3 origin;
+    mat4 base_mat;
+    vec3 axes[3];
+    f32 dist = 0.0f;
+    f32 len = 0.0f;
+};
+
+inline bool gizmo_resolve_frame(const Editor &e, const Scene &scene, u32 node, const Camera &cam, GizmoFrameData &out) {
+    GizmoSelection sel = gizmo_selection(scene, node);
+    vec3 to_cam = cam.position - sel.origin;
+    f32 dist = to_cam.length();
+    if (!(dist > 0.0f) || !std::isfinite(dist)) {
+        return false;
+    }
+    // screen-constant handle size (see pixels_to_world).
+    f32 len = DebugDraw::pixels_to_world(dist, GIZMO_HANDLE_LEN_PX, cam.fov_y_deg, e.gizmo_view_size.y);
+    if (!(len > 0.0f) || !std::isfinite(len)) {
+        return false;
+    }
+    out.origin = sel.origin;
+    out.base_mat = sel.base_mat;
+    gizmo_compute_axes(e, scene, node, sel.base_mat, out.axes);
+    out.dist = dist;
+    out.len = len;
+    return true;
+}
+
+inline void gizmo_apply_frame_data(GizmoFrame &f, u32 node, const GizmoFrameData &d) {
+    f.node = node;
+    f.origin = d.origin;
+    f.base_mat = d.base_mat;
+    f.axes[0] = d.axes[0];
+    f.axes[1] = d.axes[1];
+    f.axes[2] = d.axes[2];
+    f.dist = d.dist;
+    f.len = d.len;
+}
+
+inline void gizmo_cancel_drag(Editor &e) {
+    e.gizmo_dragging = false;
+    e.gizmo_grab_node = -1;
+    e.gizmo_hover_axis = -1;
+}
+
+inline bool gizmo_begin_frame(Editor &e, Scene &scene, GizmoFrame &f) {
+    if (!e.show_cat_gizmo || e.selected_node < 0 || (u32)e.selected_node >= scene.scene_graph.node_count) {
+        gizmo_cancel_drag(e);
+        return false;
+    }
+    SceneGraph &sg = scene.scene_graph;
+    u32 node = (u32)e.selected_node;
+    if (!sg.is_node_alive(node)) {
+        gizmo_cancel_drag(e);
+        return false;
+    }
+    if (scene.active_camera >= scene.cameras.size()) {
+        gizmo_cancel_drag(e);
+        return false;
+    }
+    if (e.gizmo_view_size.x <= 0.0f || e.gizmo_view_size.y <= 0.0f) {
+        return false; // no viewport rect cached yet (first frame)
+    }
+    const Camera &cam = scene.cameras[scene.active_camera];
+
+    GizmoFrameData d;
+    if (!gizmo_resolve_frame(e, scene, node, cam, d)) {
+        gizmo_cancel_drag(e);
+        return false;
+    }
+
+    ImVec2 mouse = ImGui::GetMousePos();
+    bool hover_view = mouse.x >= e.gizmo_view_min.x && mouse.x <= e.gizmo_view_min.x + e.gizmo_view_size.x &&
+                      mouse.y >= e.gizmo_view_min.y && mouse.y <= e.gizmo_view_min.y + e.gizmo_view_size.y;
+
+    ImVec2 o_px{};
+    bool o_visible = project_to_view(d.origin, cam.view_proj, e.gizmo_view_min, e.gizmo_view_size, o_px);
+
+    // mouse ray in world space (for grab + drag).
+    // NDC: up is y=-1.
+    // imgui pixels map straight through, no y-flip
+    f32 nx = ((mouse.x - e.gizmo_view_min.x) / e.gizmo_view_size.x) * 2.0f - 1.0f;
+    f32 ny = ((mouse.y - e.gizmo_view_min.y) / e.gizmo_view_size.y) * 2.0f - 1.0f;
+    DebugRay ray = debug_unproject(nx, ny, cam.position, cam.inv_view_proj);
+
+    f.sg = &sg;
+    f.cam = &cam;
+    gizmo_apply_frame_data(f, node, d);
+    f.mouse = mouse;
+    f.hover_view = hover_view;
+    f.o_px = o_px;
+    f.o_visible = o_visible;
+    f.ray = ray;
+    return true;
+}
+
+inline void gizmo_capture_common(Editor &e, SceneGraph &sg, u32 node, vec3 origin, const mat4 &base_mat, u8 handle) {
+    e.gizmo_dragging = true;
+    e.gizmo_grab_node = (int)node;
+    e.gizmo_drag_axis = handle;
+    e.gizmo_base_local = sg.translations[node];
+    e.gizmo_base_world = origin;
+    e.gizmo_base_world_mat = base_mat;
+    e.gizmo_base_local_mat = sg.locals[node];
+    e.gizmo_base_scale = sg.scales[node];
+}
+
+// FREE keeps the caller's plane (move/scale set it to the view plane first)
+// AXIS refines per tool (rotate -> ring plane, scale -> grab distance)
+// PLANAR always refreshes plane + distance (move ignores the distance)
+inline void gizmo_derive_grab_refs(Editor &e, const GizmoFrame &f, const Camera &cam, vec3 origin, u8 handle) {
+    if (handle == GIZMO_FREE_AXIS) {
+        e.gizmo_grab_point = ray_plane_point(origin, e.gizmo_plane_n, f.ray.origin, f.ray.dir, origin);
+    } else if (handle == GIZMO_VIEW_AXIS) {
+        vec3 n = cam.forward().normalized();
+        e.gizmo_plane_n = n;
+        gizmo_ring_basis(n, e.gizmo_drag_u, e.gizmo_drag_v);
+        vec3 p = ray_plane_point(origin, n, f.ray.origin, f.ray.dir, origin);
+        e.gizmo_grab_point = p;
+        e.gizmo_grab_angle = gizmo_angle_on_plane(p, origin, e.gizmo_drag_u, e.gizmo_drag_v);
+    } else if (handle <= GIZMO_AXIS_Z) {
+        vec3 axis = f.axes[handle];
+        e.gizmo_grab_point = closest_point_on_axis(origin, axis, f.ray.origin, f.ray.dir);
+        // rotate uses plane perpendicular to axis
+        if (e.gizmo_tool == GIZMO_TOOL_ROTATE) {
+            e.gizmo_plane_n = axis;
+            gizmo_ring_basis(axis, e.gizmo_drag_u, e.gizmo_drag_v);
+            vec3 p = ray_plane_point(origin, axis, f.ray.origin, f.ray.dir, origin);
+            e.gizmo_grab_point = p;
+            e.gizmo_grab_angle = gizmo_angle_on_plane(p, origin, e.gizmo_drag_u, e.gizmo_drag_v);
+        } else if (e.gizmo_tool == GIZMO_TOOL_SCALE) {
+            e.gizmo_grab_dist = (e.gizmo_grab_point - origin).dot(axis);
+        }
+    } else {
+        // planar handles: grab on their plane
+        GizmoPlaneMap m = gizmo_plane_map(handle);
+        e.gizmo_plane_n = f.axes[m.n_axis];
+        e.gizmo_grab_point = ray_plane_point(origin, e.gizmo_plane_n, f.ray.origin, f.ray.dir, origin);
+        e.gizmo_grab_dist = (e.gizmo_grab_point - origin).length();
+    }
+}
+
+inline void gizmo_rebase_drag(Editor &e, Scene &scene, GizmoFrame &f) {
     SceneGraph &sg = *f.sg;
+    bool alive = e.selected_node >= 0 && (u32)e.selected_node < sg.node_count && sg.is_node_alive((u32)e.selected_node);
+    if (!alive || !debug_finite_vec3(f.ray.dir) || !debug_finite_vec3(f.ray.origin)) {
+        e.gizmo_dragging = false;
+        e.gizmo_grab_node = -1;
+        return;
+    }
+    u32 node = (u32)e.selected_node;
     const Camera &cam = *f.cam;
+    GizmoFrameData d;
+    if (!gizmo_resolve_frame(e, scene, node, cam, d)) {
+        e.gizmo_dragging = false;
+        e.gizmo_grab_node = -1;
+        return;
+    }
+    gizmo_apply_frame_data(f, node, d);
+    e.gizmo_grab_node = e.selected_node;
+    e.gizmo_base_local = sg.translations[node];
+    e.gizmo_base_world = d.origin;
+    e.gizmo_base_world_mat = d.base_mat;
+    e.gizmo_base_local_mat = sg.locals[node];
+    e.gizmo_base_scale = sg.scales[node];
+    gizmo_derive_grab_refs(e, f, cam, d.origin, e.gizmo_drag_axis);
+}
 
-    // screen-space handle hit test
-    int hover_axis = -1;
-    if (f.o_visible && f.hover_view && !e.gizmo_dragging) {
-        if (gizmo_hit_center(f.mouse, f.o_px)) {
-            hover_axis = GIZMO_FREE_AXIS;
-        } else {
-            hover_axis = gizmo_hit_axis(f.mouse, f.o_px, f, e);
+inline void gizmo_write_translation(SceneGraph &sg, u32 node, vec3 base_local, vec3 base_world, vec3 new_world) {
+    int p = sg.parent[node];
+    mat4 parent_world = (p >= 0) ? sg.worlds[p] : identity();
+    vec3 local_delta = transform_direction(new_world - base_world, inverse(parent_world));
+    vec3 new_local = base_local + local_delta;
+    if (debug_finite_vec3(new_local)) {
+        sg.set_translation(node, new_local);
+    }
+}
+
+// world-space delta rotation about the node origin.
+inline void gizmo_write_rotation(SceneGraph &sg, u32 node, const mat4 &base_world, vec3 axis_world, f32 delta) {
+    TRS base_trs = mat4_to_trs(base_world);
+    quat dq = quat::from_axis_angle(axis_world, delta); // normalizes axis internally
+    // quat qn = (dq * quat(base_trs.rotation)).normalized();
+    quat qn = (quat(base_trs.rotation) * dq).normalized();
+    TRS new_world_trs{base_trs.translation, qn, base_trs.scale};
+    mat4 new_world = trs_to_mat4(new_world_trs);
+    int p = sg.parent[node];
+    mat4 parent_world = (p >= 0) ? sg.worlds[p] : identity();
+    mat4 new_local = new_world * inverse(parent_world);
+    if (debug_finite_vec3(vec3(new_local.m[12], new_local.m[13], new_local.m[14]))) {
+        sg.set_local_matrix(node, new_local);
+    }
+}
+
+inline void gizmo_write_scale_local(SceneGraph &sg, u32 node, vec3 base_scale, u8 handle, f32 factor) {
+    if (!(factor > 0.0f) || !std::isfinite(factor)) {
+        return;
+    }
+    vec3 mask = gizmo_scale_mask(handle);
+    if (mask.length_squared() <= 0.0f) {
+        return;
+    }
+    vec3 s = {
+        mask.x > 0.5f ? base_scale.x * factor : base_scale.x,
+        mask.y > 0.5f ? base_scale.y * factor : base_scale.y,
+        mask.z > 0.5f ? base_scale.z * factor : base_scale.z
+    };
+    auto clamp_axis = [](f32 v) {
+        f32 a = fabsf(v);
+        if (!(a >= 1e-4f)) {
+            a = 1e-4f;
         }
+        return (v < 0.0f ? -1.0f : 1.0f) * a;
+    };
+    s.x = clamp_axis(s.x);
+    s.y = clamp_axis(s.y);
+    s.z = clamp_axis(s.z);
+    if (debug_finite_vec3(s)) {
+        sg.set_scale(node, s);
     }
-    e.gizmo_hover_axis = hover_axis;
+}
+
+// world-space scale about the node origin (handles rotated nodes).
+inline void gizmo_write_scale_world(SceneGraph &sg, u32 node, const mat4 &base_world, u8 handle, f32 factor) {
+    if (!(factor > 0.0f) || !std::isfinite(factor)) {
+        return;
+    }
+    vec3 mask = gizmo_scale_mask(handle);
+    if (mask.length_squared() <= 0.0f) {
+        return;
+    }
+    vec3 f = vec3(1.0f) + mask * (factor - 1.0f);
+    mat4 s = scale(f);
+    mat4 new_world = base_world * s;
+    int p = sg.parent[node];
+    mat4 parent_world = (p >= 0) ? sg.worlds[p] : identity();
+    mat4 new_local = new_world * inverse(parent_world);
+    TRS t = mat4_to_trs(new_local);
+    if (t.scale.x < 1e-4f) {
+        t.scale.x = 1e-4f;
+    }
+    if (t.scale.y < 1e-4f) {
+        t.scale.y = 1e-4f;
+    }
+    if (t.scale.z < 1e-4f) {
+        t.scale.z = 1e-4f;
+    }
+    if (debug_finite_vec3(t.scale) && debug_finite_vec3(t.translation)) {
+        sg.set_local_matrix(node, trs_to_mat4(t));
+    }
+}
 
-    // rebase, if selected node updated
-    if (e.gizmo_dragging && e.selected_node != e.gizmo_grab_node) {
+inline bool gizmo_hit_center(ImVec2 mouse, ImVec2 o_px) {
+    f32 dx = mouse.x - o_px.x;
+    f32 dy = mouse.y - o_px.y;
+    return sqrtf(dx * dx + dy * dy) < GIZMO_CENTER_HIT_PX;
+}
+
+inline int gizmo_hit_axis(ImVec2 mouse, ImVec2 o_px, const GizmoFrame &f, const Editor &e) {
+    f32 best = GIZMO_AXIS_HIT_PX;
+    int hit = -1;
+    for (u8 a = 0; a < 3; ++a) {
+        ImVec2 t_px{};
+        if (!project_to_view(
+                f.origin + f.axes[a] * f.len, f.cam->view_proj, e.gizmo_view_min, e.gizmo_view_size, t_px
+            )) {
+            continue;
+        }
+        f32 d = dist_point_seg(mouse, o_px, t_px);
+        if (d < best) {
+            best = d;
+            hit = (int)a;
+        }
+        // fatten tip grab
+        f32 dx = mouse.x - t_px.x;
+        f32 dy = mouse.y - t_px.y;
+        if (sqrtf(dx * dx + dy * dy) < GIZMO_CENTER_HIT_PX && d < GIZMO_AXIS_HIT_PX + 4.0f) {
+            hit = (int)a;
+            best = d;
+        }
+    }
+    return hit;
+}
+
+inline bool gizmo_quad_contains(ImVec2 p, ImVec2 a, ImVec2 b, ImVec2 c, ImVec2 d) {
+    // convex quad a->b->c->d: same sign on all edge crosses
+    ImVec2 pts[4] = {a, b, c, d};
+    f32 sign = 0.0f;
+    for (int i = 0; i < 4; ++i) {
+        ImVec2 p0 = pts[i];
+        ImVec2 p1 = pts[(i + 1) & 3];
+        f32 cross = (p1.x - p0.x) * (p.y - p0.y) - (p1.y - p0.y) * (p.x - p0.x);
+        if (fabsf(cross) < 1e-6f) {
+            continue;
+        }
+        if (sign == 0.0f) {
+            sign = cross;
+        } else if ((sign > 0.0f) != (cross > 0.0f)) {
+            return false;
+        }
+    }
+    return sign != 0.0f;
+}
+
+// planar quad handle hit (move/scale): quad(origin, origin+a*q, origin+a*q+b*q, origin+b*q).
+inline int gizmo_hit_planar(ImVec2 mouse, const GizmoFrame &f, const Editor &e) {
+    f32 q = f.len * GIZMO_PLANE_FRAC;
+    const u8 ids[3] = {GIZMO_PLANE_YZ, GIZMO_PLANE_XZ, GIZMO_PLANE_XY};
+    for (u8 id : ids) {
+        GizmoPlaneMap m = gizmo_plane_map(id);
+        ImVec2 p0 = f.o_px, p1{}, p2{}, p3{};
+        if (!project_to_view(f.origin + f.axes[m.ia] * q, f.cam->view_proj, e.gizmo_view_min, e.gizmo_view_size, p1)) {
+            continue;
+        }
+        if (!project_to_view(
+                f.origin + (f.axes[m.ia] + f.axes[m.ib]) * q, f.cam->view_proj, e.gizmo_view_min, e.gizmo_view_size, p2
+            )) {
+            continue;
+        }
+        if (!project_to_view(f.origin + f.axes[m.ib] * q, f.cam->view_proj, e.gizmo_view_min, e.gizmo_view_size, p3)) {
+            continue;
+        }
+        // reject edge-on (tiny area)
+        f32 area = fabsf((p1.x - p0.x) * (p3.y - p0.y) - (p1.y - p0.y) * (p3.x - p0.x));
+        if (area < 80.0f) {
+            continue;
+        }
+        if (gizmo_quad_contains(mouse, p0, p1, p2, p3)) {
+            return id;
+        }
+    }
+    return -1;
+}
+
+inline f32 gizmo_ring_screen_dist(ImVec2 mouse, const GizmoFrame &f, const Editor &e, vec3 axis, f32 radius) {
+    vec3 u, v;
+    gizmo_ring_basis(axis.normalized(), u, v);
+    ImVec2 prev{};
+    bool has_prev = false;
+    f32 best = 1e30f;
+    for (u32 i = 0; i <= GIZMO_RING_SEGS; ++i) {
+        f32 t = (f32)i / (f32)GIZMO_RING_SEGS * 2.0f * PI;
+        vec3 p = f.origin + (u * cosf(t) + v * sinf(t)) * radius;
+        ImVec2 px{};
+        if (!project_to_view(p, f.cam->view_proj, e.gizmo_view_min, e.gizmo_view_size, px)) {
+            has_prev = false;
+            continue;
+        }
+        if (has_prev) {
+            f32 d = dist_point_seg(mouse, prev, px);
+            if (d < best) {
+                best = d;
+            }
+        }
+        prev = px;
+        has_prev = true;
+    }
+    return best;
+}
+
+inline int gizmo_hit_rotate(ImVec2 mouse, ImVec2 o_px, const GizmoFrame &f, const Editor &e) {
+    vec3 view_dir = f.cam->forward().normalized();
+    f32 dx = mouse.x - o_px.x;
+    f32 dy = mouse.y - o_px.y;
+    f32 center_d = sqrtf(dx * dx + dy * dy);
+    // dead-zone around the origin: edge-on rings collapse to lines through the
+    // center, so clicks here almost never mean a ring grab.
+    if (center_d < GIZMO_RING_DEADZONE_PX) {
+        return -1;
+    }
+    // outer view ring first (topmost in Blender)
+    f32 outer_r = f.len * GIZMO_OUTER_RING_SCALE;
+    f32 outer_px = GIZMO_HANDLE_LEN_PX * GIZMO_OUTER_RING_SCALE;
+    if (fabsf(center_d - outer_px) < GIZMO_OUTER_HIT_PX) {
+        f32 d = gizmo_ring_screen_dist(mouse, f, e, view_dir, outer_r);
+        if (d < GIZMO_OUTER_HIT_PX) {
+            return GIZMO_VIEW_AXIS;
+        }
+    }
+    int hit = -1;
+    f32 best = 1e30f;
+    for (u8 a = 0; a < 3; ++a) {
+        // face-on rings read as circles (easier target), edge-on as thin lines.
+        f32 facing = fabsf(f.axes[a].normalized().dot(view_dir));
+        f32 tol = facing > 0.5f ? GIZMO_RING_HIT_PX : GIZMO_RING_HIT_EDGE_PX;
+        f32 d = gizmo_ring_screen_dist(mouse, f, e, f.axes[a], f.len);
+        if (d < tol && d < best) {
+            best = d;
+            hit = (int)a;
+        }
+    }
+    return hit;
+}
+
+inline void gizmo_draw_circle(DebugDraw &draw, vec3 origin, vec3 u, vec3 v, f32 radius, vec4 col, f32 w) {
+    vec3 prev = origin + u * radius;
+    for (u32 i = 1; i <= GIZMO_RING_SEGS; ++i) {
+        f32 t = (f32)i / (f32)GIZMO_RING_SEGS * 2.0f * PI;
+        vec3 p = origin + (u * cosf(t) + v * sinf(t)) * radius;
+        draw.line(prev, p, col, DEBUG_CAT_GIZMO, DEBUG_DEPTH_XRAY, w);
+        prev = p;
+    }
+}
+
+inline void gizmo_draw_center_ring(DebugDraw &draw, const GizmoFrame &f, const Editor &e, bool active) {
+    vec4 col = active ? vec4(5.0f, 5.0f, 5.0f, 1.0f) : vec4(3.0f, 3.0f, 3.0f, 1.0f);
+    f32 w = active ? 4.0f : 3.0f;
+    f32 r = DebugDraw::pixels_to_world(f.dist, GIZMO_CENTER_RADIUS_PX, f.cam->fov_y_deg, e.gizmo_view_size.y);
+    if (!(r > 0.0f) || !std::isfinite(r)) {
+        return;
+    }
+    gizmo_draw_circle(draw, f.origin, f.cam->right(), f.cam->up(), r, col, w);
+}
+
+inline void gizmo_draw_ring(DebugDraw &draw, const GizmoFrame &f, u8 axis_idx, vec3 axis, bool active) {
+    vec4 col = active ? vec4(5.0f, 5.0f, 5.0f, 1.0f) : gizmo_axis_color(axis_idx);
+    f32 w = active ? 4.0f : 3.0f;
+    vec3 u, v;
+    gizmo_ring_basis(axis.normalized(), u, v);
+    gizmo_draw_circle(draw, f.origin, u, v, f.len, col, w);
+}
+
+inline void gizmo_draw_view_ring(DebugDraw &draw, const GizmoFrame &f, const Camera &cam, bool active) {
+    vec4 col = active ? vec4(5.0f, 5.0f, 5.0f, 1.0f) : vec4(3.0f, 3.0f, 3.0f, 1.0f);
+    f32 w = active ? 4.0f : 3.0f;
+    vec3 n = cam.forward().normalized();
+    vec3 u, v;
+    gizmo_ring_basis(n, u, v);
+    gizmo_draw_circle(draw, f.origin, u, v, f.len * GIZMO_OUTER_RING_SCALE, col, w);
+}
+
+inline void gizmo_draw_planar_quad(DebugDraw &draw, const GizmoFrame &f, u8 plane_id, bool active) {
+    GizmoPlaneMap m = gizmo_plane_map(plane_id);
+    vec4 col = gizmo_axis_color(m.n_axis);
+    col.w = 1.0f;
+    if (active) {
+        col = vec4(5.0f, 5.0f, 5.0f, 1.0f);
+    } else {
+        col.x *= 0.55f;
+        col.y *= 0.55f;
+        col.z *= 0.55f;
+    }
+    f32 w = active ? 4.0f : 2.5f;
+    f32 q = f.len * GIZMO_PLANE_FRAC;
+    vec3 p0 = f.origin;
+    vec3 p1 = f.origin + f.axes[m.ia] * q;
+    vec3 p2 = f.origin + (f.axes[m.ia] + f.axes[m.ib]) * q;
+    vec3 p3 = f.origin + f.axes[m.ib] * q;
+    draw.line(p0, p1, col, DEBUG_CAT_GIZMO, DEBUG_DEPTH_XRAY, w);
+    draw.line(p1, p2, col, DEBUG_CAT_GIZMO, DEBUG_DEPTH_XRAY, w);
+    draw.line(p2, p3, col, DEBUG_CAT_GIZMO, DEBUG_DEPTH_XRAY, w);
+    draw.line(p3, p0, col, DEBUG_CAT_GIZMO, DEBUG_DEPTH_XRAY, w);
+}
+
+inline void gizmo_draw_scale_cube(DebugDraw &draw, vec3 center, f32 size, vec4 col, f32 w) {
+    mat4 world = scale(vec3(size)) * translate(center);
+    draw.box_mat(world, col, DEBUG_CAT_GIZMO, DEBUG_DEPTH_XRAY, w);
+}
+
+// shared hover path for move/scale: center -> axis -> planar
+inline int gizmo_hover_move_scale(const GizmoFrame &f, const Editor &e) {
+    if (gizmo_hit_center(f.mouse, f.o_px)) {
+        return GIZMO_FREE_AXIS;
+    }
+    int hit = gizmo_hit_axis(f.mouse, f.o_px, f, e);
+    return hit >= 0 ? hit : gizmo_hit_planar(f.mouse, f, e);
+}
+
+// shared axis-line draw for move/scale; tip_fn draws the per-tool tip marker
+template <typename TipFn>
+inline void gizmo_draw_axes(DebugDraw &draw, const GizmoFrame &f, const Editor &e, int hover_axis, TipFn tip_fn) {
+    for (u8 a = 0; a < 3; ++a) {
+        bool active = (e.gizmo_dragging && a == e.gizmo_drag_axis) || (!e.gizmo_dragging && (int)a == hover_axis);
+        vec4 col = active ? vec4(5.0f, 5.0f, 5.0f, 1.0f) : gizmo_axis_color(a);
+        f32 w = active ? 4.0f : 3.0f;
+        vec3 tip = f.origin + f.axes[a] * f.len;
+        draw.line(f.origin, tip, col, DEBUG_CAT_GIZMO, DEBUG_DEPTH_XRAY, w);
+        tip_fn(draw, tip, f.len, col, w);
+    }
+}
+
+inline void gizmo_draw_planars(DebugDraw &draw, const GizmoFrame &f, const Editor &e, int hover_axis) {
+    for (u8 p = GIZMO_PLANE_YZ; p <= GIZMO_PLANE_XY; ++p) {
+        bool active = (e.gizmo_dragging && p == e.gizmo_drag_axis) || (!e.gizmo_dragging && (int)p == hover_axis);
+        gizmo_draw_planar_quad(draw, f, p, active);
+    }
+}
+
+inline bool gizmo_center_active(const Editor &e, int hover_axis) {
+    return (e.gizmo_dragging && e.gizmo_drag_axis == GIZMO_FREE_AXIS) ||
+           (!e.gizmo_dragging && hover_axis == GIZMO_FREE_AXIS);
+}
+
+bool gizmo_rebase_if_moved(Editor &e, Scene &scene, GizmoFrame &f, int tool) {
+    if (e.gizmo_dragging &&
+        (e.selected_node != e.gizmo_grab_node || e.gizmo_tool_grab != tool || e.gizmo_world_grab != e.gizmo_world)) {
         gizmo_rebase_drag(e, scene, f);
+        e.gizmo_tool_grab = tool;
+        e.gizmo_world_grab = e.gizmo_world;
+        return true;
     }
+    return false;
+}
+
+void gizmo_end_drag(Editor &e) {
+    e.gizmo_dragging = false;
+    e.gizmo_grab_node = -1;
+    e.gizmo_tool_grab = -1;
+}
+
+bool gizmo_can_hover(const GizmoFrame &f, const Editor &e) {
+    return f.o_visible && f.hover_view && !e.gizmo_dragging;
+}
+
+bool gizmo_can_grab(const GizmoFrame &f, int hover_axis) {
+    return f.hover_view && hover_axis >= 0 && debug_finite_vec3(f.ray.dir) && debug_finite_vec3(f.ray.origin);
+}
+
+void update_move_gizmo(Editor &e, Scene &scene, DebugDraw &draw) {
+    GizmoFrame f;
+    if (!gizmo_begin_frame(e, scene, f)) {
+        return;
+    }
+    SceneGraph &sg = *f.sg;
+    const Camera &cam = *f.cam;
+
+    int hover_axis = gizmo_can_hover(f, e) ? gizmo_hover_move_scale(f, e) : -1;
+    e.gizmo_hover_axis = hover_axis;
+
+    gizmo_rebase_if_moved(e, scene, f, GIZMO_TOOL_MOVE);
 
     bool mouse_down = ImGui::IsMouseDown(0);
     bool clicked = ImGui::IsMouseClicked(0);
 
     if (!e.gizmo_dragging) {
-        if (clicked && f.hover_view && hover_axis >= 0 && debug_finite_vec3(f.ray.dir) &&
-            debug_finite_vec3(f.ray.origin)) {
-            // gizmo handle grab, axis = 0/1/2, center = 3
-            e.gizmo_dragging = true;
-            e.gizmo_grab_node = e.selected_node;
-            e.gizmo_drag_axis = (u8)hover_axis;
-            e.gizmo_base_local = sg.translations[f.node];
-            e.gizmo_base_world = f.origin;
+        if (clicked && gizmo_can_grab(f, hover_axis)) {
+            gizmo_capture_common(e, sg, f.node, f.origin, f.base_mat, (u8)hover_axis);
+            e.gizmo_tool_grab = GIZMO_TOOL_MOVE;
+            e.gizmo_world_grab = e.gizmo_world;
             if (e.gizmo_drag_axis == GIZMO_FREE_AXIS) {
                 e.gizmo_plane_n = cam.forward().normalized();
-                e.gizmo_grab_point = ray_plane_point(f.origin, e.gizmo_plane_n, f.ray.origin, f.ray.dir, f.origin);
-            } else {
-                vec3 axis = gizmo_axis_dir(e.gizmo_drag_axis);
-                e.gizmo_grab_point = closest_point_on_axis(f.origin, axis, f.ray.origin, f.ray.dir);
             }
+            gizmo_derive_grab_refs(e, f, cam, f.origin, e.gizmo_drag_axis);
         }
     } else if (!mouse_down) {
-        e.gizmo_dragging = false;
-        e.gizmo_grab_node = -1;
+        gizmo_end_drag(e);
     } else {
-        // drag: current ref point -> world delta -> parent-space local write.
         vec3 cur = e.gizmo_grab_point;
         if (e.gizmo_drag_axis == GIZMO_FREE_AXIS) {
             cur = ray_plane_point(e.gizmo_base_world, e.gizmo_plane_n, f.ray.origin, f.ray.dir, cur);
-        } else {
-            vec3 axis = gizmo_axis_dir(e.gizmo_drag_axis);
+        } else if (e.gizmo_drag_axis <= GIZMO_AXIS_Z) {
+            // axis may have rotated since grab
+            // reconstructed from current frame axes (rebase keeps them fresh)
+            vec3 axis = f.axes[e.gizmo_drag_axis];
             cur = closest_point_on_axis(e.gizmo_base_world, axis, f.ray.origin, f.ray.dir);
+        } else {
+            cur = ray_plane_point(e.gizmo_base_world, e.gizmo_plane_n, f.ray.origin, f.ray.dir, cur);
         }
         vec3 new_world = e.gizmo_base_world + (cur - e.gizmo_grab_point);
         if (e.gizmo_snap && e.gizmo_snap_step > 0.0f) {
@@ -75,25 +809,172 @@ void update_translate_gizmo(Editor &e, Scene &scene, DebugDraw &draw) {
         gizmo_write_translation(sg, f.node, e.gizmo_base_local, e.gizmo_base_world, new_world);
     }
 
-    // handles (always drawn while a valid selection exists).
     if (!f.o_visible) {
         return;
     }
-    GizmoDrawScope tool_draw(draw);
+    gizmo_draw_axes(draw, f, e, hover_axis, [](DebugDraw &d, vec3 tip, f32 len, vec4 col, f32 w) {
+        d.cross(tip, len * 0.14f, col, DEBUG_CAT_GIZMO, DEBUG_DEPTH_XRAY, w);
+    });
+    gizmo_draw_planars(draw, f, e, hover_axis);
+    gizmo_draw_center_ring(draw, f, e, gizmo_center_active(e, hover_axis));
+}
+
+void update_rotate_gizmo(Editor &e, Scene &scene, DebugDraw &draw) {
+    GizmoFrame f;
+    if (!gizmo_begin_frame(e, scene, f)) {
+        return;
+    }
+    SceneGraph &sg = *f.sg;
+    const Camera &cam = *f.cam;
+
+    int hover_axis = gizmo_can_hover(f, e) ? gizmo_hit_rotate(f.mouse, f.o_px, f, e) : -1;
+    e.gizmo_hover_axis = hover_axis;
+
+    gizmo_rebase_if_moved(e, scene, f, GIZMO_TOOL_ROTATE);
+
+    bool mouse_down = ImGui::IsMouseDown(0);
+    bool clicked = ImGui::IsMouseClicked(0);
+
+    if (!e.gizmo_dragging) {
+        if (clicked && gizmo_can_grab(f, hover_axis)) {
+            gizmo_capture_common(e, sg, f.node, f.origin, f.base_mat, (u8)hover_axis);
+            e.gizmo_tool_grab = GIZMO_TOOL_ROTATE;
+            e.gizmo_world_grab = e.gizmo_world;
+            gizmo_derive_grab_refs(e, f, cam, f.origin, (u8)hover_axis);
+        }
+    } else if (!mouse_down) {
+        gizmo_end_drag(e);
+    } else {
+        vec3 p = ray_plane_point(e.gizmo_base_world, e.gizmo_plane_n, f.ray.origin, f.ray.dir, e.gizmo_grab_point);
+        f32 cur_ang = gizmo_angle_on_plane(p, e.gizmo_base_world, e.gizmo_drag_u, e.gizmo_drag_v);
+        f32 delta = gizmo_wrap_pi(cur_ang - e.gizmo_grab_angle);
+        if (e.gizmo_snap) {
+            f32 step = e.gizmo_snap_angle > 0.0f ? e.gizmo_snap_angle : 15.0f;
+            f32 deg = degrees(delta);
+            deg = roundf(deg / step) * step;
+            delta = radians(deg);
+        }
+        if (std::isfinite(delta) && fabsf(delta) > 1e-7f) {
+            gizmo_write_rotation(sg, f.node, e.gizmo_base_world_mat, e.gizmo_plane_n, delta);
+        }
+    }
+
+    if (!f.o_visible) {
+        return;
+    }
     for (u8 a = 0; a < 3; ++a) {
         bool active = (e.gizmo_dragging && a == e.gizmo_drag_axis) || (!e.gizmo_dragging && (int)a == hover_axis);
-        vec4 col = active ? vec4(5.0f, 5.0f, 5.0f, 1.0f) : gizmo_axis_color(a);
-        f32 w = active ? 4.0f : 3.0f;
-        vec3 tip = f.origin + gizmo_axis_dir(a) * f.len;
-        draw.line(f.origin, tip, col, DEBUG_CAT_DEFAULT, DEBUG_DEPTH_XRAY, w);
-        draw.cross(tip, f.len * 0.14f, col, DEBUG_CAT_DEFAULT, DEBUG_DEPTH_XRAY, w);
+        gizmo_draw_ring(draw, f, a, f.axes[a], active);
+    }
+    bool view_active = (e.gizmo_dragging && e.gizmo_drag_axis == GIZMO_VIEW_AXIS) ||
+                       (!e.gizmo_dragging && hover_axis == GIZMO_VIEW_AXIS);
+    gizmo_draw_view_ring(draw, f, cam, view_active);
+}
+
+void update_scale_gizmo(Editor &e, Scene &scene, DebugDraw &draw) {
+    GizmoFrame f;
+    if (!gizmo_begin_frame(e, scene, f)) {
+        return;
     }
+    SceneGraph &sg = *f.sg;
+    const Camera &cam = *f.cam;
+
+    int hover_axis = gizmo_can_hover(f, e) ? gizmo_hover_move_scale(f, e) : -1;
+    e.gizmo_hover_axis = hover_axis;
+
+    gizmo_rebase_if_moved(e, scene, f, GIZMO_TOOL_SCALE);
+
+    bool mouse_down = ImGui::IsMouseDown(0);
+    bool clicked = ImGui::IsMouseClicked(0);
 
-    bool center_active = (e.gizmo_dragging && e.gizmo_drag_axis == GIZMO_FREE_AXIS) ||
-                         (!e.gizmo_dragging && hover_axis == GIZMO_FREE_AXIS);
-    gizmo_draw_center_ring(draw, f, e, center_active);
+    if (!e.gizmo_dragging) {
+        if (clicked && gizmo_can_grab(f, hover_axis)) {
+            u8 handle = (u8)hover_axis;
+            gizmo_capture_common(e, sg, f.node, f.origin, f.base_mat, handle);
+            e.gizmo_tool_grab = GIZMO_TOOL_SCALE;
+            e.gizmo_world_grab = e.gizmo_world;
+            if (handle == GIZMO_FREE_AXIS) {
+                e.gizmo_plane_n = cam.forward().normalized();
+                gizmo_derive_grab_refs(e, f, cam, f.origin, handle);
+                e.gizmo_grab_dist = (e.gizmo_grab_point - f.origin).length();
+            } else {
+                gizmo_derive_grab_refs(e, f, cam, f.origin, handle);
+            }
+            if (!(e.gizmo_grab_dist > 1e-6f) || !std::isfinite(e.gizmo_grab_dist)) {
+                e.gizmo_grab_dist = f.len * 0.5f;
+                if (!(e.gizmo_grab_dist > 1e-6f)) {
+                    e.gizmo_grab_dist = 1.0f;
+                }
+            }
+        }
+    } else if (!mouse_down) {
+        gizmo_end_drag(e);
+    } else {
+        f32 factor = 1.0f;
+        if (e.gizmo_drag_axis <= GIZMO_AXIS_Z) {
+            vec3 axis = f.axes[e.gizmo_drag_axis];
+            f32 cur_dist = axis_signed_distance(e.gizmo_base_world, axis, f.ray.origin, f.ray.dir);
+            if (fabsf(e.gizmo_grab_dist) > 1e-6f && std::isfinite(cur_dist)) {
+                factor = cur_dist / e.gizmo_grab_dist;
+            }
+        } else {
+            vec3 n = e.gizmo_plane_n;
+            vec3 p = ray_plane_point(e.gizmo_base_world, n, f.ray.origin, f.ray.dir, e.gizmo_grab_point);
+            f32 r = (p - e.gizmo_base_world).length();
+            if (e.gizmo_grab_dist > 1e-6f && std::isfinite(r)) {
+                factor = r / e.gizmo_grab_dist;
+            }
+        }
+        if (e.gizmo_snap && e.gizmo_snap_scale > 0.0f) {
+            factor = roundf(factor / e.gizmo_snap_scale) * e.gizmo_snap_scale;
+        }
+        if (factor < 1e-4f) {
+            factor = 1e-4f;
+        }
+        if (std::isfinite(factor)) {
+            if (e.gizmo_world) {
+                gizmo_write_scale_world(sg, f.node, e.gizmo_base_world_mat, e.gizmo_drag_axis, factor);
+            } else {
+                gizmo_write_scale_local(sg, f.node, e.gizmo_base_scale, e.gizmo_drag_axis, factor);
+            }
+        }
+    }
+
+    if (!f.o_visible) {
+        return;
+    }
+    gizmo_draw_axes(draw, f, e, hover_axis, [](DebugDraw &d, vec3 tip, f32 len, vec4 col, f32 w) {
+        gizmo_draw_scale_cube(d, tip, len * 0.11f, col, w);
+    });
+    gizmo_draw_planars(draw, f, e, hover_axis);
+    bool center_active = gizmo_center_active(e, hover_axis);
+    vec4 ccol = center_active ? vec4(5.0f, 5.0f, 5.0f, 1.0f) : vec4(3.0f, 3.0f, 3.0f, 1.0f);
+    gizmo_draw_scale_cube(draw, f.origin, f.len * 0.12f, ccol, center_active ? 4.0f : 3.0f);
+}
+
+void update_gizmo(Editor &e, Scene &scene, DebugDraw &draw) {
+    // goto tool that owns an in-flight drag, or just the curr tool
+    int tool = (int)e.gizmo_tool;
+    if (e.gizmo_dragging && e.gizmo_tool_grab >= 0) {
+        tool = e.gizmo_tool_grab;
+    }
+    switch (tool) {
+    case GIZMO_TOOL_ROTATE:
+        update_rotate_gizmo(e, scene, draw);
+        break;
+    case GIZMO_TOOL_SCALE:
+        update_scale_gizmo(e, scene, draw);
+        break;
+    case GIZMO_TOOL_MOVE:
+        update_move_gizmo(e, scene, draw);
+        break;
+    default:
+        assert(false && "invalid gizmo tool");
+        update_move_gizmo(e, scene, draw);
+        break;
+    }
 }
 
 } // namespace editor
 
-#endif // ENABLE_EDITOR
+#endif
DEL · src/editor/gizmo_common.h +0 -291
--- a/src/editor/gizmo_common.h
+++ b/src/editor/gizmo_common.h
@@ -1,291 +0,0 @@
-#pragma once
-
-#ifdef ENABLE_EDITOR
-
-#include <cmath>
-
-#include "core/math_rtm.h"
-#include "imgui.h"
-#include "passes/debug/debug_draw.h"
-#include "passes/debug/debug_ray.h"
-#include "scene/camera.h"
-#include "scene/scene.h"
-
-#include "editor.h"
-
-namespace editor {
-
-// 0/1/2 = X/Y/Z axis handles, 3 = center free-move handle.
-constexpr u8 GIZMO_FREE_AXIS = 3;
-constexpr f32 GIZMO_AXIS_HIT_PX = 10.0f;
-constexpr f32 GIZMO_CENTER_RADIUS_PX = 10.0f;
-constexpr f32 GIZMO_CENTER_HIT_PX = 12.0f;
-constexpr f32 GIZMO_HANDLE_LEN_PX = 90.0f;
-constexpr u32 GIZMO_RING_SEGS = 24;
-constexpr f32 GIZMO_PI = 3.14159265358979323846f;
-
-// world-space axis directions (engine space: X fwd, Y right, Z up)
-inline vec3 gizmo_axis_dir(u8 a) {
-    if (a == 0) {
-        return vec3(1.0f, 0.0f, 0.0f);
-    }
-    if (a == 1) {
-        return vec3(0.0f, 1.0f, 0.0f);
-    }
-    return vec3(0.0f, 0.0f, 1.0f);
-}
-
-// HDR-bright axis colors (grid convention: X red, Y green, Z blue).
-inline vec4 gizmo_axis_color(u8 a) {
-    if (a == 0) {
-        return vec4(4.0f, 0.6f, 0.6f, 1.0f);
-    }
-    if (a == 1) {
-        return vec4(0.6f, 4.0f, 0.6f, 1.0f);
-    }
-    return vec4(0.6f, 0.8f, 4.0f, 1.0f);
-}
-
-inline vec3 selection_origin(const Scene &scene, u32 node) {
-    const SceneGraph &sg = scene.scene_graph;
-    mat4 base = (sg.instance_count[node] > 0) ? scene.instance_world(node, 0) : sg.worlds[node];
-    vec4 h = transform_point(vec3(0.0f, 0.0f, 0.0f), base);
-    return vec3(h.x, h.y, h.z);
-}
-
-inline bool project_to_view(vec3 p, const mat4 &view_proj, ImVec2 min, ImVec2 size, ImVec2 &out) {
-    vec4 c = transform_point(p, view_proj);
-    if (c.w <= 0.0f) { // behind camera
-        return false;
-    }
-    f32 nx = c.x / c.w;
-    f32 ny = c.y / c.w;
-    out = ImVec2(min.x + (nx * 0.5f + 0.5f) * size.x, min.y + (ny * 0.5f + 0.5f) * size.y);
-    return true;
-}
-
-inline f32 dist_point_seg(ImVec2 p, ImVec2 a, ImVec2 b) {
-    f32 dx = b.x - a.x;
-    f32 dy = b.y - a.y;
-    f32 len2 = dx * dx + dy * dy;
-    f32 t = (len2 > 1e-8f) ? ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2 : 0.0f;
-    if (t < 0.0f) {
-        t = 0.0f;
-    } else if (t > 1.0f) {
-        t = 1.0f;
-    }
-    f32 ex = a.x + dx * t - p.x;
-    f32 ey = a.y + dy * t - p.y;
-    return sqrtf(ex * ex + ey * ey);
-}
-
-// closest point on axis (origin + s*axis, |axis|==1) to ray (ray_o + t*ray_d).
-inline vec3 closest_point_on_axis(vec3 origin, vec3 axis, vec3 ray_o, vec3 ray_d) {
-    vec3 w0 = origin - ray_o;
-    f32 b = axis.dot(ray_d);
-    f32 d = axis.dot(w0);
-    f32 f = ray_d.dot(w0);
-    f32 denom = 1.0f - b * b;
-    f32 s = (fabsf(denom) < 1e-6f) ? 0.0f : (b * f - d) / denom;
-    return origin + axis * s;
-}
-
-// ray plane intersection (point + t*n, |n|==1). Falls back to `fallback` when the ray
-// runs parallel to the plane or points away.
-inline vec3 ray_plane_point(vec3 point, vec3 n, vec3 ray_o, vec3 ray_d, vec3 fallback) {
-    f32 denom = ray_d.dot(n);
-    if (fabsf(denom) < 1e-6f) {
-        return fallback;
-    }
-    f32 t = (point - ray_o).dot(n) / denom;
-    if (!(t > 0.0f) || !std::isfinite(t)) {
-        return fallback;
-    }
-    return ray_o + ray_d * t;
-}
-
-// per-frame gizmo context, computed once per tool update.
-struct GizmoFrame {
-    u32 node = 0;
-    SceneGraph *sg = nullptr;
-    const Camera *cam = nullptr;
-    vec3 origin = vec3(0.0f);
-    f32 dist = 0.0f;
-    f32 len = 0.0f; // screen-constant handle length (world units)
-    ImVec2 mouse = ImVec2(0, 0);
-    bool hover_view = false;
-    ImVec2 o_px = ImVec2(0, 0);
-    bool o_visible = false;
-    DebugRay ray = {};
-};
-
-inline bool gizmo_begin_frame(Editor &e, Scene &scene, GizmoFrame &f) {
-    if (!e.show_translate_gizmo || e.selected_node < 0 || (u32)e.selected_node >= scene.scene_graph.node_count) {
-        e.gizmo_dragging = false;
-        e.gizmo_grab_node = -1;
-        e.gizmo_hover_axis = -1;
-        return false;
-    }
-    SceneGraph &sg = scene.scene_graph;
-    u32 node = (u32)e.selected_node;
-    if (!sg.is_node_alive(node)) {
-        e.gizmo_dragging = false;
-        e.gizmo_grab_node = -1;
-        e.gizmo_hover_axis = -1;
-        return false;
-    }
-    if (scene.active_camera >= scene.cameras.size()) {
-        e.gizmo_dragging = false;
-        e.gizmo_grab_node = -1;
-        e.gizmo_hover_axis = -1;
-        return false;
-    }
-    if (e.gizmo_view_size.x <= 0.0f || e.gizmo_view_size.y <= 0.0f) {
-        return false; // no viewport rect cached yet (first frame)
-    }
-    const Camera &cam = scene.cameras[scene.active_camera];
-
-    vec3 origin = selection_origin(scene, node);
-    vec3 to_cam = cam.position - origin;
-    f32 dist = to_cam.length();
-    if (!(dist > 0.0f) || !std::isfinite(dist)) {
-        e.gizmo_dragging = false;
-        e.gizmo_grab_node = -1;
-        e.gizmo_hover_axis = -1;
-        return false;
-    }
-    // screen-constant handle size (see pixels_to_world).
-    f32 len = DebugDraw::pixels_to_world(dist, GIZMO_HANDLE_LEN_PX, cam.fov_y_deg, e.gizmo_view_size.y);
-    if (!(len > 0.0f) || !std::isfinite(len)) {
-        return false;
-    }
-
-    ImVec2 mouse = ImGui::GetMousePos();
-    bool hover_view = mouse.x >= e.gizmo_view_min.x && mouse.x <= e.gizmo_view_min.x + e.gizmo_view_size.x &&
-                      mouse.y >= e.gizmo_view_min.y && mouse.y <= e.gizmo_view_min.y + e.gizmo_view_size.y;
-
-    ImVec2 o_px{};
-    bool o_visible = project_to_view(origin, cam.view_proj, e.gizmo_view_min, e.gizmo_view_size, o_px);
-
-    // mouse ray in world space (for grab + drag).
-    // NDC: up is y=-1.
-    // imgui pixels map straight through, no y-flip
-    f32 nx = ((mouse.x - e.gizmo_view_min.x) / e.gizmo_view_size.x) * 2.0f - 1.0f;
-    f32 ny = ((mouse.y - e.gizmo_view_min.y) / e.gizmo_view_size.y) * 2.0f - 1.0f;
-    DebugRay ray = debug_unproject(nx, ny, cam.position, cam.inv_view_proj);
-
-    f.node = node;
-    f.sg = &sg;
-    f.cam = &cam;
-    f.origin = origin;
-    f.dist = dist;
-    f.len = len;
-    f.mouse = mouse;
-    f.hover_view = hover_view;
-    f.o_px = o_px;
-    f.o_visible = o_visible;
-    f.ray = ray;
-    return true;
-}
-
-inline void gizmo_rebase_drag(Editor &e, Scene &scene, GizmoFrame &f) {
-    SceneGraph &sg = *f.sg;
-    bool alive = e.selected_node >= 0 && (u32)e.selected_node < sg.node_count && sg.is_node_alive((u32)e.selected_node);
-    if (!alive || !debug_finite_vec3(f.ray.dir) || !debug_finite_vec3(f.ray.origin)) {
-        e.gizmo_dragging = false;
-        e.gizmo_grab_node = -1;
-        return;
-    }
-    u32 node = (u32)e.selected_node;
-    vec3 origin = selection_origin(scene, node);
-    const Camera &cam = *f.cam;
-    vec3 to_cam = cam.position - origin;
-    f32 dist = to_cam.length();
-    f32 len = DebugDraw::pixels_to_world(dist, GIZMO_HANDLE_LEN_PX, cam.fov_y_deg, e.gizmo_view_size.y);
-    if (!(dist > 0.0f) || !std::isfinite(dist) || !(len > 0.0f) || !std::isfinite(len)) {
-        e.gizmo_dragging = false;
-        e.gizmo_grab_node = -1;
-        return;
-    }
-    f.node = node;
-    f.origin = origin;
-    f.dist = dist;
-    f.len = len;
-    e.gizmo_grab_node = e.selected_node;
-    e.gizmo_base_local = sg.translations[node];
-    e.gizmo_base_world = origin;
-    if (e.gizmo_drag_axis == GIZMO_FREE_AXIS) {
-        e.gizmo_grab_point = ray_plane_point(origin, e.gizmo_plane_n, f.ray.origin, f.ray.dir, origin);
-    } else {
-        vec3 rebase_axis = gizmo_axis_dir(e.gizmo_drag_axis);
-        e.gizmo_grab_point = closest_point_on_axis(origin, rebase_axis, f.ray.origin, f.ray.dir);
-    }
-}
-
-inline void gizmo_write_translation(SceneGraph &sg, u32 node, vec3 base_local, vec3 base_world, vec3 new_world) {
-    int p = sg.parent[node];
-    mat4 parent_world = (p >= 0) ? sg.worlds[p] : identity();
-    vec3 local_delta = transform_direction(new_world - base_world, inverse(parent_world));
-    vec3 new_local = base_local + local_delta;
-    if (debug_finite_vec3(new_local)) {
-        sg.set_translation(node, new_local);
-    }
-}
-
-struct GizmoDrawScope {
-    DebugDraw &draw;
-    u32 saved_mask;
-    explicit GizmoDrawScope(DebugDraw &d) : draw(d), saved_mask(d.category_mask) {
-        draw.category_mask = 0xFFFFFFFFu;
-    }
-    ~GizmoDrawScope() {
-        draw.category_mask = saved_mask;
-    }
-};
-
-inline bool gizmo_hit_center(ImVec2 mouse, ImVec2 o_px) {
-    f32 dx = mouse.x - o_px.x;
-    f32 dy = mouse.y - o_px.y;
-    return sqrtf(dx * dx + dy * dy) < GIZMO_CENTER_HIT_PX;
-}
-
-inline int gizmo_hit_axis(ImVec2 mouse, ImVec2 o_px, const GizmoFrame &f, const Editor &e) {
-    f32 best = GIZMO_AXIS_HIT_PX;
-    int hit = -1;
-    for (u8 a = 0; a < 3; ++a) {
-        ImVec2 t_px{};
-        if (!project_to_view(
-                f.origin + gizmo_axis_dir(a) * f.len, f.cam->view_proj, e.gizmo_view_min, e.gizmo_view_size, t_px
-            )) {
-            continue;
-        }
-        f32 d = dist_point_seg(mouse, o_px, t_px);
-        if (d < best) {
-            best = d;
-            hit = (int)a;
-        }
-    }
-    return hit;
-}
-
-inline void gizmo_draw_center_ring(DebugDraw &draw, const GizmoFrame &f, const Editor &e, bool active) {
-    vec4 col = active ? vec4(5.0f, 5.0f, 5.0f, 1.0f) : vec4(3.0f, 3.0f, 3.0f, 1.0f);
-    f32 w = active ? 4.0f : 3.0f;
-    f32 r = DebugDraw::pixels_to_world(f.dist, GIZMO_CENTER_RADIUS_PX, f.cam->fov_y_deg, e.gizmo_view_size.y);
-    if (!(r > 0.0f) || !std::isfinite(r)) {
-        return;
-    }
-    vec3 right = f.cam->right();
-    vec3 up = f.cam->up();
-    vec3 prev = f.origin + right * r;
-    for (u32 i = 1; i <= GIZMO_RING_SEGS; ++i) {
-        f32 t = (f32)i / (f32)GIZMO_RING_SEGS * 2.0f * GIZMO_PI;
-        vec3 p = f.origin + (right * cosf(t) + up * sinf(t)) * r;
-        draw.line(prev, p, col, DEBUG_CAT_DEFAULT, DEBUG_DEPTH_XRAY, w);
-        prev = p;
-    }
-}
-
-} // namespace editor
-
-#endif // ENABLE_EDITOR
MOD · src/editor/viewport.cpp +61 -20
--- a/src/editor/viewport.cpp
+++ b/src/editor/viewport.cpp
@@ -41,39 +41,30 @@ void draw_viewport_central(Editor &e, RenderData &rd) {
 
     ImGui::Begin("Viewport", nullptr);
 
-    // translate-gizmo toolbar (widget state; drag math runs pre-execute in update_translate_gizmo)
-    {
-        bool move_on = e.show_translate_gizmo;
-        if (ImGui::Button(move_on ? "Move: ON" : "Move")) {
-            e.show_translate_gizmo = !e.show_translate_gizmo;
-        }
-        ImGui::SameLine();
-        ImGui::Checkbox("Snap", &e.gizmo_snap);
-        ImGui::SameLine();
-        ImGui::SetNextItemWidth(64.0f);
-        ImGui::DragFloat("##snapstep", &e.gizmo_snap_step, 0.01f, 0.01f, 10.0f, "%.2f");
-    }
-
     ImVec2 viewport_size = ImGui::GetContentRegionAvail();
     if (viewport_size.x > 0.0f && viewport_size.y > 0.0f) {
+        ImVec2 image_start_pos = ImGui::GetCursorPos();
         ImGui::Image((ImTextureID)(u64)slot, viewport_size);
 
-        // rect cache for next frame's pre-execute gizmo update
         e.gizmo_view_min = ImGui::GetItemRectMin();
         e.gizmo_view_size = viewport_size;
 
-        // a grab in progress owns the mouse, and handle presses are consumed
-        // by the gizmo: never (de)select underneath a drag or a handle grab
-        // (hover is 1 frame stale, same as the gizmo rect cache)
-        if (ImGui::IsItemHovered() && ImGui::IsItemClicked(ImGuiMouseButton_Left) && !e.gizmo_dragging &&
-            e.gizmo_hover_axis < 0 && rhi::valid(rd.id_blit)) {
+        static ImVec2 overlay_min = {0, 0};
+        static ImVec2 overlay_max = {0, 0};
+
+        bool clicking_overlay = ImGui::IsMouseHoveringRect(overlay_min, overlay_max, false);
+
+        // block click passthrough to viewport if its on the overlay window, compared to bounds grabbed on inti
+        if (!clicking_overlay && ImGui::IsItemHovered() && ImGui::IsItemClicked(ImGuiMouseButton_Left) &&
+            !e.gizmo_dragging && e.gizmo_hover_axis < 0 && rhi::valid(rd.id_blit)) {
+
             rhi::Image &id_img = rd.id_blit;
             ImVec2 rel = {
                 ImGui::GetMousePos().x - ImGui::GetItemRectMin().x, ImGui::GetMousePos().y - ImGui::GetItemRectMin().y
             };
             i64 tx = (i64)(rel.x * (f32)id_img.desc.width / viewport_size.x);
             i64 ty = (i64)(rel.y * (f32)id_img.desc.height / (viewport_size.y));
-            ty = id_img.desc.height - 1 - ty; // I flip the vk viewport
+            ty = id_img.desc.height - 1 - ty; // Flip Vulkan viewport
 
             rhi::ImageView id_view{};
             if (rhi::create_image_view(
@@ -91,6 +82,56 @@ void draw_viewport_central(Editor &e, RenderData &rd) {
                 e.selected_node = -1;
             }
         }
+
+        ImGui::SetCursorPos(ImVec2(image_start_pos.x + 10.0f, image_start_pos.y + 10.0f));
+
+        if (ImGui::BeginChild(
+                "##GizmoOverlay",
+                ImVec2(0.0f, 38.0f),
+                ImGuiChildFlags_AlwaysAutoResize | ImGuiChildFlags_AutoResizeX,
+                ImGuiWindowFlags_NoScrollbar
+            )) {
+
+            // need to track the bounds of this window, to decipher if its a viewport click or this window click
+            overlay_min = ImGui::GetWindowPos();
+            overlay_max = ImVec2(overlay_min.x + ImGui::GetWindowSize().x, overlay_min.y + ImGui::GetWindowSize().y);
+
+            if (ImGui::RadioButton("Move", e.gizmo_tool == GIZMO_TOOL_MOVE)) {
+                e.gizmo_tool = GIZMO_TOOL_MOVE;
+                e.gizmo_dragging = false;
+            }
+            ImGui::SameLine();
+            if (ImGui::RadioButton("Rot", e.gizmo_tool == GIZMO_TOOL_ROTATE)) {
+                e.gizmo_tool = GIZMO_TOOL_ROTATE;
+                e.gizmo_dragging = false;
+            }
+            ImGui::SameLine();
+            if (ImGui::RadioButton("Scale", e.gizmo_tool == GIZMO_TOOL_SCALE)) {
+                e.gizmo_tool = GIZMO_TOOL_SCALE;
+                e.gizmo_dragging = false;
+            }
+
+            ImGui::SameLine();
+            if (ImGui::Button(e.gizmo_world ? "World" : "Local")) {
+                e.gizmo_world = !e.gizmo_world;
+            }
+
+            ImGui::SameLine();
+            ImGui::Checkbox("Snap", &e.gizmo_snap);
+
+            if (e.gizmo_snap) {
+                ImGui::SameLine();
+                ImGui::SetNextItemWidth(128.0f);
+                if (e.gizmo_tool == GIZMO_TOOL_ROTATE) {
+                    ImGui::DragFloat("##snapangle", &e.gizmo_snap_angle, 0.5f, 0.1f, 90.0f, "%.1f deg");
+                } else if (e.gizmo_tool == GIZMO_TOOL_SCALE) {
+                    ImGui::DragFloat("##snapscale", &e.gizmo_snap_scale, 0.01f, 0.01f, 1.0f, "%.2f");
+                } else {
+                    ImGui::DragFloat("##snapstep", &e.gizmo_snap_step, 0.01f, 0.01f, 10.0f, "%.2f");
+                }
+            }
+        }
+        ImGui::EndChild();
     }
 
     u32 nw = (u32)(viewport_size.x + 0.5f), nh = (u32)(viewport_size.y + 0.5f);
MOD · src/passes/debug/debug_draw.h +1 -3
--- a/src/passes/debug/debug_draw.h
+++ b/src/passes/debug/debug_draw.h
@@ -16,9 +16,7 @@ enum DebugCategory : u8 {
     DEBUG_CAT_SELECTION = 3,
     DEBUG_CAT_LIGHT = 4,
     DEBUG_CAT_DDGI = 5,
-    DEBUG_CAT_GIZMO_X = 6, // reserved
-    DEBUG_CAT_GIZMO_Y = 7, // reserved
-    DEBUG_CAT_GIZMO_Z = 8, // reserved
+    DEBUG_CAT_GIZMO = 6,
 };
 
 enum DebugDepthMode : u8 {
MOD · src/renderer.cpp +1 -1
--- a/src/renderer.cpp
+++ b/src/renderer.cpp
@@ -1344,7 +1344,7 @@ int main() {
         if (editor.show_debug_lines) {
             ddgi_trace::debug_draw(render_data.debug_draw, render_data);
             editor::debug_draw_selection(render_data.debug_draw, scene, render_data, editor.selected_node);
-            editor::update_translate_gizmo(editor, scene, render_data.debug_draw);
+            editor::update_gizmo(editor, scene, render_data.debug_draw);
         }
 #endif