use crate::camera::camera::Camera;
use crate::interaction::input::{Action, ActionFrame};
use crate::interaction::snap::snap_value;
use crate::renderer::{ClipObject, ClipShape};
pub fn project_drag_onto_normal(
pointer_delta: glam::Vec2,
plane_normal: glam::Vec3,
plane_point: glam::Vec3,
camera: &Camera,
viewport_size: glam::Vec2,
) -> f32 {
let view_proj = camera.proj_matrix() * camera.view_matrix();
let base_ndc = view_proj.project_point3(plane_point);
let tip_ndc = view_proj.project_point3(plane_point + plane_normal);
let base_screen = glam::Vec2::new(
(base_ndc.x + 1.0) * 0.5 * viewport_size.x,
(1.0 - base_ndc.y) * 0.5 * viewport_size.y,
);
let tip_screen = glam::Vec2::new(
(tip_ndc.x + 1.0) * 0.5 * viewport_size.x,
(1.0 - tip_ndc.y) * 0.5 * viewport_size.y,
);
let axis_screen = tip_screen - base_screen;
let axis_screen_len = axis_screen.length();
if axis_screen_len < 1e-4 {
return 0.0;
}
let axis_screen_norm = axis_screen / axis_screen_len;
let drag_along_axis = pointer_delta.dot(axis_screen_norm);
drag_along_axis / axis_screen_len
}
pub fn snap_plane_distance(distance: f32, increment: f32) -> f32 {
snap_value(distance, increment)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClipAxis {
X,
Y,
Z,
}
pub fn plane_from_axis_preset(axis: ClipAxis, distance: f32) -> ClipObject {
let normal = match axis {
ClipAxis::X => [1.0, 0.0, 0.0],
ClipAxis::Y => [0.0, 1.0, 0.0],
ClipAxis::Z => [0.0, 0.0, 1.0],
};
ClipObject {
shape: ClipShape::Plane {
normal,
distance,
cap_colour: None,
display_center: None,
},
..ClipObject::default()
}
}
pub fn ray_plane_intersection(
ray_origin: glam::Vec3,
ray_dir: glam::Vec3,
plane_normal: glam::Vec3,
plane_distance: f32,
) -> Option<glam::Vec3> {
let denom = plane_normal.dot(ray_dir);
if denom.abs() < 1e-6 {
return None;
}
let t = -(plane_normal.dot(ray_origin) + plane_distance) / denom;
if t < 0.0 {
return None;
}
Some(ray_origin + ray_dir * t)
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub(crate) struct ClipPlaneOverlay {
pub center: glam::Vec3,
pub normal: glam::Vec3,
pub extent: f32,
pub fill_colour: [f32; 4],
pub border_colour: [f32; 4],
pub _hovered: bool,
pub _active: bool,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ClipPlaneHit {
None,
Quad {
t: f32,
},
NormalHandle {
t: f32,
},
}
pub fn hit_test_plane_quad(
ray_origin: glam::Vec3,
ray_dir: glam::Vec3,
plane_center: glam::Vec3,
plane_normal: glam::Vec3,
extent: f32,
) -> ClipPlaneHit {
let denom = plane_normal.dot(ray_dir);
if denom.abs() < 1e-6 {
return ClipPlaneHit::None;
}
let t = plane_normal.dot(plane_center - ray_origin) / denom;
if t < 0.0 {
return ClipPlaneHit::None;
}
let hit_point = ray_origin + ray_dir * t;
let local = hit_point - plane_center;
let (t1, t2) = plane_tangents(plane_normal);
let u = local.dot(t1);
let v = local.dot(t2);
if u.abs() <= extent && v.abs() <= extent {
ClipPlaneHit::Quad { t }
} else {
ClipPlaneHit::None
}
}
pub fn hit_test_normal_handle(
ray_origin: glam::Vec3,
ray_dir: glam::Vec3,
plane_center: glam::Vec3,
plane_normal: glam::Vec3,
handle_length: f32,
handle_radius: f32,
) -> ClipPlaneHit {
let d = ray_dir - plane_normal * ray_dir.dot(plane_normal);
let offset = ray_origin - plane_center;
let e = offset - plane_normal * offset.dot(plane_normal);
let a = d.dot(d);
let b = 2.0 * d.dot(e);
let c = e.dot(e) - handle_radius * handle_radius;
if a < 1e-10 {
return ClipPlaneHit::None; }
let discriminant = b * b - 4.0 * a * c;
if discriminant < 0.0 {
return ClipPlaneHit::None;
}
let sqrt_disc = discriminant.sqrt();
let t0 = (-b - sqrt_disc) / (2.0 * a);
let t1 = (-b + sqrt_disc) / (2.0 * a);
let t = if t0 > 0.0 {
t0
} else if t1 > 0.0 {
t1
} else {
return ClipPlaneHit::None;
};
let hit_point = ray_origin + ray_dir * t;
let h = (hit_point - plane_center).dot(plane_normal);
if h >= 0.0 && h <= handle_length {
ClipPlaneHit::NormalHandle { t }
} else {
ClipPlaneHit::None
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClipPlaneSessionKind {
Distance,
Orient,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ClipPlaneResult {
None,
Update(ClipPlaneDelta),
Commit,
Cancel,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ClipPlaneDelta {
pub distance_delta: f32,
pub normal_override: Option<[f32; 3]>,
}
#[derive(Clone)]
pub struct ClipPlaneContext {
pub plane: ClipObject,
pub camera: Camera,
pub viewport_size: glam::Vec2,
pub cursor_viewport: Option<glam::Vec2>,
pub pointer_delta: glam::Vec2,
pub drag_started: bool,
pub dragging: bool,
pub clicked: bool,
pub plane_extent: f32,
}
struct ClipPlaneSession {
kind: ClipPlaneSessionKind,
original_normal: [f32; 3],
original_distance: f32,
cursor_anchor: Option<glam::Vec2>,
cursor_last_total: glam::Vec2,
}
pub struct ClipPlaneController {
session: Option<ClipPlaneSession>,
hovered: bool,
}
impl ClipPlaneController {
pub fn new() -> Self {
Self {
session: None,
hovered: false,
}
}
pub fn update(&mut self, frame: &ActionFrame, ctx: ClipPlaneContext) -> ClipPlaneResult {
if let Some(ref mut session) = self.session {
if frame.is_active(Action::Cancel) {
self.session = None;
self.hovered = false;
return ClipPlaneResult::Cancel;
}
if !ctx.dragging {
self.session = None;
return ClipPlaneResult::Commit;
}
let pointer_delta = if let (Some(current), Some(anchor)) =
(ctx.cursor_viewport, session.cursor_anchor)
{
let total = current - anchor;
let increment = total - session.cursor_last_total;
session.cursor_last_total = total;
increment
} else {
ctx.pointer_delta
};
let delta = match session.kind {
ClipPlaneSessionKind::Distance => {
let normal = glam::Vec3::from(session.original_normal);
let plane_point = normal * session.original_distance;
let distance_delta = project_drag_onto_normal(
pointer_delta,
normal,
plane_point,
&ctx.camera,
ctx.viewport_size,
);
ClipPlaneDelta {
distance_delta,
normal_override: None,
}
}
ClipPlaneSessionKind::Orient => {
ClipPlaneDelta {
distance_delta: 0.0,
normal_override: None,
}
}
};
return ClipPlaneResult::Update(delta);
}
self.hovered = false;
if let Some(cursor) = ctx.cursor_viewport {
let view_proj = ctx.camera.proj_matrix() * ctx.camera.view_matrix();
let ray_origin = ctx.camera.eye_position();
let ray_dir =
unproject_cursor_to_ray(cursor, &ctx.camera, view_proj, ctx.viewport_size);
let (plane_normal_arr, plane_dist) = if let ClipShape::Plane {
normal, distance, ..
} = ctx.plane.shape
{
(normal, distance)
} else {
return ClipPlaneResult::None;
};
let plane_normal = glam::Vec3::from(plane_normal_arr);
let plane_center = plane_normal * plane_dist;
let handle_length = ctx.plane_extent * 0.4;
let handle_radius = ctx.plane_extent * 0.04;
let hit = {
let nh = hit_test_normal_handle(
ray_origin,
ray_dir,
plane_center,
plane_normal,
handle_length,
handle_radius,
);
if nh != ClipPlaneHit::None {
nh
} else {
hit_test_plane_quad(
ray_origin,
ray_dir,
plane_center,
plane_normal,
ctx.plane_extent,
)
}
};
let is_hit = hit != ClipPlaneHit::None;
self.hovered = is_hit;
if ctx.drag_started && is_hit {
let kind = match hit {
ClipPlaneHit::NormalHandle { .. } => ClipPlaneSessionKind::Orient,
_ => ClipPlaneSessionKind::Distance,
};
self.session = Some(ClipPlaneSession {
kind,
original_normal: plane_normal_arr,
original_distance: plane_dist,
cursor_anchor: ctx.cursor_viewport,
cursor_last_total: glam::Vec2::ZERO,
});
return ClipPlaneResult::None;
}
}
ClipPlaneResult::None
}
pub fn is_active(&self) -> bool {
self.session.is_some()
}
#[allow(dead_code)]
pub(crate) fn overlay(&self, ctx: &ClipPlaneContext) -> Option<ClipPlaneOverlay> {
if !ctx.plane.enabled {
return None;
}
let (normal_arr, distance) = if let ClipShape::Plane {
normal, distance, ..
} = ctx.plane.shape
{
(normal, distance)
} else {
return None;
};
let normal = glam::Vec3::from(normal_arr);
let center = normal * distance;
let active = self.is_active();
let hovered = self.hovered || active;
let fill_colour = if active {
[0.2, 0.6, 1.0, 0.25]
} else if hovered {
[0.4, 0.7, 1.0, 0.18]
} else {
[0.3, 0.6, 0.9, 0.12]
};
let border_colour = if active {
[0.2, 0.7, 1.0, 0.9]
} else if hovered {
[0.5, 0.8, 1.0, 0.8]
} else {
[0.4, 0.65, 0.9, 0.6]
};
Some(ClipPlaneOverlay {
center,
normal,
extent: ctx.plane_extent,
fill_colour,
border_colour,
_hovered: hovered,
_active: active,
})
}
pub fn is_hovered(&self) -> bool {
self.hovered
}
pub fn begin_distance(&mut self, obj: &ClipObject) {
if self.session.is_some() {
return;
}
if let ClipShape::Plane {
normal, distance, ..
} = obj.shape
{
self.session = Some(ClipPlaneSession {
kind: ClipPlaneSessionKind::Distance,
original_normal: normal,
original_distance: distance,
cursor_anchor: None,
cursor_last_total: glam::Vec2::ZERO,
});
}
}
pub fn reset(&mut self) {
self.session = None;
}
}
impl Default for ClipPlaneController {
fn default() -> Self {
Self::new()
}
}
pub(crate) fn plane_tangents(normal: glam::Vec3) -> (glam::Vec3, glam::Vec3) {
let up = if normal.z.abs() < 0.9 {
glam::Vec3::Z
} else {
glam::Vec3::X
};
let t1 = (up - normal * up.dot(normal)).normalize_or(glam::Vec3::X);
let t2 = normal.cross(t1);
(t1, t2)
}
fn unproject_cursor_to_ray(
cursor_viewport: glam::Vec2,
camera: &Camera,
view_proj: glam::Mat4,
viewport_size: glam::Vec2,
) -> glam::Vec3 {
let ndc_x = (cursor_viewport.x / viewport_size.x.max(1.0)) * 2.0 - 1.0;
let ndc_y = 1.0 - (cursor_viewport.y / viewport_size.y.max(1.0)) * 2.0;
let inv_vp = view_proj.inverse();
let far_world = inv_vp.project_point3(glam::Vec3::new(ndc_x, ndc_y, 1.0));
let eye = camera.eye_position();
(far_world - eye).normalize_or(glam::Vec3::NEG_Z)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::interaction::input::{ActionFrame, ResolvedActionState};
fn default_camera() -> Camera {
Camera::default()
}
fn default_plane() -> ClipObject {
ClipObject {
shape: ClipShape::Plane {
normal: [0.0, 1.0, 0.0],
distance: 0.0,
cap_colour: None,
display_center: None,
},
enabled: true,
..ClipObject::default()
}
}
fn idle_ctx() -> ClipPlaneContext {
ClipPlaneContext {
plane: default_plane(),
camera: default_camera(),
viewport_size: glam::Vec2::new(800.0, 600.0),
cursor_viewport: None,
pointer_delta: glam::Vec2::ZERO,
drag_started: false,
dragging: false,
clicked: false,
plane_extent: 2.0,
}
}
#[test]
fn snap_plane_distance_rounds_to_increment() {
assert!((snap_plane_distance(0.7, 0.5) - 0.5).abs() < 1e-6);
assert!((snap_plane_distance(0.8, 0.5) - 1.0).abs() < 1e-6);
assert!((snap_plane_distance(-0.3, 0.5) - -0.5).abs() < 1e-6);
}
#[test]
fn snap_plane_distance_zero_increment_passthrough() {
assert!((snap_plane_distance(1.23, 0.0) - 1.23).abs() < 1e-6);
}
#[test]
fn plane_from_axis_preset_x() {
let p = plane_from_axis_preset(ClipAxis::X, 3.0);
if let ClipShape::Plane {
normal, distance, ..
} = p.shape
{
assert_eq!(normal, [1.0, 0.0, 0.0]);
assert!((distance - 3.0).abs() < 1e-6);
} else {
panic!("expected Plane shape");
}
assert!(p.enabled);
}
#[test]
fn plane_from_axis_preset_y() {
let p = plane_from_axis_preset(ClipAxis::Y, -2.0);
if let ClipShape::Plane {
normal, distance, ..
} = p.shape
{
assert_eq!(normal, [0.0, 1.0, 0.0]);
assert!((distance - (-2.0)).abs() < 1e-6);
} else {
panic!("expected Plane shape");
}
}
#[test]
fn plane_from_axis_preset_z() {
let p = plane_from_axis_preset(ClipAxis::Z, 0.5);
if let ClipShape::Plane { normal, .. } = p.shape {
assert_eq!(normal, [0.0, 0.0, 1.0]);
} else {
panic!("expected Plane shape");
}
}
#[test]
fn ray_plane_intersection_hits() {
let origin = glam::Vec3::new(0.0, 5.0, 0.0);
let dir = glam::Vec3::new(0.0, -1.0, 0.0);
let hit = ray_plane_intersection(origin, dir, glam::Vec3::Y, -2.0);
assert!(hit.is_some());
let p = hit.unwrap();
assert!((p.y - 2.0).abs() < 1e-5);
}
#[test]
fn ray_plane_intersection_parallel_returns_none() {
let origin = glam::Vec3::new(0.0, 1.0, 0.0);
let dir = glam::Vec3::new(1.0, 0.0, 0.0);
assert!(ray_plane_intersection(origin, dir, glam::Vec3::Y, 0.0).is_none());
}
#[test]
fn ray_plane_intersection_behind_returns_none() {
let origin = glam::Vec3::new(0.0, 5.0, 0.0);
let dir = glam::Vec3::new(0.0, 1.0, 0.0);
assert!(ray_plane_intersection(origin, dir, glam::Vec3::Y, 0.0).is_none());
}
#[test]
fn hit_test_quad_center_hit() {
let result = hit_test_plane_quad(
glam::Vec3::new(0.0, 0.0, 5.0),
glam::Vec3::new(0.0, 0.0, -1.0),
glam::Vec3::ZERO,
glam::Vec3::Z,
1.0,
);
assert!(matches!(result, ClipPlaneHit::Quad { t } if (t - 5.0).abs() < 1e-5));
}
#[test]
fn hit_test_quad_outside_returns_none() {
let result = hit_test_plane_quad(
glam::Vec3::new(2.0, 0.0, 5.0), glam::Vec3::new(0.0, 0.0, -1.0),
glam::Vec3::ZERO,
glam::Vec3::Z,
1.0,
);
assert_eq!(result, ClipPlaneHit::None);
}
#[test]
fn hit_test_quad_parallel_returns_none() {
let result = hit_test_plane_quad(
glam::Vec3::ZERO,
glam::Vec3::new(1.0, 0.0, 0.0),
glam::Vec3::ZERO,
glam::Vec3::Z,
1.0,
);
assert_eq!(result, ClipPlaneHit::None);
}
#[test]
fn plane_tangents_y_normal_produces_orthogonal_vectors() {
let n = glam::Vec3::Y;
let (t1, t2) = plane_tangents(n);
assert!(t1.dot(n).abs() < 1e-5, "t1 not perpendicular to normal");
assert!(t2.dot(n).abs() < 1e-5, "t2 not perpendicular to normal");
assert!(
t1.dot(t2).abs() < 1e-5,
"t1 and t2 not perpendicular to each other"
);
assert!((t1.length() - 1.0).abs() < 1e-5, "t1 not unit length");
assert!((t2.length() - 1.0).abs() < 1e-5, "t2 not unit length");
}
#[test]
fn plane_tangents_x_normal_produces_orthogonal_vectors() {
let n = glam::Vec3::X;
let (t1, t2) = plane_tangents(n);
assert!(t1.dot(n).abs() < 1e-5);
assert!(t2.dot(n).abs() < 1e-5);
assert!(t1.dot(t2).abs() < 1e-5);
}
#[test]
fn controller_new_is_idle() {
assert!(!ClipPlaneController::new().is_active());
}
#[test]
fn controller_begin_distance_activates() {
let mut ctrl = ClipPlaneController::new();
ctrl.begin_distance(&default_plane());
assert!(ctrl.is_active());
}
#[test]
fn controller_reset_clears_session() {
let mut ctrl = ClipPlaneController::new();
ctrl.begin_distance(&default_plane());
ctrl.reset();
assert!(!ctrl.is_active());
}
#[test]
fn controller_begin_distance_no_op_when_active() {
let mut ctrl = ClipPlaneController::new();
ctrl.begin_distance(&default_plane());
let other = ClipObject {
shape: ClipShape::Plane {
normal: [1.0, 0.0, 0.0],
distance: 5.0,
cap_colour: None,
display_center: None,
},
enabled: true,
..ClipObject::default()
};
ctrl.begin_distance(&other);
assert!(ctrl.is_active());
}
#[test]
fn controller_idle_returns_none() {
let mut ctrl = ClipPlaneController::new();
let result = ctrl.update(&ActionFrame::default(), idle_ctx());
assert_eq!(result, ClipPlaneResult::None);
assert!(!ctrl.is_active());
}
#[test]
fn controller_escape_cancels_active_session() {
let mut ctrl = ClipPlaneController::new();
ctrl.begin_distance(&default_plane());
let mut frame = ActionFrame::default();
frame
.actions
.insert(Action::Cancel, ResolvedActionState::Pressed);
let mut ctx = idle_ctx();
ctx.dragging = true;
let result = ctrl.update(&frame, ctx);
assert_eq!(result, ClipPlaneResult::Cancel);
assert!(!ctrl.is_active());
}
#[test]
fn controller_drag_release_commits() {
let mut ctrl = ClipPlaneController::new();
ctrl.begin_distance(&default_plane());
let result = ctrl.update(&ActionFrame::default(), idle_ctx());
assert_eq!(result, ClipPlaneResult::Commit);
assert!(!ctrl.is_active());
}
#[test]
fn controller_overlay_none_when_disabled() {
let ctrl = ClipPlaneController::new();
let mut ctx = idle_ctx();
ctx.plane.enabled = false;
assert!(ctrl.overlay(&ctx).is_none());
}
#[test]
fn controller_overlay_some_when_enabled() {
let ctrl = ClipPlaneController::new();
assert!(ctrl.overlay(&idle_ctx()).is_some());
}
}