diff --git a/assets/ocad.pgp b/assets/ocad.pgp index 2a2dd847..c2be1de9 100644 --- a/assets/ocad.pgp +++ b/assets/ocad.pgp @@ -120,6 +120,7 @@ QS, *QSELECT FI, *FILTER ; ── View / display ──────────────────────────────────────────────────────── +Z, *ZOOM P, *PAN U, *UNDO PR, *PROPERTIES diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index fcfaaf8c..206ff8f0 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -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()); diff --git a/src/app/commands/dim.rs b/src/app/commands/dim.rs index e8665b41..1556d988 100644 --- a/src/app/commands/dim.rs +++ b/src/app/commands/dim.rs @@ -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::() { 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()); diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 094cea43..8d3e76b1 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -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", diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 721c1e22..6bc5bfff 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -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::() { + 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`): diff --git a/src/app/document.rs b/src/app/document.rs index abc2ea65..702d2029 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -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>, @@ -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, } diff --git a/src/app/mod.rs b/src/app/mod.rs index f7ae64bf..b9d3f17a 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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, diff --git a/src/app/settings.rs b/src/app/settings.rs index 13906d3d..727eaae1 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -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(), diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 390d48a0..f854b612 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -660,16 +660,22 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { // 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()); diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index 4e82f7f5..a3119277 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -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(); } diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 30eb93c0..5d7ed859 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -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; diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 96986637..9fed4ee8 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -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 diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index a603859c..14bd53b7 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -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 { 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 { 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. diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 363cfb63..c0ec9d44 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -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), diff --git a/src/command.rs b/src/command.rs index 0c9233f4..dca1fc0b 100644 --- a/src/command.rs +++ b/src/command.rs @@ -566,8 +566,9 @@ impl CadCommand for KeywordCommand { // `None` would hand the same text to the command a second time. None => { let up = t.to_uppercase(); - let Some((_, keyword, value_prompt)) = - self.options.iter().find(|(_, k, _)| k.eq_ignore_ascii_case(&up)) + let Some((_, keyword, value_prompt)) = self.options.iter().find(|(label, k, _)| { + k.eq_ignore_ascii_case(&up) || label.eq_ignore_ascii_case(t) + }) else { // Unknown verb — keep prompting rather than dispatch garbage. return Some(CmdResult::NeedPoint); diff --git a/src/scene/camera_ops.rs b/src/scene/camera_ops.rs index 50c7a344..dce2bbac 100644 --- a/src/scene/camera_ops.rs +++ b/src/scene/camera_ops.rs @@ -3,7 +3,10 @@ // hit-testing lives in `scene::pick::hit_test`.) use super::*; -fn rendered_wire_center(wires: &[WireModel], fallback_z: f64) -> Option { +fn rendered_wire_bounds( + wires: &[WireModel], + fallback_z: f64, +) -> Option<(glam::DVec3, glam::DVec3)> { let mut min = glam::DVec3::splat(f64::INFINITY); let mut max = glam::DVec3::splat(f64::NEG_INFINITY); let mut include = |point: glam::DVec3| { @@ -55,12 +58,16 @@ fn rendered_wire_center(wires: &[WireModel], fallback_z: f64) -> Option Option { + rendered_wire_bounds(wires, fallback_z).map(|(min, max)| (min + max) * 0.5) +} + fn block_entity_transform( document: &CadDocument, block_name: &str, @@ -189,6 +196,105 @@ impl Scene { 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` /// keeps projection framing on the same visibility and outlier filters as /// Zoom Extents instead of accepting every finite cached AABB. @@ -270,6 +376,52 @@ impl Scene { 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 /// distance, orientation, or viewport scale. pub fn center_camera_on_entity(&mut self, handle: Handle) -> bool { diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 52acf6d8..67cfa415 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -1123,6 +1123,20 @@ pub(crate) struct ModelTile { pub(crate) snap_on: bool, } +#[derive(Clone)] +enum ViewSnapshot { + Main { + layout: String, + tile: usize, + camera: Camera, + }, + Floating { + layout: String, + handle: Handle, + viewport: Box, + }, +} + /// Gap (pixels) between Model panes — the `pane_grid` spacing and the visible /// divider width. The renderer derives tile rects through this same spacing so /// the drawn viewports line up exactly with the pane_grid layout. @@ -1488,6 +1502,10 @@ struct SceneLight { pub struct Scene { pub camera: Rc>, + /// 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, /// Model-space tiled viewport layout. One full-window tile by default; /// the split buttons / VPORTS subdivide the active tile. pub(crate) model_tiles: RefCell>, @@ -1932,6 +1950,7 @@ impl Scene { pub fn new() -> Self { Self { camera: Rc::new(RefCell::new(Camera::default())), + previous_view: None, model_tiles: RefCell::new(vec![ModelTile { rect: iced::Rectangle { x: 0.0,