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
This commit is contained in:
Iraklis KourtisandClaude Opus 5.5 authored and bherbruck committed 2026-10-12 11:52:53 -04:00
1 parent d5cb276965
commit 08d7037176
19 files changed
+1146 -231

No files matched your search

+378
View File
@@ -0,0 +1,378 @@
//! Modifiers' histories (fillets, chamfers, shell, draft, offset, split, delete, surfaces): 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 solvecraft_geom::{Loop2, Plane, Region2, Vec2, Vec3};
use crate::body::Body;
use crate::boolean_record::tests::{self, Reach, changes, to_edge, to_face};
use crate::history::{Change, EdgeOrigin, FaceOrigin, History};
use crate::{BoolOp, boolean, box_solid, cylinder, extrude};
const TOL: f64 = 0.05;
const fn face(face: usize) -> FaceOrigin {
FaceOrigin::Face { operand: 0, face }
}
const fn edge(edge: usize) -> FaceOrigin {
FaceOrigin::Edge { operand: 0, edge }
}
const fn reach(made: f64) -> Reach {
Reach { made, moved: 0.0 }
}
/// [`checked`] at this module's mesh tolerance.
fn checked<'a>(r: &'a Body, operands: &[&Body], at: Reach, generated: &dyn Fn(usize) -> bool) -> &'a History {
tests::checked(r, operands, TOL, at, generated)
}
/// [`checked`] for a modifier of one operand whose every face comes from something of it.
fn verified<'a>(r: &'a Body, operand: &Body, at: Reach) -> &'a History {
checked(r, &[operand], at, &|_| false)
}
/// The faces whose origins are exactly `origins`.
fn with(h: &History, origins: &[FaceOrigin]) -> Vec<usize> {
(0..h.faces.len()).filter(|i| h.faces[*i].origins == origins).collect()
}
/// The origins of every face made in place of edges (blends).
fn blends(h: &History) -> Vec<Vec<FaceOrigin>> {
h.faces.iter().filter(|f| f.origins.iter().any(|o| matches!(o, FaceOrigin::Edge { .. }))).map(|f| f.origins.clone()).collect()
}
/// The index of the edge of `b` nearest `p`.
fn edge_at(b: &Body, p: Vec3) -> usize {
b.nearest_edge(p, TOL).unwrap().unwrap().0
}
/// The index of the face of `b` holding `p`.
fn face_at(b: &Body, p: Vec3) -> usize {
let m = b.tessellate(TOL).unwrap();
(0..b.face_count()).min_by(|x, y| to_face(&m, *x, p).total_cmp(&to_face(&m, *y, p))).unwrap()
}
fn cube() -> Body {
box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap()
}
fn square(x0: f64, y0: f64, x1: f64, y1: f64) -> Loop2 {
Loop2::polygon(&[Vec2::new(x0, y0), Vec2::new(x1, y0), Vec2::new(x1, y1), Vec2::new(x0, y1)])
}
/// An L-shaped prism (not convex), 20 high.
fn ell() -> Body {
let outer = Loop2::polygon(&[
Vec2::ZERO,
Vec2::new(40.0, 0.0),
Vec2::new(40.0, 15.0),
Vec2::new(15.0, 15.0),
Vec2::new(15.0, 30.0),
Vec2::new(0.0, 30.0),
]);
extrude(&Plane::XY, &[Region2 { outer, holes: vec![] }], 0.0, 20.0).unwrap().pop().unwrap()
}
#[test]
fn fillet_one_edge() {
let b = cube();
let at = Vec3::new(20.0, 30.0, 20.0);
let r = crate::fillet(&b, &[at], 5.0).unwrap();
let h = verified(&r, &b, reach(5.0));
assert_eq!(with(h, &[edge(edge_at(&b, at))]).len(), 1);
// Two faces along the edge and the two at its ends are trimmed, two are untouched.
assert_eq!(changes(h), [2, 4, 0, 0, 1]);
}
#[test]
fn fillet_an_edge_a_cut_split() {
let b = cube();
// A slot through the back leaves two pieces of the top back edge.
let notch = box_solid(Vec3::new(15.0, 25.0, -5.0), Vec3::new(25.0, 35.0, 25.0)).unwrap();
let cut = boolean(&b, &notch, BoolOp::Cut).unwrap().unwrap();
let at = Vec3::new(5.0, 30.0, 20.0);
let piece = edge_at(&cut, at);
assert_eq!(cut.history().unwrap().edges[piece].origins, [EdgeOrigin::Edge { operand: 0, edge: edge_at(&b, at) }]);
let r = crate::fillet(&cut, &[at], 3.0).unwrap();
let h = verified(&r, &cut, reach(3.0));
assert_eq!(blends(h), [vec![edge(piece)]]);
// Through the cut: the blend stands in place of the box's edge, which the cut split.
let mut through = h.clone();
through.substitute(0, cut.history().unwrap()).unwrap();
through.check(&r, &[&b, &notch]).unwrap();
assert_eq!(blends(&through), [vec![edge(edge_at(&b, at))]]);
}
#[test]
fn fillet_three_edges_at_a_corner() {
let b = cube();
let pts = [Vec3::new(20.0, 30.0, 20.0), Vec3::new(40.0, 15.0, 20.0), Vec3::new(40.0, 30.0, 10.0)];
let r = crate::fillet_radii(&b, &pts.map(|p| (p, 4.0))).unwrap();
let h = verified(&r, &b, reach(4.0 * 1.5));
let edges: Vec<usize> = pts.iter().map(|p| edge_at(&b, *p)).collect();
for e in &edges {
assert_eq!(with(h, &[edge(*e)]).len(), 1, "{h:?}");
}
// The corner patch stands in place of the three edges meeting there.
let mut corner: Vec<FaceOrigin> = edges.iter().map(|e| edge(*e)).collect();
corner.sort();
assert_eq!(with(h, &corner).len(), 1, "{:?}", blends(h));
}
#[test]
fn fillet_a_whole_loop() {
let b = cube();
let pts = [Vec3::new(20.0, 0.0, 20.0), Vec3::new(40.0, 15.0, 20.0), Vec3::new(20.0, 30.0, 20.0), Vec3::new(0.0, 15.0, 20.0)];
let r = crate::fillet(&b, &pts, 3.0).unwrap();
let h = verified(&r, &b, reach(3.0 * 1.5));
let mut made: Vec<FaceOrigin> = blends(h).into_iter().flatten().collect();
made.sort();
let mut want: Vec<FaceOrigin> = pts.iter().map(|p| edge(edge_at(&b, *p))).collect();
want.sort();
assert_eq!(made, want);
}
#[test]
fn fillet_every_edge_of_a_box() {
let b = cube();
let pts: Vec<Vec3> = b.edges(TOL).unwrap().iter().map(|e| e.mid).collect();
let r = crate::fillet(&b, &pts, 2.0).unwrap();
let h = verified(&r, &b, reach(2.0 * 2.0));
// Six faces shrunk, twelve cylinders, eight corners of three edges each.
assert_eq!(changes(h), [0, 6, 0, 0, 20]);
assert_eq!(blends(h).iter().filter(|o| o.len() == 1).count(), 12);
assert_eq!(blends(h).iter().filter(|o| o.len() == 3).count(), 8);
}
#[test]
fn fillet_a_rolling_ball_chain() {
let main = cylinder(Vec3::new(-40.0, 0.0, 0.0), Vec3::X, 15.0, 80.0).unwrap();
let branch = cylinder(Vec3::ZERO, Vec3::Z, 10.0, 35.0).unwrap();
let tee = boolean(&main, &branch, BoolOp::Union).unwrap().unwrap();
let at = Vec3::new(0.0, 10.0, 125f64.sqrt());
let r = crate::fillet(&tee, &[at], 3.0).unwrap();
let h = verified(&r, &tee, reach(3.0 * 2.0));
assert!(!blends(h).is_empty());
assert!(blends(h).iter().all(|o| o.len() == 1));
}
#[test]
fn fillet_variable_and_chord() {
let b = cube();
let at = Vec3::new(20.0, 30.0, 20.0);
let r = crate::fillet_variable(&b, at, Vec3::new(0.0, 30.0, 20.0), 2.0, 5.0).unwrap();
let h = verified(&r, &b, reach(5.0));
assert_eq!(blends(h), [vec![edge(edge_at(&b, at))]]);
let r = crate::fillet_chord(&b, &[at, Vec3::new(40.0, 15.0, 0.0)], 4.0).unwrap();
let h = verified(&r, &b, reach(4.0));
assert_eq!(blends(h).len(), 2);
}
#[test]
fn chamfer_cuts_one_edge() {
let b = cube();
let at = Vec3::new(20.0, 30.0, 20.0);
let r = crate::chamfer(&b, &[at], 4.0).unwrap();
let h = verified(&r, &b, reach(4.0));
let chamfer = with(h, &[edge(edge_at(&b, at))]);
assert_eq!(chamfer.len(), 1, "{h:?}");
assert_eq!(h.faces[chamfer[0]].change, Change::Generated);
assert_eq!(r.face_count(), 7);
}
#[test]
fn chamfer_cutters_mitre_at_a_corner() {
let b = cube();
let pts = [Vec3::new(20.0, 30.0, 20.0), Vec3::new(40.0, 15.0, 20.0)];
let r = crate::chamfer(&b, &pts, 3.0).unwrap();
let h = verified(&r, &b, reach(3.0 * 1.5));
for p in pts {
assert_eq!(with(h, &[edge(edge_at(&b, p))]).len(), 1, "{h:?}");
}
// The two chamfers meet along the mitre.
let (a, c) = (edge(edge_at(&b, pts[0])), edge(edge_at(&b, pts[1])));
assert!(h.edges.iter().any(|e| e.origins == [EdgeOrigin::Meet { a, b: c }] || e.origins == [EdgeOrigin::Meet { a: c, b: a }]));
}
#[test]
fn chamfer_with_two_sides() {
let b = cube();
let at = Vec3::new(20.0, 30.0, 20.0);
let r = crate::chamfer_sides(&b, &[at], 2.0, crate::ChamferSide::Distance(4.0), false).unwrap();
let h = verified(&r, &b, reach(4.0));
assert_eq!(blends(h), [vec![edge(edge_at(&b, at))]]);
}
#[test]
fn delete_a_fillet() {
let b = cube();
let r = crate::fillet(&b, &[Vec3::new(20.0, 30.0, 20.0)], 5.0).unwrap();
let round = with(r.history().unwrap(), &[edge(edge_at(&b, Vec3::new(20.0, 30.0, 20.0)))]);
let back = crate::delete_faces(&r, &round).unwrap();
let h = verified(&back, &r, Reach { made: 0.0, moved: 6.0 });
// Every face is one of the filleted body's, trimmed back out to the sharp edge.
assert_eq!(h.faces.len(), 6);
assert!(h.faces.iter().all(|f| f.origins.len() == 1));
// The faces either side of the round meet again where they met nowhere in its input.
let (top, back_face) = (face_at(&r, Vec3::new(20.0, 10.0, 20.0)), face_at(&r, Vec3::new(20.0, 30.0, 5.0)));
let (x, y) = (face(top), face(back_face));
assert!(h.edges.iter().any(|e| e.origins == [EdgeOrigin::Meet { a: x.min(y), b: x.max(y) }]));
}
#[test]
fn shell_lines_the_walls_with_offsets() {
let b = cube();
let top = Vec3::new(20.0, 15.0, 20.0);
let r = crate::shell(&b, &[top], 2.0).unwrap();
let h = verified(&r, &b, reach(2.0));
// Five inner walls, each an offset of the face it lines; the top is left as a rim.
let top = face_at(&b, top);
for f in (0..6).filter(|f| *f != top) {
let walls = with(h, &[FaceOrigin::Offset { operand: 0, face: f }]);
assert_eq!(walls.len(), 1, "{h:?}");
assert_eq!(h.faces[walls[0]].change, Change::Generated);
}
assert!(with(h, &[FaceOrigin::Offset { operand: 0, face: top }]).is_empty());
assert_eq!(changes(h), [0, 6, 0, 0, 5]);
}
#[test]
fn shell_of_a_body_that_is_not_convex() {
let b = ell();
let r = crate::shell(&b, &[Vec3::new(5.0, 5.0, 20.0)], 2.0).unwrap();
// An inner wall runs past its face by the thickness at a concave corner.
let h = verified(&r, &b, reach(2.0 * 1.5));
let offsets = h.faces.iter().filter(|f| matches!(f.origins.as_slice(), [FaceOrigin::Offset { .. }])).count();
assert_eq!(offsets, 7, "{h:?}");
}
#[test]
fn draft_tilts_faces() {
let b = cube();
let side = Vec3::new(40.0, 15.0, 10.0);
let r = crate::draft(&b, &[side], &Plane::XY, Vec3::Z, 5f64.to_radians()).unwrap();
let h = verified(&r, &b, Reach { made: 0.0, moved: 2.0 });
assert_eq!(changes(h), [0, 6, 0, 0, 0]);
assert_eq!(with(h, &[face(face_at(&b, side))]).len(), 1);
// Walls of a body that is not convex.
let l = ell();
let wall = Vec3::new(40.0, 7.0, 10.0);
let r = crate::draft(&l, &[wall], &Plane::XY, Vec3::Z, 5f64.to_radians()).unwrap();
let h = verified(&r, &l, Reach { made: 0.0, moved: 2.0 });
assert_eq!(with(h, &[face(face_at(&l, wall))]).len(), 1);
}
#[test]
fn offset_moves_a_face() {
let b = ell();
let top = Vec3::new(5.0, 5.0, 20.0);
let r = crate::offset_faces(&b, &[top], 3.0).unwrap();
let h = verified(&r, &b, Reach { made: 0.0, moved: 3.0 * 1.1 });
assert_eq!(h.faces.len(), b.face_count());
assert_eq!(with(h, &[face(face_at(&b, top))]).len(), 1);
assert!(h.edges.iter().all(|e| matches!(e.origins.as_slice(), [EdgeOrigin::Edge { .. }])));
}
#[test]
fn split_a_face() {
let b = cube();
let top = face_at(&b, Vec3::new(20.0, 15.0, 20.0));
let plane = Plane::new(Vec3::new(10.0, 0.0, 0.0), Vec3::Y, Vec3::Z).unwrap();
let (r, map) = crate::split_faces_with_map(&b, &[top], &crate::SplitTool::Plane(plane)).unwrap();
let h = verified(&r, &b, reach(0.0));
// Two pieces of the top; the faces whose edges the split cut are whole, as is the rest.
assert_eq!(with(h, &[face(top)]), map.iter().enumerate().filter(|(_, f)| **f == top).map(|(i, _)| i).collect::<Vec<_>>());
assert_eq!(changes(h), [5, 0, 2, 0, 0]);
for (i, f) in map.iter().enumerate() {
assert_eq!(h.faces[i].origins, [face(*f)]);
}
// The split line runs between pieces of one face: it comes from nothing.
assert_eq!(h.edges.iter().filter(|e| e.origins.is_empty()).count(), 1);
}
#[test]
fn split_a_body() {
let b = cube();
let plane = Plane::new(Vec3::new(10.0, 0.0, 0.0), Vec3::Y, Vec3::Z).unwrap();
let parts = crate::split_body(&b, &crate::SplitTool::Plane(plane)).unwrap();
assert_eq!(parts.len(), 2);
for p in &parts {
let h = checked(p, &[&b], reach(0.0), &|i| p.history().is_some_and(|h| h.faces[i].change == Change::Generated));
// One section face, new; the rest the box's faces or pieces of them.
assert_eq!(h.faces.iter().filter(|f| f.origins.is_empty()).count(), 1);
}
}
#[test]
fn surfaces_keep_their_faces() {
let b = cube();
let top = Vec3::new(20.0, 15.0, 20.0);
let copy = crate::copy_faces(&b, &[top]).unwrap();
let h = verified(&copy, &b, reach(0.0));
assert_eq!(h.faces[0].origins, [face(face_at(&b, top))]);
assert_eq!(h.faces[0].change, Change::Kept);
// Trimmed: the copy, cut back (its other piece is gone).
let plane = Plane::new(Vec3::new(10.0, 0.0, 0.0), Vec3::Y, Vec3::Z).unwrap();
let t = crate::trim(&copy, &crate::SplitTool::Plane(plane), Vec3::new(5.0, 15.0, 20.0)).unwrap();
let h = verified(&t, &copy, reach(0.0));
assert_eq!(h.faces[0].origins, [face(0)]);
assert_eq!(h.faces[0].change, Change::Modified);
// Extended: the face and its edges moved out.
let x = crate::extend(&copy, &[Vec3::new(40.0, 15.0, 20.0)], 5.0).unwrap();
let h = verified(&x, &copy, Reach { made: 0.0, moved: 5.0 * 1.1 });
assert_eq!(h.faces[0].origins, [face(0)]);
assert!(h.edges.iter().all(|e| e.origins.len() == 1));
// Patched: a new face on the body's own edges.
let open = crate::copy_faces(
&b,
&[
Vec3::new(20.0, 15.0, 0.0),
Vec3::new(20.0, 0.0, 10.0),
Vec3::new(20.0, 30.0, 10.0),
Vec3::new(0.0, 15.0, 10.0),
Vec3::new(40.0, 15.0, 10.0),
],
)
.unwrap();
let rim = [Vec3::new(20.0, 0.0, 20.0), Vec3::new(40.0, 15.0, 20.0), Vec3::new(20.0, 30.0, 20.0), Vec3::new(0.0, 15.0, 20.0)];
let lid = crate::patch_edges(&open, &rim).unwrap();
let h = checked(&lid, &[&open], reach(0.0), &|_| true);
assert_eq!(h.faces[0].change, Change::Generated);
assert!(h.edges.iter().all(|e| matches!(e.origins.as_slice(), [EdgeOrigin::Edge { operand: 0, .. }])));
// Stitched: each face the one it was sewn from, the seam the edges sewn together.
let closed = crate::stitch(&[&open, &lid], 0.0).unwrap();
let h = checked(&closed, &[&open, &lid], reach(0.0), &|_| false);
assert!(h.faces.iter().all(|f| f.change == Change::Kept && f.origins.len() == 1));
assert_eq!(h.edges.iter().filter(|e| e.origins.len() == 2).count(), 4);
let patch = crate::patch_region(&Plane::XY, &Region2 { outer: square(0.0, 0.0, 10.0, 10.0), holes: vec![] }).unwrap();
assert_eq!(patch.history().unwrap().operands, 0);
}
#[test]
fn thicken_offsets_the_surface() {
let s = crate::patch_region(&Plane::XY, &Region2 { outer: square(0.0, 0.0, 10.0, 10.0), holes: vec![] }).unwrap();
let t = crate::thicken(&s, 2.0, false).unwrap();
let h = verified(&t, &s, reach(2.0));
assert_eq!(with(h, &[face(0)]).len(), 1);
assert_eq!(with(h, &[FaceOrigin::Offset { operand: 0, face: 0 }]).len(), 1);
assert_eq!(blends(h).len(), 4);
}
#[test]
fn mirrored_copy_keeps_every_face_and_edge() {
let b = ell();
let m = [[-1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [100.0, 0.0, 0.0, 1.0]];
let r = crate::transform_matrix(&b, m).unwrap();
let h = r.history().unwrap();
h.check(&r, &[&b]).unwrap();
// Each edge is the mirror image of the one it says it is.
let (rm, bm) = (r.tessellate(TOL).unwrap(), b.tessellate(TOL).unwrap());
for (j, e) in h.edges.iter().enumerate() {
let [EdgeOrigin::Edge { edge, .. }] = e.origins[..] else { panic!("edge {j}: {e:?}") };
for p in &rm.edges[j] {
assert!(to_edge(&bm, edge, Vec3::new(100.0 - p.x, p.y, p.z)) < 1e-6, "edge {j} is not the mirror of edge {edge}");
}
}
}