Files
OneCAD/crates/ui-egui/src/navigation.rs
T
bherbruckandClaude Opus 5.5 be985f136d 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>
2026-10-15 13:48:05 -04:00

744 lines
35 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Camera navigation shared by every page that shows the model through a camera: the modelling
//! viewport and the Render preview today, and any future canvas (a 2D drawing or flat pattern
//! page calls it with orbit off, so pan and zoom still follow the user's bindings).
//!
//! One entry point, [`handle`], reads a frame's pointer, wheel, trackpad and modifier input over a
//! view and moves `app.cam` the way Preferences › Navigation says: the navigation preset (Fusion,
//! SolidWorks, Blender, Touchpad…), Orbit around, zoom to the cursor, the middle double-click and
//! orbit snapping. The SpaceMouse, view keys and the navigators act on `app.cam` independently of
//! the page. New canvases must call `navigation::handle` rather than reading drags or the wheel
//! themselves; `hand_rolled_camera_input_is_rejected` fails when they do.
use egui::{Pos2, Rect, pos2, vec2};
use serde_json::json;
use solidcraft_engine::geom::Vec3;
use solidcraft_engine::render::Camera;
use crate::SolveApp;
use crate::prefs::{OrbitAround, pivot_under};
use crate::viewport::{Hit, NavMode, axis_rect, nearest_quarter_turn, pick, pick_cached, projection};
/// What a page's camera may do.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct NavCaps {
/// Orbit (and continuous orbit) is allowed; off for flat 2D pages, where pan and zoom remain.
pub orbit: bool,
/// What is under the pointer can be picked or dragged, so presses on it don't navigate the
/// way presses on empty space do (OpenSCAD's left drag, Ctrl+left orbit).
pub pick: bool,
}
impl NavCaps {
/// The modelling viewport.
pub const MODEL: NavCaps = NavCaps { orbit: true, pick: true };
/// The Render workspace's product preview: the same navigation, nothing to select.
pub const RENDER: NavCaps = NavCaps { orbit: true, pick: false };
}
/// Raw wheel/trackpad input for one frame, split by source.
///
/// Mouse wheels report lines or pages; trackpads report points. Trackpad
/// scrolls arrive bracketed (`Start` … `Move` … `End`, momentum included)
/// while mouse wheels send bare `Move` events, so phases tell them apart.
pub(crate) struct RawWheel {
/// Vertical mouse-wheel input in points (zooms).
zoom: f32,
/// Trackpad point input, routed like the wheel (zooms when panning is off).
point_zoom: f32,
/// Bracketed two-finger scroll in points (pans when panning is on).
pan: egui::Vec2,
/// Bare horizontal input in points (thumb wheel: always pans sideways).
mouse_pan: egui::Vec2,
}
/// Route wheel points to the vertical axis, matching egui's scroll modifiers.
pub(crate) fn route_wheel_vertical(points: egui::Vec2, modifiers: egui::Modifiers, options: egui::InputOptions) -> f32 {
let horizontal = modifiers.matches_any(options.horizontal_scroll_modifier);
let vertical = modifiers.matches_any(options.vertical_scroll_modifier);
if horizontal && !vertical {
0.0
} else if vertical && !horizontal {
points.x + points.y
} else {
points.y
}
}
/// Match egui's wheel units and modifier routing, without its temporal smoothing.
///
/// `gesture` tracks an ongoing bracketed trackpad scroll (`Start` … `End`):
/// moves inside one pan, bare `Move` input is a mouse (vertical zooms,
/// horizontal pans). Only point input brackets; line/page input always zooms.
pub(crate) fn immediate_wheel(input: &egui::InputState, options: egui::InputOptions, page_height: f32, gesture: &mut bool) -> RawWheel {
let mut out = RawWheel { zoom: 0.0, point_zoom: 0.0, pan: egui::Vec2::ZERO, mouse_pan: egui::Vec2::ZERO };
for event in &input.events {
let egui::Event::MouseWheel { unit, delta, modifiers, phase } = event else { continue };
if modifiers.matches_any(options.zoom_modifier) {
continue;
}
let point = matches!(unit, egui::MouseWheelUnit::Point);
match phase {
egui::TouchPhase::Start => {
if point {
*gesture = true;
}
continue;
}
egui::TouchPhase::End | egui::TouchPhase::Cancel => {
if point {
*gesture = false;
}
continue;
}
egui::TouchPhase::Move => {}
}
let points = match unit {
egui::MouseWheelUnit::Point => *delta,
egui::MouseWheelUnit::Line => *delta * options.line_scroll_speed,
egui::MouseWheelUnit::Page => *delta * page_height,
};
if point && *gesture {
out.pan += points;
// Legacy path only (ignored while panning is on).
out.point_zoom += route_wheel_vertical(points, *modifiers, options);
} else if point {
// Bare moves are a mouse: vertical zooms, horizontal pans sideways.
// A wheel needs the opposite sign of fingers for the same motion.
out.zoom += route_wheel_vertical(points, *modifiers, options);
out.mouse_pan += egui::vec2(-points.x, 0.0);
} else {
out.zoom += route_wheel_vertical(points, *modifiers, options);
}
}
out
}
/// Combined pinch-to-zoom factor for one frame (1 = no pinch input).
pub(crate) fn pinch_factor(input: &egui::InputState) -> f32 {
input
.events
.iter()
.filter_map(|event| {
let egui::Event::Zoom(f) = event else { return None };
(*f).is_finite().then_some(*f).filter(|f| *f > 0.0)
})
.fold(1.0, |acc, f| acc * f)
}
/// Zoom anchor: the model point under the cursor, else the target's depth.
/// Preferences › Navigation › Zoom to the cursor (off: toward the view's centre).
pub(crate) fn zoom_anchor(app: &mut SolveApp, rect: egui::Rect, hover: Option<egui::Pos2>) -> Option<Vec3> {
hover.filter(|_| app.preferences.zoom_to_cursor).map(|p| {
let proj = projection(app, rect);
// Toward the model's surface under the cursor, else the target's depth.
if let Some(q) = pick_cached(app, &proj, p).iter().find_map(|h| if let Hit::Face { point, .. } = h { Some(*point) } else { None }) {
return q;
}
let (o, d) = proj.ray(p);
let n = app.cam.back();
let den = d.dot(n);
if den.abs() > 1e-9 { o + d * ((app.cam.target - o).dot(n) / den) } else { app.cam.target }
})
}
/// Modifier-only navigation never borrows pointer movement from another widget or gesture.
pub(crate) fn modifier_motion(
app: &mut SolveApp,
ui: &egui::Ui,
inside: bool,
hover: Option<Pos2>,
mods: egui::Modifiers,
picking: bool,
) -> Option<(NavMode, egui::Vec2)> {
let owns = inside
&& ui.input(|i| i.focused && !i.pointer.any_down() && !i.pointer.any_released() && !i.pointer.any_pressed())
&& !ui.ctx().egui_wants_keyboard_input()
&& !egui::Popup::is_any_open(ui.ctx())
&& !crate::dim_view::editing()
&& app.viewport.context_menu.is_none()
&& app.viewport.axis_drag.is_none()
&& app.viewport.point_drag.is_none()
&& app.viewport.text_drag.is_none()
&& app.viewport.boxsel.is_none()
&& !(picking && hover.is_some_and(|p| app.viewport.rect.is_some_and(|rect| crate::canvas::owns_pointer(app, &projection(app, rect), p))))
&& !hover.is_some_and(|p| app.viewport.layout.as_ref().is_some_and(|l| l.navigation.contains(p)));
let next =
hover.filter(|p| owns && p.is_finite()).and_then(|p| crate::prefs::nav_preset(&app.preferences.nav).motion_mode(mods).map(|mode| (mode, p)));
let previous = app.viewport.modifier_nav;
if previous.map(|(m, _)| m) != next.map(|(m, _)| m) {
app.viewport.orbit_pivot = None;
app.viewport.orbit_free = None;
app.viewport.orbit_snap = None;
}
app.viewport.modifier_nav = next;
next.map(|(mode, p)| {
let delta = previous.filter(|(m, _)| *m == mode).map_or(egui::Vec2::ZERO, |(_, last)| p - last);
let delta = if delta.is_finite() && delta.x.abs() <= 1e4 && delta.y.abs() <= 1e4 { delta } else { egui::Vec2::ZERO };
(mode, delta)
})
}
/// Continuous camera changes use the same JSON command as programmatic callers.
pub(crate) fn camera_motion(app: &mut SolveApp, rect: Rect, mut params: serde_json::Value) {
let stored_fov = app.cam.fov;
params["camera"] = json!(projection(app, rect).cam);
params["viewport"] = json!([rect.width().max(1.0), rect.height().max(1.0)]);
let _ = app.run("view.navigate", params);
app.cam.fov = stored_fov;
}
/// What one frame of camera input saw and did, for the caller's hover, selection and drawing.
pub(crate) struct CameraInput {
pub hover: Option<Pos2>,
pub scroll: f32,
pub mods: egui::Modifiers,
pub middle: bool,
pub secondary_down: bool,
pub primary_down: bool,
pub primary_pressed: bool,
pub delta: egui::Vec2,
/// The pointer is over this view (not a window, menu or the axis view above it).
pub inside: bool,
/// The axis view or navigation cube's rectangle.
pub axes: Rect,
pub pinch: f32,
pub pan: egui::Vec2,
/// The navigation bar's fixed mode, or the preset's left-button binding.
pub primary_nav: Option<NavMode>,
/// The navigation this frame's pointer input drives.
pub mode: Option<NavMode>,
}
/// Camera input for one frame over `rect`: the navigation preset's buttons and modifiers, Orbit
/// around, wheel, trackpad and pinch zoom (to the cursor), middle double-click, continuous orbit
/// and orbit snapping. Every camera page calls this, so the mouse moves the camera the same way
/// in every workspace (#1454). With `caps.pick` off nothing under the pointer is treated as
/// selectable, so a press that would start a selection navigates as on empty space; with
/// `caps.orbit` off, bindings that orbit do nothing and pan and zoom work as usual.
pub(crate) fn handle(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect, resp: &egui::Response, caps: NavCaps) -> CameraInput {
let picking = caps.pick;
let (hover, scroll, mods, middle, secondary_down, primary_down, primary_pressed, primary_released, delta) = ui.input(|i| {
(
i.pointer.hover_pos(),
i.smooth_scroll_delta.y,
i.modifiers,
i.pointer.middle_down(),
i.pointer.secondary_down(),
i.pointer.primary_down(),
i.pointer.primary_pressed(),
i.pointer.primary_released(),
i.pointer.delta(),
)
});
// The right button belongs to the marking menu while it is open.
let secondary_down = secondary_down && app.viewport.context_menu.is_none();
// The axis view handles its own clicks; the model underneath must not see them.
let axes = axis_rect(app, rect);
// Floating windows and menus own input over the model, including wheel navigation. egui's
// hit test also counts the model as under the pointer a few points past its own edge and
// inside a window's or a list's edge (#678), so the pointer must be in the viewport too,
// with no window or menu above it.
let inside = resp.contains_pointer() && ui.rect_contains_pointer(rect) && hover.is_some_and(|p| !axes.contains(p));
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, caps.orbit);
// ---- navigation ----
let trackpad_pan = app.preferences.trackpad_pans();
if !app.preferences.smooth_wheel_zoom {
app.viewport.wheel_zoom_raw_until_idle = true;
} else if !ui.input(|i| i.is_scrolling()) {
app.viewport.wheel_zoom_raw_until_idle = false;
}
let options = ui.ctx().options(|o| o.input_options);
let page_height = ui.ctx().viewport_rect().height();
let raw = ui.input(|i| immediate_wheel(i, options, page_height, &mut app.viewport.trackpad_gesture));
if trackpad_pan && raw.pan != egui::Vec2::ZERO {
// Swallow egui's smoothing tail while the trackpad runs (a wheel-only
// tail ends the moment trackpad input starts; mixed input is rare).
app.viewport.trackpad_until_idle = true;
} else if !ui.input(|i| i.is_scrolling()) {
app.viewport.trackpad_until_idle = false;
// Self-healing: with no scroll activity no gesture can be mid-flight
// (its End was lost); a stuck flag would pan future mouse input.
app.viewport.trackpad_gesture = false;
}
let pan = if trackpad_pan { raw.pan } else { egui::Vec2::ZERO } + raw.mouse_pan;
let scroll = if app.viewport.wheel_zoom_raw_until_idle || (trackpad_pan && app.viewport.trackpad_until_idle) {
raw.zoom + if trackpad_pan { 0.0 } else { raw.point_zoom }
} else {
scroll
};
// Pinch-to-zoom (trackpad): direct manipulation, never smoothed.
let pinch = ui.input(pinch_factor);
let h = rect.height().max(1.0) as f64;
if inside && pinch != 1.0 {
app.viewport.orbit_snap = None;
app.viewport.orbit_free = None;
// Spread zooms in: the camera distance shrinks by the pinch factor.
let f = f64::from(pinch).powf(-crate::prefs::zoom_sign(app));
let anchor = zoom_anchor(app, rect, hover);
if app.preferences.nav == crate::prefs::TOUCHPAD_NAV {
let mut params = json!({"motion":"zoom", "factor":f.clamp(0.05,20.0)});
if let Some(anchor) = anchor {
params["anchor"] = json!(anchor);
}
camera_motion(app, rect, params);
} else {
app.cancel_view_animation();
app.cam.zoom_at(f, anchor);
}
}
if inside && scroll != 0.0 {
app.viewport.orbit_snap = None;
app.viewport.orbit_free = None;
let f = (-scroll as f64 * 0.0015 * crate::prefs::zoom_sign(app)).exp();
let anchor = zoom_anchor(app, rect, hover);
if app.preferences.nav == crate::prefs::TOUCHPAD_NAV {
let mut params = json!({"motion":"zoom", "factor":f.clamp(0.05,20.0)});
if let Some(anchor) = anchor {
params["anchor"] = json!(anchor);
}
camera_motion(app, rect, params);
} else {
app.cancel_view_animation();
app.cam.zoom_at(f, anchor);
}
}
// Two-finger scroll pans the model with the fingers.
if inside && pan != egui::Vec2::ZERO {
app.viewport.orbit_snap = None;
app.viewport.orbit_free = None;
if app.preferences.nav == crate::prefs::TOUCHPAD_NAV {
camera_motion(app, rect, json!({"motion":"pan","delta":[pan.x,pan.y]}));
} else {
app.cancel_view_animation();
app.cam.pan(f64::from(pan.x), f64::from(pan.y), h);
}
}
// A left press with the preset's keys (Alias: Shift+Alt) navigates until the button is let
// go (kept through the release frame, so the end of the drag doesn't select either).
if primary_pressed && inside {
let empty_space = |app: &SolveApp| {
app.viewport.context_menu.is_none()
&& hover.is_some_and(|p| {
if !picking {
return true;
}
let proj = projection(app, rect);
pick(app, &proj, p).is_empty() && crate::dim_view::hit(p).is_none() && crate::sketch_tools::glyph_at(p).is_none()
})
};
let openscad_orbit =
app.preferences.nav == crate::prefs::OPENSCAD_NAV && app.tool.is_none() && app.dialog.is_none() && !mods.shift && empty_space(app);
let preset = if app.preferences.nav != crate::prefs::OPENSCAD_NAV || openscad_orbit {
crate::prefs::nav_mode(app, crate::prefs::NavButton::Left, mods)
} else {
None
};
// Ctrl+left drag from empty space orbits in every preset (Cmd on macOS). A Ctrl+click
// still selects, and Ctrl on geometry still drags it without snapping.
let ctrl_orbit = (mods.ctrl || mods.command) && !mods.shift && !mods.alt && empty_space(app);
app.viewport.primary_nav = preset.or(ctrl_orbit.then_some(NavMode::Orbit));
} else if !primary_down && !primary_released {
app.viewport.primary_nav = None;
}
// The fixed navigation mode (navigation bar) or the preset's left-button binding.
let primary_nav = app.viewport.nav.or(app.viewport.primary_nav);
let button = if middle {
Some(crate::prefs::NavButton::Middle)
} else if secondary_down {
Some(crate::prefs::NavButton::Right)
} else {
None
};
let dragging = resp.dragged() || (inside && button.is_some());
// Without orbit (a flat page), bindings that orbit do nothing.
let allowed = |m: NavMode| caps.orbit || m != NavMode::Orbit;
let motion = modifier_motion(app, ui, inside, hover, mods, picking).filter(|(m, _)| allowed(*m));
let mode = motion
.map(|(m, _)| m)
.or_else(|| {
if dragging && app.viewport.axis_drag.is_none() {
match button {
Some(b) => crate::prefs::nav_mode(app, b, mods),
None if primary_down => primary_nav,
None => None,
}
} else {
None
}
})
.filter(|m| allowed(*m));
if let Some((mode, delta)) = motion {
if delta != egui::Vec2::ZERO {
let mut params = json!({"motion":if mode == NavMode::Pan {"pan"} else {"orbit"},"delta":[delta.x,delta.y]});
if mode == NavMode::Orbit {
let pivot = match app.preferences.orbit_around {
crate::prefs::OrbitAround::ViewCentre => None,
crate::prefs::OrbitAround::Origin => Some(Vec3::ZERO),
crate::prefs::OrbitAround::Cursor => Some(match app.viewport.orbit_pivot {
Some(pivot) => pivot,
None => {
let pivot = crate::prefs::pivot_under(app, &projection(app, rect), hover.unwrap_or(rect.center()) - delta);
app.viewport.orbit_pivot = Some(pivot);
pivot
}
}),
};
if let Some(pivot) = pivot {
params["pivot"] = json!(pivot);
}
}
camera_motion(app, rect, params);
}
} else if mode == Some(NavMode::Orbit) {
let press = ui.input(|i| i.pointer.press_origin());
app.cancel_view_animation();
// Shift can be required to enter orbit (Fusion/Inventor middle drag, Alias Shift+Alt),
// rather than an additional request for snapping. Only an extra modifier snaps.
let without_shift = egui::Modifiers { shift: false, ..mods };
let snap = app.preferences.nav != crate::prefs::OPENSCAD_NAV
&& mods.shift
&& match button {
Some(b) => crate::prefs::nav_mode(app, b, without_shift) == Some(NavMode::Orbit),
None => app.viewport.nav.is_some(),
};
orbit_drag(app, rect, f64::from(delta.x), f64::from(delta.y), press, snap, true);
} else {
if matches!(mode, Some(NavMode::Pan | NavMode::Zoom)) {
app.viewport.orbit_snap = None;
}
if app.viewport.axis_drag.is_none() {
app.viewport.orbit_free = None;
}
if delta != egui::Vec2::ZERO {
if mode.is_some() {
app.cancel_view_animation();
}
match mode {
Some(NavMode::Pan) => app.cam.pan(f64::from(delta.x), f64::from(delta.y), h),
Some(NavMode::Zoom) => app.cam.zoom_at((f64::from(delta.y) * 0.01).exp(), None),
_ => {}
}
}
}
if !dragging && motion.is_none() {
app.viewport.orbit_pivot = None;
}
if app.viewport.spin {
// A click on the navigation bar (its own button) is not a stop.
let bar = app
.viewport
.layout
.as_ref()
.map(|l| l.navigation)
.unwrap_or_else(|| Rect::from_center_size(pos2(rect.center().x, rect.bottom() - 22.0), vec2(320.0, 30.0)));
let (pressed, key, dt) = ui.input(|i| {
(
i.pointer.any_pressed() && inside && !i.pointer.press_origin().is_some_and(|p| bar.contains(p)),
i.events.iter().any(|e| matches!(e, egui::Event::Key { pressed: true, .. })),
i.stable_dt,
)
});
if pressed || key || dragging || motion.is_some() || !caps.orbit {
app.viewport.spin = false;
} else {
app.cam.orbit(f64::from(dt.min(0.1)) * 30.0, 0.0);
ui.ctx().request_repaint();
}
}
if !app.cam.is_valid() {
app.viewport.orbit_free = None;
app.viewport.orbit_snap = None;
app.cam = Camera::default();
app.fit_view();
}
step_orbit_snap(app, rect, ui.input(|i| f64::from(i.stable_dt)));
if app.viewport.orbit_snap.is_some() {
ui.ctx().request_repaint();
}
CameraInput { hover, scroll, mods, middle, secondary_down, primary_down, primary_pressed, delta, inside, axes, pinch, pan, primary_nav, mode }
}
/// Six principal views, twelve edge diagonals and eight isometric corner directions.
pub(crate) fn snap_directions() -> impl Iterator<Item = Vec3> {
[-1.0, 0.0, 1.0].into_iter().flat_map(|x| {
[-1.0, 0.0, 1.0].into_iter().flat_map(move |y| [-1.0, 0.0, 1.0].into_iter().filter_map(move |z| Vec3::new(x, y, z).normalized()))
})
}
/// Snap to the nearest standard or diagonal direction in the user's up-axis frame.
pub(crate) fn snapped_axis_camera(cam: Camera) -> Camera {
let b = cam.to_frame(cam.back());
let dir = snap_directions().max_by(|a, c| a.dot(b).total_cmp(&c.dot(b))).unwrap_or(Vec3::Z);
let mut to = cam.looking_from(cam.from_frame(dir));
if dir.x == 0.0 && dir.y == 0.0 {
to.yaw = nearest_quarter_turn(cam.yaw);
}
to
}
pub(crate) struct OrbitSnap {
pub(crate) target: Camera,
pub(crate) yaw_velocity: f64,
pub(crate) pitch_velocity: f64,
pub(crate) pivot: Option<(Vec3, Pos2)>,
}
/// Pointer motion accumulates independently of the spring preview, so many small deltas
/// still cross snap boundaries. Keep a small angular margin to avoid boundary flicker.
pub(crate) fn orbit_drag(app: &mut SolveApp, rect: Rect, dx: f64, dy: f64, press: Option<Pos2>, snap: bool, around_cursor: bool) {
let displayed = app.cam;
if let Some(free) = app.viewport.orbit_free.take() {
app.cam = free;
}
if around_cursor {
orbit(app, rect, dx, dy, press);
} else {
app.cam.orbit(dx, dy);
}
let free = app.cam;
app.viewport.orbit_free = Some(free);
if snap || !around_cursor || app.viewport.orbit_snap.is_some() {
let mut target = if snap { snapped_axis_camera(free) } else { free };
if snap && let Some(previous) = app.viewport.orbit_snap.as_ref() {
let b = free.back();
if previous.target.back().dot(b) + 0.025 >= target.back().dot(b) {
target.yaw = previous.target.yaw;
target.pitch = previous.target.pitch;
}
}
// The cursor and origin pivots stay still while a snap eases in; the axis gizmo has no
// pivot (it orbits about the view centre, see `axis_view`).
let pivot = if around_cursor {
match app.preferences.orbit_around {
crate::prefs::OrbitAround::Origin => Some(Vec3::ZERO),
_ => app.viewport.orbit_pivot,
}
} else {
None
}
.and_then(|p| {
app.cam = displayed;
projection(app, rect).to_screen(p).map(|at| (p, at))
});
let spring = app.viewport.orbit_snap.get_or_insert(OrbitSnap { target, yaw_velocity: 0.0, pitch_velocity: 0.0, pivot });
spring.target = target;
spring.pivot = pivot;
app.cam = displayed;
}
}
/// A lightly damped spring: mostly settled in about a third of a second, with a small
/// elastic finish. Bounded substeps keep the behavior consistent across frame rates.
pub(crate) fn step_orbit_snap(app: &mut SolveApp, rect: Rect, dt: f64) {
let Some(mut spring) = app.viewport.orbit_snap.take() else { return };
let dt = if dt.is_finite() { dt.clamp(0.0, 0.05) } else { 1.0 / 60.0 };
let steps = (dt * 240.0).ceil().max(1.0) as usize;
let h = dt / steps as f64;
let omega = 16.0;
let damping = 0.82;
let yaw_error = |yaw: f64| (spring.target.yaw - yaw + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI;
for _ in 0..steps.min(12) {
spring.yaw_velocity += (omega * omega * yaw_error(app.cam.yaw) - 2.0 * damping * omega * spring.yaw_velocity) * h;
spring.pitch_velocity += (omega * omega * (spring.target.pitch - app.cam.pitch) - 2.0 * damping * omega * spring.pitch_velocity) * h;
app.cam.yaw += spring.yaw_velocity * h;
app.cam.pitch = (app.cam.pitch + spring.pitch_velocity * h).clamp(-std::f64::consts::FRAC_PI_2, std::f64::consts::FRAC_PI_2);
}
app.cam.target = spring.target.target;
let done = yaw_error(app.cam.yaw).abs() < 1e-5
&& (spring.target.pitch - app.cam.pitch).abs() < 1e-5
&& spring.yaw_velocity.abs() < 1e-4
&& spring.pitch_velocity.abs() < 1e-4;
if done {
app.cam = spring.target;
}
if let Some((pivot, at)) = spring.pivot
&& let Some(after) = projection(app, rect).to_screen(pivot)
{
app.cam.pan(f64::from(at.x - after.x), f64::from(at.y - after.y), f64::from(rect.height().max(1.0)));
}
if !done {
app.viewport.orbit_snap = Some(spring);
}
}
/// 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.
pub(crate) fn middle_double_click(app: &mut SolveApp, ui: &egui::Ui, inside: bool, orbit: 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) {
// Home turns the view; a flat page only frames its content.
crate::prefs::MiddleDoubleClick::Home if orbit => app.animate_view("home"),
crate::prefs::MiddleDoubleClick::Home => app.animate_fit_all(),
crate::prefs::MiddleDoubleClick::Fit => app.animate_fit_all(),
crate::prefs::MiddleDoubleClick::Off => {}
}
}
/// Orbit by pixel deltas about the configured centre: the view's centre (default), the world
/// origin, or the model point that was under the cursor when the drag began. The origin and the
/// cursor pivot are kept still on screen; a cursor drag that begins over empty space turns around
/// the centre of the visible geometry instead, so the model never swings around a far-off point.
/// The cursor pivot is chosen once, when the drag begins.
pub fn orbit(app: &mut SolveApp, rect: egui::Rect, dx: f64, dy: f64, press: Option<Pos2>) {
let pivot = match app.preferences.orbit_around {
OrbitAround::ViewCentre => None,
OrbitAround::Origin => Some(Vec3::ZERO),
OrbitAround::Cursor => Some(match app.viewport.orbit_pivot {
Some(p) => p,
None => {
let proj = crate::viewport::projection(app, rect);
let at = press.unwrap_or(rect.center());
let p = pivot_under(app, &proj, at);
app.viewport.orbit_pivot = Some(p);
p
}
}),
};
let Some(pivot) = pivot else {
app.cam.orbit(dx, dy);
return;
};
orbit_about(app, rect, dx, dy, pivot);
}
/// Orbit by pixel deltas, keeping `pivot` still on screen: the camera turns about it and a pan
/// correction puts it back where it was.
pub(crate) fn orbit_about(app: &mut SolveApp, rect: egui::Rect, dx: f64, dy: f64, pivot: Vec3) {
let before = crate::viewport::projection(app, rect).to_screen(pivot);
app.cam.orbit(dx, dy);
let after = crate::viewport::projection(app, rect).to_screen(pivot);
if let (Some(a), Some(b)) = (before, after) {
app.cam.pan(f64::from(a.x - b.x), f64::from(a.y - b.y), f64::from(rect.height().max(1.0)));
}
}
#[cfg(test)]
mod tests {
/// Every page with a camera navigates through [`super::handle`] (#1454). A page that reads
/// the wheel or moves the view camera itself would drift from the user's navigation settings,
/// so outside this module (and the SpaceMouse driver, a device rather than a page) the camera
/// may only be moved through it, the view commands or the navigators.
#[test]
fn hand_rolled_camera_input_is_rejected() {
const PATTERNS: [&str; 7] =
["smooth_scroll_delta", "raw_scroll_delta", "zoom_delta()", "cam.orbit(", "cam.pan(", "cam.zoom_at(", "cam.roll_by("];
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let dirs = [root.join("src"), root.join("../../apps/solidcraft/src")];
let allowed = ["navigation.rs", "space_mouse.rs"];
let mut offenders = Vec::new();
let mut scanned = 0;
let mut stack: Vec<std::path::PathBuf> = dirs.iter().filter(|d| d.is_dir()).cloned().collect();
while let Some(dir) = stack.pop() {
for entry in std::fs::read_dir(&dir).unwrap().flatten() {
let path = entry.path();
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or_default().to_string();
if path.is_dir() {
stack.push(path);
continue;
}
if !name.ends_with(".rs") || name.ends_with("_tests.rs") || allowed.contains(&name.as_str()) {
continue;
}
scanned += 1;
let text = std::fs::read_to_string(&path).unwrap();
// In-file unit tests set cameras up directly; only the code above them counts.
let code = text.split("\n#[cfg(test)]\nmod ").next().unwrap_or_default();
for (n, line) in code.lines().enumerate() {
if !line.trim_start().starts_with("//") && PATTERNS.iter().any(|p| line.contains(p)) {
offenders.push(format!("{}:{}: {}", path.display(), n + 1, line.trim()));
}
}
}
}
assert!(scanned > 50, "the source tree was scanned ({scanned} files)");
assert!(offenders.is_empty(), "camera input outside navigation::handle; call it instead:\n{}", offenders.join("\n"));
}
/// A flat page (orbit off) pans and zooms with the same bindings and never turns the view.
#[test]
fn flat_pages_pan_and_zoom_with_the_same_bindings_but_never_orbit() {
use egui::{Event, Modifiers, PointerButton, pos2, vec2};
let flat = super::NavCaps { orbit: false, pick: false };
for preset in crate::prefs::NAV_PRESETS.iter().map(|p| p.id) {
let mut s = solidcraft_engine::Session::default();
s.execute("solid.box", &serde_json::json!({"length":40,"width":30,"height":20})).unwrap();
let mut app = crate::SolveApp::new(s, crate::Services::default());
app.preferences.nav = preset.into();
app.viewport.spin = true;
let ctx = egui::Context::default();
let mut time = 0.0;
let mut frame = |app: &mut crate::SolveApp, mods: Modifiers, events: Vec<Event>| {
time += 1.0 / 60.0;
let events = std::iter::once(Event::ModifiersChanged(mods)).chain(events).collect();
let raw = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(800.0, 600.0))),
time: Some(time),
focused: true,
events,
..Default::default()
};
let mut out = ctx.run_ui(raw, |ui| {
let rect = ui.max_rect();
app.viewport.rect = Some(rect);
let resp = ui.interact(rect, ui.id().with("flat"), egui::Sense::click_and_drag());
super::handle(app, ui, rect, &resp, flat);
});
out.textures_delta.clear();
};
frame(&mut app, Modifiers::NONE, vec![]);
assert!(!app.viewport.spin, "{preset}: continuous orbit stops on a flat page");
let mut panned = false;
let at = pos2(200.0, 300.0);
for button in [PointerButton::Primary, PointerButton::Middle, PointerButton::Secondary] {
for mods in [Modifiers::NONE, Modifiers::SHIFT, Modifiers::CTRL, Modifiers::ALT, Modifiers::SHIFT | Modifiers::ALT] {
let before = app.cam;
frame(&mut app, mods, vec![Event::PointerMoved(at)]);
frame(&mut app, mods, vec![Event::PointerButton { pos: at, button, pressed: true, modifiers: mods }]);
for k in 1..5 {
frame(&mut app, mods, vec![Event::PointerMoved(at + vec2(12.0, 7.0) * k as f32)]);
}
frame(&mut app, mods, vec![Event::PointerButton { pos: at + vec2(48.0, 28.0), button, pressed: false, modifiers: mods }]);
frame(&mut app, Modifiers::NONE, vec![]);
assert_eq!((app.cam.yaw, app.cam.pitch), (before.yaw, before.pitch), "{preset}: {button:?} with {mods:?} turned a flat page");
panned |= app.cam.target != before.target;
}
}
assert!(panned, "{preset}: some binding pans");
let before = app.cam;
let wheel = Event::MouseWheel {
unit: egui::MouseWheelUnit::Line,
delta: vec2(0.0, 3.0),
modifiers: Modifiers::NONE,
phase: egui::TouchPhase::Move,
};
frame(&mut app, Modifiers::NONE, vec![Event::PointerMoved(at), wheel]);
for _ in 0..20 {
frame(&mut app, Modifiers::NONE, vec![]);
}
assert_ne!(app.cam.distance, before.distance, "{preset}: the wheel zooms");
}
}
}