Mate connectors: derive a face-only frame's X from the face, not from world

datum_frame took a FaceAndDirection frame's Z from the face normal, which
follows the body, but its X from coordsys_x_hint, a world constant, whenever
no explicit edge reference was set. Spinning a body about its own face normal
therefore left the frame bit-identical: the connector could not encode that
rotation at all, so Fastened and Slider mates claimed to fix an orientation
the frame could not see.

X now comes from the face's own first usable edge, which rotates with the
body. The hint survives only as a last resort, for faces that offer no
in-plane direction — a full circular edge has coincident endpoints, and a
seam projects to nothing in-plane.

The new test spins a box 90 degrees about its top-face normal and asserts the
frame's X turned with it. Reverting just the X_tent derivation and rerunning
makes it fail with "1.0 is within 0.000001 of 0.0" — cos(angle) between the
before and after X is exactly 1, i.e. the frame did not move — and that is the
only failure in 2019 assertions, so the test discriminates this defect and
nothing else.

Note for anyone replaying an older document: a face-only connector's frame
can now differ from what that recipe produced before, so a mate built on one
may place its body differently. Nothing in the suite or the golden v3 fixture
changed, but the semantics did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tommaso Bianchi
2026-07-26 08:18:42 +02:00
co-authored by Claude Opus 5
parent f13f2876f6
commit 1726e93760
2 changed files with 78 additions and 2 deletions
+23 -2
View File
@@ -1681,8 +1681,29 @@ CadDocument::DatumCoordSys CadDocument::datum_frame(const std::vector<CadBody>&
if (fc.IsNull()) { ds.error = "face not found"; break; }
ds.origin = GeometryEngine::face_centroid_world(fc);
Vec3d Z = GeometryEngine::face_normal_world(fc);
// Tentative X: edge direction if available, else the hint or a fallback.
Vec3d X_tent = have_edge ? edge_dir : f.coordsys_x_hint;
// Tentative X: an explicit edge reference wins. Failing that, derive X from the
// face's OWN first usable edge, so the frame rotates with the body. Reading it from
// coordsys_x_hint — a world constant — meant a face-only connector could not encode
// spin about its own normal: Z followed the body, X did not, so Fastened and Slider
// claimed to fix an orientation the frame could not see. The hint survives only as a
// last resort, for faces that offer no usable direction (a full circular edge has
// coincident endpoints, and a cylinder's seam projects to nothing in-plane).
Vec3d X_tent = Vec3d::Zero();
if (have_edge) {
X_tent = edge_dir;
} else {
for (const TopoDS_Edge& fe : GeometryEngine::edges_of_face(fc)) {
auto pts = GeometryEngine::sample_edge_world(fe);
if (pts.size() < 2) continue;
Vec3d d = pts.back() - pts.front();
if (d.squaredNorm() < 1e-18) continue; // closed edge: endpoints coincide
Vec3d in_plane = d - Z * Z.dot(d); // drop any out-of-plane component
if (in_plane.squaredNorm() < 1e-18) continue;
X_tent = in_plane;
break;
}
if (X_tent.squaredNorm() < 1e-18) X_tent = f.coordsys_x_hint;
}
if (X_tent.squaredNorm() < 1e-18) { ds.error = "zero-length direction"; break; }
X_tent.normalize();
// Gram-Schmidt: ensure orthonormal, right-handed frame.
+55
View File
@@ -2544,6 +2544,61 @@ TEST_CASE("datum coordinate system: non-perpendicular inputs produce orthonormal
CHECK_THAT(Z.z(), WithinAbs((X.cross(Y)).z(), 1e-9));
}
TEST_CASE("datum coordinate system: a face-only frame rotates with its body", "[CadDocument]")
{
using Catch::Matchers::WithinAbs;
// The bug this pins down: Z came from the face normal (body-following) but X came from
// coordsys_x_hint, a WORLD constant. Spinning the body about its own face normal left the
// frame identical, so a face-only connector could not encode that rotation at all — and a
// Fastened or Slider mate built on it claimed to fix an orientation it could not see.
// A rectangular top face is used deliberately: its edges give an unambiguous in-plane
// direction, so "did the frame follow the body" is answerable to the degree.
CadDocument doc;
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 40, 20, 10, "Box");
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude");
REQUIRE(doc.recompute());
int n_faces = GeometryEngine::face_count(doc.bodies[0].shape);
int top_face = -1;
for (int i = 0; i < n_faces; ++i) {
Vec3d fn = GeometryEngine::face_normal_world(GeometryEngine::face_by_index(doc.bodies[0].shape, i));
if (fn.z() > 0.9) { top_face = i; break; }
}
REQUIRE(top_face >= 0);
int cs = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 0), "CS");
doc.features[cs].coordsys_type = CoordSysType::FaceAndDirection;
doc.features[cs].coordsys_body = 0;
doc.features[cs].coordsys_face = top_face;
doc.features[cs].coordsys_edge = -1; // face only: no explicit direction edge
REQUIRE(doc.recompute());
auto before = doc.resolve_datum_coordsys();
REQUIRE(before.size() == 1);
REQUIRE(before[0].error.empty());
const Vec3d X0 = before[0].x;
const Vec3d Z0 = before[0].x.cross(before[0].y);
// Spin the body 90 degrees about its own face normal (world Z here).
doc.add_transform(0, Vec3d(0, 0, 0), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 90.0, false, "Spin");
REQUIRE(doc.recompute());
auto after = doc.resolve_datum_coordsys();
REQUIRE(after.size() == 1);
REQUIRE(after[0].error.empty());
const Vec3d X1 = after[0].x;
// The normal is unchanged by a spin about itself — that is exactly why the old code could
// not detect the rotation.
const Vec3d Z1 = after[0].x.cross(after[0].y);
CHECK_THAT(std::abs(Z0.dot(Z1)), WithinAbs(1.0, 1e-6));
// X must have turned with the body. Before the fix X0 == X1 and this failed.
const double cos_turn = std::clamp(X0.dot(X1), -1.0, 1.0);
CHECK_THAT(std::abs(cos_turn), WithinAbs(0.0, 1e-6)); // 90 degrees apart
}
TEST_CASE("datum coordinate system: point_world gives world axes", "[CadDocument]")
{
using Catch::Matchers::WithinAbs;