You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Detect pinch vertices in is_manifold, add cut_ and genus - #123
TriaMesh.is_manifold() only checked that no edge has more than two triangles. A mesh can pass that test and still not be a manifold: at a pinch vertex the surrounding triangles fall into several fans that touch only at the vertex, like the common tip of two cones, a bow tie, or the boundary of a sublevel set cut exactly at a saddle, which becomes a figure 8. This PR makes is_manifold() check vertices too and adds tools to find and remove such vertices, plus cut_, genus and is_orientable, which build on the same fan and edge computations.
Changes
is_manifold() now also requires a single fan of triangles at every vertex. The edge test runs first, so meshes that fail it return early.
pinch_vertices() returns the vertices with more than one fan.
split_pinch_vertices_() gives every further fan its own copy of the vertex, appended at the same position, and returns the original index of each copy.
cut_(edges) cuts the mesh open along vertex pairs: vertices on the cut get one copy per side, so a closed loop becomes two boundary loops and an open path one loop around it. A path from boundary to boundary separates the two sides. Vertices away from the cut are left alone.
is_orientable() checks whether every component can be oriented consistently, independent of the current triangle orientation. A component is orientable if its orientation double cover falls apart into two components. A Moebius band or a Klein bottle is not orientable.
genus() returns the genus summed over all components from the Euler characteristic, the number of components and the number of boundary loops, so it also works for open surfaces. It raises for non-manifold and for non-orientable meshes.
euler() counts triangles by rows. It used max(t.shape), which is 3 for meshes with one or two triangles.
In-place operations that rebuild the mesh keep 2D meshes 2D: rm_free_vertices_, keep_largest_connected_component_, orient_, refine_, split_pinch_vertices_ and cut_. They share a small _reinit_ helper. Before, they returned 3D vertices with a zero third coordinate.
boundary_loops() explains in its error message and docstring that figure-8 boundaries are pinch vertices and can be split.
All of it is vectorized: fans are connected components of a sparse graph on triangle corners, and the orientation double cover is a sparse graph on two copies of the triangles, with no Python loops over vertices or triangles. is_manifold() takes about 0.1 s on a 270k triangle cortical surface.
Behaviour change
Meshes with pinch vertices used to pass is_manifold() and now fail it, and boundary_loops() rejects them with an error instead of returning a possibly wrong result. Code that relied on the edge-only meaning can call split_pinch_vertices_() first.
2D meshes stay 2D after the in-place operations listed above.
euler() returns the correct value for meshes with one or two triangles.
Tests
New tests in lapy/utils/tests/test_tria_mesh.py cover:
two tetrahedra sharing a vertex, a bow tie, the figure-8 boundary at a torus saddle and an edge with three triangles;
the genus of closed, open and multi-component surfaces, and of meshes with one or two triangles;
orientability of a torus with mixed triangle orientation, a Moebius band and a Klein bottle, with genus() raising for the last two;
cuts along a closed loop, an open path and next to a pinch vertex;
2D meshes staying 2D through every in-place operation that rebuilds the mesh.
is_manifold now also requires a single fan of triangles at each vertex. New pinch_vertices and split_pinch_vertices_ find and separate them, and boundary_loops rejects figure-8 boundaries.
❌ Patch coverage is 95.14563% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 61.84%. Comparing base (eb0e157) to head (3c5325e).
self.euler() uses max(self.t.shape) as the triangle count, so valid meshes with fewer than three triangles get the wrong characteristic. For example, a single-triangle disk has euler() == 3 and this returns genus -1 instead of 0. Compute χ from the actual face count here (or fix euler() before relying on it).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
TriaMesh.is_manifold()only checked that no edge has more than two triangles. A mesh can pass that test and still not be a manifold: at a pinch vertex the surrounding triangles fall into several fans that touch only at the vertex, like the common tip of two cones, a bow tie, or the boundary of a sublevel set cut exactly at a saddle, which becomes a figure 8. This PR makesis_manifold()check vertices too and adds tools to find and remove such vertices, pluscut_,genusandis_orientable, which build on the same fan and edge computations.Changes
is_manifold()now also requires a single fan of triangles at every vertex. The edge test runs first, so meshes that fail it return early.pinch_vertices()returns the vertices with more than one fan.split_pinch_vertices_()gives every further fan its own copy of the vertex, appended at the same position, and returns the original index of each copy.cut_(edges)cuts the mesh open along vertex pairs: vertices on the cut get one copy per side, so a closed loop becomes two boundary loops and an open path one loop around it. A path from boundary to boundary separates the two sides. Vertices away from the cut are left alone.is_orientable()checks whether every component can be oriented consistently, independent of the current triangle orientation. A component is orientable if its orientation double cover falls apart into two components. A Moebius band or a Klein bottle is not orientable.genus()returns the genus summed over all components from the Euler characteristic, the number of components and the number of boundary loops, so it also works for open surfaces. It raises for non-manifold and for non-orientable meshes.euler()counts triangles by rows. It usedmax(t.shape), which is 3 for meshes with one or two triangles.rm_free_vertices_,keep_largest_connected_component_,orient_,refine_,split_pinch_vertices_andcut_. They share a small_reinit_helper. Before, they returned 3D vertices with a zero third coordinate.boundary_loops()explains in its error message and docstring that figure-8 boundaries are pinch vertices and can be split.All of it is vectorized: fans are connected components of a sparse graph on triangle corners, and the orientation double cover is a sparse graph on two copies of the triangles, with no Python loops over vertices or triangles.
is_manifold()takes about 0.1 s on a 270k triangle cortical surface.Behaviour change
is_manifold()and now fail it, andboundary_loops()rejects them with an error instead of returning a possibly wrong result. Code that relied on the edge-only meaning can callsplit_pinch_vertices_()first.euler()returns the correct value for meshes with one or two triangles.Tests
New tests in
lapy/utils/tests/test_tria_mesh.pycover:genus()raising for the last two;