forked from 1b-agent/fieldcraft-upstream
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:
1 parent
ddde08fb4c
commit
4a60674530
3 files changed
+123
-3
No files matched your search
@@ -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()];
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user