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 <[email protected]> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
62dbf60eb2
commit
d5b8aa0375
12 files changed
+802
-6
No files matched your search
@@ -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::<Vec<_>>()
|
||||
))
|
||||
}
|
||||
"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);
|
||||
|
||||
@@ -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<Pos2>,
|
||||
/// 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<bool> = 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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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::<f64>();
|
||||
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<String> = 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(())
|
||||
}
|
||||
@@ -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<String> {
|
||||
"DrawPoint" => json!({"point": xy(&p[0])}),
|
||||
_ => return Vec::new(),
|
||||
};
|
||||
app.run(t.cmd, params)
|
||||
let curves: Vec<String> = 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<usize> =
|
||||
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) {
|
||||
|
||||
@@ -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);
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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"}
|
||||
]
|
||||
@@ -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"}
|
||||
]
|
||||
@@ -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"}
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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?` | |
|
||||
|
||||
Reference in new issue
Block a user