Mirror CAD persistence (K1+K2+G1) from snaporca-cad onto mainline OrcaSlicer

Dual-fork mandate: port the parametric-recipe 3MF persistence from
snaporca-cad commits ed19eac+147e1c4 so a saved project reopens with the
editable CAD feature tree, not just the baked mesh.

Shared kernel/format/GUI (identical to snaporca-cad):
- CadDocument serialize_recipe/deserialize_recipe (cereal BinaryArchive,
  versioned) + CadFeature split save/load + imported_solid<->BRep string.
- Model::cad_recipe carried through 3MF zip entry Metadata/SnapOrca_cad.bin
  (writer + binary-verbatim reader branch).
- DesignPanel on_commit() stamps the recipe; on_tab_shown() rehydrates a
  loaded project via load_recipe() (deserialize -> feed_bodies + refresh_tree).

Mainline-only adapters (no snaporca-cad counterpart — Catch2 v3 vs v2):
- tests/libslic3r/test_caddocument.cpp: <catch2/catch_all.hpp> +
  `using Catch::Approx;` (v3 scopes Approx under Catch::).
- tests/libslic3r/CMakeLists.txt: register test_caddocument.cpp (the
  original CAD port had left it out of the test build).

Verified on behemoth (snaporca-deps toolchain): libslic3r_tests clean;
[CadDocument] 17/18 (only the pre-existing tangent-to-circle SIGABRT fails,
identical to snaporca-tkz); K1 serialize round-trip + version-reject pass
(13 assertions); new [3mf] "CAD recipe blob survives a 3mf save/load cycle"
passes byte-for-byte; orca-slicer GUI links clean (186/186, DesignPanel.cpp
compiled). Interactive :10 click-through pending (no Design-tab automation).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
This commit is contained in:
Tommaso Bianchi
2026-06-28 17:05:44 +02:00
co-authored by Claude Opus 4.8
parent c22351f63a
commit 2c0140afb9
10 changed files with 280 additions and 1 deletions
+52
View File
@@ -42,6 +42,10 @@
#include <cmath> #include <cmath>
#include <stdexcept> #include <stdexcept>
#include <algorithm> #include <algorithm>
#include <sstream>
#include <cereal/archives/binary.hpp>
#include <BRepTools.hxx>
namespace Slic3r { namespace Slic3r {
@@ -1537,4 +1541,52 @@ bool CadDocument::preview(const CadFeature& candidate, TriangleMesh& out_mesh, s
return preview(candidate, out_mesh, ignore, err); 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 } // namespace Slic3r
+60
View File
@@ -8,6 +8,9 @@
#include <TopoDS_Shape.hxx> #include <TopoDS_Shape.hxx>
#include <TopoDS_Wire.hxx> #include <TopoDS_Wire.hxx>
#include <cereal/cereal.hpp>
#include <cereal/types/vector.hpp>
#include <cereal/types/string.hpp>
#include <string> #include <string>
#include <vector> #include <vector>
#include <utility> #include <utility>
@@ -20,6 +23,11 @@ enum class BooleanMode { New, Add, Cut, Intersect };
enum class ExtrudeEnd { Blind, Symmetric, TwoSided, ThroughAll, UpToFace, UpToVertex }; 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 { struct CadFeature {
CadFeatureType type{CadFeatureType::Sketch}; CadFeatureType type{CadFeatureType::Sketch};
std::string name; std::string name;
@@ -181,8 +189,56 @@ struct CadFeature {
bool cut_flip{false}; // flip the normal => swaps which side is "upper" bool cut_flip{false}; // flip the normal => swaps which side is "upper"
bool cut_keep_upper{true}; // keep the +normal half bool cut_keep_upper{true}; // keep the +normal half
bool cut_keep_lower{false}; // keep the -normal half (both => split into two bodies) bool cut_keep_lower{false}; // keep the -normal half (both => split into two bodies)
template<class Archive>
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<class Archive>
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. // One independent solid in a multi-body document.
struct CadBody { struct CadBody {
TopoDS_Shape shape; TopoDS_Shape shape;
@@ -279,6 +335,10 @@ public:
void clear(); void clear();
bool recompute(); // replay features -> body + display_mesh; false on error 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 // 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 // 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 // action (add/delete/move/replace, or a direct features edit). undo()/redo() then
+33
View File
@@ -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 PRINT_CONFIG_FILE = "Metadata/Slic3r_PE.config";
const std::string MODEL_CONFIG_FILE = "Metadata/Slic3r_PE_model.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 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 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_SUPPORT_POINTS_FILE = "Metadata/Slic3r_PE_sla_support_points.txt";
const std::string SLA_DRAIN_HOLES_FILE = "Metadata/Slic3r_PE_sla_drain_holes.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; 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_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_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_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_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_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); 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; 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"). // 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. // 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! // 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; 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) bool _3MF_Exporter::_add_layer_config_ranges_file_to_archive(mz_zip_archive& archive, Model& model)
{ {
std::string out = ""; std::string out = "";
+3
View File
@@ -105,6 +105,8 @@ Model& Model::assign_copy(const Model &rhs)
this->md_name = rhs.md_name; this->md_name = rhs.md_name;
this->md_value = rhs.md_value; this->md_value = rhs.md_value;
this->cad_recipe = rhs.cad_recipe;
return *this; return *this;
} }
@@ -148,6 +150,7 @@ Model& Model::assign_copy(Model &&rhs)
rhs.model_info.reset(); rhs.model_info.reset();
this->profile_info = rhs.profile_info; this->profile_info = rhs.profile_info;
rhs.profile_info.reset(); rhs.profile_info.reset();
this->cad_recipe = std::move(rhs.cad_recipe);
return *this; return *this;
} }
+4
View File
@@ -1547,6 +1547,10 @@ public:
std::vector<std::string> md_name; std::vector<std::string> md_name;
std::vector<std::string> md_value; std::vector<std::string> 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) { void SetDesigner(std::string designer, std::string designer_user_id) {
if (design_info == nullptr) { if (design_info == nullptr) {
design_info = std::make_shared<ModelDesignInfo>(); design_info = std::make_shared<ModelDesignInfo>();
+30
View File
@@ -2652,6 +2652,29 @@ int DesignPanel::tree_icon_for(CadFeatureType t)
void DesignPanel::on_tab_shown() void DesignPanel::on_tab_shown()
{ {
if (m_viewport) m_viewport->refresh_bed(); 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() void DesignPanel::refresh_tree()
@@ -4526,6 +4549,13 @@ void DesignPanel::on_commit()
obj_list->load_mesh_object(m_disp_pick_mesh, "Design Body"); 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) if (wxGetApp().mainframe != nullptr)
wxGetApp().mainframe->select_tab(size_t(MainFrame::tp3DEditor)); wxGetApp().mainframe->select_tab(size_t(MainFrame::tp3DEditor));
} }
+3
View File
@@ -86,6 +86,9 @@ private:
// a modal dialog editing the feature's placement transform in place. // a modal dialog editing the feature's placement transform in place.
void on_transform_imported(int feat_idx); void on_transform_imported(int feat_idx);
void on_commit(); 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 refresh_tree();
void set_status_ok(); void set_status_ok();
+1
View File
@@ -3,6 +3,7 @@ get_filename_component(_TEST_NAME ${CMAKE_CURRENT_LIST_DIR} NAME)
add_executable(${_TEST_NAME}_tests add_executable(${_TEST_NAME}_tests
${_TEST_NAME}_tests.cpp ${_TEST_NAME}_tests.cpp
test_3mf.cpp test_3mf.cpp
test_caddocument.cpp
test_aabbindirect.cpp test_aabbindirect.cpp
test_appconfig.cpp test_appconfig.cpp
test_arachne_walls.cpp test_arachne_walls.cpp
+36
View File
@@ -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][.]") { SCENARIO("2D convex hull of sinking object", "[3mf][.]") {
GIVEN("model") { GIVEN("model") {
// load a model // load a model
+58 -1
View File
@@ -1,4 +1,4 @@
#include <catch2/catch.hpp> #include <catch2/catch_all.hpp> // mainline OrcaSlicer ships Catch2 v3 (v2 was catch2/catch.hpp)
#include "libslic3r/CadDocument.hpp" #include "libslic3r/CadDocument.hpp"
#include "libslic3r/SketchEngine.hpp" #include "libslic3r/SketchEngine.hpp"
@@ -13,8 +13,10 @@
#include <Bnd_Box.hxx> #include <Bnd_Box.hxx>
#include <BRepBndLib.hxx> #include <BRepBndLib.hxx>
#include <BRepPrimAPI_MakeBox.hxx> #include <BRepPrimAPI_MakeBox.hxx>
#include <cereal/archives/binary.hpp>
using namespace Slic3r; using namespace Slic3r;
using Catch::Approx; // Catch2 v3 scopes Approx under Catch:: (v2 had it unqualified)
TEST_CASE("CadDocument profile sketch -> extrude -> solid", "[CadDocument]") 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()); 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<double> 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()));
}