This commit is contained in:
Rainer Leit
2026-09-25 17:02:24 +03:00
parent cc43ed8dc8
commit 9597629951
2149 changed files with 460234 additions and 1770 deletions
@@ -0,0 +1,65 @@
#include "World/WorldMapProjection.h"
FVector2D FWorldMapProjection::WorldToNormalised(const FVector2D& WorldCm) const
{
if (!IsValid())
{
return FVector2D::ZeroVector;
}
const FVector2D FromCentreM = WorldCm / 100.0 - CentreM;
return FVector2D(FromCentreM.X / WidthM + 0.5, FromCentreM.Y / HeightM + 0.5);
}
FVector2D FWorldMapProjection::NormalisedToWorldCm(const FVector2D& Normalised) const
{
if (!IsValid())
{
return FVector2D::ZeroVector;
}
const FVector2D FromCentreM((Normalised.X - 0.5) * WidthM, (Normalised.Y - 0.5) * HeightM);
return (CentreM + FromCentreM) * 100.0;
}
double FWorldMapProjection::WrapU(double U) const
{
if (!bWrapsX)
{
return U;
}
// Not FMath::Fmod: it keeps the sign of the dividend, so -0.02 stays -0.02 and a pan west off the seam
// samples outside the art. FMath::Wrap is inclusive of its maximum, which would make 1.0 a second copy of
// the seam column. Floor is the one that gives a half-open [0, 1).
const double Wrapped = U - FMath::Floor(U);
// Floor of a tiny negative can round to exactly 1.0 in double; fold it back so the range stays half-open.
return Wrapped >= 1.0 ? 0.0 : Wrapped;
}
double FWorldMapProjection::ShortestDeltaU(double FromU, double ToU) const
{
double Delta = ToU - FromU;
if (bWrapsX)
{
Delta -= FMath::RoundToDouble(Delta);
}
return Delta;
}
double FWorldMapProjection::DistanceM(const FVector2D& FromWorldCm, const FVector2D& ToWorldCm) const
{
if (!IsValid())
{
return 0.0;
}
const FVector2D From = WorldToNormalised(FromWorldCm);
const FVector2D To = WorldToNormalised(ToWorldCm);
const double DeltaXM = ShortestDeltaU(From.X, To.X) * WidthM;
const double DeltaYM = (To.Y - From.Y) * HeightM;
return FMath::Sqrt(DeltaXM * DeltaXM + DeltaYM * DeltaYM);
}
FString FWorldMapProjection::ToString() const
{
return FString::Printf(TEXT("%.2f x %.2f km centred on (%.0f, %.0f) m, elevation %.0f..%.0f m%s"),
WidthM / 1000.0, HeightM / 1000.0, CentreM.X, CentreM.Y,
ElevationMinM, ElevationMaxM, bWrapsX ? TEXT(", wraps in X") : TEXT(""));
}
@@ -0,0 +1,98 @@
#pragma once
#include "CoreMinimal.h"
#include "WorldMapProjection.generated.h"
// Where a place in the world is on a picture of the world, and back. // pure
//
// This is the whole of what a map view needs to know about the ground, and it is four numbers, because
// L_World is a *whole planet map imported with no crop* (RawContent/World/Region.json's window is the full
// 8192 x 4096 export) laid out as a grid of landscapes straddling the world origin. So the map art and the
// world are the same rectangle at two scales, and the transform between them is one multiply and one add per
// axis - no render capture, no per-tile bookkeeping, no stitching.
//
// The axes are the region manifest's: the source image's X runs along world X and its Y along world Y, which
// is what Scripts/Authoring/generate_region_tiles.py samples by and therefore what the ground actually is.
// On screen that means the map's horizontal axis is world X and *up on the map is world -Y*. That is not the
// compass convention and it is deliberately not corrected here: the alternative is a map that disagrees with
// the coordinates every other tool in the project prints.
//
// Normalised coordinates are 0..1 across the art, which is why they are the currency rather than pixels: every
// layer is a different render of the same cylinder and they are not all the same resolution.
USTRUCT(BlueprintType)
struct SALTYCORE_API FWorldMapProjection
{
GENERATED_BODY()
// The ground the map covers, in metres. 71 400 x 35 700 for L_World.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "World Map")
double WidthM = 0.0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "World Map")
double HeightM = 0.0;
// Where the centre of the map sits in the world, in metres. The region grid straddles the origin, so this
// is 0,0 today; it is a field rather than an assumption because a window cut from somewhere else would not.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "World Map")
FVector2D CentreM = FVector2D::ZeroVector;
// True when the art is a whole cylinder, so its left and right edges are the same meridian. Panning then
// crosses the seam instead of stopping at it, and the distance between two points may go the short way
// round. False for a window cut out of one, where the edges are edges.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "World Map")
bool bWrapsX = false;
// The height contract, copied from the same manifest, so a readout can say what a place is in metres
// without the map owning a second opinion about elevation. Not used by the transform.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "World Map")
double ElevationMinM = 0.0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "World Map")
double ElevationMaxM = 0.0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "World Map")
double SeaLevelM = 0.0;
bool IsValid() const { return WidthM > 0.0 && HeightM > 0.0; }
FVector2D ExtentM() const { return FVector2D(WidthM, HeightM); }
/** Metres of ground across the whole map, per axis. The two differ on any map that is not square. */
double MetresPerUnitX() const { return WidthM; }
double MetresPerUnitY() const { return HeightM; }
/** World XY in centimetres to 0..1 across the art. Outside the map the result is simply outside 0..1. */
FVector2D WorldToNormalised(const FVector2D& WorldCm) const;
/** The same for a full world location; Z is ignored. */
FVector2D WorldToNormalised(const FVector& WorldCm) const { return WorldToNormalised(FVector2D(WorldCm.X, WorldCm.Y)); }
/** 0..1 across the art back to world XY in centimetres. */
FVector2D NormalisedToWorldCm(const FVector2D& Normalised) const;
/** Metres rather than centimetres, for anything a person reads. */
FVector2D NormalisedToWorldM(const FVector2D& Normalised) const { return NormalisedToWorldCm(Normalised) / 100.0; }
/**
* U folded into [0, 1) on a wrapping map, left alone on one that does not wrap. Every pan and every marker
* goes through this rather than through fmod at the call site: FMath::Fmod keeps the sign of its argument,
* so a westward pan past the seam lands at -0.02 and samples nothing.
*/
double WrapU(double U) const;
/** WrapU on the U of a pair, V untouched - V is latitude and a cylinder does not wrap over its poles. */
FVector2D WrapNormalised(const FVector2D& Normalised) const { return FVector2D(WrapU(Normalised.X), Normalised.Y); }
/**
* Signed shortest distance from one U to another, in normalised units. On a wrapping map two points either
* side of the seam are close, and this is what says so: the result is in [-0.5, 0.5]. Without it a marker
* at U 0.99 and a view centred on U 0.01 are 0.98 apart and the marker is drawn off the far edge.
*/
double ShortestDeltaU(double FromU, double ToU) const;
/** How far apart two places are on the ground, in metres, taking the short way round a wrapping map. */
double DistanceM(const FVector2D& FromWorldCm, const FVector2D& ToWorldCm) const;
/** A line for a log or a tooltip. Not for a player: this is metres and axis names, not a place name. */
FString ToString() const;
};