Merge pull request #189: add Windows SpaceMouse navigation

This commit is contained in:
bherbruck committed 2026-10-11 17:04:47 -04:00
commit 8cce89d78a
11 files changed
+353 -23

No files matched your search

+3
View File
@@ -12,3 +12,6 @@ workspace = true
solvecraft-geom = { workspace = true }
serde = { workspace = true }
image = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
+45 -10
View File
@@ -224,6 +224,9 @@ pub struct Camera {
pub yaw: f64,
/// Elevation above the XY plane, in (−π/2, π/2].
pub pitch: f64,
/// Rotation about the viewing direction. Zero keeps the world's up axis vertical.
#[serde(default)]
pub roll: f64,
/// Distance from target to eye.
pub distance: f64,
/// Vertical field of view (radians); 0 = orthographic.
@@ -236,7 +239,7 @@ pub struct Camera {
impl Default for Camera {
fn default() -> Self {
let (yaw, pitch) = StandardView::Iso.angles();
Camera { target: Vec3::ZERO, yaw, pitch, distance: 200.0, fov: 0.0, up: UpAxis::Z }
Camera { target: Vec3::ZERO, yaw, pitch, roll: 0.0, distance: 200.0, fov: 0.0, up: UpAxis::Z }
}
}
@@ -266,7 +269,8 @@ impl Camera {
let b = self.back();
let right = self.up.from_frame(Vec3::new(self.yaw.cos(), -self.yaw.sin(), 0.0));
let up = b.cross(right).normalized().unwrap_or(self.up.dir());
(right, up, b)
let (s, c) = self.roll.sin_cos();
(right * c + up * s, up * c - right * s, b)
}
pub fn view(&self) -> Mat4 {
let (r, u, b) = self.basis();
@@ -349,11 +353,18 @@ impl Camera {
self.yaw = (self.yaw + dx * 0.008) % std::f64::consts::TAU;
self.pitch = (self.pitch + dy * 0.008).clamp(-lim, lim);
}
/// Rotate the image about the viewing direction.
pub fn roll_by(&mut self, radians: f64) {
if radians.is_finite() {
self.roll = (self.roll + radians) % std::f64::consts::TAU;
}
}
pub fn set_view(&mut self, v: StandardView) {
let lim = std::f64::consts::FRAC_PI_2 - 1e-6;
let (y, p) = v.angles();
self.yaw = y;
self.pitch = p.clamp(-lim, lim);
self.roll = 0.0;
}
/// Frame a bounding box.
pub fn fit(&mut self, b: &Aabb3) {
@@ -376,15 +387,14 @@ impl Camera {
} else {
(-b.x).atan2(-b.y)
};
Camera { yaw, pitch, ..*self }
Camera { yaw, pitch, roll: 0.0, ..*self }
}
/// Orientation as a unit quaternion (camera → world rotation).
pub fn orientation(&self) -> Quat {
let (r, u, b) = self.basis();
Quat::from_basis(r, u, b)
}
/// Blend between two cameras at `s` in [0, 1]: heading (the shorter way), elevation,
/// target, distance and field of view linearly. The ends are returned exactly.
/// Blend between two cameras at `s` in [0, 1]: heading and roll take the shorter way.
pub fn interpolate(a: &Camera, b: &Camera, s: f64) -> Camera {
if !(s > 0.0) {
return *a;
@@ -392,24 +402,29 @@ impl Camera {
if s >= 1.0 {
return *b;
}
// The camera has no roll, so it turns in heading and elevation: both change smoothly
// to the very end (the heading the shorter way round). A slerp of orientations would
// need a roll-free heading at every step, and near straight up or down that heading
// swings fast, so a move to Top spun in its last frames.
// Keep heading and elevation smooth near straight up or down. A quaternion slerp
// swings the heading sharply there when moving to Top.
let dy = (b.yaw - a.yaw + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI;
let yaw = a.yaw + dy * s;
let dr = (b.roll - a.roll + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI;
let pitch = a.pitch + (b.pitch - a.pitch) * s;
Camera {
target: a.target + (b.target - a.target) * s,
yaw,
pitch,
roll: a.roll + dr * s,
distance: a.distance + (b.distance - a.distance) * s,
fov: a.fov + (b.fov - a.fov) * s,
up: b.up,
}
}
pub fn is_valid(&self) -> bool {
self.target.is_finite() && self.yaw.is_finite() && self.pitch.is_finite() && self.distance.is_finite() && self.distance > 0.0
self.target.is_finite()
&& self.yaw.is_finite()
&& self.pitch.is_finite()
&& self.roll.is_finite()
&& self.distance.is_finite()
&& self.distance > 0.0
}
}
@@ -444,6 +459,26 @@ mod tests {
assert!(x1 > x0, "dragging right must move the near side right: {x0} -> {x1}");
}
#[test]
fn roll_rotates_the_picture_and_standard_views_level_it() {
let mut c = Camera::default();
c.set_view(StandardView::Front);
let (_, before_y, _) = c.to_screen(Vec3::X, 800.0, 600.0, 10.0).unwrap();
c.roll_by(std::f64::consts::FRAC_PI_2);
let (after_x, after_y, _) = c.to_screen(Vec3::X, 800.0, 600.0, 10.0).unwrap();
assert!((before_y - 300.0).abs() < 1e-9);
assert!((after_x - 400.0).abs() < 1e-9 && after_y > 300.0);
let (r, u, b) = c.basis();
assert!(r.dot(u).abs() < 1e-12 && r.dot(b).abs() < 1e-12 && u.dot(b).abs() < 1e-12);
c.set_view(StandardView::Front);
assert_eq!(c.roll, 0.0);
let saved = serde_json::to_value(c).unwrap();
let mut old = saved.as_object().unwrap().clone();
old.remove("roll");
let restored: Camera = serde_json::from_value(serde_json::Value::Object(old)).unwrap();
assert_eq!(restored.roll, 0.0);
}
#[test]
fn view_animation_interpolates() {
let mut a = Camera { target: Vec3::new(1.0, 2.0, 3.0), distance: 80.0, ..Default::default() };
+9
View File
@@ -863,6 +863,15 @@ impl SolveApp {
self.cam_anim = None;
}
/// Hand the view to an external navigation device without a pending mouse orbit or spin
/// immediately taking it back on the next frame.
pub fn begin_external_navigation(&mut self) {
self.cancel_view_animation();
self.viewport.orbit_snap = None;
self.viewport.orbit_free = None;
self.viewport.spin = false;
}
fn step_view_animation(&mut self, ctx: &egui::Context) {
let Some(anim) = self.cam_anim else { return };
let (cam, done) = anim.sample(self.now);
+18
View File
@@ -39,6 +39,10 @@ pub struct Prefs {
pub zoom_reverse: bool,
/// Orbit around the point under the cursor (else around the view's centre).
pub orbit_cursor: bool,
/// Whether a connected 3D mouse moves the view.
pub space_mouse_enabled: bool,
/// Speed multiplier for 3D mouse navigation.
pub space_mouse_speed: f32,
/// Anti-aliasing samples (applies on restart).
pub msaa: u32,
/// Decimals shown for lengths and angles.
@@ -68,6 +72,8 @@ impl Default for Prefs {
nav: "fusion".into(),
zoom_reverse: false,
orbit_cursor: false,
space_mouse_enabled: true,
space_mouse_speed: 1.0,
msaa: 4,
length_decimals: 2,
angle_decimals: 1,
@@ -175,6 +181,8 @@ fn defaults(app: &mut SolveApp, page: usize) {
p.zoom_reverse = d.zoom_reverse;
p.zoom_to_cursor = d.zoom_to_cursor;
p.orbit_cursor = d.orbit_cursor;
p.space_mouse_enabled = d.space_mouse_enabled;
p.space_mouse_speed = d.space_mouse_speed;
p.look_at_anim = d.look_at_anim;
p.look_at_speed = d.look_at_speed;
}
@@ -577,6 +585,16 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
egui::Slider::new(&mut app.preferences.look_at_speed, 0.5..=3.0).step_by(0.25).custom_formatter(|v, _| format!("{v:.2}×")),
);
ui.end_row();
ui.label("3D mouse (Windows)");
ui.checkbox(&mut app.preferences.space_mouse_enabled, "Navigate with SpaceNavigator / SpaceMouse");
ui.end_row();
ui.label("3D mouse speed");
ui.add_enabled(
app.preferences.space_mouse_enabled,
egui::Slider::new(&mut app.preferences.space_mouse_speed, 0.25..=3.0).step_by(0.05).custom_formatter(|v, _| format!("{v:.2}×")),
);
ui.end_row();
note(ui, "Move and rotate the view together; sketches use pan and zoom only.");
}
PAGE_DESIGN => {
ui.label("Default modelling orientation");
+2 -2
View File
@@ -671,7 +671,7 @@ fn pick_cached(app: &mut SolveApp, proj: &Proj, pos: Pos2) -> Vec<Hit> {
let mut h = std::collections::hash_map::DefaultHasher::new();
pos.x.to_bits().hash(&mut h);
pos.y.to_bits().hash(&mut h);
for v in [app.cam.yaw, app.cam.pitch, app.cam.distance, app.cam.target.x, app.cam.target.y, app.cam.target.z] {
for v in [app.cam.yaw, app.cam.pitch, app.cam.roll, app.cam.distance, app.cam.target.x, app.cam.target.y, app.cam.target.z] {
v.to_bits().hash(&mut h);
}
app.session.revision.hash(&mut h);
@@ -2241,7 +2241,7 @@ fn cpu_render(app: &mut SolveApp, ctx: &egui::Context, painter: &egui::Painter,
}
let mut h = std::collections::hash_map::DefaultHasher::new();
sketch_scene_key(app, proj).hash(&mut h);
for v in [proj.cam.yaw, proj.cam.pitch, proj.cam.distance, proj.cam.target.x, proj.cam.target.y, proj.cam.target.z] {
for v in [proj.cam.yaw, proj.cam.pitch, proj.cam.roll, proj.cam.distance, proj.cam.target.x, proj.cam.target.y, proj.cam.target.z] {
v.to_bits().hash(&mut h);
}
(rect.width() as u32, rect.height() as u32).hash(&mut h);