Viewport: an opened design is framed at the home view, in the middle of the view (#9195) (#9204)

* claim: #9195

* Viewport: an opened design is framed at the home view, in the middle of the view (#9195)

A fit the viewport cannot frame yet is kept and asked again on the next frame instead
of being consumed and lost, so a design opened from the Home screen or by launching on
a file no longer comes up at the default camera, wherever its own coordinates put it.

Framing then centres the model on the view rather than on the free rectangle between
the floating controls: `Layout` carries `middle`, the view without the panels, computed
before the compass, the navigation bar and the OK/Cancel corner join the obstacles. The
model is still sized to `drawable`, so it stays clear of every control, but only a panel
moves it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Viewport: keep the centred fit clear of the controls, and frame --sample (#9195)

The fit framed the model into a rectangle the size of `drawable` centred on `middle`, which is
not inside `drawable`, so the model could sit under the compass, the navigation bar or the
render product frame. The model is now sized to `drawable` as before, and only the room left
over in it moves the model towards the middle of the view.

`s27_move_triad` turns the Z ring on its far side: the model now sits further right, and the
Move dialog that docks on the right afterwards covered the ring's old grab point.

A launch with `--sample` builds the design straight into the session and never asked for a
fit; it now comes up at the home view, framed, like an opened design.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Viewport tests: the cursor orbit's pivot keeps the pixel it was picked on (#9195)

With the model now centred on the view, one of the test's perspective drag points lands within
the pick tolerance of the box's top edge, and the pick snaps the pivot onto that edge, a few
pixels from the cursor. The orbit keeps that point still, as it should; the test compared it
with the cursor instead of with where it started.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
N3MI-DG
2026-10-30 09:14:18 +00:00
committed by GitHub
parent 7e22aed739
commit 63923da2ca
8 changed files with 222 additions and 15 deletions
+4
View File
@@ -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.
+78 -2
View File
@@ -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<Event>) -> Vec<ViewportCommand> {
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);
}
+49 -6
View File
@@ -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<Camera> {
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();
}
+10
View File
@@ -135,6 +135,16 @@ pub(crate) fn start(app: &mut SolveApp, remember: Option<String>) {
}
}
/// 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()))
+5 -1
View File
@@ -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:?}");
}
}
}
+31 -5
View File
@@ -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 {
@@ -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");
}
}
@@ -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"}