From 161d006e92c7a0cce5463676d9753dd7c5343220 Mon Sep 17 00:00:00 2001 From: Tommaso Bianchi Date: Fri, 24 Jul 2026 23:42:06 +0200 Subject: [PATCH] =?UTF-8?q?CAD:=20Project=20feature=20=E2=80=94=20convert?= =?UTF-8?q?=20solid=20edges=20into=20a=20parametric=20sketch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Onshape-style "Use / Convert entities": pick edges (or a whole face) of an existing solid and get sketch geometry projected onto a target plane, then extrude/revolve/edit it like any sketch. Parametric: apply_project re-derives the feature's entities from the source body on every recompute, so editing the source updates the projection. Line edges -> Line entities (exact); circles/arcs whose plane is parallel to the sketch plane -> Circle/Arc (exact); everything else (incl. non-parallel circles that project to ellipses) -> sampled Line chain. Append-only: new enum value Project + project_source_body/project_edges/ project_face fields at the end of save/load; recipe version stays 2. Recompute loop made non-const solely so apply_project can write back f.entities. Suite 67->71 cases, 1178->1229 assertions, RC=0. Fixture 25493->26835 B. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q --- src/libslic3r/CadDocument.cpp | 121 ++++++++++++++++++- src/libslic3r/CadDocument.hpp | 18 ++- src/slic3r/GUI/McpControl.cpp | 27 +++++ tests/data/cad_recipe_v2.bin | Bin 25493 -> 26835 bytes tests/libslic3r/test_caddocument.cpp | 170 +++++++++++++++++++++++++++ 5 files changed, 328 insertions(+), 8 deletions(-) diff --git a/src/libslic3r/CadDocument.cpp b/src/libslic3r/CadDocument.cpp index 1041096925..a3d41fa113 100644 --- a/src/libslic3r/CadDocument.cpp +++ b/src/libslic3r/CadDocument.cpp @@ -25,6 +25,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -783,6 +786,20 @@ int CadDocument::add_thicken(int target_body, int face, double thickness, bool f return int(features.size()) - 1; } +int CadDocument::add_project_edges(int source_body, const std::vector& edge_ids, int face, + const SketchPlane& plane, const std::string& name) +{ + CadFeature f; + f.type = CadFeatureType::Project; + f.name = name; + f.project_source_body = source_body; + f.project_edges = edge_ids; + f.project_face = face; + f.plane = plane; + 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) { @@ -1442,6 +1459,8 @@ void CadDocument::apply_feature(TopoDS_Shape& result, bool& have_body, switch (f.type) { case CadFeatureType::Sketch: return; // sketches carry no solid; consumed by an extrude + case CadFeatureType::Project: + return; // edges-to-sketch: consumed downstream, no solid body case CadFeatureType::Helix: return; // helical curve; consumed by Sweep as a path (like Sketch) case CadFeatureType::Boolean: @@ -1475,7 +1494,8 @@ void CadDocument::apply_feature(TopoDS_Shape& result, bool& have_body, // otherwise fall back to f's own inline sketch params (this makes a // single self-contained candidate previewable). const CadFeature& sk = (f.sketch_ref >= 0 && f.sketch_ref < int(features.size()) - && features[f.sketch_ref].type == CadFeatureType::Sketch) + && (features[f.sketch_ref].type == CadFeatureType::Sketch + || 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 @@ -1546,7 +1566,8 @@ void CadDocument::apply_feature(TopoDS_Shape& result, bool& have_body, // Resolve the profile sketch like Extrude: referenced Sketch when valid, // else this feature's own inline entities/profile (self-contained candidate). const CadFeature& sk = (f.sketch_ref >= 0 && f.sketch_ref < int(features.size()) - && features[f.sketch_ref].type == CadFeatureType::Sketch) + && (features[f.sketch_ref].type == CadFeatureType::Sketch + || features[f.sketch_ref].type == CadFeatureType::Project)) ? features[f.sketch_ref] : f; TopoDS_Wire wire = build_sketch_wire(sk); const double ang = f.flip ? -f.revolve_angle : f.revolve_angle; @@ -1571,7 +1592,8 @@ void CadDocument::apply_feature(TopoDS_Shape& result, bool& have_body, } case CadFeatureType::Sweep: { const CadFeature& sk = (f.sketch_ref >= 0 && f.sketch_ref < int(features.size()) - && features[f.sketch_ref].type == CadFeatureType::Sketch) + && (features[f.sketch_ref].type == CadFeatureType::Sketch + || features[f.sketch_ref].type == CadFeatureType::Project)) ? features[f.sketch_ref] : f; if (f.sweep_path_ref < 0 || f.sweep_path_ref >= int(features.size())) throw std::runtime_error("sweep needs a valid path reference"); @@ -1612,7 +1634,8 @@ void CadDocument::apply_feature(TopoDS_Shape& result, bool& have_body, std::vector profiles; for (int ref : f.loft_profile_refs) { if (ref < 0 || ref >= int(features.size()) - || features[ref].type != CadFeatureType::Sketch) + || (features[ref].type != CadFeatureType::Sketch + && features[ref].type != CadFeatureType::Project)) continue; profiles.push_back(build_sketch_wire(features[ref])); } @@ -2092,6 +2115,92 @@ void CadDocument::apply_thicken(std::vector& bodies, const CadFeature& bodies.push_back({solid, f.name.empty() ? std::string("Thicken") : f.name}); } +void CadDocument::apply_project(const std::vector& bodies, CadFeature& f) const +{ + f.entities.clear(); + const int nb = int(bodies.size()); + if (nb == 0) throw std::runtime_error("project: no source body"); + const int src = (f.project_source_body >= 0 && f.project_source_body < nb) + ? f.project_source_body : nb - 1; + if (src < 0 || bodies[src].shape.IsNull()) throw std::runtime_error("project: source body is empty"); + const TopoDS_Shape& shape = bodies[src].shape; + + std::vector edges; + if (!f.project_edges.empty()) { + for (int id : f.project_edges) { + TopoDS_Edge e = GeometryEngine::edge_by_index(shape, id); + if (e.IsNull()) throw std::runtime_error("project: edge not found"); + edges.push_back(e); + } + } else if (f.project_face >= 0) { + TopoDS_Face fc = GeometryEngine::face_by_index(shape, f.project_face); + if (fc.IsNull()) throw std::runtime_error("project: face not found"); + edges = GeometryEngine::edges_of_face(fc); + } else { + throw std::runtime_error("project: no edges or face selected"); + } + 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)); + }; + + 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()); + SketchEntity se; se.type = SketchEntity::Type::Line; + se.p0 = to2d(a); se.p1 = to2d(b); + f.entities.push_back(se); + } 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) { + SketchEntity se; se.type = SketchEntity::Type::Line; + se.p0 = to2d(gp_Pnt(pts[i-1].x(), pts[i-1].y(), pts[i-1].z())); + se.p1 = to2d(gp_Pnt(pts[i].x(), pts[i].y(), pts[i].z())); + f.entities.push_back(se); + } + } else { + std::vector pts = GeometryEngine::sample_edge_world(e); + for (size_t i = 1; i < pts.size(); ++i) { + SketchEntity se; se.type = SketchEntity::Type::Line; + se.p0 = to2d(gp_Pnt(pts[i-1].x(), pts[i-1].y(), pts[i-1].z())); + se.p1 = to2d(gp_Pnt(pts[i].x(), pts[i].y(), pts[i].z())); + f.entities.push_back(se); + } + } + } + if (f.entities.empty()) throw std::runtime_error("project: produced no entities"); +} + 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 @@ -2103,6 +2212,7 @@ void CadDocument::route_feature(std::vector& bodies, const CadFeature& 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 + if (f.type == CadFeatureType::Project) return; // sketch-like: consumed downstream, no body // 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; @@ -2135,13 +2245,14 @@ bool CadDocument::recompute() error.clear(); std::vector built; try { - for (const CadFeature& f : features) { + for (CadFeature& f : features) { if (!f.enabled) continue; if (f.type == CadFeatureType::Sketch) continue; // consumed by an extrude if (f.type == CadFeatureType::Helix) continue; // consumed by Sweep as a path if (f.type == CadFeatureType::Plane) continue; // datum: no solid, derived on demand if (f.type == CadFeatureType::Axis) continue; // datum axis if (f.type == CadFeatureType::CoordSys) continue; // datum coordinate system + if (f.type == CadFeatureType::Project) { apply_project(built, f); continue; } route_feature(built, f); } } catch (const Standard_Failure& e) { diff --git a/src/libslic3r/CadDocument.hpp b/src/libslic3r/CadDocument.hpp index c1947ba892..c4bd69e83f 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, Thicken }; +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, Project }; enum class SketchShape { Rectangle, Circle }; enum class PlaneType { Offset, Angle, Midplane, Tangent, TwoEdges, Coincident }; enum class AxisType { TwoPoints, FaceNormal, CylinderCenterline, PlaneIntersection, AlongEdge }; @@ -258,6 +258,11 @@ struct CadFeature { 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` + // Project feature: convert edges of an existing solid into sketch entities on `plane`. + int project_source_body{-1}; // body owning the edges; -1 = last body + std::vector project_edges; // global edge ids to project; empty => use project_face + int project_face{-1}; // if project_edges empty, project every edge of this face + template void save(Archive& ar) const { std::string brep = (type == CadFeatureType::Import) ? brep_to_string(imported_solid) : std::string(); @@ -286,7 +291,8 @@ struct CadFeature { 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, - cut_face_body, cut_face); + cut_face_body, cut_face, + project_source_body, project_edges, project_face); } template void load(Archive& ar) { @@ -316,7 +322,8 @@ struct CadFeature { 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, - cut_face_body, cut_face); + cut_face_body, cut_face, + project_source_body, project_edges, project_face); imported_solid = brep_from_string(brep); } }; @@ -367,6 +374,10 @@ public: int add_sketch_entities(const std::vector& entities, const SketchPlane& plane, const std::string& name, const std::vector& constraints = {}); + // Project edges of source_body onto plane, producing a sketch feature whose + // 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); // Solve features[index]'s sketch constraints, writing solved coordinates back // into its profile.points. No-op (returns true) if the feature has no // constraints. Returns false if index is invalid / not a Sketch / solve fails. @@ -537,6 +548,7 @@ private: 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; + void apply_project(const std::vector& bodies, 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 3b7d01b0e5..931984025a 100644 --- a/src/slic3r/GUI/McpControl.cpp +++ b/src/slic3r/GUI/McpControl.cpp @@ -76,6 +76,7 @@ const char* feature_type_name(CadFeatureType t) case CadFeatureType::Helix: return "Helix"; case CadFeatureType::Transform: return "Transform"; case CadFeatureType::Thicken: return "Thicken"; + case CadFeatureType::Project: return "Project"; } return "Unknown"; } @@ -248,6 +249,13 @@ json describe_tools() json{{"name", "keep_upper"},{"type", "boolean"}, {"default", true}}, json{{"name", "keep_lower"},{"type", "boolean"}, {"default", true}}, })}}, + json{{"name", "project"}, {"summary", "Project edges of a solid onto a sketch plane, producing a new sketch feature."}, + {"params", json::array({ + json{{"name", "source_body"}, {"type", "integer"}, {"default", -1}, {"description", "body owning the edges; -1 = last body"}}, + json{{"name", "face"}, {"type", "integer"}, {"default", -1}, {"description", "global face id on the source body; when set, all its edges are projected"}}, + json{{"name", "edges"}, {"type", "array"}, {"default", json::array()}, {"description", "global edge ids to project; empty => project the face"}}, + json{{"name", "plane"}, {"type", "string"}, {"default", "XY"}, {"description", "target sketch plane (XY/XZ/YZ)"}}, + })}}, 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}}, @@ -932,6 +940,24 @@ json action_split(DesignPanel* panel, const json& params) return json{{"ok", ok}, {"split_index", idx}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}}; } +json action_project(DesignPanel* panel, const json& params) +{ + CadDocument& doc = panel->mcp_doc(); + if (doc.bodies.empty()) throw std::runtime_error("no source body to project from"); + int source_body = params.value("source_body", -1); + int face = params.value("face", -1); + std::vector edges; + if (params.contains("edges") && params["edges"].is_array()) + for (const auto& v : params["edges"]) edges.push_back(v.get()); + SketchPlane pl = plane_from(params, doc); + doc.checkpoint(); + int idx = doc.add_project_edges(source_body, edges, face, pl, "Project"); + bool ok = doc.recompute(); + if (!ok) doc.undo(); + panel->mcp_after_change(); + return json{{"ok", ok}, {"project_index", idx}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}}; +} + json action_axis(DesignPanel* panel, const json& params) { CadDocument& doc = panel->mcp_doc(); @@ -1033,6 +1059,7 @@ std::string handle_on_main(const std::string& method, const json& params, const 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 == "project") return rpc_result(id, action_project(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 c07ed6261cdae772b575b52027c260ace5aa5fa9..3d131e40d72b3f751fb363dc22ee66c7c7cb16e4 100644 GIT binary patch delta 328 zcmbPwobmESMph;U1_rr}tjWxiJ-vB0r!Xf%7zf#)3^^_+qnbA!B6Uq57Q)aMNq{iA z#i3Hqq@Ysva!_4!6q6uoeyJRRFm`D`q$c0em)dNovlb$HOaC~8G1oW_!uW2s7{Zul z1!b7pr9(t!I6^J{;sP~viw9Ihzz3>5%RdI9_)rkkak61hsq)Aah}88MsN+l%5|}mr t0|78FzyQP+1G0FbeE0mEl+?U{qWrAXQ5qwZSf&HD@vGjE<~62-jvxA}bL&8w_| zM5H~?^fOLCLoD2Y8aTXwtP)?Kl6wIW%$wapfCg_22XdvO6PP!5#{mugnHa+i0H7i| AX#fBK diff --git a/tests/libslic3r/test_caddocument.cpp b/tests/libslic3r/test_caddocument.cpp index d4fd147268..87aa386e24 100644 --- a/tests/libslic3r/test_caddocument.cpp +++ b/tests/libslic3r/test_caddocument.cpp @@ -3094,6 +3094,167 @@ TEST_CASE("thicken round-trip serialization", "[CadDocument]") } } +// --- Project feature tests --- + +TEST_CASE("project a box top face to 4 lines, extrudable", "[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); + Vec3d n = GeometryEngine::face_normal_world(fc); + if (n.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); + doc.add_extrude(proj, 5.0, false, BooleanMode::New, "FromProj"); + 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.type == SketchEntity::Type::Line); + + REQUIRE(doc.bodies.size() >= 2); + double v = double(SketchEngine::tessellate(doc.bodies.back().shape).volume()); + REQUIRE_THAT(v, WithinRel(20.0 * 20.0 * 5.0, 1e-3)); +} + +TEST_CASE("project a cylinder top edge to 1 circle, extrudable", "[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 proj = doc.add_project_edges(0, {top_edge}, -1, SketchPlane::XY(), "ProjCirc"); + REQUIRE(proj >= 0); + doc.add_extrude(proj, 4.0, false, BooleanMode::New, "FromCirc"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + const auto& pf = doc.features[proj]; + REQUIRE(pf.entities.size() == 1); + REQUIRE(pf.entities[0].type == SketchEntity::Type::Circle); + REQUIRE_THAT(pf.entities[0].radius, WithinRel(6.0, 1e-3)); + + REQUIRE(doc.bodies.size() >= 2); + double v = double(SketchEngine::tessellate(doc.bodies.back().shape).volume()); + REQUIRE_THAT(v, WithinRel(M_PI * 36.0 * 4.0, 1e-2)); +} + +TEST_CASE("project bad face id returns error", "[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()); + + doc.add_project_edges(0, {}, 9999, SketchPlane::XY(), "Bad"); + bool ok = doc.recompute(); + REQUIRE_FALSE(ok); + bool has_project = doc.error.find("project") != std::string::npos + || doc.error.find("face") != std::string::npos; + REQUIRE(has_project); +} + +TEST_CASE("project round-trip serialization", "[CadDocument][project]") +{ + using Catch::Matchers::WithinAbs; + 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); + Vec3d n = GeometryEngine::face_normal_world(fc); + if (n.z() > 0.9) { top_face = i; break; } + } + REQUIRE(top_face >= 0); + + doc.add_project_edges(0, {}, top_face, SketchPlane::XY(), "ProjTop"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + + int saved_src = doc.features.back().project_source_body; + int saved_face = doc.features.back().project_face; + auto saved_edges = doc.features.back().project_edges; + 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.project_source_body == saved_src); + REQUIRE(f2.project_face == saved_face); + REQUIRE(f2.project_edges == saved_edges); + + 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)); + } +} + // --- Golden recipe fixture (v1 format tripwire) --- static CadDocument make_golden_doc_v1() @@ -3257,6 +3418,8 @@ static CadDocument make_golden_doc_v1() doc.add_split_by_face(0, 0, 2, true, false, "GoldenSplit"); + doc.add_project_edges(0, {}, 0, SketchPlane::XY(), "GoldenProject"); + return doc; } @@ -3545,6 +3708,13 @@ TEST_CASE("golden recipe v1 still deserialises", "[CadDocument]") REQUIRE(f.cut_keep_upper == true); REQUIRE(f.cut_keep_lower == false); } + + // Project + if (f.type == CadFeatureType::Project && e.name == "GoldenProject") { + REQUIRE(f.project_source_body == 0); + REQUIRE(f.project_face == 0); + REQUIRE(f.project_edges == e.project_edges); + } } // --- Layer 2: geometry check (optional — only if the document recomputes) ---