From f5f9d9efb7cfdc41bc60c18da3cd863303f0a743 Mon Sep 17 00:00:00 2001 From: Yuval Tassa Date: Mon, 13 Jul 2026 03:56:14 -0700 Subject: [PATCH] Fix bug in mesh normal scaling for nonuniform mesh scales. PiperOrigin-RevId: 946937791 Change-Id: I1b8a0ad4d573842ef9f76572dea25668dd319b52 --- doc/changelog.rst | 4 +++ src/user/user_mesh.cc | 7 +++-- test/user/user_mesh_test.cc | 58 +++++++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 3 deletions(-) diff --git a/doc/changelog.rst b/doc/changelog.rst index c2ffe06d..903133b3 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -36,6 +36,10 @@ General - Changed the default value of :ref:`sleep_tolerance` from 1e-4 to 1e-3 (1mm/sec in SI units). +Bug fixes +^^^^^^^^^ +- Fixed a bug in the mesh compiler where normals were scaled as vectors rather than covectors. + Version 3.10.0 (June 22, 2026) ------------------------------ diff --git a/src/user/user_mesh.cc b/src/user/user_mesh.cc index b75fef48..56829bb9 100644 --- a/src/user/user_mesh.cc +++ b/src/user/user_mesh.cc @@ -1292,10 +1292,11 @@ void mjCMesh::ApplyTransformations(double* dvert) { dvert[3*i + 2] *= scale[2]; } + // normals are covectors: transform by the inverse scale for (int i = 0; i < nnormal(); i++) { - normal_[3*i + 0] *= scale[0]; - normal_[3*i + 1] *= scale[1]; - normal_[3*i + 2] *= scale[2]; + normal_[3*i + 0] /= scale[0]; + normal_[3*i + 1] /= scale[1]; + normal_[3*i + 2] /= scale[2]; } } diff --git a/test/user/user_mesh_test.cc b/test/user/user_mesh_test.cc index b5025515..5d1cde99 100644 --- a/test/user/user_mesh_test.cc +++ b/test/user/user_mesh_test.cc @@ -1120,6 +1120,64 @@ TEST_F(MjCMeshTest, MeshScale) { EXPECT_THAT(AsVector(model->mesh_scale + 3, 3), ElementsAre(0.9, 1, -1)); } +TEST_F(MjCMeshTest, UserNormalsAnisotropicScale) { + // normals are covectors: under mesh scale s they must transform by 1/s. + // cube with per-vertex normals along the vertex directions, anisotropic + // scale; expected compiled normals follow (+-1/sx, +-1/sy, +-1/sz) + static constexpr char xml[] = R"( + + + + + + + + + + + + )"; + char error[1024]; + MjModelPtr model = LoadModelFromString(xml, error, sizeof(error)); + ASSERT_THAT(model, NotNull()) << error; + const mjModel* m = model.get(); + int mid = 0; + const mjtNum* mq = m->mesh_quat + 4*mid; + const mjtNum* mp = m->mesh_pos + 3*mid; + double scale[3] = {.2, .1, .1}; + int va = m->mesh_vertadr[mid]; + int na = m->mesh_normaladr[mid]; + int fa = m->mesh_faceadr[mid]; + for (int i = 0; i < 3*m->mesh_facenum[mid]; i++) { + int vid = m->mesh_face[3*fa + i]; + int nid = m->mesh_facenormal[3*fa + i]; + + // rotate vertex and normal back to the authored frame + mjtNum vm[3] = {m->mesh_vert[3*(va+vid)+0], + m->mesh_vert[3*(va+vid)+1], + m->mesh_vert[3*(va+vid)+2]}; + mjtNum nm[3] = {m->mesh_normal[3*(na+nid)+0], + m->mesh_normal[3*(na+nid)+1], + m->mesh_normal[3*(na+nid)+2]}; + mjtNum v[3], n[3]; + mju_rotVecQuat(v, vm, mq); + mju_addTo3(v, mp); + mju_rotVecQuat(n, nm, mq); + + // expected: sign pattern of the vertex, divided by scale, normalized + mjtNum expected[3]; + for (int k = 0; k < 3; k++) { + expected[k] = (v[k] > 0 ? 1.0 : -1.0) / scale[k]; + } + mju_normalize3(expected); + EXPECT_GT(mju_dot3(n, expected), 0.999); + } +} + TEST_F(MjCMeshTest, NegativeScaleUserMeshCompiles) { static constexpr char xml[] = R"(