forked from 1b-agent/opencadstudio-upstream
fix(view): preserve projection framing
Use full 3D bounds to derive a safe perspective eye distance without changing the camera target, orientation, or field of view. Refs #502
This commit is contained in:
1 parent
5a0c286a10
commit
e07f4f959b
5 files changed
+219
-19
No files matched your search
@@ -1227,8 +1227,9 @@ impl OpenCADStudio {
|
||||
Projection::Perspective
|
||||
};
|
||||
let i = self.active_tab;
|
||||
self.tabs[i].scene.camera.borrow_mut().projection = proj;
|
||||
self.tabs[i].scene.camera_generation += 1;
|
||||
self.tabs[i]
|
||||
.scene
|
||||
.set_projection_preserving_frame(proj);
|
||||
self.ribbon.set_ortho(ortho);
|
||||
self.command_line.push_output(if ortho {
|
||||
"Projection: Orthographic"
|
||||
|
||||
+74
-12
@@ -189,6 +189,53 @@ impl Scene {
|
||||
self.camera_generation += 1;
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn refresh_projection_bounds(&mut self) {
|
||||
if self.current_layout != "Model"
|
||||
|| self.active_viewport.is_some()
|
||||
|| (self.projection_bounds_epoch.get() == self.geometry_epoch
|
||||
&& self.camera.borrow().fitted_model_bounds().is_some())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let saved_camera = self.camera.borrow().clone();
|
||||
let saved_generation = self.camera_generation;
|
||||
self.camera.borrow_mut().model_bounds = None;
|
||||
self.fit_all();
|
||||
let refreshed = self.camera.borrow().fitted_model_bounds();
|
||||
*self.camera.borrow_mut() = saved_camera;
|
||||
self.camera_generation = saved_generation;
|
||||
|
||||
if let Some((min, max)) = refreshed {
|
||||
self.camera.borrow_mut().fit_depth_to_bounds(min, max);
|
||||
self.projection_bounds_epoch.set(self.geometry_epoch);
|
||||
}
|
||||
}
|
||||
|
||||
/// Switch the live camera projection without changing its target or
|
||||
/// orientation. Model layout bounds are refreshed first so perspective eye
|
||||
/// distance accounts for the current, visible drawing depth.
|
||||
pub fn set_projection_preserving_frame(
|
||||
&mut self,
|
||||
projection: view::camera::Projection,
|
||||
) -> bool {
|
||||
if self.camera.borrow().projection == projection {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.refresh_projection_bounds();
|
||||
|
||||
let aspect = self.active_camera_aspect();
|
||||
self.camera
|
||||
.borrow_mut()
|
||||
.set_projection_preserving_frame(projection, aspect);
|
||||
self.camera_generation += 1;
|
||||
true
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -219,6 +266,7 @@ impl Scene {
|
||||
self.camera
|
||||
.borrow_mut()
|
||||
.fit_to_bounds(min, max, aspect);
|
||||
self.projection_bounds_epoch.set(self.geometry_epoch);
|
||||
self.camera_generation += 1;
|
||||
}
|
||||
|
||||
@@ -603,6 +651,7 @@ impl Scene {
|
||||
*self.model_tiles.borrow_mut() = tiles;
|
||||
self.active_model_tile.set(0);
|
||||
*self.camera.borrow_mut() = active_cam;
|
||||
self.projection_bounds_epoch.set(0);
|
||||
// Rebuild the pane_grid layout from the restored tile rects so the panes
|
||||
// match (the renderer / input iterate panes, not tiles).
|
||||
self.rebuild_panes_from_tiles();
|
||||
@@ -1057,19 +1106,31 @@ impl Scene {
|
||||
}
|
||||
!is_infinite
|
||||
});
|
||||
// 3D solids render as meshes, not wires, so collect their (offset-rel)
|
||||
// XY AABBs separately — a drawing of only solids has no wires to fit.
|
||||
let mesh_aabbs: Vec<[f32; 4]> = self
|
||||
// 3D solids render as meshes, not wires, so collect their complete
|
||||
// world boxes separately — a drawing of only solids has no wires to fit.
|
||||
let mesh_aabbs: Vec<([f32; 4], [f32; 2])> = self
|
||||
.meshes
|
||||
.iter()
|
||||
.filter(|(h, _)| {
|
||||
self.document
|
||||
.get_entity(**h)
|
||||
.map(|e| e.common().owner_handle == layout_block)
|
||||
.unwrap_or(false)
|
||||
let handle = **h;
|
||||
self.mesh_entity_visible(handle)
|
||||
&& self.document.get_entity(handle).is_some_and(|entity| {
|
||||
self.belongs_to_visible_block(
|
||||
handle,
|
||||
entity.common().owner_handle,
|
||||
layout_block,
|
||||
)
|
||||
})
|
||||
})
|
||||
.map(|(_, set)| (set.world_aabb, set.z_aabb))
|
||||
.filter(|(xy, z)| {
|
||||
xy[0].is_finite()
|
||||
&& xy[1].is_finite()
|
||||
&& xy[2].is_finite()
|
||||
&& xy[3].is_finite()
|
||||
&& z[0].is_finite()
|
||||
&& z[1].is_finite()
|
||||
})
|
||||
.map(|(_, set)| set.world_aabb)
|
||||
.filter(|a| a[0].is_finite() && a[2].is_finite())
|
||||
.collect();
|
||||
if wires.is_empty() && mesh_aabbs.is_empty() && infinite_base_pts.is_empty() {
|
||||
return;
|
||||
@@ -1174,9 +1235,9 @@ impl Scene {
|
||||
}
|
||||
}
|
||||
// Fold in 3D-solid mesh AABBs (not subject to the wire IQR reject).
|
||||
for [ax, ay, bx, by] in &mesh_aabbs {
|
||||
min = min.min(glam::Vec3::new(*ax, *ay, 0.0));
|
||||
max = max.max(glam::Vec3::new(*bx, *by, 0.0));
|
||||
for ([ax, ay, bx, by], [az, bz]) in &mesh_aabbs {
|
||||
min = min.min(glam::Vec3::new(*ax, *ay, *az));
|
||||
max = max.max(glam::Vec3::new(*bx, *by, *bz));
|
||||
}
|
||||
// Drawing holds only infinite construction geometry: fit the view to
|
||||
// its base points rather than leaving the camera unchanged.
|
||||
@@ -1195,6 +1256,7 @@ impl Scene {
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -286,6 +286,7 @@ impl Scene {
|
||||
.map(|t| t.camera.clone());
|
||||
if let Some(c) = cam {
|
||||
*self.camera.borrow_mut() = c;
|
||||
self.projection_bounds_epoch.set(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -362,6 +363,7 @@ impl Scene {
|
||||
};
|
||||
if let Some(cam) = incoming {
|
||||
*self.camera.borrow_mut() = cam;
|
||||
self.projection_bounds_epoch.set(0);
|
||||
}
|
||||
self.active_model_tile.set(idx);
|
||||
true
|
||||
|
||||
+13
-5
@@ -1575,6 +1575,10 @@ pub struct Scene {
|
||||
/// preview wires, layer visibility, layout). The GPU pipeline uses this to
|
||||
/// skip re-uploading unchanged geometry buffers every frame.
|
||||
pub geometry_epoch: u64,
|
||||
/// Geometry epoch represented by the model bounds cached on the live
|
||||
/// camera. Projection switches refresh those bounds through the same
|
||||
/// filtered fit path when this falls behind `geometry_epoch`.
|
||||
projection_bounds_epoch: std::cell::Cell<u64>,
|
||||
/// Separate epoch for the (expensive) block-definition tessellation cache.
|
||||
/// Bumped together with `geometry_epoch` by `bump_geometry`, but NOT by
|
||||
/// `bump_geometry_no_blocks` — so edits that provably can't change any
|
||||
@@ -1953,6 +1957,7 @@ impl Scene {
|
||||
interim_wire: None,
|
||||
camera_generation: 0,
|
||||
geometry_epoch: GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed),
|
||||
projection_bounds_epoch: std::cell::Cell::new(0),
|
||||
block_epoch: GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed),
|
||||
selection_generation: 0,
|
||||
wire_cache: RefCell::new(None),
|
||||
@@ -9189,7 +9194,8 @@ impl Scene {
|
||||
}
|
||||
}
|
||||
// 3D solids render as meshes, not wires, so fold their
|
||||
// XY AABBs in too — otherwise ZOOM EXTENTS ignores them.
|
||||
// complete 3D boxes in too — otherwise view fitting loses
|
||||
// both solid-only drawings and their depth.
|
||||
for (&handle, set) in &self.meshes {
|
||||
if !self.mesh_entity_visible(handle)
|
||||
|| !self.document.get_entity(handle).is_some_and(|entity| {
|
||||
@@ -9203,8 +9209,9 @@ impl Scene {
|
||||
continue;
|
||||
}
|
||||
let [ax, ay, bx, by] = set.world_aabb;
|
||||
let lo = glam::Vec3::new(ax, ay, 0.0);
|
||||
let hi = glam::Vec3::new(bx, by, 0.0);
|
||||
let [az, bz] = set.z_aabb;
|
||||
let lo = glam::Vec3::new(ax, ay, az);
|
||||
let hi = glam::Vec3::new(bx, by, bz);
|
||||
if lo.is_finite() && hi.is_finite() {
|
||||
min = min.min(lo);
|
||||
max = max.max(hi);
|
||||
@@ -9261,8 +9268,9 @@ impl Scene {
|
||||
continue;
|
||||
}
|
||||
let [ax, ay, bx, by] = set.world_aabb;
|
||||
let lo = glam::Vec3::new(ax, ay, 0.0);
|
||||
let hi = glam::Vec3::new(bx, by, 0.0);
|
||||
let [az, bz] = set.z_aabb;
|
||||
let lo = glam::Vec3::new(ax, ay, az);
|
||||
let hi = glam::Vec3::new(bx, by, bz);
|
||||
if lo.is_finite() && hi.is_finite() {
|
||||
min = min.min(lo);
|
||||
max = max.max(hi);
|
||||
|
||||
@@ -107,6 +107,133 @@ impl Camera {
|
||||
self.distance * (self.fov_y * 0.5).tan()
|
||||
}
|
||||
|
||||
/// Change projection while keeping the fitted model's screen-space frame.
|
||||
///
|
||||
/// `distance` is both the orthographic zoom scale and the perspective eye
|
||||
/// distance. Reusing it unchanged only preserves scale on the target plane;
|
||||
/// geometry closer to the eye can grow dramatically. Match the maximum
|
||||
/// projected extent of the model box instead, leaving target, rotation and
|
||||
/// field of view untouched.
|
||||
pub fn set_projection_preserving_frame(
|
||||
&mut self,
|
||||
projection: Projection,
|
||||
aspect: f32,
|
||||
) {
|
||||
if self.projection == projection {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some((min, max)) = self.model_bounds else {
|
||||
self.projection = projection;
|
||||
return;
|
||||
};
|
||||
|
||||
let aspect = aspect.max(0.01);
|
||||
let tan_half_fov = (self.fov_y * 0.5).tan();
|
||||
if !tan_half_fov.is_finite() || tan_half_fov <= 1e-6 {
|
||||
self.projection = projection;
|
||||
return;
|
||||
}
|
||||
|
||||
let corners = self.bounds_in_view(min, max);
|
||||
let radius = |corner: &Vec3| corner.y.abs().max(corner.x.abs() / aspect);
|
||||
let max_radius = corners.iter().map(radius).fold(0.0_f32, f32::max);
|
||||
let min_z = corners
|
||||
.iter()
|
||||
.map(|corner| corner.z)
|
||||
.fold(f32::INFINITY, f32::min);
|
||||
let max_z = corners
|
||||
.iter()
|
||||
.map(|corner| corner.z)
|
||||
.fold(f32::NEG_INFINITY, f32::max);
|
||||
let depth_margin = ((max_z - min_z).abs() * 0.001).max(0.001);
|
||||
let near_safe = if max_z > 0.0 {
|
||||
(max_z / (1.0 - 0.001)) * 1.001
|
||||
} else {
|
||||
0.001
|
||||
};
|
||||
let far_safe = if min_z < 0.0 {
|
||||
(-min_z / (1000.0 - 1.0)) * 1.001
|
||||
} else {
|
||||
0.001
|
||||
};
|
||||
let depth_safe_distance = (max_z + depth_margin)
|
||||
.max(near_safe)
|
||||
.max(far_safe)
|
||||
.max(0.001);
|
||||
if !max_radius.is_finite() || max_radius <= 1e-6 {
|
||||
if projection == Projection::Perspective && depth_safe_distance.is_finite() {
|
||||
self.distance = self.distance.max(depth_safe_distance);
|
||||
}
|
||||
self.projection = projection;
|
||||
return;
|
||||
}
|
||||
|
||||
// Extent is the model box's largest absolute NDC X/Y displacement.
|
||||
// When an existing perspective eye is already inside the box, its
|
||||
// projected envelope is undefined; fall back to its target-plane scale
|
||||
// so switching back to orthographic still recovers a usable view.
|
||||
let old_extent = match self.projection {
|
||||
Projection::Orthographic => max_radius / self.ortho_size().max(1e-6),
|
||||
Projection::Perspective => {
|
||||
let near = (self.distance * 0.001).max(1e-6);
|
||||
let mut extent = 0.0_f32;
|
||||
let mut all_in_front = true;
|
||||
for corner in &corners {
|
||||
let depth = self.distance - corner.z;
|
||||
if depth <= near {
|
||||
all_in_front = false;
|
||||
break;
|
||||
}
|
||||
extent = extent.max(radius(corner) / (depth * tan_half_fov));
|
||||
}
|
||||
if all_in_front {
|
||||
extent
|
||||
} else {
|
||||
max_radius / (self.distance.max(0.001) * tan_half_fov)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if !old_extent.is_finite() || old_extent <= 1e-6 {
|
||||
if projection == Projection::Perspective && depth_safe_distance.is_finite() {
|
||||
self.distance = self.distance.max(depth_safe_distance);
|
||||
}
|
||||
self.projection = projection;
|
||||
return;
|
||||
}
|
||||
|
||||
match projection {
|
||||
Projection::Orthographic => {
|
||||
let distance = max_radius / (old_extent * tan_half_fov);
|
||||
if distance.is_finite() {
|
||||
self.distance = distance.max(0.001);
|
||||
}
|
||||
}
|
||||
Projection::Perspective => {
|
||||
// For every corner, radius / ((distance - z) * tan(fov/2))
|
||||
// must be no larger than the old screen extent. The maximum
|
||||
// required distance gives the tightest perspective frame with
|
||||
// at least one corner touching the old envelope.
|
||||
let extent_at_depth = old_extent * tan_half_fov;
|
||||
let mut distance = f32::NEG_INFINITY;
|
||||
for corner in &corners {
|
||||
distance = distance.max(corner.z + radius(corner) / extent_at_depth);
|
||||
}
|
||||
|
||||
// Keep the complete box ahead of the perspective near plane,
|
||||
// and its far plane, including corners whose X/Y radius does
|
||||
// not constrain framing.
|
||||
distance = distance.max(depth_safe_distance);
|
||||
if distance.is_finite() {
|
||||
self.distance = distance.max(0.001);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.projection = projection;
|
||||
}
|
||||
|
||||
// ── Projection matrices ────────────────────────────────────────────────
|
||||
|
||||
/// Orthographic near/far that CENTRE the target plane at ndc-z ≈ 0.5.
|
||||
|
||||
Reference in new issue
Block a user