forked from 1b-agent/fieldcraft-upstream
Planar all-quad meshing: size fields, TFI mapped meshes, free quad mesher (M5 base) (#18)
* 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>
This commit is contained in:
16 files changed
+3185
-6
No files matched your search
@@ -0,0 +1,361 @@
|
||||
//! 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))
|
||||
}
|
||||
Reference in new issue
Block a user