diff --git a/src/libslic3r/Format/bbs_3mf.cpp b/src/libslic3r/Format/bbs_3mf.cpp index a5d20807b7..06cc89de99 100644 --- a/src/libslic3r/Format/bbs_3mf.cpp +++ b/src/libslic3r/Format/bbs_3mf.cpp @@ -1214,6 +1214,19 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) // add backup & restore logic bool _load_model_from_file(std::string filename, Model& model, PlateDataPtrs& plate_data_list, std::vector& project_presets, DynamicPrintConfig& config, ConfigSubstitutionContext& config_substitutions, Import3mfProgressFn proFn = nullptr, BBLProject* project = nullptr, int plate_id = 0); + + // A minimal published 3MF carries no slicer tags (any tag would make old receivers show + // a baked-in, wrong "old version" popup on their geometry-only fallback), so it + // classifies as From_Other. It is still a fully structured OrcaSlicer file though: + // identified by its own metadata, it keeps BBS-grade geometry handling (no instance + // splitting, no transform baking, no renaming) in this build. Old receivers without the + // publish feature don't know the metadata and take their third-party geometry path. + // Reads the parse-time metadata: the model XML carries it before its resources, while + // m_model->model_info is only filled in after the whole XML has been parsed. + bool _is_published_3mf() const { + return this->model_info.metadata_items.find("published") != this->model_info.metadata_items.end(); + } + bool _is_svg_shape_file(const std::string &filename) const; bool _extract_from_archive(mz_zip_archive& archive, std::string const & path, std::function, bool restore = false); bool _extract_xml_from_archive(mz_zip_archive& archive, std::string const & path, XML_StartElementHandler start_handler, XML_EndElementHandler end_handler); @@ -2037,7 +2050,7 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) lock.close(); - if (!m_is_bbl_3mf) { + if (!m_is_bbl_3mf && !_is_published_3mf()) { // if the 3mf was not produced by OrcaSlicer and there is more than one instance, // split the object in as many objects as instances BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ":" << __LINE__ << boost::format(", found 3mf from other vendor, split as instance"); @@ -3591,7 +3604,7 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) m_index_paths.insert({ object.first.second, object.first.first}); } - if (!m_is_bbl_3mf) { + if (!m_is_bbl_3mf && !_is_published_3mf()) { // if the 3mf was not produced by OrcaSlicer and there is only one object, // set the object name to match the filename if (m_model->objects.size() == 1) @@ -5297,7 +5310,7 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) TriangleMesh triangle_mesh(std::move(its), volume_data.mesh_stats); - if (!m_is_bbl_3mf) { + if (!m_is_bbl_3mf && !_is_published_3mf()) { // if the 3mf was not produced by OrcaSlicer and there is only one instance, // bake the transformation into the geometry to allow the reload from disk command // to work properly @@ -5943,7 +5956,7 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) bool m_save_gcode { false }; // whether to save gcode for normal save bool m_skip_model { false }; // skip model when exporting .gcode.3mf bool m_skip_auxiliary { false }; // skip normal axuiliary files - bool m_minimal_published { false }; // published 3MF: omit embedded preset files + bool m_minimal_published { false }; // published 3MF: omit the project config, the embedded preset files and the slicer tags bool m_use_loaded_id { false }; // whether to use loaded id for identify_id bool m_share_mesh { false }; // whether to share mesh between objects std::string m_thumbnail_middle = PRINTER_THUMBNAIL_MIDDLE_FILE; @@ -6453,7 +6466,10 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) // Adds slic3r print config file ("Metadata/Slic3r_PE.config"). // This file contains the content of FullPrintConfig / SLAFullPrintConfig. - if (config != nullptr) { + // Omitted for minimal published 3MF: OrcaSlicer versions without the publish feature + // then fall back to importing the geometry only, and new versions read the published + // payload from the model metadata instead. + if (config != nullptr && !m_minimal_published) { // BBS: change to json format // if (!_add_print_config_file_to_archive(archive, *config)) { if (!_add_project_config_file_to_archive(archive, *config, model)) { return false; } @@ -6939,8 +6955,13 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) // Orca: PRIVACY: do not store creation & modification date in 3mf metadata_item_map[BBL_CREATION_DATE_TAG] = ""; metadata_item_map[BBL_MODIFICATION_TAG] = ""; - // Orca: Write the BambuStudio compatibility version string using SLIC3R_VERSION - metadata_item_map[BBL_APPLICATION_TAG] = (boost::format("%1%-%2%") % "BambuStudio" % SLIC3R_VERSION).str(); + // Orca: Write the BambuStudio compatibility version string using SLIC3R_VERSION. + // A minimal published 3MF writes no slicer tags at all: any tag would route old + // receivers onto a geometry-only fallback whose baked-in popup misreports the + // file ("old OrcaSlicer version" / "BambuStudio"), while tag-less files classify + // as From_Other and import the geometry silently. + if (!m_minimal_published) + metadata_item_map[BBL_APPLICATION_TAG] = (boost::format("%1%-%2%") % "BambuStudio" % SLIC3R_VERSION).str(); } metadata_item_map[BBS_3MF_VERSION] = std::to_string(VERSION_BBS_3MF); @@ -6966,8 +6987,17 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) stream << " <" << METADATA_TAG << " name=\"" << item.first << "\">" << xml_escape(item.second) << "\n"; if (item.first == BBL_APPLICATION_TAG) { - stream << " <" << METADATA_TAG << " name=\"" << ORCASLICER_TAG << "\">" - << xml_escape(SoftFever_VERSION) << "\n"; + // The OrcaSlicer tag is the version receivers compare against their own to + // pick the import branch, and every graceful config-less branch of an + // Orca-classified file shows a baked-in "old OrcaSlicer version" popup. A + // minimal published 3MF omits the tag (together with the Application tag + // above): old receivers then classify it From_Other and import the geometry + // silently, while this build rebuilds the config from the published metadata + // payload before the branch runs. + if (!m_minimal_published) { + stream << " <" << METADATA_TAG << " name=\"" << ORCASLICER_TAG << "\">" + << xml_escape(SoftFever_VERSION) << "\n"; + } } } diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index 50d613de33..908e4dc222 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -6881,6 +6881,11 @@ std::vector Plater::priv::load_files(const std::vector& input_ DynamicPrintConfig config; Semver file_version; En3mfType en_3mf_file_type = En3mfType::From_BBS; + // BBS: a "published" 3MF carries a flag plus the author-selected setting keys; + // on load keep the user's current presets and overlay only those keys. Declared + // here (outside the config block below) so it stays alive for the embedded-preset + // gate, the metadata strip and the preset overlay after the block closes. + PublishedConfig published_config; { DynamicPrintConfig config_loaded; @@ -6908,6 +6913,102 @@ std::vector Plater::priv::load_files(const std::vector& input_ << boost::format(", plate_data.size %1%, project_preset.size %2%, is_bbs_or_orca_3mf %3%, file_version %4% \n") % plate_data.size() % project_presets.size() % (en_3mf_file_type == En3mfType::From_BBS || en_3mf_file_type == En3mfType::From_Orca) % file_version.to_string(); + // BBS: a "published" 3MF carries a flag plus the author-selected setting keys; + // on load keep the user's current presets and overlay only those keys. Parsed + // here (before the version/fallback chain below) because a published file has + // no project_settings.config: its values travel in the published_config + // metadata payload, which must fill config_loaded before the chain decides + // whether to import geometry only. + if (model.model_info != nullptr) { + auto published_it = model.model_info->metadata_items.find("published"); + if (published_it != model.model_info->metadata_items.end() && + (published_it->second == "true" || published_it->second == "1")) { + published_config.published = true; + auto keys_it = model.model_info->metadata_items.find("published_keys"); + if (keys_it != model.model_info->metadata_items.end()) { + try { + auto j = nlohmann::json::parse(keys_it->second); + if (j.is_array()) + for (const auto &k : j) + if (k.is_string()) + published_config.published_keys.emplace_back(k.get()); + } catch (...) { + // Ignore malformed published_keys; the project still loads normally. + } + } + + auto material_keys_it = model.model_info->metadata_items.find("published_material_keys"); + if (material_keys_it != model.model_info->metadata_items.end()) { + try { + auto jm = nlohmann::json::parse(material_keys_it->second); + if (jm.is_array()) + for (const auto &m : jm) { + // Malformed entries are skipped individually. + if (!m.is_object()) + continue; + PublishedMaterialEntry entry; + const auto mat_it = m.find("material"); + if (mat_it != m.end() && mat_it->is_object()) { + const auto &mat = *mat_it; + if (mat.contains("filament_type") && mat["filament_type"].is_string()) + entry.filament_type = mat["filament_type"].get(); + if (mat.contains("filament_vendor") && mat["filament_vendor"].is_string()) + entry.filament_vendor = mat["filament_vendor"].get(); + if (mat.contains("filament_id") && mat["filament_id"].is_string()) + entry.filament_id = mat["filament_id"].get(); + if (mat.contains("setting_id") && mat["setting_id"].is_string()) + entry.setting_id = mat["setting_id"].get(); + if (mat.contains("name") && mat["name"].is_string()) + entry.preset_name = mat["name"].get(); + } + if (m.contains("slot") && m["slot"].is_number_integer()) + entry.slot = m["slot"].get(); + const auto entry_keys_it = m.find("keys"); + if (entry_keys_it != m.end() && entry_keys_it->is_array()) + for (const auto &k : *entry_keys_it) + if (k.is_string()) + entry.keys.emplace_back(k.get()); + // Fields always written by the current exporter. + if (m.contains("full") && m["full"].is_boolean()) + entry.full = m["full"].get(); + const auto entry_full_keys_it = m.find("full_keys"); + if (entry_full_keys_it != m.end() && entry_full_keys_it->is_array()) + for (const auto &k : *entry_full_keys_it) + if (k.is_string()) + entry.full_keys.emplace_back(k.get()); + if (m.contains("publish_type") && m["publish_type"].is_boolean()) + entry.publish_type = m["publish_type"].get(); + if (m.contains("type") && m["type"].is_string()) + entry.publish_type_value = m["type"].get(); + if (m.contains("publish_color") && m["publish_color"].is_boolean()) + entry.publish_color = m["publish_color"].get(); + if (m.contains("color") && m["color"].is_string()) + entry.color = m["color"].get(); + published_config.material_keys.emplace_back(std::move(entry)); + } + } catch (...) { + // Ignore malformed published_material_keys; the project still loads normally. + } + } + + // Rebuild the published values from the metadata payload: a published + // file carries no project_settings.config, so config_loaded is filled + // from here; a missing or malformed payload leaves it empty and the + // fallback chain below imports the geometry only. + auto payload_it = model.model_info->metadata_items.find("published_config"); + if (payload_it != model.model_info->metadata_items.end()) { + try { + ConfigSubstitutions payload_substitutions = config_loaded.load_from_ini_string(payload_it->second, ForwardCompatibilitySubstitutionRule::Enable); + config_substitutions.substitutions.insert(config_substitutions.substitutions.end(), + std::make_move_iterator(payload_substitutions.begin()), + std::make_move_iterator(payload_substitutions.end())); + } catch (...) { + // Ignore malformed published_config; the project still loads normally. + } + } + } + } + // 1. add extruder for prusa model if the number of existing extruders is not enough // 2. add extruder for BBS or Other model if only import geometry if (en_3mf_file_type == En3mfType::From_Prusa || (load_model && !load_config)) { @@ -7071,7 +7172,7 @@ std::vector Plater::priv::load_files(const std::vector& input_ text += "\n"; log_and_show_3mf_info(text, bambu_project_title); } - } else if (load_config) { + } else if (load_config && !published_config.published) { // BambuStudio version is older or same as our SLIC3R_VERSION wxString text = _L("The 3MF was created by BambuStudio. Some settings may differ from OrcaSlicer."); log_and_show_3mf_info(text, bambu_project_title); @@ -7153,8 +7254,10 @@ std::vector Plater::priv::load_files(const std::vector& input_ } } - // BBS:: project embedded presets - if ((project_presets.size() > 0) && load_config) { + // BBS:: project embedded presets (skipped for published projects: the author's + // embedded presets must not pollute the receiver's library, the overlay applies + // the published keys to the receiver's own presets instead). + if ((project_presets.size() > 0) && load_config && !published_config.published) { // load project embedded presets PresetsConfigSubstitutions preset_substitutions; PresetBundle & preset_bundle = *wxGetApp().preset_bundle; @@ -7210,83 +7313,6 @@ std::vector Plater::priv::load_files(const std::vector& input_ } } - // BBS: a "published" 3MF carries a flag plus the author-selected setting keys; - // on load keep the user's current presets and overlay only those keys. - PublishedConfig published_config; - if (model.model_info != nullptr) { - auto published_it = model.model_info->metadata_items.find("published"); - if (published_it != model.model_info->metadata_items.end() && - (published_it->second == "true" || published_it->second == "1")) { - published_config.published = true; - auto keys_it = model.model_info->metadata_items.find("published_keys"); - if (keys_it != model.model_info->metadata_items.end()) { - try { - auto j = nlohmann::json::parse(keys_it->second); - if (j.is_array()) - for (const auto &k : j) - if (k.is_string()) - published_config.published_keys.emplace_back(k.get()); - } catch (...) { - // Ignore malformed published_keys; the project still loads normally. - } - } - - auto material_keys_it = model.model_info->metadata_items.find("published_material_keys"); - if (material_keys_it != model.model_info->metadata_items.end()) { - try { - auto jm = nlohmann::json::parse(material_keys_it->second); - if (jm.is_array()) - for (const auto &m : jm) { - // Malformed entries are skipped individually. - if (!m.is_object()) - continue; - PublishedMaterialEntry entry; - const auto mat_it = m.find("material"); - if (mat_it != m.end() && mat_it->is_object()) { - const auto &mat = *mat_it; - if (mat.contains("filament_type") && mat["filament_type"].is_string()) - entry.filament_type = mat["filament_type"].get(); - if (mat.contains("filament_vendor") && mat["filament_vendor"].is_string()) - entry.filament_vendor = mat["filament_vendor"].get(); - if (mat.contains("filament_id") && mat["filament_id"].is_string()) - entry.filament_id = mat["filament_id"].get(); - if (mat.contains("setting_id") && mat["setting_id"].is_string()) - entry.setting_id = mat["setting_id"].get(); - if (mat.contains("name") && mat["name"].is_string()) - entry.preset_name = mat["name"].get(); - } - if (m.contains("slot") && m["slot"].is_number_integer()) - entry.slot = m["slot"].get(); - const auto entry_keys_it = m.find("keys"); - if (entry_keys_it != m.end() && entry_keys_it->is_array()) - for (const auto &k : *entry_keys_it) - if (k.is_string()) - entry.keys.emplace_back(k.get()); - // Fields always written by the current exporter. - if (m.contains("full") && m["full"].is_boolean()) - entry.full = m["full"].get(); - const auto entry_full_keys_it = m.find("full_keys"); - if (entry_full_keys_it != m.end() && entry_full_keys_it->is_array()) - for (const auto &k : *entry_full_keys_it) - if (k.is_string()) - entry.full_keys.emplace_back(k.get()); - if (m.contains("publish_type") && m["publish_type"].is_boolean()) - entry.publish_type = m["publish_type"].get(); - if (m.contains("type") && m["type"].is_string()) - entry.publish_type_value = m["type"].get(); - if (m.contains("publish_color") && m["publish_color"].is_boolean()) - entry.publish_color = m["publish_color"].get(); - if (m.contains("color") && m["color"].is_string()) - entry.color = m["color"].get(); - published_config.material_keys.emplace_back(std::move(entry)); - } - } catch (...) { - // Ignore malformed published_material_keys; the project still loads normally. - } - } - } - } - // BBS: a "published" 3MF loads as a new project: its path must not become the // project filename (Save/Ctrl-S would overwrite the shared file), and the // published metadata is consumed above and stripped so a later save is a normal @@ -7297,6 +7323,7 @@ std::vector Plater::priv::load_files(const std::vector& input_ this->model.model_info->metadata_items.erase("published"); this->model.model_info->metadata_items.erase("published_keys"); this->model.model_info->metadata_items.erase("published_material_keys"); + this->model.model_info->metadata_items.erase("published_config"); } if (load_config) { @@ -16265,9 +16292,11 @@ int Plater::export_published_3mf(const std::vector& published_keys, const bool had_published = had_model_info && (model.model_info->metadata_items.find("published") != model.model_info->metadata_items.end()); const bool had_published_keys = had_model_info && (model.model_info->metadata_items.find("published_keys") != model.model_info->metadata_items.end()); const bool had_material_keys = had_model_info && (model.model_info->metadata_items.find("published_material_keys") != model.model_info->metadata_items.end()); + const bool had_payload = had_model_info && (model.model_info->metadata_items.find("published_config") != model.model_info->metadata_items.end()); const std::string prev_published = had_published ? model.model_info->metadata_items.at("published") : std::string(); const std::string prev_published_keys = had_published_keys ? model.model_info->metadata_items.at("published_keys") : std::string(); const std::string prev_material_keys = had_material_keys ? model.model_info->metadata_items.at("published_material_keys") : std::string(); + const std::string prev_payload = had_payload ? model.model_info->metadata_items.at("published_config") : std::string(); if (model.model_info == nullptr) model.model_info = std::make_shared(); model.model_info->metadata_items["published"] = "1"; @@ -16275,9 +16304,17 @@ int Plater::export_published_3mf(const std::vector& published_keys, model.model_info->metadata_items["published_material_keys"] = jm.dump(); // Minimal published export: filter full_config to the published keys, material keys, - // identity fields and plate geometry keys, and omit project-embedded preset dumps. + // identity fields and plate geometry keys, and omit the project config file, the + // project-embedded preset dumps and the OrcaSlicer version tag from the archive. The + // filtered values are serialized into the published_config metadata payload instead, so + // OrcaSlicer versions without the publish feature fall back to importing the geometry only + // (keeping the receiver's presets) while new versions rebuild the config from the payload. DynamicPrintConfig full_cfg = wxGetApp().preset_bundle->full_config_secure(); DynamicPrintConfig filtered_cfg = filter_published_config(full_cfg, published_keys, material_keys); + std::string payload; + for (const std::string &key : filtered_cfg.keys()) + payload += key + " = " + filtered_cfg.opt_serialize(key) + "\n"; + model.model_info->metadata_items["published_config"] = std::move(payload); // Same file layout as save_project(), plus Silence (so export_3mf does not set the project // filename on success, keeping this a pure export like export_core_3mf()) and MinimalPublished. @@ -16286,7 +16323,7 @@ int Plater::export_published_3mf(const std::vector& published_keys, if (full_pathnames) save_strategy = save_strategy | SaveStrategy::FullPathSources; - const int ret = export_3mf(into_path(path), save_strategy, -1, nullptr, &filtered_cfg); + const int ret = export_3mf(into_path(path), save_strategy, -1, nullptr); // Restore the previous metadata state (both on success and on failure). if (!had_model_info) { @@ -16304,6 +16341,10 @@ int Plater::export_published_3mf(const std::vector& published_keys, model.model_info->metadata_items["published_material_keys"] = prev_material_keys; else model.model_info->metadata_items.erase("published_material_keys"); + if (had_payload) + model.model_info->metadata_items["published_config"] = prev_payload; + else + model.model_info->metadata_items.erase("published_config"); } if (ret < 0) { diff --git a/tests/libslic3r/test_3mf.cpp b/tests/libslic3r/test_3mf.cpp index 5fdad8b7ce..6a20613a79 100644 --- a/tests/libslic3r/test_3mf.cpp +++ b/tests/libslic3r/test_3mf.cpp @@ -671,12 +671,15 @@ SCENARIO("Published 3MF round-trips the published_material_keys metadata", "[3mf } } -SCENARIO("Minimal published 3MF serialization filters config and omits embedded presets", "[3mf]") { - GIVEN("a full print configuration and published keys") { +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)); @@ -687,27 +690,30 @@ SCENARIO("Minimal published 3MF serialization filters config and omits embedded { "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); - - // Filtered config keeps the published and identity 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); - - // ...and drops everything else. 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") { + WHEN("stored using SaveStrategy::MinimalPublished and reloaded") { ScopedTemporaryFile temp(".3mf"); const std::string test_file = temp.string(); @@ -736,12 +742,35 @@ SCENARIO("Minimal published 3MF serialization filters config and omits embedded 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 successfully without project embedded presets") { + THEN("the 3MF loads without project config or embedded presets") { REQUIRE(loaded); + REQUIRE(dst_config.empty()); REQUIRE(loaded_presets.empty()); - REQUIRE(dst_config.option("layer_height") != nullptr); - REQUIRE_THAT(dst_config.opt_float("layer_height"), Catch::Matchers::WithinAbs(0.24, 1e-6)); - REQUIRE(dst_config.option("sparse_infill_density") == nullptr); + } + 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); }