fix: fuse bodies that touch back to back over part of a face (#679, #674) (#704)

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:
bherbruckandClaude Opus 5.5 authored and GitHub committed 2026-10-13 10:02:44 -04:00
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}");
}
+12 -14
View File
@@ -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);
+105 -3
View File
@@ -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]))
+110
View File
@@ -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);
}
+2
View File
@@ -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;
+8
View File
@@ -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();
+86 -8
View File
@@ -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 {
+109 -9
View File
@@ -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)?;