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:
authored and GitHub committed 2026-10-13 16:15:54 -04:00
1 parent 25defdc0ec
commit 6c36f5b2ad
16 files changed
+3185 -6

No files matched your search

+250
View File
@@ -0,0 +1,250 @@
//! Constrained Laplacian smoothing of a planar quadrilateral mesh.
//!
//! Each free node moves towards the average of its edge neighbours (Laplacian smoothing), and
//! the move is kept only when it does not lower the smallest scaled Jacobian of the node's
//! quadrilaterals and keeps them valid by the exact corner test ("smart" Laplacian: Freitag,
//! *On combining Laplacian and optimization-based mesh smoothing techniques*, AMD-Vol. 220,
//! ASME 1997; Canann, Tristano, Staten, *An approach to combined Laplacian and optimization-based
//! smoothing for triangular, quadrilateral, and quad-dominant meshes*, IMR 1998). Half and quarter
//! steps are tried when the full step is refused. Nodes of quadrilaterals that are already
//! invalid accept any step that raises their smallest corner area (see below).
//!
//! Laplacian smoothing cannot untangle a node whose average position is itself bad, and the
//! nodes next to a sharp prescribed boundary point often are. Every node whose quadrilaterals
//! remain inverted or poor (smallest scaled Jacobian below [`POOR`]) then gets optimization-based
//! smoothing (Freitag 1997) by a compass search (eight directions, step halved on failure,
//! bounded). An inverted patch is untangled first by maximizing its smallest corner area, which is
//! linear in the node's position, so its maximum lies in the feasible region when there is one
//! (Freitag and Plassmann, *Local optimization-based simplicial mesh untangling and
//! improvement*, IJNME 49:109–125, 2000); a valid patch then maximizes its smallest scaled
//! Jacobian without leaving the valid set.
//!
//! Gauss–Seidel order by node id; bounded sweeps; deterministic.
use crate::predicates::{Point2, snap_to_domain};
use crate::quality::{planar_corner, turns_left};
use crate::{Error, Result};
/// Compressed adjacency: `items[start[v]..start[v + 1]]`.
struct Csr {
start: Vec<usize>,
items: Vec<u32>,
}
impl Csr {
fn of(n: usize, pairs: &mut [(u32, u32)]) -> Csr {
pairs.sort_unstable();
let mut start = vec![0usize; n + 1];
let mut items = Vec::with_capacity(pairs.len());
let mut prev = None;
for &(a, b) in pairs.iter() {
if prev == Some((a, b)) {
continue;
}
prev = Some((a, b));
if let Some(s) = start.get_mut(a as usize + 1) {
*s += 1;
}
items.push(b);
}
for i in 0..n {
let before = start.get(i).copied().unwrap_or(0);
if let Some(s) = start.get_mut(i + 1) {
*s += before;
}
}
Csr { start, items }
}
fn get(&self, v: usize) -> &[u32] {
match (self.start.get(v), self.start.get(v + 1)) {
(Some(&a), Some(&b)) => self.items.get(a..b).unwrap_or(&[]),
_ => &[],
}
}
}
fn corners(nodes: &[[f64; 2]], q: &[u32; 4], v: u32, at: [f64; 2]) -> Result<[[f64; 2]; 4]> {
let mut c = [[0.0; 2]; 4];
for (k, &u) in q.iter().enumerate() {
c[k] = if u == v { at } else { nodes.get(u as usize).copied().ok_or_else(|| Error::Failed("node index".into()))? };
}
Ok(c)
}
/// The state of `v`'s quadrilaterals with `v` at some position.
#[derive(Debug, Clone, Copy)]
struct Local {
/// Smallest scaled Jacobian.
sj: f64,
/// Smallest unnormalized corner cross product among the corners that move with `v` (linear
/// in `v`'s position: the untangling objective).
area: f64,
/// All valid by the exact test.
valid: bool,
}
impl Local {
/// Valid beats invalid; then a valid patch ranks by its worst scaled Jacobian and an invalid
/// one by its worst corner area.
fn key(&self) -> (bool, f64) {
(self.valid, if self.valid { self.sj } else { self.area })
}
fn better(&self, than: &Local) -> bool {
let (a, b) = (self.key(), than.key());
(a.0 && !b.0) || (a.0 == b.0 && a.1 > b.1)
}
fn no_worse(&self, than: &Local) -> bool {
!than.better(self)
}
}
/// `v`'s quadrilaterals with `v` at `at`.
fn local(nodes: &[[f64; 2]], quads: &[[u32; 4]], incident: &[u32], v: u32, at: [f64; 2]) -> Result<Local> {
let mut l = Local { sj: f64::INFINITY, area: f64::INFINITY, valid: true };
for &e in incident {
let quad = quads.get(e as usize).ok_or_else(|| Error::Failed("quad index".into()))?;
let c = corners(nodes, quad, v, at)?;
let k = quad.iter().position(|&u| u == v).unwrap_or(0);
for i in 0..4 {
let (cross, scaled) = planar_corner(&c, i);
l.sj = l.sj.min(scaled.unwrap_or(-1.0));
// The corner opposite `v` does not move with it.
if i != (k + 2) % 4 {
l.area = l.area.min(cross);
}
}
if l.valid {
let [a, b, c, d] = c.map(|x| Point2::new(x[0], x[1]));
l.valid = turns_left([&a?, &b?, &c?, &d?]);
}
}
Ok(l)
}
/// Nodes whose quadrilaterals score below this get optimization-based smoothing.
const POOR: f64 = 0.3;
/// Compass-search iterations per node.
const SEARCH_STEPS: usize = 48;
/// Compass search for the best position of `v` ([`Local::key`]: untangle first, then maximize
/// the smallest scaled Jacobian). Returns the new position when it improves.
fn optimize(nodes: &[[f64; 2]], quads: &[[u32; 4]], incident: &[u32], neighbours: &[u32], v: u32) -> Result<Option<[f64; 2]>> {
let old = nodes.get(v as usize).copied().ok_or_else(|| Error::Failed("node index".into()))?;
let start = local(nodes, quads, incident, v, old)?;
if start.valid && start.sj >= POOR {
return Ok(None);
}
let mut reach = 0.0_f64;
for &u in neighbours {
let p = nodes.get(u as usize).copied().ok_or_else(|| Error::Failed("node index".into()))?;
reach = reach.max(((p[0] - old[0]).powi(2) + (p[1] - old[1]).powi(2)).sqrt());
}
let (mut at, mut best) = (old, start);
let mut step = 0.25 * reach;
let floor = 1e-4 * step;
const D: f64 = std::f64::consts::FRAC_1_SQRT_2;
const DIRS: [[f64; 2]; 8] = [[1.0, 0.0], [0.0, 1.0], [-1.0, 0.0], [0.0, -1.0], [D, D], [-D, D], [-D, -D], [D, -D]];
for _ in 0..SEARCH_STEPS {
if !(step > floor) {
break;
}
let mut moved = false;
for d in DIRS {
let to = [snap_to_domain(at[0] + step * d[0]), snap_to_domain(at[1] + step * d[1])];
let l = local(nodes, quads, incident, v, to)?;
if l.better(&best) {
(at, best) = (to, l);
moved = true;
break;
}
}
if !moved {
step *= 0.5;
}
}
Ok((at != old && best.better(&start)).then_some(at))
}
/// Smooth the nodes from `first_free` on for at most `sweeps` sweeps. Returns the sweeps run and
/// whether the largest move of the last sweep fell below `1e-4` of the mean edge length.
pub(crate) fn smooth(nodes: &mut [[f64; 2]], quads: &[[u32; 4]], first_free: usize, sweeps: usize) -> Result<(usize, bool)> {
let n = nodes.len();
let mut edges = Vec::with_capacity(8 * quads.len());
let mut inc = Vec::with_capacity(4 * quads.len());
let mut total = 0.0;
for (e, q) in quads.iter().enumerate() {
for k in 0..4 {
let (a, b) = (q[k], q[(k + 1) % 4]);
edges.push((a, b));
edges.push((b, a));
inc.push((a, e as u32));
if let (Some(pa), Some(pb)) = (nodes.get(a as usize), nodes.get(b as usize)) {
total += ((pa[0] - pb[0]).powi(2) + (pa[1] - pb[1]).powi(2)).sqrt();
}
}
}
if quads.is_empty() {
return Ok((0, true));
}
let tolerance = 1e-4 * total / (4 * quads.len()) as f64;
let neighbours = Csr::of(n, &mut edges);
let incident = Csr::of(n, &mut inc);
for sweep in 0..sweeps {
let mut largest = 0.0_f64;
for v in first_free..n {
let nb = neighbours.get(v);
if nb.is_empty() {
continue;
}
let old = nodes.get(v).copied().ok_or_else(|| Error::Failed("node index".into()))?;
let mut avg = [0.0, 0.0];
for &u in nb {
let p = nodes.get(u as usize).copied().ok_or_else(|| Error::Failed("node index".into()))?;
avg = [avg[0] + p[0], avg[1] + p[1]];
}
let avg = [avg[0] / nb.len() as f64, avg[1] / nb.len() as f64];
let quads_of_v = incident.get(v);
let before = local(nodes, quads, quads_of_v, v as u32, old)?;
for step in [1.0, 0.5, 0.25] {
let at = [snap_to_domain(old[0] + step * (avg[0] - old[0])), snap_to_domain(old[1] + step * (avg[1] - old[1]))];
if at == old {
break;
}
if local(nodes, quads, quads_of_v, v as u32, at)?.no_worse(&before) {
largest = largest.max(((at[0] - old[0]).powi(2) + (at[1] - old[1]).powi(2)).sqrt());
if let Some(x) = nodes.get_mut(v) {
*x = at;
}
break;
}
}
}
if largest <= tolerance {
let polished = polish(nodes, quads, first_free, &neighbours, &incident, sweeps)?;
return Ok((sweep + 1, polished));
}
}
polish(nodes, quads, first_free, &neighbours, &incident, sweeps)?;
Ok((sweeps, false))
}
/// Optimization-based smoothing of the poor and inverted nodes, at most `sweeps` sweeps.
/// Returns whether a sweep ended with nothing left to improve.
fn polish(nodes: &mut [[f64; 2]], quads: &[[u32; 4]], first_free: usize, neighbours: &Csr, incident: &Csr, sweeps: usize) -> Result<bool> {
for _ in 0..sweeps {
let mut changed = false;
for v in first_free..nodes.len() {
if let Some(at) = optimize(nodes, quads, incident.get(v), neighbours.get(v), v as u32)? {
if let Some(x) = nodes.get_mut(v) {
*x = at;
}
changed = true;
}
}
if !changed {
return Ok(true);
}
}
Ok(false)
}