From 0100c95ed1c74b55814b6e824130749a0688b5c0 Mon Sep 17 00:00:00 2001 From: Tommaso Bianchi Date: Fri, 24 Jul 2026 17:50:08 +0200 Subject: [PATCH] =?UTF-8?q?CAD:=20Transform=20feature=20=E2=80=94=20move/r?= =?UTF-8?q?otate=20a=20body=20as=20a=20real=20B-rep=20operation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q --- src/libslic3r/CadDocument.cpp | 47 ++++++ src/libslic3r/CadDocument.hpp | 21 ++- src/slic3r/GUI/McpControl.cpp | 36 +++++ tests/data/cad_recipe_v2.bin | Bin 20255 -> 23204 bytes tests/libslic3r/test_caddocument.cpp | 232 +++++++++++++++++++++++++++ 5 files changed, 333 insertions(+), 3 deletions(-) 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 ee1ba677d58eea4b8e4bfd2463728f71ae9bc477..d44a8b4554e0b9f6a10a0383d5c35bbdd3c06e9c 100644 GIT binary patch delta 615 zcmbO~k8#OXMph;U1_tSktbds%*|JRbm)4rBAT2O?gR}5vCg$%LVn10i#XfLgiaqE4 zilO%o-)9W5^McfI%dsduduroBsK|1cqWLVWWAg)nAxk9|}AjjHIQS}Ze) zEAl%VmdU+0W)+0oWEd% zn2Xyd3^6lL%uv+!`NA~WNm>sU#nK|cxaS9A5OB}WNlDEMDN4*MPRlRK-7Lr^$T-n~ vYjcB(6yxLtn(KgSc^tqh8HAv;n8U=2Vv`eiIHZ5rLu6Q>GEkKbP7aI!;*Y&N delta 153 zcmZ3om2v(&Mph;U1_r5(tbds{2Qzs>c5#L@6i+5tf1}9ym^maB=hDpqaRF@EiHvNA2L;A z-fU@U%Dj2M4bTQ&hhI#SrB(Phr#rhdZ~pHFG}g`Q3)ALq-%mgj1ArEYgnVHF0AyP} Ac>n+a 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) ---