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

+43 -22
View File
@@ -14,6 +14,7 @@ use truck_meshalgo::tessellation::RobustMeshableShape;
use truck_modeling as mt;
use crate::body::{Body, from_p3, p3};
use crate::record::Rebuild;
use crate::{KernelError, Result, guard};
/// What splits the faces.
@@ -457,8 +458,9 @@ fn inside(poly: &[(f64, f64)], q: (f64, f64)) -> bool {
/// Split one face (index `fi` in `faces`) by one field; `faces` is updated (the face replaced
/// by its pieces, boundary edges cut in every face).
/// Returns how many lines split it and how many faces now stand in its place (at `fi`).
fn split_one(faces: &mut Vec<mt::Face>, fi: usize, g: &Field, size: f64, new_edges: &mut Vec<mt::Edge>) -> Result<(usize, usize)> {
/// Returns how many lines split it and how many faces now stand in its place (at `fi`). The
/// pieces of cut edges are recorded in `rec`.
fn split_one(faces: &mut Vec<mt::Face>, fi: usize, g: &Field, size: f64, new_edges: &mut Vec<mt::Edge>, rec: &mut Rebuild) -> Result<(usize, usize)> {
let Some(face) = faces.get(fi).cloned() else { return Ok((0, 1)) };
let chains = trace(&face, g, size)?;
if chains.is_empty() {
@@ -659,6 +661,11 @@ fn split_one(faces: &mut Vec<mt::Face>, fi: usize, g: &Field, size: f64, new_edg
if made == 0 {
return Ok((0, 1));
}
// In the order they were cut: a piece cut again is a piece of the edge it came from.
for (c, (a, b)) in &all_cuts {
rec.piece(a, c);
rec.piece(b, c);
}
let count = pieces.len();
// Neighbours take the cut edges.
let rest: Vec<mt::Face> = faces.iter().enumerate().filter(|(k, _)| *k != fi).map(|(_, f)| recut(f, &all_cuts)).collect();
@@ -802,10 +809,14 @@ pub(crate) struct Imprint {
origin: Vec<usize>,
new_edges: Vec<mt::Edge>,
made: HashMap<usize, usize>,
/// The faces and edges made from the body's (each face over the same region as the body
/// face it came from, or a piece of it; cut edges' pieces).
rec: Rebuild,
}
fn imprint(body: &Body, faces: &[usize], fields: &[Field], size: f64) -> Result<Imprint> {
let mut out = Imprint { shells: Vec::new(), origin: Vec::new(), new_edges: Vec::new(), made: HashMap::new() };
let mut out = Imprint { shells: Vec::new(), origin: Vec::new(), new_edges: Vec::new(), made: HashMap::new(), rec: Rebuild::default() };
let body_faces: Vec<&mt::Face> = body.solid.face_iter().collect();
let mut base = 0;
for sh in body.solid.boundaries() {
let mut fs: Vec<mt::Face> = sh.face_iter().cloned().collect();
@@ -822,7 +833,7 @@ fn imprint(body: &Body, faces: &[usize], fields: &[Field], size: f64) -> Result<
continue;
}
let src = origin.get(k).copied().unwrap_or(0);
let (m, pieces) = split_one(&mut fs, k, g, size, &mut out.new_edges)?;
let (m, pieces) = split_one(&mut fs, k, g, size, &mut out.new_edges, &mut out.rec)?;
*out.made.entry(src).or_default() += m;
// The pieces stand at k..k + pieces; positions after shift.
target = target.iter().take(k).copied().chain(std::iter::repeat_n(true, pieces)).chain(target.iter().skip(k + 1).copied()).collect();
@@ -830,6 +841,11 @@ fn imprint(body: &Body, faces: &[usize], fields: &[Field], size: f64) -> Result<
k += pieces;
}
}
for (f, src) in fs.iter().zip(&origin) {
if let Some(from) = body_faces.get(*src) {
out.rec.whole(f, from);
}
}
out.shells.push(fs);
out.origin.extend(origin);
}
@@ -837,7 +853,8 @@ fn imprint(body: &Body, faces: &[usize], fields: &[Field], size: f64) -> Result<
}
/// Split the given faces (indices in `body`'s face order) where the tool meets them. The solid
/// is unchanged. A listed face the tool misses is an error naming it.
/// is unchanged. A listed face the tool misses is an error naming it. The result's history is
/// over `[body]`: each piece of a split face is Split from it, every other face Kept.
pub fn split_faces(body: &Body, faces: &[usize], tool: &SplitTool) -> Result<Body> {
split_faces_with_map(body, faces, tool).map(|(b, _)| b)
}
@@ -877,20 +894,18 @@ pub(crate) fn split_any(body: &Body, faces: &[usize], tool: &SplitTool) -> Resul
}
let shells = std::mem::take(&mut imp.shells);
let solid = mt::Solid::new_unchecked(shells.into_iter().map(mt::Shell::from).collect());
Ok((
Body {
solid: std::sync::Arc::new(solid),
coarse_missing: None,
mesh: None,
color: body.color,
paint: None,
splits: body.splits.clone(),
face_sources: None,
regularization_protected: true,
history: None,
},
imp,
))
let split = Body {
solid: std::sync::Arc::new(solid),
coarse_missing: None,
mesh: None,
color: body.color,
paint: None,
splits: body.splits.clone(),
face_sources: None,
history: None,
regularization_protected: true,
};
Ok((crate::record::rebuilt_body(split, &[&body.solid], &imp.rec), imp))
})
}
@@ -979,7 +994,8 @@ fn components(faces: Vec<mt::Face>) -> Vec<Vec<mt::Face>> {
/// Split a body into the parts on either side of a tool: a plane, or another face's surface
/// (extended). Faces are divided where the tool meets them and every part is closed with the
/// piece of the tool's surface inside the body; parts come in face order, those on the tool's
/// back first. A body the tool misses comes back whole.
/// back first. A body the tool misses comes back whole. Each part's history is over `[body]`:
/// its faces the body's (or pieces of them), the section new.
pub fn split_body(body: &Body, tool: &SplitTool) -> Result<Vec<Body>> {
body.require_brep("split body")?;
if body.solid.boundaries().len() != 1 {
@@ -1022,8 +1038,9 @@ pub fn split_body(body: &Body, tool: &SplitTool) -> Result<Vec<Body>> {
let imp = imprint(body, &all, &fields, size)?;
let new_edges = imp.new_edges;
if imp.made.values().all(|m| *m == 0) || new_edges.is_empty() {
return Ok(vec![body.clone()]);
return Ok(vec![crate::boolean_record::operand(body, 0, &crate::record::counts(&[body]))]);
}
let mut rec = imp.rec;
let faces: Vec<mt::Face> = imp.shells.into_iter().flatten().collect();
// The section: loops of the new edges on the tool surface, outer loops counter-clockwise
// about its normal and holes inside them.
@@ -1128,6 +1145,10 @@ pub fn split_body(body: &Body, tool: &SplitTool) -> Result<Vec<Body>> {
front.push(f);
}
}
// The section is new: it comes from nothing of the body.
for f in &section {
rec.made(f, Vec::new());
}
// The section closes the back side facing the tool's normal, the front side facing away.
back.extend(section.iter().cloned());
front.extend(section.iter().map(|f| f.inverse()));
@@ -1138,7 +1159,7 @@ pub fn split_body(body: &Body, tool: &SplitTool) -> Result<Vec<Body>> {
if shell.shell_condition() != mt::ShellCondition::Closed {
return Err(KernelError::Failed("a part of the split does not close".into()));
}
parts.push(Body::new(mt::Solid::new_unchecked(vec![shell]))?.with_color(body.color));
parts.push(crate::record::rebuilt(mt::Solid::new_unchecked(vec![shell]), &[&body.solid], &rec)?.with_color(body.color));
}
}
Ok(parts)