From 862c1771a9a000e52dff4139cc52d195d47aeaf1 Mon Sep 17 00:00:00 2001 From: Zachary Ferguson Date: Mon, 11 May 2026 15:51:50 -0400 Subject: [PATCH 1/6] Add CollisionFilter utilities and tests - Introduce ipc::CollisionFilter (type-erased, composable predicate) with |, &, !, |=, &= and callable construction - Add inline factories: make_vertex_patches_filter and make_static_obstacle_filter; declare make_connected_components_filter - Implement make_connected_components_filter using libigl - Wire CollisionFilter into broad_phase, CollisionMesh bindings, and CMake lists - Add Catch2 unit tests and Python tests validating filter APIs --- python/src/collision_mesh.cpp | 254 ++++++++++---------- python/tests/test_collision_filter.py | 274 ++++++++++++++++++++++ python/tests/test_collision_mesh.py | 13 +- src/ipc/CMakeLists.txt | 2 + src/ipc/broad_phase/broad_phase.hpp | 11 +- src/ipc/collision_filter.cpp | 18 ++ src/ipc/collision_filter.hpp | 135 +++++++++++ src/ipc/collision_mesh.hpp | 15 +- tests/src/tests/CMakeLists.txt | 1 + tests/src/tests/test_collision_filter.cpp | 235 +++++++++++++++++++ 10 files changed, 809 insertions(+), 149 deletions(-) create mode 100644 python/tests/test_collision_filter.py create mode 100644 src/ipc/collision_filter.cpp create mode 100644 src/ipc/collision_filter.hpp create mode 100644 tests/src/tests/test_collision_filter.cpp diff --git a/python/src/collision_mesh.cpp b/python/src/collision_mesh.cpp index 4fd96b56e..ad5c12892 100644 --- a/python/src/collision_mesh.cpp +++ b/python/src/collision_mesh.cpp @@ -1,5 +1,6 @@ #include +#include #include #include @@ -20,120 +21,140 @@ struct PairHash { using MapCanCollide = std::unordered_map, bool, PairHash>; -} // namespace - -/// @brief A functor which the value of the pair if it is in the map, otherwise a default value. -class SparseCanCollide { -public: - /// @brief Construct a new Sparse Can Collide object. - /// @param explicit_values A map from vertex pairs to whether they can collide. Only the upper triangle is used. The map is assumed to be symmetric. - /// @param default_value The default value to return if the pair is not in the map. - SparseCanCollide(const MapCanCollide& explicit_values, bool default_value) - : m_explicit_values(explicit_values) - , m_default_value(default_value) - { - } - - /// @brief Can two vertices collide? - /// @param i Index of the first vertex. - /// @param j Index of the second vertex. - /// @return The value of the pair if it is in the map, otherwise the default value. - bool operator()(size_t i, size_t j) const - { +CollisionFilter +make_sparse_filter(MapCanCollide explicit_values, bool default_value) +{ + return [m_explicit_values = std::move(explicit_values), + m_default_value = default_value](size_t i, size_t j) { auto it = m_explicit_values.find({ std::min(i, j), std::max(i, j) }); - assert( m_explicit_values.find({ std::max(i, j), std::min(i, j) }) == m_explicit_values.end()); - if (it != m_explicit_values.end()) { return it->second; } return m_default_value; - } - -private: - const MapCanCollide m_explicit_values; - const bool m_default_value; -}; - -class VertexPatchesCanCollide { -public: - /// @brief Construct a new Vertex Patches Can Collide object. - /// @param vertex_patches Vector of patches labels for each vertex. - VertexPatchesCanCollide(Eigen::ConstRef vertex_patches) - : m_vertex_patches(vertex_patches) - { - } - - /// @brief Can two vertices collide? - /// @param i Index of the first vertex. - /// @param j Index of the second vertex. - /// @return true if the vertices are in different patches - bool operator()(size_t i, size_t j) const - { - assert(i < m_vertex_patches.size()); - assert(j < m_vertex_patches.size()); - return m_vertex_patches[i] != m_vertex_patches[j]; - } - - size_t num_vertices() const { return m_vertex_patches.size(); } + }; +} -private: - const Eigen::VectorXi m_vertex_patches; -}; +} // namespace void define_collision_mesh(py::module_& m) { - py::class_( - m, "SparseCanCollide", - "A functor which the value of the pair if it is in the map, otherwise a default value.") + py::class_( + m, "CollisionFilter", + R"ipc_Qu8mg5v7( + A composable, type-erased collision filter. + + Wraps any callable ``bool(int, int)`` and supports logical composition + via ``|`` (union), ``&`` (intersection), and ``~`` (negation) operators. + The default-constructed filter accepts all pairs. + + Example: + .. code-block:: python + + patches = CollisionFilter(lambda i, j: patches[i] != patches[j]) + static = make_static_obstacle_filter(n_dynamic) + active = patches & static + if active(i, j): + ... + )ipc_Qu8mg5v7") + .def(py::init<>(), "Default filter: accept all pairs.") .def( - py::init(), - R"ipc_Qu8mg5v7( - Construct a new Sparse Can Collide object. - - Parameters: - explicit_values: A map from vertex pairs to whether they can collide. Only the upper triangle is used. The map is assumed to be symmetric. - default_value: The default value to return if the pair is not in the map. - )ipc_Qu8mg5v7", - "explicit_values"_a, "default_value"_a) + py::init([](const py::function& fn) { + return CollisionFilter([fn](size_t i, size_t j) -> bool { + return py::cast(fn(i, j)); + }); + }), + "Construct from a Python callable ``bool(int, int)``.", "fn"_a) .def( - "__call__", &SparseCanCollide::operator(), R"ipc_Qu8mg5v7( - Can two vertices collide? - - Parameters: - i: Index of the first vertex. - j: Index of the second vertex. - - Returns: - The value of the pair if it is in the map, otherwise the default value. - )ipc_Qu8mg5v7", - "i"_a, "j"_a); - - py::class_( - m, "VertexPatchesCanCollide", - "A functor which returns true if the vertices are in different patches.") + "__call__", &CollisionFilter::operator(), + "Test whether two vertices may collide.", "vi"_a, "vj"_a) .def( - py::init>(), "vertex_patches"_a, - R"ipc_Qu8mg5v7( - Construct a new Vertex Patches Can Collide object. - - Parameters: - vertex_patches: Vector of patches labels for each vertex. - )ipc_Qu8mg5v7") + "__or__", + [](const CollisionFilter& a, const CollisionFilter& b) { + return a | b; + }, + "Union: accept if EITHER filter passes.") .def( - "__call__", &VertexPatchesCanCollide::operator(), R"ipc_Qu8mg5v7( - Can two vertices collide? - - Parameters: - i: Index of the first vertex. - j: Index of the second vertex. - - Returns: - True if the vertices are in different patches. - )ipc_Qu8mg5v7", - "i"_a, "j"_a); + "__and__", + [](const CollisionFilter& a, const CollisionFilter& b) { + return a & b; + }, + "Intersection: accept only if BOTH filters pass.") + .def( + "__invert__", [](const CollisionFilter& f) { return !f; }, + "Negation: accept only if this filter rejects.") + .def( + "__ior__", + [](CollisionFilter& f, + const CollisionFilter& b) -> CollisionFilter& { return f |= b; }) + .def( + "__iand__", + [](CollisionFilter& f, const CollisionFilter& b) + -> CollisionFilter& { return f &= b; }); + + m.def( + "make_sparse_filter", &make_sparse_filter, + R"ipc_Qu8mg5v7( + Create a filter from a sparse map of explicit vertex-pair values. + + Pairs present in ``explicit_values`` use the stored boolean; all + other pairs fall back to ``default_value``. Only the upper triangle + of the pair space is used — keys must satisfy ``i < j``. + + Parameters: + explicit_values: Dict mapping ``(i, j)`` pairs (``i < j``) to + whether those two vertices can collide. + default_value: Value returned for pairs not in the map. + + Returns: + A CollisionFilter backed by the sparse map. + )ipc_Qu8mg5v7", + "explicit_values"_a, "default_value"_a); + + m.def( + "make_vertex_patches_filter", &make_vertex_patches_filter, + R"ipc_Qu8mg5v7( + Create a filter that only allows collisions between vertices in different patches (e.g., different garment panels or bodies). + + Parameters: + patch_ids: Per-vertex patch label vector (one entry per vertex). + + Returns: + A CollisionFilter that blocks same-patch pairs. + )ipc_Qu8mg5v7", + "patch_ids"_a); + + m.def( + "make_static_obstacle_filter", &make_static_obstacle_filter, + R"ipc_Qu8mg5v7( + Create a filter that prevents static obstacles from colliding with each other. + A vertex is considered "static" if its index is >= n_dynamic. + Pairs where both vertices are static are rejected. + + Parameters: + n_dynamic: Number of dynamic (simulated) vertices; static vertices occupy indices [n_dynamic, n_verts). + + Returns: + A CollisionFilter that blocks static-static pairs. + )ipc_Qu8mg5v7", + "n_dynamic"_a); + + m.def( + "make_connected_components_filter", &make_connected_components_filter, + R"ipc_Qu8mg5v7( + Create a filter that prevents self-collisions within a connected + component of the face mesh. Two vertices in the same connected + component are blocked; cross-component pairs are allowed. + + Parameters: + faces: Face index matrix (#F × 3). + + Returns: + A CollisionFilter that blocks intra-component pairs. + )ipc_Qu8mg5v7", + "faces"_a); py::class_>(m, "Hyperplane") .def(py::init<>()) @@ -471,36 +492,21 @@ void define_collision_mesh(py::module_& m) .def_property( "can_collide", [](CollisionMesh& self) { return self.can_collide; }, [](CollisionMesh& self, const py::object& can_collide) { - if (py::isinstance(can_collide)) { - - self.can_collide = py::cast(can_collide); - - } else if (py::isinstance( - can_collide)) { - - const VertexPatchesCanCollide& vertex_patches_can_collide = - py::cast(can_collide); - - if (self.num_vertices() - != vertex_patches_can_collide.num_vertices()) { - throw py::value_error( - "The number of vertices in the VertexPatchesCanCollide object must match the number of vertices in the CollisionMesh."); - } - - self.can_collide = vertex_patches_can_collide; - + if (py::isinstance(can_collide)) { + self.can_collide = py::cast(can_collide); } else if (py::isinstance(can_collide)) { - logger().warn( - "Using a custom function for can_collide is deprecated because it is slow. " - "Please use a SparseCanCollide or VertexPatchesCanCollide object."); - self.can_collide = [can_collide](size_t i, size_t j) { - return py::cast(can_collide(i, j)); - }; - + "Using a custom Python function for can_collide is deprecated. Please use a CollisionFilter object."); + self.can_collide = + CollisionFilter([can_collide](size_t i, size_t j) { + return py::cast(can_collide(i, j)); + }); } else { throw py::value_error( - "Unknown type for can_collide. Must be a SparseCanCollide, VertexPatchesCanCollide, or a function."); + "can_collide must be a CollisionFilter or a callable " + "bool(int, int). Use make_sparse_filter(), " + "make_vertex_patches_filter(), or similar factory " + "functions to create a CollisionFilter."); } }, R"ipc_Qu8mg5v7( diff --git a/python/tests/test_collision_filter.py b/python/tests/test_collision_filter.py new file mode 100644 index 000000000..e9524cc28 --- /dev/null +++ b/python/tests/test_collision_filter.py @@ -0,0 +1,274 @@ +import find_ipctk +import numpy as np +from ipctk import ( + CollisionFilter, + CollisionMesh, + make_connected_components_filter, + make_sparse_filter, + make_static_obstacle_filter, + make_vertex_patches_filter, +) + +# ───────────────────────────────────────────────────────────────────────────── +# CollisionFilter basic construction +# ───────────────────────────────────────────────────────────────────────────── + + +def test_collision_filter_default(): + """Default-constructed filter accepts all pairs.""" + f = CollisionFilter() + assert f(0, 1) + assert f(5, 10) + assert f(0, 0) # self-pair accepted by default + + +def test_collision_filter_from_callable(): + """Filter constructed from a Python callable.""" + # Two bodies: verts 0-2 in body 0, verts 3-5 in body 1. + body = [0, 0, 0, 1, 1, 1] + f = CollisionFilter(lambda i, j: body[i] != body[j]) + + assert not f(0, 1) # same body + assert not f(3, 5) # same body + assert f(0, 3) # different bodies + assert f(2, 4) # different bodies + + +# ───────────────────────────────────────────────────────────────────────────── +# Composition operators +# ───────────────────────────────────────────────────────────────────────────── + + +def test_collision_filter_negation(): + """~ inverts the filter result.""" + body = [0, 0, 0, 1, 1, 1] + same_body = CollisionFilter(lambda i, j: body[i] == body[j]) + cross_body = ~same_body + + assert same_body(0, 2) + assert not cross_body(0, 2) + + assert not same_body(0, 4) + assert cross_body(0, 4) + + +def test_collision_filter_union(): + """f | g accepts a pair if EITHER filter passes.""" + # Layer tags: 0,0,1,0,1,1 + layer = [0, 0, 1, 0, 1, 1] + + a = CollisionFilter(lambda i, j: layer[i] == 1) # pass if vi in layer 1 + b = CollisionFilter(lambda i, j: layer[j] == 0) # pass if vj in layer 0 + + a_or_b = a | b + + assert a_or_b(2, 5) # a passes (vi=2, layer 1) + assert a_or_b(0, 3) # b passes (vj=3, layer 0) + assert not a_or_b(0, 4) # neither: vi=0 layer 0, vj=4 layer 1 + + +def test_collision_filter_intersection(): + """f & g accepts a pair only if BOTH filters pass.""" + body = [0, 0, 0, 1, 1, 1] + layer = [0, 0, 1, 0, 1, 1] + + self_col = CollisionFilter(lambda i, j: body[i] != body[j]) + layer_col = CollisionFilter(lambda i, j: layer[i] != layer[j]) + + both = self_col & layer_col + + # (0,1): same body → self_col blocks → intersection blocks + assert not both(0, 1) + # (0,2): same body → self_col blocks → intersection blocks + assert not both(0, 2) + # (0,3): cross body, same layer (0,0) → layer_col blocks → blocks + assert not both(0, 3) + # (0,4): cross body, cross layer (0,1) → both pass + assert both(0, 4) + + +def test_collision_filter_union_intersection_semantics(): + """Union passes when either passes; intersection requires both.""" + body = [0, 0, 0, 1, 1, 1] + layer = [0, 0, 1, 0, 1, 1] + + self_col = CollisionFilter(lambda i, j: body[i] != body[j]) + layer_col = CollisionFilter(lambda i, j: layer[i] != layer[j]) + + cf_union = self_col | layer_col + cf_inter = self_col & layer_col + + # (0,1): both block → union and intersection both block + assert not cf_union(0, 1) + assert not cf_inter(0, 1) + + # (0,2): self blocks, layer passes → union passes, inter blocks + assert cf_union(0, 2) + assert not cf_inter(0, 2) + + # (0,3): self passes, layer blocks → union passes, inter blocks + assert cf_union(0, 3) + assert not cf_inter(0, 3) + + # (0,4): both pass + assert cf_union(0, 4) + assert cf_inter(0, 4) + + +def test_collision_filter_ior(): + """f |= g modifies f to be the union of f and g.""" + f = CollisionFilter(lambda i, j: i < 3) + f |= CollisionFilter(lambda i, j: j < 3) + + assert f(5, 2) # j < 3 + assert f(2, 5) # i < 3 + assert not f(5, 5) # neither + + +def test_collision_filter_iand(): + """f &= g modifies f to be the intersection of f and g.""" + f = CollisionFilter(lambda i, j: i < 10) + f &= CollisionFilter(lambda i, j: j < 10) + + assert f(3, 4) # both + assert not f(3, 15) # j >= 10 + assert not f(15, 3) # i >= 10 + + +# ───────────────────────────────────────────────────────────────────────────── +# Factory functions +# ───────────────────────────────────────────────────────────────────────────── + + +def test_make_vertex_patches_filter(): + """Blocks pairs within the same patch, allows cross-patch pairs.""" + patches = np.array([0, 0, 0, 1, 1, 1], dtype=np.int32) + f = make_vertex_patches_filter(patches) + + assert f(0, 3) # different patches + assert f(2, 4) # different patches + assert not f(0, 1) # same patch 0 + assert not f(3, 5) # same patch 1 + + +def test_make_static_obstacle_filter(): + """Blocks static-static pairs; allows dynamic-* pairs.""" + # Vertices 0-3 are dynamic; vertices 4-5 are static obstacles. + n_dynamic = 4 + f = make_static_obstacle_filter(n_dynamic) + + # dynamic–dynamic: allowed + assert f(0, 3) + assert f(1, 2) + + # dynamic–static: allowed (either order) + assert f(2, 4) + assert f(0, 5) + assert f(4, 1) # order flipped + + # static–static: blocked + assert not f(4, 5) + assert not f(5, 4) + + +def test_make_connected_components_filter(): + """Blocks intra-component pairs; allows cross-component pairs.""" + # Two disconnected triangles: {0,1,2} and {3,4,5} + faces = np.array([[0, 1, 2], [3, 4, 5]], dtype=np.int32) + f = make_connected_components_filter(faces) + + # Cross-component: allowed + assert f(0, 3) + assert f(1, 4) + assert f(2, 5) + + # Intra-component: blocked + assert not f(0, 1) + assert not f(0, 2) + assert not f(1, 2) + assert not f(3, 4) + assert not f(3, 5) + assert not f(4, 5) + + +# ───────────────────────────────────────────────────────────────────────────── +# Composition chain +# ───────────────────────────────────────────────────────────────────────────── + + +def test_collision_filter_composition_chain(): + """Combine connected-components and static-obstacle filters.""" + # Two disconnected triangles: {0,1,2} and {3,4,5} + faces = np.array([[0, 1, 2], [3, 4, 5]], dtype=np.int32) + + # Vertices 4 and 5 are also "static obstacles" + n_dynamic = 4 + + no_self = make_connected_components_filter(faces) + no_static = make_static_obstacle_filter(n_dynamic) + + active = no_self & no_static + + # Cross-component, at least one dynamic → allowed + assert active(0, 3) + assert active(1, 4) # vj=4 is static but vi=1 is dynamic + assert active(2, 5) # vj=5 is static but vi=2 is dynamic + + # Static–static cross-component pair → blocked by no_static + assert not active(4, 5) + + # Intra-component → blocked by no_self + assert not active(0, 1) + assert not active(3, 5) + + +# ───────────────────────────────────────────────────────────────────────────── +# SparseCanCollide composition +# ───────────────────────────────────────────────────────────────────────────── + + +def test_make_sparse_filter(): + """make_sparse_filter returns a composable CollisionFilter.""" + # Block pair (0, 1) explicitly; allow everything else. + sparse = make_sparse_filter({(0, 1): False}, True) + + assert sparse(0, 2) # not in map → default True + assert not sparse(0, 1) # explicit False + + # ~ (invert): (0,1) now passes; everything else is blocked + inverted = ~sparse + assert inverted(0, 1) # was blocked, now passes + assert not inverted(0, 2) # was allowed, now blocked + + # | (union): the blocked (0,1) pair is rescued by a second filter + rescue_01 = CollisionFilter(lambda i, j: i == 0 and j == 1) + rescued = sparse | rescue_01 + assert rescued(0, 1) # sparse blocks but rescue_01 passes → union passes + assert rescued(0, 2) # sparse passes → union passes + + # & (intersection): add a restriction on top of sparse + only_small = CollisionFilter(lambda i, j: i < 3 and j < 3) + restricted = sparse & only_small + assert restricted(0, 2) # sparse allows, only_small allows → passes + assert not restricted(0, 1) # sparse blocks → intersection fails + assert not restricted(0, 5) # only_small fails (j=5 ≥ 3) → intersection fails + + +def test_collision_filter_on_mesh_can_collide(): + """CollisionFilter assigned to mesh.can_collide works correctly.""" + V = np.array( + [[0, 0], [1, 0], [0, 1], [1, 1], [2, 0], [3, 0], [2, 1], [3, 1]], dtype=float + ) + E = np.array( + [[0, 1], [1, 3], [3, 2], [2, 0], [4, 5], [5, 7], [7, 6], [6, 4]], dtype=int + ) + mesh = CollisionMesh(V, E) + + patches = np.array([0, 0, 0, 0, 1, 1, 1, 1], dtype=np.int32) + f = make_vertex_patches_filter(patches) + + mesh.can_collide = f + + for i in range(V.shape[0]): + for j in range(V.shape[0]): + assert mesh.can_collide(i, j) == (patches[i] != patches[j]) diff --git a/python/tests/test_collision_mesh.py b/python/tests/test_collision_mesh.py index 20f486e63..781833f1b 100644 --- a/python/tests/test_collision_mesh.py +++ b/python/tests/test_collision_mesh.py @@ -1,9 +1,9 @@ import time -import numpy as np -import scipy import find_ipctk -from ipctk import CollisionMesh, SparseCanCollide, VertexPatchesCanCollide +import numpy as np +import scipy +from ipctk import CollisionMesh, make_sparse_filter, make_vertex_patches_filter def test_collision_mesh(): @@ -97,7 +97,8 @@ def test_can_collide(): mesh = CollisionMesh(V, E) - def default_can_collide(i, j): return True + def default_can_collide(i, j): + return True patches = np.concatenate([np.zeros(4, dtype=int), np.ones(4, dtype=int)]) print(patches.size) @@ -114,8 +115,8 @@ def patches_can_collide(i, j): can_collides = [ default_can_collide, patches_can_collide, - SparseCanCollide(dict_can_collide, True), - VertexPatchesCanCollide(patches), + make_sparse_filter(dict_can_collide, True), + make_vertex_patches_filter(patches), ] for can_collide in can_collides: diff --git a/src/ipc/CMakeLists.txt b/src/ipc/CMakeLists.txt index aa1ae83de..fd8b5be2f 100644 --- a/src/ipc/CMakeLists.txt +++ b/src/ipc/CMakeLists.txt @@ -1,4 +1,6 @@ set(SOURCES + collision_filter.cpp + collision_filter.hpp collision_mesh.cpp collision_mesh.hpp ipc.hpp diff --git a/src/ipc/broad_phase/broad_phase.hpp b/src/ipc/broad_phase/broad_phase.hpp index 3a13cbadb..1b7ea96b5 100644 --- a/src/ipc/broad_phase/broad_phase.hpp +++ b/src/ipc/broad_phase/broad_phase.hpp @@ -96,9 +96,8 @@ class BroadPhase { virtual void detect_face_face_candidates( std::vector& candidates) const = 0; - /// @brief Function for determining if two vertices can collide. - std::function can_vertices_collide = - default_can_vertices_collide; + /// @brief Filter for determining if two vertices can collide. + CollisionFilter can_vertices_collide; protected: /// @brief Build the broad phase for collision detection. @@ -122,12 +121,6 @@ class BroadPhase { virtual bool can_edge_face_collide(size_t ei, size_t fi) const; virtual bool can_faces_collide(size_t fai, size_t fbi) const; - static bool - default_can_vertices_collide(size_t /*unused*/, size_t /*unused*/) - { - return true; - } - /// @brief AABBs for the vertices. AABBs vertex_boxes; /// @brief AABBs for the edges. diff --git a/src/ipc/collision_filter.cpp b/src/ipc/collision_filter.cpp new file mode 100644 index 000000000..28053aa99 --- /dev/null +++ b/src/ipc/collision_filter.cpp @@ -0,0 +1,18 @@ +#include + +#include +#include + +namespace ipc { + +CollisionFilter +make_connected_components_filter(Eigen::ConstRef faces) +{ + Eigen::SparseMatrix A; + igl::adjacency_matrix(faces, A); + Eigen::VectorXi C, K; + igl::connected_components(A, C, K); + return make_vertex_patches_filter(C); +} + +} // namespace ipc diff --git a/src/ipc/collision_filter.hpp b/src/ipc/collision_filter.hpp new file mode 100644 index 000000000..d8b104f89 --- /dev/null +++ b/src/ipc/collision_filter.hpp @@ -0,0 +1,135 @@ +#pragma once + +#include + +#include + +#include +#include + +namespace ipc { + +// ───────────────────────────────────────────────────────────────────────────── +// CollisionFilter +// +// A type-erased, composable predicate: operator()(size_t vi, size_t vj) -> bool +// +// CollisionFilter f = make_vertex_patches_filter(patches); +// CollisionFilter g = make_static_obstacle_filter(n_dynamic); +// CollisionFilter h = f & g; // both conditions must hold +// if (h(i, j)) { ... } +// +// Filters are value types — cheap to copy (shared_ptr to immutable impl). +// operator| → union (true if EITHER filter passes) +// operator& → intersection (true if BOTH filters pass) +// operator! → negation +// ───────────────────────────────────────────────────────────────────────────── + +class CollisionFilter { +public: + // ── Construction ───────────────────────────────────────────────────────── + + /// @brief Default filter: accept all pairs. + CollisionFilter() : m_fn([](int, int) { return true; }) { } + + /// @brief Construct from any callable bool(size_t, size_t). + /// @note Disabled when Fn is CollisionFilter itself to avoid shadowing + /// the copy constructor. + template < + typename Fn, + typename = std::enable_if_t< + std::is_invocable_r_v + && !std::is_same_v, CollisionFilter>>> + CollisionFilter(Fn&& fn) : m_fn(std::forward(fn)) + { + } + + // ── Call operator ──────────────────────────────────────────────────────── + + /// @brief Test whether two vertices may collide. + /// @param vi Index of the first vertex. + /// @param vj Index of the second vertex. + /// @return true if the pair should be considered for collision. + bool operator()(size_t vi, size_t vj) const { return m_fn(vi, vj); } + + // ── Composition ────────────────────────────────────────────────────────── + + /// @brief Union: accept if EITHER filter passes. + friend CollisionFilter operator|(CollisionFilter lhs, CollisionFilter rhs) + { + return CollisionFilter( + [l = std::move(lhs.m_fn), r = std::move(rhs.m_fn)](int vi, int vj) { + return l(vi, vj) || r(vi, vj); + }); + } + + /// @brief Intersection: accept only if BOTH filters pass. + friend CollisionFilter operator&(CollisionFilter lhs, CollisionFilter rhs) + { + return CollisionFilter( + [l = std::move(lhs.m_fn), r = std::move(rhs.m_fn)](int vi, int vj) { + return l(vi, vj) && r(vi, vj); + }); + } + + /// @brief Negation: accept only if this filter rejects. + CollisionFilter operator!() const + { + return CollisionFilter( + [f = m_fn](int vi, int vj) { return !f(vi, vj); }); + } + + /// @brief Compound union assignment. + CollisionFilter& operator|=(CollisionFilter rhs) + { + return *this = *this | std::move(rhs); + } + + /// @brief Compound intersection assignment. + CollisionFilter& operator&=(CollisionFilter rhs) + { + return *this = *this & std::move(rhs); + } + +private: + std::function m_fn; +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Inline factory functions +// ───────────────────────────────────────────────────────────────────────────── + +/// @brief Create a filter that only allows collisions between vertices in +/// different patches (e.g., different garment panels or bodies). +/// @param patch_ids Per-vertex patch label vector (one entry per vertex). +/// @return A CollisionFilter that blocks same-patch pairs. +inline CollisionFilter make_vertex_patches_filter(Eigen::VectorXi patch_ids) +{ + return CollisionFilter([ids = std::move(patch_ids)](size_t vi, size_t vj) { + return ids[vi] != ids[vj]; + }); +} + +/// @brief Create a filter that prevents static obstacles from colliding with +/// each other. A vertex is considered "static" if its index is +/// >= n_dynamic. Pairs where both vertices are static are rejected. +/// @param n_dynamic Number of dynamic (simulated) vertices; static vertices +/// occupy indices [n_dynamic, n_verts). +/// @return A CollisionFilter that blocks static-static pairs. +inline CollisionFilter make_static_obstacle_filter(size_t n_dynamic) +{ + return CollisionFilter([n_dynamic](size_t vi, size_t vj) { + return vi < n_dynamic || vj < n_dynamic; + }); +} + +/// @brief Create a filter that prevents self-collisions within a connected +/// component of the face mesh. Two vertices in the same connected +/// component are blocked; cross-component pairs are allowed. +/// @param faces Face index matrix (#F × 3). +/// @return A CollisionFilter that blocks intra-component pairs. +/// @note Implemented in collision_filter.cpp (requires libigl internally). +CollisionFilter +make_connected_components_filter(Eigen::ConstRef faces); + +} // namespace ipc diff --git a/src/ipc/collision_mesh.hpp b/src/ipc/collision_mesh.hpp index 8f1125aa2..aed01e80d 100644 --- a/src/ipc/collision_mesh.hpp +++ b/src/ipc/collision_mesh.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -331,10 +332,10 @@ class CollisionMesh { static Eigen::SparseMatrix vertex_matrix_to_dof_matrix( const Eigen::SparseMatrix& M_V, int dim); - /// A function that takes two vertex IDs and returns true if the vertices - /// (and faces or edges containing the vertices) can collide. By default all - /// primitives can collide with all other primitives. - std::function can_collide = default_can_collide; + /// A filter for determining if two vertices (and the primitives containing + /// them) can collide. By default all primitives can collide with all other + /// primitives. + CollisionFilter can_collide; /// @brief Analytic planes in the scene that can be collided with. /// This is useful for representing infinite planes (e.g., the ground plane) @@ -434,12 +435,6 @@ class CollisionMesh { /// @brief The rows of the Jacobian of the edge areas vector. std::vector> m_edge_area_jacobian; -private: - /// @brief By default all primitives can collide with all other primitives. - static bool default_can_collide(size_t /*unused*/, size_t /*unused*/) - { - return true; - } }; } // namespace ipc diff --git a/tests/src/tests/CMakeLists.txt b/tests/src/tests/CMakeLists.txt index 9775cefa9..6ac22663d 100644 --- a/tests/src/tests/CMakeLists.txt +++ b/tests/src/tests/CMakeLists.txt @@ -4,6 +4,7 @@ set(SOURCES # Tests test_cfl.cpp + test_collision_filter.cpp test_collision_mesh.cpp test_has_intersections.cpp test_ipc.cpp diff --git a/tests/src/tests/test_collision_filter.cpp b/tests/src/tests/test_collision_filter.cpp new file mode 100644 index 000000000..83cff23f9 --- /dev/null +++ b/tests/src/tests/test_collision_filter.cpp @@ -0,0 +1,235 @@ +#include + +#include + +#include + +using namespace ipc; + +// ───────────────────────────────────────────────────────────────────────────── + +TEST_CASE("CollisionFilter default", "[collision_filter]") +{ + CollisionFilter f; + CHECK(f(0, 1)); + CHECK(f(5, 10)); + CHECK(f(0, 0)); // self-pair is accepted by default +} + +// ───────────────────────────────────────────────────────────────────────────── + +TEST_CASE("CollisionFilter from lambda", "[collision_filter]") +{ + // Two bodies: verts 0-2 in body 0, verts 3-5 in body 1. + const std::vector body = { 0, 0, 0, 1, 1, 1 }; + CollisionFilter self_col( + [&body](size_t vi, size_t vj) { return body[vi] != body[vj]; }); + + CHECK_FALSE(self_col(0, 1)); // same body + CHECK_FALSE(self_col(3, 5)); // same body + CHECK(self_col(0, 3)); // different bodies + CHECK(self_col(2, 4)); // different bodies +} + +// ───────────────────────────────────────────────────────────────────────────── + +TEST_CASE("CollisionFilter operator!", "[collision_filter]") +{ + CollisionFilter same_body( + [](size_t vi, size_t vj) { return vi / 3 == vj / 3; }); + + CollisionFilter cross_body = !same_body; + + CHECK(same_body(0, 2)); + CHECK_FALSE(cross_body(0, 2)); + + CHECK_FALSE(same_body(0, 4)); + CHECK(cross_body(0, 4)); +} + +// ───────────────────────────────────────────────────────────────────────────── + +TEST_CASE("CollisionFilter operator|", "[collision_filter]") +{ + // Layer tags: 0,0,1,0,1,1 + const std::vector layer = { 0, 0, 1, 0, 1, 1 }; + + CollisionFilter a( // pass if vi is in layer 1 + [&layer](size_t vi, size_t /*vj*/) { return layer[vi] == 1; }); + CollisionFilter b( // pass if vj is in layer 0 + [&layer](size_t /*vi*/, size_t vj) { return layer[vj] == 0; }); + + CollisionFilter a_or_b = a | b; + + CHECK(a_or_b(2, 5)); // a passes (vi=2, layer 1) + CHECK(a_or_b(0, 3)); // b passes (vj=3, layer 0) + CHECK_FALSE(a_or_b(0, 4)); // neither: vi=0 layer 0, vj=4 layer 1 +} + +// ───────────────────────────────────────────────────────────────────────────── + +TEST_CASE("CollisionFilter operator&", "[collision_filter]") +{ + const std::vector body = { 0, 0, 0, 1, 1, 1 }; + const std::vector layer = { 0, 0, 1, 0, 1, 1 }; + + CollisionFilter self_col( + [&body](size_t vi, size_t vj) { return body[vi] != body[vj]; }); + CollisionFilter layer_col( + [&layer](size_t vi, size_t vj) { return layer[vi] != layer[vj]; }); + + CollisionFilter both = self_col & layer_col; + + // (0,1): same body → self_col blocks → intersection blocks + CHECK_FALSE(both(0, 1)); + // (0,2): same body → self_col blocks → intersection blocks + CHECK_FALSE(both(0, 2)); + // (0,3): cross body, same layer (0,0) → layer_col blocks → blocks + CHECK_FALSE(both(0, 3)); + // (0,4): cross body, cross layer (0,1) → both pass + CHECK(both(0, 4)); +} + +// ───────────────────────────────────────────────────────────────────────────── + +TEST_CASE("CollisionFilter union/intersection semantics", "[collision_filter]") +{ + const std::vector body = { 0, 0, 0, 1, 1, 1 }; + const std::vector layer = { 0, 0, 1, 0, 1, 1 }; + + CollisionFilter self_col( + [&body](size_t vi, size_t vj) { return body[vi] != body[vj]; }); + CollisionFilter layer_col( + [&layer](size_t vi, size_t vj) { return layer[vi] != layer[vj]; }); + + CollisionFilter cf_union = self_col | layer_col; + CollisionFilter cf_inter = self_col & layer_col; + + // (0,1): both block → union also blocks + CHECK_FALSE(cf_union(0, 1)); + CHECK_FALSE(cf_inter(0, 1)); + + // (0,2): self blocks, layer passes → union passes, inter blocks + CHECK(cf_union(0, 2)); + CHECK_FALSE(cf_inter(0, 2)); + + // (0,3): self passes, layer blocks → union passes, inter blocks + CHECK(cf_union(0, 3)); + CHECK_FALSE(cf_inter(0, 3)); + + // (0,4): both pass + CHECK(cf_union(0, 4)); + CHECK(cf_inter(0, 4)); +} + +// ───────────────────────────────────────────────────────────────────────────── + +TEST_CASE("CollisionFilter operator|=", "[collision_filter]") +{ + CollisionFilter f([](size_t vi, size_t /*vj*/) { return vi < 3; }); + f |= CollisionFilter([](size_t /*vi*/, size_t vj) { return vj < 3; }); + + CHECK(f(5, 2)); // vj < 3 + CHECK(f(2, 5)); // vi < 3 + CHECK_FALSE(f(5, 5)); // neither +} + +TEST_CASE("CollisionFilter operator&=", "[collision_filter]") +{ + CollisionFilter f([](size_t vi, size_t /*vj*/) { return vi < 10; }); + f &= CollisionFilter([](size_t /*vi*/, size_t vj) { return vj < 10; }); + + CHECK(f(3, 4)); // both + CHECK_FALSE(f(3, 15)); // vj >= 10 + CHECK_FALSE(f(15, 3)); // vi >= 10 +} + +// ───────────────────────────────────────────────────────────────────────────── + +TEST_CASE("make_vertex_patches_filter", "[collision_filter]") +{ + Eigen::VectorXi patches(6); + patches << 0, 0, 0, 1, 1, 1; + + CollisionFilter f = make_vertex_patches_filter(patches); + + CHECK(f(0, 3)); // different patches + CHECK(f(2, 4)); // different patches + CHECK_FALSE(f(0, 1)); // same patch 0 + CHECK_FALSE(f(3, 5)); // same patch 1 +} + +// ───────────────────────────────────────────────────────────────────────────── + +TEST_CASE("make_static_obstacle_filter", "[collision_filter]") +{ + // Vertices 0-3 are dynamic; vertices 4-5 are static obstacles. + const size_t n_dynamic = 4; + CollisionFilter f = make_static_obstacle_filter(n_dynamic); + + // dynamic–dynamic: allowed + CHECK(f(0, 3)); + CHECK(f(1, 2)); + + // dynamic–static: allowed + CHECK(f(2, 4)); + CHECK(f(0, 5)); + CHECK(f(4, 1)); // order flipped + + // static–static: blocked + CHECK_FALSE(f(4, 5)); + CHECK_FALSE(f(5, 4)); +} + +// ───────────────────────────────────────────────────────────────────────────── + +TEST_CASE("make_connected_components_filter", "[collision_filter]") +{ + // Two disconnected triangles: {0,1,2} and {3,4,5} + Eigen::MatrixXi faces(2, 3); + faces << 0, 1, 2, 3, 4, 5; + + CollisionFilter f = make_connected_components_filter(faces); + + // Cross-component pairs: allowed + CHECK(f(0, 3)); + CHECK(f(1, 4)); + CHECK(f(2, 5)); + + // Intra-component pairs: blocked + CHECK_FALSE(f(0, 1)); + CHECK_FALSE(f(0, 2)); + CHECK_FALSE(f(1, 2)); + CHECK_FALSE(f(3, 4)); + CHECK_FALSE(f(3, 5)); + CHECK_FALSE(f(4, 5)); +} + +// ───────────────────────────────────────────────────────────────────────────── + +TEST_CASE("CollisionFilter composition chain", "[collision_filter]") +{ + // Combine: cross-component AND not-static-static + Eigen::MatrixXi faces(2, 3); + faces << 0, 1, 2, 3, 4, 5; + + // Verts 4 and 5 are also "static obstacles" + const size_t n_dynamic = 4; + + CollisionFilter no_self = make_connected_components_filter(faces); + CollisionFilter no_static = make_static_obstacle_filter(n_dynamic); + + CollisionFilter active = no_self & no_static; + + // Cross-component, at least one dynamic → allowed + CHECK(active(0, 3)); + CHECK(active(1, 4)); // vj=4 is static but vi=1 is dynamic + CHECK(active(2, 5)); // vj=5 is static but vi=2 is dynamic + + // Static–static cross-component pair → blocked by no_static + CHECK_FALSE(active(4, 5)); + + // Intra-component → blocked by no_self + CHECK_FALSE(active(0, 1)); + CHECK_FALSE(active(3, 5)); +} From a570ea6e72cfba1a9c6781392496134ffa22a761 Mon Sep 17 00:00:00 2001 From: Zachary Ferguson Date: Mon, 11 May 2026 15:59:22 -0400 Subject: [PATCH 2/6] Update faq.rst --- docs/source/tutorials/faq.rst | 76 +++++++++++++++++++++++++++++------ 1 file changed, 63 insertions(+), 13 deletions(-) diff --git a/docs/source/tutorials/faq.rst b/docs/source/tutorials/faq.rst index 3b92e7ded..9840fa479 100644 --- a/docs/source/tutorials/faq.rst +++ b/docs/source/tutorials/faq.rst @@ -34,20 +34,22 @@ To build the edge matrix you can use :cpp:`igl::edges(faces, edges);` in C++ or Is there a way to ignore select collisions? ------------------------------------------- -Yes, it is possible to ignore select collisions. +Yes. Both :cpp:`CollisionMesh::can_collide` and :cpp:`BroadPhase::can_vertices_collide` are :cpp:`CollisionFilter` objects — composable predicates of the form :cpp:`bool(size_t vi, size_t vj)` that control which vertex pairs enter the collision pipeline. -The functionality for doing so is through the :cpp:`BroadPhase::can_vertices_collide`. -This function takes two vertex IDs and returns a true if the vertices can collide otherwise false. +When building candidates through :cpp:`Candidates::build`, the broad phase automatically inherits :cpp:`CollisionMesh::can_collide`, so you only need to set it once on the mesh. -This is used to determine if any geometry connected to the verties can collide. E.g., when checking if vertex ``vi`` can collide with triangle ``f = (vj, vk, vl)``, the code checks: +**How primitive-level checks work** -.. code-block:: +:cpp:`BroadPhase` expands vertex-level decisions to primitive pairs. For example, checking whether vertex ``vi`` can collide with triangle ``f = (vj, vk, vl)`` evaluates: - can_face_vertex_collide(f, vi) := can_vertices_collide(vj, vi) && can_vertices_collide(vk, vi) && can_vertices_collide(vl, vi) +.. code-block:: -This is a little limited since it will ignore the one-ring around a vertex instead of a single face-vertex pair, but hopefully that can get you started. + can_face_vertex_collide(f, vi) := + can_vertices_collide(vj, vi) + && can_vertices_collide(vk, vi) + && can_vertices_collide(vl, vi) -To get something more customized, you can try to modify the BroadPhase class, which has these functions hard-coded: +This means the filter acts on the one-ring of a vertex rather than a single primitive pair. For finer control you can subclass :cpp:`BroadPhase` and override the virtual methods: .. code-block:: c++ @@ -57,14 +59,62 @@ To get something more customized, you can try to modify the BroadPhase class, wh virtual bool can_edge_face_collide(size_t ei, size_t fi) const; virtual bool can_faces_collide(size_t fai, size_t fbi) const; -You can modify these with function pointers or override them to have the specific implementation you are interested in. +:cpp:`CollisionFilter` wraps any :cpp:`bool(size_t, size_t)` callable and supports logical composition via ``|`` (union), ``&`` (intersection), and ``!``/``~`` (negation). + +The available factory functions are: + +- :cpp:`make_connected_components_filter(faces)` — blocks pairs within the same connected component (prevents self-collision). +- :cpp:`make_static_obstacle_filter(n_dynamic)` — blocks static-vs-static pairs; vertices with index ``>= n_dynamic`` are considered static. +- :cpp:`make_vertex_patches_filter(patch_ids)` — blocks pairs that share the same integer patch label. +- :cpp:`make_sparse_filter(explicit_values, default_value)` — sparse explicit overrides with a fallback default (Python only). + +.. md-tab-set:: + + .. md-tab-item:: C++ + + .. code-block:: c++ + + #include + + // Built-in factory functions + CollisionFilter no_self = make_connected_components_filter(mesh.faces()); + CollisionFilter no_static = make_static_obstacle_filter(n_dynamic_verts); + CollisionFilter by_patch = make_vertex_patches_filter(patch_ids); + + // Combine with | (union), & (intersection), ! (negation) + mesh.can_collide = no_self & no_static; + + // Or construct directly from a lambda + mesh.can_collide = CollisionFilter([&](size_t vi, size_t vj) { + return group[vi] != group[vj]; + }); + + .. md-tab-item:: Python + + .. code-block:: python + + import numpy as np + from ipctk import ( + CollisionFilter, + make_connected_components_filter, + make_sparse_filter, + make_static_obstacle_filter, + make_vertex_patches_filter, + ) + + # Built-in factory functions + no_self = make_connected_components_filter(mesh.faces) + no_static = make_static_obstacle_filter(n_dynamic) + by_patch = make_vertex_patches_filter(np.array([0, 0, 1, 1], dtype=np.int32)) -.. note:: + # Composition: &, |, ~ + mesh.can_collide = no_self & no_static - If you are building collisions through the ``Candidates`` class, the ``Candidates::build`` function sets the ``BroadPhase::can_vertices_collide`` using the ``CollisionMesh::can_collide`` function pointer. This ``CollisionMesh::can_collide`` function uses the same interface as the ``BroadPhase::can_vertices_collide`` above. + # Sparse explicit overrides (e.g. always allow pair (2, 5)) + mesh.can_collide = make_sparse_filter({(2, 5): True}, default_value=False) -.. warning:: - This method is not recommended for Python since calling a Python lambda function from the C++ side is too slow to use. Instead there are ``SparseCanCollide`` and ``VertexPatchesCanCollide`` classes in Python to help do this efficiently. + # Arbitrary callable (slower — prefer factory functions for large meshes) + mesh.can_collide = CollisionFilter(lambda i, j: group[i] != group[j]) My question is not answered here. What should I do? --------------------------------------------------- From ddb9387740a75eb5d9ab68be6997793deac2bd32 Mon Sep 17 00:00:00 2001 From: Zachary Ferguson Date: Tue, 12 May 2026 09:47:26 -0400 Subject: [PATCH 3/6] Remove extra blank line in collision_mesh.hpp --- src/ipc/collision_mesh.hpp | 1 - 1 file changed, 1 deletion(-) diff --git a/src/ipc/collision_mesh.hpp b/src/ipc/collision_mesh.hpp index aed01e80d..d335c7de5 100644 --- a/src/ipc/collision_mesh.hpp +++ b/src/ipc/collision_mesh.hpp @@ -434,7 +434,6 @@ class CollisionMesh { std::vector> m_vertex_area_jacobian; /// @brief The rows of the Jacobian of the edge areas vector. std::vector> m_edge_area_jacobian; - }; } // namespace ipc From 4f7995b89e958c340d7c1d1fc09b9971cccc0f77 Mon Sep 17 00:00:00 2001 From: Zachary Ferguson Date: Tue, 12 May 2026 10:53:51 -0400 Subject: [PATCH 4/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/ipc/collision_filter.hpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ipc/collision_filter.hpp b/src/ipc/collision_filter.hpp index d8b104f89..dadcb91d5 100644 --- a/src/ipc/collision_filter.hpp +++ b/src/ipc/collision_filter.hpp @@ -19,7 +19,8 @@ namespace ipc { // CollisionFilter h = f & g; // both conditions must hold // if (h(i, j)) { ... } // -// Filters are value types — cheap to copy (shared_ptr to immutable impl). +// Filters are value types implemented with std::function; copy cost depends on +// the stored callable. // operator| → union (true if EITHER filter passes) // operator& → intersection (true if BOTH filters pass) // operator! → negation From 1f18f87be6c3739d9e8d9be6761129af8179f5f1 Mon Sep 17 00:00:00 2001 From: Zachary Ferguson Date: Tue, 12 May 2026 11:16:18 -0400 Subject: [PATCH 5/6] Add codim cross filter and use in candidates - Add make_codim_cross_filter(nCV) to allow only mixed codim pairs - Change CollisionFilter callables to use size_t indices - Add implicit conversion to std::function - Compose filter with mesh.can_collide in Candidates to skip c-vertex/c-vertex and c-edge/c-edge pairs - Add missing include and adjust lambdas accordingly --- src/ipc/candidates/candidates.cpp | 14 +++++------- src/ipc/collision_filter.hpp | 38 +++++++++++++++++++++++-------- 2 files changed, 34 insertions(+), 18 deletions(-) diff --git a/src/ipc/candidates/candidates.cpp b/src/ipc/candidates/candidates.cpp index f93833dc3..6d8bc9685 100644 --- a/src/ipc/candidates/candidates.cpp +++ b/src/ipc/candidates/candidates.cpp @@ -102,10 +102,9 @@ void Candidates::build( // TODO: Can we reuse the broad phase from above? broad_phase->clear(); - broad_phase->can_vertices_collide = [&](size_t vi, size_t vj) { - // Ignore c-edge to c-edge and c-vertex to c-vertex - return ((vi < nCV) ^ (vj < nCV)) && mesh.can_collide(vi, vj); - }; + // Ignore c-edge to c-edge and c-vertex to c-vertex + broad_phase->can_vertices_collide = + make_codim_cross_filter(nCV) & mesh.can_collide; broad_phase->build(V, CE, Eigen::MatrixXi(), inflation_radius); broad_phase->detect_edge_vertex_candidates(ev_candidates); @@ -197,10 +196,9 @@ void Candidates::build( // TODO: Can we reuse the broad phase from above? broad_phase->clear(); - broad_phase->can_vertices_collide = [&](size_t vi, size_t vj) { - // Ignore c-edge to c-edge and c-vertex to c-vertex - return ((vi < nCV) ^ (vj < nCV)) && mesh.can_collide(vi, vj); - }; + // Ignore c-edge to c-edge and c-vertex to c-vertex + broad_phase->can_vertices_collide = + make_codim_cross_filter(nCV) & mesh.can_collide; broad_phase->build(V_t0, V_t1, CE, Eigen::MatrixXi(), inflation_radius); broad_phase->detect_edge_vertex_candidates(ev_candidates); diff --git a/src/ipc/collision_filter.hpp b/src/ipc/collision_filter.hpp index dadcb91d5..16fe1cd13 100644 --- a/src/ipc/collision_filter.hpp +++ b/src/ipc/collision_filter.hpp @@ -5,6 +5,7 @@ #include #include +#include #include namespace ipc { @@ -31,7 +32,7 @@ class CollisionFilter { // ── Construction ───────────────────────────────────────────────────────── /// @brief Default filter: accept all pairs. - CollisionFilter() : m_fn([](int, int) { return true; }) { } + CollisionFilter() : m_fn([](size_t, size_t) { return true; }) { } /// @brief Construct from any callable bool(size_t, size_t). /// @note Disabled when Fn is CollisionFilter itself to avoid shadowing @@ -53,31 +54,36 @@ class CollisionFilter { /// @return true if the pair should be considered for collision. bool operator()(size_t vi, size_t vj) const { return m_fn(vi, vj); } + // ── Implicit conversion ────────────────────────────────────────────────── + + /// @brief Implicit conversion to std::function. + operator std::function() const { return m_fn; } + // ── Composition ────────────────────────────────────────────────────────── /// @brief Union: accept if EITHER filter passes. friend CollisionFilter operator|(CollisionFilter lhs, CollisionFilter rhs) { - return CollisionFilter( - [l = std::move(lhs.m_fn), r = std::move(rhs.m_fn)](int vi, int vj) { - return l(vi, vj) || r(vi, vj); - }); + return CollisionFilter([l = std::move(lhs.m_fn), + r = std::move(rhs.m_fn)](size_t vi, size_t vj) { + return l(vi, vj) || r(vi, vj); + }); } /// @brief Intersection: accept only if BOTH filters pass. friend CollisionFilter operator&(CollisionFilter lhs, CollisionFilter rhs) { - return CollisionFilter( - [l = std::move(lhs.m_fn), r = std::move(rhs.m_fn)](int vi, int vj) { - return l(vi, vj) && r(vi, vj); - }); + return CollisionFilter([l = std::move(lhs.m_fn), + r = std::move(rhs.m_fn)](size_t vi, size_t vj) { + return l(vi, vj) && r(vi, vj); + }); } /// @brief Negation: accept only if this filter rejects. CollisionFilter operator!() const { return CollisionFilter( - [f = m_fn](int vi, int vj) { return !f(vi, vj); }); + [f = m_fn](size_t vi, size_t vj) { return !f(vi, vj); }); } /// @brief Compound union assignment. @@ -124,6 +130,18 @@ inline CollisionFilter make_static_obstacle_filter(size_t n_dynamic) }); } +/// @brief Create a filter that allows only mixed codimensional pairs — +/// one codimensional vertex and one non-codimensional vertex. +/// Rejects c-vertex/c-vertex and non-c/non-c pairs. +/// @param n_codim_vertices Number of codimensional vertices; they occupy +/// indices [0, nCV). +inline CollisionFilter make_codim_cross_filter(size_t n_codim_vertices) +{ + return CollisionFilter([n_codim_vertices](size_t vi, size_t vj) { + return (vi < n_codim_vertices) ^ (vj < n_codim_vertices); + }); +} + /// @brief Create a filter that prevents self-collisions within a connected /// component of the face mesh. Two vertices in the same connected /// component are blocked; cross-component pairs are allowed. From bd8b5635621946fc0b951ac1e3be9f7bcdf8e989 Mon Sep 17 00:00:00 2001 From: Zachary Ferguson Date: Tue, 12 May 2026 11:21:08 -0400 Subject: [PATCH 6/6] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docs/source/tutorials/faq.rst | 2 +- python/src/collision_mesh.cpp | 7 ++++++- tests/src/tests/test_collision_filter.cpp | 1 + 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/source/tutorials/faq.rst b/docs/source/tutorials/faq.rst index 9840fa479..fa28ebe59 100644 --- a/docs/source/tutorials/faq.rst +++ b/docs/source/tutorials/faq.rst @@ -59,7 +59,7 @@ This means the filter acts on the one-ring of a vertex rather than a single prim virtual bool can_edge_face_collide(size_t ei, size_t fi) const; virtual bool can_faces_collide(size_t fai, size_t fbi) const; -:cpp:`CollisionFilter` wraps any :cpp:`bool(size_t, size_t)` callable and supports logical composition via ``|`` (union), ``&`` (intersection), and ``!``/``~`` (negation). +:cpp:`CollisionFilter` wraps any :cpp:`bool(size_t, size_t)` callable and supports logical composition via ``|`` (union) and ``&`` (intersection). Negation uses ``!`` in C++ and ``~`` in Python. The available factory functions are: diff --git a/python/src/collision_mesh.cpp b/python/src/collision_mesh.cpp index ad5c12892..e0941e2ff 100644 --- a/python/src/collision_mesh.cpp +++ b/python/src/collision_mesh.cpp @@ -63,10 +63,14 @@ void define_collision_mesh(py::module_& m) .def( py::init([](const py::function& fn) { return CollisionFilter([fn](size_t i, size_t j) -> bool { + py::gil_scoped_acquire gil; return py::cast(fn(i, j)); }); }), - "Construct from a Python callable ``bool(int, int)``.", "fn"_a) + "Construct from a Python callable ``bool(int, int)``. " + "Python-backed filters acquire the GIL on each call and may not " + "be safe or performant for parallel broad-phase use.", + "fn"_a) .def( "__call__", &CollisionFilter::operator(), "Test whether two vertices may collide.", "vi"_a, "vj"_a) @@ -499,6 +503,7 @@ void define_collision_mesh(py::module_& m) "Using a custom Python function for can_collide is deprecated. Please use a CollisionFilter object."); self.can_collide = CollisionFilter([can_collide](size_t i, size_t j) { + py::gil_scoped_acquire gil; return py::cast(can_collide(i, j)); }); } else { diff --git a/tests/src/tests/test_collision_filter.cpp b/tests/src/tests/test_collision_filter.cpp index 83cff23f9..d2b2bbe26 100644 --- a/tests/src/tests/test_collision_filter.cpp +++ b/tests/src/tests/test_collision_filter.cpp @@ -3,6 +3,7 @@ #include #include +#include using namespace ipc;