From 0e4ea5e099beb6e52f81d4f07a7ea9177729a747 Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:29:57 -0400 Subject: [PATCH] Meshers replace the mesh of the bodies they mesh, and only theirs mesh.tet.generate and mesh.planar stacked a second mesh on a meshed body; mesh.hex.*, mesh.hex.octree and mesh.cells wiped every body's mesh. All of them, and mesh.mirror for its image bodies, now go through replace::clear: the body's old elements and the nodes only they use go, other bodies stay. The reply reports replaced {body, elements, nodes}, stale_sets (sets that listed the old labels) and results_cleared; the command is one undo step. replace::MESHERS names the bodies each mesher fills, and replacement() says what a run would replace, for the app to ask first. mesh.block stays additive: a body is built from several blocks. Refs #267. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- crates/engine/src/cmd/cells.rs | 17 +- crates/engine/src/cmd/hex.rs | 65 ++++--- crates/engine/src/cmd/mesh.rs | 41 ++-- crates/engine/src/cmd/mirror.rs | 49 +++-- crates/engine/src/cmd/mod.rs | 1 + crates/engine/src/cmd/octree.rs | 27 ++- crates/engine/src/cmd/replace.rs | 325 +++++++++++++++++++++++++++++++ crates/engine/src/lib.rs | 1 + 8 files changed, 452 insertions(+), 74 deletions(-) create mode 100644 crates/engine/src/cmd/replace.rs diff --git a/crates/engine/src/cmd/cells.rs b/crates/engine/src/cmd/cells.rs index b3e7a41..36e9609 100644 --- a/crates/engine/src/cmd/cells.rs +++ b/crates/engine/src/cmd/cells.rs @@ -30,7 +30,7 @@ use crate::{CommandKind, CommandSpec, EngineError, Result, Session}; pub const CELLS: CommandSpec = CommandSpec { id: "mesh.cells", label: "Partitioned Mesh", - params: r#"{"methods": {"Body1/a": "tet", "Body1/b": "swept"} (per cell; default `default`), "default": "auto", "bodies": ["Body1/a", "Body1/b"] (default: every body of the open design), "size": 2.0 (element size, mm; default 1/25 of the bounding-box diagonal of all of them), "order": 1 | 2 (hex8 and tet4, or hex20 and tet10), "tolerance": 0.05 (chordal tolerance, mm)} — replaces the mesh with one mesh of the cells, each with its method: "auto" (structured hex where a method fits, else tet), "hex" (any structured hex method), "swept", "extrude", "sweep", "revolve", "mapped" or "tet"; or "default": "blocks" (with no `methods`): every cell a mapped block, division counts assigned over all cells together, exactly mesh.hex.blocks with the same parameters. Cells on either side of a partition (faces `partition::a` and `:b`) are joined: where their element faces match node for node the nodes are merged (conforming); otherwise, as between a hex and a tet cell, a set on each side and a tie between them are made (`tie__`, sets `_main` and `_secondary`: the side with fewer nodes is the main surface), replacing ties of those names from an earlier run. Reports each cell's method (and why auto fell back to tet), and each interface: `merged` with the nodes merged, or `tied`"#, + params: r#"{"methods": {"Body1/a": "tet", "Body1/b": "swept"} (per cell; default `default`), "default": "auto", "bodies": ["Body1/a", "Body1/b"] (default: every body of the open design), "size": 2.0 (element size, mm; default 1/25 of the bounding-box diagonal of all of them), "order": 1 | 2 (hex8 and tet4, or hex20 and tet10), "tolerance": 0.05 (chordal tolerance, mm)} — replaces the cells' meshes (as mesh.tet.generate; other bodies keep theirs) with one mesh of the cells, each with its method: "auto" (structured hex where a method fits, else tet), "hex" (any structured hex method), "swept", "extrude", "sweep", "revolve", "mapped" or "tet"; or "default": "blocks" (with no `methods`): every cell a mapped block, division counts assigned over all cells together, exactly mesh.hex.blocks with the same parameters. Cells on either side of a partition (faces `partition::a` and `:b`) are joined: where their element faces match node for node the nodes are merged (conforming); otherwise, as between a hex and a tet cell, a set on each side and a tie between them are made (`tie__`, sets `_main` and `_secondary`: the side with fewer nodes is the main surface), replacing ties of those names from an earlier run. Reports each cell's method (and why auto fell back to tet), and each interface: `merged` with the nodes merged, or `tied`"#, form: Some(super::forms::CELLS), kind: CommandKind::Model, requires: &[Requirement::Design], @@ -179,17 +179,19 @@ fn cells(s: &mut Session, p: &Value) -> Result { meshed.push((cell, info)); } - // Replace the mesh with the cells' meshes, each after the labels before it. - let mut model = s.model.without_mesh(); + // Replace the cells' meshes (replace.rs), each after the labels before it; other bodies + // and the definitions stay. + let model = &mut s.model; + let cleared = super::replace::clear(model, names.iter().map(String::as_str)); let mut report = Vec::with_capacity(meshed.len()); for (cell, mut info) in meshed { let (kind, elements, nodes) = match &cell { Meshed::Hex(b, m) => { - add_hex(&mut model, b, m)?; + add_hex(model, b, m)?; (m.kind, m.element_count(), m.nodes.len()) } Meshed::Tet(t) => { - add_tets(&mut model, &t.name, &t.face_names, &t.mesh)?; + add_tets(model, &t.name, &t.face_names, &t.mesh)?; (t.mesh.kind, t.mesh.elements.len(), t.mesh.points.len()) } }; @@ -200,10 +202,9 @@ fn cells(s: &mut Session, p: &Value) -> Result { } report.push(info); } - let interfaces = join(&mut model, &names, COINCIDENT * diagonal)?; + let interfaces = join(model, &names, COINCIDENT * diagonal)?; let (nodes, elements) = (model.nodes().len(), model.elements().len()); - s.model = model; - Ok(json!({"cells": report, "interfaces": interfaces, "nodes": nodes, "elements": elements, "size": size})) + Ok(cleared.report(s, json!({"cells": report, "interfaces": interfaces, "nodes": nodes, "elements": elements, "size": size}))) } /// One side of an interface: a cell's element faces on one side of a partition. diff --git a/crates/engine/src/cmd/hex.rs b/crates/engine/src/cmd/hex.rs index 4f26e18..98a7cb6 100644 --- a/crates/engine/src/cmd/hex.rs +++ b/crates/engine/src/cmd/hex.rs @@ -16,7 +16,7 @@ use crate::{CommandKind, CommandSpec, EngineError, Result, Session}; pub const HEX_GENERATE: CommandSpec = CommandSpec { id: "mesh.hex.generate", label: "Hex Mesh", - params: r#"{"body": "Body1" (default: every body of the open design) or "surface": 0 (an imported surface), "size": 2.0 (element size, mm; default 1/25 of the bounding-box diagonal), "order": 1 (hex8) or 2 (hex20, mid-edge nodes on the geometry), "method": "auto" (default) | "swept" (any sweep) | "extrude" | "sweep" | "revolve" | "mapped", "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of the diagonal)} — replaces the mesh with structured hexahedra: multi-level sweeps along a direction or around an axis, one-to-one sweeps through linking faces, or mapped blocks. Boundary element faces are tagged with their B-rep faces (named as in geometry.bodies; by index when a face has no name; `weld`, `weld_distance` and `feature_angle` as in mesh.tet.generate), so sets by geometry resolve. Edge seeds (mesh.seed.edge) set the counts and spacing of their edges: exact seeds are held, and seeds that cannot all hold (opposite sides of a mapped face, the layers between two levels) are an error naming them. Reports per body the method, counts and quality; a body no structured method fits is an error that says why"#, + params: r#"{"body": "Body1" (default: every body of the open design) or "surface": 0 (an imported surface), "size": 2.0 (element size, mm; default 1/25 of the bounding-box diagonal), "order": 1 (hex8) or 2 (hex20, mid-edge nodes on the geometry), "method": "auto" (default) | "swept" (any sweep) | "extrude" | "sweep" | "revolve" | "mapped", "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of the diagonal)} — replaces the meshes of the bodies it meshes (as mesh.tet.generate; other bodies keep theirs) with structured hexahedra: multi-level sweeps along a direction or around an axis, one-to-one sweeps through linking faces, or mapped blocks. Boundary element faces are tagged with their B-rep faces (named as in geometry.bodies; by index when a face has no name; `weld`, `weld_distance` and `feature_angle` as in mesh.tet.generate), so sets by geometry resolve. Edge seeds (mesh.seed.edge) set the counts and spacing of their edges: exact seeds are held, and seeds that cannot all hold (opposite sides of a mapped face, the layers between two levels) are an error naming them. Reports per body the method, counts and quality; a body no structured method fits is an error that says why"#, form: Some(super::forms::HEX_GENERATE), kind: CommandKind::Model, requires: &[Requirement::Geometry], @@ -76,6 +76,16 @@ pub(super) fn shape_input(shape: &Shape, tolerance: Option) -> Result(s: &'a Session, p: &Value) -> Result<(String, &'a fieldcraft_geometry::tagged_mesh::TaggedSurface)> { + let n = s.surfaces.len(); + let index = p.get("surface").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()).filter(|&i| i < n); + let index = index.ok_or_else(|| EngineError::Params(format!("`surface` must be the index of an imported surface (there are {n})")))?; + let t = s.surfaces.get(index).ok_or_else(|| EngineError::Params("no such surface".into()))?; + let name = if t.name.trim().is_empty() { format!("Surface{index}") } else { t.name.clone() }; + Ok((name, t)) +} + pub(super) fn inputs<'a>(s: &'a Session, p: &Value) -> Result>> { let tolerance = number(p, "tolerance")?; match (p.get("body"), p.get("surface")) { @@ -88,11 +98,8 @@ pub(super) fn inputs<'a>(s: &'a Session, p: &Value) -> Result>> { .ok_or_else(|| EngineError::Params(format!("no body `{name}` (open a design first)")))?; Ok(vec![Input { seeds: super::seeds::edge_seeds(s)?, ..shape_input(shape, tolerance)? }]) } - (None, Some(i)) => { - let n = s.surfaces.len(); - let index = i.as_u64().and_then(|i| usize::try_from(i).ok()).filter(|&i| i < n); - let index = index.ok_or_else(|| EngineError::Params(format!("`surface` must be the index of an imported surface (there are {n})")))?; - let t = s.surfaces.get(index).ok_or_else(|| EngineError::Params("no such surface".into()))?; + (None, Some(_)) => { + let (name, t) = surface(s, p)?; let (mut lo, mut hi) = ([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]); for q in &t.mesh.positions { for k in 0..3 { @@ -100,7 +107,6 @@ pub(super) fn inputs<'a>(s: &'a Session, p: &Value) -> Result>> { hi[k] = hi[k].max(q[k]); } } - let name = if t.name.trim().is_empty() { format!("Surface{index}") } else { t.name.clone() }; // Faces named by index, as mesh.tet.generate names them. let face_names = (0..t.faces).map(|f| f.to_string()).collect(); Ok(vec![Input { name, mesh: Cow::Borrowed(&t.mesh), surfaces: Vec::new(), face_names, diagonal: diagonal(lo, hi), seeds: Vec::new() }]) @@ -140,7 +146,7 @@ pub const HEX_MAPPED: CommandSpec = CommandSpec { pub const HEX_BLOCKS: CommandSpec = CommandSpec { id: "mesh.hex.blocks", label: "Block Hex Mesh", - params: r#"{"bodies": ["Cell1", "Cell2"] (default: every body of the open design), "size": 2.0 (element size, mm; default 1/25 of the bounding-box diagonal of all of them), "order": 1 (hex8) or 2 (hex20, mid-edge nodes on the geometry), "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of that diagonal)} — replaces the mesh with one conforming all-hex mesh of a part partitioned into cells (bodies that meet face to face, such as the pieces of solid.split_body in SolidCraft): every cell a mapped block (six four-sided faces; faces that meet smoothly, such as the two halves of a hole, count as one), division counts assigned over all cells together, each shared face meshed once and its nodes merged, radial lines graded off holes and fillets. Elements are tagged with their cell's body and boundary faces with its B-rep faces (`weld`, `weld_distance` and `feature_angle` as in mesh.tet.generate). Reports the cells, the shared faces, counts and quality; a cell that is not a block, or cells that touch without sharing whole faces, is an error that says which"#, + params: r#"{"bodies": ["Cell1", "Cell2"] (default: every body of the open design), "size": 2.0 (element size, mm; default 1/25 of the bounding-box diagonal of all of them), "order": 1 (hex8) or 2 (hex20, mid-edge nodes on the geometry), "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of that diagonal)} — replaces the cells' meshes (as mesh.tet.generate; other bodies keep theirs) with one conforming all-hex mesh of a part partitioned into cells (bodies that meet face to face, such as the pieces of solid.split_body in SolidCraft): every cell a mapped block (six four-sided faces; faces that meet smoothly, such as the two halves of a hole, count as one), division counts assigned over all cells together, each shared face meshed once and its nodes merged, radial lines graded off holes and fillets. Elements are tagged with their cell's body and boundary faces with its B-rep faces (`weld`, `weld_distance` and `feature_angle` as in mesh.tet.generate). Reports the cells, the shared faces, counts and quality; a cell that is not a block, or cells that touch without sharing whole faces, is an error that says which"#, form: Some(super::forms::HEX_BLOCKS), kind: CommandKind::Model, requires: &[Requirement::Design], @@ -216,10 +222,12 @@ pub(super) fn hex_blocks(s: &mut Session, p: &Value) -> Result { }; let cm = hex_mesh_cells(&cells, &UniformSize(h), &options).map_err(|e| EngineError::Params(e.to_string()))?; let m = &cm.mesh; - // Replace the mesh: definitions stay, nodes, elements and geometry tags are new. - let mut model = s.model.without_mesh(); + // Replace the cells' meshes (replace.rs); other bodies and the definitions stay. + let model = &mut s.model; + let cleared = super::replace::clear(model, inputs.iter().map(|b| b.name.as_str())); + let (first_node, first_element) = super::fresh_labels(model, m.nodes.len(), m.element_count())?; for (i, xyz) in m.nodes.iter().enumerate() { - model.set_node(label(i)?, *xyz)?; + model.set_node(first_node + i as u32, *xyz)?; } let mut body_tags = Vec::with_capacity(inputs.len()); for b in &inputs { @@ -227,15 +235,15 @@ pub(super) fn hex_blocks(s: &mut Session, p: &Value) -> Result { } for e in 0..m.element_count() { let cell = cm.element_cell.get(e).map_or(0, |&c| c as usize); - let nodes: Vec = m.element(e).unwrap_or(&[]).iter().map(|&i| i + 1).collect(); - model.set_element(label(e)?, m.kind, nodes)?; - model.tag_element(label(e)?, body_tags.get(cell).copied().ok_or_else(|| EngineError::Params("element without a cell".into()))?)?; + let nodes: Vec = m.element(e).unwrap_or(&[]).iter().map(|&i| first_node + i).collect(); + let l = first_element + e as u32; + model.set_element(l, m.kind, nodes)?; + model.tag_element(l, body_tags.get(cell).copied().ok_or_else(|| EngineError::Params("element without a cell".into()))?)?; } - tag_faces(&mut model, &inputs, |e| cm.element_cell.get(e as usize).map_or(0, |&c| c as usize), &m.boundary, 1)?; - s.model = model; + tag_faces(model, &inputs, |e| cm.element_cell.get(e as usize).map_or(0, |&c| c as usize), &m.boundary, first_element)?; let blocks: Vec = inputs.iter().zip(&cm.blocks).map(|(b, d)| json!({"body": b.name, "divisions": d, "elements": d.iter().product::()})).collect(); - Ok(json!({ + let reply = json!({ "cells": inputs.len(), "interfaces": cm.interfaces, "description": m.report.description, @@ -248,12 +256,8 @@ pub(super) fn hex_blocks(s: &mut Session, p: &Value) -> Result { "surface_volume": m.report.surface_volume, "size": h, "blocks": blocks, - })) -} - -/// A 1-based model label for 0-based index `i`. -fn label(i: usize) -> Result { - u32::try_from(i).ok().and_then(|x| x.checked_add(1)).ok_or_else(|| EngineError::Params("too many nodes or elements".into())) + }); + Ok(cleared.report(s, reply)) } /// The `order` parameter: false for hex8, true for hex20. @@ -298,8 +302,10 @@ fn generate(s: &mut Session, p: &Value, default: HexMethod, allowed: &[HexMethod let mesh = hex_body(p, &b, method, quadratic, size)?; meshes.push((b, mesh)); } - // Replace the mesh: definitions stay, nodes, elements and geometry tags are new. - let mut model = s.model.without_mesh(); + // Replace the bodies' meshes (replace.rs); other bodies and the definitions stay. + // A copy: the inputs borrow the session. + let mut model = s.model.clone(); + let cleared = super::replace::clear(&mut model, meshes.iter().map(|(b, _)| b.name.as_str())); let mut report = Vec::with_capacity(meshes.len()); for (b, m) in &meshes { add_hex(&mut model, b, m)?; @@ -318,7 +324,7 @@ fn generate(s: &mut Session, p: &Value, default: HexMethod, allowed: &[HexMethod } let (nodes, elements) = (model.nodes().len(), model.elements().len()); s.model = model; - Ok(json!({"bodies": report, "nodes": nodes, "elements": elements})) + Ok(cleared.report(s, json!({"bodies": report, "nodes": nodes, "elements": elements}))) } /// Mesh one body with `method` (hex20 when `quadratic`) at `size` (default: from its extent); @@ -501,7 +507,12 @@ mod tests { fn mesh_cells_all_blocks_is_mesh_hex_blocks() { let (_dir, mut s) = split_box(false); let blocks = s.execute("mesh.hex.blocks", &json!({"size": 2.5})).unwrap(); - let cells = s.execute("mesh.cells", &json!({"default": "blocks", "size": 2.5})).unwrap(); + let mut cells = s.execute("mesh.cells", &json!({"default": "blocks", "size": 2.5})).unwrap(); + let mut blocks = blocks; + // The same mesh, in place of the first one. + let replaced = cells.as_object_mut().and_then(|o| o.remove("replaced")).unwrap(); + assert_eq!(replaced.as_array().map(Vec::len), Some(4), "{replaced}"); + blocks.as_object_mut().and_then(|o| o.remove("replaced")); assert_eq!(cells, blocks); let e = s.execute("mesh.cells", &json!({"default": "blocks", "methods": {"Box": "tet"}})).unwrap_err().to_string(); assert!(e.contains("methods"), "{e}"); diff --git a/crates/engine/src/cmd/mesh.rs b/crates/engine/src/cmd/mesh.rs index 8dda4b2..4fd19c7 100644 --- a/crates/engine/src/cmd/mesh.rs +++ b/crates/engine/src/cmd/mesh.rs @@ -107,7 +107,7 @@ pub const SURFACE_CHECK: CommandSpec = CommandSpec { pub const TET_GENERATE: CommandSpec = CommandSpec { id: "mesh.tet.generate", label: "Generate Tet Mesh", - params: r#"{"body": "Body1" (default: every body of the open design) or "surface": 0 (an imported surface), "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of the body's bounding-box diagonal), the boundary options of mesh.surface.check ("weld", "weld_distance", "feature_angle"), optional "size": mm (target edge length; default a twentieth of the bounding-box diagonal), "order": "linear" | "quadratic" (tet4 or tet10; quadratic mid-edge nodes lie on the B-rep faces), "quality": true (false: the raw constrained Delaunay tetrahedralization), "quality_target": 40 (degrees: tetrahedra whose smallest dihedral-angle sine is at least sin of this are left alone by improvement, 5..=60), "curvature_angle": 22.5 (degrees per element along curved boundary, 0 for none, at most 90), "grading": 0.3 (largest size change per unit length, 0.05..=2), "max_passes": 20 (improvement passes, 0..=200), "max_points": 1000000 (1..=20000000), "max_steiner": 50000 (0..=10000000), "max_tets": 5000000 (1..=50000000) — the constrained Delaunay tetrahedralization of the welded boundary (every boundary triangle a union of tet faces), refined to the size field with its boundary vertices on the B-rep faces, smoothed and improved (Delaunay refinement, ODT smoothing, Klingner–Shewchuk improvement), with a quality report. Adds the elements and their nodes (labels after the largest existing ones), tags the elements with the body and each boundary element face with its geometry face, and fails, changing nothing, if a surface is invalid or a mesh does not pass the validity gate. Edge seeds (mesh.seed.edge) divide their edges at the seeded nodes, and the size field grades from them. Reports the totals and, under `bodies`, each body's counts, size, quality report and `seeds` (per seeded edge, the mesh edges along it)"#, + params: r#"{"body": "Body1" (default: every body of the open design) or "surface": 0 (an imported surface), "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of the body's bounding-box diagonal), the boundary options of mesh.surface.check ("weld", "weld_distance", "feature_angle"), optional "size": mm (target edge length; default a twentieth of the bounding-box diagonal), "order": "linear" | "quadratic" (tet4 or tet10; quadratic mid-edge nodes lie on the B-rep faces), "quality": true (false: the raw constrained Delaunay tetrahedralization), "quality_target": 40 (degrees: tetrahedra whose smallest dihedral-angle sine is at least sin of this are left alone by improvement, 5..=60), "curvature_angle": 22.5 (degrees per element along curved boundary, 0 for none, at most 90), "grading": 0.3 (largest size change per unit length, 0.05..=2), "max_passes": 20 (improvement passes, 0..=200), "max_points": 1000000 (1..=20000000), "max_steiner": 50000 (0..=10000000), "max_tets": 5000000 (1..=50000000) — the constrained Delaunay tetrahedralization of the welded boundary (every boundary triangle a union of tet faces), refined to the size field with its boundary vertices on the B-rep faces, smoothed and improved (Delaunay refinement, ODT smoothing, Klingner–Shewchuk improvement), with a quality report. Replaces the mesh of each body it meshes (its elements and the nodes only they use; other bodies' meshes stay; the reply's `replaced`, `stale_sets` and `results_cleared` say what went), adds the elements and their nodes (labels after the largest existing ones), tags the elements with the body and each boundary element face with its geometry face, and fails, changing nothing, if a surface is invalid or a mesh does not pass the validity gate. Edge seeds (mesh.seed.edge) divide their edges at the seeded nodes, and the size field grades from them. Reports the totals and, under `bodies`, each body's counts, size, quality report and `seeds` (per seeded edge, the mesh edges along it)"#, form: Some(super::forms::TET), kind: CommandKind::Model, requires: &[Requirement::Geometry], @@ -284,6 +284,8 @@ fn tet_generate(s: &mut Session, p: &Value) -> Result { meshed.push(mesh_body(s, p, input, &cdt_options, &options, quality, several)?); } + // Replace the bodies' meshes (replace.rs); other bodies and the definitions stay. + let cleared = super::replace::clear(&mut s.model, meshed.iter().map(|b| b.name.as_str())); let mut bodies = Vec::with_capacity(meshed.len()); for b in &meshed { let (first_node, first_element) = add_tets(&mut s.model, &b.name, &b.face_names, &b.mesh)?; @@ -316,7 +318,7 @@ fn tet_generate(s: &mut Session, p: &Value) -> Result { let sum = |f: fn(&BodyTets) -> f64| meshed.iter().map(f).sum::(); let count = |f: fn(&BodyTets) -> usize| meshed.iter().map(f).sum::(); let first = bodies.first(); - Ok(json!({ + let reply = json!({ "kind": meshed.first().map(|b| b.mesh.kind.name()), "nodes": count(|b| b.mesh.points.len()), "elements": count(|b| b.mesh.elements.len()), @@ -327,7 +329,8 @@ fn tet_generate(s: &mut Session, p: &Value) -> Result { "boundary_volume": sum(|b| b.mesh.report.boundary_volume), "surface_volume": sum(|b| b.surface_volume), "bodies": bodies, - })) + }); + Ok(cleared.report(s, reply)) } /// Add one body's elements after the model's largest labels, tagging them with the body and each @@ -366,7 +369,7 @@ pub(super) fn add_tets(model: &mut fieldcraft_model::Model, body: &str, face_nam pub const PLANAR: CommandSpec = CommandSpec { id: "mesh.planar", label: "2D Mesh", - params: r#"{"size": 1.0, "kind": "quad4|quad8|tri3|tri6" (default quad4), and either "polygon": [[x, y], ...] with optional "holes": [[[x, y], ...], ...] and "name": "Plane" (the body name its tags use), or "body": "Body1", "face": "F2:top", "tolerance": 0.05 (a planar face of an open design, parallel to XY); optional "max_elements": 1000000 (1..=4000000)} — meshes the planar region with 2D elements in the XY plane (a face at z = c is meshed at z = 0) for plane stress, plane strain or axisymmetric sections (x the radius, Y the axis): free quadrilaterals (or constrained Delaunay triangles) of about `size`, mid-side nodes on the boundary for quadratic kinds. Adds nodes and elements after the largest existing labels and tags them: the elements with the face (polygon: face `area` of body `name`), the boundary element edges with edges named after the neighbouring face of the body (polygon: `e1`, `e2`, … side by side, the outer loop then each hole, and vertices `v1`, … at the corners), so sets on those edges carry loads and boundary conditions"#, + params: r#"{"size": 1.0, "kind": "quad4|quad8|tri3|tri6" (default quad4), and either "polygon": [[x, y], ...] with optional "holes": [[[x, y], ...], ...] and "name": "Plane" (the body name its tags use), or "body": "Body1", "face": "F2:top", "tolerance": 0.05 (a planar face of an open design, parallel to XY); optional "max_elements": 1000000 (1..=4000000)} — meshes the planar region with 2D elements in the XY plane (a face at z = c is meshed at z = 0) for plane stress, plane strain or axisymmetric sections (x the radius, Y the axis): free quadrilaterals (or constrained Delaunay triangles) of about `size`, mid-side nodes on the boundary for quadratic kinds. Replaces the body's mesh (as mesh.tet.generate), then adds nodes and elements after the largest existing labels and tags them: the elements with the face (polygon: face `area` of body `name`), the boundary element edges with edges named after the neighbouring face of the body (polygon: `e1`, `e2`, … side by side, the outer loop then each hole, and vertices `v1`, … at the corners), so sets on those edges carry loads and boundary conditions"#, form: Some(super::forms::PLANAR), kind: CommandKind::Model, requires: &[], @@ -459,6 +462,8 @@ fn planar(s: &mut Session, p: &Value) -> Result { }; let m = mesh_region(&loops, kind, size, &opts)?; + // Replace the body's mesh (replace.rs); other bodies and the definitions stay. + let cleared = super::replace::clear(&mut s.model, [body.as_str()]); let (first_node, first_element) = super::fresh_labels(&s.model, m.nodes.len(), m.elements.len())?; for (i, xy) in m.nodes.iter().enumerate() { s.model.set_node(first_node + i as u32, [xy[0], xy[1], 0.0])?; @@ -489,8 +494,8 @@ fn planar(s: &mut Session, p: &Value) -> Result { s.model.tag_node(first_node + node, t)?; } } - let edges: Vec<&String> = tags.keys().filter_map(|id| names.get(id)).collect(); - Ok(json!({ + let edges: Vec = tags.keys().filter_map(|id| names.get(id)).cloned().collect(); + let reply = json!({ "body": body, "face": face, "kind": kind.name(), @@ -499,7 +504,18 @@ fn planar(s: &mut Session, p: &Value) -> Result { "first_node": first_node, "first_element": first_element, "edges": edges, - })) + }); + Ok(cleared.report(s, reply)) +} + +/// The body `mesh.planar` fills: `body`, or the polygon's `name` (default `Plane`). +pub(super) fn planar_body(_: &Session, p: &Value) -> Result> { + let name = match (p.get("polygon"), p.get("body")) { + (Some(_), None) => p.get("name").map_or(Some("Plane"), Value::as_str), + (None, Some(b)) => b.as_str(), + _ => None, + }; + Ok(vec![name.ok_or_else(|| EngineError::Params("give either `polygon` or `body`".into()))?.to_string()]) } fn node_add(s: &mut Session, p: &Value) -> Result { @@ -797,7 +813,7 @@ mod tests { } #[test] - fn tet_generate_meshes_a_body_with_tags_and_appends() { + fn tet_generate_meshes_a_body_with_tags_and_replaces_its_mesh() { use std::sync::Arc; use fieldcraft_geometry::open_design; @@ -823,11 +839,12 @@ mod tests { let q = s.execute("mesh.quality", &json!({})).unwrap(); assert_eq!(q["valid"].as_u64().unwrap() as usize, elements); assert!(q["summary"].as_str().unwrap().starts_with(&format!("{elements} elements, all valid; scaled Jacobian min ")), "{q}"); - // A second run appends after the existing labels. + // A second run replaces the body's mesh (#267). let again = s.execute("mesh.tet.generate", &json!({"body": "Body1", "tolerance": 0.5})).unwrap(); - assert_eq!(again["first_element"].as_u64().unwrap() as usize, elements + 1); - assert_eq!(s.model.elements().len(), 2 * elements); - assert_eq!(s.journal.len(), 2); + assert_eq!(again["replaced"][0]["elements"].as_u64().unwrap() as usize, elements, "{again}"); + assert_eq!(s.model.elements().len(), elements); + // The same mesh again, under the same labels: nothing changed, so no undo step. + assert_eq!(s.journal.len(), 1); let journal = s.journal.len(); let model = s.model.clone(); diff --git a/crates/engine/src/cmd/mirror.rs b/crates/engine/src/cmd/mirror.rs index e963912..664053d 100644 --- a/crates/engine/src/cmd/mirror.rs +++ b/crates/engine/src/cmd/mirror.rs @@ -21,7 +21,7 @@ use crate::{CommandKind, CommandSpec, EngineError, Result, Session}; pub const MIRROR: CommandSpec = CommandSpec { id: "mesh.mirror", label: "Mirror Mesh", - params: r#"{"plane": "Sym" (a datum plane) or {"point": [x, y, z], "normal": [nx, ny, nz]}, "bodies": ["Part/a"] (default: every body the mesh is tagged with), "as": {"Part/a": "Part/b"} (optional: the body each image is; default the design's body at the reflected place, else "/mirror")} — adds the reflection of the bodies' elements (tet4, tet10, wedge6, hex8, hex20) across the plane: each copy right-handed (its nodes in reflected order), the copies of the nodes on the plane merged with them so the mesh is conforming there, geometry tags carried to the image (its body, and on it the faces the reflected faces lie on, so sets by face name select both halves). Reports the elements and nodes added, the nodes shared on the plane and each image's body"#, + params: r#"{"plane": "Sym" (a datum plane) or {"point": [x, y, z], "normal": [nx, ny, nz]}, "bodies": ["Part/a"] (default: every body the mesh is tagged with), "as": {"Part/a": "Part/b"} (optional: the body each image is; default the design's body at the reflected place, else "/mirror")} — replaces each image body's mesh (as mesh.tet.generate) with the reflection of the bodies' elements (tet4, tet10, wedge6, hex8, hex20) across the plane: each copy right-handed (its nodes in reflected order), the copies of the nodes on the plane merged with them so the mesh is conforming there, geometry tags carried to the image (its body, and on it the faces the reflected faces lie on, so sets by face name select both halves). Reports the elements and nodes added, the nodes shared on the plane and each image's body"#, form: Some(super::forms::MIRROR), kind: CommandKind::Model, requires: &[Requirement::Mesh], @@ -78,7 +78,18 @@ fn image_body<'a>(bodies: &'a [Shape], source: &Shape, point: [f64; 3], n: [f64; Ok(None) } -fn mirror_mesh(s: &mut Session, p: &Value) -> Result { +/// What `mesh.mirror` reflects, across which plane, into which bodies. +struct Plan<'d> { + point: [f64; 3], + /// The plane's unit normal. + n: [f64; 3], + /// The elements reflected. + region: BTreeSet, + /// Each mirrored body's image: its name and, when the design has it, its shape. + image: BTreeMap)>, +} + +fn plan<'d>(s: &Session, design: Option<&'d fieldcraft_geometry::Design>, p: &Value) -> Result> { let (point, normal) = plane(s, p)?; let len = normal.iter().map(|x| x * x).sum::().sqrt(); if !(len > 1e-12 && len.is_finite() && point.iter().all(|x| x.is_finite())) { @@ -115,13 +126,12 @@ fn mirror_mesh(s: &mut Session, p: &Value) -> Result { c.elements().iter().filter(|(_, t)| t.region.and_then(|r| tagged.get(&r)).is_some_and(|b| bodies.contains(b))).map(|(e, _)| *e).collect(); // The image of each body: named, found in the design, or its own `/mirror`. - let design = s.design.clone(); let mut image: BTreeMap)> = BTreeMap::new(); for b in &bodies { - let source = design.as_deref().and_then(|d| d.body(b)); + let source = design.and_then(|d| d.body(b)); let target = match named.get(b) { - Some(t) => (t.clone(), design.as_deref().and_then(|d| d.body(t))), - None => match (design.as_deref(), source) { + Some(t) => (t.clone(), design.and_then(|d| d.body(t))), + None => match (design, source) { (Some(d), Some(src)) => match image_body(&d.bodies, src, point, n)? { Some(t) => (t.name.clone(), Some(t)), None => (format!("{b}/mirror"), None), @@ -134,6 +144,17 @@ fn mirror_mesh(s: &mut Session, p: &Value) -> Result { } image.insert(b.clone(), target); } + Ok(Plan { point, n, region, image }) +} + +/// The bodies `mesh.mirror` fills: the images. +pub(super) fn image_names(s: &Session, p: &Value) -> Result> { + Ok(plan(s, s.design.as_deref(), p)?.image.into_values().map(|(name, _)| name).collect()) +} + +fn mirror_mesh(s: &mut Session, p: &Value) -> Result { + let design = s.design.clone(); + let Plan { point, n, region, image } = plan(s, design.as_deref(), p)?; let (mut lo, mut hi) = ([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]); for node in s.model.nodes().values() { @@ -143,20 +164,22 @@ fn mirror_mesh(s: &mut Session, p: &Value) -> Result { } } let diagonal = (0..3).map(|k| (hi[k] - lo[k]).powi(2)).sum::().sqrt().max(1e-9); - let before = s.model.clone(); - let mut model = s.model.clone(); - let r = mirror(&mut model, ®ion, point, n, ON_PLANE * diagonal).map_err(|e| EngineError::Params(e.to_string()))?; - tag_image(&mut model, &before, &r, &image, point, n, diagonal)?; + // An image body's earlier mesh is replaced (replace.rs). + let model = &mut s.model; + let cleared = super::replace::clear(model, image.values().map(|(name, _)| name.as_str())); + let before = model.clone(); + let r = mirror(model, ®ion, point, n, ON_PLANE * diagonal).map_err(|e| EngineError::Params(e.to_string()))?; + tag_image(model, &before, &r, &image, point, n, diagonal)?; let added_nodes = model.nodes().len() - before.nodes().len(); - s.model = model; let images: Vec = image.iter().map(|(b, (t, shape))| json!({"body": b, "image": t, "in_design": shape.is_some()})).collect(); - Ok(json!({ + let reply = json!({ "elements": r.elements.len(), "nodes": added_nodes, "on_plane": r.on_plane, "images": images, "total": {"nodes": s.model.nodes().len(), "elements": s.model.elements().len()}, - })) + }); + Ok(cleared.report(s, reply)) } /// Tag the image: each copy with its image body, its faces with the faces of that body they lie diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index a002db1..a240482 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -17,6 +17,7 @@ pub use mesh::analytic_surfaces; mod mirror; mod model; pub(crate) mod octree; +pub mod replace; mod results; pub(crate) mod seeds; pub(crate) mod solve; diff --git a/crates/engine/src/cmd/octree.rs b/crates/engine/src/cmd/octree.rs index 7589552..889a271 100644 --- a/crates/engine/src/cmd/octree.rs +++ b/crates/engine/src/cmd/octree.rs @@ -15,7 +15,7 @@ use crate::{CommandKind, CommandSpec, EngineError, Result, Session}; pub const HEX_OCTREE: CommandSpec = CommandSpec { id: "mesh.hex.octree", label: "Octree Hex Mesh", - params: r#"{"body": "Body1" (default: every body of the open design) or "surface": 0 (an imported surface), "size": 2.0 (element size in the interior, mm; default 1/25 of the bounding-box diagonal), "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of the diagonal), "max_depth": 3 (refinement levels below the size, each a third, 0..=5), "max_elements": 2000000 (1..=10000000; default the session limit), "attempts": 4 (builds, refining where one failed, 1..=8), "max_sweeps": 300 (optimiser sweeps, 0..=10000), and the boundary options of mesh.tet.generate ("weld", "weld_distance", "feature_angle")} — replaces the mesh with an unstructured all-hex8 mesh of any part: an adaptive 3-refinement grid with conforming transition templates, a core inside the part, two boundary layers that capture every B-rep face boundary and sharp edge, and untangling. Every element is proven valid; boundary element faces are tagged with their B-rep faces (named as in geometry.bodies; by index when a face has no name), so sets by geometry resolve. Reports per body the elements, quality, volume and grid statistics"#, + params: r#"{"body": "Body1" (default: every body of the open design) or "surface": 0 (an imported surface), "size": 2.0 (element size in the interior, mm; default 1/25 of the bounding-box diagonal), "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of the diagonal), "max_depth": 3 (refinement levels below the size, each a third, 0..=5), "max_elements": 2000000 (1..=10000000; default the session limit), "attempts": 4 (builds, refining where one failed, 1..=8), "max_sweeps": 300 (optimiser sweeps, 0..=10000), and the boundary options of mesh.tet.generate ("weld", "weld_distance", "feature_angle")} — replaces the meshes of the bodies it meshes (as mesh.tet.generate; other bodies keep theirs) with an unstructured all-hex8 mesh of any part: an adaptive 3-refinement grid with conforming transition templates, a core inside the part, two boundary layers that capture every B-rep face boundary and sharp edge, and untangling. Every element is proven valid; boundary element faces are tagged with their B-rep faces (named as in geometry.bodies; by index when a face has no name), so sets by geometry resolve. Reports per body the elements, quality, volume and grid statistics"#, form: Some(super::forms::HEX_OCTREE), kind: CommandKind::Model, requires: &[Requirement::Geometry], @@ -64,22 +64,20 @@ fn hex_octree(s: &mut Session, p: &Value) -> Result { total += mesh.hexes.len(); meshes.push((b, mesh)); } - // Replace the mesh: definitions stay, nodes, elements and geometry tags are new. - let mut model = s.model.without_mesh(); + // Replace the bodies' meshes (replace.rs); other bodies and the definitions stay. + let mut model = s.model.clone(); + let cleared = super::replace::clear(&mut model, meshes.iter().map(|(b, _)| b.name.as_str())); let mut report = Vec::with_capacity(meshes.len()); - let (mut node_label, mut element_label) = (1u32, 1u32); for (b, m) in &meshes { - let first_node = node_label; - for xyz in &m.nodes { - model.set_node(node_label, *xyz)?; - node_label = node_label.checked_add(1).ok_or_else(|| EngineError::Params("too many nodes".into()))?; + let (first_node, first_element) = super::fresh_labels(&model, m.nodes.len(), m.hexes.len())?; + for (i, xyz) in m.nodes.iter().enumerate() { + model.set_node(first_node + i as u32, *xyz)?; } let body_tag = model.geometry_tag(&GeometryRef::body(b.name.as_str())?)?; - let first_element = element_label; - for hx in &m.hexes { - model.set_element(element_label, ElementKind::Hex8, hx.iter().map(|&i| first_node + i).collect())?; - model.tag_element(element_label, body_tag)?; - element_label = element_label.checked_add(1).ok_or_else(|| EngineError::Params("too many elements".into()))?; + for (i, hx) in m.hexes.iter().enumerate() { + let l = first_element + i as u32; + model.set_element(l, ElementKind::Hex8, hx.iter().map(|&i| first_node + i).collect())?; + model.tag_element(l, body_tag)?; } tag_faces(&mut model, std::slice::from_ref(b), |_| 0, &m.boundary, first_element)?; let r = &m.report; @@ -99,8 +97,9 @@ fn hex_octree(s: &mut Session, p: &Value) -> Result { "sweeps": r.sweeps, })); } + let (nodes, elements) = (model.nodes().len(), model.elements().len()); s.model = model; - Ok(json!({"bodies": report, "nodes": node_label - 1, "elements": element_label - 1})) + Ok(cleared.report(s, json!({"bodies": report, "nodes": nodes, "elements": elements}))) } #[cfg(test)] diff --git a/crates/engine/src/cmd/replace.rs b/crates/engine/src/cmd/replace.rs new file mode 100644 index 0000000..9bfb224 --- /dev/null +++ b/crates/engine/src/cmd/replace.rs @@ -0,0 +1,325 @@ +//! Meshing a body replaces its mesh (#267). Every mesher that fills bodies goes through +//! [`clear`]: before it adds a body's new elements it removes that body's old elements and the +//! nodes only they use, so other bodies keep their meshes and two meshes never stack on one +//! body. The reply's `replaced` says what went; sets that listed the removed labels are named as +//! `stale_sets` (sets by geometry re-resolve on the new mesh), and results solved on the old +//! mesh as `results_cleared`. The command is one undo step, so undo brings the old mesh back. +//! +//! [`MESHERS`] lists those commands with the bodies each one fills, which the UI reads +//! ([`replacement`]) to ask before replacing a mesh. Programmatic calls never ask. `mesh.block` +//! is not one of them: a body is built from several blocks, each call adding one. + +use std::collections::{BTreeMap, BTreeSet}; + +use fieldcraft_model::{BodyMesh, ElementKind, Model}; +use serde_json::{Value, json}; + +use crate::{EngineError, Result, Session}; + +/// What [`clear`] removed. +#[derive(Default)] +pub(super) struct Cleared { + /// Per body that had a mesh: its name and the element and node counts removed. + bodies: Vec<(String, usize, usize)>, + removed: BodyMesh, + /// Sets that listed a removed node or element by label. + stale_sets: Vec, +} + +/// Remove the meshes of `bodies` from `model` (each body once). +pub(super) fn clear<'a>(model: &mut Model, bodies: impl IntoIterator) -> Cleared { + let mut out = Cleared::default(); + let mut seen = BTreeSet::new(); + for body in bodies { + if !seen.insert(body) { + continue; + } + let r = model.remove_body_mesh(body); + if !r.elements.is_empty() { + out.bodies.push((body.to_string(), r.elements.len(), r.nodes.len())); + out.removed.elements.extend(r.elements); + out.removed.nodes.extend(r.nodes); + } + } + out.stale_sets = model.sets_using(&out.removed); + out +} + +impl Cleared { + /// 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() { + let replaced: Vec = self.bodies.iter().map(|(b, e, n)| json!({"body": b, "elements": e, "nodes": n})).collect(); + o.insert("replaced".into(), json!(replaced)); + if !self.stale_sets.is_empty() { + o.insert("stale_sets".into(), json!(self.stale_sets)); + } + let results: Vec<&str> = s.solved_steps().collect(); + if !results.is_empty() { + o.insert("results_cleared".into(), json!(results)); + } + } + reply + } +} + +/// The bodies a meshing command fills, from its parameters, without meshing anything. +pub type Targets = fn(&Session, &Value) -> Result>; + +/// Every command that fills bodies with a mesh and replaces theirs, with the bodies it fills. +pub static MESHERS: &[(&str, Targets)] = &[ + ("mesh.tet.generate", body_or_all), + ("mesh.hex.generate", body_or_all), + ("mesh.hex.sweep", body_or_all), + ("mesh.hex.mapped", body_or_all), + ("mesh.hex.octree", body_or_all), + ("mesh.hex.blocks", listed_or_all), + ("mesh.cells", listed_or_all), + ("mesh.planar", super::mesh::planar_body), + ("mesh.mirror", super::mirror::image_names), +]; + +/// `body`, the imported `surface`, or every body of the open design. +fn body_or_all(s: &Session, p: &Value) -> Result> { + match (p.get("body"), p.get("surface")) { + (Some(b), None) => Ok(vec![b.as_str().ok_or_else(|| EngineError::param("body", crate::schema::ProblemKind::NotAName))?.to_string()]), + (None, Some(_)) => Ok(vec![super::hex::surface(s, p)?.0]), + (None, None) => design_bodies(s), + _ => Err(EngineError::Params("give `body` or `surface`, not both".into())), + } +} + +/// `bodies`, or every body of the open design. +fn listed_or_all(s: &Session, p: &Value) -> Result> { + match p.get("bodies") { + Some(_) => super::req(p, "bodies"), + None => design_bodies(s), + } +} + +fn design_bodies(s: &Session) -> Result> { + let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?; + Ok(design.bodies.iter().map(|b| b.name.clone()).collect()) +} + +/// What running a meshing command would replace, for a confirmation before it runs. +#[derive(Debug, Clone, PartialEq)] +pub struct Replacement { + /// "CrankArm already has a mesh (7,274 hex20). Replace it?" + pub question: String, + /// What else goes with the old mesh: sets that list its labels, solved results. + pub warnings: Vec, +} + +/// What `command` with `params` would replace: `None` when it is not a mesher, its parameters +/// do not name bodies, or none of its bodies has a mesh. +pub fn replacement(s: &Session, command: &str, params: &Value) -> Option { + let targets = MESHERS.iter().find(|(id, _)| *id == command)?.1; + let bodies = targets(s, params).ok()?; + let mut meshed = Vec::new(); + let mut removed = BodyMesh::default(); + for body in bodies.iter().collect::>() { + let mesh = s.model.body_mesh(body); + if mesh.elements.is_empty() { + continue; + } + let mut counts: BTreeMap = BTreeMap::new(); + for e in mesh.elements.iter().filter_map(|l| s.model.element(*l)) { + *counts.entry(e.kind).or_default() += 1; + } + let kinds: Vec = ElementKind::ALL.iter().filter_map(|k| counts.get(k).map(|n| format!("{} {}", thousands(*n), k.name()))).collect(); + meshed.push((body.as_str(), kinds.join(", "))); + removed.elements.extend(mesh.elements); + removed.nodes.extend(mesh.nodes); + } + let question = match meshed.as_slice() { + [] => return None, + [(body, counts)] => format!("{body} already has a mesh ({counts}). Replace it?"), + many => { + let list: Vec = many.iter().map(|(body, counts)| format!("{body} ({counts})")).collect(); + format!("{} already have meshes. Replace them?", list.join(", ")) + } + }; + let mut warnings = Vec::new(); + let stale = s.model.sets_using(&removed); + if !stale.is_empty() { + warnings.push(format!("Sets that list its nodes or elements by label ({}) will name labels of the old mesh.", stale.join(", "))); + } + let results: Vec<&str> = s.solved_steps().collect(); + if !results.is_empty() { + warnings.push(format!("The results of {} will be cleared.", results.join(", "))); + } + Some(Replacement { question, warnings }) +} + +/// `n` with thousands separators: 7274 → "7,274". +fn thousands(n: usize) -> String { + let digits = n.to_string(); + let mut out = String::with_capacity(digits.len() + digits.len() / 3); + for (i, c) in digits.chars().enumerate() { + if i > 0 && (digits.len() - i).is_multiple_of(3) { + out.push(','); + } + out.push(c); + } + out +} + +#[cfg(test)] +mod tests { + use fieldcraft_model::Element; + + use super::*; + + #[test] + fn every_mesher_is_a_command() { + for (id, _) in MESHERS { + assert!(crate::find_command(id).is_some(), "{id}"); + } + assert_eq!(thousands(7274), "7,274"); + assert_eq!(thousands(999), "999"); + assert_eq!(thousands(1_000_000), "1,000,000"); + } + + /// A 20 × 10 × 10 box split at x = 10 into `Box` (x < 10) and `Box (1)`. + fn two_boxes() -> (crate::tests::TempDir, Session) { + let commands = json!({"commands": [ + {"command": "solid.box", "params": {"length": 20, "width": 10, "height": 10, "body_name": "Box"}}, + {"command": "solid.split_body", "params": {"body": "Box", "plane": {"origin": [10, 0, 0], "normal": [1, 0, 0]}}}, + ]}); + let bytes = fieldcraft_geometry::script_design(&commands.to_string()).unwrap(); + let (dir, path) = crate::tests::design_file(&bytes); + let mut s = Session::default(); + s.execute("geometry.open", &json!({"path": path})).unwrap(); + (dir, s) + } + + /// A body's elements, by label. + fn elements_of(s: &Session, body: &str) -> BTreeMap { + s.model.body_elements(body).into_iter().filter_map(|l| Some((l, s.model.element(l)?.clone()))).collect() + } + + /// Every mesher in [`MESHERS`], run twice on a body: the second run replaces the first (as + /// many elements as a fresh mesh, reported as `replaced`), leaves the other body's mesh as it + /// was, names the set that listed the old labels, and one undo brings the first mesh back. + #[test] + fn every_mesher_replaces_the_mesh_of_its_body_only() { + let tet_other = json!({"command": "mesh.tet.generate", "params": {"body": "Box (1)", "size": 5}}); + let hex_box = json!({"command": "mesh.hex.generate", "params": {"body": "Box", "size": 2.5}}); + let square = json!([[0, 0], [10, 0], [10, 10], [0, 10]]); + let cases: Vec<(&str, Vec, Value, &str, &str)> = vec![ + ("mesh.tet.generate", vec![tet_other.clone()], json!({"body": "Box", "size": 4}), "Box", "Box (1)"), + ("mesh.hex.generate", vec![tet_other.clone()], json!({"body": "Box", "size": 2.5}), "Box", "Box (1)"), + ("mesh.hex.sweep", vec![tet_other.clone()], json!({"body": "Box", "size": 2.5}), "Box", "Box (1)"), + ("mesh.hex.mapped", vec![tet_other.clone()], json!({"body": "Box", "size": 2.5}), "Box", "Box (1)"), + ("mesh.hex.octree", vec![tet_other.clone()], json!({"body": "Box", "size": 2.5}), "Box", "Box (1)"), + ("mesh.hex.blocks", vec![tet_other.clone()], json!({"bodies": ["Box"], "size": 2.5}), "Box", "Box (1)"), + ("mesh.cells", vec![tet_other.clone()], json!({"bodies": ["Box"], "size": 2.5, "default": "hex"}), "Box", "Box (1)"), + ("mesh.planar", vec![tet_other.clone()], json!({"polygon": square, "size": 2.5, "name": "Box"}), "Box", "Box (1)"), + // The image's tets (meshed before) are replaced by the mirrored hexahedra. + ( + "mesh.mirror", + vec![hex_box, tet_other], + json!({"plane": {"point": [10, 0, 0], "normal": [1, 0, 0]}, "bodies": ["Box"]}), + "Box (1)", + "Box", + ), + ]; + let covered: BTreeSet<&str> = cases.iter().map(|c| c.0).collect(); + assert_eq!(covered, MESHERS.iter().map(|m| m.0).collect::>(), "every mesher has a case"); + + let (_dir, fresh) = two_boxes(); + for (id, prep, params, body, other) in cases { + let mut s = fresh.clone(); + for c in &prep { + s.execute(c["command"].as_str().unwrap(), &c["params"]).unwrap(); + } + // What the session says it would replace matches what the command replaces. + let first = s.execute(id, ¶ms).unwrap(); + let fresh_count = elements_of(&s, body).len(); + assert!(fresh_count > 0, "{id}: {first}"); + let others = elements_of(&s, other); + assert!(!others.is_empty(), "{id}"); + let some: Vec = elements_of(&s, body).keys().take(3).copied().collect(); + s.execute("model.set.create", &json!({"name": "by_label", "elements": some})).unwrap(); + let asked = replacement(&s, id, ¶ms).unwrap_or_else(|| panic!("{id}: no question")); + assert!(asked.question.starts_with(&format!("{body} already has a mesh (")), "{id}: {asked:?}"); + assert!(asked.warnings.iter().any(|w| w.contains("by_label")), "{id}: {asked:?}"); + + let before = s.model.clone(); + let second = s.execute(id, ¶ms).unwrap(); + assert_eq!(elements_of(&s, body).len(), fresh_count, "{id}: {second}"); + assert_eq!(elements_of(&s, other), others, "{id}: the other body's mesh is untouched"); + assert_eq!( + second["replaced"], + json!([{"body": body, "elements": fresh_count, "nodes": second["replaced"][0]["nodes"]}]), + "{id}: {second}" + ); + assert_eq!(second["stale_sets"], json!(["by_label"]), "{id}: {second}"); + // No node is left that no element uses. + let used: BTreeSet = s.model.elements().values().flat_map(|e| e.nodes.iter().copied()).collect(); + assert!(s.model.nodes().keys().all(|n| used.contains(n)), "{id}: orphan nodes"); + + // A mesher is deterministic: the same mesh again is the same model, and no undo step. + if s.model != before { + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.model, before, "{id}: undo restores the first mesh"); + } + } + } + + #[test] + fn tets_over_hexahedra_leave_only_tets() { + let (_dir, mut s) = two_boxes(); + s.execute("mesh.hex.generate", &json!({"size": 2.5})).unwrap(); + let hexes = elements_of(&s, "Box").len(); + let other = elements_of(&s, "Box (1)"); + let r = s.execute("mesh.tet.generate", &json!({"body": "Box", "size": 4})).unwrap(); + assert_eq!(r["replaced"][0]["elements"], hexes, "{r}"); + assert!(elements_of(&s, "Box").values().all(|e| e.kind == ElementKind::Tet4)); + assert_eq!(elements_of(&s, "Box (1)"), other); + let tets = s.model.clone(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(elements_of(&s, "Box").len(), hexes, "one undo brings the hexahedra back"); + assert!(elements_of(&s, "Box").values().all(|e| e.kind == ElementKind::Hex8)); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.model, tets); + // Meshing every body replaces both. + let r = s.execute("mesh.tet.generate", &json!({"size": 4})).unwrap(); + assert_eq!(r["replaced"].as_array().map(Vec::len), Some(2), "{r}"); + assert!(s.model.elements().values().all(|e| e.kind == ElementKind::Tet4)); + // A body without a mesh asks nothing. + let (_dir2, s2) = two_boxes(); + assert_eq!(replacement(&s2, "mesh.tet.generate", &json!({"body": "Box"})), None); + assert_eq!(replacement(&s, "mesh.block", &json!({"body": "Box"})), None, "not a mesher"); + } + + #[test] + fn results_solved_on_the_old_mesh_are_cleared_and_undo_brings_them_back() { + let mut s = Session::default(); + let corners = json!([[0, 0, 0], [10, 0, 0], [10, 1, 0], [0, 1, 0], [0, 0, 1], [10, 0, 1], [10, 1, 1], [0, 1, 1]]); + s.run_script(&json!([ + {"command": "mesh.block", "params": {"body": "Bar", "kind": "hex8", "corners": corners, "divisions": [10, 1, 1], "faces": {"6": "root", "4": "tip"}}}, + {"command": "model.set.create", "params": {"name": "all", "geometry": [{"body": "Bar"}]}}, + {"command": "model.set.create", "params": {"name": "root", "geometry": [{"body": "Bar", "face": "root"}]}}, + {"command": "model.set.create", "params": {"name": "tip", "geometry": [{"body": "Bar", "face": "tip"}]}}, + {"command": "model.material.create", "params": {"name": "Mat", "elastic": {"type": "isotropic", "young": 1000.0, "poisson": 0.0}}}, + {"command": "model.section.create", "params": {"name": "all", "set": "all", "material": "Mat", "kind": {"type": "solid"}}}, + {"command": "model.boundary_condition.create", "params": {"name": "Clamp", "type": "fixed", "target": {"set": "root"}}}, + {"command": "model.load.create", "params": {"name": "Pull", "type": "force", "target": {"set": "tip"}, "vector": [1, 0, 0]}}, + {"command": "model.step.create", "params": {"name": "Load", "procedure": {"type": "static"}, "boundary_conditions": ["Clamp"], "loads": ["Pull"]}} + ])) + .unwrap(); + s.execute("solve.static", &json!({"step": "Load"})).unwrap(); + assert!(s.result("Load").is_some()); + let asked = replacement(&s, "mesh.planar", &json!({"polygon": [[0, 0], [10, 0], [10, 1], [0, 1]], "size": 1, "name": "Bar"})).unwrap(); + assert_eq!(asked.question, "Bar already has a mesh (10 hex8). Replace it?"); + assert!(asked.warnings.iter().any(|w| w.contains("results of Load")), "{asked:?}"); + let r = s.execute("mesh.planar", &json!({"polygon": [[0, 0], [10, 0], [10, 1], [0, 1]], "size": 1, "name": "Bar"})).unwrap(); + assert_eq!(r["results_cleared"], json!(["Load"]), "{r}"); + assert!(s.result("Load").is_none()); + assert!(s.model.elements().values().all(|e| e.kind.dimension() == 2)); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.result("Load").is_some(), "undo brings the solved state back"); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c4add2c..866d138 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -17,6 +17,7 @@ pub mod catalogue; pub mod cmd; pub use cmd::analytic_surfaces; +pub use cmd::replace::{MESHERS, Replacement, replacement}; pub mod history; pub mod progress; pub mod requirement;