diff --git a/differt/src/differt/geometry/triangle_mesh.py b/differt/src/differt/geometry/triangle_mesh.py index 9aeb3f49..508f49e1 100644 --- a/differt/src/differt/geometry/triangle_mesh.py +++ b/differt/src/differt/geometry/triangle_mesh.py @@ -413,7 +413,12 @@ def plane( rotate: Float[ArrayLike, " "] | None = None, ) -> Self: """ - Create an plane mesh, made of two triangles. + Create a plane mesh, made of two triangles. + + Note: + The mesh satisfies the guarantees + expected when setting + :attr:`assume_quads` to :data:`True`. Args: vertex_a: The center of the plane. @@ -470,6 +475,72 @@ def plane( triangles = jnp.array([[0, 1, 2], [0, 2, 3]], dtype=int) return cls(vertices=vertices, triangles=triangles) + @classmethod + @jaxtyped( + typechecker=None + ) # typing.Self is (currently) not compatible with jaxtyping and beartype + def box( + cls, + length: Float[ArrayLike, " "] = 1.0, + width: Float[ArrayLike, " "] = 1.0, + height: Float[ArrayLike, " "] = 1.0, + *, + with_top: bool = False, + ) -> Self: + """ + Create a box mesh, with an optional opening on the top. + + Note: + The mesh satisfies the guarantees + expected when setting + :attr:`assume_quads` to :data:`True`. + + Args: + length: The length of the box (along x-axis). + width: The width of the box (along y-axis). + height: The height of the box (along z-axis). + with_top: Whether the top of part + of the box is included or not. + + Returns: + A new box mesh. + """ + dx = jnp.array([length * 0.5, 0.0, 0.0]) + dy = jnp.array([0.0, width * 0.5, 0.0]) + dz = jnp.array([0.0, 0.0, height * 0.5]) + + vertices = jnp.stack(( + +dx + dy + dz, + +dx + dy - dz, + -dx + dy - dz, + -dx + dy + dz, + -dx - dy - dz, + -dx - dy + dz, + +dx - dy - dz, + +dx - dy + dz, + )) + triangles = jnp.array( + [ + [0, 1, 2], + [0, 2, 3], + [3, 2, 4], + [3, 4, 5], + [5, 4, 6], + [5, 6, 7], + [7, 6, 1], + [7, 1, 0], + [1, 4, 2], # Bottom + [1, 6, 4], + ], + dtype=int, + ) + if with_top: + triangles = jnp.concatenate( + (triangles, jnp.asarray([[0, 3, 5], [0, 5, 7]])), + axis=0, + ) + return cls(vertices=vertices, triangles=triangles) + @property def is_empty(self) -> bool: """Whether this scene has no triangle.""" diff --git a/differt/tests/geometry/test_triangle_mesh.py b/differt/tests/geometry/test_triangle_mesh.py index a21e4645..1288b930 100644 --- a/differt/tests/geometry/test_triangle_mesh.py +++ b/differt/tests/geometry/test_triangle_mesh.py @@ -211,6 +211,27 @@ def test_plane(self, key: PRNGKeyArray) -> None: ): _ = TriangleMesh.plane(center) # type: ignore[reportCallIssue] + @pytest.mark.parametrize( + ("length", "width", "height"), + [(10.0, 5.0, 4.0)], + ) + @pytest.mark.parametrize("with_top", [False, True]) + def test_box( + self, length: float, width: float, height: float, with_top: bool + ) -> None: + mesh = TriangleMesh.box(length, width, height, with_top=with_top) + + if with_top: + assert mesh.num_triangles == 12 + else: + assert mesh.num_triangles == 10 + + dx = length * 0.5 + dy = width * 0.5 + dz = height * 0.5 + + assert mesh.bounding_box.tolist() == [[-dx, -dy, -dz], [+dx, +dy, +dz]] + def test_rotate(self, two_buildings_mesh: TriangleMesh, key: PRNGKeyArray) -> None: angle = jax.random.uniform(key, (), minval=0, maxval=2 * jnp.pi)