Files
OneCAD-Simulation/crates/ui-egui/src/control.rs
T
jyvikoandClaude Opus 5.5 441f03d093 ui: Help › Crash Reports lists the saved reports to report, copy or delete (#300)
diagnostics.crash_reports.delete deletes one report or all. The window lists each report
(when, what, how many times) with Report…, Copy Path and Delete, and Delete All; ui.prompt
answers it over the control channel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 11:07:46 -04:00

1116 lines
57 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)
}
}
/// The key of the interim reply to a request that waits for a background job
/// ([`Outcome::Pending`]): the server then waits for the final reply however long the job takes,
/// since the job always answers (its result, its error, or that it was cancelled).
pub const PENDING: &str = "pending";
pub enum Outcome {
Done(Value),
/// The reply comes when the background job finishes: the request's sender waits in
/// [`crate::files::Loading::waiters`].
Pending,
/// 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))
}
/// The privacy choices, for `ui.inspect` and `ui.prompt`.
fn privacy_json(app: &ModelApp) -> Value {
let p = app.prefs();
json!({
"available": app.usage_available(),
"usage_sharing": p.usage_sharing,
"usage_automation": p.usage_automation,
"recording": fieldcraft_engine::usage::enabled(),
"queued": fieldcraft_engine::usage::queued(),
})
}
/// Answer `req` when the running job finishes; with none running, at once.
fn wait_job(app: &mut ModelApp, req: &ControlRequest) -> Outcome {
match app.loading.as_mut() {
Some(l) => {
l.waiters.push(req.reply.clone());
Outcome::Pending
}
None => ok(json!({"job": null})),
}
}
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() };
// A crash nothing catches (#248) still says which request came last.
fieldcraft_engine::crash::activity(&format!("control {}", req.method));
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!({}));
// A solve or a mesher runs as a job: the window keeps drawing its progress, and the
// reply comes when it is done however long that takes (or at once with `"wait":
// false`, then `job.status` / `job.wait` / `job.cancel`).
if crate::files::is_long(id) {
return match app.start_job(id, params) {
Err(e) => err(e),
Ok(job) if p.get("wait").and_then(Value::as_bool) == Some(false) => ok(json!({"job": job})),
Ok(_) => wait_job(app, req),
};
}
wrap(app.run(id, params))
}
"job.status" => ok(json!({"job": app.loading.as_ref().map(|l| l.to_json(app.now))})),
"job.wait" => wait_job(app, req),
"job.cancel" => {
let job = app.loading.as_ref().map(|l| l.to_json(app.now));
app.cancel_loading();
ok(json!({"cancelled": job}))
}
"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(),
"job": app.loading.as_ref().map(|l| l.to_json(app.now)),
"solve": solve_json(app),
"selected": app.selected.as_ref().map(|(k, n)| json!({"kind": k, "name": n})).or_else(|| app.selected_seed.as_ref().map(|e| json!({"kind": "seed", "edge": e}))),
"browser": crate::browser::describe(app),
"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::Replace::Update => "update".to_string(),
crate::files::Replace::New => "new".to_string(),
}}),
crate::files::Prompt::Recover => json!("recover"),
crate::files::Prompt::Samples => json!("samples"),
crate::files::Prompt::Design(p) => json!({"design": p}),
crate::files::Prompt::ReplaceMesh(r) => json!({"replace_mesh": r.question, "warnings": r.warnings}),
crate::files::Prompt::Delete(d) => json!({"delete": d.question(), "kind": d.kind, "name": d.name, "users": d.users, "allowed": d.allowed()}),
crate::files::Prompt::Crashed(r) => json!({"crashed": r}),
crate::files::Prompt::CrashReports(r) => json!({"crash_reports": r}),
crate::files::Prompt::ReportBug => json!({"report_bug": app.report.as_ref().and_then(|f| f.drafted_json())}),
crate::files::Prompt::Consent => json!({"consent": crate::privacy::QUESTION}),
crate::files::Prompt::Settings => json!("settings"),
crate::files::Prompt::Update => json!({"update": app.update_json()}),
}),
"privacy": privacy_json(app),
"updates": app.update_json(),
"recent": app.recent.paths,
"version": crate::VERSION,
})),
"ui.toolbar" => ok(crate::toolbar::describe(app)),
"ui.settings" => {
app.open_settings();
ok(privacy_json(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());
}
// The form's OK button, as a click runs it: a mesher that would replace a mesh asks
// first (`ui.inspect`'s `prompt`, answered with `ui.prompt`).
if b("submit") {
app.submit_dialog();
return ok(json!({"prompt": app.prompt.is_some(), "busy": app.busy()}));
}
ok(json!(app.dialog.as_ref().map(|d| json!({"command": d.command, "text": d.text, "error": d.error}))))
}
"ui.item" => {
// An item menu's entry, as a right click on the item's browser row and a click on the
// entry do (crate::menu).
let kind = s("kind");
let name = s("name").unwrap_or_default().to_string();
let target = match kind {
Some("body") => crate::menu::Target::Body(name),
Some("surface") => crate::menu::Target::Surface(name),
Some(k) => match serde_json::from_value::<crate::browser::Def>(json!(k)) {
Ok(def) => crate::menu::Target::Def(def, name),
Err(_) => return err(format!("unknown item kind `{}`", k.chars().take(40).collect::<String>())),
},
None => return err("missing `kind`"),
};
let action = match (s("action"), s("step")) {
(Some("edit"), _) => crate::menu::Action::Edit,
(Some("rename"), _) => crate::menu::Action::Rename,
(Some("suppress"), _) => crate::menu::Action::Suppress(true),
(Some("unsuppress"), _) => crate::menu::Action::Suppress(false),
(Some("hide"), _) => crate::menu::Action::Hide,
(Some("show"), _) => crate::menu::Action::Show,
(Some("isolate"), _) => crate::menu::Action::Isolate,
(Some("show_all"), _) => crate::menu::Action::ShowAll,
(Some("delete"), _) => crate::menu::Action::Delete,
(Some(a @ ("activate" | "deactivate")), Some(step)) => {
crate::menu::Action::Active { step: step.to_string(), active: a == "activate" }
}
(a, _) => {
return err(format!(
"`action` {a:?}: edit, rename, suppress, unsuppress, hide, show, isolate, show_all, delete, or activate/deactivate with `step`"
));
}
};
crate::menu::apply(app, target, action);
ok(json!({"status": app.status, "prompt": app.prompt.is_some(), "dialog": app.dialog.as_ref().map(|d| d.command)}))
}
"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::ReplaceMesh(_), Some(a @ ("replace" | "cancel"))) => {
app.answer_replace_mesh(a == "replace");
ok(json!({"busy": app.busy()}))
}
(crate::files::Prompt::Delete(_), Some(a @ ("delete" | "cancel"))) => {
app.answer_delete(a == "delete");
ok(json!({"status": app.status}))
}
(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::Crashed(r), Some("report")) => {
app.report_bug(r.first().map(|c| c.id.clone()));
ok(json!({"prompt": "report_bug"}))
}
(crate::files::Prompt::CrashReports(_), Some("report")) => match s("id") {
Some(id) => {
app.report_bug(Some(id.to_string()));
ok(json!({"prompt": "report_bug"}))
}
None => err("missing `id` (a crash report)"),
},
(crate::files::Prompt::CrashReports(_), Some("delete")) => match s("id") {
Some(id) => wrap(app.delete_crash_reports(Some(id))),
None => err("missing `id` (a crash report)"),
},
(crate::files::Prompt::CrashReports(_), Some("delete_all")) => wrap(app.delete_crash_reports(None)),
(crate::files::Prompt::Crashed(_) | crate::files::Prompt::CrashReports(_), Some("close" | "cancel")) => {
app.prompt = None;
ok(Value::Null)
}
// The form's fields; sending opens the browser, which only a click does.
(crate::files::Prompt::ReportBug, Some("set")) => wrap(app.set_report(p)),
(crate::files::Prompt::Consent, Some(a @ ("share" | "decline"))) => {
app.answer_consent(a == "share");
ok(privacy_json(app))
}
(crate::files::Prompt::Settings, Some("set")) if p.get("update_check").is_some() || p.get("update_skip").is_some() => {
let automatic = p.get("update_check").and_then(Value::as_bool).unwrap_or(app.prefs().update_check);
let skip = p.get("update_skip").and_then(Value::as_str).map(str::to_string);
app.set_update_prefs(automatic, skip);
ok(app.update_json())
}
(crate::files::Prompt::Update, Some(a @ ("restart" | "later"))) => {
app.answer_update(a == "restart");
ok(app.update_json())
}
(crate::files::Prompt::Settings, Some("set")) => {
if !app.usage_available() {
return err("this run cannot share usage (no analytics key, or FIELDCRAFT_TELEMETRY=0, CI or automation)");
}
let sharing = p.get("usage_sharing").and_then(Value::as_bool).unwrap_or(app.prefs().usage_sharing == Some(true));
let automation = p.get("usage_automation").and_then(Value::as_bool).unwrap_or(app.prefs().usage_automation);
app.set_usage(sharing, automation);
ok(privacy_json(app))
}
(crate::files::Prompt::Settings, Some("close" | "cancel")) => {
app.prompt = None;
ok(Value::Null)
}
(crate::files::Prompt::ReportBug, Some("close" | "cancel")) => {
app.prompt = None;
app.report = 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; replace_mesh: replace, cancel; delete: delete, cancel; recover: recover with id, close; samples: open with name, close; crashed: report, close; report_bug: set with title/observed/share_session/include_model, close; consent: share, decline; settings: set with usage_sharing/usage_automation or update_check/update_skip, close; update: restart, later)"
)),
}
}
"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),
};
// Sizes on screen are in points: at the window's pixels per point unless given, so a
// render at device resolution matches the window (#285).
let scale =
match p.get("scale") {
None | Some(Value::Null) => Ok(window_size(ctx).scale),
Some(v) => v.as_f64().map(|x| x as f32).filter(|x| crate::capture::SCALE_RANGE.contains(x)).ok_or_else(|| {
format!("`scale` must be from {} to {}", crate::capture::SCALE_RANGE.start(), crate::capture::SCALE_RANGE.end())
}),
};
let scale = match scale {
Ok(s) => s,
Err(e) => return err(e),
};
let png = match app.render_png(w, h, f64::from(scale)) {
Ok(png) => png,
Err(e) => return err(e),
};
match std::fs::write(path, &png) {
Ok(()) => ok(json!({"path": path, "width": w, "height": h, "scale": scale, "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}),
// A job's reply comes when it ends.
Outcome::Pending => {
app.wait_loaded();
rx.try_recv().unwrap_or(Value::Null)
}
}
}
#[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}),
// A job's reply comes when it ends.
Outcome::Pending => {
app.wait_loaded();
rx.try_recv().unwrap_or(Value::Null)
}
}
}
/// A meshed model's Materials node shows a highlighted ghost row; a click on it opens the
/// material form, and `ui.inspect` reports each node's state.
#[test]
fn a_ghost_row_click_starts_its_command() {
let mut app = ModelApp::new(Session::default());
let ctx = egui::Context::default();
let cube = [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0], [0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]];
app.run("mesh.block", json!({"body": "B", "kind": "hex8", "corners": cube, "divisions": [1, 1, 1]})).unwrap();
frame(&mut app, &ctx);
let browser = call_in(&mut app, &ctx, "ui.inspect", json!({}))["result"]["browser"].clone();
let materials = browser.as_array().and_then(|b| b.iter().find(|n| n["node"] == "materials")).cloned().unwrap_or_default();
assert_eq!(materials["state"], "next", "{browser}");
assert_eq!(materials["add"][0]["command"], "model.material.create");
let at = app.layout[crate::browser::ghost_region(fieldcraft_engine::progress::Stage::Material)].center();
call_in(&mut app, &ctx, "ui.click", json!({"x": at.x, "y": at.y}));
for _ in 0..4 {
frame(&mut app, &ctx);
}
assert_eq!(app.dialog.as_ref().map(|d| d.command), Some("model.material.create"));
}
/// 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 names the set that has exactly the selection.
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": "end"}), "{bc}");
// 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}");
// A selection no set has: Use selection names a set made only on OK, so Cancel leaves
// the model as it was (#337).
let r = call_in(&mut app, &ctx, "ui.select", json!({"nodes": [1, 2]}));
assert_eq!(r["result"]["count"], 2, "{r}");
let sets = app.session.model.sets().len();
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": "Pin", "type": "fixed"}, "use_selection": true}));
assert_eq!(r["ok"], true, "{r}");
assert_eq!(app.session.model.sets().len(), sets);
app.close_dialog();
assert_eq!(app.session.model.sets().len(), sets, "Cancel leaves no set behind");
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": "Pin", "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("Pin").unwrap()).unwrap();
assert_eq!(bc["target"], json!({"set": "Pin-set"}), "{bc}");
assert_eq!(app.session.model.resolve_set("Pin-set").unwrap().nodes, [1, 2]);
assert_eq!(app.selected, Some((crate::browser::Def::BoundaryCondition, "Pin".into())), "what OK made is selected");
// 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.item",
"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);
}
}