UI scenarios, batch 2: edit an early sketch from the timeline, components with a revolute joint driven to 90°, sheet metal base and edge flange to flat pattern and DXF, STEP export, new design and STEP insert with a hole on the imported face, parameters table drives the model, sketch constraints workflow (dimensions, delete and undo, fix, construction), rectangular pattern. Harness: widgets found by their text through the accessibility tree, file pickers answered from the scenario's folder, model renders at shot steps (SOLVECRAFT_SCENARIO_SHOTS), query/file checks; ui.at takes a dimension. Fix: a click on a sketch point under a dimension line (a diameter through the centre) selects the point; only the dimension's text takes precedence

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 10:15:21 +00:00
1 parent 717b181082
commit 187e9a97b9
13 files changed
+1115 -6

No files matched your search

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