File size: 17,718 Bytes
9f21d0a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
// 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();
    }
  }
}