diff --git a/docs/images/tiles/tile_actors_overview.jpg b/docs/images/tiles/tile_actors_overview.jpg new file mode 100644 index 00000000..cd24db59 Binary files /dev/null and b/docs/images/tiles/tile_actors_overview.jpg differ diff --git a/docs/index.md b/docs/index.md index 70cb555a..abbcf8d9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -24,6 +24,7 @@ development/use_prebuilt.md development/use_source.md client_setup.md use_plugin.md +tile_actors.md development/dev_setup_linux.md development/dev_setup_win.md development/vscode_user_settings.md @@ -97,4 +98,4 @@ ros/ros.md :caption: Python Client API Reference Python Client API Reference -``` \ No newline at end of file +``` diff --git a/docs/tile_actors.md b/docs/tile_actors.md new file mode 100644 index 00000000..7483cf93 --- /dev/null +++ b/docs/tile_actors.md @@ -0,0 +1,96 @@ +# Tile Camera and HTTP Tile Server Actors + +This page explains how to use the tile actors added to the `ProjectAirSim` Unreal plugin: + +- `ATileCameraActor` - maps XYZ tile coordinates to a camera world position and sets orthographic width. +- `ATileHttpServerActor` - serves tile PNG files over HTTP from disk and can render missing tiles on demand through `ATileCameraActor`. +- `UTileMercatorLibrary` - helper library used by both actors to convert tile coordinates to Unreal world coordinates. + +The source files are located in: + +- `unreal/Blocks/Plugins/ProjectAirSim/Source/ProjectAirSim/Private/Tiles/TileCameraActor.*` +- `unreal/Blocks/Plugins/ProjectAirSim/Source/ProjectAirSim/Private/Tiles/TileHttpServerActor.*` +- `unreal/Blocks/Plugins/ProjectAirSim/Source/ProjectAirSim/Private/Tiles/TileMercatorLibrary.*` + +![Tile actors overview (replace with your own screenshot)](images/tiles/tile_actors_overview.jpg) + +## Prerequisites + +1. Use a build that includes the tile actor commit. +2. Ensure the plugin compiles with `HTTPServer` enabled in module dependencies. +3. Open your Unreal level and place the actors from the ProjectAirSim plugin classes. + +## How to use `ATileCameraActor` + +`ATileCameraActor` is responsible for computing camera transform and ortho width for one map tile. + +1. Place a `TileCameraActor` in the level. +2. Configure these key properties in Details panel: + - `Zoom`, `TileX`, `TileY` + - `OriginLat`, `OriginLon` (reference lat/lon mapped to Unreal world origin Same as setted in Jsonc) + - `AltitudeMeters` + - `bTileYIsTMS` if your Y index uses TMS format + - `bUseMercatorScale` to match standard WebMercator tile size behavior (I use it False, True case was not working very well) +3. Enable `bUpdateOnBeginPlay` for one-time initialization or `bUpdateEveryTick` for continuous updates. +4. If you need immediate capture output, enable `bCaptureAfterUpdate`. + +Notes: + +- The actor uses a `SceneCaptureComponent2D` and defaults to orthographic projection. +- `bNorthIsX` controls axis convention: + - `false`: X=East, Y=North + - `true`: X=North, Y=East + +## How to use `ATileHttpServerActor` + +`ATileHttpServerActor` exposes tiles over HTTP with route: + +- `GET /tiles/:z/:x/:y` + +### Basic file-server mode (cache only) + +1. Place a `TileHttpServerActor` in the level. +2. Set: + - `Port` (default `8080`) + - `RoutePrefix` (default `/tiles`) + - `TileRootDir` (default `Saved/Tiles` when relative) + - `MinZoom`, `MaxZoom` +3. Keep `bRenderMissingTiles = false`. +4. Start play. Existing files at `TileRootDir/z/x/y.png` are served directly. + +### On-demand render mode (render missing tiles) + +1. Place both `TileCameraActor` and `TileHttpServerActor` in the level. +2. In `TileHttpServerActor`, set: + - `bRenderMissingTiles = true` + - `TileCamera = ` +3. Optionally set `TileSize` (default `256`) and `bTileYIsTMS`. +4. When a tile is missing on disk, the server renders it, returns PNG bytes, and saves to cache. + +## Request examples + +With default settings (`Port=8080`, `RoutePrefix=/tiles`): + +```bash +curl -o tile.png http://127.0.0.1:8080/tiles/20/0/0 +``` + +PNG extension in URL is also accepted: + +```bash +curl -o tile.png http://127.0.0.1:8080/tiles/20/0/0.png +``` + +## Tile directory layout + +Rendered or pre-generated tiles are expected/saved as: + +```text +///.png +``` + +If `TileRootDir` is relative (for example, `Tiles`), absolute location becomes: + +```text +/Tiles +``` diff --git a/docs/use_plugin.md b/docs/use_plugin.md index d20cfd03..3e4d06ee 100644 --- a/docs/use_plugin.md +++ b/docs/use_plugin.md @@ -119,6 +119,10 @@ bCookAll=True You can also add more drones to the scene. See **[Multiple Robots in a Simulation](multiple_robots.md)** for more details. +## How to use tile camera and tile HTTP server actors + +See **[Tile Camera and HTTP Tile Server Actors](tile_actors.md)** for setup and request examples. + ## How to modify the drone See **[How to Modify a Drone's Physical Geometry](modify_drone_physical.md)** for more details about how a drone's physical geometry can be modified through the **[Robot Configuration Settings](config_robot.md)**. diff --git a/unreal/Blocks/Plugins/ProjectAirSim/Source/ProjectAirSim/Private/Tiles/TileCameraActor.cpp b/unreal/Blocks/Plugins/ProjectAirSim/Source/ProjectAirSim/Private/Tiles/TileCameraActor.cpp new file mode 100644 index 00000000..82b51a22 --- /dev/null +++ b/unreal/Blocks/Plugins/ProjectAirSim/Source/ProjectAirSim/Private/Tiles/TileCameraActor.cpp @@ -0,0 +1,64 @@ +#include "Tiles/TileCameraActor.h" + +#include "Components/SceneCaptureComponent2D.h" +#include "Components/SceneComponent.h" +#include "Engine/SceneCapture2D.h" +#include "Tiles/TileMercatorLibrary.h" + +ATileCameraActor::ATileCameraActor() { + PrimaryActorTick.bCanEverTick = true; + + USceneComponent* Root = CreateDefaultSubobject(TEXT("Root")); + SetRootComponent(Root); + + CaptureComponent = + CreateDefaultSubobject(TEXT("CaptureComponent")); + CaptureComponent->SetupAttachment(RootComponent); + CaptureComponent->ProjectionType = ECameraProjectionMode::Orthographic; + CaptureComponent->bCaptureEveryFrame = false; + CaptureComponent->bCaptureOnMovement = false; + CaptureComponent->CaptureSource = ESceneCaptureSource::SCS_FinalColorLDR; + + SetActorRotation(FRotator(-90.0f, 0.0f, 0.0f)); +} + +void ATileCameraActor::BeginPlay() { + Super::BeginPlay(); + + if (bUpdateOnBeginPlay) { + UpdateFromTile(); + } +} + +void ATileCameraActor::Tick(float DeltaTime) { + Super::Tick(DeltaTime); + + if (bUpdateEveryTick) { + UpdateFromTile(); + } +} + +void ATileCameraActor::UpdateFromTile() { + int32 YForCalc = TileY; + if (bTileYIsTMS && Zoom >= 0) { + const int32 MaxIndex = (1 << Zoom) - 1; + YForCalc = MaxIndex - TileY; + } + + FVector LocationCm = FVector::ZeroVector; + double OrthoWidthCm = 0.0; + UTileMercatorLibrary::ComputeTileCamera( + Zoom, TileX, YForCalc, OriginLat, OriginLon, AltitudeMeters, bNorthIsX, + bUseMercatorScale, LocationCm, OrthoWidthCm); + + SetActorLocation(LocationCm + LocalOffsetCm); + + if (CaptureComponent && bApplyOrthoWidthToCapture) { + CaptureComponent->ProjectionType = ECameraProjectionMode::Orthographic; + CaptureComponent->OrthoWidth = OrthoWidthCm; + } + + if (CaptureComponent && bCaptureAfterUpdate) { + CaptureComponent->CaptureScene(); + } +} diff --git a/unreal/Blocks/Plugins/ProjectAirSim/Source/ProjectAirSim/Private/Tiles/TileCameraActor.h b/unreal/Blocks/Plugins/ProjectAirSim/Source/ProjectAirSim/Private/Tiles/TileCameraActor.h new file mode 100644 index 00000000..ee7770cb --- /dev/null +++ b/unreal/Blocks/Plugins/ProjectAirSim/Source/ProjectAirSim/Private/Tiles/TileCameraActor.h @@ -0,0 +1,69 @@ +#pragma once + +#include "CoreMinimal.h" +#include "GameFramework/Actor.h" +#include "TileCameraActor.generated.h" + +class USceneCaptureComponent2D; + +UCLASS() +class PROJECTAIRSIM_API ATileCameraActor : public AActor { + GENERATED_BODY() + + public: + ATileCameraActor(); + + protected: + virtual void BeginPlay() override; + + public: + virtual void Tick(float DeltaTime) override; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Tiles") + int32 Zoom = 20; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Tiles") + int32 TileX = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Tiles") + int32 TileY = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Tiles") + double OriginLat = 47.641468; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Tiles") + double OriginLon = -122.140165; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Tiles") + double AltitudeMeters = 122.0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Tiles") + bool bNorthIsX = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Tiles") + bool bUseMercatorScale = true; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Tiles") + FVector LocalOffsetCm = FVector::ZeroVector; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Tiles") + bool bTileYIsTMS = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Tiles") + bool bUpdateOnBeginPlay = true; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Tiles") + bool bUpdateEveryTick = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Tiles") + bool bApplyOrthoWidthToCapture = true; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Tiles") + bool bCaptureAfterUpdate = false; + + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Tiles") + USceneCaptureComponent2D* CaptureComponent = nullptr; + + UFUNCTION(BlueprintCallable, Category = "Tiles") + void UpdateFromTile(); +}; diff --git a/unreal/Blocks/Plugins/ProjectAirSim/Source/ProjectAirSim/Private/Tiles/TileHttpServerActor.cpp b/unreal/Blocks/Plugins/ProjectAirSim/Source/ProjectAirSim/Private/Tiles/TileHttpServerActor.cpp new file mode 100644 index 00000000..ec4e0d03 --- /dev/null +++ b/unreal/Blocks/Plugins/ProjectAirSim/Source/ProjectAirSim/Private/Tiles/TileHttpServerActor.cpp @@ -0,0 +1,325 @@ +#include "Tiles/TileHttpServerActor.h" + +#include "Async/Async.h" +#include "Components/SceneCaptureComponent2D.h" +#include "Engine/TextureRenderTarget2D.h" +#include "HAL/PlatformFileManager.h" +#include "HAL/PlatformProcess.h" +#include "HttpPath.h" +#include "HttpServerModule.h" +#include "IHttpRouter.h" +#include "IImageWrapper.h" +#include "IImageWrapperModule.h" +#include "Misc/FileHelper.h" +#include "Misc/Paths.h" +#include "Modules/ModuleManager.h" +#include "Tiles/TileCameraActor.h" +#include "Tiles/TileMercatorLibrary.h" + +DEFINE_LOG_CATEGORY_STATIC(LogTileHttpServer, Log, All); + +ATileHttpServerActor::ATileHttpServerActor() { + PrimaryActorTick.bCanEverTick = false; + + Port = 8080; + TileSize = 256; + MinZoom = 0; + MaxZoom = 20; + bStartOnBeginPlay = true; + bLogRequests = true; +} + +void ATileHttpServerActor::BeginPlay() { + Super::BeginPlay(); + + NormalizeZoomSettings(); + if (bStartOnBeginPlay) { + StartServer(); + } +} + +void ATileHttpServerActor::EndPlay(const EEndPlayReason::Type EndPlayReason) { + StopServer(); + Super::EndPlay(EndPlayReason); +} + +void ATileHttpServerActor::StartServer() { + if (HttpRouter.IsValid()) { + return; + } + + NormalizeZoomSettings(); + FHttpServerModule& HttpServerModule = FHttpServerModule::Get(); + HttpRouter = HttpServerModule.GetHttpRouter(Port); + if (!HttpRouter.IsValid()) { + UE_LOG(LogTileHttpServer, Warning, + TEXT("Failed to get HTTP router on port %d"), Port); + return; + } + + const FString Route = FString::Printf(TEXT("%s/:z/:x/:y"), *RoutePrefix); + TWeakObjectPtr WeakThis(this); + const FHttpRequestHandler Handler = FHttpRequestHandler::CreateLambda( + [WeakThis](const FHttpServerRequest& Request, + const FHttpResultCallback& OnComplete) { + if (!WeakThis.IsValid()) { + return false; + } + return WeakThis->HandleTileRequest(Request, OnComplete); + }); + RouteHandle = + HttpRouter->BindRoute(FHttpPath(Route), EHttpServerRequestVerbs::VERB_GET, + Handler); + + HttpServerModule.StartAllListeners(); + UE_LOG(LogTileHttpServer, Log, + TEXT("Tile HTTP server listening on port %d, route %s"), Port, *Route); +} + +void ATileHttpServerActor::StopServer() { + if (!HttpRouter.IsValid()) { + return; + } + + if (RouteHandle.IsValid()) { + HttpRouter->UnbindRoute(RouteHandle); + RouteHandle.Reset(); + } + + FHttpServerModule::Get().StopAllListeners(); + HttpRouter.Reset(); + UE_LOG(LogTileHttpServer, Log, TEXT("Tile HTTP server stopped")); +} + +bool ATileHttpServerActor::HandleTileRequest(const FHttpServerRequest& Request, + const FHttpResultCallback& OnComplete) { + if (bLogRequests) { + UE_LOG(LogTileHttpServer, Log, TEXT("Request: %s"), + *Request.RelativePath.GetPath()); + } + + const FString* ZStr = Request.PathParams.Find(TEXT("z")); + const FString* XStr = Request.PathParams.Find(TEXT("x")); + const FString* YStr = Request.PathParams.Find(TEXT("y")); + if (!ZStr || !XStr || !YStr) { + if (bLogRequests) { + UE_LOG(LogTileHttpServer, Warning, TEXT("Missing path params for %s"), + *Request.RelativePath.GetPath()); + } + OnComplete(FHttpServerResponse::Error(EHttpServerResponseCodes::BadRequest, + TEXT("BadRequest"), + TEXT("Missing path params"))); + return true; + } + + FString YValue = **YStr; + if (YValue.EndsWith(TEXT(".png"), ESearchCase::IgnoreCase)) { + YValue.LeftChopInline(4); + } + + if (YValue.IsEmpty()) { + if (bLogRequests) { + UE_LOG(LogTileHttpServer, Warning, TEXT("Invalid Y value for %s"), + *Request.RelativePath.GetPath()); + } + OnComplete(FHttpServerResponse::Error(EHttpServerResponseCodes::BadRequest, + TEXT("BadRequest"), + TEXT("Invalid tile y"))); + return true; + } + + const int32 Zoom = FCString::Atoi(**ZStr); + const int32 X = FCString::Atoi(**XStr); + const int32 Y = FCString::Atoi(*YValue); + + if (Zoom < MinZoom || Zoom > MaxZoom) { + if (bLogRequests) { + UE_LOG(LogTileHttpServer, Warning, + TEXT("Zoom out of range: z=%d (min=%d max=%d)"), Zoom, MinZoom, + MaxZoom); + } + OnComplete(FHttpServerResponse::Error(EHttpServerResponseCodes::NotFound, + TEXT("NotFound"), + TEXT("Zoom out of range"))); + return true; + } + + const FString TilePath = BuildTilePath(Zoom, X, Y); + TArray Data; + if (LoadFileToArray(TilePath, Data)) { + if (bLogRequests) { + UE_LOG(LogTileHttpServer, Log, TEXT("Cache hit: z=%d x=%d y=%d"), Zoom, X, + Y); + } + OnComplete(FHttpServerResponse::Create(MoveTemp(Data), TEXT("image/png"))); + return true; + } + + if (!bRenderMissingTiles || !TileCamera) { + if (bLogRequests) { + UE_LOG(LogTileHttpServer, Log, + TEXT("Cache miss: z=%d x=%d y=%d (render disabled)"), Zoom, X, Y); + } + OnComplete(FHttpServerResponse::Error(EHttpServerResponseCodes::NotFound, + TEXT("NotFound"), + TEXT("Tile missing"))); + return true; + } + + TArray Png; + bool bRendered = false; + + auto RenderOnGameThread = [this, Zoom, X, Y, &Png, &bRendered]() { + FScopeLock Lock(&RenderMutex); + bRendered = RenderTileToPng(Zoom, X, Y, Png); + }; + + if (IsInGameThread()) { + RenderOnGameThread(); + } else { + FEvent* DoneEvent = FPlatformProcess::GetSynchEventFromPool(); + AsyncTask(ENamedThreads::GameThread, [RenderOnGameThread, DoneEvent]() { + RenderOnGameThread(); + DoneEvent->Trigger(); + }); + DoneEvent->Wait(); + FPlatformProcess::ReturnSynchEventToPool(DoneEvent); + } + + if (!bRendered) { + if (bLogRequests) { + UE_LOG(LogTileHttpServer, Warning, TEXT("Render failed: z=%d x=%d y=%d"), + Zoom, X, Y); + } + OnComplete(FHttpServerResponse::Error(EHttpServerResponseCodes::NotFound, + TEXT("NotFound"), + TEXT("Tile render failed"))); + return true; + } + + SaveArrayToFile(TilePath, Png); + if (bLogRequests) { + UE_LOG(LogTileHttpServer, Log, TEXT("Rendered: z=%d x=%d y=%d (saved)"), + Zoom, X, Y); + } + OnComplete(FHttpServerResponse::Create(MoveTemp(Png), TEXT("image/png"))); + return true; +} + +void ATileHttpServerActor::NormalizeZoomSettings() { + const int32 OldMinZoom = MinZoom; + const int32 OldMaxZoom = MaxZoom; + + MinZoom = FMath::Clamp(MinZoom, 0, 30); + MaxZoom = FMath::Clamp(MaxZoom, 0, 30); + if (MaxZoom < MinZoom) { + MaxZoom = MinZoom; + } + + if (OldMinZoom != MinZoom || OldMaxZoom != MaxZoom) { + UE_LOG(LogTileHttpServer, Warning, + TEXT("Sanitized zoom range: %d..%d (was %d..%d)"), MinZoom, MaxZoom, + OldMinZoom, OldMaxZoom); + } +} + +bool ATileHttpServerActor::RenderTileToPng(int32 Zoom, int32 X, int32 Y, + TArray& OutPng) { + if (!TileCamera || !TileCamera->CaptureComponent) { + return false; + } + + const int32 YForCalc = bTileYIsTMS ? ((1 << Zoom) - 1 - Y) : Y; + + FVector LocationCm = FVector::ZeroVector; + double OrthoWidthCm = 0.0; + UTileMercatorLibrary::ComputeTileCamera( + Zoom, X, YForCalc, TileCamera->OriginLat, TileCamera->OriginLon, + TileCamera->AltitudeMeters, TileCamera->bNorthIsX, + TileCamera->bUseMercatorScale, LocationCm, OrthoWidthCm); + + TileCamera->SetActorLocation(LocationCm + TileCamera->LocalOffsetCm); + TileCamera->CaptureComponent->ProjectionType = ECameraProjectionMode::Orthographic; + TileCamera->CaptureComponent->OrthoWidth = OrthoWidthCm; + TileCamera->CaptureComponent->CaptureSource = ESceneCaptureSource::SCS_FinalColorLDR; + + if (!TileCamera->CaptureComponent->TextureTarget) { + UTextureRenderTarget2D* RenderTarget = NewObject(TileCamera); + RenderTarget->InitCustomFormat(TileSize, TileSize, PF_B8G8R8A8, false); + RenderTarget->ClearColor = FLinearColor(0.0f, 0.0f, 0.0f, 1.0f); + RenderTarget->UpdateResourceImmediate(true); + TileCamera->CaptureComponent->TextureTarget = RenderTarget; + } else { + TileCamera->CaptureComponent->TextureTarget->ClearColor = + FLinearColor(0.0f, 0.0f, 0.0f, 1.0f); + } + + TileCamera->CaptureComponent->CaptureScene(); + + UTextureRenderTarget2D* RenderTarget = TileCamera->CaptureComponent->TextureTarget; + FTextureRenderTargetResource* RenderResource = + RenderTarget->GameThread_GetRenderTargetResource(); + if (!RenderResource) { + return false; + } + + TArray Pixels; + if (!RenderResource->ReadPixels(Pixels)) { + return false; + } + + for (FColor& Pixel : Pixels) { + Pixel.A = 255; + } + + const int32 Width = RenderTarget->SizeX; + const int32 Height = RenderTarget->SizeY; + + IImageWrapperModule& ImageWrapperModule = + FModuleManager::LoadModuleChecked("ImageWrapper"); + TSharedPtr ImageWrapper = + ImageWrapperModule.CreateImageWrapper(EImageFormat::PNG); + if (!ImageWrapper.IsValid()) { + return false; + } + + if (!ImageWrapper->SetRaw(Pixels.GetData(), Pixels.Num() * sizeof(FColor), Width, + Height, ERGBFormat::BGRA, 8)) { + return false; + } + + const auto Compressed = ImageWrapper->GetCompressed(100); + OutPng.Reset(); + OutPng.Append(Compressed.GetData(), static_cast(Compressed.Num())); + + return OutPng.Num() > 0; +} + +bool ATileHttpServerActor::LoadFileToArray(const FString& FilePath, + TArray& OutData) const { + return FFileHelper::LoadFileToArray(OutData, *FilePath); +} + +bool ATileHttpServerActor::SaveArrayToFile(const FString& FilePath, + const TArray& Data) const { + IPlatformFile& PlatformFile = FPlatformFileManager::Get().GetPlatformFile(); + const FString Dir = FPaths::GetPath(FilePath); + if (!PlatformFile.DirectoryExists(*Dir)) { + PlatformFile.CreateDirectoryTree(*Dir); + } + + return FFileHelper::SaveArrayToFile(Data, *FilePath); +} + +FString ATileHttpServerActor::GetAbsoluteTileRoot() const { + if (FPaths::IsRelative(TileRootDir)) { + return FPaths::Combine(FPaths::ProjectSavedDir(), TileRootDir); + } + return TileRootDir; +} + +FString ATileHttpServerActor::BuildTilePath(int32 Zoom, int32 X, int32 Y) const { + const FString Root = GetAbsoluteTileRoot(); + return FPaths::Combine(Root, FString::FromInt(Zoom), FString::FromInt(X), + FString::Printf(TEXT("%d.png"), Y)); +} diff --git a/unreal/Blocks/Plugins/ProjectAirSim/Source/ProjectAirSim/Private/Tiles/TileHttpServerActor.h b/unreal/Blocks/Plugins/ProjectAirSim/Source/ProjectAirSim/Private/Tiles/TileHttpServerActor.h new file mode 100644 index 00000000..5ecb46b5 --- /dev/null +++ b/unreal/Blocks/Plugins/ProjectAirSim/Source/ProjectAirSim/Private/Tiles/TileHttpServerActor.h @@ -0,0 +1,79 @@ +#pragma once + +#include "CoreMinimal.h" +#include "GameFramework/Actor.h" +#include "HAL/CriticalSection.h" +#include "HttpRequestHandler.h" +#include "HttpRouteHandle.h" +#include "HttpServerRequest.h" +#include "HttpServerResponse.h" +#include "TileHttpServerActor.generated.h" + +class ATileCameraActor; +class IHttpRouter; + +UCLASS() +class PROJECTAIRSIM_API ATileHttpServerActor : public AActor { + GENERATED_BODY() + + public: + ATileHttpServerActor(); + + protected: + virtual void BeginPlay() override; + virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override; + + public: + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HTTP") + int32 Port = 8080; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HTTP") + FString RoutePrefix = TEXT("/tiles"); + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HTTP") + bool bStartOnBeginPlay = true; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HTTP") + bool bLogRequests = true; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Tiles") + FString TileRootDir = TEXT("Tiles"); + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Tiles", meta = (ClampMin = "0", ClampMax = "30")) + int32 MinZoom = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Tiles", meta = (ClampMin = "0", ClampMax = "30")) + int32 MaxZoom = 20; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Tiles") + int32 TileSize = 256; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Tiles") + bool bTileYIsTMS = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Render") + bool bRenderMissingTiles = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Render") + ATileCameraActor* TileCamera = nullptr; + + UFUNCTION(BlueprintCallable, Category = "HTTP") + void StartServer(); + + UFUNCTION(BlueprintCallable, Category = "HTTP") + void StopServer(); + + private: + void NormalizeZoomSettings(); + bool HandleTileRequest(const FHttpServerRequest& Request, + const FHttpResultCallback& OnComplete); + bool RenderTileToPng(int32 Zoom, int32 X, int32 Y, TArray& OutPng); + bool LoadFileToArray(const FString& FilePath, TArray& OutData) const; + bool SaveArrayToFile(const FString& FilePath, const TArray& Data) const; + FString GetAbsoluteTileRoot() const; + FString BuildTilePath(int32 Zoom, int32 X, int32 Y) const; + + TSharedPtr HttpRouter; + FHttpRouteHandle RouteHandle; + FCriticalSection RenderMutex; +}; diff --git a/unreal/Blocks/Plugins/ProjectAirSim/Source/ProjectAirSim/Private/Tiles/TileMercatorLibrary.cpp b/unreal/Blocks/Plugins/ProjectAirSim/Source/ProjectAirSim/Private/Tiles/TileMercatorLibrary.cpp new file mode 100644 index 00000000..8014daa3 --- /dev/null +++ b/unreal/Blocks/Plugins/ProjectAirSim/Source/ProjectAirSim/Private/Tiles/TileMercatorLibrary.cpp @@ -0,0 +1,114 @@ +#include "Tiles/TileMercatorLibrary.h" + +namespace { +constexpr double kEarthRadiusMeters = 6378137.0; +constexpr double kPi = 3.14159265358979323846; +constexpr double kDegToRad = kPi / 180.0; +constexpr double kRadToDeg = 180.0 / kPi; +constexpr double kMetersToCm = 100.0; + +double ClampLatitude(double LatDeg) { + constexpr double MaxLat = 85.05112878; + return FMath::Clamp(LatDeg, -MaxLat, MaxLat); +} + +void LatLonToMercator(double LatDeg, double LonDeg, double& OutX, + double& OutY) { + const double Lat = ClampLatitude(LatDeg) * kDegToRad; + const double Lon = LonDeg * kDegToRad; + OutX = kEarthRadiusMeters * Lon; + OutY = kEarthRadiusMeters * FMath::Loge(FMath::Tan(kPi / 4.0 + Lat / 2.0)); +} + +void MercatorToLatLon(double X, double Y, double& OutLatDeg, + double& OutLonDeg) { + const double Lon = X / kEarthRadiusMeters; + const double Lat = 2.0 * FMath::Atan(FMath::Exp(Y / kEarthRadiusMeters)) - + kPi / 2.0; + OutLatDeg = Lat * kRadToDeg; + OutLonDeg = Lon * kRadToDeg; +} + +void LatLonToLocalEnuMeters(double LatDeg, double LonDeg, double OriginLat, + double OriginLon, double& OutEast, + double& OutNorth) { + const double LatRad = LatDeg * kDegToRad; + const double OriginLatRad = OriginLat * kDegToRad; + const double OriginLonRad = OriginLon * kDegToRad; + const double LonRad = LonDeg * kDegToRad; + + const double dLat = LatRad - OriginLatRad; + const double dLon = LonRad - OriginLonRad; + + OutNorth = dLat * kEarthRadiusMeters; + OutEast = dLon * kEarthRadiusMeters * FMath::Cos(OriginLatRad); +} + +double MetersPerPixel(double LatDeg, int32 Zoom) { + const double LatRad = LatDeg * kDegToRad; + const double N = FMath::Pow(2.0, static_cast(Zoom)); + return (2.0 * kPi * kEarthRadiusMeters * FMath::Cos(LatRad)) / (256.0 * N); +} +} // namespace + +void UTileMercatorLibrary::ComputeTileCamera(int32 Zoom, + int32 X, + int32 Y, + double OriginLat, + double OriginLon, + double AltitudeMeters, + bool bNorthIsX, + bool bUseMercatorScale, + FVector& OutLocationCm, + double& OutOrthoWidthCm) { + if (Zoom < 0) { + OutLocationCm = FVector::ZeroVector; + OutOrthoWidthCm = 0.0; + return; + } + + const double N = FMath::Pow(2.0, static_cast(Zoom)); + const double WorldMeters = 2.0 * kPi * kEarthRadiusMeters; + + double OriginX = 0.0; + double OriginY = 0.0; + LatLonToMercator(OriginLat, OriginLon, OriginX, OriginY); + + const double TileMetersMercator = WorldMeters / N; + const double MinX = -WorldMeters / 2.0 + static_cast(X) * TileMetersMercator; + const double MaxY = WorldMeters / 2.0 - static_cast(Y) * TileMetersMercator; + const double CenterX = MinX + TileMetersMercator / 2.0; + const double CenterY = MaxY - TileMetersMercator / 2.0; + + double LocalX = 0.0; + double LocalY = 0.0; + double OrthoWidthMeters = 0.0; + + if (bUseMercatorScale) { + LocalX = (CenterX - OriginX) * kMetersToCm; + LocalY = (CenterY - OriginY) * kMetersToCm; + OrthoWidthMeters = TileMetersMercator; + } else { + double TileLat = 0.0; + double TileLon = 0.0; + MercatorToLatLon(CenterX, CenterY, TileLat, TileLon); + + double EastMeters = 0.0; + double NorthMeters = 0.0; + LatLonToLocalEnuMeters(TileLat, TileLon, OriginLat, OriginLon, EastMeters, + NorthMeters); + + LocalX = EastMeters * kMetersToCm; + LocalY = NorthMeters * kMetersToCm; + OrthoWidthMeters = MetersPerPixel(TileLat, Zoom) * 256.0; + } + + const double LocalZ = AltitudeMeters * kMetersToCm; + if (bNorthIsX) { + OutLocationCm = FVector(LocalY, LocalX, LocalZ); + } else { + OutLocationCm = FVector(LocalX, LocalY, LocalZ); + } + + OutOrthoWidthCm = OrthoWidthMeters * kMetersToCm; +} diff --git a/unreal/Blocks/Plugins/ProjectAirSim/Source/ProjectAirSim/Private/Tiles/TileMercatorLibrary.h b/unreal/Blocks/Plugins/ProjectAirSim/Source/ProjectAirSim/Private/Tiles/TileMercatorLibrary.h new file mode 100644 index 00000000..7ac48559 --- /dev/null +++ b/unreal/Blocks/Plugins/ProjectAirSim/Source/ProjectAirSim/Private/Tiles/TileMercatorLibrary.h @@ -0,0 +1,24 @@ +#pragma once + +#include "CoreMinimal.h" +#include "Kismet/BlueprintFunctionLibrary.h" +#include "TileMercatorLibrary.generated.h" + +UCLASS() +class PROJECTAIRSIM_API UTileMercatorLibrary : public UBlueprintFunctionLibrary { + GENERATED_BODY() + + public: + // Computes camera location (cm) and ortho width (cm) for an OSM XYZ tile. + UFUNCTION(BlueprintCallable, Category = "Tiles") + static void ComputeTileCamera(int32 Zoom, + int32 X, + int32 Y, + double OriginLat, + double OriginLon, + double AltitudeMeters, + bool bNorthIsX, + bool bUseMercatorScale, + FVector& OutLocationCm, + double& OutOrthoWidthCm); +}; diff --git a/unreal/Blocks/Plugins/ProjectAirSim/Source/ProjectAirSim/ProjectAirSim.Build.cs b/unreal/Blocks/Plugins/ProjectAirSim/Source/ProjectAirSim/ProjectAirSim.Build.cs index 5805354a..fbfa453b 100644 --- a/unreal/Blocks/Plugins/ProjectAirSim/Source/ProjectAirSim/ProjectAirSim.Build.cs +++ b/unreal/Blocks/Plugins/ProjectAirSim/Source/ProjectAirSim/ProjectAirSim.Build.cs @@ -107,6 +107,7 @@ public ProjectAirSim(ReadOnlyTargetRules Target) : base(Target) "UMG", // For weather features (Widgets), "PhysicsCore", // For UPhysicalMaterial in UE 4.26 "CinematicCamera", + "HTTPServer", // ... add other public dependencies that you statically link with here ... } );