Skip to content

Commit 207a646

Browse files
committed
#16: Implement buffered(by:...)
1 parent c1321ed commit 207a646

File tree

10 files changed

+363
-25
lines changed

10 files changed

+363
-25
lines changed

Package.swift

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,7 @@
1-
// swift-tools-version:5.10
1+
// swift-tools-version:6.0
22

33
import PackageDescription
44

5-
let swiftSettings: [SwiftSetting] = [
6-
.enableExperimentalFeature("StrictConcurrency"), // 5.10
7-
.enableUpcomingFeature("StrictConcurrency"), // 6.0
8-
.enableUpcomingFeature("InferSendableFromCaptures"), // Silences a Sendable warning
9-
]
10-
115
let package = Package(
126
name: "gis-tools",
137
platforms: [
@@ -25,12 +19,10 @@ let package = Package(
2519
],
2620
targets: [
2721
.target(
28-
name: "GISTools",
29-
swiftSettings: swiftSettings),
22+
name: "GISTools"),
3023
.testTarget(
3124
name: "GISToolsTests",
3225
dependencies: ["GISTools"],
33-
exclude: ["TestData"],
34-
swiftSettings: swiftSettings),
26+
exclude: ["TestData"]),
3527
]
3628
)

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -840,7 +840,7 @@ Hint: Most algorithms are optimized for EPSG:4326. Using other projections will
840840
| boolean-point-on-line | `lineString.checkIsOnLine(Coordinate3D(…))` | | [Source][57] |
841841
| boolean-valid | `anyGeometry.isValid` | | [Source][58] |
842842
| bbox-clip | `let clipped = lineString.clipped(to: boundingBox)` | | [Source][59] / [Tests][60] |
843-
| buffer | TODO | | [Source][61] |
843+
| buffer | `let buffered = lineString.buffered(by: 1000.meters)` | | [Source][61] |
844844
| center/centroid/center-mean | `let center = polygon.center` | | [Source][62] |
845845
| circle | `let circle = point.circle(radius: 5000.0)` | | [Source][63] / [Tests][64] |
846846
| conversions/helpers | `let distance = GISTool.convert(length: 1.0, from: .miles, to: .meters)` | | [Source][65] |

Sources/GISTools/Algorithms/Buffer.swift

Lines changed: 201 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,205 @@ import CoreLocation
33
#endif
44
import Foundation
55

6-
// TODO: Port from https://github.com/Turfjs/turf/blob/master/packages/turf-buffer
7-
// and https://github.com/DenisCarriere/turf-jsts/tree/master/src/org/locationtech/jts/operation/buffer
6+
/// Line end styles for ```GeoJson.buffered(by:lineEndStyle:steps:formUnion:)```.
7+
public enum LineEndStyle {
88

9-
// TODO
9+
/// Line ends will be flat.
10+
case flat
11+
12+
/// Line ends will be rounded.
13+
case round
14+
15+
}
16+
17+
extension GeoJson {
18+
19+
// TODO: Antimeridian Cutting
20+
// TODO: formUnion (= dissolve)
21+
22+
/// Returns the receiver with a buffer.
23+
///
24+
/// - Parameters:
25+
/// - distance: The buffer distance, in meters
26+
/// - lineCapStyle: Controls how line ends will be drawn (default round)
27+
/// - steps: The number of steps for the circles (default 64)
28+
/// - formUnion: Whether to combine all overlapping buffers into one Polygon (default true)
29+
public func buffered(
30+
by distance: Double,
31+
lineEndStyle: LineEndStyle = .round,
32+
steps: Int = 64,
33+
formUnion: Bool = true
34+
) -> MultiPolygon? {
35+
guard distance > 0.0 else { return nil }
36+
37+
switch self {
38+
// Point
39+
case let point as Point:
40+
guard let circle = point.circle(radius: distance, steps: steps) else { return nil }
41+
return MultiPolygon([circle])
42+
43+
// MultiPoint
44+
case let multiPoint as MultiPoint:
45+
let circles = multiPoint
46+
.points
47+
.compactMap({ $0.circle(radius: distance, steps: steps) })
48+
guard circles.isNotEmpty else { return nil }
49+
50+
// TODO: formUnion
51+
52+
return MultiPolygon(circles)
53+
54+
// LineString
55+
case let lineString as LineString:
56+
let bufferedSegments = lineString
57+
.lineSegments
58+
.compactMap({ $0.buffered(by: distance, lineEndStyle: .flat)?.polygons.first })
59+
guard var multiPolygon = MultiPolygon(bufferedSegments) else { return nil }
60+
61+
var bufferCoordinates = lineString.coordinates
62+
guard bufferCoordinates.count >= 2 else { return multiPolygon }
63+
64+
if lineEndStyle == .flat {
65+
bufferCoordinates.removeFirst()
66+
bufferCoordinates.removeLast()
67+
}
68+
69+
for coordinate in bufferCoordinates {
70+
guard let circle = coordinate.circle(radius: distance, steps: steps) else { continue }
71+
multiPolygon.appendPolygon(circle)
72+
}
73+
74+
// TODO: formUnion
75+
76+
return multiPolygon
77+
78+
// MultiLineString
79+
case let multiLineString as MultiLineString:
80+
let bufferedLines = multiLineString
81+
.lineStrings
82+
.compactMap({ $0.buffered(by: distance, lineEndStyle: lineEndStyle, steps: steps, formUnion: formUnion) })
83+
guard bufferedLines.isNotEmpty else { return nil }
84+
85+
// TODO: formUnion
86+
87+
return MultiPolygon(bufferedLines.map(\.polygons).flatMap({ $0 }))
88+
89+
// Polygon
90+
case let polygon as Polygon:
91+
let bufferCoordinates = polygon.allCoordinates
92+
let bufferedSegments = polygon
93+
.lineSegments
94+
.compactMap({
95+
$0.buffered(by: distance, lineEndStyle: .flat)?.polygons.first
96+
})
97+
guard bufferCoordinates.count >= 2,
98+
var multiPolygon = MultiPolygon(bufferedSegments)
99+
else { return nil }
100+
101+
for coordinate in bufferCoordinates {
102+
guard let circle = coordinate.circle(radius: distance, steps: steps) else { continue }
103+
multiPolygon.appendPolygon(circle)
104+
}
105+
106+
multiPolygon.appendPolygon(polygon)
107+
108+
// TODO: formUnion
109+
110+
return multiPolygon
111+
112+
// MultiPolygon
113+
case let multiPolygon as MultiPolygon:
114+
let bufferedPolygons = multiPolygon
115+
.polygons
116+
.compactMap({ $0.buffered(by: distance, lineEndStyle: lineEndStyle, steps: steps, formUnion: formUnion) })
117+
guard bufferedPolygons.isNotEmpty else { return nil }
118+
119+
// TODO: formUnion
120+
121+
return MultiPolygon(bufferedPolygons.map(\.polygons).flatMap({ $0 }))
122+
123+
// GeometryCollection
124+
case let geometryCollection as GeometryCollection:
125+
let bufferPolygons = geometryCollection
126+
.geometries
127+
.compactMap({
128+
$0.buffered(by: distance, lineEndStyle: lineEndStyle, steps: steps, formUnion: formUnion)?.polygons
129+
})
130+
.flatMap({ $0 })
131+
guard bufferPolygons.isNotEmpty else { return nil }
132+
133+
// TODO: formUnion
134+
135+
return MultiPolygon(bufferPolygons)
136+
137+
// Feature
138+
case let feature as Feature:
139+
return feature.geometry.buffered(by: distance, lineEndStyle: lineEndStyle, steps: steps, formUnion: formUnion)
140+
141+
// FeatureCollection
142+
case let featureCollection as FeatureCollection:
143+
let bufferPolygons = featureCollection
144+
.features
145+
.compactMap({
146+
$0.geometry.buffered(by: distance, lineEndStyle: lineEndStyle, steps: steps, formUnion: formUnion)?.polygons
147+
})
148+
.flatMap({ $0 })
149+
guard bufferPolygons.isNotEmpty else { return nil }
150+
151+
// TODO: formUnion
152+
153+
return MultiPolygon(bufferPolygons)
154+
155+
// Can't happen
156+
default:
157+
return nil
158+
}
159+
}
160+
161+
}
162+
163+
extension LineSegment {
164+
165+
/// Returns the line segment with a buffer.
166+
///
167+
/// - Parameters:
168+
/// - distance: The buffer distance, in meters
169+
/// - lineCapStyle: Controls how line ends will be drawn (default round)
170+
/// - steps: The number of steps for the circles (default 64)
171+
/// - formUnion: Whether to combine all overlapping buffers into one Polygon (default true)
172+
public func buffered(
173+
by distance: Double,
174+
lineEndStyle: LineEndStyle = .round,
175+
steps: Int = 64,
176+
formUnion: Bool = true
177+
) -> MultiPolygon? {
178+
guard distance > 0.0 else { return nil }
179+
180+
let firstBearing = self.bearing
181+
let leftBearing = (firstBearing - 90.0).truncatingRemainder(dividingBy: 360.0)
182+
let rightBearing = (firstBearing + 90.0).truncatingRemainder(dividingBy: 360.0)
183+
184+
let corners = [
185+
first.destination(distance: distance, bearing: leftBearing),
186+
second.destination(distance: distance, bearing: leftBearing),
187+
second.destination(distance: distance, bearing: rightBearing),
188+
first.destination(distance: distance, bearing: rightBearing),
189+
first.destination(distance: distance, bearing: leftBearing),
190+
]
191+
192+
guard var multiPolygon = MultiPolygon([[corners]]) else { return nil }
193+
194+
if lineEndStyle == .round,
195+
let firstCircle = first.circle(radius: distance, steps: steps),
196+
let secondCircle = second.circle(radius: distance, steps: steps)
197+
{
198+
multiPolygon.appendPolygon(firstCircle)
199+
multiPolygon.appendPolygon(secondCircle)
200+
}
201+
202+
// TODO: formUnion
203+
204+
return multiPolygon
205+
}
206+
207+
}

Sources/GISTools/Algorithms/Circle.swift

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,8 @@ extension Coordinate3D {
1414
/// - steps: The number of steps (default 64)
1515
public func circle(
1616
radius: CLLocationDistance,
17-
steps: Int = 64)
18-
-> Polygon?
19-
{
17+
steps: Int = 64
18+
) -> Polygon? {
2019
guard radius > 0.0, steps > 1 else { return nil }
2120

2221
var coordinates: [Coordinate3D] = []
@@ -39,9 +38,8 @@ extension Point {
3938
/// - steps: The number of steps (default 64)
4039
public func circle(
4140
radius: CLLocationDistance,
42-
steps: Int = 64)
43-
-> Polygon?
44-
{
41+
steps: Int = 64
42+
) -> Polygon? {
4543
coordinate.circle(radius: radius, steps: steps)
4644
}
4745

Sources/GISTools/Algorithms/LineSegments.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,18 @@ extension BoundingBox {
1717

1818
}
1919

20+
extension Ring {
21+
22+
/// Returns the receiver as *LineSegment*s.
23+
public var lineSegments: [LineSegment] {
24+
coordinates.overlappingPairs().compactMap { (first, second, index) in
25+
guard let second else { return nil }
26+
return LineSegment(first: first, second: second, index: index)
27+
}
28+
}
29+
30+
}
31+
2032
extension GeoJson {
2133

2234
/// Returns line segments for the geometry.

Sources/GISTools/Algorithms/TransformCoordinates.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ extension GeoJson {
88
/// Returns a new geometry with all coordinates transformed by the given function.
99
///
1010
/// - Parameter transform: The transformation function
11-
public func transformedCoordinates(_ transform: (Coordinate3D) -> Coordinate3D) -> Self {
11+
public func transformedCoordinates(
12+
_ transform: (Coordinate3D) -> Coordinate3D
13+
) -> Self {
1214
switch self {
1315
case let point as Point:
1416
var newPoint = Point(

Sources/GISTools/Algorithms/TransformScale.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import CoreLocation
33
#endif
44
import Foundation
55

6-
// MARK: - ScaleAnchor
6+
// MARK: ScaleAnchor
77

88
// Ported from https://github.com/Turfjs/turf/blob/master/packages/turf-transform-scale
99

Sources/GISTools/GeoJson/MultiPolygon.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ public struct MultiPolygon:
6262

6363
/// Try to initialize a MultiPolygon with some Polygons.
6464
public init?(_ polygons: [Polygon], calculateBoundingBox: Bool = false) {
65-
guard !polygons.isEmpty else { return nil }
65+
guard polygons.isNotEmpty else { return nil }
6666

6767
self.init(unchecked: polygons, calculateBoundingBox: calculateBoundingBox)
6868
}

TODO.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ represented as a MultiPolygon.
5353
}
5454
````
5555
56-
### 5. Bounding Box
56+
### 5. Bounding Box
5757
5858
```
5959
Example of a 3D bbox member with a depth of 100 meters:

0 commit comments

Comments
 (0)