Validation Package

The validation package provides comprehensive data validation with helpful error messages.

Validation functions for Luxar data structures.

This package contains: - types.py: Basic type validation and type guards - base.py: Detailed validation with helpful error messages (for writing) - nd.py: nD dimensional coverage validation

exception luxar.validation.ValidationError(message: str, suggestion: str | None = None)[source]

Bases: ValueError

Custom validation error with helpful suggestions.

__init__(message: str, suggestion: str | None = None) → None[source]

Initialize validation error.

Parameters:
  • message – The error message

  • suggestion – Optional suggestion for fixing the error

exception luxar.validation.DimensionalCoverageError(message: str, group_name: str, missing_coverage: Dict[str, Set[float]] | None = None)[source]

Bases: ValueError

Error raised when point groups have inconsistent dimensional coverage.

__init__(message: str, group_name: str, missing_coverage: Dict[str, Set[float]] | None = None) → None[source]

Initialize dimensional coverage error.

Parameters:
  • message – Error message

  • group_name – Name of the group with coverage issues

  • missing_coverage – Dict mapping dimension names to missing values

luxar.validation.validate_cholesky_for_writing(cholesky_factors: NDArray[Any], n_dims: int, context: str = 'cholesky_factors') → None[source]

Validate packed lower-triangular Cholesky factors for writing.

The GSplats sibling of validate_radii_for_writing() (Points) and validate_widths_for_writing() (Lines), per the three-geometry symmetry rule. The gsplat assembly gate normalizes SHAPE before calling (a 1D uniform (k,) input is reshaped to (1, k)), so this validator accepts a 2D array of shape (n_splats, k) OR the uniform/broadcast (1, k) form, with k = n_dims * (n_dims + 1) // 2.

Two checks, mirroring the amplitudes/radii/widths validators:

  • Finiteness across the WHOLE packed array (a single NaN/Inf used to pass the shape-only gate and die deep in the encoder AFTER centers were already on disk, leaving a half-written node).

  • Strictly positive diagonal. The packed diagonal slots are np.cumsum(np.arange(1, n_dims + 1)) - 1 (e.g. [0, 2, 5] for n_dims=3); the format invariant is diag > 0 (positive, scale-like). A zero/negative diagonal is a degenerate/singular covariance that the uint8 log encoder silently clamps to 0 → a sliver splat with no diagnostic.

Parameters:
  • cholesky_factors – Packed lower-triangular factors, shape (n_splats, k) or (1, k).

  • n_dims – Number of spatial dimensions (used to locate the diagonal slots; the caller already has it).

  • context – Context for error messages.

Raises:

ValidationError – If the factors contain NaN/Inf or any non-positive diagonal value.

luxar.validation.validate_colors_for_writing(colors: NDArray[Any], n_points: int, context: str = 'colors', channels: tuple[int, ...] = (3,)) → None[source]

Validate colors array for writing.

Colors are RGB (n, 3) — or RGBA (n, 4) for geometry types that have opted into per-element alpha (channels=(3, 4); all three geometry types since their volumetric phases: gsplats, points, lines). The alpha column is a per-element opacity in [0, 1], not an HDR emission channel.

DTYPE is checked here, via validate_color_dtype() — unlike the positions sibling, which deliberately accepts any numeric dtype (see validate_positions_for_writing()), and like validate_faces_for_writing(), which likewise refuses a non-integer array outright. A COLOR array’s dtype is one the encoder REFUSES rather than converts: floating point is free (it quantizes), but an integer array must already be uint8 or uint16. That refusal used to land mid-write, after positions (or a whole LOD child) was on disk — the #1437 stranding class this pre-write sweep exists to close. The check runs LAST here so its precedence matches the encoder’s own; see the comment at the call site.

Parameters:
  • colors – Colors array to validate

  • n_points – Expected number of points

  • context – Context for error messages

  • channels – Accepted channel counts (broadcast rows (1, c) allowed for each accepted c)

Raises:

ValidationError – If colors are invalid

luxar.validation.validate_labels_for_writing(labels: Any, n_elements: int, context: str = 'labels', noun: str = 'Labels') → None[source]

Validate per-element string labels BEFORE any zarr write.

The CSR label serializer UTF-8-encodes each entry; a non-string entry used to die with a deep AttributeError after the node’s arrays were already on disk. This pre-flight validator runs in the writers’ fail-fast gate.

Parameters:
  • labels – Candidate labels. Must be a sequence (not a bare string) of str entries; None entries are allowed (null label).

  • n_elements – Expected number of elements.

  • context – Context for error messages.

  • noun – Capitalised channel name used in error messages. The keys channel shares this validator, and reporting its faults as label faults sends the author looking at the wrong argument.

Raises:

ValidationError – If labels are not a sequence of str/None with one entry per element.

luxar.validation.validate_node_name(name: Any, context: str = 'node name') → str[source]

Validate a scene-graph node name (a single zarr path segment).

This is the single chokepoint for node naming, shared by Node.__init__ (groups + all node objects), the add_points/add_lines/add_gsplats adders, and the compiler writers (via validate_node_path). Rules:

  • Must be a non-empty, non-whitespace-only string. An empty segment is the worst case: zarr.require_group("") resolves to the store ROOT group, so a node named "" would stamp type='points' onto the scene root and make the whole store unloadable.

  • Must not contain / — that is the zarr path separator; use add_group() for hierarchy.

  • Must not start with .. Zarr v2 reserves the dot-prefixed keys .zgroup / .zattrs / .zarray / .zmetadata for its own metadata objects; a node named .zgroup dies with a deep KeyError inside zarr and .zmetadata silently collides with consolidated metadata. We reject the entire dot-prefixed namespace (stricter than the exact reserved set, but safe: it also covers future zarr metadata keys and hidden dot-files that most tooling cannot see).

  • Must not be zarr.json. Format 3 replaced the dot-prefixed documents with a single zarr.json per node, and that name is NOT dot-prefixed — so the rule above, which covered every reserved key at format 2, stops being complete the moment anything writes format 3. Measured: the write does not silently corrupt the store, but it dies inside zarr with ValueError: delete_dir was passed a prefix='zarr.json' that is a file, which is exactly the deep-internal-error experience this function exists to replace with a clear one.

  • Must not contain control characters (\x00–\x1f, \x7f) — filesystem and JSON hazards for directory-backed scenes.

Parameters:
  • name – Candidate node name.

  • context – Context for error messages.

Returns:

The validated name (unchanged).

Raises:

ValidationError – If the name is invalid.

luxar.validation.validate_positions_for_writing(positions: NDArray[Any], context: str = 'positions') → Tuple[int, int][source]

Validate positions array for writing to Zarr.

Parameters:
  • positions – Positions array to validate

  • context – Context for error messages

Returns:

Tuple of (n_points, n_dims). NOTE the array itself is NOT returned and is NOT dtype-converted: the validator deliberately accepts any numeric dtype (float32/float64/int) and verifies SHAPE + FINITENESS only. Callers that need float32 storage MUST convert via ensure_float32 (or equivalent) AFTER calling this function — see the test_validate_positions_dtype_not_checked regression-lock test for the pinned contract.

Raises:

ValidationError – If positions are invalid (wrong shape, empty, zero-dimensional, or containing NaN / ±Inf).

luxar.validation.validate_radii_for_writing(radii: NDArray[Any] | float | int, n_points: int, context: str = 'radii') → None[source]

Validate radii (array or broadcast scalar) for writing.

Scalars are validated against the same finite/positive rules as arrays (mirroring validate_widths_for_writing()); a scalar radii=-1.0 or radii=float('nan') used to slip through to the encoder after the positions were already written.

Parameters:
  • radii – Radii array or a scalar broadcast to all points

  • n_points – Expected number of points

  • context – Context for error messages

Raises:

ValidationError – If radii are invalid

luxar.validation.validate_sharpness_for_writing(sharpness: NDArray[Any] | float | int, n_points: int, context: str = 'sharpness') → None[source]

Validate sharpness (array or broadcast scalar) for writing.

Scalars are validated against the same [SHARPNESS_MIN, SHARPNESS_MAX] bounds as arrays (mirroring validate_widths_for_writing()); a scalar sharpness=5.0 used to be accepted while the equivalent array was rejected.

Parameters:
  • sharpness – Sharpness array or a scalar broadcast to all points

  • n_points – Expected number of points

  • context – Context for error messages

Raises:

ValidationError – If sharpness values are invalid

luxar.validation.validate_widths_for_writing(widths: Any, n_vertices: int, context: str = 'widths') → None[source]

Validate line widths for writing.

Accepts a per-vertex 1D array of shape (n_vertices,), a broadcast array of shape (1,), or a scalar width. The Lines sibling of validate_radii_for_writing() (Points), per the three-geometry symmetry rule.

Parameters:
  • widths – Per-vertex widths array, broadcast (1,) array, or scalar

  • n_vertices – Expected number of vertices

  • context – Context for error messages

Raises:

ValidationError – If widths are invalid

luxar.validation.validate_zarr_attributes(attrs: dict, is_root: bool = False) → None[source]

Validate that all required Zarr attributes are present.

Ensures that zarr groups have the required metadata attributes according to the Luxar Zarr format specification.

Parameters:
  • attrs – Dictionary of zarr attributes

  • is_root – Whether this is the root scene group

Raises:

ValidationError – If required attributes are missing or invalid

luxar.validation.validate_positions(positions: Any, ndim: int | None = None) → NDArray[float32][source]

Validate and convert positions array to correct type.

Parameters:
  • positions – Input array to validate

  • ndim – Expected number of dimensions (optional). If None, any dimensionality is accepted.

Returns:

Validated positions array with shape (N, D)

Raises:

ValueError – If positions are invalid shape or type

luxar.validation.validate_colors(colors: Any, n_points: int, channels: tuple[int, ...] = (3, 4)) → ndarray[Any, dtype[float32]][source]

Validate and convert colors array to HDR float32 format.

Colors are RGB (n, 3) or RGBA (n, 4). The optional alpha column is a per-element opacity in [0, 1] (NOT an HDR emission channel): each blending mode consumes it the way it consumes node-level opacity, and the volumetric mode maps it into optical depth (see VOLUMETRIC_BLENDING_SPEC.md).

Parameters:
  • colors – Input array to validate

  • n_points – Expected number of points

  • channels – Accepted channel counts. Geometry types opt into RGBA per phase (gsplats today; points/lines keep (3,) until their volumetric phases land).

Returns:

Validated colors array in HDR float32 format

Raises:

ValueError – If colors are invalid shape or type

luxar.validation.validate_radii(radii: Any, n_points: int) → ndarray[Any, dtype[float32]][source]

Validate and convert radii array to correct type.

Parameters:
  • radii – Input array to validate

  • n_points – Expected number of points

Returns:

Validated radii array

Raises:

ValueError – If radii are invalid shape or type

luxar.validation.validate_sharpness(sharpness: Any, n_points: int) → ndarray[Any, dtype[float32]][source]

Validate and convert sharpness array to correct type.

Sharpness is a normalised [0, 1] knob mapped in the viewer to the super-Gaussian falloff exponent beta = 2^(6s - 2): s=0.5 -> beta=2 (a true Gaussian), higher s -> harder/crisper edge, lower s -> peakier cusp. Must be float32 values in [SHARPNESS_MIN, SHARPNESS_MAX] = [0, 1] with shape (N,) where N is the number of points. The full range is also enforced by base.validate_sharpness_for_writing.

Parameters:
  • sharpness – Input sharpness array to validate

  • n_points – Expected number of points

Returns:

Validated sharpness array as float32

Raises:

ValueError – If sharpness values are invalid

luxar.validation.validate_transform(transform: Any) → NDArray[float32][source]

Validate and convert transform matrix to correct type.

Parameters:

transform – Input transform matrix

Returns:

Validated 4x4 transform matrix

Raises:

ValueError – If transform is invalid shape or type

luxar.validation.validate_node_type(node_type: str) → NodeType[source]

Validate node type string.

Parameters:

node_type – Input node type string

Returns:

Validated node type

Raises:

ValueError – If node type is invalid

luxar.validation.validate_physical_unit(unit: str) → PhysicalUnit[source]

Validate physical unit string.

Parameters:

unit – Input unit string

Returns:

Validated physical unit

Raises:

ValueError – If unit is invalid

luxar.validation.validate_opacity(opacity: Any) → float[source]

Validate and convert opacity value.

Parameters:

opacity – Value to validate as opacity (0.0 to 1.0)

Returns:

Valid opacity as float

Raises:
  • ValueError – If opacity is not a valid float between 0 and 1

  • TypeError – If opacity cannot be converted to float

luxar.validation.validate_truncation_radius(truncation_radius: Any) → float[source]

Validate and convert a GSplat truncation radius T (in sigmas).

T sets both the support of the shifted Gaussian and its normalization 1 / (1 - exp(-T^2/2)), so a non-positive or non-finite value poisons every derived uniform (uShiftC, uInvOneMinusC) and collapses the world-space cull box. It arrives unvalidated from dataset attrs, so it is checked on write.

A large T is merely a wide (and eventually untruncated) Gaussian, which is well defined — so the upper bound is not a modelling choice, it is the largest float32 whose square is still finite in float32. The GPU consumes T² as its own uniform (uTruncateSq, the fragment discard threshold), so a T that itself narrows finitely but whose float32 square overflows is the same degeneracy as the lower bound seen from the other end.

The lower bound is derived, not a magic number. T > 0 alone is not sufficient: below some threshold exp(-T^2/2) rounds to exactly 1.0, so 1 / (1 - C) is inf and every downstream value is poisoned — the very failure this validator exists to prevent. The check is done in float32, the narrowest consumer (the CUDA and Metal kernels and the GPU shaders all evaluate the shift in single precision), which saturates around T = 3e-4 — four orders of magnitude before float64’s ~1.5e-8. The viewer’s read-time clamp (MIN_TRUNCATION_RADIUS in rendering/materials/gsplat/math.ts) bisects this same float32 bound, so a value accepted here (e.g. 0.05) renders unmodified and stays consistent with the chunk bounds computed from it, instead of being silently raised.

Parameters:

truncation_radius – Value to validate as a truncation radius (> 0, finite, and large enough that 1 / (1 - exp(-T^2/2)) is finite)

Returns:

Valid truncation radius as float

Raises:
  • ValueError – If the value is <= 0, NaN, infinite, or so small that the shifted-Gaussian normalization overflows

  • TypeError – If the value cannot be converted to float

luxar.validation.validate_gamma(gamma: Any) → float[source]

Validate and convert gamma value.

Parameters:

gamma – Value to validate as gamma (0.1 to 10.0)

Returns:

Valid gamma as float

Raises:
  • ValueError – If gamma is not within valid range

  • TypeError – If gamma cannot be converted to float

luxar.validation.validate_absorption(absorption: Any) → float[source]

Validate and convert an absorption coefficient (volumetric kappa).

Absorption is the volumetric blending mode’s per-node coefficient: multiplicative composition with identity 1.0, no upper bound (it is a physical coefficient), and kappa=0 reproduces additive blending exactly.

Parameters:

absorption – Value to validate as absorption (>= 0, finite)

Returns:

Valid absorption as float

Raises:
  • ValueError – If absorption is negative, NaN, or infinite

  • TypeError – If absorption cannot be converted to float

luxar.validation.validate_blending_mode(mode: Any) → BlendingMode[source]

Validate blending mode string.

Parameters:

mode – Blending mode to validate. Accepted values are "normal", "additive", "max", "opaque", "luminous", and "volumetric". Volumetric mode uses emission-absorption compositing scaled by the node’s absorption (kappa) attribute.

Returns:

Valid blending mode

Raises:
luxar.validation.validate_layer_order(order: Any) → int[source]

Validate an authored cross-layer draw order.

layer_order states where a layer draws relative to the other layers it overlaps: higher = nearer the camera = drawn later, the CSS z-index / Illustrator convention. See docs/guides/specs/LAYER_ORDER_SPEC.md.

Signed, and bounded only by what survives the trip to the viewer: negative values are the natural way to push a backdrop behind everything else, and sparse values (10/20/30) leave room to insert a layer later without renumbering.

The bound is the JavaScript safe-integer range, and it is not an arbitrary clamp. This attr is written to JSON and read by the viewer as a JS number, where every integer above 2**53 - 1 shares its representation with its neighbours. Since the ONLY property this value has is its order relative to other layers, a magnitude past that point can silently collapse two orders the author deliberately separated into one band — handing the choice between those layers back to the geometry-derived inference the whole attribute exists to override. A loud refusal at write time is the only place that failure can be caught.

A bool is refused even though isinstance(True, int) is true in Python: layer_order=True almost certainly means the author confused this with a flag, and silently banding that layer at order 1 would be a wrong answer rather than an error.

Parameters:

order – Draw order to validate. A Python integer whose magnitude is at most JS_SAFE_INTEGER_MAX.

Returns:

The order as a plain int.

Raises:
  • TypeError – If order is not an integer (bool included).

  • ValueError – If order is outside the JS safe-integer range.

luxar.validation.validate_line_join(style: Any) → str[source]

Validate a line join style string.

Lines-only (issue #790): the strategy the line vertex stage uses at a degree-2 polyline joint. It has no meaning for points, gsplats or mesh, none of which build a screen-space quad per element — the adders for those three refuse it, so this validator does not need to know the geometry type.

Parameters:

style – Join style to validate. Accepted values are "none" (leave the two quads alone, so the turn leaves an uncovered wedge) and "miter" (rotate each quad’s end edge onto the shared miter edge so the two tile).

Returns:

Valid join style

Raises:
luxar.validation.validate_categories(categories: List[str] | None) → List[str] | None[source]

Validate category list for categorical dimensions.

Categories define the discrete values for a categorical dimension. Each category is a string label (e.g., [“DAPI”, “GFP”, “mCherry”]).

Parameters:

categories – List of category labels, or None for non-categorical

Returns:

Validated category list (or None if input is None)

Raises:
  • TypeError – If categories is not a list or None

  • ValueError – If categories is invalid (empty, duplicates, too long, etc.)

Examples

>>> validate_categories(["DAPI", "GFP", "mCherry"])
['DAPI', 'GFP', 'mCherry']
>>> validate_categories(None)
None
>>> validate_categories(["A", "A"])
ValueError: duplicate category name...
luxar.validation.validate_category_indices(values: ndarray, categories: List[str], context: str = 'values') → None[source]

Validate that array values are valid category indices.

For categorical dimensions, point coordinates should be integer indices into the categories list (0-indexed).

Parameters:
  • values – 1D array of values to validate

  • categories – List of category labels

  • context – Context string for error messages

Raises:

ValueError – If values contain invalid category indices

luxar.validation.is_position_array(obj: Any) → bool[source]

Check if object is a valid position array.

luxar.validation.is_color_array(obj: Any, n_points: int) → bool[source]

Check if object is a valid color array.

luxar.validation.is_transform_matrix(obj: Any) → bool[source]

Check if object is a valid transform matrix.

luxar.validation.broadcast_to_all_slices(positions: PositionArray, colors: Float32Array | None, radii: Float32Array | None, scene_dimensions: Dimensions) → Tuple[PositionArray, Float32Array | None, Float32Array | None][source]

Broadcast points to cover all non-displayed dimension values.

Helper function that replicates points across all combinations of non-displayed dimensions (e.g., Time and Channel).

Parameters:
  • positions – Original position array

  • colors – Original colors array (optional)

  • radii – Original radii array (optional)

  • scene_dimensions – Scene dimension specifications

Returns:

Tuple of (broadcasted_positions, broadcasted_colors, broadcasted_radii)

luxar.validation.validate_dimensional_coverage(scene_dimensions: Dimensions, point_groups: Dict[str, NDArray[np.float32]]) → None[source]

Validate that all point groups have consistent dimensional coverage.

For non-displayed dimensions (like Time and Channel), ensures that either: 1. All groups have points at the same set of dimension values 2. Groups are properly marked for handling (future: static flag)

Parameters:
  • scene_dimensions – Scene dimension specifications

  • point_groups – Dict mapping group names to position arrays

Raises:

DimensionalCoverageError – If groups have inconsistent coverage

Type Validation

Basic type validation and type guards for array data.

Type validation functions for runtime type checking.

This module contains basic validation functions that are used for type guards, property validation, and runtime type checking. For detailed write-time validation with helpful error messages, see validation/base.py.

These validators are simpler and focus on type conversion and basic checks, while the _for_writing validators provide comprehensive error messages for users writing data.

luxar.validation.types.validate_positions(positions: Any, ndim: int | None = None) → NDArray[float32][source]

Validate and convert positions array to correct type.

Parameters:
  • positions – Input array to validate

  • ndim – Expected number of dimensions (optional). If None, any dimensionality is accepted.

Returns:

Validated positions array with shape (N, D)

Raises:

ValueError – If positions are invalid shape or type

luxar.validation.types.validate_colors(colors: Any, n_points: int, channels: tuple[int, ...] = (3, 4)) → ndarray[Any, dtype[float32]][source]

Validate and convert colors array to HDR float32 format.

Colors are RGB (n, 3) or RGBA (n, 4). The optional alpha column is a per-element opacity in [0, 1] (NOT an HDR emission channel): each blending mode consumes it the way it consumes node-level opacity, and the volumetric mode maps it into optical depth (see VOLUMETRIC_BLENDING_SPEC.md).

Parameters:
  • colors – Input array to validate

  • n_points – Expected number of points

  • channels – Accepted channel counts. Geometry types opt into RGBA per phase (gsplats today; points/lines keep (3,) until their volumetric phases land).

Returns:

Validated colors array in HDR float32 format

Raises:

ValueError – If colors are invalid shape or type

luxar.validation.types.validate_radii(radii: Any, n_points: int) → ndarray[Any, dtype[float32]][source]

Validate and convert radii array to correct type.

Parameters:
  • radii – Input array to validate

  • n_points – Expected number of points

Returns:

Validated radii array

Raises:

ValueError – If radii are invalid shape or type

luxar.validation.types.validate_sharpness(sharpness: Any, n_points: int) → ndarray[Any, dtype[float32]][source]

Validate and convert sharpness array to correct type.

Sharpness is a normalised [0, 1] knob mapped in the viewer to the super-Gaussian falloff exponent beta = 2^(6s - 2): s=0.5 -> beta=2 (a true Gaussian), higher s -> harder/crisper edge, lower s -> peakier cusp. Must be float32 values in [SHARPNESS_MIN, SHARPNESS_MAX] = [0, 1] with shape (N,) where N is the number of points. The full range is also enforced by base.validate_sharpness_for_writing.

Parameters:
  • sharpness – Input sharpness array to validate

  • n_points – Expected number of points

Returns:

Validated sharpness array as float32

Raises:

ValueError – If sharpness values are invalid

luxar.validation.types.validate_transform(transform: Any) → NDArray[float32][source]

Validate and convert transform matrix to correct type.

Parameters:

transform – Input transform matrix

Returns:

Validated 4x4 transform matrix

Raises:

ValueError – If transform is invalid shape or type

luxar.validation.types.validate_node_type(node_type: str) → NodeType[source]

Validate node type string.

Parameters:

node_type – Input node type string

Returns:

Validated node type

Raises:

ValueError – If node type is invalid

luxar.validation.types.validate_physical_unit(unit: str) → PhysicalUnit[source]

Validate physical unit string.

Parameters:

unit – Input unit string

Returns:

Validated physical unit

Raises:

ValueError – If unit is invalid

luxar.validation.types.validate_opacity(opacity: Any) → float[source]

Validate and convert opacity value.

Parameters:

opacity – Value to validate as opacity (0.0 to 1.0)

Returns:

Valid opacity as float

Raises:
  • ValueError – If opacity is not a valid float between 0 and 1

  • TypeError – If opacity cannot be converted to float

luxar.validation.types.validate_appearance_fraction(value: Any, name: str) → float[source]

Validate a finite mesh appearance fraction in [0, 1].

luxar.validation.types.validate_positive_finite(value: Any, name: str) → float[source]

Validate a strictly-positive finite mesh appearance exponent.

luxar.validation.types.TEXTURE_FILTERS: Tuple[str, ...] = ('linear', 'nearest')

The texture magnification/minification filters the viewer implements.

linear also enables mipmaps + anisotropy at upload; nearest is the right choice for a categorical or index-like texture, where interpolating between two class ids invents a third that means nothing.

luxar.validation.types.TEXTURE_WRAPS: Tuple[str, ...] = ('repeat', 'clamp')

The texture wrap modes. Applied per-axis by the viewer, which defaults to repeat-in-u / clamp-in-v so an equirectangular basemap tiles across the dateline seam without bleeding the north pole into the south.

luxar.validation.types.TEXTURE_COLOR_SPACES: Tuple[str, ...] = ('srgb', 'linear')

The transfer functions a mesh texture may declare.

luxar.validation.types.validate_texture_filter(value: Any, name: str) → str[source]

Validate a mesh texture_filter attr.

luxar.validation.types.validate_texture_wrap(value: Any, name: str) → str[source]

Validate a mesh texture_wrap attr.

luxar.validation.types.validate_texture_color_space(value: Any, name: str) → str[source]

Validate a mesh texture_color_space declaration.

luxar.validation.types.MESH_MATERIALS: Tuple[str, ...] = ('luxar', 'physical')

The mesh material families the viewer implements (docs/guides/specs/MESH_PHYSICAL_MATERIALS_SPEC.md §3.1).

luxar is the house shader — the light-free, view-anchored key of MESH_NODE_SPEC.md §6.2 — and what every mesh renders with when the attr is absent. physical opts a mesh into three.js’s own physically based material, lit by the viewer’s scene environment; it is the only family that understands PHYSICAL_MATERIAL_ATTRS.

luxar.validation.types.PHYSICAL_MATERIAL_FRACTION_ATTRS: Tuple[str, ...] = ('roughness', 'metalness', 'clearcoat', 'clearcoat_roughness', 'iridescence', 'sheen', 'transmission')

The physical knobs that are FRACTIONS in [0, 1], in the order the viewer’s Layers panel lists them. transmission (Phase 2) is a fraction too: the share of light that passes through the surface rather than reflecting off it.

luxar.validation.types.PHYSICAL_TRANSMISSION_ATTRS: FrozenSet[str] = frozenset({'attenuation_color', 'attenuation_distance', 'dispersion', 'ior', 'refract_data', 'thickness', 'transmission'})

the Phase 2 knobs plus Phase 3’s refract_data flag. Only transmission and ior mean anything on their own; three compiles thickness, the two attenuation_* knobs and dispersion inside its USE_TRANSMISSION block, and refract_data (draw the glass AFTER the emissive data so it refracts it) is meaningless on a surface that transmits nothing — so the mesh adder refuses all of them without transmission > 0 (see PHYSICAL_TRANSMISSION_DEPENDENT_ATTRS). ior stands alone because it also sets the specular reflectance at normal incidence of an opaque surface.

Type:

The glass family (spec §3.4)

luxar.validation.types.PHYSICAL_TRANSMISSION_DEPENDENT_ATTRS: FrozenSet[str] = frozenset({'attenuation_color', 'attenuation_distance', 'dispersion', 'refract_data', 'thickness'})

The subset of PHYSICAL_TRANSMISSION_ATTRS that renders NOTHING unless transmission is authored above zero — the pairing the adder refuses.

luxar.validation.types.PHYSICAL_MATERIAL_ATTRS: FrozenSet[str] = frozenset({'attenuation_color', 'attenuation_distance', 'clearcoat', 'clearcoat_roughness', 'dispersion', 'ior', 'iridescence', 'metalness', 'refract_data', 'roughness', 'sheen', 'sheen_color', 'thickness', 'transmission'})

Every authored knob that means something ONLY under material="physical".

sheen_color rides along because three’s default sheen colour is black, so sheen alone renders nothing — a knob that silently does nothing is exactly what the material validation exists to refuse. The glass family (PHYSICAL_TRANSMISSION_ATTRS) joined in Phase 2.

luxar.validation.types.IOR_MIN = 1.0

The index-of-refraction range three’s physical material accepts (its documented ior bounds: vacuum to diamond).

luxar.validation.types.HOUSE_SHADER_ONLY_ATTRS: FrozenSet[str] = frozenset({'ambient', 'shade_exponent', 'shininess', 'specular'})

each parameterises the §6.2 wrapped-diffuse / Blinn–Phong model, which a physical material does not run. alpha_cutoff is not here — it maps onto three’s alphaTest — and neither is shading, whose smooth/flat half is a property of any lit surface (only the unlit none is refused, by the adder).

Type:

The house-shader knobs a physical mesh REFUSES rather than ignores

luxar.validation.types.validate_mesh_material(value: Any, name: str = 'Material') → str[source]

Validate a mesh material attr against MESH_MATERIALS.

A typo here would be the worst kind: material="physcial" would write cleanly and render with the house shader, and the author would conclude the physical knobs do nothing.

luxar.validation.types.validate_ior(value: Any, name: str = 'Ior') → float[source]

Validate a physical ior in three’s [IOR_MIN, IOR_MAX].

Below 1 is not a refractive index of anything light enters from vacuum, and three’s transmission shader clamps at 2.333 (diamond), so a value outside the range is a typo (ior=15) rather than an exotic material.

luxar.validation.types.validate_non_negative_finite(value: Any, name: str) → float[source]

Validate a finite mesh appearance quantity that may be zero (thickness).

luxar.validation.types.validate_hex_color(value: Any, name: str) → str[source]

Validate a #rrggbb colour string (sheen_color, attenuation_color).

Exactly the six-digit form: three.js parses the three-digit shorthand too, but accepting two spellings of one colour on disk buys nothing and costs the reader a normalisation step. The value is returned as given.

luxar.validation.types.validate_absorption(absorption: Any) → float[source]

Validate and convert an absorption coefficient (volumetric kappa).

Absorption is the volumetric blending mode’s per-node coefficient: multiplicative composition with identity 1.0, no upper bound (it is a physical coefficient), and kappa=0 reproduces additive blending exactly.

Parameters:

absorption – Value to validate as absorption (>= 0, finite)

Returns:

Valid absorption as float

Raises:
  • ValueError – If absorption is negative, NaN, or infinite

  • TypeError – If absorption cannot be converted to float

luxar.validation.types.MIN_TRUNCATION_RADIUS_FLOAT32: float = 0.00028657716757152235

Lower bound for validate_truncation_radius() (~2.44e-4). See _min_truncation_radius_float32() for why float32 sets it.

luxar.validation.types.MAX_TRUNCATION_RADIUS_FLOAT32: float = 1.8446742974197924e+19

the largest float32 whose SQUARE is still finite in float32. The GPU consumes T twice — as uTruncate and as uTruncateSq = T² (the fragment discard threshold) — so bounding T at float32’s max is not enough: everything in (sqrt(float32.max), float32.max] uploads a finite uTruncate next to an infinite uTruncateSq. One ulp above this value the float32 square overflows (pinned by test).

Type:

Upper bound for validate_truncation_radius() (~1.84e19)

luxar.validation.types.validate_truncation_radius(truncation_radius: Any) → float[source]

Validate and convert a GSplat truncation radius T (in sigmas).

T sets both the support of the shifted Gaussian and its normalization 1 / (1 - exp(-T^2/2)), so a non-positive or non-finite value poisons every derived uniform (uShiftC, uInvOneMinusC) and collapses the world-space cull box. It arrives unvalidated from dataset attrs, so it is checked on write.

A large T is merely a wide (and eventually untruncated) Gaussian, which is well defined — so the upper bound is not a modelling choice, it is the largest float32 whose square is still finite in float32. The GPU consumes T² as its own uniform (uTruncateSq, the fragment discard threshold), so a T that itself narrows finitely but whose float32 square overflows is the same degeneracy as the lower bound seen from the other end.

The lower bound is derived, not a magic number. T > 0 alone is not sufficient: below some threshold exp(-T^2/2) rounds to exactly 1.0, so 1 / (1 - C) is inf and every downstream value is poisoned — the very failure this validator exists to prevent. The check is done in float32, the narrowest consumer (the CUDA and Metal kernels and the GPU shaders all evaluate the shift in single precision), which saturates around T = 3e-4 — four orders of magnitude before float64’s ~1.5e-8. The viewer’s read-time clamp (MIN_TRUNCATION_RADIUS in rendering/materials/gsplat/math.ts) bisects this same float32 bound, so a value accepted here (e.g. 0.05) renders unmodified and stays consistent with the chunk bounds computed from it, instead of being silently raised.

Parameters:

truncation_radius – Value to validate as a truncation radius (> 0, finite, and large enough that 1 / (1 - exp(-T^2/2)) is finite)

Returns:

Valid truncation radius as float

Raises:
  • ValueError – If the value is <= 0, NaN, infinite, or so small that the shifted-Gaussian normalization overflows

  • TypeError – If the value cannot be converted to float

luxar.validation.types.validate_gamma(gamma: Any) → float[source]

Validate and convert gamma value.

Parameters:

gamma – Value to validate as gamma (0.1 to 10.0)

Returns:

Valid gamma as float

Raises:
  • ValueError – If gamma is not within valid range

  • TypeError – If gamma cannot be converted to float

luxar.validation.types.validate_intensity(intensity: Any) → float[source]

Validate and convert intensity value.

Parameters:

intensity – Value to validate as intensity (0.0 to 100.0)

Returns:

Valid intensity as float

Raises:
  • ValueError – If intensity is not within valid range

  • TypeError – If intensity cannot be converted to float

luxar.validation.types.validate_offset(offset: Any) → float[source]

Validate and convert offset value.

Parameters:

offset – Value to validate as offset (-10.0 to 10.0)

Returns:

Valid offset as float

Raises:
  • ValueError – If offset is not within valid range

  • TypeError – If offset cannot be converted to float

luxar.validation.types.validate_layer(value: Any) → bool[source]

Validate and convert layer flag.

Parameters:

value – Value to validate as a boolean layer flag

Returns:

Valid layer flag as bool

Raises:

TypeError – If value cannot be interpreted as a boolean

luxar.validation.types.validate_bool_flag(value: Any, name: str = 'Flag') → bool[source]

Validate a named boolean appearance flag (refract_data and kin).

The same acceptance as validate_layer() — bool or numpy.bool_ — but the message names the knob, because this runs from the mesh appearance validator table where several flags share one code path.

Parameters:
  • value – Value to validate as a boolean flag

  • name – Human-readable knob name for the error message

Returns:

The flag as a plain bool

Raises:

TypeError – If value is not a boolean (numbers are refused here, unlike layer: refract_data=1 reads like a slider value, and a flag that also accepts numbers invites exactly that confusion)

Validate a node’s link URL template (issue #1917).

The template is substituted per element in the viewer and the result is NAVIGATED to, so the checks here mirror the viewer’s own and exist to turn an authoring mistake into a failed write rather than a link that silently does nothing when clicked.

Validated on the template with its placeholders left in place: they are {...} runs, which no URL parser objects to, so scheme and shape can be settled at write time. What cannot be settled here is the substituted result — a label supplies the rest of the URL at click time — which is why the viewer re-checks after substitution. Substituted values are percent-encoded there, so a placeholder cannot introduce a scheme, a host, or a path segment that is not already visible in this template.

Parameters:

value – Value to validate as a URL template

Returns:

The template unchanged

Raises:
  • TypeError – If value is not a string

  • ValueError – If the template is empty, over-long, has no scheme (relative), or names a scheme outside LINK_SCHEMES

luxar.validation.types.validate_copy_template(value: Any) → str[source]

Validate a node’s copy template (issue #1917).

Plain text destined for the clipboard, not a URL — so there is nothing to check beyond the type and a length bound. It is deliberately NOT escaped or restricted in content: the whole point is to hand the user the string the author chose.

Parameters:

value – Value to validate as a copy template

Returns:

The template unchanged

Raises:

Validate a node’s link_target browsing context (issue #1917).

Only the two keywords that imply noopener are accepted. Any other value — including a near-miss like "_blank " — is a named target, which the browser opens with a live window.opener the destination can use to cross-origin navigate the viewer tab.

Parameters:

value – Value to validate as a browsing context

Returns:

The target unchanged

Raises:
luxar.validation.types.validate_visible(value: Any) → bool[source]

Validate and convert visible flag.

Parameters:

value – Value to validate as a boolean visibility flag

Returns:

Valid visible flag as bool

Raises:

TypeError – If value cannot be interpreted as a boolean

luxar.validation.types.validate_blending_mode(mode: Any) → BlendingMode[source]

Validate blending mode string.

Parameters:

mode – Blending mode to validate. Accepted values are "normal", "additive", "max", "opaque", "luminous", and "volumetric". Volumetric mode uses emission-absorption compositing scaled by the node’s absorption (kappa) attribute.

Returns:

Valid blending mode

Raises:
luxar.validation.types.validate_layer_order(order: Any) → int[source]

Validate an authored cross-layer draw order.

layer_order states where a layer draws relative to the other layers it overlaps: higher = nearer the camera = drawn later, the CSS z-index / Illustrator convention. See docs/guides/specs/LAYER_ORDER_SPEC.md.

Signed, and bounded only by what survives the trip to the viewer: negative values are the natural way to push a backdrop behind everything else, and sparse values (10/20/30) leave room to insert a layer later without renumbering.

The bound is the JavaScript safe-integer range, and it is not an arbitrary clamp. This attr is written to JSON and read by the viewer as a JS number, where every integer above 2**53 - 1 shares its representation with its neighbours. Since the ONLY property this value has is its order relative to other layers, a magnitude past that point can silently collapse two orders the author deliberately separated into one band — handing the choice between those layers back to the geometry-derived inference the whole attribute exists to override. A loud refusal at write time is the only place that failure can be caught.

A bool is refused even though isinstance(True, int) is true in Python: layer_order=True almost certainly means the author confused this with a flag, and silently banding that layer at order 1 would be a wrong answer rather than an error.

Parameters:

order – Draw order to validate. A Python integer whose magnitude is at most JS_SAFE_INTEGER_MAX.

Returns:

The order as a plain int.

Raises:
  • TypeError – If order is not an integer (bool included).

  • ValueError – If order is outside the JS safe-integer range.

luxar.validation.types.validate_line_join(style: Any) → str[source]

Validate a line join style string.

Lines-only (issue #790): the strategy the line vertex stage uses at a degree-2 polyline joint. It has no meaning for points, gsplats or mesh, none of which build a screen-space quad per element — the adders for those three refuse it, so this validator does not need to know the geometry type.

Parameters:

style – Join style to validate. Accepted values are "none" (leave the two quads alone, so the turn leaves an uncovered wedge) and "miter" (rotate each quad’s end edge onto the shared miter edge so the two tile).

Returns:

Valid join style

Raises:
luxar.validation.types.validate_colormap(value: Any) → str | ndarray[Any, Any][source]

Validate a colormap specification.

Accepts:
  • A string (colormap name, resolved at write time)

  • A numpy array of shape (N, 3) with N >= 2

Parameters:

value – Colormap name or LUT array.

Returns:

Validated colormap (string or numpy array).

Raises:
  • TypeError – If value is not a string or numpy array.

  • ValueError – If array has wrong shape or values out of range.

luxar.validation.types.validate_finite_reveal_coords(coords: Any, what: str) → None[source]

Refuse non-finite coordinates feeding a radial reveal ordering.

A reveal ranks elements by distance, and a NaN/inf coordinate poisons that rank in a way that LOOKS like success: the distances come back non-finite, they all compare equal under the stable argsort the three orderings use, and the ladder is emitted in INPUT order — a streaming node that fills in at random instead of growing outward, with nothing to say why.

This is the DATA-side twin of the reveal_center finite check. That one guards a value the user typed; this one guards the array, and it is needed separately because the three geometries were measured to disagree without it: Points and GSplats returned input order silently, while Lines raised a reveal_center must be finite error naming a knob the caller never passed (its default centre is DERIVED from the vertices, so bad data reached the knob’s validator wearing the knob’s name). One shared validator, called by both scorers, is what makes the three agree — hence its home here rather than in either implementation.

Parameters:
  • coords – The coordinate array about to be scored, (N, d).

  • what – Caller-facing name of the array for the message (e.g. "coords", "vertices", "centers"), so the error blames the input the caller actually supplied.

Raises:

ValueError – If any entry is NaN or infinite.

luxar.validation.types.validate_integral_axis_indices(values: Any, name: str = 'spatial_dims') → None[source]

Refuse a non-integral axis index before it is silently truncated to one.

int(1.9) and np.asarray([1.9], dtype=np.intp) both give 1 without a word, so a fractional entry measures the reveal over a DIFFERENT column than the caller named — and, when reveal_center is given too, pairs that centre coordinate with the wrong axis. Every other malformed spatial_dims (empty, negative, repeated, nested, out of range) is already rejected; this was the one that got through wearing a plausible answer.

Silent on an integer-dtype input (the overwhelmingly common case) and on an empty one, which the callers’ own “must not be empty” rule reports better.

Parameters:
  • values – The candidate index sequence, before any int conversion.

  • name – Caller-facing name of the argument, for the message.

Raises:

ValueError – If any entry is finite-but-fractional, NaN, or infinite.

luxar.validation.types.is_position_array(obj: Any) → bool[source]

Check if object is a valid position array.

luxar.validation.types.is_color_array(obj: Any, n_points: int) → bool[source]

Check if object is a valid color array.

luxar.validation.types.is_transform_matrix(obj: Any) → bool[source]

Check if object is a valid transform matrix.

luxar.validation.types.validate_category_indices(values: ndarray, categories: List[str], context: str = 'values') → None[source]

Validate that array values are valid category indices.

For categorical dimensions, point coordinates should be integer indices into the categories list (0-indexed).

Parameters:
  • values – 1D array of values to validate

  • categories – List of category labels

  • context – Context string for error messages

Raises:

ValueError – If values contain invalid category indices

Write Validation

Detailed validation for data being written to Zarr, with helpful error messages.

Validation utilities with helpful error messages.

This module provides validation functions with detailed, user-friendly error messages that help users understand and fix issues quickly.

exception luxar.validation.base.ValidationError(message: str, suggestion: str | None = None)[source]

Custom validation error with helpful suggestions.

__init__(message: str, suggestion: str | None = None) → None[source]

Initialize validation error.

Parameters:
  • message – The error message

  • suggestion – Optional suggestion for fixing the error

luxar.validation.base.validate_node_name(name: Any, context: str = 'node name') → str[source]

Validate a scene-graph node name (a single zarr path segment).

This is the single chokepoint for node naming, shared by Node.__init__ (groups + all node objects), the add_points/add_lines/add_gsplats adders, and the compiler writers (via validate_node_path). Rules:

  • Must be a non-empty, non-whitespace-only string. An empty segment is the worst case: zarr.require_group("") resolves to the store ROOT group, so a node named "" would stamp type='points' onto the scene root and make the whole store unloadable.

  • Must not contain / — that is the zarr path separator; use add_group() for hierarchy.

  • Must not start with .. Zarr v2 reserves the dot-prefixed keys .zgroup / .zattrs / .zarray / .zmetadata for its own metadata objects; a node named .zgroup dies with a deep KeyError inside zarr and .zmetadata silently collides with consolidated metadata. We reject the entire dot-prefixed namespace (stricter than the exact reserved set, but safe: it also covers future zarr metadata keys and hidden dot-files that most tooling cannot see).

  • Must not be zarr.json. Format 3 replaced the dot-prefixed documents with a single zarr.json per node, and that name is NOT dot-prefixed — so the rule above, which covered every reserved key at format 2, stops being complete the moment anything writes format 3. Measured: the write does not silently corrupt the store, but it dies inside zarr with ValueError: delete_dir was passed a prefix='zarr.json' that is a file, which is exactly the deep-internal-error experience this function exists to replace with a clear one.

  • Must not contain control characters (\x00–\x1f, \x7f) — filesystem and JSON hazards for directory-backed scenes.

Parameters:
  • name – Candidate node name.

  • context – Context for error messages.

Returns:

The validated name (unchanged).

Raises:

ValidationError – If the name is invalid.

luxar.validation.base.validate_labels_for_writing(labels: Any, n_elements: int, context: str = 'labels', noun: str = 'Labels') → None[source]

Validate per-element string labels BEFORE any zarr write.

The CSR label serializer UTF-8-encodes each entry; a non-string entry used to die with a deep AttributeError after the node’s arrays were already on disk. This pre-flight validator runs in the writers’ fail-fast gate.

Parameters:
  • labels – Candidate labels. Must be a sequence (not a bare string) of str entries; None entries are allowed (null label).

  • n_elements – Expected number of elements.

  • context – Context for error messages.

  • noun – Capitalised channel name used in error messages. The keys channel shares this validator, and reporting its faults as label faults sends the author looking at the wrong argument.

Raises:

ValidationError – If labels are not a sequence of str/None with one entry per element.

luxar.validation.base.validate_color_dtype(colors: NDArray[Any], context: str = 'colors') → None[source]

Refuse a COLOR array whose dtype no writer can store (#1489).

A writable COLOR array is FLOATING POINT at any width (the encoder quantizes it, and HDR needs the range) or integer uint8 / uint16. Everything else is refused — a wider or signed integer, and also complex, which is np.number enough to pass _validate_numeric_finite_values() and then dies deep inside the per-channel encoder. A whitelist rather than a pair of special cases — the same SHAPE as the two other places this rule is spelled out, encoding._encoders.base._validate_input and luxar.gsplats.lift._reject_unwritable_color_dtype. Only the encoder’s integer wording is reproduced verbatim (it is the rule being hoisted); the lift phrases its own refusal differently and raises a bare ValueError.

Hoisted into the pre-write sweep because the encoder’s copy fires from inside the colours write, one dataset after positions — or a whole LOD child in — stranding a partial node (#1437’s class). Its only caller is validate_colors_for_writing(), which is what every pre-write path runs — including the gsplats lod_group= gate, which asks the WHOLE validator of every substitutive level rather than this rule alone, so that a wrapped call cannot answer with a different fault than the flat one. It stays a named function rather than an inline block because the rule is cited by name from the docs that describe it.

Bool never reaches here: it is not an numpy.integer subtype, and _validate_numeric_finite_values() has already refused it as non-numeric.

luxar.validation.base.validate_positions_for_writing(positions: NDArray[Any], context: str = 'positions') → Tuple[int, int][source]

Validate positions array for writing to Zarr.

Parameters:
  • positions – Positions array to validate

  • context – Context for error messages

Returns:

Tuple of (n_points, n_dims). NOTE the array itself is NOT returned and is NOT dtype-converted: the validator deliberately accepts any numeric dtype (float32/float64/int) and verifies SHAPE + FINITENESS only. Callers that need float32 storage MUST convert via ensure_float32 (or equivalent) AFTER calling this function — see the test_validate_positions_dtype_not_checked regression-lock test for the pinned contract.

Raises:

ValidationError – If positions are invalid (wrong shape, empty, zero-dimensional, or containing NaN / ±Inf).

luxar.validation.base.validate_colors_for_writing(colors: NDArray[Any], n_points: int, context: str = 'colors', channels: tuple[int, ...] = (3,)) → None[source]

Validate colors array for writing.

Colors are RGB (n, 3) — or RGBA (n, 4) for geometry types that have opted into per-element alpha (channels=(3, 4); all three geometry types since their volumetric phases: gsplats, points, lines). The alpha column is a per-element opacity in [0, 1], not an HDR emission channel.

DTYPE is checked here, via validate_color_dtype() — unlike the positions sibling, which deliberately accepts any numeric dtype (see validate_positions_for_writing()), and like validate_faces_for_writing(), which likewise refuses a non-integer array outright. A COLOR array’s dtype is one the encoder REFUSES rather than converts: floating point is free (it quantizes), but an integer array must already be uint8 or uint16. That refusal used to land mid-write, after positions (or a whole LOD child) was on disk — the #1437 stranding class this pre-write sweep exists to close. The check runs LAST here so its precedence matches the encoder’s own; see the comment at the call site.

Parameters:
  • colors – Colors array to validate

  • n_points – Expected number of points

  • context – Context for error messages

  • channels – Accepted channel counts (broadcast rows (1, c) allowed for each accepted c)

Raises:

ValidationError – If colors are invalid

luxar.validation.base.validate_radii_for_writing(radii: NDArray[Any] | float | int, n_points: int, context: str = 'radii') → None[source]

Validate radii (array or broadcast scalar) for writing.

Scalars are validated against the same finite/positive rules as arrays (mirroring validate_widths_for_writing()); a scalar radii=-1.0 or radii=float('nan') used to slip through to the encoder after the positions were already written.

Parameters:
  • radii – Radii array or a scalar broadcast to all points

  • n_points – Expected number of points

  • context – Context for error messages

Raises:

ValidationError – If radii are invalid

luxar.validation.base.validate_widths_for_writing(widths: Any, n_vertices: int, context: str = 'widths') → None[source]

Validate line widths for writing.

Accepts a per-vertex 1D array of shape (n_vertices,), a broadcast array of shape (1,), or a scalar width. The Lines sibling of validate_radii_for_writing() (Points), per the three-geometry symmetry rule.

Parameters:
  • widths – Per-vertex widths array, broadcast (1,) array, or scalar

  • n_vertices – Expected number of vertices

  • context – Context for error messages

Raises:

ValidationError – If widths are invalid

luxar.validation.base.validate_cholesky_for_writing(cholesky_factors: NDArray[Any], n_dims: int, context: str = 'cholesky_factors') → None[source]

Validate packed lower-triangular Cholesky factors for writing.

The GSplats sibling of validate_radii_for_writing() (Points) and validate_widths_for_writing() (Lines), per the three-geometry symmetry rule. The gsplat assembly gate normalizes SHAPE before calling (a 1D uniform (k,) input is reshaped to (1, k)), so this validator accepts a 2D array of shape (n_splats, k) OR the uniform/broadcast (1, k) form, with k = n_dims * (n_dims + 1) // 2.

Two checks, mirroring the amplitudes/radii/widths validators:

  • Finiteness across the WHOLE packed array (a single NaN/Inf used to pass the shape-only gate and die deep in the encoder AFTER centers were already on disk, leaving a half-written node).

  • Strictly positive diagonal. The packed diagonal slots are np.cumsum(np.arange(1, n_dims + 1)) - 1 (e.g. [0, 2, 5] for n_dims=3); the format invariant is diag > 0 (positive, scale-like). A zero/negative diagonal is a degenerate/singular covariance that the uint8 log encoder silently clamps to 0 → a sliver splat with no diagnostic.

Parameters:
  • cholesky_factors – Packed lower-triangular factors, shape (n_splats, k) or (1, k).

  • n_dims – Number of spatial dimensions (used to locate the diagonal slots; the caller already has it).

  • context – Context for error messages.

Raises:

ValidationError – If the factors contain NaN/Inf or any non-positive diagonal value.

luxar.validation.base.validate_sharpness_for_writing(sharpness: NDArray[Any] | float | int, n_points: int, context: str = 'sharpness') → None[source]

Validate sharpness (array or broadcast scalar) for writing.

Scalars are validated against the same [SHARPNESS_MIN, SHARPNESS_MAX] bounds as arrays (mirroring validate_widths_for_writing()); a scalar sharpness=5.0 used to be accepted while the equivalent array was rejected.

Parameters:
  • sharpness – Sharpness array or a scalar broadcast to all points

  • n_points – Expected number of points

  • context – Context for error messages

Raises:

ValidationError – If sharpness values are invalid

luxar.validation.base.validate_vertices_for_writing(vertices: NDArray[Any], context: str = 'vertices') → None[source]

Enforce the mesh vertex-count ceiling before any zarr write.

Shape and finiteness are NOT re-checked here — the shared coordinate path (validate_positions_for_writing()) owns those for every geometry type. This validator’s sole job is the n_vertices <= MAX_MESH_VERTICES cap, which is specific to mesh because a mesh’s pick elementId is the raw gl_VertexID rather than an element-texture index (see MAX_MESH_VERTICES).

The viewer’s loader rejects an over-cap store too. Mirroring it at write time is the point: without this, the public add_mesh path could emit a store that Luxar’s own loader then refuses — a bound that exists only on the read side is not a bound, and the failure would surface far from its cause.

Parameters:
  • vertices – The mesh (V, D) vertex array.

  • context – Context for error messages.

Raises:

ValidationError – If the vertex count exceeds the cap.

luxar.validation.base.mesh_decoded_value_count(n_vertices: int, n_dims: int, n_faces: int, *, normals: Any = None, colors: Any = None, scalars: Any = None, uvs: Any = None) → int[source]

Total LOGICAL values a mesh’s arrays decode to.

The one quantity the write side can compute exactly, and the only term validate_mesh_decode_budget() charges. Dtype-independent, because every decoder-routed array materializes as float32 in the viewer — so no encoder behaviour (narrowing, LUT, array_ref dedup) has to be predicted here.

A broadcast colour or a scalar scalars is counted at its n_vertices expansion, not its one stored row: that expansion is real and it is what the loader charges.

Shared so the flat and reveal-ladder checks cannot drift apart.

luxar.validation.base.validate_mesh_ladder_decode_budget(levels: Any, context: str = 'mesh reveal ladder') → None[source]

Refuse a reveal ladder whose LEVELS SUM over the viewer’s ceiling.

The one place the flat check is not merely incomplete but multiplicatively so, which is why it gets its own validator rather than a docstring caveat.

The viewer’s progressive loader concatenates a ladder’s additive_<i> levels into one node’s buffers and keeps every level resident, so it charges their SUM against a single budget (mesh-progressive-loader.ts). The write side otherwise never sees the sum: the flat check runs once on the authored mesh, and each level’s own write_mesh re-checks only itself. A shell ladder duplicates every vertex on a shell boundary, so its total exceeds the flat mesh by a factor that grows with the level count. A comfortably-under- budget surface therefore wrote cleanly and then failed to load, which is precisely the class of failure this family of checks exists to prevent.

Sound in the same direction as its flat sibling: it charges only decoded values, so it can never refuse a ladder the loader would admit.

Parameters:
  • levels – The per-level payload dicts, each with vertices / faces and the optional channels, as built for the multi-LOD writer.

  • context – Context for the error message.

Raises:

ValidationError – If the levels’ combined decoded footprint exceeds the ceiling.

luxar.validation.base.validate_mesh_decode_budget(n_vertices: int, n_dims: int, n_faces: int, *, normals: Any = None, colors: Any = None, scalars: Any = None, uvs: Any = None, texture_decoded_bytes: int = 0, context: str = 'mesh') → None[source]

Refuse a mesh whose declared footprint provably exceeds the viewer’s ceiling.

The general rule this enforces: Luxar must not let you author a scene that provably will not load. A bound enforced only on the read side is not a bound — it is a delayed failure, surfacing in a browser far from the add_mesh call that caused it.

The viewer’s mesh loader is whole-node: it fetches and decodes every array in full, so it gates admission on a per-node byte ceiling (MESH_DECODE_BUDGET_BYTES) computed from .zarray metadata BEFORE fetching a chunk. A store above it does not render slowly — it does not render.

This check is deliberately weaker than that gate, and the asymmetry is the whole design. A hard error that over-counted would refuse a store the viewer would happily accept, which is a worse bug than the one it fixes. So it charges only the term it can compute exactly:

  • Charged: every array’s DECODED size — its logical value count times 4 bytes, since every decoder-routed array materializes as float32 in the viewer — plus the texture’s exactly-known decoded footprint. This is dtype-independent, so no encoder behaviour has to be predicted. Note a broadcast colour or a scalar scalars is charged at its logical n_vertices expansion, not its one stored row: that expansion is real, and it is what the loader charges.

  • Not charged: the STORED bytes (the encoder’s dtype narrowing, LUT and array_ref dedup choices would all have to be replicated here, coupling this validator to encoder internals for the sake of a term the loader adds on top anyway); the largest per-chunk allocation (bounded by the ~64 KB chunk policy, negligible against 512 MiB); and the label / image-label CSR arrays. All three are bounded constants.

A reveal ladder is the one case where the shortfall was multiplicative rather than a bounded constant — this validator sees one authored mesh at a time, while the viewer concatenates every level into one resident node buffer and charges their sum. That is handled separately rather than omitted: see validate_mesh_ladder_decode_budget().

Every omission is a term the loader ADDS, so this is a strict lower bound on the loader’s accounting: anything refused here is certainly refused there. The cost of that soundness is completeness — a mesh between roughly half the ceiling and the ceiling still writes and is still refused by the viewer. Half a bound enforced at the right moment beats a whole one enforced too late, and beats a whole one that sometimes cries wolf.

The authority for the full accounting is packages/luxar-viewer/src/data/mesh/preflight.ts. If that changes, this stays sound as long as it only ever charges fewer terms.

Parameters:
  • n_vertices – Vertex count.

  • n_dims – Vertex dimensionality (vertices is (n_vertices, n_dims)).

  • n_faces – Triangle count (faces decodes to 3 * n_faces indices).

  • normals – The normals array, or None. Only presence is read.

  • colors – The colors array or broadcast colour, or None.

  • scalars – The scalars array or broadcast scalar, or None.

  • uvs – The UV array, or None. Only presence is read.

  • texture_decoded_bytes – Exact decoded texture footprint, or zero.

  • context – Context for the error message.

Raises:

ValidationError – If the decoded footprint alone exceeds the ceiling.

luxar.validation.base.validate_faces_for_writing(faces: Any, n_vertices: int, context: str = 'faces') → None[source]

Validate mesh triangle indices for writing.

Accepts the two documented layouts — an (F, 3) triangle array or a flat (3F,) array. The closest precedent is the line_type='indexed' index gate in the lines writer, which already encodes each of these traps; this is the shared-validator form of the same rules.

Every check corresponds to a way bad indices fail silently rather than loudly, since the writer casts with .astype(np.uint32) and the viewer hands the result straight to kernels that index without bounds-checking:

  • integer dtype — a float array truncates on cast (1.7 → 1), producing triangles the author never wound.

  • min >= 0 — a negative index wraps to ~4 billion on the unsigned cast.

  • max < n_vertices — an out-of-range index reads past the vertex buffer; in the Rust kernel (panic = "abort") that takes down the whole WASM module rather than one node.

  • F >= 1 — a mesh with no faces draws nothing; an indexed draw never references a vertex no face names.

Parameters:
  • faces – Triangle indices, (F, 3) or flat (3F,).

  • n_vertices – Vertex count the indices must address.

  • context – Context for error messages.

Raises:

ValidationError – If the faces array is malformed or out of range.

luxar.validation.base.validate_normals_for_writing(normals: NDArray[Any], n_vertices: int, context: str = 'normals') → None[source]

Validate per-vertex mesh normals for writing.

Shape (V, 3) and finiteness are required. Normals are always 3-component even for an nD mesh: they are a display-space quantity, and which three dimensions they belong to is recorded separately by normal_dims (see validate_normal_dims_for_writing()).

Zero-length normals are warned about, not rejected. Degenerate triangles legitimately produce them, and the renderer already handles the case: the stored-normal fragment path epsilon-guards its normalize and falls back to a screen-space-derivative flat normal. The warning exists so authors fix the source rather than lean on that fallback, because the fallback is pointwise — on a shared-vertex mesh the interpolated normal near a degenerate vertex blends toward its neighbours, so shading there is locally distorted rather than cleanly flat.

Parameters:
  • normals – Per-vertex normals, shape (V, 3).

  • n_vertices – Expected vertex count.

  • context – Context for error messages.

Raises:

ValidationError – If normals are the wrong shape or non-finite.

luxar.validation.base.validate_normal_dims_for_writing(normal_dims: Any, ndim: int, context: str = 'normal_dims') → None[source]

Validate the companion attr naming which dimensions normals describe.

Required whenever normals is supplied and rejected when it is not — that pairing is enforced by the caller, which knows both. This validator checks the value itself: exactly 3 entries, integral, distinct, each a valid dimension index.

Normals are stored (V, 3) because they are only meaningful for the three displayed dimensions, so the store must say which three. Storing them against an implicit “first three dimensions” is the bug this attr exists to prevent: for a (t, x, y, z) mesh the first three dims are (t, x, y) and a normal against them is meaningless. The viewer compares this list to the active displayDims and falls back to flat normals when they differ, so a wrong-but-well-formed list degrades shading rather than corrupting it — but an ill-formed one would index out of bounds.

Parameters:
  • normal_dims – Candidate dimension-index triple.

  • ndim – The mesh’s dimensionality (indices must be < this).

  • context – Context for error messages.

Raises:

ValidationError – If the triple is malformed or out of range.

luxar.validation.base.TEXTURE_ENCODINGS: dict = {'jpeg': 'uint8', 'ktx2': 'uint8 RGB/RGBA (encoded by optional toktx tooling)', 'png': 'uint8 or uint16', 'raw': 'uint8, uint16, or any float (HDR)', 'webp': 'uint8'}

Texture payload encodings and the on-disk dtypes each admits.

The asymmetry is not a design choice: PNG, JPEG and WebP are integer codecs and there is no browser-native float codec, so HDR implies ``raw``. Within raw the accepted set follows the element-COLOR precedent (validate_color_dtype()) rather than inventing a second rule — float of any width is writable because it quantizes, and an integer array must already be uint8 or uint16.

luxar.validation.base.MAX_MESH_TEXTURE_SIZE = 16384

Per-axis pixel ceiling for a mesh texture.

A texture larger than the GPU’s MAX_TEXTURE_SIZE on either axis is silently clamped at upload, so the mesh renders with the wrong image and nothing says why. Refused at authoring time under the same policy as the decode budget: we should not be able to write a scene that provably breaks.

This is the shape the byte budget cannot see — a 100000 x 2 texture is 800 KB and passes every accounting, then fails to upload. 16384 is the limit on current desktop hardware, and is deliberately a fixed number rather than a probe: an authoring-time check has no GPU in scope, and a device-dependent ceiling would make a store load on one machine and fail on another.

MIRROR: MAX_MESH_TEXTURE_SIZE in packages/luxar-viewer/src/data/mesh/preflight.ts.

luxar.validation.base.MESH_TEXTURE_DECODE_BUDGET_BYTES = 536870912

Per-node decoded-byte ceiling the viewer admits a mesh under.

MIRROR: MESH_DECODE_BUDGET_BYTES in packages/luxar-viewer/src/config/constants.ts.

Checked here against the TEXTURE ALONE for a local fail-fast refusal. The mesh writer separately passes this exact decoded footprint to validate_mesh_decode_budget(), which combines it with the geometry and UV terms before any node group is created.

It exists because the per-axis limit above does NOT imply this one. 16000x16000 is under 16384 on both axes and still decodes to 1.02 GB — twice the ceiling — so without this a perfectly legal-looking authoring call produces a store that every viewer rejects at load, and the author finds out from a user.

luxar.validation.base.validate_uvs_for_writing(uvs: Any, n_vertices: int, context: str = 'uvs') → None[source]

Validate per-vertex texture coordinates before any zarr write.

Shape (V, 2) and finite. Values are deliberately not clamped to [0, 1]: a UV outside the unit square is meaningful under texture_wrap="repeat" — tiling a detail texture is the ordinary reason to author one — and clamping here would silently break it. Out-of-range UVs under clamp wrapping sample the edge texel, which is that wrap mode’s documented behaviour rather than an error.

Non-finite IS refused: a NaN UV samples an undefined texel, and the artifact (one triangle wearing an arbitrary smear of the texture) is hard to attribute back to the data that caused it.

Parameters:
  • uvs – The (V, 2) texture-coordinate array.

  • n_vertices – Vertex count the array must match.

  • context – Context for error messages.

Raises:

ValidationError – On a wrong shape, a length mismatch, or a non-finite value.

luxar.validation.base.validate_texture_for_writing(texture: Any, encoding: str, width: int | None = None, height: int | None = None, channels: int | None = None, color_space: str = 'srgb', context: str = 'texture', ktx2_mode: str = 'uastc', ktx2_quality: int | None = None, ktx2_rdo_l: float | None = None, ktx2_zcmp: int | None = None) → Tuple[int, int, int][source]

Validate a mesh texture payload and resolve its declared dimensions.

Two physical payload shapes, split into _resolve_raw_texture_dims() and _resolve_encoded_texture_dims(), because the declared dimensions matter identically to both and are what the viewer’s admission gate spends:

  • raw and authoring-time ktx2 — an (H, W, C) array. Dimensions come off the shape, so caller-supplied width/height/channels must agree with it.

  • png / webp / jpeg — a 1-D uint8 array of encoded bytes, the same shape image_label_bytes already uses. Dimensions cannot be read from the payload without decoding it, so they are required.

Why the declared dimensions are load-bearing rather than metadata. A compressed image is a decompression bomb, and the viewer loads arbitrary ?src= URLs: a 100 KB JPEG can decode to hundreds of megabytes. The viewer’s mesh preflight budgets a node before fetching a chunk, from the declared numbers — so a store that omits or understates them cannot be admitted safely, and refusing here is part of what makes the read-side gate mean anything. The viewer re-checks the decoded bitmap against these values.

Parameters:
  • texture – The payload — an (H, W, C) array, or 1-D uint8 bytes.

  • encoding – One of TEXTURE_ENCODINGS.

  • width – Declared width. Required for encoded payloads.

  • height – Declared height. Required for encoded payloads.

  • channels – Declared channel count. Required for encoded payloads.

  • color_space – srgb or linear. HDR raw values require linear.

  • context – Context for error messages.

  • ktx2_mode – Basis encoding mode for KTX2 authoring.

  • ktx2_quality – Optional mode-specific KTX2 quality.

  • ktx2_rdo_l – Optional UASTC RDO lambda.

  • ktx2_zcmp – Optional UASTC zstd level.

Returns:

(height, width, channels), resolved.

Raises:

ValidationError – On an unknown encoding, a dtype the encoding cannot carry, a bad channel count, missing or disagreeing dimensions, or a non-finite float value.

luxar.validation.base.validate_zarr_attributes(attrs: dict, is_root: bool = False) → None[source]

Validate that all required Zarr attributes are present.

Ensures that zarr groups have the required metadata attributes according to the Luxar Zarr format specification.

Parameters:
  • attrs – Dictionary of zarr attributes

  • is_root – Whether this is the root scene group

Raises:

ValidationError – If required attributes are missing or invalid

nD Validation

Validation for n-dimensional data coverage and slicing.

Validation for nD dimensional consistency in scenes.

This module provides validation to ensure all point groups in a scene have consistent coverage of non-displayed dimensions.

exception luxar.validation.nd.DimensionalCoverageError(message: str, group_name: str, missing_coverage: Dict[str, Set[float]] | None = None)[source]

Error raised when point groups have inconsistent dimensional coverage.

__init__(message: str, group_name: str, missing_coverage: Dict[str, Set[float]] | None = None) → None[source]

Initialize dimensional coverage error.

Parameters:
  • message – Error message

  • group_name – Name of the group with coverage issues

  • missing_coverage – Dict mapping dimension names to missing values

luxar.validation.nd.validate_dimensional_coverage(scene_dimensions: Dimensions, point_groups: Dict[str, NDArray[np.float32]]) → None[source]

Validate that all point groups have consistent dimensional coverage.

For non-displayed dimensions (like Time and Channel), ensures that either: 1. All groups have points at the same set of dimension values 2. Groups are properly marked for handling (future: static flag)

Parameters:
  • scene_dimensions – Scene dimension specifications

  • point_groups – Dict mapping group names to position arrays

Raises:

DimensionalCoverageError – If groups have inconsistent coverage

luxar.validation.nd.broadcast_to_all_slices(positions: PositionArray, colors: Float32Array | None, radii: Float32Array | None, scene_dimensions: Dimensions) → Tuple[PositionArray, Float32Array | None, Float32Array | None][source]

Broadcast points to cover all non-displayed dimension values.

Helper function that replicates points across all combinations of non-displayed dimensions (e.g., Time and Channel).

Parameters:
  • positions – Original position array

  • colors – Original colors array (optional)

  • radii – Original radii array (optional)

  • scene_dimensions – Scene dimension specifications

Returns:

Tuple of (broadcasted_positions, broadcasted_colors, broadcasted_radii)

Category Validation

Validation for categorical dimensions, extracted to avoid circular imports.

Shared category validation for categorical dimensions.

This module provides validation for category lists used in categorical dimensions. It is intentionally separate to avoid circular imports between core and validation modules.

This module provides validation for category lists used in categorical dimensions. Categories must be non-empty lists of unique strings with reasonable length limits.

Example:

from luxar.validation.category_validation import validate_categories

# Valid categories
categories = validate_categories(["DAPI", "GFP", "mCherry"])

# Raises ValueError for duplicates
try:
    validate_categories(["A", "A", "B"])
except ValueError as e:
    print(e)  # "Duplicate category names found: A"
luxar.validation.category_validation.validate_categories(categories: List[str] | None) → List[str] | None[source]

Validate category list for categorical dimensions.

Categories define the discrete values for a categorical dimension. Each category is a string label (e.g., [“DAPI”, “GFP”, “mCherry”]).

Parameters:

categories – List of category labels, or None for non-categorical

Returns:

Validated category list (or None if input is None)

Raises:
  • TypeError – If categories is not a list or None

  • ValueError – If categories is invalid (empty, duplicates, too long, etc.)

Examples

>>> validate_categories(["DAPI", "GFP", "mCherry"])
['DAPI', 'GFP', 'mCherry']
>>> validate_categories(None)
None
>>> validate_categories(["A", "A"])
ValueError: duplicate category name...