|
| 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