forked from 1b-agent/fieldcraft-upstream
ui: jobs with progress, refusals listed, datum preview, form filters and defaults
- A solve or a mesher runs as a job with one progress mechanism: the overlay shows the step, stage, count and elapsed time with a bar when bounded, OK waits while it runs, and the status sums it up (elements, smallest angle, time) (#322, #357). - Over the control channel such a command is a job too: answered when it ends however long it runs, or at once with wait: false, then job.status, job.wait, job.cancel; ui.inspect gives job; MCP waits for it (#367). - A command that cannot run yet lists what it misses in the context panel, each with a button to the command that provides it (#323). - A cancelled or refused solve stays in its workspace (#324, test). - A mesher form starts from the parameters of the mesh it would replace (#343). - Seed Edges and edge/vertex/face datum points switch the selection filter while open and restore it (#341). - Datum forms draw the datum live, the Partition form shows its datum, and new datums and partitions get the next free name (#347). Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
1 parent
3eaca26246
commit
4ee855bb27
11 files changed
+447
-28
No files matched your search
@@ -115,6 +115,11 @@ fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
}
|
||||
ctx.request_repaint();
|
||||
let mut r = rrx.recv_timeout(REPLY_TIMEOUT).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
|
||||
// A request waiting for a job (a solve, a mesher): its reply comes when the job
|
||||
// ends, however long it runs; the job always answers, even when cancelled.
|
||||
if r.get(fieldcraft_ui_egui::control::PENDING).is_some() {
|
||||
r = rrx.recv().unwrap_or_else(|_| json!({"ok": false, "error": "the job ended without a reply"}));
|
||||
}
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
}
|
||||
|
||||
@@ -342,6 +342,18 @@ fn datum_of(s: &Session, kind: &str, p: &Value) -> Result<Datum> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The datum a datum form's `params` define so far, for drawing it before OK; `None` when
|
||||
/// `command` makes no datum or the parameters do not define one yet. The model is not changed.
|
||||
pub fn preview(s: &Session, command: &str, params: &Value) -> Option<Datum> {
|
||||
let kind = match command {
|
||||
"geometry.datum" | "geometry.datum.edit" => "plane",
|
||||
"geometry.datum.axis" | "geometry.datum.axis.edit" => "axis",
|
||||
"geometry.datum.point" | "geometry.datum.point.edit" => "point",
|
||||
_ => return None,
|
||||
};
|
||||
datum_of(s, kind, params).ok()
|
||||
}
|
||||
|
||||
/// The parameters of the command that edits datum `name`, describing it as it is (a plane by
|
||||
/// its point and normal, an axis by its point and direction, a point by its coordinates); and
|
||||
/// that command.
|
||||
|
||||
@@ -15,6 +15,7 @@ mod hex;
|
||||
pub(crate) mod mesh;
|
||||
|
||||
pub use datum::edit_params as datum_edit_params;
|
||||
pub use datum::preview as datum_preview;
|
||||
pub use mesh::analytic_surfaces;
|
||||
mod mirror;
|
||||
mod model;
|
||||
|
||||
@@ -164,9 +164,10 @@ impl Remote {
|
||||
match TcpStream::connect_timeout(&sa, Duration::from_millis(800)) {
|
||||
Ok(s) => {
|
||||
s.set_nodelay(true).ok();
|
||||
// Meshing and solving big models take a while; the app answers a request it
|
||||
// has not finished within 60 s with a timeout of its own.
|
||||
s.set_read_timeout(Some(Duration::from_secs(600))).ok();
|
||||
// Meshing and solving big models take as long as they take: the app answers
|
||||
// a solve or a mesher when its job ends (docs/control-protocol.md, Jobs), and
|
||||
// a closed app closes the connection.
|
||||
s.set_read_timeout(None).ok();
|
||||
let read = s.try_clone()?;
|
||||
self.conn = Some((BufReader::new(read), s));
|
||||
return Ok(());
|
||||
|
||||
@@ -17,7 +17,12 @@ pub const WIDTH: f32 = 310.0;
|
||||
|
||||
/// Whether the panel has anything to show.
|
||||
pub fn has_context(app: &mut ModelApp) -> bool {
|
||||
app.dialog.is_some() || app.selected.is_some() || app.selected_seed.is_some() || app.reply.is_some() || app.plot().is_some()
|
||||
app.dialog.is_some()
|
||||
|| app.refused.is_some()
|
||||
|| app.selected.is_some()
|
||||
|| app.selected_seed.is_some()
|
||||
|| app.reply.is_some()
|
||||
|| app.plot().is_some()
|
||||
}
|
||||
|
||||
pub fn panel(app: &mut ModelApp, ui: &mut egui::Ui) {
|
||||
@@ -37,6 +42,10 @@ pub fn panel(app: &mut ModelApp, ui: &mut egui::Ui) {
|
||||
form(app, ui, &t);
|
||||
ui.add_space(10.0);
|
||||
}
|
||||
if app.refused.is_some() {
|
||||
refused(app, ui, &t);
|
||||
ui.add_space(10.0);
|
||||
}
|
||||
if let Some((kind, name)) = app.selected.clone() {
|
||||
properties(app, ui, &t, kind, &name);
|
||||
ui.add_space(10.0);
|
||||
@@ -67,6 +76,35 @@ pub fn panel(app: &mut ModelApp, ui: &mut egui::Ui) {
|
||||
});
|
||||
}
|
||||
|
||||
/// A command that cannot run yet: what it misses, each with a button that starts the command
|
||||
/// that provides it (as the browser's ghost rows do).
|
||||
fn refused(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
let Some(u) = app.refused.clone() else { return };
|
||||
let mut close = false;
|
||||
heading(ui, t, Level::Title, |ui| {
|
||||
ui.label(RichText::new(format!("{} — NOT YET", crate::command_label(u.command).to_uppercase())).font(label_font(ui.ctx(), 12.0)));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
close = ui.small_button("×").on_hover_text("Dismiss").clicked();
|
||||
});
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
ui.label(RichText::new("It needs:").color(t.text_dim));
|
||||
let mut start = None;
|
||||
for fieldcraft_engine::requirement::Missing(r) in &u.missing {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label(RichText::new(format!("• {}", r.what())).color(t.text));
|
||||
if ui.button(format!("{}…", r.place())).on_hover_text(r.provider()).clicked() {
|
||||
start = Some(r.provider());
|
||||
}
|
||||
});
|
||||
}
|
||||
if close {
|
||||
app.refused = None;
|
||||
} else if let Some(command) = start {
|
||||
let _ = app.start(command);
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON shorter than this is shown pretty-printed in the JSON view.
|
||||
const MAX_PRETTY: usize = 100_000;
|
||||
|
||||
@@ -76,6 +114,8 @@ const MAX_PRETTY: usize = 100_000;
|
||||
/// coupling) has a Target row that takes the viewport's selection.
|
||||
fn form(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
let selected = (!app.pick.selection.is_empty()).then(|| app.pick.selection.summary());
|
||||
// While a job runs (this form's mesher, a solve) OK waits for it.
|
||||
let running = app.busy().map(str::to_string);
|
||||
let units = app.session.model.units();
|
||||
let session = &app.session;
|
||||
// The set Use selection will make on OK is offered with the others.
|
||||
@@ -191,9 +231,12 @@ fn form(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
ui.label(RichText::new(spec.params).small().color(t.text_dim));
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
let ready = invalid == 0 && missing.is_empty();
|
||||
let not_ready =
|
||||
|| if invalid > 0 { format!("{missing}\nA field holds something that is not a value").trim().to_string() } else { missing.clone() };
|
||||
let ready = invalid == 0 && missing.is_empty() && running.is_none();
|
||||
let not_ready = || match &running {
|
||||
Some(job) => format!("{job} is running: wait for it or cancel it"),
|
||||
None if invalid > 0 => format!("{missing}\nA field holds something that is not a value").trim().to_string(),
|
||||
None => missing.clone(),
|
||||
};
|
||||
let mut attempted = false;
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
@@ -201,7 +244,7 @@ fn form(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
let ok =
|
||||
ui.add_enabled(ready, egui::Button::new(RichText::new("OK").color(egui::Color32::WHITE)).fill(t.accent).min_size(vec2(64.0, 0.0)));
|
||||
// A click on the greyed OK shows every problem, beside its field.
|
||||
attempted = !ready && ok.contains_pointer() && ui.input(|i| i.pointer.primary_clicked());
|
||||
attempted = !ready && running.is_none() && ok.contains_pointer() && ui.input(|i| i.pointer.primary_clicked());
|
||||
run = ok
|
||||
.on_disabled_hover_ui(|ui| {
|
||||
ui.label(not_ready());
|
||||
|
||||
@@ -33,8 +33,16 @@ impl ControlRequest {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
@@ -170,6 +178,17 @@ fn privacy_json(app: &ModelApp) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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);
|
||||
@@ -182,8 +201,25 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
"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()})))
|
||||
@@ -201,6 +237,7 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
"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),
|
||||
@@ -634,10 +671,15 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
fn call(app: &mut ModelApp, method: &str, params: Value) -> Value {
|
||||
let (req, _rx) = ControlRequest::new(method, params);
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -692,10 +734,15 @@ mod tests {
|
||||
}
|
||||
|
||||
fn call_in(app: &mut ModelApp, ctx: &egui::Context, method: &str, params: Value) -> Value {
|
||||
let (req, _rx) = ControlRequest::new(method, params);
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+154
-9
@@ -14,7 +14,10 @@
|
||||
//! ATTRIBUTION.md.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::mpsc::{Receiver, TryRecvError};
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc::{Receiver, Sender, TryRecvError};
|
||||
|
||||
use fieldcraft_model::progress::Progress;
|
||||
|
||||
use fieldcraft_engine::Session;
|
||||
use serde_json::{Value, json};
|
||||
@@ -115,6 +118,15 @@ fn kind_of_command(command: &str) -> Option<Kind> {
|
||||
}
|
||||
|
||||
/// Commands a form runs in the background: they can take a while on a large part.
|
||||
/// Whether `command` may run long: a solve or a mesher, run as a job off the window thread.
|
||||
pub fn is_long(command: &str) -> bool {
|
||||
BACKGROUND.contains(&command)
|
||||
|| matches!(
|
||||
command,
|
||||
"solve.run" | "solve.static" | "solve.nonlinear" | "solve.heat" | "solve.frequency" | "solve.buckle" | "solve.explicit" | "solve.fluid"
|
||||
)
|
||||
}
|
||||
|
||||
pub const BACKGROUND: &[&str] =
|
||||
&["mesh.tet.generate", "mesh.hex.generate", "mesh.hex.sweep", "mesh.hex.mapped", "mesh.hex.blocks", "mesh.hex.octree"];
|
||||
|
||||
@@ -270,6 +282,63 @@ pub struct Loading {
|
||||
/// Whether the window has drawn the job's progress: on the web the work runs (and the page
|
||||
/// stops drawing) only once the user has seen that it runs.
|
||||
shown: bool,
|
||||
/// The command it runs.
|
||||
pub command: &'static str,
|
||||
/// Where the command is, reported from the worker ([`fieldcraft_model::progress`]).
|
||||
pub progress: Arc<Progress>,
|
||||
/// When it started (app time, seconds).
|
||||
pub started: f64,
|
||||
/// Control-channel requests waiting for its reply (`engine.execute` of a long command,
|
||||
/// `job.wait`): each gets the command's result, or why it has none.
|
||||
pub waiters: Vec<Sender<Value>>,
|
||||
}
|
||||
|
||||
impl Loading {
|
||||
/// The job for `ui.inspect` and `job.status`: what runs, where it is and for how long.
|
||||
pub fn to_json(&self, now: f64) -> Value {
|
||||
let p = self.progress.snapshot();
|
||||
json!({
|
||||
"title": self.title,
|
||||
"command": self.command,
|
||||
"elapsed": (now - self.started).max(0.0),
|
||||
"progress": p,
|
||||
"fraction": p.fraction(),
|
||||
"line": p.line(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The overlay's line: `Solving · Step 1/2 Load · Load factor 0.3 of 1 · 12 s`.
|
||||
pub fn line(&self, now: f64) -> String {
|
||||
let p = self.progress.snapshot().line();
|
||||
let elapsed = elapsed((now - self.started).max(0.0));
|
||||
if p.is_empty() { format!("{}… · {elapsed}", self.title) } else { format!("{}… · {p} · {elapsed}", self.title) }
|
||||
}
|
||||
}
|
||||
|
||||
/// What the status bar says when job `command` finished: its `done` text with the time it took,
|
||||
/// and for a mesher the elements made and their worst quality.
|
||||
fn finished(command: &str, done: &str, took: &str, reply: &Value) -> String {
|
||||
let r = &reply["result"];
|
||||
match (r["elements"].as_u64(), r["kind"].as_str()) {
|
||||
(Some(n), Some(kind)) if BACKGROUND.contains(&command) => {
|
||||
let bodies = r["bodies"].as_array().into_iter().flatten();
|
||||
let dihedral = bodies.filter_map(|b| b["quality"]["mesh"]["min_dihedral"].as_f64()).reduce(f64::min);
|
||||
let q = dihedral.map(|d| format!(", smallest dihedral angle {d:.1}°")).unwrap_or_default();
|
||||
let n = fieldcraft_engine::thousands(usize::try_from(n).unwrap_or(usize::MAX));
|
||||
format!("{done}: {n} {kind}{q} in {took} — Mesh › Quality checks them")
|
||||
}
|
||||
_ if is_long(command) => format!("{done} in {took}"),
|
||||
_ => done.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A duration as `0.4 s`, `12 s` or `3 min 05 s`.
|
||||
pub fn elapsed(seconds: f64) -> String {
|
||||
match seconds {
|
||||
s if s < 10.0 => format!("{s:.1} s"),
|
||||
s if s < 60.0 => format!("{s:.0} s"),
|
||||
s => format!("{} min {:02} s", (s / 60.0) as u64, (s % 60.0) as u64),
|
||||
}
|
||||
}
|
||||
|
||||
/// A finished background job whose session has not replaced the window's yet.
|
||||
@@ -396,13 +465,18 @@ fn start(name: String, work: Work) -> Option<Work> {
|
||||
|
||||
/// Run `command` on `session` off the window thread: the reply's receiver, and the work when it
|
||||
/// waits to run on the window thread (the web).
|
||||
fn spawn(session: Session, command: &'static str, params: Value) -> (Receiver<(Box<Session>, Result<Value, String>)>, Option<Work>) {
|
||||
fn spawn(
|
||||
session: Session,
|
||||
command: &'static str,
|
||||
params: Value,
|
||||
progress: Arc<Progress>,
|
||||
) -> (Receiver<(Box<Session>, Result<Value, String>)>, Option<Work>) {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let work = start(
|
||||
format!("fieldcraft {command}"),
|
||||
Box::new(move || {
|
||||
let mut s = session;
|
||||
let r = s.execute(command, ¶ms).map_err(|e| e.to_string());
|
||||
let r = fieldcraft_model::progress::scoped(Some(progress), || s.execute(command, ¶ms)).map_err(|e| e.to_string());
|
||||
let _ = tx.send((Box::new(s), r));
|
||||
}),
|
||||
);
|
||||
@@ -444,9 +518,11 @@ impl ModelApp {
|
||||
} else {
|
||||
self.session.clone()
|
||||
};
|
||||
let (rx, pending) = spawn(base, command, params);
|
||||
let progress = Progress::new();
|
||||
let (rx, pending) = spawn(base, command, params, progress.clone());
|
||||
self.set_status(format!("{title}…"), false);
|
||||
self.loading = Some(Loading { title, done, recent, form: None, rx, pending, shown: false });
|
||||
let started = self.now;
|
||||
self.loading = Some(Loading { title, done, recent, form: None, rx, pending, shown: false, command, progress, started, waiters: Vec::new() });
|
||||
true
|
||||
}
|
||||
|
||||
@@ -558,7 +634,7 @@ impl ModelApp {
|
||||
/// Solve every step (`solve.run`) in the background; the results come up when it is done.
|
||||
pub fn solve_all(&mut self) {
|
||||
if let Some(u) = self.unavailable("solve.run") {
|
||||
self.set_status(u.to_string(), true);
|
||||
self.refuse(u);
|
||||
return;
|
||||
}
|
||||
let n = self.session.model.steps().len();
|
||||
@@ -566,6 +642,27 @@ impl ModelApp {
|
||||
self.begin_job("solve.run", json!({}), false, "Solving".into(), done, None);
|
||||
}
|
||||
|
||||
/// Run `command` as a background job for the control channel: the job's number (its
|
||||
/// opening), or why it cannot start (another job runs, the command cannot run yet).
|
||||
pub fn start_job(&mut self, command: &str, params: Value) -> Result<u64, String> {
|
||||
let spec = fieldcraft_engine::find_command(command).ok_or_else(|| format!("unknown command `{command}`"))?;
|
||||
if let Err(u) = self.session.availability(spec) {
|
||||
return Err(u.to_string());
|
||||
}
|
||||
let label = crate::command_label(spec.id).to_string();
|
||||
let title = if spec.id.starts_with("solve.") { "Solving".to_string() } else { label.clone() };
|
||||
let steps = self.session.model.steps().len();
|
||||
let done = match spec.id {
|
||||
"solve.run" => format!("Solved {steps} step{}", if steps == 1 { "" } else { "s" }),
|
||||
_ => format!("{label} done"),
|
||||
};
|
||||
if !self.begin_job(spec.id, params, false, title, done, None) {
|
||||
return Err(self.busy().map_or_else(|| "busy".to_string(), |b| format!("{b}: wait for it or cancel it")));
|
||||
}
|
||||
self.jobs = self.jobs.wrapping_add(1);
|
||||
Ok(self.jobs)
|
||||
}
|
||||
|
||||
/// Recover an autosaved model (`file.recover`) in the background.
|
||||
pub fn recover(&mut self, id: &str) {
|
||||
self.prompt = None;
|
||||
@@ -583,6 +680,9 @@ impl ModelApp {
|
||||
pub fn cancel_loading(&mut self) {
|
||||
if let Some(l) = self.loading.take() {
|
||||
self.queued.clear();
|
||||
for w in &l.waiters {
|
||||
let _ = w.send(json!({"ok": false, "error": format!("cancelled: {}", l.title), "cancelled": true}));
|
||||
}
|
||||
let line = if l.title == "Solving" { "Solve cancelled".to_string() } else { format!("{} cancelled", l.title) };
|
||||
self.set_status(line, false);
|
||||
}
|
||||
@@ -603,6 +703,16 @@ impl ModelApp {
|
||||
Err(TryRecvError::Disconnected) => None,
|
||||
};
|
||||
let Some(l) = self.loading.take() else { return false };
|
||||
let reply = match &outcome {
|
||||
Some((_, Ok(v))) => json!({"ok": true, "result": v}),
|
||||
Some((_, Err(e))) => json!({"ok": false, "error": e}),
|
||||
None => json!({"ok": false, "error": format!("{}: stopped unexpectedly", l.title)}),
|
||||
};
|
||||
for w in &l.waiters {
|
||||
let _ = w.send(reply.clone());
|
||||
}
|
||||
let took = elapsed((self.now - l.started).max(0.0));
|
||||
let l = Loading { done: finished(l.command, &l.done, &took, &reply), ..l };
|
||||
match outcome {
|
||||
Some((session, Ok(reply))) => {
|
||||
let opened = Opened { session, reply, done: l.done, recent: l.recent, form: l.form };
|
||||
@@ -864,8 +974,9 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) {
|
||||
let t = crate::theme::Tokens::get(app.ui.dark);
|
||||
let hovering = ctx.input(|i| !i.raw.hovered_files.is_empty());
|
||||
if hovering || app.loading.is_some() {
|
||||
let fraction = app.loading.as_ref().and_then(|l| l.progress.snapshot().fraction());
|
||||
let text = match app.loading.as_ref() {
|
||||
Some(l) => format!("{}…", l.title),
|
||||
Some(l) => l.line(app.now),
|
||||
None => "Drop to open: .fieldcraft models, .solidcraft designs, .3mf tagged meshes".into(),
|
||||
};
|
||||
let mut cancel = false;
|
||||
@@ -882,6 +993,9 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) {
|
||||
cancel = ui.button("Cancel").clicked();
|
||||
}
|
||||
});
|
||||
if let Some(f) = fraction {
|
||||
ui.add(egui::ProgressBar::new(f as f32).desired_height(4.0).fill(t.accent));
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -1401,7 +1515,7 @@ mod tests {
|
||||
assert_eq!(app.busy(), Some("Solving"));
|
||||
assert!(app.run("model.info", serde_json::json!({})).is_err(), "commands wait for the solve");
|
||||
app.wait_loaded();
|
||||
assert_eq!(app.status.as_ref().map(|s| s.text.as_str()), Some("Solved 2 steps"));
|
||||
assert!(app.status.as_ref().is_some_and(|s| s.text.starts_with("Solved 2 steps in ")), "{:?}", app.status);
|
||||
app.logic(&egui::Context::default());
|
||||
assert_eq!(app.ui.tab, fieldcraft_engine::catalogue::Tab::Results);
|
||||
assert!(app.plot().is_some());
|
||||
@@ -1417,6 +1531,31 @@ mod tests {
|
||||
assert!(empty.busy().is_none() && empty.status.as_ref().is_some_and(|s| s.error));
|
||||
}
|
||||
|
||||
/// A cancelled or refused solve leaves the window in the workspace it was in, and a job
|
||||
/// waiting over the control channel hears that it was cancelled (#324, #367).
|
||||
#[test]
|
||||
fn a_cancelled_or_refused_solve_stays_in_its_workspace() {
|
||||
use fieldcraft_engine::catalogue::Workspace;
|
||||
let mut app = ModelApp::new(Session::default());
|
||||
app.open_sample("cantilever");
|
||||
app.ui.select_workspace(Workspace::Model);
|
||||
let job = app.start_job("solve.run", serde_json::json!({})).unwrap();
|
||||
assert_eq!(job, 1);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
app.loading.as_mut().unwrap().waiters.push(tx);
|
||||
assert_eq!(app.loading.as_ref().unwrap().to_json(app.now)["command"], "solve.run");
|
||||
app.cancel_loading();
|
||||
app.logic(&egui::Context::default());
|
||||
assert_eq!(app.ui.workspace, Workspace::Model);
|
||||
assert_eq!(rx.recv().unwrap()["cancelled"], true);
|
||||
let mut empty = ModelApp::new(Session::default());
|
||||
empty.ui.select_workspace(Workspace::Model);
|
||||
assert!(empty.start_job("solve.run", serde_json::json!({})).is_err());
|
||||
empty.solve_all();
|
||||
empty.logic(&egui::Context::default());
|
||||
assert_eq!(empty.ui.workspace, Workspace::Model);
|
||||
}
|
||||
|
||||
/// A first-time user's path: Open picks a SolidCraft design, the Tet Mesh button opens its
|
||||
/// form, OK with the defaults meshes it in the background, and the mesh is there. A mesher
|
||||
/// that does not fit says why in the form, which stays open.
|
||||
@@ -1439,7 +1578,13 @@ mod tests {
|
||||
assert_eq!(app.busy(), Some("Generate Tet Mesh"), "meshing runs off the window thread");
|
||||
app.wait_loaded();
|
||||
assert!(app.dialog.is_none(), "the form closes when it succeeded: {:?}", app.status);
|
||||
assert_eq!(app.status.as_ref().map(|s| (s.text.as_str(), s.error)), Some(("Generate Tet Mesh done", false)));
|
||||
// The status sums up the mesh made: elements, worst angle, time, where to check it.
|
||||
let status = app.status.clone().unwrap();
|
||||
assert!(
|
||||
!status.error && status.text.starts_with("Generate Tet Mesh done: ") && status.text.contains(" tet4, smallest dihedral angle "),
|
||||
"{}",
|
||||
status.text
|
||||
);
|
||||
let counts = app.session.model.element_counts();
|
||||
assert!(matches!(counts.as_slice(), [(fieldcraft_model::ElementKind::Tet4, n)] if *n > 0), "{counts:?}");
|
||||
assert_eq!(app.session.journal.len(), 2, "open and mesh, each an undo step");
|
||||
|
||||
+126
-6
@@ -246,6 +246,9 @@ pub struct PickState {
|
||||
names: Vec<selection::BodyNames>,
|
||||
/// The selection's highlight and what it was built for (kept while only the hover changes).
|
||||
selected_overlay: (Option<u64>, Arc<Scene>),
|
||||
/// The filter an open form switched to: the form's opening, the filter it set and the one
|
||||
/// to restore when it closes.
|
||||
form_filter: Option<(u64, selection::Filter, selection::Filter)>,
|
||||
/// The highlight overlay (the selection's and the hover's) and what it was built for.
|
||||
overlay: (Option<u64>, Arc<Scene>),
|
||||
/// What the hover was last worked out for: the pointer, the camera, the scene, the filter
|
||||
@@ -313,6 +316,9 @@ struct PlotKey {
|
||||
frame: Option<u64>,
|
||||
}
|
||||
|
||||
/// A datum form's datum and what it was worked out for (revision, command, form text).
|
||||
type DatumPreview = (Option<(u64, &'static str, String)>, Option<fieldcraft_model::Datum>);
|
||||
|
||||
/// A screenshot asked for over the control channel.
|
||||
struct Shot {
|
||||
token: u64,
|
||||
@@ -331,6 +337,8 @@ pub struct ModelApp {
|
||||
pub cam: Camera,
|
||||
pub status: Option<Status>,
|
||||
pub dialog: Option<ParamsDialog>,
|
||||
/// The datum the open form defines, and the revision, command and form text it is for.
|
||||
preview: std::cell::RefCell<DatumPreview>,
|
||||
/// Forms opened so far (numbers each [`ParamsDialog::opening`]).
|
||||
forms_opened: u64,
|
||||
pub viewport: Viewport,
|
||||
@@ -401,6 +409,11 @@ pub struct ModelApp {
|
||||
setup: (Option<(u64, u64, bool, bool)>, std::sync::Arc<Vec<fieldcraft_engine::progress::StageState>>),
|
||||
/// The file opening in the background, and files waiting to open after it.
|
||||
pub loading: Option<files::Loading>,
|
||||
/// The last command refused for what the model misses, listed in the context panel until
|
||||
/// it can run or is dismissed.
|
||||
pub refused: Option<fieldcraft_engine::requirement::Unavailable>,
|
||||
/// Jobs started over the control channel (numbers each).
|
||||
pub jobs: u64,
|
||||
pub queued: std::collections::VecDeque<String>,
|
||||
/// The question the window is asking (unsaved changes, recover).
|
||||
pub prompt: Option<files::Prompt>,
|
||||
@@ -455,6 +468,7 @@ impl ModelApp {
|
||||
cam: Camera::default(),
|
||||
status: None,
|
||||
dialog: None,
|
||||
preview: Default::default(),
|
||||
forms_opened: 0,
|
||||
viewport: Viewport::default(),
|
||||
icons: icons::IconCache::default(),
|
||||
@@ -493,6 +507,8 @@ impl ModelApp {
|
||||
reload: None,
|
||||
setup: (None, std::sync::Arc::default()),
|
||||
loading: None,
|
||||
refused: None,
|
||||
jobs: 0,
|
||||
queued: std::collections::VecDeque::new(),
|
||||
prompt: None,
|
||||
report: None,
|
||||
@@ -652,7 +668,7 @@ impl ModelApp {
|
||||
missing: u.missing.iter().map(|m| m.0).collect(),
|
||||
});
|
||||
let e = u.to_string();
|
||||
self.set_status(e.clone(), true);
|
||||
self.refuse(u);
|
||||
return Err(e);
|
||||
}
|
||||
if self.file_action(spec.id, &preset, interactive) {
|
||||
@@ -672,6 +688,12 @@ impl ModelApp {
|
||||
/// Open a command's parameter form with `params` filled in.
|
||||
pub fn open_form(&mut self, command: &str, params: Value) {
|
||||
if let Some(spec) = fieldcraft_engine::find_command(command) {
|
||||
// A mesher starts from how the mesh it would replace was made (size, order…), so a
|
||||
// remesh changes only what is typed (#343).
|
||||
let params = match params.as_object() {
|
||||
Some(o) if o.is_empty() => self.last_mesher_params(spec.id).unwrap_or(params),
|
||||
_ => params,
|
||||
};
|
||||
// The schema's defaults are filled in, so the form starts as the command would run.
|
||||
let mut params = spec.form.map_or(params.clone(), |form| fieldcraft_engine::schema::prefill(form, ¶ms));
|
||||
// A required reference with one obvious candidate starts picked.
|
||||
@@ -683,6 +705,12 @@ impl ModelApp {
|
||||
_ => Some(session.names(c)).filter(|n| n.len() == 1).and_then(|n| n.into_iter().next()),
|
||||
};
|
||||
form::preselect(form, obj, &pick);
|
||||
// A new datum or partition starts with the next free name (Datum-1, …), editable.
|
||||
if let Some(name) = self.default_name(spec.id)
|
||||
&& obj.get("name").and_then(Value::as_str).is_none_or(str::is_empty)
|
||||
{
|
||||
obj.insert("name".into(), json!(name));
|
||||
}
|
||||
}
|
||||
self.forms_opened = self.forms_opened.wrapping_add(1);
|
||||
self.dialog = Some(ParamsDialog {
|
||||
@@ -700,6 +728,40 @@ impl ModelApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// The parameters mesher `id` last ran with in this model (its journal), while the mesh it
|
||||
/// made is there; `None` for other commands.
|
||||
fn last_mesher_params(&self, id: &str) -> Option<Value> {
|
||||
fieldcraft_engine::MESHERS.iter().find(|(m, _)| *m == id)?;
|
||||
let (_, params) = self.session.journal.iter().rev().find(|(c, _)| c == id)?;
|
||||
let targets = fieldcraft_engine::MESHERS.iter().find(|(m, _)| *m == id)?.1;
|
||||
let meshed = targets(&self.session, params).ok()?.iter().any(|b| !self.session.model.body_mesh(b).elements.is_empty());
|
||||
meshed.then(|| params.clone())
|
||||
}
|
||||
|
||||
/// A command that cannot run yet: the status bar says so in short, and the context panel
|
||||
/// lists each missing piece with the command that provides it ([`ModelApp::refused`]).
|
||||
pub fn refuse(&mut self, u: fieldcraft_engine::requirement::Unavailable) {
|
||||
let label = command_label(u.command);
|
||||
let text = match u.missing.as_slice() {
|
||||
[one] => format!("{label} cannot run yet. {one}"),
|
||||
many => format!("{label} cannot run yet: {} things missing (listed in the panel)", many.len()),
|
||||
};
|
||||
self.set_status(text, true);
|
||||
self.refused = Some(u);
|
||||
}
|
||||
|
||||
/// The name a new item of command `id` starts with: `<Kind>-<n>`, the first not taken.
|
||||
fn default_name(&self, id: &str) -> Option<String> {
|
||||
use fieldcraft_engine::schema::Collection;
|
||||
let (base, collection) = match id {
|
||||
"geometry.datum" | "geometry.datum.axis" | "geometry.datum.point" => ("Datum", Collection::Datum),
|
||||
"geometry.partition" => ("Partition", Collection::Partition),
|
||||
_ => return None,
|
||||
};
|
||||
let taken = self.session.names(collection);
|
||||
(1..=fieldcraft_engine::schema::MAX_NAMES).map(|n| format!("{base}-{n}")).find(|n| !taken.contains(n))
|
||||
}
|
||||
|
||||
/// A reference field's "Create …": open the command that makes an item of `collection` over
|
||||
/// the open form, which comes back (with the new item at `path`) when that command runs or
|
||||
/// is cancelled. A provider that opens no form (a file picker, a missing requirement) leaves
|
||||
@@ -941,6 +1003,7 @@ impl ModelApp {
|
||||
let mut h = std::hash::DefaultHasher::new();
|
||||
let plot = self.plot.1.as_ref().map(|p| Arc::as_ptr(p) as usize);
|
||||
(self.revision, self.ui.style, self.ui.show(), self.ui.dark, plot, self.selected_datum()).hash(&mut h);
|
||||
format!("{:?}", self.datum_preview()).hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
@@ -986,7 +1049,10 @@ impl ModelApp {
|
||||
};
|
||||
let mut datums = Aabb3::EMPTY;
|
||||
if show.geometry && self.plot.1.is_none() {
|
||||
datums = scene::datums(&mut s, &self.session, &self.display.1.bounds, self.selected_datum(), &t);
|
||||
datums = scene::datums(&mut s, &self.session, &self.display.1.bounds, self.selected_datum().as_deref(), &t);
|
||||
if let Some(d) = self.datum_preview() {
|
||||
datums.union(scene::datum_preview(&mut s, d, &self.display.1.bounds, &t));
|
||||
}
|
||||
scene::seeds(&mut s, &self.session.model, &self.pick.names, &self.display.1, &t);
|
||||
}
|
||||
self.scene = (Some(key), s, datums);
|
||||
@@ -1000,14 +1066,32 @@ impl ModelApp {
|
||||
(key, &self.scene.1)
|
||||
}
|
||||
|
||||
/// The datum selected in the browser: drawn highlighted, shown or hidden.
|
||||
fn selected_datum(&self) -> Option<&str> {
|
||||
/// The datum drawn highlighted, shown or hidden: the one the open Partition form cuts by,
|
||||
/// else the one selected in the browser.
|
||||
fn selected_datum(&self) -> Option<String> {
|
||||
if let Some(d) = self.dialog.as_ref().filter(|d| d.command == "geometry.partition") {
|
||||
return serde_json::from_str::<Value>(&d.text).ok()?.get("datum").and_then(Value::as_str).map(str::to_string);
|
||||
}
|
||||
match &self.selected {
|
||||
Some((browser::Def::Datum, name)) => Some(name.as_str()),
|
||||
Some((browser::Def::Datum, name)) => Some(name.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The datum the open datum form defines so far, drawn before OK.
|
||||
/// Worked out once per form text and model revision (a face centre costs a design query).
|
||||
fn datum_preview(&self) -> Option<fieldcraft_model::Datum> {
|
||||
let d = self.dialog.as_ref()?;
|
||||
let key = (self.revision, d.command, d.text.clone());
|
||||
let mut cache = self.preview.borrow_mut();
|
||||
if cache.0.as_ref() != Some(&key) {
|
||||
let params = serde_json::from_str::<Value>(&d.text).ok();
|
||||
let datum = params.and_then(|p| fieldcraft_engine::cmd::datum_preview(&self.session, d.command, &p));
|
||||
*cache = (Some(key), datum);
|
||||
}
|
||||
cache.1
|
||||
}
|
||||
|
||||
/// The definition selected in the browser, when it has symbols.
|
||||
fn highlighted(&self) -> Option<(Definition, &str)> {
|
||||
let (def, name) = self.selected.as_ref()?;
|
||||
@@ -1472,6 +1556,11 @@ impl ModelApp {
|
||||
self.frame_new_document();
|
||||
self.results_shown = shown;
|
||||
self.check_selection();
|
||||
self.follow_form_filter();
|
||||
// A refusal goes once the command can run.
|
||||
if self.refused.as_ref().is_some_and(|u| self.unavailable(u.command).is_none()) {
|
||||
self.refused = None;
|
||||
}
|
||||
// A selected definition that is gone (undo, delete) is no longer selected.
|
||||
if self.selected.as_ref().is_some_and(|(kind, name)| !kind.exists(&self.session.model, name)) {
|
||||
self.selected = None;
|
||||
@@ -1485,6 +1574,29 @@ impl ModelApp {
|
||||
self.screenshots(ctx);
|
||||
}
|
||||
|
||||
/// A form that picks one kind of entity (Seed Edges: edges) switches the selection filter to
|
||||
/// it while open, and the filter goes back when the form closes. A filter the user changes
|
||||
/// while the form is open stays until the form wants another kind.
|
||||
fn follow_form_filter(&mut self) {
|
||||
let want = self
|
||||
.dialog
|
||||
.as_ref()
|
||||
.and_then(|d| serde_json::from_str::<Value>(&d.text).ok().and_then(|p| selection::form_filter(d.command, &p)).map(|f| (d.opening, f)));
|
||||
match (want, self.pick.form_filter) {
|
||||
(Some((opening, f)), Some((o, set, _))) if opening == o && f == set => {}
|
||||
(Some((opening, f)), previous) => {
|
||||
let restore = previous.map_or(self.pick.filter, |(_, _, r)| r);
|
||||
self.pick.form_filter = Some((opening, f, restore));
|
||||
self.pick.filter = f;
|
||||
}
|
||||
(None, Some((_, _, restore))) => {
|
||||
self.pick.form_filter = None;
|
||||
self.pick.filter = restore;
|
||||
}
|
||||
(None, None) => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Undo (Ctrl/Cmd+Z), redo (Ctrl/Cmd+Shift+Z or Ctrl/Cmd+Y), open (Ctrl/Cmd+O), save
|
||||
/// (Ctrl/Cmd+S), save as (Ctrl/Cmd+Shift+S) and delete the browser's selection (Delete),
|
||||
/// unless a text field has the keyboard (it undoes its own typing).
|
||||
@@ -1641,6 +1753,9 @@ impl ModelApp {
|
||||
control::Outcome::Done(v) => {
|
||||
let _ = req.reply.send(v);
|
||||
}
|
||||
control::Outcome::Pending => {
|
||||
let _ = req.reply.send(json!({control::PENDING: true}));
|
||||
}
|
||||
control::Outcome::Screenshot { path } => {
|
||||
self.shot_token = self.shot_token.wrapping_add(1);
|
||||
let deadline = self.now + SHOT_TIMEOUT;
|
||||
@@ -1960,10 +2075,15 @@ mod tests {
|
||||
let err = app.start("deck.write.abaqus").unwrap_err();
|
||||
assert_eq!(err, "deck.write cannot run yet. Needs a mesh — Mesh › Tet Mesh");
|
||||
assert!(app.dialog.is_none());
|
||||
assert_eq!(app.status.as_ref().map(|s| (s.error, s.text.as_str())), Some((true, err.as_str())));
|
||||
let status = "Write Solver Deck cannot run yet. Needs a mesh — Mesh › Tet Mesh";
|
||||
assert_eq!(app.status.as_ref().map(|s| (s.error, s.text.as_str())), Some((true, status)));
|
||||
// The panel lists what is missing (#323), until the command can run.
|
||||
assert_eq!(app.refused.as_ref().map(|u| u.command), Some("deck.write"));
|
||||
// With a model, a deck entry opens deck.write's form with its format filled in.
|
||||
app.run("file.sample", json!({"name": "cantilever"})).unwrap();
|
||||
app.start("deck.write.abaqus").unwrap();
|
||||
app.logic(&egui::Context::default());
|
||||
assert!(app.refused.is_none());
|
||||
let d = app.dialog.as_ref().unwrap();
|
||||
assert_eq!((d.command, d.text.as_str()), ("deck.write", r#"{"format":"abaqus"}"#));
|
||||
}
|
||||
|
||||
@@ -89,6 +89,16 @@ pub fn datums(s: &mut Scene, session: &fieldcraft_engine::Session, bounds: &Aabb
|
||||
fieldcraft_render::glyphs::datums(&session.model, bounds, &shown, selected, &t.glyph_colors(), s)
|
||||
}
|
||||
|
||||
/// A datum being defined in a form, drawn highlighted (through the part) where OK would put it.
|
||||
pub fn datum_preview(s: &mut Scene, datum: fieldcraft_model::Datum, bounds: &Aabb3, t: &Tokens) -> Aabb3 {
|
||||
const NAME: &str = "preview";
|
||||
let mut model = fieldcraft_model::Model::default();
|
||||
if model.add_datum(NAME, datum).is_err() {
|
||||
return Aabb3::EMPTY;
|
||||
}
|
||||
fieldcraft_render::glyphs::datums(&model, bounds, &|_| true, Some(NAME), &t.glyph_colors(), s)
|
||||
}
|
||||
|
||||
/// The scene for `display` drawn in `style`.
|
||||
pub fn build(display: &DisplayMesh, style: Style, show: Show, t: &Tokens) -> Scene {
|
||||
let mut s = Scene { background: Some((rgb(t.viewport_top), rgb(t.viewport_bottom))), ..Default::default() };
|
||||
|
||||
@@ -820,6 +820,23 @@ pub fn target(id: &str, params: &Value) -> Option<Target> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The filter command `id`'s form picks with, when its target is one kind of entity: edges for
|
||||
/// edge seeds, and the vertex, edge or face a datum point is defined on.
|
||||
pub fn form_filter(id: &str, params: &Value) -> Option<Filter> {
|
||||
if target(id, params) == Some(Target::Edges) {
|
||||
return Some(Filter::Edges);
|
||||
}
|
||||
match id {
|
||||
"geometry.datum.point" | "geometry.datum.point.edit" => match params.pointer("/by/type").and_then(Value::as_str) {
|
||||
Some("vertex") => Some(Filter::Vertices),
|
||||
Some("edge") => Some(Filter::Edges),
|
||||
Some("face_centre") => Some(Filter::Faces),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `params` with the selection's members put in (replacing any members it had).
|
||||
pub fn with_members(params: &Value, selection: &Selection) -> Value {
|
||||
let mut out = params.as_object().cloned().unwrap_or_default();
|
||||
@@ -1160,6 +1177,9 @@ mod tests {
|
||||
assert_eq!(target("model.section.edit", &json!({})), Some(Target::Set));
|
||||
assert_eq!(target("model.coupling.create", &json!({})), Some(Target::Set));
|
||||
assert_eq!(target("mesh.node.add", &json!({})), None);
|
||||
assert_eq!(form_filter("mesh.seed.edge", &json!({})), Some(Filter::Edges));
|
||||
assert_eq!(form_filter("geometry.datum.point", &json!({"by": {"type": "vertex"}})), Some(Filter::Vertices));
|
||||
assert_eq!(form_filter("model.load.create", &json!({})), None);
|
||||
assert_eq!(with_set(&json!({"name": "fix"}), Target::TargetSet, "fix-set"), json!({"name": "fix", "target": {"set": "fix-set"}}));
|
||||
assert_eq!(with_set(&json!({"name": "web"}), Target::Set, "web-set"), json!({"name": "web", "set": "web-set"}));
|
||||
let mut m = Model::default();
|
||||
|
||||
@@ -17,14 +17,18 @@ The channel is untrusted input. A request line may be at most 16 MiB; a longer o
|
||||
with `{"ok": false, "error": "request too large"}` and the connection is closed, as are
|
||||
connections that send eight lines that are not JSON. At most 16 connections are served at once,
|
||||
at most 64 queued requests run per frame so a flood cannot starve drawing, and a request the UI
|
||||
thread has not answered within 60 seconds gets `{"ok": false, "error": "timeout"}`. Numbers are
|
||||
thread has not answered within 60 seconds gets `{"ok": false, "error": "timeout"}` (a solve or a
|
||||
mesher is a job, answered when it ends however long it runs: see [Jobs](#jobs)). Numbers are
|
||||
checked and clamped (drag steps, wheel lines, window size); `ui.render` refuses an image size out of range.
|
||||
|
||||
## Engine
|
||||
|
||||
| Method | Params | Result |
|
||||
|---|---|---|
|
||||
| `engine.execute` | `command`: command id, `params`: JSON object | the command's result |
|
||||
| `engine.execute` | `command`: command id, `params`: JSON object, `wait?` (default true) | the command's result; a solve or a mesher runs as a [job](#jobs) |
|
||||
| `job.status` | — | `job`: the running job (`title`, `command`, `elapsed` seconds, `progress` `{step: {name, index, count}, stage, done, total}`, `fraction` 0–1 when bounded, `line` as the window shows it), or null |
|
||||
| `job.wait` | — | the running job's result when it ends (null `job` when none runs) |
|
||||
| `job.cancel` | — | stops waiting for the job (`cancelled`: what it was); the model stays as it was and every request waiting for it gets `{"ok": false, "cancelled": true}` |
|
||||
| `engine.script` | `commands`: `[{command, params}, …]` | stops at the first error; `journal`: commands that changed the model so far |
|
||||
| `engine.commands` | — | every command: `id`, `label`, `params` (the parameter documentation), `schema` (its parameters as JSON Schema) and `requires` (what the session must hold before it runs: `design`, `geometry`, `nodes`, `mesh`, `analysable`, `material`, `step`, `result`, …; `engine.execute` of a command missing one fails without doing anything, naming each missing piece and its toolbar entry) |
|
||||
|
||||
@@ -41,7 +45,7 @@ commands (files, undo, `model.info`) leave the ribbon where it is.
|
||||
|
||||
| Method | Params | Notes |
|
||||
|---|---|---|
|
||||
| `ui.inspect` | — | view settings (`ui`), camera, viewport rect (points), renderer (`gpu`/`cpu`) and adapter, open command form, status bar, node and element counts, the results display (`results`: what the `results.*` commands reply: view, `stale`, contour range, modes), the probed node (`probe`), `solve` (the title bar's Solve: `enabled`, and `unavailable` as `ui.toolbar`'s entries give it, listing everything `solve.run` misses), `busy` (the background job: a file opening, a solve, a mesher run from its form by a click on OK), `prompt` (`{"unsaved": path}`, `{"design": path}` (a design of another part opened over a meshed or defined model), `"recover"`, `"samples"`), `recent` (recent files), `updates` (docs/updates.md: `automatic`, `checked_at`, `skip`, `available` (a newer version the status bar offers), `busy`, `staged`, `unsupported`; null when the run does not check), `selected` (the browser's selected definition `{kind, name}`, or seed `{kind: "seed", edge}`), `browser` (each top-level browser node's setup state: `[{node, state, tip, add}]`, `state` `done`, `next`, `blocked` (⚠: its command cannot run yet; `tip` says what it needs and where), `todo`, `optional` or null for nodes that are no setup stage, `add` its ghost rows `{stage, label, command, status}`; a ghost row's region is `layout`'s `browser/add/<stage>`), `nav` (the left-drag mode), `layout` (each region of the window drawn last frame as `[x, y, w, h]` in points: `app_bar`, `ribbon`, `workspace_switcher`, `document_tab`, `app_bar:<action>`, `browser`, `viewport`, `nav_bar`, `axis_view`, `selection_bar`, `legend` (while results are shown), `context`, `command_line`, `timeline`, `toolbar_dropdown`, `palette`), version |
|
||||
| `ui.inspect` | — | view settings (`ui`), camera, viewport rect (points), renderer (`gpu`/`cpu`) and adapter, open command form, status bar, node and element counts, the results display (`results`: what the `results.*` commands reply: view, `stale`, contour range, modes), the probed node (`probe`), `solve` (the title bar's Solve: `enabled`, and `unavailable` as `ui.toolbar`'s entries give it, listing everything `solve.run` misses), `busy` (the background job: a file opening, a solve, a mesher run from its form by a click on OK), `job` (that job as `job.status` gives it), `prompt` (`{"unsaved": path}`, `{"design": path}` (a design of another part opened over a meshed or defined model), `"recover"`, `"samples"`), `recent` (recent files), `updates` (docs/updates.md: `automatic`, `checked_at`, `skip`, `available` (a newer version the status bar offers), `busy`, `staged`, `unsupported`; null when the run does not check), `selected` (the browser's selected definition `{kind, name}`, or seed `{kind: "seed", edge}`), `browser` (each top-level browser node's setup state: `[{node, state, tip, add}]`, `state` `done`, `next`, `blocked` (⚠: its command cannot run yet; `tip` says what it needs and where), `todo`, `optional` or null for nodes that are no setup stage, `add` its ghost rows `{stage, label, command, status}`; a ghost row's region is `layout`'s `browser/add/<stage>`), `nav` (the left-drag mode), `layout` (each region of the window drawn last frame as `[x, y, w, h]` in points: `app_bar`, `ribbon`, `workspace_switcher`, `document_tab`, `app_bar:<action>`, `browser`, `viewport`, `nav_bar`, `axis_view`, `selection_bar`, `legend` (while results are shown), `context`, `command_line`, `timeline`, `toolbar_dropdown`, `palette`), version |
|
||||
| `ui.set` | any view setting: `style` (`shaded_edges`, `shaded`, `wireframe`), `show_nodes`, `show_geometry`, `show_mesh`, `show_loads` (boundary condition and load symbols), `show_interactions` (reference points, couplings, contact and tie surfaces, rigid walls), `symbol_size` (the setup symbols' size on screen in points, 6–48, at any zoom), `perspective`, `dark`, `show_tree` (the browser), `show_timeline`, `nav_preset` (the mouse navigation: `fieldcraft`, `fusion`, `solidworks`, `inventor`, `alias`, `tinkercad`, `openscad`; an unknown id acts as `fieldcraft`) and `reverse_zoom` (both remembered per user), `workspace` (`prepare`, `model`, `analysis`, `results`), `tab` (the toolbar tab: `geometry`, `mesh`, `properties`, `assembly`, `steps`, `loads`, `interactions`, `solve`, `results`, `tools`; a tab of another workspace brings that workspace), `palette` (the command search open), `menu` (the open menu: `file`, `help`, `workspace`, `view`, `more`, or a panel's drop-down as `<tab>/<panel>` such as `mesh/Tet`; `null` closes it) | an unknown setting or value is an error; returns the settings |
|
||||
| `ui.view` | `view`: `front`, `back`, `left`, `right`, `top`, `bottom`, `iso`, `home` (iso and fit), `fit` | every view frames the model as drawn (the deformed shape included; the setup's symbols keep their size on screen and are not framed) in the part of the viewport the overlays (`layout`'s `legend`, `selection_bar`, `axis_view`, `nav_bar`) leave free; `fit` keeps the direction of view. Opening another model, sample or design frames it in the home view (the same design opened again after an edit keeps the camera), and results that come up with part of the model out of sight are framed. Returns the camera |
|
||||
| `ui.camera` | `yaw`, `pitch` (degrees), `distance?` (model units) | turns the camera to any orientation about its target: `yaw` about Z (0 looks along +Y, the front view; −45 is the iso view's, −90 the right view's), `pitch` the elevation above the XY plane; `distance` moves the eye (the zoom of an orthographic view). Angles must be finite and the distance above 0: the yaw is wrapped, the pitch clamped to ±90 and the distance to 0.001–10⁹; anything else is an error. A sequence of calls orbits smoothly. Returns the camera (`ui.inspect`'s, angles in radians) |
|
||||
@@ -120,3 +124,14 @@ User clicks (title bar File menu, toolbar entries, Cmd/Ctrl+O, S, Shift+S), drop
|
||||
files named on the command line go through native file pickers and open in the background
|
||||
(`ui.inspect`'s `busy`); commands sent here take their paths as parameters and run at once
|
||||
(`file.open`, `file.save_as`, `geometry.open`, `geometry.import.mesh`, `deck.write`).
|
||||
|
||||
## Jobs
|
||||
|
||||
`engine.execute` of a solve (`solve.run`, `solve.static`, `solve.nonlinear`, `solve.heat`,
|
||||
`solve.frequency`, `solve.buckle`, `solve.explicit`, `solve.fluid`) or a mesher (`mesh.tet.generate`,
|
||||
`mesh.hex.*`) runs it off the window thread, as the Solve button and a mesher form's OK do: the
|
||||
window keeps drawing its progress (step, stage and count, elapsed time) with Cancel. The reply is
|
||||
the command's result when it ends, however long that takes; with `"wait": false` it is
|
||||
`{"job": n}` at once, and `job.status`, `job.wait` and `job.cancel` follow it. One job runs at a
|
||||
time; another long command meanwhile is refused, naming the job. The MCP server's `solve` and
|
||||
`mesh` tools wait for the reply.
|
||||
Reference in new issue
Block a user