feat: add tested map projection core

This commit is contained in:
torlando-agent[bot]
2026-08-07 01:23:28 +00:00
parent 504afdce70
commit bb2e4d9499
4 changed files with 764 additions and 0 deletions
+335
View File
@@ -0,0 +1,335 @@
#include "MapProjection.h"
#include <cmath>
#include <cstdint>
#include <limits>
namespace Pyxis {
namespace MapProjection {
const double WEB_MERCATOR_MAX_LATITUDE = 85.05112878;
namespace {
const double PI = 3.141592653589793238462643383279502884;
const double MAX_RAW_TILE_COORDINATE = 1000000000000.0;
bool finite(double value) {
return std::isfinite(value) != 0;
}
double worldPixels(std::uint32_t zoom) {
return static_cast<double>(tileCount(zoom)) * static_cast<double>(TILE_SIZE);
}
double wrap(double value, double period) {
double result = std::fmod(value, period);
if (result < 0.0) {
result += period;
}
// Guard against a possible rounded period after arithmetic at the edge.
return result >= period ? 0.0 : result;
}
double shortestWrappedDelta(double delta, double period) {
return wrap(delta + (period * 0.5), period) - (period * 0.5);
}
std::uint32_t wrapTileX(std::int64_t raw, std::uint32_t tiles) {
const std::int64_t period = static_cast<std::int64_t>(tiles);
std::int64_t result = raw % period;
if (result < 0) {
result += period;
}
return static_cast<std::uint32_t>(result);
}
std::uint32_t clampTileY(std::int64_t raw, std::uint32_t tiles) {
if (raw < 0) {
return 0U;
}
const std::int64_t last = static_cast<std::int64_t>(tiles) - 1;
if (raw > last) {
return tiles - 1U;
}
return static_cast<std::uint32_t>(raw);
}
bool validViewport(const Viewport& viewport) {
return finite(viewport.left) && finite(viewport.top) &&
(viewport.width != 0U) && (viewport.height != 0U);
}
} // namespace
double clampLatitude(double latitude) {
if (latitude > WEB_MERCATOR_MAX_LATITUDE) {
return WEB_MERCATOR_MAX_LATITUDE;
}
if (latitude < -WEB_MERCATOR_MAX_LATITUDE) {
return -WEB_MERCATOR_MAX_LATITUDE;
}
return latitude;
}
double normalizeLongitude(double longitude) {
if (!finite(longitude)) {
return longitude;
}
return wrap(longitude + 180.0, 360.0) - 180.0;
}
bool isValidZoom(std::uint32_t zoom) {
return zoom <= static_cast<std::uint32_t>(MAX_ZOOM);
}
std::uint32_t tileCount(std::uint32_t zoom) {
if (!isValidZoom(zoom)) {
return 0U;
}
std::uint32_t count = 1U;
for (std::uint32_t level = 0U; level < zoom; ++level) {
count *= 2U;
}
return count;
}
Status latLonToGlobalPixel(const GeoPoint& point, std::uint32_t zoom, GlobalPixel& output) {
if (!isValidZoom(zoom)) {
return Status::INVALID_ZOOM;
}
if (!finite(point.latitude) || !finite(point.longitude)) {
return Status::INVALID_ARGUMENT;
}
const double world = worldPixels(zoom);
const double latitude = clampLatitude(point.latitude);
const double longitude = normalizeLongitude(point.longitude);
const double radians = latitude * PI / 180.0;
const double mercator = std::log(std::tan((PI * 0.25) + (radians * 0.5)));
GlobalPixel result;
result.x = ((longitude + 180.0) / 360.0) * world;
result.y = (0.5 - (mercator / (2.0 * PI))) * world;
if (result.y < 0.0) {
result.y = 0.0;
} else if (result.y > world) {
result.y = world;
}
output = result;
return Status::OK;
}
Status globalPixelToLatLon(const GlobalPixel& pixel, std::uint32_t zoom, GeoPoint& output) {
if (!isValidZoom(zoom)) {
return Status::INVALID_ZOOM;
}
if (!finite(pixel.x) || !finite(pixel.y)) {
return Status::INVALID_ARGUMENT;
}
const double world = worldPixels(zoom);
double y = pixel.y;
if (y < 0.0) {
y = 0.0;
} else if (y > world) {
y = world;
}
const double normalized_y = 0.5 - (y / world);
GeoPoint result;
result.latitude = (180.0 / PI) * std::atan(std::sinh(2.0 * PI * normalized_y));
result.latitude = clampLatitude(result.latitude);
result.longitude = normalizeLongitude((wrap(pixel.x, world) / world * 360.0) - 180.0);
output = result;
return Status::OK;
}
Status globalPixelToTile(const GlobalPixel& pixel, std::uint32_t zoom, TileIndex& output) {
if (!isValidZoom(zoom)) {
return Status::INVALID_ZOOM;
}
if (!finite(pixel.x) || !finite(pixel.y)) {
return Status::INVALID_ARGUMENT;
}
const std::uint32_t tiles = tileCount(zoom);
const double world = worldPixels(zoom);
const double wrapped_x = wrap(pixel.x, world);
double clamped_y = pixel.y;
if (clamped_y < 0.0) {
clamped_y = 0.0;
} else if (clamped_y >= world) {
clamped_y = std::nextafter(world, 0.0);
}
TileIndex result;
result.x = static_cast<std::uint32_t>(std::floor(wrapped_x / static_cast<double>(TILE_SIZE)));
result.y = static_cast<std::uint32_t>(std::floor(clamped_y / static_cast<double>(TILE_SIZE)));
result.zoom = zoom;
if (result.x >= tiles) {
result.x = 0U;
}
if (result.y >= tiles) {
result.y = tiles - 1U;
}
output = result;
return Status::OK;
}
Status viewportTiles(const Viewport& viewport,
std::uint32_t zoom,
bool include_border,
TilePlacement* output,
std::size_t capacity,
std::size_t& count) {
count = 0U;
if (!isValidZoom(zoom)) {
return Status::INVALID_ZOOM;
}
if (!validViewport(viewport) || ((output == NULL) && (capacity != 0U))) {
return Status::INVALID_ARGUMENT;
}
const double right = viewport.left + static_cast<double>(viewport.width);
const double bottom = viewport.top + static_cast<double>(viewport.height);
const double tile_size = static_cast<double>(TILE_SIZE);
if (!finite(right) || !finite(bottom)) {
return Status::INVALID_ARGUMENT;
}
double first_x_value = std::floor(viewport.left / tile_size);
double last_x_value = std::ceil(right / tile_size) - 1.0;
double first_y_value = std::floor(viewport.top / tile_size);
double last_y_value = std::ceil(bottom / tile_size) - 1.0;
if (include_border) {
first_x_value -= 1.0;
last_x_value += 1.0;
first_y_value -= 1.0;
last_y_value += 1.0;
}
if ((std::fabs(first_x_value) > MAX_RAW_TILE_COORDINATE) ||
(std::fabs(last_x_value) > MAX_RAW_TILE_COORDINATE) ||
(std::fabs(first_y_value) > MAX_RAW_TILE_COORDINATE) ||
(std::fabs(last_y_value) > MAX_RAW_TILE_COORDINATE)) {
return Status::VIEWPORT_TOO_LARGE;
}
const std::int64_t first_x = static_cast<std::int64_t>(first_x_value);
const std::int64_t last_x = static_cast<std::int64_t>(last_x_value);
const std::int64_t first_y = static_cast<std::int64_t>(first_y_value);
const std::int64_t last_y = static_cast<std::int64_t>(last_y_value);
const std::int64_t columns = (last_x - first_x) + 1;
const std::int64_t rows = (last_y - first_y) + 1;
if ((columns <= 0) || (rows <= 0) ||
(columns > static_cast<std::int64_t>(MAX_VIEWPORT_TILES)) ||
(rows > static_cast<std::int64_t>(MAX_VIEWPORT_TILES)) ||
(columns * rows > static_cast<std::int64_t>(MAX_VIEWPORT_TILES))) {
return Status::VIEWPORT_TOO_LARGE;
}
TilePlacement candidates[MAX_VIEWPORT_TILES];
std::size_t unique_count = 0U;
const std::uint32_t tiles = tileCount(zoom);
const double viewport_center_x = static_cast<double>(viewport.width) * 0.5;
for (std::int64_t raw_y = first_y; raw_y <= last_y; ++raw_y) {
const std::uint32_t y = clampTileY(raw_y, tiles);
const double screen_y = (static_cast<double>(y) * tile_size) - viewport.top;
for (std::int64_t raw_x = first_x; raw_x <= last_x; ++raw_x) {
const std::uint32_t x = wrapTileX(raw_x, tiles);
const double screen_x = (static_cast<double>(raw_x) * tile_size) - viewport.left;
std::size_t duplicate = unique_count;
for (std::size_t i = 0U; i < unique_count; ++i) {
if ((candidates[i].tile.x == x) && (candidates[i].tile.y == y)) {
duplicate = i;
break;
}
}
if (duplicate == unique_count) {
candidates[unique_count].tile.x = x;
candidates[unique_count].tile.y = y;
candidates[unique_count].tile.zoom = zoom;
candidates[unique_count].screen_x = screen_x;
candidates[unique_count].screen_y = screen_y;
++unique_count;
} else {
const double old_distance = std::fabs((candidates[duplicate].screen_x + (tile_size * 0.5)) - viewport_center_x);
const double new_distance = std::fabs((screen_x + (tile_size * 0.5)) - viewport_center_x);
if (new_distance < old_distance) {
candidates[duplicate].screen_x = screen_x;
}
}
}
}
if (unique_count > capacity) {
return Status::CAPACITY_EXCEEDED;
}
if ((unique_count != 0U) && (output == NULL)) {
return Status::CAPACITY_EXCEEDED;
}
for (std::size_t i = 0U; i < unique_count; ++i) {
output[i] = candidates[i];
}
count = unique_count;
return Status::OK;
}
Status projectMarker(const GeoPoint& point,
const Viewport& viewport,
std::uint32_t zoom,
MarkerProjection& output) {
if (!isValidZoom(zoom)) {
return Status::INVALID_ZOOM;
}
if (!validViewport(viewport)) {
return Status::INVALID_ARGUMENT;
}
GlobalPixel marker_pixel;
const Status status = latLonToGlobalPixel(point, zoom, marker_pixel);
if (status != Status::OK) {
return status;
}
const double world = worldPixels(zoom);
const double half_width = static_cast<double>(viewport.width) * 0.5;
const double center_x = viewport.left + half_width;
MarkerProjection result;
result.screen_x = half_width + shortestWrappedDelta(marker_pixel.x - center_x, world);
result.screen_y = marker_pixel.y - viewport.top;
result.visible = (result.screen_x >= 0.0) &&
(result.screen_x < static_cast<double>(viewport.width)) &&
(result.screen_y >= 0.0) &&
(result.screen_y < static_cast<double>(viewport.height));
output = result;
return Status::OK;
}
Status panGlobalPixel(const GlobalPixel& input,
double delta_x,
double delta_y,
std::uint32_t zoom,
GlobalPixel& output) {
if (!isValidZoom(zoom)) {
return Status::INVALID_ZOOM;
}
if (!finite(input.x) || !finite(input.y) || !finite(delta_x) || !finite(delta_y)) {
return Status::INVALID_ARGUMENT;
}
const double world = worldPixels(zoom);
const double next_x = input.x + delta_x;
const double next_y = input.y + delta_y;
if (!finite(next_x) || !finite(next_y)) {
return Status::INVALID_ARGUMENT;
}
GlobalPixel result;
result.x = wrap(next_x, world);
result.y = next_y;
if (result.y < 0.0) {
result.y = 0.0;
} else if (result.y > world) {
result.y = world;
}
output = result;
return Status::OK;
}
} // namespace MapProjection
} // namespace Pyxis
+104
View File
@@ -0,0 +1,104 @@
#ifndef PYXIS_UI_LXMF_MAP_PROJECTION_H
#define PYXIS_UI_LXMF_MAP_PROJECTION_H
#include <cstddef>
#include <cstdint>
namespace Pyxis {
namespace MapProjection {
// Zoom 22 is the highest level normally published by OSM tile providers. With
// 256 px tiles its 1,073,741,824 px world remains exactly representable and all
// integer tile indices remain safely inside uint32_t without shifting.
enum {
TILE_SIZE = 256,
MAX_ZOOM = 22,
MAX_VIEWPORT_TILES = 64
};
extern const double WEB_MERCATOR_MAX_LATITUDE;
enum class Status {
OK,
INVALID_ARGUMENT,
INVALID_ZOOM,
CAPACITY_EXCEEDED,
VIEWPORT_TOO_LARGE
};
struct GeoPoint {
double latitude;
double longitude;
};
struct GlobalPixel {
double x;
double y;
};
struct TileIndex {
std::uint32_t x;
std::uint32_t y;
std::uint32_t zoom;
};
// left/top are global-pixel coordinates. X may be outside the canonical world
// to support seamless antimeridian panning. Right and bottom edges are
// exclusive; width and height must be non-zero.
struct Viewport {
double left;
double top;
std::uint32_t width;
std::uint32_t height;
};
struct TilePlacement {
TileIndex tile;
double screen_x;
double screen_y;
};
struct MarkerProjection {
double screen_x;
double screen_y;
bool visible;
};
double clampLatitude(double latitude);
double normalizeLongitude(double longitude);
bool isValidZoom(std::uint32_t zoom);
std::uint32_t tileCount(std::uint32_t zoom);
Status latLonToGlobalPixel(const GeoPoint& point, std::uint32_t zoom, GlobalPixel& output);
Status globalPixelToLatLon(const GlobalPixel& pixel, std::uint32_t zoom, GeoPoint& output);
Status globalPixelToTile(const GlobalPixel& pixel, std::uint32_t zoom, TileIndex& output);
// Computes unique wrapped/clamped tile indices into caller-owned storage. An
// optional border adds one raw tile on every side. At most
// MAX_VIEWPORT_TILES raw candidates are accepted, bounding stack use and run
// time. On failure count is zero and output storage is unchanged.
Status viewportTiles(const Viewport& viewport,
std::uint32_t zoom,
bool include_border,
TilePlacement* output,
std::size_t capacity,
std::size_t& count);
// Uses the shortest wrapped horizontal delta from viewport center. Visibility
// follows half-open screen bounds [0,width) x [0,height).
Status projectMarker(const GeoPoint& point,
const Viewport& viewport,
std::uint32_t zoom,
MarkerProjection& output);
// Applies screen-pixel pan deltas, wrapping X and clamping Y to the world.
Status panGlobalPixel(const GlobalPixel& input,
double delta_x,
double delta_y,
std::uint32_t zoom,
GlobalPixel& output);
} // namespace MapProjection
} // namespace Pyxis
#endif
+279
View File
@@ -0,0 +1,279 @@
#include "UI/LXMF/MapProjection.h"
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <cstdlib>
#include <iostream>
#include <limits>
namespace {
using Pyxis::MapProjection::GeoPoint;
using Pyxis::MapProjection::GlobalPixel;
using Pyxis::MapProjection::MarkerProjection;
using Pyxis::MapProjection::Status;
using Pyxis::MapProjection::TileIndex;
using Pyxis::MapProjection::TilePlacement;
using Pyxis::MapProjection::Viewport;
std::size_t tests_run = 0U;
void fail(const char* expression, int line) {
std::cerr << "line " << line << ": check failed: " << expression << '\n';
std::exit(1);
}
#define CHECK(expression) do { if (!(expression)) { fail(#expression, __LINE__); } } while (false)
bool near(double actual, double expected, double tolerance = 1.0e-9) {
return std::fabs(actual - expected) <= tolerance;
}
void beginTest() { ++tests_run; }
bool hasTile(const TilePlacement* tiles, std::size_t count, std::uint32_t x, std::uint32_t y) {
for (std::size_t i = 0U; i < count; ++i) {
if ((tiles[i].tile.x == x) && (tiles[i].tile.y == y)) {
return true;
}
}
return false;
}
void testConstantsAndZoomValidation() {
beginTest();
CHECK(near(Pyxis::MapProjection::clampLatitude(90.0), Pyxis::MapProjection::WEB_MERCATOR_MAX_LATITUDE));
CHECK(near(Pyxis::MapProjection::clampLatitude(-90.0), -Pyxis::MapProjection::WEB_MERCATOR_MAX_LATITUDE));
CHECK(Pyxis::MapProjection::isValidZoom(0U));
CHECK(Pyxis::MapProjection::isValidZoom(Pyxis::MapProjection::MAX_ZOOM));
CHECK(!Pyxis::MapProjection::isValidZoom(Pyxis::MapProjection::MAX_ZOOM + 1U));
CHECK(Pyxis::MapProjection::tileCount(0U) == 1U);
CHECK(Pyxis::MapProjection::tileCount(Pyxis::MapProjection::MAX_ZOOM) == 4194304U);
CHECK(Pyxis::MapProjection::tileCount(Pyxis::MapProjection::MAX_ZOOM + 1U) == 0U);
}
void testLongitudeNormalization() {
beginTest();
CHECK(near(Pyxis::MapProjection::normalizeLongitude(180.0), -180.0));
CHECK(near(Pyxis::MapProjection::normalizeLongitude(540.0), -180.0));
CHECK(near(Pyxis::MapProjection::normalizeLongitude(-540.0), -180.0));
CHECK(near(Pyxis::MapProjection::normalizeLongitude(181.0), -179.0));
CHECK(near(Pyxis::MapProjection::normalizeLongitude(-181.0), 179.0));
}
void testEquatorPrimeMeridian() {
beginTest();
GlobalPixel pixel = {0.0, 0.0};
CHECK(Pyxis::MapProjection::latLonToGlobalPixel(GeoPoint{0.0, 0.0}, 0U, pixel) == Status::OK);
CHECK(near(pixel.x, 128.0));
CHECK(near(pixel.y, 128.0));
GeoPoint point = {1.0, 1.0};
CHECK(Pyxis::MapProjection::globalPixelToLatLon(pixel, 0U, point) == Status::OK);
CHECK(near(point.latitude, 0.0));
CHECK(near(point.longitude, 0.0));
}
void testWorldCornersPolesAndAntimeridian() {
beginTest();
GlobalPixel northwest = {0.0, 0.0};
GlobalPixel southeast = {0.0, 0.0};
CHECK(Pyxis::MapProjection::latLonToGlobalPixel(GeoPoint{90.0, -180.0}, 3U, northwest) == Status::OK);
CHECK(near(northwest.x, 0.0));
CHECK(near(northwest.y, 0.0, 1.0e-7));
CHECK(Pyxis::MapProjection::latLonToGlobalPixel(GeoPoint{-90.0, 179.999999}, 3U, southeast) == Status::OK);
CHECK(southeast.x < 2048.0);
CHECK(near(southeast.y, 2048.0, 1.0e-7));
GlobalPixel east = {0.0, 0.0};
GlobalPixel west = {0.0, 0.0};
CHECK(Pyxis::MapProjection::latLonToGlobalPixel(GeoPoint{0.0, 180.0}, 3U, east) == Status::OK);
CHECK(Pyxis::MapProjection::latLonToGlobalPixel(GeoPoint{0.0, -180.0}, 3U, west) == Status::OK);
CHECK(near(east.x, west.x));
}
void testTileIndexWrappingAndClamping() {
beginTest();
TileIndex tile = {99U, 99U, 99U};
CHECK(Pyxis::MapProjection::globalPixelToTile(GlobalPixel{-1.0, -500.0}, 2U, tile) == Status::OK);
CHECK(tile.x == 3U);
CHECK(tile.y == 0U);
CHECK(tile.zoom == 2U);
CHECK(Pyxis::MapProjection::globalPixelToTile(GlobalPixel{1024.0, 5000.0}, 2U, tile) == Status::OK);
CHECK(tile.x == 0U);
CHECK(tile.y == 3U);
}
void testRoundTripGridAtZoomBounds() {
beginTest();
const double latitudes[] = {-85.05112878, -80.0, -45.25, 0.0, 44.75, 80.0, 85.05112878};
const double longitudes[] = {-180.0, -179.9, -90.0, 0.0, 89.5, 179.9};
const std::uint32_t zooms[] = {0U, 1U, 8U, Pyxis::MapProjection::MAX_ZOOM};
for (std::size_t z = 0U; z < (sizeof(zooms) / sizeof(zooms[0])); ++z) {
for (std::size_t i = 0U; i < (sizeof(latitudes) / sizeof(latitudes[0])); ++i) {
for (std::size_t j = 0U; j < (sizeof(longitudes) / sizeof(longitudes[0])); ++j) {
const GeoPoint input = {latitudes[i], longitudes[j]};
GlobalPixel pixel = {0.0, 0.0};
GeoPoint output = {0.0, 0.0};
CHECK(Pyxis::MapProjection::latLonToGlobalPixel(input, zooms[z], pixel) == Status::OK);
CHECK(Pyxis::MapProjection::globalPixelToLatLon(pixel, zooms[z], output) == Status::OK);
CHECK(near(output.latitude, input.latitude, 2.0e-8));
CHECK(near(output.longitude, input.longitude, 2.0e-8));
}
}
}
}
void testExactViewportEdgesAreExclusive() {
beginTest();
TilePlacement tiles[Pyxis::MapProjection::MAX_VIEWPORT_TILES] = {};
std::size_t count = 99U;
CHECK(Pyxis::MapProjection::viewportTiles(Viewport{256.0, 256.0, 256U, 256U}, 3U, false, tiles,
Pyxis::MapProjection::MAX_VIEWPORT_TILES, count) == Status::OK);
CHECK(count == 1U);
CHECK(tiles[0].tile.x == 1U);
CHECK(tiles[0].tile.y == 1U);
CHECK(near(tiles[0].screen_x, 0.0));
CHECK(near(tiles[0].screen_y, 0.0));
}
void testViewportTwoToThreeTileTransition() {
beginTest();
TilePlacement tiles[Pyxis::MapProjection::MAX_VIEWPORT_TILES] = {};
std::size_t count = 0U;
CHECK(Pyxis::MapProjection::viewportTiles(Viewport{0.0, 480.0, 320U, 100U}, 4U, false, tiles,
Pyxis::MapProjection::MAX_VIEWPORT_TILES, count) == Status::OK);
CHECK(count == 4U); // two columns by two rows
CHECK(Pyxis::MapProjection::viewportTiles(Viewport{200.0, 480.0, 320U, 100U}, 4U, false, tiles,
Pyxis::MapProjection::MAX_VIEWPORT_TILES, count) == Status::OK);
CHECK(count == 6U); // three columns by two rows: old fixed 2x2 code lost these
CHECK(hasTile(tiles, count, 0U, 1U));
CHECK(hasTile(tiles, count, 1U, 1U));
CHECK(hasTile(tiles, count, 2U, 1U));
}
void testViewportPrefetchBorderAndAntimeridian() {
beginTest();
TilePlacement tiles[Pyxis::MapProjection::MAX_VIEWPORT_TILES] = {};
std::size_t count = 0U;
CHECK(Pyxis::MapProjection::viewportTiles(Viewport{1000.0, 256.0, 64U, 64U}, 2U, true, tiles,
Pyxis::MapProjection::MAX_VIEWPORT_TILES, count) == Status::OK);
CHECK(count == 12U); // raw x 2..5 wraps across the antimeridian; y spans 0..2
CHECK(hasTile(tiles, count, 0U, 0U));
CHECK(hasTile(tiles, count, 3U, 2U));
}
void testLowZoomCoverageDeduplicatesWrappedAndClampedTiles() {
beginTest();
TilePlacement tiles[Pyxis::MapProjection::MAX_VIEWPORT_TILES] = {};
std::size_t count = 0U;
CHECK(Pyxis::MapProjection::viewportTiles(Viewport{-100.0, -100.0, 320U, 240U}, 0U, true, tiles,
Pyxis::MapProjection::MAX_VIEWPORT_TILES, count) == Status::OK);
CHECK(count == 1U);
CHECK(tiles[0].tile.x == 0U);
CHECK(tiles[0].tile.y == 0U);
}
void testCapacityAndCoverageBoundAreTransactional() {
beginTest();
TilePlacement tiles[2] = {};
tiles[0].tile.x = 77U;
std::size_t count = 55U;
CHECK(Pyxis::MapProjection::viewportTiles(Viewport{200.0, 300.0, 320U, 100U}, 4U, false, tiles, 2U, count)
== Status::CAPACITY_EXCEEDED);
CHECK(count == 0U);
CHECK(tiles[0].tile.x == 77U);
CHECK(Pyxis::MapProjection::viewportTiles(Viewport{0.0, 0.0, 4096U, 4096U}, 10U, true, tiles, 2U, count)
== Status::VIEWPORT_TOO_LARGE);
}
void testMarkerUsesShortestWrappedDeltaAndClipsExclusiveEdges() {
beginTest();
MarkerProjection marker = {0.0, 0.0, false};
CHECK(Pyxis::MapProjection::projectMarker(GeoPoint{0.0, -179.0}, Viewport{1000.0, 384.0, 100U, 256U}, 2U,
marker) == Status::OK);
CHECK(marker.visible);
CHECK(marker.screen_x > 20.0);
CHECK(marker.screen_x < 30.0);
CHECK(near(marker.screen_y, 128.0));
CHECK(Pyxis::MapProjection::projectMarker(GeoPoint{0.0, 0.0}, Viewport{0.0, 128.0, 256U, 256U}, 1U,
marker) == Status::OK);
CHECK(!marker.visible); // x is exactly the exclusive right edge
}
void testPanDeltasWrapXClampY() {
beginTest();
GlobalPixel output = {0.0, 0.0};
CHECK(Pyxis::MapProjection::panGlobalPixel(GlobalPixel{10.0, 10.0}, -20.0, -20.0, 2U, output) == Status::OK);
CHECK(near(output.x, 1014.0));
CHECK(near(output.y, 0.0));
CHECK(Pyxis::MapProjection::panGlobalPixel(output, 20.0, 5000.0, 2U, output) == Status::OK);
CHECK(near(output.x, 10.0));
CHECK(near(output.y, 1024.0));
}
void testMalformedArgumentsFailClosed() {
beginTest();
const double nan = std::numeric_limits<double>::quiet_NaN();
GlobalPixel pixel = {7.0, 8.0};
GeoPoint point = {7.0, 8.0};
TileIndex tile = {7U, 8U, 9U};
MarkerProjection marker = {7.0, 8.0, true};
std::size_t count = 9U;
CHECK(Pyxis::MapProjection::latLonToGlobalPixel(GeoPoint{nan, 0.0}, 1U, pixel) == Status::INVALID_ARGUMENT);
CHECK(near(pixel.x, 7.0));
CHECK(Pyxis::MapProjection::latLonToGlobalPixel(GeoPoint{0.0, 0.0}, 23U, pixel) == Status::INVALID_ZOOM);
CHECK(Pyxis::MapProjection::globalPixelToLatLon(GlobalPixel{nan, 0.0}, 1U, point) == Status::INVALID_ARGUMENT);
CHECK(Pyxis::MapProjection::globalPixelToTile(GlobalPixel{0.0, nan}, 1U, tile) == Status::INVALID_ARGUMENT);
CHECK(Pyxis::MapProjection::viewportTiles(Viewport{0.0, 0.0, 0U, 1U}, 1U, false, NULL, 0U, count)
== Status::INVALID_ARGUMENT);
CHECK(count == 0U);
CHECK(Pyxis::MapProjection::projectMarker(GeoPoint{0.0, 0.0}, Viewport{nan, 0.0, 1U, 1U}, 1U, marker)
== Status::INVALID_ARGUMENT);
CHECK(marker.visible);
}
void testDeterministicPropertiesAndStress() {
beginTest();
std::uint32_t state = 0x12345678U;
for (std::size_t i = 0U; i < 100000U; ++i) {
state = (state * 1664525U) + 1013904223U;
const double latitude = (static_cast<double>(state) / 4294967295.0 * 220.0) - 110.0;
state = (state * 1664525U) + 1013904223U;
const double longitude = (static_cast<double>(state) / 4294967295.0 * 2160.0) - 1080.0;
const std::uint32_t zoom = state % (Pyxis::MapProjection::MAX_ZOOM + 1U);
GlobalPixel pixel = {0.0, 0.0};
GeoPoint round_trip = {0.0, 0.0};
CHECK(Pyxis::MapProjection::latLonToGlobalPixel(GeoPoint{latitude, longitude}, zoom, pixel) == Status::OK);
CHECK(Pyxis::MapProjection::globalPixelToLatLon(pixel, zoom, round_trip) == Status::OK);
CHECK(round_trip.latitude <= Pyxis::MapProjection::WEB_MERCATOR_MAX_LATITUDE);
CHECK(round_trip.latitude >= -Pyxis::MapProjection::WEB_MERCATOR_MAX_LATITUDE);
CHECK(round_trip.longitude >= -180.0);
CHECK(round_trip.longitude < 180.0);
TileIndex tile = {0U, 0U, 0U};
CHECK(Pyxis::MapProjection::globalPixelToTile(pixel, zoom, tile) == Status::OK);
CHECK(tile.x < Pyxis::MapProjection::tileCount(zoom));
CHECK(tile.y < Pyxis::MapProjection::tileCount(zoom));
}
}
} // namespace
int main() {
testConstantsAndZoomValidation();
testLongitudeNormalization();
testEquatorPrimeMeridian();
testWorldCornersPolesAndAntimeridian();
testTileIndexWrappingAndClamping();
testRoundTripGridAtZoomBounds();
testExactViewportEdgesAreExclusive();
testViewportTwoToThreeTileTransition();
testViewportPrefetchBorderAndAntimeridian();
testLowZoomCoverageDeduplicatesWrappedAndClampedTiles();
testCapacityAndCoverageBoundAreTransactional();
testMarkerUsesShortestWrappedDeltaAndClipsExclusiveEdges();
testPanDeltasWrapXClampY();
testMalformedArgumentsFailClosed();
testDeterministicPropertiesAndStress();
std::cout << "map projection: " << tests_run << " tests passed\n";
return 0;
}
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
from pathlib import Path
import os
import subprocess
import pytest
from native_test import find_cxx
ROOT = Path(__file__).resolve().parents[2]
TEST_SOURCE = ROOT / "tests/native/test_map_projection.cpp"
PRODUCTION_SOURCE = ROOT / "lib/tdeck_ui/UI/LXMF/MapProjection.cpp"
@pytest.mark.parametrize("sanitize", [False, True], ids=["strict-cxx11", "asan-ubsan"])
def test_map_projection_core(tmp_path: Path, sanitize: bool) -> None:
binary = tmp_path / "test_map_projection"
command = [
find_cxx(),
"-std=c++11",
"-Wall",
"-Wextra",
"-Werror",
"-pedantic",
"-Wconversion",
"-Wsign-conversion",
f"-I{ROOT / 'lib/tdeck_ui'}",
str(TEST_SOURCE),
str(PRODUCTION_SOURCE),
"-o",
str(binary),
]
if sanitize:
command[1:1] = ["-fsanitize=address,undefined", "-fno-omit-frame-pointer"]
compiled = subprocess.run(command, capture_output=True, text=True, timeout=60)
assert compiled.returncode == 0, compiled.stdout + compiled.stderr
environment = os.environ.copy()
if sanitize:
environment["ASAN_OPTIONS"] = "detect_leaks=1:halt_on_error=1"
environment["UBSAN_OPTIONS"] = "halt_on_error=1:print_stacktrace=1"
ran = subprocess.run([str(binary)], capture_output=True, text=True, timeout=60, env=environment)
assert ran.returncode == 0, ran.stdout + ran.stderr
assert ran.stdout == "map projection: 15 tests passed\n"