Web app: 3D mouse navigation through the Gamepad API (#1473)
The web app reads a 3D mouse through the Gamepad API and drives the camera through navigation::device, so it follows each page's navigation capabilities (#1336). Co-authored-by: CamWight <CamWight@users.noreply.github.com> Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
18 files changed
+922
-243
No files matched your search
@@ -149,6 +149,7 @@ pub mod sketch_tools;
|
||||
pub mod smart_extrude;
|
||||
#[cfg(test)]
|
||||
mod smart_extrude_ui_tests;
|
||||
pub mod space_mouse;
|
||||
pub mod stand_up;
|
||||
#[cfg(test)]
|
||||
mod sweep_dialog_tests;
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
//! One entry point, [`handle`], reads a frame's pointer, wheel, trackpad and modifier input over a
|
||||
//! view and moves `app.cam` the way Preferences › Navigation says: the navigation preset (Fusion,
|
||||
//! SolidWorks, Blender, Touchpad…), Orbit around, zoom to the cursor, the middle double-click and
|
||||
//! orbit snapping. The SpaceMouse, view keys and the navigators act on `app.cam` independently of
|
||||
//! the page. New canvases must call `navigation::handle` rather than reading drags or the wheel
|
||||
//! themselves; `hand_rolled_camera_input_is_rejected` fails when they do.
|
||||
//! orbit snapping. The 3D mouse's motion goes through [`device`] too, with the page's
|
||||
//! [`NavCaps`]. The view keys and the navigators act on `app.cam` independently of the page. New
|
||||
//! canvases must call `navigation::handle` rather than reading drags or the wheel themselves;
|
||||
//! `hand_rolled_camera_input_is_rejected` fails when they do.
|
||||
|
||||
use egui::{Pos2, Rect, pos2, vec2};
|
||||
use serde_json::json;
|
||||
@@ -16,6 +17,7 @@ use solidcraft_engine::render::Camera;
|
||||
|
||||
use crate::SolveApp;
|
||||
use crate::prefs::{OrbitAround, pivot_under};
|
||||
use crate::space_mouse::Motion;
|
||||
use crate::viewport::{Hit, NavMode, axis_rect, nearest_quarter_turn, pick, pick_cached, projection};
|
||||
|
||||
/// What a page's camera may do.
|
||||
@@ -244,6 +246,8 @@ pub(crate) fn handle(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect, resp: &e
|
||||
// Ctrl or Alt held: drawing and dragging don't snap.
|
||||
crate::drag_snap::set_suppressed(mods.ctrl || mods.alt || mods.command);
|
||||
middle_double_click(app, ui, inside);
|
||||
// The 3D mouse's motion this frame, with what this page's camera may do.
|
||||
device(app, rect, caps);
|
||||
|
||||
// ---- navigation ----
|
||||
let trackpad_pan = app.preferences.trackpad_pans();
|
||||
@@ -464,6 +468,40 @@ pub(crate) fn handle(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect, resp: &e
|
||||
CameraInput { hover, scroll, mods, middle, secondary_down, primary_down, primary_pressed, delta, inside, axes, pinch, pan, primary_nav, mode }
|
||||
}
|
||||
|
||||
/// The 3D mouse's motion this frame (#1336), on a page `rect` whose camera may do `caps`: the cap
|
||||
/// pans and zooms every page, and turns the view only where the page may orbit and no sketch is
|
||||
/// being edited (a sketch keeps its view square to it). [`handle`] calls it for its page; the 3D
|
||||
/// mouse only reads the device and queues the motion ([`crate::space_mouse::navigate_frame`]).
|
||||
pub(crate) fn device(app: &mut SolveApp, rect: Rect, caps: NavCaps) {
|
||||
let Some(queued) = app.viewport.device_motion.take() else { return };
|
||||
let turn = caps.orbit && app.session.active_sketch.is_none();
|
||||
let height = f64::from(rect.height().max(1.0));
|
||||
device_camera(&mut app.cam, queued.motion, queued.seconds, height, turn, app.preferences.zoom_reverse);
|
||||
}
|
||||
|
||||
/// `seconds` of 3D mouse `motion` on `cam`, in a view `height` points tall. A full deflection
|
||||
/// pans about 0.6 of the view's height a second and turns about two radians a second, whatever
|
||||
/// the frame rate. With `turn` off the view keeps its orientation.
|
||||
pub(crate) fn device_camera(cam: &mut Camera, motion: Motion, seconds: f64, height: f64, turn: bool, reverse_zoom: bool) {
|
||||
if !(seconds > 0.0) || !seconds.is_finite() {
|
||||
return;
|
||||
}
|
||||
let [x, y, z] = motion.translation;
|
||||
let [rx, ry, rz] = motion.rotation;
|
||||
// Logical 3DxWare coordinates are X = left/right, Y = zoom and Z = up/down. The user's
|
||||
// profile can swap the physical forward/back and up/down axes before they reach here.
|
||||
cam.pan(x * height * 0.6 * seconds, z * height * 0.6 * seconds, height);
|
||||
let zoom_sign = if reverse_zoom { -1.0 } else { 1.0 };
|
||||
cam.zoom_at((-y * zoom_sign * 1.5 * seconds).exp(), None);
|
||||
if turn {
|
||||
// These are logical 3DxWare coordinates after the profile mapping: Rx is Tilt, Ry is
|
||||
// Roll and Rz is Spin. The KMJ profile swaps physical Ry/Rz for the selected navigation
|
||||
// mode, so swapping them again here would make Roll behave like Spin.
|
||||
cam.orbit(ry * 250.0 * seconds, rx * 250.0 * seconds);
|
||||
cam.roll_by(rz * 2.0 * seconds);
|
||||
}
|
||||
}
|
||||
|
||||
/// Six principal views, twelve edge diagonals and eight isometric corner directions.
|
||||
pub(crate) fn snap_directions() -> impl Iterator<Item = Vec3> {
|
||||
[-1.0, 0.0, 1.0].into_iter().flat_map(|x| {
|
||||
@@ -637,15 +675,15 @@ pub(crate) fn orbit_about(app: &mut SolveApp, rect: egui::Rect, dx: f64, dy: f64
|
||||
mod tests {
|
||||
/// Every page with a camera navigates through [`super::handle`] (#1454). A page that reads
|
||||
/// the wheel or moves the view camera itself would drift from the user's navigation settings,
|
||||
/// so outside this module (and the SpaceMouse driver, a device rather than a page) the camera
|
||||
/// may only be moved through it, the view commands or the navigators.
|
||||
/// so outside this module the camera may only be moved through it, the view commands or the
|
||||
/// navigators. The 3D mouse in both apps hands its motion to [`super::device`].
|
||||
#[test]
|
||||
fn hand_rolled_camera_input_is_rejected() {
|
||||
const PATTERNS: [&str; 7] =
|
||||
["smooth_scroll_delta", "raw_scroll_delta", "zoom_delta()", "cam.orbit(", "cam.pan(", "cam.zoom_at(", "cam.roll_by("];
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let dirs = [root.join("src"), root.join("../../apps/solidcraft/src")];
|
||||
let allowed = ["navigation.rs", "space_mouse.rs"];
|
||||
let dirs = [root.join("src"), root.join("../../apps/solidcraft/src"), root.join("../../apps/solidcraft-web/src")];
|
||||
let allowed = ["navigation.rs"];
|
||||
let mut offenders = Vec::new();
|
||||
let mut scanned = 0;
|
||||
let mut stack: Vec<std::path::PathBuf> = dirs.iter().filter(|d| d.is_dir()).cloned().collect();
|
||||
@@ -661,7 +699,8 @@ mod tests {
|
||||
continue;
|
||||
}
|
||||
scanned += 1;
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
// A Windows checkout's CRLF lines, read as written, so the split below finds the tests.
|
||||
let text = std::fs::read_to_string(&path).unwrap().replace("\r\n", "\n");
|
||||
// In-file unit tests set cameras up directly; only the code above them counts.
|
||||
let code = text.split("\n#[cfg(test)]\nmod ").next().unwrap_or_default();
|
||||
for (n, line) in code.lines().enumerate() {
|
||||
@@ -739,4 +778,60 @@ mod tests {
|
||||
assert_ne!(app.cam.distance, before.distance, "{preset}: the wheel zooms");
|
||||
}
|
||||
}
|
||||
|
||||
/// The 3D mouse pans and zooms a flat page with the cap and never turns it, as the mouse
|
||||
/// does there; pages that orbit turn with it (#1336).
|
||||
#[test]
|
||||
fn the_3d_mouse_pans_and_zooms_a_flat_page_but_never_turns_it() {
|
||||
use crate::space_mouse::{DeviceMotion, Motion};
|
||||
let every_axis = DeviceMotion { motion: Motion { translation: [1.0, 1.0, 1.0], rotation: [1.0, 1.0, 1.0] }, seconds: 0.05 };
|
||||
let flat = super::NavCaps { orbit: false, pick: false };
|
||||
for (caps, turns) in [(flat, false), (super::NavCaps::MODEL, true), (super::NavCaps::RENDER, true)] {
|
||||
let mut s = solidcraft_engine::Session::default();
|
||||
s.execute("solid.box", &serde_json::json!({"length":40,"width":30,"height":20})).unwrap();
|
||||
let mut app = crate::SolveApp::new(s, crate::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
let before = app.cam;
|
||||
app.viewport.device_motion = Some(every_axis);
|
||||
let raw = egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))),
|
||||
time: Some(0.0),
|
||||
focused: true,
|
||||
..Default::default()
|
||||
};
|
||||
let mut out = ctx.run_ui(raw, |ui| {
|
||||
let rect = ui.max_rect();
|
||||
app.viewport.rect = Some(rect);
|
||||
let resp = ui.interact(rect, ui.id().with("page"), egui::Sense::click_and_drag());
|
||||
super::handle(&mut app, ui, rect, &resp, caps);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
assert!(app.viewport.device_motion.is_none(), "{caps:?}: the page takes the motion");
|
||||
assert_ne!(app.cam.target, before.target, "{caps:?}: the cap pans");
|
||||
assert!(app.cam.distance < before.distance, "{caps:?}: the cap zooms");
|
||||
let turned = (app.cam.yaw, app.cam.pitch, app.cam.roll) != (before.yaw, before.pitch, before.roll);
|
||||
assert_eq!(turned, turns, "{caps:?}: the cap turns the view only where the page orbits");
|
||||
}
|
||||
}
|
||||
|
||||
/// While a sketch is edited the cap pans and zooms, and the view stays square to the sketch.
|
||||
#[test]
|
||||
fn the_3d_mouse_never_turns_a_sketch_being_edited() {
|
||||
use crate::space_mouse::{DeviceMotion, Motion};
|
||||
let mut s = solidcraft_engine::Session::default();
|
||||
s.execute("sketch.create", &serde_json::json!({"plane": "XY"})).unwrap();
|
||||
let mut app = crate::SolveApp::new(s, crate::Services::default());
|
||||
assert!(app.session.active_sketch.is_some());
|
||||
let before = app.cam;
|
||||
app.viewport.device_motion = Some(DeviceMotion { motion: Motion { translation: [1.0; 3], rotation: [1.0; 3] }, seconds: 0.05 });
|
||||
let page = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0));
|
||||
super::device(&mut app, page, super::NavCaps::MODEL);
|
||||
assert_eq!((app.cam.yaw, app.cam.pitch, app.cam.roll), (before.yaw, before.pitch, before.roll));
|
||||
assert_ne!(app.cam.target, before.target);
|
||||
assert!(app.cam.distance < before.distance);
|
||||
// Nothing queued, nothing moves.
|
||||
let still = app.cam;
|
||||
super::device(&mut app, page, super::NavCaps::MODEL);
|
||||
assert_eq!(app.cam, still);
|
||||
}
|
||||
}
|
||||
@@ -1188,7 +1188,7 @@ 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(crate::i18n::tr_label("3D mouse (Windows)"));
|
||||
ui.label(crate::i18n::tr_label("3D mouse"));
|
||||
ui.checkbox(&mut app.preferences.space_mouse_enabled, crate::i18n::tr_label("Navigate with SpaceNavigator / SpaceMouse"));
|
||||
ui.end_row();
|
||||
ui.label(crate::i18n::tr_label("3D mouse speed"));
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
//! 3D mouse input, shared by the desktop and web apps. Each app reads the device its own way (the
|
||||
//! desktop through HID, the web app through the browser's Gamepad API) and hands its six raw axes
|
||||
//! to [`navigate_frame`] once per frame. This module maps them, applies the dead zone and drops
|
||||
//! 3DxWare's emulated wheel; the page showing the camera moves it (`navigation::device`), so the
|
||||
//! cap does what that page's camera may do.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::SolveApp;
|
||||
|
||||
/// What a 3Dconnexion device reports at full deflection, in raw units, on every axis.
|
||||
pub const FULL_SCALE: i16 = 350;
|
||||
|
||||
/// One reading of the six axes in the device's raw units: X right, Y towards the user, Z down,
|
||||
/// and the rotations about those axes (right-handed).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct Axes {
|
||||
pub translation: [i16; 3],
|
||||
pub rotation: [i16; 3],
|
||||
}
|
||||
|
||||
impl Axes {
|
||||
/// Some axis is past the dead zone: the cap is being moved.
|
||||
pub fn moving(self) -> bool {
|
||||
self.translation.iter().chain(&self.rotation).any(|&v| normalized(v) != 0.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Where one physical axis goes: its logical axis, and how.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct AxisRule {
|
||||
pub output: usize,
|
||||
pub enabled: bool,
|
||||
pub reversed: bool,
|
||||
pub scale: f64,
|
||||
}
|
||||
|
||||
/// The physical-to-logical axis mapping 3DxWare applies (the desktop reads it from the driver's
|
||||
/// profile; direct reads bypass the driver).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct DriverMapping {
|
||||
pub axes: [AxisRule; 6],
|
||||
pub overall_scale: f64,
|
||||
}
|
||||
|
||||
impl Default for DriverMapping {
|
||||
fn default() -> Self {
|
||||
Self { axes: std::array::from_fn(|output| AxisRule { output, enabled: true, reversed: false, scale: 1.0 }), overall_scale: 1.0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl DriverMapping {
|
||||
/// What 3DxWare applies by default in object mode (#1172): the physical Ry and Rz swapped and
|
||||
/// reversed, so a tilt left/right rolls and a twist spins.
|
||||
pub fn object_mode() -> Self {
|
||||
let mut mapping = Self::default();
|
||||
mapping.axes[4] = AxisRule { output: 5, enabled: true, reversed: true, scale: 1.0 };
|
||||
mapping.axes[5] = AxisRule { output: 4, enabled: true, reversed: true, scale: 1.0 };
|
||||
mapping
|
||||
}
|
||||
|
||||
pub fn apply(self, axes: Axes) -> Motion {
|
||||
let raw = [
|
||||
normalized(axes.translation[0]),
|
||||
normalized(axes.translation[1]),
|
||||
normalized(axes.translation[2]),
|
||||
normalized(axes.rotation[0]),
|
||||
normalized(axes.rotation[1]),
|
||||
normalized(axes.rotation[2]),
|
||||
];
|
||||
let mut logical = [0.0; 6];
|
||||
for (input, rule) in self.axes.iter().enumerate() {
|
||||
if rule.enabled {
|
||||
let sign = if rule.reversed { -1.0 } else { 1.0 };
|
||||
if let Some(slot) = logical.get_mut(rule.output) {
|
||||
*slot = raw[input] * sign * rule.scale * self.overall_scale;
|
||||
}
|
||||
}
|
||||
}
|
||||
Motion { translation: [logical[0], logical[1], logical[2]], rotation: [logical[3], logical[4], logical[5]] }
|
||||
}
|
||||
}
|
||||
|
||||
/// The logical axes after the mapping, each in -1..1.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct Motion {
|
||||
pub translation: [f64; 3],
|
||||
pub rotation: [f64; 3],
|
||||
}
|
||||
|
||||
/// One frame of 3D mouse motion, waiting for the page that shows the camera: the logical axes,
|
||||
/// and how many seconds of motion they stand for (the frame's time, scaled by the speed).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct DeviceMotion {
|
||||
pub motion: Motion,
|
||||
pub seconds: f64,
|
||||
}
|
||||
|
||||
/// Hands one frame of 3D mouse input, `seconds` after the previous frame, to the page showing
|
||||
/// the camera. Nothing happens with navigation switched off, on the start page or while a file
|
||||
/// picker is open. Returns whether there was motion to hand over; it then asks for the next frame
|
||||
/// itself.
|
||||
pub fn navigate_frame(app: &mut SolveApp, ctx: &egui::Context, mapping: DriverMapping, axes: Axes, seconds: f64) -> bool {
|
||||
if !app.preferences.space_mouse_enabled || app.home.open || app.file_picker_busy() || axes == Axes::default() {
|
||||
return false;
|
||||
}
|
||||
let speed = f64::from(app.preferences.space_mouse_speed);
|
||||
if !speed.is_finite() {
|
||||
return false;
|
||||
}
|
||||
app.begin_external_navigation();
|
||||
// A long gap (the first frame after a pause) moves as far as a 50 ms one.
|
||||
let step = if seconds.is_finite() { seconds.min(0.05) } else { 0.0 };
|
||||
app.viewport.device_motion = Some(DeviceMotion { motion: mapping.apply(axes), seconds: step * speed.clamp(0.1, 5.0) });
|
||||
ctx.request_repaint_after(Duration::from_millis(16));
|
||||
true
|
||||
}
|
||||
|
||||
/// A small dead zone avoids drift at rest. A full cap deflection takes roughly one viewport
|
||||
/// per second to pan and about two radians per second to turn, independent of frame rate.
|
||||
fn normalized(value: i16) -> f64 {
|
||||
let v = f64::from(value);
|
||||
if v.abs() <= 20.0 { 0.0 } else { v.signum() * ((v.abs() - 20.0) / 330.0).min(1.0) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Services;
|
||||
use solidcraft_engine::Session;
|
||||
use solidcraft_engine::render::Camera;
|
||||
|
||||
/// The camera math the page applies the motion with (`navigation::device`): `sketch` keeps
|
||||
/// the orientation, as editing a sketch does.
|
||||
fn navigate(cam: &mut Camera, motion: Motion, seconds: f64, height: f64, sketch: bool, reverse_zoom: bool) {
|
||||
crate::navigation::device_camera(cam, motion, seconds, height, !sketch, reverse_zoom);
|
||||
}
|
||||
|
||||
/// One frame: the 3D mouse hands its motion over, and the modelling viewport applies it.
|
||||
fn frame(app: &mut SolveApp, ctx: &egui::Context, mapping: DriverMapping, axes: Axes, seconds: f64) -> bool {
|
||||
let handed = navigate_frame(app, ctx, mapping, axes, seconds);
|
||||
let page = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0));
|
||||
crate::navigation::device(app, page, crate::navigation::NavCaps::MODEL);
|
||||
handed
|
||||
}
|
||||
|
||||
fn raw(axes: Axes) -> Motion {
|
||||
DriverMapping::default().apply(axes)
|
||||
}
|
||||
|
||||
fn app() -> SolveApp {
|
||||
let mut app = SolveApp::new(Session::default(), Services::default());
|
||||
app.home.open = false;
|
||||
app
|
||||
}
|
||||
|
||||
const PUSH: Axes = Axes { translation: [0, 350, 0], rotation: [0; 3] };
|
||||
|
||||
#[test]
|
||||
fn six_axes_move_the_camera_and_sketches_keep_their_orientation() {
|
||||
let mut cam = Camera::default();
|
||||
let before = cam;
|
||||
navigate(&mut cam, raw(Axes { translation: [350, 350, 350], rotation: [350, 350, 350] }), 0.02, 600.0, false, false);
|
||||
assert_ne!(cam.target, before.target);
|
||||
assert!(cam.distance < before.distance);
|
||||
assert_ne!(cam.yaw, before.yaw);
|
||||
assert_ne!(cam.pitch, before.pitch);
|
||||
assert_ne!(cam.roll, before.roll);
|
||||
let orientation = (cam.yaw, cam.pitch, cam.roll);
|
||||
navigate(&mut cam, raw(Axes { translation: [350, 0, 0], rotation: [350, 350, 350] }), 0.02, 600.0, true, false);
|
||||
assert_eq!((cam.yaw, cam.pitch, cam.roll), orientation);
|
||||
assert!(cam.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn individual_axes_have_physical_spacemouse_meaning() {
|
||||
let before = Camera::default();
|
||||
|
||||
let mut slide = before;
|
||||
navigate(&mut slide, raw(Axes { translation: [350, 0, 0], rotation: [0; 3] }), 0.02, 600.0, false, false);
|
||||
assert_ne!(slide.target, before.target);
|
||||
assert_eq!(slide.distance, before.distance);
|
||||
|
||||
let mut push = before;
|
||||
navigate(&mut push, raw(Axes { translation: [0, 350, 0], rotation: [0; 3] }), 0.02, 600.0, false, false);
|
||||
assert_eq!(push.target, before.target);
|
||||
assert!(push.distance < before.distance);
|
||||
|
||||
let mut lift = before;
|
||||
navigate(&mut lift, raw(Axes { translation: [0, 0, 350], rotation: [0; 3] }), 0.02, 600.0, false, false);
|
||||
assert_ne!(lift.target, before.target);
|
||||
assert_eq!(lift.distance, before.distance);
|
||||
|
||||
let mut tilt = before;
|
||||
navigate(&mut tilt, raw(Axes { translation: [0; 3], rotation: [350, 0, 0] }), 0.02, 600.0, false, false);
|
||||
assert_eq!((tilt.yaw, tilt.roll), (before.yaw, before.roll));
|
||||
assert_ne!(tilt.pitch, before.pitch);
|
||||
|
||||
let mut roll = before;
|
||||
navigate(&mut roll, raw(Axes { translation: [0; 3], rotation: [0, 350, 0] }), 0.02, 600.0, false, false);
|
||||
assert_eq!((roll.pitch, roll.roll), (before.pitch, before.roll));
|
||||
assert_ne!(roll.yaw, before.yaw);
|
||||
|
||||
let mut twist = before;
|
||||
navigate(&mut twist, raw(Axes { translation: [0; 3], rotation: [0, 0, 350] }), 0.02, 600.0, false, false);
|
||||
assert_eq!((twist.yaw, twist.pitch), (before.yaw, before.pitch));
|
||||
assert_ne!(twist.roll, before.roll);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverse_zoom_setting_applies_to_spacemouse() {
|
||||
let before = Camera::default();
|
||||
let motion = raw(Axes { translation: [0, 350, 0], rotation: [0; 3] });
|
||||
let mut normal = before;
|
||||
navigate(&mut normal, motion, 0.02, 600.0, false, false);
|
||||
let mut reversed = before;
|
||||
navigate(&mut reversed, motion, 0.02, 600.0, false, true);
|
||||
assert!(normal.distance < before.distance);
|
||||
assert!(reversed.distance > before.distance);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_mode_tilts_roll_and_twists_spin() {
|
||||
let before = Camera::default();
|
||||
let mut tilt = before;
|
||||
navigate(&mut tilt, DriverMapping::object_mode().apply(Axes { translation: [0; 3], rotation: [0, 350, 0] }), 0.02, 600.0, false, false);
|
||||
assert_eq!((tilt.yaw, tilt.pitch), (before.yaw, before.pitch));
|
||||
assert_ne!(tilt.roll, before.roll);
|
||||
let mut twist = before;
|
||||
navigate(&mut twist, DriverMapping::object_mode().apply(Axes { translation: [0; 3], rotation: [0, 0, 350] }), 0.02, 600.0, false, false);
|
||||
assert_ne!(twist.yaw, before.yaw);
|
||||
assert_eq!((twist.pitch, twist.roll), (before.pitch, before.roll));
|
||||
// The physical tilt right (Ry−) rolls one way and the clockwise twist (Rz+) spins the
|
||||
// other, as 3DxWare's profile has them (#1172).
|
||||
let tilt_right = DriverMapping::object_mode().apply(Axes { translation: [0; 3], rotation: [0, -350, 0] });
|
||||
assert_eq!(tilt_right, Motion { translation: [0.0; 3], rotation: [0.0, 0.0, 1.0] });
|
||||
let twist_clockwise = DriverMapping::object_mode().apply(Axes { translation: [0; 3], rotation: [0, 0, 350] });
|
||||
assert_eq!(twist_clockwise, Motion { translation: [0.0; 3], rotation: [0.0, -1.0, 0.0] });
|
||||
// Translations and the forward tilt are the raw axes.
|
||||
let all = Axes { translation: [100, -200, 300], rotation: [-150, 0, 0] };
|
||||
assert_eq!(DriverMapping::object_mode().apply(all), DriverMapping::default().apply(all));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_dead_zone_is_rest() {
|
||||
assert!(!Axes::default().moving());
|
||||
assert!(!Axes { translation: [20, -20, 0], rotation: [0, 20, -20] }.moving());
|
||||
for i in 0..6 {
|
||||
let mut values = [0_i16; 6];
|
||||
values[i] = 21;
|
||||
let axes = Axes { translation: [values[0], values[1], values[2]], rotation: [values[3], values[4], values[5]] };
|
||||
assert!(axes.moving(), "axis {i}");
|
||||
values[i] = -21;
|
||||
let axes = Axes { translation: [values[0], values[1], values[2]], rotation: [values[3], values[4], values[5]] };
|
||||
assert!(axes.moving(), "axis {i} reversed");
|
||||
}
|
||||
assert!(Axes { translation: [i16::MIN, 0, 0], rotation: [0, 0, i16::MAX] }.moving());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mapping_never_reaches_past_the_six_axes() {
|
||||
let mut mapping = DriverMapping::default();
|
||||
mapping.axes[0].output = 6;
|
||||
mapping.axes[1].output = usize::MAX;
|
||||
let motion = mapping.apply(Axes { translation: [350, 350, 350], rotation: [350; 3] });
|
||||
assert_eq!(motion.translation, [0.0, 0.0, 1.0]);
|
||||
assert_eq!(motion.rotation, [1.0; 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_frame_moves_the_view_and_keeps_the_next_frame_coming() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app();
|
||||
let before = app.cam;
|
||||
assert!(frame(&mut app, &ctx, DriverMapping::default(), PUSH, 0.02));
|
||||
assert!(app.cam.distance < before.distance);
|
||||
// At rest, or with no time passed, nothing moves.
|
||||
let still = app.cam;
|
||||
assert!(!frame(&mut app, &ctx, DriverMapping::default(), Axes::default(), 0.02));
|
||||
assert!(frame(&mut app, &ctx, DriverMapping::default(), PUSH, 0.0));
|
||||
assert_eq!(app.cam.distance, still.distance);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_open_file_picker_holds_the_view_until_it_closes() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app();
|
||||
let slot: crate::picker_test_support::Slot = Default::default();
|
||||
crate::picker_test_support::open(&mut app, &slot);
|
||||
app.pick_open(|_, _| {});
|
||||
assert!(app.file_picker_busy());
|
||||
let before = app.cam;
|
||||
assert!(!frame(&mut app, &ctx, DriverMapping::default(), PUSH, 0.02));
|
||||
assert_eq!(app.cam.distance, before.distance);
|
||||
// Cancelled: the 3D mouse moves the view again.
|
||||
*slot.borrow_mut() = Some(None);
|
||||
crate::file_picker::tick(&mut app, &ctx);
|
||||
assert!(!app.file_picker_busy());
|
||||
assert!(frame(&mut app, &ctx, DriverMapping::default(), PUSH, 0.02));
|
||||
assert!(app.cam.distance < before.distance);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_gap_between_frames_moves_as_far_as_a_short_one() {
|
||||
let ctx = egui::Context::default();
|
||||
let (mut short, mut long) = (app(), app());
|
||||
frame(&mut short, &ctx, DriverMapping::default(), PUSH, 0.05);
|
||||
frame(&mut long, &ctx, DriverMapping::default(), PUSH, 30.0);
|
||||
assert_eq!(short.cam.distance, long.cam.distance);
|
||||
for seconds in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0] {
|
||||
let mut app = app();
|
||||
let before = app.cam;
|
||||
frame(&mut app, &ctx, DriverMapping::default(), PUSH, seconds);
|
||||
assert_eq!(app.cam.distance, before.distance, "{seconds}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_off_the_start_page_and_a_hostile_speed_leave_the_view_alone() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut off = app();
|
||||
off.preferences.space_mouse_enabled = false;
|
||||
let mut home = app();
|
||||
home.home.open = true;
|
||||
let mut nan = app();
|
||||
nan.preferences.space_mouse_speed = f32::NAN;
|
||||
for mut app in [off, home, nan] {
|
||||
let before = app.cam;
|
||||
assert!(!frame(&mut app, &ctx, DriverMapping::default(), PUSH, 0.02));
|
||||
assert_eq!(app.cam.distance, before.distance);
|
||||
}
|
||||
// Speed follows the preference, within 0.1 to 5 times.
|
||||
let speeds = [0.5, 2.0, 5.0, f32::MAX, 0.25, 0.0, -3.0];
|
||||
let distances: Vec<f64> = speeds
|
||||
.iter()
|
||||
.map(|&speed| {
|
||||
let mut app = app();
|
||||
app.preferences.space_mouse_speed = speed;
|
||||
frame(&mut app, &ctx, DriverMapping::default(), PUSH, 0.02);
|
||||
assert!(app.cam.is_valid(), "{speed}");
|
||||
app.cam.distance
|
||||
})
|
||||
.collect();
|
||||
assert!(distances[1] < distances[0]);
|
||||
assert_eq!(distances[3], distances[2], "past 5 times is 5 times");
|
||||
// Under 0.1 times is 0.1 times (the f32 preference can't hold 0.1 exactly): still a
|
||||
// motion, less than at 0.25.
|
||||
assert_eq!(distances[6], distances[5], "under 0.1 times is 0.1 times");
|
||||
assert!(distances[4] < distances[5] && distances[5] < app().cam.distance);
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,9 @@ pub struct ViewportState {
|
||||
pub orbit_pivot: Option<Vec3>,
|
||||
/// Continuous orbit: the view turns about the vertical until the next click or key.
|
||||
pub spin: bool,
|
||||
/// The 3D mouse's motion this frame, for the page showing the camera to apply with what its
|
||||
/// camera may do (`navigation::device`).
|
||||
pub device_motion: Option<crate::space_mouse::DeviceMotion>,
|
||||
/// After immediate zoom, ignore egui's outstanding smoothing until the gesture ends.
|
||||
pub(crate) wheel_zoom_raw_until_idle: bool,
|
||||
/// While trackpad scrolling runs, ignore egui's smoothing tail for zoom so
|
||||
|
||||
Reference in new issue
Block a user