Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
46f925a79This file is pinned to the selected release. Download the original to open it in your design tool.
// Copyright 2026 DeepMind Technologies Limited
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Tests for the IPC contact mode, engine/engine_ipc.c.
//
// Most tests exercise the geometry/barrier kernels directly (no stepping). The
// two behavioral tests take a single mj_step on a 3x3 cloth: one checks free
// fall, one checks the intersection-free guarantee (a fast cloth cannot tunnel
// a plane in one step).
#include "src/engine/engine_collision_continuous.h"
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <string>
#include <vector>
#include <mujoco/mujoco.h>
#include <mujoco/mjtype.h>
#include "test/fixture.h"
#include <gmock/gmock.h>
#include <gtest/gtest.h>
namespace mujoco {
namespace {
// convenience shims over the MJAPI geometry kernels (pose taken from d, scratch
// dropped)
static mjtNum PtTri(const mjtNum* p, const mjtNum* a, const mjtNum* b,
const mjtNum* c) {
mjtNum cp[3], w[3];
return mjc_PtTri(p, a, b, c, cp, w);
}
static mjtNum SegSeg(const mjtNum* p1, const mjtNum* p2, const mjtNum* q1,
const mjtNum* q2) {
mjtNum cp1[3], cp2[3], st[2];
return mjc_SegSeg(p1, p2, q1, q2, cp1, cp2, st);
}
static mjtNum GeomDist(const mjModel* m, const mjData* d, int gi,
const mjtNum* x, mjtNum* n) {
return mjc_GeomDist(m, gi, d->geom_xpos + 3 * gi, d->geom_xmat + 9 * gi, x, n,
1e30);
}
static int GeomVerts(const mjModel* m, const mjData* d, int gi, mjtNum* out) {
return mjc_GeomVerts(m, gi, d->geom_xpos + 3 * gi, d->geom_xmat + 9 * gi,
out);
}
static int GeomEdges(const mjModel* m, const mjData* d, int gi, mjtNum* out) {
return mjc_GeomEdges(m, gi, d->geom_xpos + 3 * gi, d->geom_xmat + 9 * gi,
out);
}
using ::testing::NotNull;
using IpcTest = MujocoTest;
static mjModel* Load(const char* xml) {
char error[1024];
MjModelPtr model = LoadModelFromString(xml, error, sizeof(error));
EXPECT_THAT(model.get(), NotNull()) << error;
return model.release();
}
//---------------------------------- element distances
//--------------------------------------------
// point-triangle distance: interior (perpendicular), edge region, vertex region
TEST_F(IpcTest, PointTriangleDistance) {
mjtNum a[3] = {0, 0, 0}, b[3] = {1, 0, 0}, c[3] = {0, 1, 0};
mjtNum p_above[3] = {0.2, 0.2, 0.5}; // over the interior
EXPECT_NEAR(PtTri(p_above, a, b, c), 0.5, 1e-12);
mjtNum p_edge[3] = {-1, 0.5, 0}; // nearest the x=0 edge
EXPECT_NEAR(PtTri(p_edge, a, b, c), 1.0, 1e-12);
mjtNum p_vert[3] = {-3, -4, 0}; // nearest vertex a
EXPECT_NEAR(PtTri(p_vert, a, b, c), 5.0, 1e-12);
mjtNum p_on[3] = {0.25, 0.25, 0}; // on the triangle
EXPECT_NEAR(PtTri(p_on, a, b, c), 0.0, 1e-12);
}
// segment-segment distance: perpendicular crossing, collinear gap, parallel
// offset
TEST_F(IpcTest, SegmentSegmentDistance) {
mjtNum p1[3] = {-1, 0, 0}, p2[3] = {1, 0, 0};
mjtNum q1[3] = {0, -1, 0.3}, q2[3] = {0, 1, 0.3}; // perpendicular, 0.3 above
EXPECT_NEAR(SegSeg(p1, p2, q1, q2), 0.3, MjTol(1e-12, 1e-6));
mjtNum r1[3] = {2, 0, 0}, r2[3] = {3, 0, 0}; // collinear, gap 1
EXPECT_NEAR(SegSeg(p1, p2, r1, r2), 1.0, 1e-12);
mjtNum s1[3] = {-1, 0, 0.5}, s2[3] = {1, 0, 0.5}; // parallel, 0.5 above
EXPECT_NEAR(SegSeg(p1, p2, s1, s2), 0.5, 1e-12);
}
//---------------------------------- geom distance
//------------------------------------------------
constexpr char kPrimitivesXml[] = R"(
<mujoco>
<worldbody>
<geom name="box" type="box" size="0.1 0.2 0.3" pos="0 0 0"/>
<geom name="sphere" type="sphere" size="0.1" pos="1 0 0"/>
<geom name="plane" type="plane" size="0 0 1" pos="0 0 -1"/>
</worldbody>
</mujoco>
)";
TEST_F(IpcTest, GeomDistance) {
mjModel* m = Load(kPrimitivesXml);
mjData* d = mj_makeData(m);
mj_forward(m, d);
int box = mj_name2id(m, mjOBJ_GEOM, "box");
int sphere = mj_name2id(m, mjOBJ_GEOM, "sphere");
int plane = mj_name2id(m, mjOBJ_GEOM, "plane");
mjtNum n[3];
// box (half-extent 0.1 in x): point on +x at 0.5 -> surface distance 0.4,
// normal +x
mjtNum px[3] = {0.5, 0, 0};
EXPECT_NEAR(GeomDist(m, d, box, px, n), 0.4, MjTol(1e-9, 1e-6));
EXPECT_NEAR(n[0], 1, 1e-9);
EXPECT_NEAR(n[1], 0, 1e-9);
EXPECT_NEAR(n[2], 0, 1e-9);
// interior point -> negative signed distance
mjtNum pc[3] = {0, 0, 0};
EXPECT_LT(GeomDist(m, d, box, pc, n), 0);
// sphere radius 0.1 at (1,0,0): point at (1.3,0,0) -> 0.2, normal +x
mjtNum ps[3] = {1.3, 0, 0};
EXPECT_NEAR(GeomDist(m, d, sphere, ps, n), 0.2, MjTol(1e-9, 1e-6));
EXPECT_NEAR(n[0], 1, 1e-9);
// plane at z=-1: point at z=0 -> 1.0, normal +z
mjtNum pp[3] = {0.3, -0.2, 0};
EXPECT_NEAR(GeomDist(m, d, plane, pp, n), 1.0, 1e-9);
EXPECT_NEAR(n[2], 1, 1e-9);
mj_deleteData(d);
mj_deleteModel(m);
}
//---------------------------------- geom sharp features
//------------------------------------------
// a box exposes its 8 corners (at +/-size) and 12 edges
TEST_F(IpcTest, BoxFeatures) {
constexpr char xml[] = R"(
<mujoco><worldbody>
<geom type="box" size="0.1 0.2 0.3"/>
</worldbody></mujoco>)";
mjModel* m = Load(xml);
mjData* d = mj_makeData(m);
mj_forward(m, d);
mjtNum verts[8 * 3], edges[12 * 6];
int nv = GeomVerts(m, d, /*gi=*/0, verts);
int ne = GeomEdges(m, d, /*gi=*/0, edges);
EXPECT_EQ(nv, 8);
EXPECT_EQ(ne, 12);
const mjtNum tol = MjTol(1e-9, 1e-6);
for (int i = 0; i < nv; i++) {
EXPECT_NEAR(std::fabs(verts[3 * i + 0]), 0.1, tol);
EXPECT_NEAR(std::fabs(verts[3 * i + 1]), 0.2, tol);
EXPECT_NEAR(std::fabs(verts[3 * i + 2]), 0.3, tol);
}
// every box edge has unit length along exactly one axis (here 0.2, 0.4, or
// 0.6)
for (int i = 0; i < ne; i++) {
mjtNum dx = edges[6 * i + 3] - edges[6 * i + 0];
mjtNum dy = edges[6 * i + 4] - edges[6 * i + 1];
mjtNum dz = edges[6 * i + 5] - edges[6 * i + 2];
mjtNum len = std::sqrt(dx * dx + dy * dy + dz * dz);
EXPECT_TRUE(std::fabs(len - 0.2) < tol || std::fabs(len - 0.4) < tol ||
std::fabs(len - 0.6) < tol)
<< "edge " << i << " length " << len;
}
mj_deleteData(d);
mj_deleteModel(m);
}
// a convex mesh exposes its vertices and its (deduplicated) hull edges; a
// tetrahedron has 4 and 6
TEST_F(IpcTest, MeshFeatures) {
constexpr char xml[] = R"(
<mujoco>
<asset><mesh name="tet" vertex="0 0 0 1 0 0 0 1 0 0 0 1"/></asset>
<worldbody><geom type="mesh" mesh="tet"/></worldbody>
</mujoco>)";
mjModel* m = Load(xml);
mjData* d = mj_makeData(m);
mj_forward(m, d);
mjtNum verts[64 * 3], edges[256 * 6];
int nv = GeomVerts(m, d, /*gi=*/0, verts);
int ne = GeomEdges(m, d, /*gi=*/0, edges);
EXPECT_EQ(nv, 4); // tetrahedron vertices
EXPECT_EQ(ne, 6); // tetrahedron edges (each shared hull edge emitted once)
mj_deleteData(d);
mj_deleteModel(m);
}
//---------------------------------- contact mode behavior
//------------------------------------------
// a single 2D cloth, no contact: one step is free fall (qvel gains -g*dt on
// every free vertex)
constexpr char kClothXml[] = R"(
<mujoco>
<option timestep="0.002" integrator="discrete" solver="CG"><flag ipc="enable"/></option>
<worldbody>
<flexcomp name="cloth" type="grid" dim="2" count="3 3 1" spacing="0.05 0.05 1"
radius="0.005" mass="0.05" pos="0 0 0.5"/>
</worldbody>
</mujoco>
)";
// The flag applies model-wide: every supported flex has passive contact of the
// flag's own kind, so the penalty form is replaced rather than added to.
// Running both applies each pair's force twice; that drove the sheets 1.9 m
// into each other and then to NaN.
TEST_F(IpcTest, FlagOverridesPassiveContact) {
static constexpr char kWith[] = R"(
<mujoco>
<option timestep="0.002" integrator="discrete" solver="CG" iterations="400">
<flag ipc="enable"/>
</option>
<worldbody>
<flexcomp name="lower" type="grid" dim="2" count="9 9 1" spacing=".04 .04 1"
radius=".004" mass=".3" pos="0 0 .2">
<contact selfcollide="auto" passive="true"/>
<elasticity young="1e5" poisson=".2" thickness="2e-3" elastic2d="both" damping="1e-4"/>
<pin id="0 8 72 80"/>
</flexcomp>
<flexcomp name="upper" type="grid" dim="2" count="5 5 1" spacing=".04 .04 1"
radius=".004" mass=".1" pos="0 0 .27">
<contact selfcollide="auto" passive="true"/>
<elasticity young="1e5" poisson=".2" thickness="2e-3" elastic2d="both" damping="1e-4"/>
</flexcomp>
</worldbody>
</mujoco>
)";
std::string without(kWith);
for (size_t i = without.find(" passive=\"true\""); i != std::string::npos;
i = without.find(" passive=\"true\"")) {
without.erase(i, std::string(" passive=\"true\"").size());
}
mjModel* mw = Load(kWith);
mjModel* mo = Load(without.c_str());
mjData* dw = mj_makeData(mw);
mjData* do_ = mj_makeData(mo);
for (int s = 0; s < 300; s++) {
mj_step(mw, dw);
mj_step(mo, do_);
// the passive path must be off: no contact is excluded to it, and it
// publishes no rows for the metric
for (int i = 0; i < dw->ncon; i++) {
ASSERT_NE(dw->contact[i].exclude, 4) << "passive exclusion at step " << s;
}
ASSERT_EQ(dw->nefmcon, 0) << "passive rows published at step " << s;
ASSERT_FALSE(dw->warning[mjWARN_BADQACC].number)
<< "diverged at step " << s;
}
for (int i = 0; i < mw->nq; i++) {
EXPECT_EQ(dw->qpos[i], do_->qpos[i]) << "qpos " << i << " differs";
}
mj_deleteData(do_);
mj_deleteData(dw);
mj_deleteModel(mo);
mj_deleteModel(mw);
}
// the reported constraint force accounts for the contact pairs: at rest on a
// plane, M*qacc equals qfrc_smooth + qfrc_constraint and the contact rows carry
// the cloth's weight
TEST_F(IpcTest, ConstraintForceReported) {
static constexpr char kXml[] = R"(
<mujoco>
<option timestep="0.002" integrator="discrete" solver="CG" iterations="400">
<flag ipc="enable"/>
</option>
<worldbody>
<geom name="floor" type="plane" size="0 0 1" pos="0 0 0"/>
<flexcomp name="cloth" type="grid" dim="2" count="5 5 1" spacing="0.04 0.04 1"
radius="0.004" mass="0.1" pos="0 0 0.03">
<edge equality="true"/>
<contact selfcollide="none"/>
</flexcomp>
</worldbody>
</mujoco>
)";
mjModel* m = Load(kXml);
mjData* d = mj_makeData(m);
for (int s = 0; s < 500; s++) {
mj_step(m, d);
ASSERT_FALSE(d->warning[mjWARN_BADQACC].number) << "diverged at step " << s;
}
int nv = m->nv;
std::vector<mjtNum> Mqacc(nv);
mj_mulM(m, d, Mqacc.data(), d->qacc);
mjtNum scale = 0, residual = 0, load = 0;
for (int i = 0; i < nv; i++) {
scale = std::max(scale, std::abs(d->qfrc_smooth[i]));
residual = std::max(residual, std::abs(Mqacc[i] - d->qfrc_smooth[i] -
d->qfrc_constraint[i]));
if (i % 3 == 2)
load += d->qfrc_constraint[i]; // the vertices' vertical dofs
}
EXPECT_GT(scale, 0);
// the identity M*qacc = qfrc_smooth + qfrc_constraint holds with the contact
// pairs included to the solver's own tolerance: the identity is the CG's
// stationarity condition
EXPECT_LT(residual, MjTol(1e-4, 1e-2) * scale)
<< "residual " << residual << " scale " << scale;
// the pairs carry the weight: the vertical constraint force sums to m*g
mjtNum weight = 0.1 * 9.81;
EXPECT_NEAR(load, weight, MjTol(1e-3, 1e-2) * weight);
mj_deleteData(d);
mj_deleteModel(m);
}
// a 5x5 cloth 3 cm above the floor, with slots for extra option flags, extra
// geoms and flex contact attributes
static constexpr char kClothOverFloorXml[] = R"(
<mujoco>
<option timestep="0.002" integrator="discrete" solver="CG" iterations="400">
<flag ipc="enable" %s/>
</option>
<worldbody>
<geom name="floor" type="plane" size="0 0 1"/>
%s
<flexcomp name="cloth" type="grid" dim="2" count="5 5 1" spacing="0.04 0.04 1"
radius="0.004" mass="0.1" pos="0 0 0.03">
<edge equality="true"/>
<contact selfcollide="none" %s/>
</flexcomp>
</worldbody>
</mujoco>
)";
// the lowest vertex of the cloth above after 1 s
static mjtNum ClothMinHeight(const char* flags, const char* geoms,
const char* contact) {
char xml[2048];
snprintf(xml, sizeof(xml), kClothOverFloorXml, flags, geoms, contact);
mjModel* m = Load(xml);
mjData* d = mj_makeData(m);
for (int s = 0; s < 500; s++) {
mj_step(m, d);
}
mjtNum zmin = 1;
for (int v = 0; v < m->nflexvert; v++) {
zmin = std::min(zmin, d->flexvert_xpos[3 * v + 2]);
}
mj_deleteData(d);
mj_deleteModel(m);
return zmin;
}
// the contact pairs follow the native collision filtering: none with the
// contact flag disabled, and the contype/conaffinity rule between a flex and a
// geom
TEST_F(IpcTest, ContactFlagAndMasksFilterGeomPairs) {
// the cloth rests on the floor
EXPECT_GT(ClothMinHeight("", "", ""), 0);
// contact disabled: it falls through
EXPECT_LT(ClothMinHeight("contact=\"disable\"", "", ""), -1);
// the flex's masks zero: filtered against everything
EXPECT_LT(ClothMinHeight("", "", "contype=\"0\" conaffinity=\"0\""), -1);
// the bitmask rule: type 2 misses the floor's affinity 1 and lands on a
// lower plane whose affinity 2 meets it
mjtNum z = ClothMinHeight(
"",
"<geom type=\"plane\" size=\"0 0 1\" pos=\"0 0 -0.5\" contype=\"0\" "
"conaffinity=\"2\"/>",
"contype=\"2\" conaffinity=\"2\"");
EXPECT_GT(z, -0.5);
EXPECT_LT(z, -0.45);
}
TEST_F(IpcTest, FreeFall) {
mjModel* m = Load(kClothXml);
mjData* d = mj_makeData(m);
int f = mj_name2id(m, mjOBJ_FLEX, "cloth");
ASSERT_GE(f, 0);
mj_step(m, d);
EXPECT_NEAR(d->time, m->opt.timestep, 1e-12);
// every free vertex slide-joint along z should hold v = g_z * dt after one
// step
mjtNum want = m->opt.gravity[2] * m->opt.timestep;
int checked = 0;
for (int i = 0; i < m->flex_vertnum[f]; i++) {
int bid = m->flex_vertbodyid[m->flex_vertadr[f] + i];
for (int j = 0; j < m->body_jntnum[bid]; j++) {
int jid = m->body_jntadr[bid] + j;
if (m->jnt_type[jid] == mjJNT_SLIDE && m->jnt_axis[3 * jid + 2] > 0.5) {
EXPECT_NEAR(d->qvel[m->jnt_dofadr[jid]], want, MjTol(1e-6, 1e-4))
<< "vertex " << i;
checked++;
}
}
}
EXPECT_GT(checked, 0); // the model really did expose free z slide joints
EXPECT_FALSE(std::isnan(d->qpos[0]));
mj_deleteData(d);
mj_deleteModel(m);
}
// the intersection-free guarantee: a cloth driven hard at a plane cannot pass
// through in one step (a single explicit Euler step at this speed would put it
// far below the plane).
TEST_F(IpcTest, ContactBlocksTunneling) {
constexpr char xml[] = R"(
<mujoco>
<option timestep="0.002" integrator="discrete" solver="CG"><flag ipc="enable"/></option>
<worldbody>
<geom name="floor" type="plane" size="0 0 1" pos="0 0 0"/>
<flexcomp name="cloth" type="grid" dim="2" count="3 3 1" spacing="0.05 0.05 1"
radius="0.005" mass="0.05" pos="0 0 0.05"/>
</worldbody>
</mujoco>)";
mjModel* m = Load(xml);
mjData* d = mj_makeData(m);
int f = mj_name2id(m, mjOBJ_FLEX, "cloth");
ASSERT_GE(f, 0);
// drive every vertex straight down at 50 m/s (0.1 m of travel per 2 ms step,
// far past the plane)
for (int i = 0; i < m->flex_vertnum[f]; i++) {
int bid = m->flex_vertbodyid[m->flex_vertadr[f] + i];
for (int j = 0; j < m->body_jntnum[bid]; j++) {
int jid = m->body_jntadr[bid] + j;
if (m->jnt_type[jid] == mjJNT_SLIDE && m->jnt_axis[3 * jid + 2] > 0.5) {
d->qvel[m->jnt_dofadr[jid]] = -50;
}
}
}
mj_step(m, d);
mj_kinematics(m, d);
mj_flex(m, d);
// no vertex crossed the plane (center stays above z=0; the radius keeps the
// surface above that)
mjtNum minz = 1e30;
for (int i = 0; i < m->flex_vertnum[f]; i++) {
mjtNum z = d->flexvert_xpos[3 * (m->flex_vertadr[f] + i) + 2];
if (z < minz) minz = z;
}
EXPECT_GT(minz, 0) << "cloth tunneled through the plane";
EXPECT_FALSE(std::isnan(minz));
mj_deleteData(d);
mj_deleteModel(m);
}
// an arm carrying a fully pinned 3x3 cloth as a paddle, with slots for the
// option flags, the arm's joint and a second flex
static constexpr char kPaddleXml[] = R"(
<mujoco>
<option timestep="0.002" integrator="discrete" solver="CG" iterations="400">%s</option>
<worldbody>
<geom name="floor" type="plane" size="0 0 1"/>
<body name="arm" pos="0 0 0.2">
<joint name="carrier" %s/>
<geom type="capsule" fromto="0 0 0 0.1 0 0" size="0.005" mass="0.02"
contype="0" conaffinity="0"/>
<flexcomp name="paddle" type="grid" dim="2" count="3 3 1" spacing="0.04 0.04 1"
radius="0.004" mass="0.05" pos="0.06 0 0">
<contact selfcollide="none"/>
<pin id="0 1 2 3 4 5 6 7 8"/>
</flexcomp>
</body>
%s
</worldbody>
</mujoco>
)";
// the arm on a sprung, well-damped vertical slide
static constexpr char kSlide[] =
"type=\"slide\" axis=\"0 0 1\" stiffness=\"50\" damping=\"2\"";
// a body carrying pinned flex vertices is integrated like any articulated
// body: released 5 cm below its rest, the arm with the pinned paddle follows
// the same trajectory under the IPC mode as under the plain discrete integrator
TEST_F(IpcTest, PinnedFlexBodyIntegrates) {
mjtNum q[2], v[2];
const char* flags[2] = {"<flag ipc=\"enable\"/>", ""};
for (int i = 0; i < 2; i++) {
char xml[2048];
snprintf(xml, sizeof(xml), kPaddleXml, flags[i], kSlide, "");
mjModel* m = Load(xml);
mjData* d = mj_makeData(m);
d->qpos[0] = -0.05;
for (int s = 0; s < 200; s++) {
mj_step(m, d);
}
q[i] = d->qpos[0];
v[i] = d->qvel[0];
mj_deleteData(d);
mj_deleteModel(m);
}
EXPECT_GT(std::fabs(q[1] + 0.05), 0.005) << "the arm did not move";
EXPECT_NEAR(q[0], q[1], MjTol(1e-8, 1e-4));
EXPECT_NEAR(v[0], v[1], MjTol(1e-6, 1e-3));
}
// a free cloth dropped on the pinned paddle rests on it and loads the arm
// through the pinned corners: the slide sags further than under the paddle's
// own weight, and the slide's constraint force closes its equation of motion
TEST_F(IpcTest, PinnedPaddleCarriesCloth) {
static constexpr char kCloth[] = R"(
<flexcomp name="cloth" type="grid" dim="2" count="3 3 1" spacing="0.03 0.03 1"
radius="0.004" mass="0.05" pos="0.06 0 0.24">
<edge equality="true"/>
<contact selfcollide="none"/>
</flexcomp>)";
mjtNum q[2];
const char* extra[2] = {"", kCloth};
for (int i = 0; i < 2; i++) {
char xml[4096];
snprintf(xml, sizeof(xml), kPaddleXml, "<flag ipc=\"enable\"/>", kSlide,
extra[i]);
mjModel* m = Load(xml);
mjData* d = mj_makeData(m);
for (int s = 0; s < 500; s++) {
mj_step(m, d);
ASSERT_FALSE(d->warning[mjWARN_BADQACC].number)
<< "diverged at step " << s;
}
q[i] = d->qpos[0];
if (i == 1) {
// the cloth stays on the paddle, above the floor by far
int f = mj_name2id(m, mjOBJ_FLEX, "cloth");
for (int k = 0; k < m->flex_vertnum[f]; k++) {
EXPECT_GT(d->flexvert_xpos[3 * (m->flex_vertadr[f] + k) + 2], 0.15)
<< "cloth vertex " << k << " fell off the paddle";
}
// M*qacc = qfrc_smooth + qfrc_constraint on the slide: the rows of the
// pairs with pinned corners act on the arm's dof. The mode applies the
// joint damping implicitly, at the new velocity, where qfrc_smooth
// carries it at the old one, so that increment is accounted for; the
// identity holds to the outer loop's velocity tolerance on the arm's
// dof, looser than the CG's stationarity on the cloth's
std::vector<mjtNum> Mqacc(m->nv);
mj_mulM(m, d, Mqacc.data(), d->qacc);
mjtNum scale = 0;
for (int j = 0; j < m->nv; j++)
scale = std::max(scale, std::abs(d->qfrc_smooth[j]));
mjtNum damping = m->dof_damping[0] * d->qacc[0] * m->opt.timestep;
EXPECT_LT(std::abs(Mqacc[0] - d->qfrc_smooth[0] + damping -
d->qfrc_constraint[0]),
MjTol(1e-3, 1e-1) * scale);
}
mj_deleteData(d);
mj_deleteModel(m);
}
// the arm sags under its own weight (a pinned vertex carries no mass, so the
// paddle adds none), and further under the cloth's: 4 mm and 10 mm at this
// stiffness; the slide keeps the paddle level, so the frictionless cloth
// stays on it
mjtNum dir = q[0] > 0 ? 1 : -1;
EXPECT_GT(std::fabs(q[0]), 1e-3) << "the arm did not sag";
EXPECT_GT((q[1] - q[0]) * dir, 3e-3)
<< "the cloth's weight did not reach the arm";
}
// a body with three slide joints is a free flex vertex only when it is the
// vertex: a cloth fully pinned to one such body shares the body's three dofs
// among its nine vertices and must take the pinned path once, so a step
// moves the carrier by h*v like the discrete integrator, not nine times that
TEST_F(IpcTest, SlideCarrierMovesOnce) {
static constexpr char kXml[] = R"(
<mujoco>
<option timestep="0.002" integrator="discrete" solver="CG" gravity="0 0 0">
%s
</option>
<worldbody>
<body name="carrier" pos="0 0 0.5">
<joint type="slide" axis="1 0 0"/>
<joint type="slide" axis="0 1 0"/>
<joint type="slide" axis="0 0 1"/>
<geom type="sphere" size="0.01" mass="0.1" contype="0" conaffinity="0"/>
<flexcomp name="cloth" type="grid" dim="2" count="3 3 1" spacing="0.03 0.03 1"
radius="0.004" mass="0.05" pos="0.06 0 0">
<pin id="0 1 2 3 4 5 6 7 8"/>
<edge equality="true"/>
<contact selfcollide="none"/>
</flexcomp>
</body>
</worldbody>
</mujoco>)";
const char* flags[2] = {"<flag ipc=\"enable\"/>", ""};
mjtNum qx[2], cx[2];
for (int i = 0; i < 2; i++) {
char xml[2048];
snprintf(xml, sizeof(xml), kXml, flags[i]);
mjModel* m = Load(xml);
mjData* d = mj_makeData(m);
d->qvel[0] = 1;
for (int s = 0; s < 10; s++) mj_step(m, d);
int f = mj_name2id(m, mjOBJ_FLEX, "cloth");
qx[i] = d->qpos[0];
cx[i] = 0;
for (int k = 0; k < m->flex_vertnum[f]; k++)
cx[i] +=
d->flexvert_xpos[3 * (m->flex_vertadr[f] + k)] / m->flex_vertnum[f];
EXPECT_NEAR(d->qvel[0], 1, MjTol(1e-12, 1e-6));
mj_deleteData(d);
mj_deleteModel(m);
}
EXPECT_NEAR(qx[0], 0.02, MjTol(1e-12, 1e-6))
<< "the carrier moved by h*v per step";
EXPECT_NEAR(qx[0], qx[1], MjTol(1e-12, 1e-6)) << "IPC vs discrete: carrier";
EXPECT_NEAR(cx[0], cx[1], MjTol(1e-12, 1e-6)) << "IPC vs discrete: cloth";
}
// a pinned vertex rides a body whose chain translates: its path over a step is
// the straight segment the CCD sweeps. A hinge or a ball joint on the chain
// moves it on an arc and is refused, at load, since the compiler steps the
// model once; a ball joint has three dofs like a flex vertex's slides and must
// not be taken for one
TEST_F(IpcTest, PinnedCarrierMustTranslate) {
const char* joints[2] = {"type=\"hinge\" axis=\"0 1 0\"", "type=\"ball\""};
for (int i = 0; i < 2; i++) {
char xml[2048], error[1024];
snprintf(xml, sizeof(xml), kPaddleXml, "<flag ipc=\"enable\"/>", joints[i],
"");
MjModelPtr m = LoadModelFromString(xml, error, sizeof(error));
EXPECT_THAT(m.get(), testing::IsNull()) << "joint " << joints[i];
EXPECT_THAT(error, testing::HasSubstr("slide joints only"));
}
}
// a fully pinned paddle on a free vertical slide falls onto the floor and
// stops on it: the pinned vertices move with their body and meet the plane
// through the mode's pairs, the only contact they have under the flag
TEST_F(IpcTest, PinnedPaddleStopsOnPlane) {
char xml[2048];
snprintf(xml, sizeof(xml), kPaddleXml, "<flag ipc=\"enable\"/>",
"type=\"slide\" axis=\"0 0 1\" damping=\"0.05\"", "");
mjModel* m = Load(xml);
mjData* d = mj_makeData(m);
int f = mj_name2id(m, mjOBJ_FLEX, "paddle");
mjtNum zmin = 1;
for (int s = 0; s < 500; s++) {
mj_step(m, d);
ASSERT_FALSE(d->warning[mjWARN_BADQACC].number) << "diverged at step " << s;
for (int k = 0; k < m->flex_vertnum[f]; k++)
zmin = std::min(zmin, d->flexvert_xpos[3 * (m->flex_vertadr[f] + k) + 2]);
}
EXPECT_GT(zmin, 0) << "the paddle passed through the floor";
EXPECT_LT(d->qpos[0], -0.15) << "the arm did not fall";
EXPECT_LT(std::fabs(d->qvel[0]), 0.05) << "the arm is not at rest";
mj_deleteData(d);
mj_deleteModel(m);
}
// under the flag the collision pipeline does not generate the contacts the
// mode resolves: a cloth on the floor has no native contact with it, while a
// free ball resting on the cloth keeps its native contacts, the mode leaving
// flex against moving bodies to the constraint solver
TEST_F(IpcTest, NativeCollisionSkipsTheModesPairs) {
static constexpr char kXml[] = R"(
<mujoco>
<option timestep="0.002" integrator="discrete" solver="CG" iterations="400">%s</option>
<worldbody>
<geom name="floor" type="plane" size="0 0 1"/>
<flexcomp name="cloth" type="grid" dim="2" count="5 5 1" spacing="0.04 0.04 1"
radius="0.004" mass="0.1" pos="0 0 0.004">
<edge equality="true"/>
<contact selfcollide="none"/>
</flexcomp>
<body name="ball" pos="0 0 0.05">
<freejoint/>
<geom type="sphere" size="0.02" mass="0.05"/>
</body>
</worldbody>
</mujoco>)";
for (int flag = 0; flag < 2; flag++) {
char xml[2048];
snprintf(xml, sizeof(xml), kXml, flag ? "<flag ipc=\"enable\"/>" : "");
mjModel* m = Load(xml);
mjData* d = mj_makeData(m);
for (int s = 0; s < 100; s++) {
mj_step(m, d);
}
int floor = 0, ball = 0;
for (int c = 0; c < d->ncon; c++) {
const mjContact* con = d->contact + c;
if (con->flex[0] < 0 && con->flex[1] < 0) continue;
int g = con->geom[0] >= 0 ? con->geom[0] : con->geom[1];
if (g < 0) continue;
if (m->geom_bodyid[g] == 0) {
floor++;
} else {
ball++;
}
}
if (flag) {
EXPECT_EQ(floor, 0) << "cloth-floor contacts generated under the flag";
} else {
EXPECT_GT(floor, 0) << "the cloth does not touch the floor";
}
EXPECT_GT(ball, 0) << "the ball's contacts with the cloth are missing";
mj_deleteData(d);
mj_deleteModel(m);
}
}
// a cloth dropped on a sphere or a capsule too small to come near any of its
// vertices: the obstacle meets a triangle's interior (the sphere) or its edges
// (the capsule), and the cloth must tent over it rather than pass through
TEST_F(IpcTest, SmoothGeomsCoverTriangleInterior) {
static constexpr char kXml[] = R"(
<mujoco>
<option timestep="0.002" integrator="discrete" solver="CG" iterations="400">
<flag ipc="enable"/>
</option>
<worldbody>
<geom name="floor" type="plane" size="0 0 1"/>
%s
<flexcomp name="cloth" type="grid" dim="2" count="5 5 1" spacing="0.04 0.04 1"
radius="0.004" mass="0.1" pos="0 0 0.05">
<edge equality="true"/>
<contact selfcollide="none"/>
</flexcomp>
</worldbody>
</mujoco>
)";
// a 5 mm sphere under a triangle's interior, 27 mm from the nearest vertex,
// and a 5 mm capsule along x midway between two vertex rows
const char* obstacles[2] = {
"<geom name=\"obstacle\" type=\"sphere\" size=\"0.005\" "
"pos=\"0.021 0.019 0.02\"/>",
"<geom name=\"obstacle\" type=\"capsule\" size=\"0.005 0.03\" "
"pos=\"0 0.02 0.02\" euler=\"0 90 0\"/>"};
for (const char* obstacle : obstacles) {
char xml[2048];
snprintf(xml, sizeof(xml), kXml, obstacle);
mjModel* m = Load(xml);
mjData* d = mj_makeData(m);
for (int s = 0; s < 500; s++) {
mj_step(m, d);
}
int f = mj_name2id(m, mjOBJ_FLEX, "cloth");
int gi = mj_name2id(m, mjOBJ_GEOM, "obstacle");
mjtNum r = m->geom_size[3 * gi], half = m->geom_size[3 * gi + 1];
const mjtNum* gp = d->geom_xpos + 3 * gi;
const mjtNum* gR = d->geom_xmat + 9 * gi;
// probe points on the obstacle's axis: the centre and, for the capsule,
// both ends of the axis
int nprobe = m->geom_type[gi] == mjGEOM_CAPSULE ? 3 : 1;
for (int q = 0; q < nprobe; q++) {
mjtNum along = (q == 0 ? 0 : (q == 1 ? half : -half));
mjtNum p[3] = {gp[0] + along * gR[2], gp[1] + along * gR[5],
gp[2] + along * gR[8]};
// the closest cloth triangle to the probe point
mjtNum best = 1e30, bcp[3] = {0, 0, 0};
const int* el = m->flex_elem + m->flex_elemdataadr[f];
const mjtNum* xv = d->flexvert_xpos + 3 * m->flex_vertadr[f];
for (int e = 0; e < m->flex_elemnum[f]; e++) {
mjtNum cp[3], w[3];
mjtNum dd = mjc_PtTri(p, xv + 3 * el[3 * e], xv + 3 * el[3 * e + 1],
xv + 3 * el[3 * e + 2], cp, w);
if (dd < best) {
best = dd;
for (int c = 0; c < 3; c++) bcp[c] = cp[c];
}
}
EXPECT_GT(bcp[2], p[2]) << obstacle << ": the cloth passed through";
EXPECT_GT(best, 0.9 * r)
<< obstacle << ": a triangle inside the obstacle";
}
mj_deleteData(d);
mj_deleteModel(m);
}
}
// Total linear momentum, summed over the flex vertex bodies (each a point mass
// on three slide joints). Read from qvel rather than d->cvel, which is stale
// after mj_step.
static mjtNum FlexMomentum(const mjModel* m, const mjData* d, mjtNum P[3]) {
mjtNum mass = 0;
mju_zero3(P);
for (int b = 1; b < m->nbody; b++) {
int adr = m->body_dofadr[b];
if (adr < 0 || m->body_dofnum[b] != 3) {
continue;
}
mass += m->body_mass[b];
for (int k = 0; k < 3; k++) {
P[k] += m->body_mass[b] * d->qvel[adr + k];
}
}
return mass;
}
// CONSERVATION under self-contact. Two cloths collide in mid-air with nothing
// else in the scene: no pins, no floor, no geoms, so contact, elastic and
// damping forces are all INTERNAL and cancel in the sum. Summing the step
// update over every vertex therefore gives, exactly,
//
// sum_i m_i * (v_i+ - v_i) == h * M_total * g
//
// independent of the contact solve -- PROVIDED the committed step is the
// solution of that solve. This is what makes the test worth its cost: a step
// that commits a line-search-truncated iterate discards the motion the
// truncation dropped, and the deficit lands here as missing momentum. That
// failure looks healthy to every other test we have (no NaN, no tunneling,
// plausible timings), so without this invariant a solver that silently stops
// advancing time passes CI.
TEST_F(IpcTest, SelfContactConservesMomentum) {
constexpr char xml[] = R"(
<mujoco>
<option timestep="0.002" integrator="discrete" solver="CG"><flag ipc="enable"/></option>
<worldbody>
<flexcomp name="lower" type="grid" dim="2" count="3 3 1" spacing="0.04 0.04 1"
radius="0.004" mass="0.02" pos="0 0 0.5"/>
<flexcomp name="upper" type="grid" dim="2" count="3 3 1" spacing="0.04 0.04 1"
radius="0.004" mass="0.02" pos="0 0 0.54"/>
</worldbody>
</mujoco>)";
mjModel* m = Load(xml);
mjData* d = mj_makeData(m);
int upper = mj_name2id(m, mjOBJ_FLEX, "upper");
ASSERT_GE(upper, 0);
// drive the upper cloth down onto the lower one: under uniform gravity alone
// the two would fall together and never touch, so the approach velocity is
// what creates the contact
for (int i = 0; i < m->flex_vertnum[upper]; i++) {
int bid = m->flex_vertbodyid[m->flex_vertadr[upper] + i];
for (int j = 0; j < m->body_jntnum[bid]; j++) {
int jid = m->body_jntadr[bid] + j;
if (m->jnt_type[jid] == mjJNT_SLIDE && m->jnt_axis[3 * jid + 2] > 0.5) {
d->qvel[m->jnt_dofadr[jid]] = -2;
}
}
}
mjtNum P0[3], P1[3];
mjtNum mass = FlexMomentum(m, d, P0);
ASSERT_GT(mass, 0) << "no flex vertex bodies found: the scene is not the one "
"this test assumes";
mjtNum h = m->opt.timestep;
mjtNum scale = mass * mju_norm3(m->opt.gravity) *
h; // magnitude of one step's momentum change
// 40 steps: the cloths meet at ~step 10 and stay in contact for the rest, so
// the window covers approach, impact and sustained contact
for (int s = 0; s < 40; s++) {
mj_step(m, d);
FlexMomentum(m, d, P1);
ASSERT_FALSE(std::isnan(P1[2])) << "NaN at step " << s;
for (int k = 0; k < 3; k++) {
mjtNum got = P1[k] - P0[k];
mjtNum want = h * mass * m->opt.gravity[k];
// tolerance is a fraction of ONE step's momentum change, so it stays
// meaningful under sustained contact; the failure this guards discards
// most of a step and is O(1) relative
EXPECT_NEAR(got, want, MjTol(1e-6, 1e-3) * scale)
<< "step " << s << " axis " << k
<< ": internal contact forces must cancel";
P0[k] = P1[k];
}
}
mj_deleteData(d);
mj_deleteModel(m);
}
// a cloth that STARTS inside a geom is pushed back out. Broad-phase must keep
// pairs whose gap is already negative: dropping them leaves the penetration
// with nothing acting on it and the cloth free-falls through.
TEST_F(IpcTest, ContactRecoversFromPenetration) {
constexpr char xml[] = R"(
<mujoco>
<option timestep="0.002" integrator="discrete" solver="CG"><flag ipc="enable"/></option>
<worldbody>
<geom name="floor" type="plane" size="0 0 1" pos="0 0 0"/>
<flexcomp name="cloth" type="grid" dim="2" count="3 3 1" spacing="0.05 0.05 1"
radius="0.005" mass="0.05" pos="0 0 -0.02"/>
</worldbody>
</mujoco>)";
mjModel* m = Load(xml);
mjData* d = mj_makeData(m);
int f = mj_name2id(m, mjOBJ_FLEX, "cloth");
ASSERT_GE(f, 0);
// every vertex starts 20 mm below the plane, so every flex-geom gap is
// negative at step 0
for (int i = 0; i < 100; i++) {
mj_step(m, d);
}
mj_kinematics(m, d);
mj_flex(m, d);
mjtNum minz = 1e30;
for (int i = 0; i < m->flex_vertnum[f]; i++) {
mjtNum z = d->flexvert_xpos[3 * (m->flex_vertadr[f] + i) + 2];
if (z < minz) minz = z;
}
EXPECT_GT(minz, 0) << "cloth stayed below the plane it started inside";
EXPECT_FALSE(std::isnan(minz));
mj_deleteData(d);
mj_deleteModel(m);
}
// RUNG 0 of the all-joint rebuild: the per-tree generalized-coordinate core
// (mj_ipcTree). With NO contact the smooth forces enter explicitly through the
// predictor (qacc_smooth), so the variational step reduces to semi-implicit
// Euler EXACTLY -- the only things under test are the predictor, the
// configuration manifold (mj_integratePos/differentiatePos exp/log for
// quaternion DOFs), and the coupled per-tree mass (mj_mulM / mj_solveM). A
// free, a freely-spinning (quaternion + Coriolis), and an articulated (coupled
// M(q)) body under flag ipc must therefore match the discrete integrator
// without the flag to machine precision: the mode adds nothing without flex
// contact.
TEST_F(IpcTest, RigidNoContactMatchesDiscrete) {
struct Scene {
const char* name;
const char* xml;
};
const Scene scenes[] = {
{"free_ballistic", R"(
<mujoco><option timestep="0.002" gravity="0 0 -9.81"/>
<worldbody><body pos="0 0 1"><freejoint/><geom type="box" size="0.1 0.15 0.2" mass="1"/></body></worldbody>
<keyframe><key qvel="1 0.5 -0.3 0 0 0"/></keyframe></mujoco>)"},
{"free_spin", R"(
<mujoco><option timestep="0.002" gravity="0 0 0"/>
<worldbody><body pos="0 0 1"><freejoint/><geom type="box" size="0.1 0.2 0.3" mass="1"/></body></worldbody>
<keyframe><key qvel="0 0 0 2 1.3 0.7"/></keyframe></mujoco>)"},
{"hinge_chain", R"(
<mujoco><option timestep="0.002" gravity="0 0 -9.81"/>
<worldbody>
<body pos="0 0 1"><joint type="hinge" axis="0 1 0"/><geom type="capsule" fromto="0 0 0 0.3 0 0" size="0.03" mass="1"/>
<body pos="0.3 0 0"><joint type="hinge" axis="0 1 0"/><geom type="capsule" fromto="0 0 0 0.3 0 0" size="0.03" mass="1"/>
<body pos="0.3 0 0"><joint type="hinge" axis="0 1 0"/><geom type="capsule" fromto="0 0 0 0.3 0 0" size="0.03" mass="1"/></body>
</body></body>
</worldbody></mujoco>)"},
};
const int N = 500;
for (const Scene& s : scenes) {
mjModel* mi = Load(s.xml);
mi->opt.integrator = mjINT_DISCRETE;
mi->opt.solver =
mjSOL_CG; // required under the flag; the reference matches it
mi->opt.enableflags |= mjENBL_IPC;
mjModel* me = Load(s.xml);
me->opt.integrator = mjINT_DISCRETE;
me->opt.solver = mjSOL_CG;
mjData* di = mj_makeData(mi);
mjData* de = mj_makeData(me);
if (mi->nkey > 0) {
mj_resetDataKeyframe(mi, di, 0);
mj_resetDataKeyframe(me, de, 0);
}
for (int k = 0; k < N; k++) {
mj_step(mi, di);
mj_step(me, de);
}
// manifold qpos error (mj_differentiatePos -- never componentwise
// quaternion subtraction) + qvel error
mjtNum dq[64];
mj_differentiatePos(mi, dq, 1.0, de->qpos, di->qpos);
mjtNum eq = 0, ev = 0;
for (int i = 0; i < mi->nv; i++) {
mjtNum a = std::fabs(dq[i]);
if (a > eq) eq = a;
mjtNum b = std::fabs(di->qvel[i] - de->qvel[i]);
if (b > ev) ev = b;
}
EXPECT_LT(eq, MjTol(1e-9, 1e-5))
<< s.name
<< ": qpos (manifold) IPC vs discrete should match to round-off";
EXPECT_LT(ev, MjTol(1e-9, 1e-5))
<< s.name << ": qvel IPC vs discrete should match to round-off";
mj_deleteData(di);
mj_deleteModel(mi);
mj_deleteData(de);
mj_deleteModel(me);
}
}
// A delayed sensor is sampled when the step advances, at the state the step
// started from, by every integrator: the IPC step commits its endpoint through
// the same advance, with the history work before the state and the effective
// acceleration published ahead of it, so a delayed position, velocity or
// acceleration sensor reads what it reads under the discrete integrator.
TEST_F(IpcTest, DelayedSensorsMatchDiscrete) {
static constexpr char xml[] = R"(
<mujoco>
<option timestep="0.002" gravity="0 0 -9.81" integrator="discrete" solver="CG"/>
<worldbody>
<body pos="0 0 1">
<joint name="j1" type="hinge" axis="0 1 0"/>
<geom type="capsule" fromto="0 0 0 0.3 0 0" size="0.03" mass="1"/>
<body name="tip" pos="0.3 0 0">
<joint name="j2" type="hinge" axis="0 1 0"/>
<geom type="capsule" fromto="0 0 0 0.3 0 0" size="0.03" mass="1"/>
<site name="tip" pos="0.3 0 0"/>
</body>
</body>
</worldbody>
<sensor>
<jointpos joint="j1" delay="0.01" nsample="8"/>
<jointvel joint="j2" delay="0.01" nsample="8"/>
<accelerometer site="tip" delay="0.01" nsample="8"/>
<framelinacc objtype="body" objname="tip" delay="0.01" nsample="8"/>
</sensor>
</mujoco>)";
mjModel* mi = Load(xml);
mi->opt.enableflags |= mjENBL_IPC;
mjModel* me = Load(xml);
mjData* di = mj_makeData(mi);
mjData* de = mj_makeData(me);
for (int k = 0; k < 100; k++) {
mj_step(mi, di);
mj_step(me, de);
}
for (int i = 0; i < mi->nsensordata; i++) {
EXPECT_NEAR(di->sensordata[i], de->sensordata[i], MjTol(1e-9, 1e-5))
<< "sensordata[" << i << "] IPC vs discrete should match to round-off";
}
mj_deleteData(di);
mj_deleteModel(mi);
mj_deleteData(de);
mj_deleteModel(me);
}
// With a 2D flex in the model the forward pass skips the constraint stage, so
// its acceleration-stage sensors see the free-flight acceleration; the step
// recomputes them from its own acceleration and constraint force before it
// commits. A sphere resting on the floor beside a pinned, non-colliding cloth
// reads gravity on its accelerometer after a step, as it does without the flag.
TEST_F(IpcTest, AccelerationSensorsSeeContact) {
static constexpr char xml[] = R"(
<mujoco>
<option timestep="0.002" integrator="discrete" solver="CG"/>
<worldbody>
<geom type="plane" size="0 0 1"/>
<body name="ball" pos="0 0 0.1">
<freejoint/>
<geom type="sphere" size="0.1" mass="1"/>
<site name="imu"/>
</body>
<flexcomp name="cloth" type="grid" dim="2" count="3 3 1" spacing="0.05 0.05 1"
radius="0.005" mass="0.05" pos="1 0 0.5">
<contact contype="0" conaffinity="0"/>
<pin id="0 1 2 3 4 5 6 7 8"/>
</flexcomp>
</worldbody>
<sensor>
<accelerometer site="imu"/>
<framelinacc objtype="body" objname="ball"/>
</sensor>
</mujoco>)";
mjModel* mi = Load(xml);
mi->opt.enableflags |= mjENBL_IPC;
mjModel* me = Load(xml);
mjData* di = mj_makeData(mi);
mjData* de = mj_makeData(me);
for (int k = 0; k < 200; k++) {
mj_step(mi, di);
mj_step(me, de);
}
// resting: the accelerometer reads +g along its z axis
EXPECT_NEAR(di->sensordata[2], 9.81, 0.1);
// the two solves of the same contact row agree to their solver residuals
for (int i = 0; i < mi->nsensordata; i++) {
EXPECT_NEAR(di->sensordata[i], de->sensordata[i], MjTol(1e-4, 1e-3))
<< "sensordata[" << i << "] IPC vs discrete";
}
mj_deleteData(di);
mj_deleteModel(mi);
mj_deleteData(de);
mj_deleteModel(me);
}
// CONTACT rung: rigid contact now reproduces MuJoCo's solref/solimp
// spring-damper (explicit aref when the timeconst >= 2h, the refsafe-safe
// regime), so a RIGID-ONLY scene through the IPC contact mode must track the
// EULER integrator of the same scene -- the fidelity property we are after. (A
// sub-2h timeconst switches to the stable implicit branch and is deliberately
// NOT Euler-faithful, since MuJoCo would have clamped it via refsafe.) The gap
// is LIVE through nonlinear FK -> no tunnel. Default solref timeconst here is
// 0.02 = 10h, well above 2h.
TEST_F(IpcTest, RigidContactMatchesEuler) {
struct Scene {
const char* name;
const char* xml;
int steps;
};
const Scene scenes[] = {
{"sphere on floor", R"(
<mujoco><option timestep="0.002" gravity="0 0 -9.81"/>
<worldbody><geom type="plane" size="3 3 0.1"/>
<body pos="0 0 0.4"><freejoint/><geom type="sphere" size="0.1" mass="1" condim="1"/></body>
</worldbody></mujoco>)",
600},
{"two spheres stack", R"(
<mujoco><option timestep="0.002" gravity="0 0 -9.81"/>
<worldbody><geom type="plane" size="3 3 0.1"/>
<body pos="0 0 0.1"><freejoint/><geom type="sphere" size="0.1" mass="1" condim="1"/></body>
<body pos="0 0 0.33"><freejoint/><geom type="sphere" size="0.1" mass="1" condim="1"/></body>
</worldbody></mujoco>)",
800},
};
for (const Scene& s : scenes) {
mjModel* mi = Load(s.xml);
mi->opt.integrator = mjINT_DISCRETE;
mi->opt.solver =
mjSOL_CG; // required under the flag; the reference matches it
mi->opt.enableflags |= mjENBL_IPC;
mjModel* me = Load(s.xml);
me->opt.integrator = mjINT_EULER;
mjData* di = mj_makeData(mi);
mjData* de = mj_makeData(me);
for (int k = 0; k < s.steps; k++) {
mj_step(mi, di);
mj_step(me, de);
}
mjtNum dq[64];
mj_differentiatePos(
mi, dq, 1.0, de->qpos,
di->qpos); // manifold qpos diff (freejoint-quaternion safe)
mjtNum eq = 0, ev = 0;
for (int i = 0; i < mi->nv; i++) {
mjtNum a = std::fabs(dq[i]);
if (a > eq) eq = a;
mjtNum b = std::fabs(di->qvel[i] - de->qvel[i]);
if (b > ev) ev = b;
}
// bounds one decade above observed round-off accumulation (grew ~4x at the
// port to the effective-metric base: the native contact pipeline under both
// integrators moved). In single precision the stacked spheres are a
// sensitive configuration: the two paths' round-off differs and the stack
// settles differently, so the float bounds are loose (measured 0.8 mm, 7
// mm/s)
EXPECT_LT(eq, MjTol(1e-8, 1e-2))
<< s.name << ": IPC qpos should track Euler to round-off";
EXPECT_LT(ev, MjTol(1e-7, 1e-1))
<< s.name << ": IPC qvel should track Euler to round-off";
EXPECT_FALSE(std::isnan(di->qvel[2])) << s.name;
mj_deleteData(di);
mj_deleteModel(mi);
mj_deleteData(de);
mj_deleteModel(me);
}
}
// GENERAL ARTICULATED contact: the penalty is driven by MuJoCo's own collision
// (mj_collision -- mj_step disables contact for the IPC predictor) and
// assembled through the FULL kinematic chain via mj_jac (b = J_B^T n - J_A^T
// n), with the gap LIVE through nonlinear FK. A capsule on a HINGE (its contact
// Jacobian runs through the joint, not a translation DOF) swings onto the floor
// and must NOT tunnel through it -- this is the humanoid-foot case in miniature
// (capsule geom + articulated body), which the old sphere-only / "first 3
// linear DOFs" path missed.
TEST_F(IpcTest, ArticulatedCapsuleDoesNotTunnel) {
mjModel* m = Load(R"(
<mujoco><option timestep="0.002" gravity="0 0 -9.81" integrator="discrete" solver="CG"><flag ipc="enable"/></option>
<worldbody><geom type="plane" size="3 3 0.1"/>
<body pos="0 0 0.3"><joint type="hinge" axis="0 1 0"/>
<geom type="capsule" fromto="0 0 0 0.4 0 0" size="0.05" mass="1"/>
</body>
</worldbody></mujoco>)");
mjData* d = mj_makeData(m);
int gcap = m->body_geomadr[1];
mjtNum worst = 1e9; // lowest capsule SURFACE point over the run
for (int s = 0; s < 700; s++) {
mj_step(m, d);
// capsule long axis = local z = xmat column 2; lowest surface z = center_z
// - |halflen*axis_z| - radius
mjtNum cz = d->geom_xpos[3 * gcap + 2], axz = d->geom_xmat[9 * gcap + 8];
mjtNum lowest = cz - std::abs(m->geom_size[3 * gcap + 1] * axz) -
m->geom_size[3 * gcap];
if (lowest < worst) worst = lowest;
ASSERT_FALSE(std::isnan(d->qvel[0])) << "NaN at step " << s;
}
EXPECT_GT(worst, -0.03)
<< "capsule tunneled through the floor (lowest surface z = " << worst
<< ")";
// it comes to rest propped on the floor (hinge can't fall through): the bob
// is supported, qvel settles
EXPECT_LT(std::abs(d->qvel[0]), 5e-2)
<< "hinge should settle against the floor";
mj_deleteData(d);
mj_deleteModel(m);
}
// FLEX-UNIFY 4b: articulated bodies participate in the UNIFIED flex solver
// vector with bit-exact kinetics. A hinge integrated in a flex scene (the
// unified path: its generalized DOFs are appended to the dense flex packing,
// share the one PCG + line search, with ih2*M / h2*M^-1 applied via
// mj_mulM/mj_solveM and a qdelta state) must match the SAME hinge alone (the
// mj_ipcTree path) to solver tolerance. A far, pinned cloth forces the unified
// path without any contact. This is the regression gate for the
// appended-articulated-kinetic machinery.
TEST_F(IpcTest, ArticulatedKineticInFlexMatchesTree) {
mjModel* m1 = Load(R"(
<mujoco><option timestep="0.002" gravity="0 0 -9.81" integrator="discrete" solver="CG"><flag ipc="enable"/></option>
<worldbody><body pos="0 0 1"><joint type="hinge" axis="0 1 0"/>
<geom type="capsule" fromto="0 0 0 0.3 0 0" size="0.03" mass="1"/></body></worldbody></mujoco>)");
mjModel* m2 = Load(R"(
<mujoco><option timestep="0.002" gravity="0 0 -9.81" integrator="discrete" solver="CG"><flag ipc="enable"/></option>
<worldbody><body pos="0 0 1"><joint type="hinge" axis="0 1 0"/>
<geom type="capsule" fromto="0 0 0 0.3 0 0" size="0.03" mass="1"/></body>
<flexcomp name="cloth" type="grid" dim="2" count="3 3 1" spacing="0.05 0.05 1" radius="0.005" mass="0.05"
pos="3 0 1"><pin id="0 2 6 8"/></flexcomp>
</worldbody></mujoco>)");
ASSERT_GT(m2->nv, m1->nv)
<< "the flex scene must add DOFs (forces the unified path)";
mjData* d1 = mj_makeData(m1); // hinge alone -> mj_ipcTree
mjData* d2 =
mj_makeData(m2); // hinge + far cloth -> unified flex path (na_artic==1)
mjtNum worst = 0;
for (int s = 0; s < 400; s++) {
mj_step(m1, d1);
mj_step(m2, d2);
worst = std::max(
worst, std::abs(d1->qpos[0] -
d2->qpos[0])); // qpos[0] is the hinge angle in both
ASSERT_FALSE(std::isnan(d2->qpos[0])) << "NaN at step " << s;
}
EXPECT_LT(worst, 1e-9)
<< "hinge in the unified flex path must match mj_ipcTree (max diff "
<< worst << ")";
mj_deleteData(d1);
mj_deleteModel(m1);
mj_deleteData(d2);
mj_deleteModel(m2);
}
// the flag's option fences: each incompatible setting is rejected at the start
// of the step, with the other settings valid
TEST_F(IpcTest, RejectsIncompatibleOptions) {
mjModel* m = Load(R"(
<mujoco><option timestep="0.002" integrator="discrete" solver="CG"><flag ipc="enable"/></option>
<worldbody><body pos="0 0 1"><freejoint/><geom type="sphere" size="0.1" mass="1"/></body></worldbody></mujoco>)");
mjData* d = mj_makeData(m);
auto forward_error = MjuErrorMessageFrom(mj_forward);
EXPECT_EQ(forward_error(m, d), "");
m->opt.integrator = mjINT_EULER;
EXPECT_THAT(forward_error(m, d),
testing::HasSubstr("flag ipc requires integrator='discrete'"));
m->opt.integrator = mjINT_DISCRETE;
m->opt.solver = mjSOL_NEWTON;
EXPECT_THAT(forward_error(m, d),
testing::HasSubstr("flag ipc requires solver='CG'"));
m->opt.solver = mjSOL_CG;
m->opt.enableflags |= mjENBL_FWDINV;
EXPECT_THAT(forward_error(m, d),
testing::HasSubstr("flag ipc does not support flag fwdinv"));
m->opt.enableflags &= ~mjENBL_FWDINV;
m->opt.enableflags |= mjENBL_SLEEP;
EXPECT_THAT(forward_error(m, d),
testing::HasSubstr("flag ipc does not support flag sleep"));
m->opt.enableflags &= ~mjENBL_SLEEP;
EXPECT_EQ(forward_error(m, d), "");
mj_deleteData(d);
mj_deleteModel(m);
}
// under the flag the collision pipeline does not generate the contacts the
// IPC step handles (dim-2 flex against static geometry, dim-2 flex against
// dim-2 flex), while flex against a moving body stays: a sheet resting on a
// static box with a ball on top
TEST_F(IpcTest, NativeRowsExcludedForIpcPairs) {
mjModel* m = Load(R"(
<mujoco><option timestep="0.002" integrator="discrete" solver="CG"><flag ipc="enable"/></option>
<worldbody>
<geom name="table" type="box" size=".3 .3 .05" pos="0 0 .05"/>
<flexcomp name="sheet" type="grid" dim="2" count="8 8 1" spacing=".04 .04 1" radius=".004"
mass=".05" pos="0 0 .13">
<edge equality="true" solref=".004 1"/>
<contact selfcollide="auto" contype="1" conaffinity="1" solref=".004 1"/>
</flexcomp>
<body pos="0 0 .25"><freejoint/><geom type="sphere" size=".03" mass=".2"/></body>
</worldbody></mujoco>)");
mjData* d = mj_makeData(m);
int flex_static = 0, flex_moving = 0;
for (int s = 0; s < 400; s++) {
mj_step(m, d);
for (int i = 0; i < d->ncon; i++) {
const mjContact* c = d->contact + i;
int fs = (c->flex[0] >= 0) != (c->flex[1] >= 0); // exactly one flex side
if (!fs) continue;
int g = c->flex[0] >= 0 ? c->geom[1] : c->geom[0];
int is_static = g >= 0 && m->body_weldid[m->geom_bodyid[g]] == 0;
if (is_static)
flex_static++;
else if (c->exclude == 0)
flex_moving++;
}
}
EXPECT_EQ(flex_static, 0)
<< "sheet-vs-table contacts are the IPC step's: none is generated";
EXPECT_GT(flex_moving, 0) << "ball-vs-sheet stays on the constraint solver";
mj_deleteData(d);
mj_deleteModel(m);
}
// geom types the IPC step cannot detect keep their native rows, so the sheet
// still rests on them
TEST_F(IpcTest, NativeRowsKeptForUnsupportedGeoms) {
static constexpr char kModel[] = R"(
<mujoco><option timestep="0.002" integrator="discrete" solver="CG"><flag ipc="enable"/></option>
<worldbody>
<geom name="drum" type="%s" size="%s" pos="0 0 .2"/>
<flexcomp name="sheet" type="grid" dim="2" count="9 9 1" spacing=".03 .03 1" radius=".003"
mass=".05" pos="0 0 .5">
<edge equality="true" solref=".004 1"/>
<contact selfcollide="auto" contype="1" conaffinity="1" solref=".004 1"/>
</flexcomp>
</worldbody></mujoco>)";
const char* geoms[][2] = {
{"cylinder", ".2 .1"}, {"ellipsoid", ".2 .2 .1"}, {"box", ".2 .2 .1"}};
for (const auto& g : geoms) {
char xml[1024];
snprintf(xml, sizeof(xml), kModel, g[0], g[1]);
mjModel* m = Load(xml);
mjData* d = mj_makeData(m);
int native = 0;
for (int s = 0; s < 600; s++) {
mj_step(m, d);
native += d->ncon;
}
mjtNum zmin = 1;
for (int v = 0; v < m->nflexvert; v++)
zmin = mju_min(zmin, d->flexvert_xpos[3 * v + 2]);
bool supported = std::string(g[0]) == "box";
EXPECT_EQ(native > 0, !supported)
<< g[0] << ": native contacts only for unsupported types";
EXPECT_GT(zmin, 0.25) << g[0] << ": the sheet rests on top after 1.2 s";
mj_deleteData(d);
mj_deleteModel(m);
}
}
} // namespace
} // namespace mujoco