From 739ec70cb86c5776a8405a1bbdbfd02279af6201 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Bardon?= Date: Thu, 10 Feb 2022 23:52:25 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Implement=20`AnyBoundingBox.union`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also fix `GeometryCollection` encoding --- Sources/GeoJSON/BoundingBox.swift | 13 +- Sources/GeoJSON/GeoJSON+Codable.swift | 33 ++++ .../Geometries/GeometryCollection.swift | 6 +- .../GeoJSON/Objects/FeatureCollection.swift | 5 +- Sources/GeoModels/2D/BoundingBox2D.swift | 153 ++++++++++++++++++ Sources/GeoModels/3D/BoundingBox3D.swift | 121 ++++++++++++++ .../GeoJSONTests/GeoJSON+EncodableTests.swift | 80 +++++++++ 7 files changed, 406 insertions(+), 5 deletions(-) create mode 100644 Sources/GeoModels/2D/BoundingBox2D.swift create mode 100644 Sources/GeoModels/3D/BoundingBox3D.swift diff --git a/Sources/GeoJSON/BoundingBox.swift b/Sources/GeoJSON/BoundingBox.swift index 17bbbfb..027ec10 100644 --- a/Sources/GeoJSON/BoundingBox.swift +++ b/Sources/GeoJSON/BoundingBox.swift @@ -40,8 +40,17 @@ public enum AnyBoundingBox: BoundingBox, Hashable, Codable { public static var zero: AnyBoundingBox = .twoDimensions(.zero) public func union(_ other: AnyBoundingBox) -> AnyBoundingBox { - #warning("Implement `AnyBoundingBox.union`") - fatalError("Not implemented yet") + switch (self, other) { + case let (.twoDimensions(self), .twoDimensions(other)): + return .twoDimensions(self.union(other)) + + case let (.twoDimensions(bbox2d), .threeDimensions(bbox3d)), + let (.threeDimensions(bbox3d), .twoDimensions(bbox2d)): + return .threeDimensions(bbox3d.union(bbox2d)) + + case let (.threeDimensions(self), .threeDimensions(other)): + return .threeDimensions(self.union(other)) + } } case twoDimensions(BoundingBox2D) diff --git a/Sources/GeoJSON/GeoJSON+Codable.swift b/Sources/GeoJSON/GeoJSON+Codable.swift index e388457..70059ec 100644 --- a/Sources/GeoJSON/GeoJSON+Codable.swift +++ b/Sources/GeoJSON/GeoJSON+Codable.swift @@ -176,6 +176,39 @@ extension SingleGeometry { } +fileprivate enum GeometryCollectionCodingKeys: String, CodingKey { + case geoJSONType = "type" + case geometries, bbox +} + +extension GeometryCollection { + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: GeometryCollectionCodingKeys.self) + + let type = try container.decode(GeoJSON.`Type`.self, forKey: .geoJSONType) + guard type == Self.geoJSONType else { + throw DecodingError.typeMismatch(Self.self, DecodingError.Context( + codingPath: container.codingPath, + debugDescription: "Found GeoJSON type '\(type.rawValue)'" + )) + } + + let geometries = try container.decode([AnyGeometry].self, forKey: .geometries) + + self.init(geometries: geometries) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: GeometryCollectionCodingKeys.self) + + try container.encode(Self.geoJSONType, forKey: .geoJSONType) + try container.encode(self.geometries, forKey: .geometries) + try container.encode(self.bbox, forKey: .bbox) + } + +} + fileprivate enum AnyGeometryCodingKeys: String, CodingKey { case geoJSONType = "type" } diff --git a/Sources/GeoJSON/Geometries/GeometryCollection.swift b/Sources/GeoJSON/Geometries/GeometryCollection.swift index 0ffc767..cc1fdc7 100644 --- a/Sources/GeoJSON/Geometries/GeometryCollection.swift +++ b/Sources/GeoJSON/Geometries/GeometryCollection.swift @@ -11,10 +11,14 @@ public struct GeometryCollection: CodableGeometry { public static var geometryType: GeoJSON.`Type`.Geometry { .geometryCollection } - public var _bbox: AnyBoundingBox { asAnyGeometry.bbox } + public var _bbox: AnyBoundingBox { geometries.bbox } public var asAnyGeometry: AnyGeometry { .geometryCollection(self) } public var geometries: [AnyGeometry] + public init(geometries: [AnyGeometry]) { + self.geometries = geometries + } + } diff --git a/Sources/GeoJSON/Objects/FeatureCollection.swift b/Sources/GeoJSON/Objects/FeatureCollection.swift index 46bd03c..bdc26ff 100644 --- a/Sources/GeoJSON/Objects/FeatureCollection.swift +++ b/Sources/GeoJSON/Objects/FeatureCollection.swift @@ -17,8 +17,9 @@ public struct FeatureCollection< public var features: [Feature] - // FIXME: Fix bounding box - public var bbox: AnyBoundingBox? { nil } + public var bbox: AnyBoundingBox? { + features.compactMap(\.bbox).reduce(nil, { $0.union($1.asAny) }) + } } diff --git a/Sources/GeoModels/2D/BoundingBox2D.swift b/Sources/GeoModels/2D/BoundingBox2D.swift new file mode 100644 index 0000000..741df10 --- /dev/null +++ b/Sources/GeoModels/2D/BoundingBox2D.swift @@ -0,0 +1,153 @@ +// +// BoundingBox2D.swift +// SwiftGeo +// +// Created by Rémi Bardon on 02/02/2022. +// Copyright © 2022 Rémi Bardon. All rights reserved. +// + +public struct BoundingBox2D: Hashable { + + public var southWest: Coordinate2D + public var width: Longitude + public var height: Latitude + + public var southLatitude: Latitude { + southWest.latitude + } + public var northLatitude: Latitude { + southLatitude + height + } + public var centerLatitude: Latitude { + southLatitude + (height / 2.0) + } + public var westLongitude: Longitude { + southWest.longitude + } + public var eastLongitude: Longitude { + let longitude = westLongitude + width + + if longitude > .halfRotation { + return longitude - .fullRotation + } else { + return longitude + } + } + public var centerLongitude: Longitude { + let longitude = westLongitude + (width / 2.0) + + if longitude > .halfRotation { + return longitude - .fullRotation + } else { + return longitude + } + } + + public var northEast: Coordinate2D { + Coordinate2D(latitude: northLatitude, longitude: eastLongitude) + } + public var northWest: Coordinate2D { + Coordinate2D(latitude: northLatitude, longitude: westLongitude) + } + public var southEast: Coordinate2D { + Coordinate2D(latitude: southLatitude, longitude: westLongitude) + } + public var center: Coordinate2D { + Coordinate2D(latitude: centerLatitude, longitude: centerLongitude) + } + + public var south: Coordinate2D { + southAtLongitude(centerLongitude) + } + public var north: Coordinate2D { + northAtLongitude(centerLongitude) + } + public var west: Coordinate2D { + westAtLatitude(centerLatitude) + } + public var east: Coordinate2D { + eastAtLatitude(centerLatitude) + } + + public var crosses180thMeridian: Bool { + westLongitude > eastLongitude + } + + public init( + southWest: Coordinate2D, + width: Longitude, + height: Latitude + ) { + self.southWest = southWest + self.width = width + self.height = height + } + + public init( + southWest: Coordinate2D, + northEast: Coordinate2D + ) { + self.init( + southWest: southWest, + width: northEast.longitude - southWest.longitude, + height: northEast.latitude - southWest.latitude + ) + } + + public func southAtLongitude(_ longitude: Longitude) -> Coordinate2D { + Coordinate2D(latitude: northEast.latitude, longitude: longitude) + } + public func northAtLongitude(_ longitude: Longitude) -> Coordinate2D { + Coordinate2D(latitude: southWest.latitude, longitude: longitude) + } + public func westAtLatitude(_ latitude: Latitude) -> Coordinate2D { + Coordinate2D(latitude: latitude, longitude: southWest.longitude) + } + public func eastAtLatitude(_ latitude: Latitude) -> Coordinate2D { + Coordinate2D(latitude: latitude, longitude: northEast.longitude) + } + + public func offsetBy(dLat: Latitude = .zero, dLong: Longitude = .zero) -> BoundingBox2D { + Self.init( + southWest: southWest.offsetBy(dLat: dLat, dLong: dLong), + width: width, + height: height + ) + } + public func offsetBy(dx: Coordinate2D.X = .zero, dy: Coordinate2D.Y = .zero) -> BoundingBox2D { + Self.init( + southWest: southWest.offsetBy(dx: dx, dy: dy), + width: width, + height: height + ) + } + +} + +extension BoundingBox2D: BoundingBox { + + public static var zero: BoundingBox2D { + Self.init(southWest: .zero, width: .zero, height: .zero) + } + + /// The union of bounding boxes gives a new bounding box that encloses the given two. + public func union(_ other: BoundingBox2D) -> BoundingBox2D { + Self.init( + southWest: Coordinate2D( + latitude: min(self.southLatitude, other.southLatitude), + longitude: min(self.westLongitude, other.westLongitude) + ), + northEast: Coordinate2D( + latitude: max(self.northLatitude, other.northLatitude), + longitude: max(self.eastLongitude, other.eastLongitude) + ) + ) + } + +} + +extension BoundingBox2D { + + public func union(_ bbox3d: BoundingBox3D) -> BoundingBox3D { bbox3d.union(self) } + +} diff --git a/Sources/GeoModels/3D/BoundingBox3D.swift b/Sources/GeoModels/3D/BoundingBox3D.swift new file mode 100644 index 0000000..4c09a73 --- /dev/null +++ b/Sources/GeoModels/3D/BoundingBox3D.swift @@ -0,0 +1,121 @@ +// +// BoundingBox3D.swift +// SwiftGeo +// +// Created by Rémi Bardon on 08/02/2022. +// Copyright © 2022 Rémi Bardon. All rights reserved. +// + +public struct BoundingBox3D: Hashable { + + public var twoDimensions: BoundingBox2D + public var lowAltitude: Altitude + public var zHeight: Altitude + + public var highAltitude: Altitude { + lowAltitude + zHeight + } + public var centerAltitude: Altitude { + lowAltitude + (zHeight / 2.0) + } + + public var southWestLow: Coordinate3D { + Coordinate3D(twoDimensions.southWest, altitude: lowAltitude) + } + public var northEastHigh: Coordinate3D { + Coordinate3D(twoDimensions.northEast, altitude: highAltitude) + } + public var center: Coordinate3D { + Coordinate3D(twoDimensions.center, altitude: centerAltitude) + } + + public var crosses180thMeridian: Bool { + twoDimensions.crosses180thMeridian + } + + public init(_ boundingBox2d: BoundingBox2D, lowAltitude: Altitude, zHeight: Altitude) { + self.twoDimensions = boundingBox2d + self.lowAltitude = lowAltitude + self.zHeight = zHeight + } + + public init( + southWestLow: Coordinate3D, + width: Longitude, + height: Latitude, + zHeight: Altitude + ) { + self.init( + BoundingBox2D(southWest: southWestLow.twoDimensions, width: width, height: height), + lowAltitude: southWestLow.altitude, + zHeight: zHeight + ) + } + + public init( + southWestLow: Coordinate3D, + northEastHigh: Coordinate3D + ) { + self.init( + southWestLow: southWestLow, + width: northEastHigh.longitude - southWestLow.longitude, + height: northEastHigh.latitude - southWestLow.latitude, + zHeight: northEastHigh.altitude - southWestLow.altitude + ) + } + + public func offsetBy( + dLat: Latitude = .zero, + dLong: Longitude = .zero, + dAlt: Altitude = .zero + ) -> BoundingBox3D { + Self.init( + twoDimensions.offsetBy(dLat: dLat, dLong: dLong), + lowAltitude: lowAltitude + dAlt, + zHeight: zHeight + ) + } + public func offsetBy( + dx: Coordinate3D.X = .zero, + dy: Coordinate3D.Y = .zero, + dz: Coordinate3D.Z = .zero + ) -> BoundingBox3D { + Self.init( + twoDimensions.offsetBy(dx: dx, dy: dy), + lowAltitude: lowAltitude + dz, + zHeight: zHeight + ) + } + +} + +extension BoundingBox3D: BoundingBox { + + public static var zero: BoundingBox3D { + BoundingBox3D(.zero, lowAltitude: .zero, zHeight: .zero) + } + + /// The union of bounding boxes gives a new bounding box that encloses the given two. + public func union(_ other: BoundingBox3D) -> BoundingBox3D { + BoundingBox3D( + southWestLow: Coordinate3D( + self.twoDimensions.union(other.twoDimensions).southWest, + altitude: min(self.lowAltitude, other.lowAltitude) + ), + northEastHigh: Coordinate3D( + self.twoDimensions.union(other.twoDimensions).northEast, + altitude: max(self.highAltitude, other.highAltitude) + ) + ) + } + +} + +extension BoundingBox3D { + + public func union(_ bbox2d: BoundingBox2D) -> BoundingBox3D { + let other = BoundingBox3D(bbox2d, lowAltitude: self.lowAltitude, zHeight: .zero) + return self.union(other) + } + +} diff --git a/Tests/GeoJSONTests/GeoJSON+EncodableTests.swift b/Tests/GeoJSONTests/GeoJSON+EncodableTests.swift index 66522eb..725f8fb 100644 --- a/Tests/GeoJSONTests/GeoJSON+EncodableTests.swift +++ b/Tests/GeoJSONTests/GeoJSON+EncodableTests.swift @@ -199,4 +199,84 @@ final class GeoJSONEncodableTests: XCTestCase { XCTAssertEqual(string, expected) } + func testFeatureCollectionOfGeometryCollectionEncode() throws { + struct FeatureProperties: Hashable, Codable {} + + let featureCollection: FeatureCollection = FeatureCollection(features: [ + Feature( + geometry: GeometryCollection(geometries: [ + .point2D(Point2D(coordinates: .nantes)), + .point2D(Point2D(coordinates: .bordeaux)), + ]), + properties: FeatureProperties() + ), + Feature( + geometry: GeometryCollection(geometries: [ + .point2D(Point2D(coordinates: .paris)), + .point2D(Point2D(coordinates: .marseille)), + ]), + properties: FeatureProperties() + ), + ]) + let data: Data = try JSONEncoder().encode(featureCollection) + let string: String = try XCTUnwrap(String(data: data, encoding: .utf8)) + + let expected: String = [ + "{", + "\"type\":\"FeatureCollection\",", + // For some reason, `"bbox"` goes here 🤷 + "\"bbox\":[-1.55366,43.29868,5.36468,48.85719],", + "\"features\":[", + "{", + // For some reason, `"properties"` goes here 🤷 + "\"properties\":{},", + "\"type\":\"Feature\",", + "\"geometry\":{", + "\"type\":\"GeometryCollection\",", + // For some reason, `"bbox"` goes here 🤷 + "\"bbox\":[-1.55366,44.8378,-0.58143,47.21881],", + "\"geometries\":[", + "{", + "\"type\":\"Point\",", + "\"coordinates\":[-1.55366,47.21881],", + "\"bbox\":[-1.55366,47.21881,-1.55366,47.21881]", + "},", + "{", + "\"type\":\"Point\",", + "\"coordinates\":[-0.58143,44.8378],", + "\"bbox\":[-0.58143,44.8378,-0.58143,44.8378]", + "}", + "]", + "},", + "\"bbox\":[-1.55366,44.8378,-0.58143,47.21881]", + "},", + "{", + // For some reason, `"properties"` goes here 🤷 + "\"properties\":{},", + "\"type\":\"Feature\",", + "\"geometry\":{", + "\"type\":\"GeometryCollection\",", + // For some reason, `"bbox"` goes here 🤷 + "\"bbox\":[2.3529,43.29868,5.36468,48.85719],", + "\"geometries\":[", + "{", + "\"type\":\"Point\",", + "\"coordinates\":[2.3529,48.85719],", + "\"bbox\":[2.3529,48.85719,2.3529,48.85719]", + "},", + "{", + "\"type\":\"Point\",", + "\"coordinates\":[5.36468,43.29868],", + "\"bbox\":[5.36468,43.29868,5.36468,43.29868]", + "}", + "]", + "},", + "\"bbox\":[2.3529,43.29868,5.36468,48.85719]", + "}", + "]", + "}", + ].joined() + XCTAssertEqual(string, expected) + } + }