Skip to content

Detect pinch vertices in is_manifold, add cut_ and genus - #123

Merged
m-reuter merged 6 commits into
Deep-MI:mainfrom
m-reuter:vertex-manifold
Oct 10, 2026
Merged

m-reuter merged 6 commits into
Deep-MI:mainfrom
m-reuter:vertex-manifold

Conversation

@m-reuter

@m-reuter m-reuter commented Oct 9, 2026 •

Copy link
Copy Markdown
Member

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.
@codecov

codecov Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ 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).

Files with missing lines Patch % Lines
lapy/tria_mesh.py 95.14% 3 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #123      +/-   ##
==========================================
+ Coverage   60.64%   61.84%   +1.20%     
==========================================
  Files          16       16              
  Lines        3087     3182      +95     
  Branches      406      417      +11     
==========================================
+ Hits         1872     1968      +96     
  Misses       1046     1046              
+ Partials      169      168       -1     
Flag Coverage Δ
unittests 61.84% <95.14%> (+1.20%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Genus handling is incorrect for non-orientable manifolds, and splitting loses 2D mesh metadata.

3 open findings
What changed in this PR

Adds vertex-fan topology analysis to improve manifold detection and support mesh splitting, cutting, and genus calculation.

Changes:

  • Detects and splits pinch vertices.
  • Adds mesh cutting and genus computation.
  • Expands topology tests for manifold edge cases.
File Description
lapy/​tria_mesh.py Implements fan analysis and topology operations.
lapy/​utils/​tests/​test_tria_mesh.py Tests pinch vertices, cuts, and genus.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread lapy/tria_mesh.py
Comment thread lapy/tria_mesh.py Outdated
Comment thread lapy/utils/tests/test_tria_mesh.py Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Genus is incorrect for valid meshes containing fewer than three triangles.

0 open findings

3 resolved since last review
Previously missed (1)

In code that hasn't changed since last review

Medium severity Use actual face count when computing mesh genus

lapy/​tria_mesh.py:820

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

🧠 Review effort: Balanced

@m-reuter
m-reuter merged commit cbb70b4 into Deep-MI:main Oct 10, 2026
29 checks passed
@m-reuter
m-reuter deleted the vertex-manifold branch October 10, 2026 10:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants