Skip to main content

lib3mf_cli/commands/
merge.rs

1//! Merge command — combines multiple 3MF files into a single output file.
2//!
3//! This module implements the core merge engine for the `3mf merge` command.
4//! The merge pipeline:
5//! 1. Expand glob patterns in inputs
6//! 2. Load all input files with full attachments
7//! 3. Check for secure content (signing/encryption) — error if found
8//! 4. Compute ID offset for each file (max ID in merged so far + 1)
9//! 5. Remap all ResourceId cross-references in each model
10//! 6. Merge attachments with path deduplication
11//! 7. Update texture/displacement paths after attachment remap
12//! 8. Combine all resources into one merged Model
13//! 9. Apply placement (plate-per-file or single-plate)
14//! 10. Write merged model to output
15
16use glob::glob;
17use lib3mf_core::archive::{ArchiveReader, ZipArchiver, find_model_path, opc};
18use lib3mf_core::model::{
19    BaseMaterialsGroup, ColorGroup, CompositeMaterials, Displacement2D, Geometry, Model,
20    MultiProperties, Object, ResourceId, SliceStack, Texture2D, Texture2DGroup, VolumetricStack,
21    stats::BoundingBox,
22};
23use lib3mf_core::parser::parse_model;
24use std::collections::HashMap;
25use std::fs::File;
26use std::io::{BufWriter, Cursor};
27use std::path::{Path, PathBuf};
28
29/// Packing algorithm for single-plate mode.
30#[derive(Debug, Clone, Copy, clap::ValueEnum, Default)]
31pub enum PackAlgorithm {
32    /// Arrange objects in a grid (default)
33    #[default]
34    Grid,
35}
36
37/// Verbosity level for merge output.
38#[derive(Debug, Clone, Copy)]
39pub enum Verbosity {
40    /// Suppress all output except errors.
41    Quiet,
42    /// Print a summary of merged objects (default).
43    Normal,
44    /// Print detailed per-object merge information.
45    Verbose,
46}
47
48/// Entry point for the merge command.
49pub fn run(
50    inputs: Vec<PathBuf>,
51    output: PathBuf,
52    force: bool,
53    single_plate: bool,
54    pack: PackAlgorithm,
55    verbosity: Verbosity,
56) -> anyhow::Result<()> {
57    // Step 1: Expand glob patterns
58    let expanded = expand_inputs(inputs)?;
59    let file_count = expanded.len();
60
61    if matches!(verbosity, Verbosity::Verbose) {
62        eprintln!("Merging {} files:", file_count);
63        for p in &expanded {
64            eprintln!("  {}", p.display());
65        }
66    }
67
68    // Step 2: Resolve output path (auto-increment if exists and force=false)
69    let out_path = resolve_output_path(&output, force)?;
70
71    // Step 3: Load all models up front — fail fast on any error (no partial output)
72    let mut loaded: Vec<Model> = Vec::with_capacity(file_count);
73    for (i, path) in expanded.iter().enumerate() {
74        if matches!(verbosity, Verbosity::Verbose) {
75            eprintln!("  Loading [{}/{}] {}", i + 1, file_count, path.display());
76        }
77        let model = load_full(path)?;
78        check_secure_content(&model)?;
79        loaded.push(model);
80    }
81
82    // Step 4: Merge all models
83    // First model becomes the base; subsequent models are remapped and transferred into it.
84    let mut merged = loaded.remove(0);
85    let mut total_objects = count_objects(&merged);
86    let mut total_materials = count_materials(&merged);
87
88    for (file_index, mut source) in loaded.into_iter().enumerate() {
89        // file_index 0 = second input file (index 1 overall)
90        let actual_file_index = file_index + 1;
91
92        // Step 4a: Compute ID offset (max resource ID in merged so far + 1)
93        let offset = max_resource_id(&merged) + 1;
94
95        if matches!(verbosity, Verbosity::Verbose) {
96            eprintln!(
97                "  Merging file {} with ID offset {}",
98                actual_file_index + 1,
99                offset
100            );
101        }
102
103        // Step 4b: Remap all IDs in source model
104        remap_model(&mut source, offset);
105
106        // Step 4c: Merge attachments with path deduplication
107        // Take attachments out of source first so we can still borrow source mutably after.
108        let src_attachments = std::mem::take(&mut source.attachments);
109        let path_remap =
110            merge_attachments(&mut merged.attachments, src_attachments, actual_file_index);
111
112        // Step 4d: Update texture/displacement paths after attachment remap
113        update_texture_paths(&mut source, &path_remap);
114
115        // Count before transfer (resources will be moved)
116        let src_objects = count_objects_resources(&source.resources);
117        let src_materials = count_materials_resources(&source.resources);
118
119        // Step 4e: Transfer all resources from source into merged
120        transfer_resources(&mut merged, source.resources)?;
121
122        // Step 4f: Transfer build items
123        merged.build.items.extend(source.build.items);
124
125        // Step 4g: Merge metadata, relationships, namespaces
126        merge_metadata(&mut merged.metadata, &source.metadata);
127        merge_relationships(
128            &mut merged.existing_relationships,
129            source.existing_relationships,
130        );
131        merge_extra_namespaces(&mut merged.extra_namespaces, &source.extra_namespaces);
132
133        total_objects += src_objects;
134        total_materials += src_materials;
135    }
136
137    // Step 5: Apply placement
138    if single_plate {
139        apply_single_plate_placement(&mut merged, pack, verbosity)?;
140    } else {
141        check_build_item_overlaps(&merged, verbosity);
142    }
143
144    // Step 6: Write output atomically (write to .tmp, rename to final)
145    let tmp_path = out_path.with_extension(format!(
146        "{}.tmp",
147        out_path
148            .extension()
149            .and_then(|e| e.to_str())
150            .unwrap_or("3mf")
151    ));
152    {
153        let tmp_file = File::create(&tmp_path)
154            .map_err(|e| anyhow::anyhow!("Failed to create temp output {:?}: {}", tmp_path, e))?;
155        let buf_writer = BufWriter::new(tmp_file);
156        merged
157            .write(buf_writer)
158            .map_err(|e| anyhow::anyhow!("Failed to write merged 3MF: {}", e))?;
159    }
160    std::fs::rename(&tmp_path, &out_path).map_err(|e| {
161        // Attempt cleanup
162        let _ = std::fs::remove_file(&tmp_path);
163        anyhow::anyhow!("Failed to finalize output file {:?}: {}", out_path, e)
164    })?;
165
166    // Step 7: Print summary
167    if !matches!(verbosity, Verbosity::Quiet) {
168        println!("Merged {} files -> {}", file_count, out_path.display());
169        println!(
170            "  Objects: {}  |  Materials: {}",
171            total_objects, total_materials
172        );
173    }
174
175    Ok(())
176}
177
178// ---------------------------------------------------------------------------
179// Resolve output path — auto-increment if exists and force=false
180// ---------------------------------------------------------------------------
181
182fn resolve_output_path(output: &Path, force: bool) -> anyhow::Result<PathBuf> {
183    if force || !output.exists() {
184        return Ok(output.to_path_buf());
185    }
186
187    // Auto-increment: try output.3mf.1, output.3mf.2, ... up to .999
188    for n in 1u32..=999 {
189        let candidate = PathBuf::from(format!("{}.{}", output.display(), n));
190        if !candidate.exists() {
191            if !matches!(std::env::var("RUST_TEST_QUIET").as_deref(), Ok("1")) {
192                eprintln!(
193                    "Warning: output {:?} exists, writing to {:?}",
194                    output, candidate
195                );
196            }
197            return Ok(candidate);
198        }
199    }
200
201    anyhow::bail!(
202        "Output file {:?} exists and no free auto-increment slot found (tried .1 through .999). Use --force to overwrite.",
203        output
204    )
205}
206
207// ---------------------------------------------------------------------------
208// Transfer all resources from a source ResourceCollection into merged model
209// ---------------------------------------------------------------------------
210
211fn transfer_resources(
212    merged: &mut Model,
213    source: lib3mf_core::model::ResourceCollection,
214) -> anyhow::Result<()> {
215    for obj in source.iter_objects().cloned().collect::<Vec<_>>() {
216        merged
217            .resources
218            .add_object(obj)
219            .map_err(|e| anyhow::anyhow!("Failed to add object during merge: {}", e))?;
220    }
221    for mat in source.iter_base_materials().cloned().collect::<Vec<_>>() {
222        merged
223            .resources
224            .add_base_materials(mat)
225            .map_err(|e| anyhow::anyhow!("Failed to add base materials during merge: {}", e))?;
226    }
227    for col in source.iter_color_groups().cloned().collect::<Vec<_>>() {
228        merged
229            .resources
230            .add_color_group(col)
231            .map_err(|e| anyhow::anyhow!("Failed to add color group during merge: {}", e))?;
232    }
233    for tex in source.iter_texture_2d().cloned().collect::<Vec<_>>() {
234        merged
235            .resources
236            .add_texture_2d(tex)
237            .map_err(|e| anyhow::anyhow!("Failed to add texture 2D during merge: {}", e))?;
238    }
239    for grp in source.iter_textures().cloned().collect::<Vec<_>>() {
240        merged
241            .resources
242            .add_texture_2d_group(grp)
243            .map_err(|e| anyhow::anyhow!("Failed to add texture 2D group during merge: {}", e))?;
244    }
245    for comp in source
246        .iter_composite_materials()
247        .cloned()
248        .collect::<Vec<_>>()
249    {
250        merged
251            .resources
252            .add_composite_materials(comp)
253            .map_err(|e| {
254                anyhow::anyhow!("Failed to add composite materials during merge: {}", e)
255            })?;
256    }
257    for mp in source.iter_multi_properties().cloned().collect::<Vec<_>>() {
258        merged
259            .resources
260            .add_multi_properties(mp)
261            .map_err(|e| anyhow::anyhow!("Failed to add multi-properties during merge: {}", e))?;
262    }
263    for ss in source.iter_slice_stacks().cloned().collect::<Vec<_>>() {
264        merged
265            .resources
266            .add_slice_stack(ss)
267            .map_err(|e| anyhow::anyhow!("Failed to add slice stack during merge: {}", e))?;
268    }
269    for vs in source.iter_volumetric_stacks().cloned().collect::<Vec<_>>() {
270        merged
271            .resources
272            .add_volumetric_stack(vs)
273            .map_err(|e| anyhow::anyhow!("Failed to add volumetric stack during merge: {}", e))?;
274    }
275    for d in source.iter_displacement_2d().cloned().collect::<Vec<_>>() {
276        merged
277            .resources
278            .add_displacement_2d(d)
279            .map_err(|e| anyhow::anyhow!("Failed to add displacement 2D during merge: {}", e))?;
280    }
281    Ok(())
282}
283
284// ---------------------------------------------------------------------------
285// Count helpers for summary output
286// ---------------------------------------------------------------------------
287
288fn count_objects(model: &Model) -> usize {
289    model.resources.iter_objects().count()
290}
291
292fn count_materials(model: &Model) -> usize {
293    model.resources.iter_base_materials().count()
294        + model.resources.iter_color_groups().count()
295        + model.resources.iter_texture_2d().count()
296}
297
298fn count_objects_resources(resources: &lib3mf_core::model::ResourceCollection) -> usize {
299    resources.iter_objects().count()
300}
301
302fn count_materials_resources(resources: &lib3mf_core::model::ResourceCollection) -> usize {
303    resources.iter_base_materials().count()
304        + resources.iter_color_groups().count()
305        + resources.iter_texture_2d().count()
306}
307
308// ---------------------------------------------------------------------------
309// Placement: plate-per-file mode (check bounding box overlaps)
310// ---------------------------------------------------------------------------
311
312/// In plate-per-file mode, preserve all transforms as-is and warn about overlaps.
313pub(crate) fn check_build_item_overlaps(model: &Model, verbosity: Verbosity) {
314    if matches!(verbosity, Verbosity::Quiet) {
315        return;
316    }
317
318    // Collect (build_item_index, world_space AABB) for each item with a mesh
319    let world_aabbs: Vec<(usize, BoundingBox)> = model
320        .build
321        .items
322        .iter()
323        .enumerate()
324        .filter_map(|(i, item)| {
325            let obj = model
326                .resources
327                .iter_objects()
328                .find(|o| o.id == item.object_id)?;
329            let aabb = mesh_aabb_for_object(obj, model)?;
330            let world_aabb = aabb.transform(item.transform);
331            Some((i, world_aabb))
332        })
333        .collect();
334
335    // O(n^2) pairwise overlap check — fine for typical merge counts
336    let n = world_aabbs.len();
337    for i in 0..n {
338        for j in (i + 1)..n {
339            let (idx_i, ref aabb_i) = world_aabbs[i];
340            let (idx_j, ref aabb_j) = world_aabbs[j];
341            if aabbs_overlap(aabb_i, aabb_j) {
342                eprintln!(
343                    "Warning: build items {} and {} have overlapping bounding boxes",
344                    idx_i, idx_j
345                );
346            }
347        }
348    }
349}
350
351/// Get the mesh AABB for an object (recursively resolves component objects).
352/// Returns None for non-mesh geometry or empty meshes.
353fn mesh_aabb_for_object(obj: &Object, model: &Model) -> Option<BoundingBox> {
354    match &obj.geometry {
355        Geometry::Mesh(mesh) => mesh.compute_aabb(),
356        Geometry::DisplacementMesh(dm) => {
357            // DisplacementMesh uses the same vertex layout as Mesh — compute AABB from vertices
358            if dm.vertices.is_empty() {
359                return None;
360            }
361            let mut min = [f32::INFINITY; 3];
362            let mut max = [f32::NEG_INFINITY; 3];
363            for v in &dm.vertices {
364                min[0] = min[0].min(v.x);
365                min[1] = min[1].min(v.y);
366                min[2] = min[2].min(v.z);
367                max[0] = max[0].max(v.x);
368                max[1] = max[1].max(v.y);
369                max[2] = max[2].max(v.z);
370            }
371            Some(BoundingBox { min, max })
372        }
373        Geometry::Components(comps) => {
374            // For component objects, compute union of child AABBs
375            let mut combined: Option<BoundingBox> = None;
376            for comp in &comps.components {
377                if let Some(child_obj) = model
378                    .resources
379                    .iter_objects()
380                    .find(|o| o.id == comp.object_id)
381                    && let Some(child_aabb) = mesh_aabb_for_object(child_obj, model)
382                {
383                    let transformed = child_aabb.transform(comp.transform);
384                    combined = Some(match combined {
385                        None => transformed,
386                        Some(existing) => union_aabb(existing, transformed),
387                    });
388                }
389            }
390            combined
391        }
392        _ => None,
393    }
394}
395
396fn union_aabb(a: BoundingBox, b: BoundingBox) -> BoundingBox {
397    BoundingBox {
398        min: [
399            a.min[0].min(b.min[0]),
400            a.min[1].min(b.min[1]),
401            a.min[2].min(b.min[2]),
402        ],
403        max: [
404            a.max[0].max(b.max[0]),
405            a.max[1].max(b.max[1]),
406            a.max[2].max(b.max[2]),
407        ],
408    }
409}
410
411fn aabbs_overlap(a: &BoundingBox, b: &BoundingBox) -> bool {
412    a.min[0] < b.max[0]
413        && a.max[0] > b.min[0]
414        && a.min[1] < b.max[1]
415        && a.max[1] > b.min[1]
416        && a.min[2] < b.max[2]
417        && a.max[2] > b.min[2]
418}
419
420// ---------------------------------------------------------------------------
421// Placement: single-plate mode (grid layout)
422// ---------------------------------------------------------------------------
423
424/// In single-plate mode, replace ALL build item transforms with grid-computed ones.
425pub(crate) fn apply_single_plate_placement(
426    model: &mut Model,
427    _pack: PackAlgorithm,
428    verbosity: Verbosity,
429) -> anyhow::Result<()> {
430    const SPACING_MM: f32 = 10.0;
431
432    // Collect (item_index, size_x, size_y) for grid layout
433    // Items without computable AABBs get placed at origin
434    struct ItemInfo {
435        size_x: f32,
436        size_y: f32,
437    }
438
439    let item_infos: Vec<ItemInfo> = model
440        .build
441        .items
442        .iter()
443        .map(|item| {
444            let aabb = model
445                .resources
446                .iter_objects()
447                .find(|o| o.id == item.object_id)
448                .and_then(|obj| mesh_aabb_for_object(obj, model));
449            match aabb {
450                Some(bb) => ItemInfo {
451                    size_x: (bb.max[0] - bb.min[0]).max(0.0),
452                    size_y: (bb.max[1] - bb.min[1]).max(0.0),
453                },
454                None => ItemInfo {
455                    size_x: 0.0,
456                    size_y: 0.0,
457                },
458            }
459        })
460        .collect();
461
462    let n = item_infos.len();
463    if n == 0 {
464        return Ok(());
465    }
466
467    // Grid: square layout — number of columns = ceil(sqrt(n))
468    let cols = (n as f64).sqrt().ceil() as usize;
469
470    // Track current x/y cursor and row heights
471    let mut row_heights: Vec<f32> = Vec::new();
472    let mut col_widths: Vec<f32> = Vec::new();
473
474    // Pre-compute per-cell widths and heights for the grid
475    for (idx, info) in item_infos.iter().enumerate() {
476        let col = idx % cols;
477        let row = idx / cols;
478        if col_widths.len() <= col {
479            col_widths.resize(col + 1, 0.0_f32);
480        }
481        if row_heights.len() <= row {
482            row_heights.resize(row + 1, 0.0_f32);
483        }
484        col_widths[col] = col_widths[col].max(info.size_x);
485        row_heights[row] = row_heights[row].max(info.size_y);
486    }
487
488    // Compute cumulative x/y positions
489    let mut x_offsets = vec![0.0_f32; cols + 1];
490    let mut y_offsets = vec![0.0_f32; row_heights.len() + 1];
491    for c in 0..cols {
492        x_offsets[c + 1] = x_offsets[c] + col_widths[c] + SPACING_MM;
493    }
494    for r in 0..row_heights.len() {
495        y_offsets[r + 1] = y_offsets[r] + row_heights[r] + SPACING_MM;
496    }
497
498    // Apply transforms: place each item at its grid cell's (x_offset, y_offset, 0)
499    for (idx, item) in model.build.items.iter_mut().enumerate() {
500        let col = idx % cols;
501        let row = idx / cols;
502        let tx = x_offsets[col];
503        let ty = y_offsets[row];
504        item.transform = glam::Mat4::from_translation(glam::Vec3::new(tx, ty, 0.0));
505    }
506
507    if matches!(verbosity, Verbosity::Verbose) {
508        eprintln!(
509            "Single-plate grid layout: {} items in {}x{} grid",
510            n,
511            cols,
512            row_heights.len()
513        );
514    }
515
516    Ok(())
517}
518
519// ---------------------------------------------------------------------------
520// Internal helper: load a 3MF file with full attachments
521// ---------------------------------------------------------------------------
522
523pub(crate) fn load_full(path: &Path) -> anyhow::Result<Model> {
524    let file = File::open(path).map_err(|e| anyhow::anyhow!("Failed to open {:?}: {}", path, e))?;
525    let mut archiver = ZipArchiver::new(file)
526        .map_err(|e| anyhow::anyhow!("Failed to open zip archive {:?}: {}", path, e))?;
527    let model_path = find_model_path(&mut archiver)
528        .map_err(|e| anyhow::anyhow!("Failed to find model path in {:?}: {}", path, e))?;
529    let model_data = archiver
530        .read_entry(&model_path)
531        .map_err(|e| anyhow::anyhow!("Failed to read model XML from {:?}: {}", path, e))?;
532    let mut model = parse_model(Cursor::new(model_data))
533        .map_err(|e| anyhow::anyhow!("Failed to parse model XML from {:?}: {}", path, e))?;
534
535    // Load all entries — preserve attachments and relationships, like the copy command.
536    let all_files = archiver
537        .list_entries()
538        .map_err(|e| anyhow::anyhow!("Failed to list archive entries in {:?}: {}", path, e))?;
539
540    for entry_path in all_files {
541        // Skip files that PackageWriter regenerates
542        if entry_path == model_path
543            || entry_path == "_rels/.rels"
544            || entry_path == "[Content_Types].xml"
545        {
546            continue;
547        }
548
549        // Load .rels files to preserve relationships
550        if entry_path.ends_with(".rels") {
551            if let Ok(data) = archiver.read_entry(&entry_path)
552                && let Ok(rels) = opc::parse_relationships(&data)
553            {
554                model.existing_relationships.insert(entry_path, rels);
555            }
556            continue;
557        }
558
559        // Load other data as attachments
560        if let Ok(data) = archiver.read_entry(&entry_path) {
561            model.attachments.insert(entry_path, data);
562        }
563    }
564
565    Ok(model)
566}
567
568// ---------------------------------------------------------------------------
569// Internal helper: check for secure content
570// ---------------------------------------------------------------------------
571
572pub(crate) fn check_secure_content(model: &Model) -> anyhow::Result<()> {
573    if model.resources.key_store.is_some() {
574        anyhow::bail!(
575            "Cannot merge signed/encrypted 3MF files. Strip signatures first using the decrypt command."
576        );
577    }
578    Ok(())
579}
580
581// ---------------------------------------------------------------------------
582// Internal helper: compute maximum ResourceId across all resource types
583// ---------------------------------------------------------------------------
584
585pub(crate) fn max_resource_id(model: &Model) -> u32 {
586    let r = &model.resources;
587    let mut max = 0u32;
588    for obj in r.iter_objects() {
589        max = max.max(obj.id.0);
590    }
591    for mat in r.iter_base_materials() {
592        max = max.max(mat.id.0);
593    }
594    for col in r.iter_color_groups() {
595        max = max.max(col.id.0);
596    }
597    for tex in r.iter_texture_2d() {
598        max = max.max(tex.id.0);
599    }
600    for grp in r.iter_textures() {
601        max = max.max(grp.id.0);
602    }
603    for comp in r.iter_composite_materials() {
604        max = max.max(comp.id.0);
605    }
606    for mp in r.iter_multi_properties() {
607        max = max.max(mp.id.0);
608    }
609    for ss in r.iter_slice_stacks() {
610        max = max.max(ss.id.0);
611    }
612    for vs in r.iter_volumetric_stacks() {
613        max = max.max(vs.id.0);
614    }
615    for d in r.iter_displacement_2d() {
616        max = max.max(d.id.0);
617    }
618    max
619}
620
621// ---------------------------------------------------------------------------
622// ID remap helpers
623// ---------------------------------------------------------------------------
624
625#[inline]
626fn remap_id(id: &mut ResourceId, offset: u32) {
627    id.0 += offset;
628}
629
630#[inline]
631fn remap_opt_id(id: &mut Option<ResourceId>, offset: u32) {
632    if let Some(inner) = id {
633        inner.0 += offset;
634    }
635}
636
637#[inline]
638fn remap_opt_u32_pid(pid: &mut Option<u32>, offset: u32) {
639    if let Some(p) = pid {
640        *p += offset;
641    }
642}
643
644// ---------------------------------------------------------------------------
645// Internal helper: remap all ResourceId cross-references in a model
646// ---------------------------------------------------------------------------
647//
648// After calling this function, every resource in the model has IDs shifted
649// by `offset`, and every cross-reference is updated to match. This allows
650// models with overlapping ID namespaces to be merged without collisions.
651
652pub(crate) fn remap_model(model: &mut Model, offset: u32) {
653    if offset == 0 {
654        return;
655    }
656
657    // --- Collect all resources out of the collection so we can mutate them ---
658    // Since ResourceCollection uses private HashMap fields, we collect resources
659    // via iterators (cloning), remap their IDs, and then rebuild the collection
660    // using add_* methods.
661
662    let old_resources = std::mem::take(&mut model.resources);
663
664    // Collect and remap objects
665    let mut objects: Vec<Object> = old_resources.iter_objects().cloned().collect();
666    for obj in &mut objects {
667        remap_id(&mut obj.id, offset);
668        remap_opt_id(&mut obj.pid, offset);
669        match &mut obj.geometry {
670            Geometry::Mesh(mesh) => {
671                for tri in &mut mesh.triangles {
672                    remap_opt_u32_pid(&mut tri.pid, offset);
673                }
674            }
675            Geometry::Components(comps) => {
676                for comp in &mut comps.components {
677                    remap_id(&mut comp.object_id, offset);
678                }
679            }
680            Geometry::BooleanShape(shape) => {
681                remap_id(&mut shape.base_object_id, offset);
682                for op in &mut shape.operations {
683                    remap_id(&mut op.object_id, offset);
684                }
685            }
686            Geometry::SliceStack(id) => {
687                remap_id(&mut *id, offset);
688            }
689            Geometry::VolumetricStack(id) => {
690                remap_id(&mut *id, offset);
691            }
692            Geometry::DisplacementMesh(dm) => {
693                for tri in &mut dm.triangles {
694                    remap_opt_u32_pid(&mut tri.pid, offset);
695                }
696            }
697        }
698    }
699
700    // Collect and remap base material groups
701    let mut base_materials: Vec<BaseMaterialsGroup> =
702        old_resources.iter_base_materials().cloned().collect();
703    for mat in &mut base_materials {
704        remap_id(&mut mat.id, offset);
705    }
706
707    // Collect and remap color groups
708    let mut color_groups: Vec<ColorGroup> = old_resources.iter_color_groups().cloned().collect();
709    for col in &mut color_groups {
710        remap_id(&mut col.id, offset);
711    }
712
713    // Collect and remap Texture2D resources
714    let mut texture_2d: Vec<Texture2D> = old_resources.iter_texture_2d().cloned().collect();
715    for tex in &mut texture_2d {
716        remap_id(&mut tex.id, offset);
717    }
718
719    // Collect and remap Texture2DGroup resources
720    let mut texture_2d_groups: Vec<Texture2DGroup> =
721        old_resources.iter_textures().cloned().collect();
722    for grp in &mut texture_2d_groups {
723        remap_id(&mut grp.id, offset);
724        remap_id(&mut grp.texture_id, offset);
725    }
726
727    // Collect and remap composite materials
728    let mut composite_materials: Vec<CompositeMaterials> =
729        old_resources.iter_composite_materials().cloned().collect();
730    for comp in &mut composite_materials {
731        remap_id(&mut comp.id, offset);
732        remap_id(&mut comp.base_material_id, offset);
733    }
734
735    // Collect and remap multi-properties
736    let mut multi_properties: Vec<MultiProperties> =
737        old_resources.iter_multi_properties().cloned().collect();
738    for mp in &mut multi_properties {
739        remap_id(&mut mp.id, offset);
740        for pid in &mut mp.pids {
741            remap_id(pid, offset);
742        }
743    }
744
745    // Collect and remap slice stacks
746    let mut slice_stacks: Vec<SliceStack> = old_resources.iter_slice_stacks().cloned().collect();
747    for ss in &mut slice_stacks {
748        remap_id(&mut ss.id, offset);
749    }
750
751    // Collect and remap volumetric stacks
752    let mut volumetric_stacks: Vec<VolumetricStack> =
753        old_resources.iter_volumetric_stacks().cloned().collect();
754    for vs in &mut volumetric_stacks {
755        remap_id(&mut vs.id, offset);
756    }
757
758    // Collect and remap Displacement2D resources
759    let mut displacement_2d: Vec<Displacement2D> =
760        old_resources.iter_displacement_2d().cloned().collect();
761    for d in &mut displacement_2d {
762        remap_id(&mut d.id, offset);
763    }
764
765    // Preserve key_store as-is (secure content check already done before remap)
766    let key_store = old_resources.key_store;
767
768    // Rebuild ResourceCollection with remapped resources
769    let mut new_resources = lib3mf_core::model::ResourceCollection::new();
770    for obj in objects {
771        new_resources
772            .add_object(obj)
773            .expect("Remapped IDs should not collide within the same model");
774    }
775    for mat in base_materials {
776        new_resources
777            .add_base_materials(mat)
778            .expect("Remapped IDs should not collide within the same model");
779    }
780    for col in color_groups {
781        new_resources
782            .add_color_group(col)
783            .expect("Remapped IDs should not collide within the same model");
784    }
785    for tex in texture_2d {
786        new_resources
787            .add_texture_2d(tex)
788            .expect("Remapped IDs should not collide within the same model");
789    }
790    for grp in texture_2d_groups {
791        new_resources
792            .add_texture_2d_group(grp)
793            .expect("Remapped IDs should not collide within the same model");
794    }
795    for comp in composite_materials {
796        new_resources
797            .add_composite_materials(comp)
798            .expect("Remapped IDs should not collide within the same model");
799    }
800    for mp in multi_properties {
801        new_resources
802            .add_multi_properties(mp)
803            .expect("Remapped IDs should not collide within the same model");
804    }
805    for ss in slice_stacks {
806        new_resources
807            .add_slice_stack(ss)
808            .expect("Remapped IDs should not collide within the same model");
809    }
810    for vs in volumetric_stacks {
811        new_resources
812            .add_volumetric_stack(vs)
813            .expect("Remapped IDs should not collide within the same model");
814    }
815    for d in displacement_2d {
816        new_resources
817            .add_displacement_2d(d)
818            .expect("Remapped IDs should not collide within the same model");
819    }
820    if let Some(ks) = key_store {
821        new_resources.set_key_store(ks);
822    }
823
824    model.resources = new_resources;
825
826    // Remap build items
827    for item in &mut model.build.items {
828        remap_id(&mut item.object_id, offset);
829    }
830}
831
832// ---------------------------------------------------------------------------
833// Internal helper: merge attachments with path deduplication
834// ---------------------------------------------------------------------------
835//
836// For each attachment from the source model:
837// - If path doesn't exist in merged: insert directly
838// - If path exists and content is byte-identical: dedup (reuse existing path)
839// - If path exists and content differs: rename to "{path}.{file_index}"
840//
841// Returns a path remap map: old_path -> new_path. Caller must update
842// Texture2D.path and Displacement2D.path in the remapped model.
843
844pub(crate) fn merge_attachments(
845    merged: &mut HashMap<String, Vec<u8>>,
846    source: HashMap<String, Vec<u8>>,
847    file_index: usize,
848) -> HashMap<String, String> {
849    let mut path_remap: HashMap<String, String> = HashMap::new();
850
851    for (path, data) in source {
852        if let Some(existing) = merged.get(&path) {
853            if *existing == data {
854                // Same content — deduplicate, keep existing path
855                path_remap.insert(path.clone(), path);
856            } else {
857                // Different content — rename with file index suffix
858                let new_path = format!("{}.{}", path, file_index);
859                merged.insert(new_path.clone(), data);
860                path_remap.insert(path, new_path);
861            }
862        } else {
863            merged.insert(path.clone(), data);
864            path_remap.insert(path.clone(), path);
865        }
866    }
867
868    path_remap
869}
870
871// ---------------------------------------------------------------------------
872// Internal helper: merge metadata with semicolon concatenation
873// ---------------------------------------------------------------------------
874
875pub(crate) fn merge_metadata(
876    merged: &mut HashMap<String, String>,
877    source: &HashMap<String, String>,
878) {
879    for (key, value) in source {
880        merged
881            .entry(key.clone())
882            .and_modify(|existing| {
883                existing.push_str("; ");
884                existing.push_str(value);
885            })
886            .or_insert_with(|| value.clone());
887    }
888}
889
890// ---------------------------------------------------------------------------
891// Internal helper: merge OPC relationships
892// ---------------------------------------------------------------------------
893
894pub(crate) fn merge_relationships(
895    merged: &mut HashMap<String, Vec<lib3mf_core::archive::opc::Relationship>>,
896    source: HashMap<String, Vec<lib3mf_core::archive::opc::Relationship>>,
897) {
898    for (path, rels) in source {
899        merged.entry(path).or_insert(rels);
900    }
901}
902
903// ---------------------------------------------------------------------------
904// Internal helper: merge extra XML namespaces
905// ---------------------------------------------------------------------------
906
907pub(crate) fn merge_extra_namespaces(
908    merged: &mut HashMap<String, String>,
909    source: &HashMap<String, String>,
910) {
911    for (prefix, uri) in source {
912        if let Some(existing_uri) = merged.get(prefix) {
913            if existing_uri != uri {
914                // Prefix collision with different URI — log a warning and skip
915                eprintln!(
916                    "Warning: XML namespace prefix '{prefix}' has conflicting URIs ('{existing_uri}' vs '{uri}'). Keeping first."
917                );
918            }
919            // Same prefix+URI: silently skip duplicate
920        } else {
921            merged.insert(prefix.clone(), uri.clone());
922        }
923    }
924}
925
926// ---------------------------------------------------------------------------
927// Internal helper: update texture and displacement paths after attachment remap
928// ---------------------------------------------------------------------------
929
930pub(crate) fn update_texture_paths(model: &mut Model, path_remap: &HashMap<String, String>) {
931    if path_remap.is_empty() {
932        return;
933    }
934
935    // Collect updated Texture2D resources
936    let old_resources = std::mem::take(&mut model.resources);
937
938    let mut texture_2d: Vec<Texture2D> = old_resources.iter_texture_2d().cloned().collect();
939    for tex in &mut texture_2d {
940        if let Some(new_path) = path_remap.get(&tex.path) {
941            tex.path = new_path.clone();
942        }
943    }
944
945    let mut displacement_2d: Vec<Displacement2D> =
946        old_resources.iter_displacement_2d().cloned().collect();
947    for d in &mut displacement_2d {
948        if let Some(new_path) = path_remap.get(&d.path) {
949            d.path = new_path.clone();
950        }
951    }
952
953    // Only rebuild if something changed — otherwise just put back
954    let textures_changed = texture_2d
955        .iter()
956        .zip(old_resources.iter_texture_2d())
957        .any(|(new, old)| new.path != old.path);
958    let displacement_changed = displacement_2d
959        .iter()
960        .zip(old_resources.iter_displacement_2d())
961        .any(|(new, old)| new.path != old.path);
962
963    if !textures_changed && !displacement_changed {
964        model.resources = old_resources;
965        return;
966    }
967
968    // Rebuild ResourceCollection with updated paths
969    let key_store = old_resources.key_store.clone();
970    let objects: Vec<Object> = old_resources.iter_objects().cloned().collect();
971    let base_materials: Vec<_> = old_resources.iter_base_materials().cloned().collect();
972    let color_groups: Vec<_> = old_resources.iter_color_groups().cloned().collect();
973    let texture_2d_groups: Vec<Texture2DGroup> = old_resources.iter_textures().cloned().collect();
974    let composite_materials: Vec<_> = old_resources.iter_composite_materials().cloned().collect();
975    let multi_properties: Vec<_> = old_resources.iter_multi_properties().cloned().collect();
976    let slice_stacks: Vec<_> = old_resources.iter_slice_stacks().cloned().collect();
977    let volumetric_stacks: Vec<_> = old_resources.iter_volumetric_stacks().cloned().collect();
978
979    let mut new_resources = lib3mf_core::model::ResourceCollection::new();
980    for obj in objects {
981        new_resources.add_object(obj).expect("no ID collision");
982    }
983    for mat in base_materials {
984        new_resources
985            .add_base_materials(mat)
986            .expect("no ID collision");
987    }
988    for col in color_groups {
989        new_resources.add_color_group(col).expect("no ID collision");
990    }
991    for tex in texture_2d {
992        new_resources.add_texture_2d(tex).expect("no ID collision");
993    }
994    for grp in texture_2d_groups {
995        new_resources
996            .add_texture_2d_group(grp)
997            .expect("no ID collision");
998    }
999    for comp in composite_materials {
1000        new_resources
1001            .add_composite_materials(comp)
1002            .expect("no ID collision");
1003    }
1004    for mp in multi_properties {
1005        new_resources
1006            .add_multi_properties(mp)
1007            .expect("no ID collision");
1008    }
1009    for ss in slice_stacks {
1010        new_resources.add_slice_stack(ss).expect("no ID collision");
1011    }
1012    for vs in volumetric_stacks {
1013        new_resources
1014            .add_volumetric_stack(vs)
1015            .expect("no ID collision");
1016    }
1017    for d in displacement_2d {
1018        new_resources
1019            .add_displacement_2d(d)
1020            .expect("no ID collision");
1021    }
1022    if let Some(ks) = key_store {
1023        new_resources.set_key_store(ks);
1024    }
1025
1026    model.resources = new_resources;
1027}
1028
1029// ---------------------------------------------------------------------------
1030// Internal helper: expand glob patterns in input list
1031// ---------------------------------------------------------------------------
1032
1033pub(crate) fn expand_inputs(raw_inputs: Vec<PathBuf>) -> anyhow::Result<Vec<PathBuf>> {
1034    let mut expanded = Vec::new();
1035    for input in raw_inputs {
1036        let pattern = input.to_string_lossy();
1037        if pattern.contains('*') || pattern.contains('?') || pattern.contains('[') {
1038            let matches: Vec<PathBuf> = glob(&pattern)
1039                .map_err(|e| anyhow::anyhow!("Invalid glob pattern {:?}: {}", input, e))?
1040                .filter_map(|r| r.ok())
1041                .filter(|p| {
1042                    p.extension()
1043                        .and_then(|e| e.to_str())
1044                        .map(|e| e.to_lowercase())
1045                        == Some("3mf".to_string())
1046                })
1047                .collect();
1048            if matches.is_empty() {
1049                anyhow::bail!("Glob pattern {:?} matched no .3mf files", input);
1050            }
1051            expanded.extend(matches);
1052        } else {
1053            expanded.push(input);
1054        }
1055    }
1056    if expanded.len() < 2 {
1057        anyhow::bail!(
1058            "Merge requires at least 2 input files (got {})",
1059            expanded.len()
1060        );
1061    }
1062    Ok(expanded)
1063}