diff --git a/src/libslic3r/CadDocument.cpp b/src/libslic3r/CadDocument.cpp index 19ed9ce665..79f92c3cdc 100644 --- a/src/libslic3r/CadDocument.cpp +++ b/src/libslic3r/CadDocument.cpp @@ -42,6 +42,10 @@ #include #include #include +#include + +#include +#include namespace Slic3r { @@ -1537,4 +1541,52 @@ bool CadDocument::preview(const CadFeature& candidate, TriangleMesh& out_mesh, s return preview(candidate, out_mesh, ignore, err); } +std::string brep_to_string(const TopoDS_Shape& s) +{ + if (s.IsNull()) return {}; + std::ostringstream oss; + BRepTools::Write(s, oss); + return oss.str(); +} + +TopoDS_Shape brep_from_string(const std::string& d) +{ + if (d.empty()) return {}; + std::istringstream iss(d); + TopoDS_Shape s; + BRep_Builder b; + BRepTools::Read(s, iss, b); + return s; +} + +std::string CadDocument::serialize_recipe() const +{ + std::ostringstream oss; + { + cereal::BinaryOutputArchive ar(oss); + uint32_t v = SNAPORCA_CAD_RECIPE_VERSION; + ar(v); + ar(features); + } + return oss.str(); +} + +bool CadDocument::deserialize_recipe(const std::string& blob) +{ + try { + std::istringstream iss(blob); + cereal::BinaryInputArchive ar(iss); + uint32_t v; + ar(v); + if (v > SNAPORCA_CAD_RECIPE_VERSION) + return false; + ar(features); + return recompute(); + } catch (const Standard_Failure&) { + return false; + } catch (...) { + return false; + } +} + } // namespace Slic3r diff --git a/src/libslic3r/CadDocument.hpp b/src/libslic3r/CadDocument.hpp index 0de663a74e..877d8d4244 100644 --- a/src/libslic3r/CadDocument.hpp +++ b/src/libslic3r/CadDocument.hpp @@ -8,6 +8,9 @@ #include #include +#include +#include +#include #include #include #include @@ -20,6 +23,11 @@ enum class BooleanMode { New, Add, Cut, Intersect }; enum class ExtrudeEnd { Blind, Symmetric, TwoSided, ThroughAll, UpToFace, UpToVertex }; +// Serialize a TopoDS_Shape to/from a BRep string (declared before CadFeature so its +// inline cereal save()/load() can resolve these non-dependent calls). +std::string brep_to_string(const TopoDS_Shape& s); +TopoDS_Shape brep_from_string(const std::string& d); + struct CadFeature { CadFeatureType type{CadFeatureType::Sketch}; std::string name; @@ -181,8 +189,56 @@ struct CadFeature { bool cut_flip{false}; // flip the normal => swaps which side is "upper" bool cut_keep_upper{true}; // keep the +normal half bool cut_keep_lower{false}; // keep the -normal half (both => split into two bodies) + + template + void save(Archive& ar) const { + std::string brep = (type == CadFeatureType::Import) ? brep_to_string(imported_solid) : std::string(); + ar(type, name, enabled, shape, plane, width, height, radius, + profile, entities, constraints, entity_constraints, imported_regions, + import_offset, import_scale_x, import_scale_y, import_on_face, import_face_body, + sketch_ref, distance, symmetric, mode, extrude_end, distance2, taper_deg, flip, + up_to_face, extrude_src_face, up_to_point, target_body, + dressup_size, face_group, dressup_edge, + hole_diameter, hole_depth, hole_through, hole_x, hole_y, + thread_radius, thread_pitch, thread_height, thread_depth, thread_internal, thread_x, thread_y, + shell_thickness, shell_face, + draft_face, draft_angle, + revolve_angle, revolve_axis, + sweep_path_ref, loft_profile_refs, loft_ruled, + pattern_circular, pattern_count, pattern_spacing, pattern_dir, pattern_angle, + plane_base, plane_offset, plane_angle_tilt, plane_axis, + bool_tool_body, bool_keep_tool, bool_tolerance, bool_target_face, bool_tool_face, + cut_offset, cut_flip, cut_keep_upper, cut_keep_lower, + brep); + } + template + void load(Archive& ar) { + std::string brep; + ar(type, name, enabled, shape, plane, width, height, radius, + profile, entities, constraints, entity_constraints, imported_regions, + import_offset, import_scale_x, import_scale_y, import_on_face, import_face_body, + sketch_ref, distance, symmetric, mode, extrude_end, distance2, taper_deg, flip, + up_to_face, extrude_src_face, up_to_point, target_body, + dressup_size, face_group, dressup_edge, + hole_diameter, hole_depth, hole_through, hole_x, hole_y, + thread_radius, thread_pitch, thread_height, thread_depth, thread_internal, thread_x, thread_y, + shell_thickness, shell_face, + draft_face, draft_angle, + revolve_angle, revolve_axis, + sweep_path_ref, loft_profile_refs, loft_ruled, + pattern_circular, pattern_count, pattern_spacing, pattern_dir, pattern_angle, + plane_base, plane_offset, plane_angle_tilt, plane_axis, + bool_tool_body, bool_keep_tool, bool_tolerance, bool_target_face, bool_tool_face, + cut_offset, cut_flip, cut_keep_upper, cut_keep_lower, + brep); + imported_solid = brep_from_string(brep); + } }; +// Serialize a TopoDS_Shape to/from a BRep string for cereal persistence. +std::string brep_to_string(const TopoDS_Shape& s); +TopoDS_Shape brep_from_string(const std::string& d); + // One independent solid in a multi-body document. struct CadBody { TopoDS_Shape shape; @@ -279,6 +335,10 @@ public: void clear(); bool recompute(); // replay features -> body + display_mesh; false on error + static constexpr uint32_t SNAPORCA_CAD_RECIPE_VERSION = 1; + std::string serialize_recipe() const; + bool deserialize_recipe(const std::string& blob); + // Undo/redo of the feature recipe (Onshape-style Ctrl+Z). The caller marks a // user-action boundary by calling checkpoint() BEFORE the mutation(s) for that // action (add/delete/move/replace, or a direct features edit). undo()/redo() then diff --git a/src/libslic3r/Format/3mf.cpp b/src/libslic3r/Format/3mf.cpp index 812e9f0154..d4f83f5ee4 100644 --- a/src/libslic3r/Format/3mf.cpp +++ b/src/libslic3r/Format/3mf.cpp @@ -73,6 +73,7 @@ const std::string THUMBNAIL_FILE = "Metadata/thumbnail.png"; const std::string PRINT_CONFIG_FILE = "Metadata/Slic3r_PE.config"; const std::string MODEL_CONFIG_FILE = "Metadata/Slic3r_PE_model.config"; const std::string LAYER_HEIGHTS_PROFILE_FILE = "Metadata/Slic3r_PE_layer_heights_profile.txt"; +const std::string CAD_RECIPE_FILE = "Metadata/SnapOrca_cad.bin"; const std::string LAYER_CONFIG_RANGES_FILE = "Metadata/Prusa_Slicer_layer_config_ranges.xml"; const std::string SLA_SUPPORT_POINTS_FILE = "Metadata/Slic3r_PE_sla_support_points.txt"; const std::string SLA_DRAIN_HOLES_FILE = "Metadata/Slic3r_PE_sla_drain_holes.txt"; @@ -805,6 +806,17 @@ ModelVolumeType type_from_string(const std::string &s) return false; } } + if (boost::algorithm::iequals(name, CAD_RECIPE_FILE)) { + if (stat.m_uncomp_size > 0) { + std::string buffer((size_t)stat.m_uncomp_size, 0); + if (mz_zip_reader_extract_file_to_mem(&archive, stat.m_filename, + (void*)buffer.data(), (size_t)stat.m_uncomp_size, 0) != 0) { + model.cad_recipe = std::move(buffer); + } else { + add_error("Error while reading CAD recipe data"); + } + } + } } } @@ -2317,6 +2329,7 @@ ModelVolumeType type_from_string(const std::string &s) bool _add_mesh_to_object_stream(mz_zip_writer_staged_context &context, ModelObject& object, VolumeToOffsetsMap& volumes_offsets); bool _add_build_to_model_stream(std::stringstream& stream, const BuildItemsList& build_items); 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_sla_support_points_file_to_archive(mz_zip_archive& archive, Model& model); bool _add_sla_drain_holes_file_to_archive(mz_zip_archive& archive, Model& model); @@ -2387,6 +2400,13 @@ ModelVolumeType type_from_string(const std::string &s) return false; } + // Adds CAD recipe file ("Metadata/SnapOrca_cad.bin"). + if (!_add_cad_recipe_file_to_archive(archive, model)) { + close_zip_writer(&archive); + boost::filesystem::remove(filename); + return false; + } + // Adds layer config ranges file ("Metadata/Slic3r_PE_layer_config_ranges.txt"). // All layer height profiles of all ModelObjects are stored here, indexed by 1 based index of the ModelObject in Model. // The index differes from the index of an object ID of an object instance of a 3MF file! @@ -2919,6 +2939,19 @@ ModelVolumeType type_from_string(const std::string &s) return true; } + bool _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, 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 _3MF_Exporter::_add_layer_config_ranges_file_to_archive(mz_zip_archive& archive, Model& model) { std::string out = ""; diff --git a/src/libslic3r/Model.cpp b/src/libslic3r/Model.cpp index 5ab9e00840..c9aeba8598 100644 --- a/src/libslic3r/Model.cpp +++ b/src/libslic3r/Model.cpp @@ -105,6 +105,8 @@ Model& Model::assign_copy(const Model &rhs) this->md_name = rhs.md_name; this->md_value = rhs.md_value; + this->cad_recipe = rhs.cad_recipe; + return *this; } @@ -148,6 +150,7 @@ Model& Model::assign_copy(Model &&rhs) rhs.model_info.reset(); this->profile_info = rhs.profile_info; rhs.profile_info.reset(); + this->cad_recipe = std::move(rhs.cad_recipe); return *this; } diff --git a/src/libslic3r/Model.hpp b/src/libslic3r/Model.hpp index d8697adb41..dbd5dfd497 100644 --- a/src/libslic3r/Model.hpp +++ b/src/libslic3r/Model.hpp @@ -1547,6 +1547,10 @@ public: std::vector md_name; std::vector md_value; + // SnapOrca: opaque parametric CAD recipe (CadDocument::serialize_recipe()), + // round-tripped through the 3MF as Metadata/SnapOrca_cad.bin. Empty for non-CAD projects. + std::string cad_recipe; + void SetDesigner(std::string designer, std::string designer_user_id) { if (design_info == nullptr) { design_info = std::make_shared(); diff --git a/src/slic3r/GUI/DesignPanel.cpp b/src/slic3r/GUI/DesignPanel.cpp index cc7e1068fd..1915a949de 100644 --- a/src/slic3r/GUI/DesignPanel.cpp +++ b/src/slic3r/GUI/DesignPanel.cpp @@ -2652,6 +2652,29 @@ int DesignPanel::tree_icon_for(CadFeatureType t) void DesignPanel::on_tab_shown() { if (m_viewport) m_viewport->refresh_bed(); + + // Rehydrate the parametric model from a freshly loaded project (the 3MF carried the + // recipe in Metadata/SnapOrca_cad.bin). Only when nothing is in progress here, so we + // never clobber an active design when the user just toggles back to the Design tab. + if (m_doc.features.empty()) { + if (Plater* plater = wxGetApp().plater()) { + const std::string& blob = plater->model().cad_recipe; + if (!blob.empty()) load_recipe(blob); + } + } +} + +void DesignPanel::load_recipe(const std::string& blob) +{ + if (blob.empty()) return; + if (!m_doc.deserialize_recipe(blob)) { + m_status->SetLabel(_L("Could not restore the CAD model from this project")); + return; + } + m_feature_counter = int(m_doc.features.size()); + feed_bodies(); // push the restored bodies into the viewport + refresh_tree(); // rebuild the feature tree from the restored recipe + set_status_ok(); } void DesignPanel::refresh_tree() @@ -4526,6 +4549,13 @@ void DesignPanel::on_commit() obj_list->load_mesh_object(m_disp_pick_mesh, "Design Body"); } + // Persist the editable parametric recipe alongside the committed meshes so the + // saved 3MF reopens with the full feature tree, not just the baked solid. An empty + // doc clears it, keeping non-CAD projects clean. + if (Plater* plater = wxGetApp().plater()) + plater->model().cad_recipe = + m_doc.features.empty() ? std::string() : m_doc.serialize_recipe(); + if (wxGetApp().mainframe != nullptr) wxGetApp().mainframe->select_tab(size_t(MainFrame::tp3DEditor)); } diff --git a/src/slic3r/GUI/DesignPanel.hpp b/src/slic3r/GUI/DesignPanel.hpp index b7af57c97d..1f0c07c3cd 100644 --- a/src/slic3r/GUI/DesignPanel.hpp +++ b/src/slic3r/GUI/DesignPanel.hpp @@ -86,6 +86,9 @@ private: // a modal dialog editing the feature's placement transform in place. void on_transform_imported(int feat_idx); void on_commit(); + // Rehydrate the parametric model from a project's saved recipe (3MF + // Metadata/SnapOrca_cad.bin): deserialize -> recompute -> refresh viewport + tree. + void load_recipe(const std::string& blob); void refresh_tree(); void set_status_ok(); diff --git a/tests/libslic3r/CMakeLists.txt b/tests/libslic3r/CMakeLists.txt index 66f7dc9ae1..cace3a3ed1 100644 --- a/tests/libslic3r/CMakeLists.txt +++ b/tests/libslic3r/CMakeLists.txt @@ -3,6 +3,7 @@ get_filename_component(_TEST_NAME ${CMAKE_CURRENT_LIST_DIR} NAME) add_executable(${_TEST_NAME}_tests ${_TEST_NAME}_tests.cpp test_3mf.cpp + test_caddocument.cpp test_aabbindirect.cpp test_appconfig.cpp test_arachne_walls.cpp diff --git a/tests/libslic3r/test_3mf.cpp b/tests/libslic3r/test_3mf.cpp index 7d7593948e..e2ede29cf8 100644 --- a/tests/libslic3r/test_3mf.cpp +++ b/tests/libslic3r/test_3mf.cpp @@ -133,6 +133,42 @@ SCENARIO("Export+Import geometry to/from 3mf file cycle", "[3mf]") { } } +SCENARIO("CAD recipe blob survives a 3mf save/load cycle", "[3mf]") { + GIVEN("a model carrying a binary cad_recipe") { + 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(); + + // Binary payload with an embedded NUL to prove the carrier is byte-safe + // (no XML/text mangling) — mirrors the cereal blob from serialize_recipe(). + std::string recipe; + recipe.push_back('\x01'); + recipe.append("SNAPORCA"); + recipe.push_back('\0'); + recipe.append("\xff\xfe\x00\x10cad-features-blob"); + src_model.cad_recipe = recipe; + + WHEN("the model is saved+loaded to/from a 3mf file") { + std::string test_file = std::string(TEST_DATA_DIR) + "/test_3mf/cad_recipe.3mf"; + store_3mf(test_file.c_str(), &src_model, nullptr, false); + + Model dst_model; + DynamicPrintConfig dst_config; + { + ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Disable }; + load_3mf(test_file.c_str(), dst_config, ctxt, &dst_model, false); + } + boost::filesystem::remove(test_file); + + THEN("the recipe round-trips byte-for-byte") { + REQUIRE(dst_model.cad_recipe.size() == recipe.size()); + REQUIRE(dst_model.cad_recipe == recipe); + } + } + } +} + SCENARIO("2D convex hull of sinking object", "[3mf][.]") { GIVEN("model") { // load a model diff --git a/tests/libslic3r/test_caddocument.cpp b/tests/libslic3r/test_caddocument.cpp index 800b4bac6f..7ce2de9a98 100644 --- a/tests/libslic3r/test_caddocument.cpp +++ b/tests/libslic3r/test_caddocument.cpp @@ -1,4 +1,4 @@ -#include +#include // mainline OrcaSlicer ships Catch2 v3 (v2 was catch2/catch.hpp) #include "libslic3r/CadDocument.hpp" #include "libslic3r/SketchEngine.hpp" @@ -13,8 +13,10 @@ #include #include #include +#include using namespace Slic3r; +using Catch::Approx; // Catch2 v3 scopes Approx under Catch:: (v2 had it unqualified) TEST_CASE("CadDocument profile sketch -> extrude -> solid", "[CadDocument]") { @@ -1364,3 +1366,58 @@ TEST_CASE("cut splits a body with a plane", "[cut]") REQUIRE_FALSE(doc.recompute()); } } + +TEST_CASE("serialize_recipe roundtrip with two bodies", "[CadDocument]") +{ + using Catch::Matchers::WithinRel; + CadDocument doc; + + // Body 1: rectangle sketch + extrude + fillet + int sk1 = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), + 20, 20, 10, "Rect1"); + REQUIRE(sk1 >= 0); + doc.add_extrude(sk1, 10.0, false, BooleanMode::New, "Extrude1"); + doc.add_fillet(2.0, FaceGroup::All, "Fillet1"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + // Body 2: circle sketch + extrude (separate New body) + SketchEntity circ; + circ.type = SketchEntity::Type::Circle; + circ.center = Vec2d(0, 0); + circ.radius = 8.0; + int sk2 = doc.add_sketch_entities({circ}, SketchPlane::XZ(), "Circle2"); + doc.add_extrude(sk2, 6.0, false, BooleanMode::New, "Extrude2"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + REQUIRE(doc.bodies.size() >= 2); + std::vector orig_vols; + for (const auto& b : doc.bodies) + orig_vols.push_back(double(SketchEngine::tessellate(b.shape).volume())); + + auto blob = doc.serialize_recipe(); + REQUIRE_FALSE(blob.empty()); + + CadDocument doc2; + REQUIRE(doc2.deserialize_recipe(blob)); + REQUIRE(doc2.error.empty()); + REQUIRE(doc2.bodies.size() == doc.bodies.size()); + + for (size_t i = 0; i < doc.bodies.size(); ++i) { + double v2 = double(SketchEngine::tessellate(doc2.bodies[i].shape).volume()); + REQUIRE_THAT(v2, WithinRel(orig_vols[i], 1e-6)); + } +} + +TEST_CASE("deserialize_recipe rejects future version", "[CadDocument]") +{ + CadDocument doc; + std::ostringstream oss; + { + cereal::BinaryOutputArchive ar(oss); + uint32_t v = 999; + ar(v); + } + REQUIRE_FALSE(doc.deserialize_recipe(oss.str())); +}