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,