Compare commits

...

2 Commits

Author SHA1 Message Date
bherbruck 80af7bbe3e Release 0.16.2
Version 0.16.2: 0.16.1 with the rigid orbit fix (#2753). Same design format (27).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:38:53 +00:00
bherbruck 191e983423 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>
(cherry picked from commit e88779218)
2026-10-19 00:38:52 +00:00
6 changed files with 91 additions and 50 deletions
Generated
+17 -17
View File
@@ -4078,7 +4078,7 @@ dependencies = [
[[package]]
name = "solidcraft"
version = "0.16.1"
version = "0.16.2"
dependencies = [
"block2 0.6.2",
"eframe",
@@ -4109,11 +4109,11 @@ dependencies = [
[[package]]
name = "solidcraft-3dx-client"
version = "0.16.1"
version = "0.16.2"
[[package]]
name = "solidcraft-cli"
version = "0.16.1"
version = "0.16.2"
dependencies = [
"image",
"serde_json",
@@ -4124,7 +4124,7 @@ dependencies = [
[[package]]
name = "solidcraft-derive"
version = "0.16.1"
version = "0.16.2"
dependencies = [
"proc-macro2",
"quote",
@@ -4133,7 +4133,7 @@ dependencies = [
[[package]]
name = "solidcraft-doc"
version = "0.16.1"
version = "0.16.2"
dependencies = [
"log",
"serde",
@@ -4150,7 +4150,7 @@ dependencies = [
[[package]]
name = "solidcraft-engine"
version = "0.16.1"
version = "0.16.2"
dependencies = [
"log",
"serde",
@@ -4167,7 +4167,7 @@ dependencies = [
[[package]]
name = "solidcraft-geom"
version = "0.16.1"
version = "0.16.2"
dependencies = [
"serde",
"serde_json",
@@ -4176,7 +4176,7 @@ dependencies = [
[[package]]
name = "solidcraft-gpu"
version = "0.16.1"
version = "0.16.2"
dependencies = [
"log",
"naga",
@@ -4193,7 +4193,7 @@ dependencies = [
[[package]]
name = "solidcraft-io"
version = "0.16.1"
version = "0.16.2"
dependencies = [
"cadmpeg-core",
"cadmpeg-ir",
@@ -4213,7 +4213,7 @@ dependencies = [
[[package]]
name = "solidcraft-kernel"
version = "0.16.1"
version = "0.16.2"
dependencies = [
"log",
"proptest",
@@ -4233,7 +4233,7 @@ dependencies = [
[[package]]
name = "solidcraft-mcp"
version = "0.16.1"
version = "0.16.2"
dependencies = [
"serde_json",
"solidcraft-engine",
@@ -4242,7 +4242,7 @@ dependencies = [
[[package]]
name = "solidcraft-render"
version = "0.16.1"
version = "0.16.2"
dependencies = [
"image",
"moxcms",
@@ -4256,7 +4256,7 @@ dependencies = [
[[package]]
name = "solidcraft-sketch"
version = "0.16.1"
version = "0.16.2"
dependencies = [
"serde",
"serde_json",
@@ -4267,11 +4267,11 @@ dependencies = [
[[package]]
name = "solidcraft-space-mouse"
version = "0.16.1"
version = "0.16.2"
[[package]]
name = "solidcraft-ui-egui"
version = "0.16.1"
version = "0.16.2"
dependencies = [
"arboard",
"egui",
@@ -4296,7 +4296,7 @@ dependencies = [
[[package]]
name = "solidcraft-web"
version = "0.16.1"
version = "0.16.2"
dependencies = [
"eframe",
"egui",
@@ -5826,7 +5826,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.16.1"
version = "0.16.2"
dependencies = [
"serde_json",
"sha2 0.10.9",
+1 -1
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["vendor"]
[workspace.package]
version = "0.16.1"
version = "0.16.2"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+2 -10
View File
@@ -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);
}
+37
View File
@@ -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() };
+7 -22
View File
@@ -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)]
+27
View File
@@ -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();