diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index af5f3fbb..c2b6080b 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -2074,16 +2074,16 @@ pub(crate) fn cube_point(app: &SolveApp, rect: Rect, p: Vec3) -> Pos2 { /// Half a view cube face, in pixels. const CUBE_SCALE: f64 = 32.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) { let (r, u, b) = cam.basis(); (cam.to_frame(r), cam.to_frame(u), cam.to_frame(b)) } +/// The view cube (top right): a cube with chamfered corners. Its 26 targets are the 6 faces +/// (orthographic views), the 12 edges (the views halfway between their two faces) and the 8 +/// corner facets (iso views); an edge is picked by a band along it on both faces it joins. +/// Hover lights the target under the pointer, a click turns the view to it, the house goes home. fn view_cube(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { let t = Tokens::get(); let c = pos2(rect.right() - 80.0, rect.top() + 80.0); @@ -2092,6 +2092,7 @@ fn view_cube(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { let painter = ui.painter_at(rect); let hover = ui.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(rect)); let targets = cube_targets(); + let drawn = cube_drawn(&targets, (r, u, b), c); let shapes = cube_projected(&targets, (r, u, b), c); let hovered = 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. @@ -2103,20 +2104,26 @@ fn view_cube(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { painter.line_segment([at, tip], Stroke::new(2.0, Color32::from_rgb(col.0, col.1, col.2))); } // Back to front: the faces and facets turned toward the camera. - for (i, pts) in shapes.iter().rev() { + for (i, pts) in drawn.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) }; + let shade = (0.75 + 0.25 * tg.normal().dot(b)) as f32; + let fill = if tg.kind == CubeKind::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))); + } + // The hovered target's patches (an edge's band lights on both its faces), and each target's + // handle at its front patch (published last, so it wins). + for (i, pts) in shapes.iter().rev() { 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); + crate::scenario::publish_handle(&format!("cube:{}", targets[*i].name), cube_centroid(pts)); + } + for (i, _) in drawn.iter().rev() { + let tg = &targets[*i]; + let n = tg.normal(); // 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 { + if tg.kind == CubeKind::Face && ink > 0.05 { painter.add(cube_label(ui.ctx(), &tg.name.to_uppercase(), n, (r, u), c, Color32::from_rgb(40, 44, 52).gamma_multiply(ink))); } } @@ -2124,7 +2131,7 @@ fn view_cube(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { 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). + // Clicked: the view from the target'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()); @@ -2145,14 +2152,26 @@ fn view_cube(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { /// 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). +/// How far into each face an edge's band reaches (the cube's faces are at ±1). +const CUBE_EDGE_BAND: f64 = 0.18; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum CubeKind { + Face, + Edge, + Corner, +} + +/// A view cube target: a face, an edge or a corner facet, its view direction (target → eye), the +/// outline it is drawn with (cube units; none for an edge, which is drawn as part of its faces) +/// and the patches that pick it, each with its normal. #[derive(Clone, Debug)] pub(crate) struct CubeTarget { pub name: String, pub dir: Vec3, - pub corner: bool, + pub kind: CubeKind, pub outline: Vec, + pub patches: Vec<(Vec3, Vec)>, } impl CubeTarget { @@ -2161,38 +2180,74 @@ impl CubeTarget { } } -/// The 14 targets: 6 octagonal faces and 8 triangular corner facets. +/// A target's name from its direction: its sides, top or bottom first, then front or back, then +/// right or left (`top`, `top-front`, `top-front-right`). +fn cube_target_name(d: Vec3) -> String { + [(d.z, "top", "bottom"), (d.y, "back", "front"), (d.x, "right", "left")] + .iter() + .filter(|(v, _, _)| v.abs() > 0.5) + .map(|(v, pos, neg)| if *v > 0.0 { *pos } else { *neg }) + .collect::>() + .join("-") +} + +/// An octagon on face `n` (in-face axes `e1`, `e2`): its sides at `side` from the face's centre, +/// its corners cut from `along` on. +fn cube_octagon(n: Vec3, (e1, e2): (Vec3, Vec3), side: f64, along: f64) -> Vec { + let (s, a) = (side, along); + [(-a, -s), (a, -s), (s, -a), (s, a), (a, s), (-a, s), (-s, a), (-s, -a)].iter().map(|(x, y)| n + e1 * *x + e2 * *y).collect() +} + +/// The 26 targets: 6 octagonal faces, 8 triangular corner facets and 12 edges. A face is picked +/// inside the bands along its edges; an edge by its band on each of its two faces, ending at the +/// corner facets. pub(crate) fn cube_targets() -> Vec { - let k = CUBE_CHAMFER; + let (k, w) = (CUBE_CHAMFER, CUBE_EDGE_BAND); + let faces = [ + Vec3::new(0.0, -1.0, 0.0), + Vec3::new(0.0, 1.0, 0.0), + Vec3::new(1.0, 0.0, 0.0), + Vec3::new(-1.0, 0.0, 0.0), + Vec3::Z, + Vec3::new(0.0, 0.0, -1.0), + ]; 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 n in faces { + let axes = cube_face_axes(n); + let inner = cube_octagon(n, axes, 1.0 - w, 1.0 - k + w); + out.push(CubeTarget { + name: cube_target_name(n), + dir: n, + kind: CubeKind::Face, + outline: cube_octagon(n, axes, 1.0, 1.0 - k), + patches: vec![(n, inner)], + }); } 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 }); + let patch = (corner, outline.clone()); + out.push(CubeTarget { name: cube_target_name(corner), dir: corner, kind: CubeKind::Corner, outline, patches: vec![patch] }); } } } + // The band on face `n` along its edge toward face `s`: from the edge in to `w`, its ends on + // the corner facets' cuts. + let band = |n: Vec3, s: Vec3, along: Vec3| { + let a = 1.0 - k; + let inside = n + s * (1.0 - w); + vec![n + s - along * a, n + s + along * a, inside + along * (a + w), inside - along * (a + w)] + }; + for (i, n1) in faces.iter().enumerate() { + for n2 in faces.iter().skip(i + 1).filter(|n2| n1.dot(**n2).abs() < 0.5) { + let along = n1.cross(*n2); + let dir = *n1 + *n2; + let patches = vec![(*n1, band(*n1, *n2, along)), (*n2, band(*n2, *n1, along))]; + out.push(CubeTarget { name: cube_target_name(dir), dir, kind: CubeKind::Edge, outline: Vec::new(), patches }); + } + } out } @@ -2225,22 +2280,34 @@ fn cube_label(ctx: &egui::Context, text: &str, n: Vec3, ru: (Vec3, Vec3), c: Pos 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)> { +/// The polygons (target index, normal, outline) turned toward the camera (basis `rub`), front +/// first, as screen polygons around the cube's centre `c`. +fn cube_project<'a>(polys: impl Iterator, 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())) + let mut v: Vec<(usize, f64, Vec)> = polys + .map(|(i, n, outline)| (i, n.normalized().unwrap_or(n).dot(b), outline)) + .filter(|(_, facing, _)| *facing > 1e-3) + .map(|(i, facing, outline)| (i, facing, 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. +/// The targets' patches turned toward the camera, front first, each with its target's index: what +/// the pointer picks. +pub(crate) fn cube_projected(targets: &[CubeTarget], rub: (Vec3, Vec3, Vec3), c: Pos2) -> Vec<(usize, Vec)> { + let patches = targets.iter().enumerate().flat_map(|(i, tg)| tg.patches.iter().map(move |(n, o)| (i, *n, o.as_slice()))); + cube_project(patches, rub, c) +} + +/// The faces and corner facets turned toward the camera, front first: what is drawn. +fn cube_drawn(targets: &[CubeTarget], rub: (Vec3, Vec3, Vec3), c: Pos2) -> Vec<(usize, Vec)> { + let outlines = targets.iter().enumerate().filter(|(_, tg)| !tg.outline.is_empty()).map(|(i, tg)| (i, tg.dir, tg.outline.as_slice())); + cube_project(outlines, rub, c) +} + +/// The target under `p`. The patches tile the cube, so a point on a line 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); @@ -2776,23 +2843,49 @@ mod tests { } #[test] - fn view_cube_has_14_targets_each_hit_where_drawn() { + fn view_cube_has_26_targets_each_hit_where_drawn() { let targets = cube_targets(); - assert_eq!(targets.len(), 14); - assert_eq!(targets.iter().filter(|t| t.corner).count(), 8); + assert_eq!(targets.len(), 26); + let count = |k: CubeKind| targets.iter().filter(|t| t.kind == k).count(); + assert_eq!((count(CubeKind::Face), count(CubeKind::Edge), count(CubeKind::Corner)), (6, 12, 8)); + let mut names: Vec<&str> = targets.iter().map(|t| t.name.as_str()).collect(); + names.sort_unstable(); + names.dedup(); + assert_eq!(names.len(), 26, "names are unique"); 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. + // Seen from a little off its own direction, the middle of each of its patches 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); + let mine: Vec<_> = shapes.iter().filter(|(j, _)| *j == i).collect(); + assert_eq!(mine.len(), tg.patches.len(), "{}: every patch visible", tg.name); + for (_, pts) in mine { + 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. + // From an iso view: 3 faces, the 4 bands on each 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!(shapes.len(), 3 + 3 * 4 + 4); assert_eq!(cube_hit(&shapes, pos2(400.0, 400.0)), None); } + /// The patches tile the cube: every point of the drawn cube picks a target, from any direction. + #[test] + fn view_cube_patches_cover_the_drawn_cube() { + let targets = cube_targets(); + let c = pos2(100.0, 100.0); + for dir in orbit_directions().step_by(11) { + let rub = basis_from(dir); + let shapes = cube_projected(&targets, rub, c); + for (_, pts) in cube_drawn(&targets, rub, c) { + let centre = cube_centroid(&pts); + // Each outline vertex pulled a little toward the middle, and the middle itself. + for p in pts.iter().map(|p| *p + (centre - *p) * 0.05).chain([centre]) { + assert!(cube_hit(&shapes, p).is_some(), "{p:?} from {dir:?} picks nothing"); + } + } + } + } + /// From any direction, including faces turned nearly edge-on, everything drawn stays within /// the cube. #[test] @@ -2803,7 +2896,8 @@ mod tests { 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) { + let rub = basis_from(dir); + for (_, pts) in cube_drawn(&targets, rub, c).into_iter().chain(cube_projected(&targets, rub, c)) { for p in drawn_points(projected_polygon(&pts, Color32::GRAY, Stroke::new(1.0, Color32::BLACK))) { worst = worst.max(p.distance(c)); } @@ -2814,7 +2908,7 @@ mod tests { #[test] fn view_cube_labels_read_upright_in_their_own_view() { - for tg in cube_targets().iter().filter(|t| !t.corner) { + for tg in cube_targets().iter().filter(|t| t.kind == CubeKind::Face) { // The view clicking the face turns to (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 { @@ -2826,7 +2920,7 @@ mod tests { } } - /// From any direction, each label is printed within its face. + /// From any direction, each label is printed within its face, clear of the edge bands. #[test] fn view_cube_labels_stay_on_their_faces() { let ctx = egui::Context::default(); @@ -2838,7 +2932,7 @@ mod tests { 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 { + if tg.kind != CubeKind::Face { continue; } for p in drawn_points(vec![cube_label(ui.ctx(), &tg.name.to_uppercase(), tg.normal(), (r, u), c, Color32::BLACK)]) { @@ -2854,24 +2948,40 @@ mod tests { } #[test] - fn view_cube_edges_are_not_targets() { + fn view_cube_edges_are_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). + let at = |dir: Vec3, p: Vec3| { + let (r, u, b) = basis_from(dir); + let shapes = cube_projected(&targets, (r, u, b), c); + name(cube_hit(&shapes, cube_to_screen((r, u), c, p))) + }; + let iso = Vec3::new(1.0, -1.2, 0.9); + // On the top-front edge, along it, and just off it on either face: the edge. 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}"); + for p in [Vec3::new(x, -1.0, 1.0), Vec3::new(x, -0.9, 1.0), Vec3::new(x, -1.0, 0.9)] { + assert_eq!(at(iso, p), "top-front", "{p:?}"); + } } - 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); + // Clear of the band: the face; at the end of the edge: the corner. + assert_eq!(at(iso, Vec3::new(0.0, -0.7, 1.0)), "top"); + assert_eq!(at(iso, Vec3::new(0.0, -1.0, 0.7)), "front"); + assert_eq!(at(iso, Vec3::new(0.85, -1.0, 0.95)), "top-front-right"); + // A vertical edge too. + assert_eq!(at(iso, Vec3::new(0.95, -1.0, 0.0)), "front-right"); + // Looking straight at FRONT, its four edges are bands along its border, and the edges + // running away from the view are hidden behind its corners. + let front = Vec3::new(0.0, -1.0, 0.0); + assert_eq!(at(front, Vec3::new(0.0, -1.0, 0.95)), "top-front"); + assert_eq!(at(front, Vec3::new(0.0, -1.0, -0.95)), "bottom-front"); + assert_eq!(at(front, Vec3::new(0.95, -1.0, 0.0)), "front-right"); + assert_eq!(at(front, Vec3::new(-0.95, -1.0, 0.0)), "front-left"); + assert_eq!(at(front, Vec3::new(0.0, -1.0, 0.0)), "front"); + // An edge turns the view halfway between its two faces. + for tg in targets.iter().filter(|t| t.kind == CubeKind::Edge) { + let n: Vec = [tg.dir.x, tg.dir.y, tg.dir.z].iter().copied().filter(|v| v.abs() > 1e-9).collect(); + assert!(n.len() == 2 && n.iter().all(|v| (v.abs() - 1.0).abs() < 1e-9), "{}: {:?}", tg.name, tg.dir); } } diff --git a/crates/ui-egui/tests/scenarios/s39_view_cube_hover.json b/crates/ui-egui/tests/scenarios/s39_view_cube_hover.json index d524f0f3..86d2f0cf 100644 --- a/crates/ui-egui/tests/scenarios/s39_view_cube_hover.json +++ b/crates/ui-egui/tests/scenarios/s39_view_cube_hover.json @@ -1,19 +1,28 @@ [ - {"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."}, + {"note": "The view cube is a cube with chamfered corners. Its targets are the 6 faces, the 12 edges (a band along each edge, on both faces it joins) and the 8 corner facets; hover lights exactly the one under the pointer, 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."}, + {"note": "On the top-front edge, and just off it on the top face: the edge."}, {"move": {"cube": [0, -1, 1]}}, - {"expect": {"count": {"name": "cube_highlights", "value": 1}}}, + {"expect": {"count": {"name": "cube_highlights", "value": 1}, "shown_handles": ["cube_hover:top-front"]}}, + {"move": {"cube": [0, -0.9, 1]}}, + {"expect": {"count": {"name": "cube_highlights", "value": 1}, "shown_handles": ["cube_hover:top-front"]}}, {"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}}}, + {"note": "Looking straight at FRONT, its top edge is a band along the face's top: it turns to the view halfway between FRONT and TOP."}, + {"move": {"cube": [0, -1, 0.95]}}, + {"expect": {"count": {"name": "cube_highlights", "value": 1}, "shown_handles": ["cube_hover:top-front"]}}, + {"click": {"handle": "cube:top-front"}}, + {"expect": {"camera": {"yaw_deg": 0, "pitch_deg": 45}}}, + {"click": {"handle": "cube:front-right"}}, + {"expect": {"camera": {"yaw_deg": -45, "pitch_deg": 0}}}, {"call": "ui.view", "params": {"view": "home"}}, {"click": {"handle": "cube:top-back-right"}}, {"expect": {"camera": {"yaw_deg": -135, "pitch_deg": 35.26}}}, diff --git a/docs/book/src/guide/ui-tour.md b/docs/book/src/guide/ui-tour.md index 1121a18d..75a28a6e 100644 --- a/docs/book/src/guide/ui-tour.md +++ b/docs/book/src/guide/ui-tour.md @@ -15,8 +15,8 @@ - **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 its standard view, a chamfered corner to that corner's - iso view, and the house goes home. Right-click for the marking menu: eight commands around the cursor, plus a + change the view: a cube face turns to its standard view, an edge to the view halfway between + its two faces, a chamfered corner to that corner's iso view, and the house goes 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 value box in the viewport.