#include "libslic3r/Model.hpp" #include "libslic3r/Format/3mf.hpp" #include "libslic3r/Format/bbs_3mf.hpp" #include "libslic3r/Format/STL.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 #include #include #include #include #include // for std::enable_if_t #include // for typeid namespace Catch { template struct is_eigen_matrix : std::is_base_of, T> {}; template struct StringMaker::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 template struct StringMaker> { static std::string convert(const Eigen::Transform& 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 = \n"; ss << matrix.format(fmt); return ss.str(); } }; // Quaternions also need an explicit specialization template struct StringMaker> { static std::string convert(const Eigen::Quaternion& 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 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); } } } } // .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(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 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("filament_map"); REQUIRE(fmap != nullptr); REQUIRE(fmap->values == std::vector({ 1, 2, 1 })); // filament_volume_map (model_settings) with the >1 -> 0 clamp auto* fvmap = rt->config.option("filament_volume_map"); REQUIRE(fvmap != nullptr); REQUIRE(fvmap->values == std::vector({ 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("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 / 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 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(); // tag: device-facing per-nozzle diameter string, written verbatim. REQUIRE(rt->nozzles_info.size() >= 1); REQUIRE(rt->nozzles_info.front().diameter == "0.5"); // 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 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("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 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 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 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({ 0, 1 })); REQUIRE(result->get_used_filaments() == std::vector({ 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 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 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 vt = { nvtStandard, nvtHighFlow }; std::vector 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 // 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 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{ 0, 1, 0 }, nozzles, std::vector{ 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(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 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 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("enable_filament_dynamic_map"); const bool persisted_true = (dyn != nullptr && dyn->value); REQUIRE_FALSE(persisted_true); } release_PlateData_list(dst_plates); } delete plate; } } // Locks the serialization contract of the "Publish" metadata: the published flag and the // 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(); model.model_info->metadata_items["published"] = "1"; model.model_info->metadata_items["published_keys"] = 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 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["published"] == "1"); REQUIRE(dst_model.model_info->metadata_items["published_keys"] == R"(["layer_height","wall_thickness"])"); // The 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["published_keys"]); 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 // "published" flag or 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 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); if (dst_model.model_info != nullptr) { REQUIRE(dst_model.model_info->metadata_items.count("published") == 0); REQUIRE(dst_model.model_info->metadata_items.count("published_keys") == 0); } } release_PlateData_list(dst_plates); } } } // Locks the serialization contract of the published_material_keys metadata: the per-entry JSON // must survive a store_bbs_3mf -> load_bbs_3mf round-trip verbatim, exactly like 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(); model.model_info->metadata_items["published_material_keys"] = 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 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["published_material_keys"] == 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 published_keys = { "layer_height", "retraction_length" }; const std::vector 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(); model.model_info->metadata_items["published"] = "1"; model.model_info->metadata_items["published_keys"] = R"(["layer_height","retraction_length"])"; model.model_info->metadata_items["published_config"] = 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 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 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["published"] == "1"); REQUIRE(dst_model.model_info->metadata_items["published_keys"] == R"(["layer_height","retraction_length"])"); REQUIRE(dst_model.model_info->metadata_items["published_config"] == 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["published_config"], 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("retraction_length")->get_at(0), Catch::Matchers::WithinAbs(1.2, 1e-6)); } release_PlateData_list(dst_plates); } } } // A "full publish" entry carries the whole slot's key list: its vector options keep only the // author's slot value, the other slots are masked to their defaults so a slot-1 full publish // does not leak slot 0's data into the file. SCENARIO("Full-publish entries filter the whole slot and mask the other slots", "[3mf]") { GIVEN("a full print configuration with two filament slots") { DynamicPrintConfig full_cfg = DynamicPrintConfig::full_print_config(); full_cfg.opt("filament_diameter")->values = { 1.75, 1.75 }; full_cfg.opt("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("filament_flow_ratio", true)->values = { 1.02, 0.98 }; PublishedMaterialEntry full_entry; full_entry.slot = 1; full_entry.full = true; full_entry.full_keys = { "filament_flow_ratio" }; WHEN("filtering with a full entry for slot 1") { DynamicPrintConfig filtered_cfg = filter_published_config(full_cfg, {}, { full_entry }); THEN("the full key list is present with the author's slot value") { REQUIRE(filtered_cfg.option("filament_flow_ratio") != nullptr); REQUIRE(filtered_cfg.opt("filament_flow_ratio")->values[1] == 0.98); } THEN("the non-published slot is masked to its default") { REQUIRE(filtered_cfg.opt("filament_flow_ratio")->values[0] == 1.0); } THEN("the identity keys stay present") { REQUIRE(filtered_cfg.option("filament_colour") != nullptr); } } } } // A partial-publish entry (per-slot keys) is masked to the author's slot exactly like a full // entry, so publishing one slot's retraction does not ship the other slots' values in the file. SCENARIO("Partial-publish entries mask the other slots like full entries", "[3mf]") { GIVEN("a full print configuration with two filament slots") { DynamicPrintConfig full_cfg = DynamicPrintConfig::full_print_config(); full_cfg.opt("filament_diameter")->values = { 1.75, 1.75 }; full_cfg.opt("filament_colour")->values = { "#111111", "#222222" }; full_cfg.opt("filament_flow_ratio", true)->values = { 1.02, 0.98 }; PublishedMaterialEntry partial_entry; partial_entry.slot = 1; partial_entry.keys = { "filament_flow_ratio" }; WHEN("filtering with a partial entry for slot 1") { DynamicPrintConfig filtered_cfg = filter_published_config(full_cfg, {}, { partial_entry }); THEN("the partial key is present with the author's slot value") { REQUIRE(filtered_cfg.option("filament_flow_ratio") != nullptr); REQUIRE(filtered_cfg.opt("filament_flow_ratio")->values[1] == 0.98); } THEN("the non-published slot is masked to its default") { REQUIRE(filtered_cfg.opt("filament_flow_ratio")->values[0] == 1.0); } THEN("the identity keys stay present") { REQUIRE(filtered_cfg.option("filament_colour") != nullptr); } } } } // 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(); model.model_info->metadata_items["published_material_keys"] = 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 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["published_material_keys"] == 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() == true); REQUIRE(entries[0]["full_keys"].is_array()); REQUIRE(entries[0]["full_keys"].size() == 2); REQUIRE(entries[0]["publish_type"].get() == true); REQUIRE(entries[0]["type"] == "PLA"); REQUIRE(entries[0]["publish_color"].get() == false); } release_PlateData_list(dst_plates); } } }