diff --git a/crates/kernel/src/tess/cutgrid.rs b/crates/kernel/src/tess/cutgrid.rs index 5c8f11099b..64db1a54ba 100644 --- a/crates/kernel/src/tess/cutgrid.rs +++ b/crates/kernel/src/tess/cutgrid.rs @@ -86,8 +86,20 @@ pub(crate) fn cut_grid( // Traces on u = c (axis 0) are lines in u; on v = c, lines in v. let tu: Vec<&Trace> = traces.iter().filter(|t| t.axis == 0).collect(); let tv: Vec<&Trace> = traces.iter().filter(|t| t.axis == 1).collect(); - let (lu, pu) = lines(us, &tu.iter().map(|t| t.at).collect::>())?; - let (lv, pv) = lines(vs, &tv.iter().map(|t| t.at).collect::>())?; + // Room for the traces' lines among the grid's: the full lines are thinned (as the face's + // grid is) until the cells fit, rather than the face falling through to Delaunay, whose + // refinement budget a grid this size is past. + let (mut us, mut vs) = (us.to_vec(), vs.to_vec()); + let cells = |us: &[f64], vs: &[f64]| (us.len() + tu.len()).saturating_sub(1).saturating_mul((vs.len() + tv.len()).saturating_sub(1)); + for _ in 0..64 { + if cells(&us, &vs) <= super::grid::MAX_FACE_GRID || (us.len() <= 2 && vs.len() <= 2) { + break; + } + let cap = us.len().saturating_mul(vs.len()).saturating_mul(9) / 10; + super::grid::thin(&mut us, &mut vs, cap); + } + let (lu, pu) = lines(&us, &tu.iter().map(|t| t.at).collect::>())?; + let (lv, pv) = lines(&vs, &tv.iter().map(|t| t.at).collect::>())?; let (nu, nv) = (lu.at.len(), lv.at.len()); let (cu, cv) = (nu - 1, nv - 1); let cells = cu.checked_mul(cv)?; diff --git a/crates/kernel/src/tess/grid.rs b/crates/kernel/src/tess/grid.rs index 9502a1a9bd..1da7da3923 100644 --- a/crates/kernel/src/tess/grid.rs +++ b/crates/kernel/src/tess/grid.rs @@ -6,6 +6,37 @@ /// Most grid points of one face. pub(crate) const MAX_FACE_GRID: usize = 150_000; +/// Keep at most `cap` grid points (`us` × `vs`) by taking evenly spaced lines in both +/// directions, the ends kept. +pub(crate) fn thin(us: &mut Vec, vs: &mut Vec, cap: usize) { + let total = us.len().saturating_mul(vs.len()); + if total <= cap { + return; + } + let f = (total as f64 / cap.max(4) as f64).sqrt(); + // At least the two ends in each direction (never more lines than there are). + let keep = |len: usize| ((len as f64 / f).floor() as usize).max(2).min(len); + let (mut nu, mut nv) = (keep(us.len()), keep(vs.len())); + // Rounding can leave the product a little over: the longer direction gives up the rest. + while nu.saturating_mul(nv) > cap.max(4) { + if nu >= nv && nu > 2 { + nu -= 1; + } else if nv > 2 { + nv -= 1; + } else { + break; + } + } + let pick = |d: &mut Vec, n: usize| { + if d.len() > n && n >= 2 { + let len = d.len(); + *d = (0..n).filter_map(|i| d.get(i * (len - 1) / (n - 1)).copied()).collect(); + } + }; + pick(us, nu); + pick(vs, nv); +} + /// The four sides of a parameter box, in counter-clockwise order. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Side { diff --git a/crates/kernel/src/tess/mod.rs b/crates/kernel/src/tess/mod.rs index 43a8119545..d5799717e5 100644 --- a/crates/kernel/src/tess/mod.rs +++ b/crates/kernel/src/tess/mod.rs @@ -772,7 +772,7 @@ fn plan_face( let (extra, triangles) = { let mut us = uv::divide(s, 0, bbox[0], bbox[1], rules, h, MAX_LINES); let mut vs = uv::divide(s, 1, bbox[1], bbox[0], rules, h, MAX_LINES); - thin(&mut us, &mut vs, grid::MAX_FACE_GRID); + grid::thin(&mut us, &mut vs, grid::MAX_FACE_GRID); let structured = match (ranges.len(), if seamed { None } else { grid::quad(&uvs) }) { (1, Some(q)) => { // Interior lines: the boundary's own where opposite sides agree (refined where the @@ -1119,23 +1119,5 @@ fn closed_boundary(ranges: &[std::ops::Range], fixed: &[Vec3], triangles: }) } -/// Keep at most `cap` grid points by taking every k-th line in both directions (ends kept). -fn thin(us: &mut Vec, vs: &mut Vec, cap: usize) { - let total = us.len().saturating_mul(vs.len()); - if total <= cap { - return; - } - let f = (total as f64 / cap.max(4) as f64).sqrt(); - let pick = |d: &mut Vec| { - let n = ((d.len() as f64 / f).ceil() as usize).max(2); - if d.len() > n { - let len = d.len(); - *d = (0..n).filter_map(|i| d.get(i * (len - 1) / (n - 1)).copied()).collect(); - } - }; - pick(us); - pick(vs); -} - #[cfg(test)] mod tests; diff --git a/crates/kernel/src/tess/tests.rs b/crates/kernel/src/tess/tests.rs index a77d79eaa1..7ce933745f 100644 --- a/crates/kernel/src/tess/tests.rs +++ b/crates/kernel/src/tess/tests.rs @@ -558,10 +558,18 @@ fn an_smlib_torus_stays_within_the_chord_tolerance() { /// y, carried `turns` turns of `pitch` about z from `base`, as an SMLib solid. #[cfg(feature = "smlib")] fn smlib_groove(section: [(f64, f64); 4], base: f64, pitch: f64, turns: f64) -> solidcraft_smlib::Brep { - use solidcraft_geom::{Loop2, Plane, Region2, Vec2}; + use solidcraft_geom::{Loop2, Vec2}; + smlib_screw(Loop2::polygon(§ion.map(|(r, z)| Vec2::new(r, z))), base, pitch, turns) +} + +/// The `section` (radius, axial offset) in the plane through the z axis along y, carried +/// `turns` turns of `pitch` about z from `base`, as an SMLib solid. +#[cfg(feature = "smlib")] +fn smlib_screw(section: solidcraft_geom::Loop2, base: f64, pitch: f64, turns: f64) -> solidcraft_smlib::Brep { + use solidcraft_geom::{Plane, Region2}; let origin = Vec3::new(0.0, 0.0, base); let plane = Plane { origin, x: Vec3::Y, y: Vec3::Z }; - let region = Region2 { outer: Loop2::polygon(§ion.map(|(r, z)| Vec2::new(r, z))), holes: Vec::new() }; + let region = Region2 { outer: section, holes: Vec::new() }; let Some(numbered) = crate::numbered::number_regions(std::slice::from_ref(®ion)).pop() else { panic!("profile") }; let profile = numbered.outer.oriented(false); let groove = ok(crate::smlib_sweeps::screw(&plane, &crate::smlib_sweeps::pieces([&profile]), origin, Vec3::Z, pitch, turns)); @@ -688,3 +696,39 @@ fn a_thread_root_band_running_out_through_an_end_face_lies_on_its_surface() { assert_faces_lie_flat("thread run out", &ok(body.display_plan_angular(tol, crate::DISPLAY_ANGLE))); } } + +/// #793: a coil (a 1 mm wire at 20 mm, six turns of pitch 4) cut in two across its axis. Its +/// halves go to the display plan for their mesh; at the measuring tolerance a half's wall wants +/// more cells than a grid may have, and its cut grid gave up for an under-refined Delaunay (the +/// halves measured 1.5% short of the whole). The cut grid now thins its lines to fit. +#[cfg(feature = "smlib")] +#[test] +fn the_halves_of_a_split_smlib_coil_measure_as_the_whole() { + use solidcraft_geom::{Loop2, Vec2}; + use solidcraft_smlib::{BooleanOp, Brep as S}; + let coil = smlib_screw(Loop2::circle(Vec2::new(20.0, 0.0), 1.0), 0.0, 4.0, 6.0); + let Ok(top) = S::make_box([-30.0, -30.0, 12.5], [60.0, 60.0, 30.0]) else { panic!("box") }; + let (Ok(Some(upper)), Ok(Some(lower))) = (coil.boolean(&top, BooleanOp::Intersect), coil.boolean(&top, BooleanOp::Subtract)) else { + panic!("split") + }; + let volume = |b: &S| ok(crate::measure(&ok(crate::native::body_from_smlib(b)))).volume; + // π r² · 2πR · turns; SMLib's wall is a spline within a ten-thousandth of the pitch. + let (whole, analytic) = (volume(&coil), PI * 2.0 * PI * 20.0 * 6.0); + assert!((whole - analytic).abs() < 3e-3 * analytic, "coil {whole} vs {analytic}"); + let halves = volume(&upper) + volume(&lower); + assert!((halves - whole).abs() < 1e-3 * whole, "halves {halves} vs the whole {whole}"); +} + +/// Thinning keeps a grid within its cap whatever the rounding, both ends of each direction, +/// and never fewer than two lines a direction (a cap below four points takes four). +#[test] +fn thinned_grid_lines_stay_within_the_cap() { + let lines = |n: usize| (0..n).map(|i| i as f64 / (n - 1) as f64).collect::>(); + for (nu, nv, cap) in [(1237, 122, 150_000), (8192, 3, 1000), (3, 8192, 10), (2, 2, 1), (5000, 5000, 4)] { + let (mut us, mut vs) = (lines(nu), lines(nv)); + grid::thin(&mut us, &mut vs, cap); + assert!(us.len() * vs.len() <= cap.max(4), "{nu}x{nv} to {cap}: {}x{}", us.len(), vs.len()); + assert!(us.len() >= 2 && vs.len() >= 2); + assert!(us.first() == Some(&0.0) && us.last() == Some(&1.0) && vs.first() == Some(&0.0) && vs.last() == Some(&1.0)); + } +}