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

+1
View File
@@ -9,6 +9,7 @@
pub mod cdt;
pub mod delaunay;
pub mod planar;
pub mod quad;
pub mod quality;
pub mod size;
+377
View File
@@ -0,0 +1,377 @@
//! 2D models of planar regions: the meshes plane stress, plane strain and axisymmetric analyses
//! run on.
//!
//! A region is bounded by loops of *curves* (polylines; the first loop is the outer boundary, the
//! others are holes). [`mesh_region`] divides every curve by the size, meshes the region with the
//! free quadrilateral mesher or the free triangle mesher of [`crate::quad`] on those boundary
//! nodes, and adds mid-side nodes for quadratic elements (on the boundary polyline for boundary
//! edges, at the chord midpoint inside). Every element edge on a curve is reported with the
//! curve's id, so a mesher above can tag it with the geometry it came from.
//!
//! [`face_boundary`] gives the curves of one planar face of a tessellated body: the face's
//! boundary loops in the XY plane, split where the neighbouring face changes (each curve's id is
//! the neighbour's face index, the B-rep edge the two faces share) and at sharp corners.
use std::collections::BTreeMap;
use modelcraft_model::ElementKind;
use crate::quad::{QuadOptions, free_quad_mesh, free_tri_mesh};
use crate::size::{UniformSize, divide_polyline, edge_divisions, even_divisions, ideal_divisions};
use crate::surface::weld::weld;
use crate::{Error, Result};
/// A boundary curve: a polyline from its start to the start of the next curve of its loop (the
/// end point is not repeated), and an id reported on the element edges that lie on it.
#[derive(Debug, Clone, PartialEq)]
pub struct Curve {
pub id: Option<u32>,
pub points: Vec<[f64; 2]>,
}
/// A planar mesh of 2D elements in the XY plane.
#[derive(Debug, Clone, PartialEq)]
pub struct PlanarMesh {
pub kind: ElementKind,
pub nodes: Vec<[f64; 2]>,
/// Counterclockwise node lists in the model's node order (corners, then mid-side nodes).
pub elements: Vec<Vec<u32>>,
/// Element edges on a curve with an id: (element index, edge number from 1, curve id).
pub edges: Vec<(usize, u8, u32)>,
/// The node at the start of each curve with an id: (node, curve id).
pub corners: Vec<(u32, u32)>,
}
/// Turns sharper than this (radians) start a new curve in [`face_boundary`], so corners stay
/// nodes of the mesh.
const CORNER_ANGLE: f64 = std::f64::consts::PI / 6.0;
/// Mesh the region bounded by `loops` with elements of `kind` (tri3, tri6, quad4 or quad8) of
/// about `size`.
pub fn mesh_region(loops: &[Vec<Curve>], kind: ElementKind, size: f64, opts: &QuadOptions) -> Result<PlanarMesh> {
if kind.dimension() != 2 {
return Err(Error::Invalid(format!("a planar mesh has tri3, tri6, quad4 or quad8 elements, not {}", kind.name())));
}
if !(size > 0.0 && size.is_finite()) {
return Err(Error::Invalid(format!("the element size must be a positive number, got {size}")));
}
if loops.is_empty() || loops.iter().any(Vec::is_empty) {
return Err(Error::Invalid("a region needs at least one loop of curves".into()));
}
let quads = matches!(kind, ElementKind::Quad4 | ElementKind::Quad8);
let quadratic = kind.is_quadratic();
let field = UniformSize(size);
// Boundary points per loop, the curve of each boundary segment, and the boundary mid points.
let mut boundary: Vec<Vec<[f64; 2]>> = Vec::with_capacity(loops.len());
let mut segment_curve: Vec<Option<u32>> = Vec::new();
let mut segment_mid: Vec<[f64; 2]> = Vec::new();
let mut curve_starts: Vec<(usize, u32)> = Vec::new();
for (l, curves) in loops.iter().enumerate() {
let polylines: Vec<Vec<[f64; 3]>> = curves
.iter()
.enumerate()
.map(|(i, c)| {
let next = curves.get((i + 1) % curves.len()).and_then(|n| n.points.first()).copied();
c.points.iter().chain(next.as_ref()).map(|p| [p[0], p[1], 0.0]).collect()
})
.collect();
let mut counts: Vec<usize> = polylines
.iter()
.map(|p| if quads { ideal_divisions(&field, p).and_then(even_divisions) } else { edge_divisions(&field, p) })
.collect::<Result<_>>()?;
// Enough points per loop for the meshers: 3 for triangles; 4 for a lone quad loop, 6 with
// holes.
let least = if !quads {
3
} else if loops.len() > 1 {
6
} else {
4
};
while counts.iter().sum::<usize>() < least {
let longest = (0..counts.len()).max_by(|&a, &b| counts[a].cmp(&counts[b]).then(b.cmp(&a))).unwrap_or(0);
if let Some(c) = counts.get_mut(longest) {
*c += if quads { 2 } else { 1 };
}
}
let mut pts = Vec::new();
for ((poly, n), curve) in polylines.iter().zip(&counts).zip(curves) {
let fine = divide_polyline(&field, poly, if quadratic { 2 * n } else { *n })?;
let step = if quadratic { 2 } else { 1 };
if let Some(id) = curve.id {
curve_starts.push((boundary.iter().map(Vec::len).sum::<usize>() + pts.len(), id));
}
for k in 0..*n {
let p = fine.get(step * k).ok_or_else(|| Error::Failed("curve division".into()))?;
pts.push([p[0], p[1]]);
segment_curve.push(curve.id);
let m = if quadratic { fine.get(2 * k + 1) } else { None }.copied().unwrap_or_default();
segment_mid.push([m[0], m[1]]);
}
}
if pts.len() < 3 {
return Err(Error::Invalid(format!("loop {l} is too short to mesh")));
}
boundary.push(pts);
}
let (nodes, corners_of): (Vec<[f64; 2]>, Vec<Vec<u32>>) = if quads {
let m = free_quad_mesh(&boundary, &field, opts)?;
(m.nodes, m.quads.iter().map(|q| q.to_vec()).collect())
} else {
let m = free_tri_mesh(&boundary, &field, opts)?;
(m.nodes, m.triangles.iter().map(|t| t.to_vec()).collect())
};
// Boundary segment of a node pair: segment k of loop l joins its points k and k + 1.
let mut segment: BTreeMap<(u32, u32), usize> = BTreeMap::new();
let mut offset = 0usize;
let mut s = 0usize;
for pts in &boundary {
let n = pts.len();
for k in 0..n {
let (a, b) = ((offset + k) as u32, (offset + (k + 1) % n) as u32);
segment.insert((a.min(b), a.max(b)), s);
s += 1;
}
offset += n;
}
let mut nodes = nodes;
let mut mids: BTreeMap<(u32, u32), u32> = BTreeMap::new();
let mut elements = Vec::with_capacity(corners_of.len());
let mut edges = Vec::new();
for (e, corners) in corners_of.iter().enumerate() {
let mut list = corners.clone();
for (i, local) in kind.edges().iter().enumerate() {
let (Some(&a), Some(&b)) = (local.first().and_then(|x| corners.get(*x)), local.get(1).and_then(|x| corners.get(*x))) else {
return Err(Error::Failed("element edge".into()));
};
let key = (a.min(b), a.max(b));
let on = segment.get(&key).copied();
if let Some(id) = on.and_then(|s| segment_curve.get(s).copied().flatten()) {
edges.push((e, i as u8 + 1, id));
}
if quadratic {
let id = match mids.get(&key) {
Some(id) => *id,
None => {
let p = match on.and_then(|s| segment_mid.get(s)) {
Some(m) => *m,
None => {
let (pa, pb) =
(nodes.get(a as usize).copied().unwrap_or_default(), nodes.get(b as usize).copied().unwrap_or_default());
[0.5 * (pa[0] + pb[0]), 0.5 * (pa[1] + pb[1])]
}
};
nodes.push(p);
let id = u32::try_from(nodes.len() - 1).map_err(|_| Error::Limit("too many nodes".into()))?;
mids.insert(key, id);
id
}
};
list.push(id);
}
}
elements.push(list);
}
let corners = curve_starts.into_iter().map(|(n, id)| (n as u32, id)).collect();
Ok(PlanarMesh { kind, nodes, elements, edges, corners })
}
/// The boundary of one planar face of a triangulated body, in the XY plane.
#[derive(Debug, Clone, PartialEq)]
pub struct FaceBoundary {
/// The outer loop first, then the holes; each curve's id is the face index of the neighbour
/// it borders (`None` where no neighbour was found).
pub loops: Vec<Vec<Curve>>,
/// The face's Z.
pub z: f64,
}
/// The boundary of face `face` of a triangulated body (`tri_face` gives each triangle's face):
/// the face must lie in a plane of constant Z (within `tolerance`); points of neighbouring faces
/// within `tolerance` are the same point.
pub fn face_boundary(positions: &[[f64; 3]], triangles: &[[u32; 3]], tri_face: &[u32], face: u32, tolerance: f64) -> Result<FaceBoundary> {
if !(tolerance >= 0.0 && tolerance.is_finite()) {
return Err(Error::Invalid(format!("tolerance must be a non-negative number, got {tolerance}")));
}
if let Some(v) = triangles.iter().flatten().find(|v| **v as usize >= positions.len()) {
return Err(Error::Invalid(format!("triangle vertex {v} is out of range")));
}
if tri_face.len() != triangles.len() {
return Err(Error::Invalid("every triangle needs its face".into()));
}
// One weld of the whole tessellation: each face's copies of a shared point become one point,
// so neighbours meet on welded edges.
let welded = weld(positions, triangles, 0.0, tolerance)?;
let xyz = welded.points;
let face_of = |t: usize| welded.source_triangle.get(t).and_then(|s| tri_face.get(*s as usize)).copied();
let mine: Vec<[u32; 3]> = welded.triangles.iter().enumerate().filter(|(t, _)| face_of(*t) == Some(face)).map(|(_, w)| *w).collect();
if mine.is_empty() {
return Err(Error::Invalid(format!("face {face} has no triangles")));
}
let z = mine.first().and_then(|w| xyz.get(w[0] as usize)).map_or(0.0, |p| p[2]);
let flat = mine.iter().flatten().filter_map(|v| xyz.get(*v as usize)).all(|p| (p[2] - z).abs() <= tolerance.max(1e-12 * (1.0 + z.abs())));
if !flat {
return Err(Error::Invalid("the face does not lie in a plane parallel to XY (a 2D model lies in the XY plane)".into()));
}
// Boundary edges: directed edges whose reverse is not in the face.
let mut directed: BTreeMap<(u32, u32), usize> = BTreeMap::new();
for w in &mine {
for k in 0..3 {
*directed.entry((w[k], w[(k + 1) % 3])).or_default() += 1;
}
}
let mut next: BTreeMap<u32, u32> = BTreeMap::new();
for (&(a, b), &count) in &directed {
if directed.contains_key(&(b, a)) {
continue;
}
if count > 1 || next.insert(a, b).is_some() {
return Err(Error::Invalid("the face's boundary touches itself (a non-manifold face); split the face".into()));
}
}
// Neighbours: the other faces' welded edges, by their ends.
let mut other_faces: BTreeMap<(u32, u32), u32> = BTreeMap::new();
for (t, w) in welded.triangles.iter().enumerate() {
let Some(f) = face_of(t).filter(|f| *f != face) else { continue };
for k in 0..3 {
let (a, b) = (w[k], w[(k + 1) % 3]);
other_faces.entry((a.min(b), a.max(b))).or_insert(f);
}
}
let neighbour = |a: u32, b: u32| other_faces.get(&(a.min(b), a.max(b))).copied();
// Chain the boundary into loops, each started at its smallest vertex id.
let mut loops: Vec<(f64, Vec<Curve>)> = Vec::new();
let mut unused: BTreeMap<u32, u32> = next.clone();
while let Some((&start, _)) = unused.iter().next() {
let mut chain = vec![start];
let mut v = start;
loop {
let Some(w) = unused.remove(&v) else {
return Err(Error::Invalid("the face's boundary is not closed".into()));
};
if w == start {
break;
}
chain.push(w);
v = w;
if chain.len() > next.len() {
return Err(Error::Failed("boundary chaining did not close".into()));
}
}
let at = |i: usize| chain.get(i % chain.len()).and_then(|v| xyz.get(*v as usize)).copied().unwrap_or_default();
let n = chain.len();
let ids: Vec<Option<u32>> = (0..n).map(|i| neighbour(chain[i], chain[(i + 1) % n])).collect();
// A curve starts where the neighbour changes or the boundary turns sharply.
let starts: Vec<bool> = (0..n)
.map(|i| {
let (p, q, r) = (at(i + n - 1), at(i), at(i + 1));
let (u, w) = ([q[0] - p[0], q[1] - p[1]], [r[0] - q[0], r[1] - q[1]]);
let turn = (u[0] * w[1] - u[1] * w[0]).atan2(u[0] * w[0] + u[1] * w[1]).abs();
ids[i] != ids[(i + n - 1) % n] || turn > CORNER_ANGLE
})
.collect();
let first = starts.iter().position(|s| *s).unwrap_or(0);
let mut curves: Vec<Curve> = Vec::new();
for k in 0..n {
let i = (first + k) % n;
let p = at(i);
if starts[i] || curves.is_empty() {
curves.push(Curve { id: ids[i], points: Vec::new() });
}
if let Some(c) = curves.last_mut() {
c.points.push([p[0], p[1]]);
}
}
let area: f64 = (0..n).map(|i| at(i)[0] * at(i + 1)[1] - at(i + 1)[0] * at(i)[1]).sum::<f64>() / 2.0;
loops.push((area.abs(), curves));
}
// The outer loop encloses the most area.
let outer = (0..loops.len()).max_by(|&a, &b| loops[a].0.total_cmp(&loops[b].0).then(b.cmp(&a))).unwrap_or(0);
let mut out = Vec::with_capacity(loops.len());
if outer < loops.len() {
out.push(loops.remove(outer).1);
}
out.extend(loops.into_iter().map(|l| l.1));
Ok(FaceBoundary { loops: out, z })
}
#[cfg(test)]
mod tests {
use super::*;
fn square_with_hole() -> Vec<Vec<Curve>> {
let side = |id: u32, a: [f64; 2]| Curve { id: Some(id), points: vec![a] };
let outer = vec![side(1, [0.0, 0.0]), side(2, [4.0, 0.0]), side(3, [4.0, 3.0]), side(4, [0.0, 3.0])];
let circle: Vec<[f64; 2]> = (0..32).map(|k| 2.0 * std::f64::consts::PI * k as f64 / 32.0).map(|t| [2.0 + t.cos(), 1.5 + t.sin()]).collect();
vec![outer, vec![Curve { id: Some(5), points: circle }]]
}
#[test]
fn regions_mesh_with_every_element_kind_and_report_curve_edges() {
let loops = square_with_hole();
let area_want = 12.0 - std::f64::consts::PI;
for kind in [ElementKind::Tri3, ElementKind::Tri6, ElementKind::Quad4, ElementKind::Quad8] {
let m = mesh_region(&loops, kind, 0.4, &QuadOptions::default()).unwrap();
assert!(m.elements.iter().all(|e| e.len() == kind.node_count()));
// Area by the corner polygons (the hole's chords make it a little larger).
let area: f64 = m
.elements
.iter()
.map(|e| {
let c: Vec<[f64; 2]> = e[..kind.corner_count()].iter().map(|v| m.nodes[*v as usize]).collect();
(0..c.len()).map(|i| c[i][0] * c[(i + 1) % c.len()][1] - c[(i + 1) % c.len()][0] * c[i][1]).sum::<f64>() / 2.0
})
.sum();
assert!(area > area_want && area - area_want < 0.1, "{kind:?}: {area}");
// Edges on each curve: the right side (x = 4, curve 2) has length 3.
let on_right: Vec<_> = m.edges.iter().filter(|e| e.2 == 2).collect();
assert!(!on_right.is_empty());
for (e, edge, _) in on_right {
let local = kind.edges()[usize::from(*edge) - 1];
for i in local {
assert!((m.nodes[m.elements[*e][*i] as usize][0] - 4.0).abs() < 1e-12, "{kind:?}");
}
}
// Quadratic mid-side nodes on the hole lie on its polyline (radius 1 within the chord sag).
assert_eq!(m.corners.len(), 5);
assert_eq!(mesh_region(&loops, kind, 0.4, &QuadOptions::default()).unwrap(), m, "deterministic");
}
assert!(mesh_region(&loops, ElementKind::Hex8, 0.4, &QuadOptions::default()).is_err());
assert!(mesh_region(&loops, ElementKind::Quad4, 0.0, &QuadOptions::default()).is_err());
assert!(mesh_region(&loops, ElementKind::Quad4, f64::NAN, &QuadOptions::default()).is_err());
assert!(mesh_region(&[], ElementKind::Quad4, 1.0, &QuadOptions::default()).is_err());
assert!(mesh_region(&loops, ElementKind::Tri3, 0.02, &QuadOptions { max_elements: 1000, ..QuadOptions::default() }).is_err());
}
#[test]
fn a_tessellated_face_gives_its_loops_and_neighbours() {
// A unit cube's bottom face (z = 0, faces 0), split into two triangles; four side faces 1–4
// with their own copies of the points.
let p = vec![
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[1.0, 1.0, 0.0],
[0.0, 1.0, 0.0],
// Side y = 0 (face 1).
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[1.0, 0.0, 1.0],
// Side x = 1 (face 2).
[1.0, 0.0, 0.0],
[1.0, 1.0, 0.0],
[1.0, 1.0, 1.0],
];
let tris = vec![[0, 2, 1], [0, 3, 2], [4, 5, 6], [7, 8, 9]];
let faces = vec![0, 0, 1, 2];
let b = face_boundary(&p, &tris, &faces, 0, 1e-9).unwrap();
assert_eq!(b.loops.len(), 1);
let ids: Vec<Option<u32>> = b.loops[0].iter().map(|c| c.id).collect();
assert!(ids.contains(&Some(1)) && ids.contains(&Some(2)) && ids.contains(&None), "{ids:?}");
let points: usize = b.loops[0].iter().map(|c| c.points.len()).sum();
assert_eq!(points, 4);
assert!(face_boundary(&p, &tris, &faces, 1, 1e-9).is_err(), "face 1 is vertical");
assert!(face_boundary(&p, &tris, &faces, 7, 1e-9).is_err());
assert!(face_boundary(&p, &[[0, 1, 99]], &[0], 0, 1e-9).is_err());
assert!(face_boundary(&p, &tris, &faces, 0, f64::NAN).is_err());
}
}
+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());
}
+1 -1
View File
@@ -46,7 +46,7 @@
mod bvh;
pub mod intersect;
mod topology;
mod weld;
pub(crate) mod weld;
#[cfg(test)]
mod tests;