mesh.plan: decomposition proposals for unmapped features

For a body with features the sweep cannot mesh, mesh.plan replies a
decomposition: the cuts that box those features into one cell, the region,
and the commands that make the cuts and mesh the cells with pyramids. A plate
with a joined dome: 5 cuts, 17 hex cells swept together, the dome cell tet,
valid and conforming, end displacement in tension within 0.4% of all-tet.

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-16 14:57:41 -04:00
1 parent 72ebd5bc7f
commit bb3def1cf5
4 files changed
+273 -4

No files matched your search

+6 -1
View File
@@ -39,6 +39,7 @@ use fieldcraft_model::{CutRole, Datum, MeshPlan, Partition, PlanCut, PlanFeature
use serde_json::{Value, json};
mod blocks;
mod decompose;
mod pillow;
use crate::requirement::Requirement;
@@ -57,7 +58,7 @@ const MIN_RELATIVE_TOLERANCE: f64 = 1e-5;
pub const PLAN: CommandSpec = CommandSpec {
id: "mesh.plan",
label: "Mesh Plan",
params: r#"{"body": "Base", "name"?: "Plan-Base", "size"?: mm (largest element size; default 1/25 of the body's diagonal), "layers"?: 2 (elements through the thinnest wall: a shell's thickness, a plate's; 1..=10), "max_elements"?: 300000 (the size grows past the walls' when more would be needed)} — the body's hex-meshing plan derived from the features that built it (geometry.features), saved in the model under `name` (replacing a plan of that name); nothing is meshed (mesh.plan.apply). The plan: the sweep `direction` (and every direction the mapped features are prismatic along), the element `size`, `tolerance` and `layers`; per mapped feature its `template` (sweep of an extrude's profile along its stored direction, block for a box, butterfly for a cylinder or a circular boss, o_grid for a hole or a circular cut, layers through a shell's thickness, imprint of a join, cut or combine tool's cross-section, copy for a pattern or mirror, partition for a split body) and the `faces` it made (the projection targets); the `rings` round the body's circles (o_grid or butterfly: centre, axis, radius, half-side of the square `frame`, the cylindrical `faces` they project onto); the partition `cuts` the frames and imprints need (each with its feature and reason, never on a face of the body); the `mesher` (cells when there are cuts, else sweep); the `copies` (the other bodies a pattern or mirror made from it); the features it does not map with the reason (`unmapped`: fillets, chamfers, revolves, sweeps and lofts are step 3; a feature not prismatic along the sweep needs projection blocking, #399) and `notes`"#,
params: r#"{"body": "Base", "name"?: "Plan-Base", "size"?: mm (largest element size; default 1/25 of the body's diagonal), "layers"?: 2 (elements through the thinnest wall: a shell's thickness, a plate's; 1..=10), "max_elements"?: 300000 (the size grows past the walls' when more would be needed)} — the body's hex-meshing plan derived from the features that built it (geometry.features), saved in the model under `name` (replacing a plan of that name); nothing is meshed (mesh.plan.apply). The plan: the sweep `direction` (and every direction the mapped features are prismatic along), the element `size`, `tolerance` and `layers`; per mapped feature its `template` (sweep of an extrude's profile along its stored direction, block for a box, butterfly for a cylinder or a circular boss, o_grid for a hole or a circular cut, layers through a shell's thickness, imprint of a join, cut or combine tool's cross-section, copy for a pattern or mirror, partition for a split body) and the `faces` it made (the projection targets); the `rings` round the body's circles (o_grid or butterfly: centre, axis, radius, half-side of the square `frame`, the cylindrical `faces` they project onto); the partition `cuts` the frames and imprints need (each with its feature and reason, never on a face of the body); the `mesher` (cells when there are cuts, else sweep); the `copies` (the other bodies a pattern or mirror made from it); the features it does not map with the reason (`unmapped`: fillets, chamfers, revolves, sweeps and lofts are step 3; a feature not prismatic along the sweep needs projection blocking, #399) and `notes`; with unmapped features that the sweep cannot mesh, a `decomposition` proposal (not saved): the partition `cuts` (point, normal) that box those features' faces into one cell, one element size clear of them, inside the body and clear of its faces (`open_sides` where no such cut fits), the boxed `tet_region`, an upper bound of its share of the volume, and the `commands` that make the cuts and mesh the cells with mesh.cells (the boxed cell tet, joined to the hex cells by pyramids)"#,
form: Some(super::forms::MESH_PLAN),
kind: CommandKind::Model,
requires: &[Requirement::Design],
@@ -1002,9 +1003,13 @@ fn plan(s: &mut Session, p: &Value) -> Result<Value> {
let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?;
let plan = build(design, &body, size, layers, max_elements)?;
s.model.set_mesh_plan(&name, plan.clone())?;
let decomposition = decompose::proposal(design, &plan)?;
let mut reply = serde_json::to_value(&plan).map_err(|e| EngineError::Params(e.to_string()))?;
if let Some(o) = reply.as_object_mut() {
o.insert("name".into(), json!(name));
if let Some(d) = decomposition {
o.insert("decomposition".into(), d);
}
}
Ok(reply)
}
+249
View File
@@ -0,0 +1,249 @@
//! Decomposition proposals (#439): for a body with features the plan cannot map, the partition
//! cuts that box those features into a cell of their own, so the rest of the body is cells the
//! sweep meshes and only the boxed cell is left to tetrahedra (joined to the hex cells by
//! pyramids, `mesh.cells`).
//!
//! The region is the bounding box of the unmapped features' faces (those not already prismatic
//! along the sweep), in the frame of the sweep direction and two directions square to it. Each
//! side of the box becomes a cut one element size outside it, where that plane lies inside the
//! body, clear of its planar faces and of its curved faces (a cut through a curved face does not
//! close in the partitions' tessellation); a side with no such plane is left open and the tet
//! cell reaches the body's boundary there. Decomposing a part into sweepable volumes and a
//! remainder for a free mesher is the practice of T. D. Blacker, *The Cooper Tool*, Proc. 5th
//! International Meshing Roundtable, 1996 (docs/meshing/literature.md, B11). The proposal is a
//! reply, not a change: the agent applies the cuts (`commands`) and meshes the cells.
use fieldcraft_geometry::{Design, FaceSurface};
use fieldcraft_model::MeshPlan;
use serde_json::{Value, json};
use super::{AXES, Clearance, V3, cross, dot, faces_prismatic, parallel, plan_shape, unit};
use crate::Result;
/// The decomposition proposal for `plan`, or `None` when every feature is mapped (or the plan
/// has no sweep direction to make cells along).
pub(super) fn proposal(design: &Design, plan: &MeshPlan) -> Result<Option<Value>> {
let Some(d) = plan.direction else { return Ok(None) };
if plan.unmapped.is_empty() {
return Ok(None);
}
let Some(shape) = plan_shape(design, &plan.body) else { return Ok(None) };
let source = design.source_of(&plan.body).unwrap_or(&plan.body);
let features: Vec<_> = design.features(Some(source))?.into_iter().next().map(|b| b.features).unwrap_or_default();
let surfaces = shape.face_surfaces()?;
// The unmapped features whose faces the sweep cannot mesh.
let lumps: Vec<_> = features
.iter()
.filter(|f| plan.unmapped.iter().any(|u| u.id == f.id))
.filter(|f| !f.faces.is_empty() && !faces_prismatic(shape, &surfaces, &f.faces, d))
.collect();
if lumps.is_empty() {
return Ok(None);
}
let faces: Vec<u32> = lumps.iter().flat_map(|f| &f.faces).filter_map(|n| shape.face_index(n)).collect();
let m = shape.measure()?;
let diagonal = (0..3).map(|k| (m.bbox_max[k] - m.bbox_min[k]).powi(2)).sum::<f64>().sqrt();
let mesh = shape.tessellate(plan.tolerance.unwrap_or(1e-3 * diagonal))?;
// The frame: the sweep direction and two directions square to it (axes when it is one).
let side = AXES.iter().copied().filter(|a| !parallel(*a, d)).min_by(|a, b| dot(*a, d).abs().total_cmp(&dot(*b, d).abs())).unwrap_or(AXES[0]);
let e1 = unit(cross(d, side)).unwrap_or(side);
let e2 = unit(cross(d, e1)).unwrap_or(side);
let frame = [d, e1, e2].map(|a| AXES.iter().copied().find(|x| parallel(*x, a)).unwrap_or(a));
let mut lo = [f64::INFINITY; 3];
let mut hi = [f64::NEG_INFINITY; 3];
let mut curved: Vec<Vec<V3>> = vec![Vec::new(); surfaces.len()];
for (t, &f) in mesh.triangles.iter().zip(&mesh.tri_face) {
let pts: Vec<V3> = t.iter().filter_map(|&i| mesh.positions.get(i as usize).copied()).collect();
if faces.contains(&f) {
for p in &pts {
for (k, a) in frame.iter().enumerate() {
lo[k] = lo[k].min(dot(*p, *a));
hi[k] = hi[k].max(dot(*p, *a));
}
}
}
if !matches!(surfaces.get(f as usize), Some(Some(FaceSurface::Plane { .. })))
&& let Some(c) = curved.get_mut(f as usize)
{
c.extend(pts);
}
}
if lo.iter().chain(&hi).any(|v| !v.is_finite()) {
return Ok(None);
}
curved.retain(|p| !p.is_empty());
let planes = surfaces
.iter()
.zip(shape.faces())
.filter_map(|(s, f)| match s {
Some(FaceSurface::Plane { normal, point }) => Some((*normal, dot(*normal, *point), f.name.as_str())),
_ => None,
})
.collect();
let corners = (0..8)
.map(|i| {
[
if i & 1 == 0 { m.bbox_min[0] } else { m.bbox_max[0] },
if i & 2 == 0 { m.bbox_min[1] } else { m.bbox_max[1] },
if i & 4 == 0 { m.bbox_min[2] } else { m.bbox_max[2] },
]
})
.collect();
let body = Clearance { corners, planes, curved, gap: 0.5 * plan.size };
let names: Vec<&str> = lumps.iter().map(|f| f.name.as_str()).collect();
let mut cuts = Vec::new();
let mut open = Vec::new();
let mut region = [[0.0; 3]; 2];
for (k, n) in frame.iter().enumerate() {
let (blo, bhi) = body.extent(*n);
region[0][k] = blo;
region[1][k] = bhi;
for (end, t) in [(0, lo[k] - plan.size), (1, hi[k] + plan.size)] {
let which = if end == 0 { "below" } else { "above" };
let why = if !body.inside(*n, t) {
Some("the body ends there".to_string())
} else if let Some((_, face)) = body.faces_near(*n, t).next() {
Some(format!("it would lie on face `{face}`"))
} else if body.crosses_curved(*n, t) {
Some("it would cross a curved face".into())
} else {
None
};
match why {
Some(why) => open.push(json!({"normal": n, "side": which, "reason": why})),
None => {
region[end][k] = t;
let point = n.map(|c| c * t);
cuts.push(json!({"point": point, "normal": n, "reason": format!("boxes {} ({which} along {n:?})", names.join(", "))}));
}
}
}
}
// The commands that make the cuts and mesh the cells (the boxed cell falls back to tet).
let mut commands = Vec::new();
for (i, c) in cuts.iter().enumerate() {
let name = format!("{}-split{}", plan.body, i + 1);
commands.push(json!({"command": "geometry.datum", "params": {"name": name, "by": {"type": "point_normal", "point": c["point"], "normal": c["normal"]}}}));
commands.push(json!({"command": "geometry.partition", "params": {"name": name, "datum": name}}));
}
commands.push(json!({"command": "mesh.cells", "params": {"default": "auto", "size": plan.size, "transition": "pyramid"}}));
let box_volume: f64 = (0..3).map(|k| region[1][k] - region[0][k]).product();
let share = if m.volume.abs() > 0.0 { (box_volume / m.volume.abs()).min(1.0) } else { 1.0 };
Ok(Some(json!({
"features": names,
"cuts": cuts,
"open_sides": open,
"tet_region": {"frame": frame, "min": region[0], "max": region[1]},
"tet_share_at_most": share,
"commands": commands,
"note": "cut the body by these planes; the cell inside `tet_region` is meshed with tet, every other cell is a box or a slice of the sweep's cross-section the hex meshers take, and mesh.cells joins the tet cell to them through pyramids",
})))
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use serde_json::{Value, json};
use crate::Session;
/// A 80 × 40 × 12 plate with a ball (radius 8) joined on its top: the sweep takes the plate,
/// not the dome.
fn plate_with_dome() -> (crate::tests::TempDir, Session) {
let commands = json!({"commands": [
{"command": "solid.box", "params": {"length": 80, "width": 40, "height": 12, "body_name": "Plate"}},
{"command": "solid.sphere", "params": {"center": [60, 20, 12], "radius": 8, "body_name": "Ball", "operation": "new"}},
{"command": "solid.combine", "params": {"target": "Plate", "tools": ["Ball"], "operation": "join"}},
]});
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)
}
/// Fix the end x = 0, pull the end x = 80 with `force` (N), solve, and return the mean
/// displacement of the loaded end along `k`.
fn pull(s: &mut Session, force: [f64; 3], k: usize) -> f64 {
let m = &s.model;
let at = |x: f64| -> Vec<u32> { m.nodes().iter().filter(|(_, n)| (n.xyz[0] - x).abs() < 1e-6).map(|(l, _)| *l).collect() };
let (root, tip) = (at(0.0), at(80.0));
// The loaded end's element faces, so the force is shared by consistent weights.
let end: Vec<Value> = m
.elements()
.iter()
.flat_map(|(&e, el)| (1..=el.kind.faces().len() as u8).map(move |f| (e, f)))
.filter(|&(e, f)| {
m.face_nodes(fieldcraft_model::ElementFace { element: e, face: f })
.is_some_and(|ns| ns.iter().all(|n| (m.node(*n).unwrap().xyz[0] - 80.0).abs() < 1e-6))
})
.map(|(e, f)| json!({"element": e, "face": f}))
.collect();
let script = json!([
{"command": "model.set.create", "params": {"name": "all", "elements": m.elements().keys().collect::<Vec<_>>()}},
{"command": "model.set.create", "params": {"name": "root", "nodes": root}},
{"command": "model.set.create", "params": {"name": "tip", "faces": end}},
{"command": "model.material.create", "params": {"name": "Steel", "elastic": {"type": "isotropic", "young": 210000, "poisson": 0.3}}},
{"command": "model.section.create", "params": {"name": "S", "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": "Pull", "type": "force", "target": {"set": "tip"}, "vector": force}},
{"command": "model.step.create", "params": {"name": "Load", "procedure": {"type": "static"}, "boundary_conditions": ["Clamp"], "loads": ["Pull"]}},
]);
s.run_script(&script).unwrap();
s.execute("solve.static", &json!({})).unwrap();
let probe = s.execute("solve.probe", &json!({"step": "Load", "nodes": tip})).unwrap();
let w: Vec<f64> = probe["nodes"].as_array().unwrap().iter().map(|n| n["displacement"][k].as_f64().unwrap()).collect();
w.iter().sum::<f64>() / w.len() as f64
}
/// #439's acceptance: the plan proposes cuts that box the dome; with them the plate is hex
/// cells and the dome's cell tet, joined by pyramids into one valid, conforming mesh whose
/// static answer matches the all-tet mesh's within 2 %.
#[test]
fn a_dome_on_a_plate_is_boxed_into_a_tet_cell_and_the_rest_is_hex() {
let (_dir, mut s) = plate_with_dome();
let plan = s.execute("mesh.plan", &json!({"body": "Plate", "size": 4})).unwrap();
let d = &plan["decomposition"];
assert!(d["features"].as_array().is_some_and(|f| !f.is_empty()), "{plan}");
let cuts = d["cuts"].as_array().unwrap();
assert!(cuts.len() >= 4, "{d}");
let commands: Vec<Value> = d["commands"].as_array().unwrap().clone();
let (cut_commands, mesh) = commands.split_at(commands.len() - 1);
s.run_script(&json!(cut_commands)).unwrap();
// The cell inside the region is tet; the others take the default (auto: hex).
let (lo, hi) = (&d["tet_region"]["min"], &d["tet_region"]["max"]);
let bodies = s.execute("geometry.bodies", &json!({})).unwrap();
// The region is given along its frame's axes (here coordinate axes).
let axis = |k: usize| (0..3).find(|&i| d["tet_region"]["frame"][k][i].as_f64().unwrap().abs() > 0.5).unwrap();
let inside = |b: &Value| {
(0..3).all(|k| {
let i = axis(k);
b["bbox"][0][i].as_f64().unwrap() >= lo[k].as_f64().unwrap() - 1e-6
&& b["bbox"][1][i].as_f64().unwrap() <= hi[k].as_f64().unwrap() + 1e-6
})
};
let tet: Vec<String> = bodies.as_array().unwrap().iter().filter(|b| inside(b)).map(|b| b["name"].as_str().unwrap().to_string()).collect();
assert_eq!(tet.len(), 1, "{bodies}");
let mut params = mesh[0]["params"].clone();
params["methods"] = json!({tet[0].clone(): "tet"});
let r = s.execute("mesh.cells", &params).unwrap();
let kinds: BTreeSet<&str> = s.model.elements().values().map(|e| e.kind.name()).collect();
assert_eq!(kinds.into_iter().collect::<Vec<_>>(), ["hex8", "pyramid5", "tet4"], "{r}");
assert!(r["interfaces"].as_array().unwrap().iter().all(|i| i["joined"] != "tied"), "{r}");
let dup = s.execute("mesh.check.duplicates", &json!({})).unwrap();
assert_eq!(dup["counts"], json!({"coincident": 0, "cracks": 0, "duplicate_elements": 0, "free_edges": 0}), "{dup}");
let q = s.execute("mesh.quality", &json!({})).unwrap();
assert_eq!(q["invalid"]["count"], 0, "{q}");
// Tension along the plate, which the dome stiffens locally. (In bending, linear hexahedra
// and linear tetrahedra lock differently at three elements through the thickness, so
// the two meshes would differ by their discretisation, not by the transition.)
let hex = s.model.elements().values().filter(|e| e.kind.name() == "hex8").count();
let mixed = pull(&mut s, [8000.0, 0.0, 0.0], 0);
let (_dir2, mut t) = plate_with_dome();
t.execute("mesh.tet.generate", &json!({"body": "Plate", "size": 4})).unwrap();
let all_tet = pull(&mut t, [8000.0, 0.0, 0.0], 0);
eprintln!("mixed {mixed:.6e} ({hex} hex of {} elements), all tet {all_tet:.6e}", s.model.elements().len());
assert!(((mixed - all_tet) / all_tet).abs() < 0.02, "{mixed} vs {all_tet}");
}
}