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:
authored and GitHub committed 2026-10-13 20:24:56 -04:00
1 parent 4e4d19b88c
commit 4c0fcc80ad
42 files changed
+3270 -128

No files matched your search

+45
View File
@@ -160,6 +160,51 @@ pub fn free_quad_mesh(loops: &[Vec<[f64; 2]>], size: &dyn SizeField, opts: &Quad
free::mesh(loops, size, opts)
}
/// A planar triangle mesh.
#[derive(Debug, Clone, PartialEq)]
pub struct TriMesh {
/// Node coordinates; `nodes[..boundary_nodes]` are the input boundary points in input order.
pub nodes: Vec<[f64; 2]>,
/// Counterclockwise corner node ids (the `Tri3` node order).
pub triangles: Vec<[u32; 3]>,
pub boundary_nodes: usize,
}
/// Free triangle mesh of a planar region on prescribed boundary nodes: the constrained Delaunay
/// triangulation of the loops ([`cdt`]: Bowyer–Watson, Sloan 1993 recovery), refined to the size
/// field by Ruppert-style circumcentre insertion that never splits a boundary edge (Steiner points
/// keep a clearance of half the local boundary spacing from the boundary), so the boundary nodes
/// are exactly the input points. Every triangle is checked counterclockwise with exact
/// predicates. Loops as for [`free_quad_mesh`], with any number of points (at least 3 each).
pub fn free_tri_mesh(loops: &[Vec<[f64; 2]>], size: &dyn SizeField, opts: &QuadOptions) -> Result<TriMesh> {
let region = domain::region(loops)?;
let mut chords = Vec::new();
for l in &region.loops {
for (i, &a) in l.iter().enumerate() {
let b = l.get((i + 1) % l.len()).copied().ok_or_else(|| Error::Failed("loop index".into()))?;
let (pa, pb) = (*region.point(a)?, *region.point(b)?);
let (ca, cb) = (pa.coords(), pb.coords());
let m = Point2::new(snap_to_domain(0.5 * (ca[0] + cb[0])), snap_to_domain(0.5 * (ca[1] + cb[1])))?;
chords.push((pa, pb, m));
}
}
let guard = guard::Guard::new(&chords)?;
let mut cdt = cdt::Cdt::build(&region, opts.max_elements.saturating_add(4 * region.pts.len() + 16))?;
cdt.refine(size, 1.0, &|p, h| guard.allows(p, cdt::CLEARANCE * h))?;
let tri = cdt.into_triangulation();
if tri.tris.len() > opts.max_elements {
return Err(Error::Limit(format!("the mesh would have {} triangles; the limit is {}", tri.tris.len(), opts.max_elements)));
}
for t in &tri.tris {
let [a, b, c] = t.map(|v| tri.p2.get(v as usize));
match (a, b, c) {
(Some(a), Some(b), Some(c)) if crate::predicates::orient2d(a, b, c) == Sign::Positive => {}
_ => return Err(Error::Failed("a triangle is inverted or degenerate".into())),
}
}
Ok(TriMesh { nodes: tri.pts, triangles: tri.tris, boundary_nodes: region.pts.len() })
}
/// Mapped mesh of a four-sided region: `boundary` lists the region's boundary nodes once (either
/// orientation), and `corners` are the positions in it of the four corners in boundary order.
/// Opposite sides need the same number of divisions. The grid is transfinite interpolation
+31
View File
@@ -537,3 +537,34 @@ fn unsmoothed_meshes_are_valid() {
let m = free_quad_mesh(&loops, &UniformSize(0.2), &QuadOptions { smoothing_sweeps: 0, ..opts() }).unwrap();
check(&m, &loops);
}
#[test]
fn free_triangle_mesh_of_a_plate_with_a_hole() {
// Odd point counts are fine for triangles.
let outer = rect(0.0, 0.0, 10.0, 6.0, 9, 5);
let hole: Vec<[f64; 2]> = circle(4.0, 3.0, 1.5, 17).into_iter().rev().collect();
let loops = vec![outer.clone(), hole.clone()];
let m = free_tri_mesh(&loops, &UniformSize(0.7), &opts()).unwrap();
assert_eq!(m.boundary_nodes, outer.len() + hole.len());
let boundary: Vec<[f64; 2]> = outer.iter().chain(&hole).copied().collect();
assert_eq!(&m.nodes[..m.boundary_nodes], boundary.as_slice(), "boundary nodes are the input points");
let area: f64 = m
.triangles
.iter()
.map(|t| {
let [a, b, c] = t.map(|v| m.nodes[v as usize]);
let s = 0.5 * ((b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]));
assert!(s > 0.0, "counterclockwise");
s
})
.sum();
let want = signed_area(&outer).abs() - signed_area(&hole).abs();
assert!((area - want).abs() < 1e-9 * want, "{area} vs {want}");
assert!(m.triangles.len() > 100, "refined to the size: {}", m.triangles.len());
// Deterministic.
assert_eq!(free_tri_mesh(&loops, &UniformSize(0.7), &opts()).unwrap(), m);
// Hostile.
assert!(free_tri_mesh(&[vec![[0.0, 0.0], [1.0, 0.0]]], &UniformSize(0.5), &opts()).is_err());
assert!(free_tri_mesh(std::slice::from_ref(&outer), &UniformSize(0.001), &QuadOptions { max_elements: 100, ..opts() }).is_err());
assert!(free_tri_mesh(&[vec![[0.0, 0.0], [f64::NAN, 0.0], [0.0, 1.0]]], &UniformSize(0.5), &opts()).is_err());
}