Open a design of another part: ask whether to start a new model

Opening (File > Open, a drop, the recent list) a design that has none of the
bodies the model's mesh and sets are on now asks, like the unsaved-changes
prompt: Start a new model with this design (default, Enter), Keep the
current model (the status names what no longer resolves), or Cancel. The
opened session waits for the answer; the same design opens without asking.
ui.inspect shows the prompt as {"design": path}; ui.prompt answers it.

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-14 18:07:30 -04:00
1 parent 14e2d25e68
commit 94a10a1bf3
4 files changed
+204 -22

No files matched your search

+11 -1
View File
@@ -197,6 +197,7 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) ->
}}),
crate::files::Prompt::Recover => json!("recover"),
crate::files::Prompt::Samples => json!("samples"),
crate::files::Prompt::Design(p) => json!({"design": p}),
}),
"recent": app.recent.paths,
"version": crate::VERSION,
@@ -305,6 +306,15 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) ->
app.answer_unsaved(answer);
ok(json!({"busy": app.busy()}))
}
(crate::files::Prompt::Design(_), Some(a @ ("new" | "keep" | "cancel"))) => {
let answer = match a {
"new" => crate::files::DesignAnswer::New,
"keep" => crate::files::DesignAnswer::Keep,
_ => crate::files::DesignAnswer::Cancel,
};
app.answer_design(answer);
ok(json!({"busy": app.busy()}))
}
(crate::files::Prompt::Samples, Some("open")) => match s("name") {
Some(name) => {
app.prompt = None;
@@ -325,7 +335,7 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) ->
None => err("missing `id` (from file.recovery.list)"),
},
(_, a) => err(format!(
"`answer` {a:?} does not answer this prompt (unsaved: save, discard, cancel; recover: recover with id, close; samples: open with name, close)"
"`answer` {a:?} does not answer this prompt (unsaved: save, discard, cancel; design: new, keep, cancel; recover: recover with id, close; samples: open with name, close)"
)),
}
}
+188 -19
View File
@@ -186,6 +186,16 @@ pub struct Loading {
shown: bool,
}
/// A finished background job whose session has not replaced the window's yet.
pub struct Opened {
session: Box<Session>,
reply: Value,
done: String,
recent: Option<String>,
fit: bool,
form: Option<&'static str>,
}
/// What waits for an answer about unsaved changes.
#[derive(Clone, Debug, PartialEq)]
pub enum Replace {
@@ -213,6 +223,20 @@ pub enum Prompt {
Recover,
/// The samples to open (`file.sample`).
Samples,
/// A design (its path) has none of the bodies the model's mesh and sets are on: start a new
/// model with it, or keep the model. The opened session waits in [`ModelApp::held`].
Design(String),
}
/// The answer to [`Prompt::Design`].
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DesignAnswer {
/// Open the design into a new model (`geometry.open` with `"model": "new"`).
New,
/// Open the design and keep the model's mesh and definitions.
Keep,
/// Do not open the design.
Cancel,
}
/// The answer to [`Prompt::Unsaved`].
@@ -444,24 +468,19 @@ impl ModelApp {
let Some(l) = self.loading.take() else { return false };
match outcome {
Some((session, Ok(reply))) => {
// The session it replaces may be large: it is freed off the window thread.
let old = std::mem::replace(&mut self.session, *session);
off_thread("fieldcraft drop".into(), move || drop(old));
self.model_changed();
if l.fit {
self.fit_view();
}
if let Some(p) = &l.recent {
self.recent.add(p);
}
if self.dialog.as_ref().is_some_and(|d| l.form == Some(d.command)) {
self.dialog = None;
}
let warnings: Vec<&str> = reply.get("warnings").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).collect();
if warnings.is_empty() {
self.set_status(l.done, false);
} else {
self.set_status(format!("{} — {}", l.done, warnings.join("; ")), true);
let opened = Opened { session, reply, done: l.done, recent: l.recent, fit: l.fit, form: l.form };
// A design of another part over a meshed or defined model: ask what to do with
// the model before it changes (the job ran with the default, keep).
let unrelated = opened.reply.get("related").and_then(Value::as_bool) == Some(false)
&& opened.reply.get("model").and_then(Value::as_str) == Some("kept");
match opened.recent.clone() {
Some(path) if unrelated && Kind::of(&path) == Some(Kind::Design) => {
self.held = Some(opened);
self.set_status(format!("{} has none of the bodies the model is on", file_name(&path)), false);
self.prompt = Some(Prompt::Design(path));
return false;
}
_ => self.take_opened(opened),
}
}
Some((_, Err(e))) => {
@@ -473,10 +492,68 @@ impl ModelApp {
}
None => self.set_status(format!("{}: stopped unexpectedly", l.title), true),
}
self.open_queued();
self.loading.is_some()
}
/// Open the next dropped file waiting its turn.
fn open_queued(&mut self) {
if let Some(next) = self.queued.pop_front() {
self.open_path(&next);
}
self.loading.is_some()
}
/// A finished job's session replaces the window's.
fn take_opened(&mut self, o: Opened) {
// The session it replaces may be large: it is freed off the window thread.
let old = std::mem::replace(&mut self.session, *o.session);
off_thread("fieldcraft drop".into(), move || drop(old));
self.model_changed();
if o.fit {
self.fit_view();
}
if let Some(p) = &o.recent {
self.recent.add(p);
}
if self.dialog.as_ref().is_some_and(|d| o.form == Some(d.command)) {
self.dialog = None;
}
let warnings: Vec<&str> = o.reply.get("warnings").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).collect();
if warnings.is_empty() {
self.set_status(o.done, false);
} else {
self.set_status(format!("{} — {}", o.done, warnings.join("; ")), true);
}
}
/// Answer [`Prompt::Design`]: a new model with the design (opened again, in the background),
/// the design over the kept model, or neither.
pub fn answer_design(&mut self, answer: DesignAnswer) {
let Some(Prompt::Design(path)) = self.prompt.clone() else { return };
self.prompt = None;
let Some(opened) = self.held.take() else { return };
match answer {
DesignAnswer::Keep => {
let stale = opened.reply.get("stale").map(stale_note).unwrap_or_default();
let done = format!("{}{stale}", opened.done);
self.take_opened(Opened { done, ..opened });
}
DesignAnswer::New => {
let name = file_name(&path);
self.begin_job(
"geometry.open",
json!({"path": path, "model": "new"}),
false,
format!("Opening {name}"),
format!("Opened {name} as a new model"),
Some(path),
);
}
DesignAnswer::Cancel => self.set_status(format!("Did not open {}", file_name(&path)), false),
}
if self.loading.is_none() {
self.open_queued();
}
}
/// Block until the background open finishes (tests and the offscreen harness).
@@ -614,6 +691,18 @@ impl ModelApp {
}
}
/// What a kept model has that the design does not (`geometry.open`'s `stale`), for the status:
/// empty when everything resolves.
fn stale_note(stale: &Value) -> String {
let names = |k: &str| stale.get(k).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).collect::<Vec<_>>().join(", ");
let parts: Vec<String> = [("mesh of", names("mesh_bodies")), ("sets", names("sets"))]
.into_iter()
.filter(|(_, n)| !n.is_empty())
.map(|(what, n)| format!("{what} {n}"))
.collect();
if parts.is_empty() { String::new() } else { format!(" — not on this design: {}", parts.join("; ")) }
}
/// The last part of a path, for messages.
pub fn file_name(path: &str) -> String {
Path::new(path).file_name().and_then(|n| n.to_str()).map_or_else(|| path.to_string(), str::to_string)
@@ -681,6 +770,33 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) {
app.answer_unsaved(a);
}
}
Some(Prompt::Design(path)) => {
let mut answer = None;
crate::frame::window(ctx, "Open design", crate::frame::Width::Normal).id(egui::Id::new("fc_design")).show(ctx, |ui| {
ui.label(format!("{} has none of the bodies {}'s mesh and sets are on.", file_name(&path), app.model_name()));
ui.label(egui::RichText::new("A new model starts empty with this design; keeping the model keeps its mesh and definitions, which then refer to bodies the design does not have.").color(t.text_dim));
ui.add_space(6.0);
ui.horizontal(|ui| {
if ui.button("Start a new model with this design").clicked() {
answer = Some(DesignAnswer::New);
}
if ui.button("Keep the current model").clicked() {
answer = Some(DesignAnswer::Keep);
}
if ui.button("Cancel").clicked() {
answer = Some(DesignAnswer::Cancel);
}
});
});
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
answer = Some(DesignAnswer::Cancel);
} else if ctx.input(|i| i.key_pressed(egui::Key::Enter)) {
answer = Some(DesignAnswer::New);
}
if let Some(a) = answer {
app.answer_design(a);
}
}
Some(Prompt::Recover) => {
let entries = app.recover_list(ctx);
let (mut recover, mut discard, mut close) = (None, None, false);
@@ -948,6 +1064,59 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// A design of another part over a meshed model asks; each answer does what it says, and the
/// same design opens again without asking.
#[test]
fn opening_a_design_of_another_part_asks_what_to_do_with_the_model() {
let dir = temp("design");
let path = dir.join("plate.solidcraft").to_string_lossy().to_string();
std::fs::write(&path, fieldcraft_geometry::sample::plate_design(&fieldcraft_geometry::sample::Plate::default()).unwrap()).unwrap();
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]];
let meshed = || {
let mut app = ModelApp::new(Session::default());
app.run("mesh.block", serde_json::json!({"body": "Seal", "kind": "hex8", "corners": cube, "divisions": [1, 1, 1]})).unwrap();
app.run("model.set.create", serde_json::json!({"name": "seal", "geometry": [{"body": "Seal"}]})).unwrap();
app
};
let opened = |app: &mut ModelApp| {
app.open_path(&path);
app.wait_loaded();
};
// Cancel: nothing changes.
let mut app = meshed();
opened(&mut app);
assert_eq!(app.prompt, Some(Prompt::Design(path.clone())));
assert!(app.session.design.is_none(), "the model waits for the answer");
app.answer_design(DesignAnswer::Cancel);
assert!(app.prompt.is_none() && app.held.is_none() && app.session.design.is_none() && app.session.model.elements().len() == 1);
// Keep: the design over the model, and the status says what does not resolve.
let mut app = meshed();
opened(&mut app);
app.answer_design(DesignAnswer::Keep);
assert!(app.session.design.is_some() && app.session.model.elements().len() == 1);
assert!(app.status.as_ref().is_some_and(|s| s.text.contains("not on this design: mesh of Seal; sets seal")), "{:?}", app.status);
assert_eq!(app.recent.paths, vec![path.clone()]);
// New: an empty model with the design, one undo step from the old one.
let mut app = meshed();
opened(&mut app);
app.answer_design(DesignAnswer::New);
app.wait_loaded();
assert!(app.prompt.is_none() && app.session.design.is_some());
assert!(app.session.model.elements().is_empty() && app.session.model.sets().is_empty());
app.run("edit.undo", serde_json::json!({})).unwrap();
assert!(app.session.design.is_none() && app.session.model.elements().len() == 1);
// The same design again (its bodies are the model's): no question.
app.run("edit.redo", serde_json::json!({})).unwrap();
app.run("model.set.create", serde_json::json!({"name": "plate", "geometry": [{"body": "Body1"}]})).unwrap();
opened(&mut app);
assert!(app.prompt.is_none() && app.session.model.set("plate").is_some());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn decks_are_written_where_the_picker_says() {
let dir = temp("decks");
+3
View File
@@ -313,6 +313,8 @@ pub struct ModelApp {
pub queued: std::collections::VecDeque<String>,
/// The question the window is asking (unsaved changes, recover).
pub prompt: Option<files::Prompt>,
/// A design opened over a model of another part, waiting for [`files::Prompt::Design`]'s answer.
pub held: Option<files::Opened>,
/// The window draws its own title bar and caption buttons (Windows, Linux).
pub custom_titlebar: bool,
/// The title bar runs under the macOS traffic lights.
@@ -382,6 +384,7 @@ impl ModelApp {
loading: None,
queued: std::collections::VecDeque::new(),
prompt: None,
held: None,
custom_titlebar: false,
integrated_titlebar: false,
palette_text: String::new(),
+2 -2
View File
@@ -41,7 +41,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`), `busy` (the background job: a file opening, a solve, a mesher run from its form by a click on OK), `prompt` (`{"unsaved": path}`, `"recover"`, `"samples"`), `recent` (recent files), `selected` (the browser's selected definition), `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`, `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`), `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), `selected` (the browser's selected definition), `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`, `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, rigid walls), `perspective`, `dark`, `show_tree` (the browser), `show_timeline`, `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` | 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) |
@@ -50,7 +50,7 @@ commands (files, undo, `model.info`) leave the ribbon where it is.
| `ui.select` | `kind` (`set`, `material`, `section`, `step`, `boundary_condition`, `load`, `reference_point`, `coupling`, `fluid_domain`), `name`; nothing to deselect | selects a definition as a browser click does; its properties (with Edit and Delete) show in the context panel |
| `ui.select` (viewport) | any of `filter`, `box_mode`, `mode`, `clear`, `at`, `box`, `geometry`, `nodes`, `elements`, `faces` (and no `kind`) | changes the viewport's selection (see [Selection](#selection)); returns `ui.selection`'s reply |
| `ui.selection` | — | the viewport's selection: `filter`, `box_mode` (box selection on), `count`, `capped`, `summary`, `items`, `params` (the selection as a set's members), `hover` (what a click would pick under the pointer) |
| `ui.prompt` | `answer`: for `unsaved` `save`, `discard` or `cancel`; for `recover` `recover` (with `id` from `file.recovery.list`) or `close`; for `samples` `open` (with `name`) or `close` | answers the window's question (`ui.inspect`'s `prompt`) |
| `ui.prompt` | `answer`: for `unsaved` `save`, `discard` or `cancel`; for `design` `new` (a new model with the design), `keep` (the design over the current model) or `cancel`; for `recover` `recover` (with `id` from `file.recovery.list`) or `close`; for `samples` `open` (with `name`) or `close` | answers the window's question (`ui.inspect`'s `prompt`) |
| `ui.dialog` | `params?` (JSON object, fills the form), `use_selection?`, `run?`, `cancel?` | drives the open command form, in that order; `use_selection` is the form's Target row (see [Selection](#selection)); `run` returns the command's result and closes the form on success |
| `ui.click` | `x`, `y` (screen points), `button?`: `left`/`right`/`middle`, `shift?`, `ctrl?`, `alt?`, `double?` | real pointer input; in the viewport a click selects what is under it (Shift adds, Ctrl/Cmd adds or removes, a click on nothing clears the selection), and a click on a results plot also probes the nearest node of the face under it (`solve.probe`; `ui.inspect` shows the values) |
| `ui.move` | `x`, `y` | |