Skip to content

Commit 06d2350

Browse files
committed
Fix scale-dependent triangle MPR initialization
1 parent ee84505 commit 06d2350

3 files changed

Lines changed: 191 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@
101101

102102
### Fixed
103103

104+
- Fix MPR returning scale-dependent, excessively deep contacts for small convex shapes on large mesh triangles while preserving triangle-specific shared-edge manifold witnesses.
104105
- Make deterministic collision pipelines cover hydroelastic contact generation and reduction, including unique reduced-contact sort keys and overflow-safe fixed-point pressure accumulation.
105106
- Convert `newton:mimicCoef0` from degrees to radians when the mimic follower joint is angular. Assets authored against the old behavior need the value rescaled to degrees.
106107
- Complete Kamino RCM traversal for large and disconnected systems and reuse the resulting permutation by default; set `reuse_permutation=False` to recompute it for changing matrix topology.

newton/_src/geometry/mpr.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -236,24 +236,40 @@ def geometric_center(
236236
# point near the contact region — this dramatically improves
237237
# MPR convergence for large triangles.
238238
#
239-
# Blend 1% toward the centroid so the point is strictly in the
240-
# face interior. This does NOT prevent an MPR degeneracy (MPR
241-
# works fine from an edge point); it improves *manifold quality*.
239+
# Nudge toward the centroid so the point moves into the face
240+
# interior when possible. This does NOT prevent an MPR
241+
# degeneracy (MPR works fine from an edge point); it improves
242+
# *manifold quality*.
242243
# When shape B projects onto a shared mesh edge, both adjacent
243244
# triangles get the same v0, producing MPR witness points biased
244245
# toward the edge. The manifold builder (multicontact.py) uses
245246
# these witness points as its center for perturbed support
246247
# mapping, so edge-biased centers cause overlapping contact
247248
# polygons across the two triangles instead of distinct ones —
248249
# resulting in asymmetric force distribution and spurious torque.
249-
# The 1% nudge gives each triangle a unique v0 pulled toward its
250-
# own interior, yielding well-separated manifold centers.
250+
# Keep that triangle-specific direction, but limit the tangential
251+
# displacement to 1% of the normal center-to-plane distance. A
252+
# face projection therefore tilts the initial ray by at most
253+
# atan(0.01), about 0.57 degrees, regardless of triangle size. A
254+
# pure barycentric blend scales with the triangle and can make the
255+
# initial ray almost tangential when a small partner lies on a
256+
# large terrain face, causing MPR to converge to a non-minimum
257+
# portal.
251258
tri_a = wp.vec3(0.0, 0.0, 0.0)
252259
tri_b = geom_a.scale
253260
tri_c = geom_a.auxiliary
254261
proj = closest_point_on_triangle(center_b_world, tri_a, tri_b, tri_c)
255262
centroid = (tri_a + tri_b + tri_c) / 3.0
256-
center_a = proj + 0.01 * (centroid - proj)
263+
to_centroid = centroid - proj
264+
distance_to_centroid = wp.length(to_centroid)
265+
face_normal = wp.cross(tri_b - tri_a, tri_c - tri_a)
266+
face_normal_length = wp.length(face_normal)
267+
if distance_to_centroid > 1.0e-12 and face_normal_length > 1.0e-12:
268+
center_to_plane = wp.abs(wp.dot(center_b_world - proj, face_normal)) / face_normal_length
269+
nudge_distance = 0.01 * wp.min(distance_to_centroid, center_to_plane)
270+
center_a = proj + to_centroid * (nudge_distance / distance_to_centroid)
271+
else:
272+
center_a = proj
257273

258274
center.B = center_b_world
259275
center.BtoA = center_a - center_b_world

newton/tests/test_mpr.py

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 The Newton Developers
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Regression tests for Minkowski Portal Refinement (MPR)."""
5+
6+
import unittest
7+
8+
import numpy as np
9+
import warp as wp
10+
11+
from newton import GeoType
12+
from newton._src.geometry.mpr import create_solve_mpr
13+
from newton._src.geometry.support_function import (
14+
GenericShapeData,
15+
GeoTypeEx,
16+
SupportMapDataProvider,
17+
support_map,
18+
)
19+
20+
21+
@wp.kernel
22+
def _triangle_mpr_kernel(
23+
triangle_b: wp.array[wp.vec3],
24+
triangle_c: wp.array[wp.vec3],
25+
shape_b_type: int,
26+
shape_b_scale: wp.vec3,
27+
shape_b_position: wp.array[wp.vec3],
28+
shape_b_orientation: wp.array[wp.quat],
29+
collision_out: wp.array[int],
30+
point_a_out: wp.array[wp.vec3],
31+
point_b_out: wp.array[wp.vec3],
32+
normal_out: wp.array[wp.vec3],
33+
penetration_out: wp.array[float],
34+
):
35+
"""Run MPR directly, with each triangle's first vertex at the origin."""
36+
i = wp.tid()
37+
38+
shape_a = GenericShapeData()
39+
shape_a.shape_type = int(GeoTypeEx.TRIANGLE)
40+
shape_a.scale = triangle_b[i]
41+
shape_a.auxiliary = triangle_c[i]
42+
43+
shape_b = GenericShapeData()
44+
shape_b.shape_type = shape_b_type
45+
shape_b.scale = shape_b_scale
46+
shape_b.auxiliary = wp.vec3(0.0)
47+
48+
data_provider = SupportMapDataProvider()
49+
collision, point_a, point_b, normal, penetration = wp.static(create_solve_mpr(support_map).core)(
50+
shape_a,
51+
shape_b,
52+
shape_b_orientation[i],
53+
shape_b_position[i],
54+
0.0,
55+
data_provider,
56+
)
57+
58+
collision_out[i] = int(collision)
59+
point_a_out[i] = point_a
60+
point_b_out[i] = point_b
61+
normal_out[i] = normal
62+
penetration_out[i] = penetration
63+
64+
65+
def _run_triangle_mpr(triangle_b, triangle_c, shape_type, shape_scale, shape_positions, shape_orientations):
66+
"""Run direct triangle-vs-convex MPR cases on CPU and return NumPy outputs."""
67+
device = "cpu"
68+
count = len(triangle_b)
69+
70+
triangle_b_wp = wp.array(np.asarray(triangle_b, dtype=np.float32), dtype=wp.vec3, device=device)
71+
triangle_c_wp = wp.array(np.asarray(triangle_c, dtype=np.float32), dtype=wp.vec3, device=device)
72+
shape_positions_wp = wp.array(np.asarray(shape_positions, dtype=np.float32), dtype=wp.vec3, device=device)
73+
shape_orientations_wp = wp.array(np.asarray(shape_orientations, dtype=np.float32), dtype=wp.quat, device=device)
74+
75+
collision = wp.zeros(count, dtype=int, device=device)
76+
point_a = wp.zeros(count, dtype=wp.vec3, device=device)
77+
point_b = wp.zeros(count, dtype=wp.vec3, device=device)
78+
normal = wp.zeros(count, dtype=wp.vec3, device=device)
79+
penetration = wp.zeros(count, dtype=float, device=device)
80+
81+
wp.launch(
82+
_triangle_mpr_kernel,
83+
dim=count,
84+
inputs=[
85+
triangle_b_wp,
86+
triangle_c_wp,
87+
int(shape_type),
88+
wp.vec3(*shape_scale),
89+
shape_positions_wp,
90+
shape_orientations_wp,
91+
],
92+
outputs=[collision, point_a, point_b, normal, penetration],
93+
device=device,
94+
)
95+
96+
return (
97+
collision.numpy(),
98+
point_a.numpy(),
99+
point_b.numpy(),
100+
normal.numpy(),
101+
penetration.numpy(),
102+
)
103+
104+
105+
class TestMPRTriangleInitialization(unittest.TestCase):
106+
"""Test triangle MPR initialization across disparate geometry scales."""
107+
108+
def test_small_cylinder_on_large_triangle(self):
109+
"""A small cylinder on a large triangle must resolve along the face normal.
110+
111+
A fixed 1% triangle-centroid blend moves the initial point by nearly
112+
half a meter in this case. With the cylinder center only 0.36 mm above
113+
the face, that makes the MPR ray almost tangential and used to produce
114+
a 58.8 m penetration instead of the geometric 61.7 mm penetration.
115+
"""
116+
cylinder_position = np.array([28.87991333, 260.53430176, 0.00036136055], dtype=np.float32)
117+
cylinder_orientation = np.array(
118+
[-0.16498479, 0.36682746, -0.48718625, 0.77515626],
119+
dtype=np.float32,
120+
)
121+
radius = 0.02
122+
half_height = 0.07
123+
124+
collision, _point_a, _point_b, normals, penetrations = _run_triangle_mpr(
125+
triangle_b=[[100.0, 320.0, 0.0]],
126+
triangle_c=[[0.0, 320.0, 0.0]],
127+
shape_type=GeoType.CYLINDER,
128+
shape_scale=(radius, half_height, radius),
129+
shape_positions=[cylinder_position],
130+
shape_orientations=[cylinder_orientation],
131+
)
132+
133+
# The cylinder axis is its rotated local Z axis. Its support extent
134+
# along world Z is h*|axis_z| + r*sqrt(1-axis_z^2).
135+
qx, qy, _qz, _qw = cylinder_orientation
136+
axis_z = 1.0 - 2.0 * (qx * qx + qy * qy)
137+
vertical_extent = half_height * abs(axis_z) + radius * np.sqrt(max(0.0, 1.0 - axis_z * axis_z))
138+
expected_penetration = vertical_extent - cylinder_position[2]
139+
140+
self.assertEqual(collision[0], 1)
141+
self.assertAlmostEqual(float(penetrations[0]), float(expected_penetration), delta=2.0e-5)
142+
np.testing.assert_allclose(normals[0], [0.0, 0.0, 1.0], atol=2.0e-4)
143+
144+
def test_shared_edge_witnesses_remain_triangle_specific(self):
145+
"""The bounded nudge must retain distinct witnesses across a mesh seam.
146+
147+
The two triangles form a square split along x=y, and the box center is
148+
directly above that shared edge. The witness on each triangle should
149+
lie on its own side of the seam rather than both being edge-biased.
150+
"""
151+
collision, points_a, _points_b, normals, penetrations = _run_triangle_mpr(
152+
triangle_b=[[2.0, 0.0, 0.0], [2.0, 2.0, 0.0]],
153+
triangle_c=[[2.0, 2.0, 0.0], [0.0, 2.0, 0.0]],
154+
shape_type=GeoType.BOX,
155+
shape_scale=(0.15, 0.12, 0.1),
156+
shape_positions=[[1.0, 1.0, 0.095], [1.0, 1.0, 0.095]],
157+
shape_orientations=[[0.0, 0.0, 0.0, 1.0], [0.0, 0.0, 0.0, 1.0]],
158+
)
159+
160+
np.testing.assert_array_equal(collision, [1, 1])
161+
self.assertGreater(float(points_a[0, 0] - points_a[0, 1]), 1.0e-4)
162+
self.assertGreater(float(points_a[1, 1] - points_a[1, 0]), 1.0e-4)
163+
np.testing.assert_allclose(normals, [[0.0, 0.0, 1.0], [0.0, 0.0, 1.0]], atol=1.0e-5)
164+
np.testing.assert_allclose(penetrations, [0.005, 0.005], atol=1.0e-5)
165+
166+
167+
if __name__ == "__main__":
168+
unittest.main(verbosity=2, failfast=True)

0 commit comments

Comments
 (0)