feat(view): complete ZOOM options

Add command-line navigation modes and persistent wheel controls.\n\nRefs #412
This commit is contained in:
Hakan Seven committed 2026-08-08 20:37:53 +03:00
1 parent 3f103d2f8b
commit adb8ced8b5
17 files changed
+380 -23

No files matched your search

+2
View File
@@ -100,6 +100,7 @@ impl OpenCADStudio {
self.ucs_icon_hover = false;
self.tabs[i].pan_mode = false;
self.tabs[i].orbit_mode = false;
self.tabs[i].zoom_dynamic_mode = false;
let _ = self.on_viewport_exit();
}
@@ -1939,6 +1940,7 @@ impl OpenCADStudio {
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.tabs[i].scene.clear_preview_wire();
self.tabs[i].scene.remember_current_view();
self.tabs[i]
.scene
.zoom_to_window(p1.as_vec3(), p2.as_vec3());
+70 -13
View File
@@ -805,40 +805,94 @@ impl OpenCADStudio {
self.tabs[i].active_cmd = Some(Box::new(cmd));
}
"ZOOM EXTENTS ALL" | "ZOOM EXTENTS ALL VIEWPORTS" | "ZEA" => {
"ZOOM EXTENTS ALL" | "ZOOM EXTENTS ALL VIEWPORTS" | "ZOOM EA" | "ZEA" => {
self.tabs[i].scene.remember_current_view();
self.tabs[i].scene.fit_all_model_viewports();
self.command_line.push_output(crate::t!("Zoom Extents — All Viewports").as_ref());
}
"ZOOM EXTENTS" | "ZOOMEXTENTS" | "ZE" => {
"ZOOM EXTENTS" | "ZOOM E" | "ZOOMEXTENTS" | "ZE" => {
self.tabs[i].scene.remember_current_view();
self.tabs[i].scene.fit_all();
self.command_line.push_output(crate::t!("Zoom Extents").as_ref());
}
"ZOOM IN" | "ZI" => {
"ZOOM IN" | "ZOOM I" | "ZI" => {
self.tabs[i].scene.remember_current_view();
self.tabs[i].scene.zoom_camera(1.0 / 1.5);
self.command_line.push_output(crate::t!("Zoom In").as_ref());
}
"ZOOM OUT" | "ZO" => {
self.tabs[i].scene.remember_current_view();
self.tabs[i].scene.zoom_camera(1.5);
self.command_line.push_output(crate::t!("Zoom Out").as_ref());
}
// ZOOM ALL — fit the configured drawing limits.
"ZOOM ALL" | "ZOOM A" | "ZA" => {
self.tabs[i].scene.remember_current_view();
self.tabs[i].scene.fit_all_with_limits();
self.command_line.push_output(crate::t!("Zoom All").as_ref());
}
"ZOOM PREVIOUS" | "ZOOM P" | "ZP" => {
if self.tabs[i].scene.restore_previous_view() {
self.command_line.push_output(crate::t!("Zoom Previous").as_ref());
} else {
self.command_line
.push_error(crate::t!("ZOOM: no previous view.").as_ref());
}
}
"ZOOM OBJECT" | "ZOOM O" | "ZOBJ" => {
let handles: Vec<_> = self.tabs[i]
.scene
.selected_entities()
.into_iter()
.map(|(handle, _)| handle)
.collect();
if handles.is_empty() {
use crate::modules::draw::select::SelectObjectsCommand;
let command = SelectObjectsCommand::new("ZOOM OBJECT");
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
} else {
self.tabs[i].scene.remember_current_view();
if self.tabs[i].scene.zoom_to_entities(&handles) {
self.command_line.push_output(crate::t!("Zoom Object").as_ref());
} else {
self.command_line.push_error(
crate::t!("ZOOM: selected objects have no visible bounds.").as_ref(),
);
}
}
}
"ZOOM DYNAMIC" | "ZOOM D" | "ZD" => {
self.tabs[i].zoom_dynamic_mode = true;
self.clear_navigation_hover(i);
self.command_line.push_output(
crate::t!(
"ZOOM Dynamic: drag horizontally to pan and vertically to zoom. Press Esc to exit."
)
.as_ref(),
);
}
// ZOOM SCALE — set zoom factor (e.g. "ZOOM SCALE 2" or "ZS 0.5")
cmd if cmd.starts_with("ZOOM SCALE ") || cmd.starts_with("ZS ") => {
cmd if cmd.starts_with("ZOOM SCALE ")
|| cmd.starts_with("ZOOM S ")
|| cmd.starts_with("ZS ") =>
{
let rest = cmd
.split_once(' ')
.and_then(|(_, r)| r.split_once(' ').map(|(_, v)| v).or(Some(r)))
.strip_prefix("ZOOM SCALE ")
.or_else(|| cmd.strip_prefix("ZOOM S "))
.or_else(|| cmd.strip_prefix("ZS "))
.unwrap_or("1");
if let Ok(factor) = rest.trim().parse::<f32>() {
if factor > 0.0 {
self.tabs[i].scene.remember_current_view();
self.tabs[i].scene.zoom_camera(1.0 / factor);
self.command_line
.push_output(crate::tf!("Zoom Scale ×{factor:.3}").as_ref());
@@ -866,15 +920,18 @@ impl OpenCADStudio {
use crate::command::KeywordCommand;
let c = KeywordCommand::new(
"ZOOM",
"ZOOM [Window / Extents / Extents All / All / In / Out / Scale]:",
"ZOOM [Window / Extents / Previous / Object / All / Dynamic / Extents All / In / Out / Scale]:",
vec![
("Window", "WINDOW", None),
("Extents", "EXTENTS", None),
("Extents All", "EXTENTS ALL", None),
("All", "ALL", None),
("In", "IN", None),
("Window", "W", None),
("Extents", "E", None),
("Previous", "P", None),
("Object", "O", None),
("All", "A", None),
("Dynamic", "D", None),
("Extents All", "EA", None),
("In", "I", None),
("Out", "OUT", None),
("Scale", "SCALE", Some("ZOOM scale factor (e.g. 2 or 0.5):")),
("Scale", "S", Some("ZOOM scale factor (e.g. 2 or 0.5):")),
],
);
self.command_line.push_info(&c.prompt());
+3
View File
@@ -98,6 +98,7 @@ impl OpenCADStudio {
// command arms below re-enable the selected one).
self.tabs[i].pan_mode = false;
self.tabs[i].orbit_mode = false;
self.tabs[i].zoom_dynamic_mode = false;
// Reset the last committed point so the first click of the new command
// is not constrained by ortho/polar relative to a previous command's endpoint.
self.last_point = None;
@@ -445,6 +446,8 @@ inventory::submit!(crate::command::CommandRegistration {
"FITSPLINE",
// System variables (typeable directly).
"MIRRTEXT",
"ZOOMWHEEL",
"ZOOMFACTOR",
"ATTREQ",
"ATTDIA",
"DIMASSOC",
+39 -2
View File
@@ -866,6 +866,8 @@ impl OpenCADStudio {
cmd if matches!(
cmd.split_whitespace().next().unwrap_or(""),
"MIRRTEXT"
| "ZOOMWHEEL"
| "ZOOMFACTOR"
| "TEXTFILL"
| "ATTREQ"
| "ATTDIA"
@@ -936,7 +938,7 @@ impl OpenCADStudio {
let value = it.next().map(|s| s.trim().to_string());
if name.is_empty() || name == "?" {
self.command_line.push_info(
"SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT ATTREQ ATTDIA DIMASSOC ANGBASE ANGDIR | CLAYER CELTYPE TEXTSTYLE (read-only)",
"SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT ZOOMWHEEL ZOOMFACTOR ATTREQ ATTDIA DIMASSOC ANGBASE ANGDIR | CLAYER CELTYPE TEXTSTYLE (read-only)",
);
} else {
// Parse a boolean given as 0/1 or ON/OFF.
@@ -1028,6 +1030,37 @@ impl OpenCADStudio {
.ok_or_else(|| "SETVAR: 0 or 1 required.".into()),
None => Ok((format!("MIRRTEXT = {}", h.mirror_text as i32), false)),
},
"ZOOMWHEEL" => match &value {
Some(v) => match parse_bool(v) {
Some(reversed) => {
self.zoom_wheel_reversed = reversed;
Ok((
format!("ZOOMWHEEL = {}", reversed as i32),
true,
))
}
None => Err("SETVAR: 0 or 1 required.".into()),
},
None => Ok((
format!(
"ZOOMWHEEL = {}",
self.zoom_wheel_reversed as i32
),
false,
)),
},
"ZOOMFACTOR" => match &value {
Some(v) => match v.parse::<i32>() {
Ok(factor) if (3..=100).contains(&factor) => {
self.zoom_factor = factor;
Ok((format!("ZOOMFACTOR = {factor}"), true))
}
_ => Err("SETVAR: integer from 3 to 100 required.".into()),
},
None => {
Ok((format!("ZOOMFACTOR = {}", self.zoom_factor), false))
}
},
// Global (not stored in the drawing): fill vs. hollow
// TrueType text. The active tab re-tessellates below.
"TEXTFILL" => match &value {
@@ -1579,7 +1612,11 @@ impl OpenCADStudio {
match outcome {
Ok((msg, changed)) => {
if changed {
self.tabs[i].dirty = true;
if matches!(name.as_str(), "ZOOMWHEEL" | "ZOOMFACTOR") {
self.persist_settings_if_changed();
} else {
self.tabs[i].dirty = true;
}
self.command_line.push_output(&msg);
} else {
// Queried with no value (`changed == false`):
+4
View File
@@ -212,6 +212,9 @@ pub(super) struct DocumentTab {
/// Interactive 3-D orbit mode. While active, a left-button drag follows
/// the same camera-orbit path as Shift + middle-button drag.
pub(super) orbit_mode: bool,
/// Interactive ZOOM Dynamic mode. A left drag pans horizontally and zooms
/// vertically until Escape or another command ends the mode.
pub(super) zoom_dynamic_mode: bool,
/// Per-plugin document state (`plugin::BuiltinPlugin` manifest id → state).
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
pub(super) plugin_state: HashMap<&'static str, Box<dyn Any + Send + Sync>>,
@@ -476,6 +479,7 @@ impl DocumentTab {
is_start: false,
pan_mode: false,
orbit_mode: false,
zoom_dynamic_mode: false,
plugin_state: HashMap::new(),
suspended_cmd: None,
}
+6
View File
@@ -444,6 +444,10 @@ pub(super) struct OpenCADStudio {
polar_mode: bool,
/// Polar tracking angle increment in degrees (15 / 30 / 45 / 90).
polar_increment_deg: f32,
/// Reverse the mouse-wheel zoom direction when true (ZOOMWHEEL = 1).
zoom_wheel_reversed: bool,
/// Mouse-wheel zoom sensitivity, clamped to 3..=100 (ZOOMFACTOR).
zoom_factor: i32,
/// Show grid lines in the viewport (F7).
show_grid: bool,
/// Dynamic input overlay (F12): show coordinate tooltip near cursor.
@@ -3030,6 +3034,8 @@ impl OpenCADStudio {
ortho_mode: false,
polar_mode: false,
polar_increment_deg: 45.0,
zoom_wheel_reversed: false,
zoom_factor: 60,
show_grid: false,
dyn_input: true,
texteditmode: false,
+4
View File
@@ -97,6 +97,8 @@ pub struct UserSettings {
pub dyn_input: bool,
pub polar: bool,
pub polar_increment_deg: f32,
pub zoom_wheel_reversed: bool,
pub zoom_factor: i32,
pub otrack: bool,
// Ortho ($ORTHOMODE) and the running OSNAP set ($OSMODE) are per-drawing —
// stored in the document header, not here (they used to be persisted app-
@@ -166,6 +168,8 @@ impl Default for UserSettings {
dyn_input: true,
polar: false,
polar_increment_deg: 45.0,
zoom_wheel_reversed: false,
zoom_factor: 60,
otrack: false,
default_assoc_prompted: false,
disabled_plugins: Vec::new(),
+6
View File
@@ -660,16 +660,22 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
// drag. Orbit exits silently; PAN keeps its existing message.
if self.tabs[self.active_tab].pan_mode
|| self.tabs[self.active_tab].orbit_mode
|| self.tabs[self.active_tab].zoom_dynamic_mode
{
let i = self.active_tab;
let was_pan = self.tabs[i].pan_mode;
self.tabs[i].pan_mode = false;
self.tabs[i].orbit_mode = false;
self.tabs[i].zoom_dynamic_mode = false;
{
let mut sel = self.tabs[i].scene.selection.borrow_mut();
sel.middle_down = false;
sel.middle_last_pos = None;
sel.orbit_pivot = None;
sel.box_anchor = None;
sel.box_anchor_world = None;
sel.box_current = None;
sel.box_crossing_locked = false;
}
if was_pan {
self.command_line.push_output(crate::t!("PAN ended.").as_ref());
+1
View File
@@ -143,6 +143,7 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven
&& self.active_modal.is_none()
&& !self.tabs[i].pan_mode
&& !self.tabs[i].orbit_mode
&& !self.tabs[i].zoom_dynamic_mode
{
self.ribbon.deactivate_tool();
}
+4
View File
@@ -265,6 +265,8 @@ impl OpenCADStudio {
dyn_input: self.dyn_input,
polar: self.polar_mode,
polar_increment_deg: self.polar_increment_deg,
zoom_wheel_reversed: self.zoom_wheel_reversed,
zoom_factor: self.zoom_factor,
otrack: self.snapper.otrack_enabled,
default_assoc_prompted: self.default_assoc_prompted,
disabled_plugins: {
@@ -299,6 +301,8 @@ impl OpenCADStudio {
self.dyn_input = s.dyn_input;
self.polar_mode = s.polar;
self.polar_increment_deg = s.polar_increment_deg;
self.zoom_wheel_reversed = s.zoom_wheel_reversed;
self.zoom_factor = s.zoom_factor.clamp(3, 100);
// Ortho + running OSNAP are per-drawing (adopted from the header on
// open / tab switch), not app-global, so they are not applied here.
self.snapper.otrack_enabled = s.otrack;
+3
View File
@@ -2831,6 +2831,7 @@ impl OpenCADStudio {
Message::ViewCubeHome => {
let i = self.active_tab;
self.clear_navigation_hover(i);
self.tabs[i].scene.remember_current_view();
let r_ucs = self.tabs[i].scene.viewcube_ucs_mat();
if self.tabs[i].scene.active_viewport.is_some() {
self.tabs[i]
@@ -2847,6 +2848,7 @@ impl OpenCADStudio {
Message::ViewCubeRoll(cw) => {
let i = self.active_tab;
self.clear_navigation_hover(i);
self.tabs[i].scene.remember_current_view();
let ang = if cw {
std::f32::consts::FRAC_PI_2
} else {
@@ -2873,6 +2875,7 @@ impl OpenCADStudio {
};
let i = self.active_tab;
self.clear_navigation_hover(i);
self.tabs[i].scene.remember_current_view();
if self.tabs[i].scene.active_viewport.is_some() {
self.tabs[i]
.scene
+60 -3
View File
@@ -739,6 +739,39 @@ impl OpenCADStudio {
if mid_down {
if let Some(last) = mid_last {
let (dx, dy) = (p.x - last.x, p.y - last.y);
if self.tabs[i].zoom_dynamic_mode {
let bounds = self.tabs[i]
.scene
.active_model_tile_bounds(vp_size.0, vp_size.1);
drop(sel);
let zoom_delta = -dy * 0.03;
if self.tabs[i].scene.active_viewport.is_some() {
self.tabs[i].scene.pan_active_viewport(dx, 0.0, bounds);
self.tabs[i]
.scene
.zoom_active_viewport(zoom_delta, None);
} else {
let local = iced::Point {
x: p.x - bounds.x,
y: p.y - bounds.y,
};
let local_bounds = iced::Rectangle {
x: 0.0,
y: 0.0,
width: bounds.width,
height: bounds.height,
};
let mut camera = self.tabs[i].scene.camera.borrow_mut();
camera.pan_screen(dx, 0.0, bounds.height);
camera.zoom_about_point(local, local_bounds, zoom_delta);
}
self.tabs[i].scene.camera_generation += 1;
self.tabs[i]
.scene
.record_nav_perf(crate::scene::NavPerfOp::Zoom, move_started);
self.tabs[i].scene.selection.borrow_mut().middle_last_pos = Some(p);
return Task::none();
}
// Shift+MMB drag orbits the model view instead of panning
// — the requested Zoom=wheel / Pan=MMB / Rotate=Shift+MMB
// scheme (#229). Floating viewports and paper keep the
@@ -2288,11 +2321,21 @@ impl OpenCADStudio {
// Interactive navigation tools reuse the middle-button movement path,
// so no selection/pick logic runs while the left button drives them.
if self.tabs[i].orbit_mode || self.tabs[i].pan_mode {
if self.tabs[i].orbit_mode
|| self.tabs[i].pan_mode
|| self.tabs[i].zoom_dynamic_mode
{
self.clear_navigation_hover(i);
self.tabs[i].scene.remember_current_view();
let mut sel = self.tabs[i].scene.selection.borrow_mut();
sel.middle_down = true;
sel.middle_last_pos = Some(p);
if self.tabs[i].zoom_dynamic_mode {
sel.box_anchor = Some(p);
sel.box_current = Some(p);
sel.box_crossing = false;
sel.box_crossing_locked = true;
}
return Task::none();
}
@@ -2460,11 +2503,18 @@ impl OpenCADStudio {
// Navigation mode: end this drag but keep the tool armed for the next
// left drag (exit is Esc / another command). Mirror of the press.
if self.tabs[i].orbit_mode || self.tabs[i].pan_mode {
if self.tabs[i].orbit_mode
|| self.tabs[i].pan_mode
|| self.tabs[i].zoom_dynamic_mode
{
let mut sel = self.tabs[i].scene.selection.borrow_mut();
sel.middle_down = false;
sel.middle_last_pos = None;
sel.orbit_pivot = None;
sel.box_anchor = None;
sel.box_anchor_world = None;
sel.box_current = None;
sel.box_crossing_locked = false;
drop(sel);
self.arm_hover_after_navigation(i);
return Task::none();
@@ -4007,6 +4057,7 @@ impl OpenCADStudio {
let i = self.active_tab;
self.clear_navigation_hover(i);
self.ribbon.close_dropdown();
self.tabs[i].scene.remember_current_view();
let now = Instant::now();
let is_double = {
let sel = self.tabs[i].scene.selection.borrow();
@@ -4035,11 +4086,16 @@ impl OpenCADStudio {
pub(super) fn on_viewport_scroll(&mut self, delta: mouse::ScrollDelta) -> Task<Message> {
let nav_started = Instant::now();
let s = match delta {
let mut s = match delta {
mouse::ScrollDelta::Lines { y, .. } => y,
mouse::ScrollDelta::Pixels { y, .. } => y * 0.01,
};
s *= self.zoom_factor as f32 / 60.0;
if self.zoom_wheel_reversed {
s = -s;
}
let i = self.active_tab;
self.tabs[i].scene.remember_current_view();
self.clear_navigation_hover(i);
let cursor = self.tabs[i].scene.selection.borrow().last_move_pos;
let (vw, vh) = self.tabs[i].scene.selection.borrow().vp_size;
@@ -4204,6 +4260,7 @@ impl OpenCADStudio {
fn snap_view_region(&mut self, region: CubeRegion, r_ucs: glam::Mat4) -> Task<Message> {
let i = self.active_tab;
self.clear_navigation_hover(i);
self.tabs[i].scene.remember_current_view();
let mut region = region;
// "Already there → flip to opposite" check: compare the
// current gaze direction with the region's target gaze.
+1 -1
View File
@@ -680,7 +680,7 @@ impl OpenCADStudio {
dividers,
pane_move_rect,
pane_drop_rect,
tab.pan_mode || tab.orbit_mode,
tab.pan_mode || tab.orbit_mode || tab.zoom_dynamic_mode,
self.ribbon.open_dropdown.is_some(),
hover_locked,
crosshair_background(tab, is_paper),