1use crate::commands::merge::{Verbosity, check_secure_content, load_full};
15use lib3mf_core::model::{
16 BaseMaterialsGroup, Build, BuildItem, ColorGroup, CompositeMaterials, Displacement2D, Geometry,
17 Model, MultiProperties, Object, ResourceCollection, ResourceId, SliceStack, Texture2D,
18 Texture2DGroup, VolumetricStack,
19};
20use std::collections::{HashMap, HashSet};
21use std::fs::{self, File};
22use std::io::BufWriter;
23use std::path::{Path, PathBuf};
24
25#[derive(Debug, Clone, Copy, clap::ValueEnum, Default)]
31pub enum SplitMode {
32 #[default]
34 ByItem,
35 ByObject,
37}
38
39struct SplitTarget {
45 root_object_id: ResourceId,
47 build_item: Option<BuildItem>,
49 name: String,
51 index: usize,
53}
54
55struct PreparedSplit {
57 target: SplitTarget,
58 needed_ids: HashSet<ResourceId>,
59 needed_attachments: HashSet<String>,
60 id_remap: HashMap<ResourceId, ResourceId>,
61 output_path: PathBuf,
62 object_count: usize,
63 material_count: usize,
64 texture_count: usize,
65}
66
67struct DependencyCollector<'a> {
74 resources: &'a ResourceCollection,
75 needed_ids: HashSet<ResourceId>,
76 needed_attachment_paths: HashSet<String>,
77}
78
79impl<'a> DependencyCollector<'a> {
80 fn new(resources: &'a ResourceCollection) -> Self {
81 Self {
82 resources,
83 needed_ids: HashSet::new(),
84 needed_attachment_paths: HashSet::new(),
85 }
86 }
87
88 fn collect_object(&mut self, id: ResourceId) {
91 if !self.needed_ids.insert(id) {
92 return; }
94
95 let Some(obj) = self.resources.get_object(id) else {
96 return;
97 };
98
99 if let Some(pid) = obj.pid {
101 self.collect_property(pid);
102 }
103
104 if let Some(ref thumb) = obj.thumbnail {
106 self.needed_attachment_paths.insert(thumb.clone());
107 }
108
109 match &obj.geometry {
111 Geometry::Mesh(mesh) => {
112 for tri in &mesh.triangles {
113 if let Some(pid) = tri.pid {
114 self.collect_property(ResourceId(pid));
115 }
116 }
117 }
118 Geometry::Components(comps) => {
119 for comp in &comps.components {
120 self.collect_object(comp.object_id); }
122 }
123 Geometry::BooleanShape(shape) => {
124 self.collect_object(shape.base_object_id);
125 for op in &shape.operations {
126 self.collect_object(op.object_id);
127 }
128 }
129 Geometry::SliceStack(stack_id) => {
130 self.needed_ids.insert(*stack_id);
131 }
132 Geometry::VolumetricStack(stack_id) => {
133 self.needed_ids.insert(*stack_id);
134 }
135 Geometry::DisplacementMesh(dm) => {
136 for tri in &dm.triangles {
137 if let Some(pid) = tri.pid {
138 self.collect_property(ResourceId(pid));
139 }
140 }
141 }
142 }
143 }
144
145 fn collect_property(&mut self, pid: ResourceId) {
147 if !self.needed_ids.insert(pid) {
148 return; }
150
151 if let Some(grp) = self.resources.get_texture_2d_group(pid) {
153 let tex_id = grp.texture_id;
154 self.needed_ids.insert(tex_id);
155 if let Some(tex) = self.resources.iter_texture_2d().find(|t| t.id == tex_id) {
157 self.needed_attachment_paths.insert(tex.path.clone());
158 }
159 }
160
161 if let Some(comp) = self.resources.get_composite_materials(pid) {
163 let base_id = comp.base_material_id;
164 self.collect_property(base_id);
165 }
166
167 if let Some(mp) = self.resources.get_multi_properties(pid) {
169 let sub_pids: Vec<ResourceId> = mp.pids.clone();
170 for sub_pid in sub_pids {
171 self.collect_property(sub_pid);
172 }
173 }
174
175 if let Some(disp) = self.resources.get_displacement_2d(pid) {
177 self.needed_attachment_paths.insert(disp.path.clone());
178 }
179
180 }
182}
183
184fn build_compact_remap(needed_ids: &HashSet<ResourceId>) -> HashMap<ResourceId, ResourceId> {
191 let mut sorted: Vec<u32> = needed_ids.iter().map(|id| id.0).collect();
192 sorted.sort_unstable();
193 sorted
194 .iter()
195 .enumerate()
196 .map(|(new_idx, &old_id)| (ResourceId(old_id), ResourceId((new_idx + 1) as u32)))
197 .collect()
198}
199
200#[inline]
205fn remap_id(id: &mut ResourceId, remap: &HashMap<ResourceId, ResourceId>) {
206 if let Some(&new_id) = remap.get(id) {
207 *id = new_id;
208 }
209}
210
211#[inline]
212fn remap_opt_id(id: &mut Option<ResourceId>, remap: &HashMap<ResourceId, ResourceId>) {
213 if let Some(inner) = id
214 && let Some(&new_id) = remap.get(inner)
215 {
216 *inner = new_id;
217 }
218}
219
220#[inline]
221fn remap_opt_u32_pid(pid: &mut Option<u32>, remap: &HashMap<ResourceId, ResourceId>) {
222 if let Some(p) = pid
223 && let Some(&new_id) = remap.get(&ResourceId(*p))
224 {
225 *p = new_id.0;
226 }
227}
228
229fn build_split_model(
235 source: &Model,
236 target: &SplitTarget,
237 preserve_transforms: bool,
238 id_remap: &HashMap<ResourceId, ResourceId>,
239 needed_ids: &HashSet<ResourceId>,
240 needed_attachments: &HashSet<String>,
241) -> anyhow::Result<Model> {
242 let mut metadata = source.metadata.clone();
244 metadata.insert(
245 "Source".to_string(),
246 source.metadata.get("Source").cloned().unwrap_or_default(),
247 );
248 metadata.insert("SourceObject".to_string(), target.name.clone());
249
250 let mut out = Model {
251 unit: source.unit,
252 language: source.language.clone(),
253 metadata,
254 resources: ResourceCollection::new(),
255 build: Build::default(),
256 attachments: std::collections::HashMap::new(),
257 existing_relationships: std::collections::HashMap::new(),
258 extra_namespaces: source.extra_namespaces.clone(),
259 };
260
261 let objects: Vec<Object> = source
263 .resources
264 .iter_objects()
265 .filter(|obj| needed_ids.contains(&obj.id))
266 .cloned()
267 .collect();
268 for mut obj in objects {
269 remap_id(&mut obj.id, id_remap);
270 remap_opt_id(&mut obj.pid, id_remap);
271 match &mut obj.geometry {
272 Geometry::Mesh(mesh) => {
273 for tri in &mut mesh.triangles {
274 remap_opt_u32_pid(&mut tri.pid, id_remap);
275 }
276 }
277 Geometry::Components(comps) => {
278 for comp in &mut comps.components {
279 remap_id(&mut comp.object_id, id_remap);
280 }
281 }
282 Geometry::BooleanShape(shape) => {
283 remap_id(&mut shape.base_object_id, id_remap);
284 for op in &mut shape.operations {
285 remap_id(&mut op.object_id, id_remap);
286 }
287 }
288 Geometry::SliceStack(id) => {
289 remap_id(&mut *id, id_remap);
290 }
291 Geometry::VolumetricStack(id) => {
292 remap_id(&mut *id, id_remap);
293 }
294 Geometry::DisplacementMesh(dm) => {
295 for tri in &mut dm.triangles {
296 remap_opt_u32_pid(&mut tri.pid, id_remap);
297 }
298 }
299 }
300 out.resources
301 .add_object(obj)
302 .map_err(|e| anyhow::anyhow!("Failed to add object to split model: {}", e))?;
303 }
304
305 let base_materials: Vec<BaseMaterialsGroup> = source
307 .resources
308 .iter_base_materials()
309 .filter(|m| needed_ids.contains(&m.id))
310 .cloned()
311 .collect();
312 for mut mat in base_materials {
313 remap_id(&mut mat.id, id_remap);
314 out.resources
315 .add_base_materials(mat)
316 .map_err(|e| anyhow::anyhow!("Failed to add base materials to split model: {}", e))?;
317 }
318
319 let color_groups: Vec<ColorGroup> = source
321 .resources
322 .iter_color_groups()
323 .filter(|c| needed_ids.contains(&c.id))
324 .cloned()
325 .collect();
326 for mut col in color_groups {
327 remap_id(&mut col.id, id_remap);
328 out.resources
329 .add_color_group(col)
330 .map_err(|e| anyhow::anyhow!("Failed to add color group to split model: {}", e))?;
331 }
332
333 let texture_2d: Vec<Texture2D> = source
335 .resources
336 .iter_texture_2d()
337 .filter(|t| needed_ids.contains(&t.id))
338 .cloned()
339 .collect();
340 for mut tex in texture_2d {
341 remap_id(&mut tex.id, id_remap);
342 out.resources
343 .add_texture_2d(tex)
344 .map_err(|e| anyhow::anyhow!("Failed to add texture 2D to split model: {}", e))?;
345 }
346
347 let texture_2d_groups: Vec<Texture2DGroup> = source
349 .resources
350 .iter_textures()
351 .filter(|g| needed_ids.contains(&g.id))
352 .cloned()
353 .collect();
354 for mut grp in texture_2d_groups {
355 remap_id(&mut grp.id, id_remap);
356 remap_id(&mut grp.texture_id, id_remap);
357 out.resources
358 .add_texture_2d_group(grp)
359 .map_err(|e| anyhow::anyhow!("Failed to add texture group to split model: {}", e))?;
360 }
361
362 let composite_materials: Vec<CompositeMaterials> = source
364 .resources
365 .iter_composite_materials()
366 .filter(|c| needed_ids.contains(&c.id))
367 .cloned()
368 .collect();
369 for mut comp in composite_materials {
370 remap_id(&mut comp.id, id_remap);
371 remap_id(&mut comp.base_material_id, id_remap);
372 out.resources.add_composite_materials(comp).map_err(|e| {
373 anyhow::anyhow!("Failed to add composite materials to split model: {}", e)
374 })?;
375 }
376
377 let multi_properties: Vec<MultiProperties> = source
379 .resources
380 .iter_multi_properties()
381 .filter(|m| needed_ids.contains(&m.id))
382 .cloned()
383 .collect();
384 for mut mp in multi_properties {
385 remap_id(&mut mp.id, id_remap);
386 for pid in &mut mp.pids {
387 remap_id(pid, id_remap);
388 }
389 out.resources
390 .add_multi_properties(mp)
391 .map_err(|e| anyhow::anyhow!("Failed to add multi-properties to split model: {}", e))?;
392 }
393
394 let slice_stacks: Vec<SliceStack> = source
396 .resources
397 .iter_slice_stacks()
398 .filter(|s| needed_ids.contains(&s.id))
399 .cloned()
400 .collect();
401 for mut ss in slice_stacks {
402 remap_id(&mut ss.id, id_remap);
403 out.resources
404 .add_slice_stack(ss)
405 .map_err(|e| anyhow::anyhow!("Failed to add slice stack to split model: {}", e))?;
406 }
407
408 let volumetric_stacks: Vec<VolumetricStack> = source
410 .resources
411 .iter_volumetric_stacks()
412 .filter(|v| needed_ids.contains(&v.id))
413 .cloned()
414 .collect();
415 for mut vs in volumetric_stacks {
416 remap_id(&mut vs.id, id_remap);
417 out.resources
418 .add_volumetric_stack(vs)
419 .map_err(|e| anyhow::anyhow!("Failed to add volumetric stack to split model: {}", e))?;
420 }
421
422 let displacement_2d: Vec<Displacement2D> = source
424 .resources
425 .iter_displacement_2d()
426 .filter(|d| needed_ids.contains(&d.id))
427 .cloned()
428 .collect();
429 for mut d in displacement_2d {
430 remap_id(&mut d.id, id_remap);
431 out.resources
432 .add_displacement_2d(d)
433 .map_err(|e| anyhow::anyhow!("Failed to add displacement 2D to split model: {}", e))?;
434 }
435
436 let new_root_id = id_remap
438 .get(&target.root_object_id)
439 .copied()
440 .unwrap_or(target.root_object_id);
441 let transform = if preserve_transforms {
442 target
443 .build_item
444 .as_ref()
445 .map(|bi| bi.transform)
446 .unwrap_or(glam::Mat4::IDENTITY)
447 } else {
448 glam::Mat4::IDENTITY
449 };
450 out.build.items.push(BuildItem {
451 object_id: new_root_id,
452 transform,
453 uuid: None,
454 path: None,
455 part_number: None,
456 printable: None,
457 });
458
459 for (path, data) in &source.attachments {
461 if needed_attachments.contains(path) {
462 out.attachments.insert(path.clone(), data.clone());
463 }
464 }
465
466 Ok(out)
467}
468
469fn sanitize_filename(name: &str) -> String {
475 name.chars()
476 .map(|c| {
477 if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
478 c
479 } else {
480 '_'
481 }
482 })
483 .collect()
484}
485
486fn derive_output_name(obj: &Object, index: usize) -> String {
488 obj.name
489 .as_deref()
490 .filter(|n| !n.is_empty())
491 .map(sanitize_filename)
492 .unwrap_or_else(|| format!("part_{}", index + 1))
493}
494
495fn resolve_split_output_path(
498 dir: &Path,
499 base_name: &str,
500 used_names: &mut HashSet<String>,
501 force: bool,
502) -> PathBuf {
503 let candidate_name = format!("{base_name}.3mf");
504 if !used_names.contains(&candidate_name) && (force || !dir.join(&candidate_name).exists()) {
505 used_names.insert(candidate_name.clone());
506 return dir.join(candidate_name);
507 }
508 for n in 1u32..=9999 {
510 let candidate_name = format!("{base_name}_{n}.3mf");
511 if !used_names.contains(&candidate_name) && (force || !dir.join(&candidate_name).exists()) {
512 used_names.insert(candidate_name.clone());
513 return dir.join(candidate_name);
514 }
515 }
516 dir.join(format!("{base_name}_overflow.3mf"))
518}
519
520fn compute_output_dir(input: &Path, output_dir: Option<&Path>) -> PathBuf {
523 if let Some(dir) = output_dir {
524 return dir.to_path_buf();
525 }
526 let stem = input
527 .file_stem()
528 .and_then(|s| s.to_str())
529 .unwrap_or("model");
530 let parent = input.parent().unwrap_or(Path::new("."));
531 parent.join(format!("{stem}_split"))
532}
533
534fn count_objects_in(needed_ids: &HashSet<ResourceId>, resources: &ResourceCollection) -> usize {
539 resources
540 .iter_objects()
541 .filter(|obj| needed_ids.contains(&obj.id))
542 .count()
543}
544
545fn count_materials_in(needed_ids: &HashSet<ResourceId>, resources: &ResourceCollection) -> usize {
546 resources
547 .iter_base_materials()
548 .filter(|m| needed_ids.contains(&m.id))
549 .count()
550 + resources
551 .iter_color_groups()
552 .filter(|c| needed_ids.contains(&c.id))
553 .count()
554 + resources
555 .iter_texture_2d()
556 .filter(|t| needed_ids.contains(&t.id))
557 .count()
558}
559
560fn count_textures_in(needed_attachment_paths: &HashSet<String>) -> usize {
561 needed_attachment_paths.len()
562}
563
564fn collect_all_targets(model: &Model, mode: SplitMode) -> Vec<SplitTarget> {
570 match mode {
571 SplitMode::ByItem => {
572 model
574 .build
575 .items
576 .iter()
577 .enumerate()
578 .map(|(index, item)| {
579 let name = model
581 .resources
582 .get_object(item.object_id)
583 .and_then(|obj| obj.name.clone())
584 .filter(|n| !n.is_empty())
585 .unwrap_or_else(|| format!("part_{}", index + 1));
586 SplitTarget {
587 root_object_id: item.object_id,
588 build_item: Some(item.clone()),
589 name,
590 index,
591 }
592 })
593 .collect()
594 }
595 SplitMode::ByObject => {
596 let mut objects: Vec<&Object> = model
598 .resources
599 .iter_objects()
600 .filter(|obj| obj.object_type.can_be_in_build())
601 .collect();
602 objects.sort_by_key(|obj| obj.id.0);
604
605 objects
606 .iter()
607 .enumerate()
608 .map(|(index, obj)| {
609 let build_item = model
611 .build
612 .items
613 .iter()
614 .find(|item| item.object_id == obj.id)
615 .cloned();
616 let name = obj
617 .name
618 .clone()
619 .filter(|n| !n.is_empty())
620 .unwrap_or_else(|| format!("part_{}", index + 1));
621 SplitTarget {
622 root_object_id: obj.id,
623 build_item,
624 name,
625 index,
626 }
627 })
628 .collect()
629 }
630 }
631}
632
633fn select_items(all_targets: Vec<SplitTarget>, selectors: &[String]) -> Vec<SplitTarget> {
640 if selectors.is_empty() {
641 return all_targets;
642 }
643
644 all_targets
645 .into_iter()
646 .filter(|target| {
647 selectors.iter().any(|sel| {
648 if let Ok(n) = sel.parse::<usize>() {
650 return target.index == n;
651 }
652 target.name.to_lowercase().contains(&sel.to_lowercase())
654 })
655 })
656 .collect()
657}
658
659#[allow(clippy::too_many_arguments)]
665pub fn run(
666 input: PathBuf,
667 output_dir: Option<PathBuf>,
668 mode: SplitMode,
669 select: Vec<String>,
670 preserve_transforms: bool,
671 dry_run: bool,
672 force: bool,
673 verbosity: Verbosity,
674) -> anyhow::Result<()> {
675 let source = load_full(&input)?;
677
678 check_secure_content(&source).map_err(|_| {
680 anyhow::anyhow!(
681 "Cannot split signed/encrypted 3MF files. Strip signatures first using the decrypt command."
682 )
683 })?;
684
685 let all_targets = collect_all_targets(&source, mode);
687
688 if all_targets.is_empty() {
689 anyhow::bail!("No objects or build items found to split in {:?}", input);
690 }
691
692 let targets = select_items(all_targets, &select);
694
695 if targets.is_empty() {
696 anyhow::bail!("--select matched no items. Use a valid index or object name substring.");
697 }
698
699 let out_dir = compute_output_dir(&input, output_dir.as_deref());
701
702 let mut used_names: HashSet<String> = HashSet::new();
705 let mut prepared: Vec<PreparedSplit> = Vec::with_capacity(targets.len());
706
707 for target in targets {
708 if matches!(verbosity, Verbosity::Verbose) {
709 eprintln!(
710 " Tracing dependencies for '{}' (root ID={})",
711 target.name, target.root_object_id.0
712 );
713 }
714
715 let mut collector = DependencyCollector::new(&source.resources);
717 collector.collect_object(target.root_object_id);
718 let needed_ids = collector.needed_ids;
719 let needed_attachments = collector.needed_attachment_paths;
720
721 let id_remap = build_compact_remap(&needed_ids);
723
724 let base_name = derive_output_name(
726 source
727 .resources
728 .get_object(target.root_object_id)
729 .unwrap_or_else(|| {
730 panic!("Object {} not found", target.root_object_id.0)
733 }),
734 target.index,
735 );
736 let output_path = resolve_split_output_path(&out_dir, &base_name, &mut used_names, force);
737
738 let object_count = count_objects_in(&needed_ids, &source.resources);
740 let material_count = count_materials_in(&needed_ids, &source.resources);
741 let texture_count = count_textures_in(&needed_attachments);
742
743 if matches!(verbosity, Verbosity::Verbose) {
744 eprintln!(
745 " Found {} objects, {} materials, {} textures",
746 object_count, material_count, texture_count
747 );
748 eprintln!(" Output: {}", output_path.display());
749 }
750
751 prepared.push(PreparedSplit {
752 target,
753 needed_ids,
754 needed_attachments,
755 id_remap,
756 output_path,
757 object_count,
758 material_count,
759 texture_count,
760 });
761 }
762
763 if dry_run {
765 println!(
766 "DRY RUN: Would write {} files to {}/",
767 prepared.len(),
768 out_dir.display()
769 );
770 for p in &prepared {
771 let filename = p
772 .output_path
773 .file_name()
774 .and_then(|n| n.to_str())
775 .unwrap_or("?");
776 println!(
777 " {:<30} ({} object{}, {} material{}, {} texture{})",
778 filename,
779 p.object_count,
780 if p.object_count == 1 { "" } else { "s" },
781 p.material_count,
782 if p.material_count == 1 { "" } else { "s" },
783 p.texture_count,
784 if p.texture_count == 1 { "" } else { "s" },
785 );
786 }
787 return Ok(());
788 }
789
790 if out_dir.exists() && !force {
793 anyhow::bail!(
794 "Output directory {:?} already exists. Use --force to overwrite files inside it.",
795 out_dir
796 );
797 }
798 fs::create_dir_all(&out_dir)
799 .map_err(|e| anyhow::anyhow!("Failed to create output directory {:?}: {}", out_dir, e))?;
800
801 for p in &prepared {
803 if matches!(verbosity, Verbosity::Verbose) {
804 eprintln!(" Writing {}", p.output_path.display());
805 }
806
807 let split_model = build_split_model(
808 &source,
809 &p.target,
810 preserve_transforms,
811 &p.id_remap,
812 &p.needed_ids,
813 &p.needed_attachments,
814 )?;
815
816 let file = File::create(&p.output_path).map_err(|e| {
817 anyhow::anyhow!("Failed to create output file {:?}: {}", p.output_path, e)
818 })?;
819 split_model.write(BufWriter::new(file)).map_err(|e| {
820 anyhow::anyhow!("Failed to write split model {:?}: {}", p.output_path, e)
821 })?;
822 }
823
824 if !matches!(verbosity, Verbosity::Quiet) {
826 println!(
827 "Split {} item{} from {} -> {}/",
828 prepared.len(),
829 if prepared.len() == 1 { "" } else { "s" },
830 input.display(),
831 out_dir.display()
832 );
833 for p in &prepared {
834 let filename = p
835 .output_path
836 .file_name()
837 .and_then(|n| n.to_str())
838 .unwrap_or("?");
839 println!(
840 " {:<30} ({} object{}, {} material{}, {} texture{})",
841 filename,
842 p.object_count,
843 if p.object_count == 1 { "" } else { "s" },
844 p.material_count,
845 if p.material_count == 1 { "" } else { "s" },
846 p.texture_count,
847 if p.texture_count == 1 { "" } else { "s" },
848 );
849 }
850 }
851
852 Ok(())
853}