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()); +}