diff --git a/src/libslic3r/CadDocument.cpp b/src/libslic3r/CadDocument.cpp index fecb31cca1..bd8d57eda8 100644 --- a/src/libslic3r/CadDocument.cpp +++ b/src/libslic3r/CadDocument.cpp @@ -31,6 +31,7 @@ #include #include #include +#include #include // multi-body: compound of bodies for display/compat #include #include // outward-normal orientation for face-extrude @@ -753,6 +754,20 @@ int CadDocument::add_transform(int target_body, const Vec3d& translate, const Ve return int(features.size()) - 1; } +int CadDocument::add_thicken(int target_body, int face, double thickness, bool flip, + const std::string& name) +{ + CadFeature f; + f.type = CadFeatureType::Thicken; + f.name = name; + f.target_body = target_body; + f.thicken_face = face; + f.thicken_thickness = thickness; + f.thicken_flip = flip; + features.push_back(f); + return int(features.size()) - 1; +} + int CadDocument::add_plane(int base, double offset, double angle_tilt, int axis, const std::string& name) { @@ -2018,6 +2033,41 @@ void CadDocument::apply_transform(std::vector& bodies, const CadFeature bodies[tgt].shape = moved; } +void CadDocument::apply_thicken(std::vector& bodies, const CadFeature& f) const +{ + const int nb = int(bodies.size()); + if (nb == 0) throw std::runtime_error("thicken: no target body"); + const int tgt = (f.target_body >= 0 && f.target_body < nb) ? f.target_body : nb - 1; + if (tgt < 0 || bodies[tgt].shape.IsNull()) throw std::runtime_error("thicken: no target body"); + + TopoDS_Face fc = GeometryEngine::face_by_index(bodies[tgt].shape, f.thicken_face); + if (fc.IsNull()) throw std::runtime_error("thicken: face not found"); + if (std::abs(f.thicken_thickness) < 1e-9) throw std::runtime_error("thicken: thickness is zero"); + + TopoDS_Shell shell; + BRep_Builder bb; + bb.MakeShell(shell); + bb.Add(shell, fc); + + const double off = f.thicken_flip ? -std::abs(f.thicken_thickness) + : std::abs(f.thicken_thickness); + BRepOffsetAPI_MakeThickSolid mts; + mts.MakeThickSolidBySimple(shell, off); + mts.Build(); + if (!mts.IsDone()) throw std::runtime_error("thicken: failed"); + TopoDS_Shape solid = mts.Shape(); + if (solid.IsNull()) throw std::runtime_error("thicken: produced no geometry"); + + // MakeThickSolidBySimple may produce a reversed solid. Ensure positive volume. + { + GProp_GProps props; + BRepGProp::VolumeProperties(solid, props); + if (props.Mass() < 0.0) solid.Reverse(); + } + + bodies.push_back({solid, f.name.empty() ? std::string("Thicken") : f.name}); +} + void CadDocument::route_feature(std::vector& bodies, const CadFeature& f) const { if (f.type == CadFeatureType::Plane) return; // datum plane: not part of the body pipeline @@ -2028,6 +2078,7 @@ void CadDocument::route_feature(std::vector& bodies, const CadFeature& if (f.type == CadFeatureType::Cut) { apply_cut(bodies, f); return; } // plane-split body if (f.type == CadFeatureType::Mirror) { apply_mirror(bodies, f); return; } // mirror body about plane if (f.type == CadFeatureType::Transform) { apply_transform(bodies, f); return; } // move/rotate body + if (f.type == CadFeatureType::Thicken) { apply_thicken(bodies, f); return; } // face -> plate // Resolve the target body: explicit target_body when valid, else the last body. const int t = (f.target_body >= 0 && f.target_body < int(bodies.size())) ? f.target_body : int(bodies.size()) - 1; diff --git a/src/libslic3r/CadDocument.hpp b/src/libslic3r/CadDocument.hpp index 47590622c6..d209cba6b2 100644 --- a/src/libslic3r/CadDocument.hpp +++ b/src/libslic3r/CadDocument.hpp @@ -17,7 +17,7 @@ namespace Slic3r { -enum class CadFeatureType { Sketch, Extrude, Fillet, Chamfer, Hole, Thread, Shell, Revolve, Sweep, Pattern, Plane, Loft, Draft, Import, Boolean, Cut, Mirror, Axis, CoordSys, Helix, Transform }; +enum class CadFeatureType { Sketch, Extrude, Fillet, Chamfer, Hole, Thread, Shell, Revolve, Sweep, Pattern, Plane, Loft, Draft, Import, Boolean, Cut, Mirror, Axis, CoordSys, Helix, Transform, Thicken }; enum class SketchShape { Rectangle, Circle }; enum class PlaneType { Offset, Angle, Midplane, Tangent, TwoEdges, Coincident }; enum class AxisType { TwoPoints, FaceNormal, CylinderCenterline, PlaneIntersection, AlongEdge }; @@ -246,6 +246,12 @@ struct CadFeature { double xf_angle_deg{0}; bool xf_copy{false}; // true: keep the original, append the moved copy as a new body + // Thicken feature: offset one face of an existing body into a new thin solid body. + // The face belongs to `target_body`; the offset runs along the face normal. + int thicken_face{-1}; // global face id on the target body; -1 = invalid + double thicken_thickness{2}; // wall thickness (always used as |value|) + bool thicken_flip{false}; // true: offset against the face normal + template void save(Archive& ar) const { std::string brep = (type == CadFeatureType::Import) ? brep_to_string(imported_solid) : std::string(); @@ -272,7 +278,8 @@ 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); + xf_translate, xf_axis, xf_pivot, xf_angle_deg, xf_copy, + thicken_face, thicken_thickness, thicken_flip); } template void load(Archive& ar) { @@ -300,7 +307,8 @@ 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); + xf_translate, xf_axis, xf_pivot, xf_angle_deg, xf_copy, + thicken_face, thicken_thickness, thicken_flip); imported_solid = brep_from_string(brep); } }; @@ -406,6 +414,9 @@ public: // copy=true keeps the source body and appends the transformed one as a new body. int add_transform(int target_body, const Vec3d& translate, const Vec3d& axis, const Vec3d& pivot, double angle_deg, bool copy, const std::string& name); + // Offset face `face` of `target_body` by `thickness` along its normal, producing a new + // thin solid appended as a new body. flip=true offsets against the normal. + int add_thicken(int target_body, int face, double thickness, bool flip, const std::string& name); // Datum plane: derived from base (0=XY/1=XZ/2=YZ/3+N=Nth earlier datum), offset // along its normal, optional tilt about a base axis. Produces no solid. int add_plane(int base, double offset, double angle_tilt, int axis, @@ -513,6 +524,7 @@ private: void apply_cut(std::vector& bodies, const CadFeature& f) const; void apply_mirror(std::vector& bodies, const CadFeature& f) const; void apply_transform(std::vector& bodies, const CadFeature& f) const; + void apply_thicken(std::vector& bodies, const CadFeature& f) const; // Undo/redo stacks of feature-list snapshots. checkpoint() pushes onto m_undo and // clears m_redo; undo()/redo() shuffle the current state between them. Capped so a diff --git a/src/slic3r/GUI/McpControl.cpp b/src/slic3r/GUI/McpControl.cpp index 8d14f0d1fb..9b6b010c69 100644 --- a/src/slic3r/GUI/McpControl.cpp +++ b/src/slic3r/GUI/McpControl.cpp @@ -75,6 +75,7 @@ const char* feature_type_name(CadFeatureType t) case CadFeatureType::CoordSys: return "CoordSys"; case CadFeatureType::Helix: return "Helix"; case CadFeatureType::Transform: return "Transform"; + case CadFeatureType::Thicken: return "Thicken"; } return "Unknown"; } @@ -232,6 +233,13 @@ json describe_tools() json{{"name", "taper_deg"}, {"type", "number"}, {"unit", "deg"}, {"default", 0}}, json{{"name", "plane"}, {"type", "string"}, {"enum", json::array({"XY", "XZ", "YZ"})}, {"default", "XY"}}, })}}, + json{{"name", "thicken"}, {"summary", "Offset a face of a body by a wall thickness, producing a new thin solid body."}, + {"params", json::array({ + json{{"name", "body"}, {"type", "integer"}, {"default", -1}, {"description", "target body; omit for the last body"}}, + json{{"name", "face"}, {"type", "integer"}, {"description", "face id to thicken (query_topology)"}}, + json{{"name", "thickness"}, {"type", "number"}, {"unit", "mm"}, {"default", 2}, {"min", 0.01}}, + json{{"name", "flip"}, {"type", "boolean"}, {"default", false}}, + })}}, 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}}, @@ -881,6 +889,23 @@ json action_transform(DesignPanel* panel, const json& params) return json{{"ok", ok}, {"transform_index", idx}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}}; } +json action_thicken(DesignPanel* panel, const json& params) +{ + if (!params.contains("face")) throw std::runtime_error("thicken needs 'face' (id from query_topology)"); + CadDocument& doc = panel->mcp_doc(); + if (doc.bodies.empty()) throw std::runtime_error("no body to thicken"); + int bi = target_body_arg(params, doc); + int face = params["face"].get(); + double thickness = params.value("thickness", 2.0); + bool flip = params.value("flip", false); + doc.checkpoint(); + int idx = doc.add_thicken(bi, face, thickness, flip, "Thicken"); + bool ok = doc.recompute(); + if (!ok) doc.undo(); + panel->mcp_after_change(); + return json{{"ok", ok}, {"thicken_index", idx}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}}; +} + json action_axis(DesignPanel* panel, const json& params) { CadDocument& doc = panel->mcp_doc(); @@ -980,6 +1005,7 @@ std::string handle_on_main(const std::string& method, const json& params, const if (method == "draft") return rpc_result(id, action_draft(panel, params)); 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 == "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 d44a8b4554..288bbdf1b1 100644 Binary files a/tests/data/cad_recipe_v2.bin and b/tests/data/cad_recipe_v2.bin differ diff --git a/tests/libslic3r/test_caddocument.cpp b/tests/libslic3r/test_caddocument.cpp index 176facd06e..609da0c869 100644 --- a/tests/libslic3r/test_caddocument.cpp +++ b/tests/libslic3r/test_caddocument.cpp @@ -2715,6 +2715,231 @@ TEST_CASE("transform round-trip preserves all xf_* fields", "[CadDocument]") } } +// --- Thicken tests --- + +TEST_CASE("thicken a planar face to a plate", "[CadDocument]") +{ + using Catch::Matchers::WithinRel; + + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxSketch"); + REQUIRE(sk >= 0); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude1"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + 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_thicken(0, top_face, 3.0, false, "Plate"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(doc.bodies.size() == 2); + + double v = double(SketchEngine::tessellate(doc.bodies[1].shape).volume()); + REQUIRE_THAT(v, WithinRel(20.0 * 20.0 * 3.0, 0.01)); +} + +TEST_CASE("thicken flip offsets against face normal", "[CadDocument]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxSketch"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude1"); + 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); + + // non-flipped: plate grows above the box (z > 10) + CadDocument doc2; + int sk2 = doc2.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxSketch2"); + doc2.add_extrude(sk2, 10.0, false, BooleanMode::New, "Extrude2"); + REQUIRE(doc2.recompute()); + int nf2 = GeometryEngine::face_count(doc2.bodies[0].shape); + int tf2 = -1; + for (int i = 0; i < nf2; ++i) { + TopoDS_Face fc = GeometryEngine::face_by_index(doc2.bodies[0].shape, i); + Vec3d n = GeometryEngine::face_normal_world(fc); + if (n.z() > 0.9) { tf2 = i; break; } + } + REQUIRE(tf2 >= 0); + doc2.add_thicken(0, tf2, 3.0, false, "PlateFwd"); + REQUIRE(doc2.recompute()); + Bnd_Box bb_fwd; BRepBndLib::Add(doc2.bodies[1].shape, bb_fwd); + Standard_Real x0, y0, z0, x1, y1, z1; + bb_fwd.Get(x0, y0, z0, x1, y1, z1); + + // flipped: plate grows below the face plane (z < 10) + CadDocument doc3; + int sk3 = doc3.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxSketch3"); + doc3.add_extrude(sk3, 10.0, false, BooleanMode::New, "Extrude3"); + REQUIRE(doc3.recompute()); + int nf3 = GeometryEngine::face_count(doc3.bodies[0].shape); + int tf3 = -1; + for (int i = 0; i < nf3; ++i) { + TopoDS_Face fc = GeometryEngine::face_by_index(doc3.bodies[0].shape, i); + Vec3d n = GeometryEngine::face_normal_world(fc); + if (n.z() > 0.9) { tf3 = i; break; } + } + REQUIRE(tf3 >= 0); + doc3.add_thicken(0, tf3, 3.0, true, "PlateRev"); + REQUIRE(doc3.recompute()); + Bnd_Box bb_rev; BRepBndLib::Add(doc3.bodies[1].shape, bb_rev); + Standard_Real rx0, ry0, rz0, rx1, ry1, rz1; + bb_rev.Get(rx0, ry0, rz0, rx1, ry1, rz1); + + // forward plate bbox z > 10 (source face at z=10, +3 offset = z in (10,13)) + REQUIRE(z0 >= 9.9); + // reverse plate bbox z < 10 (source face at z=10, -3 offset = z in (7,10)) + REQUIRE(rz1 <= 10.1); +} + +TEST_CASE("thicken bad face index returns error", "[CadDocument]") +{ + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "Box"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Ext"); + REQUIRE(doc.recompute()); + + doc.add_thicken(0, 9999, 3.0, false, "Bad"); + bool ok = doc.recompute(); + REQUIRE_FALSE(ok); + REQUIRE(doc.error.find("face") != std::string::npos); +} + +TEST_CASE("thicken zero thickness returns error", "[CadDocument]") +{ + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "Box"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Ext"); + 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_thicken(0, top_face, 0.0, false, "Zero"); + bool ok = doc.recompute(); + REQUIRE_FALSE(ok); + REQUIRE(doc.error.find("thickness") != std::string::npos); +} + +TEST_CASE("thickened plate fuses with source", "[CadDocument]") +{ + using Catch::Matchers::WithinRel; + + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "Box"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Ext"); + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 1); + + double v_box = 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_thicken(0, top_face, 3.0, false, "Plate"); + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 2); + + doc.add_boolean(BooleanMode::Add, 0, 1, false, 0.0, -1, -1, "Fuse"); + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 1); + + double v_fused = double(SketchEngine::tessellate(doc.bodies[0].shape).volume()); + REQUIRE(v_fused > v_box); +} + +TEST_CASE("thicken round-trip serialization", "[CadDocument]") +{ + using Catch::Matchers::WithinAbs; + using Catch::Matchers::WithinRel; + + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "Box"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Ext"); + REQUIRE(doc.recompute()); + 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_thicken(0, top_face, 3.0, false, "Plate"); + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 2); + + // Remember field values + int tf = doc.features.back().thicken_face; + double tt = doc.features.back().thicken_thickness; + bool tb = doc.features.back().thicken_flip; + size_t 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)}); + } + + std::string blob = doc.serialize_recipe(); + REQUIRE_FALSE(blob.empty()); + + CadDocument doc2; + REQUIRE(doc2.deserialize_recipe(blob)); + REQUIRE(doc2.features.size() == doc.features.size()); + REQUIRE(doc2.bodies.size() == nb); + + const CadFeature& f2 = doc2.features.back(); + REQUIRE(f2.thicken_face == tf); + REQUIRE_THAT(f2.thicken_thickness, WithinAbs(tt, 1e-9)); + REQUIRE(f2.thicken_flip == tb); + + for (size_t i = 0; i < 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)); + } +} + // --- Golden recipe fixture (v1 format tripwire) --- static CadDocument make_golden_doc_v1() @@ -2874,6 +3099,8 @@ static CadDocument make_golden_doc_v1() doc.add_transform(0, Vec3d(3.5, 4.5, 5.5), Vec3d(0.0, 1.0, 0.0), Vec3d(1.5, 2.5, 3.5), 37.0, true, "GoldenTransform"); + doc.add_thicken(0, 0, 1.75, true, "GoldenThicken"); + return doc; } @@ -3147,6 +3374,13 @@ TEST_CASE("golden recipe v1 still deserialises", "[CadDocument]") REQUIRE_THAT(f.xf_angle_deg, WithinAbs(37.0, 1e-9)); REQUIRE(f.xf_copy == true); } + + // Thicken + if (f.type == CadFeatureType::Thicken && e.name == "GoldenThicken") { + REQUIRE(f.thicken_face == 0); + REQUIRE_THAT(f.thicken_thickness, WithinAbs(1.75, 1e-9)); + REQUIRE(f.thicken_flip == true); + } } // --- Layer 2: geometry check (optional — only if the document recomputes) ---