mesh_repair/
assembly.rs

1//! Multi-part assembly management.
2//!
3//! This module provides tools for managing assemblies of multiple mesh parts,
4//! supporting hierarchical relationships, connections, and export.
5//!
6//! # Use Cases
7//!
8//! - Skate boot assembly (boot + blade holder + liner)
9//! - Helmet assembly (shell + liner + visor mount)
10//! - Multi-part custom devices with snap-fit connections
11//!
12//! # Example
13//!
14//! ```
15//! use mesh_repair::{Mesh, Vertex};
16//! use mesh_repair::assembly::{Assembly, Part, Connection, ConnectionType};
17//! use nalgebra::{Isometry3, Vector3};
18//!
19//! // Create a simple assembly
20//! let mut assembly = Assembly::new("skate_boot");
21//!
22//! // Create a boot mesh (simplified)
23//! let mut boot = Mesh::new();
24//! boot.vertices.push(Vertex::from_coords(0.0, 0.0, 0.0));
25//! boot.vertices.push(Vertex::from_coords(10.0, 0.0, 0.0));
26//! boot.vertices.push(Vertex::from_coords(5.0, 10.0, 0.0));
27//! boot.faces.push([0, 1, 2]);
28//!
29//! // Add the boot as a part
30//! let boot_part = Part::new("boot_shell", boot);
31//! assembly.add_part(boot_part);
32//!
33//! // Create a liner mesh
34//! let mut liner = Mesh::new();
35//! liner.vertices.push(Vertex::from_coords(1.0, 1.0, 0.0));
36//! liner.vertices.push(Vertex::from_coords(9.0, 1.0, 0.0));
37//! liner.vertices.push(Vertex::from_coords(5.0, 9.0, 0.0));
38//! liner.faces.push([0, 1, 2]);
39//!
40//! // Add liner as a child of the boot
41//! let liner_part = Part::new("liner", liner)
42//!     .with_parent("boot_shell");
43//! assembly.add_part(liner_part);
44//!
45//! println!("Assembly: {} parts", assembly.part_count());
46//! ```
47
48use crate::{Mesh, MeshError, MeshResult};
49use nalgebra::{Isometry3, Point3, UnitQuaternion, Vector3};
50use std::collections::HashMap;
51use std::io::Write;
52use std::path::Path;
53
54/// A multi-part assembly.
55#[derive(Debug, Clone)]
56pub struct Assembly {
57    /// Assembly name/identifier.
58    pub name: String,
59
60    /// Parts in this assembly, keyed by ID.
61    parts: HashMap<String, Part>,
62
63    /// Connections between parts.
64    connections: Vec<Connection>,
65
66    /// Assembly-level metadata.
67    pub metadata: HashMap<String, String>,
68
69    /// Assembly version.
70    pub version: Option<String>,
71}
72
73impl Assembly {
74    /// Create a new empty assembly.
75    pub fn new(name: impl Into<String>) -> Self {
76        Self {
77            name: name.into(),
78            parts: HashMap::new(),
79            connections: Vec::new(),
80            metadata: HashMap::new(),
81            version: None,
82        }
83    }
84
85    /// Add a part to the assembly.
86    ///
87    /// Returns an error if a part with the same ID already exists.
88    pub fn add_part(&mut self, part: Part) -> MeshResult<()> {
89        if self.parts.contains_key(&part.id) {
90            return Err(MeshError::invalid_topology(format!(
91                "Part with ID '{}' already exists",
92                part.id
93            )));
94        }
95
96        // Validate parent exists if specified
97        if let Some(ref parent_id) = part.parent_id
98            && !self.parts.contains_key(parent_id)
99        {
100            return Err(MeshError::invalid_topology(format!(
101                "Parent part '{}' does not exist for part '{}'",
102                parent_id, part.id
103            )));
104        }
105
106        self.parts.insert(part.id.clone(), part);
107        Ok(())
108    }
109
110    /// Remove a part from the assembly.
111    ///
112    /// Returns the removed part, or None if not found.
113    /// Also removes any connections involving this part.
114    pub fn remove_part(&mut self, part_id: &str) -> Option<Part> {
115        let part = self.parts.remove(part_id)?;
116
117        // Remove connections involving this part
118        self.connections
119            .retain(|conn| conn.from_part != part_id && conn.to_part != part_id);
120
121        // Clear parent references from children
122        for other_part in self.parts.values_mut() {
123            if other_part.parent_id.as_deref() == Some(part_id) {
124                other_part.parent_id = None;
125            }
126        }
127
128        Some(part)
129    }
130
131    /// Get a part by ID.
132    pub fn get_part(&self, part_id: &str) -> Option<&Part> {
133        self.parts.get(part_id)
134    }
135
136    /// Get a mutable reference to a part by ID.
137    pub fn get_part_mut(&mut self, part_id: &str) -> Option<&mut Part> {
138        self.parts.get_mut(part_id)
139    }
140
141    /// List all part IDs.
142    pub fn list_parts(&self) -> impl Iterator<Item = &str> {
143        self.parts.keys().map(|s| s.as_str())
144    }
145
146    /// Get all parts.
147    pub fn parts(&self) -> impl Iterator<Item = &Part> {
148        self.parts.values()
149    }
150
151    /// Get all parts mutably.
152    pub fn parts_mut(&mut self) -> impl Iterator<Item = &mut Part> {
153        self.parts.values_mut()
154    }
155
156    /// Number of parts in the assembly.
157    pub fn part_count(&self) -> usize {
158        self.parts.len()
159    }
160
161    /// Check if the assembly is empty.
162    pub fn is_empty(&self) -> bool {
163        self.parts.is_empty()
164    }
165
166    /// Define a connection between two parts.
167    pub fn define_connection(&mut self, connection: Connection) -> MeshResult<()> {
168        // Validate both parts exist
169        if !self.parts.contains_key(&connection.from_part) {
170            return Err(MeshError::invalid_topology(format!(
171                "Part '{}' does not exist",
172                connection.from_part
173            )));
174        }
175        if !self.parts.contains_key(&connection.to_part) {
176            return Err(MeshError::invalid_topology(format!(
177                "Part '{}' does not exist",
178                connection.to_part
179            )));
180        }
181
182        self.connections.push(connection);
183        Ok(())
184    }
185
186    /// Get all connections.
187    pub fn connections(&self) -> &[Connection] {
188        &self.connections
189    }
190
191    /// Get connections for a specific part.
192    pub fn connections_for_part(&self, part_id: &str) -> Vec<&Connection> {
193        self.connections
194            .iter()
195            .filter(|c| c.from_part == part_id || c.to_part == part_id)
196            .collect()
197    }
198
199    /// Get child parts of a parent.
200    pub fn get_children(&self, parent_id: &str) -> Vec<&Part> {
201        self.parts
202            .values()
203            .filter(|p| p.parent_id.as_deref() == Some(parent_id))
204            .collect()
205    }
206
207    /// Get root parts (parts with no parent).
208    pub fn get_root_parts(&self) -> Vec<&Part> {
209        self.parts
210            .values()
211            .filter(|p| p.parent_id.is_none())
212            .collect()
213    }
214
215    /// Compute the world transform for a part (including parent transforms).
216    pub fn get_world_transform(&self, part_id: &str) -> Option<Isometry3<f64>> {
217        let part = self.parts.get(part_id)?;
218        let mut transform = part.transform;
219
220        // Walk up the parent chain
221        let mut current_parent_id = part.parent_id.as_deref();
222        while let Some(parent_id) = current_parent_id {
223            if let Some(parent) = self.parts.get(parent_id) {
224                transform = parent.transform * transform;
225                current_parent_id = parent.parent_id.as_deref();
226            } else {
227                break;
228            }
229        }
230
231        Some(transform)
232    }
233
234    /// Get a transformed copy of a part's mesh (world coordinates).
235    pub fn get_transformed_mesh(&self, part_id: &str) -> Option<Mesh> {
236        let part = self.parts.get(part_id)?;
237        let world_transform = self.get_world_transform(part_id)?;
238
239        let mut mesh = part.mesh.clone();
240        for vertex in &mut mesh.vertices {
241            vertex.position = world_transform * vertex.position;
242        }
243
244        Some(mesh)
245    }
246
247    /// Merge all parts into a single mesh (world coordinates).
248    pub fn to_merged_mesh(&self) -> Mesh {
249        let mut result = Mesh::new();
250
251        for part_id in self.parts.keys() {
252            if let Some(mesh) = self.get_transformed_mesh(part_id) {
253                let vertex_offset = result.vertices.len() as u32;
254
255                // Add vertices
256                result.vertices.extend(mesh.vertices);
257
258                // Add faces with offset
259                for face in &mesh.faces {
260                    result.faces.push([
261                        face[0] + vertex_offset,
262                        face[1] + vertex_offset,
263                        face[2] + vertex_offset,
264                    ]);
265                }
266            }
267        }
268
269        result
270    }
271
272    /// Validate the assembly.
273    pub fn validate(&self) -> AssemblyValidation {
274        let mut result = AssemblyValidation::default();
275
276        // Check for orphan parent references
277        for part in self.parts.values() {
278            if let Some(ref parent_id) = part.parent_id
279                && !self.parts.contains_key(parent_id)
280            {
281                result
282                    .orphan_references
283                    .push((part.id.clone(), parent_id.clone()));
284            }
285        }
286
287        // Check for circular parent references
288        for part in self.parts.values() {
289            if self.has_circular_reference(&part.id) {
290                result.circular_references.push(part.id.clone());
291            }
292        }
293
294        // Check connections
295        for conn in &self.connections {
296            if !self.parts.contains_key(&conn.from_part) {
297                result
298                    .invalid_connections
299                    .push((conn.clone(), format!("Missing part: {}", conn.from_part)));
300            }
301            if !self.parts.contains_key(&conn.to_part) {
302                result
303                    .invalid_connections
304                    .push((conn.clone(), format!("Missing part: {}", conn.to_part)));
305            }
306        }
307
308        result
309    }
310
311    fn has_circular_reference(&self, part_id: &str) -> bool {
312        let mut visited = std::collections::HashSet::new();
313        let mut current = Some(part_id);
314
315        while let Some(id) = current {
316            if visited.contains(id) {
317                return true;
318            }
319            visited.insert(id);
320
321            current = self.parts.get(id).and_then(|p| p.parent_id.as_deref());
322        }
323
324        false
325    }
326
327    /// Check interference between two parts.
328    pub fn check_interference(&self, part_a: &str, part_b: &str) -> MeshResult<InterferenceResult> {
329        let mesh_a = self
330            .get_transformed_mesh(part_a)
331            .ok_or_else(|| MeshError::invalid_topology(format!("Part '{}' not found", part_a)))?;
332
333        let mesh_b = self
334            .get_transformed_mesh(part_b)
335            .ok_or_else(|| MeshError::invalid_topology(format!("Part '{}' not found", part_b)))?;
336
337        // Compute bounding boxes for quick rejection
338        let bbox_a = compute_bbox(&mesh_a);
339        let bbox_b = compute_bbox(&mesh_b);
340
341        if !bboxes_overlap(&bbox_a, &bbox_b) {
342            return Ok(InterferenceResult {
343                has_interference: false,
344                overlap_volume: 0.0,
345                min_clearance: Some(bbox_distance(&bbox_a, &bbox_b)),
346            });
347        }
348
349        // For more detailed interference, we'd need proper mesh boolean ops
350        // For now, report that bounding boxes overlap
351        Ok(InterferenceResult {
352            has_interference: true,
353            overlap_volume: 0.0, // Would need CSG for accurate volume
354            min_clearance: None,
355        })
356    }
357
358    /// Check clearance between two parts.
359    pub fn check_clearance(
360        &self,
361        part_a: &str,
362        part_b: &str,
363        min_required: f64,
364    ) -> MeshResult<ClearanceResult> {
365        let mesh_a = self
366            .get_transformed_mesh(part_a)
367            .ok_or_else(|| MeshError::invalid_topology(format!("Part '{}' not found", part_a)))?;
368
369        let mesh_b = self
370            .get_transformed_mesh(part_b)
371            .ok_or_else(|| MeshError::invalid_topology(format!("Part '{}' not found", part_b)))?;
372
373        // Compute approximate clearance using bounding boxes
374        let bbox_a = compute_bbox(&mesh_a);
375        let bbox_b = compute_bbox(&mesh_b);
376
377        let clearance = bbox_distance(&bbox_a, &bbox_b);
378
379        Ok(ClearanceResult {
380            meets_requirement: clearance >= min_required,
381            actual_clearance: clearance,
382            required_clearance: min_required,
383        })
384    }
385
386    // =========================================================================
387    // Export Methods
388    // =========================================================================
389
390    /// Save the assembly to a file.
391    ///
392    /// The format is determined by the file extension or explicit format option.
393    ///
394    /// # Supported Formats
395    /// - `.3mf` - 3MF with multiple objects and build items
396    /// - `.stl` - Merged mesh as single STL (use `save_stl_separate` for individual parts)
397    ///
398    /// # Example
399    /// ```no_run
400    /// use mesh_repair::assembly::Assembly;
401    /// use std::path::Path;
402    ///
403    /// let assembly = Assembly::new("my_assembly");
404    /// assembly.save(Path::new("output.3mf"), None).unwrap();
405    /// ```
406    pub fn save(&self, path: &Path, format: Option<AssemblyExportFormat>) -> MeshResult<()> {
407        let format = format.unwrap_or_else(|| {
408            AssemblyExportFormat::from_path(path).unwrap_or(AssemblyExportFormat::ThreeMf)
409        });
410
411        match format {
412            AssemblyExportFormat::ThreeMf => self.save_3mf(path),
413            AssemblyExportFormat::StlMerged => {
414                let merged = self.to_merged_mesh();
415                crate::io::save_stl(&merged, path)
416            }
417            AssemblyExportFormat::StlSeparate => self.save_stl_separate(path),
418        }
419    }
420
421    /// Save the assembly to a 3MF file with multiple objects and build items.
422    ///
423    /// Each part becomes a separate object in the 3MF file, with its own
424    /// build item that specifies its transform.
425    ///
426    /// # Example
427    /// ```no_run
428    /// use mesh_repair::assembly::Assembly;
429    /// use std::path::Path;
430    ///
431    /// let assembly = Assembly::new("skate");
432    /// // ... add parts ...
433    /// assembly.save_3mf(Path::new("skate.3mf")).unwrap();
434    /// ```
435    pub fn save_3mf(&self, path: &Path) -> MeshResult<()> {
436        use std::fs::File;
437        use zip::ZipWriter;
438        use zip::write::SimpleFileOptions;
439
440        if self.is_empty() {
441            return Err(MeshError::EmptyMesh {
442                details: "Cannot save empty assembly".to_string(),
443            });
444        }
445
446        let file = File::create(path).map_err(|e| MeshError::IoWrite {
447            path: path.to_path_buf(),
448            source: e,
449        })?;
450
451        let mut zip = ZipWriter::new(file);
452        let options =
453            SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
454
455        // Write content types file
456        zip.start_file("[Content_Types].xml", options)
457            .map_err(|e| MeshError::IoWrite {
458                path: path.to_path_buf(),
459                source: std::io::Error::other(e.to_string()),
460            })?;
461        zip.write_all(ASSEMBLY_CONTENT_TYPES_XML.as_bytes())
462            .map_err(|e| MeshError::IoWrite {
463                path: path.to_path_buf(),
464                source: e,
465            })?;
466
467        // Write relationships file
468        zip.start_file("_rels/.rels", options)
469            .map_err(|e| MeshError::IoWrite {
470                path: path.to_path_buf(),
471                source: std::io::Error::other(e.to_string()),
472            })?;
473        zip.write_all(ASSEMBLY_RELS_XML.as_bytes())
474            .map_err(|e| MeshError::IoWrite {
475                path: path.to_path_buf(),
476                source: e,
477            })?;
478
479        // Write the model file
480        zip.start_file("3D/3dmodel.model", options)
481            .map_err(|e| MeshError::IoWrite {
482                path: path.to_path_buf(),
483                source: std::io::Error::other(e.to_string()),
484            })?;
485
486        let model_xml = self.generate_3mf_model_xml();
487        zip.write_all(model_xml.as_bytes())
488            .map_err(|e| MeshError::IoWrite {
489                path: path.to_path_buf(),
490                source: e,
491            })?;
492
493        zip.finish().map_err(|e| MeshError::IoWrite {
494            path: path.to_path_buf(),
495            source: std::io::Error::other(e.to_string()),
496        })?;
497
498        Ok(())
499    }
500
501    /// Generate 3MF model XML for the assembly.
502    fn generate_3mf_model_xml(&self) -> String {
503        let mut xml = String::with_capacity(self.parts.len() * 1000);
504
505        xml.push_str(r#"<?xml version="1.0" encoding="UTF-8"?>
506<model unit="millimeter" xml:lang="en-US" xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02">
507  <metadata name="Title">"#);
508        xml.push_str(&escape_xml(&self.name));
509        xml.push_str("</metadata>\n");
510
511        if let Some(ref version) = self.version {
512            xml.push_str("  <metadata name=\"Version\">");
513            xml.push_str(&escape_xml(version));
514            xml.push_str("</metadata>\n");
515        }
516
517        xml.push_str("  <resources>\n");
518
519        // Create a stable ordering for parts
520        let mut part_ids: Vec<&String> = self.parts.keys().collect();
521        part_ids.sort();
522
523        // Write each part as a separate object
524        for (obj_id, part_id) in part_ids.iter().enumerate() {
525            let part = &self.parts[*part_id];
526            let object_id = obj_id + 1; // 3MF IDs start at 1
527
528            xml.push_str(&format!(
529                "    <object id=\"{}\" type=\"model\" name=\"{}\">\n",
530                object_id,
531                escape_xml(&part.id)
532            ));
533            xml.push_str("      <mesh>\n        <vertices>\n");
534
535            // Write vertices
536            for v in &part.mesh.vertices {
537                xml.push_str(&format!(
538                    "          <vertex x=\"{:.6}\" y=\"{:.6}\" z=\"{:.6}\"/>\n",
539                    v.position.x, v.position.y, v.position.z
540                ));
541            }
542
543            xml.push_str("        </vertices>\n        <triangles>\n");
544
545            // Write triangles
546            for face in &part.mesh.faces {
547                xml.push_str(&format!(
548                    "          <triangle v1=\"{}\" v2=\"{}\" v3=\"{}\"/>\n",
549                    face[0], face[1], face[2]
550                ));
551            }
552
553            xml.push_str("        </triangles>\n      </mesh>\n    </object>\n");
554        }
555
556        xml.push_str("  </resources>\n  <build>\n");
557
558        // Write build items with transforms
559        for (obj_id, part_id) in part_ids.iter().enumerate() {
560            let object_id = obj_id + 1;
561
562            // Get world transform for this part
563            let world_transform = self
564                .get_world_transform(part_id)
565                .unwrap_or_else(Isometry3::identity);
566
567            // Only include transform attribute if it's not identity
568            if is_identity_transform(&world_transform) {
569                xml.push_str(&format!("    <item objectid=\"{}\"/>\n", object_id));
570            } else {
571                // 3MF uses a 3x4 affine matrix (row-major)
572                let matrix = transform_to_3mf_matrix(&world_transform);
573                xml.push_str(&format!(
574                    "    <item objectid=\"{}\" transform=\"{}\"/>\n",
575                    object_id, matrix
576                ));
577            }
578        }
579
580        xml.push_str("  </build>\n</model>\n");
581
582        xml
583    }
584
585    /// Save each part as a separate STL file.
586    ///
587    /// Files are named `{basename}_{part_id}.stl` in the same directory as `path`.
588    ///
589    /// # Example
590    /// ```no_run
591    /// use mesh_repair::assembly::Assembly;
592    /// use std::path::Path;
593    ///
594    /// let assembly = Assembly::new("skate");
595    /// // ... add parts ...
596    /// // Creates: skate_boot.stl, skate_liner.stl, etc.
597    /// assembly.save_stl_separate(Path::new("skate.stl")).unwrap();
598    /// ```
599    pub fn save_stl_separate(&self, path: &Path) -> MeshResult<()> {
600        if self.is_empty() {
601            return Err(MeshError::EmptyMesh {
602                details: "Cannot save empty assembly".to_string(),
603            });
604        }
605
606        let parent = path.parent().unwrap_or(Path::new("."));
607        let stem = path
608            .file_stem()
609            .and_then(|s| s.to_str())
610            .unwrap_or("assembly");
611
612        for (part_id, part) in &self.parts {
613            // Skip invisible parts
614            if !part.visible {
615                continue;
616            }
617
618            // Get transformed mesh
619            let mesh = self
620                .get_transformed_mesh(part_id)
621                .unwrap_or_else(|| part.mesh.clone());
622
623            // Create filename
624            let filename = format!("{}_{}.stl", stem, sanitize_filename(part_id));
625            let file_path = parent.join(filename);
626
627            crate::io::save_stl(&mesh, &file_path)?;
628        }
629
630        Ok(())
631    }
632
633    /// Generate a bill of materials (BOM) for the assembly.
634    ///
635    /// Returns a structured list of all parts with their properties.
636    ///
637    /// # Example
638    /// ```
639    /// use mesh_repair::{Mesh, Vertex};
640    /// use mesh_repair::assembly::{Assembly, Part};
641    ///
642    /// let mut assembly = Assembly::new("product");
643    /// let mut mesh = Mesh::new();
644    /// mesh.vertices.push(Vertex::from_coords(0.0, 0.0, 0.0));
645    /// mesh.vertices.push(Vertex::from_coords(1.0, 0.0, 0.0));
646    /// mesh.vertices.push(Vertex::from_coords(0.5, 1.0, 0.0));
647    /// mesh.faces.push([0, 1, 2]);
648    ///
649    /// assembly.add_part(Part::new("shell", mesh.clone()).with_material("PA12")).unwrap();
650    /// assembly.add_part(Part::new("liner", mesh).with_material("TPU")).unwrap();
651    ///
652    /// let bom = assembly.generate_bom();
653    /// assert_eq!(bom.items.len(), 2);
654    /// ```
655    pub fn generate_bom(&self) -> BillOfMaterials {
656        let mut items = Vec::with_capacity(self.parts.len());
657
658        for (part_id, part) in &self.parts {
659            let mesh = self
660                .get_transformed_mesh(part_id)
661                .unwrap_or_else(|| part.mesh.clone());
662            let (min, max) = compute_bbox(&mesh);
663            let dimensions = max - min;
664
665            // Estimate volume (approximate using bounding box)
666            let bbox_volume = dimensions.x * dimensions.y * dimensions.z;
667
668            // Count triangles
669            let triangle_count = mesh.faces.len();
670
671            items.push(BomItem {
672                part_id: part_id.clone(),
673                name: part_id.clone(),
674                material: part.material.clone(),
675                quantity: 1,
676                dimensions: (dimensions.x, dimensions.y, dimensions.z),
677                bounding_volume: bbox_volume,
678                triangle_count,
679                parent: part.parent_id.clone(),
680                metadata: part.metadata.clone(),
681            });
682        }
683
684        // Sort by part ID for consistent output
685        items.sort_by(|a, b| a.part_id.cmp(&b.part_id));
686
687        BillOfMaterials {
688            assembly_name: self.name.clone(),
689            version: self.version.clone(),
690            items,
691            connections: self.connections.clone(),
692        }
693    }
694
695    /// Export the BOM to a CSV file.
696    ///
697    /// # Example
698    /// ```no_run
699    /// use mesh_repair::assembly::Assembly;
700    /// use std::path::Path;
701    ///
702    /// let assembly = Assembly::new("product");
703    /// assembly.export_bom_csv(Path::new("bom.csv")).unwrap();
704    /// ```
705    pub fn export_bom_csv(&self, path: &Path) -> MeshResult<()> {
706        use std::fs::File;
707
708        let bom = self.generate_bom();
709
710        let file = File::create(path).map_err(|e| MeshError::IoWrite {
711            path: path.to_path_buf(),
712            source: e,
713        })?;
714
715        let mut writer = std::io::BufWriter::new(file);
716
717        // Write header
718        writeln!(
719            writer,
720            "Part ID,Material,Quantity,Width (mm),Height (mm),Depth (mm),Volume (mm³),Triangles,Parent"
721        )
722        .map_err(|e| MeshError::IoWrite {
723            path: path.to_path_buf(),
724            source: e,
725        })?;
726
727        // Write items
728        for item in &bom.items {
729            writeln!(
730                writer,
731                "{},{},{},{:.2},{:.2},{:.2},{:.2},{},{}",
732                escape_csv(&item.part_id),
733                escape_csv(item.material.as_deref().unwrap_or("")),
734                item.quantity,
735                item.dimensions.0,
736                item.dimensions.1,
737                item.dimensions.2,
738                item.bounding_volume,
739                item.triangle_count,
740                escape_csv(item.parent.as_deref().unwrap_or(""))
741            )
742            .map_err(|e| MeshError::IoWrite {
743                path: path.to_path_buf(),
744                source: e,
745            })?;
746        }
747
748        Ok(())
749    }
750}
751
752/// Assembly export format.
753#[derive(Debug, Clone, Copy, PartialEq, Eq)]
754pub enum AssemblyExportFormat {
755    /// 3MF with multiple objects and build items.
756    ThreeMf,
757    /// Single merged STL file.
758    StlMerged,
759    /// Separate STL files for each part.
760    StlSeparate,
761}
762
763impl AssemblyExportFormat {
764    /// Determine format from file extension.
765    pub fn from_path(path: &Path) -> Option<Self> {
766        let ext = path.extension()?.to_str()?.to_lowercase();
767        match ext.as_str() {
768            "3mf" => Some(Self::ThreeMf),
769            "stl" => Some(Self::StlMerged),
770            _ => None,
771        }
772    }
773}
774
775/// Bill of materials for an assembly.
776#[derive(Debug, Clone)]
777pub struct BillOfMaterials {
778    /// Assembly name.
779    pub assembly_name: String,
780
781    /// Assembly version.
782    pub version: Option<String>,
783
784    /// List of items in the BOM.
785    pub items: Vec<BomItem>,
786
787    /// Connections between parts.
788    pub connections: Vec<Connection>,
789}
790
791impl BillOfMaterials {
792    /// Get total part count.
793    pub fn total_parts(&self) -> usize {
794        self.items.iter().map(|i| i.quantity).sum()
795    }
796
797    /// Get unique material count.
798    pub fn unique_materials(&self) -> Vec<&str> {
799        let mut materials: Vec<&str> = self
800            .items
801            .iter()
802            .filter_map(|i| i.material.as_deref())
803            .collect();
804        materials.sort();
805        materials.dedup();
806        materials
807    }
808
809    /// Get parts by material.
810    pub fn parts_by_material(&self, material: &str) -> Vec<&BomItem> {
811        self.items
812            .iter()
813            .filter(|i| i.material.as_deref() == Some(material))
814            .collect()
815    }
816}
817
818/// A single item in the bill of materials.
819#[derive(Debug, Clone)]
820pub struct BomItem {
821    /// Part ID.
822    pub part_id: String,
823
824    /// Display name.
825    pub name: String,
826
827    /// Material name.
828    pub material: Option<String>,
829
830    /// Quantity.
831    pub quantity: usize,
832
833    /// Bounding box dimensions (width, height, depth) in mm.
834    pub dimensions: (f64, f64, f64),
835
836    /// Bounding box volume in mm³.
837    pub bounding_volume: f64,
838
839    /// Number of triangles.
840    pub triangle_count: usize,
841
842    /// Parent part ID.
843    pub parent: Option<String>,
844
845    /// Additional metadata.
846    pub metadata: HashMap<String, String>,
847}
848
849/// Content types XML for 3MF assembly.
850const ASSEMBLY_CONTENT_TYPES_XML: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
851<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
852  <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
853  <Default Extension="model" ContentType="application/vnd.ms-package.3dmanufacturing-3dmodel+xml"/>
854</Types>
855"#;
856
857/// Relationships XML for 3MF assembly.
858const ASSEMBLY_RELS_XML: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
859<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
860  <Relationship Target="/3D/3dmodel.model" Id="rel0" Type="http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel"/>
861</Relationships>
862"#;
863
864/// Check if a transform is approximately identity.
865fn is_identity_transform(t: &Isometry3<f64>) -> bool {
866    let eps = 1e-10;
867    let translation_zero = t.translation.vector.norm() < eps;
868    let rotation_identity =
869        (t.rotation.angle() < eps) || (t.rotation.angle() - std::f64::consts::TAU).abs() < eps;
870    translation_zero && rotation_identity
871}
872
873/// Convert an Isometry3 to a 3MF transform matrix string.
874fn transform_to_3mf_matrix(t: &Isometry3<f64>) -> String {
875    // 3MF uses a 3x4 affine matrix in row-major order:
876    // m00 m01 m02 m03 m10 m11 m12 m13 m20 m21 m22 m23
877    let rot = t.rotation.to_rotation_matrix();
878    let trans = t.translation.vector;
879
880    format!(
881        "{:.6} {:.6} {:.6} {:.6} {:.6} {:.6} {:.6} {:.6} {:.6} {:.6} {:.6} {:.6}",
882        rot[(0, 0)],
883        rot[(0, 1)],
884        rot[(0, 2)],
885        trans.x,
886        rot[(1, 0)],
887        rot[(1, 1)],
888        rot[(1, 2)],
889        trans.y,
890        rot[(2, 0)],
891        rot[(2, 1)],
892        rot[(2, 2)],
893        trans.z
894    )
895}
896
897/// Escape special XML characters.
898fn escape_xml(s: &str) -> String {
899    s.replace('&', "&amp;")
900        .replace('<', "&lt;")
901        .replace('>', "&gt;")
902        .replace('"', "&quot;")
903        .replace('\'', "&apos;")
904}
905
906/// Escape special characters for CSV.
907fn escape_csv(s: &str) -> String {
908    if s.contains(',') || s.contains('"') || s.contains('\n') {
909        format!("\"{}\"", s.replace('"', "\"\""))
910    } else {
911        s.to_string()
912    }
913}
914
915/// Sanitize a string for use as a filename.
916fn sanitize_filename(s: &str) -> String {
917    s.chars()
918        .map(|c| match c {
919            '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
920            _ => c,
921        })
922        .collect()
923}
924
925/// A single part in an assembly.
926#[derive(Debug, Clone)]
927pub struct Part {
928    /// Unique identifier for this part.
929    pub id: String,
930
931    /// The mesh geometry.
932    pub mesh: Mesh,
933
934    /// Transform relative to parent (or world if no parent).
935    pub transform: Isometry3<f64>,
936
937    /// Parent part ID (if any).
938    pub parent_id: Option<String>,
939
940    /// Part metadata.
941    pub metadata: HashMap<String, String>,
942
943    /// Material name.
944    pub material: Option<String>,
945
946    /// Is this part visible?
947    pub visible: bool,
948}
949
950impl Part {
951    /// Create a new part with identity transform.
952    pub fn new(id: impl Into<String>, mesh: Mesh) -> Self {
953        Self {
954            id: id.into(),
955            mesh,
956            transform: Isometry3::identity(),
957            parent_id: None,
958            metadata: HashMap::new(),
959            material: None,
960            visible: true,
961        }
962    }
963
964    /// Set the parent part ID.
965    pub fn with_parent(mut self, parent_id: impl Into<String>) -> Self {
966        self.parent_id = Some(parent_id.into());
967        self
968    }
969
970    /// Set the transform.
971    pub fn with_transform(mut self, transform: Isometry3<f64>) -> Self {
972        self.transform = transform;
973        self
974    }
975
976    /// Set translation.
977    pub fn with_translation(mut self, x: f64, y: f64, z: f64) -> Self {
978        self.transform.translation.vector = Vector3::new(x, y, z);
979        self
980    }
981
982    /// Set rotation from axis-angle.
983    pub fn with_rotation(mut self, axis: Vector3<f64>, angle: f64) -> Self {
984        if let Some(axis_unit) = nalgebra::Unit::try_new(axis, 1e-10) {
985            self.transform.rotation = UnitQuaternion::from_axis_angle(&axis_unit, angle);
986        }
987        self
988    }
989
990    /// Set material name.
991    pub fn with_material(mut self, material: impl Into<String>) -> Self {
992        self.material = Some(material.into());
993        self
994    }
995
996    /// Set visibility.
997    pub fn with_visible(mut self, visible: bool) -> Self {
998        self.visible = visible;
999        self
1000    }
1001
1002    /// Add metadata.
1003    pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
1004        self.metadata.insert(key.into(), value.into());
1005        self
1006    }
1007
1008    /// Get the local bounding box of this part.
1009    pub fn bounding_box(&self) -> (Point3<f64>, Point3<f64>) {
1010        compute_bbox(&self.mesh)
1011    }
1012}
1013
1014/// Connection between two parts.
1015#[derive(Debug, Clone)]
1016pub struct Connection {
1017    /// Source part ID.
1018    pub from_part: String,
1019
1020    /// Target part ID.
1021    pub to_part: String,
1022
1023    /// Connection type.
1024    pub connection_type: ConnectionType,
1025
1026    /// Connection parameters.
1027    pub params: ConnectionParams,
1028
1029    /// Name/description.
1030    pub name: Option<String>,
1031}
1032
1033impl Connection {
1034    /// Create a new connection.
1035    pub fn new(
1036        from_part: impl Into<String>,
1037        to_part: impl Into<String>,
1038        connection_type: ConnectionType,
1039    ) -> Self {
1040        Self {
1041            from_part: from_part.into(),
1042            to_part: to_part.into(),
1043            connection_type,
1044            params: ConnectionParams::default(),
1045            name: None,
1046        }
1047    }
1048
1049    /// Create a snap-fit connection.
1050    pub fn snap_fit(from_part: impl Into<String>, to_part: impl Into<String>) -> Self {
1051        Self::new(from_part, to_part, ConnectionType::SnapFit)
1052    }
1053
1054    /// Create a press-fit connection.
1055    pub fn press_fit(
1056        from_part: impl Into<String>,
1057        to_part: impl Into<String>,
1058        interference: f64,
1059    ) -> Self {
1060        let mut conn = Self::new(from_part, to_part, ConnectionType::PressFit);
1061        conn.params.interference = Some(interference);
1062        conn
1063    }
1064
1065    /// Create a clearance connection.
1066    pub fn clearance(
1067        from_part: impl Into<String>,
1068        to_part: impl Into<String>,
1069        min_clearance: f64,
1070    ) -> Self {
1071        let mut conn = Self::new(from_part, to_part, ConnectionType::Clearance);
1072        conn.params.clearance = Some(min_clearance);
1073        conn
1074    }
1075
1076    /// Set connection name.
1077    pub fn with_name(mut self, name: impl Into<String>) -> Self {
1078        self.name = Some(name.into());
1079        self
1080    }
1081}
1082
1083/// Type of connection between parts.
1084#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1085pub enum ConnectionType {
1086    /// Snap-fit (male/female interlocking).
1087    SnapFit,
1088
1089    /// Press-fit (interference fit).
1090    PressFit,
1091
1092    /// Clearance fit (loose with minimum gap).
1093    Clearance,
1094
1095    /// Glue/adhesive bond.
1096    Adhesive,
1097
1098    /// Threaded fastener.
1099    Threaded,
1100
1101    /// Sliding fit.
1102    Sliding,
1103
1104    /// Custom connection type.
1105    Custom,
1106}
1107
1108/// Parameters for a connection.
1109#[derive(Debug, Clone, Default)]
1110pub struct ConnectionParams {
1111    /// Interference amount (for press-fit), positive = overlap.
1112    pub interference: Option<f64>,
1113
1114    /// Clearance amount (for clearance fit), minimum gap.
1115    pub clearance: Option<f64>,
1116
1117    /// Snap feature height (for snap-fit).
1118    pub snap_height: Option<f64>,
1119
1120    /// Undercut angle for snap (degrees).
1121    pub undercut_angle: Option<f64>,
1122
1123    /// Connection location (relative to from_part).
1124    pub location: Option<Point3<f64>>,
1125
1126    /// Custom parameters.
1127    pub custom: HashMap<String, String>,
1128}
1129
1130/// Assembly validation result.
1131#[derive(Debug, Clone, Default)]
1132pub struct AssemblyValidation {
1133    /// Parts with orphan parent references.
1134    pub orphan_references: Vec<(String, String)>,
1135
1136    /// Parts with circular parent references.
1137    pub circular_references: Vec<String>,
1138
1139    /// Invalid connections.
1140    pub invalid_connections: Vec<(Connection, String)>,
1141}
1142
1143impl AssemblyValidation {
1144    /// Check if the assembly is valid.
1145    pub fn is_valid(&self) -> bool {
1146        self.orphan_references.is_empty()
1147            && self.circular_references.is_empty()
1148            && self.invalid_connections.is_empty()
1149    }
1150}
1151
1152/// Result of interference check.
1153#[derive(Debug, Clone)]
1154pub struct InterferenceResult {
1155    /// Whether parts interfere.
1156    pub has_interference: bool,
1157
1158    /// Volume of overlap (if calculable).
1159    pub overlap_volume: f64,
1160
1161    /// Minimum clearance (if no interference).
1162    pub min_clearance: Option<f64>,
1163}
1164
1165/// Result of clearance check.
1166#[derive(Debug, Clone)]
1167pub struct ClearanceResult {
1168    /// Whether the clearance requirement is met.
1169    pub meets_requirement: bool,
1170
1171    /// Actual clearance measured.
1172    pub actual_clearance: f64,
1173
1174    /// Required clearance.
1175    pub required_clearance: f64,
1176}
1177
1178/// Compute the axis-aligned bounding box of a mesh.
1179fn compute_bbox(mesh: &Mesh) -> (Point3<f64>, Point3<f64>) {
1180    if mesh.vertices.is_empty() {
1181        return (Point3::origin(), Point3::origin());
1182    }
1183
1184    let mut min = mesh.vertices[0].position;
1185    let mut max = mesh.vertices[0].position;
1186
1187    for v in &mesh.vertices {
1188        min.x = min.x.min(v.position.x);
1189        min.y = min.y.min(v.position.y);
1190        min.z = min.z.min(v.position.z);
1191        max.x = max.x.max(v.position.x);
1192        max.y = max.y.max(v.position.y);
1193        max.z = max.z.max(v.position.z);
1194    }
1195
1196    (min, max)
1197}
1198
1199/// Check if two bounding boxes overlap.
1200fn bboxes_overlap(a: &(Point3<f64>, Point3<f64>), b: &(Point3<f64>, Point3<f64>)) -> bool {
1201    let (a_min, a_max) = a;
1202    let (b_min, b_max) = b;
1203
1204    !(a_max.x < b_min.x
1205        || b_max.x < a_min.x
1206        || a_max.y < b_min.y
1207        || b_max.y < a_min.y
1208        || a_max.z < b_min.z
1209        || b_max.z < a_min.z)
1210}
1211
1212/// Compute distance between two bounding boxes.
1213fn bbox_distance(a: &(Point3<f64>, Point3<f64>), b: &(Point3<f64>, Point3<f64>)) -> f64 {
1214    let (a_min, a_max) = a;
1215    let (b_min, b_max) = b;
1216
1217    let dx = (b_min.x - a_max.x).max(a_min.x - b_max.x).max(0.0);
1218    let dy = (b_min.y - a_max.y).max(a_min.y - b_max.y).max(0.0);
1219    let dz = (b_min.z - a_max.z).max(a_min.z - b_max.z).max(0.0);
1220
1221    (dx * dx + dy * dy + dz * dz).sqrt()
1222}
1223
1224#[cfg(test)]
1225mod tests {
1226    use super::*;
1227    use crate::Vertex;
1228
1229    fn create_test_mesh() -> Mesh {
1230        let mut mesh = Mesh::new();
1231        mesh.vertices.push(Vertex::from_coords(0.0, 0.0, 0.0));
1232        mesh.vertices.push(Vertex::from_coords(1.0, 0.0, 0.0));
1233        mesh.vertices.push(Vertex::from_coords(0.5, 1.0, 0.0));
1234        mesh.faces.push([0, 1, 2]);
1235        mesh
1236    }
1237
1238    #[test]
1239    fn test_assembly_new() {
1240        let assembly = Assembly::new("test_assembly");
1241        assert_eq!(assembly.name, "test_assembly");
1242        assert!(assembly.is_empty());
1243        assert_eq!(assembly.part_count(), 0);
1244    }
1245
1246    #[test]
1247    fn test_add_part() {
1248        let mut assembly = Assembly::new("test");
1249        let part = Part::new("part1", create_test_mesh());
1250
1251        assembly.add_part(part).unwrap();
1252        assert_eq!(assembly.part_count(), 1);
1253        assert!(assembly.get_part("part1").is_some());
1254    }
1255
1256    #[test]
1257    fn test_add_duplicate_part_fails() {
1258        let mut assembly = Assembly::new("test");
1259        assembly
1260            .add_part(Part::new("part1", create_test_mesh()))
1261            .unwrap();
1262
1263        let result = assembly.add_part(Part::new("part1", create_test_mesh()));
1264        assert!(result.is_err());
1265    }
1266
1267    #[test]
1268    fn test_remove_part() {
1269        let mut assembly = Assembly::new("test");
1270        assembly
1271            .add_part(Part::new("part1", create_test_mesh()))
1272            .unwrap();
1273
1274        let removed = assembly.remove_part("part1");
1275        assert!(removed.is_some());
1276        assert!(assembly.is_empty());
1277    }
1278
1279    #[test]
1280    fn test_parent_child() {
1281        let mut assembly = Assembly::new("test");
1282        assembly
1283            .add_part(Part::new("parent", create_test_mesh()))
1284            .unwrap();
1285        assembly
1286            .add_part(Part::new("child", create_test_mesh()).with_parent("parent"))
1287            .unwrap();
1288
1289        let children = assembly.get_children("parent");
1290        assert_eq!(children.len(), 1);
1291        assert_eq!(children[0].id, "child");
1292    }
1293
1294    #[test]
1295    fn test_root_parts() {
1296        let mut assembly = Assembly::new("test");
1297        assembly
1298            .add_part(Part::new("root1", create_test_mesh()))
1299            .unwrap();
1300        assembly
1301            .add_part(Part::new("root2", create_test_mesh()))
1302            .unwrap();
1303        assembly
1304            .add_part(Part::new("child", create_test_mesh()).with_parent("root1"))
1305            .unwrap();
1306
1307        let roots = assembly.get_root_parts();
1308        assert_eq!(roots.len(), 2);
1309    }
1310
1311    #[test]
1312    fn test_world_transform() {
1313        let mut assembly = Assembly::new("test");
1314
1315        let parent = Part::new("parent", create_test_mesh()).with_translation(10.0, 0.0, 0.0);
1316        assembly.add_part(parent).unwrap();
1317
1318        let child = Part::new("child", create_test_mesh())
1319            .with_parent("parent")
1320            .with_translation(5.0, 0.0, 0.0);
1321        assembly.add_part(child).unwrap();
1322
1323        let world_transform = assembly.get_world_transform("child").unwrap();
1324        assert!((world_transform.translation.vector.x - 15.0).abs() < 1e-10);
1325    }
1326
1327    #[test]
1328    fn test_define_connection() {
1329        let mut assembly = Assembly::new("test");
1330        assembly
1331            .add_part(Part::new("part1", create_test_mesh()))
1332            .unwrap();
1333        assembly
1334            .add_part(Part::new("part2", create_test_mesh()))
1335            .unwrap();
1336
1337        let conn = Connection::snap_fit("part1", "part2");
1338        assembly.define_connection(conn).unwrap();
1339
1340        assert_eq!(assembly.connections().len(), 1);
1341    }
1342
1343    #[test]
1344    fn test_connection_for_missing_part_fails() {
1345        let mut assembly = Assembly::new("test");
1346        assembly
1347            .add_part(Part::new("part1", create_test_mesh()))
1348            .unwrap();
1349
1350        let conn = Connection::snap_fit("part1", "missing");
1351        let result = assembly.define_connection(conn);
1352        assert!(result.is_err());
1353    }
1354
1355    #[test]
1356    fn test_validate() {
1357        let mut assembly = Assembly::new("test");
1358        assembly
1359            .add_part(Part::new("part1", create_test_mesh()))
1360            .unwrap();
1361
1362        let validation = assembly.validate();
1363        assert!(validation.is_valid());
1364    }
1365
1366    #[test]
1367    fn test_to_merged_mesh() {
1368        let mut assembly = Assembly::new("test");
1369        assembly
1370            .add_part(Part::new("part1", create_test_mesh()))
1371            .unwrap();
1372        assembly
1373            .add_part(Part::new("part2", create_test_mesh()))
1374            .unwrap();
1375
1376        let merged = assembly.to_merged_mesh();
1377        assert_eq!(merged.vertices.len(), 6); // 3 + 3
1378        assert_eq!(merged.faces.len(), 2); // 1 + 1
1379    }
1380
1381    #[test]
1382    fn test_check_clearance() {
1383        let mut assembly = Assembly::new("test");
1384        assembly
1385            .add_part(Part::new("part1", create_test_mesh()).with_translation(0.0, 0.0, 0.0))
1386            .unwrap();
1387        assembly
1388            .add_part(Part::new("part2", create_test_mesh()).with_translation(10.0, 0.0, 0.0))
1389            .unwrap();
1390
1391        let result = assembly.check_clearance("part1", "part2", 5.0).unwrap();
1392        assert!(result.meets_requirement);
1393        assert!(result.actual_clearance > 5.0);
1394    }
1395
1396    #[test]
1397    fn test_part_builder() {
1398        let part = Part::new("test", create_test_mesh())
1399            .with_parent("parent")
1400            .with_translation(1.0, 2.0, 3.0)
1401            .with_material("TPU")
1402            .with_visible(false)
1403            .with_metadata("key", "value");
1404
1405        assert_eq!(part.parent_id, Some("parent".to_string()));
1406        assert!((part.transform.translation.vector.x - 1.0).abs() < 1e-10);
1407        assert_eq!(part.material, Some("TPU".to_string()));
1408        assert!(!part.visible);
1409        assert_eq!(part.metadata.get("key"), Some(&"value".to_string()));
1410    }
1411
1412    #[test]
1413    fn test_connection_types() {
1414        let snap = Connection::snap_fit("a", "b");
1415        assert_eq!(snap.connection_type, ConnectionType::SnapFit);
1416
1417        let press = Connection::press_fit("a", "b", 0.1);
1418        assert_eq!(press.connection_type, ConnectionType::PressFit);
1419        assert_eq!(press.params.interference, Some(0.1));
1420
1421        let clearance = Connection::clearance("a", "b", 0.5);
1422        assert_eq!(clearance.connection_type, ConnectionType::Clearance);
1423        assert_eq!(clearance.params.clearance, Some(0.5));
1424    }
1425
1426    #[test]
1427    fn test_generate_bom() {
1428        let mut assembly = Assembly::new("test_assembly");
1429        assembly.version = Some("1.0".to_string());
1430
1431        assembly
1432            .add_part(Part::new("part1", create_test_mesh()).with_material("PLA"))
1433            .unwrap();
1434        assembly
1435            .add_part(Part::new("part2", create_test_mesh()).with_material("TPU"))
1436            .unwrap();
1437        assembly
1438            .add_part(
1439                Part::new("part3", create_test_mesh())
1440                    .with_material("PLA")
1441                    .with_parent("part1"),
1442            )
1443            .unwrap();
1444
1445        let bom = assembly.generate_bom();
1446        assert_eq!(bom.assembly_name, "test_assembly");
1447        assert_eq!(bom.version, Some("1.0".to_string()));
1448        assert_eq!(bom.items.len(), 3);
1449        assert_eq!(bom.total_parts(), 3);
1450
1451        let materials = bom.unique_materials();
1452        assert_eq!(materials.len(), 2);
1453        assert!(materials.contains(&"PLA"));
1454        assert!(materials.contains(&"TPU"));
1455
1456        let pla_parts = bom.parts_by_material("PLA");
1457        assert_eq!(pla_parts.len(), 2);
1458    }
1459
1460    #[test]
1461    fn test_bom_item_dimensions() {
1462        let mut assembly = Assembly::new("test");
1463        assembly
1464            .add_part(Part::new("part1", create_test_mesh()))
1465            .unwrap();
1466
1467        let bom = assembly.generate_bom();
1468        let item = &bom.items[0];
1469
1470        // Test mesh is a triangle from (0,0,0) to (1,0,0) to (0.5,1,0)
1471        assert!((item.dimensions.0 - 1.0).abs() < 1e-6); // width
1472        assert!((item.dimensions.1 - 1.0).abs() < 1e-6); // height
1473        assert!(item.dimensions.2 < 1e-6); // depth (flat)
1474        assert_eq!(item.triangle_count, 1);
1475    }
1476
1477    #[test]
1478    fn test_save_3mf_roundtrip() {
1479        let mut assembly = Assembly::new("test_assembly");
1480        assembly
1481            .metadata
1482            .insert("author".to_string(), "Test Author".to_string());
1483
1484        assembly
1485            .add_part(Part::new("part1", create_test_mesh()).with_translation(0.0, 0.0, 0.0))
1486            .unwrap();
1487        assembly
1488            .add_part(Part::new("part2", create_test_mesh()).with_translation(5.0, 0.0, 0.0))
1489            .unwrap();
1490
1491        let temp_dir = std::env::temp_dir();
1492        let path = temp_dir.join("test_assembly_export.3mf");
1493
1494        // Save
1495        assembly.save_3mf(&path).unwrap();
1496        assert!(path.exists());
1497
1498        // Verify it's a valid zip with expected structure
1499        let file = std::fs::File::open(&path).unwrap();
1500        let mut archive = zip::ZipArchive::new(file).unwrap();
1501
1502        // Check expected files exist
1503        assert!(archive.by_name("[Content_Types].xml").is_ok());
1504        assert!(archive.by_name("_rels/.rels").is_ok());
1505        assert!(archive.by_name("3D/3dmodel.model").is_ok());
1506
1507        // Read the model and verify it has objects
1508        let mut model_file = archive.by_name("3D/3dmodel.model").unwrap();
1509        let mut model_content = String::new();
1510        std::io::Read::read_to_string(&mut model_file, &mut model_content).unwrap();
1511
1512        // Should have 2 objects and 2 build items
1513        assert!(model_content.contains("<object id=\"1\""));
1514        assert!(model_content.contains("<object id=\"2\""));
1515        assert!(model_content.contains("<item objectid=\"1\""));
1516        assert!(model_content.contains("<item objectid=\"2\""));
1517
1518        // Clean up
1519        std::fs::remove_file(&path).ok();
1520    }
1521
1522    #[test]
1523    fn test_save_stl_separate() {
1524        let mut assembly = Assembly::new("test_assembly");
1525        assembly
1526            .add_part(Part::new("part1", create_test_mesh()))
1527            .unwrap();
1528        assembly
1529            .add_part(Part::new("part2", create_test_mesh()))
1530            .unwrap();
1531
1532        let temp_dir = std::env::temp_dir().join("test_stl_separate");
1533        std::fs::create_dir_all(&temp_dir).ok();
1534
1535        // save_stl_separate expects a file path, not a directory
1536        // It uses the file stem as a prefix for individual files
1537        let base_path = temp_dir.join("assembly.stl");
1538        assembly.save_stl_separate(&base_path).unwrap();
1539
1540        // Check that individual STL files were created (named as stem_partid.stl)
1541        assert!(temp_dir.join("assembly_part1.stl").exists());
1542        assert!(temp_dir.join("assembly_part2.stl").exists());
1543
1544        // Clean up
1545        std::fs::remove_dir_all(&temp_dir).ok();
1546    }
1547
1548    #[test]
1549    fn test_export_bom_csv() {
1550        let mut assembly = Assembly::new("test_assembly");
1551        assembly
1552            .add_part(Part::new("part1", create_test_mesh()).with_material("PLA"))
1553            .unwrap();
1554        assembly
1555            .add_part(Part::new("part2", create_test_mesh()).with_material("TPU"))
1556            .unwrap();
1557
1558        let temp_dir = std::env::temp_dir();
1559        let path = temp_dir.join("test_bom.csv");
1560
1561        assembly.export_bom_csv(&path).unwrap();
1562        assert!(path.exists());
1563
1564        let content = std::fs::read_to_string(&path).unwrap();
1565
1566        // Check header (note: actual format is Part ID,Material,Quantity,...)
1567        assert!(content.contains("Part ID,Material,Quantity"));
1568        // Check parts
1569        assert!(content.contains("part1"));
1570        assert!(content.contains("part2"));
1571        assert!(content.contains("PLA"));
1572        assert!(content.contains("TPU"));
1573
1574        // Clean up
1575        std::fs::remove_file(&path).ok();
1576    }
1577
1578    #[test]
1579    fn test_save_with_format_detection() {
1580        let mut assembly = Assembly::new("test");
1581        assembly
1582            .add_part(Part::new("part1", create_test_mesh()))
1583            .unwrap();
1584
1585        let temp_dir = std::env::temp_dir();
1586
1587        // Test 3MF detection
1588        let path_3mf = temp_dir.join("test_format.3mf");
1589        assembly.save(&path_3mf, None).unwrap();
1590        assert!(path_3mf.exists());
1591        std::fs::remove_file(&path_3mf).ok();
1592
1593        // Test STL detection
1594        let path_stl = temp_dir.join("test_format.stl");
1595        assembly.save(&path_stl, None).unwrap();
1596        assert!(path_stl.exists());
1597        std::fs::remove_file(&path_stl).ok();
1598    }
1599
1600    #[test]
1601    fn test_assembly_export_format_from_path() {
1602        assert_eq!(
1603            AssemblyExportFormat::from_path(Path::new("test.3mf")),
1604            Some(AssemblyExportFormat::ThreeMf)
1605        );
1606        assert_eq!(
1607            AssemblyExportFormat::from_path(Path::new("test.stl")),
1608            Some(AssemblyExportFormat::StlMerged)
1609        );
1610        assert_eq!(AssemblyExportFormat::from_path(Path::new("test.obj")), None);
1611    }
1612
1613    #[test]
1614    fn test_transform_helpers() {
1615        // Test identity detection
1616        let identity = Isometry3::identity();
1617        assert!(is_identity_transform(&identity));
1618
1619        // Test non-identity with translation
1620        let translated = Isometry3::translation(1.0, 0.0, 0.0);
1621        assert!(!is_identity_transform(&translated));
1622
1623        // Test transform matrix generation
1624        let matrix_str = transform_to_3mf_matrix(&translated);
1625        // Should have 12 numbers: 3x4 affine matrix
1626        let parts: Vec<&str> = matrix_str.split_whitespace().collect();
1627        assert_eq!(parts.len(), 12);
1628    }
1629
1630    #[test]
1631    fn test_escape_functions() {
1632        // XML escaping
1633        assert_eq!(escape_xml("a < b"), "a &lt; b");
1634        assert_eq!(escape_xml("a & b"), "a &amp; b");
1635        assert_eq!(escape_xml("\"test\""), "&quot;test&quot;");
1636
1637        // CSV escaping
1638        assert_eq!(escape_csv("simple"), "simple");
1639        assert_eq!(escape_csv("with,comma"), "\"with,comma\"");
1640        assert_eq!(escape_csv("with\"quote"), "\"with\"\"quote\"");
1641    }
1642
1643    #[test]
1644    fn test_sanitize_filename() {
1645        assert_eq!(sanitize_filename("normal_name"), "normal_name");
1646        assert_eq!(sanitize_filename("with/slash"), "with_slash");
1647        assert_eq!(sanitize_filename("with:colon"), "with_colon");
1648        assert_eq!(
1649            sanitize_filename("with*star?question"),
1650            "with_star_question"
1651        );
1652    }
1653}