tess: a hole in a closed face lies in its outer loop's period

Each loop of a face is projected on its own, so along a closed direction a
hole can come out a whole period away from the outer loop. A revolved
dome (rational spline closed in v, bounded by its rim, its seam both ways
and the pole) has its outer loop opened at the pole into v in [-1, 0],
while a hole cut by a ball next to the seam projected into [0, 1]: the
triangulation covered the hole as well, and the cut measured 48118.36 mm³
instead of SMLib's 46424.50 (tangency-labs/solidcore#433, kernel case
sweeps/3528-revolved-dome-cut-by-a-ball-on-its-seam). Every other loop is
now moved by whole periods to the copy nearest the outer loop.

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 16:20:17 -04:00
parent 829d161f6b
commit 5a630daee6
2 changed files with 63 additions and 0 deletions
@@ -125,3 +125,38 @@ fn a_cone_apex_comes_back_measuring_as_it_went() {
assert!((m1.area - m0.area).abs() < 1e-5 * m0.area, "area {} -> {}", m0.area, m1.area);
assert!((m1.volume - m0.volume).abs() < 1e-5 * m0.volume, "volume {} -> {}", m0.volume, m1.volume);
}
/// A revolved cap's dome comes back from SMLib as a rational spline closed around the axis,
/// bounded by its rim, its seam both ways and the pole; a ball centred on the dome near the seam
/// cuts a hole in it. Opened at the pole the outer loop spans v in [-1, 0] while the hole
/// projects into [0, 1]: the mesh covered the hole as well (+1694 mm³) until the hole is moved
/// a period to lie inside the outer loop (tangency-labs/solidcore#433).
#[test]
fn a_hole_in_a_dome_by_its_seam_measures_as_smlib_cuts_it() {
use solidcraft_smlib::sweeps::Curve;
let a0 = 6.9f64.atan2(26.0);
let line = |a: [f64; 2], b: [f64; 2]| Curve::Line { start: [a[0], 0.0, a[1]], end: [b[0], 0.0, b[1]] };
let profile = [
line([0., 0.], [7., 0.]),
line([7., 0.], [7., 20.]),
line([7., 20.], [26., 39.]),
Curve::Arc { center: [0., 0., 32.1], axis: [0., -1., 0.], reference: [1., 0., 0.], radius: 26.9, start: a0, end: PI / 2.0 },
line([0., 59.], [0., 0.]),
];
// Turned about -z, as the engine revolves the sketch (its normal is -y): the dome's v runs
// clockwise seen from above, which puts the hole at v near 1.
let revolved = solidcraft_smlib::Brep::revolve(&profile, [0.; 3], [0., 0., -1.], std::f64::consts::TAU, true).unwrap();
let cap = super::smlib_body(&super::body_from_smlib(&revolved).unwrap()).unwrap();
let ball = super::smlib_body(&sphere(v(17.38629136, 4.658642729, 52.090595805), 4.0).unwrap()).unwrap();
let cut = cap.tagged(0).unwrap().boolean(&ball.tagged(1).unwrap(), solidcraft_smlib::BooleanOp::Subtract).unwrap().unwrap();
// The cap less the lens of two spheres, radii 4 and 26.9 at 26.9 apart.
let (big, small) = (26.9f64, 4.0f64);
let cap_volume = PI * (49.0 * 20.0 + (26f64.powi(3) - 343.0) / 3.0 + big * big * 20.0 - (big.powi(3) - 6.9f64.powi(3)) / 3.0);
let lens = PI * small.powi(2) * (8.0 * big * small - 3.0 * small * small) / (12.0 * big);
let exact = cap_volume - lens;
let got = cut.volume(1e-7).unwrap();
assert!((got - exact).abs() <= 1e-6 * exact, "SMLib volume {got}, exact {exact}");
let back = super::body_from_smlib(&cut).unwrap();
let m = crate::measure(&back).unwrap();
assert!((m.volume - exact).abs() <= 2e-4 * exact, "mesh volume {} ({}), exact {exact}", m.volume, m.measured_by);
}
+28
View File
@@ -603,6 +603,33 @@ fn open_at_pole(s: &EvalSurface, dom: &uv::Domain, l: &mut seam::Loop) {
}
}
/// Each loop is projected on its own, so along a closed direction a hole can come out a whole
/// period away from the outer loop: a dome's outer loop opened at its pole spans v in [-1, 0]
/// while the hole projects into [0, 1], outside it (tangency-labs/solidcore#433). Every other
/// loop is moved by whole periods to the copy whose middle is nearest the outer loop's (the
/// loop of largest area); a face that does not wind lies within one period, so that is the
/// copy inside it.
fn holes_beside_outer(dom: &uv::Domain, loops: &mut [seam::Loop]) {
let Some(outer) = loops.iter().map(|l| area2(&l.uv).abs()).enumerate().max_by(|a, b| a.1.total_cmp(&b.1)).map(|(i, _)| i) else { return };
let middle = |l: &seam::Loop, d: usize| {
let (lo, hi) = l.uv.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), q| (a.min(q[d]), b.max(q[d])));
0.5 * (lo + hi)
};
for d in 0..2 {
let Some(per) = dom.period[d] else { continue };
let Some(centre) = loops.get(outer).map(|l| middle(l, d)) else { continue };
for (i, l) in loops.iter_mut().enumerate() {
let k = ((middle(l, d) - centre) / per).round();
if i == outer || !k.is_finite() || k == 0.0 {
continue;
}
for q in &mut l.uv {
q[d] -= k * per;
}
}
}
}
/// One face: boundary loops in 3D (shared edge nodes) to parameters and triangles.
/// `size`: the face's target side length (mm) and the surface's mean speed along u and v.
fn plan_face(
@@ -681,6 +708,7 @@ fn plan_face(
for l in projected.iter_mut() {
open_pole_to_pole(s, &dom, l);
}
holes_beside_outer(&dom, &mut projected);
}
// Split poles.
let mut fixed: Vec<Vec3> = Vec::new();