diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 087277a4..16a217fb 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -823,6 +823,8 @@ mod tests; #[cfg(test)] mod thread_tests; #[cfg(test)] +mod view_bounds_tests; +#[cfg(test)] mod world_placement_cache_tests; /// Per sketch feature: its parameter-driven dimensions' values (see [`own_values`]). diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index 8ea78f6a..21d19cfc 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -39,20 +39,32 @@ pub mod colors { pub const SECTION_CAP: Rgb = solidcraft_render::SECTION_CAP; } -/// Bounds of everything visible. +/// Bounds of everything visible now, in world space: shown bodies where their occurrences, +/// joints and moves place them, and the sketches the view draws (not ones used by a feature, +/// hidden ones, or ones in hidden components). What Home, Fit and the orbit pivot frame. pub fn bounds(s: &Session) -> Aabb3 { + visible_bounds(s, true) +} + +/// [`bounds`], with the browser's Sketches folder shown (`sketches`) or hidden. +pub fn visible_bounds(s: &Session, sketches: bool) -> Aabb3 { let st = s.world_state(); let mut b = Aabb3::EMPTY; - let v = &s.visibility; for body in st.bodies.iter().filter(|x| s.body_visible(&x.name)) { b = b.union(&body.mesh().bounds()); } - for ss in st.sketches.iter().filter(|x| !v.hidden_sketches.contains(&x.feature)) { + for ss in st.sketches.iter().filter(|x| sketch_drawn(s, x.feature, sketches)) { + // All four corners: on a tilted plane two opposite ones don't span the rectangle. if let Some((lo, hi)) = ss.sketch.bounds() { - for p in [lo, hi] { + for p in [lo, hi, solidcraft_geom::Vec2::new(lo.x, hi.y), solidcraft_geom::Vec2::new(hi.x, lo.y)] { b.add(ss.plane.to_world(p)); } } + for w in ss.sketch.wires.iter().filter(|w| !w.fit.is_empty() || !ss.sketch.view.hide_projected) { + for p in &w.pts { + b.add(*p); + } + } } b } @@ -284,8 +296,38 @@ pub fn section_segments(m: &solidcraft_geom::Mesh, o: Vec3, n: Vec3) -> Vec<(Vec /// Is a sketch drawn? The active one is; others unless hidden. Finished sketches are hidden /// once a feature uses them, like Fusion's default, unless shown one by one. pub fn sketch_shown(s: &Session, id: u64) -> bool { + sketch_drawn(s, id, true) +} + +/// Is a sketch drawn, with the browser's Sketches folder shown (`folder`) or hidden? The sketch +/// being edited always is; otherwise not when hidden, nor in a hidden component, and one used by +/// a feature only when shown on its own. +pub fn sketch_drawn(s: &Session, id: u64, folder: bool) -> bool { let v = &s.visibility; - s.active_sketch == Some(id) || (!v.hidden_sketches.contains(&id) && (v.shown_sketches.contains(&id) || !sketch_consumed(s, id))) + if s.active_sketch == Some(id) { + return true; + } + if v.hidden_sketches.contains(&id) || !sketch_component_visible(s, id) { + return false; + } + v.shown_sketches.contains(&id) || (folder && !sketch_consumed(s, id)) +} + +/// Is the placement a sketch is drawn at shown (the active one while its component is being +/// edited, else the component's first)? +fn sketch_component_visible(s: &Session, id: u64) -> bool { + if s.visibility.hidden_occurrences.is_empty() { + return true; + } + let component = s.doc.feature(id).map_or(0, |f| f.component); + if component == 0 { + return true; + } + let path = match &s.active_occurrence_path { + Some(p) if s.active_component == component => p.clone(), + _ => s.doc.first_path(component), + }; + s.occurrence_visible(&path) } /// Is a sketch used by a later feature? diff --git a/crates/engine/src/view_bounds_tests.rs b/crates/engine/src/view_bounds_tests.rs new file mode 100644 index 00000000..ccad6f97 --- /dev/null +++ b/crates/engine/src/view_bounds_tests.rs @@ -0,0 +1,162 @@ +// Home and Fit frame what is visible now: bodies where they are placed (occurrence moves, +// joints, motion), sketches only while they are drawn, nothing hidden or consumed. +use serde_json::{Value, json}; +use solidcraft_geom::{Aabb3, Vec3}; + +use crate::Session; + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + match s.execute(id, &p) { + Ok(v) => v, + Err(e) => panic!("{id} {p}: {e}"), + } +} + +/// What the viewport draws: visible bodies' meshes and the sketches it shows, from the scene. +fn drawn(s: &Session) -> Aabb3 { + let cam = solidcraft_render::Camera { distance: 1e5, ..Default::default() }; + let scene = crate::view::scene(s, &cam); + let mut b = Aabb3::EMPTY; + for m in &scene.meshes { + b = b.union(&m.mesh.bounds()); + } + use crate::view::colors::{AXIS_X, AXIS_Y, AXIS_Z, GRID, GRID_MAJOR}; + for l in scene.lines.iter().filter(|l| ![GRID, GRID_MAJOR, AXIS_X, AXIS_Y, AXIS_Z].contains(&l.color)) { + for p in &l.points { + b.add(*p); + } + } + b +} + +fn same(a: Aabb3, b: Aabb3) { + assert!(!a.is_empty() && !b.is_empty(), "{a:?} {b:?}"); + assert!(a.min.dist(b.min) < 1e-6 && a.max.dist(b.max) < 1e-6, "Home frames {a:?}, the view shows {b:?}"); +} + +fn near(a: Aabb3, lo: [f64; 3], hi: [f64; 3]) { + let (lo, hi) = (Vec3::new(lo[0], lo[1], lo[2]), Vec3::new(hi[0], hi[1], hi[2])); + assert!(a.min.dist(lo) < 1e-6 && a.max.dist(hi) < 1e-6, "{a:?} != {lo:?}..{hi:?}"); +} + +/// A 10 x 10 x 5 block drawn as a sketch and extruded, in the active component. +fn block(s: &mut Session) { + run(s, "sketch.create", json!({"plane": "XY"})); + run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]})); + run(s, "sketch.finish", json!({})); + run(s, "solid.extrude", json!({"distance": 5})); +} + +#[test] +fn moved_body_leaves_its_used_sketch_out() { + let mut s = Session::default(); + block(&mut s); + near(crate::view::bounds(&s), [0., 0., 0.], [10., 10., 5.]); + let body = s.world_state().bodies[0].name.clone(); + run(&mut s, "solid.move", json!({"bodies": [body], "translate": [500, 0, 0]})); + near(crate::view::bounds(&s), [500., 0., 0.], [510., 10., 5.]); + same(crate::view::bounds(&s), drawn(&s)); +} + +#[test] +fn moved_component_frames_where_it_is_now() { + let mut s = Session::default(); + run(&mut s, "component.create", json!({"name": "A"})); + block(&mut s); + run(&mut s, "component.activate", json!({"component": "root"})); + let b = run(&mut s, "component.create", json!({"name": "B"}))["occurrence"].as_u64().unwrap(); + block(&mut s); + run(&mut s, "component.activate", json!({"component": "root"})); + run(&mut s, "occurrence.move", json!({"occurrence": b, "translate": [800, 0, 0]})); + near(crate::view::bounds(&s), [0., 0., 0.], [810., 10., 5.]); + run(&mut s, "occurrence.move", json!({"occurrence": b, "translate": [-780, 0, 0]})); + near(crate::view::bounds(&s), [0., 0., 0.], [30., 10., 5.]); + same(crate::view::bounds(&s), drawn(&s)); + // Pending (not captured) moves too. + run(&mut s, "occurrence.move", json!({"occurrence": b, "translate": [0, 300, 0], "capture": false})); + near(crate::view::bounds(&s), [0., 0., 0.], [30., 310., 5.]); + same(crate::view::bounds(&s), drawn(&s)); +} + +#[test] +fn body_consumed_by_a_join_or_cut_is_not_framed() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10})); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [200, 0, 0], "operation": "new"})); + let names: Vec = s.world_state().bodies.iter().map(|b| b.name.clone()).collect(); + run(&mut s, "solid.move", json!({"bodies": [names[1].clone()], "translate": [-195, 0, 0]})); + run(&mut s, "solid.combine", json!({"target": names[0].clone(), "tools": [names[1].clone()], "operation": "cut"})); + near(crate::view::bounds(&s), [0., 0., 0.], [5., 10., 10.]); + same(crate::view::bounds(&s), drawn(&s)); +} + +#[test] +fn hidden_component_is_not_framed() { + let mut s = Session::default(); + run(&mut s, "component.create", json!({"name": "A"})); + block(&mut s); + run(&mut s, "component.activate", json!({"component": "root"})); + let b = run(&mut s, "component.create", json!({"name": "B"}))["occurrence"].as_u64().unwrap(); + run(&mut s, "sketch.create", json!({"plane": "XY"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance": 5})); + // An unused sketch in B, drawn while B is shown. + run(&mut s, "sketch.create", json!({"plane": "XY"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 40]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "component.activate", json!({"component": "root"})); + run(&mut s, "occurrence.move", json!({"occurrence": b, "translate": [400, 0, 0]})); + near(crate::view::bounds(&s), [0., 0., 0.], [440., 40., 5.]); + same(crate::view::bounds(&s), drawn(&s)); + run(&mut s, "browser.visibility", json!({"visible": false, "paths": [[b]]})); + near(crate::view::bounds(&s), [0., 0., 0.], [10., 10., 5.]); + same(crate::view::bounds(&s), drawn(&s)); +} + +/// Two leaves joined by a hinge (as in the joint tests). +fn hinge(s: &mut Session) { + for name in ["LeafA", "LeafB"] { + run(s, "component.activate", json!({"component": "root"})); + run(s, "component.create", json!({"name": name})); + block_leaf(s); + } + run(s, "component.activate", json!({"component": "root"})); + let a = s.doc.occurrence_of(s.doc.find_component("LeafA").unwrap()).unwrap().id; + run(s, "occurrence.ground", json!({"occurrence": a, "grounded": true})); + let b = s.doc.occurrence_of(s.doc.find_component("LeafB").unwrap()).unwrap().id; + // B starts far away; the joint brings it onto A. + run(s, "occurrence.move", json!({"occurrence": b, "translate": [600, 0, 0]})); + run( + s, + "joint.create", + json!({"type": "revolute", "a": {"occurrence": "LeafA", "circle": [1, 37, 4]}, "b": {"occurrence": "LeafB", "circle": [601, 37, 4]}, "flip": true, "name": "Hinge"}), + ); +} + +fn block_leaf(s: &mut Session) { + // The plate drawn as a sketch (used by its extrude), and the knuckle. + run(s, "sketch.create", json!({"plane": "XY"})); + run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]})); + run(s, "sketch.finish", json!({})); + run(s, "solid.extrude", json!({"distance": 3})); + run(s, "solid.cylinder", json!({"base": [0, 33, 4], "axis": [1, 0, 0], "radius": 4, "height": 40, "operation": "new"})); +} + +#[test] +fn joint_and_drive_frame_the_joined_place() { + let mut s = Session::default(); + hinge(&mut s); + let b = crate::view::bounds(&s); + assert!(b.max.x < 50.0, "the joint brought B onto A: {b:?}"); + same(b, drawn(&s)); + run(&mut s, "joint.drive", json!({"joint": "Hinge", "value": 90})); + let b = crate::view::bounds(&s); + assert!(b.max.x < 50.0 && b.max.z > 30.0, "{b:?}"); + same(b, drawn(&s)); + run(&mut s, "motion.study", json!({"name": "Swing", "steps": 10, "keys": [{"joint": "Hinge", "points": [[0, 0], [10, 180]]}]})); + run(&mut s, "motion.play", json!({"study": "Swing", "step": 10})); + let b = crate::view::bounds(&s); + assert!(b.max.x < 50.0 && b.max.y > 60.0, "{b:?}"); + same(b, drawn(&s)); +} diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index 030c80f2..69f0f10e 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -167,7 +167,7 @@ pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) { } ui.label( RichText::new( - "Select Other: hold the left button still on the model (or right-click › Select Other…) to list everything under the cursor; click again on the same spot to select the next thing there.", + "Select Other: hold the left button still on the model (or right-click › Select Other…) to list everything under the cursor; click again on the same spot to select the next thing there. Double-click the middle button to go Home (Preferences › Navigation).", ) .size(11.0) .color(t.text_dim), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a9f20ea9..523e7866 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -809,14 +809,14 @@ impl SolveApp { self.cam_anim = None; self.viewport.orbit_snap = None; self.viewport.orbit_free = None; - let bounds = solidcraft_engine::view::bounds(&self.session); + let bounds = viewport::view_bounds(self); self.pending_fit = Some((self.cam, bounds, false)); self.cam = self.fitted(self.cam); } /// `cam` moved to frame the whole model. pub fn fitted(&self, mut cam: Camera) -> Camera { - let b = solidcraft_engine::view::bounds(&self.session); + let b = viewport::view_bounds(self); if b.is_empty() { cam.target = solidcraft_engine::geom::Vec3::ZERO; cam.distance = 150.0; @@ -873,6 +873,15 @@ impl SolveApp { self.cam_anim = Some(anim); } + /// Frame everything shown, keeping the orientation (animated; the selection is ignored). + pub fn animate_fit_all(&mut self) { + self.pending_fit = None; + let request = Some((self.cam, viewport::view_bounds(self), true)); + let to = self.fitted(self.cam); + self.animate_to(to); + self.pending_fit = request; + } + /// Animated view changes for the axis view and navigation bar. pub fn animate_view(&mut self, v: &str) { self.pending_fit = None; @@ -885,13 +894,13 @@ impl SolveApp { to = self.fitted_bounds(to, &b); } None => { - self.pending_fit = Some((to, solidcraft_engine::view::bounds(&self.session), true)); + self.pending_fit = Some((to, viewport::view_bounds(self), true)); to = self.fitted(to); } }, "home" => { to.set_view(StandardView::Iso); - self.pending_fit = Some((to, solidcraft_engine::view::bounds(&self.session), true)); + self.pending_fit = Some((to, viewport::view_bounds(self), true)); to = self.fitted(to); } v => match StandardView::parse(v) { diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index 6193ab83..3a5be486 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -236,6 +236,36 @@ pub struct Prefs { pub up_axis: String, /// After a STEP or IGES file opens, offer to stand a Y-up part up (`stand_up`). pub offer_stand_up: bool, + /// What a middle-button double-click in the view does: `home`, `fit` or `off`. + pub middle_double_click: String, +} + +/// What a middle-button double-click in the view does. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MiddleDoubleClick { + /// Go Home (the iso view, framing what is shown). + Home, + /// Frame what is shown, keeping the orientation. + Fit, + Off, +} + +impl MiddleDoubleClick { + pub const ALL: [(MiddleDoubleClick, &'static str, &'static str); 3] = + [(MiddleDoubleClick::Home, "home", "Home"), (MiddleDoubleClick::Fit, "fit", "Fit"), (MiddleDoubleClick::Off, "off", "Off")]; + + /// The preference's setting (Home for an unknown one). + pub fn of(app: &SolveApp) -> MiddleDoubleClick { + Self::parse(&app.preferences.middle_double_click).unwrap_or(MiddleDoubleClick::Home) + } + + pub fn parse(id: &str) -> Option { + Self::ALL.iter().find(|(_, i, _)| *i == id).map(|(m, _, _)| *m) + } + + pub fn label(self) -> &'static str { + Self::ALL.iter().find(|(m, _, _)| *m == self).map_or("Home", |(_, _, l)| l) + } } impl Default for Prefs { @@ -262,6 +292,7 @@ impl Default for Prefs { look_at_speed: 1.0, up_axis: "z".into(), offer_stand_up: true, + middle_double_click: "home".into(), } } } @@ -366,6 +397,7 @@ fn defaults(app: &mut SolveApp, page: usize) { p.space_mouse_speed = d.space_mouse_speed; p.look_at_anim = d.look_at_anim; p.look_at_speed = d.look_at_speed; + p.middle_double_click = d.middle_double_click; } PAGE_SHORTCUTS => crate::keymap::reset(app, None), PAGE_DESIGN => { @@ -491,7 +523,7 @@ fn pivot_under(app: &SolveApp, proj: &crate::viewport::Proj, at: Pos2) -> Vec3 { _ => {} } } - let b = solidcraft_engine::view::bounds(&app.session); + let b = crate::viewport::view_bounds(app); if b.is_empty() { app.cam.target } else { b.center() } } @@ -897,6 +929,18 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { ui.radio_value(&mut app.preferences.orbit_cursor, true, "Point under the cursor"); }); ui.end_row(); + ui.label("Middle double-click"); + let current = MiddleDoubleClick::of(app); + egui::ComboBox::from_id_salt("pref_middle_double_click").selected_text(current.label()).show_ui(ui, |ui| { + for (m, id, label) in MiddleDoubleClick::ALL { + if ui.selectable_label(current == m, label).clicked() { + app.preferences.middle_double_click = id.into(); + } + } + }) + .response + .on_hover_text("Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing"); + ui.end_row(); ui.label("Animate view changes"); ui.checkbox(&mut app.preferences.look_at_anim, "Axis view, Look At, Home and Fit"); ui.end_row(); @@ -1051,6 +1095,9 @@ pub fn set(app: &mut SolveApp, p: &serde_json::Value) -> Result solidcraft_engine::render::Camera { + let mut cam = h.app.cam; + cam.set_view(StandardView::Iso); + h.app.fitted(cam) +} + +/// Somewhere in the view away from the model and the overlays. +fn spot(h: &Harness) -> egui::Pos2 { + let d = h.app.viewport.layout.as_ref().unwrap().drawable; + d.left_top() + egui::vec2(d.width() * 0.3, d.height() * 0.7) +} + +fn same_cam(a: &solidcraft_engine::render::Camera, b: &solidcraft_engine::render::Camera) -> bool { + a.target.dist(b.target) < 1e-6 && (a.distance - b.distance).abs() < 1e-6 && (a.yaw - b.yaw).abs() < 1e-9 && (a.pitch - b.pitch).abs() < 1e-9 +} + +#[test] +fn home_and_fit_frame_the_moved_body_not_its_old_place() { + let mut h = Harness::new(); + moved_block(&mut h); + near_box(crate::viewport::view_bounds(&h.app), [500., 0., 0.], [510., 10., 5.]); + for view in ["home", "fit"] { + h.app.cam.pan(300.0, 200.0, 500.0); + h.app.animate_view(view); + h.settle(); + // Near the body's centre (the fit leaves room for the palettes), not 250 mm away. + assert!(h.app.cam.target.dist(Vec3::new(505.0, 5.0, 2.5)) < 5.0, "{view} centres the body: {:?}", h.app.cam.target); + assert!(h.app.cam.distance < 200.0, "{view} frames 10 mm, not 510: {}", h.app.cam.distance); + } + // The orbit pivot over empty space (#464) is the centre of the same box. + let pivot = crate::viewport::view_bounds(&h.app).center(); + assert!(pivot.dist(Vec3::new(505.0, 5.0, 2.5)) < 1e-6); + // The Sketches folder hidden: an unused sketch no longer counts either. + h.app.run("sketch.create", json!({"plane": "XY"})).unwrap(); + h.app.run("sketch.circle.center", json!({"center": [-300, 0], "radius": 5})).unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + near_box(crate::viewport::view_bounds(&h.app), [-305., -5., 0.], [510., 10., 5.]); + h.app.ui.show_sketches = false; + near_box(crate::viewport::view_bounds(&h.app), [500., 0., 0.], [510., 10., 5.]); +} + +#[test] +fn middle_double_click_goes_home_in_every_preset() { + for preset in crate::prefs::NAV_PRESETS.iter().map(|p| p.id) { + let mut h = Harness::new(); + h.call("ui.prefs", json!({"nav": preset})); + moved_block(&mut h); + h.app.cam.set_view(StandardView::Front); + h.app.cam.pan(120.0, -80.0, 500.0); + let p = spot(&h); + h.call("ui.click", json!({"x": p.x, "y": p.y, "button": "middle", "double": true})); + h.settle(); + let want = home(&h); + assert!(same_cam(&h.app.cam, &want), "{preset}: {:?} != {:?}", h.app.cam, want); + assert!(h.app.cam.target.dist(Vec3::new(505.0, 5.0, 2.5)) < 5.0, "{preset}: {:?}", h.app.cam.target); + } +} + +#[test] +fn middle_drags_and_single_clicks_do_not_go_home() { + for preset in crate::prefs::NAV_PRESETS.iter().map(|p| p.id) { + let mut h = Harness::new(); + h.call("ui.prefs", json!({"nav": preset})); + moved_block(&mut h); + h.app.cam.set_view(StandardView::Front); + h.app.cam.pan(120.0, -80.0, 500.0); + let p = spot(&h); + // A middle drag (pan or orbit), then a click where it ended: a drag and one click. + h.call("ui.drag", json!({"x0": p.x, "y0": p.y, "x1": p.x + 40.0, "y1": p.y + 25.0, "button": "middle", "steps": 4})); + h.settle(); + let dragged = h.app.cam; + let q = p + egui::vec2(40.0, 25.0); + h.call("ui.click", json!({"x": q.x, "y": q.y, "button": "middle"})); + h.settle(); + assert!(same_cam(&h.app.cam, &dragged), "{preset}: a drag and a click went home"); + // A left click and then a middle click are not a middle double-click. + h.call("ui.click", json!({"x": q.x, "y": q.y})); + h.call("ui.click", json!({"x": q.x, "y": q.y, "button": "middle"})); + h.settle(); + assert!(same_cam(&h.app.cam, &dragged), "{preset}: left + middle went home"); + } +} + +#[test] +fn middle_double_click_preference_fit_or_off() { + let mut h = Harness::new(); + moved_block(&mut h); + let bad = h.call("ui.prefs", json!({"middle_double_click": "zoom"})); + assert!(bad.to_string().contains("middle_double_click"), "{bad}"); + assert_eq!(h.app.preferences.middle_double_click, "home"); + + h.call("ui.prefs", json!({"middle_double_click": "fit"})); + h.app.cam.set_view(StandardView::Front); + h.app.cam.pan(120.0, -80.0, 500.0); + let (yaw, pitch) = (h.app.cam.yaw, h.app.cam.pitch); + let p = spot(&h); + h.call("ui.click", json!({"x": p.x, "y": p.y, "button": "middle", "double": true})); + h.settle(); + assert_eq!((h.app.cam.yaw, h.app.cam.pitch), (yaw, pitch), "Fit keeps the orientation"); + assert!(h.app.cam.target.dist(Vec3::new(505.0, 5.0, 2.5)) < 5.0, "{:?}", h.app.cam.target); + // Fit frames everything shown, not the selection. + let body = h.app.session.world_state().bodies[0].name.clone(); + h.app.run("select.set", json!({"items": [{"type": "body", "name": body}]})).unwrap(); + let fitted = h.app.cam; + h.app.cam.pan(60.0, 0.0, 500.0); + // Well after the last pair (a third click soon after a double-click is a triple-click). + h.frames(60); + h.call("ui.click", json!({"x": p.x, "y": p.y, "button": "middle", "double": true})); + h.settle(); + assert!(same_cam(&h.app.cam, &fitted)); + + h.call("ui.prefs", json!({"middle_double_click": "off"})); + h.app.cam.pan(60.0, 0.0, 500.0); + let moved = h.app.cam; + h.frames(60); + h.call("ui.click", json!({"x": p.x, "y": p.y, "button": "middle", "double": true})); + h.settle(); + assert!(same_cam(&h.app.cam, &moved), "Off does nothing"); +} diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index a55256e0..1159a0c2 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -7,7 +7,7 @@ use solidcraft_engine::Sel; use solidcraft_engine::geom::{Vec2, Vec3}; use solidcraft_engine::render::{Camera, Mat4, Rgb}; use solidcraft_engine::sketch::CurveKind; -use solidcraft_engine::view::{colors, grid_step, sketch_consumed, sketch_lines_with_sampler}; +use solidcraft_engine::view::{colors, grid_step, sketch_lines_with_sampler}; use std::hash::{Hash, Hasher}; use crate::gpu::{GpuScene, GpuTarget, SceneSlot, ViewportCallback}; @@ -35,8 +35,11 @@ pub enum NavMode { #[derive(Default)] pub struct ViewportState { - /// Per-body bounding boxes for fast picking, keyed by the model they were taken from. - pick_bounds: std::sync::Mutex>)>>, + /// Per-body boxes of one world state, held (not just its address, which a later state can + /// reuse once the old one is freed, so stale boxes would come back). + pick_bounds: std::sync::Mutex>, + /// The last click in the view was the middle button's (for the middle double-click). + middle_clicked: bool, /// The last pick (its key and hits): reused while nothing it depends on changed. last_pick: Option<(u64, Vec)>, pub rect: Option, @@ -239,16 +242,13 @@ pub fn projection(app: &SolveApp, rect: Rect) -> Proj { /// Sketches drawn in the viewport: the active one, and finished ones not yet used by a feature. fn visible_sketches(app: &SolveApp) -> Vec { let st = app.session.world_state(); - st.sketches - .iter() - .filter(|s| { - app.session.active_sketch == Some(s.feature) - || (!app.session.visibility.hidden_sketches.contains(&s.feature) - && (app.session.visibility.shown_sketches.contains(&s.feature) - || (app.ui.show_sketches && !sketch_consumed(&app.session, s.feature)))) - }) - .map(|s| s.feature) - .collect() + st.sketches.iter().filter(|s| solidcraft_engine::view::sketch_drawn(&app.session, s.feature, app.ui.show_sketches)).map(|s| s.feature).collect() +} + +/// What Home, Fit and the orbit pivot frame: the bodies and sketches shown now, where they are +/// now (see [`solidcraft_engine::view::visible_bounds`]). +pub fn view_bounds(app: &SolveApp) -> solidcraft_engine::geom::Aabb3 { + solidcraft_engine::view::visible_bounds(&app.session, app.ui.show_sketches) } fn scene_key(app: &SolveApp) -> u64 { @@ -593,7 +593,7 @@ pub fn origin_axes(app: &SolveApp) -> Vec<(&'static str, Vec3)> { /// from, to). pub fn construction_axis_segments(app: &SolveApp) -> Vec<(String, Vec3, Vec3)> { let h = origin_size(app.cam.half_height()) * AXIS_LEN; - let bb = solidcraft_engine::view::bounds(&app.session); + let bb = view_bounds(app); solidcraft_engine::view::construction_axes(&app.session) .into_iter() .filter(|(_, n, _, _)| !app.session.visibility.hidden_origin.contains(n)) @@ -645,20 +645,21 @@ fn body_bounds( app: &SolveApp, st: &std::sync::Arc, ) -> std::sync::Arc> { - let key = std::sync::Arc::as_ptr(st) as usize; if let Ok(mut g) = app.viewport.pick_bounds.lock() { if let Some((k, b)) = g.as_ref() - && *k == key + && std::sync::Arc::ptr_eq(k, st) { return b.clone(); } let b = std::sync::Arc::new(st.bodies.iter().map(|b| (b.name.clone(), b.mesh().bounds())).collect::>()); - *g = Some((key, b.clone())); + *g = Some((st.clone(), b.clone())); return b; } std::sync::Arc::new(Vec::new()) } +type BodyBounds = (std::sync::Arc, std::sync::Arc>); + /// The model's extent: bodies (from the cached boxes) and sketches. pub fn model_bounds(app: &SolveApp) -> solidcraft_engine::geom::Aabb3 { let st = app.session.world_state(); @@ -1812,6 +1813,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { app.viewport.mouse = hover.filter(|_| inside); // Ctrl or Alt held: drawing and dragging don't snap. crate::drag_snap::set_suppressed(mods.ctrl || mods.alt || mods.command); + middle_double_click(app, ui, inside); // ---- navigation ---- let trackpad_pan = app.preferences.trackpad_pan; @@ -2949,6 +2951,34 @@ fn signed_axis_view(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { } } +/// A middle-button double-click goes Home (or fits, or nothing: Preferences › Navigation). Only +/// clicks count: a press that moves (a pan or orbit drag) or is held is not one, and the two +/// clicks must both be the middle button's, close together in time and place. +fn middle_double_click(app: &mut SolveApp, ui: &egui::Ui, inside: bool) { + use egui::PointerButton::{Extra1, Extra2, Middle, Primary, Secondary}; + let (double, releases, middle, other, dragging) = ui.input(|i| { + ( + i.pointer.button_double_clicked(Middle), + i.events.iter().filter(|e| matches!(e, egui::Event::PointerButton { button: Middle, pressed: false, .. })).count(), + i.pointer.button_clicked(Middle), + [Primary, Secondary, Extra1, Extra2].into_iter().any(|b| i.pointer.button_clicked(b)), + i.pointer.is_decidedly_dragging(), + ) + }); + // egui pairs clicks of any buttons; the first click must have been the middle one too (or + // both arrive in this frame). + let go = double && inside && !other && (app.viewport.middle_clicked || releases >= 2); + app.viewport.middle_clicked = if other || dragging || go { false } else { middle || app.viewport.middle_clicked }; + if !go { + return; + } + match crate::prefs::MiddleDoubleClick::of(app) { + crate::prefs::MiddleDoubleClick::Home => app.animate_view("home"), + crate::prefs::MiddleDoubleClick::Fit => app.animate_fit_all(), + crate::prefs::MiddleDoubleClick::Off => {} + } +} + /// At a halfway tie, prefer the square heading nearer the standard plan view. fn nearest_quarter_turn(yaw: f64) -> f64 { if !yaw.is_finite() { @@ -4389,3 +4419,7 @@ mod tests { assert!(construction_axis_segments(&app).is_empty()); } } + +#[cfg(test)] +#[path = "view_home_tests.rs"] +mod view_home_tests; diff --git a/docs/book/src/guide/shortcuts.md b/docs/book/src/guide/shortcuts.md index 273a0201..afd86c8a 100644 --- a/docs/book/src/guide/shortcuts.md +++ b/docs/book/src/guide/shortcuts.md @@ -39,5 +39,6 @@ Mouse and touch: | Click again on the same spot | select the next thing under the cursor (click cycling) | | Hold the left button still (long-press on touch), or right-click › Select Other… | list everything under the cursor to pick from | | Double-click a sketch curve or edge | select its chain or tangent loop | +| Double-click the middle button | go Home: the home view, framing everything shown (Preferences › Navigation › Middle double-click: Home, Fit or Off) | Help › Keyboard Shortcuts in the app lists the current bindings, including any you changed. diff --git a/docs/book/src/guide/ui-tour.md b/docs/book/src/guide/ui-tour.md index a9988c0d..92e80cd1 100644 --- a/docs/book/src/guide/ui-tour.md +++ b/docs/book/src/guide/ui-tour.md @@ -33,7 +33,9 @@ The axis view shows the world's X/Y/Z directions in SolidCraft's colors. Click a signed axis tip to look along that axis; click the aligned tip again to look from the opposite side. Drag the axis view to orbit around the view centre, with Shift for snapping. Top and bottom - keep the nearest quarter-turn heading. The house and navigation bar's Home button go home. + keep the nearest quarter-turn heading. The house, the navigation bar's Home button and a middle-button double-click in the view go + home, framing what is shown now (Navigation › **Middle double-click** can make it Fit, which + keeps the orientation, or turn it off). Right-click for the marking menu: eight commands around the cursor, plus a menu for whatever is under it. Shaded views omit certified smooth joins between spline spans on extruded diff --git a/docs/control-protocol.md b/docs/control-protocol.md index ff1e9851..72428150 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -77,7 +77,7 @@ unit regardless of the design, pass an expression with one: `"value": "50 mm"` o | `ui.home` | `open?`: bool; `sample?`: index | the start page: show or hide it, open a built-in sample (once built); returns `open` and the recent designs | | `ui.shortcut` | `command`, `key?`, `replace?` | a command's key, or rebinds it (`""` clears; a key another command uses is refused unless `replace`) | | `ui.help` | `item?`: `about`, `shortcuts` (read only), `report` (copies diagnostics), `crashes` (the Crash Reports window) | the version, commit, build date and diagnostic text | -| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `tinkercad`, `openscad`), `zoom_reverse`, `zoom_to_cursor`, `smooth_wheel_zoom` (default true), `trackpad_pan` (default true: two-finger scroll pans, pinch zooms), `orbit_cursor`, `look_at_anim`, `look_at_speed`, `msaa`, `length_decimals`, `angle_decimals`, `theme` (`dark`, `light`, `system`), `language` (`en`, `ja`; see [localization](localization.md)), `ui_scale`, `up_axis` (`z`, `y`), `offer_stand_up`; `open` (a page: General, Navigation, Keyboard Shortcuts, Design, Graphics, Sketch, Selection) opens the Preferences dialog there; `button`: `ok`, `cancel`, `apply`, `defaults`; `stand_up: true` stands up a just-opened Y-up file | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `dark` and `stand_up_offer` | +| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `tinkercad`, `openscad`), `zoom_reverse`, `zoom_to_cursor`, `smooth_wheel_zoom` (default true), `trackpad_pan` (default true: two-finger scroll pans, pinch zooms), `orbit_cursor`, `look_at_anim`, `look_at_speed`, `middle_double_click` (`home` (default), `fit`, `off`), `msaa`, `length_decimals`, `angle_decimals`, `theme` (`dark`, `light`, `system`), `language` (`en`, `ja`; see [localization](localization.md)), `ui_scale`, `up_axis` (`z`, `y`), `offer_stand_up`; `open` (a page: General, Navigation, Keyboard Shortcuts, Design, Graphics, Sketch, Selection) opens the Preferences dialog there; `button`: `ok`, `cancel`, `apply`, `defaults`; `stand_up: true` stands up a just-opened Y-up file | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `dark` and `stand_up_offer` | | `ui.capture` | `action?`: `capture`, `revert`, `cancel` | answers the Capture Position question (asked when a command starts or the design is saved while components were moved without capturing); without `action`, what waits | | `ui.window` | `action`: minimize, maximize, restore, toggle, close | what the title bar's caption buttons do (on Windows and Linux the application bar is the title bar); close asks about unsaved designs first | | `ui.agent_cursor` | `show` (bool), `speed`: a number from 1 to 5 (times the normal speed) or `"instant"` (no animation: the command runs at once and the pointer jumps to where it acted), `follow_camera` (bool); all optional | the settings. While the cursor is shown, `engine.execute` first glides to the command's toolbar button and to each pick, then runs and replies; later requests wait their turn. `"animate": false` on a call skips it |