//! Partitions: a design's bodies cut into cells by planes, for meshing cell by cell. //! //! A partition cuts bodies of the open design by a plane with SolidCraft's kernel //! (`split_by_plane`, the operation behind SolidCraft's split body), in memory: the design file is //! never changed. The partitions live in the FieldCraft model and are applied again, in order, //! whenever the design is opened, so an edit of the design followed by a re-open replays them. //! //! Names are FieldCraft's own, so they do not depend on the order of the design's timeline: //! - **Cells.** A body cut by a plane gives `/a` (the piece on the plane's negative side) //! and `/b`; several disjoint pieces on one side are `/a1`, `/a2`, … ordered //! by centroid. //! - **Cut faces** pair across the cut: on a cell on the negative side (its outward normal along //! the plane's normal) the face is `partition::a`, on the positive side //! `partition::b`. Several disjoint faces on one cell (a cut through a hole) are `…#0`, //! `…#1`, ordered along the plane, which a set naming `partition::a` selects together. //! - **Every other face** inherits the persistent name of the face it is a piece of (found on the //! same surface under a point of the piece). Where a cell has several pieces of one face they //! are `#0`, `#1`, …, which sets naming `` select. use solidcraft_geom::{Plane, Vec3}; use solidcraft_kernel::guard; use crate::{Design, Error, Face, FaceSurface, FeatureError, Result, Shape, SurfaceMesh}; /// Most partitions a design takes. pub const MAX_CUTS: usize = 256; /// Most cells the partitions make in all. pub const MAX_CELLS: usize = 4096; /// A cut face lies on the plane within this fraction of its body's size. const ON_PLANE: f64 = 1e-6; /// Tessellation tolerance (fraction of a body's size) for matching and ordering faces. const MATCH_TOLERANCE: f64 = 2e-3; /// One partition: the bodies it cuts by a plane. #[derive(Debug, Clone, PartialEq)] pub struct Cut { /// Names the cut faces (`partition::a|b`). pub name: String, /// A point on the plane and its normal (any length but zero). pub point: [f64; 3], pub normal: [f64; 3], /// The bodies to cut, by name, as they are when this partition comes (the cells of earlier /// partitions included); empty: every body the plane divides. pub bodies: Vec, } /// What a partition did. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CutReport { pub name: String, /// The bodies it cut, each into two or more cells. pub split: Vec, /// How many cut faces it named. pub faces: usize, } fn vec3(p: [f64; 3]) -> Vec3 { Vec3 { x: p[0], y: p[1], z: p[2] } } fn dot(a: [f64; 3], b: [f64; 3]) -> f64 { a[0] * b[0] + a[1] * b[1] + a[2] * b[2] } fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] { [a[0] - b[0], a[1] - b[1], a[2] - b[2]] } fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] { [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]] } fn lexical(a: &[f64; 3], b: &[f64; 3]) -> std::cmp::Ordering { a.iter().zip(b).map(|(x, y)| x.total_cmp(y)).find(|o| o.is_ne()).unwrap_or(std::cmp::Ordering::Equal) } /// The plane of a cut, with its unit normal. fn plane_of(cut: &Cut) -> Result<(Plane, [f64; 3])> { let finite = cut.point.iter().chain(&cut.normal).all(|v| v.is_finite() && v.abs() <= 1e9); let len = dot(cut.normal, cut.normal).sqrt(); if !finite || !(len > 1e-12) { return Err(Error::Invalid(format!("partition `{}`: the plane needs a finite point and a non-zero normal", cut.name))); } let n = cut.normal.map(|v| v / len); let plane = Plane::from_normal(vec3(cut.point), vec3(n)) .ok_or_else(|| Error::Invalid(format!("partition `{}`: the plane needs a non-zero normal", cut.name)))?; Ok((plane, n)) } impl Design { /// The design as opened, cut by `cuts` in order (the partitions replace any applied before). /// A cut that names a body the design does not have, or that does not divide a body it /// names, is listed in [`Design::errors`] (feature `partition `), as a feature of the /// design that failed to rebuild; the rest still apply. pub fn partitioned(&self, cuts: &[Cut]) -> Result { if cuts.len() > MAX_CUTS { return Err(Error::Invalid(format!("a design takes at most {MAX_CUTS} partitions"))); } let planes: Vec<(Plane, [f64; 3])> = cuts.iter().map(plane_of).collect::>()?; let mut bodies: Vec = self.source.as_ref().clone(); let mut errors: Vec = self.errors.iter().filter(|e| !e.feature.starts_with("partition ")).cloned().collect(); let mut reports = Vec::with_capacity(cuts.len()); for (cut, (plane, n)) in cuts.iter().zip(&planes) { let feature = format!("partition {}", cut.name); for b in &cut.bodies { if !bodies.iter().any(|x| &x.name == b) { errors.push(FeatureError { feature: feature.clone(), message: format!("no body `{b}` to cut") }); } } let mut report = CutReport { name: cut.name.clone(), split: Vec::new(), faces: 0 }; let mut next = Vec::with_capacity(bodies.len() + 1); for body in bodies { let listed = cut.bodies.iter().any(|b| b == &body.name); if !(cut.bodies.is_empty() || listed) { next.push(body); continue; } match split(&body, plane, *n, cut)? { Some((cells, faces)) => { report.split.push(body.name.clone()); report.faces += faces; next.extend(cells); } None => { if listed { errors.push(FeatureError { feature: feature.clone(), message: format!("the plane does not divide `{}`", body.name) }); } next.push(body); } } if next.len() > MAX_CELLS { return Err(Error::Invalid(format!("the partitions make at most {MAX_CELLS} cells"))); } } bodies = next; reports.push(report); } Ok(Design { name: self.name.clone(), bodies, errors, cuts: reports, source: std::sync::Arc::clone(&self.source) }) } } /// Cut one body: its cells, named, and how many cut faces they have; `None` when the plane does /// not divide it. fn split(body: &Shape, plane: &Plane, n: [f64; 3], cut: &Cut) -> Result, usize)>> { let parts = match guard("partition", || solidcraft_kernel::split_by_plane(&body.body, plane)) { Ok(p) if p.len() >= 2 => p, _ => return Ok(None), }; let size = body.size()?; let tol = (MATCH_TOLERANCE * size).max(1e-4); let parent = Faces::of(body, tol)?; let mut cells: Vec<(bool, [f64; 3], Shape)> = Vec::with_capacity(parts.len()); for part in parts { let mut shape = Shape { name: String::new(), body: part, faces: Vec::new() }; let m = shape.measure()?; let negative = dot(sub(m.centroid, cut.point), n) < 0.0; shape.faces = (0..m.faces).map(|_| Face { name: String::new(), exact: false }).collect(); cells.push((negative, m.centroid, shape)); } cells.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| lexical(&a.1, &b.1))); let mut faces = 0; let count = |neg: bool| cells.iter().filter(|c| c.0 == neg).count(); let (na, nb) = (count(true), count(false)); let (mut ka, mut kb) = (0, 0); let mut out = Vec::with_capacity(cells.len()); for (negative, _, mut shape) in cells { let (k, of) = if negative { ka += 1; (ka, na) } else { kb += 1; (kb, nb) }; let side = if negative { "a" } else { "b" }; shape.name = if of > 1 { format!("{}/{side}{k}", body.name) } else { format!("{}/{side}", body.name) }; faces += name_faces(&mut shape, &parent, cut, n, size, tol)?; out.push(shape); } Ok(Some((out, faces))) } /// A body's faces as matching needs them: each face's analytic surface, name and triangles. struct Faces { surfaces: Vec>, names: Vec, mesh: SurfaceMesh, } impl Faces { fn of(body: &Shape, tol: f64) -> Result { Ok(Faces { surfaces: body.face_surfaces()?, names: body.faces.clone(), mesh: body.tessellate(tol)? }) } /// The face nearest to `p` (by distance to its triangles), among those `keep` allows. fn nearest(&self, p: [f64; 3], keep: impl Fn(usize) -> bool) -> Option { let mut best: Option<(f64, usize)> = None; for (t, &f) in self.mesh.triangles.iter().zip(&self.mesh.tri_face) { let f = f as usize; if !keep(f) { continue; } let q = |k: usize| t.get(k).and_then(|&i| self.mesh.positions.get(i as usize)).copied().unwrap_or([0.0; 3]); let d = point_triangle(p, q(0), q(1), q(2)); if best.is_none_or(|(b, _)| d < b) { best = Some((d, f)); } } best.map(|(_, f)| f) } } impl Shape { /// For each point, the face (by index, the [`SurfaceMesh::tri_face`] tag) nearest to it, /// measured to a tessellation within `tolerance`; `None` for a body without faces. pub fn nearest_faces(&self, points: &[[f64; 3]], tolerance: f64) -> Result>> { let faces = Faces { surfaces: Vec::new(), names: Vec::new(), mesh: self.tessellate(tolerance)? }; Ok(points.iter().map(|p| faces.nearest(*p, |_| true).and_then(|f| u32::try_from(f).ok())).collect()) } } /// Whether two analytic surfaces are the same surface (within `tol`); free-form faces match any /// free-form face. fn same_surface(a: Option, b: Option, tol: f64) -> bool { let close = |x: [f64; 3], y: [f64; 3]| dot(sub(x, y), sub(x, y)).sqrt() <= tol; let parallel = |x: [f64; 3], y: [f64; 3]| dot(cross(x, y), cross(x, y)).sqrt() <= 1e-6; let on_line = |p: [f64; 3], o: [f64; 3], axis: [f64; 3]| { let c = cross(sub(p, o), axis); dot(c, c).sqrt() <= tol }; match (a, b) { (None, None) => true, (Some(FaceSurface::Plane { normal: n1, point: p1 }), Some(FaceSurface::Plane { normal: n2, point: p2 })) => { dot(n1, n2) > 1.0 - 1e-9 && dot(sub(p2, p1), n1).abs() <= tol } (Some(FaceSurface::Cylinder { origin: o1, axis: a1, radius: r1 }), Some(FaceSurface::Cylinder { origin: o2, axis: a2, radius: r2 })) => { (r1 - r2).abs() <= tol && parallel(a1, a2) && on_line(o2, o1, a1) } (Some(FaceSurface::Cone { apex: p1, axis: a1, half_angle: h1 }), Some(FaceSurface::Cone { apex: p2, axis: a2, half_angle: h2 })) => { close(p1, p2) && dot(a1, a2) > 1.0 - 1e-9 && (h1 - h2).abs() <= 1e-9 } (Some(FaceSurface::Sphere { center: c1, radius: r1 }), Some(FaceSurface::Sphere { center: c2, radius: r2 })) => { close(c1, c2) && (r1 - r2).abs() <= tol } ( Some(FaceSurface::Torus { center: c1, axis: a1, major: m1, minor: n1 }), Some(FaceSurface::Torus { center: c2, axis: a2, major: m2, minor: n2 }), ) => close(c1, c2) && parallel(a1, a2) && (m1 - m2).abs() <= tol && (n1 - n2).abs() <= tol, _ => false, } } /// The distance from `p` to the triangle `a b c` (Ericson, Real-Time Collision Detection §5.1.5). fn point_triangle(p: [f64; 3], a: [f64; 3], b: [f64; 3], c: [f64; 3]) -> f64 { let (ab, ac, ap) = (sub(b, a), sub(c, a), sub(p, a)); let (d1, d2) = (dot(ab, ap), dot(ac, ap)); let at = |q: [f64; 3]| dot(sub(p, q), sub(p, q)).sqrt(); let lerp = |o: [f64; 3], d: [f64; 3], t: f64| [o[0] + d[0] * t, o[1] + d[1] * t, o[2] + d[2] * t]; if d1 <= 0.0 && d2 <= 0.0 { return at(a); } let bp = sub(p, b); let (d3, d4) = (dot(ab, bp), dot(ac, bp)); if d3 >= 0.0 && d4 <= d3 { return at(b); } let vc = d1 * d4 - d3 * d2; if vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0 { return at(lerp(a, ab, d1 / (d1 - d3))); } let cp = sub(p, c); let (d5, d6) = (dot(ab, cp), dot(ac, cp)); if d6 >= 0.0 && d5 <= d6 { return at(c); } let vb = d5 * d2 - d1 * d6; if vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0 { return at(lerp(a, ac, d2 / (d2 - d6))); } let va = d3 * d6 - d5 * d4; if va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0 { return at(lerp(b, sub(c, b), (d4 - d3) / ((d4 - d3) + (d5 - d6)))); } let denom = va + vb + vc; if denom.abs() < f64::MIN_POSITIVE { return at(a); } let (v, w) = (vb / denom, vc / denom); at([a[0] + ab[0] * v + ac[0] * w, a[1] + ab[1] * v + ac[1] * w, a[2] + ab[2] * v + ac[2] * w]) } /// The name without a trailing piece number (`F2:top#1` → `F2:top`). fn base_name(name: &str) -> &str { match name.rsplit_once('#') { Some((b, k)) if !k.is_empty() && k.bytes().all(|c| c.is_ascii_digit()) => b, _ => name, } } /// Name a cell's faces: cut faces after the cut, the rest after the face of `parent` they are a /// piece of. Returns how many cut faces it has. fn name_faces(cell: &mut Shape, parent: &Faces, cut: &Cut, n: [f64; 3], size: f64, tol: f64) -> Result { let own = Faces::of(cell, tol)?; // A point on each face (the centroid of its largest triangle) and its area-weighted centroid. let count = cell.faces.len(); let mut probe = vec![(0.0f64, [0.0; 3]); count]; let mut centroid = vec![([0.0; 3], 0.0f64); count]; for (t, &f) in own.mesh.triangles.iter().zip(&own.mesh.tri_face) { let q = |k: usize| t.get(k).and_then(|&i| own.mesh.positions.get(i as usize)).copied().unwrap_or([0.0; 3]); let (a, b, c) = (q(0), q(1), q(2)); let cr = cross(sub(b, a), sub(c, a)); let area = 0.5 * dot(cr, cr).sqrt(); let mid = [(a[0] + b[0] + c[0]) / 3.0, (a[1] + b[1] + c[1]) / 3.0, (a[2] + b[2] + c[2]) / 3.0]; if let Some(slot) = probe.get_mut(f as usize).filter(|s| area > s.0) { *slot = (area, mid); } if let Some((s, w)) = centroid.get_mut(f as usize) { for k in 0..3 { s[k] += area * mid[k]; } *w += area; } } let centroid: Vec<[f64; 3]> = centroid.into_iter().map(|(s, w)| if w > 0.0 { s.map(|x| x / w) } else { s }).collect(); let on_plane = ON_PLANE * size.max(1.0); let mut names: Vec<(String, bool)> = Vec::with_capacity(count); // (name, exact) let mut cut_faces = 0; for i in 0..count { let surface = own.surfaces.get(i).copied().flatten(); if let Some(FaceSurface::Plane { normal, point }) = surface { let along = dot(normal, n); if (along.abs() - 1.0).abs() < 1e-9 && dot(sub(point, cut.point), n).abs() < on_plane.max(tol * 1e-3) { // Outward along the normal: the cell is on the negative side. let side = if along > 0.0 { "a" } else { "b" }; names.push((format!("partition:{}:{side}", cut.name), true)); cut_faces += 1; continue; } } let p = probe.get(i).map_or([0.0; 3], |x| x.1); let from = parent.nearest(p, |f| same_surface(surface, parent.surfaces.get(f).copied().flatten(), tol)).or_else(|| parent.nearest(p, |_| true)); let face = from.and_then(|f| parent.names.get(f)); names.push(match face { Some(f) if !f.name.is_empty() => (f.name.clone(), f.exact), _ => (i.to_string(), false), }); } // Several pieces of one face on the cell: numbered by position. let mut order: Vec = (0..count).collect(); order.sort_by(|&a, &b| lexical(centroid.get(a).unwrap_or(&[0.0; 3]), centroid.get(b).unwrap_or(&[0.0; 3]))); let bases: Vec = names.iter().map(|(nm, _)| base_name(nm).to_string()).collect(); let mut numbered = vec![None; count]; for (i, base) in bases.iter().enumerate() { if numbered.get(i).is_some_and(Option::is_some) { continue; } let group: Vec = order.iter().copied().filter(|&j| bases.get(j) == Some(base)).collect(); // Untouched pieces keep the numbers SolidCraft gave them; pieces the cut made (or a // name two pieces now share) are numbered again. let distinct = { let mut v: Vec<&str> = group.iter().filter_map(|&j| names.get(j).map(|x| x.0.as_str())).collect(); v.sort_unstable(); v.dedup(); v.len() == group.len() }; for (k, &j) in group.iter().enumerate() { if let Some(slot) = numbered.get_mut(j) { *slot = Some(if distinct { names.get(j).map(|x| x.0.clone()).unwrap_or_default() } else { format!("{base}#{k}") }); } } } for ((face, name), (_, exact)) in cell.faces.iter_mut().zip(numbered).zip(&names) { face.name = name.unwrap_or_default(); face.exact = *exact; } Ok(cut_faces) } #[cfg(test)] mod tests { use super::*; use crate::open_design; use crate::sample::{self, Plate}; fn plate() -> Design { open_design(&sample::plate_design(&Plate::default()).unwrap()).unwrap() } fn cut(name: &str, point: [f64; 3], normal: [f64; 3]) -> Cut { Cut { name: name.into(), point, normal, bodies: Vec::new() } } fn names(b: &Shape) -> Vec<&str> { b.faces().iter().map(|f| f.name.as_str()).collect() } #[test] fn a_plane_cuts_the_plate_into_cells_with_paired_cut_faces() { let d = plate(); let whole = d.bodies[0].measure().unwrap().volume; // Through the hole's centre (x = 15): each half has two cut faces, either side of the hole. let p = d.partitioned(&[cut("Mid", [15.0, 0.0, 0.0], [1.0, 0.0, 0.0])]).unwrap(); assert!(p.errors.is_empty(), "{:?}", p.errors); assert_eq!(p.bodies.len(), 2, "{:?}", p.bodies.iter().map(|b| &b.name).collect::>()); let volume: f64 = p.bodies.iter().map(|b| b.measure().unwrap().volume).sum(); assert!((volume - whole).abs() < 1e-6 * whole, "{volume} vs {whole}"); assert_eq!(p.cuts, vec![CutReport { name: "Mid".into(), split: vec![d.bodies[0].name.clone()], faces: 4 }]); let mut sides = Vec::new(); for b in &p.bodies { let m = b.measure().unwrap(); let side = if m.centroid[0] < 15.0 { "a" } else { "b" }; let mut cut: Vec<&str> = names(b).into_iter().filter(|n| n.starts_with("partition:")).collect(); cut.sort_unstable(); assert_eq!(cut, vec![format!("partition:Mid:{side}#0"), format!("partition:Mid:{side}#1")], "{:?}", names(b)); sides.push(side); } sides.sort_unstable(); assert_eq!(sides, ["a", "b"]); // Faces the cut did not touch keep their names; the cut's faces pair by index (#0 with // #0) and lie on the same piece of the plane. let original: Vec<&str> = names(&d.bodies[0]).into_iter().map(base_name).collect(); let body = &d.bodies[0].name; let mut cells: Vec<&str> = p.bodies.iter().map(|b| b.name.as_str()).collect(); cells.sort_unstable(); assert_eq!(cells, [format!("{body}/a"), format!("{body}/b")]); for b in &p.bodies { let n = names(b); assert!(n.iter().filter(|n| !n.starts_with("partition:")).all(|n| original.contains(&base_name(n))), "{n:?} {original:?}"); assert!(b.faces().iter().all(|f| f.exact), "{:?}", b.faces()); let mut unique = n.clone(); unique.sort_unstable(); unique.dedup(); assert_eq!(unique.len(), n.len(), "{n:?}"); } // The cut is replayed from the design as opened: partitioning again replaces it. let again = p.partitioned(&[]).unwrap(); assert_eq!(again.bodies.len(), 1); } #[test] fn cuts_follow_each_other_and_name_only_the_bodies_they_divide() { let d = plate(); let p = d.partitioned(&[cut("X", [30.0, 0.0, 0.0], [1.0, 0.0, 0.0]), cut("Z", [0.0, 0.0, 5.0], [0.0, 0.0, -1.0])]).unwrap(); assert!(p.errors.is_empty(), "{:?}", p.errors); assert_eq!(p.bodies.len(), 4); assert_eq!(p.cuts.len(), 2); assert_eq!(p.cuts[1].split.len(), 2); for b in &p.bodies { let n = names(b); assert_eq!(n.iter().filter(|n| n.starts_with("partition:X:")).count(), 1, "{n:?}"); assert_eq!(n.iter().filter(|n| n.starts_with("partition:Z:")).count(), 1, "{n:?}"); // The normal points down: the cell above z = 5 is on its negative side. let above = b.measure().unwrap().centroid[2] > 5.0; assert!(n.contains(&if above { "partition:Z:a" } else { "partition:Z:b" }), "{n:?}"); } } #[test] fn a_plane_that_misses_or_a_missing_body_is_reported_not_fatal() { let d = plate(); let body = d.bodies[0].name.clone(); let miss = Cut { bodies: vec![body.clone()], ..cut("Out", [100.0, 0.0, 0.0], [1.0, 0.0, 0.0]) }; let gone = Cut { bodies: vec!["Nope".into()], ..cut("Gone", [10.0, 0.0, 0.0], [1.0, 0.0, 0.0]) }; let p = d.partitioned(&[miss, gone, cut("Free", [100.0, 0.0, 0.0], [1.0, 0.0, 0.0])]).unwrap(); assert_eq!(p.bodies.len(), 1); let features: Vec<&str> = p.errors.iter().map(|e| e.feature.as_str()).collect(); assert_eq!(features, ["partition Out", "partition Gone"], "{:?}", p.errors); assert_eq!(p.bodies[0].name, body); assert!(p.cuts.iter().all(|c| c.split.is_empty() && c.faces == 0), "{:?}", p.cuts); } #[test] fn the_nearest_face_of_a_point() { let d = plate(); let b = &d.bodies[0]; // Above the top (z = 10) and beside the side at y = 0. let f = b.nearest_faces(&[[30.0, 20.0, 10.5], [30.0, -0.5, 5.0]], 0.05).unwrap(); let surfaces = b.face_surfaces().unwrap(); let normal = |f: Option| match surfaces[f.unwrap() as usize] { Some(FaceSurface::Plane { normal, .. }) => normal, s => panic!("{s:?}"), }; assert!((normal(f[0])[2] - 1.0).abs() < 1e-12); assert!((normal(f[1])[1] + 1.0).abs() < 1e-12); } #[test] fn hostile_planes_are_an_error() { let d = plate(); for (point, normal) in [ ([0.0; 3], [0.0; 3]), ([f64::NAN, 0.0, 0.0], [1.0, 0.0, 0.0]), ([0.0; 3], [f64::INFINITY, 0.0, 0.0]), ([1e300, 0.0, 0.0], [1.0, 0.0, 0.0]), ] { assert!(d.partitioned(&[cut("Bad", point, normal)]).is_err(), "{point:?} {normal:?}"); } let many = vec![cut("M", [1.0, 0.0, 0.0], [1.0, 0.0, 0.0]); MAX_CUTS + 1]; assert!(d.partitioned(&many).is_err()); } }