diff --git a/crates/kernel/src/blend_fit.rs b/crates/kernel/src/blend_fit.rs index 5cf57293..f2e55301 100644 --- a/crates/kernel/src/blend_fit.rs +++ b/crates/kernel/src/blend_fit.rs @@ -389,7 +389,78 @@ pub(crate) fn accept(input: &Body, out: Body, edges: &[Vec3], size: Size) -> Res } let sel = select(input, edges)?; check_in(&sel, size)?; - volume_in(&sel, input, out, size) + let out = volume_in(&sel, input, out, size)?; + right_way(input, out, edges, size) +} + +/// Whether the edge of `body` nearest `p` is convex (`Some(true)`: a chamfer takes material +/// away) or concave (`Some(false)`: it fills), from the two faces' outward normals and the +/// directions into each face across the edge at `p`. `None` unless that is clear-cut: faces +/// meeting at a few degrees of tangent or of a knife edge, an edge not between two faces. +fn convex_at(body: &Body, p: Vec3) -> Option { + use mt::{BoundedCurve, ParametricCurve, SearchNearestParameter}; + let tol = (body.size() * 1e-4).max(1e-4); + let (i, _) = body.nearest_edge(p, tol).ok()??; + let edge = Body::unique_edges(&body.solid).into_iter().nth(i)?; + let mut sides = Vec::new(); + for f in body.solid.face_iter() { + for e in f.boundary_iters().into_iter().flatten().filter(|e| e.id() == edge.id()) { + let c = e.oriented_curve(); + let (t0, t1) = c.range_tuple(); + let t = c.search_nearest_parameter(crate::body::p3(p), None, 100)?.clamp(t0.min(t1), t0.max(t1)); + let d = c.der(t); + let dir = Vec3::new(d.x, d.y, d.z).normalized()?; + let (_, n, _) = crate::curveblend::project(f, p, None)?; + // A face's boundary runs counter-clockwise about its outward normal: its inside is + // to the left of the edge. + sides.push((n, n.cross(dir).normalized()?)); + } + } + let [(n1, t1), (n2, t2)] = sides[..] else { return None }; + // About 3°: the faces neither nearly tangent nor folded onto each other. + const CLEAR: f64 = 0.05; + if t1.dot(t2) > 1.0 - CLEAR || t1.dot(t2) < -1.0 + CLEAR { + return None; + } + let (a, b) = (n1.dot(t2), n2.dot(t1)); + if a < -CLEAR && b < -CLEAR { + Some(true) + } else if a > CLEAR && b > CLEAR { + Some(false) + } else { + None + } +} + +/// A chamfer of convex edges takes material away and one of concave edges fills it: a result +/// that went the other way (a chamfer on a curved edge running off a face it skims, which the +/// wedges can't bound) is refused rather than kept. Rounds are left to the wedges: a round +/// rolled along a smooth chain may run on round convex and concave stretches alike. +fn right_way(input: &Body, out: Body, edges: &[Vec3], size: Size) -> Result { + if matches!(size, Size::Round(_)) { + return Ok(out); + } + let ways: Option> = edges.iter().map(|p| convex_at(input, *p)).collect(); + let Some(ways) = ways else { return Ok(out) }; + let (convex, concave) = (ways.iter().all(|w| *w), ways.iter().all(|w| !*w)); + if !(convex || concave) { + return Ok(out); + } + let (Ok(a), Ok(b)) = (crate::measure(input), crate::measure(&out)) else { return Ok(out) }; + let slack = a.volume.abs() * 1e-5 + 1e-6; + let grew = b.volume - a.volume; + if (convex && grew > slack) || (concave && grew < -slack) { + return Err(KernelError::TooLarge(format!( + "{} mm is too large for this {}: on {} edge it would {} the part by {} mm³ instead (it runs off a face next to the edge)", + show(size.value()), + size.what(), + if convex { "a convex" } else { "a concave" }, + if convex { "grow" } else { "shrink" }, + // A fraction of a mm³ is typical here: to the hundredth. + show((grew.abs() * 100.0).round() / 100.0) + ))); + } + Ok(out) } /// After a blend of `edges` at `size` turned `input` into `out`: refused when the volume taken diff --git a/crates/kernel/src/blend_wrong_way_tests.rs b/crates/kernel/src/blend_wrong_way_tests.rs new file mode 100644 index 00000000..9d927c16 --- /dev/null +++ b/crates/kernel/src/blend_wrong_way_tests.rs @@ -0,0 +1,53 @@ +use solidcraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3}; + +use crate::*; + +/// Robustness seed 57's part: a block with an arc side, a ball cut from the edge of its back +/// face, just touching its bottom face. +fn block_with_ball_cut() -> Body { + let p = Vec2::new; + let line = |a: Vec2, b: Vec2| Seg2::Line { a, b }; + let (c, r) = (p(24.0625, 11.25), 26.5625); + let start = (p(0.0, 22.5) - c).angle(); + let sweep = ((p(0.0, 0.0) - c).angle() - start).rem_euclid(std::f64::consts::TAU); + let segs = vec![ + line(p(0.0, 0.0), p(47.5, 0.0)), + line(p(47.5, 0.0), p(47.5, 22.5)), + line(p(47.5, 22.5), p(27.5, 22.5)), + line(p(27.5, 22.5), p(27.5, 12.5)), + line(p(27.5, 12.5), p(15.0, 12.5)), + line(p(15.0, 12.5), p(15.0, 22.5)), + line(p(15.0, 22.5), p(0.0, 22.5)), + Seg2::Arc { center: c, radius: r, start, sweep }, + ]; + let block = crate::extrude(&Plane::XY, &[Region2 { outer: Loop2 { segs }, holes: vec![] }], 0.0, 17.5).unwrap().remove(0); + let ball = crate::sphere(Vec3::new(2.5, 22.5, 5.0), 5.0).unwrap(); + boolean(&block, &ball, BoolOp::Cut).unwrap().unwrap() +} + +/// The ball's rim on the back face dips to 0.04 mm above the bottom: a 0.5 mm chamfer of it +/// would run off the back face, and what was built grew the part (by 0.65 mm³, more with other +/// edge orders). On a convex edge a chamfer takes material away, so it is refused. +#[test] +fn a_chamfer_that_would_grow_the_part_on_a_convex_edge_is_refused() { + let body = block_with_ball_cut(); + let rim = Vec3::new(3.1526309873924174, 22.5, 0.04277569594852188); + let e = chamfer(&body, &[rim], 0.5).expect_err("refused"); + assert!(matches!(e, KernelError::TooLarge(_)) && e.to_string().contains("convex"), "{e}"); +} + +/// Curved convex edges that a chamfer does take material from still build: a hole's rim in a +/// plate. +#[test] +fn a_chamfer_of_a_hole_rim_still_builds() { + let plate = crate::box_solid(Vec3::new(0.0, 0.0, 0.0), Vec3::new(40.0, 30.0, 10.0)).unwrap(); + let hole = crate::cylinder(Vec3::new(20.0, 15.0, -1.0), Vec3::Z, 5.0, 12.0).unwrap(); + let body = boolean(&plate, &hole, BoolOp::Cut).unwrap().unwrap(); + let v0 = crate::measure(&body).unwrap().volume; + let out = chamfer(&body, &[Vec3::new(25.0, 15.0, 10.0)], 1.0).unwrap(); + assert!(out.validity().is_empty(), "{:?}", out.validity()); + // A ring of a 1 mm chamfer round a 5 mm hole: (1/2)·2π·(5 + 1/3). + let taken = v0 - crate::measure(&out).unwrap().volume; + let want = std::f64::consts::PI * (5.0 + 1.0 / 3.0); + assert!((taken - want).abs() < 0.02 * want, "{taken} vs {want}"); +} diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index b1e3dd1f..46c4a087 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -17,6 +17,8 @@ mod blend_corner; mod blend_fit; mod blend_variable; mod blend_vertex; +#[cfg(test)] +mod blend_wrong_way_tests; mod body; mod boolean_diagnostics; mod boolean_guard; diff --git a/tests/action_matrix/baseline.json b/tests/action_matrix/baseline.json index 7e121f79..312a3924 100644 --- a/tests/action_matrix/baseline.json +++ b/tests/action_matrix/baseline.json @@ -9159,10 +9159,10 @@ "Body1/F3:face:31|F3:face:32": "!edge.chamfer !edge.fillet edge.normal_plane edge.project", "Body1/F3:face:31|F3:face:33": "!edge.chamfer !edge.fillet edge.normal_plane edge.project", "Body1/F3:face:32": "face.angle_plane !face.draft_neutral face.extrude !face.hole face.midplane face.offset_plane face.press_pull !face.shell face.sketch", - "Body1/F3:face:32|F3:face:37": "edge.axis edge.chamfer !edge.fillet edge.normal_plane edge.project", + "Body1/F3:face:32|F3:face:37": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project", "Body1/F3:face:32|F3:face:8": "edge.axis edge.chamfer !edge.fillet edge.normal_plane edge.project", "Body1/F3:face:33": "face.angle_plane !face.draft_neutral face.extrude !face.hole face.midplane face.offset_plane face.press_pull !face.shell face.sketch", - "Body1/F3:face:33|F3:face:44": "edge.axis edge.chamfer !edge.fillet edge.normal_plane edge.project", + "Body1/F3:face:33|F3:face:44": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project", "Body1/F3:face:33|F3:face:7": "edge.axis edge.chamfer !edge.fillet edge.normal_plane edge.project", "Body1/F3:face:34": "curved.axis curved.tangent_plane", "Body1/F3:face:34|F3:face:36": "!edge.chamfer !edge.fillet edge.normal_plane edge.project", @@ -9172,7 +9172,7 @@ "Body1/F3:face:35|F3:face:36": "!edge.chamfer !edge.fillet edge.normal_plane edge.project", "Body1/F3:face:35|F3:face:37": "!edge.chamfer !edge.fillet edge.normal_plane edge.project", "Body1/F3:face:36": "face.angle_plane !face.draft_neutral face.extrude !face.hole face.midplane face.offset_plane face.press_pull !face.shell face.sketch", - "Body1/F3:face:36|F3:face:41": "edge.axis edge.chamfer !edge.fillet edge.normal_plane edge.project", + "Body1/F3:face:36|F3:face:41": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project", "Body1/F3:face:37": "face.angle_plane !face.draft_neutral face.extrude !face.hole face.midplane face.offset_plane face.press_pull !face.shell face.sketch", "Body1/F3:face:38": "curved.axis curved.tangent_plane", "Body1/F3:face:38|F3:face:40": "!edge.chamfer !edge.fillet edge.normal_plane edge.project", @@ -9188,7 +9188,7 @@ "Body1/F3:face:3|F3:face:8": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project", "Body1/F3:face:4": "face.angle_plane !face.draft_neutral face.extrude face.hole face.midplane face.offset_plane face.press_pull !face.shell face.sketch", "Body1/F3:face:40": "face.angle_plane !face.draft_neutral face.extrude !face.hole face.midplane face.offset_plane face.press_pull !face.shell face.sketch", - "Body1/F3:face:40|F3:face:45": "edge.axis edge.chamfer !edge.fillet edge.normal_plane edge.project", + "Body1/F3:face:40|F3:face:45": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project", "Body1/F3:face:41": "face.angle_plane !face.draft_neutral face.extrude !face.hole face.midplane face.offset_plane face.press_pull !face.shell face.sketch", "Body1/F3:face:42": "curved.axis curved.tangent_plane", "Body1/F3:face:42|F3:face:44": "!edge.chamfer !edge.fillet edge.normal_plane edge.project", diff --git a/tests/robustness/goldens.json b/tests/robustness/goldens.json index 5312b481..eae512b6 100644 --- a/tests/robustness/goldens.json +++ b/tests/robustness/goldens.json @@ -58,7 +58,7 @@ "54": {"area":7080.555724,"bodies":4,"edges":90,"error":null,"faces":38,"outcome":"ok","rejected":0,"shells":4,"steps":["extruded profile","join box","circular pattern","cut cylinder","cut box","join cylinder","cut cylinder"],"unsupported":3,"volume":15737.224446}, "55": {"area":6796.974648,"bodies":1,"edges":60,"error":"Body","faces":23,"outcome":"invalid","rejected":0,"shells":1,"steps":["box","fillet","cut sphere","circular pattern","rectangular pattern","cut sphere"],"unsupported":0,"volume":24741.239331}, "56": {"area":4453.31728,"bodies":1,"edges":33,"error":null,"faces":13,"outcome":"ok","rejected":0,"shells":1,"steps":["box","cut box","chamfer"],"unsupported":0,"volume":11829.375}, - "57": {"area":4818.157176,"bodies":1,"edges":46,"error":"chamfer moved the volume the wrong way: ","faces":18,"outcome":"volume","rejected":0,"shells":1,"steps":["extruded profile","cut sphere","chamfer"],"unsupported":0,"volume":16946.593943}, + "57": {"area":4671.095724,"bodies":1,"edges":66,"error":null,"faces":24,"outcome":"ok","rejected":0,"shells":1,"steps":["extruded profile","cut sphere","circular pattern","chamfer","cut sphere","cut sphere","cut cylinder"],"unsupported":2,"volume":16017.21038}, "58": {"area":4197.049258,"bodies":1,"edges":42,"error":null,"faces":16,"outcome":"ok","rejected":0,"shells":1,"steps":["extruded profile","fillet","cut sphere"],"unsupported":0,"volume":12543.173591}, "59": {"area":7770.5632,"bodies":1,"edges":36,"error":null,"faces":15,"outcome":"ok","rejected":0,"shells":1,"steps":["box","join box","join cylinder"],"unsupported":0,"volume":29735.209746}, "60": {"area":3443.328607,"bodies":1,"edges":39,"error":null,"faces":15,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","cut sphere"],"unsupported":0,"volume":11779.204543}, @@ -120,6 +120,7 @@ {"reason":"#391 after merging main: seed 34's area moves by 0.002 mm² (1086.303472 to 1086.305562, 2e-6 relative); its steps, outcome, volume and face, edge and shell counts are unchanged; the same small kind of move as seed 39's","seeds":[34]}, {"reason":"#1213: a round or chamfer on an edge chain that runs on across a curved seam now builds (it was refused with 'a curved seam where the edge chain turns'). Seed 41's second fillet (three edges, radius 1) was one of those refusals and is now built. Replaying the seed's commands on the build before and after gives the same volume, area, faces and edges after each of steps 1 to 6; step 7, the fillet, is refused before and builds after (5292.19 to 5289.17 mm³, area 7395.26 to 7381.22 mm²); the run then reaches its eight steps one step sooner, so its last step is a cut sphere (5185.22 mm³) where it was a join cylinder (the generator draws its later steps from the new state).","seeds":[41]}, {"reason":"#391 after merging main with #1213: seed 41 now reaches its second fillet (built since #1213), and the end-snap fix again drops a sliver edge the cuts left (94 to 93 edges); its steps, outcome, faces and shells are unchanged, volume moves by 0.005 mm³ (5185.215737 to 5185.221068) and area by 0.0014 mm²","seeds":[41]}, - {"reason":"the chamfer bug is on main too, see #1390","seeds":[57]} + {"reason":"the chamfer bug is on main too, see #1390","seeds":[57]}, + {"reason":"#1390: a chamfer of a convex edge that would grow the part is refused (TooLarge). Seed 57's 0.5 mm chamfer on the sphere cut's rim, which grew the part since #391 reordered its steps, is now refused, so the run goes on to its later steps and ends ok (16017.21 mm³, 24 faces)","seeds":[57]} ] }