M8b: Revolute / Slider / Cylindrical mates

Each kind constrains the DOFs it owns and PRESERVES the rest from the body's
current pose, following the pattern Planar established in M8a. Resolved instead
as "Fastened with a parameter", all three would have been geometrically
identical to Fastened — relabelling rather than behaviour.

  Revolute     fixes position on the axis line; rotation about it survives
  Slider       fixes orientation and perpendicular position; axial position survives
  Cylindrical  fixes the axis line only; rotation and axial position both survive

No new serialized fields, no recipe bump, no fixture regeneration: mate_kind is
already an int and mate_offset / mate_angle already exist.

The minimum-rotation z-alignment (including the antiparallel 180 deg case fixed
in M8a) is now a shared make_z_align lambda rather than a second copy.

Fixes a rotation-about-pivot bug found by the no-op tests: R_full was built as a
rotation about the origin with a translation to oB appended, instead of a proper
rotation about oB (translation = oB - R*oB). It moved bodies that were already
correctly placed, and accounted for three of the seven initially failing cases.

Testing notes, both of which cost real debugging time here:

- Mates are defined on connector FRAMES, but the convenient thing to measure is
  CentreOfMass(), and the two coincide only when the body is symmetric about its
  connector. Five expectations in this milestone asserted the centroid while
  meaning the connector. These tests assert on the mated face's centroid.

- A CoordSys built from a face ALONE takes its z from the face normal (which
  follows the body) but its x from coordsys_x_hint, a world constant. Such a
  frame cannot see rotation about its own normal, so no mate can correct or
  preserve a spin it does not encode. The Slider and Cylindrical rotation tests
  pin coordsys_edge to an edge of their own body; without that both passed
  vacuously, one of them for a wrong implementation.

The Cylindrical rotation test was verified to fail when its mate kind is mutated
to Slider, and the Slider test failed at axis_aligned == 2 before the connectors
were edge-pinned. Neither is green by accident.

Known wart: mate_angle is silently ignored for Slider, whose rotation is fully
constrained. Defensible but undiagnosed at the API surface.

Suite 134 cases / 1927 assertions green. McpControl.cpp is reviewed but not
compiled by kernel-test.sh, which builds only libslic3r_tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tommaso Bianchi
2026-07-25 11:30:37 +02:00
co-authored by Claude Opus 5
parent b13ca01ccc
commit 6a0031c9e5
3 changed files with 837 additions and 41 deletions
+92 -39
View File
@@ -2951,48 +2951,101 @@ void CadDocument::apply_mate(std::vector<CadBody>& bodies, const CadFeature& f)
gp_Trsf M_B_inv = M_B.Inverted();
T = M_A * Rz * Tz * F * M_B_inv;
} else {
// Planar (mate_kind == 1): align normals only, preserve in-plane pose
Vec3d z_target = f.mate_flip ? -zA : zA;
double ddot = zB.dot(z_target);
Vec3d rot_axis;
double rot_angle = 0;
if (ddot <= -0.9999) {
// Anti-parallel: 180° rotation about any axis perpendicular to zB
Vec3d ref = (std::abs(zB.z()) < 0.9) ? Vec3d(0, 0, 1) : Vec3d(1, 0, 0);
rot_axis = zB.cross(ref).normalized();
rot_angle = M_PI;
} else {
rot_axis = zB.cross(z_target);
if (rot_axis.squaredNorm() > 1e-18) {
rot_axis.normalize();
rot_angle = std::acos(std::max(-1.0, std::min(1.0, ddot)));
// Minimum-rotation helper: compute R that rotates zB onto z_target
// about an axis through oB. Reused by Planar / Revolute / Cylindrical.
auto make_z_align = [&](const Vec3d& zsrc, const Vec3d& zdst) -> gp_Trsf {
double ddot = zsrc.dot(zdst);
Vec3d rot_axis;
double rot_angle = 0;
if (ddot <= -0.9999) {
Vec3d ref = (std::abs(zsrc.z()) < 0.9) ? Vec3d(0, 0, 1) : Vec3d(1, 0, 0);
rot_axis = zsrc.cross(ref).normalized();
rot_angle = M_PI;
} else {
rot_axis = zsrc.cross(zdst);
if (rot_axis.squaredNorm() > 1e-18) {
rot_axis.normalize();
rot_angle = std::acos(std::max(-1.0, std::min(1.0, ddot)));
}
}
gp_Trsf R;
if (rot_angle > 1e-12) {
R.SetRotation(gp_Ax1(gp_Pnt(oB.x(), oB.y(), oB.z()),
gp_Dir(rot_axis.x(), rot_axis.y(), rot_axis.z())),
rot_angle);
}
return R;
};
if (f.mate_kind == 1 || f.mate_kind == 2 || f.mate_kind == 4) {
// Planar (1) / Revolute (2) / Cylindrical (4):
// all share the same minimum-rotation z-alignment.
gp_Trsf R_align = make_z_align(zB, z_target);
gp_Trsf Rz_about_target;
if (std::abs(f.mate_angle) > 1e-12) {
Rz_about_target.SetRotation(
gp_Ax1(gp_Pnt(oB.x(), oB.y(), oB.z()),
gp_Dir(z_target.x(), z_target.y(), z_target.z())),
f.mate_angle * M_PI / 180.0);
}
gp_Trsf R = Rz_about_target * R_align;
// Translation: depends on which DOFs are constrained
Vec3d trans;
if (f.mate_kind == 1) {
// Planar: normal distance becomes mate_offset
double d = (oB - oA).dot(zA);
trans = zA * (f.mate_offset - d);
} else if (f.mate_kind == 2) {
// Revolute: full position on the axis line
trans = (oA + zA * f.mate_offset) - oB;
} else {
// Cylindrical (4): fix perpendicular, preserve axial
double axial = (oB - oA).dot(zA);
trans = (oA + zA * (axial + f.mate_offset)) - oB;
}
T.SetValues(1, 0, 0, trans.x(),
0, 1, 0, trans.y(),
0, 0, 1, trans.z());
T = T * R;
} else {
// Slider (mate_kind == 3): full orientation alignment,
// fix perpendicular position, preserve axial translation.
Vec3d x_target = xA;
Vec3d y_target = f.mate_flip ? -yA : yA;
// R = target * B^T (both bases orthonormal)
double r11 = x_target.x() * xB.x() + y_target.x() * yB.x() + z_target.x() * zB.x();
double r12 = x_target.x() * xB.y() + y_target.x() * yB.y() + z_target.x() * zB.y();
double r13 = x_target.x() * xB.z() + y_target.x() * yB.z() + z_target.x() * zB.z();
double r21 = x_target.y() * xB.x() + y_target.y() * yB.x() + z_target.y() * zB.x();
double r22 = x_target.y() * xB.y() + y_target.y() * yB.y() + z_target.y() * zB.y();
double r23 = x_target.y() * xB.z() + y_target.y() * yB.z() + z_target.y() * zB.z();
double r31 = x_target.z() * xB.x() + y_target.z() * yB.x() + z_target.z() * zB.x();
double r32 = x_target.z() * xB.y() + y_target.z() * yB.y() + z_target.z() * zB.y();
double r33 = x_target.z() * xB.z() + y_target.z() * yB.z() + z_target.z() * zB.z();
// Build rotation about oB: R_full * p = R * (p - oB) + oB
double tx = oB.x() - (r11 * oB.x() + r12 * oB.y() + r13 * oB.z());
double ty = oB.y() - (r21 * oB.x() + r22 * oB.y() + r23 * oB.z());
double tz = oB.z() - (r31 * oB.x() + r32 * oB.y() + r33 * oB.z());
gp_Trsf R_full;
R_full.SetValues(r11, r12, r13, tx,
r21, r22, r23, ty,
r31, r32, r33, tz);
double axial = (oB - oA).dot(zA);
Vec3d trans = (oA + zA * (axial + f.mate_offset)) - oB;
T.SetValues(1, 0, 0, trans.x(),
0, 1, 0, trans.y(),
0, 0, 1, trans.z());
T = T * R_full;
}
gp_Trsf R_align;
if (rot_angle > 1e-12) {
R_align.SetRotation(gp_Ax1(gp_Pnt(oB.x(), oB.y(), oB.z()),
gp_Dir(rot_axis.x(), rot_axis.y(), rot_axis.z())),
rot_angle);
}
gp_Trsf Rz_about_target;
if (std::abs(f.mate_angle) > 1e-12) {
Rz_about_target.SetRotation(
gp_Ax1(gp_Pnt(oB.x(), oB.y(), oB.z()),
gp_Dir(z_target.x(), z_target.y(), z_target.z())),
f.mate_angle * M_PI / 180.0);
}
gp_Trsf R = Rz_about_target * R_align;
double d = (oB - oA).dot(zA);
Vec3d offset_vec = zA * (f.mate_offset - d);
T.SetValues(1, 0, 0, offset_vec.x(),
0, 1, 0, offset_vec.y(),
0, 0, 1, offset_vec.z());
T = T * R;
}
BRepBuilderAPI_Transform xform(bodies[tgt_body].shape, T, true /*copy*/);
+2 -2
View File
@@ -330,9 +330,9 @@ json describe_tools()
json{{"name", "angle"}, {"type", "number"}, {"unit", "deg"}, {"default", 360}},
json{{"name", "axis"}, {"type", "integer"}, {"enum", json::array({0, 1})}, {"default", 0}},
})}},
json{{"name", "mate"}, {"summary", "Mate two bodies: transform the moving body (cs_b) so its connector lands on the fixed one (cs_a). kind: 0=Fastened, 1=Planar."},
json{{"name", "mate"}, {"summary", "Mate two bodies: transform the moving body (cs_b) so its connector lands on the fixed one (cs_a). kind: 0=Fastened, 1=Planar, 2=Revolute, 3=Slider, 4=Cylindrical."},
{"params", json::array({
json{{"name", "kind"}, {"type", "integer"}, {"default", 0}, {"description", "0=Fastened (full align), 1=Planar (normal only)"}},
json{{"name", "kind"}, {"type", "integer"}, {"default", 0}, {"description", "0=Fastened (rigid), 1=Planar (normal only), 2=Revolute (free rotation about axis), 3=Slider (free translation along axis), 4=Cylindrical (free rotation+translation)"}},
json{{"name", "cs_a"}, {"type", "integer"}, {"description", "feature index of the fixed CoordSys (mate connector A)"}},
json{{"name", "cs_b"}, {"type", "integer"}, {"description", "feature index of the CoordSys on the body that moves"}},
json{{"name", "offset"}, {"type", "number"}, {"unit", "mm"}, {"default", 0}},
+743
View File
@@ -5284,6 +5284,28 @@ static int find_face_by_normal(const CadDocument& doc, int body_idx, const Vec3d
return -1;
}
// Global edge index of the first edge belonging to `face_idx`.
//
// A CoordSys built from a face alone takes its z from the face normal (which follows the
// body) but its x from coordsys_x_hint, a WORLD constant. Such a frame is only half
// body-following: the body's rotation about the face normal is invisible to it, so no mate
// can correct or preserve a spin the connector cannot see. Pinning coordsys_edge to an edge
// of the body makes the in-plane direction follow the body too, which is what any test
// distinguishing Slider (corrects spin) from Cylindrical (preserves it) requires.
static int find_edge_on_face(const CadDocument& doc, int body_idx, int face_idx)
{
TopoDS_Face f = GeometryEngine::face_by_index(doc.bodies[body_idx].shape, face_idx);
if (f.IsNull()) return -1;
auto face_edges = GeometryEngine::edges_of_face(f);
auto all_edges = GeometryEngine::edges_of(doc.bodies[body_idx].shape);
for (int ei = 0; ei < int(all_edges.size()); ++ei) {
for (const auto& fe : face_edges) {
if (all_edges[ei].IsSame(fe)) return ei;
}
}
return -1;
}
TEST_CASE("planar mate: antiparallel normals with asymmetric body", "[CadDocument][mate]")
{
using Catch::Matchers::WithinAbs;
@@ -5486,4 +5508,725 @@ TEST_CASE("fastened mate with flip on asymmetric body", "[CadDocument][mate]")
// Flip changes the centroid Z for an asymmetric body
REQUIRE_THAT(f_z, !WithinAbs(nf_z, 1e-4));
}
// --- M8b: Revolute / Slider / Cylindrical mates ---
TEST_CASE("revolute mate: position corrected, rotation about axis preserved", "[CadDocument][mate]")
{
using Catch::Matchers::WithinAbs;
CadDocument doc;
// Body A: reference box
int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA");
doc.add_extrude(sk_a, 5.0, false, BooleanMode::New, "EA");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
// Body B: asymmetric box 20x10x5
int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 10, 0, "BoxB");
doc.add_extrude(sk_b, 5.0, false, BooleanMode::New, "EB");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
REQUIRE(doc.bodies.size() == 2);
// Pre-rotate B 30deg about Z, translate off-axis to (15, 2, 7)
doc.add_transform(1, Vec3d(15, 2, 7), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 30.0, false, "PrePose");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
// Record rotation: body B has a face that was originally +X, now at 30deg
auto faces_pre = GeometryEngine::faces_of(doc.bodies[1].shape);
REQUIRE(faces_pre.size() == 6);
Vec3d pre_x_face_normal;
bool found = false;
for (const auto& f : faces_pre) {
Vec3d n = GeometryEngine::face_normal_world(f);
// The face that was originally +X now points ~(cos30, sin30, 0)
if (std::abs(n.x() - 0.866) < 0.02 && std::abs(n.y() - 0.5) < 0.02) {
pre_x_face_normal = n; found = true; break;
}
}
REQUIRE(found);
// Fixed connector on A at (5,5,5), world axes
int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Fixed");
doc.features[cs_fixed].coordsys_body = 0;
// Moving connector on B: PointWorld at its current (transformed) centroid
int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(15, 2, 7), "CS_Moving");
doc.features[cs_moving].coordsys_body = 1;
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
doc.add_mate(2, cs_fixed, cs_moving, 0.0, 0.0, false, "Revolute");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
// Position corrected: body centroid moves to the axis line.
// Connector B (15,2,7) → (5,5,5); body centroid (15,2,9.5) → (5,5,7.5).
GProp_GProps props;
BRepGProp::VolumeProperties(doc.bodies[1].shape, props);
gp_Pnt com = props.CentreOfMass();
REQUIRE_THAT(double(com.X()), WithinAbs(5.0, 1e-4));
REQUIRE_THAT(double(com.Y()), WithinAbs(5.0, 1e-4));
REQUIRE_THAT(double(com.Z()), WithinAbs(7.5, 1e-4));
// Rotation about Z preserved: the ~(0.866, 0.5, 0) face normal still exists
auto faces_post = GeometryEngine::faces_of(doc.bodies[1].shape);
REQUIRE(faces_post.size() == 6);
bool rot_preserved = false;
for (const auto& f : faces_post) {
Vec3d n = GeometryEngine::face_normal_world(f);
if (std::abs(n.x() - 0.866) < 0.02 && std::abs(n.y() - 0.5) < 0.02) {
rot_preserved = true; break;
}
}
REQUIRE(rot_preserved);
// Fastened would have aligned face normals to world axes: no face with Y≈0.5 would exist.
}
TEST_CASE("revolute mate: no-op when already correct", "[CadDocument][mate]")
{
using Catch::Matchers::WithinAbs;
CadDocument doc;
int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA");
doc.add_extrude(sk_a, 5.0, false, BooleanMode::New, "EA");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "BoxB");
doc.add_extrude(sk_b, 5.0, false, BooleanMode::New, "EB");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
// Move body B to exactly the correct pose: on axis at (5,5,5) with no rotation
doc.add_transform(1, Vec3d(5, 5, 5), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 0.0, false, "PrePose");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
GProp_GProps pre_props;
BRepGProp::VolumeProperties(doc.bodies[1].shape, pre_props);
gp_Pnt pre_com = pre_props.CentreOfMass();
int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Fixed");
doc.features[cs_fixed].coordsys_body = 0;
int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Moving");
doc.features[cs_moving].coordsys_body = 1;
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
doc.add_mate(2, cs_fixed, cs_moving, 0.0, 0.0, false, "RevoluteNoOp");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
GProp_GProps post_props;
BRepGProp::VolumeProperties(doc.bodies[1].shape, post_props);
gp_Pnt post_com = post_props.CentreOfMass();
REQUIRE_THAT(double(post_com.X()), WithinAbs(double(pre_com.X()), 1e-4));
REQUIRE_THAT(double(post_com.Y()), WithinAbs(double(pre_com.Y()), 1e-4));
REQUIRE_THAT(double(post_com.Z()), WithinAbs(double(pre_com.Z()), 1e-4));
}
TEST_CASE("revolute mate: mate_angle applies additional rotation", "[CadDocument][mate]")
{
using Catch::Matchers::WithinAbs;
CadDocument doc;
int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA");
doc.add_extrude(sk_a, 5.0, false, BooleanMode::New, "EA");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
// Body B: asymmetric box, pre-rotated 30deg about Z, off-axis
int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 10, 0, "BoxB");
doc.add_extrude(sk_b, 5.0, false, BooleanMode::New, "EB");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
doc.add_transform(1, Vec3d(15, 2, 7), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 30.0, false, "PrePose");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Fixed");
doc.features[cs_fixed].coordsys_body = 0;
int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(15, 2, 7), "CS_Moving");
doc.features[cs_moving].coordsys_body = 1;
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
// mate_angle=45deg on top of preserved 30deg → face originally +X now at 75deg
doc.add_mate(2, cs_fixed, cs_moving, 0.0, 45.0, false, "RevoluteAngle");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
// Position on axis line (same as revolute preservation test)
GProp_GProps props;
BRepGProp::VolumeProperties(doc.bodies[1].shape, props);
gp_Pnt com = props.CentreOfMass();
REQUIRE_THAT(double(com.X()), WithinAbs(5.0, 1e-4));
REQUIRE_THAT(double(com.Y()), WithinAbs(5.0, 1e-4));
REQUIRE_THAT(double(com.Z()), WithinAbs(7.5, 1e-4));
// Rotation = 30deg (preserved) + 45deg (mate_angle) = 75deg → normal ≈ (cos75, sin75, 0)
auto faces = GeometryEngine::faces_of(doc.bodies[1].shape);
bool found_75 = false;
for (const auto& f : faces) {
Vec3d n = GeometryEngine::face_normal_world(f);
if (std::abs(n.x() - 0.2588) < 0.02 && std::abs(n.y() - 0.9659) < 0.02) {
found_75 = true; break;
}
}
REQUIRE(found_75);
}
TEST_CASE("slider mate: rotation corrected, axial position preserved", "[CadDocument][mate]")
{
using Catch::Matchers::WithinAbs;
CadDocument doc;
int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA");
doc.add_extrude(sk_a, 5.0, false, BooleanMode::New, "EA");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 10, 0, "BoxB");
doc.add_extrude(sk_b, 5.0, false, BooleanMode::New, "EB");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
// Pre-rotate B 30deg about Z, translate off-axis to (15, 2, 13)
doc.add_transform(1, Vec3d(15, 2, 13), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 30.0, false, "PrePose");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
GProp_GProps pre_props;
BRepGProp::VolumeProperties(doc.bodies[1].shape, pre_props);
double pre_z = pre_props.CentreOfMass().Z();
int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Fixed");
doc.features[cs_fixed].coordsys_body = 0;
int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(15, 2, 13), "CS_Moving");
doc.features[cs_moving].coordsys_body = 1;
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
doc.add_mate(3, cs_fixed, cs_moving, 0.0, 0.0, false, "Slider");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
// Perpendicular position corrected to the axis line (X=5, Y=5)
GProp_GProps post_props;
BRepGProp::VolumeProperties(doc.bodies[1].shape, post_props);
gp_Pnt post_com = post_props.CentreOfMass();
REQUIRE_THAT(double(post_com.X()), WithinAbs(5.0, 1e-4));
REQUIRE_THAT(double(post_com.Y()), WithinAbs(5.0, 1e-4));
// Axial (Z) position preserved from pre-mate pose
REQUIRE_THAT(double(post_com.Z()), WithinAbs(pre_z, 1e-4));
// Fastened would have placed the body at Z=5. pre_z=15.5 (centroid), clearly different.
}
TEST_CASE("slider mate: mate_offset shifts axial position", "[CadDocument][mate]")
{
using Catch::Matchers::WithinAbs;
CadDocument doc;
int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA");
doc.add_extrude(sk_a, 5.0, false, BooleanMode::New, "EA");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "BoxB");
doc.add_extrude(sk_b, 5.0, false, BooleanMode::New, "EB");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
doc.add_transform(1, Vec3d(12, 3, 9), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 0.0, false, "PrePose");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Fixed");
doc.features[cs_fixed].coordsys_body = 0;
int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(12, 3, 9), "CS_Moving");
doc.features[cs_moving].coordsys_body = 1;
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
// Slider with mate_offset=4: body centroid at (12,3,11.5), connector at (12,3,9).
// Perpendicular corrected: X=5,Y=5. Axial: preserved Z 11.5 + offset 4 = 15.5.
doc.add_mate(3, cs_fixed, cs_moving, 4.0, 0.0, false, "SliderOffset");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
GProp_GProps props;
BRepGProp::VolumeProperties(doc.bodies[1].shape, props);
gp_Pnt com = props.CentreOfMass();
REQUIRE_THAT(double(com.X()), WithinAbs(5.0, 1e-4));
REQUIRE_THAT(double(com.Y()), WithinAbs(5.0, 1e-4));
REQUIRE_THAT(double(com.Z()), WithinAbs(15.5, 1e-3));
}
TEST_CASE("slider mate: no-op when already correct", "[CadDocument][mate]")
{
using Catch::Matchers::WithinAbs;
CadDocument doc;
int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA");
doc.add_extrude(sk_a, 5.0, false, BooleanMode::New, "EA");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "BoxB");
doc.add_extrude(sk_b, 5.0, false, BooleanMode::New, "EB");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
// Body already on axis at (5,5,20), no rotation
doc.add_transform(1, Vec3d(5, 5, 20), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 0.0, false, "PrePose");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
GProp_GProps pre_props;
BRepGProp::VolumeProperties(doc.bodies[1].shape, pre_props);
gp_Pnt pre_com = pre_props.CentreOfMass();
int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Fixed");
doc.features[cs_fixed].coordsys_body = 0;
int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 20), "CS_Moving");
doc.features[cs_moving].coordsys_body = 1;
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
doc.add_mate(3, cs_fixed, cs_moving, 0.0, 0.0, false, "SliderNoOp");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
GProp_GProps post_props;
BRepGProp::VolumeProperties(doc.bodies[1].shape, post_props);
gp_Pnt post_com = post_props.CentreOfMass();
REQUIRE_THAT(double(post_com.X()), WithinAbs(double(pre_com.X()), 1e-4));
REQUIRE_THAT(double(post_com.Y()), WithinAbs(double(pre_com.Y()), 1e-4));
REQUIRE_THAT(double(post_com.Z()), WithinAbs(double(pre_com.Z()), 1e-4));
}
TEST_CASE("cylindrical mate: perpendicular corrected, rotation and axial preserved", "[CadDocument][mate]")
{
using Catch::Matchers::WithinAbs;
CadDocument doc;
int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA");
doc.add_extrude(sk_a, 5.0, false, BooleanMode::New, "EA");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 10, 0, "BoxB");
doc.add_extrude(sk_b, 5.0, false, BooleanMode::New, "EB");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
// Pre-rotate B 30deg about Z, translate off-axis to (15, 2, 13)
doc.add_transform(1, Vec3d(15, 2, 13), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 30.0, false, "PrePose");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
GProp_GProps pre_props;
BRepGProp::VolumeProperties(doc.bodies[1].shape, pre_props);
double pre_z = pre_props.CentreOfMass().Z();
int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Fixed");
doc.features[cs_fixed].coordsys_body = 0;
int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(15, 2, 13), "CS_Moving");
doc.features[cs_moving].coordsys_body = 1;
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
doc.add_mate(4, cs_fixed, cs_moving, 0.0, 0.0, false, "Cylindrical");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
// Perpendicular position corrected to axis line
GProp_GProps post_props;
BRepGProp::VolumeProperties(doc.bodies[1].shape, post_props);
gp_Pnt com = post_props.CentreOfMass();
REQUIRE_THAT(double(com.X()), WithinAbs(5.0, 1e-4));
REQUIRE_THAT(double(com.Y()), WithinAbs(5.0, 1e-4));
// Axial Z position preserved from pre-mate
REQUIRE_THAT(double(com.Z()), WithinAbs(pre_z, 1e-4));
// Rotation about Z preserved: face originally +X is still at 30deg
auto faces = GeometryEngine::faces_of(doc.bodies[1].shape);
REQUIRE(faces.size() == 6);
bool rot_preserved = false;
for (const auto& f : faces) {
Vec3d n = GeometryEngine::face_normal_world(f);
if (std::abs(n.x() - 0.866) < 0.02 && std::abs(n.y() - 0.5) < 0.02) {
rot_preserved = true; break;
}
}
REQUIRE(rot_preserved);
// Fastened would have aligned face normals to world axes and fixed Z.
}
TEST_CASE("cylindrical mate: mate_offset and mate_angle applied on top", "[CadDocument][mate]")
{
using Catch::Matchers::WithinAbs;
CadDocument doc;
int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA");
doc.add_extrude(sk_a, 5.0, false, BooleanMode::New, "EA");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 10, 0, "BoxB");
doc.add_extrude(sk_b, 5.0, false, BooleanMode::New, "EB");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
doc.add_transform(1, Vec3d(15, 2, 9), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 30.0, false, "PrePose");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Fixed");
doc.features[cs_fixed].coordsys_body = 0;
int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(15, 2, 9), "CS_Moving");
doc.features[cs_moving].coordsys_body = 1;
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
doc.add_mate(4, cs_fixed, cs_moving, 3.0, 45.0, false, "CylOffsetAngle");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
// Position: perpendicular corrected, axial = 11.5 (centroid preserved) + 3 (offset) = 14.5
GProp_GProps props;
BRepGProp::VolumeProperties(doc.bodies[1].shape, props);
gp_Pnt com = props.CentreOfMass();
REQUIRE_THAT(double(com.X()), WithinAbs(5.0, 1e-4));
REQUIRE_THAT(double(com.Y()), WithinAbs(5.0, 1e-4));
REQUIRE_THAT(double(com.Z()), WithinAbs(14.5, 1e-3));
// Rotation = 30deg (preserved) + 45deg (angle) = 75deg
auto faces = GeometryEngine::faces_of(doc.bodies[1].shape);
bool found_75 = false;
for (const auto& f : faces) {
Vec3d n = GeometryEngine::face_normal_world(f);
if (std::abs(n.x() - 0.2588) < 0.02 && std::abs(n.y() - 0.9659) < 0.02) {
found_75 = true; break;
}
}
REQUIRE(found_75);
}
TEST_CASE("cylindrical mate: no-op when already correct", "[CadDocument][mate]")
{
using Catch::Matchers::WithinAbs;
CadDocument doc;
int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA");
doc.add_extrude(sk_a, 5.0, false, BooleanMode::New, "EA");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "BoxB");
doc.add_extrude(sk_b, 5.0, false, BooleanMode::New, "EB");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
// Body already on axis at (5,5,20), no rotation
doc.add_transform(1, Vec3d(5, 5, 20), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 0.0, false, "PrePose");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
GProp_GProps pre_props;
BRepGProp::VolumeProperties(doc.bodies[1].shape, pre_props);
gp_Pnt pre_com = pre_props.CentreOfMass();
int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Fixed");
doc.features[cs_fixed].coordsys_body = 0;
int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 20), "CS_Moving");
doc.features[cs_moving].coordsys_body = 1;
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
doc.add_mate(4, cs_fixed, cs_moving, 0.0, 0.0, false, "CylNoOp");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
GProp_GProps post_props;
BRepGProp::VolumeProperties(doc.bodies[1].shape, post_props);
gp_Pnt post_com = post_props.CentreOfMass();
REQUIRE_THAT(double(post_com.X()), WithinAbs(double(pre_com.X()), 1e-4));
REQUIRE_THAT(double(post_com.Y()), WithinAbs(double(pre_com.Y()), 1e-4));
REQUIRE_THAT(double(post_com.Z()), WithinAbs(double(pre_com.Z()), 1e-4));
}
TEST_CASE("revolute mate with FaceAndDirection on non-Z faces", "[CadDocument][mate]")
{
using Catch::Matchers::WithinAbs;
CadDocument doc;
// Body A: box 20x20x10
int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA");
doc.add_extrude(sk_a, 10.0, false, BooleanMode::New, "EA");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
// Face on A with normal +Y
int faceA = find_face_by_normal(doc, 0, Vec3d(0, 1, 0));
REQUIRE(faceA >= 0);
Vec3d nA = GeometryEngine::face_normal_world(
GeometryEngine::face_by_index(doc.bodies[0].shape, faceA));
// Body B: asymmetric box 20x10x5, also with +Y face
int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 10, 0, "BoxB");
doc.add_extrude(sk_b, 5.0, false, BooleanMode::New, "EB");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
int faceB = find_face_by_normal(doc, 1, Vec3d(0, 1, 0));
REQUIRE(faceB >= 0);
Vec3d nB_pre = GeometryEngine::face_normal_world(
GeometryEngine::face_by_index(doc.bodies[1].shape, faceB));
REQUIRE_THAT(nB_pre.dot(nA), WithinAbs(1.0, 1e-4));
// Pre-rotate B about Y (the face normal) by 30deg to give it a non-trivial rotation about its connector z
doc.add_transform(1, Vec3d(0, 0, 0), Vec3d(0, 1, 0), Vec3d(0, 0, 0), 30.0, false, "PrePose");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
// FaceAndDirection on A's +Y face
int cs_fixed = doc.add_coordsys(CoordSysType::FaceAndDirection, Vec3d(0,0,0), "CS_Fixed");
doc.features[cs_fixed].coordsys_body = 0;
doc.features[cs_fixed].coordsys_face = faceA;
// FaceAndDirection on B's +Y face
int cs_moving = doc.add_coordsys(CoordSysType::FaceAndDirection, Vec3d(0,0,0), "CS_Moving");
doc.features[cs_moving].coordsys_body = 1;
doc.features[cs_moving].coordsys_face = faceB;
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
// Record face normals on B before mate (to verify rotation preservation)
// +30deg rotation about Y maps +Z=(0,0,1) → (0.5, 0, 0.866), -Z → (-0.5, 0, -0.866)
auto faces_pre = GeometryEngine::faces_of(doc.bodies[1].shape);
Vec3d pre_rotated_normal;
{
bool fnd = false;
for (const auto& f : faces_pre) {
Vec3d n = GeometryEngine::face_normal_world(f);
if (std::abs(n.z()) > 0.85 && std::abs(n.x()) > 0.45 && std::abs(n.y()) < 0.02) {
pre_rotated_normal = n; fnd = true; break;
}
}
REQUIRE(fnd);
}
doc.add_mate(2, cs_fixed, cs_moving, 0.0, 0.0, false, "RevoluteFaceDir");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
// Position: faces are coincident (same as fastened position since no offset and axes aligned)
Vec3d ca = GeometryEngine::face_centroid_world(GeometryEngine::face_by_index(doc.bodies[0].shape, faceA));
Vec3d cb = GeometryEngine::face_centroid_world(GeometryEngine::face_by_index(doc.bodies[1].shape, faceB));
REQUIRE_THAT(cb.x(), WithinAbs(ca.x(), 1e-4));
REQUIRE_THAT(cb.y(), WithinAbs(ca.y(), 1e-4));
REQUIRE_THAT(cb.z(), WithinAbs(ca.z(), 1e-4));
// Rotation about the connector z (which is +Y) is preserved:
// the face with |z| ≈ 0.866, |x| ≈ 0.5, y ≈ 0 must still exist.
auto faces_post = GeometryEngine::faces_of(doc.bodies[1].shape);
bool rot_preserved = false;
for (const auto& f : faces_post) {
Vec3d n = GeometryEngine::face_normal_world(f);
if (std::abs(n.z()) > 0.85 && std::abs(n.x()) > 0.45 && std::abs(n.y()) < 0.02) {
rot_preserved = true; break;
}
}
REQUIRE(rot_preserved);
// Fastened would force all axes to align: no face with Z≈0.87 would exist.
}
// --- M8b: rotation-coverage hole — FaceAndDirection captures body orientation ---
TEST_CASE("slider mate with FaceAndDirection corrects rotation", "[CadDocument][mate]")
{
using Catch::Matchers::WithinAbs;
CadDocument doc;
// Body A: box 20x20x10
int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA");
doc.add_extrude(sk_a, 10.0, false, BooleanMode::New, "EA");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
int faceA = find_face_by_normal(doc, 0, Vec3d(0, 0, 1));
REQUIRE(faceA >= 0);
// Body B: asymmetric box 20x10x5
int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 10, 0, "BoxB");
doc.add_extrude(sk_b, 5.0, false, BooleanMode::New, "EB");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
int faceB = find_face_by_normal(doc, 1, Vec3d(0, 0, 1));
REQUIRE(faceB >= 0);
// Pre-rotate B: first 20deg about X (tilts +Z face normal off-axis, so the
// connector z genuinely differs from A's), then 30deg about Z (adds rotation
// about the mate axis that Slider must correct and Cylindrical must preserve).
doc.add_transform(1, Vec3d(14, 3, 7), Vec3d(1, 0, 0), Vec3d(0, 0, 0), 20.0, false, "RotX");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
doc.add_transform(1, Vec3d(0, 0, 0), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 30.0, false, "RotZ");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
// Both connectors pin their in-plane direction to an edge of their own body, so each
// frame follows its body's spin. Without this the frames' x comes from the world hint
// and the 30deg spin is invisible to the mate — see find_edge_on_face.
int edgeA = find_edge_on_face(doc, 0, faceA);
int edgeB = find_edge_on_face(doc, 1, faceB);
REQUIRE(edgeA >= 0);
REQUIRE(edgeB >= 0);
int cs_fixed = doc.add_coordsys(CoordSysType::FaceAndDirection, Vec3d(0, 0, 0), "CS_Fixed");
doc.features[cs_fixed].coordsys_body = 0;
doc.features[cs_fixed].coordsys_face = faceA;
doc.features[cs_fixed].coordsys_edge = edgeA;
int cs_moving = doc.add_coordsys(CoordSysType::FaceAndDirection, Vec3d(0, 0, 0), "CS_Moving");
doc.features[cs_moving].coordsys_body = 1;
doc.features[cs_moving].coordsys_face = faceB;
doc.features[cs_moving].coordsys_edge = edgeB;
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
// Record the pre-mate axial position of the CONNECTOR, not of the centroid. Slider
// rotates the body about the connector origin to correct the 20deg tilt, and that
// rotation legitimately moves the centroid in Z (by -d*(1-cos20) for a centroid d
// below the mated face) while leaving the connector itself where it is. The DOF
// Slider preserves is the connector's position along the axis.
const double pre_conn_z = GeometryEngine::face_centroid_world(
GeometryEngine::face_by_index(doc.bodies[1].shape, faceB)).z();
doc.add_mate(3, cs_fixed, cs_moving, 0.0, 0.0, false, "SliderFaceDir");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
// Position: perpendicular corrected to axis line through A's +Z face centroid.
// The +Z face of a 20x20x10 box centred at origin has centroid at (0, 0, 10).
GProp_GProps props;
BRepGProp::VolumeProperties(doc.bodies[1].shape, props);
gp_Pnt com = props.CentreOfMass();
REQUIRE_THAT(double(com.X()), WithinAbs(0.0, 1e-4));
REQUIRE_THAT(double(com.Y()), WithinAbs(0.0, 1e-4));
// Axial position of the connector preserved — Slider does not own that DOF.
const double post_conn_z = GeometryEngine::face_centroid_world(
GeometryEngine::face_by_index(doc.bodies[1].shape, faceB)).z();
REQUIRE_THAT(post_conn_z, WithinAbs(pre_conn_z, 1e-4));
// Rotation: Slider owns this DOF — all face normals must be axis-aligned.
auto faces = GeometryEngine::faces_of(doc.bodies[1].shape);
REQUIRE(faces.size() == 6);
int axis_aligned = 0;
for (const auto& f : faces) {
Vec3d n = GeometryEngine::face_normal_world(f);
double d = std::max({std::abs(n.x()), std::abs(n.y()), std::abs(n.z())});
if (d > 0.999) ++axis_aligned;
}
REQUIRE(axis_aligned == 6);
// Dropping rotation correction would leave 30deg Z rotation — at least 2
// faces would have normals not aligned to any world axis.
}
TEST_CASE("cylindrical mate with FaceAndDirection preserves rotation", "[CadDocument][mate]")
{
using Catch::Matchers::WithinAbs;
CadDocument doc;
// Body A: box 20x20x10
int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA");
doc.add_extrude(sk_a, 10.0, false, BooleanMode::New, "EA");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
int faceA = find_face_by_normal(doc, 0, Vec3d(0, 0, 1));
REQUIRE(faceA >= 0);
// Body B: asymmetric box 20x10x5
int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 10, 0, "BoxB");
doc.add_extrude(sk_b, 5.0, false, BooleanMode::New, "EB");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
int faceB = find_face_by_normal(doc, 1, Vec3d(0, 0, 1));
REQUIRE(faceB >= 0);
// Same two-step pre-rotation as the slider test.
doc.add_transform(1, Vec3d(14, 3, 7), Vec3d(1, 0, 0), Vec3d(0, 0, 0), 20.0, false, "RotX");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
doc.add_transform(1, Vec3d(0, 0, 0), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 30.0, false, "RotZ");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
// Edge-pinned frames, as in the Slider case. These matter here for the opposite
// reason: with a world-derived in-plane direction, a Cylindrical that WRONGLY aligned
// the spin would still leave the body's 30deg face at 30deg, and the assertion below
// would pass for the wrong implementation.
int edgeA = find_edge_on_face(doc, 0, faceA);
int edgeB = find_edge_on_face(doc, 1, faceB);
REQUIRE(edgeA >= 0);
REQUIRE(edgeB >= 0);
int cs_fixed = doc.add_coordsys(CoordSysType::FaceAndDirection, Vec3d(0, 0, 0), "CS_Fixed");
doc.features[cs_fixed].coordsys_body = 0;
doc.features[cs_fixed].coordsys_face = faceA;
doc.features[cs_fixed].coordsys_edge = edgeA;
int cs_moving = doc.add_coordsys(CoordSysType::FaceAndDirection, Vec3d(0, 0, 0), "CS_Moving");
doc.features[cs_moving].coordsys_body = 1;
doc.features[cs_moving].coordsys_face = faceB;
doc.features[cs_moving].coordsys_edge = edgeB;
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
// As in the Slider case, the preserved DOF is the CONNECTOR's position along the
// axis, not the centroid's: aligning the 20deg tilt rotates the body about the
// connector origin, which moves the centroid in Z by design.
const double pre_conn_z_c = GeometryEngine::face_centroid_world(
GeometryEngine::face_by_index(doc.bodies[1].shape, faceB)).z();
doc.add_mate(4, cs_fixed, cs_moving, 0.0, 0.0, false, "CylFaceDir");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
// Position: perpendicular corrected to axis line (X=0, Y=0 at +Z face centroid)
GProp_GProps props;
BRepGProp::VolumeProperties(doc.bodies[1].shape, props);
gp_Pnt com = props.CentreOfMass();
REQUIRE_THAT(double(com.X()), WithinAbs(0.0, 1e-4));
REQUIRE_THAT(double(com.Y()), WithinAbs(0.0, 1e-4));
// Axial position of the connector preserved — Cylindrical leaves that DOF free.
const double post_conn_z_c = GeometryEngine::face_centroid_world(
GeometryEngine::face_by_index(doc.bodies[1].shape, faceB)).z();
REQUIRE_THAT(post_conn_z_c, WithinAbs(pre_conn_z_c, 1e-4));
// Rotation about Z (30deg) is preserved: the 20deg X-rotation was undone
// by z-alignment, leaving the 30deg Z-rotation intact.
// The +X face normal should be at ~(cos30, sin30, 0).
auto faces = GeometryEngine::faces_of(doc.bodies[1].shape);
REQUIRE(faces.size() == 6);
bool rot_preserved = false;
for (const auto& f : faces) {
Vec3d n = GeometryEngine::face_normal_world(f);
if (std::abs(n.x() - 0.866) < 0.02 && std::abs(n.y() - 0.5) < 0.02) {
rot_preserved = true; break;
}
}
REQUIRE(rot_preserved);
// If cylindrical behaved like slider, the face would be at (1,0,0) instead.
}