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:
bherbruckandClaude Opus 5.5 committed 2026-10-08 09:32:49 +00:00
1 parent 62dbf60eb2
commit d5b8aa0375
12 files changed
+802 -6

No files matched your search

+39
View File
@@ -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);
+24 -3
View File
@@ -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(&param).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);
+1
View File
@@ -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;
+269
View File
@@ -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(())
}
+35 -3
View File
@@ -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) {
+25
View File
@@ -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"
}
]
+2
View File
@@ -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?` | |