mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-05 03:25:58 +00:00
Fix orbit about the picked point (regression in 0.16.0) (#2753)
Orbiting about the point under the cursor (or the origin) turned the camera about its target and then panned to put the pivot back. The pan works at the target's depth with the parallel-view scale, so in a perspective view the pivot slid off its pixel and the model swung about the camera. The camera now turns rigidly about the pivot: Camera::orbit_about keeps the pivot's coordinates in the camera's frame and rebuilds the target from the new orientation, so the pivot stays exactly on its pixel in both projections. The Shift snap spring and view.navigate's pivot orbit use the same. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -91,16 +91,8 @@ fn continuous(mut cam: Camera, p: &Value) -> Result<Value> {
|
||||
cam.pan(delta.x, delta.y, viewport()?.y);
|
||||
} else if p.get("pivot").is_some() {
|
||||
let pivot = req_vec3(cmd, p, "pivot")?;
|
||||
let size = viewport()?;
|
||||
let before = cam.to_screen(pivot, size.x, size.y, 1.0);
|
||||
cam.orbit(delta.x, delta.y);
|
||||
let after = cam.to_screen(pivot, size.x, size.y, 1.0);
|
||||
if let (Some((ax, ay, _)), Some((bx, by, _))) = (before, after) {
|
||||
// Pan operates at target depth. Perspective screen correction must instead
|
||||
// use the pivot's depth after orbiting; lateral pan leaves that depth fixed.
|
||||
let scale = if cam.fov > 0.0 { (cam.eye() - pivot).dot(cam.back()) / cam.distance } else { 1.0 };
|
||||
cam.pan((ax - bx) * scale, (ay - by) * scale, size.y);
|
||||
}
|
||||
viewport()?;
|
||||
cam.orbit_about(delta.x, delta.y, pivot);
|
||||
} else {
|
||||
cam.orbit(delta.x, delta.y);
|
||||
}
|
||||
|
||||
@@ -353,6 +353,25 @@ impl Camera {
|
||||
self.yaw = (self.yaw + dx * 0.008) % std::f64::consts::TAU;
|
||||
self.pitch = (self.pitch + dy * 0.008).clamp(-lim, lim);
|
||||
}
|
||||
/// Orbit by pixel deltas about `pivot`: the camera turns rigidly about it, so the pivot keeps
|
||||
/// its place in the camera's frame and on screen, in perspective as in parallel views.
|
||||
pub fn orbit_about(&mut self, dx: f64, dy: f64, pivot: Vec3) {
|
||||
let keep = self.frame_coords(pivot);
|
||||
self.orbit(dx, dy);
|
||||
self.place(pivot, keep);
|
||||
}
|
||||
/// Where `p` lies from the target along the camera's right, up and back axes.
|
||||
pub fn frame_coords(&self, p: Vec3) -> Vec3 {
|
||||
let (r, u, b) = self.basis();
|
||||
let d = p - self.target;
|
||||
Vec3::new(d.dot(r), d.dot(u), d.dot(b))
|
||||
}
|
||||
/// Move the target so `p` lies at `coords` (from [`Camera::frame_coords`]) in the camera's
|
||||
/// frame: with the distance unchanged, `p` is where it was on screen.
|
||||
pub fn place(&mut self, p: Vec3, coords: Vec3) {
|
||||
let (r, u, b) = self.basis();
|
||||
self.target = p - (r * coords.x + u * coords.y + b * coords.z);
|
||||
}
|
||||
/// Rotate the image about the viewing direction.
|
||||
pub fn roll_by(&mut self, radians: f64) {
|
||||
if radians.is_finite() {
|
||||
@@ -432,6 +451,24 @@ impl Camera {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Orbiting about a point keeps it on its pixel, with and without perspective and roll.
|
||||
#[test]
|
||||
fn orbit_about_keeps_the_pivot_on_screen() {
|
||||
let pivot = Vec3::new(12.0, -7.0, 9.0);
|
||||
for fov in [0.0, 0.6] {
|
||||
for roll in [0.0, 0.4] {
|
||||
let mut cam = Camera { target: Vec3::new(3.0, 4.0, -2.0), distance: 150.0, yaw: 0.3, pitch: 0.5, roll, fov, ..Camera::default() };
|
||||
let before = cam.to_screen(pivot, 800.0, 600.0, 100.0).unwrap();
|
||||
for _ in 0..10 {
|
||||
cam.orbit_about(25.0, -12.0, pivot);
|
||||
}
|
||||
let after = cam.to_screen(pivot, 800.0, 600.0, 100.0).unwrap();
|
||||
assert!((after.0 - before.0).abs() < 1e-6 && (after.1 - before.1).abs() < 1e-6, "fov {fov}, roll {roll}: {before:?} -> {after:?}");
|
||||
assert!((cam.eye() - pivot).len() > 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn front_view_projects_target_to_centre() {
|
||||
let mut c = Camera { target: Vec3::new(1.0, 2.0, 3.0), distance: 50.0, ..Default::default() };
|
||||
|
||||
@@ -574,7 +574,8 @@ pub(crate) struct OrbitSnap {
|
||||
pub(crate) target: Camera,
|
||||
pub(crate) yaw_velocity: f64,
|
||||
pub(crate) pitch_velocity: f64,
|
||||
pub(crate) pivot: Option<(Vec3, Pos2)>,
|
||||
/// The pivot and where it lies in the camera's frame ([`Camera::frame_coords`]).
|
||||
pub(crate) pivot: Option<(Vec3, Vec3)>,
|
||||
}
|
||||
|
||||
/// Pointer motion accumulates independently of the spring preview, so many small deltas
|
||||
@@ -610,10 +611,7 @@ pub(crate) fn orbit_drag(app: &mut SolveApp, rect: Rect, dx: f64, dy: f64, press
|
||||
} else {
|
||||
None
|
||||
}
|
||||
.and_then(|p| {
|
||||
app.cam = displayed;
|
||||
projection(app, rect).to_screen(p).map(|at| (p, at))
|
||||
});
|
||||
.map(|p| (p, displayed.frame_coords(p)));
|
||||
let spring = app.viewport.orbit_snap.get_or_insert(OrbitSnap { target, yaw_velocity: 0.0, pitch_velocity: 0.0, pivot });
|
||||
spring.target = target;
|
||||
spring.pivot = pivot;
|
||||
@@ -623,7 +621,7 @@ pub(crate) fn orbit_drag(app: &mut SolveApp, rect: Rect, dx: f64, dy: f64, press
|
||||
|
||||
/// A lightly damped spring: mostly settled in about a third of a second, with a small
|
||||
/// elastic finish. Bounded substeps keep the behavior consistent across frame rates.
|
||||
pub(crate) fn step_orbit_snap(app: &mut SolveApp, rect: Rect, dt: f64) {
|
||||
pub(crate) fn step_orbit_snap(app: &mut SolveApp, _rect: Rect, dt: f64) {
|
||||
let Some(mut spring) = app.viewport.orbit_snap.take() else { return };
|
||||
let dt = if dt.is_finite() { dt.clamp(0.0, 0.05) } else { 1.0 / 60.0 };
|
||||
let steps = (dt * 240.0).ceil().max(1.0) as usize;
|
||||
@@ -645,10 +643,8 @@ pub(crate) fn step_orbit_snap(app: &mut SolveApp, rect: Rect, dt: f64) {
|
||||
if done {
|
||||
app.cam = spring.target;
|
||||
}
|
||||
if let Some((pivot, at)) = spring.pivot
|
||||
&& let Some(after) = projection(app, rect).to_screen(pivot)
|
||||
{
|
||||
app.cam.pan(f64::from(at.x - after.x), f64::from(at.y - after.y), f64::from(rect.height().max(1.0)));
|
||||
if let Some((pivot, coords)) = spring.pivot {
|
||||
app.cam.place(pivot, coords);
|
||||
}
|
||||
if !done {
|
||||
app.viewport.orbit_snap = Some(spring);
|
||||
@@ -707,18 +703,7 @@ pub fn orbit(app: &mut SolveApp, rect: egui::Rect, dx: f64, dy: f64, press: Opti
|
||||
app.cam.orbit(dx, dy);
|
||||
return;
|
||||
};
|
||||
orbit_about(app, rect, dx, dy, pivot);
|
||||
}
|
||||
|
||||
/// Orbit by pixel deltas, keeping `pivot` still on screen: the camera turns about it and a pan
|
||||
/// correction puts it back where it was.
|
||||
pub(crate) fn orbit_about(app: &mut SolveApp, rect: egui::Rect, dx: f64, dy: f64, pivot: Vec3) {
|
||||
let before = crate::viewport::projection(app, rect).to_screen(pivot);
|
||||
app.cam.orbit(dx, dy);
|
||||
let after = crate::viewport::projection(app, rect).to_screen(pivot);
|
||||
if let (Some(a), Some(b)) = (before, after) {
|
||||
app.cam.pan(f64::from(a.x - b.x), f64::from(a.y - b.y), f64::from(rect.height().max(1.0)));
|
||||
}
|
||||
app.cam.orbit_about(dx, dy, pivot);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -5911,6 +5911,33 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// An orbit drag that starts on the model turns about the point under the cursor, which
|
||||
/// stays on its pixel, in perspective as in parallel views. The perspective view used to
|
||||
/// correct with a pan at the target's depth, so the point slid away and the view swung
|
||||
/// about the camera instead.
|
||||
#[test]
|
||||
fn cursor_orbit_keeps_the_picked_point_on_its_pixel() {
|
||||
for perspective in [false, true] {
|
||||
for offset in [vec2(30.0, 20.0), vec2(-60.0, 10.0), vec2(120.0, -40.0)] {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.preferences.orbit_around = crate::prefs::OrbitAround::Cursor;
|
||||
h.app.ui.perspective = perspective;
|
||||
h.app.run("solid.box", json!({"length":80,"width":50,"height":20})).unwrap();
|
||||
h.app.fit_view();
|
||||
let rect = h.app.viewport.rect.unwrap();
|
||||
let at = rect.center() + offset;
|
||||
let proj = projection(&h.app, rect);
|
||||
assert!(pick(&h.app, &proj, at).iter().any(|hit| matches!(hit, Hit::Face { .. })), "the drag starts on the box");
|
||||
let pivot = crate::prefs::pivot_under(&h.app, &proj, at);
|
||||
let before = h.app.cam;
|
||||
h.call("ui.drag", json!({"x0":at.x,"y0":at.y,"x1":at.x+150.0,"y1":at.y+60.0,"button":"middle","shift":true,"steps":20}));
|
||||
assert!((h.app.cam.yaw - before.yaw).abs() > 0.5, "the view turned");
|
||||
let after = projection(&h.app, rect).to_screen(pivot).unwrap();
|
||||
assert!(after.distance(at) < 0.5, "perspective={perspective}: {at:?} -> {after:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_orbit_keeps_cursor_pivot_and_default_middle_pan() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
|
||||
Reference in New Issue
Block a user