From b3a1119b045b1f783a45596b1b1880024866188c Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 23 Jun 2026 18:27:00 +0300 Subject: [PATCH] fix(camera): precise zoom-to-cursor pivot at large coordinates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit zoom_about_point picked the world point under the cursor twice via view_proj.inverse() (the full f32 matrix) and shifted target by their difference. At UTM coordinates that inverse carries ~0.5 m of f32 error that didn't cancel between the two picks, so target drifted every zoom step and the whole scene jumped as a block. Compute the pivot as the cursor's offset *relative to target* in the camera frame (small numbers, rotated to world) instead — it never touches the large absolute target, so the before/after difference is exact and the scene stays put while zooming. Co-Authored-By: Claude Opus 4.7 --- src/scene/view/camera.rs | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/scene/view/camera.rs b/src/scene/view/camera.rs index 92bb82bdb..2583a03cf 100644 --- a/src/scene/view/camera.rs +++ b/src/scene/view/camera.rs @@ -287,15 +287,37 @@ impl Camera { self.distance = (self.distance * (1.0 - delta * 0.1)).max(0.001); } + /// World-space offset from `target` to the point under `screen` on the + /// target plane. Computed in the camera frame (small numbers) and rotated + /// to world — it never touches the large absolute target, so it stays + /// precise at UTM-scale coordinates. For perspective this is the offset + /// evaluated at the target plane (the correct pivot for zoom-to-cursor). + fn cursor_offset_on_target_plane(&self, screen: Point, bounds: Rectangle) -> Vec3 { + let ndc_x = (screen.x / bounds.width) * 2.0 - 1.0; + let ndc_y = 1.0 - (screen.y / bounds.height) * 2.0; + let aspect = bounds.width / bounds.height; + let half_h = self.ortho_size(); + let half_w = half_h * aspect; + let cam_right = self.rotation * Vec3::X; + let cam_up = self.rotation * Vec3::Y; + cam_right * (ndc_x * half_w) + cam_up * (ndc_y * half_h) + } + pub fn zoom_about_point(&mut self, screen: Point, bounds: Rectangle, delta: f32) { if bounds.width <= 0.0 || bounds.height <= 0.0 { self.zoom(delta); return; } - let before = self.pick_on_target_plane(screen, bounds); + // Keep the point under the cursor fixed by working with its offset + // *relative to target* before and after the zoom. Both offsets are + // small (camera-frame) numbers, so their difference is exact even at + // UTM coordinates — the old absolute view_proj.inverse() picks each + // carried ~0.5 m of f32 error that didn't cancel, making the whole + // scene jump on every zoom step. + let before = self.cursor_offset_on_target_plane(screen, bounds); self.zoom(delta); - let after = self.pick_on_target_plane(screen, bounds); + let after = self.cursor_offset_on_target_plane(screen, bounds); self.target += (before - after).as_dvec3(); }