mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-08 13:01:52 +00:00
Navigation: opt-in free orbit and shared Normal to Selection (#1912)
* Navigation: opt-in free orbit and shared Normal to Selection Preserve the complete reviewed feature, shared camera setting propagation, rigid pivot behavior and focus/drag regressions from PR #1912. Consolidate the prior main-integration commits before rebasing, so their merge-resolution fixes remain in the linear feature history. * docs: move free orbit help beside viewport controls * Merge main into fix/solidworks-free-orbit Camera animation is gone on main, and with it the orbit snap spring: the Shift snap keeps its unsnapped orbit in orbit_free, which the free-orbit checks now read. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --------- Co-authored-by: bherbruck <brennen.herbruck@gmail.com> Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -331,6 +331,11 @@ completed drawings publish atomically. The browser still exports synchronously.
|
||||
|
||||
## View navigation
|
||||
|
||||
- Navigation has an opt-in Free orbit preference for mouse and 3D-mouse rotation through
|
||||
both poles with roll. It defaults off, preserving existing preset snapping and pole limits.
|
||||
The SolidWorks face menu offers Normal to Selection (`view.normal_to`), shared with Look At
|
||||
and Ctrl+8, including repeat-to-flip, preserving scale and projection.
|
||||
|
||||
- Desktop Ctrl/Cmd +/- and 0 change or reset the saved UI scale. The scale slider keeps
|
||||
its layout stable during a drag and applies the selected scale on release.
|
||||
|
||||
|
||||
@@ -243,6 +243,7 @@
|
||||
"Open local libraries in the desktop app. You can export this design's library here.": "Öffnen Sie lokale Bibliotheken in der Desktop-App. Die Bibliothek dieses Designs können Sie hier exportieren.",
|
||||
"Open-source parametric 3D CAD in Rust. Licensed MIT OR Apache-2.0.": "Quelloffenes parametrisches 3D-CAD in Rust. Lizenziert unter MIT OR Apache-2.0.",
|
||||
"Operator": "Operator",
|
||||
"Orbit (right drag)": "Drehen (mit rechts ziehen)",
|
||||
"Other side": "Andere Seite",
|
||||
"Other surface (mutual trim)": "Anderer Flächenkörper (gegenseitig trimmen)",
|
||||
"Outside": "Außen",
|
||||
@@ -759,6 +760,7 @@
|
||||
"cmd.view_graphics_preset": "Grafikprofil",
|
||||
"cmd.view_list": "Benannte Ansichten auflisten",
|
||||
"cmd.view_navigate": "Ansicht navigieren",
|
||||
"cmd.view_normal_to": "Senkrecht zur Auswahl",
|
||||
"cmd.view_rename": "Benannte Ansicht umbenennen",
|
||||
"cmd.view_save": "Neue benannte Ansicht",
|
||||
"color.copy": "Klicken, um Farbwerte zu kopieren",
|
||||
@@ -927,6 +929,8 @@
|
||||
"motion.to_end": "Zum Ende",
|
||||
"motion.to_start": "Zum Anfang",
|
||||
"motion.track_name": "Spur {number}",
|
||||
"nav.free_orbit.help": "Maus und 3D-Maus drehen über beide Pole hinweg und behalten die Rollneigung bei. Das Einrasten mit Umschalt ist deaktiviert.",
|
||||
"nav.free_orbit.label": "Freie Drehung",
|
||||
"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.label": "Touchpad",
|
||||
"nav.touchpad.orbit": "Drehen (Alt/Option halten und bewegen; ohne Maustaste)",
|
||||
|
||||
@@ -253,6 +253,7 @@
|
||||
"Opens your browser and copies the report text for you to paste into GitHub.": "Opens your browser and copies the report text for you to paste into GitHub.",
|
||||
"Opens your browser. Review and submit your report on GitHub.": "Opens your browser. Review and submit your report on GitHub.",
|
||||
"Operator": "Operator",
|
||||
"Orbit (right drag)": "Orbit (right drag)",
|
||||
"Other side": "Other side",
|
||||
"Other surface (mutual trim)": "Other surface (mutual trim)",
|
||||
"Outside": "Outside",
|
||||
@@ -783,6 +784,7 @@
|
||||
"cmd.view_graphics_preset": "Graphics Preset",
|
||||
"cmd.view_list": "List Named Views",
|
||||
"cmd.view_navigate": "Navigate View",
|
||||
"cmd.view_normal_to": "Normal to Selection",
|
||||
"cmd.view_rename": "Rename Named View",
|
||||
"cmd.view_save": "New Named View",
|
||||
"color.copy": "Click to copy color values",
|
||||
@@ -957,6 +959,8 @@
|
||||
"motion.to_end": "To the end",
|
||||
"motion.to_start": "To the start",
|
||||
"motion.track_name": "Track {number}",
|
||||
"nav.free_orbit.help": "Mouse and 3D mouse rotate through both poles and preserve roll. Shift axis snapping is disabled.",
|
||||
"nav.free_orbit.label": "Free orbit",
|
||||
"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.label": "Touchpad",
|
||||
"nav.touchpad.orbit": "Orbit (hold Alt/Option and move; no mouse button)",
|
||||
|
||||
@@ -253,6 +253,7 @@
|
||||
"Opens your browser and copies the report text for you to paste into GitHub.": "ブラウザーを開き、GitHubに貼り付ける報告本文をコピーします。",
|
||||
"Opens your browser. Review and submit your report on GitHub.": "ブラウザーを開きます。GitHubで内容を確認して投稿してください。",
|
||||
"Operator": "演算子",
|
||||
"Orbit (right drag)": "回転(右ドラッグ)",
|
||||
"Other side": "反対側",
|
||||
"Other surface (mutual trim)": "相手のサーフェス(相互トリム)",
|
||||
"Outside": "外側",
|
||||
@@ -783,6 +784,7 @@
|
||||
"cmd.view_graphics_preset": "グラフィックスプリセット",
|
||||
"cmd.view_list": "名前付きビューを一覧表示",
|
||||
"cmd.view_navigate": "ビューナビゲーション",
|
||||
"cmd.view_normal_to": "選択アイテムに垂直",
|
||||
"cmd.view_rename": "名前付きビューの名前を変更",
|
||||
"cmd.view_save": "新規名前付きビュー",
|
||||
"color.copy": "クリックして色の値をコピー",
|
||||
@@ -957,6 +959,8 @@
|
||||
"motion.to_end": "最後へ",
|
||||
"motion.to_start": "最初へ",
|
||||
"motion.track_name": "トラック {number}",
|
||||
"nav.free_orbit.help": "マウスと3Dマウスで、極点を越えて回転し、ロールを保ちます。\nShiftによる軸スナップは無効になります。",
|
||||
"nav.free_orbit.label": "自由回転",
|
||||
"nav.touchpad.help": "Shift を押しながらポインターを動かすとパン、Alt/Option を押しながら動かすと回転します。マウスボタンは不要です。縦スクロールでズームします。",
|
||||
"nav.touchpad.label": "タッチパッド",
|
||||
"nav.touchpad.orbit": "回転(Alt/Option を押しながら移動、マウスボタン不要)",
|
||||
|
||||
@@ -243,6 +243,7 @@
|
||||
"Open local libraries in the desktop app. You can export this design's library here.": "로컬 라이브러리는 데스크톱 앱에서 여세요. 이 디자인의 라이브러리는 여기서 내보낼 수 있습니다.",
|
||||
"Open-source parametric 3D CAD in Rust. Licensed MIT OR Apache-2.0.": "Rust로 만든 오픈 소스 매개변수 3D CAD. 라이선스: MIT OR Apache-2.0.",
|
||||
"Operator": "연산자",
|
||||
"Orbit (right drag)": "회전 (오른쪽 드래그)",
|
||||
"Other side": "반대쪽",
|
||||
"Other surface (mutual trim)": "다른 곡면(상호 트림)",
|
||||
"Outside": "바깥쪽",
|
||||
@@ -759,6 +760,7 @@
|
||||
"cmd.view_graphics_preset": "그래픽 프리셋",
|
||||
"cmd.view_list": "이름 있는 보기 목록",
|
||||
"cmd.view_navigate": "보기 탐색",
|
||||
"cmd.view_normal_to": "선택 항목에 수직",
|
||||
"cmd.view_rename": "이름 있는 보기 이름 변경",
|
||||
"cmd.view_save": "새 이름 있는 보기",
|
||||
"color.copy": "클릭하여 색상 값 복사",
|
||||
@@ -927,6 +929,8 @@
|
||||
"motion.to_end": "끝으로",
|
||||
"motion.to_start": "처음으로",
|
||||
"motion.track_name": "트랙 {number}",
|
||||
"nav.free_orbit.help": "마우스와 3D 마우스로 롤을 유지하며 양 극점을 넘어 회전합니다. Shift 축 스냅은 비활성화됩니다.",
|
||||
"nav.free_orbit.label": "자유 회전",
|
||||
"nav.touchpad.help": "Shift를 누른 채 포인터를 움직이면 화면이 이동하고, Alt/Option을 누른 채 움직이면 회전합니다. 마우스 버튼은 필요 없습니다. 세로 스크롤로 확대/축소합니다.",
|
||||
"nav.touchpad.label": "터치패드",
|
||||
"nav.touchpad.orbit": "회전 (Alt/Option을 누르고 이동, 마우스 버튼 불필요)",
|
||||
|
||||
@@ -253,6 +253,7 @@
|
||||
"Opens your browser and copies the report text for you to paste into GitHub.": "打开浏览器,并复制报告文本以便粘贴到GitHub。",
|
||||
"Opens your browser. Review and submit your report on GitHub.": "打开浏览器。请在GitHub中检查并提交报告。",
|
||||
"Operator": "运算符",
|
||||
"Orbit (right drag)": "旋转(右键拖动)",
|
||||
"Other side": "另一侧",
|
||||
"Other surface (mutual trim)": "另一曲面(相互修剪)",
|
||||
"Outside": "外侧",
|
||||
@@ -783,6 +784,7 @@
|
||||
"cmd.view_graphics_preset": "图形预设",
|
||||
"cmd.view_list": "列出命名视图",
|
||||
"cmd.view_navigate": "导航视图",
|
||||
"cmd.view_normal_to": "垂直于所选面",
|
||||
"cmd.view_rename": "重命名命名视图",
|
||||
"cmd.view_save": "新建命名视图",
|
||||
"color.copy": "单击复制颜色值",
|
||||
@@ -957,6 +959,8 @@
|
||||
"motion.to_end": "到结尾",
|
||||
"motion.to_start": "到开头",
|
||||
"motion.track_name": "轨道 {number}",
|
||||
"nav.free_orbit.help": "鼠标和3D鼠标可保持滚转并越过两个极点旋转。Shift轴吸附将被禁用。",
|
||||
"nav.free_orbit.label": "自由旋转",
|
||||
"nav.touchpad.help": "按住 Shift 并移动指针可平移;按住 Alt/Option 并移动可旋转。无需按鼠标按钮。垂直滚动可缩放。",
|
||||
"nav.touchpad.label": "触控板",
|
||||
"nav.touchpad.orbit": "旋转(按住 Alt/Option 并移动,无需鼠标按钮)",
|
||||
|
||||
@@ -4,7 +4,7 @@ use solidcraft_render::{Camera, StandardView};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, num, req_vec2, req_vec3, vec3};
|
||||
use crate::{Result, Session};
|
||||
use crate::{Result, Sel, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("view.history_layout", "History layout", history_layout)
|
||||
@@ -17,7 +17,48 @@ CommandSpec::new("view.navigate", "Navigate View", navigate)
|
||||
.icon("orbit")
|
||||
.noundo()
|
||||
.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, degrees?: 0..180 (how far left|right|up|down turn; default 90); 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?")];
|
||||
.params("camera: Camera; direction?: [x,y,z] in the camera's up-axis frame; or action: left|right|up|down|clockwise|counterclockwise|home|fit, degrees?: 0..180 (how far left|right|up|down turn; default 90); or motion: pan|orbit|zoom, delta?: [dx,dy] (pixels, each ±10000), free_orbit?: bool (orbit through poles, default false), 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?"),
|
||||
CommandSpec::new("view.normal_to", "Normal to Selection", normal_to)
|
||||
.icon("camera")
|
||||
.noundo()
|
||||
.enabled(|s| normal_target(s).map(|_| ()).ok_or_else(|| "select a planar face or plane".into()))
|
||||
.params("camera?: Camera (default: current session camera or home view) → camera; Look At the first selected planar face or plane, centered on the picked point; repeat while aligned to flip the side; preserves scale and projection")];
|
||||
|
||||
fn normal_target(s: &Session) -> Option<(solidcraft_geom::Vec3, solidcraft_geom::Vec3)> {
|
||||
let state = s.world_state();
|
||||
s.selection.iter().find_map(|selection| match selection {
|
||||
Sel::Face { body, index, point } if point.is_finite() => {
|
||||
let plane = state.body(body)?.body.planar_face(*index)?;
|
||||
Some((*point, plane.normal()))
|
||||
}
|
||||
Sel::Plane { name } => solidcraft_geom::Plane::named(name).map(|plane| (plane.origin, plane.normal())),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn normal_to(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "view.normal_to";
|
||||
let (point, normal) = normal_target(s).ok_or_else(|| bad(cmd, "select a planar face or plane"))?;
|
||||
let cam: Camera = match p.get("camera") {
|
||||
Some(value) => serde_json::from_value(value.clone()).map_err(|_| bad(cmd, "invalid camera"))?,
|
||||
None => s.camera.unwrap_or_else(|| crate::view::home_camera(s)),
|
||||
};
|
||||
if !cam.is_valid()
|
||||
|| !cam.fov.is_finite()
|
||||
|| !(0.0..std::f64::consts::PI).contains(&cam.fov)
|
||||
|| cam.yaw.abs() > 1e6
|
||||
|| cam.roll.abs() > 1e6
|
||||
|| cam.pitch.abs() > std::f64::consts::FRAC_PI_2
|
||||
{
|
||||
return Err(bad(cmd, "invalid camera"));
|
||||
}
|
||||
// Promoted from viewport::look_at_selection: asking again from the normal side flips it.
|
||||
let direction = if cam.back().dot(normal) > 0.999 { -normal } else { normal };
|
||||
let mut to = cam.looking_from(direction);
|
||||
to.target = point;
|
||||
s.camera = Some(to);
|
||||
Ok(json!({"camera": to}))
|
||||
}
|
||||
|
||||
fn history_layout(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "view.history_layout";
|
||||
@@ -105,6 +146,10 @@ fn navigate(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// Direct manipulation is stateless, bounded and independent of document evaluation.
|
||||
fn continuous(mut cam: Camera, p: &Value) -> Result<Value> {
|
||||
let cmd = "view.navigate";
|
||||
let free_orbit = match p.get("free_orbit") {
|
||||
None => false,
|
||||
Some(value) => value.as_bool().ok_or_else(|| bad(cmd, "free_orbit must be a boolean"))?,
|
||||
};
|
||||
let viewport = || {
|
||||
let size = req_vec2(cmd, p, "viewport")?;
|
||||
if !(1.0..=1e6).contains(&size.x) || !(1.0..=1e6).contains(&size.y) {
|
||||
@@ -118,14 +163,23 @@ fn continuous(mut cam: Camera, p: &Value) -> Result<Value> {
|
||||
if delta.x.abs() > 1e4 || delta.y.abs() > 1e4 {
|
||||
return Err(bad(cmd, "motion deltas must be within ±10000 pixels"));
|
||||
}
|
||||
let orbit = |cam: &mut Camera| {
|
||||
if free_orbit {
|
||||
cam.rotate_in_view(solidcraft_geom::Vec3::new(-delta.y * 0.008, -delta.x * 0.008, 0.0));
|
||||
} else {
|
||||
cam.orbit(delta.x, delta.y);
|
||||
}
|
||||
};
|
||||
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")?;
|
||||
viewport()?;
|
||||
cam.orbit_about(delta.x, delta.y, pivot);
|
||||
let coords = cam.frame_coords(pivot);
|
||||
orbit(&mut cam);
|
||||
cam.place(pivot, coords);
|
||||
} else {
|
||||
cam.orbit(delta.x, delta.y);
|
||||
orbit(&mut cam);
|
||||
}
|
||||
}
|
||||
Some("zoom") => {
|
||||
@@ -165,6 +219,100 @@ mod tests {
|
||||
use solidcraft_geom::Vec3;
|
||||
use solidcraft_render::UpAxis;
|
||||
|
||||
#[test]
|
||||
fn look_at_planes_uses_the_first_supported_selection_and_flips_on_repeat() {
|
||||
let original = Camera { yaw: 0.3, pitch: 0.4, roll: 0.7, fov: 0.6, distance: 123.0, ..Default::default() };
|
||||
let mut s = Session {
|
||||
selection: vec![Sel::Plane { name: "missing".into() }, Sel::Plane { name: "XY".into() }, Sel::Plane { name: "YZ".into() }],
|
||||
camera: Some(original),
|
||||
..Default::default()
|
||||
};
|
||||
for normal in [Vec3::Z, -Vec3::Z, Vec3::Z] {
|
||||
let result = s.execute("view.normal_to", &json!({})).unwrap();
|
||||
let to: Camera = serde_json::from_value(result["camera"].clone()).unwrap();
|
||||
assert!(to.back().dist(normal) < 2e-6);
|
||||
assert_eq!((to.target, to.distance, to.fov), (Vec3::ZERO, original.distance, original.fov));
|
||||
}
|
||||
assert!(s.undo.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_to_selection_faces_each_planar_surface_without_editing_the_design() {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length":40,"width":30,"height":20})).unwrap();
|
||||
let state = s.world_state();
|
||||
let body = state.bodies.first().unwrap();
|
||||
let faces = body.body.faces(0.01).unwrap();
|
||||
let document = s.doc.to_json();
|
||||
let undo = s.undo.len();
|
||||
for up in [UpAxis::Z, UpAxis::Y] {
|
||||
for fov in [0.0, 0.6] {
|
||||
let cam = Camera { up, fov, roll: 1.2, distance: 123.0, ..Default::default() };
|
||||
for face in &faces {
|
||||
s.selection = vec![Sel::Face { body: body.name.clone(), index: face.index, point: face.centroid }];
|
||||
let selected = s.selection.clone();
|
||||
let result = s.execute("view.normal_to", &json!({"camera":cam})).unwrap();
|
||||
let to: Camera = serde_json::from_value(result["camera"].clone()).unwrap();
|
||||
assert!(to.back().dist(face.plane_normal.unwrap()) < 2e-6);
|
||||
assert_eq!(to.target, face.centroid);
|
||||
assert_eq!((to.distance, to.fov, to.up, to.roll), (cam.distance, cam.fov, cam.up, 0.0));
|
||||
assert_eq!(s.camera, Some(to));
|
||||
assert_eq!(s.selection, selected);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(s.doc.to_json(), document);
|
||||
assert_eq!(s.undo.len(), undo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_to_selection_rejects_curved_missing_and_invalid_inputs() {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.cylinder", &json!({"diameter":20,"height":30})).unwrap();
|
||||
let state = s.world_state();
|
||||
let body = state.bodies.first().unwrap();
|
||||
let faces = body.body.faces(0.1).unwrap();
|
||||
let flat = faces.iter().find(|f| f.plane_normal.is_some()).unwrap();
|
||||
let curved = faces.iter().find(|f| f.plane_normal.is_none()).unwrap();
|
||||
let selected = |index, point| Sel::Face { body: body.name.clone(), index, point };
|
||||
for selection in [vec![], vec![selected(curved.index, curved.centroid)], vec![selected(usize::MAX, Vec3::ZERO)]] {
|
||||
s.selection = selection;
|
||||
assert!(s.execute("view.normal_to", &json!({})).is_err());
|
||||
assert!(s.camera.is_none());
|
||||
}
|
||||
s.selection = vec![selected(flat.index, flat.centroid)];
|
||||
for camera in [
|
||||
json!({}),
|
||||
json!({"distance":0}),
|
||||
json!(Camera { distance: -1.0, ..Default::default() }),
|
||||
json!(Camera { fov: 4.0, ..Default::default() }),
|
||||
] {
|
||||
assert!(s.execute("view.normal_to", &json!({"camera":camera})).is_err());
|
||||
assert!(s.camera.is_none());
|
||||
}
|
||||
// Programmatic callers can use the current session view without supplying a camera.
|
||||
assert!(s.execute("view.normal_to", &json!({})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_to_selection_uses_the_world_normal_of_a_moved_component() {
|
||||
let mut s = Session::default();
|
||||
s.execute("component.create", &json!({"name":"Part"})).unwrap();
|
||||
s.execute("solid.box", &json!({"length":40,"width":30,"height":20})).unwrap();
|
||||
s.execute("occurrence.move", &json!({"occurrence":"Part:1","translate":[50,20,10],"axis":[1,2,3],"angle":"37 deg"})).unwrap();
|
||||
let state = s.world_state();
|
||||
let body = state.bodies.first().unwrap();
|
||||
let faces = body.body.faces(0.01).unwrap();
|
||||
let cam = Camera { roll: 1.4, ..Default::default() };
|
||||
for face in faces {
|
||||
s.selection = vec![Sel::Face { body: body.name.clone(), index: face.index, point: face.centroid }];
|
||||
let result = s.execute("view.normal_to", &json!({"camera":cam})).unwrap();
|
||||
let to: Camera = serde_json::from_value(result["camera"].clone()).unwrap();
|
||||
assert!(to.back().dist(face.plane_normal.unwrap()) < 2e-6);
|
||||
assert!(to.target.dist(face.centroid) < 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
/// #1541: left, right, up and down turn a quarter turn, or `degrees` (the arrow keys' 15°).
|
||||
#[test]
|
||||
fn turns_take_degrees_and_default_to_a_quarter_turn() {
|
||||
@@ -228,6 +376,53 @@ mod tests {
|
||||
assert!(s.undo.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continuous_free_orbit_is_opt_in_and_preserves_framing_and_document() {
|
||||
for up in [UpAxis::Z, UpAxis::Y] {
|
||||
let mut s = Session::default();
|
||||
let design = s.doc.to_json();
|
||||
let cam = Camera { pitch: 1.4, roll: 0.7, up, ..Default::default() };
|
||||
let mut p = json!({"camera":cam,"motion":"orbit","delta":[0,80]});
|
||||
let default = s.execute("view.navigate", &p).unwrap();
|
||||
p["free_orbit"] = json!(false);
|
||||
assert_eq!(s.execute("view.navigate", &p).unwrap(), default);
|
||||
let clamped: Camera = serde_json::from_value(default["camera"].clone()).unwrap();
|
||||
assert!(clamped.pitch > cam.pitch && clamped.pitch < std::f64::consts::FRAC_PI_2);
|
||||
p["free_orbit"] = json!(true);
|
||||
let result = s.execute("view.navigate", &p).unwrap();
|
||||
let free: Camera = serde_json::from_value(result["camera"].clone()).unwrap();
|
||||
assert!((free.back().dot(cam.back()) - 0.64_f64.cos()).abs() < 1e-8);
|
||||
assert_eq!((free.target, free.distance, free.fov), (cam.target, cam.distance, cam.fov));
|
||||
assert_eq!(s.doc.to_json(), design);
|
||||
assert!(s.undo.is_empty() && s.selection.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continuous_free_orbit_keeps_the_pivot_in_the_camera_frame() {
|
||||
for up in [UpAxis::Y, UpAxis::Z] {
|
||||
for fov in [0.0, 0.6] {
|
||||
let mut s = Session::default();
|
||||
let cam = Camera { target: Vec3::new(2.0, 3.0, 4.0), pitch: 1.4, roll: 0.7, fov, up, ..Default::default() };
|
||||
for pivot in [Vec3::ZERO, Vec3::new(10.0, 20.0, 30.0)] {
|
||||
let result = s
|
||||
.execute(
|
||||
"view.navigate",
|
||||
&json!({"camera":cam,"motion":"orbit","delta":[30,80],"free_orbit":true,"viewport":[800,600],"pivot":pivot}),
|
||||
)
|
||||
.unwrap();
|
||||
let to: Camera = serde_json::from_value(result["camera"].clone()).unwrap();
|
||||
assert!(to.frame_coords(pivot).dist(cam.frame_coords(pivot)) < 1e-8);
|
||||
assert!(((to.eye() - pivot).len() - (cam.eye() - pivot).len()).abs() < 1e-8);
|
||||
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);
|
||||
assert_eq!((to.distance, to.fov), (cam.distance, cam.fov));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continuous_perspective_orbit_keeps_near_and_far_pivots_on_screen() {
|
||||
let mut s = Session::default();
|
||||
@@ -257,6 +452,9 @@ mod tests {
|
||||
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":"orbit","delta":[1,2],"free_orbit":"true"}),
|
||||
json!({"motion":"orbit","delta":[1,2],"free_orbit":1}),
|
||||
json!({"motion":"orbit","delta":[1,2],"free_orbit":null}),
|
||||
json!({"motion":"zoom","factor":0}),
|
||||
json!({"motion":"zoom","factor":1e100}),
|
||||
json!({"motion":"zoom","factor":1,"anchor":[1e100,0,0]}),
|
||||
|
||||
@@ -185,7 +185,7 @@ pub struct Camera {
|
||||
pub target: Vec3,
|
||||
/// Rotation about Z; 0 = looking along +Y (from the front).
|
||||
pub yaw: f64,
|
||||
/// Elevation above the XY plane, in (−π/2, π/2].
|
||||
/// Elevation above the XY plane, in [−π/2, π/2]. Roll preserves orientation across poles.
|
||||
pub pitch: f64,
|
||||
/// Rotation about the viewing direction. Zero keeps the world's up axis vertical.
|
||||
#[serde(default)]
|
||||
@@ -341,6 +341,34 @@ impl Camera {
|
||||
self.roll = (self.roll + radians) % std::f64::consts::TAU;
|
||||
}
|
||||
}
|
||||
/// Rotate about the current screen right, up and back axes (radians), without a pole
|
||||
/// limit. Reconstruct both heading and roll so crossing a pole keeps the image continuous.
|
||||
pub fn rotate_in_view(&mut self, radians: Vec3) {
|
||||
if !self.is_valid() || !radians.is_finite() {
|
||||
return;
|
||||
}
|
||||
let angle = radians.x.hypot(radians.y).hypot(radians.z);
|
||||
if !(angle > 1e-12) || !angle.is_finite() {
|
||||
return;
|
||||
}
|
||||
let (right, up, back) = self.basis();
|
||||
let Some(axis) = (right * (radians.x / angle) + up * (radians.y / angle) + back * (radians.z / angle)).normalized() else { return };
|
||||
let (sin, cos) = angle.sin_cos();
|
||||
let rotate = |v: Vec3| v * cos + axis.cross(v) * sin + axis * (axis.dot(v) * (1.0 - cos));
|
||||
let rotated_right = rotate(right);
|
||||
let Some(rotated_back) = rotate(back).normalized() else { return };
|
||||
let framed = self.up.to_frame(rotated_back);
|
||||
let yaw = if framed.x.hypot(framed.y) < 1e-10 { self.yaw } else { (-framed.x).atan2(-framed.y) };
|
||||
let pitch = framed.z.clamp(-1.0, 1.0).asin();
|
||||
let base_right = self.up.from_frame(Vec3::new(yaw.cos(), -yaw.sin(), 0.0));
|
||||
let Some(base_up) = rotated_back.cross(base_right).normalized() else { return };
|
||||
let roll = rotated_right.dot(base_up).atan2(rotated_right.dot(base_right));
|
||||
if yaw.is_finite() && pitch.is_finite() && roll.is_finite() {
|
||||
self.yaw = yaw;
|
||||
self.pitch = pitch;
|
||||
self.roll = roll;
|
||||
}
|
||||
}
|
||||
pub fn set_view(&mut self, v: StandardView) {
|
||||
let lim = std::f64::consts::FRAC_PI_2 - 1e-6;
|
||||
let (y, p) = v.angles();
|
||||
@@ -432,6 +460,47 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screen_rotation_crosses_both_poles_and_returns_after_a_full_turn() {
|
||||
for up in [UpAxis::Z, UpAxis::Y] {
|
||||
for axis in [Vec3::X, Vec3::Y, Vec3::Z] {
|
||||
let mut cam = Camera { yaw: 0.0, pitch: 0.0, up, ..Default::default() };
|
||||
let initial = cam;
|
||||
let step = std::f64::consts::TAU / 256.0;
|
||||
for _ in 0..256 {
|
||||
let before = cam.basis();
|
||||
cam.rotate_in_view(axis * step);
|
||||
let after = cam.basis();
|
||||
assert!(cam.is_valid());
|
||||
// An X or Y turn moves the viewing direction every step, including at a pole.
|
||||
if axis != Vec3::Z {
|
||||
assert!((before.2.dot(after.2) - step.cos()).abs() < 1e-10);
|
||||
}
|
||||
assert!(before.0.dot(after.0) > 0.999 && before.1.dot(after.1) > 0.999);
|
||||
assert!(after.0.cross(after.1).dist(after.2) < 1e-10);
|
||||
}
|
||||
let (r, u, b) = initial.basis();
|
||||
let (rr, uu, bb) = cam.basis();
|
||||
assert!(r.dist(rr) < 1e-9 && u.dist(uu) < 1e-9 && b.dist(bb) < 1e-9, "{up:?} {axis:?}: {cam:?}");
|
||||
assert_eq!((cam.target, cam.distance, cam.fov, cam.up), (initial.target, initial.distance, initial.fov, initial.up));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screen_rotation_ignores_invalid_motion_and_reverses_combined_axes() {
|
||||
let initial = Camera { roll: 1.2, ..Default::default() };
|
||||
let mut cam = initial;
|
||||
for motion in [Vec3::ZERO, Vec3::new(f64::NAN, 0.0, 0.0), Vec3::new(0.0, f64::INFINITY, 0.0), Vec3::new(f64::MAX, f64::MAX, 0.0)] {
|
||||
cam.rotate_in_view(motion);
|
||||
assert_eq!(cam, initial);
|
||||
}
|
||||
let motion = Vec3::new(0.3, -0.4, 0.2);
|
||||
cam.rotate_in_view(motion);
|
||||
cam.rotate_in_view(-motion);
|
||||
assert!(cam.basis().0.dist(initial.basis().0) < 1e-12 && cam.back().dist(initial.back()) < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn front_view_projects_target_to_centre() {
|
||||
let mut c = Camera { target: Vec3::new(1.0, 2.0, 3.0), distance: 50.0, ..Default::default() };
|
||||
|
||||
@@ -497,6 +497,10 @@ fn viewport_items(app: &SolveApp) -> Vec<Item> {
|
||||
let face = sel.iter().find_map(|s| if let Sel::Face { body, .. } = s { Some(body.clone()) } else { None }).unwrap_or_default();
|
||||
let (feature, sketch) = feature_and_sketch(app, picked_face_feature(app));
|
||||
let hidden = app.session.visibility.hidden_bodies.contains(&face);
|
||||
if app.preferences.nav == "solidworks" {
|
||||
v.push(cmd(app, "view.normal_to", "Normal to Selection").with(json!({"camera": app.cam})));
|
||||
v.push(Item::sep());
|
||||
}
|
||||
v.push(cmd(app, "sketch.create", "Create Sketch"));
|
||||
v.push(cmd(app, "solid.extrude", "Extrude"));
|
||||
v.push(cmd(app, "construct.plane.offset", "Offset Plane"));
|
||||
@@ -1913,6 +1917,44 @@ pub fn finish_rename(app: &mut SolveApp, text: Option<&str>, commit: bool) -> bo
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn solidworks_face_menu_runs_normal_to_selection_and_rejects_curved_faces() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.app.run("solid.cylinder", json!({"diameter":30,"height":20})).unwrap();
|
||||
let state = h.app.session.world_state();
|
||||
let body = state.bodies.first().unwrap();
|
||||
let faces = body.body.faces(0.1).unwrap();
|
||||
let flat = faces.iter().find(|f| f.plane_normal.is_some()).unwrap();
|
||||
let curved = faces.iter().find(|f| f.plane_normal.is_none()).unwrap();
|
||||
h.app.session.selection = vec![Sel::Face { body: body.name.clone(), index: flat.index, point: flat.centroid }];
|
||||
assert!(items(&h.app, &Target::Viewport).iter().all(|i| i.id != "view.normal_to"));
|
||||
h.app.preferences.nav = "solidworks".into();
|
||||
h.app.preferences.language = "ja".into();
|
||||
h.settle();
|
||||
let before = h.app.cam;
|
||||
open_for(&mut h.app, pos2(800.0, 400.0), Target::Viewport);
|
||||
h.settle();
|
||||
assert!(items(&h.app, &Target::Viewport).iter().any(|i| i.id == "view.normal_to" && i.enabled));
|
||||
h.step(&json!({"click":{"handle":"menu:view.normal_to"}})).unwrap();
|
||||
h.frames(90);
|
||||
assert!(h.app.cam.back().dist(flat.plane_normal.unwrap()) < 2e-6);
|
||||
assert_eq!((h.app.cam.distance, h.app.cam.fov), (before.distance, before.fov));
|
||||
assert!(h.app.viewport.context_menu.is_none());
|
||||
assert_eq!(crate::i18n::Locale::for_language("ja").label("Normal to Selection"), "選択アイテムに垂直");
|
||||
// Ctrl+8 and the context menu share Look At, including its repeat-to-flip behavior.
|
||||
h.step(&json!({"key":"8","cmd":true})).unwrap();
|
||||
h.frames(90);
|
||||
assert!(h.app.cam.back().dist(-flat.plane_normal.unwrap()) < 2e-6);
|
||||
open_for(&mut h.app, pos2(800.0, 400.0), Target::Viewport);
|
||||
h.settle();
|
||||
h.step(&json!({"click":{"handle":"menu:view.normal_to"}})).unwrap();
|
||||
h.frames(90);
|
||||
assert!(h.app.cam.back().dist(flat.plane_normal.unwrap()) < 2e-6);
|
||||
h.app.session.selection = vec![Sel::Face { body: body.name.clone(), index: curved.index, point: curved.centroid }];
|
||||
assert!(items(&h.app, &Target::Viewport).iter().any(|i| i.id == "view.normal_to" && !i.enabled));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn viewport_right_click_keeps_list_menu_and_radial_is_opt_in() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
|
||||
@@ -868,6 +868,13 @@ impl SolveApp {
|
||||
if self.file_load.busy() || self.file_picker.is_some() {
|
||||
return;
|
||||
}
|
||||
if id == "view.normal_to" {
|
||||
if self.run(id, json!({"camera": self.cam})).is_ok() {
|
||||
self.follow_view_request(true);
|
||||
self.home.open = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// A disabled command starts nothing, however it was reached. The toolbar greys its
|
||||
// button, but a shortcut, the command palette and the control channel all land here.
|
||||
// Its reason goes to the status line, so the refusal is never silent.
|
||||
|
||||
@@ -276,6 +276,7 @@ pub(crate) fn camera_motion(app: &mut SolveApp, rect: Rect, mut params: serde_js
|
||||
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)]);
|
||||
params["free_orbit"] = json!(app.preferences.free_orbit);
|
||||
let _ = app.run("view.navigate", params);
|
||||
app.cam.fov = stored_fov;
|
||||
}
|
||||
@@ -550,7 +551,7 @@ pub(crate) fn handle(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect, resp: &e
|
||||
if pressed || key || dragging || motion.is_some() || !caps.orbit {
|
||||
app.viewport.spin = false;
|
||||
} else {
|
||||
app.cam.orbit(f64::from(dt.min(0.1)) * 30.0, 0.0);
|
||||
orbit_camera(app, f64::from(dt.min(0.1)) * 30.0, 0.0);
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
}
|
||||
@@ -570,13 +571,13 @@ 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);
|
||||
device_camera(&mut app.cam, queued.motion, queued.seconds, height, turn, app.preferences.zoom_reverse, app.preferences.free_orbit);
|
||||
}
|
||||
|
||||
/// `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) {
|
||||
pub(crate) fn device_camera(cam: &mut Camera, motion: Motion, seconds: f64, height: f64, turn: bool, reverse_zoom: bool, free_orbit: bool) {
|
||||
if !(seconds > 0.0) || !seconds.is_finite() {
|
||||
return;
|
||||
}
|
||||
@@ -591,8 +592,12 @@ pub(crate) fn device_camera(cam: &mut Camera, motion: Motion, seconds: f64, heig
|
||||
// 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);
|
||||
if free_orbit {
|
||||
cam.rotate_in_view(Vec3::new(-rx, -ry, rz) * (2.0 * seconds));
|
||||
} else {
|
||||
cam.orbit(ry * 250.0 * seconds, rx * 250.0 * seconds);
|
||||
cam.roll_by(rz * 2.0 * seconds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -618,6 +623,16 @@ pub(crate) fn snapped_axis_camera(cam: Camera) -> Camera {
|
||||
/// direction at once; the unsnapped orbit is kept in `orbit_free` so many small deltas still
|
||||
/// cross snap boundaries. A small angular margin avoids flicker at a boundary.
|
||||
pub(crate) fn orbit_drag(app: &mut SolveApp, rect: Rect, dx: f64, dy: f64, press: Option<Pos2>, snap: bool, around_cursor: bool) {
|
||||
// Free rotation must not enter the yaw/pitch snap: it discards roll at the poles.
|
||||
if app.preferences.free_orbit {
|
||||
app.viewport.orbit_free = None;
|
||||
if around_cursor {
|
||||
orbit(app, rect, dx, dy, press);
|
||||
} else {
|
||||
orbit_camera(app, dx, dy);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let displayed = app.cam;
|
||||
let previous_free = app.viewport.orbit_free.take();
|
||||
if let Some(free) = previous_free {
|
||||
@@ -706,6 +721,14 @@ pub fn orbit(app: &mut SolveApp, rect: egui::Rect, dx: f64, dy: f64, press: Opti
|
||||
}
|
||||
}),
|
||||
};
|
||||
if app.preferences.free_orbit {
|
||||
app.viewport.orbit_from = None;
|
||||
match pivot {
|
||||
Some(p) => orbit_about(app, rect, dx, dy, p),
|
||||
None => orbit_camera(app, dx, dy),
|
||||
}
|
||||
return;
|
||||
}
|
||||
// The drag's whole turn so far, applied to the camera it began with: the view depends on
|
||||
// where the pointer is, not on the path it took, so fast or erratic drags (a pitch held at
|
||||
// straight up or down for a while, many tiny steps) can't drift. Anything else that moves
|
||||
@@ -734,16 +757,293 @@ pub struct OrbitFrom {
|
||||
last: Camera,
|
||||
}
|
||||
|
||||
/// Rotate rigidly about the chosen pivot, keeping its camera-frame position unchanged.
|
||||
pub(crate) fn orbit_about(app: &mut SolveApp, _rect: egui::Rect, dx: f64, dy: f64, pivot: Vec3) {
|
||||
let coords = app.cam.frame_coords(pivot);
|
||||
orbit_camera(app, dx, dy);
|
||||
app.cam.place(pivot, coords);
|
||||
}
|
||||
|
||||
fn orbit_camera(app: &mut SolveApp, dx: f64, dy: f64) {
|
||||
if app.preferences.free_orbit {
|
||||
app.cam.rotate_in_view(Vec3::new(-dy * 0.008, -dx * 0.008, 0.0));
|
||||
} else {
|
||||
app.cam.orbit(dx, dy);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn clamped_drag_stays_reversible_and_restarts_after_free_orbit() {
|
||||
use super::*;
|
||||
let rect = Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
|
||||
for fov in [0.0, 0.6] {
|
||||
let mut app = crate::SolveApp::new(Default::default(), crate::Services::default());
|
||||
app.preferences.orbit_around = OrbitAround::Origin;
|
||||
app.cam = Camera { target: Vec3::new(2.0, 3.0, 4.0), pitch: 1.4, roll: 0.7, fov, ..Default::default() };
|
||||
let start = app.cam;
|
||||
for (dx, dy) in [(120.0, 400.0), (90.0, -200.0), (-210.0, -200.0)] {
|
||||
orbit(&mut app, rect, dx, dy, None);
|
||||
}
|
||||
assert!(app.cam.back().dist(start.back()) < 1e-9);
|
||||
assert!(app.cam.target.dist(start.target) < 1e-9);
|
||||
app.preferences.free_orbit = true;
|
||||
orbit(&mut app, rect, 0.0, 80.0, None);
|
||||
assert!((app.cam.back().dot(start.back()) - 0.64_f64.cos()).abs() < 1e-9);
|
||||
let mut next = app.cam;
|
||||
next.orbit_about(10.0, 20.0, Vec3::ZERO);
|
||||
app.preferences.free_orbit = false;
|
||||
orbit(&mut app, rect, 10.0, 20.0, None);
|
||||
assert!(app.cam.back().dist(next.back()) < 1e-9);
|
||||
assert!(app.cam.target.dist(next.target) < 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
fn touchpad_frame(app: &mut crate::SolveApp, ctx: &egui::Context, caps: super::NavCaps, at: egui::Pos2, mods: egui::Modifiers, time: &mut f64) {
|
||||
*time += 1.0 / 60.0;
|
||||
let raw = egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 1400.0))),
|
||||
time: Some(*time),
|
||||
focused: true,
|
||||
events: vec![egui::Event::ModifiersChanged(mods), egui::Event::PointerMoved(at)],
|
||||
..Default::default()
|
||||
};
|
||||
let mut out = ctx.run_ui(raw, |ui| {
|
||||
let rect = ui.max_rect();
|
||||
app.viewport.rect = Some(rect);
|
||||
let response = ui.interact(rect, ui.id().with("touchpad-setting"), egui::Sense::click_and_drag());
|
||||
super::handle(app, ui, rect, &response, caps);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn touchpad_free_orbit_setting_reaches_the_shared_command() {
|
||||
use super::*;
|
||||
for caps in [NavCaps::MODEL, NavCaps::RENDER, NavCaps { orbit: false, pick: false }] {
|
||||
for up in [solidcraft_engine::render::UpAxis::Y, solidcraft_engine::render::UpAxis::Z] {
|
||||
for free in [false, true] {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = crate::SolveApp::new(Default::default(), crate::Services::default());
|
||||
app.preferences.nav = crate::prefs::TOUCHPAD_NAV.into();
|
||||
app.preferences.free_orbit = free;
|
||||
app.ui.up_axis = up;
|
||||
app.cam = Camera { pitch: 1.4, roll: 0.7, up, ..Default::default() };
|
||||
let before = app.cam;
|
||||
let design = app.session.doc.to_json();
|
||||
let mut time = 0.0;
|
||||
touchpad_frame(&mut app, &ctx, caps, pos2(400.0, 300.0), egui::Modifiers::NONE, &mut time);
|
||||
touchpad_frame(&mut app, &ctx, caps, pos2(400.0, 300.0), egui::Modifiers::ALT, &mut time);
|
||||
touchpad_frame(&mut app, &ctx, caps, pos2(400.0, 380.0), egui::Modifiers::ALT, &mut time);
|
||||
if !caps.orbit {
|
||||
assert_eq!(app.cam, before);
|
||||
} else if free {
|
||||
let expected = before.back() * 0.64_f64.cos() + before.basis().1 * 0.64_f64.sin();
|
||||
assert!(app.cam.back().dist(expected) < 1e-8, "{caps:?} {up:?}: {:?}", app.cam);
|
||||
} else {
|
||||
assert!(app.cam.pitch > before.pitch && app.cam.pitch < std::f64::consts::FRAC_PI_2);
|
||||
}
|
||||
assert_eq!((app.cam.target, app.cam.distance, app.cam.fov), (before.target, before.distance, before.fov));
|
||||
assert!(app.viewport.orbit_free.is_none());
|
||||
assert!(!ctx.input(|i| i.pointer.any_down()));
|
||||
assert_eq!(app.session.doc.to_json(), design);
|
||||
assert!(app.session.undo.is_empty() && app.session.selection.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn touchpad_free_orbit_keeps_the_rigid_pivot_in_both_projections() {
|
||||
use super::*;
|
||||
for up in [solidcraft_engine::render::UpAxis::Y, solidcraft_engine::render::UpAxis::Z] {
|
||||
for fov in [0.0, 0.6] {
|
||||
for around in [OrbitAround::Origin, OrbitAround::Cursor] {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = crate::SolveApp::new(Default::default(), crate::Services::default());
|
||||
app.preferences.nav = crate::prefs::TOUCHPAD_NAV.into();
|
||||
app.preferences.free_orbit = true;
|
||||
app.preferences.orbit_around = around;
|
||||
app.ui.perspective = fov > 0.0;
|
||||
app.ui.up_axis = up;
|
||||
app.cam = Camera { target: Vec3::new(2.0, 3.0, 4.0), pitch: 1.4, roll: 0.7, fov, up, ..Default::default() };
|
||||
let caps = NavCaps::MODEL;
|
||||
let mut time = 0.0;
|
||||
touchpad_frame(&mut app, &ctx, caps, pos2(400.0, 300.0), egui::Modifiers::NONE, &mut time);
|
||||
touchpad_frame(&mut app, &ctx, caps, pos2(400.0, 300.0), egui::Modifiers::ALT, &mut time);
|
||||
let pivot = if around == OrbitAround::Origin { Vec3::ZERO } else { Vec3::new(10.0, 20.0, 30.0) };
|
||||
app.viewport.orbit_pivot = Some(pivot);
|
||||
let rect = app.viewport.rect.unwrap();
|
||||
let coords = app.cam.frame_coords(pivot);
|
||||
let pixel = projection(&app, rect).to_screen(pivot).unwrap();
|
||||
touchpad_frame(&mut app, &ctx, caps, pos2(400.0, 380.0), egui::Modifiers::ALT, &mut time);
|
||||
assert!(app.cam.frame_coords(pivot).dist(coords) < 1e-8, "{up:?} {fov} {around:?}");
|
||||
assert!(projection(&app, rect).to_screen(pivot).unwrap().distance(pixel) < 1e-3);
|
||||
assert_eq!((app.cam.distance, app.cam.fov), (Camera::default().distance, fov));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn touchpad_free_orbit_completes_a_turn_and_stops_on_release() {
|
||||
use super::*;
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = crate::SolveApp::new(Default::default(), crate::Services::default());
|
||||
app.preferences.nav = crate::prefs::TOUCHPAD_NAV.into();
|
||||
app.preferences.free_orbit = true;
|
||||
app.cam = Camera { pitch: 1.4, roll: 0.7, ..Default::default() };
|
||||
let initial = app.cam;
|
||||
let design = app.session.doc.to_json();
|
||||
let log = app.session.log.len();
|
||||
let mut time = 0.0;
|
||||
let caps = NavCaps::MODEL;
|
||||
touchpad_frame(&mut app, &ctx, caps, pos2(400.0, 300.0), egui::Modifiers::NONE, &mut time);
|
||||
touchpad_frame(&mut app, &ctx, caps, pos2(400.0, 300.0), egui::Modifiers::ALT, &mut time);
|
||||
let mut at = pos2(400.0, 300.0);
|
||||
for step in 1..=128 {
|
||||
at.y = 300.0 + (std::f64::consts::TAU * step as f64 / (128.0 * 0.008)) as f32;
|
||||
touchpad_frame(&mut app, &ctx, caps, at, egui::Modifiers::ALT, &mut time);
|
||||
}
|
||||
assert!(app.cam.back().dist(initial.back()) < 1e-6);
|
||||
assert!(app.cam.basis().0.dist(initial.basis().0) < 1e-6);
|
||||
let stopped = app.cam;
|
||||
for _ in 0..60 {
|
||||
touchpad_frame(&mut app, &ctx, caps, at, egui::Modifiers::NONE, &mut time);
|
||||
}
|
||||
assert_eq!(app.cam, stopped);
|
||||
assert_eq!(app.session.doc.to_json(), design);
|
||||
assert_eq!(app.session.log.len(), log);
|
||||
assert!(app.session.undo.is_empty() && app.session.selection.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_orbit_is_opt_in_for_every_preset_and_shared_by_mouse_and_device() {
|
||||
use super::*;
|
||||
use crate::space_mouse::DeviceMotion;
|
||||
let rect = Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
|
||||
let initial = Camera { yaw: 0.0, pitch: 1.4, ..Default::default() };
|
||||
for preset in crate::prefs::NAV_PRESETS {
|
||||
for enabled in [false, true] {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.preferences.nav = preset.id.into();
|
||||
h.app.preferences.free_orbit = enabled;
|
||||
h.app.cam = initial;
|
||||
orbit_drag(&mut h.app, rect, 0.0, 80.0, None, true, false);
|
||||
if enabled {
|
||||
assert!((h.app.cam.back().dot(initial.back()) - 0.64_f64.cos()).abs() < 1e-9);
|
||||
assert!(h.app.viewport.orbit_free.is_none());
|
||||
} else {
|
||||
assert!(h.app.cam.pitch < std::f64::consts::FRAC_PI_2);
|
||||
assert!(h.app.viewport.orbit_free.is_some(), "{} keeps Shift snap", preset.id);
|
||||
}
|
||||
h.app.cam = initial;
|
||||
h.app.viewport.device_motion =
|
||||
Some(DeviceMotion { motion: Motion { translation: [0.0; 3], rotation: [1.0, 0.0, 0.0] }, seconds: 0.32 });
|
||||
device(&mut h.app, rect, NavCaps::MODEL);
|
||||
if enabled {
|
||||
assert!((h.app.cam.back().dot(initial.back()) - 0.64_f64.cos()).abs() < 1e-9);
|
||||
} else {
|
||||
assert!(h.app.cam.pitch > initial.pitch && h.app.cam.pitch < std::f64::consts::FRAC_PI_2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solidworks_mouse_orbit_passes_the_pole_without_snapping() {
|
||||
use serde_json::json;
|
||||
for up in [solidcraft_engine::render::UpAxis::Z, solidcraft_engine::render::UpAxis::Y] {
|
||||
for button in ["middle", "right"] {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.app.preferences.nav = "solidworks".into();
|
||||
h.app.preferences.free_orbit = true;
|
||||
h.app.ui.up_axis = up;
|
||||
h.app.cam = solidcraft_engine::render::Camera { yaw: 0.0, pitch: 1.4, up, ..Default::default() };
|
||||
let before = h.app.cam;
|
||||
h.call("ui.drag", json!({"x0":800,"y0":400,"x1":800,"y1":480,"button":button,"steps":40}));
|
||||
assert!((h.app.cam.back().dot(before.back()) - 0.64_f64.cos()).abs() < 1e-6, "{up:?} {button}: {:?}", h.app.cam);
|
||||
assert!(h.app.viewport.orbit_free.is_none());
|
||||
let before = h.app.cam;
|
||||
h.call("ui.drag", json!({"x0":800,"y0":400,"x1":850,"y1":400,"button":"right","shift":true,"steps":20}));
|
||||
assert!((h.app.cam.back().dot(before.back()) - 0.4_f64.cos()).abs() < 1e-6);
|
||||
assert!(h.app.viewport.orbit_free.is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solidworks_device_and_mouse_keep_turning_with_the_same_screen_axes() {
|
||||
use super::*;
|
||||
use crate::space_mouse::DeviceMotion;
|
||||
for caps in [NavCaps::MODEL, NavCaps::RENDER] {
|
||||
for rotation in [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]] {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.preferences.nav = "solidworks".into();
|
||||
h.app.preferences.free_orbit = true;
|
||||
let rect = Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
|
||||
let initial = Camera { yaw: 0.0, pitch: 0.0, roll: 0.7, ..Default::default() };
|
||||
h.app.cam = initial;
|
||||
let mut mouse = initial;
|
||||
let seconds = std::f64::consts::TAU / 512.0;
|
||||
for _ in 0..256 {
|
||||
let before = h.app.cam;
|
||||
h.app.viewport.device_motion = Some(DeviceMotion { motion: Motion { translation: [0.0; 3], rotation }, seconds });
|
||||
device(&mut h.app, rect, caps);
|
||||
assert!((before.back().dot(h.app.cam.back()) - (seconds * 2.0).cos()).abs() < 1e-9);
|
||||
let device_cam = h.app.cam;
|
||||
h.app.cam = mouse;
|
||||
orbit_drag(&mut h.app, rect, rotation[1] * seconds * 250.0, rotation[0] * seconds * 250.0, None, true, false);
|
||||
mouse = h.app.cam;
|
||||
assert!(mouse.back().dist(device_cam.back()) < 1e-9 && mouse.basis().0.dist(device_cam.basis().0) < 1e-9);
|
||||
h.app.cam = device_cam;
|
||||
}
|
||||
assert!(h.app.cam.back().dist(initial.back()) < 1e-8 && h.app.cam.basis().0.dist(initial.basis().0) < 1e-8);
|
||||
}
|
||||
}
|
||||
// Other presets keep the existing upright camera behavior.
|
||||
let mut cam = Camera { yaw: 0.0, pitch: 1.5, ..Default::default() };
|
||||
device_camera(&mut cam, Motion { translation: [0.0; 3], rotation: [1.0, 0.0, 0.0] }, 0.1, 600.0, true, false, false);
|
||||
assert!(cam.pitch < std::f64::consts::FRAC_PI_2 && cam.pitch > 1.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solidworks_free_orbit_keeps_the_configured_pivot_on_screen() {
|
||||
use super::*;
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.preferences.nav = "solidworks".into();
|
||||
h.app.preferences.free_orbit = true;
|
||||
let rect = Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
|
||||
for fov in [0.0, 0.6] {
|
||||
h.app.ui.perspective = fov > 0.0;
|
||||
for pivot in [Vec3::ZERO, Vec3::new(10.0, 20.0, 30.0), Vec3::new(10.0, -20.0, -30.0)] {
|
||||
h.app.cam = Camera { target: Vec3::new(2.0, 3.0, 4.0), yaw: 0.0, pitch: 1.5, roll: 0.7, fov, ..Default::default() };
|
||||
let before = projection(&h.app, rect).to_screen(pivot).unwrap();
|
||||
orbit_about(&mut h.app, rect, 30.0, 50.0, pivot);
|
||||
let after = projection(&h.app, rect).to_screen(pivot).unwrap();
|
||||
assert!(before.distance(after) < 1e-3, "{fov} {pivot:?}: {before:?} -> {after:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 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("];
|
||||
const PATTERNS: [&str; 8] = [
|
||||
"smooth_scroll_delta",
|
||||
"raw_scroll_delta",
|
||||
"zoom_delta()",
|
||||
"cam.orbit(",
|
||||
"cam.pan(",
|
||||
"cam.zoom_at(",
|
||||
"cam.roll_by(",
|
||||
"cam.rotate_in_view(",
|
||||
];
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let dirs = [root.join("src"), root.join("../../apps/solidcraft/src"), root.join("../../apps/solidcraft-web/src")];
|
||||
let allowed = ["navigation.rs"];
|
||||
|
||||
@@ -185,7 +185,7 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
if let Some(face) = hovered.and_then(|i| faces.get(i)) {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||
ui.interact(Rect::from_center_size(hover.unwrap_or(face.centre), vec2(2.0, 2.0)), ui.id().with("navigation_cube_tip"), Sense::hover())
|
||||
.on_hover_text(format!("{} view. Drag to orbit; hold Shift to snap.", face.label));
|
||||
.on_hover_text(format!("{} view. Drag to orbit{}.", face.label, if app.preferences.free_orbit { "" } else { "; hold Shift to snap" }));
|
||||
if ui.input(|i| i.pointer.primary_clicked()) {
|
||||
navigate(app, None, Some(face.direction));
|
||||
}
|
||||
|
||||
@@ -378,6 +378,9 @@ preference_settings! {
|
||||
default_units: String = "mm".into(), 0;
|
||||
/// Mouse mapping: `fusion`, `solidworks` or `inventor`.
|
||||
nav: String = "fusion".into(), 1;
|
||||
/// Opt in to rotation through both poles with roll, without the Shift axis-snap spring.
|
||||
/// Independent of mouse bindings; false also preserves existing saved presets' behavior.
|
||||
free_orbit: bool = false, 1 => check_tr("nav.free_orbit.label");
|
||||
/// View orientation widget: original signed axes (default) or compact cube.
|
||||
navigator_style: NavigatorStyle = NavigatorStyle::default(), 1;
|
||||
/// Show the radial ring and enable hold/flick gestures for viewport context menus.
|
||||
@@ -1208,13 +1211,20 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
if app.preferences.nav == TOUCHPAD_NAV {
|
||||
ui_kit::text(ui, crate::i18n::tr("nav.touchpad.help"), ui_kit::Text::Note("navigation"));
|
||||
} else {
|
||||
let snap_help = if app.preferences.nav == OPENSCAD_NAV { "" } else { "; hold Shift during orbit to snap" };
|
||||
let snap_help =
|
||||
if app.preferences.free_orbit || 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}; hold Space to pan; the wheel zooms",
|
||||
nav_preset(&app.preferences.nav).help
|
||||
);
|
||||
ui_kit::text(ui, crate::i18n::tr_label(&raw_help), ui_kit::Text::Note("navigation"));
|
||||
}
|
||||
let free_orbit = setting::free_orbit(app, ui);
|
||||
crate::scenario::publish_rect("prefs:free_orbit", free_orbit.rect);
|
||||
crate::scenario::publish_handle("prefs:free_orbit", free_orbit.rect.center());
|
||||
if app.preferences.free_orbit {
|
||||
ui_kit::text(ui, crate::i18n::tr("nav.free_orbit.help"), ui_kit::Text::Note("free_orbit"));
|
||||
}
|
||||
setting::radial_context_menu(app, ui);
|
||||
crate::mouse_gesture::nav_rows(app, ui, t);
|
||||
ui_kit::row(ui, app.tr_label("View navigator"), |ui| {
|
||||
@@ -2179,6 +2189,26 @@ mod tests {
|
||||
assert_eq!(h.app.session.doc.to_json(), design);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_orbit_defaults_off_and_follows_preferences_transactions() {
|
||||
let old: Prefs = serde_json::from_value(json!({"nav":"solidworks"})).unwrap();
|
||||
assert!(!old.free_orbit);
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.call("ui.prefs", json!({"open":"Navigation"}));
|
||||
h.step(&json!({"click":{"handle":"prefs:free_orbit"}})).unwrap();
|
||||
assert!(h.app.preferences.free_orbit);
|
||||
let mut restored = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
|
||||
restored.load_prefs(&h.app.prefs());
|
||||
assert!(restored.preferences.free_orbit);
|
||||
h.call("ui.prefs", json!({"button":"cancel"}));
|
||||
assert!(!h.app.preferences.free_orbit);
|
||||
h.call("ui.prefs", json!({"open":"Navigation","free_orbit":true,"button":"apply"}));
|
||||
h.call("ui.prefs", json!({"button":"defaults"}));
|
||||
assert!(!h.app.preferences.free_orbit);
|
||||
h.call("ui.prefs", json!({"button":"cancel"}));
|
||||
assert!(h.app.preferences.free_orbit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radial_context_menu_defaults_off_and_follows_preferences_transactions() {
|
||||
assert!(!Prefs::default().radial_context_menu);
|
||||
|
||||
@@ -306,7 +306,7 @@ mod tests {
|
||||
/// 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);
|
||||
crate::navigation::device_camera(cam, motion, seconds, height, !sketch, reverse_zoom, false);
|
||||
}
|
||||
|
||||
/// A window that has run a frame, with or without focus. A context that has never run one
|
||||
@@ -352,6 +352,41 @@ mod tests {
|
||||
assert_eq!(off.apply(PUSH), still);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solidworks_free_orbit_respects_per_axis_settings_through_both_poles() {
|
||||
let ctx = window(true);
|
||||
let initial = Camera { yaw: 0.0, pitch: 1.4, roll: 0.0, ..Default::default() };
|
||||
let axes = Axes { translation: [0; 3], rotation: [350, 0, 0] };
|
||||
let step = std::f64::consts::TAU / 128.0;
|
||||
for mapping in [DriverMapping::default(), DriverMapping::object_mode()] {
|
||||
for reversed in [false, true] {
|
||||
for speed in [0.5, 2.0] {
|
||||
let mut app = app();
|
||||
app.preferences.nav = "solidworks".into();
|
||||
app.preferences.free_orbit = true;
|
||||
app.preferences.space_mouse_reverse[3] = reversed;
|
||||
app.preferences.space_mouse_axis_speed[3] = speed;
|
||||
app.cam = initial;
|
||||
for i in 0..128 {
|
||||
let before = app.cam;
|
||||
assert!(frame(&mut app, &ctx, mapping, axes, step / (2.0 * f64::from(speed))));
|
||||
assert!((before.back().dot(app.cam.back()) - step.cos()).abs() < 1e-10);
|
||||
if i == 0 {
|
||||
let direction = if reversed { -1.0 } else { 1.0 };
|
||||
assert!((app.cam.back().dot(initial.basis().1) - direction * step.sin()).abs() < 1e-10);
|
||||
}
|
||||
}
|
||||
assert!(app.cam.back().dist(initial.back()) < 1e-9 && app.cam.basis().0.dist(initial.basis().0) < 1e-9);
|
||||
assert_eq!((app.cam.target, app.cam.distance, app.cam.fov), (initial.target, initial.distance, initial.fov));
|
||||
app.preferences.space_mouse_axis_speed[3] = 0.0;
|
||||
let before = app.cam;
|
||||
frame(&mut app, &ctx, mapping, axes, 0.04);
|
||||
assert_eq!(app.cam, before);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const PUSH: Axes = Axes { translation: [0, 350, 0], rotation: [0; 3] };
|
||||
|
||||
fn wheel(dy: f32) -> egui::Event {
|
||||
|
||||
@@ -3737,8 +3737,13 @@ fn signed_axis_view(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
&& let Some((name, d, _, _)) = tips.get(i)
|
||||
{
|
||||
crate::scenario::publish_handle(&format!("axis_view_hover:{name}"), hover.unwrap_or(c));
|
||||
ui.interact(Rect::from_center_size(hover.unwrap_or(c), vec2(2.0, 2.0)), ui.id().with("axis_view_tip"), Sense::hover())
|
||||
.on_hover_text(format!("View from {}. Click again to reverse. Drag to orbit; hold Shift to snap.", name.to_uppercase()));
|
||||
ui.interact(Rect::from_center_size(hover.unwrap_or(c), vec2(2.0, 2.0)), ui.id().with("axis_view_tip"), Sense::hover()).on_hover_text(
|
||||
format!(
|
||||
"View from {}. Click again to reverse. Drag to orbit{}.",
|
||||
name.to_uppercase(),
|
||||
if app.preferences.free_orbit { "" } else { "; hold Shift to snap" }
|
||||
),
|
||||
);
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||
if ui.input(|i| i.pointer.primary_clicked()) {
|
||||
let frame = app.cam.to_frame(*d);
|
||||
@@ -3793,18 +3798,10 @@ pub(crate) fn nearest_quarter_turn(yaw: f64) -> f64 {
|
||||
/// Turn the view to face the selected planar face or plane, else the active sketch; asked again
|
||||
/// while it already faces it, from the other side (#1886).
|
||||
pub fn look_at_selection(app: &mut SolveApp) {
|
||||
let target = app.session.selection.iter().find_map(|s| match s {
|
||||
Sel::Face { body, index, point } => crate::dialogs::planar_face(&app.session, body, *index).map(|(_, n)| (*point, n)),
|
||||
Sel::Plane { name } => solidcraft_engine::geom::Plane::named(name).map(|p| (p.origin, p.normal())),
|
||||
_ => None,
|
||||
});
|
||||
match target {
|
||||
Some((p, n)) => {
|
||||
let mut to = app.cam.looking_from(crate::dialogs::facing(app, n, true));
|
||||
to.target = p;
|
||||
app.animate_to(to);
|
||||
}
|
||||
None => crate::dialogs::look_at_sketch_again(app),
|
||||
if solidcraft_engine::find_command("view.normal_to").is_some_and(|command| command.info(&app.session).disabled_reason.is_none()) {
|
||||
app.start("view.normal_to");
|
||||
} else {
|
||||
crate::dialogs::look_at_sketch_again(app);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3820,6 +3817,8 @@ fn nav_bar(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
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 if app.preferences.free_orbit {
|
||||
"Orbit (right drag)"
|
||||
} else {
|
||||
"Orbit (right drag; hold Shift to snap)"
|
||||
},
|
||||
|
||||
@@ -34,6 +34,12 @@
|
||||
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.
|
||||
|
||||
Enable **Preferences → Navigation → Free orbit** to rotate the mouse and 3D mouse continuously
|
||||
through both poles, preserving roll without axis snapping. It defaults to off for every mouse
|
||||
preset. With SolidWorks navigation, right-click a planar face and choose **Normal to Selection**
|
||||
to use Look At (also Ctrl+8); repeat to flip the side, preserving scale and projection.
|
||||
|
||||
**Mouse gestures** (Preferences › Navigation, off by default) work with the SolidWorks mouse preset
|
||||
only (the wheel orbits, Ctrl + wheel pans) and take over the right button, which then no longer
|
||||
orbits: hold it and move the pointer 4 pt to open a ring of tools (280 pt inside, 350 pt outside), even
|
||||
|
||||
Reference in New Issue
Block a user