From 07b39d45cb909adb9025c5bdb80dcd6d056ab347 Mon Sep 17 00:00:00 2001 From: Tommaso Bianchi Date: Fri, 24 Jul 2026 21:46:24 +0200 Subject: [PATCH] CAD: split a body by a picked face; add the missing both-halves cut test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split-by-face reuses the existing Cut feature rather than adding a new type: two appended fields (cut_face_body, cut_face) let apply_cut derive the cut plane from a picked face via SketchPlane::from_face when cut_face >= 0, otherwise it keeps using the base `plane`. cut_offset / cut_flip still apply along the derived normal, so the same square-wire split machinery handles both cases. add_split_by_face() is the convenience entry point; MCP gains a `split` method (body / face_body / face / keep_upper / keep_lower). Serialization stays append-only — cut_face_body, cut_face appended to save/load, recipe version unchanged at 2. Tests: the previously-missing both-halves plane cut (keep_upper && keep_lower => two bodies whose volumes sum to the original), split-by-face via a top-face plane offset into the interior, keep-upper-only, and a round-trip. Golden fixture regenerated with a GoldenSplit cut-by-face feature and exact field-value assertions. Suite 63 -> 67 cases, 1119 -> 1178 assertions. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q --- src/libslic3r/CadDocument.cpp | 26 ++++- src/libslic3r/CadDocument.hpp | 20 +++- src/slic3r/GUI/McpControl.cpp | 27 +++++ tests/data/cad_recipe_v2.bin | Bin 24409 -> 25493 bytes tests/libslic3r/test_caddocument.cpp | 164 +++++++++++++++++++++++++++ 5 files changed, 232 insertions(+), 5 deletions(-) diff --git a/src/libslic3r/CadDocument.cpp b/src/libslic3r/CadDocument.cpp index bd8d57eda8..1041096925 100644 --- a/src/libslic3r/CadDocument.cpp +++ b/src/libslic3r/CadDocument.cpp @@ -724,6 +724,21 @@ int CadDocument::add_cut(const SketchPlane& plane, double offset, bool flip, return int(features.size()) - 1; } +int CadDocument::add_split_by_face(int target_body, int face_body, int face, + bool keep_upper, bool keep_lower, const std::string& name) +{ + CadFeature f; + f.type = CadFeatureType::Cut; + f.name = name; + f.target_body = target_body; + f.cut_face_body = face_body; + f.cut_face = face; + f.cut_keep_upper = keep_upper; + f.cut_keep_lower = keep_lower; + features.push_back(f); + return int(features.size()) - 1; +} + int CadDocument::add_mirror(const SketchPlane& plane, int target_body, BooleanMode mode, const std::string& name) { @@ -1905,7 +1920,16 @@ void CadDocument::apply_cut(std::vector& bodies, const CadFeature& f) c if (!f.cut_keep_upper && !f.cut_keep_lower) throw std::runtime_error("cut keeps nothing"); - SketchPlane cp = f.plane; + SketchPlane cp; + if (f.cut_face >= 0) { + const int fb = (f.cut_face_body >= 0 && f.cut_face_body < nb) ? f.cut_face_body : tgt; + if (bodies[fb].shape.IsNull()) throw std::runtime_error("cut: face body is empty"); + TopoDS_Face fc = GeometryEngine::face_by_index(bodies[fb].shape, f.cut_face); + if (fc.IsNull()) throw std::runtime_error("cut: face not found"); + cp = SketchPlane::from_face(fc); + } else { + cp = f.plane; + } cp.origin += cp.normal * f.cut_offset; if (f.cut_flip) cp.normal = -cp.normal; diff --git a/src/libslic3r/CadDocument.hpp b/src/libslic3r/CadDocument.hpp index d209cba6b2..c1947ba892 100644 --- a/src/libslic3r/CadDocument.hpp +++ b/src/libslic3r/CadDocument.hpp @@ -252,6 +252,12 @@ struct CadFeature { double thicken_thickness{2}; // wall thickness (always used as |value|) bool thicken_flip{false}; // true: offset against the face normal + // Cut-by-face: when cut_face >= 0, apply_cut derives the cut plane from this face + // (via SketchPlane::from_face) instead of the base `plane`. cut_offset / cut_flip + // still apply along the derived normal. + int cut_face_body{-1}; // body owning the face; -1 = the target body + int cut_face{-1}; // global face id to cut along; -1 = use `plane` + template void save(Archive& ar) const { std::string brep = (type == CadFeatureType::Import) ? brep_to_string(imported_solid) : std::string(); @@ -278,8 +284,9 @@ struct CadFeature { axis_type, axis_p1, axis_p2, axis_body, axis_face, axis_edge, axis_plane_a, axis_plane_b, coordsys_type, coordsys_point, coordsys_body, coordsys_face, coordsys_edge, coordsys_x_hint, helix_radius, helix_pitch, helix_height, helix_left_handed, helix_taper_deg, - xf_translate, xf_axis, xf_pivot, xf_angle_deg, xf_copy, - thicken_face, thicken_thickness, thicken_flip); + xf_translate, xf_axis, xf_pivot, xf_angle_deg, xf_copy, + thicken_face, thicken_thickness, thicken_flip, + cut_face_body, cut_face); } template void load(Archive& ar) { @@ -307,8 +314,9 @@ struct CadFeature { axis_type, axis_p1, axis_p2, axis_body, axis_face, axis_edge, axis_plane_a, axis_plane_b, coordsys_type, coordsys_point, coordsys_body, coordsys_face, coordsys_edge, coordsys_x_hint, helix_radius, helix_pitch, helix_height, helix_left_handed, helix_taper_deg, - xf_translate, xf_axis, xf_pivot, xf_angle_deg, xf_copy, - thicken_face, thicken_thickness, thicken_flip); + xf_translate, xf_axis, xf_pivot, xf_angle_deg, xf_copy, + thicken_face, thicken_thickness, thicken_flip, + cut_face_body, cut_face); imported_solid = brep_from_string(brep); } }; @@ -408,6 +416,10 @@ public: // +normal / -normal half; both => the body is split into two coexisting bodies. int add_cut(const SketchPlane& plane, double offset, bool flip, bool keep_upper, bool keep_lower, int target_body, const std::string& name); + // Split target_body along the plane of face `face` (owned by face_body, -1 = target). + // keep_upper/keep_lower select which half survives; both => split into two bodies. + int add_split_by_face(int target_body, int face_body, int face, + bool keep_upper, bool keep_lower, const std::string& name); int add_mirror(const SketchPlane& plane, int target_body, BooleanMode mode, const std::string& name); // Rigid body transform: rotate `angle_deg` about `axis` through `pivot`, then translate. diff --git a/src/slic3r/GUI/McpControl.cpp b/src/slic3r/GUI/McpControl.cpp index 9b6b010c69..3b7d01b0e5 100644 --- a/src/slic3r/GUI/McpControl.cpp +++ b/src/slic3r/GUI/McpControl.cpp @@ -240,6 +240,14 @@ json describe_tools() json{{"name", "thickness"}, {"type", "number"}, {"unit", "mm"}, {"default", 2}, {"min", 0.01}}, json{{"name", "flip"}, {"type", "boolean"}, {"default", false}}, })}}, + json{{"name", "split"}, {"summary", "Split a body along the plane of a picked face."}, + {"params", json::array({ + json{{"name", "body"}, {"type", "integer"}, {"default", -1}, {"description", "target body; omit for the last body"}}, + json{{"name", "face_body"}, {"type", "integer"}, {"default", -1}, {"description", "body that owns the face; -1 = target"}}, + json{{"name", "face"}, {"type", "integer"}, {"description", "face id to split along (query_topology)"}}, + json{{"name", "keep_upper"},{"type", "boolean"}, {"default", true}}, + json{{"name", "keep_lower"},{"type", "boolean"}, {"default", true}}, + })}}, json{{"name", "query_topology"}, {"summary", "Measured faces (centroid/normal/cylinder) and edges (length/circle) of a body."}, {"params", json::array({ json{{"name", "body"}, {"type", "integer"}, {"default", 0}}, @@ -906,6 +914,24 @@ json action_thicken(DesignPanel* panel, const json& params) return json{{"ok", ok}, {"thicken_index", idx}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}}; } +json action_split(DesignPanel* panel, const json& params) +{ + if (!params.contains("face")) throw std::runtime_error("split needs 'face' (id from query_topology)"); + CadDocument& doc = panel->mcp_doc(); + if (doc.bodies.empty()) throw std::runtime_error("no body to split"); + int bi = target_body_arg(params, doc); + int face_body = params.value("face_body", -1); + int face = params["face"].get(); + bool keep_upper = params.value("keep_upper", true); + bool keep_lower = params.value("keep_lower", true); + doc.checkpoint(); + int idx = doc.add_split_by_face(bi, face_body, face, keep_upper, keep_lower, "Split"); + bool ok = doc.recompute(); + if (!ok) doc.undo(); + panel->mcp_after_change(); + return json{{"ok", ok}, {"split_index", idx}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}}; +} + json action_axis(DesignPanel* panel, const json& params) { CadDocument& doc = panel->mcp_doc(); @@ -1006,6 +1032,7 @@ std::string handle_on_main(const std::string& method, const json& params, const if (method == "mirror") return rpc_result(id, action_mirror(panel, params)); if (method == "transform") return rpc_result(id, action_transform(panel, params)); if (method == "thicken") return rpc_result(id, action_thicken(panel, params)); + if (method == "split") return rpc_result(id, action_split(panel, params)); if (method == "axis") return rpc_result(id, action_axis(panel, params)); if (method == "coordsys") return rpc_result(id, action_coordsys(panel, params)); if (method == "helix") return rpc_result(id, action_helix(panel, params)); diff --git a/tests/data/cad_recipe_v2.bin b/tests/data/cad_recipe_v2.bin index 288bbdf1b10b47cd8f9aff623818f7534f186caf..c07ed6261cdae772b575b52027c260ace5aa5fa9 100644 GIT binary patch delta 459 zcmcb)k8$d8Mph;U1_s%UtkKLI|Dj-W0&^%5Zz~%zkBbYL7tb4vq;iu$AQF#RBm{{U zE{?2nl@zkd-?GSN1}KIh>073<1Bn->fuwSBp1#=TliEv>) zLRQGUmA1(KXK_S!d4vnH3j{onnzWR^@;G%}vVesA&veV)k%@;#d`curyD0I31R9{?{bAL{@B delta 155 zcmbPwobl#9Mph;U1_qgptkKMyGnhk}Hy>pK5=vY^qMkRHdGjrSK<3THA|cG1`^ACW zS5iQ(vm8)%fnpf*=6@&&9kh4 wgryzO^f`_|Lw>jbHSF*JvP67zm^U8_0vfCs2IN*pMlf%_9TUb30AZOpbpQYW diff --git a/tests/libslic3r/test_caddocument.cpp b/tests/libslic3r/test_caddocument.cpp index 609da0c869..d4fd147268 100644 --- a/tests/libslic3r/test_caddocument.cpp +++ b/tests/libslic3r/test_caddocument.cpp @@ -1384,6 +1384,160 @@ TEST_CASE("cut splits a body with a plane", "[cut]") } } +TEST_CASE("both-halves plane cut splits a body into two equal halves", "[CadDocument][cut]") +{ + using Catch::Matchers::WithinRel; + + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Box"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "E"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(doc.bodies.size() == 1); + double v_orig = double(SketchEngine::tessellate(doc.bodies[0].shape).volume()); + REQUIRE(v_orig > 0.0); + + doc.add_cut(SketchPlane::XY(), 5.0, false, true, true, 0, "SplitBoth"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(doc.bodies.size() == 2); + + double v0 = double(SketchEngine::tessellate(doc.bodies[0].shape).volume()); + double v1 = double(SketchEngine::tessellate(doc.bodies[1].shape).volume()); + REQUIRE_THAT(v0 + v1, WithinRel(v_orig, 1e-4)); + REQUIRE_THAT(v0, WithinRel(20.0 * 20.0 * 5.0, 0.01)); + REQUIRE_THAT(v1, WithinRel(20.0 * 20.0 * 5.0, 0.01)); +} + +TEST_CASE("split by a body face divides a box into two halves via top-face offset", "[CadDocument][cut]") +{ + using Catch::Matchers::WithinRel; + + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Box"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "E"); + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 1); + double v_orig = double(SketchEngine::tessellate(doc.bodies[0].shape).volume()); + + int n_faces = GeometryEngine::face_count(doc.bodies[0].shape); + int top_face = -1; + for (int i = 0; i < n_faces; ++i) { + TopoDS_Face fc = GeometryEngine::face_by_index(doc.bodies[0].shape, i); + Vec3d n = GeometryEngine::face_normal_world(fc); + if (n.z() > 0.9) { top_face = i; break; } + } + REQUIRE(top_face >= 0); + + doc.add_split_by_face(0, -1, top_face, true, true, "SplitByFace"); + doc.features.back().cut_offset = -5.0; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(doc.bodies.size() == 2); + + double v0 = double(SketchEngine::tessellate(doc.bodies[0].shape).volume()); + double v1 = double(SketchEngine::tessellate(doc.bodies[1].shape).volume()); + REQUIRE_THAT(v0 + v1, WithinRel(v_orig, 1e-4)); + REQUIRE_THAT(v0, WithinRel(20.0 * 20.0 * 5.0, 0.02)); + REQUIRE_THAT(v1, WithinRel(20.0 * 20.0 * 5.0, 0.02)); +} + +TEST_CASE("split by face keep upper only", "[CadDocument][cut]") +{ + using Catch::Matchers::WithinRel; + + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Box"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "E"); + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 1); + + int n_faces = GeometryEngine::face_count(doc.bodies[0].shape); + int top_face = -1; + for (int i = 0; i < n_faces; ++i) { + TopoDS_Face fc = GeometryEngine::face_by_index(doc.bodies[0].shape, i); + Vec3d n = GeometryEngine::face_normal_world(fc); + if (n.z() > 0.9) { top_face = i; break; } + } + REQUIRE(top_face >= 0); + + doc.add_split_by_face(0, -1, top_face, true, false, "SplitUpper"); + doc.features.back().cut_offset = -5.0; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(doc.bodies.size() == 1); + + double v = double(SketchEngine::tessellate(doc.bodies[0].shape).volume()); + REQUIRE_THAT(v, WithinRel(20.0 * 20.0 * 5.0, 0.02)); +} + +TEST_CASE("split by face round-trip serialization", "[CadDocument][cut]") +{ + using Catch::Matchers::WithinRel; + using Catch::Matchers::WithinAbs; + + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Box"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "E"); + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 1); + + int n_faces = GeometryEngine::face_count(doc.bodies[0].shape); + int top_face = -1; + for (int i = 0; i < n_faces; ++i) { + TopoDS_Face fc = GeometryEngine::face_by_index(doc.bodies[0].shape, i); + Vec3d n = GeometryEngine::face_normal_world(fc); + if (n.z() > 0.9) { top_face = i; break; } + } + REQUIRE(top_face >= 0); + + doc.add_split_by_face(0, 0, top_face, true, true, "SplitRT"); + doc.features.back().cut_offset = -5.0; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(doc.bodies.size() == 2); + + int saved_face_body = doc.features.back().cut_face_body; + int saved_face = doc.features.back().cut_face; + bool saved_upper = doc.features.back().cut_keep_upper; + bool saved_lower = doc.features.back().cut_keep_lower; + size_t saved_nb = doc.bodies.size(); + + std::vector> bboxes; + for (const auto& b : doc.bodies) { + Bnd_Box bb; BRepBndLib::Add(b.shape, bb); + Standard_Real x0, y0, z0, x1, y1, z1; + bb.Get(x0, y0, z0, x1, y1, z1); + bboxes.push_back({Vec3d(x0, y0, z0), Vec3d(x1, y1, z1)}); + } + + auto blob = doc.serialize_recipe(); + REQUIRE_FALSE(blob.empty()); + + CadDocument doc2; + REQUIRE(doc2.deserialize_recipe(blob)); + REQUIRE(doc2.bodies.size() == saved_nb); + REQUIRE(doc2.features.size() == doc.features.size()); + + const auto& f2 = doc2.features.back(); + REQUIRE(f2.cut_face_body == saved_face_body); + REQUIRE(f2.cut_face == saved_face); + REQUIRE(f2.cut_keep_upper == saved_upper); + REQUIRE(f2.cut_keep_lower == saved_lower); + + for (size_t i = 0; i < saved_nb; ++i) { + Bnd_Box bb; BRepBndLib::Add(doc2.bodies[i].shape, bb); + Standard_Real x0, y0, z0, x1, y1, z1; + bb.Get(x0, y0, z0, x1, y1, z1); + REQUIRE_THAT(double(x0), WithinAbs(bboxes[i].first.x(), 1e-6)); + REQUIRE_THAT(double(y0), WithinAbs(bboxes[i].first.y(), 1e-6)); + REQUIRE_THAT(double(z0), WithinAbs(bboxes[i].first.z(), 1e-6)); + REQUIRE_THAT(double(x1), WithinAbs(bboxes[i].second.x(), 1e-6)); + REQUIRE_THAT(double(y1), WithinAbs(bboxes[i].second.y(), 1e-6)); + REQUIRE_THAT(double(z1), WithinAbs(bboxes[i].second.z(), 1e-6)); + } +} + TEST_CASE("mirror reflects a body about a plane", "[CadDocument]") { using Catch::Matchers::WithinRel; @@ -3101,6 +3255,8 @@ static CadDocument make_golden_doc_v1() doc.add_thicken(0, 0, 1.75, true, "GoldenThicken"); + doc.add_split_by_face(0, 0, 2, true, false, "GoldenSplit"); + return doc; } @@ -3381,6 +3537,14 @@ TEST_CASE("golden recipe v1 still deserialises", "[CadDocument]") REQUIRE_THAT(f.thicken_thickness, WithinAbs(1.75, 1e-9)); REQUIRE(f.thicken_flip == true); } + + // Cut-by-face: GoldenSplit uses cut_face_body/cut_face instead of plane + if (f.type == CadFeatureType::Cut && e.name == "GoldenSplit") { + REQUIRE(f.cut_face_body == 0); + REQUIRE(f.cut_face == 2); + REQUIRE(f.cut_keep_upper == true); + REQUIRE(f.cut_keep_lower == false); + } } // --- Layer 2: geometry check (optional — only if the document recomputes) ---