Engine: mesh.mirror reflects meshed bodies across a plane

The bodies' elements are reflected across a datum plane (or a point and
normal) with fieldcraft_mesh::mirror and joined on it. The image is tagged as
the design's body at the reflected place (found by volume and centroid, or
named in `as`), its faces with that body's faces under them, so sets by face
name reach both halves. Half a bar meshed hex8 or hex20 and mirrored has the
whole bar's node and element counts and volume, and its bending deflection to
1e-12; tet4 and tet10 mirror valid and conforming.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-14 20:00:59 -04:00
1 parent 9fc8e27933
commit 0f99a1c687
6 files changed
+387 -3

No files matched your search

+6
View File
@@ -710,6 +710,12 @@ pub const CELLS: &[Field] = &[
TOLERANCE,
];
pub const MIRROR: &[Field] = &[
json("plane", "Plane").req().hint(r#""Datum" or {"point": [x, y, z], "normal": [nx, ny, nz]}"#),
references("bodies", "Bodies", C::Body).hint("every meshed body"),
json("as", "Images").hint(r#"{"body": "image body"}; default: the design's body at the reflected place"#),
];
pub const HEX_OCTREE: &[Field] = &[
BODY,
SURFACE,
+376
View File
@@ -0,0 +1,376 @@
//! `mesh.mirror`: the mesh of some bodies reflected across a plane, joined to them on it.
//!
//! The geometry tags of the image name what it is the image of: the body of the open design that
//! occupies the reflected place (the other half of a part cut on its symmetry plane, found by its
//! volume and centroid, or named in `as`), and on it the face each reflected boundary face lies
//! on (found by position). Without such a body the image is `<body>/mirror` with the source's
//! face names.
use std::collections::{BTreeMap, BTreeSet};
use fieldcraft_geometry::Shape;
use fieldcraft_mesh::mirror::{mirror, reflected_edges, reflected_faces};
use fieldcraft_model::geometry::Entity;
use fieldcraft_model::{ElementFace, GeometryRef, Model};
use serde_json::{Value, json};
use crate::requirement::Requirement;
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 "<body>/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"#,
form: Some(super::forms::MIRROR),
kind: CommandKind::Model,
requires: &[Requirement::Mesh],
run: mirror_mesh,
};
/// Relative difference of volume and centroid (to the body's size) under which a body is the
/// image of another.
const SAME_PLACE: f64 = 1e-4;
/// Distance (relative to the mesh's bounding-box diagonal) within which nodes are on the plane.
const ON_PLANE: f64 = 1e-7;
/// The plane: a datum plane by name, or a point and a normal.
fn plane(s: &Session, p: &Value) -> Result<([f64; 3], [f64; 3])> {
match p.get("plane") {
Some(Value::String(name)) => s
.model
.datum(name)
.and_then(|d| d.plane())
.ok_or_else(|| EngineError::Params(format!("no datum plane `{}`", name.chars().take(64).collect::<String>()))),
Some(v @ Value::Object(_)) => Ok((super::xyz(v, "point")?, super::xyz(v, "normal")?)),
_ => Err(EngineError::Params("`plane` must be a datum plane's name or {\"point\", \"normal\"}".into())),
}
}
fn reflect(x: [f64; 3], point: [f64; 3], n: [f64; 3]) -> [f64; 3] {
let d: f64 = (0..3).map(|i| (x[i] - point[i]) * n[i]).sum();
[x[0] - 2.0 * d * n[0], x[1] - 2.0 * d * n[1], x[2] - 2.0 * d * n[2]]
}
/// The design body at the reflected place of `source`: the same volume and the reflected
/// centroid, within [`SAME_PLACE`] (the kernel measures curved bodies on a tessellation, so the
/// two halves of a symmetric part measure alike only to its accuracy).
fn image_body<'a>(bodies: &'a [Shape], source: &Shape, point: [f64; 3], n: [f64; 3]) -> Result<Option<&'a Shape>> {
let m = source.measure()?;
let size = source.size()?.max(1e-9);
let want = reflect(m.centroid, point, n);
for b in bodies.iter().filter(|b| b.name != source.name) {
let o = b.measure()?;
let d = (0..3).map(|k| (o.centroid[k] - want[k]).powi(2)).sum::<f64>().sqrt();
if (o.volume - m.volume).abs() <= SAME_PLACE * m.volume.abs().max(1e-12) && d <= SAME_PLACE * size {
return Ok(Some(b));
}
}
Ok(None)
}
fn mirror_mesh(s: &mut Session, p: &Value) -> Result<Value> {
let (point, normal) = plane(s, p)?;
let len = normal.iter().map(|x| x * x).sum::<f64>().sqrt();
if !(len > 1e-12 && len.is_finite() && point.iter().all(|x| x.is_finite())) {
return Err(EngineError::Params("the plane needs a finite point and a non-zero normal".into()));
}
let n = normal.map(|x| x / len);
let c = s.model.classification();
// The bodies the mesh is tagged with, by tag.
let tagged: BTreeMap<u32, String> = c
.entities()
.iter()
.enumerate()
.filter(|(_, r)| r.entity == Entity::Body)
.filter_map(|(t, r)| Some((u32::try_from(t).ok()?, r.body.clone())))
.collect();
let bodies: Vec<String> = match p.get("bodies") {
None => {
let mut v: Vec<String> = tagged.values().cloned().collect();
v.dedup();
v
}
Some(_) => super::req(p, "bodies")?,
};
if bodies.is_empty() {
return Err(EngineError::Params("no body to mirror: the mesh is not tagged with a body".into()));
}
for b in &bodies {
if !tagged.values().any(|t| t == b) {
return Err(EngineError::Params(format!("the mesh has no elements of body `{}`", b.chars().take(64).collect::<String>())));
}
}
let named: BTreeMap<String, String> = super::opt(p, "as")?.unwrap_or_default();
let region: BTreeSet<u32> =
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<String, (String, Option<&Shape>)> = BTreeMap::new();
for b in &bodies {
let source = design.as_deref().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(d), Some(src)) => match image_body(&d.bodies, src, point, n)? {
Some(t) => (t.name.clone(), Some(t)),
None => (format!("{b}/mirror"), None),
},
_ => (format!("{b}/mirror"), None),
},
};
if bodies.contains(&target.0) {
return Err(EngineError::Params(format!("the image of `{b}` would be `{}`, which is mirrored too", target.0)));
}
image.insert(b.clone(), target);
}
let (mut lo, mut hi) = ([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]);
for node in s.model.nodes().values() {
for k in 0..3 {
lo[k] = lo[k].min(node.xyz[k]);
hi[k] = hi[k].max(node.xyz[k]);
}
}
let diagonal = (0..3).map(|k| (hi[k] - lo[k]).powi(2)).sum::<f64>().sqrt().max(1e-9);
let before = s.model.clone();
let mut model = s.model.clone();
let r = mirror(&mut model, &region, point, n, ON_PLANE * diagonal).map_err(|e| EngineError::Params(e.to_string()))?;
tag_image(&mut model, &before, &r, &image, point, n, diagonal)?;
let added_nodes = model.nodes().len() - before.nodes().len();
s.model = model;
let images: Vec<Value> = image.iter().map(|(b, (t, shape))| json!({"body": b, "image": t, "in_design": shape.is_some()})).collect();
Ok(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()},
}))
}
/// Tag the image: each copy with its image body, its faces with the faces of that body they lie
/// on (or the source's names), its edges and the images of tagged nodes with the source's names.
fn tag_image(
model: &mut Model,
before: &Model,
r: &fieldcraft_mesh::mirror::Mirrored,
image: &BTreeMap<String, (String, Option<&Shape>)>,
point: [f64; 3],
n: [f64; 3],
diagonal: f64,
) -> Result<()> {
let c = before.classification();
let entity = |t: u32| c.entity(t).cloned();
// A source face's image: by the image body's face under its reflected centroid, one lookup per
// face name (the first element face of it stands for all).
let mut face_points: BTreeMap<(String, String), [f64; 3]> = BTreeMap::new();
for (&e, tags) in c.elements() {
if !r.elements.contains_key(&e) {
continue;
}
for (&f, &t) in &tags.faces {
let Some(GeometryRef { body, entity: Entity::Face(name) }) = entity(t) else { continue };
if face_points.contains_key(&(body.clone(), name.clone())) {
continue;
}
let Some(nodes) = before.face_nodes(ElementFace { element: e, face: f }) else { continue };
let pts: Vec<[f64; 3]> = nodes.iter().filter_map(|n| before.node(*n).map(|x| x.xyz)).collect();
let k = pts.len().max(1) as f64;
let centre = [0, 1, 2].map(|i| pts.iter().map(|p| p[i]).sum::<f64>() / k);
face_points.insert((body, name), reflect(centre, point, n));
}
}
let mut face_names: BTreeMap<(String, String), (String, String)> = BTreeMap::new();
let mut by_body: BTreeMap<String, Vec<(String, [f64; 3])>> = BTreeMap::new();
for ((body, name), p) in &face_points {
by_body.entry(body.clone()).or_default().push((name.clone(), *p));
}
for (body, faces) in by_body {
let Some((target, shape)) = image.get(&body) else { continue };
let found = match shape {
Some(shape) => {
let pts: Vec<[f64; 3]> = faces.iter().map(|(_, p)| *p).collect();
shape.nearest_faces(&pts, (1e-3 * diagonal).max(1e-4))?
}
None => vec![None; faces.len()],
};
for ((name, _), f) in faces.iter().zip(found) {
let mapped = f.and_then(|f| shape.and_then(|s| s.faces().get(f as usize))).map_or_else(|| name.clone(), |x| x.name.clone());
face_names.insert((body.clone(), name.clone()), (target.clone(), mapped));
}
}
let mut tags: BTreeMap<GeometryRef, u32> = BTreeMap::new();
let mut tag = |model: &mut Model, r: GeometryRef| -> Result<u32> {
if let Some(&t) = tags.get(&r) {
return Ok(t);
}
let t = model.geometry_tag(&r)?;
tags.insert(r, t);
Ok(t)
};
for (&e, &copy) in &r.elements {
let Some(el) = before.element(e) else { continue };
let Some(t) = c.elements().get(&e) else { continue };
let faces = reflected_faces(el.kind).unwrap_or_default();
let edges = reflected_edges(el.kind).unwrap_or_default();
if let Some(GeometryRef { body, .. }) = t.region.and_then(entity)
&& let Some((target, _)) = image.get(&body)
{
let tg = tag(model, GeometryRef::body(target.as_str())?)?;
model.tag_element(copy, tg)?;
}
for (&f, &ft) in &t.faces {
let Some(GeometryRef { body, entity: Entity::Face(name) }) = entity(ft) else { continue };
let Some((tb, tn)) = face_names.get(&(body, name)) else { continue };
let g = faces.get(usize::from(f).saturating_sub(1)).copied().unwrap_or(f);
let tg = tag(model, GeometryRef::face(tb.as_str(), tn.as_str())?)?;
model.tag_element_face(copy, g, tg)?;
}
for (&k, &et) in &t.edges {
let Some(GeometryRef { body, entity: ent }) = entity(et) else { continue };
let Some((target, _)) = image.get(&body) else { continue };
let g = edges.get(usize::from(k).saturating_sub(1)).copied().unwrap_or(k);
let tg = tag(model, GeometryRef::new(target.as_str(), ent)?)?;
model.tag_element_edge(copy, g, tg)?;
}
}
for (&node, &copy) in &r.nodes {
if copy == node {
continue;
}
let Some(GeometryRef { body, entity: ent }) = c.nodes().get(&node).and_then(|&t| entity(t)) else { continue };
let Some((target, _)) = image.get(&body) else { continue };
let tg = tag(model, GeometryRef::new(target.as_str(), ent)?)?;
model.tag_node(copy, tg)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
/// A session with a 40 × 10 × 10 bar (along x from the origin) open, partitioned at
/// x = 20 when `half`.
fn bar(half: bool) -> (crate::tests::TempDir, Session) {
let commands = json!({"commands": [
{"command": "solid.box", "params": {"length": 40, "width": 10, "height": 10, "body_name": "Bar"}},
]});
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();
let b = s.execute("geometry.bodies", &json!({})).unwrap()[0]["bbox"].clone();
let lo: Vec<f64> = b[0].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
let hi: Vec<f64> = b[1].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
assert_eq!(lo, [0.0; 3]);
assert_eq!(hi, [40.0, 10.0, 10.0]);
s.execute("geometry.datum", &json!({"name": "Mid", "by": {"type": "offset", "plane": "yz", "offset": 20}})).unwrap();
if half {
s.execute("geometry.partition", &json!({"name": "Half", "datum": "Mid"})).unwrap();
}
(dir, s)
}
/// The face of `body` nearest to `point`.
fn face(s: &Session, body: &str, point: [f64; 3]) -> String {
let shape = s.design.as_ref().unwrap().body(body).unwrap();
let f = shape.nearest_faces(&[point], 0.01).unwrap()[0].unwrap();
shape.faces()[f as usize].name.clone()
}
/// Clamped at x = 0, 1 kN down on the end at x = 40 (sets by face name on the bodies given),
/// solved; the largest downward deflection and the mesh's volume and counts.
fn bend(s: &mut Session, root: (&str, String), tip: (&str, String)) -> (f64, f64, usize, usize) {
let all: Vec<Value> = s.design.as_ref().unwrap().bodies.iter().map(|b| json!({"body": b.name})).collect();
let script = json!([
{"command": "model.set.create", "params": {"name": "all", "geometry": all}},
{"command": "model.set.create", "params": {"name": "root", "geometry": [{"body": root.0, "face": root.1}]}},
{"command": "model.set.create", "params": {"name": "tip", "geometry": [{"body": tip.0, "face": tip.1}]}},
{"command": "model.material.create", "params": {"name": "Steel", "elastic": {"type": "isotropic", "young": 210000, "poisson": 0.3}}},
{"command": "model.section.create", "params": {"name": "Bar", "set": "all", "material": "Steel", "kind": {"type": "solid"}}},
{"command": "model.boundary_condition.create", "params": {"name": "Clamp", "type": "fixed", "target": {"set": "root"}}},
{"command": "model.load.create", "params": {"name": "Push", "type": "force", "target": {"set": "tip"}, "vector": [0, 0, -1000]}},
{"command": "model.step.create", "params": {"name": "Load", "procedure": {"type": "static"}, "boundary_conditions": ["Clamp"], "loads": ["Push"]}},
]);
s.run_script(&script).unwrap();
s.execute("solve.static", &json!({})).unwrap();
let tip_nodes = s.model.resolve_set("tip").unwrap().nodes;
assert!(!tip_nodes.is_empty());
let probe = s.execute("solve.probe", &json!({"step": "Load", "nodes": tip_nodes})).unwrap();
let w = probe["nodes"].as_array().unwrap().iter().map(|n| -n["displacement"][2].as_f64().unwrap()).fold(f64::MIN, f64::max);
let q = s.execute("mesh.quality", &json!({})).unwrap();
assert_eq!(q["valid"].as_u64().unwrap() as usize, s.model.elements().len(), "{q}");
(w, q["volume"].as_f64().unwrap(), s.model.nodes().len(), s.model.elements().len())
}
/// Half the bar meshed and mirrored is the whole bar meshed: the same counts and volume, and
/// the same bending deflection to round-off, hex8 and hex20; the image's faces carry the
/// whole bar's face names, so the load on the far end (a face of the image only) is found.
#[test]
fn a_mirrored_half_is_the_whole_mesh() {
for order in [1, 2] {
let (_d1, mut whole) = bar(false);
whole.execute("mesh.hex.mapped", &json!({"body": "Bar", "size": 2.5, "order": order})).unwrap();
let (root, tip) = (face(&whole, "Bar", [0.0, 5.0, 5.0]), face(&whole, "Bar", [40.0, 5.0, 5.0]));
let full = bend(&mut whole, ("Bar", root), ("Bar", tip));
let (_d2, mut half) = bar(true);
half.execute("mesh.hex.mapped", &json!({"body": "Bar/a", "size": 2.5, "order": order})).unwrap();
let r = half.execute("mesh.mirror", &json!({"plane": "Mid"})).unwrap();
assert_eq!(r["images"][0], json!({"body": "Bar/a", "image": "Bar/b", "in_design": true}), "{r}");
// 5 × 5 corner nodes on the plane (and 40 mid-edge nodes on it for hex20).
assert_eq!(r["on_plane"], json!(if order == 1 { 25 } else { 65 }), "{r}");
let (root, tip) = (face(&half, "Bar/a", [0.0, 5.0, 5.0]), face(&half, "Bar/b", [40.0, 5.0, 5.0]));
let mirrored = bend(&mut half, ("Bar/a", root), ("Bar/b", tip));
assert_eq!((mirrored.2, mirrored.3), (full.2, full.3), "order {order}: node and element counts");
assert!((mirrored.1 - full.1).abs() < 1e-9 * full.1, "order {order}: volume {} vs {}", mirrored.1, full.1);
assert!((mirrored.0 - full.0).abs() < 1e-9 * full.0, "order {order}: deflection {} vs {}", mirrored.0, full.0);
}
}
/// Tets of either order mirror into valid elements sharing the plane's nodes; the volume
/// doubles.
#[test]
fn mirrored_tets_are_valid_and_conforming() {
for order in ["linear", "quadratic"] {
let (_d, mut s) = bar(true);
s.execute("mesh.tet.generate", &json!({"body": "Bar/a", "size": 4, "order": order})).unwrap();
let v = s.execute("mesh.quality", &json!({})).unwrap()["volume"].as_f64().unwrap();
let r = s.execute("mesh.mirror", &json!({"plane": {"point": [20, 0, 0], "normal": [1, 0, 0]}, "bodies": ["Bar/a"]})).unwrap();
assert!(r["on_plane"].as_u64().unwrap() > 0, "{r}");
let q = s.execute("mesh.quality", &json!({})).unwrap();
assert_eq!(q["valid"].as_u64().unwrap() as usize, s.model.elements().len(), "{q}");
assert!((q["volume"].as_f64().unwrap() - 2.0 * v).abs() < 1e-9 * v, "{q}");
// The plane's faces are interior now: the skin of both bodies is the bar's.
s.execute("model.set.create", &json!({"name": "a", "geometry": [{"body": "Bar/a"}]})).unwrap();
s.execute("model.set.create", &json!({"name": "b", "geometry": [{"body": "Bar/b"}]})).unwrap();
let (a, b) = (s.model.resolve_set("a").unwrap(), s.model.resolve_set("b").unwrap());
assert_eq!(a.elements.len(), b.elements.len());
assert_eq!(a.faces.len(), b.faces.len());
}
}
#[test]
fn hostile_params_change_nothing() {
let (_d, mut s) = bar(true);
s.execute("mesh.hex.mapped", &json!({"body": "Bar/a", "size": 5})).unwrap();
let model = s.model.clone();
for p in [
json!({}),
json!({"plane": "Nope"}),
json!({"plane": {"point": [0, 0, 0], "normal": [0, 0, 0]}}),
json!({"plane": {"point": [f64::MAX, 0, 0], "normal": [1, 0, 0]}}),
json!({"plane": {"point": [0, 0], "normal": [1, 0, 0]}}),
json!({"plane": "Mid", "bodies": ["Nope"]}),
json!({"plane": "Mid", "bodies": []}),
json!({"plane": "Mid", "as": {"Bar/a": "Bar/a"}}),
json!({"plane": "Mid", "as": "x"}),
] {
assert!(s.execute("mesh.mirror", &p).is_err(), "{p}");
}
assert_eq!(s.model, model);
}
}
+2
View File
@@ -14,6 +14,7 @@ mod hex;
pub(crate) mod mesh;
pub use mesh::analytic_surfaces;
mod mirror;
mod model;
pub(crate) mod octree;
mod results;
@@ -55,6 +56,7 @@ pub static ALL: &[CommandSpec] = &[
hex::HEX_MAPPED,
hex::HEX_BLOCKS,
cells::CELLS,
mirror::MIRROR,
tags::TAG,
tags::TAG_CLEAR,
model::UNITS,