From add4a18961c3b93ac0cddfdde8a065bc93d2f2ec Mon Sep 17 00:00:00 2001 From: bherbruck Date: Tue, 13 Oct 2026 10:02:44 -0400 Subject: [PATCH] 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 --- crates/engine/src/boolean_regression_tests.rs | 33 +++++ crates/engine/src/tests/robustness.rs | 26 ++-- crates/kernel/src/coplanar.rs | 108 +++++++++++++++- crates/kernel/src/issue_679_tests.rs | 110 ++++++++++++++++ crates/kernel/src/lib.rs | 2 + crates/kernel/src/ops.rs | 8 ++ crates/kernel/src/polybool.rs | 94 ++++++++++++-- crates/kernel/src/splitface.rs | 118 ++++++++++++++++-- 8 files changed, 465 insertions(+), 34 deletions(-) create mode 100644 crates/kernel/src/issue_679_tests.rs diff --git a/crates/engine/src/boolean_regression_tests.rs b/crates/engine/src/boolean_regression_tests.rs index 2667b295..f6cb451f 100644 --- a/crates/engine/src/boolean_regression_tests.rs +++ b/crates/engine/src/boolean_regression_tests.rs @@ -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}"); +} diff --git a/crates/engine/src/tests/robustness.rs b/crates/engine/src/tests/robustness.rs index 1ae3cc0b..55637a41 100644 --- a/crates/engine/src/tests/robustness.rs +++ b/crates/engine/src/tests/robustness.rs @@ -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); diff --git a/crates/kernel/src/coplanar.rs b/crates/kernel/src/coplanar.rs index 02a76e45..c66cd7c7 100644 --- a/crates/kernel/src/coplanar.rs +++ b/crates/kernel/src/coplanar.rs @@ -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 { + 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, n: Vec3, d: f64, tool: &Body, tol: f64) -> Result<(Body, Option)> { + let contact = faces_in(tool, -n, -d, tol); + let bounds = crate::history::edge_faces(&tool.solid); + let mut beside: Vec = 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 = 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 { + 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> { + 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 { + let fs: Vec<&mt::Face> = body.solid.face_iter().collect(); + let mut out: Vec = + 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> { 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> { } 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 = 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 = 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])) diff --git a/crates/kernel/src/issue_679_tests.rs b/crates/kernel/src/issue_679_tests.rs new file mode 100644 index 00000000..3306c2ae --- /dev/null +++ b/crates/kernel/src/issue_679_tests.rs @@ -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::>()); + 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); +} diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 1e0bb115..acbefa76 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -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; diff --git a/crates/kernel/src/ops.rs b/crates/kernel/src/ops.rs index 0725e97f..08e66b7e 100644 --- a/crates/kernel/src/ops.rs +++ b/crates/kernel/src/ops.rs @@ -549,6 +549,14 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result> { 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> = a.split_keep().iter().chain(b.split_keep()).cloned().collect(); diff --git a/crates/kernel/src/polybool.rs b/crates/kernel/src/polybool.rs index 32996d35..b6ae89f2 100644 --- a/crates/kernel/src/polybool.rs +++ b/crates/kernel/src/polybool.rs @@ -857,13 +857,11 @@ fn rebuild(polys: Vec, size: f64, sources: &Sources) -> Result { joined.get(roots.get(li)?).cloned() }; guard("planar boolean rebuild", || { - let tv: Vec = verts.iter().map(|v| builder::vertex(p3(*v))).collect(); - let mut edges: HashMap<(usize, usize), mt::Edge> = HashMap::new(); - let mut faces: Vec = Vec::new(); - let mut face_from: HashMap>> = 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::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 = Vec::new(); let mut loops_g: Vec> = Vec::new(); while let Some((s, t)) = segs.pop() { let mut lp = vec![s, t]; @@ -895,7 +893,19 @@ fn rebuild(polys: Vec, size: f64, sources: &Sources) -> Result { } } } - 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 = (0..verts.len()).collect(); + split_pinched_vertices(&mut all_loops, &mut verts, &mut orig); + let tv: Vec = verts.iter().map(|v| builder::vertex(p3(*v))).collect(); + let mut edges: HashMap<(usize, usize), mt::Edge> = HashMap::new(); + let mut faces: Vec = Vec::new(); + let mut face_from: HashMap>> = HashMap::new(); + for (g, plane) in groups.iter().enumerate() { + let Some(loops_g) = all_loops.get(g) else { continue }; + let mut wires: Vec = 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, size: f64, sources: &Sources) -> Result { 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::>()); // 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, size: f64, sources: &Sources) -> Result { .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, size: f64, sources: &Sources) -> Result { }) } +/// 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>], verts: &mut Vec, orig: &mut Vec) { + // Every corner: (group, loop, position), by vertex. + let mut corners: HashMap> = 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 = 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 = (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 = 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 { diff --git a/crates/kernel/src/splitface.rs b/crates/kernel/src/splitface.rs index 5c770776..b948aba8 100644 --- a/crates/kernel/src/splitface.rs +++ b/crates/kernel/src/splitface.rs @@ -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 = 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, 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 { + 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 = body + .solid + .boundaries() + .iter() + .map(|sh| { + sh.face_iter() + .map(|f| { + let nf = recut(f, &cuts); + rec.whole(&nf, f); + nf + }) + .collect::>() + .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::Face { let mut f = mt::Face::new_unchecked(wires, like.surface()); @@ -532,12 +628,16 @@ fn split_one(faces: &mut Vec, 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)?;