From 534dffa96d652f2e75809af79286dd5f99544341 Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:55:12 -0400 Subject: [PATCH] Tessellation: the band beside a slit and the sag refinement keep the face on both sides of it A face that meets itself along a line (the Pinch case's boss side, turned 30 degrees: the hole's wall touches it along a vertical line from the hole's bottom to the top rim) has its loop run up that line and back down. Its motorcycle trace runs from the slit's tip to the bottom and the iso-line grid meshes it, but the band between the grid cells and the boundary was filled by a parity flood that flipped at every constraint, the slit's too: the band on one side of the slit was taken for outside, the face did not follow its boundary, and the lenient fill took over. The band now crosses a side the loops run along both ways without counting it, as the triangulation itself already did. Delaunay's sag refinement had the same flood and left the far side of a slit unrefined (the lenient fill's 0.52 mm^3 shortfall); it takes the slits too. The Pinch case now meshes strictly: the boss side is 628.3177 mm^2 against 200 pi = 628.3185, and hole_tangent_inside_a_stepped_boss_cuts_exactly passes. New tess test: a square with a slit meshed by the cut grid follows the slit both ways and covers the square's area exactly. Co-Authored-By: Claude Opus 5.5 --- crates/kernel/src/tess/cdt.rs | 57 ++++++++++++++++++++------------- crates/kernel/src/tess/tests.rs | 33 +++++++++++++++++++ 2 files changed, 68 insertions(+), 22 deletions(-) diff --git a/crates/kernel/src/tess/cdt.rs b/crates/kernel/src/tess/cdt.rs index 167c084786..e7d80f9b46 100644 --- a/crates/kernel/src/tess/cdt.rs +++ b/crates/kernel/src/tess/cdt.rs @@ -79,18 +79,16 @@ pub(crate) fn triangulate(boundary: &[[f64; 2]], loops: &[std::ops::Range 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(); + // A side the loops run along 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 sides: Vec<(FixedVertexHandle, FixedVertexHandle)> = Vec::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; - } + sides.push((*handles.get(r.start + k)?, *handles.get(r.start + (k + 1) % n)?)); } } + let slits = slits(&sides); for r in loops { let n = r.len(); for k in 0..n { @@ -114,7 +112,7 @@ pub(crate) fn triangulate(boundary: &[[f64; 2]], loops: &[std::ops::Range } if let Some((sag, budget)) = &fill.refine { let constraints = cdt.num_constraints(); - let added = refine_sag(&mut cdt, sag, *budget, &from); + let added = refine_sag(&mut cdt, sag, *budget, &from, &slits); if fill.shape { let params = RefinementParameters::::new() .keep_constraint_edges() @@ -127,7 +125,7 @@ pub(crate) fn triangulate(boundary: &[[f64; 2]], loops: &[std::ops::Range // Shaping flips sides and adds points of its own; the triangles it leaves may // sag again (a long strip round a cylinder), so the sag is checked once more. if shaped > 0 { - refine_sag(&mut cdt, sag, budget.saturating_sub(added + shaped), &from); + refine_sag(&mut cdt, sag, budget.saturating_sub(added + shaped), &from, &slits); } } // A split constraint would put a point on the boundary the neighbour does not have. @@ -137,9 +135,7 @@ pub(crate) fn triangulate(boundary: &[[f64; 2]], loops: &[std::ops::Range } let mut uv: Vec<[f64; 2]> = boundary.to_vec(); let mut tris = Vec::new(); - 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); + let region = region(&cdt, &slits); for face in cdt.inner_faces() { if !region.get(face.index()).copied().unwrap_or(false) { continue; @@ -199,6 +195,8 @@ pub(crate) fn band(points: &[[f64; 2]], n: usize, loops: &[std::ops::Range) -> [f64; 2]) -> usize { +/// `slits`: constraint sides the loops run along both ways, inside the region on both sides. +fn refine_sag( + cdt: &mut Cdt, + sag: &Sag, + budget: usize, + from: &dyn Fn(Point2) -> [f64; 2], + slits: &std::collections::HashSet<(FixedVertexHandle, FixedVertexHandle)>, +) -> usize { let mut added = 0; let mut cache: HashMap)>> = HashMap::new(); // The widest turn between two corner normals. @@ -246,7 +251,7 @@ fn refine_sag(cdt: &mut Cdt, sag: &Sag, budget: usize, from: &dyn Fn(Point2 (sag.eval)(q[0], q[1]) }) }; - let region = region(cdt); + let region = region(cdt, slits); for face in cdt.inner_faces() { if !region.get(face.index()).copied().unwrap_or(false) { continue; @@ -318,15 +323,23 @@ fn refine_sag(cdt: &mut Cdt, sag: &Sag, budget: usize, from: &dyn Fn(Point2 added } -/// Per face (by index): inside the region the constraint loops bound. A flood fill from the -/// 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 { - region_without(cdt, &std::collections::HashSet::new()) +/// The sides a boundary runs along an even number of times (a slit: out along a line and back), +/// each as its vertices in index order: the region lies on both sides of them. +fn slits(sides: &[(FixedVertexHandle, FixedVertexHandle)]) -> std::collections::HashSet<(FixedVertexHandle, FixedVertexHandle)> { + let mut runs: HashMap<(FixedVertexHandle, FixedVertexHandle), usize> = HashMap::new(); + for &(a, b) in sides { + if a != b { + *runs.entry(if a.index() < b.index() { (a, b) } else { (b, a) }).or_insert(0) += 1; + } + } + runs.into_iter().filter(|(_, n)| n % 2 == 0).map(|(e, _)| e).collect() } -/// [`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 { +/// Per face (by index): inside the region the constraint loops bound. A flood fill from the +/// outer face that flips parity each time it crosses a constraint edge (holes are out), but for +/// the sides in `slits` (run along both ways, the region on both sides), linear in the number of +/// triangles. +fn region(cdt: &Cdt, slits: &std::collections::HashSet<(FixedVertexHandle, FixedVertexHandle)>) -> Vec { use spade::handles::{FixedFaceHandle, InnerTag}; let counts = |e: spade::handles::DirectedEdgeHandle<'_, _, _, _, _>| { let [a, b] = e.vertices().map(|v| v.fix()); diff --git a/crates/kernel/src/tess/tests.rs b/crates/kernel/src/tess/tests.rs index 1f4b9ffc92..81730249d6 100644 --- a/crates/kernel/src/tess/tests.rs +++ b/crates/kernel/src/tess/tests.rs @@ -669,6 +669,39 @@ fn loops_touching_at_a_shared_vertex_bound_the_face() { assert!(closed_boundary(&ranges, &fixed, &uvs, &triangles), "the triangles follow both holes through their shared vertex"); } +/// A face meeting itself along a line (a hole's wall tangent inside the wall round it, cut from +/// the boss side's top rim down to the hole's bottom): its loop runs up the slit and back down +/// it, with the face on both sides. The iso-line grid's band beside the slit is the face on +/// either side of it, and the triangles follow the slit both ways and cover the whole square. +#[test] +fn a_slit_in_a_trimmed_face_has_the_face_on_both_sides() { + // The square [0, 8] × [0, 4], its loop starting at the slit's tip (3, 1) up to the top, round + // the square and back down the slit. + let mut uvs: Vec<[f64; 2]> = (1..=4).map(|k| [3.0, k as f64]).collect(); + uvs.extend((0..3).rev().map(|k| [k as f64, 4.0])); + uvs.extend((0..4).rev().map(|k| [0.0, k as f64])); + uvs.extend((1..=8).map(|k| [k as f64, 0.0])); + uvs.extend((1..=4).map(|k| [8.0, k as f64])); + uvs.extend((3..8).rev().map(|k| [k as f64, 4.0])); + uvs.extend((2..4).rev().map(|k| [3.0, k as f64])); + let fixed: Vec = uvs.iter().map(|q| Vec3::new(q[0], q[1], 0.0)).collect(); + let ranges = [std::ops::Range { start: 0, end: uvs.len() }]; + let traces = motorcycle::traces(&uvs, &ranges, [1.0, 1.0], 0.0); + let lines = |n: usize, top: f64| (0..=n).map(|k| top * k as f64 / n as f64).collect::>(); + let Some((extra, triangles)) = cutgrid::cut_grid(&uvs, &ranges, &lines(16, 8.0), &lines(8, 4.0), &traces, [1.0, 1.0]) else { panic!("meshes") }; + assert!(closed_boundary(&ranges, &fixed, &uvs, &triangles), "the triangles follow the slit both ways"); + let mut all = uvs.clone(); + all.extend(extra); + let area: f64 = triangles + .iter() + .map(|t| { + let [a, b, c] = t.map(|i| all[i as usize]); + 0.5 * ((b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])) + }) + .sum(); + assert!((area - 32.0).abs() < 1e-12, "area {area}"); +} + /// A face whose loop is a thin lune meshes: a half disc of radius 2.5 less a circle through the /// same ends 0.03 mm inside it at the middle, its inner side split at 11.25°. Each edge is /// sampled to the 0.1 mm chord on its own: the outer arc's chords (every 22.5°) sag 0.048 mm,