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

+55 -3
View File
@@ -27,8 +27,9 @@ use modelcraft_model::{ElementKind, check_coordinates};
pub use jacobian::{JacobianCheck, Validity};
pub use report::{GateOptions, GateReport, MetricStats, QualityReport, enclosed_volume, quality_report, validity_gate, validity_gate_elements};
pub(crate) use shape::{planar_corner, quad_axes};
use crate::predicates::{Point3, Sign, tet_volume_sign};
use crate::predicates::{Point2, Point3, Sign, orient2d, snap_to_domain, tet_volume_sign};
use crate::{Error, Result};
/// A quality metric. Angles are in degrees.
@@ -60,6 +61,9 @@ pub enum Metric {
MinAngle,
/// Largest corner angle of a triangle or quadrilateral.
MaxAngle,
/// Skew of a quadrilateral: `|cos θ|` of the angle between its principal axes, in `[0, 1]`;
/// 0 for a rectangle, 1 when degenerate.
Skew,
}
impl Metric {
@@ -76,19 +80,20 @@ impl Metric {
Metric::EdgeRatio => "edge_ratio",
Metric::MinAngle => "min_angle",
Metric::MaxAngle => "max_angle",
Metric::Skew => "skew",
}
}
/// Whether a larger value is a better element.
pub fn higher_is_better(self) -> bool {
!matches!(self, Metric::MaxDihedral | Metric::AspectRatio | Metric::EdgeRatio | Metric::MaxAngle)
!matches!(self, Metric::MaxDihedral | Metric::AspectRatio | Metric::EdgeRatio | Metric::MaxAngle | Metric::Skew)
}
/// The fixed histogram range; values outside it fall in the first or last bin.
pub fn range(self) -> (f64, f64) {
match self {
Metric::ScaledJacobian | Metric::JacobianRatio | Metric::RadiusRatio | Metric::VolumeLength => (-1.0, 1.0),
Metric::Shape => (0.0, 1.0),
Metric::Shape | Metric::Skew => (0.0, 1.0),
Metric::MinDihedral | Metric::MaxDihedral | Metric::MinAngle | Metric::MaxAngle => (0.0, 180.0),
Metric::AspectRatio | Metric::EdgeRatio => (1.0, 11.0),
}
@@ -218,5 +223,52 @@ pub fn element_quality(kind: ElementKind, xyz: &[[f64; 3]]) -> Result<ElementQua
Ok(ElementQuality { kind, size: check.volume, jacobian: Some(check), metrics })
}
/// The exact validity of a planar bilinear quadrilateral with corners `p` in counterclockwise
/// order. Its Jacobian determinant is affine in each reference coordinate (the `ξη` term of a
/// planar bilinear map cancels), so it is positive on the whole element exactly when it is
/// positive at the four corners, i.e. when every corner turns left: `orient2d(p[i-1], p[i],
/// p[i+1]) > 0`, decided by the exact predicate. A zero corner (degenerate) or a negative one
/// (concave, bow-tie or clockwise) is [`Validity::Invalid`]. Coordinates below the predicates'
/// smallest magnitude are rounded to zero first.
pub fn planar_quad_validity(p: &[[f64; 2]; 4]) -> Result<Validity> {
check_coordinates(p.as_flattened())?;
let [a, b, c, d] = p.map(|c| Point2::new(snap_to_domain(c[0]), snap_to_domain(c[1])));
Ok(if turns_left([&a?, &b?, &c?, &d?]) { Validity::Valid } else { Validity::Invalid })
}
/// The exact corner test of [`planar_quad_validity`] on admissible points: every corner of the
/// quadrilateral turns left.
pub(crate) fn turns_left(p: [&Point2; 4]) -> bool {
(0..4).all(|i| orient2d(p[(i + 3) % 4], p[i], p[(i + 1) % 4]) == Sign::Positive)
}
/// Every metric of a planar quadrilateral (counterclockwise corners in the xy plane) with its
/// exact validity ([`planar_quad_validity`]): the Jacobian ratio is the smallest corner
/// determinant over the largest magnitude (exact bounds, see there) and the size is the signed
/// area. The shape metrics are those of [`element_quality`] for a `Quad4`.
pub fn planar_quad_quality(p: &[[f64; 2]; 4]) -> Result<ElementQuality> {
let validity = planar_quad_validity(p)?;
let o = p[0];
let l = p.map(|c| [c[0] - o[0], c[1] - o[1]]);
let corners = [0, 1, 2, 3].map(|i| shape::planar_corner(&l, i));
let dets = corners.map(|c| c.0);
let lo = dets.iter().fold(f64::INFINITY, |m, &d| m.min(d));
let hi = dets.iter().fold(0.0_f64, |m, &d| m.max(d.abs()));
let ratio = if hi > 0.0 { (lo / hi).clamp(-1.0, 1.0) } else { 0.0 };
let area = 0.5 * ((l[2][0] - l[0][0]) * (l[3][1] - l[1][1]) - (l[2][1] - l[0][1]) * (l[3][0] - l[1][0]));
let lifted = l.map(|c| [c[0], c[1], 0.0]);
let (_, mut metrics) = shape::quad(lifted);
// Corner scaled Jacobians measured against +z (the 3D form measures against the diagonals'
// normal, which flips with a clockwise quad).
let scaled = corners.iter().fold(f64::INFINITY, |m, c| m.min(c.1.unwrap_or(0.0)));
for (m, v) in metrics.iter_mut() {
if *m == Metric::ScaledJacobian {
*v = scaled;
}
}
metrics.push((Metric::JacobianRatio, ratio));
Ok(ElementQuality { kind: ElementKind::Quad4, size: area, jacobian: Some(JacobianCheck { validity, ratio, volume: area }), metrics })
}
#[cfg(test)]
mod tests;
+31
View File
@@ -6,6 +6,10 @@
//! - Hexahedra, wedges and quadrilaterals: Knupp's algebraic metrics built on the corner Jacobian
//! matrices (Knupp, *Algebraic mesh quality metrics*, SIAM J. Sci. Comput. 23(1), 2001): the
//! scaled Jacobian `det(A) / (|a1| |a2| |a3|)` and the shape metric `3 det(A)^{2/3} / |A|²_F`.
//! - Quadrilateral aspect ratio and skew from the principal axes `X1 = (p1 − p0) + (p2 − p3)`,
//! `X2 = (p2 − p1) + (p3 − p0)` (Robinson, *CRE method of element testing and the Jacobian
//! shape parameters*, Eng. Comput. 4(2):113–118, 1987): aspect `max(|X1|, |X2|) / min(…)`, skew
//! `|X1 · X2| / (|X1| |X2|)`.
//!
//! Every metric is defined for degenerate input (no division by zero, no NaN): a degenerate
//! element scores 0 on bounded metrics and `+∞` on aspect and edge ratios.
@@ -132,6 +136,7 @@ pub(crate) fn quad(p: [P; 4]) -> (f64, Vec<(Metric, f64)>) {
angles = (angles.0.min(a), angles.1.max(a));
}
let edges = [(0, 1), (1, 2), (2, 3), (3, 0)];
let (aspect, skew) = quad_axes(p);
(
area,
vec![
@@ -139,10 +144,36 @@ pub(crate) fn quad(p: [P; 4]) -> (f64, Vec<(Metric, f64)>) {
(Metric::MinAngle, angles.0),
(Metric::MaxAngle, angles.1),
(Metric::EdgeRatio, edge_ratio(&p, &edges)),
(Metric::AspectRatio, aspect),
(Metric::Skew, skew),
],
)
}
/// Corner `i` of a planar quadrilateral: the cross product `(p[i+1] − p[i]) × (p[i−1] − p[i])`
/// (twice the corner triangle's signed area, positive for a left turn of a counterclockwise quad)
/// and that cross over the two edge lengths (the corner's scaled Jacobian, `None` when an edge
/// has zero length).
pub(crate) fn planar_corner(p: &[[f64; 2]; 4], i: usize) -> (f64, Option<f64>) {
let (a, b, c) = (p[(i + 3) % 4], p[i % 4], p[(i + 1) % 4]);
let (u, w) = ([c[0] - b[0], c[1] - b[1]], [a[0] - b[0], a[1] - b[1]]);
let cross = u[0] * w[1] - u[1] * w[0];
let den = (u[0] * u[0] + u[1] * u[1]).sqrt() * (w[0] * w[0] + w[1] * w[1]).sqrt();
let scaled = cross / den;
(cross, (den > 0.0 && scaled.is_finite()).then_some(scaled))
}
/// Robinson's aspect ratio and skew of a quadrilateral from its principal axes; `(+∞, 1)` when
/// an axis vanishes.
pub(crate) fn quad_axes(p: [P; 4]) -> (f64, f64) {
let x1 = [p[1][0] - p[0][0] + p[2][0] - p[3][0], p[1][1] - p[0][1] + p[2][1] - p[3][1], p[1][2] - p[0][2] + p[2][2] - p[3][2]];
let x2 = [p[2][0] - p[1][0] + p[3][0] - p[0][0], p[2][1] - p[1][1] + p[3][1] - p[0][1], p[2][2] - p[1][2] + p[3][2] - p[0][2]];
let (l1, l2) = (norm(x1), norm(x2));
let aspect = ratio(l1.max(l2), l1.min(l2), f64::INFINITY).max(1.0);
let skew = ratio(dot(x1, x2).abs(), l1 * l2, 1.0).min(1.0);
(aspect, skew)
}
/// Wedge scaled Jacobian: the smallest determinant of the three unit edge vectors at a corner,
/// taken in right-handed order (1 for a right prism on a right-angled corner, sin 60° ≈ 0.866 on
/// an equilateral triangle).