mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +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
|
its heights arrive; a geoid fallback leaves it draped. Within 900 m of the
|
||||||
camera, `meshSampler.js` samples the rendered surface (`scene.sampleHeight`,
|
camera, `meshSampler.js` samples the rendered surface (`scene.sampleHeight`,
|
||||||
~11 m cells, nearest first, 6 ms idle budget) so lines also follow trenches,
|
~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
|
ground level. Framing a photo ignores mesh samples far below the bare earth
|
||||||
(unloaded tiles).
|
(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
|
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
|
viewer sits under the header with EXPAND, FIT/FILL, FOLLOW and close above the
|
||||||
image. FOLLOW needs the Google 3D map stack (`attachMapStackController`) and
|
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
|
cut-off date. The panel is portable (`street-level-panel` spec, minimum
|
||||||
320 × 280, `dockOnCollapse`): a floating window gives spare height to the
|
320 × 280, `dockOnCollapse`): a floating window gives spare height to the
|
||||||
viewer, and collapsing it, double-clicking the header or SHRINK after EXPAND
|
viewer, and collapsing it, double-clicking the header or SHRINK after EXPAND
|
||||||
|
|||||||
@@ -195,7 +195,9 @@
|
|||||||
"server/providers/common/same-site.js",
|
"server/providers/common/same-site.js",
|
||||||
"src/localRequestGate.mjs",
|
"src/localRequestGate.mjs",
|
||||||
"server/providers/common/rate-limit.js",
|
"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"]
|
"external": ["pbf"]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { promises as fsp } from 'node:fs';
|
import { promises as fsp } from 'node:fs';
|
||||||
|
import { readResponseBytesCapped } from '../common/http.js';
|
||||||
import { stripTileLayers } from './trim.js';
|
import { stripTileLayers } from './trim.js';
|
||||||
import {
|
import {
|
||||||
MAPILLARY_TILE_HOST,
|
MAPILLARY_TILE_HOST,
|
||||||
@@ -350,35 +351,20 @@ async function fetchUpstream(address, signal) {
|
|||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
const declared = Number(response.headers.get('content-length'));
|
// Chunked or compressed bodies have no Content-Length: the cap applies as
|
||||||
if (Number.isFinite(declared) && declared > TILE_MAX_BYTES) {
|
// the body streams, and an oversized one also aborts the request.
|
||||||
await response.body?.cancel().catch(() => {});
|
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');
|
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 */
|
/** @type {{active: number, queue: Array<() => void>}} upstream slots */
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export function createApplicationStreetLevel({ surface, sources }) {
|
|||||||
input,
|
input,
|
||||||
render,
|
render,
|
||||||
ground: surface?.groundFloor ?? null,
|
ground: surface?.groundFloor ?? null,
|
||||||
|
meshFloor: surface?.meshFloor ?? null,
|
||||||
terrain: surface?.terrain ?? 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. */
|
/** Plausible ellipsoidal land heights: Dead Sea to Everest, with geoid slack. */
|
||||||
const SURFACE_MIN_M = -500;
|
const SURFACE_MIN_M = -500;
|
||||||
const SURFACE_MAX_M = 9000;
|
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). */
|
/** Drive the globe camera from the street-level pose: continuously (follow) or once (framing). */
|
||||||
export function createCameraFollow({ state, parts }) {
|
export function createCameraFollow({ state, parts }) {
|
||||||
@@ -16,6 +13,48 @@ export function createCameraFollow({ state, parts }) {
|
|||||||
render?.governorRequestRender?.('street-level-follow');
|
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
|
* Ground under a photo. `sampleHeight` can return kilometres below the
|
||||||
* ellipsoid before tiles load, so a sample counts only when plausible and
|
* 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 caster = parts?.groundCaster;
|
||||||
const dem = caster?.groundAt(lon, lat) ?? null;
|
const dem = caster?.groundAt(lon, lat) ?? null;
|
||||||
if (dem === null) caster?.prepare([[lon, lat]]);
|
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) =>
|
const plausible = (height) =>
|
||||||
Number.isFinite(height) &&
|
Number.isFinite(height) &&
|
||||||
height >= SURFACE_MIN_M &&
|
height >= SURFACE_MIN_M &&
|
||||||
height <= SURFACE_MAX_M &&
|
height <= SURFACE_MAX_M &&
|
||||||
(dem === null ||
|
(dem === null || !withinPrior || withinPrior(height, dem));
|
||||||
(height >= dem - MESH_BELOW_DEM_M && height <= dem + MESH_ABOVE_DEM_M));
|
|
||||||
let height = null;
|
let height = null;
|
||||||
try {
|
try {
|
||||||
if (scene?.sampleHeightSupported) height = scene.sampleHeight(carto);
|
if (scene?.sampleHeightSupported) height = scene.sampleHeight(carto);
|
||||||
@@ -73,29 +113,31 @@ export function createCameraFollow({ state, parts }) {
|
|||||||
const { position, bearing, altitude } = state.street;
|
const { position, bearing, altitude } = state.street;
|
||||||
if (!position || !state.viewer) return;
|
if (!position || !state.viewer) return;
|
||||||
const ground = groundHeightAt(position.lon, position.lat, altitude);
|
const ground = groundHeightAt(position.lon, position.lat, altitude);
|
||||||
// Owned before the call: starting it cancels the previous flight (ours
|
claim(() => {
|
||||||
// included) synchronously, and a zero-length one completes at once.
|
// Owned before the call: starting it cancels the previous flight (ours
|
||||||
const flight = {};
|
// included) synchronously, and a zero-length one completes at once.
|
||||||
framing = flight;
|
const flight = {};
|
||||||
const release = () => {
|
framing = flight;
|
||||||
if (framing === flight) framing = null;
|
const release = () => {
|
||||||
};
|
if (framing === flight) framing = null;
|
||||||
state.viewer.camera.flyToBoundingSphere(
|
};
|
||||||
new Cesium.BoundingSphere(
|
state.viewer.camera.flyToBoundingSphere(
|
||||||
Cesium.Cartesian3.fromDegrees(position.lon, position.lat, ground + 2),
|
new Cesium.BoundingSphere(
|
||||||
4,
|
Cesium.Cartesian3.fromDegrees(position.lon, position.lat, ground + 2),
|
||||||
),
|
4,
|
||||||
{
|
|
||||||
offset: new Cesium.HeadingPitchRange(
|
|
||||||
Cesium.Math.toRadians(bearing || 0),
|
|
||||||
Cesium.Math.toRadians(-32),
|
|
||||||
140,
|
|
||||||
),
|
),
|
||||||
duration: 1.6,
|
{
|
||||||
complete: release,
|
offset: new Cesium.HeadingPitchRange(
|
||||||
cancel: release,
|
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. */
|
/** 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) {
|
function setFollow(enabled) {
|
||||||
state.street.follow = enabled === true && state.street.followAvailable;
|
const wanted = enabled === true && state.street.followAvailable;
|
||||||
// The framing tween would keep overwriting the follow view until it lands.
|
if (wanted && !state.street.follow) {
|
||||||
if (state.street.follow) {
|
// Following owns the camera until another feature takes it.
|
||||||
cancelFraming();
|
const granted = claim(() => {
|
||||||
followCamera();
|
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?.();
|
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(camera.cancelled, 1, 'the framing tween stops');
|
||||||
assert.equal(views.length, 1, 'and the camera stands at the photo');
|
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
|
parts.groundCaster = services.terrain?.resolveEllipsoidalGround
|
||||||
? createGroundCaster({ terrain: services.terrain })
|
? createGroundCaster({ terrain: services.terrain })
|
||||||
: null;
|
: 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
|
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;
|
: null;
|
||||||
parts.credits = createCredits();
|
parts.credits = createCredits();
|
||||||
parts.marker = createMarker(context);
|
parts.marker = createMarker(context);
|
||||||
@@ -375,6 +382,7 @@ export function createStreetLevelLayer({
|
|||||||
entry.instance.destroy(viewer);
|
entry.instance.destroy(viewer);
|
||||||
parts.marker.destroy(viewer);
|
parts.marker.destroy(viewer);
|
||||||
parts.meshSampler?.destroy();
|
parts.meshSampler?.destroy();
|
||||||
|
parts.follow.destroy();
|
||||||
unsubscribeMapStack?.();
|
unsubscribeMapStack?.();
|
||||||
unsubscribeMapStack = null;
|
unsubscribeMapStack = null;
|
||||||
mapStack = 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. */
|
/** The application map stack; FOLLOW is available only on Google 3D. */
|
||||||
attachMapStackController(controller) {
|
attachMapStackController(controller) {
|
||||||
unsubscribeMapStack?.();
|
unsubscribeMapStack?.();
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import { metresBetween, whenIdle } from './view.js';
|
|||||||
* Google 3D surface heights for ground casting (groundCast.js refineHeights):
|
* Google 3D surface heights for ground casting (groundCast.js refineHeights):
|
||||||
* one cached `scene.sampleHeight` probe per ~11 m cell. Each probe renders a
|
* 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
|
* 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
|
* camera first and only within MESH_SAMPLE_RADIUS_M. Like the application's
|
||||||
* streamed yet) is retried later. Probes hit 3D tilesets only.
|
* 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. */
|
/** 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;
|
export const MESH_RERANK_M = 100;
|
||||||
/** A cell whose probe missed is tried again after this long. */
|
/** A cell whose probe missed is tried again after this long. */
|
||||||
export const MESH_MISS_RETRY_MS = 8000;
|
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. */
|
/** Cached cells (and remembered misses) before they are dropped and refilled. */
|
||||||
const MESH_CACHE_MAX = 80_000;
|
const MESH_CACHE_MAX = 80_000;
|
||||||
/** Listeners hear about new samples at most this often. */
|
/** 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({
|
export function createMeshSampler({
|
||||||
getViewer,
|
getViewer,
|
||||||
|
groundAt = null,
|
||||||
|
withinPrior = null,
|
||||||
|
tilesReady = null,
|
||||||
budgetMs = MESH_SAMPLE_BUDGET_MS,
|
budgetMs = MESH_SAMPLE_BUDGET_MS,
|
||||||
radiusM = MESH_SAMPLE_RADIUS_M,
|
radiusM = MESH_SAMPLE_RADIUS_M,
|
||||||
cacheMax = MESH_CACHE_MAX,
|
cacheMax = MESH_CACHE_MAX,
|
||||||
@@ -91,6 +109,8 @@ export function createMeshSampler({
|
|||||||
}) {
|
}) {
|
||||||
/** cell key → sampled mesh height (ellipsoidal metres). */
|
/** cell key → sampled mesh height (ellipsoidal metres). */
|
||||||
const heights = new Map();
|
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. */
|
/** cell key → retry time for probes that hit nothing; bounded like the height cache. */
|
||||||
const misses = new Map();
|
const misses = new Map();
|
||||||
/** cell key → queued cell, so a cell is queued once. */
|
/** cell key → queued cell, so a cell is queued once. */
|
||||||
@@ -114,16 +134,40 @@ export function createMeshSampler({
|
|||||||
return heights.get(meshCellKey(lon, lat));
|
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() {
|
function cameraCentre() {
|
||||||
const carto = getViewer()?.camera?.positionCartographic;
|
const carto = getViewer()?.camera?.positionCartographic;
|
||||||
if (!carto) return null;
|
if (!carto) return null;
|
||||||
return {
|
return {
|
||||||
lon: Cesium.Math.toDegrees(carto.longitude),
|
lon: Cesium.Math.toDegrees(carto.longitude),
|
||||||
lat: Cesium.Math.toDegrees(carto.latitude),
|
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
|
* 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.
|
* 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++];
|
const [lon, lat] = points[pending.next++];
|
||||||
if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue;
|
if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue;
|
||||||
const key = meshCellKey(lon, lat);
|
const key = meshCellKey(lon, lat);
|
||||||
if (heights.has(key) || wanted.has(key)) continue;
|
if (wanted.has(key)) continue;
|
||||||
const cell = {
|
const cell = {
|
||||||
key,
|
key,
|
||||||
lon: cellOf(lon) * MESH_CELL_DEG,
|
lon: cellOf(lon) * MESH_CELL_DEG,
|
||||||
lat: cellOf(lat) * MESH_CELL_DEG,
|
lat: cellOf(lat) * MESH_CELL_DEG,
|
||||||
distance: 0,
|
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);
|
const retryAt = misses.get(key);
|
||||||
if (retryAt !== undefined) {
|
if (retryAt !== undefined) {
|
||||||
if (retryAt > time) continue;
|
if (retryAt > time) continue;
|
||||||
@@ -220,6 +270,15 @@ export function createMeshSampler({
|
|||||||
const scene = getViewer()?.scene;
|
const scene = getViewer()?.scene;
|
||||||
const centre = cameraCentre();
|
const centre = cameraCentre();
|
||||||
if (!enabled || !scene?.sampleHeightSupported || !centre) return;
|
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
|
// Nearest first, ranked from where the camera was; out-of-range cells
|
||||||
// are dropped (they stay bare earth).
|
// are dropped (they stay bare earth).
|
||||||
let worked = false;
|
let worked = false;
|
||||||
@@ -241,7 +300,8 @@ export function createMeshSampler({
|
|||||||
const { key, lon, lat } = heapPop(queue);
|
const { key, lon, lat } = heapPop(queue);
|
||||||
wanted.delete(key);
|
wanted.delete(key);
|
||||||
// Ranked from up to MESH_RERANK_M away: check the range from here.
|
// 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++;
|
probed++;
|
||||||
const probeStarted = now();
|
const probeStarted = now();
|
||||||
let height;
|
let height;
|
||||||
@@ -254,15 +314,27 @@ export function createMeshSampler({
|
|||||||
height = undefined;
|
height = undefined;
|
||||||
}
|
}
|
||||||
probeMs = probeMs * 0.75 + (now() - probeStarted) * 0.25;
|
probeMs = probeMs * 0.75 + (now() - probeStarted) * 0.25;
|
||||||
if (!Number.isFinite(height)) {
|
height = validated(height, lon, lat);
|
||||||
if (misses.size >= cacheMax) misses.clear();
|
const previous = heights.get(key);
|
||||||
misses.set(key, Date.now() + MESH_MISS_RETRY_MS);
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
if (heights.size >= cacheMax) forget();
|
if (previous === undefined && heights.size >= cacheMax) forget();
|
||||||
misses.delete(key);
|
misses.delete(key);
|
||||||
heights.set(key, height);
|
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)
|
if (fresh.length && !notifyTimer)
|
||||||
notifyTimer = setTimeout(emit, MESH_NOTIFY_MS);
|
notifyTimer = setTimeout(emit, MESH_NOTIFY_MS);
|
||||||
@@ -271,6 +343,7 @@ export function createMeshSampler({
|
|||||||
|
|
||||||
function forget() {
|
function forget() {
|
||||||
heights.clear();
|
heights.clear();
|
||||||
|
probedFrom.clear();
|
||||||
misses.clear();
|
misses.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ import * as Cesium from 'cesium';
|
|||||||
import {
|
import {
|
||||||
MESH_CELL_DEG,
|
MESH_CELL_DEG,
|
||||||
MESH_MISS_RETRY_MS,
|
MESH_MISS_RETRY_MS,
|
||||||
|
MESH_REFRESH_MIN_M,
|
||||||
MESH_SAMPLE_BUDGET_MS,
|
MESH_SAMPLE_BUDGET_MS,
|
||||||
|
MESH_STREAMING_WAIT_MS,
|
||||||
createMeshSampler,
|
createMeshSampler,
|
||||||
} from './meshSampler.js';
|
} from './meshSampler.js';
|
||||||
|
import { meshFloorSampleWithinPrior } from '../../data/groundFloor.js';
|
||||||
import { metresBetween } from './view.js';
|
import { metresBetween } from './view.js';
|
||||||
|
|
||||||
const settle = (ms = 60) => new Promise((resolve) => setTimeout(resolve, ms));
|
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);
|
assert.ok(Math.abs(Math.abs(probes[0].lon) - 179.9999) < MESH_CELL_DEG);
|
||||||
sampler.destroy();
|
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),
|
() => eventTarget?.removeEventListener('gev:map-stack-changed', onStack),
|
||||||
{ once: true },
|
{ 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,
|
* 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
|
* terrain, fonts, and Street Level's Mapillary Graph API and image CDN (its
|
||||||
* MCP server.
|
* 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([
|
const PROVIDER_ORIGINS = Object.freeze([
|
||||||
'https://tile.googleapis.com',
|
'https://tile.googleapis.com',
|
||||||
@@ -35,6 +36,9 @@ const PROVIDER_ORIGINS = Object.freeze([
|
|||||||
'https://gibs.earthdata.nasa.gov',
|
'https://gibs.earthdata.nasa.gov',
|
||||||
'https://fonts.googleapis.com',
|
'https://fonts.googleapis.com',
|
||||||
'https://fonts.gstatic.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';
|
export const MCP_APP_MIME_TYPE = 'text/html;profile=mcp-app';
|
||||||
|
|||||||
@@ -7,6 +7,21 @@ import {
|
|||||||
} from './globePanel.js';
|
} from './globePanel.js';
|
||||||
import { panelRuntime } from '../app/globePanelRuntime.js';
|
import { panelRuntime } from '../app/globePanelRuntime.js';
|
||||||
import { composeCatalog, coreTools } from './index.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', () => {
|
test('the globe panel is an MCP Apps resource that loads the app through its server', () => {
|
||||||
const resource = createGlobePanelResource({
|
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));
|
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', () => {
|
test('the globe panel needs a request key', () => {
|
||||||
for (const panelKey of [undefined, '', 42])
|
for (const panelKey of [undefined, '', 42])
|
||||||
assert.throws(
|
assert.throws(
|
||||||
|
|||||||
@@ -939,6 +939,12 @@ export class StyleManager extends ShellFacade {
|
|||||||
this._streetLevelControls?.destroy();
|
this._streetLevelControls?.destroy();
|
||||||
this._streetLevelControls = null;
|
this._streetLevelControls = null;
|
||||||
if (!this._streetLevelPanel || !streetLevelLayer) return;
|
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({
|
this._streetLevelControls = new StreetLevelControls({
|
||||||
root: this._streetLevelPanel,
|
root: this._streetLevelPanel,
|
||||||
layer: streetLevelLayer,
|
layer: streetLevelLayer,
|
||||||
@@ -1577,6 +1583,7 @@ export class StyleManager extends ShellFacade {
|
|||||||
this._cameraOrientationControls?.destroy();
|
this._cameraOrientationControls?.destroy();
|
||||||
this._clearLayersControl?.destroy();
|
this._clearLayersControl?.destroy();
|
||||||
this._cctvControls?.destroy();
|
this._cctvControls?.destroy();
|
||||||
|
this.services?.streetLevelLayer?.attachNavigation?.(null);
|
||||||
this._streetLevelControls?.destroy();
|
this._streetLevelControls?.destroy();
|
||||||
this._radioControls?.destroy();
|
this._radioControls?.destroy();
|
||||||
this._localSdrControls?.destroy();
|
this._localSdrControls?.destroy();
|
||||||
|
|||||||
Reference in new issue
Block a user