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}