fix(street-level): camera ownership, mesh refresh, panel CSP (Sameh's review)

- Photo framing and FOLLOW claim the camera through the application's
  navigation, injected with attachNavigation({ run, subscribeHandoff }):
  aircraft and satellite tracking is released first, the cockpit refuses,
  and FOLLOW stops when another feature takes the camera. No UI imports
  in the layer.
- The mesh sampler follows the mesh floor's rules: it waits for the
  visible tileset to finish streaming (visibleTilesetLoaded, now exposed
  by the mesh floor service), needs a real bare-earth prior and keeps to
  the shared meshFloorSampleWithinPrior window, which cameraFollow now
  uses too. A cell is probed again once the camera is half as far from
  it (down to 40 m), so a coarse early sample is replaced and redrawn.
- The MCP globe panel's CSP allows the Mapillary Graph API and its image
  CDN (https://*.xx.fbcdn.net).
- The tile proxy reads bodies with the shared readResponseBytesCapped.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Opus 5.5 committed 2026-10-06 23:04:33 +01:00
1 parent 630a1abdae
commit 1fa129ea8e
13 files changed
+459 -79

No files matched your search

+13 -27
View File
@@ -1,5 +1,6 @@
import path from 'node:path';
import { promises as fsp } from 'node:fs';
import { readResponseBytesCapped } from '../common/http.js';
import { stripTileLayers } from './trim.js';
import {
MAPILLARY_TILE_HOST,
@@ -350,35 +351,20 @@ async function fetchUpstream(address, signal) {
}
throw error;
}
const declared = Number(response.headers.get('content-length'));
if (Number.isFinite(declared) && declared > TILE_MAX_BYTES) {
await response.body?.cancel().catch(() => {});
// Chunked or compressed bodies have no Content-Length: the cap applies as
// the body streams, and an oversized one also aborts the request.
try {
const bytes = await readResponseBytesCapped(
response,
TILE_MAX_BYTES,
signal,
);
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
} catch (error) {
if (error?.code !== 'RESPONSE_TOO_LARGE') throw error;
oversize.abort();
throw new TileUpstreamError(502, 'Mapillary tile exceeds size cap');
}
return readCapped(response, () => oversize.abort());
}
/**
* Read a body, aborting once it passes TILE_MAX_BYTES: chunked or compressed
* responses have no Content-Length to check first.
*/
async function readCapped(response, abortRequest) {
if (!response.body) return Buffer.alloc(0);
const reader = response.body.getReader();
const chunks = [];
let total = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > TILE_MAX_BYTES) {
abortRequest();
await reader.cancel().catch(() => {});
throw new TileUpstreamError(502, 'Mapillary tile exceeds size cap');
}
chunks.push(value);
}
return Buffer.concat(chunks, total);
}
/** @type {{active: number, queue: Array<() => void>}} upstream slots */