orbit-studio / src /config /surfaceModels.ts
moncefem's picture
Deploy Orbit Studio propagator
9f21d0a
Raw
History Blame Contribute Delete
6.78 kB
// The surface model vocabulary, and every consequence of picking one.
//
// Kept here, free of Cesium, because three places have to agree about what a
// selection implies and none of them should restate it: the url schema, the Map
// menu (which has to say when a control no longer describes the picture), and
// SurfaceModel (which executes it). The matrix is also the part worth testing —
// see surfaceModels.test.ts.
//
// The two models are deliberately not symmetric. OsmBuildings adds extruded
// footprints to the globe's own surface; GooglePhotorealistic *is* the surface,
// ground and vegetation and buildings in one mesh, so the globe underneath has
// to go. See docs/adr/0005-surface-models.md.
import { SKY_MODE } from "./viewModes";
export const SURFACE_MODELS = ["None", "OsmBuildings", "GooglePhotorealistic"] as const;
// Named for what it is — a name — because `SurfaceModel` is also the class that
// owns the tileset (src/modules/SurfaceModel.ts), and one import trap of that
// kind is enough (see the note on SceneMode in src/config/viewModes.ts).
export type SurfaceModelName = (typeof SURFACE_MODELS)[number];
export type SurfaceTileset = Exclude<SurfaceModelName, "None">;
/** The Map menu groups whose selection a surface model can render meaningless. */
export type MapGroup = "layers" | "terrain";
interface SurfaceModelRules {
/**
* The view modes the model applies in.
*
* GooglePhotorealistic is the sky view only, and that is a cost decision, not
* a technical one: streamed from a fixed viewpoint looking up, tile loading is
* bounded by where the observer stands, where on the globe it is bounded only
* by how far someone cares to fly. Widening it is this one line.
*
* Neither applies in 2D or Columbus. Cesium does not refuse a tileset there —
* `Cesium3DTile` carries a 2D screen-space-error branch — so this is a choice
* too: full tile bandwidth for geometry that reads as broken is worse than
* nothing.
*/
viewModes: readonly string[];
/** Whether the model replaces the globe's surface rather than adding to it. */
hidesGlobe: boolean;
/** The terrain the model's heights assume, forced for as long as it is up. */
terrain?: string;
}
const RULES: Record<SurfaceModelName, SurfaceModelRules> = {
None: {
viewModes: ["3D", "2D", "Columbus", SKY_MODE],
hidesGlobe: false,
},
OsmBuildings: {
viewModes: ["3D", SKY_MODE],
hidesGlobe: false,
// The tileset is authored against Cesium World Terrain and Cesium has no
// ground-clamping for tilesets, so any other terrain leaves the buildings
// floating or buried by the difference — metres in flat country, far more in
// the mountains, and worst of all directly under a sky-view observer.
terrain: "CesiumWorldTerrain",
},
GooglePhotorealistic: {
viewModes: [SKY_MODE],
hidesGlobe: true,
},
};
export interface SurfaceEffects {
/** The tileset the scene should hold, or undefined for none. */
tileset: SurfaceTileset | undefined;
hideGlobe: boolean;
/** Terrain to impose over the user's choice, or undefined to honour it. */
terrain: string | undefined;
/** Groups whose controls no longer describe what is drawn. */
inert: readonly MapGroup[];
/**
* Models that cannot apply in this view mode. They stay selectable — arming
* one before entering the sky view is a reasonable thing to do, and it is what
* makes `?surface=GooglePhotorealistic&scene=Sky` a working link — so the menu
* annotates them rather than disabling them.
*/
unavailable: readonly SurfaceModelName[];
}
export function isSurfaceModelName(value: string): value is SurfaceModelName {
return (SURFACE_MODELS as readonly string[]).includes(value);
}
/**
* What a selection means in a given view mode: what to draw, what to hide, what
* to override, and what the menu should stop claiming.
*
* Total in both arguments, and the single source for all four answers, so the
* menu's annotations cannot drift from what the scene actually does.
*/
export function surfaceEffects(surfaceModel: string, viewMode: string): SurfaceEffects {
const unavailable = SURFACE_MODELS.filter((name) => !RULES[name].viewModes.includes(viewMode));
const selected: SurfaceModelName = isSurfaceModelName(surfaceModel) ? surfaceModel : "None";
const rules = RULES[selected];
// Suppressed, never deselected: an unavailable model keeps its place in the
// store and the url, so leaving 2D or entering the sky view brings it back
// rather than making the user ask twice.
const active = selected !== "None" && rules.viewModes.includes(viewMode);
if (!active) {
return { tileset: undefined, hideGlobe: false, terrain: undefined, inert: [], unavailable };
}
const inert: MapGroup[] = [];
// Nothing is drawn of the globe, so neither its imagery nor its terrain is.
if (rules.hidesGlobe) {
inert.push("layers", "terrain");
} else if (rules.terrain !== undefined) {
inert.push("terrain");
}
return {
tileset: selected as SurfaceTileset,
hideGlobe: rules.hidesGlobe,
terrain: rules.terrain,
inert,
unavailable,
};
}
/**
* How a view mode is said in a sentence, where that differs from its name. "Sky"
* is the app's own name for a vantage point rather than a projection, and reads
* as "the sky view" in prose.
*/
const VIEW_MODE_PROSE: Record<string, string> = { [SKY_MODE]: "sky" };
/**
* The menu's explanation for a model that cannot apply in the current view mode,
* built from the same `viewModes` the suppression is built from.
*
* Derived rather than written out, because a sentence written out is a second
* place the rule lives: widening `viewModes` would leave the menu asserting a
* restriction that no longer exists, which is worse than saying nothing. It also
* keeps the claim in docs/adr/0005-surface-models.md true — widening really is
* the one line.
*/
export function viewModeNote(surfaceModel: string): string {
if (!isSurfaceModelName(surfaceModel)) {
return "";
}
const modes = RULES[surfaceModel].viewModes.map((mode) => VIEW_MODE_PROSE[mode] ?? mode);
if (modes.length === 0) {
return "Applies in no view mode";
}
const last = modes[modes.length - 1] as string;
const list = modes.length === 1 ? last : `${modes.slice(0, -1).join(", ")} and ${last}`;
return `Applies in the ${list} view${modes.length === 1 ? "" : "s"} only`;
}
// No name accessor here, unlike the imagery and terrain registries: those exist
// to *filter* what is selectable (base vs overlay, visible vs hidden), and every
// surface model is selectable. `SURFACE_MODELS` is the list; a function returning
// it verbatim, behind a getter returning that, was two layers of nothing.