diff --git a/src/libslic3r/CAD/CadDocument.cpp b/src/libslic3r/CAD/CadDocument.cpp index 46f219c17d..7030c6082a 100644 --- a/src/libslic3r/CAD/CadDocument.cpp +++ b/src/libslic3r/CAD/CadDocument.cpp @@ -1238,6 +1238,11 @@ int CadDocument::add_surface_offset(int target_body, double offset, const std::s return int(features.size()) - 1; } +static void project_edges_to_entities(const std::vector& edges, + const SketchPlane& plane, + bool construction, + std::vector& out); + int CadDocument::add_project_edges(int source_body, const std::vector& edge_ids, int face, const SketchPlane& plane, const std::string& name) { @@ -1252,6 +1257,37 @@ int CadDocument::add_project_edges(int source_body, const std::vector& edge return int(features.size()) - 1; } +int CadDocument::project_edges_into_sketch(int sketch_feature, int source_body, + const std::vector& edge_ids, int face) +{ + if (sketch_feature < 0 || sketch_feature >= int(features.size())) return -1; + CadFeature& sk = features[sketch_feature]; + if (sk.type != CadFeatureType::Sketch && sk.type != CadFeatureType::Project) return -1; + if (source_body < 0 || source_body >= int(bodies.size()) + || bodies[source_body].shape.IsNull()) return -1; + const TopoDS_Shape& shape = bodies[source_body].shape; + + std::vector edges; + if (!edge_ids.empty()) { + for (int id : edge_ids) { + TopoDS_Edge e = GeometryEngine::edge_by_index(shape, id); + if (e.IsNull()) return -1; + edges.push_back(e); + } + } else if (face >= 0) { + TopoDS_Face fc = GeometryEngine::face_by_index(shape, face); + if (fc.IsNull()) return -1; + edges = GeometryEngine::edges_of_face(fc); + } else { + edges = GeometryEngine::edges_of(shape); + } + if (edges.empty()) return -1; + + const size_t before = sk.entities.size(); + project_edges_to_entities(edges, sk.plane, /*construction=*/true, sk.entities); + return int(sk.entities.size() - before); +} + int CadDocument::add_bridge(int sketch_ref, int ent_a, int end_a, int ent_b, int end_b, const std::string& name) { @@ -3098,6 +3134,78 @@ void CadDocument::apply_surface_offset(std::vector& bodies, const CadFe bodies.push_back({off_shape, f.name.empty() ? std::string("SurfaceOffset") : f.name}); } +// Project `edges` of a shape onto `plane`, appending the resulting 2D entities to `out`. +// `construction` marks them as guides rather than built geometry. This is the body of the +// Project feature's conversion loop, factored out so a sketch can borrow the same geometry. +static void project_edges_to_entities(const std::vector& edges, + const SketchPlane& plane, + bool construction, + std::vector& out) +{ + auto to2d = [&](const gp_Pnt& p) -> Vec2d { + Vec3d d(p.X() - plane.origin.x(), p.Y() - plane.origin.y(), p.Z() - plane.origin.z()); + return Vec2d(d.dot(plane.x_axis), d.dot(plane.y_axis)); + }; + + // A segment whose endpoints coincide after projection carries no geometry: that is what + // an edge perpendicular to the target plane becomes. Emitting it as a zero-length line + // would poison the sketch downstream, so drop it here. + auto push_line = [&](const Vec2d& a, const Vec2d& b) { + if ((b - a).norm() < 1e-7) return; + SketchEntity se; se.type = SketchEntity::Type::Line; + se.p0 = a; se.p1 = b; + se.construction = construction; + out.push_back(se); + }; + + for (const TopoDS_Edge& e : edges) { + BRepAdaptor_Curve ac(e); + const GeomAbs_CurveType ct = ac.GetType(); + if (ct == GeomAbs_Line) { + gp_Pnt a = ac.Value(ac.FirstParameter()); + gp_Pnt b = ac.Value(ac.LastParameter()); + push_line(to2d(a), to2d(b)); + } else if (ct == GeomAbs_Circle) { + gp_Circ c = ac.Circle(); + gp_Dir cn = c.Axis().Direction(); + Vec3d cnv(cn.X(), cn.Y(), cn.Z()); + const double par = std::abs(cnv.dot(plane.normal)); + const bool full = BRep_Tool::IsClosed(e) || + std::abs((ac.LastParameter() - ac.FirstParameter()) - 2.0 * M_PI) < 1e-6; + if (par > 0.999) { + Vec2d ctr = to2d(c.Location()); + if (full) { + SketchEntity se; se.type = SketchEntity::Type::Circle; + se.center = ctr; se.radius = c.Radius(); + se.construction = construction; + out.push_back(se); + } else { + gp_Pnt a = ac.Value(ac.FirstParameter()); + gp_Pnt b = ac.Value(ac.LastParameter()); + Vec2d a2 = to2d(a), b2 = to2d(b); + SketchEntity se; se.type = SketchEntity::Type::Arc; + se.center = ctr; se.radius = c.Radius(); + se.p0 = a2; se.p1 = b2; + se.start_angle = std::atan2(a2.y() - ctr.y(), a2.x() - ctr.x()); + se.end_angle = std::atan2(b2.y() - ctr.y(), b2.x() - ctr.x()); + se.construction = construction; + out.push_back(se); + } + continue; + } + std::vector pts = GeometryEngine::sample_edge_world(e); + for (size_t i = 1; i < pts.size(); ++i) + push_line(to2d(gp_Pnt(pts[i-1].x(), pts[i-1].y(), pts[i-1].z())), + to2d(gp_Pnt(pts[i].x(), pts[i].y(), pts[i].z()))); + } else { + std::vector pts = GeometryEngine::sample_edge_world(e); + for (size_t i = 1; i < pts.size(); ++i) + push_line(to2d(gp_Pnt(pts[i-1].x(), pts[i-1].y(), pts[i-1].z())), + to2d(gp_Pnt(pts[i].x(), pts[i].y(), pts[i].z()))); + } + } +} + void CadDocument::apply_project(const std::vector& bodies, CadFeature& f) const { f.entities.clear(); @@ -3128,65 +3236,8 @@ void CadDocument::apply_project(const std::vector& bodies, CadFeature& } if (edges.empty()) throw std::runtime_error("project: no edges to project"); - auto to2d = [&](const gp_Pnt& p) -> Vec2d { - Vec3d d(p.X() - f.plane.origin.x(), p.Y() - f.plane.origin.y(), p.Z() - f.plane.origin.z()); - return Vec2d(d.dot(f.plane.x_axis), d.dot(f.plane.y_axis)); - }; + project_edges_to_entities(edges, f.plane, /*construction=*/false, f.entities); - // A segment whose endpoints coincide after projection carries no geometry: that is what - // an edge perpendicular to the target plane becomes. Emitting it as a zero-length line - // would poison the sketch downstream, so drop it here. - auto push_line = [&](const Vec2d& a, const Vec2d& b) { - if ((b - a).norm() < 1e-7) return; - SketchEntity se; se.type = SketchEntity::Type::Line; - se.p0 = a; se.p1 = b; - f.entities.push_back(se); - }; - - for (const TopoDS_Edge& e : edges) { - BRepAdaptor_Curve ac(e); - const GeomAbs_CurveType ct = ac.GetType(); - if (ct == GeomAbs_Line) { - gp_Pnt a = ac.Value(ac.FirstParameter()); - gp_Pnt b = ac.Value(ac.LastParameter()); - push_line(to2d(a), to2d(b)); - } else if (ct == GeomAbs_Circle) { - gp_Circ c = ac.Circle(); - gp_Dir cn = c.Axis().Direction(); - Vec3d cnv(cn.X(), cn.Y(), cn.Z()); - const double par = std::abs(cnv.dot(f.plane.normal)); - const bool full = BRep_Tool::IsClosed(e) || - std::abs((ac.LastParameter() - ac.FirstParameter()) - 2.0 * M_PI) < 1e-6; - if (par > 0.999) { - Vec2d ctr = to2d(c.Location()); - if (full) { - SketchEntity se; se.type = SketchEntity::Type::Circle; - se.center = ctr; se.radius = c.Radius(); - f.entities.push_back(se); - } else { - gp_Pnt a = ac.Value(ac.FirstParameter()); - gp_Pnt b = ac.Value(ac.LastParameter()); - Vec2d a2 = to2d(a), b2 = to2d(b); - SketchEntity se; se.type = SketchEntity::Type::Arc; - se.center = ctr; se.radius = c.Radius(); - se.p0 = a2; se.p1 = b2; - se.start_angle = std::atan2(a2.y() - ctr.y(), a2.x() - ctr.x()); - se.end_angle = std::atan2(b2.y() - ctr.y(), b2.x() - ctr.x()); - f.entities.push_back(se); - } - continue; - } - std::vector pts = GeometryEngine::sample_edge_world(e); - for (size_t i = 1; i < pts.size(); ++i) - push_line(to2d(gp_Pnt(pts[i-1].x(), pts[i-1].y(), pts[i-1].z())), - to2d(gp_Pnt(pts[i].x(), pts[i].y(), pts[i].z()))); - } else { - std::vector pts = GeometryEngine::sample_edge_world(e); - for (size_t i = 1; i < pts.size(); ++i) - push_line(to2d(gp_Pnt(pts[i-1].x(), pts[i-1].y(), pts[i-1].z())), - to2d(gp_Pnt(pts[i].x(), pts[i].y(), pts[i].z()))); - } - } if (f.entities.empty()) throw std::runtime_error("project: produced no entities"); } diff --git a/src/libslic3r/CAD/CadDocument.hpp b/src/libslic3r/CAD/CadDocument.hpp index 450e65d776..b41ccac20b 100644 --- a/src/libslic3r/CAD/CadDocument.hpp +++ b/src/libslic3r/CAD/CadDocument.hpp @@ -481,6 +481,13 @@ public: // entities are (re)derived on every recompute. int add_project_edges(int source_body, const std::vector& edge_ids, int face, const SketchPlane& plane, const std::string& name); + // Onshape's "Use" / SolidWorks' "Convert Entities": project a body's edges onto the plane of + // an EXISTING sketch feature and append them to that sketch as CONSTRUCTION entities, so new + // geometry can be constrained to them. Returns the number of entities appended, or -1 if the + // sketch or body reference is invalid. Unlike add_project_edges this creates no feature: the + // references become part of the sketch that borrows them. + int project_edges_into_sketch(int sketch_feature, int source_body, + const std::vector& edge_ids, int face); // Append a bridging BSpline entity connecting endpoint `end_a` of entity `ent_a` to // endpoint `end_b` of entity `ent_b`, both within sketch feature `sketch_ref`. Returns // the new entity's index within that sketch's entities vector. Non-parametric: computed diff --git a/tests/libslic3r/test_caddocument.cpp b/tests/libslic3r/test_caddocument.cpp index 356886c557..ce93baccf1 100644 --- a/tests/libslic3r/test_caddocument.cpp +++ b/tests/libslic3r/test_caddocument.cpp @@ -3762,6 +3762,153 @@ TEST_CASE("project round-trip serialization", "[CadDocument][project]") } } +TEST_CASE("Use/Convert Entities: box top-face edges land as construction geometry", "[CadDocument][project]") +{ + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "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); + if (GeometryEngine::face_normal_world(fc).z() > 0.9) { top_face = i; break; } + } + REQUIRE(top_face >= 0); + + int target = doc.add_sketch_entities({}, SketchPlane::XY(), "Use"); + REQUIRE(target >= 0); + + int appended = doc.project_edges_into_sketch(target, 0, {}, top_face); + REQUIRE(appended == 4); + + const auto& ents = doc.features[target].entities; + REQUIRE(ents.size() == 4); + for (const auto& e : ents) { + REQUIRE(e.type == SketchEntity::Type::Line); + REQUIRE(e.construction == true); + } +} + +TEST_CASE("Use/Convert Entities: construction references never become solid", "[CadDocument][project]") +{ + 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, "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); + if (GeometryEngine::face_normal_world(fc).z() > 0.9) { top_face = i; break; } + } + REQUIRE(top_face >= 0); + + const size_t body_count_before = doc.bodies.size(); + const double volume_before = doc.body_mass_properties(0).volume; + + int target = doc.add_sketch_entities({}, SketchPlane::XY(), "Use"); + int appended = doc.project_edges_into_sketch(target, 0, {}, top_face); + REQUIRE(appended == 4); + + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(doc.bodies.size() == body_count_before); + REQUIRE_THAT(doc.body_mass_properties(0).volume, WithinRel(volume_before, 1e-9)); +} + +TEST_CASE("Use/Convert Entities: bad references return -1 and change nothing", "[CadDocument][project]") +{ + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Box"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Ext"); + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 1); + + int target = doc.add_sketch_entities({}, SketchPlane::XY(), "Use"); + REQUIRE(target >= 0); + + REQUIRE(doc.project_edges_into_sketch(-1, 0, {}, -1) == -1); + REQUIRE(doc.project_edges_into_sketch(999, 0, {}, -1) == -1); + REQUIRE(doc.project_edges_into_sketch(target, -1, {}, -1) == -1); + REQUIRE(doc.project_edges_into_sketch(target, 999, {}, -1) == -1); + REQUIRE(doc.features[target].entities.empty()); +} + +TEST_CASE("Use/Convert Entities: cylinder edge arrives as a Circle, not a polyline", "[CadDocument][project]") +{ + using Catch::Matchers::WithinRel; + + CadDocument doc; + SketchEntity c; + c.type = SketchEntity::Type::Circle; + c.center = Vec2d(0, 0); + c.radius = 6.0; + int sk = doc.add_sketch_entities({c}, SketchPlane::XY(), "Circ"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Cyl"); + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 1); + + int n_edges = GeometryEngine::edge_count(doc.bodies[0].shape); + int top_edge = -1; + for (int i = 0; i < n_edges; ++i) { + TopoDS_Edge e = GeometryEngine::edge_by_index(doc.bodies[0].shape, i); + auto pts = GeometryEngine::sample_edge_world(e); + if (pts.empty()) continue; + Vec3d mid = Vec3d::Zero(); + for (const auto& p : pts) mid += p; + mid /= double(pts.size()); + if (mid.z() > 9.0) { + BRepAdaptor_Curve ac(e); + if (ac.GetType() == GeomAbs_Circle) { top_edge = i; break; } + } + } + REQUIRE(top_edge >= 0); + + int target = doc.add_sketch_entities({}, SketchPlane::XY(), "Use"); + int appended = doc.project_edges_into_sketch(target, 0, {top_edge}, -1); + REQUIRE(appended == 1); + + const auto& ents = doc.features[target].entities; + REQUIRE(ents.size() == 1); + REQUIRE(ents[0].type == SketchEntity::Type::Circle); + REQUIRE(ents[0].construction == true); + REQUIRE_THAT(ents[0].radius, WithinRel(6.0, 1e-6)); +} + +TEST_CASE("Project feature still emits non-construction entities (regression guard)", "[CadDocument][project]") +{ + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "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); + if (GeometryEngine::face_normal_world(fc).z() > 0.9) { top_face = i; break; } + } + REQUIRE(top_face >= 0); + + int proj = doc.add_project_edges(0, {}, top_face, SketchPlane::XY(), "ProjTop"); + REQUIRE(proj >= 0); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + const auto& pf = doc.features[proj]; + REQUIRE(pf.entities.size() == 4); + for (const auto& e : pf.entities) + REQUIRE(e.construction == false); +} + // --- Golden recipe fixture (v1 format tripwire) --- static CadDocument make_golden_doc_v1()