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> {
12 let mut resolver = crate::model::resolver::PartResolver::new(archiver, self.clone());
13 let mut geom_stats = GeometryStats::default();
14
15 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 let prod_stats = ProductionStats {
29 uuid_count: 0, };
31
32 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 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 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 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 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 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 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 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 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 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 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 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 let displacement_stats = self.compute_displacement_stats();
176
177 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 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 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 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 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 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
318pub 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}