diff --git a/AGENTS.md b/AGENTS.md index 662c0f53..aaeb9c24 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`. - **The UI is thin**: panels read engine state and act through `app.run(id, params)` / `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 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`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2ff450ce..8f15211f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 `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). +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 the most complete description of the house style. diff --git a/assets/locales/de.json b/assets/locales/de.json index e76befe4..ca1e3278 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -867,7 +867,7 @@ "render.reload_settings": "Gespeicherte Einstellungen laden", "render.unavailable_preview": "Dieses Setup verlangt ein nicht verfügbares Vorschauprofil.", "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.image_title": "Bild rendern", "render.image_help": "CPU-Vorschaubild · PNG · angenäherte Transparenz", diff --git a/assets/locales/en.json b/assets/locales/en.json index 5c57abe9..8fa712e0 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -1125,7 +1125,7 @@ "render.reload_settings": "Reload Saved Settings", "render.unavailable_preview": "This setup requests an unavailable preview profile.", "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.image_title": "Render Image", "render.image_help": "CPU preview image · PNG · approximate transparency", diff --git a/assets/locales/fr.json b/assets/locales/fr.json index b1abe987..e5eaf321 100644 --- a/assets/locales/fr.json +++ b/assets/locales/fr.json @@ -1129,7 +1129,7 @@ "render.reload_settings": "Recharger les réglages enregistrés", "render.unavailable_preview": "Ce préréglage demande un profil d'aperçu indisponible.", "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.image_title": "Image de rendu", "render.image_help": "Image d'aperçu CPU · PNG · transparence approximative", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index 05c731fe..aaabd4eb 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -1125,7 +1125,7 @@ "render.reload_settings": "保存した設定を再読み込み", "render.unavailable_preview": "このセットアップは利用できないプレビュープロファイルを要求しています。", "render.use_preview": "統合プレビューを使用", - "render.navigation_help": "ドラッグで回転、中央ボタンのドラッグで移動、スクロールでズームします。「セットアップを保存」で現在のカメラを記録します。", + "render.navigation_help": "カメラはデザインと同じように動きます(環境設定 › ナビゲーション)。「セットアップを保存」で現在のカメラを記録します。", "render.return_design": "設計に戻る", "render.image_title": "画像をレンダリング", "render.image_help": "CPUプレビュー画像 · PNG · 近似透過", diff --git a/assets/locales/ko.json b/assets/locales/ko.json index b7dd3f8e..ccc557e2 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -867,7 +867,7 @@ "render.reload_settings": "저장된 설정 다시 불러오기", "render.unavailable_preview": "이 설정은 사용할 수 없는 미리보기 프로필을 요청합니다.", "render.use_preview": "통합 미리보기 사용", - "render.navigation_help": "드래그하여 회전하고, 가운데 버튼으로 드래그하여 이동하며, 스크롤하여 확대/축소합니다. 설정 저장은 현재 카메라를 기록합니다.", + "render.navigation_help": "카메라는 디자인에서와 같이 움직입니다(환경설정 › 탐색). 설정 저장은 현재 카메라를 기록합니다.", "render.return_design": "설계로 돌아가기", "render.image_title": "이미지 렌더링", "render.image_help": "CPU 미리 보기 이미지 · PNG · 근사 투명도", diff --git a/assets/locales/ru.json b/assets/locales/ru.json index 28ab0481..970cec92 100644 --- a/assets/locales/ru.json +++ b/assets/locales/ru.json @@ -1050,7 +1050,7 @@ "render.reload_settings": "Загрузить сохранённые настройки", "render.unavailable_preview": "Этот набор запрашивает недоступный профиль предпросмотра.", "render.use_preview": "Использовать встроенный предпросмотр", - "render.navigation_help": "Перетаскивайте для вращения, используйте среднюю кнопку для перемещения и колесо для масштаба. Сохранение набора записывает текущую камеру.", + "render.navigation_help": "Камера управляется так же, как в проектировании (Настройки › Навигация). Сохранение набора записывает текущую камеру.", "render.return_design": "Вернуться к проектированию", "render.image_title": "Рендеринг изображения", "render.image_help": "Предпросмотр на CPU · PNG · приближённая прозрачность", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index 65331f4c..70fe1d76 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -1125,7 +1125,7 @@ "render.reload_settings": "重新加载已保存设置", "render.unavailable_preview": "此设置请求了不可用的预览配置。", "render.use_preview": "使用集成预览", - "render.navigation_help": "拖动以旋转,按住中键拖动以平移,滚动以缩放。保存设置会记录当前相机。", + "render.navigation_help": "相机的操作与设计中相同(首选项 › 导航)。保存设置会记录当前相机。", "render.return_design": "返回设计", "render.image_title": "渲染图像", "render.image_help": "CPU 预览图像 · PNG · 近似透明度", diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d79edd06..431da63f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -96,6 +96,7 @@ pub(crate) mod display_lod; mod file_load_ui_tests; #[cfg(test)] mod fillet_spline_handle_tests; +pub(crate) mod navigation; pub mod palette; pub mod params_dialog; pub mod prefs; @@ -1056,6 +1057,11 @@ impl SolveApp { /// Frame everything shown, keeping the orientation (animated; the selection is ignored). 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; let request = Some((self.cam, viewport::view_bounds(self), true)); let to = self.fitted(self.cam); diff --git a/crates/ui-egui/src/navigation.rs b/crates/ui-egui/src/navigation.rs new file mode 100644 index 00000000..64719c9d --- /dev/null +++ b/crates/ui-egui/src/navigation.rs @@ -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) -> Option { + 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, + 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, + 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, + /// The navigation this frame's pointer input drives. + pub mode: Option, +} + +/// 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 { + [-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, 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) { + 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 = 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| { + 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"); + } + } +} diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index 0bd53b46..c28d63e7 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -604,44 +604,6 @@ pub fn zoom_sign(app: &SolveApp) -> f64 { 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) { - 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 /// 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 { @@ -1521,6 +1483,7 @@ mod tests { } use super::*; + use crate::navigation::orbit; #[test] fn unsupported_saved_msaa_is_reset_to_the_default_with_a_notice() { diff --git a/crates/ui-egui/src/render_workspace.rs b/crates/ui-egui/src/render_workspace.rs index 6045336c..42d2cdbc 100644 --- a/crates/ui-egui/src/render_workspace.rs +++ b/crates/ui-egui/src/render_workspace.rs @@ -438,20 +438,11 @@ fn hash_surface(surface: Option Result<(), String> { @@ -533,4 +524,5 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { if let Err(error) = result { 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); } diff --git a/crates/ui-egui/src/render_workspace_tests.rs b/crates/ui-egui/src/render_workspace_tests.rs index 4645d703..c873df61 100644 --- a/crates/ui-egui/src/render_workspace_tests.rs +++ b/crates/ui-egui/src/render_workspace_tests.rs @@ -470,3 +470,169 @@ fn delayed_image_export_refuses_an_internally_replaced_document_even_with_overwr assert!(matches!(&h.app.status, Some((_, _, true)))); } } + +/// One frame of either the modelling viewport or the Render preview, over the same rectangle. +fn nav_frame(app: &mut crate::SolveApp, ctx: &egui::Context, render: bool, time: &mut f64, mods: egui::Modifiers, events: Vec) { + *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, 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()); + } +} diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 29860935..ddea6402 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -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, 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)>, pub rect: Option, @@ -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, /// 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, pub mouse: Option, @@ -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, /// 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 { +pub(crate) fn pick_cached(app: &mut SolveApp, proj: &Proj, pos: Pos2) -> Vec { 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) -> Option { - 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, 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 { 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 { - [-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, 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 diff --git a/docs/render-workspace.md b/docs/render-workspace.md index 983a010f..261b5b53 100644 --- a/docs/render-workspace.md +++ b/docs/render-workspace.md @@ -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, 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 -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. Preview drafts do not make the document dirty until saved. Unknown backend preferences