From 4a60674530bd6758fdacb0fd84714f2d70144f68 Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:01:15 -0400 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- crates/engine/src/cmd/forms.rs | 5 ++- crates/engine/src/cmd/geometry.rs | 56 +++++++++++++++++++++++++- crates/engine/src/lib.rs | 65 +++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 3 deletions(-) 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); + } }