kernel: a cut grid past the grid cap thins its lines instead of giving the face to Delaunay

A face's grid lines are thinned to MAX_FACE_GRID points (and now to at most that, where the
rounding of thin() could leave it a little over), but the cut grid adds the face's motorcycle
traces to them and then refused when its cells came out over the cap. The face fell through to
Delaunay, whose refinement budget a face of that size is far past: a split coil's wall at the
measuring tolerance got 90 000 triangles where it needs 320 000, and the halves of #793's coil
measured 1.5% short of the whole. The cut grid now thins its full lines (the same evenly spaced
choice as the face's grid, moved into grid.rs) until the cells fit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017itGh5fpMEousnZ91cYcVt
This commit is contained in:
jyviko
2026-10-21 17:24:51 -04:00
parent 20d4b1f4e5
commit 5ed1e95a48
4 changed files with 92 additions and 23 deletions
+14 -2
View File
@@ -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::<Vec<_>>())?;
let (lv, pv) = lines(vs, &tv.iter().map(|t| t.at).collect::<Vec<_>>())?;
// 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::<Vec<_>>())?;
let (lv, pv) = lines(&vs, &tv.iter().map(|t| t.at).collect::<Vec<_>>())?;
let (nu, nv) = (lu.at.len(), lv.at.len());
let (cu, cv) = (nu - 1, nv - 1);
let cells = cu.checked_mul(cv)?;
+31
View File
@@ -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<f64>, vs: &mut Vec<f64>, 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<f64>, 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 {
+1 -19
View File
@@ -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<usize>], 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<f64>, vs: &mut Vec<f64>, 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<f64>| {
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;
+46 -2
View File
@@ -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(&section.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(&section.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(&region)).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::<Vec<f64>>();
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));
}
}