Luxar Viewer API Documentation - v2026.9.22
    Preparing search index...

    Progressive Mesh loader — the reveal ladder's composite.

    Implements

    Index
    lodLoaders: MeshWholeNodeLoader[]
    loadedLODs: LoadedMeshData[] = []
    nLods: number
    path: string
    _initialLoadDone: boolean = false
    _disposed: boolean = false
    _budgetChecked: boolean = false

    Latched once assertWithinByteBudget has fully accounted the ladder and admitted it. Set ONLY on that success path — never on a rejection — so a rejection is never cached as a pass: a live instance genuinely re-preflights on the next updateView (a failed initialize() caches nothing), and a disposed one is terminal and simply returns from the gate's first line. The one verdict that CANNOT come out differently is latched separately, on _budgetRefusal, so it is not re-derived either.

    _budgetRefusal: LoaderError | null = null

    The sticky AGGREGATE-over-budget refusal, once thrown — deliberately the mirror image of _budgetChecked above, which is never set on a rejection.

    The AGGREGATE comparison this field guards is DETERMINISTIC. By the time assertWithinByteBudget reaches the sum, every level's MeshPreflightResult came from an initialize() that already succeeded and is cached (MeshWholeNodeLoader.doInitialize never re-runs once this.handles is set) — there is no I/O left to vary, so re-running the gate on a later updateView can only ever repeat the same verdict.

    Recomputing it anyway is actively harmful: without this latch, hasMoreLODs stays true forever (the loaded-level count never grows on a ladder that is never allowed to fetch a single level), so queue-next.ts keeps scheduling runMeshRefinement on every slice scrub, which burns MAX_CONSECUTIVE_REFINEMENT_FAILURES refinement passes per scrub and toasts "Refinement failed … — showing a partial surface" — false, since zero triangles were ever committed.

    So the refusal is computed once, cached here, and every later call rethrows the SAME LoaderError object with no further runPreflight() calls. hasMoreLODs also reads this field directly (see above) and reports false once it is set, which is what actually removes the dead node from queueNext's refinement loop rather than merely making its gate cheap to re-fail.

    A level's OWN runPreflight() rejection stays unlatched — and NOT because it is always transient. preflightMesh raises a deterministic LoaderError('Validation') for a genuinely malformed store, so some of those rejections do repeat forever. The reason is that the error KIND cannot be trusted to separate the two here, and it fails in both directions. Over-inclusive: a 404 on additive_2/normals while a store is still being written arrives as Validation BY DESIGN — mesh-whole-node-loader.ts's optional-array open catch reads a zarr not-found as "the presence flag disagrees with the store", leaves the slot empty, and preflightMesh's flag-with-no-array check then rejectMeshes it. Network failures now reject before zarrita can classify them as missing, but the deterministic Validation cases remain indistinguishable from transient publication races. Under-inclusive: an absent REQUIRED vertices/faces array throws zarrita's NotFoundError, which classifyLoaderError matches nowhere and files as Unexpected. So latching by kind would strand nodes that the failed-loads banner's manual Retry and the LOD registry's not-ready self-heal — both of which deliberately IGNORE the kind — recover the moment the store or the connection is fixed, and would treat two 404s a few array names apart oppositely.

    The residual cost is real and accepted: while no level has committed, the refinement loop keeps re-firing on a deterministically broken level, and its "showing a partial surface" toast is inaccurate there too. Separating the two honestly would mean marking determinism where each rejection is CONSTRUCTED (in preflight.ts and the open catches) rather than inferring it from the kind after the fact — deliberately out of scope here.

    _concatCache: { lodCount: number; result: LoadedMeshData } | null = null

    Memoized concatenation, keyed on the loaded LEVEL COUNT alone.

    Its siblings additionally key on a reset generation because their ladders are rebuilt per view; a mesh ladder is view-independent (see the module docstring), so an unchanged level count returns the same object across any number of slice moves. That identity is what the commit pipeline reads to skip no-op re-commits, and what keeps the projection scratch alive.

    _loadedLODCount: number = 0
    _levelsAtPassStart: number = 0
    _payloadsAtPassStart: number = 0
    _retryFoldedPass: boolean = false
    _frameBudgetMs: number | null = null

    Per-pass playback budget from the CURRENT updateView; null outside playback.

    _ladderDepth: number | null = null

    Pinned rung count for the current pass (ViewState.ladderDepth, the playback "detail" setting), resolved through resolveLadderDepth; null when the pass is not pinned. A pinned pass loads exactly this many rungs, cold or not, and reports hasMoreLODs === false like a budgeted one — the pinned prefix IS the target.

    _lastAllResident: boolean = true

    Monitor telemetry, rolled up over the levels — see the surface below.

    _visibleTriangles: number = 0

    Triangles the current slice indexes, as reported by the commit (recordVisibleElements). Node-level, so it cannot come from the per-level roll-up — see getMetrics.

    • get committedEnergyFraction(): number | null

      Always null — a mesh reveal ladder carries no energy stamps, by construction at three independent layers.

      The stamps exist so the display gate can release an upgrade early, and so energyCompensation can brighten an incomplete EMISSIVE ladder by 1/e(k): a coarse prefix of a splat cloud is a dim version of the whole, and dividing by the committed energy fraction restores its brightness. A reveal prefix is nothing of the kind — it is a PARTIAL OBJECT AT FULL BRIGHTNESS — so the same multiplier would blow out the first shell by ~1/e and then fade it as the surface completes: the exact inverse of growing in. MESH_NODE_SPEC.md §9.1 states the rule; the Python add_mesh refuses lod_stats energy keys, the factory reads no energy table, and this getter closes the loop.

      It must EXIST and return null rather than be absent, and the difference is not cosmetic: stampLadderComplete probes 'committedEnergyFraction' in loader and takes the NON-progressive branch when the getter is missing — stamping a half-revealed mesh committedEnergyFraction: 1, i.e. "all of it is on screen". Returning null makes the stamp absent instead, which is what the display gate reads as "unstamped, fall back to committed-count crossover".

      Returns number | null

    • Measured footprint of the loaded ladder, for the shared sweep residency budget (scene-loader/progressive/residency-budget). Sums real byteLengths rather than modelling a per-element cost, so it stays correct as payload columns come and go. Rung count is reported alongside so the budget can estimate the next rung without needing per-rung sizes.

      Returns LadderResidency

    • Discard the levels the current pass appended, restoring the ladder to the prefix the pass started from. Called by the main-update and refinement catches; see ../loaders/progressive/pass-rollback for why a failed commit must not leave the cursor advanced.

      Returns number

      Levels discarded (0 when the pass appended none).

    • Charge the ladder's aggregate byte budget ONCE, at the ladder's first updateView — before any level's chunks are fetched.

      The obvious home for this aggregate is where the ladder is BUILT — createProgressiveMeshLoader (../scene-loader/loaders/loader-factory.ts), during loadMeshNodeCheap — and that spot is unusable, which is worth recording because it is the first place a reader will look for it. The cheap half runs OUTSIDE loadMeshNode's try/placeholder-attach and before registerMeshLoader, so a rejection thrown from there has nowhere to go: no placeholder to mark failed, no recordFailure, no retry, no monitor banner — a transient blip on one level's metadata open would permanently lose the whole node, silently (reportLoadOutcome still logs success). Swallowing that rejection instead only trades it for the other failure: retryFailedLoader reuses the existing loader rather than re-entering the factory, so a swallowed level's bytes would go uncounted forever, the level would then load fine on retry, and the ladder that motivates this whole check would sail through over budget with nothing left to refuse it.

      Both failure modes trace to the same cause: a check with no containment around it. A leaf's own byte budget is enforced inside MeshWholeNodeLoader.fetch(), i.e. inside loadMeshNodeExpensive's try — so charging the LADDER at the equivalent point, its own first load, gives it the identical containment (failure recorded, banner shown, siblings unaffected) as well as the identical ceiling: ladder ≡ leaf in both respects. Retryability carries over for a level's OWN rejection; the aggregate over-budget verdict is deliberately cached and rethrown instead, since no retry can make the sum fit (see _budgetRefusal). It is still strictly before any chunk is fetched — runPreflight() only opens metadata — so the two-stage gate's "refuse before allocation" property survives the move unchanged.

      Gating on EVERY level's metadata before level 0 is fetched has a real cost, and it is worth stating rather than leaving implicit:

      1. Any single level's runPreflight() rejection now costs the WHOLE first paint. Before this change, levels 0..k-1 painted and the ladder simply stalled at the bad level k with a "partial surface" toast; now nothing paints until every level's metadata has opened successfully.
      2. On a store WITHOUT consolidated metadata, the added latency is real, not theoretical. A 6-level ladder with normals and colors is ~30 arrays, i.e. ~60 metadata objects (.zarray + .zattrs each) to open before level 0 can even start fetching chunks. Behind a browser's ~6-connection limit per origin, that is several serialized round trips — measurably slower first paint on exactly the slow link this reveal ladder exists to serve well.

      For a Luxar-written store this is close to free: the compiler writes a .zmetadata consolidated-metadata document, and src/data/zarr.ts wraps every store with zarrita.withMaybeConsolidatedMetadata, so every zarr.open(..., { kind: 'array' }) above is served from an in-memory document instead of a network round trip. The cost above is real only for an arbitrary ?src= store that omits it.

      An alternative was considered and NOT taken: charge levels 1..N-1 only AFTER level 0 has been committed, so a slow/failing deeper level would never block first paint (level 0 already has its own leaf-sized runPreflight(), so the pre-refusal peak would still be bounded by one leaf's ceiling). Rejected here because it weakens the property this gate is FOR — "refuse the whole ladder before any chunk is fetched" — down to "refuse before the second chunk," and because the common (consolidated) case already pays nothing for gating everything up front. Not implemented.

      MESH_DECODE_BUDGET_BYTES is a per-NODE ceiling, and a reveal ladder is one node (MESH_NODE_SPEC.md §9.1): this class concatenates every level's vertices/faces/normals/colors/scalars into ONE committed buffer set (concatenateMeshData above), and all of it stays resident for the node's whole life. Each level's own runPreflight() only ever sees its own ceiling, with no knowledge of its siblings — so without this, a ladder's real ceiling was nAdditive x budget: a plain leaf with the ladder's total geometry is refused up front, but the same geometry split into levels sails through, N times over budget, and the tab dies on the concatenated allocation. Summing every level's accountedBytes and charging that once against the SAME ceiling is what turns N budgets back into one.

      The sum overcharges relative to what a single level's own preflight would need to: it includes each level's own largest-chunk term even though the ladder loads levels sequentially (never two chunk buffers alive at once across levels), and an array_ref target shared between levels is charged once per referring level rather than once total. In that sense it is a deliberate over-estimate.

      That does NOT make the charged figure a bound on true peak residency, which is a separate quantity this sum does not track. Per level i, the charged term is stored_i + 4·decoded_i (plus that level's own maxChunk_i, which is transient and never resident). After each successful concat, the level loaders release their decoded payloads and the composite retains only the cumulative payload plus its projection scratch. Tightening the ceiling to a true peak-residency model remains a separate concern.

      initialize() routes a transient open failure (a network blip) through classifyLoaderError precisely so it stays retryable, and that property has to survive reaching here. Letting the rejection propagate instead of catching it is correct HERE, and only because of where "here" is: loadMeshNodeExpensive's catch records the failure and keeps the blip retryable, and a later retry re-enters updateView, which re-runs this gate — _budgetChecked is latched only on a fully successful accounting, and the failed level's own initialize() cached nothing, so the retry genuinely re-preflights rather than replaying a stale rejection.

      Propagating is not the same as forgetting, though: the AGGREGATE over-budget refusal below IS latched on the way out and rethrown as the same error object rather than re-derived. A level's own rejection is not — see _budgetRefusal for why the error kind cannot be trusted to tell a deterministic one from a transient one here.

      Returns Promise<void>

    • Concatenate the loaded levels, memoized on the level count.

      Returning the SAME object reference when nothing has changed is safe because the result is never mutated downstream (the worker projection's inputs are structured-cloned, not transferred) and is what lets the commit pipeline skip no-op re-commits by identity.

      No setPrefixParent stamp, unlike the three siblings. That lineage exists so the commit layer can recognise a prefix EXTENSION and take the depth-sort append fast path, which is defined over the instanced element buffers mesh does not have — its faces are re-emitted by the projection every epoch.

      Parameters

      Returns LoadedMeshData

    • LoaderMonitor surface (optional, for the data-loading-monitor UI). Mirrors the surface the sibling loaders expose — implementations that don't track metrics may omit these. Both shipped implementations (MeshWholeNodeLoader, MeshProgressiveLoader) provide all four: connectLoaderToMonitor duck-types the complete set, so a partial implementation is silently skipped rather than partially reported.

      Parameters

      Returns void