Luxar CLI Reference

This is the authoritative catalog of every public luxar command. It lists each command group and links out to the workflow guide that covers it, so option lists live in exactly one place and cannot drift.

For the exhaustive, always-current list of options for any command, run the tool itself:

luxar --help                 # top-level commands
luxar gsplat --help          # a command group
luxar gsplat fit --help      # a single command and all its options

--help is the source of truth for options; this page is the source of truth for which commands exist and where to read about the workflow they belong to.

All gsplat command failures routed through the shared reporter print a concise error by default. Re-run with LUXAR_TRACEBACK=1 to include the full traceback; a few narrow validation paths — including some inside gsplat, such as the legacy-format rejection in gsplat info — still report only their plain error message. Long-running gsplat fit and gsplat batch-fit run / submit / merge commands always retain tracebacks in their unattended logs.

Staying in sync

The list of commands on this page is checked against the live Typer application by an automated test — packages/luxar/src/luxar/cli/tests/test_docs_command_coverage.py. One check walks the registered command tree, extracts every luxar ... invocation from the code blocks on this page, and compares the two sets in both directions: a newly added command must be documented here, and a documented command that was removed, renamed, or hidden must be pruned — either way the test fails. (Hidden/internal commands and groups are excluded.) A second check in the same file walks every per-command section heading and verifies each option spelling its prose names in inline code against the live command’s declared options — a heading that names a command GROUP is checked against the union of its subcommands’ options instead, since a group’s prose legitimately names its subcommands’ flags — so a stale or invented flag fails too, not just a stale command path.

Top-level commands

luxar info      # Dataset structure, dimensions, and compression statistics (--stats, --format json)
luxar optimize  # Re-chunk an existing store for streaming; values stay bit-identical
luxar restamp-lod  # Re-derive legacy LOD switch thresholds in place (attrs only)
luxar serve     # Serve a .luxar.zarr over HTTP, optionally with the viewer (--viewer, --open)
luxar viewer    # Serve the Luxar viewer, optionally with a dataset (--data)
luxar export    # Export a scene + viewer as a standalone offline folder (or --native bundle)
luxar profiles  # List the network-simulation profiles usable via --profile

luxar serve

Serve a scene directory directly with luxar serve scene.luxar.zarr --viewer, or serve a containing directory with luxar serve /path/to/scenes --viewer and choose a scene from the viewer’s dataset browser.

For a .luxar.zarr.zip, serve its containing directory rather than passing the archive itself, which fails with Data mount root must be a directory. The server advertises and honours the HTTP byte ranges the viewer uses to read members without extracting the archive. Zipped scenes are read-only. The viewer has no browser local-file or drag-and-drop opening path for any scene format.

luxar serve runs uvicorn, which speaks HTTP/1.1 only, and browsers open at most six connections per host with no multiplexing. A scene chunked for streaming therefore pays one round trip per chunk file: measured at 25 Mbps / 30 ms RTT, the 1.5 M-point example (1,548 chunks, 10.7 MB) loaded in 10.6 s from luxar serve and in 4.0 s when the same files were served over HTTP/2. The luxar info chunk-layout report warns when the mean chunk is under 32 KB; re-chunk with the hosting profile of luxar optimize, or put an HTTP/2 front (a CDN, nginx, Caddy) in front of the data server for hosting.

luxar export also requires a directory store; passing an archive fails with Invalid zarr store: Path is not a directory. Its exported preview server does not add byte-range support for archives. See the viewer guide for the direct-URL form and the full limitations.

luxar optimize

Re-chunk a store that already exists so it streams well, in one structure-preserving pass. No refit, no source volume, no GPU: only zarr chunk shapes change, and array values stay bit-identical.

luxar optimize                    # Re-chunk SOURCE into OUTPUT at the 64 KB default
luxar optimize --dry-run          # Report the plan and write nothing (omit OUTPUT)
luxar optimize --target-kb 128    # Set the chunk budget directly
luxar optimize --profile hosting  # Preset budget: hosting / local / archive
luxar optimize --verify           # Re-read the output; compare arrays and payload files
luxar optimize --overwrite        # Replace an existing OUTPUT store
luxar optimize --generic          # Allow a plain (non-Luxar) zarr store

It takes a source store and, unless --dry-run is given, a destination store — a compiled .luxar.zarr scene, a standalone .gsplats.zarr tree, or (with --generic) any zarr store at all.

Most already-generated datasets are chunked far below the 64 KB target — the bundled demo corpus averages 5.1 KB per file, with 97% of files under 16 KB — and a cold load over object storage is dominated by round trips, not bytes. Re-chunking one demo to 64 KB cut a 245 s / 9,390-request cold load to 51 s / 2,348 requests.

Pick the budget with exactly one of --target-kb, --target-bytes or --profile. The profiles are hosting (256 KB — fewest round trips over object storage), local (64 KB — the authoring default) and archive (1 MB — not for streaming; minimises file count). --dry-run reports the plan and writes nothing, so the output argument must be omitted. --verify re-reads the written store and compares every array — and every plain payload file the pass copied, such as an overlay image — byte for byte, reporting how many of each it checked. --generic allows a plain zarr store that is not a Luxar scene or a .gsplats.zarr tree.

One boundary is worth stating for --generic, because it is a silent no-op rather than an error: this pass merges rows, i.e. it only ever grows the chunk along axis 0. An array chunked on its trailing axes instead — an OME-Zarr (1, 1, Z, Y, X) level chunked (1, 1, 8, 32, 32), say — is therefore left alone and reported as rows already in one chunk, even though it may hold hundreds of small chunk files. Luxar’s own arrays are all row-chunked, so this only affects foreign stores; use a dedicated rechunker (rechunker, ome-zarr-py) for those.

The larger profiles trade partial-query bytes for full-load requests, so “object storage → hosting” is not unconditional. A Points or GSplats node is not loaded whole: the viewer turns the visible spatial-index chunks into element ranges of chunk_size atoms, and one atom-hit costs one zarr chunk whatever its size. Measured on a real store (atom 2340, uint16 (N, 3)): local fetches 4 atoms / 54.8 KB per partial hit, hosting 18 atoms / 246.8 KB (4.5x) and archive 74 atoms / 1014.6 KB (18x). Pick hosting/archive when the access pattern is “load the whole node” (a gallery still, a small scene, an archive upload); stay on local when the viewer will be slicing into a large one.

dtype, codecs, filters, fill_value, memory order, the chunk key layout, the on-disk zarr format version and every attribute except the two the pass must move — the root’s content_hash and the chunk_layout summary written beside it — are all preserved; a sharded array keeps its shard grid; and the spatial-index grid is never moved, since each new chunk is a whole multiple of its node’s chunk_size atom. Nothing is chunked smaller than it already is, so the chunk grid is a fixed point: a second run re-chunks nothing. It still rewrites the store, and it still moves the hash — the chunk_layout attr now records the counts that changed (22 → 13 becomes 13 → 13). From the third run on, both the grid and the hash are fixed: the same layout over the same values hashes the same.

The output gets a fresh content_hash and a chunk_layout root attribute, because chunk keys now cover different rows and a warm viewer cache validating on an unchanged hash would serve stale chunks. For the same reason, replacing an existing output requires --overwrite and rewriting in place is refused — republishing under a new URL prefix is the safe move. --overwrite replaces an existing zarr store or an empty directory and nothing else; a destination that contains the source (or sits inside it) is rejected outright, and so is one that is a symlink, since moving the new store into place would replace the link rather than what it points at (pass the target path instead). The whole output is staged beside the destination and moved into place last — and an existing store is renamed aside and deleted only once the replacement is in place — so an interrupted or failed run leaves no partial store and never costs you both copies. Run luxar info with its detailed-statistics flag to see a store’s chunk layout before and after.

luxar restamp-lod

Re-derive the LOD switch thresholds of a store that already exists, in place. An attributes-only pass: the ladder rewrite moves no chunk data and opens no array.

luxar restamp-lod                          # Re-derive every legacy ladder in STORE
luxar restamp-lod --dry-run                # Report the old→new ladders; write nothing
luxar restamp-lod --group tiled/part_0     # Restrict to one ladder (repeatable)
luxar restamp-lod --group /                # The store ROOT's own ladder
luxar restamp-lod --anchor 0.25            # Re-anchor whole-object screen-area ladders

Every kind=lod group carries per-child coverage_fraction thresholds plus a group-level selector naming the units they are in. Ladders written before the screen-area metric existed sit on the legacy coverage diagonal one (or carry no selector at all, which means the same). This command re-derives those thresholds by screen-occupancy halving — the whole-object anchor for a plain ladder, the fills-screen anchor for a tile-bound one — and stamps the group screen-area. A group already on screen-area is skipped by default, so a second run changes nothing at all, down to the content_hash. --anchor sets the requested finest-level screen-area fraction for every whole-object ladder the pass processes, both legacy ladders being migrated and groups already on screen-area; partition-bound ladders remain pinned to fills-screen 1.0. Repeating the same anchor is still a no-op. For a whole-object ladder, --anchor a corresponds to viewer ?lodBias=0.5/a; setting both compounds the effect. The equivalence does not extend to overview / adaptive partition ladders: viewer bias scales them too, while --anchor deliberately leaves them at 1.0.

Tile-bound is the tree writers’ own two-clause rule, so a restamped store matches a freshly written one: a ladder is tile-anchored when a REAL multi-part kind=partition encloses it (the tiles and adaptive per-tile ladders), or when one of its own ladder children is itself a kind=partition — the overview recipe’s [coarse cap, fine partition] pair, which is pinned at the fills-screen anchor deliberately so the opening framing shows the coarse cap instead of loading the whole dataset. A one-part partition is not a tiling (its single part IS the whole object) and does not bind.

It is an explicit opt-in, and it may override a deliberate choice. An authored coverage_fractions=[...] list and a derived ladder are indistinguishable on disk, including a hand-authored ladder already stamped screen-area. That is exactly why nothing does this automatically and why the compiler’s one-part-partition check only ever warns. The per-group old→new ladder and the anchor used are printed for that reason — run --dry-run first, and use --group (repeatable; an unmatched path is an error, not a silent no-op) to restrict the pass to the ladders you meant. Group paths are spelled as the store spells them (tiled/part_0), and the store root is / — the only way to name the ladder of a .gsplats.zarr whose root IS the kind=lod group.

Sibling of luxar optimize, not a flag on it: that pass preserves every attribute and refuses same-path work, this one changes only attributes and works in place. Give it an uncompressed .luxar.zarr or .gsplats.zarr directory — a .zarr.zip is refused, since an archive is read through a temp directory and there is nothing to write back to.

When anything changes, the store’s content_hash is restamped and the metadata re-consolidated, in that order: an attrs-only edit must still invalidate a warm viewer cache. That restamp is the one step that is not instant. A compiled scene’s content_hash is a digest of array VALUES, so restamping it reads every array in the store exactly once — linear in the store’s total size, so expect minutes on a very large scene even though only two attributes changed. A standalone .gsplats.zarr takes the other branch, a metadata-only stamp, and stays instant. A --dry-run, and a run with nothing to change, read nothing at all. The result is then read back — from both the per-node documents and the consolidated index the viewer fetches — and verified. An index is rebuilt, never introduced: a store handed over without consolidated metadata leaves without it, because is_consolidated is how batch-fit tells a finished tile from an interrupted one and creating one here would report an unfinished tile as complete.

If a write fails part-way, every attribute already rewritten is restored — including each content_hash, put back exactly as the store carried it rather than recomputed, so a legacy or hand-edited digest is not silently rewritten by a run that failed — and the error is reported. The metadata is re-consolidated only when the failed run had rewritten the store ROOT, the write that invalidates the index; a failure that never touched it leaves the valid index alone rather than risking a second one. The store is left as it was found rather than carrying a half-migrated ladder whose thresholds and selector disagree about their units — a disagreement nothing downstream can detect.

The command exits 1 if any ladder was left alone for a reason worth acting on: a selector outside the vocabulary (migrate the store with luxar gsplat migrate-format first), a finest level whose element count the store does not record, a stored ladder that descends in the resolved coarsest→finest child order (its thresholds and its child order disagree, so re-deriving would invert it), a child group that carries a coverage_fraction but cannot be classified as a ladder level (re-deriving over the rest would leave a partial, non-monotonic ladder), or a re-verification residual. Ladders that were restamped are still written in that case — nothing is silently ignored, and nothing is silently half-done.

It exits 1 for one more case, where the rewrite fully succeeded: a store that carries neither a scene type nor a .gsplats.zarr content_hash has no digest to restamp, so nothing tells a warm viewer cache that the ladder moved. At zarr format 2 — the legacy corpus this command exists for — even the viewer’s zattrs-hash fallback digests the root .zattrs bytes, which an edit to a child ladder does not touch, so a warm cache would serve the old ladder indefinitely. The run says so with a ⚠️ and exits non-zero; republish under a new URL prefix.

luxar demo

Browse, run, and manage the bundled demos.

Most of these are also published as live, interactive scenes at demos.luxarviewer.dev, so you can see what a demo looks like before spending the time to build it — several need a GPU or a large download.

luxar demo list          # List all demos (key, needs, status)
luxar demo info          # Full details for one demo
luxar demo run           # Generate a demo and open it in the viewer (forwards -- args)
luxar demo run-all       # Generate every demo (batch)
luxar demo stop          # Stop running demos and free their ports (--dry-run to list)
luxar demo deps          # Optional dependencies (--install, --extra, --only MODULE)
luxar demo cache list    # Inventory the ~/.cache/luxar demo caches
luxar demo cache clear   # Clear demo caches (--dry-run to preview)

Report-only demo deps exits 1 for any missing or outdated row. Generic --install manages Luxar extras; use --only MODULE --install to install one exact constrained requirement, including a dependency outside every extra.

demo stop clears demos left running in forgotten terminals — the usual cause of a “port busy” warning and a browser tab that still shows an older scene. It finds runs via the registry demo run maintains (plus a process-table sweep for strays), lists them, asks for confirmation (-y to skip), and tears each one down with the same SIGINT → SIGTERM → SIGKILL escalation Ctrl-C uses. luxar demo stop <key> stops just one demo; --dry-run only lists. On platforms without POSIX process groups it lists the recorded runs and prints the command to stop each by hand rather than signalling a pid it cannot first verify still belongs to the demo; once that process is gone the record drops itself from the next listing.

luxar gsplat

The Gaussian-splat toolbox. The canonical end-to-end pipeline is cal → fit → lod (then convert to embed the result in a web-viewer scene); see the format and LOD notes in Formats & migration and the full data-format spec in GSplats zarr format.

Fitting & calibration

luxar gsplat cal        # Calibrate the splat count K via blind-spot cross-validation (K*)
luxar gsplat fit        # Fit Gaussian splats to a volume (presets, tiling, per-part LOD recipes)
luxar gsplat render     # Render a fitted dataset back to a volume for comparison
luxar gsplat denoise    # Denoise a volume with Non-Local Means (auto-calibrates h)
luxar gsplat benchmark  # Benchmark GPU performance to pick optimal tile sizes

Level-of-detail

luxar gsplat lod        # Build a representation topology (--recipe flat|stream|levels|tiles|overview|adaptive)
luxar gsplat additive   # Give every leaf of an existing tree an additive (streaming) ladder
luxar gsplat flatten    # Collapse any tree (leaf/lod/partition/nested) into one flat leaf

Scene & interchange

luxar gsplat convert         # Convert a .gsplats.zarr into a Luxar scene for the web viewer
luxar gsplat import          # Import a classical splat file (INRIA PLY / .splat / .spz) → .gsplats.zarr
luxar gsplat export          # Export a .gsplats.zarr → classical INRIA PLY
luxar gsplat migrate-format  # Upgrade a legacy .gsplats.zarr layout to the current v3.4 format
luxar gsplat reencode        # Re-quantize a current-format dataset's Cholesky encoding (structure-preserving)

Format versioning and the migrate-format vs reencode distinction are explained in Formats & migration.

Inspection

luxar gsplat info              # Dataset statistics (splat count, dimensions, bounds, LOD structure)
luxar gsplat doctor           # Diagnose standalone gsplats or scene partitions (--fix repairs in place)
luxar gsplat napari           # Open a dataset in napari for visual inspection
luxar gsplat view             # Open a .gsplats.zarr directly in the web viewer
luxar gsplat compare          # Compare reconstruction quality vs a reference volume (PSNR/SSIM/MSE)
luxar gsplat annotate-quality # Retrofit Q·e quality stamps onto an existing dataset, in place

doctor is for the problems you cannot see: a dataset written by an older Luxar loads and renders fine while missing something a later version learned to record, or carrying metadata that went stale under an edit. Given a standalone gsplat path it prints the info report (suppress with --no-info); for a scene it notes that the gsplat report does not apply. It then prints a diagnosis and exits non-zero while a problem is still standing — so it can gate a pipeline. Pass --fix to repair an uncompressed .gsplats.zarr or .luxar.zarr directory in place (unpack a .zip or .tar.gz first), --full-provenance to include the complete nested fitting record in the info report, --histograms --bins N to include and re-bin the report’s distributions, or --json to write the findings out for a machine.

It currently diagnoses a standalone gsplat store whose format version the current reader cannot open, with gsplat migrate-format as the remedy, and a supported-version store with an incomplete tree or inconsistent leaf-array metadata, which must be restored or regenerated. It also diagnoses a kind=partition whose split planes (bsp_tree) are missing, or are present but disagree with where the parts actually sit. Without them the viewer orders parts by centroid, which is not a valid painter’s order and pops at the seams under normal/volumetric blending; where the parts are disjoint the planes are recovered exactly from the part boxes. For overlapping uniform-tiled parts and centroid-split lines/mesh partitions, doctor uses the largest measured interpenetration on each axis as an overlap-tolerance floor, but still catches planes outside those bands. Disjoint points and splats must still separate exactly, even when stale cuts remain between the child centers. It can recover the band-bounded cuts, and reports a coordinate-frame scale only when repeated planes support each changed axis, or when a single plane agrees with a factor proven on another axis. What cannot be repaired is reported with a remedy rather than guessed at.

Editing & selection

luxar gsplat transform  # Apply spatial / intensity transforms (scale, rotate, translate, center)
luxar gsplat merge      # Merge datasets (concatenation, new dimension, or channel colors)
luxar gsplat cull       # Remove low-contribution splats while preserving visual quality
luxar gsplat decimate   # Reduce to a TARGET SPLAT COUNT (one flat leaf): merge or prefix
luxar gsplat filter     # Filter splats by multiple criteria (AND logic; percentile thresholds)
luxar gsplat slice      # Slice splats by coordinate ranges (numpy-style syntax)
luxar gsplat partition  # Partition into a single kind=partition file via spatial BSP

luxar gsplat batch-fit

Fit a whole nD dataset at scale — locally across GPUs (run) or on a Slurm cluster (submit). Both plan the decomposition once and stream-merge to a single kind=partition.

luxar gsplat batch-fit run       # Fit a whole timelapse locally across multiple GPUs, then merge
luxar gsplat batch-fit submit    # Plan and submit Slurm array jobs for a large dataset (--dry-run to plan only)
luxar gsplat batch-fit status    # Check the status of a batch fitting run
luxar gsplat batch-fit merge     # Merge completed tiles into a single dataset (per-part LOD via --recipe)
luxar gsplat batch-fit validate  # Validate tile integrity (--fix deletes corrupt/stale tiles)
luxar gsplat batch-fit cancel    # Cancel all Slurm jobs for a batch run

luxar env

Scene environments for material="physical" meshes (docs/guides/specs/MESH_PHYSICAL_MATERIALS_SPEC.md §3.3). A physical mesh is lit by scene.environment; with viewer_config.environment.source = "scene" the viewer captures that environment from the scene itself, so metals and glass reflect the data they sit in. luxar env moves that capture off the viewer for a published scene by storing the six captured cube faces in the store.

luxar env bake     # Serve the scene + viewer, drive a headless bake, attach the result
luxar env attach   # Attach a .env.bin container a bake produced (the manual half)

luxar env bake

luxar env bake                                    # SCENE: probe auto, 128 px faces, attach when done
luxar env bake --probe node:clusters/shell_3 --resolution 256   # SCENE: capture from a node's centre
luxar env bake --out scene.env.bin --no-attach    # SCENE: keep the container, attach later
luxar env bake --build                            # SCENE: rebuild a stale viewer dist first

Serves the store and the built viewer from one process, drives a headless browser to ?bakeEnv&probe=…&envResolution=… through the viewer’s Playwright (packages/luxar-viewer/scripts/bake-env.mjs), and — by default — attaches the captured faces. --probe is auto (the scene bounds centre), node:<path> (that node’s bounding-box centre, what a marker shell around a cluster wants) or x,y,z; --resolution is the cube face size (16–1024). --timeout bounds the wait for the load to settle; --force attaches a bake whose recorded scene digest no longer matches. Needs a development checkout (Playwright is a devDependency of the viewer package, not a Python dependency); from an installed wheel, bake on a checkout and attach the result with luxar env attach. Bake and attach require an uncompressed .zarr directory because the environment attrs cannot be updated in place inside an archive.

luxar env attach

luxar env attach            # SCENE FACES: write the container's faces into the store
luxar env attach --force    # SCENE FACES: even though the scene changed since the bake

Writes the faces as environment/faces-<digest> (uint16 IEEE half-float bits, (6, H, W, 4), one chunk per face) plus the capture header as attrs of a root-level environment/ group that carries no type and is skipped by every node walker. The scene content_hash does not change (the group is excluded from the digest), so the header’s scene_content_hash guard is exact and a visitor’s warm cache survives; attaching the same map twice writes nothing, a different bake replaces the array, and a stale bake is refused unless --force. The viewer prefers a matching baked map over viewer_config.environment.source, and ignores a stale one with a console line.

luxar mesh

Bring classical triangle-surface files into Luxar, and coarsen them. Both are NumPy + stdlib only, so they work on a bare pip install luxar with no extras.

luxar mesh import            # Import one mesh file (PLY / OBJ / STL / VTP / glTF / GLB), or a T-indexed directory
luxar mesh lod               # Build a LOD ladder for a mesh scene (levels, or a reveal)

Vertices are welded and polygons fan-triangulated on the way in, because STL is always a triangle soup and glTF sometimes is; an unwelded surface defeats per-vertex normals and makes picking report a different vertex per triangle for the same corner. Welding merges two vertices only when their position and their normals and colours agree, so a hard edge — which every modelling package authors as coincident positions with different normals — survives the import instead of being flattened. Pass --no-weld to keep the reader-produced vertex list. The default welded path also removes vertices no surviving triangle references, so non-surface points cannot inflate the scene bounds or picking ordinal range.

.vtp is VTK XML PolyData — what ParaView, VTK and PyVista write for a surface, and the usual output of a marching-cubes isosurface. Every encoding the format allows is read: ascii, inline base64 (format="binary"), and an appended section in either raw or base64 form, each optionally vtkZLibDataCompressor-compressed, with a UInt32 or UInt64 block header and either byte order. Polys are fan-triangulated and Strips triangulated with the alternate winding a strip requires; Verts and Lines carry no surface and are dropped. PointData normals and colours come across. An LZ4- or LZMA-compressed file is refused by name — those codecs are outside the standard library and this importer takes no new dependency — as is a .vtu (UnstructuredGrid), which is a volume mesh: run ParaView’s Extract Surface on it first.

Draco- and meshopt-compressed glTF is refused by name rather than decoded — run the file through gltf-transform first.

A directory import defaults to --pattern '*.vtp'. Each filename must contain an uppercase T<number> token; if every filename also contains Ch<number>, channel is appended as a second hidden discrete dimension. Numeric values are preserved, so missing timepoints remain gaps instead of renumbering later files. For a hidden dimension with multiple distinct integer values, the step is the GCD of their sorted coordinate differences; because navigation is anchored at the range minimum, aligned strided exports advance directly between populated slices. A single-valued or non-integer dimension keeps a step of 1. Mixed channel naming and duplicate time/channel coordinates are refused rather than guessed. Pass the directory and output scene as the two positional arguments, e.g. luxar mesh import 000_deconv.ome.zarr/meshes/cells cells.luxar.zarr. Use --pattern for another mesh format.

For other naming schemes, pass --index-regex with a required named t capture and an optional named c capture. The regex searches each filename stem (without the final extension) and must match exactly once. Captures must be integers exactly representable as float32 coordinates. For example:

luxar mesh import --pattern '*.ply' frames out.luxar.zarr
luxar mesh import --index-regex 'frame_(?P<t>\d+)' frames out.luxar.zarr
luxar mesh import --index-regex 't=(?P<t>\d+)-c=(?P<c>\d+)' surfaces out.luxar.zarr

The resulting mesh node has no spatial index: the viewer downloads the entire stacked directory even when it draws only one timepoint. Use this path for stacks that fit comfortably in memory, not as a streaming representation for very large timelapses.

luxar mesh lod

Builds one of two ladders, selected by --recipe:

  • --recipe levels (the default) writes a kind=lod group whose coarse children are progressively decimated copies of the surface and whose finest child is the original. The viewer shows exactly one at a time, chosen by how much of the screen the object covers.

  • --recipe reveal writes an additive ladder inside the leaf — additive_<i> subgroups holding disjoint groups of faces that the viewer concatenates as they arrive, so a partial load is a partial surface that grows rather than a coarse one.

They are separate recipes rather than composable flags because a mesh has no coarse prefix: a prefix of an arbitrary index buffer is a surface with holes, not a simpler surface. add_mesh refuses the two ladders together, so one --recipe selects, and a knob aimed at the other recipe is refused by name rather than silently dropped.

Takes an input scene and an output scene, plus --node and --overwrite, and then the knobs of the chosen recipe — -L/--levels (default 3), -K/--compression-factor (default 4 — level i targets V / K**i vertices), --subst-method, and the opt-in --attribute-weight for levels; -m/--add-method, --n-lods, --counts, --reveal-center and --spatial-dims for reveal. The output path is normalized to the canonical <stem>.luxar.zarr, so an output argument of out writes out.luxar.zarr; that normalized path is what --overwrite replaces and what the same-path guard compares against.

Unlike luxar gsplat lod this reads and writes a scene, not a standalone store — there is no standalone mesh format, so the only sink for a mesh is a .luxar.zarr. The node is optional when the scene holds exactly one mesh (what luxar mesh import produces); with several, naming one is required rather than guessed at.

--subst-method shares its name with luxar gsplat lod — on both commands it selects the substitutive, level-replacing reduction — but not its values: this one takes auto, qem, or cluster, because a mesh is decimated where a gsplat level reduces a Gaussian mixture, which a surface is not. QEM is Garland-Heckbert edge collapse with a link-condition veto, so it preserves manifold topology; clustering is the vectorized large-mesh tier. auto uses QEM through 10,000 source vertices and clustering above that measured worst-case open-surface envelope. A QEM ladder builds one collapse sequence and snapshots every level from it. On an open near-planar surface QEM’s orientation veto can stop well above the requested count and shorten the ladder; use cluster when closely hitting the count matters more than topology preservation. QEM also requires at least three coarsening dimensions; auto falls back to clustering for a one- or two-dimensional coarsening, while explicit qem is refused. The selected tier is printed with the reason.

--attribute-weight 0 is the default and keeps geometry-only decimation. A positive value makes QEM penalize merging vertices with different normalized colours/scalars; on the vectorized cluster tier it makes exact attribute tuples hard grid-cell barriers. Each level is stamped with a normalized conservative geometric-error bound, displayed separately from the GSplat/Points/Lines mixture-quality percentage as ε≤…% in the Layers panel.

The flag was called --method before August 2026, and -m was its short form. --method is gone, and -m has since been claimed by --add-method — the additive ordering it already names on gsplat lod, which is what it was reserved for. Either old spelling still exits with a pointer naming --subst-method and carrying your value, rather than silently doing something else.

The reveal knobs

-m/--add-method takes radial and nothing else. That is the restriction the whole recipe rests on: only an ordering whose every prefix is one connected patch is admitted, which is what makes a partial load a growing surface rather than lace.

--n-lods (default 4) asks for N equal-count levels; --counts gives the boundaries explicitly as cumulative face counts (--counts 500,2000,10000 → four levels of 500, 1500, 8000 and the remainder), or a streaming ladder as stream:<c>. Pass one or the other, not both.

--reveal-center and --spatial-dims are the same flags, with the same meanings and the same shared parser, as on luxar gsplat lod. The centre defaults to the mesh’s own bounding-box centre, so a surface far from the origin still grows from its middle. The order of --spatial-dims is significant: it pairs one-for-one with the centre’s coordinates, which is why it is not sorted the way a coarsen-dims barrier set is (there the order carries nothing, so sorting is free). Use it to keep a stacked time or channel column out of the distance, so shells do not expand through time.

So, given an input and an output scene: --recipe reveal --n-lods 4 for an equal-count ladder, --recipe reveal --counts 500,2000,10000 for explicit boundaries, and --recipe reveal --reveal-center 0,0 --spatial-dims 0,1 to grow the shells from a chosen point on a chosen pair of axes.

(Written as prose rather than a fenced block on purpose: test_docs_command_coverage reads every luxar … line in a fenced block as a command path, stopping at the first flag, so an example carrying positional arguments would register mesh lod in.luxar.zarr out.luxar.zarr as a command that does not exist.)

Levels that cannot reduce the surface are dropped, so a small mesh may come back with fewer than --levels; one that cannot be reduced at all comes back as a plain leaf rather than a one-child group.

The source node’s placement and appearance come across with it, as does the scene’s viewer_config (a source that set none gets an explicit tone_mapping='ACES', matching luxar mesh import). The compositing attrs — transform, nd_transform, opacity, blending_mode and the rest — land on the kind=lod wrapper group, which is the layer the viewer inherits them from; colormap lands on every child, as a LUT when the palette is not one of the builtin names. Per-vertex colours and scalars are averaged per cluster on every coarse level, and every level stamps the source field’s scalar_data_range, so the colormap maps the same value to the same colour at every level rather than only at the finest. Per-vertex labels and image labels are not carried — the reader does not surface them, so the round trip cannot see them.

The output is a brand-new scene containing ONLY the picked mesh’s ladder. Every other node in the source scene is left out — other points/lines/gsplats/mesh nodes, other groups, and any user-authored overlays (add_text/add_html/add_image) — since there is nowhere else for them to go. Also left behind: any placement/compositing (transform, opacity, blending_mode, …) an ancestor group genuinely CHANGES — only the mesh’s OWN attrs are forwarded. “Genuinely changes” is narrower than “is set at all”: a key sitting at its neutral value (opacity/gamma/intensity/absorption at 1.0, offset at 0.0) composes as a no-op regardless of which layer sets it, an identity transform/nd_transform moves nothing, a layer/visible at its own default (false/true) is likewise a no-op, blending_mode is nearest-setter-wins so it only matters for the nearest group that sets it and only when the picked mesh does not set it itself, and join is skipped outright (it is lines-only — add_mesh refuses it, so a mesh leaf can never carry it and an ancestor’s join can never affect a mesh ladder) — so re-laddering a level of an existing ladder (--node surf/child_0) or a partition tile (--node surf/part_0) reports nothing here, even though the wrapper groups those workflows nest under do carry a few of these keys at their neutral values (this command’s own ladders re-forward the picked mesh’s stamped defaults onto the wrapper it writes). After every check that can still abort the command and before anything is written, it warns about each node that will not be carried across (collapsed into one line per parent + kind when more than three share both, worded by node kind rather than as “parts” — that term is reserved for actual kind=partition children — so a large group of siblings does not flood the console), each ancestor group that does lose something (naming exactly which keys), and any per-vertex labels/image labels on the picked mesh, by name.