Files
OneCAD/crates/ui-egui/src/scenario.rs
T
tbreckleandClaude Opus 5.5 cb5144b6e2 fix(sketch): constraint glyphs move off dimension values
A horizontal or vertical glyph sat beside its line on a fixed side, so a
dimension placed on that side drew its value box on top of the glyph.
A glyph that would overlap a dimension's value now goes to the other side
of its line, or steps along it when both sides are taken.

The scenario `glyphs` check also fails when a glyph overlaps a dimension
value; constraint_glyphs_clear_of_dimensions covers it.

Fixes #175

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:14:02 +02:00

934 lines
47 KiB
Rust

//! 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": "solid.extrude"}`: a toolbar click; `{"key": "E", "shift"?, "cmd"?}`; `{"text": "20"}`
//! - `{"widget": "Revolute", "near"?: "Type"}`: click a dialog field, choice or button by its
//! text (with `near`: the one in that label's row)
//! - `{"click": AT, "double"?, "shift"?, "ctrl"?, "button"?}`, `{"move": AT}`,
//! `{"drag": [AT, AT], "shift"?, "ctrl"?, "steps"?}` where AT is `[x, y]` (screen), `{"world": [x,y,z]}`
//! or `{"sketch": [x,y]}` (the sketch being edited)
//! - `{"queued": "engine.execute", "params": {…}, "min_frames"?: n, "max_frames"?: n}`: a request sent the way an
//! agent's arrive (through the app's request queue, answered between frames, so the agent
//! cursor can act it out first); fails if the answer came in fewer than `min_frames` frames
//! - `{"call": "ui.view", "params": {…}}`: any other control request (`fail: true` expects an
//! error)
//! - `{"make_image": "pic.png"}`: a picture in the scenario's folder
//! - `{"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 (`"widgets"`: also every widget's text; `"handles"`: every published
//! handle, browser rows as `row:<label>` and their fold arrows as `fold:<label>`)
//! - `{"camera": {"yaw": rad, "pitch": rad}}` puts the camera at an orbit; `{"view_anim": "top",
//! "max_deg": 10}` animates to a view and fails if any frame turns the view more than that
//! - AT may also be `{"plane": "XY"}` (the middle of an origin plane's square), `{"axis": "Z"}`,
//! `{"dimension": "d1"}`, `{"handle": "arrow"}` (a manipulator handle: see [`publish_handle`]),
//! `{"browser": "Bodies"}` (a browser row by its label) or `{"chevron": "Bodies"}` (its fold arrow)
//! - `{"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
//!
//! 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};
thread_local! {
/// Drag handles drawn this frame (name, screen point): manipulators publish theirs so
/// scenarios (and the control channel's `ui.at {handle}`) can grab them.
static HANDLES: RefCell<Vec<(String, egui::Pos2)>> = const { RefCell::new(Vec::new()) };
/// Painted parts with no widget of their own (the toolbar, the workspace switcher…): rects
/// and counts by name, for checks.
static RECTS: RefCell<Vec<(String, egui::Rect)>> = const { RefCell::new(Vec::new()) };
static COUNTS: RefCell<Vec<(String, f64)>> = const { RefCell::new(Vec::new()) };
}
/// A manipulator's handle drawn this frame at `at` (screen), by name: "arrow" (a dialog's value
/// arrow), "triad_x"… Call it from the code that draws the handle, every frame it shows.
pub fn publish_handle(name: &str, at: egui::Pos2) {
// The latest wins (lookups search from the end), so publishing is cheap for browser rows.
HANDLES.with(|h| h.borrow_mut().push((name.to_string(), at)));
}
/// Where a handle was drawn last frame.
pub fn handle_at(name: &str) -> Option<egui::Pos2> {
HANDLES.with(|h| h.borrow().iter().rfind(|(n, _)| n == name).map(|x| x.1))
}
/// Every handle drawn last frame whose name starts with `prefix`.
pub fn handles(prefix: &str) -> Vec<(String, egui::Pos2)> {
HANDLES.with(|h| h.borrow().iter().filter(|(n, _)| n.starts_with(prefix)).cloned().collect())
}
/// A painted part's rect this frame, by name ("toolbar", "workspace_switcher").
pub fn publish_rect(name: &str, r: egui::Rect) {
RECTS.with(|h| h.borrow_mut().push((name.to_string(), r)));
}
pub fn rect_of(name: &str) -> Option<egui::Rect> {
RECTS.with(|h| h.borrow().iter().rfind(|(n, _)| n == name).map(|x| x.1))
}
/// A number about this frame's drawing, by name ("axis_view_highlights": the axis view's highlighted tips).
pub fn publish_count(name: &str, v: f64) {
COUNTS.with(|h| h.borrow_mut().push((name.to_string(), v)));
}
pub fn count_of(name: &str) -> Option<f64> {
COUNTS.with(|h| h.borrow().iter().rfind(|(n, _)| n == name).map(|x| x.1))
}
/// Forget last frame's handles, rects and counts (called as a frame starts).
pub fn clear_handles() {
HANDLES.with(|h| h.borrow_mut().clear());
RECTS.with(|h| h.borrow_mut().clear());
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};
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>>>,
/// The app's request queue, opened by the first queued request.
queue: Option<std::sync::mpsc::Sender<ControlRequest>>,
time: f64,
size: egui::Vec2,
}
impl Default for Harness {
fn default() -> Self {
Harness::new()
}
}
impl Drop for Harness {
/// The scenario's files go with it (kept with SOLVECRAFT_SCENARIO_KEEP set).
fn drop(&mut self) {
if std::env::var_os("SOLVECRAFT_SCENARIO_KEEP").is_none() {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
}
impl Harness {
pub fn new() -> 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)),
queue: None,
time: 0.0,
size: egui::vec2(1600.0, 1000.0),
};
h.install();
h.ctx.enable_accesskit();
h.frames(3);
h
}
/// File pickers answer like a person would: save into the scenario's folder, open the file
/// the scenario chose.
fn install(&mut self) {
// Frames are laid out, never painted.
self.app.viewport.no_pixels = true;
let dir = self.dir.clone();
self.app.services.pick_save = Some(Box::new(move |name, _| Some(dir.join(name).to_string_lossy().into_owned())));
let next = self.next_file.clone();
self.app.services.pick_open = Some(Box::new(move || next.borrow_mut().take()));
}
/// Autosave into the scenario's recovery folder (as the desktop app does into the user's).
fn start_autosave(&mut self) {
crate::recovery_ui::start(&mut self.app, &self.dir.join("recovery"), std::time::Duration::ZERO);
}
/// 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();
self.output = out.platform_output;
}
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() && self.app.viewport.orbit_snap.is_none() {
break;
}
self.frame();
}
self.frames(4);
}
/// One control request, then frames until its input has been handled.
/// Send a request through the app's queue, as the control channel does, and run frames
/// until it is answered: (the reply, frames it took).
pub fn call_queued(&mut self, method: &str, params: Value) -> (Value, usize) {
let tx = self.queue.get_or_insert_with(|| {
let (tx, rx) = std::sync::mpsc::channel();
self.app.control_rx = Some(rx);
tx
});
let (req, rx) = ControlRequest::new(method, params);
if tx.send(req).is_err() {
return (json!({"ok": false, "error": "the request queue is closed"}), 0);
}
for n in 1..=3000 {
self.frame();
if let Ok(v) = rx.try_recv() {
self.settle();
return (v, n);
}
}
(json!({"ok": false, "error": "no answer in 3000 frames"}), 3000)
}
pub fn call(&mut self, method: &str, params: Value) -> Value {
if method == "ui.resize"
&& let (Some(w), Some(h)) = (params["width"].as_f64(), params["height"].as_f64())
&& w.is_finite()
&& h.is_finite()
&& w > 0.0
&& h > 0.0
{
self.size = egui::vec2(w as f32, h as f32);
}
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
}
/// 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]> {
self.widgets(text).into_iter().next()
}
/// The widget showing `text` nearest the label `near` (the field in a label's row).
pub fn widget_near(&self, text: &str, near: &str) -> Option<[f64; 2]> {
let [lx, ly] = self.widgets(near).into_iter().next()?;
self.widgets(text).into_iter().filter(|[x, _]| *x > lx).min_by(|a, b| {
let d = |p: &[f64; 2]| (p[1] - ly).abs() * 4.0 + (p[0] - lx).abs();
d(a).total_cmp(&d(b))
})
}
/// Centres of the visible widgets whose label or value is `text`, last drawn first.
fn widgets(&self, text: &str) -> Vec<[f64; 2]> {
let Some(tree) = self.output.accesskit_update.as_ref() else { return Vec::new() };
let centre =
|n: &egui::accesskit::Node| n.bounds().filter(|b| b.x1 > b.x0 && b.y1 > b.y0).map(|b| [(b.x0 + b.x1) / 2.0, (b.y0 + b.y1) / 2.0]);
let exact: Vec<[f64; 2]> =
tree.nodes.iter().rev().filter(|(_, n)| n.label() == Some(text) || n.value() == Some(text)).filter_map(|(_, n)| centre(n)).collect();
if !exact.is_empty() {
return exact;
}
// A menu button's label ends with its shortcut ("Save Ctrl+S").
tree.nodes
.iter()
.rev()
.filter(|(_, n)| {
n.role() == egui::accesskit::Role::Button && n.label().is_some_and(|l| l.strip_prefix(text).is_some_and(|r| r.starts_with(' ')))
})
.filter_map(|(_, n)| centre(n))
.collect()
}
/// 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}")),
}
}
/// Animate the view to `v` (as the axis view does), sampling the camera every frame: no
/// frame may turn the view (its right or back vector) by more than `max_deg`, the last
/// included, so the move ends without a jump.
fn view_anim(&mut self, v: &str, max_deg: f64) -> Result<(), String> {
let basis = |c: &solvecraft_engine::render::Camera| c.basis();
let mut prev = basis(&self.app.cam);
self.app.animate_view(v);
if self.app.cam_anim.is_none() && self.app.viewport.orbit_snap.is_none() {
return Err(format!("view_anim {v}: no animation started"));
}
let mut worst = (0.0f64, 0usize);
for i in 0..240 {
self.frame();
let now = basis(&self.app.cam);
let ang = |a: solvecraft_engine::geom::Vec3, b: solvecraft_engine::geom::Vec3| a.dot(b).clamp(-1.0, 1.0).acos().to_degrees();
let d = ang(prev.0, now.0).max(ang(prev.2, now.2));
if d > worst.0 {
worst = (d, i);
}
prev = now;
if self.app.cam_anim.is_none() && self.app.viewport.orbit_snap.is_none() {
if worst.0 > max_deg {
return Err(format!("view_anim {v}: frame {} of {} turns the view {:.1}° (limit {max_deg}°)", worst.1 + 1, i + 1, worst.0));
}
return Ok(());
}
}
Err(format!("view_anim {v}: still animating after 240 frames"))
}
/// 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(text) = s.get("widget").and_then(Value::as_str) {
// A dialog field, choice or button, found by its text.
let found = match s.get("near").and_then(Value::as_str) {
Some(near) => self.widget_near(text, near),
None => self.widget(text),
};
let [x, y] = found.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}))
} 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"), "ctrl": flag("ctrl")}))
} else if let Some(m) = s.get("queued").and_then(Value::as_str) {
let (v, frames) = self.call_queued(m, s.get("params").cloned().unwrap_or(json!({})));
let min = s.get("min_frames").and_then(Value::as_u64).unwrap_or(0);
if (frames as u64) < min {
return Err(format!("{m} was answered after {frames} frames: the agent cursor did not act it out ({min})"));
}
if let Some(max) = s.get("max_frames").and_then(Value::as_u64)
&& frames as u64 > max
{
return Err(format!("{m} was answered after {frames} frames, more than {max}"));
}
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) {
// "$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) {
// 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(c) = s.get("camera") {
// Put the camera at an orbit: {"yaw": rad, "pitch": rad}.
if let Some(y) = c.get("yaw").and_then(Value::as_f64) {
self.app.cam.yaw = y;
}
if let Some(p) = c.get("pitch").and_then(Value::as_f64) {
self.app.cam.pitch = p;
}
self.app.cam_anim = None;
self.frames(2);
return Ok(());
} else if let Some(v) = s.get("view_anim").and_then(Value::as_str) {
return self.view_anim(v, s.get("max_deg").and_then(Value::as_f64).unwrap_or(10.0));
} else if s.get("autosave").is_some() {
self.start_autosave();
return Ok(());
} else if let Some(how) = s.get("restart").and_then(Value::as_str) {
// "crash": the app dies (its autosave lock goes with it, the entry stays);
// "close": it closes normally. Then a new app starts on the same folders.
let mut old = std::mem::replace(&mut self.app, SolveApp::new(Session::default(), Services::default()));
if how == "close" {
crate::recovery_ui::close(&mut old);
// The desktop saves its settings on close and loads them on the next start.
let prefs = old.prefs();
self.app.load_prefs(&prefs);
}
drop(old);
self.ctx = egui::Context::default();
self.ctx.enable_accesskit();
self.install();
self.start_autosave();
self.frames(3);
return Ok(());
} else if let Some(f) = s.get("make_image").and_then(Value::as_str) {
// A picture to insert (a 200 x 100 checkerboard PNG in the scenario's folder).
let img = image::RgbImage::from_fn(200, 100, |x, y| {
if (x / 20 + y / 20) % 2 == 0 { image::Rgb([230, 230, 230]) } else { image::Rgb([60, 90, 160]) }
});
img.save(self.dir.join(f)).map_err(|e| format!("make_image: {e}"))?;
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!({}));
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
);
if s["debug"] == json!("widgets") {
let texts: Vec<String> = self
.output
.accesskit_update
.iter()
.flat_map(|t| t.nodes.iter())
.filter_map(|(_, n)| {
let at = n.bounds().map(|b| format!(" @{:.0},{:.0}", (b.x0 + b.x1) / 2.0, (b.y0 + b.y1) / 2.0)).unwrap_or_default();
n.label().or(n.value()).map(|l| format!("{:?} {l}{at}", n.role()))
})
.collect();
eprintln!("widgets: {texts:?}");
}
if s["debug"] == json!("handles") {
let names: Vec<String> = HANDLES.with(|h| h.borrow().iter().map(|(n, p)| format!("{n} @{:.0},{:.0}", p.x, p.y)).collect());
eprintln!("handles: {names:?}");
}
return Ok(());
} else if let Some(e) = s.get("until") {
// Background work (a sample being built, a preview): frames until the checks pass.
let mut last = String::new();
for _ in 0..600 {
match check(self, e) {
Ok(()) => return Ok(()),
Err(m) => last = m,
}
std::thread::sleep(std::time::Duration::from_millis(20));
self.frames(2);
}
return Err(format!("timed out: {last}"));
} 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), `glyphs` ({constraint id: count}, none overlapping one another or a dimension value), `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();
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:?}"));
}
}
"shown_handles" | "hidden_handles" => {
// Published handles ("fold:Bodies", "row:Body1") drawn (or not) this frame.
for n in v.as_array().into_iter().flatten().filter_map(Value::as_str) {
if handle_at(n).is_some() != (k == "shown_handles") {
return Err(format!("{n}: want {}", if k == "shown_handles" { "shown" } else { "hidden" }));
}
}
}
"handle_order" => {
// Published handles left to right in this order.
let xs: Vec<(String, Option<f32>)> =
v.as_array().into_iter().flatten().filter_map(Value::as_str).map(|n| (n.to_string(), handle_at(n).map(|p| p.x))).collect();
if xs.iter().any(|x| x.1.is_none()) || xs.windows(2).any(|w| w[0].1 >= w[1].1) {
return Err(format!("handle order: got {xs:?}"));
}
}
"camera" => {
// {"camera": {"yaw_deg": y, "pitch_deg": p, "back"?: [x,y,z]}}: where the camera
// looks from (yaw and pitch in its up-axis frame, ±0.5°),
// once any view animation has finished.
for _ in 0..240 {
if h.app.cam_anim.is_none() {
break;
}
h.frame();
}
let (yaw, pitch) = (h.app.cam.yaw.to_degrees(), h.app.cam.pitch.to_degrees());
let near = |got: f64, want: &Value| want.as_f64().is_none_or(|w| ((got - w + 540.0).rem_euclid(360.0) - 180.0).abs() < 0.5);
if !near(yaw, &v["yaw_deg"]) || !near(pitch, &v["pitch_deg"]) {
return Err(format!("camera yaw {yaw:.2}°, pitch {pitch:.2}°, want {v}"));
}
// "back": the world direction from the target to the eye.
if let Some(want) = v["back"].as_array() {
let b = h.app.cam.back();
let w = |i: usize| want.get(i).and_then(Value::as_f64).unwrap_or(f64::NAN);
if (b.x - w(0)).abs() > 1e-3 || (b.y - w(1)).abs() > 1e-3 || (b.z - w(2)).abs() > 1e-3 {
return Err(format!("camera looks from {b:?}, want {want:?}"));
}
}
}
"count" => {
// {"count": {"name": n, "value": x}}: a published count.
let n = v["name"].as_str().unwrap_or_default();
let got = count_of(n);
if got != v["value"].as_f64() {
return Err(format!("count {n}: got {got:?}, want {}", v["value"]));
}
}
"spans" => {
// {"spans": {"inner": a, "outer": b, "axis": "y", "tol": px}}: published rect a
// covers b's extent along the axis (within tol).
let (a, b) = (v["inner"].as_str().unwrap_or_default(), v["outer"].as_str().unwrap_or_default());
let (ra, rb) = (rect_of(a).ok_or(format!("no rect {a}"))?, rect_of(b).ok_or(format!("no rect {b}"))?);
let tol = v["tol"].as_f64().unwrap_or(4.0) as f32;
let (lo, hi) = if v["axis"] == "x" { (ra.x_range(), rb.x_range()) } else { (ra.y_range(), rb.y_range()) };
if (lo.min - hi.min).abs() > tol || (lo.max - hi.max).abs() > tol {
return Err(format!("{a} {lo:?} does not span {b} {hi:?} (±{tol})"));
}
}
"dialog_docked" => {
// The command dialog: docked at the viewport's right, its width in [min, max].
let r = h.ctx.memory(|m| m.area_rect(egui::Id::new("sc_dialog"))).ok_or("no command dialog")?;
let vp = h.app.viewport.rect.unwrap_or(egui::Rect::NOTHING);
let (lo, hi) = (v["min"].as_f64().unwrap_or(260.0) as f32, v["max"].as_f64().unwrap_or(380.0) as f32);
if r.width() < lo - 0.5 || r.width() > hi + 0.5 {
return Err(format!("dialog width {:.0}, want {lo}..{hi}", r.width()));
}
if (r.right() - vp.right()).abs() > 2.0 || r.top() < vp.top() || r.bottom() > vp.bottom() + 1.0 {
return Err(format!("dialog {r:?} is not docked right inside the viewport {vp:?}"));
}
}
"windows_fit" => {
// Every open window lies on screen and is at most `max_width` wide.
let screen = h.ctx.content_rect();
let max_w = v["max_width"].as_f64().unwrap_or(720.0) as f32;
let rects: Vec<(egui::Id, egui::Rect)> = h.ctx.memory(|m| {
m.areas()
.visible_layer_ids()
.into_iter()
.filter(|l| l.order == egui::Order::Middle)
.filter_map(|l| m.area_rect(l.id).map(|r| (l.id, r)))
.collect()
});
for (id, r) in rects {
if r.width() > max_w || !screen.expand(1.0).contains_rect(r) {
return Err(format!("window {id:?} at {r:?} (width {:.0}) does not fit {screen:?} at ≤{max_w}", r.width()));
}
}
}
"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"]));
}
}
"glyphs" => {
// {constraint id: glyphs drawn for it}; glyphs never overlap one another.
let g = crate::sketch_tools::glyph_positions();
for (id, n) in v.as_object().ok_or("glyphs takes {constraint id: count}")? {
let got = g.iter().filter(|(x, _)| x == id).count();
if n.as_u64() != Some(got as u64) {
return Err(format!("glyphs of {id}: got {got}, want {n} (drawn: {g:?})"));
}
}
for (i, (a, p)) in g.iter().enumerate() {
if let Some((b, _)) = g.iter().skip(i + 1).find(|(_, q)| q.distance(*p) < 14.0) {
return Err(format!("glyphs of {a} and {b} overlap at {p:?}"));
}
let r = egui::Rect::from_center_size(*p, egui::vec2(14.0, 14.0));
if let Some((d, _)) = crate::dim_view::text_rects().into_iter().find(|(_, t)| t.intersects(r)) {
return Err(format!("glyph of {a} at {p:?} overlaps the value of dimension {d}"));
}
}
}
"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]));
}
}
"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()));
}
}
"file_contains" => {
// {file, text}: a saved text file holds the text.
let p = h.dir.join(v["file"].as_str().unwrap_or_default());
let body = std::fs::read_to_string(&p).unwrap_or_default();
let t = v["text"].as_str().unwrap_or_default();
if !body.contains(t) {
return Err(format!("{} does not contain `{t}`", p.display()));
}
}
"hidden" => {
// Bodies hidden in the view.
let h = &ui["visibility"]["hidden_bodies"];
if h != v {
return Err(format!("hidden bodies: got {h}, want {v}"));
}
}
"canvas_width" => {
// The first canvas's width (mm).
let w = h.app.session.doc.canvases.first().map(|c| c.width).unwrap_or(f64::NAN);
approx(w, v, "canvas width")?;
}
"section" => {
if h.app.session.section.is_some() != v.as_bool().unwrap_or(false) {
return Err(format!("section view: want {v}"));
}
}
"camera_matches" => {
// The camera is at the saved named view (yaw, pitch, target).
let n = v.as_str().unwrap_or_default();
let nv = h.app.session.doc.named_views.iter().find(|x| x.name == n).ok_or_else(|| format!("no named view {n}"))?;
let c = &h.app.cam;
if (c.yaw - nv.yaw).abs() > 1e-6 || (c.pitch - nv.pitch).abs() > 1e-6 || c.target.dist(nv.target) > 1e-6 {
return Err(format!("camera is not at view {n}: yaw {} pitch {}, view yaw {} pitch {}", c.yaw, c.pitch, nv.yaw, nv.pitch));
}
}
"named_views" => {
let n = h.app.session.doc.named_views.len();
if v.as_u64() != Some(n as u64) {
return Err(format!("named views: got {n}, want {v}"));
}
}
"bbox" => {
// {body, min?: [x|null, y|null, z|null], max?: […]}: where a body is (±1e-3 mm).
let name = v["body"].as_str().unwrap_or_default();
// Where it is in the assembly (measured as placed).
let m = h.call("engine.execute", json!({"command": "inspect.measure", "params": {"bodies": [name]}}))["result"].clone();
let b = m["bodies"].as_array().and_then(|a| a.first()).cloned().ok_or_else(|| format!("no body {name}: {m}"))?;
for side in ["min", "max"] {
for (i, want) in v[side].as_array().into_iter().flatten().enumerate() {
let (Some(w), Some(g)) = (want.as_f64(), b["bbox"][side].get(i).and_then(Value::as_f64)) else { continue };
if (w - g).abs() > 1e-3 {
return Err(format!("{name} bbox {side}[{i}]: got {g}, want {w}"));
}
}
}
}
"sketch_curves" => {
// {sketch?, linked?, own?}: curves projected (linked) and drawn in a sketch.
let mut params = json!({});
if let Some(n) = v.get("sketch") {
params["sketch"] = n.clone();
}
let si = h.call("engine.execute", json!({"command": "sketch.inspect", "params": params}))["result"].clone();
let curves = si["curves"].as_array().cloned().unwrap_or_default();
let linked = curves.iter().filter(|c| !c["link"].is_null()).count();
let own = curves.len() - linked;
for (k, got) in [("linked", linked), ("own", own)] {
if let Some(w) = v.get(k).and_then(Value::as_u64)
&& w != got as u64
{
return Err(format!("sketch curves {k}: got {got}, want {w}"));
}
}
}
"body_volume" => {
// {body, volume}: one body's volume.
let name = v["body"].as_str().unwrap_or_default();
let m = h.call("engine.execute", json!({"command": "inspect.measure", "params": {"bodies": [name]}}))["result"].clone();
let got = m["bodies"][0]["volume_mm3"].as_f64().unwrap_or(f64::NAN);
approx(got, &v["volume"], &format!("{name} volume"))?;
}
"linked_world_range" => {
// {"x"|"y"|"z": [min, max]}: where the active sketch's projected (linked) points lie
// in the world (±1e-3 mm).
let si = h.call("engine.execute", json!({"command": "sketch.inspect", "params": {}}))["result"].clone();
let pts: Vec<Value> = si["points"].as_array().into_iter().flatten().filter(|p| !p["link"].is_null()).cloned().collect();
if pts.is_empty() {
return Err("the sketch has no projected points".into());
}
for (i, axis) in ["x", "y", "z"].iter().enumerate() {
let Some(want) = v.get(*axis) else { continue };
let xs: Vec<f64> = pts.iter().filter_map(|p| p["world"][i].as_f64()).collect();
let (lo, hi) = xs.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), &x| (a.min(x), b.max(x)));
let (wl, wh) = (want[0].as_f64().unwrap_or(f64::NAN), want[1].as_f64().unwrap_or(f64::NAN));
if (lo - wl).abs() > 1e-3 || (hi - wh).abs() > 1e-3 {
return Err(format!("projected points span {axis} {lo:.3}..{hi:.3}, want {wl}..{wh}"));
}
}
}
"sketch_world" => {
// {sketch, point, at: [x,y,z]}: where a sketch point is in the world (±1e-3 mm).
let si = h.call("engine.execute", json!({"command": "sketch.inspect", "params": {"sketch": v["sketch"]}}))["result"].clone();
let p = si["points"]
.as_array()
.into_iter()
.flatten()
.find(|p| p["id"] == v["point"])
.cloned()
.ok_or_else(|| format!("no point {}", v["point"]))?;
for i in 0..3 {
let (g, w) = (p["world"][i].as_f64().unwrap_or(f64::NAN), v["at"][i].as_f64().unwrap_or(f64::NAN));
if (g - w).abs() > 1e-3 {
return Err(format!("{} of {} is at {}, want {}", v["point"], v["sketch"], p["world"], v["at"]));
}
}
}
"shown" | "not_shown" => {
// A widget with this text is (not) on screen.
let t = v.as_str().unwrap_or_default();
if h.widget(t).is_some() != (k == "shown") {
return Err(format!("widget `{t}`: want {}", if k == "shown" { "shown" } else { "not shown" }));
}
}
"home" => {
if ui["home"] != *v {
return Err(format!("start page open: got {}, want {v}", ui["home"]));
}
}
"prefs" => {
// {key: value…}: those entries of `ui.prefs` (preferences, `dialog`, `dark`…).
let got = h.call("ui.prefs", json!({}))["result"].clone();
for (pk, pv) in v.as_object().into_iter().flatten() {
if got[pk.as_str()] != *pv {
return Err(format!("preference {pk}: got {}, want {pv}", got[pk.as_str()]));
}
}
}
"file_busy" | "quit_requested" | "quitting" => {
if ui[k] != *v {
return Err(format!("{k}: got {}, want {v}", ui[k]));
}
}
"documents" => {
if ui["documents"] != *v {
return Err(format!("open designs: got {}, want {v}", ui["documents"]));
}
}
"name" => {
if doc["name"] != *v {
return Err(format!("design name: got {}, want {v}", doc["name"]));
}
}
"browser_has" | "browser_lacks" => {
// Browser rows on screen, by label.
let r = h.call("ui.browser", json!({}));
let labels: Vec<String> =
r["result"]["rows"].as_array().into_iter().flatten().filter_map(|x| x["label"].as_str().map(str::to_string)).collect();
for want in v.as_array().into_iter().flatten().filter_map(Value::as_str) {
let there = labels.iter().any(|l| l == want);
if there != (k.as_str() == "browser_has") {
return Err(format!("browser row `{want}` {}: rows {labels:?}", if there { "still shown" } else { "missing" }));
}
}
}
"browser_selected" => {
// Nothing selected and no component picked (a fold click selects nothing).
let r = h.call("ui.browser", json!({}));
let none = r["result"]["selection"].as_array().is_none_or(|a| a.is_empty())
&& r["result"]["picked_components"].as_array().is_none_or(|a| a.is_empty());
if none == v.as_bool().unwrap_or(false) {
return Err(format!("browser selection: {}", r["result"]));
}
}
"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}`")),
}
}
Ok(())
}