Fix boss-bottom blends and merged arc-corner surfaces (#1294)

A fillet or chamfer on a cylindrical boss's bottom arc now builds with a closed display mesh: the seam-extension containment ray counts a shared curved-boundary endpoint once. Merging the split faces of a three-edge arc corner rebuilds the face on the original input face's full surface instead of a piece's restricted patch, and runs under the kernel's panic guard. Fixes #1262 and #1263.

Co-authored-by: Karl-VonRichter <20079846+Karl-VonRichter@users.noreply.github.com>
This commit is contained in:
Karl Von RichterandKarl-VonRichter authored and GitHub committed 2026-10-15 02:03:31 -04:00
1 parent af2f09fac2
commit d6f167a88a
4 files changed
+42 -12

No files matched your search

+14 -1
View File
@@ -231,6 +231,11 @@ pub(crate) fn extension_fits(face: &mt::Face, p: Vec3, q: Vec3, tol: f64) -> boo
{
return false;
}
// Curve endpoints may round to opposite sides of a ray that follows a
// straight boundary into the face. Treat points within its clearance as
// on the ray, so a shared endpoint contributes one crossing.
let ay = if ay.abs() <= tol { 0.0 } else { ay };
let by = if by.abs() <= tol { 0.0 } else { by };
if (ay > 0.0) != (by > 0.0) {
let hit = ax - ay * (bx - ax) / (by - ay);
if hit > tol && hit <= len + tol {
@@ -2663,12 +2668,20 @@ mod tests {
),
(Shape::Flat, 0.32, 0.8 / 3.0),
] {
let out = blend(&body, &[Vec3::new(52.0, 20.0, 0.0)], 0.8, shape, "blend").unwrap();
let picks = [Vec3::new(52.0, 20.0, 0.0)];
let out = match shape {
Shape::Round => fillet(&body, &picks, 0.8),
_ => chamfer(&body, &picks, 0.8),
}
.unwrap();
assert!(out.validity().is_empty(), "{:?}", out.validity());
// Pappus: the section turned half round the axis at its centroid's radius.
let want = area * std::f64::consts::PI * (12.0 - c);
let removed = v0 - vol(&out);
assert!((removed - want).abs() < want * 2e-3, "{removed} vs {want}");
// The app's planned mesh must also close, with the same analytic removal.
let planned = crate::issue_851_tests::planned_volume(&body) - crate::issue_851_tests::planned_volume(&out);
assert!((planned - want).abs() < want * 2e-3, "display mesh: {planned} vs {want}");
}
}
+14 -10
View File
@@ -188,7 +188,7 @@ fn round_vertex(cur: &Body, at: Vec3, r: f64) -> Result<Body> {
let notched = crate::ops::boolean(cur, &cutter, BoolOp::Cut)?.ok_or_else(|| unsupported("a corner this small"))?;
// The cut splits a curved face along the trace of its plane (the whole way across, not just
// where the plane cuts): the pieces are one face again.
let notched = merge_split_faces(&notched, size)?;
let notched = crate::guard("merge split faces", || merge_split_faces(&notched, cur, size))?;
// A point on what is left of each edge, half way from where its plane crosses it to its far end.
let mut picks: Vec<Vec3> = Vec::with_capacity(3);
for (e, m) in edges.iter().zip(&normals) {
@@ -320,7 +320,7 @@ fn close_with_sphere(step: &Body, ball: &Ball, planes: [Vec3; 3], r: f64, v: Vec
/// `body` with the curved faces that are pieces of one input face (as the cut that made `body`
/// recorded them) joined again: the edges two pieces share go, the rest make the one boundary.
fn merge_split_faces(body: &Body, size: f64) -> Result<Body> {
fn merge_split_faces(body: &Body, source: &Body, size: f64) -> Result<Body> {
let tol = (size * 1e-6).max(1e-9);
let Some(history) = body.history() else { return Ok(body.clone()) };
let solid = body.deep_copy();
@@ -328,7 +328,7 @@ fn merge_split_faces(body: &Body, size: f64) -> Result<Body> {
if history.faces.len() != faces.len() {
return Ok(body.clone());
}
let on = |a: &mt::Face, p: Vec3| project(a, p, None).is_some_and(|(q, _, _)| q.dist(p) <= tol * 10.0);
let on = |a: &mt::Face, p: Vec3| project(a, p, None).is_some_and(|(q, _, uv)| crate::curveblend::in_range(a, uv) && q.dist(p) <= tol * 10.0);
// Pieces by the input face they come from.
let mut groups: Vec<Vec<usize>> = Vec::new();
for (i, f) in history.faces.iter().enumerate() {
@@ -349,10 +349,8 @@ fn merge_split_faces(body: &Body, size: f64) -> Result<Body> {
continue;
}
// Edges used once, as the pieces walk them.
let all: Vec<mt::Edge> = pieces
.iter()
.flat_map(|f| f.absolute_boundaries().iter().flat_map(|w| w.edge_iter().cloned().collect::<Vec<_>>()).collect::<Vec<_>>())
.collect();
let all: Vec<mt::Edge> =
pieces.iter().flat_map(|f| f.boundaries().iter().flat_map(|w| w.edge_iter().cloned().collect::<Vec<_>>()).collect::<Vec<_>>()).collect();
let mut uses: HashMap<mt::EdgeID, usize> = HashMap::new();
for e in &all {
*uses.entry(e.id()).or_default() += 1;
@@ -371,12 +369,18 @@ fn merge_split_faces(body: &Body, size: f64) -> Result<Body> {
if !left.is_empty() || wire.first().map(|e| e.front().clone()) != wire.last().map(|e| e.back().clone()) {
continue;
}
// On the surface of the piece whose patch reaches every corner of all.
// A boolean may restrict a piece's surface to its own parameter box. The
// joined boundary needs the original face's full patch, not an extrapolation
// of one piece (which the bounded display planner correctly refuses).
let all_on = |f: &mt::Face| {
pieces.iter().all(|p| p.vertex_iter().all(|v| on(f, vtx(&v))) && p.edge_iter().all(|e| edge_mid(&e).is_none_or(|m| on(f, m))))
};
let Some(like) = pieces.iter().copied().find(|f| all_on(f)) else { continue };
let Some(joined) = crate::heal::absolute_face(like, vec![mt::Wire::from(wire)]) else { continue };
let original = group.first().and_then(|i| history.faces.get(*i)).and_then(|h| match h.origins.as_slice() {
[crate::history::FaceOrigin::Face { operand: 0, face }] => source.solid.face_iter().nth(*face),
_ => None,
});
let Some(like) = original.filter(|f| all_on(f)).or_else(|| pieces.iter().copied().find(|f| all_on(f))) else { continue };
let Ok(joined) = mt::Face::try_new(vec![mt::Wire::from(wire)], like.oriented_surface()) else { continue };
let Some(first) = group.first() else { continue };
for i in group {
if let Some(slot) = out.get_mut(*i) {
+1 -1
View File
@@ -455,7 +455,7 @@ pub(crate) fn project(f: &mt::Face, p: Vec3, hint: Option<(f64, f64)>) -> Option
}
/// Whether `uv` lies within the parameters of the face's surface (unbounded directions pass).
fn in_range(f: &mt::Face, uv: (f64, f64)) -> bool {
pub(crate) fn in_range(f: &mt::Face, uv: (f64, f64)) -> bool {
use mt::ParametricSurface;
use std::ops::Bound;
let inside = |range: (Bound<f64>, Bound<f64>), x: f64| {
+13
View File
@@ -393,6 +393,19 @@ fn fillet_the_three_edges_at_the_arcs_front_corner() {
}
}
/// #1263: check the unfilleted input separately from the three-edge result.
/// The circular wall's tessellation differs from its analytic volume by at most
/// its arc length times the chord tolerance times the height.
#[test]
fn unfilleted_knife_edge_input_has_a_closed_display_plan() {
let want = HEIGHT * ((TIP_X - R_WALL) * R_WALL + R_WALL * R_WALL * (1.0 - PI / 4.0));
for (name, body) in [("plate first", part()), ("corner first", part_other_order()), ("cut from a box", part_cut_from_a_box())] {
let volume = planned_volume(&body);
let tolerance = 0.5 * PI * R_WALL * 2e-4 * HEIGHT;
assert!((volume - want).abs() < tolerance, "{name}: volume {volume}, expected {want}");
}
}
/// The construction does not depend on the faces being curved: at a box's corner it gives the
/// body the planar corner builder does (a sphere's octant where the three rounds meet).
#[test]