diff --git a/README.md b/README.md index 8e8238e..e73adbb 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![NuGet Status](http://img.shields.io/nuget/v/mapbox-vector-tile.svg?style=flat)](https://www.nuget.org/packages/mapbox-vector-tile/) ![.NET 8](https://github.com/bertt/mapbox-vector-tile-cs/workflows/.NET%208/badge.svg) -.NET Standard 2.0 library for decoding a Mapbox vector tile. +.NET Standard 2.0 library for encoding and decoding Mapbox vector tiles. ## Dependencies @@ -16,12 +16,44 @@ $ Install-Package mapbox-vector-tile ## Usage +### Decoding + ```cs const string vtfile = "vectortile.pbf"; var stream = File.OpenRead(vtfile); var layerInfos = VectorTileParser.Parse(stream); ``` +### Encoding + +```cs +// Create a layer +var layer = new VectorTileLayer("my_layer", 2, 4096); + +// Create a feature with attributes and geometry +var attributes = new List> +{ + new KeyValuePair("name", "Example"), + new KeyValuePair("value", 42) +}; + +var coordinates = new[] { new Coordinate(100, 200) }; +var geometry = new List> +{ + new ArraySegment(coordinates) +}; + +var feature = new VectorTileFeature("1", geometry, attributes, Tile.GeomType.Point, 4096); +layer.VectorTileFeatures.Add(feature); + +// Encode to stream +var layers = new List { layer }; +var stream = VectorTileEncoder.Encode(layers, new MemoryStream()); + +// Save to file +File.WriteAllBytes("output.pbf", ((MemoryStream)stream).ToArray()); +``` + Tip: If you use this library with vector tiles loading from a webserver, you could run into the following exception: 'ProtoBuf.ProtoException: Invalid wire-type; this usually means you have over-written a file without truncating or setting the length' Probably you need to check the GZip compression, see also TileParserTests.cs for an example. diff --git a/src/AttributesEncoder.cs b/src/AttributesEncoder.cs new file mode 100644 index 0000000..05dba07 --- /dev/null +++ b/src/AttributesEncoder.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; + +namespace Mapbox.Vector.Tile; + +public static class AttributesEncoder +{ + public static List Encode(List> attributes, List keys, List values) + { + var tags = new List(); + + foreach (var attribute in attributes) + { + var key = attribute.Key; + var value = attribute.Value; + + // Get or add key index + var keyIndex = keys.IndexOf(key); + if (keyIndex == -1) + { + keyIndex = keys.Count; + keys.Add(key); + } + + // Get or add value index + var tileValue = CreateTileValue(value); + var valueIndex = FindValueIndex(values, tileValue); + if (valueIndex == -1) + { + valueIndex = values.Count; + values.Add(tileValue); + } + + tags.Add((uint)keyIndex); + tags.Add((uint)valueIndex); + } + + return tags; + } + + private static Tile.Value CreateTileValue(object value) + { + var tileValue = new Tile.Value(); + + switch (value) + { + case string stringValue: + tileValue.StringValue = stringValue; + break; + case bool boolValue: + tileValue.BoolValue = boolValue; + break; + case float floatValue: + tileValue.FloatValue = floatValue; + break; + case double doubleValue: + tileValue.DoubleValue = doubleValue; + break; + case int intValue: + tileValue.IntValue = intValue; + break; + case long longValue: + tileValue.IntValue = longValue; + break; + case uint uintValue: + tileValue.UintValue = uintValue; + break; + case ulong ulongValue: + tileValue.UintValue = ulongValue; + break; + default: + throw new NotImplementedException($"Unsupported attribute type: {value.GetType()}"); + } + + return tileValue; + } + + private static int FindValueIndex(List values, Tile.Value newValue) + { + for (int i = 0; i < values.Count; i++) + { + var existingValue = values[i]; + if (ValuesEqual(existingValue, newValue)) + { + return i; + } + } + return -1; + } + + private static bool ValuesEqual(Tile.Value a, Tile.Value b) + { + if (a.HasStringValue && b.HasStringValue) + return a.StringValue == b.StringValue; + if (a.HasBoolValue && b.HasBoolValue) + return a.BoolValue == b.BoolValue; + if (a.HasFloatValue && b.HasFloatValue) + return Math.Abs(a.FloatValue - b.FloatValue) < 0.0001f; + if (a.HasDoubleValue && b.HasDoubleValue) + return Math.Abs(a.DoubleValue - b.DoubleValue) < 0.0001; + if (a.HasIntValue && b.HasIntValue) + return a.IntValue == b.IntValue; + if (a.HasSIntValue && b.HasSIntValue) + return a.SintValue == b.SintValue; + if (a.HasUIntValue && b.HasUIntValue) + return a.UintValue == b.UintValue; + + return false; + } +} diff --git a/src/GeometryEncoder.cs b/src/GeometryEncoder.cs new file mode 100644 index 0000000..19450b8 --- /dev/null +++ b/src/GeometryEncoder.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Mapbox.Vector.Tile; + +public static class GeometryEncoder +{ + private enum Command + { + MoveTo = 1, + LineTo = 2, + ClosePath = 7 + } + + public static List EncodeGeometry(List> geometry, Tile.GeomType geomType) + { + var commands = new List(); + long lastX = 0; + long lastY = 0; + + foreach (var segment in geometry) + { + if (segment.Count == 0) + continue; + + // Access the underlying array and use offset + var array = segment.Array!; + var offset = segment.Offset; + var count = segment.Count; + + // MoveTo command for first point + var firstPoint = array[offset]; + commands.Add(EncodeCommand(Command.MoveTo, 1)); + + var dx = firstPoint.X - lastX; + var dy = firstPoint.Y - lastY; + commands.Add((uint)ZigZag.Encode(dx)); + commands.Add((uint)ZigZag.Encode(dy)); + + lastX = firstPoint.X; + lastY = firstPoint.Y; + + // LineTo commands for remaining points (excluding last if it's a duplicate of first) + var lineToCount = count - 1; + + // For polygons, check if last point equals first point + if (geomType == Tile.GeomType.Polygon && count > 1) + { + var lastPoint = array[offset + count - 1]; + if (lastPoint.X == firstPoint.X && lastPoint.Y == firstPoint.Y) + { + lineToCount--; + } + } + + if (lineToCount > 0) + { + commands.Add(EncodeCommand(Command.LineTo, (uint)lineToCount)); + + for (int i = 1; i <= lineToCount; i++) + { + var point = array[offset + i]; + dx = point.X - lastX; + dy = point.Y - lastY; + commands.Add((uint)ZigZag.Encode(dx)); + commands.Add((uint)ZigZag.Encode(dy)); + + lastX = point.X; + lastY = point.Y; + } + } + + // ClosePath command for polygons only + if (geomType == Tile.GeomType.Polygon) + { + commands.Add(EncodeCommand(Command.ClosePath, 1)); + } + } + + return commands; + } + + private static uint EncodeCommand(Command command, uint count) + { + return (count << 3) | (uint)command; + } +} diff --git a/src/VectorTileEncoder.cs b/src/VectorTileEncoder.cs new file mode 100644 index 0000000..1df37a9 --- /dev/null +++ b/src/VectorTileEncoder.cs @@ -0,0 +1,53 @@ +using ProtoBuf; +using System.Collections.Generic; +using System.IO; + +namespace Mapbox.Vector.Tile; + +public static class VectorTileEncoder +{ + public static Stream Encode(List layers, Stream stream) + { + var tile = new Tile(); + + foreach (var vectorTileLayer in layers) + { + var layer = new Tile.Layer + { + Name = vectorTileLayer.Name, + Version = vectorTileLayer.Version, + Extent = vectorTileLayer.Extent + }; + + var keys = new List(); + var values = new List(); + + foreach (var vectorTileFeature in vectorTileLayer.VectorTileFeatures) + { + var feature = new Tile.Feature + { + Id = ulong.Parse(vectorTileFeature.Id), + Type = vectorTileFeature.GeometryType + }; + + // Encode attributes + var tags = AttributesEncoder.Encode(vectorTileFeature.Attributes, keys, values); + feature.Tags.AddRange(tags); + + // Encode geometry + var geometry = GeometryEncoder.EncodeGeometry(vectorTileFeature.Geometry, vectorTileFeature.GeometryType); + feature.Geometry.AddRange(geometry); + + layer.Features.Add(feature); + } + + layer.Keys.AddRange(keys); + layer.Values.AddRange(values); + + tile.Layers.Add(layer); + } + + Serializer.Serialize(stream, tile); + return stream; + } +} diff --git a/tests/RealTileRoundTripTest.cs b/tests/RealTileRoundTripTest.cs new file mode 100644 index 0000000..296c349 --- /dev/null +++ b/tests/RealTileRoundTripTest.cs @@ -0,0 +1,46 @@ +using Mapbox.Vector.Tile; +using NUnit.Framework; +using System.IO; + +namespace mapbox.vector.tile.tests; + +public class RealTileRoundTripTest +{ + [Test] + public void TestRealTileRoundTrip() + { + // Test that we can decode a real tile and encode it back + var pbfFile = Path.Combine("testdata", "14-8801-5371.vector.pbf"); + + // Decode the original tile + var originalStream = File.OpenRead(pbfFile); + var decodedLayers = VectorTileParser.Parse(originalStream); + originalStream.Close(); + + Assert.That(decodedLayers.Count, Is.GreaterThan(0), "Should have decoded at least one layer"); + + // Encode it back + var encodedStream = new MemoryStream(); + VectorTileEncoder.Encode(decodedLayers, encodedStream); + encodedStream.Seek(0, SeekOrigin.Begin); + + Assert.That(encodedStream.Length, Is.GreaterThan(0), "Encoded stream should not be empty"); + + // Decode the encoded tile + var reDecodedLayers = VectorTileParser.Parse(encodedStream); + + // Verify layer count matches + Assert.That(reDecodedLayers.Count, Is.EqualTo(decodedLayers.Count), "Layer count should match"); + + // Verify each layer + for (int i = 0; i < decodedLayers.Count; i++) + { + var original = decodedLayers[i]; + var reDecoded = reDecodedLayers[i]; + + Assert.That(reDecoded.Name, Is.EqualTo(original.Name), $"Layer {i} name should match"); + Assert.That(reDecoded.VectorTileFeatures.Count, Is.EqualTo(original.VectorTileFeatures.Count), + $"Layer '{original.Name}' feature count should match"); + } + } +} diff --git a/tests/VectorTileEncoderTests.cs b/tests/VectorTileEncoderTests.cs new file mode 100644 index 0000000..2d10868 --- /dev/null +++ b/tests/VectorTileEncoderTests.cs @@ -0,0 +1,184 @@ +using Mapbox.Vector.Tile; +using NUnit.Framework; +using System; +using System.Collections.Generic; +using System.IO; + +namespace mapbox.vector.tile.tests; + +public class VectorTileEncoderTests +{ + [Test] + public void TestEncodeDecodeRoundTrip() + { + // Create a simple vector tile layer with a point feature + var layer = new VectorTileLayer("test_layer", 2, 4096); + + var attributes = new List> + { + new KeyValuePair("name", "Test Point"), + new KeyValuePair("value", 42) + }; + + var coordinates = new[] + { + new Coordinate(100, 200) + }; + + var geometry = new List> + { + new ArraySegment(coordinates) + }; + + var feature = new VectorTileFeature("1", geometry, attributes, Tile.GeomType.Point, 4096); + layer.VectorTileFeatures.Add(feature); + + var layers = new List { layer }; + + // Encode to stream + var stream = new MemoryStream(); + VectorTileEncoder.Encode(layers, stream); + + // Decode from stream + stream.Seek(0, SeekOrigin.Begin); + var decodedLayers = VectorTileParser.Parse(stream); + + // Verify + Assert.That(decodedLayers.Count, Is.EqualTo(1)); + Assert.That(decodedLayers[0].Name, Is.EqualTo("test_layer")); + Assert.That(decodedLayers[0].VectorTileFeatures.Count, Is.EqualTo(1)); + + var decodedFeature = decodedLayers[0].VectorTileFeatures[0]; + Assert.That(decodedFeature.GeometryType, Is.EqualTo(Tile.GeomType.Point)); + Assert.That(decodedFeature.Attributes.Count, Is.EqualTo(2)); + } + + [Test] + public void TestEncodeDecodeLineString() + { + var layer = new VectorTileLayer("roads", 2, 4096); + + var attributes = new List> + { + new KeyValuePair("type", "highway") + }; + + var coordinates = new[] + { + new Coordinate(0, 0), + new Coordinate(100, 0), + new Coordinate(100, 100), + new Coordinate(0, 100), + new Coordinate(0, 0) + }; + + var geometry = new List> + { + new ArraySegment(coordinates) + }; + + var feature = new VectorTileFeature("1", geometry, attributes, Tile.GeomType.LineString, 4096); + layer.VectorTileFeatures.Add(feature); + + var layers = new List { layer }; + + // Encode to stream + var stream = new MemoryStream(); + VectorTileEncoder.Encode(layers, stream); + + // Decode from stream + stream.Seek(0, SeekOrigin.Begin); + var decodedLayers = VectorTileParser.Parse(stream); + + // Verify + Assert.That(decodedLayers.Count, Is.EqualTo(1)); + Assert.That(decodedLayers[0].Name, Is.EqualTo("roads")); + Assert.That(decodedLayers[0].VectorTileFeatures.Count, Is.EqualTo(1)); + + var decodedFeature = decodedLayers[0].VectorTileFeatures[0]; + Assert.That(decodedFeature.GeometryType, Is.EqualTo(Tile.GeomType.LineString)); + Assert.That(decodedFeature.Geometry.Count, Is.EqualTo(1)); + } + + [Test] + public void TestEncodeDecodePolygon() + { + var layer = new VectorTileLayer("buildings", 2, 4096); + + var attributes = new List> + { + new KeyValuePair("name", "Building A"), + new KeyValuePair("height", 25.5) + }; + + var coordinates = new[] + { + new Coordinate(0, 0), + new Coordinate(100, 0), + new Coordinate(100, 100), + new Coordinate(0, 100), + new Coordinate(0, 0) + }; + + var geometry = new List> + { + new ArraySegment(coordinates) + }; + + var feature = new VectorTileFeature("1", geometry, attributes, Tile.GeomType.Polygon, 4096); + layer.VectorTileFeatures.Add(feature); + + var layers = new List { layer }; + + // Encode to stream + var stream = new MemoryStream(); + VectorTileEncoder.Encode(layers, stream); + + // Decode from stream + stream.Seek(0, SeekOrigin.Begin); + var decodedLayers = VectorTileParser.Parse(stream); + + // Verify + Assert.That(decodedLayers.Count, Is.EqualTo(1)); + Assert.That(decodedLayers[0].Name, Is.EqualTo("buildings")); + Assert.That(decodedLayers[0].VectorTileFeatures.Count, Is.EqualTo(1)); + + var decodedFeature = decodedLayers[0].VectorTileFeatures[0]; + Assert.That(decodedFeature.GeometryType, Is.EqualTo(Tile.GeomType.Polygon)); + Assert.That(decodedFeature.Attributes.Count, Is.EqualTo(2)); + } + + [Test] + public void TestEncodeMultipleFeatures() + { + var layer = new VectorTileLayer("mixed", 2, 4096); + + // Add a point + var pointCoords = new[] { new Coordinate(50, 50) }; + var pointGeometry = new List> { new ArraySegment(pointCoords) }; + var pointAttrs = new List> { new KeyValuePair("type", "marker") }; + var pointFeature = new VectorTileFeature("1", pointGeometry, pointAttrs, Tile.GeomType.Point, 4096); + layer.VectorTileFeatures.Add(pointFeature); + + // Add a line + var lineCoords = new[] { new Coordinate(0, 0), new Coordinate(100, 100), new Coordinate(0, 0) }; + var lineGeometry = new List> { new ArraySegment(lineCoords) }; + var lineAttrs = new List> { new KeyValuePair("type", "path") }; + var lineFeature = new VectorTileFeature("2", lineGeometry, lineAttrs, Tile.GeomType.LineString, 4096); + layer.VectorTileFeatures.Add(lineFeature); + + var layers = new List { layer }; + + // Encode to stream + var stream = new MemoryStream(); + VectorTileEncoder.Encode(layers, stream); + + // Decode from stream + stream.Seek(0, SeekOrigin.Begin); + var decodedLayers = VectorTileParser.Parse(stream); + + // Verify + Assert.That(decodedLayers.Count, Is.EqualTo(1)); + Assert.That(decodedLayers[0].VectorTileFeatures.Count, Is.EqualTo(2)); + } +}