Add Touchpad navigation: Shift to pan, Alt/Option to orbit (#1220)
An opt-in Touchpad navigation preset: Shift + pointer motion pans, Alt/Option + motion orbits, vertical scroll zooms, all without a mouse button. Existing presets are unchanged; view.navigate gains continuous motion for the control channel and scenarios.
This commit is contained in:
1 parent
b4edf05373
commit
bd8db8ea1c
17 files changed
+717
-60
No files matched your search
@@ -3,13 +3,14 @@ use serde_json::{Value, json};
|
||||
use solidcraft_render::{Camera, StandardView};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, vec3};
|
||||
use crate::params::{bad, num, req_vec2, req_vec3, vec3};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("view.navigate", "Navigate View", navigate)
|
||||
.icon("orbit")
|
||||
.noundo()
|
||||
.params("camera: Camera; direction?: [x,y,z] in the camera's up-axis frame; or action: left|right|up|down|clockwise|counterclockwise|home|fit → camera, fit")];
|
||||
.doc_only()
|
||||
.params("camera: Camera; direction?: [x,y,z] in the camera's up-axis frame; or action: left|right|up|down|clockwise|counterclockwise|home|fit; or motion: pan|orbit|zoom, delta?: [dx,dy] (pixels, each ±10000), viewport?: [width,height] (1..1000000, required for pan or pivot), pivot?: [x,y,z], factor?: 0.05..20 (required for zoom), anchor?: [x,y,z] → camera, fit, continuous?")];
|
||||
|
||||
fn navigate(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "view.navigate";
|
||||
@@ -24,6 +25,9 @@ fn navigate(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
{
|
||||
return Err(bad(cmd, "invalid camera"));
|
||||
}
|
||||
if p.get("motion").is_some() {
|
||||
return continuous(cam, p);
|
||||
}
|
||||
let mut fit = false;
|
||||
if let Some(value) = p.get("direction") {
|
||||
let dir = vec3(value)
|
||||
@@ -56,6 +60,53 @@ fn navigate(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"camera": cam, "fit": fit}))
|
||||
}
|
||||
|
||||
/// Direct manipulation is stateless, bounded and independent of document evaluation.
|
||||
fn continuous(mut cam: Camera, p: &Value) -> Result<Value> {
|
||||
let cmd = "view.navigate";
|
||||
let viewport = || {
|
||||
let size = req_vec2(cmd, p, "viewport")?;
|
||||
if !(1.0..=1e6).contains(&size.x) || !(1.0..=1e6).contains(&size.y) {
|
||||
return Err(bad(cmd, "viewport dimensions must be in 1..1000000"));
|
||||
}
|
||||
Ok(size)
|
||||
};
|
||||
match p.get("motion").and_then(Value::as_str) {
|
||||
Some(motion @ ("pan" | "orbit")) => {
|
||||
let delta = req_vec2(cmd, p, "delta")?;
|
||||
if delta.x.abs() > 1e4 || delta.y.abs() > 1e4 {
|
||||
return Err(bad(cmd, "motion deltas must be within ±10000 pixels"));
|
||||
}
|
||||
if motion == "pan" {
|
||||
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);
|
||||
}
|
||||
} else {
|
||||
cam.orbit(delta.x, delta.y);
|
||||
}
|
||||
}
|
||||
Some("zoom") => {
|
||||
let factor = num(p, "factor").filter(|f| (0.05..=20.0).contains(f)).ok_or_else(|| bad(cmd, "factor must be in 0.05..20"))?;
|
||||
let anchor = p.get("anchor").map(|_| req_vec3(cmd, p, "anchor")).transpose()?;
|
||||
cam.zoom_at(factor, anchor);
|
||||
}
|
||||
_ => return Err(bad(cmd, "unknown navigation motion")),
|
||||
}
|
||||
if !cam.is_valid() {
|
||||
return Err(bad(cmd, "motion produced an invalid camera"));
|
||||
}
|
||||
Ok(json!({"camera":cam, "fit":false, "continuous":true}))
|
||||
}
|
||||
|
||||
/// At a halfway tie prefer the heading nearer the front plan view.
|
||||
pub(crate) fn square_heading(yaw: f64) -> f64 {
|
||||
if !yaw.is_finite() {
|
||||
@@ -80,6 +131,77 @@ mod tests {
|
||||
use solidcraft_geom::Vec3;
|
||||
use solidcraft_render::UpAxis;
|
||||
|
||||
#[test]
|
||||
fn continuous_navigation_moves_the_camera_without_editing_the_design() {
|
||||
let mut s = Session::default();
|
||||
let before = s.doc.to_json();
|
||||
let cam = Camera { distance: 100.0, ..Default::default() };
|
||||
for (motion, extra) in
|
||||
[("pan", json!({"delta":[30,20],"viewport":[800,600]})), ("orbit", json!({"delta":[30,20]})), ("zoom", json!({"factor":0.5}))]
|
||||
{
|
||||
let mut params = extra;
|
||||
params["camera"] = json!(cam);
|
||||
params["motion"] = json!(motion);
|
||||
let result = s.execute("view.navigate", ¶ms).unwrap();
|
||||
let to: Camera = serde_json::from_value(result["camera"].clone()).unwrap();
|
||||
assert_eq!(result["continuous"], true);
|
||||
match motion {
|
||||
"pan" => {
|
||||
assert_ne!(to.target, cam.target);
|
||||
assert_eq!((to.yaw, to.pitch, to.distance), (cam.yaw, cam.pitch, cam.distance));
|
||||
}
|
||||
"orbit" => {
|
||||
assert!((to.yaw - cam.yaw - 0.24).abs() < 1e-9);
|
||||
assert!((to.pitch - cam.pitch - 0.16).abs() < 1e-9);
|
||||
assert_eq!((to.target, to.distance), (cam.target, cam.distance));
|
||||
}
|
||||
_ => assert_eq!(to.distance, 50.0),
|
||||
}
|
||||
}
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert!(s.undo.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continuous_perspective_orbit_keeps_near_and_far_pivots_on_screen() {
|
||||
let mut s = Session::default();
|
||||
let cam = Camera { yaw: 0.0, pitch: 0.0, distance: 100.0, fov: 0.6, ..Default::default() };
|
||||
for pivot in [Vec3::new(15.0, -50.0, 0.0), Vec3::new(15.0, 50.0, 0.0)] {
|
||||
let result =
|
||||
s.execute("view.navigate", &json!({"camera":cam,"motion":"orbit","delta":[20,10],"viewport":[800,600],"pivot":pivot})).unwrap();
|
||||
let to: Camera = serde_json::from_value(result["camera"].clone()).unwrap();
|
||||
let (ax, ay, _) = cam.to_screen(pivot, 800.0, 600.0, 200.0).unwrap();
|
||||
let (bx, by, _) = to.to_screen(pivot, 800.0, 600.0, 200.0).unwrap();
|
||||
assert!((ax - bx).abs() < 1e-6 && (ay - by).abs() < 1e-6, "pivot {pivot:?} drifted from ({ax},{ay}) to ({bx},{by})");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continuous_orbit_keeps_its_pivot_on_screen_and_rejects_hostile_motion() {
|
||||
let mut s = Session::default();
|
||||
let cam = Camera { distance: 100.0, ..Default::default() };
|
||||
let pivot = Vec3::new(12.0, 5.0, 0.0);
|
||||
let result = s.execute("view.navigate", &json!({"camera":cam,"motion":"orbit","delta":[20,10],"viewport":[800,600],"pivot":pivot})).unwrap();
|
||||
let to: Camera = serde_json::from_value(result["camera"].clone()).unwrap();
|
||||
let (ax, ay, _) = cam.to_screen(pivot, 800.0, 600.0, 1.0).unwrap();
|
||||
let (bx, by, _) = to.to_screen(pivot, 800.0, 600.0, 1.0).unwrap();
|
||||
assert!((ax - bx).abs() < 1e-6 && (ay - by).abs() < 1e-6);
|
||||
for extra in [
|
||||
json!({"motion":"pan","delta":[1,2],"viewport":[800,0]}),
|
||||
json!({"motion":"pan","delta":[1e100,2],"viewport":[800,600]}),
|
||||
json!({"motion":"orbit","delta":[1]}),
|
||||
json!({"motion":"orbit","delta":[1,2],"pivot":[0,0,0]}),
|
||||
json!({"motion":"zoom","factor":0}),
|
||||
json!({"motion":"zoom","factor":1e100}),
|
||||
json!({"motion":"zoom","factor":1,"anchor":[1e100,0,0]}),
|
||||
json!({"motion":"unknown"}),
|
||||
] {
|
||||
let mut p = extra;
|
||||
p["camera"] = json!(cam);
|
||||
assert!(s.execute("view.navigate", &p).is_err(), "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_cube_direction_preserves_framing_without_editing_the_design() {
|
||||
for up in [UpAxis::Z, UpAxis::Y] {
|
||||
|
||||
Reference in new issue
Block a user