gizmo.cpp

cross platform rendering playground

src/editor/gizmo.cpp

34.68 KB
#ifdef ENABLE_EDITOR
#include "editor.h"

#include <cassert>

#include "imgui.h"
#include "passes/debug/debug_draw.h"
#include "passes/debug/debug_ray.h"
#include "scene/camera.h"
#include "scene/scene.h"

namespace editor {

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);
}

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);
    }
}

// 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));
    }
}

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 && 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();
            }
            gizmo_derive_grab_refs(e, f, cam, f.origin, e.gizmo_drag_axis);
        }
    } else if (!mouse_down) {
        gizmo_end_drag(e);
    } else {
        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 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) {
            new_world.x = roundf(new_world.x / e.gizmo_snap_step) * e.gizmo_snap_step;
            new_world.y = roundf(new_world.y / e.gizmo_snap_step) * e.gizmo_snap_step;
            new_world.z = roundf(new_world.z / e.gizmo_snap_step) * e.gizmo_snap_step;
        }
        gizmo_write_translation(sg, f.node, e.gizmo_base_local, e.gizmo_base_world, new_world);
    }

    if (!f.o_visible) {
        return;
    }
    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);
        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);

    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