diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 22b9d9e0..4ad89a23 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -53,6 +53,7 @@ mod sketch_modify; mod sketch_project; mod surface; mod surface_analysis; +mod view_navigation; use serde::Serialize; use serde_json::Value; @@ -219,6 +220,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> { v.extend(inspect::COMMANDS.iter()); v.extend(materials::COMMANDS.iter()); v.extend(section::COMMANDS.iter()); + v.extend(view_navigation::COMMANDS.iter()); v.extend(diagnostics::COMMANDS.iter()); v } diff --git a/crates/engine/src/cmd/view_navigation.rs b/crates/engine/src/cmd/view_navigation.rs new file mode 100644 index 00000000..a12586d1 --- /dev/null +++ b/crates/engine/src/cmd/view_navigation.rs @@ -0,0 +1,131 @@ +//! Stateless camera commands shared by the navigation cube and programmatic callers. +use serde_json::{Value, json}; +use solidcraft_render::{Camera, StandardView}; + +use super::CommandSpec; +use crate::params::{bad, vec3}; +use crate::{Result, Session}; + +pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("view.navigate", "Navigate View", navigate) + .icon("orbit") + .noundo() + .params("camera: Camera; direction?: [x,y,z] in the camera's up-axis frame; or action: left|right|up|down|clockwise|counterclockwise|home|fit → camera, fit")]; + +fn navigate(_s: &mut Session, p: &Value) -> Result { + let cmd = "view.navigate"; + let mut cam: Camera = + serde_json::from_value(p.get("camera").cloned().ok_or_else(|| bad(cmd, "camera is required"))?).map_err(|_| bad(cmd, "invalid camera"))?; + 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")); + } + let mut fit = false; + if let Some(value) = p.get("direction") { + let dir = vec3(value) + .filter(|v| v.is_finite() && v.len() <= 2.0) + .and_then(|v| v.normalized()) + .ok_or_else(|| bad(cmd, "direction must be a finite nonzero vector of length at most 2"))?; + let heading = cam.yaw; + cam = cam.looking_from(cam.from_frame(dir)); + if dir.x.abs() < 1e-9 && dir.y.abs() < 1e-9 { + cam.yaw = square_heading(heading); + } + } else { + use std::f64::consts::FRAC_PI_2; + let (r, u, _) = cam.basis(); + match p.get("action").and_then(Value::as_str) { + Some("left") => cam = cam.looking_from(-r), + Some("right") => cam = cam.looking_from(r), + Some("up") => cam = cam.looking_from(u), + Some("down") => cam = cam.looking_from(-u), + Some("clockwise") => cam.roll = (cam.roll + FRAC_PI_2).rem_euclid(std::f64::consts::TAU), + Some("counterclockwise") => cam.roll = (cam.roll - FRAC_PI_2).rem_euclid(std::f64::consts::TAU), + Some("home") => { + cam.set_view(StandardView::Iso); + fit = true; + } + Some("fit") => fit = true, + _ => return Err(bad(cmd, "unknown navigation action")), + } + } + Ok(json!({"camera": cam, "fit": fit})) +} + +/// At a halfway tie prefer the heading nearer the front plan view. +pub(crate) fn square_heading(yaw: f64) -> f64 { + if !yaw.is_finite() { + return 0.0; + } + let q = std::f64::consts::FRAC_PI_2; + let lo = (yaw / q).floor() * q; + let hi = lo + q; + if ((yaw - lo) - (hi - yaw)).abs() < 1e-6 { + let off_front = |a: f64| ((a + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI).abs(); + if off_front(lo) <= off_front(hi) { lo } else { hi } + } else if yaw - lo < hi - yaw { + lo + } else { + hi + } +} + +#[cfg(test)] +mod tests { + use super::*; + use solidcraft_geom::Vec3; + use solidcraft_render::UpAxis; + + #[test] + fn every_cube_direction_preserves_framing_without_editing_the_design() { + for up in [UpAxis::Z, UpAxis::Y] { + let mut s = Session::default(); + let before = s.doc.to_json(); + let cam = Camera { up, target: Vec3::new(10.0, 20.0, 30.0), distance: 123.0, ..Default::default() }; + for x in [-1.0, 0.0, 1.0] { + for y in [-1.0, 0.0, 1.0] { + for z in [-1.0, 0.0, 1.0] { + let Some(dir) = Vec3::new(x, y, z).normalized() else { continue }; + let result = s.execute("view.navigate", &json!({"camera":cam,"direction":[x,y,z]})).unwrap(); + let to: Camera = serde_json::from_value(result["camera"].clone()).unwrap(); + assert!(to.back().dist(cam.from_frame(dir)) < 2e-6); + assert_eq!((to.target, to.distance, to.fov, to.up), (cam.target, cam.distance, cam.fov, cam.up)); + } + } + } + assert_eq!(s.doc.to_json(), before); + assert!(s.undo.is_empty()); + } + } + + #[test] + fn arrows_roll_and_home_are_programmatic_and_reject_hostile_input() { + let mut s = Session::default(); + let cam = Camera::default(); + let (r, u, _) = cam.basis(); + for (action, dir) in [("left", -r), ("right", r), ("up", u), ("down", -u)] { + let result = navigate(&mut s, &json!({"camera":cam,"action":action})).unwrap(); + let to: Camera = serde_json::from_value(result["camera"].clone()).unwrap(); + assert!(to.back().dist(dir) < 2e-6); + } + let result = navigate(&mut s, &json!({"camera":cam,"action":"clockwise"})).unwrap(); + let to: Camera = serde_json::from_value(result["camera"].clone()).unwrap(); + assert_eq!(to.back(), cam.back()); + assert!((to.basis().0 - u).len() < 1e-9); + for action in ["home", "fit"] { + assert_eq!(navigate(&mut s, &json!({"camera":cam,"action":action})).unwrap()["fit"], true); + } + for params in [ + json!({}), + json!({"camera":cam,"direction":[0,0,0]}), + json!({"camera":cam,"direction":[1e308,0,0]}), + json!({"camera":cam,"action":"unknown"}), + ] { + assert!(s.execute("view.navigate", ¶ms).is_err()); + } + } +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index b4475b8c..f4c5f3a1 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -95,7 +95,12 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> let Some(id) = s("command").or(s("id")) else { return err("missing `command`") }; let params = p.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({})); let r = app.run(id, params); - if r.is_ok() && app.session.active_sketch.is_none() && app.session.world_state().bodies.len() == 1 && app.session.undo.len() <= 2 { + if r.is_ok() + && id != "view.navigate" + && app.session.active_sketch.is_none() + && app.session.world_state().bodies.len() == 1 + && app.session.undo.len() <= 2 + { app.fit_view(); } // After the first-body fit, so a camera the command asked for (`view.apply`) wins. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3c447f63..a9f20ea9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -465,6 +465,16 @@ impl SolveApp { let r = self.session.execute(id, ¶ms).map_err(|e| e.to_string()); self.big_operation |= now_ms() - t0 >= recovery_ui::BIG_OPERATION_MS; if let Ok(v) = &r { + if id == "view.navigate" { + let to: Camera = serde_json::from_value(v.get("camera").cloned().unwrap_or_default()).map_err(|e| e.to_string())?; + if v.get("fit").and_then(Value::as_bool).unwrap_or(false) { + let bounds = solidcraft_engine::view::bounds(&self.session); + self.animate_to(self.fitted(to)); + self.pending_fit = Some((to, bounds, true)); + } else { + self.animate_to(to); + } + } // Curves drawn in construction (or centerline) mode take that line type. sketch_palette::apply_mode(self, id, v); } diff --git a/crates/ui-egui/src/navigation_cube.rs b/crates/ui-egui/src/navigation_cube.rs new file mode 100644 index 00000000..fe8fa3ca --- /dev/null +++ b/crates/ui-egui/src/navigation_cube.rs @@ -0,0 +1,726 @@ +//! Original, code-drawn navigation cube. Flat faces share invisible picking regions. +use super::*; + +pub(super) const SIZE: f32 = 112.0; +// Paint and picking use the same original coordinate space, scaled as a group. +pub(super) const DESIGN_SIZE: f32 = 164.0; +const HALF: f64 = 40.0; +// Corner squares lie on the six flat faces, preserving a sharp cube silhouette. +// The remaining regions belong to the face, including the middle of each edge. +const INSET: f64 = 0.68; + +struct Facet { + direction: Vec3, + points: Vec, + outer_edges: Vec, + centre: Pos2, + label: String, + text_axes: Option<(egui::Vec2, egui::Vec2)>, + depth: f64, +} + +fn project(cam: &Camera, v: Vec3, scale: f64) -> egui::Vec2 { + let (r, u, _) = cam.basis(); + let v = cam.from_frame(v); + vec2((v.dot(r) * scale) as f32, -(v.dot(u) * scale) as f32) +} + +fn direction_label(d: Vec3) -> String { + [(d.z, "BOTTOM", "TOP"), (d.y, "FRONT", "BACK"), (d.x, "LEFT", "RIGHT")] + .into_iter() + .filter_map(|(v, neg, pos)| { + if v < 0.0 { + Some(neg) + } else if v > 0.0 { + Some(pos) + } else { + None + } + }) + .collect::>() + .join(" · ") +} + +fn facets(cam: &Camera, c: Pos2, half: f64) -> Vec { + if !cam.is_valid() { + return Vec::new(); + } + let mut out = Vec::new(); + let mut add = |d: Vec3, normal: Vec3, mut vertices: Vec, axes: Option<(Vec3, Vec3)>| { + let depth = cam.from_frame(normal).dot(cam.back()); + if depth <= 1e-5 { + return; + } + let centre = vertices.iter().copied().fold(Vec3::ZERO, |sum, p| sum + p) / vertices.len() as f64; + let at = c + project(cam, centre, half); + vertices.sort_by(|a, b| { + let a = project(cam, *a - centre, half); + let b = project(cam, *b - centre, half); + a.y.atan2(a.x).total_cmp(&b.y.atan2(b.x)) + }); + let outer_edges = vertices + .iter() + .zip(vertices.iter().cycle().skip(1)) + .take(vertices.len()) + .map(|(a, b)| [(a.x, b.x), (a.y, b.y), (a.z, b.z)].into_iter().filter(|(a, b)| a.abs() == 1.0 && a == b).count() >= 2) + .collect(); + let points = vertices.into_iter().map(|v| c + project(cam, v, half)).collect(); + out.push(Facet { + direction: d, + points, + outer_edges, + centre: at, + label: direction_label(d), + text_axes: axes.map(|(r, u)| (project(cam, r, 1.0), -project(cam, u, 1.0))), + depth, + }); + }; + // Nine coplanar patches per square face. The middle and four side patches + // share the face target; three corner squares share one isometric target. + for (n, r, u) in [ + (Vec3::X, Vec3::Y, Vec3::Z), + (-Vec3::X, -Vec3::Y, Vec3::Z), + (Vec3::Y, -Vec3::X, Vec3::Z), + (-Vec3::Y, Vec3::X, Vec3::Z), + (Vec3::Z, Vec3::X, Vec3::Y), + (-Vec3::Z, Vec3::X, -Vec3::Y), + ] { + let bands = [(-1.0, -INSET, -1.0), (-INSET, INSET, 0.0), (INSET, 1.0, 1.0)]; + for (x0, x1, sx) in bands { + for (y0, y1, sy) in bands { + add( + if sx != 0.0 && sy != 0.0 { n + r * sx + u * sy } else { n }, + n, + [(x0, y0), (x1, y0), (x1, y1), (x0, y1)].into_iter().map(|(x, y)| n + r * x + u * y).collect(), + if sx == 0.0 && sy == 0.0 { Some((r, u)) } else { None }, + ); + } + } + } + out.sort_by(|a, b| a.depth.total_cmp(&b.depth)); + out +} + +fn contains(points: &[Pos2], p: Pos2) -> bool { + if !p.is_finite() || points.len() < 3 { + return false; + } + let mut sign = 0.0_f32; + for (a, b) in points.iter().zip(points.iter().cycle().skip(1)).take(points.len()) { + let edge = *b - *a; + let offset = p - *a; + let cross = edge.x * offset.y - edge.y * offset.x; + if cross.abs() > 1e-3 { + if sign != 0.0 && sign.signum() != cross.signum() { + return false; + } + sign = cross; + } + } + sign != 0.0 +} + +fn hit(faces: &[Facet], p: Pos2) -> Option { + faces.iter().enumerate().rev().find(|(_, f)| contains(&f.points, p)).map(|(i, _)| i) +} + +pub(super) fn drag_origin(cam: &Camera, centre: Pos2, pointer: Pos2, scale: f32) -> bool { + hit(&facets(cam, centre, HALF * f64::from(scale)), pointer).is_some() +} + +// Private marker for label meshes with atlas-pixel UVs. The output hook replaces +// it with the font texture before any renderer sees the frame. +const LABEL_PIXELS: egui::TextureId = egui::TextureId::User(u64::from_le_bytes(*b"cubetext")); + +struct LabelAtlas; + +impl egui::plugin::Plugin for LabelAtlas { + fn debug_name(&self) -> &'static str { + "navigation cube label atlas" + } + + fn output_hook(&mut self, ctx: &egui::Context, output: &mut egui::FullOutput) { + let size = ctx.fonts(|f| f.font_image_size()); + let normalize = vec2(1.0 / size[0].max(1) as f32, 1.0 / size[1].max(1) as f32); + for shape in &mut output.shapes { + if let Shape::Mesh(mesh) = &mut shape.shape + && mesh.texture_id == LABEL_PIXELS + { + let mesh = std::sync::Arc::make_mut(mesh); + mesh.texture_id = egui::TextureId::default(); + for v in &mut mesh.vertices { + v.uv = (v.uv.to_vec2() * normalize).to_pos2(); + } + } + } + } +} + +/// Warp the font mesh into the projected face, so labels turn with the model. +fn face_text(painter: &egui::Painter, face: &Facet, ink: Color32, font_size: f32) { + let Some((r, down)) = face.text_axes else { return }; + let opacity = ((face.depth - 0.18) / 0.17).clamp(0.0, 1.0) as f32; + if opacity <= 0.0 { + return; + } + let ink = ink.gamma_multiply(opacity); + let galley = painter.layout_no_wrap(face.label.clone(), FontId::proportional(font_size), ink); + let centre = galley.rect.center(); + // Keep atlas-pixel UVs until the frame is complete: another label or widget + // can grow the atlas after this mesh is created, invalidating early UVs. + painter.ctx().add_plugin(LabelAtlas); + let text = Shape::Text(egui::epaint::TextShape::new(Pos2::ZERO, galley, ink)); + let atlas = painter.ctx().fonts(|f| f.font_image_size()); + let primitives = + painter.ctx().tessellate(vec![egui::epaint::ClippedShape { clip_rect: Rect::EVERYTHING, shape: text }], painter.ctx().pixels_per_point()); + for primitive in primitives { + if let egui::epaint::Primitive::Mesh(mut mesh) = primitive.primitive { + mesh.texture_id = LABEL_PIXELS; + for v in &mut mesh.vertices { + v.uv = pos2(v.uv.x * atlas[0] as f32, v.uv.y * atlas[1] as f32); + let local = v.pos - centre; + v.pos = face.centre + r * local.x + down * (local.y * 1.35); + } + painter.add(Shape::mesh(mesh)); + } + } +} + +fn paint_cube(painter: &egui::Painter, faces: &[Facet], hovered: Option, t: Tokens, labels: bool, scale: f32) { + // A shared, opaque triangle mesh has no antialiasing fringes between facets. + // Per-polygon fringes and miter joins overlap as a face becomes edge-on. + let mut fill_mesh = egui::epaint::Mesh::default(); + let mut edges: Vec<(Pos2, Pos2, usize, bool)> = Vec::new(); + let hovered_direction = hovered.and_then(|i| faces.get(i)).map(|f| f.direction); + for face in faces { + let base = t.cube_face; + let shade = (0.72 + 0.28 * face.depth.min(1.0)) as f32; + let highlighted = hovered_direction == Some(face.direction); + let fill = if highlighted { + t.cube_hover + } else { + Color32::from_rgb((f32::from(base.r()) * shade) as u8, (f32::from(base.g()) * shade) as u8, (f32::from(base.b()) * shade) as u8) + }; + let centre = fill_mesh.vertices.len() as u32; + fill_mesh.colored_vertex(face.centre, fill); + for &p in &face.points { + fill_mesh.colored_vertex(p, fill); + } + for (j, (&a, &b)) in face.points.iter().zip(face.points.iter().cycle().skip(1)).take(face.points.len()).enumerate() { + fill_mesh.add_triangle(centre, centre + 1 + j as u32, centre + 1 + ((j + 1) % face.points.len()) as u32); + // Coplanar picking regions have no visible seams. Only actual cube + // edges and the hovered target's boundary receive an outline. + let outer = face.outer_edges.get(j).copied().unwrap_or(false); + if !highlighted && !outer { + continue; + } + if let Some(edge) = edges + .iter_mut() + .find(|(p, q, _, _)| (p.distance_sq(a) < 1e-6 && q.distance_sq(b) < 1e-6) || (p.distance_sq(b) < 1e-6 && q.distance_sq(a) < 1e-6)) + { + edge.2 += usize::from(highlighted); + edge.3 |= outer; + } else { + edges.push((a, b, usize::from(highlighted), outer)); + } + } + } + painter.add(Shape::mesh(fill_mesh)); + for (a, b, highlights, outer) in edges { + // Two highlighted coplanar patches share an internal subdivision, not + // a target boundary. Actual cube edges remain visible across faces. + if !outer && highlights != 1 { + continue; + } + let highlighted = highlights > 0; + painter.line_segment([a, b], Stroke::new(1.0, if highlighted { t.accent } else { t.cube_border })); + } + if labels { + for face in faces { + face_text(painter, face, t.cube_text, 14.0 * scale); + } + } +} + +fn navigate(app: &mut SolveApp, action: Option<&str>, dir: Option) { + let mut params = json!({"camera":app.cam}); + if let Some(action) = action { + params["action"] = json!(action); + } + if let Some(d) = dir { + params["direction"] = json!([d.x, d.y, d.z]); + } + let _ = app.run("view.navigate", params); +} + +fn control(app: &mut SolveApp, ui: &mut egui::Ui, bounds: Rect, action: &str, tooltip: &str) -> bool { + crate::scenario::publish_handle(&format!("navigation_cube:control:{action}"), bounds.center()); + let response = ui.interact(bounds, ui.id().with(("navigation_cube", action)), Sense::click()).on_hover_text(tooltip); + let hover = response.hovered(); + if response.clicked() { + navigate(app, Some(action), None); + } + hover +} + +fn arrow(painter: &egui::Painter, at: Pos2, direction: egui::Vec2, hover: bool, t: Tokens) { + let side = vec2(-direction.y, direction.x); + painter.add(Shape::convex_polygon( + vec![at + direction * 7.0, at - direction * 5.0 + side * 7.0, at - direction * 5.0 - side * 7.0], + if hover { t.accent_soft } else { t.cube_face }, + Stroke::new(1.0, if hover { t.accent } else { t.cube_border }), + )); +} + +fn curved_arrow(painter: &egui::Painter, at: Pos2, clockwise: bool, hover: bool, t: Tokens, scale: f32) { + let (start, end) = + if clockwise { (-std::f32::consts::FRAC_PI_4 + 0.12, 0.0) } else { (-std::f32::consts::FRAC_PI_4 - 0.12, -std::f32::consts::FRAC_PI_2) }; + let points: Vec<_> = (0..=16) + .map(|i| { + let angle = egui::lerp(start..=end, f32::from(i as u8) / 16.0); + at + vec2(angle.cos(), angle.sin()) * (32.0 * scale) + }) + .collect(); + painter.add(Shape::line(points.clone(), Stroke::new(5.0 * scale, if hover { t.accent } else { t.cube_border }))); + painter.add(Shape::line(points.clone(), Stroke::new(3.0 * scale, if hover { t.accent_soft } else { t.cube_face }))); + if let Some(tip) = points.last() { + let sign = if clockwise { 1.0 } else { -1.0 }; + arrow(painter, *tip, vec2(-end.sin(), end.cos()) * (sign * scale), hover, t); + } +} + +pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { + let outer = axis_rect(app, rect); + if !outer.is_positive() { + app.viewport.axis_drag = None; + crate::scenario::publish_count("axis_view_highlights", 0.0); + return; + } + crate::scenario::publish_rect("axis_view:outer", outer); + let scale = outer.width() / DESIGN_SIZE; + let c = axis_center(app, rect); + let orbiting = axis_view_orbit(app, ui, rect); + let active = orbiting || ui.rect_contains_pointer(outer); + let t = Tokens::get().navigation_cube(active); + crate::scenario::publish_count("navigation_cube:active", f64::from(u8::from(active))); + let faces = facets(&app.cam, c, HALF * f64::from(scale)); + let hover = ui.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(rect)); + let hovered = if orbiting { None } else { hover.and_then(|p| hit(&faces, p)) }; + let painter = ui.painter_at(outer); + paint_cube(&painter, &faces, hovered, t, true, scale); + for (i, face) in faces.iter().enumerate() { + if faces.iter().take(i).any(|f| f.direction == face.direction) { + continue; + } + let pieces: Vec<_> = faces.iter().filter(|f| f.direction == face.direction).collect(); + let centre = pieces.iter().fold(egui::Vec2::ZERO, |sum, f| sum + f.centre.to_vec2()) / pieces.len() as f32; + crate::scenario::publish_handle(&format!("navigation_cube:{}", face.label.to_ascii_lowercase().replace(" · ", "_")), centre.to_pos2()); + // Keep principal-axis handles for existing automation, using only visible faces. + let world = app.cam.from_frame(face.direction); + let axis = match (world.x, world.y, world.z) { + (1.0, 0.0, 0.0) => Some("x"), + (-1.0, 0.0, 0.0) => Some("-x"), + (0.0, 1.0, 0.0) => Some("y"), + (0.0, -1.0, 0.0) => Some("-y"), + (0.0, 0.0, 1.0) => Some("z"), + (0.0, 0.0, -1.0) => Some("-z"), + _ => None, + }; + if let Some(axis) = axis { + crate::scenario::publish_handle(&format!("axis_view:{axis}"), centre.to_pos2()); + } + } + crate::scenario::publish_count("axis_view_highlights", f64::from(u8::from(hovered.is_some()))); + 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)); + if ui.input(|i| i.pointer.primary_clicked()) { + navigate(app, None, Some(face.direction)); + } + } + let square_on = faces.iter().filter(|f| f.text_axes.is_some()).count() == 1; + let (distance, button_size) = if square_on { (54.0, 20.0) } else { (74.0, 16.0) }; + for (action, offset, direction, tooltip) in [ + ("left", vec2(-distance, 0.0), vec2(1.0, 0.0), "Orbit left 90°"), + ("right", vec2(distance, 0.0), vec2(-1.0, 0.0), "Orbit right 90°"), + ("up", vec2(0.0, -distance), vec2(0.0, 1.0), "Orbit up 90°"), + ("down", vec2(0.0, distance), vec2(0.0, -1.0), "Orbit down 90°"), + ] { + let at = c + offset * scale; + let hovered = control(app, ui, Rect::from_center_size(at, vec2(button_size, button_size) * scale), action, tooltip); + if active { + arrow(&painter, at, direction * scale, hovered, t); + } + } + for (action, offset, size, clockwise, tooltip) in [ + ("counterclockwise", vec2(42.0, -72.0), vec2(40.0, 20.0), false, "Rotate view counterclockwise 90°"), + ("clockwise", vec2(72.0, -42.0), vec2(20.0, 40.0), true, "Rotate view clockwise 90°"), + ] { + let hovered = control(app, ui, Rect::from_center_size(c + offset * scale, size * scale), action, tooltip); + if active { + curved_arrow(&painter, c + vec2(42.0, -42.0) * scale, clockwise, hovered, t, scale); + } + } + let home = Rect::from_center_size(c + vec2(-65.0, -65.0) * scale, vec2(26.0, 26.0) * scale); + crate::scenario::publish_rect("axis_view:home", home); + crate::scenario::publish_handle("axis_view:home", home.center()); + let home_hover = control(app, ui, home, "home", "Home: isometric view and fit model"); + if active { + icons::paint(&painter, home.shrink(4.0 * scale), "home", if home_hover { t.accent } else { t.cube_face }, t.cube_face, t.accent); + } + let fit = Rect::from_center_size(c + vec2(67.0, 67.0) * scale, vec2(22.0, 22.0) * scale); + let fit_hover = control(app, ui, fit, "fit", "Fit model in view"); + if active { + painter.circle(fit.center(), 6.0 * scale, if fit_hover { t.accent_soft } else { t.cube_face }, Stroke::new(1.0, t.cube_border)); + } + // Small world-axis triad below the cube; it follows the selected up-axis frame. + let origin = c + vec2(-60.0, 52.0) * scale; + for (d, color, label) in [(Vec3::X, t.axis_x, "X"), (Vec3::Y, t.axis_y, "Y"), (Vec3::Z, t.axis_z, "Z")] { + let (r, u, _) = app.cam.basis(); + let tip = origin + vec2((d.dot(r) * 17.0) as f32, -(d.dot(u) * 17.0) as f32) * scale; + painter.line_segment([origin, tip], Stroke::new(1.5, color)); + painter.text(tip, Align2::CENTER_CENTER, label, FontId::proportional(8.0), color); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use solidcraft_engine::render::{StandardView, UpAxis}; + + fn paint_frame(ctx: &egui::Context, paint: impl FnMut(&mut egui::Ui)) -> egui::FullOutput { + let mut output = ctx.run_ui(egui::RawInput::default(), paint); + // These tests inspect shapes without uploading textures to a renderer. + output.textures_delta.clear(); + output + } + + #[test] + fn cube_targets_are_only_six_faces_and_eight_corners_in_both_up_frames() { + for up in [UpAxis::Z, UpAxis::Y] { + let mut directions = Vec::new(); + for view in [StandardView::Front, StandardView::Back, StandardView::Top, StandardView::Bottom, StandardView::Left, StandardView::Right] { + let mut cam = Camera { up, ..Default::default() }; + cam.set_view(view); + for facet in facets(&cam, Pos2::ZERO, HALF) { + let axes = [facet.direction.x, facet.direction.y, facet.direction.z].into_iter().filter(|v| *v != 0.0).count(); + assert!(axes == 1 || axes == 3, "cube edges must not be independent targets: {}", facet.label); + if !directions.contains(&facet.direction) { + directions.push(facet.direction); + } + } + } + assert_eq!(directions.len(), 14); + } + } + + #[test] + fn former_edge_strips_click_the_face_and_hover_without_internal_seams() { + let mut h = crate::scenario::Harness::new(); + h.call("ui.prefs", json!({"navigator_style":"cube"})); + let ctx = egui::Context::default(); + h.app.preferences.look_at_anim = false; + for up in ["z", "y"] { + h.call("ui.prefs", json!({"up_axis":up})); + h.call("ui.view", json!({"view":"front"})); + let camera = h.app.cam; + let outer = crate::scenario::rect_of("axis_view:outer").unwrap(); + let half = HALF * f64::from(outer.width() / DESIGN_SIZE); + let faces = facets(&camera, outer.center(), half); + assert!(crate::scenario::handle_at("navigation_cube:front_right").is_none()); + assert!(crate::scenario::handle_at("navigation_cube:top_front").is_none()); + for direction in [Vec3::X, -Vec3::X, Vec3::Z, -Vec3::Z] { + let p = outer.center() + project(&camera, direction * 0.84, half); + let hovered = hit(&faces, p).unwrap(); + assert_eq!(faces[hovered].direction, -Vec3::Y); + h.call("ui.move", json!({"x":p.x,"y":p.y})); + h.call("ui.click", json!({"x":p.x,"y":p.y})); + assert_eq!(h.app.cam, camera, "a former edge strip must select FRONT, never a diagonal view"); + let t = Tokens::light(); + let output = paint_frame(&ctx, |ui| { + paint_cube(ui.painter(), &faces, Some(hovered), t, false, 1.0); + }); + let centre = Rect::from_center_size(outer.center(), vec2(2.0, 2.0) * (INSET * half) as f32); + let mut outline = 0; + for shape in &output.shapes { + if let Shape::LineSegment { points: [a, b], stroke } = &shape.shape + && stroke.color == t.accent + { + outline += 1; + assert!(!centre.contains(a.lerp(*b, 0.5)), "face subdivisions must not draw hover seams"); + } + } + assert_eq!(outline, 12, "one continuous face outline around the four corner targets"); + } + } + } + + #[test] + fn hovering_cube_sections_activates_then_leaving_restores_idle_without_navigation() { + let mut h = crate::scenario::Harness::new(); + h.call("ui.prefs", json!({"navigator_style":"cube"})); + let camera = h.app.cam; + let design = h.app.session.doc.to_json(); + let viewport = h.app.viewport.rect.unwrap(); + for name in ["front", "right", "top_front_right"] { + let p = crate::scenario::handle_at(&format!("navigation_cube:{name}")).unwrap(); + h.call("ui.move", json!({"x":p.x,"y":p.y})); + assert_eq!(crate::scenario::count_of("navigation_cube:active"), Some(1.0)); + assert_eq!(crate::scenario::count_of("axis_view_highlights"), Some(1.0)); + h.call("ui.move", json!({"x":viewport.center().x,"y":viewport.center().y})); + assert_eq!(crate::scenario::count_of("navigation_cube:active"), Some(0.0)); + assert_eq!(crate::scenario::count_of("axis_view_highlights"), Some(0.0)); + } + // Reveal auxiliary controls even before the pointer touches a facet. + let p = crate::scenario::handle_at("navigation_cube:control:home").unwrap(); + h.call("ui.move", json!({"x":p.x,"y":p.y})); + assert_eq!(crate::scenario::count_of("navigation_cube:active"), Some(1.0)); + assert_eq!(crate::scenario::count_of("axis_view_highlights"), Some(0.0)); + assert_eq!(h.app.cam, camera); + assert_eq!(h.app.session.doc.to_json(), design); + assert!(h.app.session.undo.is_empty()); + } + + #[test] + fn hovered_face_and_corner_have_a_pale_blue_opaque_fill_in_both_themes() { + let ctx = egui::Context::default(); + let faces = facets(&Camera::default(), pos2(100.0, 100.0), HALF); + for t in [Tokens::light(), Tokens::dark()] { + assert_ne!(t.navigation_cube(false).cube_face, t.cube_face); + for name in ["FRONT", "RIGHT", "TOP · FRONT · RIGHT"] { + let hovered = faces.iter().position(|f| f.label == name).unwrap(); + let output = paint_frame(&ctx, |ui| { + paint_cube(ui.painter(), &faces, Some(hovered), t.navigation_cube(true), false, 1.0); + }); + let mesh = output.shapes.iter().find_map(|s| if let Shape::Mesh(m) = &s.shape { Some(m) } else { None }).unwrap(); + let highlighted: Vec<_> = mesh.vertices.iter().filter(|v| v.color == t.cube_hover).collect(); + let pieces: Vec<_> = faces.iter().filter(|f| f.direction == faces[hovered].direction).collect(); + assert_eq!( + highlighted.len(), + pieces.iter().map(|f| f.points.len() + 1).sum::(), + "highlight the same target on adjacent flat faces" + ); + assert!(highlighted.iter().all(|v| pieces.iter().any(|f| contains(&f.points, v.pos)))); + assert!(mesh.vertices.iter().all(|v| v.color.a() == 255)); + assert!(output.shapes.iter().any(|s| matches!(&s.shape, Shape::LineSegment { stroke, .. } if stroke.color == t.accent))); + } + } + } + + #[test] + fn idle_principal_views_have_square_faces_without_visible_picking_seams() { + let ctx = egui::Context::default(); + for up in [UpAxis::Z, UpAxis::Y] { + for view in [StandardView::Front, StandardView::Back, StandardView::Top, StandardView::Bottom, StandardView::Left, StandardView::Right] { + let mut cam = Camera { up, ..Default::default() }; + cam.set_view(view); + let centre = pos2(100.0, 100.0); + let faces = facets(&cam, centre, HALF); + assert_eq!(faces.len(), 9, "one square face partitioned into nine coplanar picking regions"); + let bounds = Rect::from_points(&faces.iter().flat_map(|f| f.points.iter().copied()).collect::>()); + let expected = Rect::from_center_size(centre, vec2(80.0, 80.0)); + assert!( + bounds.min.distance(expected.min) < 1e-4 && bounds.max.distance(expected.max) < 1e-4, + "the silhouette must reach all four square corners" + ); + let output = paint_frame(&ctx, |ui| { + paint_cube(ui.painter(), &faces, None, Tokens::light(), false, 1.0); + }); + let mesh = output.shapes.iter().find_map(|s| if let Shape::Mesh(m) = &s.shape { Some(m) } else { None }).unwrap(); + let fill = mesh.vertices.first().unwrap().color; + assert!(mesh.vertices.iter().all(|v| v.color == fill), "picking regions must have identical face shading"); + for s in &output.shapes { + if let Shape::LineSegment { points: [a, b], .. } = &s.shape { + assert!( + (a.x - expected.left()).abs() < 1e-4 && (b.x - expected.left()).abs() < 1e-4 + || (a.x - expected.right()).abs() < 1e-4 && (b.x - expected.right()).abs() < 1e-4 + || (a.y - expected.top()).abs() < 1e-4 && (b.y - expected.top()).abs() < 1e-4 + || (a.y - expected.bottom()).abs() < 1e-4 && (b.y - expected.bottom()).abs() < 1e-4, + "idle outlines must lie on the cube perimeter, never on a picking boundary" + ); + } + } + } + } + } + + #[test] + fn projected_facets_pick_their_own_centres_in_all_views_and_up_frames() { + for up in [UpAxis::Z, UpAxis::Y] { + for view in [ + StandardView::Front, + StandardView::Back, + StandardView::Top, + StandardView::Bottom, + StandardView::Left, + StandardView::Right, + StandardView::Iso, + ] { + let mut cam = Camera { up, ..Default::default() }; + cam.set_view(view); + for roll in [0.0, 0.7, std::f64::consts::FRAC_PI_2] { + cam.roll = roll; + let faces = facets(&cam, Pos2::ZERO, HALF); + assert!(!faces.is_empty()); + for (i, face) in faces.iter().enumerate() { + assert_eq!(hit(&faces, face.centre), Some(i), "{up:?} {view:?} {roll} {}", face.label); + } + assert!(hit(&faces, pos2(80.0, 80.0)).is_none()); + } + } + } + } + + #[test] + fn face_labels_use_normalized_font_atlas_coordinates() { + let ctx = egui::Context::default(); + let output = paint_frame(&ctx, |ui| { + let faces = facets(&Camera::default(), pos2(100.0, 100.0), HALF); + for face in &faces { + face_text(ui.painter(), face, Tokens::light().cube_text, 14.0); + } + }); + let meshes: Vec<_> = output.shapes.iter().filter_map(|shape| if let Shape::Mesh(mesh) = &shape.shape { Some(mesh) } else { None }).collect(); + assert_eq!(meshes.len(), 3, "the isometric view must show three face labels"); + for mesh in meshes { + assert!(!mesh.vertices.is_empty()); + assert!(mesh.vertices.iter().all(|v| (0.0..=1.0).contains(&v.uv.x) && (0.0..=1.0).contains(&v.uv.y))); + } + } + + #[test] + fn label_uvs_follow_font_atlas_growth_later_in_the_same_frame() { + let ctx = egui::Context::default(); + let mut pixel_uv = Pos2::ZERO; + let mut glyph_vertex = 0; + let mut early_size = [0; 2]; + let output = paint_frame(&ctx, |ui| { + let mut cam = Camera::default(); + cam.set_view(StandardView::Top); + let faces = facets(&cam, pos2(100.0, 100.0), HALF); + let face = faces.iter().find(|f| f.label == "TOP" && f.text_axes.is_some()).unwrap(); + let galley = ui.painter().layout_no_wrap(face.label.clone(), FontId::proportional(14.0), Tokens::light().cube_text); + let (index, vertex) = galley.rows.first().unwrap().visuals.mesh.vertices.iter().enumerate().find(|(_, v)| v.uv.y > 1.0).unwrap(); + glyph_vertex = index; + pixel_uv = vertex.uv; + face_text(ui.painter(), face, Tokens::light().cube_text, 14.0); + early_size = ctx.fonts(|f| f.font_image_size()); + // Other widgets can allocate new glyphs after the cube has been drawn. + let text: String = (32_u8..127).map(char::from).collect(); + let _ = ui.painter().layout_no_wrap(text, FontId::proportional(48.0), Color32::WHITE); + }); + let size = ctx.fonts(|f| f.font_image_size()); + assert!(size[1] > early_size[1], "the reproduction must grow the font atlas"); + let vertex = output.shapes.iter().find_map(|s| if let Shape::Mesh(m) = &s.shape { m.vertices.get(glyph_vertex) } else { None }).unwrap(); + assert!(output.shapes.iter().all(|s| !matches!(&s.shape, Shape::Mesh(m) if m.texture_id == LABEL_PIXELS))); + assert!((vertex.uv.x * size[0] as f32 - pixel_uv.x).abs() < 1e-3); + assert!((vertex.uv.y * size[1] as f32 - pixel_uv.y).abs() < 1e-3); + } + + #[test] + fn rotating_cube_facets_stay_opaque_and_inside_the_projected_geometry() { + let ctx = egui::Context::default(); + for up in [UpAxis::Z, UpAxis::Y] { + for view in [StandardView::Top, StandardView::Bottom, StandardView::Front, StandardView::Back, StandardView::Left, StandardView::Right] { + let from = Camera { up, ..Default::default() }; + let mut to = from; + to.set_view(view); + for step in 0..=60 { + let cam = Camera::interpolate(&from, &to, f64::from(step) / 60.0); + let faces = facets(&cam, pos2(100.0, 100.0), HALF); + let bounds = Rect::from_points(&faces.iter().flat_map(|f| f.points.iter().copied()).collect::>()); + let output = paint_frame(&ctx, |ui| { + paint_cube(ui.painter(), &faces, Some(0), Tokens::light(), false, 1.0); + }); + let fills: Vec<_> = output.shapes.iter().filter_map(|s| if let Shape::Mesh(m) = &s.shape { Some(m) } else { None }).collect(); + assert_eq!(fills.len(), 1); + let mesh = fills[0]; + assert!(mesh.is_valid()); + assert!(mesh.vertices.iter().all(|v| v.color.a() == 255 && v.pos.is_finite() && bounds.contains(v.pos))); + } + } + } + } + + #[test] + fn control_navigation_keeps_its_transition_in_a_single_body_design() { + let mut h = crate::scenario::Harness::new(); + h.call("ui.prefs", json!({"navigator_style":"cube"})); + h.app.run("solid.box", json!({"length":20,"width":20,"height":20})).unwrap(); + h.frames(4); + let cam = h.app.cam; + let result = h.call("engine.execute", json!({"command":"view.navigate","params":{"camera":cam,"direction":[0,0,1]}})); + assert_eq!(result["ok"], true); + assert!(h.app.cam.back().dist(Vec3::Z) < 1e-6, "automatic model fitting must not cancel the navigation command"); + assert_eq!(h.app.cam.target, cam.target); + assert_eq!(h.app.cam.distance, cam.distance); + } + + #[test] + fn cube_navigation_eases_through_intermediate_views_and_can_be_interrupted() { + let mut h = crate::scenario::Harness::new(); + h.call("ui.prefs", json!({"navigator_style":"cube"})); + assert!(h.app.preferences.look_at_anim); + for (action, direction) in [(None, Some(Vec3::Z)), (Some("right"), None), (Some("clockwise"), None), (Some("home"), None)] { + let from = h.app.cam; + navigate(&mut h.app, action, direction); + let anim = h.app.cam_anim.expect("cube navigation must animate"); + assert_eq!(h.app.cam, from, "starting a transition must not jump the camera"); + let mut previous = from; + for frame in 1..=60 { + let now = anim.start + anim.duration * f64::from(frame) / 60.0 + if frame == 60 { 1e-9 } else { 0.0 }; + let (cam, done) = anim.sample(now); + let angle = |a: Vec3, b: Vec3| a.dot(b).clamp(-1.0, 1.0).acos().to_degrees(); + assert!(angle(previous.back(), cam.back()).max(angle(previous.basis().0, cam.basis().0)) < 10.0); + assert_eq!(done, frame == 60); + if frame == 30 { + assert_ne!(cam.basis(), from.basis()); + assert_ne!(cam.basis(), anim.to.basis()); + } + previous = cam; + } + h.app.cam = anim.sample(anim.start + anim.duration / 2.0).0; + let intermediate = h.app.cam; + navigate(&mut h.app, Some("left"), None); + assert_eq!(h.app.cam_anim.unwrap().from, intermediate, "another click starts where the camera is"); + h.app.cam = anim.to; + h.app.cancel_view_animation(); + } + } + + #[test] + fn cube_faces_corners_roll_and_home_drive_the_shared_command() { + let mut h = crate::scenario::Harness::new(); + h.call("ui.prefs", json!({"navigator_style":"cube"})); + h.app.preferences.look_at_anim = false; + let before = h.app.session.doc.to_json(); + for (name, direction) in [("top", Vec3::Z), ("front", -Vec3::Y), ("right", Vec3::X), ("top_front_right", Vec3::Z - Vec3::Y + Vec3::X)] { + h.call("ui.view", json!({"view":"iso"})); + let p = crate::scenario::handle_at(&format!("navigation_cube:{name}")).unwrap(); + let scale = crate::scenario::rect_of("axis_view:outer").unwrap().width() / DESIGN_SIZE; + let offsets = if name == "top_front_right" { + vec![vec2(0.0, 0.0), vec2(8.0, 0.0), vec2(-8.0, 0.0), vec2(0.0, 8.0), vec2(0.0, -8.0)] + } else { + vec![vec2(0.0, 0.0)] + }; + for offset in offsets { + let offset = offset * scale; + h.call("ui.view", json!({"view":"iso"})); + h.call("ui.click", json!({"x":p.x+offset.x,"y":p.y+offset.y})); + assert!(h.app.cam.back().dist(direction.normalized().unwrap()) < 2e-6, "{name} {offset:?}"); + assert!(h.app.session.selection.is_empty()); + } + } + let camera = h.app.cam; + let p = crate::scenario::handle_at("navigation_cube:control:clockwise").unwrap(); + h.call("ui.click", json!({"x":p.x,"y":p.y})); + assert!((h.app.cam.roll - camera.roll - std::f64::consts::FRAC_PI_2).abs() < 1e-6); + assert!(h.app.cam.back().dist(camera.back()) < 1e-6); + let p = crate::scenario::handle_at("navigation_cube:control:home").unwrap(); + h.call("ui.click", json!({"x":p.x,"y":p.y})); + assert!(h.app.cam.back().dist(Camera::default().back()) < 1e-6); + assert_eq!(h.app.session.doc.to_json(), before); + assert!(h.app.session.undo.is_empty()); + } +} diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index 516f1ba2..6193ab83 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -173,6 +173,26 @@ pub const PAGE_GRAPHICS: usize = 4; pub const THEMES: [(&str, &str); 3] = [("dark", "Dark"), ("light", "Light"), ("system", "System")]; pub const MSAA: [u32; 4] = [1, 2, 4, 8]; +/// Original view navigators, independent of the mouse navigation preset. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum NavigatorStyle { + #[default] + Axes, + Cube, +} + +impl NavigatorStyle { + pub const ALL: [Self; 2] = [Self::Axes, Self::Cube]; + + pub fn label(self) -> &'static str { + match self { + Self::Axes => "Signed axes", + Self::Cube => "Cube", + } + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct Prefs { @@ -180,6 +200,8 @@ pub struct Prefs { pub default_units: String, /// Mouse mapping: `fusion`, `solidworks` or `inventor`. pub nav: String, + /// View orientation widget: original signed axes (default) or compact cube. + pub navigator_style: NavigatorStyle, /// Show the radial ring and enable hold/flick gestures for viewport context menus. pub radial_context_menu: bool, /// The wheel zooms out when turned forward. @@ -221,6 +243,7 @@ impl Default for Prefs { Prefs { default_units: "mm".into(), nav: "fusion".into(), + navigator_style: NavigatorStyle::default(), radial_context_menu: false, zoom_reverse: false, orbit_cursor: false, @@ -332,6 +355,7 @@ fn defaults(app: &mut SolveApp, page: usize) { } 1 => { p.nav = d.nav; + p.navigator_style = d.navigator_style; p.radial_context_menu = d.radial_context_menu; p.zoom_reverse = d.zoom_reverse; p.zoom_to_cursor = d.zoom_to_cursor; @@ -839,6 +863,18 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { ui.label("Context menu"); ui.checkbox(&mut app.preferences.radial_context_menu, "Radial context menu"); ui.end_row(); + ui.label("View navigator"); + let picker = + egui::ComboBox::from_id_salt("pref_navigator_style").selected_text(app.preferences.navigator_style.label()).show_ui(ui, |ui| { + for style in NavigatorStyle::ALL { + let response = ui.selectable_value(&mut app.preferences.navigator_style, style, style.label()); + crate::scenario::publish_rect(&format!("prefs:navigator:{}", style.label()), response.rect); + crate::scenario::publish_handle(&format!("prefs:navigator:{}", style.label()), response.rect.center()); + } + }); + crate::scenario::publish_rect("prefs:navigator", picker.response.rect); + crate::scenario::publish_handle("prefs:navigator", picker.response.rect.center()); + ui.end_row(); ui.label("Reverse zoom direction"); ui.checkbox(&mut app.preferences.zoom_reverse, ""); ui.end_row(); @@ -1112,6 +1148,50 @@ mod tests { assert_eq!(parse_length_mm("1e400", 1.0), None); } + #[test] + fn navigator_picker_preserves_axes_and_follows_preferences_transactions() { + let old: Prefs = serde_json::from_value(json!({"nav":"solidworks"})).unwrap(); + assert_eq!(old.navigator_style, NavigatorStyle::Axes); + let mut h = crate::scenario::Harness::new(); + assert!(crate::scenario::handle_at("axis_view:z").is_some()); + assert!(crate::scenario::handle_at("navigation_cube:front").is_none()); + let camera = h.app.cam; + let design = h.app.session.doc.to_json(); + let nav = h.app.preferences.nav.clone(); + h.call("ui.prefs", json!({"open":"Navigation"})); + let picker = crate::scenario::rect_of("prefs:navigator").unwrap().center(); + h.call("ui.click", json!({"x":picker.x,"y":picker.y})); + let cube = crate::scenario::rect_of("prefs:navigator:Cube").unwrap().center(); + assert!(crate::scenario::rect_of("prefs:navigator:Signed axes").is_some()); + h.call("ui.click", json!({"x":cube.x,"y":cube.y})); + assert_eq!(h.app.preferences.navigator_style, NavigatorStyle::Cube); + assert!(crate::scenario::handle_at("navigation_cube:front").is_some()); + assert_eq!(h.app.preferences.nav, nav); + assert_eq!(h.app.cam, camera); + assert_eq!(h.app.session.doc.to_json(), design); + let mut restored = SolveApp::new(solidcraft_engine::Session::default(), Default::default()); + restored.load_prefs(&h.app.prefs()); + assert_eq!(restored.preferences.navigator_style, NavigatorStyle::Cube); + let before = h.app.preferences.clone(); + assert!(control(&mut h.app, &json!({"navigator_style":"unknown", "nav":"solidworks"})).is_err()); + assert_eq!(h.app.preferences, before, "invalid settings must fail atomically"); + h.call("ui.prefs", json!({"button":"cancel"})); + assert_eq!(h.app.preferences.navigator_style, NavigatorStyle::Axes); + assert!(crate::scenario::handle_at("navigation_cube:front").is_none()); + h.call("ui.prefs", json!({"open":"Navigation","navigator_style":"cube"})); + h.call("ui.prefs", json!({"button":"apply"})); + h.call("ui.prefs", json!({"button":"defaults"})); + assert_eq!(h.app.preferences.navigator_style, NavigatorStyle::Axes); + h.call("ui.prefs", json!({"button":"cancel"})); + assert_eq!(h.app.preferences.navigator_style, NavigatorStyle::Cube); + h.call("ui.prefs", json!({"navigator_style":"axes", "look_at_anim":false})); + h.call("ui.view", json!({"view":"front"})); + let axis = crate::scenario::handle_at("axis_view:-y").unwrap(); + h.call("ui.click", json!({"x":axis.x,"y":axis.y})); + assert!(h.app.cam.back().dist(Vec3::Y) < 1e-6, "original axes retain click-again reversal"); + assert_eq!(h.app.session.doc.to_json(), design); + } + #[test] fn radial_context_menu_defaults_off_and_follows_preferences_transactions() { assert!(!Prefs::default().radial_context_menu); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index d971f739..1032c470 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -70,6 +70,12 @@ pub struct Tokens { pub preview_add: Color32, /// Removed material in a feature preview (translucent). pub preview_cut: Color32, + /// Navigation cube faces, outline and labels. + pub cube_face: Color32, + pub cube_border: Color32, + pub cube_text: Color32, + /// Light blue hover fill, readable on the pale cube in either theme. + pub cube_hover: Color32, // ---- viewport ---- /// Viewport background (top and bottom of the gradient; equal for a flat fill). pub viewport_top: Color32, @@ -128,6 +134,21 @@ impl Tokens { if is_dark() { Tokens::dark() } else { Tokens::light() } } + /// Fade idle cube colours into the viewport while retaining opaque facets. + pub fn navigation_cube(self, active: bool) -> Tokens { + if active { + return self; + } + let mut t = self; + t.cube_face = t.cube_face.lerp_to_gamma(t.viewport_top, 0.55); + t.cube_border = t.cube_border.lerp_to_gamma(t.viewport_top, 0.55); + t.cube_text = t.cube_text.lerp_to_gamma(t.viewport_top, 0.55); + t.axis_x = t.axis_x.lerp_to_gamma(t.viewport_top, 0.55); + t.axis_y = t.axis_y.lerp_to_gamma(t.viewport_top, 0.55); + t.axis_z = t.axis_z.lerp_to_gamma(t.viewport_top, 0.55); + t + } + pub const fn light() -> Tokens { Tokens { app_bar: Color32::from_rgb(52, 56, 64), @@ -171,6 +192,10 @@ impl Tokens { manipulator: Color32::from_rgb(40, 110, 220), preview_add: Color32::from_rgb(112, 160, 226), preview_cut: Color32::from_rgba_unmultiplied_const(232, 84, 44, 110), + cube_face: Color32::from_rgb(236, 239, 244), + cube_border: Color32::from_rgb(85, 94, 110), + cube_text: Color32::from_rgb(38, 42, 50), + cube_hover: Color32::from_rgb(190, 220, 252), viewport_top: Color32::from_rgb(240, 243, 247), viewport_bottom: Color32::from_rgb(203, 210, 221), grid: Color32::from_rgb(196, 202, 212), diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index c8b38928..a55256e0 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -23,6 +23,9 @@ mod appearance_tests; #[path = "viewport_plane_target_tests.rs"] mod plane_target_tests; +#[path = "navigation_cube.rs"] +mod navigation_cube; + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum NavMode { Orbit, @@ -1179,6 +1182,13 @@ fn section_clip(app: &SolveApp) -> [f32; 4] { } /// Space kept free at the top right of the viewport for the axis view (docked dialogs start below). +pub(crate) fn axis_view_size(style: crate::prefs::NavigatorStyle) -> f32 { + match style { + crate::prefs::NavigatorStyle::Axes => 116.0, + crate::prefs::NavigatorStyle::Cube => navigation_cube::SIZE, + } +} +// Keep command panels anchored independently of the compact cube's display size. pub const AXIS_VIEW_CLEARANCE: f32 = 168.0; /// Axis length as a multiple of the origin plane size (solid part 1, dashed beyond). @@ -2700,27 +2710,25 @@ pub(crate) fn refresh_layout(app: &mut SolveApp, scale: f32) { .into_iter() .flatten() .collect(); - app.viewport.layout = Some(crate::viewport_layout::Layout::new(rect, &docks, scale)); + app.viewport.layout = Some(crate::viewport_layout::Layout::new(rect, &docks, scale, app.preferences.navigator_style)); } } -/// Compact orientation gizmo, drawn and hit-tested in world coordinates. -const AXIS_LENGTH: f64 = 38.0; -const AXIS_RADIUS: f32 = 11.0; - +/// Orientation controls share the layout reservation used by model fitting. fn axis_center(app: &SolveApp, rect: Rect) -> Pos2 { axis_rect(app, rect).center() } fn axis_rect(app: &SolveApp, rect: Rect) -> Rect { - app.viewport - .layout - .as_ref() - .filter(|l| l.viewport == rect) - .map(|l| l.axis_view) - .unwrap_or_else(|| Rect::from_center_size(pos2(rect.right() - 68.0, rect.top() + 68.0), vec2(116.0, 116.0))) + app.viewport.layout.as_ref().filter(|l| l.viewport == rect).map(|l| l.axis_view).unwrap_or_else(|| { + let size = axis_view_size(app.preferences.navigator_style); + Rect::from_min_size(pos2(rect.right() - size, rect.top()), vec2(size, size)) + }) } +const AXIS_LENGTH: f64 = 38.0; +const AXIS_RADIUS: f32 = 11.0; + /// Six signed world axes, sorted back to front so overlapping tips have one owner. fn axis_tips(cam: &Camera, c: Pos2) -> Vec<(&'static str, Vec3, Pos2, f64)> { let (r, u, b) = cam.basis(); @@ -2837,7 +2845,12 @@ fn axis_view_orbit(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) -> bool { (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) { - app.viewport.axis_drag = origin.filter(|p| axis_rect(app, rect).contains(*p)); + app.viewport.axis_drag = origin.filter(|p| match app.preferences.navigator_style { + crate::prefs::NavigatorStyle::Axes => axis_rect(app, rect).contains(*p), + crate::prefs::NavigatorStyle::Cube => { + navigation_cube::drag_origin(&app.cam, axis_center(app, rect), *p, axis_rect(app, rect).width() / navigation_cube::DESIGN_SIZE) + } + }); if app.viewport.axis_drag.is_some() { app.viewport.orbit_free = None; } @@ -2857,6 +2870,13 @@ fn axis_view_orbit(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) -> bool { } fn axis_view(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { + match app.preferences.navigator_style { + crate::prefs::NavigatorStyle::Axes => signed_axis_view(app, ui, rect), + crate::prefs::NavigatorStyle::Cube => navigation_cube::show(app, ui, rect), + } +} + +fn signed_axis_view(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { if !axis_rect(app, rect).is_positive() { app.viewport.axis_drag = None; crate::scenario::publish_count("axis_view_highlights", 0.0); @@ -3000,7 +3020,14 @@ fn nav_bar(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { .map(|l| l.navigation) .unwrap_or_else(|| Rect::from_center_size(pos2(rect.center().x, rect.bottom() - 22.0), vec2(w, 30.0))); crate::scenario::publish_rect("nav:outer", bar); - let painter = ui.painter_at(rect); + let active = ui.rect_contains_pointer(bar) + || ui.rect_contains_pointer(axis_rect(app, rect)) + || app.viewport.axis_drag.is_some() + || app.viewport.nav.is_some() + || app.viewport.spin + || menu_open; + let mut painter = ui.painter_at(rect); + painter.set_opacity(if active || app.preferences.navigator_style == crate::prefs::NavigatorStyle::Axes { 1.0 } else { 0.45 }); painter.rect(bar, 6.0, t.overlay, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); let columns = ((bar.width() - 10.0) / 30.0).round().clamp(1.0, 10.0) as usize; for (i, (icon, tip)) in items.iter().enumerate() { @@ -4318,14 +4345,15 @@ mod tests { } #[test] - fn axis_view_click_again_flips_and_drag_snaps() { + fn navigation_cube_repeated_face_click_keeps_view_and_drag_snaps() { let mut h = crate::scenario::Harness::new(); + h.call("ui.prefs", json!({"navigator_style":"cube"})); 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}}}, + {"expect":{"camera":{"pitch_deg":90,"yaw_deg":0}}}, {"call":"ui.view","params":{"view":"front"}} ])) .unwrap(); diff --git a/crates/ui-egui/src/viewport_layout.rs b/crates/ui-egui/src/viewport_layout.rs index cf2c8393..973b0e7e 100644 --- a/crates/ui-egui/src/viewport_layout.rs +++ b/crates/ui-egui/src/viewport_layout.rs @@ -39,13 +39,14 @@ fn free_rect(view: Rect, obstacles: &[Rect]) -> Rect { } impl Layout { - pub fn new(viewport: Rect, docks: &[Rect], scale: f32) -> Self { + pub fn new(viewport: Rect, docks: &[Rect], scale: f32, style: crate::prefs::NavigatorStyle) -> Self { let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 }; let mut obstacles: Vec<_> = docks.iter().take(6).copied().filter(|r| r.is_finite()).map(|r| r.expand(4.0)).collect(); // Navigation is anchored to the viewport, never to transient dialog space. let available = viewport; - let mut axis_view = if available.width() >= 116.0 && available.height() >= 116.0 { - Rect::from_min_size(pos2(available.right() - 116.0, available.top()), vec2(116.0, 116.0)) + let cube_size = super::viewport::axis_view_size(style); + let mut axis_view = if available.width() >= cube_size && available.height() >= cube_size { + Rect::from_min_size(pos2(available.right() - cube_size, available.top()), vec2(cube_size, cube_size)) } else { Rect::NOTHING }; @@ -60,7 +61,12 @@ impl Layout { // On tiny viewports keep the bottom controls usable rather than overlapping them. // This decision depends only on viewport size, never on an overlay. if axis_view.intersect(navigation).is_positive() { - axis_view = Rect::NOTHING; + let compact = (navigation.top() - viewport.top() - 4.0).min(cube_size); + axis_view = if style == crate::prefs::NavigatorStyle::Cube && compact >= 96.0 { + Rect::from_min_size(pos2(viewport.right() - compact, viewport.top()), vec2(compact, compact)) + } else { + Rect::NOTHING + }; } if axis_view.is_positive() { obstacles.push(axis_view); diff --git a/crates/ui-egui/src/viewport_layout_tests.rs b/crates/ui-egui/src/viewport_layout_tests.rs index 0e68c002..71e827f4 100644 --- a/crates/ui-egui/src/viewport_layout_tests.rs +++ b/crates/ui-egui/src/viewport_layout_tests.rs @@ -8,7 +8,7 @@ fn thin_rotated_bounds_fit_using_the_original_full_projection() { for (width, height, scale) in [(1400.0, 900.0, 1.0), (700.0, 450.0, 2.0), (700.0, 360.0, 2.0), (900.0, 720.0, 1.0)] { let vp = Rect::from_min_max(pos2(250.0, 126.0), pos2(width, height - 30.0)); let dock = Rect::from_min_max(pos2(width - 205.0, vp.top()), vp.max); - let layout = Layout::new(vp, &[dock], scale); + let layout = Layout::new(vp, &[dock], scale, crate::prefs::NavigatorStyle::Axes); assert!(layout.drawable.width() >= 80.0 && layout.drawable.height() >= 30.0, "{layout:?}"); assert!(layout.axis_view.is_positive() && layout.navigation.is_positive(), "{layout:?}"); assert!(!layout.drawable.intersect(dock).is_positive()); @@ -85,12 +85,12 @@ fn measured_palette_fit_exposes_endpoints_and_layout_changes_preserve_manual_vie #[test] fn hostile_or_empty_fit_keeps_the_existing_camera_valid() { let cam = Camera::default(); - let layout = Layout::new(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)), &[], 1.0); + let layout = Layout::new(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)), &[], 1.0, crate::prefs::NavigatorStyle::Axes); assert!(fit_bounds(cam, &Aabb3::EMPTY, &layout, false).is_none()); let bad = Aabb3 { min: Vec3::new(f64::NAN, 0.0, 0.0), max: Vec3::new(1.0, 1.0, 1.0) }; assert!(fit_bounds(cam, &bad, &layout, true).is_none()); let mut app = crate::SolveApp::new(Default::default(), Default::default()); - let tiny = Layout::new(Rect::from_min_size(pos2(0.0, 0.0), vec2(5.0, 5.0)), &[], 1.0); + let tiny = Layout::new(Rect::from_min_size(pos2(0.0, 0.0), vec2(5.0, 5.0)), &[], 1.0, crate::prefs::NavigatorStyle::Axes); app.viewport.layout = Some(tiny); let bounds = Aabb3 { min: Vec3::ZERO, max: Vec3::new(20.0, 1.0, 1.0) }; assert_eq!(app.fitted_bounds(cam, &bounds), cam); @@ -122,69 +122,80 @@ fn fit_home_animation_and_manual_takeover_use_one_layout_solution() { } #[test] -fn measured_axis_view_targets_home_and_drag_work_beside_the_palette_at_hidpi() { - for (scale, width, height) in [(1.0, 900.0, 720.0), (2.0, 700.0, 450.0), (2.0, 700.0, 360.0)] { - let mut h = Harness::new(); - h.app.preferences.look_at_anim = false; - h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); - h.app.run("sketch.circle.center", json!({"center":[0,0],"radius":1})).unwrap(); - h.call("ui.prefs", json!({"ui_scale":scale})); - h.call("ui.resize", json!({"width":width,"height":height})); - h.app.fit_view(); - h.settle(); - let baseline = h.app.session.doc.to_json(); - let undo = h.app.session.undo.len(); - h.call("ui.view", json!({"view":"front"})); - let layout = h.app.viewport.layout.as_ref().unwrap().clone(); - let palette = h.app.viewport.palette_rect.unwrap(); - let outer = crate::scenario::rect_of("axis_view:outer").unwrap(); - let home = crate::scenario::rect_of("axis_view:home").unwrap(); - assert_eq!(outer, layout.axis_view); - assert!(outer.contains_rect(home)); - assert!(layout.viewport.contains_rect(outer)); - assert_eq!(outer.right_top(), layout.viewport.right_top()); - if outer.intersect(palette).is_positive() { - // Short palettes may cover the fixed anchor; covered controls never steal input. - let covered = outer.intersect(palette).center(); - let camera = h.app.cam; - h.call("ui.click", json!({"x":covered.x,"y":covered.y})); - assert_eq!(h.app.cam, camera); +fn measured_navigator_targets_home_and_drag_work_beside_the_palette_at_hidpi() { + for style in crate::prefs::NavigatorStyle::ALL { + for (scale, width, height) in [(1.0, 900.0, 720.0), (2.0, 700.0, 450.0), (2.0, 700.0, 360.0)] { + let mut h = Harness::new(); + h.call("ui.prefs", json!({"navigator_style":style})); + h.app.preferences.look_at_anim = false; + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.app.run("sketch.circle.center", json!({"center":[0,0],"radius":1})).unwrap(); + h.call("ui.prefs", json!({"ui_scale":scale})); + h.call("ui.resize", json!({"width":width,"height":height})); + h.app.fit_view(); + h.settle(); + let baseline = h.app.session.doc.to_json(); + let undo = h.app.session.undo.len(); + h.call("ui.view", json!({"view":"front"})); + let layout = h.app.viewport.layout.as_ref().unwrap().clone(); + let palette = h.app.viewport.palette_rect.unwrap(); + let outer = crate::scenario::rect_of("axis_view:outer").unwrap(); + let home = crate::scenario::rect_of("axis_view:home").unwrap(); + assert_eq!(outer, layout.axis_view); + assert!(outer.contains_rect(home)); + assert!(layout.viewport.contains_rect(outer)); + assert_eq!(outer.right_top(), layout.viewport.right_top()); + if outer.intersect(palette).is_positive() { + // Short palettes may cover the fixed anchor; covered controls never steal input. + let covered = outer.intersect(palette).center(); + let camera = h.app.cam; + h.call("ui.click", json!({"x":covered.x,"y":covered.y})); + assert_eq!(h.app.cam, camera); + assert!(h.app.viewport.axis_drag.is_none()); + assert_eq!(crate::scenario::rect_of("axis_view:outer").unwrap(), outer); + assert_eq!(h.app.session.doc.to_json(), baseline); + assert_eq!(h.app.session.undo.len(), undo); + continue; + } + assert!(!layout.navigation.intersect(outer).is_positive()); + if style == crate::prefs::NavigatorStyle::Cube { + let front = crate::scenario::handle_at("navigation_cube:front").unwrap(); + assert!(outer.contains(front)); + let up = crate::scenario::handle_at("navigation_cube:control:up").unwrap(); + h.call("ui.click", json!({"x":up.x,"y":up.y})); + assert!(h.app.cam.back().dist(Vec3::Z) < 1e-5); + let top = crate::scenario::handle_at("navigation_cube:top").unwrap(); + h.call("ui.click", json!({"x":top.x,"y":top.y})); + assert!(h.app.cam.back().dist(Vec3::Z) < 1e-5); + } else { + let z = crate::scenario::handle_at("axis_view:z").unwrap(); + assert!(outer.contains(z)); + h.call("ui.click", json!({"x":z.x,"y":z.y})); + assert!(h.app.cam.back().dist(Vec3::Z) < 1e-5); + let z = crate::scenario::handle_at("axis_view:z").unwrap(); + h.call("ui.click", json!({"x":z.x,"y":z.y})); + assert!(h.app.cam.back().dist(-Vec3::Z) < 1e-5); + } + h.call("ui.view", json!({"view":"front"})); + h.app.viewport.nav = Some(crate::viewport::NavMode::Pan); + let before = h.app.cam; + let c = h.app.viewport.layout.as_ref().unwrap().axis_view.center(); + h.call("ui.drag", json!({"x0":c.x,"y0":c.y,"x1":c.x+24.0,"y1":c.y+8.0,"steps":8})); + assert!((h.app.cam.yaw - before.yaw - 24.0 * 0.008).abs() < 1e-6); + assert!((h.app.cam.pitch - before.pitch - 8.0 * 0.008).abs() < 1e-6); + assert_eq!(h.app.cam.target, before.target); + assert_eq!(h.app.cam.distance, before.distance); assert!(h.app.viewport.axis_drag.is_none()); - assert_eq!(crate::scenario::rect_of("axis_view:outer").unwrap(), outer); + assert!(h.app.viewport.boxsel.is_none()); + let home = crate::scenario::handle_at("axis_view:home").unwrap(); + h.call("ui.click", json!({"x":home.x,"y":home.y})); + let mut iso = h.app.cam; + iso.set_view(StandardView::Iso); + assert_eq!((h.app.cam.yaw, h.app.cam.pitch), (iso.yaw, iso.pitch)); assert_eq!(h.app.session.doc.to_json(), baseline); assert_eq!(h.app.session.undo.len(), undo); - continue; + assert!(h.app.session.selection.is_empty()); } - assert!(!layout.navigation.intersect(outer).is_positive()); - for name in ["x", "-x", "y", "-y", "z", "-z"] { - let p = crate::scenario::handle_at(&format!("axis_view:{name}")).unwrap(); - assert!(outer.contains_rect(Rect::from_center_size(p, vec2(22.0, 22.0)))); - } - let z = crate::scenario::handle_at("axis_view:z").unwrap(); - h.call("ui.click", json!({"x":z.x,"y":z.y})); - assert!(h.app.cam.back().dist(Vec3::Z) < 1e-5); - let z = crate::scenario::handle_at("axis_view:z").unwrap(); - h.call("ui.click", json!({"x":z.x,"y":z.y})); - assert!(h.app.cam.back().dist(-Vec3::Z) < 1e-5); - h.call("ui.view", json!({"view":"front"})); - h.app.viewport.nav = Some(crate::viewport::NavMode::Pan); - let before = h.app.cam; - let c = h.app.viewport.layout.as_ref().unwrap().axis_view.center(); - h.call("ui.drag", json!({"x0":c.x,"y0":c.y,"x1":c.x+24.0,"y1":c.y+8.0,"steps":8})); - assert!((h.app.cam.yaw - before.yaw - 24.0 * 0.008).abs() < 1e-6); - assert!((h.app.cam.pitch - before.pitch - 8.0 * 0.008).abs() < 1e-6); - assert_eq!(h.app.cam.target, before.target); - assert_eq!(h.app.cam.distance, before.distance); - assert!(h.app.viewport.axis_drag.is_none()); - assert!(h.app.viewport.boxsel.is_none()); - let home = crate::scenario::handle_at("axis_view:home").unwrap(); - h.call("ui.click", json!({"x":home.x,"y":home.y})); - let mut iso = h.app.cam; - iso.set_view(StandardView::Iso); - assert_eq!((h.app.cam.yaw, h.app.cam.pitch), (iso.yaw, iso.pitch)); - assert_eq!(h.app.session.doc.to_json(), baseline); - assert_eq!(h.app.session.undo.len(), undo); - assert!(h.app.session.selection.is_empty()); } } @@ -211,7 +222,7 @@ fn immediate_standard_view_cancels_fit_before_the_measured_frame() { fn navigation_anchors_ignore_positioned_resized_and_covering_dialogs() { for (width, height, scale) in [(1400.0, 900.0, 1.0), (700.0, 450.0, 2.0), (700.0, 360.0, 2.0)] { let vp = Rect::from_min_max(pos2(250.0, 126.0), pos2(width, height - 30.0)); - let baseline = Layout::new(vp, &[], scale); + let baseline = Layout::new(vp, &[], scale, crate::prefs::NavigatorStyle::Axes); assert!(baseline.axis_view.is_positive() && baseline.navigation.is_positive()); assert_eq!(baseline.axis_view.right_top(), vp.right_top()); assert_eq!(baseline.navigation.center().x, vp.center().x); @@ -223,7 +234,7 @@ fn navigation_anchors_ignore_positioned_resized_and_covering_dialogs() { vec![vp], ]; for docks in cases { - let measured = Layout::new(vp, &docks, scale); + let measured = Layout::new(vp, &docks, scale, crate::prefs::NavigatorStyle::Axes); assert_eq!(measured.axis_view, baseline.axis_view, "{docks:?}"); assert_eq!(measured.navigation, baseline.navigation, "{docks:?}"); assert!(vp.contains_rect(measured.axis_view)); @@ -269,8 +280,8 @@ fn tiny_viewports_never_overlap_navigation_and_axis_view() { for width in [39.0, 40.0, 90.0, 120.0, 160.0, 310.0] { for height in [30.0, 80.0, 130.0, 160.0, 240.0] { let vp = Rect::from_min_size(pos2(20.0, 30.0), vec2(width, height)); - let clear = Layout::new(vp, &[], 1.0); - let covered = Layout::new(vp, &[vp], 1.0); + let clear = Layout::new(vp, &[], 1.0, crate::prefs::NavigatorStyle::Axes); + let covered = Layout::new(vp, &[vp], 1.0, crate::prefs::NavigatorStyle::Axes); assert_eq!(clear.axis_view, covered.axis_view); assert_eq!(clear.navigation, covered.navigation); assert!(!clear.axis_view.intersect(clear.navigation).is_positive()); diff --git a/crates/ui-egui/tests/scenarios/navigation_cube_hover.json b/crates/ui-egui/tests/scenarios/navigation_cube_hover.json new file mode 100644 index 00000000..3962fec7 --- /dev/null +++ b/crates/ui-egui/tests/scenarios/navigation_cube_hover.json @@ -0,0 +1,65 @@ +[ + {"call":"ui.prefs","params":{"navigator_style":"cube"}}, + { + "note": "Cube facets own one highlight at a time and choose principal views." + }, + { + "call": "ui.view", + "params": { + "view": "home" + } + }, + { + "move": { + "handle": "navigation_cube:top" + } + }, + { + "expect": { + "count": { + "name": "axis_view_highlights", + "value": 1 + } + } + }, + { + "click": { + "handle": "navigation_cube:top" + } + }, + { + "expect": { + "camera": { + "yaw_deg": 0, + "pitch_deg": 90 + } + } + }, + { + "click": { + "handle": "navigation_cube:control:down" + } + }, + { + "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/navigation_cube_minimal_turn.json b/crates/ui-egui/tests/scenarios/navigation_cube_minimal_turn.json new file mode 100644 index 00000000..7feaeb58 --- /dev/null +++ b/crates/ui-egui/tests/scenarios/navigation_cube_minimal_turn.json @@ -0,0 +1,74 @@ +[ + {"call":"ui.prefs","params":{"navigator_style":"cube"}}, + { + "note": "Cube top/bottom face clicks preserve the nearest square heading in each up frame." + }, + { + "camera": { + "yaw": -2.356194490192345, + "pitch": 0.6154797086703874 + } + }, + { + "click": { + "handle": "navigation_cube:top" + } + }, + { + "expect": { + "camera": { + "yaw_deg": -90, + "pitch_deg": 90 + } + } + }, + { + "camera": { + "yaw": 2.9670597283903604, + "pitch": -0.6 + } + }, + { + "click": { + "handle": "navigation_cube:bottom" + } + }, + { + "expect": { + "camera": { + "yaw_deg": 180, + "pitch_deg": -90 + } + } + }, + { + "call": "ui.prefs", + "params": { + "up_axis": "y" + } + }, + { + "camera": { + "yaw": -1.3962634015954636, + "pitch": 0.6 + } + }, + { + "click": { + "handle": "navigation_cube:top" + } + }, + { + "expect": { + "camera": { + "yaw_deg": -90, + "pitch_deg": 90, + "back": [ + 0, + 1, + 0 + ] + } + } + } +] diff --git a/crates/ui-egui/tests/scenarios/navigation_cube_y_up.json b/crates/ui-egui/tests/scenarios/navigation_cube_y_up.json new file mode 100644 index 00000000..038b8ef5 --- /dev/null +++ b/crates/ui-egui/tests/scenarios/navigation_cube_y_up.json @@ -0,0 +1,54 @@ +[ + {"call":"ui.prefs","params":{"navigator_style":"cube"}}, + { + "note": "Cube face names follow the up-axis frame; with Y up, Top looks from +Y and Front from +Z." + }, + { + "call": "ui.prefs", + "params": { + "up_axis": "y" + } + }, + { + "call": "ui.view", + "params": { + "view": "home" + } + }, + { + "click": { + "handle": "navigation_cube:top" + } + }, + { + "expect": { + "camera": { + "yaw_deg": 0, + "pitch_deg": 90, + "back": [ + 0, + 1, + 0 + ] + } + } + }, + { + "click": { + "handle": "navigation_cube:control:down" + } + }, + { + "expect": { + "camera": { + "yaw_deg": 0, + "pitch_deg": 0, + "back": [ + 0, + 0, + 1 + ] + } + } + } +] diff --git a/docs/control-protocol.md b/docs/control-protocol.md index bc999691..ff1e9851 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -64,7 +64,7 @@ unit regardless of the design, pass an expression with one: `"value": "50 mm"` o | `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:` (the button alone: `button:`; a drop-down row: `dropdown:`, highlighted `dropdown_hot:`, its pin `dropdown_pin:`; Customize Toolbar's check boxes `customize:`), panel labels `panel::`, browser rows `row: