Kernel: refuse arc blends that collapse a side face (#1281)

A fillet or chamfer that consumes both seams of a side, leaving its old boundary and the fitted contact on the same curve, now returns TooLarge ("the blend would consume a neighbouring face") instead of a zero-area face that doesn't mesh. Genuine two-edge faces are kept. Fixes #1113.

Co-authored-by: Karl-VonRichter <Karl-VonRichter@users.noreply.github.com>
This commit is contained in:
Karl Von RichterandKarl-VonRichter authored and GitHub committed 2026-10-15 01:19:48 -04:00
1 parent 8535d229ff
commit ee9b4b9fb1
3 files changed
+49 -1

No files matched your search

+20
View File
@@ -768,6 +768,23 @@ fn straight(e: &mt::Edge, tol: f64) -> bool {
a.dist(b) > tol && (0..=16).all(|i| from_p3(c.subs(t0 + (t1 - t0) * i as f64 / 16.0)).dist_to_segment(a, b) <= tol)
}
/// Consuming both seams of a side can leave its old boundary and a fitted contact on the
/// same curve (#1113). They are distinct topological edges but enclose no usable face. Keep
/// genuine two-edge faces (a lens or lune); refuse only coincident traces, in both directions
/// since the curves need not have the same parameterisation.
fn collapsed_boundary(edges: &[mt::Edge], tolerance: f64) -> bool {
use mt::{BoundedCurve, ParametricCurve};
let [a, b] = edges else { return false };
[(a, b), (b, a)].into_iter().all(|(edge, other)| {
let curve = edge.curve();
let (lo, hi) = curve.range_tuple();
(0..=16).all(|k| {
let p = from_p3(curve.subs(lo + (hi - lo) * f64::from(k) / 16.0));
crate::blend::on_edge(other, p, tolerance).is_some()
})
})
}
/// A curved seam (an arc of an end face) shortened to the blend's contacts: the part of its
/// curve between the new ends `a` and `b` (`None` keeps that end), which must lie on it, in
/// order, short of each other.
@@ -1659,6 +1676,9 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
};
es.push(ne);
}
if collapsed_boundary(&es, fit_tolerance) {
return Err(too_large("the blend would consume a neighbouring face"));
}
wires.push(mt::Wire::from(es));
}
let rebuilt = crate::heal::absolute_face(f, wires).ok_or_else(|| fail("a side face could not be rebuilt"))?;
+27
View File
@@ -231,6 +231,33 @@ fn arc_top_edge_sizes_never_panic() {
}
}
/// At the full wall height the fitted contact coincides with the bottom arc: retaining that
/// wall produces a zero-area face, not a successful blend (#1113). Just below the limit the
/// wall still has area and must remain supported. Face order and mirrored edge directions
/// do not change either outcome.
#[test]
fn arc_top_blend_refuses_a_collapsed_wall() {
for body in [part(), part_other_order(), part_cut_from_a_box()] {
for z in [1.0, -1.0] {
let matrix = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, z, 0.0], [0.0, 0.0, 0.0, 1.0]];
let body = transform_matrix(&body, matrix).unwrap();
let at = arc_top();
let pick = [Vec3::new(at.x, at.y, at.z * z)];
for round in [true, false] {
let blend = |r| if round { fillet(&body, &pick, r) } else { chamfer(&body, &pick, r) };
let error = blend(HEIGHT).unwrap_err();
assert!(matches!(error, KernelError::TooLarge(_)), "round={round}, mirror={z}: {error}");
let made = blend(HEIGHT - 0.5).unwrap();
assert!(made.validity().is_empty(), "round={round}, mirror={z}: {:?}", made.validity());
let before = planned_volume(&body);
let removed = before - planned_volume(&made);
let expected = arc_top_removed(HEIGHT - 0.5, round);
assert!((removed - expected).abs() < 0.01 * expected + 0.01, "round={round}, mirror={z}: removed {removed}, expected {expected}");
}
}
}
}
fn tf() -> Vec3 {
Vec3::new(20.0, -R_WALL, HEIGHT)
}