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:
objectA 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 intovertices.
- luxar.mesh.interop.compile_index_regex(index_regex: str) Pattern[str][source]
Compile a filename index regex, raising
ValueErrorif it is malformed or has no namedtcapture.
- luxar.mesh.interop.detect_mesh_format(path: str | Path) str[source]
Detect which classical mesh dialect
pathholds.Extension-first, because the extensions map to distinct formats and only two are genuinely ambiguous.
.plyis shared with the Gaussian-splat importer. A splat PLY hasscale_0/rot_0/opacityon its vertex element and nofaceelement; a mesh PLY has the reverse. Recognising the wrong one and saying so beats a parse error thirty lines deeper..vtpshares its<VTKFile>container with every other VTK XML dataset, so the extension is confirmed against the root’stype=. 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:
FileNotFoundError – If
pathdoes not exist.ValueError – For an unknown format, or a file that is not the dialect claimed.
- 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 carryCh<number>.index_regexoverrides that parsing with a regex containing a required namedtcapture and optional namedccapture. 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:
NamedTupleOne coarser level: a complete, independently renderable surface.
- vertices: NDArray[float32]
Alias for field number 0
- faces: NDArray[uint32]
Alias for field number 1
- 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
verticestowardtarget_verticesby vertex clustering.- Parameters:
vertices –
(V, D)positions, D >= 2.faces –
(F, 3)indices intovertices.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
normalsdescribes. Required withnormals, and NOT interchangeable withspatial_dims— the two answer different questions.spatial_dimsis which axes the grid merges over, and may be any number of them;normal_dimsis the 3-axis frame a normal vector lives in. Deriving one from the other silently produced garbage: a 2-dim coarsening madenp.crossreturn 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 likecolorsbut NOT re-quantized: an integral input comes back float32, which is whatwrite_scalarsstores 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’scolormapwith 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
DecimatedMeshwith at least one triangle. The search prefers the coarsest spacing that still leavestarget_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
autoonce 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
clusterwhen 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_verticesorder. 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:
objectOne 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. intovertices[vertex_index]), not into the original vertices.
- vertex_index: NDArray[int64]
(Vi,)indices into the ORIGINAL vertex array, ascending. Gather any per-vertex attribute withattr[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.
- 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 + 24FDbytes — 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
facesinto one independently-drawableMeshParteach.- 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_partitionsplitters return when handedface_centroids().
- Returns:
One
MeshPartper entry offace_parts, in the same order.- Raises:
ValueError – If
facesis not(F, 3), orface_partsis not a partition ofrange(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
partsdivided by the distinct ones.1.0means 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_indexarrays, 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
Shading Package — bake ambient occlusion for the emissive geometry types
Core Package — the
luxar.core.Meshnode itselfLuxar CLI Reference — the
luxar mesh import/luxar mesh lodcommands