Selection feedback and origin: hover and selected highlights (theme tokens), origin planes/axes/point at a fixed screen size with browser eye toggles, click-to-pick sketch planes and faces with camera animation (Finish Sketch returns), multi-select dialog inputs with pre-selection, tangent chains and face-to-edges, window/crossing box selection, shell/draft/mirror/hole dialogs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-07 20:00:17 +00:00
1 parent 059909fb05
commit 97e73b0935
20 files changed
+1907 -518

No files matched your search

+234
View File
@@ -0,0 +1,234 @@
//! 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;
/// 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::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() }]);
}
}