diff --git a/crates/ui-egui/src/scenario.rs b/crates/ui-egui/src/scenario.rs index 17ee4a99..47e262cc 100644 --- a/crates/ui-egui/src/scenario.rs +++ b/crates/ui-egui/src/scenario.rs @@ -27,6 +27,8 @@ //! `{"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 +//! - `{"script": "examples/…/x.json"}`: run a command script through the engine; `"$DIR"` in a +//! `call` step's string parameters is the scenario's folder //! - `{"note": "…"}`: a comment; `{"pending": "why"}`: the scenario waits for a fix (its test //! is ignored) //! - `{"expect": {…}}`: checks, see [`check`]; `{"until": {…}}` waits (frames) until they pass @@ -95,6 +97,16 @@ pub fn clear_handles() { COUNTS.with(|h| h.borrow_mut().clear()); } +/// `v` with "$DIR" in its strings replaced by `dir`. +fn with_dir(v: Value, dir: &str) -> Value { + match v { + Value::String(t) => Value::String(t.replace("$DIR", dir)), + Value::Array(a) => Value::Array(a.into_iter().map(|x| with_dir(x, dir)).collect()), + Value::Object(o) => Value::Object(o.into_iter().map(|(k, x)| (k, with_dir(x, dir))).collect()), + other => other, + } +} + /// A fresh folder for one scenario's files. fn scratch_dir() -> PathBuf { use std::sync::atomic::{AtomicUsize, Ordering}; @@ -363,8 +375,24 @@ impl Harness { return Err(format!("{m} was answered after {frames} frames: the agent cursor did not act it out ({min})")); } v + } else if let Some(f) = s.get("script").and_then(Value::as_str) { + // A command script (`{"commands": [{command, params}]}`, path from the repository + // root) run through the engine, as `solvecraft-cli run` does. + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..").join(f); + let text = std::fs::read_to_string(&path).map_err(|e| format!("{f}: {e}"))?; + let v: Value = serde_json::from_str(&text).map_err(|e| format!("{f}: {e}"))?; + for (i, c) in v["commands"].as_array().into_iter().flatten().enumerate() { + let r = self.call("engine.execute", json!({"command": c["command"], "params": c.get("params").cloned().unwrap_or(json!({}))})); + if r["ok"] != true { + return Err(format!("{f}: step {} ({}): {}", i + 1, c["command"], r["error"])); + } + } + self.frames(2); + return Ok(()); } else if let Some(m) = s.get("call").and_then(Value::as_str) { - self.call(m, s.get("params").cloned().unwrap_or(json!({}))) + // "$DIR" in a string parameter is the scenario's folder. + let params = with_dir(s.get("params").cloned().unwrap_or(json!({})), &self.dir.to_string_lossy()); + self.call(m, params) } else if s.get("note").is_some() || s.get("pending").is_some() { return Ok(()); } else if let Some(name) = s.get("shot").and_then(Value::as_str) { diff --git a/crates/ui-egui/tests/scenarios/acceptance_lid_lip_after_resize.json b/crates/ui-egui/tests/scenarios/acceptance_lid_lip_after_resize.json new file mode 100644 index 00000000..0da30c42 --- /dev/null +++ b/crates/ui-egui/tests/scenarios/acceptance_lid_lip_after_resize.json @@ -0,0 +1,27 @@ +[ + { + "pending": "#40: offsets of projected geometry get new ids on recompute, the lid's lip fails" + }, + { + "note": "Acceptance (#27): the board grows 60 \u2192 70 mm; the lid's lip (offsets of the case's projected rim) follows." + }, + { + "script": "examples/acceptance/sensor_enclosure.json" + }, + { + "call": "engine.execute", + "params": { + "command": "parameters.change", + "params": { + "name": "board_w", + "expression": "70 mm" + } + } + }, + { + "expect": { + "errors": 0, + "bodies": 2 + } + } +] diff --git a/crates/ui-egui/tests/scenarios/acceptance_lip_on_rounded_rim.json b/crates/ui-egui/tests/scenarios/acceptance_lip_on_rounded_rim.json new file mode 100644 index 00000000..c2098168 --- /dev/null +++ b/crates/ui-egui/tests/scenarios/acceptance_lip_on_rounded_rim.json @@ -0,0 +1,33 @@ +[ + { + "pending": "#29: Plastic Lip fails on a rounded (filleted) shelled rim" + }, + { + "note": "Acceptance (#27): a lip on the case's rounded rim with the Lip command." + }, + { + "script": "examples/acceptance/sensor_enclosure.json" + }, + { + "call": "engine.execute", + "params": { + "command": "plastic.lip", + "params": { + "face": [ + 0, + 29, + 25 + ], + "width": 1, + "height": 1.5, + "body": "Case" + } + } + }, + { + "expect": { + "errors": 0, + "bodies": 2 + } + } +] diff --git a/crates/ui-egui/tests/scenarios/acceptance_pattern_of_suppressed_feature.json b/crates/ui-egui/tests/scenarios/acceptance_pattern_of_suppressed_feature.json new file mode 100644 index 00000000..898c0d0b --- /dev/null +++ b/crates/ui-egui/tests/scenarios/acceptance_pattern_of_suppressed_feature.json @@ -0,0 +1,29 @@ +[ + { + "pending": "#42: suppressing a patterned feature keeps the pattern's copies" + }, + { + "note": "Acceptance (#27): suppressing the PCB standoff takes the patterned copies with it." + }, + { + "script": "examples/acceptance/sensor_enclosure.json" + }, + { + "call": "engine.execute", + "params": { + "command": "timeline.suppress", + "params": { + "feature": "Standoff", + "suppressed": true + } + } + }, + { + "expect": { + "body_volume": { + "body": "Case", + "volume": 24934.4 + } + } + } +] diff --git a/crates/ui-egui/tests/scenarios/acceptance_sensor_enclosure.json b/crates/ui-egui/tests/scenarios/acceptance_sensor_enclosure.json new file mode 100644 index 00000000..30f04daa --- /dev/null +++ b/crates/ui-egui/tests/scenarios/acceptance_sensor_enclosure.json @@ -0,0 +1,145 @@ +[ + { + "note": "Acceptance (#27): the sensor enclosure built from examples/acceptance/sensor_enclosure.json, then edited: the USB cut-out moved, the height changed, features suppressed and back, saved and reopened, exported to STEP and read back." + }, + { + "script": "examples/acceptance/sensor_enclosure.json" + }, + { + "call": "ui.view", + "params": { + "view": "home" + } + }, + { + "expect": { + "bodies": 2, + "errors": 0, + "body_volume": { + "body": "Case", + "volume": 25401.7 + } + } + }, + { + "expect": { + "body_volume": { + "body": "Lid", + "volume": 19629.7 + } + } + }, + { + "call": "engine.execute", + "params": { + "command": "parameters.change", + "params": { + "name": "usb_offset", + "expression": "15 mm" + } + } + }, + { + "expect": { + "errors": 0, + "bodies": 2, + "body_volume": { + "body": "Case", + "volume": 25401.7 + } + } + }, + { + "call": "engine.execute", + "params": { + "command": "timeline.suppress", + "params": { + "feature": "SnapFit1", + "suppressed": true + } + } + }, + { + "expect": { + "errors": 0, + "body_volume": { + "body": "Case", + "volume": 25340.5 + } + } + }, + { + "call": "engine.execute", + "params": { + "command": "timeline.suppress", + "params": { + "feature": "SnapFit1", + "suppressed": false + } + } + }, + { + "expect": { + "errors": 0, + "body_volume": { + "body": "Case", + "volume": 25401.7 + } + } + }, + { + "call": "engine.execute", + "params": { + "command": "file.save_as", + "params": { + "path": "$DIR/enclosure.solvecraft" + } + } + }, + { + "call": "engine.execute", + "params": { + "command": "doc.open", + "params": { + "path": "$DIR/enclosure.solvecraft" + } + } + }, + { + "expect": { + "errors": 0, + "bodies": 2, + "body_volume": { + "body": "Case", + "volume": 25401.7 + } + } + }, + { + "call": "engine.execute", + "params": { + "command": "file.export", + "params": { + "path": "$DIR/enclosure.step" + } + } + }, + { + "call": "engine.execute", + "params": { + "command": "doc.open", + "params": { + "path": "$DIR/enclosure.step" + } + } + }, + { + "expect": { + "bodies": 2, + "body_volume": { + "body": "Case", + "volume": 25401.7 + } + } + } +] diff --git a/crates/ui-egui/tests/scenarios/acceptance_snap_fit_after_resize.json b/crates/ui-egui/tests/scenarios/acceptance_snap_fit_after_resize.json new file mode 100644 index 00000000..d2d51a42 --- /dev/null +++ b/crates/ui-egui/tests/scenarios/acceptance_snap_fit_after_resize.json @@ -0,0 +1,44 @@ +[ + { + "pending": "#41: a snap fit stays at its old point when its wall moves (a floating body)" + }, + { + "note": "Acceptance (#27): the board grows 40 \u2192 50 mm deep; the snap fit stays on the moved front wall." + }, + { + "script": "examples/acceptance/sensor_enclosure.json" + }, + { + "call": "engine.execute", + "params": { + "command": "parameters.change", + "params": { + "name": "board_d", + "expression": "50 mm" + } + } + }, + { + "expect": { + "errors": 0, + "bodies": 2 + } + }, + { + "expect": { + "bbox": { + "body": "Case", + "min": [ + -40, + -35, + 0 + ], + "max": [ + 40, + 35, + 25 + ] + } + } + } +] diff --git a/examples/acceptance/sensor_enclosure.json b/examples/acceptance/sensor_enclosure.json new file mode 100644 index 00000000..12cef4ed --- /dev/null +++ b/examples/acceptance/sensor_enclosure.json @@ -0,0 +1,772 @@ +{ + "description": "Acceptance project (#27): a sensor enclosure for a 60 x 40 mm board. A case (rounded, shelled, a USB cut-out on the front wall, 4 PCB standoffs and 4 lid bosses patterned from one each, a snap fit) and a lid (a lip made from offsets of the case's inner rim, 4 screw holes patterned from one). Everything is sized from the user parameters: board_w, board_d, gap (board to inner wall), wall, height, standoff_h, hole_inset, usb_offset, lid_h.", + "commands": [ + { + "command": "parameters.change", + "params": { + "name": "board_w", + "expression": "60 mm", + "comment": "PCB length" + } + }, + { + "command": "parameters.change", + "params": { + "name": "board_d", + "expression": "40 mm", + "comment": "PCB width" + } + }, + { + "command": "parameters.change", + "params": { + "name": "gap", + "expression": "8 mm", + "comment": "PCB to inner wall" + } + }, + { + "command": "parameters.change", + "params": { + "name": "wall", + "expression": "2 mm" + } + }, + { + "command": "parameters.change", + "params": { + "name": "height", + "expression": "25 mm" + } + }, + { + "command": "parameters.change", + "params": { + "name": "standoff_h", + "expression": "5 mm" + } + }, + { + "command": "parameters.change", + "params": { + "name": "hole_inset", + "expression": "3.5 mm", + "comment": "PCB mounting hole from its edge" + } + }, + { + "command": "parameters.change", + "params": { + "name": "usb_offset", + "expression": "10 mm", + "comment": "USB connector centre, left of the middle" + } + }, + { + "command": "parameters.change", + "params": { + "name": "lid_h", + "expression": "4 mm" + } + }, + { + "command": "sketch.create", + "params": { + "plane": "XY", + "name": "Outline" + } + }, + { + "command": "sketch.rectangle.center", + "params": { + "center": [ + 0, + 0 + ], + "corner": [ + 40, + 30 + ] + } + }, + { + "command": "sketch.constraint.coincident", + "params": { + "a": "p5", + "b": "origin" + } + }, + { + "command": "sketch.dimension", + "params": { + "entities": [ + "l1" + ], + "value": "board_w + 2 * (gap + wall)" + } + }, + { + "command": "sketch.dimension", + "params": { + "entities": [ + "l2" + ], + "value": "board_d + 2 * (gap + wall)" + } + }, + { + "command": "sketch.finish", + "params": {} + }, + { + "command": "solid.extrude", + "params": { + "distance": "height", + "body_name": "Case" + } + }, + { + "command": "solid.fillet", + "params": { + "edges": [ + [ + 40, + 30, + 12.5 + ], + [ + -40, + 30, + 12.5 + ], + [ + -40, + -30, + 12.5 + ], + [ + 40, + -30, + 12.5 + ] + ], + "radius": 4 + } + }, + { + "command": "solid.shell", + "params": { + "faces": [ + [ + 0, + 0, + 25 + ] + ], + "thickness": "wall" + } + }, + { + "command": "sketch.create", + "params": { + "plane": { + "face": [ + 5, + -7, + 2 + ] + }, + "name": "PCB standoff" + } + }, + { + "command": "sketch.circle.center", + "params": { + "center": [ + -26.5, + -16.5 + ], + "diameter": 6, + "id": "so" + } + }, + { + "command": "sketch.circle.center", + "params": { + "center": "so.center", + "diameter": 2.5, + "id": "so_hole" + } + }, + { + "command": "sketch.dimension", + "params": { + "entities": [ + "origin", + "so.center" + ], + "type": "horizontal", + "value": "board_w / 2 - hole_inset" + } + }, + { + "command": "sketch.dimension", + "params": { + "entities": [ + "origin", + "so.center" + ], + "type": "vertical", + "value": "board_d / 2 - hole_inset" + } + }, + { + "command": "sketch.dimension", + "params": { + "entities": [ + "so" + ], + "type": "diameter", + "value": "6 mm" + } + }, + { + "command": "sketch.dimension", + "params": { + "entities": [ + "so_hole" + ], + "type": "diameter", + "value": "2.5 mm" + } + }, + { + "command": "sketch.finish", + "params": {} + }, + { + "command": "solid.extrude", + "params": { + "profiles": [ + [ + "so" + ] + ], + "distance": "standoff_h", + "operation": "join", + "name": "Standoff" + } + }, + { + "command": "solid.pattern.rectangular", + "params": { + "features": [ + "Standoff" + ], + "dir1": [ + 1, + 0, + 0 + ], + "count1": 2, + "spacing1": "board_w - 2 * hole_inset", + "dir2": [ + 0, + 1, + 0 + ], + "count2": 2, + "spacing2": "board_d - 2 * hole_inset" + } + }, + { + "command": "sketch.create", + "params": { + "plane": { + "face": [ + 0, + -30, + 12 + ] + }, + "name": "USB" + } + }, + { + "command": "sketch.rectangle.center", + "params": { + "center": [ + -10, + 12 + ], + "corner": [ + -5.5, + 13.75 + ] + } + }, + { + "command": "sketch.dimension", + "params": { + "entities": [ + "l5" + ], + "value": "9 mm" + } + }, + { + "command": "sketch.dimension", + "params": { + "entities": [ + "l6" + ], + "value": "3.5 mm" + } + }, + { + "command": "sketch.dimension", + "params": { + "entities": [ + "origin", + "p9" + ], + "type": "horizontal", + "value": "usb_offset" + } + }, + { + "command": "sketch.dimension", + "params": { + "entities": [ + "origin", + "p9" + ], + "type": "vertical", + "value": "wall + standoff_h + 1.6 mm + 1.75 mm" + } + }, + { + "command": "sketch.finish", + "params": {} + }, + { + "command": "solid.extrude", + "params": { + "profiles": [ + [ + "l5", + "l6", + "l7", + "l8" + ] + ], + "distance": "-(wall + 1 mm)", + "operation": "cut", + "name": "USB cut" + } + }, + { + "command": "sketch.create", + "params": { + "plane": { + "face": [ + 5, + -7, + 2 + ] + }, + "name": "Lid boss" + } + }, + { + "command": "sketch.circle.center", + "params": { + "center": [ + -34.5, + -24.5 + ], + "diameter": 7, + "id": "bs" + } + }, + { + "command": "sketch.circle.center", + "params": { + "center": "bs.center", + "diameter": 2.5, + "id": "bs_hole" + } + }, + { + "command": "sketch.dimension", + "params": { + "entities": [ + "origin", + "bs.center" + ], + "type": "horizontal", + "value": "board_w / 2 + gap - 4.5 mm" + } + }, + { + "command": "sketch.dimension", + "params": { + "entities": [ + "origin", + "bs.center" + ], + "type": "vertical", + "value": "board_d / 2 + gap - 4.5 mm" + } + }, + { + "command": "sketch.dimension", + "params": { + "entities": [ + "bs" + ], + "type": "diameter", + "value": "7 mm" + } + }, + { + "command": "sketch.dimension", + "params": { + "entities": [ + "bs_hole" + ], + "type": "diameter", + "value": "2.5 mm" + } + }, + { + "command": "sketch.finish", + "params": {} + }, + { + "command": "solid.extrude", + "params": { + "profiles": [ + [ + "bs" + ] + ], + "distance": "height - wall", + "operation": "join", + "name": "Lid boss" + } + }, + { + "command": "solid.pattern.rectangular", + "params": { + "features": [ + "Lid boss" + ], + "dir1": [ + 1, + 0, + 0 + ], + "count1": 2, + "spacing1": "board_w + 2 * gap - 9 mm", + "dir2": [ + 0, + 1, + 0 + ], + "count2": 2, + "spacing2": "board_d + 2 * gap - 9 mm" + } + }, + { + "command": "sketch.create", + "params": { + "plane": "XY", + "offset": "height", + "name": "Lid outline" + } + }, + { + "command": "sketch.rectangle.center", + "params": { + "center": [ + 0, + 0 + ], + "corner": [ + 40, + 30 + ] + } + }, + { + "command": "sketch.constraint.coincident", + "params": { + "a": "p5", + "b": "origin" + } + }, + { + "command": "sketch.dimension", + "params": { + "entities": [ + "l1" + ], + "value": "board_w + 2 * (gap + wall)" + } + }, + { + "command": "sketch.dimension", + "params": { + "entities": [ + "l2" + ], + "value": "board_d + 2 * (gap + wall)" + } + }, + { + "command": "sketch.finish", + "params": {} + }, + { + "command": "solid.extrude", + "params": { + "distance": "lid_h", + "operation": "new", + "body_name": "Lid", + "name": "Lid" + } + }, + { + "command": "solid.fillet", + "params": { + "edges": [ + [ + 40, + 30, + 27 + ], + [ + -40, + 30, + 27 + ], + [ + -40, + -30, + 27 + ], + [ + 40, + -30, + 27 + ] + ], + "radius": 4, + "body": "Lid" + } + }, + { + "command": "plastic.snap_fit", + "params": { + "position": [ + 0, + -28, + 20 + ], + "hook": [ + 0, + 0, + 1 + ], + "length": 8, + "thickness": 1.2, + "width": 6, + "catch_depth": 0.8, + "catch_length": 1.5, + "body": "Case" + } + }, + { + "command": "sketch.create", + "params": { + "plane": { + "face": [ + 0, + 0, + 25 + ] + }, + "name": "Lid lip" + } + }, + { + "command": "sketch.project", + "params": { + "refs": [ + { + "face": [ + 0, + 29, + 25 + ], + "body": "Case" + } + ] + } + }, + { + "command": "sketch.offset", + "params": { + "curves": [ + "l9" + ], + "distance": 0.2, + "side": [ + 0, + 0 + ] + } + }, + { + "command": "sketch.offset", + "params": { + "curves": [ + "l9" + ], + "distance": 1.4, + "side": [ + 0, + 0 + ] + } + }, + { + "command": "sketch.finish", + "params": {} + }, + { + "command": "solid.extrude", + "params": { + "sketch": "Lid lip", + "profiles": [ + [ + "l13", + "a13", + "l14", + "a14", + "l15", + "a15", + "l16", + "a16" + ] + ], + "distance": "2 mm", + "operation": "join", + "targets": [ + "Lid" + ], + "name": "Lid lip" + } + }, + { + "command": "sketch.create", + "params": { + "plane": { + "face": [ + 5, + -7, + 29 + ] + }, + "name": "Lid screw" + } + }, + { + "command": "sketch.circle.center", + "params": { + "center": [ + -33.5, + -23.5 + ], + "diameter": 3.2, + "id": "ls" + } + }, + { + "command": "sketch.dimension", + "params": { + "entities": [ + "origin", + "ls.center" + ], + "type": "horizontal", + "value": "board_w / 2 + gap - 4.5 mm" + } + }, + { + "command": "sketch.dimension", + "params": { + "entities": [ + "origin", + "ls.center" + ], + "type": "vertical", + "value": "board_d / 2 + gap - 4.5 mm" + } + }, + { + "command": "sketch.dimension", + "params": { + "entities": [ + "ls" + ], + "type": "diameter", + "value": "3.2 mm" + } + }, + { + "command": "sketch.finish", + "params": {} + }, + { + "command": "solid.extrude", + "params": { + "profiles": [ + [ + "ls" + ] + ], + "through_all": true, + "direction": "negative", + "operation": "cut", + "targets": [ + "Lid" + ], + "name": "Lid screw" + } + }, + { + "command": "solid.pattern.rectangular", + "params": { + "features": [ + "Lid screw" + ], + "dir1": [ + 1, + 0, + 0 + ], + "count1": 2, + "spacing1": "board_w + 2 * gap - 9 mm", + "dir2": [ + 0, + 1, + 0 + ], + "count2": 2, + "spacing2": "board_d + 2 * gap - 9 mm" + } + } + ] +}