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 <noreply@anthropic.com>
This commit is contained in:
1 parent
3b650cac5f
commit
cbdc4fe0d8
4 files changed
+94
-41
No files matched your search
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Vec3> = None;
|
||||
let hover = ui.input(|i| i.pointer.hover_pos());
|
||||
let mut order: Vec<usize> = (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<Pos2> = corners.iter().map(|p| to2(*p).0).collect();
|
||||
let poly_hover = hover.is_some_and(|h| point_in_poly(h, &pts));
|
||||
let visible: Vec<usize> = order.into_iter().filter(|i| faces[*i].0.dot(b) > 1e-3).collect();
|
||||
let outline = |n: Vec3| -> Vec<Pos2> {
|
||||
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<Vec3> = 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).
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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)."
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user