diff --git a/crates/engine/src/catalogue.rs b/crates/engine/src/catalogue.rs index 2b90d30..5ca0c50 100644 --- a/crates/engine/src/catalogue.rs +++ b/crates/engine/src/catalogue.rs @@ -355,7 +355,7 @@ pub static CATALOGUE: &[Capability] = &[ soon("mesh.untangle", "Untangle", Mesh, "Improve", "M6", "Repair inverted elements"), soon("mesh.layers", "Layers", Mesh, "Improve", "M6", "Boundary padding and layers through thin walls"), ready_more("mesh.node.add", "Node", Mesh, "Edit", "Add a node", &["mesh.node.remove"]), - ready_more("mesh.element.add", "Element", Mesh, "Edit", "Add an element on existing nodes", &["mesh.element.remove"]), + ready_more("mesh.element.add", "Element", Mesh, "Edit", "Add an element on existing nodes", &["mesh.element.remove", "mesh.clear"]), ready("mesh.mirror", "Mirror Mesh", Mesh, "Edit", "Mirror a body's mesh across a plane, joined on the plane (mesh half a symmetric part)"), ready_more( "mesh.node.merge", diff --git a/crates/engine/src/cmd/datum.rs b/crates/engine/src/cmd/datum.rs index d898b3b..2c49449 100644 --- a/crates/engine/src/cmd/datum.rs +++ b/crates/engine/src/cmd/datum.rs @@ -84,7 +84,7 @@ pub const DATUM_POINT_EDIT: CommandSpec = CommandSpec { pub const DATUM_DELETE: CommandSpec = CommandSpec { id: "geometry.datum.delete", label: "Delete Datum", - params: r#"{"name": "Mid", "cascade"?: false} — deletes a datum plane, axis or point; one a partition cuts by is refused (naming the partitions), unless `cascade` deletes those partitions too"#, + params: r#"{"name": "Mid", "cascade"?: false} — deletes a datum plane, axis or point; one a partition cuts by is refused (naming the partitions), unless `cascade` deletes those partitions too (their cells lose their meshes: `replaced`)"#, form: Some(super::forms::DATUM_DELETE), kind: CommandKind::Model, requires: &[Requirement::Datum], @@ -94,7 +94,7 @@ pub const DATUM_DELETE: CommandSpec = CommandSpec { pub const PARTITION: CommandSpec = CommandSpec { id: "geometry.partition", label: "Partition", - params: r#"{"name": "Half", "datum": "Mid", "bodies"?: ["Body1"] (default: every body the plane divides)} — cuts bodies of the open design into cells by a datum plane, in memory (the design file is not changed); the model keeps the partition and applies it again whenever the design is opened, after the partitions before it. A cut body `B` becomes the cells `B/a` (on the plane's negative side) and `B/b` (several pieces on a side: `B/a1`, `B/a2`, …). The two faces of the cut are paired by name: `partition::a` on the cells on the negative side, `partition::b` on the positive side (`…#0`, `…#1` where a cell has several); every other face keeps the persistent name of the face it is a piece of (`#k` for several pieces on one cell), so sets by face name still select it. Reports the cells made and the cut faces named; a plane that divides no body, a listed body it does not divide, or a body the kernel fails to cut (its message) is an error"#, + params: r#"{"name": "Half", "datum": "Mid", "bodies"?: ["Body1"] (default: every body the plane divides)} — cuts bodies of the open design into cells by a datum plane, in memory (the design file is not changed); the model keeps the partition and applies it again whenever the design is opened, after the partitions before it. A cut body `B` becomes the cells `B/a` (on the plane's negative side) and `B/b` (several pieces on a side: `B/a1`, `B/a2`, …). The two faces of the cut are paired by name: `partition::a` on the cells on the negative side, `partition::b` on the positive side (`…#0`, `…#1` where a cell has several); every other face keeps the persistent name of the face it is a piece of (`#k` for several pieces on one cell), so sets by face name still select it. Reports the cells made and the cut faces named; a body it cuts loses its mesh (`replaced`, as a mesher reports it; remesh the cells); a plane that divides no body, a listed body it does not divide, or a body the kernel fails to cut (its message) is an error"#, form: Some(super::forms::PARTITION), kind: CommandKind::Model, requires: &[Requirement::Design, Requirement::DatumPlane], @@ -114,7 +114,7 @@ pub const DATUM_SUPPRESS: CommandSpec = CommandSpec { pub const PARTITION_SUPPRESS: CommandSpec = CommandSpec { id: "geometry.partition.suppress", label: "Suppress Partition", - params: r#"{"name": "Half", "suppressed"?: true} — keeps the partition but leaves it out (false brings it back): the design is cut again without it, so its cells merge back into the bodies they came from"#, + params: r#"{"name": "Half", "suppressed"?: true} — keeps the partition but leaves it out (false brings it back): the design is cut again without it, so its cells merge back into the bodies they came from and lose their meshes (`replaced`)"#, form: Some(super::forms::PARTITION_SUPPRESS), kind: CommandKind::Model, requires: &[Requirement::Partition], @@ -124,7 +124,7 @@ pub const PARTITION_SUPPRESS: CommandSpec = CommandSpec { pub const PARTITION_DELETE: CommandSpec = CommandSpec { id: "geometry.partition.delete", label: "Delete Partition", - params: r#"{"name": "Half"} — removes a partition; the design is cut again by the ones left"#, + params: r#"{"name": "Half"} — removes a partition; the design is cut again by the ones left, and the cells that are gone lose their meshes (`replaced`)"#, form: Some(super::forms::PARTITION_DELETE), kind: CommandKind::Model, requires: &[Requirement::Partition], @@ -437,10 +437,9 @@ fn datum_edit(s: &mut Session, p: &Value, kind: &str) -> Result { } let datum = datum_of(s, kind, p)?; s.model.replace_datum(name, datum)?; - if !s.model.datum_users(name).is_empty() { - recut(s)?; - } - Ok(datum_json(name, s.model.datum(name).unwrap_or(&datum))) + let cleared = if s.model.datum_users(name).is_empty() { super::replace::Cleared::default() } else { recut(s)? }; + let reply = datum_json(name, s.model.datum(name).unwrap_or(&datum)); + Ok(cleared.report_if_any(s, reply)) } fn datum_delete(s: &mut Session, p: &Value) -> Result { @@ -453,10 +452,9 @@ fn datum_delete(s: &mut Session, p: &Value) -> Result { } } s.model.remove_datum(name)?; - if cascade && !partitions.is_empty() { - recut(s)?; - } - Ok(json!({"deleted": name, "partitions": if cascade { partitions } else { Vec::new() }})) + let cleared = if cascade && !partitions.is_empty() { recut(s)? } else { super::replace::Cleared::default() }; + let reply = json!({"deleted": name, "partitions": if cascade { partitions } else { Vec::new() }}); + Ok(cleared.report_if_any(s, reply)) } /// The model's partitions as cuts of the geometry. @@ -477,13 +475,15 @@ pub(crate) fn partitioned(design: &Design, model: &Model) -> Result { Ok(design.partitioned(&cuts(model))?) } -/// Cut the session's design by its model's partitions again. -fn recut(s: &mut Session) -> Result<()> { - if let Some(d) = &s.design { - let cut = partitioned(d, &s.model)?; - s.design = Some(Arc::new(cut)); - } - Ok(()) +/// Cut the session's design by its model's partitions again. The bodies that are gone (cells +/// whose partition went, a body a new partition cut) take their meshes with them (#385): what +/// went is in the returned [`super::replace::Cleared`], which the command reports. +fn recut(s: &mut Session) -> Result { + let Some(d) = &s.design else { return Ok(super::replace::Cleared::default()) }; + let cut = partitioned(d, &s.model)?; + let gone: Vec = d.bodies.iter().filter(|b| cut.body(&b.name).is_none()).map(|b| b.name.clone()).collect(); + s.design = Some(Arc::new(cut)); + Ok(super::replace::clear(&mut s.model, gone.iter().map(String::as_str))) } fn partition(s: &mut Session, p: &Value) -> Result { @@ -492,7 +492,7 @@ fn partition(s: &mut Session, p: &Value) -> Result { let bodies: Vec = super::opt(p, "bodies")?.unwrap_or_default(); datum_plane_of(&s.model, &datum)?; s.model.add_partition(Partition { name: name.clone(), datum: datum.clone(), bodies, suppressed: false })?; - recut(s)?; + let cleared = recut(s)?; let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?; let feature = format!("partition {name}"); let errors: Vec<&str> = design.errors.iter().filter(|e| e.feature == feature).map(|e| e.message.as_str()).collect(); @@ -511,39 +511,40 @@ fn partition(s: &mut Session, p: &Value) -> Result { if s.view.hidden.len() < MAX_HIDDEN { s.view.hidden.insert(Item { kind: ItemKind::Datum, name: datum }); } - Ok(json!({ + let reply = json!({ "name": name, "split": split, "cells": cells, "cut_faces": faces, "bodies": bodies, - })) + }); + Ok(cleared.report_if_any(s, reply)) } fn datum_suppress(s: &mut Session, p: &Value) -> Result { let name = super::name(p)?; let suppressed = super::opt(p, "suppressed")?.unwrap_or(true); s.model.set_datum_suppressed(name, suppressed)?; - recut(s)?; + let cleared = recut(s)?; let bodies: Vec = s.design.iter().flat_map(|d| d.bodies.iter().map(|b| b.name.clone())).collect(); - Ok(json!({"name": name, "suppressed": suppressed, "bodies": bodies})) + Ok(cleared.report_if_any(s, json!({"name": name, "suppressed": suppressed, "bodies": bodies}))) } fn partition_suppress(s: &mut Session, p: &Value) -> Result { let name = super::name(p)?; let suppressed = super::opt(p, "suppressed")?.unwrap_or(true); s.model.set_partition_suppressed(name, suppressed)?; - recut(s)?; + let cleared = recut(s)?; let bodies: Vec = s.design.iter().flat_map(|d| d.bodies.iter().map(|b| b.name.clone())).collect(); - Ok(json!({"name": name, "suppressed": suppressed, "bodies": bodies})) + Ok(cleared.report_if_any(s, json!({"name": name, "suppressed": suppressed, "bodies": bodies}))) } fn partition_delete(s: &mut Session, p: &Value) -> Result { let name = super::name(p)?; s.model.remove_partition(name)?; - recut(s)?; + let cleared = recut(s)?; let bodies: Vec = s.design.iter().flat_map(|d| d.bodies.iter().map(|b| b.name.clone())).collect(); - Ok(json!({"deleted": name, "bodies": bodies})) + Ok(cleared.report_if_any(s, json!({"deleted": name, "bodies": bodies}))) } #[cfg(test)] @@ -860,4 +861,56 @@ pub(crate) mod tests { let z = |n: &str| design.body(n).unwrap().measure().unwrap().centroid[2]; assert!(z(centre[0]) < 9.0 && z(centre[1]) > 11.0, "{centre:?}"); } + + /// #385: the cells' meshes go with the cells (deleting, suppressing or replacing their + /// partitions), a body's mesh goes when a partition cuts it, and meshes left over from an + /// earlier cut are listed by model.check and removed by mesh.clear. + #[test] + fn meshes_go_with_the_bodies_partitions_remove() { + let (mut s, dir) = plate(); + let whole = s.design.as_ref().unwrap().bodies[0].measure().unwrap().volume; + let volume = |s: &mut Session| s.execute("mesh.quality", &json!({})).unwrap()["volume"].as_f64().unwrap(); + // Partitioning a meshed body takes its mesh. + s.execute("mesh.hex.generate", &json!({"size": 2.5})).unwrap(); + s.execute("geometry.datum", &json!({"name": "X30", "by": {"type": "offset", "plane": "yz", "offset": 30}})).unwrap(); + let r = s.execute("geometry.partition", &json!({"name": "P", "datum": "X30"})).unwrap(); + assert_eq!(r["replaced"][0]["body"], "Body1", "{r}"); + assert!(s.model.elements().is_empty()); + // Deleting the datum with its partition takes the cells' meshes; the whole body's new + // mesh is the only one. + s.execute("mesh.cells", &json!({"default": "hex", "size": 2.5})).unwrap(); + let r = s.execute("geometry.datum.delete", &json!({"name": "X30", "cascade": true})).unwrap(); + let gone: Vec<&str> = r["replaced"].as_array().unwrap().iter().map(|b| b["body"].as_str().unwrap()).collect(); + assert_eq!(gone, ["Body1/a", "Body1/b"], "{r}"); + assert!(s.model.elements().is_empty()); + s.execute("mesh.hex.generate", &json!({"size": 2.5})).unwrap(); + let v = volume(&mut s); + assert!((v - whole).abs() < 1e-2 * whole, "{v} vs {whole}"); + // Suppressing or deleting a partition does the same. + s.execute("geometry.datum", &json!({"name": "X30", "by": {"type": "offset", "plane": "yz", "offset": 30}})).unwrap(); + s.execute("geometry.partition", &json!({"name": "P", "datum": "X30"})).unwrap(); + s.execute("mesh.cells", &json!({"default": "hex", "size": 2.5})).unwrap(); + let r = s.execute("geometry.partition.suppress", &json!({"name": "P"})).unwrap(); + assert_eq!(r["replaced"].as_array().map(Vec::len), Some(2), "{r}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!s.model.elements().is_empty(), "undo brings the cells' meshes back"); + let r = s.execute("geometry.partition.delete", &json!({"name": "P"})).unwrap(); + assert_eq!(r["replaced"].as_array().map(Vec::len), Some(2), "{r}"); + // A mesh left from an earlier cut (a model saved before the cut changed): listed and + // cleared. + s.execute("geometry.partition", &json!({"name": "P", "datum": "X30"})).unwrap(); + s.execute("mesh.cells", &json!({"default": "hex", "size": 2.5})).unwrap(); + s.model.remove_partition("P").unwrap(); + let d = s.design.clone().unwrap(); + s.design = Some(Arc::new(partitioned(&d, &s.model).unwrap())); + let check = s.execute("model.check", &json!({})).unwrap(); + let listed: Vec<&str> = + check["diagnostics"].as_array().unwrap().iter().filter_map(|d| d["subject"].as_str()).filter(|d| d.starts_with("body")).collect(); + assert_eq!(listed, ["body `Body1/a`", "body `Body1/b`"], "{check}"); + let r = s.execute("mesh.clear", &json!({})).unwrap(); + assert_eq!(r["replaced"].as_array().map(Vec::len), Some(2), "{r}"); + assert!(s.model.elements().is_empty()); + assert!(s.execute("mesh.clear", &json!({"bodies": ["Body1"]})).is_err(), "nothing to clear"); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/engine/src/cmd/forms.rs b/crates/engine/src/cmd/forms.rs index 8139771..5bd344e 100644 --- a/crates/engine/src/cmd/forms.rs +++ b/crates/engine/src/cmd/forms.rs @@ -1129,6 +1129,10 @@ pub const CELLS: &[Field] = &[ TOLERANCE, ]; +pub const CLEAR_MESH: &[Field] = &[references("bodies", "Bodies", C::Body) + .help("The bodies whose meshes go. When none is ticked: the meshes of bodies the open design no longer has because its partitions changed (model.check lists them).") + .hint("the bodies partitions removed")]; + pub const MIRROR: &[Field] = &[ json("plane", "Plane") .help("The mirror plane: a datum plane's name, or a point and normal as JSON.") diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index f137768..64d3c02 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -66,6 +66,7 @@ pub static ALL: &[CommandSpec] = &[ mesh::NODE_REMOVE, mesh::ELEMENT_ADD, mesh::ELEMENT_REMOVE, + replace::CLEAR, mesh::QUALITY, mesh::DELAUNAY_POINTS, mesh::SURFACE_CHECK, diff --git a/crates/engine/src/cmd/model.rs b/crates/engine/src/cmd/model.rs index 5f22561..b6e78ec 100644 --- a/crates/engine/src/cmd/model.rs +++ b/crates/engine/src/cmd/model.rs @@ -56,6 +56,17 @@ fn units(s: &mut Session, p: &Value) -> Result { fn check(s: &mut Session, _: &Value) -> Result { let mut diagnostics = s.model.check(); + if let Some(design) = s.design.as_deref() { + for (body, n) in super::replace::stale(&s.model, design) { + diagnostics.push(fieldcraft_model::Diagnostic { + severity: Severity::Warning, + subject: format!("body `{body}`"), + message: format!( + "{n} elements mesh a body the open design no longer has (its partitions changed since it was meshed); mesh.clear removes them" + ), + }); + } + } if let Some(why) = s.mesh_outdated() { diagnostics.push(fieldcraft_model::Diagnostic { severity: Severity::Warning, subject: "mesh".into(), message: why }); } diff --git a/crates/engine/src/cmd/replace.rs b/crates/engine/src/cmd/replace.rs index c88cd7a..fddcd35 100644 --- a/crates/engine/src/cmd/replace.rs +++ b/crates/engine/src/cmd/replace.rs @@ -46,6 +46,11 @@ pub(super) fn clear<'a>(model: &mut Model, bodies: impl IntoIterator Value { + if self.bodies.is_empty() { reply } else { self.report(s, reply) } + } + /// Add `replaced`, `stale_sets` and `results_cleared` to a command's reply (an object). pub(super) fn report(self, s: &Session, mut reply: Value) -> Value { if let Some(o) = reply.as_object_mut() { @@ -63,6 +68,55 @@ impl Cleared { } } +pub const CLEAR: crate::CommandSpec = crate::CommandSpec { + id: "mesh.clear", + label: "Clear Mesh", + params: r#"{"bodies"?: ["Body1/a"]} — removes the meshes of the bodies (their elements and the nodes only they use; sets that listed them are named as `stale_sets`, results solved on them are cleared); without `bodies`, of every body the open design no longer has because its partitions changed since it was meshed (model.check lists them; none: nothing changes). Reports what went as `replaced`"#, + form: Some(super::forms::CLEAR_MESH), + kind: crate::CommandKind::Model, + requires: &[crate::requirement::Requirement::Mesh], + run: clear_meshes, +}; + +fn clear_meshes(s: &mut Session, p: &Value) -> Result { + let bodies: Vec = match p.get("bodies") { + Some(_) => super::req(p, "bodies")?, + None => s.design.as_deref().map(|d| stale(&s.model, d).into_iter().map(|(b, _)| b).collect()).unwrap_or_default(), + }; + for b in &bodies { + if s.model.body_elements(b).is_empty() { + return Err(EngineError::Params(format!("body `{}` has no mesh", b.chars().take(64).collect::()))); + } + } + let cleared = clear(&mut s.model, bodies.iter().map(String::as_str)); + let (nodes, elements) = (s.model.nodes().len(), s.model.elements().len()); + Ok(cleared.report(s, json!({"nodes": nodes, "elements": elements}))) +} + +/// The meshed bodies the open design no longer has because its partitions changed (#385): cells +/// of a partition that is gone (`Base/a/b`), or a body partitions now cut (`Base`), each with +/// its element count. Other bodies the design lacks (mirror images, imported surfaces, blocks) +/// are not stale. +pub(crate) fn stale(model: &Model, design: &fieldcraft_geometry::Design) -> Vec<(String, usize)> { + let cell = |name: &str| -> bool { + let Some(source) = design.source_of(name) else { return false }; + let rest = name.get(source.len()..).unwrap_or_default(); + rest.split('/').skip(1).all(|seg| seg.strip_prefix(['a', 'b']).is_some_and(|k| k.bytes().all(|c| c.is_ascii_digit()))) + }; + model + .classification() + .entities() + .iter() + .filter(|g| matches!(g.entity, fieldcraft_model::geometry::Entity::Body)) + .map(|g| g.body.as_str()) + .filter(|b| design.body(b).is_none() && cell(b)) + .filter_map(|b| { + let n = model.body_elements(b).len(); + (n > 0).then(|| (b.to_string(), n)) + }) + .collect() +} + /// The bodies a meshing command fills, from its parameters, without meshing anything. pub type Targets = fn(&Session, &Value) -> Result>; diff --git a/docs/features.md b/docs/features.md index 6686341..a62a9e8 100644 --- a/docs/features.md +++ b/docs/features.md @@ -47,7 +47,7 @@ the control channel and AI agents over MCP (docs/mcp.md) run the same commands. | Improve | Untangle — Repair inverted elements | coming soon (M6) | | | Improve | Layers — Boundary padding and layers through thin walls | coming soon (M6) | | | Edit | Node — Add a node | ready | `mesh.node.add`, `mesh.node.remove` | -| Edit | Element — Add an element on existing nodes | ready | `mesh.element.add`, `mesh.element.remove` | +| Edit | Element — Add an element on existing nodes | ready | `mesh.element.add`, `mesh.element.remove`, `mesh.clear` | | Edit | Mirror Mesh — Mirror a body's mesh across a plane, joined on the plane (mesh half a symmetric part) | ready | `mesh.mirror` | | Edit | Merge Nodes — Merge coincident nodes, keeping sets, loads and boundary conditions on the survivors; check for duplicates and cracks, renumber | ready | `mesh.node.merge`, `mesh.check.duplicates`, `mesh.renumber` | | Edit | Remesh Region — Remesh a region in place; split and collapse elements | coming soon (M6) | |