diff --git a/crates/solidcore-ops/tests/data/issue_2906_loft_cut_beside_hole.smb b/crates/solidcore-ops/tests/data/issue_2906_loft_cut_beside_hole.smb new file mode 100644 index 0000000000..13c183c0f3 Binary files /dev/null and b/crates/solidcore-ops/tests/data/issue_2906_loft_cut_beside_hole.smb differ diff --git a/crates/solidcore-ops/tests/issue_2845_stepped_wall.rs b/crates/solidcore-ops/tests/issue_2845_stepped_wall.rs new file mode 100644 index 0000000000..11d8aeb0f6 --- /dev/null +++ b/crates/solidcore-ops/tests/issue_2845_stepped_wall.rs @@ -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}"); + } +} diff --git a/crates/solidcore-ops/tests/issue_2906_loft_cut_rim.rs b/crates/solidcore-ops/tests/issue_2906_loft_cut_rim.rs index d26204cf43..283947671d 100644 --- a/crates/solidcore-ops/tests/issue_2906_loft_cut_rim.rs +++ b/crates/solidcore-ops/tests/issue_2906_loft_cut_rim.rs @@ -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); + } +} diff --git a/crates/solidcore/src/blend/fillet_executive/session/crossing_rails.rs b/crates/solidcore/src/blend/fillet_executive/session/crossing_rails.rs index 13f3393d0b..925b999e55 100644 --- a/crates/solidcore/src/blend/fillet_executive/session/crossing_rails.rs +++ b/crates/solidcore/src/blend/fillet_executive/session/crossing_rails.rs @@ -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 { +/// 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 { + let zone = zone_tol3d(k, fp2::tolerance(target, face)?); + let mut on = PointClassification::::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 { let (a0, a1) = edge::start_end_vertices(target, a)?; let (b0, b1) = edge::start_end_vertices(target, b)?; Ok([a0, a1] diff --git a/crates/solidcore/tests/kernel_cases/fillets/12342-round-wall-and-ledge-edges-r2-5.jpg b/crates/solidcore/tests/kernel_cases/fillets/12342-round-wall-and-ledge-edges-r2-5.jpg index 6cb7d5a864..3685682a5b 100644 Binary files a/crates/solidcore/tests/kernel_cases/fillets/12342-round-wall-and-ledge-edges-r2-5.jpg and b/crates/solidcore/tests/kernel_cases/fillets/12342-round-wall-and-ledge-edges-r2-5.jpg differ diff --git a/crates/solidcore/tests/kernel_cases/fillets/12342-round-wall-and-ledge-edges-r2-5.json b/crates/solidcore/tests/kernel_cases/fillets/12342-round-wall-and-ledge-edges-r2-5.json index 4dc3d534de..be7eedab13 100644 --- a/crates/solidcore/tests/kernel_cases/fillets/12342-round-wall-and-ledge-edges-r2-5.json +++ b/crates/solidcore/tests/kernel_cases/fillets/12342-round-wall-and-ledge-edges-r2-5.json @@ -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": [] } diff --git a/crates/solidcore/tests/kernel_cases/known_failing.json b/crates/solidcore/tests/kernel_cases/known_failing.json index 798c52b4d0..fa3a7b4923 100644 --- a/crates/solidcore/tests/kernel_cases/known_failing.json +++ b/crates/solidcore/tests/kernel_cases/known_failing.json @@ -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",