Source code for luxar.typing_utils.constants

"""Constants used throughout Luxar.

This module centralizes all magic numbers and constants to improve
maintainability and provide clear documentation of their purposes.
"""

import math
from typing import Final

from ._format_contract import LINE_JOIN_STYLES as _LINE_JOIN_STYLES
from ._format_contract import LOD_SELECTORS as _LOD_SELECTORS
from ._format_contract import SCENE_FORMAT_VERSION

# Version constants (single-sourced from format-contract/contract.yaml)
LUXAR_VERSION_CURRENT: Final[str] = SCENE_FORMAT_VERSION
DEFAULT_ZARR_VERSION: Final[str] = SCENE_FORMAT_VERSION  # Alias for default version

# Root-level groups that are NOT scene nodes.
#
# ``environment`` holds a baked environment map (``luxar env bake`` /
# ``luxar env attach``; ``MESH_PHYSICAL_MATERIALS_SPEC.md`` §3.3). It carries no
# ``type`` and no ``kind`` attr — the viewer's node discovery skips exactly such
# groups as metadata sidecars — and it is EXCLUDED from the scene ``content_hash``:
# the map is derived FROM the scene, and recording the digest it was baked
# against only works if attaching the map leaves that digest alone. ``fitting`` /
# ``provenance`` / ``pipeline`` are the bookkeeping buckets a standalone
# ``.gsplats.zarr`` root carries. Every general Python walker that enumerates
# root children as nodes consults this set (``io/reader.py``,
# ``cli/info_command.py``); the compiler refuses a USER node under any of these
# names.
ENVIRONMENT_GROUP: Final[str] = "environment"
RESERVED_ROOT_GROUPS: Final[frozenset[str]] = frozenset(
    {ENVIRONMENT_GROUP, "fitting", "provenance", "pipeline"}
)

# Rendering constants
OPACITY_MIN: Final[float] = 0.0
OPACITY_MAX: Final[float] = 1.0
DEFAULT_OPACITY: Final[float] = 1.0

GAMMA_MIN: Final[float] = 0.1  # Symmetric: gamma and 1/gamma have equal range
GAMMA_MAX: Final[float] = 10.0  # Symmetric: gamma and 1/gamma have equal range
DEFAULT_GAMMA: Final[float] = 1.0

INTENSITY_MIN: Final[float] = 0.0
INTENSITY_MAX: Final[float] = 100.0

OFFSET_MIN: Final[float] = -10.0
OFFSET_MAX: Final[float] = 10.0

# Blending modes
DEFAULT_BLENDING_MODE: Final[str] = "additive"
# Viewer-side defaults are applied only after root→leaf attribute composition:
# create-points-node.ts/create-lines-node.ts/create-gsplats-node.ts use additive,
# while create-mesh-node.ts uses opaque. Keep the source-lock test in
# io/tests/_compiler/test_blending_warnings.py in sync with those consumers.
DEFAULT_BLENDING_MODE_BY_GEOMETRY: Final[dict[str, str]] = {
    "points": "additive",
    "lines": "additive",
    "gsplats": "additive",
    "mesh": "opaque",
}

# Line join style (issue #790) — the strategy the LINE vertex stage uses at a
# degree-2 polyline joint. Lines-only: it has no meaning for points, gsplats or
# mesh, none of which build a screen-space quad per element.
#
# "none"  leave the two quads alone, so the turn leaves an uncovered circular
#         sector on the outside of the bend and a double-covered lens inside.
# "miter" rotate each quad's end edge onto the shared miter edge so the two
#         TILE. Coverage becomes a partition, so there is nothing to sum and
#         every blending mode is correct by construction.
#
# Single-sourced from `format-contract/contract.yaml::line_join_styles`; the
# viewer's `types/line-join.ts` reads the same projection, so the spellings
# cannot drift (the default is still hand-kept on both sides, see below).
LINE_JOIN_STYLES: Final[frozenset[str]] = frozenset(_LINE_JOIN_STYLES)
# Documentation of the shared default, deliberately WITHOUT a reader here: the
# writer must not bake a join style into the file, or an unset node would freeze
# today's default forever and the viewer could never move it. Not dead code.
DEFAULT_LINE_JOIN: Final[str] = "miter"

# Per-element interaction templates (issue #1917).
#
# Browsing contexts a node's `link_target` may name. Restricted to the two
# keywords that imply `noopener`: any OTHER value is a *named* target, which
# hands the opened page a live `window.opener` it can use to cross-origin
# navigate the viewer tab (reverse tabnabbing). The viewer enforces the same
# two, letter-for-letter, in `core/app/interaction/element-actions.ts`; keep them
# in step.
LINK_TARGETS: Final[frozenset[str]] = frozenset({"_blank", "_self"})
DEFAULT_LINK_TARGET: Final[str] = "_blank"

# URL schemes a `link` template may resolve to. An ALLOWLIST, not a denylist:
# the viewer NAVIGATES to this URL rather than rendering it, and `.zattrs` is
# untrusted input, so anything exotic (`javascript:`, `data:`, `blob:`,
# `file:`, `vbscript:`) must be refused rather than enumerated.
LINK_SCHEMES: Final[frozenset[str]] = frozenset({"http", "https"})

# Ceiling on a built URL, matched by the viewer's own per-click check. Well
# above any real link; there to bound what a hostile store can push at the
# browser after per-element substitution.
MAX_LINK_CHARS: Final[int] = 2048

# Ceiling on a built copy string. Larger than the URL cap because a copy
# payload is legitimately prose (a whole record, a citation) rather than an
# address, but still bounded: it reaches the system clipboard, outliving the
# page, and originates in untrusted `.zattrs`.
MAX_COPY_CHARS: Final[int] = 8 * 1024

# LOD selector modes — how the viewer interprets a kind=lod group's per-child
# `coverage_fraction` thresholds (the group's `selector` attr names the units):
#
# "screen-area"  coverage_fraction is a literal SCREEN-AREA fraction: projected
#                bbox rect area / viewport area. The derived whole-object ladder
#                is [0, …, 1/8, 1/4, 1/2] (full detail while the node occupies
#                at least half the screen, one level coarser per halving of
#                occupied area); a partition tile anchors at 1.0 (fills-screen).
#                What every DERIVED ladder stamps.
# "coverage"     legacy diagonal metric: the viewer compares against projected
#                bbox diagonal / (FILL_FACTOR=0.5 × min(viewport.width,
#                viewport.height) — the fitted screen axis; see the FILL_FACTOR
#                doc in scene/lod-group-registry.ts), bounded by
#                MAX_COVERAGE_FRACTION=4.0. Kept for existing datasets and
#                for explicit `coverage_fractions=[...]` lists, whose authored
#                values were tuned in these units.
#
# Single-sourced from `format-contract/contract.yaml::lod_selectors`; the viewer's
# `types/lod-group.ts` reads the same projection. The two named members below
# are asserted to belong to it (`test_format_contract.py`).
# The selector every derived (auto-computed) ladder stamps.
DERIVED_LOD_SELECTOR: Final[str] = "screen-area"
# The UNITS an explicitly authored `coverage_fractions=[...]` list is in — the
# legacy diagonal metric, whose values were tuned against it — and the
# `add_lod_group` default (a hand-built ladder is authored, not derived).
LEGACY_LOD_SELECTOR: Final[str] = "coverage"
LOD_SELECTORS: Final[frozenset[str]] = frozenset(_LOD_SELECTORS)

# Absorption (kappa) — the volumetric blending mode's per-node coefficient.
# Multiplicative composition, identity 1.0; no upper bound (physical
# coefficient); read only by the volumetric shader branch.
ABSORPTION_MIN: Final[float] = 0.0
DEFAULT_ABSORPTION: Final[float] = 1.0

# Sharpness constants.
# 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.
SHARPNESS_MIN: Final[float] = 0.0  # Normalised range floor
SHARPNESS_MAX: Final[float] = 1.0  # Normalised range ceiling

# HDR color constants
COLOR_SDR_MIN: Final[float] = 0.0  # Standard dynamic range minimum
COLOR_SDR_MAX: Final[float] = 1.0  # Standard dynamic range maximum
COLOR_HDR_TYPICAL_MAX: Final[float] = 10.0  # Typical HDR maximum

# Per-axis COORDINATE extent at/above which uint16 per-axis fixed-point can no
# longer resolve a unit step, so AUTO/MEMORY store float32 instead. Read by the
# encoder that applies it (encoding._encoders.perchannel) AND by the gsplat
# writer's sigma rail (io._compiler.gsplat_assembly), which defers to it rather
# than pre-empting its clearer diagnosis — the two must not drift apart.
COORDINATE_U16_MAX_EXTENT: Final[float] = 65_536.0

# Chunk size constants (SINGLE SOURCE OF TRUTH - bytes, not elements)
# Consumers (io, gsplats.io) convert to element counts based on array dtype
TARGET_CHUNK_BYTES: Final[int] = 65_536  # 64KB target chunk size
MIN_CHUNK_BYTES: Final[int] = 16_384  # 16KB minimum to amortize HTTP overhead
MAX_CHUNK_BYTES: Final[int] = 262_144  # 256KB maximum for responsive streaming

# Hard ceiling on a mesh node's vertex count. Unlike the advisory point limits
# above this is a CORRECTNESS bound, not a performance hint, so it is enforced
# (see validate_vertices_for_writing) rather than warned about.
#
# A mesh's pick elementId is the raw `gl_VertexID` — the one geometry type not
# bounded by the element-texture capacity — and the viewer's pick vote key is
# built with a stride of 2^27 per node. Once a vertex ordinal reaches that
# stride, vote keys alias ACROSS nodes and a pick resolves to the wrong node with
# no diagnostic. The largest ordinal is `n_vertices - 1`, so `n_vertices <= 2^27`
# is the exact alias-free bound: every admitted ordinal stays strictly under the
# stride. See docs/specs/MESH_NODE_SPEC.md §6.5.
#
# It also keeps the writer's face-index check sufficient: with vertices capped
# here, `max(faces) < n_vertices` guarantees every admitted index survives the
# `.astype(np.uint32)` cast unchanged (2^27 is far below 2^32).
#
# MIRROR: MAX_MESH_VERTICES in packages/luxar-viewer/src/config/constants.ts must
# hold this value — that is the loader-side gate this one is the fail-fast twin
# of. If you change one, change the other; a viewer test pins that side.
MAX_MESH_VERTICES: Final[int] = 2**27  # 134,217,728 — pick vote-key stride

# Per-node ceiling, in bytes, on what a mesh node may declare. The viewer's
# whole-node mesh loader refuses a node over this before fetching a single chunk,
# so a store above it does not render — it fails with a LoaderError.
#
# This is the write-time twin of that gate, and the twin is DELIBERATELY WEAKER:
# see validate_mesh_decode_budget for exactly which terms it charges and why it
# must under-count rather than over-count. The viewer's number is the authority;
# this is here so `add_mesh` can refuse the cases that provably exceed it.
#
# MIRROR: MESH_DECODE_BUDGET_BYTES in
# packages/luxar-viewer/src/config/constants.ts must hold this value.
MESH_DECODE_BUDGET_BYTES: Final[int] = 512 * 1024 * 1024  # 536,870,912

# Every decoder-routed array materializes as float32 in the viewer, so the
# loader charges 4 bytes per LOGICAL value regardless of the stored dtype.
# MIRROR: DECODED_BYTES_PER_VALUE in packages/luxar-viewer/src/data/mesh/preflight.ts
MESH_DECODED_BYTES_PER_VALUE: Final[int] = 4

# Container/codec family this project writes, for metadata and docs. The REAL
# default is the width-aware per-dtype policy in luxar.encoding.compression
# (zstd level 9 inside Blosc), which is where a level belongs — the
# COMPRESSION_LEVEL_MIN/DEFAULT/MAX trio that used to sit here named 0/3/9,
# agreeing with neither that policy nor the 1-9 band the old
# typing_utils.config validator enforced, and had no reader either way.
DEFAULT_COMPRESSOR: Final[str] = "blosc"  # Default compression algorithm

# Categorical dimension constants
MIN_CATEGORIES: Final[int] = 1  # Minimum categories for categorical dimensions
MAX_CATEGORY_LABEL_LENGTH: Final[int] = 1024  # Maximum length for category labels
CATEGORICAL_STEP: Final[float] = 1.0  # Step size for categorical dimensions (always 1)

# Node type identifiers. These must cover `node_types` in
# `format-contract/contract.yaml` exactly — `test_named_node_type_constants_match_contract`
# pins the set both ways, so a contract addition with no constant here fails there.
NODE_TYPE_SCENE: Final[str] = "scene"
NODE_TYPE_GROUP: Final[str] = "group"
NODE_TYPE_POINTS: Final[str] = "points"
NODE_TYPE_LINES: Final[str] = "lines"
NODE_TYPE_GSPLATS: Final[str] = "gsplats"
NODE_TYPE_MESH: Final[str] = "mesh"
NODE_TYPE_SOUND: Final[str] = "sound"

# Point radius constants
MIN_POINT_RADIUS: Final[float] = 0.001  # Minimum visible radius
MAX_POINT_RADIUS: Final[float] = 1000.0  # Maximum practical radius

#: The radius, in world units, that the renderer draws every point with when a
#: points node stores no ``radii`` array. It is therefore also the extent the
#: WRITE side must expand such a chunk's spatial bounds by: for a radii-less
#: node the pad IS the footprint, so the stored bound is exactly the set of
#: query positions from which a point in the chunk can be seen — never tighter
#: (the reader cannot miss a drawn disc) and never arbitrarily looser. That
#: holds at any coordinate magnitude: the float32 bounds array is written with
#: outward rounding, so a pad smaller than half an ULP cannot vanish into the
#: store. (The claim is scoped to this no-radii case: with per-point
#: uint8-encoded radii the encoder rounds, so a stored radius can exceed the one
#: the bounds were computed from by up to one quantum.) It is likewise the radius
#: :func:`luxar.core.group.adders.points.add_points_impl` materializes when the
#: caller supplies none, which is what makes the two agree by construction.
#:
#: MIRROR: ``DEFAULT_POINT_RADIUS`` in
#: ``packages/luxar-viewer/src/config/constants.ts`` must hold the same value —
#: that is the constant every viewer site stands in for a missing radii array
#: with. ``rendering/node-factory/create-points-node.ts`` uses it BOTH ways — it
#: fills the per-point radius array and seeds the scalar footprint the bounding
#: box is padded by; ``rendering/gpu-buffer-pool/points-adapter.ts`` only fills
#: the array, and ``data/scene-loader/commit/commit-points-geometry.ts`` only
#: supplies the scalar footprint. Both sides are pinned by tests that
#: name each other.
DEFAULT_POINT_RADIUS: Final[float] = 0.5

#: GSplat truncation radius ``T``, in sigmas: the Mahalanobis distance beyond
#: which a splat's shifted Gaussian is exactly zero. The kernel is
#: ``max(0, exp(-D²/2) - C) / (1 - C)`` with ``C = exp(-T²/2)``, so ``T`` sets
#: both the support and the normalization ``1/(1 - C)``.
#:
#: 2.75 comes from ``da53b17d2`` (2.5σ measured +1.6 dB over the prior default;
#: 2.75 is the quality/speed middle ground). It was applied only to the fitter
#: config at the time, leaving ~20 sites defaulting to 3.0 — this constant is
#: the single source that replaces them.
#:
#: MIRROR: ``GSPLAT_DEFAULT_TRUNCATION_RADIUS`` in
#: ``packages/luxar-viewer/src/config/constants.ts`` must hold the same value.
#: Both sides are pinned by tests that name each other.
#:
#: EXEMPTION: :mod:`luxar.gsplats.lift` keeps 3.0. Its ``T`` is not a render
#: default but a profile-matching parameter — the point/line super-Gaussian
#: sprite and the gsplat kernel coincide exactly at ``T* = sqrt(2 ln 100) =
#: 3.0349``. Measured radial-weighted relative L2 of the lift: 1.96% at T=3.0,
#: 16.91% at T=2.75. See ``lift.py`` for the derivation.
DEFAULT_TRUNCATION_RADIUS: Final[float] = 2.75

#: Lower bound on each Cholesky diagonal (a splat's per-axis width), in voxels.
#:
#: ``sqrt(1/12)`` is the standard deviation of a uniform distribution over one
#: voxel — the width at which a Gaussian stops describing structure and starts
#: describing the sampling grid. Below it a splat is narrower than the data can
#: resolve, and the fit spends capacity on a delta it cannot justify.
#:
#: Passing ``sigma_min_diag=None`` removes the bound entirely; that is a
#: deliberate act, not a default. It used to be reachable by accident —
#: ``ConstraintConfig`` defaulted to ``None`` while the fitter defaulted to this
#: value, so unpacking a default-constructed config switched the floor off while
#: reading as "no change" (audit A3-01).
#:
#: Lives here rather than in ``gsplats.fitting.validation``, where it was
#: defined, because ``validation`` imports ``FitConfig`` from
#: ``gsplats.fitting.config`` — so the config module could not name its own
#: default without a circular import. ``validation`` re-exports it for the
#: existing import sites.
DEFAULT_SIGMA_MIN_DIAG: Final[float] = float(math.sqrt(1.0 / 12.0))


# =============================================================================
# Per-node element-texture capacity (issue #1957)
# =============================================================================
#
# The viewer stores per-element render data in an "element texture" whose width
# is capped at ELEMENT_TEXTURE_MAX_WIDTH texels and rounded DOWN to a whole
# number of elements, so one node holds at most
#
#     floor(width * maxTextureSize / texels_per_element)
#
# elements. Beyond that the viewer CLAMPS: ``clampElementCapacity``
# (``packages/luxar-viewer/src/rendering/element-texture-layout.ts``) drops the
# node's tail with a single console warning and no other signal. Because
# geometry is stored in Hilbert order, the lost tail is one spatially
# CONTIGUOUS lobe, so the symptom is a clean-edged wedge of missing geometry
# rather than a scatter — see #1957, where clamping 2.3% of an ocean-current
# Lines node erased the whole North Atlantic.
#
# maxTextureSize is a GPU property (16384 on modern desktop, 4096 on the
# conservative floor), so the only bound an AUTHOR can rely on is the
# 4096-class one below. Nodes above it must be split with
# ``partition=dict(max_elements=...)``.
#
# MIRROR: ``ELEMENT_TEXTURE_MAX_WIDTH`` and the per-type ``texelsPerElement`` in
# ``packages/luxar-viewer/src/rendering/element-texture-layout.ts``.
ELEMENT_TEXTURE_MAX_WIDTH: Final[int] = 4096
CONSERVATIVE_MAX_TEXTURE_SIZE: Final[int] = 4096

#: Texels each geometry type consumes per element in the element texture.
ELEMENT_TEXELS_PER_ELEMENT: Final[dict[str, int]] = {
    "points": 3,
    "lines": 6,
    "gsplats": 4,
}


[docs] def max_elements_per_node(geometry_type: str) -> int: """Elements one node of ``geometry_type`` can render on a 4096-class GPU. The conservative floor: a node at or below this renders whole on ANY GPU; above it, a 4096-class GPU silently drops the tail. Args: geometry_type: A key of :data:`ELEMENT_TEXELS_PER_ELEMENT`. Returns: The element capacity — segments for lines, points for points, splats for gsplats. Raises: KeyError: ``geometry_type`` has no element-texture layout. """ texels = ELEMENT_TEXELS_PER_ELEMENT[geometry_type] width = (ELEMENT_TEXTURE_MAX_WIDTH // texels) * texels return (width * CONSERVATIVE_MAX_TEXTURE_SIZE) // texels
#: 2,793,472 segments — the conservative per-node cap for Lines. MAX_SEGMENTS_PER_LINES_NODE: Final[int] = max_elements_per_node("lines") #: 5,591,040 points — the conservative per-node cap for Points. MAX_POINTS_PER_POINTS_NODE: Final[int] = max_elements_per_node("points") #: 4,194,304 splats — the conservative per-node cap for GSplats. MAX_SPLATS_PER_GSPLATS_NODE: Final[int] = max_elements_per_node("gsplats") #: Largest integer JavaScript represents exactly (``Number.MAX_SAFE_INTEGER``, #: 2**53 - 1). The bound for any attr whose VALUE the viewer must round-trip #: exactly rather than merely approximately — today ``layer_order``, whose only #: property is its order relative to other layers, so a magnitude that collapses #: two distinct orders into one JS number is a silent wrong answer rather than a #: rounding nicety. JS_SAFE_INTEGER_MAX: Final[int] = 2**53 - 1