diff --git a/src/engine/engine_ray.c b/src/engine/engine_ray.c index 86bece81..d2b29d77 100644 --- a/src/engine/engine_ray.c +++ b/src/engine/engine_ray.c @@ -185,11 +185,15 @@ mjtNum ray_triangle(mjtNum v[][3], const mjtNum lpnt[3], const mjtNum lvec[3], return (-mju_dot3(dif[2], nrm) / denom); } + //---------------------------- geom-specific intersection functions -------------------------------- // plane static mjtNum ray_plane(const mjtNum pos[3], const mjtNum mat[9], const mjtNum size[3], - const mjtNum pnt[3], const mjtNum vec[3]) { + const mjtNum pnt[3], const mjtNum vec[3], mjtNum normal[3]) { + // clear normal if given + if (normal) mju_zero3(normal); + // map to local frame mjtNum lpnt[3], lvec[3]; ray_map(pos, mat, pnt, vec, lpnt, lvec); @@ -210,6 +214,11 @@ static mjtNum ray_plane(const mjtNum pos[3], const mjtNum mat[9], const mjtNum s // accept only within rendered rectangle if ((size[0] <= 0 || mju_abs(p0) <= size[0]) && (size[1] <= 0 || mju_abs(p1) <= size[1])) { + if (normal) { + normal[0] = mat[2]; + normal[1] = mat[5]; + normal[2] = mat[8]; + } return x; } else { return -1; @@ -219,7 +228,7 @@ static mjtNum ray_plane(const mjtNum pos[3], const mjtNum mat[9], const mjtNum s // sphere static mjtNum ray_sphere(const mjtNum pos[3], const mjtNum mat[9], mjtNum dist_sqr, - const mjtNum pnt[3], const mjtNum vec[3]) { + const mjtNum pnt[3], const mjtNum vec[3], mjtNum normal[3]) { // (x*vec+pnt-pos)'*(x*vec+pnt-pos) = size[0]*size[0] mjtNum dif[3] = {pnt[0]-pos[0], pnt[1]-pos[1], pnt[2]-pos[2]}; mjtNum a = vec[0]*vec[0] + vec[1]*vec[1] + vec[2]*vec[2]; @@ -228,16 +237,32 @@ static mjtNum ray_sphere(const mjtNum pos[3], const mjtNum mat[9], mjtNum dist_s // solve a*x^2 + 2*b*x + c = 0 mjtNum xx[2]; - return ray_quad(a, b, c, xx); + mjtNum x = ray_quad(a, b, c, xx); + + // compute normal if required + if (normal) { + if (x < 0) { + mju_zero3(normal); + } else { + // normal at surface intersection s (global frame) + mjtNum s[3]; + mju_addScl3(s, pnt, vec, x); + mju_sub3(normal, s, pos); + mju_normalize3(normal); + } + } + + return x; } // capsule -static mjtNum ray_capsule(const mjtNum* pos, const mjtNum* mat, const mjtNum* size, - const mjtNum* pnt, const mjtNum* vec) { +static mjtNum ray_capsule(const mjtNum pos[3], const mjtNum mat[9], const mjtNum size[3], + const mjtNum pnt[3], const mjtNum vec[3], mjtNum normal[3]) { // bounding sphere test mjtNum ssz = size[0] + size[1]; - if (ray_sphere(pos, NULL, ssz*ssz, pnt, vec) < 0) { + if (ray_sphere(pos, NULL, ssz * ssz, pnt, vec, NULL) < 0) { + if (normal) mju_zero3(normal); return -1; } @@ -247,6 +272,7 @@ static mjtNum ray_capsule(const mjtNum* pos, const mjtNum* mat, const mjtNum* si // init solution mjtNum x = -1, sol, xx[2]; + int type; // -1: bottom, 0: cylinder, 1: top // cylinder round side: (x*lvec+lpnt)'*(x*lvec+lpnt) = size[0]*size[0] mjtNum a = lvec[0]*lvec[0] + lvec[1]*lvec[1]; @@ -260,6 +286,7 @@ static mjtNum ray_capsule(const mjtNum* pos, const mjtNum* mat, const mjtNum* si if (sol >= 0 && mju_abs(lpnt[2]+sol*lvec[2]) <= size[1]) { if (x < 0 || sol < x) { x = sol; + type = 0; } } @@ -275,6 +302,7 @@ static mjtNum ray_capsule(const mjtNum* pos, const mjtNum* mat, const mjtNum* si if (xx[i] >= 0 && lpnt[2]+xx[i]*lvec[2] >= size[1]) { if (x < 0 || xx[i] < x) { x = xx[i]; + type = 1; } } } @@ -290,17 +318,33 @@ static mjtNum ray_capsule(const mjtNum* pos, const mjtNum* mat, const mjtNum* si if (xx[i] >= 0 && lpnt[2]+xx[i]*lvec[2] <= -size[1]) { if (x < 0 || xx[i] < x) { x = xx[i]; + type = -1; } } } + // compute normal if required + if (normal) { + if (x < 0) { + mju_zero3(normal); + } else { + normal[0] = lpnt[0] + lvec[0] * x; + normal[1] = lpnt[1] + lvec[1] * x; + normal[2] = (type == 0) ? 0 : lpnt[2] + lvec[2] * x - size[1] * type; + + // normalize, rotate into global frame + mju_normalize3(normal); + mju_mulMatVec3(normal, mat, normal); + } + } + return x; } // ellipsoid static mjtNum ray_ellipsoid(const mjtNum pos[3], const mjtNum mat[9], const mjtNum size[3], - const mjtNum pnt[3], const mjtNum vec[3]) { + const mjtNum pnt[3], const mjtNum vec[3], mjtNum normal[3]) { // map to local frame mjtNum lpnt[3], lvec[3]; ray_map(pos, mat, pnt, vec, lpnt, lvec); @@ -315,16 +359,39 @@ static mjtNum ray_ellipsoid(const mjtNum pos[3], const mjtNum mat[9], const mjtN // solve a*x^2 + 2*b*x + c = 0 mjtNum xx[2]; - return ray_quad(a, b, c, xx); + mjtNum x = ray_quad(a, b, c, xx); + + // compute normal if required + if (normal) { + if (x < 0) { + mju_zero3(normal); + } else { + // surface intersection (local frame) + mjtNum l[3]; + mju_addScl3(l, lpnt, lvec, x); + + // gradient of ellipsoid function + normal[0] = s[0] * l[0]; + normal[1] = s[1] * l[1]; + normal[2] = s[2] * l[2]; + + // normalize, rotate into global frame + mju_normalize3(normal); + mju_mulMatVec3(normal, mat, normal); + } + } + + return x; } // cylinder static mjtNum ray_cylinder(const mjtNum pos[3], const mjtNum mat[9], const mjtNum size[3], - const mjtNum pnt[3], const mjtNum vec[3]) { + const mjtNum pnt[3], const mjtNum vec[3], mjtNum normal[3]) { // bounding sphere test mjtNum ssz = size[0]*size[0] + size[1]*size[1]; - if (ray_sphere(pos, NULL, ssz, pnt, vec) < 0) { + if (ray_sphere(pos, NULL, ssz, pnt, vec, NULL) < 0) { + if (normal) mju_zero3(normal); return -1; } @@ -334,6 +401,7 @@ static mjtNum ray_cylinder(const mjtNum pos[3], const mjtNum mat[9], const mjtNu // init solution mjtNum x = -1, sol; + int type = 0; // -1: bottom, 0: round, 1: top // flat sides int side; @@ -352,13 +420,14 @@ static mjtNum ray_cylinder(const mjtNum pos[3], const mjtNum mat[9], const mjtNu if (p0*p0 + p1*p1 <= size[0]*size[0]) { if (x < 0 || sol < x) { x = sol; + type = side; } } } } } - // (x*lvec+lpnt)'*(x*lvec+lpnt) = size[0]*size[0] + // round side: (x*lvec+lpnt)'*(x*lvec+lpnt) = size[0]*size[0] mjtNum a = lvec[0]*lvec[0] + lvec[1]*lvec[1]; mjtNum b = lvec[0]*lpnt[0] + lvec[1]*lpnt[1]; mjtNum c = lpnt[0]*lpnt[0] + lpnt[1]*lpnt[1] - size[0]*size[0]; @@ -371,6 +440,33 @@ static mjtNum ray_cylinder(const mjtNum pos[3], const mjtNum mat[9], const mjtNu if (sol >= 0 && mju_abs(lpnt[2]+sol*lvec[2]) <= size[1]) { if (x < 0 || sol < x) { x = sol; + type = 0; + } + } + + // compute normal if required + if (normal) { + if (x < 0) { + mju_zero3(normal); + } else { + // round side + if (type == 0) { + // normal at surface intersection (local frame) + normal[0] = lpnt[0] + lvec[0] * x; + normal[1] = lpnt[1] + lvec[1] * x; + normal[2] = 0; + mju_normalize3(normal); + } + + // flat sides + else { + normal[0] = 0; + normal[1] = 0; + normal[2] = type; + } + + // rotate into global frame + mju_mulMatVec3(normal, mat, normal); } } @@ -380,13 +476,14 @@ static mjtNum ray_cylinder(const mjtNum pos[3], const mjtNum mat[9], const mjtNu // box static mjtNum ray_box(const mjtNum pos[3], const mjtNum mat[9], const mjtNum size[3], - const mjtNum pnt[3], const mjtNum vec[3], mjtNum all[6]) { - // clear all + const mjtNum pnt[3], const mjtNum vec[3], mjtNum all[6], mjtNum normal[3]) { + // clear outputs if (all) all[0] = all[1] = all[2] = all[3] = all[4] = all[5] = -1; + if (normal) mju_zero3(normal); // bounding sphere test mjtNum ssz = size[0]*size[0] + size[1]*size[1] + size[2]*size[2]; - if (ray_sphere(pos, NULL, ssz, pnt, vec) < 0) { + if (ray_sphere(pos, NULL, ssz, pnt, vec, NULL) < 0) { return -1; } @@ -403,6 +500,7 @@ static mjtNum ray_box(const mjtNum pos[3], const mjtNum mat[9], const mjtNum siz // init solution mjtNum x = -1, sol; + int face_side, face_axis = -1; // loop over axes with non-zero vec for (int i=0; i < 3; i++) { @@ -423,6 +521,8 @@ static mjtNum ray_box(const mjtNum pos[3], const mjtNum mat[9], const mjtNum siz // update if (x < 0 || sol < x) { x = sol; + face_axis = i; + face_side = side; } // save in all @@ -435,6 +535,13 @@ static mjtNum ray_box(const mjtNum pos[3], const mjtNum mat[9], const mjtNum siz } } + // compute normal if required + if (normal && x >= 0) { + mjtNum n_local[3] = {0, 0, 0}; + n_local[face_axis] = face_side; + mju_mulMatVec3(normal, mat, n_local); + } + return x; } @@ -471,11 +578,11 @@ mjtNum mj_rayHfield(const mjModel* m, const mjData* d, int id, }; // init: intersection with base box - mjtNum x = ray_box(base_pos, d->geom_xmat+9*id, base_size, pnt, vec, NULL); + mjtNum x = ray_box(base_pos, d->geom_xmat+9*id, base_size, pnt, vec, NULL, NULL); // check top box: done if no intersection mjtNum all[6]; - mjtNum top_intersect = ray_box(top_pos, d->geom_xmat+9*id, top_size, pnt, vec, all); + mjtNum top_intersect = ray_box(top_pos, d->geom_xmat+9*id, top_size, pnt, vec, all, NULL); if (top_intersect < 0) { return x; } @@ -728,7 +835,7 @@ mjtNum ray_sdf(const mjModel* m, const mjData* d, int g, mjtNum kMinDist = 1e-7; // exclude using bounding box - if (ray_box(d->geom_xpos+3*g, d->geom_xmat+9*g, m->geom_size+3*g, pnt, vec, NULL) < 0) { + if (ray_box(d->geom_xpos+3*g, d->geom_xmat+9*g, m->geom_size+3*g, pnt, vec, NULL, NULL) < 0) { return -1; } @@ -789,35 +896,34 @@ mjtNum mj_rayMesh(const mjModel* m, const mjData* d, int id, } // bounding box test - if (ray_box(d->geom_xpos+3*id, d->geom_xmat+9*id, m->geom_size+3*id, pnt, vec, NULL) < 0) { + if (ray_box(d->geom_xpos+3*id, d->geom_xmat+9*id, m->geom_size+3*id, pnt, vec, NULL, NULL) < 0) { return -1; } return mju_rayTree(m, d, id, pnt, vec); } - -// intersect ray with pure geom, no meshes or hfields -mjtNum mju_rayGeom(const mjtNum pos[3], const mjtNum mat[9], const mjtNum size[3], - const mjtNum pnt[3], const mjtNum vec[3], int geomtype) { +// intersect ray and find normal with primitive geom, no meshes or hfields, compute normal if given +mjtNum mju_rayGeomNormal(const mjtNum pos[3], const mjtNum mat[9], const mjtNum size[3], + const mjtNum pnt[3], const mjtNum vec[3], int geomtype, mjtNum normal[3]) { switch ((mjtGeom) geomtype) { case mjGEOM_PLANE: - return ray_plane(pos, mat, size, pnt, vec); + return ray_plane(pos, mat, size, pnt, vec, normal); case mjGEOM_SPHERE: - return ray_sphere(pos, mat, size[0]*size[0], pnt, vec); + return ray_sphere(pos, mat, size[0] * size[0], pnt, vec, normal); case mjGEOM_CAPSULE: - return ray_capsule(pos, mat, size, pnt, vec); + return ray_capsule(pos, mat, size, pnt, vec, normal); case mjGEOM_ELLIPSOID: - return ray_ellipsoid(pos, mat, size, pnt, vec); + return ray_ellipsoid(pos, mat, size, pnt, vec, normal); case mjGEOM_CYLINDER: - return ray_cylinder(pos, mat, size, pnt, vec); + return ray_cylinder(pos, mat, size, pnt, vec, normal); case mjGEOM_BOX: - return ray_box(pos, mat, size, pnt, vec, NULL); + return ray_box(pos, mat, size, pnt, vec, NULL, normal); default: mjERROR("unexpected geom type %d", geomtype); @@ -825,6 +931,11 @@ mjtNum mju_rayGeom(const mjtNum pos[3], const mjtNum mat[9], const mjtNum size[3 } } +// intersect ray with primitive geom, no meshes or hfields +mjtNum mju_rayGeom(const mjtNum pos[3], const mjtNum mat[9], const mjtNum size[3], + const mjtNum pnt[3], const mjtNum vec[3], int geomtype) { + return mju_rayGeomNormal(pos, mat, size, pnt, vec, geomtype, NULL); +} // intersect ray with flex, return nearest vertex id mjtNum mju_rayFlex(const mjModel* m, const mjData* d, int flex_layer, mjtByte flg_vert, @@ -864,7 +975,7 @@ mjtNum mju_rayFlex(const mjModel* m, const mjData* d, int flex_layer, mjtByte fl } // apply bounding-box filter - if (ray_box(pos, mat, size, pnt, vec, NULL) < 0) { + if (ray_box(pos, mat, size, pnt, vec, NULL, NULL) < 0) { return -1; } @@ -1030,7 +1141,7 @@ mjtNum mju_raySkin(int nface, int nvert, const int* face, const float* vert, } // apply bounding-box filter - if (ray_box(pos, mat, size, pnt, vec, NULL) < 0) { + if (ray_box(pos, mat, size, pnt, vec, NULL, NULL) < 0) { return -1; } @@ -1276,7 +1387,7 @@ static mjtNum mju_singleRay(const mjModel* m, mjData* d, const mjtNum pnt[3], co mjtNum* size = pos + 3; mjtNum ssz = size[0]*size[0] + size[1]*size[1] + size[2]*size[2]; mju_add3(center, pos, d->xipos+3*b); - if (ray_sphere(center, NULL, ssz, pnt, vec) < 0) { + if (ray_sphere(center, NULL, ssz, pnt, vec, NULL) < 0) { continue; } } diff --git a/src/engine/engine_ray.h b/src/engine/engine_ray.h index 6ca8c7f8..0e5a7889 100644 --- a/src/engine/engine_ray.h +++ b/src/engine/engine_ray.h @@ -54,10 +54,15 @@ MJAPI mjtNum ray_triangle(mjtNum v[][3], const mjtNum lpnt[3], const mjtNum lvec MJAPI mjtNum mj_rayMesh(const mjModel* m, const mjData* d, int geomid, const mjtNum pnt[3], const mjtNum vec[3]); -// intersect ray with pure geom, no meshes or hfields +// intersect ray with primitive geom, no meshes or hfields MJAPI mjtNum mju_rayGeom(const mjtNum pos[3], const mjtNum mat[9], const mjtNum size[3], const mjtNum pnt[3], const mjtNum vec[3], int geomtype); +// intersect ray with primitive geom, no meshes or hfields, compute normal if given +MJAPI mjtNum mju_rayGeomNormal(const mjtNum pos[3], const mjtNum mat[9], const mjtNum size[3], + const mjtNum pnt[3], const mjtNum vec[3], int geomtype, + mjtNum normal[3]); + // intersect ray with flex, return nearest vertex id MJAPI mjtNum mju_rayFlex(const mjModel* m, const mjData* d, int flex_layer, mjtByte flg_vert, mjtByte flg_edge, mjtByte flg_face, mjtByte flg_skin, int flexid, diff --git a/test/engine/engine_ray_test.cc b/test/engine/engine_ray_test.cc index 0617defa..b64c7099 100644 --- a/test/engine/engine_ray_test.cc +++ b/test/engine/engine_ray_test.cc @@ -76,8 +76,12 @@ static constexpr char kCubeletModel[] = R"( )"; +using ::std::string; +using ::testing::AnyOf; using ::testing::DoubleNear; +using ::testing::ElementsAre; using ::testing::NotNull; +using ::testing::Pointwise; using RayTest = MujocoTest; TEST_F(RayTest, NoExclusions) { @@ -397,8 +401,8 @@ void _rayMeshTest(const mjModel* m) { } TEST_F(RayTest, RayMeshPruning) { - char error[1024] = {0}; - const std::string xml_path = + char error[1024]; + const string xml_path = GetTestDataFilePath("engine/testdata/ray/stanford_bunny.xml"); mjModel* m = mj_loadXML(xml_path.c_str(), NULL, error, sizeof(error)); @@ -464,5 +468,114 @@ TEST_F(RayTest, RayHfield) { mj_deleteModel(model); } +static const char* const kPlaneModel = "engine/testdata/ray/plane.xml"; +static const char* const kSphereModel = "engine/testdata/ray/sphere.xml"; +static const char* const kCapsuleModel = "engine/testdata/ray/capsule.xml"; +static const char* const kEllipsoidModel = "engine/testdata/ray/ellipsoid.xml"; +static const char* const kCylinderModel = "engine/testdata/ray/cylinder.xml"; +static const char* const kBoxModel = "engine/testdata/ray/box.xml"; + +TEST_F(RayTest, GeomNormal) { + for (const char* path : {kPlaneModel, kSphereModel, kCapsuleModel, + kEllipsoidModel, kCylinderModel, kBoxModel}) { + const std::string xml_path = GetTestDataFilePath(path); + char error[1024]; + mjModel* m = mj_loadXML(xml_path.c_str(), 0, error, sizeof(error)); + ASSERT_THAT(m, NotNull()) << error; + + // exactly one geom and one site in each model + ASSERT_EQ(m->ngeom, 1) << path; + ASSERT_EQ(m->nsite, 1) << path; + + mjData* d = mj_makeData(m); + + // test parameters + mjtNum kDuration = 2.0; // length of rollout (seconds) + int kCompare = 100; // number of tests per rollout + int compare_every = kDuration / (m->opt.timestep * kCompare); + + // roll out and compare analytic normal with fin-diff approximation + int ntest = 0; // tests performed + int nstep = 0; // steps elapsed + while (d->time < kDuration) { + mj_step(m, d); + nstep++; + + // skip until this is timestep we should test on + if (nstep % compare_every != 1) { + continue; + } + + // geom info + const mjtNum* pos = d->geom_xpos; + const mjtNum* mat = d->geom_xmat; + const mjtNum* size = m->geom_size; + int type = m->geom_type[0]; + + // site info + const mjtNum* pnt = d->site_xpos; + const mjtNum vec[3] = {d->site_xmat[2], d->site_xmat[5], d->site_xmat[8]}; + + // compute ray length and normal + mjtNum normal[3]; + mjtNum r = mju_rayGeomNormal(pos, mat, size, pnt, vec, type, normal); + + // compare with sensor + EXPECT_EQ(r, d->sensordata[0]) << path << ", time " << d->time; + + // if no intersection, skip + if (r < 0) { + EXPECT_THAT(normal, ElementsAre(0, 0, 0)); + continue; + } + + // compute surface intersection point s + mjtNum s[3]; + mju_addScl3(s, pnt, vec, r); + + // compute intersection points ds, nudged by eps in x,y site frame + mjtNum eps = 1e-6; + mjtNum ds[2][3]; + for (int i = 0; i < 2; ++i) { + mjtNum nudge[3] = {d->site_xmat[0 + i], + d->site_xmat[3 + i], + d->site_xmat[6 + i]}; + mjtNum dpnt[3]; + mju_addScl3(dpnt, pnt, nudge, eps); + mjtNum dr = mju_rayGeomNormal(pos, mat, size, dpnt, vec, type, nullptr); + mju_addScl3(ds[i], dpnt, vec, dr); + } + + // compute in-plane tangents and expected normal + mjtNum t0[3], t1[3], expected[3]; + mju_sub3(t0, ds[0], s); + mju_sub3(t1, ds[1], s); + mju_cross(expected, t1, t0); + + // normalize expected normal, skip if degenerate + mjtNum norm = mju_normalize3(expected); + if (norm < mjMINVAL) continue; + + // flipped expected normal, should match either expected or -expected + mjtNum expected_neg[3] = {-expected[0], -expected[1], -expected[2]}; + + // compare analytic with fin-diff approximation + EXPECT_THAT(normal, AnyOf( + Pointwise(DoubleNear(10*eps), expected), + Pointwise(DoubleNear(10*eps), expected_neg) + )) << path << ", time " << d->time; + + // increment count + ntest++; + } + + // at least 10 tests should have been performed + EXPECT_GT(ntest, 10); + + mj_deleteData(d); + mj_deleteModel(m); + } +} + } // namespace } // namespace mujoco diff --git a/test/engine/testdata/ray/box.xml b/test/engine/testdata/ray/box.xml new file mode 100644 index 00000000..abecc5de --- /dev/null +++ b/test/engine/testdata/ray/box.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/test/engine/testdata/ray/capsule.xml b/test/engine/testdata/ray/capsule.xml new file mode 100644 index 00000000..16187b4d --- /dev/null +++ b/test/engine/testdata/ray/capsule.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/test/engine/testdata/ray/cylinder.xml b/test/engine/testdata/ray/cylinder.xml new file mode 100644 index 00000000..7f42db4e --- /dev/null +++ b/test/engine/testdata/ray/cylinder.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/test/engine/testdata/ray/ellipsoid.xml b/test/engine/testdata/ray/ellipsoid.xml new file mode 100644 index 00000000..f31466e5 --- /dev/null +++ b/test/engine/testdata/ray/ellipsoid.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/test/engine/testdata/ray/plane.xml b/test/engine/testdata/ray/plane.xml new file mode 100644 index 00000000..9ea9c2e5 --- /dev/null +++ b/test/engine/testdata/ray/plane.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/test/engine/testdata/ray/ray_swing.xml b/test/engine/testdata/ray/ray_swing.xml new file mode 100644 index 00000000..9904bf94 --- /dev/null +++ b/test/engine/testdata/ray/ray_swing.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/engine/testdata/ray/sphere.xml b/test/engine/testdata/ray/sphere.xml new file mode 100644 index 00000000..d224dd64 --- /dev/null +++ b/test/engine/testdata/ray/sphere.xml @@ -0,0 +1,7 @@ + + + + + + +