diff --git a/src/libslic3r/CadDocument.cpp b/src/libslic3r/CadDocument.cpp index 2d7cfebf1e..ee85072f98 100644 --- a/src/libslic3r/CadDocument.cpp +++ b/src/libslic3r/CadDocument.cpp @@ -26,6 +26,7 @@ #include #include #include +#include #include #include #include @@ -73,6 +74,56 @@ static TopoDS_Wire make_helix_wire(const gp_Ax3& axis, double radius, return BRepBuilderAPI_MakeWire(e).Wire(); } +// Helix spine from a CadFeature's helix params. Supports cylindrical (taper==0) +// and conical (taper!=0) surfaces; left_handed flips the winding direction. +// Returns null wire if validation fails (error is written to err). +static TopoDS_Wire make_helix_spine(const CadFeature& f, std::string& err) +{ + err.clear(); + const double R = f.helix_radius, P = f.helix_pitch, H = f.helix_height; + const double taper = f.helix_taper_deg * M_PI / 180.0; + + if (R <= 0) { err = "helix radius must be > 0"; return TopoDS_Wire(); } + if (P <= 0) { err = "helix pitch must be > 0"; return TopoDS_Wire(); } + if (H < 0) { err = "helix height must be >= 0"; return TopoDS_Wire(); } + if (H == 0) { err = "helix height of 0 (flat spiral) is not supported"; return TopoDS_Wire(); } + const double turns = H / P; + if (turns > 10000) { err = "helix turn count exceeds limit (10000)"; return TopoDS_Wire(); } + if (std::abs(taper) > 1e-12) { + const double R_top = R + H * std::tan(taper); + if (R_top <= 0) { + err = "helix taper drives radius negative before reaching height"; + return TopoDS_Wire(); + } + } + + gp_Dir zdir(f.plane.normal.x(), f.plane.normal.y(), f.plane.normal.z()); + gp_Dir xdir(f.plane.x_axis.x(), f.plane.x_axis.y(), f.plane.x_axis.z()); + Vec3d ori = f.plane.origin; + gp_Pnt o(ori.x(), ori.y(), ori.z()); + gp_Ax2 ax2(o, zdir, xdir); + gp_Ax3 ax3(o, zdir, xdir); + + TopoDS_Edge e; + if (std::abs(taper) > 1e-12) { + Handle(Geom_ConicalSurface) cone = new Geom_ConicalSurface(ax3, taper, R); + double u1 = f.helix_left_handed ? -2.0 * M_PI * turns : 2.0 * M_PI * turns; + gp_Pnt2d p0(0.0, 0.0); + gp_Pnt2d p1(u1, H); + Handle(Geom2d_TrimmedCurve) seg = GCE2d_MakeSegment(p0, p1); + e = BRepBuilderAPI_MakeEdge(seg, cone).Edge(); + } else { + Handle(Geom_CylindricalSurface) cyl = new Geom_CylindricalSurface(ax3, R); + double u1 = f.helix_left_handed ? -2.0 * M_PI * turns : 2.0 * M_PI * turns; + gp_Pnt2d p0(0.0, 0.0); + gp_Pnt2d p1(u1, H); + Handle(Geom2d_TrimmedCurve) seg = GCE2d_MakeSegment(p0, p1); + e = BRepBuilderAPI_MakeEdge(seg, cyl).Edge(); + } + BRepLib::BuildCurves3d(e); + return BRepBuilderAPI_MakeWire(e).Wire(); +} + // Triangular axial thread profile (a planar face) placed at the helix start // (origin + radius*xdir). Spans +-pitch/2 axially; apex offset radially by depth. // Both thread kinds sweep the SAME outward-biting V (base on the cylinder wall, @@ -720,6 +771,27 @@ int CadDocument::add_coordsys(CoordSysType type, const Vec3d& point, const std:: return int(features.size()) - 1; } +int CadDocument::add_helix(const SketchPlane& plane, double radius, double pitch, double height, + bool left_handed, double taper_deg, const std::string& name) +{ + CadFeature f; + f.type = CadFeatureType::Helix; + f.name = name; + f.plane = plane; + f.helix_radius = radius; + f.helix_pitch = pitch; + f.helix_height = height; + f.helix_left_handed = left_handed; + f.helix_taper_deg = taper_deg; + features.push_back(f); + return int(features.size()) - 1; +} + +TopoDS_Wire CadDocument::build_helix_wire(const CadFeature& f, std::string& err) const +{ + return make_helix_spine(f, err); +} + // Derive a SketchPlane: shift `base` along its normal by `offset`, then tilt // `angle_deg` about the base's X (axis 0) or Y (axis 1) axis (Rodrigues rotation). static SketchPlane offset_angle_plane(const SketchPlane& base, double offset, @@ -1323,6 +1395,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::Helix: + return; // helical curve; consumed by Sweep as a path (like Sketch) case CadFeatureType::Boolean: return; // body-body boolean is handled in route_feature/apply_boolean, never here case CadFeatureType::Import: @@ -1449,17 +1523,23 @@ void CadDocument::apply_feature(TopoDS_Shape& result, bool& have_body, break; } case CadFeatureType::Sweep: { - // Resolve the profile sketch like Extrude/Revolve, and the path (spine) from - // the referenced path Sketch. Both build through build_sketch_wire (the path - // sketch is entity-based, so its wire keeps its open/closed shape as drawn). const CadFeature& sk = (f.sketch_ref >= 0 && f.sketch_ref < int(features.size()) && features[f.sketch_ref].type == CadFeatureType::Sketch) ? features[f.sketch_ref] : f; - if (f.sweep_path_ref < 0 || f.sweep_path_ref >= int(features.size()) - || features[f.sweep_path_ref].type != CadFeatureType::Sketch) - throw std::runtime_error("sweep needs a valid path sketch"); + if (f.sweep_path_ref < 0 || f.sweep_path_ref >= int(features.size())) + throw std::runtime_error("sweep needs a valid path reference"); + const CadFeature& path_feat = features[f.sweep_path_ref]; + TopoDS_Wire path; + if (path_feat.type == CadFeatureType::Helix) { + std::string helix_err; + path = make_helix_spine(path_feat, helix_err); + if (path.IsNull()) throw std::runtime_error("helix path: " + helix_err); + } else if (path_feat.type == CadFeatureType::Sketch) { + path = build_sketch_wire(path_feat); + } else { + throw std::runtime_error("sweep path must be a sketch or helix"); + } TopoDS_Wire profile = build_sketch_wire(sk); - TopoDS_Wire path = build_sketch_wire(features[f.sweep_path_ref]); TopoDS_Shape tool = SketchEngine::make_sweep(profile, path); if (!have_body || f.mode == BooleanMode::New) { result = tool; @@ -1897,6 +1977,7 @@ void CadDocument::route_feature(std::vector& bodies, const CadFeature& if (f.type == CadFeatureType::Plane) return; // datum plane: not part of the body pipeline if (f.type == CadFeatureType::Axis) return; // datum axis if (f.type == CadFeatureType::CoordSys) return; // datum coordinate system + if (f.type == CadFeatureType::Helix) return; // helical curve; consumed by Sweep if (f.type == CadFeatureType::Boolean) { apply_boolean(bodies, f); return; } // body-body op if (f.type == CadFeatureType::Cut) { apply_cut(bodies, f); return; } // plane-split body if (f.type == CadFeatureType::Mirror) { apply_mirror(bodies, f); return; } // mirror body about plane @@ -1935,6 +2016,7 @@ bool CadDocument::recompute() for (const 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 diff --git a/src/libslic3r/CadDocument.hpp b/src/libslic3r/CadDocument.hpp index b7462ca6a3..8ac347f878 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 }; +enum class CadFeatureType { Sketch, Extrude, Fillet, Chamfer, Hole, Thread, Shell, Revolve, Sweep, Pattern, Plane, Loft, Draft, Import, Boolean, Cut, Mirror, Axis, CoordSys, Helix }; enum class SketchShape { Rectangle, Circle }; enum class PlaneType { Offset, Angle, Midplane, Tangent, TwoEdges, Coincident }; enum class AxisType { TwoPoints, FaceNormal, CylinderCenterline, PlaneIntersection, AlongEdge }; @@ -229,6 +229,15 @@ struct CadFeature { int coordsys_edge{-1}; Vec3d coordsys_x_hint{1, 0, 0}; + // Helix curve params (consumed as a sweep path to build springs/coils/augers). + // Axis = plane normal through plane origin. pitch = axial rise per full turn. + // left_handed flips the winding direction. taper_deg != 0 gives a conical helix. + double helix_radius{10}; + double helix_pitch{5}; + double helix_height{20}; + bool helix_left_handed{false}; + double helix_taper_deg{0}; + template void save(Archive& ar) const { std::string brep = (type == CadFeatureType::Import) ? brep_to_string(imported_solid) : std::string(); @@ -253,7 +262,8 @@ struct CadFeature { plane_edge_body, plane_edge, plane_edge2_body, plane_edge2, plane_u_size, plane_v_size, mirror_keep_original, axis_type, axis_p1, axis_p2, axis_body, axis_face, axis_edge, axis_plane_a, axis_plane_b, - coordsys_type, coordsys_point, coordsys_body, coordsys_face, coordsys_edge, coordsys_x_hint); + coordsys_type, coordsys_point, coordsys_body, coordsys_face, coordsys_edge, coordsys_x_hint, + helix_radius, helix_pitch, helix_height, helix_left_handed, helix_taper_deg); } template void load(Archive& ar) { @@ -279,7 +289,8 @@ struct CadFeature { plane_edge_body, plane_edge, plane_edge2_body, plane_edge2, plane_u_size, plane_v_size, mirror_keep_original, axis_type, axis_p1, axis_p2, axis_body, axis_face, axis_edge, axis_plane_a, axis_plane_b, - coordsys_type, coordsys_point, coordsys_body, coordsys_face, coordsys_edge, coordsys_x_hint); + coordsys_type, coordsys_point, coordsys_body, coordsys_face, coordsys_edge, coordsys_x_hint, + helix_radius, helix_pitch, helix_height, helix_left_handed, helix_taper_deg); imported_solid = brep_from_string(brep); } }; @@ -389,6 +400,10 @@ public: int add_axis(AxisType axis_type, const std::string& name); // Datum coordinate system. int add_coordsys(CoordSysType type, const Vec3d& point, const std::string& name); + int add_helix(const SketchPlane& plane, double radius, double pitch, double height, + bool left_handed, double taper_deg, const std::string& name); + // Build the helix wire from a Helix feature's params (exposed for tests). + TopoDS_Wire build_helix_wire(const CadFeature& f, std::string& err) const; // Every datum plane currently in the recipe, in feature order, as (name, plane). // Used by the GUI to populate plane pickers (after the 3 base planes). std::vector> resolve_datum_planes() const; diff --git a/src/slic3r/GUI/McpControl.cpp b/src/slic3r/GUI/McpControl.cpp index bfb67e2669..38030d570c 100644 --- a/src/slic3r/GUI/McpControl.cpp +++ b/src/slic3r/GUI/McpControl.cpp @@ -206,6 +206,15 @@ json describe_tools() json{{"name", "edge"}, {"type", "integer"}, {"default", -1}, {"description", "edge id (query_topology) for X axis hint"}}, json{{"name", "x_hint"}, {"type", "array"}, {"default", json::array({1,0,0})}, {"description", "fallback X direction hint if no edge given"}}, })}}, + json{{"name", "helix"}, {"summary", "Create a helical curve (consumed by sweep as a path to build springs/coils/augers). pitch = axial rise per turn. left_handed flips the winding. taper_deg != 0 gives a conical helix."}, + {"params", json::array({ + json{{"name", "radius"}, {"type", "number"}, {"unit", "mm"}, {"default", 10}, {"min", 0.01}}, + json{{"name", "pitch"}, {"type", "number"}, {"unit", "mm"}, {"default", 5}, {"min", 0.01}}, + json{{"name", "height"}, {"type", "number"}, {"unit", "mm"}, {"default", 20}, {"min", 0.01}}, + json{{"name", "left_handed"}, {"type", "boolean"}, {"default", false}}, + json{{"name", "taper_deg"}, {"type", "number"}, {"unit", "deg"}, {"default", 0}}, + json{{"name", "plane"}, {"type", "string"}, {"enum", json::array({"XY", "XZ", "YZ"})}, {"default", "XY"}}, + })}}, json{{"name", "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}}, @@ -894,6 +903,20 @@ json action_coordsys(DesignPanel* panel, const json& params) return json{{"ok", true}, {"coordsys_index", idx}, {"error", err}}; } +json action_helix(DesignPanel* panel, const json& params) +{ + const double radius = params.value("radius", 10.0); + const double pitch = params.value("pitch", 5.0); + const double height = params.value("height", 20.0); + const bool left_handed = params.value("left_handed", false); + const double taper = params.value("taper_deg", 0.0); + CadDocument& doc = panel->mcp_doc(); + doc.checkpoint(); + int idx = doc.add_helix(plane_from(params, doc), radius, pitch, height, left_handed, taper, "Helix"); + panel->mcp_after_change(); + return json{{"ok", true}, {"helix_index", idx}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}}; +} + // Dispatch one parsed request ON THE MAIN THREAD. Returns a JSON-RPC reply string. std::string handle_on_main(const std::string& method, const json& params, const json& id) { @@ -923,6 +946,7 @@ std::string handle_on_main(const std::string& method, const json& params, const if (method == "mirror") return rpc_result(id, action_mirror(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)); return rpc_error(id, -32601, "Unknown method: " + method); } catch (const Standard_Failure& ex) { // OCCT errors are NOT std::exception return rpc_error(id, -32000, std::string("OCCT: ") + (ex.GetMessageString() ? ex.GetMessageString() : "failure")); diff --git a/tests/data/cad_recipe_v2.bin b/tests/data/cad_recipe_v2.bin index 53c86aa410..ee1ba677d5 100644 Binary files a/tests/data/cad_recipe_v2.bin and b/tests/data/cad_recipe_v2.bin differ diff --git a/tests/libslic3r/test_caddocument.cpp b/tests/libslic3r/test_caddocument.cpp index a9f7d82f21..167940384d 100644 --- a/tests/libslic3r/test_caddocument.cpp +++ b/tests/libslic3r/test_caddocument.cpp @@ -13,6 +13,11 @@ #include #include #include +#include +#include +#include +#include +#include #include #include #include @@ -2213,6 +2218,290 @@ TEST_CASE("datum coordinate system: point_world gives world axes", "[CadDocument } +TEST_CASE("helix curve: arc length, bounding box, left-handed, conical", "[CadDocument]") +{ + using Catch::Matchers::WithinRel; + using Catch::Matchers::WithinAbs; + + // --- Cylindrical helix r=5, pitch=2, height=10 (5 turns) --- + // One turn arc length = sqrt((2*pi*r)^2 + pitch^2) = sqrt((10*pi)^2 + 4). + // Total = 5 * sqrt(986.96...) ≈ 5 * 31.4159 ≈ 157.08 mm. + SECTION("cylindrical helix arc length matches analytic") { + CadDocument doc; + doc.add_helix(SketchPlane::XY(), 5.0, 2.0, 10.0, false, 0.0, "H1"); + REQUIRE(doc.features.size() == 1); + std::string err; + TopoDS_Wire w = doc.build_helix_wire(doc.features[0], err); + REQUIRE_FALSE(w.IsNull()); + REQUIRE(err.empty()); + + GProp_GProps props; + BRepGProp::LinearProperties(w, props); + const double len = props.Mass(); + const double one_turn = std::sqrt(std::pow(2.0 * M_PI * 5.0, 2) + std::pow(2.0, 2)); + const double expected = 5.0 * one_turn; + REQUIRE_THAT(len, WithinRel(expected, 1e-3)); + } + + // --- Bounding box: X/Y extent = 2*radius, Z extent = height --- + SECTION("cylindrical helix bounding box") { + CadDocument doc; + doc.add_helix(SketchPlane::XY(), 5.0, 2.0, 10.0, false, 0.0, "H1"); + std::string err; + TopoDS_Wire w = doc.build_helix_wire(doc.features[0], err); + REQUIRE_FALSE(w.IsNull()); + + Bnd_Box bb; + BRepBndLib::Add(w, bb); + double xmin, ymin, zmin, xmax, ymax, zmax; + bb.Get(xmin, ymin, zmin, xmax, ymax, zmax); + REQUIRE_THAT(xmax - xmin, WithinRel(10.0, 0.1)); + REQUIRE_THAT(ymax - ymin, WithinRel(10.0, 0.1)); + REQUIRE_THAT(zmax - zmin, WithinRel(10.0, 0.1)); + } + + // --- Left-handed helix: sample a point at parameter ~0.25 and compare --- + SECTION("left_handed flips the winding direction") { + CadDocument doc_rh, doc_lh; + doc_rh.add_helix(SketchPlane::XY(), 5.0, 2.0, 10.0, false, 0.0, "RH"); + doc_lh.add_helix(SketchPlane::XY(), 5.0, 2.0, 10.0, true, 0.0, "LH"); + + std::string err; + TopoDS_Wire w_rh = doc_rh.build_helix_wire(doc_rh.features[0], err); + TopoDS_Wire w_lh = doc_lh.build_helix_wire(doc_lh.features[0], err); + REQUIRE_FALSE(w_rh.IsNull()); + REQUIRE_FALSE(w_lh.IsNull()); + + // Sample at height = height/4 along the helix: + // RH: angle = 2*pi*turns*0.25 = pi/2, direction is +2*pi*turns + // so at z = 2.5: u = pi/2 => x = r*cos(pi/2) = 0, y = r*sin(pi/2) = +5 + // LH: angle goes negative, at z = 2.5: u = -pi/2 => x = 0, y = -5 + double z_sample = 2.5; // height/4 + // Approximate by scanning edges and picking the vertex nearest to target z + auto point_at_z = [&](const TopoDS_Wire& w, double target_z) -> gp_Pnt { + double best_dz = 1e9; + gp_Pnt best(0,0,0); + for (TopExp_Explorer ex(w, TopAbs_EDGE); ex.More(); ex.Next()) { + TopoDS_Edge e = TopoDS::Edge(ex.Current()); + BRepAdaptor_Curve curve(e); + double u0 = curve.FirstParameter(); + double u1 = curve.LastParameter(); + for (int s = 0; s <= 100; ++s) { + double u = u0 + (u1 - u0) * s / 100.0; + gp_Pnt p = curve.Value(u); + if (std::abs(p.Z() - target_z) < best_dz) { + best_dz = std::abs(p.Z() - target_z); + best = p; + } + } + } + return best; + }; + + gp_Pnt prh = point_at_z(w_rh, z_sample); + gp_Pnt plh = point_at_z(w_lh, z_sample); + + // At z=2.5 for RH: angle ~ pi/2 -> y > 0 + REQUIRE(prh.Y() > 0.0); + // At z=2.5 for LH: angle ~ -pi/2 -> y < 0 + REQUIRE(plh.Y() < 0.0); + // They must differ in sign of y (mirror winding), not just "differ" + REQUIRE(prh.Y() * plh.Y() < 0.0); + } + + // --- Conical helix: top radius matches r + height*tan(taper) --- + SECTION("conical helix top radius") { + CadDocument doc; + doc.add_helix(SketchPlane::XY(), 5.0, 2.0, 10.0, false, 10.0, "Cone"); + std::string err; + TopoDS_Wire w = doc.build_helix_wire(doc.features[0], err); + REQUIRE_FALSE(w.IsNull()); + REQUIRE(err.empty()); + + // Top radius = 5 + 10*tan(10) ≈ 5 + 1.7633 = 6.7633 + const double expected_top = 5.0 + 10.0 * std::tan(10.0 * M_PI / 180.0); + + // Sample at z = height: use same sampling approach + double best_dz = 1e9; + gp_Pnt best(0,0,0); + for (TopExp_Explorer ex(w, TopAbs_EDGE); ex.More(); ex.Next()) { + TopoDS_Edge e = TopoDS::Edge(ex.Current()); + BRepAdaptor_Curve curve(e); + double u0 = curve.FirstParameter(); + double u1 = curve.LastParameter(); + for (int s = 0; s <= 200; ++s) { + double u = u0 + (u1 - u0) * s / 200.0; + gp_Pnt p = curve.Value(u); + if (std::abs(p.Z() - 10.0) < best_dz) { + best_dz = std::abs(p.Z() - 10.0); + best = p; + } + } + } + double top_r = std::sqrt(best.X() * best.X() + best.Y() * best.Y()); + REQUIRE_THAT(top_r, WithinRel(expected_top, 1e-2)); + } +} + +TEST_CASE("helix: invalid inputs fail cleanly", "[CadDocument]") +{ + SECTION("radius <= 0") { + CadDocument doc; + doc.add_helix(SketchPlane::XY(), 0.0, 2.0, 10.0, false, 0.0, "H"); + std::string err; + REQUIRE(doc.build_helix_wire(doc.features[0], err).IsNull()); + REQUIRE_FALSE(err.empty()); + } + SECTION("pitch <= 0") { + CadDocument doc; + doc.add_helix(SketchPlane::XY(), 5.0, 0.0, 10.0, false, 0.0, "H"); + std::string err; + REQUIRE(doc.build_helix_wire(doc.features[0], err).IsNull()); + REQUIRE_FALSE(err.empty()); + } + SECTION("height < 0") { + CadDocument doc; + doc.add_helix(SketchPlane::XY(), 5.0, 2.0, -1.0, false, 0.0, "H"); + std::string err; + REQUIRE(doc.build_helix_wire(doc.features[0], err).IsNull()); + REQUIRE_FALSE(err.empty()); + } + SECTION("height == 0 (flat spiral) rejected") { + CadDocument doc; + doc.add_helix(SketchPlane::XY(), 5.0, 2.0, 0.0, false, 0.0, "H"); + std::string err; + REQUIRE(doc.build_helix_wire(doc.features[0], err).IsNull()); + REQUIRE_FALSE(err.empty()); + CHECK_THAT(err, Catch::Matchers::ContainsSubstring("flat spiral")); + } + SECTION("absurd turn count") { + CadDocument doc; + doc.add_helix(SketchPlane::XY(), 5.0, 1e-4, 2.0, false, 0.0, "H"); + std::string err; + REQUIRE(doc.build_helix_wire(doc.features[0], err).IsNull()); + REQUIRE_FALSE(err.empty()); + } + SECTION("taper drives radius negative") { + CadDocument doc; + doc.add_helix(SketchPlane::XY(), 1.0, 2.0, 10.0, false, -10.0, "H"); + std::string err; + REQUIRE(doc.build_helix_wire(doc.features[0], err).IsNull()); + REQUIRE_FALSE(err.empty()); + CHECK_THAT(err, Catch::Matchers::ContainsSubstring("negative")); + } +} + +TEST_CASE("helix as sweep path: spring integration test", "[CadDocument]") +{ + using Catch::Matchers::WithinRel; + + CadDocument doc; + + // Build a plane at the helix start (5,0,0) whose normal IS the start tangent direction. + // The helix tangent at u=0 is (0, R, P/(2*pi)) = (0, 5, 3/(2*pi)). + const double R = 5.0, P = 3.0; + Vec3d tan_dir(0, R, P / (2.0 * M_PI)); + tan_dir.normalize(); + Vec3d ref = (std::abs(tan_dir.z()) < 0.9) ? Vec3d(0, 0, 1) : Vec3d(1, 0, 0); + Vec3d x_axis = ref.cross(tan_dir); + if (x_axis.squaredNorm() < 1e-12) x_axis = Vec3d(1, 0, 0); + x_axis.normalize(); + Vec3d y_axis = tan_dir.cross(x_axis).normalized(); + SketchPlane profile_plane; + profile_plane.origin = Vec3d(R, 0, 0); + profile_plane.normal = tan_dir; + profile_plane.x_axis = x_axis; + profile_plane.y_axis = y_axis; + + // Profile: small circle r=1.5 centered at 2D (0,0) = world (5,0,0) = helix start + SketchEntity prof; + prof.type = SketchEntity::Type::Circle; + prof.center = Vec2d(0, 0); + prof.radius = 1.5; + int prof_idx = doc.add_sketch_entities({prof}, profile_plane, "CircleProfile"); + + // Helix path: r=5, pitch=3, height=15 (5 turns) about Z axis from origin + int helix_idx = doc.add_helix(SketchPlane::XY(), R, P, 15.0, false, 0.0, "HelixPath"); + + int sweep_idx = doc.add_sweep(prof_idx, helix_idx, BooleanMode::New, "Spring"); + REQUIRE(sweep_idx >= 0); + + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(doc.display_mesh.facets_count() > 0); + + const double v = double(doc.display_mesh.volume()); + REQUIRE(v > 0.0); + + const double one_turn = std::sqrt(std::pow(2.0 * M_PI * R, 2) + std::pow(P, 2)); + const double total_len = 5.0 * one_turn; + const double prof_area = M_PI * 1.5 * 1.5; + const double expected_v = prof_area * total_len; + REQUIRE_THAT(v, WithinRel(expected_v, 0.1)); +} + +TEST_CASE("helix serialization round-trip with distinctive values", "[CadDocument]") +{ + using Catch::Matchers::WithinAbs; + + CadDocument doc; + doc.add_helix(SketchPlane::XZ(), 7.5, 3.25, 22.0, true, 5.0, "Helix_RT"); + doc.features.back().helix_radius = 7.5; + doc.features.back().helix_pitch = 3.25; + doc.features.back().helix_height = 22.0; + doc.features.back().helix_left_handed = true; + doc.features.back().helix_taper_deg = 5.0; + + auto blob = doc.serialize_recipe(); + REQUIRE_FALSE(blob.empty()); + + // Deserialize into a feature list directly — recompute fails because a lone + // helix doesn't produce a solid, but the serialized field values must survive. + std::vector features2; + { + std::istringstream iss(blob); + cereal::BinaryInputArchive ar(iss); + uint32_t v; + ar(v); + ar(features2); + } + REQUIRE(features2.size() == 1); + + const auto& f = features2[0]; + REQUIRE(f.type == CadFeatureType::Helix); + REQUIRE(f.name == "Helix_RT"); + REQUIRE_THAT(f.helix_radius, WithinAbs(7.5, 1e-9)); + REQUIRE_THAT(f.helix_pitch, WithinAbs(3.25, 1e-9)); + REQUIRE_THAT(f.helix_height, WithinAbs(22.0, 1e-9)); + REQUIRE(f.helix_left_handed == true); + REQUIRE_THAT(f.helix_taper_deg, WithinAbs(5.0, 1e-9)); +} + +TEST_CASE("helix with sweep path from a non-sketch/non-helix feature errors", "[CadDocument]") +{ + // An Extrude feature used as sweep path must fail cleanly. + CadDocument doc; + + // Profile: circle + SketchEntity prof; + prof.type = SketchEntity::Type::Circle; + prof.center = Vec2d(0, 0); + prof.radius = 2.0; + int prof_idx = doc.add_sketch_entities({prof}, SketchPlane::XY(), "Profile"); + + // Path: an Extrude feature (not a Sketch or Helix) + CadFeature ex; + ex.type = CadFeatureType::Extrude; + ex.name = "NotAValidPath"; + ex.sketch_ref = -1; + doc.features.push_back(ex); + int path_idx = int(doc.features.size()) - 1; + + int sw = doc.add_sweep(prof_idx, path_idx, BooleanMode::New, "BadSweep"); + REQUIRE_FALSE(doc.recompute()); + REQUIRE_FALSE(doc.error.empty()); +} + // --- Golden recipe fixture (v1 format tripwire) --- static CadDocument make_golden_doc_v1() @@ -2362,6 +2651,12 @@ static CadDocument make_golden_doc_v1() doc.features[cs].coordsys_x_hint = Vec3d(0.5, 0.8, 0.3); } + // ---- Helix: conical left-handed with distinctive non-default values ---- + { + int hx = doc.add_helix(SketchPlane::XZ(), 11.5, 4.25, 18.0, true, 3.0, "Helix_CLH"); + (void)hx; + } + return doc; } @@ -2611,6 +2906,15 @@ TEST_CASE("golden recipe v1 still deserialises", "[CadDocument]") REQUIRE_THAT(f.coordsys_x_hint.y(), WithinAbs(0.8, 1e-9)); REQUIRE_THAT(f.coordsys_x_hint.z(), WithinAbs(0.3, 1e-9)); } + + // Helix + if (f.type == CadFeatureType::Helix && e.name == "Helix_CLH") { + REQUIRE_THAT(f.helix_radius, WithinAbs(11.5, 1e-9)); + REQUIRE_THAT(f.helix_pitch, WithinAbs(4.25, 1e-9)); + REQUIRE_THAT(f.helix_height, WithinAbs(18.0, 1e-9)); + REQUIRE(f.helix_left_handed == true); + REQUIRE_THAT(f.helix_taper_deg, WithinAbs(3.0, 1e-9)); + } } // --- Layer 2: geometry check (optional — only if the document recomputes) ---