diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 02f3865b..57c3e02c 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -228,7 +228,7 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> Some("middle") => egui::PointerButton::Middle, _ => egui::PointerButton::Primary, }; - let modifiers = egui::Modifiers { shift: b("shift"), ctrl: b("ctrl"), command: b("ctrl"), ..Default::default() }; + let modifiers = egui::Modifiers { shift: b("shift"), ctrl: b("ctrl"), command: b("ctrl"), alt: b("alt"), ..Default::default() }; // `double: true` sends two clicks in one frame, which egui reads as a double-click. for _ in 0..if b("double") { 2 } else { 1 } { app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: true, modifiers }); @@ -244,7 +244,7 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> Some("middle") => egui::PointerButton::Middle, _ => egui::PointerButton::Primary, }; - let modifiers = egui::Modifiers { shift: b("shift"), ctrl: b("ctrl"), command: b("ctrl"), ..Default::default() }; + 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)); app.synthetic.push(egui::Event::PointerMoved(a)); app.synthetic.push(egui::Event::PointerButton { pos: a, button, pressed: true, modifiers }); diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index 0d38e554..1302069e 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -18,9 +18,128 @@ use crate::theme::Tokens; use crate::viewport::NavMode; pub const UNITS: [&str; 5] = ["mm", "cm", "m", "in", "ft"]; -/// Mouse mappings: (id, label). -pub const NAV_PRESETS: [(&str, &str); 5] = - [("fusion", "Fusion"), ("solidworks", "SolidWorks"), ("inventor", "Inventor"), ("alias", "Alias"), ("tinkercad", "Tinkercad")]; +/// A mouse button that can drag the camera. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NavButton { + Left, + Middle, + Right, +} + +/// Modifier keys a binding needs held (others may be held too). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub struct NavKeys { + pub shift: bool, + pub ctrl: bool, + pub alt: bool, +} + +impl NavKeys { + const NONE: NavKeys = NavKeys { shift: false, ctrl: false, alt: false }; + const SHIFT: NavKeys = NavKeys { shift: true, ctrl: false, alt: false }; + const CTRL: NavKeys = NavKeys { shift: false, ctrl: true, alt: false }; + const SHIFT_ALT: NavKeys = NavKeys { shift: true, ctrl: false, alt: true }; + + /// All of these keys are held (Ctrl is Cmd on macOS). + fn held(self, m: Modifiers) -> bool { + (!self.shift || m.shift) && (!self.ctrl || m.ctrl || m.command) && (!self.alt || m.alt) + } +} + +/// One camera binding: dragging `button` with `keys` held does `mode`. +#[derive(Clone, Copy, Debug)] +pub struct NavBinding { + pub button: NavButton, + pub keys: NavKeys, + pub mode: NavMode, +} + +const fn bind(button: NavButton, keys: NavKeys, mode: NavMode) -> NavBinding { + NavBinding { button, keys, mode } +} + +/// A mouse mapping: its bindings, most specific first (the first one whose button is dragged +/// with all its keys held wins). +pub struct NavPreset { + pub id: &'static str, + pub label: &'static str, + /// What the mouse does, for the Preferences page. + pub help: &'static str, + /// Choosing the preset turns the wheel the other way. + pub reverse_zoom: bool, + pub bindings: &'static [NavBinding], +} + +impl NavPreset { + /// What dragging `button` with `mods` held does, if anything. + pub fn mode(&self, button: NavButton, mods: Modifiers) -> Option { + self.bindings.iter().find(|b| b.button == button && b.keys.held(mods)).map(|b| b.mode) + } +} + +use NavButton::{Left, Middle, Right}; +use NavMode::{Orbit, Pan, Zoom}; + +/// The mouse mappings. The first is the default. +pub const NAV_PRESETS: [NavPreset; 5] = [ + NavPreset { + id: "fusion", + label: "Fusion", + help: "Middle drag pans, Shift+middle orbits, right drag orbits", + reverse_zoom: false, + bindings: &[bind(Middle, NavKeys::SHIFT, Orbit), bind(Middle, NavKeys::NONE, Pan), bind(Right, NavKeys::NONE, Orbit)], + }, + NavPreset { + id: "solidworks", + label: "SolidWorks", + help: "Middle drag orbits, Ctrl+middle pans, Shift+middle zooms, right drag orbits", + reverse_zoom: true, + bindings: &[ + bind(Middle, NavKeys::CTRL, Pan), + bind(Middle, NavKeys::SHIFT, Zoom), + bind(Middle, NavKeys::NONE, Orbit), + bind(Right, NavKeys::NONE, Orbit), + ], + }, + NavPreset { + id: "inventor", + label: "Inventor", + help: "Middle drag pans, Shift+middle orbits, Ctrl+middle zooms, right drag orbits", + reverse_zoom: false, + bindings: &[ + bind(Middle, NavKeys::SHIFT, Orbit), + bind(Middle, NavKeys::CTRL, Zoom), + bind(Middle, NavKeys::NONE, Pan), + bind(Right, NavKeys::NONE, Orbit), + ], + }, + NavPreset { + id: "alias", + label: "Alias", + help: "Shift+Alt+left drag orbits, Shift+Alt+middle pans, Shift+Alt+right zooms; middle drag also pans and right drag orbits", + reverse_zoom: false, + bindings: &[ + bind(Left, NavKeys::SHIFT_ALT, Orbit), + bind(Middle, NavKeys::SHIFT_ALT, Pan), + bind(Right, NavKeys::SHIFT_ALT, Zoom), + bind(Middle, NavKeys::CTRL, Zoom), + bind(Middle, NavKeys::NONE, Pan), + bind(Right, NavKeys::NONE, Orbit), + ], + }, + NavPreset { + id: "tinkercad", + label: "Tinkercad", + help: "Right drag orbits, middle or Shift+right drag pans", + reverse_zoom: false, + bindings: &[bind(Right, NavKeys::SHIFT, Pan), bind(Right, NavKeys::NONE, Orbit), bind(Middle, NavKeys::NONE, 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]) +} /// The dialog's pages, in order. pub const PAGES: [&str; 7] = ["General", "Navigation", "Keyboard Shortcuts", "Design", "Graphics", "Sketch", "Selection"]; pub const PAGE_SHORTCUTS: usize = 2; @@ -254,40 +373,9 @@ pub fn set_autosave(app: &mut SolveApp) { } } -/// Which navigation a mouse drag does under the preset. -pub fn nav_mode(app: &SolveApp, middle: bool, secondary: bool, mods: Modifiers) -> Option { - if secondary && !middle { - // Tinkercad: Shift+right pans; everywhere else right drag orbits. - return Some(if app.preferences.nav == "tinkercad" && mods.shift { NavMode::Pan } else { NavMode::Orbit }); - } - if !middle { - return None; - } - let ctrl = mods.ctrl || mods.command; - match app.preferences.nav.as_str() { - // Middle orbits, Ctrl+middle pans, Shift+middle zooms. - "solidworks" => Some(if ctrl { - NavMode::Pan - } else if mods.shift { - NavMode::Zoom - } else { - NavMode::Orbit - }), - // Middle pans, Shift+middle orbits, Ctrl+middle zooms. - "inventor" => Some(if mods.shift { - NavMode::Orbit - } else if ctrl { - NavMode::Zoom - } else { - NavMode::Pan - }), - // Alias: middle pans, Ctrl+middle zooms (right drag orbits). - "alias" => Some(if ctrl { NavMode::Zoom } else { NavMode::Pan }), - // Tinkercad: middle pans. - "tinkercad" => Some(NavMode::Pan), - // Fusion: middle pans, Shift+middle orbits. - _ => Some(if mods.shift { NavMode::Orbit } else { NavMode::Pan }), - } +/// Which navigation dragging `button` with `mods` held does under the preset. +pub fn nav_mode(app: &SolveApp, button: NavButton, mods: Modifiers) -> Option { + nav_preset(&app.preferences.nav).mode(button, mods) } /// +1, or −1 when the wheel zooms the other way. @@ -618,17 +706,15 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { } 1 => { ui.label("Pan, zoom, orbit"); - egui::ComboBox::from_id_salt("pref_nav") - .selected_text(NAV_PRESETS.iter().find(|(id, _)| *id == app.preferences.nav).map_or("Fusion", |x| x.1)) - .show_ui(ui, |ui| { - for (id, label) in NAV_PRESETS { - if ui.selectable_value(&mut app.preferences.nav, id.to_string(), label).clicked() { - app.preferences.zoom_reverse = id == "solidworks"; - } + egui::ComboBox::from_id_salt("pref_nav").selected_text(nav_preset(&app.preferences.nav).label).show_ui(ui, |ui| { + for p in &NAV_PRESETS { + if ui.selectable_value(&mut app.preferences.nav, p.id.to_string(), p.label).clicked() { + app.preferences.zoom_reverse = p.reverse_zoom; } - }); + } + }); ui.end_row(); - note(ui, &format!("{}; hold Shift during orbit to snap; the wheel zooms", nav_help(&app.preferences.nav))); + note(ui, &format!("{}; hold Shift during orbit to snap; the wheel zooms", nav_preset(&app.preferences.nav).help)); ui.label("Context menu"); ui.checkbox(&mut app.preferences.radial_context_menu, "Radial context menu"); ui.end_row(); @@ -732,17 +818,6 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { }); } -/// What the mouse does under a preset. -pub fn nav_help(preset: &str) -> &'static str { - match preset { - "solidworks" => "Middle drag orbits, Ctrl+middle pans, Shift+middle zooms, right drag orbits", - "inventor" => "Middle drag pans, Shift+middle orbits, Ctrl+middle zooms, right drag orbits", - "alias" => "Alt+right or right drag orbits, middle drag pans, Ctrl+middle zooms", - "tinkercad" => "Right drag orbits, middle or Shift+right drag pans", - _ => "Middle drag pans, Shift+middle orbits, right drag orbits", - } -} - /// The control channel's `ui.prefs`: `open` (a page name) opens the dialog there, `button` /// presses OK, Cancel, Apply or Restore Defaults, `stand_up: true` answers the stand-up offer; /// any other keys set preferences. Returns them all, the open page and the offer. @@ -795,7 +870,7 @@ pub fn set(app: &mut SolveApp, p: &serde_json::Value) -> Result>, time: f64, size: egui::Vec2, + /// The camera as the last `camera` step left it. + cam_mark: Option, } impl Default for Harness { @@ -162,6 +164,7 @@ impl Harness { queue: None, time: 0.0, size: egui::vec2(1600.0, 1000.0), + cam_mark: None, }; WIDTHS.with(|m| m.borrow_mut().clear()); h.install(); @@ -361,7 +364,7 @@ impl Harness { let [x, y] = self.at(at)?; // Hover first, as a hand does. self.call("ui.move", json!({"x": x, "y": y})); - let mut p = json!({"x": x, "y": y, "double": flag("double"), "shift": flag("shift"), "ctrl": flag("ctrl")}); + let mut p = json!({"x": x, "y": y, "double": flag("double"), "shift": flag("shift"), "ctrl": flag("ctrl"), "alt": flag("alt")}); if let Some(b) = s.get("button") { p["button"] = b.clone(); } @@ -382,7 +385,10 @@ impl Harness { let [x0, y0] = self.at(d.get(0).unwrap_or(&Value::Null))?; let [x1, y1] = self.at(d.get(1).unwrap_or(&Value::Null))?; let steps = s.get("steps").cloned().unwrap_or(json!(8)); - self.call("ui.drag", json!({"x0": x0, "y0": y0, "x1": x1, "y1": y1, "steps": steps, "shift": flag("shift"), "ctrl": flag("ctrl")})) + self.call( + "ui.drag", + json!({"x0": x0, "y0": y0, "x1": x1, "y1": y1, "steps": steps, "shift": flag("shift"), "ctrl": flag("ctrl"), "alt": flag("alt")}), + ) } else if let Some(m) = s.get("queued").and_then(Value::as_str) { let (v, frames) = self.call_queued(m, s.get("params").cloned().unwrap_or(json!({}))); let min = s.get("min_frames").and_then(Value::as_u64).unwrap_or(0); @@ -432,6 +438,8 @@ impl Harness { } self.app.cam_anim = None; self.frames(2); + // Where `camera.panned` and `camera.zoomed` checks measure from. + self.cam_mark = Some(self.app.cam); return Ok(()); } else if let Some(v) = s.get("view_anim").and_then(Value::as_str) { return self.view_anim(v, s.get("max_deg").and_then(Value::as_f64).unwrap_or(10.0)); @@ -611,6 +619,27 @@ pub fn check(h: &mut Harness, e: &Value) -> Result<(), String> { if !near(yaw, &v["yaw_deg"]) || !near(pitch, &v["pitch_deg"]) { return Err(format!("camera yaw {yaw:.2}°, pitch {pitch:.2}°, want {v}")); } + // Since the last `camera` step: "panned" (bool: the target moved) and "zoomed" + // ("in", "out" or "no": the eye's distance from the target). + if !v["panned"].is_null() || !v["zoomed"].is_null() { + let mark = h.cam_mark.ok_or("camera.panned/zoomed need a `camera` step first")?; + let c = &h.app.cam; + let moved = c.target.dist(mark.target) > 1e-6 * mark.distance.max(1.0); + if v["panned"].as_bool().is_some_and(|want| want != moved) { + return Err(format!("camera panned: got {moved}, want {}", v["panned"])); + } + let r = c.distance / mark.distance; + let zoomed = if r < 1.0 - 1e-6 { + "in" + } else if r > 1.0 + 1e-6 { + "out" + } else { + "no" + }; + if v["zoomed"].as_str().is_some_and(|want| want != zoomed) { + return Err(format!("camera zoomed: got {zoomed} (distance ×{r:.4}), want {}", v["zoomed"])); + } + } // "back": the world direction from the target to the eye. if let Some(want) = v["back"].as_array() { let b = h.app.cam.back(); diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 591ad73c..2ad98017 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -49,6 +49,9 @@ pub struct ViewportState { /// Nothing will look at the frame (the headless scenario harness): skip the software render. pub no_pixels: bool, pub nav: Option, + /// 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, /// What a click would pick now (after the active command's filter). pub hover: Option, pub mouse: Option, @@ -1543,7 +1546,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { app.viewport.rect = Some(rect); refresh_layout(app, ui.ctx().pixels_per_point()); let resp = ui.interact(rect, ui.id().with("viewport"), Sense::click_and_drag()); - let (hover, scroll, mods, middle, secondary_down, primary_down, delta) = ui.input(|i| { + let (hover, scroll, mods, middle, secondary_down, primary_down, primary_pressed, primary_released, delta) = ui.input(|i| { ( i.pointer.hover_pos(), i.smooth_scroll_delta.y, @@ -1551,6 +1554,8 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { i.pointer.middle_down(), i.pointer.secondary_down(), i.pointer.primary_down(), + i.pointer.primary_pressed(), + i.pointer.primary_released(), i.pointer.delta(), ) }); @@ -1597,14 +1602,28 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { app.cancel_view_animation(); app.cam.zoom_at(f, anchor); } - let dragging = resp.dragged() || (inside && (middle || secondary_down)); + // A left press with the preset's keys (Alias: Shift+Alt) navigates until the button is let + // go (kept through the release frame, so the end of the drag doesn't select either). + if primary_pressed && inside { + app.viewport.primary_nav = crate::prefs::nav_mode(app, crate::prefs::NavButton::Left, mods); + } else if !primary_down && !primary_released { + app.viewport.primary_nav = None; + } + // The fixed navigation mode (navigation bar) or the preset's left-button binding. + let primary_nav = app.viewport.nav.or(app.viewport.primary_nav); + let button = if middle { + Some(crate::prefs::NavButton::Middle) + } else if secondary_down { + Some(crate::prefs::NavButton::Right) + } else { + None + }; + let dragging = resp.dragged() || (inside && button.is_some()); let mode = if dragging && app.viewport.axis_drag.is_none() { - if middle || secondary_down { - crate::prefs::nav_mode(app, middle, secondary_down, mods) - } else if primary_down { - app.viewport.nav - } else { - None + match button { + Some(b) => crate::prefs::nav_mode(app, b, mods), + None if primary_down => primary_nav, + None => None, } } else { None @@ -1612,10 +1631,14 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { 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), rather - // than an additional request for snapping. Only an extra modifier snaps. + // Shift can be required to enter orbit (Fusion/Inventor middle drag, Alias Shift+Alt), + // rather than an additional request for snapping. Only an extra modifier snaps. let without_shift = egui::Modifiers { shift: false, ..mods }; - let snap = mods.shift && (!middle || crate::prefs::nav_mode(app, middle, secondary_down, without_shift) == Some(NavMode::Orbit)); + let snap = mods.shift + && match button { + Some(b) => crate::prefs::nav_mode(app, b, without_shift) == Some(NavMode::Orbit), + None => app.viewport.nav.is_some(), + }; orbit_drag(app, rect, f64::from(delta.x), f64::from(delta.y), press, snap, true); } else { if matches!(mode, Some(NavMode::Pan | NavMode::Zoom)) { @@ -1682,7 +1705,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { let navigating = scroll != 0.0 || middle || secondary_down - || (app.viewport.nav.is_some() && primary_down) + || (primary_nav.is_some() && primary_down) || app.viewport.axis_drag.is_some() || app.viewport.spin || app.viewport.orbit_snap.is_some() @@ -1834,7 +1857,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { } } // Dragging a sketch point of the active sketch moves it; the solver keeps the constraints. - if app.tool.is_none() && app.dialog.is_none() && app.viewport.nav.is_none() && app.session.active_sketch.is_some() { + if app.tool.is_none() && app.dialog.is_none() && primary_nav.is_none() && app.session.active_sketch.is_some() { let on_plane = |app: &SolveApp, p: Pos2| { let ss = app.session.active_sketch.and_then(|sid| app.session.world_state().sketch(sid).cloned())?; let (o, d) = proj.ray(p); @@ -1924,7 +1947,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { } // Box selection: a primary drag on the model when no navigation mode is on. if app.tool.is_none() - && app.viewport.nav.is_none() + && primary_nav.is_none() && app.viewport.point_drag.is_none() && !crate::dim_view::dragging() && !crate::sketch3d::dragging() diff --git a/crates/ui-egui/tests/scenarios/alias_navigation_bindings.json b/crates/ui-egui/tests/scenarios/alias_navigation_bindings.json new file mode 100644 index 00000000..28149244 --- /dev/null +++ b/crates/ui-egui/tests/scenarios/alias_navigation_bindings.json @@ -0,0 +1,30 @@ +[ + {"note": "Alias preset (#304): Shift+Alt+left drag orbits, Shift+Alt+middle pans, Shift+Alt+right zooms (down: out, up: in). Other presets don't navigate with Shift+Alt+left."}, + {"call": "engine.execute", "params": {"command": "solid.box", "params": {"length": 20, "width": 10, "height": 5}}}, + {"call": "ui.prefs", "params": {"nav": "alias"}}, + {"expect": {"prefs": {"nav": "alias"}}}, + + {"note": "Orbit: like a right drag (from the drag threshold on), and Shift (part of the binding) doesn't snap."}, + {"camera": {"yaw": 0, "pitch": 0}}, + {"call": "ui.drag", "params": {"x0": 800, "y0": 500, "x1": 848, "y1": 500, "steps": 8, "shift": true, "alt": true}}, + {"expect": {"camera": {"yaw_deg": 19.25, "pitch_deg": 0, "panned": false, "zoomed": "no"}, "selection": 0}}, + + {"note": "Pan."}, + {"camera": {"yaw": 0, "pitch": 0}}, + {"call": "ui.drag", "params": {"x0": 800, "y0": 500, "x1": 860, "y1": 540, "steps": 8, "shift": true, "alt": true, "button": "middle"}}, + {"expect": {"camera": {"yaw_deg": 0, "pitch_deg": 0, "panned": true, "zoomed": "no"}}}, + + {"note": "Zoom (dolly): dragging down moves away, dragging up moves closer."}, + {"camera": {"yaw": 0, "pitch": 0}}, + {"call": "ui.drag", "params": {"x0": 800, "y0": 500, "x1": 800, "y1": 560, "steps": 8, "shift": true, "alt": true, "button": "right"}}, + {"expect": {"camera": {"yaw_deg": 0, "pitch_deg": 0, "panned": false, "zoomed": "out"}, "dialog": null}}, + {"camera": {"yaw": 0, "pitch": 0}}, + {"call": "ui.drag", "params": {"x0": 800, "y0": 500, "x1": 800, "y1": 440, "steps": 8, "shift": true, "alt": true, "button": "right"}}, + {"expect": {"camera": {"yaw_deg": 0, "pitch_deg": 0, "panned": false, "zoomed": "in"}}}, + + {"note": "Fusion: Shift+Alt+left drag is not navigation."}, + {"call": "ui.prefs", "params": {"nav": "fusion"}}, + {"camera": {"yaw": 0, "pitch": 0}}, + {"call": "ui.drag", "params": {"x0": 800, "y0": 500, "x1": 848, "y1": 500, "steps": 8, "shift": true, "alt": true}}, + {"expect": {"camera": {"yaw_deg": 0, "pitch_deg": 0, "panned": false, "zoomed": "no"}}} +] diff --git a/crates/ui-egui/tests/scenarios/alias_plain_drag_box_selects.json b/crates/ui-egui/tests/scenarios/alias_plain_drag_box_selects.json new file mode 100644 index 00000000..7dc0015a --- /dev/null +++ b/crates/ui-egui/tests/scenarios/alias_plain_drag_box_selects.json @@ -0,0 +1,16 @@ +[ + {"note": "Alias preset (#304): a plain left drag still box-selects sketch geometry; the same drag with Shift+Alt orbits and selects nothing."}, + {"call": "ui.prefs", "params": {"nav": "alias"}}, + {"start": "sketch.create"}, + {"click": {"plane": "XY"}}, + {"call": "engine.execute", "params": {"command": "sketch.line", "params": {"points": [[10, 10], [30, 10]]}}}, + {"call": "ui.view", "params": {"view": "top"}}, + {"expect": {"camera": {"yaw_deg": 0, "pitch_deg": 90}}}, + {"drag": [{"sketch": [5, 4]}, {"sketch": [40, 20]}], "steps": 10}, + {"debug": 1}, + {"expect": {"selection": 3, "camera": {"yaw_deg": 0, "pitch_deg": 90}}}, + {"key": "Escape"}, + {"expect": {"selection": 0}}, + {"drag": [{"sketch": [5, 4]}, {"sketch": [40, 20]}], "steps": 10, "shift": true, "alt": true}, + {"expect": {"selection": 0, "sketching": true}} +] diff --git a/docs/book/src/guide/ui-tour.md b/docs/book/src/guide/ui-tour.md index 24dbe7d3..292e5cf0 100644 --- a/docs/book/src/guide/ui-tour.md +++ b/docs/book/src/guide/ui-tour.md @@ -14,7 +14,7 @@ row gives that item's menu. Rows drag into groups and other components. - **Viewport.** Click to select, Shift or Ctrl to add, drag for a window or crossing box. The wheel zooms at the cursor, the middle button pans and right-drag orbits freely (the mouse - preset in Preferences switches to SolidWorks or Inventor style). Shift+middle orbits freely + preset in Preferences switches to SolidWorks, Inventor, Alias or Tinkercad style). Shift+middle orbits freely in the default and Inventor presets. Shift+right-drag snaps to the nearest standard or diagonal view; Shift does not request snapping when the preset already requires it to orbit. The axis view shows the world's X/Y/Z directions in SolidCraft's colors. Click a signed @@ -44,5 +44,17 @@ trackpad event immediately, with no smoothing tail. It is independent of **Anima changes**, which controls axis views, Look At, Home and Fit. Re-enabling smoothing during a wheel gesture takes effect after that gesture ends, avoiding leftover zoom being replayed. +Navigation › **Alias** uses Alias's camera keys: Shift+Alt+left drag orbits, Shift+Alt+middle +drag pans and Shift+Alt+right drag zooms (drag down to move away, up to move closer). Without +Shift+Alt, a left drag still selects with a box and drags sketch geometry. + +Some Linux desktops take Alt+drag for themselves (moving or resizing windows), so the viewport +never sees it. To free it: + +- **KDE Plasma:** System Settings › Window Management › Window Behavior › Window Actions, set + **Modifier key** to Meta (or None). +- **GNOME:** run `gsettings set org.gnome.desktop.wm.preferences mouse-button-modifier ''` + (or set Tweaks › Windows › Window Action Key to Super). + Shift-orbit snapping includes 45-degree edge views and isometric corner views, with a lightly damped spring transition and a small boundary margin to prevent flicker.