mirror of
https://github.com/torlando-tech/pyxis.git
synced 2026-08-22 02:29:51 +00:00
feat: add tested map projection core
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user