feat(foundry): add OSM cache survey presets

This commit is contained in:
Codex 2026-07-16 16:03:32 +03:00
parent e96401358b
commit 751d9963f1
7 changed files with 656 additions and 31 deletions

View File

@ -37,12 +37,14 @@ import {
} from "cesium"; } from "cesium";
import "cesium/Build/Cesium/Widgets/widgets.css"; import "cesium/Build/Cesium/Widgets/widgets.css";
import sceneFixture from "../../../registry/fixtures/map/map-operational-v0.1.json"; 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"; import { mapRuntimeEntityId, type MapRuntimeBinding, type MapRuntimeFact } from "./useMapDataProductRuntime.js";
type Position = [number, number, number?]; type Position = [number, number, number?];
const MAX_SPIRAL_SUBSTEPS_PER_FRAME = 300; const MAX_SPIRAL_SUBSTEPS_PER_FRAME = 300;
const TERRAIN_SAMPLE_TIMEOUT_MS = 12_000; const TERRAIN_SAMPLE_TIMEOUT_MS = 12_000;
const SPIRAL_TILE_WAIT_TIMEOUT_MS = 45_000;
type PinPresentation = { type PinPresentation = {
variant: "elevated-spike"; variant: "elevated-spike";
stemHeightMeters: number; stemHeightMeters: number;
@ -186,11 +188,14 @@ export type CameraSpiralConfig = {
heightAboveGroundMeters: number; heightAboveGroundMeters: number;
speedMetersPerSecond: number; speedMetersPerSecond: number;
pitchMetersPerTurn: number; pitchMetersPerTurn: number;
targetRadiusMeters?: number;
viewPitchRadians?: number;
waitForTiles?: boolean;
}; };
export type CameraSpiralState = { export type CameraSpiralState = {
running: boolean; 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 = { export type CesiumMapRendererHandle = {
@ -214,6 +219,7 @@ type SpiralSession = {
surfaceDistanceMeters: number; surfaceDistanceMeters: number;
lastTimestamp: number | null; lastTimestamp: number | null;
pendingElapsedSeconds: number; pendingElapsedSeconds: number;
tilesWaitStartedAt: number | null;
requestId: number | null; requestId: number | null;
previousCameraInputsEnabled: boolean; previousCameraInputsEnabled: boolean;
terrainProvider: CesiumTerrainProvider | null; terrainProvider: CesiumTerrainProvider | null;
@ -224,6 +230,11 @@ type SpiralSession = {
terrainRequestGeneration: number; terrainRequestGeneration: number;
terrainRetryAt: number; terrainRetryAt: number;
terrainSampleSpacingMeters: 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); 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) { function interpolateTerrainRouteHeight(samples: TerrainRouteSample[], distanceMeters: number) {
if (samples.length === 0) return null; if (samples.length === 0) return null;
if (distanceMeters === samples[0].distanceMeters) return samples[0].heightMeters; if (distanceMeters === samples[0].distanceMeters) return samples[0].heightMeters;
@ -670,6 +697,17 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
spiralSessionRef.current = null; spiralSessionRef.current = null;
if (session.requestId !== null) window.cancelAnimationFrame(session.requestId); if (session.requestId !== null) window.cancelAnimationFrame(session.requestId);
if (!session.viewer.isDestroyed()) { 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.screenSpaceCameraController.enableInputs = session.previousCameraInputsEnabled;
session.viewer.scene.requestRender(); session.viewer.scene.requestRender();
rebuildGridRef.current?.(); rebuildGridRef.current?.();
@ -683,27 +721,48 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
const position = viewer?.camera.positionCartographic; const position = viewer?.camera.positionCartographic;
if (!viewer || viewer.isDestroyed() || !position) return false; if (!viewer || viewer.isDestroyed() || !position) return false;
if (![input.heightAboveGroundMeters, input.speedMetersPerSecond, input.pitchMetersPerTurn].every(Number.isFinite)) 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; const terrainProvider = presentationRef.current.terrainEnabled ? terrainRef.current?.world ?? null : null;
if (presentationRef.current.terrainEnabled && !terrainProvider) return false; if (presentationRef.current.terrainEnabled && !terrainProvider) return false;
const heightAboveGroundMeters = clamp(input.heightAboveGroundMeters, 10, 100_000);
const config: CameraSpiralConfig = { const config: CameraSpiralConfig = {
heightAboveGroundMeters: clamp(input.heightAboveGroundMeters, 10, 100_000), heightAboveGroundMeters,
speedMetersPerSecond: clamp(input.speedMetersPerSecond, 1, 5_000), 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"); stopSpiralAnimation("renderer_restarted");
viewer.camera.cancelFlight(); viewer.camera.cancelFlight();
viewer.camera.lookAtTransform(Matrix4.IDENTITY); viewer.camera.lookAtTransform(Matrix4.IDENTITY);
const controller = viewer.scene.screenSpaceCameraController; const controller = viewer.scene.screenSpaceCameraController;
const buildingsTileset = buildingsRef.current;
const session: SpiralSession = { const session: SpiralSession = {
viewer, viewer,
origin: { longitude: position.longitude, latitude: position.latitude }, origin: { longitude: position.longitude, latitude: position.latitude },
initialHeading: Number.isFinite(viewer.camera.heading) ? viewer.camera.heading : 0, 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, roll: 0,
config, config,
surfaceDistanceMeters: 0, surfaceDistanceMeters: 0,
lastTimestamp: null, lastTimestamp: null,
pendingElapsedSeconds: 0, pendingElapsedSeconds: 0,
tilesWaitStartedAt: null,
requestId: null, requestId: null,
previousCameraInputsEnabled: controller.enableInputs, previousCameraInputsEnabled: controller.enableInputs,
terrainProvider, terrainProvider,
@ -714,7 +773,23 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
terrainRequestGeneration: 0, terrainRequestGeneration: 0,
terrainRetryAt: 0, terrainRetryAt: 0,
terrainSampleSpacingMeters: clamp(config.speedMetersPerSecond / 30, 10, 250), 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; controller.enableInputs = false;
spiralSessionRef.current = session; spiralSessionRef.current = session;
@ -755,6 +830,7 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
const requestTerrainSamples = () => { const requestTerrainSamples = () => {
if (!session.terrainProvider || session.terrainSamplePending || spiralSessionRef.current !== session) return; if (!session.terrainProvider || session.terrainSamplePending || spiralSessionRef.current !== session) return;
if (session.terrainSampleExhausted) return;
if (Date.now() < session.terrainRetryAt) return; if (Date.now() < session.terrainRetryAt) return;
const lastSample = session.terrainSamples.at(-1); const lastSample = session.terrainSamples.at(-1);
// Prefetch before the camera reaches the buffer tail. At normal and // Prefetch before the camera reaches the buffer tail. At normal and
@ -764,8 +840,14 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
const distances: number[] = []; const distances: number[] = [];
const positions: Cartographic[] = []; const positions: Cartographic[] = [];
let reachedExtent = false; let reachedExtent = false;
for (let index = 0; index < 120; index += 1) { let reachedTargetDistance = false;
const distanceMeters = startDistance + index * session.terrainSampleSpacingMeters; const plannedDistances = cameraSurveySampleDistances(
startDistance,
session.terrainSampleSpacingMeters,
session.targetSurfaceDistanceMeters,
120,
);
for (const distanceMeters of plannedDistances) {
try { try {
const frame = spiralSurfaceFrame( const frame = spiralSurfaceFrame(
session.origin, session.origin,
@ -775,10 +857,13 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
); );
distances.push(distanceMeters); distances.push(distanceMeters);
positions.push(new Cartographic(frame.longitude, frame.latitude, 0)); positions.push(new Cartographic(frame.longitude, frame.latitude, 0));
if (distanceMeters >= session.targetSurfaceDistanceMeters) {
reachedTargetDistance = true;
break;
}
} catch (error) { } catch (error) {
if (error instanceof Error && error.message === "spiral_extent_limit") { if (error instanceof Error && error.message === "spiral_extent_limit") {
reachedExtent = true; reachedExtent = true;
session.terrainSampleExhausted = true;
} else { } else {
stopSpiralAnimation("spiral_runtime_error"); stopSpiralAnimation("spiral_runtime_error");
} }
@ -814,6 +899,7 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
}); });
session.terrainSamples = [...session.terrainSamples, ...nextSamples] session.terrainSamples = [...session.terrainSamples, ...nextSamples]
.filter((sample) => sample.distanceMeters >= session.surfaceDistanceMeters - session.terrainSampleSpacingMeters * 2); .filter((sample) => sample.distanceMeters >= session.surfaceDistanceMeters - session.terrainSampleSpacingMeters * 2);
if (reachedTargetDistance || reachedExtent) session.terrainSampleExhausted = true;
session.terrainSamplePending = false; session.terrainSamplePending = false;
session.terrainSampleFailures = 0; session.terrainSampleFailures = 0;
session.terrainRetryAt = 0; session.terrainRetryAt = 0;
@ -838,10 +924,33 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
if (document.hidden) { if (document.hidden) {
session.lastTimestamp = null; session.lastTimestamp = null;
session.pendingElapsedSeconds = 0; session.pendingElapsedSeconds = 0;
session.tilesWaitStartedAt = null;
} else if (session.lastTimestamp === null) { } else if (session.lastTimestamp === null) {
session.lastTimestamp = timestamp; session.lastTimestamp = timestamp;
} else { } 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.lastTimestamp = timestamp;
session.pendingElapsedSeconds += elapsedSeconds; session.pendingElapsedSeconds += elapsedSeconds;
try { try {
@ -857,6 +966,10 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
candidateDistance, candidateDistance,
session.config.pitchMetersPerTurn, session.config.pitchMetersPerTurn,
); );
if (candidateFrame.radiusMeters >= (session.config.targetRadiusMeters ?? MAX_SPIRAL_RADIUS_METERS)) {
stopSpiralAnimation("target_radius_reached");
return;
}
const candidateGround = groundHeightAtDistance(candidateDistance); const candidateGround = groundHeightAtDistance(candidateDistance);
if (candidateGround === null) { if (candidateGround === null) {
const lastTerrainSample = session.terrainSamples.at(-1); const lastTerrainSample = session.terrainSamples.at(-1);
@ -919,6 +1032,7 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
if (!session || !document.hidden) return; if (!session || !document.hidden) return;
session.lastTimestamp = null; session.lastTimestamp = null;
session.pendingElapsedSeconds = 0; session.pendingElapsedSeconds = 0;
session.tilesWaitStartedAt = null;
}; };
window.addEventListener("pagehide", stopForPageLeave); window.addEventListener("pagehide", stopForPageLeave);
document.addEventListener("visibilitychange", resetHiddenClock); document.addEventListener("visibilitychange", resetHiddenClock);
@ -960,6 +1074,7 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
let removeGridCameraListener: (() => void) | undefined; let removeGridCameraListener: (() => void) | undefined;
let removeRefreshRenderListener: (() => void) | undefined; let removeRefreshRenderListener: (() => void) | undefined;
let removeRenderErrorListener: (() => void) | undefined; let removeRenderErrorListener: (() => void) | undefined;
const removeProviderFailureListeners: Array<() => void> = [];
let cancelled = false; let cancelled = false;
const start = async () => { const start = async () => {
@ -1048,7 +1163,9 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
buildings: "Platform Map Gateway недоступен", buildings: "Platform Map Gateway недоступен",
}, },
}; };
const providerTileFailureLatched = { imagery: false, terrain: false, buildings: false };
const reportProvider = (provider: "imagery" | "terrain" | "buildings", state: MapProviderState, error?: unknown) => { const reportProvider = (provider: "imagery" | "terrain" | "buildings", state: MapProviderState, error?: unknown) => {
if (state === "ready" && providerTileFailureLatched[provider]) return;
providerStatus[provider] = state; providerStatus[provider] = state;
if (state === "error") { if (state === "error") {
providerStatus.errors[provider] = error instanceof Error && error.message ? error.message : "provider_unavailable"; 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 } }); 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 } }); onProviderStatus?.({ ...providerStatus, errors: { ...providerStatus.errors } });
let renderRecoveryScheduled = false; let renderRecoveryScheduled = false;
removeRenderErrorListener = viewer.scene.renderError.addEventListener((_scene, error) => { removeRenderErrorListener = viewer.scene.renderError.addEventListener((_scene, error) => {
@ -1091,6 +1217,7 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
tileProtocol: "https", tileProtocol: "https",
}); });
if (cancelled || !viewer || viewer.isDestroyed()) return; 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)); for (const attribution of endpoint.attributions) if (attribution.html) viewer.creditDisplay.addStaticCredit(new Credit(attribution.html, attribution.collapsible));
imageryLayerRef.current = viewer.imageryLayers.addImageryProvider(imageryProvider); imageryLayerRef.current = viewer.imageryLayers.addImageryProvider(imageryProvider);
applyPresentation(viewer, imageryLayerRef.current, buildingsRef.current, terrain, presentationRef.current); applyPresentation(viewer, imageryLayerRef.current, buildingsRef.current, terrain, presentationRef.current);
@ -1101,6 +1228,7 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
const terrainResource = buildResource(terrainEndpoint.url); const terrainResource = buildResource(terrainEndpoint.url);
const world = await CesiumTerrainProvider.fromUrl(terrainResource, { requestVertexNormals: true, requestWaterMask: true }); const world = await CesiumTerrainProvider.fromUrl(terrainResource, { requestVertexNormals: true, requestWaterMask: true });
if (cancelled || !viewer || viewer.isDestroyed()) return; if (cancelled || !viewer || viewer.isDestroyed()) return;
removeProviderFailureListeners.push(world.errorEvent.addEventListener(() => latchProviderTileFailure("terrain")));
terrain.world = world; terrain.world = world;
for (const attribution of terrainEndpoint.attributions) if (attribution.html) viewer.creditDisplay.addStaticCredit(new Credit(attribution.html, attribution.collapsible)); 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); applyPresentation(viewer, imageryLayerRef.current, buildingsRef.current, terrain, presentationRef.current);
@ -1108,10 +1236,11 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
reportProvider("terrain", "ready"); reportProvider("terrain", "ready");
}).catch((error) => reportProvider("terrain", "error", error)); }).catch((error) => reportProvider("terrain", "error", error));
void loadEndpoint("96188").then(async (buildingsEndpoint) => { 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 buildingsResource = buildResource(buildingsEndpoint.url);
const buildings = await Cesium3DTileset.fromUrl(buildingsResource); const buildings = await Cesium3DTileset.fromUrl(buildingsResource);
if (cancelled || !viewer || viewer.isDestroyed()) return; if (cancelled || !viewer || viewer.isDestroyed()) return;
removeProviderFailureListeners.push(buildings.tileFailed.addEventListener(() => latchProviderTileFailure("buildings")));
viewer.scene.primitives.add(buildings); viewer.scene.primitives.add(buildings);
buildingsRef.current = buildings; buildingsRef.current = buildings;
for (const attribution of buildingsEndpoint.attributions) if (attribution.html) viewer.creditDisplay.addStaticCredit(new Credit(attribution.html, attribution.collapsible)); 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?.(); removeGridCameraListener?.();
removeRefreshRenderListener?.(); removeRefreshRenderListener?.();
removeRenderErrorListener?.(); removeRenderErrorListener?.();
for (const removeListener of removeProviderFailureListeners) removeListener();
handler?.destroy(); handler?.destroy();
if (viewer && !viewer.isDestroyed()) viewer.destroy(); if (viewer && !viewer.isDestroyed()) viewer.destroy();
viewerRef.current = null; viewerRef.current = null;

View File

@ -1,5 +1,6 @@
import { forwardRef, lazy, Suspense, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, type CSSProperties, type PointerEvent } from "react"; import { forwardRef, lazy, Suspense, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, type CSSProperties, type PointerEvent } from "react";
import { Button, Checker, ColorField, ControlRow, GlassSurface, Icon, IconButton, Inspector, RangeControl, Window } from "@nodedc/ui-react"; import { Button, Checker, ColorField, ControlRow, GlassSurface, Icon, IconButton, Inspector, RangeControl, Select, Window } from "@nodedc/ui-react";
import type { SelectOption } from "@nodedc/ui-react";
import type { import type {
CameraSpiralState, CameraSpiralState,
CesiumMapRendererHandle, CesiumMapRendererHandle,
@ -9,16 +10,29 @@ import type {
MapProviderStatus, MapProviderStatus,
} from "./CesiumMapRenderer.js"; } from "./CesiumMapRenderer.js";
import { mapRuntimeEntityId, useMapDataProductRuntime } from "./useMapDataProductRuntime.js"; import { mapRuntimeEntityId, useMapDataProductRuntime } from "./useMapDataProductRuntime.js";
import {
CAMERA_SURVEY_PRESETS,
DEFAULT_CAMERA_SURVEY_PRESET,
OSM_BUILDINGS_OBSERVED_BAND_COUNT,
cameraSurveySpiralDistance,
findCameraSurveyPreset,
type CameraSurveySelection,
} from "./mapCameraPresets.js";
import sceneFixture from "../../../registry/fixtures/map/map-operational-v0.1.json"; import sceneFixture from "../../../registry/fixtures/map/map-operational-v0.1.json";
const CesiumMapRenderer = lazy(() => import("./CesiumMapRenderer.js").then((module) => ({ default: module.CesiumMapRenderer }))); const CesiumMapRenderer = lazy(() => import("./CesiumMapRenderer.js").then((module) => ({ default: module.CesiumMapRenderer })));
type PreviewFeatures = { inspector?: boolean; toolbar?: boolean; assistant?: boolean }; type PreviewFeatures = { inspector?: boolean; toolbar?: boolean; assistant?: boolean };
export type MapPageSettings = Omit<MapPresentation, "cacheRefresh">; export type MapPageSettings = Omit<MapPresentation, "cacheRefresh">;
type SurveySettingsSnapshot = Pick<
MapPageSettings,
"imageryVisible" | "monochrome" | "cacheEnabled" | "cacheNoOverwrite" | "terrainEnabled" | "buildingsVisible" | "buildingsDetail"
>;
type MapRuntimeConfig = { gatewayHealthUrl?: string | null; resourceProxyBase?: string | null }; type MapRuntimeConfig = { gatewayHealthUrl?: string | null; resourceProxyBase?: string | null };
type GatewayCheckState = "idle" | "checking" | "ready" | "stale" | "error"; type GatewayCheckState = "idle" | "checking" | "ready" | "stale" | "error";
type GatewayHealthOrder = { nextEpoch: number; latestStartedEpoch: number }; type GatewayHealthOrder = { nextEpoch: number; latestStartedEpoch: number };
const RENDERER_GATEWAY_HEALTH_EPOCH = 1; const RENDERER_GATEWAY_HEALTH_EPOCH = 1;
const SURVEY_GATEWAY_HEALTH_MAX_AGE_MS = 30_000;
function beginGatewayHealthEpoch(order: GatewayHealthOrder) { function beginGatewayHealthEpoch(order: GatewayHealthOrder) {
order.nextEpoch += 1; order.nextEpoch += 1;
@ -46,6 +60,13 @@ function gatewayCheckMessage(code: string, stale: boolean) {
return `${prefix} (${code}). ${suffix}`; return `${prefix} (${code}). ${suffix}`;
} }
function isWritableAppendOnlyTileCache(health: MapGatewayHealth | null) {
return health?.cache?.persistent === true
&& health.cache.mode === "readwrite"
&& health.cache.writePolicy === "append-only-no-eviction"
&& health.cache.atCapacity === false;
}
/** /**
* Provider-neutral visual binding. Foundry stores this on an Application page * Provider-neutral visual binding. Foundry stores this on an Application page
* instance; a future data binding resolves the live source behind `source`. * instance; a future data binding resolves the live source behind `source`.
@ -176,10 +197,19 @@ const formatMetricDistance = (value: number) => value >= 1000
const formatMetricSpeed = (value: number) => value >= 1000 const formatMetricSpeed = (value: number) => value >= 1000
? `${(value / 1000).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} км/с` ? `${(value / 1000).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} км/с`
: `${Math.round(value)} м/с`; : `${Math.round(value)} м/с`;
const formatDuration = (seconds: number) => {
if (seconds >= 86_400) return `${(seconds / 86_400).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} сут`;
if (seconds >= 3_600) return `${(seconds / 3_600).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} ч`;
if (seconds >= 60) return `${Math.round(seconds / 60)} мин`;
return `${Math.max(1, Math.round(seconds))} с`;
};
function spiralStopMessage(reason: CameraSpiralState["reason"]) { function spiralStopMessage(reason: CameraSpiralState["reason"]) {
if (reason === "target_radius_reached") return "Проход завершён: камера достигла выбранного радиуса.";
if (reason === "spiral_extent_limit") return "Режим остановлен у безопасной границы геодезического маршрута."; if (reason === "spiral_extent_limit") return "Режим остановлен у безопасной границы геодезического маршрута.";
if (reason === "terrain_sampling_error") return "Режим остановлен: не удалось получить высоту terrain для следующего участка."; if (reason === "terrain_sampling_error") return "Режим остановлен: не удалось получить высоту terrain для следующего участка.";
if (reason === "tile_loading_timeout") return "Режим остановлен: текущий набор Imagery, Terrain или OSM Buildings не загрузился за отведённое время.";
if (reason === "tile_loading_error") return "Режим остановлен: Cesium сообщил об ошибке tile в Imagery, Terrain или OSM Buildings. Неполный участок не засчитан.";
if (reason === "spiral_runtime_error" || reason === "render_error") return "Режим остановлен из-за ошибки рендера камеры."; if (reason === "spiral_runtime_error" || reason === "render_error") return "Режим остановлен из-за ошибки рендера камеры.";
if (reason === "renderer_restarted") return "Режим остановлен после обновления renderer."; if (reason === "renderer_restarted") return "Режим остановлен после обновления renderer.";
return null; return null;
@ -213,10 +243,13 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
const mapRendererRef = useRef<CesiumMapRendererHandle | null>(null); const mapRendererRef = useRef<CesiumMapRendererHandle | null>(null);
const [mapRendererReady, setMapRendererReady] = useState(false); const [mapRendererReady, setMapRendererReady] = useState(false);
const [animationModeEnabled, setAnimationModeEnabled] = useState(false); const [animationModeEnabled, setAnimationModeEnabled] = useState(false);
const animationSettingsSnapshotRef = useRef<SurveySettingsSnapshot | null>(null);
const [spiralRunning, setSpiralRunning] = useState(false); const [spiralRunning, setSpiralRunning] = useState(false);
const [spiralHeightMeters, setSpiralHeightMeters] = useState(1500); const [spiralPresetId, setSpiralPresetId] = useState<CameraSurveySelection>(DEFAULT_CAMERA_SURVEY_PRESET.id);
const [spiralSpeedMetersPerSecond, setSpiralSpeedMetersPerSecond] = useState(250); const [spiralHeightMeters, setSpiralHeightMeters] = useState<number>(DEFAULT_CAMERA_SURVEY_PRESET.heightAboveGroundMeters);
const [spiralPitchMetersPerTurn, setSpiralPitchMetersPerTurn] = useState(10_000); const [spiralSpeedMetersPerSecond, setSpiralSpeedMetersPerSecond] = useState<number>(DEFAULT_CAMERA_SURVEY_PRESET.speedMetersPerSecond);
const [spiralPitchMetersPerTurn, setSpiralPitchMetersPerTurn] = useState<number>(DEFAULT_CAMERA_SURVEY_PRESET.pitchMetersPerTurn);
const [spiralTargetRadiusMeters, setSpiralTargetRadiusMeters] = useState<number>(DEFAULT_CAMERA_SURVEY_PRESET.targetRadiusMeters);
const [spiralMessage, setSpiralMessage] = useState<string | null>(null); const [spiralMessage, setSpiralMessage] = useState<string | null>(null);
// Map pin bindings belong to the application page instance. They are kept // Map pin bindings belong to the application page instance. They are kept
// intact when a human changes camera or visual settings and presses Save. // intact when a human changes camera or visual settings and presses Save.
@ -268,6 +301,18 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
const [gatewayLastVerifiedAt, setGatewayLastVerifiedAt] = useState<Date | null>(null); const [gatewayLastVerifiedAt, setGatewayLastVerifiedAt] = useState<Date | null>(null);
const [providerStatus, setProviderStatus] = useState<MapProviderStatus>(initialProviderStatus); const [providerStatus, setProviderStatus] = useState<MapProviderStatus>(initialProviderStatus);
const [cacheRefresh, setCacheRefresh] = useState(false); const [cacheRefresh, setCacheRefresh] = useState(false);
const spiralPresetOptions = useMemo<Array<SelectOption<CameraSurveySelection>>>(() => [
...CAMERA_SURVEY_PRESETS.map((preset) => ({
value: preset.id,
label: preset.label,
description: `шаг ${formatMetricDistance(preset.pitchMetersPerTurn)} · до ${formatMetricDistance(preset.targetRadiusMeters)}`,
})),
...(spiralPresetId === "custom" ? [{
value: "custom" as const,
label: "Пользовательский",
description: "Значения изменены вручную",
}] : []),
], [spiralPresetId]);
const selected = selectable.find((entity) => entity.id === selectedId) ?? selectable[0]; const selected = selectable.find((entity) => entity.id === selectedId) ?? selectable[0];
const presentation = useMemo<MapPresentation>( const presentation = useMemo<MapPresentation>(
() => ({ ...mapSettings, cacheRefresh }), () => ({ ...mapSettings, cacheRefresh }),
@ -303,24 +348,124 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
setSpiralMessage(state.running ? null : spiralStopMessage(state.reason)); setSpiralMessage(state.running ? null : spiralStopMessage(state.reason));
}, []); }, []);
const setAnimationMode = (enabled: boolean) => { const prepareAnimationSurvey = () => {
setAnimationModeEnabled(enabled); const failedProviderNeedsRetry = [providerStatus.imagery, providerStatus.terrain, providerStatus.buildings]
setSpiralMessage(null); .some((state) => state === "error" || state === "not-configured");
if (!enabled) { const rendererRestartRequired = !mapSettings.cacheEnabled
mapRendererRef.current?.stopSpiralAnimation("mode_disabled"); || !mapSettings.cacheNoOverwrite
setSpiralRunning(false); || cacheRefresh
|| failedProviderNeedsRetry;
updateMapSettings({
imageryVisible: true,
// Monochrome deliberately hides the imagery layer. A cache survey must
// render it so Cesium actually requests every visible imagery tile.
monochrome: false,
cacheEnabled: true,
cacheNoOverwrite: true,
terrainEnabled: true,
buildingsVisible: true,
// The measured OSM hierarchy and presets use the canonical SSE value.
buildingsDetail: 16,
});
setCacheRefresh(false);
if (rendererRestartRequired) {
setMapRendererReady(false);
setProviderStatus(initialProviderStatus);
setRendererRevision((value) => value + 1);
} }
}; };
const setAnimationMode = (enabled: boolean) => {
if (enabled === animationModeEnabled) return;
setAnimationModeEnabled(enabled);
setSpiralMessage(null);
if (enabled) {
// Survey preparation is transient. Save the page presentation before
// forcing all cacheable providers on, and restore it when the mode ends.
animationSettingsSnapshotRef.current = {
imageryVisible: mapSettings.imageryVisible,
monochrome: mapSettings.monochrome,
cacheEnabled: mapSettings.cacheEnabled,
cacheNoOverwrite: mapSettings.cacheNoOverwrite,
terrainEnabled: mapSettings.terrainEnabled,
buildingsVisible: mapSettings.buildingsVisible,
buildingsDetail: mapSettings.buildingsDetail,
};
prepareAnimationSurvey();
void verifyGateway();
} else {
mapRendererRef.current?.stopSpiralAnimation("mode_disabled");
setSpiralRunning(false);
const snapshot = animationSettingsSnapshotRef.current;
animationSettingsSnapshotRef.current = null;
if (snapshot) {
const rendererRestartRequired = snapshot.cacheEnabled !== mapSettings.cacheEnabled
|| snapshot.cacheNoOverwrite !== mapSettings.cacheNoOverwrite;
// Restore only fields owned by the survey overlay. Any unrelated
// Inspector edits made while the mode was open remain intact.
setMapSettings((current) => ({ ...current, ...snapshot }));
setCacheRefresh(false);
if (rendererRestartRequired) {
setMapRendererReady(false);
setProviderStatus(initialProviderStatus);
setRendererRevision((value) => value + 1);
}
}
}
};
const selectSpiralPreset = (presetId: CameraSurveySelection) => {
if (presetId === "custom") return;
const preset = findCameraSurveyPreset(presetId);
if (!preset) return;
setSpiralPresetId(preset.id);
setSpiralHeightMeters(preset.heightAboveGroundMeters);
setSpiralSpeedMetersPerSecond(preset.speedMetersPerSecond);
setSpiralPitchMetersPerTurn(preset.pitchMetersPerTurn);
setSpiralTargetRadiusMeters(preset.targetRadiusMeters);
setSpiralMessage(null);
};
const spiralSurveyConfigured = mapSettings.cacheEnabled
&& mapSettings.cacheNoOverwrite
&& mapSettings.imageryVisible
&& !mapSettings.monochrome
&& mapSettings.terrainEnabled
&& mapSettings.buildingsVisible
&& mapSettings.buildingsDetail === 16;
const spiralProvidersReady = providerStatus.imagery === "ready"
&& providerStatus.terrain === "ready"
&& providerStatus.buildings === "ready";
const spiralGatewayHealthFresh = Boolean(
gatewayLastVerifiedAt
&& Date.now() - gatewayLastVerifiedAt.getTime() <= SURVEY_GATEWAY_HEALTH_MAX_AGE_MS
&& gatewayCheckState !== "error"
&& gatewayCheckState !== "stale",
);
const spiralTileCacheReady = spiralGatewayHealthFresh && isWritableAppendOnlyTileCache(gatewayHealth);
const spiralCanStart = mapRendererReady && spiralSurveyConfigured && spiralProvidersReady && spiralTileCacheReady;
useEffect(() => {
if (!spiralRunning || (spiralSurveyConfigured && spiralTileCacheReady)) return;
mapRendererRef.current?.stopSpiralAnimation("mode_disabled");
}, [spiralRunning, spiralSurveyConfigured, spiralTileCacheReady]);
const toggleSpiralAnimation = () => { const toggleSpiralAnimation = () => {
if (spiralRunning) { if (spiralRunning) {
mapRendererRef.current?.stopSpiralAnimation("stopped"); mapRendererRef.current?.stopSpiralAnimation("stopped");
return; return;
} }
if (!spiralCanStart) {
setSpiralMessage("Подождите готовности Imagery, Terrain, OSM Buildings и свежей проверки writable append-only TileCache.");
return;
}
const started = mapRendererRef.current?.startSpiralAnimation({ const started = mapRendererRef.current?.startSpiralAnimation({
heightAboveGroundMeters: spiralHeightMeters, heightAboveGroundMeters: spiralHeightMeters,
speedMetersPerSecond: spiralSpeedMetersPerSecond, speedMetersPerSecond: spiralSpeedMetersPerSecond,
pitchMetersPerTurn: spiralPitchMetersPerTurn, pitchMetersPerTurn: spiralPitchMetersPerTurn,
targetRadiusMeters: spiralTargetRadiusMeters,
viewPitchRadians: -Math.PI / 2 + 0.01,
waitForTiles: true,
}) ?? false; }) ?? false;
if (!started) setSpiralMessage("Карта ещё не готова к запуску режима анимации."); if (!started) setSpiralMessage("Карта ещё не готова к запуску режима анимации.");
}; };
@ -329,7 +474,10 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
getLayout: () => ({ getLayout: () => ({
schemaVersion: 1, schemaVersion: 1,
pageId: "map", pageId: "map",
settings: mapSettings, // Survey-only layer overrides never leak into a saved page layout.
settings: animationSettingsSnapshotRef.current
? { ...mapSettings, ...animationSettingsSnapshotRef.current }
: mapSettings,
mapHeight: Math.round(mapHeight), mapHeight: Math.round(mapHeight),
camera: mapRendererRef.current?.getCameraView() ?? mapCameraRef.current ?? mapCamera, camera: mapRendererRef.current?.getCameraView() ?? mapCameraRef.current ?? mapCamera,
pinBindings, pinBindings,
@ -435,7 +583,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
}, [beginGatewayHealthVerification, isLatestGatewayHealthRequest, rememberGatewayHealth]); }, [beginGatewayHealthVerification, isLatestGatewayHealthRequest, rememberGatewayHealth]);
useEffect(() => { useEffect(() => {
if (!inspectorOpen && !layersOpen) return; if (!inspectorOpen && !layersOpen && !animationModeEnabled) return;
void verifyGateway(); void verifyGateway();
const interval = window.setInterval(() => void verifyGateway(), 15_000); const interval = window.setInterval(() => void verifyGateway(), 15_000);
return () => { return () => {
@ -446,7 +594,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
pending.controller.abort(); pending.controller.abort();
} }
}; };
}, [inspectorOpen, layersOpen, verifyGateway]); }, [animationModeEnabled, inspectorOpen, layersOpen, verifyGateway]);
const liveCacheStatus = gatewayHealth?.cache; const liveCacheStatus = gatewayHealth?.cache;
const liveCacheSummary = liveCacheStatus const liveCacheSummary = liveCacheStatus
@ -566,7 +714,18 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
content: <> content: <>
<Checker checked={animationModeEnabled} label="Режим анимации" onChange={setAnimationMode} /> <Checker checked={animationModeEnabled} label="Режим анимации" onChange={setAnimationMode} />
{animationModeEnabled ? <> {animationModeEnabled ? <>
<small className="catalog-map-inspector__note">Стартовая точка берётся из текущей позиции камеры. Высота считается над terrain; при догрузке следующего участка маршрут ждёт данные, а не подменяет поверхность нулём. Движение идёт по региональной геодезической спирали WGS84 до 250 км от старта.</small> <small className="catalog-map-inspector__note">Стартовая точка берётся из текущей позиции камеры. Камера смотрит почти в надир, а маршрут ждёт текущие tiles перед продолжением. Движение идёт по региональной геодезической спирали WGS84 до выбранного радиуса.</small>
<ControlRow label="Профиль покрытия">
<Select
value={spiralPresetId}
options={spiralPresetOptions}
label="Профиль покрытия камеры"
disabled={spiralRunning}
onChange={selectSpiralPreset}
/>
</ControlRow>
<small className="catalog-map-inspector__note">У текущего OSM Buildings подтверждено {OSM_BUILDINGS_OBSERVED_BAND_COUNT} иерархических bands. Десять профилей управляют высотой и покрытием; фактический LOD Cesium выбирает по SSE, viewport и расстоянию.</small>
<ControlRow label="Слои прохода"><small>Imagery · Terrain · OSM Buildings</small></ControlRow>
<RangeControl <RangeControl
label="Высота над землёй" label="Высота над землёй"
value={logarithmicControlValue(spiralHeightMeters)} value={logarithmicControlValue(spiralHeightMeters)}
@ -575,7 +734,10 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
step={0.01} step={0.01}
disabled={spiralRunning} disabled={spiralRunning}
formatValue={(value) => formatMetricDistance(10 ** value)} formatValue={(value) => formatMetricDistance(10 ** value)}
onChange={(value) => setSpiralHeightMeters(valueFromLogarithmicControl(value))} onChange={(value) => {
setSpiralPresetId("custom");
setSpiralHeightMeters(valueFromLogarithmicControl(value));
}}
/> />
<RangeControl <RangeControl
label="Скорость камеры" label="Скорость камеры"
@ -585,7 +747,10 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
step={0.01} step={0.01}
disabled={spiralRunning} disabled={spiralRunning}
formatValue={(value) => formatMetricSpeed(10 ** value)} formatValue={(value) => formatMetricSpeed(10 ** value)}
onChange={(value) => setSpiralSpeedMetersPerSecond(valueFromLogarithmicControl(value))} onChange={(value) => {
setSpiralPresetId("custom");
setSpiralSpeedMetersPerSecond(valueFromLogarithmicControl(value));
}}
/> />
<RangeControl <RangeControl
label="Шаг спирали" label="Шаг спирали"
@ -595,9 +760,27 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
step={0.01} step={0.01}
disabled={spiralRunning} disabled={spiralRunning}
formatValue={(value) => formatMetricDistance(10 ** value)} formatValue={(value) => formatMetricDistance(10 ** value)}
onChange={(value) => setSpiralPitchMetersPerTurn(valueFromLogarithmicControl(value))} onChange={(value) => {
setSpiralPresetId("custom");
setSpiralPitchMetersPerTurn(valueFromLogarithmicControl(value));
}}
/> />
<Button variant="secondary" shape="pill" onClick={toggleSpiralAnimation} disabled={!mapRendererReady}>{spiralRunning ? "Остановить" : "Запустить режим анимации"}</Button> <RangeControl
label="Радиус прохода"
value={logarithmicControlValue(spiralTargetRadiusMeters)}
min={logarithmicControlValue(1_000)}
max={logarithmicControlValue(250_000)}
step={0.01}
disabled={spiralRunning}
formatValue={(value) => formatMetricDistance(10 ** value)}
onChange={(value) => {
setSpiralPresetId("custom");
setSpiralTargetRadiusMeters(valueFromLogarithmicControl(value));
}}
/>
<small className="catalog-map-inspector__note">Расчётное движение без ожидания сети: {formatDuration(cameraSurveySpiralDistance(spiralTargetRadiusMeters, spiralPitchMetersPerTurn) / spiralSpeedMetersPerSecond)}. Tile waits и автоматическое сужение шага под viewport увеличат фактическое время.</small>
{!spiralCanStart && !spiralRunning ? <small className="catalog-map-inspector__note" role="status">Подготовка: imagery {providerStateLabel[providerStatus.imagery]}, terrain {providerStateLabel[providerStatus.terrain]}, OSM Buildings {providerStateLabel[providerStatus.buildings]}, TileCache {spiralTileCacheReady ? "готов" : gatewayHealth?.cache?.atCapacity ? "заполнен" : gatewayCheckState === "checking" ? "проверяется" : "недоступен для записи"}.</small> : null}
<Button variant="secondary" shape="pill" onClick={toggleSpiralAnimation} disabled={!spiralRunning && !spiralCanStart}>{spiralRunning ? "Остановить" : "Запустить режим анимации"}</Button>
{spiralRunning ? <small className="catalog-map-inspector__note">Камера движется от исходной точки. Выключение режима, уход со страницы или reload остановят сессию.</small> : null} {spiralRunning ? <small className="catalog-map-inspector__note">Камера движется от исходной точки. Выключение режима, уход со страницы или reload остановят сессию.</small> : null}
{spiralMessage ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="status">{spiralMessage}</small> : null} {spiralMessage ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="status">{spiralMessage}</small> : null}
</> : null} </> : null}

View File

@ -0,0 +1,123 @@
/**
* Geometric-error bands observed in the current Cesium OSM Buildings 96188
* hierarchy. They are ordered from the global root towards maximum detail.
* The provider may publish a newer hierarchy later, so these values are a
* measured runtime profile rather than a permanent Cesium API contract.
*/
export const OSM_BUILDINGS_GEOMETRIC_ERROR_LEVELS = [
77_067.33978,
38_533.66989,
19_266.834945,
9_633.417472,
4_816.708736,
2_408.354368,
1_204.177184,
602.088592,
301.044296,
150.522148,
75.261074,
37.630537,
18.815269,
9.407634,
4.703817,
2.351909,
] as const;
export const CAMERA_SURVEY_CROSS_TRACK_FRACTION = 0.69;
export type CameraSurveyPreset = {
id: string;
profile: number;
label: string;
heightAboveGroundMeters: number;
pitchMetersPerTurn: number;
speedMetersPerSecond: number;
targetRadiusMeters: number;
};
/**
* Ten operational scale profiles cover a regional survey from street to
* macro scale. They deliberately do not claim a one-to-one provider LOD:
* Cesium selects imagery, terrain and 3D Tiles through different SSE rules,
* viewport geometry, pixel ratio, availability and tile bounding volumes.
*
* Height and turn pitch double together. With a 60 degree horizontal field
* of view and a near-nadir camera, pitch = 0.8 * height keeps about 31%
* cross-track overlap. Runtime additionally clamps pitch to the live viewport,
* so a narrow inspector cannot open gaps between turns.
*
* Speed is a ceiling, not a promise: the renderer pauses whenever current
* Imagery, Terrain or OSM Buildings requests are unsettled. A 25 km target
* radius makes every profile a finite city-scale pass instead of silently
* continuing to the 250 km geodesic safety boundary.
*/
export const CAMERA_SURVEY_PRESETS = [
{ id: "osm-detail-1", profile: 1, label: "Профиль 1 · 80 м", heightAboveGroundMeters: 80, pitchMetersPerTurn: 64, speedMetersPerSecond: 256, targetRadiusMeters: 25_000 },
{ id: "osm-detail-2", profile: 2, label: "Профиль 2 · 160 м", heightAboveGroundMeters: 160, pitchMetersPerTurn: 128, speedMetersPerSecond: 512, targetRadiusMeters: 25_000 },
{ id: "osm-detail-3", profile: 3, label: "Профиль 3 · 320 м", heightAboveGroundMeters: 320, pitchMetersPerTurn: 256, speedMetersPerSecond: 1_024, targetRadiusMeters: 25_000 },
{ id: "osm-detail-4", profile: 4, label: "Профиль 4 · 640 м", heightAboveGroundMeters: 640, pitchMetersPerTurn: 512, speedMetersPerSecond: 2_048, targetRadiusMeters: 25_000 },
{ id: "osm-detail-5", profile: 5, label: "Профиль 5 · 1,28 км", heightAboveGroundMeters: 1_280, pitchMetersPerTurn: 1_024, speedMetersPerSecond: 4_096, targetRadiusMeters: 25_000 },
{ id: "osm-detail-6", profile: 6, label: "Профиль 6 · 2,56 км", heightAboveGroundMeters: 2_560, pitchMetersPerTurn: 2_048, speedMetersPerSecond: 5_000, targetRadiusMeters: 25_000 },
{ id: "osm-detail-7", profile: 7, label: "Профиль 7 · 5,12 км", heightAboveGroundMeters: 5_120, pitchMetersPerTurn: 4_096, speedMetersPerSecond: 5_000, targetRadiusMeters: 25_000 },
{ id: "osm-detail-8", profile: 8, label: "Профиль 8 · 10,24 км", heightAboveGroundMeters: 10_240, pitchMetersPerTurn: 8_192, speedMetersPerSecond: 5_000, targetRadiusMeters: 25_000 },
{ id: "osm-detail-9", profile: 9, label: "Профиль 9 · 20,48 км", heightAboveGroundMeters: 20_480, pitchMetersPerTurn: 16_384, speedMetersPerSecond: 5_000, targetRadiusMeters: 25_000 },
{ id: "osm-detail-10", profile: 10, label: "Профиль 10 · 40,96 км", heightAboveGroundMeters: 40_960, pitchMetersPerTurn: 32_768, speedMetersPerSecond: 5_000, targetRadiusMeters: 25_000 },
] as const satisfies readonly CameraSurveyPreset[];
export type CameraSurveyPresetId = (typeof CAMERA_SURVEY_PRESETS)[number]["id"];
export type CameraSurveySelection = CameraSurveyPresetId | "custom";
export const DEFAULT_CAMERA_SURVEY_PRESET = CAMERA_SURVEY_PRESETS[0];
export const OSM_BUILDINGS_OBSERVED_BAND_COUNT = OSM_BUILDINGS_GEOMETRIC_ERROR_LEVELS.length;
export function findCameraSurveyPreset(id: CameraSurveyPresetId) {
return CAMERA_SURVEY_PRESETS.find((preset) => preset.id === id);
}
export function cameraSurveyCrossTrackWidth(heightAboveGroundMeters: number, horizontalFieldOfViewDegrees = 60) {
return 2 * heightAboveGroundMeters * Math.tan(horizontalFieldOfViewDegrees * Math.PI / 360);
}
export function cameraSurveyTurnOverlap(preset: CameraSurveyPreset, horizontalFieldOfViewDegrees = 60) {
const width = cameraSurveyCrossTrackWidth(preset.heightAboveGroundMeters, horizontalFieldOfViewDegrees);
return 1 - preset.pitchMetersPerTurn / width;
}
export function cameraSurveyPitchForViewport(
heightAboveGroundMeters: number,
requestedPitchMeters: number,
horizontalFieldOfViewRadians: number,
) {
const crossTrackWidthMeters = 2 * heightAboveGroundMeters * Math.tan(horizontalFieldOfViewRadians / 2);
return Math.min(requestedPitchMeters, crossTrackWidthMeters * CAMERA_SURVEY_CROSS_TRACK_FRACTION);
}
export function cameraSurveySpiralDistance(targetRadiusMeters: number, pitchMetersPerTurn: number) {
const angle = (Math.PI * 2 * targetRadiusMeters) / pitchMetersPerTurn;
const radialGrowth = pitchMetersPerTurn / (Math.PI * 2);
return (radialGrowth / 2) * (
angle * Math.sqrt(1 + angle * angle)
+ Math.asinh(angle)
);
}
export function cameraSurveySampleDistances(
startDistanceMeters: number,
spacingMeters: number,
targetDistanceMeters: number,
maximumSamples: number,
) {
if (startDistanceMeters > targetDistanceMeters || maximumSamples <= 0) return [];
const distances: number[] = [];
for (let index = 0; index < maximumSamples; index += 1) {
const distanceMeters = Math.min(startDistanceMeters + index * spacingMeters, targetDistanceMeters);
distances.push(distanceMeters);
if (distanceMeters >= targetDistanceMeters) break;
}
return distances;
}
export function cameraSurveyTravelSeconds(preset: CameraSurveyPreset) {
return cameraSurveySpiralDistance(preset.targetRadiusMeters, preset.pitchMetersPerTurn)
/ preset.speedMetersPerSecond;
}

View File

@ -329,7 +329,24 @@ Adapter добавляет `nodedc_cache_refresh=1` в provider resource root. D
Это одноразовый refresh. Renderer пересоздаётся с `nodedc_cache_refresh=1`, помечает root provider resources текущего view, после первого render возвращается к сохранённой steady-state policy. Это одноразовый refresh. Renderer пересоздаётся с `nodedc_cache_refresh=1`, помечает root provider resources текущего view, после первого render возвращается к сохранённой steady-state policy.
### 7.5 Gateway-level modes ### 7.5 OSM Buildings в persistent TileCache
Asset `96188` не является отдельным browser-only слоем. Root `tileset.json` создаётся как Cesium `Resource` с тем же Gateway proxy и cache intent, поэтому все его derived resources остаются на общем маршруте:
```text
96188 endpoint
→ root tileset.json
→ external/nested tileset JSON
→ b3dm/i3dm/pnts/cmpt или glTF/GLB dependencies
→ Platform Map Gateway
→ NAS live TileCache
```
Endpoint metadata хранится отдельно в private `ion-endpoints/96188.json`; сами building objects имеют обычные credential-free SHA-256 cache keys и лежат в `objects/`. Проверка live NAS store подтвердила `b3dm` objects, а не только root metadata. Warm building tile не требует AMD/VPN и отдаётся тем же локальным NAS path, что imagery и terrain.
Renderer обязан принять asset `96188` только с `type=3DTILES` и `credentialMode=gateway`. Direct URL, browser token или отдельный cache для Buildings запрещены.
### 7.6 Gateway-level modes
| `MAP_CACHE_MODE` | Hit | Miss | Запись | | `MAP_CACHE_MODE` | Hit | Miss | Запись |
| --- | --- | --- | --- | | --- | --- | --- | --- |
@ -339,7 +356,7 @@ Adapter добавляет `nodedc_cache_refresh=1` в provider resource root. D
Application UI не может изменить server-level `MAP_CACHE_MODE`. Application UI не может изменить server-level `MAP_CACHE_MODE`.
### 7.6 Live vs offline profile ### 7.7 Live vs offline profile
`nodedc_cache_profile=live` выбирает mutable store. `offline` выбирает read-only snapshot и никогда не допускает pass-through. `nodedc_cache_profile=live` выбирает mutable store. `offline` выбирает read-only snapshot и никогда не допускает pass-through.
@ -427,6 +444,35 @@ DC AMD Proxy держит отдельный HTTP/1.1 keep-alive pool на ка
Только idempotent `GET`/`HEAD`, потерявший уже reused keep-alive socket, повторяется один раз. Fresh-socket failure не ретраится автоматически. При cross-origin redirect `Authorization` удаляется; master/API bearer не может уйти с `api.cesium.com` на assets/Bing origin. Только idempotent `GET`/`HEAD`, потерявший уже reused keep-alive socket, повторяется один раз. Fresh-socket failure не ретраится автоматически. При cross-origin redirect `Authorization` удаляется; master/API bearer не может уйти с `api.cesium.com` на assets/Bing origin.
### 9.6 Camera cache survey presets
У imagery, terrain и OSM Buildings нет одного общего integer zoom. Текущий runtime profile измерен так:
- World Terrain публикует availability до L19;
- Bing metadata допускает internal imagery L0…L20, но фактический выбор связан с terrain geometry;
- текущая иерархия OSM Buildings содержит 16 geometric-error bands от `77067.33978` до `2.351909` м.
Поэтому UI не обещает ложное соответствие `высота = provider zoom`. Он даёт десять рабочих региональных профилей высоты и покрытия:
| Профиль | AGL | Шаг витка | Потолок скорости | Радиус | No-wait ETA |
| --- | ---: | ---: | ---: | ---: | ---: |
| 1 | 80 м | 64 м | 256 м/с | 25 км | 33,3 ч |
| 2 | 160 м | 128 м | 512 м/с | 25 км | 8,3 ч |
| 3 | 320 м | 256 м | 1,024 км/с | 25 км | 2,1 ч |
| 4 | 640 м | 512 м | 2,048 км/с | 25 км | 31 мин |
| 5 | 1,28 км | 1,024 км | 4,096 км/с | 25 км | 7,8 мин |
| 6 | 2,56 км | 2,048 км | 5 км/с | 25 км | 3,2 мин |
| 7 | 5,12 км | 4,096 км | 5 км/с | 25 км | 1,6 мин |
| 8 | 10,24 км | 8,192 км | 5 км/с | 25 км | 48 с |
| 9 | 20,48 км | 16,384 км | 5 км/с | 25 км | 25 с |
| 10 | 40,96 км | 32,768 км | 5 км/с | 25 км | 13 с |
При горизонтальном FOV около 60° и почти nadir camera шаг `0.8 × height` даёт примерно 31% поперечного перекрытия. Перед стартом renderer считает реальный horizontal FOV из текущего frustum/aspect и при необходимости уменьшает шаг до `0.69 × visible cross-track width`; узкий viewport поэтому не создаёт gaps. Скорость — только верхний предел: при незавершённых tile requests route clock стоит. `No-wait ETA` показывает нижнюю границу без сети и без viewport clamp. Профиль не форсирует конкретный OSM LOD: реальный выбор остаётся за Cesium SSE traversal и зависит от viewport, pixel ratio, tile bounds и availability. При движении к detailed child Cesium обычно проходит ancestor resources, но полнота их durable cache проверяется только post-run audit.
Включение режима survey временно подготавливает `Imagery + Terrain + OSM Buildings`, выключает monochrome, ставит canonical Buildings SSE `16` и append-only TileCache. Только эти survey-owned overrides не сохраняются в page layout и восстанавливаются после выключения режима; остальные правки Inspector не теряются. Start заблокирован, пока все три provider root не готовы и свежий Gateway health не подтвердил `persistent=true`, `mode=readwrite`, `writePolicy=append-only-no-eviction`, `atCapacity=false`. На время движения renderer отключает Cesium culling/foveated deferral для Buildings, фиксирует camera почти в nadir и не продвигает маршрут, пока `globe.tilesLoaded` и `buildings.tilesLoaded` не завершат текущие requests. Terrain prefetch обрезается по точной arc-length выбранного радиуса. Любой provider/tile failure latch'ит provider как failed и останавливает проход; повторная попытка требует bounded renderer recreate. Непрерывное ожидание tiles ограничено 45 секундами активной вкладки, background time не учитывается. Это browser readiness, не доказательство NAS commit. После Stop исходные интерактивные request policies восстанавливаются.
Preset является conservative cache traversal, а не юридической гарантией полноты provider dataset: availability и provider revision могут измениться. Выбранный радиус завершает проход детерминированно, но для доказуемого покрытия конкретного полигона дополнительно нужен coverage ledger и post-run audit cache keys.
## 10. Timeouts, aborts and recovery ## 10. Timeouts, aborts and recovery
### 10.1 Foundry BFF ### 10.1 Foundry BFF

View File

@ -17,7 +17,8 @@
"validate:registry": "node scripts/validate-registry.mjs", "validate:registry": "node scripts/validate-registry.mjs",
"test:platform-settings": "node scripts/smoke-platform-settings.mjs", "test:platform-settings": "node scripts/smoke-platform-settings.mjs",
"test:data-product-runtime": "node scripts/smoke-data-product-runtime.mjs", "test:data-product-runtime": "node scripts/smoke-data-product-runtime.mjs",
"test:map-animation": "node --test scripts/map-spiral.test.mjs" "test:map-animation": "node --test scripts/map-spiral.test.mjs scripts/map-camera-presets.test.mjs",
"test:map-cache-contract": "node --test scripts/map-cache-resource-contract.test.mjs"
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=20"

View File

@ -0,0 +1,49 @@
import assert from "node:assert/strict";
import test from "node:test";
import { DefaultProxy, Resource } from "cesium";
const gatewayProxyBase = "/api/map-gateway/api/map/cache?url=";
function decodedGatewayTarget(resource) {
const browserUrl = new URL(resource.url, "https://foundry.nodedc.test");
assert.equal(browserUrl.pathname, "/api/map-gateway/api/map/cache");
const target = browserUrl.searchParams.get("url");
assert.ok(target);
return new URL(target);
}
test("OSM Buildings external JSON and b3dm inherit the Gateway cache route", () => {
const root = new Resource({
url: "https://assets.ion.cesium.com/asset_depot/96188/OpenStreetMap/tileset.json?v=19&nodedc_client_revision=2",
proxy: new DefaultProxy(gatewayProxyBase),
});
const externalTileset = root.getDerivedResource({ url: "0-0-0.json" });
const buildingTile = externalTileset.getDerivedResource({ url: "14/31780/12975.b3dm" });
const externalTarget = decodedGatewayTarget(externalTileset);
const buildingTarget = decodedGatewayTarget(buildingTile);
assert.equal(externalTarget.pathname, "/asset_depot/96188/OpenStreetMap/0-0-0.json");
assert.equal(buildingTarget.pathname, "/asset_depot/96188/OpenStreetMap/14/31780/12975.b3dm");
for (const target of [externalTarget, buildingTarget]) {
assert.equal(target.searchParams.get("v"), "19");
assert.equal(target.searchParams.get("nodedc_client_revision"), "2");
assert.equal(target.searchParams.has("access_token"), false);
}
});
test("append-only and passthrough cache intent is inherited by Buildings children", () => {
for (const cacheQuery of [
"nodedc_client_revision=2",
"nodedc_client_revision=2&nodedc_cache_mode=passthrough",
"nodedc_client_revision=2&nodedc_cache_refresh=1",
]) {
const root = new Resource({
url: `https://assets.ion.cesium.com/96188/tileset.json?${cacheQuery}`,
proxy: new DefaultProxy(gatewayProxyBase),
});
const target = decodedGatewayTarget(root.getDerivedResource({ url: "root.b3dm" }));
for (const [name, value] of new URLSearchParams(cacheQuery)) {
assert.equal(target.searchParams.get(name), value);
}
}
});

View File

@ -0,0 +1,93 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import ts from "typescript";
const source = await readFile(new URL("../apps/catalog/src/mapCameraPresets.ts", import.meta.url), "utf8");
const { outputText } = ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.ES2022,
target: ts.ScriptTarget.ES2022,
},
fileName: "mapCameraPresets.ts",
});
const presetsModule = await import(`data:text/javascript;base64,${Buffer.from(outputText).toString("base64")}`);
const {
CAMERA_SURVEY_PRESETS,
OSM_BUILDINGS_OBSERVED_BAND_COUNT,
OSM_BUILDINGS_GEOMETRIC_ERROR_LEVELS,
cameraSurveySpiralDistance,
cameraSurveyPitchForViewport,
cameraSurveySampleDistances,
cameraSurveyTravelSeconds,
cameraSurveyTurnOverlap,
} = presetsModule;
test("OSM Buildings measured hierarchy has 16 halving geometric-error bands", () => {
assert.equal(OSM_BUILDINGS_GEOMETRIC_ERROR_LEVELS.length, 16);
for (let index = 1; index < OSM_BUILDINGS_GEOMETRIC_ERROR_LEVELS.length; index += 1) {
assert.ok(Math.abs(OSM_BUILDINGS_GEOMETRIC_ERROR_LEVELS[index - 1] / 2 - OSM_BUILDINGS_GEOMETRIC_ERROR_LEVELS[index]) < 0.000_001);
}
});
test("camera survey exposes ten regional coverage profiles", () => {
assert.equal(CAMERA_SURVEY_PRESETS.length, 10);
assert.equal(OSM_BUILDINGS_OBSERVED_BAND_COUNT, 16);
assert.equal(CAMERA_SURVEY_PRESETS[0].heightAboveGroundMeters, 80);
assert.equal(CAMERA_SURVEY_PRESETS.at(-1).heightAboveGroundMeters, 40_960);
assert.equal(new Set(CAMERA_SURVEY_PRESETS.map((preset) => preset.id)).size, CAMERA_SURVEY_PRESETS.length);
});
test("height and pitch preserve overlap while speed ceilings remain monotonic", () => {
const expectedOverlap = cameraSurveyTurnOverlap(CAMERA_SURVEY_PRESETS[0]);
assert.ok(expectedOverlap > 0.3 && expectedOverlap < 0.32);
for (const [index, preset] of CAMERA_SURVEY_PRESETS.entries()) {
assert.equal(preset.pitchMetersPerTurn, preset.heightAboveGroundMeters * 0.8);
assert.equal(preset.targetRadiusMeters, 25_000);
assert.ok(Math.abs(cameraSurveyTurnOverlap(preset) - expectedOverlap) < 1e-12);
if (index === 0) continue;
assert.equal(preset.heightAboveGroundMeters, CAMERA_SURVEY_PRESETS[index - 1].heightAboveGroundMeters * 2);
assert.ok(preset.speedMetersPerSecond >= CAMERA_SURVEY_PRESETS[index - 1].speedMetersPerSecond);
assert.ok(cameraSurveyTravelSeconds(preset) < cameraSurveyTravelSeconds(CAMERA_SURVEY_PRESETS[index - 1]));
}
});
test("every profile is a finite city-scale pass with an honest no-wait ETA", () => {
const first = CAMERA_SURVEY_PRESETS[0];
const distance = cameraSurveySpiralDistance(first.targetRadiusMeters, first.pitchMetersPerTurn);
assert.ok(distance > 30_000_000 && distance < 31_000_000);
assert.ok(cameraSurveyTravelSeconds(first) > 32 * 3_600 && cameraSurveyTravelSeconds(first) < 34 * 3_600);
assert.ok(Number.isFinite(cameraSurveyTravelSeconds(CAMERA_SURVEY_PRESETS.at(-1))));
});
test("narrow viewports reduce turn pitch instead of opening coverage gaps", () => {
const preset = CAMERA_SURVEY_PRESETS[0];
const wideFov = Math.PI / 3;
const widePitch = cameraSurveyPitchForViewport(preset.heightAboveGroundMeters, preset.pitchMetersPerTurn, wideFov);
const wideWidth = 2 * preset.heightAboveGroundMeters * Math.tan(wideFov / 2);
const narrowFov = Math.PI / 6;
const narrowPitch = cameraSurveyPitchForViewport(preset.heightAboveGroundMeters, preset.pitchMetersPerTurn, narrowFov);
const narrowWidth = 2 * preset.heightAboveGroundMeters * Math.tan(narrowFov / 2);
assert.ok(widePitch <= preset.pitchMetersPerTurn);
assert.ok(Math.abs(1 - widePitch / wideWidth - 0.31) < 1e-12);
assert.ok(narrowPitch < preset.pitchMetersPerTurn);
assert.ok(Math.abs(1 - narrowPitch / narrowWidth - 0.31) < 1e-12);
});
test("terrain prefetch is clipped to the selected survey boundary", () => {
const distances = cameraSurveySampleDistances(0, 166.7, 1_000, 120);
assert.equal(distances[0], 0);
assert.equal(distances.at(-1), 1_000);
assert.ok(distances.length < 120);
assert.ok(distances.every((distance) => distance <= 1_000));
assert.deepEqual(cameraSurveySampleDistances(1_001, 10, 1_000, 120), []);
});
test("all operational preset values remain inside renderer safety limits", () => {
for (const preset of CAMERA_SURVEY_PRESETS) {
assert.ok(preset.heightAboveGroundMeters >= 10 && preset.heightAboveGroundMeters <= 100_000);
assert.ok(preset.speedMetersPerSecond >= 1 && preset.speedMetersPerSecond <= 5_000);
assert.ok(preset.pitchMetersPerTurn >= 20 && preset.pitchMetersPerTurn <= 100_000);
}
});