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:
1 parent
e8e2ccc8a9
commit
be985f136d
16 files changed
+969
-580
No files matched your search
@@ -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`.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 · 近似透過",
|
||||||
|
|||||||
@@ -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 · 근사 투명도",
|
||||||
|
|||||||
@@ -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 · приближённая прозрачность",
|
||||||
|
|||||||
@@ -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 · 近似透明度",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user