kernel: delete the coplanar, seam and flat-face boolean strategies

coplanar.rs (coincident faces pushed apart, faces glued back to back, bores and
insets joined, imprint unions), seam_faces.rs (round faces halved before a
retried boolean) and planar_faces.rs (flat B-spline faces made planes so the
coplanar strategies see them) existed to get round the mesh-based boolean's
coincident faces and seam loops. SMLib's boolean classifies coincident faces
itself (SmMerge::ManifoldBoolean, step 2), so these are retries and second
paths around the operation, not operations: deleted, not ported.

Their tests of what the boolean gives keep their volume, lump and validity
assertions and now run against the boolean itself (coplanar_tests.rs and
seam_faces_tests.rs need their own mod lines in lib.rs); the assertions on the
strategies used, and the tests of push_faces, grow_faces_exact, trim_exact,
planarized, coincident_planes and halved, go with them.

(not built: base in progress)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017itGh5fpMEousnZ91cYcVt
This commit is contained in:
jyviko
2026-10-22 00:54:55 -04:00
parent 7225d3f2b6
commit 60b817e67d
6 changed files with 15 additions and 2277 deletions
File diff suppressed because it is too large Load Diff
+10 -49
View File
@@ -1,5 +1,5 @@
//! Booleans of bodies with faces in a common plane: a face is pushed out of the plane only where
//! that leaves the result as it is. Every expected volume is what the two bodies define.
//! Booleans of bodies with faces in a common plane. Every expected volume is what the two bodies
//! define.
#![allow(clippy::unwrap_used, clippy::panic)]
use crate::*;
use solidcraft_geom::Vec3;
@@ -32,19 +32,13 @@ fn change(a: &Body, b: &Body, op: BoolOp) -> (f64, usize, BooleanDiagnostics) {
(measure(&r).unwrap().volume - measure(a).unwrap().volume, r.lumps().unwrap().len(), how)
}
fn used(how: &BooleanDiagnostics, strategy: BooleanStrategy) -> bool {
how.events.iter().any(|e| e.strategy == strategy)
}
/// A channel cut along a block, up to the face a rib stands on. The channel's top lies in
/// that face's plane and the rib bridges it: pushed up out of the plane (2.29 % of the
/// body's size, 5.6 mm), the top would cut a notch 20 x 40 into the rib.
/// that face's plane and the rib bridges it.
#[test]
fn a_cut_up_to_a_face_leaves_what_stands_on_the_face() {
let (added, lumps, how) = change(&ribbed(), &channel(), BoolOp::Cut);
// It takes 200 x 40 x 10 of the block and nothing of the rib.
assert!((added + 80_000.0).abs() < 1e-6 && lumps == 1, "{added}, {lumps} lumps, {how:?}");
assert!(!used(&how, BooleanStrategy::CoplanarSeparation), "{how:?}");
}
/// The same with a round hole through the block beside the channel (r 4 at 30, 15: y 11 to
@@ -57,23 +51,20 @@ fn a_round_hole_beside_the_cut_changes_nothing() {
let (added, lumps, how) = change(&drilled, &channel(), BoolOp::Cut);
// 200 x 40 x 10 again (the hole is measured on its mesh, before and after).
assert!((added + 80_000.0).abs() < 1e-3 && lumps == 1, "{added}, {lumps} lumps, {how:?}");
assert!(!used(&how, BooleanStrategy::CoplanarSeparation), "{how:?}");
}
/// With nothing in their way the faces are pushed as before: a pocket's top out of the plane
/// of the block's top face, a pad's foot into the block. (This holds without the check too:
/// it keeps the check from refusing what is harmless.)
/// A pocket whose top lies in the plane of the block's top face, and a pad whose foot does.
#[test]
fn faces_with_room_are_pushed_apart() {
fn faces_in_a_common_plane_cut_and_join() {
let block = block(200.0, 100.0, 20.0);
// A pocket 50 x 60 x 5 from the top face: 15 000 less.
let pocket = box_solid(Vec3::new(75.0, 20.0, 15.0), Vec3::new(125.0, 80.0, 20.0)).unwrap();
let (added, _, how) = change(&block, &pocket, BoolOp::Cut);
assert!((added + 15_000.0).abs() < 1e-6 && used(&how, BooleanStrategy::CoplanarSeparation), "{added}, {how:?}");
assert!((added + 15_000.0).abs() < 1e-6, "{added}, {how:?}");
// A pad 50 x 60 x 10 on the top face: 30 000 more.
let pad = box_solid(Vec3::new(75.0, 20.0, 20.0), Vec3::new(125.0, 80.0, 30.0)).unwrap();
let (added, lumps, how) = change(&block, &pad, BoolOp::Union);
assert!((added - 30_000.0).abs() < 1e-6 && lumps == 1 && used(&how, BooleanStrategy::CoplanarSeparation), "{added}, {how:?}");
assert!((added - 30_000.0).abs() < 1e-6 && lumps == 1, "{added}, {how:?}");
}
/// A disc joined into a round hole it fills (issue: "boolean Union: no result" for a 1 mm
@@ -92,8 +83,6 @@ fn a_disc_joined_into_the_hole_it_fills() {
let added = measure(&r).unwrap().volume - measure(&drilled).unwrap().volume;
assert!((added - want).abs() < want * 1e-3, "{z0}..{z1}: {added} vs {want}, {how:?}");
assert!(r.lumps().unwrap().len() == 1 && r.validity().is_empty(), "{z0}..{z1}: {:?}", r.validity());
// Exact: nothing of the tool is moved.
assert!(used(&how, BooleanStrategy::GrownBoss) && !used(&how, BooleanStrategy::ToolNudge), "{z0}..{z1}: {how:?}");
}
}
@@ -105,9 +94,8 @@ fn hemisphere(r: f64) -> Body {
}
/// A hemisphere (r 20) and a block (x 5 to 35, y -30 to 30, z 0 to 25) that both stand on z = 0, the block's foot over most of the
/// hemisphere's flat face. The neighbour of that face is the sphere, which cannot be stretched, so its face is grown instead (a
/// strip along its rim) and what the join leaves below the plane is cut off by editing faces. The block takes the part of the dome
/// beyond x = 5, half of a cap 15 high: the union is the dome and the block less that part.
/// hemisphere's flat face. The block takes the part of the dome beyond x = 5, half of a cap 15 high: the union is the dome and the
/// block less that part.
#[test]
fn a_block_flush_with_the_flat_face_of_a_hemisphere_joins_exactly() {
let (r, x0) = (20.0, 5.0);
@@ -116,7 +104,7 @@ fn a_block_flush_with_the_flat_face_of_a_hemisphere_joins_exactly() {
let h = r - x0;
let expected = 2.0 / 3.0 * std::f64::consts::PI * r.powi(3) - std::f64::consts::PI * h * h * (3.0 * r - h) / 6.0 + 30.0 * 60.0 * 25.0;
for (a, b) in [(&dome, &block), (&block, &dome)] {
let joined = crate::coplanar::boolean_grown(a, b, BoolOp::Union).expect("the route applies").unwrap().unwrap();
let joined = boolean(a, b, BoolOp::Union).unwrap().unwrap();
let v = measure(&joined).unwrap().volume;
assert!((v - expected).abs() < expected * 1e-4, "{v} against {expected}");
assert_eq!(joined.lumps().unwrap().len(), 1);
@@ -127,30 +115,3 @@ fn a_block_flush_with_the_flat_face_of_a_hemisphere_joins_exactly() {
assert!(lowest > -1e-6, "the join reaches down to z = {lowest}");
}
}
/// Growing the flat face of a dome past its plane and cutting what that adds off again gives the dome back: the same volume, the same
/// faces, nothing below the plane.
#[test]
fn what_growing_a_face_adds_is_cut_off_again_exactly() {
let dome = hemisphere(20.0);
let measured = measure(&dome).unwrap().volume;
let grown = crate::coplanar::grow_faces_exact(&dome, -Vec3::Z, 0.0, 3.0).unwrap();
assert!(measure(&grown).unwrap().volume > measured + 3.0 * 300.0, "the stub adds volume");
let back = crate::coplanar::trim_exact(&grown, -Vec3::Z, 0.0).unwrap();
let v = measure(&back).unwrap().volume;
assert!((v - measured).abs() < measured * 1e-6, "{v} against {measured}");
assert_eq!(back.face_count(), dome.face_count());
assert!(back.validity().is_empty(), "{:?}", back.validity());
let lowest = back.mesh(&crate::MeshTolerance::chord(0.05)).unwrap().positions.iter().map(|p| p.z).fold(f64::MAX, f64::min);
assert!(lowest > -1e-6, "the body reaches down to z = {lowest}");
}
/// A plane the body stands on has nothing beyond it, and one clear of the body is refused (nothing is cut off).
#[test]
fn trimming_at_a_plane_with_nothing_beyond_it() {
let dome = hemisphere(20.0);
let measured = measure(&dome).unwrap().volume;
let same = crate::coplanar::trim_exact(&dome, -Vec3::Z, 0.0).unwrap();
assert!((measure(&same).unwrap().volume - measured).abs() < measured * 1e-9);
assert!(crate::coplanar::trim_exact(&dome, -Vec3::Z, 5.0).is_err());
}
-113
View File
@@ -1,113 +0,0 @@
//! Flat faces that sit on B-spline surfaces become planes.
//!
//! A wall swept along a curve has a top and a bottom that are flat but are made of the same
//! B-spline surfaces as everything else the sweep makes. The coplanar handling of a boolean
//! (coincident faces pushed apart, faces glued back to back, the exact planar fallback) looks
//! for faces on true planes, so it never sees them: two such walls that share a top plane were
//! left to the general intersection, which fails on faces that coincide.
use solidcraft_geom::Vec3;
use truck_modeling as mt;
use crate::body::{Body, Solid, from_p3, p3};
use crate::{KernelError, guard};
/// Most control points looked at in one surface.
const MAX_CONTROL_POINTS: usize = 1_000_000;
/// The control points of a B-spline or NURBS surface, as points.
fn control_points(s: &mt::Surface) -> Option<Vec<Vec3>> {
match s {
mt::Surface::BSplineSurface(b) => {
let pts: Vec<Vec3> = b.control_points().iter().flatten().take(MAX_CONTROL_POINTS + 1).map(|p| from_p3(*p)).collect();
(pts.len() <= MAX_CONTROL_POINTS).then_some(pts)
}
mt::Surface::NurbsSurface(n) => {
let mut pts = Vec::new();
for v in n.control_points().iter().flatten() {
if !(v.w > 0.0) || pts.len() >= MAX_CONTROL_POINTS {
return None;
}
pts.push(Vec3::new(v.x / v.w, v.y / v.w, v.z / v.w));
}
Some(pts)
}
_ => None,
}
}
/// The least and the greatest `along · p` over the control points of a B-spline or NURBS surface; the surface lies
/// between them (it is in the convex hull of its control net). `None` for any other surface.
pub(crate) fn control_net_span(s: &mt::Surface, along: Vec3) -> Option<(f64, f64)> {
let pts = control_points(s)?;
pts.iter().map(|p| along.dot(*p)).fold(None, |acc, h| Some(acc.map_or((h, h), |(lo, hi): (f64, f64)| (lo.min(h), hi.max(h)))))
}
/// A point and the unit normal of the plane every point lies within `tol` of, if there is one.
fn common_plane(pts: &[Vec3], tol: f64) -> Option<(Vec3, Vec3)> {
let p0 = *pts.first()?;
let far = |from: Vec3, pick: &dyn Fn(Vec3) -> f64| pts.iter().copied().max_by(|a, b| pick(*a - from).total_cmp(&pick(*b - from)));
let p1 = far(p0, &|d| d.len())?;
let dir = (p1 - p0).normalized()?;
let p2 = far(p0, &|d| (d - dir * d.dot(dir)).len())?;
let n = (p1 - p0).cross(p2 - p0).normalized()?;
pts.iter().all(|p| n.dot(*p - p0).abs() <= tol).then_some((p0, n))
}
/// `b` with every face that is flat on a B-spline surface put on a plane; `None` when there is
/// no such face. The faces keep their order, their edges and their orientation.
pub(crate) fn planarized(b: &Body) -> Option<Body> {
if b.is_mesh() {
return None;
}
let tol = (b.size() * 1e-9).max(1e-9);
guard("planar faces", || {
use mt::{ParametricSurface, ParametricSurface3D};
let mut changed = 0usize;
let mut shells = Vec::new();
for sh in b.solid.boundaries() {
let mut faces: Vec<mt::Face> = Vec::new();
for f in sh.face_iter() {
let surface = f.surface();
let plane = control_points(&surface).and_then(|pts| common_plane(&pts, tol));
let Some((origin, mut n)) = plane else {
faces.push(f.clone());
continue;
};
// The plane faces the way the surface does; a surface with no normal at any of the
// places looked at is left as it is.
let (ur, vr) = surface.try_range_tuple();
let ((u0, u1), (v0, v1)) = (ur.unwrap_or((0.0, 1.0)), vr.unwrap_or((0.0, 1.0)));
let old = [(0.5, 0.5), (0.25, 0.25), (0.75, 0.75), (0.25, 0.75), (0.75, 0.25)]
.iter()
.map(|(s, t)| surface.normal(u0 + s * (u1 - u0), v0 + t * (v1 - v0)))
.find(|v| v.x.is_finite() && v.y.is_finite() && v.z.is_finite() && v.x * v.x + v.y * v.y + v.z * v.z > 1e-18);
let Some(old) = old else {
faces.push(f.clone());
continue;
};
if n.dot(Vec3::new(old.x, old.y, old.z)) < 0.0 {
n = -n;
}
let u = n.any_perp().normalized().ok_or_else(|| KernelError::Failed("planar faces: no axis".into()))?;
let w = n.cross(u);
let plane = mt::Plane::new(p3(origin), p3(origin + u), p3(origin + w));
let mut nf =
mt::Face::try_new(f.absolute_boundaries().to_vec(), mt::Surface::Plane(plane)).map_err(|e| KernelError::Failed(e.to_string()))?;
if !f.orientation() {
nf.invert();
}
faces.push(nf);
changed += 1;
}
shells.push(faces.into());
}
if changed == 0 {
return Ok(None);
}
let solid = Solid::try_new(shells).map_err(|e| KernelError::Failed(e.to_string()))?;
Ok(Some(Body::new(solid)?))
})
.ok()
.flatten()
}
+5 -46
View File
@@ -1,10 +1,8 @@
#![allow(clippy::unwrap_used, clippy::panic)]
//! A wall swept along a spiral has a flat top and bottom that are B-spline faces. They have to be
//! planes for the coplanar handling of a boolean to see them: a join of two such walls ran into
//! the general intersection with faces that coincide, and failed. With planes, the walls are prisms
//! and a join of two is decided in 2D (`prism.rs`).
//! A wall swept along a spiral has a flat top and bottom that are B-spline faces: a join of two
//! such walls, whose tops and bottoms coincide.
use std::f64::consts::{FRAC_PI_2, TAU};
use std::f64::consts::TAU;
use solidcraft_geom::{Loop2, Plane, Region2, Vec2, Vec3};
@@ -46,43 +44,6 @@ fn two_walls(turn: f64, stations: usize) -> (Body, Body) {
(spiral_wall(60.0, 0.0, turn, stations), spiral_wall(71.0, 0.26, turn, stations))
}
/// Faces that lie on a `Plane` surface (the face-type counts of `measure` also count flat splines).
fn plane_faces(b: &Body) -> usize {
b.solid.face_iter().filter(|f| matches!(f.surface(), truck_modeling::Surface::Plane(_))).count()
}
fn volume(b: &Body) -> f64 {
b.mesh(&crate::MeshTolerance::chord(1e-3)).unwrap().measure().volume
}
#[test]
fn the_flat_faces_of_a_swept_wall_become_planes() {
let wall = spiral_wall(60.0, 0.0, FRAC_PI_2, 8);
// Two end caps are planes already; the top and bottom are flat B-splines like the two sides.
assert_eq!(plane_faces(&wall), 2);
let flat = crate::planar_faces::planarized(&wall).expect("the top and the bottom are flat");
assert_eq!(plane_faces(&flat), 4);
assert_eq!(flat.face_count(), wall.face_count());
assert!(flat.validity().is_empty(), "{:?}", flat.validity());
let (before, after) = (volume(&wall), volume(&flat));
assert!((before - after).abs() < 1e-3 * before, "{before} vs {after}");
// Nothing flat is left to change, and a box has nothing to begin with.
assert!(crate::planar_faces::planarized(&flat).is_none());
assert!(crate::planar_faces::planarized(&box_solid(Vec3::ZERO, Vec3::new(1.0, 2.0, 3.0)).unwrap()).is_none());
}
#[test]
fn walls_that_share_a_top_and_a_bottom_coincide_only_once_those_are_planes() {
let (a, b) = two_walls(FRAC_PI_2, 8);
assert!(crate::coplanar::coincident_planes(&a, &b).is_empty());
let (pa, pb) = (crate::planar_faces::planarized(&a).unwrap(), crate::planar_faces::planarized(&b).unwrap());
let planes = crate::coplanar::coincident_planes(&pa, &pb);
assert_eq!(planes.len(), 2, "{planes:?}");
let mut heights: Vec<f64> = planes.iter().map(|(n, d, _)| d * n.z).collect();
heights.sort_by(f64::total_cmp);
assert!(heights[0].abs() < 1e-6 && (heights[1] - HEIGHT).abs() < 1e-6, "{heights:?}");
}
/// The plan area of the union of the walls (each given as `(radius, start)`), by sampling every
/// 0.4 mm: a point is in a wall if it is within half its thickness of the wall's centreline and
/// not beyond either end (flat ends).
@@ -122,7 +83,7 @@ fn free_form_faces(b: &Body) -> usize {
fn join_checked(a: &Body, b: &Body, walls: &[(f64, f64)], turn: f64) {
let joined = boolean(a, b, BoolOp::Union).unwrap().unwrap();
assert_eq!(joined.solid.boundaries().len(), 1);
assert_eq!(joined.shell_count(), 1);
assert!(joined.validity().is_empty(), "{:?}", joined.validity());
// Decided in 2D: the walls come back as arcs, not as splines.
assert_eq!(free_form_faces(&joined), 0);
@@ -131,9 +92,7 @@ fn join_checked(a: &Body, b: &Body, walls: &[(f64, f64)], turn: f64) {
assert!((got - want).abs() < 5e-3 * want, "{got} vs {want}");
}
/// The general intersection fails on these two walls (`no result`: their tops and bottoms coincide,
/// and as B-splines the coincident-face handling does not see them). With flat faces made planes
/// they are prisms, and the join is decided in 2D, with the walls fitted by arcs within a micron.
/// Two walls whose tops and bottoms coincide: the join is one solid with the walls as arcs.
#[test]
fn a_join_of_swept_walls_is_decided_in_2d() {
let turn = 0.6;
-119
View File
@@ -1,119 +0,0 @@
//! Faces that run along a seam edge twice: a whole turn of a cylinder or a cone bounded by one
//! wire, as STEP files write them (the wire goes round one end, along the seam, round the other
//! end and back along the seam). SolidCraft builds its own round faces in pieces with no such
//! seam, and a boolean that has to divide one of these faces can fail where the same bodies with
//! the face in two halves work: an intersection loop lying wholly inside the face is a hole in a
//! piece whose outer wire visits the seam's vertices twice, which the boolean refuses. So when the
//! first tries at the boolean of the bodies as they are fail, each such face is split in two along
//! a plane through its axis, a quarter turn either side of the seam (the solids are unchanged), and
//! the boolean is asked again; the result's history is composed back over the operands as they
//! were given.
use solidcraft_geom::{Plane, Vec3};
use crate::body::{Body, from_p3};
use crate::boolean_record as recorded;
use crate::history::BodyHistory;
use crate::history::History;
use crate::splitface::{SplitTool, split_any};
use crate::{BoolOp, FaceSurface, Result, face_surface};
/// Most faces split before the boolean.
const MAX_SPLITS: usize = 64;
thread_local! {
/// Set while the boolean of the split bodies runs (no second round of splitting).
static SPLITTING: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// The first face of `body` whose wire runs along a seam edge twice, on a cylinder or a cone,
/// with the plane that splits it in two: through its axis, square to the seam.
fn next_split(body: &Body) -> Option<(usize, Plane)> {
body.solid.face_iter().enumerate().find_map(|(i, face)| {
let mut seen = std::collections::HashSet::new();
let seam = face.absolute_boundaries().iter().flat_map(|w| w.edge_iter()).find(|e| !seen.insert(e.id()))?;
let (origin, axis) = match face_surface(body, i)? {
FaceSurface::Cylinder { origin, axis, .. } => (origin, axis),
FaceSurface::Cone { apex, axis, .. } => (apex, axis),
_ => return None,
};
let p = from_p3(seam.front().point());
let off = p - origin;
let radial: Vec3 = (off - axis * off.dot(axis)).normalized()?;
Some((i, Plane::from_normal(origin, radial)?))
})
}
/// Whether either body has a face for [`boolean`] to split.
pub(crate) fn needed(a: &Body, b: &Body) -> bool {
!a.is_mesh() && !b.is_mesh() && !SPLITTING.with(|c| c.get()) && (next_split(a).is_some() || next_split(b).is_some())
}
/// Whether `body` has a face for [`one_body`] to split.
pub(crate) fn needed_one(body: &Body) -> bool {
!body.is_mesh() && !SPLITTING.with(|c| c.get()) && next_split(body).is_some()
}
/// `body` with every face that runs along a seam edge twice split in two, and its history over
/// `body`. `None` when a split fails.
fn halved(body: &Body) -> Option<(Body, Option<History>)> {
let (mut x, mut h) = (body.clone(), Some(History::identity(body)));
for _ in 0..MAX_SPLITS {
let Some((face, plane)) = next_split(&x) else { return Some((x, h)) };
let (mut done, _) = split_any(&x, &[face], &SplitTool::Plane(plane)).ok()?;
// The split is no authored boundary: a later feature may still merge what it separates.
done.regularization_protected = x.regularization_protected;
let through = recorded::through(&done, 0, h.as_ref());
(x, h) = (done, through);
}
next_split(&x).is_none().then_some((x, h))
}
/// The boolean `op` of `a` and `b` with their faces that run along a seam edge twice split in
/// two, `run` doing the boolean of the split bodies. `None` when neither body has such a face, a
/// split fails, or the boolean of the split bodies gave no (checked) result: the caller then
/// keeps what it had.
pub(crate) fn boolean(a: &Body, b: &Body, op: BoolOp, run: &dyn Fn(&Body, &Body, BoolOp) -> Result<Option<Body>>) -> Option<Result<Option<Body>>> {
if !needed(a, b) {
return None;
}
let ((x, hx), (y, hy)) = (halved(a)?, halved(b)?);
SPLITTING.with(|c| c.set(true));
let result = crate::guard("boolean of faces split off their seams", || run(&x, &y, op));
SPLITTING.with(|c| c.set(false));
let Ok(result) = result else { return None };
// Held to what a boolean can do, as a retry's result is (see `boolean_guard`).
crate::boolean_guard::check_retry(a, b, op, result.as_ref()).ok()?;
let counts = crate::record::counts(&[a, b]);
Some(Ok(result.map(|body| {
// Each step's history over the operands `[a, b]`.
let history = recorded::of_pair(&body, &recorded::placed(hx.clone(), 0, 2), &recorded::placed(hy.clone(), 1, 2));
body.with_checked_history(history, &counts)
})))
}
/// `run` over `body` with every face that runs along a seam edge twice split in two, for an
/// operation that takes one body. `None` when the body has no such face, a split fails, or the
/// run over the split body fails: the caller then keeps the error it had.
///
/// The same answer as [`boolean`] gives, for the same reason — an operation that has to divide
/// such a face can fail where the body with the face in two halves works — and it matters most
/// to a body read from a STEP file, which is where a whole-turn face comes from. The result's
/// history is composed back over `body` as it was given, so a caller that knows `body`'s faces
/// still recognises them: the split is no authored boundary.
pub(crate) fn one_body(body: &Body, what: &str, run: &dyn Fn(&Body) -> Result<Body>) -> Option<Result<Body>> {
if !needed_one(body) {
return None;
}
let (split, over) = halved(body)?;
SPLITTING.with(|c| c.set(true));
let result = crate::guard(what, || run(&split));
SPLITTING.with(|c| c.set(false));
let out = result.ok()?;
let history = recorded::through(&out, 0, over.as_ref());
Some(Ok(out.with_checked_history(history, &crate::record::counts(&[body]))))
}
#[cfg(test)]
#[path = "seam_faces_tests.rs"]
mod tests;
-122
View File
@@ -1,122 +0,0 @@
//! Round faces read from STEP: a whole turn bounded by one wire that runs along its seam twice.
//! A boolean whose intersection loop lies wholly inside such a face takes it in two halves
//! (exactly), where it used to end in a nudged tool or fail.
use crate::*;
use solidcraft_geom::{Loop2, Plane, Region2, Vec2, Vec3};
use std::f64::consts::PI;
const R: f64 = 4.5;
fn volume(b: &Body) -> f64 {
measure(b).unwrap().volume
}
fn rel(a: f64, b: f64) -> f64 {
(a - b).abs() / b.abs().max(1e-12)
}
/// `body` written to STEP and read back: its round faces come back whole, as other systems
/// write them.
fn through_step(body: &Body) -> Body {
step_import(&step_export(&[body], "t").unwrap()).unwrap().bodies.into_iter().next().unwrap().body
}
/// A pin of radius `R` along X, 40 long, centred on the origin.
fn pin() -> Body {
cylinder(Vec3::new(-20.0, 0.0, 0.0), Vec3::X, R, 40.0).unwrap()
}
/// A block pressed into the top of the pin's side: 10 long, 6 wide, from 2 above the axis up.
fn block() -> Body {
box_solid(Vec3::new(-5.0, -3.0, 2.0), Vec3::new(5.0, 3.0, 9.0)).unwrap()
}
/// The part of the pin inside [`block`]: the circle's segment above 2 and within 3 of the
/// middle, 10 long.
fn pressed_volume() -> f64 {
let segment = 3.0 * (R * R - 9.0).sqrt() + R * R * (3.0 / R).asin() - 6.0 * 2.0;
10.0 * segment
}
/// The boolean, which must be exact: no retry that moves the tool or ignores part of where the
/// bodies meet.
fn exact(a: &Body, b: &Body, op: BoolOp) -> Option<Body> {
let (r, report) = capture_boolean_diagnostics(|| boolean(a, b, op));
assert!(report.warning().is_none(), "{op:?}: {:?}", report.warning());
r.unwrap()
}
#[test]
fn a_pin_read_from_step_has_one_face_along_its_seam() {
let read = through_step(&pin());
assert_eq!(read.face_count(), 3);
assert!(super::needed(&read, &block()));
assert!(!super::needed(&pin(), &block()));
// Split in two halves, with the same solid.
let (halved, history) = super::halved(&read).unwrap();
assert!(super::next_split(&halved).is_none() && halved.face_count() > 3);
assert!(history.is_some());
assert!(rel(volume(&halved), PI * R * R * 40.0) < 1e-3);
}
/// Offset Face on a body read from STEP, where the hole's wall is one face along its seam.
///
/// Healing such a body, which Offset Face does before it moves anything, fails inside `truck`
/// ("This wire is not simple"), so the top could not be moved at all — and importing is how a
/// part with a hole usually arrives. The offset now splits the face off its seam and asks again,
/// as a boolean does.
#[test]
fn the_top_of_a_read_block_with_a_hole_moves() {
// A 20 x 20 x 10 block with a 6 mm hole through the middle of it.
let block = box_solid(Vec3::ZERO, Vec3::new(20.0, 20.0, 10.0)).unwrap();
let drill = cylinder(Vec3::new(10.0, 10.0, -5.0), Vec3::Z, 3.0, 20.0).unwrap();
let built = boolean(&block, &drill, BoolOp::Cut).unwrap().unwrap();
let read = through_step(&built);
// Read back, the hole's wall is one whole turn instead of the two halves it was built as.
assert!(read.face_count() < built.face_count());
assert!(super::needed_one(&read) && !super::needed_one(&built));
// The top, 2 mm up: the part grows by the plate's section, the hole still through it.
let section = 400.0 - PI * 9.0;
let moved = offset_faces(&read, &[Vec3::new(5.0, 5.0, 10.0)], 2.0).unwrap();
assert!(rel(volume(&moved), volume(&read) + section * 2.0) < 1e-3, "{} {}", volume(&moved), volume(&read) + section * 2.0);
// The same answer as moving the built body's top, which never needed the split.
let straight = offset_faces(&built, &[Vec3::new(5.0, 5.0, 10.0)], 2.0).unwrap();
assert!(rel(volume(&moved), volume(&straight)) < 1e-5, "{} {}", volume(&moved), volume(&straight));
// And the faces still say where they came from, so names and sketches survive the retry.
assert!(moved.history().is_some());
}
#[test]
fn a_block_pressed_into_a_read_pins_side_is_intersected_and_cut_exactly() {
let (read, block) = (through_step(&pin()), block());
let want = pressed_volume();
let shared = exact(&read, &block, BoolOp::Intersect).unwrap();
assert!(rel(volume(&shared), want) < 1e-3, "{} {want}", volume(&shared));
let dented = exact(&read, &block, BoolOp::Cut).unwrap();
assert!(rel(volume(&dented), PI * R * R * 40.0 - want) < 1e-3, "{}", volume(&dented));
// The operands' faces carry over: the dented pin's round face is still the pin's.
assert!(dented.history().is_some());
}
#[test]
fn a_ball_pressed_into_a_read_pins_side_matches_the_built_pin() {
let ball = sphere(Vec3::new(3.0, 1.0, 5.5), 3.0).unwrap();
let built = volume(&boolean(&pin(), &ball, BoolOp::Intersect).unwrap().unwrap());
let read = volume(&exact(&through_step(&pin()), &ball, BoolOp::Intersect).unwrap());
assert!(rel(read, built) < 1e-3, "{read} {built}");
}
/// A cone's side read from STEP is one face along its seam as well.
#[test]
fn a_block_pressed_into_a_read_cones_side_matches_the_built_cone() {
let outline =
Region2 { outer: Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(8.0, 0.0), Vec2::new(5.0, 20.0), Vec2::new(0.0, 20.0)]), holes: vec![] };
let cone = revolve(&Plane::YZ, &[outline], Vec2::ZERO, Vec2::Y, 2.0 * PI).unwrap().pop().unwrap();
let read = through_step(&cone);
assert!(super::needed(&read, &cone));
let block = box_solid(Vec3::new(-2.0, 4.0, 6.0), Vec3::new(2.0, 12.0, 12.0)).unwrap();
let built = volume(&boolean(&cone, &block, BoolOp::Intersect).unwrap().unwrap());
let read = volume(&exact(&read, &block, BoolOp::Intersect).unwrap());
assert!(rel(read, built) < 1e-3, "{read} {built}");
}