From 59b59a83e6b45b84e2e5ebf3af5864dde9b78d63 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 13:00:01 +0000 Subject: [PATCH] =?UTF-8?q?UI=20scenarios:=20the=20user's=20four=20open=20?= =?UTF-8?q?bugs=20as=20ignored=20targets=20(s31=20sketch=20on=20a=20face?= =?UTF-8?q?=20projects=20its=20loops,=20s32=20a=20cut=20in=20the=20active?= =?UTF-8?q?=20component=20leaves=20others=20alone,=20s33=20Move=20opens=20?= =?UTF-8?q?at=200=20and=20its=20X=20arrow=20moves=20along=20X,=20s34=20mov?= =?UTF-8?q?ing=20a=20component=20carries=20its=20sketches);=20Fold,=20conf?= =?UTF-8?q?igurations,=20motion=20study=20and=20autosave/recovery=20enable?= =?UTF-8?q?d=20against=20the=20landed=20features=20(s22=E2=80=93s25);=20re?= =?UTF-8?q?tests:=20fillets=20follow=20their=20edge=20after=20a=20sketch?= =?UTF-8?q?=20edit=20(s06=20volume,=20s15=20widens=20the=20sample=20plate)?= =?UTF-8?q?,=20one=20canvas=20panel=20(s19);=20s30=20patterns=20a=20hole?= =?UTF-8?q?=20as=20a=20feature=20(ignored:=20one=20hole=20results).=20Harn?= =?UTF-8?q?ess:=20manipulator=20handles=20published=20by=20name=20(scenari?= =?UTF-8?q?o::publish=5Fhandle;=20ui.at=20{handle};=20timeline=20items=20a?= =?UTF-8?q?s=20timeline:),=20restart=20after=20a=20crash=20or=20clos?= =?UTF-8?q?e,=20autosave=20into=20the=20scenario's=20folder,=20exact=20wid?= =?UTF-8?q?get=20text=20before=20menu-label=20prefixes,=20checks=20for=20s?= =?UTF-8?q?ketch=20curves,=20body=20volume=20and=20sketch=20points=20in=20?= =?UTF-8?q?the=20world?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- crates/ui-egui/src/canvas.rs | 1 + crates/ui-egui/src/control.rs | 6 + crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/scenario.rs | 126 +++++++- crates/ui-egui/src/sketch3d.rs | 3 + crates/ui-egui/src/timeline.rs | 1 + crates/ui-egui/tests/scenarios.rs | 13 +- .../scenarios/s06_edit_early_feature.json | 6 +- .../s15_start_page_sample_save_reopen.json | 8 +- .../tests/scenarios/s23_configurations.json | 156 ++++++++-- .../tests/scenarios/s24_motion_study.json | 273 +++++++++++++++--- .../scenarios/s25_autosave_recovery.json | 100 ++++++- .../tests/scenarios/s30_pattern_a_hole.json | 142 +++++++++ .../s31_sketch_on_face_projects.json | 141 +++++++++ .../s32_cut_in_active_component.json | 242 ++++++++++++++++ .../tests/scenarios/s33_move_x_arrow.json | 22 ++ .../s34_component_move_carries_sketches.json | 202 +++++++++++++ docs/control-protocol.md | 2 +- 18 files changed, 1328 insertions(+), 117 deletions(-) create mode 100644 crates/ui-egui/tests/scenarios/s30_pattern_a_hole.json create mode 100644 crates/ui-egui/tests/scenarios/s31_sketch_on_face_projects.json create mode 100644 crates/ui-egui/tests/scenarios/s32_cut_in_active_component.json create mode 100644 crates/ui-egui/tests/scenarios/s33_move_x_arrow.json create mode 100644 crates/ui-egui/tests/scenarios/s34_component_move_carries_sketches.json diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8b4be8bc..a43802aa 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -254,6 +254,7 @@ fn drag_arrow( let min = 40.0 * 2.0 * half_height / f64::from(proj.rect.height().max(1.0)); let shown = if l.abs() < min { min * if l < 0.0 { -1.0 } else { 1.0 } } else { l }; let ts = proj.to_screen(base + n * shown)?; + crate::scenario::publish_handle(if id == egui::Id::new("sc_manipulator") { "arrow" } else { "arrow2" }, ts); let r = ui.interact(egui::Rect::from_center_size(ts, vec2(20.0, 20.0)), id, egui::Sense::drag()); let hot = r.hovered() || r.dragged(); let col = if hot { t.accent } else { t.manipulator }; diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index ed5805c6..406d8c2b 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -251,6 +251,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(h) = p.get("handle").and_then(Value::as_str) { + return match crate::scenario::handle_at(h) { + Some(q) => ok(json!([q.x, q.y])), + None => err(format!("handle {h} is not shown")), + }; + } 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])), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c0b57aea..b0b07f71 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -683,6 +683,7 @@ impl SolveApp { /// Lay out the whole window. pub fn ui(&mut self, ui: &mut egui::Ui) { let t0 = now_ms(); + scenario::clear_handles(); toolbar::app_bar(self, ui); documents::prompt(self, ui.ctx()); if self.home.open { diff --git a/crates/ui-egui/src/scenario.rs b/crates/ui-egui/src/scenario.rs index 4afef865..af2ba535 100644 --- a/crates/ui-egui/src/scenario.rs +++ b/crates/ui-egui/src/scenario.rs @@ -17,7 +17,10 @@ //! - `{"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 +//! - AT may also be `{"plane": "XY"}` (the middle of an origin plane's square), `{"axis": "Z"}`, +//! `{"dimension": "d1"}` or `{"handle": "arrow"}` (a manipulator handle: see [`publish_handle`]) +//! - `{"autosave": true}`: autosave into the scenario's recovery folder; `{"restart": "crash" | +//! "close"}`: the app dies (or closes) and a new one starts on the same folders //! - `{"note": "…"}`: a comment //! - `{"expect": {…}}`: checks, see [`check`]; `{"until": {…}}` waits (frames) until they pass //! @@ -33,6 +36,32 @@ use solvecraft_engine::Session; use crate::control::{ControlRequest, Outcome, handle}; use crate::{Services, SolveApp}; +thread_local! { + /// Drag handles drawn this frame (name, screen point): manipulators publish theirs so + /// scenarios (and the control channel's `ui.at {handle}`) can grab them. + static HANDLES: RefCell> = const { RefCell::new(Vec::new()) }; +} + +/// A manipulator's handle drawn this frame at `at` (screen), by name: "arrow" (a dialog's value +/// arrow), "triad_x"… Call it from the code that draws the handle, every frame it shows. +pub fn publish_handle(name: &str, at: egui::Pos2) { + HANDLES.with(|h| { + let mut h = h.borrow_mut(); + h.retain(|(n, _)| n != name); + h.push((name.to_string(), at)); + }); +} + +/// Where a handle was drawn last frame. +pub fn handle_at(name: &str) -> Option { + HANDLES.with(|h| h.borrow().iter().find(|(n, _)| n == name).map(|x| x.1)) +} + +/// Forget last frame's handles (called as a frame starts). +pub fn clear_handles() { + HANDLES.with(|h| h.borrow_mut().clear()); +} + /// A fresh folder for one scenario's files. fn scratch_dir() -> PathBuf { use std::sync::atomic::{AtomicUsize, Ordering}; @@ -83,17 +112,26 @@ impl Harness { 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.install(); h.ctx.enable_accesskit(); h.frames(3); h } + /// File pickers answer like a person would: save into the scenario's folder, open the file + /// the scenario chose. + fn install(&mut self) { + let dir = self.dir.clone(); + self.app.services.pick_save = Some(Box::new(move |name, _| Some(dir.join(name).to_string_lossy().into_owned()))); + let next = self.next_file.clone(); + self.app.services.pick_open = Some(Box::new(move || next.borrow_mut().take())); + } + + /// Autosave into the scenario's recovery folder (as the desktop app does into the user's). + fn start_autosave(&mut self) { + crate::recovery_ui::start(&mut self.app, &self.dir.join("recovery"), std::time::Duration::ZERO); + } + /// Lay out and draw one frame (feeding queued pointer and key events). pub fn frame(&mut self) { self.time += 1.0 / 60.0; @@ -161,16 +199,21 @@ impl Harness { /// Centres of the visible widgets whose label or value is `text`, last drawn first. fn widgets(&self, text: &str) -> Vec<[f64; 2]> { let Some(tree) = self.output.accesskit_update.as_ref() else { return Vec::new() }; + let centre = + |n: &egui::accesskit::Node| n.bounds().filter(|b| b.x1 > b.x0 && b.y1 > b.y0).map(|b| [(b.x0 + b.x1) / 2.0, (b.y0 + b.y1) / 2.0]); + let exact: Vec<[f64; 2]> = + tree.nodes.iter().rev().filter(|(_, n)| n.label() == Some(text) || n.value() == Some(text)).filter_map(|(_, n)| centre(n)).collect(); + if !exact.is_empty() { + return exact; + } + // A menu button's label ends with its shortcut ("Save Ctrl+S"). 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) + n.role() == egui::accesskit::Role::Button && n.label().is_some_and(|l| l.strip_prefix(text).is_some_and(|r| r.starts_with(' '))) }) - .filter_map(|(_, n)| n.bounds()) - .filter(|b| b.x1 > b.x0 && b.y1 > b.y0) - .map(|b| [(b.x0 + b.x1) / 2.0, (b.y0 + b.y1) / 2.0]) + .filter_map(|(_, n)| centre(n)) .collect() } @@ -236,6 +279,23 @@ impl Harness { self.call("ui.render", json!({"path": path.to_string_lossy(), "width": 1000, "height": 700})); } return Ok(()); + } else if s.get("autosave").is_some() { + self.start_autosave(); + return Ok(()); + } else if let Some(how) = s.get("restart").and_then(Value::as_str) { + // "crash": the app dies (its autosave lock goes with it, the entry stays); + // "close": it closes normally. Then a new app starts on the same folders. + let mut old = std::mem::replace(&mut self.app, SolveApp::new(Session::default(), Services::default())); + if how == "close" { + crate::recovery_ui::close(&mut old); + } + drop(old); + self.ctx = egui::Context::default(); + self.ctx.enable_accesskit(); + self.install(); + self.start_autosave(); + self.frames(3); + return Ok(()); } else if let Some(f) = s.get("make_image").and_then(Value::as_str) { // A picture to insert (a 200 x 100 checkerboard PNG in the scenario's folder). let img = image::RgbImage::from_fn(200, 100, |x, y| { @@ -461,6 +521,48 @@ pub fn check(h: &mut Harness, e: &Value) -> Result<(), String> { } } } + "sketch_curves" => { + // {sketch?, linked?, own?}: curves projected (linked) and drawn in a sketch. + let mut params = json!({}); + if let Some(n) = v.get("sketch") { + params["sketch"] = n.clone(); + } + let si = h.call("engine.execute", json!({"command": "sketch.inspect", "params": params}))["result"].clone(); + let curves = si["curves"].as_array().cloned().unwrap_or_default(); + let linked = curves.iter().filter(|c| !c["link"].is_null()).count(); + let own = curves.len() - linked; + for (k, got) in [("linked", linked), ("own", own)] { + if let Some(w) = v.get(k).and_then(Value::as_u64) + && w != got as u64 + { + return Err(format!("sketch curves {k}: got {got}, want {w}")); + } + } + } + "body_volume" => { + // {body, volume}: one body's volume. + let name = v["body"].as_str().unwrap_or_default(); + let m = h.call("engine.execute", json!({"command": "MeasureCommand", "params": {"bodies": [name]}}))["result"].clone(); + let got = m["bodies"][0]["volume_mm3"].as_f64().unwrap_or(f64::NAN); + approx(got, &v["volume"], &format!("{name} volume"))?; + } + "sketch_world" => { + // {sketch, point, at: [x,y,z]}: where a sketch point is in the world (±1e-3 mm). + let si = h.call("engine.execute", json!({"command": "sketch.inspect", "params": {"sketch": v["sketch"]}}))["result"].clone(); + let p = si["points"] + .as_array() + .into_iter() + .flatten() + .find(|p| p["id"] == v["point"]) + .cloned() + .ok_or_else(|| format!("no point {}", v["point"]))?; + for i in 0..3 { + let (g, w) = (p["world"][i].as_f64().unwrap_or(f64::NAN), v["at"][i].as_f64().unwrap_or(f64::NAN)); + if (g - w).abs() > 1e-3 { + return Err(format!("{} of {} is at {}, want {}", v["point"], v["sketch"], p["world"], v["at"])); + } + } + } "home" => { if ui["home"] != *v { return Err(format!("start page open: got {}, want {v}", ui["home"])); diff --git a/crates/ui-egui/src/sketch3d.rs b/crates/ui-egui/src/sketch3d.rs index ec92338f..0595c56f 100644 --- a/crates/ui-egui/src/sketch3d.rs +++ b/crates/ui-egui/src/sketch3d.rs @@ -346,6 +346,9 @@ pub fn show(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { if !active(app) { return; } + for (name, tip) in handle_points(app, proj) { + crate::scenario::publish_handle(&format!("triad_{name}"), tip); + } for (axis, tip, base) in handles(app, proj) { match axis { Some(a) => { diff --git a/crates/ui-egui/src/timeline.rs b/crates/ui-egui/src/timeline.rs index 4fc1a659..f870553e 100644 --- a/crates/ui-egui/src/timeline.rs +++ b/crates/ui-egui/src/timeline.rs @@ -133,6 +133,7 @@ pub fn timeline(app: &mut SolveApp, ui: &mut egui::Ui) { } xs.push(x); let br = Rect::from_min_size(pos2(x, r.top() + 6.0), vec2(28.0, 28.0)); + crate::scenario::publish_handle(&format!("timeline:{name}"), br.center()); let resp = ui.interact(br, ui.id().with(("feat", *id)), Sense::click_and_drag()); let res = app.session.model.result(*id); let err = res.and_then(|r| r.error.clone()); diff --git a/crates/ui-egui/tests/scenarios.rs b/crates/ui-egui/tests/scenarios.rs index a155ce5d..044eca74 100644 --- a/crates/ui-egui/tests/scenarios.rs +++ b/crates/ui-egui/tests/scenarios.rs @@ -47,11 +47,12 @@ scenarios!( s26_drive_joint_typed, s22_sheet_metal_fold, s23_configurations, - s24_motion_study + s24_motion_study, + s25_autosave_recovery ); -/// Scenarios written ahead of their features (the commands are in solvecraft-params' unpushed -/// work, their dialogs follow): run with `--ignored`, enabled once the features land. +/// Scenarios written ahead of their fixes or features (the user's open bugs): run with +/// `--ignored`; each moves to the list above once its fix lands. macro_rules! pending { ($($name:ident: $why:literal),* $(,)?) => { $(#[test] @@ -63,5 +64,9 @@ macro_rules! pending { } pending!( - s25_autosave_recovery: "waits for autosave and Recover unsaved design", + s31_sketch_on_face_projects: "user bug: a sketch on a face projects its loops as reference (solvecraft-sketch)", + s32_cut_in_active_component: "user bug: a cut in the active component leaves others alone (solvecraft-params)", + s33_move_x_arrow: "user bug: Move opens at 0 and its X arrow moves along X only (solvecraft-ui)", + s34_component_move_carries_sketches: "user bug: moving a component carries its sketches (solvecraft-repo)", + s30_pattern_a_hole: "a hole patterned as a feature gives one hole, not three (solvecraft-ui)", ); diff --git a/crates/ui-egui/tests/scenarios/s06_edit_early_feature.json b/crates/ui-egui/tests/scenarios/s06_edit_early_feature.json index 4c5d8016..a2ba6547 100644 --- a/crates/ui-egui/tests/scenarios/s06_edit_early_feature.json +++ b/crates/ui-egui/tests/scenarios/s06_edit_early_feature.json @@ -136,7 +136,11 @@ "expect": { "sketching": false, "errors": 0, - "bodies": 1 + "bodies": 1, + "volume": [ + 17974.25, + 1e-05 + ] } }, { diff --git a/crates/ui-egui/tests/scenarios/s15_start_page_sample_save_reopen.json b/crates/ui-egui/tests/scenarios/s15_start_page_sample_save_reopen.json index 1b0c52cf..1e68fa4e 100644 --- a/crates/ui-egui/tests/scenarios/s15_start_page_sample_save_reopen.json +++ b/crates/ui-egui/tests/scenarios/s15_start_page_sample_save_reopen.json @@ -6,12 +6,12 @@ {"expect": {"errors": 0, "bodies": 1, "name": "Sample Plate", "param": {"width": 80}}}, {"shot": "s15_sample"}, {"start": "ChangeParameterCommand"}, - {"widget": "8 mm"}, + {"widget": "80 mm"}, {"key": "A", "cmd": true}, - {"text": "10 mm"}, + {"text": "100 mm"}, {"key": "Enter"}, {"widget": "Close window"}, - {"expect": {"param": {"thickness": 10}, "errors": 0}}, + {"expect": {"param": {"width": 100}, "errors": 0}}, {"key": "S", "cmd": true}, {"expect": {"file": "Sample Plate.solvecraft"}}, {"key": "N", "cmd": true}, @@ -19,6 +19,6 @@ {"choose_file": "Sample Plate.solvecraft"}, {"key": "O", "cmd": true}, {"debug": 1}, - {"expect": {"bodies": 1, "errors": 0, "param": {"thickness": 10}}}, + {"expect": {"bodies": 1, "errors": 0, "param": {"width": 100}}}, {"shot": "s15_reopened"} ] diff --git a/crates/ui-egui/tests/scenarios/s23_configurations.json b/crates/ui-egui/tests/scenarios/s23_configurations.json index cb0fcb28..8db09483 100644 --- a/crates/ui-egui/tests/scenarios/s23_configurations.json +++ b/crates/ui-egui/tests/scenarios/s23_configurations.json @@ -1,32 +1,126 @@ [ - {"note": "Target: a configuration table switches the design (FusionStartDesignConfigModeCmd, config.row/column/activate by solvecraft-params; panel by solvecraft-dialogs). Adjust the panel steps when it lands."}, - {"start": "SketchCreate"}, - {"click": {"plane": "XY"}}, - {"start": "ShapeRectangleTwoPoint"}, - {"click": {"sketch": [0, 0]}}, - {"click": {"sketch": [40, 20]}}, - {"key": "Escape"}, - {"start": "SketchDimension"}, - {"click": {"sketch": [20, 0]}}, - {"text": "40"}, - {"key": "Enter"}, - {"key": "Escape"}, - {"start": "SketchStop"}, - {"start": "Extrude"}, - {"text": "10"}, - {"key": "Enter"}, - {"expect": {"volume": 8000}}, - {"start": "FusionStartDesignConfigModeCmd"}, - {"widget": "Add Column"}, - {"widget": "d1"}, - {"widget": "Add Row"}, - {"widget": "40 mm", "near": "Configuration2"}, - {"key": "A", "cmd": true}, - {"text": "60 mm"}, - {"key": "Enter"}, - {"widget": "Activate", "near": "Configuration2"}, - {"expect": {"param": {"d1": 60}, "volume": 12000, "errors": 0}}, - {"widget": "Activate", "near": "Default"}, - {"expect": {"param": {"d1": 40}, "volume": 8000}}, - {"shot": "s23_configs"} -] + { + "note": "Configurations: a parameter column (d1) in the configuration table switches the design (FusionStartDesignConfigModeCmd)." + }, + { + "start": "SketchCreate" + }, + { + "click": { + "plane": "XY" + } + }, + { + "start": "ShapeRectangleTwoPoint" + }, + { + "click": { + "sketch": [ + 0, + 0 + ] + } + }, + { + "click": { + "sketch": [ + 40, + 20 + ] + } + }, + { + "key": "Escape" + }, + { + "start": "SketchDimension" + }, + { + "click": { + "sketch": [ + 20, + 0 + ] + } + }, + { + "text": "40" + }, + { + "key": "Enter" + }, + { + "key": "Escape" + }, + { + "start": "SketchStop" + }, + { + "start": "Extrude" + }, + { + "text": "10" + }, + { + "key": "Enter" + }, + { + "expect": { + "volume": 8000 + } + }, + { + "start": "FusionStartDesignConfigModeCmd" + }, + { + "widget": "Add Column" + }, + { + "widget": "d1" + }, + { + "widget": "Add Row" + }, + { + "widget": "40 mm", + "near": "Configuration2" + }, + { + "key": "A", + "cmd": true + }, + { + "text": "60 mm" + }, + { + "key": "Enter" + }, + { + "widget": "Activate", + "near": "Configuration2" + }, + { + "expect": { + "param": { + "d1": 60 + }, + "volume": 12000, + "errors": 0 + } + }, + { + "widget": "Activate", + "near": "Default" + }, + { + "expect": { + "param": { + "d1": 40 + }, + "volume": 8000 + } + }, + { + "shot": "s23_configs" + } +] \ No newline at end of file diff --git a/crates/ui-egui/tests/scenarios/s24_motion_study.json b/crates/ui-egui/tests/scenarios/s24_motion_study.json index 4d3cf8e6..2c729e6e 100644 --- a/crates/ui-egui/tests/scenarios/s24_motion_study.json +++ b/crates/ui-egui/tests/scenarios/s24_motion_study.json @@ -1,49 +1,226 @@ [ - {"note": "Target: a motion study keys a revolute joint and plays it (FusionMotionStudyCommand, motion.play by solvecraft-params; dialog by solvecraft-dialogs). Starts like s07; adjust the study steps when the dialog lands."}, - {"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"}, - {"start": "JointAssembleCmdNew"}, - {"click": {"world": [10, 10, 10]}}, - {"click": {"world": [40, -20, 5]}}, - {"widget": "Rigid"}, - {"widget": "Revolute"}, - {"widget": "OK"}, - {"start": "FusionMotionStudyCommand"}, - {"widget": "Add Key"}, - {"widget": "0 deg", "near": "Value"}, - {"key": "A", "cmd": true}, - {"text": "90"}, - {"key": "Enter"}, - {"widget": "OK"}, - {"expect": {"dialog": null, "errors": 0}}, - {"call": "engine.execute", "params": {"command": "motion.play", "params": {"study": 1, "step": 100}}}, - {"expect": {"query": {"command": "joint.list", "path": ["joints", 0, "values", 0, "value"], "value": 90}}}, - {"shot": "s24_motion_end"} -] + { + "note": "Motion study: key a revolute joint and play it (FusionMotionStudyCommand, motion.play)." + }, + { + "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" + }, + { + "start": "JointAssembleCmdNew" + }, + { + "click": { + "world": [ + 10, + 10, + 10 + ] + } + }, + { + "click": { + "world": [ + 40, + -20, + 5 + ] + } + }, + { + "widget": "Rigid" + }, + { + "widget": "Revolute" + }, + { + "widget": "OK" + }, + { + "start": "FusionMotionStudyCommand" + }, + { + "widget": "Add Key" + }, + { + "widget": "0 deg", + "near": "Value" + }, + { + "key": "A", + "cmd": true + }, + { + "text": "90" + }, + { + "key": "Enter" + }, + { + "widget": "OK" + }, + { + "expect": { + "dialog": null, + "errors": 0 + } + }, + { + "call": "engine.execute", + "params": { + "command": "motion.play", + "params": { + "study": 1, + "step": 100 + } + } + }, + { + "expect": { + "query": { + "command": "joint.list", + "path": [ + "joints", + 0, + "values", + 0, + "value" + ], + "value": 90 + } + } + }, + { + "shot": "s24_motion_end" + } +] \ No newline at end of file diff --git a/crates/ui-egui/tests/scenarios/s25_autosave_recovery.json b/crates/ui-egui/tests/scenarios/s25_autosave_recovery.json index 471b6870..f0662205 100644 --- a/crates/ui-egui/tests/scenarios/s25_autosave_recovery.json +++ b/crates/ui-egui/tests/scenarios/s25_autosave_recovery.json @@ -1,17 +1,85 @@ [ - {"note": "Target: unsaved work is autosaved and offered back after a crash (autosave, Recover unsaved design?, doc.recovery_list/doc.recover by solvecraft-params). The harness needs a 'restart' step (drop the app without saving, start a new one on the same recovery folder) once the recovery folder can be pointed at the scenario's folder."}, - {"start": "SketchCreate"}, - {"click": {"plane": "XY"}}, - {"start": "ShapeRectangleTwoPoint"}, - {"click": {"sketch": [0, 0]}}, - {"click": {"sketch": [30, 20]}}, - {"key": "Escape"}, - {"start": "SketchStop"}, - {"start": "Extrude"}, - {"text": "10"}, - {"key": "Enter"}, - {"expect": {"bodies": 1, "volume": 6000}}, - {"restart": "crash"}, - {"widget": "Recover"}, - {"expect": {"bodies": 1, "volume": 6000, "errors": 0}} -] + { + "note": "Autosave and recovery: unsaved work is autosaved; after a crash the next launch offers it back." + }, + { + "autosave": true + }, + { + "start": "SketchCreate" + }, + { + "click": { + "plane": "XY" + } + }, + { + "start": "ShapeRectangleTwoPoint" + }, + { + "click": { + "sketch": [ + 0, + 0 + ] + } + }, + { + "click": { + "sketch": [ + 30, + 20 + ] + } + }, + { + "key": "Escape" + }, + { + "start": "SketchStop" + }, + { + "start": "Extrude" + }, + { + "text": "10" + }, + { + "key": "Enter" + }, + { + "expect": { + "bodies": 1, + "volume": 6000 + } + }, + { + "restart": "crash" + }, + { + "expect": { + "bodies": 0 + } + }, + { + "widget": "Recover" + }, + { + "expect": { + "bodies": 1, + "volume": 6000, + "errors": 0 + } + }, + { + "restart": "close" + }, + { + "expect": { + "bodies": 0 + } + }, + { + "note": "a clean close leaves nothing to offer" + } +] \ No newline at end of file diff --git a/crates/ui-egui/tests/scenarios/s30_pattern_a_hole.json b/crates/ui-egui/tests/scenarios/s30_pattern_a_hole.json new file mode 100644 index 00000000..b011bfb3 --- /dev/null +++ b/crates/ui-egui/tests/scenarios/s30_pattern_a_hole.json @@ -0,0 +1,142 @@ +[ + { + "note": "Pattern a hole (Object Type: Features), the beginner workflow box \u2192 hole \u2192 pattern." + }, + { + "start": "SketchCreate" + }, + { + "click": { + "plane": "XY" + } + }, + { + "start": "ShapeRectangleTwoPoint" + }, + { + "click": { + "sketch": [ + 0, + 0 + ] + } + }, + { + "click": { + "sketch": [ + 50, + 25 + ] + } + }, + { + "key": "Escape" + }, + { + "start": "SketchStop" + }, + { + "call": "ui.view", + "params": { + "view": "iso" + } + }, + { + "start": "Extrude" + }, + { + "text": "15" + }, + { + "key": "Enter" + }, + { + "expect": { + "volume": 18750 + } + }, + { + "start": "FusionHoleCommand" + }, + { + "click": { + "world": [ + 10, + 12, + 15 + ] + } + }, + { + "key": "Enter" + }, + { + "expect": { + "errors": 0, + "dialog": null + } + }, + { + "expect": { + "volume": [ + 18543.855, + 1e-05 + ] + } + }, + { + "start": "PatternRectangular" + }, + { + "widget": "Bodies" + }, + { + "widget": "Features" + }, + { + "click": { + "handle": "timeline:Hole1" + } + }, + { + "call": "ui.dialogInput", + "params": { + "index": 1 + } + }, + { + "click": { + "axis": "X" + } + }, + { + "widget": "20 mm", + "near": "Spacing" + }, + { + "key": "A", + "cmd": true + }, + { + "text": "15 mm" + }, + { + "key": "Tab" + }, + { + "key": "Enter" + }, + { + "expect": { + "errors": 0, + "dialog": null, + "volume": [ + 18131.58, + 0.0001 + ] + } + }, + { + "shot": "s30_pattern" + } +] \ No newline at end of file diff --git a/crates/ui-egui/tests/scenarios/s31_sketch_on_face_projects.json b/crates/ui-egui/tests/scenarios/s31_sketch_on_face_projects.json new file mode 100644 index 00000000..60b258cb --- /dev/null +++ b/crates/ui-egui/tests/scenarios/s31_sketch_on_face_projects.json @@ -0,0 +1,141 @@ +[ + { + "note": "User bug 1 (owner solvecraft-sketch): Create Sketch on a body face projects the face's loops as reference geometry (linked), with no big square of real lines, and the face stays pickable for Project." + }, + { + "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": "15" + }, + { + "key": "Enter" + }, + { + "start": "SketchCreate" + }, + { + "click": { + "world": [ + 20, + 15, + 15 + ] + } + }, + { + "expect": { + "sketching": true + } + }, + { + "expect": { + "sketch_curves": { + "linked": 4, + "own": 0 + } + } + }, + { + "shot": "s26_face_sketch" + }, + { + "start": "ProjectNewCmd" + }, + { + "click": { + "world": [ + 5, + 5, + 15 + ] + } + }, + { + "key": "Escape" + }, + { + "expect": { + "sketch_curves": { + "linked": 8, + "own": 0 + } + } + }, + { + "start": "CircleCenterRadius" + }, + { + "click": { + "sketch": [ + 20, + 15 + ] + } + }, + { + "click": { + "sketch": [ + 25, + 15 + ] + } + }, + { + "key": "Escape" + }, + { + "expect": { + "sketch_curves": { + "own": 1 + } + } + }, + { + "start": "SketchStop" + }, + { + "expect": { + "errors": 0 + } + } +] \ No newline at end of file diff --git a/crates/ui-egui/tests/scenarios/s32_cut_in_active_component.json b/crates/ui-egui/tests/scenarios/s32_cut_in_active_component.json new file mode 100644 index 00000000..90241344 --- /dev/null +++ b/crates/ui-egui/tests/scenarios/s32_cut_in_active_component.json @@ -0,0 +1,242 @@ +[ + { + "note": "User bug 2 (owner solvecraft-params): with two components, a cut made in the active one leaves the other untouched." + }, + { + "start": "FusionCreateNewComponentCommand" + }, + { + "key": "Enter" + }, + { + "start": "SketchCreate" + }, + { + "click": { + "plane": "XY" + } + }, + { + "start": "ShapeRectangleTwoPoint" + }, + { + "click": { + "sketch": [ + 0, + 10 + ] + } + }, + { + "click": { + "sketch": [ + 40, + 30 + ] + } + }, + { + "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": [ + 42, + 0 + ] + } + }, + { + "click": { + "sketch": [ + 52, + 10 + ] + } + }, + { + "key": "Escape" + }, + { + "start": "SketchStop" + }, + { + "call": "ui.view", + "params": { + "view": "iso" + } + }, + { + "start": "Extrude" + }, + { + "text": "20" + }, + { + "key": "Enter" + }, + { + "expect": { + "bodies": 2, + "body_volume": { + "body": "Body1", + "volume": 8000 + } + } + }, + { + "expect": { + "body_volume": { + "body": "Body2", + "volume": 2000 + } + } + }, + { + "note": "Component2 (Body2) is active: cut a slot across both boxes, sketched on XY seen from the top" + }, + { + "call": "ui.view", + "params": { + "view": "top" + } + }, + { + "start": "SketchCreate" + }, + { + "click": { + "plane": "XY" + } + }, + { + "expect": { + "sketching": true + } + }, + { + "call": "ui.view", + "params": { + "view": "top" + } + }, + { + "start": "ShapeRectangleTwoPoint" + }, + { + "click": { + "sketch": [ + 30, + -15 + ] + } + }, + { + "click": { + "sketch": [ + 48, + 25 + ] + } + }, + { + "key": "Escape" + }, + { + "expect": { + "sketch_curves": { + "own": 4 + } + } + }, + { + "start": "SketchStop" + }, + { + "start": "Extrude" + }, + { + "widget": "New Body" + }, + { + "widget": "Cut" + }, + { + "text": "10" + }, + { + "key": "Enter" + }, + { + "expect": { + "errors": 0, + "body_volume": { + "body": "Body1", + "volume": 8000 + } + } + }, + { + "expect": { + "body_volume": { + "body": "Body2", + "volume": 1100 + } + } + }, + { + "shot": "s27_cut" + } +] \ No newline at end of file diff --git a/crates/ui-egui/tests/scenarios/s33_move_x_arrow.json b/crates/ui-egui/tests/scenarios/s33_move_x_arrow.json new file mode 100644 index 00000000..05491593 --- /dev/null +++ b/crates/ui-egui/tests/scenarios/s33_move_x_arrow.json @@ -0,0 +1,22 @@ +[ + {"note": "User bug 3 (owner solvecraft-ui): Move opens at 0, and dragging its X arrow moves only along X. The X arrow must be published as handle move_x (scenario::publish_handle)."}, + {"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": 250, "target": {"type": "body", "name": "Body1"}}}, + {"call": "ui.menuPick", "params": {"item": "Move/Copy"}}, + {"expect": {"dialog": "z: \"0 mm\""}}, + {"expect": {"dialog": "x: \"0 mm\""}}, + {"drag": [{"handle": "move_x"}, {"world": [30, 0, 10]}], "steps": 10}, + {"key": "Enter"}, + {"expect": {"errors": 0, "bbox": {"body": "Body1", "min": [null, 0, 0], "max": [null, 20, 10]}}}, + {"shot": "s28_moved"} +] diff --git a/crates/ui-egui/tests/scenarios/s34_component_move_carries_sketches.json b/crates/ui-egui/tests/scenarios/s34_component_move_carries_sketches.json new file mode 100644 index 00000000..42168e72 --- /dev/null +++ b/crates/ui-egui/tests/scenarios/s34_component_move_carries_sketches.json @@ -0,0 +1,202 @@ +[ + { + "note": "User bug 4 (owner solvecraft-repo): moving a component carries its sketches, and a sketch on a body face follows the body." + }, + { + "start": "FusionCreateNewComponentCommand" + }, + { + "key": "Enter" + }, + { + "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" + }, + { + "start": "SketchCreate" + }, + { + "click": { + "world": [ + 15, + 10, + 10 + ] + } + }, + { + "start": "CircleCenterRadius" + }, + { + "click": { + "sketch": [ + 15, + 10 + ] + } + }, + { + "click": { + "sketch": [ + 18, + 10 + ] + } + }, + { + "key": "Escape" + }, + { + "start": "SketchStop" + }, + { + "expect": { + "sketch_world": { + "sketch": "Sketch2", + "point": "c1.center", + "at": [ + 15, + 10, + 10 + ] + } + } + }, + { + "expect": { + "sketch_world": { + "sketch": "Sketch1", + "point": "p1", + "at": [ + 0, + 0, + 0 + ] + } + } + }, + { + "call": "ui.menu", + "params": { + "x": 100, + "y": 170, + "target": { + "type": "component", + "id": 1 + } + } + }, + { + "call": "ui.menuPick", + "params": { + "item": "Move/Copy" + } + }, + { + "widget": "0.0 mm", + "near": "X distance" + }, + { + "key": "A", + "cmd": true + }, + { + "text": "50" + }, + { + "key": "Enter" + }, + { + "widget": "Capture Position" + }, + { + "expect": { + "bbox": { + "body": "Body1", + "min": [ + 50, + 0, + 0 + ], + "max": [ + 80, + 20, + 10 + ] + } + } + }, + { + "expect": { + "sketch_world": { + "sketch": "Sketch1", + "point": "p1", + "at": [ + 50, + 0, + 0 + ] + } + } + }, + { + "expect": { + "sketch_world": { + "sketch": "Sketch2", + "point": "c1.center", + "at": [ + 65, + 10, + 10 + ] + } + } + }, + { + "shot": "s29_moved" + } +] \ No newline at end of file diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 24d3feb9..a08295cf 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"` \| `dimension: "d1"` (its value text) | 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) \| `handle: "arrow"` (a manipulator handle, see `scenario::publish_handle`) | the screen point (a scenario step's target; see `crates/ui-egui/src/scenario.rs`) | | `ui.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?` | |