Skip to content
Draft
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -840,7 +840,7 @@ Hint: Most algorithms are optimized for EPSG:4326. Using other projections will
| boolean-point-on-line | `lineString.checkIsOnLine(Coordinate3D(…))` | | [Source][57] |
| boolean-valid | `anyGeometry.isValid` | | [Source][58] |
| bbox-clip | `let clipped = lineString.clipped(to: boundingBox)` | | [Source][59] / [Tests][60] |
| buffer | TODO | | [Source][61] |
| buffer | `let buffered = lineString.buffered(by: 1000.meters)` | | [Source][61] |
| center/centroid/center-mean | `let center = polygon.center` | | [Source][62] |
| circle | `let circle = point.circle(radius: 5000.0)` | | [Source][63] / [Tests][64] |
| conversions/helpers | `let distance = GISTool.convert(length: 1.0, from: .miles, to: .meters)` | | [Source][65] |
Expand Down
277 changes: 274 additions & 3 deletions Sources/GISTools/Algorithms/Buffer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,278 @@ import CoreLocation
#endif
import Foundation

// TODO: Port from https://github.com/Turfjs/turf/blob/master/packages/turf-buffer
// and https://github.com/DenisCarriere/turf-jsts/tree/master/src/org/locationtech/jts/operation/buffer
/// Line end styles for GeoJSON buffer.
public enum BufferLineEndStyle: Sendable {

// TODO
/// Line ends will be flat.
case flat

/// Line ends will be rounded.
case round

}

/// Options for how to form an union of polygons.
public enum BufferUnionType: Sendable {

/// Don't form a union from all geometries that make a buffer.
case none

/// Combine the buffered parts for each input geometry into one Polygon.
case individual

/// Combine all overlapping buffered geometries.
case overlapping

}

extension GeoJson {

// TODO: Antimeridian Cutting
// TODO: formUnion
// TODO: Negative distance?

/// Returns the receiver with a buffer.
///
/// - Parameters:
/// - distance: The buffer distance, in meters
/// - lineEndStyle: Controls how line ends will be drawn (default round)
/// - unionType: How to combine buffered geometries (default individual)
/// - steps: The number of steps for the circles (default 64)
public func buffered(
by distance: Double,
lineEndStyle: BufferLineEndStyle = .round,
unionType: BufferUnionType = .individual,
steps: Int = 64
) -> MultiPolygon? {
guard distance > 0.0 else { return nil }

switch self {
// Point
case let point as Point:
guard let circle = point.circle(radius: distance, steps: steps) else { return nil }
return MultiPolygon([circle])

// MultiPoint
case let multiPoint as MultiPoint:
let bufferedPoints = multiPoint
.points
.compactMap({ $0.circle(radius: distance, steps: steps) })
guard bufferedPoints.isNotEmpty else { return nil }

if unionType == .overlapping {
return UnionHelper.union(polygons: bufferedPoints)
}

return MultiPolygon(bufferedPoints)

// LineString
case let lineString as LineString:
var polygons = lineString
.lineSegments
.compactMap({
$0.buffered(
by: distance,
lineEndStyle: .flat,
unionType: .none)?
.polygons
.first
})

var bufferCoordinates = lineString.coordinates
guard bufferCoordinates.count >= 2 else {
return MultiPolygon(polygons)
}

if lineEndStyle == .flat {
bufferCoordinates.removeFirst()
bufferCoordinates.removeLast()
}

for coordinate in bufferCoordinates {
guard let circle = coordinate.circle(radius: distance, steps: steps) else { continue }
polygons.append(circle)
}

if unionType.isIn([.individual, .overlapping]) {
return UnionHelper.union(polygons: polygons)
}

return MultiPolygon(polygons)

// MultiLineString
case let multiLineString as MultiLineString:
let bufferedLineStrings = multiLineString
.lineStrings
.compactMap({
$0.buffered(
by: distance,
lineEndStyle: lineEndStyle,
unionType: unionType,
steps: steps)
})
.map(\.polygons)
.flatMap({ $0 })
guard bufferedLineStrings.isNotEmpty else { return nil }

if unionType == .overlapping {
return UnionHelper.union(polygons: bufferedLineStrings)
}

return MultiPolygon(bufferedLineStrings)

// Polygon
case let polygon as Polygon:
let bufferCoordinates = polygon.allCoordinates
guard bufferCoordinates.count >= 2 else { return nil }

var polygons = polygon
.lineSegments
.compactMap({
$0.buffered(
by: distance,
lineEndStyle: .flat,
unionType: .none)?
.polygons
.first
})

for coordinate in bufferCoordinates {
guard let circle = coordinate.circle(radius: distance, steps: steps) else { continue }
polygons.append(circle)
}

polygons.append(polygon)

if unionType.isIn([.individual, .overlapping]) {
return UnionHelper.union(polygons: polygons)
}

return MultiPolygon(polygons)

// MultiPolygon
case let multiPolygon as MultiPolygon:
let bufferedPolygons = multiPolygon
.polygons
.compactMap({
$0.buffered(
by: distance,
lineEndStyle: lineEndStyle,
unionType: unionType,
steps: steps)
})
.map(\.polygons)
.flatMap({ $0 })
guard bufferedPolygons.isNotEmpty else { return nil }

if unionType == .overlapping {
return UnionHelper.union(polygons: bufferedPolygons)
}

return MultiPolygon(bufferedPolygons)

// GeometryCollection
case let geometryCollection as GeometryCollection:
let bufferedPolygons = geometryCollection
.geometries
.compactMap({
$0.buffered(
by: distance,
lineEndStyle: lineEndStyle,
unionType: unionType,
steps: steps)?
.polygons
})
.flatMap({ $0 })
guard bufferedPolygons.isNotEmpty else { return nil }

if unionType == .overlapping {
return UnionHelper.union(polygons: bufferedPolygons)
}

return MultiPolygon(bufferedPolygons)

// Feature
case let feature as Feature:
return feature.geometry.buffered(
by: distance,
lineEndStyle: lineEndStyle,
unionType: unionType,
steps: steps)

// FeatureCollection
case let featureCollection as FeatureCollection:
let bufferedPolygons = featureCollection
.features
.compactMap({
$0.geometry.buffered(
by: distance,
lineEndStyle: lineEndStyle,
unionType: unionType,
steps: steps)?
.polygons
})
.flatMap({ $0 })
guard bufferedPolygons.isNotEmpty else { return nil }

if unionType == .overlapping {
return UnionHelper.union(polygons: bufferedPolygons)
}

return MultiPolygon(bufferedPolygons)

// Can't happen
default:
return nil
}
}

}

extension LineSegment {

/// Returns the line segment with a buffer.
///
/// - Parameters:
/// - distance: The buffer distance, in meters
/// - lineEndStyle: Controls how line ends will be drawn (default round)
/// - unionType: Whether to combine all overlapping buffers into one Polygon (default true)
/// - steps: The number of steps for the circles (default 64)
public func buffered(
by distance: Double,
lineEndStyle: BufferLineEndStyle = .round,
unionType: BufferUnionType = .individual,
steps: Int = 64
) -> MultiPolygon? {
guard distance > 0.0 else { return nil }

let firstBearing = self.bearing
let leftBearing = (firstBearing - 90.0).truncatingRemainder(dividingBy: 360.0)
let rightBearing = (firstBearing + 90.0).truncatingRemainder(dividingBy: 360.0)

let corners = [
first.destination(distance: distance, bearing: leftBearing),
second.destination(distance: distance, bearing: leftBearing),
second.destination(distance: distance, bearing: rightBearing),
first.destination(distance: distance, bearing: rightBearing),
first.destination(distance: distance, bearing: leftBearing),
]

var polygons = [Polygon([corners])!]

if lineEndStyle == .round,
let firstCircle = first.circle(radius: distance, steps: steps),
let secondCircle = second.circle(radius: distance, steps: steps)
{
polygons.append(firstCircle)
polygons.append(secondCircle)
}

if unionType == .none {
return MultiPolygon(polygons)
}

return UnionHelper.union(polygons: polygons)
}

}
45 changes: 45 additions & 0 deletions Sources/GISTools/Algorithms/Dissolve.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
#if canImport(CoreLocation)
import CoreLocation
#endif
import Foundation

extension FeatureCollection {

private static let unknownValuePlaceholder: String = UUID().uuidString

/// Creates a new FeatureCollection where all (Multi)Polygon features with the same property value
/// are union'ed together. Features that are not (Multi)Polygon are removed from the result.
///
/// This currently works only for properties with a String value.
///
/// - Parameters:
/// - property: The `property` name with which the Features should be divided
/// - removeUnknown: Whether to remove features without the property from the result
public func dissolved(
by property: String,
removeUnknown: Bool = false
) -> FeatureCollection {
let dividedFeatures = divideFeatures { feature in
guard feature.type.isIn([.polygon, .multiPolygon]) else { return nil }

let value: String? = feature.property(for: property)
if value == nil, removeUnknown {
return nil
}
return value ?? Self.unknownValuePlaceholder // Improve this
}

var result: [Feature] = []
for (key, features) in dividedFeatures {
let polygons: [PolygonGeometry] = features.compactMap({ $0.geometry as? PolygonGeometry })
let union = UnionHelper.union(polygons: polygons)
let properties: [String: Sendable] = key == Self.unknownValuePlaceholder
? [:]
: [property: key]
result.append(Feature(union, properties: properties))
}

return FeatureCollection(result)
}

}
12 changes: 12 additions & 0 deletions Sources/GISTools/Algorithms/LineSegments.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,18 @@ extension BoundingBox {

}

extension Ring {

/// Returns the receiver as *LineSegment*s.
public var lineSegments: [LineSegment] {
coordinates.overlappingPairs().compactMap { (first, second, index) in
guard let second else { return nil }
return LineSegment(first: first, second: second, index: index)
}
}

}

extension GeoJson {

/// Returns line segments for the geometry.
Expand Down
36 changes: 36 additions & 0 deletions Sources/GISTools/Algorithms/PolygonUnion.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
#if canImport(CoreLocation)
import CoreLocation
#endif
import Foundation

extension Polygon {

public func union(_ other: PolygonGeometry) -> MultiPolygon {
UnionHelper.union(polygons: [self, other])
}

}

extension MultiPolygon {

public func union(_ other: PolygonGeometry) -> MultiPolygon {
UnionHelper.union(polygons: [self, other])
}

public mutating func formUnion(_ other: PolygonGeometry) {
self = union(other)
}

}

struct UnionHelper {

static func union(polygons: [PolygonGeometry]) -> MultiPolygon {
assert(polygons.isNotEmpty, "Input polygons must not be empty")

let inputPolygons = polygons.map(\.polygons).flatMap({ $0 })

return MultiPolygon(inputPolygons)!
}

}
Loading