From c8eb55a483a8e22f5f49cec74e64405d83206501 Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Sat, 10 Dec 2022 17:04:42 +1100 Subject: [PATCH 1/6] Adds equivalents of turf-line-chunk Also: - GeoJSON.LineString.length - GeoJSON.Polygon.LinearRing.circumference --- README.md | 1 + Sources/GeoJSONKitTurf/Position+Helpers.swift | 5 + Sources/GeoJSONKitTurf/Turf+LineString.swift | 158 +++++++++++------- Sources/GeoJSONKitTurf/Turf+LinearRing.swift | 10 ++ 4 files changed, 118 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index 362cc35..e8210c9 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ Turf.js | GeoJSONKit-Turf [turf-helpers#radiansToDegrees](https://github.com/Turfjs/turf/tree/master/packages/turf-helpers/#radiansToDegrees) | `GeoJSON.DegreesRadians.toDegrees()` [turf-helpers#convertLength](https://github.com/Turfjs/turf/tree/master/packages/turf-helpers#convertlength)
[turf-helpers#convertArea](https://github.com/Turfjs/turf/tree/master/packages/turf-helpers#convertarea) | `Measurement.converted(to:)` [turf-length](https://github.com/Turfjs/turf/tree/master/packages/turf-length/) | `GeoJSON.LineString.distance(from:to:)` +[turf-line-chunk](http://turfjs.org/docs/#lineChunk) | `GeoJSON.LineString.chunked(length:)`
`GeoJSON.Polygon.LinearRing.chunked(length:)` | [turf-line-intersect](https://github.com/Turfjs/turf/tree/master/packages/turf-line-intersect/) | `GeoJSON.LineString.intersection(with:)` [turf-line-slice](https://github.com/Turfjs/turf/tree/master/packages/turf-line-slice/) | `GeoJSON.LineString.sliced(from:to:)` [turf-line-slice-along](https://github.com/Turfjs/turf/tree/master/packages/turf-line-slice-along/) | `GeoJSON.LineString.trimmed(from:distance:)`
`GeoJSON.LineString.trimmed(from:to:)` diff --git a/Sources/GeoJSONKitTurf/Position+Helpers.swift b/Sources/GeoJSONKitTurf/Position+Helpers.swift index ed69dac..f36abdb 100644 --- a/Sources/GeoJSONKitTurf/Position+Helpers.swift +++ b/Sources/GeoJSONKitTurf/Position+Helpers.swift @@ -17,4 +17,9 @@ extension GeoJSON.Position { return dx * dx + dy * dy } + static func length(of positions: [GeoJSON.Position]) -> GeoJSON.Distance { + let pairs = zip(positions.prefix(upTo: positions.count - 1), positions.suffix(from: 1)) + return pairs.map { $0.distance(to: $1) }.reduce(0, +) + } + } diff --git a/Sources/GeoJSONKitTurf/Turf+LineString.swift b/Sources/GeoJSONKitTurf/Turf+LineString.swift index 3a7ad7b..7ace302 100644 --- a/Sources/GeoJSONKitTurf/Turf+LineString.swift +++ b/Sources/GeoJSONKitTurf/Turf+LineString.swift @@ -6,11 +6,16 @@ extension GeoJSON.LineString { var coordinates: [GeoJSON.Position] { positions } /** - Representation of current `LineString` as an array of `LineSegment`s. - */ - var segments: [LineSegment] { - return zip(coordinates.dropLast(), coordinates.dropFirst()).map { LineSegment($0.0, $0.1) } - } + Representation of current `LineString` as an array of `LineSegment`s. + */ + var segments: [LineSegment] { + return zip(coordinates.dropLast(), coordinates.dropFirst()).map { LineSegment($0.0, $0.1) } + } + + /// The length of the line, in metres + public var length: GeoJSON.Distance { + GeoJSON.Position.length(of: positions) + } /// Returns a new line string based on bezier transformation of the input line. /// @@ -34,56 +39,9 @@ extension GeoJSON.LineString { This method is equivalent to the [turf-line-slice-along](https://turfjs.org/docs/#lineSliceAlong) package of Turf.js ([source code](https://github.com/Turfjs/turf/tree/master/packages/turf-line-slice-along/)). */ public func trimmed(from startDistance: GeoJSON.Distance, to stopDistance: GeoJSON.Distance) -> GeoJSON.LineString? { - // The method is porting from https://github.com/Turfjs/turf/blob/5375941072b90d489389db22b43bfe809d5e451e/packages/turf-line-slice-along/index.js - guard startDistance >= 0.0 && stopDistance >= startDistance else { return nil } - let positions = self.coordinates - var traveled: GeoJSON.Distance = 0 - var slice = [GeoJSON.Position]() - - for i in 0..= traveled && i == positions.endIndex - 1 { - break - } else if traveled > startDistance && slice.isEmpty { - let overshoot = startDistance - traveled - if overshoot == 0.0 { - slice.append(positions[i]) - return GeoJSON.LineString(positions: slice) - } - let direction = positions[i].direction(to: positions[i - 1]) - 180 - let interpolated = positions[i].coordinate(at: overshoot, facing: direction) - slice.append(interpolated) - } - - if traveled >= stopDistance { - let overshoot = stopDistance - traveled - if overshoot == 0.0 { - slice.append(positions[i]) - return GeoJSON.LineString(positions: slice) - } - let direction = positions[i].direction(to: positions[i - 1]) - 180 - let interpolated = positions[i].coordinate(at: overshoot, facing: direction) - slice.append(interpolated) - return GeoJSON.LineString(positions: slice) - } - - if traveled >= startDistance { - slice.append(positions[i]) - } - - if i == positions.count - 1 { - return GeoJSON.LineString(positions: slice) - } - - traveled += positions[i].distance(to: positions[i + 1]) - } - - if traveled < startDistance { return nil } - - if let last = positions.last { - return GeoJSON.LineString(positions: [last, last]) - } - - return nil + let trimmed = Self.trimmed(positions, from: startDistance, to: stopDistance) + guard !trimmed.isEmpty else { return nil } + return .init(positions: trimmed) } /// Returns the portion of the line string that begins at the given coordinate and extends the given distance along the line string. @@ -217,7 +175,7 @@ extension GeoJSON.LineString { public func closestCoordinate(to coordinate: GeoJSON.Position) -> IndexedCoordinate? { .findClosest(to: coordinate, on: positions) } - + /** Returns all intersections with another `LineString`. @@ -235,5 +193,93 @@ extension GeoJSON.LineString { } return intersections } +} + +// MARK: - Turf-Slice + +extension GeoJSON.LineString { + + /// Divides a ``GeoJSON.LineString`` into chunks of a specified length. + /// If the line is shorter than the segment length then the original line is returned. + public func chunked(length: GeoJSON.Distance) -> GeoJSON.LineString { + return .init(positions: GeoJSON.LineString.sliceLineSegments(positions, length: length)) + } + // Ported from https://github.com/Turfjs/turf/blob/master/packages/turf-line-chunk/index.js + static func sliceLineSegments(_ positions: [GeoJSON.Position], length: GeoJSON.Distance) -> [GeoJSON.Position] { + let lineLength = GeoJSON.Position.length(of: positions) + + // If the line is shorter than the segment length then the orginal line is returned. + if lineLength <= length { + return positions + } + + let segmentProportion = lineLength / length + var segmentCount = Int(segmentProportion) + if Double(segmentCount) < segmentProportion { + segmentCount += 1 + } + + return (0.. [GeoJSON.Position] { + guard startDistance >= 0.0, stopDistance >= startDistance else { return [] } + var traveled: GeoJSON.Distance = 0 + var slice = [GeoJSON.Position]() + + for i in 0..= traveled && i == positions.endIndex - 1 { + break + } else if traveled > startDistance && slice.isEmpty { + let overshoot = startDistance - traveled + if overshoot == 0.0 { + slice.append(positions[i]) + return slice + } + let direction = positions[i].direction(to: positions[i - 1]) - 180 + let interpolated = positions[i].coordinate(at: overshoot, facing: direction) + slice.append(interpolated) + } + + if traveled >= stopDistance { + let overshoot = stopDistance - traveled + if overshoot == 0.0 { + slice.append(positions[i]) + return slice + } + let direction = positions[i].direction(to: positions[i - 1]) - 180 + let interpolated = positions[i].coordinate(at: overshoot, facing: direction) + slice.append(interpolated) + return slice + } + + if traveled >= startDistance { + slice.append(positions[i]) + } + + if i == positions.count - 1 { + return slice + } + + traveled += positions[i].distance(to: positions[i + 1]) + } + + if traveled < startDistance { return [] } + + if let last = positions.last { + return [last, last] + } + + return [] + } + } diff --git a/Sources/GeoJSONKitTurf/Turf+LinearRing.swift b/Sources/GeoJSONKitTurf/Turf+LinearRing.swift index 8f2992a..acb1c57 100644 --- a/Sources/GeoJSONKitTurf/Turf+LinearRing.swift +++ b/Sources/GeoJSONKitTurf/Turf+LinearRing.swift @@ -7,6 +7,10 @@ import GeoJSONKit */ extension GeoJSON.Polygon.LinearRing { + public var circumference: GeoJSON.Distance { + GeoJSON.Position.length(of: positions) + } + /** * Calculate the approximate area of the polygon were it projected onto the earth, in square meters. * Note that this area will be positive if ring is oriented clockwise, otherwise it will be negative. @@ -124,5 +128,11 @@ extension GeoJSON.Polygon.LinearRing { func closestPosition(to coordinate: GeoJSON.Position) -> GeoJSON.Position? { GeoJSON.LineString.IndexedCoordinate.findClosest(to: coordinate, on: positions)?.coordinate } + + /// Divides a ``GeoJSON.Polygon.LinearRing`` into chunks of a specified length. + /// If the line is shorter than the segment length then the original line is returned. + public func chunked(length: GeoJSON.Distance) -> GeoJSON.Polygon.LinearRing { + return .init(positions: GeoJSON.LineString.sliceLineSegments(positions, length: length)) + } } From 3bd416e7500c7233bb92b18d46876ccc3d022c5c Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Sat, 10 Dec 2022 17:20:01 +1100 Subject: [PATCH 2/6] Potential help for Linux --- Sources/GeoJSONKitTurf/Turf+LineString.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Sources/GeoJSONKitTurf/Turf+LineString.swift b/Sources/GeoJSONKitTurf/Turf+LineString.swift index 7ace302..5e6856e 100644 --- a/Sources/GeoJSONKitTurf/Turf+LineString.swift +++ b/Sources/GeoJSONKitTurf/Turf+LineString.swift @@ -222,11 +222,12 @@ extension GeoJSON.LineString { return (0.. Date: Sat, 10 Dec 2022 17:26:43 +1100 Subject: [PATCH 3/6] Come on, Linux... --- Sources/GeoJSONKitTurf/Turf+LineString.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Sources/GeoJSONKitTurf/Turf+LineString.swift b/Sources/GeoJSONKitTurf/Turf+LineString.swift index 5e6856e..2510bff 100644 --- a/Sources/GeoJSONKitTurf/Turf+LineString.swift +++ b/Sources/GeoJSONKitTurf/Turf+LineString.swift @@ -221,13 +221,17 @@ extension GeoJSON.LineString { } return (0.. [GeoJSON.Position] in let trimmed = Self.trimmed( positions, from: length * Double(i), to: length * Double(i + 1) ) - return trimmed.dropFirst(i == 0 ? 0 : 1) + if i == 0 { + return trimmed + } else { + return Array(trimmed.dropFirst()) + } } } From 45b3ce670d4e665778db925bc0ac364cb2a44a8f Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Sat, 25 Nov 2023 11:46:47 +1100 Subject: [PATCH 4/6] Add tests --- .../GeoJSONKitTurfTests/LineStringTests.swift | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/Tests/GeoJSONKitTurfTests/LineStringTests.swift b/Tests/GeoJSONKitTurfTests/LineStringTests.swift index 2696640..ef4cf2f 100644 --- a/Tests/GeoJSONKitTurfTests/LineStringTests.swift +++ b/Tests/GeoJSONKitTurfTests/LineStringTests.swift @@ -205,6 +205,56 @@ class LineStringTests: XCTestCase { indexLineString.coordinates[3].distance(to: indexLineString.coordinates.first!)) } + func testChunk() throws { + // Adapted from https://github.com/Turfjs/turf/blob/master/packages/turf-line-chunk/test.js + let input = try GeoJSON(geoJSONString: """ + { + "type": "LineString", + "coordinates": [ + [-86.28524780273438, 40.250184183819854], + [-85.98587036132812, 40.17887331434696], + [-85.97213745117188, 40.08857859823707], + [-85.77987670898438, 40.15578608609647] + ] + } + """) + + let segmented = try GeoJSON(geoJSONString: """ + { + "type": "LineString", + "coordinates": [ + [-86.2852, 40.2501], + [-86.1947, 40.2287], + [-86.104, 40.2071], + [-86.0138, 40.185], + [-85.985, 40.1788], + [-85.9783, 40.1292], + [-85.9721, 40.0885], + [-85.9347, 40.1016], + [-85.8487, 40.1317], + [-85.7798, 40.155] + ] + } + """) + + guard + case .geometry(.single(.lineString(let lineIn))) = input.object, + case .geometry(.single(.lineString(let lineOut))) = segmented.object + else { return XCTFail() } + + // Chunked by 5 miles should split it into smaller segments + let fiveMiles = 8046.72 + let chunked = lineIn.chunked(length: fiveMiles) + XCTAssertEqual(chunked.positions.count, lineOut.positions.count) + for (offset, pair) in zip(lineOut.positions, chunked.positions).enumerated() { + XCTAssertEqual(pair.0.latitude, pair.1.latitude, accuracy: 0.001, "Failure at: \(offset)") + XCTAssertEqual(pair.0.longitude, pair.1.longitude, accuracy: 0.001, "Failure at: \(offset)") + } + + // Chunked by 50 miles should stay the same + XCTAssertEqual(lineIn.chunked(length: 10 * fiveMiles), lineIn) + } + func testCoordinateFromStart() { // Ported from https://github.com/Turfjs/turf/blob/142e137ce0c758e2825a260ab32b24db0aa19439/packages/turf-along/test.js From 8719e117b0197600748ec84a90a91bb28e86ed99 Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Sat, 25 Nov 2023 11:47:27 +1100 Subject: [PATCH 5/6] README review --- README.md | 13 +++++++++---- Sources/GeoJSONKitTurf/Turf+LineString.swift | 6 ++++++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index be55b3e..ff651ff 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fmaparoni%2FGeoJSONKit-Turf%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/maparoni/GeoJSONKit-Turf) [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fmaparoni%2FGeoJSONKit-Turf%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/maparoni/GeoJSONKit-Turf) -This package provides various geospatial extensions for [GeoJSONKit](https://github.com/maparoni/geojsonkit). It is a fork of [turf-swift](https://github.com/mapbox/turf-swift.git), which is ported from [Turf.js](https://github.com/Turfjs/turf/). +This package provides various geospatial extensions for [GeoJSONKit](https://github.com/maparoni/geojsonkit). It is a fork of [turf-swift](https://github.com/mapbox/turf-swift.git), which itself is a partial Swift-port of [Turf.js](https://github.com/Turfjs/turf/). ## Requirements @@ -13,6 +13,7 @@ GeoJSONKitTurf requires Xcode 14.x and supports the following minimum deployment - iOS 15 and above - macOS 12 and above - tvOS 15 and above +- visionOS 1.0 and above - watchOS 8.0 and above It's also compatible with Linux (and possibly other platforms), as long as you have [Swift](https://swift.org/download/) 5.7 (or above) installed. @@ -24,7 +25,7 @@ It's also compatible with Linux (and possibly other platforms), as long as you h To install GeoJSONKitTurf using the [Swift Package Manager](https://swift.org/package-manager/), add the following package to the `dependencies` in your Package.swift file: ```swift -.package(name: "GeoJSONKitTurf", url: "https://github.com/maparoni/geojsonkit-turf", from: "0.1.0") +.package(name: "GeoJSONKitTurf", url: "https://github.com/maparoni/geojsonkit-turf", from: "0.3.0") ``` Then use: @@ -67,8 +68,12 @@ Turf.js | GeoJSONKit-Turf [turf-polygon-smooth](https://github.com/Turfjs/turf/tree/master/packages/turf-polygon-smooth) | `GeoJSON.Polygon.smooth(iterations:)` [turf-union](https://github.com/Turfjs/turf/tree/master/packages/turf-union) | Not provided, but see [ASPolygonKit](https://github.com/nighthawk/ASPolygonKit) [turf-simplify](https://github.com/Turfjs/turf/tree/master/packages/turf-simplify) | `GeoJSON.simplify(options:)` -— | `GeoJSON.Direction.difference(from:)` -— | `GeoJSON.Direction.wrap(min:max:)` + +Additionally, it adds the following features, which do not have a direct equivalent in turf.js: + +* `GeoJSON.Direction.difference(from:)` +* `GeoJSON.Direction.wrap(min:max:)` +* `GeoJSON.LineString.frechetDistance(to:)`: Determines [Fréchet Distance](https://en.wikipedia.org/wiki/Fréchet_distance) between two line strings, which is a measure of their similarity. ## CLI diff --git a/Sources/GeoJSONKitTurf/Turf+LineString.swift b/Sources/GeoJSONKitTurf/Turf+LineString.swift index b67abd8..0d17c42 100644 --- a/Sources/GeoJSONKitTurf/Turf+LineString.swift +++ b/Sources/GeoJSONKitTurf/Turf+LineString.swift @@ -201,6 +201,12 @@ extension GeoJSON.LineString { /// Divides a ``GeoJSON.LineString`` into chunks of a specified length. /// If the line is shorter than the segment length then the original line is returned. + /// + /// Adopted This function is roughly equivalent to the [turf-line-chunk](https://turfjs.org/docs/#lineChunk) package of Turf.js ([source code](https://github.com/Turfjs/turf/tree/master/packages/turf-line-chunk/)). However, it returns another line string + /// rather than a feature collection. + /// + /// - Parameter length: How long to make each segment, in metres. + /// - Returns: Line string with positions repeating as requested. public func chunked(length: GeoJSON.Distance) -> GeoJSON.LineString { return .init(positions: GeoJSON.LineString.sliceLineSegments(positions, length: length)) } From 1f06d8fd19cd08255e8ba83b2f9c51a857b2902c Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Sat, 25 Nov 2023 17:05:34 +1100 Subject: [PATCH 6/6] Improvements: - New `GeoJSON.GeometryObject(splittingWhenCrossingAntiMeridian:)` helper - Rename `clip(to:)` to `clipped(to:)` - Adds `GeoJSON.LineString.clipped(to:)` - Move algorithms to algorithms folder --- README.md | 5 +- .../GeoJSONKitTurf/Turf+GeometryObject.swift | 84 +++++++++++++++ Sources/GeoJSONKitTurf/Turf+LineString.swift | 6 +- Sources/GeoJSONKitTurf/Turf+LinearRing.swift | 4 + Sources/GeoJSONKitTurf/Turf+Polygon.swift | 93 +--------------- .../{ => algorithms}/Spline.swift | 0 .../algorithms/SutherlandHodgeman.swift | 100 ++++++++++++++++++ .../GeoJSONKitTurfTests/LineStringTests.swift | 54 ++++++++++ Tests/GeoJSONKitTurfTests/PolygonTests.swift | 2 +- 9 files changed, 253 insertions(+), 95 deletions(-) rename Sources/GeoJSONKitTurf/{ => algorithms}/Spline.swift (100%) create mode 100644 Sources/GeoJSONKitTurf/algorithms/SutherlandHodgeman.swift diff --git a/README.md b/README.md index ff651ff..09903f4 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ Turf.js | GeoJSONKit-Turf ----|---- [turf-along](https://github.com/Turfjs/turf/tree/master/packages/turf-along/) | `GeoJSON.LineString.coordinateFromStart(distance:)` [turf-area](https://github.com/Turfjs/turf/blob/master/packages/turf-area/) | `GeoJSON.Polygon.area` -[turf-bbox-clip](https://turfjs.org/docs/#bboxClip) | `GeoJSON.Polygon.clip(to:)` +[turf-bbox-clip](https://turfjs.org/docs/#bboxClip) | `GeoJSON.LineString.clipped(to:)`
`GeoJSON.Polygon.clipped(to:)` [turf-bearing](https://turfjs.org/docs/#bearing) | `GeoJSON.Position.direction(to:)`
`RadianCoordinate2D.direction(to:)` [turf-bezier-spline](https://github.com/Turfjs/turf/tree/master/packages/turf-bezier-spline/) | `GeoJSON.LineString.bezier(resolution:sharpness:)` [turf-boolean-point-in-polygon](https://github.com/Turfjs/turf/tree/master/packages/turf-boolean-point-in-polygon) | `GeoJSON.Polygon.contains(_:)` @@ -73,7 +73,8 @@ Additionally, it adds the following features, which do not have a direct equival * `GeoJSON.Direction.difference(from:)` * `GeoJSON.Direction.wrap(min:max:)` -* `GeoJSON.LineString.frechetDistance(to:)`: Determines [Fréchet Distance](https://en.wikipedia.org/wiki/Fréchet_distance) between two line strings, which is a measure of their similarity. +* `GeoJSON.LineString.frechetDistance(to:)`: Determines the [Fréchet distance](https://en.wikipedia.org/wiki/Fréchet_distance) between two line strings, which is a measure of their similarity. +* `GeoJSON.GeometryObject(splittingWhenCrossingAntiMeridian:)`: Breaks up a LineString or Polygon into two when crossing the anti-meridian. ## CLI diff --git a/Sources/GeoJSONKitTurf/Turf+GeometryObject.swift b/Sources/GeoJSONKitTurf/Turf+GeometryObject.swift index 63dacf7..e3f25c9 100644 --- a/Sources/GeoJSONKitTurf/Turf+GeometryObject.swift +++ b/Sources/GeoJSONKitTurf/Turf+GeometryObject.swift @@ -98,6 +98,7 @@ extension GeoJSON.Geometry { return polygon.nearestPoint(to: position) } } + } extension GeoJSON.GeometryObject { @@ -119,4 +120,87 @@ extension GeoJSON.GeometryObject { return objects.contains(where: { $0.contains(coordinate, ignoreBoundary: ignoreBoundary, checkBoundingBox: checkBoundingBox) }) } } + +} + +// MARK: - Anti-meridian safety + +extension GeoJSON.Geometry { + + func breakUpIfNecessary() -> [GeoJSON.Geometry] { + let bbox = GeoJSON.BoundingBox(positions: positions, allowSpanningAntimeridian: true) + guard bbox.spansAntimeridian else { + return [self] + } + + let normalized = self.wrapped(min: 0, max: 360) + + // Western-most point to anti-meridian + // No need to wrap, as already positive + let easterlyBox = GeoJSON.BoundingBox(positions: [ + .init(latitude: bbox.northEasterlyLatitude, longitude: 180), + .init(latitude: bbox.southWesterlyLatitude, longitude: bbox.southWesterlyLongitude) + ]) + + // Anti-meridian to eastern-mode point + // This needs to be wrapped sa the clipping doesn't work otherwise + let westerlyBox = GeoJSON.BoundingBox(positions: [ + .init(latitude: bbox.northEasterlyLatitude, longitude: bbox.northEasterlyLongitude.wrap(min: 0, max: 360)), + .init(latitude: bbox.southWesterlyLatitude, longitude: 180) + ]) + + let easterly = normalized.clipped(to: easterlyBox) + let westerly = normalized.clipped(to: westerlyBox) + return [ + easterly, + westerly.wrapped(min: -180, max: 180), + ] + } + + private func clipped(to bbox: GeoJSON.BoundingBox) -> GeoJSON.Geometry { + switch self { + case .point: + return self + case .lineString(let line): + return .lineString(line.clipped(to: bbox)) + case .polygon(let polygon): + return .polygon(polygon.clipped(to: bbox)) + } + } + + private func wrapped(min: GeoJSON.Degrees, max: GeoJSON.Degrees) -> GeoJSON.Geometry { + switch self { + case .point: + return self + + case .lineString(let line): + let normalized = line.positions.map { + GeoJSON.Position(latitude: $0.latitude, longitude: $0.longitude.wrap(min: min, max: max)) + } + return .lineString(.init(positions: normalized)) + + case .polygon(let polygon): + let normalized = polygon.exterior.positions.map { + GeoJSON.Position(latitude: $0.latitude, longitude: $0.longitude.wrap(min: min, max: max)) + } + return .polygon(.init(exterior: .init(positions: normalized))) + } + } +} + + +extension GeoJSON.GeometryObject { + + /// Checks the provided geometry, if it crosses the anti-meridian. If it doesn't, it's returned as + /// a `.single`, otherwise it's broken up into two and returned as a `.multi`. + /// + /// - Parameter candidate: A geometry, which might cross the anti-meridian + public init(splittingWhenCrossingAntiMeridian candidate: GeoJSON.Geometry) { + let components = candidate.breakUpIfNecessary() + if components.count == 1, let only = components.first { + self = .single(only) + } else { + self = .multi(components) + } + } } diff --git a/Sources/GeoJSONKitTurf/Turf+LineString.swift b/Sources/GeoJSONKitTurf/Turf+LineString.swift index 0d17c42..89c5d51 100644 --- a/Sources/GeoJSONKitTurf/Turf+LineString.swift +++ b/Sources/GeoJSONKitTurf/Turf+LineString.swift @@ -33,6 +33,10 @@ extension GeoJSON.LineString { return GeoJSON.LineString(positions: coords) } + public func clipped(to boundingBox: GeoJSON.BoundingBox) -> GeoJSON.LineString { + return .init(positions: SutherlandHodgeman.clip(positions, to: boundingBox, close: false)) + } + /** Returns the portion of the line string that begins at the given start distance and extends the given stop distance along the line string. @@ -295,7 +299,7 @@ extension GeoJSON.LineString { return [] } - // MARK: - Fretched Distance + // MARK: - Fretchet Distance /// Frechet distance to another line, which is a measure of how similar the the lines are /// diff --git a/Sources/GeoJSONKitTurf/Turf+LinearRing.swift b/Sources/GeoJSONKitTurf/Turf+LinearRing.swift index 50a94a9..4dc3c46 100644 --- a/Sources/GeoJSONKitTurf/Turf+LinearRing.swift +++ b/Sources/GeoJSONKitTurf/Turf+LinearRing.swift @@ -136,5 +136,9 @@ extension GeoJSON.Polygon.LinearRing { public func chunked(length: GeoJSON.Distance) -> GeoJSON.Polygon.LinearRing { return .init(positions: GeoJSON.LineString.sliceLineSegments(positions, length: length)) } + + public func clipped(to boundingBox: GeoJSON.BoundingBox) -> GeoJSON.Polygon.LinearRing { + return .init(positions: SutherlandHodgeman.clip(positions, to: boundingBox, close: true)) + } } diff --git a/Sources/GeoJSONKitTurf/Turf+Polygon.swift b/Sources/GeoJSONKitTurf/Turf+Polygon.swift index 5614c2d..075376a 100644 --- a/Sources/GeoJSONKitTurf/Turf+Polygon.swift +++ b/Sources/GeoJSONKitTurf/Turf+Polygon.swift @@ -79,10 +79,10 @@ extension GeoJSON.Polygon { /// Clips a `.Polygon` to a bounding box /// /// Ported from https://github.com/Turfjs/turf/blob/master/packages/turf-bbox-clip/index.ts - public func clip(to boundingBox: GeoJSON.BoundingBox) -> GeoJSON.Polygon { + public func clipped(to boundingBox: GeoJSON.BoundingBox) -> GeoJSON.Polygon { var rings: [GeoJSON.Polygon.LinearRing] = [] for ring in [exterior] + interiors { - var clip: GeoJSON.Polygon.LinearRing = ring.clip(to: boundingBox) + var clip: GeoJSON.Polygon.LinearRing = ring.clipped(to: boundingBox) if let first = clip.positions.first { if first != clip.positions.last { clip.positions.append(first) @@ -97,95 +97,6 @@ extension GeoJSON.Polygon { } -extension GeoJSON.Polygon.LinearRing { - // Sutherland-Hodgeman polygon clipping algorithm - func clip(to boundingBox: GeoJSON.BoundingBox) -> GeoJSON.Polygon.LinearRing { - var result = [GeoJSON.Position]() - var prev: GeoJSON.Position - var prevInside: Bool - var inside: Bool - var points = positions - - for edge in [1, 2, 4, 8] { - result = [] - prev = points.last! - prevInside = !(bitCode(p: prev, bbox: boundingBox) & edge != 0) - - for point in points { - inside = !(bitCode(p: point, bbox: boundingBox) & edge != 0) - - if inside != prevInside { - if let intersection = intersect(a: prev, b: point, edge: edge, bbox: boundingBox) { - result.append(intersection) - } - } - - if inside { - result.append(point) - } - - prev = point - prevInside = inside - } - - points = result - if result.isEmpty { - break - } - } - - return .init(positions: result) - } - - private func intersect(a: GeoJSON.Position, b: GeoJSON.Position, edge: Int, bbox: GeoJSON.BoundingBox) -> GeoJSON.Position? { - if edge & 8 != 0 { - return .init(x: a.x + ((b.x - a.x) * (bbox.top - a.y)) / (b.y - a.y), y: bbox.top) - } else if edge & 4 != 0 { - return .init(x: a.x + ((b.x - a.x) * (bbox.bottom - a.y)) / (b.y - a.y), y: bbox.bottom) - } else if edge & 2 != 0 { - return .init(x: bbox.right, y: a.y + ((b.y - a.y) * (bbox.right - a.x)) / (b.x - a.x)) - } else if edge & 1 != 0 { - return .init(x: bbox.left, y: a.y + ((b.y - a.y) * (bbox.left - a.x)) / (b.x - a.x)) - } else { - return nil - } - } - - private func bitCode(p: GeoJSON.Position, bbox: GeoJSON.BoundingBox) -> Int { - var code = 0 - - if p.x < bbox.left { - code |= 1 - } else if p.x > bbox.right { - code |= 2 - } - - if p.y < bbox.bottom { - code |= 4 - } else if p.y > bbox.top { - code |= 8 - } - - return code - } -} - -fileprivate extension GeoJSON.BoundingBox { - var left: Double { southWesterlyLongitude } - var right: Double { northEasterlyLongitude } - var top: Double { northEasterlyLatitude } - var bottom: Double { southWesterlyLatitude } -} - -fileprivate extension GeoJSON.Position { - var x: Double { longitude } - var y: Double { latitude } - - init(x: Double, y: Double) { - self.init(latitude: y, longitude: x) - } -} - // MARK: - Polygon.smooth() extension GeoJSON.Polygon { diff --git a/Sources/GeoJSONKitTurf/Spline.swift b/Sources/GeoJSONKitTurf/algorithms/Spline.swift similarity index 100% rename from Sources/GeoJSONKitTurf/Spline.swift rename to Sources/GeoJSONKitTurf/algorithms/Spline.swift diff --git a/Sources/GeoJSONKitTurf/algorithms/SutherlandHodgeman.swift b/Sources/GeoJSONKitTurf/algorithms/SutherlandHodgeman.swift new file mode 100644 index 0000000..ffbbb1d --- /dev/null +++ b/Sources/GeoJSONKitTurf/algorithms/SutherlandHodgeman.swift @@ -0,0 +1,100 @@ +// +// SutherlandHodgeman.swift +// +// +// Created by Adrian Schönig on 25/11/2023. +// + +import Foundation + +import GeoJSONKit + +// Sutherland-Hodgeman polygon clipping algorithm +enum SutherlandHodgeman { + + static func clip(_ positions: [GeoJSON.Position], to boundingBox: GeoJSON.BoundingBox, close: Bool) -> [GeoJSON.Position] { + var result = [GeoJSON.Position]() + var prev: GeoJSON.Position + var prevInside: Bool + var inside: Bool + var points = positions + + for edge in [1, 2, 4, 8] { + result = [] + prev = close ? points.last! : points.first! + prevInside = !(Self.bitCode(p: prev, bbox: boundingBox) & edge != 0) + + for point in points { + inside = !(Self.bitCode(p: point, bbox: boundingBox) & edge != 0) + + if inside != prevInside { + if let intersection = Self.intersect(a: prev, b: point, edge: edge, bbox: boundingBox) { + result.append(intersection) + } + } + + if inside { + result.append(point) + } + + prev = point + prevInside = inside + } + + points = result + if result.isEmpty { + break + } + } + return result + } + + private static func intersect(a: GeoJSON.Position, b: GeoJSON.Position, edge: Int, bbox: GeoJSON.BoundingBox) -> GeoJSON.Position? { + if edge & 8 != 0 { + return .init(x: a.x + ((b.x - a.x) * (bbox.top - a.y)) / (b.y - a.y), y: bbox.top) + } else if edge & 4 != 0 { + return .init(x: a.x + ((b.x - a.x) * (bbox.bottom - a.y)) / (b.y - a.y), y: bbox.bottom) + } else if edge & 2 != 0 { + return .init(x: bbox.right, y: a.y + ((b.y - a.y) * (bbox.right - a.x)) / (b.x - a.x)) + } else if edge & 1 != 0 { + return .init(x: bbox.left, y: a.y + ((b.y - a.y) * (bbox.left - a.x)) / (b.x - a.x)) + } else { + return nil + } + } + + private static func bitCode(p: GeoJSON.Position, bbox: GeoJSON.BoundingBox) -> Int { + var code = 0 + + if p.x < bbox.left { + code |= 1 + } else if p.x > bbox.right { + code |= 2 + } + + if p.y < bbox.bottom { + code |= 4 + } else if p.y > bbox.top { + code |= 8 + } + + return code + } + +} + +fileprivate extension GeoJSON.BoundingBox { + var left: Double { southWesterlyLongitude } + var right: Double { northEasterlyLongitude } + var top: Double { northEasterlyLatitude } + var bottom: Double { southWesterlyLatitude } +} + +fileprivate extension GeoJSON.Position { + var x: Double { longitude } + var y: Double { latitude } + + init(x: Double, y: Double) { + self.init(latitude: y, longitude: x) + } +} diff --git a/Tests/GeoJSONKitTurfTests/LineStringTests.swift b/Tests/GeoJSONKitTurfTests/LineStringTests.swift index ef4cf2f..42a4c3f 100644 --- a/Tests/GeoJSONKitTurfTests/LineStringTests.swift +++ b/Tests/GeoJSONKitTurfTests/LineStringTests.swift @@ -255,6 +255,60 @@ class LineStringTests: XCTestCase { XCTAssertEqual(lineIn.chunked(length: 10 * fiveMiles), lineIn) } + func testChunkCrossingAntiMeridian() throws { + let input = try GeoJSON(geoJSONString: """ + { + "type": "LineString", + "coordinates": [ + [151.21, -33.86], + [-122.40, 37.78] + ] + } + """) + + let segmented = try GeoJSON(geoJSONString: """ + { + "type": "MultiLineString", + "coordinates": [ + [ + [151.21, -33.86], + [159.66614, -28.51214], + [167.27209, -22.69315], + [174.24093, -16.54493], + [180.0, -10.93165] + ], + [ + [-180.0, -10.93165], + [-179.22884, -10.18002], + [-172.95704, -3.69086], + [-166.776739, 2.84170], + [-160.52609, 9.34092], + [-154.03934, 15.72689], + [-147.138400, 21.90916], + [-139.627215, 27.77830], + [-131.293853, 33.19675], + [-122.40, 37.78] + ] + ] + } + """) + + guard + case .geometry(.single(.lineString(let lineIn))) = input.object, + case .geometry(let geoOut) = segmented.object + else { return XCTFail() } + + let chunked = GeoJSON.GeometryObject(splittingWhenCrossingAntiMeridian: .lineString(lineIn.chunked(length: 1_000_000))) + + XCTAssertEqual(chunked.positions.count, geoOut.positions.count) + + for (offset, pair) in zip(geoOut.positions, chunked.positions).enumerated() { + XCTAssertEqual(pair.0.latitude, pair.1.latitude, accuracy: 0.001, "Failure at: \(offset)") + XCTAssertEqual(pair.0.longitude, pair.1.longitude, accuracy: 0.001, "Failure at: \(offset)") + } + + } + func testCoordinateFromStart() { // Ported from https://github.com/Turfjs/turf/blob/142e137ce0c758e2825a260ab32b24db0aa19439/packages/turf-along/test.js diff --git a/Tests/GeoJSONKitTurfTests/PolygonTests.swift b/Tests/GeoJSONKitTurfTests/PolygonTests.swift index f5ec6a2..33d2215 100644 --- a/Tests/GeoJSONKitTurfTests/PolygonTests.swift +++ b/Tests/GeoJSONKitTurfTests/PolygonTests.swift @@ -618,7 +618,7 @@ class PolygonTests: XCTestCase { } let bboxPositions = bboxFeature.geometry.positions - let actualPolygon = polygon.clip(to: .init(positions: bboxPositions)) + let actualPolygon = polygon.clipped(to: .init(positions: bboxPositions)) let actual = GeoJSON(features: [ inputFeature.colorized(color: "#080"), .init(geometry: .single(.polygon(actualPolygon))).colorized(color: "#F00"),