//! Exact-plane booleans for bodies whose faces are all planar (BSP-tree CSG on polygons, in //! the classic style), used when the B-rep boolean fails — typically on coincident faces, such //! as a wall flush with the side of its base. The polygons are rebuilt into a B-rep: coplanar //! pieces are merged into faces, T-junctions are split so neighbouring faces share edges. use std::collections::{BTreeMap, BTreeSet, HashMap}; use solidcraft_geom::{Mesh, Vec3}; use truck_modeling::{self as mt, builder}; use crate::body::{Body, Solid, edge_order, from_p3, p3}; use crate::boolean_record::{self as recorded, Traced}; use crate::heal::find; use crate::history::{EdgeOrigin, FaceOrigin, History}; use crate::{BoolOp, KernelError, Result, guard, record}; const MAX_DEPTH: usize = 4000; const MAX_POLYS: usize = 200_000; #[derive(Clone, Copy, Debug)] struct PlaneEq { n: Vec3, w: f64, } impl PlaneEq { fn flipped(self) -> PlaneEq { PlaneEq { n: -self.n, w: -self.w } } } #[derive(Clone, Debug)] struct Poly { v: Vec, plane: PlaneEq, /// The operand face this is (a piece of). src: FaceOrigin, } impl Poly { fn flip(&mut self) { self.v.reverse(); self.plane = self.plane.flipped(); } } const COPLANAR: u8 = 0; const FRONT: u8 = 1; const BACK: u8 = 2; const SPANNING: u8 = 3; /// Split `p` by `pl` into the four buckets. fn split(pl: PlaneEq, p: &Poly, eps: f64, cf: &mut Vec, cb: &mut Vec, f: &mut Vec, b: &mut Vec) { let mut ptype = 0u8; let types: Vec = p.v.iter() .map(|v| { let t = pl.n.dot(*v) - pl.w; let ty = if t < -eps { BACK } else if t > eps { FRONT } else { COPLANAR }; ptype |= ty; ty }) .collect(); match ptype { COPLANAR => { if pl.n.dot(p.plane.n) > 0.0 { cf.push(p.clone()); } else { cb.push(p.clone()); } } FRONT => f.push(p.clone()), BACK => b.push(p.clone()), _ => { let (mut fv, mut bv) = (Vec::new(), Vec::new()); let n = p.v.len(); for i in 0..n { let j = (i + 1) % n; let (Some(&ti), Some(&tj), Some(&vi), Some(&vj)) = (types.get(i), types.get(j), p.v.get(i), p.v.get(j)) else { continue }; if ti != BACK { fv.push(vi); } if ti != FRONT { bv.push(vi); } if (ti | tj) == SPANNING { let den = pl.n.dot(vj - vi); if den.abs() > 1e-300 { let t = (pl.w - pl.n.dot(vi)) / den; let x = vi.lerp(vj, t); fv.push(x); bv.push(x); } } } if fv.len() >= 3 { f.push(Poly { v: fv, plane: p.plane, src: p.src }); } if bv.len() >= 3 { b.push(Poly { v: bv, plane: p.plane, src: p.src }); } } } } #[derive(Default)] struct Node { plane: Option, front: Option>, back: Option>, polys: Vec, } impl Node { fn build(&mut self, polys: Vec, eps: f64, depth: usize) -> Result<()> { if polys.is_empty() { return Ok(()); } if depth > MAX_DEPTH { return Err(KernelError::Failed("planar boolean: tree too deep".into())); } let pl = match self.plane { Some(p) => p, None => { let p = polys.first().map(|p| p.plane).ok_or_else(|| KernelError::Failed("bsp".into()))?; self.plane = Some(p); p } }; let (mut f, mut b) = (Vec::new(), Vec::new()); let mut cf = Vec::new(); let mut cb = Vec::new(); for p in &polys { split(pl, p, eps, &mut cf, &mut cb, &mut f, &mut b); } self.polys.extend(cf); self.polys.extend(cb); if !f.is_empty() { self.front.get_or_insert_with(Default::default).build(f, eps, depth + 1)?; } if !b.is_empty() { self.back.get_or_insert_with(Default::default).build(b, eps, depth + 1)?; } Ok(()) } fn invert(&mut self) { for p in &mut self.polys { p.flip(); } self.plane = self.plane.map(PlaneEq::flipped); if let Some(f) = &mut self.front { f.invert(); } if let Some(b) = &mut self.back { b.invert(); } std::mem::swap(&mut self.front, &mut self.back); } fn clip_polys(&self, polys: Vec, eps: f64) -> Vec { let Some(pl) = self.plane else { return polys }; let (mut f, mut b) = (Vec::new(), Vec::new()); let (mut cf, mut cb) = (Vec::new(), Vec::new()); for p in &polys { split(pl, p, eps, &mut cf, &mut cb, &mut f, &mut b); } f.extend(cf); b.extend(cb); let f = match &self.front { Some(n) => n.clip_polys(f, eps), None => f, }; let b = match &self.back { Some(n) => n.clip_polys(b, eps), None => Vec::new(), }; let mut out = f; out.extend(b); out } fn clip_to(&mut self, other: &Node, eps: f64) { let polys = std::mem::take(&mut self.polys); self.polys = other.clip_polys(polys, eps); if let Some(f) = &mut self.front { f.clip_to(other, eps); } if let Some(b) = &mut self.back { b.clip_to(other, eps); } } fn all(&self, out: &mut Vec) { out.extend(self.polys.iter().cloned()); if let Some(f) = &self.front { f.all(out); } if let Some(b) = &self.back { b.all(out); } } } /// Why a body's polygons can't be had: in words that finish "the {role} has …" or "the /// {role}'s …". #[derive(Debug, PartialEq)] enum NotPlanar { /// Faces on curved surfaces, counted by kind. Curved(std::collections::BTreeMap<&'static str, usize>), /// Something else (a face that could not be meshed, too many polygons). Other(String), } impl NotPlanar { fn describe(&self, role: &str) -> String { match self { NotPlanar::Curved(kinds) => { let n: usize = kinds.values().sum(); let list: Vec<&str> = kinds.keys().copied().collect(); format!("the {role} has {n} curved face{} ({})", if n == 1 { "" } else { "s" }, list.join(", ")) } NotPlanar::Other(m) => format!("the {role}'s {m}"), } } } /// The kind of curved surface `face` lies on, or None when it lies on a plane. `flat`: whether /// its mesh is flat. The meshes here are as coarse as a thousandth of the body, which spans a /// round much smaller than that (a 2 mm corner of a 1.2 m channel) with one flat strip of /// facets: the surface decides, and the mesh only for a surface none of these kinds fits. fn curved_kind(face: &mt::Face, flat: bool, tol: f64) -> Option<&'static str> { use crate::offset::{Surf, surf_of}; if matches!(face.surface(), mt::Surface::Plane(_)) { return None; } match surf_of(face, tol) { Some(Surf::Plane { .. }) => None, Some(Surf::Cylinder { .. }) => Some("cylinder"), Some(Surf::Cone { .. }) => Some("cone"), Some(Surf::Sphere { .. }) => Some("sphere"), Some(Surf::Torus { .. }) => Some("torus"), None => (!flat).then_some("freeform"), } } /// The tolerance [`curved_kind`] tells surfaces apart by: face_surface's, with the body's /// bounds computed once. Walking to each indexed face separately made curved-body diagnostics /// quadratic. fn kind_tol(b: &Body) -> f64 { (b.size() * 1e-7).max(1e-9) * 100.0 } /// The plane of each face (from its mesh, which must be flat), or what stands in the way: the /// curved faces by the kind of surface they lie on. fn face_planes(b: &Body, faces: &[crate::FaceInfo]) -> std::result::Result, NotPlanar> { let surface_tol = kind_tol(b); let mut surfaces = b.solid.face_iter(); let mut curved: std::collections::BTreeMap<&'static str, usize> = std::collections::BTreeMap::new(); let mut out = Vec::with_capacity(faces.len()); for (index, f) in faces.iter().enumerate() { let Some(surface) = surfaces.next() else { return Err(NotPlanar::Other("mesh is inconsistent".into())); }; if f.index != index { return Err(NotPlanar::Other("mesh is inconsistent".into())); } if f.area <= 0.0 { return Err(NotPlanar::Other(format!("face {} could not be meshed", f.index))); } match (f.plane_normal, curved_kind(surface, f.plane_normal.is_some(), surface_tol)) { (Some(n), None) => out.push(PlaneEq { n, w: n.dot(f.centroid) }), (None, None) => return Err(NotPlanar::Other(format!("face {} is planar but its mesh is not flat", f.index))), (_, Some(kind)) => *curved.entry(kind).or_insert(0) += 1, } } if surfaces.next().is_some() { return Err(NotPlanar::Other("mesh is inconsistent".into())); } if curved.is_empty() { Ok(out) } else { Err(NotPlanar::Curved(curved)) } } /// Polygons of a body whose faces are all planar, as pieces of its faces as operand `operand`. fn planar_polys(b: &Body, operand: usize) -> std::result::Result, NotPlanar> { let tol = (b.size() * 1e-3).max(1e-3); let unmeshed = |e: KernelError| NotPlanar::Other(format!("faces could not be meshed ({e})")); let faces = b.faces(tol).map_err(unmeshed)?; let mesh = b.tessellate(tol).map_err(unmeshed)?; let planes = face_planes(b, &faces)?; if mesh.triangles.len() > MAX_POLYS { return Err(NotPlanar::Other(format!("mesh is too detailed ({} triangles)", mesh.triangles.len()))); } let bad_mesh = || NotPlanar::Other("mesh is inconsistent".into()); let mut out = Vec::new(); for (t, fi) in mesh.triangles.iter().zip(&mesh.tri_face) { let [a, bb, c] = mesh.tri(t).ok_or_else(bad_mesh)?; let plane = *planes.get(*fi as usize).ok_or_else(bad_mesh)?; // Snap the vertices onto the exact face plane. let snap = |p: Vec3| p - plane.n * (plane.n.dot(p) - plane.w); let mut v = vec![snap(a), snap(bb), snap(c)]; if (v[1] - v[0]).cross(v[2] - v[0]).dot(plane.n) < 0.0 { v.reverse(); } out.push(Poly { v, plane, src: FaceOrigin::Face { operand, face: *fi as usize } }); } Ok(out) } /// Polygons of any body: planar faces exactly, curved ones as their triangles (within `tol`); /// pieces of its faces as operand `operand`. fn faceted_polys(b: &Body, tol: f64, operand: usize) -> Option> { let faces = b.faces(tol).ok()?; let mesh = b.tessellate(tol).ok()?; let planes: Vec> = faces.iter().map(|f| f.plane_normal.map(|n| PlaneEq { n, w: n.dot(f.centroid) })).collect(); let mut out = Vec::new(); for (t, fi) in mesh.triangles.iter().zip(&mesh.tri_face) { let [a, bb, c] = mesh.tri(t)?; let plane = match planes.get(*fi as usize)? { Some(pl) => *pl, None => { let Some(n) = (bb - a).cross(c - a).normalized() else { continue }; PlaneEq { n, w: n.dot(a) } } }; let snap = |p: Vec3| p - plane.n * (plane.n.dot(p) - plane.w); let mut v = vec![snap(a), snap(bb), snap(c)]; if (v[1] - v[0]).cross(v[2] - v[0]).dot(plane.n) < 0.0 { v.reverse(); } out.push(Poly { v, plane, src: FaceOrigin::Face { operand, face: *fi as usize } }); } (out.len() <= MAX_POLYS).then_some(out) } /// Boolean of a body, its curved faces faceted to `tol`, with a closed triangle mesh (wound /// outward); the result has planar faces only. For tools the B-rep boolean cannot take, such as /// the helical groove of a thread. /// /// The result's history is over `[a, b]`, the mesh's faces being its triangles (and it having /// no edges): a face of the result lies on faces of `a` (a facet of a curved one) or on /// triangles of `b`; an edge between facets of one face of `a`, or between triangles of `b`, /// has no origin. pub fn faceted_boolean(a: &Body, b: &Mesh, op: BoolOp, tol: f64) -> Result> { crate::profile::timed(crate::profile::Kind::Boolean, || faceted_boolean_untimed(a, b, op, tol)) } fn faceted_boolean_untimed(a: &Body, b: &Mesh, op: BoolOp, tol: f64) -> Result> { a.require_brep("a boolean")?; if !(tol.is_finite() && tol > 0.0) { return Err(KernelError::Invalid("facet tolerance".into())); } let Some(pa) = faceted_polys(a, tol, 0) else { return Err(KernelError::Failed("the body is too detailed to facet".into())); }; let mut pb = Vec::with_capacity(b.triangles.len()); for (face, t) in b.triangles.iter().enumerate() { let Some([p, q, r]) = b.tri(t) else { continue }; let Some(n) = (q - p).cross(r - p).normalized() else { continue }; pb.push(Poly { v: vec![p, q, r], plane: PlaneEq { n, w: n.dot(p) }, src: FaceOrigin::Face { operand: 1, face } }); } let edges = OperandEdges::of(a); let sources = Sources { counts: vec![(a.face_count(), edges.ends.len()), (b.triangles.len(), 0)], edges: vec![Some(edges), None] }; if pa.len() + pb.len() > MAX_POLYS { return Err(KernelError::Failed("the bodies are too detailed to facet".into())); } let (lo, hi) = b .positions .iter() .fold((Vec3::new(f64::MAX, f64::MAX, f64::MAX), Vec3::new(f64::MIN, f64::MIN, f64::MIN)), |(l, h), p| (l.min(*p), h.max(*p))); let size = if b.positions.is_empty() { a.size() } else { a.size().max((hi - lo).len()) }; // Fine facets of curved faces meet at shallow angles, where the coplanar test decides // whether the pieces' boundaries come out consistent: try a few tolerances, and take the // first whose result is watertight and agrees with the complementary boolean (A − B and // A ∩ B add up to A; A ∪ B is A plus B less A ∩ B). // The BSP takes each list's first polygon as its splitting plane. Facets in sweep order // (a thread's groove, turn after turn) make every splitter cut nothing, so the tree is as // deep as the list is long: quadratic time and memory, and a long thread ran out of depth // (#1250). The same lists in a fixed shuffled order give a shallow tree. let (pa, pb) = (shuffled(pa), shuffled(pb)); let (va, vb) = (polys_volume(&pa), polys_volume(&pb)); let mut last = KernelError::Failed("faceted boolean".into()); for rel in [1e-8, 1e-7, 3e-8, 1e-9, 3e-7] { let eps = size * rel; let out = csg(pa.clone(), pb.clone(), op, eps)?; let both = csg(pa.clone(), pb.clone(), BoolOp::Intersect, eps)?; let (vo, vi) = (polys_volume(&out), polys_volume(&both)); let expect = match op { BoolOp::Cut => va - vi, BoolOp::Union => va + vb - vi, BoolOp::Intersect => vi, }; if (vo - expect).abs() > 1e-6 * (va.abs() + vb.abs()) || vi < -1e-9 * (va.abs() + vb.abs()) { last = KernelError::Failed(format!("faceted boolean: inconsistent volumes ({vo} vs {expect})")); continue; } if out.is_empty() { return Ok(None); } match rebuild(out, size, &sources) { Ok(b) => return Ok(Some(b)), Err(e) => last = e, } } Err(last) } /// `polys` in a fixed pseudo-random order (the same every run, so results are reproducible). fn shuffled(mut polys: Vec) -> Vec { let mut state = 0x9E37_79B9_7F4A_7C15u64 ^ polys.len() as u64; let mut next = || { // SplitMix64. state = state.wrapping_add(0x9E37_79B9_7F4A_7C15); let mut z = state; z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); z ^ (z >> 31) }; for i in (1..polys.len()).rev() { polys.swap(i, (next() % (i as u64 + 1)) as usize); } polys } /// Boolean of two all-planar bodies. pub fn planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Result> { a.require_brep("a boolean")?; b.require_brep("a boolean")?; let (pa, pb) = match (planar_polys(a, 0), planar_polys(b, 1)) { (Ok(pa), Ok(pb)) => (pa, pb), (ra, rb) => { if let Some(r) = sliced_planar_boolean(a, b, op) { return r; } let why: Vec = [(ra.err(), "target"), (rb.err(), "tool")].into_iter().filter_map(|(e, role)| e.map(|e| e.describe(role))).collect(); return Err(KernelError::Failed(format!( "not supported yet: the exact fallback for where the bodies meet (faces that touch, or cross at a shallow angle) takes planar faces only, and {}", why.join(" and ") ))); } }; let size = a.size().max(b.size()); polys_boolean(pa, pb, size, op, size * 1e-9, &Sources::of(&[a, b])) } /// [`planar_boolean`] of a body with curved faces and an all-planar one, when a slab between /// two parallel planes holds `b` and none of `a`'s curved faces (a pocket cut beside a round /// hole): `a` is split across the slab, the middle piece takes the exact boolean, and the /// pieces outside are joined back on. `None` when no such slab exists. pub(crate) fn sliced_planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Option>> { let pb = planar_polys(b, 1).ok()?; let size = a.size().max(b.size()); let tol = (a.size() * 1e-3).max(1e-3); let infos = a.faces(tol).ok()?; let mesh = a.tessellate(tol).ok()?; // Each face's plane normal where it is planar (see [`curved_kind`]): a small round meshed // flat is no plane, and taken for one the middle piece would rebuild it as a chamfer. let surface_tol = kind_tol(a); let planes: Vec> = a.solid.face_iter().zip(&infos).map(|(f, info)| info.plane_normal.filter(|_| curved_kind(f, true, surface_tol).is_none())).collect(); let mut curved: BTreeMap = BTreeMap::new(); for (t, fi) in mesh.triangles.iter().zip(&mesh.tri_face) { if planes.get(*fi as usize)?.is_some() { continue; } let bx = curved.entry(*fi).or_insert(solidcraft_geom::Aabb3::EMPTY); for p in mesh.tri(t)? { bx.add(p); } } // All planar: the exact boolean itself, not this. if curved.is_empty() { return None; } let mut tool = solidcraft_geom::Aabb3::EMPTY; for p in pb.iter().flat_map(|p| &p.v) { tool.add(*p); } let at = |v: Vec3, k: usize| [v.x, v.y, v.z].get(k).copied().unwrap_or(0.0); let axes = [Vec3::X, Vec3::Y, Vec3::Z]; // Planar faces of `a` square to an axis: a cut must not land in one of their planes. let flat: Vec<(Vec3, Vec3)> = planes.iter().zip(&infos).filter_map(|(n, f)| Some(((*n)?, f.centroid))).collect(); let gap = size * 1e-4; let place = |k: usize, near: f64, far: f64| -> Option { let axis = *axes.get(k)?; [0.5, 0.37, 0.63, 0.25, 0.75] .iter() .map(|t| near + (far - near) * t) .find(|x| !flat.iter().any(|(n, c)| n.dot(axis).abs() > 1.0 - 1e-9 && (at(*c, k) - x).abs() < gap)) }; // An axis along which every curved face lies clear of the tool: the cuts either side. let (k, lo, hi) = (0..3).find_map(|k| { let (t0, t1) = (at(tool.min, k), at(tool.max, k)); let (mut below, mut above) = (None::, None::); for bx in curved.values() { let (c0, c1) = (at(bx.min, k), at(bx.max, k)); if c1 < t0 - gap { below = Some(below.map_or(c1, |x| x.max(c1))); } else if c0 > t1 + gap { above = Some(above.map_or(c0, |x| x.min(c0))); } else { return None; } } let lo = match below { Some(c) => Some(place(k, t0, c)?), None => None, }; let hi = match above { Some(c) => Some(place(k, t1, c)?), None => None, }; Some((k, lo, hi)) })?; let (u, w) = match k { 0 => (Vec3::Y, Vec3::Z), 1 => (Vec3::Z, Vec3::X), _ => (Vec3::X, Vec3::Y), }; let axis = *axes.get(k)?; let run = || -> Result> { let fail = |m: &str| KernelError::Failed(format!("planar boolean beside curved faces: {m}")); // Every piece with its history over [a, b, then the boxes of each cut: the one away // from the middle, the one around it]; the boxes' faces are gone from the result (glued // away), so they are dropped at the end. let n = 6; let mut middle: Traced = (a.clone(), recorded::kept(a, 0, n)); let mut outside: Vec = Vec::new(); for (slot, (cut, keep_positive)) in [(2, (lo, true)), (4, (hi, false))] { let Some(x) = cut else { continue }; let plane = solidcraft_geom::Plane::new(axis * x, u, w).ok_or_else(|| fail("plane"))?; // Split along the plane (an imprint: no boolean); two booleans with half boxes // where the split can't be sorted into one piece kept and the rest away. if let Some((away, kept)) = split_beside(&middle, &plane, axis, x, keep_positive) { outside.extend(away); middle = kept; continue; } let (away, kept) = (crate::ops::half_box(&middle.0, &plane, !keep_positive)?, crate::ops::half_box(&middle.0, &plane, keep_positive)?); let (h_away, h_kept) = (recorded::kept(&away, slot, n), recorded::kept(&kept, slot + 1, n)); if let Some(o) = crate::ops::boolean(&middle.0, &away, BoolOp::Intersect)? { let h = recorded::of_pair(&o, &middle.1, &h_away); outside.push((o, h)); } let m = crate::ops::boolean(&middle.0, &kept, BoolOp::Intersect)?.ok_or_else(|| fail("nothing beside the tool"))?; let h = recorded::of_pair(&m, &middle.1, &h_kept); middle = (m, h); } let pm = planar_polys(&middle.0, 0).map_err(|e| fail(&e.describe("middle piece")))?; let r = polys_boolean(pm, pb, size, op, size * 1e-9, &Sources::of(&[&middle.0, b]))?; reassembled(a, b, op, &middle, r, outside, n, &fail) }; Some(run()) } /// The middle piece's boolean with `b` (`r`, over `[middle, b]`) and the pieces of `a` split /// away from the middle (`outside`) glued back into one result over `[a, b]`: the operands past /// the second (`n` in all, the boxes a piece was cut with) are dropped. `fail` names the route /// in its errors. Shared with [`crate::prism_local`]. #[allow(clippy::too_many_arguments)] pub(crate) fn reassembled( a: &Body, b: &Body, op: BoolOp, middle: &Traced, r: Option, outside: Vec, n: usize, fail: &dyn Fn(&str) -> KernelError, ) -> Result> { let size = a.size().max(b.size()); let h_b = recorded::kept(b, 1, n); let counts = record::counts(&[a, b]); if op == BoolOp::Intersect { return Ok(r.map(|r| { let h = dropped(recorded::of_pair(&r, &middle.1, &h_b)); r.with_checked_history(h, &counts) })); } // The rebuild leaves the cut faces' edges split where other polygons were cut; healed, // they match the pieces outside edge for edge. let pieces: Vec = match r { Some(m) => { let healed = recorded::heal_body(&m, m.deep_copy(), size, a.split_keep(), &record::counts(&[&middle.0, b]))?; let h = recorded::of_pair(&healed, &middle.1, &h_b); vec![(healed, h)] } None => Vec::new(), }; // Each piece in its lumps: a cut plane crossing a frame or a ring leaves the middle (or // a piece outside) in several parts, which meet the others along several cut faces. let lumps = |(whole, h): Traced| -> Result> { if whole.solid.boundaries().len() == 1 { return Ok(vec![(whole, h)]); } let parts = whole.lumps()?; let histories = recorded::parts_of(&parts, &whole); Ok(parts .into_iter() .zip(histories) .map(|(p, hp)| { let h = match (hp, h.as_ref()) { (Some(hp), Some(h)) => recorded::composed(hp.substituted(0, h)), _ => None, }; (p, h) }) .collect()) }; let mut all: Vec = Vec::new(); for piece in pieces.into_iter().chain(outside) { all.extend(lumps(piece)?); } // Glue pieces that meet along their cut faces until one is left; every cut face pair // goes (a pair left behind would be a slit through the result). let mut joined: Vec = Vec::new(); while let Some(mut cur) = all.pop() { loop { let found = all.iter().enumerate().find_map(|(i, (o, _))| Some((i, crate::coplanar::glue(&cur.0, o)?.ok()?))); let Some((i, j)) = found else { break }; let other = all.swap_remove(i); let h = recorded::of_pair(&j, &cur.1, &other.1); cur = (j, h); } joined.push(cur); } if joined.iter().any(|(x, _)| x.solid.boundaries().len() != 1) { return Err(fail("a piece in several parts")); } // A join onto one piece is one piece: anything apart is a cut face that did not glue. if op == BoolOp::Union && a.solid.boundaries().len() == 1 && joined.len() != 1 { return Err(fail("a piece does not join back")); } let pieces = joined; recorded::join(pieces.into_iter().map(|(p, h)| (p, dropped(h))).collect(), &counts) } /// `piece` split by `plane` (at `x` along `axis`): the parts on the side away from the tool /// and the one part on its side (positive along `axis` when `keep_positive`), each with its /// history over the operands `piece`'s is over. `None` when the split leaves no part on either /// side, or several on the tool's. pub(crate) fn split_beside(piece: &Traced, plane: &solidcraft_geom::Plane, axis: Vec3, x: f64, keep_positive: bool) -> Option<(Vec, Traced)> { let parts = crate::splitface::split_body(&piece.0, &crate::splitface::SplitTool::Plane(*plane)).ok()?; if parts.len() < 2 { return None; } let tol = piece.0.size() * 1e-6 + 1e-9; let (mut away, mut kept) = (Vec::new(), Vec::new()); for part in parts { let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY); for v in part.solid.vertex_iter() { let t = from_p3(v.point()).dot(axis) - x; lo = lo.min(t); hi = hi.max(t); } let h = match (part.history(), piece.1.as_ref()) { (Some(hp), Some(h)) => recorded::composed(hp.clone().substituted(0, h)), _ => None, }; let positive = if lo >= -tol { true } else if hi <= tol { false } else { return None; }; if positive == keep_positive { kept.push((part, h)) } else { away.push((part, h)) } } let kept = kept.pop().filter(|_| kept.is_empty())?; (!away.is_empty()).then_some((away, kept)) } /// `h` over `[a, b]`: the operands after them (the boxes a piece was cut with) dropped, which /// fails where something of them is left. fn dropped(h: Option) -> Option { let h = h?; let map: Vec> = (0..h.operands).map(|op| (op < 2).then_some(op)).collect(); recorded::composed(h.renumbered(&map, 2)) } fn polys_boolean(pa: Vec, pb: Vec, size: f64, op: BoolOp, eps: f64, sources: &Sources) -> Result> { let polys = csg(pa, pb, op, eps)?; if polys.is_empty() { return Ok(None); } rebuild(polys, size, sources).map(Some) } /// What a rebuilt body's history is over: each operand's face and edge counts and, where it is /// a B-rep, its edges in its edge order (the faces each bounds, and its ends). struct Sources { counts: Vec<(usize, usize)>, edges: Vec>, } /// An operand's edges in its edge order (each one's ends), and those between each pair of its /// faces (lesser face first). struct OperandEdges { ends: Vec<(Vec3, Vec3)>, between: BTreeMap<(usize, usize), Vec>, } impl OperandEdges { fn of(b: &Body) -> OperandEdges { let (edges, bounds) = edge_order(b.solid.face_iter()); let mut between: BTreeMap<(usize, usize), Vec> = BTreeMap::new(); for (i, fs) in bounds.iter().enumerate() { if let [f, g] = fs.as_slice() { between.entry((*f.min(g), *f.max(g))).or_default().push(i); } } let ends = edges.iter().map(|e| (from_p3(e.absolute_front().point()), from_p3(e.absolute_back().point()))).collect(); OperandEdges { ends, between } } } impl Sources { fn of(bodies: &[&Body]) -> Sources { let edges: Vec = bodies.iter().map(|b| OperandEdges::of(b)).collect(); let counts = bodies.iter().zip(&edges).map(|(b, e)| (b.face_count(), e.ends.len())).collect(); Sources { counts, edges: edges.into_iter().map(Some).collect() } } /// The origins of an edge between a piece of `p` and a piece of `q` running from `ends.0` /// to `ends.1`: where faces of two operands meet; the edge between two faces of one /// operand (the one the piece lies along, when they share several); none between pieces of /// one face, or between triangles of a mesh operand. `None` when it can't be told. fn edge(&self, p: FaceOrigin, q: FaceOrigin, ends: (Vec3, Vec3), tol: f64) -> Option> { let (FaceOrigin::Face { operand: x, face: f }, FaceOrigin::Face { operand: y, face: g }) = (p, q) else { return None }; if x != y { return Some(vec![EdgeOrigin::meet(p, q)]); } let Some(edges) = self.edges.get(x)?.as_ref() else { return Some(Vec::new()) }; if f == g { return Some(Vec::new()); } let between = edges.between.get(&(f.min(g), f.max(g)))?; let along: Vec = match between.as_slice() { [_] => between.clone(), _ => between .iter() .copied() .filter(|i| edges.ends.get(*i).is_some_and(|(s, t)| ends.0.dist_to_segment(*s, *t) <= tol && ends.1.dist_to_segment(*s, *t) <= tol)) .collect(), }; match along.as_slice() { [edge] => Some(vec![EdgeOrigin::Edge { operand: x, edge: *edge }]), _ => None, } } } /// Enclosed volume of a closed set of polygons (divergence theorem). fn polys_volume(ps: &[Poly]) -> f64 { let mut v6 = 0.0; for p in ps { let Some(&o) = p.v.first() else { continue }; for w in p.v.windows(2).skip(1) { v6 += o.dot(w[0].cross(w[1])); } } v6 / 6.0 } /// The polygons of a BSP boolean (csg.js style). fn csg(pa: Vec, pb: Vec, op: BoolOp, eps: f64) -> Result> { let (mut na, mut nb) = (Node::default(), Node::default()); na.build(pa, eps, 0)?; nb.build(pb, eps, 0)?; match op { BoolOp::Union => { na.clip_to(&nb, eps); nb.clip_to(&na, eps); nb.invert(); nb.clip_to(&na, eps); nb.invert(); let mut extra = Vec::new(); nb.all(&mut extra); na.build(extra, eps, 0)?; } BoolOp::Cut => { na.invert(); na.clip_to(&nb, eps); nb.clip_to(&na, eps); nb.invert(); nb.clip_to(&na, eps); nb.invert(); let mut extra = Vec::new(); nb.all(&mut extra); na.build(extra, eps, 0)?; na.invert(); } BoolOp::Intersect => { na.invert(); nb.clip_to(&na, eps); nb.invert(); na.clip_to(&nb, eps); nb.clip_to(&na, eps); let mut extra = Vec::new(); nb.all(&mut extra); na.build(extra, eps, 0)?; na.invert(); } } let mut polys = Vec::new(); na.all(&mut polys); Ok(polys) } /// `shell`'s connected components, each face in the shell's order and the components by their /// first face. truck's own come out in the hash order of face addresses, so the same rebuild /// gave a body with its faces in a different order from run to run (#707). pub(crate) fn components_in_order(shell: &mt::Shell) -> Vec { let pos: HashMap = shell.face_iter().enumerate().map(|(i, f)| (f.id(), i)).collect(); let key = |f: &mt::Face| pos.get(&f.id()).copied().unwrap_or(usize::MAX); let mut comps: Vec> = shell .connected_components() .into_iter() .map(|c| { let mut faces: Vec = c.into_iter().collect(); faces.sort_by_key(|f| key(f)); faces }) .collect(); comps.sort_by_key(|c| c.first().map(key).unwrap_or(usize::MAX)); comps.into_iter().map(mt::Shell::from).collect() } /// Position key for vertex merging. fn key(p: Vec3, q: f64) -> (i64, i64, i64) { ((p.x / q).round() as i64, (p.y / q).round() as i64, (p.z / q).round() as i64) } /// Turn polygons into a B-rep solid: merge by plane, split T-junctions, chain boundary loops. /// Its history over the operands the polygons are pieces of (`sources`): each face the /// operand faces whose pieces it joins (pieces joined across the edges between them), each edge /// what the pieces either side of it say (see [`Sources::edge`]). fn rebuild(polys: Vec, size: f64, sources: &Sources) -> Result { let q = size * 1e-7; // Global vertex table. let mut index: BTreeMap<(i64, i64, i64), usize> = BTreeMap::new(); let mut verts: Vec = Vec::new(); // Points within `q` share a vertex, also across the rounding grid's cell boundaries. let mut id = |p: Vec3, verts: &mut Vec| -> usize { let k = key(p, q); for dx in -1..=1 { for dy in -1..=1 { for dz in -1..=1 { if let Some(&i) = index.get(&(k.0 + dx, k.1 + dy, k.2 + dz)) && verts.get(i).is_some_and(|v| (*v - p).len() <= q) { return i; } } } } verts.push(p); index.insert(k, verts.len() - 1); verts.len() - 1 }; let mut loops: Vec<(PlaneEq, Vec)> = Vec::new(); let mut srcs: Vec = Vec::new(); for p in &polys { let mut l: Vec = p.v.iter().map(|v| id(*v, &mut verts)).collect(); l.dedup(); if l.first() == l.last() && l.len() > 1 { l.pop(); } if l.len() >= 3 { loops.push((p.plane, l)); srcs.push(p.src); } } // Split every polygon edge at vertices lying on it (T-junctions). Vertices are binned in a // grid sized to the typical edge, so each edge only looks at its neighbourhood. let tol = size * 1e-7; let edge_len: Vec = loops .iter() .flat_map(|(_, l)| (0..l.len()).filter_map(move |i| Some((*l.get(i)?, *l.get((i + 1) % l.len())?)))) .filter_map(|(a, b)| Some((*verts.get(b)? - *verts.get(a)?).len())) .collect(); let mean = edge_len.iter().sum::() / edge_len.len().max(1) as f64; let cell = mean.max(size * 1e-4).max(1e-9); let ckey = |p: Vec3| ((p.x / cell).floor() as i64, (p.y / cell).floor() as i64, (p.z / cell).floor() as i64); let mut grid: BTreeMap<(i64, i64, i64), Vec> = BTreeMap::new(); for (k, v) in verts.iter().enumerate() { grid.entry(ckey(*v)).or_default().push(k); } let mut directed: Vec<(usize, usize, usize)> = Vec::new(); // (plane group, from, to) // The polygons (by loop) each directed edge comes from. let mut owners: BTreeMap<(usize, usize, usize), Vec> = BTreeMap::new(); let mut groups: Vec = Vec::new(); let mut group_of: BTreeMap<(i64, i64, i64, i64), usize> = BTreeMap::new(); for (li, (pl, l)) in loops.iter().enumerate() { let gk = ((pl.n.x * 1e8).round() as i64, (pl.n.y * 1e8).round() as i64, (pl.n.z * 1e8).round() as i64, (pl.w / (tol * 10.0)).round() as i64); let g = *group_of.entry(gk).or_insert_with(|| { groups.push(*pl); groups.len() - 1 }); let n = l.len(); for i in 0..n { let (Some(&a), Some(&b)) = (l.get(i), l.get((i + 1) % n)) else { continue }; let (Some(&pa), Some(&pb)) = (verts.get(a), verts.get(b)) else { continue }; let d = pb - pa; let len2 = d.len2(); let (lo, hi) = (ckey(pa.min(pb) - Vec3::new(tol, tol, tol)), ckey(pa.max(pb) + Vec3::new(tol, tol, tol))); let cells = (hi.0 - lo.0 + 1) * (hi.1 - lo.1 + 1) * (hi.2 - lo.2 + 1); let candidates: Vec = if cells > 4096 { (0..verts.len()).collect() } else { let mut c = Vec::new(); for x in lo.0..=hi.0 { for y in lo.1..=hi.1 { for z in lo.2..=hi.2 { if let Some(ks) = grid.get(&(x, y, z)) { c.extend(ks.iter().copied()); } } } } c }; let mut on: Vec<(f64, usize)> = candidates .into_iter() .filter(|k| *k != a && *k != b) .filter_map(|k| { let v = verts.get(k)?; let t = (*v - pa).dot(d) / len2.max(1e-300); (t > 1e-9 && t < 1.0 - 1e-9 && v.dist_to_segment(pa, pb) < tol).then_some((t, k)) }) .collect(); on.sort_by(|x, y| x.0.total_cmp(&y.0)); let mut prev = a; for (_, k) in on { directed.push((g, prev, k)); owners.entry((g, prev, k)).or_default().push(li); prev = k; } directed.push((g, prev, b)); owners.entry((g, prev, b)).or_default().push(li); } } // Interior edges cancel against their reverse within the same plane group. let mut count: BTreeMap<(usize, usize, usize), i64> = BTreeMap::new(); for (g, a, b) in &directed { *count.entry((*g, *a, *b)).or_insert(0) += 1; } let mut boundary: Vec<(usize, usize, usize)> = Vec::new(); for (&(g, a, b), &c) in &count { let rev = count.get(&(g, b, a)).copied().unwrap_or(0); for _ in 0..(c - rev).max(0) { boundary.push((g, a, b)); } } boundary.sort_unstable(); // Pieces either side of an edge inside a plane group lie in one face. let mut parent: Vec = (0..loops.len()).collect(); for ((g, a, b), here) in &owners { let (Some(there), Some(&first)) = (owners.get(&(*g, *b, *a)), here.first()) else { continue }; for other in here.iter().chain(there) { let (rp, rq) = (find(&mut parent, first), find(&mut parent, *other)); if let Some(slot) = parent.get_mut(rp) { *slot = rq; } } } let roots: Vec = (0..loops.len()).map(|li| find(&mut parent, li)).collect(); let mut joined: BTreeMap> = BTreeMap::new(); for (root, src) in roots.iter().zip(&srcs) { joined.entry(*root).or_default().insert(*src); } // What lies on each side of each boundary edge (from, to): the faces of its polygons. let mut side: BTreeMap<(usize, usize), BTreeSet> = BTreeMap::new(); for (g, a, b) in &boundary { let set = side.entry((*a, *b)).or_default(); for li in owners.get(&(*g, *a, *b)).into_iter().flatten() { set.extend(srcs.get(*li)); } } // The faces of one boundary loop's polygons. let loop_faces = |g: usize, lp: &[usize]| -> Option> { let (a, b) = (*lp.first()?, *lp.get(1)?); let li = *owners.get(&(g, a, b))?.first()?; joined.get(roots.get(li)?).cloned() }; guard("planar boolean rebuild", || { 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 loops_g: Vec> = Vec::new(); while let Some((s, t)) = segs.pop() { let mut lp = vec![s, t]; let mut cur = t; let mut guard_n = 0; while cur != s && guard_n < 100_000 { guard_n += 1; let Some(k) = segs.iter().position(|(a, _)| *a == cur) else { break }; let (_, nb) = segs.remove(k); lp.push(nb); cur = nb; } if cur == s { lp.pop(); // A walk through a vertex twice (two loops touching there) is split into // simple loops at that vertex. let mut stack: Vec = Vec::new(); for v in lp { if let Some(at) = stack.iter().position(|x| *x == v) { let sub: Vec = stack.split_off(at); if sub.len() >= 3 { loops_g.push(sub); } } stack.push(v); } if stack.len() >= 3 { loops_g.push(stack); } } } 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: BTreeMap<(usize, usize), mt::Edge> = BTreeMap::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 { let (Some(&a), Some(&b)) = (lp.get(i), lp.get((i + 1) % n)) else { continue }; let k = (a.min(b), a.max(b)); let e = match edges.get(&k) { Some(e) => e.clone(), None => { let (Some(va), Some(vb)) = (tv.get(k.0), tv.get(k.1)) else { continue }; let e = builder::line(va, vb); edges.insert(k, e.clone()); e } }; es.push(if a < b { e } else { e.inverse() }); } wires.push(es.into()); } if wires.is_empty() { continue; } // Outer loops are counter-clockwise about the plane normal; holes go with the // outer loop that contains them. let area = |lp: &Vec| -> f64 { let n = lp.len(); let mut s = Vec3::ZERO; for i in 0..n { if let (Some(a), Some(b)) = (lp.get(i).and_then(|k| verts.get(*k)), lp.get((i + 1) % n).and_then(|k| verts.get(*k))) { s += a.cross(*b); } } s.dot(plane.n) * 0.5 }; let (outer, holes): (Vec, Vec) = (0..loops_g.len()).partition(|i| loops_g.get(*i).is_some_and(|l| area(l) > 0.0)); let u = plane.n.any_perp(); let w = plane.n.cross(u); let to2 = |p: Vec3| (p.dot(u), p.dot(w)); let inside = |pt: Vec3, lp: &Vec| { let (px, py) = to2(pt); let n = lp.len(); let mut c = false; for i in 0..n { let (Some(a), Some(b)) = (lp.get(i).and_then(|k| verts.get(*k)), lp.get((i + 1) % n).and_then(|k| verts.get(*k))) else { continue; }; let ((ax, ay), (bx, by)) = (to2(*a), to2(*b)); if (ay > py) != (by > py) && px < ax + (py - ay) / (by - ay) * (bx - ax) { c = !c; } } c }; for o in &outer { let Some(ol) = loops_g.get(*o) else { continue }; let mut ws = vec![wires.get(*o).cloned().ok_or_else(|| KernelError::Failed("wire".into()))?]; for h in &holes { let Some(hl) = loops_g.get(*h) else { continue }; let probe = hl.first().and_then(|k| verts.get(*k)).copied().unwrap_or_default(); // The innermost outer loop containing the hole. let owner = outer.iter().filter(|x| loops_g.get(**x).is_some_and(|l| inside(probe, l))).min_by(|x, y| { let ax = loops_g.get(**x).map(area).unwrap_or(0.0); let ay = loops_g.get(**y).map(area).unwrap_or(0.0); ax.total_cmp(&ay) }); if owner == Some(o) { ws.push(wires.get(*h).cloned().ok_or_else(|| KernelError::Failed("wire".into()))?); } } 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, Err(e) => { let o = plane.n * plane.w; let surface = mt::Plane::new(p3(o), p3(o + u), p3(o + w)); mt::Face::try_new(ws, mt::Surface::Plane(surface)) .map_err(|e2| KernelError::Failed(format!("planar boolean face: {e}; {e2}")))? } }; face_from.insert(face.id(), from); faces.push(face); } } let shell: mt::Shell = faces.into(); let parts = components_in_order(&shell); let solid = Solid::try_new(parts).map_err(|e| KernelError::Failed(format!("planar boolean: {e}")))?; let regions = solid.face_iter().map(|f| face_from.get(&f.id()).cloned().flatten().map(|o| (o, false))).collect::>>(); let tol = size * 1e-6; let edge_from = edges .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))? { origins.extend(sources.edge(*p, *q, ends, tol)?); } } Some((e.id(), origins.into_iter().collect())) }) .collect::>>>(); record::build(solid, &sources.counts, regions.map(record::regions), edge_from.as_ref()) }) } /// 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: BTreeMap> = BTreeMap::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: BTreeMap = BTreeMap::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 { use super::*; #[test] fn compound_curved_diagnostics_count_every_surface_kind() { // Many independent shells exercise the error path without performing a costly // Boolean. Each cylinder has two analytic half-cylinder walls and two flat caps. let mut shells = Vec::new(); for i in 0..64 { let cylinder = crate::cylinder(Vec3::X * (i as f64 * 6.0), Vec3::Z, 2.0, 5.0).unwrap(); shells.extend(cylinder.solid.boundaries().iter().cloned()); } let sphere = crate::sphere(Vec3::new(0.0, 12.0, 0.0), 2.0).unwrap(); let sphere_faces = sphere.face_count(); shells.extend(sphere.solid.boundaries().iter().cloned()); let body = guard("diagnostic compound", || Body::new(Solid::try_new(shells).unwrap())).unwrap(); let faces = body.faces(0.02).unwrap(); let error = face_planes(&body, &faces).err().unwrap(); let expected = std::collections::BTreeMap::from([("cylinder", 128), ("sphere", sphere_faces)]); assert_eq!(error, NotPlanar::Curved(expected)); assert_eq!(error.describe("tool"), format!("the tool has {} curved faces (cylinder, sphere)", 128 + sphere_faces)); } #[test] fn planar_mesh_inconsistency_does_not_become_curved_classification() { let body = crate::box_solid(Vec3::ZERO, Vec3::new(4.0, 5.0, 6.0)).unwrap(); let mut faces = body.faces(0.01).unwrap(); assert_eq!(face_planes(&body, &faces).unwrap().len(), 6); faces[0].plane_normal = None; assert_eq!(face_planes(&body, &faces).err().unwrap(), NotPlanar::Other("face 0 is planar but its mesh is not flat".into())); faces[0].area = 0.0; assert_eq!(face_planes(&body, &faces).err().unwrap(), NotPlanar::Other("face 0 could not be meshed".into())); faces[0].index = 999; assert_eq!(face_planes(&body, &faces).err().unwrap(), NotPlanar::Other("mesh is inconsistent".into())); } } #[cfg(test)] #[allow(clippy::unwrap_used)] mod small_round_tests { use std::f64::consts::FRAC_PI_2; use solidcraft_geom::{Loop2, Plane, Region2, Seg2, Vec2}; use super::*; /// An `l` x `w` x 1 plate on XY with its four corners rounded (radius `r`), and a hole of /// diameter 4 through it 100 from one end when `hole`. fn rounded_plate(l: f64, w: f64, r: f64, hole: bool) -> Body { let (x, y) = (l / 2.0, w / 2.0); let corners = [(x - r, y - r), (r - x, y - r), (r - x, r - y), (x - r, r - y)]; let mut segs = Vec::new(); for (k, (cx, cy)) in corners.iter().enumerate() { let start = k as f64 * FRAC_PI_2; let at = |t: f64| Vec2::new(cx + r * t.cos(), cy + r * t.sin()); segs.push(Seg2::Arc { center: Vec2::new(*cx, *cy), radius: r, start, sweep: FRAC_PI_2 }); let (nx, ny) = corners[(k + 1) % 4]; let next = start + FRAC_PI_2; segs.push(Seg2::Line { a: at(next), b: Vec2::new(nx + r * next.cos(), ny + r * next.sin()) }); } let holes = if hole { vec![Loop2::circle(Vec2::new(x - 100.0, 0.0), 2.0).reversed()] } else { Vec::new() }; crate::extrude_planar_regions(&Plane::XY, &[Region2 { outer: Loop2 { segs }, holes }], 0.0, 1.0).unwrap() } /// Faces of `b` on cylinders of radius `r`. fn cylinders(b: &Body, r: f64) -> usize { (0..b.face_count()) .filter(|&i| matches!(crate::face_surface(b, i), Some(crate::FaceSurface::Cylinder { radius, .. }) if (radius - r).abs() < 1e-9)) .count() } /// A pocket cut into the middle of a long plate by the exact fallback leaves the plate's /// small corner rounds round. Meshed at a thousandth of the 1200 mm plate (1.2 mm), each /// 2 mm quarter round (its chord 0.59 mm inside the arc) is one flat strip of facets, which /// was taken for a plane: the whole plate went to the polygon boolean (and, beside a round /// hole, the rounds went into the planar slab around the pocket), and the rounds came back /// flat chamfers, each taking r^2 (pi/4 - 1/2) = 1.14 mm^3 more than its round. #[test] fn a_long_plates_small_rounds_stay_round_beside_a_pocket() { for hole in [false, true] { let plate = rounded_plate(1200.0, 40.0, 2.0, hole); assert_eq!(cylinders(&plate, 2.0), 4 + 2 * usize::from(hole)); // 10 x 5, 0.1 deep from the top. let tool = crate::box_solid(Vec3::new(-5.0, -2.5, 0.9), Vec3::new(5.0, 2.5, 1.0)).unwrap(); let r = planar_boolean(&plate, &tool, BoolOp::Cut).unwrap().unwrap(); assert_eq!(cylinders(&r, 2.0), 4 + 2 * usize::from(hole), "hole {hole}"); // The pocket's 10 x 5 x 0.1 and nothing else (a flattened round takes 1.14 more). let removed = crate::measure(&plate).unwrap().volume - crate::measure(&r).unwrap().volume; assert!((removed - 5.0).abs() < 1e-3, "hole {hole}: removed {removed}"); } } }