Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 7 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Purpose

Exora turns catalogued astronomy into a place you can stand in. It reads confirmed exoplanets from the NASA Exoplanet Archive and stars from SIMBAD, derives a deterministic visual recipe from each object's measured properties, and renders that recipe as a real-time Babylon.js world you can orbit on a desktop or walk inside a WebXR headset.
Exora turns catalogued astronomy into a place you can stand in. It reads confirmed exoplanets from the NASA Exoplanet Archive and stars from SIMBAD, derives a deterministic visual recipe from each object's measured properties, and renders that recipe as a real-time Babylon.js world you can orbit on a desktop or place on a table in phone AR.

Every world is a reading of a catalog row, never observed imagery. Exora keeps the three tiers apart at the type level and in the interface: **measured** values are printed verbatim from the archive, **derived** values come from established physics applied to those measurements, and **inferred** appearance is a cautious probabilistic read carrying its own confidence. A field the catalog never reported stays `null` rather than being backfilled with a plausible number.

Expand All @@ -29,11 +29,9 @@ The system diorama is where that discipline is most visible, because a picture o
- **Surface vistas built from geology:** Standing on a world means standing in its landform provinces — crater-saturated highlands, dune seas, canyon systems, lava fields, fractured ice, folded ranges — mixed per world and blended across a patch that runs to a real horizon. For a Solar System body the provinces, palette, crater density, relief and sky are stated from mission science rather than inferred: Io has no impact craters because nothing on it survives long enough to keep one, Venus has almost none, Europa's relief is a fraction of a rocky planet's, and the Moon's sky is black at noon. Sunlight is baked per vertex into the terrain and everything standing on it, so a low sun throws real shadows, and each world is exposed the way a camera would expose for it.
- **One resolved star implementation:** Photosphere with multi-scale convection, a supergranular magnetic network, limb-brightened faculae, deterministic starspots, limb darkening, corona, and glare — shared by the star scene and by every host star hanging in a planet's sky.
- **World Forge:** A seeded, reproducible builder for procedural planets, custom stars, and black holes, using the same recipe engines as the catalogs.
- **Persistent immersive session:** The engine, scene, camera, and WebXR session outlive the active destination, so entering and leaving VR does not rebuild the viewing context.
- **iPhone and Android AR:** The same immersive control prefers the established Meta Quest VR session, selects native `immersive-ar` on an AR-only phone, and uses Variant Launch's App Clip handoff on iPhone. AR presents the existing Babylon world at tabletop scale over camera passthrough, with hit-tested placement, drag repositioning, and pinch scaling — no GLB or USDZ export path.
- **Direct Quest shortcuts:** The controller trigger can enter or exit immersive VR when the runtime exposes it. Explore and World Forge remain browser-only; no browser UI is captured or rendered inside VR.
- **Adaptive rendering budget:** Separate desktop, mobile, and Quest profiles govern shader octaves, sphere tessellation, star count, texture detail, and render scale. Immersive sessions raise fixed foveation after three seconds below 62 FPS and relax it again above 70.
- **Desktop WebXR emulation:** An opt-in Immersive Web Emulation Runtime installs a synthetic Quest over `navigator.xr`, so the immersive path runs unmodified in a normal tab.
- **Persistent immersive session:** The engine, scene, camera, and WebXR session outlive the active destination, so entering and leaving AR does not rebuild the viewing context.
- **iPhone and Android AR:** The immersive control selects native `immersive-ar` on a phone that supports it and uses Variant Launch's App Clip handoff on iPhone. AR presents the existing Babylon world at tabletop scale over camera passthrough, with hit-tested placement, drag repositioning, and pinch scaling — no GLB or USDZ export path.
- **Adaptive rendering budget:** Separate desktop and mobile profiles govern shader octaves, sphere tessellation, star count, texture detail, and render scale.
- **Graceful degradation:** A six-hour planet cache, a twelve-hour star cache, and a bundled local profile keep the experience alive when NASA, SIMBAD, or the API is unreachable.

## Keyboard
Expand All @@ -53,7 +51,6 @@ The system diorama is where that discipline is most visible, because a picture o
- **Web:** React 19, Vite, Babylon.js 9 (WebGL2 + WebXR)
- **API:** Hono on Node 24
- **Data sources:** NASA Exoplanet Archive TAP, SIMBAD TAP (CDS, Strasbourg), and NASA/JPL APIs
- **Immersive tooling:** IWER and `@iwer/devui` for desktop WebXR emulation
- **Hosting:** Vercel static output plus a Vercel Function, with Analytics and Speed Insights

## Development
Expand Down Expand Up @@ -117,9 +114,9 @@ vp run @exora/api#build

### Immersive mode

WebXR requires a secure context. Localhost works for desktop development, but testing from a Quest on the local network needs HTTPS or a deployed origin.
WebXR requires a secure context. Localhost works for desktop development, but testing from a phone on the local network needs HTTPS or a deployed origin.

To exercise the immersive VR flow without a headset, open <http://localhost:5173/?xr=emulate>; `?xr=stereo` renders both eyes side by side and `?xr=off` returns to the native runtime. See the [desktop WebXR emulation guide](docs/webxr-emulation.md), use the [Meta Quest smoke-test checklist](docs/quest-testing.md) for headset validation and performance targets, and follow the [iPhone AR deployment and smoke-test guide](docs/iphone-ar.md) for Variant Launch configuration and real-device testing.
Follow the [iPhone AR deployment and smoke-test guide](docs/iphone-ar.md) for Variant Launch configuration and real-device testing.

## Workspace

Expand Down Expand Up @@ -172,13 +169,12 @@ quotas. See [API rate limiting and caching](docs/api-rate-limiting-and-caching.m
boundary, shared-cache behavior, operational tradeoffs, and the evidence threshold for adding a
globally enforced control.

The browser holds one Babylon engine for the lifetime of the page. Worlds are built into and removed from that single scene, which is what lets an immersive session survive travel between destinations.
The browser holds one Babylon engine for the lifetime of the page. Worlds are built into and removed from that single scene, which is what lets travel between destinations reuse one engine and XR session.

```mermaid
flowchart TB
subgraph Clients
Desktop["Desktop browser<br/>orbit controls"]
Headset["Meta Quest<br/>WebXR immersive session"]
Phone["iPhone / Android<br/>WebXR AR session"]
end

Expand All @@ -200,7 +196,6 @@ flowchart TB
end

Desktop --> Static
Headset --> Static
Phone --> Static
Static --> UI
UI --> Host
Expand Down
8 changes: 4 additions & 4 deletions THIRD_PARTY_ASSETS.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ quantisation.
| Neptune | `textures/solar-system/neptune.jpg` | NASA/JPL Solar System Simulator visualization | `science.nasa.gov/3d-resources/neptune/` |
| Pluto | `textures/solar-system/pluto.jpg` | New Horizons MVIC global color map / NASA/JHUAPL | `science.nasa.gov/resource/pluto-global-color-map/` |

Mobile and Quest routes use 1024×512 JPEG derivatives of only the source mosaics larger than
Mobile routes use 1024×512 JPEG derivatives of only the source mosaics larger than
900 kB. They preserve the source pixels and coverage with no retouching: `callisto-mobile.jpg`,
`dione-mobile.jpg`, `enceladus-mobile.jpg`, `europa-mobile.jpg`, `ganymede-mobile.jpg`,
`mars-mobile.jpg`, `mercury-mobile.jpg`, `mimas-mobile.jpg`, `rhea-mobile.jpg`,
Expand Down Expand Up @@ -265,8 +265,8 @@ They are flat-lit, orthographic, texture-only material scans with no text, objec
baked directional lighting. The exact production prompt set is recorded in
`docs/planet-texture-prompts.md`.

On Quest and mobile, only the palette-selected 1K chemistry map is sampled. Desktop adds the two
palette-selected 2K normal/roughness pairs. This keeps chemistry visible on constrained headsets
On mobile, only the palette-selected 1K chemistry map is sampled. Desktop adds the two
palette-selected 2K normal/roughness pairs. This keeps chemistry visible on constrained devices
without paying the full physical-detail fragment cost.

## Babylon.js KTX2 runtime
Expand All @@ -287,7 +287,7 @@ Babylon release adds another.
| ------------------------------ | -------------------------------------------- | -------------------------------------- |
| `babylon.ktx2Decoder.js` | Worker-side decoder entry point | npm `babylonjs-ktx2decoder@9.22.1` |
| `msc_basis_transcoder.js/wasm` | ETC1S/BasisLZ — roughness and chemistry maps | `cdn.babylonjs.com/ktx2Transcoders/1/` |
| `uastc_astc.wasm` | UASTC → ASTC, used on Quest and mobile | `cdn.babylonjs.com/ktx2Transcoders/1/` |
| `uastc_astc.wasm` | UASTC → ASTC, used on mobile | `cdn.babylonjs.com/ktx2Transcoders/1/` |
| `uastc_bc7.wasm` | UASTC → BC7, used on desktop | `cdn.babylonjs.com/ktx2Transcoders/1/` |
| `uastc_r8_unorm.wasm` | UASTC → R8, single-channel fallback | `cdn.babylonjs.com/ktx2Transcoders/1/` |
| `uastc_rg8_unorm.wasm` | UASTC → RG8, two-channel fallback | `cdn.babylonjs.com/ktx2Transcoders/1/` |
Expand Down
2 changes: 0 additions & 2 deletions apps/website/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,12 @@
"react-dom": "catalog:"
},
"devDependencies": {
"@iwer/devui": "catalog:",
"@lhci/cli": "catalog:",
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@vitejs/plugin-react": "catalog:",
"@vitest/browser-playwright": "catalog:",
"iwer": "catalog:",
"playwright": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
Expand Down
19 changes: 8 additions & 11 deletions apps/website/src/App.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,13 @@ const planetSceneStub = vi.hoisted(() => ({

const mountedWorld = () => ({
dispose: () => undefined,
focusXrRig: () => undefined,
restoreDesktopView: () => undefined,
setEphemeris: () => undefined,
setEphemerisTime: () => undefined,
});

vi.mock("./scene-host.ts", () => {
let insideHeadset = false;
let insideXr = false;
const host = {
beginTravel: () => undefined,
cancelTravel: () => undefined,
Expand All @@ -36,12 +35,11 @@ vi.mock("./scene-host.ts", () => {
dispose: () => undefined,
engine: null,
enterImmersive: async () => {
insideHeadset = true;
insideXr = true;
},
getFps: () => 60,
isArSupported: () => false,
isInXr: () => insideHeadset,
isVrSupported: () => false,
isInXr: () => insideXr,
prefersReducedMotion: () => false,
mountWorld: async (build: () => unknown) => build(),
onRendererStatus: (listener: (status: string) => void) => {
Expand All @@ -53,13 +51,13 @@ vi.mock("./scene-host.ts", () => {
return () => undefined;
},
onXrStatus: (listener: (status: string) => void) => {
listener("ready-vr");
listener("ready-ar");
return () => undefined;
},
profile: { hardwareScalingLevel: 1, tier: "desktop" },
qualityTier: "desktop",
setInXr: (value: boolean) => {
insideHeadset = value;
insideXr = value;
},
renderSuspensions: 0,
suspendRendering: () => {
Expand All @@ -72,7 +70,6 @@ vi.mock("./scene-host.ts", () => {
};
},
scene: null,
xrCamera: () => null,
};

return { acquireSceneHost: () => host, recreateSceneHost: () => host };
Expand Down Expand Up @@ -1120,15 +1117,15 @@ desktopTest(
);

desktopTest(
"VR presents the active destination without a console and exits to the same browser view",
"AR presents the active destination without a console and exits to the same browser view",
async () => {
stubArchive();
mountApp();
const destination = page.getByRole("heading", { level: 1 });
await expect.element(destination).toBeVisible();
const destinationName = destination.element().textContent;

await userEvent.click(page.getByRole("button", { name: "Enter VR" }));
await userEvent.click(page.getByRole("button", { name: "View in AR" }));
expect(page.getByRole("dialog")).not.toBeInTheDocument();

stubbedHost().setInXr(false);
Expand Down Expand Up @@ -1220,7 +1217,7 @@ test("the navigation deck keeps an even gap between every control", async () =>
.querySelector<HTMLElement>('[data-testid="clear-view"]')!
.getBoundingClientRect();
const xr = document
.querySelector<HTMLElement>('[data-testid="enter-vr"]')!
.querySelector<HTMLElement>('[data-testid="enter-ar"]')!
.getBoundingClientRect();
const firstGap = clearView.left - discover.right;
const secondGap = xr.left - clearView.right;
Expand Down
6 changes: 3 additions & 3 deletions apps/website/src/black-hole-lensing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,9 +259,9 @@ export interface LensingOptions {
seed: number;
}

const STEP_BUDGET = { desktop: 180, mobile: 120, quest: 90 } as const;
const STEP_SCALE = { desktop: 0.1, mobile: 0.13, quest: 0.16 } as const;
const DISK_OCTAVES = { desktop: 4, mobile: 3, quest: 2 } as const;
const STEP_BUDGET = { desktop: 180, mobile: 120 } as const;
const STEP_SCALE = { desktop: 0.1, mobile: 0.13 } as const;
const DISK_OCTAVES = { desktop: 4, mobile: 3 } as const;

export const createLensingMaterial = (scene: Scene, options: LensingOptions): ShaderMaterial => {
Effect.ShadersStore.exoraLensingVertexShader = LENSING_VERTEX_SHADER;
Expand Down
14 changes: 0 additions & 14 deletions apps/website/src/black-hole-scene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import type { MountedWorld, SceneHost } from "./scene-host.ts";
import { createStarfield } from "./star-visuals.ts";

const BLACK_HOLE_POSITION = new Vector3(0, 0.7, 7.5);
const XR_BLACK_HOLE_STAND = new Vector3(0, 0, -10);
const COMPANION_OFFSET = new Vector3(-7.8, 2.6, 0.4);
const SHADOW_RADIUS = 2.125;

Expand Down Expand Up @@ -122,20 +121,7 @@ export const createBlackHoleWorld = (
});
const firstFrameObserver = scene.onAfterRenderObservable.addOnce(onFirstFrame);

const placeXrCamera = (initial: boolean): void => {
const rig = host.xrCamera();
if (!rig) return;
const headOffset = initial ? 0 : rig.realWorldHeight;
rig.position.set(
XR_BLACK_HOLE_STAND.x,
XR_BLACK_HOLE_STAND.y + headOffset,
XR_BLACK_HOLE_STAND.z,
);
rig.setTarget(BLACK_HOLE_POSITION);
};

return {
focusXrRig: placeXrCamera,
restoreDesktopView: () => camera.attachControl(canvas, true),
dispose: () => {
scene.onBeforeRenderObservable.remove(renderObserver);
Expand Down
5 changes: 2 additions & 3 deletions apps/website/src/components/ExplorationActions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,12 +105,11 @@ test("the star view offers the same controls under the same names", () => {
});

test.each([
["ready-vr", "Enter VR"],
["ready-ar", "View in AR"],
["ready-ar-launch", "View in AR"],
] as const)("the immersive control appears once %s is ready", (status, label) => {
const markup = topBarMarkup(status);
const immersive = deckButtons(markup).find((button) => button.includes('data-testid="enter-vr"'));
const immersive = deckButtons(markup).find((button) => button.includes('data-testid="enter-ar"'));

expect(deckNames(markup)).toContain(label);
expect(immersive).toBeDefined();
Expand All @@ -120,7 +119,7 @@ test.each([
test.each(["checking", "unavailable", "in-xr"] as const)(
"the immersive control stays away while XR is %s",
(status) => {
expect(topBarMarkup(status)).not.toContain('data-testid="enter-vr"');
expect(topBarMarkup(status)).not.toContain('data-testid="enter-ar"');
},
);

Expand Down
11 changes: 5 additions & 6 deletions apps/website/src/components/shell/TopBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,9 @@ const XR_LABELS: Partial<Record<XrStatus, string>> = {
entering: "Entering…",
"ready-ar": "View in AR",
"ready-ar-launch": "View in AR",
"ready-vr": "Enter VR",
};

const XR_READY = new Set<XrStatus>(["ready-ar", "ready-ar-launch", "ready-vr"]);
const XR_READY = new Set<XrStatus>(["ready-ar", "ready-ar-launch"]);

interface TopBarProps {
host: SceneHost | null;
Expand All @@ -22,8 +21,8 @@ interface TopBarProps {

/*
* Everything a view can be asked to do, in one row: go somewhere by name, browse, clear the view,
* or step into it with a headset. The immersive control only appears once the device can honour
* it — a disabled "not available" button is a promise the page cannot keep.
* or place it in the room around you. The immersive control only appears once the device can
* honour it — a disabled "not available" button is a promise the page cannot keep.
*/
export const TopBar = ({ host, xrStatus }: TopBarProps) => {
const { chromeHidden, openDiscover, openPalette, toggleChrome } = useChrome();
Expand Down Expand Up @@ -76,8 +75,8 @@ export const TopBar = ({ host, xrStatus }: TopBarProps) => {
{xrLabel ? (
<Button
className={styles["xr"]}
data-testid="enter-vr"
icon="headset"
data-testid="enter-ar"
icon="cube"
variant="surface"
disabled={!XR_READY.has(xrStatus)}
onClick={() =>
Expand Down
4 changes: 1 addition & 3 deletions apps/website/src/components/ui/Icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const PATHS = {
<path d="m12.8 7.2-1.6 4-4 1.6 1.6-4z" />
</>
),
cube: <path d="M10 3 16.5 6.7v6.6L10 17l-6.5-3.7V6.7zM3.5 6.7 10 10.4l6.5-3.7M10 10.4V17" />,
dice: (
<>
<rect x="3.5" y="3.5" width="13" height="13" rx="3" />
Expand Down Expand Up @@ -73,9 +74,6 @@ const PATHS = {
<rect x="11.5" y="11.5" width="5" height="5" rx="1.2" />
</>
),
headset: (
<path d="M2.5 8.2C2.5 6.4 4 5 5.8 5h8.4c1.8 0 3.3 1.4 3.3 3.2v2.9c0 1.8-1.4 3.2-3.2 3.2h-1.5c-1 0-1.5-.6-2-1.4-.4-.6-.5-.9-.8-.9s-.4.3-.8.9c-.5.8-1 1.4-2 1.4H5.7c-1.8 0-3.2-1.4-3.2-3.2z" />
),
home: <path d="M3.5 9 10 3.5 16.5 9v7a.5.5 0 0 1-.5.5h-3.5v-5h-5v5H4a.5.5 0 0 1-.5-.5z" />,
info: (
<>
Expand Down
9 changes: 1 addition & 8 deletions apps/website/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,4 @@ if (import.meta.env.PROD && "serviceWorker" in navigator) {
});
}

if (import.meta.env.DEV || import.meta.env.VITE_XR_EMULATOR === "1") {
void import("./xr-emulator.ts").then(({ installXrEmulator, isXrEmulatorRequested }) => {
if (isXrEmulatorRequested()) void installXrEmulator().then(render);
else render();
});
} else {
render();
}
render();
Loading
Loading