diff --git a/apps/solidcraft/src/main.rs b/apps/solidcraft/src/main.rs index f2b9f4ef27..505f1518e0 100644 --- a/apps/solidcraft/src/main.rs +++ b/apps/solidcraft/src/main.rs @@ -1016,6 +1016,10 @@ fn main() -> eframe::Result { app.session.doc_mut().name = "Sample Plate".into(); app.session.mark_saved(); app.session.undo.clear(); + // Built straight into the session, so nothing asked for a fit: frame it at the + // home view as any opened design is (#9195). The fit waits for the first layout. + app.cam.set_view(solidcraft_engine::render::StandardView::Iso); + app.fit_view(); } // Lock files next to open designs: a design open on another computer says so (#5467), // the files given at launch included. diff --git a/crates/ui-egui/src/file_load_ui_tests.rs b/crates/ui-egui/src/file_load_ui_tests.rs index 9dd6e22003..ad301a0506 100644 --- a/crates/ui-egui/src/file_load_ui_tests.rs +++ b/crates/ui-egui/src/file_load_ui_tests.rs @@ -12,13 +12,15 @@ struct Win { ctx: egui::Context, app: SolveApp, time: f64, + /// The window's size in points; a test that changes it moves the viewport's layout. + size: egui::Vec2, } impl Win { fn new() -> Self { let mut app = SolveApp::new(Session::default(), Default::default()); app.custom_titlebar = true; - let mut w = Win { ctx: crate::fonts::test_context(), app, time: 0.0 }; + let mut w = Win { ctx: crate::fonts::test_context(), app, time: 0.0, size: vec2(1600.0, 1000.0) }; for _ in 0..3 { w.frame(vec![]); } @@ -29,7 +31,7 @@ impl Win { fn frame(&mut self, events: Vec) -> Vec { self.time += 1.0 / 60.0; let raw = RawInput { - screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 1000.0))), + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, self.size)), time: Some(self.time), focused: true, events, @@ -673,3 +675,77 @@ fn cancelling_a_step_open_releases_the_slot_before_the_worker_returns() { assert_eq!(fingerprint(&w.app.session), fingerprint(&expected)); let _ = std::fs::remove_dir_all(&dir); } + +/// An opened design comes up at the home view, framed on its own model: the angle the design +/// before it was left at doesn't carry over, and a design opened while the Home screen is over +/// the viewport is framed when the viewport is drawn, not against the layout of what was there +/// before (which left the design wherever the camera happened to be). +#[test] +fn an_opened_design_comes_up_at_the_home_view() { + use solidcraft_engine::render::StandardView; + let dir = scratch("home-view"); + let path = large_design(&dir, 4); + let home_angles = { + let mut cam = solidcraft_engine::render::Camera::default(); + cam.set_view(StandardView::Iso); + (cam.yaw, cam.pitch, cam.roll) + }; + let mut w = Win::new(); + // The view the design before it was left at: straight down, zoomed right in. + w.app.cam.set_view(StandardView::Top); + w.app.cam.distance = 12.0; + w.app.cam.target = solidcraft_engine::geom::Vec3::new(50.0, -70.0, 3.0); + // Opened from the Home screen, which covers the viewport while the design goes in: the + // viewport is not drawn, so the layout a fit would be framed into is the one left over from + // what was there before, which cannot frame anything. + let revision = w.app.session.revision; + w.app.home.show(revision); + let stale = Rect::from_min_size(Pos2::ZERO, vec2(24.0, 24.0)); + w.app.open_path_async(&path); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(120); + while w.app.file_load.busy() { + w.app.viewport.rect = Some(stale); + w.frame(vec![]); + assert!(std::time::Instant::now() < deadline, "the open finishes"); + std::thread::yield_now(); + } + // The design is meshed and framed while the Home screen is still over the viewport, as it is + // for a design opened from it: nothing draws the viewport in between, so nothing frames the + // design again afterwards. + while crate::open_meshing::active(&w.app) { + w.app.viewport.rect = Some(stale); + w.frame(vec![]); + assert!(std::time::Instant::now() < deadline, "the meshing finishes"); + std::thread::yield_now(); + } + for _ in 0..4 { + w.app.viewport.rect = Some(stale); + w.frame(vec![]); + } + // The Home screen closes and the viewport is drawn for the first time since the open. + w.app.home.open = false; + for _ in 0..4 { + w.frame(vec![]); + } + assert_eq!((w.app.cam.yaw, w.app.cam.pitch, w.app.cam.roll), home_angles, "opened at the home view"); + // Framed on its model, as a fit asked for now frames it: not left at the default camera. + let opened = w.app.cam; + w.app.fit_view(); + let refitted = w.app.cam; + assert!((refitted.target - opened.target).len() < 1e-6, "framed on the model: {opened:?} vs {refitted:?}"); + assert!((refitted.distance - opened.distance).abs() < 1e-6, "framed on the model: {opened:?} vs {refitted:?}"); + // A synchronous open (the control channel's) into a blank design, which keeps its tab and so + // its camera, comes up at the home view too. + let other = scratch("home-view-sync"); + let second = large_design(&other, 2); + crate::documents::new_design(&mut w.app); + w.frame(vec![]); + w.app.cam.set_view(StandardView::Front); + w.app.open_path(&second); + for _ in 0..4 { + w.frame(vec![]); + } + assert_eq!((w.app.cam.yaw, w.app.cam.pitch, w.app.cam.roll), home_angles, "a synchronous open too"); + let _ = std::fs::remove_dir_all(&other); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c68cc8dd69..c42ccfd941 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -658,6 +658,8 @@ pub struct SolveApp { styled: bool, fitted: bool, pending_fit: Option<(Camera, solidcraft_engine::geom::Aabb3, bool)>, + /// Frames a pending fit has waited for a layout that can frame it (`resolve_fit`). + fit_waits: u32, /// The sheet metal flat pattern's own pan and zoom. pub flat_view: dialogs_sheet::FlatView, /// The start page. @@ -813,6 +815,7 @@ impl SolveApp { styled: false, fitted: false, pending_fit: None, + fit_waits: 0, flat_view: Default::default(), embedded: false, home: home::HomeState::default(), @@ -2141,6 +2144,9 @@ impl SolveApp { self.home.open = false; documents::prepare_open(self); if let Ok(r) = self.run("doc.open", json!({ "path": path })) { + // An opened design comes up at the home view, not at the angle the design before it + // was left at: `prepare_open` keeps the camera when it reuses a blank design. + self.cam.set_view(StandardView::Iso); self.fit_view(); stand_up::offer(self, path, &[], Some(&r)); } @@ -2191,6 +2197,10 @@ impl SolveApp { self.fit_view(); } + /// How many frames a fit waits for a viewport that can frame it before it gives up (about + /// two seconds): a window that never draws its viewport must not ask for frames forever. + const FIT_WAITS: u32 = 120; + pub fn fit_view(&mut self) { if render_workspace::result_active(self) { render_workspace::fit_result(self); @@ -2213,6 +2223,7 @@ impl SolveApp { self.viewport.orbit_free = None; let bounds = viewport::view_bounds(self); self.pending_fit = Some((self.cam, bounds, false)); + self.fit_waits = 0; self.cam = self.fitted(self.cam); } @@ -2221,6 +2232,7 @@ impl SolveApp { self.cancel_view_animation(); self.viewport.orbit_free = None; self.pending_fit = Some((self.cam, bounds, false)); + self.fit_waits = 0; self.cam = self.fitted_bounds(self.cam, &bounds); } @@ -2236,12 +2248,20 @@ impl SolveApp { cam } + /// `cam` framing `bounds` in the viewport as it is laid out now, or `None` when this layout + /// can't frame it: the viewport has not been drawn since the fit was asked for, so the + /// rectangle to frame into is the one from before the Home screen or a dialog covered it. + fn try_fitted_bounds(&self, cam: Camera, bounds: &solidcraft_engine::geom::Aabb3) -> Option { + let layout = self.viewport.layout.as_ref()?; + if render_workspace::active(self) { + return viewport_layout::fit_product_bounds(cam, bounds, layout); + } + viewport_layout::fit_bounds(cam, bounds, layout, self.view_fov()) + } + fn fitted_bounds(&self, mut cam: Camera, bounds: &solidcraft_engine::geom::Aabb3) -> Camera { - if let Some(layout) = &self.viewport.layout { - if render_workspace::active(self) { - return viewport_layout::fit_product_bounds(cam, bounds, layout).unwrap_or(cam); - } - return viewport_layout::fit_bounds(cam, bounds, layout, self.view_fov()).unwrap_or(cam); + if self.viewport.layout.is_some() { + return self.try_fitted_bounds(cam, bounds).unwrap_or(cam); } // Framed at the angle the view draws with, keeping the camera's own. let previous = cam; @@ -2256,12 +2276,35 @@ impl SolveApp { if self.viewport.layout.is_some() && let Some((cam, bounds, animate)) = self.pending_fit.take() { - let to = if bounds.is_empty() { self.fitted(cam) } else { self.fitted_bounds(cam, &bounds) }; + // Nothing to frame: the camera goes to the empty design's own distance. + let want = if bounds.is_empty() { viewport::view_bounds(self) } else { bounds }; + let to = if want.is_empty() { Some(self.fitted(cam)) } else { self.try_fitted_bounds(cam, &want) }; + let Some(to) = to else { + // The viewport hasn't been drawn since the fit was asked for (the Home screen was + // over it, as when a sample or a file is opened from it), so framing it now would + // frame it into the rectangle of whatever was there before and leave the design + // wherever the camera happened to be. Keep the request and frame it when the + // viewport is drawn; give up only if that never comes. + if self.fit_waits < Self::FIT_WAITS { + self.fit_waits += 1; + self.pending_fit = Some((cam, bounds, animate)); + } else { + self.fit_waits = 0; + self.cam = self.fitted_bounds(cam, &want); + } + ctx.request_repaint(); + return; + }; + self.fit_waits = 0; if animate { self.animate_to(to); } else { self.cam = to; self.cam_anim = None; + // An open's framing settles here, once the layout is known. An open still meshing + // takes this as the camera it framed from the outlines, so the framing it does + // when the last body is ready isn't mistaken for the user having moved the view. + open_meshing::note_framed(self); } ctx.request_repaint(); } diff --git a/crates/ui-egui/src/open_meshing.rs b/crates/ui-egui/src/open_meshing.rs index 420fae03a3..9f96b91923 100644 --- a/crates/ui-egui/src/open_meshing.rs +++ b/crates/ui-egui/src/open_meshing.rs @@ -135,6 +135,16 @@ pub(crate) fn start(app: &mut SolveApp, remember: Option) { } } +/// Take the camera as the one the open framed, after a fit the app itself resolved (the view is +/// framed again as the design goes in, once the layout is known): `tick` frames the meshes only +/// while the camera is still the one it framed, which is how the user taking the view over keeps +/// their own framing. +pub(crate) fn note_framed(app: &mut SolveApp) { + if let Some(m) = app.open_meshing.as_mut().filter(|m| !m.complete && m.framed.is_some()) { + m.framed = Some(app.cam); + } +} + /// How far the meshing is: bodies done, and of how many. pub(crate) fn progress(app: &SolveApp) -> Option<(usize, usize)> { app.open_meshing.as_ref().filter(|m| !m.complete).map(|m| (m.done.load(Ordering::Acquire).min(m.world.bodies.len()), m.world.bodies.len())) diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 204597b895..312c0f4419 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -10519,11 +10519,15 @@ mod tests { let proj = projection(&h.app, rect); assert!(pick(&h.app, &proj, at).iter().any(|hit| matches!(hit, Hit::Face { .. })), "the drag starts on the box"); let pivot = crate::prefs::pivot_under(&h.app, &proj, at); + // Near an edge the pick snaps the pivot onto it, a few pixels from the cursor: + // the pivot keeps the pixel it was picked on, wherever that is. + let start = proj.to_screen(pivot).unwrap(); + assert!(start.distance(at) < 8.0, "perspective={perspective}: picked under the cursor: {at:?} -> {start:?}"); let before = h.app.cam; h.call("ui.drag", json!({"x0":at.x,"y0":at.y,"x1":at.x+150.0,"y1":at.y+60.0,"button":"middle","shift":true,"steps":20})); assert!((h.app.cam.yaw - before.yaw).abs() > 0.5, "the view turned"); let after = projection(&h.app, rect).to_screen(pivot).unwrap(); - assert!(after.distance(at) < 0.5, "perspective={perspective}: {at:?} -> {after:?}"); + assert!(after.distance(start) < 0.5, "perspective={perspective}: {start:?} -> {after:?}"); } } } diff --git a/crates/ui-egui/src/viewport_layout.rs b/crates/ui-egui/src/viewport_layout.rs index b8b77c8cc1..a9f9923b8d 100644 --- a/crates/ui-egui/src/viewport_layout.rs +++ b/crates/ui-egui/src/viewport_layout.rs @@ -7,6 +7,10 @@ use solidcraft_engine::render::Camera; pub(crate) struct Layout { pub viewport: Rect, pub drawable: Rect, + /// The view without the panels (Browser, Assistant, workspace panels), but still counting + /// the floating controls as drawn over it: where a fit puts the middle of the model. The + /// compass and the navigation bar take room out of `drawable` without moving this. + pub middle: Rect, pub content: Rect, pub axis_view: Rect, pub navigation: Rect, @@ -161,13 +165,18 @@ impl Layout { Rect::NOTHING }; } + let within = if fit_in_content { content } else { viewport }; + // Only the panels decide where the middle of the view is; the controls below are drawn + // over the view and would otherwise push the model into a corner of it. + let middle = free_rect(within, &obstacles); + let middle = if middle.is_positive() && middle.is_finite() { middle } else { within }; for control in [axis_view, navigation, confirm] { if control.is_positive() { obstacles.push(control); } } - let drawable = free_rect(if fit_in_content { content } else { viewport }, &obstacles).shrink(16.0 / scale); - Self { viewport, drawable, content, axis_view, navigation, confirm } + let drawable = free_rect(within, &obstacles).shrink(16.0 / scale); + Self { viewport, drawable, middle, content, axis_view, navigation, confirm } } } @@ -211,8 +220,25 @@ pub(crate) fn fit_bounds(mut cam: Camera, bounds: &Aabb3, layout: &Layout, fov: depth = depth.max(d.dot(b).abs()); } let h = f64::from(layout.viewport.height()); - let hh = (hx * h / f64::from(layout.drawable.width())).max(hy * h / f64::from(layout.drawable.height())).max(0.5); - let offset = layout.drawable.center() - layout.viewport.center(); + let drawable = layout.drawable; + let hh = (hx * h / f64::from(drawable.width())).max(hy * h / f64::from(drawable.height())).max(0.5); + // Home and Fit put the model in the middle of the view. `drawable` decides its size and keeps + // it clear of every control; the room it leaves over in either direction moves the model + // towards `middle`, the middle of the view, instead of the middle of the gap between the + // compass and the navigation bar, which left it low and to one side of an otherwise empty view. + let size = vec2((hx * h / hh) as f32, (hy * h / hh) as f32).min(drawable.size()); + let toward = |want: f32, lo: f32, hi: f32| if lo <= hi { want.clamp(lo, hi) } else { (lo + hi) * 0.5 }; + let at = pos2( + toward(layout.middle.center().x, drawable.left() + size.x * 0.5, drawable.right() - size.x * 0.5), + toward(layout.middle.center().y, drawable.top() + size.y * 0.5, drawable.bottom() - size.y * 0.5), + ); + let half = vec2((at.x - drawable.left()).min(drawable.right() - at.x), (at.y - drawable.top()).min(drawable.bottom() - at.y)); + let room = Rect::from_center_size(at, half * 2.0); + if !room.is_positive() || !room.is_finite() { + return None; + } + let hh = (hx * h / f64::from(room.width())).max(hy * h / f64::from(room.height())).max(0.5); + let offset = room.center() - layout.viewport.center(); let tangent = if perspective { (fov * 0.5).tan() } else { 0.4 }; cam.distance = hh / tangent + if perspective { depth } else { 0.0 }; // Offset the target so the model projects into the free rectangle in the original viewport. @@ -226,7 +252,7 @@ pub(crate) fn fit_bounds(mut cam: Camera, bounds: &Aabb3, layout: &Layout, fov: x.is_finite() && y.is_finite() && (0.0..=1.0).contains(&z) - && layout.drawable.contains(pos2(layout.viewport.left() + x as f32, layout.viewport.top() + y as f32)) + && room.contains(pos2(layout.viewport.left() + x as f32, layout.viewport.top() + y as f32)) }) }); if fits { diff --git a/crates/ui-egui/src/viewport_layout_tests.rs b/crates/ui-egui/src/viewport_layout_tests.rs index c9efd7937a..ca8fe4ea7a 100644 --- a/crates/ui-egui/src/viewport_layout_tests.rs +++ b/crates/ui-egui/src/viewport_layout_tests.rs @@ -555,3 +555,47 @@ fn escape_ends_navigation_before_sketch_tools_and_does_not_count_as_a_finish_pre h.step(&json!({"key":"Escape"})).unwrap(); assert!(h.app.session.active_sketch.is_none(), "two ordinary quick Escapes still finish the sketch"); } + +/// Home and Fit put the model in the middle of the view. The floating controls take room out of +/// it, so the model is drawn smaller and kept clear of them, but the room they leave over moves it +/// towards the middle of the view rather than the middle of the gap between them; a panel moves +/// the middle itself (#9195). +#[test] +fn a_fit_centres_the_model_in_the_view_and_is_moved_only_by_panels() { + let viewport = Rect::from_min_size(pos2(0.0, 0.0), vec2(1600.0, 1000.0)); + let bounds = Aabb3 { min: Vec3::new(-50.0, -50.0, -50.0), max: Vec3::new(50.0, 50.0, 50.0) }; + let centre = bounds.center(); + let on_screen = |cam: Camera, p: Vec3| { + let radius = (bounds.diagonal() * 0.5 + centre.dist(cam.target)).max(cam.half_height() * 2.0).max(10.0); + let (x, y, _) = cam.to_screen(p, f64::from(viewport.width()), f64::from(viewport.height()), radius).unwrap(); + pos2(viewport.left() + x as f32, viewport.top() + y as f32) + }; + for style in [crate::prefs::NavigatorStyle::Axes, crate::prefs::NavigatorStyle::Cube] { + // The compass and the navigation bar, and nothing else. + let bare = layout(viewport, &[], 1.0, style, true); + let cam = fit_bounds(Camera::default(), &bounds, &bare, 0.0).expect("framed"); + for x in [bounds.min.x, bounds.max.x] { + for y in [bounds.min.y, bounds.max.y] { + for z in [bounds.min.z, bounds.max.z] { + let p = on_screen(cam, Vec3::new(x, y, z)); + assert!(bare.drawable.contains(p), "{style:?}: the model is clear of the controls: {p:?} {bare:?}"); + } + } + } + let middle = bare.middle.center(); + let gap = bare.drawable.center().distance(middle); + let off = on_screen(cam, centre).distance(middle); + assert!(off + 1.0 < gap, "{style:?}: nearer the middle of the view than the middle of the gap: {off} vs {gap}"); + // A panel down the left takes that part of the view away, so the middle moves right: the + // camera's target moves the other way, against its right vector. + let panel = Rect::from_min_size(pos2(0.0, 0.0), vec2(320.0, 1000.0)); + let docked = layout(viewport, &[panel], 1.0, style, true); + let moved = fit_bounds(Camera::default(), &bounds, &docked, 0.0).expect("framed"); + let right = moved.basis().0; + assert!((moved.target - centre).dot(right) < -1.0, "{style:?}: a panel must move the model clear of it: {:?}", moved.target); + // It is drawn smaller with the controls shown than with the whole view to itself. + let empty = Layout::with_content(viewport, viewport, &[], 1.0, style, egui::Vec2::ZERO, Rect::NOTHING); + let roomy = fit_bounds(Camera::default(), &bounds, &empty, 0.0).expect("framed"); + assert!(roomy.distance <= cam.distance + 1e-9, "{style:?}: controls must not make the model larger"); + } +} diff --git a/crates/ui-egui/tests/scenarios/s27_move_triad.json b/crates/ui-egui/tests/scenarios/s27_move_triad.json index 195a6cf05d..c837164943 100644 --- a/crates/ui-egui/tests/scenarios/s27_move_triad.json +++ b/crates/ui-egui/tests/scenarios/s27_move_triad.json @@ -13,7 +13,7 @@ {"expect": {"errors": 0, "dialog": null, "bbox": {"body": "Body1", "min": [20, 0, 0], "max": [40, 30, 10]}}}, {"start": "solid.move"}, {"click": {"world": [30, 15, 10]}}, - {"drag": [{"gizmo": "rz", "turn": 60}, {"gizmo": "rz", "turn": 150}], "steps": 12}, + {"drag": [{"gizmo": "rz", "turn": 200}, {"gizmo": "rz", "turn": 290}], "steps": 12}, {"key": "Enter"}, {"expect": {"errors": 0, "dialog": null, "bbox": {"body": "Body1", "min": [15, 5, 0], "max": [45, 25, 10]}}}, {"shot": "s27_move_triad"}