1use 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#[derive(Debug, Clone, Copy, clap::ValueEnum, Default)]
31pub enum PackAlgorithm {
32 #[default]
34 Grid,
35}
36
37#[derive(Debug, Clone, Copy)]
39pub enum Verbosity {
40 Quiet,
42 Normal,
44 Verbose,
46}
47
48pub 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 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 let out_path = resolve_output_path(&output, force)?;
70
71 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 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 let actual_file_index = file_index + 1;
91
92 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 remap_model(&mut source, offset);
105
106 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 update_texture_paths(&mut source, &path_remap);
114
115 let src_objects = count_objects_resources(&source.resources);
117 let src_materials = count_materials_resources(&source.resources);
118
119 transfer_resources(&mut merged, source.resources)?;
121
122 merged.build.items.extend(source.build.items);
124
125 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 if single_plate {
139 apply_single_plate_placement(&mut merged, pack, verbosity)?;
140 } else {
141 check_build_item_overlaps(&merged, verbosity);
142 }
143
144 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 let _ = std::fs::remove_file(&tmp_path);
163 anyhow::anyhow!("Failed to finalize output file {:?}: {}", out_path, e)
164 })?;
165
166 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
178fn 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 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
207fn 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
284fn 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
308pub(crate) fn check_build_item_overlaps(model: &Model, verbosity: Verbosity) {
314 if matches!(verbosity, Verbosity::Quiet) {
315 return;
316 }
317
318 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 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
351fn 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 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 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
420pub(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 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 let cols = (n as f64).sqrt().ceil() as usize;
469
470 let mut row_heights: Vec<f32> = Vec::new();
472 let mut col_widths: Vec<f32> = Vec::new();
473
474 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 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 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
519pub(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 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 if entry_path == model_path
543 || entry_path == "_rels/.rels"
544 || entry_path == "[Content_Types].xml"
545 {
546 continue;
547 }
548
549 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 if let Ok(data) = archiver.read_entry(&entry_path) {
561 model.attachments.insert(entry_path, data);
562 }
563 }
564
565 Ok(model)
566}
567
568pub(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
581pub(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#[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
644pub(crate) fn remap_model(model: &mut Model, offset: u32) {
653 if offset == 0 {
654 return;
655 }
656
657 let old_resources = std::mem::take(&mut model.resources);
663
664 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 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 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 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 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 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 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 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 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 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 let key_store = old_resources.key_store;
767
768 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 for item in &mut model.build.items {
828 remap_id(&mut item.object_id, offset);
829 }
830}
831
832pub(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 path_remap.insert(path.clone(), path);
856 } else {
857 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
871pub(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
890pub(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
903pub(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 eprintln!(
916 "Warning: XML namespace prefix '{prefix}' has conflicting URIs ('{existing_uri}' vs '{uri}'). Keeping first."
917 );
918 }
919 } else {
921 merged.insert(prefix.clone(), uri.clone());
922 }
923 }
924}
925
926pub(crate) fn update_texture_paths(model: &mut Model, path_remap: &HashMap<String, String>) {
931 if path_remap.is_empty() {
932 return;
933 }
934
935 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 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 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
1029pub(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}