// 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 Promise> = { // 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 { 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 { 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 { 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(); } } }