diff --git a/crates/engine/src/tests/robustness.rs b/crates/engine/src/tests/robustness.rs index dc9bac2d..1d3e3c71 100644 --- a/crates/engine/src/tests/robustness.rs +++ b/crates/engine/src/tests/robustness.rs @@ -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] fn fixed_seeds_run_clean() { 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); assert!(!r.outcome.is_bug(), "seed {seed}: {:?}\n{}", r.outcome, r.script()); } diff --git a/crates/kernel/src/ops.rs b/crates/kernel/src/ops.rs index 55671441..045f48ba 100644 --- a/crates/kernel/src/ops.rs +++ b/crates/kernel/src/ops.rs @@ -171,7 +171,7 @@ thread_local! { /// 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. pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result> { - 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. let retry = match &r { Err(_) => true, @@ -183,12 +183,52 @@ pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result> { if (la.len() > 1 || lb.len() > 1) && 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 } +/// 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> = 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). fn meeting_size(a: &Body, b: &Body) -> f64 { let bbox = |x: &Body| { diff --git a/crates/kernel/src/tests.rs b/crates/kernel/src/tests.rs index bcceeb0e..30144e1f 100644 --- a/crates/kernel/src/tests.rs +++ b/crates/kernel/src/tests.rs @@ -317,6 +317,37 @@ fn planar_booleans_with_coincident_faces() { 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] 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();