mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +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
@@ -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();
|
||||
});
|
||||
Reference in new issue
Block a user