test: rim halves thickened (#1502) and crescent on a rim union (#1513)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
bherbruck
2026-10-30 09:30:16 -04:00
parent bc18b721c8
commit 3bc4f5bb45
2 changed files with 393 additions and 0 deletions
@@ -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<FaceId> = 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);
}
@@ -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);
}