solidcraft-smlib: booleans, fillet, chamfer and shell through SMLib

Brep::boolean (union, subtract, intersect; Ok(None) for an empty result), Brep::solid_count,
Brep::fillet, Brep::chamfer and Brep::shell, over the fork's smlib_ops.cpp shims (ffi/ops.rs).
Edges and faces are chosen by points, each the nearest within a tolerance; a point that
selects nothing or cannot decide is Error::NoMatch / Error::Ambiguous with its index, and a
solid input that gives anything but a closed solid is Error::NotSolid. Fillets and chamfers
take whole tangent chains; a chamfer distance is the setback along each face; a shell's
thickness is negative inward.

The C API with these shims is version 2 (the fork's capi-flat line), so ABI_VERSION is 2.

Tests (ops_tests.rs) against analytic volumes and counts, SMLib's own check at every level on
each result, and history through a tagged box minus box: union 9000 mm3 [11, 24, 16]; slot
6000 [10, 24, 16]; intersection 1000; empty results; a split body (2 solids); a cube with all
12 edges rounded r = 2 against the Minkowski formula; one chamfer (7910, [7, 15, 10]); shells
open at the top inward (3392), outward (4672) and closed; a tangent chain from one pick equal
to picking all three edges; hostile sizes, tolerances, points and counts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT
This commit is contained in:
jyviko
2026-10-21 01:36:36 -04:00
parent c35fd77f61
commit 734883bdb5
6 changed files with 456 additions and 2 deletions
+1 -1
View File
@@ -1 +1 @@
0a48843fd764b0b4a6e8998716e800a91518fe7a
5fe723c7f0b9275064ffb1995d6c065c4ea64856
+1
View File
@@ -183,6 +183,7 @@ pub mod check;
pub mod flat;
pub mod history;
pub mod measure;
pub mod ops;
/// Where [`set_log`] sends SMLib's log lines.
#[cfg(feature = "native")]
+114
View File
@@ -0,0 +1,114 @@
//! The operations block of `capi/include/smlib.h` (`smlib_boolean`, `smlib_fillet`,
//! `smlib_chamfer`, `smlib_shell`, `smlib_brep_solid_count`; the fork's `capi/src/smlib_ops.cpp`),
//! declared by hand. Part of the FFI boundary, like its parent: called only through
//! [`crate::ops`].
#![allow(unsafe_code)]
use super::Handle;
#[cfg(not(feature = "native"))]
use super::NOT_BUILT;
#[cfg(feature = "native")]
use super::{ERR_ARG, OK, RawBrep, new_brep};
pub const ERR_NOT_SOLID: i32 = -1100;
pub const ERR_PICK_NONE: i32 = -1101;
pub const ERR_PICK_AMBIGUOUS: i32 = -1102;
pub const BOOLEAN_UNION: i32 = 0;
pub const BOOLEAN_SUBTRACT: i32 = 1;
pub const BOOLEAN_INTERSECT: i32 = 2;
/// Which selection operation: the C functions share one signature.
#[derive(Clone, Copy)]
pub enum Pick {
Fillet,
Chamfer,
Shell,
}
/// A failed selection operation: the status, and the index of the point that selected nothing
/// or several things (only for `ERR_PICK_*`).
pub type PickError = (i32, Option<usize>);
#[cfg(feature = "native")]
type PickFn = unsafe extern "C" fn(*const RawBrep, *const f64, i64, f64, f64, *mut *mut RawBrep, *mut i64) -> i32;
#[cfg(feature = "native")]
unsafe extern "C" {
fn smlib_boolean(a: *const RawBrep, b: *const RawBrep, op: i32, out: *mut *mut RawBrep) -> i32;
fn smlib_brep_solid_count(brep: *const RawBrep, out: *mut i64) -> i32;
fn smlib_fillet(
brep: *const RawBrep,
points: *const f64,
n_points: i64,
tolerance: f64,
radius: f64,
out: *mut *mut RawBrep,
out_failed_point: *mut i64,
) -> i32;
fn smlib_chamfer(
brep: *const RawBrep,
points: *const f64,
n_points: i64,
tolerance: f64,
distance: f64,
out: *mut *mut RawBrep,
out_failed_point: *mut i64,
) -> i32;
fn smlib_shell(
brep: *const RawBrep,
points: *const f64,
n_points: i64,
tolerance: f64,
thickness: f64,
out: *mut *mut RawBrep,
out_failed_point: *mut i64,
) -> i32;
}
#[cfg(feature = "native")]
pub fn boolean(a: &Handle, b: &Handle, op: i32) -> Result<Handle, i32> {
new_brep(|out| unsafe { smlib_boolean(a.raw(), b.raw(), op, out) })
}
#[cfg(feature = "native")]
pub fn solid_count(b: &Handle) -> Result<i64, i32> {
let mut n = 0i64;
match unsafe { smlib_brep_solid_count(b.raw(), &mut n) } {
OK => Ok(n),
s => Err(s),
}
}
/// Runs a selection operation; `points` are passed as the flat x, y, z array the C API reads.
#[cfg(feature = "native")]
pub fn pick_op(kind: Pick, b: &Handle, points: &[[f64; 3]], tolerance: f64, size: f64) -> Result<Handle, PickError> {
let n = i64::try_from(points.len()).map_err(|_| (ERR_ARG, None))?;
let f: PickFn = match kind {
Pick::Fillet => smlib_fillet,
Pick::Chamfer => smlib_chamfer,
Pick::Shell => smlib_shell,
};
let flat = points.as_ptr().cast::<f64>();
let mut failed = -1i64;
new_brep(|out| unsafe { f(b.raw(), flat, n, tolerance, size, out, &mut failed) }).map_err(|s| {
let at = (s == ERR_PICK_NONE || s == ERR_PICK_AMBIGUOUS).then(|| usize::try_from(failed).ok()).flatten();
(s, at)
})
}
#[cfg(not(feature = "native"))]
pub fn boolean(_: &Handle, _: &Handle, _: i32) -> Result<Handle, i32> {
Err(NOT_BUILT)
}
#[cfg(not(feature = "native"))]
pub fn solid_count(_: &Handle) -> Result<i64, i32> {
Err(NOT_BUILT)
}
#[cfg(not(feature = "native"))]
pub fn pick_op(_: Pick, _: &Handle, _: &[[f64; 3]], _: f64, _: f64) -> Result<Handle, PickError> {
Err((NOT_BUILT, None))
}
+13 -1
View File
@@ -22,9 +22,11 @@ pub mod flat;
pub mod history;
pub mod import;
mod measure;
mod ops;
pub use check::{CheckLevel, CheckReport};
pub use measure::{MEASURE_ACCURACY, MassProperties};
pub use ops::{BooleanOp, MAX_POINTS};
/// Why an SMLib call failed.
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -33,8 +35,14 @@ pub enum Error {
Status(i32),
/// An argument the C API rejected before SMLib saw it.
Argument,
/// SMLib reported success but returned no body.
/// SMLib reported success but returned no body (for a boolean: the result is empty).
Empty,
/// SMLib reported success, but a solid input gave a result that is not a closed solid.
NotSolid,
/// Selection point `point` is farther than the tolerance from every edge (face).
NoMatch { point: usize },
/// Selection point `point` is equally near to two edges (faces).
Ambiguous { point: usize },
/// A C++ exception (most likely out of memory) was caught at the boundary.
Exception,
/// The C API is a different version from the one this crate was written against.
@@ -52,6 +60,9 @@ impl std::fmt::Display for Error {
Error::Status(s) => write!(f, "SMLib failed (status {s})"),
Error::Argument => write!(f, "invalid argument for SMLib"),
Error::Empty => write!(f, "SMLib returned an empty body"),
Error::NotSolid => write!(f, "SMLib's result is not a closed solid"),
Error::NoMatch { point } => write!(f, "selection point {} is not near any edge or face", point + 1),
Error::Ambiguous { point } => write!(f, "selection point {} is equally near to two edges or faces", point + 1),
Error::Exception => write!(f, "SMLib raised an internal exception"),
Error::Version(v) => write!(f, "SMLib C API version {v}, expected {}", ffi::ABI_VERSION),
Error::NotBuilt => write!(f, "not supported yet: this build has no SMLib"),
@@ -68,6 +79,7 @@ fn error(s: i32) -> Error {
match s {
ffi::ERR_ARG | ffi::ERR_BUFFER => Error::Argument,
ffi::ERR_EMPTY => Error::Empty,
ffi::ops::ERR_NOT_SOLID => Error::NotSolid,
ffi::ERR_EXCEPTION => Error::Exception,
ffi::NOT_BUILT => Error::NotBuilt,
s => Error::Status(s),
+85
View File
@@ -0,0 +1,85 @@
//! SMLib's modelling operations: booleans, fillet, chamfer and shell. Each returns a new body
//! and leaves its inputs unchanged.
//!
//! Edges and faces are chosen by points, never by SMLib's internal order: each point selects the
//! edge (face) nearest to it, which must lie within `tolerance` of it and be nearer than any other
//! by more than the body's modelling tolerance ([`Error::NoMatch`], [`Error::Ambiguous`]).
use crate::{Brep, Error, Result, error, ffi, init};
/// How [`Brep::boolean`] combines two bodies.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BooleanOp {
Union,
/// The first body minus the second.
Subtract,
Intersect,
}
/// Most points one selection may hold (the C API's limit).
pub const MAX_POINTS: usize = 4096;
fn pick_error((s, at): ffi::ops::PickError) -> Error {
match (s, at) {
(ffi::ops::ERR_PICK_NONE, Some(point)) => Error::NoMatch { point },
(ffi::ops::ERR_PICK_AMBIGUOUS, Some(point)) => Error::Ambiguous { point },
(s, _) => error(s),
}
}
impl Brep {
/// `self` and `other` combined by `op`: `None` when the result is empty (disjoint bodies
/// intersected, a body subtracted by one that encloses it). Several separate solids come back
/// as one body, counted by [`Brep::solid_count`]. Tagged operands ([`Brep::tagged`]) give a
/// result whose [`Brep::history`] says where each face and edge came from.
pub fn boolean(&self, other: &Brep, op: BooleanOp) -> Result<Option<Brep>> {
init()?;
let op = match op {
BooleanOp::Union => ffi::ops::BOOLEAN_UNION,
BooleanOp::Subtract => ffi::ops::BOOLEAN_SUBTRACT,
BooleanOp::Intersect => ffi::ops::BOOLEAN_INTERSECT,
};
match ffi::ops::boolean(&self.0, &other.0, op) {
Ok(h) => Ok(Some(Brep(h))),
Err(ffi::ERR_EMPTY) => Ok(None),
Err(s) => Err(error(s)),
}
}
/// The number of separate solid regions (voids not counted).
pub fn solid_count(&self) -> Result<usize> {
ffi::ops::solid_count(&self.0).map(|n| usize::try_from(n).unwrap_or(0)).map_err(error)
}
/// The edges nearest to `edges` (each within `tolerance`), with their tangent chains, rounded
/// at `radius`.
pub fn fillet(&self, edges: &[[f64; 3]], tolerance: f64, radius: f64) -> Result<Brep> {
self.pick_op(ffi::ops::Pick::Fillet, edges, tolerance, radius, false)
}
/// The edges nearest to `edges` (each within `tolerance`), with their tangent chains, cut by
/// an equal-distance chamfer `distance` back along each face (measured at each edge's
/// midpoint), with bevelled corners.
pub fn chamfer(&self, edges: &[[f64; 3]], tolerance: f64, distance: f64) -> Result<Brep> {
self.pick_op(ffi::ops::Pick::Chamfer, edges, tolerance, distance, false)
}
/// Hollowed to a wall of `thickness` (negative: inside the original surface, positive:
/// outside), open at the faces nearest to `open_faces` (each within `tolerance`); none gives
/// a closed hollow body.
pub fn shell(&self, open_faces: &[[f64; 3]], tolerance: f64, thickness: f64) -> Result<Brep> {
self.pick_op(ffi::ops::Pick::Shell, open_faces, tolerance, thickness, true)
}
fn pick_op(&self, kind: ffi::ops::Pick, points: &[[f64; 3]], tolerance: f64, size: f64, allow_none: bool) -> Result<Brep> {
init()?;
if points.len() > MAX_POINTS || (points.is_empty() && !allow_none) {
return Err(Error::Argument);
}
ffi::ops::pick_op(kind, &self.0, points, tolerance, size).map(Brep).map_err(pick_error)
}
}
#[cfg(test)]
#[path = "ops_tests.rs"]
mod tests;
+242
View File
@@ -0,0 +1,242 @@
//! The operations against analytic volumes and topology counts (built with SMLib only). `SMLIB_OPS_SHOTS=<dir>` also
//! writes every result there as a native `.smb`, to render.
#![cfg(feature = "native")]
use super::*;
use crate::CheckLevel;
/// Relative accuracy of SMLib's volume integration in these tests.
const ACC: f64 = 1e-7;
fn cube(a: f64) -> Brep {
Brep::make_box([0.0; 3], [a; 3]).unwrap()
}
fn volume(b: &Brep) -> f64 {
b.volume(ACC).unwrap()
}
fn assert_close(got: f64, want: f64, rel: f64, what: &str) {
assert!((got - want).abs() <= rel * want.abs(), "{what}: {got} vs analytic {want} (rel {:.3e})", (got - want).abs() / want.abs());
}
/// SMLib's own check, every test, finds nothing wrong with `b`; then, when `SMLIB_OPS_SHOTS` is
/// set, `b` is written there as `<name>.smb`.
fn shot(b: &Brep, name: &str) {
let report = b.check(CheckLevel::All).unwrap();
assert!(report.is_valid(), "{name}: {} failed validity tests:\n{}", report.failures, report.text);
if let Some(dir) = std::env::var_os("SMLIB_OPS_SHOTS") {
let dir = std::path::PathBuf::from(dir);
std::fs::create_dir_all(&dir).unwrap();
b.write(&dir.join(format!("{name}.smb")), false).unwrap();
}
}
/// The midpoints of the 12 edges of the cube [0, a]³.
fn cube_edge_midpoints(a: f64) -> Vec<[f64; 3]> {
let h = a / 2.0;
let mut v = Vec::new();
for (p, q) in [(0.0, 0.0), (a, 0.0), (0.0, a), (a, a)] {
v.push([h, p, q]);
v.push([p, h, q]);
v.push([p, q, h]);
}
v
}
/// The inputs of an operation are left as they were.
fn assert_untouched(b: &Brep, vol: f64, counts: [usize; 3]) {
assert_close(volume(b), vol, 1e-9, "input volume");
assert_eq!(b.counts().unwrap(), counts, "input counts");
}
#[test]
fn union_of_overlapping_boxes() {
let a = cube(20.0);
let b = Brep::make_box([10.0, 5.0, 5.0], [20.0, 10.0, 10.0]).unwrap();
let u = a.boolean(&b, BooleanOp::Union).unwrap().unwrap();
shot(&u, "union");
// 20³ + 20·10·10 − the 10·10·10 overlap.
assert_close(volume(&u), 9000.0, 1e-9, "union volume");
// A's 6 faces (+x now with a hole) and B's 5 outside it; A's 12 edges, B's 8 outside and the
// 4 where B passes through +x; A's 8 vertices and B's 8 outside.
assert_eq!(u.counts().unwrap(), [11, 24, 16]);
assert_eq!(u.solid_count().unwrap(), 1);
assert_untouched(&a, 8000.0, [6, 12, 8]);
assert_untouched(&b, 2000.0, [6, 12, 8]);
}
#[test]
fn subtract_cuts_a_slot() {
let a = cube(20.0);
// A 10 mm wide, 10 mm deep slot across the top, from y = −1 to 21.
let tool = Brep::make_box([5.0, -1.0, 10.0], [10.0, 22.0, 15.0]).unwrap();
let s = a.boolean(&tool, BooleanOp::Subtract).unwrap().unwrap();
shot(&s, "subtract_slot");
assert_close(volume(&s), 8000.0 - 10.0 * 20.0 * 10.0, 1e-9, "slot volume");
// A prism over a U of 8 corners: 8 sides + 2 ends, 24 edges, 16 vertices.
assert_eq!(s.counts().unwrap(), [10, 24, 16]);
assert_eq!(s.solid_count().unwrap(), 1);
assert_untouched(&a, 8000.0, [6, 12, 8]);
}
#[test]
fn intersect_keeps_the_overlap() {
let a = cube(20.0);
let b = Brep::make_box([10.0, 5.0, 5.0], [20.0, 10.0, 10.0]).unwrap();
let i = a.boolean(&b, BooleanOp::Intersect).unwrap().unwrap();
shot(&i, "intersect");
assert_close(volume(&i), 1000.0, 1e-9, "intersection volume");
assert_eq!(i.counts().unwrap(), [6, 12, 8]);
}
#[test]
fn empty_and_split_boolean_results() {
let a = cube(10.0);
let far = Brep::make_box([50.0, 0.0, 0.0], [10.0, 10.0, 10.0]).unwrap();
assert!(a.boolean(&far, BooleanOp::Intersect).unwrap().is_none(), "disjoint intersection");
let big = Brep::make_box([-5.0; 3], [20.0; 3]).unwrap();
assert!(a.boolean(&big, BooleanOp::Subtract).unwrap().is_none(), "subtracting an enclosing body");
let two = a.boolean(&far, BooleanOp::Union).unwrap().unwrap();
shot(&two, "union_disjoint");
assert_eq!(two.solid_count().unwrap(), 2, "a union of disjoint boxes is two solids in one body");
assert_close(volume(&two), 2000.0, 1e-9, "disjoint union volume");
// A body through a slot splits in two.
let bar = cube(20.0);
let cut = Brep::make_box([8.0, -1.0, -1.0], [4.0, 22.0, 22.0]).unwrap();
let halves = bar.boolean(&cut, BooleanOp::Subtract).unwrap().unwrap();
shot(&halves, "subtract_split");
assert_eq!(halves.solid_count().unwrap(), 2);
assert_close(volume(&halves), 8000.0 - 4.0 * 400.0, 1e-9, "split volume");
}
#[test]
fn fillet_every_edge_of_a_cube() {
let (a, r) = (20.0, 2.0);
let c = cube(a);
let f = c.fillet(&cube_edge_midpoints(a), 0.1, r).unwrap();
shot(&f, "fillet_all_edges");
// The rounded cube is the Minkowski sum of the inner cube b = a − 2r with a ball of radius r:
// b³ (core) + 6·b²·r (face slabs) + 12·(π r²/4)·b (quarter cylinders on the edges)
// + 8·(π r³/6) (sphere octants at the corners) = 7804.6961… for a = 20, r = 2.
let b = a - 2.0 * r;
let pi = std::f64::consts::PI;
let want = b.powi(3) + 6.0 * b * b * r + 3.0 * pi * r * r * b + 4.0 / 3.0 * pi * r.powi(3);
let got = volume(&f);
eprintln!("filleted cube: volume {got} (analytic {want}), counts {:?}", f.counts().unwrap());
assert_close(got, want, 1e-6, "rounded cube volume");
// 6 planar faces, 12 edge rounds, 8 corner patches.
assert_eq!(f.counts().unwrap()[0], 26);
assert_untouched(&c, 8000.0, [6, 12, 8]);
}
#[test]
fn chamfer_one_edge() {
let c = cube(20.0);
// The edge y = 20, z = 20, set back 3 mm along both faces: a prism of 3·3/2 × 20 goes.
let ch = c.chamfer(&[[10.0, 20.0, 20.0]], 0.1, 3.0).unwrap();
shot(&ch, "chamfer_one_edge");
assert_close(volume(&ch), 8000.0 - 4.5 * 20.0, 1e-9, "chamfered volume");
// One face more; the edge becomes two edges joined by two new ones; 2 vertices more.
assert_eq!(ch.counts().unwrap(), [7, 15, 10]);
assert_untouched(&c, 8000.0, [6, 12, 8]);
}
#[test]
fn shell_a_box_open_at_the_top() {
let c = cube(20.0);
let top = [[10.0, 10.0, 20.0]];
let inward = c.shell(&top, 0.1, -2.0).unwrap();
shot(&inward, "shell_inward");
// The cavity is 16 × 16 × 18 (the floor is 2 thick, the top open).
assert_close(volume(&inward), 8000.0 - 16.0 * 16.0 * 18.0, 1e-9, "inward shell volume");
// Outside 5, inside 5, the rim 1.
assert_eq!(inward.counts().unwrap()[0], 11);
let outward = c.shell(&top, 0.1, 2.0).unwrap();
shot(&outward, "shell_outward");
// The outside grows to [−2, 22]² × [−2, 20] around the original box, which becomes the cavity.
assert_close(volume(&outward), 24.0 * 24.0 * 22.0 - 8000.0, 1e-9, "outward shell volume");
let closed = c.shell(&[], 0.1, -2.0).unwrap();
shot(&closed, "shell_closed");
assert_close(volume(&closed), 8000.0 - 16.0f64.powi(3), 1e-9, "closed hollow volume");
assert_untouched(&c, 8000.0, [6, 12, 8]);
}
#[test]
fn fillet_follows_a_tangent_chain() {
// Round the vertical edge x = y = 20 first (r = 5): the top rim is then line, arc, line,
// meeting tangentially.
let c = cube(20.0);
let round = c.fillet(&[[20.0, 20.0, 10.0]], 0.1, 5.0).unwrap();
shot(&round, "fillet_one_edge");
let pi = std::f64::consts::PI;
assert_close(volume(&round), 8000.0 - (1.0 - pi / 4.0) * 25.0 * 20.0, 1e-6, "one rounded edge");
// One point on the straight part of the rim rounds the whole chain.
let chain = round.fillet(&[[7.5, 20.0, 20.0]], 0.1, 1.0).unwrap();
shot(&chain, "fillet_tangent_chain");
let arc = [15.0 + 5.0 * (pi / 4.0).cos(), 15.0 + 5.0 * (pi / 4.0).sin(), 20.0];
let all = round.fillet(&[[7.5, 20.0, 20.0], arc, [20.0, 7.5, 20.0]], 0.1, 1.0).unwrap();
shot(&all, "fillet_chain_picked");
assert_eq!(chain.counts().unwrap(), all.counts().unwrap(), "one pick = the whole chain");
assert_close(volume(&chain), volume(&all), 1e-9, "one pick = the whole chain");
assert_eq!(chain.counts().unwrap()[0], round.counts().unwrap()[0] + 3, "three rounds on the chain");
}
#[test]
fn hostile_inputs_are_errors() {
let c = cube(20.0);
let mid = [[10.0, 20.0, 20.0]];
for r in [f64::NAN, f64::INFINITY, -1.0, 0.0, 1e7] {
assert_eq!(c.fillet(&mid, 0.1, r).unwrap_err(), Error::Argument, "radius {r}");
assert_eq!(c.chamfer(&mid, 0.1, r).unwrap_err(), Error::Argument, "distance {r}");
}
for t in [f64::NAN, 0.0, -1.0, f64::INFINITY] {
assert_eq!(c.fillet(&mid, t, 1.0).unwrap_err(), Error::Argument, "tolerance {t}");
}
assert_eq!(c.fillet(&[[f64::NAN, 0.0, 0.0]], 0.1, 1.0).unwrap_err(), Error::Argument);
assert_eq!(c.fillet(&[], 0.1, 1.0).unwrap_err(), Error::Argument);
assert_eq!(c.fillet(&vec![[10.0, 20.0, 20.0]; MAX_POINTS + 1], 0.1, 1.0).unwrap_err(), Error::Argument);
// A second point far from every edge.
assert_eq!(c.fillet(&[mid[0], [100.0, 100.0, 100.0]], 0.1, 1.0).unwrap_err(), Error::NoMatch { point: 1 });
assert_eq!(c.shell(&[[10.0, 10.0, 25.0]], 0.1, -1.0).unwrap_err(), Error::NoMatch { point: 0 });
// A corner is equally near three edges, an edge equally near two faces.
assert_eq!(c.fillet(&[[20.0, 20.0, 20.0]], 0.1, 1.0).unwrap_err(), Error::Ambiguous { point: 0 });
assert_eq!(c.shell(&mid, 0.1, -1.0).unwrap_err(), Error::Ambiguous { point: 0 });
for t in [0.0, f64::NAN, -1e7] {
assert_eq!(c.shell(&[[10.0, 10.0, 20.0]], 0.1, t).unwrap_err(), Error::Argument, "thickness {t}");
}
// Sizes SMLib cannot build fail as errors, whatever SMLib says.
for (what, r) in [
("radius larger than the cube", c.fillet(&mid, 0.1, 50.0)),
("chamfer past the faces", c.chamfer(&mid, 0.1, 30.0)),
("wall thicker than the box", c.shell(&[[10.0, 10.0, 20.0]], 0.1, -15.0)),
] {
match r {
Ok(b) => panic!("{what}: SMLib built a body of volume {:?}, counts {:?}", b.volume(ACC), b.counts()),
Err(e) => eprintln!("{what}: {e:?}"),
}
}
assert_untouched(&c, 8000.0, [6, 12, 8]);
}
#[test]
fn boolean_history_through_tagged_operands() {
// A slot across the top of the cube: the cube's 6 faces give 7 result faces (the top is cut
// in two), the tool gives the slot's 2 walls and floor; no face is new.
let a = cube(20.0).tagged(0).unwrap();
let tool = Brep::make_box([5.0, -1.0, 10.0], [10.0, 22.0, 15.0]).unwrap().tagged(1).unwrap();
let s = a.boolean(&tool, BooleanOp::Subtract).unwrap().unwrap();
shot(&s, "subtract_slot_tagged");
let h = s.history().unwrap();
assert_eq!(h.faces.len(), 10);
let mut from = [0usize; 2];
for f in &h.faces {
assert!(!f.origins.is_empty(), "{f:?}");
for &(operand, _) in &f.origins {
from[operand.min(1)] += 1;
}
}
assert_eq!(from, [7, 3], "{h:?}");
// The 8 edges where the slot's walls and floor meet the top, front and back are new.
assert_eq!(h.edges.iter().filter(|e| e.origins.is_empty()).count(), 8, "{h:?}");
}