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"));