feat(ui): offer a cube navigator alongside signed axes (#664)

Adds a View navigator preference (Navigation page) with an optional cube navigator alongside the signed axes, which stay the default (#165).
This commit is contained in:
Karl Von Richter authored and GitHub committed 2026-10-13 09:28:52 -04:00
1 parent aa24d9f071
commit 7b34d57b25
15 files changed
+1344 -93

No files matched your search

+6 -1
View File
@@ -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.
+10
View File
@@ -465,6 +465,16 @@ impl SolveApp {
let r = self.session.execute(id, &params).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);
}
+726
View File
@@ -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<Pos2>,
outer_edges: Vec<bool>,
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::<Vec<_>>()
.join(" · ")
}
fn facets(cam: &Camera, c: Pos2, half: f64) -> Vec<Facet> {
if !cam.is_valid() {
return Vec::new();
}
let mut out = Vec::new();
let mut add = |d: Vec3, normal: Vec3, mut vertices: Vec<Vec3>, 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<usize> {
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<usize>, 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<Vec3>) {
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::<usize>(),
"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::<Vec<_>>());
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::<Vec<_>>());
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());
}
}
+80
View File
@@ -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);
+25
View File
@@ -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),
+43 -15
View File
@@ -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();
+10 -4
View File
@@ -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);
+77 -66
View File
@@ -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());