diff --git a/src/libslic3r/CadDocument.cpp b/src/libslic3r/CadDocument.cpp index 8a348c1213..422616d922 100644 --- a/src/libslic3r/CadDocument.cpp +++ b/src/libslic3r/CadDocument.cpp @@ -2064,6 +2064,17 @@ TopoDS_Wire CadDocument::build_sketch_wire(const CadFeature& sketch) const return prof.to_occt_wire(sketch.plane); } +TopoDS_Face CadDocument::build_sketch_face(const CadFeature& sketch) const +{ + if (!sketch.entities.empty()) { + const std::vector loops = SketchEngine::entities_to_wires(sketch.entities, sketch.plane); + if (loops.empty()) + throw std::runtime_error("sketch entities do not form a closed loop"); + return SketchEngine::wires_to_face(loops, sketch.plane); + } + return BRepBuilderAPI_MakeFace(build_sketch_wire(sketch)).Face(); +} + void CadDocument::apply_feature(TopoDS_Shape& result, bool& have_body, const TopoDS_Shape& context, const CadFeature& f) const { @@ -2109,8 +2120,8 @@ void CadDocument::apply_feature(TopoDS_Shape& result, bool& have_body, || features[f.sketch_ref].type == CadFeatureType::Project)) ? features[f.sketch_ref] : f; // Imported rigid art (Text/SVG) extrudes via the faces-with-holes path - // (with its placement transform applied); otherwise build a single wire - // from entities/profile/shape. + // (with its placement transform applied); otherwise build the sketch's planar + // region (outer loop + holes) from entities/profile/shape. tool = !sk.imported_regions.empty() ? SketchEngine::make_extrude_regions( transform_regions(sk.imported_regions, sk.import_offset, @@ -2119,17 +2130,30 @@ void CadDocument::apply_feature(TopoDS_Shape& result, bool& have_body, f.extrude_end == ExtrudeEnd::ThroughAll ? 1e5 : signed_d, f.extrude_end == ExtrudeEnd::ThroughAll ? true : (sym || f.extrude_end == ExtrudeEnd::TwoSided)) : [&]() { - TopoDS_Wire wire = build_sketch_wire(sk); + const TopoDS_Face profile = build_sketch_face(sk); + // A tapered extrude offsets the profile; a holed profile would have to + // offset its inner loops the opposite way, which is not implemented yet. + auto taper = [&](double L) -> TopoDS_Shape { + // Count the loops off the face we ALREADY built, rather than rebuilding + // every wire from the entities a second time to ask how many there are. + // It is also the more honest test: what matters is whether the profile + // being extruded has holes, not what the entity list could produce. + int nloops = 0; + for (TopExp_Explorer ex(profile, TopAbs_WIRE); ex.More(); ex.Next()) ++nloops; + if (nloops > 1) + throw std::runtime_error("tapered extrude of a sketch with holes is not supported yet"); + return SketchEngine::make_extrude_taper(build_sketch_wire(sk), sk.plane, L, f.taper_deg); + }; TopoDS_Shape t; switch (f.extrude_end) { case ExtrudeEnd::Blind: t = (std::abs(f.taper_deg) > 1e-6) - ? SketchEngine::make_extrude_taper(wire, sk.plane, signed_d, f.taper_deg) - : SketchEngine::make_extrude(wire, sk.plane, signed_d, false); + ? taper(signed_d) + : SketchEngine::make_extrude(profile, sk.plane, signed_d, false); break; - case ExtrudeEnd::Symmetric: t = SketchEngine::make_extrude(wire, sk.plane, f.distance, true); break; - case ExtrudeEnd::TwoSided: t = SketchEngine::make_extrude_two_sided(wire, sk.plane, f.distance, f.distance2); break; - case ExtrudeEnd::ThroughAll: t = SketchEngine::make_extrude(wire, sk.plane, 1.0e5, true); break; + case ExtrudeEnd::Symmetric: t = SketchEngine::make_extrude(profile, sk.plane, f.distance, true); break; + case ExtrudeEnd::TwoSided: t = SketchEngine::make_extrude_two_sided(profile, sk.plane, f.distance, f.distance2); break; + case ExtrudeEnd::ThroughAll: t = SketchEngine::make_extrude(profile, sk.plane, 1.0e5, true); break; case ExtrudeEnd::UpToFace: { const TopoDS_Face tgt = GeometryEngine::face_by_index(context, f.up_to_face); double L = signed_d; @@ -2138,16 +2162,16 @@ void CadDocument::apply_feature(TopoDS_Shape& result, bool& have_body, L = (c - sk.plane.origin).dot(sk.plane.normal); } t = (std::abs(f.taper_deg) > 1e-6) - ? SketchEngine::make_extrude_taper(wire, sk.plane, L, f.taper_deg) - : SketchEngine::make_extrude(wire, sk.plane, L, false); + ? taper(L) + : SketchEngine::make_extrude(profile, sk.plane, L, false); break; } case ExtrudeEnd::UpToVertex: { const double L = (f.up_to_point - sk.plane.origin).dot(sk.plane.normal); - t = SketchEngine::make_extrude(wire, sk.plane, L, false); + t = SketchEngine::make_extrude(profile, sk.plane, L, false); break; } - default: t = SketchEngine::make_extrude(wire, sk.plane, signed_d, false); break; + default: t = SketchEngine::make_extrude(profile, sk.plane, signed_d, false); break; } return t; }(); diff --git a/src/libslic3r/CadDocument.hpp b/src/libslic3r/CadDocument.hpp index 13d112c7c6..14448e0428 100644 --- a/src/libslic3r/CadDocument.hpp +++ b/src/libslic3r/CadDocument.hpp @@ -703,6 +703,10 @@ public: private: TopoDS_Wire build_sketch_wire(const CadFeature& sketch) const; + // The planar region an Extrude sweeps: the sketch's outer loop with its inner loops as + // holes. Falls back to a face over build_sketch_wire() for the legacy profile/shape paths, + // which have no concept of a second loop. + TopoDS_Face build_sketch_face(const CadFeature& sketch) const; // Apply a single feature to (result, have_body), throwing std::runtime_error on // failure. `context` is the body whose faces/edges the feature reads (face-extrude // source, up-to-face target, dress-up, hole) — it differs from `result` only when the diff --git a/src/libslic3r/SketchEngine.cpp b/src/libslic3r/SketchEngine.cpp index 193d7360bf..76a822de23 100644 --- a/src/libslic3r/SketchEngine.cpp +++ b/src/libslic3r/SketchEngine.cpp @@ -7,6 +7,10 @@ #include #include #include +#include +#include +#include +#include #include #include #include @@ -168,12 +172,18 @@ static TopoDS_Shape extrude_face_internal(const TopoDS_Face& face, const gp_Dir& } TopoDS_Shape SketchEngine::make_extrude(const TopoDS_Wire& wire, const SketchPlane& plane, - double length, bool symmetric, double /*taper_deg*/) + double length, bool symmetric, double taper_deg) { BRepBuilderAPI_MakeFace fm(wire); if (!fm.IsDone()) throw std::runtime_error("Failed to make face from wire"); + return make_extrude(fm.Face(), plane, length, symmetric, taper_deg); +} + +TopoDS_Shape SketchEngine::make_extrude(const TopoDS_Face& face, const SketchPlane& plane, + double length, bool symmetric, double /*taper_deg*/) +{ gp_Dir dir(plane.normal.x(), plane.normal.y(), plane.normal.z()); - return extrude_face_internal(fm.Face(), dir, length, symmetric); + return extrude_face_internal(face, dir, length, symmetric); } TopoDS_Shape SketchEngine::make_extrude_two_sided(const TopoDS_Wire& wire, const SketchPlane& plane, @@ -181,13 +191,19 @@ TopoDS_Shape SketchEngine::make_extrude_two_sided(const TopoDS_Wire& wire, const { BRepBuilderAPI_MakeFace fm(wire); if (!fm.IsDone()) throw std::runtime_error("Failed to make face from wire"); + return make_extrude_two_sided(fm.Face(), plane, up, down); +} + +TopoDS_Shape SketchEngine::make_extrude_two_sided(const TopoDS_Face& face, const SketchPlane& plane, + double up, double down) +{ gp_Dir dir(plane.normal.x(), plane.normal.y(), plane.normal.z()); const double u = std::abs(up), d = std::abs(down); if (u < 1e-9 && d < 1e-9) return TopoDS_Shape(); - if (d < 1e-9) { BRepPrimAPI_MakePrism p(fm.Face(), gp_Vec(dir) * u); return p.Shape(); } - if (u < 1e-9) { BRepPrimAPI_MakePrism p(fm.Face(), gp_Vec(dir) * -d); return p.Shape(); } - BRepPrimAPI_MakePrism pos(fm.Face(), gp_Vec(dir) * u); - BRepPrimAPI_MakePrism neg(fm.Face(), gp_Vec(dir) * -d); + if (d < 1e-9) { BRepPrimAPI_MakePrism p(face, gp_Vec(dir) * u); return p.Shape(); } + if (u < 1e-9) { BRepPrimAPI_MakePrism p(face, gp_Vec(dir) * -d); return p.Shape(); } + BRepPrimAPI_MakePrism pos(face, gp_Vec(dir) * u); + BRepPrimAPI_MakePrism neg(face, gp_Vec(dir) * -d); BRepAlgoAPI_Fuse fuse(pos.Shape(), neg.Shape()); if (!fuse.IsDone()) throw std::runtime_error("two-sided extrude fuse failed"); return fuse.Shape(); @@ -514,16 +530,19 @@ TriangleMesh SketchEngine::tessellate(const TopoDS_Shape& shape, return TriangleMesh(std::move(its)); } -TopoDS_Wire SketchEngine::entities_to_wire(const std::vector& entities, - const SketchPlane& plane) +std::vector SketchEngine::entities_to_wires(const std::vector& entities, + const SketchPlane& plane) { - std::vector valid; - for (const auto& e : entities) { + struct Item { const SketchEntity* e; size_t idx; }; + std::vector valid; + valid.reserve(entities.size()); + for (size_t i = 0; i < entities.size(); ++i) { + const SketchEntity& e = entities[i]; if (e.construction) continue; if (e.type == SketchEntity::Type::Point) continue; - valid.push_back(&e); + valid.push_back({&e, i}); } - if (valid.empty()) return TopoDS_Wire{}; + if (valid.empty()) return {}; // Build an OCCT ellipse (gp_Elips) in the sketch plane from an Ellipse(Arc) // entity. Major-axis direction = plane-rotated (cos phi, sin phi). Enforces @@ -562,75 +581,205 @@ TopoDS_Wire SketchEngine::entities_to_wire(const std::vector& enti return new Geom_BSplineCurve(poles, knots, mults, p); }; - bool has_closed_single = false; // Circle or full Ellipse (stand-alone closed) - bool has_chain = false; // Line / Arc / EllipseArc - for (const auto* e : valid) { - if (e->type == SketchEntity::Type::Circle || e->type == SketchEntity::Type::Ellipse) - has_closed_single = true; - else - has_chain = true; - } + auto is_chain = [](const SketchEntity& e) { + return e.type == SketchEntity::Type::Line || e.type == SketchEntity::Type::Arc || + e.type == SketchEntity::Type::EllipseArc || e.type == SketchEntity::Type::BSpline; + }; - // Case 1: exactly one closed entity (Circle or Ellipse) and nothing else - if (has_closed_single && !has_chain && valid.size() == 1) { - const SketchEntity& c = *valid[0]; - TopoDS_Edge e; - if (c.type == SketchEntity::Type::Ellipse) { - if (c.radius <= 1e-9 || c.rminor <= 1e-9) return TopoDS_Wire{}; - e = BRepBuilderAPI_MakeEdge(make_elips(c)).Edge(); - } else { - Vec3d c3 = plane.to_world(c.center); - gp_Pnt center(c3.x(), c3.y(), c3.z()); - gp_Dir n(plane.normal.x(), plane.normal.y(), plane.normal.z()); - gp_Circ circ(gp_Ax2(center, n), c.radius); - e = BRepBuilderAPI_MakeEdge(circ).Edge(); + // Endpoints of a chain entity in SKETCH coordinates (before to_world). False on a + // degenerate (fewer than two control points) BSpline, which can never close a loop. + auto endpoints = [&](const SketchEntity& e, Vec2d& a, Vec2d& b) -> bool { + if (e.type == SketchEntity::Type::BSpline) { + if (e.ctrl.size() < 2) return false; + a = e.ctrl.front(); b = e.ctrl.back(); + return true; + } + a = e.p0; b = e.p1; + return true; + }; + + const double EPS = 1e-6; + auto same = [&](const Vec2d& p, const Vec2d& q) { return (p - q).norm() < EPS; }; + + // Union-find over the valid index list: chain entities sharing an endpoint belong to one loop. + std::vector parent(valid.size()); + for (size_t i = 0; i < valid.size(); ++i) parent[i] = int(i); + auto find = [&](int x) { + while (parent[x] != x) { parent[x] = parent[parent[x]]; x = parent[x]; } + return x; + }; + auto unite = [&](int x, int y) { + int rx = find(x), ry = find(y); + if (rx != ry) parent[rx] = ry; + }; + + std::vector chain_idx; + chain_idx.reserve(valid.size()); + for (size_t i = 0; i < valid.size(); ++i) + if (is_chain(*valid[i].e)) chain_idx.push_back(i); + + for (size_t a = 0; a < chain_idx.size(); ++a) { + const size_t i = chain_idx[a]; + Vec2d i0, i1; + if (!endpoints(*valid[i].e, i0, i1)) continue; + for (size_t b = a + 1; b < chain_idx.size(); ++b) { + const size_t j = chain_idx[b]; + Vec2d j0, j1; + if (!endpoints(*valid[j].e, j0, j1)) continue; + if (same(i0, j0) || same(i0, j1) || same(i1, j0) || same(i1, j1)) + unite(int(i), int(j)); } - BRepBuilderAPI_MakeWire wm(e); - if (!wm.IsDone()) return TopoDS_Wire{}; - return wm.Wire(); } - // Case 2: closed chain of Line/Arc/EllipseArc entities (no closed-single) - if (!has_closed_single && has_chain) { - BRepBuilderAPI_MakeWire builder; - for (const SketchEntity* e : valid) { - if (e->type == SketchEntity::Type::Line) { - Vec3d p0 = plane.to_world(e->p0); - Vec3d p1 = plane.to_world(e->p1); - gp_Pnt pa(p0.x(), p0.y(), p0.z()); - gp_Pnt pb(p1.x(), p1.y(), p1.z()); - builder.Add(BRepBuilderAPI_MakeEdge(pa, pb).Edge()); - } else if (e->type == SketchEntity::Type::EllipseArc) { - if (e->radius <= 1e-9 || e->rminor <= 1e-9) return TopoDS_Wire{}; - GC_MakeArcOfEllipse arc_maker(make_elips(*e), e->start_angle, e->end_angle, Standard_True); - if (!arc_maker.IsDone()) return TopoDS_Wire{}; - builder.Add(BRepBuilderAPI_MakeEdge(arc_maker.Value()).Edge()); - } else if (e->type == SketchEntity::Type::Arc) { - Vec3d p0 = plane.to_world(e->p0); - Vec3d p1 = plane.to_world(e->p1); - double mid_angle = (e->start_angle + e->end_angle) * 0.5; - Vec2d mid_2d(e->center.x() + e->radius * std::cos(mid_angle), - e->center.y() + e->radius * std::sin(mid_angle)); - Vec3d mid_3d = plane.to_world(mid_2d); - gp_Pnt pa(p0.x(), p0.y(), p0.z()); - gp_Pnt pm(mid_3d.x(), mid_3d.y(), mid_3d.z()); - gp_Pnt pb(p1.x(), p1.y(), p1.z()); - GC_MakeArcOfCircle arc_maker(pa, pm, pb); - if (!arc_maker.IsDone()) return TopoDS_Wire{}; - Handle(Geom_TrimmedCurve) curve = arc_maker.Value(); - builder.Add(BRepBuilderAPI_MakeEdge(curve).Edge()); - } else if (e->type == SketchEntity::Type::BSpline) { - Handle(Geom_BSplineCurve) crv = make_bspline(*e); - if (crv.IsNull()) return TopoDS_Wire{}; - builder.Add(BRepBuilderAPI_MakeEdge(crv).Edge()); + // Group chain entities by connected-component root. + std::map> comps; + for (size_t i = 0; i < valid.size(); ++i) { + if (!is_chain(*valid[i].e)) continue; + comps[find(int(i))].push_back(i); + } + + // Loop descriptors: each Circle/Ellipse is its own loop; each chain component is a loop. + struct Loop { size_t min_idx{0}; bool closed_single{false}; size_t member{0}; std::vector members; }; + std::vector loops; + for (size_t i = 0; i < valid.size(); ++i) { + const SketchEntity& e = *valid[i].e; + if (e.type == SketchEntity::Type::Circle || e.type == SketchEntity::Type::Ellipse) { + Loop l; l.min_idx = valid[i].idx; l.closed_single = true; l.member = i; + loops.push_back(l); + } + } + for (const auto& kv : comps) { + Loop l; l.closed_single = false; l.members = kv.second; + size_t mn = std::numeric_limits::max(); + for (size_t m : kv.second) mn = std::min(mn, valid[m].idx); + l.min_idx = mn; + loops.push_back(l); + } + // Deterministic order: by the index of each loop's first entity. + std::sort(loops.begin(), loops.end(), [](const Loop& x, const Loop& y) { return x.min_idx < y.min_idx; }); + + // Build each loop. All-or-nothing: one failed loop poisons the whole result. + std::vector out; + out.reserve(loops.size()); + for (const Loop& loop : loops) { + BRepBuilderAPI_MakeWire wm; + if (loop.closed_single) { + const SketchEntity& c = *valid[loop.member].e; + TopoDS_Edge e; + if (c.type == SketchEntity::Type::Ellipse) { + if (c.radius <= 1e-9 || c.rminor <= 1e-9) return {}; + e = BRepBuilderAPI_MakeEdge(make_elips(c)).Edge(); + } else { + Vec3d c3 = plane.to_world(c.center); + gp_Pnt center(c3.x(), c3.y(), c3.z()); + gp_Dir n(plane.normal.x(), plane.normal.y(), plane.normal.z()); + gp_Circ circ(gp_Ax2(center, n), c.radius); + e = BRepBuilderAPI_MakeEdge(circ).Edge(); + } + wm.Add(e); + } else { + for (size_t m : loop.members) { + const SketchEntity* e = valid[m].e; + if (e->type == SketchEntity::Type::Line) { + Vec3d p0 = plane.to_world(e->p0); + Vec3d p1 = plane.to_world(e->p1); + gp_Pnt pa(p0.x(), p0.y(), p0.z()); + gp_Pnt pb(p1.x(), p1.y(), p1.z()); + wm.Add(BRepBuilderAPI_MakeEdge(pa, pb).Edge()); + } else if (e->type == SketchEntity::Type::EllipseArc) { + if (e->radius <= 1e-9 || e->rminor <= 1e-9) return {}; + GC_MakeArcOfEllipse arc_maker(make_elips(*e), e->start_angle, e->end_angle, Standard_True); + if (!arc_maker.IsDone()) return {}; + wm.Add(BRepBuilderAPI_MakeEdge(arc_maker.Value()).Edge()); + } else if (e->type == SketchEntity::Type::Arc) { + Vec3d p0 = plane.to_world(e->p0); + Vec3d p1 = plane.to_world(e->p1); + double mid_angle = (e->start_angle + e->end_angle) * 0.5; + Vec2d mid_2d(e->center.x() + e->radius * std::cos(mid_angle), + e->center.y() + e->radius * std::sin(mid_angle)); + Vec3d mid_3d = plane.to_world(mid_2d); + gp_Pnt pa(p0.x(), p0.y(), p0.z()); + gp_Pnt pm(mid_3d.x(), mid_3d.y(), mid_3d.z()); + gp_Pnt pb(p1.x(), p1.y(), p1.z()); + GC_MakeArcOfCircle arc_maker(pa, pm, pb); + if (!arc_maker.IsDone()) return {}; + Handle(Geom_TrimmedCurve) curve = arc_maker.Value(); + wm.Add(BRepBuilderAPI_MakeEdge(curve).Edge()); + } else if (e->type == SketchEntity::Type::BSpline) { + Handle(Geom_BSplineCurve) crv = make_bspline(*e); + if (crv.IsNull()) return {}; + wm.Add(BRepBuilderAPI_MakeEdge(crv).Edge()); + } } } - builder.Build(); - if (!builder.IsDone()) return TopoDS_Wire{}; - return builder.Wire(); + wm.Build(); + if (!wm.IsDone()) return {}; + out.push_back(wm.Wire()); + } + return out; +} + +TopoDS_Wire SketchEngine::entities_to_wire(const std::vector& entities, + const SketchPlane& plane) +{ + const std::vector w = entities_to_wires(entities, plane); + return w.size() == 1 ? w[0] : TopoDS_Wire{}; +} + +TopoDS_Face SketchEngine::wires_to_face(const std::vector& wires, + const SketchPlane& /*plane*/) +{ + if (wires.empty()) throw std::runtime_error("sketch has no closed loop"); + + if (wires.size() == 1) { + BRepBuilderAPI_MakeFace fm(wires[0]); + if (!fm.IsDone()) throw std::runtime_error("sketch loop does not bound a face"); + return fm.Face(); } - return TopoDS_Wire{}; + // Two or more loops: build a face per wire and let the largest area be the outer + // boundary; every other loop is a candidate hole inside it. + std::vector faces; + faces.reserve(wires.size()); + std::vector areas; + areas.reserve(wires.size()); + for (const TopoDS_Wire& w : wires) { + BRepBuilderAPI_MakeFace fm(w); + if (!fm.IsDone()) throw std::runtime_error("sketch loop does not bound a face"); + faces.push_back(fm.Face()); + GProp_GProps props; + BRepGProp::SurfaceProperties(faces.back(), props); + areas.push_back(props.Mass()); + } + + size_t outer = 0; + for (size_t i = 1; i < areas.size(); ++i) + if (areas[i] > areas[outer]) outer = i; + + // Note: NOT MakeFace(faces[outer], wires[outer]) — that constructor copies the outer face + // (including its existing boundary wire) and then adds the wire again, doubling the outer + // boundary. The wire-only constructor starts clean and the reversed holes follow. + BRepBuilderAPI_MakeFace fm(wires[outer]); + for (size_t i = 0; i < wires.size(); ++i) { + if (i == outer) continue; + // Containment is checked, not assumed: a vertex of the inner wire must lie strictly + // inside the outer face. A loop outside the largest one is a second island, not a hole. + gp_Pnt p; + bool got = false; + for (TopExp_Explorer ex(wires[i], TopAbs_VERTEX); ex.More(); ex.Next()) { + p = BRep_Tool::Pnt(TopoDS::Vertex(ex.Current())); + got = true; + break; + } + if (!got) throw std::runtime_error("sketch loop does not bound a face"); + BRepClass_FaceClassifier fc(faces[outer], p, 1e-7); + if (fc.State() != TopAbs_IN) + throw std::runtime_error("sketch has two disjoint regions; put each in its own sketch"); + // A reversed wire tells OCCT this loop is a hole, not a second boundary. + fm.Add(TopoDS::Wire(wires[i].Reversed())); + } + if (!fm.IsDone()) throw std::runtime_error("sketch loop does not bound a face"); + return fm.Face(); } std::vector SketchEngine::mirror_entities( diff --git a/src/libslic3r/SketchEngine.hpp b/src/libslic3r/SketchEngine.hpp index de008f22df..d4f230f348 100644 --- a/src/libslic3r/SketchEngine.hpp +++ b/src/libslic3r/SketchEngine.hpp @@ -155,6 +155,8 @@ class SketchEngine public: static TopoDS_Shape make_extrude(const TopoDS_Wire& wire, const SketchPlane& plane, double length, bool symmetric = false, double taper_deg = 0.0); + static TopoDS_Shape make_extrude(const TopoDS_Face& face, const SketchPlane& plane, + double length, bool symmetric = false, double taper_deg = 0.0); // Asymmetric two-sided prism: extrude the wire's face by `up` along +normal and `down` // along -normal, fused into one solid. up/down are non-negative magnitudes. // Tapered (draft) extrude of a planar wire: the top profile is the base wire offset in its @@ -164,6 +166,8 @@ public: double length, double taper_deg); static TopoDS_Shape make_extrude_two_sided(const TopoDS_Wire& wire, const SketchPlane& plane, double up, double down); + static TopoDS_Shape make_extrude_two_sided(const TopoDS_Face& face, const SketchPlane& plane, + double up, double down); static TopoDS_Shape make_extrude_face(const TopoDS_Face& face, const SketchPlane& plane, double length, bool symmetric = false, double taper_deg = 0.0); @@ -212,6 +216,20 @@ public: static TopoDS_Wire entities_to_wire(const std::vector& entities, const SketchPlane& plane); + // Every closed loop the sketch holds, in the order each loop's FIRST entity appears in + // `entities`. A Circle or Ellipse is a loop on its own; Line/Arc/EllipseArc/BSpline + // entities are grouped into loops by shared endpoints. An OPEN chain is returned too — + // a sweep path is legitimately open, so open-ness is not an error here. + // Empty vector = nothing usable; the caller decides whether that is an error. + static std::vector entities_to_wires(const std::vector& entities, + const SketchPlane& plane); + + // A planar face from a set of coplanar loops: the largest-area loop is the outer boundary + // and every other loop is a hole in it. Throws std::runtime_error with a message naming the + // problem when the loops do not describe one such region. + static TopoDS_Face wires_to_face(const std::vector& wires, + const SketchPlane& plane); + static std::vector mirror_entities( const std::vector& src, const Vec2d& a, const Vec2d& b); diff --git a/tests/libslic3r/test_caddocument.cpp b/tests/libslic3r/test_caddocument.cpp index ddab69db1b..85e59dca15 100644 --- a/tests/libslic3r/test_caddocument.cpp +++ b/tests/libslic3r/test_caddocument.cpp @@ -7057,20 +7057,134 @@ TEST_CASE("An entity sketch that forms no wire fails instead of extruding a defa SketchPlane::XY(), "Sketch"); doc.add_extrude(sk, 5.0, false, BooleanMode::New, "Extrude"); CHECK_FALSE(doc.recompute()); - CHECK(doc.error.find("do not form a single closed wire") != std::string::npos); + CHECK(doc.error.find("does not bound a face") != std::string::npos); CHECK(doc.bodies.empty()); } - SECTION("two circles are rejected too") { + SECTION("two disjoint circles are rejected too") { CadDocument doc; int sk = doc.add_sketch_entities({ circle({-20, 0}, 8.0), circle({20, 0}, 8.0) }, SketchPlane::XY(), "Sketch"); doc.add_extrude(sk, 5.0, false, BooleanMode::New, "Extrude"); CHECK_FALSE(doc.recompute()); - CHECK(doc.error.find("do not form a single closed wire") != std::string::npos); + CHECK(doc.error.find("disjoint") != std::string::npos); } } +namespace { + +std::vector rect_entities(double w, double h) +{ + const double hw = w * 0.5, hh = h * 0.5; + return { + {SketchEntity::Type::Line, Vec2d(-hw, -hh), Vec2d( hw, -hh)}, + {SketchEntity::Type::Line, Vec2d( hw, -hh), Vec2d( hw, hh)}, + {SketchEntity::Type::Line, Vec2d( hw, hh), Vec2d(-hw, hh)}, + {SketchEntity::Type::Line, Vec2d(-hw, hh), Vec2d(-hw, -hh)}, + }; +} + +SketchEntity circle_entity(const Vec2d& c, double r) +{ + SketchEntity e; + e.type = SketchEntity::Type::Circle; + e.center = c; e.p0 = c; e.radius = r; + return e; +} + +CadDocument plate_doc(const std::vector& entities, double distance) +{ + CadDocument doc; + CadFeature sk; + sk.type = CadFeatureType::Sketch; + sk.name = "sketch"; + sk.plane = SketchPlane::XY(); + sk.entities = entities; + doc.features.push_back(sk); + CadFeature ex; + ex.type = CadFeatureType::Extrude; + ex.name = "extrude"; + ex.sketch_ref = 0; + ex.distance = distance; + ex.mode = BooleanMode::New; + doc.features.push_back(ex); + return doc; +} + +} // namespace + +// snaporca-88v: a sketch may hold more than one closed loop. The Extrude path builds the +// sketch's planar region via SketchEngine::entities_to_wires + wires_to_face: the largest loop +// is the outer boundary, every other loop a hole. Volumes are the proof — a plate with a hole +// must subtract the hole, not merely "not throw". +TEST_CASE("a circle inside a rectangle extrudes to a plate with a hole", "[CadDocument][sketchwire]") +{ + std::vector ents = rect_entities(40, 30); + ents.push_back(circle_entity({0, 0}, 5.0)); + CadDocument doc = plate_doc(ents, 10.0); + + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + const double expected = 40.0 * 30.0 * 10.0 - M_PI * 25.0 * 10.0; + REQUIRE_THAT(double(doc.display_mesh.volume()), Catch::Matchers::WithinRel(expected, 0.01)); +} + +TEST_CASE("two holes are both subtracted", "[CadDocument][sketchwire]") +{ + std::vector ents = rect_entities(40, 30); + ents.push_back(circle_entity({ 5, 0}, 3.0)); + ents.push_back(circle_entity({-5, 0}, 3.0)); + CadDocument doc = plate_doc(ents, 10.0); + + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + const double expected = 40.0 * 30.0 * 10.0 - 2.0 * M_PI * 9.0 * 10.0; + REQUIRE_THAT(double(doc.display_mesh.volume()), Catch::Matchers::WithinRel(expected, 0.01)); +} + +TEST_CASE("a lone circle still extrudes exactly as before", "[CadDocument][sketchwire]") +{ + std::vector ents = { circle_entity({0, 0}, 8.0) }; + CadDocument doc = plate_doc(ents, 5.0); + + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + const double expected = M_PI * 64.0 * 5.0; + REQUIRE_THAT(double(doc.display_mesh.volume()), Catch::Matchers::WithinRel(expected, 0.01)); +} + +TEST_CASE("a closed polygon still extrudes exactly as before", "[CadDocument][sketchwire]") +{ + std::vector ents = rect_entities(20, 20); + CadDocument doc = plate_doc(ents, 10.0); + + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + const double expected = 20.0 * 20.0 * 10.0; + REQUIRE_THAT(double(doc.display_mesh.volume()), Catch::Matchers::WithinRel(expected, 0.01)); +} + +TEST_CASE("two disjoint regions are refused, not guessed", "[CadDocument][sketchwire]") +{ + std::vector ents = { circle_entity({-10, 0}, 5.0), circle_entity({10, 0}, 5.0) }; + CadDocument doc = plate_doc(ents, 10.0); + + CHECK_FALSE(doc.recompute()); + CHECK(doc.error.find("disjoint") != std::string::npos); + CHECK(doc.bodies.empty()); +} + +TEST_CASE("entities_to_wires returns one wire per loop", "[CadDocument][sketchwire]") +{ + std::vector ents = rect_entities(40, 30); + ents.push_back(circle_entity({0, 0}, 5.0)); + + const std::vector wires = SketchEngine::entities_to_wires(ents, SketchPlane::XY()); + REQUIRE(wires.size() == 2); + REQUIRE_FALSE(wires[0].IsNull()); + REQUIRE_FALSE(wires[1].IsNull()); +} + // Sketching on a picked face is the most common gesture in solid modelling, and it was impossible: // the plane came from a combo of base + datum planes only, so the sole route onto a face was to // build a Coincident datum plane first. plane_of_face is the shared derivation that makes the