Chamfer: refuse a result that grows a convex edge's part (#1428)

* Chamfer: refuse a result that grew a convex edge's part (or shrank a concave one's)

A chamfer of a curved edge that runs off a face it skims (robustness seed 57's
ball rim) built a part bigger than before. Checked from the two faces' normals
at the edge; rounds and unclear angles are left to the wedge bound.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Action matrix: the sheet metal tray's relief corners refuse a 0.25 mm chamfer

Each is a 2.5 mm concave edge (the corner of a corner relief, between two
planes at right angles): a chamfer there fills at most 0.25²/2 × 2.5 = 0.08
mm³. What was built took 0.95 mm³ away; the chamfer guard now refuses it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Robustness goldens: seed 57's wrong-way chamfer is now refused (#1390)

On main with #391, seed 57's chamfer grew the part; this refuses it, so the run goes on
to its later steps and ends ok.

---------

Co-authored-by: Phanoob <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: bherbruck <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-15 14:26:30 -04:00
1 parent 2d069ca186
commit 6ad391fd75
5 files changed
+134 -7

No files matched your search

+72 -1
View File
@@ -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<bool> {
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<Body> {
if matches!(size, Size::Round(_)) {
return Ok(out);
}
let ways: Option<Vec<bool>> = 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
@@ -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}");
}
+2
View File
@@ -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;
+4 -4
View File
@@ -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",
+3 -2
View File
@@ -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]}
]
}