Published 3MF: silent geometry-only fallback in old versions via tag/config-less export

This commit is contained in:
Lam Wei Lun
2026-08-21 13:00:32 +08:00
parent 19a0407560
commit aa3ce35683
3 changed files with 202 additions and 102 deletions
+39 -9
View File
@@ -1214,6 +1214,19 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
// add backup & restore logic // add backup & restore logic
bool _load_model_from_file(std::string filename, Model& model, PlateDataPtrs& plate_data_list, std::vector<Preset*>& project_presets, DynamicPrintConfig& config, ConfigSubstitutionContext& config_substitutions, Import3mfProgressFn proFn = nullptr, bool _load_model_from_file(std::string filename, Model& model, PlateDataPtrs& plate_data_list, std::vector<Preset*>& project_presets, DynamicPrintConfig& config, ConfigSubstitutionContext& config_substitutions, Import3mfProgressFn proFn = nullptr,
BBLProject* project = nullptr, int plate_id = 0); 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 _is_svg_shape_file(const std::string &filename) const;
bool _extract_from_archive(mz_zip_archive& archive, std::string const & path, std::function<bool (mz_zip_archive& archive, const mz_zip_archive_file_stat& stat)>, bool restore = false); bool _extract_from_archive(mz_zip_archive& archive, std::string const & path, std::function<bool (mz_zip_archive& archive, const mz_zip_archive_file_stat& stat)>, bool restore = false);
bool _extract_xml_from_archive(mz_zip_archive& archive, std::string const & path, XML_StartElementHandler start_handler, XML_EndElementHandler end_handler); 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(); 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, // if the 3mf was not produced by OrcaSlicer and there is more than one instance,
// split the object in as many objects as instances // 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"); 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}); 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, // if the 3mf was not produced by OrcaSlicer and there is only one object,
// set the object name to match the filename // set the object name to match the filename
if (m_model->objects.size() == 1) 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); 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, // 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 // bake the transformation into the geometry to allow the reload from disk command
// to work properly // 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_save_gcode { false }; // whether to save gcode for normal save
bool m_skip_model { false }; // skip model when exporting .gcode.3mf bool m_skip_model { false }; // skip model when exporting .gcode.3mf
bool m_skip_auxiliary { false }; // skip normal axuiliary files 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_use_loaded_id { false }; // whether to use loaded id for identify_id
bool m_share_mesh { false }; // whether to share mesh between objects bool m_share_mesh { false }; // whether to share mesh between objects
std::string m_thumbnail_middle = PRINTER_THUMBNAIL_MIDDLE_FILE; 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"). // Adds slic3r print config file ("Metadata/Slic3r_PE.config").
// This file contains the content of FullPrintConfig / SLAFullPrintConfig. // 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 // BBS: change to json format
// if (!_add_print_config_file_to_archive(archive, *config)) { // if (!_add_print_config_file_to_archive(archive, *config)) {
if (!_add_project_config_file_to_archive(archive, *config, model)) { return false; } 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 // Orca: PRIVACY: do not store creation & modification date in 3mf
metadata_item_map[BBL_CREATION_DATE_TAG] = ""; metadata_item_map[BBL_CREATION_DATE_TAG] = "";
metadata_item_map[BBL_MODIFICATION_TAG] = ""; metadata_item_map[BBL_MODIFICATION_TAG] = "";
// Orca: Write the BambuStudio compatibility version string using SLIC3R_VERSION // Orca: Write the BambuStudio compatibility version string using SLIC3R_VERSION.
metadata_item_map[BBL_APPLICATION_TAG] = (boost::format("%1%-%2%") % "BambuStudio" % SLIC3R_VERSION).str(); // 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); 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 << "\">" stream << " <" << METADATA_TAG << " name=\"" << item.first << "\">"
<< xml_escape(item.second) << "</" << METADATA_TAG << ">\n"; << xml_escape(item.second) << "</" << METADATA_TAG << ">\n";
if (item.first == BBL_APPLICATION_TAG) { if (item.first == BBL_APPLICATION_TAG) {
stream << " <" << METADATA_TAG << " name=\"" << ORCASLICER_TAG << "\">" // The OrcaSlicer tag is the version receivers compare against their own to
<< xml_escape(SoftFever_VERSION) << "</" << METADATA_TAG << ">\n"; // 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) << "</" << METADATA_TAG << ">\n";
}
} }
} }
+123 -82
View File
@@ -6881,6 +6881,11 @@ std::vector<size_t> Plater::priv::load_files(const std::vector<fs::path>& input_
DynamicPrintConfig config; DynamicPrintConfig config;
Semver file_version; Semver file_version;
En3mfType en_3mf_file_type = En3mfType::From_BBS; 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; DynamicPrintConfig config_loaded;
@@ -6908,6 +6913,102 @@ std::vector<size_t> Plater::priv::load_files(const std::vector<fs::path>& input_
<< boost::format(", plate_data.size %1%, project_preset.size %2%, is_bbs_or_orca_3mf %3%, file_version %4% \n") % plate_data.size() % << 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(); 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<std::string>());
} 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<std::string>();
if (mat.contains("filament_vendor") && mat["filament_vendor"].is_string())
entry.filament_vendor = mat["filament_vendor"].get<std::string>();
if (mat.contains("filament_id") && mat["filament_id"].is_string())
entry.filament_id = mat["filament_id"].get<std::string>();
if (mat.contains("setting_id") && mat["setting_id"].is_string())
entry.setting_id = mat["setting_id"].get<std::string>();
if (mat.contains("name") && mat["name"].is_string())
entry.preset_name = mat["name"].get<std::string>();
}
if (m.contains("slot") && m["slot"].is_number_integer())
entry.slot = m["slot"].get<int>();
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<std::string>());
// Fields always written by the current exporter.
if (m.contains("full") && m["full"].is_boolean())
entry.full = m["full"].get<bool>();
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<std::string>());
if (m.contains("publish_type") && m["publish_type"].is_boolean())
entry.publish_type = m["publish_type"].get<bool>();
if (m.contains("type") && m["type"].is_string())
entry.publish_type_value = m["type"].get<std::string>();
if (m.contains("publish_color") && m["publish_color"].is_boolean())
entry.publish_color = m["publish_color"].get<bool>();
if (m.contains("color") && m["color"].is_string())
entry.color = m["color"].get<std::string>();
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 // 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 // 2. add extruder for BBS or Other model if only import geometry
if (en_3mf_file_type == En3mfType::From_Prusa || (load_model && !load_config)) { if (en_3mf_file_type == En3mfType::From_Prusa || (load_model && !load_config)) {
@@ -7071,7 +7172,7 @@ std::vector<size_t> Plater::priv::load_files(const std::vector<fs::path>& input_
text += "\n"; text += "\n";
log_and_show_3mf_info(text, bambu_project_title); 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 // 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."); wxString text = _L("The 3MF was created by BambuStudio. Some settings may differ from OrcaSlicer.");
log_and_show_3mf_info(text, bambu_project_title); log_and_show_3mf_info(text, bambu_project_title);
@@ -7153,8 +7254,10 @@ std::vector<size_t> Plater::priv::load_files(const std::vector<fs::path>& input_
} }
} }
// BBS:: project embedded presets // BBS:: project embedded presets (skipped for published projects: the author's
if ((project_presets.size() > 0) && load_config) { // 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 // load project embedded presets
PresetsConfigSubstitutions preset_substitutions; PresetsConfigSubstitutions preset_substitutions;
PresetBundle & preset_bundle = *wxGetApp().preset_bundle; PresetBundle & preset_bundle = *wxGetApp().preset_bundle;
@@ -7210,83 +7313,6 @@ std::vector<size_t> Plater::priv::load_files(const std::vector<fs::path>& 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<std::string>());
} 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<std::string>();
if (mat.contains("filament_vendor") && mat["filament_vendor"].is_string())
entry.filament_vendor = mat["filament_vendor"].get<std::string>();
if (mat.contains("filament_id") && mat["filament_id"].is_string())
entry.filament_id = mat["filament_id"].get<std::string>();
if (mat.contains("setting_id") && mat["setting_id"].is_string())
entry.setting_id = mat["setting_id"].get<std::string>();
if (mat.contains("name") && mat["name"].is_string())
entry.preset_name = mat["name"].get<std::string>();
}
if (m.contains("slot") && m["slot"].is_number_integer())
entry.slot = m["slot"].get<int>();
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<std::string>());
// Fields always written by the current exporter.
if (m.contains("full") && m["full"].is_boolean())
entry.full = m["full"].get<bool>();
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<std::string>());
if (m.contains("publish_type") && m["publish_type"].is_boolean())
entry.publish_type = m["publish_type"].get<bool>();
if (m.contains("type") && m["type"].is_string())
entry.publish_type_value = m["type"].get<std::string>();
if (m.contains("publish_color") && m["publish_color"].is_boolean())
entry.publish_color = m["publish_color"].get<bool>();
if (m.contains("color") && m["color"].is_string())
entry.color = m["color"].get<std::string>();
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 // 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 // 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 // published metadata is consumed above and stripped so a later save is a normal
@@ -7297,6 +7323,7 @@ std::vector<size_t> Plater::priv::load_files(const std::vector<fs::path>& input_
this->model.model_info->metadata_items.erase("published"); 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_keys");
this->model.model_info->metadata_items.erase("published_material_keys"); this->model.model_info->metadata_items.erase("published_material_keys");
this->model.model_info->metadata_items.erase("published_config");
} }
if (load_config) { if (load_config) {
@@ -16265,9 +16292,11 @@ int Plater::export_published_3mf(const std::vector<std::string>& 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 = 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_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_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 = 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_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_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) if (model.model_info == nullptr)
model.model_info = std::make_shared<ModelInfo>(); model.model_info = std::make_shared<ModelInfo>();
model.model_info->metadata_items["published"] = "1"; model.model_info->metadata_items["published"] = "1";
@@ -16275,9 +16304,17 @@ int Plater::export_published_3mf(const std::vector<std::string>& published_keys,
model.model_info->metadata_items["published_material_keys"] = jm.dump(); model.model_info->metadata_items["published_material_keys"] = jm.dump();
// Minimal published export: filter full_config to the published keys, material keys, // 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 full_cfg = wxGetApp().preset_bundle->full_config_secure();
DynamicPrintConfig filtered_cfg = filter_published_config(full_cfg, published_keys, material_keys); 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 // 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. // 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<std::string>& published_keys,
if (full_pathnames) if (full_pathnames)
save_strategy = save_strategy | SaveStrategy::FullPathSources; 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). // Restore the previous metadata state (both on success and on failure).
if (!had_model_info) { if (!had_model_info) {
@@ -16304,6 +16341,10 @@ int Plater::export_published_3mf(const std::vector<std::string>& published_keys,
model.model_info->metadata_items["published_material_keys"] = prev_material_keys; model.model_info->metadata_items["published_material_keys"] = prev_material_keys;
else else
model.model_info->metadata_items.erase("published_material_keys"); 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) { if (ret < 0) {
+40 -11
View File
@@ -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]") { SCENARIO("Minimal published 3MF omits project config, preset dumps and slicer tags", "[3mf]") {
GIVEN("a full print configuration and published keys") { GIVEN("a multi-instance model carrying published metadata and a published_config payload") {
Model model; Model model;
std::string src_file = std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl"; std::string src_file = std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl";
REQUIRE(load_stl(src_file.c_str(), &model)); REQUIRE(load_stl(src_file.c_str(), &model));
model.add_default_instances(); 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(); DynamicPrintConfig full_cfg = DynamicPrintConfig::full_print_config();
full_cfg.set_key_value("layer_height", new ConfigOptionFloat(0.24)); 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" } } { "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); 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("layer_height") != nullptr);
REQUIRE(filtered_cfg.option("retraction_length") != nullptr); REQUIRE(filtered_cfg.option("retraction_length") != nullptr);
REQUIRE(filtered_cfg.option("filament_colour") != nullptr); REQUIRE(filtered_cfg.option("filament_colour") != nullptr);
REQUIRE(filtered_cfg.option("filament_type") != nullptr); REQUIRE(filtered_cfg.option("filament_type") != nullptr);
REQUIRE(filtered_cfg.option("wipe_tower_x") != 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("sparse_infill_density") == nullptr);
REQUIRE(filtered_cfg.option("machine_start_gcode") == nullptr); REQUIRE(filtered_cfg.option("machine_start_gcode") == nullptr);
// Serialize the payload exactly like export_published_3mf does.
std::string payload;
for (const std::string &key : filtered_cfg.keys())
payload += key + " = " + filtered_cfg.opt_serialize(key) + "\n";
model.model_info = std::make_shared<ModelInfo>(); model.model_info = std::make_shared<ModelInfo>();
model.model_info->metadata_items["published"] = "1"; 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_keys"] = R"(["layer_height","retraction_length"])";
model.model_info->metadata_items["published_config"] = payload;
ScopedTemporaryDir backup_dir("orca_min_pub"); ScopedTemporaryDir backup_dir("orca_min_pub");
model.set_backup_path(backup_dir.string()); model.set_backup_path(backup_dir.string());
WHEN("stored using SaveStrategy::MinimalPublished") { WHEN("stored using SaveStrategy::MinimalPublished and reloaded") {
ScopedTemporaryFile temp(".3mf"); ScopedTemporaryFile temp(".3mf");
const std::string test_file = temp.string(); 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, 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, &loaded_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr,
LoadStrategy::LoadModel | LoadStrategy::LoadConfig); 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(loaded);
REQUIRE(dst_config.empty());
REQUIRE(loaded_presets.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)); THEN("the file carries no slicer tags and classifies as a generic 3MF") {
REQUIRE(dst_config.option("sparse_infill_density") == nullptr); 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<ConfigOptionFloats>("retraction_length")->get_at(0), Catch::Matchers::WithinAbs(1.2, 1e-6));
} }
release_PlateData_list(dst_plates); release_PlateData_list(dst_plates);
} }