Booleans and Shell keep the lines faces were split along on purpose

Split Face records its lines on the body (split_lines): heal and the
certified-planar regularizer keep the faces apart along them, and they
make the body regularization-protected. On SMLib the boolean, the n-way
union and Shell carried the protection over but dropped the lines, so a
body with an authored split lost its record after a Union with partial
contact (issue #674/#782's case), and a shelled one after Shell. Each
result now keeps its operands' lines, each line once
(Body::inherit_split_lines), as the old kernel's coplanar join did.

regularization_protection_tests: SMLib's shell makes no section strips,
so the painted cup no longer has more faces than the ordinary one. The
ordinary cup is the box's five kept faces, their five linings and the
rim: 11. A paint over a whole face authors no boundary inside it, so the
painted cup has the same 11 faces; the split side's line stays an edge on
the wall and its lining: 13. The test now asserts those counts exactly
(it asserted more than 11 for both, the old strip route's witness) and
that the split lines survive.

Fixes kernel issue_674_tests::partial_contact_retains_an_authored_split_away_from_the_contact
and body::regularization_protection_tests::stamped_shell_keeps_authored_painted_and_split_boundaries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jyviko
2026-10-29 21:51:09 -04:00
committed by Irakli Kourtis
parent f60bf96dfb
commit e1b762c4bd
4 changed files with 32 additions and 7 deletions
+16
View File
@@ -574,6 +574,22 @@ impl Body {
self
}
/// This body with the lines `inputs`' faces were split along on purpose added to its own:
/// an operation combining bodies keeps their authored splits (each line once), as it keeps
/// their regularization protection.
pub(crate) fn inherit_split_lines(self, inputs: &[&Body]) -> Self {
let mut lines: Vec<Vec<Vec3>> = self.split_keep().to_vec();
for line in inputs.iter().flat_map(|input| input.split_keep()) {
if !lines.contains(line) {
lines.push(line.clone());
}
}
if lines.len() == self.split_keep().len() {
return self;
}
self.with_split_lines(Some(std::sync::Arc::new(lines)))
}
/// 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> {
+6 -3
View File
@@ -49,7 +49,9 @@ pub fn union_all(bodies: &[&Body]) -> Result<Body> {
if bodies.is_empty() {
return Err(KernelError::Invalid("a union of no bodies".into()));
}
guard("SMLib union", || crate::smlib_boolean::smlib_union_all(bodies))?.with_preserved_union_planar_sources(bodies)
guard("SMLib union", || crate::smlib_boolean::smlib_union_all(bodies))?
.inherit_split_lines(bodies)
.with_preserved_union_planar_sources(bodies)
})
}
@@ -64,9 +66,10 @@ fn apart(a: &Body, b: &Body) -> Result<bool> {
}
/// A boolean's result with what its operands' faces stood for carried over: regularization
/// protection and the planar construction and shell roles on supports it kept.
/// protection, the lines they were split along on purpose, and the planar construction and shell roles on supports it kept.
fn with_operand_roles(a: &Body, b: &Body, op: BoolOp, r: Result<Option<Body>>) -> Result<Option<Body>> {
let r = r.map(|result| result.map(|body| body.inherit_regularization_protection(a).inherit_regularization_protection(b)));
let r = r
.map(|result| result.map(|body| body.inherit_regularization_protection(a).inherit_regularization_protection(b).inherit_split_lines(&[a, b])));
match op {
BoolOp::Cut => r.and_then(|result| result.map(|body| body.with_preserved_boolean_planar_sources(a, b, true)).transpose()),
BoolOp::Union => r.and_then(|result| result.map(|body| body.with_preserved_union_planar_sources(&[a, b])).transpose()),
+3 -2
View File
@@ -187,10 +187,11 @@ fn polyhedron_record(hs: &[HalfSpace], verts: &[Vec3], faces: &[(usize, Vec<usiz
/// Hollow a body: walls of `thickness` inside, open at the faces under `open` (SMLib's shell).
/// The result's history is over `[b]`: the inner walls are offsets of the faces they line. Each
/// kept planar wall and its inner lining carry the outer and inner shell roles of the face they
/// came from (face certificates: sketches and press-pulls attach to them).
/// came from (face certificates: sketches and press-pulls attach to them), and the body keeps
/// the lines its faces were split along on purpose.
pub fn shell(b: &Body, open: &[Vec3], thickness: f64) -> Result<Body> {
let _span = solidcraft_profile::span("kernel", "shell");
crate::offset_backend::shell(b, open, thickness)?.with_shell_planar_sources(b, thickness, open)
crate::offset_backend::shell(b, open, thickness)?.inherit_split_lines(&[b]).with_shell_planar_sources(b, thickness, open)
}
/// Tilt the faces near `faces` by `angle` about their line on the neutral plane, so that they
@@ -18,10 +18,15 @@ fn stamped_shell_keeps_authored_painted_and_split_boundaries() {
let side = (0..stock.face_count()).find(|i| stock.planar_face(*i).is_some_and(|p| p.normal().dot(Vec3::X) > 0.999 && p.origin.x == 20.)).unwrap();
let split = crate::split_faces(&stock, &[side], &crate::SplitTool::Plane(Plane { origin: Vec3::new(0., 0., 8.), ..Plane::XY })).unwrap();
assert!(split.split_lines().is_some());
for protected in [painted, split] {
// SMLib's shell makes no section strips: the ordinary cup is the stock's five kept faces,
// their five linings and the rim (11). A paint over a whole face authors no boundary inside
// it, so the painted cup has those same faces; the split side's line stays an edge on both
// walls (13).
for (protected, faces) in [(painted, ordinary.face_count()), (split, ordinary.face_count() + 2)] {
let result = cup(&protected);
assert!(result.regularization_protected);
assert!(result.face_count() > ordinary.face_count(), "authored boundaries must survive both Shell regularizers");
assert_eq!(result.face_count(), faces, "authored boundaries must survive Shell");
assert_eq!(result.split_lines(), protected.split_lines());
assert!(result.validity().is_empty());
let expected = 40. * 30. * 24. - 38. * 28. * 23.;
let mesh = result.mesh(&crate::MeshTolerance::chord(1e-5)).unwrap();