CAD: helix / spiral curve, consumable as a Sweep path

Adds CadFeatureType::Helix — the missing input for Sweep. Sweep already
existed but could only follow a sketch, so springs, coils, augers and
non-standard-pitch threads were unreachable. Helix + the existing Sweep now
gives all of them with no further work.

Built the OCCT way: a 2D line on a Geom_CylindricalSurface (Geom_ConicalSurface
when helix_taper_deg != 0) turned into an edge and lifted to 3D with
BRepLib::BuildCurves3d — a true analytic helix, not a sampled polyline, so a
swept spring is smooth rather than faceted. The axis is the plane normal
through the plane origin, matching how Revolve and Plane already work.

Sweep's path resolution is widened to accept either a Sketch (unchanged
behaviour) or a Helix, and rejects anything else with a clear error. Helix
itself is skipped in route_feature and recompute — like the datum features, it
produces no body and exists to be consumed.

Invalid input is refused rather than approximated: non-positive radius or
pitch, negative height, a turn count above 10000 (which would hang OCCT), and
a taper that would drive the radius negative before reaching the top all fail
with a specific error.

Serialization: helix_radius/pitch/height/left_handed/taper_deg appended at the
very end of both save and load, identical order, after the coordsys block.
SNAPORCA_CAD_RECIPE_VERSION stays 2; Helix is appended to the end of
CadFeatureType. Golden fixture regenerated with distinctive literals and
field-value assertions; all pre-existing assertions pass unchanged.

Tests assert analytic values. The one that actually proves it is a helix and
not a circle or a spiral: arc length of r=5 pitch=2 height=10 measured with
BRepGProp::LinearProperties against 5*sqrt((2*pi*5)^2 + 2^2), WithinRel 1e-3.
Plus bounding box (2r in X and Y, height in Z), the conical top radius, the
left-handed winding compared at equal parameter, and the integration test:
a circle r=1.5 swept along a 5-turn helix gives one valid solid of ~1115 mm^3
(WithinRel 0.1 — pipe sweeping is not exact).

MCP: `helix` method registered in describe_tools().

Ported from snaporca 6cdc6b5459. Two fork-specific adjustments: the two new
error-message assertions use Catch2 v3's ContainsSubstring (v2's Contains does
not exist here), and the golden fixture is copied rather than regenerated
because this fork cannot be compiled locally — make_golden_doc_v1() is
byte-identical across both forks, so the blob is provably the same.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
This commit is contained in:
Tommaso Bianchi
2026-07-24 16:39:36 +02:00
co-authored by Claude Opus 4.8
parent 93e80bc407
commit 017bb08a1d
5 changed files with 435 additions and 10 deletions
+89 -7
View File
@@ -26,6 +26,7 @@
#include <BRepCheck_Analyzer.hxx>
#include <BRepLib.hxx>
#include <Geom_CylindricalSurface.hxx>
#include <Geom_ConicalSurface.hxx>
#include <Geom2d_TrimmedCurve.hxx>
#include <GCE2d_MakeSegment.hxx>
#include <TopoDS_Edge.hxx>
@@ -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<CadBody>& 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
+18 -3
View File
@@ -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<class Archive>
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<class Archive>
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<std::pair<std::string, SketchPlane>> resolve_datum_planes() const;
+24
View File
@@ -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"));
Binary file not shown.
+304
View File
@@ -13,6 +13,11 @@
#include <set>
#include <Bnd_Box.hxx>
#include <BRepBndLib.hxx>
#include <BRepAdaptor_Curve.hxx>
#include <TopExp_Explorer.hxx>
#include <TopoDS.hxx>
#include <BRepGProp.hxx>
#include <GProp_GProps.hxx>
#include <BRepPrimAPI_MakeBox.hxx>
#include <BRepBuilderAPI_Transform.hxx>
#include <gp_Trsf.hxx>
@@ -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<CadFeature> 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) ---