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:
1 parent
2cac9cad6a
commit
611422e4df
7 files changed
+278
-83
No files matched your search
@@ -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
@@ -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());
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}}
|
||||
]
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user