feat(view): complete ZOOM options

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

View file

@ -120,6 +120,7 @@ QS, *QSELECT
FI, *FILTER FI, *FILTER
; ── View / display ──────────────────────────────────────────────────────── ; ── View / display ────────────────────────────────────────────────────────
Z, *ZOOM
P, *PAN P, *PAN
U, *UNDO U, *UNDO
PR, *PROPERTIES PR, *PROPERTIES

View file

@ -100,6 +100,7 @@ impl OpenCADStudio {
self.ucs_icon_hover = false; self.ucs_icon_hover = false;
self.tabs[i].pan_mode = false; self.tabs[i].pan_mode = false;
self.tabs[i].orbit_mode = false; self.tabs[i].orbit_mode = false;
self.tabs[i].zoom_dynamic_mode = false;
let _ = self.on_viewport_exit(); let _ = self.on_viewport_exit();
} }
@ -1939,6 +1940,7 @@ impl OpenCADStudio {
self.tabs[i].active_cmd = None; self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None; self.tabs[i].snap_result = None;
self.tabs[i].scene.clear_preview_wire(); self.tabs[i].scene.clear_preview_wire();
self.tabs[i].scene.remember_current_view();
self.tabs[i] self.tabs[i]
.scene .scene
.zoom_to_window(p1.as_vec3(), p2.as_vec3()); .zoom_to_window(p1.as_vec3(), p2.as_vec3());

View file

@ -805,40 +805,94 @@ impl OpenCADStudio {
self.tabs[i].active_cmd = Some(Box::new(cmd)); 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.tabs[i].scene.fit_all_model_viewports();
self.command_line.push_output(crate::t!("Zoom Extents — All Viewports").as_ref()); 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.tabs[i].scene.fit_all();
self.command_line.push_output(crate::t!("Zoom Extents").as_ref()); 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.tabs[i].scene.zoom_camera(1.0 / 1.5);
self.command_line.push_output(crate::t!("Zoom In").as_ref()); self.command_line.push_output(crate::t!("Zoom In").as_ref());
} }
"ZOOM OUT" | "ZO" => { "ZOOM OUT" | "ZO" => {
self.tabs[i].scene.remember_current_view();
self.tabs[i].scene.zoom_camera(1.5); self.tabs[i].scene.zoom_camera(1.5);
self.command_line.push_output(crate::t!("Zoom Out").as_ref()); self.command_line.push_output(crate::t!("Zoom Out").as_ref());
} }
// ZOOM ALL — fit the configured drawing limits. // ZOOM ALL — fit the configured drawing limits.
"ZOOM ALL" | "ZOOM A" | "ZA" => { "ZOOM ALL" | "ZOOM A" | "ZA" => {
self.tabs[i].scene.remember_current_view();
self.tabs[i].scene.fit_all_with_limits(); self.tabs[i].scene.fit_all_with_limits();
self.command_line.push_output(crate::t!("Zoom All").as_ref()); 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") // 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 let rest = cmd
.split_once(' ') .strip_prefix("ZOOM SCALE ")
.and_then(|(_, r)| r.split_once(' ').map(|(_, v)| v).or(Some(r))) .or_else(|| cmd.strip_prefix("ZOOM S "))
.or_else(|| cmd.strip_prefix("ZS "))
.unwrap_or("1"); .unwrap_or("1");
if let Ok(factor) = rest.trim().parse::<f32>() { if let Ok(factor) = rest.trim().parse::<f32>() {
if factor > 0.0 { if factor > 0.0 {
self.tabs[i].scene.remember_current_view();
self.tabs[i].scene.zoom_camera(1.0 / factor); self.tabs[i].scene.zoom_camera(1.0 / factor);
self.command_line self.command_line
.push_output(crate::tf!("Zoom Scale ×{factor:.3}").as_ref()); .push_output(crate::tf!("Zoom Scale ×{factor:.3}").as_ref());
@ -866,15 +920,18 @@ impl OpenCADStudio {
use crate::command::KeywordCommand; use crate::command::KeywordCommand;
let c = KeywordCommand::new( let c = KeywordCommand::new(
"ZOOM", "ZOOM",
"ZOOM [Window / Extents / Extents All / All / In / Out / Scale]:", "ZOOM [Window / Extents / Previous / Object / All / Dynamic / Extents All / In / Out / Scale]:",
vec![ vec![
("Window", "WINDOW", None), ("Window", "W", None),
("Extents", "EXTENTS", None), ("Extents", "E", None),
("Extents All", "EXTENTS ALL", None), ("Previous", "P", None),
("All", "ALL", None), ("Object", "O", None),
("In", "IN", None), ("All", "A", None),
("Dynamic", "D", None),
("Extents All", "EA", None),
("In", "I", None),
("Out", "OUT", 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()); self.command_line.push_info(&c.prompt());

View file

@ -98,6 +98,7 @@ impl OpenCADStudio {
// command arms below re-enable the selected one). // command arms below re-enable the selected one).
self.tabs[i].pan_mode = false; self.tabs[i].pan_mode = false;
self.tabs[i].orbit_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 // 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. // is not constrained by ortho/polar relative to a previous command's endpoint.
self.last_point = None; self.last_point = None;
@ -445,6 +446,8 @@ inventory::submit!(crate::command::CommandRegistration {
"FITSPLINE", "FITSPLINE",
// System variables (typeable directly). // System variables (typeable directly).
"MIRRTEXT", "MIRRTEXT",
"ZOOMWHEEL",
"ZOOMFACTOR",
"ATTREQ", "ATTREQ",
"ATTDIA", "ATTDIA",
"DIMASSOC", "DIMASSOC",

View file

@ -866,6 +866,8 @@ impl OpenCADStudio {
cmd if matches!( cmd if matches!(
cmd.split_whitespace().next().unwrap_or(""), cmd.split_whitespace().next().unwrap_or(""),
"MIRRTEXT" "MIRRTEXT"
| "ZOOMWHEEL"
| "ZOOMFACTOR"
| "TEXTFILL" | "TEXTFILL"
| "ATTREQ" | "ATTREQ"
| "ATTDIA" | "ATTDIA"
@ -936,7 +938,7 @@ impl OpenCADStudio {
let value = it.next().map(|s| s.trim().to_string()); let value = it.next().map(|s| s.trim().to_string());
if name.is_empty() || name == "?" { if name.is_empty() || name == "?" {
self.command_line.push_info( 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 { } else {
// Parse a boolean given as 0/1 or ON/OFF. // 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()), .ok_or_else(|| "SETVAR: 0 or 1 required.".into()),
None => Ok((format!("MIRRTEXT = {}", h.mirror_text as i32), false)), 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 // Global (not stored in the drawing): fill vs. hollow
// TrueType text. The active tab re-tessellates below. // TrueType text. The active tab re-tessellates below.
"TEXTFILL" => match &value { "TEXTFILL" => match &value {
@ -1579,7 +1612,11 @@ impl OpenCADStudio {
match outcome { match outcome {
Ok((msg, changed)) => { Ok((msg, changed)) => {
if 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); self.command_line.push_output(&msg);
} else { } else {
// Queried with no value (`changed == false`): // Queried with no value (`changed == false`):

View file

@ -212,6 +212,9 @@ pub(super) struct DocumentTab {
/// Interactive 3-D orbit mode. While active, a left-button drag follows /// Interactive 3-D orbit mode. While active, a left-button drag follows
/// the same camera-orbit path as Shift + middle-button drag. /// the same camera-orbit path as Shift + middle-button drag.
pub(super) orbit_mode: bool, 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). /// Per-plugin document state (`plugin::BuiltinPlugin` manifest id → state).
#[cfg_attr(target_arch = "wasm32", allow(dead_code))] #[cfg_attr(target_arch = "wasm32", allow(dead_code))]
pub(super) plugin_state: HashMap<&'static str, Box<dyn Any + Send + Sync>>, pub(super) plugin_state: HashMap<&'static str, Box<dyn Any + Send + Sync>>,
@ -476,6 +479,7 @@ impl DocumentTab {
is_start: false, is_start: false,
pan_mode: false, pan_mode: false,
orbit_mode: false, orbit_mode: false,
zoom_dynamic_mode: false,
plugin_state: HashMap::new(), plugin_state: HashMap::new(),
suspended_cmd: None, suspended_cmd: None,
} }

View file

@ -444,6 +444,10 @@ pub(super) struct OpenCADStudio {
polar_mode: bool, polar_mode: bool,
/// Polar tracking angle increment in degrees (15 / 30 / 45 / 90). /// Polar tracking angle increment in degrees (15 / 30 / 45 / 90).
polar_increment_deg: f32, 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 lines in the viewport (F7).
show_grid: bool, show_grid: bool,
/// Dynamic input overlay (F12): show coordinate tooltip near cursor. /// Dynamic input overlay (F12): show coordinate tooltip near cursor.
@ -3030,6 +3034,8 @@ impl OpenCADStudio {
ortho_mode: false, ortho_mode: false,
polar_mode: false, polar_mode: false,
polar_increment_deg: 45.0, polar_increment_deg: 45.0,
zoom_wheel_reversed: false,
zoom_factor: 60,
show_grid: false, show_grid: false,
dyn_input: true, dyn_input: true,
texteditmode: false, texteditmode: false,

View file

@ -97,6 +97,8 @@ pub struct UserSettings {
pub dyn_input: bool, pub dyn_input: bool,
pub polar: bool, pub polar: bool,
pub polar_increment_deg: f32, pub polar_increment_deg: f32,
pub zoom_wheel_reversed: bool,
pub zoom_factor: i32,
pub otrack: bool, pub otrack: bool,
// Ortho ($ORTHOMODE) and the running OSNAP set ($OSMODE) are per-drawing — // Ortho ($ORTHOMODE) and the running OSNAP set ($OSMODE) are per-drawing —
// stored in the document header, not here (they used to be persisted app- // stored in the document header, not here (they used to be persisted app-
@ -166,6 +168,8 @@ impl Default for UserSettings {
dyn_input: true, dyn_input: true,
polar: false, polar: false,
polar_increment_deg: 45.0, polar_increment_deg: 45.0,
zoom_wheel_reversed: false,
zoom_factor: 60,
otrack: false, otrack: false,
default_assoc_prompted: false, default_assoc_prompted: false,
disabled_plugins: Vec::new(), disabled_plugins: Vec::new(),

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. // drag. Orbit exits silently; PAN keeps its existing message.
if self.tabs[self.active_tab].pan_mode if self.tabs[self.active_tab].pan_mode
|| self.tabs[self.active_tab].orbit_mode || self.tabs[self.active_tab].orbit_mode
|| self.tabs[self.active_tab].zoom_dynamic_mode
{ {
let i = self.active_tab; let i = self.active_tab;
let was_pan = self.tabs[i].pan_mode; let was_pan = self.tabs[i].pan_mode;
self.tabs[i].pan_mode = false; self.tabs[i].pan_mode = false;
self.tabs[i].orbit_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(); let mut sel = self.tabs[i].scene.selection.borrow_mut();
sel.middle_down = false; sel.middle_down = false;
sel.middle_last_pos = None; sel.middle_last_pos = None;
sel.orbit_pivot = 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 { if was_pan {
self.command_line.push_output(crate::t!("PAN ended.").as_ref()); self.command_line.push_output(crate::t!("PAN ended.").as_ref());

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.active_modal.is_none()
&& !self.tabs[i].pan_mode && !self.tabs[i].pan_mode
&& !self.tabs[i].orbit_mode && !self.tabs[i].orbit_mode
&& !self.tabs[i].zoom_dynamic_mode
{ {
self.ribbon.deactivate_tool(); self.ribbon.deactivate_tool();
} }

View file

@ -265,6 +265,8 @@ impl OpenCADStudio {
dyn_input: self.dyn_input, dyn_input: self.dyn_input,
polar: self.polar_mode, polar: self.polar_mode,
polar_increment_deg: self.polar_increment_deg, polar_increment_deg: self.polar_increment_deg,
zoom_wheel_reversed: self.zoom_wheel_reversed,
zoom_factor: self.zoom_factor,
otrack: self.snapper.otrack_enabled, otrack: self.snapper.otrack_enabled,
default_assoc_prompted: self.default_assoc_prompted, default_assoc_prompted: self.default_assoc_prompted,
disabled_plugins: { disabled_plugins: {
@ -299,6 +301,8 @@ impl OpenCADStudio {
self.dyn_input = s.dyn_input; self.dyn_input = s.dyn_input;
self.polar_mode = s.polar; self.polar_mode = s.polar;
self.polar_increment_deg = s.polar_increment_deg; 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 // Ortho + running OSNAP are per-drawing (adopted from the header on
// open / tab switch), not app-global, so they are not applied here. // open / tab switch), not app-global, so they are not applied here.
self.snapper.otrack_enabled = s.otrack; self.snapper.otrack_enabled = s.otrack;

View file

@ -2831,6 +2831,7 @@ impl OpenCADStudio {
Message::ViewCubeHome => { Message::ViewCubeHome => {
let i = self.active_tab; let i = self.active_tab;
self.clear_navigation_hover(i); self.clear_navigation_hover(i);
self.tabs[i].scene.remember_current_view();
let r_ucs = self.tabs[i].scene.viewcube_ucs_mat(); let r_ucs = self.tabs[i].scene.viewcube_ucs_mat();
if self.tabs[i].scene.active_viewport.is_some() { if self.tabs[i].scene.active_viewport.is_some() {
self.tabs[i] self.tabs[i]
@ -2847,6 +2848,7 @@ impl OpenCADStudio {
Message::ViewCubeRoll(cw) => { Message::ViewCubeRoll(cw) => {
let i = self.active_tab; let i = self.active_tab;
self.clear_navigation_hover(i); self.clear_navigation_hover(i);
self.tabs[i].scene.remember_current_view();
let ang = if cw { let ang = if cw {
std::f32::consts::FRAC_PI_2 std::f32::consts::FRAC_PI_2
} else { } else {
@ -2873,6 +2875,7 @@ impl OpenCADStudio {
}; };
let i = self.active_tab; let i = self.active_tab;
self.clear_navigation_hover(i); self.clear_navigation_hover(i);
self.tabs[i].scene.remember_current_view();
if self.tabs[i].scene.active_viewport.is_some() { if self.tabs[i].scene.active_viewport.is_some() {
self.tabs[i] self.tabs[i]
.scene .scene

View file

@ -739,6 +739,39 @@ impl OpenCADStudio {
if mid_down { if mid_down {
if let Some(last) = mid_last { if let Some(last) = mid_last {
let (dx, dy) = (p.x - last.x, p.y - last.y); 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 // Shift+MMB drag orbits the model view instead of panning
// — the requested Zoom=wheel / Pan=MMB / Rotate=Shift+MMB // — the requested Zoom=wheel / Pan=MMB / Rotate=Shift+MMB
// scheme (#229). Floating viewports and paper keep the // scheme (#229). Floating viewports and paper keep the
@ -2288,11 +2321,21 @@ impl OpenCADStudio {
// Interactive navigation tools reuse the middle-button movement path, // Interactive navigation tools reuse the middle-button movement path,
// so no selection/pick logic runs while the left button drives them. // 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.clear_navigation_hover(i);
self.tabs[i].scene.remember_current_view();
let mut sel = self.tabs[i].scene.selection.borrow_mut(); let mut sel = self.tabs[i].scene.selection.borrow_mut();
sel.middle_down = true; sel.middle_down = true;
sel.middle_last_pos = Some(p); 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(); return Task::none();
} }
@ -2460,11 +2503,18 @@ impl OpenCADStudio {
// Navigation mode: end this drag but keep the tool armed for the next // Navigation mode: end this drag but keep the tool armed for the next
// left drag (exit is Esc / another command). Mirror of the press. // 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(); let mut sel = self.tabs[i].scene.selection.borrow_mut();
sel.middle_down = false; sel.middle_down = false;
sel.middle_last_pos = None; sel.middle_last_pos = None;
sel.orbit_pivot = 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); drop(sel);
self.arm_hover_after_navigation(i); self.arm_hover_after_navigation(i);
return Task::none(); return Task::none();
@ -4007,6 +4057,7 @@ impl OpenCADStudio {
let i = self.active_tab; let i = self.active_tab;
self.clear_navigation_hover(i); self.clear_navigation_hover(i);
self.ribbon.close_dropdown(); self.ribbon.close_dropdown();
self.tabs[i].scene.remember_current_view();
let now = Instant::now(); let now = Instant::now();
let is_double = { let is_double = {
let sel = self.tabs[i].scene.selection.borrow(); 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> { pub(super) fn on_viewport_scroll(&mut self, delta: mouse::ScrollDelta) -> Task<Message> {
let nav_started = Instant::now(); let nav_started = Instant::now();
let s = match delta { let mut s = match delta {
mouse::ScrollDelta::Lines { y, .. } => y, mouse::ScrollDelta::Lines { y, .. } => y,
mouse::ScrollDelta::Pixels { y, .. } => y * 0.01, 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; let i = self.active_tab;
self.tabs[i].scene.remember_current_view();
self.clear_navigation_hover(i); self.clear_navigation_hover(i);
let cursor = self.tabs[i].scene.selection.borrow().last_move_pos; let cursor = self.tabs[i].scene.selection.borrow().last_move_pos;
let (vw, vh) = self.tabs[i].scene.selection.borrow().vp_size; 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> { fn snap_view_region(&mut self, region: CubeRegion, r_ucs: glam::Mat4) -> Task<Message> {
let i = self.active_tab; let i = self.active_tab;
self.clear_navigation_hover(i); self.clear_navigation_hover(i);
self.tabs[i].scene.remember_current_view();
let mut region = region; let mut region = region;
// "Already there → flip to opposite" check: compare the // "Already there → flip to opposite" check: compare the
// current gaze direction with the region's target gaze. // current gaze direction with the region's target gaze.

View file

@ -680,7 +680,7 @@ impl OpenCADStudio {
dividers, dividers,
pane_move_rect, pane_move_rect,
pane_drop_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(), self.ribbon.open_dropdown.is_some(),
hover_locked, hover_locked,
crosshair_background(tab, is_paper), crosshair_background(tab, is_paper),

View file

@ -566,8 +566,9 @@ impl CadCommand for KeywordCommand {
// `None` would hand the same text to the command a second time. // `None` would hand the same text to the command a second time.
None => { None => {
let up = t.to_uppercase(); let up = t.to_uppercase();
let Some((_, keyword, value_prompt)) = let Some((_, keyword, value_prompt)) = self.options.iter().find(|(label, k, _)| {
self.options.iter().find(|(_, k, _)| k.eq_ignore_ascii_case(&up)) k.eq_ignore_ascii_case(&up) || label.eq_ignore_ascii_case(t)
})
else { else {
// Unknown verb — keep prompting rather than dispatch garbage. // Unknown verb — keep prompting rather than dispatch garbage.
return Some(CmdResult::NeedPoint); return Some(CmdResult::NeedPoint);

View file

@ -3,7 +3,10 @@
// hit-testing lives in `scene::pick::hit_test`.) // hit-testing lives in `scene::pick::hit_test`.)
use super::*; use super::*;
fn rendered_wire_center(wires: &[WireModel], fallback_z: f64) -> Option<glam::DVec3> { fn rendered_wire_bounds(
wires: &[WireModel],
fallback_z: f64,
) -> Option<(glam::DVec3, glam::DVec3)> {
let mut min = glam::DVec3::splat(f64::INFINITY); let mut min = glam::DVec3::splat(f64::INFINITY);
let mut max = glam::DVec3::splat(f64::NEG_INFINITY); let mut max = glam::DVec3::splat(f64::NEG_INFINITY);
let mut include = |point: glam::DVec3| { let mut include = |point: glam::DVec3| {
@ -55,12 +58,16 @@ fn rendered_wire_center(wires: &[WireModel], fallback_z: f64) -> Option<glam::DV
} }
if min.is_finite() && max.is_finite() { if min.is_finite() && max.is_finite() {
Some((min + max) * 0.5) Some((min, max))
} else { } else {
None None
} }
} }
fn rendered_wire_center(wires: &[WireModel], fallback_z: f64) -> Option<glam::DVec3> {
rendered_wire_bounds(wires, fallback_z).map(|(min, max)| (min + max) * 0.5)
}
fn block_entity_transform( fn block_entity_transform(
document: &CadDocument, document: &CadDocument,
block_name: &str, block_name: &str,
@ -189,6 +196,105 @@ impl Scene {
self.camera_generation += 1; self.camera_generation += 1;
} }
/// Save the active view immediately before a navigation operation. The
/// paper-space camera and an entered floating viewport use different state,
/// so the snapshot records whichever one currently owns navigation.
pub fn remember_current_view(&mut self) {
let layout = self.current_layout.clone();
if let Some(handle) = self.active_viewport {
if let Some(EntityType::Viewport(viewport)) = self.document.get_entity(handle) {
self.previous_view = Some(ViewSnapshot::Floating {
layout,
handle,
viewport: Box::new(viewport.clone()),
});
return;
}
}
self.previous_view = Some(ViewSnapshot::Main {
layout,
tile: self.active_model_tile.get(),
camera: self.camera.borrow().clone(),
});
}
/// Swap the active view with the most recently remembered one. Keeping the
/// displaced current view makes repeated ZOOM Previous calls toggle cleanly
/// between the last two views.
pub fn restore_previous_view(&mut self) -> bool {
let Some(snapshot) = self.previous_view.take() else {
return false;
};
match snapshot {
ViewSnapshot::Main {
layout,
tile,
camera,
} if self.active_viewport.is_none()
&& self.current_layout == layout
&& (layout != "Model" || self.active_model_tile.get() == tile) =>
{
let current = ViewSnapshot::Main {
layout,
tile,
camera: self.camera.borrow().clone(),
};
*self.camera.borrow_mut() = camera;
self.previous_view = Some(current);
self.camera_generation += 1;
true
}
ViewSnapshot::Floating {
layout,
handle,
viewport,
} if self.current_layout == layout && self.active_viewport == Some(handle) => {
let Some(EntityType::Viewport(current)) = self.document.get_entity(handle) else {
self.previous_view = Some(ViewSnapshot::Floating {
layout,
handle,
viewport,
});
return false;
};
if current.status.locked {
self.previous_view = Some(ViewSnapshot::Floating {
layout,
handle,
viewport,
});
return false;
}
let current = Box::new(current.clone());
if let Some(EntityType::Viewport(destination)) =
self.document.get_entity_mut(handle)
{
destination.view_center = viewport.view_center;
destination.view_direction = viewport.view_direction;
destination.view_target = viewport.view_target;
destination.lens_length = viewport.lens_length;
destination.front_clip_z = viewport.front_clip_z;
destination.back_clip_z = viewport.back_clip_z;
destination.view_height = viewport.view_height;
destination.twist_angle = viewport.twist_angle;
destination.custom_scale = viewport.custom_scale;
destination.status.perspective = viewport.status.perspective;
}
self.previous_view = Some(ViewSnapshot::Floating {
layout,
handle,
viewport: current,
});
self.camera_generation += 1;
true
}
snapshot => {
self.previous_view = Some(snapshot);
false
}
}
}
/// Refresh model bounds without moving the live camera. Reusing `fit_all` /// Refresh model bounds without moving the live camera. Reusing `fit_all`
/// keeps projection framing on the same visibility and outlier filters as /// keeps projection framing on the same visibility and outlier filters as
/// Zoom Extents instead of accepting every finite cached AABB. /// Zoom Extents instead of accepting every finite cached AABB.
@ -270,6 +376,52 @@ impl Scene {
self.camera_generation += 1; self.camera_generation += 1;
} }
/// Fit the active view to the rendered bounds of the supplied entities.
/// Returns false when the selection has no finite visible geometry.
pub fn zoom_to_entities(&mut self, handles: &[Handle]) -> bool {
let fallback_z = self.active_camera_target().z;
let wires = self.wire_models_for(handles);
let mut bounds = rendered_wire_bounds(&wires, fallback_z);
for handle in handles {
let Some(mesh) = self.meshes.get(handle) else {
continue;
};
let [x0, y0, x1, y1] = mesh.world_aabb;
let [z0, z1] = mesh.z_aabb;
let mesh_min = glam::DVec3::new(x0 as f64, y0 as f64, z0 as f64);
let mesh_max = glam::DVec3::new(x1 as f64, y1 as f64, z1 as f64);
if !mesh_min.is_finite() || !mesh_max.is_finite() {
continue;
}
bounds = Some(match bounds {
Some((min, max)) => (min.min(mesh_min), max.max(mesh_max)),
None => (mesh_min, mesh_max),
});
}
let Some((mut min, mut max)) = bounds else {
return false;
};
if min == max {
min -= glam::DVec3::splat(1.0);
max += glam::DVec3::splat(1.0);
}
let min = min.as_vec3();
let max = max.as_vec3();
if !min.is_finite() || !max.is_finite() {
return false;
}
if self.active_viewport.is_some() {
return self.fit_active_viewport_to_bounds(min, max);
}
let aspect = self.active_camera_aspect();
self.camera.borrow_mut().fit_to_bounds(min, max, aspect);
self.projection_bounds_epoch.set(self.geometry_epoch);
self.camera_generation += 1;
true
}
/// Centre the active camera on one rendered entity without changing zoom /// Centre the active camera on one rendered entity without changing zoom
/// distance, orientation, or viewport scale. /// distance, orientation, or viewport scale.
pub fn center_camera_on_entity(&mut self, handle: Handle) -> bool { pub fn center_camera_on_entity(&mut self, handle: Handle) -> bool {

View file

@ -1123,6 +1123,20 @@ pub(crate) struct ModelTile {
pub(crate) snap_on: bool, pub(crate) snap_on: bool,
} }
#[derive(Clone)]
enum ViewSnapshot {
Main {
layout: String,
tile: usize,
camera: Camera,
},
Floating {
layout: String,
handle: Handle,
viewport: Box<acadrust::entities::Viewport>,
},
}
/// Gap (pixels) between Model panes — the `pane_grid` spacing and the visible /// Gap (pixels) between Model panes — the `pane_grid` spacing and the visible
/// divider width. The renderer derives tile rects through this same spacing so /// divider width. The renderer derives tile rects through this same spacing so
/// the drawn viewports line up exactly with the pane_grid layout. /// the drawn viewports line up exactly with the pane_grid layout.
@ -1488,6 +1502,10 @@ struct SceneLight {
pub struct Scene { pub struct Scene {
pub camera: Rc<RefCell<Camera>>, pub camera: Rc<RefCell<Camera>>,
/// View saved immediately before the latest navigation operation. ZOOM
/// Previous swaps with this snapshot, allowing the user to toggle between
/// the two most recent views without touching drawing history.
previous_view: Option<ViewSnapshot>,
/// Model-space tiled viewport layout. One full-window tile by default; /// Model-space tiled viewport layout. One full-window tile by default;
/// the split buttons / VPORTS subdivide the active tile. /// the split buttons / VPORTS subdivide the active tile.
pub(crate) model_tiles: RefCell<Vec<ModelTile>>, pub(crate) model_tiles: RefCell<Vec<ModelTile>>,
@ -1932,6 +1950,7 @@ impl Scene {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
camera: Rc::new(RefCell::new(Camera::default())), camera: Rc::new(RefCell::new(Camera::default())),
previous_view: None,
model_tiles: RefCell::new(vec![ModelTile { model_tiles: RefCell::new(vec![ModelTile {
rect: iced::Rectangle { rect: iced::Rectangle {
x: 0.0, x: 0.0,