From d5b8aa0375f787dc8fa818670c5e5ee776ec16f7 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 09:07:52 +0000 Subject: [PATCH] User scenarios in the real UI, headless: scenario::Harness draws the app frame by frame into an egui context and drives it with control-channel input (toolbar starts, clicks on world/sketch/origin-plane/axis points, keys, typing, drags) and checks the model; tests/scenarios/*.json: box with fillets, shell and hole; revolve on XZ; sketch on a face and cut; mirror; delete and undo. Control: ui.at, ui.dialogInput. Fixes found by them: a shape started on an existing point (the origin, a corner) is tied to it; the Dimension tool's value is edited in place with the value selected, so typing replaces it Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- crates/ui-egui/src/control.rs | 39 +++ crates/ui-egui/src/dim_view.rs | 27 +- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/scenario.rs | 269 ++++++++++++++++++ crates/ui-egui/src/tools.rs | 38 ++- crates/ui-egui/tests/scenarios.rs | 25 ++ .../scenarios/s01_box_fillet_hole_shell.json | 206 ++++++++++++++ .../tests/scenarios/s02_revolve_on_xz.json | 18 ++ .../scenarios/s03_sketch_on_face_cut.json | 30 ++ .../tests/scenarios/s04_mirror_body.json | 22 ++ .../tests/scenarios/s05_delete_and_undo.json | 131 +++++++++ docs/control-protocol.md | 2 + 12 files changed, 802 insertions(+), 6 deletions(-) create mode 100644 crates/ui-egui/src/scenario.rs create mode 100644 crates/ui-egui/tests/scenarios.rs create mode 100644 crates/ui-egui/tests/scenarios/s01_box_fillet_hole_shell.json create mode 100644 crates/ui-egui/tests/scenarios/s02_revolve_on_xz.json create mode 100644 crates/ui-egui/tests/scenarios/s03_sketch_on_face_cut.json create mode 100644 crates/ui-egui/tests/scenarios/s04_mirror_body.json create mode 100644 crates/ui-egui/tests/scenarios/s05_delete_and_undo.json diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 731adf5b..65c66b60 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -232,6 +232,45 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> crate::sketch3d::handle_points(app, &proj).into_iter().map(|(n, p)| json!({"handle": n, "x": p.x, "y": p.y})).collect::>() )) } + "ui.dialogInput" => { + // What clicking a selection input's chip does: make it the one picks go to. + let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize; + match app.dialog.as_mut() { + Some(d) if i < d.inputs.len() => { + d.active = i; + ok(json!({"label": d.inputs.get(i).map(|x| x.label)})) + } + _ => err("no dialog input with that index"), + } + } + "ui.at" => { + // A scenario point: {world: [x,y,z]} | {sketch: [x,y]} | {plane: "XY"} (the middle of an + // origin plane's square) → screen point. + let Some(rect) = app.viewport.rect else { return err("no viewport") }; + let proj = crate::viewport::projection(app, rect); + let num = |v: &Value, i: usize| v.get(i).and_then(Value::as_f64).unwrap_or(0.0); + let world = if let Some(w) = p.get("world") { + Some(solvecraft_engine::geom::Vec3::new(num(w, 0), num(w, 1), num(w, 2))) + } else if let Some(s) = p.get("sketch") { + let st = app.session.model.state(); + app.session + .active_sketch + .and_then(|id| st.sketch(id)) + .map(|ss| ss.plane.to_world(solvecraft_engine::geom::Vec2::new(num(s, 0), num(s, 1)))) + } else if let Some(n) = p.get("plane").and_then(Value::as_str) { + crate::viewport::origin_planes(app).into_iter().find(|(name, _, _)| *name == n).map(|(_, _, q)| (q[0] + q[1] + q[2] + q[3]) / 4.0) + } else if let Some(n) = p.get("axis").and_then(Value::as_str) { + // Along an origin axis, on its solid part. + let size = crate::selection::origin_size(app.cam.half_height()); + crate::viewport::origin_axes(app).into_iter().find(|(name, _)| *name == n).map(|(_, d)| d * (size * 0.8)) + } else { + None + }; + match world.and_then(|w| proj.to_screen(w)) { + Some(q) => ok(json!([q.x, q.y])), + None => err(format!("not on screen: {}", p)), + } + } "ui.worldToScreen" => { let Some(rect) = app.viewport.rect else { return err("no viewport") }; let proj = crate::viewport::projection(app, rect); diff --git a/crates/ui-egui/src/dim_view.rs b/crates/ui-egui/src/dim_view.rs index 3761d3fa..24f1fbcf 100644 --- a/crates/ui-egui/src/dim_view.rs +++ b/crates/ui-egui/src/dim_view.rs @@ -37,10 +37,13 @@ struct Drawn { struct Edit { param: String, expr: String, - at: Pos2, + /// Where it sits (None until the dimension has been drawn). + at: Option, /// Frames left in which the box (re)takes the keyboard: the rest of the double-click /// that opened it must not close it. fresh: u8, + /// Frames left to wait for the dimension to be drawn. + wait: u8, } thread_local! { @@ -103,7 +106,7 @@ pub fn double_click(app: &mut SolveApp, pos: Pos2) -> bool { return true; }; let expr = app.session.doc.param(¶m).map(|p| p.expr.clone()).unwrap_or_default(); - EDIT.with(|e| *e.borrow_mut() = Some(Edit { param, expr, at: d.text.center(), fresh: FRESH })); + EDIT.with(|e| *e.borrow_mut() = Some(Edit { param, expr, at: Some(d.text.center()), fresh: FRESH, wait: 0 })); true } @@ -256,11 +259,29 @@ fn pos2_mid_top(r: Rect) -> Pos2 { } /// The inline value editor: Enter applies, Esc (or a click elsewhere) cancels. +/// Edit a dimension's value in place by its parameter (a new dimension from the Dimension tool: +/// its value is selected, so typing replaces it). +pub fn edit_param(app: &SolveApp, param: &str) { + let expr = app.session.doc.param(param).map(|p| p.expr.clone()).unwrap_or_default(); + EDIT.with(|e| *e.borrow_mut() = Some(Edit { param: param.to_string(), expr, at: None, fresh: FRESH, wait: 10 })); +} + fn edit_box(app: &mut SolveApp, ctx: &egui::Context) { let Some(mut e) = EDIT.with(|x| x.borrow_mut().take()) else { return }; + if e.at.is_none() { + e.at = DRAWN.with(|d| d.borrow().iter().find(|x| x.param.as_deref() == Some(e.param.as_str())).map(|x| x.text.center())); + } + let Some(at) = e.at else { + // Not drawn yet (this frame's solve): try again next frame, for a few frames. + if e.wait > 0 { + e.wait -= 1; + EDIT.with(|x| *x.borrow_mut() = Some(e)); + } + return; + }; let id = egui::Id::new("sc_dim_edit"); let mut done: Option = None; - egui::Area::new(egui::Id::new("sc_dim_edit_area")).fixed_pos(e.at - vec2(45.0, 11.0)).order(egui::Order::Foreground).show(ctx, |ui| { + egui::Area::new(egui::Id::new("sc_dim_edit_area")).fixed_pos(at - vec2(45.0, 11.0)).order(egui::Order::Foreground).show(ctx, |ui| { egui::Frame::popup(ui.style()).inner_margin(egui::Margin::symmetric(3, 1)).show(ui, |ui| { let r = ui.add(egui::TextEdit::singleline(&mut e.expr).id(id).desired_width(90.0)); crate::params_dialog::complete(ui, &r, &mut e.expr); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index eaed3213..abe5cb97 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -31,6 +31,7 @@ pub mod params_dialog; mod preselect_tests; pub mod preview; pub mod ref_images; +pub mod scenario; pub mod selection; pub mod sketch3d; pub mod sketch_dims; diff --git a/crates/ui-egui/src/scenario.rs b/crates/ui-egui/src/scenario.rs new file mode 100644 index 00000000..2a2deee5 --- /dev/null +++ b/crates/ui-egui/src/scenario.rs @@ -0,0 +1,269 @@ +//! User scenarios run headless: the real app (`SolveApp`) drawn frame by frame into an egui +//! context without a window, driven by the same requests as the control channel (clicks, keys, +//! drags, toolbar starts) the way a person would use it, with checks on the result. +//! +//! A scenario is a JSON list of steps: +//! - `{"start": "Extrude"}`: a toolbar click; `{"key": "E", "shift"?, "cmd"?}`; `{"text": "20"}` +//! - `{"click": AT, "double"?, "shift"?, "ctrl"?, "button"?}`, `{"move": AT}`, +//! `{"drag": [AT, AT], "shift"?, "steps"?}` where AT is `[x, y]` (screen), `{"world": [x,y,z]}` +//! or `{"sketch": [x,y]}` (the sketch being edited) +//! - `{"call": "ui.view", "params": {…}}`: any other control request (`fail: true` expects an +//! error) +//! - `{"shot": "name"}`: a screenshot when run in a window (ignored headless); `{"debug": 1}` +//! prints the UI state +//! - AT may also be `{"plane": "XY"}`: the middle of an origin plane's square +//! - `{"expect": {…}}`: checks, see [`check`] +//! +//! The same files drive the live app over the control channel for screenshots. + +use serde_json::{Value, json}; +use solvecraft_engine::Session; + +use crate::control::{ControlRequest, Outcome, handle}; +use crate::{Services, SolveApp}; + +/// The app in a headless egui context. +pub struct Harness { + pub app: SolveApp, + pub ctx: egui::Context, + time: f64, + size: egui::Vec2, +} + +impl Default for Harness { + fn default() -> Self { + Harness::new() + } +} + +impl Harness { + pub fn new() -> Harness { + let mut h = Harness { + app: SolveApp::new(Session::default(), Services::default()), + ctx: egui::Context::default(), + time: 0.0, + size: egui::vec2(1600.0, 1000.0), + }; + h.frames(3); + h + } + + /// Lay out and draw one frame (feeding queued pointer and key events). + pub fn frame(&mut self) { + self.time += 1.0 / 60.0; + let mut raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, self.size)), + time: Some(self.time), + focused: true, + ..Default::default() + }; + self.app.raw_input_hook(&mut raw); + let app = &mut self.app; + let mut out = self.ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + // Nothing is painted: drop the frame's texture changes. + out.textures_delta.clear(); + } + + pub fn frames(&mut self, n: usize) { + for _ in 0..n { + self.frame(); + } + } + + /// Frames until the queued input is used up (and the view has settled). + pub fn settle(&mut self) { + for _ in 0..2000 { + // Queued input used up and the view no longer moving. + if self.app.synthetic.is_empty() && self.app.cam_anim.is_none() { + break; + } + self.frame(); + } + self.frames(4); + } + + /// One control request, then frames until its input has been handled. + 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) { + Outcome::Done(v) => v, + Outcome::Screenshot { .. } => json!({"ok": true, "result": null}), + }; + self.settle(); + out + } + + /// A step's point on screen. + pub fn at(&mut self, v: &Value) -> Result<[f64; 2], String> { + if v.is_array() { + let x = v.get(0).and_then(Value::as_f64).ok_or_else(|| format!("not a point: {v}"))?; + let y = v.get(1).and_then(Value::as_f64).ok_or_else(|| format!("not a point: {v}"))?; + return Ok([x, y]); + } + let r = self.call("ui.at", v.clone()); + let p = &r["result"]; + match (p.get(0).and_then(Value::as_f64), p.get(1).and_then(Value::as_f64)) { + (Some(x), Some(y)) => Ok([x, y]), + _ => Err(format!("{v} is not on screen: {r}")), + } + } + + /// Run one step. + pub fn step(&mut self, s: &Value) -> Result<(), String> { + let flag = |k: &str| s.get(k).and_then(Value::as_bool).unwrap_or(false); + let res = if let Some(id) = s.get("start").and_then(Value::as_str) { + self.call("ui.start", json!({ "command": id })) + } else if let Some(k) = s.get("key").and_then(Value::as_str) { + self.call("ui.key", json!({"key": k, "shift": flag("shift"), "cmd": flag("cmd")})) + } else if let Some(t) = s.get("text").and_then(Value::as_str) { + self.call("ui.text", json!({ "text": t })) + } else if let Some(at) = s.get("click") { + let [x, y] = self.at(at)?; + // Hover first, as a hand does. + self.call("ui.move", json!({"x": x, "y": y})); + let mut p = json!({"x": x, "y": y, "double": flag("double"), "shift": flag("shift"), "ctrl": flag("ctrl")}); + if let Some(b) = s.get("button") { + p["button"] = b.clone(); + } + self.call("ui.click", p) + } else if let Some(at) = s.get("move") { + let [x, y] = self.at(at)?; + self.call("ui.move", json!({"x": x, "y": y})) + } else if let Some(d) = s.get("drag") { + let [x0, y0] = self.at(d.get(0).unwrap_or(&Value::Null))?; + 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")})) + } 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("shot").is_some() { + return Ok(()); + } else if s.get("debug").is_some() { + let ui = self.call("ui.inspect", json!({})); + let sel = self.call("ui.selection", json!({})); + eprintln!( + "debug: dialog {} tool {} hover {}\nselection {}\nstatus {:?}", + ui["result"]["dialog"], ui["result"]["tool"], ui["result"]["hover"], sel["result"]["selection"], self.app.status + ); + return Ok(()); + } else if let Some(e) = s.get("expect") { + return check(self, e); + } else { + return Err(format!("unknown step {s}")); + }; + let failed = res["ok"] != json!(true); + if failed != flag("fail") { + return Err(format!("{s}: {res}")); + } + Ok(()) + } + + /// Run a scenario (its steps); the error names the failing step. + pub fn run(&mut self, steps: &Value) -> Result<(), String> { + for (i, s) in steps.as_array().ok_or("a scenario is a list of steps")?.iter().enumerate() { + self.step(s).map_err(|e| format!("step {i}: {e}"))?; + } + Ok(()) + } +} + +fn approx(got: f64, want: &Value, what: &str) -> Result<(), String> { + let (w, tol) = match want { + Value::Array(a) => (a.first().and_then(Value::as_f64).unwrap_or(f64::NAN), a.get(1).and_then(Value::as_f64).unwrap_or(1e-3)), + v => (v.as_f64().unwrap_or(f64::NAN), 1e-3), + }; + if (got - w).abs() <= tol * w.abs().max(1.0) { Ok(()) } else { Err(format!("{what}: got {got}, want {w} (±{tol} rel)")) } +} + +/// Checks: `bodies` (count), `volume` / `area` (total, number or [value, rel tol]), `faces`, +/// `features` (count), `errors` (timeline features in error, default 0), `dialog` (null: none +/// open, or a word its kind must contain), `tool` (null or command id), `sketching` (bool), +/// `selection` (count), `param` ({name: value}), `sketch_status` (solved|…), `dof`. +pub fn check(h: &mut Harness, e: &Value) -> Result<(), String> { + let doc = h.call("document.inspect", json!({"measure": true}))["result"].clone(); + let ui = h.call("ui.inspect", json!({}))["result"].clone(); + let sum = |k: &str| doc["bodies"].as_array().into_iter().flatten().filter_map(|b| b[k].as_f64()).sum::(); + let total = json!({"volume_mm3": sum("volume_mm3"), "area_mm2": sum("area_mm2"), "faces": sum("faces") as u64}); + let total = &total; + let bodies = doc["bodies"].as_array().map_or(0, Vec::len); + let obj = e.as_object().ok_or("expect takes an object")?; + for (k, v) in obj { + match k.as_str() { + "bodies" => { + if v.as_u64() != Some(bodies as u64) { + return Err(format!("bodies: got {bodies}, want {v}")); + } + } + "volume" => approx(total["volume_mm3"].as_f64().unwrap_or(f64::NAN), v, "volume")?, + "area" => approx(total["area_mm2"].as_f64().unwrap_or(f64::NAN), v, "area")?, + "faces" => { + if total["faces"] != *v { + return Err(format!("faces: got {}, want {v}", total["faces"])); + } + } + "features" => { + let n = doc["timeline"].as_array().map_or(0, Vec::len); + if v.as_u64() != Some(n as u64) { + return Err(format!("features: got {n}, want {v}")); + } + } + "errors" => { + let bad: Vec = doc["timeline"] + .as_array() + .into_iter() + .flatten() + .filter(|f| !f["error"].is_null()) + .map(|f| format!("{}: {}", f["name"], f["error"])) + .collect(); + if v.as_u64() != Some(bad.len() as u64) { + return Err(format!("errors: {bad:?}")); + } + } + "dialog" => { + let d = &ui["dialog"]; + let ok = match v { + Value::Null => d.is_null(), + Value::String(w) => d.as_str().is_some_and(|d| d.contains(w.as_str())), + _ => false, + }; + if !ok { + return Err(format!("dialog: got {d}, want {v}")); + } + } + "tool" => { + if ui["tool"] != *v { + return Err(format!("tool: got {}, want {v}", ui["tool"])); + } + } + "sketching" => { + if doc["active_sketch"].is_null() == v.as_bool().unwrap_or(false) { + return Err(format!("sketching: want {v}, active sketch {}", doc["active_sketch"])); + } + } + "selection" => { + let n = doc["selection"].as_array().map_or(0, Vec::len); + if v.as_u64() != Some(n as u64) { + return Err(format!("selection: got {n}, want {v}")); + } + } + "param" => { + for (name, want) in v.as_object().into_iter().flatten() { + let p = doc["params"].as_array().into_iter().flatten().find(|p| p["name"] == json!(name)).cloned().unwrap_or_default(); + approx(p["value"].as_f64().unwrap_or(f64::NAN), want, &format!("param {name}"))?; + } + } + "sketch_status" | "dof" => { + let si = h.call("engine.execute", json!({"command": "sketch.inspect", "params": {}}))["result"].clone(); + let key = if k == "dof" { "dof" } else { "status" }; + if si[key] != *v { + return Err(format!("{k}: got {}, want {v}", si[key])); + } + } + other => return Err(format!("unknown check `{other}`")), + } + } + Ok(()) +} diff --git a/crates/ui-egui/src/tools.rs b/crates/ui-egui/src/tools.rs index e706b0ae..4be487dc 100644 --- a/crates/ui-egui/src/tools.rs +++ b/crates/ui-egui/src/tools.rs @@ -163,7 +163,8 @@ pub fn on_click(app: &mut SolveApp, proj: &Proj, pos: Pos2) { if let Ok(v) = app.run("SketchDimension", json!({"entities": tool.picks})) && let Some(p) = v["param"].as_str() { - app.dialog = Some(crate::dialogs::Dialog::edit_param(&app.session, p)); + // The value is edited in place, selected so typing replaces it. + crate::dim_view::edit_param(app, p); } tool.picks.clear(); } @@ -237,10 +238,41 @@ fn run_shape(app: &mut SolveApp, t: &Tool) -> Vec { "DrawPoint" => json!({"point": xy(&p[0])}), _ => return Vec::new(), }; - app.run(t.cmd, params) + let curves: Vec = app + .run(t.cmd, params) .ok() .map(|v| v["curves"].as_array().into_iter().flatten().filter_map(|c| c.as_str().map(str::to_string)).collect()) - .unwrap_or_default() + .unwrap_or_default(); + tie_snaps(app, t, &curves); + curves +} + +/// A click that snapped to an existing point (the origin, a corner) but went in as plain +/// coordinates: tie the new shape's point there to it, as Fusion does. +fn tie_snaps(app: &mut SolveApp, t: &Tool, curves: &[String]) { + let mut ties = Vec::new(); + { + let st = app.session.model.state(); + let Some(ss) = app.session.active_sketch.and_then(|s| st.sketch(s)) else { return }; + let sk = &ss.sketch; + let new_pts: Vec = + curves.iter().filter_map(|c| sk.curve_index(c)).flat_map(|i| sk.curves.get(i).map(|c| c.kind.point_ids()).unwrap_or_default()).collect(); + for (at, r) in &t.pts { + let Some(r) = r else { continue }; + let Some(target) = sk.resolve_point(r) else { continue }; + if new_pts.contains(&target) { + continue; + } + if let Some(q) = new_pts.iter().copied().find(|q| sk.point(*q).is_some_and(|p| p.dist(*at) < 1e-6)) + && let Some(qid) = sk.points.get(q).map(|p| p.id.clone()) + { + ties.push((r.clone(), qid)); + } + } + } + for (a, b) in ties { + let _ = app.run("ConstraintCoincident", json!({"a": a, "b": b})); + } } fn run_pick(app: &mut SolveApp, t: &Tool) { diff --git a/crates/ui-egui/tests/scenarios.rs b/crates/ui-egui/tests/scenarios.rs new file mode 100644 index 00000000..88d1d4ff --- /dev/null +++ b/crates/ui-egui/tests/scenarios.rs @@ -0,0 +1,25 @@ +//! User scenarios in the real UI, headless (see `solvecraft_ui_egui::scenario`). Each JSON file +//! under `tests/scenarios/` is one workflow done with clicks, keys and drags. + +use solvecraft_ui_egui::scenario::Harness; + +fn run(name: &str) { + let path = format!("{}/tests/scenarios/{name}.json", env!("CARGO_MANIFEST_DIR")); + let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{path}: {e}")); + let steps: serde_json::Value = serde_json::from_str(&text).unwrap_or_else(|e| panic!("{path}: {e}")); + let mut h = Harness::new(); + if let Err(e) = h.run(&steps) { + panic!("{name}: {e}"); + } +} + +macro_rules! scenarios { + ($($name:ident),* $(,)?) => { + $(#[test] + fn $name() { + run(stringify!($name)); + })* + }; +} + +scenarios!(s01_box_fillet_hole_shell, s02_revolve_on_xz, s03_sketch_on_face_cut, s04_mirror_body, s05_delete_and_undo); diff --git a/crates/ui-egui/tests/scenarios/s01_box_fillet_hole_shell.json b/crates/ui-egui/tests/scenarios/s01_box_fillet_hole_shell.json new file mode 100644 index 00000000..0a9e5e29 --- /dev/null +++ b/crates/ui-egui/tests/scenarios/s01_box_fillet_hole_shell.json @@ -0,0 +1,206 @@ +[ + { + "start": "SketchCreate" + }, + { + "click": { + "plane": "XY" + } + }, + { + "expect": { + "sketching": true + } + }, + { + "start": "ShapeRectangleTwoPoint" + }, + { + "click": { + "sketch": [ + 0, + 0 + ] + } + }, + { + "click": { + "sketch": [ + 40, + 30 + ] + } + }, + { + "key": "Escape" + }, + { + "expect": { + "dof": 2 + } + }, + { + "start": "SketchDimension" + }, + { + "click": { + "sketch": [ + 20, + 0 + ] + } + }, + { + "text": "50" + }, + { + "key": "Enter" + }, + { + "key": "Escape" + }, + { + "expect": { + "dialog": null, + "param": { + "d1": 50 + } + } + }, + { + "shot": "s01_sketch" + }, + { + "call": "ui.view", + "params": { + "view": "iso" + } + }, + { + "start": "SketchStop" + }, + { + "expect": { + "sketching": false + } + }, + { + "start": "Extrude" + }, + { + "text": "20" + }, + { + "key": "Enter" + }, + { + "expect": { + "bodies": 1, + "volume": 30000, + "dialog": null + } + }, + { + "shot": "s01_extrude" + }, + { + "start": "FusionFilletEdgesCommand" + }, + { + "click": { + "world": [ + 50, + 15, + 20 + ] + } + }, + { + "click": { + "world": [ + 0, + 15, + 20 + ] + } + }, + { + "text": "3" + }, + { + "key": "Enter" + }, + { + "expect": { + "bodies": 1, + "errors": 0, + "dialog": null, + "volume": [ + 29884.1, + 1e-05 + ] + } + }, + { + "shot": "s01_fillet" + }, + { + "start": "FusionShellBodyCommand" + }, + { + "click": { + "world": [ + 25, + 0, + 10 + ] + } + }, + { + "text": "2" + }, + { + "key": "Enter" + }, + { + "expect": { + "bodies": 1, + "errors": 0, + "dialog": null + } + }, + { + "shot": "s01_shell" + }, + { + "start": "FusionHoleCommand" + }, + { + "click": { + "world": [ + 25, + 15, + 20 + ] + } + }, + { + "text": "8" + }, + { + "key": "Enter" + }, + { + "expect": { + "bodies": 1, + "errors": 0, + "dialog": null + } + }, + { + "debug": 1 + }, + { + "shot": "s01_hole" + } +] \ No newline at end of file diff --git a/crates/ui-egui/tests/scenarios/s02_revolve_on_xz.json b/crates/ui-egui/tests/scenarios/s02_revolve_on_xz.json new file mode 100644 index 00000000..381e3823 --- /dev/null +++ b/crates/ui-egui/tests/scenarios/s02_revolve_on_xz.json @@ -0,0 +1,18 @@ +[ + {"start": "SketchCreate"}, + {"click": {"plane": "XZ"}}, + {"expect": {"sketching": true}}, + {"start": "ShapeRectangleTwoPoint"}, + {"click": {"sketch": [10, 0]}}, + {"click": {"sketch": [20, 30]}}, + {"key": "Escape"}, + {"shot": "s02_sketch"}, + {"start": "SketchStop"}, + {"call": "ui.view", "params": {"view": "iso"}}, + {"start": "Revolve"}, + {"click": {"axis": "Z"}}, + {"debug": 1}, + {"key": "Enter"}, + {"expect": {"bodies": 1, "errors": 0, "dialog": null, "volume": [28274.33, 1e-3]}}, + {"shot": "s02_revolve"} +] diff --git a/crates/ui-egui/tests/scenarios/s03_sketch_on_face_cut.json b/crates/ui-egui/tests/scenarios/s03_sketch_on_face_cut.json new file mode 100644 index 00000000..0cf29ee0 --- /dev/null +++ b/crates/ui-egui/tests/scenarios/s03_sketch_on_face_cut.json @@ -0,0 +1,30 @@ +[ + {"start": "SketchCreate"}, + {"click": {"plane": "XY"}}, + {"start": "ShapeRectangleTwoPoint"}, + {"click": {"sketch": [0, 0]}}, + {"click": {"sketch": [40, 30]}}, + {"key": "Escape"}, + {"start": "SketchStop"}, + {"call": "ui.view", "params": {"view": "iso"}}, + {"start": "Extrude"}, + {"text": "20"}, + {"key": "Enter"}, + {"expect": {"bodies": 1, "volume": 24000}}, + {"start": "SketchCreate"}, + {"click": {"world": [20, 15, 20]}}, + {"expect": {"sketching": true}}, + {"start": "CircleCenterRadius"}, + {"click": {"sketch": [20, 15]}}, + {"click": {"sketch": [25, 15]}}, + {"key": "Escape"}, + {"shot": "s03_sketch_on_face"}, + {"start": "SketchStop"}, + {"start": "Extrude"}, + {"click": {"world": [20, 15, 20]}}, + {"debug": 1}, + {"text": "-10"}, + {"key": "Enter"}, + {"expect": {"bodies": 1, "errors": 0, "dialog": null, "volume": [23214.6, 1e-4]}}, + {"shot": "s03_cut"} +] diff --git a/crates/ui-egui/tests/scenarios/s04_mirror_body.json b/crates/ui-egui/tests/scenarios/s04_mirror_body.json new file mode 100644 index 00000000..d47533cf --- /dev/null +++ b/crates/ui-egui/tests/scenarios/s04_mirror_body.json @@ -0,0 +1,22 @@ +[ + {"start": "SketchCreate"}, + {"click": {"plane": "XY"}}, + {"start": "CircleCenterRadius"}, + {"click": {"sketch": [20, 10]}}, + {"click": {"sketch": [25, 10]}}, + {"key": "Escape"}, + {"start": "SketchStop"}, + {"call": "ui.view", "params": {"view": "iso"}}, + {"start": "Extrude"}, + {"text": "10"}, + {"key": "Enter"}, + {"expect": {"bodies": 1}}, + {"start": "MirrorCommand"}, + {"click": {"world": [20, 10, 10]}}, + {"call": "ui.dialogInput", "params": {"index": 1}}, + {"click": {"plane": "YZ"}}, + {"debug": 1}, + {"key": "Enter"}, + {"expect": {"bodies": 2, "errors": 0, "dialog": null, "volume": [1570.8, 1e-3]}}, + {"shot": "s04_mirror"} +] diff --git a/crates/ui-egui/tests/scenarios/s05_delete_and_undo.json b/crates/ui-egui/tests/scenarios/s05_delete_and_undo.json new file mode 100644 index 00000000..dc4a0f6e --- /dev/null +++ b/crates/ui-egui/tests/scenarios/s05_delete_and_undo.json @@ -0,0 +1,131 @@ +[ + { + "start": "SketchCreate" + }, + { + "click": { + "plane": "XY" + } + }, + { + "start": "ShapeRectangleTwoPoint" + }, + { + "click": { + "sketch": [ + 0, + 0 + ] + } + }, + { + "click": { + "sketch": [ + 30, + 20 + ] + } + }, + { + "key": "Escape" + }, + { + "start": "SketchStop" + }, + { + "call": "ui.view", + "params": { + "view": "iso" + } + }, + { + "start": "Extrude" + }, + { + "text": "10" + }, + { + "key": "Enter" + }, + { + "expect": { + "bodies": 1, + "volume": 6000 + } + }, + { + "click": { + "world": [ + 15, + 10, + 10 + ] + } + }, + { + "expect": { + "selection": 1 + } + }, + { + "expect": { + "selection": 1 + } + }, + { + "call": "ui.menu", + "params": { + "x": 100, + "y": 260, + "target": { + "type": "body", + "name": "Body1" + } + } + }, + { + "call": "ui.menuPick", + "params": { + "item": "Delete" + } + }, + { + "debug": 1 + }, + { + "expect": { + "bodies": 0 + } + }, + { + "key": "Z", + "cmd": true + }, + { + "expect": { + "bodies": 1, + "volume": 6000 + } + }, + { + "key": "Y", + "cmd": true + }, + { + "expect": { + "bodies": 0 + } + }, + { + "key": "Z", + "cmd": true + }, + { + "expect": { + "bodies": 1 + } + }, + { + "shot": "s05_undone" + } +] \ No newline at end of file diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 0935ce7a..2dc6e6a0 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -35,6 +35,8 @@ Commands never open dialogs when run this way. `solvecraft-cli commands` prints | `ui.worldToScreen` | `points`: `[[x, y, z]…]` world points | their screen points | | `ui.triad` | | screen points of the 3D sketch move triad's handles (`x`, `y`, `z`, `plane`) | | `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"` | the screen point (a scenario step's target; see `crates/ui-egui/src/scenario.rs`) | | `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?` | |