Debug Interface Guide
Overview
Luxar exposes a window.__luxarDebug object that provides programmatic access
to the viewer’s internal state. This is intended for:
Diagnosing rendering or data-loading issues during development
Writing and debugging Playwright E2E tests
AI-assisted debugging via the agent driver (
pnpm agent:debug)Generating state dumps for bug reports
The interface is defined in two stages: src/core/bootstrap.ts creates the
base object with the app reference and version string, then
installDebugInterface() (in src/core/app/debug/debug-interface.ts, invoked
from app.ts via setupDebugInterface()) extends it with runtime components
(scene, camera, cache helpers, etc.) after initialization completes.
Enabling the Debug Interface
URL parameter (recommended):
http://localhost:5173/?src=http://127.0.0.1:8000&debug
localStorage (persists across page loads):
localStorage.setItem('luxar.debug', 'true');
Note: either flag — the ?debug URL parameter or the persisted luxar.debug
localStorage entry — enables full debug mode: bootstrap.ts seeds the base
debug object, and setupDebugInterface() in app.ts (which delegates to
installDebugInterface() in src/core/app/debug/debug-interface.ts) adds the
runtime components (scene, camera, controls, etc.) once initialization completes.
When enabled, the viewer logs Debug interface available at window.__luxarDebug
to the console and, after initialization, prints a summary of available commands.
When the ?debug URL parameter is absent and localStorage is not set, the
object is never created, so there is zero overhead in production.
Available Properties and Methods
Base properties (from bootstrap.ts, available immediately)
Property |
Type |
Description |
|---|---|---|
|
|
Unconditional pre-initialization build identity: version, commit, build time, and whether the bundle was stamped. |
|
|
The application instance. |
|
|
Captures all console output for replay. |
|
|
|
Runtime components (from installDebugInterface(), available once runtimeReady is true)
Property |
Type |
Description |
|---|---|---|
|
|
The Three.js scene graph root. |
|
|
Active camera (perspective or orthographic). |
|
|
The WebGL renderer instance. |
|
|
Orbit/fly controls manager. |
|
|
Bloom, AO, AA pipeline. |
|
|
Manages the render loop. |
|
|
Keyboard and mouse input system. |
|
|
UI panel for rendering settings. |
|
|
Screenshot and video capture panel. |
|
|
Dimension navigation state. |
|
|
|
Helper functions
Method |
Return type |
Description |
|---|---|---|
|
|
JSON-serializable snapshot of current state (point counts per cloud, camera position/FOV, dimension info, animation status, initialization status, the two memory-ceiling fields tabled below, and |
|
|
Kicks the animation loop to force a single render frame. Useful for stable screenshots. |
|
|
Returns the singleton scene loader manager for inspecting loaded data. |
Memory-ceiling fields on getState()
Two fields answer “were these element counts decided by the store, or by this
machine?”. core/app/debug/capture-readiness.ts refuses a capture on either
(#2508). refinementResidency and gpuPool.byteBudgetEvictions are
cumulative for the life of the scene loader and never reset within it: they
report that something happened at some point while this scene was loaded, not
that it is true right now. The scope is the LOADER, not the page — an in-page
dataset switch builds a fresh SceneLoader (fresh reporter, fresh GPU pool), so
the next scene starts clean with no reload. The rest of gpuPool is not
cumulative: activeBytes,
pooledBytes, totalBytes, largestPooledBytes, activeBuffers and
pooledBuffers are instantaneous readings, and evictions is a lifetime
counter dominated by routine recycling.
Field |
Type |
Description |
|---|---|---|
|
|
GPU buffer-pool usage — the instantaneous |
|
|
Present once progressive refinement declined at least one rung at the residency byte ceiling; carries the first refusal’s |
Cache helpers (__luxarDebug.cache)
Method |
Return type |
Description |
|---|---|---|
|
|
Returns hit/miss/size statistics for all three cache tiers. |
|
|
Lists datasets stored in the caching store. |
|
|
Clears the L0 decompressed chunk cache (in-memory). |
|
|
Clears the L1 memory cache. |
|
|
Clears the L2 OPFS (Origin Private File System) persistent cache. |
|
|
Clears all three cache tiers. |
Common Debug Workflows
Inspecting scene state
Open the browser console and run:
const state = __luxarDebug.getState();
console.table(state.pointClouds); // Per-cloud point counts, visibility, attributes
console.log('Total points:', state.totalPoints);
console.log('Camera:', state.camera);
console.log('Dimensions:', state.dimensions);
To walk the Three.js scene graph directly:
__luxarDebug.scene.traverse(obj => {
if (obj.type === 'Points') console.log(obj.name, obj.geometry.attributes);
});
Checking cache performance
const stats = __luxarDebug.cache.getStats();
console.log('L0 (decompressed):', stats.l0);
console.log('L1 (memory):', stats.l1);
console.log('L2 (OPFS):', stats.l2);
To isolate a cache tier during testing:
__luxarDebug.cache.clearL0(); // Force re-decompression on next access
await __luxarDebug.cache.clearL2(); // Force re-fetch from network
Forcing re-renders
__luxarDebug.renderOnce();
This restarts the animation loop briefly, producing at least one fresh frame. Useful after programmatically changing material uniforms or camera position.
Dumping state for bug reports
Copy-paste the following into the console to produce a JSON blob suitable for attaching to an issue:
JSON.stringify(__luxarDebug.getState(), null, 2);
For cache state:
JSON.stringify(__luxarDebug.cache.getStats(), null, 2);
Using with Playwright (E2E Tests)
E2E tests load the viewer with ?debug in the URL and wait for the debug
interface before making assertions. The helpers in
src/tests/e2e/helpers.ts encapsulate the common patterns.
Eight helpers poll getState().isLoading: waitForDataLoaded,
waitForDimensionNavigation, waitForSpatialQuery,
waitForSpatialQueryOrThrow, waitForNavigationComplete,
waitForNavigationCompleteOrThrow, and the state-based fallbacks inside
waitForRenderStable and waitForNextRender. (waitForPointsLoaded does not —
it gates on state.totalPoints >= minPoints.) So that flag has to be a real
boolean in every snapshot, not merely absent when nothing is loading: !undefined
is true, which gates on nothing.
isLoading is scoped to a load pass — an updateView sweep (fetch / decode /
upload) up to its geometry commit, a failed-loader retry (which takes the same
lock), or a view-state that is queued behind either and has not begun
loading yet. That last clause is what keeps the refinement exclusion below from
opening a hole: a nav arriving during a refinement hold parks in the queue
without touching the lock, so an isLoading: true with no fetch in flight is
the expected reading on any laddered dataset. It deliberately does not
cover:
the initial
loadScene(that path only touches the loader’s lock at its very end, to hand it to the post-load refinement kick). Wait oninitializedfor the first load. An in-page dataset switch is covered by neither flag:initializedstays true and the replacement loader is registered before itsloadSceneruns, soisLoadingreads false throughout the switch’s load.lazy substitutive-LOD / deferred-partition
ensureLoadedpromotions, which run outside anyupdateViewcycle and surface as content-change notifications instead.the progressive-LOD refinement drain, which inherits the same lock after the current view has already committed. Excluding it keeps the flag reporting first-commit latency rather than full-ladder latency, so a wait doesn’t sit through every additive ladder.
SceneLoader.isUpdateInProgress()retains the broader “the lock is held at all” meaning for the adaptive-DPR manager and the init pipeline.
Waiting for initialization
import { waitForLuxarReady } from './helpers';
await waitForLuxarReady(page); // Waits for getState().initialized === true
Under the hood this polls window.__luxarDebug.getState().initialized:
await page.waitForFunction(() => {
const debug = (window as any).__luxarDebug;
return debug && debug.getState && debug.getState().initialized;
}, null, { timeout: 45000 });
Reading state from a test
import { getLuxarState } from './helpers';
const state = await getLuxarState(page);
expect(state.totalPoints).toBeGreaterThan(0);
Forcing a render for screenshots
import { renderOnce } from './helpers';
await renderOnce(page); // Triggers render + 100ms settle
await page.screenshot({ path: 'screenshot.png' });
Waiting for data to load
import { waitForPointsLoaded } from './helpers';
await waitForPointsLoaded(page, 100); // Wait until >= 100 points are loaded
Direct page.evaluate access
For one-off checks not covered by helpers:
const cacheStats = await page.evaluate(() => {
return (window as any).__luxarDebug.cache.getStats();
});
Using with the Agent Debugger
The agent debugger (pnpm agent:debug) launches a headless Playwright browser
with ?debug in the URL and dumps the debug interface state automatically.
cd packages/luxar-viewer
pnpm agent:debug # Headless, captures state + screenshot
pnpm agent:debug:visible # Headed browser for visual inspection
The output includes:
[BROWSER-CONSOLE-*]– all console messages captured byconsoleInterceptorA JSON state dump from
getState()(point counts, camera, dimensions)A screenshot saved to
test-results/debug/debug-view.png
Typical workflow:
Run
pnpm agent:debugto capture current viewer state.Inspect the JSON dump and screenshot for anomalies.
If needed, add
console.log()calls in source code and re-run.Fix the issue and verify with another
pnpm agent:debugrun.Remove any temporary logging before committing.
Source Files
File |
Role |
|---|---|
|
Creates the base |
|
Calls |
|
|
|
Playwright helper functions that consume the debug interface. |