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:
bherbruckandClaude Opus 5.5 authored and GitHub committed 2026-10-15 13:48:05 -04:00
1 parent e8e2ccc8a9
commit be985f136d
16 files changed
+969 -580

No files matched your search

+27 -520
View File
@@ -11,6 +11,7 @@ use solidcraft_engine::view::{colors, grid_step, sketch_lines_with_sampler};
use std::hash::{Hash, Hasher};
use crate::gpu::{GpuScene, GpuTarget, SceneSlot, ViewportCallback};
use crate::navigation::{CameraInput, NavCaps, OrbitSnap, orbit_drag};
use crate::selection::{BoxSel, origin_quads, origin_size, ray_quad};
use crate::theme::Tokens;
use crate::{SolveApp, icons};
@@ -52,7 +53,7 @@ pub struct ViewportState {
/// Each body mesh's box (the mesh held, so its address stays its own), from the last state.
mesh_bounds: std::sync::Mutex<std::collections::HashMap<usize, (std::sync::Arc<solidcraft_engine::geom::Mesh>, solidcraft_engine::geom::Aabb3)>>,
/// The last click in the view was the middle button's (for the middle double-click).
middle_clicked: bool,
pub(crate) middle_clicked: bool,
/// The last pick (its key and hits): reused while nothing it depends on changed.
last_pick: Option<(u64, Vec<Hit>)>,
pub rect: Option<Rect>,
@@ -76,7 +77,7 @@ pub struct ViewportState {
/// when the button went down, so the drag neither selects nor moves sketch geometry.
pub primary_nav: Option<NavMode>,
/// Button-free gesture and its last owned pointer position, kept through pauses.
modifier_nav: Option<(NavMode, Pos2)>,
pub(crate) modifier_nav: Option<(NavMode, Pos2)>,
/// What a click would pick now (after the active command's filter).
pub hover: Option<Hit>,
pub mouse: Option<Pos2>,
@@ -91,13 +92,13 @@ pub struct ViewportState {
/// Continuous orbit: the view turns about the vertical until the next click or key.
pub spin: bool,
/// After immediate zoom, ignore egui's outstanding smoothing until the gesture ends.
wheel_zoom_raw_until_idle: bool,
pub(crate) wheel_zoom_raw_until_idle: bool,
/// While trackpad scrolling runs, ignore egui's smoothing tail for zoom so
/// two-finger input is not replayed as zoom after it pans.
trackpad_until_idle: bool,
pub(crate) trackpad_until_idle: bool,
/// Inside a bracketed trackpad scroll (`Start` without its `End` yet):
/// point moves pan, bare moves are a mouse.
trackpad_gesture: bool,
pub(crate) trackpad_gesture: bool,
/// A primary press on the axis gizmo: the pointer position used by its last orbit step.
pub axis_drag: Option<Pos2>,
/// Unsnapped camera while Shift-orbit previews a standard or diagonal view.
@@ -1025,7 +1026,7 @@ fn screen_box(proj: &Proj, b: &solidcraft_engine::geom::Aabb3, px: f32) -> Optio
/// What is under `pos`, reusing the last answer while the cursor, the camera and the model are
/// unchanged.
fn pick_cached(app: &mut SolveApp, proj: &Proj, pos: Pos2) -> Vec<Hit> {
pub(crate) fn pick_cached(app: &mut SolveApp, proj: &Proj, pos: Pos2) -> Vec<Hit> {
let mut h = std::collections::hash_map::DefaultHasher::new();
pos.x.to_bits().hash(&mut h);
pos.y.to_bits().hash(&mut h);
@@ -2093,392 +2094,28 @@ pub fn delete_selection(app: &mut SolveApp) {
crate::delete::delete_selection(app);
}
/// 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.
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.
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.
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).
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).
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.
fn modifier_motion(app: &mut SolveApp, ui: &egui::Ui, inside: bool, hover: Option<Pos2>, mods: egui::Modifiers) -> 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()
&& !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.
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;
}
pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
let t = Tokens::get();
let rect = ui.max_rect();
app.viewport.rect = Some(rect);
refresh_layout(app, ui.ctx().pixels_per_point());
let resp = ui.interact(rect, ui.id().with("viewport"), Sense::click_and_drag());
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);
// ---- 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| {
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());
let motion = modifier_motion(app, ui, inside, hover, mods);
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
}
});
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() {
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();
}
let CameraInput {
hover,
scroll,
mods,
middle,
secondary_down,
primary_down,
primary_pressed,
delta,
inside,
axes,
pinch,
pan,
primary_nav,
mode,
} = crate::navigation::handle(app, ui, rect, &resp, NavCaps::MODEL);
app.viewport.physical_scale = ui.ctx().pixels_per_point();
let proj = projection(app, rect);
if let Ok(mut cache) = app.viewport.display.lock() {
@@ -3368,7 +3005,7 @@ fn axis_center(app: &SolveApp, rect: Rect) -> Pos2 {
axis_rect(app, rect).center()
}
fn axis_rect(app: &SolveApp, rect: Rect) -> Rect {
pub(crate) fn axis_rect(app: &SolveApp, rect: Rect) -> Rect {
app.viewport.layout.as_ref().filter(|l| l.viewport == rect).map(|l| l.axis_view).unwrap_or_else(|| {
let size = axis_view_size(app.preferences.navigator_style);
Rect::from_min_size(pos2(rect.right() - size, rect.top()), vec2(size, size))
@@ -3393,109 +3030,6 @@ fn axis_hit(tips: &[(&str, Vec3, Pos2, f64)], p: Pos2) -> Option<usize> {
tips.iter().enumerate().rev().find(|(_, (_, _, at, _))| at.distance(p) <= AXIS_RADIUS).map(|(i, _)| i)
}
/// Six principal views, twelve edge diagonals and eight isometric corner directions.
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.
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 {
target: Camera,
yaw_velocity: f64,
pitch_velocity: f64,
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.
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 {
crate::prefs::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.
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);
}
}
/// Dragging anywhere on the axis gizmo orbits about the view centre. A stationary press
/// on a tip chooses its view. Floating windows own their input over the gizmo.
fn axis_view_orbit(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) -> bool {
@@ -3528,7 +3062,7 @@ fn axis_view_orbit(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) -> bool {
true
}
fn axis_view(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
pub(crate) fn axis_view(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
match app.preferences.navigator_style {
crate::prefs::NavigatorStyle::Axes => signed_axis_view(app, ui, rect),
crate::prefs::NavigatorStyle::Cube => navigation_cube::show(app, ui, rect),
@@ -3591,36 +3125,8 @@ fn signed_axis_view(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
}
}
/// 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.
fn middle_double_click(app: &mut SolveApp, ui: &egui::Ui, inside: 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) {
crate::prefs::MiddleDoubleClick::Home => app.animate_view("home"),
crate::prefs::MiddleDoubleClick::Fit => app.animate_fit_all(),
crate::prefs::MiddleDoubleClick::Off => {}
}
}
/// At a halfway tie, prefer the square heading nearer the standard plan view.
fn nearest_quarter_turn(yaw: f64) -> f64 {
pub(crate) fn nearest_quarter_turn(yaw: f64) -> f64 {
if !yaw.is_finite() {
return 0.0;
}
@@ -3784,6 +3290,7 @@ fn view_menu(app: &mut SolveApp, ui: &mut egui::Ui, button: Rect) -> bool {
#[cfg(test)]
mod tests {
use super::*;
use crate::navigation::*;
/// Bodies drawn from their display plans, evaluated on the GPU, look like the same bodies
/// built into triangles on the CPU: the sample design rendered both ways through the