orbit-studio / src /modules /SurfaceModel.ts
moncefem's picture
Deploy Orbit Studio propagator
9f21d0a
Raw
History Blame Contribute Delete
17.7 kB
// The surface model: the 3D tileset standing in for, or standing on, the globe's
// surface — and everything that follows from having one.
//
// This module owns the tileset and nothing else owns any part of it. What a
// selection *means* is not decided here: src/config/surfaceModels.ts holds the
// matrix, Cesium-free and tested, and this executes it. Rationale:
// docs/adr/0005-surface-models.md.
//
// Two consequences reach outside the tileset, so they arrive as callbacks rather
// than being reached for directly. Imposing a terrain belongs to the thing that
// owns the terrain (CesiumController.suppressTerrain), and reporting a failure
// belongs to whatever can put the selection back.
import { Cartesian3, Cartographic, type Cesium3DTileset, createGooglePhotorealistic3DTileset, createOsmBuildingsAsync, type Scene } from "@cesium/engine";
import { surfaceEffects, type SurfaceTileset } from "../config/surfaceModels";
import { SKY_MODE } from "../config/viewModes";
import type { Observer } from "./skyGeometry";
import { isPlausibleGroundHeight } from "./SkyView";
import { DeviceDetect } from "./util/DeviceDetect";
export interface SurfaceModelDeps {
scene: Scene;
/** Impose a terrain provider, or `undefined` to honour the user's choice again. */
setTerrainOverride: (name: string | undefined) => void;
/** A selection that could not be loaded, and is therefore not in effect. */
onFailure: (name: SurfaceTileset, error: unknown) => void;
/**
* Whether the sky view has arrived. The photorealistic mesh waits for it: the
* descent passes through every altitude between orbit and the pavement, and
* streaming a corridor of photogrammetry for viewpoints that last two seconds is
* the largest avoidable cost in this feature.
*/
skyLanded: () => boolean;
}
/**
* What the photorealistic mesh is allowed to spend.
*
* Cesium's defaults for this tileset are 1.5 GB of tile cache plus a 1 GB
* overflow, sized for a desktop flying the globe. The sky view is the opposite
* case — one viewpoint, a neighbourhood of tiles, frequently a phone — so the
* budget comes down and `dynamicScreenSpaceError`, which Cesium recommends for
* photogrammetry, drops the detail of tiles further from the camera.
*/
function googleTilesetOptions(): Cesium3DTileset.ConstructorOptions {
// A coarse pointer is the honest proxy for "a phone or tablet", and it catches
// Android, which an iOS test does not. Not `inIframe`: an embed on a desktop has
// a desktop's memory, and it was the memory this budget is about.
const constrained = DeviceDetect.isIos() || !DeviceDetect.canHover();
return {
cacheBytes: (constrained ? 192 : 512) * 1024 * 1024,
maximumCacheOverflowBytes: (constrained ? 64 : 256) * 1024 * 1024,
dynamicScreenSpaceError: true,
// Above Cesium's default of 16 everywhere, not just on phones. This is the one
// saving here that costs picture quality rather than only patience, and it is
// the mesh degrading — blurrier, never absent — which is why it is acceptable
// where the same move would be wrong for OSM Buildings.
maximumScreenSpaceError: 24,
// Load the tiles wanted and not the chain of coarser ones that would be thrown
// away on arrival: "only tiles that meet the maximum screen space error will
// ever be downloaded". On a tileset some twenty levels deep that is most of the
// bytes. The cost is that a view resolves out of nothing rather than out of a
// coarse stand-in, which is a fair trade from a fixed viewpoint that is not
// being flown around.
skipLevelOfDetail: true,
immediatelyLoadDesiredLevelOfDetail: true,
// Google's Map Tiles policies ask for the attributions on screen, in a line
// along the bottom, rather than behind the collapsed "Data attribution" link
// Cesium defaults to. Cesium reads its own default as the minimum compliant
// behaviour; this follows Google's wording instead.
showCreditsOnScreen: true,
// Left at Cesium's default `true` deliberately: with the globe hidden, the
// mesh is the only thing stopping the camera from dropping through the ground.
};
}
/**
* How high above the ground buildings stop being worth loading at all, on the globe.
*
* A hard gate rather than another turn of the screen-space-error screw, because
* `show = false` is the one setting Cesium treats as *nothing to do*: it skips the
* whole traversal (`Cesium3DTileset.updateForPass`), and `preloadWhenHidden` is off
* by default, so a hidden tileset issues no requests at all rather than merely
* fewer. Squeezing the error tolerance can only ever reduce.
*
* Above the *ground*, not above the ellipsoid, which is not pedantry: measured from
* the ellipsoid, a ceiling this low would put La Paz at 3.6 km permanently over it
* and its buildings permanently absent.
*/
const GLOBE_BUILDING_CEILING = 1000;
/**
* How each model is built. Here rather than beside the imagery and terrain
* registries: a surface model is not a layer provider (see CONTEXT.md), this is
* its only consumer, and creation and lifetime belong to the same owner.
*/
const SURFACE_TILESETS: Record<SurfaceTileset, () => Promise<Cesium3DTileset>> = {
// No options: this helper takes styling only, and its default style is the one
// worth having — it colours each building from the tileset's own `cesium#color`
// property. Its "© OpenStreetMap contributors" credit is applied by Cesium in
// the attribution display, which is where an ion asset's credits belong; only
// Google's policies ask for more than that.
OsmBuildings: () => createOsmBuildingsAsync(),
// No Google Maps API key of our own, so this resolves ion asset 2275207 with
// `Ion.defaultAccessToken`. `onlyUsingWithGoogleGeocoder` only silences a
// one-time console warning about geocoders; this app has none at all.
GooglePhotorealistic: () => createGooglePhotorealistic3DTileset({ onlyUsingWithGoogleGeocoder: true }, googleTilesetOptions()),
};
export class SurfaceModel {
#deps: SurfaceModelDeps;
#tileset: Cesium3DTileset | undefined;
/** Which model `#tileset` is, and what a repeat call can therefore skip. */
#name: SurfaceTileset | undefined;
/**
* Guards the async creation. A user can pick a second model, or leave the view
* mode that allowed the first, while a tileset is still being resolved — and
* that answer is then about a scene that no longer wants it.
*/
#generation = 0;
/**
* Whether the tileset should be drawn right now, or undefined for "always".
* Set from the view mode; asked per frame, because what it depends on — the camera,
* the flight — is not something a store can announce.
*/
#gate: (() => boolean) | undefined;
#removeGateWatch: (() => void) | undefined;
/** Whether the current selection wants the globe hidden, from the last effects. */
#hideGlobe = false;
/** The last believable ground height under the camera. Sea level until one arrives. */
#groundHeight = 0;
constructor(deps: SurfaceModelDeps) {
this.#deps = deps;
}
/** The model currently drawing, if any. */
get active(): SurfaceTileset | undefined {
return this.#name;
}
/**
* Make the scene match a selection in a view mode.
*
* Idempotent, and safe to call for a change to either argument: the effects are
* derived from both, so leaving the sky view takes the photorealistic mesh down
* as surely as choosing None does.
*/
async apply(surfaceModel: string, viewMode: string): Promise<void> {
const effects = surfaceEffects(surfaceModel, viewMode);
const generation = ++this.#generation;
this.#hideGlobe = effects.hideGlobe;
this.#deps.setTerrainOverride(effects.terrain);
if (effects.tileset === this.#name) {
// Already right. Two things still have to be re-asserted, because both depend
// on the view mode and it can change while the tileset stays exactly as it
// was: whether the globe is hidden, and how far buildings are worth loading.
this.#syncGlobe();
this.#tuneForViewMode(viewMode);
return;
}
this.#remove();
if (!effects.tileset) {
this.#syncGlobe();
return;
}
// The globe stays up until the tileset is actually there. Hiding it first
// would trade a globe for a black void for as long as the network takes.
const tileset = await this.#create(effects.tileset);
if (generation !== this.#generation) {
// Overtaken while loading. Destroy what arrived rather than adding it: the
// call that overtook this one has already put the scene the way it wants it.
tileset?.destroy();
return;
}
if (!tileset) {
this.#syncGlobe();
return;
}
this.#tileset = tileset;
this.#name = effects.tileset;
this.#deps.scene.primitives.add(tileset);
this.#watchTileFailures(tileset, effects.tileset);
this.#syncGlobe();
this.#tuneForViewMode(viewMode);
// The stack arrived asynchronously and `requestRenderMode` is on, so without
// this the tileset is never traversed and nothing appears — the same reason
// the imagery setter ends this way.
this.#deps.scene.requestRender();
}
/**
* How far OSM Buildings are worth loading, which depends on where you stand.
*
* Cesium already rolls a tileset's screen-space error off with distance for a
* ground-level camera — `dynamicScreenSpaceError` is on by default — but its
* defaults are sized for looking *down* at a city. At the defaults (density
* 2.0e-4, factor 24) buildings keep refining out to some 5.2 km from the eye,
* which from a fixed point two metres above the pavement buys tiles behind
* buildings you cannot see past.
*
* The numbers are derived, not picked. The reduction at distance d is
* `factor * (1 - exp(-(d * density)^2))`, and refinement stops once that reaches
* `maximumScreenSpaceError` (16), so density 8.0e-4 with factor 48 puts the edge
* at about 800 m — further than a street view reaches.
*
* Only in the sky view, and only for this model. On the globe the wider radius is
* the point, and the photorealistic mesh *is* the ground, so capping its radius
* would delete the horizon rather than some buildings behind other buildings.
*
* The cost is the one named when this was chosen: OSM Buildings refines
* additively, so beyond the edge distant buildings are absent rather than coarse.
*/
#tuneForViewMode(viewMode: string): void {
const tileset = this.#tileset;
if (!tileset) {
return;
}
if (this.#name === "GooglePhotorealistic") {
// Not an altitude gate: measured against the ground it would be open for the
// whole descent anyway, and the thing worth waiting for is not a height but an
// arrival. Until then the globe stands in — see `#syncGlobe`.
this.#setGate(() => this.#deps.skyLanded());
return;
}
if (this.#name !== "OsmBuildings") {
return;
}
const onTheGround = viewMode === SKY_MODE;
tileset.dynamicScreenSpaceErrorDensity = onTheGround ? 8.0e-4 : 2.0e-4;
tileset.dynamicScreenSpaceErrorFactor = onTheGround ? 48 : 24;
// No gate standing on the ground: the sky view is under any ceiling by
// definition, and a gate that can never close is a per-frame check for nothing.
this.#setGate(onTheGround ? undefined : () => this.#heightAboveGround() < GLOBE_BUILDING_CEILING);
}
/**
* The camera's height over whatever is under it. `getHeight` is a lookup into tiles
* already loaded, so this is cheap enough to ask every frame.
*
* The last believable answer is kept, the way the sky view keeps its own: while
* terrain is still coming in, `getHeight` answers either nothing or nonsense — a
* coarse tile under the camera has been seen returning -76594 — and treating that
* as sea level would make the gate strictest exactly while it is least informed.
* Anywhere high up that would read as far below the ceiling forever: measured from
* the ellipsoid, La Paz sits 3.6 km over a ceiling of one.
*/
#heightAboveGround(): number {
const cartographic = this.#deps.scene.camera.positionCartographic;
const measured = this.#deps.scene.globe.getHeight(cartographic);
if (isPlausibleGroundHeight(measured)) {
this.#groundHeight = measured;
}
return cartographic.height - this.#groundHeight;
}
/**
* Withhold the tileset until a condition holds, re-asking every frame while there
* is a condition to ask about.
*
* A `preRender` listener rather than something reactive: what the gates depend on —
* the camera's height, whether a flight has landed — is not state any store holds,
* and each check is a property read.
*/
#setGate(gate: (() => boolean) | undefined): void {
this.#gate = gate;
if (gate === undefined) {
this.#removeGateWatch?.();
this.#removeGateWatch = undefined;
if (this.#tileset) {
this.#tileset.show = true;
}
this.#syncGlobe();
return;
}
this.#applyGate();
this.#removeGateWatch ??= this.#deps.scene.preRender.addEventListener(() => this.#applyGate());
}
#applyGate(): void {
const tileset = this.#tileset;
const gate = this.#gate;
if (!tileset || !gate) {
return;
}
const show = gate();
if (tileset.show !== show) {
tileset.show = show;
// The globe is what stands in while a surface model is withheld, so the two
// move together — and request-render mode cannot notice a property changing.
this.#syncGlobe();
this.#deps.scene.requestRender();
}
}
/**
* The height of the model's surface under a point, or undefined when there is
* no model, no support for asking, or no geometry there.
*
* "Most detailed" rather than a per-frame sample: this is asked when the sky
* view arrives somewhere, and the honest answer needs the tiles at that spot
* loaded rather than whichever coarse ancestor happens to be up. Note it clamps
* to the *top* of what is there, so standing where a building stands gives its
* roof — see docs/adr/0005-surface-models.md.
*/
async surfaceHeight(observer: Observer): Promise<number | undefined> {
const { scene } = this.#deps;
const tileset = this.#tileset;
if (!tileset || !scene.clampToHeightSupported) {
return undefined;
}
const [clamped] = await scene.clampToHeightMostDetailed([Cartesian3.fromDegrees(observer.lon, observer.lat, 0)]);
// Guarded on the tileset rather than on `#generation`: a re-apply that
// changes nothing must not throw away a measurement in flight, and a model
// that actually went away has no height to report.
if (this.#tileset !== tileset || !clamped) {
return undefined;
}
return Cartographic.fromCartesian(clamped).height;
}
/**
* Report tiles that fail after the tileset is up, and only report them.
*
* A single failed tile is not grounds for tearing the surface down — the rest of
* the scene is fine and the next camera move may not even ask for it again. But
* it is worth one line, because a quota that runs out mid-session looks exactly
* like this and nothing else would say so.
*
* Bounded to the first failure on purpose: the same causes that produce one
* produce hundreds, and a console flooded by them is no more informative than a
* console with one line in it.
*/
#watchTileFailures(tileset: Cesium3DTileset, name: SurfaceTileset): void {
let reported = false;
tileset.tileFailed.addEventListener((error: { url?: string; message?: string }) => {
if (reported) {
return;
}
reported = true;
console.warn(`Surface model ${name} failed to load a tile, and further tile failures will not be reported`, error.url, error.message);
});
}
async #create(name: SurfaceTileset): Promise<Cesium3DTileset | undefined> {
try {
return await SURFACE_TILESETS[name]();
} catch (error) {
// Every way this fails looks the same from here — a token ion rejects, an
// exhausted quota, a network that is not there — and none of them leave a
// selection worth keeping.
console.error(`Surface model ${name} failed to load`, error);
this.#deps.onFailure(name, error);
return undefined;
}
}
#remove(): void {
const tileset = this.#tileset;
this.#tileset = undefined;
this.#name = undefined;
this.#removeGateWatch?.();
this.#removeGateWatch = undefined;
this.#gate = undefined;
if (tileset) {
// `remove` destroys it, which is what releases the tile cache — up to half
// a gigabyte for the photorealistic mesh.
this.#deps.scene.primitives.remove(tileset);
this.#deps.scene.requestRender();
}
}
/**
* The globe is visible unless a surface model is actually standing in for it —
* asked of the tileset rather than of the selection, so a model that failed to
* load, has not arrived yet, or is being withheld by its gate leaves a globe
* rather than a black void. That last case is what lets the mesh wait for the
* descent to land: on the way down you are looking at the globe.
*
* Only ever driven from here, so there is no one else's `show` to preserve.
*/
#syncGlobe(): void {
const standingIn = this.#hideGlobe && this.#tileset !== undefined && this.#tileset.show;
const { globe } = this.#deps.scene;
if (globe.show !== !standingIn) {
globe.show = !standingIn;
this.#deps.scene.requestRender();
}
}
}