1use crate::{Mesh, MeshError, MeshResult};
49use nalgebra::{Isometry3, Point3, UnitQuaternion, Vector3};
50use std::collections::HashMap;
51use std::io::Write;
52use std::path::Path;
53
54#[derive(Debug, Clone)]
56pub struct Assembly {
57 pub name: String,
59
60 parts: HashMap<String, Part>,
62
63 connections: Vec<Connection>,
65
66 pub metadata: HashMap<String, String>,
68
69 pub version: Option<String>,
71}
72
73impl Assembly {
74 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 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 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 pub fn remove_part(&mut self, part_id: &str) -> Option<Part> {
115 let part = self.parts.remove(part_id)?;
116
117 self.connections
119 .retain(|conn| conn.from_part != part_id && conn.to_part != part_id);
120
121 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 pub fn get_part(&self, part_id: &str) -> Option<&Part> {
133 self.parts.get(part_id)
134 }
135
136 pub fn get_part_mut(&mut self, part_id: &str) -> Option<&mut Part> {
138 self.parts.get_mut(part_id)
139 }
140
141 pub fn list_parts(&self) -> impl Iterator<Item = &str> {
143 self.parts.keys().map(|s| s.as_str())
144 }
145
146 pub fn parts(&self) -> impl Iterator<Item = &Part> {
148 self.parts.values()
149 }
150
151 pub fn parts_mut(&mut self) -> impl Iterator<Item = &mut Part> {
153 self.parts.values_mut()
154 }
155
156 pub fn part_count(&self) -> usize {
158 self.parts.len()
159 }
160
161 pub fn is_empty(&self) -> bool {
163 self.parts.is_empty()
164 }
165
166 pub fn define_connection(&mut self, connection: Connection) -> MeshResult<()> {
168 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 pub fn connections(&self) -> &[Connection] {
188 &self.connections
189 }
190
191 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 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 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 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 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 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 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 result.vertices.extend(mesh.vertices);
257
258 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 pub fn validate(&self) -> AssemblyValidation {
274 let mut result = AssemblyValidation::default();
275
276 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 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 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 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 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 Ok(InterferenceResult {
352 has_interference: true,
353 overlap_volume: 0.0, min_clearance: None,
355 })
356 }
357
358 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 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 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 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 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 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 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 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 let mut part_ids: Vec<&String> = self.parts.keys().collect();
521 part_ids.sort();
522
523 for (obj_id, part_id) in part_ids.iter().enumerate() {
525 let part = &self.parts[*part_id];
526 let object_id = obj_id + 1; 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 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 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 for (obj_id, part_id) in part_ids.iter().enumerate() {
560 let object_id = obj_id + 1;
561
562 let world_transform = self
564 .get_world_transform(part_id)
565 .unwrap_or_else(Isometry3::identity);
566
567 if is_identity_transform(&world_transform) {
569 xml.push_str(&format!(" <item objectid=\"{}\"/>\n", object_id));
570 } else {
571 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 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 if !part.visible {
615 continue;
616 }
617
618 let mesh = self
620 .get_transformed_mesh(part_id)
621 .unwrap_or_else(|| part.mesh.clone());
622
623 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 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 let bbox_volume = dimensions.x * dimensions.y * dimensions.z;
667
668 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 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 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
754pub enum AssemblyExportFormat {
755 ThreeMf,
757 StlMerged,
759 StlSeparate,
761}
762
763impl AssemblyExportFormat {
764 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#[derive(Debug, Clone)]
777pub struct BillOfMaterials {
778 pub assembly_name: String,
780
781 pub version: Option<String>,
783
784 pub items: Vec<BomItem>,
786
787 pub connections: Vec<Connection>,
789}
790
791impl BillOfMaterials {
792 pub fn total_parts(&self) -> usize {
794 self.items.iter().map(|i| i.quantity).sum()
795 }
796
797 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 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#[derive(Debug, Clone)]
820pub struct BomItem {
821 pub part_id: String,
823
824 pub name: String,
826
827 pub material: Option<String>,
829
830 pub quantity: usize,
832
833 pub dimensions: (f64, f64, f64),
835
836 pub bounding_volume: f64,
838
839 pub triangle_count: usize,
841
842 pub parent: Option<String>,
844
845 pub metadata: HashMap<String, String>,
847}
848
849const 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
857const 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
864fn 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
873fn transform_to_3mf_matrix(t: &Isometry3<f64>) -> String {
875 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
897fn escape_xml(s: &str) -> String {
899 s.replace('&', "&")
900 .replace('<', "<")
901 .replace('>', ">")
902 .replace('"', """)
903 .replace('\'', "'")
904}
905
906fn 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
915fn sanitize_filename(s: &str) -> String {
917 s.chars()
918 .map(|c| match c {
919 '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
920 _ => c,
921 })
922 .collect()
923}
924
925#[derive(Debug, Clone)]
927pub struct Part {
928 pub id: String,
930
931 pub mesh: Mesh,
933
934 pub transform: Isometry3<f64>,
936
937 pub parent_id: Option<String>,
939
940 pub metadata: HashMap<String, String>,
942
943 pub material: Option<String>,
945
946 pub visible: bool,
948}
949
950impl Part {
951 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 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 pub fn with_transform(mut self, transform: Isometry3<f64>) -> Self {
972 self.transform = transform;
973 self
974 }
975
976 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 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 pub fn with_material(mut self, material: impl Into<String>) -> Self {
992 self.material = Some(material.into());
993 self
994 }
995
996 pub fn with_visible(mut self, visible: bool) -> Self {
998 self.visible = visible;
999 self
1000 }
1001
1002 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 pub fn bounding_box(&self) -> (Point3<f64>, Point3<f64>) {
1010 compute_bbox(&self.mesh)
1011 }
1012}
1013
1014#[derive(Debug, Clone)]
1016pub struct Connection {
1017 pub from_part: String,
1019
1020 pub to_part: String,
1022
1023 pub connection_type: ConnectionType,
1025
1026 pub params: ConnectionParams,
1028
1029 pub name: Option<String>,
1031}
1032
1033impl Connection {
1034 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 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 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 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 pub fn with_name(mut self, name: impl Into<String>) -> Self {
1078 self.name = Some(name.into());
1079 self
1080 }
1081}
1082
1083#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1085pub enum ConnectionType {
1086 SnapFit,
1088
1089 PressFit,
1091
1092 Clearance,
1094
1095 Adhesive,
1097
1098 Threaded,
1100
1101 Sliding,
1103
1104 Custom,
1106}
1107
1108#[derive(Debug, Clone, Default)]
1110pub struct ConnectionParams {
1111 pub interference: Option<f64>,
1113
1114 pub clearance: Option<f64>,
1116
1117 pub snap_height: Option<f64>,
1119
1120 pub undercut_angle: Option<f64>,
1122
1123 pub location: Option<Point3<f64>>,
1125
1126 pub custom: HashMap<String, String>,
1128}
1129
1130#[derive(Debug, Clone, Default)]
1132pub struct AssemblyValidation {
1133 pub orphan_references: Vec<(String, String)>,
1135
1136 pub circular_references: Vec<String>,
1138
1139 pub invalid_connections: Vec<(Connection, String)>,
1141}
1142
1143impl AssemblyValidation {
1144 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#[derive(Debug, Clone)]
1154pub struct InterferenceResult {
1155 pub has_interference: bool,
1157
1158 pub overlap_volume: f64,
1160
1161 pub min_clearance: Option<f64>,
1163}
1164
1165#[derive(Debug, Clone)]
1167pub struct ClearanceResult {
1168 pub meets_requirement: bool,
1170
1171 pub actual_clearance: f64,
1173
1174 pub required_clearance: f64,
1176}
1177
1178fn 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
1199fn 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
1212fn 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); assert_eq!(merged.faces.len(), 2); }
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 assert!((item.dimensions.0 - 1.0).abs() < 1e-6); assert!((item.dimensions.1 - 1.0).abs() < 1e-6); assert!(item.dimensions.2 < 1e-6); 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 assembly.save_3mf(&path).unwrap();
1496 assert!(path.exists());
1497
1498 let file = std::fs::File::open(&path).unwrap();
1500 let mut archive = zip::ZipArchive::new(file).unwrap();
1501
1502 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 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 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 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 let base_path = temp_dir.join("assembly.stl");
1538 assembly.save_stl_separate(&base_path).unwrap();
1539
1540 assert!(temp_dir.join("assembly_part1.stl").exists());
1542 assert!(temp_dir.join("assembly_part2.stl").exists());
1543
1544 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 assert!(content.contains("Part ID,Material,Quantity"));
1568 assert!(content.contains("part1"));
1570 assert!(content.contains("part2"));
1571 assert!(content.contains("PLA"));
1572 assert!(content.contains("TPU"));
1573
1574 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 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 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 let identity = Isometry3::identity();
1617 assert!(is_identity_transform(&identity));
1618
1619 let translated = Isometry3::translation(1.0, 0.0, 0.0);
1621 assert!(!is_identity_transform(&translated));
1622
1623 let matrix_str = transform_to_3mf_matrix(&translated);
1625 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 assert_eq!(escape_xml("a < b"), "a < b");
1634 assert_eq!(escape_xml("a & b"), "a & b");
1635 assert_eq!(escape_xml("\"test\""), ""test"");
1636
1637 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}