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 <[email protected]>
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

+1 -1
View File
@@ -31,7 +31,7 @@ pressure) is a degree of freedom of the existing DOF framework, not a separate s
| M2 Tet meshing and size field | Size field (global, per face and edge seeds, curvature and proximity; the `fieldcraft_mesh::size::SizeField` trait and edge-division helpers are in); Bowyer–Watson Delaunay (exact, symbolic perturbation, verified; `fieldcraft-mesh::delaunay`, done); robust CDT boundary recovery (Diazzi et al. 2023: implicit LNC Steiner points on segments, gift-wrapping facet recovery, inside/outside labelling, B-rep face tags on boundary tet faces, flip repair of rounding (§5.1) checked by the validity gate; `fieldcraft-mesh::cdt` and `mesh.tet.generate`, done: all 20 valid corpus parts); quality stage (done: `fieldcraft-mesh::tet` — curvature-graded size field, edge-collapse coarsening, constrained Delaunay refinement with boundary points on the B-rep faces and curves, ODT smoothing, Klingner–Shewchuk improvement, tet4 and tet10 with mid-side nodes on the B-rep, quality report; one validity criterion shared by the gate and both solvers; the filleted-bracket fillet stress converges, tests/oracle); mesh verify per set | in progress | | M2 Tet meshing and size field | Size field (global, per face and edge seeds, curvature and proximity; the `fieldcraft_mesh::size::SizeField` trait and edge-division helpers are in); Bowyer–Watson Delaunay (exact, symbolic perturbation, verified; `fieldcraft-mesh::delaunay`, done); robust CDT boundary recovery (Diazzi et al. 2023: implicit LNC Steiner points on segments, gift-wrapping facet recovery, inside/outside labelling, B-rep face tags on boundary tet faces, flip repair of rounding (§5.1) checked by the validity gate; `fieldcraft-mesh::cdt` and `mesh.tet.generate`, done: all 20 valid corpus parts); quality stage (done: `fieldcraft-mesh::tet` — curvature-graded size field, edge-collapse coarsening, constrained Delaunay refinement with boundary points on the B-rep faces and curves, ODT smoothing, Klingner–Shewchuk improvement, tet4 and tet10 with mid-side nodes on the B-rep, quality report; one validity criterion shared by the gate and both solvers; the filleted-bracket fillet stress converges, tests/oracle); mesh verify per set | in progress |
| M3 First deck | Sets and surfaces bound to SolidCraft face, edge, vertex and body names, resolved after meshing; materials (isotropic and orthotropic elastic, density) with a unit-system label and consistency check; sections (solid, shell, beam profiles) assigned by set; element formulation per region mapped to each solver; BCs (fixed, displacement, symmetry); loads (force and moment on reference points, pressure, gravity); reference points with kinematic and distributing coupling; steps (linear static, frequency) with explicit per-step load and BC tables and output presets; `model.check` naming every missing or bad item; `model.*` and `mesh.tag` commands (model definition: done); writers for Abaqus input (done: `deck.write`), Nastran SOL 101/103 (done: `deck.write`) and LS-DYNA implicit (done); the `.fieldcraft` file (text definition, binary mesh, geometry references reopened on load, journal; atomic saves, autosave and recovery; undo/redo on the journal: done) | in progress | | M3 First deck | Sets and surfaces bound to SolidCraft face, edge, vertex and body names, resolved after meshing; materials (isotropic and orthotropic elastic, density) with a unit-system label and consistency check; sections (solid, shell, beam profiles) assigned by set; element formulation per region mapped to each solver; BCs (fixed, displacement, symmetry); loads (force and moment on reference points, pressure, gravity); reference points with kinematic and distributing coupling; steps (linear static, frequency) with explicit per-step load and BC tables and output presets; `model.check` naming every missing or bad item; `model.*` and `mesh.tag` commands (model definition: done); writers for Abaqus input (done: `deck.write`), Nastran SOL 101/103 (done: `deck.write`) and LS-DYNA implicit (done); the `.fieldcraft` file (text definition, binary mesh, geometry references reopened on load, journal; atomic saves, autosave and recovery; undo/redo on the journal: done) | in progress |
| M4 Own solver and first results | Element library per docs/elements.md (1D and 2D first); Linear static and modal solver (tet4/10, wedge6, hex8, hex20; sparse direct, Lanczos), verified against analytic solutions (solver: done — `fieldcraft-fem` kernels, `fieldcraft-solver` with nested dissection, sparse Cholesky and block Lanczos, `solve.static`/`solve.frequency`/`solve.probe`, patch, cantilever, Lamé, Kirsch and beam-frequency tests; reference points with kinematic and distributing couplings and moments by master–slave constraint elimination, verified by rigid motion, clamping, Saint-Venant tension, bending and tube torsion; 1D elements I: rod2/rod3 trusses, point masses, rotary inertia, springs and dashpots in both solvers and all three decks, verified by the method of joints, the three-bar truss, spring–mass, two-mass chain and torsional pendulum frequencies, and damped decay; 2D elements: plane stress, plane strain and axisymmetric tri3, tri6, quad4 (incompatible modes QM6), quad8 with two degrees of freedom per node, centrifugal loads, `mesh.planar` 2D meshes of polygons and planar faces, verified by patch tests, Kirsch, Lamé (plane strain and axisymmetric), rotating discs and cylinders, and a plane stress cantilever; deck mapping to Abaqus CPS/CPE/CAX, Nastran membranes, LS-DYNA ELFORM 12/13); shells and beams (done: quad4 MITC4 and tri3 MITC3 flat shells with a Hughes–Brezzi drilling rotation, two-node Timoshenko beams with profile properties and orientations, six degrees of freedom at shell and beam nodes shared with solids and trusses, section forces and surface stresses, verified by shell patch tests, Navier's plate, cantilever and portal-frame beams, and the Scordelis–Lo and pinched-cylinder benchmarks); results display (done: `results.*` commands, deformed shape to scale, banded contours of displacement, von Mises, principal and component stresses with a legend, mode shapes with their frequencies and animation, probing a node by clicking it, reaction force and moment sums per set, stale results marked after an edit; explicit steps: field frames by slider or animation with contours per frame, energy histories plotted) | started | | M4 Own solver and first results | Element library per docs/elements.md (1D and 2D first); Linear static and modal solver (tet4/10, wedge6, hex8, hex20; sparse direct, Lanczos), verified against analytic solutions (solver: done — `fieldcraft-fem` kernels, `fieldcraft-solver` with nested dissection, sparse Cholesky and block Lanczos, `solve.static`/`solve.frequency`/`solve.probe`, patch, cantilever, Lamé, Kirsch and beam-frequency tests; reference points with kinematic and distributing couplings and moments by master–slave constraint elimination, verified by rigid motion, clamping, Saint-Venant tension, bending and tube torsion; 1D elements I: rod2/rod3 trusses, point masses, rotary inertia, springs and dashpots in both solvers and all three decks, verified by the method of joints, the three-bar truss, spring–mass, two-mass chain and torsional pendulum frequencies, and damped decay; 2D elements: plane stress, plane strain and axisymmetric tri3, tri6, quad4 (incompatible modes QM6), quad8 with two degrees of freedom per node, centrifugal loads, `mesh.planar` 2D meshes of polygons and planar faces, verified by patch tests, Kirsch, Lamé (plane strain and axisymmetric), rotating discs and cylinders, and a plane stress cantilever; deck mapping to Abaqus CPS/CPE/CAX, Nastran membranes, LS-DYNA ELFORM 12/13); shells and beams (done: quad4 MITC4 and tri3 MITC3 flat shells with a Hughes–Brezzi drilling rotation, two-node Timoshenko beams with profile properties and orientations, six degrees of freedom at shell and beam nodes shared with solids and trusses, section forces and surface stresses, verified by shell patch tests, Navier's plate, cantilever and portal-frame beams, and the Scordelis–Lo and pinched-cylinder benchmarks); results display (done: `results.*` commands, deformed shape to scale, banded contours of displacement, von Mises, principal and component stresses with a legend, mode shapes with their frequencies and animation, probing a node by clicking it, reaction force and moment sums per set, stale results marked after an edit; explicit steps: field frames by slider or animation with contours per frame, energy histories plotted) | started |
| M5 Structured hex | 2D quad meshing of faces (`fieldcraft-mesh::quad`: transfinite mapped meshes, free all-quad meshes of polygons with holes on prescribed boundary nodes, exact planar quad validity, shared `SizeField` and even edge divisions; done for planar faces); mapped hex of six-sided regions (trivariate TFI, curved faces) and sweep meshing (multi-level extrusions with imprinted cross-sections, revolutions, one-to-one sweeps through linking faces; interval assignment, hex8/hex20 on the geometry, B-rep face tags, `mesh.hex.generate`/`sweep`/`mapped`: done, 16 of 21 corpus parts all-hex); conforming interfaces between cells (done: `mesh.hex.blocks` meshes a part split with SolidCraft's Split Body into mapped blocks as one conforming mesh — composite faces for seams and tangent faces, interval assignment over all cells, shared faces meshed once, grading off holes and fillets, free-internal-face check; the filleted bracket in 30 cells, hex20 within 0.1 % of a fine tet10 tip deflection); partitions inside FieldCraft (done: datum planes and axes, `geometry.partition` cuts the open design by a datum plane, kept in the model and replayed when the design opens, the two faces of each cut paired by name); cells meshed each with its own method into one mesh (done: `mesh.cells` — hex where a method fits, else tet; matching interfaces merged node for node, others, such as hex next to tet, tied by dual mortar, verified by a tied hex–tet bar in tension to 1e-6); pyramid transitions; automatic decomposition into sweepable and mappable regions, proposed as SolidCraft splits | started | | M5 Structured hex | 2D quad meshing of faces (`fieldcraft-mesh::quad`: transfinite mapped meshes, free all-quad meshes of polygons with holes on prescribed boundary nodes, exact planar quad validity, shared `SizeField` and even edge divisions; done for planar faces); mapped hex of six-sided regions (trivariate TFI, curved faces) and sweep meshing (multi-level extrusions with imprinted cross-sections, revolutions, one-to-one sweeps through linking faces; interval assignment, hex8/hex20 on the geometry, B-rep face tags, `mesh.hex.generate`/`sweep`/`mapped`: done, 16 of 21 corpus parts all-hex); conforming interfaces between cells (done: `mesh.hex.blocks` meshes a part split with SolidCraft's Split Body into mapped blocks as one conforming mesh — composite faces for seams and tangent faces, interval assignment over all cells, shared faces meshed once, grading off holes and fillets, free-internal-face check; the filleted bracket in 30 cells, hex20 within 0.1 % of a fine tet10 tip deflection); partitions inside FieldCraft (done: datum planes and axes, `geometry.partition` cuts the open design by a datum plane, kept in the model and replayed when the design opens, the two faces of each cut paired by name); cells meshed each with its own method into one mesh (done: `mesh.cells` — hex where a method fits, else tet; matching interfaces merged node for node, others, such as hex next to tet, tied by dual mortar, verified by a tied hex–tet bar in tension to 1e-6); meshing half a symmetric part and mirroring it (done: `mesh.mirror` reflects hex8/hex20/tet4/tet10/wedge6 across a datum plane, right-handed, nodes on the plane merged, tags carried to the image half; a mirrored half bar equals the whole bar's mesh and bending deflection to 1e-12); pyramid transitions; automatic decomposition into sweepable and mappable regions, proposed as SolidCraft splits | started |
| M6 Automatic hex | Octree/grid all-hex for any part (first version: done — `fieldcraft-mesh::octree`, `mesh.hex.octree`: 3-refinement grid with conforming templates refined by curvature, thickness and narrow faces, a core cleared to a manifold boundary, feature map onto it, padding and a pillow per patch capturing every sharp curve, foldover-free fitting, certified validity, B-rep face tags; the corpus hex column and the filleted-bracket oracle case; next: transitions for concave refined regions so thin and curved parts stop over-refining); hex-dominant by tet recombination; polycube for blocky parts; layers through thin walls; mesh editing and orphan-mesh import | started | | M6 Automatic hex | Octree/grid all-hex for any part (first version: done — `fieldcraft-mesh::octree`, `mesh.hex.octree`: 3-refinement grid with conforming templates refined by curvature, thickness and narrow faces, a core cleared to a manifold boundary, feature map onto it, padding and a pillow per patch capturing every sharp curve, foldover-free fitting, certified validity, B-rep face tags; the corpus hex column and the filleted-bracket oracle case; next: transitions for concave refined regions so thin and curved parts stop over-refining); hex-dominant by tet recombination; polycube for blocky parts; layers through thin walls; mesh editing and orphan-mesh import | started |
| M7 Assemblies, contact and friction | Needs M15. Part instances from SolidCraft occurrences; tie between non-matching meshes (mortar: done — `interaction.tie`, dual mortar constraints in the implicit analyses, Abaqus `*TIE` and LS-DYNA tied contact, the tie patch test); implicit contact: surface-to-surface mortar and node-to-surface, penalty and augmented Lagrange, self-contact, automatic pair detection (mortar contact pairs with Coulomb friction in nonlinear static steps: done — segment-to-segment mortar with the consistent tangent by automatic differentiation of the segmentation, augmented Lagrangian with Uzawa updates to a gap tolerance, semismooth active set with a chattering cutback, small sliding without nonlinear geometry and finite sliding with it, overclosure adjusted or refused, Coulomb friction (stick/slip return map, static and kinetic coefficients, symmetrised tangent), pressure, gap, status, shear and slip output; verified by the contact patch test, Hertz cylinder and sphere, Newton's quadratic rate and sliding blocks; the linear analyses refuse contact by name); friction: Coulomb with stick–slip, static/kinetic, anisotropic, pressure-, rate- and temperature-dependent coefficients; contact output (pressure, slip, status); thermal contact conductance and frictional heating; wear (Archard); bolt pretension, connectors with behaviour (elastic, damped, stops, locks); amplitudes, multiple steps, restart; nonlinear steps in all three decks. Verified by Hertz (sphere and cylinder), the Cattaneo–Mindlin partial-slip problem, the sliding block, NAFEMS contact benchmarks | started | | M7 Assemblies, contact and friction | Needs M15. Part instances from SolidCraft occurrences; tie between non-matching meshes (mortar: done — `interaction.tie`, dual mortar constraints in the implicit analyses, Abaqus `*TIE` and LS-DYNA tied contact, the tie patch test); implicit contact: surface-to-surface mortar and node-to-surface, penalty and augmented Lagrange, self-contact, automatic pair detection (mortar contact pairs with Coulomb friction in nonlinear static steps: done — segment-to-segment mortar with the consistent tangent by automatic differentiation of the segmentation, augmented Lagrangian with Uzawa updates to a gap tolerance, semismooth active set with a chattering cutback, small sliding without nonlinear geometry and finite sliding with it, overclosure adjusted or refused, Coulomb friction (stick/slip return map, static and kinetic coefficients, symmetrised tangent), pressure, gap, status, shear and slip output; verified by the contact patch test, Hertz cylinder and sphere, Newton's quadratic rate and sliding blocks; the linear analyses refuse contact by name); friction: Coulomb with stick–slip, static/kinetic, anisotropic, pressure-, rate- and temperature-dependent coefficients; contact output (pressure, slip, status); thermal contact conductance and frictional heating; wear (Archard); bolt pretension, connectors with behaviour (elastic, damped, stops, locks); amplitudes, multiple steps, restart; nonlinear steps in all three decks. Verified by Hertz (sphere and cylinder), the Cattaneo–Mindlin partial-slip problem, the sliding block, NAFEMS contact benchmarks | started |
| M8 Materials, explicit, shells, composites | Plasticity, hyperelasticity, damage data per solver; explicit dynamics decks (Abaqus/Explicit, LS-DYNA); midsurface shells; composite layups and material orientations, continuum shells on swept hex; hybrid (mixed u–p) hex8/hex20/tet10 for nearly incompressible materials (done); heat transfer (solids done: steady and θ-method transient conduction with flux, convection and sources, thermal strain in static steps (solids and 2D) and explicit dynamics (with plasticity), coupled temperature–displacement, `solve.heat`; deck output, 2D/shell/beam conduction planned) | started | | M8 Materials, explicit, shells, composites | Plasticity, hyperelasticity, damage data per solver; explicit dynamics decks (Abaqus/Explicit, LS-DYNA); midsurface shells; composite layups and material orientations, continuum shells on swept hex; hybrid (mixed u–p) hex8/hex20/tet10 for nearly incompressible materials (done); heat transfer (solids done: steady and θ-method transient conduction with flux, convection and sources, thermal strain in static steps (solids and 2D) and explicit dynamics (with plasticity), coupled temperature–displacement, `solve.heat`; deck output, 2D/shell/beam conduction planned) | started |
+1 -1
View File
@@ -315,7 +315,7 @@ pub static CATALOGUE: &[Capability] = &[
Mesh, Mesh,
"Edit", "Edit",
"Add an element on existing nodes, or a mapped block of them", "Add an element on existing nodes, or a mapped block of them",
&["mesh.element.remove", "mesh.block"], &["mesh.element.remove", "mesh.block", "mesh.mirror"],
), ),
soon("mesh.node.merge", "Merge Nodes", Mesh, "Edit", "M6", "Merge coincident nodes"), soon("mesh.node.merge", "Merge Nodes", Mesh, "Edit", "M6", "Merge coincident nodes"),
soon("mesh.region.remesh", "Remesh Region", Mesh, "Edit", "M6", "Remesh a region in place; split and collapse elements"), soon("mesh.region.remesh", "Remesh Region", Mesh, "Edit", "M6", "Remesh a region in place; split and collapse elements"),
+6
View File
@@ -710,6 +710,12 @@ pub const CELLS: &[Field] = &[
TOLERANCE, 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] = &[ pub const HEX_OCTREE: &[Field] = &[
BODY, BODY,
SURFACE, 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(crate) mod mesh;
pub use mesh::analytic_surfaces; pub use mesh::analytic_surfaces;
mod mirror;
mod model; mod model;
pub(crate) mod octree; pub(crate) mod octree;
mod results; mod results;
@@ -55,6 +56,7 @@ pub static ALL: &[CommandSpec] = &[
hex::HEX_MAPPED, hex::HEX_MAPPED,
hex::HEX_BLOCKS, hex::HEX_BLOCKS,
cells::CELLS, cells::CELLS,
mirror::MIRROR,
tags::TAG, tags::TAG,
tags::TAG_CLEAR, tags::TAG_CLEAR,
model::UNITS, model::UNITS,
+1 -1
View File
@@ -45,7 +45,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 | Untangle — Repair inverted elements | coming soon (M6) | |
| Improve | Layers — Boundary padding and layers through thin walls | 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 | Node — Add a node | ready | `mesh.node.add`, `mesh.node.remove` |
| Edit | Element — Add an element on existing nodes, or a mapped block of them | ready | `mesh.element.add`, `mesh.element.remove`, `mesh.block` | | Edit | Element — Add an element on existing nodes, or a mapped block of them | ready | `mesh.element.add`, `mesh.element.remove`, `mesh.block`, `mesh.mirror` |
| Edit | Merge Nodes — Merge coincident nodes | coming soon (M6) | | | Edit | Merge Nodes — Merge coincident nodes | coming soon (M6) | |
| Edit | Remesh Region — Remesh a region in place; split and collapse elements | coming soon (M6) | | | Edit | Remesh Region — Remesh a region in place; split and collapse elements | coming soon (M6) | |
| Edit | Orphan Mesh — Import a mesh without geometry | coming soon (M6) | | | Edit | Orphan Mesh — Import a mesh without geometry | coming soon (M6) | |