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:
ValueErrorCustom validation error with helpful suggestions.
- exception luxar.validation.DimensionalCoverageError(message: str, group_name: str, missing_coverage: Dict[str, Set[float]] | None = None)[source]
Bases:
ValueErrorError 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) andvalidate_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, withk = 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]forn_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 (seevalidate_positions_for_writing()), and likevalidate_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 beuint8oruint16. That refusal used to land mid-write, afterpositions(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 acceptedc)
- 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
AttributeErrorafter 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
strentries;Noneentries are allowed (null label).n_elements – Expected number of elements.
context – Context for error messages.
noun – Capitalised channel name used in error messages. The
keyschannel 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), theadd_points/add_lines/add_gsplatsadders, and the compiler writers (viavalidate_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 stamptype='points'onto the scene root and make the whole store unloadable.Must not contain
/— that is the zarr path separator; useadd_group()for hierarchy.Must not start with
.. Zarr v2 reserves the dot-prefixed keys.zgroup/.zattrs/.zarray/.zmetadatafor its own metadata objects; a node named.zgroupdies with a deepKeyErrorinside zarr and.zmetadatasilently 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 singlezarr.jsonper 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 withValueError: 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 thetest_validate_positions_dtype_not_checkedregression-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 scalarradii=-1.0orradii=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 scalarsharpness=5.0used 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 ofvalidate_radii_for_writing()(Points), per the three-geometry symmetry rule.- Parameters:
widths – Per-vertex widths array, broadcast
(1,)array, or scalarn_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).Tsets both the support of the shifted Gaussian and its normalization1 / (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
Tis 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 consumesT²as its own uniform (uTruncateSq, the fragment discard threshold), so aTthat 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 > 0alone is not sufficient: below some thresholdexp(-T^2/2)rounds to exactly 1.0, so1 / (1 - C)isinfand 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 aroundT = 3e-4— four orders of magnitude before float64’s ~1.5e-8. The viewer’s read-time clamp (MIN_TRUNCATION_RADIUSinrendering/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’sabsorption(kappa) attribute.- Returns:
Valid blending mode
- Raises:
ValueError – If mode is not a valid blending mode
TypeError – If mode is not a string
- luxar.validation.validate_layer_order(order: Any) int[source]
Validate an authored cross-layer draw order.
layer_orderstates where a layer draws relative to the other layers it overlaps: higher = nearer the camera = drawn later, the CSSz-index/ Illustrator convention. Seedocs/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
boolis refused even thoughisinstance(True, int)is true in Python:layer_order=Truealmost 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
orderis not an integer (boolincluded).ValueError – If
orderis 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:
ValueError – If style is not a known join style
TypeError – If style is not a string
- 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.
linearalso enables mipmaps + anisotropy at upload;nearestis 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_filterattr.
- luxar.validation.types.validate_texture_wrap(value: Any, name: str) str[source]
Validate a mesh
texture_wrapattr.
- luxar.validation.types.validate_texture_color_space(value: Any, name: str) str[source]
Validate a mesh
texture_color_spacedeclaration.
- 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).luxaris the house shader — the light-free, view-anchored key ofMESH_NODE_SPEC.md§6.2 — and what every mesh renders with when the attr is absent.physicalopts a mesh into three.js’s own physically based material, lit by the viewer’s scene environment; it is the only family that understandsPHYSICAL_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_dataflag. Onlytransmissionandiormean anything on their own; three compilesthickness, the twoattenuation_*knobs anddispersioninside itsUSE_TRANSMISSIONblock, andrefract_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 withouttransmission > 0(seePHYSICAL_TRANSMISSION_DEPENDENT_ATTRS).iorstands 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_ATTRSthat renders NOTHING unlesstransmissionis 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_colorrides along because three’s default sheen colour is black, sosheenalone 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
iorbounds: 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_cutoffis not here — it maps onto three’salphaTest— and neither isshading, whose smooth/flat half is a property of any lit surface (only the unlitnoneis 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
materialattr againstMESH_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
iorin 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
#rrggbbcolour 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
Ttwice — asuTruncateand asuTruncateSq = T²(the fragment discard threshold) — so boundingTat float32’s max is not enough: everything in(sqrt(float32.max), float32.max]uploads a finiteuTruncatenext to an infiniteuTruncateSq. 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).Tsets both the support of the shifted Gaussian and its normalization1 / (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
Tis 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 consumesT²as its own uniform (uTruncateSq, the fragment discard threshold), so aTthat 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 > 0alone is not sufficient: below some thresholdexp(-T^2/2)rounds to exactly 1.0, so1 / (1 - C)isinfand 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 aroundT = 3e-4— four orders of magnitude before float64’s ~1.5e-8. The viewer’s read-time clamp (MIN_TRUNCATION_RADIUSinrendering/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_dataand kin).The same acceptance as
validate_layer()—boolornumpy.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=1reads like a slider value, and a flag that also accepts numbers invites exactly that confusion)
- luxar.validation.types.validate_link(value: Any) str[source]
Validate a node’s
linkURL 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
copytemplate (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:
TypeError – If value is not a string
ValueError – If the template is empty or over-long
- luxar.validation.types.validate_link_target(value: Any) str[source]
Validate a node’s
link_targetbrowsing context (issue #1917).Only the two keywords that imply
noopenerare accepted. Any other value — including a near-miss like"_blank "— is a named target, which the browser opens with a livewindow.openerthe destination can use to cross-origin navigate the viewer tab.- Parameters:
value – Value to validate as a browsing context
- Returns:
The target unchanged
- Raises:
TypeError – If value is not a string
ValueError – If value is not one of
LINK_TARGETS
- 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’sabsorption(kappa) attribute.- Returns:
Valid blending mode
- Raises:
ValueError – If mode is not a valid blending mode
TypeError – If mode is not a string
- luxar.validation.types.validate_layer_order(order: Any) int[source]
Validate an authored cross-layer draw order.
layer_orderstates where a layer draws relative to the other layers it overlaps: higher = nearer the camera = drawn later, the CSSz-index/ Illustrator convention. Seedocs/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
boolis refused even thoughisinstance(True, int)is true in Python:layer_order=Truealmost 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
orderis not an integer (boolincluded).ValueError – If
orderis 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:
ValueError – If style is not a known join style
TypeError – If style is not a string
- 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
radialreveal 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_centerfinite 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 areveal_center must be finiteerror 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)andnp.asarray([1.9], dtype=np.intp)both give1without a word, so a fractional entry measures the reveal over a DIFFERENT column than the caller named — and, whenreveal_centeris given too, pairs that centre coordinate with the wrong axis. Every other malformedspatial_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.
- 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), theadd_points/add_lines/add_gsplatsadders, and the compiler writers (viavalidate_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 stamptype='points'onto the scene root and make the whole store unloadable.Must not contain
/— that is the zarr path separator; useadd_group()for hierarchy.Must not start with
.. Zarr v2 reserves the dot-prefixed keys.zgroup/.zattrs/.zarray/.zmetadatafor its own metadata objects; a node named.zgroupdies with a deepKeyErrorinside zarr and.zmetadatasilently 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 singlezarr.jsonper 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 withValueError: 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
AttributeErrorafter 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
strentries;Noneentries are allowed (null label).n_elements – Expected number of elements.
context – Context for error messages.
noun – Capitalised channel name used in error messages. The
keyschannel 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 alsocomplex, which isnp.numberenough 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_inputandluxar.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 bareValueError.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 isvalidate_colors_for_writing(), which is what every pre-write path runs — including the gsplatslod_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.integersubtype, 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 thetest_validate_positions_dtype_not_checkedregression-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 (seevalidate_positions_for_writing()), and likevalidate_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 beuint8oruint16. That refusal used to land mid-write, afterpositions(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 acceptedc)
- 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 scalarradii=-1.0orradii=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 ofvalidate_radii_for_writing()(Points), per the three-geometry symmetry rule.- Parameters:
widths – Per-vertex widths array, broadcast
(1,)array, or scalarn_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) andvalidate_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, withk = 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]forn_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 scalarsharpness=5.0used 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 then_vertices <= MAX_MESH_VERTICEScap, which is specific to mesh because a mesh’s pickelementIdis the rawgl_VertexIDrather than an element-texture index (seeMAX_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_meshpath 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_refdedup) has to be predicted here.A broadcast colour or a scalar
scalarsis counted at itsn_verticesexpansion, 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 ownwrite_meshre-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/facesand 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_meshcall 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.zarraymetadata 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
scalarsis charged at its logicaln_verticesexpansion, 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_refdedup 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 (
verticesis(n_vertices, n_dims)).n_faces – Triangle count (
facesdecodes to3 * n_facesindices).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 theline_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 bynormal_dims(seevalidate_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
normalizeand 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
normalsdescribe.Required whenever
normalsis 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 activedisplayDimsand 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
rawthe 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_SIZEon 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 2texture 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_SIZEinpackages/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_BYTESinpackages/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 undertexture_wrap="repeat"— tiling a detail texture is the ordinary reason to author one — and clamping here would silently break it. Out-of-range UVs underclampwrapping sample the edge texel, which is that wrap mode’s documented behaviour rather than an error.Non-finite IS refused: a
NaNUV 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:rawand authoring-timektx2— an(H, W, C)array. Dimensions come off the shape, so caller-suppliedwidth/height/channelsmust agree with it.png/webp/jpeg— a 1-Duint8array of encoded bytes, the same shapeimage_label_bytesalready 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-Duint8bytes.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 –
srgborlinear. HDR raw values requirelinear.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...