forked from 1b-agent/fieldcraft-upstream
* Planar all-quad meshing: size fields, TFI mapped meshes, free quad mesher, planar quad validity - modelcraft_mesh::size: SizeField trait, division counts, graded polyline division, per-loop parity - modelcraft_mesh::quad: transfinite (Gordon-Hall) mapped meshes of four-sided regions; free all-quad meshes of polygons with holes on prescribed boundary nodes (half-resolution CDT with Ruppert refinement, Lee-Lo recombination, uniform split, smart Laplacian plus Freitag-Plassmann untangling), exact validity gate on every element - modelcraft_mesh::quality: Robinson aspect ratio and skew for quads, exact planar quad validity Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * quad mesher: true-boundary guard for Steiner points, untangling by corner area, parity retry, recombination quality floor; I-beam and perforated plate tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * docs: quad meshing papers (B7), roadmap M5 and M2, architecture pipeline step Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * quad mesher: simplify (shared corner and validity helpers, vertex rotation helper, guard decoupled from the CDT, verify inside the CDT, non-allocating final gate) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> 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>
362 lines
16 KiB
Rust
362 lines
16 KiB
Rust
//! The free all-quadrilateral mesher: half-resolution constrained Delaunay triangulation,
|
||
//! recombination into quadrilaterals, one uniform split that lands on the prescribed boundary
|
||
//! nodes, then constrained smoothing. See the module docs of [`super`] for the method and its
|
||
//! sources.
|
||
|
||
use std::collections::BTreeMap;
|
||
|
||
use super::cdt::{CLEARANCE, Cdt, Triangulation};
|
||
use super::domain::{self, Region};
|
||
use super::guard::Guard;
|
||
use super::{QuadMesh, QuadOptions, finish};
|
||
use crate::predicates::{Sign, orient2d};
|
||
use crate::quality::{planar_corner, turns_left};
|
||
use crate::size::SizeField;
|
||
use crate::{Error, Result};
|
||
|
||
/// Mesh the region bounded by `loops` (the first is the outer boundary, the rest are holes;
|
||
/// either orientation) with quadrilaterals only. See [`super::free_quad_mesh`].
|
||
pub(crate) fn mesh(loops: &[Vec<[f64; 2]>], size: &dyn SizeField, opts: &QuadOptions) -> Result<QuadMesh> {
|
||
let region = domain::region(loops)?;
|
||
for (k, l) in region.loops.iter().enumerate() {
|
||
if l.len() % 2 == 1 {
|
||
return Err(Error::Invalid(format!(
|
||
"loop {k} has an odd number ({}) of boundary points; an all-quadrilateral mesh needs an even number on every loop \
|
||
(choose the edge divisions with modelcraft_mesh::size::loop_divisions or even_divisions)",
|
||
l.len()
|
||
)));
|
||
}
|
||
}
|
||
if region.loops.len() == 1 && region.loops.first().is_some_and(|l| l.len() == 4) {
|
||
let l = region.loops.first().cloned().unwrap_or_default();
|
||
let quad = [l.first(), l.get(1), l.get(2), l.get(3)];
|
||
let quad = match quad {
|
||
[Some(&a), Some(&b), Some(&c), Some(&d)] => [a, b, c, d],
|
||
_ => return Err(Error::Failed("quad loop".into())),
|
||
};
|
||
return finish(region.pts.clone(), vec![quad], region.pts.len(), opts, Default::default());
|
||
}
|
||
if let Some((k, l)) = region.loops.iter().enumerate().find(|(_, l)| l.len() < 6) {
|
||
return Err(Error::Invalid(format!(
|
||
"loop {k} has {} boundary points; the free quad mesher needs at least 6 on every loop of a region with more than one loop \
|
||
(split its sides)",
|
||
l.len()
|
||
)));
|
||
}
|
||
|
||
// Both parity classes, preferred first; the second is tried when the first cannot be made
|
||
// valid.
|
||
let mut last = None;
|
||
for coarse in coarse_regions(®ion)? {
|
||
match attempt(®ion, coarse, size, opts) {
|
||
Ok(mesh) => return Ok(mesh),
|
||
Err(e @ Error::Failed(_)) => last = Some(e),
|
||
Err(e) => return Err(e),
|
||
}
|
||
}
|
||
Err(last.unwrap_or_else(|| Error::Failed("no half-resolution boundary".into())))
|
||
}
|
||
|
||
/// One run of the pipeline on one half-resolution region.
|
||
fn attempt(region: &Region, coarse: Coarse, size: &dyn SizeField, opts: &QuadOptions) -> Result<QuadMesh> {
|
||
let Coarse { region: coarse, to_input, skipped, before } = coarse;
|
||
let chords = chords(&coarse, &skipped)?;
|
||
// For each coarse boundary edge (coarse ids, smaller first), the input point it skips.
|
||
let boundary_mid: BTreeMap<(u32, u32), u32> = chords.iter().map(|&(a, b, m)| ((a.min(b), a.max(b)), m)).collect();
|
||
let guard =
|
||
Guard::new(&chords.iter().map(|&(a, b, m)| Ok((*coarse.point(a)?, *coarse.point(b)?, *region.point(m)?))).collect::<Result<Vec<_>>>()?)?;
|
||
let need = fan_needs(region, &to_input, &before, &skipped)?;
|
||
let max_tris = opts.max_elements / 2 + 4 * region.pts.len() + 16;
|
||
let mut cdt = Cdt::build(&coarse, max_tris)?;
|
||
cdt.refine(size, 2.0, &|p, h| guard.allows(p, CLEARANCE * h))?;
|
||
cdt.ensure_fans(&need)?;
|
||
let tri = cdt.into_triangulation();
|
||
let (quads, leftovers) = recombine(&tri, &need)?;
|
||
let count = 4 * quads.len() + 3 * leftovers.len();
|
||
if count > opts.max_elements {
|
||
return Err(Error::Limit(format!("the mesh would have {count} quadrilaterals; the limit is {}", opts.max_elements)));
|
||
}
|
||
let (nodes, elements) = split(region, &tri, &to_input, &boundary_mid, &quads, &leftovers)?;
|
||
let stats = super::QuadStats { recombined: quads.len(), leftover_triangles: leftovers.len(), ..Default::default() };
|
||
finish(nodes, elements, region.pts.len(), opts, stats)
|
||
}
|
||
|
||
/// `1 − cos θ` of the turn at each loop position (0 on a straight run, 2 on a full reversal).
|
||
fn turns(region: &Region, l: &[u32]) -> Result<Vec<f64>> {
|
||
let n = l.len();
|
||
let at = |i: usize| -> Result<[f64; 2]> {
|
||
let v = l.get(i % n).copied().ok_or_else(|| Error::Failed("loop index".into()))?;
|
||
region.pts.get(v as usize).copied().ok_or_else(|| Error::Failed("point index".into()))
|
||
};
|
||
(0..n)
|
||
.map(|i| {
|
||
let (a, b, c) = (at(i + n - 1)?, at(i)?, at(i + 1)?);
|
||
let (u, w) = ([b[0] - a[0], b[1] - a[1]], [c[0] - b[0], c[1] - b[1]]);
|
||
let den = (u[0] * u[0] + u[1] * u[1]).sqrt() * (w[0] * w[0] + w[1] * w[1]).sqrt();
|
||
let cos = (u[0] * w[0] + u[1] * w[1]) / den;
|
||
Ok(if den > 0.0 && cos.is_finite() { 1.0 - cos } else { 0.0 })
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// The half-resolution regions to try: every other point of each loop, first keeping the parity
|
||
/// class with the sharper turns (so loop corners survive when they sit at an even spacing), then
|
||
/// the other class of every loop. Choices whose chords cross or flip are dropped.
|
||
fn coarse_regions(region: &Region) -> Result<Vec<Coarse>> {
|
||
let mut preferred = Vec::with_capacity(region.loops.len());
|
||
for l in ®ion.loops {
|
||
let t = turns(region, l)?;
|
||
let even: f64 = t.iter().step_by(2).sum();
|
||
let odd: f64 = t.iter().skip(1).step_by(2).sum();
|
||
preferred.push(usize::from(odd > even));
|
||
}
|
||
let mut out = Vec::new();
|
||
let mut last = None;
|
||
for flip in [0, 1] {
|
||
let offsets: Vec<usize> = preferred.iter().map(|&o| o ^ flip).collect();
|
||
let coarse = pick(region, &offsets)?;
|
||
match check_coarse(&coarse.region) {
|
||
Ok(()) => out.push(coarse),
|
||
Err(e) => last = Some(e),
|
||
}
|
||
}
|
||
if out.is_empty() {
|
||
return Err(Error::Failed(format!(
|
||
"the boundary is too coarse for the free quad mesher: the chords joining every other boundary point cross or flip ({}); \
|
||
refine the boundary divisions",
|
||
last.map(|e| e.to_string()).unwrap_or_default()
|
||
)));
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
/// The half-resolution region, with each coarse point's input id and the input id of the
|
||
/// boundary point after it (the one the coarse edge skips).
|
||
struct Coarse {
|
||
region: Region,
|
||
to_input: Vec<u32>,
|
||
skipped: Vec<u32>,
|
||
/// The input point before each coarse point.
|
||
before: Vec<u32>,
|
||
}
|
||
|
||
fn pick(region: &Region, offsets: &[usize]) -> Result<Coarse> {
|
||
let (mut pts, mut p2, mut loops, mut to_input, mut skipped, mut before) =
|
||
(Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new());
|
||
for (l, &off) in region.loops.iter().zip(offsets) {
|
||
let mut ids = Vec::with_capacity(l.len() / 2);
|
||
for pos in (off..l.len()).step_by(2) {
|
||
let (v, after, prior) = match (l.get(pos), l.get((pos + 1) % l.len()), l.get((pos + l.len() - 1) % l.len())) {
|
||
(Some(&v), Some(&a), Some(&b)) => (v, a, b),
|
||
_ => return Err(Error::Failed("loop index".into())),
|
||
};
|
||
ids.push(pts.len() as u32);
|
||
pts.push(region.pts.get(v as usize).copied().ok_or_else(|| Error::Failed("point index".into()))?);
|
||
p2.push(*region.point(v)?);
|
||
to_input.push(v);
|
||
skipped.push(after);
|
||
before.push(prior);
|
||
}
|
||
loops.push(ids);
|
||
}
|
||
Ok(Coarse { region: Region { pts, p2, loops }, to_input, skipped, before })
|
||
}
|
||
|
||
/// The coarse loops must be simple, keep their orientation and nesting.
|
||
fn check_coarse(coarse: &Region) -> Result<()> {
|
||
domain::check_simple(coarse)?;
|
||
domain::check_orientation(coarse)?;
|
||
domain::check_nesting(coarse)
|
||
}
|
||
|
||
/// The coarse boundary edges `(a, b, m)`: coarse ids in loop order and the input point `m` the
|
||
/// edge skips.
|
||
fn chords(coarse: &Region, skipped: &[u32]) -> Result<Vec<(u32, u32, u32)>> {
|
||
let mut out = Vec::with_capacity(coarse.pts.len());
|
||
for (a, b) in domain::segments(&coarse.loops) {
|
||
out.push((a, b, skipped.get(a as usize).copied().ok_or_else(|| Error::Failed("coarse id".into()))?));
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
/// How many coarse elements each coarse boundary vertex needs around it. A coarse vertex `v`
|
||
/// gets one final quadrilateral per incident coarse element, and the corner of the first and last
|
||
/// of them at `v` opens towards the prescribed neighbours of `v`. A single element would span the
|
||
/// whole boundary angle at `v`, so a straight or nearly straight (interior angle above 150°) or
|
||
/// reflex vertex needs two, and one above 300° needs three. Decided with the exact `orient2d` and
|
||
/// the cosine of the turn.
|
||
fn fan_needs(region: &Region, to_input: &[u32], before: &[u32], after: &[u32]) -> Result<Vec<usize>> {
|
||
let mut need = Vec::with_capacity(to_input.len());
|
||
for ((&v, &a), &b) in to_input.iter().zip(before).zip(after) {
|
||
let (pa, pv, pb) = (region.point(a)?, region.point(v)?, region.point(b)?);
|
||
let o = orient2d(pa, pv, pb);
|
||
let (a, v, b) = (pa.coords(), pv.coords(), pb.coords());
|
||
let (u, w) = ([v[0] - a[0], v[1] - a[1]], [b[0] - v[0], b[1] - v[1]]);
|
||
let cos = (u[0] * w[0] + u[1] * w[1]) / ((u[0] * u[0] + u[1] * u[1]).sqrt() * (w[0] * w[0] + w[1] * w[1]).sqrt());
|
||
// Interior angle 180° − turn: above 150° when the turn is below 30° (cos > √3/2).
|
||
need.push(match o {
|
||
Sign::Positive if cos > 0.866_025_403_784_438_6 => 2,
|
||
Sign::Positive => 1,
|
||
Sign::Zero => 2,
|
||
Sign::Negative if cos < -0.5 => 3,
|
||
Sign::Negative => 2,
|
||
});
|
||
}
|
||
Ok(need)
|
||
}
|
||
|
||
/// The smallest corner scaled Jacobian of a planar quadrilateral (corner `i`:
|
||
/// `cross(p[i+1] − p[i], p[i−1] − p[i]) / (|…| |…|)`), in `[-1, 1]`.
|
||
/// A degenerate corner (zero-length edge) scores −1, the worst, so optimizers move away from it.
|
||
pub(crate) fn min_scaled_jacobian(p: [[f64; 2]; 4]) -> f64 {
|
||
(0..4).fold(f64::INFINITY, |m, i| m.min(planar_corner(&p, i).1.unwrap_or(-1.0)))
|
||
}
|
||
|
||
/// The smallest corner scaled Jacobian of a recombined quadrilateral (sin 15°): a flatter corner
|
||
/// would survive the split as a nearly straight angle, which rounding of the mid nodes can turn
|
||
/// degenerate.
|
||
const MIN_RECOMBINED_QUALITY: f64 = 0.258_819_045_102_520_74;
|
||
|
||
/// Greedy recombination of adjacent triangle pairs into strictly convex quadrilaterals, best
|
||
/// first by the smallest corner scaled Jacobian (Lee and Lo 1994; Knupp 2001). Returns the
|
||
/// quadrilaterals (coarse ids, counterclockwise) and the triangles left over.
|
||
///
|
||
/// A merge removes one element around the two shared vertices; it is skipped when a boundary
|
||
/// vertex would drop below the elements it needs ([`fan_needs`]).
|
||
fn recombine(tri: &Triangulation, need: &[usize]) -> Result<(Vec<[u32; 4]>, Vec<[u32; 3]>)> {
|
||
let mut count = vec![0usize; tri.pts.len()];
|
||
for t in &tri.tris {
|
||
for &v in t {
|
||
if let Some(c) = count.get_mut(v as usize) {
|
||
*c += 1;
|
||
}
|
||
}
|
||
}
|
||
let mut edges: Vec<(u32, u32, u32, usize)> = Vec::with_capacity(3 * tri.tris.len());
|
||
for (t, v) in tri.tris.iter().enumerate() {
|
||
for i in 0..3 {
|
||
let (a, b) = (v[(i + 1) % 3], v[(i + 2) % 3]);
|
||
edges.push((a.min(b), a.max(b), t as u32, i));
|
||
}
|
||
}
|
||
edges.sort_unstable();
|
||
let pt = |v: u32| tri.p2.get(v as usize).ok_or_else(|| Error::Failed("point index".into()));
|
||
let xy = |v: u32| tri.pts.get(v as usize).copied().ok_or_else(|| Error::Failed("point index".into()));
|
||
let mut candidates: Vec<(f64, u32, u32, [u32; 4], (u32, u32))> = Vec::new();
|
||
for w in edges.windows(2) {
|
||
let [(a0, b0, t1, i), (a1, b1, t2, j)] = w else { continue };
|
||
if (a0, b0) != (a1, b1) {
|
||
continue;
|
||
}
|
||
let (v1, v2) = match (tri.tris.get(*t1 as usize), tri.tris.get(*t2 as usize)) {
|
||
(Some(x), Some(y)) => (x, y),
|
||
_ => continue,
|
||
};
|
||
let (p, q, r, s) = (v1[*i], v1[(i + 1) % 3], v1[(i + 2) % 3], v2[*j]);
|
||
let quad = [p, q, s, r];
|
||
if !turns_left([pt(p)?, pt(q)?, pt(s)?, pt(r)?]) {
|
||
continue;
|
||
}
|
||
let quality = min_scaled_jacobian([xy(p)?, xy(q)?, xy(s)?, xy(r)?]);
|
||
if quality < MIN_RECOMBINED_QUALITY {
|
||
continue;
|
||
}
|
||
candidates.push((quality, *t1, *t2, quad, (q, r)));
|
||
}
|
||
candidates.sort_by(|x, y| y.0.total_cmp(&x.0).then((x.1, x.2).cmp(&(y.1, y.2))));
|
||
let mut used = vec![false; tri.tris.len()];
|
||
let mut quads = Vec::new();
|
||
for (_, t1, t2, quad, (q, r)) in candidates {
|
||
let (Some(&u1), Some(&u2)) = (used.get(t1 as usize), used.get(t2 as usize)) else { continue };
|
||
if u1 || u2 {
|
||
continue;
|
||
}
|
||
let keeps = |v: u32| count.get(v as usize).copied().unwrap_or(0) > need.get(v as usize).copied().unwrap_or(0);
|
||
if !keeps(q) || !keeps(r) {
|
||
continue;
|
||
}
|
||
for v in [q, r] {
|
||
if let Some(c) = count.get_mut(v as usize) {
|
||
*c -= 1;
|
||
}
|
||
}
|
||
for t in [t1, t2] {
|
||
if let Some(u) = used.get_mut(t as usize) {
|
||
*u = true;
|
||
}
|
||
}
|
||
quads.push(quad);
|
||
}
|
||
let leftovers = tri.tris.iter().zip(&used).filter(|(_, u)| !**u).map(|(t, _)| *t).collect();
|
||
Ok((quads, leftovers))
|
||
}
|
||
|
||
/// The uniform split: every coarse edge gets a mid node (on the boundary, the prescribed point
|
||
/// it skipped), every element a centre node; a quadrilateral becomes four quadrilaterals, a
|
||
/// triangle three. Node order: the input boundary points (input order), the interior coarse
|
||
/// vertices, the interior edge mid nodes, the centres.
|
||
fn split(
|
||
region: &Region,
|
||
tri: &Triangulation,
|
||
to_input: &[u32],
|
||
boundary_mid: &BTreeMap<(u32, u32), u32>,
|
||
quads: &[[u32; 4]],
|
||
leftovers: &[[u32; 3]],
|
||
) -> Result<(Vec<[f64; 2]>, Vec<[u32; 4]>)> {
|
||
let mut nodes = region.pts.clone();
|
||
let mut final_id = vec![u32::MAX; tri.pts.len()];
|
||
for (c, &input) in to_input.iter().enumerate() {
|
||
if let Some(f) = final_id.get_mut(c) {
|
||
*f = input;
|
||
}
|
||
}
|
||
let xy = |v: u32| tri.pts.get(v as usize).copied().ok_or_else(|| Error::Failed("point index".into()));
|
||
let elements: Vec<&[u32]> = quads.iter().map(|q| q.as_slice()).chain(leftovers.iter().map(|t| t.as_slice())).collect();
|
||
for e in &elements {
|
||
for &v in e.iter() {
|
||
let f = final_id.get_mut(v as usize).ok_or_else(|| Error::Failed("vertex id".into()))?;
|
||
if *f == u32::MAX {
|
||
*f = nodes.len() as u32;
|
||
nodes.push(xy(v)?);
|
||
}
|
||
}
|
||
}
|
||
let fid = |v: u32| final_id.get(v as usize).copied().filter(|&f| f != u32::MAX).ok_or_else(|| Error::Failed("vertex id".into()));
|
||
let mut mids: BTreeMap<(u32, u32), u32> = BTreeMap::new();
|
||
for e in &elements {
|
||
for (i, &a) in e.iter().enumerate() {
|
||
let b = e.get((i + 1) % e.len()).copied().ok_or_else(|| Error::Failed("element".into()))?;
|
||
let key = (a.min(b), a.max(b));
|
||
if let std::collections::btree_map::Entry::Vacant(slot) = mids.entry(key) {
|
||
let id = match boundary_mid.get(&key) {
|
||
Some(&m) => m,
|
||
None => {
|
||
let (pa, pb) = (xy(a)?, xy(b)?);
|
||
nodes.push([0.5 * (pa[0] + pb[0]), 0.5 * (pa[1] + pb[1])]);
|
||
nodes.len() as u32 - 1
|
||
}
|
||
};
|
||
slot.insert(id);
|
||
}
|
||
}
|
||
}
|
||
let mid = |a: u32, b: u32| mids.get(&(a.min(b), a.max(b))).copied().ok_or_else(|| Error::Failed("mid node".into()));
|
||
let mut out = Vec::with_capacity(4 * quads.len() + 3 * leftovers.len());
|
||
for e in &elements {
|
||
let k = e.len();
|
||
let mut c = [0.0, 0.0];
|
||
for &v in e.iter() {
|
||
let p = xy(v)?;
|
||
c = [c[0] + p[0], c[1] + p[1]];
|
||
}
|
||
nodes.push([c[0] / k as f64, c[1] / k as f64]);
|
||
let centre = nodes.len() as u32 - 1;
|
||
for (i, &v) in e.iter().enumerate() {
|
||
let next = e.get((i + 1) % k).copied().ok_or_else(|| Error::Failed("element".into()))?;
|
||
let prev = e.get((i + k - 1) % k).copied().ok_or_else(|| Error::Failed("element".into()))?;
|
||
out.push([fid(v)?, mid(v, next)?, centre, mid(prev, v)?]);
|
||
}
|
||
}
|
||
Ok((nodes, out))
|
||
}
|