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:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-15 09:14:34 -04:00
1 parent 3eaca26246
commit 4ee855bb27
11 files changed
+447 -28

No files matched your search

+12
View File
@@ -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.
+1
View File
@@ -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;
+4 -3
View File
@@ -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(());
+48 -5
View File
@@ -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());
+49 -2
View File
@@ -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
View File
@@ -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, &params).map_err(|e| e.to_string());
let r = fieldcraft_model::progress::scoped(Some(progress), || s.execute(command, &params)).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
View File
@@ -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, &params));
// 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"}"#));
}
+10
View File
@@ -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() };
+20
View File
@@ -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();