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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
717b181082
commit
187e9a97b9
13 files changed
+1115
-6
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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}`")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user