Merge pull request #3097 from tangency-labs/fix/2845-crossing-rails

fillet: rounds whose rails only run on past their edges' ends do not cross (#2845)
This commit is contained in:
bherbruck
2026-11-07 10:02:57 -05:00
committed by GitHub
7 changed files with 196 additions and 25 deletions
@@ -0,0 +1,84 @@
//! #2845: rounds on a stepped wall. The union of a 4 mm wall on a 7 mm base, so the base stands
//! 3 mm proud of the wall as a ledge. The wall's edge and the ledge's both end on the back face
//! y = 20, an L, and share no vertex. Their rails run on past the edges' ends until they are
//! trimmed, and the crossing check read that run-on as a rail inside the other round's strip.
//! Its own test binary: the fix set is the process kernel's.
#![allow(
clippy::unwrap_used,
clippy::indexing_slicing,
clippy::disallowed_methods
)] // test: the closed form uses pi
mod common;
use common::{all_fixes, built, edge_at, kernel_copy, volume};
use solidcore::blend::fillet_executive::api::fillet_edges;
use solidcore::kernel::Kernel;
use solidcore_ops::boolean::boolean;
use solidcore_ops::{blend, make_box, Blend, Body, BooleanOp, CheckLevel, Corners};
const WALL: [f64; 3] = [20.0, 20.0, -19.0];
const LEDGE: [f64; 3] = [23.0, 20.0, -45.0];
fn stepped_wall() -> Body {
let wall = make_box([16.0, -20.0, -40.0], [4.0, 40.0, 42.0]).unwrap();
let base = make_box([16.0, -20.0, -50.0], [7.0, 40.0, 10.0]).unwrap();
boolean(wall, base, BooleanOp::Union).unwrap().unwrap()
}
/// The two rounds are apart, each between faces square to it: each takes (1 - pi/4) r^2 per
/// length, over 42 + 10 mm.
fn closed_form(r: f64) -> f64 {
9520.0 - (1.0 - std::f64::consts::FRAC_PI_4) * r * r * 52.0
}
#[test]
fn both_rounds_that_fit_build_their_closed_form() {
all_fixes();
for r in [2.5, 3.0] {
let b = stepped_wall();
let edges = [edge_at(&b, WALL), edge_at(&b, LEDGE)];
blend(&b, Blend::Round, &edges, &[r], &[], Corners::Filled).unwrap();
assert!(b.check(CheckLevel::All).unwrap().is_valid(), "r {r}");
let v = volume(&b);
assert!((v - closed_form(r)).abs() <= 1e-9 * v, "r {r}: {v}");
}
}
/// Past the wall's 4 mm the ball rolls on the face x = 20 and on the wall's far edge (x = 16,
/// y = 20): the arc runs from that edge to its rail, and the section it takes off the wall is
/// the strip less the disc's part under the rail. With every fix on: SolidCraft's set fails here
/// with a bare 1001 (the case `12342-round-wall-edge-r5-past-the-wall-refused`).
fn past_the_wall(r: f64) -> f64 {
let near = r - 4.0;
let height = (r * r - near * near).sqrt();
let under = |u: f64| u / 2.0 * (r * r - u * u).max(0.0).sqrt() + r * r / 2.0 * (u / r).asin();
9520.0 - 42.0 * (4.0 * height - (under(r) - under(near)))
}
#[test]
fn the_wall_alone_rides_its_far_edge_past_its_width() {
all_fixes();
for r in [4.5, 5.0, 6.0] {
let b = stepped_wall();
let e = edge_at(&b, WALL);
blend(&b, Blend::Round, &[e], &[r], &[], Corners::Filled).unwrap();
assert!(b.check(CheckLevel::All).unwrap().is_valid(), "r {r}");
let v = volume(&b);
assert!((v - past_the_wall(r)).abs() <= 1e-9 * v, "r {r}: {v}");
}
}
#[test]
fn the_same_on_the_default_kernel() {
for r in [2.5, 3.0] {
let body = stepped_wall();
let edges = [edge_at(&body, WALL), edge_at(&body, LEDGE)];
let (_, mut b) = kernel_copy(&body);
let ids = edges.map(|i| b.edge_list()[i]);
fillet_edges(&mut Kernel::default(), &mut b, &ids, r, 1, 1, 1.0).unwrap();
let v = volume(&built(|_| Ok(b)));
assert!((v - closed_form(r)).abs() <= 1e-9 * v, "r {r}: {v}");
}
}
@@ -27,12 +27,9 @@ fn slab_less_loft() -> f64 {
900.0 * 480.0 * 160.0 - 4.0 / 6.0 * (top + 4.0 * middle + bottom)
}
/// SolidCraft's set: every fix but two.
/// SolidCraft's set: every fix but one.
fn fixes() {
let off = [
"fillet_refuse_rounds_breaking_through",
"fillet_round_ends_on_outline",
];
let off = ["fillet_round_ends_on_outline"];
let on = solidcore::kernel::UpstreamFixes::NAMES
.iter()
.copied()
@@ -57,6 +54,15 @@ fn blended(kind: Blend, p: [f64; 3]) -> f64 {
volume(&b)
}
/// SolidCraft's `loft_cut_beside_round_hole`: the same cut from a pocket's floor, a hole beside it.
fn beside_a_hole() -> Body {
Body::from_smb(
include_bytes!("data/issue_2906_loft_cut_beside_hole.smb").to_vec(),
false,
)
.unwrap()
}
/// The operand's volume to 1e-8, so the closed forms are held to 2e-10 of the body (0.014 mm3).
const TOL: f64 = 2e-10;
@@ -100,3 +106,51 @@ fn the_rest_of_the_rim_builds() {
}
}
}
/// The same floor edges from a pocket's floor: the same closed forms on the operand's volume.
#[test]
fn the_cut_from_a_pocket_floor_takes_the_same_closed_forms() {
fixes();
let v = volume(&beside_a_hole());
let phi = (4.0_f64 / 9.5).atan();
let round = 80.0 * 64.0 * ((phi / 2.0).tan() - phi / 2.0);
let chamfer = 80.0 * 32.0 * phi.sin();
for y in [-61.16, -141.16] {
for (kind, added) in [(Blend::Round, round), (Blend::Chamfer, chamfer)] {
let b = beside_a_hole();
let e = edge_at(&b, [450.0, y, 726.0]);
blend(&b, kind, &[e], &[8.0], &[], Corners::Filled).unwrap();
assert!(b.check(CheckLevel::All).unwrap().is_valid(), "{y}");
let made = volume(&b);
assert!((made - (v + added)).abs() <= TOL * v, "{made}");
}
}
for p in [[290.83, -56.41, 728.0], [609.17, -145.91, 728.0]] {
let b = beside_a_hole();
let e = edge_at(&b, p);
blend(&b, Blend::Round, &[e], &[8.0], &[], Corners::Filled).unwrap();
assert!(b.check(CheckLevel::All).unwrap().is_valid(), "{p:?}");
}
}
/// Writes the operand and each blended body for the PR's pictures, when asked.
#[test]
#[ignore = "writes the bodies for the PR's pictures"]
fn bodies_for_the_pictures() {
fixes();
let dir = std::env::var("ISSUE_2906_OUT").unwrap();
let write = |name: &str, b: &Body| {
std::fs::write(format!("{dir}/{name}.smb"), b.to_smb(false).unwrap()).unwrap();
};
write("before", &operand());
for (name, kind, p) in [
("round", Blend::Round, [450.0, 418.84, 156.0]),
("chamfer", Blend::Chamfer, [450.0, 418.84, 156.0]),
("side", Blend::Round, [290.83, 423.59, 158.0]),
] {
let b = operand();
let e = edge_at(&b, p);
blend(&b, kind, &[e], &[8.0], &[], Corners::Filled).unwrap();
write(name, &b);
}
}
@@ -5,7 +5,10 @@
//! face with no room between their rails. `SmFilletExecutive::DoFilleting` builds
//! the open body anyway. After `CreateFilletBrep` and before the target is edited,
//! each rail of a round is compared with the strip the other round takes from the
//! same face: a rail lying inside that strip means the rounds do not fit.
//! same face: a rail lying inside that strip means the rounds do not fit. Only what is on
//! the face and across the strip counts: the rails are not trimmed yet and run on past their
//! edges' ends, so a sample off the face, or beside the other edge's end, is no crossing
//! (#2845: a wall's edge and a ledge's on a stepped face, whose rounds never meet).
//!
//! Deviations: not upstream. Rail positions are sampled, and the distance to the other
//! round's edge is a 3D distance (exact on planar faces, a lower bound on curved ones
@@ -13,18 +16,22 @@
//! solution (`SmEdge::ClosestPoint` finding none, #2050) measures nothing: a rail point
//! without one is not inside the other round's strip, and gives its own round no width;
//! a rail with no width at all takes no strip it can be tested against.
// ponytail: sampled 3D distances, no face-interior test; a C-shaped face whose two
// edges face each other across its notch would be refused though they fit. Upgrade to
// the surface distance within the face if that shows up.
// ponytail: sampled 3D distances; a C-shaped face whose two edges face each other across
// its notch would be refused though they fit. Upgrade to the surface distance within the
// face if that shows up.
use super::{FilletBody, FilletObject, FilletSession};
use crate::error::{EntityRef, Result, SolidError};
use crate::geom::tol::zone_tol3d;
use crate::intersect::point_class::{PointClassType, PointClassification};
use crate::kernel::Kernel;
use crate::math::Point3;
use crate::query::edge::closest_point;
use crate::query::face::p1::{point_3d_classify, Point3dClassifyOptions};
use crate::topology::edge;
use crate::topology::edgeuse::p1 as eu1;
use crate::topology::{EdgeId, FaceId};
use crate::topology::face::p2 as fp2;
use crate::topology::{Brep, EdgeId, FaceId};
const OP: &str = "blend::fillet_executive::session::crossing_rails";
/// Rail points compared per rail.
@@ -47,18 +54,26 @@ impl FilletSession<'_> {
pub(super) fn refuse_crossing_rails(&mut self, k: &Kernel) -> Result<()> {
let rails = self.collect_rails(k)?;
let tolerance = self.objects.target.tolerance();
let target = &*self.objects.target;
for a in &rails {
for b in &rails {
if a.face != b.face || a.edge == b.edge || shares_vertex(target, a.edge, b.edge)? {
if a.face != b.face
|| a.edge == b.edge
|| shares_vertex(self.objects.target, a.edge, b.edge)?
{
continue;
}
let along = edge::interval(self.objects.target, b.edge)?;
for &p in &a.points {
// No closest point on the other round's edge: no crossing found here.
let Some(closest) = closest_point(k, target, b.edge, p)? else {
let Some(closest) = closest_point(k, self.objects.target, b.edge, p)? else {
continue;
};
if closest.distance + tolerance < b.width {
// At an end of the edge, the point is beside the strip, not across it.
let across = closest.parameter > along.min() && closest.parameter < along.max();
if across
&& closest.distance + tolerance < b.width
&& on_face(k, self.objects.target, a.face, p)?
{
self.executive.error.code = 4; // SM_FILERR_EDGE_PROBLEM
self.executive.error.status = 2; // SM_FIL_FAILURE
self.executive.error.edge = Some(FilletObject::Topology(
@@ -139,7 +154,24 @@ impl FilletSession<'_> {
}
}
fn shares_vertex(target: &crate::topology::Brep, a: EdgeId, b: EdgeId) -> Result<bool> {
/// Whether `p` lies on `face`, within the zone of the face's tolerance.
fn on_face(k: &Kernel, target: &mut Brep, face: FaceId, p: Point3) -> Result<bool> {
let zone = zone_tol3d(k, fp2::tolerance(target, face)?);
let mut on = PointClassification::<EntityRef>::new(zone);
point_3d_classify(
k,
target,
face,
p,
zone,
true,
&mut on,
Point3dClassifyOptions::default(),
)?;
Ok(on.point_class() != PointClassType::Unknown)
}
fn shares_vertex(target: &Brep, a: EdgeId, b: EdgeId) -> Result<bool> {
let (a0, a1) = edge::start_end_vertices(target, a)?;
let (b0, b1) = edge::start_end_vertices(target, b)?;
Ok([a0, a1]
Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 10 KiB

@@ -8,11 +8,13 @@
},
"calls": [
{
"error": "solidcore failed (status 1001)",
"inputs": [
"b1"
],
"op": "blend_dropping",
"outputs": [
"b2"
],
"params": {
"edges": {
"kind": "edge",
@@ -84,18 +86,22 @@
"rel": 1e-09
},
"volume_mm3": 9450.25440310417,
"what": "an L body: a 4 mm wall (x 16..20, y -20..20, z -40..2) on a 7 mm base (x 16..23, z -50..-40), so the base stands 3 mm proud of the wall as a ledge at z = -40; the wall's outer vertical edge at x = 20, y = 20 and the ledge's at x = 23, y = 20, both convex edges between the back face y = 20 and the faces x = 20 and x = 23. Both edges rounded R2.5, a size that fits each: one closed solid; it fails with status 1001 (R3 the same), while each edge alone builds"
"what": "an L body: a 4 mm wall (x 16..20, y -20..20, z -40..2) on a 7 mm base (x 16..23, z -50..-40), so the base stands 3 mm proud of the wall as a ledge at z = -40; the wall's outer vertical edge at x = 20, y = 20 and the ledge's at x = 23, y = 20, both convex edges between the back face y = 20 and the faces x = 20 and x = 23. Both edges rounded R2.5, a size that fits each: one closed solid (R3 the same), as each edge alone builds"
},
"expressible": true,
"gallery_note": "the back face y = 20 and the ledge top highlighted: R2.5 fits each edge alone, but the two together fail with status 1001",
"gallery_note": "R2.5 fits each edge alone and the two together: the rounds end apart on the stepped back face",
"operation_expressible": true,
"records": "12342-round-wall-and-ledge-edges-r2-5.bodies.json.gz",
"result": [],
"result": [
{
"made": "b2"
}
],
"sources": [
"tangency-labs/solidcraft#12342",
"tangency-labs/solidcore#2845"
],
"ungraded": [],
"what": "an L body: a 4 mm wall (x 16..20, y -20..20, z -40..2) on a 7 mm base (x 16..23, z -50..-40), so the base stands 3 mm proud of the wall as a ledge at z = -40; the wall's outer vertical edge at x = 20, y = 20 and the ledge's at x = 23, y = 20, both convex edges between the back face y = 20 and the faces x = 20 and x = 23. Both edges rounded R2.5, a size that fits each: one closed solid; it fails with status 1001 (R3 the same), while each edge alone builds",
"what": "an L body: a 4 mm wall (x 16..20, y -20..20, z -40..2) on a 7 mm base (x 16..23, z -50..-40), so the base stands 3 mm proud of the wall as a ledge at z = -40; the wall's outer vertical edge at x = 20, y = 20 and the ledge's at x = 23, y = 20, both convex edges between the back face y = 20 and the faces x = 20 and x = 23. Both edges rounded R2.5, a size that fits each: one closed solid (R3 the same), as each edge alone builds",
"why_not": []
}
@@ -4,11 +4,6 @@
"issue": "solidcore#2844",
"reason": "the round of the loft's circular rim is not a closed solid"
},
{
"case": "fillets/12342-round-wall-and-ledge-edges-r2-5",
"issue": "solidcore#2845",
"reason": "status 1001 instead of a closed solid"
},
{
"case": "fillets/12342-round-wall-and-ledge-edges-r5-refused",
"issue": "solidcore#2845",