diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 0b722ca2..494582f2 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -10,7 +10,7 @@ third-party material under an open licence. Screenshots of Autodesk software are Generated-in-code assets are original and have no file to list: - the UI icon set (`crates/ui-egui/src/icons.rs`); -- the view cube, navigation bar and timeline glyphs (`crates/ui-egui/src/viewport.rs`, +- the axis view, navigation bar and timeline glyphs (`crates/ui-egui/src/viewport.rs`, `crates/ui-egui/src/timeline.rs`). ## Files @@ -46,7 +46,7 @@ Generated-in-code assets are original and have no file to list: | SolveCraft file | Original contribution | Licence | |---|---|---| -| crates/ui-egui/src/viewport.rs (`nearest_quarter_turn`, `view_cube_orbit`) | N3MI-DG's [PR #101](https://github.com/bherbruck/solvecraft/pull/101): [minimal TOP/BOTTOM turns](https://github.com/bherbruck/solvecraft/commit/c28e780f52490784ef5886f094b9d3f6d10c3e3f) and [drag-to-orbit](https://github.com/bherbruck/solvecraft/commit/f30b30be6ecc4e2e6544a851243137c01a354934), adapted to the 14-target cube | MIT OR Apache-2.0 | +| crates/ui-egui/src/viewport.rs (`nearest_quarter_turn`, `axis_view_orbit`) | N3MI-DG's [PR #101](https://github.com/bherbruck/solvecraft/pull/101): [minimal TOP/BOTTOM turns](https://github.com/bherbruck/solvecraft/commit/c28e780f52490784ef5886f094b9d3f6d10c3e3f) and [drag-to-orbit](https://github.com/bherbruck/solvecraft/commit/f30b30be6ecc4e2e6544a851243137c01a354934), adapted to the axis gizmo | MIT OR Apache-2.0 | ## Vendored third-party code diff --git a/README.md b/README.md index e72cbe63..51b0dbbd 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ and help is welcome: see [CONTRIBUTING.md](CONTRIBUTING.md). that later features build on — open a `.step`/`.stp`/`.igs`/`.iges` file, insert one into a design, or drop it on the window ([docs/step-import.md](docs/step-import.md)). - **Desktop app** (egui + wgpu): toolbar with workspace tabs, browser, timeline, 3D viewport with - view cube and navigation bar, sketch tools, feature dialogs, command palette, and a JSON-lines + axis view and navigation bar, sketch tools, feature dialogs, command palette, and a JSON-lines control channel so agents and tests can drive it. - **Headless CLI** `solvecraft-cli`: run command scripts, measure (volume, area, centre of mass, face/edge/vertex counts), export, render PNG snapshots, replay Fusion oracle recipes. diff --git a/ROADMAP.md b/ROADMAP.md index 8df8b640..520f2074 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -61,6 +61,13 @@ headline counts in-scope tabs only and lists the deferred ones separately. | M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Per-row body materials and appearances (library looks, custom colour/opacity and clearing overrides), undoable activation and save/reopen: done. Ordered typed configuration rules, atomic validation and structured rule editing: done. Component appearance and certified semantic face style columns, atomic preview/apply and independent-copy remapping: done. Exact constructed sketch-side and immutable imported-face lineage now preserve legacy names and reject retargeting. Left: ambiguous shifted/two-sided caps | in progress | 4 | | M13 | Never lose work: atomic saves (temporary file, flush, rename; a crash at any moment leaves the old file or the new one) with the replaced version kept as `.bak`; autosave of unsaved changes to the recovery folder every 5 minutes (preference) and after big operations, only when something changed; "Recover unsaved design?" on the next launch (`doc.recovery_list`, `doc.recover`, `doc.recovery_discard`); a kill-mid-save test (process killed while saving a 0.5 MB design 25 times). File format: `"format": "solvecraft/11"`, older formats upgraded step by step (`doc::format`), newer ones refused with a message, out-of-range numbers and broken placements refused; a corpus of designs saved by M0, components-era, joints-era and current builds (apps/solvecraft-cli/tests/corpus) that must open and evaluate as saved; damaged and hostile variants (truncation, bit flips, hostile values, removed keys, repeated features, deep nesting) never panic | done | 6 | +## View navigation + +- Compact clickable signed X/Y/Z axis tips, smooth drag-to-orbit, and Shift-orbit snapping + to 26 standard and diagonal views. Pointer motion accumulates independently of a lightly + damped spring, with a boundary margin to prevent flicker. Z-up/Y-up and the nearest square + top/bottom heading are preserved. Diagonals include 45-degree edge and isometric corner views. + ## Known limitations - Fillets and chamfers: constant, variable-radius and chord-length fillets; a radius per edge, diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 72ed25d1..a2981b01 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -469,7 +469,7 @@ fn screenshot(b: &mut dyn Backend, a: &Map) -> Value { fn capture(b: &mut dyn Backend, a: &Map) -> Result<(Vec, Value), String> { if let Some(v) = str_arg(a, "view") { b.call("ui.view", json!({"view": v}))?; - // Turning to a standard view also frames the model (like a view cube click). + // Turning to a standard view also frames the model (like a axis view click). if v != "fit" && v != "home" { b.call("ui.view", json!({"view": "fit"}))?; } diff --git a/crates/render/src/camera.rs b/crates/render/src/camera.rs index 2b665ac0..0357f988 100644 --- a/crates/render/src/camera.rs +++ b/crates/render/src/camera.rs @@ -525,7 +525,7 @@ mod tests { c }) .collect(); - // Corners and edges of the view cube, and a fit (new target and distance). + // Corners and edges of the axis view, and a fit (new target and distance). cams.push(base.looking_from(Vec3::new(1.0, -1.0, 1.0))); cams.push(base.looking_from(Vec3::new(-1.0, 0.0, 1.0))); cams.push(base.looking_from(Vec3::new(0.0, 0.0, 1.0))); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index e4b23a41..6e7fb06a 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -190,6 +190,8 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> } "ui.view" => { let v = s("view").unwrap_or("home"); + app.viewport.orbit_snap = None; + app.viewport.orbit_free = None; if p.get("animate").and_then(Value::as_bool).unwrap_or(false) { if v != "fit" && v != "home" && StandardView::parse(v).is_none() { return err(format!("unknown view `{v}`")); @@ -343,12 +345,6 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> }; } let Some(rect) = app.viewport.rect else { return err("no viewport") }; - if let Some(c) = p.get("cube") { - // A point on the view cube, in cube units (faces at ±1). - let n = |i: usize| c.get(i).and_then(Value::as_f64).unwrap_or(0.0); - let q = crate::viewport::cube_point(app, rect, solvecraft_engine::geom::Vec3::new(n(0), n(1), n(2))); - return ok(json!([q.x, q.y])); - } let proj = crate::viewport::projection(app, rect); let num = |v: &Value, i: usize| v.get(i).and_then(Value::as_f64).unwrap_or(0.0); let world = if let Some(w) = p.get("world") { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 284f048e..2e6caa23 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1263,7 +1263,7 @@ fn auto_operation(app: &SolveApp, d: &mut Dialog) { } /// Show the active dialog (if any), docked flush to the right edge of the viewport just below -/// the view cube, sized to its content. +/// the axis view, sized to its content. pub fn show(app: &mut SolveApp, ctx: &egui::Context) { let Some(mut d) = app.dialog.take() else { return }; let _language = crate::i18n::scope(&app.preferences.language); @@ -1272,7 +1272,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { update_fill_cells(app, &mut d); let t = Tokens::get(); let vp = app.viewport.rect.unwrap_or_else(|| ctx.content_rect()); - let anchor = egui::pos2(vp.right(), vp.top() + crate::viewport::VIEW_CUBE_CLEARANCE); + let anchor = egui::pos2(vp.right(), vp.top() + crate::viewport::AXIS_VIEW_CLEARANCE); let mut keep = true; let mut ok = false; let mut applied = false; diff --git a/crates/ui-egui/src/documents.rs b/crates/ui-egui/src/documents.rs index 954498fb..27ce9f1c 100644 --- a/crates/ui-egui/src/documents.rs +++ b/crates/ui-egui/src/documents.rs @@ -79,6 +79,8 @@ fn swap_in(app: &mut SolveApp, slot: DocSlot) -> DocSlot { app.tool = None; app.params = None; app.cam_anim = None; + app.viewport.orbit_snap = None; + app.viewport.orbit_free = None; app.preview = crate::preview::PreviewState::default(); app.viewport.hover = None; app.menu.props = None; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4293e258..0c387aba 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -3,7 +3,7 @@ //! A thin shell over [`solvecraft_engine::Session`]: panels read engine state and act through //! commands ([`SolveApp::run`]). Nothing here owns design data. The layout follows the familiar //! parametric-CAD desktop: application bar, a toolbar of workspace tabs and panels, the browser -//! on the left, the 3D viewport with a view cube and navigation bar, the command dialog on the +//! on the left, the 3D viewport with an axis view and navigation bar, the command dialog on the //! right of the viewport, the command palette and the timeline at the bottom. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] @@ -129,7 +129,7 @@ pub struct UiState { pub visual_style: u8, /// A soft shadow on the ground under the model (seen from above). pub ground_shadow: bool, - /// The world's up axis (Z or Y; #3): the camera, view cube, home view and ground follow it. + /// The world's up axis (Z or Y; #3): the camera, axis view, home view and ground follow it. pub up_axis: solvecraft_engine::render::UpAxis, /// Sketches whose closed profiles are not shaded. pub hidden_profiles: Vec, @@ -180,7 +180,7 @@ pub struct SolveApp { pub session: Session, pub ui: UiState, pub cam: Camera, - /// A view change in progress (view cube, Home, Fit); any mouse navigation cancels it. + /// A view change in progress (axis view, Home, Fit); any mouse navigation cancels it. pub cam_anim: Option, /// Seconds since start, as of this frame. pub now: f64, @@ -736,6 +736,8 @@ impl SolveApp { pub fn fit_view(&mut self) { self.cam_anim = None; + self.viewport.orbit_snap = None; + self.viewport.orbit_free = None; self.cam = self.fitted(self.cam); } @@ -753,6 +755,8 @@ impl SolveApp { /// Move the camera to `to` over half a second (ease in-out, orientation slerped). pub fn animate_to(&mut self, to: Camera) { + self.viewport.orbit_snap = None; + self.viewport.orbit_free = None; if !to.is_valid() { return; } @@ -770,7 +774,7 @@ impl SolveApp { self.cam_anim = Some(anim); } - /// Animated view changes for the view cube and navigation bar. + /// Animated view changes for the axis view and navigation bar. pub fn animate_view(&mut self, v: &str) { let mut to = self.cam; match v { diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index b7e509ef..5f361e79 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -52,7 +52,7 @@ pub struct Prefs { pub ui_scale: f32, /// The wheel zooms toward the point under the cursor (else toward the view's centre). pub zoom_to_cursor: bool, - /// View changes (view cube, Look At, Home) animate; `look_at_speed` scales how fast. + /// View changes (axis view, Look At, Home) animate; `look_at_speed` scales how fast. pub look_at_anim: bool, pub look_at_speed: f32, /// Default modelling orientation: `z` (Z up) or `y` (Y up) (#3). @@ -554,7 +554,7 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { } }); ui.end_row(); - note(ui, &format!("{}; the wheel zooms", nav_help(&app.preferences.nav))); + note(ui, &format!("{}; hold Shift during orbit to snap; the wheel zooms", nav_help(&app.preferences.nav))); ui.label("Reverse zoom direction"); ui.checkbox(&mut app.preferences.zoom_reverse, ""); ui.end_row(); @@ -569,7 +569,7 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { }); ui.end_row(); ui.label("Animate view changes"); - ui.checkbox(&mut app.preferences.look_at_anim, "View cube, Look At, Home and Fit"); + ui.checkbox(&mut app.preferences.look_at_anim, "Axis view, Look At, Home and Fit"); ui.end_row(); ui.label("Animation speed"); ui.add_enabled( @@ -585,7 +585,7 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { ui.radio_value(&mut app.preferences.up_axis, "y".to_string(), "Y up"); }); ui.end_row(); - note(ui, "Which axis points up in the views (view cube, Home, Look At). Designs are unchanged."); + note(ui, "Which axis points up in the views (axis view, Home, Look At). Designs are unchanged."); ui.label("Offer to stand up Y-up files"); ui.checkbox(&mut app.preferences.offer_stand_up, "") .on_hover_text("After a STEP or IGES file opens with Z up, offer a quarter turn about X"); diff --git a/crates/ui-egui/src/scenario.rs b/crates/ui-egui/src/scenario.rs index 4508db57..1f0d31b4 100644 --- a/crates/ui-egui/src/scenario.rs +++ b/crates/ui-egui/src/scenario.rs @@ -82,7 +82,7 @@ pub fn rect_of(name: &str) -> Option { RECTS.with(|h| h.borrow().iter().rfind(|(n, _)| n == name).map(|x| x.1)) } -/// A number about this frame's drawing, by name ("cube_highlights": the view cube's lit patches). +/// A number about this frame's drawing, by name ("axis_view_highlights": the axis view's highlighted tips). pub fn publish_count(name: &str, v: f64) { COUNTS.with(|h| h.borrow_mut().push((name.to_string(), v))); } @@ -213,7 +213,7 @@ impl Harness { pub fn settle(&mut self) { for _ in 0..2000 { // Queued input used up and the view no longer moving. - if self.app.synthetic.is_empty() && self.app.cam_anim.is_none() { + if self.app.synthetic.is_empty() && self.app.cam_anim.is_none() && self.app.viewport.orbit_snap.is_none() { break; } self.frame(); @@ -314,14 +314,14 @@ impl Harness { } } - /// Animate the view to `v` (as the view cube does), sampling the camera every frame: no + /// Animate the view to `v` (as the axis view does), sampling the camera every frame: no /// frame may turn the view (its right or back vector) by more than `max_deg`, the last /// included, so the move ends without a jump. fn view_anim(&mut self, v: &str, max_deg: f64) -> Result<(), String> { let basis = |c: &solvecraft_engine::render::Camera| c.basis(); let mut prev = basis(&self.app.cam); self.app.animate_view(v); - if self.app.cam_anim.is_none() { + if self.app.cam_anim.is_none() && self.app.viewport.orbit_snap.is_none() { return Err(format!("view_anim {v}: no animation started")); } let mut worst = (0.0f64, 0usize); @@ -334,7 +334,7 @@ impl Harness { worst = (d, i); } prev = now; - if self.app.cam_anim.is_none() { + if self.app.cam_anim.is_none() && self.app.viewport.orbit_snap.is_none() { if worst.0 > max_deg { return Err(format!("view_anim {v}: frame {} of {} turns the view {:.1}° (limit {max_deg}°)", worst.1 + 1, i + 1, worst.0)); } diff --git a/crates/ui-egui/src/sketch_palette.rs b/crates/ui-egui/src/sketch_palette.rs index f80862a6..921c0654 100644 --- a/crates/ui-egui/src/sketch_palette.rs +++ b/crates/ui-egui/src/sketch_palette.rs @@ -178,7 +178,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { }; let t = Tokens::get(); let vp = app.viewport.rect.unwrap_or_else(|| ctx.content_rect()); - let anchor = egui::pos2(vp.right(), vp.top() + crate::viewport::VIEW_CUBE_CLEARANCE); + let anchor = egui::pos2(vp.right(), vp.top() + crate::viewport::AXIS_VIEW_CLEARANCE); let frame = egui::Frame::window(&ctx.global_style()) .fill(t.dialog_bg) .stroke(Stroke::new(1.0, t.dialog_border)) diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 93d2371b..50c33cb8 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -103,9 +103,6 @@ pub struct Tokens { pub glyph_text: Color32, /// Chips drawn over the viewport (status, navigation bar, dimension labels). pub overlay: Color32, - /// View cube face (shaded by orientation) and edges. - pub cube_face: Color32, - pub cube_edge: Color32, /// Rubber band of the shape being drawn. pub rubber_band: Color32, /// Light text on the application bar. @@ -191,8 +188,6 @@ impl Tokens { glyph_edge: Color32::from_rgb(90, 150, 90), glyph_text: Color32::from_rgb(40, 110, 40), overlay: Color32::from_rgba_unmultiplied_const(255, 255, 255, 235), - cube_face: Color32::from_rgb(236, 236, 236), - cube_edge: Color32::from_rgb(140, 148, 160), rubber_band: Color32::from_rgb(30, 120, 230), app_bar_text: Color32::from_rgb(230, 234, 242), timeline_item: Color32::WHITE, @@ -247,8 +242,6 @@ impl Tokens { t.glyph_edge = Color32::from_rgb(96, 170, 110); t.glyph_text = Color32::from_rgb(150, 220, 160); t.overlay = Color32::from_rgba_unmultiplied_const(34, 38, 46, 240); - t.cube_face = Color32::from_rgb(196, 200, 207); - t.cube_edge = Color32::from_rgb(100, 108, 122); t.rubber_band = Color32::from_rgb(92, 160, 255); t.timeline_item = Color32::from_rgb(52, 58, 70); t.timeline_item_error = Color32::from_rgb(110, 50, 54); diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index b752919c..a1fb98bf 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -1,5 +1,5 @@ -//! The 3D viewport: navigation, GPU (or CPU fallback) rendering, picking, overlays, the view -//! cube and the navigation bar. +//! The 3D viewport: navigation, GPU (or CPU fallback) rendering, picking, overlays, the axis +//! view and the navigation bar. use egui::{Align2, Color32, FontId, Pos2, Rect, Sense, Shape, Stroke, pos2, vec2}; use serde_json::json; @@ -55,8 +55,12 @@ pub struct ViewportState { pub orbit_pivot: Option, /// Continuous orbit: the view turns about the vertical until the next click or key. pub spin: bool, - /// A primary press on the cube: the pointer position used by its last orbit step. - pub cube_drag: Option, + /// A primary press on the axis gizmo: the pointer position used by its last orbit step. + pub axis_drag: Option, + /// Unsnapped camera while Shift-orbit previews a standard or diagonal view. + pub orbit_free: Option, + /// Spring toward the chosen orientation, including after the drag ends. + pub(crate) orbit_snap: Option, pub build_ms: f64, /// The right-click menu, open at this screen position. pub context_menu: Option, @@ -983,8 +987,8 @@ fn section_clip(app: &SolveApp) -> [f32; 4] { } } -/// Space kept free at the top right of the viewport for the view cube (docked dialogs start below). -pub const VIEW_CUBE_CLEARANCE: f32 = 168.0; +/// Space kept free at the top right of the viewport for the axis view (docked dialogs start below). +pub const AXIS_VIEW_CLEARANCE: f32 = 168.0; /// Axis length as a multiple of the origin plane size (solid part 1, dashed beyond). const AXIS_LEN: f64 = 1.8; @@ -1442,10 +1446,10 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { }); // The right button belongs to the marking menu while it is open. let secondary_down = secondary_down && app.viewport.context_menu.is_none(); - // The view cube handles its own clicks; the model underneath must not see them. - let cube = Rect::from_center_size(pos2(rect.right() - 80.0, rect.top() + 80.0), vec2(150.0, 150.0)); + // The axis view handles its own clicks; the model underneath must not see them. + let axes = axis_rect(rect); // Floating windows and menus own input over the model, including wheel navigation. - let inside = resp.contains_pointer() && hover.is_some_and(|p| !cube.contains(p)); + let inside = resp.contains_pointer() && hover.is_some_and(|p| !axes.contains(p)); app.viewport.mouse = hover.filter(|_| inside); // Ctrl or Alt held: drawing and dragging don't snap. crate::drag_snap::set_suppressed(mods.ctrl || mods.alt || mods.command); @@ -1453,6 +1457,8 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { // ---- navigation ---- let h = rect.height().max(1.0) as f64; if inside && scroll != 0.0 { + app.viewport.orbit_snap = None; + app.viewport.orbit_free = None; let f = (-scroll as f64 * 0.0015 * crate::prefs::zoom_sign(app)).exp(); // Preferences › Navigation › Zoom to the cursor (off: toward the view's centre). let anchor = hover.filter(|_| app.preferences.zoom_to_cursor).map(|p| { @@ -1470,26 +1476,37 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { app.cam.zoom_at(f, anchor); } let dragging = resp.dragged() || (inside && (middle || secondary_down)); - if dragging && delta != egui::Vec2::ZERO { - let (dx, dy) = (delta.x as f64, delta.y as f64); - let mode = if middle || secondary_down { + let mode = if dragging && app.viewport.axis_drag.is_none() { + if middle || secondary_down { crate::prefs::nav_mode(app, middle, secondary_down, mods) - } else if primary_down && app.viewport.cube_drag.is_none() { + } else if primary_down { app.viewport.nav } else { None - }; - if mode.is_some() { - app.cancel_view_animation(); } - match mode { - Some(NavMode::Orbit) => { - let press = ui.input(|i| i.pointer.press_origin()); - crate::prefs::orbit(app, rect, dx, dy, press); + } else { + None + }; + if mode == Some(NavMode::Orbit) { + let press = ui.input(|i| i.pointer.press_origin()); + app.cancel_view_animation(); + orbit_drag(app, rect, f64::from(delta.x), f64::from(delta.y), press, mods.shift, true); + } else { + if matches!(mode, Some(NavMode::Pan | NavMode::Zoom)) { + app.viewport.orbit_snap = None; + } + if app.viewport.axis_drag.is_none() { + app.viewport.orbit_free = None; + } + if delta != egui::Vec2::ZERO { + if mode.is_some() { + app.cancel_view_animation(); + } + match mode { + Some(NavMode::Pan) => app.cam.pan(f64::from(delta.x), f64::from(delta.y), h), + Some(NavMode::Zoom) => app.cam.zoom_at((f64::from(delta.y) * 0.01).exp(), None), + _ => {} } - Some(NavMode::Pan) => app.cam.pan(dx, dy, h), - Some(NavMode::Zoom) => app.cam.zoom_at((dy * 0.01).exp(), None), - None => {} } } if !dragging { @@ -1513,10 +1530,16 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { } } if !app.cam.is_valid() { + app.viewport.orbit_free = None; + app.viewport.orbit_snap = None; app.cam = Camera::default(); app.fit_view(); } + step_orbit_snap(app, rect, ui.input(|i| f64::from(i.stable_dt))); + if app.viewport.orbit_snap.is_some() { + ui.ctx().request_repaint(); + } let proj = projection(app, rect); // Suppress hover for the whole navigation gesture, including frames between pointer // events. Testing delta made the highlight reappear whenever a held drag paused. @@ -1525,8 +1548,9 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { || middle || secondary_down || (app.viewport.nav.is_some() && primary_down) - || app.viewport.cube_drag.is_some() + || app.viewport.axis_drag.is_some() || app.viewport.spin + || app.viewport.orbit_snap.is_some() || app.cam_anim.is_some(); let hits = hover.filter(|_| inside && app.viewport.boxsel.is_none() && !navigating).map(|p| pick_cached(app, &proj, p)).unwrap_or_default(); let cand = if app.tool.is_some() { hits.first().cloned().map(|h| (h, None)) } else { candidate(app, &hits).map(|(h, s)| (h, Some(s))) }; @@ -1762,7 +1786,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { if resp.dragged_by(egui::PointerButton::Primary) && let (Some(a), Some(b)) = (origin, hover) && rect.contains(a) - && !cube.contains(a) + && !axes.contains(a) { app.viewport.boxsel = Some(BoxSel::from_drag(a, b)); } @@ -1807,7 +1831,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { crate::canvas::show(app, ui, &painter, &proj); crate::sketch_tools::show(app, ui, &painter, &proj); crate::sketch_dims::show(app, ui, &proj); - view_cube(app, ui, rect); + axis_view(app, ui, rect); nav_bar(app, ui, rect); // Status chip. if let Some((msg, at, err)) = app.status.clone() { @@ -2153,117 +2177,220 @@ fn cpu_render(app: &mut SolveApp, ctx: &egui::Context, painter: &egui::Painter, } } -/// Where a point in view-cube units (faces at ±1) is drawn. -pub(crate) fn cube_point(app: &SolveApp, rect: Rect, p: Vec3) -> Pos2 { - let (r, u, _) = cube_basis(&app.cam); - pos2(rect.right() - 80.0 + (p.dot(r) * CUBE_SCALE) as f32, rect.top() + 80.0 - (p.dot(u) * CUBE_SCALE) as f32) +/// Compact orientation gizmo, drawn and hit-tested in world coordinates. +const AXIS_LENGTH: f64 = 38.0; +const AXIS_RADIUS: f32 = 11.0; + +fn axis_center(rect: Rect) -> Pos2 { + pos2(rect.right() - 68.0, rect.top() + 68.0) } -/// Half a view cube face, in pixels. -const CUBE_SCALE: f64 = 32.0; +fn axis_rect(rect: Rect) -> Rect { + Rect::from_center_size(axis_center(rect), vec2(116.0, 116.0)) +} -/// The view cube (top right): a cube with chamfered corners. Its 14 targets are the 6 faces -/// (orthographic views) and the 8 corner facets (iso views); the edges between them are not -/// targets. Hover lights the face or facet under the pointer, a click turns the view to it, the -/// house goes home. -/// The camera's basis in its up-axis frame (Z up): the cube's TOP is the world's up, X, Y or Z. -fn cube_basis(cam: &Camera) -> (Vec3, Vec3, Vec3) { +/// Six signed world axes, sorted back to front so overlapping tips have one owner. +fn axis_tips(cam: &Camera, rect: Rect) -> Vec<(&'static str, Vec3, Pos2, f64)> { let (r, u, b) = cam.basis(); - (cam.to_frame(r), cam.to_frame(u), cam.to_frame(b)) + let c = axis_center(rect); + let mut tips: Vec<_> = [("x", Vec3::X), ("-x", -Vec3::X), ("y", Vec3::Y), ("-y", -Vec3::Y), ("z", Vec3::Z), ("-z", -Vec3::Z)] + .into_iter() + .map(|(name, d)| (name, d, c + vec2((d.dot(r) * AXIS_LENGTH) as f32, -(d.dot(u) * AXIS_LENGTH) as f32), d.dot(b))) + .collect(); + tips.sort_by(|a, b| a.3.total_cmp(&b.3)); + tips } -/// Dragging a primary press on the cube orbits about the view centre. A stationary press -/// remains a click; a floating window over the cube owns its input. -fn view_cube_orbit(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect, targets: &[CubeTarget], c: Pos2) -> bool { - let (pressed, down, decided, origin, pos) = ui.input(|i| { +fn axis_hit(tips: &[(&str, Vec3, Pos2, f64)], p: Pos2) -> Option { + tips.iter().enumerate().rev().find(|(_, (_, _, at, _))| at.distance(p) <= AXIS_RADIUS).map(|(i, _)| i) +} + +/// Six principal views, twelve edge diagonals and eight isometric corner directions. +fn snap_directions() -> impl Iterator { + [-1.0, 0.0, 1.0].into_iter().flat_map(|x| { + [-1.0, 0.0, 1.0].into_iter().flat_map(move |y| [-1.0, 0.0, 1.0].into_iter().filter_map(move |z| Vec3::new(x, y, z).normalized())) + }) +} + +/// Snap to the nearest standard or diagonal direction in the user's up-axis frame. +fn snapped_axis_camera(cam: Camera) -> Camera { + let b = cam.to_frame(cam.back()); + let dir = snap_directions().max_by(|a, c| a.dot(b).total_cmp(&c.dot(b))).unwrap_or(Vec3::Z); + let mut to = cam.looking_from(cam.from_frame(dir)); + if dir.x == 0.0 && dir.y == 0.0 { + to.yaw = nearest_quarter_turn(cam.yaw); + } + to +} + +pub(crate) struct OrbitSnap { + target: Camera, + yaw_velocity: f64, + pitch_velocity: f64, + pivot: Option<(Vec3, Pos2)>, +} + +/// Pointer motion accumulates independently of the spring preview, so many small deltas +/// still cross snap boundaries. Keep a small angular margin to avoid boundary flicker. +fn orbit_drag(app: &mut SolveApp, rect: Rect, dx: f64, dy: f64, press: Option, snap: bool, around_cursor: bool) { + let displayed = app.cam; + if let Some(free) = app.viewport.orbit_free.take() { + app.cam = free; + } + if around_cursor { + crate::prefs::orbit(app, rect, dx, dy, press); + } else { + app.cam.orbit(dx, dy); + } + let free = app.cam; + app.viewport.orbit_free = Some(free); + if snap || !around_cursor || app.viewport.orbit_snap.is_some() { + let mut target = if snap { snapped_axis_camera(free) } else { free }; + if snap && let Some(previous) = app.viewport.orbit_snap.as_ref() { + let b = free.back(); + if previous.target.back().dot(b) + 0.025 >= target.back().dot(b) { + target.yaw = previous.target.yaw; + target.pitch = previous.target.pitch; + } + } + let pivot = app.viewport.orbit_pivot.filter(|_| around_cursor).and_then(|p| { + app.cam = displayed; + projection(app, rect).to_screen(p).map(|at| (p, at)) + }); + let spring = app.viewport.orbit_snap.get_or_insert(OrbitSnap { target, yaw_velocity: 0.0, pitch_velocity: 0.0, pivot }); + spring.target = target; + spring.pivot = pivot; + app.cam = displayed; + } +} + +/// A lightly damped spring: mostly settled in about a third of a second, with a small +/// elastic finish. Bounded substeps keep the behavior consistent across frame rates. +fn step_orbit_snap(app: &mut SolveApp, rect: Rect, dt: f64) { + let Some(mut spring) = app.viewport.orbit_snap.take() else { return }; + let dt = if dt.is_finite() { dt.clamp(0.0, 0.05) } else { 1.0 / 60.0 }; + let steps = (dt * 240.0).ceil().max(1.0) as usize; + let h = dt / steps as f64; + let omega = 16.0; + let damping = 0.82; + let yaw_error = |yaw: f64| (spring.target.yaw - yaw + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI; + for _ in 0..steps.min(12) { + spring.yaw_velocity += (omega * omega * yaw_error(app.cam.yaw) - 2.0 * damping * omega * spring.yaw_velocity) * h; + spring.pitch_velocity += (omega * omega * (spring.target.pitch - app.cam.pitch) - 2.0 * damping * omega * spring.pitch_velocity) * h; + app.cam.yaw += spring.yaw_velocity * h; + app.cam.pitch = (app.cam.pitch + spring.pitch_velocity * h).clamp(-std::f64::consts::FRAC_PI_2, std::f64::consts::FRAC_PI_2); + } + app.cam.target = spring.target.target; + let done = yaw_error(app.cam.yaw).abs() < 1e-5 + && (spring.target.pitch - app.cam.pitch).abs() < 1e-5 + && spring.yaw_velocity.abs() < 1e-4 + && spring.pitch_velocity.abs() < 1e-4; + if done { + app.cam = spring.target; + } + if let Some((pivot, at)) = spring.pivot + && let Some(after) = projection(app, rect).to_screen(pivot) + { + app.cam.pan(f64::from(at.x - after.x), f64::from(at.y - after.y), f64::from(rect.height().max(1.0))); + } + if !done { + app.viewport.orbit_snap = Some(spring); + } +} + +/// Dragging anywhere on the axis gizmo orbits about the view centre. A stationary press +/// on a tip chooses its view. Floating windows own their input over the gizmo. +fn axis_view_orbit(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) -> bool { + let (pressed, down, decided, origin, pos, shift) = ui.input(|i| { let p = &i.pointer; - (p.primary_pressed(), p.primary_down(), p.is_decidedly_dragging(), p.press_origin(), p.interact_pos()) + (p.primary_pressed(), p.primary_down(), p.is_decidedly_dragging(), p.press_origin(), p.interact_pos(), i.modifiers.shift) }); if pressed && ui.rect_contains_pointer(rect) { - let shapes = cube_projected(targets, cube_basis(&app.cam), c); - app.viewport.cube_drag = origin.filter(|p| cube_hit(&shapes, *p).is_some()); + app.viewport.axis_drag = origin.filter(|p| axis_rect(rect).contains(*p)); + if app.viewport.axis_drag.is_some() { + app.viewport.orbit_free = None; + } } - if !down { - app.viewport.cube_drag = None; + if !down && app.viewport.axis_drag.take().is_some() { + app.viewport.orbit_free = None; } - let Some(last) = app.viewport.cube_drag.filter(|_| decided) else { return false }; + let Some(last) = app.viewport.axis_drag.filter(|_| decided) else { return false }; ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); - // Include the movement that crossed the drag threshold, so the orbit follows the pointer. - if let Some(p) = pos - && p != last - { + if let Some(p) = pos { app.cancel_view_animation(); - app.cam.orbit(f64::from(p.x - last.x), f64::from(p.y - last.y)); - app.viewport.cube_drag = Some(p); + orbit_drag(app, rect, f64::from(p.x - last.x), f64::from(p.y - last.y), None, shift, false); + app.viewport.axis_drag = Some(p); ui.ctx().request_repaint(); } true } -fn view_cube(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { +fn axis_view(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { let t = Tokens::get(); - let c = pos2(rect.right() - 80.0, rect.top() + 80.0); - let targets = cube_targets(); - let orbiting = view_cube_orbit(app, ui, rect, &targets, c); - let (r, u, b) = cube_basis(&app.cam); - let (wr, wu, _) = app.cam.basis(); - let painter = ui.painter_at(rect); + let c = axis_center(rect); + let orbiting = axis_view_orbit(app, ui, rect); + let tips = axis_tips(&app.cam, rect); let hover = ui.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(rect)); - let shapes = cube_projected(&targets, (r, u, b), c); - let hovered = if orbiting { None } else { hover.and_then(|h| cube_hit(&shapes, h)) }; - // Axis triad from the cube's back corner, drawn first: the cube hides what is behind it. - // The corner is in the cube's frame, the axes are the world's. - let o = Vec3::new(-1.0, -1.0, -1.0); - let at = pos2(c.x + (o.dot(r) * CUBE_SCALE) as f32, c.y - (o.dot(u) * CUBE_SCALE) as f32); - for (d, col) in [(Vec3::X, colors::AXIS_X), (Vec3::Y, colors::AXIS_Y), (Vec3::Z, colors::AXIS_Z)] { - let tip = at + vec2((d.dot(wr) * CUBE_SCALE * 2.6) as f32, -(d.dot(wu) * CUBE_SCALE * 2.6) as f32); - painter.line_segment([at, tip], Stroke::new(2.0, Color32::from_rgb(col.0, col.1, col.2))); + let hovered = if orbiting { None } else { hover.and_then(|p| axis_hit(&tips, p)) }; + let painter = ui.painter_at(rect); + if orbiting || hover.is_some_and(|p| axis_rect(rect).contains(p)) { + painter.circle_filled(c, 55.0, t.overlay); } - let labels: Vec = targets.iter().filter(|tg| !tg.corner).map(|tg| tg.name.to_uppercase()).collect(); - let size = cube_label_size(ui.ctx(), &labels); - // Back to front: the faces and facets turned toward the camera. - for (i, pts) in shapes.iter().rev() { - let tg = &targets[*i]; - let n = tg.normal(); - let shade = (0.75 + 0.25 * n.dot(b)) as f32; - let fill = if tg.corner { t.cube_face.gamma_multiply(shade * 0.92) } else { t.cube_face.gamma_multiply(shade) }; - painter.extend(projected_polygon(pts, fill.to_opaque(), Stroke::new(1.0, t.cube_edge))); - if hovered == Some(*i) { - painter.extend(projected_polygon(pts, t.accent_soft, Stroke::new(1.0, t.accent))); - } - let centre = cube_centroid(pts); - crate::scenario::publish_handle(&format!("cube:{}", tg.name), centre); - // Fades out as the face turns edge-on. - let ink = (n.dot(b) / 0.3).clamp(0.0, 1.0) as f32; - if !tg.corner && ink > 0.05 { - let color = Color32::from_rgb(40, 44, 52).gamma_multiply(ink); - painter.add(cube_label(ui.ctx(), &tg.name.to_uppercase(), size, n, (r, u), c, color)); + for (i, (name, d, at, depth)) in tips.iter().enumerate() { + let col = if d.x != 0.0 { + colors::AXIS_X + } else if d.y != 0.0 { + colors::AXIS_Y + } else { + colors::AXIS_Z + }; + let col = Color32::from_rgb(col.0, col.1, col.2); + let positive = !name.starts_with('-'); + let ink = if *depth < -0.01 { col.gamma_multiply(0.55) } else { col }; + painter.line_segment([c, *at], Stroke::new(if positive { 2.0 } else { 1.0 }, ink)); + painter.circle_filled(*at, AXIS_RADIUS, if hovered == Some(i) { t.accent_soft } else { t.overlay }); + painter.circle_stroke( + *at, + AXIS_RADIUS, + Stroke::new(if hovered == Some(i) { 2.0 } else { 1.0 }, if hovered == Some(i) { t.accent } else { ink }), + ); + painter.text(*at, Align2::CENTER_CENTER, name.to_uppercase(), FontId::proportional(11.0), ink); + crate::scenario::publish_handle(&format!("axis_view:{name}"), *at); + } + crate::scenario::publish_count("axis_view_highlights", f64::from(u8::from(hovered.is_some()))); + if let Some(i) = hovered + && 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.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + if ui.input(|i| i.pointer.primary_clicked()) { + let frame = app.cam.to_frame(*d); + let dir = if app.cam.back().dot(*d) > 0.9999 { -*d } else { *d }; + let mut to = app.cam.looking_from(dir); + if frame.z.abs() > 0.5 { + to.yaw = nearest_quarter_turn(app.cam.yaw); + } + if app.preferences.look_at_anim { + app.cancel_view_animation(); + app.viewport.orbit_free = None; + app.viewport.orbit_snap = Some(OrbitSnap { target: to, yaw_velocity: 0.0, pitch_velocity: 0.0, pivot: None }); + ui.ctx().request_repaint(); + } else { + app.animate_to(to); + } } } - crate::scenario::publish_count("cube_highlights", f64::from(u8::from(hovered.is_some()))); - if let Some(i) = hovered { - crate::scenario::publish_handle(&format!("cube_hover:{}", targets[i].name), hover.unwrap_or(c)); - } - // Clicked: the view from the face's or facet's direction (target → eye). - let clicked = hovered.filter(|_| ui.input(|i| i.pointer.primary_clicked())).map(|i| targets[i].dir); - let home = Rect::from_center_size(pos2(c.x - 52.0, c.y - 48.0), vec2(18.0, 18.0)); - let hr = ui.interact(home, ui.id().with("vc_home"), Sense::click()); + let home = Rect::from_center_size(pos2(c.x - 46.0, c.y - 54.0), vec2(18.0, 18.0)); + let hr = ui.interact(home, ui.id().with("axis_view_home"), Sense::click()); icons::paint(&painter, home, "home", t.icon, if hr.hovered() { t.accent_soft } else { t.overlay }, t.accent); if hr.on_hover_text("Home view").clicked() { app.animate_view("home"); } - if let Some(dir) = clicked { - let mut to = app.cam.looking_from(app.cam.from_frame(dir)); - // Keep the nearest square heading when looking straight down or up. - if dir.x == 0.0 && dir.y == 0.0 { - to.yaw = nearest_quarter_turn(app.cam.yaw); - } - app.animate_to(to); - } } -/// The nearest square heading. At a halfway tie, prefer the heading nearer the standard -/// plan view, so clicking TOP or BOTTOM from home still puts the front edge at the bottom. +/// At a halfway tie, prefer the square heading nearer the standard plan view. fn nearest_quarter_turn(yaw: f64) -> f64 { if !yaw.is_finite() { return 0.0; @@ -2282,168 +2409,6 @@ fn nearest_quarter_turn(yaw: f64) -> f64 { } } -/// How far along each edge a view cube corner is cut off (the cube's faces are at ±1). -const CUBE_CHAMFER: f64 = 0.38; - -/// A view cube target: a face or a corner facet, its view direction (target → eye) and outline -/// (cube units). -#[derive(Clone, Debug)] -pub(crate) struct CubeTarget { - pub name: String, - pub dir: Vec3, - pub corner: bool, - pub outline: Vec, -} - -impl CubeTarget { - fn normal(&self) -> Vec3 { - self.dir.normalized().unwrap_or(Vec3::Z) - } -} - -/// The 14 targets: 6 octagonal faces and 8 triangular corner facets. -pub(crate) fn cube_targets() -> Vec { - let k = CUBE_CHAMFER; - let mut out = Vec::new(); - for (n, name) in [ - (Vec3::new(0.0, -1.0, 0.0), "front"), - (Vec3::new(0.0, 1.0, 0.0), "back"), - (Vec3::new(1.0, 0.0, 0.0), "right"), - (Vec3::new(-1.0, 0.0, 0.0), "left"), - (Vec3::Z, "top"), - (Vec3::new(0.0, 0.0, -1.0), "bottom"), - ] { - let (e1, e2) = cube_face_axes(n); - let uv = - [(-1.0 + k, -1.0), (1.0 - k, -1.0), (1.0, -1.0 + k), (1.0, 1.0 - k), (1.0 - k, 1.0), (-1.0 + k, 1.0), (-1.0, 1.0 - k), (-1.0, -1.0 + k)]; - out.push(CubeTarget { name: name.into(), dir: n, corner: false, outline: uv.iter().map(|(a, b2)| n + e1 * *a + e2 * *b2).collect() }); - } - for sz in [1.0, -1.0] { - for sy in [-1.0, 1.0] { - for sx in [1.0, -1.0] { - let corner = Vec3::new(sx, sy, sz); - let name = format!( - "{}-{}-{}", - if sz > 0.0 { "top" } else { "bottom" }, - if sy < 0.0 { "front" } else { "back" }, - if sx > 0.0 { "right" } else { "left" } - ); - let outline = vec![corner - Vec3::X * (sx * k), corner - Vec3::Y * (sy * k), corner - Vec3::Z * (sz * k)]; - out.push(CubeTarget { name, dir: corner, corner: true, outline }); - } - } - } - out -} - -/// Where a point in cube units is drawn, for the camera's right and up `ru` and the cube's -/// centre `c`. -fn cube_to_screen((r, u): (Vec3, Vec3), c: Pos2, p: Vec3) -> Pos2 { - pos2(c.x + (p.dot(r) * CUBE_SCALE) as f32, c.y - (p.dot(u) * CUBE_SCALE) as f32) -} - -/// A face's text directions (right, up): read from outside the cube, and upright in the view -/// of the standard face view (TOP and BOTTOM: yaw 0, front edge at the bottom). -fn cube_label_axes(n: Vec3) -> (Vec3, Vec3) { - if n.z.abs() > 0.5 { (Vec3::X, Vec3::Y * n.z.signum()) } else { (Vec3::Z.cross(n), Vec3::Z) } -} - -/// How much of a face the widest face label spans. -const CUBE_LABEL_WIDTH: f32 = 0.9; - -/// The face labels' font size: one size for all, the widest of `labels` spanning -/// `CUBE_LABEL_WIDTH` of a face. -fn cube_label_size(ctx: &egui::Context, labels: &[String]) -> f32 { - let probe = 10.0; - let widest = labels - .iter() - .map(|s| ctx.fonts_mut(|f| f.layout_no_wrap(s.clone(), FontId::proportional(probe), Color32::WHITE).size().x)) - .fold(0.0, f32::max); - if widest > 0.0 { probe * CUBE_LABEL_WIDTH * 2.0 * CUBE_SCALE as f32 / widest } else { probe } -} - -/// A face's label printed on the face at `size`: laid out flat, then mapped through the cube's -/// projection, so it turns and foreshortens with the face. -fn cube_label(ctx: &egui::Context, text: &str, size: f32, n: Vec3, ru: (Vec3, Vec3), c: Pos2, color: Color32) -> Shape { - let galley = ctx.fonts_mut(|f| f.layout(text.to_string(), FontId::proportional(size), color, f32::INFINITY)); - let options = egui::epaint::TessellationOptions { round_text_to_pixels: false, ..Default::default() }; - let mut tess = egui::epaint::Tessellator::new(ctx.pixels_per_point(), options, ctx.fonts(|f| f.font_image_size()), vec![]); - let mut mesh = egui::Mesh::default(); - let at = (-galley.size() / 2.0).to_pos2(); - tess.tessellate_text(&egui::epaint::TextShape::new(at, galley, color), &mut mesh); - let (right, up) = cube_label_axes(n); - for v in &mut mesh.vertices { - let p = n + right * (f64::from(v.pos.x) / CUBE_SCALE) - up * (f64::from(v.pos.y) / CUBE_SCALE); - v.pos = cube_to_screen(ru, c, p); - } - Shape::mesh(mesh) -} - -/// The targets turned toward the camera (basis `rub`), front first, as screen polygons around -/// the cube's centre `c`. -pub(crate) fn cube_projected(targets: &[CubeTarget], rub: (Vec3, Vec3, Vec3), c: Pos2) -> Vec<(usize, Vec)> { - let (r, u, b) = rub; - let mut v: Vec<(usize, f64, Vec)> = targets - .iter() - .enumerate() - .filter(|(_, tg)| tg.normal().dot(b) > 1e-3) - .map(|(i, tg)| (i, tg.normal().dot(b), tg.outline.iter().map(|p| cube_to_screen((r, u), c, *p)).collect())) - .collect(); - v.sort_by(|a, b2| b2.1.total_cmp(&a.1)); - v.into_iter().map(|(i, _, p)| (i, p)).collect() -} - -/// The target under `p`. The faces and facets tile the cube, so a point on an edge between two -/// of them (where neither polygon claims it) goes to the nearer one, within a few pixels. -pub(crate) fn cube_hit(shapes: &[(usize, Vec)], p: Pos2) -> Option { - if let Some((i, _)) = shapes.iter().find(|(_, pts)| point_in_poly(p, pts)) { - return Some(*i); - } - shapes.iter().map(|(i, pts)| (*i, poly_distance(p, pts))).filter(|(_, d)| *d <= 3.0).min_by(|a, b| a.1.total_cmp(&b.1)).map(|(i, _)| i) -} - -fn poly_distance(p: Pos2, pts: &[Pos2]) -> f32 { - (0..pts.len()) - .map(|i| { - let (a, b) = (pts[i], pts[(i + 1) % pts.len()]); - let ab = b - a; - let t = ((p - a).dot(ab) / ab.length_sq().max(1e-6)).clamp(0.0, 1.0); - p.distance(a + ab * t) - }) - .fold(f32::INFINITY, f32::min) -} - -fn cube_centroid(pts: &[Pos2]) -> Pos2 { - let n = pts.len().max(1) as f32; - pos2(pts.iter().map(|p| p.x).sum::() / n, pts.iter().map(|p| p.y).sum::() / n) -} - -/// The in-face axes of a view cube face. -fn cube_face_axes(n: Vec3) -> (Vec3, Vec3) { - if n.z.abs() > 0.5 { - (Vec3::X, Vec3::Y) - } else if n.x.abs() > 0.5 { - (Vec3::Y, Vec3::Z) - } else { - (Vec3::X, Vec3::Z) - } -} - -fn point_in_poly(p: Pos2, poly: &[Pos2]) -> bool { - let n = poly.len(); - let mut inside = false; - for i in 0..n { - let (a, b) = (poly[i], poly[(i + 1) % n]); - if (a.y > p.y) != (b.y > p.y) { - let x = a.x + (p.y - a.y) / (b.y - a.y) * (b.x - a.x); - if p.x < x { - inside = !inside; - } - } - } - inside -} - /// Navigation bar (bottom centre). /// Turn the view to face the selected planar face or plane, else the active sketch. pub fn look_at_selection(app: &mut SolveApp) { @@ -2465,7 +2430,7 @@ pub fn look_at_selection(app: &mut SolveApp) { fn nav_bar(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { let t = Tokens::get(); let items: [(&str, &str); 10] = [ - ("orbit", "Orbit (drag; or Shift+middle / right drag)"), + ("orbit", "Orbit (right drag; hold Shift to snap)"), ("spin", "Continuous orbit (stops at the next click or key)"), ("pan", "Pan (drag; or middle drag)"), ("zoom", "Zoom (drag; or wheel)"), @@ -2945,44 +2910,44 @@ mod tests { } #[test] - fn floating_dialog_blocks_view_cube_highlights_and_clicks() { + fn floating_dialog_blocks_axis_view_highlights_and_clicks() { let mut h = crate::scenario::Harness::new(); h.call("ui.resize", json!({"width": 800, "height": 700})); - let p = crate::scenario::handle_at("cube:top").unwrap(); + let p = crate::scenario::handle_at("axis_view:z").unwrap(); h.call("ui.move", json!({"x": p.x, "y": p.y})); - assert_eq!(crate::scenario::count_of("cube_highlights"), Some(1.0)); + assert_eq!(crate::scenario::count_of("axis_view_highlights"), Some(1.0)); h.call("ui.prefs", json!({"open": "Navigation"})); let window = h.ctx.memory(|m| m.area_rect(egui::Id::new("sc_prefs"))).unwrap(); - assert!(window.contains(p), "the floating dialog must cover the cube target"); + assert!(window.contains(p), "the floating dialog must cover the axis target"); let camera = serde_json::to_value(h.app.cam).unwrap(); h.call("ui.move", json!({"x": p.x, "y": p.y})); - assert_eq!(crate::scenario::count_of("cube_highlights"), Some(0.0)); + assert_eq!(crate::scenario::count_of("axis_view_highlights"), Some(0.0)); h.call("ui.click", json!({"x": p.x, "y": p.y})); - assert_eq!(serde_json::to_value(h.app.cam).unwrap(), camera, "dialog clicks must not turn the view cube"); + assert_eq!(serde_json::to_value(h.app.cam).unwrap(), camera, "dialog clicks must not turn the axis view"); h.call("ui.drag", json!({"x0": p.x, "y0": p.y, "x1": p.x + 20.0, "y1": p.y, "steps": 8})); - assert_eq!(serde_json::to_value(h.app.cam).unwrap(), camera, "dialog drags must not orbit the cube"); - assert!(h.app.viewport.cube_drag.is_none()); + assert_eq!(serde_json::to_value(h.app.cam).unwrap(), camera, "dialog drags must not orbit the axis gizmo"); + assert!(h.app.viewport.axis_drag.is_none()); h.call("ui.prefs", json!({"button": "cancel"})); h.call("ui.move", json!({"x": p.x, "y": p.y})); - assert_eq!(crate::scenario::count_of("cube_highlights"), Some(1.0)); + assert_eq!(crate::scenario::count_of("axis_view_highlights"), Some(1.0)); h.call("ui.click", json!({"x": p.x, "y": p.y})); assert_ne!(serde_json::to_value(h.app.cam).unwrap(), camera); } #[test] - fn view_cube_drag_owns_navigation_and_keeps_the_view_centre() { + fn view_axis_drag_owns_navigation_and_keeps_the_view_centre() { for nav in [None, Some(NavMode::Orbit), Some(NavMode::Pan)] { let mut h = crate::scenario::Harness::new(); h.call("ui.view", json!({"view": "front"})); h.app.viewport.nav = nav; let before = h.app.cam; - let p = crate::scenario::handle_at("cube:front").unwrap(); + let p = crate::scenario::handle_at("axis_view:-y").unwrap(); h.call("ui.drag", json!({"x0": p.x, "y0": p.y, "x1": p.x + 48.0, "y1": p.y, "steps": 8})); - assert!((h.app.cam.yaw - 48.0 * 0.008).abs() < 1e-6, "{nav:?}: cube drag must orbit exactly once"); - assert_eq!(h.app.cam.target, before.target, "{nav:?}: cube drag must not pan"); + assert!((h.app.cam.yaw - 48.0 * 0.008).abs() < 1e-6, "{nav:?}: axis drag must orbit exactly once"); + assert_eq!(h.app.cam.target, before.target, "{nav:?}: axis drag must not pan"); assert_eq!(h.app.cam.distance, before.distance); assert_eq!(h.app.cam.pitch, before.pitch); - assert!(h.app.viewport.cube_drag.is_none(), "release clears the cube gesture"); + assert!(h.app.viewport.axis_drag.is_none(), "release clears the axis gesture"); assert!(h.app.viewport.boxsel.is_none()); } } @@ -3018,69 +2983,8 @@ mod tests { assert!(!build_highlight(&app).hidden.is_empty()); } - /// A camera basis looking from `dir` (target → eye), as the view cube draws with. - fn basis_from(dir: Vec3) -> (Vec3, Vec3, Vec3) { - let cam = solvecraft_engine::render::Camera::default().looking_from(dir); - cam.basis() - } - #[test] - fn view_cube_has_14_targets_each_hit_where_drawn() { - let targets = cube_targets(); - assert_eq!(targets.len(), 14); - assert_eq!(targets.iter().filter(|t| t.corner).count(), 8); - let c = pos2(100.0, 100.0); - for (i, tg) in targets.iter().enumerate() { - // Seen from a little off its own direction, its middle hits it. - let shapes = cube_projected(&targets, basis_from(tg.dir + Vec3::new(0.11, 0.07, 0.05)), c); - let pts = &shapes.iter().find(|(j, _)| *j == i).expect("visible").1; - assert_eq!(cube_hit(&shapes, cube_centroid(pts)), Some(i), "{}", tg.name); - } - // From an iso view: 3 faces and the 4 facets turned toward it. - let shapes = cube_projected(&targets, basis_from(Vec3::new(1.0, -1.0, 1.0)), c); - assert_eq!(shapes.len(), 3 + 4); - assert_eq!(cube_hit(&shapes, pos2(400.0, 400.0)), None); - } - - /// From any direction, including faces turned nearly edge-on, everything drawn stays within - /// the cube. - #[test] - fn view_cube_faces_never_spike_past_the_cube() { - let targets = cube_targets(); - let c = pos2(100.0, 100.0); - // The cube's circumradius (corner at √3) plus the stroke and feathering. - let limit = (3f64.sqrt() * CUBE_SCALE) as f32 + 3.0; - let mut worst = 0.0f32; - for dir in orbit_directions() { - for (_, pts) in cube_projected(&targets, basis_from(dir), c) { - for p in drawn_points(projected_polygon(&pts, Color32::GRAY, Stroke::new(1.0, Color32::BLACK))) { - worst = worst.max(p.distance(c)); - } - } - } - assert!(worst <= limit, "a vertex {worst} px from the centre (limit {limit})"); - } - - #[test] - fn view_cube_labels_read_upright_in_their_own_view() { - for tg in cube_targets().iter().filter(|t| !t.corner) { - // The standard face view (TOP and BOTTOM with yaw 0). - let mut cam = solvecraft_engine::render::Camera::default().looking_from(tg.dir); - if tg.dir.z.abs() > 0.5 { - cam.yaw = 0.0; - } - let (r, u, _) = cam.basis(); - let (right, up) = cube_label_axes(tg.dir); - assert!(right.dot(r) > 0.99 && up.dot(u) > 0.99, "{}: right·r {} up·u {}", tg.name, right.dot(r), up.dot(u)); - } - } - - fn face_labels(targets: &[CubeTarget]) -> Vec { - targets.iter().filter(|t| !t.corner).map(|t| t.name.to_uppercase()).collect() - } - - #[test] - fn view_cube_top_bottom_choose_nearest_square_heading() { + fn axis_view_top_bottom_choose_nearest_square_heading() { for (from, to) in [ (0.0, 0.0), (30.0, 0.0), @@ -3105,72 +3009,136 @@ mod tests { assert_eq!(nearest_quarter_turn(f64::INFINITY), 0.0); } - /// The widest label spans 90% of its face, looking straight at it. #[test] - fn view_cube_widest_label_spans_most_of_its_face() { - let ctx = egui::Context::default(); - let mut span = 0.0f32; - let _ = ctx.run_ui(egui::RawInput::default(), |ui| { - let size = cube_label_size(ui.ctx(), &face_labels(&cube_targets())); - let front = Vec3::new(0.0, -1.0, 0.0); - let (r, u, _) = basis_from(front); - let pts = drawn_points(vec![cube_label(ui.ctx(), "BOTTOM", size, front, (r, u), pos2(100.0, 100.0), Color32::BLACK)]); - let (lo, hi) = pts.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), p| (lo.min(p.x), hi.max(p.x))); - span = (hi - lo) / (2.0 * CUBE_SCALE as f32); - }); - assert!((span - CUBE_LABEL_WIDTH).abs() < 0.05, "BOTTOM spans {span} of the face"); + fn shift_orbit_accumulates_and_returns_to_free_rotation() { + let mut h = crate::scenario::Harness::new(); + h.call("ui.view", json!({"view":"front"})); + let rect = h.app.viewport.rect.unwrap(); + let before = h.app.cam; + for _ in 0..100 { + orbit_drag(&mut h.app, rect, 2.0, 0.0, None, true, false); + step_orbit_snap(&mut h.app, rect, 1.0 / 60.0); + } + for _ in 0..120 { + step_orbit_snap(&mut h.app, rect, 1.0 / 60.0); + } + assert!((h.app.cam.yaw - std::f64::consts::FRAC_PI_2).abs() < 1e-6); + assert_eq!(h.app.cam.target, before.target); + assert_eq!(h.app.cam.distance, before.distance); + orbit_drag(&mut h.app, rect, 0.0, 0.0, None, false, false); + h.settle(); + assert!((h.app.cam.yaw - 1.6).abs() < 1e-6); + assert!(h.app.viewport.orbit_free.is_none()); } - /// From any direction, each label is printed within its face. #[test] - fn view_cube_labels_stay_on_their_faces() { - let ctx = egui::Context::default(); - let (mut worst, mut drawn) = (f32::NEG_INFINITY, 0); - let _ = ctx.run_ui(egui::RawInput::default(), |ui| { - let targets = cube_targets(); - let size = cube_label_size(ui.ctx(), &face_labels(&targets)); - let c = pos2(100.0, 100.0); - for dir in orbit_directions().step_by(7) { - let (r, u, b) = basis_from(dir); - for (i, pts) in cube_projected(&targets, (r, u, b), c) { - let tg = &targets[i]; - if tg.corner { - continue; - } - for p in drawn_points(vec![cube_label(ui.ctx(), &tg.name.to_uppercase(), size, tg.normal(), (r, u), c, Color32::BLACK)]) { - let outside = if point_in_poly(p, &pts) { 0.0 } else { poly_distance(p, &pts) }; - worst = worst.max(outside); - drawn += 1; - } - } + fn axis_snap_selects_all_directions_in_both_up_frames() { + assert_eq!(snap_directions().count(), 26); + for up in [solvecraft_engine::render::UpAxis::Z, solvecraft_engine::render::UpAxis::Y] { + for d in snap_directions() { + let mut cam = Camera { up, ..Camera::default() }; + cam = cam.looking_from(cam.from_frame(d + Vec3::new(0.05, 0.03, 0.02))); + let to = snapped_axis_camera(cam); + assert!(to.back().dist(cam.from_frame(d)) < 1e-6); + assert_eq!(to.target, cam.target); + assert_eq!(to.distance, cam.distance); + assert_eq!(to.fov, cam.fov); } - }); - assert!(drawn > 10_000, "only {drawn} label vertices drawn"); - assert!(worst <= 0.5, "a label vertex {worst} px outside its face"); + } } #[test] - fn view_cube_edges_are_not_targets() { - let targets = cube_targets(); - let c = pos2(100.0, 100.0); - let shapes = cube_projected(&targets, basis_from(Vec3::new(1.0, -1.2, 0.9)), c); - let (r, u, _) = basis_from(Vec3::new(1.0, -1.2, 0.9)); - let to2 = |p: Vec3| pos2(c.x + (p.dot(r) * CUBE_SCALE) as f32, c.y - (p.dot(u) * CUBE_SCALE) as f32); - let name = |i: Option| i.map(|i| targets[i].name.clone()).unwrap_or_default(); - // On the top-front edge, its middle and near its ends: a neighbouring face (or facet). - for x in [-0.5, 0.0, 0.5] { - let hit = name(cube_hit(&shapes, to2(Vec3::new(x, -1.0, 1.0)))); - assert!(hit == "top" || hit == "front", "edge point {x}: {hit}"); + fn camera_resets_cancel_pending_axis_motion() { + for reset in 0..3 { + let mut h = crate::scenario::Harness::new(); + h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap(); + let rect = h.app.viewport.rect.unwrap(); + orbit_drag(&mut h.app, rect, 120.0, 40.0, None, true, false); + assert!(h.app.viewport.orbit_snap.is_some()); + match reset { + 0 => h.app.fit_view(), + 1 => h.app.set_up_axis(solvecraft_engine::render::UpAxis::Y), + _ => crate::documents::new_design(&mut h.app), + } + let before = h.app.cam; + step_orbit_snap(&mut h.app, rect, 1.0 / 60.0); + assert_eq!(h.app.cam.yaw, before.yaw); + assert_eq!(h.app.cam.pitch, before.pitch); + assert_eq!(h.app.cam.target, before.target); + assert!(h.app.viewport.orbit_snap.is_none()); + assert!(h.app.viewport.orbit_free.is_none()); } - let end = name(cube_hit(&shapes, to2(Vec3::new(0.9, -1.0, 1.0)))); - assert!(["top", "front", "top-front-right"].contains(&end.as_str()), "{end}"); - // Every direction a click turns to is a face's or a corner's: none along an edge. - for tg in &targets { - let zeros = [tg.dir.x, tg.dir.y, tg.dir.z].iter().filter(|v| v.abs() < 1e-9).count(); - assert!(zeros == 2 || zeros == 0, "{}: {:?}", tg.name, tg.dir); + } + + #[test] + fn axis_snap_spring_eases_in_and_settles_at_each_frame_rate() { + for fps in [30, 60, 120] { + let mut h = crate::scenario::Harness::new(); + h.call("ui.view", json!({"view":"front"})); + let rect = h.app.viewport.rect.unwrap(); + orbit_drag(&mut h.app, rect, 120.0, 0.0, None, true, false); + assert_eq!(h.app.cam.yaw, 0.0, "choosing a diagonal must not jump the camera"); + step_orbit_snap(&mut h.app, rect, 1.0 / f64::from(fps)); + assert!(h.app.cam.yaw > 0.0 && h.app.cam.yaw < std::f64::consts::FRAC_PI_4 * 0.25); + for _ in 0..fps * 2 { + step_orbit_snap(&mut h.app, rect, 1.0 / f64::from(fps)); + assert!(h.app.cam.yaw <= std::f64::consts::FRAC_PI_4 + 0.03, "elastic finish stays small"); + } + assert!((h.app.cam.yaw - std::f64::consts::FRAC_PI_4).abs() < 1e-6); + assert!(h.app.viewport.orbit_snap.is_none()); } } + #[test] + fn shift_orbit_keeps_cursor_pivot_and_default_middle_pan() { + let mut h = crate::scenario::Harness::new(); + h.app.preferences.orbit_cursor = true; + h.app.run("solid.box", json!({"length":40,"width":40,"height":40})).unwrap(); + h.app.fit_view(); + let rect = h.app.viewport.rect.unwrap(); + let at = rect.center() + vec2(50.0, -20.0); + orbit_drag(&mut h.app, rect, 0.0, 0.0, Some(at), false, true); + let pivot = h.app.viewport.orbit_pivot.unwrap(); + let before = projection(&h.app, rect).to_screen(pivot).unwrap(); + for _ in 0..20 { + orbit_drag(&mut h.app, rect, 4.0, 2.0, Some(at), true, true); + step_orbit_snap(&mut h.app, rect, 1.0 / 60.0); + let after = projection(&h.app, rect).to_screen(pivot).unwrap(); + assert!(after.distance(before) < 2.0); + } + h.app.viewport.orbit_free = None; + h.app.viewport.orbit_pivot = None; + h.call("ui.view", json!({"view":"front"})); + let before = h.app.cam; + h.call("ui.drag", json!({"x0":800,"y0":500,"x1":1020,"y1":500,"button":"middle","shift":true,"steps":40})); + assert!((h.app.cam.yaw - std::f64::consts::FRAC_PI_2).abs() < 1e-6); + h.call("ui.view", json!({"view":"front"})); + h.call("ui.drag", json!({"x0":800,"y0":500,"x1":850,"y1":520,"button":"middle"})); + assert_eq!(h.app.cam.yaw, before.yaw); + assert_eq!(h.app.cam.pitch, before.pitch); + assert_ne!(h.app.cam.target, before.target); + } + + #[test] + fn axis_view_click_again_flips_and_drag_snaps() { + let mut h = crate::scenario::Harness::new(); + h.run(&json!([ + {"call":"ui.view","params":{"view":"home"}}, + {"click":{"handle":"axis_view:z"}}, + {"expect":{"camera":{"pitch_deg":90,"yaw_deg":0}}}, + {"click":{"handle":"axis_view:z"}}, + {"expect":{"camera":{"pitch_deg":-90,"yaw_deg":0}}}, + {"call":"ui.view","params":{"view":"front"}} + ])) + .unwrap(); + let c = axis_center(h.app.viewport.rect.unwrap()); + h.call("ui.drag", json!({"x0":c.x,"y0":c.y,"x1":c.x+220.0,"y1":c.y,"shift":true,"steps":40})); + assert!((h.app.cam.yaw - std::f64::consts::FRAC_PI_2).abs() < 1e-6); + assert!(h.app.viewport.axis_drag.is_none()); + assert!(h.app.viewport.orbit_free.is_none()); + assert!(h.app.viewport.boxsel.is_none()); + } + #[test] fn construction_axes_span_the_model_and_points_show() { let mut s = solvecraft_engine::Session::default(); diff --git a/crates/ui-egui/tests/scenarios/axis_view_drag_orbit.json b/crates/ui-egui/tests/scenarios/axis_view_drag_orbit.json new file mode 100644 index 00000000..3de94e4c --- /dev/null +++ b/crates/ui-egui/tests/scenarios/axis_view_drag_orbit.json @@ -0,0 +1,56 @@ +[ + { + "note": "Viewport right-drag orbits freely; holding Shift snaps after accumulated motion." + }, + { + "call": "ui.view", + "params": { + "view": "front" + } + }, + { + "call": "ui.drag", + "params": { + "x0": 800, + "y0": 500, + "x1": 848, + "y1": 500, + "steps": 8, + "button": "right" + } + }, + { + "expect": { + "camera": { + "yaw_deg": 22, + "pitch_deg": 0 + } + } + }, + { + "call": "ui.view", + "params": { + "view": "front" + } + }, + { + "call": "ui.drag", + "params": { + "x0": 800, + "y0": 500, + "x1": 920, + "y1": 500, + "steps": 40, + "shift": true, + "button": "right" + } + }, + { + "expect": { + "camera": { + "yaw_deg": 45, + "pitch_deg": 0 + } + } + } +] diff --git a/crates/ui-egui/tests/scenarios/axis_view_hover.json b/crates/ui-egui/tests/scenarios/axis_view_hover.json new file mode 100644 index 00000000..1afb6fb4 --- /dev/null +++ b/crates/ui-egui/tests/scenarios/axis_view_hover.json @@ -0,0 +1,67 @@ +[ + { + "note": "Signed axis tips own one highlight at a time and choose principal views." + }, + { + "call": "ui.view", + "params": { + "view": "home" + } + }, + { + "move": { + "handle": "axis_view:z" + } + }, + { + "expect": { + "count": { + "name": "axis_view_highlights", + "value": 1 + }, + "shown_handles": [ + "axis_view_hover:z" + ] + } + }, + { + "click": { + "handle": "axis_view:z" + } + }, + { + "expect": { + "camera": { + "yaw_deg": 0, + "pitch_deg": 90 + } + } + }, + { + "click": { + "handle": "axis_view:-y" + } + }, + { + "expect": { + "camera": { + "yaw_deg": 0, + "pitch_deg": 0 + } + } + }, + { + "move": [ + 800, + 500 + ] + }, + { + "expect": { + "count": { + "name": "axis_view_highlights", + "value": 0 + } + } + } +] diff --git a/crates/ui-egui/tests/scenarios/axis_view_minimal_turn.json b/crates/ui-egui/tests/scenarios/axis_view_minimal_turn.json new file mode 100644 index 00000000..b787acfa --- /dev/null +++ b/crates/ui-egui/tests/scenarios/axis_view_minimal_turn.json @@ -0,0 +1,73 @@ +[ + { + "note": "Top/bottom axis clicks preserve the nearest square heading in each up frame." + }, + { + "camera": { + "yaw": -2.356194490192345, + "pitch": 0.6154797086703874 + } + }, + { + "click": { + "handle": "axis_view:z" + } + }, + { + "expect": { + "camera": { + "yaw_deg": -90, + "pitch_deg": 90 + } + } + }, + { + "camera": { + "yaw": 2.9670597283903604, + "pitch": -0.6 + } + }, + { + "click": { + "handle": "axis_view:-z" + } + }, + { + "expect": { + "camera": { + "yaw_deg": 180, + "pitch_deg": -90 + } + } + }, + { + "call": "ui.prefs", + "params": { + "up_axis": "y" + } + }, + { + "camera": { + "yaw": -1.3962634015954636, + "pitch": 0.6 + } + }, + { + "click": { + "handle": "axis_view:y" + } + }, + { + "expect": { + "camera": { + "yaw_deg": -90, + "pitch_deg": 90, + "back": [ + 0, + 1, + 0 + ] + } + } + } +] diff --git a/crates/ui-egui/tests/scenarios/axis_view_y_up.json b/crates/ui-egui/tests/scenarios/axis_view_y_up.json new file mode 100644 index 00000000..4fb427bf --- /dev/null +++ b/crates/ui-egui/tests/scenarios/axis_view_y_up.json @@ -0,0 +1,53 @@ +[ + { + "note": "Axis labels are world X/Y/Z; with Y up, Y chooses top and Z chooses front." + }, + { + "call": "ui.prefs", + "params": { + "up_axis": "y" + } + }, + { + "call": "ui.view", + "params": { + "view": "home" + } + }, + { + "click": { + "handle": "axis_view:y" + } + }, + { + "expect": { + "camera": { + "yaw_deg": 0, + "pitch_deg": 90, + "back": [ + 0, + 1, + 0 + ] + } + } + }, + { + "click": { + "handle": "axis_view:z" + } + }, + { + "expect": { + "camera": { + "yaw_deg": 0, + "pitch_deg": 0, + "back": [ + 0, + 0, + 1 + ] + } + } + } +] diff --git a/crates/ui-egui/tests/scenarios/s39_view_cube_hover.json b/crates/ui-egui/tests/scenarios/s39_view_cube_hover.json deleted file mode 100644 index d524f0f3..00000000 --- a/crates/ui-egui/tests/scenarios/s39_view_cube_hover.json +++ /dev/null @@ -1,22 +0,0 @@ -[ - {"note": "The view cube is a cube with chamfered corners. Its targets are the 6 faces and the 8 corner facets; hover lights exactly the one under the pointer (edges are not targets: a point on an edge goes to a neighbour), and a click turns the view to it."}, - {"call": "ui.view", "params": {"view": "home"}}, - {"move": {"cube": [0, 0, 1]}}, - {"expect": {"count": {"name": "cube_highlights", "value": 1}, "shown_handles": ["cube_hover:top"]}}, - {"move": {"cube": [0.3, -1, 0.2]}}, - {"expect": {"count": {"name": "cube_highlights", "value": 1}, "shown_handles": ["cube_hover:front"]}}, - {"note": "On the top-front edge: one target, the top or the front face."}, - {"move": {"cube": [0, -1, 1]}}, - {"expect": {"count": {"name": "cube_highlights", "value": 1}}}, - {"move": {"cube": [0.873, -0.873, 0.873]}}, - {"expect": {"count": {"name": "cube_highlights", "value": 1}, "shown_handles": ["cube_hover:top-front-right"]}}, - {"click": {"handle": "cube:top-front-right"}}, - {"expect": {"camera": {"yaw_deg": -45, "pitch_deg": 35.26}}}, - {"click": {"handle": "cube:front"}}, - {"expect": {"camera": {"yaw_deg": 0, "pitch_deg": 0}}}, - {"call": "ui.view", "params": {"view": "home"}}, - {"click": {"handle": "cube:top-back-right"}}, - {"expect": {"camera": {"yaw_deg": -135, "pitch_deg": 35.26}}}, - {"move": [800, 500]}, - {"expect": {"count": {"name": "cube_highlights", "value": 0}}} -] diff --git a/crates/ui-egui/tests/scenarios/view_cube_drag_orbit.json b/crates/ui-egui/tests/scenarios/view_cube_drag_orbit.json deleted file mode 100644 index cf285b92..00000000 --- a/crates/ui-egui/tests/scenarios/view_cube_drag_orbit.json +++ /dev/null @@ -1,10 +0,0 @@ -[ - {"note": "A cube drag rotates by the whole pointer movement and releasing it does not click the pressed face."}, - {"call": "ui.view", "params": {"view": "front"}}, - {"drag": [{"cube": [0, -1, 0]}, {"cube": [1.5, -1, 0]}]}, - {"expect": {"camera": {"yaw_deg": 22, "pitch_deg": 0}}}, - {"note": "A stationary click still selects a face."}, - {"call": "ui.view", "params": {"view": "home"}}, - {"click": {"handle": "cube:front"}}, - {"expect": {"camera": {"yaw_deg": 0, "pitch_deg": 0}}} -] diff --git a/crates/ui-egui/tests/scenarios/view_cube_minimal_turn.json b/crates/ui-egui/tests/scenarios/view_cube_minimal_turn.json deleted file mode 100644 index 08290ad6..00000000 --- a/crates/ui-egui/tests/scenarios/view_cube_minimal_turn.json +++ /dev/null @@ -1,31 +0,0 @@ -[ - {"note": "TOP and BOTTOM keep the nearest square heading, with home ties toward yaw zero. Clicking side faces still selects their standard view."}, - {"call": "engine.execute", "params": {"command": "solid.box", "params": {"length": 40, "width": 30, "height": 20}}}, - {"call": "ui.view", "params": {"view": "home"}}, - {"click": {"handle": "cube:top"}}, - {"expect": {"camera": {"yaw_deg": 0, "pitch_deg": 90}}}, - {"call": "ui.view", "params": {"view": "home"}}, - {"click": {"handle": "cube:top-back-right"}}, - {"expect": {"camera": {"yaw_deg": -135, "pitch_deg": 35.26}}}, - {"click": {"handle": "cube:top"}}, - {"expect": {"camera": {"yaw_deg": -90, "pitch_deg": 90, "back": [0, 0, 1]}}}, - {"shot": "minimal-turn-top-z"}, - {"camera": {"yaw": 2.9670597283903604, "pitch": -0.6}}, - {"click": {"handle": "cube:bottom"}}, - {"expect": {"camera": {"yaw_deg": 180, "pitch_deg": -90, "back": [0, 0, -1]}}}, - {"camera": {"yaw": 1.3962634015954636, "pitch": 0.6}}, - {"click": {"handle": "cube:top"}}, - {"expect": {"camera": {"yaw_deg": 90, "pitch_deg": 90}}}, - {"call": "ui.view", "params": {"view": "home"}}, - {"click": {"handle": "cube:front"}}, - {"expect": {"camera": {"yaw_deg": 0, "pitch_deg": 0}}}, - {"note": "The same heading choice is measured in the Y-up camera frame."}, - {"call": "ui.prefs", "params": {"up_axis": "y"}}, - {"camera": {"yaw": -1.3962634015954636, "pitch": 0.6}}, - {"click": {"handle": "cube:top"}}, - {"expect": {"camera": {"yaw_deg": -90, "pitch_deg": 90, "back": [0, 1, 0]}}}, - {"shot": "minimal-turn-top-y"}, - {"camera": {"yaw": 1.3962634015954636, "pitch": -0.6}}, - {"click": {"handle": "cube:bottom"}}, - {"expect": {"camera": {"yaw_deg": 90, "pitch_deg": -90, "back": [0, -1, 0]}}} -] diff --git a/crates/ui-egui/tests/scenarios/view_cube_y_up.json b/crates/ui-egui/tests/scenarios/view_cube_y_up.json deleted file mode 100644 index 98b19040..00000000 --- a/crates/ui-egui/tests/scenarios/view_cube_y_up.json +++ /dev/null @@ -1,16 +0,0 @@ -[ - {"note": "With Y up (#3) the view cube turns with the world: its TOP is world +Y and clicking it looks down −Y in the standard plan orientation; FRONT looks along −Z."}, - {"call": "engine.execute", "params": {"command": "solid.box", "params": {"length": 40, "width": 30, "height": 20}}}, - {"call": "ui.prefs", "params": {"up_axis": "y"}}, - {"call": "ui.view", "params": {"view": "home"}}, - {"move": {"cube": [0, 0, 1]}}, - {"expect": {"shown_handles": ["cube_hover:top"]}}, - {"click": {"handle": "cube:top"}}, - {"expect": {"camera": {"pitch_deg": 90, "yaw_deg": 0, "back": [0, 1, 0]}}}, - {"call": "ui.view", "params": {"view": "home"}}, - {"click": {"handle": "cube:front"}}, - {"expect": {"camera": {"pitch_deg": 0, "yaw_deg": 0, "back": [0, 0, 1]}}}, - {"call": "ui.view", "params": {"view": "home"}}, - {"click": {"handle": "cube:top-front-right"}}, - {"expect": {"camera": {"pitch_deg": 35.26, "yaw_deg": -45, "back": [0.57735, 0.57735, 0.57735]}}} -] diff --git a/docs/book/src/guide/ui-tour.md b/docs/book/src/guide/ui-tour.md index d1086c73..5be08a8e 100644 --- a/docs/book/src/guide/ui-tour.md +++ b/docs/book/src/guide/ui-tour.md @@ -13,11 +13,13 @@ hides an item, a component's radio button makes it the active component, and right-clicking any row gives that item's menu. Rows drag into groups and other components. - **Viewport.** Click to select, Shift or Ctrl to add, drag for a window or crossing box. The - wheel zooms at the cursor, the middle button pans and Shift+middle orbits (the mouse preset in - Preferences switches to SolidWorks or Inventor style). The view cube and the navigation bar - change the view: a cube face turns to look straight at it (TOP and BOTTOM keep the nearest - quarter-turn heading), a chamfered corner to that corner's - iso view, and the house goes home. Drag the cube to orbit around the view centre. + wheel zooms at the cursor, the middle button pans and right-drag orbits freely (the mouse + preset in Preferences switches to SolidWorks or Inventor style). Hold Shift during an orbit + to snap to the nearest standard or diagonal view; the default Shift+middle gesture also snaps. + The axis view shows the world's X/Y/Z directions in SolveCraft's colors. Click a signed + axis tip to look along that axis; click the aligned tip again to look from the opposite side. + Drag the axis view to orbit around the view centre, with Shift for snapping. Top and bottom + keep the nearest quarter-turn heading. The house and navigation bar's Home button go home. Right-click for the marking menu: eight commands around the cursor, plus a menu for whatever is under it. - **Dialogs** dock on the right with a live preview. Most features also show drag handles and a @@ -30,3 +32,6 @@ Preferences (File › Preferences) hold the theme, the units of new designs, the mouse preset, the visual style and autosave. Help › Keyboard Shortcuts lists every key, and File › Keyboard Shortcuts changes them. + +Shift-orbit snapping includes 45-degree edge views and isometric corner views, with a +lightly damped spring transition and a small boundary margin to prevent flicker. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index e833f5a4..de6ff528 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -49,7 +49,7 @@ rolls the numbering back with the design), so they can go straight into later co | `ui.sketchToScreen` | `points`: `[[x, y]…]` in active-sketch coordinates | their screen points (null when behind the camera) | | `ui.dialogInput` | `index` | what clicking an input's Select chip does: picks go to that input | | `ui.at` | `world: [x,y,z]` \| `sketch: [x,y]` \| `plane: "XY"` \| `axis: "Z"` \| `dimension: "d1"` (its value text) \| `handle: "arrow"` (a manipulator handle, see `scenario::publish_handle`) | the screen point (a scenario step's target; see `crates/ui-egui/src/scenario.rs`) | -| `ui.handles` | `prefix?` | the handles drawn last frame, `[{name, x, y}]`: toolbar buttons and drop-down items `toolbar:`, panel labels `panel::`, browser rows `row: