Compare commits

...

6 Commits

Author SHA1 Message Date
bherbruck 5de48e916d Release 0.16.2 2026-10-19 00:39:30 +00:00
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
bherbruck 2b9445b049 Release 0.16.1 2026-10-18 20:16:21 +00:00
bherbruck 7ddf48ca16 Release 0.16.1
Version 0.16.1: 0.16.0 with the orbit fix (reverts #2142). Same design format (27).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:12:04 +00:00
bherbruck d2f6848377 Revert #2142: orbit about the picked point like 0.15.0
#2142 made orbiting about the point under the cursor turn the camera in place instead of turning the part about that point. Restore the 0.15.0 behaviour.

This reverts commit cac4bccea8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:12:00 +00:00
7 changed files with 100 additions and 117 deletions
Generated
+17 -17
View File
@@ -4078,7 +4078,7 @@ dependencies = [
[[package]]
name = "solidcraft"
version = "0.16.0"
version = "0.16.2"
dependencies = [
"block2 0.6.2",
"eframe",
@@ -4109,11 +4109,11 @@ dependencies = [
[[package]]
name = "solidcraft-3dx-client"
version = "0.16.0"
version = "0.16.2"
[[package]]
name = "solidcraft-cli"
version = "0.16.0"
version = "0.16.2"
dependencies = [
"image",
"serde_json",
@@ -4124,7 +4124,7 @@ dependencies = [
[[package]]
name = "solidcraft-derive"
version = "0.16.0"
version = "0.16.2"
dependencies = [
"proc-macro2",
"quote",
@@ -4133,7 +4133,7 @@ dependencies = [
[[package]]
name = "solidcraft-doc"
version = "0.16.0"
version = "0.16.2"
dependencies = [
"log",
"serde",
@@ -4150,7 +4150,7 @@ dependencies = [
[[package]]
name = "solidcraft-engine"
version = "0.16.0"
version = "0.16.2"
dependencies = [
"log",
"serde",
@@ -4167,7 +4167,7 @@ dependencies = [
[[package]]
name = "solidcraft-geom"
version = "0.16.0"
version = "0.16.2"
dependencies = [
"serde",
"serde_json",
@@ -4176,7 +4176,7 @@ dependencies = [
[[package]]
name = "solidcraft-gpu"
version = "0.16.0"
version = "0.16.2"
dependencies = [
"log",
"naga",
@@ -4193,7 +4193,7 @@ dependencies = [
[[package]]
name = "solidcraft-io"
version = "0.16.0"
version = "0.16.2"
dependencies = [
"cadmpeg-core",
"cadmpeg-ir",
@@ -4213,7 +4213,7 @@ dependencies = [
[[package]]
name = "solidcraft-kernel"
version = "0.16.0"
version = "0.16.2"
dependencies = [
"log",
"proptest",
@@ -4233,7 +4233,7 @@ dependencies = [
[[package]]
name = "solidcraft-mcp"
version = "0.16.0"
version = "0.16.2"
dependencies = [
"serde_json",
"solidcraft-engine",
@@ -4242,7 +4242,7 @@ dependencies = [
[[package]]
name = "solidcraft-render"
version = "0.16.0"
version = "0.16.2"
dependencies = [
"image",
"moxcms",
@@ -4256,7 +4256,7 @@ dependencies = [
[[package]]
name = "solidcraft-sketch"
version = "0.16.0"
version = "0.16.2"
dependencies = [
"serde",
"serde_json",
@@ -4267,11 +4267,11 @@ dependencies = [
[[package]]
name = "solidcraft-space-mouse"
version = "0.16.0"
version = "0.16.2"
[[package]]
name = "solidcraft-ui-egui"
version = "0.16.0"
version = "0.16.2"
dependencies = [
"arboard",
"egui",
@@ -4296,7 +4296,7 @@ dependencies = [
[[package]]
name = "solidcraft-web"
version = "0.16.0"
version = "0.16.2"
dependencies = [
"eframe",
"egui",
@@ -5826,7 +5826,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.16.0"
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.0"
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)]
+9 -67
View File
@@ -849,47 +849,18 @@ pub fn zoom_sign(app: &SolveApp) -> f64 {
}
/// The model point under a screen position. Over empty space: the centre of the visible bodies
/// and sketches (what Fit frames) while it is on screen; zoomed in so far that it isn't, the
/// model point at the view's centre, so what you look at stays in view (#2138); and the view's
/// centre when neither is there.
/// and sketches (what Fit frames), or the view's centre when nothing is visible.
pub(crate) fn pivot_under(app: &SolveApp, proj: &crate::viewport::Proj, at: Pos2) -> Vec3 {
let under = |at: Pos2| {
use crate::viewport::Hit;
crate::viewport::pick(app, proj, at).into_iter().find_map(|h| match h {
Hit::Face { point, .. } | Hit::Vertex { point, .. } => Some(point),
// The point of the edge under the cursor: a long edge's midpoint can be far off screen.
Hit::Edge { body, index, mid } => Some(edge_point_near(app, proj, &body, index, at).unwrap_or(mid)),
_ => None,
})
};
if let Some(p) = under(at) {
return p;
}
let b = crate::viewport::view_bounds(app);
if !b.is_empty() && proj.to_screen(b.center()).is_some_and(|s| proj.rect.contains(s)) {
return b.center();
}
under(proj.rect.center()).unwrap_or(app.cam.target)
}
/// The point of a body's edge nearest `at` on screen (each segment of its polyline measured on
/// screen, the nearest point taken along the segment in the model).
fn edge_point_near(app: &SolveApp, proj: &crate::viewport::Proj, body: &str, index: usize, at: Pos2) -> Option<Vec3> {
let st = app.session.world_state();
let mesh = st.body(body)?.mesh();
let edge = mesh.edges.get(index)?;
let mut best: Option<(f32, Vec3)> = None;
for w in edge.windows(2) {
let (Some(a), Some(b)) = (w.first().copied(), w.get(1).copied()) else { continue };
let (Some(sa), Some(sb)) = (proj.to_screen(a), proj.to_screen(b)) else { continue };
let d = sb - sa;
let t = if d.length_sq() > 1e-6 { ((at - sa).dot(d) / d.length_sq()).clamp(0.0, 1.0) } else { 0.0 };
let dist = (sa + d * t).distance(at);
if best.is_none_or(|(bd, _)| dist < bd) {
best = Some((dist, a + (b - a) * f64::from(t)));
use crate::viewport::Hit;
for h in crate::viewport::pick(app, proj, at) {
match h {
Hit::Face { point, .. } | Hit::Vertex { point, .. } => return point,
Hit::Edge { mid, .. } => return mid,
_ => {}
}
}
best.map(|(_, p)| p)
let b = crate::viewport::view_bounds(app);
if b.is_empty() { app.cam.target } else { b.center() }
}
/// A plain number typed into a field, with a decimal point or a decimal comma (#1347): "2.5"
@@ -2424,35 +2395,6 @@ mod tests {
assert_eq!(app.viewport.orbit_pivot, Some(solidcraft_engine::geom::Vec3::new(5.0, -3.0, 2.0)));
}
/// #2138: zoomed in on one end of a long part, the visible geometry's centre is off screen. A
/// drag from empty space must not turn around it, or the end you are looking at swings out of
/// view; it turns around the model point at the view's centre instead.
#[test]
fn orbiting_from_empty_space_keeps_a_zoomed_in_view_on_screen() {
use solidcraft_engine::geom::Vec3;
let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
app.run("solid.box", json!({"length": 400, "width": 10, "height": 10})).unwrap();
app.preferences.orbit_around = OrbitAround::Cursor;
let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0));
app.fit_view();
// Look closely at the end near the origin.
let end = Vec3::new(5.0, 5.0, 5.0);
app.cam.target = end;
app.cam.distance = 60.0;
let proj = crate::viewport::projection(&app, rect);
let centre = crate::viewport::view_bounds(&app).center();
assert!(proj.to_screen(centre).is_none_or(|s| !rect.contains(s)), "the part's centre is off screen");
let corner = egui::pos2(10.0, 10.0);
use crate::viewport::Hit;
assert!(!crate::viewport::pick(&app, &proj, corner).iter().any(|h| matches!(h, Hit::Face { .. } | Hit::Edge { .. } | Hit::Vertex { .. })));
for _ in 0..4 {
orbit(&mut app, rect, 60.0, 20.0, Some(corner));
}
assert_ne!(app.viewport.orbit_pivot, Some(centre));
let s = crate::viewport::projection(&app, rect).to_screen(end);
assert!(s.is_some_and(|s| rect.contains(s)), "the end stays in view: {s:?}");
}
/// The Y up preference turns the view: the camera's up axis follows it, startup included.
#[test]
fn up_axis_preference_turns_the_view() {
+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();