From dfd58293a6df22cace289df94428bcb8d56f992c Mon Sep 17 00:00:00 2001 From: cronos Date: Wed, 21 Oct 2026 23:29:29 +0000 Subject: [PATCH] Navigation: opt-in free orbit and shared Normal to Selection (#1912) * Navigation: opt-in free orbit and shared Normal to Selection Preserve the complete reviewed feature, shared camera setting propagation, rigid pivot behavior and focus/drag regressions from PR #1912. Consolidate the prior main-integration commits before rebasing, so their merge-resolution fixes remain in the linear feature history. * docs: move free orbit help beside viewport controls * Merge main into fix/solidworks-free-orbit Camera animation is gone on main, and with it the orbit snap spring: the Shift snap keeps its unsnapped orbit in orbit_free, which the free-orbit checks now read. Co-Authored-By: Claude Sonnet 5.5 --------- Co-authored-by: bherbruck Co-authored-by: Claude Sonnet 5.5 --- ROADMAP.md | 5 + assets/locales/de.json | 4 + assets/locales/en.json | 4 + assets/locales/ja.json | 4 + assets/locales/ko.json | 4 + assets/locales/zh.json | 4 + crates/engine/src/cmd/view_navigation.rs | 206 ++++++++++++++- crates/render/src/camera.rs | 71 ++++- crates/ui-egui/src/context_menu.rs | 42 +++ crates/ui-egui/src/lib.rs | 7 + crates/ui-egui/src/navigation.rs | 314 ++++++++++++++++++++++- crates/ui-egui/src/navigation_cube.rs | 2 +- crates/ui-egui/src/prefs.rs | 32 ++- crates/ui-egui/src/space_mouse.rs | 37 ++- crates/ui-egui/src/viewport.rs | 27 +- docs/book/src/guide/ui-tour.md | 6 + 16 files changed, 740 insertions(+), 29 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 6d0f80da55..12016e8627 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -331,6 +331,11 @@ completed drawings publish atomically. The browser still exports synchronously. ## View navigation +- Navigation has an opt-in Free orbit preference for mouse and 3D-mouse rotation through + both poles with roll. It defaults off, preserving existing preset snapping and pole limits. + The SolidWorks face menu offers Normal to Selection (`view.normal_to`), shared with Look At + and Ctrl+8, including repeat-to-flip, preserving scale and projection. + - Desktop Ctrl/Cmd +/- and 0 change or reset the saved UI scale. The scale slider keeps its layout stable during a drag and applies the selected scale on release. diff --git a/assets/locales/de.json b/assets/locales/de.json index 64375c5391..4ee9d6a50a 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -243,6 +243,7 @@ "Open local libraries in the desktop app. You can export this design's library here.": "Öffnen Sie lokale Bibliotheken in der Desktop-App. Die Bibliothek dieses Designs können Sie hier exportieren.", "Open-source parametric 3D CAD in Rust. Licensed MIT OR Apache-2.0.": "Quelloffenes parametrisches 3D-CAD in Rust. Lizenziert unter MIT OR Apache-2.0.", "Operator": "Operator", + "Orbit (right drag)": "Drehen (mit rechts ziehen)", "Other side": "Andere Seite", "Other surface (mutual trim)": "Anderer Flächenkörper (gegenseitig trimmen)", "Outside": "Außen", @@ -759,6 +760,7 @@ "cmd.view_graphics_preset": "Grafikprofil", "cmd.view_list": "Benannte Ansichten auflisten", "cmd.view_navigate": "Ansicht navigieren", + "cmd.view_normal_to": "Senkrecht zur Auswahl", "cmd.view_rename": "Benannte Ansicht umbenennen", "cmd.view_save": "Neue benannte Ansicht", "color.copy": "Klicken, um Farbwerte zu kopieren", @@ -927,6 +929,8 @@ "motion.to_end": "Zum Ende", "motion.to_start": "Zum Anfang", "motion.track_name": "Spur {number}", + "nav.free_orbit.help": "Maus und 3D-Maus drehen über beide Pole hinweg und behalten die Rollneigung bei. Das Einrasten mit Umschalt ist deaktiviert.", + "nav.free_orbit.label": "Freie Drehung", "nav.touchpad.help": "Umschalt gedrückt halten und den Zeiger zum Verschieben bewegen; mit Alt/Option drehen. Keine Maustaste nötig. Vertikales Scrollen zoomt.", "nav.touchpad.label": "Touchpad", "nav.touchpad.orbit": "Drehen (Alt/Option halten und bewegen; ohne Maustaste)", diff --git a/assets/locales/en.json b/assets/locales/en.json index 719566cbf6..9b22c4e7ce 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -253,6 +253,7 @@ "Opens your browser and copies the report text for you to paste into GitHub.": "Opens your browser and copies the report text for you to paste into GitHub.", "Opens your browser. Review and submit your report on GitHub.": "Opens your browser. Review and submit your report on GitHub.", "Operator": "Operator", + "Orbit (right drag)": "Orbit (right drag)", "Other side": "Other side", "Other surface (mutual trim)": "Other surface (mutual trim)", "Outside": "Outside", @@ -783,6 +784,7 @@ "cmd.view_graphics_preset": "Graphics Preset", "cmd.view_list": "List Named Views", "cmd.view_navigate": "Navigate View", + "cmd.view_normal_to": "Normal to Selection", "cmd.view_rename": "Rename Named View", "cmd.view_save": "New Named View", "color.copy": "Click to copy color values", @@ -957,6 +959,8 @@ "motion.to_end": "To the end", "motion.to_start": "To the start", "motion.track_name": "Track {number}", + "nav.free_orbit.help": "Mouse and 3D mouse rotate through both poles and preserve roll. Shift axis snapping is disabled.", + "nav.free_orbit.label": "Free orbit", "nav.touchpad.help": "Hold Shift and move the pointer to pan; hold Alt/Option and move it to orbit. No mouse button is needed. Vertical scroll zooms.", "nav.touchpad.label": "Touchpad", "nav.touchpad.orbit": "Orbit (hold Alt/Option and move; no mouse button)", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index cfd220b2b3..868a0d4e7b 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -253,6 +253,7 @@ "Opens your browser and copies the report text for you to paste into GitHub.": "ブラウザーを開き、GitHubに貼り付ける報告本文をコピーします。", "Opens your browser. Review and submit your report on GitHub.": "ブラウザーを開きます。GitHubで内容を確認して投稿してください。", "Operator": "演算子", + "Orbit (right drag)": "回転(右ドラッグ)", "Other side": "反対側", "Other surface (mutual trim)": "相手のサーフェス(相互トリム)", "Outside": "外側", @@ -783,6 +784,7 @@ "cmd.view_graphics_preset": "グラフィックスプリセット", "cmd.view_list": "名前付きビューを一覧表示", "cmd.view_navigate": "ビューナビゲーション", + "cmd.view_normal_to": "選択アイテムに垂直", "cmd.view_rename": "名前付きビューの名前を変更", "cmd.view_save": "新規名前付きビュー", "color.copy": "クリックして色の値をコピー", @@ -957,6 +959,8 @@ "motion.to_end": "最後へ", "motion.to_start": "最初へ", "motion.track_name": "トラック {number}", + "nav.free_orbit.help": "マウスと3Dマウスで、極点を越えて回転し、ロールを保ちます。\nShiftによる軸スナップは無効になります。", + "nav.free_orbit.label": "自由回転", "nav.touchpad.help": "Shift を押しながらポインターを動かすとパン、Alt/Option を押しながら動かすと回転します。マウスボタンは不要です。縦スクロールでズームします。", "nav.touchpad.label": "タッチパッド", "nav.touchpad.orbit": "回転(Alt/Option を押しながら移動、マウスボタン不要)", diff --git a/assets/locales/ko.json b/assets/locales/ko.json index 8530af89b5..8a80675f7c 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -243,6 +243,7 @@ "Open local libraries in the desktop app. You can export this design's library here.": "로컬 라이브러리는 데스크톱 앱에서 여세요. 이 디자인의 라이브러리는 여기서 내보낼 수 있습니다.", "Open-source parametric 3D CAD in Rust. Licensed MIT OR Apache-2.0.": "Rust로 만든 오픈 소스 매개변수 3D CAD. 라이선스: MIT OR Apache-2.0.", "Operator": "연산자", + "Orbit (right drag)": "회전 (오른쪽 드래그)", "Other side": "반대쪽", "Other surface (mutual trim)": "다른 곡면(상호 트림)", "Outside": "바깥쪽", @@ -759,6 +760,7 @@ "cmd.view_graphics_preset": "그래픽 프리셋", "cmd.view_list": "이름 있는 보기 목록", "cmd.view_navigate": "보기 탐색", + "cmd.view_normal_to": "선택 항목에 수직", "cmd.view_rename": "이름 있는 보기 이름 변경", "cmd.view_save": "새 이름 있는 보기", "color.copy": "클릭하여 색상 값 복사", @@ -927,6 +929,8 @@ "motion.to_end": "끝으로", "motion.to_start": "처음으로", "motion.track_name": "트랙 {number}", + "nav.free_orbit.help": "마우스와 3D 마우스로 롤을 유지하며 양 극점을 넘어 회전합니다. Shift 축 스냅은 비활성화됩니다.", + "nav.free_orbit.label": "자유 회전", "nav.touchpad.help": "Shift를 누른 채 포인터를 움직이면 화면이 이동하고, Alt/Option을 누른 채 움직이면 회전합니다. 마우스 버튼은 필요 없습니다. 세로 스크롤로 확대/축소합니다.", "nav.touchpad.label": "터치패드", "nav.touchpad.orbit": "회전 (Alt/Option을 누르고 이동, 마우스 버튼 불필요)", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index 2fe7801b33..938f03cbd8 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -253,6 +253,7 @@ "Opens your browser and copies the report text for you to paste into GitHub.": "打开浏览器,并复制报告文本以便粘贴到GitHub。", "Opens your browser. Review and submit your report on GitHub.": "打开浏览器。请在GitHub中检查并提交报告。", "Operator": "运算符", + "Orbit (right drag)": "旋转(右键拖动)", "Other side": "另一侧", "Other surface (mutual trim)": "另一曲面(相互修剪)", "Outside": "外侧", @@ -783,6 +784,7 @@ "cmd.view_graphics_preset": "图形预设", "cmd.view_list": "列出命名视图", "cmd.view_navigate": "导航视图", + "cmd.view_normal_to": "垂直于所选面", "cmd.view_rename": "重命名命名视图", "cmd.view_save": "新建命名视图", "color.copy": "单击复制颜色值", @@ -957,6 +959,8 @@ "motion.to_end": "到结尾", "motion.to_start": "到开头", "motion.track_name": "轨道 {number}", + "nav.free_orbit.help": "鼠标和3D鼠标可保持滚转并越过两个极点旋转。Shift轴吸附将被禁用。", + "nav.free_orbit.label": "自由旋转", "nav.touchpad.help": "按住 Shift 并移动指针可平移;按住 Alt/Option 并移动可旋转。无需按鼠标按钮。垂直滚动可缩放。", "nav.touchpad.label": "触控板", "nav.touchpad.orbit": "旋转(按住 Alt/Option 并移动,无需鼠标按钮)", diff --git a/crates/engine/src/cmd/view_navigation.rs b/crates/engine/src/cmd/view_navigation.rs index 0a3fde2f41..a8b9d3d328 100644 --- a/crates/engine/src/cmd/view_navigation.rs +++ b/crates/engine/src/cmd/view_navigation.rs @@ -4,7 +4,7 @@ use solidcraft_render::{Camera, StandardView}; use super::CommandSpec; use crate::params::{bad, num, req_vec2, req_vec3, vec3}; -use crate::{Result, Session}; +use crate::{Result, Sel, Session}; pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("view.history_layout", "History layout", history_layout) @@ -17,7 +17,48 @@ CommandSpec::new("view.navigate", "Navigate View", navigate) .icon("orbit") .noundo() .doc_only() - .params("camera: Camera; direction?: [x,y,z] in the camera's up-axis frame; or action: left|right|up|down|clockwise|counterclockwise|home|fit, degrees?: 0..180 (how far left|right|up|down turn; default 90); or motion: pan|orbit|zoom, delta?: [dx,dy] (pixels, each ±10000), viewport?: [width,height] (1..1000000, required for pan or pivot), pivot?: [x,y,z], factor?: 0.05..20 (required for zoom), anchor?: [x,y,z] → camera, fit, continuous?")]; + .params("camera: Camera; direction?: [x,y,z] in the camera's up-axis frame; or action: left|right|up|down|clockwise|counterclockwise|home|fit, degrees?: 0..180 (how far left|right|up|down turn; default 90); or motion: pan|orbit|zoom, delta?: [dx,dy] (pixels, each ±10000), free_orbit?: bool (orbit through poles, default false), viewport?: [width,height] (1..1000000, required for pan or pivot), pivot?: [x,y,z], factor?: 0.05..20 (required for zoom), anchor?: [x,y,z] → camera, fit, continuous?"), +CommandSpec::new("view.normal_to", "Normal to Selection", normal_to) + .icon("camera") + .noundo() + .enabled(|s| normal_target(s).map(|_| ()).ok_or_else(|| "select a planar face or plane".into())) + .params("camera?: Camera (default: current session camera or home view) → camera; Look At the first selected planar face or plane, centered on the picked point; repeat while aligned to flip the side; preserves scale and projection")]; + +fn normal_target(s: &Session) -> Option<(solidcraft_geom::Vec3, solidcraft_geom::Vec3)> { + let state = s.world_state(); + s.selection.iter().find_map(|selection| match selection { + Sel::Face { body, index, point } if point.is_finite() => { + let plane = state.body(body)?.body.planar_face(*index)?; + Some((*point, plane.normal())) + } + Sel::Plane { name } => solidcraft_geom::Plane::named(name).map(|plane| (plane.origin, plane.normal())), + _ => None, + }) +} + +fn normal_to(s: &mut Session, p: &Value) -> Result { + let cmd = "view.normal_to"; + let (point, normal) = normal_target(s).ok_or_else(|| bad(cmd, "select a planar face or plane"))?; + let cam: Camera = match p.get("camera") { + Some(value) => serde_json::from_value(value.clone()).map_err(|_| bad(cmd, "invalid camera"))?, + None => s.camera.unwrap_or_else(|| crate::view::home_camera(s)), + }; + if !cam.is_valid() + || !cam.fov.is_finite() + || !(0.0..std::f64::consts::PI).contains(&cam.fov) + || cam.yaw.abs() > 1e6 + || cam.roll.abs() > 1e6 + || cam.pitch.abs() > std::f64::consts::FRAC_PI_2 + { + return Err(bad(cmd, "invalid camera")); + } + // Promoted from viewport::look_at_selection: asking again from the normal side flips it. + let direction = if cam.back().dot(normal) > 0.999 { -normal } else { normal }; + let mut to = cam.looking_from(direction); + to.target = point; + s.camera = Some(to); + Ok(json!({"camera": to})) +} fn history_layout(_s: &mut Session, p: &Value) -> Result { let cmd = "view.history_layout"; @@ -105,6 +146,10 @@ fn navigate(_s: &mut Session, p: &Value) -> Result { /// Direct manipulation is stateless, bounded and independent of document evaluation. fn continuous(mut cam: Camera, p: &Value) -> Result { let cmd = "view.navigate"; + let free_orbit = match p.get("free_orbit") { + None => false, + Some(value) => value.as_bool().ok_or_else(|| bad(cmd, "free_orbit must be a boolean"))?, + }; let viewport = || { let size = req_vec2(cmd, p, "viewport")?; if !(1.0..=1e6).contains(&size.x) || !(1.0..=1e6).contains(&size.y) { @@ -118,14 +163,23 @@ fn continuous(mut cam: Camera, p: &Value) -> Result { if delta.x.abs() > 1e4 || delta.y.abs() > 1e4 { return Err(bad(cmd, "motion deltas must be within ±10000 pixels")); } + let orbit = |cam: &mut Camera| { + if free_orbit { + cam.rotate_in_view(solidcraft_geom::Vec3::new(-delta.y * 0.008, -delta.x * 0.008, 0.0)); + } else { + cam.orbit(delta.x, delta.y); + } + }; if motion == "pan" { cam.pan(delta.x, delta.y, viewport()?.y); } else if p.get("pivot").is_some() { let pivot = req_vec3(cmd, p, "pivot")?; viewport()?; - cam.orbit_about(delta.x, delta.y, pivot); + let coords = cam.frame_coords(pivot); + orbit(&mut cam); + cam.place(pivot, coords); } else { - cam.orbit(delta.x, delta.y); + orbit(&mut cam); } } Some("zoom") => { @@ -165,6 +219,100 @@ mod tests { use solidcraft_geom::Vec3; use solidcraft_render::UpAxis; + #[test] + fn look_at_planes_uses_the_first_supported_selection_and_flips_on_repeat() { + let original = Camera { yaw: 0.3, pitch: 0.4, roll: 0.7, fov: 0.6, distance: 123.0, ..Default::default() }; + let mut s = Session { + selection: vec![Sel::Plane { name: "missing".into() }, Sel::Plane { name: "XY".into() }, Sel::Plane { name: "YZ".into() }], + camera: Some(original), + ..Default::default() + }; + for normal in [Vec3::Z, -Vec3::Z, Vec3::Z] { + let result = s.execute("view.normal_to", &json!({})).unwrap(); + let to: Camera = serde_json::from_value(result["camera"].clone()).unwrap(); + assert!(to.back().dist(normal) < 2e-6); + assert_eq!((to.target, to.distance, to.fov), (Vec3::ZERO, original.distance, original.fov)); + } + assert!(s.undo.is_empty()); + } + + #[test] + fn normal_to_selection_faces_each_planar_surface_without_editing_the_design() { + let mut s = Session::default(); + s.execute("solid.box", &json!({"length":40,"width":30,"height":20})).unwrap(); + let state = s.world_state(); + let body = state.bodies.first().unwrap(); + let faces = body.body.faces(0.01).unwrap(); + let document = s.doc.to_json(); + let undo = s.undo.len(); + for up in [UpAxis::Z, UpAxis::Y] { + for fov in [0.0, 0.6] { + let cam = Camera { up, fov, roll: 1.2, distance: 123.0, ..Default::default() }; + for face in &faces { + s.selection = vec![Sel::Face { body: body.name.clone(), index: face.index, point: face.centroid }]; + let selected = s.selection.clone(); + let result = s.execute("view.normal_to", &json!({"camera":cam})).unwrap(); + let to: Camera = serde_json::from_value(result["camera"].clone()).unwrap(); + assert!(to.back().dist(face.plane_normal.unwrap()) < 2e-6); + assert_eq!(to.target, face.centroid); + assert_eq!((to.distance, to.fov, to.up, to.roll), (cam.distance, cam.fov, cam.up, 0.0)); + assert_eq!(s.camera, Some(to)); + assert_eq!(s.selection, selected); + } + } + } + assert_eq!(s.doc.to_json(), document); + assert_eq!(s.undo.len(), undo); + } + + #[test] + fn normal_to_selection_rejects_curved_missing_and_invalid_inputs() { + let mut s = Session::default(); + s.execute("solid.cylinder", &json!({"diameter":20,"height":30})).unwrap(); + let state = s.world_state(); + let body = state.bodies.first().unwrap(); + let faces = body.body.faces(0.1).unwrap(); + let flat = faces.iter().find(|f| f.plane_normal.is_some()).unwrap(); + let curved = faces.iter().find(|f| f.plane_normal.is_none()).unwrap(); + let selected = |index, point| Sel::Face { body: body.name.clone(), index, point }; + for selection in [vec![], vec![selected(curved.index, curved.centroid)], vec![selected(usize::MAX, Vec3::ZERO)]] { + s.selection = selection; + assert!(s.execute("view.normal_to", &json!({})).is_err()); + assert!(s.camera.is_none()); + } + s.selection = vec![selected(flat.index, flat.centroid)]; + for camera in [ + json!({}), + json!({"distance":0}), + json!(Camera { distance: -1.0, ..Default::default() }), + json!(Camera { fov: 4.0, ..Default::default() }), + ] { + assert!(s.execute("view.normal_to", &json!({"camera":camera})).is_err()); + assert!(s.camera.is_none()); + } + // Programmatic callers can use the current session view without supplying a camera. + assert!(s.execute("view.normal_to", &json!({})).is_ok()); + } + + #[test] + fn normal_to_selection_uses_the_world_normal_of_a_moved_component() { + let mut s = Session::default(); + s.execute("component.create", &json!({"name":"Part"})).unwrap(); + s.execute("solid.box", &json!({"length":40,"width":30,"height":20})).unwrap(); + s.execute("occurrence.move", &json!({"occurrence":"Part:1","translate":[50,20,10],"axis":[1,2,3],"angle":"37 deg"})).unwrap(); + let state = s.world_state(); + let body = state.bodies.first().unwrap(); + let faces = body.body.faces(0.01).unwrap(); + let cam = Camera { roll: 1.4, ..Default::default() }; + for face in faces { + s.selection = vec![Sel::Face { body: body.name.clone(), index: face.index, point: face.centroid }]; + let result = s.execute("view.normal_to", &json!({"camera":cam})).unwrap(); + let to: Camera = serde_json::from_value(result["camera"].clone()).unwrap(); + assert!(to.back().dist(face.plane_normal.unwrap()) < 2e-6); + assert!(to.target.dist(face.centroid) < 1e-9); + } + } + /// #1541: left, right, up and down turn a quarter turn, or `degrees` (the arrow keys' 15°). #[test] fn turns_take_degrees_and_default_to_a_quarter_turn() { @@ -228,6 +376,53 @@ mod tests { assert!(s.undo.is_empty()); } + #[test] + fn continuous_free_orbit_is_opt_in_and_preserves_framing_and_document() { + for up in [UpAxis::Z, UpAxis::Y] { + let mut s = Session::default(); + let design = s.doc.to_json(); + let cam = Camera { pitch: 1.4, roll: 0.7, up, ..Default::default() }; + let mut p = json!({"camera":cam,"motion":"orbit","delta":[0,80]}); + let default = s.execute("view.navigate", &p).unwrap(); + p["free_orbit"] = json!(false); + assert_eq!(s.execute("view.navigate", &p).unwrap(), default); + let clamped: Camera = serde_json::from_value(default["camera"].clone()).unwrap(); + assert!(clamped.pitch > cam.pitch && clamped.pitch < std::f64::consts::FRAC_PI_2); + p["free_orbit"] = json!(true); + let result = s.execute("view.navigate", &p).unwrap(); + let free: Camera = serde_json::from_value(result["camera"].clone()).unwrap(); + assert!((free.back().dot(cam.back()) - 0.64_f64.cos()).abs() < 1e-8); + assert_eq!((free.target, free.distance, free.fov), (cam.target, cam.distance, cam.fov)); + assert_eq!(s.doc.to_json(), design); + assert!(s.undo.is_empty() && s.selection.is_empty()); + } + } + + #[test] + fn continuous_free_orbit_keeps_the_pivot_in_the_camera_frame() { + for up in [UpAxis::Y, UpAxis::Z] { + for fov in [0.0, 0.6] { + let mut s = Session::default(); + let cam = Camera { target: Vec3::new(2.0, 3.0, 4.0), pitch: 1.4, roll: 0.7, fov, up, ..Default::default() }; + for pivot in [Vec3::ZERO, Vec3::new(10.0, 20.0, 30.0)] { + let result = s + .execute( + "view.navigate", + &json!({"camera":cam,"motion":"orbit","delta":[30,80],"free_orbit":true,"viewport":[800,600],"pivot":pivot}), + ) + .unwrap(); + let to: Camera = serde_json::from_value(result["camera"].clone()).unwrap(); + assert!(to.frame_coords(pivot).dist(cam.frame_coords(pivot)) < 1e-8); + assert!(((to.eye() - pivot).len() - (cam.eye() - pivot).len()).abs() < 1e-8); + let (ax, ay, _) = cam.to_screen(pivot, 800.0, 600.0, 200.0).unwrap(); + let (bx, by, _) = to.to_screen(pivot, 800.0, 600.0, 200.0).unwrap(); + assert!((ax - bx).abs() < 1e-6 && (ay - by).abs() < 1e-6); + assert_eq!((to.distance, to.fov), (cam.distance, cam.fov)); + } + } + } + } + #[test] fn continuous_perspective_orbit_keeps_near_and_far_pivots_on_screen() { let mut s = Session::default(); @@ -257,6 +452,9 @@ mod tests { json!({"motion":"pan","delta":[1e100,2],"viewport":[800,600]}), json!({"motion":"orbit","delta":[1]}), json!({"motion":"orbit","delta":[1,2],"pivot":[0,0,0]}), + json!({"motion":"orbit","delta":[1,2],"free_orbit":"true"}), + json!({"motion":"orbit","delta":[1,2],"free_orbit":1}), + json!({"motion":"orbit","delta":[1,2],"free_orbit":null}), json!({"motion":"zoom","factor":0}), json!({"motion":"zoom","factor":1e100}), json!({"motion":"zoom","factor":1,"anchor":[1e100,0,0]}), diff --git a/crates/render/src/camera.rs b/crates/render/src/camera.rs index 1fffb03c2b..704c44f070 100644 --- a/crates/render/src/camera.rs +++ b/crates/render/src/camera.rs @@ -185,7 +185,7 @@ pub struct Camera { pub target: Vec3, /// Rotation about Z; 0 = looking along +Y (from the front). pub yaw: f64, - /// Elevation above the XY plane, in (−π/2, π/2]. + /// Elevation above the XY plane, in [−π/2, π/2]. Roll preserves orientation across poles. pub pitch: f64, /// Rotation about the viewing direction. Zero keeps the world's up axis vertical. #[serde(default)] @@ -341,6 +341,34 @@ impl Camera { self.roll = (self.roll + radians) % std::f64::consts::TAU; } } + /// Rotate about the current screen right, up and back axes (radians), without a pole + /// limit. Reconstruct both heading and roll so crossing a pole keeps the image continuous. + pub fn rotate_in_view(&mut self, radians: Vec3) { + if !self.is_valid() || !radians.is_finite() { + return; + } + let angle = radians.x.hypot(radians.y).hypot(radians.z); + if !(angle > 1e-12) || !angle.is_finite() { + return; + } + let (right, up, back) = self.basis(); + let Some(axis) = (right * (radians.x / angle) + up * (radians.y / angle) + back * (radians.z / angle)).normalized() else { return }; + let (sin, cos) = angle.sin_cos(); + let rotate = |v: Vec3| v * cos + axis.cross(v) * sin + axis * (axis.dot(v) * (1.0 - cos)); + let rotated_right = rotate(right); + let Some(rotated_back) = rotate(back).normalized() else { return }; + let framed = self.up.to_frame(rotated_back); + let yaw = if framed.x.hypot(framed.y) < 1e-10 { self.yaw } else { (-framed.x).atan2(-framed.y) }; + let pitch = framed.z.clamp(-1.0, 1.0).asin(); + let base_right = self.up.from_frame(Vec3::new(yaw.cos(), -yaw.sin(), 0.0)); + let Some(base_up) = rotated_back.cross(base_right).normalized() else { return }; + let roll = rotated_right.dot(base_up).atan2(rotated_right.dot(base_right)); + if yaw.is_finite() && pitch.is_finite() && roll.is_finite() { + self.yaw = yaw; + self.pitch = pitch; + self.roll = roll; + } + } pub fn set_view(&mut self, v: StandardView) { let lim = std::f64::consts::FRAC_PI_2 - 1e-6; let (y, p) = v.angles(); @@ -432,6 +460,47 @@ mod tests { } } + #[test] + fn screen_rotation_crosses_both_poles_and_returns_after_a_full_turn() { + for up in [UpAxis::Z, UpAxis::Y] { + for axis in [Vec3::X, Vec3::Y, Vec3::Z] { + let mut cam = Camera { yaw: 0.0, pitch: 0.0, up, ..Default::default() }; + let initial = cam; + let step = std::f64::consts::TAU / 256.0; + for _ in 0..256 { + let before = cam.basis(); + cam.rotate_in_view(axis * step); + let after = cam.basis(); + assert!(cam.is_valid()); + // An X or Y turn moves the viewing direction every step, including at a pole. + if axis != Vec3::Z { + assert!((before.2.dot(after.2) - step.cos()).abs() < 1e-10); + } + assert!(before.0.dot(after.0) > 0.999 && before.1.dot(after.1) > 0.999); + assert!(after.0.cross(after.1).dist(after.2) < 1e-10); + } + let (r, u, b) = initial.basis(); + let (rr, uu, bb) = cam.basis(); + assert!(r.dist(rr) < 1e-9 && u.dist(uu) < 1e-9 && b.dist(bb) < 1e-9, "{up:?} {axis:?}: {cam:?}"); + assert_eq!((cam.target, cam.distance, cam.fov, cam.up), (initial.target, initial.distance, initial.fov, initial.up)); + } + } + } + + #[test] + fn screen_rotation_ignores_invalid_motion_and_reverses_combined_axes() { + let initial = Camera { roll: 1.2, ..Default::default() }; + let mut cam = initial; + for motion in [Vec3::ZERO, Vec3::new(f64::NAN, 0.0, 0.0), Vec3::new(0.0, f64::INFINITY, 0.0), Vec3::new(f64::MAX, f64::MAX, 0.0)] { + cam.rotate_in_view(motion); + assert_eq!(cam, initial); + } + let motion = Vec3::new(0.3, -0.4, 0.2); + cam.rotate_in_view(motion); + cam.rotate_in_view(-motion); + assert!(cam.basis().0.dist(initial.basis().0) < 1e-12 && cam.back().dist(initial.back()) < 1e-12); + } + #[test] fn front_view_projects_target_to_centre() { let mut c = Camera { target: Vec3::new(1.0, 2.0, 3.0), distance: 50.0, ..Default::default() }; diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index 803a9daaed..03fc3390f3 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -497,6 +497,10 @@ fn viewport_items(app: &SolveApp) -> Vec { let face = sel.iter().find_map(|s| if let Sel::Face { body, .. } = s { Some(body.clone()) } else { None }).unwrap_or_default(); let (feature, sketch) = feature_and_sketch(app, picked_face_feature(app)); let hidden = app.session.visibility.hidden_bodies.contains(&face); + if app.preferences.nav == "solidworks" { + v.push(cmd(app, "view.normal_to", "Normal to Selection").with(json!({"camera": app.cam}))); + v.push(Item::sep()); + } v.push(cmd(app, "sketch.create", "Create Sketch")); v.push(cmd(app, "solid.extrude", "Extrude")); v.push(cmd(app, "construct.plane.offset", "Offset Plane")); @@ -1913,6 +1917,44 @@ pub fn finish_rename(app: &mut SolveApp, text: Option<&str>, commit: bool) -> bo mod tests { use super::*; + #[test] + fn solidworks_face_menu_runs_normal_to_selection_and_rejects_curved_faces() { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.app.run("solid.cylinder", json!({"diameter":30,"height":20})).unwrap(); + let state = h.app.session.world_state(); + let body = state.bodies.first().unwrap(); + let faces = body.body.faces(0.1).unwrap(); + let flat = faces.iter().find(|f| f.plane_normal.is_some()).unwrap(); + let curved = faces.iter().find(|f| f.plane_normal.is_none()).unwrap(); + h.app.session.selection = vec![Sel::Face { body: body.name.clone(), index: flat.index, point: flat.centroid }]; + assert!(items(&h.app, &Target::Viewport).iter().all(|i| i.id != "view.normal_to")); + h.app.preferences.nav = "solidworks".into(); + h.app.preferences.language = "ja".into(); + h.settle(); + let before = h.app.cam; + open_for(&mut h.app, pos2(800.0, 400.0), Target::Viewport); + h.settle(); + assert!(items(&h.app, &Target::Viewport).iter().any(|i| i.id == "view.normal_to" && i.enabled)); + h.step(&json!({"click":{"handle":"menu:view.normal_to"}})).unwrap(); + h.frames(90); + assert!(h.app.cam.back().dist(flat.plane_normal.unwrap()) < 2e-6); + assert_eq!((h.app.cam.distance, h.app.cam.fov), (before.distance, before.fov)); + assert!(h.app.viewport.context_menu.is_none()); + assert_eq!(crate::i18n::Locale::for_language("ja").label("Normal to Selection"), "選択アイテムに垂直"); + // Ctrl+8 and the context menu share Look At, including its repeat-to-flip behavior. + h.step(&json!({"key":"8","cmd":true})).unwrap(); + h.frames(90); + assert!(h.app.cam.back().dist(-flat.plane_normal.unwrap()) < 2e-6); + open_for(&mut h.app, pos2(800.0, 400.0), Target::Viewport); + h.settle(); + h.step(&json!({"click":{"handle":"menu:view.normal_to"}})).unwrap(); + h.frames(90); + assert!(h.app.cam.back().dist(flat.plane_normal.unwrap()) < 2e-6); + h.app.session.selection = vec![Sel::Face { body: body.name.clone(), index: curved.index, point: curved.centroid }]; + assert!(items(&h.app, &Target::Viewport).iter().any(|i| i.id == "view.normal_to" && !i.enabled)); + } + #[test] fn viewport_right_click_keeps_list_menu_and_radial_is_opt_in() { let mut h = crate::scenario::Harness::new(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3a058a0499..d9050e9ac2 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -868,6 +868,13 @@ impl SolveApp { if self.file_load.busy() || self.file_picker.is_some() { return; } + if id == "view.normal_to" { + if self.run(id, json!({"camera": self.cam})).is_ok() { + self.follow_view_request(true); + self.home.open = false; + } + return; + } // A disabled command starts nothing, however it was reached. The toolbar greys its // button, but a shortcut, the command palette and the control channel all land here. // Its reason goes to the status line, so the refusal is never silent. diff --git a/crates/ui-egui/src/navigation.rs b/crates/ui-egui/src/navigation.rs index 08ce207cac..349ccc4e8e 100644 --- a/crates/ui-egui/src/navigation.rs +++ b/crates/ui-egui/src/navigation.rs @@ -276,6 +276,7 @@ pub(crate) fn camera_motion(app: &mut SolveApp, rect: Rect, mut params: serde_js 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)]); + params["free_orbit"] = json!(app.preferences.free_orbit); let _ = app.run("view.navigate", params); app.cam.fov = stored_fov; } @@ -550,7 +551,7 @@ pub(crate) fn handle(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect, resp: &e 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); + orbit_camera(app, f64::from(dt.min(0.1)) * 30.0, 0.0); ui.ctx().request_repaint(); } } @@ -570,13 +571,13 @@ pub(crate) fn device(app: &mut SolveApp, rect: Rect, caps: NavCaps) { let Some(queued) = app.viewport.device_motion.take() else { return }; let turn = caps.orbit && app.session.active_sketch.is_none(); let height = f64::from(rect.height().max(1.0)); - device_camera(&mut app.cam, queued.motion, queued.seconds, height, turn, app.preferences.zoom_reverse); + device_camera(&mut app.cam, queued.motion, queued.seconds, height, turn, app.preferences.zoom_reverse, app.preferences.free_orbit); } /// `seconds` of 3D mouse `motion` on `cam`, in a view `height` points tall. A full deflection /// pans about 0.6 of the view's height a second and turns about two radians a second, whatever /// the frame rate. With `turn` off the view keeps its orientation. -pub(crate) fn device_camera(cam: &mut Camera, motion: Motion, seconds: f64, height: f64, turn: bool, reverse_zoom: bool) { +pub(crate) fn device_camera(cam: &mut Camera, motion: Motion, seconds: f64, height: f64, turn: bool, reverse_zoom: bool, free_orbit: bool) { if !(seconds > 0.0) || !seconds.is_finite() { return; } @@ -591,8 +592,12 @@ pub(crate) fn device_camera(cam: &mut Camera, motion: Motion, seconds: f64, heig // These are logical 3DxWare coordinates after the profile mapping: Rx is Tilt, Ry is // Roll and Rz is Spin. The KMJ profile swaps physical Ry/Rz for the selected navigation // mode, so swapping them again here would make Roll behave like Spin. - cam.orbit(ry * 250.0 * seconds, rx * 250.0 * seconds); - cam.roll_by(rz * 2.0 * seconds); + if free_orbit { + cam.rotate_in_view(Vec3::new(-rx, -ry, rz) * (2.0 * seconds)); + } else { + cam.orbit(ry * 250.0 * seconds, rx * 250.0 * seconds); + cam.roll_by(rz * 2.0 * seconds); + } } } @@ -618,6 +623,16 @@ pub(crate) fn snapped_axis_camera(cam: Camera) -> Camera { /// direction at once; the unsnapped orbit is kept in `orbit_free` so many small deltas still /// cross snap boundaries. A small angular margin avoids flicker at a boundary. pub(crate) fn orbit_drag(app: &mut SolveApp, rect: Rect, dx: f64, dy: f64, press: Option, snap: bool, around_cursor: bool) { + // Free rotation must not enter the yaw/pitch snap: it discards roll at the poles. + if app.preferences.free_orbit { + app.viewport.orbit_free = None; + if around_cursor { + orbit(app, rect, dx, dy, press); + } else { + orbit_camera(app, dx, dy); + } + return; + } let displayed = app.cam; let previous_free = app.viewport.orbit_free.take(); if let Some(free) = previous_free { @@ -706,6 +721,14 @@ pub fn orbit(app: &mut SolveApp, rect: egui::Rect, dx: f64, dy: f64, press: Opti } }), }; + if app.preferences.free_orbit { + app.viewport.orbit_from = None; + match pivot { + Some(p) => orbit_about(app, rect, dx, dy, p), + None => orbit_camera(app, dx, dy), + } + return; + } // The drag's whole turn so far, applied to the camera it began with: the view depends on // where the pointer is, not on the path it took, so fast or erratic drags (a pitch held at // straight up or down for a while, many tiny steps) can't drift. Anything else that moves @@ -734,16 +757,293 @@ pub struct OrbitFrom { last: Camera, } +/// Rotate rigidly about the chosen pivot, keeping its camera-frame position unchanged. +pub(crate) fn orbit_about(app: &mut SolveApp, _rect: egui::Rect, dx: f64, dy: f64, pivot: Vec3) { + let coords = app.cam.frame_coords(pivot); + orbit_camera(app, dx, dy); + app.cam.place(pivot, coords); +} + +fn orbit_camera(app: &mut SolveApp, dx: f64, dy: f64) { + if app.preferences.free_orbit { + app.cam.rotate_in_view(Vec3::new(-dy * 0.008, -dx * 0.008, 0.0)); + } else { + app.cam.orbit(dx, dy); + } +} + #[cfg(test)] mod tests { + #[test] + fn clamped_drag_stays_reversible_and_restarts_after_free_orbit() { + use super::*; + let rect = Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)); + for fov in [0.0, 0.6] { + let mut app = crate::SolveApp::new(Default::default(), crate::Services::default()); + app.preferences.orbit_around = OrbitAround::Origin; + app.cam = Camera { target: Vec3::new(2.0, 3.0, 4.0), pitch: 1.4, roll: 0.7, fov, ..Default::default() }; + let start = app.cam; + for (dx, dy) in [(120.0, 400.0), (90.0, -200.0), (-210.0, -200.0)] { + orbit(&mut app, rect, dx, dy, None); + } + assert!(app.cam.back().dist(start.back()) < 1e-9); + assert!(app.cam.target.dist(start.target) < 1e-9); + app.preferences.free_orbit = true; + orbit(&mut app, rect, 0.0, 80.0, None); + assert!((app.cam.back().dot(start.back()) - 0.64_f64.cos()).abs() < 1e-9); + let mut next = app.cam; + next.orbit_about(10.0, 20.0, Vec3::ZERO); + app.preferences.free_orbit = false; + orbit(&mut app, rect, 10.0, 20.0, None); + assert!(app.cam.back().dist(next.back()) < 1e-9); + assert!(app.cam.target.dist(next.target) < 1e-9); + } + } + + fn touchpad_frame(app: &mut crate::SolveApp, ctx: &egui::Context, caps: super::NavCaps, at: egui::Pos2, mods: egui::Modifiers, time: &mut f64) { + *time += 1.0 / 60.0; + let raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 1400.0))), + time: Some(*time), + focused: true, + events: vec![egui::Event::ModifiersChanged(mods), egui::Event::PointerMoved(at)], + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| { + let rect = ui.max_rect(); + app.viewport.rect = Some(rect); + let response = ui.interact(rect, ui.id().with("touchpad-setting"), egui::Sense::click_and_drag()); + super::handle(app, ui, rect, &response, caps); + }); + out.textures_delta.clear(); + } + + #[test] + fn touchpad_free_orbit_setting_reaches_the_shared_command() { + use super::*; + for caps in [NavCaps::MODEL, NavCaps::RENDER, NavCaps { orbit: false, pick: false }] { + for up in [solidcraft_engine::render::UpAxis::Y, solidcraft_engine::render::UpAxis::Z] { + for free in [false, true] { + let ctx = egui::Context::default(); + let mut app = crate::SolveApp::new(Default::default(), crate::Services::default()); + app.preferences.nav = crate::prefs::TOUCHPAD_NAV.into(); + app.preferences.free_orbit = free; + app.ui.up_axis = up; + app.cam = Camera { pitch: 1.4, roll: 0.7, up, ..Default::default() }; + let before = app.cam; + let design = app.session.doc.to_json(); + let mut time = 0.0; + touchpad_frame(&mut app, &ctx, caps, pos2(400.0, 300.0), egui::Modifiers::NONE, &mut time); + touchpad_frame(&mut app, &ctx, caps, pos2(400.0, 300.0), egui::Modifiers::ALT, &mut time); + touchpad_frame(&mut app, &ctx, caps, pos2(400.0, 380.0), egui::Modifiers::ALT, &mut time); + if !caps.orbit { + assert_eq!(app.cam, before); + } else if free { + let expected = before.back() * 0.64_f64.cos() + before.basis().1 * 0.64_f64.sin(); + assert!(app.cam.back().dist(expected) < 1e-8, "{caps:?} {up:?}: {:?}", app.cam); + } else { + assert!(app.cam.pitch > before.pitch && app.cam.pitch < std::f64::consts::FRAC_PI_2); + } + assert_eq!((app.cam.target, app.cam.distance, app.cam.fov), (before.target, before.distance, before.fov)); + assert!(app.viewport.orbit_free.is_none()); + assert!(!ctx.input(|i| i.pointer.any_down())); + assert_eq!(app.session.doc.to_json(), design); + assert!(app.session.undo.is_empty() && app.session.selection.is_empty()); + } + } + } + } + + #[test] + fn touchpad_free_orbit_keeps_the_rigid_pivot_in_both_projections() { + use super::*; + for up in [solidcraft_engine::render::UpAxis::Y, solidcraft_engine::render::UpAxis::Z] { + for fov in [0.0, 0.6] { + for around in [OrbitAround::Origin, OrbitAround::Cursor] { + let ctx = egui::Context::default(); + let mut app = crate::SolveApp::new(Default::default(), crate::Services::default()); + app.preferences.nav = crate::prefs::TOUCHPAD_NAV.into(); + app.preferences.free_orbit = true; + app.preferences.orbit_around = around; + app.ui.perspective = fov > 0.0; + app.ui.up_axis = up; + app.cam = Camera { target: Vec3::new(2.0, 3.0, 4.0), pitch: 1.4, roll: 0.7, fov, up, ..Default::default() }; + let caps = NavCaps::MODEL; + let mut time = 0.0; + touchpad_frame(&mut app, &ctx, caps, pos2(400.0, 300.0), egui::Modifiers::NONE, &mut time); + touchpad_frame(&mut app, &ctx, caps, pos2(400.0, 300.0), egui::Modifiers::ALT, &mut time); + let pivot = if around == OrbitAround::Origin { Vec3::ZERO } else { Vec3::new(10.0, 20.0, 30.0) }; + app.viewport.orbit_pivot = Some(pivot); + let rect = app.viewport.rect.unwrap(); + let coords = app.cam.frame_coords(pivot); + let pixel = projection(&app, rect).to_screen(pivot).unwrap(); + touchpad_frame(&mut app, &ctx, caps, pos2(400.0, 380.0), egui::Modifiers::ALT, &mut time); + assert!(app.cam.frame_coords(pivot).dist(coords) < 1e-8, "{up:?} {fov} {around:?}"); + assert!(projection(&app, rect).to_screen(pivot).unwrap().distance(pixel) < 1e-3); + assert_eq!((app.cam.distance, app.cam.fov), (Camera::default().distance, fov)); + } + } + } + } + + #[test] + fn touchpad_free_orbit_completes_a_turn_and_stops_on_release() { + use super::*; + let ctx = egui::Context::default(); + let mut app = crate::SolveApp::new(Default::default(), crate::Services::default()); + app.preferences.nav = crate::prefs::TOUCHPAD_NAV.into(); + app.preferences.free_orbit = true; + app.cam = Camera { pitch: 1.4, roll: 0.7, ..Default::default() }; + let initial = app.cam; + let design = app.session.doc.to_json(); + let log = app.session.log.len(); + let mut time = 0.0; + let caps = NavCaps::MODEL; + touchpad_frame(&mut app, &ctx, caps, pos2(400.0, 300.0), egui::Modifiers::NONE, &mut time); + touchpad_frame(&mut app, &ctx, caps, pos2(400.0, 300.0), egui::Modifiers::ALT, &mut time); + let mut at = pos2(400.0, 300.0); + for step in 1..=128 { + at.y = 300.0 + (std::f64::consts::TAU * step as f64 / (128.0 * 0.008)) as f32; + touchpad_frame(&mut app, &ctx, caps, at, egui::Modifiers::ALT, &mut time); + } + assert!(app.cam.back().dist(initial.back()) < 1e-6); + assert!(app.cam.basis().0.dist(initial.basis().0) < 1e-6); + let stopped = app.cam; + for _ in 0..60 { + touchpad_frame(&mut app, &ctx, caps, at, egui::Modifiers::NONE, &mut time); + } + assert_eq!(app.cam, stopped); + assert_eq!(app.session.doc.to_json(), design); + assert_eq!(app.session.log.len(), log); + assert!(app.session.undo.is_empty() && app.session.selection.is_empty()); + } + + #[test] + fn free_orbit_is_opt_in_for_every_preset_and_shared_by_mouse_and_device() { + use super::*; + use crate::space_mouse::DeviceMotion; + let rect = Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)); + let initial = Camera { yaw: 0.0, pitch: 1.4, ..Default::default() }; + for preset in crate::prefs::NAV_PRESETS { + for enabled in [false, true] { + let mut h = crate::scenario::Harness::new(); + h.app.preferences.nav = preset.id.into(); + h.app.preferences.free_orbit = enabled; + h.app.cam = initial; + orbit_drag(&mut h.app, rect, 0.0, 80.0, None, true, false); + if enabled { + assert!((h.app.cam.back().dot(initial.back()) - 0.64_f64.cos()).abs() < 1e-9); + assert!(h.app.viewport.orbit_free.is_none()); + } else { + assert!(h.app.cam.pitch < std::f64::consts::FRAC_PI_2); + assert!(h.app.viewport.orbit_free.is_some(), "{} keeps Shift snap", preset.id); + } + h.app.cam = initial; + h.app.viewport.device_motion = + Some(DeviceMotion { motion: Motion { translation: [0.0; 3], rotation: [1.0, 0.0, 0.0] }, seconds: 0.32 }); + device(&mut h.app, rect, NavCaps::MODEL); + if enabled { + assert!((h.app.cam.back().dot(initial.back()) - 0.64_f64.cos()).abs() < 1e-9); + } else { + assert!(h.app.cam.pitch > initial.pitch && h.app.cam.pitch < std::f64::consts::FRAC_PI_2); + } + } + } + } + + #[test] + fn solidworks_mouse_orbit_passes_the_pole_without_snapping() { + use serde_json::json; + for up in [solidcraft_engine::render::UpAxis::Z, solidcraft_engine::render::UpAxis::Y] { + for button in ["middle", "right"] { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.app.preferences.nav = "solidworks".into(); + h.app.preferences.free_orbit = true; + h.app.ui.up_axis = up; + h.app.cam = solidcraft_engine::render::Camera { yaw: 0.0, pitch: 1.4, up, ..Default::default() }; + let before = h.app.cam; + h.call("ui.drag", json!({"x0":800,"y0":400,"x1":800,"y1":480,"button":button,"steps":40})); + assert!((h.app.cam.back().dot(before.back()) - 0.64_f64.cos()).abs() < 1e-6, "{up:?} {button}: {:?}", h.app.cam); + assert!(h.app.viewport.orbit_free.is_none()); + let before = h.app.cam; + h.call("ui.drag", json!({"x0":800,"y0":400,"x1":850,"y1":400,"button":"right","shift":true,"steps":20})); + assert!((h.app.cam.back().dot(before.back()) - 0.4_f64.cos()).abs() < 1e-6); + assert!(h.app.viewport.orbit_free.is_none()); + } + } + } + + #[test] + fn solidworks_device_and_mouse_keep_turning_with_the_same_screen_axes() { + use super::*; + use crate::space_mouse::DeviceMotion; + for caps in [NavCaps::MODEL, NavCaps::RENDER] { + for rotation in [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]] { + let mut h = crate::scenario::Harness::new(); + h.app.preferences.nav = "solidworks".into(); + h.app.preferences.free_orbit = true; + let rect = Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)); + let initial = Camera { yaw: 0.0, pitch: 0.0, roll: 0.7, ..Default::default() }; + h.app.cam = initial; + let mut mouse = initial; + let seconds = std::f64::consts::TAU / 512.0; + for _ in 0..256 { + let before = h.app.cam; + h.app.viewport.device_motion = Some(DeviceMotion { motion: Motion { translation: [0.0; 3], rotation }, seconds }); + device(&mut h.app, rect, caps); + assert!((before.back().dot(h.app.cam.back()) - (seconds * 2.0).cos()).abs() < 1e-9); + let device_cam = h.app.cam; + h.app.cam = mouse; + orbit_drag(&mut h.app, rect, rotation[1] * seconds * 250.0, rotation[0] * seconds * 250.0, None, true, false); + mouse = h.app.cam; + assert!(mouse.back().dist(device_cam.back()) < 1e-9 && mouse.basis().0.dist(device_cam.basis().0) < 1e-9); + h.app.cam = device_cam; + } + assert!(h.app.cam.back().dist(initial.back()) < 1e-8 && h.app.cam.basis().0.dist(initial.basis().0) < 1e-8); + } + } + // Other presets keep the existing upright camera behavior. + let mut cam = Camera { yaw: 0.0, pitch: 1.5, ..Default::default() }; + device_camera(&mut cam, Motion { translation: [0.0; 3], rotation: [1.0, 0.0, 0.0] }, 0.1, 600.0, true, false, false); + assert!(cam.pitch < std::f64::consts::FRAC_PI_2 && cam.pitch > 1.5); + } + + #[test] + fn solidworks_free_orbit_keeps_the_configured_pivot_on_screen() { + use super::*; + let mut h = crate::scenario::Harness::new(); + h.app.preferences.nav = "solidworks".into(); + h.app.preferences.free_orbit = true; + let rect = Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)); + for fov in [0.0, 0.6] { + h.app.ui.perspective = fov > 0.0; + for pivot in [Vec3::ZERO, Vec3::new(10.0, 20.0, 30.0), Vec3::new(10.0, -20.0, -30.0)] { + h.app.cam = Camera { target: Vec3::new(2.0, 3.0, 4.0), yaw: 0.0, pitch: 1.5, roll: 0.7, fov, ..Default::default() }; + let before = projection(&h.app, rect).to_screen(pivot).unwrap(); + orbit_about(&mut h.app, rect, 30.0, 50.0, pivot); + let after = projection(&h.app, rect).to_screen(pivot).unwrap(); + assert!(before.distance(after) < 1e-3, "{fov} {pivot:?}: {before:?} -> {after:?}"); + } + } + } + /// 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 the camera may only be moved through it, the view commands or the /// navigators. The 3D mouse in both apps hands its motion to [`super::device`]. #[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("]; + const PATTERNS: [&str; 8] = [ + "smooth_scroll_delta", + "raw_scroll_delta", + "zoom_delta()", + "cam.orbit(", + "cam.pan(", + "cam.zoom_at(", + "cam.roll_by(", + "cam.rotate_in_view(", + ]; let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); let dirs = [root.join("src"), root.join("../../apps/solidcraft/src"), root.join("../../apps/solidcraft-web/src")]; let allowed = ["navigation.rs"]; diff --git a/crates/ui-egui/src/navigation_cube.rs b/crates/ui-egui/src/navigation_cube.rs index b0d17f067a..b5d77a8e2b 100644 --- a/crates/ui-egui/src/navigation_cube.rs +++ b/crates/ui-egui/src/navigation_cube.rs @@ -185,7 +185,7 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { if let Some(face) = hovered.and_then(|i| faces.get(i)) { ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); ui.interact(Rect::from_center_size(hover.unwrap_or(face.centre), vec2(2.0, 2.0)), ui.id().with("navigation_cube_tip"), Sense::hover()) - .on_hover_text(format!("{} view. Drag to orbit; hold Shift to snap.", face.label)); + .on_hover_text(format!("{} view. Drag to orbit{}.", face.label, if app.preferences.free_orbit { "" } else { "; hold Shift to snap" })); if ui.input(|i| i.pointer.primary_clicked()) { navigate(app, None, Some(face.direction)); } diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index 595f6cab8a..8b77ec164e 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -378,6 +378,9 @@ preference_settings! { default_units: String = "mm".into(), 0; /// Mouse mapping: `fusion`, `solidworks` or `inventor`. nav: String = "fusion".into(), 1; + /// Opt in to rotation through both poles with roll, without the Shift axis-snap spring. + /// Independent of mouse bindings; false also preserves existing saved presets' behavior. + free_orbit: bool = false, 1 => check_tr("nav.free_orbit.label"); /// View orientation widget: original signed axes (default) or compact cube. navigator_style: NavigatorStyle = NavigatorStyle::default(), 1; /// Show the radial ring and enable hold/flick gestures for viewport context menus. @@ -1208,13 +1211,20 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { if app.preferences.nav == TOUCHPAD_NAV { ui_kit::text(ui, crate::i18n::tr("nav.touchpad.help"), ui_kit::Text::Note("navigation")); } else { - let snap_help = if app.preferences.nav == OPENSCAD_NAV { "" } else { "; hold Shift during orbit to snap" }; + let snap_help = + if app.preferences.free_orbit || app.preferences.nav == OPENSCAD_NAV { "" } else { "; hold Shift during orbit to snap" }; let raw_help = format!( "{}; Ctrl+left drag on empty space orbits{snap_help}; hold Space to pan; the wheel zooms", nav_preset(&app.preferences.nav).help ); ui_kit::text(ui, crate::i18n::tr_label(&raw_help), ui_kit::Text::Note("navigation")); } + let free_orbit = setting::free_orbit(app, ui); + crate::scenario::publish_rect("prefs:free_orbit", free_orbit.rect); + crate::scenario::publish_handle("prefs:free_orbit", free_orbit.rect.center()); + if app.preferences.free_orbit { + ui_kit::text(ui, crate::i18n::tr("nav.free_orbit.help"), ui_kit::Text::Note("free_orbit")); + } setting::radial_context_menu(app, ui); crate::mouse_gesture::nav_rows(app, ui, t); ui_kit::row(ui, app.tr_label("View navigator"), |ui| { @@ -2179,6 +2189,26 @@ mod tests { assert_eq!(h.app.session.doc.to_json(), design); } + #[test] + fn free_orbit_defaults_off_and_follows_preferences_transactions() { + let old: Prefs = serde_json::from_value(json!({"nav":"solidworks"})).unwrap(); + assert!(!old.free_orbit); + let mut h = crate::scenario::Harness::new(); + h.call("ui.prefs", json!({"open":"Navigation"})); + h.step(&json!({"click":{"handle":"prefs:free_orbit"}})).unwrap(); + assert!(h.app.preferences.free_orbit); + let mut restored = SolveApp::new(solidcraft_engine::Session::default(), Default::default()); + restored.load_prefs(&h.app.prefs()); + assert!(restored.preferences.free_orbit); + h.call("ui.prefs", json!({"button":"cancel"})); + assert!(!h.app.preferences.free_orbit); + h.call("ui.prefs", json!({"open":"Navigation","free_orbit":true,"button":"apply"})); + h.call("ui.prefs", json!({"button":"defaults"})); + assert!(!h.app.preferences.free_orbit); + h.call("ui.prefs", json!({"button":"cancel"})); + assert!(h.app.preferences.free_orbit); + } + #[test] fn radial_context_menu_defaults_off_and_follows_preferences_transactions() { assert!(!Prefs::default().radial_context_menu); diff --git a/crates/ui-egui/src/space_mouse.rs b/crates/ui-egui/src/space_mouse.rs index 664e0f6f5f..46fec0c167 100644 --- a/crates/ui-egui/src/space_mouse.rs +++ b/crates/ui-egui/src/space_mouse.rs @@ -306,7 +306,7 @@ mod tests { /// The camera math the page applies the motion with (`navigation::device`): `sketch` keeps /// the orientation, as editing a sketch does. fn navigate(cam: &mut Camera, motion: Motion, seconds: f64, height: f64, sketch: bool, reverse_zoom: bool) { - crate::navigation::device_camera(cam, motion, seconds, height, !sketch, reverse_zoom); + crate::navigation::device_camera(cam, motion, seconds, height, !sketch, reverse_zoom, false); } /// A window that has run a frame, with or without focus. A context that has never run one @@ -352,6 +352,41 @@ mod tests { assert_eq!(off.apply(PUSH), still); } + #[test] + fn solidworks_free_orbit_respects_per_axis_settings_through_both_poles() { + let ctx = window(true); + let initial = Camera { yaw: 0.0, pitch: 1.4, roll: 0.0, ..Default::default() }; + let axes = Axes { translation: [0; 3], rotation: [350, 0, 0] }; + let step = std::f64::consts::TAU / 128.0; + for mapping in [DriverMapping::default(), DriverMapping::object_mode()] { + for reversed in [false, true] { + for speed in [0.5, 2.0] { + let mut app = app(); + app.preferences.nav = "solidworks".into(); + app.preferences.free_orbit = true; + app.preferences.space_mouse_reverse[3] = reversed; + app.preferences.space_mouse_axis_speed[3] = speed; + app.cam = initial; + for i in 0..128 { + let before = app.cam; + assert!(frame(&mut app, &ctx, mapping, axes, step / (2.0 * f64::from(speed)))); + assert!((before.back().dot(app.cam.back()) - step.cos()).abs() < 1e-10); + if i == 0 { + let direction = if reversed { -1.0 } else { 1.0 }; + assert!((app.cam.back().dot(initial.basis().1) - direction * step.sin()).abs() < 1e-10); + } + } + assert!(app.cam.back().dist(initial.back()) < 1e-9 && app.cam.basis().0.dist(initial.basis().0) < 1e-9); + assert_eq!((app.cam.target, app.cam.distance, app.cam.fov), (initial.target, initial.distance, initial.fov)); + app.preferences.space_mouse_axis_speed[3] = 0.0; + let before = app.cam; + frame(&mut app, &ctx, mapping, axes, 0.04); + assert_eq!(app.cam, before); + } + } + } + } + const PUSH: Axes = Axes { translation: [0, 350, 0], rotation: [0; 3] }; fn wheel(dy: f32) -> egui::Event { diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 90903a0038..d3a9fc77d6 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -3737,8 +3737,13 @@ fn signed_axis_view(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { && let Some((name, d, _, _)) = tips.get(i) { crate::scenario::publish_handle(&format!("axis_view_hover:{name}"), hover.unwrap_or(c)); - ui.interact(Rect::from_center_size(hover.unwrap_or(c), vec2(2.0, 2.0)), ui.id().with("axis_view_tip"), Sense::hover()) - .on_hover_text(format!("View from {}. Click again to reverse. Drag to orbit; hold Shift to snap.", name.to_uppercase())); + ui.interact(Rect::from_center_size(hover.unwrap_or(c), vec2(2.0, 2.0)), ui.id().with("axis_view_tip"), Sense::hover()).on_hover_text( + format!( + "View from {}. Click again to reverse. Drag to orbit{}.", + name.to_uppercase(), + if app.preferences.free_orbit { "" } else { "; hold Shift to snap" } + ), + ); ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); if ui.input(|i| i.pointer.primary_clicked()) { let frame = app.cam.to_frame(*d); @@ -3793,18 +3798,10 @@ pub(crate) fn nearest_quarter_turn(yaw: f64) -> f64 { /// Turn the view to face the selected planar face or plane, else the active sketch; asked again /// while it already faces it, from the other side (#1886). pub fn look_at_selection(app: &mut SolveApp) { - let target = app.session.selection.iter().find_map(|s| match s { - Sel::Face { body, index, point } => crate::dialogs::planar_face(&app.session, body, *index).map(|(_, n)| (*point, n)), - Sel::Plane { name } => solidcraft_engine::geom::Plane::named(name).map(|p| (p.origin, p.normal())), - _ => None, - }); - match target { - Some((p, n)) => { - let mut to = app.cam.looking_from(crate::dialogs::facing(app, n, true)); - to.target = p; - app.animate_to(to); - } - None => crate::dialogs::look_at_sketch_again(app), + if solidcraft_engine::find_command("view.normal_to").is_some_and(|command| command.info(&app.session).disabled_reason.is_none()) { + app.start("view.normal_to"); + } else { + crate::dialogs::look_at_sketch_again(app); } } @@ -3820,6 +3817,8 @@ fn nav_bar(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { crate::i18n::tr("nav.touchpad.orbit") } else if app.preferences.nav == crate::prefs::OPENSCAD_NAV { "Orbit (left drag on empty space; Shift+left drag box-selects)" + } else if app.preferences.free_orbit { + "Orbit (right drag)" } else { "Orbit (right drag; hold Shift to snap)" }, diff --git a/docs/book/src/guide/ui-tour.md b/docs/book/src/guide/ui-tour.md index ea62b84e58..24d64f3ea2 100644 --- a/docs/book/src/guide/ui-tour.md +++ b/docs/book/src/guide/ui-tour.md @@ -34,6 +34,12 @@ in the default and Inventor presets. In every preset, Ctrl+left drag (Cmd on macOS) that starts on empty space orbits; a Ctrl+click still adds to the selection. Shift+right-drag snaps to the nearest standard or diagonal view; Shift does not request snapping when the preset already requires it to orbit. + + Enable **Preferences → Navigation → Free orbit** to rotate the mouse and 3D mouse continuously + through both poles, preserving roll without axis snapping. It defaults to off for every mouse + preset. With SolidWorks navigation, right-click a planar face and choose **Normal to Selection** + to use Look At (also Ctrl+8); repeat to flip the side, preserving scale and projection. + **Mouse gestures** (Preferences › Navigation, off by default) work with the SolidWorks mouse preset only (the wheel orbits, Ctrl + wheel pans) and take over the right button, which then no longer orbits: hold it and move the pointer 4 pt to open a ring of tools (280 pt inside, 350 pt outside), even