fix(kernel): preserve intentional Split Face lines in contact joins (#782)

Intentional Split Face lines survive a join with partial planar contact, so the far face stays in two pieces. Follow-up to #704 / #674.
This commit is contained in:
selune authored and GitHub committed 2026-10-13 12:46:34 -04:00
1 parent beca7eb126
commit 293d660d6c
6 files changed
+120 -1

No files matched your search

@@ -5,6 +5,35 @@ use serde_json::{Value, json};
use crate::Session;
/// Runs in PR CI as well as the heavier fixed-seed suite. Two pattern copies are
/// disconnected, and one cylinder overlaps the block by half its volume.
#[test]
fn partial_face_contact_pattern_has_analytic_material() {
// Repeated construction exposes re-imprinting a cylinder through both of its
// side faces, which can create a redundant arc along the first imprint.
for _ in 0..8 {
check_partial_face_contact_pattern();
}
}
fn check_partial_face_contact_pattern() {
let s = replay(include_str!("../../../examples/regressions/partial_face_contact_pattern.json"));
let (mut volume, mut area, mut shells) = (0.0, 0.0, 0);
for b in &s.model.state().bodies {
assert!(b.body.validity().is_empty(), "{:?}", b.body.validity());
let m = b.body.tessellate(1e-5).unwrap().measure();
volume += m.volume;
area += m.area;
shells += b.body.shell_count();
}
let expected_volume = 25.0 * 47.5 * 37.5 + 3.5 * std::f64::consts::PI * 25.0 * 2.5;
let expected_area = 2.0 * (25.0 * 47.5 + 25.0 * 37.5 + 47.5 * 37.5) + 237.5 * std::f64::consts::PI - 25.0;
assert!((volume - expected_volume).abs() < 0.02, "{volume} expected {expected_volume}");
assert!((area - expected_area).abs() < 0.02, "{area} expected {expected_area}");
assert_eq!(shells, 3);
println!("#674 volume {volume:.6} / {expected_volume:.6} mm³; area {area:.6} / {expected_area:.6} mm²; {shells} shells");
}
fn replay(script: &str) -> Session {
let mut s = Session::default();
let script: Value = serde_json::from_str(script).unwrap();
+3 -1
View File
@@ -716,7 +716,9 @@ pub(crate) fn glue(a: &Body, b: &Body) -> Option<Result<Body>> {
let from: Vec<FaceProv> = solid.face_iter().filter_map(|f| prov.get(&f.id()).cloned()).collect();
// Each edge's origins in the solid's edge order.
let origins = crate::body::edge_order(solid.face_iter()).0.iter().map(|e| origins.get(&e.id()).cloned()).collect();
crate::boolean_record::heal_build(solid, size, &[], Some(from), origins, &record::counts(&[a, b]))
let keep: Vec<_> = a.split_keep().iter().chain(b.split_keep()).cloned().collect();
crate::boolean_record::heal_build(solid, size, &keep, Some(from), origins, &record::counts(&[a, b]))
.map(|body| body.with_split_lines(Some(std::sync::Arc::new(keep))))
}))
}
+70
View File
@@ -0,0 +1,70 @@
//! Partial planar contact must sew a solid without displacing or inventing material.
use crate::*;
use solidcraft_geom::Vec3;
use std::f64::consts::PI;
#[test]
fn split_contact_arc_crossing_one_edge_twice_preserves_the_body() {
use truck_modeling::Surface;
let a = box_solid(Vec3::ZERO, Vec3::new(25.0, 47.5, 37.5)).unwrap();
let b = cylinder(Vec3::new(0.0, 7.5, 0.0), -Vec3::X, 5.0, 2.5).unwrap();
let face = a.faces(0.01).unwrap().iter().position(|f| f.plane_normal.is_some_and(|n| n.dot(-Vec3::X) > 0.99)).unwrap();
let side = b.solid.face_iter().position(|f| !matches!(f.surface(), Surface::Plane(_))).unwrap();
let r = split_faces(&a, &[face], &SplitTool::Face { body: &b, face: side }).unwrap();
assert_eq!(r.face_count(), a.face_count() + 1);
assert!(r.validity().is_empty(), "{:?}", r.validity());
assert!(r.history().is_some());
let (before, after) = (measure(&a).unwrap(), measure(&r).unwrap());
assert!((before.volume - after.volume).abs() < 1e-7);
assert!((before.area - after.area).abs() < 1e-7);
}
#[test]
fn partial_contact_does_not_join_tangent_or_separated_faces() {
let a = box_solid(Vec3::ZERO, Vec3::new(25.0, 47.5, 37.5)).unwrap();
for z in [-5.0, -6.0] {
let b = cylinder(Vec3::new(0.0, 7.5, z), -Vec3::X, 5.0, 2.5).unwrap();
assert!(crate::coplanar::imprint_union(&a, &b, &|_| true).is_none());
}
}
#[test]
fn partial_contact_retains_an_authored_split_away_from_the_contact() {
let a = box_solid(Vec3::ZERO, Vec3::new(25.0, 47.5, 37.5)).unwrap();
let face = a.faces(0.01).unwrap().iter().position(|f| f.plane_normal.is_some_and(|n| n.dot(Vec3::X) > 0.99)).unwrap();
let a = split_faces(&a, &[face], &SplitTool::Plane(solidcraft_geom::Plane { origin: Vec3::new(0.0, 0.0, 15.0), ..solidcraft_geom::Plane::XY }))
.unwrap();
let b = cylinder(Vec3::new(0.0, 7.5, 0.0), -Vec3::X, 5.0, 2.5).unwrap();
let r = boolean(&a, &b, BoolOp::Union).unwrap().unwrap();
assert_eq!(r.split_lines(), a.split_lines());
assert!(r.regularization_protected);
assert!(r.validity().is_empty());
let remote_faces =
r.faces(0.01).unwrap().iter().filter(|f| f.plane_normal.is_some_and(|n| n.dot(Vec3::X) > 0.99) && (f.centroid.x - 25.0).abs() < 1e-7).count();
assert_eq!(remote_faces, 2);
}
#[test]
fn partial_circular_contact_preserves_material_and_history() {
for z in [-3.0_f64, 0.0, 3.0] {
for angle in [0.0, 0.37] {
let a = box_solid(Vec3::ZERO, Vec3::new(25.0, 47.5, 37.5)).unwrap();
let b = cylinder(Vec3::new(0.0, 7.5, z), -Vec3::X, 5.0, 2.5).unwrap();
let place = |body: &Body| transform(body, Vec3::new(4.0, -8.0, 2.0), Vec3::ZERO, Vec3::new(1.0, 2.0, 3.0), angle).unwrap();
let (a, b) = (place(&a), place(&b));
for reversed in [false, true] {
let (a, b) = if reversed { (&b, &a) } else { (&a, &b) };
let r = boolean(a, b, BoolOp::Union).unwrap().unwrap();
assert!(r.validity().is_empty(), "z={z} angle={angle}: {:?}", r.validity());
assert_eq!(r.shell_count(), 1);
assert!(r.history().is_some(), "missing history z={z} angle={angle}");
let m = measure(&r).unwrap();
let volume = 25.0 * 47.5 * 37.5 + PI * 25.0 * 2.5;
let contact = 25.0 * (-z / 5.0).acos() + z * (25.0 - z * z).sqrt();
let area = 2.0 * (25.0 * 47.5 + 25.0 * 37.5 + 47.5 * 37.5) + 2.0 * PI * 5.0 * (5.0 + 2.5) - 2.0 * contact;
assert!((m.volume - volume).abs() < 0.1, "z={z} angle={angle}: volume {} expected {volume}", m.volume);
assert!((m.area - area).abs() < 0.2, "z={z} angle={angle}: area {} expected {area}", m.area);
}
}
}
}
+2
View File
@@ -40,6 +40,8 @@ mod intersection_curve_tests;
#[cfg(test)]
mod issue_616_tests;
#[cfg(test)]
mod issue_674_tests;
#[cfg(test)]
mod issue_679_tests;
mod lip;
mod loopblend;
+8
View File
@@ -97,6 +97,14 @@ fixes below (sound face pushes, no panics in the boolean's final assembly).
### Fixed
- Back-to-back contact joins preserve intentional Split Face boundaries, including on
faces away from the contact. The glue pass retains their lines during healing and in
the resulting body for subsequent operations. Partial circular-contact regressions
cover offsets, rotation, operand order, volume, area and history; the seed-16 pattern
also runs in the ordinary engine suite with analytic volume and area checks.
Imprinting visits a shared analytic support only once: the two side faces of a
cylinder must not recut the same outline into redundant arcs.
- **Shell corners where a curved face meets another at an angle** (the biggest cause, 215 of
the 363 failed designs before). Moving a vertex took one linear step, exact only for planes;
a cylinder meeting a plane at an angle (a shallow arc side) landed off the moved surface.
@@ -0,0 +1,8 @@
{
"description": "Issue #674: a patterned cylinder cap touches only part of a block face; fuse the shared region without adding material beyond the edge.",
"commands": [
{"command": "solid.box", "params": {"height": 37.5, "length": 25.0, "width": 47.5}},
{"command": "solid.cylinder", "params": {"axis": [1.0, 0.0, 0.0], "base": [0.0, 7.5, 0.0], "height": 2.5, "operation": "join", "radius": 5.0}},
{"command": "solid.pattern.circular", "params": {"axis": "Z", "count": 4, "features": ["Cylinder1"]}}
]
}