diff --git a/README.md b/README.md index 26e28c5..7a01a39 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 @@ -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 @@ -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 ; `?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 @@ -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
orbit controls"] - Headset["Meta Quest
WebXR immersive session"] Phone["iPhone / Android
WebXR AR session"] end @@ -200,7 +196,6 @@ flowchart TB end Desktop --> Static - Headset --> Static Phone --> Static Static --> UI UI --> Host diff --git a/THIRD_PARTY_ASSETS.md b/THIRD_PARTY_ASSETS.md index 8e4ed72..d4b9628 100644 --- a/THIRD_PARTY_ASSETS.md +++ b/THIRD_PARTY_ASSETS.md @@ -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`, @@ -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 @@ -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/` | diff --git a/apps/website/package.json b/apps/website/package.json index d567e68..b02b715 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -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:", diff --git a/apps/website/src/App.browser.test.tsx b/apps/website/src/App.browser.test.tsx index e2042c2..4183004 100644 --- a/apps/website/src/App.browser.test.tsx +++ b/apps/website/src/App.browser.test.tsx @@ -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, @@ -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) => { @@ -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: () => { @@ -72,7 +70,6 @@ vi.mock("./scene-host.ts", () => { }; }, scene: null, - xrCamera: () => null, }; return { acquireSceneHost: () => host, recreateSceneHost: () => host }; @@ -1120,7 +1117,7 @@ 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(); @@ -1128,7 +1125,7 @@ desktopTest( 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); @@ -1220,7 +1217,7 @@ test("the navigation deck keeps an even gap between every control", async () => .querySelector('[data-testid="clear-view"]')! .getBoundingClientRect(); const xr = document - .querySelector('[data-testid="enter-vr"]')! + .querySelector('[data-testid="enter-ar"]')! .getBoundingClientRect(); const firstGap = clearView.left - discover.right; const secondGap = xr.left - clearView.right; diff --git a/apps/website/src/black-hole-lensing.ts b/apps/website/src/black-hole-lensing.ts index ed8a67a..1f44c6c 100644 --- a/apps/website/src/black-hole-lensing.ts +++ b/apps/website/src/black-hole-lensing.ts @@ -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; diff --git a/apps/website/src/black-hole-scene.ts b/apps/website/src/black-hole-scene.ts index 2687940..a105c5f 100644 --- a/apps/website/src/black-hole-scene.ts +++ b/apps/website/src/black-hole-scene.ts @@ -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; @@ -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); diff --git a/apps/website/src/components/ExplorationActions.test.tsx b/apps/website/src/components/ExplorationActions.test.tsx index 32af5fd..0096f36 100644 --- a/apps/website/src/components/ExplorationActions.test.tsx +++ b/apps/website/src/components/ExplorationActions.test.tsx @@ -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(); @@ -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"'); }, ); diff --git a/apps/website/src/components/shell/TopBar.tsx b/apps/website/src/components/shell/TopBar.tsx index 5e11501..8464623 100644 --- a/apps/website/src/components/shell/TopBar.tsx +++ b/apps/website/src/components/shell/TopBar.tsx @@ -10,10 +10,9 @@ const XR_LABELS: Partial> = { entering: "Entering…", "ready-ar": "View in AR", "ready-ar-launch": "View in AR", - "ready-vr": "Enter VR", }; -const XR_READY = new Set(["ready-ar", "ready-ar-launch", "ready-vr"]); +const XR_READY = new Set(["ready-ar", "ready-ar-launch"]); interface TopBarProps { host: SceneHost | null; @@ -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(); @@ -76,8 +75,8 @@ export const TopBar = ({ host, xrStatus }: TopBarProps) => { {xrLabel ? (