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:
1 parent
aa24d9f071
commit
7b34d57b25
15 files changed
+1344
-93
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
@@ -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", ¶ms).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -465,6 +465,16 @@ impl SolveApp {
|
||||
let r = self.session.execute(id, ¶ms).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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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
@@ -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` | – |
|
||||
Reference in new issue
Block a user