Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ let package = Package(
.package(url: "https://github.com/Outdooractive/gis-tools-csv", from: "1.1.0"),
.package(url: "https://github.com/Outdooractive/gis-tools-geopackage", from: "1.0.2"),
.package(url: "https://github.com/Outdooractive/gis-tools-gpx", from: "1.0.4"),
.package(url: "https://github.com/Outdooractive/gis-tools-fit", from: "1.0.0"),
.package(url: "https://github.com/Outdooractive/gis-tools-fit", from: "1.1.4"),
.package(url: "https://github.com/Outdooractive/gis-tools-shapefile", from: "1.0.2"),
.package(url: "https://github.com/1024jp/GzipSwift.git", from: "7.0.0"),
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.8.2"),
Expand Down
9 changes: 9 additions & 0 deletions Sources/MVTTools/Coders/MLT/MLTEncoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,15 @@ enum MLTEncoder {

case .multiPolygon:
let mpoly = feature.geometry as! MultiPolygon
// Encode the MultiPolygon structure as-is: each element's
// rings (first = exterior, subsequent = holes) are written
// to the topology so the decoder reconstructs the exact
// grouping. Any hole/interior-ring association is the
// responsibility of the data source (e.g. PostGIS
// ST_MakeValid) and must not be re-derived here via a
// winding-order heuristic, which corrupts legitimate
// MultiPolygons that contain several separate same-winding
// components (e.g. many disjoint forest patches).
for poly in mpoly.coordinates {
polygonRingCounts.append(UInt32(poly.count))
for ring in poly { collectRing(ring) }
Expand Down
214 changes: 214 additions & 0 deletions Tests/MVTToolsTests/MLT/MLTRoundtripGeometryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -182,4 +182,218 @@ struct MLTRoundtripGeometryTests {
#expect(pt.altitude == nil)
}

// MARK: - MultiPolygon ring grouping

/// A MultiPolygon with multiple polygons, some with holes, must preserve
/// the ring grouping through an MLT encode → decode round-trip.
///
/// This is a regression test for the triangular-artifact bug where
/// interior rings (holes) were being separated from their parent polygons
/// and turned into standalone filled polygons, causing overlapping
/// triangles in maplibre-gl-js.
@Test
func multiPolygonWithHolesPreservesRingGrouping() throws {
// Polygon 0: exterior + 1 hole
let poly0 = try #require(Polygon([[
Coordinate3D(x: 0.0, y: 0.0, projection: .noSRID),
Coordinate3D(x: 1000.0, y: 0.0, projection: .noSRID),
Coordinate3D(x: 1000.0, y: 1000.0, projection: .noSRID),
Coordinate3D(x: 0.0, y: 1000.0, projection: .noSRID),
Coordinate3D(x: 0.0, y: 0.0, projection: .noSRID),
], [
Coordinate3D(x: 200.0, y: 200.0, projection: .noSRID),
Coordinate3D(x: 400.0, y: 200.0, projection: .noSRID),
Coordinate3D(x: 400.0, y: 400.0, projection: .noSRID),
Coordinate3D(x: 200.0, y: 400.0, projection: .noSRID),
Coordinate3D(x: 200.0, y: 200.0, projection: .noSRID),
]]))
// Polygon 1: just exterior
let poly1 = try #require(Polygon([[
Coordinate3D(x: 2000.0, y: 2000.0, projection: .noSRID),
Coordinate3D(x: 3000.0, y: 2000.0, projection: .noSRID),
Coordinate3D(x: 3000.0, y: 3000.0, projection: .noSRID),
Coordinate3D(x: 2000.0, y: 3000.0, projection: .noSRID),
Coordinate3D(x: 2000.0, y: 2000.0, projection: .noSRID),
]]))
// Polygon 2: exterior + 2 holes
let poly2 = try #require(Polygon([[
Coordinate3D(x: 4000.0, y: 4000.0, projection: .noSRID),
Coordinate3D(x: 5000.0, y: 4000.0, projection: .noSRID),
Coordinate3D(x: 5000.0, y: 5000.0, projection: .noSRID),
Coordinate3D(x: 4000.0, y: 5000.0, projection: .noSRID),
Coordinate3D(x: 4000.0, y: 4000.0, projection: .noSRID),
], [
Coordinate3D(x: 4200.0, y: 4200.0, projection: .noSRID),
Coordinate3D(x: 4400.0, y: 4200.0, projection: .noSRID),
Coordinate3D(x: 4400.0, y: 4400.0, projection: .noSRID),
Coordinate3D(x: 4200.0, y: 4400.0, projection: .noSRID),
Coordinate3D(x: 4200.0, y: 4200.0, projection: .noSRID),
], [
Coordinate3D(x: 4600.0, y: 4600.0, projection: .noSRID),
Coordinate3D(x: 4800.0, y: 4600.0, projection: .noSRID),
Coordinate3D(x: 4800.0, y: 4800.0, projection: .noSRID),
Coordinate3D(x: 4600.0, y: 4800.0, projection: .noSRID),
Coordinate3D(x: 4600.0, y: 4600.0, projection: .noSRID),
]]))

let mpoly = try #require(MultiPolygon([poly0, poly1, poly2]))
let feature = Feature(mpoly, id: .int(1))

guard let data = MLTEncoder.encode(
layers: ["test": VectorTile.LayerContainer(features: [feature], boundingBox: nil)],
x: 0, y: 0, z: 0, projection: .noSRID)
else { return }
let decoded = try MLTDecoder.decode(from: data, x: 0, y: 0, z: 0, projection: .noSRID)
let features = try #require(decoded["test"]?.features)
#expect(features.count == 1)

let decodedMpoly = try #require(features[0].geometry as? MultiPolygon)
// Must have 3 polygons
#expect(decodedMpoly.coordinates.count == 3)
// Polygon 0: 2 rings (exterior + 1 hole)
#expect(decodedMpoly.coordinates[0].count == 2)
// Polygon 1: 1 ring (exterior only)
#expect(decodedMpoly.coordinates[1].count == 1)
// Polygon 2: 3 rings (exterior + 2 holes)
#expect(decodedMpoly.coordinates[2].count == 3)
}

/// Multiple MultiPolygon features in the same layer, each with different
/// ring counts, must all preserve their ring grouping.
@Test
func multipleMultiPolygonsWithHolesInSameLayer() throws {
// Feature 1: MultiPolygon with 1 polygon that has 1 hole
let poly1 = try #require(Polygon([[
Coordinate3D(x: 0.0, y: 0.0, projection: .noSRID),
Coordinate3D(x: 1000.0, y: 0.0, projection: .noSRID),
Coordinate3D(x: 1000.0, y: 1000.0, projection: .noSRID),
Coordinate3D(x: 0.0, y: 1000.0, projection: .noSRID),
Coordinate3D(x: 0.0, y: 0.0, projection: .noSRID),
], [
Coordinate3D(x: 200.0, y: 200.0, projection: .noSRID),
Coordinate3D(x: 400.0, y: 200.0, projection: .noSRID),
Coordinate3D(x: 400.0, y: 400.0, projection: .noSRID),
Coordinate3D(x: 200.0, y: 400.0, projection: .noSRID),
Coordinate3D(x: 200.0, y: 200.0, projection: .noSRID),
]]))
let feat1 = Feature(try #require(MultiPolygon([poly1])), id: .int(1))

// Feature 2: MultiPolygon with 2 polygons, one with a hole, one without
let poly2a = try #require(Polygon([[
Coordinate3D(x: 2000.0, y: 2000.0, projection: .noSRID),
Coordinate3D(x: 3000.0, y: 2000.0, projection: .noSRID),
Coordinate3D(x: 3000.0, y: 3000.0, projection: .noSRID),
Coordinate3D(x: 2000.0, y: 3000.0, projection: .noSRID),
Coordinate3D(x: 2000.0, y: 2000.0, projection: .noSRID),
], [
Coordinate3D(x: 2200.0, y: 2200.0, projection: .noSRID),
Coordinate3D(x: 2400.0, y: 2200.0, projection: .noSRID),
Coordinate3D(x: 2400.0, y: 2400.0, projection: .noSRID),
Coordinate3D(x: 2200.0, y: 2400.0, projection: .noSRID),
Coordinate3D(x: 2200.0, y: 2200.0, projection: .noSRID),
]]))
let poly2b = try #require(Polygon([[
Coordinate3D(x: 4000.0, y: 4000.0, projection: .noSRID),
Coordinate3D(x: 4500.0, y: 4000.0, projection: .noSRID),
Coordinate3D(x: 4500.0, y: 4500.0, projection: .noSRID),
Coordinate3D(x: 4000.0, y: 4500.0, projection: .noSRID),
Coordinate3D(x: 4000.0, y: 4000.0, projection: .noSRID),
]]))
let feat2 = Feature(try #require(MultiPolygon([poly2a, poly2b])), id: .int(2))

// Feature 3: simple Polygon (no hole)
let poly3 = try #require(Polygon([[
Coordinate3D(x: 5000.0, y: 5000.0, projection: .noSRID),
Coordinate3D(x: 6000.0, y: 5000.0, projection: .noSRID),
Coordinate3D(x: 6000.0, y: 6000.0, projection: .noSRID),
Coordinate3D(x: 5000.0, y: 6000.0, projection: .noSRID),
Coordinate3D(x: 5000.0, y: 5000.0, projection: .noSRID),
]]))
let feat3 = Feature(poly3, id: .int(3))

guard let data = MLTEncoder.encode(
layers: ["test": VectorTile.LayerContainer(features: [feat1, feat2, feat3], boundingBox: nil)],
x: 0, y: 0, z: 0, projection: .noSRID)
else { return }
let decoded = try MLTDecoder.decode(from: data, x: 0, y: 0, z: 0, projection: .noSRID)
let features = try #require(decoded["test"]?.features)
#expect(features.count == 3)

// Feature 1: MultiPolygon with 1 polygon, 2 rings
let mp1 = try #require(features[0].geometry as? MultiPolygon)
#expect(mp1.coordinates.count == 1)
#expect(mp1.coordinates[0].count == 2)

// Feature 2: MultiPolygon with 2 polygons, first has 2 rings, second has 1
let mp2 = try #require(features[1].geometry as? MultiPolygon)
#expect(mp2.coordinates.count == 2)
#expect(mp2.coordinates[0].count == 2)
#expect(mp2.coordinates[1].count == 1)

// Feature 3: Polygon with 1 ring
let p3 = try #require(features[2].geometry as? Polygon)
#expect(p3.rings.count == 1)
}

/// Encode the same VectorTile to both MVT and MLT and verify that
/// MultiPolygon ring grouping is preserved in both.
/// This simulates what `MVTPostgis.data()` does: create one VectorTile
/// and encode it to both formats.
@Test
func dualMvtMltEncodingPreservesRingGrouping() throws {
// MultiPolygon with 2 polygons, first has a hole
// Use realistic EPSG:3857 coordinates within tile 7/65/47
// (lng [2.81, 5.62], lat [40.98, 43.07])
// EPSG:3857: x = lng * 111319, y = lat(log) * ...
let poly0 = try #require(Polygon([[
Coordinate3D(x: 320000.0, y: 5160000.0, projection: .epsg3857),
Coordinate3D(x: 420000.0, y: 5160000.0, projection: .epsg3857),
Coordinate3D(x: 420000.0, y: 5300000.0, projection: .epsg3857),
Coordinate3D(x: 320000.0, y: 5300000.0, projection: .epsg3857),
Coordinate3D(x: 320000.0, y: 5160000.0, projection: .epsg3857),
], [
Coordinate3D(x: 340000.0, y: 5200000.0, projection: .epsg3857),
Coordinate3D(x: 360000.0, y: 5200000.0, projection: .epsg3857),
Coordinate3D(x: 360000.0, y: 5220000.0, projection: .epsg3857),
Coordinate3D(x: 340000.0, y: 5220000.0, projection: .epsg3857),
Coordinate3D(x: 340000.0, y: 5200000.0, projection: .epsg3857),
]]))
let poly1 = try #require(Polygon([[
Coordinate3D(x: 500000.0, y: 5000000.0, projection: .epsg3857),
Coordinate3D(x: 550000.0, y: 5000000.0, projection: .epsg3857),
Coordinate3D(x: 550000.0, y: 5100000.0, projection: .epsg3857),
Coordinate3D(x: 500000.0, y: 5100000.0, projection: .epsg3857),
Coordinate3D(x: 500000.0, y: 5000000.0, projection: .epsg3857),
]]))
let mpoly = try #require(MultiPolygon([poly0, poly1]))
let feature = Feature(mpoly, id: .int(1))

// Create a VectorTile at z7 with EPSG:3857 (same as PostGIS)
var tile = try VectorTile(x: 65, y: 47, z: 7, projection: .epsg3857)
tile.appendFeatures([feature], to: "landuse_a")

let options = VectorTile.ExportOptions(
bufferSize: .pixel(32),
compression: .level(9),
simplifyFeatures: .no)

// Encode to both formats using the SAME tile
let mvtData = try #require(tile.mvtData(options: options))
let mltData = try #require(tile.mltData(options: options))

// Decode MVT and check (MVT flattens rings, decoder uses winding order)
let mvtTile = try VectorTile(mvtData: mvtData, x: 65, y: 47, z: 7, projection: .epsg4326)
let mvtFeatures = mvtTile.features(for: "landuse_a")
#expect(mvtFeatures.count == 1)

// Decode MLT and check — MLT must preserve the explicit polygon grouping
let mltLayers = try MLTDecoder.decode(from: mltData, x: 65, y: 47, z: 7, projection: .epsg4326)
let mltFeatures = try #require(mltLayers["landuse_a"]?.features)
#expect(mltFeatures.count == 1)
let mltMpoly = try #require(mltFeatures[0].geometry as? MultiPolygon)
#expect(mltMpoly.coordinates.count == 2)
#expect(mltMpoly.coordinates[0].count == 2) // exterior + hole
#expect(mltMpoly.coordinates[1].count == 1) // exterior only
}

}
Loading