diff --git a/ROADMAP.md b/ROADMAP.md index 13869441..59e5c723 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -59,6 +59,11 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings. | M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Per-row body materials and appearances (library looks, custom colour/opacity and clearing overrides), undoable activation and save/reopen: done. Ordered typed configuration rules, atomic validation and structured rule editing: done. Component appearance and certified semantic face style columns, atomic preview/apply and independent-copy remapping: done. Exact constructed sketch-side and immutable imported-face lineage now preserve legacy names and reject retargeting. Left: ambiguous shifted/two-sided caps | in progress | 4 | | M13 | Never lose work: atomic saves (temporary file, flush, rename; a crash at any moment leaves the old file or the new one) with the replaced version kept as `.bak`; autosave of unsaved changes to the recovery folder every 5 minutes (preference) and after big operations, only when something changed; "Recover unsaved design?" on the next launch (`doc.recovery_list`, `doc.recover`, `doc.recovery_discard`); a kill-mid-save test (process killed while saving a 0.5 MB design 25 times). File format: `"format": "solidcraft/12"`, older formats upgraded step by step (`doc::format`), newer ones refused with a message, out-of-range numbers and broken placements refused; a corpus of designs saved by M0, components-era, joints-era and current builds (apps/solidcraft-cli/tests/corpus) that must open and evaluate as saved; damaged and hostile variants (truncation, bit flips, hostile values, removed keys, repeated features, deep nesting) never panic | done | 6 | +M6 navigation preferences include **Touchpad**: Shift + pointer movement pans, Alt/Option + +pointer movement orbits without a mouse button, and vertical scrolling zooms. Other presets +retain their saved two-finger scroll preference; camera motion is available through +`view.navigate` and modifier-aware `ui.move` automation. + ## Physical material data - Physical material card validation (#923/#922) provides typed SI mechanical and diff --git a/assets/locales/de.json b/assets/locales/de.json index 298b649a..b136916c 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -4,6 +4,12 @@ "1. Open GitHub draft below. Your title and report will be filled in.": "1. Unten den GitHub-Entwurf öffnen. Titel und Bericht werden vorausgefüllt.", "2. Attach your screenshot: copy and paste it, or save and upload the PNG.": "2. Screenshot anhängen: kopieren und einfügen oder als PNG speichern und hochladen.", "3. Click Submit new issue on GitHub to send your report.": "3. Auf GitHub auf Submit new issue klicken, um den Bericht zu senden.", + "nav.touchpad.label": "Touchpad", + "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.orbit": "Drehen (Alt/Option halten und bewegen; ohne Maustaste)", + "nav.touchpad.pan": "Verschieben (Umschalt halten und bewegen; ohne Maustaste)", + "nav.touchpad.zoom": "Zoom (vertikal scrollen oder Finger spreizen/zusammenziehen)", + "nav.touchpad.scroll": "Touchpad verwendet vertikales Scrollen zum Zoomen. Die Zwei-Finger-Verschiebung bleibt für andere Vorgaben gespeichert.", "browser.body_name": "Körper {number}", "browser.component_name": "Komponente {number}", "browser.parameters": "Parameter ({count})", diff --git a/assets/locales/en.json b/assets/locales/en.json index a72660a5..71c95196 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -4,6 +4,12 @@ "1. Open GitHub draft below. Your title and report will be filled in.": "1. Open GitHub draft below. Your title and report will be filled in.", "2. Attach your screenshot: copy and paste it, or save and upload the PNG.": "2. Attach your screenshot: copy and paste it, or save and upload the PNG.", "3. Click Submit new issue on GitHub to send your report.": "3. Click Submit new issue on GitHub to send your report.", + "nav.touchpad.label": "Touchpad", + "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.orbit": "Orbit (hold Alt/Option and move; no mouse button)", + "nav.touchpad.pan": "Pan (hold Shift and move; no mouse button)", + "nav.touchpad.zoom": "Zoom (vertical scroll or pinch)", + "nav.touchpad.scroll": "Touchpad uses vertical scroll to zoom. Your two-finger pan setting is kept for other presets.", "browser.body_name": "Body{number}", "browser.component_name": "Component{number}", "browser.parameters": "Parameters ({count})", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index 0ab3584f..a47fec71 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -4,6 +4,12 @@ "1. Open GitHub draft below. Your title and report will be filled in.": "1. 下のボタンでGitHubの下書きを開きます。タイトルとレポートは入力済みです。", "2. Attach your screenshot: copy and paste it, or save and upload the PNG.": "2. スクリーンショットを添付します。コピーして貼り付けるか、PNGを保存してアップロードします。", "3. Click Submit new issue on GitHub to send your report.": "3. GitHubでSubmit new issueをクリックしてレポートを送信します。", + "nav.touchpad.label": "タッチパッド", + "nav.touchpad.help": "Shift を押しながらポインターを動かすとパン、Alt/Option を押しながら動かすと回転します。マウスボタンは不要です。縦スクロールでズームします。", + "nav.touchpad.orbit": "回転(Alt/Option を押しながら移動、マウスボタン不要)", + "nav.touchpad.pan": "パン(Shift を押しながら移動、マウスボタン不要)", + "nav.touchpad.zoom": "ズーム(縦スクロールまたはピンチ)", + "nav.touchpad.scroll": "タッチパッドでは縦スクロールでズームします。2 本指パンの設定は他のプリセット用に保持されます。", "browser.body_name": "ボディ{number}", "browser.component_name": "コンポーネント{number}", "browser.parameters": "パラメータ ({count})", diff --git a/assets/locales/ko.json b/assets/locales/ko.json index 59d74d9e..a703fcc6 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -4,6 +4,12 @@ "1. Open GitHub draft below. Your title and report will be filled in.": "1. 아래에서 GitHub 초안을 여세요. 제목과 보고서가 자동으로 입력됩니다.", "2. Attach your screenshot: copy and paste it, or save and upload the PNG.": "2. 스크린샷을 첨부하세요. 복사하여 붙여넣거나 PNG로 저장한 뒤 업로드하세요.", "3. Click Submit new issue on GitHub to send your report.": "3. GitHub에서 Submit new issue를 클릭하여 보고서를 보내세요.", + "nav.touchpad.label": "터치패드", + "nav.touchpad.help": "Shift를 누른 채 포인터를 움직이면 화면이 이동하고, Alt/Option을 누른 채 움직이면 회전합니다. 마우스 버튼은 필요 없습니다. 세로 스크롤로 확대/축소합니다.", + "nav.touchpad.orbit": "회전 (Alt/Option을 누르고 이동, 마우스 버튼 불필요)", + "nav.touchpad.pan": "화면 이동 (Shift를 누르고 이동, 마우스 버튼 불필요)", + "nav.touchpad.zoom": "확대/축소 (세로 스크롤 또는 핀치)", + "nav.touchpad.scroll": "터치패드는 세로 스크롤로 확대/축소합니다. 두 손가락 이동 설정은 다른 프리셋을 위해 유지됩니다.", "browser.body_name": "바디{number}", "browser.component_name": "컴포넌트{number}", "browser.parameters": "매개변수 ({count})", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index 31e819d2..d02b80aa 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -4,6 +4,12 @@ "1. Open GitHub draft below. Your title and report will be filled in.": "1. 点击下方按钮打开 GitHub 草稿。标题和报告将自动填入。", "2. Attach your screenshot: copy and paste it, or save and upload the PNG.": "2. 附上截图:复制并粘贴,或保存为 PNG 后上传。", "3. Click Submit new issue on GitHub to send your report.": "3. 在 GitHub 上点击 Submit new issue 发送报告。", + "nav.touchpad.label": "触控板", + "nav.touchpad.help": "按住 Shift 并移动指针可平移;按住 Alt/Option 并移动可旋转。无需按鼠标按钮。垂直滚动可缩放。", + "nav.touchpad.orbit": "旋转(按住 Alt/Option 并移动,无需鼠标按钮)", + "nav.touchpad.pan": "平移(按住 Shift 并移动,无需鼠标按钮)", + "nav.touchpad.zoom": "缩放(垂直滚动或双指捏合)", + "nav.touchpad.scroll": "触控板使用垂直滚动进行缩放。双指平移设置将保留供其他预设使用。", "browser.body_name": "实体{number}", "browser.component_name": "组件{number}", "browser.parameters": "参数 ({count})", diff --git a/crates/engine/src/cmd/view_navigation.rs b/crates/engine/src/cmd/view_navigation.rs index a12586d1..ad5f3a4f 100644 --- a/crates/engine/src/cmd/view_navigation.rs +++ b/crates/engine/src/cmd/view_navigation.rs @@ -3,13 +3,14 @@ use serde_json::{Value, json}; use solidcraft_render::{Camera, StandardView}; use super::CommandSpec; -use crate::params::{bad, vec3}; +use crate::params::{bad, num, req_vec2, req_vec3, vec3}; use crate::{Result, Session}; pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("view.navigate", "Navigate View", navigate) .icon("orbit") .noundo() - .params("camera: Camera; direction?: [x,y,z] in the camera's up-axis frame; or action: left|right|up|down|clockwise|counterclockwise|home|fit → camera, fit")]; + .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; 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?")]; fn navigate(_s: &mut Session, p: &Value) -> Result { let cmd = "view.navigate"; @@ -24,6 +25,9 @@ fn navigate(_s: &mut Session, p: &Value) -> Result { { return Err(bad(cmd, "invalid camera")); } + if p.get("motion").is_some() { + return continuous(cam, p); + } let mut fit = false; if let Some(value) = p.get("direction") { let dir = vec3(value) @@ -56,6 +60,53 @@ fn navigate(_s: &mut Session, p: &Value) -> Result { Ok(json!({"camera": cam, "fit": fit})) } +/// Direct manipulation is stateless, bounded and independent of document evaluation. +fn continuous(mut cam: Camera, p: &Value) -> Result { + let cmd = "view.navigate"; + let viewport = || { + let size = req_vec2(cmd, p, "viewport")?; + if !(1.0..=1e6).contains(&size.x) || !(1.0..=1e6).contains(&size.y) { + return Err(bad(cmd, "viewport dimensions must be in 1..1000000")); + } + Ok(size) + }; + match p.get("motion").and_then(Value::as_str) { + Some(motion @ ("pan" | "orbit")) => { + let delta = req_vec2(cmd, p, "delta")?; + if delta.x.abs() > 1e4 || delta.y.abs() > 1e4 { + return Err(bad(cmd, "motion deltas must be within ±10000 pixels")); + } + 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")?; + let size = viewport()?; + let before = cam.to_screen(pivot, size.x, size.y, 1.0); + cam.orbit(delta.x, delta.y); + let after = cam.to_screen(pivot, size.x, size.y, 1.0); + if let (Some((ax, ay, _)), Some((bx, by, _))) = (before, after) { + // Pan operates at target depth. Perspective screen correction must instead + // use the pivot's depth after orbiting; lateral pan leaves that depth fixed. + let scale = if cam.fov > 0.0 { (cam.eye() - pivot).dot(cam.back()) / cam.distance } else { 1.0 }; + cam.pan((ax - bx) * scale, (ay - by) * scale, size.y); + } + } else { + cam.orbit(delta.x, delta.y); + } + } + Some("zoom") => { + let factor = num(p, "factor").filter(|f| (0.05..=20.0).contains(f)).ok_or_else(|| bad(cmd, "factor must be in 0.05..20"))?; + let anchor = p.get("anchor").map(|_| req_vec3(cmd, p, "anchor")).transpose()?; + cam.zoom_at(factor, anchor); + } + _ => return Err(bad(cmd, "unknown navigation motion")), + } + if !cam.is_valid() { + return Err(bad(cmd, "motion produced an invalid camera")); + } + Ok(json!({"camera":cam, "fit":false, "continuous":true})) +} + /// At a halfway tie prefer the heading nearer the front plan view. pub(crate) fn square_heading(yaw: f64) -> f64 { if !yaw.is_finite() { @@ -80,6 +131,77 @@ mod tests { use solidcraft_geom::Vec3; use solidcraft_render::UpAxis; + #[test] + fn continuous_navigation_moves_the_camera_without_editing_the_design() { + let mut s = Session::default(); + let before = s.doc.to_json(); + let cam = Camera { distance: 100.0, ..Default::default() }; + for (motion, extra) in + [("pan", json!({"delta":[30,20],"viewport":[800,600]})), ("orbit", json!({"delta":[30,20]})), ("zoom", json!({"factor":0.5}))] + { + let mut params = extra; + params["camera"] = json!(cam); + params["motion"] = json!(motion); + let result = s.execute("view.navigate", ¶ms).unwrap(); + let to: Camera = serde_json::from_value(result["camera"].clone()).unwrap(); + assert_eq!(result["continuous"], true); + match motion { + "pan" => { + assert_ne!(to.target, cam.target); + assert_eq!((to.yaw, to.pitch, to.distance), (cam.yaw, cam.pitch, cam.distance)); + } + "orbit" => { + assert!((to.yaw - cam.yaw - 0.24).abs() < 1e-9); + assert!((to.pitch - cam.pitch - 0.16).abs() < 1e-9); + assert_eq!((to.target, to.distance), (cam.target, cam.distance)); + } + _ => assert_eq!(to.distance, 50.0), + } + } + assert_eq!(s.doc.to_json(), before); + assert!(s.undo.is_empty()); + } + + #[test] + fn continuous_perspective_orbit_keeps_near_and_far_pivots_on_screen() { + let mut s = Session::default(); + let cam = Camera { yaw: 0.0, pitch: 0.0, distance: 100.0, fov: 0.6, ..Default::default() }; + for pivot in [Vec3::new(15.0, -50.0, 0.0), Vec3::new(15.0, 50.0, 0.0)] { + let result = + s.execute("view.navigate", &json!({"camera":cam,"motion":"orbit","delta":[20,10],"viewport":[800,600],"pivot":pivot})).unwrap(); + let to: Camera = serde_json::from_value(result["camera"].clone()).unwrap(); + 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, "pivot {pivot:?} drifted from ({ax},{ay}) to ({bx},{by})"); + } + } + + #[test] + fn continuous_orbit_keeps_its_pivot_on_screen_and_rejects_hostile_motion() { + let mut s = Session::default(); + let cam = Camera { distance: 100.0, ..Default::default() }; + let pivot = Vec3::new(12.0, 5.0, 0.0); + let result = s.execute("view.navigate", &json!({"camera":cam,"motion":"orbit","delta":[20,10],"viewport":[800,600],"pivot":pivot})).unwrap(); + let to: Camera = serde_json::from_value(result["camera"].clone()).unwrap(); + let (ax, ay, _) = cam.to_screen(pivot, 800.0, 600.0, 1.0).unwrap(); + let (bx, by, _) = to.to_screen(pivot, 800.0, 600.0, 1.0).unwrap(); + assert!((ax - bx).abs() < 1e-6 && (ay - by).abs() < 1e-6); + for extra in [ + json!({"motion":"pan","delta":[1,2],"viewport":[800,0]}), + 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":"zoom","factor":0}), + json!({"motion":"zoom","factor":1e100}), + json!({"motion":"zoom","factor":1,"anchor":[1e100,0,0]}), + json!({"motion":"unknown"}), + ] { + let mut p = extra; + p["camera"] = json!(cam); + assert!(s.execute("view.navigate", &p).is_err(), "{p}"); + } + } + #[test] fn every_cube_direction_preserves_framing_without_editing_the_design() { for up in [UpAxis::Z, UpAxis::Y] { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 7a3a307b..662cc247 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -197,6 +197,13 @@ fn edge_bisector(m: &Mesh, index: usize, p: Vec3) -> Option { sum.normalized() } +/// Whether a dynamically registered canvas handle owns this point before navigation. +pub(crate) fn owns_pointer(app: &SolveApp, proj: &Proj, p: Pos2) -> bool { + // The triad only registers an egui hit widget after a handle is under the pointer. + // Test its geometry before navigation, including the first frame entering a handle. + app.dialog.as_ref().and_then(|d| crate::dialogs_move::triad(app, d)).is_some_and(|t| crate::gizmo::contains_pointer(proj, &t, p)) +} + /// Draw the manipulator and the value box (called by the viewport after the model is drawn). pub fn show(app: &mut SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj) { // While OK computes, the inputs hold what it computes: no arrow to drag, no box to type in. diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 2cea259d..81e5562c 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -340,7 +340,16 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) -> } "ui.move" | "ui.click" => { let (Some(x), Some(y)) = (f("x"), f("y")) else { return err("missing x/y") }; + if x.abs() > 1e6 || y.abs() > 1e6 { + return err("x/y must be within ±1000000 screen points"); + } let pos = egui::pos2(x as f32, y as f32); + let modifiers = if method == "ui.move" { + egui::Modifiers { shift: b("shift"), alt: b("alt"), ctrl: b("ctrl"), command: b("ctrl") || b("cmd"), mac_cmd: b("cmd") } + } else { + egui::Modifiers::NONE + }; + app.synthetic.push(egui::Event::ModifiersChanged(modifiers)); app.synthetic.push(egui::Event::PointerMoved(pos)); if method == "ui.click" { let button = match s("button") { @@ -366,6 +375,8 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) -> }; let modifiers = egui::Modifiers { shift: b("shift"), ctrl: b("ctrl"), command: b("ctrl"), alt: b("alt"), ..Default::default() }; let (a, z) = (egui::pos2(x0 as f32, y0 as f32), egui::pos2(x1 as f32, y1 as f32)); + // Positioning precedes the press by a frame: it is not a modifier-only gesture. + app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); app.synthetic.push(egui::Event::PointerMoved(a)); app.synthetic.push(egui::Event::PointerButton { pos: a, button, pressed: true, modifiers }); // `hold`: frames to keep the button down before moving (press-and-hold). @@ -705,6 +716,92 @@ mod tests { use super::*; use crate::Services; + #[test] + fn touchpad_automation_moves_with_modifiers_and_releases_without_buttons() { + let mut h = crate::scenario::Harness::new(); + h.app.preferences.nav = "touchpad".into(); + h.call("ui.move", json!({"x":800,"y":500})); + let before = h.app.cam; + h.call("ui.move", json!({"x":800,"y":500,"shift":true})); + h.step(&json!({"move":[830,520],"shift":true})).unwrap(); + assert_ne!(h.app.cam.target, before.target); + assert_eq!(h.app.cam.yaw, before.yaw); + assert!(!h.ctx.input(|i| i.pointer.any_down())); + h.call("ui.move", json!({"x":830,"y":520,"alt":true})); + h.step(&json!({"move":[850,530],"alt":true})).unwrap(); + assert!((h.app.cam.yaw - before.yaw - 0.16).abs() < 1e-6); + let after = h.app.cam; + h.call("ui.move", json!({"x":800,"y":500})); + h.frames(3); + assert_eq!(h.app.cam, after); + assert!(h.ctx.input(|i| i.modifiers.is_none())); + assert!(h.app.session.selection.is_empty()); + assert!(h.app.viewport.boxsel.is_none()); + } + + #[test] + fn touchpad_drag_positioning_releases_previous_motion_modifiers() { + let mut h = crate::scenario::Harness::new(); + h.app.preferences.nav = "touchpad".into(); + h.call("ui.move", json!({"x":800,"y":500})); + for modifier in ["shift", "alt"] { + let mut params = json!({"x":800,"y":500}); + params[modifier] = json!(true); + h.call("ui.move", params); + let before = h.app.cam; + h.call("ui.drag", json!({"x0":900,"y0":500,"x1":930,"y1":520,"shift":true,"steps":2})); + assert_eq!(h.app.cam, before, "positioning a selection drag must not inherit {modifier} motion"); + assert!(!h.ctx.input(|i| i.pointer.any_down())); + } + } + + #[test] + fn touchpad_text_prefix_does_not_collapse_queued_pointer_frames() { + let mut app = SolveApp::new(Session::default(), Services::default()); + let ctx = egui::Context::default(); + let (req, _) = ControlRequest::new("ui.text", json!({"text":"prefix"})); + assert!(matches!(handle(&mut app, &ctx, &req), Outcome::Done(_))); + for params in [json!({"x":10,"y":10,"shift":true}), json!({"x":20,"y":20,"alt":true}), json!({"x":30,"y":30})] { + let (req, _) = ControlRequest::new("ui.move", params); + assert!(matches!(handle(&mut app, &ctx, &req), Outcome::Done(_))); + } + for (p, shift, alt) in [(10.0, true, false), (20.0, false, true), (30.0, false, false)] { + let mut raw = egui::RawInput::default(); + app.raw_input_hook(&mut raw); + let mut out = ctx.run_ui(raw, |ui| { + ui.input(|i| { + assert_eq!(i.pointer.hover_pos(), Some(egui::pos2(p, p))); + assert_eq!((i.modifiers.shift, i.modifiers.alt), (shift, alt)); + }); + }); + out.textures_delta.clear(); + } + assert!(app.synthetic.is_empty()); + } + + #[test] + fn touchpad_automation_preserves_each_queued_moves_modifiers() { + let mut app = SolveApp::new(Session::default(), Services::default()); + let ctx = egui::Context::default(); + for params in [json!({"x":10,"y":10,"shift":true}), json!({"x":20,"y":20,"alt":true}), json!({"x":30,"y":30})] { + let (req, _) = ControlRequest::new("ui.move", params); + assert!(matches!(handle(&mut app, &ctx, &req), Outcome::Done(_))); + } + for (p, shift, alt) in [(10.0, true, false), (20.0, false, true), (30.0, false, false)] { + let mut raw = egui::RawInput::default(); + app.raw_input_hook(&mut raw); + let mut out = ctx.run_ui(raw, |ui| { + ui.input(|i| { + assert_eq!(i.pointer.hover_pos(), Some(egui::pos2(p, p))); + assert_eq!((i.modifiers.shift, i.modifiers.alt), (shift, alt)); + assert!(!i.pointer.any_down()); + }); + }); + out.textures_delta.clear(); + } + assert!(app.synthetic.is_empty()); + } + fn render(app: &mut SolveApp, path: &std::path::Path) -> Vec { let (req, _rx) = ControlRequest::new("ui.render", json!({"path": path.to_string_lossy(), "width": 200, "height": 140})); match handle(app, &egui::Context::default(), &req) { diff --git a/crates/ui-egui/src/gizmo.rs b/crates/ui-egui/src/gizmo.rs index 47691692..f3d2a916 100644 --- a/crates/ui-egui/src/gizmo.rs +++ b/crates/ui-egui/src/gizmo.rs @@ -238,6 +238,11 @@ pub(crate) fn cancel(ctx: &egui::Context, id: egui::Id) { ctx.data_mut(|d| d.remove::(id)); } +/// Hit-test before registering the dynamic egui drag widget or changing the camera. +pub(crate) fn contains_pointer(proj: &Proj, t: &Triad, p: Pos2) -> bool { + mm_per_px(proj, t.center).and_then(|scale| shapes(proj, t, scale * LEN_PX)).is_some_and(|sh| hit(&sh, p).is_some()) +} + /// Draw the triad and run a drag on it. `step` is the distance snap step. pub fn show(ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, id: egui::Id, t: &Triad, step: f64) -> Option { let tk = Tokens::get(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fd18a82b..a7e383f7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -557,7 +557,10 @@ impl SolveApp { if let Ok(v) = &r { if id == "view.navigate" { let to: Camera = serde_json::from_value(v.get("camera").cloned().unwrap_or_default()).map_err(|e| e.to_string())?; - if v.get("fit").and_then(Value::as_bool).unwrap_or(false) { + if v.get("continuous").and_then(Value::as_bool).unwrap_or(false) { + self.cancel_view_animation(); + self.cam = to; + } else if v.get("fit").and_then(Value::as_bool).unwrap_or(false) { let bounds = solidcraft_engine::view::bounds(&self.session); self.animate_to(self.fitted(to)); self.pending_fit = Some((to, bounds, true)); @@ -569,14 +572,16 @@ impl SolveApp { sketch_palette::apply_mode(self, id, v); } match &r { - Ok(_) => { + Ok(v) => { // Programmatic callers (the control channel, scripts) run outside a render // language scope, so resolve the echo from the saved preference instead of the // thread-local scope. The command's own label is translated too, so the echo // reads the same way the toolbar button it came from does. let language = i18n::Locale::for_language(&self.preferences.language); let label = language.label(command_label(id)); - self.session.echo(language.format("ui.command_done", &[("command", label)])); + if !(id == "view.navigate" && v.get("continuous").and_then(Value::as_bool) == Some(true)) { + self.session.echo(language.format("ui.command_done", &[("command", label)])); + } if matches!(id, "file.new" | "doc.open" | "doc.recover") { render_workspace::reset_document(self); self.sketch_choices = sketch_families::Choices::default(); @@ -1140,26 +1145,36 @@ impl SolveApp { if self.synthetic.is_empty() { return; } - let n = match self.synthetic.first() { - Some(egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. } | egui::Event::MouseWheel { .. }) => 1, - _ => self.synthetic.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1), - }; - if let Some(egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. }) = self.synthetic.first() { - raw.events.push(egui::Event::PointerMoved(*p)); - } - // egui keeps the modifier keys as input state, changed by their own event. - match self.synthetic.first() { - Some(egui::Event::PointerButton { modifiers, pressed, .. }) => { - // A drag keeps its press's modifiers until the release. - self.synthetic_mods = if *pressed { *modifiers } else { egui::Modifiers::default() }; - raw.events.push(egui::Event::ModifiersChanged(*modifiers)) + // Text and modifier prefixes may precede a pointer event. Never drain past that event. + let n = self + .synthetic + .iter() + .position(|e| { + matches!( + e, + egui::Event::PointerMoved(_) + | egui::Event::PointerButton { .. } + | egui::Event::PointerGone + | egui::Event::MouseWheel { .. } + | egui::Event::Key { pressed: false, .. } + ) + }) + .map_or(self.synthetic.len(), |i| i + 1); + for event in self.synthetic.drain(..n) { + match &event { + egui::Event::ModifiersChanged(modifiers) => self.synthetic_mods = *modifiers, + egui::Event::PointerButton { pos, modifiers, pressed, .. } => { + raw.events.push(egui::Event::PointerMoved(*pos)); + raw.events.push(egui::Event::ModifiersChanged(*modifiers)); + // A drag keeps its press's modifiers until the release. + self.synthetic_mods = if *pressed { *modifiers } else { egui::Modifiers::NONE }; + } + egui::Event::Key { modifiers, .. } => raw.events.push(egui::Event::ModifiersChanged(*modifiers)), + egui::Event::PointerMoved(_) => raw.events.push(egui::Event::ModifiersChanged(self.synthetic_mods)), + _ => {} } - Some(egui::Event::Key { modifiers, .. }) => raw.events.push(egui::Event::ModifiersChanged(*modifiers)), - Some(egui::Event::PointerMoved(_)) => raw.events.push(egui::Event::ModifiersChanged(self.synthetic_mods)), - _ => {} + raw.events.push(event); } - let n = n.min(self.synthetic.len()); - raw.events.extend(self.synthetic.drain(..n)); } /// Lay out the whole window. diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index d27fb239..871efa91 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -30,6 +30,7 @@ use crate::viewport::NavMode; pub const UNITS: [&str; 5] = ["mm", "cm", "m", "in", "ft"]; pub const OPENSCAD_NAV: &str = "openscad"; +pub const TOUCHPAD_NAV: &str = "touchpad"; /// A mouse button that can drag the camera. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum NavButton { @@ -93,7 +94,7 @@ use NavButton::{Left, Middle, Right}; use NavMode::{Orbit, Pan, Zoom}; /// The mouse mappings. The first is the default. -pub const NAV_PRESETS: [NavPreset; 6] = [ +pub const NAV_PRESETS: [NavPreset; 7] = [ NavPreset { id: "fusion", label: "Fusion", @@ -159,8 +160,40 @@ pub const NAV_PRESETS: [NavPreset; 6] = [ bind(Middle, NavKeys::NONE, Pan), ], }, + NavPreset { + id: TOUCHPAD_NAV, + label: "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.", + reverse_zoom: false, + bindings: &[bind(Middle, NavKeys::NONE, Pan), bind(Right, NavKeys::NONE, Orbit)], + }, ]; +impl NavPreset { + /// Button-free motion has exact modifiers, unlike the additive drag bindings. + pub fn motion_mode(&self, mods: Modifiers) -> Option { + if self.id != TOUCHPAD_NAV || mods.ctrl || mods.command || mods.mac_cmd { + return None; + } + match (mods.shift, mods.alt) { + (true, false) => Some(Pan), + (false, true) => Some(Orbit), + _ => None, + } + } + + fn translated_label(&self) -> &str { + if self.id == TOUCHPAD_NAV { crate::i18n::tr("nav.touchpad.label") } else { crate::i18n::tr_label(self.label) } + } +} + +impl Prefs { + /// Touchpad scroll zooms without overwriting the user's preference for other presets. + pub fn trackpad_pans(&self) -> bool { + self.nav != TOUCHPAD_NAV && self.trackpad_pan + } +} + /// The preset with this id (the default for an unknown one). pub fn nav_preset(id: &str) -> &'static NavPreset { NAV_PRESETS.iter().find(|p| p.id == id).unwrap_or(&NAV_PRESETS[0]) @@ -542,7 +575,7 @@ pub fn orbit(app: &mut SolveApp, rect: egui::Rect, dx: f64, dy: f64, press: Opti /// 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. -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 { use crate::viewport::Hit; for h in crate::viewport::pick(app, proj, at) { match h { @@ -927,18 +960,22 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { } 1 => { ui.label(app.tr_label("Pan, zoom, orbit")); - let current_preset_label = app.tr_label(nav_preset(&app.preferences.nav).label); + let current_preset_label = nav_preset(&app.preferences.nav).translated_label(); egui::ComboBox::from_id_salt("pref_nav").selected_text(current_preset_label).show_ui(ui, |ui| { for p in &NAV_PRESETS { - if ui.selectable_value(&mut app.preferences.nav, p.id.to_string(), crate::i18n::tr_label(p.label)).clicked() { + if ui.selectable_value(&mut app.preferences.nav, p.id.to_string(), p.translated_label()).clicked() { app.preferences.zoom_reverse = p.reverse_zoom; } } }); ui.end_row(); - let snap_help = if 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}; the wheel zooms", nav_preset(&app.preferences.nav).help); - note(ui, crate::i18n::tr_label(&raw_help)); + if app.preferences.nav == TOUCHPAD_NAV { + note(ui, crate::i18n::tr("nav.touchpad.help")); + } else { + let snap_help = if 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}; the wheel zooms", nav_preset(&app.preferences.nav).help); + note(ui, crate::i18n::tr_label(&raw_help)); + } ui.label(crate::i18n::tr_label("Context menu")); ui.checkbox(&mut app.preferences.radial_context_menu, crate::i18n::tr_label("Radial context menu")); ui.end_row(); @@ -966,9 +1003,12 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { .on_hover_text(crate::i18n::tr_label("Turn off to apply each wheel event immediately, without a smoothing tail")); ui.end_row(); ui.label(crate::i18n::tr_label("Two-finger scroll pans")); - ui.checkbox(&mut app.preferences.trackpad_pan, "").on_hover_text(crate::i18n::tr_label( - "Trackpad gestures pan (off: they zoom like the wheel); mouse wheel zooms, thumb wheel pans sideways, pinch always zooms", - )); + let touchpad = app.preferences.nav == TOUCHPAD_NAV; + ui.add_enabled(!touchpad, egui::Checkbox::new(&mut app.preferences.trackpad_pan, "")).on_hover_text(if touchpad { + crate::i18n::tr("nav.touchpad.scroll") + } else { + crate::i18n::tr_label("Trackpad gestures pan (off: they zoom like the wheel); mouse wheel zooms, thumb wheel pans sideways, pinch always zooms") + }); ui.end_row(); ui.label(crate::i18n::tr_label("Orbit around")); ui.horizontal(|ui| { @@ -1249,7 +1289,7 @@ pub fn set(app: &mut SolveApp, p: &serde_json::Value) -> Result>().join(", "))); } if new.msaa != app.preferences.msaa && !app.msaa_supported.is_empty() && !app.msaa_supported.contains(&new.msaa) { return Err(format!("this graphics device doesn't support `msaa` {}; it supports {:?}", new.msaa, app.msaa_supported)); @@ -1471,6 +1511,24 @@ mod tests { assert!(!app.preferences.smooth_wheel_zoom); } + #[test] + fn touchpad_preset_persists_and_obeys_preference_transactions() { + let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default()); + control(&mut app, &json!({"open":"Navigation", "nav":"touchpad"})).unwrap(); + assert_eq!(app.preferences.nav, "touchpad"); + assert!(app.preferences.trackpad_pan, "choosing Touchpad must not overwrite the saved scroll preference"); + control(&mut app, &json!({"button":"cancel"})).unwrap(); + assert_eq!(app.preferences.nav, "fusion"); + control(&mut app, &json!({"open":"Navigation", "nav":"touchpad", "button":"apply"})).unwrap(); + let mut other = SolveApp::new(solidcraft_engine::Session::default(), Default::default()); + other.load_prefs(&app.prefs()); + assert_eq!(other.preferences.nav, "touchpad"); + control(&mut app, &json!({"button":"defaults"})).unwrap(); + assert_eq!(app.preferences.nav, "fusion"); + control(&mut app, &json!({"button":"cancel"})).unwrap(); + assert_eq!(app.preferences.nav, "touchpad"); + } + #[test] fn openscad_preset_maps_navigation_and_persists() { let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default()); diff --git a/crates/ui-egui/src/scenario.rs b/crates/ui-egui/src/scenario.rs index 927ee040..5a530c3a 100644 --- a/crates/ui-egui/src/scenario.rs +++ b/crates/ui-egui/src/scenario.rs @@ -6,7 +6,7 @@ //! - `{"start": "solid.extrude"}`: a toolbar click; `{"key": "E", "shift"?, "cmd"?}`; `{"text": "20"}` //! - `{"widget": "Revolute", "near"?: "Type"}`: click a dialog field, choice or button by its //! text (with `near`: the one in that label's row) -//! - `{"click": AT, "double"?, "shift"?, "ctrl"?, "button"?}`, `{"move": AT}`, +//! - `{"click": AT, "double"?, "shift"?, "ctrl"?, "button"?}`, `{"move": AT, "shift"?, "alt"?, "ctrl"?, "cmd"?}`, //! `{"hold": AT, "frames"?}` (press and hold still: Select Other), //! `{"drag": [AT, AT], "shift"?, "ctrl"?, "steps"?}` where AT is `[x, y]` (screen), `{"world": [x,y,z]}` //! or `{"sketch": [x,y]}` (the sketch being edited) @@ -399,7 +399,7 @@ impl Harness { ) } else if let Some(at) = s.get("move") { let [x, y] = self.at(at)?; - self.call("ui.move", json!({"x": x, "y": y})) + self.call("ui.move", json!({"x": x, "y": y,"shift":flag("shift"),"alt":flag("alt"),"ctrl":flag("ctrl"),"cmd":flag("cmd")})) } else if let Some(d) = s.get("drag") { let [x0, y0] = self.at(d.get(0).unwrap_or(&Value::Null))?; let [x1, y1] = self.at(d.get(1).unwrap_or(&Value::Null))?; diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 1db3dada..5eb34bbc 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -66,6 +66,8 @@ pub struct ViewportState { /// The preset's navigation for the left-button drag under way (Alias: Shift+Alt), taken /// when the button went down, so the drag neither selects nor moves sketch geometry. pub primary_nav: Option, + /// Button-free gesture and its last owned pointer position, kept through pauses. + modifier_nav: Option<(NavMode, Pos2)>, /// What a click would pick now (after the active command's filter). pub hover: Option, pub mouse: Option, @@ -1988,6 +1990,45 @@ fn zoom_anchor(app: &mut SolveApp, rect: egui::Rect, hover: Option) }) } +/// Modifier-only navigation never borrows pointer movement from another widget or gesture. +fn modifier_motion(app: &mut SolveApp, ui: &egui::Ui, inside: bool, hover: Option, mods: egui::Modifiers) -> Option<(NavMode, egui::Vec2)> { + let owns = inside + && ui.input(|i| i.focused && !i.pointer.any_down() && !i.pointer.any_released() && !i.pointer.any_pressed()) + && !ui.ctx().egui_wants_keyboard_input() + && !egui::Popup::is_any_open(ui.ctx()) + && !crate::dim_view::editing() + && app.viewport.context_menu.is_none() + && app.viewport.axis_drag.is_none() + && app.viewport.point_drag.is_none() + && app.viewport.text_drag.is_none() + && app.viewport.boxsel.is_none() + && !hover.is_some_and(|p| app.viewport.rect.is_some_and(|rect| crate::canvas::owns_pointer(app, &projection(app, rect), p))) + && !hover.is_some_and(|p| app.viewport.layout.as_ref().is_some_and(|l| l.navigation.contains(p))); + let next = + hover.filter(|p| owns && p.is_finite()).and_then(|p| crate::prefs::nav_preset(&app.preferences.nav).motion_mode(mods).map(|mode| (mode, p))); + let previous = app.viewport.modifier_nav; + if previous.map(|(m, _)| m) != next.map(|(m, _)| m) { + app.viewport.orbit_pivot = None; + app.viewport.orbit_free = None; + app.viewport.orbit_snap = None; + } + app.viewport.modifier_nav = next; + next.map(|(mode, p)| { + let delta = previous.filter(|(m, _)| *m == mode).map_or(egui::Vec2::ZERO, |(_, last)| p - last); + let delta = if delta.is_finite() && delta.x.abs() <= 1e4 && delta.y.abs() <= 1e4 { delta } else { egui::Vec2::ZERO }; + (mode, delta) + }) +} + +/// Continuous camera changes use the same JSON command as programmatic callers. +fn camera_motion(app: &mut SolveApp, rect: Rect, mut params: serde_json::Value) { + let stored_fov = app.cam.fov; + params["camera"] = json!(projection(app, rect).cam); + params["viewport"] = json!([rect.width().max(1.0), rect.height().max(1.0)]); + let _ = app.run("view.navigate", params); + app.cam.fov = stored_fov; +} + pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { let t = Tokens::get(); let rect = ui.max_rect(); @@ -2022,7 +2063,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { middle_double_click(app, ui, inside); // ---- navigation ---- - let trackpad_pan = app.preferences.trackpad_pan; + 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()) { @@ -2056,23 +2097,43 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { // 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); - app.cancel_view_animation(); - app.cam.zoom_at(f, anchor); + 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); - app.cancel_view_animation(); - app.cam.zoom_at(f, anchor); + 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; - app.cancel_view_animation(); - app.cam.pan(f64::from(pan.x), f64::from(pan.y), h); + 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). @@ -2108,16 +2169,35 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { None }; let dragging = resp.dragged() || (inside && button.is_some()); - let mode = 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, + 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 } - } else { - None - }; - if mode == Some(NavMode::Orbit) { + }); + 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 && app.preferences.orbit_cursor { + let pivot = 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 + } + }; + 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), @@ -2148,7 +2228,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { } } } - if !dragging { + if !dragging && motion.is_none() { app.viewport.orbit_pivot = None; } if app.viewport.spin { @@ -2166,7 +2246,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { i.stable_dt, ) }); - if pressed || key || dragging { + 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); @@ -3354,15 +3434,17 @@ fn nav_bar(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { let items: [(&str, &str); 10] = [ ( "orbit", - if app.preferences.nav == crate::prefs::OPENSCAD_NAV { + if app.preferences.nav == crate::prefs::TOUCHPAD_NAV { + 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 { "Orbit (right drag; hold Shift to snap)" }, ), ("spin", "Continuous orbit (stops at the next click or key)"), - ("pan", "Pan (drag; or middle drag)"), - ("zoom", "Zoom (drag; or wheel)"), + ("pan", if app.preferences.nav == crate::prefs::TOUCHPAD_NAV { crate::i18n::tr("nav.touchpad.pan") } else { "Pan (drag; or middle drag)" }), + ("zoom", if app.preferences.nav == crate::prefs::TOUCHPAD_NAV { crate::i18n::tr("nav.touchpad.zoom") } else { "Zoom (drag; or wheel)" }), ("fit", "Fit (F6)"), ("lookat", "Look At the selected face or plane (or the active sketch)"), ("home", "Home view"), @@ -3384,6 +3466,7 @@ fn nav_bar(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { || ui.rect_contains_pointer(axis_rect(app, rect)) || app.viewport.axis_drag.is_some() || app.viewport.nav.is_some() + || app.viewport.modifier_nav.is_some() || app.viewport.spin || menu_open; let mut painter = ui.painter_at(rect); @@ -4077,6 +4160,220 @@ mod tests { } } + #[test] + fn touchpad_modifier_motion_navigates_without_buttons_or_document_changes() { + for alt in [false, true] { + let ctx = egui::Context::default(); + let mut app = hover_app(false); + app.preferences.nav = "touchpad".into(); + let p = pos2(350.0, 300.0); + hover_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(p)]); + let before = app.cam; + let doc = app.session.doc.to_json(); + let undo = app.session.undo.len(); + let log = app.session.log.len(); + let mods = egui::Modifiers { shift: !alt, alt, ..Default::default() }; + hover_frame(&mut app, &ctx, vec![egui::Event::ModifiersChanged(mods)]); + hover_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(p + vec2(30.0, 20.0))]); + assert!(!ctx.input(|i| i.pointer.any_down())); + if alt { + assert!((app.cam.yaw - before.yaw - 0.24).abs() < 1e-6); + assert_eq!(app.cam.target, before.target); + } else { + assert_ne!(app.cam.target, before.target); + assert_eq!(app.cam.yaw, before.yaw); + } + assert_eq!(app.cam.distance, before.distance); + let after = app.cam; + for _ in 0..3 { + hover_frame(&mut app, &ctx, vec![]); + assert_eq!(app.cam, after, "no drift between pointer events"); + assert!(app.viewport.hover.is_none()); + } + hover_frame(&mut app, &ctx, vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE), egui::Event::PointerMoved(p)]); + assert_eq!(app.cam, after, "release ends modifier-only navigation"); + assert_eq!(app.session.doc.to_json(), doc); + assert_eq!(app.session.undo.len(), undo); + assert_eq!(app.session.log.len(), log, "continuous motion must not flood the command log"); + assert!(app.session.selection.is_empty()); + assert!(app.viewport.boxsel.is_none() && app.viewport.point_drag.is_none()); + } + } + + #[test] + fn touchpad_motion_uses_the_effective_perspective_projection() { + let mut app = hover_app(false); + app.ui.perspective = true; + app.cam = Camera { yaw: 0.0, pitch: 0.0, distance: 100.0, ..Default::default() }; + let rect = Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)); + let pivot = Vec3::new(15.0, -50.0, 0.0); + let before = projection(&app, rect).to_screen(pivot).unwrap(); + camera_motion(&mut app, rect, json!({"motion":"orbit","delta":[20,10],"pivot":pivot})); + let after = projection(&app, rect).to_screen(pivot).unwrap(); + assert!(before.distance(after) < 1e-3, "rendered pivot drifted from {before:?} to {after:?}"); + assert_eq!(app.cam.fov, 0.0, "the projection toggle remains UI state, not a stored-camera mutation"); + } + + #[test] + fn touchpad_motion_does_not_move_the_camera_under_a_canvas_triad() { + let ctx = egui::Context::default(); + let mut s = solidcraft_engine::Session::default(); + s.execute("solid.box", &json!({"length":20,"width":20,"height":20})).unwrap(); + s.selection = vec![Sel::Body { name: "Body1".into() }]; + let mut app = SolveApp::new(s, crate::Services::default()); + app.preferences.nav = "touchpad".into(); + app.viewport.no_pixels = true; + app.start("solid.move"); + app.dialog.as_mut().unwrap().focus = false; + let p = pos2(100.0, 100.0); + hover_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(p)]); + ctx.memory_mut(|m| { + if let Some(id) = m.focused() { + m.surrender_focus(id); + } + }); + let triad = crate::dialogs_move::triad(&app, app.dialog.as_ref().unwrap()).unwrap(); + let handle = projection(&app, app.viewport.rect.unwrap()).to_screen(triad.center).unwrap(); + let mods = egui::Modifiers { alt: true, ..Default::default() }; + hover_frame(&mut app, &ctx, vec![egui::Event::ModifiersChanged(mods)]); + assert!(!ctx.egui_wants_keyboard_input()); + let before = app.cam; + hover_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(handle)]); + assert_eq!(app.cam, before, "a triad handle owns its input before the camera changes"); + hover_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(p)]); + assert_eq!(app.cam, before, "leaving the handle rearms without a jump"); + hover_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(p + vec2(5.0, 0.0))]); + assert_ne!(app.cam.yaw, before.yaw, "the exposed viewport remains navigable with Move open"); + } + + #[test] + fn touchpad_motion_preserves_input_ownership_and_rearms_without_jumping() { + let ctx = egui::Context::default(); + let mut app = hover_app(false); + app.preferences.nav = "touchpad".into(); + app.preferences.orbit_cursor = true; + let p = pos2(350.0, 300.0); + let alt = egui::Modifiers { alt: true, ..Default::default() }; + hover_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(p)]); + hover_frame(&mut app, &ctx, vec![egui::Event::ModifiersChanged(alt)]); + hover_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(p + vec2(10.0, 0.0))]); + let pivot = app.viewport.orbit_pivot; + assert!(pivot.is_some()); + hover_frame(&mut app, &ctx, vec![]); + assert_eq!(app.viewport.orbit_pivot, pivot, "pauses keep the same orbit pivot"); + let before = app.cam; + let bar = app.viewport.layout.as_ref().unwrap().navigation.center(); + hover_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(bar)]); + assert_eq!(app.cam, before, "the navigation bar owns its input"); + assert!(app.viewport.orbit_pivot.is_none()); + hover_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(p)]); + assert_eq!(app.cam, before, "reentry must not apply the distance from an overlay"); + hover_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(p + vec2(5.0, 0.0))]); + assert_ne!(app.cam.yaw, before.yaw); + let before = app.cam; + for mods in [ + egui::Modifiers::NONE, + egui::Modifiers { shift: true, alt: true, ..Default::default() }, + egui::Modifiers { shift: true, ctrl: true, ..Default::default() }, + egui::Modifiers { alt: true, command: true, mac_cmd: true, ..Default::default() }, + ] { + hover_frame(&mut app, &ctx, vec![egui::Event::ModifiersChanged(mods), egui::Event::PointerMoved(p)]); + assert_eq!(app.cam, before, "unbound combinations must not navigate"); + } + let shift = egui::Modifiers { shift: true, ..Default::default() }; + hover_frame(&mut app, &ctx, vec![egui::Event::ModifiersChanged(shift), egui::Event::PointerMoved(p)]); + hover_frame( + &mut app, + &ctx, + vec![egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: shift }], + ); + hover_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(p + vec2(30.0, 20.0))]); + assert_eq!(app.cam, before, "Shift+left drag must still box-select, not pan"); + assert!(app.viewport.boxsel.is_some()); + hover_frame( + &mut app, + &ctx, + vec![egui::Event::PointerButton { pos: p + vec2(30.0, 20.0), button: egui::PointerButton::Primary, pressed: false, modifiers: shift }], + ); + assert_eq!(app.cam, before, "the release frame still belongs to selection"); + } + + #[test] + fn touchpad_focus_and_preset_changes_discard_old_motion() { + let ctx = egui::Context::default(); + let mut app = hover_app(false); + app.preferences.nav = "touchpad".into(); + let alt = egui::Modifiers { alt: true, ..Default::default() }; + let frame = |app: &mut SolveApp, focused: bool, p: Pos2| { + let mut out = ctx.run_ui( + egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), + focused, + events: vec![egui::Event::ModifiersChanged(alt), egui::Event::PointerMoved(p)], + ..Default::default() + }, + |ui| show(app, ui), + ); + out.textures_delta.clear(); + }; + frame(&mut app, true, pos2(300.0, 300.0)); + frame(&mut app, true, pos2(310.0, 300.0)); + let before = app.cam; + frame(&mut app, false, pos2(500.0, 400.0)); + frame(&mut app, true, pos2(350.0, 300.0)); + assert_eq!(app.cam, before, "focus loss and regain must not jump"); + frame(&mut app, true, pos2(360.0, 300.0)); + assert!((app.cam.yaw - before.yaw - 0.08).abs() < 1e-6); + app.preferences.nav = "fusion".into(); + let before = app.cam; + frame(&mut app, true, pos2(450.0, 300.0)); + assert_eq!(app.cam, before); + app.preferences.nav = "touchpad".into(); + frame(&mut app, true, pos2(500.0, 300.0)); + assert_eq!(app.cam, before, "changing presets must rearm before moving"); + app.viewport.spin = true; + frame(&mut app, true, pos2(510.0, 300.0)); + assert!(!app.viewport.spin, "pointer-only navigation stops continuous orbit"); + assert!((app.cam.yaw - before.yaw - 0.08).abs() < 1e-6); + } + + #[test] + fn touchpad_vertical_trackpad_scroll_zooms_without_overwriting_saved_pan() { + let mut h = crate::scenario::Harness::new(); + h.app.preferences.nav = "touchpad".into(); + h.app.preferences.smooth_wheel_zoom = false; + h.app.preferences.zoom_to_cursor = false; + h.call("ui.move", json!({"x":800,"y":500})); + let before = h.app.cam; + for phase in [egui::TouchPhase::Start, egui::TouchPhase::Move, egui::TouchPhase::End] { + h.app.synthetic.push(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, 120.0), + modifiers: egui::Modifiers::NONE, + phase, + }); + h.frame(); + } + assert!((h.app.cam.distance - before.distance * (-0.18_f64).exp()).abs() < 1e-6); + assert_eq!(h.app.cam.target, before.target); + assert!(h.app.preferences.trackpad_pan); + let after = h.app.cam; + h.frames(60); + assert_eq!(h.app.cam, after); + h.app.preferences.nav = "fusion".into(); + for phase in [egui::TouchPhase::Start, egui::TouchPhase::Move, egui::TouchPhase::End] { + h.app.synthetic.push(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, 120.0), + modifiers: egui::Modifiers::NONE, + phase, + }); + h.frame(); + } + assert_eq!(h.app.cam.distance, after.distance); + assert_ne!(h.app.cam.target, after.target); + } + #[test] fn hover_stays_off_between_navigation_events_and_returns_on_release() { for gpu in [false, true] { diff --git a/docs/book/src/guide/shortcuts.md b/docs/book/src/guide/shortcuts.md index afd86c8a..babc08fd 100644 --- a/docs/book/src/guide/shortcuts.md +++ b/docs/book/src/guide/shortcuts.md @@ -42,3 +42,8 @@ Mouse and touch: | Double-click the middle button | go Home: the home view, framing everything shown (Preferences › Navigation › Middle double-click: Home, Fit or Off) | Help › Keyboard Shortcuts in the app lists the current bindings, including any you changed. + +With the **Touchpad** navigation preset, hold **Shift** while moving the pointer to pan, or +**Alt/Option** to orbit, without pressing a mouse button. Vertical scrolling zooms. These +viewport gestures are separate from rebindable command shortcuts; mouse-button selection +and editing keep their existing modifiers. diff --git a/docs/book/src/guide/ui-tour.md b/docs/book/src/guide/ui-tour.md index 0b9de9d0..b0971850 100644 --- a/docs/book/src/guide/ui-tour.md +++ b/docs/book/src/guide/ui-tour.md @@ -23,13 +23,19 @@ Esc or a click elsewhere closes the list. Clicking again on the same spot, with nothing changed in between, selects the next thing in that list. The wheel zooms at the cursor, the middle button pans and right-drag orbits freely (the mouse -< preset in Preferences switches to SolidWorks, Inventor, Alias or Tinkercad style). On a + preset in Preferences switches to SolidWorks, Inventor, Alias, Tinkercad, OpenSCAD or Touchpad style). On a trackpad, pinch zooms and two-finger scroll pans (Navigation › Two-finger scroll pans; off: scroll zooms like the wheel); mouse wheels zoom and the thumb wheel pans sideways. Shift+middle orbits freely 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. + With **Touchpad** selected in Preferences › Navigation, hold **Shift** and move the pointer to + pan, or hold **Alt** (**Option** on macOS) and move it to orbit—no mouse button is needed. + Vertical two-finger scrolling zooms in this preset, regardless of the saved two-finger pan + setting; switching presets restores that setting. Release the modifier to stop navigating. + Shift+click and Shift+left-drag still select. Combined Shift+Alt or Ctrl/Cmd modifiers do not + start pointer-only navigation. The axis view shows the world's X/Y/Z directions in SolidCraft's colors. Click a signed axis tip to look along that axis; click the aligned tip again to look from the opposite side. Drag the axis view to orbit around the view centre, with Shift for snapping. Top and bottom diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 68a811c6..114dbe21 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -106,7 +106,7 @@ unit regardless of the design, pass an expression with one: `"value": "50 mm"` o | `ui.view` | `view`: `front`, `back`, `top`, `bottom`, `left`, `right`, `iso`, `home`, `fit`; `animate` (bool, default false: snap) | | | `ui.start` | `command` | like a toolbar click: starts the sketch tool or dialog for the command | | `ui.click` | `x`, `y` (screen points), `button?`: left/right/middle, `shift?`, `ctrl?`, `double?` | real pointer input | -| `ui.move` | `x`, `y` | | +| `ui.move` | `x`, `y`, `shift?`, `alt?`, `ctrl?`, `cmd?` | pointer movement without a button; modifiers apply until the next synthetic move/click; a move with omitted modifiers releases them. With `nav: "touchpad"`, Shift pans and Alt/Option orbits. | | `ui.drag` | `x0`, `y0`, `x1`, `y1`, `button?`, `shift?`, `ctrl?`, `steps?`, `hold?`, `hold_end?` | press, move, release; a left drag on the model is a box selection (left to right: window, right to left: crossing); with OpenSCAD navigation, plain left-drag on empty space orbits, Shift+left-drag box-selects, and drags starting on sketch geometry edit it; `hold` keeps the button down that many frames first (a right press-and-hold opens the marking menu, releasing over a direction picks it), `hold_end` that many frames at the end (a sketch drag shows its snap) | | `ui.menu` | `x?`, `y?`, `target?`, `close?` | with a point: open a context menu there as a right-click would (the viewport's marking menu, or a browser menu with `target: {"type": "body", "name"} \| {"type": "sketch", "id"} \| {"type": "component", "id"}`); without: the open menu (`radial` and `items`: id, label, shortcut, enabled) | | `ui.menuPick` | `item`: id or label | runs an item of the open menu, as clicking it does | @@ -131,7 +131,7 @@ unit regardless of the design, pass an expression with one: `"value": "50 mm"` o | `ui.home` | `open?`: bool; `sample?`: index | the start page: show or hide it, open a built-in sample (once built); returns `open` and the recent designs | | `ui.shortcut` | `command`, `key?`, `replace?` | a command's key, or rebinds it (`""` clears; a key another command uses is refused unless `replace`) | | `ui.help` | `item?`: `about`, `shortcuts` (read only), `report` (opens a report draft for review), `crashes` (the Crash Reports window) | the version, commit, build date and diagnostic text | -| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `tinkercad`, `openscad`), `zoom_reverse`, `zoom_to_cursor`, `smooth_wheel_zoom` (default true), `trackpad_pan` (default true: two-finger scroll pans, pinch zooms), `orbit_cursor`, `look_at_anim`, `look_at_speed`, `middle_double_click` (`home` (default), `fit`, `off`), `msaa`, `length_decimals`, `angle_decimals`, `theme` (`dark`, `light`, `system`), `language` (`en`, `ja`, `de`, `fr`, `ko`, `ru`, `uk`, `zh`; see [localization](localization.md)), `ui_scale`, `up_axis` (`z`, `y`), `offer_stand_up`, `color_vision` (`off` (default), `protan`, `deutan`, `tritan`: colour vision modes, #778), `check_updates` (default true; [updates](book/src/guide/updates.md)); `open` (a page: General, Navigation, Keyboard Shortcuts, Design, Graphics, Sketch, Selection) opens the Preferences dialog there; `button`: `ok`, `cancel`, `apply`, `defaults`; `stand_up: true` stands up a just-opened Y-up file | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `dark` and `stand_up_offer` | +| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `tinkercad`, `openscad`, `touchpad`), `zoom_reverse`, `zoom_to_cursor`, `smooth_wheel_zoom` (default true), `trackpad_pan` (default true: two-finger scroll pans, except Touchpad always zooms; pinch zooms), `orbit_cursor`, `look_at_anim`, `look_at_speed`, `middle_double_click` (`home` (default), `fit`, `off`), `msaa`, `length_decimals`, `angle_decimals`, `theme` (`dark`, `light`, `system`), `language` (`en`, `ja`, `de`, `fr`, `ko`, `ru`, `uk`, `zh`; see [localization](localization.md)), `ui_scale`, `up_axis` (`z`, `y`), `offer_stand_up`, `color_vision` (`off` (default), `protan`, `deutan`, `tritan`: colour vision modes, #778), `check_updates` (default true; [updates](book/src/guide/updates.md)); `open` (a page: General, Navigation, Keyboard Shortcuts, Design, Graphics, Sketch, Selection) opens the Preferences dialog there; `button`: `ok`, `cancel`, `apply`, `defaults`; `stand_up: true` stands up a just-opened Y-up file | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `dark` and `stand_up_offer` | | `ui.capture` | `action?`: `capture`, `revert`, `cancel` | answers the Capture Position question (asked when a command starts or the design is saved while components were moved without capturing); without `action`, what waits | | `ui.window` | `action`: minimize, maximize, restore, toggle, close | what the title bar's caption buttons do (on Windows and Linux the application bar is the title bar); close asks about unsaved designs first | | `ui.agent_cursor` | `show` (bool), `speed`: a number from 1 to 5 (times the normal speed) or `"instant"` (no animation: the command runs at once and the pointer jumps to where it acted), `follow_camera` (bool); all optional | the settings. While the cursor is shown, `engine.execute` first glides to the command's toolbar button and to each pick, then runs and replies; later requests wait their turn. `"animate": false` on a call skips it | @@ -142,6 +142,16 @@ unit regardless of the design, pass an expression with one: `"value": "50 mm"` o Entering Render requires finishing the current modelling operation; returning restores the Design camera. See [the integrated Render workspace](render-workspace.md). +### Continuous camera motion + +Continuous camera motion is also available through `engine.execute` with command +`view.navigate`: pass `camera` (from `ui.inspect`) and `motion: "pan" | "orbit" | "zoom"`. +Pan and orbit take `delta: [dx,dy]` in screen points (each within ±10000); pan also requires +`viewport: [width,height]` (each 1..1000000). Orbit accepts a world-space `pivot` with the +viewport size. Zoom takes `factor` (0.05..20, below 1 zooms in) and an optional world-space +`anchor`. The returned `camera` is applied immediately by the UI, without animation, +document edits, undo steps or a success-log entry per movement. + ## MCP `solidcraft-cli mcp --connect 127.0.0.1:PORT` bridges an MCP client (an AI agent) to this