diff --git a/crates/kernel/src/blend.rs b/crates/kernel/src/blend.rs
index d5e71fcd..59b27aa3 100644
--- a/crates/kernel/src/blend.rs
+++ b/crates/kernel/src/blend.rs
@@ -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}");
}
}
diff --git a/crates/kernel/src/blend_vertex.rs b/crates/kernel/src/blend_vertex.rs
index 45c8f989..018cf5c9 100644
--- a/crates/kernel/src/blend_vertex.rs
+++ b/crates/kernel/src/blend_vertex.rs
@@ -188,7 +188,7 @@ fn round_vertex(cur: &Body, at: Vec3, r: f64) -> Result
{
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(¬ched, size)?;
+ let notched = crate::guard("merge split faces", || merge_split_faces(¬ched, 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 = 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 {
+fn merge_split_faces(body: &Body, source: &Body, size: f64) -> Result {
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 {
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::new();
for (i, f) in history.faces.iter().enumerate() {
@@ -349,10 +349,8 @@ fn merge_split_faces(body: &Body, size: f64) -> Result {
continue;
}
// Edges used once, as the pieces walk them.
- let all: Vec = pieces
- .iter()
- .flat_map(|f| f.absolute_boundaries().iter().flat_map(|w| w.edge_iter().cloned().collect::>()).collect::>())
- .collect();
+ let all: Vec =
+ pieces.iter().flat_map(|f| f.boundaries().iter().flat_map(|w| w.edge_iter().cloned().collect::>()).collect::>()).collect();
let mut uses: HashMap = 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 {
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) {
diff --git a/crates/kernel/src/curveblend.rs b/crates/kernel/src/curveblend.rs
index 669543b0..0f09d99d 100644
--- a/crates/kernel/src/curveblend.rs
+++ b/crates/kernel/src/curveblend.rs
@@ -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, Bound), x: f64| {
diff --git a/crates/kernel/src/issue_851_tests.rs b/crates/kernel/src/issue_851_tests.rs
index 34eb8648..a214d68f 100644
--- a/crates/kernel/src/issue_851_tests.rs
+++ b/crates/kernel/src/issue_851_tests.rs
@@ -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]