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 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 let prod_stats = ProductionStats {
28 uuid_count: 0, };
30
31 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 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 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 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 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 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 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 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 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 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 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 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 let displacement_stats = self.compute_displacement_stats();
175
176 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 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 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 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 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 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
317pub 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}