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]