Files
OrcaSlicer/tests/libslic3r/test_3mf.cpp
T
HanifKoh d35ea27ea5 Validate Zip Entry Sizes Before Parsing 3MF XML (#15958)
The 3MF importers read XML entries into a single expat buffer whose size is
an int, while the archive extraction used the entry's 64-bit declared size.
The two could disagree for entries declaring more than INT_MAX bytes.

Reject such entries before allocating, and use one size for the buffer, the
extraction and the parse. This applies to the BBS importer, the PrusaSlicer
importer and the PrusaSlicer fingerprint probe. The load now fails with an
error instead.
2026-09-29 02:32:50 +08:00

1657 lines
84 KiB
C++

#include "libslic3r/Model.hpp"
#include "libslic3r/Format/3mf.hpp"
#include "libslic3r/Format/bbs_3mf.hpp"
#include "libslic3r/Format/STL.hpp"
#include "libslic3r/miniz_extension.hpp"
#include "libslic3r/Zipper.hpp"
#include "libslic3r/PrintConfig.hpp"
#include "libslic3r/Semver.hpp"
#include "libslic3r/Preset.hpp"
#include "libslic3r/MultiNozzleUtils.hpp"
#include "libslic3r/ProjectTask.hpp"
#include "libslic3r/PublishSettings.hpp"
#include "test_utils.hpp"
#include <nlohmann/json.hpp>
#include <boost/filesystem/operations.hpp>
#include <boost/nowide/fstream.hpp>
#include <boost/algorithm/string/predicate.hpp>
#include <algorithm>
#include <functional>
#include <catch2/catch_tostring.hpp>
#include <Eigen/Core>
#include <Eigen/Geometry>
#include <type_traits> // for std::enable_if_t
#include <typeinfo> // for typeid
namespace Catch {
template <typename T>
struct is_eigen_matrix : std::is_base_of<Eigen::MatrixBase<T>, T> {};
template <typename T>
struct StringMaker<T, std::enable_if_t<is_eigen_matrix<T>::value>> {
static std::string convert(const T& eigen_obj) {
// Newline at end of rows
Eigen::IOFormat fmt(4, 0, ", ", "\n", "[", "]");
std::stringstream ss;
ss << "Matrix<" << typeid(eigen_obj).name() << "> = \n";
ss << eigen_obj.format(fmt);
return ss.str();
}
};
// We must manually specialize for Eigen::Transform as it doesn't derive from MatrixBase.
// It's defined as: Eigen::Transform<Scalar, Dim, Mode, Options>
template <typename Scalar, int Dim, int Mode, int Options>
struct StringMaker<Eigen::Transform<Scalar, Dim, Mode, Options>> {
static std::string convert(const Eigen::Transform<Scalar, Dim, Mode, Options>& trafo) {
// We print the underlying matrix
const auto& matrix = trafo.matrix();
// Newline at end of rows
Eigen::IOFormat fmt(4, 0, ", ", "\n", "[", "]");
std::stringstream ss;
ss << "Transform<Mode=" << Mode << ", Dim=" << Dim << "> = \n";
ss << matrix.format(fmt);
return ss.str();
}
};
// Quaternions also need an explicit specialization
template <typename Scalar, int Options>
struct StringMaker<Eigen::Quaternion<Scalar, Options>> {
static std::string convert(const Eigen::Quaternion<Scalar, Options>& quat) {
std::stringstream ss;
ss << "Quaternion(w=" << quat.w() << ", x=" << quat.x() << ", y=" << quat.y() << ", z=" << quat.z() << ")";
return ss.str();
}
};
} // end namespace Catch
#include <catch2/catch_all.hpp>
using namespace Slic3r;
SCENARIO("Reading 3mf file", "[3mf]") {
GIVEN("umlauts in the path of the file") {
Model model;
WHEN("3mf model is read") {
std::string path = std::string(TEST_DATA_DIR) + "/test_3mf/Geräte/Büchse.3mf";
DynamicPrintConfig config;
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Disable };
bool ret = load_3mf(path.c_str(), config, ctxt, &model, false);
THEN("load should succeed") {
REQUIRE(ret);
}
}
}
}
SCENARIO("Export+Import geometry to/from 3mf file cycle", "[3mf]") {
GIVEN("world vertices coordinates before save") {
// load a model from stl file
Model src_model;
std::string src_file = std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl";
load_stl(src_file.c_str(), &src_model);
src_model.add_default_instances();
ModelObject* src_object = src_model.objects.front();
// apply generic transformation to the 1st volume
Geometry::Transformation src_volume_transform;
src_volume_transform.set_offset({ 10.0, 20.0, 0.0 });
src_volume_transform.set_rotation({ Geometry::deg2rad(25.0), Geometry::deg2rad(35.0), Geometry::deg2rad(45.0) });
src_volume_transform.set_scaling_factor({ 1.1, 1.2, 1.3 });
src_volume_transform.set_mirror({ -1.0, 1.0, -1.0 });
src_object->volumes.front()->set_transformation(src_volume_transform);
// apply generic transformation to the 1st instance
Geometry::Transformation src_instance_transform;
src_instance_transform.set_offset({ 5.0, 10.0, 0.0 });
src_instance_transform.set_rotation({ Geometry::deg2rad(12.0), Geometry::deg2rad(13.0), Geometry::deg2rad(14.0) });
src_instance_transform.set_scaling_factor({ 0.9, 0.8, 0.7 });
src_instance_transform.set_mirror({ 1.0, -1.0, -1.0 });
src_object->instances.front()->set_transformation(src_instance_transform);
WHEN("model is saved+loaded to/from 3mf file") {
ScopedTemporaryFile temp(".3mf");
const std::string test_file = temp.string();
store_3mf(test_file.c_str(), &src_model, nullptr, false);
// load back the model from the 3mf file
Model dst_model;
DynamicPrintConfig dst_config;
{
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Disable };
load_3mf(test_file.c_str(), dst_config, ctxt, &dst_model, false);
}
// compare meshes
TriangleMesh src_mesh = src_model.mesh();
TriangleMesh dst_mesh = dst_model.mesh();
bool res = src_mesh.its.vertices.size() == dst_mesh.its.vertices.size();
if (res) {
for (size_t i = 0; i < dst_mesh.its.vertices.size(); ++i) {
res &= dst_mesh.its.vertices[i].isApprox(src_mesh.its.vertices[i]);
}
}
THEN("world vertices coordinates after load match") {
REQUIRE(res);
}
}
}
}
// The recipe is an opaque binary blob (CadDocument::serialize_recipe()), so the 3mf backend has
// to carry it byte-for-byte — no XML/text mangling, embedded NULs intact.
static std::string make_cad_recipe()
{
// Built from an explicit length, not append(const char*), which would stop at the first
// embedded NUL — the one thing this blob exists to prove survives the archive.
static const char blob[] = "\x01" "RECIPE" "\0" "\xff\xfe\x00\x10" "cad-features-blob";
return std::string(blob, sizeof(blob) - 1);
}
static const std::string CAD_RECIPE_ENTRY = "Metadata/orca_cad.bin";
static const std::string LEGACY_CAD_RECIPE_ENTRY = "Metadata/SnapOrca_cad.bin";
// Pulls one named entry out of a 3mf archive; false when it is absent.
static bool read_cad_recipe_entry(const std::string& path, std::string& out,
const std::string& entry = CAD_RECIPE_ENTRY)
{
mz_zip_archive zip;
mz_zip_zero_struct(&zip);
REQUIRE(open_zip_reader(&zip, path));
bool found = false;
mz_uint n = mz_zip_reader_get_num_files(&zip);
for (mz_uint i = 0; i < n; ++i) {
mz_zip_archive_file_stat st;
if (!mz_zip_reader_file_stat(&zip, i, &st)) continue;
std::string name(st.m_filename);
std::replace(name.begin(), name.end(), '\\', '/');
if (boost::algorithm::iequals(name, entry)) {
out.resize(st.m_uncomp_size);
found = mz_zip_reader_extract_to_mem(&zip, i, out.data(), out.size(), 0) != 0;
break;
}
}
close_zip_reader(&zip);
return found;
}
// Rewrites the archive at `path`, letting `edit` change the name or data of each entry; returns
// whether `edit` reported a change for any of them. miniz cannot edit in place and
// open_zip_writer truncates, so the entries are held across the switch.
static bool rewrite_3mf_entries(const std::string& path, const std::function<bool(std::string& name, std::string& data)>& edit)
{
std::vector<std::pair<std::string, std::string>> entries;
bool changed = false;
{
mz_zip_archive zip;
mz_zip_zero_struct(&zip);
REQUIRE(open_zip_reader(&zip, path));
mz_uint n = mz_zip_reader_get_num_files(&zip);
for (mz_uint i = 0; i < n; ++i) {
mz_zip_archive_file_stat st;
REQUIRE(mz_zip_reader_file_stat(&zip, i, &st));
if (st.m_is_directory) continue;
std::string name(st.m_filename);
std::replace(name.begin(), name.end(), '\\', '/');
std::string data((size_t) st.m_uncomp_size, '\0');
if (st.m_uncomp_size > 0)
REQUIRE(mz_zip_reader_extract_to_mem(&zip, i, data.data(), data.size(), 0));
changed |= edit(name, data);
entries.emplace_back(std::move(name), std::move(data));
}
close_zip_reader(&zip);
}
Zipper out(path);
for (const auto& e : entries)
out.add_entry(e.first, e.second.data(), e.second.size());
out.finalize();
return changed;
}
// Rewrites the archive at `path` with the recipe entry back under the name it had before the
// rename, which is what every project saved by an earlier build looks like on disk. Generated
// rather than checked in because a whole project archive is not frozen evidence the way a bare
// recipe blob is -- it has to be whatever today's exporter writes, with only the name aged.
static void rename_cad_recipe_entry_to_legacy(const std::string& path)
{
const bool renamed = rewrite_3mf_entries(path, [](std::string& name, std::string&) {
if (!boost::algorithm::iequals(name, CAD_RECIPE_ENTRY))
return false;
name = LEGACY_CAD_RECIPE_ENTRY;
return true;
});
// Without this the scenario would degrade silently into re-testing the new name if the
// exporter's constant ever moved again: every load below would still pass.
REQUIRE(renamed);
}
// Replaces the first occurrence of `from` in any entry whose name ends with `suffix`.
static bool replace_in_3mf_entry(const std::string& path, const std::string& suffix, const std::string& from, const std::string& to)
{
bool replaced = false;
rewrite_3mf_entries(path, [&](std::string& name, std::string& data) {
if (replaced || !boost::algorithm::ends_with(name, suffix))
return false;
if (const size_t pos = data.find(from); pos != std::string::npos) {
data.replace(pos, from.size(), to);
replaced = true;
}
return replaced;
});
return replaced;
}
// Stores a one-plate project holding a cube whose first two facets are painted Extruder2 and
// Extruder3, which the exporter writes as paint_color="8" and paint_color="0C".
static void store_painted_cube(const std::string& path)
{
Model model;
ModelObject* object = model.add_object();
ModelVolume* volume = object->add_volume(make_cube(10., 10., 10.));
object->add_instance();
{
TriangleSelector selector(volume->mesh());
selector.set_facet(0, EnforcerBlockerType::Extruder2);
selector.set_facet(1, EnforcerBlockerType::Extruder3);
REQUIRE(volume->mmu_segmentation_facets.set(selector));
}
ScopedTemporaryDir backup_dir("orca_paint_src");
model.set_backup_path(backup_dir.string());
DynamicPrintConfig cfg;
PlateData plate;
plate.plate_index = 0;
StoreParams sp;
sp.path = path.c_str();
sp.model = &model;
sp.config = &cfg;
sp.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence;
sp.plate_data_list.push_back(&plate);
REQUIRE(store_bbs_3mf(sp));
}
// Loads `path` through the BBS importer into `model`, releasing the plates it returns. The
// importer stages metadata through `backup_dir`, which has to outlive the model.
static bool load_project(const std::string& path, Model& model, const ScopedTemporaryDir& backup_dir)
{
model.set_backup_path(backup_dir.string());
DynamicPrintConfig config;
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable };
PlateDataPtrs plates;
std::vector<Preset*> project_presets;
bool is_bbl_3mf = false, is_orca_3mf = false;
Semver file_version;
const bool loaded = load_bbs_3mf(path.c_str(), &config, &ctxt, &model, &plates, &project_presets, &is_bbl_3mf,
&is_orca_3mf, &file_version, nullptr, LoadStrategy::LoadModel | LoadStrategy::LoadConfig);
release_PlateData_list(plates);
return loaded;
}
TEST_CASE("A project with a plate id below 1 fails to load", "[3mf][Regression]")
{
const int plate_id = GENERATE(0, -1);
INFO("plater_id " << plate_id);
ScopedTemporaryFile temp(".3mf");
store_painted_cube(temp.string());
{
ScopedTemporaryDir backup_dir("orca_plate_dst");
Model model;
REQUIRE(load_project(temp.string(), model, backup_dir));
}
REQUIRE(replace_in_3mf_entry(temp.string(), "model_settings.config", "key=\"plater_id\" value=\"1\"",
"key=\"plater_id\" value=\"" + std::to_string(plate_id) + "\""));
ScopedTemporaryDir backup_dir("orca_plate_dst");
Model model;
bool loaded = true;
REQUIRE_NOTHROW(loaded = load_project(temp.string(), model, backup_dir));
REQUIRE_FALSE(loaded);
}
TEST_CASE("A project with malformed paint data loads without the damaged facet", "[3mf][Regression]")
{
ScopedTemporaryFile temp(".3mf");
store_painted_cube(temp.string());
// Split codes with no children behind them: the stream runs out mid-tree.
REQUIRE(replace_in_3mf_entry(temp.string(), ".model", "paint_color=\"8\"", "paint_color=\"FFFFFFFFFFFFFFFF3\""));
ScopedTemporaryDir backup_dir("orca_paint_dst");
Model model;
REQUIRE(load_project(temp.string(), model, backup_dir));
REQUIRE(model.objects.size() == 1);
const ModelVolume& volume = *model.objects.front()->volumes.front();
const auto& data = volume.mmu_segmentation_facets.get_data();
REQUIRE_FALSE(data.used_states[size_t(EnforcerBlockerType::Extruder2)]);
REQUIRE(data.used_states[size_t(EnforcerBlockerType::Extruder3)]);
TriangleSelector selector(volume.mesh());
REQUIRE_NOTHROW(selector.deserialize(data));
REQUIRE(selector.num_facets(EnforcerBlockerType::Extruder2) == 0);
REQUIRE(selector.num_facets(EnforcerBlockerType::Extruder3) == 1);
}
// The recipe lives only in the BBS-native backend, because that is the only one that runs:
// store_bbs_3mf is the sole exporter the app calls, and 3mf.cpp's load_3mf is reached only for
// files fingerprinted as PrusaSlicer's, which never carry a recipe. This locks in both halves:
// the archive entry is at the exact path the importer looks for, and the recipe comes back
// through the real importer.
SCENARIO("CAD recipe is embedded in the BBS 3mf archive", "[3mf][CAD]") {
GIVEN("a model carrying a binary cad_recipe") {
Model model;
std::string src = std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl";
REQUIRE(load_stl(src.c_str(), &model));
model.add_default_instances();
// store_bbs_3mf stages its metadata through the model's backup path; point it at a
// writable temp dir, as the sibling BBS scenarios do. The process-global
// set_temporary_dir() would leak into every test that ran afterwards.
ScopedTemporaryDir backup_dir("orca_cad");
model.set_backup_path(backup_dir.string());
const std::string recipe = make_cad_recipe();
model.cad_recipe = recipe;
WHEN("saved through the BBS backend (the format the GUI uses)") {
ScopedTemporaryFile temp(".3mf");
const std::string test_file = temp.string();
DynamicPrintConfig cfg;
StoreParams sp;
sp.path = test_file.c_str();
sp.model = &model;
sp.config = &cfg;
sp.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence;
REQUIRE(store_bbs_3mf(sp));
THEN("the archive entry is present byte-for-byte") {
std::string got;
REQUIRE(read_cad_recipe_entry(test_file, got));
REQUIRE(got.size() == recipe.size());
REQUIRE(got == recipe);
}
THEN("the importer restores it onto the loaded model") {
Model dst_model;
ScopedTemporaryDir dst_backup_dir("orca_cad_dst");
dst_model.set_backup_path(dst_backup_dir.string());
DynamicPrintConfig dst_config;
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable };
PlateDataPtrs dst_plates;
std::vector<Preset*> project_presets;
bool is_bbl_3mf = false, is_orca_3mf = false;
Semver file_version;
REQUIRE(load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model, &dst_plates,
&project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr,
LoadStrategy::LoadModel | LoadStrategy::LoadConfig));
REQUIRE(dst_model.cad_recipe.size() == recipe.size());
REQUIRE(dst_model.cad_recipe == recipe);
release_PlateData_list(dst_plates);
}
}
WHEN("the same model is saved with no recipe") {
model.cad_recipe.clear();
ScopedTemporaryFile temp(".3mf");
const std::string test_file = temp.string();
DynamicPrintConfig cfg;
StoreParams sp;
sp.path = test_file.c_str();
sp.model = &model;
sp.config = &cfg;
sp.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence;
REQUIRE(store_bbs_3mf(sp));
THEN("no entry is written at all") {
std::string got;
REQUIRE_FALSE(read_cad_recipe_entry(test_file, got));
}
}
}
}
// The recipe entry was renamed from Metadata/SnapOrca_cad.bin to Metadata/orca_cad.bin. Nothing
// in the blob marks that move, so a reader that knows only the new name loads a project written
// before it with an empty cad_recipe and no error at all — a feature tree gone with no symptom
// but an empty Design tab. The importer must still accept the old name; the exporter may never
// write it.
SCENARIO("a project saved under the pre-rename recipe name still loads", "[3mf][CAD]") {
GIVEN("a project whose recipe entry carries the old name") {
Model model;
std::string src = std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl";
REQUIRE(load_stl(src.c_str(), &model));
model.add_default_instances();
const std::string recipe = make_cad_recipe();
model.cad_recipe = recipe;
WHEN("it was written by the BBS backend") {
ScopedTemporaryDir backup_dir("orca_cad_legacy");
model.set_backup_path(backup_dir.string());
ScopedTemporaryFile temp(".3mf");
const std::string test_file = temp.string();
DynamicPrintConfig cfg;
StoreParams sp;
sp.path = test_file.c_str();
sp.model = &model;
sp.config = &cfg;
sp.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence;
REQUIRE(store_bbs_3mf(sp));
rename_cad_recipe_entry_to_legacy(test_file);
// Catch2 replays the enclosing sections per THEN, so one load here serves both.
Model dst_model;
ScopedTemporaryDir dst_backup_dir("orca_cad_legacy_dst");
dst_model.set_backup_path(dst_backup_dir.string());
DynamicPrintConfig dst_config;
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable };
PlateDataPtrs dst_plates;
std::vector<Preset*> project_presets;
bool is_bbl_3mf = false, is_orca_3mf = false;
Semver file_version;
REQUIRE(load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model, &dst_plates,
&project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr,
LoadStrategy::LoadModel | LoadStrategy::LoadConfig));
release_PlateData_list(dst_plates);
THEN("the recipe still comes back byte-for-byte") {
REQUIRE(dst_model.cad_recipe == recipe);
}
THEN("re-saving migrates it to the new name and leaves the old one behind") {
ScopedTemporaryFile again(".3mf");
const std::string resaved = again.string();
DynamicPrintConfig cfg2;
StoreParams sp2;
sp2.path = resaved.c_str();
sp2.model = &dst_model;
sp2.config = &cfg2;
sp2.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence;
REQUIRE(store_bbs_3mf(sp2));
std::string got;
REQUIRE(read_cad_recipe_entry(resaved, got));
REQUIRE(got == recipe);
REQUIRE_FALSE(read_cad_recipe_entry(resaved, got, LEGACY_CAD_RECIPE_ENTRY));
}
}
}
}
// .3mf multi-nozzle round-trip.
// Locks the load/save handling for the H2C multi-nozzle plate metadata:
// * filament_volume_maps -> plate config "filament_volume_map" (with the >1 -> 0 clamp)
// * nozzle_volume_type -> PlateData::nozzle_volume_types (previously write-only)
// and pins the deliberately-lossy keys (enable_filament_dynamic_map) so a future change has to
// consciously unpin them. Uses a store_bbs_3mf -> load_bbs_3mf cycle (no external fixture needed).
SCENARIO("H2C multi-nozzle .3mf round-trip", "[3mf][MultiNozzle]") {
GIVEN("a plate carrying multi-nozzle filament assignment metadata") {
Model model;
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();
// store_bbs_3mf stages Metadata/project_settings.config through the model's backup path;
// point it at a writable temp dir (the default lives under a read-only root in CI).
ScopedTemporaryDir backup_dir("orca_mn");
model.set_backup_path(backup_dir.string());
// Global (printer) config: give nozzle_volume_type a non-default value so the slice_info
// read-back is a meaningful assertion (High Flow == 1).
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_key_value("nozzle_volume_type",
new ConfigOptionEnumsGeneric({ (int) NozzleVolumeType::nvtHighFlow }));
PlateData* plate = new PlateData();
plate->plate_index = 0;
plate->is_sliced_valid = true; // gate for the slice_info.config writer (nozzle_volume_type)
plate->filament_maps = { 1, 2, 1 }; // slice_info uses this; keep it == model_settings' value
plate->config.set_key_value("filament_map_mode", new ConfigOptionEnum<FilamentMapMode>(fmmManual));
plate->config.set_key_value("filament_map", new ConfigOptionInts({ 1, 2, 1 }));
// Deliberately include out-of-range volume-type ids (2 == Hybrid, 3 == TPU High Flow):
// the loader must clamp them back to Standard (0).
plate->config.set_key_value("filament_volume_map", new ConfigOptionInts({ 0, 2, 1, 3 }));
// Known-lossy: a true value must NOT survive the round-trip (slice_info hardcodes false,
// model_settings never writes it).
plate->config.set_key_value("enable_filament_dynamic_map", new ConfigOptionBool(true));
WHEN("stored to and reloaded from a .3mf") {
ScopedTemporaryFile temp(".3mf");
const std::string test_file = temp.string();
StoreParams store_params;
store_params.path = test_file.c_str();
store_params.model = &model;
store_params.config = &config;
store_params.plate_data_list.push_back(plate);
store_params.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence;
REQUIRE(store_bbs_3mf(store_params));
Model dst_model;
DynamicPrintConfig dst_config;
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable };
PlateDataPtrs dst_plates;
std::vector<Preset*> project_presets;
bool is_bbl_3mf = false, is_orca_3mf = false;
Semver file_version;
// LoadConfig is required for slice_info.config (nozzle_volume_type) to be parsed —
// matches how the app loads projects.
bool loaded = load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model, &dst_plates,
&project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr,
LoadStrategy::LoadModel | LoadStrategy::LoadConfig);
THEN("every multi-nozzle key round-trips as expected") {
REQUIRE(loaded);
REQUIRE(dst_plates.size() >= 1);
PlateData* rt = dst_plates.front();
// filament_map (model_settings + slice_info; already round-tripped)
auto* fmap = rt->config.option<ConfigOptionInts>("filament_map");
REQUIRE(fmap != nullptr);
REQUIRE(fmap->values == std::vector<int>({ 1, 2, 1 }));
// filament_volume_map (model_settings) with the >1 -> 0 clamp
auto* fvmap = rt->config.option<ConfigOptionInts>("filament_volume_map");
REQUIRE(fvmap != nullptr);
REQUIRE(fvmap->values == std::vector<int>({ 0, 0, 1, 0 }));
// nozzle_volume_type read-back into PlateData::nozzle_volume_types
REQUIRE(rt->nozzle_volume_types == "1");
// enable_filament_dynamic_map pinned lossy: model_settings never serializes it and
// slice_info hardcodes false, so the `true` we set is dropped. Pinned here
// (absent or false, never true) so a future change that persists it must update this.
auto* dyn = rt->config.option<ConfigOptionBool>("enable_filament_dynamic_map");
const bool persisted_true = (dyn != nullptr && dyn->value);
REQUIRE_FALSE(persisted_true);
}
release_PlateData_list(dst_plates);
}
delete plate; // store_bbs_3mf does not take ownership of the source plate
}
}
// Saved nozzle diameter for a single-nozzle-per-extruder printer with a non-standard nozzle.
// The grouping result rounds every nozzle diameter to the nearest of {0.2,0.4,0.6,0.8} for its
// internal matching key. That rounded value must NOT reach the saved <filament>/<nozzle> metadata on
// a printer whose extruders each carry one nozzle: the exact per-extruder config diameter is written
// instead, so a 0.5 mm nozzle is preserved rather than saved as 0.4. (Only an extruder that carries a
// nozzle cluster, which the per-extruder config cannot express, keeps the grouping result's diameter.)
SCENARIO("Non-standard nozzle diameter survives .3mf save on a single-nozzle printer", "[3mf][MultiNozzle]") {
GIVEN("a single-extruder plate whose nozzle is 0.5 mm and whose stamped diameter was rounded to 0.4") {
Model model;
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_nd");
model.set_backup_path(backup_dir.string());
// Single extruder with a non-standard 0.5 mm nozzle; extruder_max_nozzle_count stays at its
// default (no nozzle cluster), so the writer must emit the exact config diameter.
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_key_value("nozzle_diameter", new ConfigOptionFloats({ 0.5 }));
PlateData* plate = new PlateData();
plate->plate_index = 0;
plate->is_sliced_valid = true; // gate for the slice_info.config writer
plate->filament_maps = { 1 };
// Seed the stamped diameter with the grouping result's rounded value (0.5 -> 0.4) so the
// assertion proves the writer ignores it and emits the exact config diameter instead.
FilamentInfo fi;
fi.id = 0;
fi.type = "PLA";
fi.color = "#FFFFFFFF";
fi.group_id = { 0 };
fi.nozzle_diameter = 0.4; // rounded; must NOT be the value written
plate->slice_filaments_info.push_back(fi);
WHEN("stored to and reloaded from a .3mf") {
ScopedTemporaryFile temp(".3mf");
const std::string test_file = temp.string();
StoreParams store_params;
store_params.path = test_file.c_str();
store_params.model = &model;
store_params.config = &config;
store_params.plate_data_list.push_back(plate);
store_params.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence;
REQUIRE(store_bbs_3mf(store_params));
Model dst_model;
DynamicPrintConfig dst_config;
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable };
PlateDataPtrs dst_plates;
std::vector<Preset*> project_presets;
bool is_bbl_3mf = false, is_orca_3mf = false;
Semver file_version;
bool loaded = load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model, &dst_plates,
&project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr,
LoadStrategy::LoadModel | LoadStrategy::LoadConfig);
THEN("the saved nozzle diameter is the exact 0.5, not the rounded 0.4") {
REQUIRE(loaded);
REQUIRE(dst_plates.size() >= 1);
PlateData* rt = dst_plates.front();
// <nozzle> tag: device-facing per-nozzle diameter string, written verbatim.
REQUIRE(rt->nozzles_info.size() >= 1);
REQUIRE(rt->nozzles_info.front().diameter == "0.5");
// <filament> tag: per-filament nozzle_diameter parsed back as 0.5, not 0.4.
REQUIRE(rt->slice_filaments_info.size() >= 1);
REQUIRE_THAT(rt->slice_filaments_info.front().nozzle_diameter, Catch::Matchers::WithinAbs(0.5, 1e-6));
}
release_PlateData_list(dst_plates);
}
delete plate; // store_bbs_3mf does not take ownership of the source plate
}
}
// A legacy / foreign project (no multi-nozzle metadata) must load crash-safe through the BBS
// importer and must not fabricate a filament_volume_map.
SCENARIO("Legacy project loads crash-safe via load_bbs_3mf", "[3mf][MultiNozzle]") {
GIVEN("a project without any multi-nozzle metadata") {
std::string path = std::string(TEST_DATA_DIR) + "/test_3mf/Geräte/Büchse.3mf";
Model model;
DynamicPrintConfig config;
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable };
PlateDataPtrs plates;
std::vector<Preset*> project_presets;
bool is_bbl_3mf = false, is_orca_3mf = false;
Semver file_version;
WHEN("loaded through the BBS importer") {
bool loaded = false;
REQUIRE_NOTHROW(loaded = load_bbs_3mf(path.c_str(), &config, &ctxt, &model, &plates,
&project_presets, &is_bbl_3mf, &is_orca_3mf,
&file_version, nullptr,
LoadStrategy::LoadModel | LoadStrategy::LoadConfig));
THEN("it does not crash and invents no per-filament volume map") {
for (PlateData* p : plates) {
REQUIRE(p->config.option<ConfigOptionInts>("filament_volume_map") == nullptr);
}
}
release_PlateData_list(plates);
}
}
}
// Device-side nozzle-grouping serialization surface.
// Direct unit coverage for the pure serialize/deserialize + StaticNozzleGroupResult helpers that the
// gcode.3mf writer/reader lean on.
SCENARIO("MultiNozzle serialization helpers", "[3mf][MultiNozzle]") {
using namespace Slic3r::MultiNozzleUtils;
GIVEN("NozzleInfo / NozzleGroupInfo") {
NozzleInfo n0; n0.group_id = 0; n0.extruder_id = 0; n0.diameter = "0.4"; n0.volume_type = nvtStandard;
NozzleInfo n1; n1.group_id = 1; n1.extruder_id = 1; n1.diameter = "0.4"; n1.volume_type = nvtHighFlow;
THEN("NozzleInfo::serialize matches the <nozzle> tag attributes (extruder_id 1-based)") {
REQUIRE(n0.serialize() == "id=\"0\" extruder_id=\"1\" nozzle_diameter=\"0.4\" volume_type=\"Standard\"");
REQUIRE(n1.serialize() == "id=\"1\" extruder_id=\"2\" nozzle_diameter=\"0.4\" volume_type=\"High Flow\"");
}
THEN("NozzleGroupInfo serialize/deserialize round-trips and rejects malformed input") {
NozzleGroupInfo g("0.4", nvtHighFlow, 1, 3);
REQUIRE(g.serialize() == "1-0.4-High Flow-3");
auto rt = NozzleGroupInfo::deserialize(g.serialize());
REQUIRE(rt.has_value());
REQUIRE(*rt == g);
REQUIRE_FALSE(NozzleGroupInfo::deserialize("1-0.4-Standard").has_value()); // too few tokens
REQUIRE_FALSE(NozzleGroupInfo::deserialize("x-0.4-Standard-3").has_value()); // non-numeric extruder
}
}
GIVEN("a StaticNozzleGroupResult built from filament + nozzle infos") {
std::vector<NozzleInfo> nozzles;
{ NozzleInfo n; n.group_id = 0; n.extruder_id = 0; n.diameter = "0.4"; n.volume_type = nvtStandard; nozzles.push_back(n); }
{ NozzleInfo n; n.group_id = 1; n.extruder_id = 1; n.diameter = "0.4"; n.volume_type = nvtHighFlow; nozzles.push_back(n); }
std::vector<FilamentInfo> filaments(3);
filaments[0].id = 0; filaments[0].group_id = { 0 };
filaments[1].id = 1; filaments[1].group_id = { 1 };
filaments[2].id = 2; filaments[2].group_id = { 0, 1 };
auto result = StaticNozzleGroupResult::create(filaments, nozzles, { 0, 1, 2 }, { 0, 1, 0 }, false);
REQUIRE(result.has_value());
THEN("filament->nozzle queries resolve to the stored mapping") {
REQUIRE(result->get_extruder_count() == 2);
REQUIRE(result->get_used_extruders() == std::vector<int>({ 0, 1 }));
REQUIRE(result->get_used_filaments() == std::vector<unsigned int>({ 0, 1, 2 }));
REQUIRE(result->get_nozzles_for_filament(0).size() == 1);
REQUIRE(result->get_nozzles_for_filament(2).size() == 2);
// first-use resolves through the (filament,nozzle) change sequences.
auto first = result->get_first_nozzle_for_filament(1);
REQUIRE(first.has_value());
REQUIRE(first->group_id == 1);
}
THEN("empty inputs yield nullopt") {
REQUIRE_FALSE(StaticNozzleGroupResult::create({}, nozzles, {}, {}, false).has_value());
REQUIRE_FALSE(StaticNozzleGroupResult::create(filaments, {}, {}, {}, false).has_value());
}
}
GIVEN("load_nozzle_infos_with_compatibility fallbacks") {
std::vector<NozzleInfo> new_format;
{ NozzleInfo n; n.group_id = 1; n.extruder_id = 1; n.diameter = "0.4"; n.volume_type = nvtHighFlow; new_format.push_back(n); }
{ NozzleInfo n; n.group_id = 0; n.extruder_id = 0; n.diameter = "0.4"; n.volume_type = nvtStandard; new_format.push_back(n); }
THEN("new-format <nozzle> tags are returned sorted by logical id") {
auto out = load_nozzle_infos_with_compatibility(new_format, {}, {}, {}, {});
REQUIRE(out.size() == 2);
REQUIRE(out[0].group_id == 0);
REQUIRE(out[1].group_id == 1);
}
THEN("oldest single-nozzle 3mf (no tags, no filament group_id) rebuilds from diameters/volume types") {
std::vector<NozzleVolumeType> vt = { nvtStandard, nvtHighFlow };
std::vector<double> dia = { 0.4, 0.4 };
auto out = load_nozzle_infos_with_compatibility({}, {}, {}, vt, dia);
REQUIRE(out.size() == 2);
REQUIRE(out[0].extruder_id == 0);
REQUIRE(out[0].volume_type == nvtStandard);
REQUIRE(out[1].volume_type == nvtHighFlow);
}
}
}
// The layer-aware grouping result must survive the gcode.3mf write/read as
// <nozzle> tags and the enable_filament_dynamic_map flag. Proves the parse_filament_info stamping,
// the NOZZLE_TAG writer, the _handle_config_nozzle reader, and the nozzles_info plate copy.
SCENARIO("Nozzle-group metadata .3mf round-trip", "[3mf][MultiNozzle]") {
GIVEN("a plate carrying a two-nozzle LayeredNozzleGroupResult") {
Model model;
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_ng");
model.set_backup_path(backup_dir.string());
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
std::vector<MultiNozzleUtils::NozzleInfo> nozzles;
{ MultiNozzleUtils::NozzleInfo n; n.group_id = 0; n.extruder_id = 0; n.diameter = "0.4"; n.volume_type = NozzleVolumeType::nvtStandard; nozzles.push_back(n); }
{ MultiNozzleUtils::NozzleInfo n; n.group_id = 1; n.extruder_id = 1; n.diameter = "0.4"; n.volume_type = NozzleVolumeType::nvtHighFlow; nozzles.push_back(n); }
auto group = MultiNozzleUtils::LayeredNozzleGroupResult::create(
std::vector<int>{ 0, 1, 0 }, nozzles, std::vector<unsigned int>{ 0, 1, 2 });
REQUIRE(group.has_value());
PlateData* plate = new PlateData();
plate->plate_index = 0;
plate->is_sliced_valid = true;
plate->filament_maps = { 1, 2, 1 };
plate->nozzle_group_result = group;
plate->config.set_key_value("filament_map_mode", new ConfigOptionEnum<FilamentMapMode>(fmmManual));
plate->config.set_key_value("filament_map", new ConfigOptionInts({ 1, 2, 1 }));
WHEN("stored to and reloaded from a .3mf") {
ScopedTemporaryFile temp(".3mf");
const std::string test_file = temp.string();
StoreParams store_params;
store_params.path = test_file.c_str();
store_params.model = &model;
store_params.config = &config;
store_params.plate_data_list.push_back(plate);
store_params.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence;
REQUIRE(store_bbs_3mf(store_params));
Model dst_model;
DynamicPrintConfig dst_config;
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable };
PlateDataPtrs dst_plates;
std::vector<Preset*> project_presets;
bool is_bbl_3mf = false, is_orca_3mf = false;
Semver file_version;
bool loaded = load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model, &dst_plates,
&project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr,
LoadStrategy::LoadModel | LoadStrategy::LoadConfig);
THEN("the <nozzle> tags round-trip into the loaded plate's nozzles_info") {
REQUIRE(loaded);
REQUIRE(dst_plates.size() >= 1);
PlateData* rt = dst_plates.front();
REQUIRE(rt->nozzles_info.size() == 2);
// reader stores extruder_id 0-based (tag is 1-based), diameter/volume_type preserved.
std::sort(rt->nozzles_info.begin(), rt->nozzles_info.end());
REQUIRE(rt->nozzles_info[0].group_id == 0);
REQUIRE(rt->nozzles_info[0].extruder_id == 0);
REQUIRE(rt->nozzles_info[0].diameter == "0.4");
REQUIRE(rt->nozzles_info[0].volume_type == NozzleVolumeType::nvtStandard);
REQUIRE(rt->nozzles_info[1].group_id == 1);
REQUIRE(rt->nozzles_info[1].extruder_id == 1);
REQUIRE(rt->nozzles_info[1].volume_type == NozzleVolumeType::nvtHighFlow);
// A static (non-selector) result must persist enable_filament_dynamic_map = false.
auto* dyn = rt->config.option<ConfigOptionBool>("enable_filament_dynamic_map");
const bool persisted_true = (dyn != nullptr && dyn->value);
REQUIRE_FALSE(persisted_true);
}
release_PlateData_list(dst_plates);
}
delete plate;
}
}
// A mixed-color filament occupies an ordinary filament slot, and painting with it stores an
// ordinary extruder state: a project saved by BambuStudio encodes filament 5 of a 5-slot setup
// as paint state 5, with the mix described by the parallel filament_mixed_* project arrays.
SCENARIO("Mixed-color filament setup and painting round-trip through a .3mf", "[3mf][MixedFilament]") {
GIVEN("a painted model whose project config describes a mixed filament in the last slot") {
Model model;
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();
// Both the exporter and the importer stage Metadata/project_settings.config through the
// model's backup path; point them at writable temp dirs.
ScopedTemporaryDir backup_dir("orca_mixed_src");
model.set_backup_path(backup_dir.string());
ModelVolume* mv = model.objects.front()->volumes.front();
{
TriangleSelector selector(mv->mesh());
selector.set_facet(0, EnforcerBlockerType::Extruder5); // the mixed slot
selector.set_facet(1, EnforcerBlockerType::Extruder2);
REQUIRE(mv->mmu_segmentation_facets.set(selector));
}
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_key_value("filament_colour", new ConfigOptionStrings(
{ "#00AE42", "#FFFF00", "#FF0000", "#0000FF", "#FF6A26" }));
config.set_key_value("filament_is_mixed", new ConfigOptionBools(
{ false, false, false, false, true }));
config.set_key_value("filament_mixed_components", new ConfigOptionStrings(
{ "", "", "", "", "3,2" }));
config.set_key_value("filament_mixed_sublayer_ratios", new ConfigOptionStrings(
{ "", "", "", "", "0.4200,0.5800" }));
WHEN("stored to and reloaded from a .3mf") {
ScopedTemporaryFile temp(".3mf");
const std::string test_file = temp.string();
PlateData* plate = new PlateData();
plate->plate_index = 0;
StoreParams store_params;
store_params.path = test_file.c_str();
store_params.model = &model;
store_params.config = &config;
store_params.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence;
store_params.plate_data_list.push_back(plate);
REQUIRE(store_bbs_3mf(store_params));
Model dst_model;
ScopedTemporaryDir dst_backup_dir("orca_mixed_dst");
dst_model.set_backup_path(dst_backup_dir.string());
DynamicPrintConfig dst_config;
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable };
PlateDataPtrs dst_plates;
std::vector<Preset*> project_presets;
bool is_bbl_3mf = false, is_orca_3mf = false;
Semver file_version;
REQUIRE(load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model, &dst_plates,
&project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr,
LoadStrategy::LoadModel | LoadStrategy::LoadConfig));
THEN("the mixed-filament project keys survive") {
auto* is_mixed = dst_config.option<ConfigOptionBools>("filament_is_mixed");
REQUIRE(is_mixed != nullptr);
REQUIRE(is_mixed->values == std::vector<unsigned char>({ 0, 0, 0, 0, 1 }));
auto* components = dst_config.option<ConfigOptionStrings>("filament_mixed_components");
REQUIRE(components != nullptr);
REQUIRE(components->values.size() == 5);
REQUIRE(components->values[4] == "3,2");
auto* ratios = dst_config.option<ConfigOptionStrings>("filament_mixed_sublayer_ratios");
REQUIRE(ratios != nullptr);
REQUIRE(ratios->values.size() == 5);
REQUIRE(ratios->values[4] == "0.4200,0.5800");
}
THEN("the painted facets survive, including the one painted with the mixed slot") {
REQUIRE(dst_model.objects.size() == 1);
ModelVolume* dst_mv = dst_model.objects.front()->volumes.front();
REQUIRE_FALSE(dst_mv->mmu_segmentation_facets.empty());
REQUIRE(dst_mv->mmu_segmentation_facets.has_facets(*dst_mv, EnforcerBlockerType::Extruder2));
REQUIRE(dst_mv->mmu_segmentation_facets.has_facets(*dst_mv, EnforcerBlockerType::Extruder5));
}
release_PlateData_list(dst_plates);
delete plate; // store_bbs_3mf does not take ownership of the source plate
}
}
}
// Locks the serialization contract of the "Publish" metadata: the orca_published flag and the
// orca_published_keys JSON array in model.model_info->metadata_items must survive a store_bbs_3mf ->
// load_bbs_3mf round-trip unchanged. (The full preset-preservation behavior is exercised
// headlessly in test_preset_bundle_loading.cpp.)
SCENARIO("Published 3MF round-trips the published flag and published_keys metadata", "[3mf]") {
GIVEN("a model carrying published metadata") {
Model model;
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();
model.model_info = std::make_shared<ModelInfo>();
model.model_info->metadata_items[ORCA_PUBLISHED_TAG] = "1";
model.model_info->metadata_items[ORCA_PUBLISHED_KEYS_TAG] = R"(["layer_height","wall_thickness"])";
// store_bbs_3mf stages project_settings.config through the model's backup path; point
// it at a writable temp dir (the default lives under a read-only root in CI).
ScopedTemporaryDir backup_dir("orca_pub");
model.set_backup_path(backup_dir.string());
WHEN("stored to and reloaded from a .3mf") {
ScopedTemporaryFile temp(".3mf");
const std::string test_file = temp.string();
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
StoreParams store_params;
store_params.path = test_file.c_str();
store_params.model = &model;
store_params.config = &config;
store_params.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence;
REQUIRE(store_bbs_3mf(store_params));
Model dst_model;
DynamicPrintConfig dst_config;
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable };
PlateDataPtrs dst_plates;
std::vector<Preset*> project_presets;
bool is_bbl_3mf = false, is_orca_3mf = false;
Semver file_version;
bool loaded = load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model, &dst_plates,
&project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr,
LoadStrategy::LoadModel | LoadStrategy::LoadConfig);
THEN("the published metadata round-trips unchanged") {
REQUIRE(loaded);
REQUIRE(dst_model.model_info != nullptr);
REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_TAG] == "1");
REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_KEYS_TAG] == R"(["layer_height","wall_thickness"])");
// The orca_published_keys value is a JSON array of setting keys; it must parse back to
// the same keys that were selected.
nlohmann::json keys = nlohmann::json::parse(dst_model.model_info->metadata_items[ORCA_PUBLISHED_KEYS_TAG]);
REQUIRE(keys.is_array());
REQUIRE(keys.size() == 2);
REQUIRE(keys[0] == "layer_height");
REQUIRE(keys[1] == "wall_thickness");
}
release_PlateData_list(dst_plates);
}
}
}
// A normal 3MF (no Publish metadata) must load identically: the loader must not fabricate a
// "orca_published" flag or orca_published_keys for files that never carried them.
SCENARIO("Legacy 3MF without published metadata loads unchanged", "[3mf]") {
GIVEN("a model without any published metadata") {
Model model;
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_legacy");
model.set_backup_path(backup_dir.string());
WHEN("stored to and reloaded from a .3mf") {
ScopedTemporaryFile temp(".3mf");
const std::string test_file = temp.string();
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
StoreParams store_params;
store_params.path = test_file.c_str();
store_params.model = &model;
store_params.config = &config;
store_params.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence;
REQUIRE(store_bbs_3mf(store_params));
Model dst_model;
DynamicPrintConfig dst_config;
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable };
PlateDataPtrs dst_plates;
std::vector<Preset*> project_presets;
bool is_bbl_3mf = false, is_orca_3mf = false;
Semver file_version;
bool loaded = load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model, &dst_plates,
&project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr,
LoadStrategy::LoadModel | LoadStrategy::LoadConfig);
THEN("no published key is fabricated") {
REQUIRE(loaded);
REQUIRE(dst_model.model_info != nullptr);
REQUIRE(dst_model.model_info->metadata_items.count(ORCA_PUBLISHED_TAG) == 0);
REQUIRE(dst_model.model_info->metadata_items.count(ORCA_PUBLISHED_KEYS_TAG) == 0);
}
release_PlateData_list(dst_plates);
}
}
}
// Locks the serialization contract of the orca_published_material_keys metadata: the per-entry JSON
// must survive a store_bbs_3mf -> load_bbs_3mf round-trip verbatim, exactly like orca_published_keys.
SCENARIO("Published 3MF round-trips the published_material_keys metadata", "[3mf]") {
GIVEN("a model carrying published material keys metadata") {
Model model;
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();
const std::string material_keys_json =
R"([{"material":{"filament_type":"PLA","filament_vendor":"Generic","filament_id":"GFL99"},"slot":0,"keys":["filament_retraction_length","filament_z_hop"]}])";
model.model_info = std::make_shared<ModelInfo>();
model.model_info->metadata_items[ORCA_PUBLISHED_MATERIAL_TAG] = material_keys_json;
ScopedTemporaryDir backup_dir("orca_pub_mat");
model.set_backup_path(backup_dir.string());
WHEN("stored to and reloaded from a .3mf") {
ScopedTemporaryFile temp(".3mf");
const std::string test_file = temp.string();
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
StoreParams store_params;
store_params.path = test_file.c_str();
store_params.model = &model;
store_params.config = &config;
store_params.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence;
REQUIRE(store_bbs_3mf(store_params));
Model dst_model;
DynamicPrintConfig dst_config;
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable };
PlateDataPtrs dst_plates;
std::vector<Preset*> project_presets;
bool is_bbl_3mf = false, is_orca_3mf = false;
Semver file_version;
bool loaded = load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model, &dst_plates,
&project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr,
LoadStrategy::LoadModel | LoadStrategy::LoadConfig);
THEN("the published material keys metadata round-trips unchanged") {
REQUIRE(loaded);
REQUIRE(dst_model.model_info != nullptr);
REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_MATERIAL_TAG] == material_keys_json);
// The value must parse back to one material entry carrying the nested identity
// object, the author slot ordinal and the key list.
nlohmann::json entries = nlohmann::json::parse(material_keys_json);
REQUIRE(entries.is_array());
REQUIRE(entries.size() == 1);
REQUIRE(entries[0]["material"]["filament_type"] == "PLA");
REQUIRE(entries[0]["material"]["filament_vendor"] == "Generic");
REQUIRE(entries[0]["material"]["filament_id"] == "GFL99");
REQUIRE(entries[0]["slot"] == 0);
REQUIRE(entries[0]["keys"].is_array());
REQUIRE(entries[0]["keys"].size() == 2);
REQUIRE(entries[0]["keys"][0] == "filament_retraction_length");
}
release_PlateData_list(dst_plates);
}
}
}
SCENARIO("Minimal published 3MF omits project config, preset dumps and slicer tags", "[3mf]") {
GIVEN("a multi-instance model carrying published metadata and a published_config payload") {
Model model;
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();
// A second instance: tag-less third-party files get their multi-instance objects split,
// published files must not (the loader recognizes them by their metadata).
model.objects.front()->add_instance();
DynamicPrintConfig full_cfg = DynamicPrintConfig::full_print_config();
full_cfg.set_key_value("layer_height", new ConfigOptionFloat(0.24));
full_cfg.set_key_value("retraction_length", new ConfigOptionFloats({ 1.2 }));
const std::vector<std::string> published_keys = { "layer_height", "retraction_length" };
const std::vector<PublishedMaterialEntry> material_keys = {
{ "PLA", "Generic", "GFL99", "", "Generic PLA", 0, { "filament_retraction_length" } }
};
// The payload builder keeps the published and identity keys and drops everything else.
DynamicPrintConfig filtered_cfg = filter_published_config(full_cfg, published_keys, material_keys);
REQUIRE(filtered_cfg.option("layer_height") != nullptr);
REQUIRE(filtered_cfg.option("retraction_length") != nullptr);
REQUIRE(filtered_cfg.option("filament_colour") != nullptr);
REQUIRE(filtered_cfg.option("filament_type") != nullptr);
REQUIRE(filtered_cfg.option("wipe_tower_x") != nullptr);
REQUIRE(filtered_cfg.option("sparse_infill_density") == nullptr);
REQUIRE(filtered_cfg.option("machine_start_gcode") == nullptr);
// Serialize the payload exactly like export_published_3mf does.
std::string payload;
for (const std::string &key : filtered_cfg.keys())
payload += key + " = " + filtered_cfg.opt_serialize(key) + "\n";
model.model_info = std::make_shared<ModelInfo>();
model.model_info->metadata_items[ORCA_PUBLISHED_TAG] = "1";
model.model_info->metadata_items[ORCA_PUBLISHED_KEYS_TAG] = R"(["layer_height","retraction_length"])";
model.model_info->metadata_items[ORCA_PUBLISHED_CONFIG_TAG] = payload;
ScopedTemporaryDir backup_dir("orca_min_pub");
model.set_backup_path(backup_dir.string());
WHEN("stored using SaveStrategy::MinimalPublished and reloaded") {
ScopedTemporaryFile temp(".3mf");
const std::string test_file = temp.string();
// Create a fake project preset to verify MinimalPublished omits it.
Preset preset(Preset::TYPE_PRINT, "TestPrintPreset");
preset.config = full_cfg;
std::vector<Preset*> project_presets = { &preset };
StoreParams store_params;
store_params.path = test_file.c_str();
store_params.model = &model;
store_params.config = &filtered_cfg;
store_params.project_presets = project_presets;
store_params.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence | SaveStrategy::MinimalPublished;
REQUIRE(store_bbs_3mf(store_params));
Model dst_model;
ScopedTemporaryDir loaded_backup_dir("orca_min_pub_loaded");
dst_model.set_backup_path(loaded_backup_dir.string());
DynamicPrintConfig dst_config;
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable };
PlateDataPtrs dst_plates;
std::vector<Preset*> loaded_presets;
bool is_bbl_3mf = false, is_orca_3mf = false;
Semver file_version;
bool loaded = load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model, &dst_plates,
&loaded_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr,
LoadStrategy::LoadModel | LoadStrategy::LoadConfig);
THEN("the 3MF loads without project config or embedded presets") {
REQUIRE(loaded);
REQUIRE(dst_config.empty());
REQUIRE(loaded_presets.empty());
}
THEN("the file carries no slicer tags and classifies as a generic 3MF") {
REQUIRE_FALSE(is_bbl_3mf);
REQUIRE_FALSE(is_orca_3mf);
// No Application / OrcaSlicer tag: old receivers import the geometry silently
// instead of showing a baked-in, wrong "old version" popup.
REQUIRE_FALSE(file_version.valid());
}
THEN("the geometry keeps BBS-grade handling: instances are not split") {
REQUIRE(dst_model.objects.size() == 1);
REQUIRE(dst_model.objects.front()->instances.size() == 2);
}
THEN("the published metadata and payload round-trip unchanged") {
REQUIRE(dst_model.model_info != nullptr);
REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_TAG] == "1");
REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_KEYS_TAG] == R"(["layer_height","retraction_length"])");
REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_CONFIG_TAG] == payload);
}
THEN("the payload parses back to the published values") {
DynamicPrintConfig parsed_payload;
parsed_payload.load_from_ini_string(dst_model.model_info->metadata_items[ORCA_PUBLISHED_CONFIG_TAG], ForwardCompatibilitySubstitutionRule::Enable);
REQUIRE(parsed_payload.option("layer_height") != nullptr);
REQUIRE_THAT(parsed_payload.opt_float("layer_height"), Catch::Matchers::WithinAbs(0.24, 1e-6));
REQUIRE(parsed_payload.option("retraction_length") != nullptr);
REQUIRE_THAT(parsed_payload.opt<ConfigOptionFloats>("retraction_length")->get_at(0), Catch::Matchers::WithinAbs(1.2, 1e-6));
}
release_PlateData_list(dst_plates);
}
}
}
// A minimal published 3MF must not leak the slicer tags of the source project. The exporter seeds
// metadata_item_map from the input file's metadata_items, so re-publishing a project opened from a
// regular Orca/BBS 3MF (the typical remix flow) must strip the Application / OrcaSlicer tags it
// came with, otherwise old receivers route onto the baked-in "old version" popup.
SCENARIO("MinimalPublished strips slicer tags carried by the source project", "[3mf]") {
GIVEN("a model loaded from a regular Orca/BBS 3MF whose metadata carries the slicer tags") {
Model model;
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();
model.model_info = std::make_shared<ModelInfo>();
model.model_info->metadata_items[ORCA_PUBLISHED_TAG] = "1";
model.model_info->metadata_items["Application"] = "BambuStudio-2.0.0";
model.model_info->metadata_items["OrcaSlicer"] = "2.1.0";
ScopedTemporaryDir backup_dir("orca_strip_tags");
model.set_backup_path(backup_dir.string());
WHEN("stored using SaveStrategy::MinimalPublished and reloaded") {
ScopedTemporaryFile temp(".3mf");
const std::string test_file = temp.string();
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
StoreParams store_params;
store_params.path = test_file.c_str();
store_params.model = &model;
store_params.config = &config;
store_params.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence | SaveStrategy::MinimalPublished;
REQUIRE(store_bbs_3mf(store_params));
Model dst_model;
DynamicPrintConfig dst_config;
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable };
PlateDataPtrs dst_plates;
std::vector<Preset*> loaded_presets;
bool is_bbl_3mf = false, is_orca_3mf = false;
Semver file_version;
bool loaded = load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model, &dst_plates,
&loaded_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr,
LoadStrategy::LoadModel | LoadStrategy::LoadConfig);
THEN("the source slicer tags are stripped, not carried through") {
REQUIRE(loaded);
REQUIRE(dst_model.model_info != nullptr);
REQUIRE(dst_model.model_info->metadata_items.count("Application") == 0);
REQUIRE(dst_model.model_info->metadata_items.count("OrcaSlicer") == 0);
// The published marker itself must survive.
REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_TAG] == "1");
}
THEN("the file classifies as a generic 3MF without a version popup") {
REQUIRE_FALSE(is_bbl_3mf);
REQUIRE_FALSE(is_orca_3mf);
REQUIRE_FALSE(file_version.valid());
}
release_PlateData_list(dst_plates);
}
}
}
// An entry masks the non-published slots to their defaults so publishing slot 1 never leaks slot
// 0's value into the file. Both a full entry (the whole-slot key list) and a partial entry (a
// per-slot key) go through the same masking path in filter_published_config (keys and full_keys
// are filtered identically), so the two forms are exercised together.
SCENARIO("Published entries mask the other slots to their defaults", "[3mf]") {
const bool full = GENERATE(true, false);
GIVEN("a full print configuration with two filament slots") {
DynamicPrintConfig full_cfg = DynamicPrintConfig::full_print_config();
full_cfg.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75, 1.75 };
full_cfg.opt<ConfigOptionStrings>("filament_colour")->values = { "#111111", "#222222" };
// filament_flow_ratio carries a non-empty option default (1.0) of the same type, so the
// mask can restore it on the non-published slot.
full_cfg.opt<ConfigOptionFloatsNullable>("filament_flow_ratio", true)->values = { 1.02, 0.98 };
WHEN("filtering with a published entry for slot 1") {
PublishedMaterialEntry entry;
entry.slot = 1;
if (full) {
entry.full = true;
entry.full_keys = { "filament_flow_ratio" };
} else {
entry.keys = { "filament_flow_ratio" };
}
DynamicPrintConfig filtered_cfg = filter_published_config(full_cfg, {}, { entry });
THEN("the selected key is present with the author's slot value") {
REQUIRE(filtered_cfg.option("filament_flow_ratio") != nullptr);
REQUIRE_THAT(filtered_cfg.opt<ConfigOptionFloatsNullable>("filament_flow_ratio")->values[1], Catch::Matchers::WithinAbs(0.98, 1e-6));
}
THEN("the non-published slot is masked to its default") {
REQUIRE_THAT(filtered_cfg.opt<ConfigOptionFloatsNullable>("filament_flow_ratio")->values[0], Catch::Matchers::WithinAbs(1.0, 1e-6));
}
THEN("the identity keys stay present") {
REQUIRE(filtered_cfg.option("filament_colour") != nullptr);
}
}
}
}
// A key needing slot masking that cannot be masked (no registered option default of the same
// type) is dropped from the payload entirely instead of shipping the author's whole vector.
SCENARIO("Unmaskable keys are dropped from the published payload instead of leaking", "[3mf]") {
GIVEN("a config carrying a synthetic def-less vector key and a maskable one") {
DynamicPrintConfig full_cfg = DynamicPrintConfig::full_print_config();
full_cfg.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75, 1.75 };
full_cfg.opt<ConfigOptionStrings>("filament_colour")->values = { "#111111", "#222222" };
// Not a PrintConfig key: print_config_def has no default to mask with.
full_cfg.set_key_value("orca_synthetic_setting", new ConfigOptionFloats({ 9.9, 8.8 }));
full_cfg.opt<ConfigOptionFloatsNullable>("filament_flow_ratio", true)->values = { 1.02, 0.98 };
PublishedMaterialEntry partial_entry;
partial_entry.slot = 1;
partial_entry.keys = { "orca_synthetic_setting", "filament_flow_ratio" };
WHEN("filtering with a partial entry for slot 1") {
DynamicPrintConfig filtered_cfg = filter_published_config(full_cfg, {}, { partial_entry });
THEN("the unmaskable synthetic key is not published") {
REQUIRE(filtered_cfg.option("orca_synthetic_setting") == nullptr);
}
THEN("the maskable key is present, author slot kept, other slot masked") {
REQUIRE(filtered_cfg.opt<ConfigOptionFloatsNullable>("filament_flow_ratio") != nullptr);
REQUIRE_THAT(filtered_cfg.opt<ConfigOptionFloatsNullable>("filament_flow_ratio")->values[1], Catch::Matchers::WithinAbs(0.98, 1e-6));
REQUIRE_THAT(filtered_cfg.opt<ConfigOptionFloatsNullable>("filament_flow_ratio")->values[0], Catch::Matchers::WithinAbs(1.0, 1e-6));
}
THEN("the identity keys stay present") {
REQUIRE(filtered_cfg.option("filament_colour") != nullptr);
}
}
}
}
// A per-extruder printer key carrying a "#N" variant (e.g. retraction_length#1) must not serialize
// every extruder's value: the base is masked to the author's extruder and the other slots are
// restored to their option default, matching the material-side slot-masking invariant. A bare
// printer base key (no variant) keeps whole-vector serialization.
SCENARIO("Published per-extruder printer keys mask the other extruders to their defaults", "[3mf]") {
GIVEN("a full print configuration with three extruders carrying per-extruder retraction values") {
DynamicPrintConfig full_cfg = DynamicPrintConfig::full_print_config();
// Non-default values on the un-selected slots, so a leak is distinguishable from the mask
// restoring the option default (retraction_length defaults to {0.8}).
full_cfg.opt<ConfigOptionFloats>("retraction_length")->values = { 3.0, 1.2, 4.0 };
WHEN("filtering with only extruder 1's retraction_length checked") {
DynamicPrintConfig filtered_cfg = filter_published_config(full_cfg, { "retraction_length#1" }, {});
THEN("the author's extruder value survives") {
REQUIRE_THAT(filtered_cfg.opt<ConfigOptionFloats>("retraction_length")->values[1], Catch::Matchers::WithinAbs(1.2, 1e-6));
}
THEN("the other extruders are masked to their default") {
REQUIRE_THAT(filtered_cfg.opt<ConfigOptionFloats>("retraction_length")->values[0], Catch::Matchers::WithinAbs(0.8, 1e-6));
REQUIRE_THAT(filtered_cfg.opt<ConfigOptionFloats>("retraction_length")->values[2], Catch::Matchers::WithinAbs(0.8, 1e-6));
}
}
WHEN("filtering the bare base key without a '#N' variant") {
DynamicPrintConfig filtered_cfg = filter_published_config(full_cfg, { "retraction_length" }, {});
THEN("the whole vector is serialized unmasked") {
REQUIRE_THAT(filtered_cfg.opt<ConfigOptionFloats>("retraction_length")->values[0], Catch::Matchers::WithinAbs(3.0, 1e-6));
REQUIRE_THAT(filtered_cfg.opt<ConfigOptionFloats>("retraction_length")->values[1], Catch::Matchers::WithinAbs(1.2, 1e-6));
REQUIRE_THAT(filtered_cfg.opt<ConfigOptionFloats>("retraction_length")->values[2], Catch::Matchers::WithinAbs(4.0, 1e-6));
}
}
}
}
// The extended per-entry fields (full dump list, published type and colour) travel inside the
// published_material_keys metadata and round-trip unchanged.
SCENARIO("Published 3MF round-trips the extended material metadata", "[3mf]") {
GIVEN("a model carrying extended published material keys metadata") {
Model model;
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();
const std::string material_keys_json =
R"([{"material":{"filament_type":"PLA","filament_vendor":"Generic","filament_id":"GFL99","setting_id":"RFs9eCKYOMUSmvZf","name":"Generic PLA Matte @System"},"slot":1,"keys":[],"full":true,"full_keys":["filament_retraction_length","filament_colour"],"publish_type":true,"type":"PLA","publish_color":false,"color":""}])";
model.model_info = std::make_shared<ModelInfo>();
model.model_info->metadata_items[ORCA_PUBLISHED_MATERIAL_TAG] = material_keys_json;
ScopedTemporaryDir backup_dir("orca_pub_mat2");
model.set_backup_path(backup_dir.string());
WHEN("stored to and reloaded from a .3mf") {
ScopedTemporaryFile temp(".3mf");
const std::string test_file = temp.string();
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
StoreParams store_params;
store_params.path = test_file.c_str();
store_params.model = &model;
store_params.config = &config;
store_params.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence;
REQUIRE(store_bbs_3mf(store_params));
Model dst_model;
DynamicPrintConfig dst_config;
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable };
PlateDataPtrs dst_plates;
std::vector<Preset*> project_presets;
bool is_bbl_3mf = false, is_orca_3mf = false;
Semver file_version;
bool loaded = load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model, &dst_plates,
&project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr,
LoadStrategy::LoadModel | LoadStrategy::LoadConfig);
THEN("the extended material metadata round-trips unchanged") {
REQUIRE(loaded);
REQUIRE(dst_model.model_info != nullptr);
REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_MATERIAL_TAG] == material_keys_json);
// The value must parse back with every extended field intact.
nlohmann::json entries = nlohmann::json::parse(material_keys_json);
REQUIRE(entries.is_array());
REQUIRE(entries.size() == 1);
REQUIRE(entries[0]["full"].get<bool>() == true);
REQUIRE(entries[0]["full_keys"].is_array());
REQUIRE(entries[0]["full_keys"].size() == 2);
REQUIRE(entries[0]["publish_type"].get<bool>() == true);
REQUIRE(entries[0]["type"] == "PLA");
REQUIRE(entries[0]["publish_color"].get<bool>() == false);
}
release_PlateData_list(dst_plates);
}
}
}
// A published mixed filament serializes its whole definition (components, ratios, gradient)
// masked to the author's slot: the mix slot's values survive, the non-published slots reset to
// their defaults, so a partial publish never leaks another slot's mix data.
SCENARIO("Published mixed-filament keys are masked to the author's slot", "[3mf]") {
GIVEN("a full print configuration with three slots, one of them mixed") {
DynamicPrintConfig full_cfg = DynamicPrintConfig::full_print_config();
full_cfg.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75, 1.75, 1.75 };
full_cfg.opt<ConfigOptionStrings>("filament_colour")->values = { "#111111", "#222222", "#333333" };
full_cfg.opt<ConfigOptionBools>("filament_is_mixed")->values = { 0, 0, 1 };
full_cfg.opt<ConfigOptionStrings>("filament_mixed_components")->values = { "", "", "1,2" };
full_cfg.opt<ConfigOptionStrings>("filament_mixed_sublayer_ratios")->values = { "", "", "0.6,0.4" };
full_cfg.opt<ConfigOptionBools>("filament_mixed_gradient")->values = { 0, 0, 1 };
full_cfg.opt<ConfigOptionStrings>("filament_mixed_gradient_range")->values = { "", "", "0.9,0.1" };
full_cfg.opt<ConfigOptionStrings>("filament_mixed_gradient_curve")->values = { "", "", "0,0.1|1,0.9" };
full_cfg.opt<ConfigOptionBools>("filament_mixed_gradient_per_part")->values = { 0, 0, 1 };
PublishedMaterialEntry mix_entry;
mix_entry.slot = 2;
mix_entry.keys = {
"filament_is_mixed", "filament_mixed_components", "filament_mixed_sublayer_ratios",
"filament_mixed_gradient", "filament_mixed_gradient_range", "filament_mixed_gradient_curve",
"filament_mixed_gradient_per_part"
};
WHEN("filtering with a mixed entry for slot 2") {
DynamicPrintConfig filtered_cfg = filter_published_config(full_cfg, {}, { mix_entry });
THEN("the author's mixed slot keeps its definition") {
REQUIRE(filtered_cfg.option("filament_is_mixed") != nullptr);
REQUIRE(filtered_cfg.opt<ConfigOptionBools>("filament_is_mixed")->values == std::vector<unsigned char>{ 0, 0, 1 });
const auto& components = filtered_cfg.opt<ConfigOptionStrings>("filament_mixed_components")->values;
REQUIRE(components.size() == 3);
CHECK(components[2] == "1,2");
CHECK(filtered_cfg.opt<ConfigOptionStrings>("filament_mixed_sublayer_ratios")->values[2] == "0.6,0.4");
CHECK(filtered_cfg.opt<ConfigOptionStrings>("filament_mixed_gradient_curve")->values[2] == "0,0.1|1,0.9");
CHECK(filtered_cfg.opt<ConfigOptionBools>("filament_mixed_gradient")->values[2]);
CHECK(filtered_cfg.opt<ConfigOptionBools>("filament_mixed_gradient_per_part")->values[2]);
}
THEN("the non-published slots are masked to their defaults") {
CHECK(filtered_cfg.opt<ConfigOptionStrings>("filament_mixed_components")->values[0] == "");
CHECK(filtered_cfg.opt<ConfigOptionStrings>("filament_mixed_components")->values[1] == "");
CHECK(filtered_cfg.opt<ConfigOptionBools>("filament_is_mixed")->values[0] == 0);
CHECK(filtered_cfg.opt<ConfigOptionBools>("filament_is_mixed")->values[1] == 0);
}
THEN("the identity keys stay present") {
REQUIRE(filtered_cfg.option("filament_colour") != nullptr);
}
}
}
}
// The published flag is gated on the exact string "1": any other serialized value means "not
// published", so a receiver never treats a file as published on a loose truthiness check.
TEST_CASE("is_published_3mf_flag accepts only the literal \"1\"", "[3mf]") {
CHECK(is_published_3mf_flag("1"));
CHECK_FALSE(is_published_3mf_flag("0"));
CHECK_FALSE(is_published_3mf_flag("false"));
CHECK_FALSE(is_published_3mf_flag("true"));
CHECK_FALSE(is_published_3mf_flag(""));
CHECK_FALSE(is_published_3mf_flag("YES"));
}
// bbs_3mf_is_published is the lightweight metadata probe used to decide whether a file was
// produced by the publish feature (GUI "recently published" tracking). It must return true only
// for a file whose metadata carries the flag set to "1", and false for legacy files and for a
// file whose flag is present but not "1" (which loads as a normal, non-published 3MF).
SCENARIO("bbs_3mf_is_published detects only genuinely published 3MFs", "[3mf]") {
auto store_model = [](const std::string &path, const std::string &flag_value, const std::string &keys_value) {
Model model;
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();
model.model_info = std::make_shared<ModelInfo>();
// An empty flag_value means "don't write the flag at all" (a legacy file).
if (!flag_value.empty())
model.model_info->metadata_items[ORCA_PUBLISHED_TAG] = flag_value;
model.model_info->metadata_items[ORCA_PUBLISHED_KEYS_TAG] = keys_value;
ScopedTemporaryDir backup_dir("orca_is_pub");
model.set_backup_path(backup_dir.string());
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
StoreParams store_params;
store_params.path = path.c_str();
store_params.model = &model;
store_params.config = &config;
store_params.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence;
REQUIRE(store_bbs_3mf(store_params));
};
GIVEN("a minimal published 3MF whose flag is \"1\"") {
ScopedTemporaryFile temp(".3mf");
store_model(temp.string(), "1", R"(["layer_height"])");
WHEN("probed by bbs_3mf_is_published") {
THEN("it is recognized as published") {
CHECK(bbs_3mf_is_published(temp.string()));
}
}
}
GIVEN("a legacy 3MF without any published flag") {
ScopedTemporaryFile temp(".3mf");
store_model(temp.string(), "", R"(["layer_height"])");
WHEN("probed by bbs_3mf_is_published") {
THEN("it is not recognized as published") {
CHECK_FALSE(bbs_3mf_is_published(temp.string()));
}
}
}
GIVEN("a 3MF carrying the flag set to \"0\"") {
ScopedTemporaryFile temp(".3mf");
store_model(temp.string(), "0", R"(["layer_height"])");
WHEN("probed and loaded") {
THEN("it is not recognized as published") {
CHECK_FALSE(bbs_3mf_is_published(temp.string()));
}
THEN("it loads as a normal, non-published 3MF") {
Model dst_model;
DynamicPrintConfig dst_config;
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable };
PlateDataPtrs dst_plates;
std::vector<Preset*> project_presets;
bool is_bbl_3mf = false, is_orca_3mf = false;
Semver file_version;
REQUIRE(load_bbs_3mf(temp.string().c_str(), &dst_config, &ctxt, &dst_model, &dst_plates,
&project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr,
LoadStrategy::LoadModel | LoadStrategy::LoadConfig));
REQUIRE(dst_model.model_info != nullptr);
// The key is present but not "1", so nothing treats the file as published; the
// stored keys still round-trip verbatim.
REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_TAG] == "0");
REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_KEYS_TAG] == R"(["layer_height"])");
release_PlateData_list(dst_plates);
}
}
}
}
// Writes a single-entry zip whose central directory carries a zip64 record declaring an
// uncompressed size beyond what the 32-bit expat buffer API can take, while the deflated
// payload inflates to only ~64 KiB. Built by hand because miniz never writes a size that
// disagrees with the data.
static void write_zip_with_oversized_entry(const std::string& path, const std::string& entry)
{
const std::string xml = "<?xml version=\"1.0\"?><!--" + std::string(65536, 'A') + "--><a/>";
size_t comp_len = 0;
void* comp = tdefl_compress_mem_to_heap(xml.data(), xml.size(), &comp_len, TDEFL_DEFAULT_MAX_PROBES);
REQUIRE(comp != nullptr);
const std::string deflated(static_cast<const char*>(comp), comp_len);
mz_free(comp);
const uint32_t crc = static_cast<uint32_t>(mz_crc32(MZ_CRC32_INIT, reinterpret_cast<const unsigned char*>(xml.data()), xml.size()));
const uint64_t claimed_size = (uint64_t(1) << 32) + 16;
std::string out;
auto put = [&out](uint64_t v, int bytes) {
for (int i = 0; i < bytes; ++i)
out.push_back(static_cast<char>((v >> (8 * i)) & 0xFF));
};
// local file header, with the true sizes
put(0x04034b50, 4); put(45, 2); put(0, 2); put(8, 2); put(0, 2); put(0, 2);
put(crc, 4); put(deflated.size(), 4); put(xml.size(), 4); put(entry.size(), 2); put(0, 2);
out += entry + deflated;
// central directory header, sizes deferred to the zip64 extra field
const size_t cd_offset = out.size();
put(0x02014b50, 4); put(45, 2); put(45, 2); put(0, 2); put(8, 2); put(0, 2); put(0, 2);
put(crc, 4); put(0xFFFFFFFF, 4); put(0xFFFFFFFF, 4); put(entry.size(), 2); put(20, 2);
put(0, 2); put(0, 2); put(0, 2); put(0, 4); put(0, 4);
out += entry;
put(0x0001, 2); put(16, 2); put(claimed_size, 8); put(deflated.size(), 8);
const size_t cd_size = out.size() - cd_offset;
// end of central directory
put(0x06054b50, 4); put(0, 2); put(0, 2); put(1, 2); put(1, 2);
put(cd_size, 4); put(cd_offset, 4); put(0, 2);
boost::nowide::ofstream f(path, std::ios::binary);
REQUIRE(f.good());
f.write(out.data(), static_cast<std::streamsize>(out.size()));
REQUIRE(f.good());
}
TEST_CASE("3MF XML entries declaring more than an int can hold fail to load", "[3mf]") {
ScopedTemporaryFile temp(".3mf");
const std::string path = temp.string();
SECTION("BBS importer") {
write_zip_with_oversized_entry(path, "_rels/.rels");
Model model;
DynamicPrintConfig config;
ConfigSubstitutionContext ctxt{ForwardCompatibilitySubstitutionRule::Enable};
PlateDataPtrs plates;
std::vector<Preset*> project_presets;
bool is_bbl_3mf = false, is_orca_3mf = false;
Semver file_version;
bool loaded = true;
REQUIRE_NOTHROW(loaded = load_bbs_3mf(path.c_str(), &config, &ctxt, &model, &plates, &project_presets, &is_bbl_3mf,
&is_orca_3mf, &file_version, nullptr, LoadStrategy::LoadModel | LoadStrategy::LoadConfig));
CHECK_FALSE(loaded);
release_PlateData_list(plates);
}
SECTION("PrusaSlicer importer") {
write_zip_with_oversized_entry(path, "Metadata/Slic3r_PE_model.config");
Model model;
DynamicPrintConfig config;
ConfigSubstitutionContext ctxt{ForwardCompatibilitySubstitutionRule::Disable};
bool loaded = true;
REQUIRE_NOTHROW(loaded = load_3mf(path.c_str(), config, ctxt, &model, false));
CHECK_FALSE(loaded);
}
SECTION("PrusaSlicer fingerprint probe") {
write_zip_with_oversized_entry(path, "3D/3dmodel.model");
PrusaFileParser parser;
CHECK_FALSE(parser.check_3mf_from_prusa(path));
}
}