# VR360 Aerial Panoramas (Shared · Project Catalogue)

**Context:** a sub-module of [Project Catalogue](/docs/modules_handbook/shared/project-catalogue/readMe.md) · **Nav:** the **VR360** tab on a catalogue project's Show page (`/manage/property/catalog/{id}?tab=vr`), plus a full-page alignment studio at `…/vr/{bakeId}/align`; buyers see it as the **360° Aerial** tab on the public project page · **Used by:** Project Catalogue (admin), the public project detail page.

## What it does

Places a **virtual camera** above a catalogue project — at a chosen floor, or a raw height — and captures a full **equirectangular 360° panorama** of Google's Photorealistic 3D Tiles from that point. An admin reviews the result, optionally aligns the development's **site plan onto the rooftops**, and approves it; the approved panorama becomes a *360° Aerial* tab on the public project page.

It answers the one question a floor plan cannot: *what will I actually see out of the window at this height?*

This is an **aerial exterior** view. It is not an interior VR tour and there is no photography involved — every pixel is rendered from Google's 3D city.

## How it works

```
admin picks floor/height  →  catalog_vr_bakes row (Queued)  →  vr-bake queue
       │
   BakeProjectVr (bake box)  →  node workers/vrbake/bake.mjs
       │   headless Chromium + CesiumJS + Google 3D Tiles
       │   6 cube faces (front/right/back/left/up/down) at 90° FOV
       │   stitch.mjs → equirectangular panorama.webp + thumbnail
       │   extract_face.mjs → nadir.png (the looking-straight-down view)
       ▼
   artefacts → MediaService (private GCS)  →  status Completed
       │
   optional: brochure PDF → ExtractVrFloorplan (ai queue) → Gemini locates the
             site plan → crop → align in the studio → CompositeVrFloorplan
             (bake box) reprojects it onto the nadir → panorama rewritten
       ▼
   admin approves → the public project page grows a 360° Aerial tab
```

- **The capture is the hard part, and its tuning is load-bearing.** `cesium-page.html` holds constants that each fix a specific visible artefact: `maximumScreenSpaceError: 2` (lower does not add detail Google does not have — it just requests more tiles than headless Chromium can hold, so Cesium evicts the ones the camera needs and the centre goes soft), a tile-cache budget sized against the **host** rather than the image, and a wait loop that requires **8 consecutive quiet checks after a 12 s floor** before capturing — because `tilesLoaded` flips true between refinement passes, and believing it captures a coarse parent tile. Do not "tidy" these.
- **The stitcher's UV math is paired with the camera orientations.** `stitch.mjs`'s face selection and `cesium-page.html`'s `FACE_ORIENTATIONS` are one contract; changing either alone rotates or mirrors the seams. `overlay.mjs` re-implements the down-face branch and `extract_face.mjs` its inverse — all three must agree.
- **Baking never runs on the web box.** The `vr-bake` lane has **no Horizon supervisor** (`config/queue.php`); a dedicated bake box drains it. A bake is minutes of pegged CPU and over a gigabyte of resident memory, and it bills Google per tile — the machine serving the site must never take one. See the [bake worker runbook](/docs/modules_handbook/production-setup/vr-bake-worker.md).
- **Timeout nesting is an invariant, not a preference.** worker process (`vr_bake.timeout`, 1800 s) < job `$timeout` (2100 s) < lane `retry_after` (2400 s). A re-delivery does not merely repeat the work — it pays for it again. `tries = 1` for the same reason: a failed bake is requeued by a person who can see why it failed.
- **Two panoramas are always stored, never one.** Baking a floor plan into the nadir *rewrites the panorama pixels*, so a pristine `panorama_clean` copy is kept and every composite starts from it. Without that, re-positioning a plan would stack a second plan on top of the first.
- **The alignment studio works in flat nadir space**, against the reconstructed looking-straight-down view — the same coordinate system the compositor paints in, so what an admin drags is what gets baked. Positions are **fractions of the face, never pixels**, so a transform survives a rebake at a different resolution. The editor's coordinates are converted back to raw down-face space (`1 − x`, `1 − y`, `rotation + 180`) in `CompositeVrFloorplan`, undoing the 180° rotation `extract_face.mjs` applied to make the background WYSIWYG. Getting that conversion wrong puts the plan on the **opposite side of the building**, which still looks plausible in isolation.
- **Exactly one bake per project may be approved.** `CatalogVrBakeRepository::approve()` retires the previous one in the same transaction, so "live" can never be ambiguous and the admin list cannot disagree with the public page.
- **The floor-plan pipeline has its own status column.** Extracting or compositing does not un-bake the panorama; folding these into `status` would send an approved bake back to "baking" and pull it off the buyer's screen while an admin nudged an overlay.
- **Extraction is capped** at `vr_bake.floorplan.max_pages` (14). Every page is a billed vision call and a Malaysian launch brochure routinely runs past forty pages; the site plan is almost always in the first third.
- **Artefacts live on private GCS**, never `public/`. The web box has no disk for panoramas and is not the box that produces them, so a filesystem path would be meaningless on whichever machine read the row. Every image reaches a browser as a short-lived signed URL (`vr_bake.signed_url_minutes`, 120 — longer than the 15-minute media default, because a 360 session outlives it easily).
- **Guests see it.** Unlike the analysis tabs, the VR tab is not membership-gated: an aerial view of the neighbourhood is what makes someone *want* the analysis.

### Failure modes worth knowing

| Symptom | Cause |
| --- | --- |
| Black or missing patches in the panorama | The Google key is HTTP-referrer restricted and the worker's `--referer` does not match. |
| Sharp horizon, soft centre | Tile cache too small — Cesium evicted near-ground tiles mid-capture. Raise `VR_BAKE_CACHE_BYTES` on a box with the RAM. |
| One blurry side | A face was captured mid-refinement. Raise `VR_BAKE_TILE_WAIT`. |
| Stuck in "Baking" forever | The worker process died (OOM, reboot). `BakeProjectVr::failed()` catches queue-level failures; anything older than `vr_bake.stuck_after_minutes` needs the sweep. |
| Floor plan on the wrong side of the building | The editor↔down-face 180° conversion was changed on one side only. |

## Reference usage

Queue a bake for a catalogue project:

```php
use App\Jobs\Property\BakeProjectVr;
use Src\Analysis\Reference\CatalogVrBake;
use Src\Analysis\Repositories\CatalogVrBakeRepository;

$data['catalog_vr_bake'] = [
    'latitude'      => $catalogue->latitude,
    'longitude'     => $catalogue->longitude,
    // Metres above SEA LEVEL — the frame the 3D tiles use. A floor count alone
    // is not a height; add it to the ground elevation or the camera ends up
    // underground wherever the terrain is above sea level.
    'camera_height' => CatalogVrBake::deriveCameraHeight($groundElevation, $floors),
    'face_size'     => config('vr_bake.face_size'),
    'label'         => $catalogue->project_name . ' · 120m',
];

$bake = app(CatalogVrBakeRepository::class)->create($catalogue, $data);

BakeProjectVr::dispatch($bake->id);
```

Render one anywhere in the UI — the viewer is shared by the admin tab, the studio and the public page:

```vue
<VrPanoramaViewer :panorama-url="bake.panorama_url" show-compass height="420px" />
```

> It holds a **WebGL context**, which browsers cap per page. Mount it behind a `v-if` (never `v-show`) so it is created only when actually visible; a leaked context does not fail loudly, it silently kills the *next* viewer the user opens.

Present a bake for the frontend — always through the shared trait, so the tab, its poller and the studio cannot drift into three ideas of the same row:

```php
use App\Http\Controllers\Concerns\PresentsVrBakes;

$bakes = CatalogVrBake::where('catalog_project_id', $id)
    ->with(self::$vrBakeRelations)   // REQUIRED — four media lookups per row otherwise
    ->orderByDesc('id')
    ->get();

return $this->presentVrBakes($bakes, app(MediaService::class));
```

## Related files

**Backend**
- `src/Analysis/Reference/CatalogVrBake.php` — the model (key model: uuid + blame + soft delete), `STATUS_*` / `FLOORPLAN_*` constants, `deriveCameraHeight()`
- `src/Analysis/Repositories/CatalogVrBakeRepository.php` + `src/Analysis/Facades/CatalogVrBakeRepository.php`
- `app/Http/Controllers/Manage/Property/CatalogVrController.php`
- `app/Http/Controllers/Concerns/PresentsVrBakes.php` — the one shape every VR screen renders
- `app/Http/Requests/Manage/Property/{StoreCatalogVrBakeRequest,StoreCatalogVrFloorplanRequest,UpdateCatalogVrOverlayRequest,LookupVrElevationRequest}.php` — `LookupVrElevationRequest` is where the elevation lookup's Malaysian bounds are enforced; the controller does no validation of its own
- `app/Jobs/Property/{BakeProjectVr,CompositeVrFloorplan,ExtractVrFloorplan}.php`
- `app/Services/Property/VrFloorplanExtractor.php`
- `app/Http/Controllers/Main/Site/ProjectDetailController.php` — `approvedVrPanorama()`
- `config/vr_bake.php`, `config/queue.php` (`redis-vr-bake`), `config/ai_prompts.php` (`vr_floorplan`)
- `resources/prompts/vr_floorplan.md`

**Worker engine (Node — runs on the bake box)**
- `workers/vrbake/bake.mjs` — puppeteer driver, progress line protocol
- `workers/vrbake/cesium-page.html` — the CesiumJS + Google 3D Tiles capture rig
- `workers/vrbake/stitch.mjs` — cube faces → equirectangular
- `workers/vrbake/extract_face.mjs` — panorama → nadir (the studio background)
- `workers/vrbake/overlay.mjs` — floor plan → nadir composite
- `workers/vrbake/package.json` — its own dependency tree (`puppeteer-core`, `sharp`)

**Frontend**
- `resources/js/Components/Vr/VrPanoramaViewer.vue` — the shared viewer
- `resources/js/Pages/Manage/Property/Catalog/Partials/Tabs/VrTab.vue`
- `resources/js/Pages/Manage/Property/Catalog/VrAlign.vue` — the alignment studio
- `resources/js/Pages/Manage/Property/Catalog/Show.vue` — mounts the tab
- `resources/js/Pages/Main/Site/ProjectDetail.vue` — the buyer-facing tab

**Migrations**
- `database/migrations/2026_08_07_100000_create_catalog_vr_bakes_table.php`

**Routes** (`routes/web.php`, inside the `manage.property.catalog.` group)
- `GET {id}/vr/status` · `GET {id}/vr/elevation` · `POST {id}/vr`
- `GET {id}/vr/{bakeId}/align` · `POST …/rebake` · `POST|DELETE …/approve`
- `POST|DELETE …/floorplan` · `POST …/overlay` · `DELETE {id}/vr/{bakeId}`

**Tests**
- `tests/Feature/Property/CatalogVrBakeTest.php`

**Ops**
- [`docs/modules_handbook/production-setup/vr-bake-worker.md`](/docs/modules_handbook/production-setup/vr-bake-worker.md)
