Source code for simvx.core.mesh_lod

"""Mesh Level of Detail (LOD) system.

Provides automatic mesh decimation via vertex clustering and distance-based
LOD selection for efficient rendering of 3D scenes.

Usage::

    from simvx.core import Mesh, MeshLOD, generate_lods

    mesh = Mesh.sphere(radius=1.0, rings=32, segments=32)
    lod = generate_lods(mesh, levels=3, ratios=[1.0, 0.5, 0.25])
    level = lod.select_lod(distance=25.0)
"""


from __future__ import annotations

import logging
from dataclasses import dataclass

import numpy as np

from .graphics.mesh import Mesh

__all__ = ["LODLevel", "MeshLOD", "generate_lods"]

log = logging.getLogger(__name__)


[docs] @dataclass class LODLevel: """A single level of detail. Attributes: mesh: The mesh geometry for this LOD level. ratio: Decimation ratio (1.0 = original, 0.5 = half the triangles). max_distance: Maximum camera distance at which this level is used. ``float('inf')`` for the coarsest level (always a fallback). """ mesh: Mesh ratio: float = 1.0 max_distance: float = float("inf")
[docs] class MeshLOD: """Container for multiple LOD levels of a single mesh. Levels are stored sorted by ascending ``max_distance`` so that ``select_lod`` can do a simple linear scan. """ __slots__ = ("_levels",) def __init__(self, levels: list[LODLevel] | None = None) -> None: self._levels: list[LODLevel] = sorted(levels or [], key=lambda lv: lv.max_distance) # -- Access --------------------------------------------------------------- @property def levels(self) -> list[LODLevel]: """All LOD levels, ordered by max_distance (nearest first).""" return self._levels @property def level_count(self) -> int: return len(self._levels)
[docs] def add_level(self, level: LODLevel) -> None: """Insert a level, maintaining sorted order.""" self._levels.append(level) self._levels.sort(key=lambda lv: lv.max_distance)
# -- Selection ------------------------------------------------------------
[docs] def select_lod(self, distance: float, bias: float = 0.0) -> LODLevel: """Return the appropriate LOD level for *distance*. Args: distance: Camera-to-object distance (world units). bias: Additive bias applied to thresholds. Positive values push selection toward coarser levels (cheaper), negative toward finer (higher quality). Returns: The best ``LODLevel`` for the given distance. Raises: ValueError: If no levels are registered. """ if not self._levels: raise ValueError("MeshLOD has no levels") for level in self._levels: if distance < level.max_distance + bias: return level return self._levels[-1]
# ============================================================================ # Vertex-clustering mesh decimation # ============================================================================ def _decimate_mesh(mesh: Mesh, ratio: float) -> Mesh: """Decimate *mesh* to approximately *ratio* of its original triangle count. Uses a uniform-grid vertex clustering approach: 1. Compute the mesh bounding box. 2. Partition space into a grid whose cell count targets the desired vertex count (derived from *ratio*). 3. Merge all vertices falling into the same cell — average their positions, normals, and UVs. 4. Re-index triangles using the merged vertex IDs; discard degenerate triangles (all three corners in the same cell). """ if ratio >= 1.0: return mesh positions = mesh.positions n_verts = len(positions) target_verts = max(4, int(n_verts * ratio)) # Bounding box with small epsilon to avoid zero-size axes bb_min = positions.min(axis=0) bb_max = positions.max(axis=0) extent = bb_max - bb_min extent = np.maximum(extent, 1e-8) # Grid resolution — cube root of target vertex count gives a uniform grid # that yields roughly the desired number of non-empty cells. grid_res = max(2, int(np.cbrt(target_verts) + 0.5)) # Map each vertex to a grid cell normalised = (positions - bb_min) / extent # [0, 1] cell_coords = np.clip((normalised * grid_res).astype(np.int32), 0, grid_res - 1) cell_ids = cell_coords[:, 0] * grid_res * grid_res + cell_coords[:, 1] * grid_res + cell_coords[:, 2] # Cluster: average attributes per cell unique_cells, inverse = np.unique(cell_ids, return_inverse=True) n_clusters = len(unique_cells) new_positions = np.zeros((n_clusters, 3), dtype=np.float32) counts = np.zeros(n_clusters, dtype=np.float32) np.add.at(new_positions, inverse, positions) np.add.at(counts, inverse, 1.0) new_positions /= counts[:, None] new_normals = None if mesh.normals is not None: new_normals = np.zeros((n_clusters, 3), dtype=np.float32) np.add.at(new_normals, inverse, mesh.normals) lengths = np.linalg.norm(new_normals, axis=1, keepdims=True) np.divide(new_normals, lengths, where=lengths > 1e-10, out=new_normals) new_texcoords = None if mesh.texcoords is not None: new_texcoords = np.zeros((n_clusters, 2), dtype=np.float32) np.add.at(new_texcoords, inverse, mesh.texcoords) new_texcoords /= counts[:, None] # Re-index triangles if mesh.indices is not None: old_tris = mesh.indices.reshape(-1, 3) else: old_tris = np.arange(n_verts, dtype=np.uint32).reshape(-1, 3) new_tri_indices = inverse[old_tris] # (T, 3) mapped to cluster IDs # Remove degenerate triangles (where 2+ corners map to the same cluster) non_degenerate = ( (new_tri_indices[:, 0] != new_tri_indices[:, 1]) & (new_tri_indices[:, 1] != new_tri_indices[:, 2]) & (new_tri_indices[:, 0] != new_tri_indices[:, 2]) ) new_tri_indices = new_tri_indices[non_degenerate] new_indices = new_tri_indices.ravel().astype(np.uint32) if len(new_tri_indices) > 0 else None result = Mesh(new_positions, new_indices, new_normals, new_texcoords, topology=mesh.topology) return result
[docs] def generate_lods( mesh: Mesh, levels: int = 3, ratios: list[float] | None = None, distances: list[float] | None = None, ) -> MeshLOD: """Generate a ``MeshLOD`` from *mesh* by decimating at each ratio. Args: mesh: Source mesh (highest quality). levels: Number of LOD levels to produce. ratios: Per-level decimation ratios (descending), e.g. ``[1.0, 0.5, 0.25]``. Defaults to evenly spaced ratios from 1.0 down to ``1/levels``. distances: Per-level max-distance thresholds. Defaults to ``[10, 25, inf]`` style geometric progression. Returns: A ``MeshLOD`` containing the requested levels. """ if ratios is None: ratios = [1.0 - i * (1.0 / levels) + (1.0 / levels) for i in range(levels)] # e.g. levels=3 -> [1.0, 0.667, 0.333] ratios = [1.0 / (i + 1) * levels / levels for i in range(levels)] ratios = [max(1.0 - i / levels, 1.0 / levels) for i in range(levels)] if distances is None: # Default geometric progression: 10, 25, inf distances = [] for i in range(levels - 1): distances.append(10.0 * (2.5 ** i)) distances.append(float("inf")) if len(ratios) != levels: raise ValueError(f"Expected {levels} ratios, got {len(ratios)}") if len(distances) != levels: raise ValueError(f"Expected {levels} distances, got {len(distances)}") lod_levels: list[LODLevel] = [] for ratio, dist in zip(ratios, distances, strict=True): decimated = _decimate_mesh(mesh, ratio) lod_levels.append(LODLevel(mesh=decimated, ratio=ratio, max_distance=dist)) return MeshLOD(lod_levels)