1use brepkit_math::aabb::Aabb3;
8use brepkit_topology::Topology;
9use brepkit_topology::compound::CompoundId;
10use brepkit_topology::solid::SolidId;
11
12pub 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
25pub 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 let bboxes: Vec<Aabb3> = solids
51 .iter()
52 .map(|&sid| crate::measure::solid_bounding_box(topo, sid))
53 .collect::<Result<_, _>>()?;
54
55 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 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
87pub fn solid_count(topo: &Topology, compound: CompoundId) -> Result<usize, crate::OperationsError> {
93 let comp = topo.compound(compound)?;
94 Ok(comp.solids().len())
95}
96
97pub 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
124fn 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
133struct PolyhedralBounds {
136 normals: Vec<brepkit_math::vec::Vec3>,
137 verts: Vec<brepkit_math::vec::Point3>,
138}
139
140fn 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 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
184fn 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
211fn 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 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
252pub(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 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 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 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 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 assert!(
369 vol > 1.0 && vol < 2.0,
370 "fused volume should be between 1 and 2, got {vol}"
371 );
372 }
373
374 #[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 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 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 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 #[test]
440 fn fuse_all_honeycomb_stays_disjoint() {
441 let r = 1.0_f64; let pitch = 2.3_f64; 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 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 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; 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}