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 <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 08d7037176
commit 43ddde4a5d
11 files changed
+347 -76

No files matched your search

+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();