Two L's extruded up and down from one 2 x 2 grid now join into one solid (the planar rebuild gives each fan of faces around a pinched vertex its own vertex copy), and a pad whose end lies half past the edge of the face it stands on fuses exactly: contact faces are imprinted with each other's outline and the common faces glued away, still held to the retry guard. Face splitting handles a line crossing an edge twice; glue matches differently parametrised curves and keeps several pieces apart. Robustness seed 16 now fuses with the exact volume. Fixes #679, fixes #674. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
17e42e7b94
commit
add4a18961
8 files changed
+465
-34
No files matched your search
@@ -153,3 +153,36 @@ fn ribs_flush_with_a_window_change_nothing_away_from_them() {
|
||||
let bounds = s.model.state().body("Lid").unwrap().body.tessellate(0.01).unwrap().bounds();
|
||||
assert!((bounds.max.z - 14.1).abs() < 1e-9, "{}", bounds.max.z);
|
||||
}
|
||||
|
||||
/// #679: one sketch of a 2 × 2 grid of 10 mm squares; three cells extruded up 10 mm (XO/XX) and
|
||||
/// three down 10 mm, joined (XX/OX). The two L's meet back to back over two whole cells, along
|
||||
/// an edge and at the centre: one solid of 6 cells × 10 mm × 100 mm².
|
||||
#[test]
|
||||
fn ls_extruded_up_and_down_from_one_grid_join() {
|
||||
let mut s = Session::default();
|
||||
let run = |s: &mut Session, id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Grid"}));
|
||||
for (p0, p1) in [([-10, -10], [0, 0]), ([0, -10], [10, 0]), ([-10, 0], [0, 10]), ([0, 0], [10, 10])] {
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": p0, "p1": p1}));
|
||||
}
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let cells = |pts: &[[f64; 2]]| Value::Array(pts.iter().map(|p| json!({"point": p})).collect());
|
||||
run(
|
||||
&mut s,
|
||||
"solid.extrude",
|
||||
json!({"sketch": "Grid", "profiles": cells(&[[-5., 5.], [-5., -5.], [5., -5.]]), "distance": 10, "body_name": "Logo"}),
|
||||
);
|
||||
let reply = run(
|
||||
&mut s,
|
||||
"solid.extrude",
|
||||
json!({"sketch": "Grid", "profiles": cells(&[[-5., 5.], [5., 5.], [5., -5.]]), "distance": "10 mm", "direction": "negative", "operation": "join"}),
|
||||
);
|
||||
assert!(reply["warning"].as_str().is_none_or(|w| !w.contains("tool displacement")), "{reply}");
|
||||
let st = s.model.state();
|
||||
assert_eq!(st.bodies.len(), 1);
|
||||
let body = &st.body("Logo").unwrap().body;
|
||||
assert_eq!(body.shell_count(), 1);
|
||||
assert!(body.validity().is_empty(), "{:?}", body.validity());
|
||||
let v = volume(&s, "Logo");
|
||||
assert!((v - 6000.0).abs() < 1e-6, "{v}");
|
||||
}
|
||||
@@ -202,8 +202,8 @@ fn fillet_into_an_earlier_round() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Clean seeds, and the original seed 5 edge contact and seed 16 partial face contact, now
|
||||
/// rejected without distortion.
|
||||
/// Clean seeds (seed 16: a partial face contact, fused), and the original seed 5 edge contact,
|
||||
/// now rejected without distortion.
|
||||
#[test]
|
||||
fn fixed_seeds_run_clean() {
|
||||
for seed in FIXED_SEEDS {
|
||||
@@ -228,12 +228,10 @@ fn fixed_seeds_run_clean() {
|
||||
continue;
|
||||
}
|
||||
if *seed == 16 {
|
||||
// Seed 16 patterns a short cylinder around a block: one copy's cap lies on a side of
|
||||
// the block but half of it runs past the block's bottom edge. That contact can't be
|
||||
// fused yet (#674). Before #577 the join came back as two shells touching over the
|
||||
// half disc (#396); with #577 alone, the cap was pushed into the block over its whole
|
||||
// area, which added 72 mm³ past the edge (45290.6 mm³ for 45218.4). The retry guard (#644) refuses that,
|
||||
// so the pattern is refused and the part stays as it was.
|
||||
// Seed 16 patterns a short cylinder around a block: one copy's end lies on a side of
|
||||
// the block with half of it past the block's bottom edge (#674). The end and the side
|
||||
// are split along each other's outline and the half disc they share goes: the block,
|
||||
// half of the first cylinder (the other half is inside the block) and three whole ones.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 25.0, "width": 47.5, "height": 37.5}));
|
||||
run(
|
||||
@@ -241,13 +239,13 @@ fn fixed_seeds_run_clean() {
|
||||
"solid.cylinder",
|
||||
json!({"axis": [1.0, 0.0, 0.0], "base": [0.0, 7.5, 0.0], "height": 2.5, "operation": "join", "radius": 5.0}),
|
||||
);
|
||||
let before = total_volume(&s);
|
||||
// The block, and the half of the cylinder outside it.
|
||||
assert!((before - (25.0 * 47.5 * 37.5 + PI * 25.0 * 2.5 / 2.0)).abs() < 0.05, "{before}");
|
||||
let error = s.execute("solid.pattern.circular", &json!({"axis": "Z", "count": 4, "features": ["Cylinder1"]})).unwrap_err();
|
||||
assert!(error.to_string().contains("touch along faces that could not be fused"), "{error}");
|
||||
assert!((total_volume(&s) - before).abs() < 1e-6);
|
||||
run(&mut s, "solid.pattern.circular", json!({"axis": "Z", "count": 4, "features": ["Cylinder1"]}));
|
||||
// (Within the measured round sides' mesh error, about 0.03 mm³ a cylinder.)
|
||||
let want = 25.0 * 47.5 * 37.5 + 3.5 * PI * 25.0 * 2.5;
|
||||
assert!((total_volume(&s) - want).abs() < 0.2, "{} vs {want}", total_volume(&s));
|
||||
assert!(s.model.state().bodies.iter().all(|b| b.body.validity().is_empty()));
|
||||
let r = fuzz::run(*seed, 8);
|
||||
assert!(!r.outcome.is_bug(), "seed {seed}: {:?}\n{}", r.outcome, r.script());
|
||||
continue;
|
||||
}
|
||||
let r = fuzz::run(*seed, 8);
|
||||
|
||||
@@ -468,6 +468,94 @@ fn backed(a: &Body, b: &Body, m: Vec3, d: f64, depth: f64) -> bool {
|
||||
!points.is_empty() && points.iter().all(|p| [0.05, 0.5, 1.0].iter().all(|k| inside.contains(*p + m * (depth * k))))
|
||||
}
|
||||
|
||||
/// The faces of `body` lying in the plane (n, d), facing along n.
|
||||
fn faces_in(body: &Body, n: Vec3, d: f64, tol: f64) -> Vec<usize> {
|
||||
planes(body).into_iter().filter(|(_, p)| p.n.dot(n) > 1.0 - 1e-9 && (p.d - d).abs() < tol).map(|(i, _)| i).collect()
|
||||
}
|
||||
|
||||
/// `body` (with its history `h` over its operand) with its faces in the plane (n, d) split
|
||||
/// where the faces of `tool` next to the tool's faces in the opposite plane cross them: the
|
||||
/// outline of the tool's contact imprinted on the body's.
|
||||
fn imprinted(body: Body, mut h: Option<History>, n: Vec3, d: f64, tool: &Body, tol: f64) -> Result<(Body, Option<History>)> {
|
||||
let contact = faces_in(tool, -n, -d, tol);
|
||||
let bounds = crate::history::edge_faces(&tool.solid);
|
||||
let mut beside: Vec<usize> = bounds
|
||||
.iter()
|
||||
.filter(|fs| fs.len() == 2 && fs.iter().any(|f| contact.contains(f)))
|
||||
.flat_map(|fs| fs.iter().copied())
|
||||
.filter(|f| !contact.contains(f))
|
||||
.collect();
|
||||
beside.sort_unstable();
|
||||
beside.dedup();
|
||||
let mut body = body;
|
||||
for t in beside {
|
||||
let faces = faces_in(&body, n, d, tol);
|
||||
if faces.is_empty() {
|
||||
break;
|
||||
}
|
||||
// Faces the tool's face does not cross stay as they are.
|
||||
let crossed: Vec<usize> = faces
|
||||
.into_iter()
|
||||
.filter(|f| crate::splitface::split_any(&body, &[*f], &crate::SplitTool::Face { body: tool, face: t }).is_ok())
|
||||
.collect();
|
||||
if crossed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (split, _) = crate::splitface::split_any(&body, &crossed, &crate::SplitTool::Face { body: tool, face: t })?;
|
||||
h = crate::boolean_record::through(&split, 0, h.as_ref());
|
||||
body = split;
|
||||
}
|
||||
Ok((body, h))
|
||||
}
|
||||
|
||||
/// Union of bodies touching back to back over part of a planar face (a pad half past the edge
|
||||
/// of the face it stands on, two blocks meeting over some of their faces, #674, #679): each
|
||||
/// body's contact faces are split along the outline of the other's, so the common area is the
|
||||
/// same face in both, and those faces are glued away. `None` when that does not give a result
|
||||
/// `accept`ed.
|
||||
pub(crate) fn imprint_union(a: &Body, b: &Body, accept: &dyn Fn(&Body) -> bool) -> Option<Body> {
|
||||
let size = a.size().max(b.size());
|
||||
let tol = (size * 1e-7).max(1e-9) * 10.0;
|
||||
let back: Vec<(Vec3, f64)> = coincident_planes(a, b).into_iter().filter(|(_, _, same)| !*same).map(|(n, d, _)| (n, d)).collect();
|
||||
if back.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let run = || -> Result<Option<Body>> {
|
||||
let (mut a2, mut ha) = (a.clone(), crate::boolean_record::kept(a, 0, 1));
|
||||
let (mut b2, mut hb) = (b.clone(), crate::boolean_record::kept(b, 0, 1));
|
||||
for (n, d) in &back {
|
||||
// a's faces face along n in the plane; b's face the other way.
|
||||
(a2, ha) = imprinted(a2, ha, *n, *d, b, tol)?;
|
||||
(b2, hb) = imprinted(b2, hb, -*n, -*d, a, tol)?;
|
||||
}
|
||||
// The common faces' boundaries with the same vertices on both sides.
|
||||
for (n, d) in &back {
|
||||
let corners = |body: &Body, n: Vec3, d: f64| -> Vec<Vec3> {
|
||||
let fs: Vec<&mt::Face> = body.solid.face_iter().collect();
|
||||
let mut out: Vec<Vec3> =
|
||||
faces_in(body, n, d, tol).iter().filter_map(|i| fs.get(*i)).flat_map(|f| f.vertex_iter().map(|v| from_p3(v.point()))).collect();
|
||||
out.dedup_by(|x, y| x.dist(*y) <= tol);
|
||||
out
|
||||
};
|
||||
let (pa, pb) = (corners(&a2, *n, *d), corners(&b2, -*n, -*d));
|
||||
let a3 = crate::splitface::split_edges_at(&a2, &pb)?;
|
||||
ha = crate::boolean_record::through(&a3, 0, ha.as_ref());
|
||||
let b3 = crate::splitface::split_edges_at(&b2, &pa)?;
|
||||
hb = crate::boolean_record::through(&b3, 0, hb.as_ref());
|
||||
(a2, b2) = (a3, b3);
|
||||
}
|
||||
let Some(r) = glue(&a2, &b2) else { return Ok(None) };
|
||||
let r = r?;
|
||||
let (ha, hb) = (crate::boolean_record::placed(ha, 0, 2), crate::boolean_record::placed(hb, 1, 2));
|
||||
let h = crate::boolean_record::of_pair(&r, &ha, &hb);
|
||||
Ok(Some(r.with_checked_history(h, &record::counts(&[a, b]))))
|
||||
};
|
||||
match guard("imprint union", run) {
|
||||
Ok(Some(r)) if accept(&r) => Some(r),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Union of bodies that touch along identical planar faces, back to back (a body joined with
|
||||
/// its mirror image, or the pieces of a body cut apart by a plane, which may meet in several
|
||||
/// faces): every such pair of faces goes and the shells are stitched along their edges. `None`
|
||||
@@ -506,10 +594,20 @@ pub(crate) fn glue(a: &Body, b: &Body) -> Option<Result<Body>> {
|
||||
let mut ok = true;
|
||||
for e in &eb {
|
||||
let ends = (key(from_p3(e.absolute_front().point())), key(from_p3(e.absolute_back().point())));
|
||||
let m = key(mid(e));
|
||||
let m = mid(e);
|
||||
// The same curve: the same ends and e's middle on f (curves of one shape may be
|
||||
// parametrised differently, so their parameter middles needn't coincide).
|
||||
let on = |f: &mt::Edge| {
|
||||
use mt::SearchNearestParameter;
|
||||
let c = f.curve();
|
||||
let (t0, t1) = c.range_tuple();
|
||||
key(mid(f)) == key(m)
|
||||
|| c.search_nearest_parameter(p3(m), None, 100)
|
||||
.is_some_and(|t| (t0.min(t1) - 1e-9..=t0.max(t1) + 1e-9).contains(&t) && from_p3(c.subs(t)).dist(m) <= tol)
|
||||
};
|
||||
let hit = ea.iter().find(|f| {
|
||||
let fe = (key(from_p3(f.absolute_front().point())), key(from_p3(f.absolute_back().point())));
|
||||
(fe == ends || fe == (ends.1, ends.0)) && key(mid(f)) == m
|
||||
(fe == ends || fe == (ends.1, ends.0)) && on(f)
|
||||
});
|
||||
let Some(f) = hit else {
|
||||
ok = false;
|
||||
@@ -586,7 +684,11 @@ pub(crate) fn glue(a: &Body, b: &Body) -> Option<Result<Body>> {
|
||||
}
|
||||
faces.push(nf);
|
||||
}
|
||||
let solid = Solid::try_new(vec![faces.into()]).map_err(|e| fail(&e.to_string()))?;
|
||||
// Bodies of several pieces stay in several pieces (each face's provenance follows it).
|
||||
let prov: HashMap<mt::FaceID, FaceProv> = faces.iter().map(|f| f.id()).zip(from).collect();
|
||||
let shell: mt::Shell = faces.into();
|
||||
let solid = Solid::try_new(shell.connected_components()).map_err(|e| fail(&e.to_string()))?;
|
||||
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]))
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
//! Bodies touching back to back over part of a face fuse into one solid: two L's of a 2 × 2
|
||||
//! grid extruded up and down (#679), a pad half past the edge of the face it stands on (#674),
|
||||
//! several contact regions, contact beside a shared edge, and random rectangles.
|
||||
|
||||
use solidcraft_geom::{Loop2, Plane, Region2, Vec2, Vec3};
|
||||
|
||||
use crate::*;
|
||||
|
||||
fn prism(pts: &[[f64; 2]], z0: f64, z1: f64) -> Body {
|
||||
let outer = Loop2::polygon(&pts.iter().map(|p| Vec2::new(p[0], p[1])).collect::<Vec<_>>());
|
||||
extrude(&Plane::XY, &[Region2 { outer, holes: vec![] }], z0, z1).unwrap().pop().unwrap()
|
||||
}
|
||||
|
||||
fn rect(x0: f64, y0: f64, x1: f64, y1: f64, z0: f64, z1: f64) -> Body {
|
||||
box_solid(Vec3::new(x0, y0, z0), Vec3::new(x1, y1, z1)).unwrap()
|
||||
}
|
||||
|
||||
fn fused(a: &Body, b: &Body) -> (Body, BooleanDiagnostics) {
|
||||
let (r, d) = capture_boolean_diagnostics(|| boolean(a, b, BoolOp::Union));
|
||||
(r.unwrap().unwrap(), d)
|
||||
}
|
||||
|
||||
fn assert_one_solid(r: &Body, volume: f64, tol: f64, what: &str) {
|
||||
let m = measure(r).unwrap();
|
||||
assert!((m.volume - volume).abs() <= tol, "{what}: volume {} vs {volume}", m.volume);
|
||||
assert_eq!(r.shell_count(), 1, "{what}: one solid");
|
||||
assert!(r.validity().is_empty(), "{what}: {:?}", r.validity());
|
||||
}
|
||||
|
||||
/// #679: XO/XX extruded up 10, XX/OX down 10, 10 mm cells. They meet back to back over two
|
||||
/// whole cells, along an edge and at the centre point: 6 cells × 10 mm × 100 mm².
|
||||
#[test]
|
||||
fn two_ls_up_and_down_fuse_into_one_solid() {
|
||||
let up = prism(&[[-10., -10.], [10., -10.], [10., 0.], [0., 0.], [0., 10.], [-10., 10.]], 0., 10.);
|
||||
let down = prism(&[[-10., 10.], [-10., 0.], [0., 0.], [0., -10.], [10., -10.], [10., 10.]], -10., 0.);
|
||||
for (a, b) in [(&up, &down), (&down, &up)] {
|
||||
let (r, d) = fused(a, b);
|
||||
assert_one_solid(&r, 6000.0, 1e-6, "#679");
|
||||
assert!(!d.events.iter().any(|e| e.strategy == BooleanStrategy::ToolNudge), "{d:?}");
|
||||
// Two tops, two bottoms, the two cells left open at z = 0, six sides.
|
||||
assert_eq!(r.face_count(), 12);
|
||||
}
|
||||
}
|
||||
|
||||
/// Two blocks up from z = 0 and one plate down under both: two separate contacts fuse into one
|
||||
/// solid. A plate under a block, flush with its sides along shared edges.
|
||||
#[test]
|
||||
fn several_contact_regions_and_edge_contact() {
|
||||
let up = rect(-10., -10., -2., -2., 0., 10.);
|
||||
let up2 = rect(2., 2., 10., 10., 0., 10.);
|
||||
let ups = boolean(&up, &up2, BoolOp::Union).unwrap().unwrap();
|
||||
// Down under both up cells, and under one more.
|
||||
let down = prism(&[[-10., -10.], [0., -10.], [0., 0.], [10., 0.], [10., 10.], [-10., 10.]], -5., 0.);
|
||||
let (r, _) = fused(&ups, &down);
|
||||
assert_one_solid(&r, 2.0 * 640.0 + 3.0 * 500.0, 1e-6, "two contacts");
|
||||
// Down under one up cell and beside the other: one face contact and one edge contact.
|
||||
// A plate under one block, flush with two of its sides and running on past the third.
|
||||
let down = rect(-10., -10., -2., 5., -5., 0.);
|
||||
let (r, _) = fused(&up, &down);
|
||||
assert_one_solid(&r, 640.0 + 8.0 * 15.0 * 5.0, 1e-6, "contact and flush sides");
|
||||
}
|
||||
|
||||
/// #674 (robustness seed 16): a short cylinder whose end lies on a block's side with half of it
|
||||
/// past the block's bottom edge. The end is split along the edge, the block's side along the
|
||||
/// circle, and the half disc they share goes.
|
||||
#[test]
|
||||
fn a_pad_half_past_the_edge_of_its_face_fuses() {
|
||||
let block = rect(0., 0., 25., 47.5, 0., 37.5);
|
||||
let pad = cylinder(Vec3::new(7.5, 0., 0.), -Vec3::Y, 5.0, 2.5).unwrap();
|
||||
// Measured as the result is (the round side's volume from its mesh).
|
||||
let want = measure(&block).unwrap().volume + measure(&pad).unwrap().volume;
|
||||
assert!((want - (25.0 * 47.5 * 37.5 + std::f64::consts::PI * 25.0 * 2.5)).abs() < 0.1);
|
||||
for (a, b) in [(&block, &pad), (&pad, &block)] {
|
||||
let (r, d) = fused(a, b);
|
||||
assert_one_solid(&r, want, 1e-6 * want, "#674");
|
||||
assert!(d.events.is_empty(), "an exact join: {d:?}");
|
||||
let bounds = r.tessellate(0.01).unwrap().bounds();
|
||||
assert!((bounds.min.y + 2.5).abs() < 1e-9 && (bounds.min.z + 5.0).abs() < 0.01, "{bounds:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Random rectangles extruded up from z = 0 and down from it, overlapping anywhere (inside one
|
||||
/// another, crossing, sharing a side): one solid of exactly the two prisms' volume.
|
||||
#[test]
|
||||
fn random_rectangles_either_side_of_a_plane_fuse_exactly() {
|
||||
let mut seed = 0x679_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)
|
||||
};
|
||||
let q = |x: f64| (x * 2.0).round() / 2.0;
|
||||
let mut done = 0;
|
||||
for case in 0..30 {
|
||||
let (x0, y0) = (q(next(0., 20.)), q(next(0., 20.)));
|
||||
let (w0, h0) = (q(next(2., 20.)), q(next(2., 20.)));
|
||||
// The second overlaps the first over an area.
|
||||
let (x1, y1) = (q(next(x0 - 15., x0 + w0 - 1.)), q(next(y0 - 15., y0 + h0 - 1.)));
|
||||
let (w1, h1) = (q(next(1., 25.)).max(x0 + 1. - x1).max(1.), q(next(1., 25.)).max(y0 + 1. - y1).max(1.));
|
||||
let (t0, t1) = (q(next(1., 10.)), q(next(1., 10.)));
|
||||
let up = rect(x0, y0, x0 + w0, y0 + h0, 0., t0);
|
||||
let down = rect(x1, y1, x1 + w1, y1 + h1, -t1, 0.);
|
||||
let want = w0 * h0 * t0 + w1 * h1 * t1;
|
||||
let what = format!("case {case}: up {x0},{y0} {w0}x{h0}x{t0}, down {x1},{y1} {w1}x{h1}x{t1}");
|
||||
let (r, d) = fused(&up, &down);
|
||||
assert_one_solid(&r, want, 1e-6 * want, &what);
|
||||
assert!(!d.events.iter().any(|e| e.strategy == BooleanStrategy::ToolNudge), "{what}: {d:?}");
|
||||
done += 1;
|
||||
}
|
||||
assert_eq!(done, 30);
|
||||
}
|
||||
@@ -39,6 +39,8 @@ mod iges_out;
|
||||
mod intersection_curve_tests;
|
||||
#[cfg(test)]
|
||||
mod issue_616_tests;
|
||||
#[cfg(test)]
|
||||
mod issue_679_tests;
|
||||
mod lip;
|
||||
mod loopblend;
|
||||
mod measure;
|
||||
|
||||
@@ -549,6 +549,14 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||||
return Ok(Some(body));
|
||||
}
|
||||
}
|
||||
// Faces back to back over part of their area: imprinted on each other and glued (exact; held
|
||||
// to the retry guard's checks all the same).
|
||||
if op == BoolOp::Union && crate::coplanar::faces_touch(a, b) {
|
||||
let accept = |body: &Body| check(body).0 && !unfused(body) && crate::boolean_guard::check_retry(a, b, op, Some(body)).is_ok();
|
||||
if let Some(body) = crate::coplanar::imprint_union(a, b, &accept) {
|
||||
return Ok(Some(body));
|
||||
}
|
||||
}
|
||||
let mut empty_votes = 0;
|
||||
let mut empty_diagnostics = crate::BooleanDiagnostics::default();
|
||||
let keep: Vec<Vec<Vec3>> = a.split_keep().iter().chain(b.split_keep()).cloned().collect();
|
||||
|
||||
@@ -857,13 +857,11 @@ fn rebuild(polys: Vec<Poly>, size: f64, sources: &Sources) -> Result<Body> {
|
||||
joined.get(roots.get(li)?).cloned()
|
||||
};
|
||||
guard("planar boolean rebuild", || {
|
||||
let tv: Vec<mt::Vertex> = verts.iter().map(|v| builder::vertex(p3(*v))).collect();
|
||||
let mut edges: HashMap<(usize, usize), mt::Edge> = HashMap::new();
|
||||
let mut faces: Vec<mt::Face> = Vec::new();
|
||||
let mut face_from: HashMap<mt::FaceID, Option<BTreeSet<FaceOrigin>>> = HashMap::new();
|
||||
for (g, plane) in groups.iter().enumerate() {
|
||||
let mut verts = verts.clone();
|
||||
// Every plane group's boundary loops (vertex indices).
|
||||
let mut all_loops: Vec<Vec<Vec<usize>>> = Vec::new();
|
||||
for (g, _) in groups.iter().enumerate() {
|
||||
let mut segs: Vec<(usize, usize)> = boundary.iter().filter(|e| e.0 == g).map(|e| (e.1, e.2)).collect();
|
||||
let mut wires: Vec<mt::Wire> = Vec::new();
|
||||
let mut loops_g: Vec<Vec<usize>> = Vec::new();
|
||||
while let Some((s, t)) = segs.pop() {
|
||||
let mut lp = vec![s, t];
|
||||
@@ -895,7 +893,19 @@ fn rebuild(polys: Vec<Poly>, size: f64, sources: &Sources) -> Result<Body> {
|
||||
}
|
||||
}
|
||||
}
|
||||
for lp in &loops_g {
|
||||
all_loops.push(loops_g);
|
||||
}
|
||||
// The original vertex of each vertex index (pinched vertices get copies, below).
|
||||
let mut orig: Vec<usize> = (0..verts.len()).collect();
|
||||
split_pinched_vertices(&mut all_loops, &mut verts, &mut orig);
|
||||
let tv: Vec<mt::Vertex> = verts.iter().map(|v| builder::vertex(p3(*v))).collect();
|
||||
let mut edges: HashMap<(usize, usize), mt::Edge> = HashMap::new();
|
||||
let mut faces: Vec<mt::Face> = Vec::new();
|
||||
let mut face_from: HashMap<mt::FaceID, Option<BTreeSet<FaceOrigin>>> = HashMap::new();
|
||||
for (g, plane) in groups.iter().enumerate() {
|
||||
let Some(loops_g) = all_loops.get(g) else { continue };
|
||||
let mut wires: Vec<mt::Wire> = Vec::new();
|
||||
for lp in loops_g {
|
||||
let n = lp.len();
|
||||
let mut es = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
@@ -964,7 +974,7 @@ fn rebuild(polys: Vec<Poly>, size: f64, sources: &Sources) -> Result<Body> {
|
||||
ws.push(wires.get(*h).cloned().ok_or_else(|| KernelError::Failed("wire".into()))?);
|
||||
}
|
||||
}
|
||||
let from = loop_faces(g, ol);
|
||||
let from = loop_faces(g, &ol.iter().map(|k| orig.get(*k).copied().unwrap_or(*k)).collect::<Vec<_>>());
|
||||
// A loop a hair off flat (fine facets of a curved face) takes the group's plane.
|
||||
let face = match builder::try_attach_plane(&ws) {
|
||||
Ok(f) => f,
|
||||
@@ -988,6 +998,7 @@ fn rebuild(polys: Vec<Poly>, size: f64, sources: &Sources) -> Result<Body> {
|
||||
.iter()
|
||||
.map(|((a, b), e)| {
|
||||
let ends = (*verts.get(*a)?, *verts.get(*b)?);
|
||||
let (a, b) = (orig.get(*a)?, orig.get(*b)?);
|
||||
let mut origins = BTreeSet::new();
|
||||
for p in side.get(&(*a, *b))? {
|
||||
for q in side.get(&(*b, *a))? {
|
||||
@@ -1001,6 +1012,73 @@ fn rebuild(polys: Vec<Poly>, size: f64, sources: &Sources) -> Result<Body> {
|
||||
})
|
||||
}
|
||||
|
||||
/// A vertex where the solid's surface pinches, as where two blocks meet at one corner only
|
||||
/// (#679): the faces around it form several fans that share no edge there. Each fan past the
|
||||
/// first gets its own copy of the vertex (same point), so every vertex of the shell is a
|
||||
/// manifold one.
|
||||
fn split_pinched_vertices(loops: &mut [Vec<Vec<usize>>], verts: &mut Vec<Vec3>, orig: &mut Vec<usize>) {
|
||||
// Every corner: (group, loop, position), by vertex.
|
||||
let mut corners: HashMap<usize, Vec<(usize, usize, usize)>> = HashMap::new();
|
||||
for (g, ls) in loops.iter().enumerate() {
|
||||
for (li, l) in ls.iter().enumerate() {
|
||||
for (i, v) in l.iter().enumerate() {
|
||||
corners.entry(*v).or_default().push((g, li, i));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut renames: Vec<((usize, usize, usize), usize)> = Vec::new();
|
||||
let mut ids: Vec<usize> = corners.keys().copied().collect();
|
||||
ids.sort_unstable();
|
||||
for v in ids {
|
||||
let Some(cs) = corners.get(&v) else { continue };
|
||||
if cs.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
// The two neighbours of each corner; corners sharing an edge (v, x) are in one fan.
|
||||
let nbrs: Vec<[usize; 2]> = cs
|
||||
.iter()
|
||||
.map(|(g, li, i)| {
|
||||
let l = loops.get(*g).and_then(|ls| ls.get(*li)).map(Vec::as_slice).unwrap_or(&[]);
|
||||
let n = l.len().max(1);
|
||||
[l.get((i + n - 1) % n).copied().unwrap_or(v), l.get((i + 1) % n).copied().unwrap_or(v)]
|
||||
})
|
||||
.collect();
|
||||
let mut parent: Vec<usize> = (0..cs.len()).collect();
|
||||
for i in 0..cs.len() {
|
||||
for j in i + 1..cs.len() {
|
||||
let (Some(a), Some(b)) = (nbrs.get(i), nbrs.get(j)) else { continue };
|
||||
if a.iter().any(|x| b.contains(x)) {
|
||||
let (ri, rj) = (find(&mut parent, i), find(&mut parent, j));
|
||||
if let Some(slot) = parent.get_mut(ri) {
|
||||
*slot = rj;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut fan_id: HashMap<usize, usize> = HashMap::new();
|
||||
for (k, c) in cs.iter().enumerate() {
|
||||
let root = find(&mut parent, k);
|
||||
let next = fan_id.len();
|
||||
let fan = *fan_id.entry(root).or_insert(next);
|
||||
if fan > 0 {
|
||||
// A copy per extra fan.
|
||||
let copy = verts.len() + fan - 1;
|
||||
renames.push((*c, copy));
|
||||
}
|
||||
}
|
||||
let p = verts.get(v).copied().unwrap_or_default();
|
||||
for _ in 1..fan_id.len() {
|
||||
verts.push(p);
|
||||
orig.push(v);
|
||||
}
|
||||
}
|
||||
for ((g, li, i), new) in renames {
|
||||
if let Some(slot) = loops.get_mut(g).and_then(|ls| ls.get_mut(li)).and_then(|l| l.get_mut(i)) {
|
||||
*slot = new;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod diagnostic_tests {
|
||||
|
||||
@@ -385,7 +385,14 @@ fn cut_edge(e: &mt::Edge, t: f64, p: mt::Point3, tol: f64) -> Result<(mt::Vertex
|
||||
return Ok((abs.back().clone(), None));
|
||||
}
|
||||
let v = mt::Vertex::new(p);
|
||||
let (a, b) = abs.cut_with_parameter(&v, t).ok_or_else(|| KernelError::Failed("cannot cut an edge at the split line".into()))?;
|
||||
// `t` is on the edge as it was: when an earlier crossing already cut it (a split line
|
||||
// crossing it twice), the parameter is found again on the piece.
|
||||
let cut = abs.cut_with_parameter(&v, t).or_else(|| {
|
||||
use mt::SearchNearestParameter;
|
||||
let t = abs.curve().search_nearest_parameter(p, None, 100)?;
|
||||
abs.cut_with_parameter(&v, t)
|
||||
});
|
||||
let (a, b) = cut.ok_or_else(|| KernelError::Failed("cannot cut an edge at the split line".into()))?;
|
||||
Ok((v, Some((a, b))))
|
||||
}
|
||||
|
||||
@@ -401,11 +408,7 @@ fn recut(face: &mt::Face, cuts: &[(mt::Edge, (mt::Edge, mt::Edge))]) -> mt::Face
|
||||
.map(|w| {
|
||||
let mut out: Vec<mt::Edge> = Vec::new();
|
||||
for e in w.edge_iter() {
|
||||
match cuts.iter().find(|(c, _)| c.id() == e.id()) {
|
||||
Some((c, (a, b))) if c.orientation() == e.orientation() => out.extend([a.clone(), b.clone()]),
|
||||
Some((_, (a, b))) => out.extend([b.inverse(), a.inverse()]),
|
||||
None => out.push(e.clone()),
|
||||
}
|
||||
expand(e, cuts, &mut out, 0);
|
||||
}
|
||||
out.into()
|
||||
})
|
||||
@@ -417,6 +420,99 @@ fn recut(face: &mt::Face, cuts: &[(mt::Edge, (mt::Edge, mt::Edge))]) -> mt::Face
|
||||
f
|
||||
}
|
||||
|
||||
/// `e` as the pieces it was cut into, in its direction; a piece cut again (a line crossing an
|
||||
/// edge twice) as its own pieces.
|
||||
fn expand(e: &mt::Edge, cuts: &[(mt::Edge, (mt::Edge, mt::Edge))], out: &mut Vec<mt::Edge>, depth: usize) {
|
||||
match cuts.iter().find(|(c, _)| c.id() == e.id()).filter(|_| depth < 64) {
|
||||
Some((c, (a, b))) if c.orientation() == e.orientation() => {
|
||||
expand(a, cuts, out, depth + 1);
|
||||
expand(b, cuts, out, depth + 1);
|
||||
}
|
||||
Some((_, (a, b))) => {
|
||||
expand(&b.inverse(), cuts, out, depth + 1);
|
||||
expand(&a.inverse(), cuts, out, depth + 1);
|
||||
}
|
||||
None => out.push(e.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// `body` with its edges cut at the given points where they pass through them (not at their
|
||||
/// ends): the faces either side keep the same region, bounded by the pieces. Used to give two
|
||||
/// faces that cover the same region the same vertices before they are glued (#674).
|
||||
pub(crate) fn split_edges_at(body: &Body, points: &[Vec3]) -> Result<Body> {
|
||||
use mt::SearchNearestParameter;
|
||||
let size = body.size();
|
||||
let tol = size * 1e-7 + 1e-9;
|
||||
guard("split edges", || {
|
||||
let mut cuts: Vec<(mt::Edge, (mt::Edge, mt::Edge))> = Vec::new();
|
||||
let mut rec = Rebuild::default();
|
||||
// Each current piece and the body edge it is a piece of.
|
||||
let mut current: Vec<(mt::Edge, mt::Edge)> = body
|
||||
.solid
|
||||
.edge_iter()
|
||||
.map(|e| {
|
||||
let abs = if e.orientation() { e.clone() } else { e.inverse() };
|
||||
(abs.clone(), abs)
|
||||
})
|
||||
.collect();
|
||||
for p in points {
|
||||
let q = p3(*p);
|
||||
let hit = current.iter().position(|(e, _)| {
|
||||
if e.front().point().distance(q) <= tol * 10.0 || e.back().point().distance(q) <= tol * 10.0 {
|
||||
return false;
|
||||
}
|
||||
let c = e.curve();
|
||||
let (t0, t1) = c.range_tuple();
|
||||
c.search_nearest_parameter(q, None, 100).is_some_and(|t| t > t0.min(t1) && t < t0.max(t1) && c.subs(t).distance(q) <= tol * 10.0)
|
||||
});
|
||||
let Some(k) = hit else { continue };
|
||||
let (e, root) = current.swap_remove(k);
|
||||
let Some(t) = e.curve().search_nearest_parameter(q, None, 100) else { continue };
|
||||
let (_, cut) = cut_edge(&e, t, q, tol)?;
|
||||
if let Some((a, b)) = cut {
|
||||
rec.piece(&a, &root);
|
||||
rec.piece(&b, &root);
|
||||
current.push((a.clone(), root.clone()));
|
||||
current.push((b.clone(), root.clone()));
|
||||
cuts.push((e, (a, b)));
|
||||
} else {
|
||||
current.push((e, root));
|
||||
}
|
||||
}
|
||||
if cuts.is_empty() {
|
||||
return Ok(body.clone());
|
||||
}
|
||||
let shells: Vec<mt::Shell> = body
|
||||
.solid
|
||||
.boundaries()
|
||||
.iter()
|
||||
.map(|sh| {
|
||||
sh.face_iter()
|
||||
.map(|f| {
|
||||
let nf = recut(f, &cuts);
|
||||
rec.whole(&nf, f);
|
||||
nf
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.into()
|
||||
})
|
||||
.collect();
|
||||
let solid = mt::Solid::new_unchecked(shells);
|
||||
let split = Body {
|
||||
solid: std::sync::Arc::new(solid),
|
||||
coarse_missing: None,
|
||||
mesh: None,
|
||||
color: body.color,
|
||||
paint: None,
|
||||
splits: body.splits.clone(),
|
||||
face_sources: None,
|
||||
history: None,
|
||||
regularization_protected: true,
|
||||
};
|
||||
Ok(crate::record::rebuilt_body(split, &[&body.solid], &rec))
|
||||
})
|
||||
}
|
||||
|
||||
/// A face rebuilt from loops (absolute) on the same surface and orientation as `like`.
|
||||
fn face_like(like: &mt::Face, wires: Vec<mt::Wire>) -> mt::Face {
|
||||
let mut f = mt::Face::new_unchecked(wires, like.surface());
|
||||
@@ -532,12 +628,16 @@ fn split_one(faces: &mut Vec<mt::Face>, fi: usize, g: &Field, size: f64, new_edg
|
||||
let mut e = h.edge.clone();
|
||||
for (c, (x, y)) in cuts.iter() {
|
||||
if c.id() == e.id() || c.id() == e.inverse().id() {
|
||||
let on = |pc: &mt::Edge| {
|
||||
// The piece the hit lies on (a cut line's pieces are parametrised afresh,
|
||||
// so by where the point is, not by the original parameter).
|
||||
let off = |pc: &mt::Edge| {
|
||||
use mt::SearchNearestParameter;
|
||||
let cc = pc.curve();
|
||||
let (t0, t1) = cc.range_tuple();
|
||||
(t0.min(t1) - 1e-12..=t0.max(t1) + 1e-12).contains(&h.t)
|
||||
let t = cc.search_nearest_parameter(h.p, None, 100).unwrap_or(h.t).clamp(t0.min(t1), t0.max(t1));
|
||||
cc.subs(t).distance(h.p)
|
||||
};
|
||||
e = if on(x) { x.clone() } else { y.clone() };
|
||||
e = if off(x) <= off(y) { x.clone() } else { y.clone() };
|
||||
}
|
||||
}
|
||||
let (v, cut) = cut_edge(&e, h.t, h.p, tol)?;
|
||||
|
||||
Reference in new issue
Block a user