#include "libslic3r/Model.hpp" #include "libslic3r/TriangleSelector.hpp" #include "libslic3r/Format/3mf.hpp" #include "libslic3r/Format/bbs_3mf.hpp" #include "libslic3r/Format/STL.hpp" #include "libslic3r/Utils.hpp" #include "libslic3r/PrintConfig.hpp" #include "libslic3r/Semver.hpp" #include "libslic3r/Preset.hpp" #include "libslic3r/MultiNozzleUtils.hpp" #include "libslic3r/ProjectTask.hpp" #include "test_utils.hpp" #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; } } SCENARIO("BBS 3MF round-trips per-plate IMEX state (parallel mode + head filament map)", "[3mf][IMEX]") { // Regression guard for the class of bug where per-plate state silently drops through // save/load (the variant-truncation bug was the precipitating example; IMEX plate state // rides the same XML metadata path and is equally vulnerable). // // The mode name is user-typed free text, so the values below deliberately carry every // character class that is special inside an XML attribute value: // & and < must be escaped or the document is not well formed and the project will // not load at all, // " must be escaped or it terminates the attribute early, // tab must be written as a numeric character reference, because XML normalizes // literal whitespace in attribute values on read and the mode would be // silently renamed, // ' and > are legal raw inside a double-quoted value and must survive untouched. // The head filament map value is machine generated in practice; it is given hostile // content here only to pin the escaping of the sibling attribute, so this asserts the // XML transport, not the map grammar (the loader stores the string verbatim). // // BBS exporter scaffolds a backup dir under temporary_dir() for the project config file; // point it at a writable location for the test process. set_temporary_dir(boost::filesystem::temp_directory_path().string()); const std::string hostile_mode = "PLA & ABS \"2x\"\tcopy's mode"; const std::string hostile_hfm = "1:2,2:3 & <\"\tx>"; GIVEN("A Model with a single object on plate 0 and IMEX plate state set") { Model src_model; std::string src_file = std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl"; REQUIRE(load_stl(src_file.c_str(), &src_model)); src_model.add_default_instances(); DynamicPrintConfig src_config; PlateDataPtrs src_plates; auto *plate0 = new PlateData(); plate0->plate_index = 0; plate0->config.set_key_value("imex_parallel_mode", new ConfigOptionString(hostile_mode)); plate0->config.set_key_value("imex_head_filament_map", new ConfigOptionString(hostile_hfm)); src_plates.push_back(plate0); WHEN("the model is saved to BBS 3MF and loaded back") { std::string test_file = std::string(TEST_DATA_DIR) + "/test_3mf/imex_roundtrip.3mf"; StoreParams store_params; store_params.path = test_file.c_str(); store_params.model = &src_model; store_params.plate_data_list = src_plates; store_params.config = &src_config; REQUIRE(store_bbs_3mf(store_params)); Model dst_model; DynamicPrintConfig dst_config; PlateDataPtrs dst_plates; std::vector dst_presets; bool is_bbl = false; bool is_orca = false; Semver file_version; ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Disable }; bool loaded = load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model, &dst_plates, &dst_presets, &is_bbl, &is_orca, &file_version); boost::filesystem::remove(test_file); THEN("load succeeds") { REQUIRE(loaded); } THEN("the loaded plate list has the same number of plates") { REQUIRE(dst_plates.size() == src_plates.size()); } THEN("imex_parallel_mode round-trips byte for byte, XML metacharacters included") { REQUIRE(dst_plates.size() >= 1); auto *mode_opt = dst_plates[0]->config.option("imex_parallel_mode"); REQUIRE(mode_opt != nullptr); REQUIRE(mode_opt->value == hostile_mode); } THEN("imex_head_filament_map round-trips byte for byte, XML metacharacters included") { REQUIRE(dst_plates.size() >= 1); auto *hfm_opt = dst_plates[0]->config.option("imex_head_filament_map"); REQUIRE(hfm_opt != nullptr); REQUIRE(hfm_opt->value == hostile_hfm); } release_PlateData_list(dst_plates); } release_PlateData_list(src_plates); } } SCENARIO("BBS 3MF round-trips distinct IMEX state across multiple plates", "[3mf][IMEX]") { // Guards against a plate-indexing regression where IMEX metadata lands on the wrong // plate or bleeds across plates on reload. Each plate carries distinct mode + head- // filament-map values; the reload must reproduce them in the same order. set_temporary_dir(boost::filesystem::temp_directory_path().string()); GIVEN("A Model with two plates each carrying different IMEX state") { Model src_model; std::string src_file = std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl"; REQUIRE(load_stl(src_file.c_str(), &src_model)); src_model.add_default_instances(); DynamicPrintConfig src_config; PlateDataPtrs src_plates; auto *plate0 = new PlateData(); plate0->plate_index = 0; plate0->config.set_key_value("imex_parallel_mode", new ConfigOptionString("copy_mode")); plate0->config.set_key_value("imex_head_filament_map", new ConfigOptionString("1:2")); src_plates.push_back(plate0); auto *plate1 = new PlateData(); plate1->plate_index = 1; plate1->config.set_key_value("imex_parallel_mode", new ConfigOptionString("mirror_mode")); plate1->config.set_key_value("imex_head_filament_map", new ConfigOptionString("2:4,3:5")); src_plates.push_back(plate1); WHEN("the model is saved to BBS 3MF and loaded back") { std::string test_file = std::string(TEST_DATA_DIR) + "/test_3mf/imex_multiplate_roundtrip.3mf"; StoreParams store_params; store_params.path = test_file.c_str(); store_params.model = &src_model; store_params.plate_data_list = src_plates; store_params.config = &src_config; REQUIRE(store_bbs_3mf(store_params)); Model dst_model; DynamicPrintConfig dst_config; PlateDataPtrs dst_plates; std::vector dst_presets; bool is_bbl = false; bool is_orca = false; Semver file_version; ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Disable }; bool loaded = load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model, &dst_plates, &dst_presets, &is_bbl, &is_orca, &file_version); boost::filesystem::remove(test_file); THEN("load succeeds and both plates are returned") { REQUIRE(loaded); REQUIRE(dst_plates.size() == 2); } THEN("plate 0 retains its own IMEX state (copy_mode, 1:2) and does not inherit plate 1's") { REQUIRE(dst_plates.size() == 2); auto *mode = dst_plates[0]->config.option("imex_parallel_mode"); auto *hfm = dst_plates[0]->config.option("imex_head_filament_map"); REQUIRE(mode != nullptr); REQUIRE(hfm != nullptr); REQUIRE(mode->value == "copy_mode"); REQUIRE(hfm->value == "1:2"); } THEN("plate 1 retains its own IMEX state (mirror_mode, 2:4,3:5) and does not inherit plate 0's") { REQUIRE(dst_plates.size() == 2); auto *mode = dst_plates[1]->config.option("imex_parallel_mode"); auto *hfm = dst_plates[1]->config.option("imex_head_filament_map"); REQUIRE(mode != nullptr); REQUIRE(hfm != nullptr); REQUIRE(mode->value == "mirror_mode"); REQUIRE(hfm->value == "2:4,3:5"); } release_PlateData_list(dst_plates); } release_PlateData_list(src_plates); } } SCENARIO("BBS 3MF does not emit IMEX metadata when plate is in primary mode", "[3mf][IMEX]") { // The serialization guard short-circuits when the mode is empty or "primary", so loading // a plate that was saved in primary mode must not leave a stale imex_parallel_mode option // on the plate's config. If this regressed, we'd see ghost "primary" strings appearing on // plates that had no IMEX state at all. set_temporary_dir(boost::filesystem::temp_directory_path().string()); GIVEN("A Model with plate 0 in primary mode and an empty head-filament map") { Model src_model; std::string src_file = std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl"; REQUIRE(load_stl(src_file.c_str(), &src_model)); src_model.add_default_instances(); DynamicPrintConfig src_config; PlateDataPtrs src_plates; auto *plate0 = new PlateData(); plate0->plate_index = 0; plate0->config.set_key_value("imex_parallel_mode", new ConfigOptionString("primary")); plate0->config.set_key_value("imex_head_filament_map", new ConfigOptionString("")); src_plates.push_back(plate0); WHEN("the model is saved to BBS 3MF and loaded back") { std::string test_file = std::string(TEST_DATA_DIR) + "/test_3mf/imex_primary_roundtrip.3mf"; StoreParams store_params; store_params.path = test_file.c_str(); store_params.model = &src_model; store_params.plate_data_list = src_plates; store_params.config = &src_config; REQUIRE(store_bbs_3mf(store_params)); Model dst_model; DynamicPrintConfig dst_config; PlateDataPtrs dst_plates; std::vector dst_presets; bool is_bbl = false; bool is_orca = false; Semver file_version; ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Disable }; bool loaded = load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model, &dst_plates, &dst_presets, &is_bbl, &is_orca, &file_version); boost::filesystem::remove(test_file); THEN("load succeeds") { REQUIRE(loaded); } THEN("the loaded plate has no imex_parallel_mode option set (primary is not serialized)") { REQUIRE(dst_plates.size() >= 1); auto *mode_opt = dst_plates[0]->config.option("imex_parallel_mode"); REQUIRE(mode_opt == nullptr); } THEN("the loaded plate has no imex_head_filament_map option set (empty is not serialized)") { REQUIRE(dst_plates.size() >= 1); auto *hfm_opt = dst_plates[0]->config.option("imex_head_filament_map"); REQUIRE(hfm_opt == nullptr); } release_PlateData_list(dst_plates); } release_PlateData_list(src_plates); } } // 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 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("filament_is_mixed"); REQUIRE(is_mixed != nullptr); REQUIRE(is_mixed->values == std::vector({ 0, 0, 0, 0, 1 })); auto* components = dst_config.option("filament_mixed_components"); REQUIRE(components != nullptr); REQUIRE(components->values.size() == 5); REQUIRE(components->values[4] == "3,2"); auto* ratios = dst_config.option("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 } } }