fix: reject planar joins that displace original faces
This commit is contained in:
1 parent
74fed469dc
commit
fc8b71956c
3 files changed
+94
-3
No files matched your search
@@ -202,10 +202,30 @@ fn fillet_into_an_earlier_round() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Seeds that run clean: no failure, no invalid body, no volume going the wrong way.
|
/// Clean seeds and the original seed 5 edge contact, now rejected without distortion.
|
||||||
#[test]
|
#[test]
|
||||||
fn fixed_seeds_run_clean() {
|
fn fixed_seeds_run_clean() {
|
||||||
for seed in FIXED_SEEDS {
|
for seed in FIXED_SEEDS {
|
||||||
|
if *seed == 5 {
|
||||||
|
// Seed 5 originally joined these boxes along only an edge. Accepting
|
||||||
|
// it required silently shifting the tool; that is now invalid input.
|
||||||
|
let mut s = Session::default();
|
||||||
|
run(&mut s, "solid.box", json!({"length": 25, "width": 15, "height": 10}));
|
||||||
|
let error = s
|
||||||
|
.execute(
|
||||||
|
"solid.box",
|
||||||
|
&json!({
|
||||||
|
"corner": [25, 2.5, 10], "length": 17.5, "width": 17.5,
|
||||||
|
"height": 20, "operation": "join"
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(error.to_string().contains("zero-thickness edge contact"), "{error}");
|
||||||
|
assert!((total_volume(&s) - 3750.0).abs() < 1e-6);
|
||||||
|
assert_eq!(s.model.state().bodies.len(), 1);
|
||||||
|
assert!(s.model.state().bodies.first().unwrap().body.validity().is_empty());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let r = fuzz::run(*seed, 8);
|
let r = fuzz::run(*seed, 8);
|
||||||
assert!(!r.outcome.is_bug(), "seed {seed}: {:?}\n{}", r.outcome, r.script());
|
assert!(!r.outcome.is_bug(), "seed {seed}: {:?}\n{}", r.outcome, r.script());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ thread_local! {
|
|||||||
/// everything or an intersection of disjoint bodies. Results are checked against volume bounds
|
/// everything or an intersection of disjoint bodies. Results are checked against volume bounds
|
||||||
/// and retried with shifted copies and other tolerances when they fail or look wrong.
|
/// and retried with shifted copies and other tolerances when they fail or look wrong.
|
||||||
pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||||||
let r = boolean_whole(a, b, op);
|
let mut r = boolean_whole(a, b, op);
|
||||||
// Bodies in several pieces: when the whole failed (or came out empty), a piece at a time.
|
// Bodies in several pieces: when the whole failed (or came out empty), a piece at a time.
|
||||||
let retry = match &r {
|
let retry = match &r {
|
||||||
Err(_) => true,
|
Err(_) => true,
|
||||||
@@ -183,12 +183,52 @@ pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
|||||||
if (la.len() > 1 || lb.len() > 1)
|
if (la.len() > 1 || lb.len() > 1)
|
||||||
&& let Ok(Some(x)) = by_lumps(la, lb, op)
|
&& let Ok(Some(x)) = by_lumps(la, lb, op)
|
||||||
{
|
{
|
||||||
return Ok(Some(x));
|
r = Ok(Some(x));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if op == BoolOp::Union
|
||||||
|
&& let Ok(Some(body)) = &r
|
||||||
|
&& !preserves_planar_surfaces(a, b, body)
|
||||||
|
{
|
||||||
|
// Numerical retries must not quietly alter a planar design's dimensions.
|
||||||
|
// Prefer the exact polygon result; reject an edge-only pinch if it cannot
|
||||||
|
// form a closed solid without displacing either operand.
|
||||||
|
r = match crate::polybool::planar_boolean(a, b, op) {
|
||||||
|
Ok(Some(exact)) if preserves_planar_surfaces(a, b, &exact) => Ok(Some(exact)),
|
||||||
|
_ => Err(KernelError::Invalid(
|
||||||
|
"join cannot preserve the original faces; check for zero-thickness edge contact or add intentional overlap".into(),
|
||||||
|
)),
|
||||||
|
};
|
||||||
|
}
|
||||||
r
|
r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every face of an all-planar union must remain on an operand's original plane.
|
||||||
|
/// Curved operands keep their existing surface-specific boolean handling.
|
||||||
|
fn preserves_planar_surfaces(a: &Body, b: &Body, result: &Body) -> bool {
|
||||||
|
guard("validate planar union", || Ok(planar_surfaces_match(a, b, result))).unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn planar_surfaces_match(a: &Body, b: &Body, result: &Body) -> bool {
|
||||||
|
let planes: Option<Vec<(Vec3, f64)>> = a
|
||||||
|
.solid
|
||||||
|
.face_iter()
|
||||||
|
.chain(b.solid.face_iter())
|
||||||
|
.map(|f| {
|
||||||
|
let mt::Surface::Plane(p) = f.oriented_surface() else { return None };
|
||||||
|
let n = p.normal();
|
||||||
|
let n = Vec3::new(n.x, n.y, n.z).normalized()?;
|
||||||
|
Some((n, n.dot(from_p3(p.origin()))))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let Some(planes) = planes else { return true };
|
||||||
|
let tol = (a.size().max(b.size()) * 1e-9).max(1e-9);
|
||||||
|
result.solid.face_iter().all(|f| {
|
||||||
|
matches!(f.oriented_surface(), mt::Surface::Plane(_))
|
||||||
|
&& planes.iter().any(|(n, d)| f.vertex_iter().all(|v| (n.dot(from_p3(v.point())) - d).abs() <= tol))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Smallest side of the box where the bodies' vertex boxes overlap (infinite when they don't).
|
/// Smallest side of the box where the bodies' vertex boxes overlap (infinite when they don't).
|
||||||
fn meeting_size(a: &Body, b: &Body) -> f64 {
|
fn meeting_size(a: &Body, b: &Body) -> f64 {
|
||||||
let bbox = |x: &Body| {
|
let bbox = |x: &Body| {
|
||||||
|
|||||||
@@ -317,6 +317,37 @@ fn planar_booleans_with_coincident_faces() {
|
|||||||
assert!(rel(measure(&i).unwrap().volume, 250.0) < 1e-9);
|
assert!(rel(measure(&i).unwrap().volume, 250.0) < 1e-9);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stepped_logo_join_never_moves_faces_to_hide_an_edge_pinch() {
|
||||||
|
let mut body = box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 10.0)).unwrap();
|
||||||
|
for (i, (lo, hi)) in
|
||||||
|
[(Vec3::new(0.0, 0.0, 10.0), Vec3::new(10.0, 10.0, 20.0)), (Vec3::new(-10.0, 0.0, 10.0), Vec3::new(0.0, 10.0, 20.0))].into_iter().enumerate()
|
||||||
|
{
|
||||||
|
let tool = box_solid(lo, hi).unwrap();
|
||||||
|
body = boolean(&body, &tool, BoolOp::Union).unwrap().unwrap();
|
||||||
|
assert!(body.validity().is_empty());
|
||||||
|
let m = measure(&body).unwrap();
|
||||||
|
assert!((m.volume - (3000.0 + i as f64 * 1000.0)).abs() < 1e-6, "join {i}: {}", m.volume);
|
||||||
|
assert!((m.bbox.max.z - 20.0).abs() < 1e-9, "join {i}: {}", m.bbox.max.z);
|
||||||
|
let tops =
|
||||||
|
body.faces(0.01).unwrap().iter().filter(|f| f.plane_normal.is_some_and(|n| n.z > 0.999999) && (f.centroid.z - 20.0).abs() < 1e-9).count();
|
||||||
|
assert_eq!(tops, 1, "join {i}: the top must be a single selectable face");
|
||||||
|
assert!(m.bbox.min.dist(Vec3::new(if i == 0 { 0.0 } else { -10.0 }, 0.0, 0.0)) < 1e-9);
|
||||||
|
assert!(m.bbox.max.dist(Vec3::new(10.0, 20.0, 20.0)) < 1e-9);
|
||||||
|
}
|
||||||
|
let tool = box_solid(Vec3::new(-10.0, 10.0, 10.0), Vec3::new(0.0, 20.0, 20.0)).unwrap();
|
||||||
|
let error = boolean(&body, &tool, BoolOp::Union).unwrap_err();
|
||||||
|
assert!(error.to_string().contains("zero-thickness edge contact"), "{error}");
|
||||||
|
// An intentional overlap removes the pinch without any automatic displacement.
|
||||||
|
let overlap = box_solid(Vec3::new(-10.0, 10.0, 10.0), Vec3::new(0.1, 20.0, 20.0)).unwrap();
|
||||||
|
let joined = boolean(&body, &overlap, BoolOp::Union).unwrap().unwrap();
|
||||||
|
assert!(joined.validity().is_empty());
|
||||||
|
let m = measure(&joined).unwrap();
|
||||||
|
assert!((m.volume - 5010.0).abs() < 1e-6);
|
||||||
|
assert!(m.bbox.min.dist(Vec3::new(-10.0, 0.0, 0.0)) < 1e-9);
|
||||||
|
assert!(m.bbox.max.dist(Vec3::new(10.0, 20.0, 20.0)) < 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn corner_boss_on_stepped_case_has_one_exact_top_face() {
|
fn corner_boss_on_stepped_case_has_one_exact_top_face() {
|
||||||
let outer = box_solid(Vec3::new(-50.0, -25.0, 0.0), Vec3::new(50.0, 25.0, 25.0)).unwrap();
|
let outer = box_solid(Vec3::new(-50.0, -25.0, 0.0), Vec3::new(50.0, 25.0, 25.0)).unwrap();
|
||||||
|
|||||||
Reference in new issue
Block a user