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));
Binary file not shown.
+232
View File
@@ -2502,6 +2502,219 @@ TEST_CASE("helix with sweep path from a non-sketch/non-helix feature errors", "[
REQUIRE_FALSE(doc.error.empty());
}
TEST_CASE("transform translate shifts body bbox by the given vector", "[CadDocument]")
{
using Catch::Matchers::WithinAbs;
using Catch::Matchers::WithinRel;
CadDocument doc;
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "S");
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Box");
REQUIRE(doc.recompute());
Bnd_Box bb0;
BRepBndLib::Add(doc.bodies[0].shape, bb0);
double xmin0, ymin0, zmin0, xmax0, ymax0, zmax0;
bb0.Get(xmin0, ymin0, zmin0, xmax0, ymax0, zmax0);
double vol0 = double(SketchEngine::tessellate(doc.bodies[0].shape).volume());
doc.checkpoint();
doc.add_transform(0, Vec3d(5, 0, 0), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 0, false, "Move");
REQUIRE(doc.recompute());
REQUIRE(doc.bodies.size() == 1);
double vol1 = double(SketchEngine::tessellate(doc.bodies[0].shape).volume());
REQUIRE_THAT(vol1, WithinRel(vol0, 1e-6));
Bnd_Box bb1;
BRepBndLib::Add(doc.bodies[0].shape, bb1);
double xmin1, ymin1, zmin1, xmax1, ymax1, zmax1;
bb1.Get(xmin1, ymin1, zmin1, xmax1, ymax1, zmax1);
REQUIRE_THAT(xmin1, WithinAbs(xmin0 + 5, 1e-6));
REQUIRE_THAT(xmax1, WithinAbs(xmax0 + 5, 1e-6));
}
TEST_CASE("transform rotate 90 about Z swaps XY bbox extents", "[CadDocument]")
{
using Catch::Matchers::WithinAbs;
using Catch::Matchers::WithinRel;
CadDocument doc;
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "S");
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Box");
REQUIRE(doc.recompute());
Bnd_Box bb0;
BRepBndLib::Add(doc.bodies[0].shape, bb0);
double xmin0, ymin0, zmin0, xmax0, ymax0, zmax0;
bb0.Get(xmin0, ymin0, zmin0, xmax0, ymax0, zmax0);
double vol0 = double(SketchEngine::tessellate(doc.bodies[0].shape).volume());
double dx0 = xmax0 - xmin0;
double dy0 = ymax0 - ymin0;
doc.checkpoint();
doc.add_transform(0, Vec3d(0, 0, 0), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 90, false, "Rot90");
REQUIRE(doc.recompute());
double vol1 = double(SketchEngine::tessellate(doc.bodies[0].shape).volume());
REQUIRE_THAT(vol1, WithinRel(vol0, 1e-6));
Bnd_Box bb1;
BRepBndLib::Add(doc.bodies[0].shape, bb1);
double xmin1, ymin1, zmin1, xmax1, ymax1, zmax1;
bb1.Get(xmin1, ymin1, zmin1, xmax1, ymax1, zmax1);
double dx1 = xmax1 - xmin1;
double dy1 = ymax1 - ymin1;
REQUIRE_THAT(dx1, WithinAbs(dy0, 1e-6));
REQUIRE_THAT(dy1, WithinAbs(dx0, 1e-6));
}
TEST_CASE("transform copy keeps original and appends transformed body", "[CadDocument]")
{
using Catch::Matchers::WithinAbs;
CadDocument doc;
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "S");
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Box");
REQUIRE(doc.recompute());
Bnd_Box bb0;
BRepBndLib::Add(doc.bodies[0].shape, bb0);
double xmin0, ymin0, zmin0, xmax0, ymax0, zmax0;
bb0.Get(xmin0, ymin0, zmin0, xmax0, ymax0, zmax0);
doc.checkpoint();
doc.add_transform(0, Vec3d(5, 0, 0), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 0, true, "Copy");
REQUIRE(doc.recompute());
REQUIRE(doc.bodies.size() == 2);
Bnd_Box bb_orig;
BRepBndLib::Add(doc.bodies[0].shape, bb_orig);
double xo_min, yo_min, zo_min, xo_max, yo_max, zo_max;
bb_orig.Get(xo_min, yo_min, zo_min, xo_max, yo_max, zo_max);
REQUIRE_THAT(xo_min, WithinAbs(xmin0, 1e-6));
REQUIRE_THAT(xo_max, WithinAbs(xmax0, 1e-6));
Bnd_Box bb_copy;
BRepBndLib::Add(doc.bodies[1].shape, bb_copy);
double xc_min, yc_min, zc_min, xc_max, yc_max, zc_max;
bb_copy.Get(xc_min, yc_min, zc_min, xc_max, yc_max, zc_max);
REQUIRE_THAT(xc_min, WithinAbs(xmin0 + 5, 1e-6));
REQUIRE_THAT(xc_max, WithinAbs(xmax0 + 5, 1e-6));
}
TEST_CASE("transform degenerate axis errors", "[CadDocument]")
{
CadDocument doc;
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "S");
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Box");
REQUIRE(doc.recompute());
doc.checkpoint();
doc.add_transform(0, Vec3d(0, 0, 0), Vec3d(0, 0, 0), Vec3d(0, 0, 0), 45, false, "Bad");
REQUIRE_FALSE(doc.recompute());
REQUIRE(doc.error.find("axis") != std::string::npos);
}
TEST_CASE("transform moved body participates in later boolean at its new position", "[CadDocument]")
{
using Catch::Matchers::WithinAbs;
CadDocument doc;
// Two boxes that do NOT overlap
int sk0 = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 5, "S0");
doc.add_extrude(sk0, 5.0, false, BooleanMode::New, "Box0");
int sk1 = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 5, "S1");
doc.add_extrude(sk1, 5.0, false, BooleanMode::New, "Box1");
doc.features.back().target_body = 0;
REQUIRE(doc.recompute());
double v0 = double(SketchEngine::tessellate(doc.bodies[0].shape).volume());
double v1 = double(SketchEngine::tessellate(doc.bodies[1].shape).volume());
double sum = v0 + v1;
// Move body 1 so it overlaps body 0
doc.checkpoint();
doc.add_transform(1, Vec3d(5, 5, 0), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 0, false, "Move1");
REQUIRE(doc.recompute());
doc.add_boolean(BooleanMode::Add, 0, 1, false, 0.0, -1, -1, "Fuse");
REQUIRE(doc.recompute());
REQUIRE(doc.bodies.size() == 1);
double vf = double(SketchEngine::tessellate(doc.bodies[0].shape).volume());
// Partial overlap: strictly more than one box (the move DID take effect) and strictly
// less than both (they still intersect). Without a real B-rep transform the two boxes
// stay coincident and vf would equal v0 -- that is what `vf > v0` catches.
REQUIRE(vf > v0 * 1.05);
REQUIRE(vf < sum);
}
TEST_CASE("transform round-trip preserves all xf_* fields", "[CadDocument]")
{
using Catch::Matchers::WithinAbs;
CadDocument doc;
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "S");
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Box");
doc.add_transform(0, Vec3d(3, 4, 5), Vec3d(0, 1, 0), Vec3d(1, 2, 3), 37, true, "RoundTrip");
REQUIRE(doc.recompute());
int nb = int(doc.bodies.size());
REQUIRE(nb >= 1);
// Capture bboxes before serialization
std::vector<std::pair<Vec3d, Vec3d>> bboxes_before;
for (int i = 0; i < nb; ++i) {
Bnd_Box bb;
BRepBndLib::Add(doc.bodies[i].shape, bb);
double x0, y0, z0, x1, y1, z1;
bb.Get(x0, y0, z0, x1, y1, z1);
bboxes_before.push_back({Vec3d(x0, y0, z0), Vec3d(x1, y1, z1)});
}
auto blob = doc.serialize_recipe();
CadDocument doc2;
REQUIRE(doc2.deserialize_recipe(blob));
REQUIRE(doc2.bodies.size() == size_t(nb));
// Check the Transform feature fields
bool found = false;
for (const auto& f : doc2.features) {
if (f.type != CadFeatureType::Transform) continue;
REQUIRE_THAT(f.xf_translate.x(), WithinAbs(3, 1e-9));
REQUIRE_THAT(f.xf_translate.y(), WithinAbs(4, 1e-9));
REQUIRE_THAT(f.xf_translate.z(), WithinAbs(5, 1e-9));
REQUIRE_THAT(f.xf_axis.x(), WithinAbs(0, 1e-9));
REQUIRE_THAT(f.xf_axis.y(), WithinAbs(1, 1e-9));
REQUIRE_THAT(f.xf_axis.z(), WithinAbs(0, 1e-9));
REQUIRE_THAT(f.xf_pivot.x(), WithinAbs(1, 1e-9));
REQUIRE_THAT(f.xf_pivot.y(), WithinAbs(2, 1e-9));
REQUIRE_THAT(f.xf_pivot.z(), WithinAbs(3, 1e-9));
REQUIRE_THAT(f.xf_angle_deg, WithinAbs(37, 1e-9));
REQUIRE(f.xf_copy == true);
found = true;
break;
}
REQUIRE(found);
// Verify bboxes equal
for (int i = 0; i < nb; ++i) {
Bnd_Box bb;
BRepBndLib::Add(doc2.bodies[i].shape, bb);
double x0, y0, z0, x1, y1, z1;
bb.Get(x0, y0, z0, x1, y1, z1);
REQUIRE_THAT(x0, WithinAbs(bboxes_before[i].first.x(), 1e-6));
REQUIRE_THAT(y0, WithinAbs(bboxes_before[i].first.y(), 1e-6));
REQUIRE_THAT(z0, WithinAbs(bboxes_before[i].first.z(), 1e-6));
REQUIRE_THAT(x1, WithinAbs(bboxes_before[i].second.x(), 1e-6));
REQUIRE_THAT(y1, WithinAbs(bboxes_before[i].second.y(), 1e-6));
REQUIRE_THAT(z1, WithinAbs(bboxes_before[i].second.z(), 1e-6));
}
}
// --- Golden recipe fixture (v1 format tripwire) ---
static CadDocument make_golden_doc_v1()
@@ -2657,6 +2870,10 @@ static CadDocument make_golden_doc_v1()
(void)hx;
}
// ---- Transform: rigid move/rotate with distinctive non-default values ----
doc.add_transform(0, Vec3d(3.5, 4.5, 5.5), Vec3d(0.0, 1.0, 0.0), Vec3d(1.5, 2.5, 3.5),
37.0, true, "GoldenTransform");
return doc;
}
@@ -2915,6 +3132,21 @@ TEST_CASE("golden recipe v1 still deserialises", "[CadDocument]")
REQUIRE(f.helix_left_handed == true);
REQUIRE_THAT(f.helix_taper_deg, WithinAbs(3.0, 1e-9));
}
// Transform
if (f.type == CadFeatureType::Transform && e.name == "GoldenTransform") {
REQUIRE_THAT(f.xf_translate.x(), WithinAbs(3.5, 1e-9));
REQUIRE_THAT(f.xf_translate.y(), WithinAbs(4.5, 1e-9));
REQUIRE_THAT(f.xf_translate.z(), WithinAbs(5.5, 1e-9));
REQUIRE_THAT(f.xf_axis.x(), WithinAbs(0.0, 1e-9));
REQUIRE_THAT(f.xf_axis.y(), WithinAbs(1.0, 1e-9));
REQUIRE_THAT(f.xf_axis.z(), WithinAbs(0.0, 1e-9));
REQUIRE_THAT(f.xf_pivot.x(), WithinAbs(1.5, 1e-9));
REQUIRE_THAT(f.xf_pivot.y(), WithinAbs(2.5, 1e-9));
REQUIRE_THAT(f.xf_pivot.z(), WithinAbs(3.5, 1e-9));
REQUIRE_THAT(f.xf_angle_deg, WithinAbs(37.0, 1e-9));
REQUIRE(f.xf_copy == true);
}
}
// --- Layer 2: geometry check (optional — only if the document recomputes) ---