Skip to main content

brepkit_operations/
assembly.rs

1//! Assembly management: hierarchical product structure with positioned components.
2//!
3//! Provides a tree-based assembly model where each component is a solid
4//! placed at a specific location via a transform matrix. Components can
5//! be instances of the same shape (instance sharing).
6//!
7//! Provides a product structure for managing multi-component assemblies.
8
9use std::collections::HashMap;
10
11use brepkit_math::aabb::Aabb3;
12use brepkit_math::mat::Mat4;
13use brepkit_math::vec::Point3;
14use brepkit_topology::Topology;
15use brepkit_topology::solid::SolidId;
16
17use crate::OperationsError;
18
19/// A unique identifier for a component in an assembly.
20pub type ComponentId = usize;
21
22/// A positioned component in an assembly.
23#[derive(Debug, Clone)]
24pub struct Component {
25    /// Human-readable name.
26    pub name: String,
27    /// The solid shape this component represents.
28    pub solid: SolidId,
29    /// Transform placing this component in the assembly's coordinate system.
30    pub transform: Mat4,
31    /// Parent component (None for root-level components).
32    pub parent: Option<ComponentId>,
33    /// Child component IDs.
34    pub children: Vec<ComponentId>,
35}
36
37/// A hierarchical assembly of positioned components.
38///
39/// The assembly tree supports:
40/// - Adding components with transforms
41/// - Parent-child hierarchy
42/// - Instance sharing (same solid, different transforms)
43/// - Bounding box computation for the entire assembly
44/// - Flattening to a list of positioned solids
45#[derive(Debug, Default, Clone)]
46pub struct Assembly {
47    /// All components, indexed by their ID.
48    components: HashMap<ComponentId, Component>,
49    /// Root-level component IDs (no parent).
50    roots: Vec<ComponentId>,
51    /// Next available component ID.
52    next_id: ComponentId,
53    /// Assembly name.
54    name: String,
55}
56
57impl Assembly {
58    /// Creates a new empty assembly.
59    #[must_use]
60    pub fn new(name: impl Into<String>) -> Self {
61        Self {
62            name: name.into(),
63            ..Self::default()
64        }
65    }
66
67    /// Returns the assembly name.
68    #[must_use]
69    pub fn name(&self) -> &str {
70        &self.name
71    }
72
73    /// Adds a root-level component (no parent).
74    pub fn add_root_component(
75        &mut self,
76        name: impl Into<String>,
77        solid: SolidId,
78        transform: Mat4,
79    ) -> ComponentId {
80        let id = self.next_id;
81        self.next_id += 1;
82
83        self.components.insert(
84            id,
85            Component {
86                name: name.into(),
87                solid,
88                transform,
89                parent: None,
90                children: Vec::new(),
91            },
92        );
93        self.roots.push(id);
94        id
95    }
96
97    /// Adds a child component under an existing parent.
98    ///
99    /// # Errors
100    /// Returns an error if the parent ID doesn't exist.
101    pub fn add_child_component(
102        &mut self,
103        parent: ComponentId,
104        name: impl Into<String>,
105        solid: SolidId,
106        transform: Mat4,
107    ) -> Result<ComponentId, OperationsError> {
108        if !self.components.contains_key(&parent) {
109            return Err(OperationsError::InvalidInput {
110                reason: format!("parent component {parent} not found"),
111            });
112        }
113
114        let id = self.next_id;
115        self.next_id += 1;
116
117        self.components.insert(
118            id,
119            Component {
120                name: name.into(),
121                solid,
122                transform,
123                parent: Some(parent),
124                children: Vec::new(),
125            },
126        );
127
128        if let Some(parent_comp) = self.components.get_mut(&parent) {
129            parent_comp.children.push(id);
130        }
131
132        Ok(id)
133    }
134
135    /// Returns a component by ID.
136    #[must_use]
137    pub fn component(&self, id: ComponentId) -> Option<&Component> {
138        self.components.get(&id)
139    }
140
141    /// Returns root-level component IDs.
142    #[must_use]
143    pub fn roots(&self) -> &[ComponentId] {
144        &self.roots
145    }
146
147    /// Returns the total number of components.
148    #[must_use]
149    pub fn component_count(&self) -> usize {
150        self.components.len()
151    }
152
153    /// Computes the world transform for a component by multiplying
154    /// all parent transforms in the hierarchy.
155    #[must_use]
156    pub fn world_transform(&self, id: ComponentId) -> Option<Mat4> {
157        let comp = self.components.get(&id)?;
158        let mut result = comp.transform;
159
160        let mut current_parent = comp.parent;
161        while let Some(pid) = current_parent {
162            let parent = self.components.get(&pid)?;
163            result = parent.transform * result;
164            current_parent = parent.parent;
165        }
166
167        Some(result)
168    }
169
170    /// Flattens the assembly to a list of `(solid, world_transform)` pairs.
171    ///
172    /// This resolves the full hierarchy, computing the accumulated
173    /// transform for each leaf component.
174    #[must_use]
175    pub fn flatten(&self) -> Vec<(SolidId, Mat4)> {
176        let mut result = Vec::new();
177        for &root_id in &self.roots {
178            self.flatten_recursive(root_id, Mat4::identity(), &mut result);
179        }
180        result
181    }
182
183    fn flatten_recursive(
184        &self,
185        id: ComponentId,
186        parent_transform: Mat4,
187        result: &mut Vec<(SolidId, Mat4)>,
188    ) {
189        let Some(comp) = self.components.get(&id) else {
190            return;
191        };
192
193        let world = parent_transform * comp.transform;
194
195        if comp.children.is_empty() {
196            result.push((comp.solid, world));
197        } else {
198            for &child_id in &comp.children {
199                self.flatten_recursive(child_id, world, result);
200            }
201        }
202    }
203
204    /// Computes the bounding box of the entire assembly.
205    ///
206    /// # Errors
207    /// Returns an error if any solid's bounding box computation fails.
208    pub fn bounding_box(&self, topo: &Topology) -> Result<Aabb3, OperationsError> {
209        let mut min_x = f64::MAX;
210        let mut min_y = f64::MAX;
211        let mut min_z = f64::MAX;
212        let mut max_x = f64::MIN;
213        let mut max_y = f64::MIN;
214        let mut max_z = f64::MIN;
215
216        for (solid_id, transform) in self.flatten() {
217            let bbox = crate::measure::solid_bounding_box(topo, solid_id)?;
218            let lo = bbox.min;
219            let hi = bbox.max;
220            let corners = [
221                Point3::new(lo.x(), lo.y(), lo.z()),
222                Point3::new(hi.x(), lo.y(), lo.z()),
223                Point3::new(lo.x(), hi.y(), lo.z()),
224                Point3::new(hi.x(), hi.y(), lo.z()),
225                Point3::new(lo.x(), lo.y(), hi.z()),
226                Point3::new(hi.x(), lo.y(), hi.z()),
227                Point3::new(lo.x(), hi.y(), hi.z()),
228                Point3::new(hi.x(), hi.y(), hi.z()),
229            ];
230
231            for corner in &corners {
232                let transformed = transform.mul_point(*corner);
233                min_x = min_x.min(transformed.x());
234                min_y = min_y.min(transformed.y());
235                min_z = min_z.min(transformed.z());
236                max_x = max_x.max(transformed.x());
237                max_y = max_y.max(transformed.y());
238                max_z = max_z.max(transformed.z());
239            }
240        }
241
242        Ok(Aabb3 {
243            min: Point3::new(min_x, min_y, min_z),
244            max: Point3::new(max_x, max_y, max_z),
245        })
246    }
247
248    /// Generate a bill of materials: list of unique solids and their instance count.
249    #[must_use]
250    pub fn bill_of_materials(&self) -> Vec<BomEntry> {
251        let mut solid_counts: HashMap<usize, (String, usize)> = HashMap::new();
252
253        for comp in self.components.values() {
254            let entry = solid_counts
255                .entry(comp.solid.index())
256                .or_insert_with(|| (comp.name.clone(), 0));
257            entry.1 += 1;
258        }
259
260        solid_counts
261            .into_iter()
262            .map(|(solid_idx, (name, count))| BomEntry {
263                name,
264                solid_index: solid_idx,
265                instance_count: count,
266            })
267            .collect()
268    }
269}
270
271/// An entry in the bill of materials.
272#[derive(Debug, Clone)]
273pub struct BomEntry {
274    /// Component name.
275    pub name: String,
276    /// Arena index of the solid shape.
277    pub solid_index: usize,
278    /// Number of instances of this shape in the assembly.
279    pub instance_count: usize,
280}
281
282#[cfg(test)]
283#[allow(clippy::unwrap_used)]
284mod tests {
285    use super::*;
286    use crate::primitives::make_box;
287
288    #[test]
289    fn empty_assembly() {
290        let asm = Assembly::new("test");
291        assert_eq!(asm.component_count(), 0);
292        assert!(asm.roots().is_empty());
293        assert!(asm.flatten().is_empty());
294    }
295
296    #[test]
297    fn add_root_component() {
298        let mut topo = Topology::new();
299        let box1 = make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
300
301        let mut asm = Assembly::new("test");
302        let id = asm.add_root_component("box1", box1, Mat4::identity());
303
304        assert_eq!(asm.component_count(), 1);
305        assert_eq!(asm.roots().len(), 1);
306
307        let comp = asm.component(id).unwrap();
308        assert_eq!(comp.name, "box1");
309        assert!(comp.parent.is_none());
310    }
311
312    #[test]
313    fn add_child_component() {
314        let mut topo = Topology::new();
315        let box1 = make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
316        let box2 = make_box(&mut topo, 0.5, 0.5, 0.5).unwrap();
317
318        let mut asm = Assembly::new("test");
319        let parent = asm.add_root_component("parent", box1, Mat4::identity());
320        let child = asm
321            .add_child_component(parent, "child", box2, Mat4::translation(2.0, 0.0, 0.0))
322            .unwrap();
323
324        assert_eq!(asm.component_count(), 2);
325        assert_eq!(asm.roots().len(), 1);
326
327        let parent_comp = asm.component(parent).unwrap();
328        assert_eq!(parent_comp.children.len(), 1);
329
330        let child_comp = asm.component(child).unwrap();
331        assert_eq!(child_comp.parent, Some(parent));
332    }
333
334    #[test]
335    fn flatten_assembly() {
336        let mut topo = Topology::new();
337        let box1 = make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
338
339        let mut asm = Assembly::new("test");
340        asm.add_root_component("box_a", box1, Mat4::identity());
341        asm.add_root_component("box_b", box1, Mat4::translation(3.0, 0.0, 0.0));
342
343        let flat = asm.flatten();
344        assert_eq!(flat.len(), 2, "two root components = two instances");
345    }
346
347    #[test]
348    fn world_transform_chain() {
349        let mut topo = Topology::new();
350        let box1 = make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
351
352        let mut asm = Assembly::new("test");
353        let parent = asm.add_root_component("parent", box1, Mat4::translation(1.0, 0.0, 0.0));
354        let child = asm
355            .add_child_component(parent, "child", box1, Mat4::translation(0.0, 2.0, 0.0))
356            .unwrap();
357
358        let world = asm.world_transform(child).unwrap();
359        let origin = world.mul_point(Point3::new(0.0, 0.0, 0.0));
360
361        // Parent translates (1,0,0), child translates (0,2,0)
362        // World should be (1,2,0)
363        assert!((origin.x() - 1.0).abs() < 1e-10);
364        assert!((origin.y() - 2.0).abs() < 1e-10);
365        assert!((origin.z() - 0.0).abs() < 1e-10);
366    }
367
368    #[test]
369    fn bill_of_materials() {
370        let mut topo = Topology::new();
371        let box1 = make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
372
373        let mut asm = Assembly::new("test");
374        asm.add_root_component("wheel_1", box1, Mat4::identity());
375        asm.add_root_component("wheel_2", box1, Mat4::translation(1.0, 0.0, 0.0));
376        asm.add_root_component("wheel_3", box1, Mat4::translation(2.0, 0.0, 0.0));
377
378        let bom = asm.bill_of_materials();
379        // All 3 use the same solid — should have 1 BOM entry with count 3
380        assert_eq!(bom.len(), 1);
381        assert_eq!(bom[0].instance_count, 3);
382    }
383
384    #[test]
385    fn invalid_parent_error() {
386        let mut topo = Topology::new();
387        let box1 = make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
388
389        let mut asm = Assembly::new("test");
390        let result = asm.add_child_component(999, "child", box1, Mat4::identity());
391        assert!(result.is_err());
392    }
393
394    #[test]
395    fn assembly_bounding_box() {
396        let mut topo = Topology::new();
397        let box1 = make_box(&mut topo, 2.0, 2.0, 2.0).unwrap();
398
399        let mut asm = Assembly::new("test");
400        asm.add_root_component("box_a", box1, Mat4::identity());
401        asm.add_root_component("box_b", box1, Mat4::translation(10.0, 0.0, 0.0));
402
403        let bbox = asm.bounding_box(&topo).unwrap();
404
405        // box_a at origin: [0, 2]³, box_b translated by 10: [10, 12] in x
406        assert!(bbox.min.x() < 0.5);
407        assert!(bbox.max.x() > 11.5);
408    }
409}