diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index dd93bc111..a1afcbb40 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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 diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index d10fb0b46..ce778ee95 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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"] }, diff --git a/server/providers/mapillary/tiles.js b/server/providers/mapillary/tiles.js index 50e539c72..832e8e483 100644 --- a/server/providers/mapillary/tiles.js +++ b/server/providers/mapillary/tiles.js @@ -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 */ diff --git a/src/app/layers/streetLevel.js b/src/app/layers/streetLevel.js index 4f5fc2195..56ba06590 100644 --- a/src/app/layers/streetLevel.js +++ b/src/app/layers/streetLevel.js @@ -15,6 +15,7 @@ export function createApplicationStreetLevel({ surface, sources }) { input, render, ground: surface?.groundFloor ?? null, + meshFloor: surface?.meshFloor ?? null, terrain: surface?.terrain ?? null, }, }); diff --git a/src/layers/streetLevel/cameraFollow.js b/src/layers/streetLevel/cameraFollow.js index 90f887005..3e60ec78f 100644 --- a/src/layers/streetLevel/cameraFollow.js +++ b/src/layers/streetLevel/cameraFollow.js @@ -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, + }; } diff --git a/src/layers/streetLevel/cameraFollow.test.mjs b/src/layers/streetLevel/cameraFollow.test.mjs index f074b5cb9..75f7ef384 100644 --- a/src/layers/streetLevel/cameraFollow.test.mjs +++ b/src/layers/streetLevel/cameraFollow.test.mjs @@ -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); +}); diff --git a/src/layers/streetLevel/index.js b/src/layers/streetLevel/index.js index 5a8964a5a..9ec25f654 100644 --- a/src/layers/streetLevel/index.js +++ b/src/layers/streetLevel/index.js @@ -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?.(); diff --git a/src/layers/streetLevel/meshSampler.js b/src/layers/streetLevel/meshSampler.js index 52054eafc..fec297a69 100644 --- a/src/layers/streetLevel/meshSampler.js +++ b/src/layers/streetLevel/meshSampler.js @@ -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(); } diff --git a/src/layers/streetLevel/meshSampler.test.mjs b/src/layers/streetLevel/meshSampler.test.mjs index 5982bf638..315cd60ba 100644 --- a/src/layers/streetLevel/meshSampler.test.mjs +++ b/src/layers/streetLevel/meshSampler.test.mjs @@ -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(); +}); diff --git a/src/services/meshFloorSampler.js b/src/services/meshFloorSampler.js index 0faee939d..b1cf65f29 100644 --- a/src/services/meshFloorSampler.js +++ b/src/services/meshFloorSampler.js @@ -195,5 +195,9 @@ export function createMeshFloorSampler({ () => eventTarget?.removeEventListener('gev:map-stack-changed', onStack), { once: true }, ); - return { sampleMeshFloorCells, cachedGroundFloor }; + return { + sampleMeshFloorCells, + cachedGroundFloor, + visibleTilesetLoaded: _visibleTilesetLoaded, + }; } diff --git a/src/tools/globePanel.js b/src/tools/globePanel.js index 71345dae3..226bd0528 100644 --- a/src/tools/globePanel.js +++ b/src/tools/globePanel.js @@ -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'; diff --git a/src/tools/globePanel.test.mjs b/src/tools/globePanel.test.mjs index 964ca0a64..5c65596bf 100644 --- a/src/tools/globePanel.test.mjs +++ b/src/tools/globePanel.test.mjs @@ -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( diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index ff55daa3f..ec8125cdd 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -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();