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

    Class SceneLoader

    Main scene loader that handles the complete loading pipeline.

    Features:

    • Spatial index-based loading for efficient nD queries
    • Hierarchical scene graph construction
    • Transform and rendering attribute inheritance
    • Dimension metadata management
    • Memory-efficient loading with proper caching

    Lifecycle: one-shot. Each loadScene() call disposes prior loaders + caches and nulls the monitor reference. Reusing a single SceneLoader instance across two loadScene() calls is unsupported and will leave the second load with a null monitor reference. Use SceneLoaderManager.createLoader() (the canonical entry point in data/zarr-loader.ts), which constructs a fresh loader per load — the SceneLoaderManager handles the destroy/recreate dance for you.

    Index
    _zarrStore: Readable | null = null
    cachingStore: MultiLevelCachingStore | null = null
    l0Cache: DecompressedChunkCache | null = null
    sliceCache: SliceCache | null = null
    cacheBudgets: CacheBudgets | null = null
    registry: LoaderRegistry = ...
    lineWorkingSetGate: LineWorkingSetGate
    refinementResidencyReporter: RefinementResidencyReporter = ...

    Scene-wide byte-ceiling record for progressive refinement, surfaced on the debug snapshot (#2508); lifetime contract on RefinementResidencyStop.

    DELIBERATELY NOT CLEARED BY dispose(), because production reaches a new scene only through SceneLoaderManager.createLoaderAsync, which builds a fresh loader and reporter — a reset here would be code no path executes. Only the in-place dispose() in scene-loader/lifecycle/load-scene.ts would notice: it nulls _gpuBufferPool for good (the pool is constructed only in this class's constructor, so SceneLoaderManager.gpuPoolStats() then returns undefined and gpuPool is ABSENT from every later snapshot) while this readonly reporter survives. Latent regardless — reusing one SceneLoader across two loadScene() calls is unsupported (class docstring).

    refinementDensityGate: RefinementDensityGate | null = null
    lastResidencyBudget: RefinementResidencyBudget | null = null

    The last refinement run's residency budget, kept so the data monitor can tell a rung held at the residency ceiling from one still streaming (refinementHoldReason). Replaced at every run start.

    viewState: ViewState
    config: LoaderConfig
    rootGroup: Group<Object3DEventMap> | null = null
    monitor: SceneLoaderMonitorPort | null = null

    Optional monitor port — populated at construction by the factory passed in via SceneLoaderManager.setMonitorFactory. Null when no UI is wired up (tests, embedders), in which case all monitor calls become no-ops at the call sites.

    arrayRefRegistry: ArrayRefRegistry
    _gpuBufferPool: GPUBufferPool | null = null
    nodeFactory: NodeFactory = ...
    profiler: UpdateProfiler | null = null
    _identityWatchdog: SceneIdentityWatchdog | null = null
    _updateInProgress: boolean = false
    _archiveFault: ArchiveFaultError | null = null
    archiveFaultListeners: Set<(error: ArchiveFaultError) => void> = ...
    _refining: boolean = false

    True for the duration of a progressive-LOD refinement run (scheduleGSplatsRefinement).

    Refinement does not take the serialization lock — it INHERITS it: the update tail (update-view/queue-next.ts) and the post-load kick (lifecycle/load-scene.ts) hand _updateInProgress = true straight to the orchestrator, whose final (mesh) phase releases it. So the lock stays latched for the whole additive-ladder drain, which happens strictly AFTER the current view has already been committed to the GPU.

    This flag marks that stretch so consumers meaning "is data still arriving for the current view" can subtract it — see isLoadPassInProgress. Consumers that mean "is the loader busy at all" keep reading isUpdateInProgress.

    _lastUpdateWasFrameBudgeted: boolean = false
    _updateVersion: number = 0

    Monotonic view-generation counter, read by the LOD registry as currentViewVersion and stamped onto every committed leaf as userData.loadedViewVersion (freshness is EXACT equality — see scene/lod-freshness.ts).

    CONTRACT: it bumps ONLY when a query-determinant of the view changes (viewStatesEqual: displayDims / slicePosition / tolerance / the dimensions query signature). A pass whose merged view state equals the current one — a partition resync, a retry re-run, a depth-sort re-commit — re-sweeps under the SAME version. This matters because lazy lod_group levels are NOT in the sweep and are never re-stamped by it: bumping on an unchanged view invalidated every resident fine level scene-wide, so a partition part crossing the screen edge dropped ALL LOD groups to coarse and re-streamed them (the #2366 regression on the h2afva 44-part scene).

    _pendingResyncPaths: Set<string> | null = null

    Targeted-resync paths that arrived while a pass was in flight. Folded into the pass that runs next (or dropped when a full pending state supersedes them — a full sweep is a superset). Never aborts the in-flight pass.

    _queuedResyncPaths: Set<string> | null = null

    Resync paths handed to the follow-up pass by queueNext's re-entry.

    _pendingIsResyncOnly: boolean = false

    True while the pending slot holds the empty state a RESYNC queued (so a later rising edge may merge into _queuedResyncPaths); false once any other caller sets the slot — a real view change or an untargeted reprocess — because that pass must sweep everything and a stash arriving on top of it must be dropped, not merged (it would narrow that pass).

    _passCount: number = 0

    Passes actually run (every updateView that reached the sweep). The per-geometry processors gate their first-update info logs on this being <= 1; it used to be _updateVersion, which now only advances on a view CHANGE and would keep those logs on forever for a static scene.

    _refinementKickPending: boolean = false
    _disposed: boolean = false
    _sceneGraph: SceneNode | null = null
    viewStateQueue: ViewStateQueue = ...

    View-state queue: owns _pendingViewState (set/take/has + drain) and the per-loader previous view-state map used by predictive prefetch. See ./scene-loader/view-state/view-state-queue.ts.

    The pending-state slot is overwritten on every queued update, so a burst of view changes during an in-flight retry collapses to a single drained call (latest-wins). The per-node prev-state map is reset on dataset switch (loadScene) and dispose; skipped paths forget their snapshot so the next non-skip update re-baselines.

    _datasetAbortController: AbortController | null = null

    Per-dataset AbortController. Created on every loadScene and aborted at the START of the next loadScene (and on dispose) so worker tasks queued by the previous dataset settle immediately instead of running to completion against a superseded scene. The signal is registered with the WorkerPool via setAbortSignal. WASM execution itself cannot be cancelled, but the orphan results are discarded — see WorkerAbortError.

    _updateAbortController: AbortController | null = null

    Per-update AbortController. Created at the start of each in-flight updateView and aborted in the supersede branch when a newer view-state arrives (and on dispose). Its signal is threaded into the per-type handler ctxs → loader.updateView → the L0 proxy chokepoint, so a superseded update's chunk reads/decodes bail with an AbortError instead of running to completion, and its geometry commit is skipped (the winning update commits the correct frame). DISTINCT from _datasetAbortController: it is per-update, NOT registered via WorkerPool.setAbortSignal (which replaces, not chains); it composes with the dataset signal through the worker pool's combineSignals.

    _passWaiters: { gen: number; resolve: () => void }[] = []

    Waiters for "the requested-or-newer view-state completed a main pass". Created by the queued/supersede branch, a same-view join, or a superseded direct pass awaiting its replacement. Each waiter carries the generation it needs; a committing pass of that generation or newer settles it. If no pass remains pending, queueNext drains waiters covered by the last pass. This keeps sceneDimsManager.waitForUpdate() and the dimension-animation pacing gate tied to a completed view. dispose() and archive faults flush waiters (resolve-only, never reject) so callers cannot hang.

    _requestSeq: number = 0

    Request generations (#2943). Every view request that starts or queues a pass takes the next number; a queued state carries its number (keyed on the state object, which travels unchanged through takePending → reenterPending) to the pass that eventually runs it. A waiter records the generation it asked for and is settled only by a pass of that generation or a newer one — a newer pass merges onto everything an older request set, so it covers the older waiter.

    _requestGens: WeakMap<object, number> = ...
    _passGen: number = 0

    Generation of the main pass now running (0 while none is).

    _completedGen: number = 0

    Newest generation whose main pass has finished.

    _slicePrefetcher: SlicePrefetcher | null = null

    Background t+1 slice prefetcher (dimension playback). Lazily created on the first prefetchSlice call; aborted at the top of every updateView (foreground always preempts); disposed with the loader. Its SHADOW loader instances share nothing mutable with the foreground loaders — the S-cache is the only handoff (see slice-prefetcher.ts).

    lodGroupRegistry: LODGroupRegistry | null

    Per-loader LOD-group registry. Constructed via the factory passed by SceneLoaderManager so the registry's camera / viewport / displayDims getters can close over the live SceneManager — which the data/ layer must not import directly. null when the host (e.g. headless tests) doesn't supply a factory; in that case the scene loader still loads lod_group nodes but the per-frame selector is a no-op (default level renders).

    _requestRender: (() => void) | null = null

    Optional render-loop wake-up, wired by SceneLoaderManager from the app pipeline (→ AnimationController.startAnimation). Fired after every geometry commit so late commits — progressive-refinement passes, failed-load retries, the online auto-retry, lazy LOD loads — repaint even when the rAF loop has idle-paused meanwhile. The callback is idempotent on the receiving side (startAnimation early-outs while animating and re-arms the idle timer), so per-node calls inside an atomic sweep are harmless. Null in bare/test loaders → no-op.

    decodeKTX2: KTX2TextureDecoder | null
    • get zarrStore(): Readable | null

      Public accessor for the zarr store (needed by LabelLoader).

      Returns Readable | null

    • get hasCachingStore(): boolean

      True if the L1/L2 caching store is configured for this loader.

      Returns boolean

    • get gpuBufferPool(): GPUBufferPool | null

      The GPU buffer pool, or null when pooling is disabled. Exposed so the LOD-group registry's resident-byte query (getResidentBytes) can read the single VRAM truth; callers treat a disabled pool as 0 resident bytes, so it is never over budget and never evicts.

      Returns GPUBufferPool | null

    • get refinementResidencyStop(): RefinementResidencyStop | undefined

      This scene's progressive-refinement BYTE-ceiling stop, or undefined when refinement never declined a rung.

      The reporter is scene-scoped (one per loader) while the residency budget is rebuilt per refinement run, so this accumulates across runs — which is what makes it answerable at capture time, long after the run that stopped. Read by the debug snapshot through SceneLoaderManager.refinementResidencyStop().

      Returns RefinementResidencyStop | undefined

    • get currentViewVersion(): number

      Monotonic view-generation version (bumped by updateView only when the merged view state differs from the current one — see _updateVersion). The LOD registry reads this to decide whether a level's committed geometry is fresh for the CURRENT view — a mesh stamped with an older version (its data still reflects a previous slice/displayDims) is treated as stale so the registry can show a coarser fresh level until the re-slice commits.

      Returns number

    • Whether an updateView sweep (fetch/decode/upload) is currently in flight. Exposed for consumers that must treat loading-time frame jank as unrepresentative — e.g. the adaptive DPR manager suppresses probe learning while this is true. Covers the serialized update sweep, not late lazy-LOD commits (those surface as content-change notifications instead).

      Returns boolean

    • Whether a LOAD PASS is in flight. Three parts:

      1. a RUNNING sweep — an updateView pass (fetch / decode / upload) up to its geometry commit, or a failed-loader retry sweep, which takes the same lock;
      2. MINUS the progressive-LOD refinement drain, which inherits that same lock (see _refining);
      3. PLUS a sweep that is QUEUED but has not started yet.

      Together they answer "has the data for the view the user asked for arrived yet?".

      Refinement is excluded because it runs after the current view has already been committed, so folding it in would turn this into full-ladder latency instead of first-commit latency. That is the same distinction update-view/queue-next.ts already draws where it resolves the pass waiters at refinement ENTRY rather than completion — the pacing gate there needs first-commit latency too.

      The queued slot counts because the refinement exclusion would otherwise open a hole big enough to drive a test through. The steady state on any laddered dataset right after a commit is "lock held, _refining true"; an updateView arriving then takes the supersede branch above, parks its state with viewStateQueue.setPending and returns without touching either flag. The requested slice has not begun loading, yet both flags still describe the refinement that preceded it — so without this clause a poller would read idle and conclude the new slice had rendered. hasPending() is true across exactly that window: the slot is filled in the supersede branch and cleared by takePending() at the moment the next pass starts (queueNext before it re-enters updateView, the refinement loop's own loop-top cancellation check before it hands off, or finalReleaseLock's drain), so the flag cannot latch busy after a pass begins.

      Also outside its scope: the initial loadScene (which only touches the lock at its very end, to hand it to the post-load refinement kick) and lazy substitutive-LOD / deferred-partition ensureLoaded promotions, which run outside any updateView cycle and surface as content-change notifications instead.

      isUpdateInProgress keeps its existing, broader meaning ("the serialization lock is held, refinement included") for its existing consumers — the adaptive-DPR manager and core/app/init/pipeline.ts, both of which want to discount loading-time frame jank for as long as the loader is doing work of any kind.

      Returns boolean

    • Settle the queued-update waiters covered by generation upToGen (all of them by default — the dispose / archive-fault flushes); newer waiters stay parked for the pass that carries their state.

      Parameters

      • upToGen: number = Infinity

      Returns void

    • Settle the waiters the newest finished main pass covers.

      Returns void

    • Fire one background prefetch pass for the PREDICTED next view (t+1 during playback). Fire-and-forget: returns immediately; the shadow pass is aborted by the next foreground updateView. The partial is merged onto a COPY of the current view state — never persisted (a prefetch must not move the real view; see the stuck-display hazard in slice-prefetcher.ts).

      Parameters

      • viewState: Partial<ViewState>
      • budgetMs: number
      • OptionalladderDepth: number | "auto"

      Returns void

    • Release the prefetcher's shadow loaders (frees their accumulators). Called when playback ends; shadows rebuild lazily on the next play.

      Returns void

    • Subscribe to archive-fault episodes for this loader. An explicit retry clears the latch; if the archive fails again, listeners are notified again. Listener exceptions are logged and do not propagate to the caller.

      Parameters

      • listener: (error: ArchiveFaultError) => void
      • options: { replayCurrent?: boolean } = {}
        • OptionalreplayCurrent?: boolean

          Replay the current fault immediately when one is latched.

      Returns () => void

    • Clear the in-memory L0 decompressed-chunk cache. No-op if absent.

      Returns void

    • Clear the persistent L2 OPFS cache. No-op if absent.

      Returns Promise<void>

    • Clear ALL cache tiers (L0 + L1 + L2 + the decoded-slice S-cache).

      Returns Promise<void>

    • Resolved per-tier cache budgets from the last setupCaches run (source: heap / explicit / device-class / fixed), or null before the first scene load / after dispose. Surfaced for the Settings popover's budget readout.

      Returns CacheBudgets | null

    • Return a node-attrs record with rendering attributes replaced by the effective values composed along the scene-graph ancestry (root → leaf). Hierarchical composition: opacity/gamma/intensity multiply, offset adds, blending_mode uses the nearest ancestor's choice.

      If the scene graph is unavailable, falls back to the node's raw attrs.

      Parameters

      Returns {
          transform?: readonly number[];
          nd_transform?: NdTransformMap;
          opacity?: number;
          absorption?: number;
          gamma?: number;
          intensity?: number;
          offset?: number;
          blending_mode?: string;
          join?: string;
          max_radius?: number;
          n_points?: number;
          layer?: boolean;
          visible?: boolean;
          color_data_range?: [number, number];
          amplitude_data_range?: [number, number];
          scalar_data_range?: [number, number];
          colormap?: string;
          has_scalars?: boolean;
          extend_to_all?: string[];
          [key: string]: unknown;
      }

      • [key: string]: unknown

        Any additional attributes

      • Optionaltransform?: readonly number[]

        Transformation matrix (16 elements for a column-major 4x4 matrix). Typed as readonly number[] because zarr metadata is parsed dynamically; see import('../types/zarr').Matrix4x4 for the narrowed 16-tuple shape.

      • Optionalnd_transform?: NdTransformMap

        Per-dimension transforms for non-displayed dimensions

      • Optionalopacity?: number

        Rendering attributes

      • Optionalabsorption?: number
      • Optionalgamma?: number
      • Optionalintensity?: number
      • Optionaloffset?: number
      • Optionalblending_mode?: string
      • Optionaljoin?: string

        Lines-only join style at degree-2 polyline joints: 'none' | 'miter' (#790).

      • Optionalmax_radius?: number
      • Optionaln_points?: number
      • Optionallayer?: boolean

        Whether this node is exposed as a layer in the Layers panel

      • Optionalvisible?: boolean

        Initial visibility when the scene loads (default: true). Authoring-time only.

      • Optionalcolor_data_range?: [number, number]

        Min/max of color data, computed at encoding time

      • Optionalamplitude_data_range?: [number, number]

        Min/max of amplitude data (GSplats), computed at encoding time

      • Optionalscalar_data_range?: [number, number]

        Min/max of scalar data (Points/Lines), computed at encoding time

      • Optionalcolormap?: string

        Colormap name for scalar-to-color mapping (e.g., "viridis", "green", "custom")

      • Optionalhas_scalars?: boolean

        Whether this node has scalar values for colormap lookup

      • Optionalextend_to_all?: string[]

        Dimensions to extend visibility across (points visible at all values)

    • Install (or clear) the render-loop wake-up callback.

      Parameters

      • callback: (() => void) | null

      Returns void

    • Install (or clear) the notification used to arm online failure retries.

      Parameters

      • callback: (() => void) | null

      Returns void

    • Why this node's next rung is held back, if it is: 'density' when the density gate deferred it at the current framing, 'budget' when the last run's residency budget declined it, null otherwise (streaming, or nothing pending). Density first: a density refusal is also folded into the budget's declined set, and the camera-dependent reason is the actionable one.

      Parameters

      • path: string

      Returns RefinementHoldReason | null

    • Per-frame hook from the density walk: if any rung the gate deferred now fits (the camera moved in), re-kick refinement. Cheap when nothing is deferred (the common case). Returns how many paths resumed.

      Returns number

    • Load a complete scene from a Zarr store using chunk-based spatial indexing.

      Orchestrates the loading of hierarchical scene graphs, managing spatial indices, attribute inheritance, and dimension metadata. Supports both points and lines with automatic fallback for datasets without spatial ordering.

      The loading process:

      1. Opens Zarr store with optional two-level caching (L1 memory + L2 OPFS)
      2. Loads scene metadata and initializes dimensions
      3. Recursively constructs THREE.js scene graph from Zarr group hierarchy
      4. Creates spatial index loaders for efficient nD queries
      5. Connects loaders to data monitor for debugging

      Parameters

      Returns Promise<Group<Object3DEventMap>>

      Promise resolving to a THREE.Group containing the complete scene graph. The group's userData contains: - sceneDimensions: Dimension metadata if available - bounds: AABB of all points - nodeCount: Total number of leaf nodes

      If the Zarr store cannot be opened or is invalid

      If consolidated metadata (.zmetadata) is malformed

      If required arrays (positions) are missing from point nodes

      // Load a scene from HTTP URL
      const scene = await sceneLoader.loadScene('https://example.com/data.zarr');
      threeScene.add(scene);
      console.log(`Loaded ${scene.children.length} top-level nodes`);
      // Load with error handling
      try {
      const scene = await sceneLoader.loadScene(url);
      if (scene.children.length === 0) {
      console.warn('Scene is empty');
      }
      } catch (error) {
      console.error('Failed to load scene:', error);
      // Fallback to default visualization
      }
      // Access scene metadata after loading
      const scene = await sceneLoader.loadScene(url);
      const dims = scene.userData.sceneDimensions;
      if (dims) {
      console.log(`${dims.length}D dataset:`, dims.map(d => d.name).join(', '));
      }

      MultiLevelCachingStore for caching implementation

    • Derive a per-node view state from this.viewState, folding in extend_to_all tolerance overrides and the inverse nd_transform for the node's path.

      The single source of truth for query-state derivation: both the main update path and retryFailedLoader go through this method so a retry cannot load a different query region than a fresh update would. The pre-extraction retry skipped both adjustments, which could produce a "successful" retry rendering incorrect data.

      A fully-extended node (extend_to_all covers all non-displayed dims) is returned as a NORMAL { skip: false } node whose query is made slice-INVARIANT (extend-to-all tolerance sentinel + extended dims' slicePosition pinned to 0), so per-sweep re-queries hit the loader's same-view no-op. No extend_to_all skip is produced.

      Parameters

      • path: string

        Scene-graph path of the node, used to compose the world nd_transform from this node up to the root.

      • attrs: { extend_to_all?: string[] } | undefined

        Node attrs (only extend_to_all is read here).

      • opts: {
            applyPartialExtendTolerance: boolean;
            extendedToleranceCache?: Map<string, number[]>;
        }
        • applyPartialExtendTolerance: boolean

          When true, partial extend_to_all coverage triggers a tolerance override via getOrComputeExtendedTolerance. Points/GSplats: true. Lines: false because line bounds already encode their non-displayed spatial extent.

        • OptionalextendedToleranceCache?: Map<string, number[]>

          Optional cross-node cache for the partial-extend tolerance array. Main update path passes one cache per cycle; retry passes nothing (fresh).

      Returns { skip: false; viewState: ViewState }

    • Shared scaffolding for the per-geometry update loops. The Points / Lines / GSplats branches in updateView differ only in the type-specific work (deriveNodeViewState, call loader.updateView, post-process, setMetadata, return staged) — the surrounding try/catch with failedLoaders bookkeeping + retryCount tracking, profiler dispatch, and Promise.all are all identical and live here.

      Type Parameters

      • TLoader
      • TStaged

      Parameters

      • loaders: Map<string, TLoader>

        The map of (path → loader) for one geometry type.

      • loaderType: "Mesh" | "Points" | "Lines" | "GSplats"

        Human-readable type for profiler label + error log.

      • updateFn: (
            path: string,
            loader: TLoader,
            session: UpdateSession,
        ) => Promise<TStaged | null>

        Per-loader work; returns staged commit data or null when there's nothing to commit.

      • onArchiveFault: (fault: ArchiveFaultError) => void
      • OptionalresyncPaths: ReadonlySet<string>

      Returns Promise<{ staged: TStaged | null; session: UpdateSession }[]>

    • Re-run the current view state without blocking the caller. With paths (the partition parts that just re-entered the frustum) the sweep is restricted to loaders at/under them; otherwise every loader re-runs. Either way the view state is unchanged, so the view version is NOT bumped.

      Parameters

      • Optionalpaths: readonly string[]

      Returns void

    • Re-open exhausted progressive ladders after connectivity is restored.

      Returns boolean

    • Re-enter updateView with a drained pending state. The ONLY way a pending state may be re-entered: every drain site must consume the resync paths stashed for it, or they leak into a later, unrelated same-view pass (e.g. a depth-sort requestReprocess() queued behind a pass) and narrow it to loaders that have nothing to do with it — leaving the nodes that pass existed to re-commit stamp-less. A changed view ignores the paths anyway (see updateView), so passing them along is always safe.

      Parameters

      Returns Promise<void>

    • Whether a request can JOIN the main pass in flight instead of superseding it (#2943): it names a slice (a dims-driven request, not a {} reprocess that exists to force a re-commit), nothing newer is queued, neither the request nor the running pass carries a playback directive (a budget-free refine must still re-run a budgeted pass), and it merges to exactly the view the running pass is loading.

      Parameters

      Returns boolean

    • Update all points and lines for a new view state.

      Uses serialized execution to prevent race conditions: only one update runs at a time. If a new update arrives while one is in progress, it's queued as "pending" and processed after the current update completes. Only the LATEST pending state is kept (older ones are discarded), ensuring eventual convergence without starvation.

      Parameters

      Returns Promise<void>

    • Any registered loader (points / lines / gsplats / mesh) with LODs left to stream.

      Returns boolean

    • Kick the progressive refinement orchestrator from OUTSIDE an update pass.

      Refinement is normally scheduled only at update-view tails (queue-next.ts) and after the initial scene load (load-scene.ts) — loaders that register OUTSIDE those moments otherwise sit at their first additive chunk until the next slice change. The one such registration path is a deferred lod_group SUBTREE activation (e.g. the overview recipe's fine kind=partition branch): its part leaves join the sweep maps mid-session, so the deferred-group ensureLoaded calls this after loadChildren settles.

      Lock discipline: when idle, take the serialization lock and run the same orchestrator the other kick sites use (each phase releases/hands off the lock — see scheduleGSplatsRefinement), with the same belt-and-braces release on an orchestrator-glue rejection. When an update or refinement already holds the lock, its own tail probe usually covers the new loaders — but a sequenced refinement run may already be PAST the new loaders' geometry phase, so instead of assuming, re-check on a short timer (single pending re-check; drops out as soon as nothing has more LODs or this loader is disposed). A plain timer, deliberately NOT scheduleFrame: that helper runs synchronously when rAF is missing, which would turn this lock-held re-check into unbounded recursion.

      "Busy" is the lock OR a live refinement, not the lock alone: the final refinement phase's finalReleaseLock opens the lock while _refining is still set (the flag is cleared one level up, in the orchestrator's finally, once the phase's await unwinds). A microtask already queued at that instant — a deferred lod_group ensureLoaded continuation is exactly one — would read the open lock as idle and start a SECOND refinement run on top of the first. The two runs share one _refining boolean, so the first run's finally would clear it mid-flight and isLoadPassInProgress() would report a load pass for the whole remaining drain.

      Returns void

    • Schedule progressive GSplats LOD refinement.

      Thin wrapper around runGSplatsRefinement in data/gsplats/lod-refinement.ts. The full timing semantics — rAF yield per pass, cancellation hand-off on pending view-state, and lock release on normal completion — live in that module.

      Returns Promise<void>

    • Stamp the load timeline's refinementComplete milestone when the final geometry phase ran every ladder to completion — not on a cancellation hand-off (the next update re-kicks refinement) and not on a dead loader.

      Parameters

      • cancelled: boolean

      Returns void

    • Clear the committedData identity stamp on a node's mesh. Called when a lazy LOD level is demoted: its geometry returned to the evictable pool, so the stamp (a) no longer describes what's on the GPU and (b) would pin the released node's large CPU arrays in memory. Re-promotion builds a fresh loader → new data reference → full recommit either way.

      Parameters

      • path: string

      Returns void

    • Aggregate visible counts from all lines and gsplats meshes and update monitor. This should be called after view updates to report accurate visible counts.

      Returns void

    • Recompute the monitor's visible-element tally outside the data-load cycle. Substitutive LOD selection (LODGroupRegistry.evaluatePerFrame) swaps which level renders on camera moves with no reload, so the per-frame callback calls this after a LOD switch — otherwise the monitor's "visible" counts stay pinned to the level that was active at the last updateView (e.g. the coarsest default level).

      Returns void

    • Commit lines geometry to GPU buffers (synchronous). Called as part of the atomic commit stage — no async operations allowed. The optional session is forwarded so the helper can record an "Update Buffers" child entry under the per-node profiler session (parity with Points and GSplats).

      Parameters

      Returns void

    • Commit gsplats geometry to GPU buffers (synchronous). Called as part of the atomic commit stage — no async operations allowed. The optional session is forwarded so the helper can record an "Update Buffers" child entry under the per-node profiler session (parity with Points and Lines).

      Parameters

      Returns void

    • Project + stage a mesh for commit.

      Implementation lives in scene-loader/process/data-processor-mesh.ts. Async only because backend selection is, so unlike the lines/gsplats twins this never resolves to null — there is no worker projection that can decline.

      Parameters

      • path: string
      • data: LoadedMeshData
      • viewState: ViewState
      • attrs: Pick<
            MeshMetadata,
            "normal_dims"
            | "double_sided"
            | "extend_to_all"
            | "slab_tolerance",
        >

      Returns Promise<StagedMeshCommit>

    • Commit mesh geometry (synchronous).

      Implementation lives in scene-loader/commit/commit-mesh-geometry.ts. Takes no GPU buffer pool: a mesh's vertex buffers are uploaded once per displayDims epoch and never resized, so there is nothing for the pool to recycle.

      Parameters

      Returns void

    • Build the per-call NodeBuildCtx for the initial-load leaf helpers. Snapshots viewState + factoryDeps so a concurrent updateView can't mutate state mid-flight. Never passes this.

      Returns NodeBuildCtx

    • Initialize scene dimensions from metadata. Implementation lives in scene-loader/nodes/initialize-scene-dimensions.ts; null return means validation failed and the existing viewState stays.

      Parameters

      • sceneDims: unknown

      Returns void

    • Whether candidate describes the view this loader has ALREADY committed (same displayed dims, slice, tolerances and per-dim query signature, by viewStatesEqual). Used by updateSceneForDimensions to turn the post-load updateAllNDNodes — fired unconditionally after loadScene has fetched, decoded, projected and committed every node at exactly this state — into a no-op instead of a second full pass (measured: L0 hits == misses on every 3-D scene, and ~1 s of extra main-thread work on a 29.6 M-splat slide). A real slider change compares unequal and proceeds.

      False while a requested main pass has not finished: this.viewState names a pass's view as soon as it starts. This also covers the frame boundary between a superseded pass and its queued replacement, when no main pass is running but the old view never committed (#2943).

      Parameters

      Returns boolean

    • Normalize URL for zarr store access. Guards window so this works in non-DOM contexts (tests, embed-in-Worker scenarios). Absolute URLs ignore the origin entirely; the fallback only matters for relative paths.

      Parameters

      • url: string

      Returns string

    • Get information about failed loaders

      Returns ReadonlyMap<string, { error: Error; timestamp: number; retryCount: number }>

      Map of loader paths to error information

    • Whether a path or one of its descendants has a recorded network failure.

      Parameters

      • path: string

      Returns boolean

    • Build a FailedLoadsProviderPort over the failed-load set for a UI consumer — the data-monitor's failure banner (wired in monitor-wiring.ts) and the layers panel's per-row error badge (wired in core/app/dataset/load-dataset.ts). Each call returns a NEW provider object (the monitor and the panel hold distinct instances), but all of them read the SAME live loader failures, archive-fault latch, and latched lazy branches through the one retryAllFailedLoaders entry point, so they always agree. getFailedReason powers the layers-panel tooltip.

      Returns FailedLoadsProviderPort

    • Whether any failure is worth an AUTOMATIC retry: a latched archive fault, a transient loader cause still under the attempt cap (see LoaderRegistry.autoRetryablePaths), or a lazy branch latched by an archive fault. The connectivity-triggered retry gates on this so deterministic ordinary loader failures remain quiet while reconnecting can re-open the loader and deferred LOD work.

      Returns boolean

    • Clear failed loader tracking Useful for retry operations or after user acknowledges errors

      Returns void

    • Retry loading a specific failed loader.

      Re-triggers the update for a failed loader using the current view state. Useful for recovering from transient network errors or after connectivity is restored.

      Parameters

      • path: string

        The path of the failed loader to retry

      Returns Promise<boolean>

      Promise resolving to true if retry succeeded, false if failed or not found. For a LAZY substitutive LOD level (not in the sweep maps), true means the deferred reload was KICKED (fire-and-forget) — the lazy thunk owns the eventual ready/failed outcome, and a repeat failure re-records itself for another retry. Registered lines paths first wait for the session working-set gate, so this call can remain pending behind an eager scene walk.

      // Retry a specific loader after network recovery
      const success = await sceneLoader.retryFailedLoader('/points/cloud1');
      if (success) {
      console.log('Loader recovered successfully');
      }
    • Retry all failed loaders. Useful for batch recovery after network connectivity is restored.

      Parameters

      • opts: { onlyAutoRetryable?: boolean } = {}
        • OptionalonlyAutoRetryable?: boolean

          Retry only entries that pass the automatic-retry filter (a latched archive fault, or a transient cause under the attempt cap). Set by the connectivity-triggered retry. A manual Retry omits it and forces every failed path.

      Returns Promise<{ succeeded: string[]; failed: string[]; deferred?: boolean }>

      Promise resolving to { succeeded, failed, deferred? }. deferred: true means NOTHING was retried — a main update held the serialization lock, so the batch was refused (every path is reported in failed for compatibility, but none genuinely re-failed). Callers must not present a deferred result as a failed re-attempt; retry again once the update settles.

      // Retry all failed loaders after network recovery
      const result = await sceneLoader.retryAllFailedLoaders();
      console.log(`Recovered: ${result.succeeded.length}, Still failing: ${result.failed.length}`);
    • Dispose of all resources.

      Async because the caching store dispose path drains the prefetcher, cancels in-flight validation, and flushes OPFS metadata — work that a dataset switch should wait for before constructing the next loader. Existing sync callers (the beforeunload path, SceneLoaderManager.destroyLoader/destroyAll) still work; the returned promise just unwinds in the background. Callers that need deterministic teardown should await this method or use SceneLoaderManager.destroyLoaderAsync (added in a follow-up commit).

      Worker pool policy: Web Workers used for projection/decoding live in a MODULE-LEVEL singleton (workers/worker-pool.ts:getWorkerPool), not per-SceneLoader. Dataset switches deliberately do NOT terminate workers — the pool is bounded, and tearing it down per switch would force a fresh worker spin-up on the next load (10s of ms of WASM re-init on each cycle). Workers are terminated only at app shutdown via disposeWorkerPool() in core/app.ts, which is the right scope for that lifecycle.

      Returns Promise<void>