Render preview uses the shared camera navigation (#1462)
* Render preview uses the shared camera navigation The Render workspace's product preview had its own hard-coded camera controls. Camera input now lives in one component, navigation::handle, which the modelling viewport and the Render preview both call (NavCaps: orbit, pick). Render gets the navigation preset, Orbit around, zoom to the cursor, the middle double-click and the axis view or navigation cube. Flat pages can call it with orbit off. A test rejects pages that read the wheel or move the camera themselves. Fixes #1454 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Render panel hint says the camera moves as in Design Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e8e2ccc8a9
commit
be985f136d
16 files changed
+969
-580
No files matched your search
@@ -470,3 +470,169 @@ fn delayed_image_export_refuses_an_internally_replaced_document_even_with_overwr
|
||||
assert!(matches!(&h.app.status, Some((_, _, true))));
|
||||
}
|
||||
}
|
||||
|
||||
/// One frame of either the modelling viewport or the Render preview, over the same rectangle.
|
||||
fn nav_frame(app: &mut crate::SolveApp, ctx: &egui::Context, render: bool, time: &mut f64, mods: egui::Modifiers, events: Vec<egui::Event>) {
|
||||
*time += 1.0 / 60.0;
|
||||
let events = std::iter::once(egui::Event::ModifiersChanged(mods)).chain(events).collect();
|
||||
let raw = egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))),
|
||||
time: Some(*time),
|
||||
focused: true,
|
||||
events,
|
||||
..Default::default()
|
||||
};
|
||||
let mut out = ctx.run_ui(raw, |ui| {
|
||||
if render {
|
||||
show(app, ui);
|
||||
} else {
|
||||
crate::viewport::show(app, ui);
|
||||
}
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
fn nav_app(render: bool, preset: &str, around: crate::prefs::OrbitAround) -> crate::SolveApp {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length":40,"width":30,"height":20,"body_name":"Product"})).unwrap();
|
||||
let mut app = crate::SolveApp::new(s, crate::Services::default());
|
||||
app.viewport.no_pixels = true;
|
||||
if render {
|
||||
enter(&mut app, true).unwrap();
|
||||
assert!(active(&app));
|
||||
}
|
||||
app.preferences.nav = preset.into();
|
||||
app.preferences.orbit_around = around;
|
||||
app.preferences.zoom_to_cursor = true;
|
||||
app.ui.perspective = true;
|
||||
let mut cam = Camera::default();
|
||||
cam.set_view(solidcraft_engine::render::StandardView::Iso);
|
||||
cam.fit(&solidcraft_engine::product_scene::extract(&app.session).unwrap().bounds);
|
||||
app.cam = cam;
|
||||
app
|
||||
}
|
||||
|
||||
/// #1454: the same mouse, wheel and modifier input moves the Render preview's camera exactly as
|
||||
/// it moves the modelling viewport's, under every navigation preset and Orbit around setting.
|
||||
#[test]
|
||||
fn render_preview_navigates_like_the_modelling_viewport_in_every_preset() {
|
||||
use egui::{Event, Modifiers, PointerButton, pos2, vec2};
|
||||
let none = Modifiers::NONE;
|
||||
let chords = [none, Modifiers::SHIFT, Modifiers::CTRL, Modifiers::ALT, Modifiers::SHIFT | Modifiers::ALT];
|
||||
let arounds = [crate::prefs::OrbitAround::ViewCentre, crate::prefs::OrbitAround::Cursor, crate::prefs::OrbitAround::Origin];
|
||||
for preset in crate::prefs::NAV_PRESETS.iter().map(|p| p.id) {
|
||||
for around in arounds {
|
||||
let (design_ctx, render_ctx) = (egui::Context::default(), egui::Context::default());
|
||||
let mut design = nav_app(false, preset, around);
|
||||
let mut render = nav_app(true, preset, around);
|
||||
let (mut design_time, mut render_time) = (0.0, 0.0);
|
||||
let mut both = |events: Vec<Event>, mods: Modifiers, design: &mut crate::SolveApp, render: &mut crate::SolveApp| {
|
||||
nav_frame(design, &design_ctx, false, &mut design_time, mods, events.clone());
|
||||
nav_frame(render, &render_ctx, true, &mut render_time, mods, events);
|
||||
};
|
||||
both(vec![], none, &mut design, &mut render);
|
||||
assert_eq!(design.viewport.rect, render.viewport.rect);
|
||||
// Presses start on empty space (the Render preview has nothing to pick), and the wheel
|
||||
// turns over the model, so zoom to the cursor anchors on its surface.
|
||||
let empty = pos2(90.0, 520.0);
|
||||
let proj = crate::viewport::projection(&design, design.viewport.rect.unwrap());
|
||||
assert!(crate::viewport::pick(&design, &proj, empty).is_empty());
|
||||
let model = proj.to_screen(design.cam.target).unwrap();
|
||||
assert!(!crate::viewport::pick(&design, &proj, model).is_empty());
|
||||
let mut moved = 0;
|
||||
for button in [PointerButton::Primary, PointerButton::Middle, PointerButton::Secondary] {
|
||||
for mods in chords {
|
||||
let before = design.cam;
|
||||
both(vec![Event::PointerMoved(empty)], mods, &mut design, &mut render);
|
||||
both(vec![Event::PointerButton { pos: empty, button, pressed: true, modifiers: mods }], mods, &mut design, &mut render);
|
||||
let mut at = empty;
|
||||
for _ in 0..5 {
|
||||
at += vec2(14.0, -9.0);
|
||||
both(vec![Event::PointerMoved(at)], mods, &mut design, &mut render);
|
||||
}
|
||||
both(vec![Event::PointerButton { pos: at, button, pressed: false, modifiers: mods }], mods, &mut design, &mut render);
|
||||
for _ in 0..30 {
|
||||
both(vec![], none, &mut design, &mut render);
|
||||
}
|
||||
assert_eq!(design.cam, render.cam, "{preset} {around:?}: {button:?} drag with {mods:?}");
|
||||
moved += usize::from(design.cam != before);
|
||||
// Let any snap settle and start the next gesture from the same view.
|
||||
design.cam = render.cam;
|
||||
}
|
||||
}
|
||||
assert!(moved >= 2, "{preset}: the preset's drags move the camera ({moved})");
|
||||
// Wheel over the model, then modifier-only motion (Touchpad: Shift pans, Alt orbits).
|
||||
let before = design.cam;
|
||||
both(vec![Event::PointerMoved(model)], none, &mut design, &mut render);
|
||||
let wheel = Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta: vec2(0.0, 3.0), modifiers: none, phase: egui::TouchPhase::Move };
|
||||
both(vec![wheel], none, &mut design, &mut render);
|
||||
for _ in 0..30 {
|
||||
both(vec![], none, &mut design, &mut render);
|
||||
}
|
||||
assert_ne!(design.cam, before, "{preset}: the wheel zooms");
|
||||
assert_eq!(design.cam, render.cam, "{preset} {around:?}: wheel zoom to the cursor");
|
||||
for mods in [Modifiers::SHIFT, Modifiers::ALT] {
|
||||
both(vec![Event::PointerMoved(empty)], mods, &mut design, &mut render);
|
||||
both(vec![Event::PointerMoved(empty + vec2(25.0, 15.0))], mods, &mut design, &mut render);
|
||||
both(vec![Event::PointerMoved(empty)], none, &mut design, &mut render);
|
||||
assert_eq!(design.cam, render.cam, "{preset} {around:?}: modifier motion with {mods:?}");
|
||||
}
|
||||
assert!(render.session.selection.is_empty(), "the Render preview never selects");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #1454: Fit (F6), a middle double-click, and the axis view's and navigation cube's Home and
|
||||
/// view controls all work in the Render workspace, framing the product scene.
|
||||
#[test]
|
||||
fn render_workspace_view_keys_double_middle_click_and_navigators_work() {
|
||||
for style in [crate::prefs::NavigatorStyle::Axes, crate::prefs::NavigatorStyle::Cube] {
|
||||
let mut h = Harness::new();
|
||||
h.app.preferences.navigator_style = style;
|
||||
h.app.preferences.look_at_anim = false;
|
||||
h.app.run("solid.box", json!({"length":40,"width":30,"height":20,"body_name":"Product"})).unwrap();
|
||||
enter(&mut h.app, true).unwrap();
|
||||
h.frames(3);
|
||||
let bounds = solidcraft_engine::product_scene::extract(&h.app.session).unwrap().bounds;
|
||||
let mut home = h.app.cam;
|
||||
home.set_view(solidcraft_engine::render::StandardView::Iso);
|
||||
home.fit(&bounds);
|
||||
let off = |h: &mut Harness| {
|
||||
h.app.cam.yaw += 0.7;
|
||||
h.app.cam.pitch = 0.1;
|
||||
h.app.cam.distance *= 3.0;
|
||||
h.app.cam.target.x += 25.0;
|
||||
h.frames(2);
|
||||
};
|
||||
// F6 fits, keeping the direction.
|
||||
off(&mut h);
|
||||
let mut fitted = h.app.cam;
|
||||
fitted.fit(&bounds);
|
||||
h.step(&json!({"key":"F6"})).unwrap();
|
||||
h.settle();
|
||||
assert_eq!(h.app.cam, fitted, "{style:?}: F6");
|
||||
// A middle double-click goes Home.
|
||||
off(&mut h);
|
||||
let centre = h.app.viewport.rect.unwrap().center();
|
||||
h.step(&json!({"click":[centre.x, centre.y], "double":true, "button":"middle"})).unwrap();
|
||||
h.settle();
|
||||
assert_eq!(h.app.cam, home, "{style:?}: middle double-click");
|
||||
// The navigator's Home control.
|
||||
off(&mut h);
|
||||
let handle = match style {
|
||||
crate::prefs::NavigatorStyle::Axes => "axis_view:home",
|
||||
crate::prefs::NavigatorStyle::Cube => "navigation_cube:control:home",
|
||||
};
|
||||
h.step(&json!({"click":{"handle":handle}})).unwrap();
|
||||
h.settle();
|
||||
assert!(
|
||||
(h.app.cam.yaw - home.yaw).abs() < 1e-6 && (h.app.cam.pitch - home.pitch).abs() < 1e-6,
|
||||
"{style:?}: navigator Home turns to the home view"
|
||||
);
|
||||
// A standard view from the navigator.
|
||||
h.step(&json!({"click":{"handle":"axis_view:z"}})).unwrap();
|
||||
h.settle();
|
||||
assert!(h.app.cam.back().dot(solidcraft_engine::geom::Vec3::Z) > 0.999, "{style:?}: view from +Z, got {:?}", h.app.cam.back());
|
||||
assert!(h.app.session.selection.is_empty());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user