Tessellation: a slit (a side the loops run along both ways) bounds the face on neither side

Where a face meets itself along a line (a hole's wall tangent inside the wall round it: the
boss side touched by the hole along a vertical line, run up and back down), its loop runs along
that line both ways. The triangulation's region test flipped inside and outside at every
constraint side, so crossing the slit put the face's far side outside it; the follow check
wanted every boundary side under exactly one triangle, where a slit has one each side. Sides the
loops run along an even number of times no longer flip the region, and the follow check wants a
triangle per run. (The Pinch case's boss side still falls back to the lenient fill, now within
0.52 mm^3 of the exact 8717.92 where it was 340 short; the strict fill's remaining mismatch on
the seam-cut face is open.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jyviko
2026-10-29 02:22:56 -04:00
committed by Irakli Kourtis
parent fcd5f79975
commit 43ad9d0b0c
2 changed files with 40 additions and 9 deletions
+26 -3
View File
@@ -79,6 +79,18 @@ pub(crate) fn triangulate(boundary: &[[f64; 2]], loops: &[std::ops::Range<usize>
index.entry(h).or_insert(u32::try_from(i).ok()?);
handles.push(h);
}
// How often the loops run along each side: a side run both ways (a slit, where a face's
// boundary goes out along a line its two sides share and back) bounds the face on neither.
let mut runs: HashMap<(FixedVertexHandle, FixedVertexHandle), usize> = HashMap::new();
for r in loops {
let n = r.len();
for k in 0..n {
let (a, b) = (*handles.get(r.start + k)?, *handles.get(r.start + (k + 1) % n)?);
if a != b {
*runs.entry(if a.index() < b.index() { (a, b) } else { (b, a) }).or_insert(0) += 1;
}
}
}
for r in loops {
let n = r.len();
for k in 0..n {
@@ -125,7 +137,9 @@ pub(crate) fn triangulate(boundary: &[[f64; 2]], loops: &[std::ops::Range<usize>
}
let mut uv: Vec<[f64; 2]> = boundary.to_vec();
let mut tris = Vec::new();
let region = region(&cdt);
let slits: std::collections::HashSet<(FixedVertexHandle, FixedVertexHandle)> =
runs.into_iter().filter(|(_, n)| n % 2 == 0).map(|(e, _)| e).collect();
let region = region_without(&cdt, &slits);
for face in cdt.inner_faces() {
if !region.get(face.index()).copied().unwrap_or(false) {
continue;
@@ -308,14 +322,23 @@ fn refine_sag(cdt: &mut Cdt, sag: &Sag, budget: usize, from: &dyn Fn(Point2<f64>
/// outer face that flips parity each time it crosses a constraint edge (holes are out), linear
/// in the number of triangles.
fn region(cdt: &Cdt) -> Vec<bool> {
region_without(cdt, &std::collections::HashSet::new())
}
/// [`region`], the constraint sides in `slits` (run along both ways) crossed without counting.
fn region_without(cdt: &Cdt, slits: &std::collections::HashSet<(FixedVertexHandle, FixedVertexHandle)>) -> Vec<bool> {
use spade::handles::{FixedFaceHandle, InnerTag};
let counts = |e: spade::handles::DirectedEdgeHandle<'_, _, _, _, _>| {
let [a, b] = e.vertices().map(|v| v.fix());
e.is_constraint_edge() && !slits.contains(&if a.index() < b.index() { (a, b) } else { (b, a) })
};
let mut parity: Vec<Option<bool>> = vec![None; cdt.num_all_faces()];
let mut todo: Vec<(FixedFaceHandle<InnerTag>, bool)> = Vec::new();
for e in cdt.convex_hull() {
// Hull edges have the outer face on one side; the other side is an inner face.
for side in [e, e.rev()] {
if let Some(inner) = side.face().as_inner() {
todo.push((inner.fix(), e.is_constraint_edge()));
todo.push((inner.fix(), counts(e)));
}
}
}
@@ -326,7 +349,7 @@ fn region(cdt: &Cdt) -> Vec<bool> {
}
for e in cdt.face(h).adjacent_edges() {
if let Some(next) = e.rev().face().as_inner() {
todo.push((next.fix(), odd ^ e.is_constraint_edge()));
todo.push((next.fix(), odd ^ counts(e)));
}
}
}
+14 -6
View File
@@ -1133,7 +1133,8 @@ fn graded(a: [f64; 2], b: [f64; 2], sa: f64, sb: f64, h: f64, speed: [f64; 2], f
all.into_iter().filter(|t| *t > 0.0 && *t < 1.0).collect()
}
/// Every boundary segment (consecutive nodes of a loop) is the side of exactly one triangle:
/// Every boundary segment (consecutive nodes of a loop) is the side of exactly one triangle (two
/// for a slit the loops run along both ways):
/// the face covers its boundary with nothing missing or doubled, so it meets its neighbours
/// exactly. Some boundary nodes are one vertex, a side ending on any of them the same side: pole
/// copies of one node (consecutive in a loop, at one point, the segment between them of no
@@ -1186,12 +1187,19 @@ fn closed_boundary(ranges: &[std::ops::Range<usize>], fixed: &[Vec3], uvs: &[[f6
}
}
}
ranges.iter().all(|r| {
(0..r.len()).all(|k| {
// Each boundary side has a triangle for every time the loops run along it: one, or two for
// a slit (a side run out and back, where the face meets itself along a line: a hole's wall
// touching the wall round it), one triangle either side.
let mut runs: HashMap<(u32, u32), u32> = HashMap::new();
for r in ranges {
for k in 0..r.len() {
let (a, b) = segment(r, k);
side(a, b).is_none_or(|k| sides.get(&k).copied() == Some(1))
})
})
if let Some(k) = side(a, b) {
*runs.entry(k).or_default() += 1;
}
}
}
runs.iter().all(|(k, n)| sides.get(k).copied() == Some(*n))
}
#[cfg(test)]