diff --git a/crates/kernel/src/body.rs b/crates/kernel/src/body.rs
index 5ca3adf6..11a23ef4 100644
--- a/crates/kernel/src/body.rs
+++ b/crates/kernel/src/body.rs
@@ -10,7 +10,7 @@ mod planar_regularize;
#[path = "planar_sources.rs"]
mod planar_sources;
#[path = "shell_groups.rs"]
-mod shell_groups;
+pub(crate) mod shell_groups;
pub(crate) type Solid = mt::Solid;
diff --git a/crates/kernel/src/bore_union_tests.rs b/crates/kernel/src/bore_union_tests.rs
index 54915b5a..3b75567e 100644
--- a/crates/kernel/src/bore_union_tests.rs
+++ b/crates/kernel/src/bore_union_tests.rs
@@ -116,3 +116,20 @@ fn unproved_curved_boundary_between_clear_endpoints_refuses_recut() {
assert!(crate::bore_guard::boundaries_clear(&body, &[], Vec3::ZERO, Vec3::Z, 5., 14., 24., 1e-6).is_none());
assert_eq!(step_export(&[&body], "curved source").unwrap(), before);
}
+
+/// Posts sunk into one floor have their tops in a common plane but apart: they don't
+/// coincide, so joining another post doesn't go through pushing faces apart (#261).
+#[test]
+fn coplanar_faces_apart_from_each_other_do_not_coincide() {
+ let floor = box_solid(Vec3::new(0., 0., 0.), Vec3::new(80., 60., 2.)).unwrap();
+ let post = |x: f64| cylinder(Vec3::new(x, 30., 1.), Vec3::Z, 3., 6.).unwrap();
+ let with_one = boolean(&floor, &post(10.), BoolOp::Union).unwrap().unwrap();
+ assert!(crate::coplanar::coincident_planes(&with_one, &post(60.)).is_empty());
+ assert!(crate::coplanar::boolean_apart(&with_one, &post(60.), BoolOp::Union).is_none());
+ // A post over the first one's top does meet it there.
+ assert!(!crate::coplanar::coincident_planes(&with_one, &cylinder(Vec3::new(12., 30., 7.), Vec3::Z, 3., 6.).unwrap()).is_empty());
+ let joined = boolean(&with_one, &post(60.), BoolOp::Union).unwrap().unwrap();
+ let v = joined.tessellate_with(1e-3, true).unwrap().measure().volume;
+ let expected = 80. * 60. * 2. + 2. * std::f64::consts::PI * 9. * 5.;
+ assert!((v - expected).abs() < 0.5, "volume {v}, expected {expected}");
+}
diff --git a/crates/kernel/src/coplanar.rs b/crates/kernel/src/coplanar.rs
index cd2816b6..f46e4ec4 100644
--- a/crates/kernel/src/coplanar.rs
+++ b/crates/kernel/src/coplanar.rs
@@ -13,9 +13,10 @@
use std::collections::HashMap;
-use solidcraft_geom::Vec3;
+use solidcraft_geom::{Aabb3, Vec3};
use truck_modeling::{self as mt, builder};
+use crate::body::shell_groups::boxes_separate;
use crate::body::{Body, Solid, from_p3, p3, v3};
use crate::history::{Change, EdgeOrigin, FaceOrigin, FaceProv, History};
use crate::{BoolOp, KernelError, Result, guard, record};
@@ -243,11 +244,67 @@ fn slab_beyond(n: Vec3, d: f64, depth: f64, extent: f64) -> Result
{
crate::extrude(&pl, &[r], 0.0, depth)?.pop().ok_or_else(|| KernelError::Failed("slab".into()))
}
-/// Do the bodies have planar faces in a common plane?
-pub(crate) fn has_coincident_faces(a: &Body, b: &Body) -> bool {
- let tol = (a.size().max(b.size()) * 1e-7).max(1e-9);
+/// The box of a face's boundary sampled to within `tol` of each curve: the face, planar, lies
+/// within `tol` of it. `None` when the boundary can't be sampled.
+fn face_box(f: &mt::Face, tol: f64) -> Option {
+ use mt::{BoundedCurve, ParameterDivision1D};
+ const MAX_POINTS: usize = 100_000;
+ guard("face box", || {
+ let mut bb = Aabb3::EMPTY;
+ let mut count = 0usize;
+ for e in f.edge_iter() {
+ let points = match e.curve() {
+ mt::Curve::Line(_) => vec![e.front().point(), e.back().point()],
+ // The division checks its deviation at midpoints only (as in blend.rs).
+ c => c.parameter_division(c.range_tuple(), tol * 0.25).1,
+ };
+ count = count.saturating_add(points.len());
+ if count > MAX_POINTS {
+ return Err(KernelError::Failed("face box: too many boundary points".into()));
+ }
+ for p in points {
+ bb.add(from_p3(p));
+ }
+ }
+ if bb.is_empty() {
+ return Err(KernelError::Failed("face box: no boundary".into()));
+ }
+ Ok(bb)
+ })
+ .ok()
+}
+
+/// The planes (normal, offset) in which a planar face of `a` and one of `b` coincide, as `a`'s
+/// face lies in them, each with whether `b`'s face points the same way. Faces in a common plane but apart from
+/// each other (the tops of two posts standing on one floor) don't coincide: they need no
+/// pushing apart. A face whose extent can't be found counts as meeting every face in its plane.
+pub(crate) fn coincident_planes(a: &Body, b: &Body) -> Vec<(Vec3, f64, bool)> {
+ let size = a.size().max(b.size());
+ let tol = (size * 1e-7).max(1e-9) * 10.0;
+ let sample = (size * 1e-4).max(tol);
+ let (fa, fb): (Vec<&mt::Face>, Vec<&mt::Face>) = (a.solid.face_iter().collect(), b.solid.face_iter().collect());
let (pa, pb) = (planes(a), planes(b));
- pa.iter().any(|(_, x)| pb.iter().any(|(_, y)| x.n.dot(y.n).abs() > 1.0 - 1e-9 && (x.d - y.d * x.n.dot(y.n)).abs() < tol * 10.0))
+ let (mut boxes_a, mut boxes_b): (Vec