feat(kernel): exact face and edge history model

Bodies can now carry the exact history of the operation that made them:
for each result face its origins among the operation's operands (kept,
modified, split, merged, or generated from a profile segment, cap, edge or
offset) and for each edge the faces it separates and the operand edge or
face pair it came from. History is recorded by the operation, never
sampled, and checked against the body when attached.

- history.rs: the vocabulary (moved from provenance.rs), History::check,
  identity, substitute (composes an operation with the one that made an
  operand, including bodies made from a sketch) and face-source propagation.
- One edge order for a body (body::edge_order), shared by tessellation and
  history, so history edge indices are the mesh's edge indices.
- Transforms report an identity history; placed_topology documents the
  face/edge order guarantee it relies on.
- provenance.rs keeps the geometric trace for operations that do not yet
  report history.

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 65a99608d9
commit c87efa1f35
9 files changed
+530 -99

No files matched your search

+51 -21
View File
@@ -35,6 +35,9 @@ pub struct Body {
pub(crate) face_sources: Option<std::sync::Arc<Vec<Option<FaceSource>>>>,
/// Sticky transient veto: authored face boundaries must survive later rebuilds.
pub(crate) regularization_protected: bool,
/// Exact history of the operation that made this body, relative to that operation's
/// operands; `None` when the operation does not report one.
pub(crate) history: Option<std::sync::Arc<crate::history::History>>,
}
#[derive(Clone, Debug, PartialEq)]
@@ -334,6 +337,31 @@ pub(crate) fn from_p3(p: mt::Point3) -> Vec3 {
Vec3::new(p.x, p.y, p.z)
}
/// The distinct edges of `faces` in order of first use, and the faces (by position) each one
/// bounds: the one edge order of a body, used by [`Body::edges`], tessellations and
/// [`crate::history`].
pub(crate) fn edge_order<'a, P: 'a, C: 'a, S: 'a>(
faces: impl IntoIterator<Item = &'a truck_topology::Face<P, C, S>>,
) -> (Vec<truck_topology::Edge<P, C>>, Vec<Vec<usize>>) {
let mut index = std::collections::HashMap::new();
let (mut edges, mut bounds): (Vec<truck_topology::Edge<P, C>>, Vec<Vec<usize>>) = (Vec::new(), Vec::new());
for (fi, f) in faces.into_iter().enumerate() {
for e in f.edge_iter() {
let i = *index.entry(e.id()).or_insert_with(|| {
edges.push(e.clone());
bounds.push(Vec::new());
edges.len() - 1
});
if let Some(slot) = bounds.get_mut(i)
&& !slot.contains(&fi)
{
slot.push(fi);
}
}
}
(edges, bounds)
}
fn polyline_mid(pts: &[Vec3]) -> (Vec3, f64) {
let len: f64 = pts.windows(2).map(|w| w.first().zip(w.get(1)).map(|(a, b)| a.dist(*b)).unwrap_or(0.0)).sum();
let mut acc = 0.0;
@@ -363,6 +391,11 @@ impl Body {
&& self.splits == other.splits
&& self.face_sources == other.face_sources
&& self.regularization_protected == other.regularization_protected
&& match (&self.history, &other.history) {
(Some(a), Some(b)) => std::sync::Arc::ptr_eq(a, b),
(None, None) => true,
_ => false,
}
}
pub(crate) fn new(solid: Solid) -> Result<Body> {
@@ -402,6 +435,7 @@ impl Body {
splits: None,
face_sources: None,
regularization_protected: false,
history: None,
})
}
@@ -410,6 +444,20 @@ impl Body {
self
}
/// Exact history of the operation that made this body, relative to that operation's
/// operands (see [`crate::history`]); `None` when the operation does not report one.
pub fn history(&self) -> Option<&crate::history::History> {
self.history.as_deref()
}
/// Attach the history of the operation that made this body from `operands`, after checking
/// it describes this body.
pub(crate) fn with_history(mut self, history: crate::history::History, operands: &[&Body]) -> Result<Self> {
history.check(&self, operands)?;
self.history = Some(std::sync::Arc::new(history));
Ok(self)
}
pub fn face_sources(&self) -> Option<&[Option<FaceSource>]> {
self.face_sources.as_deref().map(Vec::as_slice)
}
@@ -768,27 +816,9 @@ impl Body {
}
}
}
let mut seen = std::collections::HashMap::new();
for e in meshed.iter().flat_map(|s| s.edge_iter()) {
if seen.contains_key(&e.id()) {
continue;
}
seen.insert(e.id(), out.edges.len());
let c = e.oriented_curve();
out.edges.push(c.0.iter().map(|p| Vec3::new(p.x, p.y, p.z)).collect());
}
// Which faces each edge bounds.
out.edge_faces = vec![Vec::new(); out.edges.len()];
for (fi, face) in meshed.iter().flat_map(|s| s.face_iter()).enumerate() {
for e in face.edge_iter() {
if let Some(slot) = seen.get(&e.id()).and_then(|i| out.edge_faces.get_mut(*i)) {
let f = u32::try_from(fi).unwrap_or(u32::MAX);
if !slot.contains(&f) {
slot.push(f);
}
}
}
}
let (edges, faces) = edge_order(meshed.iter().flat_map(|s| s.face_iter()));
out.edges = edges.iter().map(|e| e.oriented_curve().0.iter().map(|p| Vec3::new(p.x, p.y, p.z)).collect()).collect();
out.edge_faces = faces.into_iter().map(|fs| fs.into_iter().map(|f| u32::try_from(f).unwrap_or(u32::MAX)).collect()).collect();
Ok(out)
})
}
+267
View File
@@ -0,0 +1,267 @@
//! Exact face and edge history: for each face and edge of an operation's result, what it came
//! from among the operation's operands, recorded by the code that built it (never sampled).
//!
//! Indices are positions in a body's face order (`face_iter`) and edge order (`unique_edges`,
//! the order of [`Body::edges`] and of a tessellation's edges). Operand numbers are positions in
//! the operation's own operand list, which each operation documents. A body made by an
//! operation carries its history (see [`Body::history`]); [`History::substitute`] composes an
//! operation with one that made one of its operands.
use std::collections::BTreeSet;
use crate::body::{Body, FaceSource, Solid};
use crate::{KernelError, Result};
/// Where a result face came from.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum FaceOrigin {
/// A face of input `operand` (its index in the operand's face order).
Face { operand: usize, face: usize },
/// Swept from segment `segment` of the profile's loops (in order: outer, then holes).
Profile { segment: usize },
/// The start or end cap of a sweep (the profile itself, or where it ends up).
Cap { end: bool },
/// Made in place of edge `edge` of input `operand` (a fillet or chamfer).
Edge { operand: usize, edge: usize },
/// An offset of face `face` of input `operand` (a shell's inner wall).
Offset { operand: usize, face: usize },
}
/// How a result face relates to its origins.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Change {
/// The same face as its origin (same region).
Kept,
/// Its origin's surface, trimmed differently.
Modified,
/// One of several result faces made from one input face.
Split,
/// Made from several input faces (coplanar or cosurface faces joined).
Merged,
/// New to the operation (no input face under it).
Generated,
}
#[derive(Clone, Debug, PartialEq)]
pub struct FaceProv {
pub origins: Vec<FaceOrigin>,
pub change: Change,
}
/// Where a result edge came from.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum EdgeOrigin {
/// (Part of) edge `edge` of input `operand`.
Edge { operand: usize, edge: usize },
/// Where two faces meet that met nowhere before (a boolean's intersection curve).
Meet { a: FaceOrigin, b: FaceOrigin },
}
/// A result edge: the result faces it separates and what it came from (empty when it is new
/// and lies between faces new to the operation).
#[derive(Clone, Debug, PartialEq)]
pub struct EdgeProv {
pub faces: Vec<usize>,
pub origins: Vec<EdgeOrigin>,
}
/// History of an operation's result: one entry per face and per edge, in the result's orders.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct History {
/// How many operands the operation had.
pub operands: usize,
pub faces: Vec<FaceProv>,
pub edges: Vec<EdgeProv>,
}
fn failed(detail: &str) -> KernelError {
KernelError::Failed(format!("history: {detail}"))
}
impl FaceOrigin {
/// The same origin with its operand (if it has one) renumbered by `f`.
fn map_operand(self, f: impl Fn(usize) -> usize) -> FaceOrigin {
match self {
FaceOrigin::Face { operand, face } => FaceOrigin::Face { operand: f(operand), face },
FaceOrigin::Edge { operand, edge } => FaceOrigin::Edge { operand: f(operand), edge },
FaceOrigin::Offset { operand, face } => FaceOrigin::Offset { operand: f(operand), face },
other => other,
}
}
}
impl EdgeOrigin {
/// The same origin with its operands renumbered by `f`.
fn map_operand(self, f: impl Fn(usize) -> usize) -> EdgeOrigin {
match self {
EdgeOrigin::Edge { operand, edge } => EdgeOrigin::Edge { operand: f(operand), edge },
EdgeOrigin::Meet { a, b } => EdgeOrigin::Meet { a: a.map_operand(&f), b: b.map_operand(&f) },
}
}
}
impl History {
/// The history of an operation that keeps every face and edge of its one operand in place
/// (a rigid move, a rebuild that keeps the topology's order).
pub fn identity(body: &Body) -> History {
let faces =
(0..body.face_count()).map(|face| FaceProv { origins: vec![FaceOrigin::Face { operand: 0, face }], change: Change::Kept }).collect();
let edges = edge_faces(&body.solid)
.into_iter()
.enumerate()
.map(|(edge, faces)| EdgeProv { faces, origins: vec![EdgeOrigin::Edge { operand: 0, edge }] })
.collect();
History { operands: 1, faces, edges }
}
/// Check that this history describes `result` made from `operands`: one entry per face and
/// edge, and every origin within its operand's faces and edges.
pub fn check(&self, result: &Body, operands: &[&Body]) -> Result<()> {
if self.operands != operands.len() {
return Err(failed("operand count does not match"));
}
if self.faces.len() != result.face_count() || self.edges.len() != result.edge_count() {
return Err(failed("entries do not match the result's faces and edges"));
}
let counts: Vec<(usize, usize)> = operands.iter().map(|b| (b.face_count(), b.edge_count())).collect();
let face_ok = |o: &FaceOrigin| match *o {
FaceOrigin::Face { operand, face } | FaceOrigin::Offset { operand, face } => counts.get(operand).is_some_and(|c| face < c.0),
FaceOrigin::Edge { operand, edge } => counts.get(operand).is_some_and(|c| edge < c.1),
FaceOrigin::Profile { .. } | FaceOrigin::Cap { .. } => true,
};
if !self.faces.iter().all(|f| f.origins.iter().all(face_ok)) {
return Err(failed("a face origin lies outside its operand"));
}
for e in &self.edges {
if e.faces.iter().any(|f| *f >= self.faces.len()) {
return Err(failed("an edge lies on a face outside the result"));
}
let ok = e.origins.iter().all(|o| match o {
EdgeOrigin::Edge { operand, edge } => counts.get(*operand).is_some_and(|c| *edge < c.1),
EdgeOrigin::Meet { a, b } => face_ok(a) && face_ok(b),
});
if !ok {
return Err(failed("an edge origin lies outside its operand"));
}
}
Ok(())
}
/// Compose with the history of the operation that made operand `k`: origins on operand `k`
/// become that operation's origins, its operands (none, for a body made from a sketch)
/// taking `k`'s place in the operand list. Fails, without changing `self`, where a lineage
/// cannot be expressed (an offset of a face that operation generated, a blend of an edge
/// it generated, an edge between merged faces).
pub fn substitute(&mut self, k: usize, inner: &History) -> Result<()> {
if k >= self.operands {
return Err(failed("no such operand"));
}
let n = inner.operands;
// Operands before k stay, k's come in at k, those after k move by n - 1.
let outer = |op: usize| if op < k { op } else { op + n - 1 };
let lifted = |op: usize| op + k;
let inner_face = |face: usize| inner.faces.get(face).ok_or_else(|| failed("a face origin lies outside the inner result"));
let inner_edge = |edge: usize| inner.edges.get(edge).ok_or_else(|| failed("an edge origin lies outside the inner result"));
// A face origin as origins of the composed operation, with the inner face's change.
let map_face = |o: FaceOrigin| -> Result<(Vec<FaceOrigin>, Option<Change>)> {
match o {
FaceOrigin::Face { operand, face } if operand == k => {
let p = inner_face(face)?;
Ok((p.origins.iter().map(|x| x.map_operand(lifted)).collect(), Some(p.change)))
}
FaceOrigin::Offset { operand, face } if operand == k => match inner_face(face)?.origins.as_slice() {
[FaceOrigin::Face { operand, face }] => Ok((vec![FaceOrigin::Offset { operand: lifted(*operand), face: *face }], None)),
_ => Err(failed("an offset of a face the inner operation generated or merged")),
},
FaceOrigin::Edge { operand, edge } if operand == k => match inner_edge(edge)?.origins.as_slice() {
[EdgeOrigin::Edge { operand, edge }] => Ok((vec![FaceOrigin::Edge { operand: lifted(*operand), edge: *edge }], None)),
_ => Err(failed("a blend of an edge the inner operation generated")),
},
other => Ok((vec![other.map_operand(outer)], None)),
}
};
let mut faces = Vec::with_capacity(self.faces.len());
for f in &self.faces {
let mut origins = BTreeSet::new();
let (mut split, mut kept) = (f.change == Change::Split, f.change == Change::Kept);
for o in &f.origins {
let (os, change) = map_face(*o)?;
origins.extend(os);
split |= change == Some(Change::Split);
kept &= change.is_none_or(|c| c == Change::Kept);
}
let origins: Vec<FaceOrigin> = origins.into_iter().collect();
let change = if f.change == Change::Generated || !origins.iter().any(|o| matches!(o, FaceOrigin::Face { .. })) {
Change::Generated
} else if origins.len() > 1 {
Change::Merged
} else if split {
Change::Split
} else if kept {
Change::Kept
} else {
Change::Modified
};
faces.push(FaceProv { origins, change });
}
// One side of a meeting: a single face of the composed operation.
let side = |o: FaceOrigin| -> Result<FaceOrigin> {
match map_face(o)?.0.as_slice() {
[x] => Ok(*x),
_ => Err(failed("an edge meets a face the inner operation merged")),
}
};
let mut edges = Vec::with_capacity(self.edges.len());
for e in &self.edges {
let mut origins = BTreeSet::new();
for o in &e.origins {
match *o {
EdgeOrigin::Edge { operand, edge } if operand == k => {
origins.extend(inner_edge(edge)?.origins.iter().map(|x| x.map_operand(lifted)))
}
EdgeOrigin::Edge { .. } => {
origins.insert(o.map_operand(outer));
}
EdgeOrigin::Meet { a, b } => {
origins.insert(EdgeOrigin::Meet { a: side(a)?, b: side(b)? });
}
}
}
edges.push(EdgeProv { faces: e.faces.clone(), origins: origins.into_iter().collect() });
}
self.operands = self.operands + n - 1;
self.faces = faces;
self.edges = edges;
Ok(())
}
/// The result's face sources, carried from its operands' through this history: a face
/// kept, modified or split from one face keeps that face's source; a merged face keeps the
/// source its origins share, if they share one; a generated face has none.
pub fn face_sources(&self, operands: &[&Body]) -> Vec<Option<FaceSource>> {
let source = |o: &FaceOrigin| match *o {
FaceOrigin::Face { operand, face } => operands.get(operand).and_then(|b| b.face_sources()).and_then(|s| s.get(face)).cloned().flatten(),
_ => None,
};
self.faces
.iter()
.map(|f| {
if f.change == Change::Generated {
return None;
}
let mut sources = f.origins.iter().map(source);
let first = sources.next().flatten()?;
sources.all(|s| s.as_ref() == Some(&first)).then_some(first)
})
.collect()
}
}
/// The faces each edge separates, in the body's edge order.
pub(crate) fn edge_faces(solid: &Solid) -> Vec<Vec<usize>> {
crate::body::edge_order(solid.face_iter()).1
}
#[cfg(test)]
#[path = "history_tests.rs"]
mod tests;
+174
View File
@@ -0,0 +1,174 @@
use solvecraft_geom::Vec3;
use super::{Change, EdgeOrigin, EdgeProv, FaceOrigin, FaceProv, History};
use crate::{Body, box_solid, transform};
fn face(operand: usize, face: usize) -> FaceOrigin {
FaceOrigin::Face { operand, face }
}
fn prov(origins: Vec<FaceOrigin>, change: Change) -> FaceProv {
FaceProv { origins, change }
}
fn centroids(b: &Body) -> Vec<Vec3> {
b.faces(1e-3).unwrap().into_iter().map(|f| f.centroid).collect()
}
#[test]
fn a_transform_keeps_every_face_and_edge_in_place() {
let a = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 10.0)).unwrap();
let t = Vec3::new(5.0, -3.0, 2.0);
let moved = transform(&a, t, Vec3::ZERO, Vec3::Z, 0.7).unwrap();
let h = moved.history().unwrap();
h.check(&moved, &[&a]).unwrap();
assert!(h.faces.iter().enumerate().all(|(i, f)| f.origins == vec![face(0, i)] && f.change == Change::Kept));
// Face i of the result is face i of the input, moved.
let rot = |p: Vec3| {
let (s, c) = 0.7f64.sin_cos();
Vec3::new(p.x * c - p.y * s, p.x * s + p.y * c, p.z) + t
};
for (before, after) in centroids(&a).into_iter().zip(centroids(&moved)) {
assert!(rot(before).dist(after) < 1e-6, "{before:?} → {after:?}");
}
// Each edge separates the same faces it did.
let (ea, em) = (super::edge_faces(&a.solid), super::edge_faces(&moved.solid));
assert_eq!(ea, em);
assert!(h.edges.iter().all(|e| e.faces.len() == 2 && e.origins.len() == 1));
// The history's edges are the tessellation's edges, bounding the same faces.
let mesh = moved.tessellate(1e-3).unwrap();
let mesh_faces: Vec<Vec<usize>> = mesh.edge_faces.iter().map(|fs| fs.iter().map(|f| *f as usize).collect()).collect();
assert_eq!(mesh_faces, h.edges.iter().map(|e| e.faces.clone()).collect::<Vec<_>>());
}
/// Joining an extrusion: the tool came from a sketch (no operand bodies), so substituting its
/// history removes it from the operand list and its faces become generated profile faces.
#[test]
fn substitute_a_body_made_from_a_sketch() {
let extrude = History {
operands: 0,
faces: vec![prov(vec![FaceOrigin::Cap { end: false }], Change::Generated), prov(vec![FaceOrigin::Profile { segment: 3 }], Change::Generated)],
edges: vec![EdgeProv { faces: vec![0, 1], origins: vec![] }],
};
let mut join = History {
operands: 2,
faces: vec![prov(vec![face(0, 2)], Change::Modified), prov(vec![face(1, 1)], Change::Kept)],
edges: vec![EdgeProv { faces: vec![0, 1], origins: vec![EdgeOrigin::Meet { a: face(0, 2), b: face(1, 1) }] }],
};
join.substitute(1, &extrude).unwrap();
assert_eq!(join.operands, 1);
assert_eq!(join.faces[0], prov(vec![face(0, 2)], Change::Modified));
assert_eq!(join.faces[1], prov(vec![FaceOrigin::Profile { segment: 3 }], Change::Generated));
assert_eq!(join.edges[0].origins, vec![EdgeOrigin::Meet { a: face(0, 2), b: FaceOrigin::Profile { segment: 3 } }]);
}
#[test]
fn bodies_without_reported_history_have_none() {
let a = box_solid(Vec3::ZERO, Vec3::new(4.0, 3.0, 1.0)).unwrap();
assert!(a.history().is_none());
}
#[test]
fn check_refuses_a_history_that_does_not_describe_the_body() {
let a = box_solid(Vec3::ZERO, Vec3::new(4.0, 3.0, 1.0)).unwrap();
let mut h = History::identity(&a);
h.check(&a, &[&a]).unwrap();
assert!(h.check(&a, &[]).is_err(), "operand count");
let mut short = h.clone();
short.faces.pop();
assert!(short.check(&a, &[&a]).is_err(), "face count");
h.faces[0].origins = vec![face(0, 6)];
assert!(h.check(&a, &[&a]).is_err(), "origin beyond the operand's faces");
assert!(a.clone().with_history(h, &[&a]).is_err());
}
/// A two-step chain: op1 makes B from (A0, A1); op2 makes C from (X, B, Y). Composing puts
/// A0, A1 in B's place: C's operands become (X, A0, A1, Y).
#[test]
fn substitute_composes_kept_split_merged_and_generated_faces() {
let op1 = History {
operands: 2,
faces: vec![
prov(vec![face(0, 0)], Change::Kept), // B0 = A0.f0
prov(vec![face(0, 1)], Change::Split), // B1, B2 = pieces of A0.f1
prov(vec![face(0, 1)], Change::Split), //
prov(vec![face(1, 4)], Change::Modified), // B3 = A1.f4 trimmed
prov(vec![FaceOrigin::Profile { segment: 2 }], Change::Generated), // B4 new
],
edges: vec![
EdgeProv { faces: vec![0, 1], origins: vec![EdgeOrigin::Edge { operand: 0, edge: 7 }] },
EdgeProv { faces: vec![3, 4], origins: vec![EdgeOrigin::Meet { a: face(1, 4), b: FaceOrigin::Profile { segment: 2 } }] },
],
};
let mut op2 = History {
operands: 3,
faces: vec![
prov(vec![face(1, 0)], Change::Kept), // kept B0 → kept A0.f0
prov(vec![face(1, 1), face(1, 2)], Change::Merged), // B1 + B2 rejoined → one origin
prov(vec![face(1, 3)], Change::Kept), // kept B3 → modified A1.f4
prov(vec![face(1, 4)], Change::Modified), // trimmed generated face
prov(vec![face(0, 5), face(2, 1)], Change::Merged), // X and Y faces renumbered
prov(vec![FaceOrigin::Edge { operand: 1, edge: 0 }], Change::Generated), // blend of B's edge 0
],
edges: vec![
EdgeProv { faces: vec![0, 1], origins: vec![EdgeOrigin::Edge { operand: 1, edge: 1 }] },
EdgeProv { faces: vec![2, 4], origins: vec![EdgeOrigin::Meet { a: face(1, 3), b: face(2, 1) }] },
EdgeProv { faces: vec![4, 5], origins: vec![EdgeOrigin::Edge { operand: 2, edge: 3 }] },
],
};
op2.substitute(1, &op1).unwrap();
assert_eq!(op2.operands, 4);
let f = &op2.faces;
assert_eq!(f[0], prov(vec![face(1, 0)], Change::Kept));
// Two pieces of one face, merged back: one origin, so not a merge across faces.
assert_eq!(f[1], prov(vec![face(1, 1)], Change::Split));
assert_eq!(f[2], prov(vec![face(2, 4)], Change::Modified));
assert_eq!(f[3], prov(vec![FaceOrigin::Profile { segment: 2 }], Change::Generated));
assert_eq!(f[4], prov(vec![face(0, 5), face(3, 1)], Change::Merged));
assert_eq!(f[5], prov(vec![FaceOrigin::Edge { operand: 1, edge: 7 }], Change::Generated));
let e = &op2.edges;
assert_eq!(e[0].origins, vec![EdgeOrigin::Meet { a: face(2, 4), b: FaceOrigin::Profile { segment: 2 } }]);
assert_eq!(e[1].origins, vec![EdgeOrigin::Meet { a: face(2, 4), b: face(3, 1) }]);
assert_eq!(e[2].origins, vec![EdgeOrigin::Edge { operand: 3, edge: 3 }]);
}
#[test]
fn substitute_refuses_lineage_it_cannot_express_and_leaves_history_unchanged() {
let inner = History {
operands: 1,
faces: vec![prov(vec![FaceOrigin::Profile { segment: 0 }], Change::Generated)],
edges: vec![EdgeProv { faces: vec![0], origins: vec![] }],
};
// An offset of a face the inner operation generated.
let mut offset = History { operands: 1, faces: vec![prov(vec![FaceOrigin::Offset { operand: 0, face: 0 }], Change::Generated)], edges: vec![] };
let before = offset.clone();
assert!(offset.substitute(0, &inner).is_err());
assert_eq!(offset, before);
// A blend of an edge the inner operation generated.
let mut blend = History { operands: 1, faces: vec![prov(vec![FaceOrigin::Edge { operand: 0, edge: 0 }], Change::Generated)], edges: vec![] };
assert!(blend.substitute(0, &inner).is_err());
// Out-of-range references and operands.
let mut bad = History { operands: 1, faces: vec![prov(vec![face(0, 9)], Change::Kept)], edges: vec![] };
assert!(bad.substitute(0, &inner).is_err());
assert!(bad.substitute(3, &inner).is_err());
}
#[test]
fn face_sources_follow_kept_split_and_agreeing_merged_faces() {
let a = box_solid(Vec3::ZERO, Vec3::new(4.0, 3.0, 1.0)).unwrap().with_box_certificate(7).unwrap();
let src = |i: usize| a.face_sources().unwrap()[i].clone();
assert!(src(0).is_some() && src(1).is_some() && src(0) != src(1));
let h = History {
operands: 1,
faces: vec![
prov(vec![face(0, 0)], Change::Kept),
prov(vec![face(0, 1)], Change::Split),
prov(vec![face(0, 0), face(0, 1)], Change::Merged),
prov(vec![face(0, 2), face(0, 2)], Change::Merged),
prov(vec![FaceOrigin::Cap { end: true }], Change::Generated),
],
edges: vec![],
};
let s = h.face_sources(&[&a]);
assert_eq!(s, vec![src(0), src(1), None, src(2), None]);
}
+1
View File
@@ -29,6 +29,7 @@ mod freeform;
pub mod fuzz;
mod heal;
mod helix;
pub mod history;
mod iges_in;
mod iges_out;
#[cfg(test)]
+2
View File
@@ -109,6 +109,7 @@ pub fn mesh_body(positions: &[Vec3], triangles: &[[u32; 3]]) -> Result<Body> {
paint: None,
splits: None,
face_sources: None,
history: None,
regularization_protected: false,
})
}
@@ -202,6 +203,7 @@ impl Body {
paint: self.paint.clone(),
splits: self.splits.clone(),
face_sources: None,
history: None,
regularization_protected: self.regularization_protected,
})
}
+20 -16
View File
@@ -647,24 +647,28 @@ pub fn transform_matrix(body: &Body, m: [[f64; 4]; 4]) -> Result<Body> {
if m.iter().flatten().any(|x| !x.is_finite()) {
return Err(KernelError::Invalid("non-finite transform".into()));
}
if let Some(b) = body.mesh_transformed(&m) {
return Ok(b);
}
guard("transform", || {
let mat = mt::Matrix4::new(
m[0][0], m[0][1], m[0][2], m[0][3], m[1][0], m[1][1], m[1][2], m[1][3], m[2][0], m[2][1], m[2][2], m[2][3], m[3][0], m[3][1], m[3][2],
m[3][3],
);
// An affine placement retains face order and appearance metadata.
let sources = body.transformed_sources(m)?;
Body::new(placed_topology(&body.solid, mat)?).map(|placed| {
placed.with_color(body.color()).with_paint(body.paint().cloned()).with_face_sources(sources).inherit_regularization_protection(body)
})
})
let placed = match body.mesh_transformed(&m) {
Some(b) => b,
None => guard("transform", || {
let mat = mt::Matrix4::new(
m[0][0], m[0][1], m[0][2], m[0][3], m[1][0], m[1][1], m[1][2], m[1][3], m[2][0], m[2][1], m[2][2], m[2][3], m[3][0], m[3][1],
m[3][2], m[3][3],
);
// An affine placement retains face order and appearance metadata.
let sources = body.transformed_sources(m)?;
Body::new(placed_topology(&body.solid, mat)?).map(|b| {
b.with_color(body.color()).with_paint(body.paint().cloned()).with_face_sources(sources).inherit_regularization_protection(body)
})
})?,
};
let history = crate::history::History::identity(&placed);
placed.with_history(history, &[body])
}
// A STEP periodic face may traverse a seam edge twice. Generic topology builders
// reject that wire while cloning, even though placement preserves its topology.
/// `source` moved by `matrix`, keeping its face and edge order (transforms report an identity
/// [`crate::history::History`] on that basis). Built here rather than by generic topology
/// builders: a STEP periodic face may traverse a seam edge twice, and those reject that wire
/// while cloning even though placement preserves its topology.
pub(crate) fn placed_topology(source: &Solid, matrix: mt::Matrix4) -> Result<Solid> {
use mt::{Transform, Transformed};
use std::collections::HashMap;
+12 -62
View File
@@ -1,4 +1,5 @@
//! Face and edge provenance: where each face (and edge) of an operation's result came from.
//! Face and edge provenance found by geometry: where each face (and edge) of an operation's
//! result came from, for operations that do not yet report exact [`crate::history`].
//!
//! [`trace`] works for any operation: a result face that lies on an input face (on its surface,
//! inside its trimmed region) comes from that face; one that lies on none is generated by the
@@ -13,58 +14,7 @@ use solvecraft_geom::{Mesh, Plane, Region2, Seg2, Vec2, Vec3};
use crate::Result;
use crate::body::Body;
/// Where a result face came from.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum FaceOrigin {
/// A face of input `operand` (its index in the operand's face order).
Face { operand: usize, face: usize },
/// Swept from segment `segment` of the profile's loops (in order: outer, then holes).
Profile { segment: usize },
/// The start or end cap of a sweep (the profile itself, or where it ends up).
Cap { end: bool },
/// Made in place of edge `edge` of input `operand` (a fillet or chamfer).
Edge { operand: usize, edge: usize },
/// An offset of face `face` of input `operand` (a shell's inner wall).
Offset { operand: usize, face: usize },
}
/// How a result face relates to its origins.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Change {
/// The same face as its origin (same region).
Kept,
/// Its origin's surface, trimmed differently.
Modified,
/// One of several result faces made from one input face.
Split,
/// Made from several input faces (coplanar or cosurface faces joined).
Merged,
/// New to the operation (no input face under it).
Generated,
}
#[derive(Clone, Debug, PartialEq)]
pub struct FaceProv {
pub origins: Vec<FaceOrigin>,
pub change: Change,
}
/// Where a result edge came from: the result faces it separates and, when it lies on an edge
/// of an input body, that edge.
#[derive(Clone, Debug, PartialEq)]
pub struct EdgeProv {
pub faces: Vec<usize>,
pub origin: Option<(usize, usize)>,
}
/// Provenance of an operation's result: one entry per face (in the result's face order) and
/// per edge (in its mesh's edge order).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Provenance {
pub faces: Vec<FaceProv>,
pub edges: Vec<EdgeProv>,
}
use crate::history::{Change, EdgeOrigin, EdgeProv, FaceOrigin, FaceProv, History};
/// Up to this many sample points per result face.
const SAMPLES: usize = 24;
@@ -153,7 +103,7 @@ fn face_samples(f: &FaceTris) -> Vec<(Vec3, Vec3)> {
}
/// Provenance of `result` from the bodies it was made of (in operand order), by geometry.
pub fn trace(result: &Body, inputs: &[&Body]) -> Result<Provenance> {
pub fn trace(result: &Body, inputs: &[&Body]) -> Result<History> {
let size = inputs.iter().map(|b| b.size()).fold(result.size(), f64::max).max(1e-9);
let mesh_tol = size * 2e-4;
let tol = mesh_tol * 2.0;
@@ -221,9 +171,9 @@ pub fn trace(result: &Body, inputs: &[&Body]) -> Result<Provenance> {
}
}
}
edges.push(EdgeProv { faces: fs, origin });
edges.push(EdgeProv { faces: fs, origins: origin.map(|(operand, edge)| EdgeOrigin::Edge { operand, edge }).into_iter().collect() });
}
Ok(Provenance { faces, edges })
Ok(History { operands: inputs.len(), faces, edges })
}
/// The profile segments of the loops, in [`FaceOrigin::Profile`] order (outer, then holes).
@@ -320,7 +270,7 @@ pub fn revolve_origins(tool: &Body, plane: &Plane, regions: &[Region2], axis_ori
/// Fill in generated faces of a fillet or chamfer (`result` from `input` with `edges` blended,
/// edge indices in `input`'s mesh order): each blend face comes from the edge it lies along.
pub fn blend_origins(result: &Body, input: &Body, edges: &[usize], prov: &mut Provenance) -> Result<()> {
pub fn blend_origins(result: &Body, input: &Body, edges: &[usize], prov: &mut History) -> Result<()> {
let size = input.size().max(1e-9);
let rm = result.tessellate(size * 2e-4)?;
let im = input.tessellate(size * 2e-4)?;
@@ -346,7 +296,7 @@ pub fn blend_origins(result: &Body, input: &Body, edges: &[usize], prov: &mut Pr
/// Fill in generated faces of a shell or offset (`result` from `input`, walls `thickness`
/// inside): an inner face whose points, moved back out by the thickness, land on an input face
/// comes from that face ([`FaceOrigin::Offset`]).
pub fn offset_origins(result: &Body, input: &Body, thickness: f64, prov: &mut Provenance) -> Result<()> {
pub fn offset_origins(result: &Body, input: &Body, thickness: f64, prov: &mut History) -> Result<()> {
let size = input.size().max(1e-9);
let tol = size * 4e-4;
let rm = result.tessellate(size * 2e-4)?;
@@ -431,7 +381,7 @@ pub fn sweep_origins(tool: &Body, plane: &Plane, regions: &[Region2]) -> Result<
/// Replace origins on operand `operand` (a tool made by the operation) with that tool's own
/// origins (`tool[k]` for its face k): a result face from the tool's side face k comes from the
/// profile segment that swept it.
pub fn compose(prov: &mut Provenance, operand: usize, tool: &[FaceOrigin]) {
pub fn compose(prov: &mut History, operand: usize, tool: &[FaceOrigin]) {
for fp in &mut prov.faces {
for o in &mut fp.origins {
if let FaceOrigin::Face { operand: op, face } = *o
@@ -501,7 +451,7 @@ mod tests {
let m = cut.tessellate(1e-3).unwrap();
let p = trace(&cut, &[&a, &pin]).unwrap();
let corner = (0..m.edges.len()).find(|i| m.edges[*i].iter().all(|q| q.x.abs() < 1e-9 && q.y.abs() < 1e-9)).unwrap();
assert!(matches!(p.edges[corner].origin, Some((0, _))));
assert!(matches!(p.edges[corner].origins[..], [super::EdgeOrigin::Edge { operand: 0, .. }]));
assert_eq!(p.edges[corner].faces.len(), 2);
}
@@ -509,7 +459,7 @@ mod tests {
fn provenance_of_sweeps_blends_and_shells() {
use solvecraft_geom::{Loop2, Plane, Region2, Vec2};
use super::{Provenance, blend_origins, compose, extrude_origins, offset_origins, revolve_origins};
use super::{History, blend_origins, compose, extrude_origins, offset_origins, revolve_origins};
let plane = Plane::named("XY").unwrap();
// A 40 × 30 rectangle (segments 0: bottom, 1: right, 2: top, 3: left) with a hole.
let rect = Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(40.0, 0.0), Vec2::new(40.0, 30.0), Vec2::new(0.0, 30.0)]);
@@ -543,7 +493,7 @@ mod tests {
let bm = b.tessellate(1e-3).unwrap();
let e = (0..bm.edges.len()).find(|i| bm.edges[*i].iter().all(|q| (q.y - 30.0).abs() < 1e-9 && (q.z - 20.0).abs() < 1e-9)).unwrap();
let f = crate::fillet(&b, &[Vec3::new(20.0, 30.0, 20.0)], 5.0).unwrap();
let mut p: Provenance = trace(&f, &[&b]).unwrap();
let mut p: History = trace(&f, &[&b]).unwrap();
blend_origins(&f, &b, &[e], &mut p).unwrap();
let blend = face_at(&f, Vec3::new(20.0, 25.0 + 5.0 * std::f64::consts::FRAC_1_SQRT_2, 15.0 + 5.0 * std::f64::consts::FRAC_1_SQRT_2));
assert_eq!(p.faces[blend].origins, vec![FaceOrigin::Edge { operand: 0, edge: e }]);
+1
View File
@@ -887,6 +887,7 @@ pub(crate) fn split_any(body: &Body, faces: &[usize], tool: &SplitTool) -> Resul
splits: body.splits.clone(),
face_sources: None,
regularization_protected: true,
history: None,
},
imp,
))
+2
View File
@@ -31,6 +31,7 @@ fn surface_body(faces: Vec<mt::Face>) -> Body {
splits: None,
face_sources: None,
regularization_protected: false,
history: None,
}
}
@@ -49,6 +50,7 @@ fn body_of(faces: Vec<mt::Face>) -> Result<Body> {
splits: None,
face_sources: None,
regularization_protected: false,
history: None,
})
}
}