Files
solidcraft-upstream/crates/kernel/src/boolean_history_tests.rs
T
Iraklis KourtisandClaude Opus 5.5 08d7037176 feat(kernel): modifiers report exact face and edge history
Fillets (local, loop, rolling ball, corner, variable, chord), chamfers
(cutters, local corners, loops, two sides), shell, draft, offset faces,
split face/body, delete face, patch/copy/stitch/trim/extend and
transforms record their history over [body] while they build: faces
rebuilt from the body's, blends in place of edges (corner patches in
place of the edges at the corner), shell walls as offsets, edges as
pieces of the body's or where two faces meet. Multi-step operations
compose each step. The push-face offset fallback now has history, and
the bore union composes its narrowed tool.

A reflection reorders a body's edges, so transforms now match edges by
identity (mirrored copies reported the wrong edges).

Refs #136

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8
2026-10-12 11:52:53 -04:00

422 lines
19 KiB
Rust

//! Booleans' histories, path by path: every face and edge origin checked against the geometry
//! of the operands (in tests only: the history itself is recorded, never sampled), and the exact
//! origins asserted where the outcome is known.
use std::collections::BTreeSet;
use solvecraft_geom::{Mesh, Vec3};
use crate::body::Body;
use crate::history::{Change, EdgeOrigin, FaceOrigin, History};
use crate::{BoolOp, boolean, box_solid};
const fn face(operand: usize, face: usize) -> FaceOrigin {
FaceOrigin::Face { operand, face }
}
fn closest_on_triangle(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Vec3 {
// Ericson, Real-Time Collision Detection, 5.1.5.
let (ab, ac, ap) = (b - a, c - a, p - a);
let (d1, d2) = (ab.dot(ap), ac.dot(ap));
if d1 <= 0.0 && d2 <= 0.0 {
return a;
}
let bp = p - b;
let (d3, d4) = (ab.dot(bp), ac.dot(bp));
if d3 >= 0.0 && d4 <= d3 {
return b;
}
let vc = d1 * d4 - d3 * d2;
if vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0 {
return a + ab * (d1 / (d1 - d3));
}
let cp = p - c;
let (d5, d6) = (ab.dot(cp), ac.dot(cp));
if d6 >= 0.0 && d5 <= d6 {
return c;
}
let vb = d5 * d2 - d1 * d6;
if vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0 {
return a + ac * (d2 / (d2 - d6));
}
let va = d3 * d6 - d5 * d4;
if va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0 {
return b + (c - b) * ((d4 - d3) / ((d4 - d3) + (d5 - d6)));
}
let denom = 1.0 / (va + vb + vc);
a + ab * (vb * denom) + ac * (vc * denom)
}
/// Distance from `p` to face `f` of a mesh.
pub(crate) fn to_face(m: &Mesh, f: usize, p: Vec3) -> f64 {
m.triangles
.iter()
.zip(&m.tri_face)
.filter(|(_, x)| **x as usize == f)
.map(|(t, _)| {
let [a, b, c] = t.map(|i| m.positions[i as usize]);
closest_on_triangle(p, a, b, c).dist(p)
})
.fold(f64::INFINITY, f64::min)
}
/// Distance from `p` to edge `e` of a mesh.
pub(crate) fn to_edge(m: &Mesh, e: usize, p: Vec3) -> f64 {
m.edges[e].windows(2).map(|w| p.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min)
}
/// How far, in a check, a face or edge may lie from what it came from.
#[derive(Clone, Copy)]
pub(crate) struct Reach {
/// A face made in place of an edge, or an offset of a face, lies within this of it.
pub made: f64,
/// A face or edge the operation moved lies within this of the one it came from.
pub moved: f64,
}
/// The result's history, checked against it and its operands, with every origin checked
/// against the geometry: each face's triangles lie on its origin faces, each origin face holds
/// some of them; each edge lies along its origin edges, or on both faces of a meeting. `tol`:
/// the meshes' tolerance.
fn verified<'a>(r: &'a Body, operands: &[&Body], tol: f64) -> &'a History {
checked(r, operands, tol, Reach { made: 0.0, moved: 0.0 }, &|_| false)
}
/// [`verified`] for any operation: a face lies on its origin faces (within `at.moved` where the
/// operation moves faces), within `at.made` of the edges it is made in place of and of the faces
/// it is an offset of; an edge lies along its origin edges (within `at.moved`). Faces from
/// nothing are allowed only where `generated` says.
pub(crate) fn checked<'a>(r: &'a Body, operands: &[&Body], tol: f64, at: Reach, generated: &dyn Fn(usize) -> bool) -> &'a History {
let h = r.history().expect("the operation reports its history");
h.check(r, operands).unwrap();
let rm = r.tessellate(tol).unwrap();
let ms: Vec<Mesh> = operands.iter().map(|b| b.tessellate(tol).unwrap()).collect();
let near = 4.0 * tol + r.size() * 1e-6;
let on = |o: &FaceOrigin, p: Vec3| match *o {
FaceOrigin::Face { operand, face } => to_face(&ms[operand], face, p) < near.max(at.moved),
FaceOrigin::Edge { operand, edge } => to_edge(&ms[operand], edge, p) < at.made + near,
FaceOrigin::Offset { operand, face } => to_face(&ms[operand], face, p) < at.made + near,
_ => false,
};
let along = |o: &EdgeOrigin, p: &Vec3| match *o {
EdgeOrigin::Edge { operand, edge } => to_edge(&ms[operand], edge, *p) < near.max(at.moved),
EdgeOrigin::Meet { a, b } => on(&a, *p) && on(&b, *p),
};
for (i, f) in h.faces.iter().enumerate() {
assert!(!f.origins.is_empty() || generated(i), "face {i} has no origin");
let centres = centres(&rm, i);
for c in centres.iter().filter(|_| !f.origins.is_empty()) {
assert!(f.origins.iter().any(|o| on(o, *c)), "face {i} ({f:?}) at {c:?} lies on none of its origins");
}
for o in &f.origins {
assert!(centres.iter().any(|c| on(o, *c)), "face {i}: origin {o:?} is under none of it");
}
}
for (j, e) in h.edges.iter().enumerate() {
let pts = &rm.edges[j];
for o in &e.origins {
match o {
// A joined edge runs along all of its origins, each along part of it.
EdgeOrigin::Edge { .. } if e.origins.len() > 1 => assert!(pts.iter().any(|p| along(o, p)), "edge {j}: {o:?} is not along it"),
_ => assert!(pts.iter().all(|p| along(o, p)), "edge {j} ({e:?}) does not lie along {o:?}"),
}
}
if e.origins.len() > 1 {
for p in pts {
assert!(e.origins.iter().any(|o| along(o, p)), "edge {j}: {p:?} lies along none of {e:?}");
}
}
}
h
}
/// The centres of face `f`'s triangles.
pub(crate) fn centres(m: &Mesh, f: usize) -> Vec<Vec3> {
m.triangles
.iter()
.zip(&m.tri_face)
.filter(|(_, x)| **x as usize == f)
.map(|(t, _)| t.map(|k| m.positions[k as usize]).iter().fold(Vec3::ZERO, |s, p| s + *p) * (1.0 / 3.0))
.collect()
}
/// How many faces of each change.
pub(crate) fn changes(h: &History) -> [usize; 5] {
let mut n = [0; 5];
for f in &h.faces {
n[match f.change {
Change::Kept => 0,
Change::Modified => 1,
Change::Split => 2,
Change::Merged => 3,
Change::Generated => 4,
}] += 1;
}
n
}
/// The faces of the result that come from operand face `o`.
fn from(h: &History, o: FaceOrigin) -> Vec<usize> {
(0..h.faces.len()).filter(|i| h.faces[*i].origins.contains(&o)).collect()
}
/// The meetings among the edges' origins, as face pairs.
fn meets(h: &History) -> BTreeSet<(FaceOrigin, FaceOrigin)> {
h.edges.iter().flat_map(|e| &e.origins).filter_map(|o| if let EdgeOrigin::Meet { a, b } = o { Some((*a, *b)) } else { None }).collect()
}
fn boxed(x0: f64, y0: f64, z0: f64, x1: f64, y1: f64, z1: f64) -> Body {
box_solid(Vec3::new(x0, y0, z0), Vec3::new(x1, y1, z1)).unwrap()
}
/// A short summary of a history for failure messages.
fn show(h: &History) -> String {
let faces: Vec<String> = h.faces.iter().enumerate().map(|(i, f)| format!("{i}: {:?} {:?}", f.change, f.origins)).collect();
let edges: Vec<String> = h.edges.iter().enumerate().map(|(i, e)| format!("{i}: {:?} {:?}", e.faces, e.origins)).collect();
format!("faces\n{}\nedges\n{}", faces.join("\n"), edges.join("\n"))
}
/// Each face's origins and change, in face order.
fn faces_of(h: &History) -> Vec<(Change, Vec<FaceOrigin>)> {
h.faces.iter().map(|f| (f.change, f.origins.clone())).collect()
}
/// Truck's boolean with a sphere (no prism, no coincident faces): a box with a corner taken
/// off by a sphere. The three faces at the corner get a notch, the sphere's patches inside the
/// box bound it, meeting the box's faces along new edges.
#[test]
fn box_minus_sphere_through_truck() {
let a = boxed(0.0, 0.0, 0.0, 10.0, 10.0, 10.0);
let b = crate::sphere(Vec3::new(10.0, 10.0, 10.0), 4.0).unwrap();
let r = boolean(&a, &b, BoolOp::Cut).unwrap().unwrap();
let h = verified(&r, &[&a, &b], 0.02);
assert_eq!(changes(h), [3, 6, 0, 0, 0], "{}", show(h));
let from_b: BTreeSet<usize> = h
.faces
.iter()
.flat_map(|f| &f.origins)
.filter_map(|o| match o {
FaceOrigin::Face { operand: 1, face } => Some(*face),
_ => None,
})
.collect();
assert_eq!(from_b.len(), 3);
// Each notched box face meets two of the sphere's patches.
assert_eq!(meets(h).len(), 6, "{}", show(h));
assert!(meets(h).iter().all(|(x, y)| matches!((x, y), (FaceOrigin::Face { operand: 0, .. }, FaceOrigin::Face { operand: 1, .. }))));
}
/// The jitter retries move both operands and the result back: the history holds for the
/// operands themselves, the same faces coming from the same operand faces.
#[test]
fn shifted_truck_attempt_keeps_the_history() {
let a = boxed(0.0, 0.0, 0.0, 10.0, 10.0, 10.0);
let b = crate::sphere(Vec3::new(10.0, 10.0, 10.0), 4.0).unwrap();
let counts = crate::record::counts(&[&a, &b]);
let mut compared = 0;
for op in [BoolOp::Cut, BoolOp::Union, BoolOp::Intersect] {
let Ok((_, Some((plain, _)))) = crate::ops::truck_boolean(&a, &b, op, None, 0.035) else { continue };
// The shifts the retries use (a hundredth of the size along each).
for j in [[0.1373, 0.2719, 0.3911], [-0.5127, 0.0833, 0.2291], [0.6911, -0.3337, -0.6127]] {
let shift = truck_modeling::Vector3::new(j[0], j[1], j[2]) * 0.17;
let Ok((s, h)) = crate::ops::truck_boolean(&a, &b, op, Some(shift), 0.035) else { continue };
let faces = h.as_ref().map(|(f, _)| f.clone());
assert_eq!(faces, Some(plain.clone()), "{op:?}");
let r = crate::boolean_record::truck_result(s, h, 17.4, &[], &counts).unwrap();
verified(&r, &[&a, &b], 0.02);
compared += 1;
}
}
assert!(compared > 0);
}
/// Two prisms along x, the tool's section inside the target's, overlapping in part: sewn slab by
/// slab, then healed. The target's face at x = 10 gets the tool's outline as a hole, which the
/// tool's sides meet; the tool's sides and far end stand out of it.
#[test]
fn box_union_box_prism() {
let a = boxed(0.0, 0.0, 0.0, 10.0, 10.0, 10.0);
let b = boxed(5.0, 2.0, 2.0, 15.0, 8.0, 8.0);
let r = boolean(&a, &b, BoolOp::Union).unwrap().unwrap();
let h = verified(&r, &[&a, &b], 0.02);
assert_eq!(h.faces.len(), 11);
let mut origins: Vec<FaceOrigin> = h
.faces
.iter()
.map(|f| match f.origins.as_slice() {
[o] => *o,
_ => panic!("one origin each: {}", show(h)),
})
.collect();
origins.sort();
// All of a's faces, and b's but the end inside a.
let mut want: Vec<FaceOrigin> = (0..6).map(|i| face(0, i)).chain((0..6).map(|i| face(1, i))).collect();
let inside = face(1, 4);
want.retain(|o| *o != inside);
assert_eq!(origins, want, "{}", show(h));
// b's four sides meet a's face at x = 10.
let rim: BTreeSet<_> = meets(h).iter().map(|(x, _)| *x).collect();
assert_eq!(meets(h).len(), 4);
assert_eq!(rim.len(), 1);
assert!(h.edges.iter().all(|e| e.origins.len() == 1));
}
/// A through hole: the box's sides are kept, top and bottom get the hole, the cylinder's two
/// half walls line it, meeting top and bottom along its rims, and meet each other along the
/// cylinder's own seams.
#[test]
fn box_minus_cylinder_prism() {
let a = boxed(0.0, 0.0, 0.0, 10.0, 10.0, 10.0);
let b = crate::cylinder(Vec3::new(5.0, 5.0, -1.0), Vec3::Z, 2.0, 12.0).unwrap();
let r = boolean(&a, &b, BoolOp::Cut).unwrap().unwrap();
let h = verified(&r, &[&a, &b], 0.01);
assert_eq!(changes(h), [4, 4, 0, 0, 0], "{}", show(h));
let walls = from(h, face(1, 1)).len() + from(h, face(1, 2)).len();
assert_eq!(walls, 2, "{}", show(h));
// Rims: each wall meets top and bottom.
assert_eq!(meets(h).len(), 4, "{}", show(h));
let seams = h.edges.iter().filter(|e| matches!(e.origins.as_slice(), [EdgeOrigin::Edge { operand: 1, .. }])).count();
assert_eq!(seams, 2);
// Every other edge is one of the box's twelve.
let boxed_edges: BTreeSet<_> = h.edges.iter().flat_map(|e| &e.origins).filter(|o| matches!(o, EdgeOrigin::Edge { operand: 0, .. })).collect();
assert_eq!(boxed_edges.len(), 12);
}
/// A slot across the top (a stepped prism along y): the top and the box's ends come apart in
/// pieces (Split); the slot's floor and sides are the tool's.
#[test]
#[ignore = "fails until #136 is fixed"]
fn slot_splits_a_face() {
let a = boxed(0.0, 0.0, 0.0, 10.0, 10.0, 10.0);
let b = boxed(4.0, -1.0, 5.0, 6.0, 11.0, 11.0);
let r = boolean(&a, &b, BoolOp::Cut).unwrap().unwrap();
let h = verified(&r, &[&a, &b], 0.02);
let top = (0..6).find(|i| a.planar_face(*i).is_some_and(|p| p.origin.z > 9.0 && p.normal().z > 0.9)).unwrap();
let pieces = from(h, face(0, top));
assert!(pieces.len() >= 2, "{}", show(h));
assert!(pieces.iter().all(|i| h.faces[*i].change == Change::Split));
// Three of the tool's faces bound the slot.
let tool: BTreeSet<_> = h.faces.iter().flat_map(|f| &f.origins).filter(|o| matches!(o, FaceOrigin::Face { operand: 1, .. })).collect();
assert_eq!(tool.len(), 3, "{}", show(h));
}
/// Bodies clear of each other: a union keeps both whole, a cut keeps the target.
#[test]
fn trivial_paths() {
let a = boxed(0.0, 0.0, 0.0, 10.0, 10.0, 10.0);
let b = crate::sphere(Vec3::new(30.0, 0.0, 0.0), 3.0).unwrap();
let u = boolean(&a, &b, BoolOp::Union).unwrap().unwrap();
let h = verified(&u, &[&a, &b], 0.02);
let want: Vec<(Change, Vec<FaceOrigin>)> =
(0..a.face_count()).map(|i| (Change::Kept, vec![face(0, i)])).chain((0..b.face_count()).map(|i| (Change::Kept, vec![face(1, i)]))).collect();
assert_eq!(faces_of(h), want);
let c = boolean(&a, &b, BoolOp::Cut).unwrap().unwrap();
let h = verified(&c, &[&a, &b], 0.02);
assert_eq!(faces_of(h), (0..a.face_count()).map(|i| (Change::Kept, vec![face(0, i)])).collect::<Vec<_>>());
// Wholly inside: the intersection is the tool, the union the target.
let inner = crate::sphere(Vec3::new(5.0, 5.0, 5.0), 2.0).unwrap();
let i = boolean(&a, &inner, BoolOp::Intersect).unwrap().unwrap();
let h = verified(&i, &[&a, &inner], 0.02);
assert!(h.faces.iter().all(|f| f.change == Change::Kept && matches!(f.origins.as_slice(), [FaceOrigin::Face { operand: 1, .. }])));
let u = boolean(&a, &inner, BoolOp::Union).unwrap().unwrap();
let h = verified(&u, &[&a, &inner], 0.02);
assert!(h.faces.iter().all(|f| matches!(f.origins.as_slice(), [FaceOrigin::Face { operand: 0, .. }])));
// Cut out of the middle: a void, the tool's faces turned inward.
let v = boolean(&a, &inner, BoolOp::Cut).unwrap().unwrap();
let h = verified(&v, &[&a, &inner], 0.02);
assert_eq!(v.shell_count(), 2);
assert_eq!(h.faces.len(), a.face_count() + inner.face_count());
assert!(h.faces.iter().all(|f| f.change == Change::Kept));
}
/// A body in two pieces, cut a piece at a time: each piece's faces are the body's own.
#[test]
fn by_lumps_composes_the_pieces() {
let left = boxed(0.0, 0.0, 0.0, 10.0, 10.0, 10.0);
let right = boxed(20.0, 0.0, 0.0, 30.0, 10.0, 10.0);
let a = boolean(&left, &right, BoolOp::Union).unwrap().unwrap();
assert_eq!(a.shell_count(), 2);
let b = crate::sphere(Vec3::new(15.0, 5.0, 10.0), 7.0).unwrap();
let r = crate::ops::by_lumps(&a, &b, a.lumps().unwrap(), b.lumps().unwrap(), BoolOp::Cut).unwrap().unwrap();
let h = verified(&r, &[&a, &b], 0.02);
// Both pieces are bitten: the sphere's patches inside each line its bite.
let bites: BTreeSet<_> = h.faces.iter().flat_map(|f| &f.origins).filter(|o| matches!(o, FaceOrigin::Face { operand: 1, .. })).collect();
assert!(bites.len() >= 2, "{}", show(h));
let targets: BTreeSet<_> = h.faces.iter().flat_map(|f| &f.origins).filter(|o| matches!(o, FaceOrigin::Face { operand: 0, .. })).collect();
assert_eq!(targets.len(), 12);
}
/// Two bodies touching along one whole face, back to back: glued, the two faces gone, the
/// coplanar sides each side of the seam merged.
#[test]
fn glue_merges_across_the_seam() {
let a = boxed(0.0, 0.0, 0.0, 10.0, 10.0, 10.0);
let b = boxed(10.0, 0.0, 0.0, 20.0, 10.0, 10.0);
let r = crate::coplanar::glue(&a, &b).unwrap().unwrap();
let h = verified(&r, &[&a, &b], 0.02);
assert_eq!(changes(h), [2, 0, 0, 4, 0], "{}", show(h));
// The four seam edges went with the merged faces; the ends' edges are each box's.
assert_eq!(h.edges.len(), 12);
assert!(h.edges.iter().all(|e| !e.origins.is_empty()));
}
/// Coincident faces pushed apart: a cut flush with the target's top, and a union of bodies
/// standing on one floor (the floor that is cut back is both operands' floors).
#[test]
#[ignore = "fails until #136 is fixed"]
fn faces_pushed_apart() {
let a = boxed(0.0, 0.0, 0.0, 10.0, 10.0, 10.0);
let tool = boxed(2.0, 2.0, 5.0, 8.0, 8.0, 10.0);
let r = crate::coplanar::boolean_apart(&a, &tool, BoolOp::Cut).unwrap().unwrap().unwrap();
let h = verified(&r, &[&a, &tool], 0.02);
// The pocket: its floor and four sides are the tool's.
let tool_faces: BTreeSet<_> = h.faces.iter().flat_map(|f| &f.origins).filter(|o| matches!(o, FaceOrigin::Face { operand: 1, .. })).collect();
assert_eq!(tool_faces.len(), 5, "{}", show(h));
let left = boxed(0.0, 0.0, 0.0, 10.0, 10.0, 10.0);
let right = boxed(5.0, 3.0, 0.0, 15.0, 7.0, 4.0);
let r = crate::coplanar::boolean_apart(&left, &right, BoolOp::Union).unwrap().unwrap().unwrap();
let h = verified(&r, &[&left, &right], 0.02);
assert!(h.faces.iter().any(|f| f.change == Change::Merged && f.origins.len() == 2), "{}", show(h));
}
/// The exact polygon boolean: pieces of planar faces joined back into faces.
#[test]
fn planar_fallback() {
let a = boxed(0.0, 0.0, 0.0, 10.0, 10.0, 10.0);
let b = boxed(5.0, 5.0, 5.0, 15.0, 15.0, 15.0);
for (op, count) in [(BoolOp::Cut, 9), (BoolOp::Union, 12), (BoolOp::Intersect, 6)] {
let r = crate::polybool::planar_boolean(&a, &b, op).unwrap().unwrap();
let h = verified(&r, &[&a, &b], 0.02);
assert_eq!(h.faces.len(), count, "{op:?}\n{}", show(h));
assert!(h.faces.iter().all(|f| f.origins.len() == 1));
// Three faces of each meet three of the other's.
assert_eq!(meets(h).len(), 6, "{op:?}\n{}", show(h));
assert!(h.edges.iter().all(|e| e.origins.len() == 1), "{op:?}\n{}", show(h));
}
}
/// The nudged retry: a boolean with the tool moved a hair, composed with the move.
#[test]
fn nudged_tool_composes() {
let a = boxed(0.0, 0.0, 0.0, 10.0, 10.0, 10.0);
let b = crate::sphere(Vec3::new(10.0, 10.0, 10.0), 4.0).unwrap();
let nb = crate::transform(&b, Vec3::new(1e-5, 0.0, 0.0), Vec3::ZERO, Vec3::Z, 0.0).unwrap();
let r = boolean(&a, &nb, BoolOp::Cut).unwrap().unwrap();
let r = crate::ops::nudged(r, &nb, &crate::record::counts(&[&a, &b]));
verified(&r, &[&a, &b], 0.02);
}
/// A boolean with a triangle mesh: the mesh's faces are its triangles (and it has no edges).
#[test]
fn faceted_tool() {
let a = boxed(0.0, 0.0, 0.0, 10.0, 10.0, 10.0);
let tool = boxed(5.0, 5.0, 5.0, 15.0, 15.0, 15.0).tessellate(0.1).unwrap();
let r = crate::faceted_boolean(&a, &tool, BoolOp::Cut, 0.01).unwrap().unwrap();
let h = r.history().expect("the faceted boolean reports its history");
h.check_counts((r.face_count(), r.edge_count()), &[(a.face_count(), a.edge_count()), (tool.triangles.len(), 0)]).unwrap();
// The box's six faces (three notched) and the three tool faces lining the notch.
let from_a: BTreeSet<_> = h.faces.iter().flat_map(|f| &f.origins).filter(|o| matches!(o, FaceOrigin::Face { operand: 0, .. })).collect();
assert_eq!(from_a.len(), 6);
assert_eq!(h.faces.iter().filter(|f| matches!(f.origins.first(), Some(FaceOrigin::Face { operand: 1, .. }))).count(), 3);
}