simplify
tit.scene.simplify ¶
Grid vertex clustering — how the GM surface gets under the §S3 budget.
Measured problem (plan §0, re-measured 2026-09-04 in the dev container):
sub-ernie's head mesh has 335 930 grey-matter triangles / 168 952
vertices = 6.06 MB of raw TVSC1. Decision S3 caps a single surface at
150 000 triangles and 3 MB.
Why vertex clustering and not quadric decimation. Quadric edge collapse
gives a better surface for the same triangle count, but no library already in
the SimNIBS image can perform it. Measured in the running container on
2026-09-04: trimesh 5.0.0 and meshio are installed, but
Trimesh.simplify_quadric_decimation raises
ModuleNotFoundError: No module named 'fast_simplification' (trimesh 5 has
no built-in implementation, it delegates); open3d, pymeshlab, vtk,
pyvista, pyacvd and fast_simplification are all absent. Adding one
for a form control's backdrop is exactly the dependency decision S3 exists to
refuse.
Grid clustering needs only numpy, is ~0.4 s on the GM surface, and has a
property quadric decimation does not: every output vertex is an input
vertex, unmoved. A retained vertex's world position is bit-identical to the
mesh's, so an electrode drawn at its measured coordinate and a region drawn
from a per-vertex label still line up with the surface; and the per-vertex
label array can be carried across by plain index selection instead of a
second nearest-neighbour transfer that would introduce its own error.
The geometric cost is therefore not a displacement of the vertices that
survive (that is exactly 0) but the removal of the ones that do not:
:attr:SimplifyResult.max_deviation is the largest distance from any input
vertex to the nearest surviving one — the one-sided Hausdorff distance from
the input vertex set to the output's, computed exactly, in pure numpy,
and checked against an independent cKDTree measurement on real meshes in
tests/test_scene_realdata.py.
Pure numpy: scipy is a MagicMock in the host suite, so nothing
here may reach for scipy.spatial.
SimplifyResult
dataclass
¶
SimplifyResult(vertices: ndarray, triangles: ndarray, source_index: ndarray, cell: float, max_deviation: float, rounds: int = 1)
One clustering pass.
source_index[j] is the index, in the input vertex array, of output
vertex j -- so any per-vertex attribute is carried over with
attr[result.source_index] and cannot drift out of alignment.
mean_edge_length ¶
Mean triangle-edge length in mm (each edge counted once per triangle).
Source code in tit/scene/simplify.py
cluster_simplify ¶
cluster_simplify(vertices: ndarray, triangles: ndarray, cell: float) -> SimplifyResult
Collapse every vertex in a cell-sized grid cube to one of its own.
The representative of a cluster is the member closest to the cluster's centroid -- a deterministic, position-preserving choice (ties broken by the lowest input index, so the same mesh always yields the same output and the cache fingerprint stays meaningful).
Source code in tit/scene/simplify.py
simplify_to_budget ¶
simplify_to_budget(vertices: ndarray, triangles: ndarray, max_triangles: int, max_bytes: int | None = None) -> SimplifyResult
Cluster until the surface fits the budget, or report the shortfall.
The first cell size is derived, not guessed: clustering a surface of mean
edge e with cell c leaves roughly (e/c)**2 of its triangles,
so c = e * sqrt(T / T_budget) is the one-shot estimate. Subsequent
rounds correct it by the same ratio (plus 2 %, so a round that lands
exactly on the budget still terminates).
A surface already inside the budget is returned untouched, with
cell == 0.0 and max_deviation == 0.0 -- the skin surface (77 032
triangles) takes this path on every subject measured, and a caller can
tell the two cases apart with :attr:SimplifyResult.simplified.