From 36b2bd8bfc9da2720d2031444d1d3c5da7dc9574 Mon Sep 17 00:00:00 2001 From: Tommaso Bianchi Date: Sun, 28 Jun 2026 21:53:36 +0200 Subject: [PATCH] CAD persistence: implement recipe round-trip on the BBS 3mf backend (mirror) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror of snaporca-cad ad822bf. The earlier mirror (2c0140afb9) implemented persistence in the PrusaSlicer 3mf.cpp, but the GUI saves/loads projects via the BBS-native backend (store_bbs_3mf / load_bbs_3mf), so the recipe was never written to nor read from GUI-saved projects. - bbs_3mf.cpp: BBS_CAD_RECIPE_FILE = "Metadata/SnapOrca_cad.bin"; writer _add_cad_recipe_file_to_archive (called after layer-height in store_bbs_3mf); reader branch in _load_model_from_file's metadata dispatch loop (iterate + iequals on m_filename — mz_zip_reader_locate_file does not work on these archives). - Plater.cpp: load_files carries the Model-level cad_recipe onto q->model(). - test_3mf.cpp: [3mf] test asserting store_bbs_3mf embeds the recipe entry byte-for-byte (read back via miniz, Catch2 v3). Verified on behemoth: libslic3r_tests + the new [3mf] BBS test pass; orca-slicer GUI links clean. Round-trip verified live on snaporca-cad (identical code path). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q --- src/libslic3r/Format/bbs_3mf.cpp | 28 +++++++++++++++ src/slic3r/GUI/Plater.cpp | 6 ++++ tests/libslic3r/test_3mf.cpp | 59 ++++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+) diff --git a/src/libslic3r/Format/bbs_3mf.cpp b/src/libslic3r/Format/bbs_3mf.cpp index 31519e4fbe..37afa5d2b6 100644 --- a/src/libslic3r/Format/bbs_3mf.cpp +++ b/src/libslic3r/Format/bbs_3mf.cpp @@ -213,6 +213,7 @@ const std::string BBS_MODEL_CONFIG_RELS_FILE = "Metadata/_rels/model_settings.co const std::string SLICE_INFO_CONFIG_FILE = "Metadata/slice_info.config"; const std::string FILAMENT_SEQUENCE_FILE = "Metadata/filament_sequence.json"; const std::string BBS_LAYER_HEIGHTS_PROFILE_FILE = "Metadata/layer_heights_profile.txt"; +const std::string BBS_CAD_RECIPE_FILE = "Metadata/SnapOrca_cad.bin"; const std::string LAYER_CONFIG_RANGES_FILE = "Metadata/layer_config_ranges.xml"; const std::string BRIM_EAR_POINTS_FILE = "Metadata/brim_ear_points.txt"; /*const std::string SLA_SUPPORT_POINTS_FILE = "Metadata/Slic3r_PE_sla_support_points.txt"; @@ -1924,6 +1925,14 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) // extract slic3r print config file _extract_project_config_from_archive(archive, stat, config, config_substitutions, model); } + else if (boost::algorithm::iequals(name, BBS_CAD_RECIPE_FILE)) { + // SnapOrca: restore the editable CAD recipe (optional; absent in non-CAD projects). + if (stat.m_uncomp_size > 0) { + std::string buf((size_t)stat.m_uncomp_size, '\0'); + if (mz_zip_reader_extract_to_mem(&archive, stat.m_file_index, buf.data(), buf.size(), 0)) + model.cad_recipe = std::move(buf); + } + } else if (boost::algorithm::iequals(name, CUT_INFORMATION_FILE)) { // extract object cut info _extract_cut_information_from_archive(archive, stat, config_substitutions); @@ -5893,6 +5902,7 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) bool _add_mesh_to_object_stream(std::function const &flush, ObjectData const &object_data) const; bool _add_build_to_model_stream(std::stringstream& stream, const BuildItemsList& build_items) const; bool _add_layer_height_profile_file_to_archive(mz_zip_archive& archive, Model& model); + bool _add_cad_recipe_file_to_archive(mz_zip_archive& archive, Model& model); bool _add_layer_config_ranges_file_to_archive(mz_zip_archive& archive, Model& model); bool _add_brim_ear_points_file_to_archive(mz_zip_archive& archive, Model& model); bool _add_sla_support_points_file_to_archive(mz_zip_archive& archive, Model& model); @@ -6288,6 +6298,11 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) return false; } + if (!_add_cad_recipe_file_to_archive(archive, model)) { + close_zip_writer(&archive); + return false; + } + // BBS progress point /*BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ":" <<__LINE__ << boost::format("export 3mf EXPORT_STAGE_ADD_LAYER_RANGE\n"); if (proFn) { @@ -7514,6 +7529,19 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) return true; } + bool _BBS_3MF_Exporter::_add_cad_recipe_file_to_archive(mz_zip_archive& archive, Model& model) + { + if (model.cad_recipe.empty()) + return true; + if (!mz_zip_writer_add_mem(&archive, BBS_CAD_RECIPE_FILE.c_str(), + (const void*)model.cad_recipe.data(), model.cad_recipe.length(), + MZ_DEFAULT_COMPRESSION)) { + add_error("Unable to add CAD recipe file to archive"); + return false; + } + return true; + } + bool _BBS_3MF_Exporter::_add_layer_config_ranges_file_to_archive(mz_zip_archive& archive, Model& model) { std::string out = ""; diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index a64acd439c..bdee81b867 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -6801,6 +6801,12 @@ std::vector Plater::priv::load_files(const std::vector& input_ auto loaded_idxs = load_model_objects(model.objects, is_project_file); obj_idxs.insert(obj_idxs.end(), loaded_idxs.begin(), loaded_idxs.end()); + // load_model_objects only transfers ModelObjects; carry the Model-level CAD + // recipe (Metadata/SnapOrca_cad.bin) onto the plater model so the Design tab + // can rehydrate the editable feature tree on reopen. + if (!model.cad_recipe.empty()) + q->model().cad_recipe = model.cad_recipe; + BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ":" << __LINE__ << boost::format(", finished load_model_objects"); wxString msg = wxString::Format(_L("Loading file: %s"), from_path(real_filename)); dlg_cont = dlg.Update(progress_percent, msg); diff --git a/tests/libslic3r/test_3mf.cpp b/tests/libslic3r/test_3mf.cpp index e2ede29cf8..9cb66f8a63 100644 --- a/tests/libslic3r/test_3mf.cpp +++ b/tests/libslic3r/test_3mf.cpp @@ -1,9 +1,13 @@ #include "libslic3r/Model.hpp" #include "libslic3r/Format/3mf.hpp" +#include "libslic3r/Format/bbs_3mf.hpp" #include "libslic3r/Format/STL.hpp" +#include "libslic3r/miniz_extension.hpp" #include +#include +#include #include #include @@ -169,6 +173,61 @@ SCENARIO("CAD recipe blob survives a 3mf save/load cycle", "[3mf]") { } } +// The GUI saves/loads projects via the BBS-native 3mf backend (store_bbs_3mf / +// load_bbs_3mf), NOT the PrusaSlicer 3mf.cpp. This locks in that store_bbs_3mf embeds the +// CAD recipe (Metadata/SnapOrca_cad.bin) byte-for-byte, read back the same iterate-and-match +// way load_bbs_3mf does. The full GUI reopen is verified live on the Design tab. +SCENARIO("CAD recipe is embedded in the BBS 3mf archive", "[3mf]") { + GIVEN("a model carrying a binary cad_recipe") { + Model model; + std::string src = std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl"; + load_stl(src.c_str(), &model); + model.add_default_instances(); + + std::string recipe; + recipe.push_back('\x01'); + recipe.append("SNAPORCA"); + recipe.push_back('\0'); + recipe.append("\xff\xfe\x00\x10cad-features-blob"); + model.cad_recipe = recipe; + + WHEN("saved through the BBS backend (the format the GUI uses)") { + std::string test_file = std::string(TEST_DATA_DIR) + "/test_3mf/cad_bbs.3mf"; + DynamicPrintConfig cfg; + StoreParams sp; + sp.path = test_file.c_str(); + sp.model = &model; + sp.config = &cfg; + sp.strategy = SaveStrategy::Zip64; + REQUIRE(store_bbs_3mf(sp)); + + mz_zip_archive zip; + mz_zip_zero_struct(&zip); + REQUIRE(open_zip_reader(&zip, test_file)); + std::string got; + mz_uint n = mz_zip_reader_get_num_files(&zip); + for (mz_uint i = 0; i < n; ++i) { + mz_zip_archive_file_stat st; + if (!mz_zip_reader_file_stat(&zip, i, &st)) continue; + std::string name(st.m_filename); + std::replace(name.begin(), name.end(), '\\', '/'); + if (boost::algorithm::iequals(name, std::string("Metadata/SnapOrca_cad.bin"))) { + got.resize(st.m_uncomp_size); + mz_zip_reader_extract_to_mem(&zip, i, got.data(), got.size(), 0); + break; + } + } + close_zip_reader(&zip); + boost::filesystem::remove(test_file); + + THEN("the recipe is present byte-for-byte") { + REQUIRE(got.size() == recipe.size()); + REQUIRE(got == recipe); + } + } + } +} + SCENARIO("2D convex hull of sinking object", "[3mf][.]") { GIVEN("model") { // load a model