diff --git a/crates/engine/src/cmd/forms.rs b/crates/engine/src/cmd/forms.rs index 5af8fb1..598de42 100644 --- a/crates/engine/src/cmd/forms.rs +++ b/crates/engine/src/cmd/forms.rs @@ -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()]; diff --git a/crates/engine/src/cmd/geometry.rs b/crates/engine/src/cmd/geometry.rs index bf186aa..9984d38 100644 --- a/crates/engine/src/cmd/geometry.rs +++ b/crates/engine/src/cmd/geometry.rs @@ -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 { + 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::>(), "errors": design.errors.iter().map(|e| json!({"feature": e.feature, "message": e.message})).collect::>(), + "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, + /// Sets naming a body or a face that is not there. + sets: Vec, +} + +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 { let Some(design) = &s.design else { return Ok(json!([])) }; let mut out = Vec::with_capacity(design.bodies.len()); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ec833ab..5023047 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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); + } }