From cbdc4fe0d8529e0ef020f93b69f6f407946a2ea6 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Fri, 9 Oct 2026 18:45:27 +0000 Subject: [PATCH] View cube hover lights the whole region a click turns to: a corner its 3 patches, an edge its 2 strips, a face its middle (one hit test for hover and click; test over all 26 regions). View animations ease with smoothstep and take longer for turns past a quarter turn, so no frame sweeps much more than 9 degrees; s38 and s39 enabled Fixes #15 Co-Authored-By: Claude Opus 5.5 --- crates/render/src/camera.rs | 24 ++-- crates/ui-egui/src/viewport.rs | 105 +++++++++++++----- .../scenarios/s38_view_anim_no_jump.json | 3 - .../tests/scenarios/s39_view_cube_hover.json | 3 - 4 files changed, 94 insertions(+), 41 deletions(-) diff --git a/crates/render/src/camera.rs b/crates/render/src/camera.rs index f86b9bcc..6654f366 100644 --- a/crates/render/src/camera.rs +++ b/crates/render/src/camera.rs @@ -72,10 +72,11 @@ impl StandardView { } } -/// Smooth ease-in-out on [0, 1] (cubic). +/// Smooth ease-in-out on [0, 1] (smoothstep: at its fastest, mid-way, 1.5 times the average +/// speed, so long turns stay gentle). pub fn ease_in_out(t: f64) -> f64 { let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 1.0 }; - if t < 0.5 { 4.0 * t * t * t } else { 1.0 - (-2.0 * t + 2.0).powi(3) / 2.0 } + t * t * (3.0 - 2.0 * t) } /// A unit quaternion (w, x, y, z). @@ -162,8 +163,13 @@ pub struct CameraAnim { impl CameraAnim { /// The usual length of a view change, in seconds. pub const DURATION: f64 = 0.5; + /// A view change: half a second, longer for turns past a quarter turn (so a big turn, such + /// as from the home view to the bottom, never sweeps more than about 9 degrees a frame). pub fn new(from: Camera, to: Camera, now: f64) -> CameraAnim { - CameraAnim { from, to, start: now, duration: CameraAnim::DURATION } + let dy = (to.yaw - from.yaw + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI; + let turn = dy.abs().max((to.pitch - from.pitch).abs()); + let k = if turn.is_finite() { (turn / std::f64::consts::FRAC_PI_2).clamp(1.0, 2.0) } else { 1.0 }; + CameraAnim { from, to, start: now, duration: CameraAnim::DURATION * k } } /// The camera at time `now`, and whether the move has finished. pub fn sample(&self, now: f64) -> (Camera, bool) { @@ -396,10 +402,10 @@ mod tests { let anim = CameraAnim::new(a, b, 10.0); // Endpoints are exact. assert_eq!(anim.sample(10.0).0, a); - assert_eq!(anim.sample(10.0 + CameraAnim::DURATION), (b, true)); + assert_eq!(anim.sample(10.0 + anim.duration), (b, true)); assert_eq!(anim.sample(99.0), (b, true)); // Midpoint: half way in heading, elevation, target and distance. - let (m, done) = anim.sample(10.0 + CameraAnim::DURATION / 2.0); + let (m, done) = anim.sample(10.0 + anim.duration / 2.0); assert!(!done && m.is_valid()); let dy = (b.yaw - a.yaw + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI; assert!((m.yaw - (a.yaw + dy / 2.0)).abs() < 1e-12 && (m.pitch - (a.pitch + b.pitch) / 2.0).abs() < 1e-12); @@ -476,7 +482,7 @@ mod tests { for a in &cams { for b in &cams { let anim = CameraAnim::new(*a, *b, 0.0); - let frames = 30; + let frames = (anim.duration * 60.0).round() as usize; let mut prev = anim.sample(0.0).0; let (mut steps, mut done_at) = (Vec::new(), None); for f in 1..=frames + 2 { @@ -495,9 +501,9 @@ mod tests { let total_turn: f64 = steps.iter().map(|s| s.1).sum(); let total_move: f64 = steps.iter().map(|s| s.2).sum(); for (f, turn, moved) in &steps { - // The eased curve's fastest frame is 1.5x the average: allow 3x. - assert!(*turn <= 3.0 * total_turn / frames as f64 + 1e-9, "turn jump at frame {f}: {turn} of {total_turn} ({a:?} -> {b:?})"); - assert!(*moved <= 3.0 * total_move / frames as f64 + 1e-9, "move jump at frame {f}: {moved} of {total_move}"); + // The eased curve's fastest frame is 1.5x the average: allow 2x. + assert!(*turn <= 2.0 * total_turn / frames as f64 + 1e-9, "turn jump at frame {f}: {turn} of {total_turn} ({a:?} -> {b:?})"); + assert!(*moved <= 2.0 * total_move / frames as f64 + 1e-9, "move jump at frame {f}: {moved} of {total_move}"); } } } diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index cc0ee5ff..3d840d30 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -1830,31 +1830,33 @@ fn view_cube(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { (Vec3::new(0.0, 0.0, -1.0), "BOTTOM", StandardView::Bottom), ]; let painter = ui.painter_at(rect); - // Clicked view direction (target → eye): a face, or an edge/corner zone of a face. - let mut clicked: Option = None; let hover = ui.input(|i| i.pointer.hover_pos()); let mut order: Vec = (0..6).collect(); order.sort_by(|a, b2| faces[*a].0.dot(b).total_cmp(&faces[*b2].0.dot(b))); - for i in order { - let (n, label, _) = faces[i]; - if n.dot(b) <= 1e-3 { - continue; - } - let (e1, e2) = 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) - }; - let corners = [n - e1 - e2, n + e1 - e2, n + e1 + e2, n - e1 + e2]; - let pts: Vec = corners.iter().map(|p| to2(*p).0).collect(); - let poly_hover = hover.is_some_and(|h| point_in_poly(h, &pts)); + let visible: Vec = order.into_iter().filter(|i| faces[*i].0.dot(b) > 1e-3).collect(); + let outline = |n: Vec3| -> Vec { + let (e1, e2) = cube_face_axes(n); + [n - e1 - e2, n + e1 - e2, n + e1 + e2, n - e1 + e2].iter().map(|p| to2(*p).0).collect() + }; + // The region under the cursor (a face, edge or corner, as the view direction a click + // turns to): found on the frontmost face under it, the same zones a click uses. + let region: Option = hover.and_then(|h| { + visible.iter().rev().find_map(|i| { + let n = faces[*i].0; + let pts = outline(n); + let (e1, e2) = cube_face_axes(n); + point_in_poly(h, &pts).then(|| cube_zone(&pts, h)).flatten().map(|(zi, zj)| n + e1 * f64::from(zi) + e2 * f64::from(zj)) + }) + }); + for i in &visible { + let (n, label, _) = faces[*i]; + let pts = outline(n); let shade = (0.75 + 0.25 * n.dot(b)) as f32; - painter.add(Shape::convex_polygon(pts.clone(), t.cube_face.gamma_multiply(shade).to_opaque(), Stroke::new(1.0, t.cube_edge))); - // Hover zone: the outer band of a face picks the edge or corner it borders. - let zone = hover.filter(|_| poly_hover).and_then(|h| cube_zone(&pts, h)); - if let Some((zi, zj)) = zone { + painter.add(Shape::convex_polygon(pts, t.cube_face.gamma_multiply(shade).to_opaque(), Stroke::new(1.0, t.cube_edge))); + // Every patch of this face that belongs to the hovered region: a corner lights its + // three patches, an edge its two strips, a face its middle. + let (e1, e2) = cube_face_axes(n); + for (zi, zj) in region.map(|d| cube_patches_on(n, d)).unwrap_or_default() { let lo = |k: i32| match k { -1 => (-1.0, -1.0 + 2.0 * CUBE_BAND), 0 => (-1.0 + 2.0 * CUBE_BAND, 1.0 - 2.0 * CUBE_BAND), @@ -1869,12 +1871,9 @@ fn view_cube(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { if n.dot(b) > 0.35 { painter.text(center, Align2::CENTER_CENTER, label, FontId::proportional(9.5), Color32::from_rgb(40, 44, 52)); } - if let Some((zi, zj)) = zone - && ui.input(|i| i.pointer.primary_clicked()) - { - clicked = Some(n + e1 * zi as f64 + e2 * zj as f64); - } } + // Clicked view direction (target → eye): the hovered region. + let clicked = region.filter(|_| ui.input(|i| i.pointer.primary_clicked())); crate::scenario::publish_count("cube_highlights", f64::from(lit)); // Axis triad at the cube's corner. let o = Vec3::new(-1.0, -1.0, -1.0); @@ -1895,6 +1894,32 @@ fn view_cube(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { } } +/// The in-face axes of a view cube face (the zone grid's u and v). +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) + } +} + +/// The patches (zones) of face `n` that belong to region `d` (a face, edge or corner direction +/// with components −1, 0 or 1): the zones a click on would turn to `d`. +pub(crate) fn cube_patches_on(n: Vec3, d: Vec3) -> Vec<(i32, i32)> { + let (e1, e2) = cube_face_axes(n); + let mut out = Vec::new(); + for zi in -1..=1 { + for zj in -1..=1 { + if (n + e1 * f64::from(zi) + e2 * f64::from(zj)).dist(d) < 1e-9 { + out.push((zi, zj)); + } + } + } + out +} + /// Width of the edge/corner band of a view cube face, as a fraction of the face. const CUBE_BAND: f64 = 0.2; @@ -2060,6 +2085,34 @@ fn view_menu(app: &mut SolveApp, ui: &mut egui::Ui, button: Rect) -> bool { mod tests { use super::*; + #[test] + fn view_cube_regions_light_their_patches() { + let faces = [Vec3::X, -Vec3::X, Vec3::Y, -Vec3::Y, Vec3::Z, -Vec3::Z]; + let mut regions = 0; + for x in -1i32..=1 { + for y in -1i32..=1 { + for z in -1i32..=1 { + let d = Vec3::new(f64::from(x), f64::from(y), f64::from(z)); + let k = x.abs() + y.abs() + z.abs(); + if k == 0 { + continue; + } + regions += 1; + let lit: Vec<(Vec3, (i32, i32))> = faces.iter().flat_map(|n| cube_patches_on(*n, d).into_iter().map(move |z| (*n, z))).collect(); + // A face lights 1 patch, an edge 2, a corner 3, each on a different face. + assert_eq!(lit.len() as i32, k, "{d:?}: {lit:?}"); + // Each lit patch is one a click turns to this same region. + for (n, (zi, zj)) in lit { + let (e1, e2) = cube_face_axes(n); + assert!((n + e1 * f64::from(zi) + e2 * f64::from(zj)).dist(d) < 1e-12); + assert!(n.dot(d) > 0.5, "a patch on a face away from {d:?}"); + } + } + } + } + assert_eq!(regions, 26); + } + #[test] fn view_cube_zones() { // A square face 100 px wide, corners in order −−, +−, ++, −+ (screen y down). diff --git a/crates/ui-egui/tests/scenarios/s38_view_anim_no_jump.json b/crates/ui-egui/tests/scenarios/s38_view_anim_no_jump.json index 918b3db1..d7531829 100644 --- a/crates/ui-egui/tests/scenarios/s38_view_anim_no_jump.json +++ b/crates/ui-egui/tests/scenarios/s38_view_anim_no_jump.json @@ -1,7 +1,4 @@ [ - { - "pending": "the last frame of an animation to Top turns the view 22.5 degrees (solvecraft-ui)" - }, { "note": "Animating to Top or Bottom from iso, front or any orbit turns the view smoothly to the end: no frame (the last included) turns it more than 10 degrees." }, 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 7857b5ec..633dd3b3 100644 --- a/crates/ui-egui/tests/scenarios/s39_view_cube_hover.json +++ b/crates/ui-egui/tests/scenarios/s39_view_cube_hover.json @@ -1,7 +1,4 @@ [ - { - "pending": "view cube edge and corner hover light 1 patch, Fusion lights 2 and 3 (solvecraft-ui)" - }, { "note": "View cube hover from the home view: a face lights one patch, an edge the 2 patches on its faces, a corner the 3 around it (Fusion)." },