From 3bc4f5bb45c3702bfa32ce0652d08cfce8e29aba Mon Sep 17 00:00:00 2001 From: bherbruck Date: Fri, 30 Oct 2026 09:30:16 -0400 Subject: [PATCH] test: rim halves thickened (#1502) and crescent on a rim union (#1513) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tests/issue_1502_thicken_rim_halves.rs | 219 ++++++++++++++++++ .../tests/issue_1513_crescent_on_rim_union.rs | 174 ++++++++++++++ 2 files changed, 393 insertions(+) create mode 100644 crates/solidcore/tests/issue_1502_thicken_rim_halves.rs create mode 100644 crates/solidcore/tests/issue_1513_crescent_on_rim_union.rs diff --git a/crates/solidcore/tests/issue_1502_thicken_rim_halves.rs b/crates/solidcore/tests/issue_1502_thicken_rim_halves.rs new file mode 100644 index 0000000000..699c458222 --- /dev/null +++ b/crates/solidcore/tests/issue_1502_thicken_rim_halves.rs @@ -0,0 +1,219 @@ +//! tangency-labs/solidcore#1502: thickening a sphere cap whose rim is two half circles. +//! Upstream, `TrimExtendedEdge` can take the first trim point's twin across the lifted +//! offset circle's seam as an open rim half's second trim point. Each half then gets +//! the whole circle and the shell fails (1001). +//! +//! Test-only: the issue does not reproduce in the Rust port. On these bodies each rim +//! half's second trim point already lands on its own other vertex: the fork's guard, +//! tried as an opt-in, changed no result. +//! +//! Each body is a ball (r 10) cut by a half space, shelled open at the flat face by +1 +//! or -1, with and without an offset solid. One cut is at the equator and one through +//! the poles, so the rim crosses the sphere's seam differently. The ninth body is the +//! two stitched quarter-sphere sheets thickened by +1. All runs have +//! `offset_closed_curve_trim_arc` (#403) on, so each half of a closed rim keeps its own +//! arc. Every result is a manifold solid of the spherical shell's volume +//! 2/3 π (`r_out³` − `r_in³`), to the accuracy the volume integration is asked for (1e-6; +//! the results come within 1.4e-6 relative). +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing +)] // test helpers + +#[path = "support/offset_fixture.rs"] +mod offset_fixture; + +use std::f64::consts::PI; + +use offset_fixture::{volume, Notifications}; +use solidcore::blend::fillet_executive::api::create_face_from_surface; +use solidcore::boolean::api_brep::boolean_intersection; +use solidcore::geom::extent2d::Extent2d; +use solidcore::geom::tol; +use solidcore::math::Vec3; +use solidcore::offset::offset_executive::p1::OffsetExecutive; +use solidcore::offset::offset_executive::p2::{OffsetOptions, ShellOptions}; +use solidcore::primitive::api_primitives::{create_box, create_sphere}; +use solidcore::query::api_queries::brep_is_manifold_solid; +use solidcore::surface::plane::Plane; +use solidcore::surface::sphere::{Sphere, SphereOptions}; +use solidcore::surface::surface::Surface; +use solidcore::topology::brep::p4::stitch_faces; +use solidcore::{Brep, FaceId, Kernel}; + +/// Relative tolerance on the volume: the integration is asked for 1e-6, and the shelled +/// caps come within 1.4e-6. +const REL: f64 = 1e-5; + +fn kernel() -> Kernel { + let mut k = Kernel::default(); + k.fixes.offset_closed_curve_trim_arc = true; + k +} + +fn shell_options(distance: f64, create_offset_solid: bool) -> ShellOptions { + ShellOptions { + offset: OffsetOptions { + distance, + extend_convex_edges: true, + merge_results: true, + }, + create_offset_solid, + merge_sheet_side_walls: false, + } +} + +/// The ball r 10 at the origin cut by the box at `origin` of `size` (a half space), shelled +/// open at its flat face by `distance`. +fn shelled_cap(origin: Vec3, size: Vec3, distance: f64, solid: bool) -> (Kernel, Brep) { + let mut k = kernel(); + let ball = create_sphere(&mut k, Vec3::new(0.0, 0.0, 0.0), 10.0).unwrap(); + let half = create_box(&mut k, origin, size.x, size.y, size.z).unwrap(); + let mut cap = boolean_intersection(&mut k, ball, half).unwrap(); + let flat: Vec = cap + .face_list() + .iter() + .copied() + .filter(|&f| { + cap.face(f) + .unwrap() + .surface + .as_ref() + .is_some_and(|s| s.is_kind_of(Plane::TYPE).unwrap()) + }) + .collect(); + assert_eq!(flat.len(), 1); + let mut notifications = Notifications::new(&mut cap); + let out = OffsetExecutive::shell_brep( + &mut k, + cap, + shell_options(distance, solid), + &flat, + &mut notifications.context(), + ) + .result + .unwrap(); + (k, out) +} + +/// The cut at the equator (z >= 0). +fn equator(distance: f64, solid: bool) -> (Kernel, Brep) { + shelled_cap( + Vec3::new(-20.0, -20.0, 0.0), + Vec3::new(40.0, 40.0, 20.0), + distance, + solid, + ) +} + +/// The cut through the poles (x >= 0). +fn poles(distance: f64, solid: bool) -> (Kernel, Brep) { + shelled_cap( + Vec3::new(0.0, -20.0, -20.0), + Vec3::new(20.0, 40.0, 40.0), + distance, + solid, + ) +} + +/// The upper hemisphere r 10 as two quarter-sphere sheets (longitudes 0-180 and +/// 180-360) stitched along their meridians, thickened by +1: its rim is two half circles. +fn stitched_quarters_thickened() -> (Kernel, Brep) { + let mut k = kernel(); + let mut sheet = + solidcore::topology::brep::p3::new_brep(&mut k, tol::USE_DEFAULT, tol::USE_DEFAULT) + .unwrap(); + for (lon0, lon1) in [(0.0, 180.0), (180.0, 360.0)] { + let mut quarter = Sphere::new( + &k, + Vec3::new(0.0, 0.0, 0.0), + Vec3::new(1.0, 0.0, 0.0), + Vec3::new(0.0, 1.0, 0.0), + Extent2d::from_bounds(lon0, 0.0, lon1, 90.0), + 10.0, + SphereOptions::default(), + ) + .unwrap(); + quarter.make_nurb(&k).unwrap(); + create_face_from_surface(&mut k, &mut sheet, &Surface::from(quarter)).unwrap(); + } + stitch_faces(&mut k, &mut sheet, 1.0e-5, None, false).unwrap(); + assert_eq!(sheet.face_list().len(), 2); + assert!(!brep_is_manifold_solid(&sheet).unwrap()); + let mut notifications = Notifications::new(&mut sheet); + let out = OffsetExecutive::shell_brep( + &mut k, + sheet, + shell_options(1.0, true), + &[], + &mut notifications.context(), + ) + .result + .unwrap(); + (k, out) +} + +/// The result is a manifold solid of `faces` faces whose volume is the hemispherical +/// shell between radii 10 and `10 + distance`. +fn assert_sound((k, mut b): (Kernel, Brep), distance: f64, faces: usize) { + assert!(brep_is_manifold_solid(&b).unwrap()); + assert_eq!(b.face_list().len(), faces); + let (outer, inner) = if distance > 0.0 { + (10.0 + distance, 10.0) + } else { + (10.0, 10.0 + distance) + }; + let expected = 2.0 / 3.0 * PI * (outer * outer * outer - inner * inner * inner); + let v = volume(&k, &mut b).unwrap(); + assert!( + (v - expected).abs() <= REL * expected, + "volume {v}, expected {expected}" + ); +} + +#[test] +fn equator_cap_shelled_out_as_offset_solid() { + assert_sound(equator(1.0, true), 1.0, 3); +} + +#[test] +fn equator_cap_shelled_in_as_offset_solid() { + assert_sound(equator(-1.0, true), -1.0, 3); +} + +#[test] +fn equator_cap_shelled_out() { + assert_sound(equator(1.0, false), 1.0, 3); +} + +#[test] +fn equator_cap_shelled_in() { + assert_sound(equator(-1.0, false), -1.0, 3); +} + +#[test] +fn poles_cap_shelled_out_as_offset_solid() { + assert_sound(poles(1.0, true), 1.0, 5); +} + +#[test] +fn poles_cap_shelled_in_as_offset_solid() { + assert_sound(poles(-1.0, true), -1.0, 5); +} + +#[test] +fn poles_cap_shelled_out() { + assert_sound(poles(1.0, false), 1.0, 5); +} + +#[test] +fn poles_cap_shelled_in() { + assert_sound(poles(-1.0, false), -1.0, 5); +} + +#[test] +fn stitched_hemisphere_quarters_thickened_out() { + assert_sound(stitched_quarters_thickened(), 1.0, 6); +} diff --git a/crates/solidcore/tests/issue_1513_crescent_on_rim_union.rs b/crates/solidcore/tests/issue_1513_crescent_on_rim_union.rs new file mode 100644 index 0000000000..f3be10a04f --- /dev/null +++ b/crates/solidcore/tests/issue_1513_crescent_on_rim_union.rs @@ -0,0 +1,174 @@ +//! tangency-labs/solidcore#1513: uniting a cylinder (r 10, h 10) with a crescent prism +//! standing on its rim from outside. The crescent is the r 6 disk centred on the rim, +//! minus the r 10 disk. Upstream, `CloseIntersectionLoop` relates two edges between the +//! same vertices whenever a closest-point drop succeeds. That relates the rim's long arc +//! to the crescent's r 6 arc, so an edge ends up 37.9 off its face. +//! +//! Test-only: the issue does not reproduce in the Rust port. At the default fixes its +//! union of these operands keeps every edge on its faces, with the exact volume. +//! +//! The crescent stands at 0, 45, 90 and 180 degrees, so its rim arc crosses the +//! cylinder's seam or stays clear of it. It is built two ways: as the issue builds it, +//! by sweeping its two arcs, and as the r 6 cylinder minus the r 10 one. Each union is +//! a manifold solid that `check_brep` (level 2) finds clean. Every edge's curve lies on +//! each of its faces' surfaces within the edge's tolerance. The volume is that of the +//! two cylinders' union, (π (10² + 6²) − lens) 10, to 1e-6 relative; the results come +//! within 2e-9. +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing +)] // test helpers + +#[path = "support/offset_fixture.rs"] +mod offset_fixture; + +use std::f64::consts::PI; + +use offset_fixture::volume; +use solidcore::boolean::api_brep::{boolean_difference, boolean_union}; +use solidcore::curve::curve::Curve; +use solidcore::math::Vec3; +use solidcore::primitive::api_curves::create_arc; +use solidcore::primitive::api_primitives::create_cylinder; +use solidcore::query::api_queries::{brep_is_manifold_solid, surface_closest_point}; +use solidcore::sweep::api_brep::create_linear_sweep; +use solidcore::topology::brep::validation::{check_brep, CheckLevel}; +use solidcore::topology::edge; +use solidcore::topology::edgeuse::p1 as eu1; +use solidcore::{Brep, Kernel}; + +/// The cylinder's radius; the crescent's circle (r 6) is centred on its rim. +const R: f64 = 10.0; + +/// The crescent prism as the issue builds it: a linear sweep (10 up, capped) of the +/// rim's arc inside the r 6 circle and the r 6 arc outside the rim. The two circles +/// meet at x = 8.2 in the crescent's frame. +fn crescent_swept(k: &mut Kernel, angle_deg: f64) -> Brep { + let y = (R * R - 8.2 * 8.2).sqrt(); + let big = y.atan2(8.2).to_degrees(); + let small = y.atan2(8.2 - R).to_degrees(); + let centre = Vec3::new( + R * angle_deg.to_radians().cos(), + R * angle_deg.to_radians().sin(), + 0.0, + ); + let inner = Curve::from( + create_arc( + Vec3::new(0.0, 0.0, 0.0), + R, + angle_deg - big, + angle_deg + big, + ) + .unwrap(), + ); + let outer = Curve::from(create_arc(centre, 6.0, angle_deg - small, angle_deg + small).unwrap()); + let curves = [Some(&inner), Some(&outer)]; + create_linear_sweep(k, &curves, Vec3::new(0.0, 0.0, 1.0), 10.0, true).unwrap() +} + +/// The crescent prism as the r 6 cylinder minus the r 10 one. +fn crescent_booleaned(k: &mut Kernel, angle_deg: f64) -> Brep { + let centre = Vec3::new( + R * angle_deg.to_radians().cos(), + R * angle_deg.to_radians().sin(), + 0.0, + ); + let small = create_cylinder(k, centre, 6.0, 10.0).unwrap(); + let big = create_cylinder(k, Vec3::new(0.0, 0.0, 0.0), R, 10.0).unwrap(); + boolean_difference(k, small, big).unwrap() +} + +/// The volume of the union of the r 10 and r 6 cylinders (h 10) whose axes are 10 apart. +fn expected_volume() -> f64 { + let (r, d) = (6.0_f64, R); + let lens = r * r * ((d * d + r * r - R * R) / (2.0 * d * r)).acos() + + R * R * ((d * d + R * R - r * r) / (2.0 * d * R)).acos() + - 0.5 * ((-d + r + R) * (d + r - R) * (d - r + R) * (d + r + R)).sqrt(); + (PI * (R * R + r * r) - lens) * 10.0 +} + +/// The largest distance, over 9 samples of each edge's curve, of the curve from each of +/// its faces' surfaces, against the edge's tolerance. +fn assert_edges_on_faces(k: &Kernel, b: &Brep) { + for &e in b.edge_list() { + let c = edge::curve(b, e).unwrap().unwrap(); + let iv = edge::interval(b, e).unwrap(); + let tol = edge::tolerance(b, e).unwrap(); + for eu in edge::edgeuses(b, e, None, None).unwrap() { + let f = eu1::face(b, eu).unwrap().unwrap(); + let s = b.face(f).unwrap().surface.as_ref().unwrap(); + for i in 0..=8 { + let p = c + .evaluate_point(k, iv.min() + iv.length() * f64::from(i) / 8.0) + .unwrap(); + let gap = surface_closest_point(k, s, p).unwrap().closest.distance; + assert!(gap <= tol, "edge {e:?} {gap} off face {f:?} (tol {tol})"); + } + } + } +} + +fn assert_union_sound(angle_deg: f64, swept: bool) { + let mut k = Kernel::default(); + let cylinder = create_cylinder(&mut k, Vec3::new(0.0, 0.0, 0.0), R, 10.0).unwrap(); + let prism = if swept { + crescent_swept(&mut k, angle_deg) + } else { + crescent_booleaned(&mut k, angle_deg) + }; + let mut b = boolean_union(&mut k, cylinder, prism).unwrap(); + assert!(brep_is_manifold_solid(&b).unwrap()); + assert_edges_on_faces(&k, &b); + let expected = expected_volume(); + let v = volume(&k, &mut b).unwrap(); + assert!( + (v - expected).abs() <= 1e-6 * expected, + "volume {v}, expected {expected}" + ); + let lines = check_brep(&mut k, &mut b, CheckLevel::Level2) + .unwrap() + .lines(); + assert!(lines.is_empty(), "{lines:?}"); +} + +#[test] +fn swept_crescent_at_0_degrees() { + assert_union_sound(0.0, true); +} + +#[test] +fn swept_crescent_at_45_degrees() { + assert_union_sound(45.0, true); +} + +#[test] +fn swept_crescent_at_90_degrees() { + assert_union_sound(90.0, true); +} + +#[test] +fn swept_crescent_at_180_degrees() { + assert_union_sound(180.0, true); +} + +#[test] +fn booleaned_crescent_at_0_degrees() { + assert_union_sound(0.0, false); +} + +#[test] +fn booleaned_crescent_at_45_degrees() { + assert_union_sound(45.0, false); +} + +#[test] +fn booleaned_crescent_at_90_degrees() { + assert_union_sound(90.0, false); +} + +#[test] +fn booleaned_crescent_at_180_degrees() { + assert_union_sound(180.0, false); +}