Skip to main content

lib3mf_cli/commands/
split.rs

1//! Split command — extracts individual objects from a 3MF file into separate output files.
2//!
3//! This module implements the core split engine for the `3mf split` command.
4//! The split pipeline:
5//! 1. Load the source 3MF file with full attachments
6//! 2. Check for secure content (signing/encryption) — error if found
7//! 3. Collect all split targets (by build item or by object resource)
8//! 4. Apply --select filter to cherry-pick specific items
9//! 5. Phase 1 — Trace dependencies for ALL items before writing any files
10//!    (dry-run output or validate completeness)
11//! 6. Phase 2 — Write each split model to a separate output file
12//! 7. Print summary of written files
13
14use 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// ---------------------------------------------------------------------------
26// Public types
27// ---------------------------------------------------------------------------
28
29/// Determines how the source 3MF is partitioned into output files.
30#[derive(Debug, Clone, Copy, clap::ValueEnum, Default)]
31pub enum SplitMode {
32    /// One output file per build item (default).
33    #[default]
34    ByItem,
35    /// One output file per unique object resource.
36    ByObject,
37}
38
39// ---------------------------------------------------------------------------
40// Internal types
41// ---------------------------------------------------------------------------
42
43/// A candidate item to extract into its own output file.
44struct SplitTarget {
45    /// Root object ID to trace dependencies from.
46    root_object_id: ResourceId,
47    /// Build item (if available) for transform info.
48    build_item: Option<BuildItem>,
49    /// Display name for output naming.
50    name: String,
51    /// Index in the original list (for --select).
52    index: usize,
53}
54
55/// Pre-computed data for one output file, ready for writing.
56struct 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
67// ---------------------------------------------------------------------------
68// Dependency Collector
69// ---------------------------------------------------------------------------
70
71/// Walks the transitive dependency graph starting from a root object to collect
72/// the minimal set of resource IDs and attachment paths needed in the output file.
73struct 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    /// Walk all references reachable from an object, collecting resource IDs and attachment paths.
89    /// Uses `HashSet::insert` return value for cycle detection (returns false if already visited).
90    fn collect_object(&mut self, id: ResourceId) {
91        if !self.needed_ids.insert(id) {
92            return; // Already visited — prevents infinite loops in component graphs
93        }
94
95        let Some(obj) = self.resources.get_object(id) else {
96            return;
97        };
98
99        // Object-level material reference
100        if let Some(pid) = obj.pid {
101            self.collect_property(pid);
102        }
103
104        // Object thumbnail attachment
105        if let Some(ref thumb) = obj.thumbnail {
106            self.needed_attachment_paths.insert(thumb.clone());
107        }
108
109        // Geometry-specific references
110        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); // recurse into child objects
121                }
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    /// Collect a property group and its transitive dependencies.
146    fn collect_property(&mut self, pid: ResourceId) {
147        if !self.needed_ids.insert(pid) {
148            return; // Already visited
149        }
150
151        // Texture2DGroup -> Texture2D -> attachment
152        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            // Look up Texture2D to get the attachment path
156            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        // CompositeMaterials -> BaseMaterialsGroup
162        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        // MultiProperties -> multiple property groups (Vec<ResourceId>)
168        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        // Displacement2D -> attachment path
176        if let Some(disp) = self.resources.get_displacement_2d(pid) {
177            self.needed_attachment_paths.insert(disp.path.clone());
178        }
179
180        // BaseMaterialsGroup and ColorGroup: no further dependencies (leaves)
181    }
182}
183
184// ---------------------------------------------------------------------------
185// Compact ID remap builder
186// ---------------------------------------------------------------------------
187
188/// Assigns sequential IDs starting from 1 to all needed resource IDs.
189/// Returns a map from old ID -> new ID.
190fn 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// ---------------------------------------------------------------------------
201// Remap helpers (lookup-based, unlike merge.rs which uses offset arithmetic)
202// ---------------------------------------------------------------------------
203
204#[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
229// ---------------------------------------------------------------------------
230// Build split model
231// ---------------------------------------------------------------------------
232
233/// Construct a new Model containing only the resources for one extracted item.
234fn 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    // Copy model-level fields; metadata is annotated with source provenance (Pattern 8)
243    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    // --- Add only needed objects, with remapped IDs ---
262    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    // --- Base materials ---
306    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    // --- Color groups ---
320    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    // --- Texture2D ---
334    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    // --- Texture2DGroup ---
348    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    // --- CompositeMaterials ---
363    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    // --- MultiProperties ---
378    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    // --- SliceStack ---
395    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    // --- VolumetricStack ---
409    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    // --- Displacement2D ---
423    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    // --- Build section: one item for the root object ---
437    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    // --- Attachments: only needed ones ---
460    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
469// ---------------------------------------------------------------------------
470// Output naming helpers
471// ---------------------------------------------------------------------------
472
473/// Replace characters invalid in filenames with underscores.
474fn 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
486/// Derive a base output filename from the object name (or fall back to index).
487fn 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
495/// Resolve a unique output path, auto-incrementing to avoid collisions.
496/// Tracks used names within this run AND checks filesystem (unless force is set).
497fn 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    // Auto-increment: Part.3mf -> Part_1.3mf -> Part_2.3mf
509    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    // Should be unreachable in practice
517    dir.join(format!("{base_name}_overflow.3mf"))
518}
519
520/// Compute the output directory.
521/// If output_dir is provided, use it; else derive `{input_stem}_split/` next to the input file.
522fn 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
534// ---------------------------------------------------------------------------
535// Count helpers for summary output
536// ---------------------------------------------------------------------------
537
538fn 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
564// ---------------------------------------------------------------------------
565// Split mode: collect all targets
566// ---------------------------------------------------------------------------
567
568/// Collect all items to extract, based on the split mode.
569fn collect_all_targets(model: &Model, mode: SplitMode) -> Vec<SplitTarget> {
570    match mode {
571        SplitMode::ByItem => {
572            // One output per build item
573            model
574                .build
575                .items
576                .iter()
577                .enumerate()
578                .map(|(index, item)| {
579                    // Derive name from the referenced object's name
580                    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            // One output per unique printable object resource
597            let mut objects: Vec<&Object> = model
598                .resources
599                .iter_objects()
600                .filter(|obj| obj.object_type.can_be_in_build())
601                .collect();
602            // Sort by ID for deterministic ordering
603            objects.sort_by_key(|obj| obj.id.0);
604
605            objects
606                .iter()
607                .enumerate()
608                .map(|(index, obj)| {
609                    // Find a matching build item if one exists
610                    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
633// ---------------------------------------------------------------------------
634// --select filter
635// ---------------------------------------------------------------------------
636
637/// Filter split targets to only the selected items.
638/// Selectors can be numeric indices or case-insensitive name contains-matches.
639fn 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                // Try parsing as index
649                if let Ok(n) = sel.parse::<usize>() {
650                    return target.index == n;
651                }
652                // Try case-insensitive name contains-match
653                target.name.to_lowercase().contains(&sel.to_lowercase())
654            })
655        })
656        .collect()
657}
658
659// ---------------------------------------------------------------------------
660// pub fn run() — main entry point
661// ---------------------------------------------------------------------------
662
663/// Entry point for the split command.
664#[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    // Step 1: Load source file with full attachments
676    let source = load_full(&input)?;
677
678    // Step 2: Check for secure content — error if present (consistent with merge)
679    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    // Step 3: Collect all split targets
686    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    // Step 4: Apply --select filter
693    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    // Step 5: Compute output directory
700    let out_dir = compute_output_dir(&input, output_dir.as_deref());
701
702    // Step 6: Phase 1 — Trace all dependencies for ALL items BEFORE creating any files.
703    // This ensures we don't create a partial output directory if tracing fails.
704    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        // Trace transitive dependencies
716        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        // Build compact ID remap
722        let id_remap = build_compact_remap(&needed_ids);
723
724        // Derive output filename
725        let base_name = derive_output_name(
726            source
727                .resources
728                .get_object(target.root_object_id)
729                .unwrap_or_else(|| {
730                    // Fallback: use target.name directly if object lookup fails
731                    // This should not happen, but handle gracefully
732                    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        // Count summaries for output/dry-run
739        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    // Step 7: Dry-run — print summary and return without writing
764    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    // Step 8: Create output directory.
791    // If it exists and --force is not set, bail. Otherwise proceed.
792    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    // Step 9: Phase 2 — Write each split model to its output file
802    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    // Step 10: Print summary
825    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}