use glam::DVec3;
pub(super) fn triangulate_face(points: &[DVec3], corners: &[u32]) -> Option<Vec<[u32; 3]>> {
let fan = || {
(1..corners.len() - 1)
.map(|k| [corners[0], corners[k], corners[k + 1]])
.collect()
};
if corners.len() == 3 {
return Some(fan());
}
let ring: Vec<DVec3> = corners.iter().map(|&c| points[c as usize]).collect();
let normal = newell_normal(&ring);
let size = ring
.iter()
.fold(0.0_f64, |m, p| m.max(p.abs().max_element()));
if !(normal.length() > 0.0 && normal.is_finite()) {
return Some(fan());
}
let normal = normal.normalize();
let tolerance = 1e-12 * size * size;
let turns = turns(&ring, normal);
if !turns.iter().any(|&t| t < -tolerance) {
return Some(fan());
}
ear_clip(&ring, normal, tolerance).map(|local| {
local
.into_iter()
.map(|tri| tri.map(|k| corners[k]))
.collect()
})
}
fn newell_normal(ring: &[DVec3]) -> DVec3 {
let mut sum = DVec3::ZERO;
for (i, &a) in ring.iter().enumerate() {
sum += a.cross(ring[(i + 1) % ring.len()]);
}
sum
}
fn turns(ring: &[DVec3], normal: DVec3) -> Vec<f64> {
let n = ring.len();
(0..n)
.map(|i| {
let (a, b, c) = (ring[(i + n - 1) % n], ring[i], ring[(i + 1) % n]);
normal.dot((b - a).cross(c - b))
})
.collect()
}
#[expect(
clippy::many_single_char_names,
reason = "a, b, c, n and p are the textbook names of the ear-clipping vertices"
)]
fn ear_clip(ring: &[DVec3], normal: DVec3, tolerance: f64) -> Option<Vec<[usize; 3]>> {
let n = ring.len();
let helper = if normal.x.abs() < 0.9 {
DVec3::X
} else {
DVec3::Y
};
let u = normal.cross(helper).normalize();
let v = normal.cross(u);
let flat: Vec<(f64, f64)> = ring.iter().map(|&p| (p.dot(u), p.dot(v))).collect();
let cross = |a: usize, b: usize, c: usize| {
(flat[b].0 - flat[a].0).mul_add(
flat[c].1 - flat[a].1,
-((flat[b].1 - flat[a].1) * (flat[c].0 - flat[a].0)),
)
};
let mut prev: Vec<usize> = (0..n).map(|i| (i + n - 1) % n).collect();
let mut next: Vec<usize> = (0..n).map(|i| (i + 1) % n).collect();
let mut live = n;
let mut at = 0;
let mut out = Vec::with_capacity(n - 2);
while live > 3 {
let mut found = None;
let mut i = at;
for _ in 0..live {
let (a, c) = (prev[i], next[i]);
if cross(a, i, c) > tolerance {
let blocked = {
let mut k = next[c];
let mut hit = false;
while k != a {
let same = |q: usize| (ring[k] - ring[q]).length_squared() <= 1e-24;
if !(same(a) || same(i) || same(c))
&& cross(a, i, k) >= -tolerance
&& cross(i, c, k) >= -tolerance
&& cross(c, a, k) >= -tolerance
{
hit = true;
break;
}
k = next[k];
}
hit
};
if !blocked {
found = Some(i);
break;
}
}
i = next[i];
}
let i = found?;
let (a, c) = (prev[i], next[i]);
out.push([a, i, c]);
next[a] = c;
prev[c] = a;
live -= 1;
at = a;
}
let a = at;
let (b, c) = (next[a], next[next[a]]);
if cross(a, b, c) > tolerance {
out.push([a, b, c]);
Some(out)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
fn area(points: &[DVec3], tris: &[[u32; 3]]) -> f64 {
tris.iter()
.map(|t| {
let [a, b, c] = t.map(|k| points[k as usize]);
(b - a).cross(c - a).z * 0.5
})
.sum()
}
fn pts(xy: &[(f64, f64)]) -> Vec<DVec3> {
xy.iter().map(|&(x, y)| DVec3::new(x, y, 0.0)).collect()
}
#[test]
fn a_convex_quad_is_fanned_unchanged() {
let p = pts(&[(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0)]);
let tris = triangulate_face(&p, &[0, 1, 2, 3]).expect("fans");
assert_eq!(tris, vec![[0, 1, 2], [0, 2, 3]]);
let q = pts(&[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.5, 1.5), (0.0, 1.0)]);
let tris = triangulate_face(&q, &[0, 1, 2, 3, 4]).expect("fans");
assert_eq!(tris, vec![[0, 1, 2], [0, 2, 3], [0, 3, 4]]);
}
#[test]
fn a_concave_quad_is_split_along_its_inner_diagonal() {
let p = pts(&[(0.0, 0.0), (4.0, 0.0), (0.0, 4.0), (1.0, 1.0)]);
assert!(
area(&p, &[[0, 2, 3]]) < 0.0,
"the fan's second triangle flips"
);
let tris = triangulate_face(&p, &[0, 1, 2, 3]).expect("ear-clips");
assert_eq!(tris.len(), 2);
assert!((area(&p, &tris) - 6.0).abs() < 1e-12, "{tris:?}");
assert!(tris.iter().all(|t| area(&p, &[*t]) > 0.0), "{tris:?}");
}
#[test]
fn a_concave_pentagon_covers_exactly_its_area() {
let p = pts(&[(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (2.0, 1.0), (0.0, 4.0)]);
let corners = [0, 1, 2, 3, 4];
let tris = triangulate_face(&p, &corners).expect("ear-clips");
assert_eq!(tris.len(), 3);
let shoelace: f64 = (0..5)
.map(|i| {
let (a, b) = (p[i], p[(i + 1) % 5]);
a.x.mul_add(b.y, -(b.x * a.y))
})
.sum::<f64>()
* 0.5;
assert!((area(&p, &tris) - shoelace).abs() < 1e-12, "{tris:?}");
assert!(tris.iter().all(|t| area(&p, &[*t]) > 0.0));
let fan_all_positive = (1..4).all(|k| area(&p, &[[0, k, k + 1]]) > 0.0);
assert!(!fan_all_positive, "the fixture must defeat the fan");
}
#[test]
fn a_self_intersecting_bowtie_is_refused() {
let p = pts(&[(0.0, 0.0), (3.0, 3.0), (3.0, 0.0), (0.0, 1.0)]);
assert_eq!(triangulate_face(&p, &[0, 1, 2, 3]), None);
}
}