Tooling
This commit is contained in:
@@ -12,4 +12,8 @@ namespace TelemetryEvents
|
||||
inline const FName SessionStarted(TEXT("session_started"));
|
||||
inline const FName SessionEnded(TEXT("session_ended"));
|
||||
inline const FName CheatUsed(TEXT("cheat_used"));
|
||||
|
||||
// World map (off the ladder, with the world). One event: opening it is the act worth counting, and which
|
||||
// layer and how far zoomed in say what a person actually wanted from it.
|
||||
inline const FName WorldMapOpened(TEXT("world_map_opened"));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
#include "Misc/AutomationTest.h"
|
||||
#include "World/WorldMapProjection.h"
|
||||
|
||||
#if WITH_DEV_AUTOMATION_TESTS
|
||||
|
||||
namespace
|
||||
{
|
||||
// L_World as RawContent/World/Region.json describes it: 14 x 7 tiles of 2550 quads at 200 cm, straddling
|
||||
// the origin, the whole cylinder. The numbers are duplicated here on purpose - a test that read the
|
||||
// manifest would pass when both it and the manifest were wrong together.
|
||||
FWorldMapProjection MakeWorldProjection()
|
||||
{
|
||||
FWorldMapProjection Projection;
|
||||
Projection.WidthM = 71400.0;
|
||||
Projection.HeightM = 35700.0;
|
||||
Projection.CentreM = FVector2D::ZeroVector;
|
||||
Projection.bWrapsX = true;
|
||||
Projection.ElevationMinM = -1024.0;
|
||||
Projection.ElevationMaxM = 6144.0;
|
||||
Projection.SeaLevelM = 0.0;
|
||||
return Projection;
|
||||
}
|
||||
|
||||
constexpr double Tolerance = 1e-9;
|
||||
}
|
||||
|
||||
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FWorldMapProjectionCornersTest, "Salty.Core.WorldMap.Projection.CornersAndCentre",
|
||||
EAutomationTestFlags::ProductFilter | EAutomationTestFlags_ApplicationContextMask)
|
||||
|
||||
bool FWorldMapProjectionCornersTest::RunTest(const FString& Parameters)
|
||||
{
|
||||
const FWorldMapProjection Projection = MakeWorldProjection();
|
||||
|
||||
// The world straddles the origin, so the centre of the art is the origin and the corners are half the
|
||||
// extent away. In centimetres, because that is the unit every call site is in.
|
||||
const FVector2D Centre = Projection.WorldToNormalised(FVector2D(0.0, 0.0));
|
||||
TestNearlyEqual(TEXT("world origin is the middle of the map in U"), Centre.X, 0.5, Tolerance);
|
||||
TestNearlyEqual(TEXT("world origin is the middle of the map in V"), Centre.Y, 0.5, Tolerance);
|
||||
|
||||
const FVector2D TopLeft = Projection.WorldToNormalised(FVector2D(-3570000.0, -1785000.0));
|
||||
TestNearlyEqual(TEXT("west edge is U 0"), TopLeft.X, 0.0, Tolerance);
|
||||
TestNearlyEqual(TEXT("north edge is V 0"), TopLeft.Y, 0.0, Tolerance);
|
||||
|
||||
const FVector2D BottomRight = Projection.WorldToNormalised(FVector2D(3570000.0, 1785000.0));
|
||||
TestNearlyEqual(TEXT("east edge is U 1"), BottomRight.X, 1.0, Tolerance);
|
||||
TestNearlyEqual(TEXT("south edge is V 1"), BottomRight.Y, 1.0, Tolerance);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FWorldMapProjectionRoundTripTest, "Salty.Core.WorldMap.Projection.RoundTrips",
|
||||
EAutomationTestFlags::ProductFilter | EAutomationTestFlags_ApplicationContextMask)
|
||||
|
||||
bool FWorldMapProjectionRoundTripTest::RunTest(const FString& Parameters)
|
||||
{
|
||||
const FWorldMapProjection Projection = MakeWorldProjection();
|
||||
|
||||
// Arbitrary places, including outside the map, which is legal: a marker off the edge is off the edge and
|
||||
// the transform is not the thing that decides what to do about it.
|
||||
const TArray<FVector2D> Places = {
|
||||
FVector2D(0.0, 0.0),
|
||||
FVector2D(1234567.0, -890123.0),
|
||||
FVector2D(-3569999.0, 1784999.0),
|
||||
FVector2D(9000000.0, 4000000.0),
|
||||
};
|
||||
|
||||
for (const FVector2D& Place : Places)
|
||||
{
|
||||
const FVector2D Back = Projection.NormalisedToWorldCm(Projection.WorldToNormalised(Place));
|
||||
TestNearlyEqual(*FString::Printf(TEXT("X round trips at (%.0f, %.0f)"), Place.X, Place.Y), Back.X, Place.X, 1e-6);
|
||||
TestNearlyEqual(*FString::Printf(TEXT("Y round trips at (%.0f, %.0f)"), Place.X, Place.Y), Back.Y, Place.Y, 1e-6);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FWorldMapProjectionWrapTest, "Salty.Core.WorldMap.Projection.WrapsWestwardPastTheSeam",
|
||||
EAutomationTestFlags::ProductFilter | EAutomationTestFlags_ApplicationContextMask)
|
||||
|
||||
bool FWorldMapProjectionWrapTest::RunTest(const FString& Parameters)
|
||||
{
|
||||
const FWorldMapProjection Projection = MakeWorldProjection();
|
||||
|
||||
// The case that motivated WrapU: panning west off the seam. FMath::Fmod would leave these negative and the
|
||||
// map would sample nothing at all.
|
||||
TestNearlyEqual(TEXT("just west of the seam comes back to just east of it"), Projection.WrapU(-0.02), 0.98, Tolerance);
|
||||
TestNearlyEqual(TEXT("a whole turn west is the seam"), Projection.WrapU(-1.0), 0.0, Tolerance);
|
||||
TestNearlyEqual(TEXT("two and a bit turns east"), Projection.WrapU(2.25), 0.25, Tolerance);
|
||||
TestNearlyEqual(TEXT("inside the map is untouched"), Projection.WrapU(0.5), 0.5, Tolerance);
|
||||
|
||||
// Half open: 1.0 is the same meridian as 0.0 and must not be a second copy of it, or the seam column is
|
||||
// drawn twice at some zoom levels and shimmers.
|
||||
TestNearlyEqual(TEXT("U 1 is U 0"), Projection.WrapU(1.0), 0.0, Tolerance);
|
||||
|
||||
// And a map that is a window rather than a whole cylinder has edges, which stay edges.
|
||||
FWorldMapProjection Window = MakeWorldProjection();
|
||||
Window.bWrapsX = false;
|
||||
TestNearlyEqual(TEXT("a window does not wrap"), Window.WrapU(-0.02), -0.02, Tolerance);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FWorldMapProjectionShortestDeltaTest, "Salty.Core.WorldMap.Projection.ShortestWayRound",
|
||||
EAutomationTestFlags::ProductFilter | EAutomationTestFlags_ApplicationContextMask)
|
||||
|
||||
bool FWorldMapProjectionShortestDeltaTest::RunTest(const FString& Parameters)
|
||||
{
|
||||
const FWorldMapProjection Projection = MakeWorldProjection();
|
||||
|
||||
// Either side of the seam is close, not almost a whole world apart. This is what keeps a marker at U 0.99
|
||||
// drawn next to a view centred on U 0.01 rather than off the far edge.
|
||||
TestNearlyEqual(TEXT("across the seam eastward"), Projection.ShortestDeltaU(0.99, 0.01), 0.02, Tolerance);
|
||||
TestNearlyEqual(TEXT("across the seam westward"), Projection.ShortestDeltaU(0.01, 0.99), -0.02, Tolerance);
|
||||
TestNearlyEqual(TEXT("ordinary distance is unchanged"), Projection.ShortestDeltaU(0.20, 0.35), 0.15, Tolerance);
|
||||
|
||||
FWorldMapProjection Window = MakeWorldProjection();
|
||||
Window.bWrapsX = false;
|
||||
TestNearlyEqual(TEXT("a window goes the long way because there is no short way"),
|
||||
Window.ShortestDeltaU(0.99, 0.01), -0.98, Tolerance);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FWorldMapProjectionDistanceTest, "Salty.Core.WorldMap.Projection.DistanceInMetres",
|
||||
EAutomationTestFlags::ProductFilter | EAutomationTestFlags_ApplicationContextMask)
|
||||
|
||||
bool FWorldMapProjectionDistanceTest::RunTest(const FString& Parameters)
|
||||
{
|
||||
const FWorldMapProjection Projection = MakeWorldProjection();
|
||||
|
||||
// A kilometre east of the origin is a kilometre, in the unit a player would be told.
|
||||
TestNearlyEqual(TEXT("a kilometre east"),
|
||||
Projection.DistanceM(FVector2D(0.0, 0.0), FVector2D(100000.0, 0.0)), 1000.0, 1e-6);
|
||||
|
||||
// Two places a hundred metres either side of the seam are two hundred metres apart, not 71.2 km.
|
||||
// The map is 71 400 m wide and straddles the origin, so its edges are at +-35 700 m: a hundred metres
|
||||
// inside each of them is +-35 600 m, which is +-3 560 000 cm.
|
||||
const double JustWest = -3560000.0;
|
||||
const double JustEast = 3560000.0;
|
||||
TestNearlyEqual(TEXT("either side of the seam is 200 m, not most of a world"),
|
||||
Projection.DistanceM(FVector2D(JustWest, 0.0), FVector2D(JustEast, 0.0)), 200.0, 1e-6);
|
||||
|
||||
// And on a map that does not wrap those same two places really are most of a world apart.
|
||||
FWorldMapProjection Window = MakeWorldProjection();
|
||||
Window.bWrapsX = false;
|
||||
TestNearlyEqual(TEXT("without a seam to cross it is the long way"),
|
||||
Window.DistanceM(FVector2D(JustWest, 0.0), FVector2D(JustEast, 0.0)), 71200.0, 1e-6);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FWorldMapProjectionInvalidTest, "Salty.Core.WorldMap.Projection.UnsetIsInvalidAndSafe",
|
||||
EAutomationTestFlags::ProductFilter | EAutomationTestFlags_ApplicationContextMask)
|
||||
|
||||
bool FWorldMapProjectionInvalidTest::RunTest(const FString& Parameters)
|
||||
{
|
||||
// A definition asset that was never filled in is the normal state of a new one, and asking it where a place
|
||||
// is must not divide by zero - the widget checks IsValid and draws nothing, rather than NaNs.
|
||||
const FWorldMapProjection Empty;
|
||||
TestFalse(TEXT("a default projection is not valid"), Empty.IsValid());
|
||||
TestEqual(TEXT("and maps everything to the origin rather than to NaN"),
|
||||
Empty.WorldToNormalised(FVector2D(1234.0, 5678.0)), FVector2D::ZeroVector);
|
||||
TestEqual(TEXT("both ways"), Empty.NormalisedToWorldCm(FVector2D(0.5, 0.5)), FVector2D::ZeroVector);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
#endif // WITH_DEV_AUTOMATION_TESTS
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user