Mesh Package

The mesh package holds everything about triangle meshes that is not the scene-graph object. The node class itself is luxar.core.Mesh (see Core Package); this package is its toolbox, mirroring how luxar.gsplats sits beside luxar.core.GSplats.

Mesh is the only shaded geometry type — Points, Lines and GSplats are purely emissive — and the only one with no per-element size, because a triangle’s extent comes from its own vertices.

Mesh-specific tooling that sits beside the Mesh node type.

Mirrors luxar.gsplats: the node class itself lives in luxar.core.mesh, while everything about meshes that is not the scene-graph object lives here. Today that is luxar.mesh.interop, which imports classical mesh files; luxar.mesh.split, the by-face re-indexing behind add_mesh(partition=…); luxar.mesh.decimate, which produces substitutive mesh LOD levels; and luxar.mesh.primitives, analytic shapes (the welded icosphere) for demos and marker geometry.

Deliberately does NOT re-export Mesh. luxar.mesh and luxar.core.mesh are distinct modules, and pulling the node class up here would make this package import luxar.core — an edge that buys nothing and risks a cycle, since luxar.core already reaches a great deal of the package.

Everything in luxar.mesh’s __all__ is re-exported from luxar.mesh.interop, luxar.mesh.decimate or luxar.mesh.primitives, so the members are documented once, under their home module below, rather than twice under two names.

Importing Classical Mesh Files

Reads PLY, OBJ, STL, VTP and glTF/GLB into a common TriangleMesh, including indexed directories of per-timepoint files.

Classical mesh-format interchange (PLY / OBJ / STL / VTP / glTF → Luxar).

Everything here is NumPy + stdlib — no new dependency, matching luxar.gsplats.interop, so luxar mesh import works on a bare pip install luxar. The VTK XML reader holds to the same bar: xml.etree + base64 + zlib, all standard library.

class luxar.mesh.interop.TriangleMesh(vertices: NDArray[float32], faces: NDArray[uint32], normals: NDArray[float32] | None = None, colors: NDArray[uint8] | None = None, source_format: str = '', dimension_names: tuple[str, ...] = ('x', 'y', 'z'))[source]

Bases: object

A decoded triangle mesh in its source coordinate frame.

Every reader returns this, welded and triangulated, so the CLI and the scene embed have exactly one shape to handle.

vertices: NDArray[float32]

(V, D) float32 vertex positions. Single-file imports are always 3D; directory imports append discrete time and optional channel coordinates.

faces: NDArray[uint32]

(F, 3) uint32 triangle indices into vertices.

normals: NDArray[float32] | None = None

(V, 3) float32 unit normals, or None.

colors: NDArray[uint8] | None = None

(V, 3) or (V, 4) uint8 per-vertex colour, or None.

source_format: str = ''

One of MESH_FORMATS, or "mixed" for a mixed-format directory.

dimension_names: tuple[str, ...] = ('x', 'y', 'z')

Names of the coordinate columns in vertices.

property n_vertices: int
property n_faces: int
__init__(vertices: NDArray[float32], faces: NDArray[uint32], normals: NDArray[float32] | None = None, colors: NDArray[uint8] | None = None, source_format: str = '', dimension_names: tuple[str, ...] = ('x', 'y', 'z')) → None
luxar.mesh.interop.compile_index_regex(index_regex: str) → Pattern[str][source]

Compile a filename index regex, raising ValueError if it is malformed or has no named t capture.

luxar.mesh.interop.detect_mesh_format(path: str | Path) → str[source]

Detect which classical mesh dialect path holds.

Extension-first, because the extensions map to distinct formats and only two are genuinely ambiguous.

.ply is shared with the Gaussian-splat importer. A splat PLY has scale_0 / rot_0 / opacity on its vertex element and no face element; a mesh PLY has the reverse. Recognising the wrong one and saying so beats a parse error thirty lines deeper.

.vtp shares its <VTKFile> container with every other VTK XML dataset, so the extension is confirmed against the root’s type=. A .vtu (UnstructuredGrid) renamed — or simply handed over by mistake — is a volume mesh, not a surface, and is named as such rather than failing inside the PolyData parser.

luxar.mesh.interop.import_mesh(path: str | Path, *, format: str = 'auto', weld: bool = True) → TriangleMesh[source]

Read a classical mesh file into a TriangleMesh.

Parameters:
  • path – The file. .ply / .obj / .stl / .vtp / .gltf / .glb.

  • format – Source dialect, or "auto" to sniff.

  • weld – Merge duplicate vertex positions, remove vertices no surviving triangle references, and reindex. On by default because an unwelded surface (always, for STL; often, for glTF without indices) has no shared vertices, which defeats per-vertex normals, trips the writer’s authoring lint, and gives picking a different vertex ordinal for the same corner depending on which triangle was hit. Pass False to keep the vertex list the reader produced. That is not always the file’s own list: an OBJ that indexes normals independently of positions has no per-vertex normal array to begin with, so the reader splits vertices per distinct (position, normal) pair and welding is what merges them back.

Raises:
luxar.mesh.interop.import_mesh_directory(path: str | Path, *, pattern: str = '*.vtp', index_regex: str | None = None, format: str = 'auto', weld: bool = True, progress: Callable[[int, int, Path], None] | None = None) → TriangleMesh[source]

Read per-timepoint mesh files into one nD triangle mesh.

By default, filenames must carry T<number> and may all carry Ch<number>. index_regex overrides that parsing with a regex containing a required named t capture and optional named c capture. Numeric values are preserved as coordinates, so a missing timepoint remains a gap rather than shifting later data. Files are ordered by (time, channel) and faces are rebased into the concatenated vertex array. progress, when provided, is called before each file with (one_based_index, total_files, path).

Decimation (substitutive LOD)

Produces the genuinely coarser surfaces a kind=lod group needs. A surface cannot be coarsened by dropping elements the way points, lines and gsplats can — dropping triangles punches holes — so decimation is what makes a substitutive mesh ladder possible at all.

Two methods are offered: cluster (vectorized vertex-grid collapse, O(V log V), usable at the writer’s 227-vertex cap) and qem (quadric error metrics, higher quality at small vertex counts). auto resolves to qem through 10,000 vertices and cluster above that.

Mesh decimation — the producer a substitutive LOD ladder needs.

A kind=lod group selects ONE child at a time by coverage_fraction, so each level must be an independently renderable stand-in for the finer one. For points, lines and gsplats the coarse level is a subset or merge of independent elements. A surface has no such freedom: dropping triangles punches holes, and dropping vertices without repairing the faces that index them corrupts the topology outright. Decimation is the operation that produces a genuinely coarser SURFACE, and its absence — not any structural objection — is the only reason mesh had no substitutive ladder (docs/specs/MESH_NODE_SPEC.md §9).

The dispatcher offers cluster and qem. cluster is the vectorized implementation in this module: snap vertices to a grid, collapse each occupied cell to its centroid, reindex the faces, drop the triangles that collapsed to a line. It is O(V log V) per pass and fully vectorized, which matters because the writer’s cap is 2**27 vertices and a Python edge-collapse loop is unusable at that scale. It has no quality-driven failure mode — no seeding, no convergence criterion, nothing to diverge — and refuses only input that is not a surface at all (no faces, or every vertex coincident).

The grid spacing is found by bisection, so the cost is several passes rather than one. Measured ~0.2 MV/s per pass, with the bracket-convergence exit keeping it to a handful of passes rather than the full iteration budget.

The clustering representative is the plain centroid; a Garland-Heckbert quadric placement inside each grid cell was tried and removed. The theory says the quadric minimizer preserves creases a centroid rounds off, and the algebra does work — three orthogonal planes solve to their exact corner. It moved vertices (up to 0.067 on a unit sphere, most of them by something) but improved no measurable quality: same vertex counts, same face counts, same topology, different positions that were not better positions. On a closed sphere the centroid was marginally BETTER (mean radial error 0.00659 against 0.00667); on a sharp wedge the two agreed to five decimals, because a crease is two planes, giving a rank-2 system that is singular and falls back to the centroid anyway; on a cube the two were bit-identical, because a cube’s corner is a lone vertex in its cell and the centroid already lands on it exactly. Rank-3 cells, the only case where the quadric can differ, are rare enough on real sampled surfaces that nothing observable changed. It is recorded here so the next person does not re-derive it: the win is real in the literature and absent at this grid resolution, where the cell is already smaller than the features being preserved.

Clustering does not preserve manifoldness, and cannot. When a cell swallows two sheets that were separate on the fine surface, their triangles land on the same representatives and the result has edges shared by more than two faces. Measured on a closed unit sphere (4098 vertices, boundary-free, chi=2), the coarse levels come back chi=2 with 0 boundary edges at most cell sizes but hit 120 boundary and 60 non-manifold edges at one. That is a property of the algorithm rather than a defect here, it renders correctly either way (a non-manifold edge is still just triangles), and it is the concrete reason to keep an edge-collapse tier around rather than a vague appeal to quality: edge collapse can refuse a collapse that would break the link condition, and clustering has no such veto to exercise. That argument stands independently of the quadric note above — it is about topology, not placement.

Related: dropping the DUPLICATE triangles clustering produces is not cosmetic. With them the sphere above measures chi=86 with 96 non-manifold edges; without them, chi=2 with 60. The duplicates visually seal the degenerate region while corrupting the topology underneath it, and two co-planar opaque triangles z-fight.

class luxar.mesh.decimate.DecimatedMesh(vertices: NDArray[np.float32], faces: NDArray[np.uint32], normals: NDArray[np.float32] | None, colors: NDArray[Any] | None, scalars: Any = None, geometric_error: float = 0.0)[source]

Bases: NamedTuple

One coarser level: a complete, independently renderable surface.

vertices: NDArray[float32]

Alias for field number 0

faces: NDArray[uint32]

Alias for field number 1

normals: NDArray[float32] | None

Alias for field number 2

colors: NDArray[Any] | None

Alias for field number 3

scalars: Any

Alias for field number 4

geometric_error: float

Alias for field number 5

luxar.mesh.decimate.decimate(vertices: NDArray[float32], faces: NDArray[uint32], *, target_vertices: int, method: str = 'auto', **kwargs: Any) → DecimatedMesh[source]

Dispatch to the selected mesh decimator.

luxar.mesh.decimate.decimate_cluster(vertices: NDArray[float32], faces: NDArray[uint32], *, target_vertices: int, normals: NDArray[float32] | None = None, normal_dims: tuple[int, ...] | None = None, colors: NDArray[Any] | None = None, scalars: Any = None, spatial_dims: tuple[int, ...] | None = None, attribute_weight: float = 0.0, max_iterations: int = 24) → DecimatedMesh[source]

Reduce vertices toward target_vertices by vertex clustering.

Parameters:
  • vertices – (V, D) positions, D >= 2.

  • faces – (F, 3) indices into vertices.

  • target_vertices – Desired vertex count of the result. Approximate — the grid cannot hit an arbitrary count exactly, so the search stops at the coarsest spacing that still leaves at least this many vertices.

  • normals – Optional (V, 3). Recomputed from the coarse geometry rather than averaged: an averaged normal describes the FINE surface and would light the coarse one wrongly at exactly the creases clustering moved.

  • normal_dims – The three dimension indices normals describes. Required with normals, and NOT interchangeable with spatial_dims — the two answer different questions. spatial_dims is which axes the grid merges over, and may be any number of them; normal_dims is the 3-axis frame a normal vector lives in. Deriving one from the other silently produced garbage: a 2-dim coarsening made np.cross return scalars (which then broke the accumulation), and a 4-dim one made it raise.

  • colors – Optional (V, C) per-vertex colours — uint8, uint16 or float32 are all valid mesh colours — averaged within each cluster. The input dtype round-trips: integers are rounded and clipped to their own range, floats are left unclipped (an HDR colour exceeds 1.0 legitimately).

  • scalars – Optional per-vertex (V,) (or (V, 1)) values, averaged within each cluster like colors but NOT re-quantized: an integral input comes back float32, which is what write_scalars stores anyway, so a 0/1 field keeps its fractional cluster means instead of being hard-classified on the coarse levels only. A coarse level that dropped scalars entirely would carry the caller’s colormap with nothing to map, so it would render unmapped while the finest level is mapped — a visible pop at every LOD switch. A non-array (uniform broadcast) value is passed through untouched: it applies to every vertex, so there is nothing to merge.

  • spatial_dims – Which columns are spatial. Defaults to the first min(3, D).

  • max_iterations – Bisection budget for the cell-size search.

Returns:

A DecimatedMesh with at least one triangle. The search prefers the coarsest spacing that still leaves target_vertices, and falls back to the least-reduced level it can build rather than returning something emptier.

Raises:

ValueError – If inputs are malformed, target_vertices < 4, or the input surface is degenerate enough that no triangle survives. That last case is an error rather than an empty result on purpose: the substitutive-LOD wrapper keeps a decimated level only when its vertex count strictly exceeds the previous one’s, so an empty return (count 0) would be dropped in silence — a degenerate mesh would come out as a plain leaf with nothing naming which mesh caused it.

luxar.mesh.decimate.decimate_ladder(vertices: NDArray[float32], faces: NDArray[uint32], *, target_vertices: Sequence[int], method: str = 'auto', **kwargs: Any) → list[DecimatedMesh][source]

Dispatch a target sequence, sharing one collapse sequence for QEM.

luxar.mesh.decimate.resolve_decimation_method(method: str, n_vertices: int, *, spatial_ndim: int | None = None, announce: bool = True) → Literal['cluster', 'qem'][source]

Resolve auto once from source size and QEM’s dimensional envelope.

luxar.mesh.qem.decimate_qem(vertices: NDArray[float32], faces: NDArray[uint32], *, target_vertices: int, normals: NDArray[float32] | None = None, normal_dims: tuple[int, ...] | None = None, colors: NDArray[Any] | None = None, scalars: Any = None, spatial_dims: tuple[int, ...] | None = None, attribute_weight: float = 0.0) → DecimatedMesh[source]

Reduce a mesh by quadric edge collapse without violating the link condition.

Parameters:
  • vertices – (N, D) vertex coordinates.

  • faces – (F, 3) triangle indices.

  • target_vertices – Approximate maximum vertex count for the result. On an open near-planar surface the orientation veto can stop well above this target, which may remove a requested coarse ladder level; use cluster when closely hitting the count matters more than topology preservation.

  • normals – Optional per-vertex normals.

  • normal_dims – Three coordinate columns defining the normal frame.

  • colors – Optional per-vertex colours.

  • scalars – Optional per-vertex scalars or a uniform scalar value.

  • spatial_dims – Coordinate columns QEM may coarsen across. At least three are required; use decimate() for automatic clustering fallback.

Raises:

ValueError – If the inputs are invalid or the surface collapses completely.

luxar.mesh.qem.decimate_qem_ladder(vertices: NDArray[float32], faces: NDArray[uint32], *, target_vertices: Sequence[int], normals: NDArray[float32] | None = None, normal_dims: tuple[int, ...] | None = None, colors: NDArray[Any] | None = None, scalars: Any = None, spatial_dims: tuple[int, ...] | None = None, attribute_weight: float = 0.0) → list[DecimatedMesh][source]

Build several QEM levels from one collapse sequence.

Results follow target_vertices order. Each snapshot aggregates attributes from the original vertices rather than averaging an already-coarsened level.

Splitting by Faces

The by-face re-indexing behind add_mesh(partition=…). Splitting a mesh duplicates the vertices that straddle a part boundary, so duplication_factor() reports what that cost.

Split a triangle mesh into spatially disjoint parts, by face.

The mesh counterpart of the flat index-array split that Points / Lines / GSplats get from luxar.core.group.partition. Those three geometries can be partitioned by handing each part a slice of the element arrays, because their elements are independent: element i owns its own row in every attribute. A triangle does not — it owns three references into a shared vertex table, and two triangles on opposite sides of a BSP cut routinely share a vertex.

So a mesh part is not a slice. It is a re-indexing:

  • Faces are assigned whole to one part (the split is on face CENTROIDS), so no triangle is ever geometrically cut and no new geometry is invented.

  • Each part then gathers the vertices its own faces reference and renumbers those faces to index the gathered table.

  • A vertex referenced from both sides of a cut is therefore duplicated — it appears, byte-identical, in both parts’ vertex arrays.

Duplication is what makes the parts independently renderable, which is the whole point of a partition: each part must stand alone as a drawable leaf. It costs vertices (bounded by 3F in the pathological case, and in practice a few percent — only the cut surface duplicates), and it is invisible in the render because both copies carry identical position AND identical stored normal, so the shared edge stays seamless. Derivative (flat) shading is per-fragment and therefore part-agnostic, so it is seamless too.

This module is deliberately geometry-only: it returns index bookkeeping and never touches vertices, normals, colors or scalars. The caller gathers each per-vertex attribute through MeshPart.vertex_index, which keeps this function ignorant of the attribute set (and so immune to a new attribute being added without updating it).

class luxar.mesh.split.MeshPart(faces: NDArray[uint32], vertex_index: NDArray[int64], face_index: NDArray[int64])[source]

Bases: object

One part of a face-partitioned mesh: renumbered faces + a gather map.

faces: NDArray[uint32]

(Fi, 3) triangle indices into this part’s OWN gathered vertex table (i.e. into vertices[vertex_index]), not into the original vertices.

vertex_index: NDArray[int64]

(Vi,) indices into the ORIGINAL vertex array, ascending. Gather any per-vertex attribute with attr[part.vertex_index]. Values may repeat ACROSS parts (a boundary vertex) but never within one.

face_index: NDArray[int64]

(Fi,) indices into the ORIGINAL face array — which input triangles this part received. Lets a caller carry per-FACE data across the split.

__init__(faces: NDArray[uint32], vertex_index: NDArray[int64], face_index: NDArray[int64]) → None
luxar.mesh.split.face_centroids(vertices: NDArray, faces: NDArray[uint32]) → NDArray[floating][source]

Centroid of each triangle, (F, D).

The quantity the BSP splits on. A centroid is used rather than any single corner so a part’s spatial extent is symmetric about the cut — splitting on (say) the first vertex biases every part toward one corner of its own box.

Accumulated one corner column at a time rather than as vertices.astype(float64)[faces].mean(axis=1). That reading is shorter and identical in value, but its peak is a float64 copy of the WHOLE vertex table plus an (F, 3, D) float64 gather — 8VD + 24FD bytes — on exactly the large meshes partitioning exists to make manageable. This form never materializes the third axis and never widens the vertex table, so the peak is the (F, D) accumulator plus one same-shape gather.

luxar.mesh.split.split_mesh_by_faces(faces: NDArray, face_parts: Sequence[NDArray[int64]]) → List[MeshPart][source]

Re-index faces into one independently-drawable MeshPart each.

Parameters:
  • faces – (F, 3) triangle indices into a shared vertex table.

  • face_parts – One index array per part, each holding indices into faces. Must be a true partition — every face exactly once. This is what the *_bsp_partition splitters return when handed face_centroids().

Returns:

One MeshPart per entry of face_parts, in the same order.

Raises:

ValueError – If faces is not (F, 3), or face_parts is not a partition of range(F). The partition check is cheap next to the gather and catches the one bug that would otherwise be silent — dropped or double-counted triangles, which render as holes or as invisible double-drawn surfaces rather than as an error.

luxar.mesh.split.duplication_factor(parts: Sequence[MeshPart]) → float[source]

Total gathered vertices across parts divided by the distinct ones.

1.0 means the cut fell entirely between connected components (no shared vertex crossed it); larger means boundary vertices were duplicated. Reported by the writer as a diagnostic — it is the honest cost of the partition, and a surprising value (say > 1.5) usually means the cap is far too small for the mesh’s connectivity rather than that anything is wrong.

The denominator is the number of distinct SOURCE vertices the parts between them reference — which is the union of their vertex_index arrays, since every face lands in exactly one part. Deliberately not the source vertex count: a vertex no face references is dropped by the split, so dividing by the full table would report a duplication below 1.0 on a mesh that carries unused vertices (a trimmed surface, an imported table shared between objects), which reads as nonsense next to the word.

See Also