From bb2e4d949907241d458d75abe73a1544c6253eef Mon Sep 17 00:00:00 2001 From: "torlando-agent[bot]" <281092095+torlando-agent[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 20:55:42 +0000 Subject: [PATCH] feat: add tested map projection core --- lib/tdeck_ui/UI/LXMF/MapProjection.cpp | 335 +++++++++++++++++++++++++ lib/tdeck_ui/UI/LXMF/MapProjection.h | 104 ++++++++ tests/native/test_map_projection.cpp | 279 ++++++++++++++++++++ tests/native/test_map_projection.py | 46 ++++ 4 files changed, 764 insertions(+) create mode 100644 lib/tdeck_ui/UI/LXMF/MapProjection.cpp create mode 100644 lib/tdeck_ui/UI/LXMF/MapProjection.h create mode 100644 tests/native/test_map_projection.cpp create mode 100644 tests/native/test_map_projection.py diff --git a/lib/tdeck_ui/UI/LXMF/MapProjection.cpp b/lib/tdeck_ui/UI/LXMF/MapProjection.cpp new file mode 100644 index 00000000..247a0027 --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/MapProjection.cpp @@ -0,0 +1,335 @@ +#include "MapProjection.h" + +#include +#include +#include + +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(tileCount(zoom)) * static_cast(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(tiles); + std::int64_t result = raw % period; + if (result < 0) { + result += period; + } + return static_cast(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(tiles) - 1; + if (raw > last) { + return tiles - 1U; + } + return static_cast(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(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::floor(wrapped_x / static_cast(TILE_SIZE))); + result.y = static_cast(std::floor(clamped_y / static_cast(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(viewport.width); + const double bottom = viewport.top + static_cast(viewport.height); + const double tile_size = static_cast(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(first_x_value); + const std::int64_t last_x = static_cast(last_x_value); + const std::int64_t first_y = static_cast(first_y_value); + const std::int64_t last_y = static_cast(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(MAX_VIEWPORT_TILES)) || + (rows > static_cast(MAX_VIEWPORT_TILES)) || + (columns * rows > static_cast(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(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(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(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(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(viewport.width)) && + (result.screen_y >= 0.0) && + (result.screen_y < static_cast(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 diff --git a/lib/tdeck_ui/UI/LXMF/MapProjection.h b/lib/tdeck_ui/UI/LXMF/MapProjection.h new file mode 100644 index 00000000..53cb7da1 --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/MapProjection.h @@ -0,0 +1,104 @@ +#ifndef PYXIS_UI_LXMF_MAP_PROJECTION_H +#define PYXIS_UI_LXMF_MAP_PROJECTION_H + +#include +#include + +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 diff --git a/tests/native/test_map_projection.cpp b/tests/native/test_map_projection.cpp new file mode 100644 index 00000000..71dcc95e --- /dev/null +++ b/tests/native/test_map_projection.cpp @@ -0,0 +1,279 @@ +#include "UI/LXMF/MapProjection.h" + +#include +#include +#include +#include +#include +#include + +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::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(state) / 4294967295.0 * 220.0) - 110.0; + state = (state * 1664525U) + 1013904223U; + const double longitude = (static_cast(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; +} diff --git a/tests/native/test_map_projection.py b/tests/native/test_map_projection.py new file mode 100644 index 00000000..d7c1bcf9 --- /dev/null +++ b/tests/native/test_map_projection.py @@ -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"