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
@@ -59,6 +59,11 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
| M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Per-row body materials and appearances (library looks, custom colour/opacity and clearing overrides), undoable activation and save/reopen: done. Ordered typed configuration rules, atomic validation and structured rule editing: done. Component appearance and certified semantic face style columns, atomic preview/apply and independent-copy remapping: done. Exact constructed sketch-side and immutable imported-face lineage now preserve legacy names and reject retargeting. Left: ambiguous shifted/two-sided caps | in progress | 4 |
|
||||
| M13 | Never lose work: atomic saves (temporary file, flush, rename; a crash at any moment leaves the old file or the new one) with the replaced version kept as `<file>.bak`; autosave of unsaved changes to the recovery folder every 5 minutes (preference) and after big operations, only when something changed; "Recover unsaved design?" on the next launch (`doc.recovery_list`, `doc.recover`, `doc.recovery_discard`); a kill-mid-save test (process killed while saving a 0.5 MB design 25 times). File format: `"format": "solidcraft/12"`, older formats upgraded step by step (`doc::format`), newer ones refused with a message, out-of-range numbers and broken placements refused; a corpus of designs saved by M0, components-era, joints-era and current builds (apps/solidcraft-cli/tests/corpus) that must open and evaluate as saved; damaged and hostile variants (truncation, bit flips, hostile values, removed keys, repeated features, deep nesting) never panic | done | 6 |
|
||||
|
||||
M6 navigation preferences include **Touchpad**: Shift + pointer movement pans, Alt/Option +
|
||||
pointer movement orbits without a mouse button, and vertical scrolling zooms. Other presets
|
||||
retain their saved two-finger scroll preference; camera motion is available through
|
||||
`view.navigate` and modifier-aware `ui.move` automation.
|
||||
|
||||
## Physical material data
|
||||
|
||||
- Physical material card validation (#923/#922) provides typed SI mechanical and
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
"1. Open GitHub draft below. Your title and report will be filled in.": "1. Unten den GitHub-Entwurf öffnen. Titel und Bericht werden vorausgefüllt.",
|
||||
"2. Attach your screenshot: copy and paste it, or save and upload the PNG.": "2. Screenshot anhängen: kopieren und einfügen oder als PNG speichern und hochladen.",
|
||||
"3. Click Submit new issue on GitHub to send your report.": "3. Auf GitHub auf Submit new issue klicken, um den Bericht zu senden.",
|
||||
"nav.touchpad.label": "Touchpad",
|
||||
"nav.touchpad.help": "Umschalt gedrückt halten und den Zeiger zum Verschieben bewegen; mit Alt/Option drehen. Keine Maustaste nötig. Vertikales Scrollen zoomt.",
|
||||
"nav.touchpad.orbit": "Drehen (Alt/Option halten und bewegen; ohne Maustaste)",
|
||||
"nav.touchpad.pan": "Verschieben (Umschalt halten und bewegen; ohne Maustaste)",
|
||||
"nav.touchpad.zoom": "Zoom (vertikal scrollen oder Finger spreizen/zusammenziehen)",
|
||||
"nav.touchpad.scroll": "Touchpad verwendet vertikales Scrollen zum Zoomen. Die Zwei-Finger-Verschiebung bleibt für andere Vorgaben gespeichert.",
|
||||
"browser.body_name": "Körper {number}",
|
||||
"browser.component_name": "Komponente {number}",
|
||||
"browser.parameters": "Parameter ({count})",
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
"1. Open GitHub draft below. Your title and report will be filled in.": "1. Open GitHub draft below. Your title and report will be filled in.",
|
||||
"2. Attach your screenshot: copy and paste it, or save and upload the PNG.": "2. Attach your screenshot: copy and paste it, or save and upload the PNG.",
|
||||
"3. Click Submit new issue on GitHub to send your report.": "3. Click Submit new issue on GitHub to send your report.",
|
||||
"nav.touchpad.label": "Touchpad",
|
||||
"nav.touchpad.help": "Hold Shift and move the pointer to pan; hold Alt/Option and move it to orbit. No mouse button is needed. Vertical scroll zooms.",
|
||||
"nav.touchpad.orbit": "Orbit (hold Alt/Option and move; no mouse button)",
|
||||
"nav.touchpad.pan": "Pan (hold Shift and move; no mouse button)",
|
||||
"nav.touchpad.zoom": "Zoom (vertical scroll or pinch)",
|
||||
"nav.touchpad.scroll": "Touchpad uses vertical scroll to zoom. Your two-finger pan setting is kept for other presets.",
|
||||
"browser.body_name": "Body{number}",
|
||||
"browser.component_name": "Component{number}",
|
||||
"browser.parameters": "Parameters ({count})",
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
"1. Open GitHub draft below. Your title and report will be filled in.": "1. 下のボタンでGitHubの下書きを開きます。タイトルとレポートは入力済みです。",
|
||||
"2. Attach your screenshot: copy and paste it, or save and upload the PNG.": "2. スクリーンショットを添付します。コピーして貼り付けるか、PNGを保存してアップロードします。",
|
||||
"3. Click Submit new issue on GitHub to send your report.": "3. GitHubでSubmit new issueをクリックしてレポートを送信します。",
|
||||
"nav.touchpad.label": "タッチパッド",
|
||||
"nav.touchpad.help": "Shift を押しながらポインターを動かすとパン、Alt/Option を押しながら動かすと回転します。マウスボタンは不要です。縦スクロールでズームします。",
|
||||
"nav.touchpad.orbit": "回転(Alt/Option を押しながら移動、マウスボタン不要)",
|
||||
"nav.touchpad.pan": "パン(Shift を押しながら移動、マウスボタン不要)",
|
||||
"nav.touchpad.zoom": "ズーム(縦スクロールまたはピンチ)",
|
||||
"nav.touchpad.scroll": "タッチパッドでは縦スクロールでズームします。2 本指パンの設定は他のプリセット用に保持されます。",
|
||||
"browser.body_name": "ボディ{number}",
|
||||
"browser.component_name": "コンポーネント{number}",
|
||||
"browser.parameters": "パラメータ ({count})",
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
"1. Open GitHub draft below. Your title and report will be filled in.": "1. 아래에서 GitHub 초안을 여세요. 제목과 보고서가 자동으로 입력됩니다.",
|
||||
"2. Attach your screenshot: copy and paste it, or save and upload the PNG.": "2. 스크린샷을 첨부하세요. 복사하여 붙여넣거나 PNG로 저장한 뒤 업로드하세요.",
|
||||
"3. Click Submit new issue on GitHub to send your report.": "3. GitHub에서 Submit new issue를 클릭하여 보고서를 보내세요.",
|
||||
"nav.touchpad.label": "터치패드",
|
||||
"nav.touchpad.help": "Shift를 누른 채 포인터를 움직이면 화면이 이동하고, Alt/Option을 누른 채 움직이면 회전합니다. 마우스 버튼은 필요 없습니다. 세로 스크롤로 확대/축소합니다.",
|
||||
"nav.touchpad.orbit": "회전 (Alt/Option을 누르고 이동, 마우스 버튼 불필요)",
|
||||
"nav.touchpad.pan": "화면 이동 (Shift를 누르고 이동, 마우스 버튼 불필요)",
|
||||
"nav.touchpad.zoom": "확대/축소 (세로 스크롤 또는 핀치)",
|
||||
"nav.touchpad.scroll": "터치패드는 세로 스크롤로 확대/축소합니다. 두 손가락 이동 설정은 다른 프리셋을 위해 유지됩니다.",
|
||||
"browser.body_name": "바디{number}",
|
||||
"browser.component_name": "컴포넌트{number}",
|
||||
"browser.parameters": "매개변수 ({count})",
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
"1. Open GitHub draft below. Your title and report will be filled in.": "1. 点击下方按钮打开 GitHub 草稿。标题和报告将自动填入。",
|
||||
"2. Attach your screenshot: copy and paste it, or save and upload the PNG.": "2. 附上截图:复制并粘贴,或保存为 PNG 后上传。",
|
||||
"3. Click Submit new issue on GitHub to send your report.": "3. 在 GitHub 上点击 Submit new issue 发送报告。",
|
||||
"nav.touchpad.label": "触控板",
|
||||
"nav.touchpad.help": "按住 Shift 并移动指针可平移;按住 Alt/Option 并移动可旋转。无需按鼠标按钮。垂直滚动可缩放。",
|
||||
"nav.touchpad.orbit": "旋转(按住 Alt/Option 并移动,无需鼠标按钮)",
|
||||
"nav.touchpad.pan": "平移(按住 Shift 并移动,无需鼠标按钮)",
|
||||
"nav.touchpad.zoom": "缩放(垂直滚动或双指捏合)",
|
||||
"nav.touchpad.scroll": "触控板使用垂直滚动进行缩放。双指平移设置将保留供其他预设使用。",
|
||||
"browser.body_name": "实体{number}",
|
||||
"browser.component_name": "组件{number}",
|
||||
"browser.parameters": "参数 ({count})",
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -197,6 +197,13 @@ fn edge_bisector(m: &Mesh, index: usize, p: Vec3) -> Option<Vec3> {
|
||||
sum.normalized()
|
||||
}
|
||||
|
||||
/// Whether a dynamically registered canvas handle owns this point before navigation.
|
||||
pub(crate) fn owns_pointer(app: &SolveApp, proj: &Proj, p: Pos2) -> bool {
|
||||
// The triad only registers an egui hit widget after a handle is under the pointer.
|
||||
// Test its geometry before navigation, including the first frame entering a handle.
|
||||
app.dialog.as_ref().and_then(|d| crate::dialogs_move::triad(app, d)).is_some_and(|t| crate::gizmo::contains_pointer(proj, &t, p))
|
||||
}
|
||||
|
||||
/// Draw the manipulator and the value box (called by the viewport after the model is drawn).
|
||||
pub fn show(app: &mut SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj) {
|
||||
// While OK computes, the inputs hold what it computes: no arrow to drag, no box to type in.
|
||||
|
||||
@@ -340,7 +340,16 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) ->
|
||||
}
|
||||
"ui.move" | "ui.click" => {
|
||||
let (Some(x), Some(y)) = (f("x"), f("y")) else { return err("missing x/y") };
|
||||
if x.abs() > 1e6 || y.abs() > 1e6 {
|
||||
return err("x/y must be within ±1000000 screen points");
|
||||
}
|
||||
let pos = egui::pos2(x as f32, y as f32);
|
||||
let modifiers = if method == "ui.move" {
|
||||
egui::Modifiers { shift: b("shift"), alt: b("alt"), ctrl: b("ctrl"), command: b("ctrl") || b("cmd"), mac_cmd: b("cmd") }
|
||||
} else {
|
||||
egui::Modifiers::NONE
|
||||
};
|
||||
app.synthetic.push(egui::Event::ModifiersChanged(modifiers));
|
||||
app.synthetic.push(egui::Event::PointerMoved(pos));
|
||||
if method == "ui.click" {
|
||||
let button = match s("button") {
|
||||
@@ -366,6 +375,8 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) ->
|
||||
};
|
||||
let modifiers = egui::Modifiers { shift: b("shift"), ctrl: b("ctrl"), command: b("ctrl"), alt: b("alt"), ..Default::default() };
|
||||
let (a, z) = (egui::pos2(x0 as f32, y0 as f32), egui::pos2(x1 as f32, y1 as f32));
|
||||
// Positioning precedes the press by a frame: it is not a modifier-only gesture.
|
||||
app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
|
||||
app.synthetic.push(egui::Event::PointerMoved(a));
|
||||
app.synthetic.push(egui::Event::PointerButton { pos: a, button, pressed: true, modifiers });
|
||||
// `hold`: frames to keep the button down before moving (press-and-hold).
|
||||
@@ -705,6 +716,92 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::Services;
|
||||
|
||||
#[test]
|
||||
fn touchpad_automation_moves_with_modifiers_and_releases_without_buttons() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.preferences.nav = "touchpad".into();
|
||||
h.call("ui.move", json!({"x":800,"y":500}));
|
||||
let before = h.app.cam;
|
||||
h.call("ui.move", json!({"x":800,"y":500,"shift":true}));
|
||||
h.step(&json!({"move":[830,520],"shift":true})).unwrap();
|
||||
assert_ne!(h.app.cam.target, before.target);
|
||||
assert_eq!(h.app.cam.yaw, before.yaw);
|
||||
assert!(!h.ctx.input(|i| i.pointer.any_down()));
|
||||
h.call("ui.move", json!({"x":830,"y":520,"alt":true}));
|
||||
h.step(&json!({"move":[850,530],"alt":true})).unwrap();
|
||||
assert!((h.app.cam.yaw - before.yaw - 0.16).abs() < 1e-6);
|
||||
let after = h.app.cam;
|
||||
h.call("ui.move", json!({"x":800,"y":500}));
|
||||
h.frames(3);
|
||||
assert_eq!(h.app.cam, after);
|
||||
assert!(h.ctx.input(|i| i.modifiers.is_none()));
|
||||
assert!(h.app.session.selection.is_empty());
|
||||
assert!(h.app.viewport.boxsel.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn touchpad_drag_positioning_releases_previous_motion_modifiers() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.preferences.nav = "touchpad".into();
|
||||
h.call("ui.move", json!({"x":800,"y":500}));
|
||||
for modifier in ["shift", "alt"] {
|
||||
let mut params = json!({"x":800,"y":500});
|
||||
params[modifier] = json!(true);
|
||||
h.call("ui.move", params);
|
||||
let before = h.app.cam;
|
||||
h.call("ui.drag", json!({"x0":900,"y0":500,"x1":930,"y1":520,"shift":true,"steps":2}));
|
||||
assert_eq!(h.app.cam, before, "positioning a selection drag must not inherit {modifier} motion");
|
||||
assert!(!h.ctx.input(|i| i.pointer.any_down()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn touchpad_text_prefix_does_not_collapse_queued_pointer_frames() {
|
||||
let mut app = SolveApp::new(Session::default(), Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
let (req, _) = ControlRequest::new("ui.text", json!({"text":"prefix"}));
|
||||
assert!(matches!(handle(&mut app, &ctx, &req), Outcome::Done(_)));
|
||||
for params in [json!({"x":10,"y":10,"shift":true}), json!({"x":20,"y":20,"alt":true}), json!({"x":30,"y":30})] {
|
||||
let (req, _) = ControlRequest::new("ui.move", params);
|
||||
assert!(matches!(handle(&mut app, &ctx, &req), Outcome::Done(_)));
|
||||
}
|
||||
for (p, shift, alt) in [(10.0, true, false), (20.0, false, true), (30.0, false, false)] {
|
||||
let mut raw = egui::RawInput::default();
|
||||
app.raw_input_hook(&mut raw);
|
||||
let mut out = ctx.run_ui(raw, |ui| {
|
||||
ui.input(|i| {
|
||||
assert_eq!(i.pointer.hover_pos(), Some(egui::pos2(p, p)));
|
||||
assert_eq!((i.modifiers.shift, i.modifiers.alt), (shift, alt));
|
||||
});
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
assert!(app.synthetic.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn touchpad_automation_preserves_each_queued_moves_modifiers() {
|
||||
let mut app = SolveApp::new(Session::default(), Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
for params in [json!({"x":10,"y":10,"shift":true}), json!({"x":20,"y":20,"alt":true}), json!({"x":30,"y":30})] {
|
||||
let (req, _) = ControlRequest::new("ui.move", params);
|
||||
assert!(matches!(handle(&mut app, &ctx, &req), Outcome::Done(_)));
|
||||
}
|
||||
for (p, shift, alt) in [(10.0, true, false), (20.0, false, true), (30.0, false, false)] {
|
||||
let mut raw = egui::RawInput::default();
|
||||
app.raw_input_hook(&mut raw);
|
||||
let mut out = ctx.run_ui(raw, |ui| {
|
||||
ui.input(|i| {
|
||||
assert_eq!(i.pointer.hover_pos(), Some(egui::pos2(p, p)));
|
||||
assert_eq!((i.modifiers.shift, i.modifiers.alt), (shift, alt));
|
||||
assert!(!i.pointer.any_down());
|
||||
});
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
assert!(app.synthetic.is_empty());
|
||||
}
|
||||
|
||||
fn render(app: &mut SolveApp, path: &std::path::Path) -> Vec<u8> {
|
||||
let (req, _rx) = ControlRequest::new("ui.render", json!({"path": path.to_string_lossy(), "width": 200, "height": 140}));
|
||||
match handle(app, &egui::Context::default(), &req) {
|
||||
|
||||
@@ -238,6 +238,11 @@ pub(crate) fn cancel(ctx: &egui::Context, id: egui::Id) {
|
||||
ctx.data_mut(|d| d.remove::<State>(id));
|
||||
}
|
||||
|
||||
/// Hit-test before registering the dynamic egui drag widget or changing the camera.
|
||||
pub(crate) fn contains_pointer(proj: &Proj, t: &Triad, p: Pos2) -> bool {
|
||||
mm_per_px(proj, t.center).and_then(|scale| shapes(proj, t, scale * LEN_PX)).is_some_and(|sh| hit(&sh, p).is_some())
|
||||
}
|
||||
|
||||
/// Draw the triad and run a drag on it. `step` is the distance snap step.
|
||||
pub fn show(ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, id: egui::Id, t: &Triad, step: f64) -> Option<Drag> {
|
||||
let tk = Tokens::get();
|
||||
|
||||
+36
-21
@@ -557,7 +557,10 @@ impl SolveApp {
|
||||
if let Ok(v) = &r {
|
||||
if id == "view.navigate" {
|
||||
let to: Camera = serde_json::from_value(v.get("camera").cloned().unwrap_or_default()).map_err(|e| e.to_string())?;
|
||||
if v.get("fit").and_then(Value::as_bool).unwrap_or(false) {
|
||||
if v.get("continuous").and_then(Value::as_bool).unwrap_or(false) {
|
||||
self.cancel_view_animation();
|
||||
self.cam = to;
|
||||
} else if v.get("fit").and_then(Value::as_bool).unwrap_or(false) {
|
||||
let bounds = solidcraft_engine::view::bounds(&self.session);
|
||||
self.animate_to(self.fitted(to));
|
||||
self.pending_fit = Some((to, bounds, true));
|
||||
@@ -569,14 +572,16 @@ impl SolveApp {
|
||||
sketch_palette::apply_mode(self, id, v);
|
||||
}
|
||||
match &r {
|
||||
Ok(_) => {
|
||||
Ok(v) => {
|
||||
// Programmatic callers (the control channel, scripts) run outside a render
|
||||
// language scope, so resolve the echo from the saved preference instead of the
|
||||
// thread-local scope. The command's own label is translated too, so the echo
|
||||
// reads the same way the toolbar button it came from does.
|
||||
let language = i18n::Locale::for_language(&self.preferences.language);
|
||||
let label = language.label(command_label(id));
|
||||
self.session.echo(language.format("ui.command_done", &[("command", label)]));
|
||||
if !(id == "view.navigate" && v.get("continuous").and_then(Value::as_bool) == Some(true)) {
|
||||
self.session.echo(language.format("ui.command_done", &[("command", label)]));
|
||||
}
|
||||
if matches!(id, "file.new" | "doc.open" | "doc.recover") {
|
||||
render_workspace::reset_document(self);
|
||||
self.sketch_choices = sketch_families::Choices::default();
|
||||
@@ -1140,26 +1145,36 @@ impl SolveApp {
|
||||
if self.synthetic.is_empty() {
|
||||
return;
|
||||
}
|
||||
let n = match self.synthetic.first() {
|
||||
Some(egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. } | egui::Event::MouseWheel { .. }) => 1,
|
||||
_ => self.synthetic.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1),
|
||||
};
|
||||
if let Some(egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. }) = self.synthetic.first() {
|
||||
raw.events.push(egui::Event::PointerMoved(*p));
|
||||
}
|
||||
// egui keeps the modifier keys as input state, changed by their own event.
|
||||
match self.synthetic.first() {
|
||||
Some(egui::Event::PointerButton { modifiers, pressed, .. }) => {
|
||||
// A drag keeps its press's modifiers until the release.
|
||||
self.synthetic_mods = if *pressed { *modifiers } else { egui::Modifiers::default() };
|
||||
raw.events.push(egui::Event::ModifiersChanged(*modifiers))
|
||||
// Text and modifier prefixes may precede a pointer event. Never drain past that event.
|
||||
let n = self
|
||||
.synthetic
|
||||
.iter()
|
||||
.position(|e| {
|
||||
matches!(
|
||||
e,
|
||||
egui::Event::PointerMoved(_)
|
||||
| egui::Event::PointerButton { .. }
|
||||
| egui::Event::PointerGone
|
||||
| egui::Event::MouseWheel { .. }
|
||||
| egui::Event::Key { pressed: false, .. }
|
||||
)
|
||||
})
|
||||
.map_or(self.synthetic.len(), |i| i + 1);
|
||||
for event in self.synthetic.drain(..n) {
|
||||
match &event {
|
||||
egui::Event::ModifiersChanged(modifiers) => self.synthetic_mods = *modifiers,
|
||||
egui::Event::PointerButton { pos, modifiers, pressed, .. } => {
|
||||
raw.events.push(egui::Event::PointerMoved(*pos));
|
||||
raw.events.push(egui::Event::ModifiersChanged(*modifiers));
|
||||
// A drag keeps its press's modifiers until the release.
|
||||
self.synthetic_mods = if *pressed { *modifiers } else { egui::Modifiers::NONE };
|
||||
}
|
||||
egui::Event::Key { modifiers, .. } => raw.events.push(egui::Event::ModifiersChanged(*modifiers)),
|
||||
egui::Event::PointerMoved(_) => raw.events.push(egui::Event::ModifiersChanged(self.synthetic_mods)),
|
||||
_ => {}
|
||||
}
|
||||
Some(egui::Event::Key { modifiers, .. }) => raw.events.push(egui::Event::ModifiersChanged(*modifiers)),
|
||||
Some(egui::Event::PointerMoved(_)) => raw.events.push(egui::Event::ModifiersChanged(self.synthetic_mods)),
|
||||
_ => {}
|
||||
raw.events.push(event);
|
||||
}
|
||||
let n = n.min(self.synthetic.len());
|
||||
raw.events.extend(self.synthetic.drain(..n));
|
||||
}
|
||||
|
||||
/// Lay out the whole window.
|
||||
|
||||
+69
-11
@@ -30,6 +30,7 @@ use crate::viewport::NavMode;
|
||||
|
||||
pub const UNITS: [&str; 5] = ["mm", "cm", "m", "in", "ft"];
|
||||
pub const OPENSCAD_NAV: &str = "openscad";
|
||||
pub const TOUCHPAD_NAV: &str = "touchpad";
|
||||
/// A mouse button that can drag the camera.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum NavButton {
|
||||
@@ -93,7 +94,7 @@ use NavButton::{Left, Middle, Right};
|
||||
use NavMode::{Orbit, Pan, Zoom};
|
||||
|
||||
/// The mouse mappings. The first is the default.
|
||||
pub const NAV_PRESETS: [NavPreset; 6] = [
|
||||
pub const NAV_PRESETS: [NavPreset; 7] = [
|
||||
NavPreset {
|
||||
id: "fusion",
|
||||
label: "Fusion",
|
||||
@@ -159,8 +160,40 @@ pub const NAV_PRESETS: [NavPreset; 6] = [
|
||||
bind(Middle, NavKeys::NONE, Pan),
|
||||
],
|
||||
},
|
||||
NavPreset {
|
||||
id: TOUCHPAD_NAV,
|
||||
label: "Touchpad",
|
||||
help: "Hold Shift and move the pointer to pan; hold Alt/Option and move it to orbit. No mouse button is needed. Vertical scroll zooms.",
|
||||
reverse_zoom: false,
|
||||
bindings: &[bind(Middle, NavKeys::NONE, Pan), bind(Right, NavKeys::NONE, Orbit)],
|
||||
},
|
||||
];
|
||||
|
||||
impl NavPreset {
|
||||
/// Button-free motion has exact modifiers, unlike the additive drag bindings.
|
||||
pub fn motion_mode(&self, mods: Modifiers) -> Option<NavMode> {
|
||||
if self.id != TOUCHPAD_NAV || mods.ctrl || mods.command || mods.mac_cmd {
|
||||
return None;
|
||||
}
|
||||
match (mods.shift, mods.alt) {
|
||||
(true, false) => Some(Pan),
|
||||
(false, true) => Some(Orbit),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn translated_label(&self) -> &str {
|
||||
if self.id == TOUCHPAD_NAV { crate::i18n::tr("nav.touchpad.label") } else { crate::i18n::tr_label(self.label) }
|
||||
}
|
||||
}
|
||||
|
||||
impl Prefs {
|
||||
/// Touchpad scroll zooms without overwriting the user's preference for other presets.
|
||||
pub fn trackpad_pans(&self) -> bool {
|
||||
self.nav != TOUCHPAD_NAV && self.trackpad_pan
|
||||
}
|
||||
}
|
||||
|
||||
/// The preset with this id (the default for an unknown one).
|
||||
pub fn nav_preset(id: &str) -> &'static NavPreset {
|
||||
NAV_PRESETS.iter().find(|p| p.id == id).unwrap_or(&NAV_PRESETS[0])
|
||||
@@ -542,7 +575,7 @@ pub fn orbit(app: &mut SolveApp, rect: egui::Rect, dx: f64, dy: f64, press: Opti
|
||||
|
||||
/// The model point under a screen position. Over empty space: the centre of the visible bodies
|
||||
/// and sketches (what Fit frames), or the view's centre when nothing is visible.
|
||||
fn pivot_under(app: &SolveApp, proj: &crate::viewport::Proj, at: Pos2) -> Vec3 {
|
||||
pub(crate) fn pivot_under(app: &SolveApp, proj: &crate::viewport::Proj, at: Pos2) -> Vec3 {
|
||||
use crate::viewport::Hit;
|
||||
for h in crate::viewport::pick(app, proj, at) {
|
||||
match h {
|
||||
@@ -927,18 +960,22 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
}
|
||||
1 => {
|
||||
ui.label(app.tr_label("Pan, zoom, orbit"));
|
||||
let current_preset_label = app.tr_label(nav_preset(&app.preferences.nav).label);
|
||||
let current_preset_label = nav_preset(&app.preferences.nav).translated_label();
|
||||
egui::ComboBox::from_id_salt("pref_nav").selected_text(current_preset_label).show_ui(ui, |ui| {
|
||||
for p in &NAV_PRESETS {
|
||||
if ui.selectable_value(&mut app.preferences.nav, p.id.to_string(), crate::i18n::tr_label(p.label)).clicked() {
|
||||
if ui.selectable_value(&mut app.preferences.nav, p.id.to_string(), p.translated_label()).clicked() {
|
||||
app.preferences.zoom_reverse = p.reverse_zoom;
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
let snap_help = if app.preferences.nav == OPENSCAD_NAV { "" } else { "; hold Shift during orbit to snap" };
|
||||
let raw_help = format!("{}; Ctrl+left drag on empty space orbits{snap_help}; the wheel zooms", nav_preset(&app.preferences.nav).help);
|
||||
note(ui, crate::i18n::tr_label(&raw_help));
|
||||
if app.preferences.nav == TOUCHPAD_NAV {
|
||||
note(ui, crate::i18n::tr("nav.touchpad.help"));
|
||||
} else {
|
||||
let snap_help = if app.preferences.nav == OPENSCAD_NAV { "" } else { "; hold Shift during orbit to snap" };
|
||||
let raw_help = format!("{}; Ctrl+left drag on empty space orbits{snap_help}; the wheel zooms", nav_preset(&app.preferences.nav).help);
|
||||
note(ui, crate::i18n::tr_label(&raw_help));
|
||||
}
|
||||
ui.label(crate::i18n::tr_label("Context menu"));
|
||||
ui.checkbox(&mut app.preferences.radial_context_menu, crate::i18n::tr_label("Radial context menu"));
|
||||
ui.end_row();
|
||||
@@ -966,9 +1003,12 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
.on_hover_text(crate::i18n::tr_label("Turn off to apply each wheel event immediately, without a smoothing tail"));
|
||||
ui.end_row();
|
||||
ui.label(crate::i18n::tr_label("Two-finger scroll pans"));
|
||||
ui.checkbox(&mut app.preferences.trackpad_pan, "").on_hover_text(crate::i18n::tr_label(
|
||||
"Trackpad gestures pan (off: they zoom like the wheel); mouse wheel zooms, thumb wheel pans sideways, pinch always zooms",
|
||||
));
|
||||
let touchpad = app.preferences.nav == TOUCHPAD_NAV;
|
||||
ui.add_enabled(!touchpad, egui::Checkbox::new(&mut app.preferences.trackpad_pan, "")).on_hover_text(if touchpad {
|
||||
crate::i18n::tr("nav.touchpad.scroll")
|
||||
} else {
|
||||
crate::i18n::tr_label("Trackpad gestures pan (off: they zoom like the wheel); mouse wheel zooms, thumb wheel pans sideways, pinch always zooms")
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label(crate::i18n::tr_label("Orbit around"));
|
||||
ui.horizontal(|ui| {
|
||||
@@ -1249,7 +1289,7 @@ pub fn set(app: &mut SolveApp, p: &serde_json::Value) -> Result<serde_json::Valu
|
||||
return Err(format!("units must be one of {}", UNITS.join(", ")));
|
||||
}
|
||||
if !NAV_PRESETS.iter().any(|p| p.id == new.nav) || !MSAA.contains(&new.msaa) {
|
||||
return Err("`nav` must be fusion, solidworks, inventor, alias, tinkercad or openscad and `msaa` 1, 2, 4 or 8".into());
|
||||
return Err(format!("`nav` must be one of {} and `msaa` 1, 2, 4 or 8", NAV_PRESETS.iter().map(|p| p.id).collect::<Vec<_>>().join(", ")));
|
||||
}
|
||||
if new.msaa != app.preferences.msaa && !app.msaa_supported.is_empty() && !app.msaa_supported.contains(&new.msaa) {
|
||||
return Err(format!("this graphics device doesn't support `msaa` {}; it supports {:?}", new.msaa, app.msaa_supported));
|
||||
@@ -1471,6 +1511,24 @@ mod tests {
|
||||
assert!(!app.preferences.smooth_wheel_zoom);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn touchpad_preset_persists_and_obeys_preference_transactions() {
|
||||
let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
|
||||
control(&mut app, &json!({"open":"Navigation", "nav":"touchpad"})).unwrap();
|
||||
assert_eq!(app.preferences.nav, "touchpad");
|
||||
assert!(app.preferences.trackpad_pan, "choosing Touchpad must not overwrite the saved scroll preference");
|
||||
control(&mut app, &json!({"button":"cancel"})).unwrap();
|
||||
assert_eq!(app.preferences.nav, "fusion");
|
||||
control(&mut app, &json!({"open":"Navigation", "nav":"touchpad", "button":"apply"})).unwrap();
|
||||
let mut other = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
|
||||
other.load_prefs(&app.prefs());
|
||||
assert_eq!(other.preferences.nav, "touchpad");
|
||||
control(&mut app, &json!({"button":"defaults"})).unwrap();
|
||||
assert_eq!(app.preferences.nav, "fusion");
|
||||
control(&mut app, &json!({"button":"cancel"})).unwrap();
|
||||
assert_eq!(app.preferences.nav, "touchpad");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openscad_preset_maps_navigation_and_persists() {
|
||||
let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! - `{"start": "solid.extrude"}`: a toolbar click; `{"key": "E", "shift"?, "cmd"?}`; `{"text": "20"}`
|
||||
//! - `{"widget": "Revolute", "near"?: "Type"}`: click a dialog field, choice or button by its
|
||||
//! text (with `near`: the one in that label's row)
|
||||
//! - `{"click": AT, "double"?, "shift"?, "ctrl"?, "button"?}`, `{"move": AT}`,
|
||||
//! - `{"click": AT, "double"?, "shift"?, "ctrl"?, "button"?}`, `{"move": AT, "shift"?, "alt"?, "ctrl"?, "cmd"?}`,
|
||||
//! `{"hold": AT, "frames"?}` (press and hold still: Select Other),
|
||||
//! `{"drag": [AT, AT], "shift"?, "ctrl"?, "steps"?}` where AT is `[x, y]` (screen), `{"world": [x,y,z]}`
|
||||
//! or `{"sketch": [x,y]}` (the sketch being edited)
|
||||
@@ -399,7 +399,7 @@ impl Harness {
|
||||
)
|
||||
} else if let Some(at) = s.get("move") {
|
||||
let [x, y] = self.at(at)?;
|
||||
self.call("ui.move", json!({"x": x, "y": y}))
|
||||
self.call("ui.move", json!({"x": x, "y": y,"shift":flag("shift"),"alt":flag("alt"),"ctrl":flag("ctrl"),"cmd":flag("cmd")}))
|
||||
} else if let Some(d) = s.get("drag") {
|
||||
let [x0, y0] = self.at(d.get(0).unwrap_or(&Value::Null))?;
|
||||
let [x1, y1] = self.at(d.get(1).unwrap_or(&Value::Null))?;
|
||||
|
||||
+318
-21
@@ -66,6 +66,8 @@ pub struct ViewportState {
|
||||
/// The preset's navigation for the left-button drag under way (Alias: Shift+Alt), taken
|
||||
/// when the button went down, so the drag neither selects nor moves sketch geometry.
|
||||
pub primary_nav: Option<NavMode>,
|
||||
/// Button-free gesture and its last owned pointer position, kept through pauses.
|
||||
modifier_nav: Option<(NavMode, Pos2)>,
|
||||
/// What a click would pick now (after the active command's filter).
|
||||
pub hover: Option<Hit>,
|
||||
pub mouse: Option<Pos2>,
|
||||
@@ -1988,6 +1990,45 @@ fn zoom_anchor(app: &mut SolveApp, rect: egui::Rect, hover: Option<egui::Pos2>)
|
||||
})
|
||||
}
|
||||
|
||||
/// Modifier-only navigation never borrows pointer movement from another widget or gesture.
|
||||
fn modifier_motion(app: &mut SolveApp, ui: &egui::Ui, inside: bool, hover: Option<Pos2>, mods: egui::Modifiers) -> Option<(NavMode, egui::Vec2)> {
|
||||
let owns = inside
|
||||
&& ui.input(|i| i.focused && !i.pointer.any_down() && !i.pointer.any_released() && !i.pointer.any_pressed())
|
||||
&& !ui.ctx().egui_wants_keyboard_input()
|
||||
&& !egui::Popup::is_any_open(ui.ctx())
|
||||
&& !crate::dim_view::editing()
|
||||
&& app.viewport.context_menu.is_none()
|
||||
&& app.viewport.axis_drag.is_none()
|
||||
&& app.viewport.point_drag.is_none()
|
||||
&& app.viewport.text_drag.is_none()
|
||||
&& app.viewport.boxsel.is_none()
|
||||
&& !hover.is_some_and(|p| app.viewport.rect.is_some_and(|rect| crate::canvas::owns_pointer(app, &projection(app, rect), p)))
|
||||
&& !hover.is_some_and(|p| app.viewport.layout.as_ref().is_some_and(|l| l.navigation.contains(p)));
|
||||
let next =
|
||||
hover.filter(|p| owns && p.is_finite()).and_then(|p| crate::prefs::nav_preset(&app.preferences.nav).motion_mode(mods).map(|mode| (mode, p)));
|
||||
let previous = app.viewport.modifier_nav;
|
||||
if previous.map(|(m, _)| m) != next.map(|(m, _)| m) {
|
||||
app.viewport.orbit_pivot = None;
|
||||
app.viewport.orbit_free = None;
|
||||
app.viewport.orbit_snap = None;
|
||||
}
|
||||
app.viewport.modifier_nav = next;
|
||||
next.map(|(mode, p)| {
|
||||
let delta = previous.filter(|(m, _)| *m == mode).map_or(egui::Vec2::ZERO, |(_, last)| p - last);
|
||||
let delta = if delta.is_finite() && delta.x.abs() <= 1e4 && delta.y.abs() <= 1e4 { delta } else { egui::Vec2::ZERO };
|
||||
(mode, delta)
|
||||
})
|
||||
}
|
||||
|
||||
/// Continuous camera changes use the same JSON command as programmatic callers.
|
||||
fn camera_motion(app: &mut SolveApp, rect: Rect, mut params: serde_json::Value) {
|
||||
let stored_fov = app.cam.fov;
|
||||
params["camera"] = json!(projection(app, rect).cam);
|
||||
params["viewport"] = json!([rect.width().max(1.0), rect.height().max(1.0)]);
|
||||
let _ = app.run("view.navigate", params);
|
||||
app.cam.fov = stored_fov;
|
||||
}
|
||||
|
||||
pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get();
|
||||
let rect = ui.max_rect();
|
||||
@@ -2022,7 +2063,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
middle_double_click(app, ui, inside);
|
||||
|
||||
// ---- navigation ----
|
||||
let trackpad_pan = app.preferences.trackpad_pan;
|
||||
let trackpad_pan = app.preferences.trackpad_pans();
|
||||
if !app.preferences.smooth_wheel_zoom {
|
||||
app.viewport.wheel_zoom_raw_until_idle = true;
|
||||
} else if !ui.input(|i| i.is_scrolling()) {
|
||||
@@ -2056,23 +2097,43 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
// Spread zooms in: the camera distance shrinks by the pinch factor.
|
||||
let f = f64::from(pinch).powf(-crate::prefs::zoom_sign(app));
|
||||
let anchor = zoom_anchor(app, rect, hover);
|
||||
app.cancel_view_animation();
|
||||
app.cam.zoom_at(f, anchor);
|
||||
if app.preferences.nav == crate::prefs::TOUCHPAD_NAV {
|
||||
let mut params = json!({"motion":"zoom", "factor":f.clamp(0.05,20.0)});
|
||||
if let Some(anchor) = anchor {
|
||||
params["anchor"] = json!(anchor);
|
||||
}
|
||||
camera_motion(app, rect, params);
|
||||
} else {
|
||||
app.cancel_view_animation();
|
||||
app.cam.zoom_at(f, anchor);
|
||||
}
|
||||
}
|
||||
if inside && scroll != 0.0 {
|
||||
app.viewport.orbit_snap = None;
|
||||
app.viewport.orbit_free = None;
|
||||
let f = (-scroll as f64 * 0.0015 * crate::prefs::zoom_sign(app)).exp();
|
||||
let anchor = zoom_anchor(app, rect, hover);
|
||||
app.cancel_view_animation();
|
||||
app.cam.zoom_at(f, anchor);
|
||||
if app.preferences.nav == crate::prefs::TOUCHPAD_NAV {
|
||||
let mut params = json!({"motion":"zoom", "factor":f.clamp(0.05,20.0)});
|
||||
if let Some(anchor) = anchor {
|
||||
params["anchor"] = json!(anchor);
|
||||
}
|
||||
camera_motion(app, rect, params);
|
||||
} else {
|
||||
app.cancel_view_animation();
|
||||
app.cam.zoom_at(f, anchor);
|
||||
}
|
||||
}
|
||||
// Two-finger scroll pans the model with the fingers.
|
||||
if inside && pan != egui::Vec2::ZERO {
|
||||
app.viewport.orbit_snap = None;
|
||||
app.viewport.orbit_free = None;
|
||||
app.cancel_view_animation();
|
||||
app.cam.pan(f64::from(pan.x), f64::from(pan.y), h);
|
||||
if app.preferences.nav == crate::prefs::TOUCHPAD_NAV {
|
||||
camera_motion(app, rect, json!({"motion":"pan","delta":[pan.x,pan.y]}));
|
||||
} else {
|
||||
app.cancel_view_animation();
|
||||
app.cam.pan(f64::from(pan.x), f64::from(pan.y), h);
|
||||
}
|
||||
}
|
||||
// A left press with the preset's keys (Alias: Shift+Alt) navigates until the button is let
|
||||
// go (kept through the release frame, so the end of the drag doesn't select either).
|
||||
@@ -2108,16 +2169,35 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
None
|
||||
};
|
||||
let dragging = resp.dragged() || (inside && button.is_some());
|
||||
let mode = if dragging && app.viewport.axis_drag.is_none() {
|
||||
match button {
|
||||
Some(b) => crate::prefs::nav_mode(app, b, mods),
|
||||
None if primary_down => primary_nav,
|
||||
None => None,
|
||||
let motion = modifier_motion(app, ui, inside, hover, mods);
|
||||
let mode = motion.map(|(m, _)| m).or_else(|| {
|
||||
if dragging && app.viewport.axis_drag.is_none() {
|
||||
match button {
|
||||
Some(b) => crate::prefs::nav_mode(app, b, mods),
|
||||
None if primary_down => primary_nav,
|
||||
None => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if mode == Some(NavMode::Orbit) {
|
||||
});
|
||||
if let Some((mode, delta)) = motion {
|
||||
if delta != egui::Vec2::ZERO {
|
||||
let mut params = json!({"motion":if mode == NavMode::Pan {"pan"} else {"orbit"},"delta":[delta.x,delta.y]});
|
||||
if mode == NavMode::Orbit && app.preferences.orbit_cursor {
|
||||
let pivot = match app.viewport.orbit_pivot {
|
||||
Some(pivot) => pivot,
|
||||
None => {
|
||||
let pivot = crate::prefs::pivot_under(app, &projection(app, rect), hover.unwrap_or(rect.center()) - delta);
|
||||
app.viewport.orbit_pivot = Some(pivot);
|
||||
pivot
|
||||
}
|
||||
};
|
||||
params["pivot"] = json!(pivot);
|
||||
}
|
||||
camera_motion(app, rect, params);
|
||||
}
|
||||
} else if mode == Some(NavMode::Orbit) {
|
||||
let press = ui.input(|i| i.pointer.press_origin());
|
||||
app.cancel_view_animation();
|
||||
// Shift can be required to enter orbit (Fusion/Inventor middle drag, Alias Shift+Alt),
|
||||
@@ -2148,7 +2228,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if !dragging {
|
||||
if !dragging && motion.is_none() {
|
||||
app.viewport.orbit_pivot = None;
|
||||
}
|
||||
if app.viewport.spin {
|
||||
@@ -2166,7 +2246,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
i.stable_dt,
|
||||
)
|
||||
});
|
||||
if pressed || key || dragging {
|
||||
if pressed || key || dragging || motion.is_some() {
|
||||
app.viewport.spin = false;
|
||||
} else {
|
||||
app.cam.orbit(f64::from(dt.min(0.1)) * 30.0, 0.0);
|
||||
@@ -3354,15 +3434,17 @@ fn nav_bar(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let items: [(&str, &str); 10] = [
|
||||
(
|
||||
"orbit",
|
||||
if app.preferences.nav == crate::prefs::OPENSCAD_NAV {
|
||||
if app.preferences.nav == crate::prefs::TOUCHPAD_NAV {
|
||||
crate::i18n::tr("nav.touchpad.orbit")
|
||||
} else if app.preferences.nav == crate::prefs::OPENSCAD_NAV {
|
||||
"Orbit (left drag on empty space; Shift+left drag box-selects)"
|
||||
} else {
|
||||
"Orbit (right drag; hold Shift to snap)"
|
||||
},
|
||||
),
|
||||
("spin", "Continuous orbit (stops at the next click or key)"),
|
||||
("pan", "Pan (drag; or middle drag)"),
|
||||
("zoom", "Zoom (drag; or wheel)"),
|
||||
("pan", if app.preferences.nav == crate::prefs::TOUCHPAD_NAV { crate::i18n::tr("nav.touchpad.pan") } else { "Pan (drag; or middle drag)" }),
|
||||
("zoom", if app.preferences.nav == crate::prefs::TOUCHPAD_NAV { crate::i18n::tr("nav.touchpad.zoom") } else { "Zoom (drag; or wheel)" }),
|
||||
("fit", "Fit (F6)"),
|
||||
("lookat", "Look At the selected face or plane (or the active sketch)"),
|
||||
("home", "Home view"),
|
||||
@@ -3384,6 +3466,7 @@ fn nav_bar(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
|| ui.rect_contains_pointer(axis_rect(app, rect))
|
||||
|| app.viewport.axis_drag.is_some()
|
||||
|| app.viewport.nav.is_some()
|
||||
|| app.viewport.modifier_nav.is_some()
|
||||
|| app.viewport.spin
|
||||
|| menu_open;
|
||||
let mut painter = ui.painter_at(rect);
|
||||
@@ -4077,6 +4160,220 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn touchpad_modifier_motion_navigates_without_buttons_or_document_changes() {
|
||||
for alt in [false, true] {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = hover_app(false);
|
||||
app.preferences.nav = "touchpad".into();
|
||||
let p = pos2(350.0, 300.0);
|
||||
hover_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(p)]);
|
||||
let before = app.cam;
|
||||
let doc = app.session.doc.to_json();
|
||||
let undo = app.session.undo.len();
|
||||
let log = app.session.log.len();
|
||||
let mods = egui::Modifiers { shift: !alt, alt, ..Default::default() };
|
||||
hover_frame(&mut app, &ctx, vec![egui::Event::ModifiersChanged(mods)]);
|
||||
hover_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(p + vec2(30.0, 20.0))]);
|
||||
assert!(!ctx.input(|i| i.pointer.any_down()));
|
||||
if alt {
|
||||
assert!((app.cam.yaw - before.yaw - 0.24).abs() < 1e-6);
|
||||
assert_eq!(app.cam.target, before.target);
|
||||
} else {
|
||||
assert_ne!(app.cam.target, before.target);
|
||||
assert_eq!(app.cam.yaw, before.yaw);
|
||||
}
|
||||
assert_eq!(app.cam.distance, before.distance);
|
||||
let after = app.cam;
|
||||
for _ in 0..3 {
|
||||
hover_frame(&mut app, &ctx, vec![]);
|
||||
assert_eq!(app.cam, after, "no drift between pointer events");
|
||||
assert!(app.viewport.hover.is_none());
|
||||
}
|
||||
hover_frame(&mut app, &ctx, vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE), egui::Event::PointerMoved(p)]);
|
||||
assert_eq!(app.cam, after, "release ends modifier-only navigation");
|
||||
assert_eq!(app.session.doc.to_json(), doc);
|
||||
assert_eq!(app.session.undo.len(), undo);
|
||||
assert_eq!(app.session.log.len(), log, "continuous motion must not flood the command log");
|
||||
assert!(app.session.selection.is_empty());
|
||||
assert!(app.viewport.boxsel.is_none() && app.viewport.point_drag.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn touchpad_motion_uses_the_effective_perspective_projection() {
|
||||
let mut app = hover_app(false);
|
||||
app.ui.perspective = true;
|
||||
app.cam = Camera { yaw: 0.0, pitch: 0.0, distance: 100.0, ..Default::default() };
|
||||
let rect = Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
|
||||
let pivot = Vec3::new(15.0, -50.0, 0.0);
|
||||
let before = projection(&app, rect).to_screen(pivot).unwrap();
|
||||
camera_motion(&mut app, rect, json!({"motion":"orbit","delta":[20,10],"pivot":pivot}));
|
||||
let after = projection(&app, rect).to_screen(pivot).unwrap();
|
||||
assert!(before.distance(after) < 1e-3, "rendered pivot drifted from {before:?} to {after:?}");
|
||||
assert_eq!(app.cam.fov, 0.0, "the projection toggle remains UI state, not a stored-camera mutation");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn touchpad_motion_does_not_move_the_camera_under_a_canvas_triad() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut s = solidcraft_engine::Session::default();
|
||||
s.execute("solid.box", &json!({"length":20,"width":20,"height":20})).unwrap();
|
||||
s.selection = vec![Sel::Body { name: "Body1".into() }];
|
||||
let mut app = SolveApp::new(s, crate::Services::default());
|
||||
app.preferences.nav = "touchpad".into();
|
||||
app.viewport.no_pixels = true;
|
||||
app.start("solid.move");
|
||||
app.dialog.as_mut().unwrap().focus = false;
|
||||
let p = pos2(100.0, 100.0);
|
||||
hover_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(p)]);
|
||||
ctx.memory_mut(|m| {
|
||||
if let Some(id) = m.focused() {
|
||||
m.surrender_focus(id);
|
||||
}
|
||||
});
|
||||
let triad = crate::dialogs_move::triad(&app, app.dialog.as_ref().unwrap()).unwrap();
|
||||
let handle = projection(&app, app.viewport.rect.unwrap()).to_screen(triad.center).unwrap();
|
||||
let mods = egui::Modifiers { alt: true, ..Default::default() };
|
||||
hover_frame(&mut app, &ctx, vec![egui::Event::ModifiersChanged(mods)]);
|
||||
assert!(!ctx.egui_wants_keyboard_input());
|
||||
let before = app.cam;
|
||||
hover_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(handle)]);
|
||||
assert_eq!(app.cam, before, "a triad handle owns its input before the camera changes");
|
||||
hover_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(p)]);
|
||||
assert_eq!(app.cam, before, "leaving the handle rearms without a jump");
|
||||
hover_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(p + vec2(5.0, 0.0))]);
|
||||
assert_ne!(app.cam.yaw, before.yaw, "the exposed viewport remains navigable with Move open");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn touchpad_motion_preserves_input_ownership_and_rearms_without_jumping() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = hover_app(false);
|
||||
app.preferences.nav = "touchpad".into();
|
||||
app.preferences.orbit_cursor = true;
|
||||
let p = pos2(350.0, 300.0);
|
||||
let alt = egui::Modifiers { alt: true, ..Default::default() };
|
||||
hover_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(p)]);
|
||||
hover_frame(&mut app, &ctx, vec![egui::Event::ModifiersChanged(alt)]);
|
||||
hover_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(p + vec2(10.0, 0.0))]);
|
||||
let pivot = app.viewport.orbit_pivot;
|
||||
assert!(pivot.is_some());
|
||||
hover_frame(&mut app, &ctx, vec![]);
|
||||
assert_eq!(app.viewport.orbit_pivot, pivot, "pauses keep the same orbit pivot");
|
||||
let before = app.cam;
|
||||
let bar = app.viewport.layout.as_ref().unwrap().navigation.center();
|
||||
hover_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(bar)]);
|
||||
assert_eq!(app.cam, before, "the navigation bar owns its input");
|
||||
assert!(app.viewport.orbit_pivot.is_none());
|
||||
hover_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(p)]);
|
||||
assert_eq!(app.cam, before, "reentry must not apply the distance from an overlay");
|
||||
hover_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(p + vec2(5.0, 0.0))]);
|
||||
assert_ne!(app.cam.yaw, before.yaw);
|
||||
let before = app.cam;
|
||||
for mods in [
|
||||
egui::Modifiers::NONE,
|
||||
egui::Modifiers { shift: true, alt: true, ..Default::default() },
|
||||
egui::Modifiers { shift: true, ctrl: true, ..Default::default() },
|
||||
egui::Modifiers { alt: true, command: true, mac_cmd: true, ..Default::default() },
|
||||
] {
|
||||
hover_frame(&mut app, &ctx, vec![egui::Event::ModifiersChanged(mods), egui::Event::PointerMoved(p)]);
|
||||
assert_eq!(app.cam, before, "unbound combinations must not navigate");
|
||||
}
|
||||
let shift = egui::Modifiers { shift: true, ..Default::default() };
|
||||
hover_frame(&mut app, &ctx, vec![egui::Event::ModifiersChanged(shift), egui::Event::PointerMoved(p)]);
|
||||
hover_frame(
|
||||
&mut app,
|
||||
&ctx,
|
||||
vec![egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: shift }],
|
||||
);
|
||||
hover_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(p + vec2(30.0, 20.0))]);
|
||||
assert_eq!(app.cam, before, "Shift+left drag must still box-select, not pan");
|
||||
assert!(app.viewport.boxsel.is_some());
|
||||
hover_frame(
|
||||
&mut app,
|
||||
&ctx,
|
||||
vec![egui::Event::PointerButton { pos: p + vec2(30.0, 20.0), button: egui::PointerButton::Primary, pressed: false, modifiers: shift }],
|
||||
);
|
||||
assert_eq!(app.cam, before, "the release frame still belongs to selection");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn touchpad_focus_and_preset_changes_discard_old_motion() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = hover_app(false);
|
||||
app.preferences.nav = "touchpad".into();
|
||||
let alt = egui::Modifiers { alt: true, ..Default::default() };
|
||||
let frame = |app: &mut SolveApp, focused: bool, p: Pos2| {
|
||||
let mut out = ctx.run_ui(
|
||||
egui::RawInput {
|
||||
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))),
|
||||
focused,
|
||||
events: vec![egui::Event::ModifiersChanged(alt), egui::Event::PointerMoved(p)],
|
||||
..Default::default()
|
||||
},
|
||||
|ui| show(app, ui),
|
||||
);
|
||||
out.textures_delta.clear();
|
||||
};
|
||||
frame(&mut app, true, pos2(300.0, 300.0));
|
||||
frame(&mut app, true, pos2(310.0, 300.0));
|
||||
let before = app.cam;
|
||||
frame(&mut app, false, pos2(500.0, 400.0));
|
||||
frame(&mut app, true, pos2(350.0, 300.0));
|
||||
assert_eq!(app.cam, before, "focus loss and regain must not jump");
|
||||
frame(&mut app, true, pos2(360.0, 300.0));
|
||||
assert!((app.cam.yaw - before.yaw - 0.08).abs() < 1e-6);
|
||||
app.preferences.nav = "fusion".into();
|
||||
let before = app.cam;
|
||||
frame(&mut app, true, pos2(450.0, 300.0));
|
||||
assert_eq!(app.cam, before);
|
||||
app.preferences.nav = "touchpad".into();
|
||||
frame(&mut app, true, pos2(500.0, 300.0));
|
||||
assert_eq!(app.cam, before, "changing presets must rearm before moving");
|
||||
app.viewport.spin = true;
|
||||
frame(&mut app, true, pos2(510.0, 300.0));
|
||||
assert!(!app.viewport.spin, "pointer-only navigation stops continuous orbit");
|
||||
assert!((app.cam.yaw - before.yaw - 0.08).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn touchpad_vertical_trackpad_scroll_zooms_without_overwriting_saved_pan() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.preferences.nav = "touchpad".into();
|
||||
h.app.preferences.smooth_wheel_zoom = false;
|
||||
h.app.preferences.zoom_to_cursor = false;
|
||||
h.call("ui.move", json!({"x":800,"y":500}));
|
||||
let before = h.app.cam;
|
||||
for phase in [egui::TouchPhase::Start, egui::TouchPhase::Move, egui::TouchPhase::End] {
|
||||
h.app.synthetic.push(egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
delta: vec2(0.0, 120.0),
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
phase,
|
||||
});
|
||||
h.frame();
|
||||
}
|
||||
assert!((h.app.cam.distance - before.distance * (-0.18_f64).exp()).abs() < 1e-6);
|
||||
assert_eq!(h.app.cam.target, before.target);
|
||||
assert!(h.app.preferences.trackpad_pan);
|
||||
let after = h.app.cam;
|
||||
h.frames(60);
|
||||
assert_eq!(h.app.cam, after);
|
||||
h.app.preferences.nav = "fusion".into();
|
||||
for phase in [egui::TouchPhase::Start, egui::TouchPhase::Move, egui::TouchPhase::End] {
|
||||
h.app.synthetic.push(egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
delta: vec2(0.0, 120.0),
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
phase,
|
||||
});
|
||||
h.frame();
|
||||
}
|
||||
assert_eq!(h.app.cam.distance, after.distance);
|
||||
assert_ne!(h.app.cam.target, after.target);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hover_stays_off_between_navigation_events_and_returns_on_release() {
|
||||
for gpu in [false, true] {
|
||||
|
||||
@@ -42,3 +42,8 @@ Mouse and touch:
|
||||
| Double-click the middle button | go Home: the home view, framing everything shown (Preferences › Navigation › Middle double-click: Home, Fit or Off) |
|
||||
|
||||
Help › Keyboard Shortcuts in the app lists the current bindings, including any you changed.
|
||||
|
||||
With the **Touchpad** navigation preset, hold **Shift** while moving the pointer to pan, or
|
||||
**Alt/Option** to orbit, without pressing a mouse button. Vertical scrolling zooms. These
|
||||
viewport gestures are separate from rebindable command shortcuts; mouse-button selection
|
||||
and editing keep their existing modifiers.
|
||||
@@ -23,13 +23,19 @@
|
||||
Esc or a click elsewhere closes the list. Clicking again on the same spot, with nothing changed
|
||||
in between, selects the next thing in that list. The
|
||||
wheel zooms at the cursor, the middle button pans and right-drag orbits freely (the mouse
|
||||
< preset in Preferences switches to SolidWorks, Inventor, Alias or Tinkercad style). On a
|
||||
preset in Preferences switches to SolidWorks, Inventor, Alias, Tinkercad, OpenSCAD or Touchpad style). On a
|
||||
trackpad, pinch zooms and two-finger scroll pans (Navigation › Two-finger scroll pans;
|
||||
off: scroll zooms like the wheel); mouse wheels zoom and the thumb wheel pans sideways.
|
||||
Shift+middle orbits freely
|
||||
in the default and Inventor presets. In every preset, Ctrl+left drag (Cmd on macOS) that starts
|
||||
on empty space orbits; a Ctrl+click still adds to the selection. Shift+right-drag snaps to the nearest standard or
|
||||
diagonal view; Shift does not request snapping when the preset already requires it to orbit.
|
||||
With **Touchpad** selected in Preferences › Navigation, hold **Shift** and move the pointer to
|
||||
pan, or hold **Alt** (**Option** on macOS) and move it to orbit—no mouse button is needed.
|
||||
Vertical two-finger scrolling zooms in this preset, regardless of the saved two-finger pan
|
||||
setting; switching presets restores that setting. Release the modifier to stop navigating.
|
||||
Shift+click and Shift+left-drag still select. Combined Shift+Alt or Ctrl/Cmd modifiers do not
|
||||
start pointer-only navigation.
|
||||
The axis view shows the world's X/Y/Z directions in SolidCraft's colors. Click a signed
|
||||
axis tip to look along that axis; click the aligned tip again to look from the opposite side.
|
||||
Drag the axis view to orbit around the view centre, with Shift for snapping. Top and bottom
|
||||
|
||||
@@ -106,7 +106,7 @@ unit regardless of the design, pass an expression with one: `"value": "50 mm"` o
|
||||
| `ui.view` | `view`: `front`, `back`, `top`, `bottom`, `left`, `right`, `iso`, `home`, `fit`; `animate` (bool, default false: snap) | |
|
||||
| `ui.start` | `command` | like a toolbar click: starts the sketch tool or dialog for the command |
|
||||
| `ui.click` | `x`, `y` (screen points), `button?`: left/right/middle, `shift?`, `ctrl?`, `double?` | real pointer input |
|
||||
| `ui.move` | `x`, `y` | |
|
||||
| `ui.move` | `x`, `y`, `shift?`, `alt?`, `ctrl?`, `cmd?` | pointer movement without a button; modifiers apply until the next synthetic move/click; a move with omitted modifiers releases them. With `nav: "touchpad"`, Shift pans and Alt/Option orbits. |
|
||||
| `ui.drag` | `x0`, `y0`, `x1`, `y1`, `button?`, `shift?`, `ctrl?`, `steps?`, `hold?`, `hold_end?` | press, move, release; a left drag on the model is a box selection (left to right: window, right to left: crossing); with OpenSCAD navigation, plain left-drag on empty space orbits, Shift+left-drag box-selects, and drags starting on sketch geometry edit it; `hold` keeps the button down that many frames first (a right press-and-hold opens the marking menu, releasing over a direction picks it), `hold_end` that many frames at the end (a sketch drag shows its snap) |
|
||||
| `ui.menu` | `x?`, `y?`, `target?`, `close?` | with a point: open a context menu there as a right-click would (the viewport's marking menu, or a browser menu with `target: {"type": "body", "name"} \| {"type": "sketch", "id"} \| {"type": "component", "id"}`); without: the open menu (`radial` and `items`: id, label, shortcut, enabled) |
|
||||
| `ui.menuPick` | `item`: id or label | runs an item of the open menu, as clicking it does |
|
||||
@@ -131,7 +131,7 @@ unit regardless of the design, pass an expression with one: `"value": "50 mm"` o
|
||||
| `ui.home` | `open?`: bool; `sample?`: index | the start page: show or hide it, open a built-in sample (once built); returns `open` and the recent designs |
|
||||
| `ui.shortcut` | `command`, `key?`, `replace?` | a command's key, or rebinds it (`""` clears; a key another command uses is refused unless `replace`) |
|
||||
| `ui.help` | `item?`: `about`, `shortcuts` (read only), `report` (opens a report draft for review), `crashes` (the Crash Reports window) | the version, commit, build date and diagnostic text |
|
||||
| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `tinkercad`, `openscad`), `zoom_reverse`, `zoom_to_cursor`, `smooth_wheel_zoom` (default true), `trackpad_pan` (default true: two-finger scroll pans, pinch zooms), `orbit_cursor`, `look_at_anim`, `look_at_speed`, `middle_double_click` (`home` (default), `fit`, `off`), `msaa`, `length_decimals`, `angle_decimals`, `theme` (`dark`, `light`, `system`), `language` (`en`, `ja`, `de`, `fr`, `ko`, `ru`, `uk`, `zh`; see [localization](localization.md)), `ui_scale`, `up_axis` (`z`, `y`), `offer_stand_up`, `color_vision` (`off` (default), `protan`, `deutan`, `tritan`: colour vision modes, #778), `check_updates` (default true; [updates](book/src/guide/updates.md)); `open` (a page: General, Navigation, Keyboard Shortcuts, Design, Graphics, Sketch, Selection) opens the Preferences dialog there; `button`: `ok`, `cancel`, `apply`, `defaults`; `stand_up: true` stands up a just-opened Y-up file | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `dark` and `stand_up_offer` |
|
||||
| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `tinkercad`, `openscad`, `touchpad`), `zoom_reverse`, `zoom_to_cursor`, `smooth_wheel_zoom` (default true), `trackpad_pan` (default true: two-finger scroll pans, except Touchpad always zooms; pinch zooms), `orbit_cursor`, `look_at_anim`, `look_at_speed`, `middle_double_click` (`home` (default), `fit`, `off`), `msaa`, `length_decimals`, `angle_decimals`, `theme` (`dark`, `light`, `system`), `language` (`en`, `ja`, `de`, `fr`, `ko`, `ru`, `uk`, `zh`; see [localization](localization.md)), `ui_scale`, `up_axis` (`z`, `y`), `offer_stand_up`, `color_vision` (`off` (default), `protan`, `deutan`, `tritan`: colour vision modes, #778), `check_updates` (default true; [updates](book/src/guide/updates.md)); `open` (a page: General, Navigation, Keyboard Shortcuts, Design, Graphics, Sketch, Selection) opens the Preferences dialog there; `button`: `ok`, `cancel`, `apply`, `defaults`; `stand_up: true` stands up a just-opened Y-up file | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `dark` and `stand_up_offer` |
|
||||
| `ui.capture` | `action?`: `capture`, `revert`, `cancel` | answers the Capture Position question (asked when a command starts or the design is saved while components were moved without capturing); without `action`, what waits |
|
||||
| `ui.window` | `action`: minimize, maximize, restore, toggle, close | what the title bar's caption buttons do (on Windows and Linux the application bar is the title bar); close asks about unsaved designs first |
|
||||
| `ui.agent_cursor` | `show` (bool), `speed`: a number from 1 to 5 (times the normal speed) or `"instant"` (no animation: the command runs at once and the pointer jumps to where it acted), `follow_camera` (bool); all optional | the settings. While the cursor is shown, `engine.execute` first glides to the command's toolbar button and to each pick, then runs and replies; later requests wait their turn. `"animate": false` on a call skips it |
|
||||
@@ -142,6 +142,16 @@ unit regardless of the design, pass an expression with one: `"value": "50 mm"` o
|
||||
Entering Render requires finishing the current modelling operation; returning restores
|
||||
the Design camera. See [the integrated Render workspace](render-workspace.md).
|
||||
|
||||
### Continuous camera motion
|
||||
|
||||
Continuous camera motion is also available through `engine.execute` with command
|
||||
`view.navigate`: pass `camera` (from `ui.inspect`) and `motion: "pan" | "orbit" | "zoom"`.
|
||||
Pan and orbit take `delta: [dx,dy]` in screen points (each within ±10000); pan also requires
|
||||
`viewport: [width,height]` (each 1..1000000). Orbit accepts a world-space `pivot` with the
|
||||
viewport size. Zoom takes `factor` (0.05..20, below 1 zooms in) and an optional world-space
|
||||
`anchor`. The returned `camera` is applied immediately by the UI, without animation,
|
||||
document edits, undo steps or a success-log entry per movement.
|
||||
|
||||
## MCP
|
||||
|
||||
`solidcraft-cli mcp --connect 127.0.0.1:PORT` bridges an MCP client (an AI agent) to this
|
||||
|
||||
Reference in new issue
Block a user