mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 18:45:55 +00:00
Fillet setbacks not past where the rounds meet are refused naming the least; the rig grades a refusal by its reason
A corner setback must reach past where the corner's rounds' contact lines meet without one (smlib_blend_setback); short of it SMLib failed with its own status (1001). The kernel now refuses it before SMLib runs, for the rolling-ball corner (fillet_setback) and the curvature corner (fillet_g2_corners) alike, naming the least: on each face at a corner of planar faces and straight edges the two rails, r tan(psi/2) from their edges, cross (d2 + d1 cos alpha) / sin alpha along an edge (r on faces square to each other). Other corners stay SMLib's to refuse. The rig's `refused` cases now name the reason (`refusal`, a part of the message, required), so a failure for another reason no longer passes; a `solid_or_refused` case may name one. Fillets: the setback cases and 783, 3435 pass; 1390, 896, 4782 past-a-full-round and the no-tangent-chain case are refused for an unclear reason and fail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -246,9 +246,22 @@ fn check_case(dir: &str, dumps: Option<&std::path::Path>) -> Value {
|
||||
return out;
|
||||
}
|
||||
(Err(e), true) => {
|
||||
// The right answer is a clean refusal.
|
||||
out["status"] = json!("pass");
|
||||
out["refused_with"] = json!(e.to_string());
|
||||
// The right answer is a clean refusal, for the reason the case gives: a `refused`
|
||||
// case names it (`refusal`, a part of the message), so a failure for another reason
|
||||
// (an SMLib status) is not taken for it.
|
||||
let e = e.to_string();
|
||||
out["refused_with"] = json!(e);
|
||||
match want["refusal"].as_str() {
|
||||
Some(why) if !e.contains(why) => {
|
||||
out["status"] = json!("fail");
|
||||
out["error"] = json!(format!("refused, but not because {why:?}: {e}"));
|
||||
}
|
||||
None if refused => {
|
||||
out["status"] = json!("fail");
|
||||
out["error"] = json!(format!("refused ({e}), but expected.json gives no `refusal`: the reason it must be refused for"));
|
||||
}
|
||||
_ => out["status"] = json!("pass"),
|
||||
}
|
||||
return out;
|
||||
}
|
||||
(Err(e), false) => {
|
||||
|
||||
@@ -225,9 +225,96 @@ pub fn fillet(body: &Body, edges: &[Vec3], radius: f64) -> Result<Body> {
|
||||
pub fn fillet_setback(body: &Body, edges: &[Vec3], radius: f64, setbacks: &[(Vec3, f64)]) -> Result<Body> {
|
||||
let _span = solidcraft_profile::span("kernel", "fillet_setback");
|
||||
body.require_brep("fillet")?;
|
||||
setbacks_reach(body, edges, radius, setbacks)?;
|
||||
crate::blend_smlib::fillet_setback(body, edges, radius, setbacks).and_then(|b| closed(body, b))
|
||||
}
|
||||
|
||||
/// Refuses a setback that does not reach past where its corner's rounds meet without one
|
||||
/// ([`setback_minimum`]), naming the least it must exceed, at a corner where every edge is one
|
||||
/// of `edges` (the others are SMLib's to refuse). SMLib fails there with its own status
|
||||
/// (smlib_blend_setback): its patch does not join contact lines that already cross.
|
||||
fn setbacks_reach(body: &Body, edges: &[Vec3], radius: f64, setbacks: &[(Vec3, f64)]) -> Result<()> {
|
||||
let faces = Faces::of(body)?;
|
||||
let (picked, _) = crate::blend_smlib::picked_edges(body, edges)?;
|
||||
let picked: BTreeSet<usize> = picked.into_iter().collect();
|
||||
let reach = body.size() * 0.05 + 1e-3;
|
||||
for (at, distance) in setbacks {
|
||||
let nearest = (0..faces.rec.vertices.len())
|
||||
.filter_map(|v| u32::try_from(v).ok())
|
||||
.filter_map(|v| faces.point(v).map(|p| (p.dist(*at), v)))
|
||||
.filter(|(d, _)| *d <= reach)
|
||||
.min_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let Some((_, v)) = nearest else { continue };
|
||||
let all_rounded = (0..faces.rec.edges.len())
|
||||
.filter(|e| faces.rec.edges.get(*e).is_some_and(|x| !x.uses.is_empty() && x.vertices.contains(&v)))
|
||||
.all(|e| picked.contains(&e));
|
||||
if !all_rounded {
|
||||
continue;
|
||||
}
|
||||
let Some(least) = setback_minimum(&faces, v, radius) else { continue };
|
||||
if *distance <= least * (1.0 + 1e-9) {
|
||||
let p = faces.point(v).unwrap_or(*at);
|
||||
let shown = format!("{:.4}", least).trim_end_matches('0').trim_end_matches('.').to_string();
|
||||
let why = if (least - radius).abs() <= radius * 1e-9 {
|
||||
format!("more than the radius ({shown} mm)")
|
||||
} else {
|
||||
format!("more than {shown} mm")
|
||||
};
|
||||
return Err(KernelError::Invalid(format!(
|
||||
"the setback must be {why} at the corner ({:.3}, {:.3}, {:.3}): the rounds' contact lines meet {shown} mm from it along its edges, \
|
||||
and the corner's patch starts past that",
|
||||
p.x, p.y, p.z
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The least setback a corner of rounds of `radius` at `vertex` takes: on each face at the
|
||||
/// corner, the contact lines (rails) of the two rounded edges beside it cross, and a setback
|
||||
/// must reach past that crossing along both edges (smlib_blend_setback). A rail lies
|
||||
/// r tan(psi / 2) from its edge, psi the angle between the outward normals of the edge's two
|
||||
/// faces (r on faces square to each other); the rails of edges at angle alpha on a face, d1 and
|
||||
/// d2 from them, cross (d2 + d1 cos alpha) / sin alpha along the first edge from the vertex.
|
||||
/// Exact for planar faces and straight edges; `None` for a corner with any other (left to SMLib)
|
||||
/// or one it cannot read.
|
||||
fn setback_minimum(faces: &Faces, vertex: u32, radius: f64) -> Option<f64> {
|
||||
let at: Vec<usize> = (0..faces.rec.edges.len())
|
||||
.filter(|e| faces.rec.edges.get(*e).is_some_and(|x| !x.uses.is_empty() && x.vertices.contains(&vertex)))
|
||||
.collect();
|
||||
if at.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
// Each edge's direction away from the vertex and its rail's distance from it.
|
||||
let mut away = Vec::with_capacity(at.len());
|
||||
let mut rail = Vec::with_capacity(at.len());
|
||||
for e in &at {
|
||||
let [a, b] = faces.rec.edges.get(*e)?.vertices;
|
||||
if a == b || !faces.straight(*e) {
|
||||
return None;
|
||||
}
|
||||
let (pa, pb) = faces.ends(*e)?;
|
||||
away.push(if a == vertex { (pb - pa).normalized()? } else { (pa - pb).normalized()? });
|
||||
let [f, g]: [u32; 2] = faces.faces_of(*e).into_iter().collect::<Vec<_>>().try_into().ok()?;
|
||||
let cos = faces.plane_normal(f as usize)?.dot(faces.plane_normal(g as usize)?).clamp(-1.0, 1.0);
|
||||
rail.push(radius * (cos.acos() / 2.0).tan());
|
||||
}
|
||||
let mut least: f64 = 0.0;
|
||||
let around: BTreeSet<u32> = at.iter().flat_map(|e| faces.faces_of(*e)).collect();
|
||||
for f in around {
|
||||
let beside: Vec<usize> = (0..at.len()).filter(|i| at.get(*i).is_some_and(|e| faces.faces_of(*e).contains(&f))).collect();
|
||||
let [i, j] = beside[..] else { return None };
|
||||
let cos = away.get(i)?.dot(*away.get(j)?).clamp(-1.0, 1.0);
|
||||
let sin = (1.0 - cos * cos).sqrt();
|
||||
if sin <= 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let (di, dj) = (*rail.get(i)?, *rail.get(j)?);
|
||||
least = least.max((dj + di * cos) / sin).max((di + dj * cos) / sin);
|
||||
}
|
||||
(least.is_finite() && least > 0.0).then_some(least)
|
||||
}
|
||||
|
||||
/// [`fillet`] whose corners where three or more rounded edges meet are G2 patches
|
||||
/// ([`crate::corner_g2`]): each such corner is set back, `setbacks` naming a corner by a point at
|
||||
/// its vertex and the distance its patch starts along each edge there (the others take
|
||||
@@ -298,6 +385,7 @@ pub fn fillet_g2_corners(body: &Body, edges: &[Vec3], radius: f64, setbacks: &[(
|
||||
}
|
||||
}
|
||||
let set_back: Vec<(Vec3, f64)> = corners.iter().copied().zip(distance).collect();
|
||||
setbacks_reach(body, edges, radius, &set_back)?;
|
||||
let open = crate::blend_smlib::fillet_setback_open(body, edges, radius, &set_back)?;
|
||||
let mut patches = Vec::with_capacity(open.holes.len());
|
||||
for hole in &open.holes {
|
||||
|
||||
@@ -42,3 +42,18 @@ fn a_setback_off_the_rounded_corner_is_refused() {
|
||||
assert!(fillet_setback(&b, &edges, 5.0, &[(Vec3::new(0.0, 0.0, 0.0), 8.0)]).is_err());
|
||||
assert!(matches!(corner_case(Some(20.0)), Err(KernelError::TooLarge(_))));
|
||||
}
|
||||
|
||||
/// A setback that does not reach past where the corner's rounds meet without one (on faces square
|
||||
/// to each other, the radius) is refused before SMLib runs, naming that least setback; a setback
|
||||
/// just past it builds.
|
||||
#[test]
|
||||
fn a_setback_not_past_the_radius_is_refused_naming_the_least() {
|
||||
for s in [2.0, 5.0] {
|
||||
match corner_case(Some(s)) {
|
||||
Err(KernelError::Invalid(why)) => assert!(why.contains("setback must be more than the radius (5 mm)"), "{why}"),
|
||||
other => panic!("setback {s}: {:?}", other.map(|b| b.face_count())),
|
||||
}
|
||||
}
|
||||
let out = corner_case(Some(5.5)).unwrap();
|
||||
assert!(out.validity().is_empty(), "{:?}", out.validity());
|
||||
}
|
||||
|
||||
@@ -83,6 +83,7 @@ the frozen truck-geometry PRs, a `kernel-paused` PR), for the report's coverage
|
||||
| `source` | `analytic` (numbers worked out by hand; `method` gives the formulas) or `invariants` (no number is known: only the outcome, validity and counts are graded). |
|
||||
| `method` | How the numbers were derived, or which invariants hold and why. |
|
||||
| `outcome` | `solid` (default: the script succeeds), `refused` (the right answer is a clean error, never a panic or a hang), or `solid_or_refused` (either a clean error or a result graded as usual, where the geometry has no single manifold answer, such as two bodies touching along a curve only). |
|
||||
| `refusal` | Required with `outcome: refused` (optional with `solid_or_refused`): a part of the refusal's message, the reason it must be refused for. A refusal for another reason (an SMLib status, a different check) fails. |
|
||||
| `valid` | Default `true`: every body is one closed shell (or `shells` in all) that passes the record's structure and geometry checks and SMLib's `check(All)`. `false` only where the result is legitimately not a solid (a sheet). Where SMLib can't take the record yet (a surface kind its conversion lacks), its check gives no verdict and is not graded. |
|
||||
| `shells` | The total number of shells, where it isn't one per body: a body with a sealed void (2), several lumps kept in one body. |
|
||||
| `body_count`, `faces`, `edges`, `vertices` | Exact counts, where the geometry fixes them. Faces and edges count whole analytic surfaces and curves once (a full cylinder side is one face). |
|
||||
|
||||
+3
-2
@@ -4,7 +4,8 @@
|
||||
],
|
||||
"what": "after the boss's partial base arc is rounded r2, the bar's top edge that runs into the round rounded r2 without its tangent chain",
|
||||
"source": "invariants",
|
||||
"method": "bar and boss as 1207-fillet-bar-top-edge-and-boss-arc-together; the base arc rounded r2 first (V0 + 28.9190), running out over the front face where its section is a curve on that face tangent to the bar's top edge; then that edge rounded r2 alone (tangent chain off): it ends where the section curve goes on tangentially, so a clean refusal or a valid body",
|
||||
"method": "bar and boss as 1207-fillet-bar-top-edge-and-boss-arc-together; the base arc rounded r2 first (V0 + 28.9190), running out over the front face where its section is a curve on that face tangent to the bar's top edge; then that edge rounded r2 alone (tangent chain off): it ends where the section curve goes on tangentially, so a valid body or a refusal saying so (the edge continues tangentially)",
|
||||
"body_count": 1,
|
||||
"outcome": "solid_or_refused"
|
||||
"outcome": "solid_or_refused",
|
||||
"refusal": "continues tangentially"
|
||||
}
|
||||
|
||||
@@ -7,5 +7,6 @@
|
||||
"what": "a 3 mm chamfer on the rim of a hole that is tangent to the bottom face runs off that face and must not add material below the part",
|
||||
"source": "invariants",
|
||||
"method": "cube 20 with a diameter 10 hole along -Y from the back face, its axis at z=5 so the hole wall touches the bottom face z=0 along one line. The hole rim on the back face has no face material between it and the bottom edge there, so a 3 mm setback cannot lie on the face: no chamfer of that size exists. The right answer is a clean refusal (a chamfer of a convex edge only ever removes material; building it grew the part by 1067 mm3 below the bottom face).",
|
||||
"outcome": "refused"
|
||||
"outcome": "refused",
|
||||
"refusal": "larger than the faces beside its edges allow"
|
||||
}
|
||||
@@ -6,5 +6,6 @@
|
||||
"what": "a fillet of radius 6.9 on a cylinder rim whose flat top ring (round the joined boss) is only 4.3 mm wide must be refused, not grow the part",
|
||||
"source": "invariants",
|
||||
"method": "cylinder radius 7, boss radius 2.7 flush on its top face: the flat ring round the boss is 7 - 2.7 = 4.3 mm wide, and a rolling-ball round of radius 6.9 needs 6.9 mm of top face (for a convex rim the round consumes r of each neighbouring face). No such round exists, so the right answer is a clean refusal; accepting it produced a body larger than before the fillet (a round only removes material).",
|
||||
"outcome": "refused"
|
||||
"outcome": "refused",
|
||||
"refusal": "larger than the faces beside its edges allow"
|
||||
}
|
||||
+3
-2
@@ -4,6 +4,7 @@
|
||||
],
|
||||
"what": "both vertical edges of a 15 mm wide box end rounded r10, past the full round (7.5): refused",
|
||||
"source": "invariants",
|
||||
"method": "each round's rail on the end face lies 10 from its edge, past the other's (15 - 10 = 5): the rounds would cross on the face; the largest that fits is the full round, 7.5",
|
||||
"outcome": "refused"
|
||||
"method": "each round's rail on the end face lies 10 from its edge, past the other's (15 - 10 = 5): the rounds would cross on the face; refused as too large (the largest that fits is the full round, 7.5, which fit reduce finds)",
|
||||
"outcome": "refused",
|
||||
"refusal": "larger than the faces beside its edges allow"
|
||||
}
|
||||
|
||||
@@ -6,5 +6,6 @@
|
||||
"what": "a fillet whose edge pick is 15 mm from the nearest edge must be refused, not applied to some other edge",
|
||||
"source": "invariants",
|
||||
"method": "box 20x20x10 and a pick at (10,-15,10): the nearest edge is the top front edge, 15 mm away, so the pick names no edge. The right answer is a clean refusal (never silently rounding the nearest edge).",
|
||||
"outcome": "refused"
|
||||
"outcome": "refused",
|
||||
"refusal": "no edge at"
|
||||
}
|
||||
@@ -6,5 +6,6 @@
|
||||
"what": "a fillet picked on the seam where a rounded edge runs tangent into the flat top has no corner to round and is refused as an invalid pick",
|
||||
"source": "invariants",
|
||||
"method": "box 40x30x20 with all 12 edges rounded R3 (faces: 6 planes, 12 quarter cylinders, 8 sphere octants). The line at y=3 along the top where the flat top meets the R3 round tangentially is a smooth edge (dihedral 180 degrees): there is no corner to fillet, so the pick is refused with a clean error, never a result.",
|
||||
"outcome": "refused"
|
||||
"outcome": "refused",
|
||||
"refusal": "is smooth"
|
||||
}
|
||||
+3
-2
@@ -4,6 +4,7 @@
|
||||
],
|
||||
"what": "three r2 rounds at a box corner, curvature (g2) corner, set back 1.5: not past where the rounds meet (r): refused",
|
||||
"source": "invariants",
|
||||
"method": "a setback must reach past where the corner's rounds meet without one, past r = 2 on a corner of faces square to each other (smlib_blend_setback, solid.fillet `setbacks`); 1.5 is not, so the right answer is a clean refusal",
|
||||
"outcome": "refused"
|
||||
"method": "a setback must reach past where the corner's rounds meet without one, past r = 2 on a corner of faces square to each other (smlib_blend_setback, solid.fillet `setbacks`); 1.5 is not, so the right answer is a refusal naming the least setback, before SMLib runs. A rolling-ball corner takes setbacks too (they replace the ball's corner by SMLib's set-back patch, ladder 07-fillets/013), so the rule holds for both corners",
|
||||
"outcome": "refused",
|
||||
"refusal": "the setback must be more than the radius (2 mm)"
|
||||
}
|
||||
|
||||
+3
-2
@@ -4,6 +4,7 @@
|
||||
],
|
||||
"what": "three r2 rounds at a box corner, rolling-ball corner, set back 1.5: not past where the rounds meet (r): refused",
|
||||
"source": "invariants",
|
||||
"method": "a setback must reach past where the corner's rounds meet without one, past r = 2 on a corner of faces square to each other (smlib_blend_setback, solid.fillet `setbacks`); 1.5 is not, so the right answer is a clean refusal",
|
||||
"outcome": "refused"
|
||||
"method": "a setback must reach past where the corner's rounds meet without one, past r = 2 on a corner of faces square to each other (smlib_blend_setback, solid.fillet `setbacks`); 1.5 is not, so the right answer is a refusal naming the least setback, before SMLib runs. A rolling-ball corner takes setbacks too (they replace the ball's corner by SMLib's set-back patch, ladder 07-fillets/013), so the rule holds for both corners",
|
||||
"outcome": "refused",
|
||||
"refusal": "the setback must be more than the radius (2 mm)"
|
||||
}
|
||||
|
||||
+3
-2
@@ -4,6 +4,7 @@
|
||||
],
|
||||
"what": "three r2 rounds at a box corner, curvature (g2) corner, set back 2: not past where the rounds meet (r): refused",
|
||||
"source": "invariants",
|
||||
"method": "a setback must reach past where the corner's rounds meet without one, past r = 2 on a corner of faces square to each other (smlib_blend_setback, solid.fillet `setbacks`); 2 is not, so the right answer is a clean refusal",
|
||||
"outcome": "refused"
|
||||
"method": "a setback must reach past where the corner's rounds meet without one, past r = 2 on a corner of faces square to each other (smlib_blend_setback, solid.fillet `setbacks`); 2 is not, so the right answer is a refusal naming the least setback, before SMLib runs. A rolling-ball corner takes setbacks too (they replace the ball's corner by SMLib's set-back patch, ladder 07-fillets/013), so the rule holds for both corners",
|
||||
"outcome": "refused",
|
||||
"refusal": "the setback must be more than the radius (2 mm)"
|
||||
}
|
||||
|
||||
+3
-2
@@ -4,6 +4,7 @@
|
||||
],
|
||||
"what": "three r2 rounds at a box corner, rolling-ball corner, set back 2: not past where the rounds meet (r): refused",
|
||||
"source": "invariants",
|
||||
"method": "a setback must reach past where the corner's rounds meet without one, past r = 2 on a corner of faces square to each other (smlib_blend_setback, solid.fillet `setbacks`); 2 is not, so the right answer is a clean refusal",
|
||||
"outcome": "refused"
|
||||
"method": "a setback must reach past where the corner's rounds meet without one, past r = 2 on a corner of faces square to each other (smlib_blend_setback, solid.fillet `setbacks`); 2 is not, so the right answer is a refusal naming the least setback, before SMLib runs. A rolling-ball corner takes setbacks too (they replace the ball's corner by SMLib's set-back patch, ladder 07-fillets/013), so the rule holds for both corners",
|
||||
"outcome": "refused",
|
||||
"refusal": "the setback must be more than the radius (2 mm)"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user