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

+154 -2
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 {
+19
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,