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:
German KhokhlovandClaude Opus 5.5 committed 2026-10-07 21:59:15 +04:00
1 parent 7dec7883aa
commit 0f41fb04cd
37 files changed
+430 -3323

No files matched your search

-1
View File
@@ -152,7 +152,6 @@ export function createApplicationCatalog({
}),
createApplicationAlpr({ surface, source: sources.alpr }),
createApplicationStreetLevel({
surface,
sources: { mapillary: sources.mapillary },
}),
satellites,
+2 -10
View File
@@ -6,17 +6,9 @@ import * as input from '../../data/inputOwnership.js';
import * as render from '../../renderGovernor.js';
/** A new provider registers here; its chip, credit and share bit follow. */
export function createApplicationStreetLevel({ surface, sources }) {
export function createApplicationStreetLevel({ sources }) {
return createStreetLevelLayer({
providers: [createMapillaryProvider({ source: sources.mapillary })],
services: {
sprites,
picking,
input,
render,
ground: surface?.groundFloor ?? null,
meshFloor: surface?.meshFloor ?? null,
terrain: surface?.terrain ?? null,
},
services: { sprites, picking, input, render },
});
}
-209
View File
@@ -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);
});
+96
View File
@@ -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);
});
-256
View File
@@ -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 };
}
-370
View File
@@ -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}`);
});
+8 -134
View File
@@ -1,6 +1,6 @@
import { createState } from './state.js';
import { createMarker } from './marker.js';
import { createCameraFollow } from './cameraFollow.js';
import { createCameraFraming } from './cameraFraming.js';
import { createCredits } from './credits.js';
import { createPickRouter } from './pickRouter.js';
import { createSelection } from './selection.js';
@@ -9,14 +9,8 @@ import { requiresKeyIdFor, validateProviders } from './registry.js';
import { normalizeFilter, resolveFilter, sameFilter } from './filter.js';
import { decodeParams, encodeParams } from './params.js';
import { composeUIState, summarizeCoverage } from './uiState.js';
import { cameraHeightAboveGround, viewCentre, whenIdle } from './view.js';
import { createGroundCaster, nextSurfaceMode } from './groundCast.js';
import { createMeshSampler } from './meshSampler.js';
/** Above this camera height the ground under the camera is not worth fetching. */
const SURFACE_WARM_BELOW_M = 6000;
import { viewCentre, whenIdle } from './view.js';
import {
FOLLOW_MAP_STACK_ID,
NEAREST_RADIUS_M,
POSITION_PICK_ID,
STREET_LEVEL_LAYER_ID,
@@ -37,22 +31,9 @@ export function createStreetLevelLayer({
const state = createState({ services });
const parts = {};
const context = { state, parts };
parts.groundCaster = services.terrain?.resolveEllipsoidalGround
? createGroundCaster({ terrain: services.terrain })
: null;
// Mesh samples follow the application's rules: a real bare-earth prior,
// its mesh window, and a tileset that has finished streaming.
parts.meshSampler = parts.groundCaster
? createMeshSampler({
getViewer: () => state.viewer,
groundAt: parts.groundCaster.groundAt,
withinPrior: services.ground?.meshFloorSampleWithinPrior ?? null,
tilesReady: services.meshFloor?.visibleTilesetLoaded ?? null,
})
: null;
parts.credits = createCredits();
parts.marker = createMarker(context);
parts.follow = createCameraFollow(context);
parts.framing = createCameraFraming(context);
parts.router = createPickRouter(() => state.providers.values(), {
positionId: POSITION_PICK_ID,
});
@@ -90,9 +71,6 @@ export function createStreetLevelLayer({
services: state.services,
getFilter: () => resolveFilter(state.filter),
isActive: () => state.enabled && entry.on,
groundCaster: parts.groundCaster,
meshSampler: parts.meshSampler,
getSurface: () => state.surface,
notify,
actions: {
openImage: (imageId) => openImage(entry.def.id, imageId),
@@ -128,85 +106,6 @@ export function createStreetLevelLayer({
state.providers.set(def.id, entry);
}
let mapStack = null;
let unsubscribeMapStack = null;
/** Follow is offered only on the Google 3D stack; leaving it stops following. */
function syncFollowAvailability() {
const available = mapStack?.getActiveId?.() === FOLLOW_MAP_STACK_ID;
if (available === state.street.followAvailable) return;
state.street.followAvailable = available;
if (!available && state.street.follow) parts.follow.setFollow(false);
notify();
}
/**
* Camera height above bare earth, for the surface mode. A cold grid cell is
* fetched once and the mode rechecked when it lands.
*/
function cameraHeightForSurface() {
const carto = state.viewer?.camera?.positionCartographic;
if (!carto) return null;
const caster = parts.groundCaster;
const height = cameraHeightAboveGround(state.viewer, {
groundAt: caster.groundAt,
});
const lon = (carto.longitude * 180) / Math.PI;
const lat = (carto.latitude * 180) / Math.PI;
if (caster.groundAt(lon, lat) === null && height < SURFACE_WARM_BELOW_M)
caster.prepare([[lon, lat]]).then((ready) => {
if (ready) scheduleSurfaceSync();
});
return height;
}
/** Cast overlays to bare earth on Google 3D at street zoom; drape them elsewhere. */
function syncSurface() {
const available = Boolean(parts.groundCaster) && state.enabled;
const photoreal = mapStack?.getActiveId?.() === FOLLOW_MAP_STACK_ID;
const mode = nextSurfaceMode(state.surface, {
available,
photoreal,
heightM: available && photoreal ? cameraHeightForSurface() : null,
});
if (mode === state.surface) return;
state.surface = mode;
parts.meshSampler?.setEnabled(mode === 'terrain');
parts.marker.setSurface(mode);
for (const entry of state.providers.values())
entry.instance.setSurface?.(mode);
notify();
}
let surfaceTimer = null;
function scheduleSurfaceSync() {
clearTimeout(surfaceTimer);
surfaceTimer = setTimeout(syncSurface, 150);
}
let removeSurfaceListeners = null;
function watchSurface(viewer) {
removeSurfaceListeners?.();
const camera = viewer?.camera;
if (!camera?.changed || !camera?.moveEnd) {
removeSurfaceListeners = null;
return;
}
const removeChanged = camera.changed.addEventListener(scheduleSurfaceSync);
const removeEnd = camera.moveEnd.addEventListener(scheduleSurfaceSync);
removeSurfaceListeners = () => {
removeChanged();
removeEnd();
};
}
function unwatchSurface() {
removeSurfaceListeners?.();
removeSurfaceListeners = null;
clearTimeout(surfaceTimer);
surfaceTimer = null;
}
const activeEntries = () =>
[...state.providers.values()].filter((entry) => entry.on);
@@ -319,7 +218,6 @@ export function createStreetLevelLayer({
providers: providerSnapshots(),
street,
sequence: sequenceSnapshot(),
surface: state.surface,
});
}
@@ -354,18 +252,14 @@ export function createStreetLevelLayer({
parts.marker.setVisible(true);
parts.selection.install(viewer);
for (const entry of activeEntries()) activate(entry);
watchSurface(viewer);
syncSurface();
notify();
},
disable() {
state.enabled = false;
abortNearest();
unwatchSurface();
parts.viewerHost.unmount();
for (const entry of state.providers.values()) entry.instance.deactivate();
syncSurface();
parts.credits.hideAll(state.viewer);
parts.selection.uninstall();
parts.marker.setVisible(false);
@@ -381,11 +275,7 @@ export function createStreetLevelLayer({
for (const entry of state.providers.values())
entry.instance.destroy(viewer);
parts.marker.destroy(viewer);
parts.meshSampler?.destroy();
parts.follow.destroy();
unsubscribeMapStack?.();
unsubscribeMapStack = null;
mapStack = null;
parts.framing.attachNavigation(null);
state.listeners.clear();
state.viewer = null;
state.initialized = false;
@@ -414,27 +304,12 @@ export function createStreetLevelLayer({
},
/**
* The application's camera authority: `run(noun, move)`, the deferred
* `begin(noun)` / `reassert(generation)` pair and
* `subscribeHandoff(listener)`. FOLLOW claims the camera with `run`; a
* photo claims it when opening starts and frames only if it still owns
* it once loaded. FOLLOW stops when another feature takes the camera.
* The application's camera authority, the deferred `begin(noun)` /
* `reassert(generation)` pair: a photo claims the camera when opening
* starts and frames only if it still owns it once loaded.
*/
attachNavigation(navigation) {
parts.follow.attachNavigation(navigation);
},
/** The application map stack; FOLLOW is available only on Google 3D. */
attachMapStackController(controller) {
unsubscribeMapStack?.();
mapStack = controller || null;
unsubscribeMapStack =
mapStack?.subscribe?.(() => {
syncFollowAvailability();
syncSurface();
}) || null;
syncFollowAvailability();
syncSurface();
parts.framing.attachNavigation(navigation);
},
/** Share-link and stored state: provider switches plus the filter. */
@@ -523,7 +398,6 @@ export function createStreetLevelLayer({
parts.clearSequences();
},
setViewerRenderMode: (mode) => parts.viewerHost.setRenderMode(mode),
setFollow: (enabled) => parts.follow.setFollow(enabled),
resizeViewer: () => parts.viewerHost.resize(),
selectSequence(
sequenceId,
-59
View File
@@ -129,65 +129,6 @@ function slowLookups() {
return { lookups, nearestImage };
}
/** A map stack controller that can be switched between stacks. */
function fakeMapStack(initial) {
let active = initial;
const listeners = new Set();
return {
getActiveId: () => active,
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
switchTo(id) {
active = id;
for (const listener of listeners) listener();
},
listenerCount: () => listeners.size,
};
}
test('FOLLOW is available only while the Google 3D map stack is active', () => {
const layer = createStreetLevelLayer({ providers: [fakeProvider()] });
assert.equal(
layer.getUIState().street.followAvailable,
false,
'unknown stack: off',
);
const stack = fakeMapStack('esri-imagery');
layer.attachMapStackController(stack);
assert.equal(layer.getUIState().street.followAvailable, false);
layer.setFollow(true);
assert.equal(layer.getUIState().street.follow, false, 'refused on Esri');
stack.switchTo('photoreal');
assert.equal(layer.getUIState().street.followAvailable, true);
layer.setFollow(true);
assert.equal(layer.getUIState().street.follow, true);
stack.switchTo('osm');
assert.equal(layer.getUIState().street.followAvailable, false);
assert.equal(
layer.getUIState().street.follow,
false,
'leaving Google 3D stops following',
);
});
test('the layer lets go of the map stack when destroyed or re-attached', () => {
const layer = createStreetLevelLayer({ providers: [fakeProvider()] });
const first = fakeMapStack('photoreal');
layer.attachMapStackController(first);
assert.equal(first.listenerCount(), 1);
const second = fakeMapStack('photoreal');
layer.attachMapStackController(second);
assert.equal(first.listenerCount(), 0);
assert.equal(second.listenerCount(), 1);
layer.destroy();
assert.equal(second.listenerCount(), 0);
});
test('the CC BY-SA credit shows while a provider is on and goes with it', async (t) => {
const { layer, viewer } = await enabledLayer(t);
const shown = () => viewer.credits.map((credit) => credit.html);
+4 -48
View File
@@ -1,15 +1,13 @@
import * as Cesium from 'cesium';
import { positionMarkerGlyph } from './glyphs.js';
import { COLORS, POSITION_PICK_ID } from './policy.js';
import { refineHeights } from './groundCast.js';
import { createHorizonCull } from './view.js';
const SPRITE_ID = 'street-level:marker';
/** On-globe marker for the open image; in terrain mode it stands on bare earth. */
export function createMarker({ state, parts }) {
/** On-globe marker for the open image, clamped to the ground. */
export function createMarker({ state }) {
const { render, sprites } = state.services;
let last = null;
function requestRender() {
render?.governorRequestRender?.('street-level-marker');
@@ -31,36 +29,10 @@ export function createMarker({ state, parts }) {
sprites?.registerSpriteCollection?.(SPRITE_ID, state.marker.collection);
}
/**
* Terrain-mode height (bare earth refined by mesh samples), or null to
* clamp. Requests a missing terrain cell or mesh sample as a side effect.
*/
function castHeight(position) {
const ground = parts?.groundCaster;
if (state.surface !== 'terrain' || !ground) return null;
const { lon, lat } = position;
const dem = ground.groundAt(lon, lat);
if (dem === null) {
ground.prepare([[lon, lat]]).then((ready) => {
if (ready && last?.position === position) set(position, last.bearing);
});
return null;
}
const mesh = parts.meshSampler?.meshAt(lon, lat);
if (mesh === undefined) parts.meshSampler?.request([[lon, lat]]);
return refineHeights([{ dem, mesh }])[0];
}
// A new mesh sample may move the marker onto (or off) the road surface.
parts?.meshSampler?.onSampled(() => {
if (last && state.surface === 'terrain') set(last.position, last.bearing);
});
/** Move (or create) the marker; a null position removes it. */
function set(position, bearing) {
const collection = state.marker.collection;
if (!collection) return;
last = position ? { position, bearing } : null;
if (!position) {
collection.removeAll();
state.marker.billboard = null;
@@ -68,16 +40,7 @@ export function createMarker({ state, parts }) {
requestRender();
return;
}
const height = castHeight(position);
const cartesian = Cesium.Cartesian3.fromDegrees(
position.lon,
position.lat,
height ?? 0,
);
const heightReference =
height === null
? Cesium.HeightReference.CLAMP_TO_GROUND
: Cesium.HeightReference.NONE;
const cartesian = Cesium.Cartesian3.fromDegrees(position.lon, position.lat);
if (!state.marker.billboard) {
state.marker.billboard = collection.add({
id: POSITION_PICK_ID,
@@ -87,12 +50,11 @@ export function createMarker({ state, parts }) {
width: 40,
height: 40,
alignedAxis: Cesium.Cartesian3.UNIT_Z,
heightReference,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
verticalOrigin: Cesium.VerticalOrigin.CENTER,
});
} else {
state.marker.billboard.heightReference = heightReference;
state.marker.billboard.position = cartesian;
}
state.marker.billboard.rotation = -Cesium.Math.toRadians(bearing || 0);
@@ -115,17 +77,11 @@ export function createMarker({ state, parts }) {
state.marker.billboard = null;
}
/** Re-place the marker after the surface mode changes. */
function setSurface() {
if (last) set(last.position, last.bearing);
}
return {
ensure,
set,
clear: () => set(null),
setVisible,
setSurface,
destroy,
};
}
+11 -104
View File
@@ -2,7 +2,6 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import { createMarker } from './marker.js';
import { GROUND_CAST_LIFT_M, MESH_LIFT_M } from './groundCast.js';
// The marker glyph draws on a canvas; Node has none, so drawing is a no-op.
const noop = () => {};
@@ -12,7 +11,7 @@ globalThis.document ??= {
};
/** A viewer whose camera is 20,000 km over `view`, and its pre-render listeners. */
function setup({ parts = {}, surface = 'draped' } = {}) {
function setup() {
const view = { lon: -121.49, lat: 38.58, height: 20_000_000 };
const preRender = new Set();
const viewer = {
@@ -34,7 +33,6 @@ function setup({ parts = {}, surface = 'draped' } = {}) {
const state = {
services: {},
viewer,
surface,
marker: {
// Stands in for the billboard collection; ensure() keeps it.
collection: {
@@ -45,7 +43,7 @@ function setup({ parts = {}, surface = 'draped' } = {}) {
billboard: null,
},
};
const marker = createMarker({ state, parts });
const marker = createMarker({ state });
const frame = () => {
for (const listener of [...preRender]) listener();
};
@@ -78,112 +76,21 @@ test('destroying the marker stops the horizon cull', () => {
assert.equal(preRender.size, 0);
});
/**
* Bare earth from a ground caster (null until its cell is fetched) and the
* sampled Google 3D surface from a mesh sampler (undefined until sampled).
*/
function terrainParts({ dem = null, mesh } = {}) {
const ground = { dem, prepares: [] };
const sampler = { mesh, requests: [], listeners: [] };
return {
ground,
sampler,
parts: {
groundCaster: {
groundAt: () => ground.dem,
prepare(points) {
let resolve;
const promise = new Promise((done) => {
resolve = done;
});
ground.prepares.push({ points, resolve });
return promise;
},
},
meshSampler: {
meshAt: () => sampler.mesh,
request: (points) => sampler.requests.push(points),
onSampled: (listener) => sampler.listeners.push(listener),
},
},
};
}
const heightOf = (billboard) =>
Cesium.Cartographic.fromCartesian(billboard.position).height;
const SPOT = { lon: -121.49, lat: 38.58 };
test('in terrain mode the marker stands on the sampled road surface (mesh near bare earth)', () => {
const { parts } = terrainParts({ dem: 10, mesh: 11.5 });
const { state, marker } = setup({ parts, surface: 'terrain' });
marker.set(SPOT, 45);
const billboard = state.marker.billboard;
assert.equal(billboard.heightReference, Cesium.HeightReference.NONE);
assert.ok(
Math.abs(heightOf(billboard) - (11.5 + MESH_LIFT_M)) < 1e-3,
`height ${heightOf(billboard)}`,
);
});
test('in terrain mode without a mesh sample the marker stands on bare earth and asks for one', () => {
const { parts, sampler } = terrainParts({ dem: 10 });
const { state, marker } = setup({ parts, surface: 'terrain' });
marker.set(SPOT, 45);
const billboard = state.marker.billboard;
assert.equal(billboard.heightReference, Cesium.HeightReference.NONE);
assert.ok(
Math.abs(heightOf(billboard) - (10 + GROUND_CAST_LIFT_M)) < 1e-3,
`height ${heightOf(billboard)}`,
);
assert.deepEqual(sampler.requests, [[[SPOT.lon, SPOT.lat]]]);
// The sample lands: the marker moves onto the road surface.
sampler.mesh = 10.5;
for (const listener of sampler.listeners) listener();
assert.ok(Math.abs(heightOf(billboard) - (10.5 + MESH_LIFT_M)) < 1e-3);
});
test('in terrain mode with no ground yet the marker clamps, then stands up once the ground arrives', async () => {
const { parts, ground } = terrainParts({ mesh: 11 });
const { state, marker } = setup({ parts, surface: 'terrain' });
test('the marker is clamped to the ground and turned to the bearing', () => {
const { state, marker } = setup();
marker.set(SPOT, 45);
const billboard = state.marker.billboard;
assert.equal(
billboard.heightReference,
Cesium.HeightReference.CLAMP_TO_GROUND,
);
assert.deepEqual(ground.prepares[0].points, [[SPOT.lon, SPOT.lat]]);
ground.dem = 10;
ground.prepares[0].resolve(true);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(billboard.heightReference, Cesium.HeightReference.NONE);
assert.ok(Math.abs(heightOf(billboard) - (11 + MESH_LIFT_M)) < 1e-3);
assert.equal(billboard.rotation, -Cesium.Math.toRadians(45), 'bearing kept');
});
test('ground that arrives after the marker moved on does not move it back', async () => {
const { parts, ground } = terrainParts();
const { state, marker } = setup({ parts, surface: 'terrain' });
marker.set(SPOT, 0);
const elsewhere = { lon: -121.5, lat: 38.6 };
marker.set(elsewhere, 0);
ground.dem = 10;
ground.prepares[0].resolve(true);
await new Promise((resolve) => setTimeout(resolve, 0));
const placed = Cesium.Cartographic.fromCartesian(
state.marker.billboard.position,
);
assert.ok(
Math.abs(Cesium.Math.toDegrees(placed.longitude) - elsewhere.lon) < 1e-9,
);
});
test('draped mode clamps the marker to the globe whatever the ground says', () => {
const { parts, ground } = terrainParts({ dem: 10, mesh: 11 });
const { state, marker } = setup({ parts, surface: 'draped' });
marker.set(SPOT, 0);
assert.equal(
state.marker.billboard.heightReference,
Cesium.HeightReference.CLAMP_TO_GROUND,
);
assert.equal(ground.prepares.length, 0);
assert.equal(billboard.rotation, -Cesium.Math.toRadians(45));
// Moving it keeps the same billboard.
marker.set({ lon: -121.5, lat: 38.6 }, 90);
assert.equal(state.marker.billboard, billboard);
const placed = Cesium.Cartographic.fromCartesian(billboard.position);
assert.ok(Math.abs(Cesium.Math.toDegrees(placed.longitude) + 121.5) < 1e-9);
assert.equal(billboard.rotation, -Cesium.Math.toRadians(90));
});
-371
View File
@@ -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 };
}
-405
View File
@@ -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();
});
-9
View File
@@ -26,12 +26,3 @@ export const FILTER_DEFAULT = Object.freeze({ pano: 'all', sinceDays: 0 });
/** Nearest-image search radius in metres (Mapillary's graph API caps it at 50 m). */
export const NEAREST_RADIUS_M = 50;
/**
* The only map stack camera follow is offered on: elsewhere a camera at eye
* height looks at a smeared texture.
*/
export const FOLLOW_MAP_STACK_ID = 'photoreal';
/** Eye height above the sampled ground when the globe camera follows the viewer. */
export const FOLLOW_EYE_HEIGHT_M = 2.4;
@@ -7,10 +7,7 @@ import {
metresBetween,
viewFocus,
visibleBbox,
whenIdle,
} from '../../view.js';
import { densifyLine, MESH_DENSIFY_DEG } from '../../groundCast.js';
import { MESH_SAMPLE_RADIUS_M } from '../../meshSampler.js';
import {
coverageZoomForHeight,
overviewZoomForHeight,
@@ -33,12 +30,6 @@ import {
SEQUENCE_VIEW_RANGE_PER_HEIGHT,
} from './policy.js';
/** Draped lines stay at most this long while a tile's cast lines build. */
export const SWAP_MAX_WAIT_MS = 4000;
/** Tiles touched by new mesh samples are redrawn at most this often. */
const REMESH_INTERVAL_MS = 1500;
/** Gap between redrawing one dirty tile and the next. */
const REMESH_STAGGER_MS = 120;
/** Old-zoom tiles are kept at most this long after a zoom change. */
const STALE_TILE_MAX_MS = 6000;
/** Sequences per primitive: smaller batches build, and show, sooner. */
@@ -76,28 +67,9 @@ export function sequenceIdFromPick(pickId) {
return cut === -1 ? rest : rest.slice(0, cut);
}
/**
* Whether any point of `box` ({west, south, east, north}) is within the mesh
* sampler's range of `centre`, the ground point under the camera.
*/
export function meshBoxInRange(box, centre) {
// Move the camera's longitude to the box's side of the date line first:
// clamping 179.999 into a box at -179.99 would pick its far edge.
const lon =
centre.lon +
360 * Math.round(((box.west + box.east) / 2 - centre.lon) / 360);
const nearest = {
lon: Math.min(box.east, Math.max(box.west, lon)),
lat: Math.min(box.north, Math.max(box.south, centre.lat)),
};
return metresBetween(centre, nearest) <= MESH_SAMPLE_RADIUS_M;
}
/**
* Camera-driven coverage: overview points (z0–5) from orbit, sequence lines
* (z11–14) near the ground. In terrain surface mode a tile is drawn draped
* first, then swapped for lines cast to the bare earth (draped lines would
* land on roofs), so coverage never waits on terrain heights.
* (z11–14) near the ground, draped on terrain and 3D tiles alike.
*/
export function createCoverage({ state, source }) {
const { render } = state.services;
@@ -120,13 +92,6 @@ export function createCoverage({ state, source }) {
return state.context.getFilter();
}
function terrainMode() {
return (
state.context.getSurface?.() === 'terrain' &&
Boolean(state.context.groundCaster)
);
}
/**
* A tile's sequences, filtered then capped: capping first could leave a
* dense tile of newer flat captures with no 360° lines at all.
@@ -142,46 +107,31 @@ export function createCoverage({ state, source }) {
}
/**
* Batched primitives for a tile's sequence parts: cast polylines where
* terrain heights are cached, draped ground lines otherwise. Each batch
* records the selection its colours were built with.
* Batched draped primitives for a tile's sequence parts. Each batch records
* the selection its colours were built with.
*/
function buildSequencePrimitives(sequences) {
const ground = terrainMode() ? state.context.groundCaster : null;
const meshAt = ground ? state.context.meshSampler?.meshAt : undefined;
const selectedId = state.sequence.selectedId ?? null;
const draped = [];
const cast = [];
const instances = [];
for (const sequence of sequences) {
const color = Cesium.ColorGeometryInstanceAttribute.fromColor(
sequenceColor({ selected: sequence.id === selectedId }),
);
const ids = partIds(sequence);
sequence.parts.forEach((coordinates, index) => {
const flat = ground?.castLine(coordinates, { meshAt });
let positions;
try {
positions = flat
? Cesium.Cartesian3.fromDegreesArrayHeights(flat)
: Cesium.Cartesian3.fromDegreesArray(coordinates.flat());
positions = Cesium.Cartesian3.fromDegreesArray(coordinates.flat());
} catch {
return;
}
if (positions.length < 2) return;
const geometry = flat
? new Cesium.PolylineGeometry({
positions,
width: COVERAGE_LINE_WIDTH_PX,
vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT,
arcType: Cesium.ArcType.NONE,
})
: new Cesium.GroundPolylineGeometry({
positions,
width: COVERAGE_LINE_WIDTH_PX,
});
(flat ? cast : draped).push(
instances.push(
new Cesium.GeometryInstance({
geometry,
geometry: new Cesium.GroundPolylineGeometry({
positions,
width: COVERAGE_LINE_WIDTH_PX,
}),
id: ids[index],
attributes: { color },
}),
@@ -189,212 +139,19 @@ export function createCoverage({ state, source }) {
});
}
const primitives = [];
for (let i = 0; i < draped.length; i += SEQUENCE_PRIMITIVE_BATCH)
for (let i = 0; i < instances.length; i += SEQUENCE_PRIMITIVE_BATCH)
primitives.push({
onGround: true,
selectedId,
primitive: new Cesium.GroundPolylinePrimitive({
geometryInstances: draped.slice(i, i + SEQUENCE_PRIMITIVE_BATCH),
geometryInstances: instances.slice(i, i + SEQUENCE_PRIMITIVE_BATCH),
appearance: new Cesium.PolylineColorAppearance(),
// Drapes on the globe and on 3D tiles (Google 3D) alike.
classificationType: Cesium.ClassificationType.BOTH,
asynchronous: true,
allowPicking: true,
}),
});
for (let i = 0; i < cast.length; i += SEQUENCE_PRIMITIVE_BATCH)
primitives.push({
onGround: false,
selectedId,
primitive: new Cesium.Primitive({
geometryInstances: cast.slice(i, i + SEQUENCE_PRIMITIVE_BATCH),
appearance: new Cesium.PolylineColorAppearance({ translucent: true }),
asynchronous: true,
allowPicking: true,
}),
});
return { primitives, draped, cast: cast.length };
}
/**
* Fetch the terrain heights a tile's lines need, then redraw it cast.
* `castRequested` stops the redraw from casting again at once; a refresh
* `retry`s a tile whose lines are still draped (a failed or partial cast),
* and it is redrawn only when more of its lines can be cast.
*/
function castTile(entry, { retry = false } = {}) {
if ((entry.castRequested && !retry) || entry.castAbort || !terrainMode())
return;
entry.castRequested = true;
entry.castAbort = new AbortController();
const { signal } = entry.castAbort;
const lines = [...entry.sequences.values()].flatMap(
(sequence) => sequence.parts,
);
const caster = state.context.groundCaster;
caster.prepareLines(lines, { signal }).then(() => {
if (entry.castAbort?.signal === signal) entry.castAbort = null;
const attached = [...state.coverage.tiles.values()].includes(entry);
if (signal.aborted || !attached || !terrainMode()) return;
// Nothing new to cast: keep what is drawn until the next refresh.
// Counted like buildSequencePrimitives: a cast line needs two points.
const castable = lines.filter(
(coords) => caster.castLine(coords)?.length >= 6,
).length;
if (castable <= (entry.castLines || 0)) {
entry.castRequested = (entry.castLines || 0) > 0;
return;
}
// A remesh may have cleared the flag while this was in flight.
entry.castRequested = true;
redrawTile(entry);
requestMesh(entry);
});
}
/** Rebuild a tile's lines; the old ones stay up until the new are ready. */
function redrawTile(entry) {
const previous = entry.primitives;
entry.primitives = [];
attachPrimitive(entry);
removeWhenReady(entry, previous);
}
/** Lon/lat under the camera, where mesh range is measured from. */
function meshCentre() {
const carto = state.viewer?.camera?.positionCartographic;
if (!carto) return null;
return {
lon: Cesium.Math.toDegrees(carto.longitude),
lat: Cesium.Math.toDegrees(carto.latitude),
};
}
/**
* A tile's drawn lines with bounding boxes, built once per filter; mesh
* points are densified the first time a line is in range.
*/
function meshParts(entry) {
if (entry.meshParts) return entry.meshParts;
entry.meshParts = [];
for (const sequence of entry.sequences.values())
for (const part of sequence.parts) {
const box = {
west: Infinity,
south: Infinity,
east: -Infinity,
north: -Infinity,
};
for (const [lon, lat] of part) {
if (lon < box.west) box.west = lon;
if (lon > box.east) box.east = lon;
if (lat < box.south) box.south = lat;
if (lat > box.north) box.north = lat;
}
entry.meshParts.push({ part, box, points: null });
}
return entry.meshParts;
}
/**
* Ask for mesh samples under a cast tile's lines within the sampler's range
* of the camera; the rest are asked for when a camera move brings them in.
*/
function requestMesh(entry) {
const sampler = state.context.meshSampler;
if (!sampler || entry.kind !== 'sequence' || !terrainMode()) return;
const centre = meshCentre();
if (!centre || !entry.bounds || !meshBoxInRange(entry.bounds, centre))
return;
const points = [];
for (const line of meshParts(entry)) {
if (!meshBoxInRange(line.box, centre)) continue;
line.points ||= densifyLine(line.part, MESH_DENSIFY_DEG);
for (const point of line.points) points.push(point);
}
if (points.length) sampler.request(points);
}
/** Redraw the tiles new mesh samples fall in, throttled. */
function onMeshSampled(batch) {
if (!terrainMode() || !state.context.isActive()) return;
for (const entry of state.coverage.tiles.values()) {
if (entry.kind !== 'sequence' || !entry.bounds) continue;
const { west, south, east, north } = entry.bounds;
if (
batch.some(
([lon, lat]) =>
lon >= west && lon <= east && lat >= south && lat <= north,
)
)
state.coverage.remeshDirty.add(entry);
}
if (!state.coverage.remeshDirty.size || state.coverage.remeshTimer) return;
const wait = Math.max(
0,
REMESH_INTERVAL_MS - (Date.now() - (state.coverage.remeshAt || 0)),
);
state.coverage.remeshTimer = setTimeout(remesh, wait);
}
/** Redraw one dirty tile per idle slice, so a burst of samples never stalls a frame. */
function remesh() {
state.coverage.remeshTimer = null;
state.coverage.remeshAt = Date.now();
if (!terrainMode() || !state.context.isActive()) {
state.coverage.remeshDirty.clear();
return;
}
const attached = new Set(state.coverage.tiles.values());
const [entry] = state.coverage.remeshDirty;
if (!entry) return;
state.coverage.remeshDirty.delete(entry);
if (attached.has(entry)) {
redrawTile(entry);
requestRender();
}
if (state.coverage.remeshDirty.size)
state.coverage.remeshTimer = setTimeout(
() => whenIdle(remesh, 500),
REMESH_STAGGER_MS,
);
}
state.coverage.remeshDirty = new Set();
state.context.meshSampler?.onSampled(onMeshSampled);
/**
* Remove a tile's previous primitives once all its current ones are ready
* (or after a few seconds); detaching the tile removes them at once.
*/
function removeWhenReady(entry, old) {
const scene = state.viewer?.scene;
finishSwap(entry);
if (!scene?.postRender) {
removePrimitives(old);
return;
}
const fresh = entry.primitives;
const started = Date.now();
const stop = scene.postRender.addEventListener(() => {
const ready = fresh.every(
({ primitive }) => primitive.ready || primitive.isDestroyed?.(),
);
if (!ready && Date.now() - started < SWAP_MAX_WAIT_MS) {
requestRender();
return;
}
finishSwap(entry);
requestRender();
});
entry.swap = { old, stop };
requestRender();
}
function finishSwap(entry) {
if (!entry.swap) return;
entry.swap.stop();
removePrimitives(entry.swap.old);
entry.swap = null;
return primitives;
}
function buildOverviewCollection(points) {
@@ -442,17 +199,10 @@ export function createCoverage({ state, source }) {
// Lookup, picking and counts follow what is drawn, not the whole tile.
const sequences = drawnSequences(entry);
entry.sequences = new Map(sequences.map((s) => [s.id, s]));
const { primitives, draped, cast } = buildSequencePrimitives(sequences);
entry.primitives = primitives;
entry.primitives = buildSequencePrimitives(sequences);
entry.count = sequences.length;
for (const { primitive, onGround } of primitives)
(onGround ? scene.groundPrimitives : scene.primitives).add(primitive);
// Fewer lines cast than last time: the caster has dropped heights this
// tile used (a full cache), so it must be cast again.
if (cast < (entry.castLines || 0)) entry.castRequested = false;
entry.castLines = cast;
entry.drapedLines = draped.length;
if (draped.length) castTile(entry);
for (const { primitive } of entry.primitives)
scene.groundPrimitives.add(primitive);
watchSelection();
} else {
const { collection, points } = buildOverviewCollection(entry.points);
@@ -465,23 +215,15 @@ export function createCoverage({ state, source }) {
}
}
function removePrimitives(list) {
function detachPrimitive(entry) {
const scene = state.viewer?.scene;
for (const { primitive, onGround } of list || []) {
for (const { primitive } of entry.primitives || []) {
try {
(onGround ? scene?.groundPrimitives : scene?.primitives)?.remove(
primitive,
);
scene?.groundPrimitives?.remove(primitive);
} catch {
/* already gone */
}
}
}
function detachPrimitive(entry) {
const scene = state.viewer?.scene;
finishSwap(entry);
removePrimitives(entry.primitives);
entry.primitives = [];
if (entry.primitive) {
try {
@@ -494,16 +236,9 @@ export function createCoverage({ state, source }) {
}
}
/** Stop a tile's pending terrain lookup (tile dropped or retired). */
function cancelCast(entry) {
entry.castAbort?.abort();
entry.castAbort = null;
}
function removeTile(key) {
const entry = state.coverage.tiles.get(key);
if (!entry) return;
cancelCast(entry);
detachPrimitive(entry);
state.coverage.tiles.delete(key);
}
@@ -630,7 +365,6 @@ export function createCoverage({ state, source }) {
controller.abort();
state.coverage.pending.clear();
for (const [key, entry] of state.coverage.tiles) {
cancelCast(entry);
const previous = state.coverage.stale.get(key);
if (previous) detachPrimitive(previous);
state.coverage.stale.set(key, entry);
@@ -659,9 +393,7 @@ export function createCoverage({ state, source }) {
if (!viewer || !state.context.isActive() || state.keyRequired) return;
if (state.statusKnown === false) return;
if (state.keyRejected || state.coverage.holdUntil > Date.now()) return;
const ground = groundUnderCamera(viewer, {
groundAt: state.context.groundCaster?.groundAt,
});
const ground = groundUnderCamera(viewer);
const cameraHeight = viewer.camera?.positionCartographic?.height;
const height = Number.isFinite(cameraHeight)
? cameraHeight - (ground ?? 0)
@@ -671,7 +403,7 @@ export function createCoverage({ state, source }) {
const zoom = sequenceZoom ?? overviewZoom;
const kind = sequenceZoom ? 'sequence' : 'overview';
// At street zooms the rays meet the ground where it really is (1,600 m up
// in Denver; Google 3D hides the globe) and stop short of the horizon.
// in Denver) and stop short of the horizon.
const ranged =
kind === 'sequence'
? {
@@ -723,13 +455,6 @@ export function createCoverage({ state, source }) {
}
for (const tile of tiles) loadTile(tile, kind);
if (!state.coverage.pending.size) purgeStale();
// The camera moved: cells that were out of the sampler's range may not be,
// and lines left draped by a failed, partial or dropped cast get another try.
if (terrainMode())
for (const entry of state.coverage.tiles.values()) {
if (entry.castRequested) requestMesh(entry);
if (entry.drapedLines) castTile(entry, { retry: true });
}
notify();
}
@@ -767,9 +492,6 @@ export function createCoverage({ state, source }) {
purgeStale();
state.coverage.zoom = null;
state.coverage.kind = null;
clearTimeout(state.coverage.remeshTimer);
state.coverage.remeshTimer = null;
state.coverage.remeshDirty.clear();
stopSelectionWatch();
horizon.stop();
requestRender();
@@ -779,9 +501,6 @@ export function createCoverage({ state, source }) {
function rebuild() {
purgeStale();
for (const entry of state.coverage.tiles.values()) {
cancelCast(entry);
entry.castRequested = false;
entry.meshParts = null;
detachPrimitive(entry);
attachPrimitive(entry);
}
@@ -789,16 +508,6 @@ export function createCoverage({ state, source }) {
notify();
}
/**
* Redraw tiles draped or cast for the new surface mode, then re-pick the
* zoom, since the ground height under the camera may have changed.
*/
function setSurface() {
if (!state.context.isActive()) return;
rebuild();
refresh();
}
/** Recolour every part of a sequence in one ready primitive. */
function recolorInstances(primitive, sequence, selected) {
const value = sequenceColor({ selected });
@@ -821,18 +530,12 @@ export function createCoverage({ state, source }) {
return [...state.coverage.tiles.values(), ...state.coverage.stale.values()];
}
/** A tile's primitives on the globe: its own, and the ones a swap still shows. */
function drawnRecords(entry) {
const records = entry.primitives || [];
return entry.swap ? [...records, ...entry.swap.old] : records;
}
/** Recolour one sequence, every part of it, in place (selection highlight). */
function recolorSequence(id, selected) {
for (const entry of drawnEntries()) {
const sequence = entry.sequences.get(id);
if (!sequence) continue;
for (const record of drawnRecords(entry)) {
for (const record of entry.primitives || []) {
// Still building: `syncSelection` catches it up once it is ready.
if (!record.primitive.ready) continue;
recolorInstances(record.primitive, sequence, selected);
@@ -874,9 +577,6 @@ export function createCoverage({ state, source }) {
if (!record.primitive.ready) building = true;
else if (syncSelection(entry, record)) changed = true;
}
// Lines a swap still shows were ready (or never show): catch up only.
for (const record of entry.swap?.old || [])
if (syncSelection(entry, record)) changed = true;
}
if (changed) requestRender();
if (!building) stopSelectionWatch();
@@ -910,7 +610,6 @@ export function createCoverage({ state, source }) {
clear,
resetErrors,
rebuild,
setSurface,
recolorSequence,
sequenceCount,
};
@@ -1,11 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import {
SWAP_MAX_WAIT_MS,
createCoverage,
meshBoxInRange,
} from './coverage.js';
import { createCoverage } from './coverage.js';
import { encodeCoverageTile } from './coverageFixture.mjs';
import { rayCamera } from '../../../../testSupport/streetLevelFakes.mjs';
import {
@@ -13,7 +9,6 @@ import {
COVERAGE_MAX_SEQUENCES,
COVERAGE_MAX_TILES,
} from './policy.js';
import { createGroundCaster } from '../../groundCast.js';
import { lonToTileX, latToTileY, tileBounds } from '../../tileMath.js';
const RAD = Math.PI / 180;
@@ -108,11 +103,7 @@ function deferredSource() {
};
}
function setup({
surface = 'draped',
groundCaster = null,
meshSampler = null,
} = {}) {
function setup() {
const centre = tileCentre(-121.4944, 38.5816);
const viewer = fakeViewer({ lon: centre.lon, lat: centre.lat, height: 900 });
const source = deferredSource();
@@ -125,9 +116,6 @@ function setup({
getFilter: () => state.context.filter,
isActive: () => true,
notify() {},
getSurface: () => surface,
groundCaster,
meshSampler,
},
coverage: {
zoom: null,
@@ -207,67 +195,19 @@ test('a superseded tile request cannot strand lines on the globe', async () => {
assert.equal(onGlobe(), 0);
});
test('in terrain mode a tile draws draped, then swaps to cast lines without a blank frame', async () => {
let heightsReady = false;
const groundCaster = {
prepareLines: async () => {
heightsReady = true;
return true;
},
castLine: (coords) =>
heightsReady ? coords.flatMap(([lon, lat]) => [lon, lat, 30]) : null,
};
const { viewer, source, coverage, bytes, onGlobe } = setup({
surface: 'terrain',
groundCaster,
});
test('sequence lines are draped on the globe and on 3D tiles alike', async () => {
const { viewer, source, coverage, bytes } = setup();
coverage.refresh();
source.calls[0].resolve(bytes);
await settle();
await settle();
const { groundPrimitives, primitives } = viewer.scene;
// Both are on the globe while the cast lines build: no blink.
assert.equal(groundPrimitives.items.size, 1, 'draped lines kept');
assert.equal(primitives.items.size, 1, 'cast lines added');
assert.ok(
[...primitives.items][0] instanceof Cesium.Primitive,
'cast lines are plain polylines at terrain height',
);
// Leaving mid-swap removes both sets, not just the new one.
coverage.clear();
assert.equal(onGlobe(), 0);
assert.equal(viewer.postRender.size, 0, 'the swap listener is gone');
});
test('a tile whose lines cannot be cast is not rebuilt for nothing', async () => {
let prepares = 0;
const groundCaster = {
prepareLines: async () => {
prepares++;
return false; // terrain proxy down, or tile too big
},
castLine: () => null,
};
const { viewer, source, coverage, bytes } = setup({
surface: 'terrain',
groundCaster,
});
const added = [];
const add = viewer.scene.groundPrimitives.add.bind(
viewer.scene.groundPrimitives,
);
viewer.scene.groundPrimitives.add = (primitive) => {
added.push(primitive);
return add(primitive);
};
coverage.refresh();
source.calls[0].resolve(bytes);
await settle();
await settle();
assert.equal(prepares, 1);
assert.equal(added.length, 1, 'drawn once, draped; no second draped build');
assert.equal(viewer.scene.primitives.items.size, 0);
assert.equal(primitives.items.size, 0);
assert.equal(groundPrimitives.items.size, 1);
const [line] = groundPrimitives.items;
assert.ok(line instanceof Cesium.GroundPolylinePrimitive);
assert.equal(line.classificationType, Cesium.ClassificationType.BOTH);
coverage.clear();
assert.equal(groundPrimitives.items.size, 0);
});
test('panning off a tile that is still loading stops counting it as loading', async () => {
@@ -391,89 +331,6 @@ test('overview points behind the horizon are hidden, not drawn through the globe
assert.equal(viewer.preRender.size, 0, 'the cull listener is gone');
});
test('a cast tile whose heights were evicted is cast again, not left draped', async () => {
const terrain = {
calls: 0,
async resolveEllipsoidalGround(coords) {
this.calls++;
return coords.map(() => ({ ellipsoid: 20, source: 'reearth' }));
},
};
// Room for one tile's corners at a time.
const groundCaster = createGroundCaster({
terrain,
maxCorners: 16,
cacheMax: 20,
});
let sampled = null;
const meshSampler = {
onSampled: (listener) => (sampled = listener),
request() {},
meshAt: () => undefined,
};
const { source, state, coverage, bytes, centre } = setup({
surface: 'terrain',
groundCaster,
meshSampler,
});
const isCast = () => {
const [entry] = state.coverage.tiles.values();
return (
entry.primitives.length > 0 &&
entry.primitives.every(({ onGround }) => !onGround)
);
};
coverage.refresh();
source.calls[0].resolve(bytes);
await settle();
await settle();
assert.ok(isCast(), 'cast once the heights are in');
// Another region fills the cache and evicts this tile's corners...
await groundCaster.prepare([[centre.lon + 0.05, centre.lat + 0.05]]);
assert.equal(groundCaster.groundAt(centre.lon - 0.001, centre.lat), null);
// ...then mesh samples redraw the tile, which can only drape now.
sampled([[centre.lon, centre.lat]]);
await new Promise((resolve) => setTimeout(resolve, 5));
coverage.refresh();
await settle();
await settle();
assert.ok(isCast(), 'the tile is cast again');
coverage.clear();
});
test('a tile left draped by a failed terrain lookup is cast on the next refresh', async () => {
let up = false;
let prepares = 0;
let heightsReady = false;
const groundCaster = {
prepareLines: async () => {
prepares++;
heightsReady = up;
return up;
},
castLine: (coords) =>
heightsReady ? coords.flatMap(([lon, lat]) => [lon, lat, 30]) : null,
};
const { viewer, source, coverage, bytes } = setup({
surface: 'terrain',
groundCaster,
});
coverage.refresh();
source.calls[0].resolve(bytes);
await settle();
await settle();
assert.equal(prepares, 1);
assert.equal(viewer.scene.primitives.items.size, 0, 'draped');
await settle();
assert.equal(prepares, 1, 'no retry loop while the proxy is down');
up = true; // the proxy is back
coverage.refresh();
await settle();
assert.equal(prepares, 2, 'the next refresh tries again');
assert.equal(viewer.scene.primitives.items.size, 1, 'cast lines added');
coverage.clear();
});
test('the per-tile cap applies after the imagery filter, so a dense tile keeps its 360° lines', async () => {
const { state, source, coverage, centre } = setup();
const line = (n) => [
@@ -598,54 +455,6 @@ test('a sequence that crosses a tile edge is counted once', async () => {
coverage.clear();
});
/** A terrain-mode tile mid-swap, with a cast primitive the test readies. */
async function midSwap() {
// Drawn draped first; castLine only answers once the heights are in.
let heightsReady = false;
const groundCaster = {
prepareLines: async () => (heightsReady = true),
castLine: (coords) =>
heightsReady ? coords.flatMap(([lon, lat]) => [lon, lat, 30]) : null,
};
const context = setup({ surface: 'terrain', groundCaster });
context.coverage.refresh();
context.source.calls[0].resolve(context.bytes);
await settle();
await settle();
const { groundPrimitives, primitives } = context.viewer.scene;
assert.equal(groundPrimitives.items.size, 1, 'draped lines kept');
assert.equal(primitives.items.size, 1, 'cast lines building');
const [cast] = primitives.items;
const control = { ready: false };
Object.defineProperty(cast, 'ready', { get: () => control.ready });
return { ...context, control };
}
test('the draped lines go once the cast lines are ready', async () => {
const { viewer, coverage, control } = await midSwap();
frame(viewer.postRender);
assert.equal(viewer.scene.groundPrimitives.items.size, 1, 'still building');
control.ready = true;
frame(viewer.postRender);
assert.equal(viewer.scene.groundPrimitives.items.size, 0, 'draped removed');
assert.equal(viewer.scene.primitives.items.size, 1, 'cast lines stay');
assert.equal(viewer.postRender.size, 0, 'nothing left watching');
coverage.clear();
});
test('the draped lines go after the wait even if the cast lines never get ready', async (t) => {
t.mock.timers.enable({ apis: ['Date'] });
const { viewer, coverage } = await midSwap();
t.mock.timers.tick(SWAP_MAX_WAIT_MS - 1);
frame(viewer.postRender);
assert.equal(viewer.scene.groundPrimitives.items.size, 1, 'still waiting');
t.mock.timers.tick(2);
frame(viewer.postRender);
assert.equal(viewer.scene.groundPrimitives.items.size, 0, 'draped removed');
assert.equal(viewer.scene.primitives.items.size, 1);
coverage.clear();
});
/** A camera over a 100×100 canvas with a 60°×40° view (see rayCamera). */
function tiltedCamera({ lon, lat, height, heading, pitch }) {
return rayCamera({
@@ -663,7 +472,7 @@ function tiltedCamera({ lon, lat, height, heading, pitch }) {
/**
* Denver: 300 m above a street 1,600 m up, at a z14 tile centre, looking east
* at ground `aheadM` metres out. Only the bare-earth caster knows the ground.
* at ground `aheadM` metres out; the globe terrain knows the ground.
*/
function denverStreetView(aheadM) {
const z = 14;
@@ -674,7 +483,12 @@ function denverStreetView(aheadM) {
const lat = (tile.south + tile.north) / 2;
const ground = 1600;
const above = 300;
const context = setup({ groundCaster: { groundAt: () => ground } });
const context = setup();
context.viewer.scene.globe = {
show: true,
ellipsoid: Cesium.Ellipsoid.WGS84,
getHeight: () => ground,
};
const tileWidthM = (tile.east - tile.west) * 111_320 * Math.cos(lat * RAD);
context.viewer.camera = tiltedCamera({
lon,
@@ -780,66 +594,6 @@ test('a selection made while the old zoom is still building is applied once it i
assert.equal(viewer.postRender.size, 0, 'the selection watch is gone');
});
test('only lines within the mesh sampler range of the camera are asked for', async () => {
let heightsReady = false;
const groundCaster = {
prepareLines: async () => (heightsReady = true),
castLine: (coords) =>
heightsReady ? coords.flatMap(([lon, lat]) => [lon, lat, 30]) : null,
};
const requested = [];
const meshSampler = {
onSampled() {},
request: (points) => requested.push(...points),
meshAt: () => undefined,
};
const { source, coverage, centre } = setup({
surface: 'terrain',
groundCaster,
meshSampler,
});
const { east, north } = tileBounds(centre.tile.x, centre.tile.y, 14);
coverage.refresh();
source.calls[0].resolve(
encodeCoverageTile(centre.tile, {
sequences: [
{
id: 'here',
parts: [
[
[centre.lon - 0.001, centre.lat],
[centre.lon + 0.001, centre.lat],
],
],
},
// ~1.1 km from the camera, in the tile's north-east corner.
{
id: 'corner',
parts: [
[
[east - 0.002, north - 0.002],
[east - 0.0005, north - 0.0005],
],
],
},
],
}),
);
await settle();
await settle();
assert.ok(requested.length > 0, 'the near line is asked for');
const farthest = Math.max(
...requested.map(([lon, lat]) =>
Math.hypot(
(lon - centre.lon) * 111_320 * Math.cos(centre.lat * RAD),
(lat - centre.lat) * 110_540,
),
),
);
assert.ok(farthest < 200, `nothing from the corner (${farthest} m)`);
coverage.clear();
});
test('the old zoom goes as soon as the new zoom has loaded, not after the stale wait', async () => {
const { viewer, source, state, coverage, bytes } = setup();
coverage.refresh();
@@ -894,19 +648,6 @@ test('a rate limit holds every refresh inside its wait, not only the first', asy
coverage.resetErrors();
});
test('a line across the date line from the camera is in mesh range', () => {
// A z14 tile's width of lines just past -180, the camera 111 m short of it.
const camera = { lon: 179.999, lat: 0 };
const east = { west: -180, east: -179.978, south: -0.001, north: 0.001 };
assert.equal(meshBoxInRange(east, camera), true);
// The mirror case, from the other side.
const west = { west: 179.978, east: 180, south: -0.001, north: 0.001 };
assert.equal(meshBoxInRange(west, { lon: -179.999, lat: 0 }), true);
// ~2.2 km away across the line: out of range either way.
const far = { west: -179.99, east: -179.97, south: -0.001, north: 0.001 };
assert.equal(meshBoxInRange(far, camera), false);
});
test('a tile that fails before the terrain table loads is reported at once, never unhandled', async () => {
const { source, state, coverage, tileKey } = setup();
let terrainLoaded;
@@ -930,60 +671,3 @@ test('a tile that fails before the terrain table loads is reported at once, neve
process.off('unhandledRejection', onUnhandled);
}
});
test('lines a partial terrain lookup left draped are cast once the terrain recovers', async () => {
// Two lines: the west one's heights arrive, the east one's fail at first.
let eastUp = false;
let prepares = 0;
const groundCaster = {
prepareLines: async () => {
prepares++;
return eastUp;
},
castLine: (coords) =>
coords[0][0] < centreLon || eastUp
? coords.flatMap(([lon, lat]) => [lon, lat, 30])
: null,
};
const { state, source, coverage, centre } = setup({
surface: 'terrain',
groundCaster,
});
const centreLon = centre.lon;
const line = (from, to) => [
[
[from, centre.lat],
[to, centre.lat],
],
];
coverage.refresh();
source.calls[0].resolve(
encodeCoverageTile(centre.tile, {
sequences: [
{ id: 'west', parts: line(centre.lon - 0.002, centre.lon - 0.001) },
{ id: 'east', parts: line(centre.lon + 0.001, centre.lon + 0.002) },
],
}),
);
await settle();
await settle();
const [entry] = state.coverage.tiles.values();
assert.equal(prepares, 1);
assert.deepEqual([entry.castLines, entry.drapedLines], [1, 1], 'partial');
await settle();
assert.equal(prepares, 1, 'no retry loop while the lookup fails');
// A refresh while still failing asks again but rebuilds nothing.
const drawn = entry.primitives;
coverage.refresh();
await settle();
assert.equal(prepares, 2);
assert.equal(entry.primitives, drawn, 'no rebuild without new heights');
eastUp = true; // the terrain proxy recovers
coverage.refresh();
await settle();
assert.equal(prepares, 3, 'the next refresh tries the draped line again');
assert.deepEqual([entry.castLines, entry.drapedLines], [2, 0], 'all cast');
coverage.clear();
});
@@ -143,12 +143,6 @@ export function createMapillaryProvider({ source }) {
parts.sequences.rerender();
},
/** Core surface mode changed: redraw lines and cones draped or cast. */
setSurface() {
parts.coverage.setSurface();
parts.sequences.rerender();
},
coverageStats() {
return {
count: parts.coverage.sequenceCount(),
@@ -1,9 +1,7 @@
import * as Cesium from 'cesium';
import { imageConeGlyph } from '../../glyphs.js';
import { passesImageryFilter } from '../../filter.js';
import { refineHeights } from '../../groundCast.js';
import { createHorizonCull, metresBetween } from '../../view.js';
import { meshCellKey } from '../../meshSampler.js';
import {
COLORS,
IMAGE_CONE_MIN_SPACING_M,
@@ -58,14 +56,13 @@ export function createSequences({ state, source, parts }) {
state.context.notify();
}
const nothingDrawn = () => ({ images: [], cones: [], cells: new Set() });
/** Drawn cones, plus the mesh cells under them, so a sample moves only those. */
let drawn = nothingDrawn();
/** Drawn cone billboards. */
let cones = [];
// The cones skip the depth test: hide the ones behind the globe.
const horizon = createHorizonCull({
getViewer: () => state.viewer,
items: () => drawn.cones.map((cone) => cone.billboard),
items: () => cones,
onChange: requestRender,
});
@@ -81,58 +78,15 @@ export function createSequences({ state, source, parts }) {
function clearCones() {
state.sequence.collection?.removeAll();
state.sequence.images = [];
drawn = nothingDrawn();
cones = [];
horizon.stop();
}
/** Where a cone stands: at `height`, or clamped to the ground when null. */
function placement(image, height) {
return {
position: Cesium.Cartesian3.fromDegrees(
image.lon,
image.lat,
height ?? 0,
),
heightReference:
height === null
? Cesium.HeightReference.CLAMP_TO_GROUND
: Cesium.HeightReference.NONE,
};
}
/**
* Terrain-mode cone heights on the street, not on roofs: bare earth refined
* by mesh samples. Null when draped or while terrain heights are fetched.
*/
function coneHeights(images, { request = true } = {}) {
const ground = state.context.groundCaster;
if (state.context.getSurface?.() !== 'terrain' || !ground) return null;
const dems = images.map((image) => ground.groundAt(image.lon, image.lat));
if (dems.includes(null)) {
ground
.prepare(images.map((image) => [image.lon, image.lat]))
.then((ready) => {
if (ready && state.sequence.images === images) renderCones(images);
});
return null;
}
const sampler = state.context.meshSampler;
if (request)
sampler?.request(images.map((image) => [image.lon, image.lat]));
return refineHeights(
images.map((image, i) => ({
dem: dems[i],
mesh: sampler?.meshAt(image.lon, image.lat),
})),
);
}
function renderCones(images) {
const collection = state.sequence.collection;
if (!collection) return;
collection.removeAll();
drawn = { ...nothingDrawn(), images };
const heights = coneHeights(images);
cones = [];
// One colour per source; 360° images keep the ring shape.
const cone = imageConeGlyph({ size: 32, color: COLORS.coverage });
const ring = imageConeGlyph({
@@ -142,12 +96,12 @@ export function createSequences({ state, source, parts }) {
});
// Resolved now, so a "since N days" window keeps up with the clock.
const filter = state.context.getFilter();
images.forEach((image, index) => {
if (!passesImageryFilter(image, filter)) return;
const height = heights?.[index] ?? null;
for (const image of images) {
if (!passesImageryFilter(image, filter)) continue;
const billboard = collection.add({
id: `${PICK_PREFIX.image}${image.id}`,
...placement(image, height),
position: Cesium.Cartesian3.fromDegrees(image.lon, image.lat),
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
image: image.isPano ? ring : cone,
imageId: image.isPano ? 'mly-cone-pano' : 'mly-cone',
width: IMAGE_CONE_SIZE_PX,
@@ -158,41 +112,9 @@ export function createSequences({ state, source, parts }) {
verticalOrigin: Cesium.VerticalOrigin.CENTER,
scaleByDistance: new Cesium.NearFarScalar(200, 1.1, 6000, 0.35),
});
drawn.cones.push({ index, billboard, height });
drawn.cells.add(meshCellKey(image.lon, image.lat));
});
horizon.update();
requestRender();
}
/**
* Re-place cones whose height new mesh samples changed. A sample can move
* a neighbour too, so every height is recomputed but only moved cones touched.
*/
function onMeshSampled(batch) {
if (state.context.getSurface?.() !== 'terrain' || !drawn.cones.length)
return;
if (!batch.some(([lon, lat]) => drawn.cells.has(meshCellKey(lon, lat))))
return;
const { images, cones } = drawn;
// Already asked for when drawn; a missing terrain cell redraws them all.
const heights = coneHeights(images, { request: false });
if (!heights) return;
const moved = [];
for (const cone of cones) {
const height = heights[cone.index] ?? null;
if (height === cone.height) continue;
cone.height = height;
const { position, heightReference } = placement(
images[cone.index],
height,
);
cone.billboard.heightReference = heightReference;
cone.billboard.position = position;
moved.push(cone.billboard);
cones.push(billboard);
}
if (!moved.length) return;
horizon.update(moved);
horizon.update();
requestRender();
}
@@ -288,9 +210,6 @@ export function createSequences({ state, source, parts }) {
if (state.sequence.images.length) renderCones(state.sequence.images);
}
// New mesh samples may move the cones onto (or off) the road surface.
const stopMeshListener = state.context.meshSampler?.onSampled(onMeshSampled);
function setVisible(visible) {
if (state.sequence.collection) state.sequence.collection.show = visible;
requestRender();
@@ -299,8 +218,7 @@ export function createSequences({ state, source, parts }) {
function destroy(viewer) {
state.sequence.abort?.abort();
horizon.stop();
drawn = nothingDrawn();
stopMeshListener?.();
cones = [];
const collection = state.sequence.collection;
if (collection) {
sprites?.unregisterSpriteCollection?.(SPRITE_ID, collection);
@@ -1,7 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import { MESH_CELL_DEG } from '../../meshSampler.js';
import { createSequences } from './sequences.js';
// The cone glyphs draw on a canvas; Node has none, so any drawing is a no-op.
@@ -66,9 +65,6 @@ function setup() {
context: {
notify() {},
getFilter: () => ({ pano: 'all', sinceMs: null }),
getSurface: () => 'draped',
groundCaster: null,
meshSampler: null,
actions: { reportError: (message) => errors.push(message) },
},
sequence: {
@@ -173,112 +169,17 @@ test('clearSelection cancels the lookup and uncolours the sequence', async () =>
assert.deepEqual(drawn(), [], 'the late answer draws nothing');
});
/**
* Terrain mode with bare earth at 10 m, a mesh sampler the test drives, and
* cones that log every change made to them.
*/
function terrainSetup() {
const context = setup();
const { state } = context;
const mesh = new Map();
const cell = (lon, lat) =>
`${Math.round(lon / MESH_CELL_DEG)},${Math.round(lat / MESH_CELL_DEG)}`;
let announce = null;
state.context.getSurface = () => 'terrain';
state.context.groundCaster = {
groundAt: () => 10,
prepare: async () => true,
};
state.context.meshSampler = {
request() {},
meshAt: (lon, lat) => mesh.get(cell(lon, lat)),
onSampled: (listener) => {
announce = listener;
return () => (announce = null);
},
};
const changes = [];
const items = [];
state.sequence.collection = {
items,
show: true,
add(options) {
changes.push(['add', options.id]);
const cone = new Proxy(options, {
set(target, key, value) {
changes.push([key, target.id]);
target[key] = value;
return true;
},
});
items.push(cone);
return cone;
},
removeAll() {
changes.push(['removeAll']);
items.length = 0;
},
};
// Created again, so it listens to this sampler.
const sequences = createSequences({
state,
source: context.source,
parts: { coverage: { recolorSequence() {} } },
});
/** Sample the mesh under a point, then announce it as the sampler does. */
function sample(lon, lat, height) {
const centre = [
Math.round(lon / MESH_CELL_DEG) * MESH_CELL_DEG,
Math.round(lat / MESH_CELL_DEG) * MESH_CELL_DEG,
];
mesh.set(cell(lon, lat), height);
announce([centre]);
test('cones are clamped to the ground and turned to their compass angle', async () => {
const { state, source, sequences } = setup();
sequences.select('A');
source.last('A').resolve(records('A', 2));
await settle();
const items = state.sequence.collection.items;
assert.equal(items.length, 2);
for (const cone of items) {
assert.equal(cone.heightReference, Cesium.HeightReference.CLAMP_TO_GROUND);
assert.equal(cone.rotation, -Cesium.Math.toRadians(90));
}
const heightOf = (cone) =>
Cesium.Cartographic.fromCartesian(cone.position).height;
return { ...context, sequences, changes, items, sample, heightOf };
}
test('a mesh sample away from the selected sequence changes no cone', async () => {
const { source, sequences, changes, items, sample } = terrainSetup();
sequences.select('A');
source.last('A').resolve(records('A', 3));
await settle();
assert.equal(items.length, 3);
changes.length = 0;
sample(-121.3, 38.7, 12); // a street elsewhere in the tile
assert.deepEqual(changes, [], 'nothing rebuilt or moved');
});
test('a mesh sample under a cone moves that cone in place, and no other', async () => {
const { source, sequences, changes, items, sample, heightOf } =
terrainSetup();
sequences.select('A');
source.last('A').resolve(records('A', 3));
await settle();
const before = items.map(heightOf);
changes.length = 0;
// The road under the second image is 2.5 m above the bare earth.
sample(-121.489, 38.58, 12.5);
assert.ok(
changes.every(([, id]) => id === 'mly:img:A-1'),
`only A-1 changed: ${JSON.stringify(changes)}`,
);
assert.ok(
changes.some(([key]) => key === 'position'),
'A-1 moved',
);
assert.equal(
changes.some(([key]) => key === 'removeAll' || key === 'add'),
false,
'the collection is not rebuilt',
);
const after = items.map(heightOf);
assert.ok(
Math.abs(after[1] - 13.5) < 0.01,
`A-1 stands on the road (${after[1]})`,
);
assert.deepEqual([after[0], after[2]], [before[0], before[2]]);
});
test('cones behind the globe are hidden, and show again from the other side', async () => {
+1 -11
View File
@@ -12,12 +12,9 @@
* @property {(context: ProviderContext) => ProviderInstance} create
*
* @typedef {object} ProviderContext Handed to `create()` once by the core.
* @property {object} services Scene services: picking, input, render, sprites, ground, terrain.
* @property {object} services Scene services: picking, input, render, sprites.
* @property {() => {pano: string, sinceMs: number|null}} getFilter Filter resolved against now; read it on every use so "since N days" keeps up.
* @property {() => boolean} isActive Layer enabled and this provider switched on.
* @property {() => 'draped'|'terrain'} getSurface 'terrain' on Google 3D at street zoom: draw on bare earth.
* @property {GroundCaster|null} groundCaster Bare-earth heights for terrain mode (groundCast.js); null without a terrain service.
* @property {{meshAt: (lon: number, lat: number) => number|undefined, request: (points: Array<[number, number]>) => void, onSampled: (listener: (cells: Array<[number, number]>) => void) => () => void}|null} meshSampler Sampled Google 3D surface heights (meshSampler.js); pass `meshAt` to `castLine`.
* @property {() => void} notify Ask the core to publish a new UI snapshot.
* @property {{openImage: (imageId: string) => Promise<void>, reportError: (message: string|null) => void}} actions
*
@@ -29,7 +26,6 @@
* @property {(viewer: object) => void} destroy
* @property {() => void} refreshCoverage
* @property {(filter: {pano: string, sinceMs: number|null}) => void} setFilter The filter changed: redraw (reading `getFilter()`).
* @property {(mode: 'draped'|'terrain') => void} [setSurface] Redraw coverage and cones for the new surface mode.
* @property {() => {count: number, zoom: number|null, kind: string|null, loading: boolean, hint: string, error: string|null, keyRequired: boolean}} coverageStats
* @property {(pickId: string) => boolean} handlePick The id carries the provider's own prefix.
* @property {(sequenceId: string) => Promise<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 {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
* @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.
+1 -7
View File
@@ -2,12 +2,11 @@ import { FILTER_DEFAULT } from './policy.js';
/**
* The per-image fields of `state.street` with no image open; closing resets
* exactly these (host, render mode and follow availability outlive an image).
* exactly these (host and render mode outlive an image).
*/
export function freshStreet() {
return {
open: false,
follow: false,
providerId: null,
providerName: null,
providerLabel: null,
@@ -43,16 +42,11 @@ export function createState({ services }) {
street: {
host: null,
/** Whether the active map stack allows following (Google 3D only). */
followAvailable: false,
/** 'letterbox' shows the whole image; 'fill' crops it to the frame. */
renderMode: 'letterbox',
...freshStreet(),
},
/** 'terrain' on Google 3D at street zoom (overlays on the bare earth), else 'draped'. */
surface: 'draped',
marker: { collection: null, billboard: null },
clickHandler: null,
};
-2
View File
@@ -49,7 +49,6 @@ export function composeUIState({
providers,
street,
sequence,
surface = 'draped',
}) {
const active = providers.filter((p) => p.on);
const { keyRequired, keyRejected, ...coverage } =
@@ -71,6 +70,5 @@ export function composeUIState({
legend,
sequence: { ...sequence },
street: { ...street },
surface,
};
}
-12
View File
@@ -137,15 +137,3 @@ test('a rejected key gates like a missing one and is named as rejected', () => {
false,
);
});
test('the surface mode defaults to draped and passes through', () => {
assert.equal(
composeUIState({ ...base, providers: [provider()] }).surface,
'draped',
);
assert.equal(
composeUIState({ ...base, providers: [provider()], surface: 'terrain' })
.surface,
'terrain',
);
});
+9 -23
View File
@@ -21,31 +21,17 @@ export function whenIdle(task, timeout) {
}
/**
* Ellipsoidal height (m) of the surface under the camera: globe terrain, or
* `groundAt` where Google 3D hides the globe; null without a sample.
* @param {object} viewer
* @param {{groundAt?: (lon: number, lat: number) => number|null}} [options]
* Ellipsoidal height (m) of the globe terrain under the camera; null where
* the globe is hidden (Google 3D) or not yet loaded.
*/
export function groundUnderCamera(viewer, { groundAt } = {}) {
export function groundUnderCamera(viewer) {
const carto = viewer?.camera?.positionCartographic;
if (!carto) return null;
const globe = viewer.scene?.globe;
let ground = globe?.show === false ? null : globe?.getHeight?.(carto);
if (!Number.isFinite(ground))
ground = groundAt?.(
Cesium.Math.toDegrees(carto.longitude),
Cesium.Math.toDegrees(carto.latitude),
);
const globe = viewer?.scene?.globe;
if (!carto || !globe || globe.show === false) return null;
const ground = globe.getHeight?.(carto);
return Number.isFinite(ground) ? ground : null;
}
/** Camera height (m) above the ground under it (options as `groundUnderCamera`). */
export function cameraHeightAboveGround(viewer, options = {}) {
const carto = viewer?.camera?.positionCartographic;
if (!carto) return null;
return carto.height - (groundUnderCamera(viewer, options) ?? 0);
}
/** The ellipsoid `height` metres above `ellipsoid` (the ground at that height). */
function raisedEllipsoid(ellipsoid, height) {
if (!Number.isFinite(height) || Math.abs(height) < 1) return ellipsoid;
@@ -77,9 +63,9 @@ function groundHit(camera, point, ellipsoid, maxRange) {
/**
* Visible [west, south, east, north] degrees, or null. Only screen rays that
* hit count, so a horizon cannot inflate the box to the world.
* `groundHeight` raises the ellipsoid to the ground (Google 3D hides the
* globe), `maxRange` (m) drops near-horizon hits, and `nearRange` (m) always
* includes the ground around the camera.
* `groundHeight` raises the ellipsoid to the ground, `maxRange` (m) drops
* near-horizon hits, and `nearRange` (m) always includes the ground around
* the camera.
*/
export function visibleBbox(
viewer,
+14 -20
View File
@@ -2,8 +2,8 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import {
cameraHeightAboveGround,
createHorizonCull,
groundUnderCamera,
metresBetween,
viewCentre,
viewFocus,
@@ -28,28 +28,22 @@ function viewer({ globeShown, globeHeight }) {
test('a shown globe answers the ground under the camera', () => {
assert.equal(
cameraHeightAboveGround(viewer({ globeShown: true, globeHeight: 1600 })),
300,
groundUnderCamera(viewer({ globeShown: true, globeHeight: 1600 })),
1600,
);
});
test('a hidden globe (Google 3D) falls back to the bare-earth height', () => {
const calls = [];
const groundAt = (lon, lat) => {
calls.push([+lon.toFixed(2), +lat.toFixed(2)]);
return 1600;
};
test('a hidden globe (Google 3D) or a missing sample gives no ground', () => {
// A hidden globe's getHeight is ignored even when it returns a number.
const hidden = viewer({ globeShown: false, globeHeight: 0 });
assert.equal(cameraHeightAboveGround(hidden, { groundAt }), 300);
assert.deepEqual(calls, [[-104.99, 39.74]]);
});
test('with no ground sample the ellipsoidal height is used', () => {
const hidden = viewer({ globeShown: false, globeHeight: 0 });
assert.equal(cameraHeightAboveGround(hidden), 1900);
assert.equal(cameraHeightAboveGround(hidden, { groundAt: () => null }), 1900);
assert.equal(cameraHeightAboveGround({}), null);
assert.equal(
groundUnderCamera(viewer({ globeShown: false, globeHeight: 0 })),
null,
);
assert.equal(
groundUnderCamera(viewer({ globeShown: true, globeHeight: undefined })),
null,
);
assert.equal(groundUnderCamera({}), null);
});
/** A camera whose screen rays land on a grid of lon/lat points. */
@@ -285,7 +279,7 @@ function cullFrom(camera, points) {
}
test('cones and the marker stay visible on ground below the WGS84 ellipsoid', () => {
// NYC in FOLLOW: eye 2.4 m above ground at -22 m, a cone 10 m away.
// NYC at street level: eye 2.4 m above ground at -22 m, a cone 10 m away.
assert.deepEqual(
cullFrom([-74.006, 40.7128, -19.6], [[-74.006, 40.71289, -22]]),
[true],
+5 -7
View File
@@ -2,7 +2,7 @@ import { freshStreet } from './state.js';
/**
* Owns the panel's viewer element and the provider adapter mounted in it.
* Adapter poses update `state.street`, the marker and the follow camera.
* Adapter poses update `state.street` and the marker.
*/
export function createViewerHost({ state, parts }) {
/** @type {{id: string, adapter: object, unsubscribe: () => void}|null} */
@@ -38,7 +38,6 @@ export function createViewerHost({ state, parts }) {
externalUrl: pose.externalUrl || null,
});
parts.marker.set(state.street.position, state.street.bearing);
parts.follow.followCamera();
// Select the sequence only once the image is on screen, so its lookup
// never competes with the image download.
if (!state.street.loading && state.street.sequenceId !== previousSequence)
@@ -101,7 +100,7 @@ export function createViewerHost({ state, parts }) {
}
/** Open an image: true once its first pose is in, false if it failed or was overtaken. */
async function open(providerId, imageId, { frame = true } = {}) {
async function open(providerId, imageId) {
const entry = providerEntry(providerId);
if (!entry || !imageId) return false;
if (!state.street.host) {
@@ -114,8 +113,7 @@ export function createViewerHost({ state, parts }) {
const seq = ++openSeq;
const current = () => seq === openSeq;
// Claimed now, honoured after loading only if nothing newer took the camera.
const ticket =
frame && !state.street.follow ? parts.follow.beginFraming() : null;
const ticket = parts.framing.begin();
Object.assign(state.street, {
loading: true,
error: null,
@@ -130,7 +128,7 @@ export function createViewerHost({ state, parts }) {
if (!current()) return false;
await adapter.open(String(imageId));
if (!current()) return false;
if (ticket && !state.street.follow) parts.follow.lookAtPosition(ticket);
if (ticket) parts.framing.frame(ticket);
} catch (error) {
if (current())
state.street.error = error?.message || 'Image could not be opened';
@@ -146,7 +144,7 @@ export function createViewerHost({ state, parts }) {
/** Close the image and stop any framing flight; the adapter stays warm. */
function close() {
openSeq++;
parts.follow.cancelFraming();
parts.framing.cancel();
active?.adapter.close();
Object.assign(state.street, freshStreet());
parts.marker.clear();
+11 -22
View File
@@ -32,7 +32,7 @@ const { createMapillaryViewer } =
/**
* A viewer host over `adapter`. `marker` records where the position marker
* was put (null once cleared), `followed` counts camera-follow updates and
* was put (null once cleared), `framing` counts framing calls and
* `selected` every sequence the provider was asked to select.
*/
function harness(adapter) {
@@ -40,7 +40,7 @@ function harness(adapter) {
enabled: true,
notify() {},
providers: new Map(),
street: { host: {}, renderMode: 'letterbox', follow: false },
street: { host: {}, renderMode: 'letterbox' },
};
const def = { id: 'mapillary', name: 'Mapillary', label: 'MAPILLARY' };
const selected = [];
@@ -56,25 +56,22 @@ function harness(adapter) {
state.providers.set('mapillary', { def, instance });
const framing = { begun: 0, started: 0, cancelled: 0 };
const marker = [];
const followed = { count: 0 };
const parts = {
marker: {
set: (position, bearing) =>
marker.push(position && { position, bearing }),
clear: () => marker.push(null),
},
follow: {
followCamera: () => followed.count++,
beginFraming: () => ({ generation: ++framing.begun }),
lookAtPosition: (ticket) => ticket && framing.started++,
cancelFraming: () => framing.cancelled++,
framing: {
begin: () => ({ generation: ++framing.begun }),
frame: (ticket) => ticket && framing.started++,
cancel: () => framing.cancelled++,
},
};
return {
state,
framing,
marker,
followed,
selected,
/** Esc on the map: the provider drops its selected sequence. */
clearSequence() {
@@ -258,24 +255,21 @@ test('switching the layer off and on during a cold first open keeps the shared v
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
test('every pose the viewer reports moves the marker and the followed camera', async () => {
test('every pose the viewer reports moves the marker; only the open frames the camera', async () => {
const adapter = fakeAdapter();
const { state, host, marker, followed } = harness(adapter);
const { host, marker, framing } = harness(adapter);
await host.open('mapillary', 'a');
assert.deepEqual(marker, [{ position: { lon: 1, lat: 2 }, bearing: 90 }]);
assert.equal(followed.count, 1);
// The user walks on, with FOLLOW on: the globe camera goes along.
state.street.follow = true;
// The user walks on: the marker goes along, the globe camera stays.
adapter.emitPose('b', { position: { lon: 3, lat: 4 }, bearing: 180 });
assert.deepEqual(marker.at(-1), {
position: { lon: 3, lat: 4 },
bearing: 180,
});
assert.equal(followed.count, 2);
// Looking around in place is a pose too.
adapter.emitPose('b', { position: { lon: 3, lat: 4 }, bearing: 270 });
assert.equal(marker.at(-1).bearing, 270);
assert.equal(followed.count, 3);
assert.deepEqual([framing.begun, framing.started], [1, 1]);
});
test('a pose on another sequence selects it, but not while the image still loads', async () => {
@@ -562,7 +556,7 @@ test('back-to-back Mapillary opens download only the newer image', async () => {
test('a photo asks for the camera when it starts opening and frames with that ticket once loaded', async () => {
const openGate = deferred();
const adapter = fakeAdapter({ openGate });
const { state, host, framing } = harness(adapter);
const { host, framing } = harness(adapter);
const opening = host.open('mapillary', 'a');
assert.equal(framing.begun, 1, 'claimed before the download');
await settle();
@@ -570,9 +564,4 @@ test('a photo asks for the camera when it starts opening and frames with that ti
openGate.resolve();
assert.equal(await opening, true);
assert.equal(framing.started, 1);
// FOLLOW owns the camera already: an open neither claims nor frames.
state.street.follow = true;
assert.equal(await host.open('mapillary', 'b'), true);
assert.deepEqual([framing.begun, framing.started], [1, 1]);
});
+1 -5
View File
@@ -195,9 +195,5 @@ export function createMeshFloorSampler({
() => eventTarget?.removeEventListener('gev:map-stack-changed', onStack),
{ once: true },
);
return {
sampleMeshFloorCells,
cachedGroundFloor,
visibleTilesetLoaded: _visibleTilesetLoaded,
};
return { sampleMeshFloorCells, cachedGroundFloor };
}
+1 -6
View File
@@ -29,7 +29,6 @@ test('the harness expects exactly the providers the app registers, in order', ()
// Any method a provider asks its source for is a no-op: only ids matter.
const source = new Proxy({}, { get: () => () => {} });
const layer = createApplicationStreetLevel({
surface: null,
sources: { mapillary: source },
});
assert.deepEqual([...EXPECTED_PROVIDERS], [...layer.providerIds]);
@@ -330,13 +329,9 @@ test('evidence never carries a Mapillary token', () => {
});
test('--strict accepts only the documented skips', () => {
assert.deepEqual(
[...STRICT_ALLOWED_SKIPS],
['no Google 3D', 'fixtures only'],
);
assert.deepEqual([...STRICT_ALLOWED_SKIPS], ['fixtures only']);
assert.deepEqual(
strictViolations([
{ reason: 'no Google 3D', label: 'terrain' },
{ reason: 'fixtures only', label: 'keyless page' },
{ reason: 'no Mapillary key', label: 'the keyed steps' },
]),
+2 -4
View File
@@ -938,13 +938,11 @@ export class StyleManager extends ShellFacade {
this._streetLevelControls?.destroy();
this._streetLevelControls = null;
if (!this._streetLevelPanel || !streetLevelLayer) return;
// Photo framing and FOLLOW release tracking like any explicit flight.
// Photo framing yields to newer navigation and releases tracking like
// any deferred flight.
streetLevelLayer.attachNavigation?.({
run: (noun, move) => this._runExplicitNavigation(noun, move),
begin: (noun) => this._beginDeferredNavigation(noun),
reassert: (generation) => this._reassertNavigationHandoff(generation),
subscribeHandoff: (listener) =>
this._navigation.subscribeCameraHandoff(listener),
});
this._streetLevelControls = new StreetLevelControls({
root: this._streetLevelPanel,
-1
View File
@@ -394,7 +394,6 @@ function uiState({
imageId: open ? 'img-1' : null,
error: null,
},
surface: 'draped',
};
}