From 09323ddb4510428748f6ddd87923be4771cf6d3b Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Thu, 15 Oct 2026 13:15:19 -0400 Subject: [PATCH] geometry: meshes go with the bodies a partition change removes (#385) Deleting, suppressing or editing a partition (or its datum), or cutting a meshed body, removes the meshes of the bodies that are gone, reported as replaced, in the same undo step. model.check lists meshes of partition cells the open design no longer has (a model saved before the cut changed), and mesh.clear removes them, or the meshes of named bodies. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- crates/engine/src/catalogue.rs | 2 +- crates/engine/src/cmd/datum.rs | 109 +++++++++++++++++++++++-------- crates/engine/src/cmd/forms.rs | 4 ++ crates/engine/src/cmd/mod.rs | 1 + crates/engine/src/cmd/model.rs | 11 ++++ crates/engine/src/cmd/replace.rs | 54 +++++++++++++++ docs/features.md | 2 +- 7 files changed, 153 insertions(+), 30 deletions(-) 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) | |