use crate::{Gizmo, GizmoCamera, HandleId, Overlay};
use crate::math::{Ray, Vec3};
pub const HANDLE_NONE: HandleId = 0;
pub const HANDLE_AXIS_X: HandleId = 1;
pub const HANDLE_AXIS_Y: HandleId = 2;
pub const HANDLE_AXIS_Z: HandleId = 3;
pub const HANDLE_PLANE_XY: HandleId = 4;
pub const HANDLE_PLANE_YZ: HandleId = 5;
pub const HANDLE_PLANE_ZX: HandleId = 6;
pub const HANDLE_RING_X: HandleId = 7;
pub const HANDLE_RING_Y: HandleId = 8;
pub const HANDLE_RING_Z: HandleId = 9;
pub const HANDLE_CENTER: HandleId = 10;
const C_ROD: [f32; 4] = [0.80, 0.81, 0.82, 1.0]; const C_ARROW: [f32; 4] = [0.961, 0.651, 0.137, 1.0]; const C_RING: [f32; 4] = [0.91, 0.91, 0.91, 1.0]; const C_DOT: [f32; 4] = [0.961, 0.651, 0.137, 1.0]; const C_GOLD: [f32; 4] = [1.00, 0.85, 0.35, 1.0]; const C_CENTER: [f32; 4] = [0.961, 0.651, 0.137, 1.0];
pub const PX_AXIS_LEN: f32 = 90.0;
const PX_SHAFT_START: f32 = 8.0; const PX_HEAD_LEN: f32 = 18.0; const PX_HEAD_RAD: f32 = 7.0; const PX_SHAFT_RAD: f32 = 2.2; const PX_ARC_RAD: f32 = 64.0; const PX_CENTER_RAD: f32 = 7.0; const PX_RING_GRAB_RAD: f32 = 5.0;
const RING_SEGMENTS: usize = 24; const CONE_SEGMENTS: usize = 16; const TUBE_SEGMENTS: usize = 8; const SPHERE_RINGS: usize = 6; const SPHERE_SECTORS: usize = 10;
const ARCS: [(usize, usize, usize); 3] = [(2, 0, 1), (0, 1, 2), (1, 2, 0)];
#[derive(Debug, Clone, Copy)]
struct Sizes {
px: f32,
axis_len: f32,
shaft_start: f32,
head_len: f32,
head_rad: f32,
shaft_rad: f32,
arc_rad: f32,
center_rad: f32,
grab_rad: f32,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum DragDelta {
Translate(Vec3),
Rotate { axis_index: usize, radians: f32 },
None,
}
#[derive(Debug, Clone, Copy)]
pub struct TransformGizmo {
pub origin: Vec3,
pub ex: Vec3,
pub ey: Vec3,
pub ez: Vec3,
pub show_center: bool,
}
impl Default for TransformGizmo {
fn default() -> Self {
Self {
origin: Vec3::ZERO,
ex: Vec3::X,
ey: Vec3::Y,
ez: Vec3::Z,
show_center: true,
}
}
}
impl TransformGizmo {
pub fn at(origin: Vec3) -> Self {
Self { origin, ..Self::default() }
}
pub fn set_frame(&mut self, origin: Vec3, ex: Vec3, ey: Vec3, ez: Vec3) {
self.origin = origin;
let ex = ex.normalized();
let mut ez = ez.normalized();
if ez.length() < 1e-6 {
ez = ex.cross(ey).normalized();
}
let ey = ez.cross(ex).normalized();
let ez = ex.cross(ey).normalized();
self.ex = ex;
self.ey = ey;
self.ez = ez;
}
pub fn axis(&self, i: usize) -> Vec3 {
match i {
0 => self.ex,
1 => self.ey,
_ => self.ez,
}
}
fn sizes(&self, camera: &GizmoCamera) -> Sizes {
let px = camera.world_per_pixel(self.origin).max(1e-6);
Sizes {
px,
axis_len: PX_AXIS_LEN * px,
shaft_start: PX_SHAFT_START * px,
head_len: PX_HEAD_LEN * px,
head_rad: PX_HEAD_RAD * px,
shaft_rad: PX_SHAFT_RAD * px,
arc_rad: PX_ARC_RAD * px,
center_rad: PX_CENTER_RAD * px,
grab_rad: PX_RING_GRAB_RAD * px,
}
}
pub fn axis_seg(&self, camera: &GizmoCamera, i: usize) -> (Vec3, Vec3) {
let s = self.sizes(camera);
let a = self.axis(i);
(
self.origin.add(a.scale(s.shaft_start)),
self.origin.add(a.scale(s.axis_len)),
)
}
pub fn drag_delta(
&self,
camera: &GizmoCamera,
handle: HandleId,
start: Ray,
current: Ray,
) -> DragDelta {
match handle {
HANDLE_AXIS_X => DragDelta::Translate(self.axis_translate(camera, 0, start, current)),
HANDLE_AXIS_Y => DragDelta::Translate(self.axis_translate(camera, 1, start, current)),
HANDLE_AXIS_Z => DragDelta::Translate(self.axis_translate(camera, 2, start, current)),
HANDLE_PLANE_XY => DragDelta::Translate(self.plane_translate(0, 1, start, current)),
HANDLE_PLANE_YZ => DragDelta::Translate(self.plane_translate(1, 2, start, current)),
HANDLE_PLANE_ZX => DragDelta::Translate(self.plane_translate(2, 0, start, current)),
HANDLE_RING_X => DragDelta::Rotate { axis_index: 0, radians: self.ring_rotate(0, start, current) },
HANDLE_RING_Y => DragDelta::Rotate { axis_index: 1, radians: self.ring_rotate(1, start, current) },
HANDLE_RING_Z => DragDelta::Rotate { axis_index: 2, radians: self.ring_rotate(2, start, current) },
HANDLE_CENTER => DragDelta::Translate(self.center_translate(camera, start, current)),
_ => DragDelta::None,
}
}
pub fn axis_translate(&self, camera: &GizmoCamera, i: usize, start: Ray, current: Ray) -> Vec3 {
let n = camera.forward.normalized();
let (p0, p1) = match (
plane_point(&start, self.origin, n),
plane_point(¤t, self.origin, n),
) {
(Some(a), Some(b)) => (a, b),
_ => return Vec3::ZERO,
};
let d = p1.sub(p0).dot(self.axis(i));
let mut out = Vec3::ZERO;
match i {
0 => out.x = d,
1 => out.y = d,
_ => out.z = d,
}
out
}
pub fn plane_translate(&self, i: usize, j: usize, start: Ray, current: Ray) -> Vec3 {
let k = 3 - i - j; let n = self.axis(k);
let (p0, p1) = match (
plane_point(&start, self.origin, n),
plane_point(¤t, self.origin, n),
) {
(Some(a), Some(b)) => (a, b),
_ => return Vec3::ZERO,
};
let delta = p1.sub(p0);
let a = delta.dot(self.axis(i));
let b = delta.dot(self.axis(j));
let mut out = Vec3::ZERO;
set_comp(&mut out, i, a);
set_comp(&mut out, j, b);
out
}
pub fn ring_rotate(&self, i: usize, start: Ray, current: Ray) -> f32 {
let n = self.axis(i);
let (p0, p1) = match (
plane_point(&start, self.origin, n),
plane_point(¤t, self.origin, n),
) {
(Some(a), Some(b)) => (a, b),
_ => return 0.0,
};
let v0 = p0.sub(self.origin);
let v1 = p1.sub(self.origin);
if v0.length() < 1e-9 || v1.length() < 1e-9 {
return 0.0;
}
let v0 = v0.normalized();
let v1 = v1.normalized();
let cross = v0.cross(v1).dot(n);
let dot = v0.dot(v1).clamp(-1.0, 1.0);
cross.atan2(dot)
}
pub fn center_translate(&self, camera: &GizmoCamera, start: Ray, current: Ray) -> Vec3 {
let n = camera.forward.normalized();
let (p0, p1) = match (
plane_point(&start, self.origin, n),
plane_point(¤t, self.origin, n),
) {
(Some(a), Some(b)) => (a, b),
_ => return Vec3::ZERO,
};
let delta = p1.sub(p0);
Vec3::new(delta.dot(self.ex), delta.dot(self.ey), delta.dot(self.ez))
}
fn arc_point(&self, i: usize, j: usize, t: f32, radius: f32) -> Vec3 {
let ang = t * std::f32::consts::FRAC_PI_2;
self.origin
.add(self.axis(i).scale(ang.cos() * radius))
.add(self.axis(j).scale(ang.sin() * radius))
}
fn arc_grab_point(&self, i: usize, j: usize, s: &Sizes) -> Vec3 {
self.arc_point(i, j, 0.5, s.arc_rad)
}
fn draw_axis_arrow(
&self,
ov: &mut Overlay,
s: &Sizes,
i: usize,
shaft_color: [f32; 4],
head_color: [f32; 4],
) {
let a = self.axis(i);
let shaft_a = self.origin.add(a.scale(s.shaft_start));
let base = self.origin.add(a.scale(s.axis_len - s.head_len));
let tip = self.origin.add(a.scale(s.axis_len));
push_tube(ov, shaft_a, base, s.shaft_rad, shaft_color);
push_cone(ov, base, tip, s.head_rad, head_color);
}
fn draw_arc(&self, ov: &mut Overlay, i: usize, j: usize, s: &Sizes, color: [f32; 4]) {
let mut prev = self.arc_point(i, j, 0.0, s.arc_rad);
for k in 1..=RING_SEGMENTS {
let t = k as f32 / RING_SEGMENTS as f32;
let cur = self.arc_point(i, j, t, s.arc_rad);
ov.line(prev, cur, color);
prev = cur;
}
}
fn draw_center(&self, ov: &mut Overlay, s: &Sizes, color: [f32; 4]) {
push_sphere(ov, self.origin, s.center_rad, color);
}
fn highlight(id: HandleId, base: [f32; 4], hovered: Option<HandleId>, active: Option<HandleId>) -> [f32; 4] {
if active == Some(id) || hovered == Some(id) {
C_GOLD
} else {
base
}
}
}
impl Gizmo for TransformGizmo {
fn geometry(
&self,
camera: &GizmoCamera,
hovered: Option<HandleId>,
active: Option<HandleId>,
) -> Overlay {
let s = self.sizes(camera);
let mut ov = Overlay::new();
let hl = |id, base| Self::highlight(id, base, hovered, active);
for (k, i, j) in ARCS {
let id = ring_handle(k);
self.draw_arc(&mut ov, i, j, &s, hl(id, C_RING));
push_sphere(&mut ov, self.arc_grab_point(i, j, &s), s.grab_rad, hl(id, C_DOT));
}
for (i, id) in [(0, HANDLE_AXIS_X), (1, HANDLE_AXIS_Y), (2, HANDLE_AXIS_Z)] {
self.draw_axis_arrow(&mut ov, &s, i, hl(id, C_ROD), hl(id, C_ARROW));
}
if self.show_center {
self.draw_center(&mut ov, &s, hl(HANDLE_CENTER, C_CENTER));
}
ov
}
fn hit(&self, camera: &GizmoCamera, screen: [f32; 2]) -> Option<HandleId> {
let s = self.sizes(camera);
let ray = camera.ray_from_screen(screen[0], screen[1]);
let line_thresh_px = 7.0_f32;
let mut best: Option<(u8, f32, HandleId)> = None;
let mut consider = |rank: u8, dist_px: f32, id: HandleId| {
match best {
Some((br, bd, _)) if (br, bd) <= (rank, dist_px) => {}
_ => best = Some((rank, dist_px, id)),
}
};
if self.show_center {
if let Some(os) = camera.world_to_screen(self.origin) {
let d = ((screen[0] - os[0]).powi(2) + (screen[1] - os[1]).powi(2)).sqrt();
if d <= PX_CENTER_RAD + 2.0 {
consider(0, d, HANDLE_CENTER);
}
}
}
for (i, id) in [(0, HANDLE_AXIS_X), (1, HANDLE_AXIS_Y), (2, HANDLE_AXIS_Z)] {
let (a, b) = self.axis_seg(camera, i);
let d_px = ray.distance_to_segment(a, b) / s.px;
if d_px <= line_thresh_px {
consider(0, d_px, id);
}
}
for (k, i, j) in ARCS {
let id = ring_handle(k);
let mut prev = self.arc_point(i, j, 0.0, s.arc_rad);
let mut min_w = f32::INFINITY;
for step in 1..=RING_SEGMENTS {
let t = step as f32 / RING_SEGMENTS as f32;
let cur = self.arc_point(i, j, t, s.arc_rad);
let d = ray.distance_to_segment(prev, cur);
if d < min_w {
min_w = d;
}
prev = cur;
}
let grab = self.arc_grab_point(i, j, &s);
let grab_px = camera
.world_to_screen(grab)
.map(|g| ((screen[0] - g[0]).powi(2) + (screen[1] - g[1]).powi(2)).sqrt())
.unwrap_or(f32::INFINITY);
let d_px = (min_w / s.px).min(grab_px.max(0.0) - PX_RING_GRAB_RAD).max(0.0);
if (min_w / s.px) <= line_thresh_px || grab_px <= PX_RING_GRAB_RAD + 3.0 {
consider(0, d_px, id);
}
}
best.map(|(_, _, id)| id)
}
}
fn plane_point(ray: &Ray, p0: Vec3, n: Vec3) -> Option<Vec3> {
ray.intersect_plane(p0, n).map(|t| ray.at(t))
}
fn set_comp(v: &mut Vec3, i: usize, val: f32) {
match i {
0 => v.x = val,
1 => v.y = val,
_ => v.z = val,
}
}
fn ring_handle(k: usize) -> HandleId {
match k {
0 => HANDLE_RING_X,
1 => HANDLE_RING_Y,
_ => HANDLE_RING_Z,
}
}
fn axis_basis(axis: Vec3) -> (Vec3, Vec3) {
let u = axis.any_perp();
let v = axis.cross(u).normalized();
(u, v)
}
fn push_tube(ov: &mut Overlay, a: Vec3, b: Vec3, radius: f32, color: [f32; 4]) {
let axis = b.sub(a);
if axis.length() < 1e-9 || radius <= 0.0 {
return;
}
let (u, v) = axis_basis(axis.normalized());
let ring = |center: Vec3, k: usize| -> Vec3 {
let ang = (k as f32 / TUBE_SEGMENTS as f32) * std::f32::consts::TAU;
center
.add(u.scale(ang.cos() * radius))
.add(v.scale(ang.sin() * radius))
};
for k in 0..TUBE_SEGMENTS {
let a0 = ring(a, k);
let a1 = ring(a, k + 1);
let b0 = ring(b, k);
let b1 = ring(b, k + 1);
ov.tri(a0, b0, b1, color);
ov.tri(a0, b1, a1, color);
}
}
fn push_cone(ov: &mut Overlay, base: Vec3, tip: Vec3, radius: f32, color: [f32; 4]) {
let axis = tip.sub(base);
if axis.length() < 1e-9 || radius <= 0.0 {
return;
}
let (u, v) = axis_basis(axis.normalized());
let ring = |k: usize| -> Vec3 {
let ang = (k as f32 / CONE_SEGMENTS as f32) * std::f32::consts::TAU;
base.add(u.scale(ang.cos() * radius))
.add(v.scale(ang.sin() * radius))
};
let mut prev = ring(0);
for k in 1..=CONE_SEGMENTS {
let cur = ring(k);
ov.tri(tip, prev, cur, color); ov.tri(base, cur, prev, color); prev = cur;
}
}
fn push_sphere(ov: &mut Overlay, center: Vec3, radius: f32, color: [f32; 4]) {
if radius <= 0.0 {
return;
}
let point = |ring: usize, sector: usize| -> Vec3 {
let lat = std::f32::consts::PI * (ring as f32 / SPHERE_RINGS as f32) - std::f32::consts::FRAC_PI_2;
let lon = std::f32::consts::TAU * (sector as f32 / SPHERE_SECTORS as f32);
center.add(Vec3::new(
lat.cos() * lon.cos() * radius,
lat.cos() * lon.sin() * radius,
lat.sin() * radius,
))
};
for r in 0..SPHERE_RINGS {
for sct in 0..SPHERE_SECTORS {
let p00 = point(r, sct);
let p01 = point(r, sct + 1);
let p10 = point(r + 1, sct);
let p11 = point(r + 1, sct + 1);
ov.tri(p00, p10, p11, color);
ov.tri(p00, p11, p01, color);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::raster;
fn oblique_cam(vp: f32) -> GizmoCamera {
let eye = [8.0, -10.0, 7.0];
let view_proj = raster::test_view_proj(eye, [0.0, 0.0, 0.0], vp, vp);
let fwd = Vec3::new(-eye[0], -eye[1], -eye[2]).normalized();
GizmoCamera {
view_proj,
eye: Vec3::from(eye),
forward: fwd,
up: Vec3::Z,
viewport: [vp, vp],
orthographic: true,
}
}
fn topdown_cam(vp: f32) -> GizmoCamera {
let eye = [0.0, 0.0, 10.0];
let view_proj = raster::test_view_proj(eye, [0.0, 0.0, 0.0], vp, vp);
GizmoCamera {
view_proj,
eye: Vec3::from(eye),
forward: Vec3::new(0.0, 0.0, -1.0),
up: Vec3::Y,
viewport: [vp, vp],
orthographic: true,
}
}
#[test]
fn hit_picks_the_axis_the_ray_passes_near() {
let cam = oblique_cam(240.0);
let gz = TransformGizmo::default();
let (a, b) = gz.axis_seg(&cam, 0);
let p = a.lerp(b, 0.82);
let s = cam.world_to_screen(p).unwrap();
assert_eq!(gz.hit(&cam, s), Some(HANDLE_AXIS_X), "screen {s:?}");
let (a, b) = gz.axis_seg(&cam, 1);
let p = a.lerp(b, 0.82);
let s = cam.world_to_screen(p).unwrap();
assert_eq!(gz.hit(&cam, s), Some(HANDLE_AXIS_Y));
}
#[test]
fn hit_is_none_in_empty_space() {
let cam = oblique_cam(240.0);
let gz = TransformGizmo::default();
assert_eq!(gz.hit(&cam, [6.0, 6.0]), None);
assert_eq!(gz.hit(&cam, [234.0, 234.0]), None);
}
#[test]
fn hit_picks_a_ring() {
let cam = oblique_cam(240.0);
let gz = TransformGizmo::default();
let s = gz.sizes(&cam);
let p = gz.arc_point(0, 1, 0.5, s.arc_rad);
let sp = cam.world_to_screen(p).unwrap();
assert_eq!(gz.hit(&cam, sp), Some(HANDLE_RING_Z));
}
#[test]
fn axis_translate_moves_positive_x_for_a_positive_x_drag() {
let cam = topdown_cam(200.0);
let gz = TransformGizmo::default();
let center = [100.0, 100.0];
let start = cam.ray_from_screen(center[0], center[1]);
let current = cam.ray_from_screen(center[0] + 40.0, center[1]);
let d = gz.axis_translate(&cam, 0, start, current);
assert!(d.x > 0.0, "expected +X translate, got {d:?}");
assert!(d.y.abs() < 1e-3 && d.z.abs() < 1e-3, "off-axis leak {d:?}");
let expect = 40.0 * cam.world_per_pixel(gz.origin);
assert!((d.x - expect).abs() < 0.05 * expect.max(1.0), "d.x={} expect={}", d.x, expect);
}
#[test]
fn plane_translate_reports_both_in_plane_components() {
let cam = topdown_cam(200.0);
let gz = TransformGizmo::default();
let center = [100.0, 100.0];
let start = cam.ray_from_screen(center[0], center[1]);
let current = cam.ray_from_screen(center[0] + 30.0, center[1] - 20.0);
let d = gz.plane_translate(0, 1, start, current);
assert!(d.x > 0.0 && d.y > 0.0, "expected +X,+Y in-plane move, got {d:?}");
assert!(d.z.abs() < 1e-3, "no normal-axis component expected {d:?}");
}
#[test]
fn ring_rotate_returns_expected_angle() {
let cam = topdown_cam(200.0);
let gz = TransformGizmo::default();
let center = [100.0, 100.0];
let start = cam.ray_from_screen(center[0] + 40.0, center[1]);
let current = cam.ray_from_screen(center[0], center[1] - 40.0);
let ang = gz.ring_rotate(2, start, current);
assert!(
(ang - std::f32::consts::FRAC_PI_2).abs() < 1e-2,
"expected +pi/2, got {ang}"
);
let ang2 = gz.ring_rotate(2, current, start);
assert!((ang2 + std::f32::consts::FRAC_PI_2).abs() < 1e-2, "got {ang2}");
}
#[test]
fn drag_delta_dispatches_by_handle() {
let cam = topdown_cam(200.0);
let gz = TransformGizmo::default();
let c = [100.0, 100.0];
let start = cam.ray_from_screen(c[0], c[1]);
let cur = cam.ray_from_screen(c[0] + 20.0, c[1]);
match gz.drag_delta(&cam, HANDLE_AXIS_X, start, cur) {
DragDelta::Translate(v) => assert!(v.x > 0.0),
other => panic!("expected translate, got {other:?}"),
}
let cur_rot = cam.ray_from_screen(c[0], c[1] - 20.0);
let start_rot = cam.ray_from_screen(c[0] + 20.0, c[1]);
match gz.drag_delta(&cam, HANDLE_RING_Z, start_rot, cur_rot) {
DragDelta::Rotate { axis_index, radians } => {
assert_eq!(axis_index, 2);
assert!(radians > 0.0);
}
other => panic!("expected rotate, got {other:?}"),
}
assert_eq!(gz.drag_delta(&cam, HANDLE_NONE, start, cur), DragDelta::None);
}
#[test]
fn geometry_emits_lines_and_tris() {
let cam = oblique_cam(240.0);
let gz = TransformGizmo::default();
let ov = gz.geometry(&cam, Some(HANDLE_AXIS_X), None);
assert!(!ov.lines.is_empty(), "expected line geometry (rotation arcs)");
assert!(!ov.tris.is_empty(), "expected triangle geometry (shafts/cones/spheres)");
let gold = ov
.tris
.iter()
.any(|v| (v.color[0] - C_GOLD[0]).abs() < 1e-3 && (v.color[1] - C_GOLD[1]).abs() < 1e-3);
assert!(gold, "hovered handle should be highlighted gold");
}
#[test]
fn handles_are_screen_constant_across_zoom() {
let cam_a = topdown_cam(240.0);
let cam_b = topdown_cam(480.0);
let gz = TransformGizmo::default();
let span = |cam: &GizmoCamera| {
let (_a, tip) = gz.axis_seg(cam, 0);
let o = cam.world_to_screen(gz.origin).unwrap();
let t = cam.world_to_screen(tip).unwrap();
((t[0] - o[0]).powi(2) + (t[1] - o[1]).powi(2)).sqrt()
};
let sa = span(&cam_a);
let sb = span(&cam_b);
assert!((sa - sb).abs() < 1.0, "screen span not constant: {sa} vs {sb}");
assert!((sa - PX_AXIS_LEN).abs() < 3.0, "arrow span {sa}px");
}
#[test]
fn cone_base_sits_outside_the_rotation_arc() {
let cam = topdown_cam(240.0);
let gz = TransformGizmo::default();
let s = gz.sizes(&cam);
let cone_base_dist = s.axis_len - s.head_len; assert!(
cone_base_dist > s.arc_rad + 4.0 * s.px,
"cone base ({cone_base_dist}) must clear the arc ({}) by >4px",
s.arc_rad
);
assert!(s.axis_len > cone_base_dist, "cone tip past its base");
}
#[test]
fn rotation_grab_sphere_sits_on_the_arc() {
let cam = oblique_cam(240.0);
let gz = TransformGizmo::default();
let s = gz.sizes(&cam);
for (_, i, j) in ARCS {
let grab = gz.arc_grab_point(i, j, &s);
let mid = gz.arc_point(i, j, 0.5, s.arc_rad);
assert!(grab.sub(mid).length() < 1e-5, "grab off the arc mid: {grab:?}");
let r = grab.sub(gz.origin).length();
assert!((r - s.arc_rad).abs() < 1e-4, "grab radius {r} != arc_rad {}", s.arc_rad);
}
let s = gz.sizes(&cam);
let grab = gz.arc_grab_point(0, 1, &s); let sp = cam.world_to_screen(grab).unwrap();
assert_eq!(gz.hit(&cam, sp), Some(HANDLE_RING_Z), "grab-sphere click picks its ring");
}
}