diff --git a/CHANGELOG.md b/CHANGELOG.md index 37a6b3a95..c42d5eb2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,8 @@ imagery). One right-rail panel holds an ON/OFF pill, shared 360°/flat and captured-since filters, a legend, and an embedded viewer with full-screen EXPAND and FIT/FILL. Coverage lines and image cones are drawn from orbit - down to a single street; on Google 3D they sit on the bare earth, refined - against the rendered mesh near the camera, so trees and buildings hide them. + down to a single street, draped on the terrain and on Google 3D tiles; + opening a photo frames it with one camera flight. Share links carry the provider switches, the filters and the panel's collapsed state. A server proxy caches Mapillary coverage tiles (24 h, 1 GiB on disk), refuses cross-site requests, rate-limits per IP and backs off when Mapillary diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c691023b3..bfc0ecb5c 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2952,33 +2952,23 @@ sequence lines below 60 km; the proxy strips the unused `image` layer from z14 tiles (12 MB → ~80 KB) and shares one upstream fetch between concurrent requests for a tile. -On Google 3D (`photoreal`) at street zoom (in below 1,400 m above ground, out -above 1,800 m) the surface mode switches from `draped` to `terrain`: draped -lines land on roofs and tree tops, so `groundCast.js` instead places lines, -cones and the marker 2 m above the bare earth from `/api/terrain/heights`, and -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. 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. A -failed probe keeps the last good sample and distance and is retried after the -miss cooldown. Roads on elevated decks are still drawn at -ground level. Framing a photo ignores mesh samples far below the bare earth -(unloaded tiles). +Sequence lines are `GroundPolylinePrimitive`s with +`ClassificationType.BOTH`, so they drape on the terrain globe and on Google 3D +tiles alike (on 3D tiles they can land on roofs and tree tops). Cones and the +photo marker are billboards clamped to the ground; they skip the depth test, so +a horizon cull hides the ones behind the globe. Roads on elevated decks are +drawn at ground level. The header pill is the only layer switch; switching on also turns back on a provider a share link or tool switched off. The viewer sits under the header with EXPAND, FIT/FILL and close above the image. EXPAND is the browser's Fullscreen API on the viewer element, which stays in the panel (Esc or SHRINK -leaves; the button is hidden where the API is missing). There is no FOLLOW -button; the layer's `setFollow` has no panel control. Camera moves go through -the application's navigation (`attachNavigation`), which releases aircraft and -satellite tracking and is refused in the cockpit; a photo takes a deferred -ticket when it starts opening and frames only if nothing newer took the camera -while it loaded. SINCE is a stepped slider whose readout names the +leaves; the button is hidden where the API is missing). Opening a photo flies +the globe camera once to frame it; there is no camera follow. The flight goes +through the application's deferred navigation (`attachNavigation`: `begin` / +`reassert`), which releases aircraft and satellite tracking and is refused in +the cockpit; a photo takes its ticket when it starts opening and frames only +if nothing newer took the camera while it loaded. SINCE is a stepped slider whose readout names the cut-off date. At phone width (≤720 px) the viewer height is derived from the rail band so the whole photo fits. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 94abd4e08..1fcafb021 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -871,11 +871,9 @@ "src/layers/streetLevel/params.js", "src/layers/streetLevel/pickRouter.js", "src/layers/streetLevel/uiState.js", - "src/layers/streetLevel/groundCast.js", - "src/layers/streetLevel/meshSampler.js", "src/layers/streetLevel/view.js", "src/layers/streetLevel/marker.js", - "src/layers/streetLevel/cameraFollow.js", + "src/layers/streetLevel/cameraFraming.js", "src/layers/streetLevel/credits.js", "src/layers/streetLevel/viewerHost.js", "src/layers/streetLevel/providers/mapillary/index.js", @@ -904,14 +902,12 @@ "src/layers/streetLevel/params.test.mjs", "src/layers/streetLevel/pickRouter.test.mjs", "src/layers/streetLevel/uiState.test.mjs", - "src/layers/streetLevel/groundCast.test.mjs", - "src/layers/streetLevel/meshSampler.test.mjs", "src/layers/streetLevel/view.test.mjs", "src/layers/streetLevel/providers/mapillary/provider.test.mjs", "src/layers/streetLevel/providers/mapillary/decode.test.mjs", "src/layers/streetLevel/providers/mapillary/coverage.test.mjs", "src/layers/streetLevel/providers/mapillary/coverageFixture.mjs", - "src/layers/streetLevel/cameraFollow.test.mjs", + "src/layers/streetLevel/cameraFraming.test.mjs", "src/ui/streetLevelControls.test.mjs", "src/layers/streetLevel/marker.test.mjs", "src/layers/streetLevel/viewerHost.test.mjs", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 4f53ab761..638e2d363 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1186,12 +1186,10 @@ "src/layers/streetLevel/providers/mapillary/index.js", "src/layers/streetLevel/viewerHost.js", "src/layers/streetLevel/credits.js", - "src/layers/streetLevel/cameraFollow.js", + "src/layers/streetLevel/cameraFraming.js", "src/layers/streetLevel/marker.js", "src/layers/streetLevel/view.js", "src/layers/streetLevel/uiState.js", - "src/layers/streetLevel/groundCast.js", - "src/layers/streetLevel/meshSampler.js", "src/layers/streetLevel/pickRouter.js", "src/layers/streetLevel/params.js", "src/layers/streetLevel/registry.js", @@ -2105,12 +2103,10 @@ "src/layers/streetLevel/providers/mapillary/index.js", "src/layers/streetLevel/viewerHost.js", "src/layers/streetLevel/credits.js", - "src/layers/streetLevel/cameraFollow.js", + "src/layers/streetLevel/cameraFraming.js", "src/layers/streetLevel/marker.js", "src/layers/streetLevel/view.js", "src/layers/streetLevel/uiState.js", - "src/layers/streetLevel/groundCast.js", - "src/layers/streetLevel/meshSampler.js", "src/layers/streetLevel/pickRouter.js", "src/layers/streetLevel/params.js", "src/layers/streetLevel/registry.js", @@ -2626,12 +2622,10 @@ "src/layers/streetLevel/providers/mapillary/index.js", "src/layers/streetLevel/viewerHost.js", "src/layers/streetLevel/credits.js", - "src/layers/streetLevel/cameraFollow.js", + "src/layers/streetLevel/cameraFraming.js", "src/layers/streetLevel/marker.js", "src/layers/streetLevel/view.js", "src/layers/streetLevel/uiState.js", - "src/layers/streetLevel/groundCast.js", - "src/layers/streetLevel/meshSampler.js", "src/layers/streetLevel/pickRouter.js", "src/layers/streetLevel/params.js", "src/layers/streetLevel/registry.js", diff --git a/scripts/qa-street-level.mjs b/scripts/qa-street-level.mjs index 19fc83d2e..5d0c028f7 100644 --- a/scripts/qa-street-level.mjs +++ b/scripts/qa-street-level.mjs @@ -45,14 +45,11 @@ export const VIEWPORTS = Object.freeze([ export const EXPECTED_PROVIDERS = Object.freeze(['mapillary']); /** - * Skips no setup can avoid: CI forks have no Google key, and live runs cannot - * stage fixture-only cases. `no Mapillary key` is not accepted: CI's dummy - * token runs the keyed flow, so a keyless strict run means that wiring broke. + * Skips no setup can avoid: live runs cannot stage fixture-only cases. + * `no Mapillary key` is not accepted: CI's dummy token runs the keyed flow, + * so a keyless strict run means that wiring broke. */ -export const STRICT_ALLOWED_SKIPS = Object.freeze([ - 'no Google 3D', - 'fixtures only', -]); +export const STRICT_ALLOWED_SKIPS = Object.freeze(['fixtures only']); export function strictViolations(skips) { return skips.filter((skip) => !STRICT_ALLOWED_SKIPS.includes(skip.reason)); @@ -767,12 +764,6 @@ async function main() { page.evaluate(() => window.__godsEyeView.mapStackController.getActiveId(), ), - photoreal: () => - page.evaluate(() => - window.__godsEyeView.mapStackController.isStackAvailable( - 'photoreal', - ), - ), set: async (id) => { await page.evaluate( (stackId) => @@ -1041,7 +1032,6 @@ async function main() { const original = await stacks.active(); // Draped lines on flat imagery: the line is where it is drawn. await stacks.set('esri-imagery'); - await uiUntil((u) => u.surface === 'draped'); await waitForCoverage(page); const target = fixtures ? PHOTO_SEQUENCE_ID : null; const along = Array.from({ length: 9 }, (_, i) => [ @@ -1295,49 +1285,6 @@ async function main() { assert.equal((await ui()).street.open, false); }, ); - await step( - 'coverage sits on the bare earth on Google 3D and drapes elsewhere', - async ({ skip }) => { - const surface = () => - page.evaluate(() => { - const u = window.__godsEyeView.dataManager.layers - .get('street-level') - .module.getUIState(); - return { surface: u.surface, count: u.coverage.count }; - }); - const original = await stacks.active(); - await stacks.set('esri-imagery'); - await page.waitForFunction( - () => - window.__godsEyeView.dataManager.layers - .get('street-level') - .module.getUIState().surface === 'draped', - { timeout: 10_000 }, - ); - const coverageLoaded = () => - waitForCoverage(page, { timeout: 60_000 }); - await coverageLoaded(); - assert.equal((await surface()).surface, 'draped', 'draped on Esri'); - if (await stacks.photoreal()) { - await stacks.set('photoreal'); - await page.waitForFunction( - () => - window.__godsEyeView.dataManager.layers - .get('street-level') - .module.getUIState().surface === 'terrain', - { timeout: 15_000 }, - ); - await coverageLoaded(); - assert.equal((await surface()).surface, 'terrain'); - } else { - console.log( - ' (Google 3D unavailable here: only the draped path ran)', - ); - skip('no Google 3D'); - } - await stacks.set(original); - }, - ); await step( '× closes the image and deselects it on the globe', async () => { diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 0a8ef4fe8..d45567e28 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -152,7 +152,6 @@ export function createApplicationCatalog({ }), createApplicationAlpr({ surface, source: sources.alpr }), createApplicationStreetLevel({ - surface, sources: { mapillary: sources.mapillary }, }), satellites, diff --git a/src/app/layers/streetLevel.js b/src/app/layers/streetLevel.js index 56ba06590..9b08316d6 100644 --- a/src/app/layers/streetLevel.js +++ b/src/app/layers/streetLevel.js @@ -6,17 +6,9 @@ import * as input from '../../data/inputOwnership.js'; import * as render from '../../renderGovernor.js'; /** A new provider registers here; its chip, credit and share bit follow. */ -export function createApplicationStreetLevel({ surface, sources }) { +export function createApplicationStreetLevel({ sources }) { return createStreetLevelLayer({ providers: [createMapillaryProvider({ source: sources.mapillary })], - services: { - sprites, - picking, - input, - render, - ground: surface?.groundFloor ?? null, - meshFloor: surface?.meshFloor ?? null, - terrain: surface?.terrain ?? null, - }, + services: { sprites, picking, input, render }, }); } diff --git a/src/layers/streetLevel/cameraFollow.js b/src/layers/streetLevel/cameraFollow.js deleted file mode 100644 index 09733e172..000000000 --- a/src/layers/streetLevel/cameraFollow.js +++ /dev/null @@ -1,209 +0,0 @@ -import * as Cesium from 'cesium'; -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; - -/** Drive the globe camera from the street-level pose: continuously (follow) or once (framing). */ -export function createCameraFollow({ state, parts }) { - const { render } = state.services; - - function requestRender() { - render?.governorRequestRender?.('street-level-follow'); - } - - /** - * The application's camera authority (`attachNavigation`): `run(noun, move)` - * releases tracking and other owners before `move`, or refuses (cockpit); - * `begin(noun)` takes a deferred ticket (a generation, or false) that - * `reassert(generation)` honours only if nothing newer took the camera; - * `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; - } - } - - /** - * Ask for the camera as a photo starts opening. The ticket goes to - * `lookAtPosition`; null when the application refuses (cockpit). - */ - function beginFraming() { - if (!navigation?.begin || !navigation.reassert) return { generation: null }; - claiming = true; - try { - const generation = navigation.begin('photo'); - return generation === false ? null : { generation }; - } 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 - * within the mesh window around bare earth (once that is known). - */ - function groundHeightAt(lon, lat, fallback) { - const scene = state.viewer?.scene; - const carto = Cesium.Cartographic.fromDegrees(lon, lat); - 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 || !withinPrior || withinPrior(height, dem)); - let height = null; - try { - if (scene?.sampleHeightSupported) height = scene.sampleHeight(carto); - } catch { - /* not sampleable yet */ - } - if (!plausible(height)) height = dem; - if (!plausible(height)) height = scene?.globe?.getHeight?.(carto); - if (!plausible(height)) height = fallback; - return plausible(height) ? height : 0; - } - - /** Put the Cesium camera where the street-level camera is. */ - function followCamera() { - const { position, bearing, tilt, follow, altitude } = state.street; - if (!follow || !position || !state.viewer) return; - const ground = groundHeightAt(position.lon, position.lat, altitude); - state.viewer.camera.setView({ - destination: Cesium.Cartesian3.fromDegrees( - position.lon, - position.lat, - ground + FOLLOW_EYE_HEIGHT_M, - ), - orientation: { - heading: Cesium.Math.toRadians(bearing || 0), - pitch: Cesium.Math.toRadians(Number.isFinite(tilt) ? tilt : 0), - roll: 0, - }, - }); - requestRender(); - } - - /** Our framing flight while it is current; Cesium calls `cancel` when another flight starts. */ - let framing = null; - - /** - * Frame the current image from a short distance behind it, unless another - * action took the camera since `ticket` (from `beginFraming`) was issued. - */ - function lookAtPosition(ticket) { - const { position, bearing, altitude } = state.street; - if (!ticket || !position || !state.viewer) return; - if ( - ticket.generation !== null && - !navigation?.reassert?.(ticket.generation) - ) - return; - const ground = groundHeightAt(position.lon, position.lat, altitude); - const fly = () => { - // 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, - ), - duration: 1.6, - complete: release, - cancel: release, - }, - ); - }; - // A reasserted ticket has already released the other owners. - if (ticket.generation === null) claim(fly); - else fly(); - } - - /** Stop our framing flight if it is still in the air; another feature's flight is left alone. */ - function cancelFraming() { - if (!framing) return; - // Give up ownership first: cancelFlight delivers `cancel` synchronously. - framing = null; - state.viewer?.camera?.cancelFlight?.(); - } - - function setFollow(enabled) { - 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?.(); - } - - function destroy() { - attachNavigation(null); - } - - return { - followCamera, - beginFraming, - lookAtPosition, - cancelFraming, - setFollow, - attachNavigation, - destroy, - }; -} diff --git a/src/layers/streetLevel/cameraFollow.test.mjs b/src/layers/streetLevel/cameraFollow.test.mjs deleted file mode 100644 index 365858e4e..000000000 --- a/src/layers/streetLevel/cameraFollow.test.mjs +++ /dev/null @@ -1,265 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import * as Cesium from 'cesium'; -import { createCameraFollow } from './cameraFollow.js'; -import { FOLLOW_EYE_HEIGHT_M } from './policy.js'; - -/** Height (m) of a Cartesian above the WGS84 ellipsoid. */ -const heightOf = (cartesian) => - Cesium.Cartographic.fromCartesian(cartesian).height; - -/** - * A camera with Cesium's flight bookkeeping: a new flight or `cancelFlight` - * cancels the current one, `land()` completes it, `cancelled` counts stops. - */ -function flightCamera(flights, views) { - let current = null; - function stop() { - const flight = current; - current = null; - flight?.options.cancel?.(); - return Boolean(flight); - } - const camera = { - cancelled: 0, - flyToBoundingSphere(sphere, options = {}) { - stop(); - current = { sphere, options }; - flights.push(current); - }, - cancelFlight() { - if (stop()) camera.cancelled++; - }, - land() { - const flight = current; - current = null; - flight?.options.complete?.(); - }, - setView: (view) => views.push(view), - }; - return camera; -} - -/** - * A viewer whose scene sample is `sampled` (e.g. −14,886 m before the tiles - * under the photo load) and whose bare earth is `dem`. - */ -function setup({ sampled, dem = null, globe = null, altitude = 149 }) { - const flights = []; - const views = []; - const state = { - services: {}, - street: { - position: { lon: -97.7364, lat: 30.2672 }, - bearing: 105, - tilt: -10, - altitude, - follow: false, - followAvailable: true, - }, - viewer: { - scene: { - sampleHeightSupported: true, - sampleHeight: () => sampled, - globe: { getHeight: () => globe }, - }, - camera: flightCamera(flights, views), - }, - }; - const parts = { - groundCaster: dem === null ? null : { groundAt: () => dem, prepare() {} }, - }; - return { - follow: createCameraFollow({ state, parts }), - state, - flights, - views, - }; -} - -test('framing a photo ignores a sample kilometres underground and uses bare earth', () => { - const { follow, flights } = setup({ sampled: -14_886, dem: 117 }); - follow.lookAtPosition(follow.beginFraming()); - assert.equal(flights.length, 1); - const centre = heightOf(flights[0].sphere.center); - assert.ok( - Math.abs(centre - 119) < 0.01, - `centre at ${centre} m, not underground`, - ); -}); - -test('a plausible mesh sample near bare earth wins (a street, or a modest roof)', () => { - const { follow, flights } = setup({ sampled: 121, dem: 117 }); - follow.lookAtPosition(follow.beginFraming()); - assert.ok(Math.abs(heightOf(flights[0].sphere.center) - 123) < 0.01); -}); - -test('without bare earth an absurd sample falls back to the image altitude, never below the surface range', () => { - const { follow, flights } = setup({ - sampled: -14_886, - dem: null, - globe: undefined, - altitude: 149, - }); - follow.lookAtPosition(follow.beginFraming()); - assert.ok(Math.abs(heightOf(flights[0].sphere.center) - 151) < 0.01); -}); - -test('following stands the camera at eye height above the checked ground', () => { - const { follow, state, views } = setup({ sampled: -14_886, dem: 117 }); - state.street.follow = true; - follow.followCamera(); - assert.equal(views.length, 1); - assert.ok( - Math.abs(heightOf(views[0].destination) - (117 + FOLLOW_EYE_HEIGHT_M)) < - 0.01, - ); -}); - -test('cancelFraming stops the framing flight while it is still ours', () => { - const { follow, state } = setup({ sampled: 121, dem: 117 }); - const { camera } = state.viewer; - follow.lookAtPosition(follow.beginFraming()); - follow.cancelFraming(); - assert.equal(camera.cancelled, 1, 'the flight toward the closed photo stops'); - follow.cancelFraming(); - assert.equal(camera.cancelled, 1, 'and only once'); - - // Re-framing (the next photo) supersedes the first flight, not the claim. - follow.lookAtPosition(follow.beginFraming()); - follow.lookAtPosition(follow.beginFraming()); - follow.cancelFraming(); - assert.equal(camera.cancelled, 2); -}); - -test('cancelFraming leaves a landed flight and a newer navigation flight alone', () => { - const { follow, state, flights } = setup({ sampled: 121, dem: 117 }); - const { camera } = state.viewer; - follow.lookAtPosition(follow.beginFraming()); - camera.land(); - follow.cancelFraming(); - assert.equal(camera.cancelled, 0, 'landed: nothing to stop'); - - follow.lookAtPosition(follow.beginFraming()); - // A search result flies the globe elsewhere; Cesium cancels ours first. - camera.flyToBoundingSphere(null, {}); - assert.equal(flights.length, 3); - follow.cancelFraming(); - assert.equal(camera.cancelled, 0, 'the newer flight keeps going'); -}); - -test('turning FOLLOW on stops the framing flight so it cannot fight the follow view', () => { - const { follow, state, views } = setup({ sampled: 121, dem: 117 }); - const { camera } = state.viewer; - follow.lookAtPosition(follow.beginFraming()); - follow.setFollow(true); - 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(); - let generation = 0; - const stamp = () => { - generation++; - for (const listener of listeners) listener({ generation }); - return generation; - }; - const nav = { - runs: [], - begins: [], - run(noun, move) { - nav.runs.push(noun); - if (refuse) return false; - const current = stamp(); - state.viewer.trackedEntity = undefined; - return move(current); - }, - /** Deferred: stamp now, release only on a successful reassert. */ - begin(noun) { - nav.begins.push(noun); - return refuse ? false : stamp(); - }, - reassert(ticket) { - if (ticket !== generation) return false; - state.viewer.trackedEntity = undefined; - return true; - }, - subscribeHandoff(listener) { - listeners.add(listener); - return () => listeners.delete(listener); - }, - /** Another feature takes the camera. */ - handOff: () => stamp(), - listeners, - }; - return nav; -} - -test('a photo claims the camera when it starts opening and frames once loaded, releasing tracking', () => { - const { follow, state, flights } = setup({ sampled: 160 }); - state.viewer.trackedEntity = { id: 'aircraft' }; - const nav = navigationFor(state); - follow.attachNavigation(nav); - const ticket = follow.beginFraming(); - assert.deepEqual(nav.begins, ['photo']); - assert.ok(state.viewer.trackedEntity, 'nothing is released while it loads'); - follow.lookAtPosition(ticket); - assert.equal(state.viewer.trackedEntity, undefined); - assert.equal(flights.length, 1); -}); - -test('a photo that finishes loading after newer navigation does not frame', () => { - const { follow, state, flights } = setup({ sampled: 160 }); - const nav = navigationFor(state); - follow.attachNavigation(nav); - const ticket = follow.beginFraming(); - // The user flies to Sacramento, and then tracks an aircraft, while it loads. - nav.handOff(); - state.viewer.trackedEntity = { id: 'aircraft' }; - follow.lookAtPosition(ticket); - assert.equal(flights.length, 0, 'no flight back to the photo'); - assert.deepEqual(state.viewer.trackedEntity, { id: 'aircraft' }); -}); - -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.beginFraming()); - 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/cameraFraming.js b/src/layers/streetLevel/cameraFraming.js new file mode 100644 index 000000000..fa0545719 --- /dev/null +++ b/src/layers/streetLevel/cameraFraming.js @@ -0,0 +1,96 @@ +import * as Cesium from 'cesium'; + +/** Plausible ellipsoidal land heights: Dead Sea to Everest, with geoid slack. */ +const SURFACE_MIN_M = -500; +const SURFACE_MAX_M = 9000; + +/** Fly the globe camera once to frame a photo after it opens. */ +export function createCameraFraming({ state }) { + /** + * The application's camera authority (`attachNavigation`): `begin(noun)` + * takes a deferred ticket (a generation, or false when refused) that + * `reassert(generation)` honours, releasing tracking, only if nothing newer + * took the camera. Without it, framing flies directly. + */ + let navigation = null; + /** Our framing flight while it is current; Cesium calls `cancel` when another flight starts. */ + let flight = null; + + function attachNavigation(next) { + navigation = next || null; + } + + /** Ask for the camera as a photo starts opening: a ticket for `frame`, null when refused (cockpit). */ + function begin() { + if (!navigation?.begin || !navigation.reassert) return { generation: null }; + const generation = navigation.begin('photo'); + return generation === false ? null : { generation }; + } + + /** + * Ground under a photo. `sampleHeight` can return kilometres below the + * ellipsoid before tiles load, so only a plausible height counts. + */ + function groundHeightAt(lon, lat, fallback) { + const scene = state.viewer?.scene; + const carto = Cesium.Cartographic.fromDegrees(lon, lat); + const plausible = (height) => + Number.isFinite(height) && + height >= SURFACE_MIN_M && + height <= SURFACE_MAX_M; + let height = null; + try { + if (scene?.sampleHeightSupported) height = scene.sampleHeight(carto); + } catch { + /* not sampleable yet */ + } + if (!plausible(height)) height = scene?.globe?.getHeight?.(carto); + if (!plausible(height)) height = fallback; + return plausible(height) ? height : 0; + } + + /** + * Frame the open photo from a short distance behind it, unless another + * action took the camera since `ticket` (from `begin`) was issued. + */ + function frame(ticket) { + const { position, bearing, altitude } = state.street; + if (!ticket || !position || !state.viewer) return; + if (ticket.generation !== null && !navigation.reassert(ticket.generation)) + 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 current = {}; + flight = current; + const release = () => { + if (flight === current) flight = 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, + ), + 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. */ + function cancel() { + if (!flight) return; + // Give up ownership first: cancelFlight delivers `cancel` synchronously. + flight = null; + state.viewer?.camera?.cancelFlight?.(); + } + + return { attachNavigation, begin, frame, cancel }; +} diff --git a/src/layers/streetLevel/cameraFraming.test.mjs b/src/layers/streetLevel/cameraFraming.test.mjs new file mode 100644 index 000000000..22e752627 --- /dev/null +++ b/src/layers/streetLevel/cameraFraming.test.mjs @@ -0,0 +1,179 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as Cesium from 'cesium'; +import { createCameraFraming } from './cameraFraming.js'; + +/** Height (m) of a Cartesian above the WGS84 ellipsoid. */ +const heightOf = (cartesian) => + Cesium.Cartographic.fromCartesian(cartesian).height; + +/** + * A camera with Cesium's flight bookkeeping: a new flight or `cancelFlight` + * cancels the current one, `land()` completes it, `cancelled` counts stops. + */ +function flightCamera(flights) { + let current = null; + function stop() { + const flight = current; + current = null; + flight?.options.cancel?.(); + return Boolean(flight); + } + const camera = { + cancelled: 0, + flyToBoundingSphere(sphere, options = {}) { + stop(); + current = { sphere, options }; + flights.push(current); + }, + cancelFlight() { + if (stop()) camera.cancelled++; + }, + land() { + const flight = current; + current = null; + flight?.options.complete?.(); + }, + }; + return camera; +} + +/** + * A viewer whose scene sample is `sampled` (e.g. −14,886 m before the tiles + * under the photo load) and whose globe terrain is `globe`. + */ +function setup({ sampled, globe = null, altitude = 149 }) { + const flights = []; + const state = { + services: {}, + street: { + position: { lon: -97.7364, lat: 30.2672 }, + bearing: 105, + altitude, + }, + viewer: { + scene: { + sampleHeightSupported: true, + sampleHeight: () => sampled, + globe: { getHeight: () => globe }, + }, + camera: flightCamera(flights), + }, + }; + return { + framing: createCameraFraming({ state }), + state, + flights, + }; +} + +test('framing a photo stands the sphere on a plausible scene sample', () => { + const { framing, flights } = setup({ sampled: 121 }); + framing.frame(framing.begin()); + assert.equal(flights.length, 1); + assert.ok(Math.abs(heightOf(flights[0].sphere.center) - 123) < 0.01); +}); + +test('a sample kilometres underground falls back to the globe, then the image altitude', () => { + const globe = setup({ sampled: -14_886, globe: 117 }); + globe.framing.frame(globe.framing.begin()); + assert.ok(Math.abs(heightOf(globe.flights[0].sphere.center) - 119) < 0.01); + const altitude = setup({ + sampled: -14_886, + globe: undefined, + altitude: 149, + }); + altitude.framing.frame(altitude.framing.begin()); + assert.ok(Math.abs(heightOf(altitude.flights[0].sphere.center) - 151) < 0.01); +}); + +test('cancel stops the framing flight while it is still ours', () => { + const { framing, state } = setup({ sampled: 121 }); + const { camera } = state.viewer; + framing.frame(framing.begin()); + framing.cancel(); + assert.equal(camera.cancelled, 1, 'the flight toward the closed photo stops'); + framing.cancel(); + assert.equal(camera.cancelled, 1, 'and only once'); + + // Re-framing (the next photo) supersedes the first flight, not the claim. + framing.frame(framing.begin()); + framing.frame(framing.begin()); + framing.cancel(); + assert.equal(camera.cancelled, 2); +}); + +test('cancel leaves a landed flight and a newer navigation flight alone', () => { + const { framing, state, flights } = setup({ sampled: 121 }); + const { camera } = state.viewer; + framing.frame(framing.begin()); + camera.land(); + framing.cancel(); + assert.equal(camera.cancelled, 0, 'landed: nothing to stop'); + + framing.frame(framing.begin()); + // A search result flies the globe elsewhere; Cesium cancels ours first. + camera.flyToBoundingSphere(null, {}); + assert.equal(flights.length, 3); + framing.cancel(); + assert.equal(camera.cancelled, 0, 'the newer flight keeps going'); +}); + +/** + * The application's camera authority: `begin` stamps a generation (or refuses + * like the cockpit), `reassert` releases tracking (here, a tracked entity) + * only while that generation is current. + */ +function navigationFor(state, { refuse = false } = {}) { + let generation = 0; + const stamp = () => ++generation; + const nav = { + begins: [], + /** Deferred: stamp now, release only on a successful reassert. */ + begin(noun) { + nav.begins.push(noun); + return refuse ? false : stamp(); + }, + reassert(ticket) { + if (ticket !== generation) return false; + state.viewer.trackedEntity = undefined; + return true; + }, + /** Another feature takes the camera. */ + handOff: () => stamp(), + }; + return nav; +} + +test('a photo claims the camera when it starts opening and frames once loaded, releasing tracking', () => { + const { framing, state, flights } = setup({ sampled: 160 }); + state.viewer.trackedEntity = { id: 'aircraft' }; + const nav = navigationFor(state); + framing.attachNavigation(nav); + const ticket = framing.begin(); + assert.deepEqual(nav.begins, ['photo']); + assert.ok(state.viewer.trackedEntity, 'nothing is released while it loads'); + framing.frame(ticket); + assert.equal(state.viewer.trackedEntity, undefined); + assert.equal(flights.length, 1); +}); + +test('a photo that finishes loading after newer navigation does not frame', () => { + const { framing, state, flights } = setup({ sampled: 160 }); + const nav = navigationFor(state); + framing.attachNavigation(nav); + const ticket = framing.begin(); + // The user flies to Sacramento, and then tracks an aircraft, while it loads. + nav.handOff(); + state.viewer.trackedEntity = { id: 'aircraft' }; + framing.frame(ticket); + assert.equal(flights.length, 0, 'no flight back to the photo'); + assert.deepEqual(state.viewer.trackedEntity, { id: 'aircraft' }); +}); + +test('a refused claim (cockpit) does not frame the photo', () => { + const { framing, state, flights } = setup({ sampled: 160 }); + framing.attachNavigation(navigationFor(state, { refuse: true })); + framing.frame(framing.begin()); + assert.equal(flights.length, 0); +}); diff --git a/src/layers/streetLevel/groundCast.js b/src/layers/streetLevel/groundCast.js deleted file mode 100644 index c5b1885e3..000000000 --- a/src/layers/streetLevel/groundCast.js +++ /dev/null @@ -1,256 +0,0 @@ -/** - * Ground casting for street-level overlays on Google 3D. Draped overlays land - * on the mesh top (trees, overpasses), so casting places them at bare-earth - * (DEM) height plus a lift and lets the mesh hide what is behind buildings. - * Heights come from a coarse grid shared with the ground-floor service; a - * geoid fallback (terrain proxy down) counts as unknown and stays draped. - */ - -import { wrapLon } from './tileMath.js'; - -/** Grid spacing in degrees (~111 m north-south): the ground-floor grid. */ -export const GROUND_CAST_STEP_DEG = 0.001; -/** Longest line segment left undivided, in degrees, so lines follow the grid. */ -export const GROUND_CAST_DENSIFY_DEG = 0.0005; -/** Metres above the bare earth, so lines sit on the road instead of in it. */ -export const GROUND_CAST_LIFT_M = 2; -/** Grid corners one prepare call may request; more stays draped. */ -export const GROUND_CAST_MAX_CORNERS = 1024; -/** Cached grid corners before the oldest are dropped to make room. */ -const GROUND_CAST_CACHE_MAX = 50_000; - -/** - * Mesh refinement, where the Google 3D surface was sampled (the DEM grid - * misses trenches, steep streets and roads under trees): - * - mesh up to MESH_ROAD_ABOVE_M above bare earth is the road: follow it; - * - higher mesh is over the road (canopy, deck): interpolate the road's - * offset from the nearest road samples on both sides; - * - otherwise bare earth plus GROUND_CAST_LIFT_M. - */ -export const MESH_ROAD_ABOVE_M = 3; -/** A sample this far below bare earth is a bad probe, not a road. */ -export const MESH_ROAD_BELOW_M = 30; -/** Metres above the sampled road surface. */ -export const MESH_LIFT_M = 1; -/** Longest run of covered points bridged from the road on either side. */ -export const MESH_BRIDGE_MAX_POINTS = 8; -/** Line spacing, in degrees (~22 m), while the mesh refines heights. */ -export const MESH_DENSIFY_DEG = 0.0002; - -/** - * Heights for a line's points by the rule above. - * @param {Array<{dem: number, mesh?: number|null}>} points - * @param {{lift?: number}} [options] - * @returns {Array} - */ -export function refineHeights(points, { lift = GROUND_CAST_LIFT_M } = {}) { - const road = points.map(({ dem, mesh }) => - Number.isFinite(mesh) && - mesh <= dem + MESH_ROAD_ABOVE_M && - mesh >= dem - MESH_ROAD_BELOW_M - ? mesh - dem - : null, - ); - return points.map(({ dem, mesh }, i) => { - if (road[i] !== null) return dem + road[i] + MESH_LIFT_M; - if (Number.isFinite(mesh) && mesh > dem + MESH_ROAD_ABOVE_M) { - let a = i - 1; - while (a >= 0 && i - a <= MESH_BRIDGE_MAX_POINTS && road[a] === null) a--; - let b = i + 1; - while ( - b < points.length && - b - i <= MESH_BRIDGE_MAX_POINTS && - road[b] === null - ) - b++; - const left = a >= 0 && i - a <= MESH_BRIDGE_MAX_POINTS ? road[a] : null; - const right = - b < points.length && b - i <= MESH_BRIDGE_MAX_POINTS ? road[b] : null; - if (left !== null && right !== null) { - const t = (i - a) / (b - a); - return dem + left + (right - left) * t + MESH_LIFT_M; - } - } - return dem + lift; - }); -} - -/** Camera height (m above ground) below which Google 3D switches to terrain mode. */ -export const SURFACE_TERRAIN_ENTER_M = 1400; -/** Camera height above which terrain mode switches back to draped. */ -export const SURFACE_TERRAIN_EXIT_M = 1800; - -/** - * Next surface mode: terrain only on Google 3D at street zoom, with - * hysteresis between the enter and exit heights. - * @param {'draped'|'terrain'} current - * @param {{photoreal: boolean, heightM: number|null, available: boolean}} view - * @returns {'draped'|'terrain'} - */ -export function nextSurfaceMode(current, { photoreal, heightM, available }) { - if (!available || !photoreal || !Number.isFinite(heightM)) return 'draped'; - if (current === 'terrain') - return heightM > SURFACE_TERRAIN_EXIT_M ? 'draped' : 'terrain'; - return heightM < SURFACE_TERRAIN_ENTER_M ? 'terrain' : 'draped'; -} - -/** - * Split long segments so a line follows the terrain. Segments across ±180° - * go the short way round, with added points wrapped into [-180, 180]. - * @param {Array<[number, number]>} coords [lon, lat] pairs - * @param {number} [maxStep] longest segment, in degrees - * @returns {Array<[number, number]>} - */ -export function densifyLine(coords, maxStep = GROUND_CAST_DENSIFY_DEG) { - if (!Array.isArray(coords) || coords.length < 2) return coords || []; - const out = [coords[0]]; - for (let i = 1; i < coords.length; i++) { - const [lon0, lat0] = coords[i - 1]; - const [lon1, lat1] = coords[i]; - const dLon = wrapLon(lon1 - lon0); - const span = Math.max(Math.abs(dLon), Math.abs(lat1 - lat0)); - const parts = Math.min(64, Math.ceil(span / maxStep - 1e-9)); - for (let k = 1; k < parts; k++) { - const t = k / parts; - out.push([wrapLon(lon0 + dLon * t), lat0 + (lat1 - lat0) * t]); - } - out.push(coords[i]); - } - return out; -} - -/** - * A bare-earth height cache over the terrain service. - * @param {{terrain: {resolveEllipsoidalGround: Function}, step?: number, lift?: number, maxCorners?: number, cacheMax?: number}} options - */ -export function createGroundCaster({ - terrain, - step = GROUND_CAST_STEP_DEG, - lift = GROUND_CAST_LIFT_M, - maxCorners = GROUND_CAST_MAX_CORNERS, - cacheMax = GROUND_CAST_CACHE_MAX, -}) { - if (typeof terrain?.resolveEllipsoidalGround !== 'function') - throw new TypeError('Ground casting requires a terrain service'); - /** "i,j" grid corner → ellipsoidal ground height in metres. */ - const heights = new Map(); - /** Prepares run one at a time, so neighbouring tiles share their corners. */ - let queue = Promise.resolve(); - - const cornerKey = (i, j) => `${i},${j}`; - - function missingCorners(points) { - const missing = new Map(); - for (const point of points) { - const [lon, lat] = point; - if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue; - const i0 = Math.floor(lon / step); - const j0 = Math.floor(lat / step); - for (let di = 0; di <= 1; di++) - for (let dj = 0; dj <= 1; dj++) { - const key = cornerKey(i0 + di, j0 + dj); - if (!heights.has(key) && !missing.has(key)) - missing.set(key, { i: i0 + di, j: j0 + dj }); - } - } - return [...missing.entries()]; - } - - async function resolve(points, signal) { - if (signal?.aborted) return false; - // Evict before counting, or we could drop corners this request counted - // as cached. Oldest first, so the tiles still on screen keep theirs. - for (const key of heights.keys()) { - if (heights.size + maxCorners <= cacheMax) break; - heights.delete(key); - } - const missing = missingCorners(points); - if (!missing.length) return true; - if (missing.length > maxCorners) return false; - // A corner east of 180° is asked for at its wrapped longitude. - const results = await terrain.resolveEllipsoidalGround( - missing.map(([, { i, j }]) => ({ - lon: Number(wrapLon(i * step).toFixed(6)), - lat: Number((j * step).toFixed(6)), - })), - { signal }, - ); - let complete = true; - missing.forEach(([key], n) => { - const result = results?.[n]; - if (result?.source === 'reearth' && Number.isFinite(result.ellipsoid)) - heights.set(key, result.ellipsoid); - else complete = false; - }); - return complete && !signal?.aborted; - } - - /** - * Fetch the grid corners around the points. True when all can be cast; - * false when some are unknown, too many, or aborted. Never rejects. - * @param {Array<[number, number]>} points [lon, lat] pairs - * @param {{signal?: AbortSignal}} [options] - */ - function prepare(points, { signal } = {}) { - const run = queue.then(() => resolve(points, signal)).catch(() => false); - queue = run; - return run; - } - - /** - * `prepare` for lines at both densities: the finer mesh points can fall in - * cells the coarse ones skip, and a missing cell leaves the line draped. - */ - function prepareLines(lines, options) { - return prepare( - lines.flatMap((coords) => [ - ...densifyLine(coords), - ...densifyLine(coords, MESH_DENSIFY_DEG), - ]), - options, - ); - } - - /** Bare-earth ellipsoidal height at a point, from cached corners only; null when unknown. */ - function groundAt(lon, lat) { - const x = lon / step; - const y = lat / step; - const i0 = Math.floor(x); - const j0 = Math.floor(y); - const h00 = heights.get(cornerKey(i0, j0)); - const h10 = heights.get(cornerKey(i0 + 1, j0)); - const h01 = heights.get(cornerKey(i0, j0 + 1)); - const h11 = heights.get(cornerKey(i0 + 1, j0 + 1)); - if (h00 === undefined || h10 === undefined) return null; - if (h01 === undefined || h11 === undefined) return null; - const fx = x - i0; - const fy = y - j0; - const south = h00 + (h10 - h00) * fx; - const north = h01 + (h11 - h01) * fx; - return south + (north - south) * fy; - } - - /** - * Densified line as flat [lon, lat, height, ...]; null until every corner is - * cached. With `meshAt` it is densified finer and refined by `refineHeights`. - * @param {Array<[number, number]>} coords - * @param {{meshAt?: (lon: number, lat: number) => number|null|undefined}} [options] - */ - function castLine(coords, { meshAt = null } = {}) { - const rows = []; - const points = meshAt - ? densifyLine(coords, MESH_DENSIFY_DEG) - : densifyLine(coords); - for (const [lon, lat] of points) { - const dem = groundAt(lon, lat); - if (dem === null) return null; - rows.push({ lon, lat, dem, mesh: meshAt ? meshAt(lon, lat) : null }); - } - const heights = refineHeights(rows, { lift }); - const flat = []; - rows.forEach(({ lon, lat }, i) => flat.push(lon, lat, heights[i])); - return flat; - } - - return { prepare, prepareLines, groundAt, castLine }; -} diff --git a/src/layers/streetLevel/groundCast.test.mjs b/src/layers/streetLevel/groundCast.test.mjs deleted file mode 100644 index 44fee92e3..000000000 --- a/src/layers/streetLevel/groundCast.test.mjs +++ /dev/null @@ -1,370 +0,0 @@ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { - GROUND_CAST_LIFT_M, - MESH_LIFT_M, - refineHeights, - SURFACE_TERRAIN_ENTER_M, - SURFACE_TERRAIN_EXIT_M, - createGroundCaster, - densifyLine, - nextSurfaceMode, -} from './groundCast.js'; - -/** A terrain service whose ground rises 1 m per 0.001° east and 2 m per 0.001° north. */ -function fakeTerrain({ source = 'reearth', fail = false } = {}) { - const calls = []; - return { - calls, - async resolveEllipsoidalGround(coords) { - calls.push(coords); - if (fail) throw new Error('proxy down'); - return coords.map(({ lon, lat }) => ({ - ellipsoid: 100 + (lon - 10) * 1000 + (lat - 50) * 2000, - source, - })); - }, - }; -} - -test('surface mode: terrain only on Google 3D at street zoom, with hysteresis', () => { - const view = { photoreal: true, available: true }; - assert.equal(nextSurfaceMode('draped', { ...view, heightM: 500 }), 'terrain'); - assert.equal( - nextSurfaceMode('draped', { - ...view, - heightM: (SURFACE_TERRAIN_ENTER_M + SURFACE_TERRAIN_EXIT_M) / 2, - }), - 'draped', - ); - assert.equal( - nextSurfaceMode('terrain', { - ...view, - heightM: (SURFACE_TERRAIN_ENTER_M + SURFACE_TERRAIN_EXIT_M) / 2, - }), - 'terrain', - ); - assert.equal( - nextSurfaceMode('terrain', { - ...view, - heightM: SURFACE_TERRAIN_EXIT_M + 1, - }), - 'draped', - ); - assert.equal( - nextSurfaceMode('terrain', { ...view, photoreal: false, heightM: 200 }), - 'draped', - ); - assert.equal( - nextSurfaceMode('terrain', { ...view, available: false, heightM: 200 }), - 'draped', - ); - assert.equal( - nextSurfaceMode('terrain', { ...view, heightM: null }), - 'draped', - ); -}); - -test('densifyLine splits long segments and keeps the vertices', () => { - const line = [ - [10, 50], - [10.002, 50], - ]; - const dense = densifyLine(line, 0.0005); - assert.equal(dense.length, 5); - assert.deepEqual(dense[0], [10, 50]); - assert.deepEqual(dense.at(-1), [10.002, 50]); - assert.ok(Math.abs(dense[2][0] - 10.001) < 1e-9); - const short = [ - [10, 50], - [10.0001, 50], - ]; - assert.deepEqual(densifyLine(short, 0.0005), short); - assert.deepEqual(densifyLine([[10, 50]]), [[10, 50]]); -}); - -test('densifyLine crosses the date line the short way and wraps what it adds', () => { - // 0.0002° apart: short enough to stay one segment, not 65 points via 0°. - const short = [ - [179.9999, 0], - [-179.9999, 0], - ]; - assert.deepEqual(densifyLine(short), short); - const line = [ - [179.999, 50.0005], - [-179.999, 50.0005], - ]; - const dense = densifyLine(line, 0.0005); - assert.equal(dense.length, 5); - assert.deepEqual(dense[0], line[0]); - assert.deepEqual(dense.at(-1), line[1]); - for (const [lon] of dense) - assert.ok(lon >= -180 && lon <= 180 && Math.abs(lon) >= 179.999, `${lon}`); - assert.ok(Math.abs(dense[1][0] - 179.9995) < 1e-9); - assert.ok(Math.abs(Math.abs(dense[2][0]) - 180) < 1e-9); - assert.ok(Math.abs(dense[3][0] - -179.9995) < 1e-9); - // Westward across the date line too. - const back = densifyLine([...line].reverse(), 0.0005); - assert.equal(back.length, 5); - assert.ok(Math.abs(back[1][0] - -179.9995) < 1e-9); -}); - -test('the caster interpolates cached grid corners and adds the lift', async () => { - const terrain = fakeTerrain(); - const caster = createGroundCaster({ terrain, step: 0.001 }); - assert.equal(caster.groundAt(10.0005, 50.0005), null); - assert.equal(await caster.prepare([[10.0005, 50.0005]]), true); - assert.equal(terrain.calls.length, 1); - assert.equal(terrain.calls[0].length, 4); - // Centre of the cell: mean of 100, 101, 102, 103. - assert.ok(Math.abs(caster.groundAt(10.0005, 50.0005) - 101.5) < 1e-6); - // A cast line sits the lift above it. - const point = [10.0005, 50.0005]; - const [, , height] = caster.castLine([point, point]); - assert.ok(Math.abs(height - (101.5 + GROUND_CAST_LIFT_M)) < 1e-6); - // Same cell again: nothing to fetch. - assert.equal(await caster.prepare([[10.0002, 50.0008]]), true); - assert.equal(terrain.calls.length, 1); -}); - -test('castLine returns lon, lat, height triples or null when a corner is missing', async () => { - const caster = createGroundCaster({ terrain: fakeTerrain(), step: 0.001 }); - const line = [ - [10.0001, 50.0001], - [10.0009, 50.0001], - ]; - assert.equal(caster.castLine(line), null); - await caster.prepareLines([line]); - const flat = caster.castLine(line); - assert.equal(flat.length % 3, 0); - assert.ok(flat.length >= 6); - assert.equal(flat[0], 10.0001); - assert.equal(flat[1], 50.0001); - assert.ok(flat[2] > 100 && flat[2] < 110); -}); - -test('a geoid fallback or a failing proxy leaves the heights unknown', async () => { - const fallback = createGroundCaster({ - terrain: fakeTerrain({ source: 'geoid-fallback' }), - }); - assert.equal(await fallback.prepare([[10.0005, 50.0005]]), false); - assert.equal(fallback.groundAt(10.0005, 50.0005), null); - - const down = createGroundCaster({ terrain: fakeTerrain({ fail: true }) }); - assert.equal(await down.prepare([[10.0005, 50.0005]]), false); - assert.equal(down.groundAt(10.0005, 50.0005), null); -}); - -test('too many corners are not requested, and aborted prepares do nothing', async () => { - const terrain = fakeTerrain(); - const caster = createGroundCaster({ terrain, step: 0.001, maxCorners: 8 }); - const wide = []; - for (let i = 0; i < 10; i++) wide.push([10 + i * 0.001 + 0.0005, 50.0005]); - assert.equal(await caster.prepare(wide), false); - assert.equal(terrain.calls.length, 0); - - const controller = new AbortController(); - controller.abort(); - assert.equal( - await caster.prepare([[10.0005, 50.0005]], { signal: controller.signal }), - false, - ); - assert.equal(terrain.calls.length, 0); -}); - -test('prepares run one at a time so neighbours share corners', async () => { - const terrain = fakeTerrain(); - const caster = createGroundCaster({ terrain, step: 0.001 }); - const [a, b] = await Promise.all([ - caster.prepare([[10.0005, 50.0005]]), - caster.prepare([[10.0015, 50.0005]]), - ]); - assert.equal(a, true); - assert.equal(b, true); - // The second cell shares its west corners with the first. - assert.equal(terrain.calls[1].length, 2); -}); - -test('the caster needs a terrain service', () => { - assert.throws(() => createGroundCaster({ terrain: null }), TypeError); -}); - -test('refineHeights follows the mesh where it is the road', () => { - // A freeway trench 6 m below the bare-earth grid, and a steep street 2 m above it. - const heights = refineHeights([ - { dem: 100, mesh: 94 }, - { dem: 100, mesh: 102 }, - { dem: 100, mesh: null }, - ]); - assert.deepEqual(heights, [ - 94 + MESH_LIFT_M, - 102 + MESH_LIFT_M, - 100 + GROUND_CAST_LIFT_M, - ]); -}); - -test('refineHeights carries the road under a canopy from both sides', () => { - // Road 1 m and 3 m above bare earth either side of a tree 12 m tall. - const heights = refineHeights([ - { dem: 50, mesh: 51 }, - { dem: 50, mesh: 62 }, - { dem: 50, mesh: 62 }, - { dem: 50, mesh: 53 }, - ]); - assert.equal(heights[0], 51 + MESH_LIFT_M); - assert.ok(Math.abs(heights[1] - (50 + 1 + 2 / 3 + MESH_LIFT_M)) < 1e-9); - assert.ok(Math.abs(heights[2] - (50 + 1 + 4 / 3 + MESH_LIFT_M)) < 1e-9); - assert.equal(heights[3], 53 + MESH_LIFT_M); -}); - -test('refineHeights keeps bare earth under a deck with no road beside it, and rejects bad probes', () => { - assert.deepEqual( - refineHeights([ - { dem: 20, mesh: 35 }, - { dem: 20, mesh: 35 }, - ]), - [20 + GROUND_CAST_LIFT_M, 20 + GROUND_CAST_LIFT_M], - ); - // A probe kilometres under the ground is not a road. - assert.deepEqual(refineHeights([{ dem: 20, mesh: -14000 }]), [ - 20 + GROUND_CAST_LIFT_M, - ]); -}); - -test('castLine refines heights with sampled mesh and densifies finer', async () => { - const caster = createGroundCaster({ terrain: fakeTerrain(), step: 0.001 }); - const line = [ - [10.0001, 50.0001], - [10.0009, 50.0001], - ]; - await caster.prepareLines([line]); - const plain = caster.castLine(line); - const meshed = caster.castLine(line, { meshAt: () => 98 }); - assert.ok(meshed.length > plain.length, 'finer spacing with the mesh'); - for (let i = 2; i < meshed.length; i += 3) - assert.equal(meshed[i], 98 + MESH_LIFT_M); - // Unsampled points keep the bare-earth height. - const unsampled = caster.castLine(line, { meshAt: () => undefined }); - assert.ok(unsampled[2] > 100 && unsampled[2] < 110); -}); - -test('a full cache is cleared before a request counts its corners, so the request still casts', async () => { - const caster = createGroundCaster({ - terrain: fakeTerrain(), - step: 0.001, - maxCorners: 8, - cacheMax: 5, - }); - // One cell's four corners are cached... - assert.equal(await caster.prepare([[10.0005, 50.0005]]), true); - // ...then a line over that cell and the next needs two more, and the first - // cell must survive the eviction. - const line = [ - [10.0002, 50.0005], - [10.0018, 50.0005], - ]; - assert.equal(await caster.prepareLines([line]), true); - assert.ok(caster.castLine(line), 'the whole line casts'); -}); - -test('a full cache drops its oldest corners, not every corner', async () => { - const caster = createGroundCaster({ - terrain: fakeTerrain(), - step: 0.001, - maxCorners: 4, - cacheMax: 12, - }); - // Three cells apart from each other, four corners each: the cache is full. - const cells = [10.0005, 10.0105, 10.0205, 10.0305].map((lon) => [ - lon, - 50.0005, - ]); - for (const cell of cells.slice(0, 3)) - assert.equal(await caster.prepare([cell]), true); - // A fourth makes room by dropping the first cell only. - assert.equal(await caster.prepare([cells[3]]), true); - assert.equal(caster.groundAt(...cells[0]), null, 'oldest dropped'); - for (const cell of cells.slice(1)) - assert.notEqual(caster.groundAt(...cell), null, 'newer cells kept'); -}); - -test('cancelling a prepare cancels its terrain request', async () => { - const seen = []; - const caster = createGroundCaster({ - terrain: { - async resolveEllipsoidalGround(coords, options) { - seen.push(options?.signal); - return coords.map(() => ({ ellipsoid: 100, source: 'reearth' })); - }, - }, - }); - const controller = new AbortController(); - await caster.prepare([[10.0005, 50.0005]], { signal: controller.signal }); - assert.equal(seen.length, 1); - assert.equal(seen[0], controller.signal, 'the tile signal reaches the proxy'); -}); - -test('prepared lines always cast with the mesh, however their segments cross the grid', async () => { - // Seeded walks with 50–300 m segments: the finer mesh points can land in - // grid cells the coarse points skip. - let seed = 7; - const random = () => { - seed = (seed * 16807) % 2147483647; - return seed / 2147483647; - }; - let failures = 0; - for (let n = 0; n < 300; n++) { - const caster = createGroundCaster({ terrain: fakeTerrain() }); - const line = [[10 + random() * 0.01, 50 + random() * 0.01]]; - for (let k = 0; k < 10; k++) { - const [lon, lat] = line[line.length - 1]; - const length = (50 + random() * 250) / 111_000; - const angle = random() * 2 * Math.PI; - line.push([ - lon + length * Math.cos(angle), - lat + length * Math.sin(angle), - ]); - } - assert.equal(await caster.prepareLines([line]), true); - if (caster.castLine(line, { meshAt: () => undefined }) === null) failures++; - } - assert.equal(failures, 0); -}); - -test('a line across the date line casts along the date line', async () => { - // Ground that rises northward only, so both sides of ±180° agree. - const calls = []; - const terrain = { - async resolveEllipsoidalGround(coords) { - calls.push(...coords); - return coords.map(({ lat }) => ({ - ellipsoid: 100 + (lat - 50) * 2000, - source: 'reearth', - })); - }, - }; - const caster = createGroundCaster({ terrain, step: 0.001 }); - const line = [ - [179.9995, 50.0005], - [180, 50.0005], - [-179.9995, 50.0005], - ]; - assert.equal(await caster.prepareLines([line]), true); - for (const meshAt of [null, () => undefined]) { - const flat = caster.castLine(line, { meshAt }); - assert.ok(flat, 'every point casts'); - assert.ok( - flat.length / 3 <= 8, - `${flat.length / 3} points, not a line round the world`, - ); - for (let i = 0; i < flat.length; i += 3) { - assert.ok(Math.abs(flat[i]) >= 179.999 && Math.abs(flat[i]) <= 180); - assert.ok(Math.abs(flat[i + 2] - (101 + GROUND_CAST_LIFT_M)) < 1e-6); - } - } - // Only ground near the date line is asked for, and every longitude is a - // real one (the far corner of a point on 180° is asked for at -179.999°). - assert.ok(calls.length <= 16, `${calls.length} corners`); - for (const { lon } of calls) - assert.ok(Math.abs(lon) >= 179.999 && Math.abs(lon) <= 180, `${lon}`); -}); diff --git a/src/layers/streetLevel/index.js b/src/layers/streetLevel/index.js index dd4478c58..294e016f2 100644 --- a/src/layers/streetLevel/index.js +++ b/src/layers/streetLevel/index.js @@ -1,6 +1,6 @@ import { createState } from './state.js'; import { createMarker } from './marker.js'; -import { createCameraFollow } from './cameraFollow.js'; +import { createCameraFraming } from './cameraFraming.js'; import { createCredits } from './credits.js'; import { createPickRouter } from './pickRouter.js'; import { createSelection } from './selection.js'; @@ -9,14 +9,8 @@ import { requiresKeyIdFor, validateProviders } from './registry.js'; import { normalizeFilter, resolveFilter, sameFilter } from './filter.js'; import { decodeParams, encodeParams } from './params.js'; import { composeUIState, summarizeCoverage } from './uiState.js'; -import { cameraHeightAboveGround, viewCentre, whenIdle } from './view.js'; -import { createGroundCaster, nextSurfaceMode } from './groundCast.js'; -import { createMeshSampler } from './meshSampler.js'; - -/** Above this camera height the ground under the camera is not worth fetching. */ -const SURFACE_WARM_BELOW_M = 6000; +import { viewCentre, whenIdle } from './view.js'; import { - FOLLOW_MAP_STACK_ID, NEAREST_RADIUS_M, POSITION_PICK_ID, STREET_LEVEL_LAYER_ID, @@ -37,22 +31,9 @@ export function createStreetLevelLayer({ const state = createState({ services }); const parts = {}; const context = { state, parts }; - 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, - groundAt: parts.groundCaster.groundAt, - withinPrior: services.ground?.meshFloorSampleWithinPrior ?? null, - tilesReady: services.meshFloor?.visibleTilesetLoaded ?? null, - }) - : null; parts.credits = createCredits(); parts.marker = createMarker(context); - parts.follow = createCameraFollow(context); + parts.framing = createCameraFraming(context); parts.router = createPickRouter(() => state.providers.values(), { positionId: POSITION_PICK_ID, }); @@ -90,9 +71,6 @@ export function createStreetLevelLayer({ services: state.services, getFilter: () => resolveFilter(state.filter), isActive: () => state.enabled && entry.on, - groundCaster: parts.groundCaster, - meshSampler: parts.meshSampler, - getSurface: () => state.surface, notify, actions: { openImage: (imageId) => openImage(entry.def.id, imageId), @@ -128,85 +106,6 @@ export function createStreetLevelLayer({ state.providers.set(def.id, entry); } - let mapStack = null; - let unsubscribeMapStack = null; - - /** Follow is offered only on the Google 3D stack; leaving it stops following. */ - function syncFollowAvailability() { - const available = mapStack?.getActiveId?.() === FOLLOW_MAP_STACK_ID; - if (available === state.street.followAvailable) return; - state.street.followAvailable = available; - if (!available && state.street.follow) parts.follow.setFollow(false); - notify(); - } - - /** - * Camera height above bare earth, for the surface mode. A cold grid cell is - * fetched once and the mode rechecked when it lands. - */ - function cameraHeightForSurface() { - const carto = state.viewer?.camera?.positionCartographic; - if (!carto) return null; - const caster = parts.groundCaster; - const height = cameraHeightAboveGround(state.viewer, { - groundAt: caster.groundAt, - }); - const lon = (carto.longitude * 180) / Math.PI; - const lat = (carto.latitude * 180) / Math.PI; - if (caster.groundAt(lon, lat) === null && height < SURFACE_WARM_BELOW_M) - caster.prepare([[lon, lat]]).then((ready) => { - if (ready) scheduleSurfaceSync(); - }); - return height; - } - - /** Cast overlays to bare earth on Google 3D at street zoom; drape them elsewhere. */ - function syncSurface() { - const available = Boolean(parts.groundCaster) && state.enabled; - const photoreal = mapStack?.getActiveId?.() === FOLLOW_MAP_STACK_ID; - const mode = nextSurfaceMode(state.surface, { - available, - photoreal, - heightM: available && photoreal ? cameraHeightForSurface() : null, - }); - if (mode === state.surface) return; - state.surface = mode; - parts.meshSampler?.setEnabled(mode === 'terrain'); - parts.marker.setSurface(mode); - for (const entry of state.providers.values()) - entry.instance.setSurface?.(mode); - notify(); - } - - let surfaceTimer = null; - function scheduleSurfaceSync() { - clearTimeout(surfaceTimer); - surfaceTimer = setTimeout(syncSurface, 150); - } - - let removeSurfaceListeners = null; - function watchSurface(viewer) { - removeSurfaceListeners?.(); - const camera = viewer?.camera; - if (!camera?.changed || !camera?.moveEnd) { - removeSurfaceListeners = null; - return; - } - const removeChanged = camera.changed.addEventListener(scheduleSurfaceSync); - const removeEnd = camera.moveEnd.addEventListener(scheduleSurfaceSync); - removeSurfaceListeners = () => { - removeChanged(); - removeEnd(); - }; - } - - function unwatchSurface() { - removeSurfaceListeners?.(); - removeSurfaceListeners = null; - clearTimeout(surfaceTimer); - surfaceTimer = null; - } - const activeEntries = () => [...state.providers.values()].filter((entry) => entry.on); @@ -319,7 +218,6 @@ export function createStreetLevelLayer({ providers: providerSnapshots(), street, sequence: sequenceSnapshot(), - surface: state.surface, }); } @@ -354,18 +252,14 @@ export function createStreetLevelLayer({ parts.marker.setVisible(true); parts.selection.install(viewer); for (const entry of activeEntries()) activate(entry); - watchSurface(viewer); - syncSurface(); notify(); }, disable() { state.enabled = false; abortNearest(); - unwatchSurface(); parts.viewerHost.unmount(); for (const entry of state.providers.values()) entry.instance.deactivate(); - syncSurface(); parts.credits.hideAll(state.viewer); parts.selection.uninstall(); parts.marker.setVisible(false); @@ -381,11 +275,7 @@ export function createStreetLevelLayer({ for (const entry of state.providers.values()) entry.instance.destroy(viewer); parts.marker.destroy(viewer); - parts.meshSampler?.destroy(); - parts.follow.destroy(); - unsubscribeMapStack?.(); - unsubscribeMapStack = null; - mapStack = null; + parts.framing.attachNavigation(null); state.listeners.clear(); state.viewer = null; state.initialized = false; @@ -414,27 +304,12 @@ export function createStreetLevelLayer({ }, /** - * The application's camera authority: `run(noun, move)`, the deferred - * `begin(noun)` / `reassert(generation)` pair and - * `subscribeHandoff(listener)`. FOLLOW claims the camera with `run`; a - * photo claims it when opening starts and frames only if it still owns - * it once loaded. FOLLOW stops when another feature takes the camera. + * The application's camera authority, the deferred `begin(noun)` / + * `reassert(generation)` pair: a photo claims the camera when opening + * starts and frames only if it still owns it once loaded. */ attachNavigation(navigation) { - parts.follow.attachNavigation(navigation); - }, - - /** The application map stack; FOLLOW is available only on Google 3D. */ - attachMapStackController(controller) { - unsubscribeMapStack?.(); - mapStack = controller || null; - unsubscribeMapStack = - mapStack?.subscribe?.(() => { - syncFollowAvailability(); - syncSurface(); - }) || null; - syncFollowAvailability(); - syncSurface(); + parts.framing.attachNavigation(navigation); }, /** Share-link and stored state: provider switches plus the filter. */ @@ -523,7 +398,6 @@ export function createStreetLevelLayer({ parts.clearSequences(); }, setViewerRenderMode: (mode) => parts.viewerHost.setRenderMode(mode), - setFollow: (enabled) => parts.follow.setFollow(enabled), resizeViewer: () => parts.viewerHost.resize(), selectSequence( sequenceId, diff --git a/src/layers/streetLevel/index.test.mjs b/src/layers/streetLevel/index.test.mjs index d4990c6ab..240a233b7 100644 --- a/src/layers/streetLevel/index.test.mjs +++ b/src/layers/streetLevel/index.test.mjs @@ -129,65 +129,6 @@ function slowLookups() { return { lookups, nearestImage }; } -/** A map stack controller that can be switched between stacks. */ -function fakeMapStack(initial) { - let active = initial; - const listeners = new Set(); - return { - getActiveId: () => active, - subscribe(listener) { - listeners.add(listener); - return () => listeners.delete(listener); - }, - switchTo(id) { - active = id; - for (const listener of listeners) listener(); - }, - listenerCount: () => listeners.size, - }; -} - -test('FOLLOW is available only while the Google 3D map stack is active', () => { - const layer = createStreetLevelLayer({ providers: [fakeProvider()] }); - assert.equal( - layer.getUIState().street.followAvailable, - false, - 'unknown stack: off', - ); - - const stack = fakeMapStack('esri-imagery'); - layer.attachMapStackController(stack); - assert.equal(layer.getUIState().street.followAvailable, false); - layer.setFollow(true); - assert.equal(layer.getUIState().street.follow, false, 'refused on Esri'); - - stack.switchTo('photoreal'); - assert.equal(layer.getUIState().street.followAvailable, true); - layer.setFollow(true); - assert.equal(layer.getUIState().street.follow, true); - - stack.switchTo('osm'); - assert.equal(layer.getUIState().street.followAvailable, false); - assert.equal( - layer.getUIState().street.follow, - false, - 'leaving Google 3D stops following', - ); -}); - -test('the layer lets go of the map stack when destroyed or re-attached', () => { - const layer = createStreetLevelLayer({ providers: [fakeProvider()] }); - const first = fakeMapStack('photoreal'); - layer.attachMapStackController(first); - assert.equal(first.listenerCount(), 1); - const second = fakeMapStack('photoreal'); - layer.attachMapStackController(second); - assert.equal(first.listenerCount(), 0); - assert.equal(second.listenerCount(), 1); - layer.destroy(); - assert.equal(second.listenerCount(), 0); -}); - test('the CC BY-SA credit shows while a provider is on and goes with it', async (t) => { const { layer, viewer } = await enabledLayer(t); const shown = () => viewer.credits.map((credit) => credit.html); diff --git a/src/layers/streetLevel/marker.js b/src/layers/streetLevel/marker.js index a8818e164..fb12883de 100644 --- a/src/layers/streetLevel/marker.js +++ b/src/layers/streetLevel/marker.js @@ -1,15 +1,13 @@ import * as Cesium from 'cesium'; import { positionMarkerGlyph } from './glyphs.js'; import { COLORS, POSITION_PICK_ID } from './policy.js'; -import { refineHeights } from './groundCast.js'; import { createHorizonCull } from './view.js'; const SPRITE_ID = 'street-level:marker'; -/** On-globe marker for the open image; in terrain mode it stands on bare earth. */ -export function createMarker({ state, parts }) { +/** On-globe marker for the open image, clamped to the ground. */ +export function createMarker({ state }) { const { render, sprites } = state.services; - let last = null; function requestRender() { render?.governorRequestRender?.('street-level-marker'); @@ -31,36 +29,10 @@ export function createMarker({ state, parts }) { sprites?.registerSpriteCollection?.(SPRITE_ID, state.marker.collection); } - /** - * Terrain-mode height (bare earth refined by mesh samples), or null to - * clamp. Requests a missing terrain cell or mesh sample as a side effect. - */ - function castHeight(position) { - const ground = parts?.groundCaster; - if (state.surface !== 'terrain' || !ground) return null; - const { lon, lat } = position; - const dem = ground.groundAt(lon, lat); - if (dem === null) { - ground.prepare([[lon, lat]]).then((ready) => { - if (ready && last?.position === position) set(position, last.bearing); - }); - return null; - } - const mesh = parts.meshSampler?.meshAt(lon, lat); - if (mesh === undefined) parts.meshSampler?.request([[lon, lat]]); - return refineHeights([{ dem, mesh }])[0]; - } - - // A new mesh sample may move the marker onto (or off) the road surface. - parts?.meshSampler?.onSampled(() => { - if (last && state.surface === 'terrain') set(last.position, last.bearing); - }); - /** Move (or create) the marker; a null position removes it. */ function set(position, bearing) { const collection = state.marker.collection; if (!collection) return; - last = position ? { position, bearing } : null; if (!position) { collection.removeAll(); state.marker.billboard = null; @@ -68,16 +40,7 @@ export function createMarker({ state, parts }) { requestRender(); return; } - const height = castHeight(position); - const cartesian = Cesium.Cartesian3.fromDegrees( - position.lon, - position.lat, - height ?? 0, - ); - const heightReference = - height === null - ? Cesium.HeightReference.CLAMP_TO_GROUND - : Cesium.HeightReference.NONE; + const cartesian = Cesium.Cartesian3.fromDegrees(position.lon, position.lat); if (!state.marker.billboard) { state.marker.billboard = collection.add({ id: POSITION_PICK_ID, @@ -87,12 +50,11 @@ export function createMarker({ state, parts }) { width: 40, height: 40, alignedAxis: Cesium.Cartesian3.UNIT_Z, - heightReference, + heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, disableDepthTestDistance: Number.POSITIVE_INFINITY, verticalOrigin: Cesium.VerticalOrigin.CENTER, }); } else { - state.marker.billboard.heightReference = heightReference; state.marker.billboard.position = cartesian; } state.marker.billboard.rotation = -Cesium.Math.toRadians(bearing || 0); @@ -115,17 +77,11 @@ export function createMarker({ state, parts }) { state.marker.billboard = null; } - /** Re-place the marker after the surface mode changes. */ - function setSurface() { - if (last) set(last.position, last.bearing); - } - return { ensure, set, clear: () => set(null), setVisible, - setSurface, destroy, }; } diff --git a/src/layers/streetLevel/marker.test.mjs b/src/layers/streetLevel/marker.test.mjs index ae90b6fd3..cbea37a67 100644 --- a/src/layers/streetLevel/marker.test.mjs +++ b/src/layers/streetLevel/marker.test.mjs @@ -2,7 +2,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import * as Cesium from 'cesium'; import { createMarker } from './marker.js'; -import { GROUND_CAST_LIFT_M, MESH_LIFT_M } from './groundCast.js'; // The marker glyph draws on a canvas; Node has none, so drawing is a no-op. const noop = () => {}; @@ -12,7 +11,7 @@ globalThis.document ??= { }; /** A viewer whose camera is 20,000 km over `view`, and its pre-render listeners. */ -function setup({ parts = {}, surface = 'draped' } = {}) { +function setup() { const view = { lon: -121.49, lat: 38.58, height: 20_000_000 }; const preRender = new Set(); const viewer = { @@ -34,7 +33,6 @@ function setup({ parts = {}, surface = 'draped' } = {}) { const state = { services: {}, viewer, - surface, marker: { // Stands in for the billboard collection; ensure() keeps it. collection: { @@ -45,7 +43,7 @@ function setup({ parts = {}, surface = 'draped' } = {}) { billboard: null, }, }; - const marker = createMarker({ state, parts }); + const marker = createMarker({ state }); const frame = () => { for (const listener of [...preRender]) listener(); }; @@ -78,112 +76,21 @@ test('destroying the marker stops the horizon cull', () => { assert.equal(preRender.size, 0); }); -/** - * Bare earth from a ground caster (null until its cell is fetched) and the - * sampled Google 3D surface from a mesh sampler (undefined until sampled). - */ -function terrainParts({ dem = null, mesh } = {}) { - const ground = { dem, prepares: [] }; - const sampler = { mesh, requests: [], listeners: [] }; - return { - ground, - sampler, - parts: { - groundCaster: { - groundAt: () => ground.dem, - prepare(points) { - let resolve; - const promise = new Promise((done) => { - resolve = done; - }); - ground.prepares.push({ points, resolve }); - return promise; - }, - }, - meshSampler: { - meshAt: () => sampler.mesh, - request: (points) => sampler.requests.push(points), - onSampled: (listener) => sampler.listeners.push(listener), - }, - }, - }; -} - -const heightOf = (billboard) => - Cesium.Cartographic.fromCartesian(billboard.position).height; const SPOT = { lon: -121.49, lat: 38.58 }; -test('in terrain mode the marker stands on the sampled road surface (mesh near bare earth)', () => { - const { parts } = terrainParts({ dem: 10, mesh: 11.5 }); - const { state, marker } = setup({ parts, surface: 'terrain' }); - marker.set(SPOT, 45); - const billboard = state.marker.billboard; - assert.equal(billboard.heightReference, Cesium.HeightReference.NONE); - assert.ok( - Math.abs(heightOf(billboard) - (11.5 + MESH_LIFT_M)) < 1e-3, - `height ${heightOf(billboard)}`, - ); -}); - -test('in terrain mode without a mesh sample the marker stands on bare earth and asks for one', () => { - const { parts, sampler } = terrainParts({ dem: 10 }); - const { state, marker } = setup({ parts, surface: 'terrain' }); - marker.set(SPOT, 45); - const billboard = state.marker.billboard; - assert.equal(billboard.heightReference, Cesium.HeightReference.NONE); - assert.ok( - Math.abs(heightOf(billboard) - (10 + GROUND_CAST_LIFT_M)) < 1e-3, - `height ${heightOf(billboard)}`, - ); - assert.deepEqual(sampler.requests, [[[SPOT.lon, SPOT.lat]]]); - // The sample lands: the marker moves onto the road surface. - sampler.mesh = 10.5; - for (const listener of sampler.listeners) listener(); - assert.ok(Math.abs(heightOf(billboard) - (10.5 + MESH_LIFT_M)) < 1e-3); -}); - -test('in terrain mode with no ground yet the marker clamps, then stands up once the ground arrives', async () => { - const { parts, ground } = terrainParts({ mesh: 11 }); - const { state, marker } = setup({ parts, surface: 'terrain' }); +test('the marker is clamped to the ground and turned to the bearing', () => { + const { state, marker } = setup(); marker.set(SPOT, 45); const billboard = state.marker.billboard; assert.equal( billboard.heightReference, Cesium.HeightReference.CLAMP_TO_GROUND, ); - assert.deepEqual(ground.prepares[0].points, [[SPOT.lon, SPOT.lat]]); - ground.dem = 10; - ground.prepares[0].resolve(true); - await new Promise((resolve) => setTimeout(resolve, 0)); - assert.equal(billboard.heightReference, Cesium.HeightReference.NONE); - assert.ok(Math.abs(heightOf(billboard) - (11 + MESH_LIFT_M)) < 1e-3); - assert.equal(billboard.rotation, -Cesium.Math.toRadians(45), 'bearing kept'); -}); - -test('ground that arrives after the marker moved on does not move it back', async () => { - const { parts, ground } = terrainParts(); - const { state, marker } = setup({ parts, surface: 'terrain' }); - marker.set(SPOT, 0); - const elsewhere = { lon: -121.5, lat: 38.6 }; - marker.set(elsewhere, 0); - ground.dem = 10; - ground.prepares[0].resolve(true); - await new Promise((resolve) => setTimeout(resolve, 0)); - const placed = Cesium.Cartographic.fromCartesian( - state.marker.billboard.position, - ); - assert.ok( - Math.abs(Cesium.Math.toDegrees(placed.longitude) - elsewhere.lon) < 1e-9, - ); -}); - -test('draped mode clamps the marker to the globe whatever the ground says', () => { - const { parts, ground } = terrainParts({ dem: 10, mesh: 11 }); - const { state, marker } = setup({ parts, surface: 'draped' }); - marker.set(SPOT, 0); - assert.equal( - state.marker.billboard.heightReference, - Cesium.HeightReference.CLAMP_TO_GROUND, - ); - assert.equal(ground.prepares.length, 0); + assert.equal(billboard.rotation, -Cesium.Math.toRadians(45)); + // Moving it keeps the same billboard. + marker.set({ lon: -121.5, lat: 38.6 }, 90); + assert.equal(state.marker.billboard, billboard); + const placed = Cesium.Cartographic.fromCartesian(billboard.position); + assert.ok(Math.abs(Cesium.Math.toDegrees(placed.longitude) + 121.5) < 1e-9); + assert.equal(billboard.rotation, -Cesium.Math.toRadians(90)); }); diff --git a/src/layers/streetLevel/meshSampler.js b/src/layers/streetLevel/meshSampler.js deleted file mode 100644 index 2711e8c68..000000000 --- a/src/layers/streetLevel/meshSampler.js +++ /dev/null @@ -1,371 +0,0 @@ -import * as Cesium from 'cesium'; -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. 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. */ -export const MESH_CELL_DEG = 0.0001; -/** Cells farther than this from the point under the camera are not probed. */ -export const MESH_SAMPLE_RADIUS_M = 900; -/** Main-thread time per idle slice, taking requests in and ranking included. */ -export const MESH_SAMPLE_BUDGET_MS = 6; -/** The queue is re-ranked once the camera has moved this far, in metres. */ -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. */ -const MESH_NOTIFY_MS = 700; - -const cellOf = (value) => Math.round(value / MESH_CELL_DEG); - -/** Numeric key of the cell holding a point (exact within 2^53; no per-point string). */ -export const meshCellKey = (lon, lat) => - (cellOf(lon) + 1_800_001) * 2_000_000 + cellOf(lat) + 1_000_000; - -/** Add a queued cell to a min-heap on `distance`. */ -function heapPush(heap, cell) { - let i = heap.push(cell) - 1; - while (i > 0) { - const parent = (i - 1) >> 1; - if (heap[parent].distance <= cell.distance) break; - heap[i] = heap[parent]; - i = parent; - } - heap[i] = cell; -} - -/** Restore the heap below `i` after its cell was replaced. */ -function siftDown(heap, i) { - const cell = heap[i]; - const n = heap.length; - for (;;) { - let child = 2 * i + 1; - if (child >= n) break; - if (child + 1 < n && heap[child + 1].distance < heap[child].distance) - child++; - if (heap[child].distance >= cell.distance) break; - heap[i] = heap[child]; - i = child; - } - heap[i] = cell; -} - -function heapPop(heap) { - const top = heap[0]; - const last = heap.pop(); - if (heap.length) { - heap[0] = last; - siftDown(heap, 0); - } - return top; -} - -/** Every top-level primitive that is not a 3D tileset: what a probe skips. */ -function overlays(scene) { - const out = []; - const primitives = scene.primitives; - for (let i = 0; i < primitives.length; i++) { - const primitive = primitives.get(i); - if (!(primitive instanceof Cesium.Cesium3DTileset)) out.push(primitive); - } - return out; -} - -/** - * `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, - now = () => performance.now(), -}) { - /** 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. */ - const wanted = new Map(); - /** Queued {key, lon, lat, distance} cells, a min-heap on distance from `rankedFrom`. */ - let queue = []; - /** Requests not yet taken in: {points, next, centre} (centre: the camera then). */ - let intake = []; - /** Where the camera was when the queue was ranked. */ - let rankedFrom = null; - /** Running estimate of one probe's cost, so a slice stops before overrunning. */ - let probeMs = 1; - const listeners = new Set(); - let enabled = false; - let running = false; - let fresh = []; - let notifyTimer = null; - - /** The sampled mesh height for a point, or undefined when not sampled. */ - function meshAt(lon, lat) { - return heights.get(meshCellKey(lon, lat)); - } - - /** 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. - */ - function request(points) { - if (!enabled || !points?.length) return; - const centre = cameraCentre(); - if (!centre) return; - intake.push({ points, next: 0, centre }); - schedule(); - } - - /** Queue requested cells, oldest request first, until the slice reaches `until`. */ - function takeIn(until) { - const time = Date.now(); - while (intake.length) { - const pending = intake[0]; - const { points, centre } = pending; - while (pending.next < points.length) { - if ((pending.next & 255) === 255 && now() > until) return; - const [lon, lat] = points[pending.next++]; - if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue; - const key = meshCellKey(lon, lat); - if (wanted.has(key)) continue; - const cell = { - key, - lon: cellOf(lon) * MESH_CELL_DEG, - lat: cellOf(lat) * MESH_CELL_DEG, - distance: 0, - }; - 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; - misses.delete(key); - } - cell.distance = metresBetween(cell, rankedFrom); - wanted.set(key, cell); - heapPush(queue, cell); - } - intake.shift(); - } - } - - /** - * Rank the queue from where the camera is now, dropping cells it has left - * out of range: one linear heapify, run only after the camera has moved. - */ - function rerank(centre) { - rankedFrom = centre; - const kept = []; - for (const cell of queue) { - cell.distance = metresBetween(cell, centre); - if (cell.distance > radiusM) wanted.delete(cell.key); - else kept.push(cell); - } - queue = kept; - for (let i = (queue.length >> 1) - 1; i >= 0; i--) siftDown(queue, i); - } - - function clearQueue() { - intake = []; - wanted.clear(); - queue = []; - rankedFrom = null; - } - - function schedule() { - if (running || !enabled || !(queue.length || intake.length)) return; - running = true; - whenIdle(step, 300); - } - - function emit() { - notifyTimer = null; - const batch = fresh; - fresh = []; - for (const listener of [...listeners]) { - try { - listener(batch); - } catch (error) { - console.warn('[Data:StreetLevel] mesh listener error:', error); - } - } - } - - function step(deadline) { - running = false; - // The slice starts now: taking requests in and ranking count too. - const started = now(); - const until = - started + - Math.min(budgetMs, Math.max(1, deadline?.timeRemaining?.() ?? budgetMs)); - 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; - if (!rankedFrom || metresBetween(rankedFrom, centre) > MESH_RERANK_M) { - rerank(centre); - worked = true; - } - if (intake.length) { - // Leave room for a probe, so a stream of requests cannot starve them. - takeIn(until - probeMs); - worked = true; - } - const exclude = overlays(scene); - let probed = 0; - while (queue.length) { - // Stop before a probe would overrun the slice. A slice that did - // nothing else always probes once, so the queue drains. - if ((probed || worked) && now() + probeMs > until) break; - const { key, lon, lat } = heapPop(queue); - wanted.delete(key); - // Ranked from up to MESH_RERANK_M away: check the range from here. - const ground = metresBetween({ lon, lat }, centre); - if (ground > radiusM) continue; - probed++; - const probeStarted = now(); - let height; - try { - height = scene.sampleHeight( - Cesium.Cartographic.fromDegrees(lon, lat), - exclude, - ); - } catch { - height = undefined; - } - probeMs = probeMs * 0.75 + (now() - probeStarted) * 0.25; - height = validated(height, lon, lat); - const previous = heights.get(key); - if (height === undefined) { - // A miss, or a failed refresh: the old sample (if any) and the - // distance it was taken from stay, and the probe is retried after - // the same cooldown. - if (misses.size >= cacheMax) misses.clear(); - misses.set(key, Date.now() + MESH_MISS_RETRY_MS); - continue; - } - if (previous === undefined && heights.size >= cacheMax) forget(); - misses.delete(key); - heights.set(key, height); - 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); - schedule(); - } - - function forget() { - heights.clear(); - probedFrom.clear(); - misses.clear(); - } - - /** Probe only while overlays are cast (Google 3D at street zoom). */ - function setEnabled(on) { - enabled = on === true; - if (!enabled) clearQueue(); - else schedule(); - } - - /** Hear about newly sampled cells, as [lon, lat] cell centres. */ - function onSampled(listener) { - listeners.add(listener); - return () => listeners.delete(listener); - } - - function destroy() { - enabled = false; - forget(); - clearQueue(); - listeners.clear(); - clearTimeout(notifyTimer); - notifyTimer = null; - } - - return { meshAt, request, setEnabled, onSampled, destroy }; -} diff --git a/src/layers/streetLevel/meshSampler.test.mjs b/src/layers/streetLevel/meshSampler.test.mjs deleted file mode 100644 index 065c22645..000000000 --- a/src/layers/streetLevel/meshSampler.test.mjs +++ /dev/null @@ -1,405 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -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)); -const RAD = Math.PI / 180; - -/** A scene whose mesh is 100 m everywhere except where `holes` say. */ -function fakeViewer({ holes = () => false } = {}) { - const tileset = Object.create(Cesium.Cesium3DTileset.prototype); - const overlay = { name: 'lines' }; - const probes = []; - const scene = { - sampleHeightSupported: true, - primitives: { - length: 2, - get: (i) => [tileset, overlay][i], - }, - sampleHeight(carto, exclude) { - const lon = carto.longitude / RAD; - const lat = carto.latitude / RAD; - probes.push({ lon, lat, exclude }); - return holes(lon, lat) ? undefined : 100; - }, - }; - return { - probes, - overlay, - viewer: { - scene, - camera: { - positionCartographic: { longitude: 10 * RAD, latitude: 50 * RAD }, - }, - }, - }; -} - -test('cells are probed once, nearest first, with overlays excluded', async () => { - const { viewer, probes, overlay } = fakeViewer(); - const sampler = createMeshSampler({ getViewer: () => viewer }); - sampler.setEnabled(true); - const heard = []; - sampler.onSampled((batch) => heard.push(batch.length)); - sampler.request([ - [10.004, 50], - [10.001, 50], - [10.00101, 50.00001], // same ~11 m cell as the previous point - ]); - await settle(1000); - assert.equal(probes.length, 2); - assert.ok(probes[0].lon < probes[1].lon, 'nearest the camera first'); - assert.deepEqual(probes[0].exclude, [overlay], 'only tilesets are hit'); - assert.equal(sampler.meshAt(10.001, 50), 100); - assert.equal(sampler.meshAt(10.00101, 50.00001), 100); - assert.deepEqual(heard, [2]); - sampler.request([[10.001, 50]]); - await settle(); - assert.equal(probes.length, 2, 'a sampled cell is not probed again'); - sampler.destroy(); -}); - -test('far cells are skipped, misses are not latched, and nothing runs while disabled', async () => { - const { viewer, probes } = fakeViewer({ holes: (lon) => lon > 10.0049 }); - const sampler = createMeshSampler({ getViewer: () => viewer }); - sampler.request([[10.001, 50]]); - await settle(); - assert.equal(probes.length, 0, 'disabled: nothing queued'); - sampler.setEnabled(true); - sampler.request([ - [10.05, 50], // ~3.6 km away - [10.005, 50], // a hole: tiles not streamed - ]); - await settle(); - assert.equal(probes.length, 1); - assert.equal(sampler.meshAt(10.05, 50), undefined); - assert.equal(sampler.meshAt(10.005, 50), undefined); - assert.ok(Math.abs(probes[0].lon - 10.005) < MESH_CELL_DEG); - sampler.destroy(); -}); - -/** Probes run on a 16 ms timer headless; with mocked timers, run them now. */ -function probeNow(t) { - t.mock.timers.tick(20); -} - -test('a missed cell is probed again after the retry window, not before', (t) => { - t.mock.timers.enable({ apis: ['setTimeout', 'Date'] }); - const { viewer, probes } = fakeViewer({ holes: () => true }); - const sampler = createMeshSampler({ getViewer: () => viewer }); - sampler.setEnabled(true); - sampler.request([[10.001, 50]]); - probeNow(t); - assert.equal(probes.length, 1, 'probed, and missed'); - t.mock.timers.tick(MESH_MISS_RETRY_MS - 100); - sampler.request([[10.001, 50]]); - probeNow(t); - assert.equal(probes.length, 1, 'within the window: not probed'); - t.mock.timers.tick(100 + 1); - sampler.request([[10.001, 50]]); - probeNow(t); - assert.equal(probes.length, 2, 'after the window: probed again'); - sampler.destroy(); -}); - -test('remembered misses are bounded like the samples', (t) => { - t.mock.timers.enable({ apis: ['setTimeout', 'Date'] }); - const { viewer, probes } = fakeViewer({ holes: () => true }); - const sampler = createMeshSampler({ getViewer: () => viewer, cacheMax: 2 }); - sampler.setEnabled(true); - for (const lon of [10.001, 10.002, 10.003]) { - sampler.request([[lon, 50]]); - probeNow(t); - } - assert.equal(probes.length, 3); - // The third miss overflowed the list, which was dropped: the first cell - // is asked about again rather than remembered forever. - sampler.request([[10.001, 50]]); - probeNow(t); - assert.equal(probes.length, 4); - sampler.destroy(); -}); - -test('destroy forgets the samples', (t) => { - t.mock.timers.enable({ apis: ['setTimeout', 'Date'] }); - const { viewer } = fakeViewer(); - const sampler = createMeshSampler({ getViewer: () => viewer }); - sampler.setEnabled(true); - sampler.request([[10.001, 50]]); - probeNow(t); - assert.equal(sampler.meshAt(10.001, 50), 100); - sampler.destroy(); - assert.equal(sampler.meshAt(10.001, 50), undefined); -}); - -/** - * A sampler over `fakeViewer` on a fake clock: each probe costs `probeMs` - * and each read of the camera `cameraMs` (work done before any probe). - */ -function clocked(t, { probeMs = 2, cameraMs = 0, holes } = {}) { - t.mock.timers.enable({ apis: ['setTimeout', 'Date'] }); - const fake = fakeViewer({ holes }); - const clock = { now: 0 }; - const { scene, camera } = fake.viewer; - const probe = scene.sampleHeight; - scene.sampleHeight = (...args) => { - clock.now += probeMs; - return probe(...args); - }; - const position = { ...camera.positionCartographic }; - Object.defineProperty(camera, 'positionCartographic', { - get: () => { - clock.now += cameraMs; - return position; - }, - }); - const sampler = createMeshSampler({ - getViewer: () => fake.viewer, - now: () => clock.now, - }); - sampler.setEnabled(true); - /** Run one idle slice; its length on the fake clock. */ - function slice() { - const started = clock.now; - probeNow(t); - return clock.now - started; - } - /** Put the camera over a point. */ - function moveTo(lon, lat) { - position.longitude = lon * RAD; - position.latitude = lat * RAD; - } - return { ...fake, sampler, slice, moveTo }; -} - -/** Points on a grid `step` degrees apart, `n`×`n`, centred on a point. */ -function grid(lon, lat, n, step) { - const points = []; - for (let i = 0; i < n; i++) - for (let j = 0; j < n; j++) - points.push([ - lon + (i - (n - 1) / 2) * step, - lat + (j - (n - 1) / 2) * step, - ]); - return points; -} - -test('cells out of range when asked are not queued, even once the camera reaches them', (t) => { - const { sampler, probes, slice, moveTo } = clocked(t); - // 400 cells ~3.6 km east, past the sampler's range. - sampler.request(grid(10.05, 50, 20, 0.0002)); - // The camera gets there before any idle slice runs. - moveTo(10.05, 50); - for (let i = 0; i < 5; i++) slice(); - assert.equal(probes.length, 0, 'nothing was queued for later'); - // Asked again from there (as a camera move does), they are probed. - sampler.request([[10.05, 50]]); - slice(); - assert.equal(probes.length, 1); - sampler.destroy(); -}); - -test('a slice keeps to its budget, counting the work before its first probe', (t) => { - const { sampler, probes, slice } = clocked(t, { probeMs: 2, cameraMs: 3 }); - sampler.request(grid(10.001, 50, 5, 0.0002)); - const lengths = []; - while (probes.length < 25 && lengths.length < 40) lengths.push(slice()); - assert.equal(probes.length, 25, 'every cell is probed in the end'); - for (const length of lengths) - assert.ok(length <= MESH_SAMPLE_BUDGET_MS, `a ${length} ms slice`); - sampler.destroy(); -}); - -test('a large queue is ranked once, not sorted again every slice', (t) => { - const { sampler, probes, slice, moveTo } = clocked(t); - // 2,500 cells ~17 m apart around the camera, all within range. - const cells = grid(10, 50, 50, 0.00015); - sampler.request(cells); - const sort = Array.prototype.sort; - let sorted = 0; - t.after(() => (Array.prototype.sort = sort)); - Array.prototype.sort = function (...args) { - if (this.length >= 100) sorted += this.length; - return sort.apply(this, args); - }; - for (let i = 0; i < 10; i++) slice(); - const first = probes.map((probe) => - metresBetween(probe, { lon: 10, lat: 50 }), - ); - assert.ok(first.length >= 10, 'probes ran'); - first.forEach((distance, i) => - assert.ok( - !i || distance >= first[i - 1] - 0.01, - 'nearest the camera first, across slices', - ), - ); - Array.prototype.sort = sort; - assert.equal(sorted, 0, `${sorted} queued cells sorted in 10 slices`); - // The camera moves ~200 m east: the queue is ranked from there. - const done = probes.length; - moveTo(10.003, 50); - slice(); - slice(); - const after = probes - .slice(done) - .map((probe) => metresBetween(probe, { lon: 10.003, lat: 50 })); - assert.ok(after.length > 0); - assert.ok(after[0] < 20, `nearest the new view first (${after[0]} m)`); - sampler.destroy(); -}); - -test('the sampling range is measured the short way round the date line', (t) => { - const { sampler, probes, slice, moveTo } = clocked(t); - moveTo(179.9999, 0); - sampler.request([ - [-179.9999, 0], // ~22 m away, across the date line - [179.99, 0], // ~1.1 km away, past the range - ]); - for (let i = 0; i < 5; i++) slice(); - assert.equal(probes.length, 1, 'only the neighbour across ±180°'); - 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(); -}); - -test('a failed refresh keeps the last good distance and is retried after the cooldown', (t) => { - t.mock.timers.enable({ apis: ['setTimeout', 'Date'] }); - const { viewer, probes } = liveViewer({ surface: 90, height: 700 }); - viewer.camera.positionCartographic.longitude = 10.001 * RAD; - const sampler = liveSampler(viewer); - sampler.setEnabled(true); - sampler.request([[10.001, 50]]); - probeNow(t); - assert.equal(sampler.meshAt(10.001, 50), 90); - // The camera comes down to 20 m and the refresh misses (tiles in flux). - viewer.camera.positionCartographic.height = 110; - viewer.surface = undefined; - sampler.request([[10.001, 50]]); - probeNow(t); - assert.equal(probes.length, 2); - assert.equal(sampler.meshAt(10.001, 50), 90, 'the old sample stays'); - viewer.surface = 100; - t.mock.timers.tick(MESH_MISS_RETRY_MS - 100); - sampler.request([[10.001, 50]]); - probeNow(t); - assert.equal(probes.length, 2, 'not before the cooldown'); - t.mock.timers.tick(100); - sampler.request([[10.001, 50]]); - probeNow(t); - assert.equal(probes.length, 3, 'retried after it'); - assert.equal(sampler.meshAt(10.001, 50), 100); - sampler.destroy(); -}); diff --git a/src/layers/streetLevel/policy.js b/src/layers/streetLevel/policy.js index 865006f6a..23b85fa29 100644 --- a/src/layers/streetLevel/policy.js +++ b/src/layers/streetLevel/policy.js @@ -26,12 +26,3 @@ export const FILTER_DEFAULT = Object.freeze({ pano: 'all', sinceDays: 0 }); /** Nearest-image search radius in metres (Mapillary's graph API caps it at 50 m). */ export const NEAREST_RADIUS_M = 50; - -/** - * The only map stack camera follow is offered on: elsewhere a camera at eye - * height looks at a smeared texture. - */ -export const FOLLOW_MAP_STACK_ID = 'photoreal'; - -/** Eye height above the sampled ground when the globe camera follows the viewer. */ -export const FOLLOW_EYE_HEIGHT_M = 2.4; diff --git a/src/layers/streetLevel/providers/mapillary/coverage.js b/src/layers/streetLevel/providers/mapillary/coverage.js index 5b9b5246c..e6af2846a 100644 --- a/src/layers/streetLevel/providers/mapillary/coverage.js +++ b/src/layers/streetLevel/providers/mapillary/coverage.js @@ -7,10 +7,7 @@ import { metresBetween, viewFocus, visibleBbox, - whenIdle, } from '../../view.js'; -import { densifyLine, MESH_DENSIFY_DEG } from '../../groundCast.js'; -import { MESH_SAMPLE_RADIUS_M } from '../../meshSampler.js'; import { coverageZoomForHeight, overviewZoomForHeight, @@ -33,12 +30,6 @@ import { SEQUENCE_VIEW_RANGE_PER_HEIGHT, } from './policy.js'; -/** Draped lines stay at most this long while a tile's cast lines build. */ -export const SWAP_MAX_WAIT_MS = 4000; -/** Tiles touched by new mesh samples are redrawn at most this often. */ -const REMESH_INTERVAL_MS = 1500; -/** Gap between redrawing one dirty tile and the next. */ -const REMESH_STAGGER_MS = 120; /** Old-zoom tiles are kept at most this long after a zoom change. */ const STALE_TILE_MAX_MS = 6000; /** Sequences per primitive: smaller batches build, and show, sooner. */ @@ -76,28 +67,9 @@ export function sequenceIdFromPick(pickId) { return cut === -1 ? rest : rest.slice(0, cut); } -/** - * Whether any point of `box` ({west, south, east, north}) is within the mesh - * sampler's range of `centre`, the ground point under the camera. - */ -export function meshBoxInRange(box, centre) { - // Move the camera's longitude to the box's side of the date line first: - // clamping 179.999 into a box at -179.99 would pick its far edge. - const lon = - centre.lon + - 360 * Math.round(((box.west + box.east) / 2 - centre.lon) / 360); - const nearest = { - lon: Math.min(box.east, Math.max(box.west, lon)), - lat: Math.min(box.north, Math.max(box.south, centre.lat)), - }; - return metresBetween(centre, nearest) <= MESH_SAMPLE_RADIUS_M; -} - /** * Camera-driven coverage: overview points (z0–5) from orbit, sequence lines - * (z11–14) near the ground. In terrain surface mode a tile is drawn draped - * first, then swapped for lines cast to the bare earth (draped lines would - * land on roofs), so coverage never waits on terrain heights. + * (z11–14) near the ground, draped on terrain and 3D tiles alike. */ export function createCoverage({ state, source }) { const { render } = state.services; @@ -120,13 +92,6 @@ export function createCoverage({ state, source }) { return state.context.getFilter(); } - function terrainMode() { - return ( - state.context.getSurface?.() === 'terrain' && - Boolean(state.context.groundCaster) - ); - } - /** * A tile's sequences, filtered then capped: capping first could leave a * dense tile of newer flat captures with no 360° lines at all. @@ -142,46 +107,31 @@ export function createCoverage({ state, source }) { } /** - * Batched primitives for a tile's sequence parts: cast polylines where - * terrain heights are cached, draped ground lines otherwise. Each batch - * records the selection its colours were built with. + * Batched draped primitives for a tile's sequence parts. Each batch records + * the selection its colours were built with. */ function buildSequencePrimitives(sequences) { - const ground = terrainMode() ? state.context.groundCaster : null; - const meshAt = ground ? state.context.meshSampler?.meshAt : undefined; const selectedId = state.sequence.selectedId ?? null; - const draped = []; - const cast = []; + const instances = []; for (const sequence of sequences) { const color = Cesium.ColorGeometryInstanceAttribute.fromColor( sequenceColor({ selected: sequence.id === selectedId }), ); const ids = partIds(sequence); sequence.parts.forEach((coordinates, index) => { - const flat = ground?.castLine(coordinates, { meshAt }); let positions; try { - positions = flat - ? Cesium.Cartesian3.fromDegreesArrayHeights(flat) - : Cesium.Cartesian3.fromDegreesArray(coordinates.flat()); + positions = Cesium.Cartesian3.fromDegreesArray(coordinates.flat()); } catch { return; } if (positions.length < 2) return; - const geometry = flat - ? new Cesium.PolylineGeometry({ - positions, - width: COVERAGE_LINE_WIDTH_PX, - vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT, - arcType: Cesium.ArcType.NONE, - }) - : new Cesium.GroundPolylineGeometry({ - positions, - width: COVERAGE_LINE_WIDTH_PX, - }); - (flat ? cast : draped).push( + instances.push( new Cesium.GeometryInstance({ - geometry, + geometry: new Cesium.GroundPolylineGeometry({ + positions, + width: COVERAGE_LINE_WIDTH_PX, + }), id: ids[index], attributes: { color }, }), @@ -189,212 +139,19 @@ export function createCoverage({ state, source }) { }); } const primitives = []; - for (let i = 0; i < draped.length; i += SEQUENCE_PRIMITIVE_BATCH) + for (let i = 0; i < instances.length; i += SEQUENCE_PRIMITIVE_BATCH) primitives.push({ - onGround: true, selectedId, primitive: new Cesium.GroundPolylinePrimitive({ - geometryInstances: draped.slice(i, i + SEQUENCE_PRIMITIVE_BATCH), + geometryInstances: instances.slice(i, i + SEQUENCE_PRIMITIVE_BATCH), appearance: new Cesium.PolylineColorAppearance(), + // Drapes on the globe and on 3D tiles (Google 3D) alike. classificationType: Cesium.ClassificationType.BOTH, asynchronous: true, allowPicking: true, }), }); - for (let i = 0; i < cast.length; i += SEQUENCE_PRIMITIVE_BATCH) - primitives.push({ - onGround: false, - selectedId, - primitive: new Cesium.Primitive({ - geometryInstances: cast.slice(i, i + SEQUENCE_PRIMITIVE_BATCH), - appearance: new Cesium.PolylineColorAppearance({ translucent: true }), - asynchronous: true, - allowPicking: true, - }), - }); - return { primitives, draped, cast: cast.length }; - } - - /** - * Fetch the terrain heights a tile's lines need, then redraw it cast. - * `castRequested` stops the redraw from casting again at once; a refresh - * `retry`s a tile whose lines are still draped (a failed or partial cast), - * and it is redrawn only when more of its lines can be cast. - */ - function castTile(entry, { retry = false } = {}) { - if ((entry.castRequested && !retry) || entry.castAbort || !terrainMode()) - return; - entry.castRequested = true; - entry.castAbort = new AbortController(); - const { signal } = entry.castAbort; - const lines = [...entry.sequences.values()].flatMap( - (sequence) => sequence.parts, - ); - const caster = state.context.groundCaster; - caster.prepareLines(lines, { signal }).then(() => { - if (entry.castAbort?.signal === signal) entry.castAbort = null; - const attached = [...state.coverage.tiles.values()].includes(entry); - if (signal.aborted || !attached || !terrainMode()) return; - // Nothing new to cast: keep what is drawn until the next refresh. - // Counted like buildSequencePrimitives: a cast line needs two points. - const castable = lines.filter( - (coords) => caster.castLine(coords)?.length >= 6, - ).length; - if (castable <= (entry.castLines || 0)) { - entry.castRequested = (entry.castLines || 0) > 0; - return; - } - // A remesh may have cleared the flag while this was in flight. - entry.castRequested = true; - redrawTile(entry); - requestMesh(entry); - }); - } - - /** Rebuild a tile's lines; the old ones stay up until the new are ready. */ - function redrawTile(entry) { - const previous = entry.primitives; - entry.primitives = []; - attachPrimitive(entry); - removeWhenReady(entry, previous); - } - - /** Lon/lat under the camera, where mesh range is measured from. */ - function meshCentre() { - const carto = state.viewer?.camera?.positionCartographic; - if (!carto) return null; - return { - lon: Cesium.Math.toDegrees(carto.longitude), - lat: Cesium.Math.toDegrees(carto.latitude), - }; - } - - /** - * A tile's drawn lines with bounding boxes, built once per filter; mesh - * points are densified the first time a line is in range. - */ - function meshParts(entry) { - if (entry.meshParts) return entry.meshParts; - entry.meshParts = []; - for (const sequence of entry.sequences.values()) - for (const part of sequence.parts) { - const box = { - west: Infinity, - south: Infinity, - east: -Infinity, - north: -Infinity, - }; - for (const [lon, lat] of part) { - if (lon < box.west) box.west = lon; - if (lon > box.east) box.east = lon; - if (lat < box.south) box.south = lat; - if (lat > box.north) box.north = lat; - } - entry.meshParts.push({ part, box, points: null }); - } - return entry.meshParts; - } - - /** - * Ask for mesh samples under a cast tile's lines within the sampler's range - * of the camera; the rest are asked for when a camera move brings them in. - */ - function requestMesh(entry) { - const sampler = state.context.meshSampler; - if (!sampler || entry.kind !== 'sequence' || !terrainMode()) return; - const centre = meshCentre(); - if (!centre || !entry.bounds || !meshBoxInRange(entry.bounds, centre)) - return; - const points = []; - for (const line of meshParts(entry)) { - if (!meshBoxInRange(line.box, centre)) continue; - line.points ||= densifyLine(line.part, MESH_DENSIFY_DEG); - for (const point of line.points) points.push(point); - } - if (points.length) sampler.request(points); - } - - /** Redraw the tiles new mesh samples fall in, throttled. */ - function onMeshSampled(batch) { - if (!terrainMode() || !state.context.isActive()) return; - for (const entry of state.coverage.tiles.values()) { - if (entry.kind !== 'sequence' || !entry.bounds) continue; - const { west, south, east, north } = entry.bounds; - if ( - batch.some( - ([lon, lat]) => - lon >= west && lon <= east && lat >= south && lat <= north, - ) - ) - state.coverage.remeshDirty.add(entry); - } - if (!state.coverage.remeshDirty.size || state.coverage.remeshTimer) return; - const wait = Math.max( - 0, - REMESH_INTERVAL_MS - (Date.now() - (state.coverage.remeshAt || 0)), - ); - state.coverage.remeshTimer = setTimeout(remesh, wait); - } - - /** Redraw one dirty tile per idle slice, so a burst of samples never stalls a frame. */ - function remesh() { - state.coverage.remeshTimer = null; - state.coverage.remeshAt = Date.now(); - if (!terrainMode() || !state.context.isActive()) { - state.coverage.remeshDirty.clear(); - return; - } - const attached = new Set(state.coverage.tiles.values()); - const [entry] = state.coverage.remeshDirty; - if (!entry) return; - state.coverage.remeshDirty.delete(entry); - if (attached.has(entry)) { - redrawTile(entry); - requestRender(); - } - if (state.coverage.remeshDirty.size) - state.coverage.remeshTimer = setTimeout( - () => whenIdle(remesh, 500), - REMESH_STAGGER_MS, - ); - } - - state.coverage.remeshDirty = new Set(); - state.context.meshSampler?.onSampled(onMeshSampled); - - /** - * Remove a tile's previous primitives once all its current ones are ready - * (or after a few seconds); detaching the tile removes them at once. - */ - function removeWhenReady(entry, old) { - const scene = state.viewer?.scene; - finishSwap(entry); - if (!scene?.postRender) { - removePrimitives(old); - return; - } - const fresh = entry.primitives; - const started = Date.now(); - const stop = scene.postRender.addEventListener(() => { - const ready = fresh.every( - ({ primitive }) => primitive.ready || primitive.isDestroyed?.(), - ); - if (!ready && Date.now() - started < SWAP_MAX_WAIT_MS) { - requestRender(); - return; - } - finishSwap(entry); - requestRender(); - }); - entry.swap = { old, stop }; - requestRender(); - } - - function finishSwap(entry) { - if (!entry.swap) return; - entry.swap.stop(); - removePrimitives(entry.swap.old); - entry.swap = null; + return primitives; } function buildOverviewCollection(points) { @@ -442,17 +199,10 @@ export function createCoverage({ state, source }) { // Lookup, picking and counts follow what is drawn, not the whole tile. const sequences = drawnSequences(entry); entry.sequences = new Map(sequences.map((s) => [s.id, s])); - const { primitives, draped, cast } = buildSequencePrimitives(sequences); - entry.primitives = primitives; + entry.primitives = buildSequencePrimitives(sequences); entry.count = sequences.length; - for (const { primitive, onGround } of primitives) - (onGround ? scene.groundPrimitives : scene.primitives).add(primitive); - // Fewer lines cast than last time: the caster has dropped heights this - // tile used (a full cache), so it must be cast again. - if (cast < (entry.castLines || 0)) entry.castRequested = false; - entry.castLines = cast; - entry.drapedLines = draped.length; - if (draped.length) castTile(entry); + for (const { primitive } of entry.primitives) + scene.groundPrimitives.add(primitive); watchSelection(); } else { const { collection, points } = buildOverviewCollection(entry.points); @@ -465,23 +215,15 @@ export function createCoverage({ state, source }) { } } - function removePrimitives(list) { + function detachPrimitive(entry) { const scene = state.viewer?.scene; - for (const { primitive, onGround } of list || []) { + for (const { primitive } of entry.primitives || []) { try { - (onGround ? scene?.groundPrimitives : scene?.primitives)?.remove( - primitive, - ); + scene?.groundPrimitives?.remove(primitive); } catch { /* already gone */ } } - } - - function detachPrimitive(entry) { - const scene = state.viewer?.scene; - finishSwap(entry); - removePrimitives(entry.primitives); entry.primitives = []; if (entry.primitive) { try { @@ -494,16 +236,9 @@ export function createCoverage({ state, source }) { } } - /** Stop a tile's pending terrain lookup (tile dropped or retired). */ - function cancelCast(entry) { - entry.castAbort?.abort(); - entry.castAbort = null; - } - function removeTile(key) { const entry = state.coverage.tiles.get(key); if (!entry) return; - cancelCast(entry); detachPrimitive(entry); state.coverage.tiles.delete(key); } @@ -630,7 +365,6 @@ export function createCoverage({ state, source }) { controller.abort(); state.coverage.pending.clear(); for (const [key, entry] of state.coverage.tiles) { - cancelCast(entry); const previous = state.coverage.stale.get(key); if (previous) detachPrimitive(previous); state.coverage.stale.set(key, entry); @@ -659,9 +393,7 @@ export function createCoverage({ state, source }) { if (!viewer || !state.context.isActive() || state.keyRequired) return; if (state.statusKnown === false) return; if (state.keyRejected || state.coverage.holdUntil > Date.now()) return; - const ground = groundUnderCamera(viewer, { - groundAt: state.context.groundCaster?.groundAt, - }); + const ground = groundUnderCamera(viewer); const cameraHeight = viewer.camera?.positionCartographic?.height; const height = Number.isFinite(cameraHeight) ? cameraHeight - (ground ?? 0) @@ -671,7 +403,7 @@ export function createCoverage({ state, source }) { const zoom = sequenceZoom ?? overviewZoom; const kind = sequenceZoom ? 'sequence' : 'overview'; // At street zooms the rays meet the ground where it really is (1,600 m up - // in Denver; Google 3D hides the globe) and stop short of the horizon. + // in Denver) and stop short of the horizon. const ranged = kind === 'sequence' ? { @@ -723,13 +455,6 @@ export function createCoverage({ state, source }) { } for (const tile of tiles) loadTile(tile, kind); if (!state.coverage.pending.size) purgeStale(); - // The camera moved: cells that were out of the sampler's range may not be, - // and lines left draped by a failed, partial or dropped cast get another try. - if (terrainMode()) - for (const entry of state.coverage.tiles.values()) { - if (entry.castRequested) requestMesh(entry); - if (entry.drapedLines) castTile(entry, { retry: true }); - } notify(); } @@ -767,9 +492,6 @@ export function createCoverage({ state, source }) { purgeStale(); state.coverage.zoom = null; state.coverage.kind = null; - clearTimeout(state.coverage.remeshTimer); - state.coverage.remeshTimer = null; - state.coverage.remeshDirty.clear(); stopSelectionWatch(); horizon.stop(); requestRender(); @@ -779,9 +501,6 @@ export function createCoverage({ state, source }) { function rebuild() { purgeStale(); for (const entry of state.coverage.tiles.values()) { - cancelCast(entry); - entry.castRequested = false; - entry.meshParts = null; detachPrimitive(entry); attachPrimitive(entry); } @@ -789,16 +508,6 @@ export function createCoverage({ state, source }) { notify(); } - /** - * Redraw tiles draped or cast for the new surface mode, then re-pick the - * zoom, since the ground height under the camera may have changed. - */ - function setSurface() { - if (!state.context.isActive()) return; - rebuild(); - refresh(); - } - /** Recolour every part of a sequence in one ready primitive. */ function recolorInstances(primitive, sequence, selected) { const value = sequenceColor({ selected }); @@ -821,18 +530,12 @@ export function createCoverage({ state, source }) { return [...state.coverage.tiles.values(), ...state.coverage.stale.values()]; } - /** A tile's primitives on the globe: its own, and the ones a swap still shows. */ - function drawnRecords(entry) { - const records = entry.primitives || []; - return entry.swap ? [...records, ...entry.swap.old] : records; - } - /** Recolour one sequence, every part of it, in place (selection highlight). */ function recolorSequence(id, selected) { for (const entry of drawnEntries()) { const sequence = entry.sequences.get(id); if (!sequence) continue; - for (const record of drawnRecords(entry)) { + for (const record of entry.primitives || []) { // Still building: `syncSelection` catches it up once it is ready. if (!record.primitive.ready) continue; recolorInstances(record.primitive, sequence, selected); @@ -874,9 +577,6 @@ export function createCoverage({ state, source }) { if (!record.primitive.ready) building = true; else if (syncSelection(entry, record)) changed = true; } - // Lines a swap still shows were ready (or never show): catch up only. - for (const record of entry.swap?.old || []) - if (syncSelection(entry, record)) changed = true; } if (changed) requestRender(); if (!building) stopSelectionWatch(); @@ -910,7 +610,6 @@ export function createCoverage({ state, source }) { clear, resetErrors, rebuild, - setSurface, recolorSequence, sequenceCount, }; diff --git a/src/layers/streetLevel/providers/mapillary/coverage.test.mjs b/src/layers/streetLevel/providers/mapillary/coverage.test.mjs index 26c6b13fc..867c72031 100644 --- a/src/layers/streetLevel/providers/mapillary/coverage.test.mjs +++ b/src/layers/streetLevel/providers/mapillary/coverage.test.mjs @@ -1,11 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import * as Cesium from 'cesium'; -import { - SWAP_MAX_WAIT_MS, - createCoverage, - meshBoxInRange, -} from './coverage.js'; +import { createCoverage } from './coverage.js'; import { encodeCoverageTile } from './coverageFixture.mjs'; import { rayCamera } from '../../../../testSupport/streetLevelFakes.mjs'; import { @@ -13,7 +9,6 @@ import { COVERAGE_MAX_SEQUENCES, COVERAGE_MAX_TILES, } from './policy.js'; -import { createGroundCaster } from '../../groundCast.js'; import { lonToTileX, latToTileY, tileBounds } from '../../tileMath.js'; const RAD = Math.PI / 180; @@ -108,11 +103,7 @@ function deferredSource() { }; } -function setup({ - surface = 'draped', - groundCaster = null, - meshSampler = null, -} = {}) { +function setup() { const centre = tileCentre(-121.4944, 38.5816); const viewer = fakeViewer({ lon: centre.lon, lat: centre.lat, height: 900 }); const source = deferredSource(); @@ -125,9 +116,6 @@ function setup({ getFilter: () => state.context.filter, isActive: () => true, notify() {}, - getSurface: () => surface, - groundCaster, - meshSampler, }, coverage: { zoom: null, @@ -207,67 +195,19 @@ test('a superseded tile request cannot strand lines on the globe', async () => { assert.equal(onGlobe(), 0); }); -test('in terrain mode a tile draws draped, then swaps to cast lines without a blank frame', async () => { - let heightsReady = false; - const groundCaster = { - prepareLines: async () => { - heightsReady = true; - return true; - }, - castLine: (coords) => - heightsReady ? coords.flatMap(([lon, lat]) => [lon, lat, 30]) : null, - }; - const { viewer, source, coverage, bytes, onGlobe } = setup({ - surface: 'terrain', - groundCaster, - }); +test('sequence lines are draped on the globe and on 3D tiles alike', async () => { + const { viewer, source, coverage, bytes } = setup(); coverage.refresh(); source.calls[0].resolve(bytes); await settle(); - await settle(); const { groundPrimitives, primitives } = viewer.scene; - // Both are on the globe while the cast lines build: no blink. - assert.equal(groundPrimitives.items.size, 1, 'draped lines kept'); - assert.equal(primitives.items.size, 1, 'cast lines added'); - assert.ok( - [...primitives.items][0] instanceof Cesium.Primitive, - 'cast lines are plain polylines at terrain height', - ); - // Leaving mid-swap removes both sets, not just the new one. - coverage.clear(); - assert.equal(onGlobe(), 0); - assert.equal(viewer.postRender.size, 0, 'the swap listener is gone'); -}); - -test('a tile whose lines cannot be cast is not rebuilt for nothing', async () => { - let prepares = 0; - const groundCaster = { - prepareLines: async () => { - prepares++; - return false; // terrain proxy down, or tile too big - }, - castLine: () => null, - }; - const { viewer, source, coverage, bytes } = setup({ - surface: 'terrain', - groundCaster, - }); - const added = []; - const add = viewer.scene.groundPrimitives.add.bind( - viewer.scene.groundPrimitives, - ); - viewer.scene.groundPrimitives.add = (primitive) => { - added.push(primitive); - return add(primitive); - }; - coverage.refresh(); - source.calls[0].resolve(bytes); - await settle(); - await settle(); - assert.equal(prepares, 1); - assert.equal(added.length, 1, 'drawn once, draped; no second draped build'); - assert.equal(viewer.scene.primitives.items.size, 0); + assert.equal(primitives.items.size, 0); + assert.equal(groundPrimitives.items.size, 1); + const [line] = groundPrimitives.items; + assert.ok(line instanceof Cesium.GroundPolylinePrimitive); + assert.equal(line.classificationType, Cesium.ClassificationType.BOTH); coverage.clear(); + assert.equal(groundPrimitives.items.size, 0); }); test('panning off a tile that is still loading stops counting it as loading', async () => { @@ -391,89 +331,6 @@ test('overview points behind the horizon are hidden, not drawn through the globe assert.equal(viewer.preRender.size, 0, 'the cull listener is gone'); }); -test('a cast tile whose heights were evicted is cast again, not left draped', async () => { - const terrain = { - calls: 0, - async resolveEllipsoidalGround(coords) { - this.calls++; - return coords.map(() => ({ ellipsoid: 20, source: 'reearth' })); - }, - }; - // Room for one tile's corners at a time. - const groundCaster = createGroundCaster({ - terrain, - maxCorners: 16, - cacheMax: 20, - }); - let sampled = null; - const meshSampler = { - onSampled: (listener) => (sampled = listener), - request() {}, - meshAt: () => undefined, - }; - const { source, state, coverage, bytes, centre } = setup({ - surface: 'terrain', - groundCaster, - meshSampler, - }); - const isCast = () => { - const [entry] = state.coverage.tiles.values(); - return ( - entry.primitives.length > 0 && - entry.primitives.every(({ onGround }) => !onGround) - ); - }; - coverage.refresh(); - source.calls[0].resolve(bytes); - await settle(); - await settle(); - assert.ok(isCast(), 'cast once the heights are in'); - // Another region fills the cache and evicts this tile's corners... - await groundCaster.prepare([[centre.lon + 0.05, centre.lat + 0.05]]); - assert.equal(groundCaster.groundAt(centre.lon - 0.001, centre.lat), null); - // ...then mesh samples redraw the tile, which can only drape now. - sampled([[centre.lon, centre.lat]]); - await new Promise((resolve) => setTimeout(resolve, 5)); - coverage.refresh(); - await settle(); - await settle(); - assert.ok(isCast(), 'the tile is cast again'); - coverage.clear(); -}); - -test('a tile left draped by a failed terrain lookup is cast on the next refresh', async () => { - let up = false; - let prepares = 0; - let heightsReady = false; - const groundCaster = { - prepareLines: async () => { - prepares++; - heightsReady = up; - return up; - }, - castLine: (coords) => - heightsReady ? coords.flatMap(([lon, lat]) => [lon, lat, 30]) : null, - }; - const { viewer, source, coverage, bytes } = setup({ - surface: 'terrain', - groundCaster, - }); - coverage.refresh(); - source.calls[0].resolve(bytes); - await settle(); - await settle(); - assert.equal(prepares, 1); - assert.equal(viewer.scene.primitives.items.size, 0, 'draped'); - await settle(); - assert.equal(prepares, 1, 'no retry loop while the proxy is down'); - up = true; // the proxy is back - coverage.refresh(); - await settle(); - assert.equal(prepares, 2, 'the next refresh tries again'); - assert.equal(viewer.scene.primitives.items.size, 1, 'cast lines added'); - coverage.clear(); -}); - test('the per-tile cap applies after the imagery filter, so a dense tile keeps its 360° lines', async () => { const { state, source, coverage, centre } = setup(); const line = (n) => [ @@ -598,54 +455,6 @@ test('a sequence that crosses a tile edge is counted once', async () => { coverage.clear(); }); -/** A terrain-mode tile mid-swap, with a cast primitive the test readies. */ -async function midSwap() { - // Drawn draped first; castLine only answers once the heights are in. - let heightsReady = false; - const groundCaster = { - prepareLines: async () => (heightsReady = true), - castLine: (coords) => - heightsReady ? coords.flatMap(([lon, lat]) => [lon, lat, 30]) : null, - }; - const context = setup({ surface: 'terrain', groundCaster }); - context.coverage.refresh(); - context.source.calls[0].resolve(context.bytes); - await settle(); - await settle(); - const { groundPrimitives, primitives } = context.viewer.scene; - assert.equal(groundPrimitives.items.size, 1, 'draped lines kept'); - assert.equal(primitives.items.size, 1, 'cast lines building'); - const [cast] = primitives.items; - const control = { ready: false }; - Object.defineProperty(cast, 'ready', { get: () => control.ready }); - return { ...context, control }; -} - -test('the draped lines go once the cast lines are ready', async () => { - const { viewer, coverage, control } = await midSwap(); - frame(viewer.postRender); - assert.equal(viewer.scene.groundPrimitives.items.size, 1, 'still building'); - control.ready = true; - frame(viewer.postRender); - assert.equal(viewer.scene.groundPrimitives.items.size, 0, 'draped removed'); - assert.equal(viewer.scene.primitives.items.size, 1, 'cast lines stay'); - assert.equal(viewer.postRender.size, 0, 'nothing left watching'); - coverage.clear(); -}); - -test('the draped lines go after the wait even if the cast lines never get ready', async (t) => { - t.mock.timers.enable({ apis: ['Date'] }); - const { viewer, coverage } = await midSwap(); - t.mock.timers.tick(SWAP_MAX_WAIT_MS - 1); - frame(viewer.postRender); - assert.equal(viewer.scene.groundPrimitives.items.size, 1, 'still waiting'); - t.mock.timers.tick(2); - frame(viewer.postRender); - assert.equal(viewer.scene.groundPrimitives.items.size, 0, 'draped removed'); - assert.equal(viewer.scene.primitives.items.size, 1); - coverage.clear(); -}); - /** A camera over a 100×100 canvas with a 60°×40° view (see rayCamera). */ function tiltedCamera({ lon, lat, height, heading, pitch }) { return rayCamera({ @@ -663,7 +472,7 @@ function tiltedCamera({ lon, lat, height, heading, pitch }) { /** * Denver: 300 m above a street 1,600 m up, at a z14 tile centre, looking east - * at ground `aheadM` metres out. Only the bare-earth caster knows the ground. + * at ground `aheadM` metres out; the globe terrain knows the ground. */ function denverStreetView(aheadM) { const z = 14; @@ -674,7 +483,12 @@ function denverStreetView(aheadM) { const lat = (tile.south + tile.north) / 2; const ground = 1600; const above = 300; - const context = setup({ groundCaster: { groundAt: () => ground } }); + const context = setup(); + context.viewer.scene.globe = { + show: true, + ellipsoid: Cesium.Ellipsoid.WGS84, + getHeight: () => ground, + }; const tileWidthM = (tile.east - tile.west) * 111_320 * Math.cos(lat * RAD); context.viewer.camera = tiltedCamera({ lon, @@ -780,66 +594,6 @@ test('a selection made while the old zoom is still building is applied once it i assert.equal(viewer.postRender.size, 0, 'the selection watch is gone'); }); -test('only lines within the mesh sampler range of the camera are asked for', async () => { - let heightsReady = false; - const groundCaster = { - prepareLines: async () => (heightsReady = true), - castLine: (coords) => - heightsReady ? coords.flatMap(([lon, lat]) => [lon, lat, 30]) : null, - }; - const requested = []; - const meshSampler = { - onSampled() {}, - request: (points) => requested.push(...points), - meshAt: () => undefined, - }; - const { source, coverage, centre } = setup({ - surface: 'terrain', - groundCaster, - meshSampler, - }); - const { east, north } = tileBounds(centre.tile.x, centre.tile.y, 14); - coverage.refresh(); - source.calls[0].resolve( - encodeCoverageTile(centre.tile, { - sequences: [ - { - id: 'here', - parts: [ - [ - [centre.lon - 0.001, centre.lat], - [centre.lon + 0.001, centre.lat], - ], - ], - }, - // ~1.1 km from the camera, in the tile's north-east corner. - { - id: 'corner', - parts: [ - [ - [east - 0.002, north - 0.002], - [east - 0.0005, north - 0.0005], - ], - ], - }, - ], - }), - ); - await settle(); - await settle(); - assert.ok(requested.length > 0, 'the near line is asked for'); - const farthest = Math.max( - ...requested.map(([lon, lat]) => - Math.hypot( - (lon - centre.lon) * 111_320 * Math.cos(centre.lat * RAD), - (lat - centre.lat) * 110_540, - ), - ), - ); - assert.ok(farthest < 200, `nothing from the corner (${farthest} m)`); - coverage.clear(); -}); - test('the old zoom goes as soon as the new zoom has loaded, not after the stale wait', async () => { const { viewer, source, state, coverage, bytes } = setup(); coverage.refresh(); @@ -894,19 +648,6 @@ test('a rate limit holds every refresh inside its wait, not only the first', asy coverage.resetErrors(); }); -test('a line across the date line from the camera is in mesh range', () => { - // A z14 tile's width of lines just past -180, the camera 111 m short of it. - const camera = { lon: 179.999, lat: 0 }; - const east = { west: -180, east: -179.978, south: -0.001, north: 0.001 }; - assert.equal(meshBoxInRange(east, camera), true); - // The mirror case, from the other side. - const west = { west: 179.978, east: 180, south: -0.001, north: 0.001 }; - assert.equal(meshBoxInRange(west, { lon: -179.999, lat: 0 }), true); - // ~2.2 km away across the line: out of range either way. - const far = { west: -179.99, east: -179.97, south: -0.001, north: 0.001 }; - assert.equal(meshBoxInRange(far, camera), false); -}); - test('a tile that fails before the terrain table loads is reported at once, never unhandled', async () => { const { source, state, coverage, tileKey } = setup(); let terrainLoaded; @@ -930,60 +671,3 @@ test('a tile that fails before the terrain table loads is reported at once, neve process.off('unhandledRejection', onUnhandled); } }); - -test('lines a partial terrain lookup left draped are cast once the terrain recovers', async () => { - // Two lines: the west one's heights arrive, the east one's fail at first. - let eastUp = false; - let prepares = 0; - const groundCaster = { - prepareLines: async () => { - prepares++; - return eastUp; - }, - castLine: (coords) => - coords[0][0] < centreLon || eastUp - ? coords.flatMap(([lon, lat]) => [lon, lat, 30]) - : null, - }; - const { state, source, coverage, centre } = setup({ - surface: 'terrain', - groundCaster, - }); - const centreLon = centre.lon; - const line = (from, to) => [ - [ - [from, centre.lat], - [to, centre.lat], - ], - ]; - coverage.refresh(); - source.calls[0].resolve( - encodeCoverageTile(centre.tile, { - sequences: [ - { id: 'west', parts: line(centre.lon - 0.002, centre.lon - 0.001) }, - { id: 'east', parts: line(centre.lon + 0.001, centre.lon + 0.002) }, - ], - }), - ); - await settle(); - await settle(); - const [entry] = state.coverage.tiles.values(); - assert.equal(prepares, 1); - assert.deepEqual([entry.castLines, entry.drapedLines], [1, 1], 'partial'); - await settle(); - assert.equal(prepares, 1, 'no retry loop while the lookup fails'); - - // A refresh while still failing asks again but rebuilds nothing. - const drawn = entry.primitives; - coverage.refresh(); - await settle(); - assert.equal(prepares, 2); - assert.equal(entry.primitives, drawn, 'no rebuild without new heights'); - - eastUp = true; // the terrain proxy recovers - coverage.refresh(); - await settle(); - assert.equal(prepares, 3, 'the next refresh tries the draped line again'); - assert.deepEqual([entry.castLines, entry.drapedLines], [2, 0], 'all cast'); - coverage.clear(); -}); diff --git a/src/layers/streetLevel/providers/mapillary/index.js b/src/layers/streetLevel/providers/mapillary/index.js index 1cbe887a6..c97aacb88 100644 --- a/src/layers/streetLevel/providers/mapillary/index.js +++ b/src/layers/streetLevel/providers/mapillary/index.js @@ -143,12 +143,6 @@ export function createMapillaryProvider({ source }) { parts.sequences.rerender(); }, - /** Core surface mode changed: redraw lines and cones draped or cast. */ - setSurface() { - parts.coverage.setSurface(); - parts.sequences.rerender(); - }, - coverageStats() { return { count: parts.coverage.sequenceCount(), diff --git a/src/layers/streetLevel/providers/mapillary/sequences.js b/src/layers/streetLevel/providers/mapillary/sequences.js index 4be1f54a2..cc70f2794 100644 --- a/src/layers/streetLevel/providers/mapillary/sequences.js +++ b/src/layers/streetLevel/providers/mapillary/sequences.js @@ -1,9 +1,7 @@ import * as Cesium from 'cesium'; import { imageConeGlyph } from '../../glyphs.js'; import { passesImageryFilter } from '../../filter.js'; -import { refineHeights } from '../../groundCast.js'; import { createHorizonCull, metresBetween } from '../../view.js'; -import { meshCellKey } from '../../meshSampler.js'; import { COLORS, IMAGE_CONE_MIN_SPACING_M, @@ -58,14 +56,13 @@ export function createSequences({ state, source, parts }) { state.context.notify(); } - const nothingDrawn = () => ({ images: [], cones: [], cells: new Set() }); - /** Drawn cones, plus the mesh cells under them, so a sample moves only those. */ - let drawn = nothingDrawn(); + /** Drawn cone billboards. */ + let cones = []; // The cones skip the depth test: hide the ones behind the globe. const horizon = createHorizonCull({ getViewer: () => state.viewer, - items: () => drawn.cones.map((cone) => cone.billboard), + items: () => cones, onChange: requestRender, }); @@ -81,58 +78,15 @@ export function createSequences({ state, source, parts }) { function clearCones() { state.sequence.collection?.removeAll(); state.sequence.images = []; - drawn = nothingDrawn(); + cones = []; horizon.stop(); } - /** Where a cone stands: at `height`, or clamped to the ground when null. */ - function placement(image, height) { - return { - position: Cesium.Cartesian3.fromDegrees( - image.lon, - image.lat, - height ?? 0, - ), - heightReference: - height === null - ? Cesium.HeightReference.CLAMP_TO_GROUND - : Cesium.HeightReference.NONE, - }; - } - - /** - * Terrain-mode cone heights on the street, not on roofs: bare earth refined - * by mesh samples. Null when draped or while terrain heights are fetched. - */ - function coneHeights(images, { request = true } = {}) { - const ground = state.context.groundCaster; - if (state.context.getSurface?.() !== 'terrain' || !ground) return null; - const dems = images.map((image) => ground.groundAt(image.lon, image.lat)); - if (dems.includes(null)) { - ground - .prepare(images.map((image) => [image.lon, image.lat])) - .then((ready) => { - if (ready && state.sequence.images === images) renderCones(images); - }); - return null; - } - const sampler = state.context.meshSampler; - if (request) - sampler?.request(images.map((image) => [image.lon, image.lat])); - return refineHeights( - images.map((image, i) => ({ - dem: dems[i], - mesh: sampler?.meshAt(image.lon, image.lat), - })), - ); - } - function renderCones(images) { const collection = state.sequence.collection; if (!collection) return; collection.removeAll(); - drawn = { ...nothingDrawn(), images }; - const heights = coneHeights(images); + cones = []; // One colour per source; 360° images keep the ring shape. const cone = imageConeGlyph({ size: 32, color: COLORS.coverage }); const ring = imageConeGlyph({ @@ -142,12 +96,12 @@ export function createSequences({ state, source, parts }) { }); // Resolved now, so a "since N days" window keeps up with the clock. const filter = state.context.getFilter(); - images.forEach((image, index) => { - if (!passesImageryFilter(image, filter)) return; - const height = heights?.[index] ?? null; + for (const image of images) { + if (!passesImageryFilter(image, filter)) continue; const billboard = collection.add({ id: `${PICK_PREFIX.image}${image.id}`, - ...placement(image, height), + position: Cesium.Cartesian3.fromDegrees(image.lon, image.lat), + heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, image: image.isPano ? ring : cone, imageId: image.isPano ? 'mly-cone-pano' : 'mly-cone', width: IMAGE_CONE_SIZE_PX, @@ -158,41 +112,9 @@ export function createSequences({ state, source, parts }) { verticalOrigin: Cesium.VerticalOrigin.CENTER, scaleByDistance: new Cesium.NearFarScalar(200, 1.1, 6000, 0.35), }); - drawn.cones.push({ index, billboard, height }); - drawn.cells.add(meshCellKey(image.lon, image.lat)); - }); - horizon.update(); - requestRender(); - } - - /** - * Re-place cones whose height new mesh samples changed. A sample can move - * a neighbour too, so every height is recomputed but only moved cones touched. - */ - function onMeshSampled(batch) { - if (state.context.getSurface?.() !== 'terrain' || !drawn.cones.length) - return; - if (!batch.some(([lon, lat]) => drawn.cells.has(meshCellKey(lon, lat)))) - return; - const { images, cones } = drawn; - // Already asked for when drawn; a missing terrain cell redraws them all. - const heights = coneHeights(images, { request: false }); - if (!heights) return; - const moved = []; - for (const cone of cones) { - const height = heights[cone.index] ?? null; - if (height === cone.height) continue; - cone.height = height; - const { position, heightReference } = placement( - images[cone.index], - height, - ); - cone.billboard.heightReference = heightReference; - cone.billboard.position = position; - moved.push(cone.billboard); + cones.push(billboard); } - if (!moved.length) return; - horizon.update(moved); + horizon.update(); requestRender(); } @@ -288,9 +210,6 @@ export function createSequences({ state, source, parts }) { if (state.sequence.images.length) renderCones(state.sequence.images); } - // New mesh samples may move the cones onto (or off) the road surface. - const stopMeshListener = state.context.meshSampler?.onSampled(onMeshSampled); - function setVisible(visible) { if (state.sequence.collection) state.sequence.collection.show = visible; requestRender(); @@ -299,8 +218,7 @@ export function createSequences({ state, source, parts }) { function destroy(viewer) { state.sequence.abort?.abort(); horizon.stop(); - drawn = nothingDrawn(); - stopMeshListener?.(); + cones = []; const collection = state.sequence.collection; if (collection) { sprites?.unregisterSpriteCollection?.(SPRITE_ID, collection); diff --git a/src/layers/streetLevel/providers/mapillary/sequences.test.mjs b/src/layers/streetLevel/providers/mapillary/sequences.test.mjs index e29f05a92..d644cfc87 100644 --- a/src/layers/streetLevel/providers/mapillary/sequences.test.mjs +++ b/src/layers/streetLevel/providers/mapillary/sequences.test.mjs @@ -1,7 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import * as Cesium from 'cesium'; -import { MESH_CELL_DEG } from '../../meshSampler.js'; import { createSequences } from './sequences.js'; // The cone glyphs draw on a canvas; Node has none, so any drawing is a no-op. @@ -66,9 +65,6 @@ function setup() { context: { notify() {}, getFilter: () => ({ pano: 'all', sinceMs: null }), - getSurface: () => 'draped', - groundCaster: null, - meshSampler: null, actions: { reportError: (message) => errors.push(message) }, }, sequence: { @@ -173,112 +169,17 @@ test('clearSelection cancels the lookup and uncolours the sequence', async () => assert.deepEqual(drawn(), [], 'the late answer draws nothing'); }); -/** - * Terrain mode with bare earth at 10 m, a mesh sampler the test drives, and - * cones that log every change made to them. - */ -function terrainSetup() { - const context = setup(); - const { state } = context; - const mesh = new Map(); - const cell = (lon, lat) => - `${Math.round(lon / MESH_CELL_DEG)},${Math.round(lat / MESH_CELL_DEG)}`; - let announce = null; - state.context.getSurface = () => 'terrain'; - state.context.groundCaster = { - groundAt: () => 10, - prepare: async () => true, - }; - state.context.meshSampler = { - request() {}, - meshAt: (lon, lat) => mesh.get(cell(lon, lat)), - onSampled: (listener) => { - announce = listener; - return () => (announce = null); - }, - }; - const changes = []; - const items = []; - state.sequence.collection = { - items, - show: true, - add(options) { - changes.push(['add', options.id]); - const cone = new Proxy(options, { - set(target, key, value) { - changes.push([key, target.id]); - target[key] = value; - return true; - }, - }); - items.push(cone); - return cone; - }, - removeAll() { - changes.push(['removeAll']); - items.length = 0; - }, - }; - // Created again, so it listens to this sampler. - const sequences = createSequences({ - state, - source: context.source, - parts: { coverage: { recolorSequence() {} } }, - }); - /** Sample the mesh under a point, then announce it as the sampler does. */ - function sample(lon, lat, height) { - const centre = [ - Math.round(lon / MESH_CELL_DEG) * MESH_CELL_DEG, - Math.round(lat / MESH_CELL_DEG) * MESH_CELL_DEG, - ]; - mesh.set(cell(lon, lat), height); - announce([centre]); +test('cones are clamped to the ground and turned to their compass angle', async () => { + const { state, source, sequences } = setup(); + sequences.select('A'); + source.last('A').resolve(records('A', 2)); + await settle(); + const items = state.sequence.collection.items; + assert.equal(items.length, 2); + for (const cone of items) { + assert.equal(cone.heightReference, Cesium.HeightReference.CLAMP_TO_GROUND); + assert.equal(cone.rotation, -Cesium.Math.toRadians(90)); } - const heightOf = (cone) => - Cesium.Cartographic.fromCartesian(cone.position).height; - return { ...context, sequences, changes, items, sample, heightOf }; -} - -test('a mesh sample away from the selected sequence changes no cone', async () => { - const { source, sequences, changes, items, sample } = terrainSetup(); - sequences.select('A'); - source.last('A').resolve(records('A', 3)); - await settle(); - assert.equal(items.length, 3); - changes.length = 0; - sample(-121.3, 38.7, 12); // a street elsewhere in the tile - assert.deepEqual(changes, [], 'nothing rebuilt or moved'); -}); - -test('a mesh sample under a cone moves that cone in place, and no other', async () => { - const { source, sequences, changes, items, sample, heightOf } = - terrainSetup(); - sequences.select('A'); - source.last('A').resolve(records('A', 3)); - await settle(); - const before = items.map(heightOf); - changes.length = 0; - // The road under the second image is 2.5 m above the bare earth. - sample(-121.489, 38.58, 12.5); - assert.ok( - changes.every(([, id]) => id === 'mly:img:A-1'), - `only A-1 changed: ${JSON.stringify(changes)}`, - ); - assert.ok( - changes.some(([key]) => key === 'position'), - 'A-1 moved', - ); - assert.equal( - changes.some(([key]) => key === 'removeAll' || key === 'add'), - false, - 'the collection is not rebuilt', - ); - const after = items.map(heightOf); - assert.ok( - Math.abs(after[1] - 13.5) < 0.01, - `A-1 stands on the road (${after[1]})`, - ); - assert.deepEqual([after[0], after[2]], [before[0], before[2]]); }); test('cones behind the globe are hidden, and show again from the other side', async () => { diff --git a/src/layers/streetLevel/registry.js b/src/layers/streetLevel/registry.js index 01d008ab4..53feaf15b 100644 --- a/src/layers/streetLevel/registry.js +++ b/src/layers/streetLevel/registry.js @@ -12,12 +12,9 @@ * @property {(context: ProviderContext) => ProviderInstance} create * * @typedef {object} ProviderContext Handed to `create()` once by the core. - * @property {object} services Scene services: picking, input, render, sprites, ground, terrain. + * @property {object} services Scene services: picking, input, render, sprites. * @property {() => {pano: string, sinceMs: number|null}} getFilter Filter resolved against now; read it on every use so "since N days" keeps up. * @property {() => boolean} isActive Layer enabled and this provider switched on. - * @property {() => 'draped'|'terrain'} getSurface 'terrain' on Google 3D at street zoom: draw on bare earth. - * @property {GroundCaster|null} groundCaster Bare-earth heights for terrain mode (groundCast.js); null without a terrain service. - * @property {{meshAt: (lon: number, lat: number) => number|undefined, request: (points: Array<[number, number]>) => void, onSampled: (listener: (cells: Array<[number, number]>) => void) => () => void}|null} meshSampler Sampled Google 3D surface heights (meshSampler.js); pass `meshAt` to `castLine`. * @property {() => void} notify Ask the core to publish a new UI snapshot. * @property {{openImage: (imageId: string) => Promise, reportError: (message: string|null) => void}} actions * @@ -29,7 +26,6 @@ * @property {(viewer: object) => void} destroy * @property {() => void} refreshCoverage * @property {(filter: {pano: string, sinceMs: number|null}) => void} setFilter The filter changed: redraw (reading `getFilter()`). - * @property {(mode: 'draped'|'terrain') => void} [setSurface] Redraw coverage and cones for the new surface mode. * @property {() => {count: number, zoom: number|null, kind: string|null, loading: boolean, hint: string, error: string|null, keyRequired: boolean}} coverageStats * @property {(pickId: string) => boolean} handlePick The id carries the provider's own prefix. * @property {(sequenceId: string) => Promise} [selectSequence] @@ -38,12 +34,6 @@ * @property {(point: {lat: number, lon: number}, options?: {signal?: AbortSignal}) => Promise} nearestImage An aborted lookup gives up (it may reject with an AbortError). * @property {ViewerAdapter} viewer * - * @typedef {object} GroundCaster - * @property {(points: Array<[number, number]>, options?: {signal?: AbortSignal}) => Promise} prepare Fetch the heights around [lon, lat] points; true when all are known. - * @property {(lines: Array>, options?: {signal?: AbortSignal}) => Promise} prepareLines - * @property {(lon: number, lat: number) => number|null} groundAt Cached bare-earth ellipsoidal height. - * @property {(coords: Array<[number, number]>) => Array|null} castLine Densified [lon, lat, height, ...] or null. - * * @typedef {object} ViewerAdapter * @property {(host: HTMLElement) => Promise} mount Idempotent; may lazy-load a library. * @property {(imageId: string) => Promise} open Resolves once the image is on screen and a pose was emitted. diff --git a/src/layers/streetLevel/state.js b/src/layers/streetLevel/state.js index be5e33bd3..44340ddf7 100644 --- a/src/layers/streetLevel/state.js +++ b/src/layers/streetLevel/state.js @@ -2,12 +2,11 @@ import { FILTER_DEFAULT } from './policy.js'; /** * The per-image fields of `state.street` with no image open; closing resets - * exactly these (host, render mode and follow availability outlive an image). + * exactly these (host and render mode outlive an image). */ export function freshStreet() { return { open: false, - follow: false, providerId: null, providerName: null, providerLabel: null, @@ -43,16 +42,11 @@ export function createState({ services }) { street: { host: null, - /** Whether the active map stack allows following (Google 3D only). */ - followAvailable: false, /** 'letterbox' shows the whole image; 'fill' crops it to the frame. */ renderMode: 'letterbox', ...freshStreet(), }, - /** 'terrain' on Google 3D at street zoom (overlays on the bare earth), else 'draped'. */ - surface: 'draped', - marker: { collection: null, billboard: null }, clickHandler: null, }; diff --git a/src/layers/streetLevel/uiState.js b/src/layers/streetLevel/uiState.js index 695c87653..5d8fbd02b 100644 --- a/src/layers/streetLevel/uiState.js +++ b/src/layers/streetLevel/uiState.js @@ -49,7 +49,6 @@ export function composeUIState({ providers, street, sequence, - surface = 'draped', }) { const active = providers.filter((p) => p.on); const { keyRequired, keyRejected, ...coverage } = @@ -71,6 +70,5 @@ export function composeUIState({ legend, sequence: { ...sequence }, street: { ...street }, - surface, }; } diff --git a/src/layers/streetLevel/uiState.test.mjs b/src/layers/streetLevel/uiState.test.mjs index 1b4d09eaf..ea2add40b 100644 --- a/src/layers/streetLevel/uiState.test.mjs +++ b/src/layers/streetLevel/uiState.test.mjs @@ -137,15 +137,3 @@ test('a rejected key gates like a missing one and is named as rejected', () => { false, ); }); - -test('the surface mode defaults to draped and passes through', () => { - assert.equal( - composeUIState({ ...base, providers: [provider()] }).surface, - 'draped', - ); - assert.equal( - composeUIState({ ...base, providers: [provider()], surface: 'terrain' }) - .surface, - 'terrain', - ); -}); diff --git a/src/layers/streetLevel/view.js b/src/layers/streetLevel/view.js index 09bc516ed..7685ebd08 100644 --- a/src/layers/streetLevel/view.js +++ b/src/layers/streetLevel/view.js @@ -21,31 +21,17 @@ export function whenIdle(task, timeout) { } /** - * Ellipsoidal height (m) of the surface under the camera: globe terrain, or - * `groundAt` where Google 3D hides the globe; null without a sample. - * @param {object} viewer - * @param {{groundAt?: (lon: number, lat: number) => number|null}} [options] + * Ellipsoidal height (m) of the globe terrain under the camera; null where + * the globe is hidden (Google 3D) or not yet loaded. */ -export function groundUnderCamera(viewer, { groundAt } = {}) { +export function groundUnderCamera(viewer) { const carto = viewer?.camera?.positionCartographic; - if (!carto) return null; - const globe = viewer.scene?.globe; - let ground = globe?.show === false ? null : globe?.getHeight?.(carto); - if (!Number.isFinite(ground)) - ground = groundAt?.( - Cesium.Math.toDegrees(carto.longitude), - Cesium.Math.toDegrees(carto.latitude), - ); + const globe = viewer?.scene?.globe; + if (!carto || !globe || globe.show === false) return null; + const ground = globe.getHeight?.(carto); return Number.isFinite(ground) ? ground : null; } -/** Camera height (m) above the ground under it (options as `groundUnderCamera`). */ -export function cameraHeightAboveGround(viewer, options = {}) { - const carto = viewer?.camera?.positionCartographic; - if (!carto) return null; - return carto.height - (groundUnderCamera(viewer, options) ?? 0); -} - /** The ellipsoid `height` metres above `ellipsoid` (the ground at that height). */ function raisedEllipsoid(ellipsoid, height) { if (!Number.isFinite(height) || Math.abs(height) < 1) return ellipsoid; @@ -77,9 +63,9 @@ function groundHit(camera, point, ellipsoid, maxRange) { /** * Visible [west, south, east, north] degrees, or null. Only screen rays that * hit count, so a horizon cannot inflate the box to the world. - * `groundHeight` raises the ellipsoid to the ground (Google 3D hides the - * globe), `maxRange` (m) drops near-horizon hits, and `nearRange` (m) always - * includes the ground around the camera. + * `groundHeight` raises the ellipsoid to the ground, `maxRange` (m) drops + * near-horizon hits, and `nearRange` (m) always includes the ground around + * the camera. */ export function visibleBbox( viewer, diff --git a/src/layers/streetLevel/view.test.mjs b/src/layers/streetLevel/view.test.mjs index 0f9c7d2a5..5a78ead69 100644 --- a/src/layers/streetLevel/view.test.mjs +++ b/src/layers/streetLevel/view.test.mjs @@ -2,8 +2,8 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import * as Cesium from 'cesium'; import { - cameraHeightAboveGround, createHorizonCull, + groundUnderCamera, metresBetween, viewCentre, viewFocus, @@ -28,28 +28,22 @@ function viewer({ globeShown, globeHeight }) { test('a shown globe answers the ground under the camera', () => { assert.equal( - cameraHeightAboveGround(viewer({ globeShown: true, globeHeight: 1600 })), - 300, + groundUnderCamera(viewer({ globeShown: true, globeHeight: 1600 })), + 1600, ); }); -test('a hidden globe (Google 3D) falls back to the bare-earth height', () => { - const calls = []; - const groundAt = (lon, lat) => { - calls.push([+lon.toFixed(2), +lat.toFixed(2)]); - return 1600; - }; +test('a hidden globe (Google 3D) or a missing sample gives no ground', () => { // A hidden globe's getHeight is ignored even when it returns a number. - const hidden = viewer({ globeShown: false, globeHeight: 0 }); - assert.equal(cameraHeightAboveGround(hidden, { groundAt }), 300); - assert.deepEqual(calls, [[-104.99, 39.74]]); -}); - -test('with no ground sample the ellipsoidal height is used', () => { - const hidden = viewer({ globeShown: false, globeHeight: 0 }); - assert.equal(cameraHeightAboveGround(hidden), 1900); - assert.equal(cameraHeightAboveGround(hidden, { groundAt: () => null }), 1900); - assert.equal(cameraHeightAboveGround({}), null); + assert.equal( + groundUnderCamera(viewer({ globeShown: false, globeHeight: 0 })), + null, + ); + assert.equal( + groundUnderCamera(viewer({ globeShown: true, globeHeight: undefined })), + null, + ); + assert.equal(groundUnderCamera({}), null); }); /** A camera whose screen rays land on a grid of lon/lat points. */ @@ -285,7 +279,7 @@ function cullFrom(camera, points) { } test('cones and the marker stay visible on ground below the WGS84 ellipsoid', () => { - // NYC in FOLLOW: eye 2.4 m above ground at -22 m, a cone 10 m away. + // NYC at street level: eye 2.4 m above ground at -22 m, a cone 10 m away. assert.deepEqual( cullFrom([-74.006, 40.7128, -19.6], [[-74.006, 40.71289, -22]]), [true], diff --git a/src/layers/streetLevel/viewerHost.js b/src/layers/streetLevel/viewerHost.js index 98c9e0a41..56a83d04f 100644 --- a/src/layers/streetLevel/viewerHost.js +++ b/src/layers/streetLevel/viewerHost.js @@ -2,7 +2,7 @@ import { freshStreet } from './state.js'; /** * Owns the panel's viewer element and the provider adapter mounted in it. - * Adapter poses update `state.street`, the marker and the follow camera. + * Adapter poses update `state.street` and the marker. */ export function createViewerHost({ state, parts }) { /** @type {{id: string, adapter: object, unsubscribe: () => void}|null} */ @@ -38,7 +38,6 @@ export function createViewerHost({ state, parts }) { externalUrl: pose.externalUrl || null, }); parts.marker.set(state.street.position, state.street.bearing); - parts.follow.followCamera(); // Select the sequence only once the image is on screen, so its lookup // never competes with the image download. if (!state.street.loading && state.street.sequenceId !== previousSequence) @@ -101,7 +100,7 @@ export function createViewerHost({ state, parts }) { } /** Open an image: true once its first pose is in, false if it failed or was overtaken. */ - async function open(providerId, imageId, { frame = true } = {}) { + async function open(providerId, imageId) { const entry = providerEntry(providerId); if (!entry || !imageId) return false; if (!state.street.host) { @@ -114,8 +113,7 @@ export function createViewerHost({ state, parts }) { const seq = ++openSeq; const current = () => seq === openSeq; // Claimed now, honoured after loading only if nothing newer took the camera. - const ticket = - frame && !state.street.follow ? parts.follow.beginFraming() : null; + const ticket = parts.framing.begin(); Object.assign(state.street, { loading: true, error: null, @@ -130,7 +128,7 @@ export function createViewerHost({ state, parts }) { if (!current()) return false; await adapter.open(String(imageId)); if (!current()) return false; - if (ticket && !state.street.follow) parts.follow.lookAtPosition(ticket); + if (ticket) parts.framing.frame(ticket); } catch (error) { if (current()) state.street.error = error?.message || 'Image could not be opened'; @@ -146,7 +144,7 @@ export function createViewerHost({ state, parts }) { /** Close the image and stop any framing flight; the adapter stays warm. */ function close() { openSeq++; - parts.follow.cancelFraming(); + parts.framing.cancel(); active?.adapter.close(); Object.assign(state.street, freshStreet()); parts.marker.clear(); diff --git a/src/layers/streetLevel/viewerHost.test.mjs b/src/layers/streetLevel/viewerHost.test.mjs index 76830b1ae..a2d7ccf2b 100644 --- a/src/layers/streetLevel/viewerHost.test.mjs +++ b/src/layers/streetLevel/viewerHost.test.mjs @@ -32,7 +32,7 @@ const { createMapillaryViewer } = /** * A viewer host over `adapter`. `marker` records where the position marker - * was put (null once cleared), `followed` counts camera-follow updates and + * was put (null once cleared), `framing` counts framing calls and * `selected` every sequence the provider was asked to select. */ function harness(adapter) { @@ -40,7 +40,7 @@ function harness(adapter) { enabled: true, notify() {}, providers: new Map(), - street: { host: {}, renderMode: 'letterbox', follow: false }, + street: { host: {}, renderMode: 'letterbox' }, }; const def = { id: 'mapillary', name: 'Mapillary', label: 'MAPILLARY' }; const selected = []; @@ -56,25 +56,22 @@ function harness(adapter) { state.providers.set('mapillary', { def, instance }); const framing = { begun: 0, started: 0, cancelled: 0 }; const marker = []; - const followed = { count: 0 }; const parts = { marker: { set: (position, bearing) => marker.push(position && { position, bearing }), clear: () => marker.push(null), }, - follow: { - followCamera: () => followed.count++, - beginFraming: () => ({ generation: ++framing.begun }), - lookAtPosition: (ticket) => ticket && framing.started++, - cancelFraming: () => framing.cancelled++, + framing: { + begin: () => ({ generation: ++framing.begun }), + frame: (ticket) => ticket && framing.started++, + cancel: () => framing.cancelled++, }, }; return { state, framing, marker, - followed, selected, /** Esc on the map: the provider drops its selected sequence. */ clearSequence() { @@ -258,24 +255,21 @@ test('switching the layer off and on during a cold first open keeps the shared v const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); -test('every pose the viewer reports moves the marker and the followed camera', async () => { +test('every pose the viewer reports moves the marker; only the open frames the camera', async () => { const adapter = fakeAdapter(); - const { state, host, marker, followed } = harness(adapter); + const { host, marker, framing } = harness(adapter); await host.open('mapillary', 'a'); assert.deepEqual(marker, [{ position: { lon: 1, lat: 2 }, bearing: 90 }]); - assert.equal(followed.count, 1); - // The user walks on, with FOLLOW on: the globe camera goes along. - state.street.follow = true; + // The user walks on: the marker goes along, the globe camera stays. adapter.emitPose('b', { position: { lon: 3, lat: 4 }, bearing: 180 }); assert.deepEqual(marker.at(-1), { position: { lon: 3, lat: 4 }, bearing: 180, }); - assert.equal(followed.count, 2); // Looking around in place is a pose too. adapter.emitPose('b', { position: { lon: 3, lat: 4 }, bearing: 270 }); assert.equal(marker.at(-1).bearing, 270); - assert.equal(followed.count, 3); + assert.deepEqual([framing.begun, framing.started], [1, 1]); }); test('a pose on another sequence selects it, but not while the image still loads', async () => { @@ -562,7 +556,7 @@ test('back-to-back Mapillary opens download only the newer image', async () => { test('a photo asks for the camera when it starts opening and frames with that ticket once loaded', async () => { const openGate = deferred(); const adapter = fakeAdapter({ openGate }); - const { state, host, framing } = harness(adapter); + const { host, framing } = harness(adapter); const opening = host.open('mapillary', 'a'); assert.equal(framing.begun, 1, 'claimed before the download'); await settle(); @@ -570,9 +564,4 @@ test('a photo asks for the camera when it starts opening and frames with that ti openGate.resolve(); assert.equal(await opening, true); assert.equal(framing.started, 1); - - // FOLLOW owns the camera already: an open neither claims nor frames. - state.street.follow = true; - assert.equal(await host.open('mapillary', 'b'), true); - assert.deepEqual([framing.begun, framing.started], [1, 1]); }); diff --git a/src/services/meshFloorSampler.js b/src/services/meshFloorSampler.js index b1cf65f29..0faee939d 100644 --- a/src/services/meshFloorSampler.js +++ b/src/services/meshFloorSampler.js @@ -195,9 +195,5 @@ export function createMeshFloorSampler({ () => eventTarget?.removeEventListener('gev:map-stack-changed', onStack), { once: true }, ); - return { - sampleMeshFloorCells, - cachedGroundFloor, - visibleTilesetLoaded: _visibleTilesetLoaded, - }; + return { sampleMeshFloorCells, cachedGroundFloor }; } diff --git a/src/tooling/streetLevelQa.test.mjs b/src/tooling/streetLevelQa.test.mjs index 48dbf5560..942839040 100644 --- a/src/tooling/streetLevelQa.test.mjs +++ b/src/tooling/streetLevelQa.test.mjs @@ -29,7 +29,6 @@ test('the harness expects exactly the providers the app registers, in order', () // Any method a provider asks its source for is a no-op: only ids matter. const source = new Proxy({}, { get: () => () => {} }); const layer = createApplicationStreetLevel({ - surface: null, sources: { mapillary: source }, }); assert.deepEqual([...EXPECTED_PROVIDERS], [...layer.providerIds]); @@ -330,13 +329,9 @@ test('evidence never carries a Mapillary token', () => { }); test('--strict accepts only the documented skips', () => { - assert.deepEqual( - [...STRICT_ALLOWED_SKIPS], - ['no Google 3D', 'fixtures only'], - ); + assert.deepEqual([...STRICT_ALLOWED_SKIPS], ['fixtures only']); assert.deepEqual( strictViolations([ - { reason: 'no Google 3D', label: 'terrain' }, { reason: 'fixtures only', label: 'keyless page' }, { reason: 'no Mapillary key', label: 'the keyed steps' }, ]), diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index a9ba8022d..9a03482eb 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -938,13 +938,11 @@ 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. + // Photo framing yields to newer navigation and releases tracking like + // any deferred flight. streetLevelLayer.attachNavigation?.({ - run: (noun, move) => this._runExplicitNavigation(noun, move), begin: (noun) => this._beginDeferredNavigation(noun), reassert: (generation) => this._reassertNavigationHandoff(generation), - subscribeHandoff: (listener) => - this._navigation.subscribeCameraHandoff(listener), }); this._streetLevelControls = new StreetLevelControls({ root: this._streetLevelPanel, diff --git a/src/ui/streetLevelControls.test.mjs b/src/ui/streetLevelControls.test.mjs index dd28ae794..e81129a65 100644 --- a/src/ui/streetLevelControls.test.mjs +++ b/src/ui/streetLevelControls.test.mjs @@ -394,7 +394,6 @@ function uiState({ imageId: open ? 'img-1' : null, error: null, }, - surface: 'draped', }; }