diff --git a/src/app/commands/dim.rs b/src/app/commands/dim.rs index 54062caf7..fed6658cc 100644 --- a/src/app/commands/dim.rs +++ b/src/app/commands/dim.rs @@ -695,6 +695,11 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(cmd)); } + "ZOOM EXTENTS ALL" | "ZOOM EXTENTS ALL VIEWPORTS" | "ZEA" => { + self.tabs[i].scene.fit_all_model_viewports(); + self.command_line.push_output("Zoom Extents — All Viewports"); + } + "ZOOM EXTENTS" | "ZOOMEXTENTS" | "ZE" => { self.tabs[i].scene.fit_all(); self.command_line.push_output("Zoom Extents"); @@ -751,10 +756,11 @@ impl OpenCADStudio { use crate::command::KeywordCommand; let c = KeywordCommand::new( "ZOOM", - "ZOOM [Window / Extents / All / In / Out / Scale]:", + "ZOOM [Window / Extents / Extents All / All / In / Out / Scale]:", vec![ ("Window", "WINDOW", None), ("Extents", "EXTENTS", None), + ("Extents All", "EXTENTS ALL", None), ("All", "ALL", None), ("In", "IN", None), ("Out", "OUT", None), diff --git a/src/scene/camera_ops.rs b/src/scene/camera_ops.rs index 8070065b5..0a2548d0e 100644 --- a/src/scene/camera_ops.rs +++ b/src/scene/camera_ops.rs @@ -98,6 +98,25 @@ impl Scene { self.camera_generation += 1; } + /// Aspect ratio of the active camera's actual render rectangle. Model + /// panes are separate shader widgets, so the full-canvas render aspect is + /// wrong whenever a tiled viewport is active. + pub(super) fn active_camera_aspect(&self) -> f32 { + if self.current_layout == "Model" { + let (canvas_w, canvas_h) = self.selection.borrow().vp_size; + let tiles = self.model_tiles.borrow(); + let active = self.active_model_tile.get().min(tiles.len().saturating_sub(1)); + if let Some(tile) = tiles.get(active) { + let width = tile.rect.width * canvas_w; + let height = tile.rect.height * canvas_h; + if width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0 { + return width / height; + } + } + } + self.last_render_aspect.get().max(0.01) + } + /// Fit the camera to a world-space bounding box (corners p1, p2). pub fn zoom_to_window(&mut self, p1: glam::Vec3, p2: glam::Vec3) { let min = p1.min(p2); @@ -105,9 +124,10 @@ impl Scene { if min == max { return; } + let aspect = self.active_camera_aspect(); self.camera .borrow_mut() - .fit_to_bounds(min, max, self.last_render_aspect.get().max(0.01)); + .fit_to_bounds(min, max, aspect); self.camera_generation += 1; } @@ -925,9 +945,61 @@ impl Scene { if min == max { max += glam::Vec3::splat(1.0); } - self.camera - .borrow_mut() - .fit_to_bounds(min, max, self.last_render_aspect.get().max(0.01)); + let aspect = self.active_camera_aspect(); + self.camera.borrow_mut().fit_to_bounds(min, max, aspect); + self.camera_generation += 1; + } + + /// Fit every tiled Model viewport independently. The drawing bounds are + /// shared, but each tile keeps its own orientation and uses its own screen + /// aspect when calculating the required zoom. + pub fn fit_all_model_viewports(&mut self) { + if self.current_layout != "Model" || self.model_tiles.borrow().len() <= 1 { + self.fit_all(); + return; + } + let generation_before = self.camera_generation; + self.fit_all(); + if self.camera_generation == generation_before { + return; + } + let Some((min, max)) = self.camera.borrow().fitted_model_bounds() else { + return; + }; + + let (canvas_w, canvas_h) = self.selection.borrow().vp_size; + let fallback = self.last_render_aspect.get().max(0.01); + let aspects: Vec = self + .model_tiles + .borrow() + .iter() + .map(|tile| { + let width = tile.rect.width * canvas_w; + let height = tile.rect.height * canvas_h; + if width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0 { + width / height + } else { + fallback + } + }) + .collect(); + let active = self + .active_model_tile + .get() + .min(aspects.len().saturating_sub(1)); + let live_camera = self.camera.borrow_mut(); + let mut tiles = self.model_tiles.borrow_mut(); + for (index, tile) in tiles.iter_mut().enumerate() { + let aspect = aspects.get(index).copied().unwrap_or(fallback); + if index == active { + tile.camera = live_camera.clone(); + } else { + tile.camera.fit_to_bounds(min, max, aspect); + } + } + if let Some(aspect) = aspects.get(active) { + self.last_render_aspect.set(*aspect); + } self.camera_generation += 1; } diff --git a/src/scene/limits.rs b/src/scene/limits.rs index e470a9057..22cd7f825 100644 --- a/src/scene/limits.rs +++ b/src/scene/limits.rs @@ -137,9 +137,8 @@ impl Scene { return; } - self.camera - .borrow_mut() - .fit_to_bounds(min, max, self.last_render_aspect.get().max(0.01)); + let aspect = self.active_camera_aspect(); + self.camera.borrow_mut().fit_to_bounds(min, max, aspect); self.camera_generation += 1; } } diff --git a/src/scene/view/camera.rs b/src/scene/view/camera.rs index c38f8232f..e6425af9b 100644 --- a/src/scene/view/camera.rs +++ b/src/scene/view/camera.rs @@ -464,6 +464,10 @@ impl Camera { self.depth_half_range = self.depth_extent_in_view(min, max); } + pub(crate) fn fitted_model_bounds(&self) -> Option<(Vec3, Vec3)> { + self.model_bounds + } + // ── ViewCube snap ───────────────────────────────────────────────────── /// Snap to a canonical view direction (called by ViewCubeSnap). diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 43bef938a..ac1660c8d 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -1923,6 +1923,10 @@ impl Scene { }; let active = self.active_model_tile.get(); let is_active = tile_idx == active; + if is_active && canvas.1 > 0.0 { + self.set_render_aspect(canvas.0 / canvas.1); + self.set_render_pixel_scale(canvas.0, canvas.1); + } let nav_build_started = iced::time::Instant::now(); let perf_nav = if is_active { self.take_nav_perf()