feat(foundry): add OSM cache survey presets
This commit is contained in:
@@ -37,12 +37,14 @@ import {
|
||||
} from "cesium";
|
||||
import "cesium/Build/Cesium/Widgets/widgets.css";
|
||||
import sceneFixture from "../../../registry/fixtures/map/map-operational-v0.1.json";
|
||||
import { spiralSurfaceFrame, type GeodeticRadians } from "./mapSpiralMath.js";
|
||||
import { MAX_SPIRAL_RADIUS_METERS, spiralSurfaceFrame, type GeodeticRadians } from "./mapSpiralMath.js";
|
||||
import { cameraSurveyPitchForViewport, cameraSurveySampleDistances, cameraSurveySpiralDistance } from "./mapCameraPresets.js";
|
||||
import { mapRuntimeEntityId, type MapRuntimeBinding, type MapRuntimeFact } from "./useMapDataProductRuntime.js";
|
||||
|
||||
type Position = [number, number, number?];
|
||||
const MAX_SPIRAL_SUBSTEPS_PER_FRAME = 300;
|
||||
const TERRAIN_SAMPLE_TIMEOUT_MS = 12_000;
|
||||
const SPIRAL_TILE_WAIT_TIMEOUT_MS = 45_000;
|
||||
type PinPresentation = {
|
||||
variant: "elevated-spike";
|
||||
stemHeightMeters: number;
|
||||
@@ -186,11 +188,14 @@ export type CameraSpiralConfig = {
|
||||
heightAboveGroundMeters: number;
|
||||
speedMetersPerSecond: number;
|
||||
pitchMetersPerTurn: number;
|
||||
targetRadiusMeters?: number;
|
||||
viewPitchRadians?: number;
|
||||
waitForTiles?: boolean;
|
||||
};
|
||||
|
||||
export type CameraSpiralState = {
|
||||
running: boolean;
|
||||
reason?: "stopped" | "mode_disabled" | "renderer_restarted" | "page_hidden" | "render_error" | "spiral_extent_limit" | "spiral_runtime_error" | "terrain_sampling_error";
|
||||
reason?: "stopped" | "mode_disabled" | "renderer_restarted" | "page_hidden" | "render_error" | "spiral_extent_limit" | "spiral_runtime_error" | "terrain_sampling_error" | "tile_loading_timeout" | "tile_loading_error" | "target_radius_reached";
|
||||
};
|
||||
|
||||
export type CesiumMapRendererHandle = {
|
||||
@@ -214,6 +219,7 @@ type SpiralSession = {
|
||||
surfaceDistanceMeters: number;
|
||||
lastTimestamp: number | null;
|
||||
pendingElapsedSeconds: number;
|
||||
tilesWaitStartedAt: number | null;
|
||||
requestId: number | null;
|
||||
previousCameraInputsEnabled: boolean;
|
||||
terrainProvider: CesiumTerrainProvider | null;
|
||||
@@ -224,6 +230,11 @@ type SpiralSession = {
|
||||
terrainRequestGeneration: number;
|
||||
terrainRetryAt: number;
|
||||
terrainSampleSpacingMeters: number;
|
||||
targetSurfaceDistanceMeters: number;
|
||||
buildingsTileset: Cesium3DTileset | null;
|
||||
previousBuildingsCullRequestsWhileMoving: boolean | null;
|
||||
previousBuildingsFoveatedScreenSpaceError: boolean | null;
|
||||
previousBuildingsFoveatedTimeDelay: number | null;
|
||||
};
|
||||
|
||||
const toCartesian = ([longitude, latitude, height = 0]: Position) => Cartesian3.fromDegrees(longitude, latitude, height);
|
||||
@@ -260,6 +271,22 @@ function getCameraView(viewer: Viewer): MapCameraView {
|
||||
};
|
||||
}
|
||||
|
||||
function horizontalFieldOfViewRadians(viewer: Viewer) {
|
||||
const frustum = viewer.camera.frustum as unknown as {
|
||||
fovy?: number;
|
||||
fov?: number;
|
||||
aspectRatio?: number;
|
||||
};
|
||||
const aspectRatio = Number.isFinite(frustum.aspectRatio) && Number(frustum.aspectRatio) > 0
|
||||
? Number(frustum.aspectRatio)
|
||||
: Math.max(0.1, viewer.canvas.clientWidth / Math.max(1, viewer.canvas.clientHeight));
|
||||
if (Number.isFinite(frustum.fovy) && Number(frustum.fovy) > 0) {
|
||||
return 2 * Math.atan(Math.tan(Number(frustum.fovy) / 2) * aspectRatio);
|
||||
}
|
||||
if (Number.isFinite(frustum.fov) && Number(frustum.fov) > 0) return Number(frustum.fov);
|
||||
return Math.PI / 3;
|
||||
}
|
||||
|
||||
function interpolateTerrainRouteHeight(samples: TerrainRouteSample[], distanceMeters: number) {
|
||||
if (samples.length === 0) return null;
|
||||
if (distanceMeters === samples[0].distanceMeters) return samples[0].heightMeters;
|
||||
@@ -670,6 +697,17 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
||||
spiralSessionRef.current = null;
|
||||
if (session.requestId !== null) window.cancelAnimationFrame(session.requestId);
|
||||
if (!session.viewer.isDestroyed()) {
|
||||
if (session.buildingsTileset && !session.buildingsTileset.isDestroyed()) {
|
||||
if (session.previousBuildingsCullRequestsWhileMoving !== null) {
|
||||
session.buildingsTileset.cullRequestsWhileMoving = session.previousBuildingsCullRequestsWhileMoving;
|
||||
}
|
||||
if (session.previousBuildingsFoveatedScreenSpaceError !== null) {
|
||||
session.buildingsTileset.foveatedScreenSpaceError = session.previousBuildingsFoveatedScreenSpaceError;
|
||||
}
|
||||
if (session.previousBuildingsFoveatedTimeDelay !== null) {
|
||||
session.buildingsTileset.foveatedTimeDelay = session.previousBuildingsFoveatedTimeDelay;
|
||||
}
|
||||
}
|
||||
session.viewer.scene.screenSpaceCameraController.enableInputs = session.previousCameraInputsEnabled;
|
||||
session.viewer.scene.requestRender();
|
||||
rebuildGridRef.current?.();
|
||||
@@ -683,27 +721,48 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
||||
const position = viewer?.camera.positionCartographic;
|
||||
if (!viewer || viewer.isDestroyed() || !position) return false;
|
||||
if (![input.heightAboveGroundMeters, input.speedMetersPerSecond, input.pitchMetersPerTurn].every(Number.isFinite)) return false;
|
||||
if (input.targetRadiusMeters !== undefined && !Number.isFinite(input.targetRadiusMeters)) return false;
|
||||
if (input.viewPitchRadians !== undefined && !Number.isFinite(input.viewPitchRadians)) return false;
|
||||
const terrainProvider = presentationRef.current.terrainEnabled ? terrainRef.current?.world ?? null : null;
|
||||
if (presentationRef.current.terrainEnabled && !terrainProvider) return false;
|
||||
const heightAboveGroundMeters = clamp(input.heightAboveGroundMeters, 10, 100_000);
|
||||
const config: CameraSpiralConfig = {
|
||||
heightAboveGroundMeters: clamp(input.heightAboveGroundMeters, 10, 100_000),
|
||||
heightAboveGroundMeters,
|
||||
speedMetersPerSecond: clamp(input.speedMetersPerSecond, 1, 5_000),
|
||||
pitchMetersPerTurn: clamp(input.pitchMetersPerTurn, 20, 100_000),
|
||||
// Preserve overlap even in a narrow/portrait viewport. Presets express
|
||||
// an upper bound; the live frustum may reduce the actual turn pitch.
|
||||
pitchMetersPerTurn: clamp(
|
||||
cameraSurveyPitchForViewport(
|
||||
heightAboveGroundMeters,
|
||||
input.pitchMetersPerTurn,
|
||||
horizontalFieldOfViewRadians(viewer),
|
||||
),
|
||||
1,
|
||||
100_000,
|
||||
),
|
||||
targetRadiusMeters: clamp(input.targetRadiusMeters ?? 25_000, 100, MAX_SPIRAL_RADIUS_METERS),
|
||||
...(input.viewPitchRadians !== undefined
|
||||
? { viewPitchRadians: clamp(input.viewPitchRadians, -Math.PI / 2 + 0.01, -0.05) }
|
||||
: {}),
|
||||
waitForTiles: input.waitForTiles === true,
|
||||
};
|
||||
stopSpiralAnimation("renderer_restarted");
|
||||
viewer.camera.cancelFlight();
|
||||
viewer.camera.lookAtTransform(Matrix4.IDENTITY);
|
||||
const controller = viewer.scene.screenSpaceCameraController;
|
||||
const buildingsTileset = buildingsRef.current;
|
||||
const session: SpiralSession = {
|
||||
viewer,
|
||||
origin: { longitude: position.longitude, latitude: position.latitude },
|
||||
initialHeading: Number.isFinite(viewer.camera.heading) ? viewer.camera.heading : 0,
|
||||
pitch: clamp(Number.isFinite(viewer.camera.pitch) ? viewer.camera.pitch : -Math.PI / 4, -Math.PI / 2 + 0.01, -0.05),
|
||||
pitch: config.viewPitchRadians
|
||||
?? clamp(Number.isFinite(viewer.camera.pitch) ? viewer.camera.pitch : -Math.PI / 4, -Math.PI / 2 + 0.01, -0.05),
|
||||
roll: 0,
|
||||
config,
|
||||
surfaceDistanceMeters: 0,
|
||||
lastTimestamp: null,
|
||||
pendingElapsedSeconds: 0,
|
||||
tilesWaitStartedAt: null,
|
||||
requestId: null,
|
||||
previousCameraInputsEnabled: controller.enableInputs,
|
||||
terrainProvider,
|
||||
@@ -714,7 +773,23 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
||||
terrainRequestGeneration: 0,
|
||||
terrainRetryAt: 0,
|
||||
terrainSampleSpacingMeters: clamp(config.speedMetersPerSecond / 30, 10, 250),
|
||||
targetSurfaceDistanceMeters: cameraSurveySpiralDistance(
|
||||
config.targetRadiusMeters ?? 25_000,
|
||||
config.pitchMetersPerTurn,
|
||||
),
|
||||
buildingsTileset,
|
||||
previousBuildingsCullRequestsWhileMoving: buildingsTileset?.cullRequestsWhileMoving ?? null,
|
||||
previousBuildingsFoveatedScreenSpaceError: buildingsTileset?.foveatedScreenSpaceError ?? null,
|
||||
previousBuildingsFoveatedTimeDelay: buildingsTileset?.foveatedTimeDelay ?? null,
|
||||
};
|
||||
// A survey must request the whole visible OSM Buildings footprint while
|
||||
// the camera moves. Cesium's interactive defaults deliberately defer and
|
||||
// cull edge requests; restore them when the survey stops.
|
||||
if (buildingsTileset) {
|
||||
buildingsTileset.cullRequestsWhileMoving = false;
|
||||
buildingsTileset.foveatedScreenSpaceError = false;
|
||||
buildingsTileset.foveatedTimeDelay = 0;
|
||||
}
|
||||
controller.enableInputs = false;
|
||||
spiralSessionRef.current = session;
|
||||
|
||||
@@ -755,6 +830,7 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
||||
|
||||
const requestTerrainSamples = () => {
|
||||
if (!session.terrainProvider || session.terrainSamplePending || spiralSessionRef.current !== session) return;
|
||||
if (session.terrainSampleExhausted) return;
|
||||
if (Date.now() < session.terrainRetryAt) return;
|
||||
const lastSample = session.terrainSamples.at(-1);
|
||||
// Prefetch before the camera reaches the buffer tail. At normal and
|
||||
@@ -764,8 +840,14 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
||||
const distances: number[] = [];
|
||||
const positions: Cartographic[] = [];
|
||||
let reachedExtent = false;
|
||||
for (let index = 0; index < 120; index += 1) {
|
||||
const distanceMeters = startDistance + index * session.terrainSampleSpacingMeters;
|
||||
let reachedTargetDistance = false;
|
||||
const plannedDistances = cameraSurveySampleDistances(
|
||||
startDistance,
|
||||
session.terrainSampleSpacingMeters,
|
||||
session.targetSurfaceDistanceMeters,
|
||||
120,
|
||||
);
|
||||
for (const distanceMeters of plannedDistances) {
|
||||
try {
|
||||
const frame = spiralSurfaceFrame(
|
||||
session.origin,
|
||||
@@ -775,10 +857,13 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
||||
);
|
||||
distances.push(distanceMeters);
|
||||
positions.push(new Cartographic(frame.longitude, frame.latitude, 0));
|
||||
if (distanceMeters >= session.targetSurfaceDistanceMeters) {
|
||||
reachedTargetDistance = true;
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "spiral_extent_limit") {
|
||||
reachedExtent = true;
|
||||
session.terrainSampleExhausted = true;
|
||||
} else {
|
||||
stopSpiralAnimation("spiral_runtime_error");
|
||||
}
|
||||
@@ -814,6 +899,7 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
||||
});
|
||||
session.terrainSamples = [...session.terrainSamples, ...nextSamples]
|
||||
.filter((sample) => sample.distanceMeters >= session.surfaceDistanceMeters - session.terrainSampleSpacingMeters * 2);
|
||||
if (reachedTargetDistance || reachedExtent) session.terrainSampleExhausted = true;
|
||||
session.terrainSamplePending = false;
|
||||
session.terrainSampleFailures = 0;
|
||||
session.terrainRetryAt = 0;
|
||||
@@ -838,10 +924,33 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
||||
if (document.hidden) {
|
||||
session.lastTimestamp = null;
|
||||
session.pendingElapsedSeconds = 0;
|
||||
session.tilesWaitStartedAt = null;
|
||||
} else if (session.lastTimestamp === null) {
|
||||
session.lastTimestamp = timestamp;
|
||||
} else {
|
||||
const elapsedSeconds = Math.max(0, (timestamp - session.lastTimestamp) / 1000);
|
||||
const currentTilesReady = !session.config.waitForTiles
|
||||
|| (viewer.scene.globe.tilesLoaded && (!session.buildingsTileset || session.buildingsTileset.tilesLoaded));
|
||||
if (!currentTilesReady) {
|
||||
session.tilesWaitStartedAt ??= timestamp;
|
||||
if (timestamp - session.tilesWaitStartedAt >= SPIRAL_TILE_WAIT_TIMEOUT_MS) {
|
||||
stopSpiralAnimation("tile_loading_timeout");
|
||||
return;
|
||||
}
|
||||
// Do not accumulate route time while the current Terrain, Imagery or
|
||||
// OSM Buildings footprint is still resolving through TileCache.
|
||||
session.lastTimestamp = timestamp;
|
||||
session.pendingElapsedSeconds = 0;
|
||||
requestTerrainSamples();
|
||||
viewer.scene.requestRender();
|
||||
session.requestId = window.requestAnimationFrame(tick);
|
||||
return;
|
||||
}
|
||||
session.tilesWaitStartedAt = null;
|
||||
// Never catch up a stalled UI thread with one large camera jump. A
|
||||
// cache survey values continuous visible coverage over wall-clock
|
||||
// speed; limiting one rendered advance to 1/30 s keeps intermediate
|
||||
// viewports observable by Cesium before the next tile-settle gate.
|
||||
const elapsedSeconds = Math.min(1 / 30, Math.max(0, (timestamp - session.lastTimestamp) / 1000));
|
||||
session.lastTimestamp = timestamp;
|
||||
session.pendingElapsedSeconds += elapsedSeconds;
|
||||
try {
|
||||
@@ -857,6 +966,10 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
||||
candidateDistance,
|
||||
session.config.pitchMetersPerTurn,
|
||||
);
|
||||
if (candidateFrame.radiusMeters >= (session.config.targetRadiusMeters ?? MAX_SPIRAL_RADIUS_METERS)) {
|
||||
stopSpiralAnimation("target_radius_reached");
|
||||
return;
|
||||
}
|
||||
const candidateGround = groundHeightAtDistance(candidateDistance);
|
||||
if (candidateGround === null) {
|
||||
const lastTerrainSample = session.terrainSamples.at(-1);
|
||||
@@ -919,6 +1032,7 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
||||
if (!session || !document.hidden) return;
|
||||
session.lastTimestamp = null;
|
||||
session.pendingElapsedSeconds = 0;
|
||||
session.tilesWaitStartedAt = null;
|
||||
};
|
||||
window.addEventListener("pagehide", stopForPageLeave);
|
||||
document.addEventListener("visibilitychange", resetHiddenClock);
|
||||
@@ -960,6 +1074,7 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
||||
let removeGridCameraListener: (() => void) | undefined;
|
||||
let removeRefreshRenderListener: (() => void) | undefined;
|
||||
let removeRenderErrorListener: (() => void) | undefined;
|
||||
const removeProviderFailureListeners: Array<() => void> = [];
|
||||
let cancelled = false;
|
||||
|
||||
const start = async () => {
|
||||
@@ -1048,7 +1163,9 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
||||
buildings: "Platform Map Gateway недоступен",
|
||||
},
|
||||
};
|
||||
const providerTileFailureLatched = { imagery: false, terrain: false, buildings: false };
|
||||
const reportProvider = (provider: "imagery" | "terrain" | "buildings", state: MapProviderState, error?: unknown) => {
|
||||
if (state === "ready" && providerTileFailureLatched[provider]) return;
|
||||
providerStatus[provider] = state;
|
||||
if (state === "error") {
|
||||
providerStatus.errors[provider] = error instanceof Error && error.message ? error.message : "provider_unavailable";
|
||||
@@ -1057,6 +1174,15 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
||||
}
|
||||
if (!cancelled) onProviderStatus?.({ ...providerStatus, errors: { ...providerStatus.errors } });
|
||||
};
|
||||
const latchProviderTileFailure = (provider: "imagery" | "terrain" | "buildings") => {
|
||||
if (cancelled) return;
|
||||
// Keep the provider failed until the renderer is recreated. Merely
|
||||
// emptying Cesium's request queues cannot turn a viewport with a
|
||||
// failed tile back into a successful cache survey.
|
||||
providerTileFailureLatched[provider] = true;
|
||||
stopSpiralAnimation("tile_loading_error");
|
||||
reportProvider(provider, "error", new Error(`${provider}_tile_failed`));
|
||||
};
|
||||
onProviderStatus?.({ ...providerStatus, errors: { ...providerStatus.errors } });
|
||||
let renderRecoveryScheduled = false;
|
||||
removeRenderErrorListener = viewer.scene.renderError.addEventListener((_scene, error) => {
|
||||
@@ -1091,6 +1217,7 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
||||
tileProtocol: "https",
|
||||
});
|
||||
if (cancelled || !viewer || viewer.isDestroyed()) return;
|
||||
removeProviderFailureListeners.push(imageryProvider.errorEvent.addEventListener(() => latchProviderTileFailure("imagery")));
|
||||
for (const attribution of endpoint.attributions) if (attribution.html) viewer.creditDisplay.addStaticCredit(new Credit(attribution.html, attribution.collapsible));
|
||||
imageryLayerRef.current = viewer.imageryLayers.addImageryProvider(imageryProvider);
|
||||
applyPresentation(viewer, imageryLayerRef.current, buildingsRef.current, terrain, presentationRef.current);
|
||||
@@ -1101,6 +1228,7 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
||||
const terrainResource = buildResource(terrainEndpoint.url);
|
||||
const world = await CesiumTerrainProvider.fromUrl(terrainResource, { requestVertexNormals: true, requestWaterMask: true });
|
||||
if (cancelled || !viewer || viewer.isDestroyed()) return;
|
||||
removeProviderFailureListeners.push(world.errorEvent.addEventListener(() => latchProviderTileFailure("terrain")));
|
||||
terrain.world = world;
|
||||
for (const attribution of terrainEndpoint.attributions) if (attribution.html) viewer.creditDisplay.addStaticCredit(new Credit(attribution.html, attribution.collapsible));
|
||||
applyPresentation(viewer, imageryLayerRef.current, buildingsRef.current, terrain, presentationRef.current);
|
||||
@@ -1108,10 +1236,11 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
||||
reportProvider("terrain", "ready");
|
||||
}).catch((error) => reportProvider("terrain", "error", error));
|
||||
void loadEndpoint("96188").then(async (buildingsEndpoint) => {
|
||||
if (!buildingsEndpoint.url || buildingsEndpoint.credentialMode !== "gateway") throw new Error("buildings_endpoint_invalid");
|
||||
if (buildingsEndpoint.type !== "3DTILES" || !buildingsEndpoint.url || buildingsEndpoint.credentialMode !== "gateway") throw new Error("buildings_endpoint_invalid");
|
||||
const buildingsResource = buildResource(buildingsEndpoint.url);
|
||||
const buildings = await Cesium3DTileset.fromUrl(buildingsResource);
|
||||
if (cancelled || !viewer || viewer.isDestroyed()) return;
|
||||
removeProviderFailureListeners.push(buildings.tileFailed.addEventListener(() => latchProviderTileFailure("buildings")));
|
||||
viewer.scene.primitives.add(buildings);
|
||||
buildingsRef.current = buildings;
|
||||
for (const attribution of buildingsEndpoint.attributions) if (attribution.html) viewer.creditDisplay.addStaticCredit(new Credit(attribution.html, attribution.collapsible));
|
||||
@@ -1202,6 +1331,7 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
||||
removeGridCameraListener?.();
|
||||
removeRefreshRenderListener?.();
|
||||
removeRenderErrorListener?.();
|
||||
for (const removeListener of removeProviderFailureListeners) removeListener();
|
||||
handler?.destroy();
|
||||
if (viewer && !viewer.isDestroyed()) viewer.destroy();
|
||||
viewerRef.current = null;
|
||||
|
||||
Reference in New Issue
Block a user