Files
OneCAD-Simulation/crates/mesh/src/quad/free.rs
T
6c36f5b2ad 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>
2026-10-13 16:15:54 -04:00

362 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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(&region)? {
match attempt(&region, 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 &region.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))
}