mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
refactor(street-level): drop FOLLOW and Google 3D ground casting
FOLLOW is gone: no setFollow, follow state, map-stack availability or handoff listener. Opening a photo still frames it with one flight through the application's deferred navigation (begin/reassert), so a slow photo never overrides newer navigation and tracking is released as before. Ground casting is gone: groundCast.js, meshSampler.js, the surface mode, the draped-to-cast swap and remesh. Coverage lines are always draped GroundPolylinePrimitives (ClassificationType.BOTH); cones and the marker clamp to the ground and keep the horizon cull. The terrain/meshFloor/ground services and meshFloor.visibleTilesetLoaded are no longer needed. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
7dec7883aa
commit
0f41fb04cd
37 files changed
+430
-3323
No files matched your search
+2
-2
@@ -7,8 +7,8 @@
|
|||||||
imagery). One right-rail panel holds an ON/OFF pill, shared 360°/flat and
|
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
|
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
|
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
|
down to a single street, draped on the terrain and on Google 3D tiles;
|
||||||
against the rendered mesh near the camera, so trees and buildings hide them.
|
opening a photo frames it with one camera flight.
|
||||||
Share links carry the provider switches, the filters and the panel's collapsed
|
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),
|
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
|
refuses cross-site requests, rate-limits per IP and backs off when Mapillary
|
||||||
|
|||||||
+12
-22
@@ -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
|
tiles (12 MB → ~80 KB) and shares one upstream fetch between concurrent
|
||||||
requests for a tile.
|
requests for a tile.
|
||||||
|
|
||||||
On Google 3D (`photoreal`) at street zoom (in below 1,400 m above ground, out
|
Sequence lines are `GroundPolylinePrimitive`s with
|
||||||
above 1,800 m) the surface mode switches from `draped` to `terrain`: draped
|
`ClassificationType.BOTH`, so they drape on the terrain globe and on Google 3D
|
||||||
lines land on roofs and tree tops, so `groundCast.js` instead places lines,
|
tiles alike (on 3D tiles they can land on roofs and tree tops). Cones and the
|
||||||
cones and the marker 2 m above the bare earth from `/api/terrain/heights`, and
|
photo marker are billboards clamped to the ground; they skip the depth test, so
|
||||||
the mesh hides what is behind buildings and trees. A tile is drawn draped until
|
a horizon cull hides the ones behind the globe. Roads on elevated decks are
|
||||||
its heights arrive; a geoid fallback leaves it draped. Within 900 m of the
|
drawn at ground level.
|
||||||
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).
|
|
||||||
|
|
||||||
The header pill is the only layer switch; switching on also turns back on a
|
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
|
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
|
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
|
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
|
leaves; the button is hidden where the API is missing). Opening a photo flies
|
||||||
button; the layer's `setFollow` has no panel control. Camera moves go through
|
the globe camera once to frame it; there is no camera follow. The flight goes
|
||||||
the application's navigation (`attachNavigation`), which releases aircraft and
|
through the application's deferred navigation (`attachNavigation`: `begin` /
|
||||||
satellite tracking and is refused in the cockpit; a photo takes a deferred
|
`reassert`), which releases aircraft and satellite tracking and is refused in
|
||||||
ticket when it starts opening and frames only if nothing newer took the camera
|
the cockpit; a photo takes its ticket when it starts opening and frames only
|
||||||
while it loaded. SINCE is a stepped slider whose readout names the
|
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
|
cut-off date. At phone width (≤720 px) the viewer height is derived from the
|
||||||
rail band so the whole photo fits.
|
rail band so the whole photo fits.
|
||||||
|
|
||||||
|
|||||||
@@ -871,11 +871,9 @@
|
|||||||
"src/layers/streetLevel/params.js",
|
"src/layers/streetLevel/params.js",
|
||||||
"src/layers/streetLevel/pickRouter.js",
|
"src/layers/streetLevel/pickRouter.js",
|
||||||
"src/layers/streetLevel/uiState.js",
|
"src/layers/streetLevel/uiState.js",
|
||||||
"src/layers/streetLevel/groundCast.js",
|
|
||||||
"src/layers/streetLevel/meshSampler.js",
|
|
||||||
"src/layers/streetLevel/view.js",
|
"src/layers/streetLevel/view.js",
|
||||||
"src/layers/streetLevel/marker.js",
|
"src/layers/streetLevel/marker.js",
|
||||||
"src/layers/streetLevel/cameraFollow.js",
|
"src/layers/streetLevel/cameraFraming.js",
|
||||||
"src/layers/streetLevel/credits.js",
|
"src/layers/streetLevel/credits.js",
|
||||||
"src/layers/streetLevel/viewerHost.js",
|
"src/layers/streetLevel/viewerHost.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/index.js",
|
"src/layers/streetLevel/providers/mapillary/index.js",
|
||||||
@@ -904,14 +902,12 @@
|
|||||||
"src/layers/streetLevel/params.test.mjs",
|
"src/layers/streetLevel/params.test.mjs",
|
||||||
"src/layers/streetLevel/pickRouter.test.mjs",
|
"src/layers/streetLevel/pickRouter.test.mjs",
|
||||||
"src/layers/streetLevel/uiState.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/view.test.mjs",
|
||||||
"src/layers/streetLevel/providers/mapillary/provider.test.mjs",
|
"src/layers/streetLevel/providers/mapillary/provider.test.mjs",
|
||||||
"src/layers/streetLevel/providers/mapillary/decode.test.mjs",
|
"src/layers/streetLevel/providers/mapillary/decode.test.mjs",
|
||||||
"src/layers/streetLevel/providers/mapillary/coverage.test.mjs",
|
"src/layers/streetLevel/providers/mapillary/coverage.test.mjs",
|
||||||
"src/layers/streetLevel/providers/mapillary/coverageFixture.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/ui/streetLevelControls.test.mjs",
|
||||||
"src/layers/streetLevel/marker.test.mjs",
|
"src/layers/streetLevel/marker.test.mjs",
|
||||||
"src/layers/streetLevel/viewerHost.test.mjs",
|
"src/layers/streetLevel/viewerHost.test.mjs",
|
||||||
|
|||||||
@@ -1186,12 +1186,10 @@
|
|||||||
"src/layers/streetLevel/providers/mapillary/index.js",
|
"src/layers/streetLevel/providers/mapillary/index.js",
|
||||||
"src/layers/streetLevel/viewerHost.js",
|
"src/layers/streetLevel/viewerHost.js",
|
||||||
"src/layers/streetLevel/credits.js",
|
"src/layers/streetLevel/credits.js",
|
||||||
"src/layers/streetLevel/cameraFollow.js",
|
"src/layers/streetLevel/cameraFraming.js",
|
||||||
"src/layers/streetLevel/marker.js",
|
"src/layers/streetLevel/marker.js",
|
||||||
"src/layers/streetLevel/view.js",
|
"src/layers/streetLevel/view.js",
|
||||||
"src/layers/streetLevel/uiState.js",
|
"src/layers/streetLevel/uiState.js",
|
||||||
"src/layers/streetLevel/groundCast.js",
|
|
||||||
"src/layers/streetLevel/meshSampler.js",
|
|
||||||
"src/layers/streetLevel/pickRouter.js",
|
"src/layers/streetLevel/pickRouter.js",
|
||||||
"src/layers/streetLevel/params.js",
|
"src/layers/streetLevel/params.js",
|
||||||
"src/layers/streetLevel/registry.js",
|
"src/layers/streetLevel/registry.js",
|
||||||
@@ -2105,12 +2103,10 @@
|
|||||||
"src/layers/streetLevel/providers/mapillary/index.js",
|
"src/layers/streetLevel/providers/mapillary/index.js",
|
||||||
"src/layers/streetLevel/viewerHost.js",
|
"src/layers/streetLevel/viewerHost.js",
|
||||||
"src/layers/streetLevel/credits.js",
|
"src/layers/streetLevel/credits.js",
|
||||||
"src/layers/streetLevel/cameraFollow.js",
|
"src/layers/streetLevel/cameraFraming.js",
|
||||||
"src/layers/streetLevel/marker.js",
|
"src/layers/streetLevel/marker.js",
|
||||||
"src/layers/streetLevel/view.js",
|
"src/layers/streetLevel/view.js",
|
||||||
"src/layers/streetLevel/uiState.js",
|
"src/layers/streetLevel/uiState.js",
|
||||||
"src/layers/streetLevel/groundCast.js",
|
|
||||||
"src/layers/streetLevel/meshSampler.js",
|
|
||||||
"src/layers/streetLevel/pickRouter.js",
|
"src/layers/streetLevel/pickRouter.js",
|
||||||
"src/layers/streetLevel/params.js",
|
"src/layers/streetLevel/params.js",
|
||||||
"src/layers/streetLevel/registry.js",
|
"src/layers/streetLevel/registry.js",
|
||||||
@@ -2626,12 +2622,10 @@
|
|||||||
"src/layers/streetLevel/providers/mapillary/index.js",
|
"src/layers/streetLevel/providers/mapillary/index.js",
|
||||||
"src/layers/streetLevel/viewerHost.js",
|
"src/layers/streetLevel/viewerHost.js",
|
||||||
"src/layers/streetLevel/credits.js",
|
"src/layers/streetLevel/credits.js",
|
||||||
"src/layers/streetLevel/cameraFollow.js",
|
"src/layers/streetLevel/cameraFraming.js",
|
||||||
"src/layers/streetLevel/marker.js",
|
"src/layers/streetLevel/marker.js",
|
||||||
"src/layers/streetLevel/view.js",
|
"src/layers/streetLevel/view.js",
|
||||||
"src/layers/streetLevel/uiState.js",
|
"src/layers/streetLevel/uiState.js",
|
||||||
"src/layers/streetLevel/groundCast.js",
|
|
||||||
"src/layers/streetLevel/meshSampler.js",
|
|
||||||
"src/layers/streetLevel/pickRouter.js",
|
"src/layers/streetLevel/pickRouter.js",
|
||||||
"src/layers/streetLevel/params.js",
|
"src/layers/streetLevel/params.js",
|
||||||
"src/layers/streetLevel/registry.js",
|
"src/layers/streetLevel/registry.js",
|
||||||
|
|||||||
@@ -45,14 +45,11 @@ export const VIEWPORTS = Object.freeze([
|
|||||||
export const EXPECTED_PROVIDERS = Object.freeze(['mapillary']);
|
export const EXPECTED_PROVIDERS = Object.freeze(['mapillary']);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Skips no setup can avoid: CI forks have no Google key, and live runs cannot
|
* Skips no setup can avoid: live runs cannot stage fixture-only cases.
|
||||||
* stage fixture-only cases. `no Mapillary key` is not accepted: CI's dummy
|
* `no Mapillary key` is not accepted: CI's dummy token runs the keyed flow,
|
||||||
* token runs the keyed flow, so a keyless strict run means that wiring broke.
|
* so a keyless strict run means that wiring broke.
|
||||||
*/
|
*/
|
||||||
export const STRICT_ALLOWED_SKIPS = Object.freeze([
|
export const STRICT_ALLOWED_SKIPS = Object.freeze(['fixtures only']);
|
||||||
'no Google 3D',
|
|
||||||
'fixtures only',
|
|
||||||
]);
|
|
||||||
|
|
||||||
export function strictViolations(skips) {
|
export function strictViolations(skips) {
|
||||||
return skips.filter((skip) => !STRICT_ALLOWED_SKIPS.includes(skip.reason));
|
return skips.filter((skip) => !STRICT_ALLOWED_SKIPS.includes(skip.reason));
|
||||||
@@ -767,12 +764,6 @@ async function main() {
|
|||||||
page.evaluate(() =>
|
page.evaluate(() =>
|
||||||
window.__godsEyeView.mapStackController.getActiveId(),
|
window.__godsEyeView.mapStackController.getActiveId(),
|
||||||
),
|
),
|
||||||
photoreal: () =>
|
|
||||||
page.evaluate(() =>
|
|
||||||
window.__godsEyeView.mapStackController.isStackAvailable(
|
|
||||||
'photoreal',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
set: async (id) => {
|
set: async (id) => {
|
||||||
await page.evaluate(
|
await page.evaluate(
|
||||||
(stackId) =>
|
(stackId) =>
|
||||||
@@ -1041,7 +1032,6 @@ async function main() {
|
|||||||
const original = await stacks.active();
|
const original = await stacks.active();
|
||||||
// Draped lines on flat imagery: the line is where it is drawn.
|
// Draped lines on flat imagery: the line is where it is drawn.
|
||||||
await stacks.set('esri-imagery');
|
await stacks.set('esri-imagery');
|
||||||
await uiUntil((u) => u.surface === 'draped');
|
|
||||||
await waitForCoverage(page);
|
await waitForCoverage(page);
|
||||||
const target = fixtures ? PHOTO_SEQUENCE_ID : null;
|
const target = fixtures ? PHOTO_SEQUENCE_ID : null;
|
||||||
const along = Array.from({ length: 9 }, (_, i) => [
|
const along = Array.from({ length: 9 }, (_, i) => [
|
||||||
@@ -1295,49 +1285,6 @@ async function main() {
|
|||||||
assert.equal((await ui()).street.open, false);
|
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(
|
await step(
|
||||||
'× closes the image and deselects it on the globe',
|
'× closes the image and deselects it on the globe',
|
||||||
async () => {
|
async () => {
|
||||||
|
|||||||
@@ -152,7 +152,6 @@ export function createApplicationCatalog({
|
|||||||
}),
|
}),
|
||||||
createApplicationAlpr({ surface, source: sources.alpr }),
|
createApplicationAlpr({ surface, source: sources.alpr }),
|
||||||
createApplicationStreetLevel({
|
createApplicationStreetLevel({
|
||||||
surface,
|
|
||||||
sources: { mapillary: sources.mapillary },
|
sources: { mapillary: sources.mapillary },
|
||||||
}),
|
}),
|
||||||
satellites,
|
satellites,
|
||||||
|
|||||||
@@ -6,17 +6,9 @@ import * as input from '../../data/inputOwnership.js';
|
|||||||
import * as render from '../../renderGovernor.js';
|
import * as render from '../../renderGovernor.js';
|
||||||
|
|
||||||
/** A new provider registers here; its chip, credit and share bit follow. */
|
/** A new provider registers here; its chip, credit and share bit follow. */
|
||||||
export function createApplicationStreetLevel({ surface, sources }) {
|
export function createApplicationStreetLevel({ sources }) {
|
||||||
return createStreetLevelLayer({
|
return createStreetLevelLayer({
|
||||||
providers: [createMapillaryProvider({ source: sources.mapillary })],
|
providers: [createMapillaryProvider({ source: sources.mapillary })],
|
||||||
services: {
|
services: { sprites, picking, input, render },
|
||||||
sprites,
|
|
||||||
picking,
|
|
||||||
input,
|
|
||||||
render,
|
|
||||||
ground: surface?.groundFloor ?? null,
|
|
||||||
meshFloor: surface?.meshFloor ?? null,
|
|
||||||
terrain: surface?.terrain ?? 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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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<number>}
|
|
||||||
*/
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
@@ -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}`);
|
|
||||||
});
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createState } from './state.js';
|
import { createState } from './state.js';
|
||||||
import { createMarker } from './marker.js';
|
import { createMarker } from './marker.js';
|
||||||
import { createCameraFollow } from './cameraFollow.js';
|
import { createCameraFraming } from './cameraFraming.js';
|
||||||
import { createCredits } from './credits.js';
|
import { createCredits } from './credits.js';
|
||||||
import { createPickRouter } from './pickRouter.js';
|
import { createPickRouter } from './pickRouter.js';
|
||||||
import { createSelection } from './selection.js';
|
import { createSelection } from './selection.js';
|
||||||
@@ -9,14 +9,8 @@ import { requiresKeyIdFor, validateProviders } from './registry.js';
|
|||||||
import { normalizeFilter, resolveFilter, sameFilter } from './filter.js';
|
import { normalizeFilter, resolveFilter, sameFilter } from './filter.js';
|
||||||
import { decodeParams, encodeParams } from './params.js';
|
import { decodeParams, encodeParams } from './params.js';
|
||||||
import { composeUIState, summarizeCoverage } from './uiState.js';
|
import { composeUIState, summarizeCoverage } from './uiState.js';
|
||||||
import { cameraHeightAboveGround, viewCentre, whenIdle } from './view.js';
|
import { 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 {
|
import {
|
||||||
FOLLOW_MAP_STACK_ID,
|
|
||||||
NEAREST_RADIUS_M,
|
NEAREST_RADIUS_M,
|
||||||
POSITION_PICK_ID,
|
POSITION_PICK_ID,
|
||||||
STREET_LEVEL_LAYER_ID,
|
STREET_LEVEL_LAYER_ID,
|
||||||
@@ -37,22 +31,9 @@ export function createStreetLevelLayer({
|
|||||||
const state = createState({ services });
|
const state = createState({ services });
|
||||||
const parts = {};
|
const parts = {};
|
||||||
const context = { state, 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.credits = createCredits();
|
||||||
parts.marker = createMarker(context);
|
parts.marker = createMarker(context);
|
||||||
parts.follow = createCameraFollow(context);
|
parts.framing = createCameraFraming(context);
|
||||||
parts.router = createPickRouter(() => state.providers.values(), {
|
parts.router = createPickRouter(() => state.providers.values(), {
|
||||||
positionId: POSITION_PICK_ID,
|
positionId: POSITION_PICK_ID,
|
||||||
});
|
});
|
||||||
@@ -90,9 +71,6 @@ export function createStreetLevelLayer({
|
|||||||
services: state.services,
|
services: state.services,
|
||||||
getFilter: () => resolveFilter(state.filter),
|
getFilter: () => resolveFilter(state.filter),
|
||||||
isActive: () => state.enabled && entry.on,
|
isActive: () => state.enabled && entry.on,
|
||||||
groundCaster: parts.groundCaster,
|
|
||||||
meshSampler: parts.meshSampler,
|
|
||||||
getSurface: () => state.surface,
|
|
||||||
notify,
|
notify,
|
||||||
actions: {
|
actions: {
|
||||||
openImage: (imageId) => openImage(entry.def.id, imageId),
|
openImage: (imageId) => openImage(entry.def.id, imageId),
|
||||||
@@ -128,85 +106,6 @@ export function createStreetLevelLayer({
|
|||||||
state.providers.set(def.id, entry);
|
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 = () =>
|
const activeEntries = () =>
|
||||||
[...state.providers.values()].filter((entry) => entry.on);
|
[...state.providers.values()].filter((entry) => entry.on);
|
||||||
|
|
||||||
@@ -319,7 +218,6 @@ export function createStreetLevelLayer({
|
|||||||
providers: providerSnapshots(),
|
providers: providerSnapshots(),
|
||||||
street,
|
street,
|
||||||
sequence: sequenceSnapshot(),
|
sequence: sequenceSnapshot(),
|
||||||
surface: state.surface,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,18 +252,14 @@ export function createStreetLevelLayer({
|
|||||||
parts.marker.setVisible(true);
|
parts.marker.setVisible(true);
|
||||||
parts.selection.install(viewer);
|
parts.selection.install(viewer);
|
||||||
for (const entry of activeEntries()) activate(entry);
|
for (const entry of activeEntries()) activate(entry);
|
||||||
watchSurface(viewer);
|
|
||||||
syncSurface();
|
|
||||||
notify();
|
notify();
|
||||||
},
|
},
|
||||||
|
|
||||||
disable() {
|
disable() {
|
||||||
state.enabled = false;
|
state.enabled = false;
|
||||||
abortNearest();
|
abortNearest();
|
||||||
unwatchSurface();
|
|
||||||
parts.viewerHost.unmount();
|
parts.viewerHost.unmount();
|
||||||
for (const entry of state.providers.values()) entry.instance.deactivate();
|
for (const entry of state.providers.values()) entry.instance.deactivate();
|
||||||
syncSurface();
|
|
||||||
parts.credits.hideAll(state.viewer);
|
parts.credits.hideAll(state.viewer);
|
||||||
parts.selection.uninstall();
|
parts.selection.uninstall();
|
||||||
parts.marker.setVisible(false);
|
parts.marker.setVisible(false);
|
||||||
@@ -381,11 +275,7 @@ export function createStreetLevelLayer({
|
|||||||
for (const entry of state.providers.values())
|
for (const entry of state.providers.values())
|
||||||
entry.instance.destroy(viewer);
|
entry.instance.destroy(viewer);
|
||||||
parts.marker.destroy(viewer);
|
parts.marker.destroy(viewer);
|
||||||
parts.meshSampler?.destroy();
|
parts.framing.attachNavigation(null);
|
||||||
parts.follow.destroy();
|
|
||||||
unsubscribeMapStack?.();
|
|
||||||
unsubscribeMapStack = null;
|
|
||||||
mapStack = null;
|
|
||||||
state.listeners.clear();
|
state.listeners.clear();
|
||||||
state.viewer = null;
|
state.viewer = null;
|
||||||
state.initialized = false;
|
state.initialized = false;
|
||||||
@@ -414,27 +304,12 @@ export function createStreetLevelLayer({
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The application's camera authority: `run(noun, move)`, the deferred
|
* The application's camera authority, the deferred `begin(noun)` /
|
||||||
* `begin(noun)` / `reassert(generation)` pair and
|
* `reassert(generation)` pair: a photo claims the camera when opening
|
||||||
* `subscribeHandoff(listener)`. FOLLOW claims the camera with `run`; a
|
* starts and frames only if it still owns it once loaded.
|
||||||
* photo claims it when opening starts and frames only if it still owns
|
|
||||||
* it once loaded. FOLLOW stops when another feature takes the camera.
|
|
||||||
*/
|
*/
|
||||||
attachNavigation(navigation) {
|
attachNavigation(navigation) {
|
||||||
parts.follow.attachNavigation(navigation);
|
parts.framing.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();
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Share-link and stored state: provider switches plus the filter. */
|
/** Share-link and stored state: provider switches plus the filter. */
|
||||||
@@ -523,7 +398,6 @@ export function createStreetLevelLayer({
|
|||||||
parts.clearSequences();
|
parts.clearSequences();
|
||||||
},
|
},
|
||||||
setViewerRenderMode: (mode) => parts.viewerHost.setRenderMode(mode),
|
setViewerRenderMode: (mode) => parts.viewerHost.setRenderMode(mode),
|
||||||
setFollow: (enabled) => parts.follow.setFollow(enabled),
|
|
||||||
resizeViewer: () => parts.viewerHost.resize(),
|
resizeViewer: () => parts.viewerHost.resize(),
|
||||||
selectSequence(
|
selectSequence(
|
||||||
sequenceId,
|
sequenceId,
|
||||||
|
|||||||
@@ -129,65 +129,6 @@ function slowLookups() {
|
|||||||
return { lookups, nearestImage };
|
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) => {
|
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 { layer, viewer } = await enabledLayer(t);
|
||||||
const shown = () => viewer.credits.map((credit) => credit.html);
|
const shown = () => viewer.credits.map((credit) => credit.html);
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { positionMarkerGlyph } from './glyphs.js';
|
import { positionMarkerGlyph } from './glyphs.js';
|
||||||
import { COLORS, POSITION_PICK_ID } from './policy.js';
|
import { COLORS, POSITION_PICK_ID } from './policy.js';
|
||||||
import { refineHeights } from './groundCast.js';
|
|
||||||
import { createHorizonCull } from './view.js';
|
import { createHorizonCull } from './view.js';
|
||||||
|
|
||||||
const SPRITE_ID = 'street-level:marker';
|
const SPRITE_ID = 'street-level:marker';
|
||||||
|
|
||||||
/** On-globe marker for the open image; in terrain mode it stands on bare earth. */
|
/** On-globe marker for the open image, clamped to the ground. */
|
||||||
export function createMarker({ state, parts }) {
|
export function createMarker({ state }) {
|
||||||
const { render, sprites } = state.services;
|
const { render, sprites } = state.services;
|
||||||
let last = null;
|
|
||||||
|
|
||||||
function requestRender() {
|
function requestRender() {
|
||||||
render?.governorRequestRender?.('street-level-marker');
|
render?.governorRequestRender?.('street-level-marker');
|
||||||
@@ -31,36 +29,10 @@ export function createMarker({ state, parts }) {
|
|||||||
sprites?.registerSpriteCollection?.(SPRITE_ID, state.marker.collection);
|
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. */
|
/** Move (or create) the marker; a null position removes it. */
|
||||||
function set(position, bearing) {
|
function set(position, bearing) {
|
||||||
const collection = state.marker.collection;
|
const collection = state.marker.collection;
|
||||||
if (!collection) return;
|
if (!collection) return;
|
||||||
last = position ? { position, bearing } : null;
|
|
||||||
if (!position) {
|
if (!position) {
|
||||||
collection.removeAll();
|
collection.removeAll();
|
||||||
state.marker.billboard = null;
|
state.marker.billboard = null;
|
||||||
@@ -68,16 +40,7 @@ export function createMarker({ state, parts }) {
|
|||||||
requestRender();
|
requestRender();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const height = castHeight(position);
|
const cartesian = Cesium.Cartesian3.fromDegrees(position.lon, position.lat);
|
||||||
const cartesian = Cesium.Cartesian3.fromDegrees(
|
|
||||||
position.lon,
|
|
||||||
position.lat,
|
|
||||||
height ?? 0,
|
|
||||||
);
|
|
||||||
const heightReference =
|
|
||||||
height === null
|
|
||||||
? Cesium.HeightReference.CLAMP_TO_GROUND
|
|
||||||
: Cesium.HeightReference.NONE;
|
|
||||||
if (!state.marker.billboard) {
|
if (!state.marker.billboard) {
|
||||||
state.marker.billboard = collection.add({
|
state.marker.billboard = collection.add({
|
||||||
id: POSITION_PICK_ID,
|
id: POSITION_PICK_ID,
|
||||||
@@ -87,12 +50,11 @@ export function createMarker({ state, parts }) {
|
|||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
alignedAxis: Cesium.Cartesian3.UNIT_Z,
|
alignedAxis: Cesium.Cartesian3.UNIT_Z,
|
||||||
heightReference,
|
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||||
verticalOrigin: Cesium.VerticalOrigin.CENTER,
|
verticalOrigin: Cesium.VerticalOrigin.CENTER,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
state.marker.billboard.heightReference = heightReference;
|
|
||||||
state.marker.billboard.position = cartesian;
|
state.marker.billboard.position = cartesian;
|
||||||
}
|
}
|
||||||
state.marker.billboard.rotation = -Cesium.Math.toRadians(bearing || 0);
|
state.marker.billboard.rotation = -Cesium.Math.toRadians(bearing || 0);
|
||||||
@@ -115,17 +77,11 @@ export function createMarker({ state, parts }) {
|
|||||||
state.marker.billboard = null;
|
state.marker.billboard = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Re-place the marker after the surface mode changes. */
|
|
||||||
function setSurface() {
|
|
||||||
if (last) set(last.position, last.bearing);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ensure,
|
ensure,
|
||||||
set,
|
set,
|
||||||
clear: () => set(null),
|
clear: () => set(null),
|
||||||
setVisible,
|
setVisible,
|
||||||
setSurface,
|
|
||||||
destroy,
|
destroy,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,6 @@ import assert from 'node:assert/strict';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { createMarker } from './marker.js';
|
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.
|
// The marker glyph draws on a canvas; Node has none, so drawing is a no-op.
|
||||||
const noop = () => {};
|
const noop = () => {};
|
||||||
@@ -12,7 +11,7 @@ globalThis.document ??= {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** A viewer whose camera is 20,000 km over `view`, and its pre-render listeners. */
|
/** 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 view = { lon: -121.49, lat: 38.58, height: 20_000_000 };
|
||||||
const preRender = new Set();
|
const preRender = new Set();
|
||||||
const viewer = {
|
const viewer = {
|
||||||
@@ -34,7 +33,6 @@ function setup({ parts = {}, surface = 'draped' } = {}) {
|
|||||||
const state = {
|
const state = {
|
||||||
services: {},
|
services: {},
|
||||||
viewer,
|
viewer,
|
||||||
surface,
|
|
||||||
marker: {
|
marker: {
|
||||||
// Stands in for the billboard collection; ensure() keeps it.
|
// Stands in for the billboard collection; ensure() keeps it.
|
||||||
collection: {
|
collection: {
|
||||||
@@ -45,7 +43,7 @@ function setup({ parts = {}, surface = 'draped' } = {}) {
|
|||||||
billboard: null,
|
billboard: null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const marker = createMarker({ state, parts });
|
const marker = createMarker({ state });
|
||||||
const frame = () => {
|
const frame = () => {
|
||||||
for (const listener of [...preRender]) listener();
|
for (const listener of [...preRender]) listener();
|
||||||
};
|
};
|
||||||
@@ -78,112 +76,21 @@ test('destroying the marker stops the horizon cull', () => {
|
|||||||
assert.equal(preRender.size, 0);
|
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 };
|
const SPOT = { lon: -121.49, lat: 38.58 };
|
||||||
|
|
||||||
test('in terrain mode the marker stands on the sampled road surface (mesh near bare earth)', () => {
|
test('the marker is clamped to the ground and turned to the bearing', () => {
|
||||||
const { parts } = terrainParts({ dem: 10, mesh: 11.5 });
|
const { state, marker } = setup();
|
||||||
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' });
|
|
||||||
marker.set(SPOT, 45);
|
marker.set(SPOT, 45);
|
||||||
const billboard = state.marker.billboard;
|
const billboard = state.marker.billboard;
|
||||||
assert.equal(
|
assert.equal(
|
||||||
billboard.heightReference,
|
billboard.heightReference,
|
||||||
Cesium.HeightReference.CLAMP_TO_GROUND,
|
Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||||
);
|
);
|
||||||
assert.deepEqual(ground.prepares[0].points, [[SPOT.lon, SPOT.lat]]);
|
assert.equal(billboard.rotation, -Cesium.Math.toRadians(45));
|
||||||
ground.dem = 10;
|
// Moving it keeps the same billboard.
|
||||||
ground.prepares[0].resolve(true);
|
marker.set({ lon: -121.5, lat: 38.6 }, 90);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
assert.equal(state.marker.billboard, billboard);
|
||||||
assert.equal(billboard.heightReference, Cesium.HeightReference.NONE);
|
const placed = Cesium.Cartographic.fromCartesian(billboard.position);
|
||||||
assert.ok(Math.abs(heightOf(billboard) - (11 + MESH_LIFT_M)) < 1e-3);
|
assert.ok(Math.abs(Cesium.Math.toDegrees(placed.longitude) + 121.5) < 1e-9);
|
||||||
assert.equal(billboard.rotation, -Cesium.Math.toRadians(45), 'bearing kept');
|
assert.equal(billboard.rotation, -Cesium.Math.toRadians(90));
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
@@ -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). */
|
/** Nearest-image search radius in metres (Mapillary's graph API caps it at 50 m). */
|
||||||
export const NEAREST_RADIUS_M = 50;
|
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;
|
|
||||||
@@ -7,10 +7,7 @@ import {
|
|||||||
metresBetween,
|
metresBetween,
|
||||||
viewFocus,
|
viewFocus,
|
||||||
visibleBbox,
|
visibleBbox,
|
||||||
whenIdle,
|
|
||||||
} from '../../view.js';
|
} from '../../view.js';
|
||||||
import { densifyLine, MESH_DENSIFY_DEG } from '../../groundCast.js';
|
|
||||||
import { MESH_SAMPLE_RADIUS_M } from '../../meshSampler.js';
|
|
||||||
import {
|
import {
|
||||||
coverageZoomForHeight,
|
coverageZoomForHeight,
|
||||||
overviewZoomForHeight,
|
overviewZoomForHeight,
|
||||||
@@ -33,12 +30,6 @@ import {
|
|||||||
SEQUENCE_VIEW_RANGE_PER_HEIGHT,
|
SEQUENCE_VIEW_RANGE_PER_HEIGHT,
|
||||||
} from './policy.js';
|
} 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. */
|
/** Old-zoom tiles are kept at most this long after a zoom change. */
|
||||||
const STALE_TILE_MAX_MS = 6000;
|
const STALE_TILE_MAX_MS = 6000;
|
||||||
/** Sequences per primitive: smaller batches build, and show, sooner. */
|
/** 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);
|
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
|
* 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
|
* (z11–14) near the ground, draped on terrain and 3D tiles alike.
|
||||||
* first, then swapped for lines cast to the bare earth (draped lines would
|
|
||||||
* land on roofs), so coverage never waits on terrain heights.
|
|
||||||
*/
|
*/
|
||||||
export function createCoverage({ state, source }) {
|
export function createCoverage({ state, source }) {
|
||||||
const { render } = state.services;
|
const { render } = state.services;
|
||||||
@@ -120,13 +92,6 @@ export function createCoverage({ state, source }) {
|
|||||||
return state.context.getFilter();
|
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
|
* A tile's sequences, filtered then capped: capping first could leave a
|
||||||
* dense tile of newer flat captures with no 360° lines at all.
|
* 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
|
* Batched draped primitives for a tile's sequence parts. Each batch records
|
||||||
* terrain heights are cached, draped ground lines otherwise. Each batch
|
* the selection its colours were built with.
|
||||||
* records the selection its colours were built with.
|
|
||||||
*/
|
*/
|
||||||
function buildSequencePrimitives(sequences) {
|
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 selectedId = state.sequence.selectedId ?? null;
|
||||||
const draped = [];
|
const instances = [];
|
||||||
const cast = [];
|
|
||||||
for (const sequence of sequences) {
|
for (const sequence of sequences) {
|
||||||
const color = Cesium.ColorGeometryInstanceAttribute.fromColor(
|
const color = Cesium.ColorGeometryInstanceAttribute.fromColor(
|
||||||
sequenceColor({ selected: sequence.id === selectedId }),
|
sequenceColor({ selected: sequence.id === selectedId }),
|
||||||
);
|
);
|
||||||
const ids = partIds(sequence);
|
const ids = partIds(sequence);
|
||||||
sequence.parts.forEach((coordinates, index) => {
|
sequence.parts.forEach((coordinates, index) => {
|
||||||
const flat = ground?.castLine(coordinates, { meshAt });
|
|
||||||
let positions;
|
let positions;
|
||||||
try {
|
try {
|
||||||
positions = flat
|
positions = Cesium.Cartesian3.fromDegreesArray(coordinates.flat());
|
||||||
? Cesium.Cartesian3.fromDegreesArrayHeights(flat)
|
|
||||||
: Cesium.Cartesian3.fromDegreesArray(coordinates.flat());
|
|
||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (positions.length < 2) return;
|
if (positions.length < 2) return;
|
||||||
const geometry = flat
|
instances.push(
|
||||||
? 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(
|
|
||||||
new Cesium.GeometryInstance({
|
new Cesium.GeometryInstance({
|
||||||
geometry,
|
geometry: new Cesium.GroundPolylineGeometry({
|
||||||
|
positions,
|
||||||
|
width: COVERAGE_LINE_WIDTH_PX,
|
||||||
|
}),
|
||||||
id: ids[index],
|
id: ids[index],
|
||||||
attributes: { color },
|
attributes: { color },
|
||||||
}),
|
}),
|
||||||
@@ -189,212 +139,19 @@ export function createCoverage({ state, source }) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const primitives = [];
|
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({
|
primitives.push({
|
||||||
onGround: true,
|
|
||||||
selectedId,
|
selectedId,
|
||||||
primitive: new Cesium.GroundPolylinePrimitive({
|
primitive: new Cesium.GroundPolylinePrimitive({
|
||||||
geometryInstances: draped.slice(i, i + SEQUENCE_PRIMITIVE_BATCH),
|
geometryInstances: instances.slice(i, i + SEQUENCE_PRIMITIVE_BATCH),
|
||||||
appearance: new Cesium.PolylineColorAppearance(),
|
appearance: new Cesium.PolylineColorAppearance(),
|
||||||
|
// Drapes on the globe and on 3D tiles (Google 3D) alike.
|
||||||
classificationType: Cesium.ClassificationType.BOTH,
|
classificationType: Cesium.ClassificationType.BOTH,
|
||||||
asynchronous: true,
|
asynchronous: true,
|
||||||
allowPicking: true,
|
allowPicking: true,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
for (let i = 0; i < cast.length; i += SEQUENCE_PRIMITIVE_BATCH)
|
return primitives;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildOverviewCollection(points) {
|
function buildOverviewCollection(points) {
|
||||||
@@ -442,17 +199,10 @@ export function createCoverage({ state, source }) {
|
|||||||
// Lookup, picking and counts follow what is drawn, not the whole tile.
|
// Lookup, picking and counts follow what is drawn, not the whole tile.
|
||||||
const sequences = drawnSequences(entry);
|
const sequences = drawnSequences(entry);
|
||||||
entry.sequences = new Map(sequences.map((s) => [s.id, s]));
|
entry.sequences = new Map(sequences.map((s) => [s.id, s]));
|
||||||
const { primitives, draped, cast } = buildSequencePrimitives(sequences);
|
entry.primitives = buildSequencePrimitives(sequences);
|
||||||
entry.primitives = primitives;
|
|
||||||
entry.count = sequences.length;
|
entry.count = sequences.length;
|
||||||
for (const { primitive, onGround } of primitives)
|
for (const { primitive } of entry.primitives)
|
||||||
(onGround ? scene.groundPrimitives : scene.primitives).add(primitive);
|
scene.groundPrimitives.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);
|
|
||||||
watchSelection();
|
watchSelection();
|
||||||
} else {
|
} else {
|
||||||
const { collection, points } = buildOverviewCollection(entry.points);
|
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;
|
const scene = state.viewer?.scene;
|
||||||
for (const { primitive, onGround } of list || []) {
|
for (const { primitive } of entry.primitives || []) {
|
||||||
try {
|
try {
|
||||||
(onGround ? scene?.groundPrimitives : scene?.primitives)?.remove(
|
scene?.groundPrimitives?.remove(primitive);
|
||||||
primitive,
|
|
||||||
);
|
|
||||||
} catch {
|
} catch {
|
||||||
/* already gone */
|
/* already gone */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function detachPrimitive(entry) {
|
|
||||||
const scene = state.viewer?.scene;
|
|
||||||
finishSwap(entry);
|
|
||||||
removePrimitives(entry.primitives);
|
|
||||||
entry.primitives = [];
|
entry.primitives = [];
|
||||||
if (entry.primitive) {
|
if (entry.primitive) {
|
||||||
try {
|
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) {
|
function removeTile(key) {
|
||||||
const entry = state.coverage.tiles.get(key);
|
const entry = state.coverage.tiles.get(key);
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
cancelCast(entry);
|
|
||||||
detachPrimitive(entry);
|
detachPrimitive(entry);
|
||||||
state.coverage.tiles.delete(key);
|
state.coverage.tiles.delete(key);
|
||||||
}
|
}
|
||||||
@@ -630,7 +365,6 @@ export function createCoverage({ state, source }) {
|
|||||||
controller.abort();
|
controller.abort();
|
||||||
state.coverage.pending.clear();
|
state.coverage.pending.clear();
|
||||||
for (const [key, entry] of state.coverage.tiles) {
|
for (const [key, entry] of state.coverage.tiles) {
|
||||||
cancelCast(entry);
|
|
||||||
const previous = state.coverage.stale.get(key);
|
const previous = state.coverage.stale.get(key);
|
||||||
if (previous) detachPrimitive(previous);
|
if (previous) detachPrimitive(previous);
|
||||||
state.coverage.stale.set(key, entry);
|
state.coverage.stale.set(key, entry);
|
||||||
@@ -659,9 +393,7 @@ export function createCoverage({ state, source }) {
|
|||||||
if (!viewer || !state.context.isActive() || state.keyRequired) return;
|
if (!viewer || !state.context.isActive() || state.keyRequired) return;
|
||||||
if (state.statusKnown === false) return;
|
if (state.statusKnown === false) return;
|
||||||
if (state.keyRejected || state.coverage.holdUntil > Date.now()) return;
|
if (state.keyRejected || state.coverage.holdUntil > Date.now()) return;
|
||||||
const ground = groundUnderCamera(viewer, {
|
const ground = groundUnderCamera(viewer);
|
||||||
groundAt: state.context.groundCaster?.groundAt,
|
|
||||||
});
|
|
||||||
const cameraHeight = viewer.camera?.positionCartographic?.height;
|
const cameraHeight = viewer.camera?.positionCartographic?.height;
|
||||||
const height = Number.isFinite(cameraHeight)
|
const height = Number.isFinite(cameraHeight)
|
||||||
? cameraHeight - (ground ?? 0)
|
? cameraHeight - (ground ?? 0)
|
||||||
@@ -671,7 +403,7 @@ export function createCoverage({ state, source }) {
|
|||||||
const zoom = sequenceZoom ?? overviewZoom;
|
const zoom = sequenceZoom ?? overviewZoom;
|
||||||
const kind = sequenceZoom ? 'sequence' : 'overview';
|
const kind = sequenceZoom ? 'sequence' : 'overview';
|
||||||
// At street zooms the rays meet the ground where it really is (1,600 m up
|
// 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 =
|
const ranged =
|
||||||
kind === 'sequence'
|
kind === 'sequence'
|
||||||
? {
|
? {
|
||||||
@@ -723,13 +455,6 @@ export function createCoverage({ state, source }) {
|
|||||||
}
|
}
|
||||||
for (const tile of tiles) loadTile(tile, kind);
|
for (const tile of tiles) loadTile(tile, kind);
|
||||||
if (!state.coverage.pending.size) purgeStale();
|
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();
|
notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -767,9 +492,6 @@ export function createCoverage({ state, source }) {
|
|||||||
purgeStale();
|
purgeStale();
|
||||||
state.coverage.zoom = null;
|
state.coverage.zoom = null;
|
||||||
state.coverage.kind = null;
|
state.coverage.kind = null;
|
||||||
clearTimeout(state.coverage.remeshTimer);
|
|
||||||
state.coverage.remeshTimer = null;
|
|
||||||
state.coverage.remeshDirty.clear();
|
|
||||||
stopSelectionWatch();
|
stopSelectionWatch();
|
||||||
horizon.stop();
|
horizon.stop();
|
||||||
requestRender();
|
requestRender();
|
||||||
@@ -779,9 +501,6 @@ export function createCoverage({ state, source }) {
|
|||||||
function rebuild() {
|
function rebuild() {
|
||||||
purgeStale();
|
purgeStale();
|
||||||
for (const entry of state.coverage.tiles.values()) {
|
for (const entry of state.coverage.tiles.values()) {
|
||||||
cancelCast(entry);
|
|
||||||
entry.castRequested = false;
|
|
||||||
entry.meshParts = null;
|
|
||||||
detachPrimitive(entry);
|
detachPrimitive(entry);
|
||||||
attachPrimitive(entry);
|
attachPrimitive(entry);
|
||||||
}
|
}
|
||||||
@@ -789,16 +508,6 @@ export function createCoverage({ state, source }) {
|
|||||||
notify();
|
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. */
|
/** Recolour every part of a sequence in one ready primitive. */
|
||||||
function recolorInstances(primitive, sequence, selected) {
|
function recolorInstances(primitive, sequence, selected) {
|
||||||
const value = sequenceColor({ selected });
|
const value = sequenceColor({ selected });
|
||||||
@@ -821,18 +530,12 @@ export function createCoverage({ state, source }) {
|
|||||||
return [...state.coverage.tiles.values(), ...state.coverage.stale.values()];
|
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). */
|
/** Recolour one sequence, every part of it, in place (selection highlight). */
|
||||||
function recolorSequence(id, selected) {
|
function recolorSequence(id, selected) {
|
||||||
for (const entry of drawnEntries()) {
|
for (const entry of drawnEntries()) {
|
||||||
const sequence = entry.sequences.get(id);
|
const sequence = entry.sequences.get(id);
|
||||||
if (!sequence) continue;
|
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.
|
// Still building: `syncSelection` catches it up once it is ready.
|
||||||
if (!record.primitive.ready) continue;
|
if (!record.primitive.ready) continue;
|
||||||
recolorInstances(record.primitive, sequence, selected);
|
recolorInstances(record.primitive, sequence, selected);
|
||||||
@@ -874,9 +577,6 @@ export function createCoverage({ state, source }) {
|
|||||||
if (!record.primitive.ready) building = true;
|
if (!record.primitive.ready) building = true;
|
||||||
else if (syncSelection(entry, record)) changed = 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 (changed) requestRender();
|
||||||
if (!building) stopSelectionWatch();
|
if (!building) stopSelectionWatch();
|
||||||
@@ -910,7 +610,6 @@ export function createCoverage({ state, source }) {
|
|||||||
clear,
|
clear,
|
||||||
resetErrors,
|
resetErrors,
|
||||||
rebuild,
|
rebuild,
|
||||||
setSurface,
|
|
||||||
recolorSequence,
|
recolorSequence,
|
||||||
sequenceCount,
|
sequenceCount,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import {
|
import { createCoverage } from './coverage.js';
|
||||||
SWAP_MAX_WAIT_MS,
|
|
||||||
createCoverage,
|
|
||||||
meshBoxInRange,
|
|
||||||
} from './coverage.js';
|
|
||||||
import { encodeCoverageTile } from './coverageFixture.mjs';
|
import { encodeCoverageTile } from './coverageFixture.mjs';
|
||||||
import { rayCamera } from '../../../../testSupport/streetLevelFakes.mjs';
|
import { rayCamera } from '../../../../testSupport/streetLevelFakes.mjs';
|
||||||
import {
|
import {
|
||||||
@@ -13,7 +9,6 @@ import {
|
|||||||
COVERAGE_MAX_SEQUENCES,
|
COVERAGE_MAX_SEQUENCES,
|
||||||
COVERAGE_MAX_TILES,
|
COVERAGE_MAX_TILES,
|
||||||
} from './policy.js';
|
} from './policy.js';
|
||||||
import { createGroundCaster } from '../../groundCast.js';
|
|
||||||
import { lonToTileX, latToTileY, tileBounds } from '../../tileMath.js';
|
import { lonToTileX, latToTileY, tileBounds } from '../../tileMath.js';
|
||||||
|
|
||||||
const RAD = Math.PI / 180;
|
const RAD = Math.PI / 180;
|
||||||
@@ -108,11 +103,7 @@ function deferredSource() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function setup({
|
function setup() {
|
||||||
surface = 'draped',
|
|
||||||
groundCaster = null,
|
|
||||||
meshSampler = null,
|
|
||||||
} = {}) {
|
|
||||||
const centre = tileCentre(-121.4944, 38.5816);
|
const centre = tileCentre(-121.4944, 38.5816);
|
||||||
const viewer = fakeViewer({ lon: centre.lon, lat: centre.lat, height: 900 });
|
const viewer = fakeViewer({ lon: centre.lon, lat: centre.lat, height: 900 });
|
||||||
const source = deferredSource();
|
const source = deferredSource();
|
||||||
@@ -125,9 +116,6 @@ function setup({
|
|||||||
getFilter: () => state.context.filter,
|
getFilter: () => state.context.filter,
|
||||||
isActive: () => true,
|
isActive: () => true,
|
||||||
notify() {},
|
notify() {},
|
||||||
getSurface: () => surface,
|
|
||||||
groundCaster,
|
|
||||||
meshSampler,
|
|
||||||
},
|
},
|
||||||
coverage: {
|
coverage: {
|
||||||
zoom: null,
|
zoom: null,
|
||||||
@@ -207,67 +195,19 @@ test('a superseded tile request cannot strand lines on the globe', async () => {
|
|||||||
assert.equal(onGlobe(), 0);
|
assert.equal(onGlobe(), 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('in terrain mode a tile draws draped, then swaps to cast lines without a blank frame', async () => {
|
test('sequence lines are draped on the globe and on 3D tiles alike', async () => {
|
||||||
let heightsReady = false;
|
const { viewer, source, coverage, bytes } = setup();
|
||||||
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,
|
|
||||||
});
|
|
||||||
coverage.refresh();
|
coverage.refresh();
|
||||||
source.calls[0].resolve(bytes);
|
source.calls[0].resolve(bytes);
|
||||||
await settle();
|
await settle();
|
||||||
await settle();
|
|
||||||
const { groundPrimitives, primitives } = viewer.scene;
|
const { groundPrimitives, primitives } = viewer.scene;
|
||||||
// Both are on the globe while the cast lines build: no blink.
|
assert.equal(primitives.items.size, 0);
|
||||||
assert.equal(groundPrimitives.items.size, 1, 'draped lines kept');
|
assert.equal(groundPrimitives.items.size, 1);
|
||||||
assert.equal(primitives.items.size, 1, 'cast lines added');
|
const [line] = groundPrimitives.items;
|
||||||
assert.ok(
|
assert.ok(line instanceof Cesium.GroundPolylinePrimitive);
|
||||||
[...primitives.items][0] instanceof Cesium.Primitive,
|
assert.equal(line.classificationType, Cesium.ClassificationType.BOTH);
|
||||||
'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);
|
|
||||||
coverage.clear();
|
coverage.clear();
|
||||||
|
assert.equal(groundPrimitives.items.size, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('panning off a tile that is still loading stops counting it as loading', async () => {
|
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');
|
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 () => {
|
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 { state, source, coverage, centre } = setup();
|
||||||
const line = (n) => [
|
const line = (n) => [
|
||||||
@@ -598,54 +455,6 @@ test('a sequence that crosses a tile edge is counted once', async () => {
|
|||||||
coverage.clear();
|
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). */
|
/** A camera over a 100×100 canvas with a 60°×40° view (see rayCamera). */
|
||||||
function tiltedCamera({ lon, lat, height, heading, pitch }) {
|
function tiltedCamera({ lon, lat, height, heading, pitch }) {
|
||||||
return rayCamera({
|
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
|
* 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) {
|
function denverStreetView(aheadM) {
|
||||||
const z = 14;
|
const z = 14;
|
||||||
@@ -674,7 +483,12 @@ function denverStreetView(aheadM) {
|
|||||||
const lat = (tile.south + tile.north) / 2;
|
const lat = (tile.south + tile.north) / 2;
|
||||||
const ground = 1600;
|
const ground = 1600;
|
||||||
const above = 300;
|
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);
|
const tileWidthM = (tile.east - tile.west) * 111_320 * Math.cos(lat * RAD);
|
||||||
context.viewer.camera = tiltedCamera({
|
context.viewer.camera = tiltedCamera({
|
||||||
lon,
|
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');
|
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 () => {
|
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();
|
const { viewer, source, state, coverage, bytes } = setup();
|
||||||
coverage.refresh();
|
coverage.refresh();
|
||||||
@@ -894,19 +648,6 @@ test('a rate limit holds every refresh inside its wait, not only the first', asy
|
|||||||
coverage.resetErrors();
|
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 () => {
|
test('a tile that fails before the terrain table loads is reported at once, never unhandled', async () => {
|
||||||
const { source, state, coverage, tileKey } = setup();
|
const { source, state, coverage, tileKey } = setup();
|
||||||
let terrainLoaded;
|
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);
|
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();
|
|
||||||
});
|
|
||||||
@@ -143,12 +143,6 @@ export function createMapillaryProvider({ source }) {
|
|||||||
parts.sequences.rerender();
|
parts.sequences.rerender();
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Core surface mode changed: redraw lines and cones draped or cast. */
|
|
||||||
setSurface() {
|
|
||||||
parts.coverage.setSurface();
|
|
||||||
parts.sequences.rerender();
|
|
||||||
},
|
|
||||||
|
|
||||||
coverageStats() {
|
coverageStats() {
|
||||||
return {
|
return {
|
||||||
count: parts.coverage.sequenceCount(),
|
count: parts.coverage.sequenceCount(),
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { imageConeGlyph } from '../../glyphs.js';
|
import { imageConeGlyph } from '../../glyphs.js';
|
||||||
import { passesImageryFilter } from '../../filter.js';
|
import { passesImageryFilter } from '../../filter.js';
|
||||||
import { refineHeights } from '../../groundCast.js';
|
|
||||||
import { createHorizonCull, metresBetween } from '../../view.js';
|
import { createHorizonCull, metresBetween } from '../../view.js';
|
||||||
import { meshCellKey } from '../../meshSampler.js';
|
|
||||||
import {
|
import {
|
||||||
COLORS,
|
COLORS,
|
||||||
IMAGE_CONE_MIN_SPACING_M,
|
IMAGE_CONE_MIN_SPACING_M,
|
||||||
@@ -58,14 +56,13 @@ export function createSequences({ state, source, parts }) {
|
|||||||
state.context.notify();
|
state.context.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
const nothingDrawn = () => ({ images: [], cones: [], cells: new Set() });
|
/** Drawn cone billboards. */
|
||||||
/** Drawn cones, plus the mesh cells under them, so a sample moves only those. */
|
let cones = [];
|
||||||
let drawn = nothingDrawn();
|
|
||||||
|
|
||||||
// The cones skip the depth test: hide the ones behind the globe.
|
// The cones skip the depth test: hide the ones behind the globe.
|
||||||
const horizon = createHorizonCull({
|
const horizon = createHorizonCull({
|
||||||
getViewer: () => state.viewer,
|
getViewer: () => state.viewer,
|
||||||
items: () => drawn.cones.map((cone) => cone.billboard),
|
items: () => cones,
|
||||||
onChange: requestRender,
|
onChange: requestRender,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,58 +78,15 @@ export function createSequences({ state, source, parts }) {
|
|||||||
function clearCones() {
|
function clearCones() {
|
||||||
state.sequence.collection?.removeAll();
|
state.sequence.collection?.removeAll();
|
||||||
state.sequence.images = [];
|
state.sequence.images = [];
|
||||||
drawn = nothingDrawn();
|
cones = [];
|
||||||
horizon.stop();
|
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) {
|
function renderCones(images) {
|
||||||
const collection = state.sequence.collection;
|
const collection = state.sequence.collection;
|
||||||
if (!collection) return;
|
if (!collection) return;
|
||||||
collection.removeAll();
|
collection.removeAll();
|
||||||
drawn = { ...nothingDrawn(), images };
|
cones = [];
|
||||||
const heights = coneHeights(images);
|
|
||||||
// One colour per source; 360° images keep the ring shape.
|
// One colour per source; 360° images keep the ring shape.
|
||||||
const cone = imageConeGlyph({ size: 32, color: COLORS.coverage });
|
const cone = imageConeGlyph({ size: 32, color: COLORS.coverage });
|
||||||
const ring = imageConeGlyph({
|
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.
|
// Resolved now, so a "since N days" window keeps up with the clock.
|
||||||
const filter = state.context.getFilter();
|
const filter = state.context.getFilter();
|
||||||
images.forEach((image, index) => {
|
for (const image of images) {
|
||||||
if (!passesImageryFilter(image, filter)) return;
|
if (!passesImageryFilter(image, filter)) continue;
|
||||||
const height = heights?.[index] ?? null;
|
|
||||||
const billboard = collection.add({
|
const billboard = collection.add({
|
||||||
id: `${PICK_PREFIX.image}${image.id}`,
|
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,
|
image: image.isPano ? ring : cone,
|
||||||
imageId: image.isPano ? 'mly-cone-pano' : 'mly-cone',
|
imageId: image.isPano ? 'mly-cone-pano' : 'mly-cone',
|
||||||
width: IMAGE_CONE_SIZE_PX,
|
width: IMAGE_CONE_SIZE_PX,
|
||||||
@@ -158,41 +112,9 @@ export function createSequences({ state, source, parts }) {
|
|||||||
verticalOrigin: Cesium.VerticalOrigin.CENTER,
|
verticalOrigin: Cesium.VerticalOrigin.CENTER,
|
||||||
scaleByDistance: new Cesium.NearFarScalar(200, 1.1, 6000, 0.35),
|
scaleByDistance: new Cesium.NearFarScalar(200, 1.1, 6000, 0.35),
|
||||||
});
|
});
|
||||||
drawn.cones.push({ index, billboard, height });
|
cones.push(billboard);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
if (!moved.length) return;
|
horizon.update();
|
||||||
horizon.update(moved);
|
|
||||||
requestRender();
|
requestRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,9 +210,6 @@ export function createSequences({ state, source, parts }) {
|
|||||||
if (state.sequence.images.length) renderCones(state.sequence.images);
|
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) {
|
function setVisible(visible) {
|
||||||
if (state.sequence.collection) state.sequence.collection.show = visible;
|
if (state.sequence.collection) state.sequence.collection.show = visible;
|
||||||
requestRender();
|
requestRender();
|
||||||
@@ -299,8 +218,7 @@ export function createSequences({ state, source, parts }) {
|
|||||||
function destroy(viewer) {
|
function destroy(viewer) {
|
||||||
state.sequence.abort?.abort();
|
state.sequence.abort?.abort();
|
||||||
horizon.stop();
|
horizon.stop();
|
||||||
drawn = nothingDrawn();
|
cones = [];
|
||||||
stopMeshListener?.();
|
|
||||||
const collection = state.sequence.collection;
|
const collection = state.sequence.collection;
|
||||||
if (collection) {
|
if (collection) {
|
||||||
sprites?.unregisterSpriteCollection?.(SPRITE_ID, collection);
|
sprites?.unregisterSpriteCollection?.(SPRITE_ID, collection);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { MESH_CELL_DEG } from '../../meshSampler.js';
|
|
||||||
import { createSequences } from './sequences.js';
|
import { createSequences } from './sequences.js';
|
||||||
|
|
||||||
// The cone glyphs draw on a canvas; Node has none, so any drawing is a no-op.
|
// The cone glyphs draw on a canvas; Node has none, so any drawing is a no-op.
|
||||||
@@ -66,9 +65,6 @@ function setup() {
|
|||||||
context: {
|
context: {
|
||||||
notify() {},
|
notify() {},
|
||||||
getFilter: () => ({ pano: 'all', sinceMs: null }),
|
getFilter: () => ({ pano: 'all', sinceMs: null }),
|
||||||
getSurface: () => 'draped',
|
|
||||||
groundCaster: null,
|
|
||||||
meshSampler: null,
|
|
||||||
actions: { reportError: (message) => errors.push(message) },
|
actions: { reportError: (message) => errors.push(message) },
|
||||||
},
|
},
|
||||||
sequence: {
|
sequence: {
|
||||||
@@ -173,112 +169,17 @@ test('clearSelection cancels the lookup and uncolours the sequence', async () =>
|
|||||||
assert.deepEqual(drawn(), [], 'the late answer draws nothing');
|
assert.deepEqual(drawn(), [], 'the late answer draws nothing');
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
test('cones are clamped to the ground and turned to their compass angle', async () => {
|
||||||
* Terrain mode with bare earth at 10 m, a mesh sampler the test drives, and
|
const { state, source, sequences } = setup();
|
||||||
* cones that log every change made to them.
|
sequences.select('A');
|
||||||
*/
|
source.last('A').resolve(records('A', 2));
|
||||||
function terrainSetup() {
|
await settle();
|
||||||
const context = setup();
|
const items = state.sequence.collection.items;
|
||||||
const { state } = context;
|
assert.equal(items.length, 2);
|
||||||
const mesh = new Map();
|
for (const cone of items) {
|
||||||
const cell = (lon, lat) =>
|
assert.equal(cone.heightReference, Cesium.HeightReference.CLAMP_TO_GROUND);
|
||||||
`${Math.round(lon / MESH_CELL_DEG)},${Math.round(lat / MESH_CELL_DEG)}`;
|
assert.equal(cone.rotation, -Cesium.Math.toRadians(90));
|
||||||
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]);
|
|
||||||
}
|
}
|
||||||
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 () => {
|
test('cones behind the globe are hidden, and show again from the other side', async () => {
|
||||||
|
|||||||
@@ -12,12 +12,9 @@
|
|||||||
* @property {(context: ProviderContext) => ProviderInstance} create
|
* @property {(context: ProviderContext) => ProviderInstance} create
|
||||||
*
|
*
|
||||||
* @typedef {object} ProviderContext Handed to `create()` once by the core.
|
* @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 {() => {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 {() => 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 {() => void} notify Ask the core to publish a new UI snapshot.
|
||||||
* @property {{openImage: (imageId: string) => Promise<void>, reportError: (message: string|null) => void}} actions
|
* @property {{openImage: (imageId: string) => Promise<void>, reportError: (message: string|null) => void}} actions
|
||||||
*
|
*
|
||||||
@@ -29,7 +26,6 @@
|
|||||||
* @property {(viewer: object) => void} destroy
|
* @property {(viewer: object) => void} destroy
|
||||||
* @property {() => void} refreshCoverage
|
* @property {() => void} refreshCoverage
|
||||||
* @property {(filter: {pano: string, sinceMs: number|null}) => void} setFilter The filter changed: redraw (reading `getFilter()`).
|
* @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 {() => {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 {(pickId: string) => boolean} handlePick The id carries the provider's own prefix.
|
||||||
* @property {(sequenceId: string) => Promise<void>} [selectSequence]
|
* @property {(sequenceId: string) => Promise<void>} [selectSequence]
|
||||||
@@ -38,12 +34,6 @@
|
|||||||
* @property {(point: {lat: number, lon: number}, options?: {signal?: AbortSignal}) => Promise<string|null>} nearestImage An aborted lookup gives up (it may reject with an AbortError).
|
* @property {(point: {lat: number, lon: number}, options?: {signal?: AbortSignal}) => Promise<string|null>} nearestImage An aborted lookup gives up (it may reject with an AbortError).
|
||||||
* @property {ViewerAdapter} viewer
|
* @property {ViewerAdapter} viewer
|
||||||
*
|
*
|
||||||
* @typedef {object} GroundCaster
|
|
||||||
* @property {(points: Array<[number, number]>, options?: {signal?: AbortSignal}) => Promise<boolean>} prepare Fetch the heights around [lon, lat] points; true when all are known.
|
|
||||||
* @property {(lines: Array<Array<[number, number]>>, options?: {signal?: AbortSignal}) => Promise<boolean>} prepareLines
|
|
||||||
* @property {(lon: number, lat: number) => number|null} groundAt Cached bare-earth ellipsoidal height.
|
|
||||||
* @property {(coords: Array<[number, number]>) => Array<number>|null} castLine Densified [lon, lat, height, ...] or null.
|
|
||||||
*
|
|
||||||
* @typedef {object} ViewerAdapter
|
* @typedef {object} ViewerAdapter
|
||||||
* @property {(host: HTMLElement) => Promise<void>} mount Idempotent; may lazy-load a library.
|
* @property {(host: HTMLElement) => Promise<void>} mount Idempotent; may lazy-load a library.
|
||||||
* @property {(imageId: string) => Promise<void>} open Resolves once the image is on screen and a pose was emitted.
|
* @property {(imageId: string) => Promise<void>} open Resolves once the image is on screen and a pose was emitted.
|
||||||
|
|||||||
@@ -2,12 +2,11 @@ import { FILTER_DEFAULT } from './policy.js';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The per-image fields of `state.street` with no image open; closing resets
|
* 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() {
|
export function freshStreet() {
|
||||||
return {
|
return {
|
||||||
open: false,
|
open: false,
|
||||||
follow: false,
|
|
||||||
providerId: null,
|
providerId: null,
|
||||||
providerName: null,
|
providerName: null,
|
||||||
providerLabel: null,
|
providerLabel: null,
|
||||||
@@ -43,16 +42,11 @@ export function createState({ services }) {
|
|||||||
|
|
||||||
street: {
|
street: {
|
||||||
host: null,
|
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. */
|
/** 'letterbox' shows the whole image; 'fill' crops it to the frame. */
|
||||||
renderMode: 'letterbox',
|
renderMode: 'letterbox',
|
||||||
...freshStreet(),
|
...freshStreet(),
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 'terrain' on Google 3D at street zoom (overlays on the bare earth), else 'draped'. */
|
|
||||||
surface: 'draped',
|
|
||||||
|
|
||||||
marker: { collection: null, billboard: null },
|
marker: { collection: null, billboard: null },
|
||||||
clickHandler: null,
|
clickHandler: null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ export function composeUIState({
|
|||||||
providers,
|
providers,
|
||||||
street,
|
street,
|
||||||
sequence,
|
sequence,
|
||||||
surface = 'draped',
|
|
||||||
}) {
|
}) {
|
||||||
const active = providers.filter((p) => p.on);
|
const active = providers.filter((p) => p.on);
|
||||||
const { keyRequired, keyRejected, ...coverage } =
|
const { keyRequired, keyRejected, ...coverage } =
|
||||||
@@ -71,6 +70,5 @@ export function composeUIState({
|
|||||||
legend,
|
legend,
|
||||||
sequence: { ...sequence },
|
sequence: { ...sequence },
|
||||||
street: { ...street },
|
street: { ...street },
|
||||||
surface,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -137,15 +137,3 @@ test('a rejected key gates like a missing one and is named as rejected', () => {
|
|||||||
false,
|
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',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -21,31 +21,17 @@ export function whenIdle(task, timeout) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ellipsoidal height (m) of the surface under the camera: globe terrain, or
|
* Ellipsoidal height (m) of the globe terrain under the camera; null where
|
||||||
* `groundAt` where Google 3D hides the globe; null without a sample.
|
* the globe is hidden (Google 3D) or not yet loaded.
|
||||||
* @param {object} viewer
|
|
||||||
* @param {{groundAt?: (lon: number, lat: number) => number|null}} [options]
|
|
||||||
*/
|
*/
|
||||||
export function groundUnderCamera(viewer, { groundAt } = {}) {
|
export function groundUnderCamera(viewer) {
|
||||||
const carto = viewer?.camera?.positionCartographic;
|
const carto = viewer?.camera?.positionCartographic;
|
||||||
if (!carto) return null;
|
const globe = viewer?.scene?.globe;
|
||||||
const globe = viewer.scene?.globe;
|
if (!carto || !globe || globe.show === false) return null;
|
||||||
let ground = globe?.show === false ? null : globe?.getHeight?.(carto);
|
const ground = globe.getHeight?.(carto);
|
||||||
if (!Number.isFinite(ground))
|
|
||||||
ground = groundAt?.(
|
|
||||||
Cesium.Math.toDegrees(carto.longitude),
|
|
||||||
Cesium.Math.toDegrees(carto.latitude),
|
|
||||||
);
|
|
||||||
return Number.isFinite(ground) ? ground : null;
|
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). */
|
/** The ellipsoid `height` metres above `ellipsoid` (the ground at that height). */
|
||||||
function raisedEllipsoid(ellipsoid, height) {
|
function raisedEllipsoid(ellipsoid, height) {
|
||||||
if (!Number.isFinite(height) || Math.abs(height) < 1) return ellipsoid;
|
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
|
* Visible [west, south, east, north] degrees, or null. Only screen rays that
|
||||||
* hit count, so a horizon cannot inflate the box to the world.
|
* hit count, so a horizon cannot inflate the box to the world.
|
||||||
* `groundHeight` raises the ellipsoid to the ground (Google 3D hides the
|
* `groundHeight` raises the ellipsoid to the ground, `maxRange` (m) drops
|
||||||
* globe), `maxRange` (m) drops near-horizon hits, and `nearRange` (m) always
|
* near-horizon hits, and `nearRange` (m) always includes the ground around
|
||||||
* includes the ground around the camera.
|
* the camera.
|
||||||
*/
|
*/
|
||||||
export function visibleBbox(
|
export function visibleBbox(
|
||||||
viewer,
|
viewer,
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import assert from 'node:assert/strict';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import {
|
import {
|
||||||
cameraHeightAboveGround,
|
|
||||||
createHorizonCull,
|
createHorizonCull,
|
||||||
|
groundUnderCamera,
|
||||||
metresBetween,
|
metresBetween,
|
||||||
viewCentre,
|
viewCentre,
|
||||||
viewFocus,
|
viewFocus,
|
||||||
@@ -28,28 +28,22 @@ function viewer({ globeShown, globeHeight }) {
|
|||||||
|
|
||||||
test('a shown globe answers the ground under the camera', () => {
|
test('a shown globe answers the ground under the camera', () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
cameraHeightAboveGround(viewer({ globeShown: true, globeHeight: 1600 })),
|
groundUnderCamera(viewer({ globeShown: true, globeHeight: 1600 })),
|
||||||
300,
|
1600,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a hidden globe (Google 3D) falls back to the bare-earth height', () => {
|
test('a hidden globe (Google 3D) or a missing sample gives no ground', () => {
|
||||||
const calls = [];
|
|
||||||
const groundAt = (lon, lat) => {
|
|
||||||
calls.push([+lon.toFixed(2), +lat.toFixed(2)]);
|
|
||||||
return 1600;
|
|
||||||
};
|
|
||||||
// A hidden globe's getHeight is ignored even when it returns a number.
|
// A hidden globe's getHeight is ignored even when it returns a number.
|
||||||
const hidden = viewer({ globeShown: false, globeHeight: 0 });
|
assert.equal(
|
||||||
assert.equal(cameraHeightAboveGround(hidden, { groundAt }), 300);
|
groundUnderCamera(viewer({ globeShown: false, globeHeight: 0 })),
|
||||||
assert.deepEqual(calls, [[-104.99, 39.74]]);
|
null,
|
||||||
});
|
);
|
||||||
|
assert.equal(
|
||||||
test('with no ground sample the ellipsoidal height is used', () => {
|
groundUnderCamera(viewer({ globeShown: true, globeHeight: undefined })),
|
||||||
const hidden = viewer({ globeShown: false, globeHeight: 0 });
|
null,
|
||||||
assert.equal(cameraHeightAboveGround(hidden), 1900);
|
);
|
||||||
assert.equal(cameraHeightAboveGround(hidden, { groundAt: () => null }), 1900);
|
assert.equal(groundUnderCamera({}), null);
|
||||||
assert.equal(cameraHeightAboveGround({}), null);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/** A camera whose screen rays land on a grid of lon/lat points. */
|
/** 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', () => {
|
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(
|
assert.deepEqual(
|
||||||
cullFrom([-74.006, 40.7128, -19.6], [[-74.006, 40.71289, -22]]),
|
cullFrom([-74.006, 40.7128, -19.6], [[-74.006, 40.71289, -22]]),
|
||||||
[true],
|
[true],
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { freshStreet } from './state.js';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Owns the panel's viewer element and the provider adapter mounted in it.
|
* 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 }) {
|
export function createViewerHost({ state, parts }) {
|
||||||
/** @type {{id: string, adapter: object, unsubscribe: () => void}|null} */
|
/** @type {{id: string, adapter: object, unsubscribe: () => void}|null} */
|
||||||
@@ -38,7 +38,6 @@ export function createViewerHost({ state, parts }) {
|
|||||||
externalUrl: pose.externalUrl || null,
|
externalUrl: pose.externalUrl || null,
|
||||||
});
|
});
|
||||||
parts.marker.set(state.street.position, state.street.bearing);
|
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
|
// Select the sequence only once the image is on screen, so its lookup
|
||||||
// never competes with the image download.
|
// never competes with the image download.
|
||||||
if (!state.street.loading && state.street.sequenceId !== previousSequence)
|
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. */
|
/** 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);
|
const entry = providerEntry(providerId);
|
||||||
if (!entry || !imageId) return false;
|
if (!entry || !imageId) return false;
|
||||||
if (!state.street.host) {
|
if (!state.street.host) {
|
||||||
@@ -114,8 +113,7 @@ export function createViewerHost({ state, parts }) {
|
|||||||
const seq = ++openSeq;
|
const seq = ++openSeq;
|
||||||
const current = () => seq === openSeq;
|
const current = () => seq === openSeq;
|
||||||
// Claimed now, honoured after loading only if nothing newer took the camera.
|
// Claimed now, honoured after loading only if nothing newer took the camera.
|
||||||
const ticket =
|
const ticket = parts.framing.begin();
|
||||||
frame && !state.street.follow ? parts.follow.beginFraming() : null;
|
|
||||||
Object.assign(state.street, {
|
Object.assign(state.street, {
|
||||||
loading: true,
|
loading: true,
|
||||||
error: null,
|
error: null,
|
||||||
@@ -130,7 +128,7 @@ export function createViewerHost({ state, parts }) {
|
|||||||
if (!current()) return false;
|
if (!current()) return false;
|
||||||
await adapter.open(String(imageId));
|
await adapter.open(String(imageId));
|
||||||
if (!current()) return false;
|
if (!current()) return false;
|
||||||
if (ticket && !state.street.follow) parts.follow.lookAtPosition(ticket);
|
if (ticket) parts.framing.frame(ticket);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (current())
|
if (current())
|
||||||
state.street.error = error?.message || 'Image could not be opened';
|
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. */
|
/** Close the image and stop any framing flight; the adapter stays warm. */
|
||||||
function close() {
|
function close() {
|
||||||
openSeq++;
|
openSeq++;
|
||||||
parts.follow.cancelFraming();
|
parts.framing.cancel();
|
||||||
active?.adapter.close();
|
active?.adapter.close();
|
||||||
Object.assign(state.street, freshStreet());
|
Object.assign(state.street, freshStreet());
|
||||||
parts.marker.clear();
|
parts.marker.clear();
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const { createMapillaryViewer } =
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* A viewer host over `adapter`. `marker` records where the position marker
|
* 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.
|
* `selected` every sequence the provider was asked to select.
|
||||||
*/
|
*/
|
||||||
function harness(adapter) {
|
function harness(adapter) {
|
||||||
@@ -40,7 +40,7 @@ function harness(adapter) {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
notify() {},
|
notify() {},
|
||||||
providers: new Map(),
|
providers: new Map(),
|
||||||
street: { host: {}, renderMode: 'letterbox', follow: false },
|
street: { host: {}, renderMode: 'letterbox' },
|
||||||
};
|
};
|
||||||
const def = { id: 'mapillary', name: 'Mapillary', label: 'MAPILLARY' };
|
const def = { id: 'mapillary', name: 'Mapillary', label: 'MAPILLARY' };
|
||||||
const selected = [];
|
const selected = [];
|
||||||
@@ -56,25 +56,22 @@ function harness(adapter) {
|
|||||||
state.providers.set('mapillary', { def, instance });
|
state.providers.set('mapillary', { def, instance });
|
||||||
const framing = { begun: 0, started: 0, cancelled: 0 };
|
const framing = { begun: 0, started: 0, cancelled: 0 };
|
||||||
const marker = [];
|
const marker = [];
|
||||||
const followed = { count: 0 };
|
|
||||||
const parts = {
|
const parts = {
|
||||||
marker: {
|
marker: {
|
||||||
set: (position, bearing) =>
|
set: (position, bearing) =>
|
||||||
marker.push(position && { position, bearing }),
|
marker.push(position && { position, bearing }),
|
||||||
clear: () => marker.push(null),
|
clear: () => marker.push(null),
|
||||||
},
|
},
|
||||||
follow: {
|
framing: {
|
||||||
followCamera: () => followed.count++,
|
begin: () => ({ generation: ++framing.begun }),
|
||||||
beginFraming: () => ({ generation: ++framing.begun }),
|
frame: (ticket) => ticket && framing.started++,
|
||||||
lookAtPosition: (ticket) => ticket && framing.started++,
|
cancel: () => framing.cancelled++,
|
||||||
cancelFraming: () => framing.cancelled++,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
state,
|
state,
|
||||||
framing,
|
framing,
|
||||||
marker,
|
marker,
|
||||||
followed,
|
|
||||||
selected,
|
selected,
|
||||||
/** Esc on the map: the provider drops its selected sequence. */
|
/** Esc on the map: the provider drops its selected sequence. */
|
||||||
clearSequence() {
|
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));
|
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 adapter = fakeAdapter();
|
||||||
const { state, host, marker, followed } = harness(adapter);
|
const { host, marker, framing } = harness(adapter);
|
||||||
await host.open('mapillary', 'a');
|
await host.open('mapillary', 'a');
|
||||||
assert.deepEqual(marker, [{ position: { lon: 1, lat: 2 }, bearing: 90 }]);
|
assert.deepEqual(marker, [{ position: { lon: 1, lat: 2 }, bearing: 90 }]);
|
||||||
assert.equal(followed.count, 1);
|
// The user walks on: the marker goes along, the globe camera stays.
|
||||||
// The user walks on, with FOLLOW on: the globe camera goes along.
|
|
||||||
state.street.follow = true;
|
|
||||||
adapter.emitPose('b', { position: { lon: 3, lat: 4 }, bearing: 180 });
|
adapter.emitPose('b', { position: { lon: 3, lat: 4 }, bearing: 180 });
|
||||||
assert.deepEqual(marker.at(-1), {
|
assert.deepEqual(marker.at(-1), {
|
||||||
position: { lon: 3, lat: 4 },
|
position: { lon: 3, lat: 4 },
|
||||||
bearing: 180,
|
bearing: 180,
|
||||||
});
|
});
|
||||||
assert.equal(followed.count, 2);
|
|
||||||
// Looking around in place is a pose too.
|
// Looking around in place is a pose too.
|
||||||
adapter.emitPose('b', { position: { lon: 3, lat: 4 }, bearing: 270 });
|
adapter.emitPose('b', { position: { lon: 3, lat: 4 }, bearing: 270 });
|
||||||
assert.equal(marker.at(-1).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 () => {
|
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 () => {
|
test('a photo asks for the camera when it starts opening and frames with that ticket once loaded', async () => {
|
||||||
const openGate = deferred();
|
const openGate = deferred();
|
||||||
const adapter = fakeAdapter({ openGate });
|
const adapter = fakeAdapter({ openGate });
|
||||||
const { state, host, framing } = harness(adapter);
|
const { host, framing } = harness(adapter);
|
||||||
const opening = host.open('mapillary', 'a');
|
const opening = host.open('mapillary', 'a');
|
||||||
assert.equal(framing.begun, 1, 'claimed before the download');
|
assert.equal(framing.begun, 1, 'claimed before the download');
|
||||||
await settle();
|
await settle();
|
||||||
@@ -570,9 +564,4 @@ test('a photo asks for the camera when it starts opening and frames with that ti
|
|||||||
openGate.resolve();
|
openGate.resolve();
|
||||||
assert.equal(await opening, true);
|
assert.equal(await opening, true);
|
||||||
assert.equal(framing.started, 1);
|
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]);
|
|
||||||
});
|
});
|
||||||
@@ -195,9 +195,5 @@ export function createMeshFloorSampler({
|
|||||||
() => eventTarget?.removeEventListener('gev:map-stack-changed', onStack),
|
() => eventTarget?.removeEventListener('gev:map-stack-changed', onStack),
|
||||||
{ once: true },
|
{ once: true },
|
||||||
);
|
);
|
||||||
return {
|
return { sampleMeshFloorCells, cachedGroundFloor };
|
||||||
sampleMeshFloorCells,
|
|
||||||
cachedGroundFloor,
|
|
||||||
visibleTilesetLoaded: _visibleTilesetLoaded,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
@@ -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.
|
// Any method a provider asks its source for is a no-op: only ids matter.
|
||||||
const source = new Proxy({}, { get: () => () => {} });
|
const source = new Proxy({}, { get: () => () => {} });
|
||||||
const layer = createApplicationStreetLevel({
|
const layer = createApplicationStreetLevel({
|
||||||
surface: null,
|
|
||||||
sources: { mapillary: source },
|
sources: { mapillary: source },
|
||||||
});
|
});
|
||||||
assert.deepEqual([...EXPECTED_PROVIDERS], [...layer.providerIds]);
|
assert.deepEqual([...EXPECTED_PROVIDERS], [...layer.providerIds]);
|
||||||
@@ -330,13 +329,9 @@ test('evidence never carries a Mapillary token', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('--strict accepts only the documented skips', () => {
|
test('--strict accepts only the documented skips', () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual([...STRICT_ALLOWED_SKIPS], ['fixtures only']);
|
||||||
[...STRICT_ALLOWED_SKIPS],
|
|
||||||
['no Google 3D', 'fixtures only'],
|
|
||||||
);
|
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
strictViolations([
|
strictViolations([
|
||||||
{ reason: 'no Google 3D', label: 'terrain' },
|
|
||||||
{ reason: 'fixtures only', label: 'keyless page' },
|
{ reason: 'fixtures only', label: 'keyless page' },
|
||||||
{ reason: 'no Mapillary key', label: 'the keyed steps' },
|
{ reason: 'no Mapillary key', label: 'the keyed steps' },
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -938,13 +938,11 @@ export class StyleManager extends ShellFacade {
|
|||||||
this._streetLevelControls?.destroy();
|
this._streetLevelControls?.destroy();
|
||||||
this._streetLevelControls = null;
|
this._streetLevelControls = null;
|
||||||
if (!this._streetLevelPanel || !streetLevelLayer) return;
|
if (!this._streetLevelPanel || !streetLevelLayer) return;
|
||||||
// Photo framing and FOLLOW release tracking like any explicit flight.
|
// Photo framing yields to newer navigation and releases tracking like
|
||||||
|
// any deferred flight.
|
||||||
streetLevelLayer.attachNavigation?.({
|
streetLevelLayer.attachNavigation?.({
|
||||||
run: (noun, move) => this._runExplicitNavigation(noun, move),
|
|
||||||
begin: (noun) => this._beginDeferredNavigation(noun),
|
begin: (noun) => this._beginDeferredNavigation(noun),
|
||||||
reassert: (generation) => this._reassertNavigationHandoff(generation),
|
reassert: (generation) => this._reassertNavigationHandoff(generation),
|
||||||
subscribeHandoff: (listener) =>
|
|
||||||
this._navigation.subscribeCameraHandoff(listener),
|
|
||||||
});
|
});
|
||||||
this._streetLevelControls = new StreetLevelControls({
|
this._streetLevelControls = new StreetLevelControls({
|
||||||
root: this._streetLevelPanel,
|
root: this._streetLevelPanel,
|
||||||
|
|||||||
@@ -394,7 +394,6 @@ function uiState({
|
|||||||
imageId: open ? 'img-1' : null,
|
imageId: open ? 'img-1' : null,
|
||||||
error: null,
|
error: null,
|
||||||
},
|
},
|
||||||
surface: 'draped',
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user