feat(kernel): an edge's meeting sides are sets of origins (merged faces)

EdgeOrigin::Meet now names each side as a Side: the sorted, de-duplicated
origins of the face there. substitute maps a side to the union of the inner
faces' origins, so a meeting with a face an inner operation merged keeps its
lineage; meeting() derives sides from whole faces. Fillets after box cuts
that leave coplanar pieces now report exact history. Prism lineage meets a
cap of several origins and resolves wall junctions away from shared
vertices by the operand's one edge between the walls. bore_union's recut
reports history only for a one-face bore (pieces of a two-arc bore's recut
wall were given both arcs).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
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 08d7037176
commit 43ddde4a5d
11 files changed
+347 -76

No files matched your search

+54 -8
View File
@@ -7,7 +7,7 @@ use std::collections::BTreeSet;
use solvecraft_geom::{Mesh, Vec3};
use crate::body::Body;
use crate::history::{Change, EdgeOrigin, FaceOrigin, History};
use crate::history::{Change, EdgeOrigin, FaceOrigin, History, Side};
use crate::{BoolOp, boolean, box_solid};
const fn face(operand: usize, face: usize) -> FaceOrigin {
@@ -98,9 +98,9 @@ pub(crate) fn checked<'a>(r: &'a Body, operands: &[&Body], tol: f64, at: Reach,
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),
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 } => a.origins().iter().any(|o| on(o, *p)) && b.origins().iter().any(|o| on(o, *p)),
};
for (i, f) in h.faces.iter().enumerate() {
assert!(!f.origins.is_empty() || generated(i), "face {i} has no origin");
@@ -116,8 +116,12 @@ pub(crate) fn checked<'a>(r: &'a Body, operands: &[&Body], tol: f64, at: Reach,
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"),
// A joined edge runs along all of its origins, each along part of it (one in the
// middle of a straight edge holds none of its points, but its own lie on it).
EdgeOrigin::Edge { operand, edge } if e.origins.len() > 1 => assert!(
pts.iter().any(|p| along(o, p)) || ms[*operand].edges[*edge].iter().any(|q| to_edge(&rm, j, *q) < near.max(at.moved)),
"edge {j}: {o:?} is not along it"
),
_ => assert!(pts.iter().all(|p| along(o, p)), "edge {j} ({e:?}) does not lie along {o:?}"),
}
}
@@ -160,9 +164,10 @@ 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.
/// The meetings among the edges' origins, as face pairs (each side one face here).
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()
let one = |s: &Side| s.single().expect("a side of one face");
h.edges.iter().flat_map(|e| &e.origins).filter_map(|o| if let EdgeOrigin::Meet { a, b } = o { Some((one(a), one(b))) } else { None }).collect()
}
fn boxed(x0: f64, y0: f64, z0: f64, x1: f64, y1: f64, z1: f64) -> Body {
@@ -419,3 +424,44 @@ fn faceted_tool() {
assert_eq!(from_a.len(), 6);
assert_eq!(h.faces.iter().filter(|f| matches!(f.origins.first(), Some(FaceOrigin::Face { operand: 1, .. }))).count(), 3);
}
/// A step cut into a certified box: the boolean leaves the faces it split on the box's planes
/// in pieces, which the planar regularization joins back; the joined body keeps the boolean's
/// history composed through the join.
#[test]
fn regularized_step_cut() {
let (pa, pb) = (boxed(0.0, 0.0, 0.0, 30.0, 35.0, 7.5), boxed(0.0, 30.0, 2.5, 22.5, 50.0, 25.0));
let (a, b) = (pa.clone().with_box_certificate(1).unwrap(), pb.clone().with_box_certificate(2).unwrap());
let r = boolean(&a, &b, BoolOp::Cut).unwrap().unwrap();
let h = verified(&r, &[&a, &b], 0.01);
let plain = boolean(&pa, &pb, BoolOp::Cut).unwrap().unwrap();
let ph = verified(&plain, &[&pa, &pb], 0.01);
// Uncertified, the box's face 3 stays in two pieces; certified, they are joined into one
// face, the only one from it.
assert_eq!(from(ph, face(0, 3)).len(), 2, "{}", show(ph));
assert_eq!(r.face_count() + 1, plain.face_count());
let joined = from(h, face(0, 3));
assert_eq!(joined.len(), 1, "{}", show(h));
assert_eq!(faces_of(h)[joined[0]], (Change::Modified, vec![face(0, 3)]));
// Every other face is as the boolean made it, in its order.
let rest = |h: &History| -> Vec<(Change, Vec<FaceOrigin>)> { faces_of(h).into_iter().filter(|(_, o)| *o != [face(0, 3)]).collect() };
assert_eq!(rest(h), rest(ph));
}
/// A ring joined under a floor, its bore on the floor's hole (coincident cylinders): the ring's
/// bore is narrowed, the bodies joined, and the bore cut out again. The ring's bore is a circle
/// built in two arcs, and the recut wall is split where the cutting cylinder's own construction
/// splits it, so which arc each piece of it restores is not known: no history.
#[test]
fn bore_union_of_a_bore_in_two_arcs_has_no_history() {
let hole = crate::cylinder(Vec3::new(50.0, 40.0, -5.0), Vec3::Z, 10.0, 30.0).unwrap();
let floor = boolean(&boxed(0.0, 0.0, 0.0, 100.0, 80.0, 10.0), &hole, BoolOp::Cut).unwrap().unwrap();
let centre = solvecraft_geom::Vec2::new(50.0, 40.0);
let annulus = solvecraft_geom::Region2 {
outer: solvecraft_geom::Loop2::circle(centre, 20.0),
holes: vec![solvecraft_geom::Loop2::circle(centre, 10.0).reversed()],
};
let ring = crate::extrude(&solvecraft_geom::Plane::XY, &[annulus], -5.0, 2.0).unwrap().remove(0);
let r = crate::coplanar::bore_union(&floor, &ring, &|_| true).expect("the bores are recut");
assert!(r.history().is_none());
}
+1 -1
View File
@@ -112,7 +112,7 @@ pub(crate) fn shapeops(h: &truck_shapeops::History, tool: &Solid, tool_edges: Op
tool_edges.and_then(|t| t.get(passed.get(edge)?)).cloned()
}
truck_shapeops::EdgeOrigin::Edge { operand, edge } => Some(vec![EdgeOrigin::Edge { operand, edge }]),
truck_shapeops::EdgeOrigin::Meet { a, b } => Some(vec![EdgeOrigin::Meet { a: face(a), b: face(b) }]),
truck_shapeops::EdgeOrigin::Meet { a, b } => Some(vec![EdgeOrigin::meet(face(a), face(b))]),
})
.collect::<Option<Vec<_>>>()?;
Some((faces, edges))
+10 -5
View File
@@ -357,14 +357,15 @@ fn trimmed(x: &Body, h: History, slab: &Body, operands: [&Body; 2], n: Vec3, d:
f.origins = origins.into_iter().collect();
}
}
let cap = crate::history::Side::from(cap);
for e in &mut cut.edges {
for o in &mut e.origins {
let side = match *o {
EdgeOrigin::Meet { a, b } if a == cap => b,
EdgeOrigin::Meet { a, b } if b == cap => a,
let side = match o {
EdgeOrigin::Meet { a, b } if *a == cap => b,
EdgeOrigin::Meet { a, b } if *b == cap => a,
_ => continue,
};
let FaceOrigin::Face { operand, face } = side else { return None };
let Some(FaceOrigin::Face { operand, face }) = side.single() else { return None };
let (plane_faces, bounds) = (in_plane.get(operand)?, bounds.get(operand)?);
let mut along = (0..bounds.len())
.filter(|k| bounds.get(*k).is_some_and(|fs| fs.len() == 2 && fs.contains(&face) && fs.iter().any(|f| plane_faces.contains(f))));
@@ -541,8 +542,12 @@ fn same_cylinder(x: &CylFace, y: &CylFace, tol: f64) -> bool {
/// The history over `[a, b]` of `r`, made in [`bore_union`]: `joined` (the target joined with
/// `narrowed`, the tool with its bore narrowed) less `cut`, the cylinder whose wall restores the
/// tool's bore faces `bore` (the tool is operand `tool_operand`). Nothing of the cylinder but its
/// wall may be left.
/// wall may be left. Only a bore of one face: the cylinder's wall faces are split where its own
/// construction splits them, so which of several bore faces a piece of it lies over is not known.
fn recut(r: &Body, joined: &Body, narrowed: &Body, cut: &Body, bore: &[usize], tool_operand: usize) -> Option<History> {
if bore.len() != 1 {
return None;
}
// Over [target, tool].
let joined = crate::boolean_record::through(joined, 1, narrowed.history())?;
let wall = || record::region(bore.iter().map(|&face| FaceOrigin::Face { operand: 1, face }).collect(), false);
+1 -1
View File
@@ -446,7 +446,7 @@ impl HealMap {
.map(|(id, under)| {
let mut origins = BTreeSet::new();
for i in under {
origins.extend(edges.get(*i)?.iter().copied());
origins.extend(edges.get(*i)?.iter().cloned());
}
Some((*id, origins.into_iter().collect()))
})
+88 -22
View File
@@ -48,13 +48,64 @@ pub struct FaceProv {
pub change: Change,
}
/// One side of a meeting: the origins of the face on that side of the edge. One origin, or
/// several where that face was made from several (a merged face) and the edge lies along it
/// as a whole. Never empty; sorted, without repeats, so equal sides compare equal.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Side(Vec<FaceOrigin>);
impl Side {
/// The side made of `origins` (in any order, repeats allowed); `None` when there are none.
pub fn new(origins: impl IntoIterator<Item = FaceOrigin>) -> Option<Side> {
let side = Side::canonical(origins.into_iter().collect());
(!side.0.is_empty()).then_some(side)
}
/// Its origins, sorted.
pub fn origins(&self) -> &[FaceOrigin] {
&self.0
}
/// The side's one origin, when it has exactly one.
pub fn single(&self) -> Option<FaceOrigin> {
match self.0.as_slice() {
[o] => Some(*o),
_ => None,
}
}
/// The same side with each origin mapped by `f` (failing where `f` does).
fn try_map(&self, f: impl Fn(FaceOrigin) -> Result<FaceOrigin>) -> Result<Side> {
Ok(Side::canonical(self.0.iter().map(|o| f(*o)).collect::<Result<_>>()?))
}
/// The same side with each origin's operand renumbered by `f`.
fn map_operand(&self, f: impl Fn(usize) -> usize) -> Side {
Side::canonical(self.0.iter().map(|o| o.map_operand(&f)).collect())
}
/// `origins` sorted, without repeats.
fn canonical(mut origins: Vec<FaceOrigin>) -> Side {
origins.sort_unstable();
origins.dedup();
Side(origins)
}
}
impl From<FaceOrigin> for Side {
fn from(origin: FaceOrigin) -> Side {
Side(vec![origin])
}
}
/// Where a result edge came from.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[derive(Clone, 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 },
/// Where two faces meet that met nowhere before (a boolean's intersection curve): the
/// origins of the face on each side, `a <= b` (made with [`EdgeOrigin::meet`]).
Meet { a: Side, b: Side },
}
/// A result edge: the result faces it separates and what it came from (empty when it is new
@@ -91,11 +142,18 @@ impl FaceOrigin {
}
impl EdgeOrigin {
/// Where the faces with origins `a` and `b` (sides, or single origins) meet, in canonical
/// order (the lesser side first).
pub fn meet(a: impl Into<Side>, b: impl Into<Side>) -> EdgeOrigin {
let (a, b) = (a.into(), b.into());
if a <= b { EdgeOrigin::Meet { a, b } } else { EdgeOrigin::Meet { a: b, b: a } }
}
/// The same origin with its operands renumbered by `f`.
fn map_operand(self, f: impl Fn(usize) -> usize) -> EdgeOrigin {
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) },
EdgeOrigin::Edge { operand, edge } => EdgeOrigin::Edge { operand: f(*operand), edge: *edge },
EdgeOrigin::Meet { a, b } => EdgeOrigin::meet(a.map_operand(&f), b.map_operand(&f)),
}
}
}
@@ -143,20 +201,24 @@ impl History {
}
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),
EdgeOrigin::Meet { a, b } => a.origins().iter().chain(b.origins()).all(face_ok),
});
if !ok {
return Err(failed("an edge origin lies outside its operand"));
}
if e.origins.iter().any(|o| matches!(o, EdgeOrigin::Meet { a, b } if a > b)) {
return Err(failed("a meeting's sides are out of order"));
}
}
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).
/// taking `k`'s place in the operand list; a meeting's side becomes the origins of the faces
/// it names (several where that operation merged faces). Fails, without changing `self`,
/// where a lineage cannot be expressed (an offset of a face that operation generated or
/// merged, a blend of an edge it generated, a meeting with a face it made from nothing).
pub fn substitute(&mut self, k: usize, inner: &History) -> Result<()> {
if k >= self.operands {
return Err(failed("no such operand"));
@@ -209,26 +271,30 @@ impl History {
};
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")),
// Split where an origin lies under several composed faces (pieces the inner operation
// split and this one joined back, or dropped all but one of, are not).
crate::record::mark_shared_split(&mut faces);
// One side of a meeting: the origins of what its faces were made from.
let side = |s: &Side| -> Result<Side> {
let mut origins = Vec::new();
for o in s.origins() {
origins.extend(map_face(*o)?.0);
}
Side::new(origins).ok_or_else(|| failed("an edge meets a face the inner operation made from nothing"))
};
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)))
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)? });
origins.insert(EdgeOrigin::meet(side(a)?, side(b)?));
}
}
}
@@ -268,9 +334,9 @@ impl History {
let origins: BTreeSet<EdgeOrigin> = e
.origins
.iter()
.map(|o| match *o {
EdgeOrigin::Edge { operand, edge } => Ok(EdgeOrigin::Edge { operand: to(operand)?, edge }),
EdgeOrigin::Meet { a, b } => Ok(EdgeOrigin::Meet { a: face(a)?, b: face(b)? }),
.map(|o| match o {
EdgeOrigin::Edge { operand, edge } => Ok(EdgeOrigin::Edge { operand: to(*operand)?, edge: *edge }),
EdgeOrigin::Meet { a, b } => Ok(EdgeOrigin::meet(a.try_map(face)?, b.try_map(face)?)),
})
.collect::<Result<_>>()?;
edges.push(EdgeProv { faces: e.faces.clone(), origins: origins.into_iter().collect() });
+77 -7
View File
@@ -1,6 +1,6 @@
use solvecraft_geom::Vec3;
use super::{Change, EdgeOrigin, EdgeProv, FaceOrigin, FaceProv, History};
use super::{Change, EdgeOrigin, EdgeProv, FaceOrigin, FaceProv, History, Side};
use crate::{Body, box_solid, transform};
fn face(operand: usize, face: usize) -> FaceOrigin {
@@ -53,13 +53,13 @@ fn substitute_a_body_made_from_a_sketch() {
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) }] }],
edges: vec![EdgeProv { faces: vec![0, 1], origins: vec![EdgeOrigin::meet(face(0, 2), 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 } }]);
assert_eq!(join.edges[0].origins, vec![EdgeOrigin::meet(face(0, 2), FaceOrigin::Profile { segment: 3 })]);
}
#[test]
@@ -78,6 +78,13 @@ fn check_refuses_a_history_that_does_not_describe_the_body() {
let mut short = h.clone();
short.faces.pop();
assert!(short.check(&a, &[&a]).is_err(), "face count");
let mut reversed = h.clone();
reversed.edges[0].origins = vec![EdgeOrigin::Meet { a: face(0, 2).into(), b: face(0, 1).into() }];
assert!(reversed.check(&a, &[&a]).is_err(), "meeting sides out of order");
reversed.edges[0].origins = vec![EdgeOrigin::meet(face(0, 2), Side::new([face(0, 1), face(0, 6)]).unwrap())];
assert!(reversed.check(&a, &[&a]).is_err(), "a side's origin beyond the operand's faces");
reversed.edges[0].origins = vec![EdgeOrigin::meet(face(0, 2), Side::new([face(0, 1), face(0, 5)]).unwrap())];
reversed.check(&a, &[&a]).unwrap();
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());
@@ -98,7 +105,7 @@ fn substitute_composes_kept_split_merged_and_generated_faces() {
],
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 } }] },
EdgeProv { faces: vec![3, 4], origins: vec![EdgeOrigin::meet(face(1, 4), FaceOrigin::Profile { segment: 2 })] },
],
};
let mut op2 = History {
@@ -113,7 +120,7 @@ fn substitute_composes_kept_split_merged_and_generated_faces() {
],
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![2, 4], origins: vec![EdgeOrigin::meet(face(1, 3), face(2, 1))] },
EdgeProv { faces: vec![4, 5], origins: vec![EdgeOrigin::Edge { operand: 2, edge: 3 }] },
],
};
@@ -128,8 +135,8 @@ fn substitute_composes_kept_split_merged_and_generated_faces() {
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[0].origins, vec![EdgeOrigin::meet(face(2, 4), FaceOrigin::Profile { segment: 2 })]);
assert_eq!(e[1].origins, vec![EdgeOrigin::meet(face(2, 4), face(3, 1))]);
assert_eq!(e[2].origins, vec![EdgeOrigin::Edge { operand: 3, edge: 3 }]);
}
@@ -148,12 +155,75 @@ fn substitute_refuses_lineage_it_cannot_express_and_leaves_history_unchanged() {
// 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());
// A meeting with a face the inner operation made from nothing.
let bare = History { operands: 0, faces: vec![prov(vec![], Change::Generated)], edges: vec![] };
let mut meet = History {
operands: 1,
faces: vec![prov(vec![face(0, 0)], Change::Modified), prov(vec![FaceOrigin::Cap { end: true }], Change::Generated)],
edges: vec![EdgeProv { faces: vec![0, 1], origins: vec![EdgeOrigin::meet(face(0, 0), FaceOrigin::Cap { end: true })] }],
};
let before = meet.clone();
assert!(meet.substitute(0, &bare).is_err());
assert_eq!(meet, before);
// 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());
}
/// A side is a set: sorted, without repeats, never empty; a meeting puts the lesser side first.
#[test]
fn sides_and_meetings_are_canonical() {
let side = |o: &[FaceOrigin]| Side::new(o.iter().copied()).unwrap();
assert_eq!(side(&[face(1, 2), face(0, 5), face(1, 2)]).origins(), [face(0, 5), face(1, 2)]);
assert!(Side::new([]).is_none());
assert_eq!(Side::from(face(0, 1)).single(), Some(face(0, 1)));
assert_eq!(side(&[face(0, 1), face(0, 2)]).single(), None);
let (x, y) = (side(&[face(1, 0)]), side(&[face(0, 3), face(0, 4)]));
assert_eq!(EdgeOrigin::meet(x.clone(), y.clone()), EdgeOrigin::meet(y.clone(), x.clone()));
assert_eq!(EdgeOrigin::meet(x.clone(), y.clone()), EdgeOrigin::Meet { a: y, b: x });
assert_eq!(EdgeOrigin::meet(face(2, 0), face(1, 0)), EdgeOrigin::meet(face(1, 0), face(2, 0)));
}
/// An edge meeting a face the inner operation merged: that side becomes the merged face's
/// origins, and the other side stays one face.
#[test]
fn substitute_composes_a_meeting_with_a_merged_face() {
// Heal-like: the inner operation joined faces 1 and 2 of its operand into its face 0.
let inner = History {
operands: 1,
faces: vec![prov(vec![face(0, 1), face(0, 2)], Change::Merged), prov(vec![face(0, 0)], Change::Kept)],
edges: vec![EdgeProv { faces: vec![0, 1], origins: vec![EdgeOrigin::Edge { operand: 0, edge: 4 }] }],
};
// Blend-like: a face in place of the inner edge 0, meeting both inner faces.
let mut outer = History {
operands: 1,
faces: vec![
prov(vec![face(0, 0)], Change::Modified),
prov(vec![face(0, 1)], Change::Modified),
prov(vec![FaceOrigin::Edge { operand: 0, edge: 0 }], Change::Generated),
],
edges: vec![
EdgeProv { faces: vec![0, 2], origins: vec![EdgeOrigin::meet(face(0, 0), FaceOrigin::Edge { operand: 0, edge: 0 })] },
EdgeProv { faces: vec![1, 2], origins: vec![EdgeOrigin::meet(face(0, 1), FaceOrigin::Edge { operand: 0, edge: 0 })] },
],
};
outer.substitute(0, &inner).unwrap();
let blend = Side::from(FaceOrigin::Edge { operand: 0, edge: 4 });
let merged = Side::new([face(0, 2), face(0, 1)]).unwrap();
assert_eq!(outer.faces[0], prov(vec![face(0, 1), face(0, 2)], Change::Merged));
assert_eq!(outer.edges[0].origins, [EdgeOrigin::meet(blend.clone(), merged)]);
assert_eq!(outer.edges[1].origins, [EdgeOrigin::meet(blend, face(0, 0))]);
// Renumbering joins operands within a side, and keeps the meeting canonical.
let mut two = History {
operands: 2,
faces: vec![],
edges: vec![EdgeProv { faces: vec![], origins: vec![EdgeOrigin::meet(Side::new([face(0, 3), face(1, 3)]).unwrap(), face(1, 0))] }],
};
two.renumber(&[Some(1), Some(1)], 2).unwrap();
assert_eq!(two.edges[0].origins, [EdgeOrigin::meet(face(1, 0), face(1, 3))]);
}
#[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();
+59 -2
View File
@@ -193,7 +193,7 @@ fn chamfer_cutters_mitre_at_a_corner() {
}
// 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 }]));
assert!(h.edges.iter().any(|e| e.origins == [EdgeOrigin::meet(a, c)]));
}
#[test]
@@ -218,7 +218,7 @@ fn delete_a_fillet() {
// 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) }]));
assert!(h.edges.iter().any(|e| e.origins == [EdgeOrigin::meet(x, y)]));
}
#[test]
@@ -376,3 +376,60 @@ fn mirrored_copy_keeps_every_face_and_edge() {
}
}
}
/// The block with box cuts of each common shape: a notch through the back, a pocket, a step
/// along one end, a slot across the top, a hole through, a corner bitten out.
fn box_cuts() -> Vec<(&'static str, Body, Body)> {
let b = cube();
[
("notch", Vec3::new(15.0, 25.0, -5.0), Vec3::new(25.0, 35.0, 25.0)),
("pocket", Vec3::new(10.0, 10.0, 10.0), Vec3::new(30.0, 20.0, 25.0)),
("step", Vec3::new(30.0, -5.0, 10.0), Vec3::new(45.0, 35.0, 25.0)),
("slot", Vec3::new(15.0, -5.0, 10.0), Vec3::new(25.0, 35.0, 25.0)),
("hole", Vec3::new(15.0, 10.0, -5.0), Vec3::new(25.0, 20.0, 25.0)),
("bite", Vec3::new(30.0, 20.0, 10.0), Vec3::new(45.0, 35.0, 25.0)),
]
.into_iter()
.map(|(name, lo, hi)| {
let tool = box_solid(lo, hi).unwrap();
let cut = boolean(&b, &tool, BoolOp::Cut).unwrap().unwrap();
(name, cut, tool)
})
.collect()
}
/// Meetings with a side of several origins (a face the modifier's heal merged).
fn merged_meetings(h: &History) -> usize {
h.edges.iter().flat_map(|e| &e.origins).filter(|o| matches!(o, EdgeOrigin::Meet { a, b } if a.single().is_none() || b.single().is_none())).count()
}
/// A fillet right after a box cut. Where the cut leaves coplanar pieces (the step, slot and
/// bite leave the front face in pieces), the fillet's heal merges them, and the edge where the
/// round runs into that face meets all of it. Composed with the cut, over the block and the tool.
#[test]
fn fillet_after_box_cuts() {
let b = cube();
let at = Vec3::new(5.0, 0.0, 20.0);
for (name, cut, tool) in box_cuts() {
let r = crate::fillet(&cut, &[at], 2.0).unwrap();
let h = verified(&r, &cut, reach(2.0 * 1.5));
assert_eq!(blends(h), [vec![edge(edge_at(&cut, at))]], "{name}");
let merged = matches!(name, "step" | "slot" | "bite");
assert_eq!(merged_meetings(h) > 0, merged, "{name}: {h:?}");
let mut through = h.clone();
through.substitute(0, cut.history().unwrap()).unwrap();
through.check(&r, &[&b, &tool]).unwrap();
}
}
/// A chamfer right after a box cut whose cutter's cut goes the prism way: the cutter's walls meet
/// the cut body's walls where a segment was split off.
#[test]
fn chamfer_after_box_cuts() {
let at = Vec3::new(5.0, 0.0, 20.0);
for (name, cut, _) in box_cuts().into_iter().filter(|(name, ..)| matches!(*name, "notch" | "hole")) {
let r = crate::chamfer(&cut, &[at], 2.0).unwrap();
let h = verified(&r, &cut, reach(2.0 * 1.5));
assert_eq!(with(h, &[edge(edge_at(&cut, at))]).len(), 1, "{name}: {h:?}");
}
}
+1 -1
View File
@@ -587,7 +587,7 @@ impl Sources {
fn edge(&self, p: FaceOrigin, q: FaceOrigin, ends: (Vec3, Vec3), tol: f64) -> Option<Vec<EdgeOrigin>> {
let (FaceOrigin::Face { operand: x, face: f }, FaceOrigin::Face { operand: y, face: g }) = (p, q) else { return None };
if x != y {
return Some(vec![EdgeOrigin::Meet { a: p.min(q), b: p.max(q) }]);
return Some(vec![EdgeOrigin::meet(p, q)]);
}
let Some(edges) = self.edges.get(x)?.as_ref() else { return Some(Vec::new()) };
if f == g {
+25 -19
View File
@@ -13,7 +13,7 @@ use truck_modeling as mt;
use super::{Index, Section, Tag, dist_to, inside};
use crate::BoolOp;
use crate::body::{Body, edge_order, from_p3};
use crate::history::{EdgeOrigin, FaceOrigin, FaceOrigin::Face, FaceProv};
use crate::history::{EdgeOrigin, FaceOrigin, FaceOrigin::Face, FaceProv, Side};
/// A cap of an operand: a planar face square to the prism direction.
struct Cap {
@@ -188,7 +188,8 @@ impl Context<'_> {
/// The origins of the edge between the wall over a segment tagged `t` and a cap lying on the
/// operand faces `cap`: the operand's edge between the wall and its own cap there (the very
/// edge the segment was read off, when that is one), else where the wall meets the cap.
/// edge the segment was read off, when that is one), else where the wall meets the cap (all of
/// the faces it lies on).
fn cap_edge(&self, t: Tag, cap: &BTreeSet<FaceOrigin>) -> Option<Vec<EdgeOrigin>> {
let (x, w) = (t.operand, t.wall?);
let own: Vec<usize> =
@@ -199,33 +200,32 @@ impl Context<'_> {
return Some(vec![EdgeOrigin::Edge { operand: x, edge: e }]);
}
let along: Vec<usize> = own.iter().flat_map(|c| self.edges_between(x, [w, *c], None)).collect();
match (along.as_slice(), cap.first()) {
([e], _) => Some(vec![EdgeOrigin::Edge { operand: x, edge: *e }]),
([], Some(o)) if cap.len() == 1 => {
let wall = Self::wall(t)?;
Some(vec![EdgeOrigin::Meet { a: wall.min(*o), b: wall.max(*o) }])
}
match along.as_slice() {
[e] => Some(vec![EdgeOrigin::Edge { operand: x, edge: *e }]),
[] => Some(vec![EdgeOrigin::meet(Self::wall(t)?, Side::new(cap.iter().copied())?)]),
_ => None,
}
}
/// The origins of the edge between the walls over two segments where the first (tagged
/// `t1`) runs into the second (`t2`): the operand's edge there between its walls (at the
/// operand vertex the segments meet at), where two operands' walls meet, or none within one
/// wall (a segment split there).
/// operand vertex the segments meet at, else the one edge between them), where two
/// operands' walls meet, or none within one wall (a segment split there).
fn junction(&self, t1: Tag, t2: Tag) -> Option<Vec<EdgeOrigin>> {
let (p, q) = (Self::wall(t1)?, Self::wall(t2)?);
if t1.operand != t2.operand {
return Some(vec![EdgeOrigin::Meet { a: p.min(q), b: p.max(q) }]);
return Some(vec![EdgeOrigin::meet(p, q)]);
}
let (x, w1, w2) = (t1.operand, t1.wall?, t2.wall?);
match (t1.end, t2.start) {
(Some(v), Some(u)) if v == u => match self.edges_between(x, [w1, w2], Some(v)).as_slice() {
[e] => Some(vec![EdgeOrigin::Edge { operand: x, edge: *e }]),
_ => None,
},
// At that vertex: the operand's edge there (a seam, within one wall), else two edges
// of one wall's cap outline running on through it.
(Some(v), Some(u)) if v == u => self.one_edge(x, [w1, w2], Some(v)).or_else(|| (w1 == w2).then(Vec::new)),
_ if w1 == w2 => Some(Vec::new()),
_ => None,
// Elsewhere (a segment split off there, or read off another cap): only the operand's
// walls are around the edge, so the operand has its edge between them there, and
// that is the one edge between them where there is one.
_ => self.one_edge(x, [w1, w2], None),
}
}
@@ -238,10 +238,16 @@ impl Context<'_> {
return Some(Vec::new());
}
if t1.operand != t2.operand {
return Some(vec![EdgeOrigin::Meet { a: p.min(q), b: p.max(q) }]);
return Some(vec![EdgeOrigin::meet(p, q)]);
}
match self.edges_between(t1.operand, [t1.wall?, t2.wall?], None).as_slice() {
[e] => Some(vec![EdgeOrigin::Edge { operand: t1.operand, edge: *e }]),
self.one_edge(t1.operand, [t1.wall?, t2.wall?], None)
}
/// The operand's edge between the faces `faces` (touching vertex `at` when given), when it
/// has exactly one.
fn one_edge(&self, operand: usize, faces: [usize; 2], at: Option<usize>) -> Option<Vec<EdgeOrigin>> {
match self.edges_between(operand, faces, at).as_slice() {
[edge] => Some(vec![EdgeOrigin::Edge { operand, edge: *edge }]),
_ => None,
}
}
+10 -9
View File
@@ -166,7 +166,7 @@ pub(crate) fn attach(body: Body, operands: &[Operand], faces: Option<Vec<FacePro
}
}
let (edges, bounds) = edge_order(body.solid.face_iter());
let edges = edges.iter().zip(bounds).map(|(e, faces)| EdgeProv { faces, origins: owned.get(&e.id()).copied().into_iter().collect() }).collect();
let edges = edges.iter().zip(bounds).map(|(e, faces)| EdgeProv { faces, origins: owned.get(&e.id()).cloned().into_iter().collect() }).collect();
let history = History { operands: operands.len(), faces, edges };
let counts: Vec<(usize, usize)> = operands.iter().map(|o| (o.body.face_count(), o.edges.len())).collect();
body.with_history_counts(history, &counts)
@@ -323,15 +323,16 @@ impl Rebuild {
}
/// The origins of a new edge between the result faces `between` (whose provenance is in
/// `faces`): the meeting of the two faces, when each comes from one origin and they differ;
/// else none (a seam, an edge between pieces of one face).
/// `faces`): the meeting of the two faces (each side all of its face's origins, one or
/// several), when each comes from something and no origin is under both; else none (a seam,
/// an edge between pieces of one face, a face new to the operation).
pub(crate) fn meeting(between: &[usize], faces: &[FaceProv]) -> Vec<EdgeOrigin> {
let side = |k: usize| match between.get(k).and_then(|f| faces.get(*f)).map(|p| p.origins.as_slice()) {
Some([o]) => Some(*o),
_ => None,
};
match (between.len(), side(0), side(1)) {
(2, Some(a), Some(b)) if a != b => vec![EdgeOrigin::Meet { a: a.min(b), b: a.max(b) }],
let side = |f: &usize| faces.get(*f).and_then(|p| crate::history::Side::new(p.origins.iter().copied()));
match between {
[f, g] => match (side(f), side(g)) {
(Some(a), Some(b)) if a.origins().iter().all(|o| !b.origins().contains(o)) => vec![EdgeOrigin::meet(a, b)],
_ => Vec::new(),
},
_ => Vec::new(),
}
}
+21 -1
View File
@@ -7,7 +7,7 @@ use solvecraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3};
use crate::body::Body;
use crate::build::PathSeg;
use crate::history::{Change, FaceOrigin, History};
use crate::history::{Change, EdgeOrigin, FaceOrigin, FaceProv, History, Side};
const P: fn(usize) -> FaceOrigin = |segment| FaceOrigin::Profile { segment };
const START: FaceOrigin = FaceOrigin::Cap { end: false };
@@ -550,3 +550,23 @@ fn emboss_reports_kept_trimmed_and_profile_faces() {
}
}
}
/// A new edge's meeting: each side all of its face's origins (a merged face's several); none
/// where an origin lies on both sides (pieces of one face) or a face comes from nothing.
#[test]
fn a_new_edge_meets_whole_faces() {
let f = |face: usize| FaceOrigin::Face { operand: 0, face };
let blend = FaceOrigin::Edge { operand: 0, edge: 3 };
let faces = vec![
FaceProv { origins: vec![f(0), f(1)], change: Change::Merged },
FaceProv { origins: vec![blend], change: Change::Generated },
FaceProv { origins: vec![f(1)], change: Change::Split },
FaceProv { origins: vec![], change: Change::Generated },
];
let merged = Side::new([f(0), f(1)]).unwrap();
assert_eq!(super::meeting(&[1, 0], &faces), [EdgeOrigin::meet(merged, blend)]);
assert_eq!(super::meeting(&[2, 1], &faces), [EdgeOrigin::meet(f(1), blend)]);
assert!(super::meeting(&[0, 2], &faces).is_empty());
assert!(super::meeting(&[1, 3], &faces).is_empty());
assert!(super::meeting(&[1], &faces).is_empty());
}