Files
OneCAD-Simulation/crates/ui-egui/src/control.rs
T
jyvikoandClaude Opus 5.5 6bfa0b410a UI: a reference field is always a drop-down; with nothing to pick it offers Create …, and one candidate starts picked (#215)
A reference field (a material, set, step…) with no items was a free-text box; it is now a greyed
drop-down ("No materials yet") with a button that opens the command making one (the
collection's requirement provider). That form comes back to this one on OK, with the new item
picked, or as it was on Cancel. A required reference with one candidate, or with the browser's
selection of its kind, starts picked when the form opens, so the fields and the JSON view agree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-14 20:34:24 -04:00

882 lines
43 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Programmatic control of the running app (agents, tests). The desktop app reads JSON lines
//! from its socket and hands each request here; replies are `{"ok": true, "result": …}` or
//! `{"ok": false, "error": "…"}`. docs/control-protocol.md lists the methods.
//!
//! Requests are untrusted input: numbers are checked and clamped, and nothing here panics.
//! Adapted from SolidCraft's `crates/ui-egui/src/control.rs` (MIT OR Apache-2.0), see
//! ATTRIBUTION.md.
use std::sync::mpsc::Sender;
use fieldcraft_render::StandardView;
use serde_json::{Value, json};
use crate::capture::CaptureSize;
use crate::{ModelApp, UiState};
/// `ui.render`'s image size when none is given.
const RENDER_DEFAULT: [usize; 2] = [1280, 800];
/// Most files one `ui.drop` drops, and the longest path it takes.
const MAX_DROP: usize = 16;
const MAX_DROP_PATH: usize = 4096;
pub struct ControlRequest {
pub method: String,
pub params: Value,
pub reply: Sender<Value>,
}
impl ControlRequest {
pub fn new(method: impl Into<String>, params: Value) -> (Self, std::sync::mpsc::Receiver<Value>) {
let (tx, rx) = std::sync::mpsc::channel();
(Self { method: method.into(), params, reply: tx }, rx)
}
}
pub enum Outcome {
Done(Value),
/// A window screenshot, answered once a frame is presented.
Screenshot {
path: Option<String>,
},
}
fn ok(v: Value) -> Outcome {
Outcome::Done(json!({"ok": true, "result": v}))
}
fn err(e: impl std::fmt::Display) -> Outcome {
Outcome::Done(json!({"ok": false, "error": e.to_string()}))
}
fn wrap(r: Result<Value, String>) -> Outcome {
match r {
Ok(v) => ok(v),
Err(e) => err(e),
}
}
fn button(name: Option<&str>) -> egui::PointerButton {
match name {
Some("right") => egui::PointerButton::Secondary,
Some("middle") => egui::PointerButton::Middle,
_ => egui::PointerButton::Primary,
}
}
fn key_from(name: &str) -> Option<egui::Key> {
egui::Key::from_name(name).or(match name.to_ascii_lowercase().as_str() {
"enter" | "return" => Some(egui::Key::Enter),
"esc" | "escape" => Some(egui::Key::Escape),
"delete" => Some(egui::Key::Delete),
"space" => Some(egui::Key::Space),
"tab" => Some(egui::Key::Tab),
_ => None,
})
}
/// An image side for `ui.render`: absent gives `default`; anything but a whole number of pixels
/// in 1..=MAX_DIM is an error (the renderer also caps the total pixel count).
fn render_dim(p: &Value, key: &str, default: usize) -> Result<usize, String> {
match p.get(key) {
None | Some(Value::Null) => Ok(default),
Some(v) => v
.as_f64()
.filter(|x| x.fract() == 0.0 && (1.0..=fieldcraft_render::MAX_DIM as f64).contains(x))
.map(|x| x as usize)
.ok_or_else(|| format!("`{key}` must be a whole number of pixels in 1..={}", fieldcraft_render::MAX_DIM)),
}
}
/// The keys that make `ui.select` change the viewport's selection (without them it selects a
/// definition in the browser, `{kind, name}`).
pub const VIEWPORT_SELECT: [&str; 10] = ["filter", "box_mode", "mode", "clear", "at", "box", "geometry", "nodes", "elements", "faces"];
/// The viewport's selection: the filter, the items, their set members and the hover.
pub fn selection_json(app: &ModelApp) -> Value {
let s = &app.pick.selection;
json!({
"filter": app.pick.filter,
"box_mode": app.pick.box_mode,
"count": s.len(),
"capped": s.capped(),
"summary": s.summary(),
"items": s.items().iter().map(crate::selection::Item::to_json).collect::<Vec<_>>(),
"params": s.params(),
"hover": app.pick.hover.as_ref().map(crate::selection::Item::to_json),
})
}
/// The window points in `v`, a list of `n` numbers (two per point).
fn points<const N: usize>(v: &Value, key: &str, what: &str) -> Result<[f32; N], String> {
let bad = || format!("`{key}` must be {what} in window points");
let list = v.as_array().filter(|a| a.len() == N).ok_or_else(bad)?;
let mut out = [0.0; N];
for (slot, x) in out.iter_mut().zip(list) {
*slot = x.as_f64().filter(|x| x.is_finite() && x.abs() <= 1e6).ok_or_else(bad)? as f32;
}
Ok(out)
}
/// `ui.select` on the viewport: set the filter and box mode, then pick by name and label
/// (`geometry`, `nodes`, `elements`, `faces`), at a point (`at`) or in a box (`box`), changing
/// the selection by `mode`; `clear` empties it first.
fn select(app: &mut ModelApp, p: &Value) -> Result<Value, String> {
use crate::selection::{Filter, Mode, items_from_params};
if let Some(f) = p.get("filter") {
app.pick.filter = serde_json::from_value::<Filter>(f.clone())
.map_err(|_| format!("`filter` must be one of {}", Filter::ALL.map(|f| json!(f).as_str().unwrap_or_default().to_string()).join(", ")))?;
}
if let Some(on) = p.get("box_mode") {
app.pick.box_mode = on.as_bool().ok_or("`box_mode` must be true or false")?;
}
let mode = match p.get("mode") {
None => Mode::Replace,
Some(m) => serde_json::from_value::<Mode>(m.clone()).map_err(|_| "`mode` must be replace, add, remove or toggle".to_string())?,
};
let at = p.get("at").map(|v| points::<2>(v, "at", "a point [x, y]")).transpose()?;
let corners = p.get("box").map(|v| points::<4>(v, "box", "[x0, y0, x1, y1]")).transpose()?;
if corners.is_some() && !app.pick.filter.boxes() {
return Err(format!("box selection picks nodes, element faces or elements, not {}", app.pick.filter.label().to_lowercase()));
}
if (at.is_some() || corners.is_some()) && app.viewport.rect.is_none() {
return Err("the viewport has not been drawn yet".into());
}
let items = items_from_params(p)?;
if p.get("clear").and_then(Value::as_bool) == Some(true) {
app.clear_selection();
}
if !items.is_empty() {
app.select(items, mode);
}
if let Some([x, y]) = at {
app.select_at(egui::pos2(x, y), mode);
}
if let Some([x0, y0, x1, y1]) = corners {
app.select_box(egui::pos2(x0, y0), egui::pos2(x1, y1), mode);
}
Ok(selection_json(app))
}
pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome {
let p = &req.params;
let s = |k: &str| p.get(k).and_then(Value::as_str);
let f = |k: &str| p.get(k).and_then(Value::as_f64).filter(|x| x.is_finite());
let b = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(false);
let mods = || egui::Modifiers { shift: b("shift"), ctrl: b("ctrl"), command: b("ctrl"), alt: b("alt"), ..Default::default() };
match req.method.as_str() {
"engine.execute" => {
let Some(id) = s("command") else { return err("missing `command`") };
let params = p.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({}));
wrap(app.run(id, params))
}
"engine.script" => {
let Some(steps) = p.get("commands") else { return err("missing `commands` (a list of {command, params})") };
wrap(app.run_script(steps).map(|()| json!({"journal": app.session.journal.len()})))
}
"engine.commands" => ok(Value::Array(fieldcraft_engine::commands().iter().map(fieldcraft_engine::CommandSpec::listing).collect())),
"ui.inspect" => ok(json!({
"ui": app.ui,
"camera": app.cam,
"viewport": app.viewport.rect.map(|r| json!([r.left(), r.top(), r.width(), r.height()])),
"renderer": if app.viewport.gpu.is_some() { "gpu" } else { "cpu" },
"gpu": app.gpu_info,
"dialog": app.dialog.as_ref().map(|d| json!({"command": d.command, "text": d.text, "error": d.error})),
"status": app.status,
"model": {"nodes": app.session.model.nodes().len(), "elements": app.session.model.elements().len()},
"results": app.session.results_report(),
"probe": app.probe,
"busy": app.busy(),
"solve": solve_json(app),
"selected": app.selected.as_ref().map(|(k, n)| json!({"kind": k, "name": n})),
"nav": app.nav,
"layout": app.layout.iter().map(|(k, r)| (k.to_string(), json!([r.left(), r.top(), r.width(), r.height()]))).collect::<serde_json::Map<String, Value>>(),
"prompt": app.prompt.as_ref().map(|p| match p {
crate::files::Prompt::Unsaved(next) => json!({"unsaved": match next {
crate::files::Replace::File(p) => p.clone(),
crate::files::Replace::Sample(n) => format!("sample {n}"),
}}),
crate::files::Prompt::Recover => json!("recover"),
crate::files::Prompt::Samples => json!("samples"),
crate::files::Prompt::Design(p) => json!({"design": p}),
}),
"recent": app.recent.paths,
"version": crate::VERSION,
})),
"ui.toolbar" => ok(crate::toolbar::describe(app)),
"ui.select" if p.get("kind").is_none() && VIEWPORT_SELECT.iter().any(|k| p.get(*k).is_some()) => wrap(select(app, p)),
"ui.selection" => ok(selection_json(app)),
"ui.select" => {
// Select a definition as a browser click does (its properties show on the right).
let kind = p.get("kind").cloned().and_then(|k| serde_json::from_value::<crate::browser::Def>(k).ok());
match (kind, s("name")) {
(Some(kind), Some(name)) if crate::browser::definition(&app.session.model, kind, name).is_some() => {
app.selected = Some((kind, name.to_string()));
ok(Value::Null)
}
(Some(_), Some(name)) => err(format!("no such definition `{name}`")),
_ => {
app.selected = None;
ok(Value::Null)
}
}
}
"ui.set" => {
let mut cur = serde_json::to_value(&app.ui).unwrap_or(json!({}));
if let (Some(o), Some(src)) = (cur.as_object_mut(), p.as_object()) {
for (k, v) in src {
if !o.contains_key(k) {
return err(format!("unknown setting `{k}`"));
}
o.insert(k.clone(), v.clone());
}
}
match serde_json::from_value::<UiState>(cur) {
Ok(mut u) => {
// The workspace and its tab stay together: a tab brings its workspace, a
// workspace alone its first tab.
if p.get("tab").is_some() {
u.select_tab(u.tab);
} else if p.get("workspace").is_some() {
u.select_workspace(u.workspace);
}
app.ui = u;
ok(json!(app.ui))
}
Err(e) => err(e),
}
}
"ui.view" => {
match s("view").unwrap_or("iso") {
"fit" => app.fit_view(),
"home" => {
app.home_view();
}
v => match StandardView::parse(v) {
Some(sv) => app.set_view(sv),
None => return err(format!("unknown view `{v}` (front, back, left, right, top, bottom, iso, home, fit)")),
},
}
ok(json!(app.cam))
}
"ui.camera" => match fieldcraft_engine::view::orientation(p) {
Ok(o) => {
app.cam.orient(&o);
ok(json!(app.cam))
}
Err(e) => err(e),
},
"ui.start" => {
let Some(id) = s("command") else { return err("missing `command`") };
match app.start_programmatic(id) {
Ok(()) => ok(json!({"dialog": app.dialog.as_ref().map(|d| d.command)})),
Err(e) => err(e),
}
}
"ui.dialog" => {
if app.dialog.is_none() {
return err("no command form is open (ui.start opens one)");
}
if b("cancel") {
app.close_dialog();
return ok(Value::Null);
}
if let (Some(d), Some(params)) = (app.dialog.as_mut(), p.get("params")) {
d.text = serde_json::to_string_pretty(params).unwrap_or_default();
}
// The form's Target row: the viewport's selection into the parameters.
if b("use_selection")
&& let Err(e) = app.use_selection_in_dialog()
{
return err(e);
}
if b("run") {
return wrap(app.run_dialog());
}
ok(json!(app.dialog.as_ref().map(|d| json!({"command": d.command, "text": d.text, "error": d.error}))))
}
"ui.prompt" => {
let Some(prompt) = app.prompt.clone() else { return err("no prompt is open") };
match (prompt, s("answer")) {
(crate::files::Prompt::Unsaved(_), Some(a @ ("save" | "discard" | "cancel"))) => {
let answer = match a {
"save" => crate::files::Answer::Save,
"discard" => crate::files::Answer::Discard,
_ => crate::files::Answer::Cancel,
};
app.answer_unsaved(answer);
ok(json!({"busy": app.busy()}))
}
(crate::files::Prompt::Design(_), Some(a @ ("new" | "keep" | "cancel"))) => {
let answer = match a {
"new" => crate::files::DesignAnswer::New,
"keep" => crate::files::DesignAnswer::Keep,
_ => crate::files::DesignAnswer::Cancel,
};
app.answer_design(answer);
ok(json!({"busy": app.busy()}))
}
(crate::files::Prompt::Samples, Some("open")) => match s("name") {
Some(name) => {
app.prompt = None;
app.open_sample(name);
ok(json!({"prompt": app.prompt.is_some()}))
}
None => err("missing `name` (a sample)"),
},
(crate::files::Prompt::Recover | crate::files::Prompt::Samples, Some("close" | "cancel")) => {
app.prompt = None;
ok(Value::Null)
}
(crate::files::Prompt::Recover, Some("recover")) => match s("id") {
Some(id) => {
app.recover(id);
ok(json!({"busy": app.busy()}))
}
None => err("missing `id` (from file.recovery.list)"),
},
(_, a) => err(format!(
"`answer` {a:?} does not answer this prompt (unsaved: save, discard, cancel; design: new, keep, cancel; recover: recover with id, close; samples: open with name, close)"
)),
}
}
"ui.move" | "ui.click" => {
let (Some(x), Some(y)) = (f("x"), f("y")) else { return err("missing x/y") };
let pos = egui::pos2(x as f32, y as f32);
app.synthetic.push(egui::Event::PointerMoved(pos));
if req.method == "ui.click" {
let (button, modifiers) = (button(s("button")), mods());
// `double: true` sends two clicks in one go, which egui reads as a double click.
for _ in 0..if b("double") { 2 } else { 1 } {
app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: true, modifiers });
app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: false, modifiers });
}
}
ok(Value::Null)
}
"ui.drag" => {
let (Some(x0), Some(y0), Some(x1), Some(y1)) = (f("x0"), f("y0"), f("x1"), f("y1")) else { return err("missing x0/y0/x1/y1") };
let (button, modifiers) = (button(s("button")), mods());
let (a, z) = (egui::pos2(x0 as f32, y0 as f32), egui::pos2(x1 as f32, y1 as f32));
app.synthetic.push(egui::Event::PointerMoved(a));
app.synthetic.push(egui::Event::PointerButton { pos: a, button, pressed: true, modifiers });
let steps = f("steps").unwrap_or(10.0).clamp(1.0, 200.0) as usize;
for k in 1..=steps {
app.synthetic.push(egui::Event::PointerMoved(a + (z - a) * (k as f32 / steps as f32)));
}
app.synthetic.push(egui::Event::PointerButton { pos: z, button, pressed: false, modifiers });
ok(Value::Null)
}
"ui.scroll" => {
let (Some(x), Some(y)) = (f("x"), f("y")) else { return err("missing x/y") };
app.synthetic.push(egui::Event::PointerMoved(egui::pos2(x as f32, y as f32)));
// Wheel lines; negative zooms out, positive zooms in.
let lines = f("delta").unwrap_or(1.0).clamp(-100.0, 100.0);
app.synthetic.push(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Line,
delta: egui::vec2(0.0, lines as f32),
modifiers: Default::default(),
phase: egui::TouchPhase::Move,
});
ok(Value::Null)
}
"ui.key" => {
let Some(key) = s("key").and_then(key_from) else { return err("unknown key") };
let modifiers = egui::Modifiers { shift: b("shift"), command: b("cmd"), ctrl: b("cmd") || b("ctrl"), ..Default::default() };
app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers });
app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers });
ok(Value::Null)
}
"ui.text" => {
app.synthetic.push(egui::Event::Text(s("text").unwrap_or("").to_string()));
ok(Value::Null)
}
"ui.drop" => {
// Files dropped on the window: the next frame takes them with the window system's
// drops, and they open as a picked file opens (the native picker cannot be driven).
let paths: Option<Vec<String>> =
p.get("paths").and_then(Value::as_array).and_then(|a| a.iter().map(|v| v.as_str().map(str::to_string)).collect());
match paths {
Some(paths) if !paths.is_empty() && paths.len() <= MAX_DROP && paths.iter().all(|p| !p.is_empty() && p.len() <= MAX_DROP_PATH) => {
let n = paths.len();
app.synthetic_drops.extend(paths);
ok(json!({"dropped": n}))
}
_ => err(format!("`paths` must be a list of 1 to {MAX_DROP} file paths")),
}
}
"ui.worldToScreen" => {
let pts: Vec<Value> = p
.get("points")
.and_then(Value::as_array)
.into_iter()
.flatten()
.take(1000)
.map(|v| {
let c = |i: usize| v.get(i).and_then(Value::as_f64).unwrap_or(f64::NAN);
crate::viewport::to_screen(app, fieldcraft_render::Vec3::new(c(0), c(1), c(2))).map_or(Value::Null, |q| json!([q.x, q.y]))
})
.collect();
ok(json!(pts))
}
"ui.resize" => {
let (Some(w), Some(h)) = (f("width"), f("height")) else { return err("missing width/height") };
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(w.clamp(320.0, 8192.0) as f32, h.clamp(240.0, 8192.0) as f32)));
ok(Value::Null)
}
"ui.fullscreen" => {
// Into or out of full screen (`on`, default true); the title bar follows next frame.
let on = p.get("on").and_then(Value::as_bool).unwrap_or(true);
ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(on));
ok(json!({"fullscreen": on}))
}
"ui.screenshot" => Outcome::Screenshot { path: s("path").map(str::to_string) },
"ui.capture" => {
let live = window_size(ctx);
let dim = |k: &str, default: f32| match p.get(k) {
None | Some(Value::Null) => Ok(default),
Some(v) => v.as_f64().filter(|x| x.is_finite()).map(|x| x as f32).ok_or_else(|| format!("`{k}` must be a number")),
};
let size = match (dim("width", live.width), dim("height", live.height), dim("scale", live.scale)) {
(Ok(width), Ok(height), Ok(scale)) => CaptureSize { width, height, scale },
(Err(e), ..) | (_, Err(e), _) | (.., Err(e)) => return err(e),
};
Outcome::Done(capture_to(app, size, s("path")))
}
"ui.render" => {
let Some(path) = s("path") else { return err("missing `path`") };
let (w, h) = match (render_dim(p, "width", RENDER_DEFAULT[0]), render_dim(p, "height", RENDER_DEFAULT[1])) {
(Ok(w), Ok(h)) => (w, h),
(Err(e), _) | (_, Err(e)) => return err(e),
};
let png = match app.render_png(w, h) {
Ok(png) => png,
Err(e) => return err(e),
};
match std::fs::write(path, &png) {
Ok(()) => ok(json!({"path": path, "width": w, "height": h, "bytes": png.len()})),
Err(e) => err(format!("{path}: {e}")),
}
}
"app.quit" => {
app.quit_requested = true;
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
ok(Value::Null)
}
other => err(format!("unknown method `{other}`")),
}
}
/// The window's size in points and its pixels per point (1400 × 900 at 1 before a window was
/// laid out).
pub fn window_size(ctx: &egui::Context) -> CaptureSize {
let r = ctx.content_rect();
let fits = |v: f32| v.is_finite() && (1.0..=crate::capture::MAX_SIDE as f32).contains(&v);
let scale = Some(ctx.pixels_per_point()).filter(|s| crate::capture::SCALE_RANGE.contains(s)).unwrap_or(1.0);
if fits(r.width()) && fits(r.height()) {
CaptureSize { width: r.width(), height: r.height(), scale }
} else {
CaptureSize { width: 1400.0, height: 900.0, scale }
}
}
/// Capture the whole window offscreen ([`ModelApp::capture`]) and save it as PNG (a reply).
pub fn capture_to(app: &mut ModelApp, size: CaptureSize, path: Option<&str>) -> Value {
match app.capture(size) {
Ok(image) => {
let mut reply = save_screenshot(&image, path);
if let Some(r) = reply.get_mut("result").and_then(Value::as_object_mut) {
r.insert("offscreen".into(), json!(true));
r.insert("scale".into(), json!(size.scale));
}
reply
}
Err(e) => json!({"ok": false, "error": e}),
}
}
/// Save a screenshot as PNG: at `path`, or `fieldcraft-shot.png` in the temporary folder.
pub fn save_screenshot(image: &egui::ColorImage, path: Option<&str>) -> Value {
let [w, h] = image.size;
let rgba: Vec<u8> = image.pixels.iter().flat_map(|c| c.to_array()).collect();
let Some(png) = fieldcraft_render::png(w, h, &rgba) else { return json!({"ok": false, "error": "could not encode the screenshot"}) };
let path = path.map(str::to_string).unwrap_or_else(|| std::env::temp_dir().join("fieldcraft-shot.png").to_string_lossy().to_string());
match std::fs::write(&path, &png) {
Ok(()) => json!({"ok": true, "result": {"path": path, "width": w, "height": h}}),
Err(e) => json!({"ok": false, "error": format!("{path}: {e}")}),
}
}
/// The title bar's Solve as `ui.inspect` reports it.
fn solve_json(app: &ModelApp) -> Value {
let missing = app.unavailable("solve.run");
json!({"enabled": app.busy().is_none() && missing.is_none(), "unavailable": missing.map(|u| u.to_json())})
}
#[cfg(test)]
mod tests {
use fieldcraft_engine::Session;
use super::*;
fn call(app: &mut ModelApp, method: &str, params: Value) -> Value {
let (req, _rx) = ControlRequest::new(method, params);
match handle(app, &egui::Context::default(), &req) {
Outcome::Done(v) => v,
Outcome::Screenshot { .. } => json!({"screenshot": true}),
}
}
#[test]
fn engine_view_and_settings_methods() {
let mut app = ModelApp::new(Session::default());
let r = call(&mut app, "engine.script", json!({"commands": fieldcraft_engine::sample::script()}));
assert_eq!(r["ok"], true, "{r}");
let r = call(&mut app, "engine.execute", json!({"command": "model.info"}));
assert_eq!(r["result"]["elements"]["hex8"], 64);
assert_eq!(call(&mut app, "engine.execute", json!({"command": "no.such"}))["ok"], false);
assert!(call(&mut app, "engine.commands", json!({}))["result"].as_array().is_some_and(|a| a.len() == fieldcraft_engine::commands().len()));
let r = call(&mut app, "ui.set", json!({"style": "wireframe", "show_nodes": true}));
assert_eq!(r["result"]["style"], "wireframe");
assert_eq!(app.ui.style, crate::Style::Wireframe);
assert_eq!(call(&mut app, "ui.set", json!({"style": "cartoon"}))["ok"], false);
assert_eq!(call(&mut app, "ui.set", json!({"nope": 1}))["ok"], false);
assert_eq!(call(&mut app, "ui.view", json!({"view": "top"}))["ok"], true);
assert!((app.cam.pitch - std::f64::consts::FRAC_PI_2).abs() < 1e-3);
assert_eq!(call(&mut app, "ui.view", json!({"view": "sideways"}))["ok"], false);
// Any orientation in degrees; hostile numbers are refused or clamped and leave a valid camera.
let r = call(&mut app, "ui.camera", json!({"yaw": 90, "pitch": 30, "distance": 250}));
assert!((app.cam.yaw - std::f64::consts::FRAC_PI_2).abs() < 1e-9 && (app.cam.pitch - 30f64.to_radians()).abs() < 1e-9, "{r}");
assert_eq!(r["result"]["distance"], 250.0);
for hostile in [
json!({"yaw": 1}),
json!({"yaw": "x", "pitch": 0}),
json!({"yaw": 0, "pitch": 0, "distance": 0}),
json!({"yaw": 0, "pitch": 0, "spin": 1}),
] {
assert_eq!(call(&mut app, "ui.camera", hostile.clone())["ok"], false, "{hostile}");
}
assert_eq!(app.cam.distance, 250.0);
assert_eq!(call(&mut app, "ui.camera", json!({"yaw": -1e12, "pitch": 1e300, "distance": 1e300}))["ok"], true);
assert!(app.cam.is_valid() && app.cam.pitch < std::f64::consts::FRAC_PI_2);
assert_eq!(call(&mut app, "ui.inspect", json!({}))["result"]["model"]["elements"], 64 + 162);
assert_eq!(call(&mut app, "ui.screenshot", json!({})), json!({"screenshot": true}));
assert_eq!(call(&mut app, "no.method", json!({}))["ok"], false);
}
/// One frame of the whole window (1400 × 900 points), with the control channel's synthetic
/// input, as the desktop app runs it.
fn frame(app: &mut ModelApp, ctx: &egui::Context) {
let mut raw =
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), ..Default::default() };
app.raw_input_hook(&mut raw);
let mut out = ctx.run_ui(raw, |ui| {
app.logic(ui.ctx());
app.ui(ui);
});
out.textures_delta.clear();
}
fn call_in(app: &mut ModelApp, ctx: &egui::Context, method: &str, params: Value) -> Value {
let (req, _rx) = ControlRequest::new(method, params);
match handle(app, ctx, &req) {
Outcome::Done(v) => v,
Outcome::Screenshot { .. } => json!({"screenshot": true}),
}
}
/// The cantilever's free end (x = 100), looked at from +x: picked by a simulated click and
/// by `ui.select`, made into a set through the set form's Target row, and resolved.
#[test]
fn the_cantilevers_end_is_picked_and_made_a_set_over_the_control_channel() {
let mut app = ModelApp::new(Session::default());
app.open_sample("cantilever");
let ctx = egui::Context::default();
assert_eq!(call_in(&mut app, &ctx, "ui.view", json!({"view": "right"}))["ok"], true);
app.fit_view();
frame(&mut app, &ctx);
let viewport = app.viewport.rect.unwrap();
// A click on the end cell at y ∈ [0, 5], z ∈ [5, 10]: element 1 + 19 + 0·20 + 1·40, face 4.
let at = crate::viewport::to_screen(&app, fieldcraft_render::Vec3::new(100.0, 2.5, 7.5)).unwrap();
assert_eq!(call_in(&mut app, &ctx, "ui.select", json!({"filter": "element_faces"}))["result"]["count"], 0);
call_in(&mut app, &ctx, "ui.click", json!({"x": at.x, "y": at.y}));
for _ in 0..4 {
frame(&mut app, &ctx);
}
let r = call_in(&mut app, &ctx, "ui.selection", json!({}));
assert_eq!(r["result"]["items"], json!([{"element": 60, "face": 4}]), "{r}");
// What is under the pointer is the hover.
assert_eq!(r["result"]["hover"], json!({"element": 60, "face": 4}));
// The same pick by `ui.select {at}`, added to with Cmd (toggle) removes it again.
let r = call_in(&mut app, &ctx, "ui.select", json!({"at": [at.x, at.y], "mode": "toggle"}));
assert_eq!(r["result"]["count"], 0, "{r}");
// A box over the whole viewport holds the end's eight element faces (the rest are hidden).
let corners = [viewport.left(), viewport.top(), viewport.right(), viewport.bottom()];
let r = call_in(&mut app, &ctx, "ui.select", json!({"box": corners}));
assert_eq!(r["result"]["count"], 8, "{r}");
assert!(r["result"]["items"].as_array().unwrap().iter().all(|f| f["face"] == 4));
// The set form's Target row takes them; the set resolves to the end's faces and nodes.
call_in(&mut app, &ctx, "ui.start", json!({"command": "model.set.create"}));
let r = call_in(&mut app, &ctx, "ui.dialog", json!({"params": {"name": "end"}, "use_selection": true, "run": true}));
assert_eq!(r["ok"], true, "{r}");
let r = call_in(&mut app, &ctx, "engine.execute", json!({"command": "model.set.resolve", "params": {"name": "end"}}));
assert_eq!((r["result"]["faces"]["count"].as_u64(), r["result"]["nodes"]["count"].as_u64()), (Some(8), Some(15)), "{r}");
// A boundary condition's Target row makes a set of its own and names it.
call_in(&mut app, &ctx, "ui.start", json!({"command": "model.boundary_condition.create"}));
let r = call_in(&mut app, &ctx, "ui.dialog", json!({"params": {"name": "Hold", "type": "fixed"}, "use_selection": true, "run": true}));
assert_eq!(r["ok"], true, "{r}");
let bc = serde_json::to_value(app.session.model.boundary_conditions().get("Hold").unwrap()).unwrap();
assert_eq!(bc["target"], json!({"set": "Hold-set"}), "{bc}");
assert_eq!(app.session.model.resolve_set("Hold-set").unwrap().faces.len(), 8);
// Nodes in the box: the end's 3 × 5, none of those behind it.
let r = call_in(&mut app, &ctx, "ui.select", json!({"filter": "nodes", "box": corners}));
assert_eq!(r["result"]["count"], 15, "{r}");
// Escape clears the selection.
call_in(&mut app, &ctx, "ui.key", json!({"key": "Escape"}));
frame(&mut app, &ctx);
assert_eq!(call_in(&mut app, &ctx, "ui.selection", json!({}))["result"]["count"], 0);
}
/// Text typed into one form never lands in the next one (#219): Young's modulus is focused
/// and typed into, Plasticity opens straight over it, and its yield stress stays empty, both
/// when the text arrives in the same frame as the switch and when it is typed afterwards.
#[test]
fn typing_never_crosses_into_the_next_form() {
let ctx = egui::Context::default();
for same_frame in [true, false] {
let mut app = ModelApp::new(Session::default());
// Plasticity needs a material to act on.
app.run("model.material.create", json!({"name": "Steel", "elastic": {"type": "isotropic", "young": 1, "poisson": 0.3}})).unwrap();
call_in(&mut app, &ctx, "ui.start", json!({"command": "model.material.create"}));
for _ in 0..2 {
frame(&mut app, &ctx);
}
let opening = app.dialog.as_ref().unwrap().opening;
let form = egui::Id::new(("fc_form", "model.material.create", opening));
let young = ctx.read_response(egui::Id::new(("fc_form", form, "elastic/young"))).expect("the Young's modulus field").rect.center();
call_in(&mut app, &ctx, "ui.click", json!({"x": young.x, "y": young.y}));
for _ in 0..3 {
frame(&mut app, &ctx);
}
assert!(ctx.memory(|m| m.focused()).is_some(), "the click focuses Young's modulus");
if !same_frame {
call_in(&mut app, &ctx, "ui.start", json!({"command": "model.material.plasticity"}));
}
call_in(&mut app, &ctx, "ui.text", json!({"text": "210000"}));
if same_frame {
call_in(&mut app, &ctx, "ui.start", json!({"command": "model.material.plasticity"}));
}
for _ in 0..3 {
frame(&mut app, &ctx);
}
let d = app.dialog.as_ref().unwrap();
assert_eq!(d.command, "model.material.plasticity");
assert!(!d.text.contains("210000"), "same frame {same_frame}: {}", d.text);
assert!(ctx.memory(|m| m.focused()).is_none(), "the new form does not inherit the focus");
}
}
#[test]
fn hostile_selections_are_refused_or_capped() {
let mut app = ModelApp::new(Session::default());
let ctx = egui::Context::default();
// Before the viewport is drawn, a point has nothing to pick on.
assert_eq!(call_in(&mut app, &ctx, "ui.select", json!({"at": [10, 10]}))["ok"], false);
// An empty scene picks nothing anywhere.
frame(&mut app, &ctx);
for filter in ["faces", "edges", "vertices", "bodies", "nodes", "element_faces", "elements"] {
let r = call_in(&mut app, &ctx, "ui.select", json!({"filter": filter, "at": [700, 450]}));
assert_eq!(r["result"]["count"], 0, "{filter}: {r}");
}
for bad in [
json!({"filter": "atoms"}),
json!({"mode": "xor", "nodes": [1]}),
json!({"at": [1e300, 5]}),
json!({"at": "here"}),
json!({"at": [1, 2, 3]}),
json!({"box": [0, 0, 1]}),
json!({"box": [0, f64::MAX, 1, 1]}),
json!({"filter": "faces", "box": [0, 0, 100, 100]}),
json!({"nodes": [0]}),
json!({"faces": [{"element": 1}]}),
json!({"geometry": [{"body": "B", "face": ""}]}),
json!({"elements": vec![1; crate::selection::MAX_SELECTION + 1]}),
] {
let r = call_in(&mut app, &ctx, "ui.select", bad.clone());
assert_eq!(r["ok"], false, "{bad}: {r}");
}
// A huge selection is capped and says so.
let all: Vec<u32> = (1..=crate::selection::MAX_SELECTION as u32).collect();
call_in(&mut app, &ctx, "ui.select", json!({"nodes": all}));
let r = call_in(&mut app, &ctx, "ui.select", json!({"elements": [1, 2], "mode": "add"}));
assert_eq!((r["result"]["count"].as_u64(), r["result"]["capped"].as_bool()), (Some(crate::selection::MAX_SELECTION as u64), Some(true)));
// The form's Target row: no form, a command without a target.
assert_eq!(call_in(&mut app, &ctx, "ui.dialog", json!({"use_selection": true}))["ok"], false);
call_in(&mut app, &ctx, "ui.start", json!({"command": "mesh.node.add"}));
assert_eq!(call_in(&mut app, &ctx, "ui.dialog", json!({"use_selection": true}))["ok"], false);
// `ui.select` with a definition still selects in the browser; `clear` empties the viewport's.
assert_eq!(call_in(&mut app, &ctx, "ui.select", json!({"clear": true}))["result"]["count"], 0);
frame(&mut app, &ctx);
}
#[test]
fn the_command_form_is_driven_after_ui_start() {
let mut app = ModelApp::new(Session::default());
assert_eq!(call(&mut app, "ui.dialog", json!({"run": true}))["ok"], false);
assert_eq!(call(&mut app, "ui.start", json!({"command": "mesh.node.add"}))["result"]["dialog"], "mesh.node.add");
let r = call(&mut app, "ui.dialog", json!({"params": {"label": 3, "xyz": [1, 2, 3]}, "run": true}));
assert_eq!(r["ok"], true, "{r}");
assert!(app.dialog.is_none() && app.session.model.node(3).is_some());
}
/// `ui.drop` stands in for the window system: the next frame opens the files as a drop does.
#[test]
fn dropped_files_open_as_a_drop_does() {
let dir = std::env::temp_dir().join(format!("fieldcraft-drop-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let design = dir.join("plate.solidcraft");
std::fs::write(&design, fieldcraft_geometry::sample::plate_design(&fieldcraft_geometry::sample::Plate::default()).unwrap()).unwrap();
let mut app = ModelApp::new(Session::default());
let r = call(&mut app, "ui.drop", json!({"paths": [design.to_string_lossy()]}));
assert_eq!(r["result"]["dropped"], 1, "{r}");
assert!(app.session.design.is_none(), "nothing opens before the next frame");
app.logic(&egui::Context::default());
assert_eq!(app.busy(), Some("Opening plate.solidcraft"));
app.wait_loaded();
assert_eq!(app.session.design.as_ref().map(|d| d.bodies.len()), Some(1));
assert_eq!(app.status.as_ref().map(|s| s.text.as_str()), Some("Opened plate.solidcraft"));
for bad in [json!({}), json!({"paths": []}), json!({"paths": [""]}), json!({"paths": [3]}), json!({"paths": vec!["a.solidcraft"; 17]})] {
assert_eq!(call(&mut app, "ui.drop", bad.clone())["ok"], false, "{bad}");
}
assert!(app.synthetic_drops.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn ui_render_writes_a_png_of_the_model() {
let mut app = ModelApp::new(Session::default());
app.run_script(&fieldcraft_engine::sample::script()).unwrap();
let path = std::env::temp_dir().join(format!("fieldcraft-render-{}.png", std::process::id()));
let r = call(&mut app, "ui.render", json!({"path": path.to_string_lossy(), "width": 120, "height": 80}));
assert_eq!(r["ok"], true, "{r}");
let bytes = std::fs::read(&path).unwrap();
assert!(bytes.starts_with(&[0x89, b'P', b'N', b'G']));
let _ = std::fs::remove_file(&path);
assert_eq!(call(&mut app, "ui.render", json!({}))["ok"], false);
}
#[test]
fn ui_render_refuses_hostile_sizes() {
let mut app = ModelApp::new(Session::default());
app.run_script(&fieldcraft_engine::sample::script()).unwrap();
let path = std::env::temp_dir().join(format!("fieldcraft-render-hostile-{}.png", std::process::id()));
let path = path.to_string_lossy();
for (w, h) in [
(json!(100_000), json!(100_000)),
(json!(8192), json!(8192)),
(json!(1e12), json!(10)),
(json!(-5), json!(10)),
(json!(0), json!(10)),
(json!(10.5), json!(10)),
(json!("big"), json!(10)),
(json!(f64::MAX), json!(f64::MIN)),
] {
let r = call(&mut app, "ui.render", json!({"path": path, "width": w, "height": h}));
assert_eq!(r["ok"], false, "{w} x {h}: {r}");
}
assert!(std::fs::metadata(&*path).is_err(), "nothing is written for a refused size");
}
#[test]
fn hostile_requests_never_panic() {
let mut app = ModelApp::new(Session::default());
let hostile = [
json!(null),
json!([]),
json!({"x": 1e300, "y": f64::MAX, "x0": -1e308, "y0": 0, "x1": 1e308, "y1": 1, "steps": 1e12, "delta": 1e300}),
json!({"width": 1e12, "height": -5, "path": ""}),
json!({"command": 5, "params": "x", "commands": {"a": 1}}),
json!({"key": "NoSuchKey", "text": 7, "view": null, "points": [[1], "x", null]}),
json!({"tab": "nope", "command": "mesh.tet.generate"}),
json!({"tab": 3}),
];
for method in [
"engine.execute",
"engine.script",
"ui.set",
"ui.view",
"ui.camera",
"ui.start",
"ui.dialog",
"ui.move",
"ui.click",
"ui.drag",
"ui.scroll",
"ui.key",
"ui.text",
"ui.worldToScreen",
"ui.resize",
"ui.render",
"ui.capture",
"ui.toolbar",
"ui.prompt",
"ui.select",
"ui.selection",
"ui.fullscreen",
"ui.drop",
] {
for p in &hostile {
let _ = call(&mut app, method, p.clone());
}
}
assert!(app.synthetic.len() < 1000);
}
#[test]
fn the_toolbar_lists_every_entry_and_switches_tabs() {
let mut app = ModelApp::new(Session::default());
assert_eq!(call(&mut app, "ui.set", json!({"tab": "mesh"}))["ok"], true);
let r = call(&mut app, "ui.toolbar", json!({}));
assert_eq!(r["result"]["tab"], "mesh", "{r}");
let entries = r["result"]["entries"].as_array().unwrap();
assert_eq!(entries.len(), fieldcraft_engine::catalogue::CATALOGUE.len());
let soon = entries.iter().find(|e| e["id"] == "mesh.order.quadratic").unwrap();
assert_eq!((soon["coming_soon"].as_str(), soon["command"].is_null()), (Some("M2"), true));
assert_eq!(call(&mut app, "ui.set", json!({"tab": "nope"}))["ok"], false);
// A workspace brings its first tab; a tab brings its workspace.
let r = call(&mut app, "ui.set", json!({"workspace": "model"}));
assert_eq!((r["result"]["workspace"].as_str(), r["result"]["tab"].as_str()), (Some("model"), Some("properties")));
let r = call(&mut app, "ui.set", json!({"tab": "solve"}));
assert_eq!(r["result"]["workspace"], "analysis");
let r = call(&mut app, "ui.start", json!({"command": "mesh.order.quadratic"}));
assert_eq!((r["ok"].as_bool(), r["error"].as_str()), (Some(false), Some("Quadratic is coming soon (M2)")));
}
#[test]
fn ui_capture_saves_the_whole_window() {
let mut app = ModelApp::new(Session::default());
let path = std::env::temp_dir().join(format!("fieldcraft-capture-{}.png", std::process::id()));
let r = call(&mut app, "ui.capture", json!({"path": path.to_string_lossy(), "width": 640, "height": 400, "scale": 1}));
assert_eq!(
(r["ok"].as_bool(), r["result"]["width"].as_u64(), r["result"]["offscreen"].as_bool()),
(Some(true), Some(640), Some(true)),
"{r}"
);
assert!(std::fs::read(&path).unwrap().starts_with(&[0x89, b'P', b'N', b'G']));
let _ = std::fs::remove_file(&path);
for bad in [json!({"width": "x"}), json!({"width": 1e9}), json!({"scale": 0}), json!({"height": -1})] {
assert_eq!(call(&mut app, "ui.capture", bad.clone())["ok"], false, "{bad}");
}
}
#[test]
fn screenshots_are_saved_as_png() {
let image = egui::ColorImage::filled([4, 3], egui::Color32::RED);
let path = std::env::temp_dir().join(format!("fieldcraft-shot-{}.png", std::process::id()));
let r = save_screenshot(&image, Some(&path.to_string_lossy()));
assert_eq!(r["ok"], true, "{r}");
assert!(std::fs::read(&path).unwrap().starts_with(&[0x89, b'P', b'N', b'G']));
let _ = std::fs::remove_file(&path);
}
}