mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 02:25:55 +00:00
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:
File diff suppressed because it is too large
Load Diff
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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}");
|
||||
}
|
||||
Reference in New Issue
Block a user