geometry.open: choose to keep the model or start a new one, and report what is stale

Opening a design over a model kept its mesh and every definition silently,
even when the design was another part (#145, #114). geometry.open now takes
`model`: "keep" (default: same-design reopen keeps the mesh and sets that
follow persistent face names) or "new" (clears mesh, definitions and
imported surfaces, keeps the unit system). The reply says whether the design
is `related` to the model's geometry references and lists `stale` mesh
bodies and sets. One undo step either way.

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 ddde08fb4c
commit 4a60674530
3 files changed
+123 -3

No files matched your search

+4 -1
View File
@@ -799,7 +799,10 @@ pub const TAG: &[Field] = &[
// ---- Geometry and files ----
pub const OPEN_DESIGN: &[Field] = &[path("path", "Design", false).req()];
pub const OPEN_DESIGN: &[Field] = &[
path("path", "Design", false).req(),
choice("model", "Model", &[("keep", "Keep the current model"), ("new", "Start a new model")]).def("\"keep\""),
];
pub const TESSELLATE: &[Field] = &[reference("body", "Body", C::Body).req(), TOLERANCE];
pub const IMPORT_MESH: &[Field] = &[path("path", "3MF file", false).req()];
pub const FILE_OPEN: &[Field] = &[path("path", "Model", false).req()];
+54 -2
View File
@@ -1,9 +1,12 @@
use std::collections::BTreeSet;
use std::sync::Arc;
use fieldcraft_geometry::tagged_mesh;
use fieldcraft_geometry::{MAX_DESIGN_BYTES, Shape, SurfaceMesh, open_design};
use fieldcraft_geometry::{Design, MAX_DESIGN_BYTES, Shape, SurfaceMesh, open_design};
use fieldcraft_io::SourceFile;
use fieldcraft_io::roots::Access;
use fieldcraft_model::Model;
use fieldcraft_model::geometry::{Entity, GeometryRef};
use serde_json::{Value, json};
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
@@ -11,7 +14,7 @@ use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
pub const OPEN: CommandSpec = CommandSpec {
id: "geometry.open",
label: "Open Design",
params: r#"{"path": "part.solidcraft"} — rebuilds the SolidCraft design and makes its bodies the geometry; opening it again after an edit picks up the change"#,
params: r#"{"path": "part.solidcraft", "model"?: "keep"} — rebuilds the SolidCraft design and makes its bodies the geometry. `model`: "keep" (default) keeps the mesh and every definition, so opening the same design again after an edit picks up the change and sets follow their persistent face names; "new" starts a new model with the design (no mesh, sets, materials, sections, BCs, loads or steps; the unit system stays). The reply's `related` is false when the model (before the open) refers to bodies (mesh tags, set geometry) and the design has none of them, and `stale` lists what no longer resolves: `mesh_bodies` (bodies the mesh is tagged with that the design lacks) and `sets` (sets naming a body or face it lacks). One undo step"#,
form: Some(super::forms::OPEN_DESIGN),
kind: CommandKind::Model,
run: open,
@@ -45,20 +48,69 @@ pub const IMPORT_MESH: CommandSpec = CommandSpec {
};
fn open(s: &mut Session, p: &Value) -> Result<Value> {
let new_model = match p.get("model") {
None | Some(Value::Null) => false,
Some(v) => match v.as_str() {
Some("keep") => false,
Some("new") => true,
_ => return Err(EngineError::Params(r#"`model` must be "keep" or "new""#.into())),
},
};
let path = super::file_param(s, p, "path", Access::Read)?;
let bytes = super::file::read_file(&path, MAX_DESIGN_BYTES as u64)?;
let source = SourceFile::of(super::file::absolute(&path), &bytes)?;
let design = open_design(&bytes)?;
let mut fit = fit(s, &design);
if new_model {
let mut model = Model::default();
model.set_units(s.model.units());
s.model = model;
s.surfaces.clear();
s.sources.meshes.clear();
s.results_view = None;
fit.mesh_bodies.clear();
fit.sets.clear();
}
let out = json!({
"name": design.name,
"bodies": design.bodies.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(),
"errors": design.errors.iter().map(|e| json!({"feature": e.feature, "message": e.message})).collect::<Vec<_>>(),
"model": if new_model { "new" } else { "kept" },
"related": fit.related,
"stale": {"mesh_bodies": fit.mesh_bodies, "sets": fit.sets},
});
s.design = Some(Arc::new(design));
s.sources.design = Some(source);
Ok(out)
}
/// How the model's geometry references fit a design about to become the geometry.
struct Fit {
/// Whether the design (or an imported surface) has a body the model refers to, or the model
/// refers to none: `false` means the model is of another part.
related: bool,
/// Bodies the mesh is tagged with that neither the design nor a surface has.
mesh_bodies: Vec<String>,
/// Sets naming a body or a face that is not there.
sets: Vec<String>,
}
fn fit(s: &Session, design: &Design) -> Fit {
let has = |body: &str| design.body(body).is_some() || s.surfaces.iter().any(|t| t.name == body);
let resolves = |r: &GeometryRef| match (&r.entity, design.body(&r.body)) {
(Entity::Face(face), Some(b)) => b.faces().iter().any(|f| &f.name == face),
_ => has(&r.body),
};
let tagged: BTreeSet<&str> = s.model.classification().entities().iter().map(|r| r.body.as_str()).collect();
let mut referenced = tagged.clone();
referenced.extend(s.model.sets().values().flat_map(|set| set.geometry.iter().map(|r| r.body.as_str())));
Fit {
related: referenced.is_empty() || referenced.iter().any(|b| has(b)),
mesh_bodies: tagged.into_iter().filter(|b| !has(b)).map(str::to_string).collect(),
sets: s.model.sets().iter().filter(|(_, set)| !set.geometry.iter().all(resolves)).map(|(n, _)| n.clone()).collect(),
}
}
fn bodies(s: &mut Session, _: &Value) -> Result<Value> {
let Some(design) = &s.design else { return Ok(json!([])) };
let mut out = Vec::with_capacity(design.bodies.len());
+65
View File
@@ -651,4 +651,69 @@ mod tests {
assert_eq!(wider[0]["faces"], bodies[0]["faces"]);
assert_eq!(s.journal.len(), 2);
}
/// A meshed, defined model of body `body` (a hex block whose bottom face is named `bottom`)
/// with a set on it and one on its face `face`.
fn defined_model(body: &str, face: &str) -> Session {
let mut s = Session::default();
let cube = [[0, 0, 0], [10, 0, 0], [10, 10, 0], [0, 10, 0], [0, 0, 10], [10, 0, 10], [10, 10, 10], [0, 10, 10]];
let script = json!([
{"command": "mesh.block", "params": {"body": body, "kind": "hex8", "corners": cube, "divisions": [1, 1, 1], "faces": {"1": "bottom"}}},
{"command": "model.set.create", "params": {"name": "all", "geometry": [{"body": body}]}},
{"command": "model.set.create", "params": {"name": "hole", "geometry": [{"body": body, "face": face}]}},
{"command": "model.material.create", "params": {"name": "Steel", "elastic": {"type": "isotropic", "young": 210000, "poisson": 0.3}}},
]);
s.run_script(&script).unwrap();
s
}
#[test]
fn reopening_the_same_design_keeps_the_mesh_and_definitions() {
let (_dir, path) = sample_design_file(&Plate::default());
let mut probe = Session::default();
probe.execute("geometry.open", &json!({"path": path})).unwrap();
let faces = probe.execute("geometry.bodies", &json!({})).unwrap()[0]["faces"].clone();
let mut s = defined_model("Body1", faces[0]["name"].as_str().unwrap());
let before = s.model.clone();
let r = s.execute("geometry.open", &json!({"path": path})).unwrap();
assert_eq!((r["model"].as_str(), r["related"].as_bool()), (Some("kept"), Some(true)), "{r}");
assert_eq!(r["stale"], json!({"mesh_bodies": [], "sets": []}));
assert_eq!(s.model, before);
}
#[test]
fn a_different_design_starts_a_new_model_or_reports_what_is_stale() {
let (_dir, path) = sample_design_file(&Plate::default());
let defined = defined_model("Seal", "no-such-face");
let before = defined.model.clone();
assert!(!before.elements().is_empty() && before.sets().len() == 2);
// Kept (the default): nothing is lost, and the reply names what no longer resolves.
let mut kept = defined.clone();
let r = kept.execute("geometry.open", &json!({"path": path})).unwrap();
assert_eq!((r["model"].as_str(), r["related"].as_bool()), (Some("kept"), Some(false)), "{r}");
assert_eq!(r["stale"], json!({"mesh_bodies": ["Seal"], "sets": ["all", "hole"]}));
assert_eq!(kept.model, before);
// New: the mesh and every definition go, the units stay; one undo brings them back.
let mut fresh = defined.clone();
fresh.execute("model.units", &json!({"system": "m-kg-s-N-Pa"})).unwrap();
let units = fresh.model.units();
let before = fresh.model.clone();
let r = fresh.execute("geometry.open", &json!({"path": path, "model": "new"})).unwrap();
assert_eq!((r["model"].as_str(), r["related"].as_bool()), (Some("new"), Some(false)), "{r}");
assert_eq!(r["stale"], json!({"mesh_bodies": [], "sets": []}));
assert!(fresh.model.elements().is_empty() && fresh.model.nodes().is_empty());
assert!(fresh.model.definition_counts().iter().all(|(_, n)| *n == 0));
assert_eq!(fresh.model.units(), units);
assert!(fresh.design.is_some());
fresh.execute("edit.undo", &json!({})).unwrap();
assert_eq!(fresh.model, before);
assert!(fresh.design.is_none());
// A bad choice changes nothing.
let mut s = defined.clone();
assert!(s.execute("geometry.open", &json!({"path": path, "model": "discard"})).is_err());
assert!(s.design.is_none() && s.model == defined.model);
}
}