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
; ── View / display ────────────────────────────────────────────────────────
Z, *ZOOM
P, *PAN
U, *UNDO
PR, *PROPERTIES

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());

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());

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",

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`):

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,
}

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,

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(),

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());

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();
}

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;

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

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.

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),

View file

@ -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);

View file

@ -3,7 +3,10 @@
// hit-testing lives in `scene::pick::hit_test`.)
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 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<glam::DV
}
if min.is_finite() && max.is_finite() {
Some((min + max) * 0.5)
Some((min, max))
} else {
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(
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 {

View file

@ -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<acadrust::entities::Viewport>,
},
}
/// 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<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;
/// the split buttons / VPORTS subdivide the active tile.
pub(crate) model_tiles: RefCell<Vec<ModelTile>>,
@ -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,