mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
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:
1 parent
630a1abdae
commit
1fa129ea8e
13 files changed
+459
-79
No files matched your search
@@ -2959,7 +2959,10 @@ the mesh hides what is behind buildings and trees. A tile is drawn draped until
|
||||
its heights arrive; a geoid fallback leaves it draped. Within 900 m of the
|
||||
camera, `meshSampler.js` samples the rendered surface (`scene.sampleHeight`,
|
||||
~11 m cells, nearest first, 6 ms idle budget) so lines also follow trenches,
|
||||
steep streets and roads under trees. Roads on elevated decks are still drawn at
|
||||
steep streets and roads under trees. It uses the mesh floor's rules (the
|
||||
visible tileset has finished streaming, a real bare-earth prior, the shared
|
||||
mesh window) and probes a cell again once the camera is half as far from it,
|
||||
down to 40 m, so coarse early samples are replaced as finer tiles load. Roads on elevated decks are still drawn at
|
||||
ground level. Framing a photo ignores mesh samples far below the bare earth
|
||||
(unloaded tiles).
|
||||
|
||||
@@ -2967,7 +2970,10 @@ The header pill is the layer switch. With one provider its chip is a layer
|
||||
switch too; with several, darkening the last lit chip turns the layer off. The
|
||||
viewer sits under the header with EXPAND, FIT/FILL, FOLLOW and close above the
|
||||
image. FOLLOW needs the Google 3D map stack (`attachMapStackController`) and
|
||||
stops when the stack changes. SINCE is a stepped slider whose readout names the
|
||||
stops when the stack changes. Framing a photo and turning FOLLOW on claim the
|
||||
camera through the application's navigation (`attachNavigation`), which
|
||||
releases aircraft and satellite tracking (and is refused in the cockpit);
|
||||
FOLLOW stops when another feature takes the camera. SINCE is a stepped slider whose readout names the
|
||||
cut-off date. The panel is portable (`street-level-panel` spec, minimum
|
||||
320 × 280, `dockOnCollapse`): a floating window gives spare height to the
|
||||
viewer, and collapsing it, double-clicking the header or SHRINK after EXPAND
|
||||
|
||||
@@ -195,7 +195,9 @@
|
||||
"server/providers/common/same-site.js",
|
||||
"src/localRequestGate.mjs",
|
||||
"server/providers/common/rate-limit.js",
|
||||
"src/sources/rateLimit.js"
|
||||
"src/sources/rateLimit.js",
|
||||
"server/providers/common/http.js",
|
||||
"src/sources/httpBody.js"
|
||||
],
|
||||
"external": ["pbf"]
|
||||
},
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -15,6 +15,7 @@ export function createApplicationStreetLevel({ surface, sources }) {
|
||||
input,
|
||||
render,
|
||||
ground: surface?.groundFloor ?? null,
|
||||
meshFloor: surface?.meshFloor ?? null,
|
||||
terrain: surface?.terrain ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4,9 +4,6 @@ import { FOLLOW_EYE_HEIGHT_M } from './policy.js';
|
||||
/** Plausible ellipsoidal land heights: Dead Sea to Everest, with geoid slack. */
|
||||
const SURFACE_MIN_M = -500;
|
||||
const SURFACE_MAX_M = 9000;
|
||||
/** Rendered mesh window around the bare earth: roofs and bridges, not whole towers. */
|
||||
const MESH_BELOW_DEM_M = 15;
|
||||
const MESH_ABOVE_DEM_M = 80;
|
||||
|
||||
/** Drive the globe camera from the street-level pose: continuously (follow) or once (framing). */
|
||||
export function createCameraFollow({ state, parts }) {
|
||||
@@ -16,6 +13,48 @@ export function createCameraFollow({ state, parts }) {
|
||||
render?.governorRequestRender?.('street-level-follow');
|
||||
}
|
||||
|
||||
/**
|
||||
* The application's camera authority (`attachNavigation`): `run(noun, move)`
|
||||
* releases tracking and other owners before `move`, or refuses (cockpit);
|
||||
* `subscribeHandoff` reports a newer owner. Without it, moves run directly.
|
||||
*/
|
||||
let navigation = null;
|
||||
let unsubscribeHandoff = null;
|
||||
/** Set while our own claim stamps a handoff, so it does not end FOLLOW. */
|
||||
let claiming = false;
|
||||
|
||||
/** Take the camera for `move`; false when the application refuses. */
|
||||
function claim(move) {
|
||||
if (!navigation?.run) {
|
||||
move();
|
||||
return true;
|
||||
}
|
||||
claiming = true;
|
||||
try {
|
||||
return (
|
||||
navigation.run('photo', () => {
|
||||
move();
|
||||
return true;
|
||||
}) === true
|
||||
);
|
||||
} finally {
|
||||
claiming = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Another feature took the camera: stop following it. */
|
||||
function onHandoff() {
|
||||
if (claiming || !state.street.follow) return;
|
||||
state.street.follow = false;
|
||||
state.notify?.();
|
||||
}
|
||||
|
||||
function attachNavigation(next) {
|
||||
unsubscribeHandoff?.();
|
||||
navigation = next || null;
|
||||
unsubscribeHandoff = navigation?.subscribeHandoff?.(onHandoff) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ground under a photo. `sampleHeight` can return kilometres below the
|
||||
* ellipsoid before tiles load, so a sample counts only when plausible and
|
||||
@@ -27,12 +66,13 @@ export function createCameraFollow({ state, parts }) {
|
||||
const caster = parts?.groundCaster;
|
||||
const dem = caster?.groundAt(lon, lat) ?? null;
|
||||
if (dem === null) caster?.prepare([[lon, lat]]);
|
||||
// The application's mesh window around bare earth, once that is known.
|
||||
const withinPrior = state.services.ground?.meshFloorSampleWithinPrior;
|
||||
const plausible = (height) =>
|
||||
Number.isFinite(height) &&
|
||||
height >= SURFACE_MIN_M &&
|
||||
height <= SURFACE_MAX_M &&
|
||||
(dem === null ||
|
||||
(height >= dem - MESH_BELOW_DEM_M && height <= dem + MESH_ABOVE_DEM_M));
|
||||
(dem === null || !withinPrior || withinPrior(height, dem));
|
||||
let height = null;
|
||||
try {
|
||||
if (scene?.sampleHeightSupported) height = scene.sampleHeight(carto);
|
||||
@@ -73,29 +113,31 @@ export function createCameraFollow({ state, parts }) {
|
||||
const { position, bearing, altitude } = state.street;
|
||||
if (!position || !state.viewer) return;
|
||||
const ground = groundHeightAt(position.lon, position.lat, altitude);
|
||||
// Owned before the call: starting it cancels the previous flight (ours
|
||||
// included) synchronously, and a zero-length one completes at once.
|
||||
const flight = {};
|
||||
framing = flight;
|
||||
const release = () => {
|
||||
if (framing === flight) framing = null;
|
||||
};
|
||||
state.viewer.camera.flyToBoundingSphere(
|
||||
new Cesium.BoundingSphere(
|
||||
Cesium.Cartesian3.fromDegrees(position.lon, position.lat, ground + 2),
|
||||
4,
|
||||
),
|
||||
{
|
||||
offset: new Cesium.HeadingPitchRange(
|
||||
Cesium.Math.toRadians(bearing || 0),
|
||||
Cesium.Math.toRadians(-32),
|
||||
140,
|
||||
claim(() => {
|
||||
// Owned before the call: starting it cancels the previous flight (ours
|
||||
// included) synchronously, and a zero-length one completes at once.
|
||||
const flight = {};
|
||||
framing = flight;
|
||||
const release = () => {
|
||||
if (framing === flight) framing = null;
|
||||
};
|
||||
state.viewer.camera.flyToBoundingSphere(
|
||||
new Cesium.BoundingSphere(
|
||||
Cesium.Cartesian3.fromDegrees(position.lon, position.lat, ground + 2),
|
||||
4,
|
||||
),
|
||||
duration: 1.6,
|
||||
complete: release,
|
||||
cancel: release,
|
||||
},
|
||||
);
|
||||
{
|
||||
offset: new Cesium.HeadingPitchRange(
|
||||
Cesium.Math.toRadians(bearing || 0),
|
||||
Cesium.Math.toRadians(-32),
|
||||
140,
|
||||
),
|
||||
duration: 1.6,
|
||||
complete: release,
|
||||
cancel: release,
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Stop our framing flight if it is still in the air; another feature's flight is left alone. */
|
||||
@@ -107,14 +149,32 @@ export function createCameraFollow({ state, parts }) {
|
||||
}
|
||||
|
||||
function setFollow(enabled) {
|
||||
state.street.follow = enabled === true && state.street.followAvailable;
|
||||
// The framing tween would keep overwriting the follow view until it lands.
|
||||
if (state.street.follow) {
|
||||
cancelFraming();
|
||||
followCamera();
|
||||
const wanted = enabled === true && state.street.followAvailable;
|
||||
if (wanted && !state.street.follow) {
|
||||
// Following owns the camera until another feature takes it.
|
||||
const granted = claim(() => {
|
||||
state.street.follow = true;
|
||||
// The framing tween would keep overwriting the follow view until it lands.
|
||||
cancelFraming();
|
||||
followCamera();
|
||||
});
|
||||
if (!granted) state.street.follow = false;
|
||||
} else if (!wanted) {
|
||||
state.street.follow = false;
|
||||
}
|
||||
state.notify?.();
|
||||
}
|
||||
|
||||
return { followCamera, lookAtPosition, cancelFraming, setFollow };
|
||||
function destroy() {
|
||||
attachNavigation(null);
|
||||
}
|
||||
|
||||
return {
|
||||
followCamera,
|
||||
lookAtPosition,
|
||||
cancelFraming,
|
||||
setFollow,
|
||||
attachNavigation,
|
||||
destroy,
|
||||
};
|
||||
}
|
||||
@@ -156,3 +156,80 @@ test('turning FOLLOW on stops the framing flight so it cannot fight the follow v
|
||||
assert.equal(camera.cancelled, 1, 'the framing tween stops');
|
||||
assert.equal(views.length, 1, 'and the camera stands at the photo');
|
||||
});
|
||||
|
||||
/**
|
||||
* The application's camera authority: `run` releases tracking (here, a tracked
|
||||
* entity) and stamps a handoff before the move, or refuses like the cockpit.
|
||||
*/
|
||||
function navigationFor(state, { refuse = false } = {}) {
|
||||
const listeners = new Set();
|
||||
const nav = {
|
||||
runs: [],
|
||||
run(noun, move) {
|
||||
nav.runs.push(noun);
|
||||
if (refuse) return false;
|
||||
for (const listener of listeners)
|
||||
listener({ generation: nav.runs.length });
|
||||
state.viewer.trackedEntity = undefined;
|
||||
return move(nav.runs.length);
|
||||
},
|
||||
subscribeHandoff(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
handOff() {
|
||||
for (const listener of listeners) listener({ generation: -1 });
|
||||
},
|
||||
listeners,
|
||||
};
|
||||
return nav;
|
||||
}
|
||||
|
||||
test('framing a photo claims the camera through the application, releasing tracking', () => {
|
||||
const { follow, state, flights } = setup({ sampled: 160 });
|
||||
state.viewer.trackedEntity = { id: 'aircraft' };
|
||||
const nav = navigationFor(state);
|
||||
follow.attachNavigation(nav);
|
||||
follow.lookAtPosition();
|
||||
assert.deepEqual(nav.runs, ['photo']);
|
||||
assert.equal(state.viewer.trackedEntity, undefined);
|
||||
assert.equal(flights.length, 1);
|
||||
});
|
||||
|
||||
test('a refused claim (cockpit) neither frames the photo nor turns FOLLOW on', () => {
|
||||
const { follow, state, flights, views } = setup({ sampled: 160 });
|
||||
follow.attachNavigation(navigationFor(state, { refuse: true }));
|
||||
follow.lookAtPosition();
|
||||
follow.setFollow(true);
|
||||
assert.equal(flights.length, 0);
|
||||
assert.equal(views.length, 0);
|
||||
assert.equal(state.street.follow, false);
|
||||
});
|
||||
|
||||
test('FOLLOW claims the camera once and ends when another feature takes it', () => {
|
||||
const { follow, state, views } = setup({ sampled: 160 });
|
||||
let notified = 0;
|
||||
state.notify = () => notified++;
|
||||
const nav = navigationFor(state);
|
||||
follow.attachNavigation(nav);
|
||||
follow.setFollow(true);
|
||||
assert.equal(state.street.follow, true, 'our own handoff keeps FOLLOW on');
|
||||
assert.deepEqual(nav.runs, ['photo']);
|
||||
follow.followCamera();
|
||||
assert.deepEqual(nav.runs, ['photo'], 'pose updates do not claim again');
|
||||
assert.equal(views.length, 2);
|
||||
nav.handOff();
|
||||
assert.equal(state.street.follow, false);
|
||||
assert.ok(notified >= 2);
|
||||
follow.followCamera();
|
||||
assert.equal(views.length, 2, 'the camera is no longer driven');
|
||||
});
|
||||
|
||||
test('destroying the follow part stops listening for handoffs', () => {
|
||||
const { follow, state } = setup({ sampled: 160 });
|
||||
const nav = navigationFor(state);
|
||||
follow.attachNavigation(nav);
|
||||
assert.equal(nav.listeners.size, 1);
|
||||
follow.destroy();
|
||||
assert.equal(nav.listeners.size, 0);
|
||||
});
|
||||
@@ -40,8 +40,15 @@ export function createStreetLevelLayer({
|
||||
parts.groundCaster = services.terrain?.resolveEllipsoidalGround
|
||||
? createGroundCaster({ terrain: services.terrain })
|
||||
: null;
|
||||
// Mesh samples follow the application's rules: a real bare-earth prior,
|
||||
// its mesh window, and a tileset that has finished streaming.
|
||||
parts.meshSampler = parts.groundCaster
|
||||
? createMeshSampler({ getViewer: () => state.viewer })
|
||||
? createMeshSampler({
|
||||
getViewer: () => state.viewer,
|
||||
groundAt: parts.groundCaster.groundAt,
|
||||
withinPrior: services.ground?.meshFloorSampleWithinPrior ?? null,
|
||||
tilesReady: services.meshFloor?.visibleTilesetLoaded ?? null,
|
||||
})
|
||||
: null;
|
||||
parts.credits = createCredits();
|
||||
parts.marker = createMarker(context);
|
||||
@@ -375,6 +382,7 @@ export function createStreetLevelLayer({
|
||||
entry.instance.destroy(viewer);
|
||||
parts.marker.destroy(viewer);
|
||||
parts.meshSampler?.destroy();
|
||||
parts.follow.destroy();
|
||||
unsubscribeMapStack?.();
|
||||
unsubscribeMapStack = null;
|
||||
mapStack = null;
|
||||
@@ -405,6 +413,15 @@ export function createStreetLevelLayer({
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* The application's camera authority: `run(noun, move)` and
|
||||
* `subscribeHandoff(listener)`. Framing and FOLLOW claim the camera
|
||||
* through it, and FOLLOW stops when another feature takes the camera.
|
||||
*/
|
||||
attachNavigation(navigation) {
|
||||
parts.follow.attachNavigation(navigation);
|
||||
},
|
||||
|
||||
/** The application map stack; FOLLOW is available only on Google 3D. */
|
||||
attachMapStackController(controller) {
|
||||
unsubscribeMapStack?.();
|
||||
|
||||
@@ -5,8 +5,10 @@ import { metresBetween, whenIdle } from './view.js';
|
||||
* Google 3D surface heights for ground casting (groundCast.js refineHeights):
|
||||
* one cached `scene.sampleHeight` probe per ~11 m cell. Each probe renders a
|
||||
* pick pass (1–3 ms), so probes run in budgeted idle slices, nearest the
|
||||
* camera first and only within MESH_SAMPLE_RADIUS_M. A miss (tiles not
|
||||
* streamed yet) is retried later. Probes hit 3D tilesets only.
|
||||
* camera first and only within MESH_SAMPLE_RADIUS_M. Like the application's
|
||||
* mesh floor, a probe waits for the visible tileset to finish streaming and a
|
||||
* sample counts only within the shared window around a real bare-earth height;
|
||||
* anything else is a miss, retried later. Probes hit 3D tilesets only.
|
||||
*/
|
||||
|
||||
/** Cell size, in degrees (~11 m): samples are shared within a cell. */
|
||||
@@ -19,6 +21,16 @@ export const MESH_SAMPLE_BUDGET_MS = 6;
|
||||
export const MESH_RERANK_M = 100;
|
||||
/** A cell whose probe missed is tried again after this long. */
|
||||
export const MESH_MISS_RETRY_MS = 8000;
|
||||
/** While the visible tileset is streaming, the next probe waits this long. */
|
||||
export const MESH_STREAMING_WAIT_MS = 500;
|
||||
/**
|
||||
* A sampled cell is probed again once the camera is half as far from it as at
|
||||
* its last probe (finer tiles have loaded by then), down to this distance:
|
||||
* at most a handful of probes per cell.
|
||||
*/
|
||||
export const MESH_REFRESH_MIN_M = 40;
|
||||
/** A refreshed sample that moved less than this is not redrawn. */
|
||||
const MESH_REFRESH_CHANGE_M = 0.5;
|
||||
/** Cached cells (and remembered misses) before they are dropped and refilled. */
|
||||
const MESH_CACHE_MAX = 80_000;
|
||||
/** Listeners hear about new samples at most this often. */
|
||||
@@ -80,10 +92,16 @@ function overlays(scene) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{getViewer: () => object|null, budgetMs?: number, radiusM?: number, cacheMax?: number, now?: () => number}} options
|
||||
* `groundAt(lon, lat)` is the bare-earth height or null, `withinPrior(height,
|
||||
* prior)` the application's mesh window, and `tilesReady(scene)` whether the
|
||||
* visible tileset has finished streaming.
|
||||
* @param {{getViewer: () => object|null, groundAt?: Function, withinPrior?: Function, tilesReady?: Function, budgetMs?: number, radiusM?: number, cacheMax?: number, now?: () => number}} options
|
||||
*/
|
||||
export function createMeshSampler({
|
||||
getViewer,
|
||||
groundAt = null,
|
||||
withinPrior = null,
|
||||
tilesReady = null,
|
||||
budgetMs = MESH_SAMPLE_BUDGET_MS,
|
||||
radiusM = MESH_SAMPLE_RADIUS_M,
|
||||
cacheMax = MESH_CACHE_MAX,
|
||||
@@ -91,6 +109,8 @@ export function createMeshSampler({
|
||||
}) {
|
||||
/** cell key → sampled mesh height (ellipsoidal metres). */
|
||||
const heights = new Map();
|
||||
/** cell key → camera distance (m) at the cell's last probe. */
|
||||
const probedFrom = new Map();
|
||||
/** cell key → retry time for probes that hit nothing; bounded like the height cache. */
|
||||
const misses = new Map();
|
||||
/** cell key → queued cell, so a cell is queued once. */
|
||||
@@ -114,16 +134,40 @@ export function createMeshSampler({
|
||||
return heights.get(meshCellKey(lon, lat));
|
||||
}
|
||||
|
||||
/** The point under the camera, or null. */
|
||||
/** The point under the camera, with the camera's height, or null. */
|
||||
function cameraCentre() {
|
||||
const carto = getViewer()?.camera?.positionCartographic;
|
||||
if (!carto) return null;
|
||||
return {
|
||||
lon: Cesium.Math.toDegrees(carto.longitude),
|
||||
lat: Cesium.Math.toDegrees(carto.latitude),
|
||||
height: carto.height,
|
||||
};
|
||||
}
|
||||
|
||||
/** Camera distance (m) to a cell at `height`, from its ground distance. */
|
||||
function cameraDistance(ground, centre, height) {
|
||||
const above = (centre.height ?? 0) - (height ?? 0);
|
||||
return Number.isFinite(above)
|
||||
? Math.hypot(ground, Math.max(0, above))
|
||||
: ground;
|
||||
}
|
||||
|
||||
/** A sampled cell is probed again once the camera is half as far as before. */
|
||||
function dueForRefresh(key, distance) {
|
||||
const last = probedFrom.get(key);
|
||||
return last > MESH_REFRESH_MIN_M && distance < last / 2;
|
||||
}
|
||||
|
||||
/** A probe result the application would accept, or undefined. */
|
||||
function validated(height, lon, lat) {
|
||||
if (!Number.isFinite(height)) return undefined;
|
||||
if (!groundAt) return height;
|
||||
const prior = groundAt(lon, lat);
|
||||
if (!Number.isFinite(prior)) return undefined;
|
||||
return !withinPrior || withinPrior(height, prior) ? height : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask for the cells under [lon, lat] points; the list is read later, so do
|
||||
* not mutate it. Out-of-range cells are dropped; callers ask again on move.
|
||||
@@ -147,14 +191,20 @@ export function createMeshSampler({
|
||||
const [lon, lat] = points[pending.next++];
|
||||
if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue;
|
||||
const key = meshCellKey(lon, lat);
|
||||
if (heights.has(key) || wanted.has(key)) continue;
|
||||
if (wanted.has(key)) continue;
|
||||
const cell = {
|
||||
key,
|
||||
lon: cellOf(lon) * MESH_CELL_DEG,
|
||||
lat: cellOf(lat) * MESH_CELL_DEG,
|
||||
distance: 0,
|
||||
};
|
||||
if (metresBetween(cell, centre) > radiusM) continue;
|
||||
const ground = metresBetween(cell, centre);
|
||||
if (ground > radiusM) continue;
|
||||
if (
|
||||
heights.has(key) &&
|
||||
!dueForRefresh(key, cameraDistance(ground, centre, heights.get(key)))
|
||||
)
|
||||
continue;
|
||||
const retryAt = misses.get(key);
|
||||
if (retryAt !== undefined) {
|
||||
if (retryAt > time) continue;
|
||||
@@ -220,6 +270,15 @@ export function createMeshSampler({
|
||||
const scene = getViewer()?.scene;
|
||||
const centre = cameraCentre();
|
||||
if (!enabled || !scene?.sampleHeightSupported || !centre) return;
|
||||
// Mid-stream tiles answer with coarse heights: wait for the stream.
|
||||
if (tilesReady && !tilesReady(scene)) {
|
||||
running = true;
|
||||
setTimeout(() => {
|
||||
running = false;
|
||||
schedule();
|
||||
}, MESH_STREAMING_WAIT_MS);
|
||||
return;
|
||||
}
|
||||
// Nearest first, ranked from where the camera was; out-of-range cells
|
||||
// are dropped (they stay bare earth).
|
||||
let worked = false;
|
||||
@@ -241,7 +300,8 @@ export function createMeshSampler({
|
||||
const { key, lon, lat } = heapPop(queue);
|
||||
wanted.delete(key);
|
||||
// Ranked from up to MESH_RERANK_M away: check the range from here.
|
||||
if (metresBetween({ lon, lat }, centre) > radiusM) continue;
|
||||
const ground = metresBetween({ lon, lat }, centre);
|
||||
if (ground > radiusM) continue;
|
||||
probed++;
|
||||
const probeStarted = now();
|
||||
let height;
|
||||
@@ -254,15 +314,27 @@ export function createMeshSampler({
|
||||
height = undefined;
|
||||
}
|
||||
probeMs = probeMs * 0.75 + (now() - probeStarted) * 0.25;
|
||||
if (!Number.isFinite(height)) {
|
||||
if (misses.size >= cacheMax) misses.clear();
|
||||
misses.set(key, Date.now() + MESH_MISS_RETRY_MS);
|
||||
height = validated(height, lon, lat);
|
||||
const previous = heights.get(key);
|
||||
if (height === undefined) {
|
||||
// A failed refresh keeps the old sample, and waits for a closer camera.
|
||||
if (previous !== undefined)
|
||||
probedFrom.set(key, cameraDistance(ground, centre, previous));
|
||||
else {
|
||||
if (misses.size >= cacheMax) misses.clear();
|
||||
misses.set(key, Date.now() + MESH_MISS_RETRY_MS);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (heights.size >= cacheMax) forget();
|
||||
if (previous === undefined && heights.size >= cacheMax) forget();
|
||||
misses.delete(key);
|
||||
heights.set(key, height);
|
||||
fresh.push([lon, lat]);
|
||||
probedFrom.set(key, cameraDistance(ground, centre, height));
|
||||
if (
|
||||
previous === undefined ||
|
||||
Math.abs(height - previous) >= MESH_REFRESH_CHANGE_M
|
||||
)
|
||||
fresh.push([lon, lat]);
|
||||
}
|
||||
if (fresh.length && !notifyTimer)
|
||||
notifyTimer = setTimeout(emit, MESH_NOTIFY_MS);
|
||||
@@ -271,6 +343,7 @@ export function createMeshSampler({
|
||||
|
||||
function forget() {
|
||||
heights.clear();
|
||||
probedFrom.clear();
|
||||
misses.clear();
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,12 @@ import * as Cesium from 'cesium';
|
||||
import {
|
||||
MESH_CELL_DEG,
|
||||
MESH_MISS_RETRY_MS,
|
||||
MESH_REFRESH_MIN_M,
|
||||
MESH_SAMPLE_BUDGET_MS,
|
||||
MESH_STREAMING_WAIT_MS,
|
||||
createMeshSampler,
|
||||
} from './meshSampler.js';
|
||||
import { meshFloorSampleWithinPrior } from '../../data/groundFloor.js';
|
||||
import { metresBetween } from './view.js';
|
||||
|
||||
const settle = (ms = 60) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
@@ -267,3 +270,107 @@ test('the sampling range is measured the short way round the date line', (t) =>
|
||||
assert.ok(Math.abs(Math.abs(probes[0].lon) - 179.9999) < MESH_CELL_DEG);
|
||||
sampler.destroy();
|
||||
});
|
||||
|
||||
/** A scene whose mesh height can change (finer tiles) and a camera that can move. */
|
||||
function liveViewer({ surface = 80, height = 700, ready = () => true } = {}) {
|
||||
const tileset = Object.create(Cesium.Cesium3DTileset.prototype);
|
||||
const probes = [];
|
||||
const viewer = {
|
||||
surface,
|
||||
ready,
|
||||
scene: {
|
||||
sampleHeightSupported: true,
|
||||
primitives: { length: 1, get: () => tileset },
|
||||
sampleHeight(carto) {
|
||||
probes.push(carto);
|
||||
return viewer.surface;
|
||||
},
|
||||
},
|
||||
camera: {
|
||||
positionCartographic: { longitude: 10 * RAD, latitude: 50 * RAD, height },
|
||||
},
|
||||
};
|
||||
return { viewer, probes };
|
||||
}
|
||||
|
||||
function liveSampler(viewer, options = {}) {
|
||||
return createMeshSampler({
|
||||
getViewer: () => viewer,
|
||||
groundAt: () => 75,
|
||||
withinPrior: meshFloorSampleWithinPrior,
|
||||
tilesReady: () => viewer.ready(),
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
test('a sample is probed again once the camera is twice as close, and the change is redrawn', async () => {
|
||||
const { viewer, probes } = liveViewer({ surface: 80, height: 700 });
|
||||
const sampler = liveSampler(viewer);
|
||||
sampler.setEnabled(true);
|
||||
const heard = [];
|
||||
sampler.onSampled((batch) => heard.push(batch.length));
|
||||
sampler.request([[10.001, 50]]);
|
||||
await settle(900);
|
||||
assert.equal(sampler.meshAt(10.001, 50), 80);
|
||||
// Finer tiles load: the surface is really 100 m.
|
||||
viewer.surface = 100;
|
||||
sampler.request([[10.001, 50]]);
|
||||
await settle();
|
||||
assert.equal(probes.length, 1, 'no new probe from the same distance');
|
||||
assert.equal(sampler.meshAt(10.001, 50), 80);
|
||||
viewer.camera.positionCartographic.height = 250;
|
||||
sampler.request([[10.001, 50]]);
|
||||
await settle(900);
|
||||
assert.equal(probes.length, 2);
|
||||
assert.equal(sampler.meshAt(10.001, 50), 100);
|
||||
assert.deepEqual(heard, [1, 1], 'the new height is announced for a redraw');
|
||||
sampler.destroy();
|
||||
});
|
||||
|
||||
test('refreshing stops once a cell was probed from close by', async () => {
|
||||
const { viewer, probes } = liveViewer({ surface: 80, height: 110 });
|
||||
viewer.camera.positionCartographic.longitude = 10.001 * RAD;
|
||||
const sampler = liveSampler(viewer);
|
||||
sampler.setEnabled(true);
|
||||
sampler.request([[10.001, 50]]);
|
||||
await settle();
|
||||
viewer.camera.positionCartographic.height = 85;
|
||||
sampler.request([[10.001, 50]]);
|
||||
await settle();
|
||||
assert.equal(probes.length, 1, `probed from under ${MESH_REFRESH_MIN_M} m`);
|
||||
sampler.destroy();
|
||||
});
|
||||
|
||||
test('probes wait while the visible tileset is still streaming', async () => {
|
||||
let ready = false;
|
||||
const { viewer, probes } = liveViewer({ ready: () => ready });
|
||||
const sampler = liveSampler(viewer);
|
||||
sampler.setEnabled(true);
|
||||
sampler.request([[10.001, 50]]);
|
||||
await settle(MESH_STREAMING_WAIT_MS * 2);
|
||||
assert.equal(probes.length, 0);
|
||||
ready = true;
|
||||
await settle(MESH_STREAMING_WAIT_MS * 2);
|
||||
assert.equal(probes.length, 1);
|
||||
assert.equal(sampler.meshAt(10.001, 50), 80);
|
||||
sampler.destroy();
|
||||
});
|
||||
|
||||
test('a sample needs a real bare-earth prior and must sit in the shared mesh window', async () => {
|
||||
const { viewer } = liveViewer({ surface: 300 });
|
||||
const unknown = liveSampler(viewer, { groundAt: () => null });
|
||||
unknown.setEnabled(true);
|
||||
unknown.request([[10.001, 50]]);
|
||||
const outside = liveSampler(viewer);
|
||||
outside.setEnabled(true);
|
||||
outside.request([[10.001, 50]]);
|
||||
await settle();
|
||||
assert.equal(unknown.meshAt(10.001, 50), undefined, 'no prior: not kept');
|
||||
assert.equal(
|
||||
outside.meshAt(10.001, 50),
|
||||
undefined,
|
||||
'225 m above it: not kept',
|
||||
);
|
||||
unknown.destroy();
|
||||
outside.destroy();
|
||||
});
|
||||
@@ -195,5 +195,9 @@ export function createMeshFloorSampler({
|
||||
() => eventTarget?.removeEventListener('gev:map-stack-changed', onStack),
|
||||
{ once: true },
|
||||
);
|
||||
return { sampleMeshFloorCells, cachedGroundFloor };
|
||||
return {
|
||||
sampleMeshFloorCells,
|
||||
cachedGroundFloor,
|
||||
visibleTilesetLoaded: _visibleTilesetLoaded,
|
||||
};
|
||||
}
|
||||
@@ -14,8 +14,9 @@ export const PANEL_WORKER_PRELUDE_PATH = 'cesium/worker-prelude.js';
|
||||
|
||||
/**
|
||||
* Sites the app's browser code loads from directly: map imagery, 3D tiles,
|
||||
* terrain and fonts. Everything from the app's own server comes through the
|
||||
* MCP server.
|
||||
* terrain, fonts, and Street Level's Mapillary Graph API and image CDN (its
|
||||
* coverage tiles come through the app's server). Everything from the app's
|
||||
* own server comes through the MCP server.
|
||||
*/
|
||||
const PROVIDER_ORIGINS = Object.freeze([
|
||||
'https://tile.googleapis.com',
|
||||
@@ -35,6 +36,9 @@ const PROVIDER_ORIGINS = Object.freeze([
|
||||
'https://gibs.earthdata.nasa.gov',
|
||||
'https://fonts.googleapis.com',
|
||||
'https://fonts.gstatic.com',
|
||||
'https://graph.mapillary.com',
|
||||
// Mapillary photos come from regional hosts, e.g. scontent-man2-1.xx.fbcdn.net.
|
||||
'https://*.xx.fbcdn.net',
|
||||
]);
|
||||
|
||||
export const MCP_APP_MIME_TYPE = 'text/html;profile=mcp-app';
|
||||
|
||||
@@ -7,6 +7,21 @@ import {
|
||||
} from './globePanel.js';
|
||||
import { panelRuntime } from '../app/globePanelRuntime.js';
|
||||
import { composeCatalog, coreTools } from './index.js';
|
||||
import { MAPILLARY_GRAPH_HOST } from '../layers/streetLevel/providers/mapillary/policy.js';
|
||||
|
||||
/** Whether a CSP source list allows a URL (exact origin or `https://*.host`). */
|
||||
function allows(sources, url) {
|
||||
const { origin, protocol, hostname } = new URL(url);
|
||||
return sources.some((source) => {
|
||||
if (source === origin) return true;
|
||||
const wildcard = source.match(/^(https:)\/\/\*\.(.+)$/);
|
||||
return (
|
||||
Boolean(wildcard) &&
|
||||
protocol === wildcard[1] &&
|
||||
hostname.endsWith(`.${wildcard[2]}`)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('the globe panel is an MCP Apps resource that loads the app through its server', () => {
|
||||
const resource = createGlobePanelResource({
|
||||
@@ -45,6 +60,27 @@ test('the globe panel is an MCP Apps resource that loads the app through its ser
|
||||
assert.doesNotThrow(() => new Function(script));
|
||||
});
|
||||
|
||||
test('the panel lets Street Level reach Mapillary from inside the embed', () => {
|
||||
const { csp } = createGlobePanelResource({
|
||||
runtime: panelRuntime,
|
||||
panelKey: 'test-key',
|
||||
})._meta.ui;
|
||||
// Graph API lookups (nearest image, sequences) and MapillaryJS photo loads.
|
||||
for (const url of [
|
||||
`${MAPILLARY_GRAPH_HOST}/images?fields=id&bbox=1,2,3,4`,
|
||||
'https://scontent-man2-1.xx.fbcdn.net/m1/v/t6/photo.jpg',
|
||||
'https://scontent-iad3-2.xx.fbcdn.net/m1/v/t6/photo.jpg',
|
||||
]) {
|
||||
assert.ok(allows(csp.connectDomains, url), `connect-src allows ${url}`);
|
||||
assert.ok(allows(csp.resourceDomains, url), `img-src allows ${url}`);
|
||||
}
|
||||
assert.equal(allows(csp.connectDomains, 'https://evil.example/x'), false);
|
||||
assert.equal(
|
||||
allows(csp.connectDomains, 'https://fbcdn.net.evil.example/'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('the globe panel needs a request key', () => {
|
||||
for (const panelKey of [undefined, '', 42])
|
||||
assert.throws(
|
||||
|
||||
@@ -939,6 +939,12 @@ export class StyleManager extends ShellFacade {
|
||||
this._streetLevelControls?.destroy();
|
||||
this._streetLevelControls = null;
|
||||
if (!this._streetLevelPanel || !streetLevelLayer) return;
|
||||
// Photo framing and FOLLOW release tracking like any explicit flight.
|
||||
streetLevelLayer.attachNavigation?.({
|
||||
run: (noun, move) => this._runExplicitNavigation(noun, move),
|
||||
subscribeHandoff: (listener) =>
|
||||
this._navigation.subscribeCameraHandoff(listener),
|
||||
});
|
||||
this._streetLevelControls = new StreetLevelControls({
|
||||
root: this._streetLevelPanel,
|
||||
layer: streetLevelLayer,
|
||||
@@ -1577,6 +1583,7 @@ export class StyleManager extends ShellFacade {
|
||||
this._cameraOrientationControls?.destroy();
|
||||
this._clearLayersControl?.destroy();
|
||||
this._cctvControls?.destroy();
|
||||
this.services?.streetLevelLayer?.attachNavigation?.(null);
|
||||
this._streetLevelControls?.destroy();
|
||||
this._radioControls?.destroy();
|
||||
this._localSdrControls?.destroy();
|
||||
|
||||
Reference in new issue
Block a user