Tessellation: sag refinement splits a flat triangle's long side and never across an obtuse one's short side

Refinement split the side of a triangle that sagged most. Where a long
inner side lies on the surface at its middle (a section with an inflection,
a loft tube of the bike frame's front triangle) and the triangle's short
sides sag, only the short sides were split: each split left a child with the
long side and its vertex nearer to it, and the triangle beside a split side
lost its shape the same way. The triangles flattened onto the long side
without end, refinement ran to its point budget, and the mesh of the face
counted 3.4 times its area (1.45 times on a second tube).

A triangle whose widest angle is past 150 degrees now splits its longest
side, and a split never goes across an obtuse triangle's shorter side: that
triangle's longest side is split first, along the path (Rivara's
longest-edge propagation, its lengths growing, so it ends). Only sides inside
the face are split, as before.

Test: tess sag_refinement_across_an_inflection_ends_without_slivers, a sheet
with an S section meshed with a triangle flat on the chord across its
middle; it ran to its budget of 20000 points before and adds 28 now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jyviko
2026-10-29 04:17:16 -04:00
committed by Irakli Kourtis
parent 47b1d716d0
commit 666ccd85d5
2 changed files with 89 additions and 0 deletions
+33
View File
@@ -82,6 +82,25 @@ pub(crate) fn refine(s: &EvalSurface, tol: f64, reach: f64, fixed: &[Vec3], uv:
Some((sag, _, e)) if sag > 0.25 * tol && off(centroid, (pa + pb + pc) * (1.0 / 3.0)) > tol => e,
_ => continue,
};
// Splitting a triangle's shorter side leaves a child with its long side and a wider
// angle, and where the long side's middle lies on the surface (a section with an
// inflection) the children flatten toward it without end. So a triangle flattened onto its
// longest side (its widest angle past 150 degrees) splits that side, and a split never
// goes across an obtuse triangle's shorter side: that triangle's longest side is split
// first, and so on along the path (Rivara's longest-edge propagation, its lengths growing,
// so it ends). Only sides inside the face are split.
let mut split = flat_long_side(&t, &pos, &sides, shortest, 150.0).unwrap_or(split);
let mut from = ti;
while let Some(&across) = sides.get(&split).and_then(|ts| ts.iter().find(|&&o| o != from)) {
match tris.get(across as usize).and_then(|o| flat_long_side(o, &pos, &sides, shortest, 90.0)) {
Some(long) if long != split => (split, from) = (long, across),
_ => break,
}
}
// The triangle is looked at again when the split was another's, on its way.
if from != ti {
queue.push(ti);
}
let (Some(ua), Some(ub)) = (q(split.0), q(split.1)) else { continue };
let m = mean(&[ua, ub]);
let mi = uv.len() as u32;
@@ -105,6 +124,20 @@ fn mean(s: &EvalSurface, dom: &super::uv::Domain, reach: f64, qs: &[[f64; 2]]) -
out
}
/// The longest side of triangle `t` when the angle opposite it is past `degrees` and the side lies
/// inside the face (two triangles on it) and is long enough to split.
fn flat_long_side(t: &[u32; 3], pos: &[Vec3], sides: &HashMap<(u32, u32), Vec<u32>>, shortest: f64, degrees: f64) -> Option<(u32, u32)> {
let p = |i: u32| pos.get(i as usize).copied();
let len = |k: usize| p(t[k]).zip(p(t[(k + 1) % 3])).map(|(a, b)| a.dist(b));
let lengths = [len(0)?, len(1)?, len(2)?];
let k = (0..3).max_by(|&i, &j| lengths[i].total_cmp(&lengths[j]))?;
let (l, a, b) = (lengths[k], lengths[(k + 1) % 3], lengths[(k + 2) % 3]);
let side = key(t[k], t[(k + 1) % 3]);
let inside = sides.get(&side).is_some_and(|ts| ts.len() == 2) && l > shortest;
let flat = a > 0.0 && b > 0.0 && (a * a + b * b - l * l) / (2.0 * a * b) < degrees.to_radians().cos();
(inside && flat).then_some(side)
}
fn key(a: u32, b: u32) -> (u32, u32) {
(a.min(b), a.max(b))
}
+56
View File
@@ -908,3 +908,59 @@ fn a_small_sphere_void_in_a_large_block_meshes() {
assert_watertight(&m, "block with a sphere void");
assert_eq!(hollow.unmeshed_faces(), 0);
}
/// A sheet whose section is an S (a cubic 30 long, 1.5 off its chord each way, its middle on the
/// chord), straight across, meshed as fans with one triangle flat on the chord across its middle
/// (A, B at the sides, C a hair above the chord a third along). The chord's middle lies on the
/// surface while the triangle's short sides sag: splitting those left a child with the chord and
/// its vertex nearer to it, without end (a loft side of a bike frame refined to slivers until the
/// point budget ran out, its area counted three times). The flat triangle now splits the chord,
/// and refinement ends within its budget with no sliver.
#[test]
fn sag_refinement_across_an_inflection_ends_without_slivers() {
let mut ctrl = Vec::new();
for j in 0..2 {
for (i, z) in [0.0, 4.0, -4.0, 0.0].into_iter().enumerate() {
ctrl.push([10.0 * i as f64, 10.0 * f64::from(j), z, 1.0]);
}
}
let s = EvalSurface::Nurbs {
degree: [3, 1],
knots: [vec![0., 0., 0., 0., 1., 1., 1., 1.], vec![0., 0., 1., 1.]],
count: [4, 2],
ctrl,
closed: [false, false],
};
// Boundary: bottom and top rows of n + 1 nodes (their sides within the tolerance, 0.5), A and
// B halfway up the ends. Inside: C. Bottom fan from A, the triangle A B C, top fan from C.
let n = 6u32;
let mut uv: Vec<[f64; 2]> = Vec::new();
for i in 0..=n {
uv.push([f64::from(i) / f64::from(n), 0.0]);
}
for i in 0..=n {
uv.push([f64::from(i) / f64::from(n), 1.0]);
}
let (bottom, top) = (0u32, n + 1);
let (a, b, c) = (2 * n + 2, 2 * n + 3, 2 * n + 4);
uv.extend([[0.0, 0.5], [1.0, 0.5], [1.0 / 3.0, 0.5 + 1e-3]]);
let mut tris: Vec<[u32; 3]> = vec![[a, bottom, bottom + 1]];
tris.extend((1..n).map(|i| [a, bottom + i, bottom + i + 1]));
tris.push([a, bottom + n, b]);
tris.push([a, b, c]);
tris.push([c, b, top + n]);
tris.extend((0..n).rev().map(|i| [c, top + i + 1, top + i]));
tris.push([c, top, a]);
// The sides along the ends are straight.
let fixed: Vec<Vec3> = Vec::new();
let tol = 0.5;
let start = uv.len();
super::sag::refine(&s, tol, 1e-9, &fixed, &mut uv, &mut tris, 20_000);
assert!(uv.len() - start < 1_000, "{} points added: refinement ran to its budget", uv.len() - start);
for t in &tris {
let [p, q, r] = t.map(|i| uv[i as usize]);
let area = (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]);
let longest = [(p, q), (q, r), (r, p)].iter().map(|(x, y)| (x[0] - y[0]).hypot(x[1] - y[1])).fold(0.0, f64::max);
assert!(area > 1e-6 * longest * longest, "a sliver {t:?}: {p:?} {q:?} {r:?}");
}
}