use crate::{Gizmo, GizmoCamera, HandleId, Overlay};
use crate::hit_region::{point_region, segment_region, HitShape};
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; pub const PX_CENTER_RAD: f32 = 7.0;
pub const PX_RING_GRAB_RAD: f32 = 5.0;
pub const AXIS_HIT_THRESH_PX: f32 = 7.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,
pub show_axes: bool,
pub show_rings: bool,
}
impl Default for TransformGizmo {
fn default() -> Self {
Self {
origin: Vec3::ZERO,
ex: Vec3::X,
ey: Vec3::Y,
ez: Vec3::Z,
show_center: true,
show_axes: true,
show_rings: 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 center_grab_point(&self) -> Option<Vec3> {
self.show_center.then_some(self.origin)
}
pub fn ring_grab_points(&self, camera: &GizmoCamera) -> [Vec3; 3] {
let s = self.sizes(camera);
let mut out = [Vec3::ZERO; 3];
for (idx, (_, i, j)) in ARCS.iter().enumerate() {
out[idx] = self.arc_grab_point(*i, *j, &s);
}
out
}
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);
if self.show_rings {
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));
}
}
if self.show_axes {
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 mut best: Option<(u8, f32, HandleId)> = None;
for (id, shape) in self.hit_regions(camera) {
let d = shape.spine_distance(screen);
if d > shape.radius() {
continue;
}
let rank = if is_ring(id) { 1 } else { 0 };
match best {
Some((br, bd, _)) if (br, bd) <= (rank, d) => {}
_ => best = Some((rank, d, id)),
}
}
best.map(|(_, _, id)| id)
}
}
impl TransformGizmo {
pub fn hit_regions(&self, camera: &GizmoCamera) -> Vec<(HandleId, HitShape)> {
let s = self.sizes(camera);
let mut out: Vec<(HandleId, HitShape)> = Vec::with_capacity(7);
if self.show_center {
if let Some(shape) = point_region(camera, v3(self.origin), PX_CENTER_RAD + 2.0) {
out.push((HANDLE_CENTER, shape));
}
}
if self.show_axes {
for (i, id) in [(0, HANDLE_AXIS_X), (1, HANDLE_AXIS_Y), (2, HANDLE_AXIS_Z)] {
let (a, b) = self.axis_seg(camera, i);
if let Some(shape) = segment_region(camera, v3(a), v3(b), AXIS_HIT_THRESH_PX) {
out.push((id, shape));
}
}
}
if self.show_rings {
for (k, i, j) in ARCS {
let grab = self.arc_grab_point(i, j, &s);
if let Some(shape) = point_region(camera, v3(grab), PX_RING_GRAB_RAD + 3.0) {
out.push((ring_handle(k), shape));
}
}
}
out
}
}
fn v3(v: Vec3) -> [f64; 3] {
[v.x as f64, v.y as f64, v.z as f64]
}
fn is_ring(id: HandleId) -> bool {
matches!(id, HANDLE_RING_X | HANDLE_RING_Y | HANDLE_RING_Z)
}
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 persp_cam(eye: [f32; 3], target: [f32; 3], fov_y_deg: f32, vp: f32) -> GizmoCamera {
let eye = Vec3::from(eye);
let target = Vec3::from(target);
let fwd = target.sub(eye).normalized();
let up_hint = if fwd.z.abs() > 0.9 { Vec3::Y } else { Vec3::Z };
let right = fwd.cross(up_hint).normalized();
let u = right.cross(fwd).normalized();
let view = [
[right.x, u.x, -fwd.x, 0.0],
[right.y, u.y, -fwd.y, 0.0],
[right.z, u.z, -fwd.z, 0.0],
[-right.dot(eye), -u.dot(eye), fwd.dot(eye), 1.0],
];
let f = 1.0 / (fov_y_deg.to_radians() * 0.5).tan();
let aspect = 1.0_f32;
let (near, far) = (0.05_f32, 100.0_f32);
let proj = [
[f / aspect, 0.0, 0.0, 0.0],
[0.0, f, 0.0, 0.0],
[0.0, 0.0, far / (near - far), -1.0],
[0.0, 0.0, (near * far) / (near - far), 0.0],
];
let view_proj = mat_mul_cols(&proj, &view);
GizmoCamera {
view_proj,
eye,
forward: fwd,
up: u,
viewport: [vp, vp],
orthographic: false,
}
}
fn mat_mul_cols(a: &[[f32; 4]; 4], b: &[[f32; 4]; 4]) -> [[f32; 4]; 4] {
let mut out = [[0.0f32; 4]; 4];
for col in 0..4 {
for row in 0..4 {
let mut sum = 0.0;
for k in 0..4 {
sum += a[k][row] * b[col][k];
}
out[col][row] = sum;
}
}
out
}
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");
}
#[test]
fn show_flags_gate_both_geometry_and_hit_regions() {
let cam = oblique_cam(240.0);
let translate_only = TransformGizmo {
show_rings: false,
..TransformGizmo::default()
};
let regions = translate_only.hit_regions(&cam);
assert!(
regions.iter().all(|(id, _)| !is_ring(*id)),
"no ring hit regions when rings are hidden"
);
assert_eq!(
regions.iter().filter(|(id, _)| matches!(*id, HANDLE_AXIS_X | HANDLE_AXIS_Y | HANDLE_AXIS_Z)).count(),
3,
"axis capsules stay"
);
let ov = translate_only.geometry(&cam, None, None);
assert!(ov.lines.is_empty(), "the arcs are the only line geometry — hidden rings draw none");
let s = translate_only.sizes(&cam);
let grab = translate_only.arc_grab_point(0, 1, &s);
let sp = cam.world_to_screen(grab).unwrap();
assert_ne!(translate_only.hit(&cam, sp), Some(HANDLE_RING_Z));
let rotate_only = TransformGizmo {
show_axes: false,
show_center: false,
..TransformGizmo::default()
};
let regions = rotate_only.hit_regions(&cam);
assert!(
regions.iter().all(|(id, _)| is_ring(*id)),
"only ring regions when axes + center are hidden: {:?}",
regions.iter().map(|(id, _)| *id).collect::<Vec<_>>()
);
let s = rotate_only.sizes(&cam);
let grab = rotate_only.arc_grab_point(0, 1, &s);
let sp = cam.world_to_screen(grab).unwrap();
assert_eq!(rotate_only.hit(&cam, sp), Some(HANDLE_RING_Z), "ring grab still picks");
}
fn probe_off_tip(cam: &GizmoCamera, gz: &TransformGizmo, i: usize, off_px: f32) -> [f32; 2] {
let (a, b) = gz.axis_seg(cam, i);
let sa = cam.world_to_screen(a).unwrap();
let sb = cam.world_to_screen(b).unwrap(); let dir = [sb[0] - sa[0], sb[1] - sa[1]];
let len = (dir[0] * dir[0] + dir[1] * dir[1]).sqrt().max(1e-6);
let perp = [-dir[1] / len, dir[0] / len];
[sb[0] + perp[0] * off_px, sb[1] + perp[1] * off_px]
}
#[test]
fn foreshortened_arrow_stays_grabbable_screen_space() {
let cam = persp_cam([-6.0, -2.0, 1.5], [0.0, 0.0, 0.0], 90.0, 100.0);
let gz = TransformGizmo::default();
let probe = probe_off_tip(&cam, &gz, 0, 5.0);
assert_eq!(gz.hit(&cam, probe), Some(HANDLE_AXIS_X), "probe {probe:?}");
let (a, b) = gz.axis_seg(&cam, 0);
let ray = cam.ray_from_screen(probe[0], probe[1]);
let old_px = ray.distance_to_segment(a, b) / cam.world_per_pixel(gz.origin);
assert!(
old_px > 7.0,
"old metric {old_px}px should exceed the 7px threshold (pins the bug)"
);
}
#[test]
fn arrow_grabbable_at_extreme_zoom() {
let cam = persp_cam([-6.0, -2.0, 1.5], [0.0, 0.0, 0.0], 90.0, 4000.0);
let gz = TransformGizmo::default();
let probe = probe_off_tip(&cam, &gz, 0, 4.0);
assert_eq!(gz.hit(&cam, probe), Some(HANDLE_AXIS_X), "probe {probe:?}");
}
fn probe_perp(
cam: &GizmoCamera,
pa_world: Vec3,
pm_world: Vec3,
off_px: f32,
) -> [f32; 2] {
let pa = cam.world_to_screen(pa_world).unwrap();
let pm = cam.world_to_screen(pm_world).unwrap();
let dir = [pm[0] - pa[0], pm[1] - pa[1]];
let len = (dir[0] * dir[0] + dir[1] * dir[1]).sqrt().max(1e-6);
let perp = [-dir[1] / len, dir[0] / len];
[pm[0] + perp[0] * off_px, pm[1] + perp[1] * off_px]
}
#[test]
fn arrow_with_far_end_behind_the_eye_is_grabbable_on_its_visible_shaft() {
let cam = persp_cam([0.0, 2.0, 0.3], [0.0, 0.0, 0.0], 90.0, 100.0);
let gz = TransformGizmo::default();
let (a, b) = gz.axis_seg(&cam, 1);
assert!(cam.view_depth(b) < 0.0, "tip must be behind the eye: {}", cam.view_depth(b));
assert!(cam.view_depth(a) > 0.0, "shaft-start must be in front: {}", cam.view_depth(a));
let visible = a.lerp(b, 0.3); assert!(cam.view_depth(visible) > 0.0, "probe point must be visible");
let probe = probe_perp(&cam, a, visible, 3.0);
assert_eq!(gz.hit(&cam, probe), Some(HANDLE_AXIS_Y), "probe {probe:?}");
let (xa, xb) = gz.axis_seg(&cam, 0);
assert!(cam.view_depth(xa) > 0.0 && cam.view_depth(xb) > 0.0, "+X fully in front");
let xprobe = probe_perp(&cam, xa, xa.lerp(xb, 0.6), 3.0);
assert_eq!(gz.hit(&cam, xprobe), Some(HANDLE_AXIS_X), "xprobe {xprobe:?}");
}
#[test]
fn handle_entirely_behind_the_eye_is_not_grabbable() {
let cam = persp_cam([0.0, -0.5, 0.0], [0.0, -1.0, 0.0], 90.0, 100.0);
let gz = TransformGizmo::default();
assert!(cam.view_depth(gz.origin) < 0.0, "gizmo behind the eye");
assert_eq!(gz.hit(&cam, [50.0, 50.0]), None);
}
fn edge_on_y_cam(vp: f32) -> GizmoCamera {
let eye = [0.0, 20.0, 0.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, -1.0, 0.0),
up: Vec3::Z,
viewport: [vp, vp],
orthographic: true,
}
}
#[test]
fn axis_arrow_beats_ring_when_both_regions_contain_the_cursor() {
let cam = edge_on_y_cam(240.0);
let gz = TransformGizmo::default();
let s = gz.sizes(&cam);
let grab = gz.arc_grab_point(0, 1, &s); let sp = cam.world_to_screen(grab).unwrap();
let regions = gz.hit_regions(&cam);
let x_axis = regions.iter().find(|(id, _)| *id == HANDLE_AXIS_X).unwrap().1;
let ring_z = regions.iter().find(|(id, _)| *id == HANDLE_RING_Z).unwrap().1;
assert!(x_axis.contains(sp), "the +X axis region must contain the cursor");
assert!(ring_z.contains(sp), "the Z ring region must contain the cursor");
assert_eq!(
gz.hit(&cam, sp),
Some(HANDLE_AXIS_X),
"arrow must beat the ring at {sp:?}"
);
}
#[test]
fn draw_equals_hit_regions_pick_their_own_handle() {
for cam in [oblique_cam(240.0), edge_on_y_cam(240.0), topdown_cam(240.0)] {
let gz = TransformGizmo::default();
let regions = gz.hit_regions(&cam);
for (id, shape) in ®ions {
let probe = match shape {
HitShape::Circle { c, .. } => *c,
HitShape::Capsule { a, b, .. } => [(a[0] + b[0]) * 0.5, (a[1] + b[1]) * 0.5],
};
let picked = gz.hit(&cam, probe).expect("a handle under its own region");
let picked_shape = regions.iter().find(|(pid, _)| *pid == picked).unwrap().1;
assert!(
picked_shape.contains(probe),
"picked handle {picked}'s region must contain the cursor {probe:?}"
);
let _ = id;
}
if let Some((_, HitShape::Capsule { a, b, r })) =
regions.iter().find(|(id, _)| *id == HANDLE_AXIS_X)
{
let seg = [b[0] - a[0], b[1] - a[1]];
let len = (seg[0] * seg[0] + seg[1] * seg[1]).sqrt().max(1e-3);
let past = [b[0] + seg[0] / len * (r + 4.0), b[1] + seg[1] / len * (r + 4.0)];
assert_ne!(
gz.hit(&cam, past),
Some(HANDLE_AXIS_X),
"a pixel past the +X tip cap is outside its region"
);
}
}
}
}