From cd993e35a3180d634af4a11a0a328484457bc41f Mon Sep 17 00:00:00 2001 From: bherbruck Date: Wed, 7 Oct 2026 19:33:19 +0000 Subject: [PATCH] View changes animate: view cube faces, edges and corners, Home and Fit move the camera over 500 ms (quaternion slerp, ease in-out); mouse navigation cancels; control channel snaps unless animate Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- crates/render/src/camera.rs | 187 +++++++++++++++++++++++++++++++++ crates/render/src/lib.rs | 2 +- crates/ui-egui/src/control.rs | 13 ++- crates/ui-egui/src/lib.rs | 65 +++++++++++- crates/ui-egui/src/viewport.rs | 100 +++++++++++++++--- docs/control-protocol.md | 2 +- 6 files changed, 345 insertions(+), 24 deletions(-) diff --git a/crates/render/src/camera.rs b/crates/render/src/camera.rs index 347ef247..540f6b73 100644 --- a/crates/render/src/camera.rs +++ b/crates/render/src/camera.rs @@ -72,6 +72,109 @@ impl StandardView { } } +/// Smooth ease-in-out on [0, 1] (cubic). +pub fn ease_in_out(t: f64) -> f64 { + let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 1.0 }; + if t < 0.5 { 4.0 * t * t * t } else { 1.0 - (-2.0 * t + 2.0).powi(3) / 2.0 } +} + +/// A unit quaternion (w, x, y, z). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Quat { + pub w: f64, + pub x: f64, + pub y: f64, + pub z: f64, +} + +impl Quat { + /// From an orthonormal right-handed basis (the columns of a rotation matrix). + pub fn from_basis(c0: Vec3, c1: Vec3, c2: Vec3) -> Quat { + let (m00, m11, m22) = (c0.x, c1.y, c2.z); + let tr = m00 + m11 + m22; + let q = if tr > 0.0 { + let s = (tr + 1.0).sqrt() * 2.0; + Quat { w: 0.25 * s, x: (c1.z - c2.y) / s, y: (c2.x - c0.z) / s, z: (c0.y - c1.x) / s } + } else if m00 > m11 && m00 > m22 { + let s = (1.0 + m00 - m11 - m22).sqrt() * 2.0; + Quat { w: (c1.z - c2.y) / s, x: 0.25 * s, y: (c1.x + c0.y) / s, z: (c2.x + c0.z) / s } + } else if m11 > m22 { + let s = (1.0 + m11 - m00 - m22).sqrt() * 2.0; + Quat { w: (c2.x - c0.z) / s, x: (c1.x + c0.y) / s, y: 0.25 * s, z: (c2.y + c1.z) / s } + } else { + let s = (1.0 + m22 - m00 - m11).sqrt() * 2.0; + Quat { w: (c0.y - c1.x) / s, x: (c2.x + c0.z) / s, y: (c2.y + c1.z) / s, z: 0.25 * s } + }; + q.normalized() + } + pub fn dot(self, o: Quat) -> f64 { + self.w * o.w + self.x * o.x + self.y * o.y + self.z * o.z + } + pub fn len(self) -> f64 { + self.dot(self).sqrt() + } + fn scale(self, k: f64) -> Quat { + Quat { w: self.w * k, x: self.x * k, y: self.y * k, z: self.z * k } + } + fn add(self, o: Quat) -> Quat { + Quat { w: self.w + o.w, x: self.x + o.x, y: self.y + o.y, z: self.z + o.z } + } + pub fn normalized(self) -> Quat { + let l = self.len(); + if l > 1e-300 && l.is_finite() { self.scale(1.0 / l) } else { Quat { w: 1.0, x: 0.0, y: 0.0, z: 0.0 } } + } + /// Shortest-arc spherical interpolation. + pub fn slerp(self, o: Quat, t: f64) -> Quat { + let mut d = self.dot(o); + let o = if d < 0.0 { + d = -d; + o.scale(-1.0) + } else { + o + }; + if d > 0.9995 { + return self.scale(1.0 - t).add(o.scale(t)).normalized(); + } + let th = d.clamp(-1.0, 1.0).acos(); + let s = th.sin(); + self.scale(((1.0 - t) * th).sin() / s).add(o.scale((t * th).sin() / s)).normalized() + } + /// The rotated basis vectors (columns of the rotation matrix). + pub fn basis(self) -> (Vec3, Vec3, Vec3) { + let Quat { w, x, y, z } = self; + ( + Vec3::new(1.0 - 2.0 * (y * y + z * z), 2.0 * (x * y + w * z), 2.0 * (x * z - w * y)), + Vec3::new(2.0 * (x * y - w * z), 1.0 - 2.0 * (x * x + z * z), 2.0 * (y * z + w * x)), + Vec3::new(2.0 * (x * z + w * y), 2.0 * (y * z - w * x), 1.0 - 2.0 * (x * x + y * y)), + ) + } +} + +/// A camera move in progress: from one camera to another over `duration` seconds. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CameraAnim { + pub from: Camera, + pub to: Camera, + pub start: f64, + pub duration: f64, +} + +impl CameraAnim { + /// The usual length of a view change, in seconds. + pub const DURATION: f64 = 0.5; + pub fn new(from: Camera, to: Camera, now: f64) -> CameraAnim { + CameraAnim { from, to, start: now, duration: CameraAnim::DURATION } + } + /// The camera at time `now`, and whether the move has finished. + pub fn sample(&self, now: f64) -> (Camera, bool) { + let t = if self.duration > 0.0 { (now - self.start) / self.duration } else { 1.0 }; + if !(t < 1.0) { + return (self.to, true); + } + (Camera::interpolate(&self.from, &self.to, ease_in_out(t)), false) + } +} + /// Orbit camera around `target` (Z up). #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] pub struct Camera { @@ -205,6 +308,55 @@ impl Camera { let r = (b.diagonal() * 0.5).max(1.0); self.distance = if self.fov > 0.0 { r / (self.fov * 0.5).sin() * 1.1 } else { r / 0.4 * 1.15 }; } + /// The same camera looking from direction `back` (target → eye). Straight up or down + /// keeps the yaw, rounded to the nearest quarter turn so the view stays square. + pub fn looking_from(&self, back: Vec3) -> Camera { + let Some(b) = back.normalized() else { return *self }; + let lim = std::f64::consts::FRAC_PI_2 - 1e-6; + let pitch = b.z.clamp(-1.0, 1.0).asin().clamp(-lim, lim); + let yaw = if b.x.hypot(b.y) < 1e-6 { + let q = std::f64::consts::FRAC_PI_2; + (self.yaw / q).round() * q + } else { + (-b.x).atan2(-b.y) + }; + Camera { yaw, pitch, ..*self } + } + /// Orientation as a unit quaternion (camera → world rotation). + pub fn orientation(&self) -> Quat { + let (r, u, b) = self.basis(); + Quat::from_basis(r, u, b) + } + /// Blend between two cameras at `s` in [0, 1]: orientation by quaternion slerp, target, + /// distance and field of view linearly. The ends are returned exactly. + pub fn interpolate(a: &Camera, b: &Camera, s: f64) -> Camera { + if !(s > 0.0) { + return *a; + } + if s >= 1.0 { + return *b; + } + let q = a.orientation().slerp(b.orientation(), s); + let (r, _, back) = q.basis(); + let lim = std::f64::consts::FRAC_PI_2 - 1e-6; + let pitch = back.z.clamp(-1.0, 1.0).asin().clamp(-lim, lim); + // The camera has no roll: follow the slerped view direction, and near straight up/down + // (where the direction has no heading) the slerped right vector. + let yaw = if back.x.hypot(back.y) > 1e-3 { + (-back.x).atan2(-back.y) + } else if r.x.hypot(r.y) > 1e-9 { + (-r.y).atan2(r.x) + } else { + a.yaw + (b.yaw - a.yaw) * s + }; + Camera { + target: a.target + (b.target - a.target) * s, + yaw, + pitch, + distance: a.distance + (b.distance - a.distance) * s, + fov: a.fov + (b.fov - a.fov) * s, + } + } pub fn is_valid(&self) -> bool { self.target.is_finite() && self.yaw.is_finite() && self.pitch.is_finite() && self.distance.is_finite() && self.distance > 0.0 } @@ -241,6 +393,41 @@ mod tests { assert!(x1 > x0, "dragging right must move the near side right: {x0} -> {x1}"); } + #[test] + fn view_animation_interpolates() { + let mut a = Camera { target: Vec3::new(1.0, 2.0, 3.0), distance: 80.0, ..Default::default() }; + a.set_view(StandardView::Iso); + let mut b = Camera { target: Vec3::new(-5.0, 0.0, 10.0), distance: 120.0, ..Default::default() }; + b.set_view(StandardView::Top); + let anim = CameraAnim::new(a, b, 10.0); + // Endpoints are exact. + assert_eq!(anim.sample(10.0).0, a); + assert_eq!(anim.sample(10.0 + CameraAnim::DURATION), (b, true)); + assert_eq!(anim.sample(99.0), (b, true)); + // Midpoint: a valid orientation half way between (equal angles to both ends). + let (m, done) = anim.sample(10.0 + CameraAnim::DURATION / 2.0); + assert!(!done && m.is_valid()); + let q = m.orientation(); + assert!((q.len() - 1.0).abs() < 1e-12); + let (qa, qb) = (a.orientation(), b.orientation()); + let qm = qa.slerp(qb, 0.5); + assert!((qm.dot(qa).abs() - qm.dot(qb).abs()).abs() < 1e-12); + // The camera keeps no roll but follows the slerped view direction. + let (_, _, back) = qm.basis(); + assert!(m.back().dist(back) < 1e-9, "{:?} vs {back:?}", m.back()); + assert!(m.target.dist(Vec3::new(-2.0, 1.0, 6.5)) < 1e-9 && (m.distance - 100.0).abs() < 1e-9); + // Quaternion round trip of a basis. + let (r, u, bk) = a.basis(); + let (r2, u2, b2) = a.orientation().basis(); + assert!(r.dist(r2) < 1e-12 && u.dist(u2) < 1e-12 && bk.dist(b2) < 1e-12); + // Ease: fixed ends, symmetric, monotonic. + assert_eq!((ease_in_out(0.0), ease_in_out(1.0), ease_in_out(0.5)), (0.0, 1.0, 0.5)); + assert!(ease_in_out(0.25) < 0.25 && ease_in_out(0.75) > 0.75); + // Looking from a corner direction. + let c = a.looking_from(Vec3::new(1.0, -1.0, 1.0)); + assert!(c.back().dist(Vec3::new(1.0, -1.0, 1.0).normalized().unwrap()) < 1e-9); + } + #[test] fn top_view_and_perspective() { let mut c = Camera { distance: 100.0, fov: 0.8, ..Default::default() }; diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index a9b99177..54be069c 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -7,5 +7,5 @@ mod camera; mod raster; -pub use camera::{Camera, Mat4, StandardView}; +pub use camera::{Camera, CameraAnim, Mat4, Quat, StandardView, ease_in_out}; pub use raster::{Canvas, Rgb, Scene, SceneLine, SceneMesh, render, render_png}; diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 3d4e0d4c..1f16f407 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -6,7 +6,7 @@ //! - `engine.commands`: every command with tab, panel, params and enablement //! - `document.inspect {measure?}`: the design (parameters, timeline, bodies, sketches) //! - `ui.inspect`: UI state, viewport rect, camera, tool and dialog -//! - `ui.set {...UiState fields}`; `ui.view {view: front|back|top|bottom|left|right|iso|home|fit}` +//! - `ui.set {...UiState fields}`; `ui.view {view: front|back|top|bottom|left|right|iso|home|fit, animate?: bool}` (snaps unless animate) //! - `ui.start {command}`: like clicking the toolbar button (starts tools/dialogs) //! - `ui.click {x, y, button?, shift?}`, `ui.move {x, y}`, `ui.scroll {x, y, delta}`: real //! pointer input in screen points; `ui.key {key, cmd?, shift?}`, `ui.text {text}` @@ -110,7 +110,16 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> } } "ui.view" => { - match s("view").unwrap_or("home") { + let v = s("view").unwrap_or("home"); + if p.get("animate").and_then(Value::as_bool).unwrap_or(false) { + if v != "fit" && v != "home" && StandardView::parse(v).is_none() { + return err(format!("unknown view `{v}`")); + } + app.animate_view(v); + return ok(json!({ "animating": true, "to": app.cam_anim.map(|a| a.to) })); + } + app.cam_anim = None; + match v { "fit" => app.fit_view(), "home" => { app.cam.set_view(StandardView::Iso); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 041803e0..aeec3547 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -25,7 +25,7 @@ use std::sync::mpsc::Receiver; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use solvecraft_engine::Session; -use solvecraft_engine::render::Camera; +use solvecraft_engine::render::{Camera, CameraAnim, StandardView}; pub use control::{ControlRequest, ControlResponse}; @@ -72,6 +72,10 @@ pub struct SolveApp { pub session: Session, pub ui: UiState, pub cam: Camera, + /// A view change in progress (view cube, Home, Fit); any mouse navigation cancels it. + pub cam_anim: Option, + /// Seconds since start, as of this frame. + pub now: f64, pub services: Services, pub viewport: viewport::ViewportState, pub tool: Option, @@ -95,6 +99,8 @@ impl SolveApp { session, ui: UiState::default(), cam: Camera::default(), + cam_anim: None, + now: 0.0, services, viewport: viewport::ViewportState::default(), tool: None, @@ -175,13 +181,62 @@ impl SolveApp { } } + /// Frame the model at once (programmatic use; the toolbar animates). pub fn fit_view(&mut self) { + self.cam_anim = None; + self.cam = self.fitted(self.cam); + } + + /// `cam` moved to frame the whole model. + pub fn fitted(&self, mut cam: Camera) -> Camera { let b = solvecraft_engine::view::bounds(&self.session); if b.is_empty() { - self.cam.target = solvecraft_engine::geom::Vec3::ZERO; - self.cam.distance = 150.0; + cam.target = solvecraft_engine::geom::Vec3::ZERO; + cam.distance = 150.0; } else { - self.cam.fit(&b); + cam.fit(&b); + } + cam + } + + /// Move the camera to `to` over half a second (ease in-out, orientation slerped). + pub fn animate_to(&mut self, to: Camera) { + if !to.is_valid() { + return; + } + self.cam_anim = Some(CameraAnim::new(self.cam, to, self.now)); + } + + /// Animated view changes for the view cube and navigation bar. + pub fn animate_view(&mut self, v: &str) { + let mut to = self.cam; + match v { + "fit" => to = self.fitted(to), + "home" => { + to.set_view(StandardView::Iso); + to = self.fitted(to); + } + v => match StandardView::parse(v) { + Some(sv) => to.set_view(sv), + None => return, + }, + } + self.animate_to(to); + } + + /// Stop any view animation where it is (the user took over the camera). + pub fn cancel_view_animation(&mut self) { + self.cam_anim = None; + } + + fn step_view_animation(&mut self, ctx: &egui::Context) { + let Some(anim) = self.cam_anim else { return }; + let (cam, done) = anim.sample(self.now); + self.cam = if cam.is_valid() { cam } else { anim.to }; + if done { + self.cam_anim = None; + } else { + ctx.request_repaint(); } } @@ -195,6 +250,8 @@ impl SolveApp { self.fit_view(); self.fitted = true; } + self.now = ctx.input(|i| i.time); + self.step_view_animation(ctx); self.drain_control(ctx); if !self.synthetic.is_empty() { ctx.request_repaint(); diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index e18b1f83..a9ecb397 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -379,7 +379,9 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { i.pointer.delta(), ) }); - let inside = hover.is_some_and(|p| rect.contains(p)); + // The view cube handles its own clicks; the model underneath must not see them. + let cube = Rect::from_center_size(pos2(rect.right() - 80.0, rect.top() + 80.0), vec2(150.0, 150.0)); + let inside = hover.is_some_and(|p| rect.contains(p) && !cube.contains(p)); app.viewport.mouse = hover.filter(|_| inside); // ---- navigation ---- @@ -393,6 +395,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { let den = d.dot(n); if den.abs() > 1e-9 { o + d * ((app.cam.target - o).dot(n) / den) } else { app.cam.target } }); + app.cancel_view_animation(); app.cam.zoom_at(f, anchor); } let dragging = resp.dragged() || (inside && (middle || secondary_down)); @@ -407,6 +410,9 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { } else { None }; + if mode.is_some() { + app.cancel_view_animation(); + } match mode { Some(NavMode::Orbit) => app.cam.orbit(dx, dy), Some(NavMode::Pan) => app.cam.pan(dx, dy, h), @@ -707,12 +713,13 @@ fn view_cube(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { (Vec3::new(0.0, 0.0, -1.0), "BOTTOM", StandardView::Bottom), ]; let painter = ui.painter_at(rect); - let mut clicked: Option = None; + // Clicked view direction (target → eye): a face, or an edge/corner zone of a face. + let mut clicked: Option = None; let hover = ui.input(|i| i.pointer.hover_pos()); let mut order: Vec = (0..6).collect(); order.sort_by(|a, b2| faces[*a].0.dot(b).total_cmp(&faces[*b2].0.dot(b))); for i in order { - let (n, label, v) = faces[i]; + let (n, label, _) = faces[i]; if n.dot(b) <= 1e-3 { continue; } @@ -727,14 +734,31 @@ fn view_cube(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { let pts: Vec = corners.iter().map(|p| to2(*p).0).collect(); let poly_hover = hover.is_some_and(|h| point_in_poly(h, &pts)); let shade = (0.75 + 0.25 * n.dot(b)) as f32; - let fill = if poly_hover { t.accent_soft } else { Color32::from_gray((236.0 * shade) as u8) }; - painter.add(Shape::convex_polygon(pts.clone(), fill, Stroke::new(1.0, Color32::from_rgb(140, 148, 160)))); + painter.add(Shape::convex_polygon( + pts.clone(), + Color32::from_gray((236.0 * shade) as u8), + Stroke::new(1.0, Color32::from_rgb(140, 148, 160)), + )); + // Hover zone: the outer band of a face picks the edge or corner it borders. + let zone = hover.filter(|_| poly_hover).and_then(|h| cube_zone(&pts, h)); + if let Some((zi, zj)) = zone { + let lo = |k: i32| match k { + -1 => (-1.0, -1.0 + 2.0 * CUBE_BAND), + 0 => (-1.0 + 2.0 * CUBE_BAND, 1.0 - 2.0 * CUBE_BAND), + _ => (1.0 - 2.0 * CUBE_BAND, 1.0), + }; + let ((a0, a1), (b0, b1)) = (lo(zi), lo(zj)); + let zp: Vec = [(a0, b0), (a1, b0), (a1, b1), (a0, b1)].iter().map(|(x, y)| to2(n + e1 * *x + e2 * *y).0).collect(); + painter.add(Shape::convex_polygon(zp, t.accent_soft, Stroke::NONE)); + } let center = to2(n).0; if n.dot(b) > 0.35 { painter.text(center, Align2::CENTER_CENTER, label, FontId::proportional(9.5), t.text); } - if poly_hover && ui.input(|i| i.pointer.primary_clicked()) { - clicked = Some(v); + if let Some((zi, zj)) = zone + && ui.input(|i| i.pointer.primary_clicked()) + { + clicked = Some(n + e1 * zi as f64 + e2 * zj as f64); } } // Axis triad at the cube's corner. @@ -748,14 +772,45 @@ fn view_cube(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { let hr = ui.interact(home, ui.id().with("vc_home"), Sense::click()); icons::paint(&painter, home, "home", t.icon, if hr.hovered() { t.accent_soft } else { Color32::WHITE }, t.accent); if hr.on_hover_text("Home view").clicked() { - app.cam.set_view(StandardView::Iso); - app.fit_view(); + app.animate_view("home"); } - if let Some(v) = clicked { - app.cam.set_view(v); + if let Some(dir) = clicked { + let to = app.cam.looking_from(dir); + app.animate_to(to); } } +/// Width of the edge/corner band of a view cube face, as a fraction of the face. +const CUBE_BAND: f64 = 0.2; + +/// Which of the 3×3 zones of a projected cube face (corners in order −−, +−, ++, −+) the point +/// is over: (−1|0|1, −1|0|1). +pub(crate) fn cube_zone(pts: &[Pos2], p: Pos2) -> Option<(i32, i32)> { + let [c0, c1, _, c3] = pts else { return None }; + let (ux, uy) = ((c1.x - c0.x) as f64, (c1.y - c0.y) as f64); + let (vx, vy) = ((c3.x - c0.x) as f64, (c3.y - c0.y) as f64); + let det = ux * vy - uy * vx; + if det.abs() < 1e-9 { + return None; + } + let (px, py) = ((p.x - c0.x) as f64, (p.y - c0.y) as f64); + let a = (px * vy - py * vx) / det; + let b = (ux * py - uy * px) / det; + if !(-1e-6..=1.0 + 1e-6).contains(&a) || !(-1e-6..=1.0 + 1e-6).contains(&b) { + return None; + } + let k = |t: f64| { + if t < CUBE_BAND { + -1 + } else if t > 1.0 - CUBE_BAND { + 1 + } else { + 0 + } + }; + Some((k(a), k(b))) +} + fn point_in_poly(p: Pos2, poly: &[Pos2]) -> bool { let n = poly.len(); let mut inside = false; @@ -808,14 +863,27 @@ fn nav_bar(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { "orbit" => app.viewport.nav = toggle(NavMode::Orbit, app.viewport.nav), "pan" => app.viewport.nav = toggle(NavMode::Pan, app.viewport.nav), "zoom" => app.viewport.nav = toggle(NavMode::Zoom, app.viewport.nav), - "fit" => app.fit_view(), - "home" => { - app.cam.set_view(StandardView::Iso); - app.fit_view(); - } + "fit" => app.animate_view("fit"), + "home" => app.animate_view("home"), "perspective" => app.ui.perspective = !app.ui.perspective, _ => app.ui.show_grid = !app.ui.show_grid, } } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn view_cube_zones() { + // A square face 100 px wide, corners in order −−, +−, ++, −+ (screen y down). + let pts = [pos2(0.0, 100.0), pos2(100.0, 100.0), pos2(100.0, 0.0), pos2(0.0, 0.0)]; + assert_eq!(cube_zone(&pts, pos2(50.0, 50.0)), Some((0, 0))); + assert_eq!(cube_zone(&pts, pos2(5.0, 95.0)), Some((-1, -1))); + assert_eq!(cube_zone(&pts, pos2(95.0, 50.0)), Some((1, 0))); + assert_eq!(cube_zone(&pts, pos2(50.0, 5.0)), Some((0, 1))); + assert_eq!(cube_zone(&pts, pos2(150.0, 50.0)), None); + } +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 78c7e026..0d875b6b 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -23,7 +23,7 @@ Commands never open dialogs when run this way. `solvecraft-cli commands` prints |---|---|---| | `ui.inspect` | — | UI state, viewport rect, camera, active tool/dialog, hover, renderer, timings | | `ui.set` | any `UiState` field (`tab`, `perspective`, `showGrid`, `hiddenBodies`, …) | | -| `ui.view` | `view`: `front`, `back`, `top`, `bottom`, `left`, `right`, `iso`, `home`, `fit` | | +| `ui.view` | `view`: `front`, `back`, `top`, `bottom`, `left`, `right`, `iso`, `home`, `fit`; `animate` (bool, default false: snap) | | | `ui.start` | `command` | like a toolbar click: starts the sketch tool or dialog for the command | | `ui.click` | `x`, `y` (screen points), `button?`: left/right/middle, `shift?` | real pointer input | | `ui.move` | `x`, `y` | |