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