Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
243 lines
9.9 KiB
Rust
243 lines
9.9 KiB
Rust
//! Selection inputs and the geometry behind picking: what a command input accepts, the origin
|
||
//! widget (a fixed size on screen), ray tests against planar quads, and window/crossing box
|
||
//! selection. The pure parts are unit tested; the viewport uses them.
|
||
|
||
use egui::{Pos2, Rect};
|
||
use solvecraft_engine::Sel;
|
||
use solvecraft_engine::geom::Vec3;
|
||
|
||
use crate::viewport::Hit;
|
||
|
||
/// What a selection input accepts (bit set).
|
||
pub type Accept = u16;
|
||
pub const PROFILES: Accept = 1;
|
||
pub const EDGES: Accept = 2;
|
||
pub const FACES: Accept = 4;
|
||
/// Planar faces only.
|
||
pub const PLANAR_FACES: Accept = 8;
|
||
pub const BODIES: Accept = 16;
|
||
/// Origin and construction planes.
|
||
pub const PLANES: Accept = 32;
|
||
/// Origin axes and straight sketch curves.
|
||
pub const AXES: Accept = 64;
|
||
pub const VERTICES: Accept = 128;
|
||
/// Any sketch curve (a sweep path).
|
||
pub const CURVES: Accept = 256;
|
||
/// Sketch points of any visible sketch (hole centres); picked as "<sketch id>:<point id>".
|
||
pub const POINTS: Accept = 512;
|
||
/// Timeline features (pattern objects), picked in the timeline.
|
||
pub const FEATURES: Accept = 1024;
|
||
|
||
/// One selection input of a command dialog ("Profiles", "Edges", "Plane", ...).
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub struct SelInput {
|
||
pub label: &'static str,
|
||
pub accept: Accept,
|
||
/// Many items (clicks toggle them), or one (a click replaces it).
|
||
pub multi: bool,
|
||
pub items: Vec<Sel>,
|
||
}
|
||
|
||
impl SelInput {
|
||
pub fn new(label: &'static str, accept: Accept, multi: bool) -> SelInput {
|
||
SelInput { label, accept, multi, items: Vec::new() }
|
||
}
|
||
|
||
/// The selection a hit stands for, if this input accepts it.
|
||
pub fn accepts(&self, h: &Hit, planar: impl Fn(&str, usize) -> bool) -> Option<Sel> {
|
||
let a = self.accept;
|
||
match h {
|
||
Hit::Profile { sketch, index } if a & PROFILES != 0 => Some(Sel::Profile { sketch: *sketch, index: *index }),
|
||
Hit::Edge { body, index, mid } if a & EDGES != 0 => Some(Sel::Edge { body: body.clone(), index: *index, point: *mid }),
|
||
Hit::Face { body, index, point } if a & FACES != 0 || (a & PLANAR_FACES != 0 && planar(body, *index)) => {
|
||
Some(Sel::Face { body: body.clone(), index: *index, point: *point })
|
||
}
|
||
Hit::Face { body, .. } if a & BODIES != 0 => Some(Sel::Body { name: body.clone() }),
|
||
Hit::Plane { name, .. } if a & PLANES != 0 => Some(Sel::Plane { name: name.clone() }),
|
||
Hit::Axis { name } if a & AXES != 0 => Some(Sel::Axis { name: name.clone() }),
|
||
Hit::SketchCurve { id, straight: true, .. } if a & AXES != 0 => Some(Sel::SketchCurve { id: id.clone() }),
|
||
Hit::SketchCurve { id, .. } if a & CURVES != 0 => Some(Sel::SketchCurve { id: id.clone() }),
|
||
Hit::SketchPoint { sketch, id, .. } if a & POINTS != 0 => Some(Sel::SketchPoint { id: format!("{sketch}:{id}") }),
|
||
Hit::Vertex { body, point } if a & VERTICES != 0 => Some(Sel::Vertex { body: body.clone(), point: *point }),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Add or remove items: in a multi input a pick toggles, otherwise it replaces.
|
||
pub fn toggle(&mut self, picked: Vec<Sel>) {
|
||
if picked.is_empty() {
|
||
return;
|
||
}
|
||
if !self.multi {
|
||
self.items = picked.into_iter().take(1).collect();
|
||
return;
|
||
}
|
||
// A pick of something already in (or a chain fully in) takes it out.
|
||
if picked.iter().all(|p| self.items.contains(p)) {
|
||
self.items.retain(|x| !picked.contains(x));
|
||
} else {
|
||
for p in picked {
|
||
if !self.items.contains(&p) {
|
||
self.items.push(p);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// World size of the origin widget: a fixed fraction of the viewport height, whatever the zoom
|
||
/// (`half_height` is half the visible height in mm at the target).
|
||
pub fn origin_size(half_height: f64) -> f64 {
|
||
(half_height * 2.0 * 0.075).max(1e-6)
|
||
}
|
||
|
||
/// The three origin plane squares (positive quadrant): name, plane normal and corners.
|
||
pub fn origin_quads(size: f64) -> [(&'static str, Vec3, [Vec3; 4]); 3] {
|
||
let s = size;
|
||
[
|
||
("XY", Vec3::Z, [Vec3::ZERO, Vec3::new(s, 0.0, 0.0), Vec3::new(s, s, 0.0), Vec3::new(0.0, s, 0.0)]),
|
||
("XZ", Vec3::new(0.0, -1.0, 0.0), [Vec3::ZERO, Vec3::new(s, 0.0, 0.0), Vec3::new(s, 0.0, s), Vec3::new(0.0, 0.0, s)]),
|
||
("YZ", Vec3::X, [Vec3::ZERO, Vec3::new(0.0, s, 0.0), Vec3::new(0.0, s, s), Vec3::new(0.0, 0.0, s)]),
|
||
]
|
||
}
|
||
|
||
/// Ray parameter where the ray (o, d) hits the planar quad (corners in order), if it does.
|
||
pub fn ray_quad(o: Vec3, d: Vec3, q: &[Vec3; 4]) -> Option<f64> {
|
||
let [a, b, _, dd] = *q;
|
||
let (u, v) = (b - a, dd - a);
|
||
let n = u.cross(v);
|
||
let den = n.dot(d);
|
||
if den.abs() < 1e-12 {
|
||
return None;
|
||
}
|
||
let t = n.dot(a - o) / den;
|
||
if !(t.is_finite() && t >= 0.0) {
|
||
return None;
|
||
}
|
||
let p = o + d * t - a;
|
||
// Coordinates in the (u, v) frame (the quad is a parallelogram).
|
||
let (uu, uv, vv) = (u.dot(u), u.dot(v), v.dot(v));
|
||
let det = uu * vv - uv * uv;
|
||
if det.abs() < 1e-300 {
|
||
return None;
|
||
}
|
||
let (pu, pv) = (p.dot(u), p.dot(v));
|
||
let s = (pu * vv - pv * uv) / det;
|
||
let r = (pv * uu - pu * uv) / det;
|
||
((0.0..=1.0).contains(&s) && (0.0..=1.0).contains(&r)).then_some(t)
|
||
}
|
||
|
||
/// A rectangle dragged in the viewport selects by window (dragged left to right: items fully
|
||
/// inside) or by crossing (right to left: items touching it).
|
||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||
pub struct BoxSel {
|
||
pub rect: Rect,
|
||
pub crossing: bool,
|
||
}
|
||
|
||
impl BoxSel {
|
||
pub fn from_drag(start: Pos2, end: Pos2) -> BoxSel {
|
||
BoxSel { rect: Rect::from_two_pos(start, end), crossing: end.x < start.x }
|
||
}
|
||
|
||
/// Does the polyline (screen points) count as selected?
|
||
pub fn takes(&self, pts: &[Pos2]) -> bool {
|
||
if pts.is_empty() {
|
||
return false;
|
||
}
|
||
if !self.crossing {
|
||
return pts.iter().all(|p| self.rect.contains(*p));
|
||
}
|
||
pts.iter().any(|p| self.rect.contains(*p)) || pts.windows(2).any(|w| segment_hits_rect(w[0], w[1], self.rect))
|
||
}
|
||
}
|
||
|
||
/// Does the segment a–b cross the rectangle's boundary or lie inside it?
|
||
pub fn segment_hits_rect(a: Pos2, b: Pos2, r: Rect) -> bool {
|
||
if r.contains(a) || r.contains(b) {
|
||
return true;
|
||
}
|
||
let c = [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()];
|
||
(0..4).any(|i| segments_cross(a, b, c[i], c[(i + 1) % 4]))
|
||
}
|
||
|
||
fn segments_cross(p: Pos2, q: Pos2, r: Pos2, s: Pos2) -> bool {
|
||
let o = |a: Pos2, b: Pos2, c: Pos2| (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
|
||
let (d1, d2, d3, d4) = (o(r, s, p), o(r, s, q), o(p, q, r), o(p, q, s));
|
||
(d1 > 0.0) != (d2 > 0.0) && (d3 > 0.0) != (d4 > 0.0)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use egui::pos2;
|
||
use solvecraft_engine::render::{Camera, StandardView};
|
||
|
||
#[test]
|
||
fn origin_widget_keeps_its_screen_size() {
|
||
let (w, h) = (1200.0, 800.0);
|
||
let mut sizes = Vec::new();
|
||
for distance in [20.0, 200.0, 5000.0] {
|
||
let mut cam = Camera { distance, ..Default::default() };
|
||
cam.set_view(StandardView::Front);
|
||
let s = origin_size(cam.half_height());
|
||
let a = cam.to_screen(Vec3::ZERO, w, h, distance).unwrap();
|
||
let b = cam.to_screen(Vec3::new(s, 0.0, 0.0), w, h, distance).unwrap();
|
||
sizes.push(b.0 - a.0);
|
||
}
|
||
assert!(sizes.iter().all(|x| (x - sizes[0]).abs() < 1e-6 && *x > 40.0), "{sizes:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn rays_hit_origin_quads() {
|
||
let q = origin_quads(10.0);
|
||
// Straight down onto XY at (3, 4).
|
||
let t = ray_quad(Vec3::new(3.0, 4.0, 50.0), Vec3::new(0.0, 0.0, -1.0), &q[0].2).unwrap();
|
||
assert!((t - 50.0).abs() < 1e-12);
|
||
// Outside the quadrant: misses.
|
||
assert!(ray_quad(Vec3::new(-3.0, 4.0, 50.0), Vec3::new(0.0, 0.0, -1.0), &q[0].2).is_none());
|
||
// Parallel: misses.
|
||
assert!(ray_quad(Vec3::new(3.0, 4.0, 50.0), Vec3::X, &q[0].2).is_none());
|
||
// From the front onto XZ.
|
||
assert!(ray_quad(Vec3::new(5.0, -100.0, 5.0), Vec3::Y, &q[1].2).is_some());
|
||
// Behind the ray origin: misses.
|
||
assert!(ray_quad(Vec3::new(3.0, 4.0, -50.0), Vec3::new(0.0, 0.0, -1.0), &q[0].2).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn window_and_crossing_selection() {
|
||
let inside = [pos2(20.0, 20.0), pos2(40.0, 30.0)];
|
||
let partly = [pos2(20.0, 20.0), pos2(140.0, 30.0)];
|
||
let through = [pos2(-10.0, 50.0), pos2(200.0, 50.0)];
|
||
let away = [pos2(300.0, 300.0), pos2(320.0, 330.0)];
|
||
// Left to right: window.
|
||
let w = BoxSel::from_drag(pos2(0.0, 0.0), pos2(100.0, 100.0));
|
||
assert!(!w.crossing);
|
||
assert!(w.takes(&inside) && !w.takes(&partly) && !w.takes(&through) && !w.takes(&away));
|
||
// Right to left: crossing.
|
||
let c = BoxSel::from_drag(pos2(100.0, 100.0), pos2(0.0, 0.0));
|
||
assert!(c.crossing);
|
||
assert!(c.takes(&inside) && c.takes(&partly) && c.takes(&through) && !c.takes(&away));
|
||
}
|
||
|
||
#[test]
|
||
fn inputs_toggle_and_filter() {
|
||
let mut inp = SelInput::new("Edges", EDGES, true);
|
||
let e = |i: usize| Sel::Edge { body: "B".into(), index: i, point: Vec3::new(i as f64, 0.0, 0.0) };
|
||
inp.toggle(vec![e(1)]);
|
||
inp.toggle(vec![e(2), e(3)]);
|
||
assert_eq!(inp.items.len(), 3);
|
||
inp.toggle(vec![e(2), e(3)]);
|
||
assert_eq!(inp.items, vec![e(1)]);
|
||
let face = Hit::Face { body: "B".into(), index: 0, point: Vec3::ZERO };
|
||
assert!(inp.accepts(&face, |_, _| true).is_none());
|
||
let planes = SelInput::new("Plane", PLANES | PLANAR_FACES, false);
|
||
assert!(planes.accepts(&face, |_, _| true).is_some());
|
||
assert!(planes.accepts(&face, |_, _| false).is_none());
|
||
let mut one = SelInput::new("Plane", PLANES, false);
|
||
one.toggle(vec![Sel::Plane { name: "XY".into() }]);
|
||
one.toggle(vec![Sel::Plane { name: "YZ".into() }]);
|
||
assert_eq!(one.items, vec![Sel::Plane { name: "YZ".into() }]);
|
||
}
|
||
}
|