Joining ribs flush with a lid's window left a zero-thickness slit far from the rib (the planar fallback glued back only one of the faces its pieces shared) and displaced the tool on the other joins. Gluing now joins every shared face pair, coincident-face separation checks there is material behind a pushed face for every join, and any join or cut that needed a retry must be closed, keep volume monotonic and leave the part unchanged outside the tool's box, or it is refused with an error. Feature command replies carry the feature's warning. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
186 lines
10 KiB
Rust
186 lines
10 KiB
Rust
//! Issue #616: small boxes joined flush to the walls and floor of a part never change it away
|
||
//! from the box, and a retry that would is refused.
|
||
|
||
use solidcraft_geom::{Aabb3, Mesh, Vec3};
|
||
|
||
use crate::*;
|
||
|
||
fn bx(c: [f64; 3], s: [f64; 3]) -> Body {
|
||
box_solid(Vec3::new(c[0], c[1], c[2]), Vec3::new(c[0] + s[0], c[1] + s[1], c[2] + s[2])).unwrap()
|
||
}
|
||
|
||
fn volume(b: &Body) -> f64 {
|
||
measure(b).unwrap().volume
|
||
}
|
||
|
||
/// The lid of the issue (public dimensions): a plate with rounded corners, a skirt ring on
|
||
/// it, a window and a slot cut through, and a notch cut in the skirt.
|
||
fn lid() -> Body {
|
||
let plate = bx([100., 0., 0.], [76.8, 89.3, 2.4]);
|
||
let corners: Vec<Vec3> = [[100., 0.], [176.8, 0.], [100., 89.3], [176.8, 89.3]].iter().map(|p| Vec3::new(p[0], p[1], 1.2)).collect();
|
||
let plate = fillet(&plate, &corners, 3.0).unwrap();
|
||
let ring = boolean(&bx([102.65, 2.65, 2.4], [71.5, 84., 3.]), &bx([104.15, 4.15, 2.0], [68.5, 81., 4.]), BoolOp::Cut).unwrap().unwrap();
|
||
let mut b = boolean(&plate, &ring, BoolOp::Union).unwrap().unwrap();
|
||
for (c, s) in [([104.4, 5.4, -1.], [68., 61., 8.]), ([116.4, 72., -1.], [44., 10., 8.]), ([127.4, 84., 2.4], [22., 6., 4.])] {
|
||
b = boolean(&b, &bx(c, s), BoolOp::Cut).unwrap().unwrap();
|
||
}
|
||
b
|
||
}
|
||
|
||
/// Distance from `p` to the triangle `abc` (brute force, independent of the guard's grid).
|
||
fn tri_dist(p: Vec3, [a, b, c]: [Vec3; 3]) -> f64 {
|
||
let n = (b - a).cross(c - a);
|
||
let Some(u) = n.normalized() else { return f64::INFINITY };
|
||
let h = (p - a).dot(u);
|
||
let q = p - u * h;
|
||
if [(a, b), (b, c), (c, a)].iter().all(|(x, y)| (*y - *x).cross(q - *x).dot(n) >= 0.0) {
|
||
h.abs()
|
||
} else {
|
||
[(a, b), (b, c), (c, a)].iter().map(|(x, y)| p.dist_to_segment(*x, *y)).fold(f64::INFINITY, f64::min)
|
||
}
|
||
}
|
||
|
||
/// A point of `from`'s surface outside `region` that is not on `onto`'s surface.
|
||
fn moved_outside(from: &Mesh, onto: &Mesh, region: &Aabb3, tol: f64) -> Option<Vec3> {
|
||
let tris: Vec<[Vec3; 3]> = onto.triangles.iter().filter_map(|t| onto.tri(t)).collect();
|
||
from.triangles.iter().filter_map(|t| from.tri(t)).find_map(|[a, b, c]| {
|
||
let p = (a + b + c) / 3.0;
|
||
let inside = p.x > region.min.x && p.y > region.min.y && p.z > region.min.z && p.x < region.max.x && p.y < region.max.y && p.z < region.max.z;
|
||
(!inside && !tris.iter().any(|t| tri_dist(p, *t) < tol)).then_some(p)
|
||
})
|
||
}
|
||
|
||
/// The join of `tool` onto `target` left `target` as it was away from the tool: no new surface
|
||
/// (a slit, a moved face) and none lost, outside the tool's box.
|
||
fn assert_local(target: &Body, tool: &Body, joined: &Body, what: &str) {
|
||
let tol = 1e-3;
|
||
let (mt, mtool, mj) = (target.tessellate(0.01).unwrap(), tool.tessellate(0.01).unwrap(), joined.tessellate(0.01).unwrap());
|
||
let mut region = mtool.bounds();
|
||
region.min -= Vec3::new(0.01, 0.01, 0.01);
|
||
region.max += Vec3::new(0.01, 0.01, 0.01);
|
||
assert_eq!(moved_outside(&mj, &mt, ®ion, tol), None, "{what}: new surface away from the tool");
|
||
assert_eq!(moved_outside(&mt, &mj, ®ion, tol), None, "{what}: surface lost away from the tool");
|
||
}
|
||
|
||
/// The four ribs of the issue, 1.6 × 6 × 11.7, sit on the plate (z = 2.4), over the skirt, flush
|
||
/// with the window's sides. Each join adds exactly what lies outside the skirt, changes nothing
|
||
/// else (it used to leave a slit through the far wall), and needs no displacement of the tool.
|
||
#[test]
|
||
fn ribs_joined_flush_to_the_lid_change_nothing_else() {
|
||
let mut b = lid();
|
||
assert!((volume(&b) - 6703.5222).abs() < 1e-3, "{}", volume(&b));
|
||
for c in [[102.8, 14., 2.4], [102.8, 48., 2.4], [172.4, 14., 2.4], [172.4, 48., 2.4]] {
|
||
let tool = bx(c, [1.6, 6., 11.7]);
|
||
let (r, report) = capture_boolean_diagnostics(|| boolean(&b, &tool, BoolOp::Union));
|
||
let r = r.unwrap().unwrap();
|
||
// 1.6 × 6 × 11.7 less the 1.35 × 6 × 3 inside the skirt.
|
||
assert!((volume(&r) - volume(&b) - 88.02).abs() < 1e-6, "{c:?}: {}", volume(&r) - volume(&b));
|
||
assert!(!report.events.iter().any(|e| e.strategy == BooleanStrategy::ToolNudge), "{c:?}: {report:?}");
|
||
assert_eq!(r.solid.boundaries().len(), 1);
|
||
assert_local(&b, &tool, &r, &format!("{c:?}"));
|
||
b = r;
|
||
}
|
||
let bounds = b.tessellate(0.01).unwrap().bounds();
|
||
assert!((bounds.max.z - 14.1).abs() < 1e-9, "the ribs keep their height: {}", bounds.max.z);
|
||
}
|
||
|
||
/// A plane cut through a frame meets it in two faces; gluing the halves back joins both (one
|
||
/// left over was the slit of #616).
|
||
#[test]
|
||
fn glue_joins_every_face_the_pieces_share() {
|
||
let frame = boolean(&bx([0., 0., 0.], [40., 30., 5.]), &bx([5., 5., -1.], [30., 20., 7.]), BoolOp::Cut).unwrap().unwrap();
|
||
let low = boolean(&frame, &bx([-1., -1., -1.], [42., 16., 7.]), BoolOp::Intersect).unwrap().unwrap();
|
||
let high = boolean(&frame, &bx([-1., 15., -1.], [42., 16., 7.]), BoolOp::Intersect).unwrap().unwrap();
|
||
let glued = crate::coplanar::glue(&low, &high).unwrap().unwrap();
|
||
assert_eq!(glued.solid.boundaries().len(), 1);
|
||
assert!((volume(&glued) - volume(&frame)).abs() < 1e-6);
|
||
assert!((measure(&glued).unwrap().area - measure(&frame).unwrap().area).abs() < 1e-6, "no faces left inside");
|
||
}
|
||
|
||
/// The guard refuses a result that changed the target away from the tool, or lost volume in a
|
||
/// join, and accepts the right one.
|
||
#[test]
|
||
fn the_retry_guard_refuses_changes_away_from_the_tool() {
|
||
let target = bx([0., 0., 0.], [40., 30., 5.]);
|
||
let tool = bx([0., 0., 5.], [4., 4., 4.]);
|
||
let right = boolean(&target, &tool, BoolOp::Union).unwrap().unwrap();
|
||
assert!(crate::boolean_guard::check_retry(&target, &tool, BoolOp::Union, Some(&right)).is_ok());
|
||
// A notch far from the tool (volume still up: the tool adds more than the notch takes).
|
||
let notched = boolean(&right, &bx([30., 10., 4.], [2., 2., 2.]), BoolOp::Cut).unwrap().unwrap();
|
||
let e = crate::boolean_guard::check_retry(&target, &tool, BoolOp::Union, Some(¬ched)).unwrap_err().to_string();
|
||
assert!(e.contains("away from the tool") && e.contains("refused"), "{e}");
|
||
// A join that lost volume.
|
||
let e = crate::boolean_guard::check_retry(&target, &tool, BoolOp::Union, Some(&bx([0., 0., 0.], [40., 30., 4.]))).unwrap_err().to_string();
|
||
assert!(e.contains("volume"), "{e}");
|
||
// A cut that comes out empty while the tool covers only part of the target.
|
||
assert!(crate::boolean_guard::check_retry(&target, &tool, BoolOp::Cut, None).is_err());
|
||
assert!(crate::boolean_guard::check_retry(&tool, &target, BoolOp::Cut, None).is_err());
|
||
assert!(crate::boolean_guard::check_retry(&tool, &bx([-1., -1., 4.], [6., 6., 6.]), BoolOp::Cut, None).is_ok());
|
||
}
|
||
|
||
/// Random small boxes joined flush to the walls and floor of a shelled box with rounded corners
|
||
/// (sitting on the floor, against a wall, flush with a wall's top or into its corner), and of
|
||
/// the same box with a window through its floor (on the ledge beside the window, flush with its
|
||
/// side, as the ribs of #616), never lose volume and never change the part away from the box. A
|
||
/// join that fails is an error, never a wrong result.
|
||
#[test]
|
||
fn boxes_joined_flush_to_a_shelled_box_stay_local() {
|
||
let (w, d, h, t) = (60.0, 40.0, 20.0, 2.0);
|
||
let outer = bx([0., 0., 0.], [w, d, h]);
|
||
let corners: Vec<Vec3> = [[0., 0.], [w, 0.], [0., d], [w, d]].iter().map(|p| Vec3::new(p[0], p[1], h / 2.0)).collect();
|
||
let outer = fillet(&outer, &corners, 3.0).unwrap();
|
||
let shelled = boolean(&outer, &bx([t, t, t], [w - 2. * t, d - 2. * t, h]), BoolOp::Cut).unwrap().unwrap();
|
||
// The window leaves a 1.5 ledge inside the walls.
|
||
let (ledge, gap) = (t + 1.5, 6.0);
|
||
let framed = boolean(&shelled, &bx([ledge, gap, -1.], [w - 2. * ledge, d - 2. * gap, t + 2.]), BoolOp::Cut).unwrap().unwrap();
|
||
let mut seed = 0x616_u64;
|
||
let mut next = |lo: f64, hi: f64| {
|
||
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||
lo + (hi - lo) * ((seed >> 11) as f64 / (1u64 << 53) as f64)
|
||
};
|
||
// Sizes and places on a 0.1 grid, like typed values.
|
||
let q = |x: f64| (x * 10.0).round() / 10.0;
|
||
let (mut joined, mut refused) = (0, 0);
|
||
for (name, target) in [("shelled", &shelled), ("framed", &framed)] {
|
||
let base = volume(target);
|
||
for case in 0..20 {
|
||
let s = [q(next(1.0, 6.0)), q(next(1.0, 6.0)), q(next(1.0, 12.0))];
|
||
let (x, y) = (q(next(t + 1.0, w - t - 8.0)), q(next(gap, d - gap - 8.0)));
|
||
let c = match (name, case % 4) {
|
||
// On the floor.
|
||
("shelled", 0) => [x, y, t],
|
||
// On the floor against the -x wall.
|
||
("shelled", 1) => [t, y, t],
|
||
// Into the +y wall, flush with its top, overlapping it.
|
||
("shelled", 2) => [x, d - t - s[1] / 2.0, h - s[2]],
|
||
// On the floor in the corner of the -x and -y walls, sunk into both.
|
||
("shelled", _) => [t - 0.5, t - 0.5, t],
|
||
// On the ledge over the wall, flush with the window's -x or +x side.
|
||
(_, 0) => [ledge - 1.6, y, t],
|
||
(_, 1) => [w - ledge, y, t],
|
||
// On the ledge against the -x wall, short of the window.
|
||
(_, 2) => [t, y, t],
|
||
// Into the +x wall from the ledge, flush with the window's side.
|
||
_ => [w - ledge, y, t],
|
||
};
|
||
let s = if name == "framed" && case % 4 != 2 { [1.6, s[1], s[2]] } else { s };
|
||
let tool = bx(c, s);
|
||
let what = format!("{name} case {case}: corner {c:?} size {s:?}");
|
||
match boolean(target, &tool, BoolOp::Union) {
|
||
Ok(Some(r)) => {
|
||
assert!(volume(&r) >= base - 1e-6, "{what}: the join lost volume ({} < {base})", volume(&r));
|
||
assert!(volume(&r) <= base + volume(&tool) + 1e-6, "{what}");
|
||
assert_local(target, &tool, &r, &what);
|
||
joined += 1;
|
||
}
|
||
Ok(None) => panic!("{what}: a join came out empty"),
|
||
Err(e) => {
|
||
eprintln!("{what}: refused: {e}");
|
||
refused += 1;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
assert_eq!((joined, refused), (40, 0), "every one of these joins works");
|
||
}
|