Sketch: a profile may hold more than one closed loop — a plate with a hole extrudes

entities_to_wire handled exactly two shapes of sketch: one lone Circle/Ellipse, or
any number of Line/Arc/EllipseArc/BSpline pushed into a single MakeWire. Everything
else fell off the end as a null wire, so a circle drawn inside a rectangle — the
most ordinary thing in this whole program — refused with "not supported yet". Two
separate closed polygons were quietly worse: both went into one MakeWire, which
does not mean "two loops" to OCCT.

entities_to_wires now returns one wire per loop. A Circle or Ellipse is a loop on
its own; chain entities are grouped by shared endpoints (union-find, 1e-6 in sketch
coordinates), and an open chain still comes back as a wire because a sweep path is
legitimately open. It is all-or-nothing: one loop that fails to build poisons the
whole result, because a partial profile would extrude a shape the user did not draw
— the failure 2e6a8f9e91 was written to stop.

entities_to_wire survives as a two-line wrapper returning the single wire when
there is exactly one loop and a null wire otherwise, so all nine of its call sites
keep their exact contract and Revolve/Sweep/Loft/Surface* are untouched. What a
holed profile means for each of those is a separate question.

wires_to_face takes the largest-area loop as the outer boundary and adds the rest
reversed, which is how OCCT is told a wire is a hole. Containment is CHECKED with
BRepClass_FaceClassifier, not assumed: a loop outside the largest one is a second
island, and one sketch producing several solids is a much bigger feature, so it is
refused by name ("two disjoint regions") rather than guessed at.

Only the Extrude case consumes the new face. Tapered extrudes of a holed profile
are refused — offsetting inner loops has to go the opposite way — and the guard
counts wires on the face already built rather than rebuilding every wire to ask how
many there are, which is also the more honest test: what matters is the profile
being extruded.

Tests: six new [CadDocument][sketchwire] cases, proved by VOLUME rather than by not
throwing — plate-with-hole, two holes, and two regression guards that a lone circle
and a lone polygon extrude exactly as before. Suite 177 cases / 2366 assertions.
No serialized field, recipe version untouched, golden fixtures unchanged.

snaporca-88v.
This commit is contained in:
Tommaso Bianchi
2026-08-13 08:49:18 +02:00
parent ad8b5a73fe
commit 7245415af7
5 changed files with 397 additions and 88 deletions
+36 -12
View File
@@ -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<TopoDS_Wire> 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;
}();
+4
View File
@@ -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
+222 -73
View File
@@ -7,6 +7,10 @@
#include <BRepBuilderAPI_MakeWire.hxx>
#include <BRepBuilderAPI_MakeEdge.hxx>
#include <BRepBuilderAPI_MakeFace.hxx>
#include <BRepGProp.hxx>
#include <GProp_GProps.hxx>
#include <BRepClass_FaceClassifier.hxx>
#include <TopoDS_Vertex.hxx>
#include <GC_MakeArcOfCircle.hxx>
#include <GC_MakeArcOfEllipse.hxx>
#include <Geom_TrimmedCurve.hxx>
@@ -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<SketchEntity>& entities,
const SketchPlane& plane)
std::vector<TopoDS_Wire> SketchEngine::entities_to_wires(const std::vector<SketchEntity>& entities,
const SketchPlane& plane)
{
std::vector<const SketchEntity*> valid;
for (const auto& e : entities) {
struct Item { const SketchEntity* e; size_t idx; };
std::vector<Item> 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<SketchEntity>& 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<int> 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<size_t> 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<int, std::vector<size_t>> 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<size_t> members; };
std::vector<Loop> 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<size_t>::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<TopoDS_Wire> 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<SketchEntity>& entities,
const SketchPlane& plane)
{
const std::vector<TopoDS_Wire> w = entities_to_wires(entities, plane);
return w.size() == 1 ? w[0] : TopoDS_Wire{};
}
TopoDS_Face SketchEngine::wires_to_face(const std::vector<TopoDS_Wire>& 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<TopoDS_Face> faces;
faces.reserve(wires.size());
std::vector<double> 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<SketchEntity> SketchEngine::mirror_entities(
+18
View File
@@ -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<SketchEntity>& 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<TopoDS_Wire> entities_to_wires(const std::vector<SketchEntity>& 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<TopoDS_Wire>& wires,
const SketchPlane& plane);
static std::vector<SketchEntity> mirror_entities(
const std::vector<SketchEntity>& src, const Vec2d& a, const Vec2d& b);
+117 -3
View File
@@ -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<SketchEntity> 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<SketchEntity>& 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<SketchEntity> 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<SketchEntity> 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<SketchEntity> 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<SketchEntity> 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<SketchEntity> 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<SketchEntity> ents = rect_entities(40, 30);
ents.push_back(circle_entity({0, 0}, 5.0));
const std::vector<TopoDS_Wire> 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