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.
This commit is contained in:
packerlschupfer
2026-10-13 08:44:20 -05:00
committed by GitHub
parent f546551b3f
commit b77b5afd49
2 changed files with 102 additions and 12 deletions
+34 -12
View File
@@ -6262,7 +6262,9 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
std::vector<std::string> const &targets = {},
std::vector<std::string> 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<size_t>(export_plate_idx);
bool rels_thumbnail_written = false;
bool rels_small_thumbnail_written = false;
if (!m_skip_static) {
std::vector<bool> thumbnail_status(plate_data_list.size(), false);
std::vector<bool> 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<size_t>(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<size_t>(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<std::string> const &targets, std::vector<std::string> const &types, PackingTemporaryData data, int export_plate_idx) const
mz_zip_archive &archive, std::string const &from, std::vector<std::string> const &targets, std::vector<std::string> const &types, PackingTemporaryData data, int export_plate_idx,
bool plate_thumbnail_written, bool plate_small_thumbnail_written) const
{
std::stringstream stream;
stream << "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\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 << " <Relationship Target=\"/Metadata/plate_1.png"
// ORCA: only reference the plate thumbnail if it was written
if (plate_thumbnail_written)
stream << " <Relationship Target=\"/Metadata/plate_1.png"
<< "\" Id=\"rel-2\" Type=\"http://schemas.openxmlformats.org/package/2006/relationships/metadata/thumbnail\"/>\n";
} else {
stream << " <Relationship Target=\"/" << xml_escape(data._3mf_thumbnail)
@@ -7054,22 +7072,24 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
}
if (data._3mf_printer_thumbnail_middle.empty()) {
stream << " <Relationship Target=\"/Metadata/plate_1.png"
<< "\" Id=\"rel-4\" Type=\"http://schemas.bambulab.com/package/2021/cover-thumbnail-middle\"/>\n";
if (plate_thumbnail_written)
stream << " <Relationship Target=\"/Metadata/plate_1.png"
<< "\" Id=\"rel-4\" Type=\"http://schemas.bambulab.com/package/2021/cover-thumbnail-middle\"/>\n";
} else {
stream << " <Relationship Target=\"/" << xml_escape(data._3mf_printer_thumbnail_middle)
<< "\" Id=\"rel-4\" Type=\"http://schemas.bambulab.com/package/2021/cover-thumbnail-middle\"/>\n";
}
if (data._3mf_printer_thumbnail_small.empty()) {
stream << "<Relationship Target=\"/Metadata/plate_1_small.png"
<< "\" Id=\"rel-5\" Type=\"http://schemas.bambulab.com/package/2021/cover-thumbnail-small\"/>\n";
if (plate_small_thumbnail_written)
stream << "<Relationship Target=\"/Metadata/plate_1_small.png"
<< "\" Id=\"rel-5\" Type=\"http://schemas.bambulab.com/package/2021/cover-thumbnail-small\"/>\n";
} else {
stream << " <Relationship Target=\"/" << xml_escape(data._3mf_printer_thumbnail_small)
<< "\" Id=\"rel-5\" Type=\"http://schemas.bambulab.com/package/2021/cover-thumbnail-small\"/>\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 << " <Relationship Target=\"/" << xml_escape(thumbnail_file_str)
@@ -7079,9 +7099,11 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
stream << " <Relationship Target=\"/" << xml_escape(thumbnail_file_str)
<< "\" Id=\"rel-4\" Type=\"http://schemas.bambulab.com/package/2021/cover-thumbnail-middle\"/>\n";
thumbnail_file_str = (boost::format("Metadata/plate_%1%_small.png") % (export_plate_idx + 1)).str();
stream << " <Relationship Target=\"/" << xml_escape(thumbnail_file_str)
<< "\" Id=\"rel-5\" Type=\"http://schemas.bambulab.com/package/2021/cover-thumbnail-small\"/>\n";
if (plate_small_thumbnail_written) {
thumbnail_file_str = (boost::format("Metadata/plate_%1%_small.png") % (export_plate_idx + 1)).str();
stream << " <Relationship Target=\"/" << xml_escape(thumbnail_file_str)
<< "\" Id=\"rel-5\" Type=\"http://schemas.bambulab.com/package/2021/cover-thumbnail-small\"/>\n";
}
}
}
else if (targets.empty()) {
+68
View File
@@ -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<ThumbnailData *> 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<unsigned char>(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"));
}
}
}
}