Home and Fit frame what is shown now; middle double-click goes Home (#701)

Home, Fit, the orbit pivot and the scene's depth range counted sketches the view doesn't draw (ones used by an extrude or revolve, ones in hidden components), so after a body moved away from its sketch Home framed the empty spot it came from. They now use the world-space bounds of what is shown now (view::visible_bounds / view::sketch_drawn, viewport::view_bounds). The pick-box cache holds its world state instead of its address. A middle-button double-click goes Home in every preset (Preferences › Navigation: Home, Fit or Off).

Fixes #689

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bherbruckandClaude Opus 5.5 authored and GitHub committed 2026-10-13 09:33:55 -04:00
1 parent 7b34d57b25
commit 7249623855
11 files changed
+474 -31

No files matched your search

+2
View File
@@ -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`]).
+47 -5
View File
@@ -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?
+162
View File
@@ -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<String> = 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));
}
+1 -1
View File
@@ -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),
+13 -4
View File
@@ -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) {
+49 -2
View File
@@ -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<MiddleDoubleClick> {
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<serde_json::Valu
if !(0.5..=2.5).contains(&new.ui_scale) || !(0.25..=4.0).contains(&new.look_at_speed) {
return Err("`ui_scale` must be 0.5…2.5 and `look_at_speed` 0.25…4".into());
}
if MiddleDoubleClick::parse(&new.middle_double_click).is_none() {
return Err("`middle_double_click` must be home, fit or off".into());
}
if new.theme != app.preferences.theme && new.theme != "system" {
app.ui.dark = new.theme == "dark";
}
@@ -1433,7 +1480,7 @@ mod tests {
let hits = crate::viewport::pick(&app, &proj, corner);
use crate::viewport::Hit;
assert!(!hits.iter().any(|h| matches!(h, Hit::Face { .. } | Hit::Edge { .. } | Hit::Vertex { .. })), "the corner is empty space");
let centre = solidcraft_engine::view::bounds(&app.session).center();
let centre = crate::viewport::view_bounds(&app).center();
orbit(&mut app, rect, 40.0, 25.0, Some(corner));
assert_eq!(app.viewport.orbit_pivot, Some(centre));
+144
View File
@@ -0,0 +1,144 @@
// Home and Fit frame what is shown now, and a middle-button double-click goes Home.
use crate::scenario::Harness;
use serde_json::json;
use solidcraft_engine::geom::{Aabb3, Vec3};
use solidcraft_engine::render::StandardView;
/// A block drawn at the origin and extruded, then moved 500 mm along X: its sketch stays at the
/// origin, used by the extrude (so not drawn).
fn moved_block(h: &mut Harness) {
h.app.run("sketch.create", json!({"plane": "XY"})).unwrap();
h.app.run("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]})).unwrap();
h.app.run("sketch.finish", json!({})).unwrap();
h.app.run("solid.extrude", json!({"distance": 5})).unwrap();
let body = h.app.session.world_state().bodies[0].name.clone();
h.app.run("solid.move", json!({"bodies": [body], "translate": [500, 0, 0]})).unwrap();
h.app.preferences.look_at_anim = false;
h.settle();
}
fn near_box(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:?}");
}
/// The camera Home gives: the iso view framing what is shown.
fn home(h: &Harness) -> 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");
}
+51 -17
View File
@@ -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<Option<(usize, std::sync::Arc<Vec<(String, solidcraft_engine::geom::Aabb3)>>)>>,
/// 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<Option<BodyBounds>>,
/// 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<Hit>)>,
pub rect: Option<Rect>,
@@ -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<u64> {
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<solidcraft_engine::doc::ModelState>,
) -> std::sync::Arc<Vec<(String, solidcraft_engine::geom::Aabb3)>> {
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::<Vec<_>>());
*g = Some((key, b.clone()));
*g = Some((st.clone(), b.clone()));
return b;
}
std::sync::Arc::new(Vec::new())
}
type BodyBounds = (std::sync::Arc<solidcraft_engine::doc::ModelState>, std::sync::Arc<Vec<(String, solidcraft_engine::geom::Aabb3)>>);
/// 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;
+1
View File
@@ -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.
+3 -1
View File
@@ -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
+1 -1
View File
@@ -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 |