Files
OneCAD/crates/ui-egui/src/gizmo.rs
T
bherbruck 6f16b31771 Fix viewport input leaking through dialogs
Use egui pointer ownership for viewport navigation and picking, view-cube
interaction, and sketch-dimension hover. Cover scrolling, model selection,
and cube interaction under floating dialogs, including after they close.
Shield constraint glyph and move-gizmo highlights from overlapping UI too.

Clear strict Clippy warnings and use the same saved path in the recent-file
scenario on Windows and Unix.

Fixes #87
2026-10-10 17:34:14 -04:00

453 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The move/rotate triad shared by Move/Copy and occurrence moves: X/Y/Z arrows, XY/YZ/XZ plane
//! squares, X/Y/Z rotation rings and a centre ball for a free drag in the view plane. It is a
//! constant size on screen. A drag reports its change since the drag began (distances snap to
//! round steps at the current zoom, angles to 5°; hold Alt or Ctrl for a free drag), so the caller
//! adds it to the values it had when the drag started.
use std::cell::RefCell;
use egui::{Color32, Pos2, Rect, Sense, Shape, Stroke, vec2};
use solvecraft_engine::geom::Vec3;
use crate::theme::Tokens;
use crate::viewport::{Proj, projected_polygon};
/// A part of the triad.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Handle {
/// Arrow along X, Y or Z (0, 1, 2).
Axis(usize),
/// Square in the plane across that axis (Plane(2) is XY).
Plane(usize),
/// Ring turning about that axis.
Ring(usize),
/// Centre ball: free drag in the view plane.
Ball,
}
/// What a drag has done so far.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Change {
Translate(Vec3),
/// About X, Y or Z through the centre, in radians.
Rotate {
axis: usize,
angle: f64,
},
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Drag {
pub handle: Handle,
pub change: Change,
/// The first frame of the drag (take a snapshot of the values now).
pub started: bool,
/// The drag ended this frame.
pub done: bool,
}
/// Where the triad is and which handles it shows.
#[derive(Clone, Copy, Debug)]
pub struct Triad {
pub center: Vec3,
pub translate: bool,
pub rotate: bool,
}
#[derive(Clone, Copy, Debug)]
struct State {
handle: Handle,
center: Vec3,
/// Grab point on the plane (planes, ball) or parameter along the axis.
grab: Vec3,
t0: f64,
/// Ring: last angle seen and the angle turned so far.
last: f64,
turned: f64,
}
const DIRS: [Vec3; 3] = [Vec3::X, Vec3::Y, Vec3::Z];
/// Arrow length on screen (px).
const LEN_PX: f64 = 110.0;
thread_local! {
/// The last triad drawn: centre and arrow length in mm (for scenarios and tests).
static LAST: RefCell<Option<(Vec3, f64)>> = const { RefCell::new(None) };
}
/// A world point on a handle of the last triad drawn: "x"/"y"/"z" on an arrow, "xy"/"yz"/"xz"
/// in a square, "rx"/"ry"/"rz" on a ring, "ball". `by` moves it, `turn` (degrees) turns a ring
/// point about its axis.
pub fn handle_point(name: &str, by: Vec3, turn: f64) -> Option<Vec3> {
let (c, l) = LAST.with(|x| *x.borrow())?;
let axis = |s: &str| match s {
"x" => Some(0),
"y" => Some(1),
"z" => Some(2),
_ => None,
};
let p = match name {
"ball" => c,
"xy" => c + (Vec3::X + Vec3::Y) * (0.35 * l),
"yz" => c + (Vec3::Y + Vec3::Z) * (0.35 * l),
"xz" => c + (Vec3::X + Vec3::Z) * (0.35 * l),
r if r.starts_with('r') => {
let i = axis(&r[1..])?;
let (e1, e2) = ring_basis(i);
let th = turn.to_radians();
return Some(c + (e1 * th.cos() + e2 * th.sin()) * (RING * l) + by);
}
a => c + DIRS[axis(a)?] * (0.75 * l),
};
Some(p + by)
}
/// Ring radius as a share of the arrow length.
const RING: f64 = 0.62;
/// In-plane directions of the ring about an axis (right-handed: e1 × e2 = axis).
fn ring_basis(i: usize) -> (Vec3, Vec3) {
match i {
0 => (Vec3::Y, Vec3::Z),
1 => (Vec3::Z, Vec3::X),
_ => (Vec3::X, Vec3::Y),
}
}
fn axis_color(i: usize) -> Color32 {
match i {
0 => Color32::from_rgb(225, 70, 60),
1 => Color32::from_rgb(80, 190, 80),
_ => Color32::from_rgb(70, 130, 240),
}
}
/// Distance from `p` to the screen segment a–b.
fn seg_dist(p: Pos2, a: Pos2, b: Pos2) -> f32 {
let ab = b - a;
let t = ((p - a).dot(ab) / ab.length_sq().max(1e-6)).clamp(0.0, 1.0);
(a + ab * t).distance(p)
}
fn in_quad(p: Pos2, q: &[Pos2]) -> bool {
if q.len() < 3 {
return false;
}
let mut sign = 0.0f32;
for i in 0..q.len() {
let (a, b) = (q[i], q[(i + 1) % q.len()]);
let c = (b - a).x * (p - a).y - (b - a).y * (p - a).x;
if c.abs() < 1e-6 {
continue;
}
if sign == 0.0 {
sign = c.signum();
} else if c.signum() != sign {
return false;
}
}
true
}
/// Where the view ray through `pos` meets the plane through `o` with normal `n`.
fn on_plane(proj: &Proj, pos: Pos2, o: Vec3, n: Vec3) -> Option<Vec3> {
let (ro, rd) = proj.ray(pos);
let den = rd.dot(n);
if den.abs() < 1e-9 {
return None;
}
Some(ro + rd * ((o - ro).dot(n) / den))
}
/// Parameter along the line `o + d t` closest to the view ray through `pos`.
fn along(proj: &Proj, pos: Pos2, o: Vec3, d: Vec3) -> Option<f64> {
let (ro, r) = proj.ray(pos);
let w0 = o - ro;
let (b, dd, e) = (d.dot(r), d.dot(w0), r.dot(w0));
let den = 1.0 - b * b;
if den.abs() < 1e-6 {
return None;
}
Some((b * e - dd) / den)
}
/// The triad's shapes: (handle, screen polygon or polyline, is it a filled area).
struct Shapes {
arrows: Vec<(usize, Pos2, Pos2)>,
planes: Vec<(usize, Vec<Pos2>)>,
rings: Vec<(usize, Vec<Pos2>)>,
ball: Pos2,
}
fn shapes(proj: &Proj, t: &Triad, l: f64) -> Option<Shapes> {
let c = t.center;
let ball = proj.to_screen(c)?;
let mut s = Shapes { arrows: Vec::new(), planes: Vec::new(), rings: Vec::new(), ball };
if t.translate {
for (i, d) in DIRS.iter().enumerate() {
if let (Some(a), Some(b)) = (proj.to_screen(c + *d * (0.18 * l)), proj.to_screen(c + *d * l)) {
s.arrows.push((i, a, b));
}
}
for i in 0..3 {
let (e1, e2) = ring_basis(i);
let q: Vec<Pos2> = [(0.25, 0.25), (0.45, 0.25), (0.45, 0.45), (0.25, 0.45)]
.iter()
.filter_map(|(a, b)| proj.to_screen(c + e1 * (a * l) + e2 * (b * l)))
.collect();
if q.len() == 4 {
s.planes.push((i, q));
}
}
}
if t.rotate {
for i in 0..3 {
let (e1, e2) = ring_basis(i);
let pts: Vec<Pos2> = (0..=64)
.map(|k| k as f64 / 64.0 * std::f64::consts::TAU)
.filter_map(|th| proj.to_screen(c + (e1 * th.cos() + e2 * th.sin()) * (RING * l)))
.collect();
if pts.len() > 2 {
s.rings.push((i, pts));
}
}
}
Some(s)
}
fn hit(s: &Shapes, p: Pos2) -> Option<Handle> {
if s.ball.distance(p) < 9.0 {
return Some(Handle::Ball);
}
if let Some((i, ..)) = s.arrows.iter().find(|(_, a, b)| seg_dist(p, *a, *b) < 6.0) {
return Some(Handle::Axis(*i));
}
if let Some((i, _)) = s.planes.iter().find(|(_, q)| in_quad(p, q)) {
return Some(Handle::Plane(*i));
}
s.rings.iter().find(|(_, pts)| pts.windows(2).any(|w| seg_dist(p, w[0], w[1]) < 5.0)).map(|(i, _)| Handle::Ring(*i))
}
/// Millimetres per screen pixel at a point.
fn mm_per_px(proj: &Proj, c: Vec3) -> Option<f64> {
let (_, view) = proj.ray(proj.to_screen(c)?);
let u = view.any_perp().normalized()?;
let (a, b) = (proj.to_screen(c)?, proj.to_screen(c + u)?);
let px = f64::from(a.distance(b));
(px > 1e-9).then(|| 1.0 / px)
}
fn snap(v: f64, step: f64, free: bool) -> f64 {
if free || step <= 0.0 { v } else { (v / step).round() * step }
}
/// Draw the triad and run a drag on it. `step` is the distance snap step.
pub fn show(ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, id: egui::Id, t: &Triad, step: f64) -> Option<Drag> {
let tk = Tokens::get();
let l = mm_per_px(proj, t.center)? * LEN_PX;
LAST.with(|x| *x.borrow_mut() = Some((t.center, l)));
let sh = shapes(proj, t, l)?;
// Handles by name for scenarios ("move_x", "move_rz", "move_ball"…).
for n in ["x", "y", "z", "xy", "yz", "xz", "rx", "ry", "rz", "ball"] {
if let Some(q) = handle_point(n, Vec3::ZERO, 60.0).and_then(|p| proj.to_screen(p)) {
crate::scenario::publish_handle(&format!("move_{n}"), q);
}
}
let state: Option<State> = ui.data(|d| d.get_temp(id));
let pointer = ui.input(|i| i.pointer.hover_pos());
let hovered = if state.is_none() { pointer.filter(|_| ui.rect_contains_pointer(proj.rect)).and_then(|p| hit(&sh, p)) } else { None };
let active = state.map(|s| s.handle).or(hovered);
let hot = |h: Handle| active == Some(h);
let col = |h: Handle, i: usize| if hot(h) { tk.accent } else { axis_color(i) };
for (i, pts) in &sh.rings {
painter.add(Shape::line(pts.clone(), Stroke::new(if hot(Handle::Ring(*i)) { 3.0 } else { 1.8 }, col(Handle::Ring(*i), *i))));
}
for (i, q) in &sh.planes {
let c = col(Handle::Plane(*i), *i);
painter.extend(projected_polygon(q, c.gamma_multiply(if hot(Handle::Plane(*i)) { 0.7 } else { 0.35 }), Stroke::new(1.0, c)));
}
for (i, a, b) in &sh.arrows {
arrow(painter, *a, *b, col(Handle::Axis(*i), *i), hot(Handle::Axis(*i)));
}
let bc = if hot(Handle::Ball) { tk.accent } else { Color32::from_gray(235) };
painter.circle(sh.ball, 6.0, bc, Stroke::new(1.5, Color32::from_gray(60)));
let p = pointer?;
active?;
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
let r = ui.interact(Rect::from_center_size(p, vec2(24.0, 24.0)), id.with("hit"), Sense::drag());
let free = ui.input(|i| i.modifiers.alt || i.modifiers.ctrl || i.modifiers.command);
let view = proj.ray(sh.ball).1;
let plane_normal = |h: Handle| match h {
Handle::Plane(i) | Handle::Ring(i) => DIRS[i],
_ => view,
};
let mut started = false;
let mut st = match state {
Some(s) => s,
None => {
if !r.drag_started() {
return None;
}
let h = hovered?;
let from = ui.input(|i| i.pointer.press_origin()).unwrap_or(p);
let c = t.center;
let mut s = State { handle: h, center: c, grab: c, t0: 0.0, last: 0.0, turned: 0.0 };
match h {
Handle::Axis(i) => s.t0 = along(proj, from, c, DIRS[i])?,
Handle::Ring(i) => {
let q = on_plane(proj, from, c, DIRS[i])? - c;
let (e1, e2) = ring_basis(i);
s.last = q.dot(e2).atan2(q.dot(e1));
}
_ => s.grab = on_plane(proj, from, c, plane_normal(h))?,
}
started = true;
s
}
};
let change = match st.handle {
Handle::Axis(i) => {
let v = along(proj, p, st.center, DIRS[i]).map(|v| v - st.t0).unwrap_or(0.0);
Change::Translate(DIRS[i] * snap(v, step, free))
}
Handle::Ring(i) => {
if let Some(q) = on_plane(proj, p, st.center, DIRS[i]).map(|q| q - st.center) {
let (e1, e2) = ring_basis(i);
let th = q.dot(e2).atan2(q.dot(e1));
let mut d = th - st.last;
while d > std::f64::consts::PI {
d -= std::f64::consts::TAU;
}
while d < -std::f64::consts::PI {
d += std::f64::consts::TAU;
}
st.turned += d;
st.last = th;
}
Change::Rotate { axis: i, angle: snap(st.turned, 5f64.to_radians(), free) }
}
h => {
let d = on_plane(proj, p, st.center, plane_normal(h)).map(|q| q - st.grab).unwrap_or(Vec3::ZERO);
Change::Translate(Vec3::new(snap(d.x, step, free), snap(d.y, step, free), snap(d.z, step, free)))
}
};
let done = r.drag_stopped() || !r.dragged() && !started;
if done {
ui.data_mut(|d| d.remove::<State>(id));
} else {
ui.data_mut(|d| d.insert_temp(id, st));
}
Some(Drag { handle: st.handle, change, started, done })
}
fn arrow(painter: &egui::Painter, a: Pos2, b: Pos2, col: Color32, hot: bool) {
let v = b - a;
let len = v.length();
if len < 1.0 {
return;
}
let u = v / len;
let n = vec2(-u.y, u.x);
painter.line_segment([a, b], Stroke::new(if hot { 3.0 } else { 2.0 }, col));
let head = 12.0f32.min(len * 0.5);
painter.add(Shape::convex_polygon(vec![b + u * 2.0, b - u * head + n * head * 0.4, b - u * head - n * head * 0.4], col, Stroke::NONE));
}
/// A 3×3 rotation (rows) about a unit axis.
pub fn rotation(axis: Vec3, angle: f64) -> [[f64; 3]; 3] {
let (c, s) = (angle.cos(), angle.sin());
let t = 1.0 - c;
let (x, y, z) = (axis.x, axis.y, axis.z);
[
[t * x * x + c, t * x * y - s * z, t * x * z + s * y],
[t * x * y + s * z, t * y * y + c, t * y * z - s * x],
[t * x * z - s * y, t * y * z + s * x, t * z * z + c],
]
}
pub fn mul(a: &[[f64; 3]; 3], b: &[[f64; 3]; 3]) -> [[f64; 3]; 3] {
let mut m = [[0.0; 3]; 3];
for (i, row) in m.iter_mut().enumerate() {
for (j, v) in row.iter_mut().enumerate() {
*v = (0..3).map(|k| a[i][k] * b[k][j]).sum();
}
}
m
}
pub fn apply(m: &[[f64; 3]; 3], p: Vec3) -> Vec3 {
Vec3::new(
m[0][0] * p.x + m[0][1] * p.y + m[0][2] * p.z,
m[1][0] * p.x + m[1][1] * p.y + m[1][2] * p.z,
m[2][0] * p.x + m[2][1] * p.y + m[2][2] * p.z,
)
}
/// The axis and angle of a rotation (angle 0 for none).
pub fn axis_angle(m: &[[f64; 3]; 3]) -> (Vec3, f64) {
let tr = m[0][0] + m[1][1] + m[2][2];
let angle = ((tr - 1.0) / 2.0).clamp(-1.0, 1.0).acos();
if angle < 1e-12 {
return (Vec3::Z, 0.0);
}
let v = Vec3::new(m[2][1] - m[1][2], m[0][2] - m[2][0], m[1][0] - m[0][1]);
if let Some(a) = v.normalized().filter(|_| v.len() > 1e-6) {
return (a, angle);
}
// Half a turn: the axis from the diagonal.
let d = [((m[0][0] + 1.0) / 2.0).max(0.0).sqrt(), ((m[1][1] + 1.0) / 2.0).max(0.0).sqrt(), ((m[2][2] + 1.0) / 2.0).max(0.0).sqrt()];
let (x, mut y, mut z) = (d[0], d[1], d[2]);
if m[0][1] + m[1][0] < 0.0 && x > 1e-6 {
y = -y;
}
if m[0][2] + m[2][0] < 0.0 && x > 1e-6 {
z = -z;
}
if x <= 1e-6 && m[1][2] + m[2][1] < 0.0 {
z = -z;
}
(Vec3::new(x, y, z).normalized().unwrap_or(Vec3::Z), angle)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plane_squares_stay_within_the_triad() {
let rect = Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
let t = Triad { center: Vec3::ZERO, translate: true, rotate: false };
// The squares reach 0.45 √2 of the arrow length from the centre, plus stroke and feathering.
let limit = (0.45 * 2f64.sqrt() * LEN_PX) as f32 + 3.0;
let mut worst = 0.0f32;
for dir in crate::viewport::orbit_directions() {
let cam = solvecraft_engine::render::Camera::default().looking_from(dir);
let proj = Proj { cam, vp: cam.view_proj(4.0 / 3.0, 100.0), rect };
let l = mm_per_px(&proj, t.center).unwrap() * LEN_PX;
let sh = shapes(&proj, &t, l).unwrap();
for (_, q) in &sh.planes {
for p in crate::viewport::drawn_points(projected_polygon(q, Color32::GRAY, Stroke::new(1.0, Color32::BLACK))) {
worst = worst.max(p.distance(sh.ball));
}
}
}
assert!(worst <= limit, "a vertex {worst} px from the centre (limit {limit})");
}
#[test]
fn rotations_compose_to_one_axis_angle() {
let r = mul(&rotation(Vec3::Z, 0.7), &rotation(Vec3::X, 0.4));
let (a, ang) = axis_angle(&r);
let back = rotation(a, ang);
for i in 0..3 {
for j in 0..3 {
assert!((back[i][j] - r[i][j]).abs() < 1e-9);
}
}
let h = rotation(Vec3::Y, std::f64::consts::PI);
let (a, ang) = axis_angle(&h);
assert!((ang - std::f64::consts::PI).abs() < 1e-9 && (a.y.abs() - 1.0).abs() < 1e-9);
assert!((apply(&rotation(Vec3::Z, std::f64::consts::FRAC_PI_2), Vec3::X) - Vec3::Y).len() < 1e-12);
}
}