Skip to main content

brepkit_operations/
compound_ops.rs

1//! Operations on compound entities.
2//!
3//! Provides utilities for working with compounds of solids:
4//! extracting individual solids, fusing all solids in a compound,
5//! and computing compound-level measurements.
6
7use brepkit_math::aabb::Aabb3;
8use brepkit_topology::Topology;
9use brepkit_topology::compound::CompoundId;
10use brepkit_topology::solid::SolidId;
11
12/// Extract all solid IDs from a compound.
13///
14/// # Errors
15///
16/// Returns an error if the compound ID is invalid.
17pub fn explode(
18    topo: &Topology,
19    compound: CompoundId,
20) -> Result<Vec<SolidId>, crate::OperationsError> {
21    let comp = topo.compound(compound)?;
22    Ok(comp.solids().to_vec())
23}
24
25/// Fuse (union) all solids in a compound into a single solid.
26///
27/// Performs iterative boolean union on all solids. Requires at least
28/// one solid in the compound.
29///
30/// # Errors
31///
32/// Returns an error if the compound is empty or a boolean operation fails.
33pub fn fuse_all(
34    topo: &mut Topology,
35    compound: CompoundId,
36) -> Result<SolidId, crate::OperationsError> {
37    let solids = {
38        let comp = topo.compound(compound)?;
39        comp.solids().to_vec()
40    };
41
42    if solids.is_empty() {
43        return Err(crate::OperationsError::InvalidInput {
44            reason: "compound has no solids to fuse".into(),
45        });
46    }
47
48    // Partition solids into overlapping groups. Disjoint solids can be merged
49    // directly (no boolean needed), while overlapping groups use boolean fuse.
50    let bboxes: Vec<Aabb3> = solids
51        .iter()
52        .map(|&sid| crate::measure::solid_bounding_box(topo, sid))
53        .collect::<Result<_, _>>()?;
54
55    // Per-solid polyhedral bounds (plane normals + vertices), or `None` for any
56    // solid with a curved face. Lets `partition_touching` prove that two solids
57    // whose loose AABBs overlap are actually disjoint (e.g. honeycomb hex prisms
58    // packed tighter than their corner-to-corner AABB extent), keeping them off
59    // the expensive boolean path.
60    let margin = brepkit_math::tolerance::Tolerance::new().linear;
61    let poly_bounds: Vec<Option<PolyhedralBounds>> =
62        solids.iter().map(|&s| polyhedral_bounds(topo, s)).collect();
63
64    let groups = partition_touching(&bboxes, &poly_bounds, margin);
65
66    let mut group_results: Vec<SolidId> = Vec::new();
67    for group in &groups {
68        let group_solids: Vec<SolidId> = group.iter().map(|&i| solids[i]).collect();
69        if group_solids.len() == 1 {
70            group_results.push(group_solids[0]);
71            continue;
72        }
73        // Each group is a connected cluster of interpenetrating/touching solids
74        // — fuse it in ONE GFA arrangement (via `fuse_cluster`, N-way with a
75        // sequential fallback) instead of a pairwise reduction that re-processes
76        // a growing accumulator O(n²).
77        group_results.push(crate::boolean::fuse_cluster(topo, &group_solids)?);
78    }
79
80    if group_results.len() == 1 {
81        return Ok(group_results[0]);
82    }
83
84    merge_disjoint_solids(topo, &group_results)
85}
86
87/// Count the total number of solids in a compound.
88///
89/// # Errors
90///
91/// Returns an error if the compound ID is invalid.
92pub fn solid_count(topo: &Topology, compound: CompoundId) -> Result<usize, crate::OperationsError> {
93    let comp = topo.compound(compound)?;
94    Ok(comp.solids().len())
95}
96
97/// Compute the combined bounding box of all solids in a compound.
98///
99/// # Errors
100///
101/// Returns an error if the compound is empty or measurement fails.
102pub fn compound_bounding_box(
103    topo: &Topology,
104    compound: CompoundId,
105) -> Result<brepkit_math::aabb::Aabb3, crate::OperationsError> {
106    let comp = topo.compound(compound)?;
107    let solids = comp.solids();
108
109    if solids.is_empty() {
110        return Err(crate::OperationsError::InvalidInput {
111            reason: "compound is empty".into(),
112        });
113    }
114
115    let mut combined = crate::measure::solid_bounding_box(topo, solids[0])?;
116    for &sid in &solids[1..] {
117        let bb = crate::measure::solid_bounding_box(topo, sid)?;
118        combined = combined.union(bb);
119    }
120
121    Ok(combined)
122}
123
124/// Union-find path-compressed lookup.
125fn uf_find(parent: &mut [usize], mut x: usize) -> usize {
126    while parent[x] != x {
127        parent[x] = parent[parent[x]];
128        x = parent[x];
129    }
130    x
131}
132
133/// Plane normals (candidate separating axes) and boundary vertices of a
134/// solid, used to prove disjointness via the separating-axis theorem.
135struct PolyhedralBounds {
136    normals: Vec<brepkit_math::vec::Vec3>,
137    verts: Vec<brepkit_math::vec::Point3>,
138}
139
140/// Collect a solid's plane normals and vertices — but only if *every* outer-shell
141/// face is planar. A flat-faced solid is contained in the convex hull of its
142/// vertices, which makes the vertex-projection separation test (below) sound.
143/// A single curved face can bulge past that hull, so any non-`Plane` face makes
144/// this return `None` (the caller then falls back to the conservative AABB test).
145fn polyhedral_bounds(topo: &Topology, sid: SolidId) -> Option<PolyhedralBounds> {
146    use brepkit_topology::face::FaceSurface;
147
148    let solid = topo.solid(sid).ok()?;
149    let shell = topo.shell(solid.outer_shell()).ok()?;
150
151    let mut normals = Vec::new();
152    let mut vert_ids = std::collections::HashSet::new();
153    for &fid in shell.faces() {
154        let face = topo.face(fid).ok()?;
155        match face.surface() {
156            // Normalize: stored plane normals aren't guaranteed unit length (e.g.
157            // raw STEP `DIRECTION` data), and `polyhedral_separated` compares
158            // projection gaps against a world-space margin, which is only valid
159            // for unit axes. Bail the whole solid to the AABB path on a
160            // degenerate normal.
161            FaceSurface::Plane { normal, .. } => normals.push(normal.normalize().ok()?),
162            _ => return None,
163        }
164        for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied()) {
165            let wire = topo.wire(wid).ok()?;
166            for oe in wire.edges() {
167                let edge = topo.edge(oe.edge()).ok()?;
168                vert_ids.insert(edge.start());
169                vert_ids.insert(edge.end());
170            }
171        }
172    }
173
174    let mut verts = Vec::with_capacity(vert_ids.len());
175    for vid in vert_ids {
176        verts.push(topo.vertex(vid).ok()?.point());
177    }
178    if verts.is_empty() {
179        return None;
180    }
181    Some(PolyhedralBounds { normals, verts })
182}
183
184/// Whether two flat-faced solids are provably disjoint: `true` iff some face
185/// normal of either separates their vertex projections by a clear `margin`.
186///
187/// Soundness: each solid lies within the convex hull of its vertices (all faces
188/// planar), so a gap between the vertex projections on any axis is a real gap
189/// between the solids. Only face-normal axes are tried (not edge-edge cross
190/// products), so the test is sound but not complete — an undetected separation
191/// just falls through to the boolean, never a false "disjoint" for touching
192/// inputs.
193fn polyhedral_separated(a: &PolyhedralBounds, b: &PolyhedralBounds, margin: f64) -> bool {
194    let project = |verts: &[brepkit_math::vec::Point3], axis: &brepkit_math::vec::Vec3| {
195        let mut lo = f64::INFINITY;
196        let mut hi = f64::NEG_INFINITY;
197        for p in verts {
198            let d = p.x() * axis.x() + p.y() * axis.y() + p.z() * axis.z();
199            lo = lo.min(d);
200            hi = hi.max(d);
201        }
202        (lo, hi)
203    };
204    a.normals.iter().chain(b.normals.iter()).any(|axis| {
205        let (a_lo, a_hi) = project(&a.verts, axis);
206        let (b_lo, b_hi) = project(&b.verts, axis);
207        b_lo - a_hi > margin || a_lo - b_hi > margin
208    })
209}
210
211/// Partition indices into groups that may actually touch (union-find).
212///
213/// Two solids share a group when their AABBs overlap *unless* both are flat-faced
214/// and a separating axis proves a real gap between them. This keeps geometrically
215/// disjoint pieces whose loose AABBs overlap (honeycomb hex prisms, tightly
216/// packed feet) in separate groups, so `fuse_all` merges them via the cheap
217/// disjoint-shell path instead of an O(n) chain of boolean unions.
218fn partition_touching(
219    bboxes: &[Aabb3],
220    poly_bounds: &[Option<PolyhedralBounds>],
221    margin: f64,
222) -> Vec<Vec<usize>> {
223    let n = bboxes.len();
224    let mut parent: Vec<usize> = (0..n).collect();
225
226    for i in 0..n {
227        for j in (i + 1)..n {
228            if !bboxes[i].intersects(bboxes[j]) {
229                continue;
230            }
231            // AABBs overlap. Only keep them apart if we can *prove* a gap.
232            if let (Some(pi), Some(pj)) = (&poly_bounds[i], &poly_bounds[j])
233                && polyhedral_separated(pi, pj, margin)
234            {
235                continue;
236            }
237            let ri = uf_find(&mut parent, i);
238            let rj = uf_find(&mut parent, j);
239            if ri != rj {
240                parent[ri] = rj;
241            }
242        }
243    }
244
245    let mut groups: std::collections::HashMap<usize, Vec<usize>> = std::collections::HashMap::new();
246    for i in 0..n {
247        groups.entry(uf_find(&mut parent, i)).or_default().push(i);
248    }
249    groups.into_values().collect()
250}
251
252/// Merge disjoint solids into a single solid by combining all faces.
253///
254/// Note: the resulting outer shell contains disconnected face groups,
255/// which technically violates the connected-shell invariant. This is
256/// acceptable for volume measurement and tessellation (which iterate
257/// faces independently), but algorithms that assume shell connectivity
258/// should be aware. A future improvement would return a `Compound`.
259///
260/// The result references the input solids' existing faces (no deep copy),
261/// so callers that need an independent result must pass copies.
262pub(crate) fn merge_disjoint_solids(
263    topo: &mut Topology,
264    solids: &[SolidId],
265) -> Result<SolidId, crate::OperationsError> {
266    use brepkit_topology::shell::Shell;
267    use brepkit_topology::solid::Solid;
268
269    let mut all_faces = Vec::new();
270    let mut inner_shell_ids = Vec::new();
271
272    // Snapshot phase: collect all face IDs and inner shell face sets.
273    let mut inner_face_sets: Vec<Vec<brepkit_topology::face::FaceId>> = Vec::new();
274    for &sid in solids {
275        let solid_data = topo.solid(sid)?;
276        let outer_shell = topo.shell(solid_data.outer_shell())?;
277        all_faces.extend_from_slice(outer_shell.faces());
278
279        let inner_ids: Vec<_> = solid_data.inner_shells().to_vec();
280        for inner_id in inner_ids {
281            let inner_shell = topo.shell(inner_id)?;
282            inner_face_sets.push(inner_shell.faces().to_vec());
283        }
284    }
285
286    // Allocate phase: create inner shells.
287    for faces in inner_face_sets {
288        let inner = Shell::new(faces).map_err(crate::OperationsError::Topology)?;
289        inner_shell_ids.push(topo.add_shell(inner));
290    }
291
292    let outer = Shell::new(all_faces).map_err(crate::OperationsError::Topology)?;
293    let outer_id = topo.add_shell(outer);
294    Ok(topo.add_solid(Solid::new(outer_id, inner_shell_ids)))
295}
296
297#[cfg(test)]
298mod tests {
299    #![allow(clippy::unwrap_used)]
300
301    use brepkit_math::tolerance::Tolerance;
302    use brepkit_topology::Topology;
303    use brepkit_topology::compound::Compound;
304
305    use super::*;
306
307    #[test]
308    fn explode_returns_solids() {
309        let mut topo = Topology::new();
310        let s1 = crate::primitives::make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
311        let s2 = crate::primitives::make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
312        let cid = topo.add_compound(Compound::new(vec![s1, s2]));
313
314        let solids = explode(&topo, cid).unwrap();
315        assert_eq!(solids.len(), 2);
316    }
317
318    #[test]
319    fn solid_count_works() {
320        let mut topo = Topology::new();
321        let s1 = crate::primitives::make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
322        let cid = topo.add_compound(Compound::new(vec![s1]));
323
324        assert_eq!(solid_count(&topo, cid).unwrap(), 1);
325    }
326
327    #[test]
328    fn compound_bbox() {
329        let mut topo = Topology::new();
330        let s1 = crate::primitives::make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
331        let s2 = crate::primitives::make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
332
333        crate::transform::transform_solid(
334            &mut topo,
335            s2,
336            &brepkit_math::mat::Mat4::translation(5.0, 0.0, 0.0),
337        )
338        .unwrap();
339
340        let cid = topo.add_compound(Compound::new(vec![s1, s2]));
341        let bb = compound_bounding_box(&topo, cid).unwrap();
342
343        let tol = Tolerance::loose();
344        // s1 is [0,1], s2 translated by 5 is [5,6]
345        assert!(tol.approx_eq(bb.min.x(), 0.0));
346        assert!(tol.approx_eq(bb.max.x(), 6.0));
347    }
348
349    #[test]
350    fn fuse_all_two_overlapping_boxes() {
351        let mut topo = Topology::new();
352        let s1 = crate::primitives::make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
353        let s2 = crate::primitives::make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
354
355        // Offset s2 slightly — overlapping boxes.
356        crate::transform::transform_solid(
357            &mut topo,
358            s2,
359            &brepkit_math::mat::Mat4::translation(0.5, 0.0, 0.0),
360        )
361        .unwrap();
362
363        let cid = topo.add_compound(Compound::new(vec![s1, s2]));
364        let fused = fuse_all(&mut topo, cid).unwrap();
365
366        let vol = crate::measure::solid_volume(&topo, fused, 0.1).unwrap();
367        // Two overlapping unit cubes: total should be less than 2.0.
368        assert!(
369            vol > 1.0 && vol < 2.0,
370            "fused volume should be between 1 and 2, got {vol}"
371        );
372    }
373
374    /// A connected chain of four overlapping unit cubes forms ONE cluster, so
375    /// `fuse_all` fuses it via the N-way path. The union is a solid
376    /// [0,2.5]×[0,1]×[0,1] bar, so the result must be watertight with volume 2.5.
377    #[test]
378    fn fuse_all_connected_cluster_is_watertight_bar() {
379        use brepkit_math::mat::Mat4;
380
381        let offsets = [0.0, 0.5, 1.0, 1.5];
382
383        // fuse_all (N-way) path.
384        let mut topo = Topology::new();
385        let boxes: Vec<SolidId> = offsets
386            .iter()
387            .map(|&dx| {
388                let b = crate::primitives::make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
389                crate::transform::transform_solid(&mut topo, b, &Mat4::translation(dx, 0.0, 0.0))
390                    .unwrap();
391                b
392            })
393            .collect();
394        let cid = topo.add_compound(Compound::new(boxes));
395        let fused = fuse_all(&mut topo, cid).unwrap();
396        let vol = crate::measure::solid_volume(&topo, fused, 0.01).unwrap();
397
398        // Every edge of a watertight solid is used by exactly two faces.
399        let mut uses: std::collections::HashMap<usize, usize> = std::collections::HashMap::new();
400        for fid in brepkit_topology::explorer::solid_faces(&topo, fused).unwrap() {
401            let face = topo.face(fid).unwrap();
402            for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied())
403            {
404                for oe in topo.wire(wid).unwrap().edges() {
405                    *uses.entry(oe.edge().index()).or_default() += 1;
406                }
407            }
408        }
409        assert!(
410            uses.values().all(|&c| c == 2),
411            "fuse_all cluster result must be watertight"
412        );
413        assert!(
414            (vol - 2.5).abs() < 0.01,
415            "union of the overlapping row is a [0,2.5] bar (vol 2.5), got {vol}"
416        );
417    }
418
419    /// Build a hexagonal prism (flat top at z=0..h) centred at the origin via
420    /// convex hull — a polyhedral stand-in for a honeycomb pocket.
421    fn make_hex_prism(topo: &mut Topology, circumradius: f64, height: f64) -> SolidId {
422        use brepkit_math::vec::Point3;
423        let mut pts = Vec::with_capacity(12);
424        for k in 0..6 {
425            let a = std::f64::consts::PI / 3.0 * k as f64;
426            let (x, y) = (circumradius * a.cos(), circumradius * a.sin());
427            pts.push(Point3::new(x, y, 0.0));
428            pts.push(Point3::new(x, y, height));
429        }
430        crate::primitives::make_convex_hull(topo, &pts).unwrap()
431    }
432
433    /// Honeycomb-packed hex prisms with a real gap between every pair, but
434    /// corner-to-corner AABBs that overlap. The AABB-only partition collapsed
435    /// these into one giant group and unioned them with an O(n) boolean chain;
436    /// `partition_touching` proves the gaps with the separating-axis test and
437    /// keeps each prism in its own group, so `fuse_all` takes the cheap
438    /// disjoint-shell merge.
439    #[test]
440    fn fuse_all_honeycomb_stays_disjoint() {
441        let r = 1.0_f64; // circumradius; across-corners = 2r = 2.0
442        let pitch = 2.3_f64; // clear gap on every neighbour, AABBs still overlap
443        let height = 4.0_f64;
444        let nx = 6;
445        let ny = 6;
446
447        let mut topo = Topology::new();
448        let mut bboxes = Vec::new();
449        let mut solids = Vec::new();
450        for j in 0..ny {
451            for i in 0..nx {
452                let s = make_hex_prism(&mut topo, r, height);
453                let x = i as f64 * pitch + (j % 2) as f64 * pitch / 2.0;
454                let y = j as f64 * pitch * 0.9;
455                crate::transform::transform_solid(
456                    &mut topo,
457                    s,
458                    &brepkit_math::mat::Mat4::translation(x, y, 0.0),
459                )
460                .unwrap();
461                bboxes.push(crate::measure::solid_bounding_box(&topo, s).unwrap());
462                solids.push(s);
463            }
464        }
465        let n = solids.len();
466
467        // Every prism is provably disjoint from the others -> one group each.
468        let margin = brepkit_math::tolerance::Tolerance::new().linear;
469        let pb: Vec<Option<PolyhedralBounds>> = solids
470            .iter()
471            .map(|&s| polyhedral_bounds(&topo, s))
472            .collect();
473        let groups = partition_touching(&bboxes, &pb, margin);
474        assert_eq!(
475            groups.len(),
476            n,
477            "disjoint hex prisms should each be their own group, got {} groups",
478            groups.len()
479        );
480
481        // Geometry is still the full disjoint union: volume == n * hex-prism volume.
482        let cid = topo.add_compound(Compound::new(solids));
483        let fused = fuse_all(&mut topo, cid).unwrap();
484        let vol = crate::measure::solid_volume(&topo, fused, 0.05).unwrap();
485        let hex_area = 3.0_f64.sqrt() * 1.5 * r * r; // (3*sqrt(3)/2) r^2
486        let expected = n as f64 * hex_area * height;
487        assert!(
488            (vol - expected).abs() < expected * 0.02,
489            "fused volume {vol:.2} should match {expected:.2} (n disjoint prisms)"
490        );
491    }
492}