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}},