forked from 1b-agent/fieldcraft-upstream
2D elements: plane stress, plane strain and axisymmetric sections on the DOF framework; mesh.planar (M4) (#39)
Model: planar section kinds with Extent, element edges in sets, edge-weighted 2D loads,
centrifugal load, 2D model checks. fem: Plane kernels on Basis::{Tri3,Tri6,Quad4,Quad8}
declaring ElementDofs::new(DofSet::PLANE); QM6 incompatible modes with the
Wilson-Ibrahimbegovic correction. Solver: Kernel enum on the DofLayout; 3D results
bit-identical (hash-checked). Decks: Abaqus CPS/CPE/CAX, Nastran membranes and RFORCE,
LS-DYNA ELFORM 12/13. Mesh: free triangle mesher, planar region meshing, face boundaries.
Engine: mesh.planar with tagged edges. Analytic verification suite in solver/tests/plane.rs.
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
42 files changed
+3270
-128
No files matched your search
@@ -66,7 +66,7 @@ definition_commands!(
|
||||
Set,
|
||||
"set",
|
||||
"Set",
|
||||
r#"{"name": "root", "geometry": [{"body": "Body1", "face": "F2:top"}, {"body": "Body1", "edge": "…"}, {"body": "Body1", "vertex": "…"}, {"body": "Body1"}], "nodes": [labels], "elements": [labels], "faces": [{"element": 1, "face": 3}]} — any of the member lists; geometry members resolve against the mesh's geometry tags after every remesh (a face name also selects its split pieces `name#0`, `name#1`, …)"#,
|
||||
r#"{"name": "root", "geometry": [{"body": "Body1", "face": "F2:top"}, {"body": "Body1", "edge": "…"}, {"body": "Body1", "vertex": "…"}, {"body": "Body1"}], "nodes": [labels], "elements": [labels], "faces": [{"element": 1, "face": 3}], "edges": [{"element": 1, "edge": 2}]} — any of the member lists (element edges: where a 2D model's surface loads act); geometry members resolve against the mesh's geometry tags after every remesh (a face name also selects its split pieces `name#0`, `name#1`, …)"#,
|
||||
add_set,
|
||||
replace_set,
|
||||
remove_set,
|
||||
@@ -94,7 +94,7 @@ definition_commands!(
|
||||
Section,
|
||||
"section",
|
||||
"Section",
|
||||
r#"{"name": "web", "set": "all", "material": "Steel", "kind": {"type": "solid"} | {"type": "shell", "thickness": 2, "offset": 0 (-0.5..=0.5 of the thickness), "points": 5} | {"type": "beam", "profile": {"type": "rectangle", "width", "height"} | {"type": "circle", "radius"} | {"type": "pipe", "radius", "thickness"} | {"type": "i", "height", "width", "web", "flange"} | {"type": "box", "width", "height", "thickness"}, "orientation": [x, y, z]} | {"type": "rod", "area": 10}, "formulation": {"integration": "full|reduced", "incompatible_modes": false, "hybrid": false}}"#,
|
||||
r#"{"name": "web", "set": "all", "material": "Steel", "kind": {"type": "solid"} | {"type": "shell", "thickness": 2, "offset": 0 (-0.5..=0.5 of the thickness), "points": 5} | {"type": "beam", "profile": {"type": "rectangle", "width", "height"} | {"type": "circle", "radius"} | {"type": "pipe", "radius", "thickness"} | {"type": "i", "height", "width", "web", "flange"} | {"type": "box", "width", "height", "thickness"}, "orientation": [x, y, z]} | {"type": "rod", "area": 10} | {"type": "plane_stress", "thickness": 1} | {"type": "plane_strain", "thickness": 1 (default)} | {"type": "axisymmetric"} (2D: elements in the XY plane; axisymmetric about Y with x the radius, forces totals over the circumference), "formulation": {"integration": "full|reduced", "incompatible_modes": false, "hybrid": false}}"#,
|
||||
add_section,
|
||||
replace_section,
|
||||
remove_section,
|
||||
@@ -150,7 +150,7 @@ definition_commands!(
|
||||
Load,
|
||||
"load",
|
||||
"Load",
|
||||
r#"{"name": "push", "type": "force|moment", "target": {"set": "…"} | {"reference_point": "…"}, "vector": [x, y, z], "distribution": "total" (default: the vector is the total, split by area over the set's faces, or equally over its nodes) | "per_node" (at every node: depends on the mesh)} | {"name", "type": "traction", "set", "vector" (per area, on the set's faces)} | {"name", "type": "pressure", "set", "magnitude" (positive pushes in)} | {"name", "type": "gravity", "acceleration": [x, y, z], "set" (optional, default the whole model)} | {"name", "type": "body_force", "set", "vector" (per volume)} — active in the steps that list it (model.step.activate)"#,
|
||||
r#"{"name": "push", "type": "force|moment", "target": {"set": "…"} | {"reference_point": "…"}, "vector": [x, y, z], "distribution": "total" (default: the vector is the total, split by area over the set's faces, or equally over its nodes) | "per_node" (at every node: depends on the mesh)} | {"name", "type": "traction", "set", "vector" (per area, on the set's faces)} | {"name", "type": "pressure", "set", "magnitude" (positive pushes in)} | {"name", "type": "gravity", "acceleration": [x, y, z], "set" (optional, default the whole model)} | {"name", "type": "body_force", "set", "vector" (per volume)} | {"name", "type": "centrifugal", "angular_velocity" (rad per time unit), "point": [x, y, z] (on the axis), "axis": [x, y, z], "set" (optional)} — 2D models: in-plane vectors, surface loads on element edges; active in the steps that list it (model.step.activate)"#,
|
||||
add_load,
|
||||
replace_load,
|
||||
remove_load,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::borrow::Cow;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use modelcraft_geometry::SurfaceMesh;
|
||||
use modelcraft_mesh::cdt::{CdtOptions, tetrahedralize};
|
||||
@@ -87,6 +88,17 @@ pub const TET_GENERATE: CommandSpec = CommandSpec {
|
||||
run: tet_generate,
|
||||
};
|
||||
|
||||
/// The first node and element labels of a mesh of `nodes` nodes and `elements` elements added
|
||||
/// after the model's largest labels, or an error when the labels would pass [`MAX_LABEL`].
|
||||
fn next_labels(s: &Session, nodes: usize, elements: usize) -> Result<(u32, u32)> {
|
||||
let next = |last: Option<u32>| last.map_or(Some(1), |l| l.checked_add(1));
|
||||
let fits = |first: Option<u32>, count: usize| first.filter(|&f| u64::from(f) + count as u64 - 1 <= u64::from(MAX_LABEL));
|
||||
match (fits(next(s.model.nodes().keys().next_back().copied()), nodes), fits(next(s.model.elements().keys().next_back().copied()), elements)) {
|
||||
(Some(n), Some(e)) => Ok((n, e)),
|
||||
_ => Err(EngineError::Params(format!("the mesh needs labels beyond {MAX_LABEL}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The most Steiner points and tetrahedra `mesh.tet.generate` may be asked for.
|
||||
const TET_MAX_STEINER: u64 = 10_000_000;
|
||||
const TET_MAX_TETS: u64 = 50_000_000;
|
||||
@@ -127,13 +139,7 @@ fn tet_generate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cdt = tetrahedralize(&surface, &cdt_options)?;
|
||||
let gate = cdt.gate(surface.volume())?.into_result()?;
|
||||
|
||||
let next = |last: Option<u32>| last.map_or(Some(1), |l| l.checked_add(1));
|
||||
let first_node = next(s.model.nodes().keys().next_back().copied());
|
||||
let first_element = next(s.model.elements().keys().next_back().copied());
|
||||
let fits = |first: Option<u32>, count: usize| first.filter(|&f| u64::from(f) + count as u64 - 1 <= u64::from(MAX_LABEL));
|
||||
let (Some(first_node), Some(first_element)) = (fits(first_node, cdt.points.len()), fits(first_element, cdt.tets.len())) else {
|
||||
return Err(EngineError::Params(format!("the mesh needs labels beyond {MAX_LABEL}")));
|
||||
};
|
||||
let (first_node, first_element) = next_labels(s, cdt.points.len(), cdt.tets.len())?;
|
||||
for (i, xyz) in cdt.points.iter().enumerate() {
|
||||
s.model.set_node(first_node + i as u32, *xyz)?;
|
||||
}
|
||||
@@ -175,6 +181,143 @@ fn tet_generate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}))
|
||||
}
|
||||
|
||||
pub const PLANAR: CommandSpec = CommandSpec {
|
||||
id: "mesh.planar",
|
||||
label: "2D Mesh",
|
||||
params: r#"{"size": 1.0, "kind": "quad4|quad8|tri3|tri6" (default quad4), and either "polygon": [[x, y], ...] with optional "holes": [[[x, y], ...], ...] and "name": "Plane" (the body name its tags use), or "body": "Body1", "face": "F2:top", "tolerance": 0.05 (a planar face of an open design, parallel to XY); optional "max_elements": 1000000 (1..=4000000)} — meshes the planar region with 2D elements in the XY plane (a face at z = c is meshed at z = 0) for plane stress, plane strain or axisymmetric sections (x the radius, Y the axis): free quadrilaterals (or constrained Delaunay triangles) of about `size`, mid-side nodes on the boundary for quadratic kinds. Adds nodes and elements after the largest existing labels and tags them: the elements with the face (polygon: face `area` of body `name`), the boundary element edges with edges named after the neighbouring face of the body (polygon: `e1`, `e2`, … side by side, the outer loop then each hole, and vertices `v1`, … at the corners), so sets on those edges carry loads and boundary conditions"#,
|
||||
kind: CommandKind::Model,
|
||||
run: planar,
|
||||
};
|
||||
|
||||
/// Most points of a `mesh.planar` polygon or hole, and most holes.
|
||||
const PLANAR_MAX_POINTS: usize = 100_000;
|
||||
const PLANAR_MAX_HOLES: usize = 10_000;
|
||||
const PLANAR_MAX_ELEMENTS: u64 = 4_000_000;
|
||||
|
||||
/// A list of `[x, y]` points.
|
||||
fn polygon(v: &Value, what: &str) -> Result<Vec<[f64; 2]>> {
|
||||
let bad = || EngineError::Params(format!("`{what}` must be a list of 3 to {PLANAR_MAX_POINTS} [x, y] points"));
|
||||
let raw = v.as_array().filter(|a| (3..=PLANAR_MAX_POINTS).contains(&a.len())).ok_or_else(bad)?;
|
||||
raw.iter()
|
||||
.map(|p| match p.as_array().map(Vec::as_slice) {
|
||||
Some([x, y]) => x.as_f64().zip(y.as_f64()).map(|(x, y)| [x, y]).filter(|c| c.iter().all(|v| v.is_finite())),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()
|
||||
.ok_or_else(bad)
|
||||
}
|
||||
|
||||
fn planar(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use modelcraft_mesh::planar::{Curve, face_boundary, mesh_region};
|
||||
use modelcraft_model::Entity;
|
||||
|
||||
let size = p
|
||||
.get("size")
|
||||
.and_then(Value::as_f64)
|
||||
.filter(|v| *v > 0.0 && v.is_finite())
|
||||
.ok_or_else(|| EngineError::Params("`size` must be a positive number (the element size)".into()))?;
|
||||
let kind = match p.get("kind") {
|
||||
None => ElementKind::Quad4,
|
||||
Some(k) => k
|
||||
.as_str()
|
||||
.and_then(ElementKind::from_name)
|
||||
.filter(|k| k.dimension() == 2)
|
||||
.ok_or_else(|| EngineError::Params("`kind` must be quad4, quad8, tri3 or tri6".into()))?,
|
||||
};
|
||||
let opts = modelcraft_mesh::quad::QuadOptions {
|
||||
max_elements: optional_count(p, "max_elements", 1_000_000, 1..=PLANAR_MAX_ELEMENTS)?,
|
||||
..Default::default()
|
||||
};
|
||||
// The region's loops of curves, the body name, the face its elements fill, and each curve
|
||||
// id's edge name.
|
||||
let (loops, body, face, names): (Vec<Vec<Curve>>, String, String, BTreeMap<u32, String>) = match (p.get("polygon"), p.get("body")) {
|
||||
(Some(outer), None) => {
|
||||
let mut rings = vec![polygon(outer, "polygon")?];
|
||||
if let Some(h) = p.get("holes") {
|
||||
let list = h
|
||||
.as_array()
|
||||
.filter(|a| a.len() <= PLANAR_MAX_HOLES)
|
||||
.ok_or_else(|| EngineError::Params(format!("`holes` must be a list of at most {PLANAR_MAX_HOLES} polygons")))?;
|
||||
for hole in list {
|
||||
rings.push(polygon(hole, "holes")?);
|
||||
}
|
||||
}
|
||||
let name = match p.get("name") {
|
||||
None => "Plane".to_string(),
|
||||
Some(n) => n.as_str().map(str::to_string).ok_or_else(|| EngineError::Params("`name` must be a body name".into()))?,
|
||||
};
|
||||
GeometryRef::body(name.clone())?;
|
||||
let mut id = 0u32;
|
||||
let mut names = BTreeMap::new();
|
||||
let loops = rings
|
||||
.into_iter()
|
||||
.map(|ring| {
|
||||
ring.into_iter()
|
||||
.map(|pt| {
|
||||
id += 1;
|
||||
names.insert(id, format!("e{id}"));
|
||||
Curve { id: Some(id), points: vec![pt] }
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
(loops, name, "area".to_string(), names)
|
||||
}
|
||||
(None, Some(_)) => {
|
||||
let face_name = p.get("face").and_then(Value::as_str).ok_or_else(|| EngineError::Params("`face` must be a face name".into()))?;
|
||||
let (shape, mesh) = super::geometry::body_mesh(s, p)?;
|
||||
let face = shape.face_index(face_name).ok_or_else(|| EngineError::Params(format!("body `{}` has no face `{face_name}`", shape.name)))?;
|
||||
let b = face_boundary(&mesh.positions, &mesh.triangles, &mesh.tri_face, face, mesh.weld_distance)?;
|
||||
let names = shape.faces().iter().enumerate().map(|(i, f)| (i as u32, f.name.clone())).collect();
|
||||
(b.loops, shape.name.clone(), face_name.to_string(), names)
|
||||
}
|
||||
_ => return Err(EngineError::Params("give either `polygon` (and `holes`) or `body` with `face` and `tolerance`".into())),
|
||||
};
|
||||
let m = mesh_region(&loops, kind, size, &opts)?;
|
||||
|
||||
let (first_node, first_element) = next_labels(s, m.nodes.len(), m.elements.len())?;
|
||||
for (i, xy) in m.nodes.iter().enumerate() {
|
||||
s.model.set_node(first_node + i as u32, [xy[0], xy[1], 0.0])?;
|
||||
}
|
||||
let region = s.model.geometry_tag(&GeometryRef::face(body.clone(), face.clone())?)?;
|
||||
for (i, nodes) in m.elements.iter().enumerate() {
|
||||
let label = first_element + i as u32;
|
||||
s.model.set_element(label, kind, nodes.iter().map(|v| first_node + v).collect())?;
|
||||
s.model.tag_element(label, region)?;
|
||||
}
|
||||
let mut tags: BTreeMap<u32, u32> = BTreeMap::new();
|
||||
for (e, edge, id) in &m.edges {
|
||||
let Some(name) = names.get(id) else { continue };
|
||||
let tag = match tags.get(id) {
|
||||
Some(t) => *t,
|
||||
None => {
|
||||
let t = s.model.geometry_tag(&GeometryRef::new(body.clone(), Entity::Edge(name.clone()))?)?;
|
||||
tags.insert(*id, t);
|
||||
t
|
||||
}
|
||||
};
|
||||
s.model.tag_element_edge(first_element + *e as u32, *edge, tag)?;
|
||||
}
|
||||
let polygon_input = p.get("polygon").is_some();
|
||||
if polygon_input {
|
||||
for (node, id) in &m.corners {
|
||||
let t = s.model.geometry_tag(&GeometryRef::new(body.clone(), Entity::Vertex(format!("v{id}")))?)?;
|
||||
s.model.tag_node(first_node + node, t)?;
|
||||
}
|
||||
}
|
||||
let edges: Vec<&String> = tags.keys().filter_map(|id| names.get(id)).collect();
|
||||
Ok(json!({
|
||||
"body": body,
|
||||
"face": face,
|
||||
"kind": kind.name(),
|
||||
"nodes": m.nodes.len(),
|
||||
"elements": m.elements.len(),
|
||||
"first_node": first_node,
|
||||
"first_element": first_element,
|
||||
"edges": edges,
|
||||
}))
|
||||
}
|
||||
|
||||
fn node_add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let l = label(p, "label")?;
|
||||
s.model.set_node(l, xyz(p, "xyz")?)?;
|
||||
@@ -527,4 +670,80 @@ mod tests {
|
||||
assert!(s.execute("mesh.tet.generate", &json!({"surface": 1})).is_err(), "an open surface bounds no volume");
|
||||
assert_eq!(s.model.elements().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planar_meshes_a_polygon_that_solves_as_a_2d_model() {
|
||||
let mut s = Session::default();
|
||||
let hole: Vec<[f64; 2]> = (0..12).map(|k| std::f64::consts::TAU * k as f64 / 12.0).map(|t| [5.0 + t.cos(), 2.0 + t.sin()]).collect();
|
||||
let r = s
|
||||
.execute(
|
||||
"mesh.planar",
|
||||
&json!({"polygon": [[0, 0], [10, 0], [10, 4], [0, 4]], "holes": [hole], "size": 0.5, "kind": "quad8", "name": "Strip"}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["kind"], "quad8");
|
||||
let elements = r["elements"].as_u64().unwrap() as usize;
|
||||
assert!(elements > 20, "{r}");
|
||||
assert!(s.model.elements().values().all(|e| e.kind == modelcraft_model::ElementKind::Quad8));
|
||||
assert!(s.model.nodes().values().all(|n| n.xyz[2] == 0.0));
|
||||
// Sets on the tagged edges carry the boundary condition and the load.
|
||||
for (cmd, params) in [
|
||||
("model.set.create", json!({"name": "all", "geometry": [{"body": "Strip", "face": "area"}]})),
|
||||
("model.set.create", json!({"name": "left", "geometry": [{"body": "Strip", "edge": "e4"}]})),
|
||||
("model.set.create", json!({"name": "right", "geometry": [{"body": "Strip", "edge": "e2"}]})),
|
||||
("model.material.create", json!({"name": "Steel", "elastic": {"type": "isotropic", "young": 210000.0, "poisson": 0.3}})),
|
||||
("model.section.create", json!({"name": "plate", "set": "all", "material": "Steel", "kind": {"type": "plane_stress", "thickness": 2.0}})),
|
||||
("model.boundary_condition.create", json!({"name": "hold", "type": "fixed", "target": {"set": "left"}})),
|
||||
("model.load.create", json!({"name": "pull", "type": "traction", "set": "right", "vector": [10.0, 0.0, 0.0]})),
|
||||
("model.step.create", json!({"name": "Pull", "procedure": {"type": "static"}, "boundary_conditions": ["hold"], "loads": ["pull"]})),
|
||||
] {
|
||||
s.execute(cmd, ¶ms).unwrap_or_else(|e| panic!("{cmd}: {e}"));
|
||||
}
|
||||
let r = s.execute("solve.static", &json!({})).unwrap();
|
||||
let total: f64 = r["reaction_total"][0].as_f64().unwrap();
|
||||
// The traction over the right edge: 10 × 4 × 2.
|
||||
assert!((total + 80.0).abs() < 1e-9, "{r}");
|
||||
// A second mesh appends; failures change nothing.
|
||||
let journal = s.journal.len();
|
||||
let model = s.model.clone();
|
||||
for bad in [
|
||||
json!({"polygon": [[0, 0], [1, 0], [0, 1]], "size": 0}),
|
||||
json!({"polygon": [[0, 0], [1, 0], [0, 1]], "size": 1e-9}),
|
||||
json!({"polygon": [[0, 0], [1, 0], [0, 1]], "size": 0.1, "kind": "hex8"}),
|
||||
json!({"polygon": [[0, 0], [1, 0]], "size": 0.1}),
|
||||
json!({"polygon": [[0, 0], [1, 0], [1, 1], [0, 1]], "holes": [[[2, 2], [3, 2], [3, 3]]], "size": 0.1}),
|
||||
json!({"polygon": [[0, 0], [1, 1], [1, 0], [0, 1]], "size": 0.1}),
|
||||
json!({"polygon": [[0, 0], [1, 0], [0, 1]], "size": 0.1, "max_elements": 0}),
|
||||
json!({"polygon": [[0, 0], [1, 0], [0, 1]], "size": 0.1, "name": ""}),
|
||||
json!({"polygon": [[0, 0], [1, 0], [0, 1]], "size": 0.1, "body": "Body1", "face": "x"}),
|
||||
json!({"body": "Body1", "face": "x", "tolerance": 0.1, "size": 1}),
|
||||
json!({"size": 1}),
|
||||
] {
|
||||
assert!(s.execute("mesh.planar", &bad).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!((s.journal.len(), &s.model), (journal, &model), "failures change nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planar_meshes_a_flat_face_of_a_design_with_edges_named_by_their_neighbours() {
|
||||
use std::sync::Arc;
|
||||
|
||||
use modelcraft_geometry::open_design;
|
||||
use modelcraft_geometry::sample::{Plate, plate_design};
|
||||
|
||||
let design = open_design(&plate_design(&Plate::default()).unwrap()).unwrap();
|
||||
let names: Vec<String> = design.bodies[0].faces().iter().map(|f| f.name.clone()).collect();
|
||||
let mut s = Session { design: Some(Arc::new(design)), ..Session::default() };
|
||||
let mut meshed = 0;
|
||||
for face in &names {
|
||||
let Ok(r) = s.execute("mesh.planar", &json!({"body": "Body1", "face": face, "tolerance": 0.2, "size": 4.0, "kind": "tri6"})) else {
|
||||
continue;
|
||||
};
|
||||
meshed += 1;
|
||||
let edges: Vec<&str> = r["edges"].as_array().unwrap().iter().map(|e| e.as_str().unwrap()).collect();
|
||||
// The plate's top and bottom faces border its four sides and the hole.
|
||||
assert!(edges.len() >= 5 && edges.iter().all(|e| names.iter().any(|n| n == e)), "{face}: {edges:?}");
|
||||
}
|
||||
assert_eq!(meshed, 2, "the top and bottom faces are flat and parallel to XY; the sides and hole are not");
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ pub static ALL: &[CommandSpec] = &[
|
||||
mesh::QUALITY,
|
||||
mesh::DELAUNAY_POINTS,
|
||||
mesh::SURFACE_CHECK,
|
||||
mesh::PLANAR,
|
||||
mesh::TET_GENERATE,
|
||||
tags::TAG,
|
||||
tags::TAG_CLEAR,
|
||||
|
||||
Reference in new issue
Block a user