axiolid_inspect/genus.rs
1//! Topological genus from the Euler characteristic.
2//!
3//! For a closed orientable surface, V - E + F = 2 - 2g. The formula is only
4//! meaningful on a closed two-manifold, so this refuses anything else
5//! rather than returning a number the caller would have no way to distrust.
6
7use axiolid_mesh::{component_count, EdgeAdjacency, TriMesh};
8use thiserror::Error;
9
10/// Why a genus could not be computed.
11#[derive(Debug, Clone, PartialEq, Eq, Error)]
12pub enum GenusError {
13 /// The mesh is not a closed two-manifold, so Euler's formula does not apply.
14 #[error("genus requires a closed two-manifold; found {boundary} boundary and {non_manifold} non-manifold edges")]
15 NotClosedManifold {
16 /// Edges used by exactly one triangle.
17 boundary: usize,
18 /// Edges used by more than two triangles.
19 non_manifold: usize,
20 },
21 /// The Euler characteristic was odd, so no integer genus exists.
22 #[error("Euler characteristic {characteristic} is odd; the surface is not orientable")]
23 NotOrientable {
24 /// The computed characteristic.
25 characteristic: i64,
26 },
27 /// The mesh has several components, so a single genus does not describe it.
28 ///
29 /// `chi = 2c - 2g` still holds for the whole surface, but the resulting
30 /// `g` is a total across components and is negative whenever `c > g + 1`
31 /// -- a cube with eight spherical cavities has `c = 9`, `chi = 18` and a
32 /// total genus of `-8`. Reporting that as a `u32` is impossible, and
33 /// reporting `0` would be indistinguishable from a genuine sphere, so
34 /// this refuses and hands back what it measured.
35 #[error(
36 "genus requires a single connected component; found {components} \
37 (Euler characteristic {characteristic}). Use `decompose` and take \
38 the genus of each component separately."
39 )]
40 MultipleComponents {
41 /// How many connected components the mesh has.
42 components: usize,
43 /// The characteristic of the whole surface, for callers that want it.
44 characteristic: i64,
45 },
46}
47
48/// Genus of a closed orientable triangle mesh.
49///
50/// A sphere or cube is 0, a torus 1, a double torus 2.
51///
52/// # Errors
53///
54/// Refuses a mesh with boundary or non-manifold edges: Euler's formula
55/// assumes a closed two-manifold, and applying it anyway would produce a
56/// plausible-looking integer with no meaning.
57///
58/// Refuses a mesh with more than one connected component for the same
59/// reason. `chi = 2 - 2g` is the single-component form of `chi = 2c - 2g`;
60/// applying it when `c > 1` yields a negative genus that cannot be
61/// represented, and clamping that into `0` would report a solid full of
62/// cavities as a sphere. Split with [`axiolid_mesh::decompose`] and call
63/// this per component.
64pub fn genus(mesh: &TriMesh) -> Result<u32, GenusError> {
65 let adjacency = EdgeAdjacency::build(mesh);
66 let boundary = adjacency.boundary_edges().count();
67 let non_manifold = adjacency.non_manifold_edges().count();
68 if boundary > 0 || non_manifold > 0 {
69 return Err(GenusError::NotClosedManifold {
70 boundary,
71 non_manifold,
72 });
73 }
74
75 // Only vertices actually referenced by a triangle count: an unused
76 // position is stray data, not part of the surface. `EdgeAdjacency`
77 // already applies that rule, so the two cannot disagree.
78 let characteristic = adjacency.euler_characteristic();
79
80 // The general form is chi = 2c - 2g. Everything below assumes c == 1,
81 // so a multi-component mesh must be refused BEFORE the arithmetic --
82 // otherwise `doubled` goes negative and the `u32` conversion silently
83 // clamps a cavity-filled solid to the same answer as a sphere.
84 let components = component_count(mesh);
85 if components != 1 {
86 return Err(GenusError::MultipleComponents {
87 components,
88 characteristic,
89 });
90 }
91
92 // chi = 2 - 2g, so g = (2 - chi) / 2. An odd characteristic means the
93 // input is not a closed orientable surface after all.
94 let doubled = 2 - characteristic;
95 if doubled % 2 != 0 {
96 return Err(GenusError::NotOrientable { characteristic });
97 }
98
99 // With c == 1 and chi even, `doubled / 2` is non-negative for every
100 // closed orientable surface, so this conversion cannot silently clamp.
101 // A failure here would mean one of the guards above was wrong, and
102 // saying so is better than inventing a genus.
103 u32::try_from(doubled / 2).map_err(|_| GenusError::NotOrientable { characteristic })
104}