diff --git a/src/libslic3r/CadDocument.cpp b/src/libslic3r/CadDocument.cpp index ee85072f98..fecb31cca1 100644 --- a/src/libslic3r/CadDocument.cpp +++ b/src/libslic3r/CadDocument.cpp @@ -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& bodies, const CadFeature& f } } +void CadDocument::apply_transform(std::vector& 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& 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& 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; diff --git a/src/libslic3r/CadDocument.hpp b/src/libslic3r/CadDocument.hpp index 8ac347f878..47590622c6 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, 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 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 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& bodies, const CadFeature& f) const; void apply_cut(std::vector& bodies, const CadFeature& f) const; void apply_mirror(std::vector& bodies, const CadFeature& f) const; + void apply_transform(std::vector& 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 diff --git a/src/slic3r/GUI/McpControl.cpp b/src/slic3r/GUI/McpControl.cpp index 38030d570c..8d14f0d1fb 100644 --- a/src/slic3r/GUI/McpControl.cpp +++ b/src/slic3r/GUI/McpControl.cpp @@ -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)); diff --git a/tests/data/cad_recipe_v2.bin b/tests/data/cad_recipe_v2.bin index ee1ba677d5..d44a8b4554 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 167940384d..176facd06e 100644 --- a/tests/libslic3r/test_caddocument.cpp +++ b/tests/libslic3r/test_caddocument.cpp @@ -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> 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) ---