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));
}