diff --git a/crates/kernel/src/blend.rs b/crates/kernel/src/blend.rs index 5d612258..188e91f6 100644 --- a/crates/kernel/src/blend.rs +++ b/crates/kernel/src/blend.rs @@ -860,6 +860,17 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul return Err(KernelError::Invalid(format!("no edge near {:?}", [p.x, p.y, p.z]))); } let edge = Body::unique_edges(&solid).into_iter().nth(idx).ok_or_else(|| KernelError::Invalid("edge index".into()))?; + // Faces that meet smoothly (a round's seam with the face it runs into) leave no + // corner to blend: say so rather than that blending it isn't supported. + if tangent_faces(&solid, &edge) { + if first_err.is_none() { + first_err = Some(KernelError::Invalid(format!( + "the edge at [{:.3}, {:.3}, {:.3}] is smooth: the faces on either side meet tangentially there, so there is no corner to {what}; pick the sharp edges", + p.x, p.y, p.z + ))); + } + continue; + } // Roll a ball along the edge's smooth chain (the points of `todo` on it go with it). let roll = |ids: &[mt::EdgeID]| { let on_chain: Vec = todo @@ -1064,6 +1075,26 @@ fn curved_case(solid: &Solid, edge: &mt::Edge) -> bool { solid.face_iter().filter(|f| f.edge_iter().any(|e| e.id() == edge.id())).any(|f| !matches!(f.oriented_surface(), mt::Surface::Plane(_))) } +/// Whether the two faces of `edge` meet tangentially along it (sampled at a quarter, the +/// middle and three quarters of the edge): a seam, not a corner. +fn tangent_faces(solid: &Solid, edge: &mt::Edge) -> bool { + use mt::{BoundedCurve, ParametricCurve, ParametricSurface3D, SearchParameter}; + let faces: Vec<&mt::Face> = solid.face_iter().filter(|f| f.edge_iter().any(|e| e.id() == edge.id())).take(3).collect(); + let [f1, f2] = faces[..] else { return false }; + let c = edge.curve(); + let (t0, t1) = c.range_tuple(); + let normal = |f: &mt::Face, q: mt::Point3| -> Option { + let s = f.oriented_surface(); + let (u, v) = s.search_parameter(q, None, 100)?; + let n = s.normal(u, v); + Vec3::new(n.x, n.y, n.z).normalized() + }; + [0.25, 0.5, 0.75].iter().all(|k| { + let q = c.subs(t0 + (t1 - t0) * k); + matches!((normal(f1, q), normal(f2, q)), (Some(a), Some(b)) if a.dot(b) > 1.0 - 1e-6) + }) +} + /// The chain of edges continuing `edge` smoothly at its ends (its ids): closed, or open where /// the next edge turns sharply. fn smooth_chain(solid: &Solid, edge: &mt::Edge) -> Option> { diff --git a/crates/kernel/src/curveblend_tests.rs b/crates/kernel/src/curveblend_tests.rs index 5961291c..ddcd240c 100644 --- a/crates/kernel/src/curveblend_tests.rs +++ b/crates/kernel/src/curveblend_tests.rs @@ -351,3 +351,37 @@ fn a_refused_edge_is_not_rolled_again_after_blends_far_from_it() { // The far edges alone still round. assert!(fillet(&body, &ends, 3.0).is_ok()); } + +/// #896: the seams where rounds run into the faces beside them are picked to be rounded again. +/// The faces meet tangentially there, so there is nothing to round: the refusal says so, rather +/// than that blending such an edge isn't supported yet. +#[test] +fn a_smooth_seam_is_refused_as_having_no_corner() { + let b = crate::box_solid(Vec3::ZERO, Vec3::new(150.0, 200.0, 70.0)).unwrap(); + let mut edges = Vec::new(); + for (x, y) in [(0.0, 0.0), (150.0, 0.0), (0.0, 200.0), (150.0, 200.0)] { + edges.push(Vec3::new(x, y, 35.0)); + } + for (a, z) in [(0.0, 0.0), (150.0, 0.0), (0.0, 70.0), (150.0, 70.0)] { + edges.push(Vec3::new(a, 100.0, z)); + } + for (y, z) in [(0.0, 0.0), (200.0, 0.0), (0.0, 70.0), (200.0, 70.0)] { + edges.push(Vec3::new(75.0, y, z)); + } + let rounded = fillet(&b, &edges, 10.0).unwrap(); + // The seam on the top face beside the round of the edge along y at x = 0, and the seam + // with the end face x = 0 beside the round of the top edge along y. + for seam in [Vec3::new(10.0, 100.0, 70.0), Vec3::new(0.0, 100.0, 60.0)] { + for chamfer in [false, true] { + let r = if chamfer { crate::chamfer(&rounded, &[seam], 1.0) } else { fillet(&rounded, &[seam], 1.0) }; + match r { + Err(KernelError::Invalid(m)) => assert!(m.contains("smooth") && m.contains("no corner"), "{m}"), + other => panic!("{seam:?}: {:?}", other.map(|_| "a body")), + } + } + } + // A sharp edge of a box with one round still takes a fillet beside the round's seams. + let one = fillet(&crate::box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap(), &[Vec3::new(40.0, 30.0, 10.0)], 5.0).unwrap(); + assert!(fillet(&one, &[Vec3::new(20.0, 0.0, 20.0)], 1.0).is_ok()); + assert!(matches!(fillet(&one, &[Vec3::new(35.0, 30.0, 10.0)], 1.0), Err(KernelError::Invalid(_)))); +} diff --git a/tests/robustness/goldens.json b/tests/robustness/goldens.json index 10427291..b5444c59 100644 --- a/tests/robustness/goldens.json +++ b/tests/robustness/goldens.json @@ -73,7 +73,7 @@ "69": {"area":6451.122768,"bodies":1,"edges":132,"error":"offset: a corner whose faces don't meet the same way after t","faces":27,"outcome":"failed","rejected":0,"shells":3,"steps":["revolved section","join box","shell","shell"],"unsupported":0,"volume":4761.477514}, "70": {"area":3682.669157,"bodies":1,"edges":24,"error":"lumps: shell boundaries intersect or touch; cavity ownership","faces":12,"outcome":"failed","rejected":0,"shells":2,"steps":["extruded profile","join box","hole"],"unsupported":1,"volume":9155.027162}, "71": {"area":3980.38113,"bodies":1,"edges":50,"error":null,"faces":19,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","cut box","cut cylinder","hole","join cylinder"],"unsupported":0,"volume":15750.655245}, - "72": {"area":5770.270336,"bodies":2,"edges":46,"error":null,"faces":20,"outcome":"ok","rejected":0,"shells":2,"steps":["box","join cylinder","hole","join box","join box","cut box","cut cylinder","cut cylinder"],"unsupported":1,"volume":16081.801492}, + "72": {"area":5770.270336,"bodies":2,"edges":46,"error":null,"faces":20,"outcome":"ok","rejected":1,"shells":2,"steps":["box","join cylinder","hole","join box","join box","cut box","cut cylinder","cut cylinder"],"unsupported":0,"volume":16081.801492}, "73": {"area":6868.162117,"bodies":1,"edges":40,"error":"offset: a corner whose faces don't meet the same way after t","faces":18,"outcome":"failed","rejected":0,"shells":1,"steps":["extruded profile","join box","shell"],"unsupported":1,"volume":36461.939381}, "74": {"area":9294.57311,"bodies":1,"edges":22,"error":null,"faces":9,"outcome":"ok","rejected":0,"shells":1,"steps":["box","hole"],"unsupported":0,"volume":55866.183074}, "75": {"area":7893.117931,"bodies":1,"edges":27,"error":null,"faces":11,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","cut cylinder","chamfer","rectangular pattern","rectangular pattern","cut box"],"unsupported":0,"volume":38757.62083}, @@ -101,6 +101,7 @@ "notes": [ {"reason":"First record: what each seed builds on main at 1a0100cd","seeds":[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95]}, {"reason":"seed 0 meshes differently since display tessellation plans (#805), which landed alongside these goldens (#826)","seeds":[0]}, - {"reason":"the kernel fixes merged together on 13 October (#838, #839, #841, #855, #856) change what these seeds build: several joins that used to fail now succeed, small volume and area shifts, and shell now refuses seeds 6, 14, 43 and 73 with an offset corner error (seed 14 built before: #897)","seeds":[2,6,14,22,36,43,50,63,71,73,94]} + {"reason":"the kernel fixes merged together on 13 October (#838, #839, #841, #855, #856) change what these seeds build: several joins that used to fail now succeed, small volume and area shifts, and shell now refuses seeds 6, 14, 43 and 73 with an offset corner error (seed 14 built before: #897)","seeds":[2,6,14,22,36,43,50,63,71,73,94]}, + {"reason":"#936: a fillet or chamfer of a smooth seam is refused as an invalid pick (no corner to round) instead of as unsupported","seeds":[72]} ] }