openscad navigation (#211)

* openscad navigation

* addressed pr feedback: fixing box selection conflict with openscad and use constant for openscad
This commit is contained in:
palaniraja authored and GitHub committed 2026-10-12 13:46:40 -04:00
1 parent a04ef4ca59
commit 6dab0fb2c1
5 files changed
+167 -10

No files matched your search

+2
View File
@@ -137,6 +137,8 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
## View navigation
- OpenSCAD navigation preset: left drag orbits, right/middle drag pans, Shift+right/middle
drag zooms, and the wheel zooms. Available in the shared desktop/web Navigation preferences.
- Compact clickable signed X/Y/Z axis tips, smooth drag-to-orbit, and Shift-orbit snapping
to 26 standard and diagonal views. Pointer motion accumulates independently of a lightly
damped spring, with a boundary margin to prevent flicker. Z-up/Y-up and the nearest square
+1 -1
View File
@@ -1326,7 +1326,7 @@ fn track_hold(app: &mut SolveApp, ctx: &egui::Context) {
i.time,
)
});
let free = app.tool.is_none() && app.dialog.is_none();
let free = app.tool.is_none() && app.dialog.is_none() && app.preferences.nav != crate::prefs::OPENSCAD_NAV;
if pressed && free && app.viewport.context_menu.is_none() && app.viewport.mouse.is_some() {
app.menu.press = pos.map(|p| (p, time));
}
+32 -3
View File
@@ -18,6 +18,7 @@ use crate::theme::Tokens;
use crate::viewport::NavMode;
pub const UNITS: [&str; 5] = ["mm", "cm", "m", "in", "ft"];
pub const OPENSCAD_NAV: &str = "openscad";
/// A mouse button that can drag the camera.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum NavButton {
@@ -81,7 +82,7 @@ use NavButton::{Left, Middle, Right};
use NavMode::{Orbit, Pan, Zoom};
/// The mouse mappings. The first is the default.
pub const NAV_PRESETS: [NavPreset; 5] = [
pub const NAV_PRESETS: [NavPreset; 6] = [
NavPreset {
id: "fusion",
label: "Fusion",
@@ -134,6 +135,19 @@ pub const NAV_PRESETS: [NavPreset; 5] = [
reverse_zoom: false,
bindings: &[bind(Right, NavKeys::SHIFT, Pan), bind(Right, NavKeys::NONE, Orbit), bind(Middle, NavKeys::NONE, Pan)],
},
NavPreset {
id: OPENSCAD_NAV,
label: "OpenSCAD",
help: "Left drag on empty space orbits, Shift+left drag box-selects, sketch geometry drags edit; right or middle drag pans, Shift+right or Shift+middle drag zooms",
reverse_zoom: false,
bindings: &[
bind(Left, NavKeys::NONE, Orbit),
bind(Right, NavKeys::SHIFT, Zoom),
bind(Middle, NavKeys::SHIFT, Zoom),
bind(Right, NavKeys::NONE, Pan),
bind(Middle, NavKeys::NONE, Pan),
],
},
];
/// The preset with this id (the default for an unknown one).
@@ -714,7 +728,8 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
}
});
ui.end_row();
note(ui, &format!("{}; hold Shift during orbit to snap; the wheel zooms", nav_preset(&app.preferences.nav).help));
let snap_help = if app.preferences.nav == OPENSCAD_NAV { "" } else { "; hold Shift during orbit to snap" };
note(ui, &format!("{}{snap_help}; 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();
@@ -871,7 +886,7 @@ pub fn set(app: &mut SolveApp, p: &serde_json::Value) -> Result<serde_json::Valu
return Err(format!("units must be one of {}", UNITS.join(", ")));
}
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());
return Err("`nav` must be fusion, solidworks, inventor, alias, tinkercad or openscad and `msaa` 1, 2, 4 or 8".into());
}
if !THEMES.iter().any(|(id, _)| *id == new.theme) || !["z", "y"].contains(&new.up_axis.as_str()) {
return Err("`theme` must be dark, light or system and `up_axis` z or y".into());
@@ -959,6 +974,20 @@ mod tests {
assert!(!app.preferences.smooth_wheel_zoom);
}
#[test]
fn openscad_preset_maps_navigation_and_persists() {
let mut app = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
set(&mut app, &json!({"nav": OPENSCAD_NAV})).unwrap();
for button in [NavButton::Middle, NavButton::Right] {
assert_eq!(nav_mode(&app, button, Modifiers::default()), Some(NavMode::Pan));
assert_eq!(nav_mode(&app, button, Modifiers { shift: true, ..Default::default() }), Some(NavMode::Zoom));
}
assert_eq!(nav_mode(&app, NavButton::Left, Modifiers::default()), Some(NavMode::Orbit));
let mut other = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
other.load_prefs(&app.prefs());
assert_eq!(other.preferences.nav, OPENSCAD_NAV);
}
#[test]
fn presets_map_the_mouse_and_prefs_round_trip() {
let mut app = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
+130 -4
View File
@@ -1622,7 +1622,20 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
// 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);
let openscad_orbit = app.preferences.nav == crate::prefs::OPENSCAD_NAV
&& app.tool.is_none()
&& app.dialog.is_none()
&& app.viewport.context_menu.is_none()
&& !mods.shift
&& hover.is_some_and(|p| {
let proj = projection(app, rect);
pick(app, &proj, p).is_empty() && crate::dim_view::hit(p).is_none() && crate::sketch_tools::glyph_at(p).is_none()
});
app.viewport.primary_nav = if app.preferences.nav != crate::prefs::OPENSCAD_NAV || openscad_orbit {
crate::prefs::nav_mode(app, crate::prefs::NavButton::Left, mods)
} else {
None
};
} else if !primary_down && !primary_released {
app.viewport.primary_nav = None;
}
@@ -1651,7 +1664,8 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
// 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
let snap = app.preferences.nav != crate::prefs::OPENSCAD_NAV
&& mods.shift
&& match button {
Some(b) => crate::prefs::nav_mode(app, b, without_shift) == Some(NavMode::Orbit),
None => app.viewport.nav.is_some(),
@@ -1723,6 +1737,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|| middle
|| secondary_down
|| (primary_nav.is_some() && primary_down)
|| mode.is_some()
|| app.viewport.axis_drag.is_some()
|| app.viewport.spin
|| app.viewport.orbit_snap.is_some()
@@ -1881,7 +1896,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() && primary_nav.is_none() && app.session.active_sketch.is_some() {
if app.tool.is_none() && app.dialog.is_none() && primary_nav.is_none() && mode.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);
@@ -1994,6 +2009,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()
&& primary_nav.is_none()
&& mode.is_none()
&& app.viewport.point_drag.is_none()
&& !crate::dim_view::dragging()
&& !crate::sketch3d::dragging()
@@ -2707,7 +2723,14 @@ fn nav_bar(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
}
let t = Tokens::get();
let items: [(&str, &str); 10] = [
("orbit", "Orbit (right drag; hold Shift to snap)"),
(
"orbit",
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)"),
@@ -2965,6 +2988,109 @@ mod tests {
app
}
#[test]
fn openscad_left_drag_preserves_editing_and_selection() {
for action in ["point", "box", "tool"] {
let ctx = egui::Context::default();
let mut app = hover_app(false);
app.preferences.nav = crate::prefs::OPENSCAD_NAV.into();
app.ui.show_origin = false;
hover_frame(&mut app, &ctx, vec![]);
let point = projection(&app, app.viewport.rect.unwrap()).to_screen(Vec3::ZERO).unwrap();
let (start, end) =
if action == "point" { (point, point + vec2(30.0, 20.0)) } else { (point - vec2(40.0, 40.0), point + vec2(40.0, 40.0)) };
if action == "tool" {
app.start("sketch.line");
assert!(app.tool.is_some());
}
let before = app.cam;
let undo = app.session.undo.len();
let modifiers = egui::Modifiers { shift: action == "box", ..Default::default() };
let frame = |app: &mut SolveApp, mut events: Vec<egui::Event>| {
events.insert(0, egui::Event::ModifiersChanged(modifiers));
let mut out = ctx.run_ui(
egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), events, ..Default::default() },
|ui| show(app, ui),
);
out.textures_delta.clear();
};
frame(
&mut app,
vec![
egui::Event::PointerMoved(start),
egui::Event::PointerButton { pos: start, button: egui::PointerButton::Primary, pressed: true, modifiers },
],
);
frame(&mut app, vec![egui::Event::PointerMoved(end)]);
frame(&mut app, vec![]);
assert_eq!(app.cam, before, "{action} drag must not orbit");
if action == "point" {
assert!(app.viewport.point_drag.is_some());
assert_eq!(app.session.undo.len(), undo + 1);
} else if action == "box" {
assert!(app.viewport.boxsel.is_some());
} else {
assert!(app.tool.is_some());
assert!(app.viewport.primary_nav.is_none());
}
frame(&mut app, vec![egui::Event::PointerButton { pos: end, button: egui::PointerButton::Primary, pressed: false, modifiers }]);
if action == "box" {
assert!(!app.session.selection.is_empty());
}
}
}
#[test]
fn openscad_empty_space_drags_navigate_without_editing_or_box_selecting() {
for (button, shift) in [
(egui::PointerButton::Primary, false),
(egui::PointerButton::Secondary, false),
(egui::PointerButton::Middle, false),
(egui::PointerButton::Secondary, true),
(egui::PointerButton::Middle, true),
] {
let ctx = egui::Context::default();
let mut app = hover_app(false);
app.preferences.nav = crate::prefs::OPENSCAD_NAV.into();
hover_frame(&mut app, &ctx, vec![]);
let point = projection(&app, app.viewport.rect.unwrap()).to_screen(Vec3::ZERO).unwrap();
let p = if button == egui::PointerButton::Primary { point + vec2(80.0, 80.0) } else { point };
let before = app.cam;
let undo = app.session.undo.len();
let modifiers = egui::Modifiers { shift, ..Default::default() };
let frame = |app: &mut SolveApp, mut events: Vec<egui::Event>| {
events.insert(0, egui::Event::ModifiersChanged(modifiers));
let _ = ctx.run_ui(
egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), events, ..Default::default() },
|ui| show(app, ui),
);
};
frame(&mut app, vec![egui::Event::PointerMoved(p), egui::Event::PointerButton { pos: p, button, pressed: true, modifiers }]);
let end = p + vec2(30.0, 20.0);
frame(&mut app, vec![egui::Event::PointerMoved(end)]);
frame(&mut app, vec![]);
assert!(app.viewport.hover.is_none());
assert!(app.viewport.boxsel.is_none() && app.viewport.point_drag.is_none());
frame(&mut app, vec![egui::Event::PointerButton { pos: end, button, pressed: false, modifiers }]);
assert_eq!(app.session.undo.len(), undo);
assert!(app.session.selection.is_empty());
assert!(app.viewport.context_menu.is_none());
if button == egui::PointerButton::Primary {
assert_ne!(app.cam.yaw, before.yaw);
assert_eq!(app.cam.target, before.target);
assert_eq!(app.cam.distance, before.distance);
} else if shift {
assert_ne!(app.cam.distance, before.distance);
assert_eq!(app.cam.target, before.target);
assert_eq!(app.cam.yaw, before.yaw);
} else {
assert_ne!(app.cam.target, before.target);
assert_eq!(app.cam.distance, before.distance);
assert_eq!(app.cam.yaw, before.yaw);
}
}
}
#[test]
fn hover_stays_off_between_navigation_events_and_returns_on_release() {
for gpu in [false, true] {
+2 -2
View File
@@ -53,7 +53,7 @@ unit regardless of the design, pass an expression with one: `"value": "50 mm"` o
| `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.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); `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.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 |
| `ui.rename` | `text?`, `commit?` (default true) | finishes the rename box a Rename item opened |
@@ -76,7 +76,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` (copies diagnostics) | the version, commit, build date and diagnostic text |
| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `tinkercad`), `zoom_reverse`, `zoom_to_cursor`, `smooth_wheel_zoom` (default true), `orbit_cursor`, `look_at_anim`, `look_at_speed`, `msaa`, `length_decimals`, `angle_decimals`, `theme` (`dark`, `light`, `system`), `language` (`en`, `ja`; see [localization](localization.md)), `ui_scale`, `up_axis` (`z`, `y`), `offer_stand_up`; `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`), `zoom_reverse`, `zoom_to_cursor`, `smooth_wheel_zoom` (default true), `orbit_cursor`, `look_at_anim`, `look_at_speed`, `msaa`, `length_decimals`, `angle_decimals`, `theme` (`dark`, `light`, `system`), `language` (`en`, `ja`; see [localization](localization.md)), `ui_scale`, `up_axis` (`z`, `y`), `offer_stand_up`; `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 |