mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-08 13:01:52 +00:00
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user