diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 65c66b60..63502fd3 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -246,6 +246,12 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> "ui.at" => { // A scenario point: {world: [x,y,z]} | {sketch: [x,y]} | {plane: "XY"} (the middle of an // origin plane's square) → screen point. + if let Some(d) = p.get("dimension").and_then(Value::as_str) { + return match crate::dim_view::text_at(d) { + Some(q) => ok(json!([q.x, q.y])), + None => err(format!("dimension {d} is not shown")), + }; + } 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); diff --git a/crates/ui-egui/src/dim_view.rs b/crates/ui-egui/src/dim_view.rs index 24f1fbcf..b5af5935 100644 --- a/crates/ui-egui/src/dim_view.rs +++ b/crates/ui-egui/src/dim_view.rs @@ -65,6 +65,11 @@ fn seg_dist(p: Pos2, a: Pos2, b: Pos2) -> f32 { p.distance(a + ab * t) } +/// The dimension whose value text is under a screen position (its id). +pub fn hit_text(pos: Pos2) -> Option { + DRAWN.with(|d| d.borrow().iter().find(|x| x.text.expand(2.0).contains(pos)).map(|x| x.id.clone())) +} + /// The dimension under a screen position (its id). pub fn hit(pos: Pos2) -> Option { DRAWN.with(|d| { @@ -88,6 +93,12 @@ pub fn selected(app: &SolveApp) -> Vec { .collect() } +/// Where the dimension driven by parameter `param` (or with constraint id `param`) shows its +/// value (screen), as drawn last frame. +pub fn text_at(param: &str) -> Option { + DRAWN.with(|d| d.borrow().iter().find(|x| x.param.as_deref() == Some(param) || x.id == param).map(|x| x.text.center())) +} + /// Dimension text boxes drawn last frame (for box selection). pub fn text_rects() -> Vec<(String, Rect)> { DRAWN.with(|d| d.borrow().iter().map(|x| (x.id.clone(), x.text)).collect()) diff --git a/crates/ui-egui/src/scenario.rs b/crates/ui-egui/src/scenario.rs index 2a2deee5..b2a6609c 100644 --- a/crates/ui-egui/src/scenario.rs +++ b/crates/ui-egui/src/scenario.rs @@ -4,28 +4,52 @@ //! //! A scenario is a JSON list of steps: //! - `{"start": "Extrude"}`: a toolbar click; `{"key": "E", "shift"?, "cmd"?}`; `{"text": "20"}` +//! - `{"widget": "Revolute"}`: click a dialog field, choice or button by its text //! - `{"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}` +//! - `{"choose_file": "part.step"}`: the next Open picker returns that file of the scenario's +//! folder (Save pickers save there) +//! - `{"shot": "name"}`: a screenshot when run in a window; headless, a render of the model +//! into `$SOLVECRAFT_SCENARIO_SHOTS` when that is set; `{"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 std::cell::RefCell; +use std::path::PathBuf; +use std::rc::Rc; + use serde_json::{Value, json}; use solvecraft_engine::Session; use crate::control::{ControlRequest, Outcome, handle}; use crate::{Services, SolveApp}; +/// A fresh folder for one scenario's files. +fn scratch_dir() -> PathBuf { + use std::sync::atomic::{AtomicUsize, Ordering}; + static N: AtomicUsize = AtomicUsize::new(0); + let d = std::env::temp_dir().join(format!("solvecraft-scenario-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed))); + let _ = std::fs::create_dir_all(&d); + d +} + /// The app in a headless egui context. pub struct Harness { pub app: SolveApp, pub ctx: egui::Context, + /// The last frame's platform output (with its accessibility tree: dialog widgets are found + /// by their text there). + pub output: egui::PlatformOutput, + /// Files the scenario saves and opens live here; file pickers answer from it. + pub dir: PathBuf, + /// The file the next Open picker returns (a `choose_file` step). + next_file: Rc>>, time: f64, size: egui::Vec2, } @@ -41,9 +65,19 @@ impl Harness { let mut h = Harness { app: SolveApp::new(Session::default(), Services::default()), ctx: egui::Context::default(), + output: Default::default(), + dir: scratch_dir(), + next_file: Rc::new(RefCell::new(None)), time: 0.0, size: egui::vec2(1600.0, 1000.0), }; + // File pickers answer like a person would: save into the scenario's folder, open the + // file the scenario chose. + let dir = h.dir.clone(); + h.app.services.pick_save = Some(Box::new(move |name, _| Some(dir.join(name).to_string_lossy().into_owned()))); + let next = h.next_file.clone(); + h.app.services.pick_open = Some(Box::new(move || next.borrow_mut().take())); + h.ctx.enable_accesskit(); h.frames(3); h } @@ -65,6 +99,7 @@ impl Harness { }); // Nothing is painted: drop the frame's texture changes. out.textures_delta.clear(); + self.output = out.platform_output; } pub fn frames(&mut self, n: usize) { @@ -96,6 +131,22 @@ impl Harness { out } + /// The centre of the visible widget whose label or value is `text` (the last one drawn wins: + /// popups are drawn after what opened them). + pub fn widget(&self, text: &str) -> Option<[f64; 2]> { + let tree = self.output.accesskit_update.as_ref()?; + tree.nodes + .iter() + .rev() + // A menu button's label ends with its shortcut ("Save Ctrl+S"). + .filter(|(_, n)| { + n.label().is_some_and(|l| l == text || l.strip_prefix(text).is_some_and(|r| r.starts_with(' '))) || n.value() == Some(text) + }) + .filter_map(|(_, n)| n.bounds()) + .find(|b| b.x1 > b.x0 && b.y1 > b.y0) + .map(|b| [(b.x0 + b.x1) / 2.0, (b.y0 + b.y1) / 2.0]) + } + /// A step's point on screen. pub fn at(&mut self, v: &Value) -> Result<[f64; 2], String> { if v.is_array() { @@ -129,6 +180,11 @@ impl Harness { p["button"] = b.clone(); } self.call("ui.click", p) + } else if let Some(text) = s.get("widget").and_then(Value::as_str) { + // A dialog field, choice or button, found by its text. + let [x, y] = self.widget(text).ok_or_else(|| format!("no widget `{text}` on screen"))?; + self.call("ui.move", json!({"x": x, "y": y})); + self.call("ui.click", json!({"x": x, "y": y})) } else if let Some(at) = s.get("move") { let [x, y] = self.at(at)?; self.call("ui.move", json!({"x": x, "y": y})) @@ -139,7 +195,19 @@ impl Harness { 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() { + } else if let Some(name) = s.get("shot").and_then(Value::as_str) { + // With SOLVECRAFT_SCENARIO_SHOTS set: a render of the model as the camera sees it. + if let Ok(dir) = std::env::var("SOLVECRAFT_SCENARIO_SHOTS") { + let path = std::path::Path::new(&dir).join(format!("{name}.png")); + self.call("ui.render", json!({"path": path.to_string_lossy(), "width": 1000, "height": 700})); + } + return Ok(()); + } else if let Some(f) = s.get("choose_file").and_then(Value::as_str) { + *self.next_file.borrow_mut() = Some(self.dir.join(f).to_string_lossy().into_owned()); + return Ok(()); + } else if let Some(m) = s.get("print").and_then(Value::as_str) { + let r = self.call(m, s.get("params").cloned().unwrap_or(json!({}))); + eprintln!("print {m}: {r}"); return Ok(()); } else if s.get("debug").is_some() { let ui = self.call("ui.inspect", json!({})); @@ -148,6 +216,16 @@ impl Harness { "debug: dialog {} tool {} hover {}\nselection {}\nstatus {:?}", ui["result"]["dialog"], ui["result"]["tool"], ui["result"]["hover"], sel["result"]["selection"], self.app.status ); + if s["debug"] == json!("widgets") { + let texts: Vec = self + .output + .accesskit_update + .iter() + .flat_map(|t| t.nodes.iter()) + .filter_map(|(_, n)| n.label().or(n.value()).map(|l| format!("{:?} {l}", n.role()))) + .collect(); + eprintln!("widgets: {texts:?}"); + } return Ok(()); } else if let Some(e) = s.get("expect") { return check(self, e); @@ -181,7 +259,8 @@ fn approx(got: f64, want: &Value, what: &str) -> Result<(), String> { /// 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`. +/// `selection` (count), `param` ({name: value}), `sketch_status` (solved|…), `dof`, `query` +/// ({command, params?, path, value}: a command's result). 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(); @@ -262,6 +341,34 @@ pub fn check(h: &mut Harness, e: &Value) -> Result<(), String> { return Err(format!("{k}: got {}, want {v}", si[key])); } } + "file" => { + // A file the scenario saved: it exists and is not empty. + let p = h.dir.join(v.as_str().unwrap_or_default()); + if std::fs::metadata(&p).map_or(0, |m| m.len()) == 0 { + return Err(format!("no file {}", p.display())); + } + } + "query" => { + // {command, params?, path: [keys or indices], value}: a command's result at path. + let cmd = v["command"].as_str().ok_or("query needs `command`")?; + let r = h.call("engine.execute", json!({"command": cmd, "params": v.get("params").cloned().unwrap_or(json!({}))})); + let mut at = &r["result"]; + for k in v["path"].as_array().into_iter().flatten() { + at = match k { + Value::Number(n) => &at[n.as_u64().unwrap_or(0) as usize], + Value::String(s) => &at[s.as_str()], + _ => &Value::Null, + }; + } + let want = &v["value"]; + let ok = match (at.as_f64(), want.as_f64()) { + (Some(a), Some(w)) => (a - w).abs() <= 1e-6 * w.abs().max(1.0), + _ => at == want, + }; + if !ok { + return Err(format!("query {cmd} {}: got {at}, want {want}", v["path"])); + } + } other => return Err(format!("unknown check `{other}`")), } } diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 58755767..a7f9d5d2 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -1170,7 +1170,12 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { // Dimensions and constraint glyphs sit on top of the geometry. let off = |k: &str| app.ui.pick_off.iter().any(|x| x == k); let sketching = app.session.active_sketch.is_some(); - let on_dim = crate::dim_view::hit(p).filter(|_| sketching && !off("dimensions")); + // A dimension's text wins; its lines only where no sketch point or curve is (a + // diameter's line runs through the centre point). + let on_geometry = matches!(cand.as_ref().map(|c| &c.0), Some(Hit::SketchPoint { .. } | Hit::SketchCurve { .. })); + let on_dim = crate::dim_view::hit_text(p) + .or_else(|| if on_geometry { None } else { crate::dim_view::hit(p) }) + .filter(|_| sketching && !off("dimensions")); let on_glyph = crate::sketch_tools::glyph_at(p).filter(|_| sketching && !off("constraints")); if !crate::sketch3d::click(app, &proj, p) { match on_dim.or(on_glyph) { diff --git a/crates/ui-egui/tests/scenarios.rs b/crates/ui-egui/tests/scenarios.rs index 88d1d4ff..18b90c65 100644 --- a/crates/ui-egui/tests/scenarios.rs +++ b/crates/ui-egui/tests/scenarios.rs @@ -22,4 +22,17 @@ macro_rules! scenarios { }; } -scenarios!(s01_box_fillet_hole_shell, s02_revolve_on_xz, s03_sketch_on_face_cut, s04_mirror_body, s05_delete_and_undo); +scenarios!( + s01_box_fillet_hole_shell, + s02_revolve_on_xz, + s03_sketch_on_face_cut, + s04_mirror_body, + s05_delete_and_undo, + s06_edit_early_feature, + s07_components_joint_drive, + s08_sheet_metal_flat_dxf, + s09_step_roundtrip_hole, + s10_parameters_drive_model, + s11_sketch_constraints, + s12_pattern_bodies +); diff --git a/crates/ui-egui/tests/scenarios/s06_edit_early_feature.json b/crates/ui-egui/tests/scenarios/s06_edit_early_feature.json new file mode 100644 index 00000000..4c5d8016 --- /dev/null +++ b/crates/ui-egui/tests/scenarios/s06_edit_early_feature.json @@ -0,0 +1,145 @@ +[ + { + "start": "SketchCreate" + }, + { + "click": { + "plane": "XY" + } + }, + { + "start": "ShapeRectangleTwoPoint" + }, + { + "click": { + "sketch": [ + 0, + 0 + ] + } + }, + { + "click": { + "sketch": [ + 40, + 30 + ] + } + }, + { + "key": "Escape" + }, + { + "start": "SketchDimension" + }, + { + "click": { + "sketch": [ + 20, + 0 + ] + } + }, + { + "text": "40" + }, + { + "key": "Enter" + }, + { + "key": "Escape" + }, + { + "start": "SketchStop" + }, + { + "call": "ui.view", + "params": { + "view": "iso" + } + }, + { + "start": "Extrude" + }, + { + "text": "10" + }, + { + "key": "Enter" + }, + { + "start": "FusionFilletEdgesCommand" + }, + { + "click": { + "world": [ + 40, + 15, + 10 + ] + } + }, + { + "text": "2" + }, + { + "key": "Enter" + }, + { + "expect": { + "bodies": 1, + "errors": 0, + "features": 3, + "volume": [ + 11974.25, + 1e-05 + ] + } + }, + { + "call": "ui.editFeature", + "params": { + "feature": "Sketch1" + } + }, + { + "expect": { + "sketching": true + } + }, + { + "click": { + "dimension": "d1" + }, + "double": true + }, + { + "text": "60" + }, + { + "key": "Enter" + }, + { + "expect": { + "param": { + "d1": 60 + } + } + }, + { + "shot": "s06_edit_sketch" + }, + { + "start": "SketchStop" + }, + { + "expect": { + "sketching": false, + "errors": 0, + "bodies": 1 + } + }, + { + "shot": "s06_updated" + } +] \ No newline at end of file diff --git a/crates/ui-egui/tests/scenarios/s07_components_joint_drive.json b/crates/ui-egui/tests/scenarios/s07_components_joint_drive.json new file mode 100644 index 00000000..1d7e8e13 --- /dev/null +++ b/crates/ui-egui/tests/scenarios/s07_components_joint_drive.json @@ -0,0 +1,224 @@ +[ + { + "start": "FusionCreateNewComponentCommand" + }, + { + "key": "Enter" + }, + { + "start": "SketchCreate" + }, + { + "click": { + "plane": "XY" + } + }, + { + "start": "ShapeRectangleTwoPoint" + }, + { + "click": { + "sketch": [ + 0, + 0 + ] + } + }, + { + "click": { + "sketch": [ + 20, + 20 + ] + } + }, + { + "key": "Escape" + }, + { + "start": "SketchStop" + }, + { + "call": "ui.view", + "params": { + "view": "iso" + } + }, + { + "start": "Extrude" + }, + { + "text": "10" + }, + { + "key": "Enter" + }, + { + "call": "ui.menu", + "params": { + "x": 100, + "y": 170, + "target": { + "type": "component", + "id": 0 + } + } + }, + { + "call": "ui.menuPick", + "params": { + "item": "Activate" + } + }, + { + "start": "FusionCreateNewComponentCommand" + }, + { + "key": "Enter" + }, + { + "start": "SketchCreate" + }, + { + "click": { + "plane": "XZ" + } + }, + { + "start": "ShapeRectangleTwoPoint" + }, + { + "click": { + "sketch": [ + 30, + 0 + ] + } + }, + { + "click": { + "sketch": [ + 50, + 10 + ] + } + }, + { + "key": "Escape" + }, + { + "start": "SketchStop" + }, + { + "call": "ui.view", + "params": { + "view": "iso" + } + }, + { + "start": "Extrude" + }, + { + "click": { + "world": [ + 40, + 0, + 5 + ] + } + }, + { + "text": "20" + }, + { + "key": "Enter" + }, + { + "expect": { + "bodies": 2, + "errors": 0 + } + }, + { + "start": "JointAssembleCmdNew" + }, + { + "click": { + "world": [ + 10, + 10, + 10 + ] + } + }, + { + "click": { + "world": [ + 40, + 0, + 5 + ] + } + }, + { + "widget": "Rigid" + }, + { + "widget": "Revolute" + }, + { + "shot": "s07_joint" + }, + { + "widget": "OK" + }, + { + "expect": { + "dialog": null, + "errors": 0, + "query": { + "command": "joint.list", + "path": [ + "joints", + 0, + "type" + ], + "value": "revolute" + } + } + }, + { + "start": "FusionMoveJointsCommand" + }, + { + "widget": "0 deg" + }, + { + "key": "A", + "cmd": true + }, + { + "text": "90" + }, + { + "key": "Enter" + }, + { + "expect": { + "query": { + "command": "joint.list", + "path": [ + "joints", + 0, + "values", + 0, + "value" + ], + "value": 90 + } + } + }, + { + "shot": "s07_driven" + } +] \ No newline at end of file diff --git a/crates/ui-egui/tests/scenarios/s08_sheet_metal_flat_dxf.json b/crates/ui-egui/tests/scenarios/s08_sheet_metal_flat_dxf.json new file mode 100644 index 00000000..066682aa --- /dev/null +++ b/crates/ui-egui/tests/scenarios/s08_sheet_metal_flat_dxf.json @@ -0,0 +1,116 @@ +[ + { + "call": "ui.set", + "params": { + "tab": "SHEET METAL" + } + }, + { + "start": "SketchCreate" + }, + { + "click": { + "plane": "XY" + } + }, + { + "start": "ShapeRectangleTwoPoint" + }, + { + "click": { + "sketch": [ + 0, + 0 + ] + } + }, + { + "click": { + "sketch": [ + 60, + 40 + ] + } + }, + { + "key": "Escape" + }, + { + "start": "SketchStop" + }, + { + "call": "ui.view", + "params": { + "view": "iso" + } + }, + { + "start": "FusionSheetMetalFlangeCommand" + }, + { + "click": { + "world": [ + 30, + 20, + 0 + ] + } + }, + { + "key": "Enter" + }, + { + "expect": { + "bodies": 1, + "errors": 0, + "dialog": null + } + }, + { + "expect": { + "volume": 6000 + } + }, + { + "shot": "s08_base" + }, + { + "start": "FusionSheetMetalFlangeCommand" + }, + { + "click": { + "world": [ + 30, + 0, + 2.5 + ] + } + }, + { + "key": "Enter" + }, + { + "expect": { + "bodies": 1, + "errors": 0, + "dialog": null + } + }, + { + "shot": "s08_flange" + }, + { + "start": "FusionSheetMetalFlatPatternCmd" + }, + { + "widget": "Export DXF\u2026" + }, + { + "expect": { + "file": "Untitled-Body1.dxf" + } + }, + { + "shot": "s08_flat" + } +] \ No newline at end of file diff --git a/crates/ui-egui/tests/scenarios/s09_step_roundtrip_hole.json b/crates/ui-egui/tests/scenarios/s09_step_roundtrip_hole.json new file mode 100644 index 00000000..4b50366a --- /dev/null +++ b/crates/ui-egui/tests/scenarios/s09_step_roundtrip_hole.json @@ -0,0 +1,126 @@ +[ + { + "start": "SketchCreate" + }, + { + "click": { + "plane": "XY" + } + }, + { + "start": "ShapeRectangleTwoPoint" + }, + { + "click": { + "sketch": [ + 0, + 0 + ] + } + }, + { + "click": { + "sketch": [ + 40, + 30 + ] + } + }, + { + "key": "Escape" + }, + { + "start": "SketchStop" + }, + { + "start": "Extrude" + }, + { + "text": "15" + }, + { + "key": "Enter" + }, + { + "expect": { + "bodies": 1, + "volume": 18000 + } + }, + { + "click": [ + 127, + 16 + ] + }, + { + "widget": "Export STEP\u2026" + }, + { + "expect": { + "file": "Untitled.step" + } + }, + { + "key": "N", + "cmd": true + }, + { + "expect": { + "bodies": 0, + "features": 0 + } + }, + { + "choose_file": "Untitled.step" + }, + { + "click": [ + 127, + 16 + ] + }, + { + "widget": "Insert STEP\u2026" + }, + { + "debug": 1 + }, + { + "expect": { + "bodies": 1, + "volume": 18000 + } + }, + { + "call": "ui.view", + "params": { + "view": "iso" + } + }, + { + "start": "FusionHoleCommand" + }, + { + "click": { + "world": [ + 20, + 15, + 15 + ] + } + }, + { + "key": "Enter" + }, + { + "expect": { + "bodies": 1, + "errors": 0, + "dialog": null + } + }, + { + "shot": "s09_hole_on_import" + } +] \ No newline at end of file diff --git a/crates/ui-egui/tests/scenarios/s10_parameters_drive_model.json b/crates/ui-egui/tests/scenarios/s10_parameters_drive_model.json new file mode 100644 index 00000000..ba0ed57c --- /dev/null +++ b/crates/ui-egui/tests/scenarios/s10_parameters_drive_model.json @@ -0,0 +1,111 @@ +[ + { + "start": "SketchCreate" + }, + { + "click": { + "plane": "XY" + } + }, + { + "start": "ShapeRectangleTwoPoint" + }, + { + "click": { + "sketch": [ + 0, + 0 + ] + } + }, + { + "click": { + "sketch": [ + 40, + 30 + ] + } + }, + { + "key": "Escape" + }, + { + "start": "SketchDimension" + }, + { + "click": { + "sketch": [ + 20, + 0 + ] + } + }, + { + "text": "40" + }, + { + "key": "Enter" + }, + { + "key": "Escape" + }, + { + "start": "SketchStop" + }, + { + "start": "Extrude" + }, + { + "text": "10" + }, + { + "key": "Enter" + }, + { + "expect": { + "volume": 12000 + } + }, + { + "start": "ChangeParameterCommand" + }, + { + "shot": "s10_params" + }, + { + "widget": "40" + }, + { + "key": "A", + "cmd": true + }, + { + "text": "55" + }, + { + "key": "Enter" + }, + { + "debug": 1 + }, + { + "expect": { + "param": { + "d1": 55 + }, + "volume": 16500, + "errors": 0 + } + }, + { + "widget": "Close window" + }, + { + "expect": { + "dialog": null + } + }, + { + "shot": "s10_updated" + } +] \ No newline at end of file diff --git a/crates/ui-egui/tests/scenarios/s11_sketch_constraints.json b/crates/ui-egui/tests/scenarios/s11_sketch_constraints.json new file mode 100644 index 00000000..3ad933ec --- /dev/null +++ b/crates/ui-egui/tests/scenarios/s11_sketch_constraints.json @@ -0,0 +1,222 @@ +[ + { + "start": "SketchCreate" + }, + { + "click": { + "plane": "XY" + } + }, + { + "start": "DrawPolyline" + }, + { + "click": { + "sketch": [ + 0, + 0 + ] + } + }, + { + "click": { + "sketch": [ + 40, + 0 + ] + } + }, + { + "click": { + "sketch": [ + 40, + 25 + ] + } + }, + { + "click": { + "sketch": [ + 0, + 25 + ] + } + }, + { + "click": { + "sketch": [ + 0, + 0 + ] + } + }, + { + "key": "Escape" + }, + { + "key": "Escape" + }, + { + "expect": { + "dof": 2, + "sketch_status": "solved" + } + }, + { + "start": "CircleCenterRadius" + }, + { + "click": { + "sketch": [ + 20, + 12 + ] + } + }, + { + "click": { + "sketch": [ + 25, + 12 + ] + } + }, + { + "key": "Escape" + }, + { + "start": "SketchDimension" + }, + { + "click": { + "sketch": [ + 20, + 0 + ] + } + }, + { + "text": "40" + }, + { + "key": "Enter" + }, + { + "click": { + "sketch": [ + 40, + 12 + ] + } + }, + { + "text": "25" + }, + { + "key": "Enter" + }, + { + "click": { + "sketch": [ + 25, + 12 + ] + } + }, + { + "text": "10" + }, + { + "key": "Enter" + }, + { + "key": "Escape" + }, + { + "expect": { + "dof": 2 + } + }, + { + "click": { + "sketch": [ + 20, + 12 + ] + } + }, + { + "expect": { + "selection": 1 + } + }, + { + "key": "Delete", + "fail": false + }, + { + "expect": { + "dof": 0, + "selection": 0 + } + }, + { + "key": "Z", + "cmd": true + }, + { + "expect": { + "dof": 2 + } + }, + { + "start": "ConstraintFix" + }, + { + "click": { + "sketch": [ + 20, + 12 + ] + } + }, + { + "key": "Escape" + }, + { + "expect": { + "dof": 0 + } + }, + { + "shot": "s11_constrained" + }, + { + "click": { + "sketch": [ + 0, + 12 + ] + } + }, + { + "key": "X" + }, + { + "expect": { + "dof": 0 + } + }, + { + "start": "SketchStop" + }, + { + "expect": { + "sketching": false, + "errors": 0 + } + }, + { + "shot": "s11_done" + } +] \ No newline at end of file diff --git a/crates/ui-egui/tests/scenarios/s12_pattern_bodies.json b/crates/ui-egui/tests/scenarios/s12_pattern_bodies.json new file mode 100644 index 00000000..3ab3c5e5 --- /dev/null +++ b/crates/ui-egui/tests/scenarios/s12_pattern_bodies.json @@ -0,0 +1,23 @@ +[ + {"start": "SketchCreate"}, + {"click": {"plane": "XY"}}, + {"start": "CircleCenterRadius"}, + {"click": {"sketch": [10, 10]}}, + {"click": {"sketch": [14, 10]}}, + {"key": "Escape"}, + {"start": "SketchStop"}, + {"call": "ui.view", "params": {"view": "iso"}}, + {"start": "Extrude"}, + {"text": "8"}, + {"key": "Enter"}, + {"expect": {"bodies": 1}}, + {"start": "PatternRectangular"}, + {"click": {"world": [10, 10, 8]}}, + {"call": "ui.dialogInput", "params": {"index": 1}}, + {"click": {"axis": "X"}}, + {"debug": "widgets"}, + {"key": "Enter"}, + {"debug": 1}, + {"expect": {"errors": 0, "dialog": null, "volume": [1206.37, 1e-3]}}, + {"shot": "s12_pattern"} +] diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 2dc6e6a0..55c26f17 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -36,7 +36,7 @@ Commands never open dialogs when run this way. `solvecraft-cli commands` prints | `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.at` | `world: [x,y,z]` \| `sketch: [x,y]` \| `plane: "XY"` \| `axis: "Z"` \| `dimension: "d1"` (its value text) | 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?` | |