Merge pull request #189: add Windows SpaceMouse navigation
This commit is contained in:
commit
8cce89d78a
11 files changed
+353
-23
No files matched your search
@@ -12,3 +12,6 @@ workspace = true
|
||||
solvecraft-geom = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
image = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
+45
-10
@@ -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() };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user