From 54a55c9a7bc6bc62ca76b65569e91f89bf0550a3 Mon Sep 17 00:00:00 2001 From: Paul Meems Date: Sat, 31 Oct 2026 09:25:11 +0100 Subject: [PATCH] Add Core viewport with coordinate transformations and extent fitting Introduce mwg::Viewport: a pixel size plus a center/resolution view that converts between map and screen coordinates, fits extents with padding while preserving aspect ratio, and clamps to documented limits. Add ToPixel for the single rounding step, viewport tests, and architecture docs. Co-authored-by: Junie --- docs/ARCHITECTURE.md | 36 ++ src/MapWinGIS.Core/MapWinGIS.Core.vcxproj | 2 + .../include/mapwingis/map/Viewport.h | 188 ++++++ src/MapWinGIS.Core/src/map/Viewport.cpp | 220 +++++++ .../MapWinGIS.Core.Tests.vcxproj | 1 + tests/MapWinGIS.Core.Tests/ViewportTests.cpp | 539 ++++++++++++++++++ 6 files changed, 986 insertions(+) create mode 100644 src/MapWinGIS.Core/include/mapwingis/map/Viewport.h create mode 100644 src/MapWinGIS.Core/src/map/Viewport.cpp create mode 100644 tests/MapWinGIS.Core.Tests/ViewportTests.cpp diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 75c2e16..11237e5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -138,6 +138,42 @@ state; hosts and renderers read from it. The map does not reproject; layers in different coordinate reference systems do not align until coordinate transformations are implemented. +## Viewport + +`mwg::Viewport` (`mapwingis/map/Viewport.h`) is the view of map coordinates on +a pixel area. It converts between map and screen coordinates and fits an +extent, typically `Map::GetExtent()`, into the display area. It does not +reference a `Map` and holds no windowing, COM, or rendering state. It is a +small copyable value type, like `Extent`, so renderers and navigation code can +take snapshots. + +- **Coordinates.** Map X increases right and map Y increases up. Screen X + increases right and screen Y increases down, in pixels from the top-left + corner. Screen coordinates are continuous `double` values: pixel `(i, j)` + covers `[i, i + 1) x [j, j + 1)`. `ToPixel` rounds down to the containing + pixel; round only there. +- **State.** A size in pixels plus, once set, a view: the map point at the + viewport center and one resolution (map units per pixel) for both axes. The + visible extent is derived from these, so it always has the viewport's + aspect ratio and geometry is never distorted. Resizing keeps the center and + resolution. +- **Validity.** A viewport is valid when both dimensions are positive and it + has a view. On an invalid viewport, transformations return `std::nullopt` + and `VisibleExtent` returns an empty `Extent`. A zero-sized viewport keeps + its view. Non-finite inputs or results give `std::nullopt`; negative sizes, + non-finite views, and invalid padding throw `InvalidArgument`. +- **Fitting.** `FitExtent` centers the extent and picks the smallest + resolution that shows it on both axes, so the other axis expands. Padding + is kept free on every side. Extent edges map to the centers of the + outermost pixels inside the padding, so pixel rounding never pushes + boundary geometry outside the viewport. A zero-span axis does not constrain + the resolution; a single point keeps the current resolution. Invalid + extents and zero-sized viewports return `false` without changing the view. +- **Limits.** Sizes are limited to `kMaxDimension` pixels. Centers are clamped + to `±kMaxCoordinate` and resolutions to `[MinResolution(center), + kMaxResolution]`, where the minimum is relative to the center's magnitude so + that pixels stay many representable doubles wide. + ## ActiveX and rendering architecture The first ActiveX and rendering vertical slice is implemented across the diff --git a/src/MapWinGIS.Core/MapWinGIS.Core.vcxproj b/src/MapWinGIS.Core/MapWinGIS.Core.vcxproj index 2f859bd..6ff648d 100644 --- a/src/MapWinGIS.Core/MapWinGIS.Core.vcxproj +++ b/src/MapWinGIS.Core/MapWinGIS.Core.vcxproj @@ -61,6 +61,7 @@ + @@ -79,6 +80,7 @@ + diff --git a/src/MapWinGIS.Core/include/mapwingis/map/Viewport.h b/src/MapWinGIS.Core/include/mapwingis/map/Viewport.h new file mode 100644 index 0000000..b6bbc42 --- /dev/null +++ b/src/MapWinGIS.Core/include/mapwingis/map/Viewport.h @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: MPL-2.0 + +/** + * @file Viewport.h + * @brief Declares the viewport: the view of map coordinates on a pixel area. + * + * The viewport converts between map coordinates and screen coordinates and + * fits a map extent into the available display area. It holds no windowing, + * COM, or rendering-backend state, and it does not reference a Map: callers + * fit the viewport to Map::GetExtent and keep the two side by side. + * + * Coordinate conventions: + * - Map coordinates: X increases to the right, Y increases upwards, in the + * units of the layers' coordinate system. + * - Screen coordinates: X increases to the right, Y increases downwards, in + * pixels, with the origin at the top-left corner of the viewport. Screen + * coordinates are continuous: pixel (i, j) covers [i, i + 1) x [j, j + 1), + * so its center is at (i + 0.5, j + 0.5) and the viewport covers + * [0, Width()] x [0, Height()]. + */ +#pragma once + +#include + +#include "mapwingis/core/Api.h" +#include "mapwingis/core/Extent.h" + +namespace mwg +{ + /// A position in map coordinates (X right, Y up). + struct MapPoint + { + double x = 0.0; + double y = 0.0; + }; + + /// A position in continuous screen coordinates (X right, Y down, pixels). + struct ScreenPoint + { + double x = 0.0; + double y = 0.0; + }; + + /// The integer pixel that contains a screen position. + struct PixelPoint + { + int x = 0; + int y = 0; + }; + + /// Pixel that contains a screen position: each coordinate is rounded + /// down, so pixel (i, j) holds every position in [i, i + 1) x [j, j + 1). + /// Rounding happens only here; keep screen coordinates in double until + /// pixels are needed. Returns std::nullopt when a coordinate is not + /// finite or the pixel is outside the int range. + [[nodiscard]] MWG_CORE_API std::optional ToPixel(const ScreenPoint& point) noexcept; + + /// The view of map coordinates on a rectangular pixel area. + /// + /// State: a size in pixels and, once set, a view given by the map + /// coordinate shown at the viewport center and the resolution in map + /// units per pixel. The same resolution applies to both axes, so the view + /// never distorts geometry. The visible extent follows from these values + /// and always has the viewport's aspect ratio. + /// + /// Validity: a default-constructed viewport has size 0 x 0 and no view. + /// A viewport is valid when both dimensions are positive and it has a + /// view. Transformations and VisibleExtent need a valid viewport; on an + /// invalid one they return std::nullopt or an empty Extent. A zero-sized + /// viewport (for example, a minimized window) keeps its view, so the view + /// returns when the viewport gets a size again. + /// + /// Limits: the center is clamped to +/-kMaxCoordinate on each axis and + /// the resolution to [MinResolution(center), kMaxResolution]. The lower + /// resolution bound keeps every pixel many representable doubles wide, + /// so screen-to-map conversions stay stable at any supported location. + /// + /// Viewport is a small copyable value type, like Extent, so render + /// scenes and navigation code can take snapshots of it. + /// Not thread-safe. + class MWG_CORE_API Viewport + { + public: + /// Largest supported width or height, in pixels. + static constexpr int kMaxDimension = 1 << 16; + + /// Largest supported absolute center coordinate, in map units. + static constexpr double kMaxCoordinate = 1.0e12; + + /// Largest supported resolution, in map units per pixel. + static constexpr double kMaxResolution = 1.0e9; + + /// Smallest supported resolution relative to the center's magnitude; + /// see MinResolution. + static constexpr double kMinRelativeResolution = 1.0e-10; + + /// Resolution used to fit a single point when there is no view yet. + static constexpr double kDefaultPointResolution = 1.0; + + /// Smallest supported resolution for a center: kMinRelativeResolution + /// times the larger of 1 and the center's largest absolute coordinate. + [[nodiscard]] static double MinResolution(const MapPoint& center) noexcept; + + /// Size 0 x 0 and no view. + Viewport() noexcept = default; + + /// A viewport of this size without a view. + /// \throws InvalidArgument when a dimension is negative or larger than + /// kMaxDimension. + Viewport(int width, int height); + + [[nodiscard]] int Width() const noexcept { return _width; } + [[nodiscard]] int Height() const noexcept { return _height; } + + /// True when the view has been set by SetView or FitExtent. + [[nodiscard]] bool HasView() const noexcept { return _hasView; } + + /// True when both dimensions are positive and the viewport has a view. + [[nodiscard]] bool IsValid() const noexcept + { + return _hasView && _width > 0 && _height > 0; + } + + /// Map coordinate at the viewport center, or std::nullopt without a view. + [[nodiscard]] std::optional Center() const noexcept; + + /// Map units per pixel, or std::nullopt without a view. + [[nodiscard]] std::optional Resolution() const noexcept; + + /// Changes the size and keeps the center and resolution, so the map + /// stays centered and keeps its scale when the display area resizes. + /// \throws InvalidArgument when a dimension is negative or larger than + /// kMaxDimension. + void SetSize(int width, int height); + + /// Sets the view. The center and resolution are clamped to the limits + /// described above. + /// \throws InvalidArgument when a center coordinate is not finite or + /// the resolution is not a finite positive number. + void SetView(const MapPoint& center, double resolution); + + /// Removes the view; the size is kept. + void ClearView() noexcept; + + /// Fits extent into the viewport, keeping paddingPixels free on every + /// side, and returns true. The extent is centered, and the resolution + /// is the smallest that shows the whole extent on both axes, so the + /// visible extent grows on the other axis to keep the aspect ratio. + /// + /// The extent edges map to the centers of the outermost pixels inside + /// the padding, so rounding to pixels never puts boundary geometry + /// outside the viewport. When the padding leaves less than half a + /// pixel between those centers, the extent spans half a pixel around + /// the viewport center. + /// + /// Degenerate extents: an axis with zero span does not constrain the + /// resolution. A single point is centered and keeps the current + /// resolution, or kDefaultPointResolution without a view. Results are + /// clamped to the limits; an extent larger than the limits allow is + /// then not fully visible. + /// + /// Returns false, and changes nothing, when the extent is not valid + /// (see Extent::IsValid) or a dimension is zero. + /// \throws InvalidArgument when paddingPixels is negative or not finite. + bool FitExtent(const Extent& extent, double paddingPixels = 0.0); + + /// Map extent covered by the whole viewport, or an empty Extent when + /// the viewport is not valid. + [[nodiscard]] Extent VisibleExtent() const noexcept; + + /// Screen position of a map point. Returns std::nullopt when the + /// viewport is not valid, a coordinate is not finite, or the result + /// is not finite. Points outside the visible extent give positions + /// outside the viewport. + [[nodiscard]] std::optional MapToScreen(const MapPoint& point) const noexcept; + + /// Map position of a screen point; the inverse of MapToScreen. + /// Returns std::nullopt under the same conditions. + [[nodiscard]] std::optional ScreenToMap(const ScreenPoint& point) const noexcept; + + private: + int _width = 0; + int _height = 0; + bool _hasView = false; + MapPoint _center{}; + double _resolution = 0.0; + }; +} diff --git a/src/MapWinGIS.Core/src/map/Viewport.cpp b/src/MapWinGIS.Core/src/map/Viewport.cpp new file mode 100644 index 0000000..8d2fc07 --- /dev/null +++ b/src/MapWinGIS.Core/src/map/Viewport.cpp @@ -0,0 +1,220 @@ +// SPDX-License-Identifier: MPL-2.0 + +/** + * @file Viewport.cpp + * @brief Implements viewport transformations and extent fitting. + */ +#include "mapwingis/map/Viewport.h" + +#include +#include +#include +#include + +#include "mapwingis/core/Error.h" + +namespace mwg +{ + namespace + { + void ValidateSize(const int width, const int height) + { + if (width < 0 || height < 0 || width > Viewport::kMaxDimension || + height > Viewport::kMaxDimension) + { + throw InvalidArgument( + "Viewport size out of range: " + std::to_string(width) + " x " + + std::to_string(height)); + } + } + + [[nodiscard]] MapPoint ClampCenter(const MapPoint& center) noexcept + { + return { + std::clamp(center.x, -Viewport::kMaxCoordinate, Viewport::kMaxCoordinate), + std::clamp(center.y, -Viewport::kMaxCoordinate, Viewport::kMaxCoordinate)}; + } + + [[nodiscard]] double ClampResolution(const double resolution, const MapPoint& center) noexcept + { + return std::clamp(resolution, Viewport::MinResolution(center), Viewport::kMaxResolution); + } + + // Pixels available for the extent on one axis. The extent edges map + // to the centers of the outermost pixels inside the padding, which + // removes one pixel from the span. At least half a pixel remains, so + // even a one-pixel axis keeps both edges inside the pixel. + [[nodiscard]] double AvailablePixels(const int dimension, const double padding) noexcept + { + return std::max(static_cast(dimension) - 1.0 - 2.0 * padding, 0.5); + } + + [[nodiscard]] bool IsFinite(const double x, const double y) noexcept + { + return std::isfinite(x) && std::isfinite(y); + } + + [[nodiscard]] bool IsInIntRange(const double value) noexcept + { + return value >= static_cast(std::numeric_limits::min()) && + value <= static_cast(std::numeric_limits::max()); + } + } + + std::optional ToPixel(const ScreenPoint& point) noexcept + { + if (!IsFinite(point.x, point.y)) + { + return std::nullopt; + } + + const double x = std::floor(point.x); + const double y = std::floor(point.y); + if (!IsInIntRange(x) || !IsInIntRange(y)) + { + return std::nullopt; + } + return PixelPoint{static_cast(x), static_cast(y)}; + } + + double Viewport::MinResolution(const MapPoint& center) noexcept + { + const double magnitude = std::max({1.0, std::abs(center.x), std::abs(center.y)}); + return kMinRelativeResolution * magnitude; + } + + Viewport::Viewport(const int width, const int height) + { + SetSize(width, height); + } + + std::optional Viewport::Center() const noexcept + { + if (!_hasView) + { + return std::nullopt; + } + return _center; + } + + std::optional Viewport::Resolution() const noexcept + { + if (!_hasView) + { + return std::nullopt; + } + return _resolution; + } + + void Viewport::SetSize(const int width, const int height) + { + ValidateSize(width, height); + _width = width; + _height = height; + } + + void Viewport::SetView(const MapPoint& center, const double resolution) + { + if (!IsFinite(center.x, center.y)) + { + throw InvalidArgument("Viewport center must be finite"); + } + if (!std::isfinite(resolution) || resolution <= 0.0) + { + throw InvalidArgument("Viewport resolution must be a finite positive number"); + } + + _center = ClampCenter(center); + _resolution = ClampResolution(resolution, _center); + _hasView = true; + } + + void Viewport::ClearView() noexcept + { + _hasView = false; + _center = {}; + _resolution = 0.0; + } + + bool Viewport::FitExtent(const Extent& extent, const double paddingPixels) + { + if (!std::isfinite(paddingPixels) || paddingPixels < 0.0) + { + throw InvalidArgument("Viewport padding must be a finite non-negative number"); + } + if (!extent.IsValid() || _width <= 0 || _height <= 0) + { + return false; + } + + // Halving before adding keeps the center finite for extents near the + // double range. + const MapPoint center = ClampCenter( + {extent.xMin / 2.0 + extent.xMax / 2.0, extent.yMin / 2.0 + extent.yMax / 2.0}); + + double resolution = std::max( + extent.Width() / AvailablePixels(_width, paddingPixels), + extent.Height() / AvailablePixels(_height, paddingPixels)); + if (!std::isfinite(resolution)) + { + resolution = kMaxResolution; + } + else if (resolution <= 0.0) + { + resolution = _hasView ? _resolution : kDefaultPointResolution; + } + + _center = center; + _resolution = ClampResolution(resolution, center); + _hasView = true; + return true; + } + + Extent Viewport::VisibleExtent() const noexcept + { + if (!IsValid()) + { + return {}; + } + + const double halfWidth = static_cast(_width) * _resolution / 2.0; + const double halfHeight = static_cast(_height) * _resolution / 2.0; + return { + _center.x - halfWidth, + _center.y - halfHeight, + _center.x + halfWidth, + _center.y + halfHeight}; + } + + std::optional Viewport::MapToScreen(const MapPoint& point) const noexcept + { + if (!IsValid() || !IsFinite(point.x, point.y)) + { + return std::nullopt; + } + + const double x = static_cast(_width) / 2.0 + (point.x - _center.x) / _resolution; + const double y = static_cast(_height) / 2.0 - (point.y - _center.y) / _resolution; + if (!IsFinite(x, y)) + { + return std::nullopt; + } + return ScreenPoint{x, y}; + } + + std::optional Viewport::ScreenToMap(const ScreenPoint& point) const noexcept + { + if (!IsValid() || !IsFinite(point.x, point.y)) + { + return std::nullopt; + } + + const double x = _center.x + (point.x - static_cast(_width) / 2.0) * _resolution; + const double y = _center.y - (point.y - static_cast(_height) / 2.0) * _resolution; + if (!IsFinite(x, y)) + { + return std::nullopt; + } + return MapPoint{x, y}; + } +} diff --git a/tests/MapWinGIS.Core.Tests/MapWinGIS.Core.Tests.vcxproj b/tests/MapWinGIS.Core.Tests/MapWinGIS.Core.Tests.vcxproj index 8135b50..5a561c6 100644 --- a/tests/MapWinGIS.Core.Tests/MapWinGIS.Core.Tests.vcxproj +++ b/tests/MapWinGIS.Core.Tests/MapWinGIS.Core.Tests.vcxproj @@ -65,6 +65,7 @@ + diff --git a/tests/MapWinGIS.Core.Tests/ViewportTests.cpp b/tests/MapWinGIS.Core.Tests/ViewportTests.cpp new file mode 100644 index 0000000..0eb24ef --- /dev/null +++ b/tests/MapWinGIS.Core.Tests/ViewportTests.cpp @@ -0,0 +1,539 @@ +// SPDX-License-Identifier: MPL-2.0 + +/** + * @file ViewportTests.cpp + * @brief Tests viewport coordinate transformations and extent fitting. + * + * Verifies map-to-screen and screen-to-map conversions, aspect-preserving + * extent fitting with padding, limits, and the handling of zero-sized + * viewports, degenerate extents, and non-finite inputs. + */ +#include + +#include +#include +#include +#include + +#include "mapwingis/core/Error.h" +#include "mapwingis/map/Viewport.h" + +using namespace mwg; + +namespace +{ + constexpr double kNaN = std::numeric_limits::quiet_NaN(); + constexpr double kInfinity = std::numeric_limits::infinity(); + constexpr double kTolerance = 1e-9; + + void ExpectScreen(const std::optional& actual, const double x, const double y) + { + ASSERT_TRUE(actual.has_value()); + EXPECT_NEAR(actual->x, x, kTolerance); + EXPECT_NEAR(actual->y, y, kTolerance); + } + + void ExpectMap(const std::optional& actual, const double x, const double y, + const double tolerance = kTolerance) + { + ASSERT_TRUE(actual.has_value()); + EXPECT_NEAR(actual->x, x, tolerance); + EXPECT_NEAR(actual->y, y, tolerance); + } + + void ExpectExtent(const Extent& actual, const Extent& expected) + { + EXPECT_NEAR(actual.xMin, expected.xMin, kTolerance); + EXPECT_NEAR(actual.yMin, expected.yMin, kTolerance); + EXPECT_NEAR(actual.xMax, expected.xMax, kTolerance); + EXPECT_NEAR(actual.yMax, expected.yMax, kTolerance); + } + + /// True when the extent lies within the visible extent. + bool IsVisible(const Viewport& viewport, const Extent& extent) + { + const Extent visible = viewport.VisibleExtent(); + return visible.xMin <= extent.xMin && visible.yMin <= extent.yMin && + visible.xMax >= extent.xMax && visible.yMax >= extent.yMax; + } + + /// Viewport of 101 x 51 pixels fitted to (0, 0)-(100, 50): one map unit + /// per pixel, with the extent edges on the outermost pixel centers. + Viewport MakeFittedViewport() + { + Viewport viewport(101, 51); + EXPECT_TRUE(viewport.FitExtent({0, 0, 100, 50})); + return viewport; + } +} + +TEST(Viewport, DefaultViewportHasNoSizeAndNoView) +{ + const Viewport viewport; + + EXPECT_EQ(viewport.Width(), 0); + EXPECT_EQ(viewport.Height(), 0); + EXPECT_FALSE(viewport.HasView()); + EXPECT_FALSE(viewport.IsValid()); + EXPECT_FALSE(viewport.Center().has_value()); + EXPECT_FALSE(viewport.Resolution().has_value()); + EXPECT_TRUE(viewport.VisibleExtent().IsEmpty()); + EXPECT_FALSE(viewport.MapToScreen({0, 0}).has_value()); + EXPECT_FALSE(viewport.ScreenToMap({0, 0}).has_value()); +} + +TEST(Viewport, SizedViewportWithoutViewIsNotValid) +{ + const Viewport viewport(640, 480); + + EXPECT_EQ(viewport.Width(), 640); + EXPECT_EQ(viewport.Height(), 480); + EXPECT_FALSE(viewport.IsValid()); + EXPECT_FALSE(viewport.MapToScreen({0, 0}).has_value()); +} + +TEST(Viewport, RejectsNegativeAndOversizedDimensions) +{ + EXPECT_THROW(Viewport(-1, 10), InvalidArgument); + EXPECT_THROW(Viewport(10, -1), InvalidArgument); + EXPECT_THROW(Viewport(Viewport::kMaxDimension + 1, 10), InvalidArgument); + + Viewport viewport(10, 10); + EXPECT_THROW(viewport.SetSize(10, Viewport::kMaxDimension + 1), InvalidArgument); + EXPECT_EQ(viewport.Width(), 10); + EXPECT_EQ(viewport.Height(), 10); + EXPECT_NO_THROW(viewport.SetSize(Viewport::kMaxDimension, Viewport::kMaxDimension)); +} + +TEST(Viewport, MapsExtentCornersToOutermostPixelCenters) +{ + const Viewport viewport = MakeFittedViewport(); + + ExpectScreen(viewport.MapToScreen({0, 50}), 0.5, 0.5); // top-left + ExpectScreen(viewport.MapToScreen({100, 50}), 100.5, 0.5); // top-right + ExpectScreen(viewport.MapToScreen({0, 0}), 0.5, 50.5); // bottom-left + ExpectScreen(viewport.MapToScreen({100, 0}), 100.5, 50.5); // bottom-right +} + +TEST(Viewport, MapsMapCenterToViewportCenter) +{ + const Viewport viewport = MakeFittedViewport(); + + ExpectMap(viewport.Center(), 50, 25); + ExpectScreen(viewport.MapToScreen({50, 25}), 50.5, 25.5); + ExpectMap(viewport.ScreenToMap({50.5, 25.5}), 50, 25); +} + +TEST(Viewport, ScreenYIncreasesDownwardsWhileMapYIncreasesUpwards) +{ + const Viewport viewport = MakeFittedViewport(); + + const std::optional lower = viewport.MapToScreen({50, 10}); + const std::optional higher = viewport.MapToScreen({50, 40}); + ASSERT_TRUE(lower.has_value()); + ASSERT_TRUE(higher.has_value()); + EXPECT_GT(lower->y, higher->y); + + const std::optional left = viewport.MapToScreen({10, 25}); + const std::optional right = viewport.MapToScreen({90, 25}); + ASSERT_TRUE(left.has_value()); + ASSERT_TRUE(right.has_value()); + EXPECT_LT(left->x, right->x); +} + +TEST(Viewport, MapsViewportCornersToVisibleExtentCorners) +{ + const Viewport viewport = MakeFittedViewport(); + + ExpectExtent(viewport.VisibleExtent(), {-0.5, -0.5, 100.5, 50.5}); + ExpectMap(viewport.ScreenToMap({0, 0}), -0.5, 50.5); + ExpectMap(viewport.ScreenToMap({101, 51}), 100.5, -0.5); +} + +TEST(Viewport, KeepsFractionalScreenCoordinates) +{ + const Viewport viewport = MakeFittedViewport(); + + ExpectScreen(viewport.MapToScreen({10.25, 20.75}), 10.75, 29.75); +} + +TEST(Viewport, RoundTripsMapToScreenToMap) +{ + const Extent extents[] = { + {0, 0, 100, 50}, + {-180, -90, 180, 90}, + {500'000, 6'000'000, 501'000, 6'000'010}, + {-20'037'508.34, -20'037'508.34, 20'037'508.34, 20'037'508.34}, + {0.001, 0.002, 0.003, 0.004}, + }; + const int sizes[][2] = {{1, 1}, {101, 51}, {800, 600}, {37, 1200}}; + + for (const Extent& extent : extents) + { + for (const auto& size : sizes) + { + Viewport viewport(size[0], size[1]); + ASSERT_TRUE(viewport.FitExtent(extent, 5.0)); + const double tolerance = 1e-9 * std::max(1.0, std::abs(extent.xMax) + std::abs(extent.yMax)); + + const MapPoint points[] = { + {extent.xMin, extent.yMin}, + {extent.xMax, extent.yMax}, + {extent.CenterX(), extent.CenterY()}, + {extent.xMin + extent.Width() / 3.0, extent.yMax - extent.Height() / 7.0}, + {extent.xMax + extent.Width(), extent.yMin - extent.Height()}, + }; + for (const MapPoint& point : points) + { + const std::optional screen = viewport.MapToScreen(point); + ASSERT_TRUE(screen.has_value()); + ExpectMap(viewport.ScreenToMap(*screen), point.x, point.y, tolerance); + } + } + } +} + +TEST(Viewport, RoundTripsScreenToMapToScreen) +{ + const Viewport viewport = MakeFittedViewport(); + + for (const ScreenPoint point : {ScreenPoint{0, 0}, ScreenPoint{17.3, 42.9}, ScreenPoint{-20, 300}}) + { + const std::optional map = viewport.ScreenToMap(point); + ASSERT_TRUE(map.has_value()); + ExpectScreen(viewport.MapToScreen(*map), point.x, point.y); + } +} + +TEST(Viewport, WideViewportExpandsVisibleExtentHorizontally) +{ + Viewport viewport(201, 51); + ASSERT_TRUE(viewport.FitExtent({0, 0, 100, 50})); + + EXPECT_DOUBLE_EQ(*viewport.Resolution(), 1.0); + ExpectExtent(viewport.VisibleExtent(), {-50.5, -0.5, 150.5, 50.5}); + ExpectScreen(viewport.MapToScreen({0, 0}), 50.5, 50.5); + ExpectScreen(viewport.MapToScreen({100, 50}), 150.5, 0.5); +} + +TEST(Viewport, TallViewportExpandsVisibleExtentVertically) +{ + Viewport viewport(101, 201); + ASSERT_TRUE(viewport.FitExtent({0, 0, 100, 50})); + + EXPECT_DOUBLE_EQ(*viewport.Resolution(), 1.0); + ExpectExtent(viewport.VisibleExtent(), {-0.5, -75.5, 100.5, 125.5}); + ExpectScreen(viewport.MapToScreen({0, 0}), 0.5, 125.5); + ExpectScreen(viewport.MapToScreen({100, 50}), 100.5, 75.5); +} + +TEST(Viewport, WideExtentIsLimitedByViewportWidth) +{ + Viewport viewport(101, 101); + ASSERT_TRUE(viewport.FitExtent({0, 0, 400, 10})); + + EXPECT_DOUBLE_EQ(*viewport.Resolution(), 4.0); + ExpectScreen(viewport.MapToScreen({0, 0}), 0.5, 51.75); + ExpectScreen(viewport.MapToScreen({400, 10}), 100.5, 49.25); +} + +TEST(Viewport, TallExtentIsLimitedByViewportHeight) +{ + Viewport viewport(101, 101); + ASSERT_TRUE(viewport.FitExtent({0, 0, 10, 400})); + + EXPECT_DOUBLE_EQ(*viewport.Resolution(), 4.0); + ExpectScreen(viewport.MapToScreen({0, 0}), 49.25, 100.5); + ExpectScreen(viewport.MapToScreen({10, 400}), 51.75, 0.5); +} + +TEST(Viewport, PreservesAspectRatioForAllFits) +{ + const Extent extents[] = {{0, 0, 100, 50}, {0, 0, 10, 400}, {0, 0, 400, 10}}; + const int sizes[][2] = {{300, 100}, {100, 300}, {256, 256}}; + + for (const Extent& extent : extents) + { + for (const auto& size : sizes) + { + Viewport viewport(size[0], size[1]); + ASSERT_TRUE(viewport.FitExtent(extent, 8.0)); + + // A map square stays a screen square. + const std::optional origin = viewport.MapToScreen({0, 0}); + const std::optional corner = viewport.MapToScreen({5, 5}); + ASSERT_TRUE(origin.has_value()); + ASSERT_TRUE(corner.has_value()); + EXPECT_NEAR(corner->x - origin->x, origin->y - corner->y, kTolerance); + + const Extent visible = viewport.VisibleExtent(); + EXPECT_NEAR(visible.Width() / visible.Height(), + static_cast(size[0]) / size[1], kTolerance); + EXPECT_TRUE(IsVisible(viewport, extent)); + } + } +} + +TEST(Viewport, KeepsPaddingFreeOnEverySide) +{ + Viewport viewport(121, 71); + ASSERT_TRUE(viewport.FitExtent({0, 0, 100, 50}, 10.0)); + + EXPECT_DOUBLE_EQ(*viewport.Resolution(), 1.0); + ExpectScreen(viewport.MapToScreen({0, 50}), 10.5, 10.5); + ExpectScreen(viewport.MapToScreen({100, 0}), 110.5, 60.5); +} + +TEST(Viewport, CentersExtentOnTheAxisWithSpareRoom) +{ + Viewport viewport(221, 71); + ASSERT_TRUE(viewport.FitExtent({0, 0, 100, 50}, 10.0)); + + // Height constrains: 50 units over 50 available pixels. The width has + // more than the padding to spare. + EXPECT_DOUBLE_EQ(*viewport.Resolution(), 1.0); + ExpectScreen(viewport.MapToScreen({0, 50}), 60.5, 10.5); + ExpectScreen(viewport.MapToScreen({100, 0}), 160.5, 60.5); +} + +TEST(Viewport, PaddingLargerThanViewportLeavesHalfAPixel) +{ + Viewport viewport(11, 11); + ASSERT_TRUE(viewport.FitExtent({0, 0, 10, 10}, 100.0)); + + EXPECT_DOUBLE_EQ(*viewport.Resolution(), 20.0); + ExpectMap(viewport.Center(), 5, 5); +} + +TEST(Viewport, RejectsNegativeAndNonFinitePadding) +{ + Viewport viewport(100, 100); + + EXPECT_THROW((void)viewport.FitExtent({0, 0, 1, 1}, -1.0), InvalidArgument); + EXPECT_THROW((void)viewport.FitExtent({0, 0, 1, 1}, kNaN), InvalidArgument); + EXPECT_THROW((void)viewport.FitExtent({0, 0, 1, 1}, kInfinity), InvalidArgument); + EXPECT_FALSE(viewport.HasView()); +} + +TEST(Viewport, FittedBoundaryGeometryRoundsToPixelsInsideViewport) +{ + const int sizes[][2] = {{1, 1}, {2, 2}, {7, 3}, {640, 480}, {1000, 17}}; + const Extent extent{-123.456, 7.89, 987.654, 321.0}; + + for (const auto& size : sizes) + { + Viewport viewport(size[0], size[1]); + ASSERT_TRUE(viewport.FitExtent(extent)); + + for (const MapPoint corner : {MapPoint{extent.xMin, extent.yMin}, MapPoint{extent.xMin, extent.yMax}, + MapPoint{extent.xMax, extent.yMin}, MapPoint{extent.xMax, extent.yMax}}) + { + const std::optional screen = viewport.MapToScreen(corner); + ASSERT_TRUE(screen.has_value()); + const std::optional pixel = ToPixel(*screen); + ASSERT_TRUE(pixel.has_value()); + EXPECT_GE(pixel->x, 0); + EXPECT_GE(pixel->y, 0); + EXPECT_LT(pixel->x, size[0]); + EXPECT_LT(pixel->y, size[1]); + } + } +} + +TEST(Viewport, ZeroSizedViewportCannotFitOrTransform) +{ + Viewport viewport(0, 100); + + EXPECT_FALSE(viewport.FitExtent({0, 0, 10, 10})); + EXPECT_FALSE(viewport.HasView()); + + viewport.SetSize(100, 0); + EXPECT_FALSE(viewport.FitExtent({0, 0, 10, 10})); + EXPECT_FALSE(viewport.HasView()); +} + +TEST(Viewport, ZeroSizedViewportKeepsItsView) +{ + Viewport viewport = MakeFittedViewport(); + + viewport.SetSize(0, 0); + EXPECT_TRUE(viewport.HasView()); + EXPECT_FALSE(viewport.IsValid()); + EXPECT_TRUE(viewport.VisibleExtent().IsEmpty()); + EXPECT_FALSE(viewport.MapToScreen({50, 25}).has_value()); + + viewport.SetSize(101, 51); + EXPECT_TRUE(viewport.IsValid()); + ExpectScreen(viewport.MapToScreen({0, 50}), 0.5, 0.5); +} + +TEST(Viewport, ResizeKeepsCenterAndResolution) +{ + Viewport viewport = MakeFittedViewport(); + + viewport.SetSize(301, 11); + + ExpectMap(viewport.Center(), 50, 25); + EXPECT_DOUBLE_EQ(*viewport.Resolution(), 1.0); + ExpectScreen(viewport.MapToScreen({50, 25}), 150.5, 5.5); +} + +TEST(Viewport, FitsHorizontalAndVerticalLines) +{ + Viewport viewport(101, 101); + + ASSERT_TRUE(viewport.FitExtent({0, 5, 100, 5})); + EXPECT_DOUBLE_EQ(*viewport.Resolution(), 1.0); + ExpectMap(viewport.Center(), 50, 5); + + ASSERT_TRUE(viewport.FitExtent({5, 0, 5, 200})); + EXPECT_DOUBLE_EQ(*viewport.Resolution(), 2.0); + ExpectMap(viewport.Center(), 5, 100); +} + +TEST(Viewport, FitsSinglePointWithDefaultOrCurrentResolution) +{ + Viewport viewport(100, 100); + + ASSERT_TRUE(viewport.FitExtent({3, 4, 3, 4})); + EXPECT_DOUBLE_EQ(*viewport.Resolution(), Viewport::kDefaultPointResolution); + ExpectMap(viewport.Center(), 3, 4); + + viewport.SetView({0, 0}, 0.25); + ASSERT_TRUE(viewport.FitExtent({-7, 8, -7, 8})); + EXPECT_DOUBLE_EQ(*viewport.Resolution(), 0.25); + ExpectMap(viewport.Center(), -7, 8); + ExpectScreen(viewport.MapToScreen({-7, 8}), 50, 50); +} + +TEST(Viewport, RejectsInvalidExtentsWithoutChangingTheView) +{ + Viewport viewport = MakeFittedViewport(); + + EXPECT_FALSE(viewport.FitExtent(Extent{})); + EXPECT_FALSE(viewport.FitExtent({10, 0, 0, 10})); + EXPECT_FALSE(viewport.FitExtent({kNaN, 0, 10, 10})); + EXPECT_FALSE(viewport.FitExtent({0, 0, kInfinity, 10})); + EXPECT_FALSE(viewport.FitExtent({-kInfinity, 0, 10, 10})); + + ExpectMap(viewport.Center(), 50, 25); + EXPECT_DOUBLE_EQ(*viewport.Resolution(), 1.0); +} + +TEST(Viewport, TransformationsRejectNonFiniteInputs) +{ + const Viewport viewport = MakeFittedViewport(); + + EXPECT_FALSE(viewport.MapToScreen({kNaN, 0}).has_value()); + EXPECT_FALSE(viewport.MapToScreen({0, kInfinity}).has_value()); + EXPECT_FALSE(viewport.MapToScreen({-kInfinity, 0}).has_value()); + EXPECT_FALSE(viewport.ScreenToMap({kNaN, 0}).has_value()); + EXPECT_FALSE(viewport.ScreenToMap({0, -kInfinity}).has_value()); +} + +TEST(Viewport, TransformationsRejectResultsThatOverflow) +{ + Viewport viewport(100, 100); + viewport.SetView({0, 0}, Viewport::MinResolution({0, 0})); + EXPECT_FALSE(viewport.MapToScreen({std::numeric_limits::max(), 0}).has_value()); + + viewport.SetView({0, 0}, Viewport::kMaxResolution); + EXPECT_FALSE(viewport.ScreenToMap({std::numeric_limits::max(), 0}).has_value()); +} + +TEST(Viewport, SetViewRejectsNonFiniteAndNonPositiveValues) +{ + Viewport viewport(100, 100); + + EXPECT_THROW(viewport.SetView({kNaN, 0}, 1.0), InvalidArgument); + EXPECT_THROW(viewport.SetView({0, kInfinity}, 1.0), InvalidArgument); + EXPECT_THROW(viewport.SetView({0, 0}, 0.0), InvalidArgument); + EXPECT_THROW(viewport.SetView({0, 0}, -1.0), InvalidArgument); + EXPECT_THROW(viewport.SetView({0, 0}, kNaN), InvalidArgument); + EXPECT_THROW(viewport.SetView({0, 0}, kInfinity), InvalidArgument); + EXPECT_FALSE(viewport.HasView()); +} + +TEST(Viewport, SetViewClampsToLimits) +{ + Viewport viewport(100, 100); + + viewport.SetView({1.0e20, -1.0e20}, 1.0e-30); + ExpectMap(viewport.Center(), Viewport::kMaxCoordinate, -Viewport::kMaxCoordinate); + EXPECT_DOUBLE_EQ(*viewport.Resolution(), + Viewport::kMinRelativeResolution * Viewport::kMaxCoordinate); + + viewport.SetView({0, 0}, 1.0e-30); + EXPECT_DOUBLE_EQ(*viewport.Resolution(), Viewport::kMinRelativeResolution); + + viewport.SetView({0, 0}, 1.0e30); + EXPECT_DOUBLE_EQ(*viewport.Resolution(), Viewport::kMaxResolution); +} + +TEST(Viewport, FitClampsHugeExtentsToLimits) +{ + Viewport viewport(100, 100); + const double huge = std::numeric_limits::max(); + + ASSERT_TRUE(viewport.FitExtent({-huge, -huge, huge, huge})); + ExpectMap(viewport.Center(), 0, 0); + EXPECT_DOUBLE_EQ(*viewport.Resolution(), Viewport::kMaxResolution); + + ASSERT_TRUE(viewport.FitExtent({1.0e15, 1.0e15, 1.0e15 + 1.0, 1.0e15 + 1.0})); + ExpectMap(viewport.Center(), Viewport::kMaxCoordinate, Viewport::kMaxCoordinate); + EXPECT_GE(*viewport.Resolution(), Viewport::MinResolution(*viewport.Center())); +} + +TEST(Viewport, FitClampsTinyExtentsToMinimumResolution) +{ + Viewport viewport(100, 100); + + ASSERT_TRUE(viewport.FitExtent({1000, 1000, 1000 + 1.0e-12, 1000 + 1.0e-12})); + EXPECT_DOUBLE_EQ(*viewport.Resolution(), Viewport::MinResolution(*viewport.Center())); + EXPECT_NEAR(*viewport.Resolution(), 1.0e-7, 1.0e-12); + EXPECT_TRUE(IsVisible(viewport, {1000, 1000, 1000 + 1.0e-12, 1000 + 1.0e-12})); +} + +TEST(Viewport, ClearViewKeepsSize) +{ + Viewport viewport = MakeFittedViewport(); + + viewport.ClearView(); + + EXPECT_FALSE(viewport.HasView()); + EXPECT_FALSE(viewport.IsValid()); + EXPECT_EQ(viewport.Width(), 101); + EXPECT_EQ(viewport.Height(), 51); +} + +TEST(Viewport, CopiesAreIndependentSnapshots) +{ + Viewport viewport = MakeFittedViewport(); + const Viewport snapshot = viewport; + + viewport.SetView({0, 0}, 2.0); + + ExpectMap(snapshot.Center(), 50, 25); + EXPECT_DOUBLE_EQ(*snapshot.Resolution(), 1.0); +} + +TEST(ToPixel, RoundsDownToTheContainingPixel) +{ + const std::optional inside = ToPixel({10.99, 0.0}); + ASSERT_TRUE(inside.has_value()); + EXPECT_EQ(inside->x, 10); + EXPECT_EQ(inside->y, 0); + + const std::optional negative = ToPixel({-0.5, -1.0}); + ASSERT_TRUE(negative.has_value()); + EXPECT_EQ(negative->x, -1); + EXPECT_EQ(negative->y, -1); +} + +TEST(ToPixel, RejectsNonFiniteAndOutOfRangeCoordinates) +{ + EXPECT_FALSE(ToPixel({kNaN, 0}).has_value()); + EXPECT_FALSE(ToPixel({0, kInfinity}).has_value()); + EXPECT_FALSE(ToPixel({1.0e20, 0}).has_value()); + EXPECT_FALSE(ToPixel({0, -1.0e20}).has_value()); +}