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
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);
// 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 (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);
@@ -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) << "</" << METADATA_TAG << ">\n";
if (item.first == BBL_APPLICATION_TAG) {
stream << " <" << METADATA_TAG << " name=\"" << ORCASLICER_TAG << "\">"
<< xml_escape(SoftFever_VERSION) << "</" << METADATA_TAG << ">\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) << "</" << 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;
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<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() %
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
// 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<size_t> Plater::priv::load_files(const std::vector<fs::path>& 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<size_t> Plater::priv::load_files(const std::vector<fs::path>& 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<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
// 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<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_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<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_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<ModelInfo>();
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();
// 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<std::string>& 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<std::string>& 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) {