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:
cronos
2026-10-21 23:29:29 +00:00
committed by GitHub
parent ba4f7864ff
commit dfd58293a6
16 changed files with 740 additions and 29 deletions
+5
View File
@@ -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.
+4
View File
@@ -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)",
+4
View File
@@ -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)",
+4
View File
@@ -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 を押しながら移動、マウスボタン不要)",
+4
View File
@@ -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을 누르고 이동, 마우스 버튼 불필요)",
+4
View File
@@ -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 并移动,无需鼠标按钮)",
+202 -4
View File
@@ -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]}),
+70 -1
View File
@@ -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() };
+42
View File
@@ -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();
+7
View File
@@ -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.
+307 -7
View File
@@ -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"];
+1 -1
View File
@@ -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));
}
+31 -1
View File
@@ -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);
+36 -1
View File
@@ -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 {
+13 -14
View File
@@ -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)"
},
+6
View File
@@ -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