View cube edges are targets: hovering an edge lights a band along it on both faces it joins, and a click turns the view halfway between those faces (top-front looks down at 45°, front-right along the diagonal). The cube now has 26 targets (6 faces, 12 edges, 8 corner facets); its drawing is unchanged. Looking straight at a face, the four edges around it are bands along its border. Tests: every target's patches are hit where drawn, the patches cover the drawn cube from any direction, edges pick from an iso view and face-on, and the s39 scenario hovers and clicks edges

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
N3MI-DGandClaude Opus 5.5 committed 2026-10-11 09:18:29 +11:00
1 parent 63fd838f55
commit 2fb3dcae6b
3 files changed
+195 -76

No files matched your search

+181 -71
View File
@@ -2074,16 +2074,16 @@ pub(crate) fn cube_point(app: &SolveApp, rect: Rect, p: Vec3) -> Pos2 {
/// Half a view cube face, in pixels. /// Half a view cube face, in pixels.
const CUBE_SCALE: f64 = 32.0; 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. /// 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) { fn cube_basis(cam: &Camera) -> (Vec3, Vec3, Vec3) {
let (r, u, b) = cam.basis(); let (r, u, b) = cam.basis();
(cam.to_frame(r), cam.to_frame(u), cam.to_frame(b)) (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) { fn view_cube(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
let t = Tokens::get(); let t = Tokens::get();
let c = pos2(rect.right() - 80.0, rect.top() + 80.0); 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 painter = ui.painter_at(rect);
let hover = ui.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(rect)); let hover = ui.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(rect));
let targets = cube_targets(); let targets = cube_targets();
let drawn = cube_drawn(&targets, (r, u, b), c);
let shapes = cube_projected(&targets, (r, u, b), c); let shapes = cube_projected(&targets, (r, u, b), c);
let hovered = hover.and_then(|h| cube_hit(&shapes, h)); 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. // 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))); 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. // 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 tg = &targets[*i];
let n = tg.normal(); let shade = (0.75 + 0.25 * tg.normal().dot(b)) as f32;
let shade = (0.75 + 0.25 * n.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) };
let fill = if tg.corner { t.cube_face.gamma_multiply(shade * 0.92) } else { t.cube_face.gamma_multiply(shade) };
painter.extend(projected_polygon(pts, fill.to_opaque(), Stroke::new(1.0, t.cube_edge))); 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) { if hovered == Some(*i) {
painter.extend(projected_polygon(pts, t.accent_soft, Stroke::new(1.0, t.accent))); 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:{}", targets[*i].name), cube_centroid(pts));
crate::scenario::publish_handle(&format!("cube:{}", tg.name), centre); }
for (i, _) in drawn.iter().rev() {
let tg = &targets[*i];
let n = tg.normal();
// Fades out as the face turns edge-on. // Fades out as the face turns edge-on.
let ink = (n.dot(b) / 0.3).clamp(0.0, 1.0) as f32; 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))); 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 { if let Some(i) = hovered {
crate::scenario::publish_handle(&format!("cube_hover:{}", targets[i].name), hover.unwrap_or(c)); 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 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 home = Rect::from_center_size(pos2(c.x - 52.0, c.y - 48.0), vec2(18.0, 18.0));
let hr = ui.interact(home, ui.id().with("vc_home"), Sense::click()); let 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). /// 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; const CUBE_CHAMFER: f64 = 0.38;
/// A view cube target: a face or a corner facet, its view direction (target → eye) and outline /// How far into each face an edge's band reaches (the cube's faces are at ±1).
/// (cube units). 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)] #[derive(Clone, Debug)]
pub(crate) struct CubeTarget { pub(crate) struct CubeTarget {
pub name: String, pub name: String,
pub dir: Vec3, pub dir: Vec3,
pub corner: bool, pub kind: CubeKind,
pub outline: Vec<Vec3>, pub outline: Vec<Vec3>,
pub patches: Vec<(Vec3, Vec<Vec3>)>,
} }
impl CubeTarget { 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::<Vec<_>>()
.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<Vec3> {
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<CubeTarget> { pub(crate) fn cube_targets() -> Vec<CubeTarget> {
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(); let mut out = Vec::new();
for (n, name) in [ for n in faces {
(Vec3::new(0.0, -1.0, 0.0), "front"), let axes = cube_face_axes(n);
(Vec3::new(0.0, 1.0, 0.0), "back"), let inner = cube_octagon(n, axes, 1.0 - w, 1.0 - k + w);
(Vec3::new(1.0, 0.0, 0.0), "right"), out.push(CubeTarget {
(Vec3::new(-1.0, 0.0, 0.0), "left"), name: cube_target_name(n),
(Vec3::Z, "top"), dir: n,
(Vec3::new(0.0, 0.0, -1.0), "bottom"), kind: CubeKind::Face,
] { outline: cube_octagon(n, axes, 1.0, 1.0 - k),
let (e1, e2) = cube_face_axes(n); patches: vec![(n, inner)],
let uv = });
[(-1.0 + k, -1.0), (1.0 - k, -1.0), (1.0, -1.0 + k), (1.0, 1.0 - k), (1.0 - k, 1.0), (-1.0 + k, 1.0), (-1.0, 1.0 - k), (-1.0, -1.0 + k)];
out.push(CubeTarget { name: name.into(), dir: n, corner: false, outline: uv.iter().map(|(a, b2)| n + e1 * *a + e2 * *b2).collect() });
} }
for sz in [1.0, -1.0] { for sz in [1.0, -1.0] {
for sy in [-1.0, 1.0] { for sy in [-1.0, 1.0] {
for sx in [1.0, -1.0] { for sx in [1.0, -1.0] {
let corner = Vec3::new(sx, sy, sz); 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)]; 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 out
} }
@@ -2225,22 +2280,34 @@ fn cube_label(ctx: &egui::Context, text: &str, n: Vec3, ru: (Vec3, Vec3), c: Pos
Shape::mesh(mesh) Shape::mesh(mesh)
} }
/// The targets turned toward the camera (basis `rub`), front first, as screen polygons around /// The polygons (target index, normal, outline) turned toward the camera (basis `rub`), front
/// the cube's centre `c`. /// 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<Pos2>)> { fn cube_project<'a>(polys: impl Iterator<Item = (usize, Vec3, &'a [Vec3])>, rub: (Vec3, Vec3, Vec3), c: Pos2) -> Vec<(usize, Vec<Pos2>)> {
let (r, u, b) = rub; let (r, u, b) = rub;
let mut v: Vec<(usize, f64, Vec<Pos2>)> = targets let mut v: Vec<(usize, f64, Vec<Pos2>)> = polys
.iter() .map(|(i, n, outline)| (i, n.normalized().unwrap_or(n).dot(b), outline))
.enumerate() .filter(|(_, facing, _)| *facing > 1e-3)
.filter(|(_, tg)| tg.normal().dot(b) > 1e-3) .map(|(i, facing, outline)| (i, facing, outline.iter().map(|p| cube_to_screen((r, u), c, *p)).collect()))
.map(|(i, tg)| (i, tg.normal().dot(b), tg.outline.iter().map(|p| cube_to_screen((r, u), c, *p)).collect()))
.collect(); .collect();
v.sort_by(|a, b2| b2.1.total_cmp(&a.1)); v.sort_by(|a, b2| b2.1.total_cmp(&a.1));
v.into_iter().map(|(i, _, p)| (i, p)).collect() 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 /// The targets' patches turned toward the camera, front first, each with its target's index: what
/// of them (where neither polygon claims it) goes to the nearer one, within a few pixels. /// the pointer picks.
pub(crate) fn cube_projected(targets: &[CubeTarget], rub: (Vec3, Vec3, Vec3), c: Pos2) -> Vec<(usize, Vec<Pos2>)> {
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<Pos2>)> {
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<Pos2>)], p: Pos2) -> Option<usize> { pub(crate) fn cube_hit(shapes: &[(usize, Vec<Pos2>)], p: Pos2) -> Option<usize> {
if let Some((i, _)) = shapes.iter().find(|(_, pts)| point_in_poly(p, pts)) { if let Some((i, _)) = shapes.iter().find(|(_, pts)| point_in_poly(p, pts)) {
return Some(*i); return Some(*i);
@@ -2776,23 +2843,49 @@ mod tests {
} }
#[test] #[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(); let targets = cube_targets();
assert_eq!(targets.len(), 14); assert_eq!(targets.len(), 26);
assert_eq!(targets.iter().filter(|t| t.corner).count(), 8); 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); let c = pos2(100.0, 100.0);
for (i, tg) in targets.iter().enumerate() { 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 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; let mine: Vec<_> = shapes.iter().filter(|(j, _)| *j == i).collect();
assert_eq!(cube_hit(&shapes, cube_centroid(pts)), Some(i), "{}", tg.name); 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); 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); 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 /// From any direction, including faces turned nearly edge-on, everything drawn stays within
/// the cube. /// the cube.
#[test] #[test]
@@ -2803,7 +2896,8 @@ mod tests {
let limit = (3f64.sqrt() * CUBE_SCALE) as f32 + 3.0; let limit = (3f64.sqrt() * CUBE_SCALE) as f32 + 3.0;
let mut worst = 0.0f32; let mut worst = 0.0f32;
for dir in orbit_directions() { 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))) { for p in drawn_points(projected_polygon(&pts, Color32::GRAY, Stroke::new(1.0, Color32::BLACK))) {
worst = worst.max(p.distance(c)); worst = worst.max(p.distance(c));
} }
@@ -2814,7 +2908,7 @@ mod tests {
#[test] #[test]
fn view_cube_labels_read_upright_in_their_own_view() { 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). // 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); let mut cam = solvecraft_engine::render::Camera::default().looking_from(tg.dir);
if tg.dir.z.abs() > 0.5 { 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] #[test]
fn view_cube_labels_stay_on_their_faces() { fn view_cube_labels_stay_on_their_faces() {
let ctx = egui::Context::default(); let ctx = egui::Context::default();
@@ -2838,7 +2932,7 @@ mod tests {
let (r, u, b) = basis_from(dir); let (r, u, b) = basis_from(dir);
for (i, pts) in cube_projected(&targets, (r, u, b), c) { for (i, pts) in cube_projected(&targets, (r, u, b), c) {
let tg = &targets[i]; let tg = &targets[i];
if tg.corner { if tg.kind != CubeKind::Face {
continue; continue;
} }
for p in drawn_points(vec![cube_label(ui.ctx(), &tg.name.to_uppercase(), tg.normal(), (r, u), c, Color32::BLACK)]) { 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] #[test]
fn view_cube_edges_are_not_targets() { fn view_cube_edges_are_targets() {
let targets = cube_targets(); let targets = cube_targets();
let c = pos2(100.0, 100.0); 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<usize>| i.map(|i| targets[i].name.clone()).unwrap_or_default(); let name = |i: Option<usize>| 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] { for x in [-0.5, 0.0, 0.5] {
let hit = name(cube_hit(&shapes, to2(Vec3::new(x, -1.0, 1.0)))); 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!(hit == "top" || hit == "front", "edge point {x}: {hit}"); assert_eq!(at(iso, p), "top-front", "{p:?}");
}
} }
let end = name(cube_hit(&shapes, to2(Vec3::new(0.9, -1.0, 1.0)))); // Clear of the band: the face; at the end of the edge: the corner.
assert!(["top", "front", "top-front-right"].contains(&end.as_str()), "{end}"); assert_eq!(at(iso, Vec3::new(0.0, -0.7, 1.0)), "top");
// Every direction a click turns to is a face's or a corner's: none along an edge. assert_eq!(at(iso, Vec3::new(0.0, -1.0, 0.7)), "front");
for tg in &targets { assert_eq!(at(iso, Vec3::new(0.85, -1.0, 0.95)), "top-front-right");
let zeros = [tg.dir.x, tg.dir.y, tg.dir.z].iter().filter(|v| v.abs() < 1e-9).count(); // A vertical edge too.
assert!(zeros == 2 || zeros == 0, "{}: {:?}", tg.name, tg.dir); 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<f64> = [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);
} }
} }
@@ -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"}}, {"call": "ui.view", "params": {"view": "home"}},
{"move": {"cube": [0, 0, 1]}}, {"move": {"cube": [0, 0, 1]}},
{"expect": {"count": {"name": "cube_highlights", "value": 1}, "shown_handles": ["cube_hover:top"]}}, {"expect": {"count": {"name": "cube_highlights", "value": 1}, "shown_handles": ["cube_hover:top"]}},
{"move": {"cube": [0.3, -1, 0.2]}}, {"move": {"cube": [0.3, -1, 0.2]}},
{"expect": {"count": {"name": "cube_highlights", "value": 1}, "shown_handles": ["cube_hover:front"]}}, {"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]}}, {"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]}}, {"move": {"cube": [0.873, -0.873, 0.873]}},
{"expect": {"count": {"name": "cube_highlights", "value": 1}, "shown_handles": ["cube_hover:top-front-right"]}}, {"expect": {"count": {"name": "cube_highlights", "value": 1}, "shown_handles": ["cube_hover:top-front-right"]}},
{"click": {"handle": "cube:top-front-right"}}, {"click": {"handle": "cube:top-front-right"}},
{"expect": {"camera": {"yaw_deg": -45, "pitch_deg": 35.26}}}, {"expect": {"camera": {"yaw_deg": -45, "pitch_deg": 35.26}}},
{"click": {"handle": "cube:front"}}, {"click": {"handle": "cube:front"}},
{"expect": {"camera": {"yaw_deg": 0, "pitch_deg": 0}}}, {"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"}}, {"call": "ui.view", "params": {"view": "home"}},
{"click": {"handle": "cube:top-back-right"}}, {"click": {"handle": "cube:top-back-right"}},
{"expect": {"camera": {"yaw_deg": -135, "pitch_deg": 35.26}}}, {"expect": {"camera": {"yaw_deg": -135, "pitch_deg": 35.26}}},
+2 -2
View File
@@ -15,8 +15,8 @@
- **Viewport.** Click to select, Shift or Ctrl to add, drag for a window or crossing box. The - **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 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 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 change the view: a cube face turns to its standard view, an edge to the view halfway between
iso view, and the house goes home. Right-click for the marking menu: eight commands around the cursor, plus a 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. menu for whatever is under it.
- **Dialogs** dock on the right with a live preview. Most features also show drag handles and a - **Dialogs** dock on the right with a live preview. Most features also show drag handles and a
value box in the viewport. value box in the viewport.