/** * 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; }