From 50a74ac66d69ce3feab209a77036818bc29e4e44 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Fri, 9 Oct 2026 18:21:47 +0000 Subject: [PATCH] Agent cursor: a violet pointer labelled Agent acts out control-channel and MCP commands. It glides to the command's toolbar button (or its panel) and clicks with a ripple, then to each pick (faces, edges and points projected to the screen, sketch points, a new sketch's plane, the profiles an extrude takes), then the command runs; the reply waits for it and later requests queue behind. Speed instant/normal/slow; follow camera turns to a new sketch and back on finish. View settings on the navigation bar (Show Agent Cursor, speed, follow camera; remembered, off by default); ui.agent_cursor {show, speed, follow_camera}; "animate": false per call. Tests: pick to screen mapping; scenario with the cursor on (replies wait for the animation) matching the cursor-off result Fixes #35 Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/agent_cursor.rs | 339 ++++++++++++++++++ crates/ui-egui/src/control.rs | 19 + crates/ui-egui/src/lib.rs | 17 +- crates/ui-egui/src/scenario.rs | 35 ++ crates/ui-egui/src/viewport.rs | 48 ++- .../ui-egui/tests/scenarios/agent_cursor.json | 17 + docs/control-protocol.md | 1 + 7 files changed, 472 insertions(+), 4 deletions(-) create mode 100644 crates/ui-egui/src/agent_cursor.rs create mode 100644 crates/ui-egui/tests/scenarios/agent_cursor.json diff --git a/crates/ui-egui/src/agent_cursor.rs b/crates/ui-egui/src/agent_cursor.rs new file mode 100644 index 00000000..d2d35b8e --- /dev/null +++ b/crates/ui-egui/src/agent_cursor.rs @@ -0,0 +1,339 @@ +//! The agent cursor (#35): a second, clearly marked pointer that acts out what an agent does +//! over the control channel or MCP. Before an `engine.execute` runs, the pointer glides to the +//! command's toolbar button (or its panel when the button sits in a drop-down) and clicks it, +//! then glides to each pick in the parameters (faces, edges and points projected to the screen, +//! sketch points, the plane of a new sketch), and only then does the command run. The reply +//! waits for the animation, so an agent's timing stays honest. With "follow camera" on, a new +//! sketch turns the view to face it and finishing the sketch turns back. +//! +//! The pointer is drawn by the app, so screen recordings capture it. It is off by default (View +//! settings on the navigation bar, or `ui.agent_cursor {show, speed, follow_camera}`); a call +//! with `"animate": false` skips it. + +use std::collections::VecDeque; +use std::sync::mpsc::Sender; + +use egui::{Color32, FontId, Pos2, Shape, Stroke, pos2, vec2}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use solvecraft_engine::geom::{Vec2, Vec3}; + +use crate::SolveApp; +use crate::control::ControlResponse; + +/// How fast the pointer moves. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Speed { + /// No animation: the command runs at once. + Instant, + #[default] + Normal, + Slow, +} + +impl Speed { + pub const ALL: [Speed; 3] = [Speed::Instant, Speed::Normal, Speed::Slow]; + pub fn label(self) -> &'static str { + match self { + Speed::Instant => "Instant", + Speed::Normal => "Normal", + Speed::Slow => "Slow", + } + } + /// Seconds to glide to a stop, and to click there. + fn times(self) -> (f64, f64) { + match self { + Speed::Instant => (0.0, 0.0), + Speed::Normal => (0.45, 0.25), + Speed::Slow => (0.9, 0.45), + } + } +} + +/// The settings (kept with the other view settings). +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct Settings { + pub show: bool, + pub speed: Speed, + pub follow_camera: bool, +} + +/// A request held back while the pointer acts it out. +pub struct Job { + method: String, + params: Value, + reply: Sender, + stops: VecDeque, + /// The leg under way: where it started and when. + from: Pos2, + started: f64, + /// Clicked at the current stop (waiting out the click). + clicked: bool, +} + +/// The pointer's state. +#[derive(Default)] +pub struct AgentCursor { + pub pos: Option, + job: Option, + /// Click ripples: where and when. + ripples: Vec<(Pos2, f64)>, + /// The view before a followed sketch (restored when it finishes). + saved_view: Option, +} + +impl AgentCursor { + /// A request is being acted out (later requests wait behind it). + pub fn busy(&self) -> bool { + self.job.is_some() + } +} + +/// Keys whose [x, y, z] values are directions or amounts, not places to point at. +const NOT_PLACES: [&str; 14] = + ["translate", "dir1", "dir2", "axis", "direction", "normal", "pull", "color", "x_dir", "y_dir", "scale", "values", "limits", "size"]; + +/// Where the pointer goes for a command: its toolbar button (or its panel), then each pick. +pub fn stops(app: &SolveApp, command: &str, params: &Value) -> Vec { + let mut out = Vec::new(); + if let Some(spec) = solvecraft_engine::find_command(command) { + let button = crate::scenario::handle_at(&format!("toolbar:{}", spec.id)) + .or_else(|| crate::scenario::handle_at(&format!("panel:{}:{}", spec.tab, spec.panel))); + out.extend(button); + } + let Some(rect) = app.viewport.rect else { return out }; + let proj = crate::viewport::projection(app, rect); + for w in picks(app, command, params) { + if let Some(p) = proj.to_screen(w).filter(|p| rect.contains(*p)) { + out.push(p); + } + } + out +} + +/// The places a command's parameters pick, in the world: [x, y, z] points (faces, edges, +/// vertices, positions), active-sketch [x, y] points, and a new sketch's plane. +pub fn picks(app: &SolveApp, command: &str, params: &Value) -> Vec { + let id = solvecraft_engine::find_command(command).map_or(command, |c| c.id); + let mut out = Vec::new(); + if id == "sketch.create" + && let Some(n) = params.get("plane").and_then(Value::as_str) + && let Some((_, _, q)) = crate::viewport::origin_planes(app).into_iter().find(|(name, _, _)| *name == n) + { + out.push((q[0] + q[1] + q[2] + q[3]) / 4.0); + } + let st = app.session.world_state(); + let sketch = app.session.active_sketch.and_then(|s| st.sketch(s)).map(|ss| ss.plane); + walk(params, None, &mut |key, v| { + let num = |i: usize| v.get(i).and_then(Value::as_f64).filter(|x| x.is_finite()); + let place = !key.is_some_and(|k| NOT_PLACES.contains(&k)); + match (v.as_array().map(Vec::len), num(0), num(1), num(2)) { + (Some(3), Some(x), Some(y), Some(z)) if place => out.push(Vec3::new(x, y, z)), + (Some(2), Some(x), Some(y), None) if place => { + if let Some(pl) = &sketch { + out.push(pl.to_world(Vec2::new(x, y))); + } + } + _ => {} + } + }); + // A profile feature without picks takes the profiles of the active (else the last) sketch: + // point at them. + if out.is_empty() && matches!(id, "solid.extrude" | "solid.revolve") { + let ss = app.session.active_sketch.and_then(|s| st.sketch(s)).or_else(|| st.sketches.iter().rev().find(|s| !s.profiles.is_empty())); + if let Some(ss) = ss { + out.extend(ss.profiles.iter().take(4).map(|p| ss.plane.to_world(p.region.interior_point()))); + } + } + out +} + +/// Visit every value with the key it sits under (array items inherit their array's key). +fn walk<'a>(v: &'a Value, key: Option<&'a str>, f: &mut dyn FnMut(Option<&'a str>, &'a Value)) { + match v { + Value::Object(m) => { + for (k, x) in m { + walk(x, Some(k.as_str()), f); + } + } + Value::Array(a) => { + // A point is a leaf; a list of points or other values is walked. + if a.len() >= 2 && a.len() <= 3 && a.iter().all(Value::is_number) { + f(key, v); + } else { + for x in a { + walk(x, key, f); + } + } + } + _ => {} + } +} + +/// Take over a control request when the pointer should act it out first: returns the request +/// back when it should simply run. +pub fn intercept(app: &mut SolveApp, method: &str, params: &Value, reply: &Sender) -> bool { + let s = app.ui.agent_cursor; + if !s.show || s.speed == Speed::Instant || !matches!(method, "engine.execute" | "command") { + return false; + } + if params.get("animate").and_then(Value::as_bool) == Some(false) { + return false; + } + let Some(cmd) = params.get("command").or(params.get("id")).and_then(Value::as_str) else { return false }; + let inner = params.get("params").cloned().unwrap_or(json!({})); + let stops: VecDeque = stops(app, cmd, &inner).into(); + if stops.is_empty() { + return false; + } + let from = app.cursor_start(); + app.agent.job = + Some(Job { method: method.to_string(), params: params.clone(), reply: reply.clone(), stops, from, started: app.now, clicked: false }); + true +} + +impl SolveApp { + /// Where the agent pointer starts: where it was, else the middle of the viewport. + fn cursor_start(&self) -> Pos2 { + self.agent.pos.or_else(|| self.viewport.rect.map(|r| r.center())).unwrap_or(pos2(600.0, 400.0)) + } +} + +fn ease(t: f64) -> f64 { + let t = t.clamp(0.0, 1.0); + t * t * (3.0 - 2.0 * t) +} + +/// Move the pointer along; when the last stop is clicked, run the held request and reply. +pub fn step(app: &mut SolveApp, ctx: &egui::Context) { + let now = app.now; + app.agent.ripples.retain(|(_, t)| now - t < 0.6); + if !app.agent.ripples.is_empty() { + ctx.request_repaint(); + } + let (glide, click) = app.ui.agent_cursor.speed.times(); + let Some(job) = app.agent.job.as_mut() else { return }; + ctx.request_repaint(); + let Some(&to) = job.stops.front() else { + finish(app, ctx); + return; + }; + let t = if glide > 0.0 { (now - job.started) / glide } else { 1.0 }; + if !job.clicked { + let e = ease(t) as f32; + app.agent.pos = Some(job.from + (to - job.from) * e); + if t >= 1.0 { + job.clicked = true; + job.started = now; + app.agent.ripples.push((to, now)); + } + return; + } + if now - job.started >= click { + job.stops.pop_front(); + job.from = to; + job.started = now; + job.clicked = false; + if job.stops.is_empty() { + finish(app, ctx); + } + } +} + +/// Run the held request now (as a call without animation) and send its reply. +fn finish(app: &mut SolveApp, ctx: &egui::Context) { + let Some(job) = app.agent.job.take() else { return }; + let mut params = job.params; + params["animate"] = json!(false); + let cmd = params.get("command").or(params.get("id")).and_then(Value::as_str).unwrap_or_default().to_string(); + let id = solvecraft_engine::find_command(&cmd).map_or(cmd.clone(), |c| c.id.to_string()); + let follow = app.ui.agent_cursor.follow_camera; + if follow && id == "sketch.create" { + app.agent.saved_view = Some(app.cam); + } + let (req, _rx) = crate::control::ControlRequest::new(job.method, params); + let out = match crate::control::handle(app, ctx, &req) { + crate::control::Outcome::Done(v) => v, + crate::control::Outcome::Screenshot { .. } => json!({"ok": false, "error": "not a command"}), + }; + let ran = out.get("ok").and_then(Value::as_bool).unwrap_or(false); + if follow && ran { + if id == "sketch.create" { + crate::dialogs::look_at_sketch(app); + } else if id == "sketch.finish" + && let Some(cam) = app.agent.saved_view.take() + { + app.animate_to(cam); + } + } + let _ = job.reply.send(out); +} + +/// The pointer's colour: a violet no part of the UI uses. +const AGENT: Color32 = Color32::from_rgb(168, 85, 247); + +/// Draw the pointer, its "Agent" tag and the click ripples over everything. +pub fn paint(app: &SolveApp, ctx: &egui::Context) { + if !app.ui.agent_cursor.show { + return; + } + let Some(p) = app.agent.pos else { return }; + let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Debug, egui::Id::new("sc_agent_cursor"))); + for (at, t0) in &app.agent.ripples { + let k = ((app.now - t0) / 0.6).clamp(0.0, 1.0) as f32; + painter.circle_stroke(*at, 6.0 + 22.0 * k, Stroke::new(3.0 * (1.0 - k) + 0.5, AGENT.gamma_multiply(1.0 - k))); + } + // An arrow pointer, tip at `p`. + let pts: Vec = + [(0.0, 0.0), (0.0, 17.0), (4.5, 13.0), (7.5, 20.0), (10.5, 18.8), (7.5, 12.0), (13.0, 12.0)].iter().map(|(x, y)| p + vec2(*x, *y)).collect(); + painter.add(Shape::convex_polygon(pts.clone(), AGENT, Stroke::NONE)); + painter.add(Shape::closed_line(pts, Stroke::new(1.5, Color32::WHITE))); + let label = painter.layout_no_wrap("Agent".into(), FontId::proportional(11.0), Color32::WHITE); + let r = egui::Rect::from_min_size(p + vec2(15.0, 18.0), label.size() + vec2(10.0, 4.0)); + painter.rect_filled(r, 4.0, AGENT); + painter.galley(r.min + vec2(5.0, 2.0), label, Color32::WHITE); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Services; + use solvecraft_engine::Session; + + fn app() -> SolveApp { + let mut s = Session::default(); + s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap(); + let mut a = SolveApp::new(s, Services::default()); + a.viewport.rect = Some(egui::Rect::from_min_size(pos2(250.0, 120.0), vec2(1100.0, 760.0))); + a.fit_view(); + a + } + + #[test] + fn picks_are_the_places_in_the_parameters() { + let a = app(); + // Points and lists of points, not directions or colours. + let p = json!({"edges": [[20, 0, 20], [40, 15, 20]], "position": [5, 5, 20], "translate": [1, 2, 3], "color": [255, 0, 0], "radius": 2}); + let w = picks(&a, "solid.fillet", &p); + assert_eq!(w.len(), 3, "{w:?}"); + assert!(w.contains(&Vec3::new(20.0, 0.0, 20.0)) && w.contains(&Vec3::new(5.0, 5.0, 20.0))); + // A new sketch's origin plane is a place too (the middle of its square). + assert_eq!(picks(&a, "sketch.create", &json!({"plane": "XY"})).len(), 1); + } + + #[test] + fn picks_land_where_the_viewport_draws_them() { + let a = app(); + let rect = a.viewport.rect.unwrap(); + let proj = crate::viewport::projection(&a, rect); + let at = Vec3::new(20.0, 15.0, 20.0); + let s = stops(&a, "solid.hole", &json!({"position": [20, 15, 20], "diameter": 5})); + let want = proj.to_screen(at).unwrap(); + assert!(s.iter().any(|p| p.distance(want) < 0.01), "{s:?} vs {want:?}"); + assert!(rect.contains(want)); + // Off-screen places are skipped rather than pointed at outside the view. + assert!(stops(&a, "solid.hole", &json!({"position": [1e6, 0, 0]})).iter().all(|p| rect.contains(*p))); + } +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 87351df7..d6023da9 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -27,6 +27,9 @@ //! - `ui.prefs {...}`: the preferences (default_units, nav, zoom_reverse, orbit_cursor, msaa, //! length_decimals, angle_decimals); returns them all //! - `ui.capture {action?: capture|revert|cancel}`: the Capture Position question +//! - `ui.agent_cursor {show?, speed?: instant|normal|slow, follow_camera?}`: the agent cursor +//! (#35); while it is shown, `engine.execute` first glides to the command's button and picks +//! and replies once the command has run (`"animate": false` skips that one call) //! - `ui.resize {width, height}`, `app.quit` use std::sync::mpsc::Sender; @@ -95,6 +98,22 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> } wrap(r) } + "ui.agent_cursor" => { + let s = &mut app.ui.agent_cursor; + if let Some(v) = p.get("show").and_then(Value::as_bool) { + s.show = v; + } + if let Some(v) = p.get("follow_camera").and_then(Value::as_bool) { + s.follow_camera = v; + } + if let Some(v) = p.get("speed") { + match serde_json::from_value(v.clone()) { + Ok(sp) => s.speed = sp, + Err(_) => return err("speed must be instant, normal or slow"), + } + } + ok(json!(app.ui.agent_cursor)) + } "engine.script" => wrap(app.session.run_script(p).map(|v| json!(v)).map_err(|e| e.to_string())), "engine.commands" => wrap(app.session.execute("engine.commands", &json!({})).map_err(|e| e.to_string())), "document.inspect" => wrap(app.session.execute("document.inspect", p).map_err(|e| e.to_string())), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9f27229d..bb0df76b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -9,6 +9,7 @@ #![forbid(unsafe_code)] pub mod access; +pub mod agent_cursor; pub mod browser; pub mod canvas; pub mod context_menu; @@ -104,6 +105,8 @@ pub struct UiState { pub shown_dims: Vec, /// Bodies locked in the browser (no move or delete). pub locked_bodies: Vec, + /// The agent cursor: shown, speed, follow camera (#35). + pub agent_cursor: agent_cursor::Settings, } impl Default for UiState { @@ -129,6 +132,7 @@ impl Default for UiState { hidden_profiles: Vec::new(), shown_dims: Vec::new(), locked_bodies: Vec::new(), + agent_cursor: Default::default(), } } } @@ -199,6 +203,8 @@ pub struct SolveApp { /// The modifiers of the synthetic press in progress (a Ctrl-drag keeps Ctrl while it moves). pub synthetic_mods: egui::Modifiers, control_rx: Option>, + /// The agent cursor acting out control requests. + pub agent: agent_cursor::AgentCursor, pending_shots: Vec<(u64, Option, std::sync::mpsc::Sender, f64)>, queued_shots: Vec<(u64, f64, u32)>, shot_token: u64, @@ -248,6 +254,7 @@ impl SolveApp { synthetic: Vec::new(), synthetic_mods: egui::Modifiers::default(), control_rx: None, + agent: Default::default(), pending_shots: Vec::new(), queued_shots: Vec::new(), shot_token: 0, @@ -653,6 +660,7 @@ impl SolveApp { self.now = ctx.input(|i| i.time); self.step_view_animation(ctx); self.drain_control(ctx); + agent_cursor::step(self, ctx); if !self.synthetic.is_empty() { ctx.request_repaint(); } @@ -732,13 +740,20 @@ impl SolveApp { shortcut_box::show(self, ui.ctx()); keymap::show(self, ui.ctx()); prefs::show(self, ui.ctx()); + agent_cursor::paint(self, ui.ctx()); self.frame_ms = now_ms() - t0; } fn drain_control(&mut self, ctx: &egui::Context) { let Some(rx) = self.control_rx.take() else { return }; - while let Ok(req) = rx.try_recv() { + // While the agent cursor acts out a request, later ones wait their turn. + while !self.agent.busy() { + let Ok(req) = rx.try_recv() else { break }; let reply = req.reply.clone(); + if agent_cursor::intercept(self, &req.method, &req.params, &reply) { + ctx.request_repaint(); + continue; + } match control::handle(self, ctx, &req) { control::Outcome::Done(v) => { let _ = reply.send(v); diff --git a/crates/ui-egui/src/scenario.rs b/crates/ui-egui/src/scenario.rs index 92b77ff6..3964f603 100644 --- a/crates/ui-egui/src/scenario.rs +++ b/crates/ui-egui/src/scenario.rs @@ -9,6 +9,9 @@ //! - `{"click": AT, "double"?, "shift"?, "ctrl"?, "button"?}`, `{"move": AT}`, //! `{"drag": [AT, AT], "shift"?, "ctrl"?, "steps"?}` where AT is `[x, y]` (screen), `{"world": [x,y,z]}` //! or `{"sketch": [x,y]}` (the sketch being edited) +//! - `{"queued": "engine.execute", "params": {…}, "min_frames"?: n}`: a request sent the way an +//! agent's arrive (through the app's request queue, answered between frames, so the agent +//! cursor can act it out first); fails if the answer came in fewer than `min_frames` frames //! - `{"call": "ui.view", "params": {…}}`: any other control request (`fail: true` expects an //! error) //! - `{"make_image": "pic.png"}`: a picture in the scenario's folder @@ -112,6 +115,8 @@ pub struct Harness { pub dir: PathBuf, /// The file the next Open picker returns (a `choose_file` step). next_file: Rc>>, + /// The app's request queue, opened by the first queued request. + queue: Option>, time: f64, size: egui::Vec2, } @@ -139,6 +144,7 @@ impl Harness { output: Default::default(), dir: scratch_dir(), next_file: Rc::new(RefCell::new(None)), + queue: None, time: 0.0, size: egui::vec2(1600.0, 1000.0), }; @@ -201,6 +207,28 @@ impl Harness { } /// One control request, then frames until its input has been handled. + /// Send a request through the app's queue, as the control channel does, and run frames + /// until it is answered: (the reply, frames it took). + pub fn call_queued(&mut self, method: &str, params: Value) -> (Value, usize) { + let tx = self.queue.get_or_insert_with(|| { + let (tx, rx) = std::sync::mpsc::channel(); + self.app.control_rx = Some(rx); + tx + }); + let (req, rx) = ControlRequest::new(method, params); + if tx.send(req).is_err() { + return (json!({"ok": false, "error": "the request queue is closed"}), 0); + } + for n in 1..=3000 { + self.frame(); + if let Ok(v) = rx.try_recv() { + self.settle(); + return (v, n); + } + } + (json!({"ok": false, "error": "no answer in 3000 frames"}), 3000) + } + pub fn call(&mut self, method: &str, params: Value) -> Value { let (req, _rx) = ControlRequest::new(method, params); let out = match handle(&mut self.app, &self.ctx, &req) { @@ -328,6 +356,13 @@ impl Harness { let [x1, y1] = self.at(d.get(1).unwrap_or(&Value::Null))?; let steps = s.get("steps").cloned().unwrap_or(json!(8)); self.call("ui.drag", json!({"x0": x0, "y0": y0, "x1": x1, "y1": y1, "steps": steps, "shift": flag("shift"), "ctrl": flag("ctrl")})) + } else if let Some(m) = s.get("queued").and_then(Value::as_str) { + let (v, frames) = self.call_queued(m, s.get("params").cloned().unwrap_or(json!({}))); + let min = s.get("min_frames").and_then(Value::as_u64).unwrap_or(0); + if (frames as u64) < min { + return Err(format!("{m} was answered after {frames} frames: the agent cursor did not act it out ({min})")); + } + v } else if let Some(m) = s.get("call").and_then(Value::as_str) { self.call(m, s.get("params").cloned().unwrap_or(json!({}))) } else if s.get("note").is_some() || s.get("pending").is_some() { diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index d90e176a..cc0ee5ff 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -1971,8 +1971,10 @@ fn nav_bar(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { ("home", "Home view"), ("perspective", "Perspective / orthographic"), ("style", "Visual style: shaded with edges, shaded, wireframe, shaded with hidden edges"), - ("settings", "Grid on/off"), + ("settings", "View settings: grid, ground shadow, agent cursor"), ]; + let menu_id = ui.id().with("nav_view_menu"); + let mut menu_open = ui.data(|d| d.get_temp::(menu_id)).unwrap_or(false); let w = items.len() as f32 * 30.0 + 10.0; let bar = Rect::from_center_size(pos2(rect.center().x, rect.bottom() - 22.0), vec2(w, 30.0)); let painter = ui.painter_at(rect); @@ -1985,7 +1987,7 @@ fn nav_bar(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { "pan" => app.viewport.nav == Some(NavMode::Pan), "zoom" => app.viewport.nav == Some(NavMode::Zoom), "perspective" => app.ui.perspective, - "settings" => app.ui.show_grid, + "settings" => menu_open, "spin" => app.viewport.spin, _ => false, }; @@ -2008,10 +2010,50 @@ fn nav_bar(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { "lookat" => look_at_selection(app), "style" => app.ui.visual_style = (app.ui.visual_style + 1) % 4, "perspective" => app.ui.perspective = !app.ui.perspective, - _ => app.ui.show_grid = !app.ui.show_grid, + _ => menu_open = !menu_open, + } + } + if *icon == "settings" { + crate::scenario::publish_handle("nav:view", br.center()); + if menu_open { + menu_open = view_menu(app, ui, br); } } } + ui.data_mut(|d| d.insert_temp(menu_id, menu_open)); +} + +/// The View settings above the navigation bar's last button: grid, ground shadow and the agent +/// cursor (#35). False once it should close (a click outside it). +fn view_menu(app: &mut SolveApp, ui: &mut egui::Ui, button: Rect) -> bool { + let area = egui::Area::new(egui::Id::new("sc_view_menu")) + .order(egui::Order::Foreground) + .pivot(Align2::RIGHT_BOTTOM) + .fixed_pos(button.right_top() - vec2(0.0, 6.0)); + let r = area.show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.set_width(220.0); + ui.label(egui::RichText::new("VIEW").strong().size(11.5)); + ui.checkbox(&mut app.ui.show_grid, "Grid"); + ui.checkbox(&mut app.ui.ground_shadow, "Ground shadow"); + ui.separator(); + let s = &mut app.ui.agent_cursor; + let show = ui.checkbox(&mut s.show, "Show Agent Cursor"); + crate::scenario::publish_handle("view:agent_cursor", show.rect.center()); + ui.add_enabled_ui(s.show, |ui| { + ui.horizontal(|ui| { + ui.label("Speed"); + for sp in crate::agent_cursor::Speed::ALL { + ui.selectable_value(&mut s.speed, sp, sp.label()); + } + }); + ui.checkbox(&mut s.follow_camera, "Follow camera (Look At sketches)"); + }); + }); + }); + let clicked_out = ui.input(|i| i.pointer.any_pressed()) + && ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| !r.response.rect.contains(p) && !button.contains(p)); + !clicked_out } #[cfg(test)] diff --git a/crates/ui-egui/tests/scenarios/agent_cursor.json b/crates/ui-egui/tests/scenarios/agent_cursor.json new file mode 100644 index 00000000..2d1380be --- /dev/null +++ b/crates/ui-egui/tests/scenarios/agent_cursor.json @@ -0,0 +1,17 @@ +[ + {"note": "#35: with the agent cursor on, an agent's commands are acted out (the reply waits for the pointer) and give the same result as with it off"}, + {"call": "ui.agent_cursor", "params": {"show": true, "speed": "normal", "follow_camera": true}}, + {"queued": "engine.execute", "params": {"command": "sketch.create", "params": {"plane": "XY"}}, "min_frames": 30}, + {"queued": "engine.execute", "params": {"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [40, 25]}}, "min_frames": 45}, + {"queued": "engine.execute", "params": {"command": "sketch.finish", "params": {}}, "min_frames": 30}, + {"queued": "engine.execute", "params": {"command": "solid.extrude", "params": {"distance": 10, "operation": "new"}}, "min_frames": 30}, + {"expect": {"errors": 0, "bodies": 1, "volume": [10000, 1e-6]}}, + {"note": "A call with animate: false runs at once"}, + {"queued": "engine.execute", "params": {"command": "sketch.create", "params": {"plane": "XY"}, "animate": false}}, + {"call": "ui.agent_cursor", "params": {"show": false}}, + {"note": "The same steps with the cursor off: a second, identical body"}, + {"queued": "engine.execute", "params": {"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [40, 25]}}}, + {"queued": "engine.execute", "params": {"command": "sketch.finish", "params": {}}}, + {"queued": "engine.execute", "params": {"command": "solid.extrude", "params": {"distance": 10, "operation": "new"}}}, + {"expect": {"errors": 0, "bodies": 2, "volume": [20000, 1e-6]}} +] diff --git a/docs/control-protocol.md b/docs/control-protocol.md index f9d18b6b..b81021eb 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -52,6 +52,7 @@ Commands never open dialogs when run this way. `solvecraft-cli commands` prints | `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`), `zoom_reverse`, `orbit_cursor`, `msaa`, `length_decimals`, `angle_decimals` | sets preferences (kept between runs), returns them all | | `ui.capture` | `action?`: `capture`, `revert`, `cancel` | answers the Capture Position question (asked when a command starts or the design is saved while components were moved without capturing); without `action`, what waits | | `ui.window` | `action`: minimize, maximize, restore, toggle, close | what the title bar's caption buttons do (on Windows and Linux the application bar is the title bar); close asks about unsaved designs first | +| `ui.agent_cursor` | `show` (bool), `speed`: `instant`, `normal`, `slow`, `follow_camera` (bool); all optional | the settings. While the cursor is shown, `engine.execute` first glides to the command's toolbar button and to each pick, then runs and replies; later requests wait their turn. `"animate": false` on a call skips it | | `ui.resize` | `width`, `height` | | | `app.quit` | `force?` (quit without asking about unsaved designs) | `quitting` (the window closes), `asking` (a "save changes?" prompt is open) |