forked from 1b-agent/fieldcraft-upstream
Engine: the meshing commands pass the design's edge seeds to the meshers
mesh.tet.generate, mesh.hex.*, mesh.hex.blocks and mesh.cells give every seed on the open design to their meshers, which match them to their own curves by position, so a seed on an edge two cells share holds in both. The tet reply gives, per seed, the mesh edges along it. Docs and the roadmap say what is honoured where. Fixes #230. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
1 parent
f78680e091
commit
eb66b86e26
8 files changed
+142
-15
No files matched your search
@@ -138,10 +138,11 @@ fn cells(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (tet_options, quality) = quality_options(&tet_params, s.limits.max_tets as u64)?;
|
||||
let cdt = CdtOptions { max_steiner: s.limits.max_steiner, max_tets: tet_options.max_tets };
|
||||
|
||||
let seeds = super::seeds::edge_seeds(s)?;
|
||||
let mut meshed: Vec<(Meshed, Value)> = Vec::with_capacity(shapes.len());
|
||||
for shape in shapes {
|
||||
let method = methods.get(&shape.name).copied().unwrap_or(default);
|
||||
let input = || super::hex::shape_input(shape, Some(tolerance));
|
||||
let input = || Ok::<_, EngineError>(Input { seeds: seeds.clone(), ..super::hex::shape_input(shape, Some(tolerance))? });
|
||||
let hex = |m: HexMethod| -> Result<(Input<'static>, HexMesh)> {
|
||||
let b = input()?;
|
||||
let mesh = hex_body(&hex_params, &b, m, quadratic, size)?;
|
||||
|
||||
@@ -15,7 +15,7 @@ use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
pub const HEX_GENERATE: CommandSpec = CommandSpec {
|
||||
id: "mesh.hex.generate",
|
||||
label: "Generate 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. 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 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"#,
|
||||
form: Some(super::forms::HEX_GENERATE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Geometry],
|
||||
@@ -35,9 +35,12 @@ const DEFAULT_RELATIVE_TOLERANCE: f64 = 1e-3;
|
||||
pub(super) struct Input<'a> {
|
||||
pub(super) name: String,
|
||||
pub(super) mesh: Cow<'a, SurfaceMesh>,
|
||||
surfaces: Vec<Option<Surface>>,
|
||||
pub(super) surfaces: Vec<Option<Surface>>,
|
||||
pub(super) face_names: Vec<String>,
|
||||
pub(super) diagonal: f64,
|
||||
/// The design's edge seeds ([`super::seeds::edge_seeds`]); the mesher applies those on its
|
||||
/// edges.
|
||||
pub(super) seeds: Vec<fieldcraft_mesh::seeds::EdgeSeed>,
|
||||
}
|
||||
|
||||
fn analytic(s: FaceSurface) -> Surface {
|
||||
@@ -71,7 +74,7 @@ pub(super) fn shape_input(shape: &Shape, tolerance: Option<f64>) -> Result<Input
|
||||
}
|
||||
let surfaces = shape.face_surfaces()?.into_iter().map(|f| f.map(analytic)).collect();
|
||||
let face_names = shape.faces().iter().map(|f| f.name.clone()).collect();
|
||||
Ok(Input { name: shape.name.clone(), mesh: Cow::Owned(shape.tessellate(tol)?), surfaces, face_names, diagonal: diag })
|
||||
Ok(Input { name: shape.name.clone(), mesh: Cow::Owned(shape.tessellate(tol)?), surfaces, face_names, diagonal: diag, seeds: Vec::new() })
|
||||
}
|
||||
|
||||
pub(super) fn inputs<'a>(s: &'a Session, p: &Value) -> Result<Vec<Input<'a>>> {
|
||||
@@ -84,7 +87,7 @@ pub(super) fn inputs<'a>(s: &'a Session, p: &Value) -> Result<Vec<Input<'a>>> {
|
||||
.as_deref()
|
||||
.and_then(|d| d.body(name))
|
||||
.ok_or_else(|| EngineError::Params(format!("no body `{name}` (open a design first)")))?;
|
||||
Ok(vec![shape_input(shape, tolerance)?])
|
||||
Ok(vec![Input { seeds: super::seeds::edge_seeds(s)?, ..shape_input(shape, tolerance)? }])
|
||||
}
|
||||
(None, Some(i)) => {
|
||||
let n = s.surfaces.len();
|
||||
@@ -101,14 +104,15 @@ pub(super) fn inputs<'a>(s: &'a Session, p: &Value) -> Result<Vec<Input<'a>>> {
|
||||
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) }])
|
||||
Ok(vec![Input { name, mesh: Cow::Borrowed(&t.mesh), surfaces: Vec::new(), face_names, diagonal: diagonal(lo, hi), seeds: Vec::new() }])
|
||||
}
|
||||
(None, None) => {
|
||||
let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first, or give `surface`".into()))?;
|
||||
if design.bodies.is_empty() || design.bodies.len() > MAX_BODIES {
|
||||
return Err(EngineError::Params(format!("the design has {} bodies; meshing takes 1 to {MAX_BODIES}", design.bodies.len())));
|
||||
}
|
||||
design.bodies.iter().map(|b| shape_input(b, tolerance)).collect()
|
||||
let seeds = super::seeds::edge_seeds(s)?;
|
||||
design.bodies.iter().map(|b| Ok(Input { seeds: seeds.clone(), ..shape_input(b, tolerance)? })).collect()
|
||||
}
|
||||
_ => Err(EngineError::Params("give `body` or `surface`, not both".into())),
|
||||
}
|
||||
@@ -208,6 +212,7 @@ fn hex_blocks(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
quadratic,
|
||||
chordal_tolerance: tolerance,
|
||||
max_elements: fieldcraft_model::MAX_ELEMENTS / 8,
|
||||
seeds: super::seeds::edge_seeds(s)?,
|
||||
..HexOptions::default()
|
||||
};
|
||||
let cm = hex_mesh_cells(&cells, &UniformSize(h), &options).map_err(|e| EngineError::Params(e.to_string()))?;
|
||||
@@ -331,8 +336,14 @@ pub(super) fn hex_body(p: &Value, b: &Input, method: HexMethod, quadratic: bool,
|
||||
let surface = BoundarySurface::new(&b.mesh.positions, &b.mesh.triangles, &b.mesh.tri_face, &surface_options)
|
||||
.map_err(|e| EngineError::Params(format!("body `{}`: {e}", b.name)))?;
|
||||
let chord = if b.surfaces.is_empty() { 0.0 } else { number(p, "tolerance")?.unwrap_or(DEFAULT_RELATIVE_TOLERANCE * b.diagonal) };
|
||||
let options =
|
||||
HexOptions { method, quadratic, chordal_tolerance: chord, max_elements: fieldcraft_model::MAX_ELEMENTS / 8, ..HexOptions::default() };
|
||||
let options = HexOptions {
|
||||
method,
|
||||
quadratic,
|
||||
chordal_tolerance: chord,
|
||||
max_elements: fieldcraft_model::MAX_ELEMENTS / 8,
|
||||
seeds: b.seeds.clone(),
|
||||
..HexOptions::default()
|
||||
};
|
||||
hex_mesh(&surface, &b.surfaces, &UniformSize(h), &options).map_err(|e| EngineError::Params(format!("body `{}`: {e}", b.name)))
|
||||
}
|
||||
|
||||
|
||||
@@ -106,7 +106,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. Reports the totals and, under `bodies`, each body's counts, size and quality report"#,
|
||||
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)"#,
|
||||
form: Some(super::forms::TET),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Geometry],
|
||||
@@ -250,7 +250,12 @@ pub(super) fn mesh_body(
|
||||
};
|
||||
let surface = BoundarySurface::new(&input.mesh.positions, &input.mesh.triangles, &input.mesh.tri_face, &surface_options(p, &input.mesh)?)?;
|
||||
let tets = tetrahedralize(&surface, cdt)?;
|
||||
let mesh = if quality { quality_mesh(&surface, &tets, analytic, options)? } else { raw_mesh(&surface, &tets)? };
|
||||
let mesh = if quality {
|
||||
let options = QualityOptions { seeds: input.seeds.clone(), ..options.clone() };
|
||||
quality_mesh(&surface, &tets, analytic, &options)?
|
||||
} else {
|
||||
raw_mesh(&surface, &tets)?
|
||||
};
|
||||
Ok(BodyTets {
|
||||
name: input.name.clone(),
|
||||
face_names: input.face_names.clone(),
|
||||
@@ -301,6 +306,7 @@ fn tet_generate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"straightened": r.straightened,
|
||||
"stopped": r.stopped,
|
||||
},
|
||||
"seeds": r.seeds.iter().map(|(name, count, edges)| json!({"edge": name, "count": count, "mesh_edges": edges})).collect::<Vec<_>>(),
|
||||
}));
|
||||
}
|
||||
let sum = |f: fn(&BodyTets) -> f64| meshed.iter().map(f).sum::<f64>();
|
||||
|
||||
@@ -65,6 +65,37 @@ pub(crate) fn seeded_edges(design: &Design, r: &GeometryRef) -> Result<Vec<Edge>
|
||||
Ok(body.edges()?.into_iter().filter(|e| GeometryRef::new(r.body.clone(), Entity::Edge(e.name.clone())).is_ok_and(|g| r.selects(&g))).collect())
|
||||
}
|
||||
|
||||
/// Every seed on the open design's edges, as the meshers take them. A mesher matches them to its
|
||||
/// own curves by position, so a seed on an edge two partition cells share holds in both.
|
||||
pub(crate) fn edge_seeds(s: &Session) -> Result<Vec<fieldcraft_mesh::seeds::EdgeSeed>> {
|
||||
let Some(design) = s.design.as_deref() else { return Ok(Vec::new()) };
|
||||
let mut out = Vec::new();
|
||||
// Each body's edges, found once.
|
||||
let mut edges: std::collections::BTreeMap<&str, (f64, Vec<Edge>)> = std::collections::BTreeMap::new();
|
||||
for seed in s.model.seeds() {
|
||||
let Some(body) = design.body(&seed.edge.body) else { continue };
|
||||
let (tolerance, all) = match edges.entry(body.name.as_str()) {
|
||||
std::collections::btree_map::Entry::Occupied(e) => e.into_mut(),
|
||||
std::collections::btree_map::Entry::Vacant(v) => {
|
||||
v.insert(((body.size()? * fieldcraft_geometry::EDGE_TOLERANCE).max(1e-4), body.edges()?))
|
||||
}
|
||||
};
|
||||
let selected = all.iter().filter(|e| GeometryRef::new(body.name.clone(), Entity::Edge(e.name.clone())).is_ok_and(|g| seed.edge.selects(&g)));
|
||||
for e in selected.cloned().collect::<Vec<_>>() {
|
||||
let tolerance = *tolerance;
|
||||
out.push(fieldcraft_mesh::seeds::EdgeSeed {
|
||||
name: format!("{}:{}", seed.edge.body, e.name),
|
||||
count: seed.count(e.length()) as usize,
|
||||
points: e.points,
|
||||
tolerance,
|
||||
bias: seed.bias,
|
||||
constraint: seed.constraint,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn design(s: &Session) -> Result<&Design> {
|
||||
s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first (geometry.open)".into()))
|
||||
}
|
||||
@@ -192,6 +223,78 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The polyline of edge `name` of body `body`.
|
||||
fn edge_points(s: &Session, body: &str, name: &str) -> Vec<[f64; 3]> {
|
||||
s.design.as_ref().unwrap().body(body).unwrap().edges().unwrap().into_iter().find(|e| e.name == name).unwrap().points
|
||||
}
|
||||
|
||||
/// The model's nodes on a straight edge, by their distance from its start, sorted.
|
||||
fn nodes_on(s: &Session, line: &[[f64; 3]]) -> Vec<f64> {
|
||||
let (a, b) = (line[0], *line.last().unwrap());
|
||||
let d = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
|
||||
let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
|
||||
let mut out: Vec<f64> = s
|
||||
.model
|
||||
.nodes()
|
||||
.values()
|
||||
.filter_map(|n| {
|
||||
let v = [n.xyz[0] - a[0], n.xyz[1] - a[1], n.xyz[2] - a[2]];
|
||||
let t = (v[0] * d[0] + v[1] * d[1] + v[2] * d[2]) / len;
|
||||
let off = [v[0] - t * d[0] / len, v[1] - t * d[1] / len, v[2] - t * d[2] / len];
|
||||
let off = (off[0] * off[0] + off[1] * off[1] + off[2] * off[2]).sqrt();
|
||||
(off < 1e-6 && (-1e-6..=len + 1e-6).contains(&t)).then_some(t)
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(f64::total_cmp);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_meshers_honour_seeds() {
|
||||
let mut s = plate();
|
||||
let edges = thickness_edges(&mut s);
|
||||
let name = edges[0]["edge"].as_str().unwrap().to_string();
|
||||
let line = edge_points(&s, "Body1", &name);
|
||||
let seed = json!({"edges": [edges[0]], "count": 5, "bias": {"ratio": 3, "toward": "end"}});
|
||||
s.execute("mesh.seed.edge", &seed).unwrap();
|
||||
// Hex sweep: five layers through the thickness, graded geometrically toward the edge's end.
|
||||
s.execute("mesh.hex.sweep", &json!({"body": "Body1", "size": 4})).unwrap();
|
||||
let t = nodes_on(&s, &line);
|
||||
let dt: Vec<f64> = t.windows(2).map(|w| w[1] - w[0]).collect();
|
||||
assert_eq!(dt.len(), 5, "{t:?}");
|
||||
assert!((dt[0] / dt[4] - 3.0).abs() < 1e-6, "{dt:?}");
|
||||
// Tet: the edge is divided at the seed's nodes.
|
||||
let r = s.execute("mesh.tet.generate", &json!({"body": "Body1", "size": 5})).unwrap();
|
||||
assert_eq!(r["bodies"][0]["seeds"][0]["mesh_edges"], 5, "{}", r["bodies"][0]["seeds"]);
|
||||
// Two exact seeds that cannot both hold are refused, naming both.
|
||||
s.execute("mesh.seed.edge", &json!({"edges": [edges[1]], "count": 3})).unwrap();
|
||||
let e = s.execute("mesh.hex.sweep", &json!({"body": "Body1", "size": 4})).unwrap_err().to_string();
|
||||
assert!(e.contains(&name) && e.contains(edges[1]["edge"].as_str().unwrap()) && e.contains("conflict"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_seed_on_a_cut_edge_holds_in_both_cells() {
|
||||
let (mut s, _dir) = super::super::datum::tests::plate();
|
||||
s.execute("geometry.datum", &json!({"name": "X30", "by": {"type": "offset", "plane": "yz", "offset": 30}})).unwrap();
|
||||
s.execute("geometry.partition", &json!({"name": "P", "datum": "X30"})).unwrap();
|
||||
// An edge of the cut face, 30 mm along y, on the cell with the hole.
|
||||
let edge = s
|
||||
.design
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.body("Body1/a")
|
||||
.unwrap()
|
||||
.edges()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|e| e.name.contains("partition:P:a") && (e.length() - 30.0).abs() < 1e-6)
|
||||
.unwrap();
|
||||
s.execute("mesh.seed.edge", &json!({"edges": [{"body": "Body1/a", "edge": edge.name}], "count": 9})).unwrap();
|
||||
let r = s.execute("mesh.cells", &json!({"default": "hex", "size": 2.5})).unwrap();
|
||||
assert_eq!(r["interfaces"][0]["joined"], "merged", "{r}");
|
||||
assert_eq!(nodes_on(&s, &edge.points).len(), 10, "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_seeds_are_refused_and_change_nothing() {
|
||||
let mut s = plate();
|
||||
|
||||
Reference in new issue
Block a user