From b77b5afd49ffac9fd4e854114f8ff71d07769c51 Mon Sep 17 00:00:00 2001 From: packerlschupfer <83344883+packerlschupfer@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:44:20 +0200 Subject: [PATCH] 3mf: do not declare thumbnail relationships for parts that are not written (#16306) A 3mf saved without a plate thumbnail, such as a CLI export with no OpenGL context or a backup, still listed /Metadata/plate_1.png and plate_1_small.png in _rels/.rels. lib3mf rejects a package whose thumbnail relationship points at a missing part. The writer now records which plate thumbnails it wrote and only declares those. --- src/libslic3r/Format/bbs_3mf.cpp | 46 +++++++++++++++------ tests/libslic3r/test_3mf.cpp | 68 ++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 12 deletions(-) diff --git a/src/libslic3r/Format/bbs_3mf.cpp b/src/libslic3r/Format/bbs_3mf.cpp index 24c2c027e5..5419434936 100644 --- a/src/libslic3r/Format/bbs_3mf.cpp +++ b/src/libslic3r/Format/bbs_3mf.cpp @@ -6262,7 +6262,9 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) std::vector const &targets = {}, std::vector const &types = {}, PackingTemporaryData data = PackingTemporaryData(), - int export_plate_idx = -1) const; + int export_plate_idx = -1, + bool plate_thumbnail_written = false, + bool plate_small_thumbnail_written = false) const; bool _add_model_file_to_archive(const std::string& filename, mz_zip_archive& archive, const Model& model, ObjectToObjectDataMap& objects_data, Export3mfProgressFn proFn = nullptr, BBLProject* project = nullptr) const; bool _add_object_to_model_stream(mz_zip_writer_staged_context &context, ObjectData const &object_data) const; void _add_object_components_to_stream(std::stringstream &stream, ObjectData const &object_data) const; @@ -6472,6 +6474,11 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ":" <<__LINE__ << boost::format(",top&&pick thumbnails, count %1%")%top_thumbnail_data.size(); //BBS: add thumbnail for each plate + //ORCA: the relationships file points at plate 1's thumbnail for a whole-project export, + // otherwise at the exported plate's. Track whether that thumbnail was written. + const size_t rels_plate_idx = (export_plate_idx < 0) ? 0 : static_cast(export_plate_idx); + bool rels_thumbnail_written = false; + bool rels_small_thumbnail_written = false; if (!m_skip_static) { std::vector thumbnail_status(plate_data_list.size(), false); std::vector no_light_thumbnail_status(plate_data_list.size(), false); @@ -6520,6 +6527,11 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ":" <<__LINE__ << boost::format(",add thumbnail %1%'s data into 3mf")%(index+1); thumbnail_status[index] = true; + if (static_cast(index) == rels_plate_idx) { + //ORCA: this call also writes the _small companion + rels_thumbnail_written = true; + rels_small_thumbnail_written = true; + } } } @@ -6566,6 +6578,9 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << ":" << __LINE__ << boost::format(", add thumbnail %1% from file %2% failed\n") % (i+1) %plate_data->thumbnail_file; return false; } + //ORCA: copied from file, so there is no _small companion for this plate + if (static_cast(i) == rels_plate_idx) + rels_thumbnail_written = true; } if (!no_light_thumbnail_status[i] && !plate_data->no_light_thumbnail_file.empty() && (boost::filesystem::exists(plate_data->no_light_thumbnail_file))){ @@ -6860,7 +6875,7 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) // Adds relationships file ("_rels/.rels"). // The content of this file is the same for each OrcaSlicer 3mf. // The relationshis file contains a reference to the geometry file "3D/3dmodel.model", the name was chosen to be compatible with CURA. - if (!_add_relationships_file_to_archive(archive, {}, {}, {}, temp_data, export_plate_idx)) { + if (!_add_relationships_file_to_archive(archive, {}, {}, {}, temp_data, export_plate_idx, rels_thumbnail_written, rels_small_thumbnail_written)) { BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << ":" <<__LINE__ << boost::format(", _add_relationships_file_to_archive failed\n"); return false; } @@ -7035,7 +7050,8 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) } bool _BBS_3MF_Exporter::_add_relationships_file_to_archive( - mz_zip_archive &archive, std::string const &from, std::vector const &targets, std::vector const &types, PackingTemporaryData data, int export_plate_idx) const + mz_zip_archive &archive, std::string const &from, std::vector const &targets, std::vector const &types, PackingTemporaryData data, int export_plate_idx, + bool plate_thumbnail_written, bool plate_small_thumbnail_written) const { std::stringstream stream; stream << "\n"; @@ -7046,7 +7062,9 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) if (export_plate_idx < 0) { //use cover image if have if (data._3mf_thumbnail.empty()) { - stream << " \n"; } else { stream << " \n"; + if (plate_thumbnail_written) + stream << " \n"; } else { stream << " \n"; } if (data._3mf_printer_thumbnail_small.empty()) { - stream << "\n"; + if (plate_small_thumbnail_written) + stream << "\n"; } else { stream << " \n"; } } - else { + else if (plate_thumbnail_written) { //always use plate thumbnails std::string thumbnail_file_str = (boost::format("Metadata/plate_%1%.png") % (export_plate_idx + 1)).str(); stream << " \n"; - thumbnail_file_str = (boost::format("Metadata/plate_%1%_small.png") % (export_plate_idx + 1)).str(); - stream << " \n"; + if (plate_small_thumbnail_written) { + thumbnail_file_str = (boost::format("Metadata/plate_%1%_small.png") % (export_plate_idx + 1)).str(); + stream << " \n"; + } } } else if (targets.empty()) { diff --git a/tests/libslic3r/test_3mf.cpp b/tests/libslic3r/test_3mf.cpp index a59800fafe..e74e14fb44 100644 --- a/tests/libslic3r/test_3mf.cpp +++ b/tests/libslic3r/test_3mf.cpp @@ -7,6 +7,7 @@ #include "libslic3r/Model.hpp" #include "libslic3r/Format/3mf.hpp" #include "libslic3r/Format/bbs_3mf.hpp" +#include "libslic3r/GCode/ThumbnailData.hpp" #include "libslic3r/Format/STL.hpp" #include "libslic3r/Utils.hpp" #include "libslic3r/miniz_extension.hpp" @@ -2245,3 +2246,70 @@ TEST_CASE("Loading a gcode.3mf from a stream keeps the plate's pick and pattern CHECK(dst_plates.front()->pattern_bbox_file == "Metadata/plate_1.json"); release_PlateData_list(dst_plates); } + +// A relationship must name a part the package actually contains. The writer used to fall +// back to /Metadata/plate_1.png whenever no cover image was set, so an export that wrote no +// plate thumbnail still declared one -- a dangling relationship a strict OPC reader may reject. +SCENARIO("thumbnail relationships are declared only for thumbnails that were written", "[3mf]") +{ + GIVEN("a project with one plate") { + Model model; + const std::string src_file = std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl"; + REQUIRE(load_stl(src_file.c_str(), &model)); + model.add_default_instances(); + + ScopedTemporaryDir backup_dir("orca_rels"); + model.set_backup_path(backup_dir.string()); + + PlateData plate; + plate.plate_index = 0; + PlateDataPtrs plates{ &plate }; + + auto store = [&](const std::string &path, std::vector thumbnails) { + DynamicPrintConfig cfg; + StoreParams sp; + sp.path = path.c_str(); + sp.model = &model; + sp.config = &cfg; + sp.plate_data_list = plates; + sp.thumbnail_data = std::move(thumbnails); + sp.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence; + REQUIRE(store_bbs_3mf(sp)); + }; + + WHEN("no thumbnail is rendered") { + ScopedTemporaryFile temp(".3mf"); + ThumbnailData none; // left invalid: nothing was rendered + REQUIRE_FALSE(none.is_valid()); + store(temp.string(), { &none }); + + THEN("the package declares no thumbnail relationship") { + std::string rels; + REQUIRE(read_cad_recipe_entry(temp.string(), rels, "_rels/.rels")); + CHECK(rels.find("3dmodel") != std::string::npos); // the model is still declared + CHECK(rels.find("plate_1.png") == std::string::npos); + CHECK(rels.find("thumbnail") == std::string::npos); + } + } + + WHEN("a plate thumbnail is rendered") { + ScopedTemporaryFile temp(".3mf"); + ThumbnailData rendered; + rendered.set(16, 16); + rendered.pixels.assign(16 * 16 * 4, static_cast(0xff)); + REQUIRE(rendered.is_valid()); + store(temp.string(), { &rendered }); + + THEN("the relationships name it, and the parts are in the package") { + std::string rels; + REQUIRE(read_cad_recipe_entry(temp.string(), rels, "_rels/.rels")); + CHECK(rels.find("/Metadata/plate_1.png") != std::string::npos); + CHECK(rels.find("/Metadata/plate_1_small.png") != std::string::npos); + + std::string part; + CHECK(read_cad_recipe_entry(temp.string(), part, "Metadata/plate_1.png")); + CHECK(read_cad_recipe_entry(temp.string(), part, "Metadata/plate_1_small.png")); + } + } + } +}