luxar-visualization · git:20261018.1c54257 · 2026-10-18 · sha256 da41585e78f9a387
luxar-visualization git:20261018.1c54257A
Immutable. This exact content never changes and is served at /api/v1/blob/da41585e78f9a387.
---
name: luxar-visualization
description: >-
Build a Luxar visualization for a dataset. Use when a user wants to turn data
(point clouds, trajectories/lines, nD images, volumes, time series, multichannel
stacks) into a .luxar.zarr scene and view it in the web viewer. Covers the
LuxarZarrCompiler → create_scene → add_points/add_lines/add_gsplats →
serve/export flow, Dimensions, transforms, hierarchy, baked ambient occlusion
(luxar.shading) for emissive geometry that reads flat, and the demo/example
patterns that are the canonical source of know-how.
---
# Build a Luxar visualization
Luxar compiles nD scientific scenes (Points, Lines, GSplats, Mesh) to a `.luxar.zarr`
archive served to a WebGL viewer. This skill builds a scene from a dataset.
**The repo's demos and examples are the canonical know-how** — when in doubt, read a
matching one before writing code:
- `packages/luxar/src/luxar/demos/demo_*.py` (90 complete demos)
- `packages/luxar/examples/*_example.py` (56 focused examples)
## The canonical pattern (every demo follows this)
```python
import numpy as np
from luxar import LuxarZarrCompiler, Dimensions
from luxar.demos import launch_viewer # convenience: shells to `luxar serve --viewer`
output_path = "my_scene.luxar.zarr"
with LuxarZarrCompiler(output_path) as compiler: # 1. open the compiler
scene = compiler.create_scene(dimensions=Dimensions.default_3d()) # 2. dims REQUIRED
scene.add_points( # 3. add geometry nodes
"MyData",
positions, # (N, 3) float32
colors=colors, # (N, 3) float32/uint8
radii=radii, # (N,) or scalar
)
# 4. context exit finalizes the store
launch_viewer(output_path) # 5. view it
```
That is the whole shape. Build data → `LuxarZarrCompiler` → `create_scene(dimensions=)`
→ `add_points` / `add_lines` / `add_gsplats` → serve. See [references/scene-api.md](references/scene-api.md)
for every method signature, `**attrs`, transforms, and overlays.
## Pick the geometry type
| Data | Node | Required arrays |
| --- | --- | --- |
| Point cloud, particles, cells | `add_points` | `positions` (N,D) |
| Trajectories, skeletons, vectors, meshedges | `add_lines` | `vertices` (N,D) + `widths` |
| Triangle surfaces, isosurfaces | `add_mesh` | `vertices` (V,D) + `faces` (F,3); optional normals (with `normal_dims`), colors, scalars |
| Volumes / dense nD images (fitted to Gaussians) | `add_gsplats*` | centers/amplitudes/cholesky, or a fitted `.gsplats.zarr` |
For raw volumes, fit Gaussian splats first — see the **`luxar-gsplat-pipeline`**
skill, then `add_gsplats_from_file` / `add_gsplats_from_volume` here.
## Fitted volumes: axis order and the first view
Two things trip up nearly everyone who views their own fitted volume:
- **Axis order.** A fit's centre columns follow the ARRAY's axis order — `(z, y, x)`
for a ZYX stack — while `Dimensions.default_3d()` is `x, y, z`. Grafted as-is,
the stack's z lands on the scene's x and a thin z-stack opens edge-on. Map the
columns by name with `dim_order` (`demo_gsplats_3d_cells3d_multichannel.py`
passes the same `dim_order=["z", "y", "x"]` to `scene.add_gsplats`):
```python
scene = compiler.create_scene(dimensions=Dimensions.default_3d())
scene.add_gsplats_from_file("embryo", "fit.gsplats.zarr", dim_order=["z", "y", "x"])
```
`dim_order` works on a leaf / additive / `kind=lod` store. A tiled or
partitioned store (`fit --tiling uniform|content`, `batch-fit`) refuses it:
pass `flatten=True` as well (one flat leaf of the default finest selection, so
the parts are no longer culled separately), or keep the partition with
`luxar gsplat convert`, which grafts the tree whole in its stored column order.
- **The opening view.** `luxar gsplat convert` names the columns itself (the
store's recorded axis names, else array order for a fit; `--dim-order t,z,y,x`
overrides). When the y column precedes the x column (image order, as in every
fit) it opens the scene the way Fiji/napari show the stack: facing XY with y
pointing down, stored as `viewer_config.camera` with `up=(0, -1, 0)` and
`view_axis="xy"`. The flip is the camera's, so coordinates stay as fitted.
`--view xz|yz` picks another face; `?viewAxis=xy|xz|yz` overrides it for one
session. From Python, set the same thing yourself:
`ViewerConfig(camera=CameraConfig(up=(0, -1, 0), view_axis="xy"))`.
- **A dense fluorescence volume under `additive` blending saturates to a white
blob**, because every splat along a ray adds. Pick the blending for the data:
`blending_mode="max"`, or `"volumetric"` with `absorption=` for
emission–absorption depth; then set exposure with `opacity`
(see "Appearance is authored" below). The modes are `normal`, `additive`, `max`,
`opaque`, `luminous`, `volumetric`. `luxar gsplat convert` keeps the
amplitudes in source data units and auto-exposes a separate `display_gain`
(dimmed, never brightened, so the opening view is not saturated;
`--display-gain`, `--target-brightness`, `--no-auto-exposure`).
**The Python adders do NOT auto-expose** (they normalise each splat, not the
sum along a ray), so a multichannel scene built with `add_gsplats_from_file`
per channel opens white. Get convert's exposure per node with
`luxar.gsplats.exposure.auto_exposure_attrs`, passing the same `dim_order` /
`colormap` (and your `camera` / `tone_mapping` when not the defaults):
```python
from luxar.gsplats.exposure import auto_exposure_attrs
camera = CameraConfig(up=(0, -1, 0), view_axis="xy") # the scene's ViewerConfig camera
zyx = ["z", "y", "x"]
for name, path, cmap in [("membrane", "mem.gsplats.zarr", "bop_orange"),
("nuclei", "nuc.gsplats.zarr", "bop_blue")]:
scene.add_gsplats_from_file(
name, path, dim_order=zyx, colormap=cmap, layer=True,
blending_mode="additive",
**auto_exposure_attrs(path, dim_order=zyx, colormap=cmap, camera=camera),
)
```
Pass the store PATH (it reads the unit labels' stats and takes tiled fits).
Each channel is exposed on its own; where channels overlap their colours add.
**`max` is not a MIP.** It keeps the brightest SINGLE splat on each ray, and a
structure many splats share (any splat fit) reads dimmer than its voxel MIP.
On a UNIFORM TILED fit it shows a dark seam cross: the Hann-blended tiles split
each overlap's signal between their splats (Hann weights of 1/2 each at
mid-overlap), which additive sums back and `max` does not: a 20-35% dip in a
measured 3x3-tile fit (`convert` warns). For max-blend
viewing, fit with `--tiling none`.
## Dimensions = the coordinate system
`Dimensions` is the single source of truth for the scene's axes. Displayed dims are
the viewport; non-displayed dims (time, channel, z) become nD slider axes.
```python
from luxar import Dimensions, Dimension
# 3D spatial
dims = Dimensions.default_3d()
# 4D time series: t is a non-displayed, discrete slider axis
dims = Dimensions([
Dimension("t", unit="s", display=False, discrete=True, range=(0, 100), step=1),
Dimension("z", unit="um", display=True),
Dimension("y", unit="um", display=True),
Dimension("x", unit="um", display=True),
])
```
`positions` columns must match the dimension order. Use `extend_to_all=` on a node to
broadcast geometry across a non-displayed dim (e.g. show the same points at every t).
## Common recipes
```python
# Scalars -> colormap (instead of explicit colors)
scene.add_points("field", pts, scalars=values, colormap="viridis")
# Lines: polyline trajectory with tapering width
scene.add_lines("track", verts, widths=w, line_type="polyline", blending_mode="additive")
# Hierarchy: a group with a transform; children compose under it
from luxar import transforms
g = scene.add_group("left", transform=transforms.translate(x=-50))
g.add_points("nuclei", pts)
# Fitted gsplats from a volume, in one step
scene.add_gsplats_from_volume("embryo", volume, seeds=8000, n_iters=5000, device="cuda")
# On-screen label
scene.add_text("Embryo, t=0", position=(0.02, 0.02), font_size=0.05, anchor="top-left")
```
## Demo helpers (for scripts that fit/serve)
From `luxar.demos` (the barrel that re-exports these — the single spelling; never
import the owning `luxar.demos._support.*` modules directly from a demo):
- `launch_viewer(output_path, open_browser=True)` — serve via the CLI viewer.
- `parse_demo_flags()` — standard `--recompute` / `--no-serve` / `--serve-only` flags.
- `load_precomputed_gsplats(demo_name, file_names, recompute=...)` — load cached
(Git-LFS) fitted splats, refitting only when `--recompute`.
- `create_lorenz_attractor`, `create_random_spheres`, `create_time_series_demo`,
`detect_device`, `warn_if_no_cuda_gpu`.
From `luxar.utils.paths`: `get_demos_output_dir()`, `get_examples_output_dir()` for
output locations (never commit `.luxar.zarr`).
### Shipping a demo's data
Bundled demos resolve their payloads with `ensure_dataset("<key>")` against a
GENERATED manifest — never hand-edit `data_manifest.json`. The full procedure is in
[references/demo-authoring.md](references/demo-authoring.md).
## Appearance is authored, not defaulted
The Layers panel (press `L`) is where you dial a scene in — but it rewrites the
node's uniforms at every load, so whatever you tune there must be written back into
the `add_*` call or it is lost. Five things about that round-trip surprise people:
- **The display window is stored as an `intensity`/`offset` PAIR, not a gain.** For
a window `[lo, hi]`: `intensity = 1/(hi-lo)`, `offset = -lo/(hi-lo)`. Passing the
panel's max straight into `intensity=` stores a window `hi²` times too narrow and
the scene renders blown out. Read it back the same way:
`lo = -offset/intensity`, `hi = (1-offset)/intensity`.
- **A DIRECT-COLOUR node shows a COLOUR RANGE instead, and that is a plain gain.**
The panel draws `0 – 1/intensity` on a slider whose top is the authored
`1/intensity`; a reviewer dragging the top to 45.26 on a 0-128 slider means
`intensity = 1/45.26` (2.8x brighter than the authored `2**-7`). Colormapped
node: window pair. Direct colour: `intensity = 1/hi`. Same panel, two meanings.
- **`validate_intensity` caps `intensity` at 100.** A window top of 0.002 asks for
x500; fold the excess into a build-time amplitude boost (the TotalSegmentator
nervous system does `amp_boost=5.0` at `intensity=100`) and say so in a comment,
or the panel silently reads a different window than the one you meant.
- **Bloom is a global, not a per-layer knob, and additive line scenes fog under
it.** The Zebrahub streamlines shipped at strength 0.74 / radius 0.55 /
threshold 0; the reviewed values were threshold 0.01, strength 0.05, radius 1.0
over 8 mipmap levels — a faint, wide glow that keeps only the brightest cores.
- **SSAA and `allow_high_dpr` together are a 16x fragment bill.** The protein
universe demo keeps full, fixed DPR in both builds so its fine structure stays
sharp during flights, but enables SSAA only with `--high-quality`
(`demo_esm_protein_universe.py`). Check the cost on the machines visitors use.
- **Raw fitted amplitudes need a brightness factor — and the colormap window is
not it.** A fitted `.gsplats.zarr` stores amplitudes in SOURCE DATA UNITS (the
fitter multiplies its `[0,1]` working copy back out by the volume's intensity
range, so a uint16 fit carries detector counts). In the shader the stored
amplitude picks the colormap LUT index (`t = clamp((A - min) * scale, 0, 1)` —
clamped, so the window selects a *colour* and can never scale brightness), and
it sets **emitted radiance and, under `volumetric`, optical depth** (`tau =
absorption * opacity * gain * intensity`, `intensity ∝ A`). Two ways to
supply the factor:
- **`display_gain`** (what `luxar gsplat convert` / `view` do): keep the data
units and set a per-node gain the viewer multiplies into every
amplitude-linear term — unbounded, unlike `opacity` — so the Layers panel
reads real splat amplitudes plus a Brightness control. From Python:
`add_gsplats_from_data(..., normalize_amplitudes=False,
**luxar.gsplats.exposure.data_unit_display_attrs(stats, amplitudes=amps))`.
- **Normalising on insertion** (the Python adders' DEFAULT):
`add_gsplats_from_data` / `add_gsplats_from_file` / a graft scale a robust
p99.9 to 1.0 (one factor for the whole structure, a no-op on data already in
range). A child inserted directly into a `kind=lod` / `kind=partition` group
defaults to no normalisation so its exposure stays shared with its siblings.
The data-unit attrs are refused while normalising.
- **`opacity` is the exposure lever, and with normalised amplitudes it lives in
its natural range.** That is the point of normalising: against raw counts
`opacity` has to carry a ~1/500 factor on a `[0,1]` control, which is
unauthorable in the panel and is why so much older shipped appearance is a
magic tiny constant.
- **For a colormapped node, the display window is NOT an exposure lever, and
reaching for it first is the classic wrong turn.** Under `volumetric` (or any sum
projection) a pixel accumulates along the ray, while the window only picks each
element's LUT index. The symptom that tells the two apart: if widening the window
*dims the whole object toward the colormap's dark foot and shrinks its footprint*
rather than spreading it across the LUT, you are over-accumulated and want
`opacity`. A frame whose bright regions are genuinely clipped flat is the
window's problem. With explicit `colors=`, the pair is a direct color gain and
offset: a `[0, hi]` window is pure pre-gamma gain. Under additive at
`gamma=1`, it scales RGB like lowering `opacity`; under `volumetric`, `opacity`
also lowers optical depth and coverage. Any nonzero `lo` carries an offset that
shifts the authored colors. Prefer `opacity`, which the panel round-trips as
exposure rather than as a window.
- **`absorption` (volumetric blending) is optical depth and ACCUMULATES along the
ray**, so the right value depends on how deep the object is, not on how bright it
is. It is not portable between datasets: a value tuned on a 170 µm brain will
over- or under-attenuate on a different one. Retune it, and remember the pair —
raising absorption usually wants the window's max lowered with it. τ on a ray
= κ × opacity × the colour-free sum of gained amplitudes along it; auto
exposure never runs for `volumetric`, so that sum is set by the fit (thickness,
splat size in SCENE units — µm vs voxel changes it). `gsplat convert
--blending-mode volumetric` prints the predicted p99.9 of that sum and the κ for
τ≈1 there (a 10-tp zebrafish nuclei fit: 3.95 → κ≈0.25); start from it. Pair
it with a black-footed colormap (`inferno`, `magma`, `gray`):
`plasma`/`viridis` turn residual haze into blue/purple fog.
- **A stored window only means anything for that exact store.** Any refit, rescale
or re-encode moves it. Say so in a comment next to the constant.
Framing is authored too. The viewer's default fits the bounding box with margin,
which leaves an object small; bake a `CameraConfig` derived from the LOADED bounds
(never hard-coded numbers — the centres carry whatever origin the pipeline left):
```python
from luxar.core.viewer_config import CameraConfig, ViewerConfig
# visible_height(d) = 2·d·tan(fov/2); visible_width(d) = that · aspect
# Fit at the NEAR FACE, then add the half-depth back:
# d = max((W/fill)/(2 tan(fov/2)·aspect), (H/fill)/(2 tan(fov/2))) + depth/2
scene = compiler.create_scene(
dimensions=dims,
viewer_config=ViewerConfig(
tone_mapping="ACES",
camera=CameraConfig(position=(cx, cy, cz + d), target=(cx, cy, cz), fov=47.0),
),
)
```
**In a bundled demo this exact call fails required tests** (demos run under the
cinematic 35 mm preset and must not pin `fov`); the demo-safe wrapper is in
[references/demo-authoring.md](references/demo-authoring.md).
Two decisions this formula makes explicit:
- **`fov` is VERTICAL**, so visible width scales with the LIVE viewport aspect
while a baked distance cannot. A wide object therefore has no single distance
that fills it everywhere: at the aspect you calibrate for it occupies `fill` of
the width, a WIDER window leaves margin, a NARROWER one crops. (That is the
direction — it is easy to write it backwards.) The design aspect is a declared
calibration point, not a safety margin, so say which one you picked. Framing for
a square viewport is the no-crop-ever choice; the viewer default uses that same
shorter-axis rule with `fill = 0.75`, while an explicit camera can choose a
tighter fill and a declared landscape calibration aspect. Work out both numbers
before choosing.
- **Fit at the NEAR FACE, not the target plane.** This matches the viewer default:
the frustum narrows towards the camera, so a deep object's camera-facing side has
the least room. Fit at `d - depth/2` and add the half-depth back; otherwise the
near corners silently leave the frame.
A test that merely asserts the camera block *exists* is vacuous — a silent revert
to the default still loads a valid scene. Assert the geometry, and prefer one
assertion that re-derives nothing: project the eight bbox corners through the
camera and require every one inside the frustum. That catches the near-face error;
a test that repeats the distance formula cannot.
If a node's data sits diagonally in its own footprint, level it before authoring the
camera: the amplitude-weighted principal axis of the cloud in the view plane gives
the correction angle directly (`gsplat transform --rotate-z <-angle>`), and it can
shrink the bounding box dramatically — 483×508 → 663×303 µm on a fly brain, which is
the difference between framing the specimen and framing empty corners.
### Thin-line scenes need `allow_high_dpr=True`
The viewer renders at CSS resolution (device pixel ratio 1.0) by default, even on
a Retina display, because a 2x panel costs 4x the fragment work and soft-edged
emissive geometry barely rewards it. **Line-dominant scenes are the documented
exception — set `allow_high_dpr=True` on them.**
```python
viewer_config=ViewerConfig(
cinematic_mode=True,
allow_high_dpr=True, # river networks / tractograms / wiring diagrams
)
```
Measured on a Retina panel against DPR 2, brightness and coverage hold to within
2.5% on every geometry type; the entire visible effect of the cap is a 15-35%
loss of high-frequency detail. On points and gsplats that reads as slightly
softer. On dense thin lines it reads as **mush** — individual rivers, streamlines
or edges stop being separable, which loses information rather than polish.
It is also the cheapest place to spend the pixels. Line scenes are not
fill-bound, so they gain least from the cap to begin with: a trajectory scene
measured 1.06-1.17x faster at DPR 1, against 2.6-2.7x for a point cloud. You buy
the detail back for almost no frame time.
Leave it off for points, gsplats and mesh unless a specific scene proves
otherwise — that is where the default earns its keep. Mesh is the one worth
checking by eye, since it is shaded with hard silhouette edges rather than soft
sprites, and the "emissive geometry barely rewards it" argument does not cover it.
## Baked ambient occlusion (`luxar.shading`)
**Reach for this when a dense scene renders as an even glow and the shape is
gone.** Points, Lines and GSplats are emissive — their shaders know nothing about
neighbouring geometry, so there is no shading term at all and no exposure tweak
will put one there. `bake_ambient_occlusion` computes the missing one offline.
```python
from luxar.shading import bake_ambient_occlusion
shade = bake_ambient_occlusion(positions, mass=amplitudes) # volumetric data
shade = bake_ambient_occlusion( # a surface
vertices, normals=normals, occluder="opaque", radius=4.0, strength=0.85
)
colors = (base_colors * shade[:, None]).astype(np.float32) # multiply into EMISSION
```
The mental model that makes the knobs obvious: **emissivity is a function of
ambient illumination.** Emission–absorption transport has two terms; the blending
mode already supplies the outgoing attenuation, and for matter lit from outside
rather than glowing the emission term is `albedo × incident irradiance` — which is
what occlusion measures. So it multiplies into COLOUR, never into `opacity` or
`absorption`, and it *composes* with `volumetric` instead of double-counting it.
Choosing settings:
- **`occluder`** — `"density"` (default) for a medium; `"opaque"` for a **surface
sampled as points**. Not cosmetic: under Beer–Lambert a one-cell-thick shell
only attenuates by `exp(-k)`, so a *wall* passes about half the light, and no
single `k` both blocks walls and spares thick regions.
- **`normals`** — pass them if you have them (mesh normals, marching-cubes or
distance-field gradients). Roughly doubles the discrimination on a shell. Never
invent them: local-PCA normals on a volumetric cloud are meaningless, and
full-sphere is the correct reading there anyway.
- **`radius`** — the scale of structure it responds to, and the easiest thing to
get wrong. **Size it against the geometry you actually built, not the science.**
A demo whose radius came from the real complex's 120 nm ring, on a scaled-down
25 nm model, sat at 23% of the object and measured *worse* contrast than a
radius four times smaller — past a point the window stops reporting enclosure
and starts reporting depth.
- **`group_by`** — required for nD. Pass the timepoint index or occlusion crosses
the time axis and the whole sequence shades as one solid.
- **`strength`** — physical, not taste: `1 - strength` is the indirect,
multiply-scattered ambient reaching even an enclosed element. Points want more
than mesh (≈0.85 vs ≈0.45): a depth-tested surface puts one element in each
pixel, while a point cloud blends overlapping sprites and an additive one shows
the ray-*averaged* shade.
Three traps worth knowing before you judge a result:
- **AO always lowers mean brightness.** Dividing by the term's own maximum
(`normalized = shade / shade.max()`) preserves peak brightness, not the mean;
scale node intensity by `1 / normalized.mean()` if the authored mean exposure
must survive.
- **A frame that merely looks crisper may just be darker.** Compare against the
unshaded original, and judge a stronger setting on the 5th percentile, not the
contrast number — crushing the dark end to black raises contrast while showing
less.
- **Check your sprite radius before blaming the occlusion.** If the render radius
is under half the sample spacing the sprites never touch, the surface renders as
stipple, and that per-pixel noise drowns the gradient completely. This has
already cost one debugging session.
`directional_optical_depth` is the lower-level primitive AO is built from — the
column toward ONE direction. Use it only when the direction is a real light that
belongs to the subject (`demo_volumetric_cloud`'s sun). It is **not** a general
appearance path: a single baked direction is locked to world space, so it stops
reading the moment the camera orbits. A key light has to follow the viewer, which
makes it a material concern — the mesh shader does it properly in view space.
Occlusion is authored at SCENE time and deliberately absent from the gsplat
toolbox: a `.gsplats.zarr` is a reconstruction, and a per-element sidecar there
would be one more thing for `reencode`/`lod`/`decimate`/refits to reorder or
invalidate. Working demos: `demo_exotic_surfaces` (surfaces, opaque),
`demo_nuclear_pore_complex` and `demo_atp_synthase` (burial shading on PDB atoms),
`demo_mesh_isosurface_cells3d` (per-vertex, on top of the mesh key light).
## Serve & export
```bash
luxar serve my_scene.luxar.zarr --viewer --open # interactive viewer
luxar export my_scene.luxar.zarr -o out/ --open # standalone offline folder
luxar info my_scene.luxar.zarr --stats # inspect a built scene
```
`luxar serve` binds `127.0.0.1` (this machine only). On a remote or headless box,
either tunnel both ports from your laptop (`ssh -L 8000:127.0.0.1:8000 -L
5173:127.0.0.1:5173 host`, then open the printed URL locally) or pass `--host
0.0.0.0` to bind the data and viewer servers on every interface. `launch_viewer`
(from `luxar.demos`) runs `luxar serve --viewer` on a port pair derived from the
dataset, not 8000/5173 — tunnel the ports it prints.
## Gotchas (from the codebase)
- `create_scene` REQUIRES `dimensions=`.
- GSplat `cholesky_factors` are the packed lower-triangular factor **L of the
covariance** (Σ = L·Lᵀ): 3D = `(N,6)` `[L00, L10, L11, L20, L21, L22]`. The
diagonal is **scale-like** — isotropic std σ -> `[σ,0,σ,0,0,σ]`, NOT `1/σ`.
This is the same contract documented by `Group.add_gsplats` and
the `AdditiveSubLOD` docstring.
- `transforms.compose(t1, t2, t3)` applies `t1` FIRST.
- 4D/nD scenes can show 0 elements at a given slice — navigate to a populated slice,
or use `extend_to_all` to broadcast across a non-displayed dim.
- To hand-author a stacked time/channel axis from lower-dimensional splats, use
`dim_order=["x", "y", "z"]`, `fill={"time": t}`,
`fill_sigma={"time": 0.0}`, and `extend_to_all=[]` (omitting the last argument
auto-broadcasts the unmapped axis). Embedding regularizes the semantic zero to
`1e-7`. For full scene-dimensional input without `dim_order` embedding, use a
strictly positive diagonal smaller than the coordinate step; the writer rejects
zero.
- Match `positions` column order to the `Dimensions` order.
- **Always pass `n_iters` to `add_gsplats_from_volume` / `fit_gaussian_splats`** (the
example above uses 5000). The default is 1000 — *below* the CLI's lowest preset —
and on thin structures it leaves splats at their isotropic seed shape and renders
filaments as bead chains. See the `luxar-gsplat-pipeline` skill.
- **The element-cap gate (`test_demo_element_caps.py`) refuses `**kwargs` spreads
in geometry adder calls** (`add_points`/`add_lines`/`add_gsplats*`), because it
resolves each call's budget statically. Spell every keyword out; a
`common = dict(...)` + `**common` pattern fails the corpus gate even when the
values are right.
- **`DimensionsConfig` indexes its two fields DIFFERENTLY**: `current_step` by
absolute dimension (one entry per declared dim), `selected_dimension` by
NAVIGABLE position among the non-displayed dims (`0` for the first hidden axis).
`auto_rotate_speed` is revolutions per MINUTE (`60 / period_s`); `exposure` is
log2 stops.
- **A hidden axis with `discrete=True` (the default `__post_init__` gives a hidden
non-spatial dim) makes the writer order SLICE-MAJOR** — that axis is lexsorted
first and the Hilbert curve runs within each slice, for points, lines and
gsplats alike. Per-frame chunk locality is therefore free; what you choose is
the chunk SIZE (frames per chunk) — see the gsplat-pipeline skill.
- **Time-lapse splat scenes: prefer one part per timepoint with a per-part capped
stream ladder over one sliced leaf with a global ladder** (measured 2026-09-10;
see the gsplat-pipeline skill and `demo_gsplats_4d_h2afva_timelapse.py`).
- **A demo that caches a fit must key the cache on every parameter that changes the
result**, the optimizer schedule included. Seeds/floor/retention are the obvious
ones, but the schedule changes splat SHAPES while leaving the COUNT identical, so a
key blind to it silently returns the old fit and makes a retune look like a no-op.
Fold the parameters into the filename (a short digest) rather than relying on
anyone remembering `--recompute`.
See [references/scene-api.md](references/scene-api.md) for exact signatures and `**attrs`. For volume→splats,
use the `luxar-gsplat-pipeline` skill.