From b370686ba0dff9ea1fea876e2e06b7ec642745b1 Mon Sep 17 00:00:00 2001 From: Horde Date: Mon, 10 Aug 2026 19:11:13 +0000 Subject: [PATCH] Add resolve_duplicated_faces binding + fix boundary_loop/vertex_components docs - Bind igl::resolve_duplicated_faces (core), returning (F2, J). Copies the input into a concrete matrix first since the implementation reuses the input's Derived type for internal plain objects (an Eigen::Ref won't default-construct). Docstring reflects actual Release behavior: the non-orientable case drops those faces rather than throwing (the rule-4 check is an IGL_ASSERT, compiled out in Release). Closes #277. - Fix the misleading comment on the boundary_loop wrapper (it returns the longest loop, not all loops). Related to #286. - Fix vertex_components docstring/comment: it takes a face list F and returns per-vertex component ids, not "an adjacency matrix ... and counts". Adds test_resolve_duplicated_faces covering the no-duplicate, cancel, keep-one, and non-orientable cases. Full suite: 91 passed. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/boundary_loop.cpp | 2 +- src/resolve_duplicated_faces.cpp | 45 ++++++++++++++++++++++++++++++++ src/vertex_components.cpp | 8 +++--- tests/test_all.py | 37 ++++++++++++++++++++++++++ 4 files changed, 87 insertions(+), 5 deletions(-) create mode 100644 src/resolve_duplicated_faces.cpp diff --git a/src/boundary_loop.cpp b/src/boundary_loop.cpp index d08a854c..25873ed8 100644 --- a/src/boundary_loop.cpp +++ b/src/boundary_loop.cpp @@ -16,7 +16,7 @@ namespace pyigl igl::boundary_loop(F, loops); return loops; } - // Wrapper for boundary_loop that returns all loops as a vector of vectors + // Wrapper for boundary_loop that returns only the longest loop as a vector auto boundary_loop(const nb::DRef &F) { Eigen::VectorXI longest; diff --git a/src/resolve_duplicated_faces.cpp b/src/resolve_duplicated_faces.cpp new file mode 100644 index 00000000..4cca4179 --- /dev/null +++ b/src/resolve_duplicated_faces.cpp @@ -0,0 +1,45 @@ +#include "default_types.h" +#include +#include +#include +#include +#include + +namespace nb = nanobind; +using namespace nb::literals; + +namespace pyigl +{ + auto resolve_duplicated_faces( + const nb::DRef &F1) + { + // resolve_duplicated_faces reuses the input's Derived type for internal + // plain objects, so it needs a concrete matrix rather than an Eigen::Ref. + const Eigen::MatrixXI F1c = F1; + Eigen::MatrixXI F2; + Eigen::VectorXI J; + igl::resolve_duplicated_faces(F1c, F2, J); + return std::make_tuple(F2, J); + } +} + +void bind_resolve_duplicated_faces(nb::module_ &m) +{ + m.def("resolve_duplicated_faces", &pyigl::resolve_duplicated_faces, "F1"_a, + R"(Resolve duplicated faces according to the following rules per unique face: + +1. If the number of positively oriented faces equals the number of negatively + oriented faces, remove all duplicated faces at this triangle. +2. If the number of positively oriented faces equals the number of negatively + oriented faces plus 1, keep one of the positively oriented faces. +3. If the number of positively oriented faces equals the number of negatively + oriented faces minus 1, keep one of the negatively oriented faces. +4. If the number of positively oriented faces differs from the number of + negatively oriented faces by more than 1, the mesh is not orientable at that + triangle and all of its copies are dropped. + +@param[in] F1 #F1 by 3 array of input faces +@return Tuple (F2, J) where + F2 #F2 by 3 array of output faces without duplicated faces + J #F2 list of indices into F1)"); +} diff --git a/src/vertex_components.cpp b/src/vertex_components.cpp index 40be01c5..850aae9b 100644 --- a/src/vertex_components.cpp +++ b/src/vertex_components.cpp @@ -21,13 +21,13 @@ namespace pyigl // Bind the wrappers to the Python module void bind_vertex_components(nb::module_ &m) { - // Binding for vertex_components with adjacency matrix and counts + // Binding for vertex_components from a mesh's face list m.def( "vertex_components", &pyigl::vertex_components, "F"_a, - R"(Compute the connected components of a graph using an adjacency matrix, returning component IDs and counts. + R"(Compute the connected component ids of each vertex of a mesh given its faces. -@param[in] F Matrix of triangle indices -@return Vector C of component IDs per vertex)"); +@param[in] F #F by 3 matrix of triangle (face) indices +@return Vector C of per-vertex connected-component ids)"); } diff --git a/tests/test_all.py b/tests/test_all.py index 96f7533a..370c7431 100644 --- a/tests/test_all.py +++ b/tests/test_all.py @@ -1754,3 +1754,40 @@ def test_predicates_spline_winding_number(): assert W.shape == (2,) assert np.isclose(abs(W[0]), 1.0, atol=1e-9) assert np.isclose(W[1], 0.0, atol=1e-9) + + +# -------------------------------------------------------------------------- +# resolve_duplicated_faces +# -------------------------------------------------------------------------- + +def test_resolve_duplicated_faces(): + # A mesh with no duplicated faces is returned unchanged. + F = np.array([[0, 1, 2], [2, 3, 0]], dtype=np.int64) + F2, J = igl.resolve_duplicated_faces(F) + assert np.array_equal(F2, F) + assert list(J.ravel()) == [0, 1] + + # A pair of oppositely-oriented copies of the same face cancels out, while a + # distinct unique face is kept (rule 1). + F = np.array([[0, 1, 2], # unique face, kept + [2, 1, 3], # face B, + + [3, 1, 2]], # face B reversed, - -> cancels with the above + dtype=np.int64) + F2, J = igl.resolve_duplicated_faces(F) + assert set(map(tuple, F2.tolist())) == {(0, 1, 2)} + assert list(J.ravel()) == [0] + + # Two positive copies and one negative copy: keep a single positive face + # (rule 2). J indexes back into the input. + F = np.array([[0, 1, 2], [0, 1, 2], [1, 0, 2]], dtype=np.int64) + F2, J = igl.resolve_duplicated_faces(F) + assert F2.shape[0] == 1 + assert tuple(F2[0]) == (0, 1, 2) + assert F.shape[1] == F2.shape[1] + assert np.array_equal(F[J.ravel()], F2) + + # Non-orientable triangle (three identical copies, |pos-neg| > 1): all of its + # copies are dropped, unrelated faces survive (rule 4). + F = np.array([[0, 1, 2], [0, 1, 2], [0, 1, 2], [3, 4, 5]], dtype=np.int64) + F2, J = igl.resolve_duplicated_faces(F) + assert set(map(tuple, F2.tolist())) == {(3, 4, 5)}