Skip to main content

lib3mf_core/model/
stats_impl.rs

1use crate::archive::ArchiveReader;
2use crate::error::Result;
3use crate::model::stats::{
4    DisplacementStats, FilamentInfo, GeometryStats, MaterialsStats, ModelStats, ProductionStats,
5    VendorData,
6};
7use crate::model::{Geometry, Model};
8
9impl Model {
10    /// Computes comprehensive statistics about the model, including geometry, materials, and vendor data.
11    pub fn compute_stats(&self, archiver: &mut impl ArchiveReader) -> Result<ModelStats> {
12        let mut resolver = crate::model::resolver::PartResolver::new(archiver, self.clone());
13        let mut geom_stats = GeometryStats::default();
14
15        // 1. Process Build Items (Entry points)
16        for item in &self.build.items {
17            geom_stats.instance_count += 1;
18            self.accumulate_object_stats(
19                item.object_id,
20                item.path.as_deref(),
21                item.transform,
22                &mut resolver,
23                &mut geom_stats,
24            )?;
25        }
26
27        // 2. Production Stats
28        let prod_stats = ProductionStats {
29            uuid_count: 0, // Placeholder
30        };
31
32        // 3. Vendor Data (Bambu Studio / OrcaSlicer)
33        let mut vendor_data = VendorData::default();
34        let generator = self.metadata.get("Application").cloned();
35
36        let is_bambu = generator
37            .as_ref()
38            .is_some_and(|app| app.contains("Bambu") || app.contains("Orca"));
39
40        if is_bambu {
41            let archive = resolver.archive_mut();
42
43            // 3a. Parse slice_info.config (slicer version, printer model, filaments, print time, warnings)
44            if archive.entry_exists("Metadata/slice_info.config")
45                && let Ok(content) = archive.read_entry("Metadata/slice_info.config")
46                && let Ok(slice_info) = crate::parser::bambu_config::parse_slice_info(&content)
47            {
48                vendor_data.slicer_version = slice_info.client_version.as_ref().map(|v| {
49                    let client = slice_info.client_type.as_deref().unwrap_or("BambuStudio");
50                    format!("{}-{}", client.replace(' ', ""), v)
51                });
52
53                // Aggregate print time and filaments across all plates
54                let mut total_time_secs: u32 = 0;
55                for plate in &slice_info.plates {
56                    if let Some(pred) = plate.prediction {
57                        total_time_secs += pred;
58                    }
59                    // Collect slicer warnings
60                    for w in &plate.warnings {
61                        vendor_data.slicer_warnings.push(w.clone());
62                    }
63                }
64
65                if total_time_secs > 0 {
66                    vendor_data.print_time_estimate = Some(format_duration(total_time_secs));
67                }
68
69                // Filaments from first plate (they are per-plate but typically same)
70                if let Some(first_plate) = slice_info.plates.first() {
71                    for f in &first_plate.filaments {
72                        vendor_data.filaments.push(FilamentInfo {
73                            id: f.id,
74                            tray_info_idx: f.tray_info_idx.clone(),
75                            type_: f.type_.clone().unwrap_or_default(),
76                            color: f.color.clone(),
77                            used_m: f.used_m,
78                            used_g: f.used_g,
79                        });
80                    }
81                }
82            }
83
84            // 3b. Parse model_settings.config (plates, objects, assembly)
85            if archive.entry_exists("Metadata/model_settings.config")
86                && let Ok(content) = archive.read_entry("Metadata/model_settings.config")
87                && let Ok(data) = crate::parser::bambu_config::parse_model_settings(&content)
88            {
89                vendor_data.plates = data.plates;
90                vendor_data.object_metadata = data.objects;
91                vendor_data.assembly_info = data.assembly;
92            }
93
94            // 3c. Parse project_settings.config (printer model, bed type, layer height, etc.)
95            if archive.entry_exists("Metadata/project_settings.config")
96                && let Ok(content) = archive.read_entry("Metadata/project_settings.config")
97                && let Ok(settings) = crate::parser::bambu_config::parse_project_settings(&content)
98            {
99                // Use project settings for printer model if not already set from slice_info
100                if vendor_data.printer_model.is_none() {
101                    vendor_data.printer_model = settings
102                        .printer_inherits
103                        .clone()
104                        .or_else(|| settings.printer_model.clone());
105                }
106                if vendor_data.nozzle_diameter.is_none() {
107                    vendor_data.nozzle_diameter = settings.nozzle_diameter.first().copied();
108                }
109                vendor_data.project_settings = Some(settings);
110            }
111
112            // 3d. Parse per-profile configs (filament_settings_N.config, machine_settings_N.config, process_settings_N.config)
113            for config_type in &["filament", "machine", "process"] {
114                for n in 0u32..16 {
115                    let path = format!("Metadata/{}_settings_{}.config", config_type, n);
116                    if archive.entry_exists(&path)
117                        && let Ok(content) = archive.read_entry(&path)
118                        && let Ok(config) = crate::parser::bambu_config::parse_profile_config(
119                            &content,
120                            config_type,
121                            n,
122                        )
123                    {
124                        vendor_data.profile_configs.push(config);
125                    }
126                }
127            }
128
129            // Try to get printer model from machine profile if still not set
130            if vendor_data.printer_model.is_none()
131                && let Some(machine_config) = vendor_data
132                    .profile_configs
133                    .iter()
134                    .find(|c| c.config_type == "machine")
135            {
136                vendor_data.printer_model = machine_config
137                    .inherits
138                    .clone()
139                    .or_else(|| machine_config.name.clone());
140            }
141
142            // 3e. Read OPC relationships and identify Bambu-specific entries
143            if archive.entry_exists("_rels/.rels")
144                && let Ok(rels_data) = archive.read_entry("_rels/.rels")
145                && let Ok(rels) = crate::archive::opc::parse_relationships(&rels_data)
146            {
147                use crate::archive::opc::bambu_rel_types;
148                for rel in &rels {
149                    match rel.rel_type.as_str() {
150                        bambu_rel_types::COVER_THUMBNAIL_MIDDLE
151                        | bambu_rel_types::COVER_THUMBNAIL_SMALL => {
152                            if vendor_data.bambu_cover_thumbnail.is_none() {
153                                vendor_data.bambu_cover_thumbnail = Some(rel.target.clone());
154                            }
155                        }
156                        bambu_rel_types::GCODE => {
157                            vendor_data.bambu_gcode = Some(rel.target.clone());
158                        }
159                        _ => {}
160                    }
161                }
162            }
163        }
164
165        // 4. Material Stats
166        let materials_stats = MaterialsStats {
167            base_materials_count: self.resources.base_material_groups_count(),
168            color_groups_count: self.resources.color_groups_count(),
169            texture_2d_groups_count: self.resources.texture_2d_groups_count(),
170            composite_materials_count: self.resources.composite_materials_count(),
171            multi_properties_count: self.resources.multi_properties_count(),
172        };
173
174        // 5. Displacement Stats
175        let displacement_stats = self.compute_displacement_stats();
176
177        // 6. Thumbnails
178        // Check archiver for package thumbnail (attachments may not be loaded)
179        let pkg_thumb = archiver.entry_exists("Metadata/thumbnail.png")
180            || archiver.entry_exists("/Metadata/thumbnail.png");
181        let obj_thumb_count = self
182            .resources
183            .iter_objects()
184            .filter(|o| o.thumbnail.is_some())
185            .count();
186
187        Ok(ModelStats {
188            unit: self.unit,
189            generator,
190            metadata: self.metadata.clone(),
191            geometry: geom_stats,
192            materials: materials_stats,
193            production: prod_stats,
194            displacement: displacement_stats,
195            vendor: vendor_data,
196            system_info: crate::utils::hardware::detect_capabilities(),
197            thumbnails: crate::model::stats::ThumbnailStats {
198                package_thumbnail_present: pkg_thumb,
199                object_thumbnail_count: obj_thumb_count,
200            },
201        })
202    }
203
204    fn compute_displacement_stats(&self) -> DisplacementStats {
205        let mut stats = DisplacementStats {
206            texture_count: self.resources.displacement_2d_count(),
207            ..Default::default()
208        };
209
210        // Count DisplacementMesh objects and accumulate metrics
211        for obj in self.resources.iter_objects() {
212            if let Geometry::DisplacementMesh(dmesh) = &obj.geometry {
213                stats.mesh_count += 1;
214                stats.normal_count += dmesh.normals.len() as u64;
215                stats.gradient_count += dmesh.gradients.as_ref().map_or(0, |g| g.len() as u64);
216                stats.total_triangle_count += dmesh.triangles.len() as u64;
217
218                // Count triangles with displacement indices
219                for tri in &dmesh.triangles {
220                    if tri.d1.is_some() || tri.d2.is_some() || tri.d3.is_some() {
221                        stats.displaced_triangle_count += 1;
222                    }
223                }
224            }
225        }
226
227        stats
228    }
229
230    fn accumulate_object_stats(
231        &self,
232        id: crate::model::ResourceId,
233        path: Option<&str>,
234        transform: glam::Mat4,
235        resolver: &mut crate::model::resolver::PartResolver<impl ArchiveReader>,
236        stats: &mut GeometryStats,
237    ) -> Result<()> {
238        let (geom, path_to_use, obj_type) = {
239            let resolved = resolver.resolve_object(id, path)?;
240            if let Some((_model, object)) = resolved {
241                // Determine the next path to use for children.
242                // If this object was found in a specific path, children inherit it
243                // UNLESS they specify their own.
244                let current_path = if path.is_none()
245                    || path == Some("ROOT")
246                    || path == Some("/3D/3dmodel.model")
247                    || path == Some("3D/3dmodel.model")
248                {
249                    None
250                } else {
251                    path
252                };
253                (
254                    Some(object.geometry.clone()),
255                    current_path.map(|s| s.to_string()),
256                    Some(object.object_type),
257                )
258            } else {
259                (None, None, None)
260            }
261        };
262
263        if let Some(geometry) = geom {
264            // Count object by type
265            if let Some(ot) = obj_type {
266                *stats.type_counts.entry(ot.to_string()).or_insert(0) += 1;
267            }
268
269            match geometry {
270                Geometry::Mesh(mesh) => {
271                    stats.object_count += 1;
272                    stats.vertex_count += mesh.vertices.len() as u64;
273                    stats.triangle_count += mesh.triangles.len() as u64;
274
275                    if let Some(mesh_aabb) = mesh.compute_aabb() {
276                        let transformed_aabb = mesh_aabb.transform(transform);
277                        if let Some(total_aabb) = &mut stats.bounding_box {
278                            total_aabb.min[0] = total_aabb.min[0].min(transformed_aabb.min[0]);
279                            total_aabb.min[1] = total_aabb.min[1].min(transformed_aabb.min[1]);
280                            total_aabb.min[2] = total_aabb.min[2].min(transformed_aabb.min[2]);
281                            total_aabb.max[0] = total_aabb.max[0].max(transformed_aabb.max[0]);
282                            total_aabb.max[1] = total_aabb.max[1].max(transformed_aabb.max[1]);
283                            total_aabb.max[2] = total_aabb.max[2].max(transformed_aabb.max[2]);
284                        } else {
285                            stats.bounding_box = Some(transformed_aabb);
286                        }
287                    }
288
289                    let (area, volume) = mesh.compute_area_and_volume();
290                    let scale_det = transform.determinant().abs() as f64;
291                    let area_scale = scale_det.powf(2.0 / 3.0);
292                    stats.surface_area += area * area_scale;
293                    stats.volume += volume * scale_det;
294                }
295                Geometry::Components(comps) => {
296                    for comp in comps.components {
297                        // Priority:
298                        // 1. component's own path
299                        // 2. path inherited from parent (path_to_use)
300                        let next_path = comp.path.as_deref().or(path_to_use.as_deref());
301
302                        self.accumulate_object_stats(
303                            comp.object_id,
304                            next_path,
305                            transform * comp.transform,
306                            resolver,
307                            stats,
308                        )?;
309                    }
310                }
311                _ => {}
312            }
313        }
314        Ok(())
315    }
316}
317
318/// Format seconds as human-readable duration (e.g., "31m 35s", "2h 15m 3s").
319pub fn format_duration(total_secs: u32) -> String {
320    let hours = total_secs / 3600;
321    let minutes = (total_secs % 3600) / 60;
322    let seconds = total_secs % 60;
323
324    if hours > 0 {
325        if seconds > 0 {
326            format!("{}h {}m {}s", hours, minutes, seconds)
327        } else if minutes > 0 {
328            format!("{}h {}m", hours, minutes)
329        } else {
330            format!("{}h", hours)
331        }
332    } else if minutes > 0 {
333        if seconds > 0 {
334            format!("{}m {}s", minutes, seconds)
335        } else {
336            format!("{}m", minutes)
337        }
338    } else {
339        format!("{}s", seconds)
340    }
341}
342
343#[cfg(test)]
344mod tests {
345    use super::format_duration;
346
347    #[test]
348    fn test_format_duration() {
349        assert_eq!(format_duration(0), "0s");
350        assert_eq!(format_duration(45), "45s");
351        assert_eq!(format_duration(60), "1m");
352        assert_eq!(format_duration(61), "1m 1s");
353        assert_eq!(format_duration(1895), "31m 35s");
354        assert_eq!(format_duration(3600), "1h");
355        assert_eq!(format_duration(3660), "1h 1m");
356        assert_eq!(format_duration(3661), "1h 1m 1s");
357        assert_eq!(format_duration(7200), "2h");
358    }
359}