Fix bug with box-box #2356.

PiperOrigin-RevId: 721138760
Change-Id: Ib9548b0bc505a9856176188c91858cff7c9accdf
This commit is contained in:
Baruch Tabanpour
2025-01-29 15:59:40 -08:00
committed by Copybara-Service
parent 5b245e5d02
commit e0664b1bb8
6 changed files with 67 additions and 135 deletions
+1
View File
@@ -35,6 +35,7 @@ General
MJX MJX
^^^ ^^^
- Added support for spatial tendons with internal sphere and cylinder wrapping. - Added support for spatial tendons with internal sphere and cylinder wrapping.
- Fix a bug with box-box collisions :github:issue:`2356`.
Version 3.2.7 (Jan 14, 2025) Version 3.2.7 (Jan 14, 2025)
---------------------------- ----------------------------
+47 -59
View File
@@ -678,7 +678,7 @@ def _create_contact_manifold(
return dist, pos, normal return dist, pos, normal
def _sat_bruteforce( def _box_box_impl(
faces_a: jax.Array, faces_a: jax.Array,
faces_b: jax.Array, faces_b: jax.Array,
vertices_a: jax.Array, vertices_a: jax.Array,
@@ -688,17 +688,7 @@ def _sat_bruteforce(
unique_edges_a: jax.Array, unique_edges_a: jax.Array,
unique_edges_b: jax.Array, unique_edges_b: jax.Array,
) -> Tuple[jax.Array, jax.Array, jax.Array]: ) -> Tuple[jax.Array, jax.Array, jax.Array]:
"""Runs the Separating Axis Test for a pair of hulls. """Runs the Separating Axis Test for two boxes.
Given two convex hulls, the Separating Axis Test finds a separating axis
between all edge pairs and face pairs. Edge pairs create a single contact
point and face pairs create a contact manifold (up to four contact points).
We return both the edge and face contacts. Valid contacts can be checked with
dist < 0. Resulting edge contacts should be preferred over face contacts.
This method checks all separating axes via a brute force support function, and
is thus costly to run over large meshes, but is more performant for smaller
meshes (boxes, tetrahedra, etc.).
Args: Args:
faces_a: Faces for hull A. faces_a: Faces for hull A.
@@ -713,18 +703,15 @@ def _sat_bruteforce(
Returns: Returns:
tuple of dist, pos, and normal tuple of dist, pos, and normal
""" """
# get the separating axes edge_dir_a, edge_dir_b = unique_edges_a, unique_edges_b
v_norm = jax.vmap(math.normalize) edge_dir_a_r = jp.tile(edge_dir_a, reps=(edge_dir_b.shape[0], 1))
edge_dir_a = v_norm(unique_edges_a[:, 0] - unique_edges_a[:, 1]) edge_dir_b_r = jp.repeat(edge_dir_b, repeats=edge_dir_a.shape[0], axis=0)
edge_dir_b = v_norm(unique_edges_b[:, 0] - unique_edges_b[:, 1])
edge_dir_a_r = jp.tile(edge_dir_a, reps=(unique_edges_b.shape[0], 1))
edge_dir_b_r = jp.repeat(edge_dir_b, repeats=unique_edges_a.shape[0], axis=0)
edge_axes = jax.vmap(jp.cross)(edge_dir_a_r, edge_dir_b_r) edge_axes = jax.vmap(jp.cross)(edge_dir_a_r, edge_dir_b_r)
degenerate_edge_axes = (edge_axes**2).sum(axis=1) < 1e-6 degenerate_edge_axes = (edge_axes**2).sum(axis=1) < 1e-6
edge_axes = jax.vmap(lambda x: math.normalize(x, axis=0))(edge_axes) edge_axes = jax.vmap(lambda x: math.normalize(x, axis=0))(edge_axes)
n_norm = normals_a.shape[0] + normals_b.shape[0] n_face_axes = normals_a.shape[0] + normals_b.shape[0]
degenerate_axes = jp.concatenate( degenerate_axes = jp.concatenate(
[jp.array([False] * n_norm), degenerate_edge_axes] [jp.array([False] * n_face_axes), degenerate_edge_axes]
) )
axes = jp.concatenate([normals_a, normals_b, edge_axes]) axes = jp.concatenate([normals_a, normals_b, edge_axes])
@@ -745,11 +732,16 @@ def _sat_bruteforce(
support, sign = get_support(axes, degenerate_axes) support, sign = get_support(axes, degenerate_axes)
# get the best face axis
best_face_idx = jp.argmin(support[:n_face_axes])
best_face_axis = axes[best_face_idx]
# choose the best separating axis # choose the best separating axis
best_idx = jp.argmin(support) best_idx = jp.argmin(support)
best_sign = sign[best_idx] best_sign = sign[best_idx]
best_axis = axes[best_idx] best_axis = axes[best_idx]
is_edge_contact = best_idx >= (normals_a.shape[0] + normals_b.shape[0]) is_edge_contact = best_idx >= n_face_axes
is_edge_contact &= jp.abs(best_face_axis.dot(best_axis)) < 0.99 # prefer face
# get the (reference) face most aligned with the separating axis # get the (reference) face most aligned with the separating axis
dist_a = normals_a @ best_axis dist_a = normals_a @ best_axis
@@ -788,6 +780,40 @@ def _sat_bruteforce(
return dist, pos, normal return dist, pos, normal
def _box_box(b1: ConvexInfo, b2: ConvexInfo) -> Collision:
"""Calculates contacts between two boxes."""
faces1 = b1.face
faces2 = b2.face
to_local_pos = b2.mat.T @ (b1.pos - b2.pos)
to_local_mat = b2.mat.T @ b1.mat
faces1 = to_local_pos + faces1 @ to_local_mat.T
normals1 = b1.face_normal @ to_local_mat.T
normals2 = b2.face_normal
vertices1 = to_local_pos + b1.vert @ to_local_mat.T
vertices2 = b2.vert
dist, pos, normal = _box_box_impl(
faces1,
faces2,
vertices1,
vertices2,
normals1,
normals2,
to_local_mat.T,
jp.eye(3, dtype=float),
)
# Go back to world frame.
pos = b2.pos + pos @ b2.mat.T
n = normal @ b2.mat.T
dist = jp.where(jp.isinf(dist), jp.finfo(float).max, dist)
return dist, pos, n
def _arcs_intersect( def _arcs_intersect(
a: jax.Array, b: jax.Array, c: jax.Array, d: jax.Array a: jax.Array, b: jax.Array, c: jax.Array, d: jax.Array
) -> jax.Array: ) -> jax.Array:
@@ -941,44 +967,6 @@ def _sat_gaussmap(
return dist, pos, normal return dist, pos, normal
def _box_box(b1: ConvexInfo, b2: ConvexInfo) -> Collision:
"""Calculates contacts between two boxes."""
faces1 = b1.face
faces2 = b2.face
to_local_pos = b2.mat.T @ (b1.pos - b2.pos)
to_local_mat = b2.mat.T @ b1.mat
faces1 = to_local_pos + faces1 @ to_local_mat.T
normals1 = b1.face_normal @ to_local_mat.T
normals2 = b2.face_normal
vertices1 = to_local_pos + b1.vert @ to_local_mat.T
vertices2 = b2.vert
unique_edges1 = jp.take(vertices1, b1.edge_dir, axis=0)
unique_edges2 = jp.take(vertices2, b2.edge_dir, axis=0)
# brute-force SAT is more performant for box-box
dist, pos, normal = _sat_bruteforce(
faces1,
faces2,
vertices1,
vertices2,
normals1,
normals2,
unique_edges1,
unique_edges2,
)
# Go back to world frame.
pos = b2.pos + pos @ b2.mat.T
n = normal @ b2.mat.T
dist = jp.where(jp.isinf(dist), jp.finfo(float).max, dist)
return dist, pos, n
def _convex_convex(c1: ConvexInfo, c2: ConvexInfo) -> Collision: def _convex_convex(c1: ConvexInfo, c2: ConvexInfo) -> Collision:
"""Calculates contacts between two convex meshes.""" """Calculates contacts between two convex meshes."""
# pad face vertices so that we can broadcast between geom1 and geom2 # pad face vertices so that we can broadcast between geom1 and geom2
+18 -12
View File
@@ -49,11 +49,15 @@ def _assert_attr_eq(mjx_d, mj_d, attr, name, atol):
def _collide( def _collide(
mjcf: str, assets: Optional[Dict[str, str]] = None mjcf: str,
assets: Optional[Dict[str, str]] = None,
keyframe: Optional[int] = None,
) -> Tuple[mujoco.MjModel, mujoco.MjData, Model, Data]: ) -> Tuple[mujoco.MjModel, mujoco.MjData, Model, Data]:
m = mujoco.MjModel.from_xml_string(mjcf, assets or {}) m = mujoco.MjModel.from_xml_string(mjcf, assets or {})
mx = mjx.put_model(m) mx = mjx.put_model(m)
d = mujoco.MjData(m) d = mujoco.MjData(m)
if keyframe is not None:
mujoco.mj_resetDataKeyframe(m, d, keyframe)
dx = mjx.put_data(m, d) dx = mjx.put_data(m, d)
m.opt.enableflags |= mujoco.mjtEnableBit.mjENBL_NATIVECCD m.opt.enableflags |= mujoco.mjtEnableBit.mjENBL_NATIVECCD
@@ -649,28 +653,30 @@ class ConvexTest(absltest.TestCase):
_BOX_BOX = """ _BOX_BOX = """
<mujoco> <mujoco>
<worldbody> <worldbody>
<body pos="0.0 1.0 0.2"> <light name="top" pos="0 0 1"/>
<joint axis="1 0 0" type="free"/> <geom type="box" size="0.025 0.025 0.025" pos="0 0 0.025"/>
<geom size="0.2 0.2 0.2" type="box"/> <body name="peg" pos="0 0 0.06">
</body> <freejoint/>
<body pos="0.1 1.0 0.495" euler="0.1 -0.1 0"> <geom name="peg" size="0.048 0.01 0.01" type="box"/>
<joint axis="1 0 0" type="free"/> </body>
<geom size="0.1 0.1 0.1" type="box"/>
</body>
</worldbody> </worldbody>
<keyframe>
<!-- Boxes are penetrating with a slightly off-axis face contact -->
<key qpos='-0.00234853 0.0112999 0.0533649 0.474162 0.472141 0.524886 0.526069'/>
</keyframe>
</mujoco> </mujoco>
""" """
def test_box_box(self): def test_box_box(self):
"""Tests a face contact for a box-box collision.""" """Tests a face contact for a box-box collision."""
d, dx = _collide(self._BOX_BOX) d, dx = _collide(self._BOX_BOX, keyframe=0)
c = dx.contact c = dx.contact
self.assertEqual(c.pos.shape[0], 4) self.assertEqual(c.pos.shape[0], 4)
np.testing.assert_array_less(c.dist, 0) np.testing.assert_array_less(c.dist, 0)
np.testing.assert_array_almost_equal(c.pos[:, 2], np.array([0.39] * 4), 2) np.testing.assert_array_almost_equal(c.pos[:, 2], np.array([0.05] * 4), 2)
np.testing.assert_array_almost_equal( np.testing.assert_array_almost_equal(
c.frame[:, 0, :], np.array([[0.0, 0.0, 1.0]] * 4) c.frame[:, 0, :], np.array([[0.0, 0.0, 1.0]] * 4), decimal=2
) )
np.testing.assert_array_almost_equal( np.testing.assert_array_almost_equal(
c.frame.reshape((-1, 9)), d.contact.frame[:4, :] c.frame.reshape((-1, 9)), d.contact.frame[:4, :]
+1 -2
View File
@@ -15,7 +15,7 @@
"""Collision base types.""" """Collision base types."""
import dataclasses import dataclasses
from typing import Optional, Tuple from typing import Tuple
import jax import jax
from mujoco.mjx._src.dataclasses import PyTreeNode # pylint: disable=g-importing-member from mujoco.mjx._src.dataclasses import PyTreeNode # pylint: disable=g-importing-member
import numpy as np import numpy as np
@@ -46,7 +46,6 @@ class ConvexInfo(PyTreeNode):
face_normal: jax.Array face_normal: jax.Array
edge: jax.Array edge: jax.Array
edge_face_normal: jax.Array edge_face_normal: jax.Array
edge_dir: Optional[jax.Array] = None
class HFieldInfo(PyTreeNode): class HFieldInfo(PyTreeNode):
-39
View File
@@ -53,42 +53,6 @@ def _get_face_norm(vert: np.ndarray, face: np.ndarray) -> np.ndarray:
return face_norm return face_norm
def _get_unique_edge_dir(vert: np.ndarray, face: np.ndarray) -> np.ndarray:
"""Returns unique edge directions.
Args:
vert: (n_vert, 3) vertices
face: (n_face, n_vert) face index array
Returns:
edges: tuples of vertex indexes for each edge
"""
r_face = np.roll(face, 1, axis=1)
edges = np.concatenate(np.array([face, r_face]).T)
# do a first pass to remove duplicates
edges.sort(axis=1)
edges = np.unique(edges, axis=0)
edges = edges[edges[:, 0] != edges[:, 1]] # get rid of edges from padded face
# get normalized edge directions
edge_vert = vert.take(edges, axis=0)
edge_dir = edge_vert[:, 0] - edge_vert[:, 1]
norms = np.sqrt(np.sum(edge_dir**2, axis=1))
edge_dir = edge_dir / norms.reshape((-1, 1))
# get the first unique edge for all pairwise comparisons
diff1 = edge_dir[:, None, :] - edge_dir[None, :, :]
diff2 = edge_dir[:, None, :] + edge_dir[None, :, :]
matches = (np.linalg.norm(diff1, axis=-1) < 1e-6) | (
np.linalg.norm(diff2, axis=-1) < 1e-6
)
matches = np.tril(matches).sum(axis=-1)
unique_edge_idx = np.where(matches == 1)[0]
return edges[unique_edge_idx]
def _get_edge_normals( def _get_edge_normals(
face: np.ndarray, face_norm: np.ndarray face: np.ndarray, face_norm: np.ndarray
) -> Tuple[np.ndarray, np.ndarray]: ) -> Tuple[np.ndarray, np.ndarray]:
@@ -221,7 +185,6 @@ def box(info: GeomInfo) -> ConvexInfo:
# pyformat: enable # pyformat: enable
face_normal = _get_face_norm(vert, face) face_normal = _get_face_norm(vert, face)
edge, edge_face_normal = _get_edge_normals(face, face_normal) edge, edge_face_normal = _get_edge_normals(face, face_normal)
edge_dir = _get_unique_edge_dir(vert, face)
face = vert[face] # materialize full nface x nvert matrix face = vert[face] # materialize full nface x nvert matrix
c = ConvexInfo( c = ConvexInfo(
@@ -233,7 +196,6 @@ def box(info: GeomInfo) -> ConvexInfo:
face_normal, face_normal,
edge, edge,
edge_face_normal, edge_face_normal,
edge_dir,
) )
c = jax.tree_util.tree_map(jp.array, c) c = jax.tree_util.tree_map(jp.array, c)
vert = jax.vmap(jp.multiply, in_axes=(None, 0))(c.vert, info.size) vert = jax.vmap(jp.multiply, in_axes=(None, 0))(c.vert, info.size)
@@ -356,7 +318,6 @@ def hfield_prism(vert: jax.Array) -> ConvexInfo:
face_norm, face_norm,
edges, edges,
face_norm[edge_face_norm], face_norm[edge_face_norm],
None,
) )
return jax.tree_util.tree_map(jp.array, c) return jax.tree_util.tree_map(jp.array, c)
-23
View File
@@ -61,15 +61,6 @@ class MeshTest(absltest.TestCase):
expected_face_verts, expected_face_verts,
) )
# check edges
edge_dir = mesh._get_unique_edge_dir(convex_vert, convex_face)
unique_edge = np.vectorize(map_.get)(edge_dir)
unique_edge = np.array(sorted(unique_edge.tolist()))
np.testing.assert_array_equal(
unique_edge,
np.array([[0, 2], [0, 3], [0, 4], [1, 4], [2, 4], [3, 4]]),
)
# face normals # face normals
face_normal = mesh._get_face_norm(convex_vert, convex_face) face_normal = mesh._get_face_norm(convex_vert, convex_face)
self.assertEqual(face_normal.shape, (5, 3)) self.assertEqual(face_normal.shape, (5, 3))
@@ -143,19 +134,5 @@ class ConvexHull2DTest(absltest.TestCase):
np.testing.assert_array_almost_equal(normal, expected) np.testing.assert_array_almost_equal(normal, expected)
class UniqueEdgesTest(absltest.TestCase):
def test_tetrahedron_edges(self):
"""Tests unique edges for a tetrahedron."""
vert = np.array(
[[-0.1, 0.0, -0.1], [0.0, 0.1, 0.1], [0.1, 0.0, -0.1], [0.0, -0.1, 0.1]]
)
face = np.array([[0, 1, 2], [0, 2, 3], [0, 3, 1], [2, 1, 3]])
idx = mesh._get_unique_edge_dir(vert, face)
np.testing.assert_array_equal(
idx, np.array([[0, 1], [0, 2], [0, 3], [1, 2], [1, 3], [2, 3]])
)
if __name__ == '__main__': if __name__ == '__main__':
absltest.main() absltest.main()