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

+2
View File
@@ -53,6 +53,7 @@ mod sketch_modify;
mod sketch_project;
mod surface;
mod surface_analysis;
mod view_navigation;
use serde::Serialize;
use serde_json::Value;
@@ -219,6 +220,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
v.extend(inspect::COMMANDS.iter());
v.extend(materials::COMMANDS.iter());
v.extend(section::COMMANDS.iter());
v.extend(view_navigation::COMMANDS.iter());
v.extend(diagnostics::COMMANDS.iter());
v
}
+131
View File
@@ -0,0 +1,131 @@
//! Stateless camera commands shared by the navigation cube and programmatic callers.
use serde_json::{Value, json};
use solidcraft_render::{Camera, StandardView};
use super::CommandSpec;
use crate::params::{bad, vec3};
use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("view.navigate", "Navigate View", navigate)
.icon("orbit")
.noundo()
.params("camera: Camera; direction?: [x,y,z] in the camera's up-axis frame; or action: left|right|up|down|clockwise|counterclockwise|home|fit → camera, fit")];
fn navigate(_s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "view.navigate";
let mut cam: Camera =
serde_json::from_value(p.get("camera").cloned().ok_or_else(|| bad(cmd, "camera is required"))?).map_err(|_| bad(cmd, "invalid camera"))?;
if !cam.is_valid()
|| !cam.fov.is_finite()
|| !(0.0..std::f64::consts::PI).contains(&cam.fov)
|| cam.yaw.abs() > 1e6
|| cam.roll.abs() > 1e6
|| cam.pitch.abs() > std::f64::consts::FRAC_PI_2
{
return Err(bad(cmd, "invalid camera"));
}
let mut fit = false;
if let Some(value) = p.get("direction") {
let dir = vec3(value)
.filter(|v| v.is_finite() && v.len() <= 2.0)
.and_then(|v| v.normalized())
.ok_or_else(|| bad(cmd, "direction must be a finite nonzero vector of length at most 2"))?;
let heading = cam.yaw;
cam = cam.looking_from(cam.from_frame(dir));
if dir.x.abs() < 1e-9 && dir.y.abs() < 1e-9 {
cam.yaw = square_heading(heading);
}
} else {
use std::f64::consts::FRAC_PI_2;
let (r, u, _) = cam.basis();
match p.get("action").and_then(Value::as_str) {
Some("left") => cam = cam.looking_from(-r),
Some("right") => cam = cam.looking_from(r),
Some("up") => cam = cam.looking_from(u),
Some("down") => cam = cam.looking_from(-u),
Some("clockwise") => cam.roll = (cam.roll + FRAC_PI_2).rem_euclid(std::f64::consts::TAU),
Some("counterclockwise") => cam.roll = (cam.roll - FRAC_PI_2).rem_euclid(std::f64::consts::TAU),
Some("home") => {
cam.set_view(StandardView::Iso);
fit = true;
}
Some("fit") => fit = true,
_ => return Err(bad(cmd, "unknown navigation action")),
}
}
Ok(json!({"camera": cam, "fit": fit}))
}
/// At a halfway tie prefer the heading nearer the front plan view.
pub(crate) fn square_heading(yaw: f64) -> f64 {
if !yaw.is_finite() {
return 0.0;
}
let q = std::f64::consts::FRAC_PI_2;
let lo = (yaw / q).floor() * q;
let hi = lo + q;
if ((yaw - lo) - (hi - yaw)).abs() < 1e-6 {
let off_front = |a: f64| ((a + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI).abs();
if off_front(lo) <= off_front(hi) { lo } else { hi }
} else if yaw - lo < hi - yaw {
lo
} else {
hi
}
}
#[cfg(test)]
mod tests {
use super::*;
use solidcraft_geom::Vec3;
use solidcraft_render::UpAxis;
#[test]
fn every_cube_direction_preserves_framing_without_editing_the_design() {
for up in [UpAxis::Z, UpAxis::Y] {
let mut s = Session::default();
let before = s.doc.to_json();
let cam = Camera { up, target: Vec3::new(10.0, 20.0, 30.0), distance: 123.0, ..Default::default() };
for x in [-1.0, 0.0, 1.0] {
for y in [-1.0, 0.0, 1.0] {
for z in [-1.0, 0.0, 1.0] {
let Some(dir) = Vec3::new(x, y, z).normalized() else { continue };
let result = s.execute("view.navigate", &json!({"camera":cam,"direction":[x,y,z]})).unwrap();
let to: Camera = serde_json::from_value(result["camera"].clone()).unwrap();
assert!(to.back().dist(cam.from_frame(dir)) < 2e-6);
assert_eq!((to.target, to.distance, to.fov, to.up), (cam.target, cam.distance, cam.fov, cam.up));
}
}
}
assert_eq!(s.doc.to_json(), before);
assert!(s.undo.is_empty());
}
}
#[test]
fn arrows_roll_and_home_are_programmatic_and_reject_hostile_input() {
let mut s = Session::default();
let cam = Camera::default();
let (r, u, _) = cam.basis();
for (action, dir) in [("left", -r), ("right", r), ("up", u), ("down", -u)] {
let result = navigate(&mut s, &json!({"camera":cam,"action":action})).unwrap();
let to: Camera = serde_json::from_value(result["camera"].clone()).unwrap();
assert!(to.back().dist(dir) < 2e-6);
}
let result = navigate(&mut s, &json!({"camera":cam,"action":"clockwise"})).unwrap();
let to: Camera = serde_json::from_value(result["camera"].clone()).unwrap();
assert_eq!(to.back(), cam.back());
assert!((to.basis().0 - u).len() < 1e-9);
for action in ["home", "fit"] {
assert_eq!(navigate(&mut s, &json!({"camera":cam,"action":action})).unwrap()["fit"], true);
}
for params in [
json!({}),
json!({"camera":cam,"direction":[0,0,0]}),
json!({"camera":cam,"direction":[1e308,0,0]}),
json!({"camera":cam,"action":"unknown"}),
] {
assert!(s.execute("view.navigate", &params).is_err());
}
}
}
+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());
@@ -0,0 +1,65 @@
[
{"call":"ui.prefs","params":{"navigator_style":"cube"}},
{
"note": "Cube facets own one highlight at a time and choose principal views."
},
{
"call": "ui.view",
"params": {
"view": "home"
}
},
{
"move": {
"handle": "navigation_cube:top"
}
},
{
"expect": {
"count": {
"name": "axis_view_highlights",
"value": 1
}
}
},
{
"click": {
"handle": "navigation_cube:top"
}
},
{
"expect": {
"camera": {
"yaw_deg": 0,
"pitch_deg": 90
}
}
},
{
"click": {
"handle": "navigation_cube:control:down"
}
},
{
"expect": {
"camera": {
"yaw_deg": 0,
"pitch_deg": 0
}
}
},
{
"move": [
800,
500
]
},
{
"expect": {
"count": {
"name": "axis_view_highlights",
"value": 0
}
}
}
]
@@ -0,0 +1,74 @@
[
{"call":"ui.prefs","params":{"navigator_style":"cube"}},
{
"note": "Cube top/bottom face clicks preserve the nearest square heading in each up frame."
},
{
"camera": {
"yaw": -2.356194490192345,
"pitch": 0.6154797086703874
}
},
{
"click": {
"handle": "navigation_cube:top"
}
},
{
"expect": {
"camera": {
"yaw_deg": -90,
"pitch_deg": 90
}
}
},
{
"camera": {
"yaw": 2.9670597283903604,
"pitch": -0.6
}
},
{
"click": {
"handle": "navigation_cube:bottom"
}
},
{
"expect": {
"camera": {
"yaw_deg": 180,
"pitch_deg": -90
}
}
},
{
"call": "ui.prefs",
"params": {
"up_axis": "y"
}
},
{
"camera": {
"yaw": -1.3962634015954636,
"pitch": 0.6
}
},
{
"click": {
"handle": "navigation_cube:top"
}
},
{
"expect": {
"camera": {
"yaw_deg": -90,
"pitch_deg": 90,
"back": [
0,
1,
0
]
}
}
}
]
@@ -0,0 +1,54 @@
[
{"call":"ui.prefs","params":{"navigator_style":"cube"}},
{
"note": "Cube face names follow the up-axis frame; with Y up, Top looks from +Y and Front from +Z."
},
{
"call": "ui.prefs",
"params": {
"up_axis": "y"
}
},
{
"call": "ui.view",
"params": {
"view": "home"
}
},
{
"click": {
"handle": "navigation_cube:top"
}
},
{
"expect": {
"camera": {
"yaw_deg": 0,
"pitch_deg": 90,
"back": [
0,
1,
0
]
}
}
},
{
"click": {
"handle": "navigation_cube:control:down"
}
},
{
"expect": {
"camera": {
"yaw_deg": 0,
"pitch_deg": 0,
"back": [
0,
0,
1
]
}
}
}
]
+33 -4
View File
@@ -64,7 +64,7 @@ unit regardless of the design, pass an expression with one: `"value": "50 mm"` o
| `ui.sketchToScreen` | `points`: `[[x, y]…]` in active-sketch coordinates | their screen points (null when behind the camera) |
| `ui.dialogInput` | `index` | what clicking an input's Select chip does: picks go to that input |
| `ui.at` | `world: [x,y,z]` \| `sketch: [x,y]` \| `plane: "XY"` \| `axis: "Z"` \| `dimension: "d1"` (its value text) \| `handle: "arrow"` (a manipulator handle, see `scenario::publish_handle`) | the screen point (a scenario step's target; see `crates/ui-egui/src/scenario.rs`) |
| `ui.handles` | `prefix?` | the handles drawn last frame, `[{name, x, y}]`: toolbar buttons and drop-down items `toolbar:<command id>` (the button alone: `button:<command id>`; a drop-down row: `dropdown:<command id>`, highlighted `dropdown_hot:<command id>`, its pin `dropdown_pin:<command id>`; Customize Toolbar's check boxes `customize:<command id>`), panel labels `panel:<tab>:<panel>`, browser rows `row:<label>` and fold arrows `fold:<label>`, manipulator handles; orientation tips `axis_view:x`, `axis_view:-x` (and y/z) |
| `ui.handles` | `prefix?` | the handles drawn last frame, `[{name, x, y}]`: toolbar buttons and drop-down items `toolbar:<command id>` (the button alone: `button:<command id>`; a drop-down row: `dropdown:<command id>`, highlighted `dropdown_hot:<command id>`, its pin `dropdown_pin:<command id>`; Customize Toolbar's check boxes `customize:<command id>`), panel labels `panel:<tab>:<panel>`, browser rows `row:<label>` and fold arrows `fold:<label>`, manipulator handles; signed-axis tips `axis_view:z`, `axis_view:-y`; optional cube facets `navigation_cube:top`, `navigation_cube:top_front_right`, controls `navigation_cube:control:home` (and other actions), plus signed-axis aliases for visible cube faces; Preferences navigator picker `prefs:navigator`, options `prefs:navigator:Signed axes` and `prefs:navigator:Cube` |
| `ui.editFeature` | `feature` (id or name) | what double-clicking a timeline item does: rolls back to it and opens its dialog filled in (sketches open in sketch mode) |
| `ui.scroll` | `x`, `y`, `delta?` | wheel zoom at the cursor |
| `ui.key` | `key` (egui key name, `Enter`, `Escape`…), `cmd?`, `shift?` | |
@@ -99,10 +99,39 @@ channel; see [mcp.md](mcp.md).
{"id":5,"method":"ui.screenshot","params":{"path":"/tmp/part.png"}}
```
Preferences → Navigation → View navigator offers **Signed axes** (the original default) and
**Cube**. This choice is saved independently of the mouse preset. The control channel selects it
with `ui.prefs {"navigator_style":"axes"}` or `ui.prefs {"navigator_style":"cube"}`.
Existing settings without this field keep signed axes. Signed-axis tips choose principal views;
clicking an aligned tip again reverses the view.
Hold Shift during an orbit gesture to snap to the nearest standard or diagonal view. This applies to
right-drag, the Orbit navigation tool, and dragging the axis view. Under the default preset,
Shift+middle also orbits with snapping; middle alone pans. Axis-view tips use world coordinates,
regardless of the up-axis preference. Clicking an aligned tip again flips to its opposite.
right-drag, the Orbit navigation tool, and dragging either view navigator. Under the default preset,
Shift+middle also orbits with snapping; middle alone pans. Cube faces choose principal views,
and corners choose isometric views. The middle of each cube edge belongs to its face. Face names
follow the up-axis preference; the small X/Y/Z triad shows world axes. Clicking the same face keeps that view.
The inward-pointing arrows orbit by 90 degrees; the curved arrows at the upper right roll the
view by 90 degrees. The Home button at the upper left returns to isometric and the lower-right circle
fits the model.
The cube is subdued while idle. Hovering its area brightens the cube and navigation bar and
reveals the surrounding controls; the picked face or corner gets a pale blue fill and
blue outline. Moving away restores the subdued state. Navigation-bar controls also brighten
when hovered or when a navigation tool is active.
The cube has six face targets and eight corner targets, with no edge targets. Corner picking
regions lie on the six flat faces, preserving sharp corners with no permanent subdivision
lines. Hovering a corner highlights its parts on the adjacent visible faces together.
The cube widget occupies a compact 112-point square in the viewport corner. A straight-on
cube face is approximately 55 points wide; painting and picking scale together. Face labels
are stretched vertically. View clicks ease through intermediate orientations when Navigation
animated view changes is enabled (the default), at the configured animation speed.
The cube runs `view.navigate` through the command engine. Programmatic callers pass `camera`
and either `direction: [x,y,z]` in the camera's up-axis frame or `action` (`left`, `right`, `up`,
`down`, `clockwise`, `counterclockwise`, `home`, `fit`). The result contains `camera` and `fit`;
UI callers apply the camera with their animation preference, without editing the design.
Shift-orbit snapping includes 45-degree edge views and isometric corner views, with a
lightly damped spring transition and a small boundary margin to prevent flicker.
+8 -3
View File
@@ -1,6 +1,6 @@
# Features
SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **322 commands.** Every command runs the same way from the toolbar, a script, `solidcraft-cli exec <id> '<json>'`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md).
SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **328 commands.** Every command runs the same way from the toolbar, a script, `solidcraft-cli exec <id> '<json>'`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md).
## Sketch (98)
@@ -207,7 +207,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm
| Assign Plastic Rule | `plastic.assign_rule` | PLASTIC › SETUP |
| List Plastic Rules | `plastic.rules` | – |
## Components (12)
## Components (16)
| Command | Id | Toolbar |
|---|---|---|
@@ -223,6 +223,10 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm
| Paste New | `component.paste_new` | – |
| Capture Position | `component.capture_position` | SOLID › POSITION |
| Revert Position | `component.revert_position` | SOLID › POSITION |
| Insert Design | `component.insert_design` | – |
| Update Links | `component.update_links` | – |
| Break Link | `component.break_link` | – |
| List Links | `component.links` | – |
## Assembly joints (11)
@@ -490,7 +494,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm
|---|---|---|
| Delete | `selection.delete` | – |
## `view.*` (5)
## `view.*` (6)
| Command | Id | Toolbar |
|---|---|---|
@@ -499,3 +503,4 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm
| Delete Named View | `view.delete` | – |
| List Named Views | `view.list` | – |
| Restore Named View | `view.apply` | – |
| Navigate View | `view.navigate` | – |