diff --git a/ROADMAP.md b/ROADMAP.md index 59e5c723..7ac1c53c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -301,7 +301,8 @@ retain their saved two-finger scroll preference; camera motion is available thro of two edges at a vertex meet in a crease, as on a box), picked together or one after the other, except a round after a round of the edge between the wall and a flat face that it would run over; the three edges at a vertex of any smooth faces, of one radius, end in a - piece of sphere. + piece of sphere. A limiting blend that collapses a side wall into coincident boundary + curves is refused as too large rather than returning an unmeshable face (#1113). - Booleans: prisms along one direction (plates with holes, slots, patterns of them, equal sections stacked end to end) are combined exactly in 2D. Qualified axial cuts preserve annular holes with overrun cutters, including inward Press Pull after a joined extrusion; diff --git a/crates/kernel/src/curveblend.rs b/crates/kernel/src/curveblend.rs index 0c93fe06..f0de3803 100644 --- a/crates/kernel/src/curveblend.rs +++ b/crates/kernel/src/curveblend.rs @@ -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"))?; diff --git a/crates/kernel/src/issue_851_tests.rs b/crates/kernel/src/issue_851_tests.rs index 1bea650c..34eb8648 100644 --- a/crates/kernel/src/issue_851_tests.rs +++ b/crates/kernel/src/issue_851_tests.rs @@ -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) }