Skip to content

Commit

Permalink
Fix misplacement of geometry parts (#30)
Browse files Browse the repository at this point in the history
* Reset currentX and currentY when encoded data is not written
* Add provided unit test slightly modified
* Check extent of polygon's interior rings prior to encoding
* Add extent test for lineal geometries
* Fix failure case
* Improve performance when writing MBTiles (#31)
* Write encoded geometries directly to the destination list
* If id is ulong use it directly
* Reset currentX and currentY when encoded data is not written
* Add provided unit test slightly modified
* Check extent of polygon's interior rings prior to encoding
* Add extent test for lineal geometries
* Add min[Lineal|Polygonal]Extent to MapBoxTileWriter
* Add ExtentInPixels to TileGeometryTransform
* Add HasValidExtent functions to EncodeTo functions for lineal and polygonal geometries
* Expose default values for min[Lineal|Polygonal]Extent and idAttributeName
* Add unit test for min[Lineal|Polygonal]Extent
* Fix GitHub Actions criticism
* Update README.md

* Update frameworks
* Update action versions

closes #29

---------

Co-authored-by: Dave Leaver <danzel@localhost.geek.nz>
  • Loading branch information
FObermaier and danzel authored Aug 14, 2024
1 parent 6a30b00 commit 437d29e
Show file tree
Hide file tree
Showing 18 changed files with 264 additions and 64 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/dotnetcore.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@ jobs:

steps:
- name: Get source
uses: actions/checkout@v1
uses: actions/checkout@v4

- name: Setup .NET Core 6.
uses: actions/setup-dotnet@v1
uses: actions/setup-dotnet@v4
with:
dotnet-version: 6.0.x

Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/prerelease.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ jobs:

steps:
- name: Get source
uses: actions/checkout@v2
uses: actions/checkout@v4
with:
submodules: 'true'
- name: Setup .NET Core 6.
uses: actions/setup-dotnet@v1
uses: actions/setup-dotnet@v4
with:
dotnet-version: 6.0.x
- name: Restore packages.
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ jobs:

steps:
- name: Get source
uses: actions/checkout@v2
uses: actions/checkout@v4
with:
submodules: 'true'
- name: Setup .NET Core 6.
uses: actions/setup-dotnet@v1
uses: actions/setup-dotnet@v4
with:
dotnet-version: 6.0.x
- name: Restore packages.
Expand Down
17 changes: 13 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ A package that can be used to read or generate vector tiles using NTS.

## Getting started

This package is somewhat experimental. A NuGet-package is hosted on [Github Packages](https://github.com/orgs/NetTopologySuite/packages?repo_name=NetTopologySuite.IO.VectorTiles)
A NuGet-package is hosted on [Github Packages](https://github.com/orgs/NetTopologySuite/packages?repo_name=NetTopologySuite.IO.VectorTiles)

### Create a vector tile

Expand All @@ -34,10 +34,19 @@ vt.Layers.Add(lyr);
using (var fs = new FileStream(filePath, FileMode.Create))
{
//Write the tile to the stream.
vt.Write(fs);
vt.Write(fs, MapboxTileWriter.DefaultMinLinealExtent, MapboxTileWriter.DefaultMinPolygonalExtent);
}
```

`MapboxTileWriter.Write` method takes two arguments controlling the output of features.

Argument | Default | Meaning
--- | --- | ---
minLinealExtent | 1 pixel |This applies to features with lineal geometries. If their scaled geometries for this tile have a bounding box with both edge lengths less than this value will not be written
minPolygonalExtent | 2 pixel<sup>2</sup> | This applies to polygonal geometries. If their scaled geometries for this tile have a bounding box with an area less than this value will not be exported.

Both constraints apply to parts of geometries, too. Holes in polygons or parts of multi-geometries not meeting the requirement will be omitted.

### Read a vector tile

The following shows how to read an individual vector tile and access the underlying NTS Features.
Expand Down Expand Up @@ -127,10 +136,10 @@ vt.Layers.Add(lyr);
using (var fs = new FileStream(filePath, FileMode.Create))
{
//Write the tile to the stream. This will automatically look for an "id" attribute that is a ulong or integer value as set the tile's feature ID to it.
vt.Write(fs);
vt.Write(fs, MapboxTileWriter.DefaultMinLinealExtent, MapboxTileWriter.DefaultMinPolygonalExtent);

//Alternatively, pass in a different attribute name to have the vector tiles feature use that ID value.
//vt.Write(fs, 4096, "alternateId");
//vt.Write(fs, MapboxTileWriter.DefaultMinLinealExtent, MapboxTileWriter.DefaultMinPolygonalExtent, 4096, "alternateId");
}
```

Expand Down
117 changes: 99 additions & 18 deletions src/NetTopologySuite.IO.VectorTiles.Mapbox/MapboxTileWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,43 @@ namespace NetTopologySuite.IO.VectorTiles.Mapbox
// see: https://github.com/mapbox/vector-tile-spec/tree/master/2.1
public static class MapboxTileWriter
{
/// <summary>
/// The default minimum edge length (in pixel) of a lineal feature's extent
/// </summary>
public const uint DefaultMinLinealExtent = 1;

/// <summary>
/// The default minimum area (in square pixel) of a lineal feature's extent
/// </summary>
public const uint DefaultMinPolygonalExtent = 2;

/// <summary>
/// The default attribute name of a feature's identifier
/// </summary>
public const string DefaultIdAttributeName = "id";

/// <summary>
/// Writes the tiles in a /z/x/y.mvt folder structure.
/// </summary>
/// <param name="tree">The tree.</param>
/// <param name="path">The path.</param>
/// <param name="extent">The extent.</param>
/// <remarks>Replaces the files if they are already present.</remarks>
[Obsolete("Use overload that can specify minLineal- and minPolygonalExtent")]
public static void Write(this VectorTileTree tree, string path, uint extent = 4096)
=> Write(tree, path, DefaultMinLinealExtent, DefaultMinPolygonalExtent, extent, DefaultIdAttributeName);

/// <summary>
/// Writes the tiles in a /z/x/y.mvt folder structure.
/// </summary>
/// <param name="tree">The tree.</param>
/// <param name="path">The path.</param>
/// <param name="minLinealExtent">The minimum length in pixel one of a lineal feature's extent edges has to have in order for the feature to be written. The default is 1.</param>
/// <param name="minPolygonalExtent">The minimum area in pixel one of a polygonal feature's extent has to have in order for the feature to be written. The default is 2.</param>
/// <param name="extent">The extent.</param>
/// <remarks>Replaces the files if they are already present.</remarks>
public static void Write(this VectorTileTree tree, string path, uint minLinealExtent, uint minPolygonalExtent,
uint extent = 4096, string idAttributeName = DefaultIdAttributeName)
{
IEnumerable<VectorTile> GetTiles()
{
Expand All @@ -26,7 +55,7 @@ IEnumerable<VectorTile> GetTiles()
}
}

GetTiles().Write(path, extent);
GetTiles().Write(path, minLinealExtent, minPolygonalExtent, extent, idAttributeName);
}

/// <summary>
Expand All @@ -36,7 +65,21 @@ IEnumerable<VectorTile> GetTiles()
/// <param name="path">The path.</param>
/// <param name="extent">The extent.</param>
/// <remarks>Replaces the files if they are already present.</remarks>
[Obsolete("Use overload that can specify minLineal- and minPolygonalExtent")]
public static void Write(this IEnumerable<VectorTile> vectorTiles, string path, uint extent = 4096)
=> Write(vectorTiles, path, DefaultMinLinealExtent, DefaultMinPolygonalExtent, extent, DefaultIdAttributeName);

/// <summary>
/// Writes the tiles in a /z/x/y.mvt folder structure.
/// </summary>
/// <param name="vectorTiles">The tiles.</param>
/// <param name="path">The path.</param>
/// <param name="minLinealExtent">The minimum length in pixel one of a lineal feature's extent edges has to have in order for the feature to be written. The default is 1.</param>
/// <param name="minPolygonalExtent">The minimum area in pixel one of a polygonal feature's extent has to have in order for the feature to be written. The default is 2.</param>
/// <param name="extent">The extent.</param>
/// <remarks>Replaces the files if they are already present.</remarks>
public static void Write(this IEnumerable<VectorTile> vectorTiles, string path, uint minLinealExtent, uint minPolygonalExtent,
uint extent = 4096, string idAttributeName = DefaultIdAttributeName)
{
foreach (var vectorTile in vectorTiles)
{
Expand All @@ -54,7 +97,7 @@ public static void Write(this IEnumerable<VectorTile> vectorTiles, string path,
string file = Path.Combine(xFolder, $"{tile.Y}.mvt");

using var stream = File.Open(file, FileMode.Create);
vectorTile.Write(stream, extent);
vectorTile.Write(stream, minLinealExtent, minPolygonalExtent, extent, idAttributeName);
}
}

Expand All @@ -65,8 +108,25 @@ public static void Write(this IEnumerable<VectorTile> vectorTiles, string path,
/// <param name="stream">The stream to write to.</param>
/// <param name="extent">The extent.</param>
/// <param name="idAttributeName">The name of an attribute property to use as the ID for the Feature. Vector tile feature ID's should be integer or ulong numbers.</param>
[Obsolete("Use overload that can specify minLineal- and minPolygonalExtent")]
public static void Write(this VectorTile vectorTile, Stream stream, uint extent = 4096, string idAttributeName = "id")
=> Write(vectorTile, stream, DefaultMinLinealExtent, DefaultMinPolygonalExtent, extent, idAttributeName);

/// <summary>
/// Writes the tile to the given stream.
/// </summary>
/// <param name="vectorTile">The vector tile.</param>
/// <param name="stream">The stream to write to.</param>
/// <param name="minLinealExtent">The minimum length in pixel one of a lineal feature's extent edges has to have in order for the feature to be written. The default is 1.</param>
/// <param name="minPolygonalExtent">The minimum area in pixel one of a polygonal feature's extent has to have in order for the feature to be written. The default is 2.</param>
/// <param name="extent">The extent.</param>
/// <param name="idAttributeName">The name of an attribute property to use as the ID for the Feature. Vector tile feature ID's should be integer or ulong numbers.</param>
public static void Write(this VectorTile vectorTile, Stream stream, uint minLinealExtent, uint minPolygonalExtent,
uint extent = 4096, string idAttributeName = "id")
{
// ensure valid minimal polygonal extent
if (minPolygonalExtent < 1) minPolygonalExtent = 1;

var tile = new Tiles.Tile(vectorTile.TileId);
var tgt = new TileGeometryTransform(tile, extent);

Expand Down Expand Up @@ -95,11 +155,11 @@ public static void Write(this VectorTile vectorTile, Stream stream, uint extent
break;
case ILineal lineal:
feature.Type = Tile.GeomType.LineString;
EncodeTo(feature.Geometry, lineal, tgt);
EncodeTo(feature.Geometry, lineal, minLinealExtent, tgt);
break;
case IPolygonal polygonal:
feature.Type = Tile.GeomType.Polygon;
EncodeTo(feature.Geometry, polygonal, tgt, tile.Zoom);
EncodeTo(feature.Geometry, polygonal, minPolygonalExtent, tgt);
break;
default:
feature.Type = Tile.GeomType.Unknown;
Expand Down Expand Up @@ -236,37 +296,49 @@ private static void EncodeTo(List<uint> destination, IPuntal puntal, TileGeometr
destination[moveToIndex] = GenerateCommandInteger(MapboxCommandType.MoveTo, (destination.Count - moveToIndex) / 2);
}

private static void EncodeTo(List<uint> destination, ILineal lineal, TileGeometryTransform tgt)
private static void EncodeTo(List<uint> destination, ILineal lineal, uint minLinealExtent, TileGeometryTransform tgt)
{
bool HasValidLength((long x, long y) tpl)
=> tpl.x >= minLinealExtent || tpl.y >= minLinealExtent;

var geometry = (Geometry)lineal;
int currentX = 0, currentY = 0;
for (int i = 0; i < geometry.NumGeometries; i++)
{
var lineString = (LineString)geometry.GetGeometryN(i);
EncodeTo(destination, lineString.CoordinateSequence, tgt, ref currentX, ref currentY, false);
if (HasValidLength(tgt.ExtentInPixel(lineString.EnvelopeInternal)))
EncodeTo(destination, lineString.CoordinateSequence, tgt, ref currentX, ref currentY, false);
}
}

private static void EncodeTo(List<uint> destination, IPolygonal polygonal, TileGeometryTransform tgt, int zoom)
private static void EncodeTo(List<uint> destination, IPolygonal polygonal, uint minPolygonalExtent, TileGeometryTransform tgt)
{
bool HasValidExtent((long x, long y) tpl)
=> (tpl.x >= minPolygonalExtent && tpl.y > 0) ||
(tpl.x > 0 && tpl.y >= minPolygonalExtent);

var geometry = (Geometry)polygonal;

//Test the whole polygon geometry is larger than a single pixel.
if (tgt.IsGreaterThanOnePixelOfTile(geometry))
//Test the whole polygonal geometry has a valid extent.
if (HasValidExtent(tgt.ExtentInPixel(geometry.EnvelopeInternal)))
{
int currentX = 0, currentY = 0;
for (int i = 0; i < geometry.NumGeometries; i++)
{
var polygon = (Polygon)geometry.GetGeometryN(i);

//Test that individual polygons are larger than a single pixel.
if (!tgt.IsGreaterThanOnePixelOfTile(polygon))
// Test that the exterior ring has a valid extent
if (!HasValidExtent(tgt.ExtentInPixel(polygon.ExteriorRing.EnvelopeInternal)))
continue;

EncodeTo(destination, polygon.Shell.CoordinateSequence, tgt, ref currentX, ref currentY, true, false);
foreach (var hole in polygon.InteriorRings)
EncodeTo(destination, polygon.ExteriorRing.CoordinateSequence, tgt, ref currentX, ref currentY, true, false);
foreach (var interiorRing in polygon.InteriorRings)
{
EncodeTo(destination, hole.CoordinateSequence, tgt, ref currentX, ref currentY, true, true);
// Test that the interior ring has a valid extent.
if (!HasValidExtent(tgt.ExtentInPixel(interiorRing.EnvelopeInternal)))
continue;

EncodeTo(destination, interiorRing.CoordinateSequence, tgt, ref currentX, ref currentY, true, true);
}
}
}
Expand All @@ -284,14 +356,16 @@ private static void EncodeTo(List<uint> destination, CoordinateSequence sequence
if (count == 0)
return;

// In case we decide to ditch encoded data, we must reset currentX and currentY
// or subsequent geometry items will not be positioned correctly.
int initialCurrentX = currentX;
int initialCurrentY = currentY;

// if we have a ring we need to check orientation
if (ring)
{
if (ccw != Algorithm.Orientation.IsCCW(sequence))
{
sequence = sequence.Copy();
CoordinateSequences.Reverse(sequence);
}
sequence = sequence.Reversed();
}

int initialSize = destination.Count;
Expand Down Expand Up @@ -335,6 +409,13 @@ private static void EncodeTo(List<uint> destination, CoordinateSequence sequence
if (destination.Count - initialSize - 2 < 4)
destination.RemoveRange(initialSize, destination.Count - initialSize);
}

// Reset currentX and currentY to intital values if there is no encoded data to return
if (destination.Count == initialSize)
{
currentX = initialCurrentX;
currentY = initialCurrentY;
}
}

/*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,18 +105,27 @@ public bool IsPointInExtent(int x, int y)
/// </summary>
/// <param name="polygon">Polygon to test.</param>
/// <returns>true if the <paramref name="polygon"/> is greater than 1 pixel in the tile pixel coordinates</returns>
public bool IsGreaterThanOnePixelOfTile(Geometry polygon)
public bool IsGreaterThanOnePixelOfTile(Geometry geometry)
{
if (polygon.IsEmpty) return false;
if (geometry.IsEmpty) return false;

(double x1, double y1) = WebMercatorHandler.FromMetersToPixels(WebMercatorHandler.LatLonToMeters(polygon.EnvelopeInternal.MinY, polygon.EnvelopeInternal.MinX), ZoomResolution);
(double x2, double y2) = WebMercatorHandler.FromMetersToPixels(WebMercatorHandler.LatLonToMeters(polygon.EnvelopeInternal.MaxY, polygon.EnvelopeInternal.MaxX), ZoomResolution);
var env = geometry.EnvelopeInternal;
(double x1, double y1) = WebMercatorHandler.FromMetersToPixels(WebMercatorHandler.LatLonToMeters(env.MinY, env.MinX), ZoomResolution);
(double x2, double y2) = WebMercatorHandler.FromMetersToPixels(WebMercatorHandler.LatLonToMeters(env.MaxY, env.MaxX), ZoomResolution);

double dx = Math.Abs(x2 - x1);
double dy = Math.Abs(y2 - y1);

// Both must be greater than 0, and at least one of them needs to be larger than 1.
return dx > 0 && dy > 0 && (dx > 1 || dy > 1);
}

public (long x, long y) ExtentInPixel(Envelope env)
{
(long minX, long minY) = WebMercatorHandler.FromMetersToPixels(WebMercatorHandler.LatLonToMeters(env.MinY, env.MinX), ZoomResolution);
(long maxX, long maxY) = WebMercatorHandler.FromMetersToPixels(WebMercatorHandler.LatLonToMeters(env.MaxY, env.MaxX), ZoomResolution);

return (maxX - minX, maxY - minY);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<TargetFramework>net6.0</TargetFramework>
<CopyRefAssembliesToPublishDirectory>false</CopyRefAssembliesToPublishDirectory>
</PropertyGroup>

Expand Down Expand Up @@ -34,7 +34,7 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="5.0.7" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="6.0.28" />
<PackageReference Include="NetTopologySuite.IO.GeoJSON" Version="2.0.2" />
</ItemGroup>

Expand Down
2 changes: 1 addition & 1 deletion test/NetTopologySuite.IO.VectorTiles.Samples/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env)

tree.GetExtents(out Pages.IndexModel._BBox, out Pages.IndexModel._MinZoom, out Pages.IndexModel._MaxZoom);

tree.Write("wwwroot/tiles");
tree.Write("wwwroot/tiles", MapboxTileWriter.DefaultMinLinealExtent, MapboxTileWriter.DefaultMinPolygonalExtent);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ private static void RunTest(string fileName, int minZoom, int maxZoom, string st
};

// write the tiles to disk as mvt.
Mapbox.MapboxTileWriter.Write(tree, "tiles");
Mapbox.MapboxTileWriter.Write(tree, "tiles", 1, 2);

stopwatch.Stop();

Expand Down
Loading

0 comments on commit 437d29e

Please sign in to comment.