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 <[email protected]>

* Render panel hint says the camera moves as in Design

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
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

+4
View File
@@ -81,6 +81,10 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
egui/eframe/winit/wgpu/rfd. **The UI crate is swappable.** Only `crates/kernel` may use `truck`. egui/eframe/winit/wgpu/rfd. **The UI crate is swappable.** Only `crates/kernel` may use `truck`.
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` / - **The UI is thin**: panels read engine state and act through `app.run(id, params)` /
`app.start(id)`. Colours come from `theme::Tokens`. `app.start(id)`. Colours come from `theme::Tokens`.
- **One set of camera controls**: every page or canvas with a camera (viewport, Render
preview, future flat pattern or drawing pages) calls `navigation::handle` with its `NavCaps`
(orbit off for flat 2D pages). New canvases must use `navigation::handle`; never hand-roll
camera input (a test rejects it).
- **Rust only.** Units are millimetres and radians internally; Z is up. The one exception is - **Rust only.** Units are millimetres and radians internally; Z is up. The one exception is
JavaScript the browser requires: the service worker `apps/solidcraft-web/sw.js` (browsers only JavaScript the browser requires: the service worker `apps/solidcraft-web/sw.js` (browsers only
run service workers as JavaScript) and the small inline scripts in `apps/solidcraft-web/index.html`. run service workers as JavaScript) and the small inline scripts in `apps/solidcraft-web/index.html`.
+6
View File
@@ -116,6 +116,12 @@ The [README](README.md#architecture) has the layer table. The short version:
5. **Everything is a command, with a test.** A new feature means a `CommandSpec` in 5. **Everything is a command, with a test.** A new feature means a `CommandSpec` in
`crates/engine/src/cmd/`, engine tests that measure the result (volume, area, `crates/engine/src/cmd/`, engine tests that measure the result (volume, area,
face/edge/vertex counts against a value you can derive), and for UI work a scenario (below). face/edge/vertex counts against a value you can derive), and for UI work a scenario (below).
6. **One set of camera controls.** Every page or canvas that shows a view (the modelling
viewport, the Render preview, and any new page such as a flat pattern or drawing) calls
`navigation::handle` (`crates/ui-egui/src/navigation.rs`) for its camera input, with
`NavCaps` saying whether it can orbit (off for flat 2D pages) and pick. New canvases must use
`navigation::handle`; never read drags, the wheel or pinch to move the camera yourself. The
`hand_rolled_camera_input_is_rejected` test fails if a page does.
[AGENTS.md](AGENTS.md) has the long form of these rules. It's written for AI agents, but it's [AGENTS.md](AGENTS.md) has the long form of these rules. It's written for AI agents, but it's
the most complete description of the house style. the most complete description of the house style.
+1 -1
View File
@@ -867,7 +867,7 @@
"render.reload_settings": "Gespeicherte Einstellungen laden", "render.reload_settings": "Gespeicherte Einstellungen laden",
"render.unavailable_preview": "Dieses Setup verlangt ein nicht verfügbares Vorschauprofil.", "render.unavailable_preview": "Dieses Setup verlangt ein nicht verfügbares Vorschauprofil.",
"render.use_preview": "Integrierte Vorschau verwenden", "render.use_preview": "Integrierte Vorschau verwenden",
"render.navigation_help": "Ziehen zum Drehen; mit mittlerer Taste ziehen zum Verschieben; scrollen zum Zoomen. Setup speichern übernimmt die aktuelle Kamera.", "render.navigation_help": "Die Kamera bewegt sich wie im Konstruieren (Einstellungen › Navigation). Setup speichern übernimmt die aktuelle Kamera.",
"render.return_design": "Zurück zu Design", "render.return_design": "Zurück zu Design",
"render.image_title": "Bild rendern", "render.image_title": "Bild rendern",
"render.image_help": "CPU-Vorschaubild · PNG · angenäherte Transparenz", "render.image_help": "CPU-Vorschaubild · PNG · angenäherte Transparenz",
+1 -1
View File
@@ -1125,7 +1125,7 @@
"render.reload_settings": "Reload Saved Settings", "render.reload_settings": "Reload Saved Settings",
"render.unavailable_preview": "This setup requests an unavailable preview profile.", "render.unavailable_preview": "This setup requests an unavailable preview profile.",
"render.use_preview": "Use Integrated Preview", "render.use_preview": "Use Integrated Preview",
"render.navigation_help": "Drag to orbit; middle-drag to pan; scroll to zoom. Save Setup records the current camera.", "render.navigation_help": "The camera moves as in Design (Preferences › Navigation). Save Setup records the current camera.",
"render.return_design": "Return to Design", "render.return_design": "Return to Design",
"render.image_title": "Render Image", "render.image_title": "Render Image",
"render.image_help": "CPU preview image · PNG · approximate transparency", "render.image_help": "CPU preview image · PNG · approximate transparency",
+1 -1
View File
@@ -1129,7 +1129,7 @@
"render.reload_settings": "Recharger les réglages enregistrés", "render.reload_settings": "Recharger les réglages enregistrés",
"render.unavailable_preview": "Ce préréglage demande un profil d'aperçu indisponible.", "render.unavailable_preview": "Ce préréglage demande un profil d'aperçu indisponible.",
"render.use_preview": "Utiliser l'aperçu intégré", "render.use_preview": "Utiliser l'aperçu intégré",
"render.navigation_help": "Faire glisser pour orbiter ; glisser avec le bouton du milieu pour un panoramique ; molette pour zoomer. Enregistrer le préréglage mémorise la caméra actuelle.", "render.navigation_help": "La caméra se déplace comme dans Conception (Préférences › Navigation). Enregistrer le préréglage mémorise la caméra actuelle.",
"render.return_design": "Revenir à la conception", "render.return_design": "Revenir à la conception",
"render.image_title": "Image de rendu", "render.image_title": "Image de rendu",
"render.image_help": "Image d'aperçu CPU · PNG · transparence approximative", "render.image_help": "Image d'aperçu CPU · PNG · transparence approximative",
+1 -1
View File
@@ -1125,7 +1125,7 @@
"render.reload_settings": "保存した設定を再読み込み", "render.reload_settings": "保存した設定を再読み込み",
"render.unavailable_preview": "このセットアップは利用できないプレビュープロファイルを要求しています。", "render.unavailable_preview": "このセットアップは利用できないプレビュープロファイルを要求しています。",
"render.use_preview": "統合プレビューを使用", "render.use_preview": "統合プレビューを使用",
"render.navigation_help": "ドラッグで回転、中央ボタンのドラッグで移動、スクロールでズームします。「セットアップを保存」で現在のカメラを記録します。", "render.navigation_help": "カメラはデザインと同じように動きます(環境設定 › ナビゲーション)。「セットアップを保存」で現在のカメラを記録します。",
"render.return_design": "設計に戻る", "render.return_design": "設計に戻る",
"render.image_title": "画像をレンダリング", "render.image_title": "画像をレンダリング",
"render.image_help": "CPUプレビュー画像 · PNG · 近似透過", "render.image_help": "CPUプレビュー画像 · PNG · 近似透過",
+1 -1
View File
@@ -867,7 +867,7 @@
"render.reload_settings": "저장된 설정 다시 불러오기", "render.reload_settings": "저장된 설정 다시 불러오기",
"render.unavailable_preview": "이 설정은 사용할 수 없는 미리보기 프로필을 요청합니다.", "render.unavailable_preview": "이 설정은 사용할 수 없는 미리보기 프로필을 요청합니다.",
"render.use_preview": "통합 미리보기 사용", "render.use_preview": "통합 미리보기 사용",
"render.navigation_help": "드래그하여 회전하고, 가운데 버튼으로 드래그하여 이동하며, 스크롤하여 확대/축소합니다. 설정 저장은 현재 카메라를 기록합니다.", "render.navigation_help": "카메라는 디자인에서와 같이 움직입니다(환경설정 › 탐색). 설정 저장은 현재 카메라를 기록합니다.",
"render.return_design": "설계로 돌아가기", "render.return_design": "설계로 돌아가기",
"render.image_title": "이미지 렌더링", "render.image_title": "이미지 렌더링",
"render.image_help": "CPU 미리 보기 이미지 · PNG · 근사 투명도", "render.image_help": "CPU 미리 보기 이미지 · PNG · 근사 투명도",
+1 -1
View File
@@ -1050,7 +1050,7 @@
"render.reload_settings": "Загрузить сохранённые настройки", "render.reload_settings": "Загрузить сохранённые настройки",
"render.unavailable_preview": "Этот набор запрашивает недоступный профиль предпросмотра.", "render.unavailable_preview": "Этот набор запрашивает недоступный профиль предпросмотра.",
"render.use_preview": "Использовать встроенный предпросмотр", "render.use_preview": "Использовать встроенный предпросмотр",
"render.navigation_help": "Перетаскивайте для вращения, используйте среднюю кнопку для перемещения и колесо для масштаба. Сохранение набора записывает текущую камеру.", "render.navigation_help": "Камера управляется так же, как в проектировании (Настройки › Навигация). Сохранение набора записывает текущую камеру.",
"render.return_design": "Вернуться к проектированию", "render.return_design": "Вернуться к проектированию",
"render.image_title": "Рендеринг изображения", "render.image_title": "Рендеринг изображения",
"render.image_help": "Предпросмотр на CPU · PNG · приближённая прозрачность", "render.image_help": "Предпросмотр на CPU · PNG · приближённая прозрачность",
+1 -1
View File
@@ -1125,7 +1125,7 @@
"render.reload_settings": "重新加载已保存设置", "render.reload_settings": "重新加载已保存设置",
"render.unavailable_preview": "此设置请求了不可用的预览配置。", "render.unavailable_preview": "此设置请求了不可用的预览配置。",
"render.use_preview": "使用集成预览", "render.use_preview": "使用集成预览",
"render.navigation_help": "拖动以旋转,按住中键拖动以平移,滚动以缩放。保存设置会记录当前相机。", "render.navigation_help": "相机的操作与设计中相同(首选项 › 导航)。保存设置会记录当前相机。",
"render.return_design": "返回设计", "render.return_design": "返回设计",
"render.image_title": "渲染图像", "render.image_title": "渲染图像",
"render.image_help": "CPU 预览图像 · PNG · 近似透明度", "render.image_help": "CPU 预览图像 · PNG · 近似透明度",
+6
View File
@@ -96,6 +96,7 @@ pub(crate) mod display_lod;
mod file_load_ui_tests; mod file_load_ui_tests;
#[cfg(test)] #[cfg(test)]
mod fillet_spline_handle_tests; mod fillet_spline_handle_tests;
pub(crate) mod navigation;
pub mod palette; pub mod palette;
pub mod params_dialog; pub mod params_dialog;
pub mod prefs; pub mod prefs;
@@ -1056,6 +1057,11 @@ impl SolveApp {
/// Frame everything shown, keeping the orientation (animated; the selection is ignored). /// Frame everything shown, keeping the orientation (animated; the selection is ignored).
pub fn animate_fit_all(&mut self) { pub fn animate_fit_all(&mut self) {
if render_workspace::active(self) {
// The Render preview frames its product scene (see `fit_view`).
self.fit_view();
return;
}
self.pending_fit = None; self.pending_fit = None;
let request = Some((self.cam, viewport::view_bounds(self), true)); let request = Some((self.cam, viewport::view_bounds(self), true));
let to = self.fitted(self.cam); let to = self.fitted(self.cam);
+743
View File
@@ -0,0 +1,743 @@
//! 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");
}
}
}
+1 -38
View File
@@ -604,44 +604,6 @@ pub fn zoom_sign(app: &SolveApp) -> f64 {
if app.preferences.zoom_reverse { -1.0 } else { 1.0 } if app.preferences.zoom_reverse { -1.0 } else { 1.0 }
} }
/// 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.
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)));
}
}
/// The model point under a screen position. Over empty space: the centre of the visible bodies /// The model point under a screen position. Over empty space: the centre of the visible bodies
/// and sketches (what Fit frames), or the view's centre when nothing is visible. /// and sketches (what Fit frames), or the view's centre when nothing is visible.
pub(crate) fn pivot_under(app: &SolveApp, proj: &crate::viewport::Proj, at: Pos2) -> Vec3 { pub(crate) fn pivot_under(app: &SolveApp, proj: &crate::viewport::Proj, at: Pos2) -> Vec3 {
@@ -1521,6 +1483,7 @@ mod tests {
} }
use super::*; use super::*;
use crate::navigation::orbit;
#[test] #[test]
fn unsupported_saved_msaa_is_reset_to_the_default_with_a_notice() { fn unsupported_saved_msaa_is_reset_to_the_default_with_a_notice() {
+6 -14
View File
@@ -438,20 +438,11 @@ fn hash_surface(surface: Option<solidcraft_engine::render::surface::OpaqueSurfac
pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
let rect = ui.max_rect(); let rect = ui.max_rect();
app.viewport.rect = Some(rect); app.viewport.rect = Some(rect);
let response = ui.allocate_rect(rect, Sense::click_and_drag()); crate::viewport::refresh_layout(app, ui.ctx().pixels_per_point());
let delta = ui.input(|i| i.pointer.delta()); let response = ui.interact(rect, ui.id().with("render_preview"), Sense::click_and_drag());
if response.dragged_by(egui::PointerButton::Primary) { // The modelling viewport's camera input (preset, Orbit around, zoom to the cursor, middle
app.cam.orbit(f64::from(delta.x), f64::from(delta.y)); // double-click), with nothing to pick or select here.
} crate::navigation::handle(app, ui, rect, &response, crate::navigation::NavCaps::RENDER);
if response.dragged_by(egui::PointerButton::Middle) {
app.cam.pan(f64::from(delta.x), f64::from(delta.y), f64::from(rect.height()));
}
if response.hovered() {
let scroll = ui.input(|i| i.smooth_scroll_delta.y);
if scroll != 0.0 {
app.cam.zoom_at((-f64::from(scroll) * 0.0015 * crate::prefs::zoom_sign(app)).exp(), None);
}
}
let bg = app.render_workspace.view.draft.background; let bg = app.render_workspace.view.draft.background;
ui.painter().rect_filled(rect, 0.0, Color32::from_rgb(bg[0], bg[1], bg[2])); ui.painter().rect_filled(rect, 0.0, Color32::from_rgb(bg[0], bg[1], bg[2]));
let result = (|| -> Result<(), String> { let result = (|| -> Result<(), String> {
@@ -533,4 +524,5 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
if let Err(error) = result { if let Err(error) = result {
ui.painter().text(rect.center(), egui::Align2::CENTER_CENTER, error, egui::FontId::proportional(14.0), Tokens::get().text); ui.painter().text(rect.center(), egui::Align2::CENTER_CENTER, error, egui::FontId::proportional(14.0), Tokens::get().text);
} }
crate::viewport::axis_view(app, ui, rect);
} }
@@ -470,3 +470,169 @@ fn delayed_image_export_refuses_an_internally_replaced_document_even_with_overwr
assert!(matches!(&h.app.status, Some((_, _, true)))); 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());
}
}
+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 std::hash::{Hash, Hasher};
use crate::gpu::{GpuScene, GpuTarget, SceneSlot, ViewportCallback}; 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::selection::{BoxSel, origin_quads, origin_size, ray_quad};
use crate::theme::Tokens; use crate::theme::Tokens;
use crate::{SolveApp, icons}; 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. /// 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)>>, 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). /// 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. /// The last pick (its key and hits): reused while nothing it depends on changed.
last_pick: Option<(u64, Vec<Hit>)>, last_pick: Option<(u64, Vec<Hit>)>,
pub rect: Option<Rect>, 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. /// when the button went down, so the drag neither selects nor moves sketch geometry.
pub primary_nav: Option<NavMode>, pub primary_nav: Option<NavMode>,
/// Button-free gesture and its last owned pointer position, kept through pauses. /// 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). /// What a click would pick now (after the active command's filter).
pub hover: Option<Hit>, pub hover: Option<Hit>,
pub mouse: Option<Pos2>, 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. /// Continuous orbit: the view turns about the vertical until the next click or key.
pub spin: bool, pub spin: bool,
/// After immediate zoom, ignore egui's outstanding smoothing until the gesture ends. /// 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 /// While trackpad scrolling runs, ignore egui's smoothing tail for zoom so
/// two-finger input is not replayed as zoom after it pans. /// 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): /// Inside a bracketed trackpad scroll (`Start` without its `End` yet):
/// point moves pan, bare moves are a mouse. /// 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. /// A primary press on the axis gizmo: the pointer position used by its last orbit step.
pub axis_drag: Option<Pos2>, pub axis_drag: Option<Pos2>,
/// Unsnapped camera while Shift-orbit previews a standard or diagonal view. /// 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 /// What is under `pos`, reusing the last answer while the cursor, the camera and the model are
/// unchanged. /// 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(); let mut h = std::collections::hash_map::DefaultHasher::new();
pos.x.to_bits().hash(&mut h); pos.x.to_bits().hash(&mut h);
pos.y.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); 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) { pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
let t = Tokens::get(); let t = Tokens::get();
let rect = ui.max_rect(); let rect = ui.max_rect();
app.viewport.rect = Some(rect); app.viewport.rect = Some(rect);
refresh_layout(app, ui.ctx().pixels_per_point()); refresh_layout(app, ui.ctx().pixels_per_point());
let resp = ui.interact(rect, ui.id().with("viewport"), Sense::click_and_drag()); 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| { let CameraInput {
( hover,
i.pointer.hover_pos(), scroll,
i.smooth_scroll_delta.y, mods,
i.modifiers, middle,
i.pointer.middle_down(), secondary_down,
i.pointer.secondary_down(), primary_down,
i.pointer.primary_down(), primary_pressed,
i.pointer.primary_pressed(), delta,
i.pointer.primary_released(), inside,
i.pointer.delta(), axes,
) pinch,
}); pan,
// The right button belongs to the marking menu while it is open. primary_nav,
let secondary_down = secondary_down && app.viewport.context_menu.is_none(); mode,
// The axis view handles its own clicks; the model underneath must not see them. } = crate::navigation::handle(app, ui, rect, &resp, NavCaps::MODEL);
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();
}
app.viewport.physical_scale = ui.ctx().pixels_per_point(); app.viewport.physical_scale = ui.ctx().pixels_per_point();
let proj = projection(app, rect); let proj = projection(app, rect);
if let Ok(mut cache) = app.viewport.display.lock() { 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() 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(|| { 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); let size = axis_view_size(app.preferences.navigator_style);
Rect::from_min_size(pos2(rect.right() - size, rect.top()), vec2(size, size)) 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) 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 /// 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. /// 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 { 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 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 { match app.preferences.navigator_style {
crate::prefs::NavigatorStyle::Axes => signed_axis_view(app, ui, rect), crate::prefs::NavigatorStyle::Axes => signed_axis_view(app, ui, rect),
crate::prefs::NavigatorStyle::Cube => navigation_cube::show(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. /// 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() { if !yaw.is_finite() {
return 0.0; return 0.0;
} }
@@ -3784,6 +3290,7 @@ fn view_menu(app: &mut SolveApp, ui: &mut egui::Ui, button: Rect) -> bool {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::navigation::*;
/// Bodies drawn from their display plans, evaluated on the GPU, look like the same bodies /// 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 /// built into triangles on the CPU: the sample design rendered both ways through the
+3 -1
View File
@@ -43,7 +43,9 @@ discards staged settings. Draft settings are separate for each open document.
**Restore Saved Camera** runs `render.settings.apply {name}`, preserving target, **Restore Saved Camera** runs `render.settings.apply {name}`, preserving target,
distance, yaw, pitch, roll, FOV and up axis as view state. It neither edits the document distance, yaw, pitch, roll, FOV and up axis as view state. It neither edits the document
nor settles a stale model. The UI honours its up axis in Render; the Design camera nor settles a stale model. The UI honours its up axis in Render; the Design camera
is restored when returning. Drag orbits, middle-drag pans, scrolling zooms, and is restored when returning. The camera moves exactly as in Design: the navigation preset,
Orbit around, zoom to the cursor, the SpaceMouse, the middle double-click, F6 and the axis
view or navigation cube all work (nothing is picked or selected here), and
**Fit Product** frames visible product bodies without including sketches or selection. **Fit Product** frames visible product bodies without including sketches or selection.
Preview drafts do not make the document dirty until saved. Unknown backend preferences Preview drafts do not make the document dirty until saved. Unknown backend preferences