1pub mod batch;
7pub mod merge;
8pub mod split;
9pub mod thumbnails;
11
12use clap::ValueEnum;
13use lib3mf_core::archive::{ArchiveReader, ZipArchiver, find_model_path, opc};
14use lib3mf_core::parser::parse_model;
15use serde::Serialize;
16use std::collections::BTreeMap;
17use std::fs::File;
18use std::io::{Read, Seek, Write};
19use std::path::PathBuf;
20
21#[derive(Clone, ValueEnum, Debug, PartialEq)]
25pub enum OutputFormat {
26 Text,
28 Json,
30 Tree,
32}
33
34#[derive(Clone, ValueEnum, Debug, PartialEq, Copy)]
38pub enum RepairType {
39 Degenerate,
41 Duplicates,
43 Harmonize,
45 Islands,
47 Holes,
49 All,
51}
52
53enum ModelSource {
54 Archive(ZipArchiver<File>, lib3mf_core::model::Model),
55 Raw(lib3mf_core::model::Model),
56}
57
58fn open_model(path: &PathBuf) -> anyhow::Result<ModelSource> {
59 let mut file =
60 File::open(path).map_err(|e| anyhow::anyhow!("Failed to open file {:?}: {}", path, e))?;
61
62 let mut magic = [0u8; 4];
63 let is_zip = file.read_exact(&mut magic).is_ok() && &magic == b"PK\x03\x04";
64 file.rewind()?;
65
66 if is_zip {
67 let mut archiver = ZipArchiver::new(file)
68 .map_err(|e| anyhow::anyhow!("Failed to open zip archive: {}", e))?;
69 let model_path = find_model_path(&mut archiver)
70 .map_err(|e| anyhow::anyhow!("Failed to find model path: {}", e))?;
71 let model_data = archiver
72 .read_entry(&model_path)
73 .map_err(|e| anyhow::anyhow!("Failed to read model data: {}", e))?;
74 let model = parse_model(std::io::Cursor::new(model_data))
75 .map_err(|e| anyhow::anyhow!("Failed to parse model XML: {}", e))?;
76 Ok(ModelSource::Archive(archiver, model))
77 } else {
78 let ext = path
79 .extension()
80 .and_then(|s| s.to_str())
81 .unwrap_or("")
82 .to_lowercase();
83
84 match ext.as_str() {
85 "stl" => {
86 let model = lib3mf_converters::stl::StlImporter::read(file)
87 .map_err(|e| anyhow::anyhow!("Failed to import STL: {}", e))?;
88 Ok(ModelSource::Raw(model))
89 }
90 "obj" => {
91 let model = lib3mf_converters::obj::ObjImporter::read_from_path(path)
92 .map_err(|e| anyhow::anyhow!("Failed to import OBJ: {}", e))?;
93 Ok(ModelSource::Raw(model))
94 }
95 _ => Err(anyhow::anyhow!(
96 "Unsupported format: {} (and not a ZIP/3MF archive)",
97 ext
98 )),
99 }
100 }
101}
102
103pub fn stats(path: PathBuf, format: OutputFormat) -> anyhow::Result<()> {
129 let mut source = open_model(&path)?;
130 let stats = match source {
131 ModelSource::Archive(ref mut archiver, ref model) => model
132 .compute_stats(archiver)
133 .map_err(|e| anyhow::anyhow!("Failed to compute stats: {}", e))?,
134 ModelSource::Raw(ref model) => {
135 struct NoArchive;
136 impl std::io::Read for NoArchive {
137 fn read(&mut self, _: &mut [u8]) -> std::io::Result<usize> {
138 Ok(0)
139 }
140 }
141 impl std::io::Seek for NoArchive {
142 fn seek(&mut self, _: std::io::SeekFrom) -> std::io::Result<u64> {
143 Ok(0)
144 }
145 }
146 impl lib3mf_core::archive::ArchiveReader for NoArchive {
147 fn read_entry(&mut self, _: &str) -> lib3mf_core::error::Result<Vec<u8>> {
148 Err(lib3mf_core::error::Lib3mfError::Io(std::io::Error::new(
149 std::io::ErrorKind::NotFound,
150 "Raw format",
151 )))
152 }
153 fn entry_exists(&mut self, _: &str) -> bool {
154 false
155 }
156 fn list_entries(&mut self) -> lib3mf_core::error::Result<Vec<String>> {
157 Ok(vec![])
158 }
159 }
160 model
161 .compute_stats(&mut NoArchive)
162 .map_err(|e| anyhow::anyhow!("Failed to compute stats: {}", e))?
163 }
164 };
165
166 match format {
167 OutputFormat::Json => {
168 println!("{}", serde_json::to_string_pretty(&stats)?);
169 }
170 OutputFormat::Tree => {
171 println!("Model Hierarchy for {:?}", path);
172 match source {
173 ModelSource::Archive(mut archiver, model) => {
174 let mut resolver =
175 lib3mf_core::model::resolver::PartResolver::new(&mut archiver, model);
176 print_model_hierarchy_resolved(&mut resolver);
177 }
178 ModelSource::Raw(model) => {
179 print_model_hierarchy(&model);
180 }
181 }
182 }
183 _ => {
184 println!("Stats for {:?}", path);
185 println!(
186 "Unit: {:?} (Scale: {} m)",
187 stats.unit,
188 stats.unit.scale_factor()
189 );
190 println!("Generator: {:?}", stats.generator.unwrap_or_default());
191 println!("Geometry:");
192
193 let type_display: Vec<String> =
195 ["model", "support", "solidsupport", "surface", "other"]
196 .iter()
197 .filter_map(|&type_name| {
198 stats
199 .geometry
200 .type_counts
201 .get(type_name)
202 .and_then(|&count| {
203 if count > 0 {
204 Some(format!("{} {}", count, type_name))
205 } else {
206 None
207 }
208 })
209 })
210 .collect();
211
212 if type_display.is_empty() {
213 println!(" Objects: 0");
214 } else {
215 println!(" Objects: {}", type_display.join(", "));
216 }
217
218 println!(" Instances: {}", stats.geometry.instance_count);
219 println!(" Vertices: {}", stats.geometry.vertex_count);
220 println!(" Triangles: {}", stats.geometry.triangle_count);
221 if let Some(bbox) = stats.geometry.bounding_box {
222 println!(" Bounding Box: Min {:?}, Max {:?}", bbox.min, bbox.max);
223 }
224 let scale = stats.unit.scale_factor();
225 println!(
226 " Surface Area: {:.2} (native units^2)",
227 stats.geometry.surface_area
228 );
229 println!(
230 " {:.6} m^2",
231 stats.geometry.surface_area * scale * scale
232 );
233 println!(
234 " Volume: {:.2} (native units^3)",
235 stats.geometry.volume
236 );
237 println!(
238 " {:.6} m^3",
239 stats.geometry.volume * scale * scale * scale
240 );
241
242 println!("\nSystem Info:");
243 println!(" Architecture: {}", stats.system_info.architecture);
244 println!(" CPUs (Threads): {}", stats.system_info.num_cpus);
245 println!(
246 " SIMD Features: {}",
247 stats.system_info.simd_features.join(", ")
248 );
249
250 println!("Materials:");
251 println!(" Base Groups: {}", stats.materials.base_materials_count);
252 println!(" Color Groups: {}", stats.materials.color_groups_count);
253 println!(
254 " Texture 2D Groups: {}",
255 stats.materials.texture_2d_groups_count
256 );
257 println!(
258 " Composite Materials: {}",
259 stats.materials.composite_materials_count
260 );
261 println!(
262 " Multi Properties: {}",
263 stats.materials.multi_properties_count
264 );
265
266 let has_bambu = stats.vendor.printer_model.is_some()
268 || !stats.vendor.plates.is_empty()
269 || !stats.vendor.filaments.is_empty()
270 || stats.vendor.slicer_version.is_some();
271
272 if has_bambu {
273 println!("\nVendor Data (Bambu Studio):");
274
275 if let Some(ref version) = stats.vendor.slicer_version {
276 println!(" Slicer: {}", version);
277 }
278
279 if let Some(ref model) = stats.vendor.printer_model {
281 let nozzle_str = stats
282 .vendor
283 .nozzle_diameter
284 .map(|d| format!(" -- {}mm nozzle", d))
285 .unwrap_or_default();
286 println!(" Printer: {}{}", model, nozzle_str);
287 }
288
289 if let Some(ref ps) = stats.vendor.project_settings {
291 if let Some(ref bed) = ps.bed_type {
292 println!(" Bed Type: {}", bed);
293 }
294 if let Some(lh) = ps.layer_height {
295 println!(" Layer Height: {}mm", lh);
296 }
297 }
298
299 if let Some(ref time) = stats.vendor.print_time_estimate {
301 println!(" Print Time: {}", time);
302 }
303
304 let total_g: f32 = stats.vendor.filaments.iter().filter_map(|f| f.used_g).sum();
306 if total_g > 0.0 {
307 println!(" Total Weight: {:.2}g", total_g);
308 }
309
310 if !stats.vendor.filaments.is_empty() {
312 println!("\n Filaments:");
313 println!(
314 " {:>3} {:<6} {:<9} {:>6} {:>6}",
315 "ID", "Type", "Color", "Meters", "Grams"
316 );
317 for f in &stats.vendor.filaments {
318 println!(
319 " {:>3} {:<6} {:<9} {:>6} {:>6}",
320 f.id,
321 &f.type_,
322 f.color.as_deref().unwrap_or("-"),
323 f.used_m
324 .map(|v| format!("{:.2}", v))
325 .unwrap_or_else(|| "-".to_string()),
326 f.used_g
327 .map(|v| format!("{:.2}", v))
328 .unwrap_or_else(|| "-".to_string()),
329 );
330 }
331 }
332
333 if !stats.vendor.plates.is_empty() {
335 println!("\n Plates:");
336 for plate in &stats.vendor.plates {
337 let name = plate.name.as_deref().unwrap_or("[unnamed]");
338 let locked_str = if plate.locked { " [locked]" } else { "" };
339 println!(" Plate {}: {}{}", plate.id, name, locked_str);
340
341 if !plate.items.is_empty() {
343 let obj_ids: Vec<String> = plate
344 .items
345 .iter()
346 .map(|item| {
347 let name = stats
349 .vendor
350 .object_metadata
351 .iter()
352 .find(|o| o.id == item.object_id)
353 .and_then(|o| o.name.as_deref());
354 match name {
355 Some(n) => format!("{} (ID {})", n, item.object_id),
356 None => format!("ID {}", item.object_id),
357 }
358 })
359 .collect();
360 println!(" Objects: {}", obj_ids.join(", "));
361 }
362 }
363 }
364
365 if !stats.vendor.slicer_warnings.is_empty() {
367 println!("\n Slicer Warnings:");
368 for (i, w) in stats.vendor.slicer_warnings.iter().enumerate() {
369 let code = w.error_code.as_deref().unwrap_or("");
370 if code.is_empty() {
371 println!(" [{}] {}", i + 1, w.msg);
372 } else {
373 println!(" [{}] {} ({})", i + 1, w.msg, code);
374 }
375 }
376 }
377 }
378
379 println!("Thumbnails:");
380 println!(
381 " Package Thumbnail: {}",
382 if stats.thumbnails.package_thumbnail_present {
383 "Yes"
384 } else {
385 "No"
386 }
387 );
388 println!(
389 " Object Thumbnails: {}",
390 stats.thumbnails.object_thumbnail_count
391 );
392
393 if stats.displacement.mesh_count > 0 || stats.displacement.texture_count > 0 {
395 println!("\nDisplacement:");
396 println!(" Meshes: {}", stats.displacement.mesh_count);
397 println!(" Textures: {}", stats.displacement.texture_count);
398 if stats.displacement.normal_count > 0 {
399 println!(" Vertex Normals: {}", stats.displacement.normal_count);
400 }
401 if stats.displacement.gradient_count > 0 {
402 println!(" Gradient Vectors: {}", stats.displacement.gradient_count);
403 }
404 if stats.displacement.total_triangle_count > 0 {
405 let coverage = 100.0 * stats.displacement.displaced_triangle_count as f64
406 / stats.displacement.total_triangle_count as f64;
407 println!(
408 " Displaced Triangles: {} of {} ({:.1}%)",
409 stats.displacement.displaced_triangle_count,
410 stats.displacement.total_triangle_count,
411 coverage
412 );
413 }
414 }
415 }
416 }
417 Ok(())
418}
419
420pub fn list(path: PathBuf, format: OutputFormat) -> anyhow::Result<()> {
433 let source = open_model(&path)?;
434
435 let entries = match source {
436 ModelSource::Archive(mut archiver, _) => archiver
437 .list_entries()
438 .map_err(|e| anyhow::anyhow!("Failed to list entries: {}", e))?,
439 ModelSource::Raw(_) => vec![
440 path.file_name()
441 .and_then(|n| n.to_str())
442 .unwrap_or("model")
443 .to_string(),
444 ],
445 };
446
447 match format {
448 OutputFormat::Json => {
449 let tree = build_file_tree(&entries);
450 println!("{}", serde_json::to_string_pretty(&tree)?);
451 }
452 OutputFormat::Tree => {
453 print_tree(&entries);
454 }
455 OutputFormat::Text => {
456 for entry in entries {
457 println!("{}", entry);
458 }
459 }
460 }
461 Ok(())
462}
463
464pub fn rels(path: PathBuf, format: OutputFormat) -> anyhow::Result<()> {
478 let mut archiver = open_archive(&path)?;
479
480 let rels_data = archiver.read_entry("_rels/.rels").unwrap_or_default();
482 let rels = if !rels_data.is_empty() {
483 opc::parse_relationships(&rels_data).unwrap_or_default()
484 } else {
485 Vec::new()
486 };
487
488 let types_data = archiver
490 .read_entry("[Content_Types].xml")
491 .unwrap_or_default();
492 let types = if !types_data.is_empty() {
493 opc::parse_content_types(&types_data).unwrap_or_default()
494 } else {
495 Vec::new()
496 };
497
498 match format {
499 OutputFormat::Json => {
500 #[derive(Serialize)]
501 struct OpcData {
502 relationships: Vec<lib3mf_core::archive::opc::Relationship>,
503 content_types: Vec<lib3mf_core::archive::opc::ContentType>,
504 }
505 let data = OpcData {
506 relationships: rels,
507 content_types: types,
508 };
509 println!("{}", serde_json::to_string_pretty(&data)?);
510 }
511 _ => {
512 println!("Relationships:");
513 for rel in rels {
514 println!(
515 " - ID: {}, Type: {}, Target: {}",
516 rel.id, rel.rel_type, rel.target
517 );
518 }
519 println!("\nContent Types:");
520 for ct in types {
521 println!(" - {:?}", ct);
522 }
523 }
524 }
525 Ok(())
526}
527
528pub fn dump(path: PathBuf, format: OutputFormat) -> anyhow::Result<()> {
541 let mut archiver = open_archive(&path)?;
542 let model_path = find_model_path(&mut archiver)
543 .map_err(|e| anyhow::anyhow!("Failed to find model path: {}", e))?;
544 let model_data = archiver
545 .read_entry(&model_path)
546 .map_err(|e| anyhow::anyhow!("Failed to read model data: {}", e))?;
547 let model = parse_model(std::io::Cursor::new(model_data))
548 .map_err(|e| anyhow::anyhow!("Failed to parse model XML: {}", e))?;
549
550 match format {
551 OutputFormat::Json => {
552 println!("{}", serde_json::to_string_pretty(&model)?);
553 }
554 _ => {
555 println!("{:#?}", model);
556 }
557 }
558 Ok(())
559}
560
561pub fn extract(path: PathBuf, inner_path: String, output: Option<PathBuf>) -> anyhow::Result<()> {
575 let mut archiver = open_archive(&path)?;
576 let data = archiver
577 .read_entry(&inner_path)
578 .map_err(|e| anyhow::anyhow!("Failed to read entry '{}': {}", inner_path, e))?;
579
580 if let Some(out_path) = output {
581 let mut f = File::create(&out_path)
582 .map_err(|e| anyhow::anyhow!("Failed to create output file {:?}: {}", out_path, e))?;
583 f.write_all(&data)?;
584 println!("Extracted '{}' to {:?}", inner_path, out_path);
585 } else {
586 std::io::stdout().write_all(&data)?;
587 }
588 Ok(())
589}
590
591pub fn extract_by_resource_id(
605 path: PathBuf,
606 resource_id: u32,
607 output: Option<PathBuf>,
608) -> anyhow::Result<()> {
609 let mut archiver = open_archive(&path)?;
610 let model_path = find_model_path(&mut archiver)?;
611 let model_data = archiver.read_entry(&model_path)?;
612 let model = parse_model(std::io::Cursor::new(model_data))?;
613
614 let resource_id = lib3mf_core::model::ResourceId(resource_id);
615
616 if let Some(disp2d) = model.resources.get_displacement_2d(resource_id) {
618 let texture_path = &disp2d.path;
619 let archive_path = texture_path.trim_start_matches('/');
620 let data = archiver
621 .read_entry(archive_path)
622 .map_err(|e| anyhow::anyhow!("Failed to read texture '{}': {}", archive_path, e))?;
623
624 if let Some(out_path) = output {
625 let mut f = File::create(&out_path)?;
626 f.write_all(&data)?;
627 println!(
628 "Extracted displacement texture (ID {}) to {:?}",
629 resource_id.0, out_path
630 );
631 } else {
632 std::io::stdout().write_all(&data)?;
633 }
634 return Ok(());
635 }
636
637 Err(anyhow::anyhow!(
638 "No displacement texture resource found with ID {}",
639 resource_id.0
640 ))
641}
642
643pub fn copy(input: PathBuf, output: PathBuf) -> anyhow::Result<()> {
657 let mut archiver = open_archive(&input)?;
658 let model_path = find_model_path(&mut archiver)
659 .map_err(|e| anyhow::anyhow!("Failed to find model path: {}", e))?;
660 let model_data = archiver
661 .read_entry(&model_path)
662 .map_err(|e| anyhow::anyhow!("Failed to read model data: {}", e))?;
663 let mut model = parse_model(std::io::Cursor::new(model_data))
664 .map_err(|e| anyhow::anyhow!("Failed to parse model XML: {}", e))?;
665
666 let all_files = archiver.list_entries()?;
668 for entry_path in all_files {
669 if entry_path == model_path
671 || entry_path == "_rels/.rels"
672 || entry_path == "[Content_Types].xml"
673 {
674 continue;
675 }
676
677 if entry_path.ends_with(".rels") {
679 if let Ok(data) = archiver.read_entry(&entry_path)
680 && let Ok(rels) = lib3mf_core::archive::opc::parse_relationships(&data)
681 {
682 model.existing_relationships.insert(entry_path, rels);
683 }
684 continue;
685 }
686
687 if let Ok(data) = archiver.read_entry(&entry_path) {
689 model.attachments.insert(entry_path, data);
690 }
691 }
692
693 let file = File::create(&output)
694 .map_err(|e| anyhow::anyhow!("Failed to create output file: {}", e))?;
695 model
696 .write(file)
697 .map_err(|e| anyhow::anyhow!("Failed to write 3MF: {}", e))?;
698
699 println!("Copied {:?} to {:?}", input, output);
700 Ok(())
701}
702
703fn open_archive(path: &PathBuf) -> anyhow::Result<ZipArchiver<File>> {
704 let file =
705 File::open(path).map_err(|e| anyhow::anyhow!("Failed to open file {:?}: {}", path, e))?;
706 ZipArchiver::new(file).map_err(|e| anyhow::anyhow!("Failed to open zip archive: {}", e))
707}
708
709fn build_file_tree(paths: &[String]) -> node::FileNode {
710 let mut root = node::FileNode::new_dir();
711 for path in paths {
712 let parts: Vec<&str> = path.split('/').collect();
713 root.insert(&parts);
714 }
715 root
716}
717
718fn print_tree(paths: &[String]) {
719 let mut tree: BTreeMap<String, node::Node> = BTreeMap::new();
722
723 for path in paths {
724 let parts: Vec<&str> = path.split('/').collect();
725 let mut current_level = &mut tree;
726
727 for (i, part) in parts.iter().enumerate() {
728 let _is_file = i == parts.len() - 1;
729 let node = current_level
730 .entry(part.to_string())
731 .or_insert_with(node::Node::new);
732 current_level = &mut node.children;
733 }
734 }
735
736 node::print_nodes(&tree, "");
737}
738
739fn print_model_hierarchy(model: &lib3mf_core::model::Model) {
740 let mut tree: BTreeMap<String, node::Node> = BTreeMap::new();
741
742 for (i, item) in model.build.items.iter().enumerate() {
743 let (obj_name, obj_type) = model
744 .resources
745 .get_object(item.object_id)
746 .map(|obj| {
747 (
748 obj.name
749 .clone()
750 .unwrap_or_else(|| format!("Object {}", item.object_id.0)),
751 obj.object_type,
752 )
753 })
754 .unwrap_or_else(|| {
755 (
756 format!("Object {}", item.object_id.0),
757 lib3mf_core::model::ObjectType::Model,
758 )
759 });
760
761 let name = format!(
762 "Build Item {} [{}] (type: {}, ID: {})",
763 i + 1,
764 obj_name,
765 obj_type,
766 item.object_id.0
767 );
768 let node = tree.entry(name).or_insert_with(node::Node::new);
769
770 add_object_to_tree(model, item.object_id, node);
772 }
773
774 node::print_nodes(&tree, "");
775}
776
777fn add_object_to_tree(
778 model: &lib3mf_core::model::Model,
779 id: lib3mf_core::model::ResourceId,
780 parent: &mut node::Node,
781) {
782 if let Some(obj) = model.resources.get_object(id) {
783 match &obj.geometry {
784 lib3mf_core::model::Geometry::Mesh(mesh) => {
785 let info = format!(
786 "Mesh: {} vertices, {} triangles",
787 mesh.vertices.len(),
788 mesh.triangles.len()
789 );
790 parent.children.insert(info, node::Node::new());
791 }
792 lib3mf_core::model::Geometry::Components(comps) => {
793 for (i, comp) in comps.components.iter().enumerate() {
794 let child_obj_name = model
795 .resources
796 .get_object(comp.object_id)
797 .and_then(|obj| obj.name.clone())
798 .unwrap_or_else(|| format!("Object {}", comp.object_id.0));
799
800 let name = format!(
801 "Component {} [{}] (ID: {})",
802 i + 1,
803 child_obj_name,
804 comp.object_id.0
805 );
806 let node = parent.children.entry(name).or_insert_with(node::Node::new);
807 add_object_to_tree(model, comp.object_id, node);
808 }
809 }
810 _ => {
811 parent
812 .children
813 .insert("Unknown Geometry".to_string(), node::Node::new());
814 }
815 }
816 }
817}
818
819fn print_model_hierarchy_resolved<A: ArchiveReader>(
820 resolver: &mut lib3mf_core::model::resolver::PartResolver<A>,
821) {
822 let mut tree: BTreeMap<String, node::Node> = BTreeMap::new();
823
824 let build_items = resolver.get_root_model().build.items.clone();
825
826 for (i, item) in build_items.iter().enumerate() {
827 let (obj_name, obj_id, obj_type) = {
828 let res = resolver
829 .resolve_object(item.object_id, None)
830 .unwrap_or(None);
831 match res {
832 Some((_model, obj)) => (
833 obj.name
834 .clone()
835 .unwrap_or_else(|| format!("Object {}", obj.id.0)),
836 obj.id,
837 obj.object_type,
838 ),
839 None => (
840 format!("Missing Object {}", item.object_id.0),
841 item.object_id,
842 lib3mf_core::model::ObjectType::Model,
843 ),
844 }
845 };
846
847 let name = format!(
848 "Build Item {} [{}] (type: {}, ID: {})",
849 i + 1,
850 obj_name,
851 obj_type,
852 obj_id.0
853 );
854 let node = tree.entry(name).or_insert_with(node::Node::new);
855
856 add_object_to_tree_resolved(resolver, obj_id, None, node);
858 }
859
860 node::print_nodes(&tree, "");
861}
862
863fn add_object_to_tree_resolved<A: ArchiveReader>(
864 resolver: &mut lib3mf_core::model::resolver::PartResolver<A>,
865 id: lib3mf_core::model::ResourceId,
866 path: Option<&str>,
867 parent: &mut node::Node,
868) {
869 let components = {
870 let resolved = resolver.resolve_object(id, path).unwrap_or(None);
871 if let Some((_model, obj)) = resolved {
872 match &obj.geometry {
873 lib3mf_core::model::Geometry::Mesh(mesh) => {
874 let info = format!(
875 "Mesh: {} vertices, {} triangles",
876 mesh.vertices.len(),
877 mesh.triangles.len()
878 );
879 parent.children.insert(info, node::Node::new());
880 None
881 }
882 lib3mf_core::model::Geometry::Components(comps) => Some(comps.components.clone()),
883 _ => {
884 parent
885 .children
886 .insert("Unknown Geometry".to_string(), node::Node::new());
887 None
888 }
889 }
890 } else {
891 None
892 }
893 };
894
895 if let Some(comps) = components {
896 for (i, comp) in comps.iter().enumerate() {
897 let next_path = comp.path.as_deref().or(path);
898 let (child_obj_name, child_obj_id) = {
899 let res = resolver
900 .resolve_object(comp.object_id, next_path)
901 .unwrap_or(None);
902 match res {
903 Some((_model, obj)) => (
904 obj.name
905 .clone()
906 .unwrap_or_else(|| format!("Object {}", obj.id.0)),
907 obj.id,
908 ),
909 None => (
910 format!("Missing Object {}", comp.object_id.0),
911 comp.object_id,
912 ),
913 }
914 };
915
916 let name = format!(
917 "Component {} [{}] (ID: {})",
918 i + 1,
919 child_obj_name,
920 child_obj_id.0
921 );
922 let node = parent.children.entry(name).or_insert_with(node::Node::new);
923 add_object_to_tree_resolved(resolver, child_obj_id, next_path, node);
924 }
925 }
926}
927
928mod node {
929 use serde::Serialize;
930 use std::collections::BTreeMap;
931
932 #[derive(Serialize)]
933 #[serde(untagged)]
934 pub enum FileNode {
935 File(Empty),
936 Dir(BTreeMap<String, FileNode>),
937 }
938
939 #[derive(Serialize)]
940 pub struct Empty {}
941
942 impl FileNode {
943 pub fn new_dir() -> Self {
944 FileNode::Dir(BTreeMap::new())
945 }
946
947 pub fn new_file() -> Self {
948 FileNode::File(Empty {})
949 }
950
951 pub fn insert(&mut self, path_parts: &[&str]) {
952 if let FileNode::Dir(children) = self
953 && let Some((first, rest)) = path_parts.split_first()
954 {
955 let entry = children
956 .entry(first.to_string())
957 .or_insert_with(FileNode::new_dir);
958
959 if rest.is_empty() {
960 if let FileNode::Dir(sub) = entry {
962 if sub.is_empty() {
963 *entry = FileNode::new_file();
964 } else {
965 }
969 }
970 } else {
971 entry.insert(rest);
973 }
974 }
975 }
976 }
977
978 #[derive(Serialize)] pub struct Node {
982 pub children: BTreeMap<String, Node>,
983 }
984
985 impl Node {
986 pub fn new() -> Self {
987 Self {
988 children: BTreeMap::new(),
989 }
990 }
991 }
992
993 pub fn print_nodes(nodes: &BTreeMap<String, Node>, prefix: &str) {
994 let count = nodes.len();
995 for (i, (name, node)) in nodes.iter().enumerate() {
996 let is_last = i == count - 1;
997 let connector = if is_last { "└── " } else { "├── " };
998 println!("{}{}{}", prefix, connector, name);
999
1000 let child_prefix = if is_last { " " } else { "│ " };
1001 let new_prefix = format!("{}{}", prefix, child_prefix);
1002 print_nodes(&node.children, &new_prefix);
1003 }
1004 }
1005}
1006
1007pub fn convert(input: PathBuf, output: PathBuf, ascii: bool) -> anyhow::Result<()> {
1042 let output_ext = output
1043 .extension()
1044 .and_then(|e| e.to_str())
1045 .unwrap_or("")
1046 .to_lowercase();
1047
1048 if output_ext == "stl" || output_ext == "obj" {
1050 let file_res = File::open(&input);
1053
1054 let should_use_resolver = if let Ok(mut f) = file_res {
1055 let mut magic = [0u8; 4];
1056 f.read_exact(&mut magic).is_ok() && &magic == b"PK\x03\x04"
1057 } else {
1058 false
1059 };
1060
1061 if should_use_resolver {
1062 let mut archiver = open_archive(&input)?;
1063 let model_path = find_model_path(&mut archiver)
1064 .map_err(|e| anyhow::anyhow!("Failed to find model path: {}", e))?;
1065 let model_data = archiver
1066 .read_entry(&model_path)
1067 .map_err(|e| anyhow::anyhow!("Failed to read model data: {}", e))?;
1068 let model = parse_model(std::io::Cursor::new(model_data))
1069 .map_err(|e| anyhow::anyhow!("Failed to parse model XML: {}", e))?;
1070
1071 let resolver = lib3mf_core::model::resolver::PartResolver::new(&mut archiver, model);
1072 let file = File::create(&output)
1073 .map_err(|e| anyhow::anyhow!("Failed to create output file: {}", e))?;
1074
1075 let root_model = resolver.get_root_model().clone(); if output_ext == "obj" {
1079 lib3mf_converters::obj::ObjExporter::write_with_resolver(
1080 &root_model,
1081 resolver,
1082 file,
1083 )
1084 .map_err(|e| anyhow::anyhow!("Failed to export OBJ: {}", e))?;
1085 } else if ascii {
1086 lib3mf_converters::stl::AsciiStlExporter::write_with_resolver(
1087 &root_model,
1088 resolver,
1089 file,
1090 )
1091 .map_err(|e| anyhow::anyhow!("Failed to export ASCII STL: {}", e))?;
1092 } else {
1093 lib3mf_converters::stl::BinaryStlExporter::write_with_resolver(
1094 &root_model,
1095 resolver,
1096 file,
1097 )
1098 .map_err(|e| anyhow::anyhow!("Failed to export STL: {}", e))?;
1099 }
1100
1101 println!("Converted {:?} to {:?}", input, output);
1102 return Ok(());
1103 }
1104 }
1105
1106 let model = load_model(&input)?;
1109
1110 let file = File::create(&output)
1112 .map_err(|e| anyhow::anyhow!("Failed to create output file: {}", e))?;
1113
1114 match output_ext.as_str() {
1115 "3mf" => {
1116 model
1117 .write(file)
1118 .map_err(|e| anyhow::anyhow!("Failed to write 3MF: {}", e))?;
1119 }
1120 "stl" => {
1121 if ascii {
1122 lib3mf_converters::stl::AsciiStlExporter::write(&model, file)
1123 .map_err(|e| anyhow::anyhow!("Failed to export ASCII STL: {}", e))?;
1124 } else {
1125 lib3mf_converters::stl::BinaryStlExporter::write(&model, file)
1126 .map_err(|e| anyhow::anyhow!("Failed to export STL: {}", e))?;
1127 }
1128 }
1129 "obj" => {
1130 lib3mf_converters::obj::ObjExporter::write(&model, file)
1131 .map_err(|e| anyhow::anyhow!("Failed to export OBJ: {}", e))?;
1132 }
1133 _ => return Err(anyhow::anyhow!("Unsupported output format: {}", output_ext)),
1134 }
1135
1136 println!("Converted {:?} to {:?}", input, output);
1137 Ok(())
1138}
1139
1140pub fn validate(path: PathBuf, level: String) -> anyhow::Result<()> {
1161 use lib3mf_core::validation::{ValidationLevel, ValidationSeverity};
1162
1163 let level_enum = match level.to_lowercase().as_str() {
1164 "minimal" => ValidationLevel::Minimal,
1165 "standard" => ValidationLevel::Standard,
1166 "strict" => ValidationLevel::Strict,
1167 "paranoid" => ValidationLevel::Paranoid,
1168 _ => ValidationLevel::Standard,
1169 };
1170
1171 println!("Validating {:?} at {:?} level...", path, level_enum);
1172
1173 let model = load_model(&path)?;
1174
1175 let report = model.validate(level_enum);
1177
1178 let errors: Vec<_> = report
1179 .items
1180 .iter()
1181 .filter(|i| i.severity == ValidationSeverity::Error)
1182 .collect();
1183 let warnings: Vec<_> = report
1184 .items
1185 .iter()
1186 .filter(|i| i.severity == ValidationSeverity::Warning)
1187 .collect();
1188
1189 if !errors.is_empty() {
1190 println!("Validation Failed with {} error(s):", errors.len());
1191 for item in &errors {
1192 println!(" [ERROR {}] {}", item.code, item.message);
1193 }
1194 std::process::exit(1);
1195 } else if !warnings.is_empty() {
1196 println!("Validation Passed with {} warning(s):", warnings.len());
1197 for item in &warnings {
1198 println!(" [WARN {}] {}", item.code, item.message);
1199 }
1200 } else {
1201 println!("Validation Passed.");
1202 }
1203
1204 Ok(())
1205}
1206
1207pub fn repair(
1228 input: PathBuf,
1229 output: PathBuf,
1230 epsilon: f32,
1231 fixes: Vec<RepairType>,
1232) -> anyhow::Result<()> {
1233 use lib3mf_core::model::{Geometry, MeshRepair, RepairOptions};
1234
1235 println!("Repairing {:?} -> {:?}", input, output);
1236
1237 let mut archiver = open_archive(&input)?;
1238 let model_path = find_model_path(&mut archiver)
1239 .map_err(|e| anyhow::anyhow!("Failed to find model path: {}", e))?;
1240 let model_data = archiver
1241 .read_entry(&model_path)
1242 .map_err(|e| anyhow::anyhow!("Failed to read model data: {}", e))?;
1243 let mut model = parse_model(std::io::Cursor::new(model_data))
1244 .map_err(|e| anyhow::anyhow!("Failed to parse model XML: {}", e))?;
1245
1246 let mut options = RepairOptions {
1247 stitch_epsilon: epsilon,
1248 remove_degenerate: false,
1249 remove_duplicate_faces: false,
1250 harmonize_orientations: false,
1251 remove_islands: false,
1252 fill_holes: false,
1253 };
1254
1255 let has_all = fixes.contains(&RepairType::All);
1256 for fix in fixes {
1257 match fix {
1258 RepairType::Degenerate => options.remove_degenerate = true,
1259 RepairType::Duplicates => options.remove_duplicate_faces = true,
1260 RepairType::Harmonize => options.harmonize_orientations = true,
1261 RepairType::Islands => options.remove_islands = true,
1262 RepairType::Holes => options.fill_holes = true,
1263 RepairType::All => {
1264 options.remove_degenerate = true;
1265 options.remove_duplicate_faces = true;
1266 options.harmonize_orientations = true;
1267 options.remove_islands = true;
1268 options.fill_holes = true;
1269 }
1270 }
1271 }
1272
1273 if has_all {
1274 options.remove_degenerate = true;
1275 options.remove_duplicate_faces = true;
1276 options.harmonize_orientations = true;
1277 options.remove_islands = true;
1278 options.fill_holes = true;
1279 }
1280
1281 println!("Repair Options: {:?}", options);
1282
1283 let mut total_vertices_removed = 0;
1284 let mut total_triangles_removed = 0;
1285 let mut total_triangles_flipped = 0;
1286 let mut total_triangles_added = 0;
1287
1288 for object in model.resources.iter_objects_mut() {
1289 if let Geometry::Mesh(mesh) = &mut object.geometry {
1290 let stats = mesh.repair(options);
1291 if stats.vertices_removed > 0
1292 || stats.triangles_removed > 0
1293 || stats.triangles_flipped > 0
1294 || stats.triangles_added > 0
1295 {
1296 println!(
1297 "Repaired Object {}: Removed {} vertices, {} triangles. Flipped {}. Added {}.",
1298 object.id.0,
1299 stats.vertices_removed,
1300 stats.triangles_removed,
1301 stats.triangles_flipped,
1302 stats.triangles_added
1303 );
1304 total_vertices_removed += stats.vertices_removed;
1305 total_triangles_removed += stats.triangles_removed;
1306 total_triangles_flipped += stats.triangles_flipped;
1307 total_triangles_added += stats.triangles_added;
1308 }
1309 }
1310 }
1311
1312 println!("Total Repair Stats:");
1313 println!(" Vertices Removed: {}", total_vertices_removed);
1314 println!(" Triangles Removed: {}", total_triangles_removed);
1315 println!(" Triangles Flipped: {}", total_triangles_flipped);
1316 println!(" Triangles Added: {}", total_triangles_added);
1317
1318 let file = File::create(&output)
1320 .map_err(|e| anyhow::anyhow!("Failed to create output file: {}", e))?;
1321 model
1322 .write(file)
1323 .map_err(|e| anyhow::anyhow!("Failed to write 3MF: {}", e))?;
1324
1325 Ok(())
1326}
1327
1328pub fn benchmark(path: PathBuf) -> anyhow::Result<()> {
1341 use std::time::Instant;
1342
1343 println!("Benchmarking {:?}...", path);
1344
1345 let start = Instant::now();
1346 let mut archiver = open_archive(&path)?;
1347 let t_zip = start.elapsed();
1348
1349 let start_parse = Instant::now();
1350 let model_path = find_model_path(&mut archiver)
1351 .map_err(|e| anyhow::anyhow!("Failed to find model path: {}", e))?;
1352 let model_data = archiver
1353 .read_entry(&model_path)
1354 .map_err(|e| anyhow::anyhow!("Failed to read model data: {}", e))?;
1355 let model = parse_model(std::io::Cursor::new(model_data))
1356 .map_err(|e| anyhow::anyhow!("Failed to parse model XML: {}", e))?;
1357 let t_parse = start_parse.elapsed();
1358
1359 let start_stats = Instant::now();
1360 let stats = model
1361 .compute_stats(&mut archiver)
1362 .map_err(|e| anyhow::anyhow!("Failed to compute stats: {}", e))?;
1363 let t_stats = start_stats.elapsed();
1364
1365 let total = start.elapsed();
1366
1367 println!("Results:");
1368 println!(
1369 " System: {} ({} CPUs), SIMD: {}",
1370 stats.system_info.architecture,
1371 stats.system_info.num_cpus,
1372 stats.system_info.simd_features.join(", ")
1373 );
1374 println!(" Zip Open: {:?}", t_zip);
1375 println!(" XML Parse: {:?}", t_parse);
1376 println!(" Stats Calc: {:?}", t_stats);
1377 println!(" Total: {:?}", total);
1378 println!(" Triangles: {}", stats.geometry.triangle_count);
1379 println!(
1380 " Area: {:.2}, Volume: {:.2}",
1381 stats.geometry.surface_area, stats.geometry.volume
1382 );
1383
1384 Ok(())
1385}
1386
1387pub fn diff(file1: PathBuf, file2: PathBuf, format: &str) -> anyhow::Result<()> {
1402 println!("Comparing {:?} and {:?}...", file1, file2);
1403
1404 let model_a = load_model(&file1)?;
1405 let model_b = load_model(&file2)?;
1406
1407 let diff = lib3mf_core::utils::diff::compare_models(&model_a, &model_b);
1408
1409 if format == "json" {
1410 println!("{}", serde_json::to_string_pretty(&diff)?);
1411 } else if diff.is_empty() {
1412 println!("Models are identical.");
1413 } else {
1414 println!("Differences found:");
1415 if !diff.metadata_diffs.is_empty() {
1416 println!(" Metadata:");
1417 for d in &diff.metadata_diffs {
1418 println!(" - {:?}: {:?} -> {:?}", d.key, d.old_value, d.new_value);
1419 }
1420 }
1421 if !diff.resource_diffs.is_empty() {
1422 println!(" Resources:");
1423 for d in &diff.resource_diffs {
1424 match d {
1425 lib3mf_core::utils::diff::ResourceDiff::Added { id, type_name } => {
1426 println!(" + Added ID {}: {}", id, type_name)
1427 }
1428 lib3mf_core::utils::diff::ResourceDiff::Removed { id, type_name } => {
1429 println!(" - Removed ID {}: {}", id, type_name)
1430 }
1431 lib3mf_core::utils::diff::ResourceDiff::Changed { id, details } => {
1432 println!(" * Changed ID {}:", id);
1433 for det in details {
1434 println!(" . {}", det);
1435 }
1436 }
1437 }
1438 }
1439 }
1440 if !diff.build_diffs.is_empty() {
1441 println!(" Build Items:");
1442 for d in &diff.build_diffs {
1443 println!(" - {:?}", d);
1444 }
1445 }
1446 }
1447
1448 Ok(())
1449}
1450
1451fn load_model(path: &PathBuf) -> anyhow::Result<lib3mf_core::model::Model> {
1452 match open_model(path)? {
1453 ModelSource::Archive(_, model) => Ok(model),
1454 ModelSource::Raw(model) => Ok(model),
1455 }
1456}
1457
1458pub fn sign(
1474 _input: PathBuf,
1475 _output: PathBuf,
1476 _key: PathBuf,
1477 _cert: PathBuf,
1478) -> anyhow::Result<()> {
1479 anyhow::bail!(
1480 "Sign command not implemented: lib3mf-rs currently supports reading/verifying \
1481 signed 3MF files but does not support creating signatures.\n\n\
1482 Implementing signing requires:\n\
1483 - RSA signing with PEM private keys\n\
1484 - XML-DSIG structure creation and canonicalization\n\
1485 - OPC package modification with signature relationships\n\
1486 - X.509 certificate embedding\n\n\
1487 To create signed 3MF files, use the official 3MF SDK or other tools.\n\
1488 Verification of existing signatures works via: {} verify <file>",
1489 std::env::args()
1490 .next()
1491 .unwrap_or_else(|| "lib3mf-cli".to_string())
1492 );
1493}
1494
1495#[cfg(feature = "crypto")]
1512pub fn verify(file: PathBuf) -> anyhow::Result<()> {
1513 println!("Verifying signatures in {:?}...", file);
1514 let mut archiver = open_archive(&file)?;
1515
1516 let rels_data = archiver.read_entry("_rels/.rels").unwrap_or_default();
1518 if rels_data.is_empty() {
1519 println!("No relationships found. File is not signed.");
1520 return Ok(());
1521 }
1522
1523 let rels = opc::parse_relationships(&rels_data)?;
1524 let sig_rels: Vec<_> = rels.iter().filter(|r| r.rel_type == "http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel/relationship/signature"
1525 || r.rel_type.ends_with("/signature") ).collect();
1527
1528 if sig_rels.is_empty() {
1529 println!("No signature relationships found.");
1530 return Ok(());
1531 }
1532
1533 println!("Found {} signatures to verify.", sig_rels.len());
1534
1535 let mut all_valid = true;
1537 let mut signature_count = 0;
1538 let mut failed_signatures = Vec::new();
1539
1540 for rel in sig_rels {
1541 println!("Verifying signature: {}", rel.target);
1542 signature_count += 1;
1543 let target_path = rel.target.trim_start_matches('/');
1545
1546 let sig_xml_bytes = match archiver.read_entry(target_path) {
1547 Ok(b) => b,
1548 Err(e) => {
1549 println!(" [ERROR] Failed to read signature part: {}", e);
1550 all_valid = false;
1551 failed_signatures.push(rel.target.clone());
1552 continue;
1553 }
1554 };
1555
1556 let sig_xml_str = String::from_utf8_lossy(&sig_xml_bytes);
1558 let mut sig_parser = lib3mf_core::parser::xml_parser::XmlParser::new(std::io::Cursor::new(
1560 sig_xml_bytes.clone(),
1561 ));
1562 let signature = match lib3mf_core::parser::crypto_parser::parse_signature(&mut sig_parser) {
1563 Ok(s) => s,
1564 Err(e) => {
1565 println!(" [ERROR] Failed to parse signature XML: {}", e);
1566 all_valid = false;
1567 failed_signatures.push(rel.target.clone());
1568 continue;
1569 }
1570 };
1571
1572 let signed_info_c14n = match lib3mf_core::utils::c14n::Canonicalizer::canonicalize_subtree(
1577 &sig_xml_str,
1578 "SignedInfo",
1579 ) {
1580 Ok(b) => b,
1581 Err(e) => {
1582 println!(" [ERROR] Failed to extract/canonicalize SignedInfo: {}", e);
1583 all_valid = false;
1584 failed_signatures.push(rel.target.clone());
1585 continue;
1586 }
1587 };
1588
1589 let mut content_map = BTreeMap::new();
1615 for ref_item in &signature.signed_info.references {
1616 let uri = &ref_item.uri;
1617 if uri.is_empty() {
1618 continue;
1619 } let part_path = uri.trim_start_matches('/');
1621 match archiver.read_entry(part_path) {
1622 Ok(data) => {
1623 content_map.insert(uri.clone(), data);
1624 }
1625 Err(e) => println!(" [WARNING] Could not read referenced part {}: {}", uri, e),
1626 }
1627 }
1628
1629 let resolver = |uri: &str| -> lib3mf_core::error::Result<Vec<u8>> {
1630 content_map.get(uri).cloned().ok_or_else(|| {
1631 lib3mf_core::error::Lib3mfError::Validation(format!("Content not found: {}", uri))
1632 })
1633 };
1634
1635 match lib3mf_core::crypto::verification::verify_signature_extended(
1636 &signature,
1637 resolver,
1638 &signed_info_c14n,
1639 ) {
1640 Ok(valid) => {
1641 if valid {
1642 println!(" [PASS] Signature is VALID.");
1643 if let Some(mut ki) = signature.key_info {
1645 if let Some(x509) = ki.x509_data.take() {
1646 if let Some(_cert_str) = x509.certificate {
1647 println!(
1648 " [INFO] Signed by X.509 Certificate (Trust check pending)"
1649 );
1650 }
1652 } else {
1653 println!(" [INFO] Signed by Raw Key (Self-signed equivalent)");
1654 }
1655 }
1656 } else {
1657 println!(" [FAIL] Signature is INVALID (Verification returned false).");
1658 all_valid = false;
1659 failed_signatures.push(rel.target.clone());
1660 }
1661 }
1662 Err(e) => {
1663 println!(" [FAIL] Verification Error: {}", e);
1664 all_valid = false;
1665 failed_signatures.push(rel.target.clone());
1666 }
1667 }
1668 }
1669
1670 if !all_valid {
1672 anyhow::bail!(
1673 "Signature verification failed for {} of {} signature(s): {:?}",
1674 failed_signatures.len(),
1675 signature_count,
1676 failed_signatures
1677 );
1678 }
1679
1680 println!(
1681 "\nAll {} signature(s) verified successfully.",
1682 signature_count
1683 );
1684 Ok(())
1685}
1686
1687#[cfg(not(feature = "crypto"))]
1695pub fn verify(_file: PathBuf) -> anyhow::Result<()> {
1696 anyhow::bail!(
1697 "Signature verification requires the 'crypto' feature to be enabled.\n\
1698 The CLI was built without cryptographic support."
1699 )
1700}
1701
1702pub fn encrypt(_input: PathBuf, _output: PathBuf, _recipient: PathBuf) -> anyhow::Result<()> {
1717 anyhow::bail!(
1718 "Encrypt command not implemented: lib3mf-rs currently supports reading/parsing \
1719 encrypted 3MF files but does not support creating encrypted packages.\n\n\
1720 Implementing encryption requires:\n\
1721 - AES-256-GCM content encryption\n\
1722 - RSA-OAEP key wrapping for recipients\n\
1723 - KeyStore XML structure creation\n\
1724 - OPC package modification with encrypted content types\n\
1725 - Encrypted relationship handling\n\n\
1726 To create encrypted 3MF files, use the official 3MF SDK or other tools.\n\
1727 Decryption of existing encrypted files is also not yet implemented."
1728 );
1729}
1730
1731pub fn decrypt(_input: PathBuf, _output: PathBuf, _key: PathBuf) -> anyhow::Result<()> {
1746 anyhow::bail!(
1747 "Decrypt command not implemented: lib3mf-rs currently supports reading/parsing \
1748 encrypted 3MF files but does not support decrypting content.\n\n\
1749 Implementing decryption requires:\n\
1750 - KeyStore parsing and key unwrapping\n\
1751 - RSA-OAEP private key operations\n\
1752 - AES-256-GCM content decryption\n\
1753 - OPC package reconstruction with decrypted parts\n\
1754 - Consumer authorization validation\n\n\
1755 To decrypt 3MF files, use the official 3MF SDK or other tools.\n\
1756 The library can parse KeyStore and encryption metadata from encrypted files."
1757 );
1758}