CAD: Transform feature — move/rotate a body as a real B-rep operation

Until now move and rotate lived only in the GUI as m_body_xform, a display
transform. That made them a correctness hole, not a missing tool: a moved body
recomputed and booleaned at its ORIGINAL position, and the move was not in the
recipe at all, so it vanished on save/reload. Only export_step consulted the
transform, which is why the discrepancy stayed hidden.

CadFeatureType::Transform makes it a real feature: rotate angle_deg about
xf_axis through xf_pivot, then translate by xf_translate, applied to the target
body with BRepBuilderAPI_Transform. xf_copy=true keeps the source and appends
the transformed body instead of mutating in place, which covers Onshape's
Transform/copy in the same feature.

Rotation is composed before translation (trsf = tr * rot) so the pivot means
what a user expects — the point the body turns about, not a point that then
drifts with the translation. A rotation with a degenerate axis is refused
rather than silently skipped; a zero angle skips the rotation entirely so a
pure move needs no axis at all.

The decisive test is not the bbox arithmetic but "moved body participates in a
later boolean at its new position": two coincident boxes, one moved to partial
overlap, fused. The fused volume must be strictly greater than one box (the
move took effect in the kernel) and strictly less than both (they still
intersect). With a display-only transform the first assertion fails.

Serialization stays append-only; recipe version unchanged at 2. Golden fixture
regenerated with a GoldenTransform feature carrying distinctive literals so a
field reorder shows up as obviously wrong values. Also fills in the Helix arm
of feature_type_name(), missing since the helix commit.

Kernel suite 51 -> 57 cases, 985 -> 1054 assertions, green.

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 17:50:08 +02:00
co-authored by Claude Opus 4.8
parent 017bb08a1d
commit 0100c95ed1
5 changed files with 333 additions and 3 deletions
+47
View File
@@ -736,6 +736,23 @@ int CadDocument::add_mirror(const SketchPlane& plane, int target_body, BooleanMo
return int(features.size()) - 1;
}
int CadDocument::add_transform(int target_body, const Vec3d& translate, const Vec3d& axis,
const Vec3d& pivot, double angle_deg, bool copy,
const std::string& name)
{
CadFeature f;
f.type = CadFeatureType::Transform;
f.name = name;
f.target_body = target_body;
f.xf_translate = translate;
f.xf_axis = axis;
f.xf_pivot = pivot;
f.xf_angle_deg = angle_deg;
f.xf_copy = copy;
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)
{
@@ -1972,6 +1989,35 @@ void CadDocument::apply_mirror(std::vector<CadBody>& bodies, const CadFeature& f
}
}
void CadDocument::apply_transform(std::vector<CadBody>& bodies, const CadFeature& f) const
{
const int nb = int(bodies.size());
if (nb == 0) throw std::runtime_error("transform: no target body");
const int tgt = (f.target_body >= 0 && f.target_body < nb) ? f.target_body : nb - 1;
if (tgt < 0 || bodies[tgt].shape.IsNull()) throw std::runtime_error("transform: no target body");
gp_Trsf rot;
if (std::abs(f.xf_angle_deg) > 1e-12) {
if (f.xf_axis.norm() < 1e-9)
throw std::runtime_error("transform: rotation axis is degenerate");
rot.SetRotation(gp_Ax1(gp_Pnt(f.xf_pivot.x(), f.xf_pivot.y(), f.xf_pivot.z()),
gp_Dir(f.xf_axis.x(), f.xf_axis.y(), f.xf_axis.z())),
f.xf_angle_deg * M_PI / 180.0);
}
gp_Trsf tr;
tr.SetTranslation(gp_Vec(f.xf_translate.x(), f.xf_translate.y(), f.xf_translate.z()));
const gp_Trsf trsf = tr * rot; // rotate first, then translate
BRepBuilderAPI_Transform xform(bodies[tgt].shape, trsf, true /*copy*/);
if (!xform.IsDone()) throw std::runtime_error("transform: failed");
TopoDS_Shape moved = xform.Shape();
if (f.xf_copy)
bodies.push_back({moved, f.name.empty() ? std::string("Transform") : f.name});
else
bodies[tgt].shape = moved;
}
void CadDocument::route_feature(std::vector<CadBody>& bodies, const CadFeature& f) const
{
if (f.type == CadFeatureType::Plane) return; // datum plane: not part of the body pipeline
@@ -1981,6 +2027,7 @@ void CadDocument::route_feature(std::vector<CadBody>& bodies, const CadFeature&
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
if (f.type == CadFeatureType::Transform) { apply_transform(bodies, f); return; } // move/rotate 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;
+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, Helix };
enum class CadFeatureType { Sketch, Extrude, Fillet, Chamfer, Hole, Thread, Shell, Revolve, Sweep, Pattern, Plane, Loft, Draft, Import, Boolean, Cut, Mirror, Axis, CoordSys, Helix, Transform };
enum class SketchShape { Rectangle, Circle };
enum class PlaneType { Offset, Angle, Midplane, Tangent, TwoEdges, Coincident };
enum class AxisType { TwoPoints, FaceNormal, CylinderCenterline, PlaneIntersection, AlongEdge };
@@ -238,6 +238,14 @@ struct CadFeature {
bool helix_left_handed{false};
double helix_taper_deg{0};
// Transform feature: rigid move/rotate of an existing body. Rotation is applied
// first (about xf_axis through xf_pivot), then the translation.
Vec3d xf_translate{0, 0, 0};
Vec3d xf_axis{0, 0, 1};
Vec3d xf_pivot{0, 0, 0};
double xf_angle_deg{0};
bool xf_copy{false}; // true: keep the original, append the moved copy as a new body
template<class Archive>
void save(Archive& ar) const {
std::string brep = (type == CadFeatureType::Import) ? brep_to_string(imported_solid) : std::string();
@@ -263,7 +271,8 @@ struct CadFeature {
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,
helix_radius, helix_pitch, helix_height, helix_left_handed, helix_taper_deg);
helix_radius, helix_pitch, helix_height, helix_left_handed, helix_taper_deg,
xf_translate, xf_axis, xf_pivot, xf_angle_deg, xf_copy);
}
template<class Archive>
void load(Archive& ar) {
@@ -290,7 +299,8 @@ struct CadFeature {
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,
helix_radius, helix_pitch, helix_height, helix_left_handed, helix_taper_deg);
helix_radius, helix_pitch, helix_height, helix_left_handed, helix_taper_deg,
xf_translate, xf_axis, xf_pivot, xf_angle_deg, xf_copy);
imported_solid = brep_from_string(brep);
}
};
@@ -392,6 +402,10 @@ public:
bool keep_upper, bool keep_lower, int target_body, const std::string& name);
int add_mirror(const SketchPlane& plane, int target_body, BooleanMode mode,
const std::string& name);
// Rigid body transform: rotate `angle_deg` about `axis` through `pivot`, then translate.
// copy=true keeps the source body and appends the transformed one as a new body.
int add_transform(int target_body, const Vec3d& translate, const Vec3d& axis,
const Vec3d& pivot, double angle_deg, bool copy, const std::string& name);
// Datum plane: derived from base (0=XY/1=XZ/2=YZ/3+N=Nth earlier datum), offset
// along its normal, optional tilt about a base axis. Produces no solid.
int add_plane(int base, double offset, double angle_tilt, int axis,
@@ -498,6 +512,7 @@ private:
void apply_boolean(std::vector<CadBody>& bodies, const CadFeature& f) const;
void apply_cut(std::vector<CadBody>& bodies, const CadFeature& f) const;
void apply_mirror(std::vector<CadBody>& bodies, const CadFeature& f) const;
void apply_transform(std::vector<CadBody>& bodies, const 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
+36
View File
@@ -73,6 +73,8 @@ const char* feature_type_name(CadFeatureType t)
case CadFeatureType::Cut: return "Cut";
case CadFeatureType::Axis: return "Axis";
case CadFeatureType::CoordSys: return "CoordSys";
case CadFeatureType::Helix: return "Helix";
case CadFeatureType::Transform: return "Transform";
}
return "Unknown";
}
@@ -186,6 +188,21 @@ json describe_tools()
json{{"name", "keep_original"},{"type", "boolean"}, {"default", true}, {"description", "when mode=new, keep the source body"}},
json{{"name", "body"}, {"type", "integer"}, {"default", -1}, {"description", "target body; omit for the last body"}},
})}},
json{{"name", "transform"}, {"summary", "Move and/or rotate a body (B-rep transform). copy=true keeps the source and appends the transformed copy as a new body."},
{"params", json::array({
json{{"name", "body"}, {"type", "integer"}, {"default", -1}, {"description", "target body; omit for the last body"}},
json{{"name", "dx"}, {"type", "number"}, {"unit", "mm"}, {"default", 0}},
json{{"name", "dy"}, {"type", "number"}, {"unit", "mm"}, {"default", 0}},
json{{"name", "dz"}, {"type", "number"}, {"unit", "mm"}, {"default", 0}},
json{{"name", "axis_x"}, {"type", "number"}, {"default", 0}},
json{{"name", "axis_y"}, {"type", "number"}, {"default", 0}},
json{{"name", "axis_z"}, {"type", "number"}, {"default", 1}},
json{{"name", "pivot_x"}, {"type", "number"}, {"unit", "mm"}, {"default", 0}},
json{{"name", "pivot_y"}, {"type", "number"}, {"unit", "mm"}, {"default", 0}},
json{{"name", "pivot_z"}, {"type", "number"}, {"unit", "mm"}, {"default", 0}},
json{{"name", "angle"}, {"type", "number"}, {"unit", "deg"}, {"default", 0}},
json{{"name", "copy"}, {"type", "boolean"}, {"default", false}},
})}},
json{{"name", "axis"}, {"summary", "Create a datum axis (reference line): two points, face normal, cylinder centreline, plane intersection, or along edge."},
{"params", json::array({
json{{"name", "type"}, {"type", "string"}, {"enum", json::array({"two_points", "face_normal", "cylinder", "plane_intersection", "along_edge"})}, {"default", "two_points"}},
@@ -846,6 +863,24 @@ json action_mirror(DesignPanel* panel, const json& params)
return json{{"ok", ok}, {"mirror_index", idx}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}};
}
json action_transform(DesignPanel* panel, const json& params)
{
CadDocument& doc = panel->mcp_doc();
if (doc.bodies.empty()) throw std::runtime_error("no body to transform");
int bi = target_body_arg(params, doc);
Vec3d translate(params.value("dx", 0.0), params.value("dy", 0.0), params.value("dz", 0.0));
Vec3d axis(params.value("axis_x", 0.0), params.value("axis_y", 0.0), params.value("axis_z", 1.0));
Vec3d pivot(params.value("pivot_x", 0.0), params.value("pivot_y", 0.0), params.value("pivot_z", 0.0));
double angle = params.value("angle", 0.0);
bool copy = params.value("copy", false);
doc.checkpoint();
int idx = doc.add_transform(bi, translate, axis, pivot, angle, copy, "Transform");
bool ok = doc.recompute();
if (!ok) doc.undo();
panel->mcp_after_change();
return json{{"ok", ok}, {"transform_index", idx}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}};
}
json action_axis(DesignPanel* panel, const json& params)
{
CadDocument& doc = panel->mcp_doc();
@@ -944,6 +979,7 @@ std::string handle_on_main(const std::string& method, const json& params, const
if (method == "shell") return rpc_result(id, action_shell(panel, params));
if (method == "draft") return rpc_result(id, action_draft(panel, params));
if (method == "mirror") return rpc_result(id, action_mirror(panel, params));
if (method == "transform") return rpc_result(id, action_transform(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));