Alias navigation preset uses Shift+Alt with LMB/MMB/RMB (Fixes #304) (#306)

Navigation presets are now a table of bindings (button, required modifier
keys, camera mode) instead of string matches. The Alias preset binds
Shift+Alt+left to orbit, Shift+Alt+middle to pan and Shift+Alt+right to
zoom. A left press with a binding's keys is latched until release, so that
drag neither box-selects nor drags sketch geometry; plain left drags behave
as before. Shift only requests snapping when the binding without Shift
still orbits.

The scenario harness sends Alt with ui.drag/ui.click, and camera checks can
assert panned/zoomed since the last camera step. The user guide notes Alt+drag
grabs by KDE and GNOME and how to free them.


Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bherbruckandClaude Opus 5.5 authored and GitHub committed 2026-10-12 10:33:53 -04:00
1 parent 2cac9cad6a
commit 611422e4df
7 files changed
+278 -83

No files matched your search

+2 -2
View File
@@ -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 });
+149 -64
View File
@@ -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<NavMode> {
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<NavMode> {
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<NavMode> {
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<serde_json::Valu
if !UNITS.contains(&new.default_units.as_str()) {
return Err(format!("units must be one of {}", UNITS.join(", ")));
}
if !NAV_PRESETS.iter().any(|(id, _)| *id == new.nav) || !MSAA.contains(&new.msaa) {
if !NAV_PRESETS.iter().any(|p| p.id == new.nav) || !MSAA.contains(&new.msaa) {
return Err("`nav` must be fusion, solidworks, inventor, alias or tinkercad and `msaa` 1, 2, 4 or 8".into());
}
if !THEMES.iter().any(|(id, _)| *id == new.theme) || !["z", "y"].contains(&new.up_axis.as_str()) {
@@ -890,15 +965,25 @@ mod tests {
let shift = Modifiers { shift: true, ..Default::default() };
let ctrl = Modifiers { ctrl: true, ..Default::default() };
let none = Modifiers::default();
assert!(matches!(nav_mode(&app, true, false, none), Some(NavMode::Pan)));
assert!(matches!(nav_mode(&app, true, false, shift), Some(NavMode::Orbit)));
assert!(matches!(nav_mode(&app, NavButton::Middle, none), Some(NavMode::Pan)));
assert!(matches!(nav_mode(&app, NavButton::Middle, shift), Some(NavMode::Orbit)));
set(&mut app, &json!({"nav": "solidworks"})).unwrap();
assert!(matches!(nav_mode(&app, true, false, none), Some(NavMode::Orbit)));
assert!(matches!(nav_mode(&app, true, false, ctrl), Some(NavMode::Pan)));
assert!(matches!(nav_mode(&app, NavButton::Middle, none), Some(NavMode::Orbit)));
assert!(matches!(nav_mode(&app, NavButton::Middle, ctrl), Some(NavMode::Pan)));
set(&mut app, &json!({"nav": "inventor", "zoom_reverse": true})).unwrap();
assert!(matches!(nav_mode(&app, true, false, ctrl), Some(NavMode::Zoom)));
assert!(matches!(nav_mode(&app, NavButton::Middle, ctrl), Some(NavMode::Zoom)));
assert_eq!(zoom_sign(&app), -1.0);
assert!(matches!(nav_mode(&app, false, true, none), Some(NavMode::Orbit)), "right drag orbits everywhere");
assert!(matches!(nav_mode(&app, NavButton::Right, none), Some(NavMode::Orbit)), "right drag orbits everywhere");
assert_eq!(nav_mode(&app, NavButton::Left, none), None, "left drag selects");
let shift_alt = Modifiers { shift: true, alt: true, ..Default::default() };
assert_eq!(nav_mode(&app, NavButton::Left, shift_alt), None, "only Alias navigates with the left button");
set(&mut app, &json!({"nav": "alias"})).unwrap();
assert_eq!(nav_mode(&app, NavButton::Left, shift_alt), Some(NavMode::Orbit));
assert_eq!(nav_mode(&app, NavButton::Middle, shift_alt), Some(NavMode::Pan));
assert_eq!(nav_mode(&app, NavButton::Right, shift_alt), Some(NavMode::Zoom));
assert_eq!(nav_mode(&app, NavButton::Left, none), None, "a plain left drag still selects");
assert_eq!(nav_mode(&app, NavButton::Left, shift), None);
assert_eq!(nav_mode(&app, NavButton::Right, none), Some(NavMode::Orbit));
assert!(set(&mut app, &json!({"nav": "blender"})).is_err());
assert!(set(&mut app, &json!({"default_units": "furlong"})).is_err());
assert!(set(&mut app, &json!({"nope": 1})).is_err());
+31 -2
View File
@@ -134,6 +134,8 @@ pub struct Harness {
queue: Option<std::sync::mpsc::Sender<ControlRequest>>,
time: f64,
size: egui::Vec2,
/// The camera as the last `camera` step left it.
cam_mark: Option<solvecraft_engine::render::Camera>,
}
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();
+37 -14
View File
@@ -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<NavMode>,
/// 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<NavMode>,
/// What a click would pick now (after the active command's filter).
pub hover: Option<Hit>,
pub mouse: Option<Pos2>,
@@ -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()
@@ -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"}}}
]
@@ -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}}
]
+13 -1
View File
@@ -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 '<Super>'`
(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.