# VR360 Aerial Panoramas (Shared · Project Catalogue)

> 📍 Part of the project catalogue doc set — the map and routing table is
> [start-here.md](/docs/modules_handbook/shared/project-catalogue/start-here.md).

**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 default, `VR_BAKE_TIMEOUT`) < job `$timeout` (**derived**, not fixed: `min(vr_bake.timeout + 300, 5700)`) < lane `retry_after` (**6000**, `config/queue.php`). Only the innermost one is tunable; the job ceiling follows it automatically, which is what stops the order inverting when a big-face box raises `VR_BAKE_TIMEOUT`. 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 through **the app's own origin** — `GET /vr-media/{uuid}/{kind}` (`VrMediaController`), which streams the object — **not** a signed GCS URL. Photo Sphere Viewer uploads the panorama into a WebGL texture, the private bucket sends no `Access-Control-Allow-Origin`, and the texture read is blocked while a plain `<img>` of the same file loads fine: thumbnails visible, viewer empty. Same-origin also keeps a private object's signed URL out of page source and history. The controller gates each kind: `panorama` and `thumbnail` are public only on an APPROVED bake; `nadir` and `floorplan`, and anything on an unapproved bake, need an admin. ⚠️ `vr_bake.signed_url_minutes` is left over from the signed-URL era and is **read by nothing** — do not tune it expecting an effect.
- **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.
- ⚠️ **A bake is Malaysia-only BY VALIDATION, and Hong Kong's tab can never be filled.** `StoreCatalogVrBakeRequest` and `LookupVrElevationRequest` both bound latitude to `between:0.5,7.5` and longitude to `99.0,119.5`, and the store request is the only way a bake row is ever created. Hong Kong sits at ~22.3°N, so an admin is offered the VR360 tab on an HK record with coordinates prefilled, and **Bake 422s with "The latitude is outside Malaysia"** — while the public HK page still renders an always-visible VR360 tab that no bake can ever fill. Widening the bounds per country is an open product decision (Google 3D Tiles coverage and per-bake cost both matter), not a bug to quietly patch.
- **`catalog_vr_bakes` is a SITE table; the project it points at may be in another database.** Its migration is in `database/migrations/`, not `database/migrations/catalogue/`, so it lives on the default connection while `CatalogProject` pins `catalogue`. That is why the row carries `catalog_project_uuid` beside the integer key, and why `catalogProject()` is a `FederatedBelongsTo`. ⚠️ **`ProjectDetailController::approvedVrPanorama()` filters with `whereHas('catalogProject', …)`, and `whereHas` is NOT federated** — it emits a stock same-connection subquery, so on a box reading the live master the public 360° tab silently finds nothing for a master-owned project. Invisible on production (collapsed) and to the suite.

### 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, but a *hard* kill is not one of them — and ⚠️ **the `vr_bake.stuck_after_minutes` sweep does not exist**: nothing in the repo reads that key. What actually rescues the row is the lane's own `retry_after` (6000 s): the job is re-delivered ~100 minutes later, `tries = 1` makes that exceed max attempts, and *that* fires `failed()`. Until then the row reads `Baking` and both Rebake and Delete refuse it (`isActive()`). |
| 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`)
- `workers/vrbake/verify.mjs` — **the cheap proof that the three projection files agree.**
  18 assertions over synthetic faces (cube-face placement, the nadir round-tripping back to
  `rotate180(down)`, a composite landing at the nadir without touching another face) with no
  Chromium, no Google key and no billed tiles. Run it after touching `stitch.mjs`,
  `extract_face.mjs` or `overlay.mjs`: `cd workers/vrbake && npm install && node verify.mjs`

**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 (MY)
- `resources/js/Components/ProjectDetailHk/HkVrTab.vue` — **the SECOND buyer-facing surface.**
  Fed by the same `approvedVrPanorama()` payload, but delivered inside the deferred `hk-tabs`
  group rather than as a top-level prop, so renaming a key there breaks the HK tab silently.
  ⚠️ It renders a plain `<img class="object-cover">`, **not** `VrPanoramaViewer` — so it is
  neither draggable nor zoomable despite its own caption promising both, and `object-cover`
  crops the 2:1 equirectangular strip.

**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)
