From 7024ba9dd6f63bbc38e87757e3a81209cf47dcee Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 21:26:41 -0800 Subject: [PATCH 1/2] Interpolate the closing edge of geodesic polygon rings createInterpolatePoints only interpolates between neighbouring points, and polygon rings arrive open (last != first). The closing edge (last -> first) of a geodesic polygon was therefore never densified and was drawn as a straight chord, while the outline - which closes the ring and densifies it as a polyline - followed the great circle. Fill and outline disagreed on that edge (e.g. Boston -> London -> Iceland: the Iceland -> Boston edge's great circle passes 55.6N at 53.2W, the chord passes 51.6N there). Add densifyGeodesicRing, which closes the ring before densifying and keeps the input's open/closed shape, and use it for outer rings and holes in buildPolygonRings / buildUnwrappedPolygonRings and for the PolygonManager hit test (whose holes now follow the great circle too, as on Android and web). geodesic=false is unchanged. The public API only gains the new function. Same change as android-sdk-core and js-sdk-core. --- .../geometry/OverlayGeometry.swift | 44 +++++- .../polygon/PolygonManager.swift | 11 +- .../GeodesicPolygonRingTests.swift | 129 ++++++++++++++++++ api/MapConductorCore.api.swift | 1 + 4 files changed, 177 insertions(+), 8 deletions(-) create mode 100644 Tests/MapConductorCoreTests/GeodesicPolygonRingTests.swift diff --git a/Sources/MapConductorCore/geometry/OverlayGeometry.swift b/Sources/MapConductorCore/geometry/OverlayGeometry.swift index 19f1e4b..2b852a8 100644 --- a/Sources/MapConductorCore/geometry/OverlayGeometry.swift +++ b/Sources/MapConductorCore/geometry/OverlayGeometry.swift @@ -32,6 +32,42 @@ public func densifyAndNormalize( return interpolated.map { $0.normalize() } } +/// ポリゴンのリング(外周・穴)を測地線(大圏)で密度化する。 +/// +/// `WGS84Geodesic.createInterpolatePoints` は隣り合う点の間しか補間しないため、開いたリング +/// (末尾 ≠ 先頭)を渡すと閉じる辺(末尾 → 先頭)が補間されず、描画側で閉じたときに直線の弦に +/// なる。ポリゴンの輪郭はリングを閉じてからポリラインとして密度化するので、塗りと輪郭で +/// 閉じる辺の形が食い違う。ここでは閉じる辺も含めて補間する。 +/// +/// 入力が開いたリングなら開いたまま(末尾に先頭の重複点を付けない)、閉じたリングなら閉じた +/// まま返す。正規化はしない。頂点 2 未満の入力はそのまま `createInterpolatePoints` に渡す。 +public func densifyGeodesicRing( + _ ring: [GeoPointProtocol], + maxSegmentLength: Double = 10_000.0 +) -> [GeoPointProtocol] { + guard ring.count >= 2, let first = ring.first, let last = ring.last, + !(first.latitude == last.latitude && first.longitude == last.longitude) + else { + return WGS84Geodesic.createInterpolatePoints(ring, maxSegmentLength: maxSegmentLength) + } + return Array( + WGS84Geodesic.createInterpolatePoints(ring + [first], maxSegmentLength: maxSegmentLength).dropLast() + ) +} + +/// リング用の `densifyAndNormalize`。geodesic のときは閉じる辺も補間する(`densifyGeodesicRing`)。 +/// geodesic=false は従来どおり `densifyAndNormalize` と同じ。 +private func densifyRingAndNormalize( + _ ring: [GeoPointProtocol], + geodesic: Bool, + maxSegmentLength: Double = 10_000.0 +) -> [GeoPointProtocol] { + guard geodesic else { + return densifyAndNormalize(ring, geodesic: false, maxSegmentLength: maxSegmentLength) + } + return densifyGeodesicRing(ring, maxSegmentLength: maxSegmentLength).map { $0.normalize() } +} + // ─── 分割版パイプライン(経度 ±180 に制約のある SDK 向け) ───────────────────── // // 経度 ±180 を超える座標を受け付けない SDK(ネイティブオーバーレイ系)は、密度化・正規化後に @@ -61,14 +97,14 @@ public func buildPolygonRings( ) -> PolygonRings { guard points.count >= 3 else { return PolygonRings(outerRings: [], holeRings: []) } let outerRings = splitRingByMeridian( - densifyAndNormalize(points, geodesic: geodesic), + densifyRingAndNormalize(points, geodesic: geodesic), geodesic: geodesic ).filter { $0.count >= 3 } let includeHoles = !holes.isEmpty && outerRings.count == 1 let holeRings = includeHoles ? holes .filter { $0.count >= 3 } - .map { densifyAndNormalize($0, geodesic: geodesic) } + .map { densifyRingAndNormalize($0, geodesic: geodesic) } .filter { $0.count >= 3 } : [] return PolygonRings(outerRings: outerRings, holeRings: holeRings) @@ -122,14 +158,14 @@ public func buildUnwrappedPolygonRings( ) -> PolygonRings { guard points.count >= 3 else { return PolygonRings(outerRings: [], holeRings: []) } let outer = unwrapContinuous( - densifyAndNormalize(points, geodesic: geodesic, maxSegmentLength: maxSegmentLength) + densifyRingAndNormalize(points, geodesic: geodesic, maxSegmentLength: maxSegmentLength) ) let anchor = outer[0].longitude let holeRings = holes .filter { $0.count >= 3 } .map { hole in unwrapContinuous( - densifyAndNormalize(hole, geodesic: geodesic, maxSegmentLength: maxSegmentLength), + densifyRingAndNormalize(hole, geodesic: geodesic, maxSegmentLength: maxSegmentLength), anchorLng: anchor ) } diff --git a/Sources/MapConductorCore/polygon/PolygonManager.swift b/Sources/MapConductorCore/polygon/PolygonManager.swift index c06bdf4..fc28e89 100644 --- a/Sources/MapConductorCore/polygon/PolygonManager.swift +++ b/Sources/MapConductorCore/polygon/PolygonManager.swift @@ -97,7 +97,8 @@ public final class PolygonManager: PolygonManagerProtocol { let ring: [GeoPointProtocol] if state.geodesic { - ring = WGS84Geodesic.createInterpolatePoints(basePoints) + // 閉じる辺(末尾 → 先頭)も大圏で補間する(描画と同じ形で判定する)。 + ring = densifyGeodesicRing(basePoints) } else { ring = basePoints } @@ -112,10 +113,12 @@ public final class PolygonManager: PolygonManagerProtocol { var insideHole = false for holePoints in state.holes { if holePoints.count < 3 { continue } - guard let holeFirst = holePoints.first else { continue } - let holeLast = holePoints.last + // 穴も外周と同じく geodesic なら大圏で補間する(android-sdk / js-sdk-core と同じ)。 + let holeRing = state.geodesic ? densifyGeodesicRing(holePoints) : holePoints + guard let holeFirst = holeRing.first else { continue } + let holeLast = holeRing.last let holeIsClosed = holeLast.map { GeoPoint.from(position: holeFirst) == GeoPoint.from(position: $0) } ?? false - let closedHole = holeIsClosed ? holePoints : (holePoints + [holeFirst]) + let closedHole = holeIsClosed ? holeRing : (holeRing + [holeFirst]) if pointInPolygonWindingNumber(testX: testX, testY: testY, ring: closedHole) { insideHole = true break diff --git a/Tests/MapConductorCoreTests/GeodesicPolygonRingTests.swift b/Tests/MapConductorCoreTests/GeodesicPolygonRingTests.swift new file mode 100644 index 0000000..fca474a --- /dev/null +++ b/Tests/MapConductorCoreTests/GeodesicPolygonRingTests.swift @@ -0,0 +1,129 @@ +import Foundation +import XCTest + +@testable import MapConductorCore + +/// geodesic ポリゴンの閉じる辺(末尾 → 先頭)も大圏で補間すること。 +/// +/// js-sdk-core の `GeodesicPolygonRing.test.mjs`、android-sdk の `GeodesicPolygonRingTest` と 1 対 1 で対応する。 +/// +/// `WGS84Geodesic.createInterpolatePoints` は隣り合う点の間しか補間しない。ポリゴンの点は開いたリング +/// (末尾 ≠ 先頭)で渡されるので、塗りのリングは閉じる辺だけが補間されず直線の弦になっていた。 +/// 輪郭はリングを閉じてからポリラインとして密度化するので大圏になり、「輪郭は曲線、塗りの一辺だけ直線」になる。 +/// Google のように塗りと輪郭を 1 つのネイティブポリゴンで描くプロバイダは、輪郭まで直線になる。 +final class GeodesicPolygonRingTests: XCTestCase { + private let boston = GeoPoint(latitude: 42.36, longitude: -71.06) + private let london = GeoPoint(latitude: 51.5, longitude: -0.12) + private let iceland = GeoPoint(latitude: 64.1, longitude: -21.9) + + // 実機検証と同じ順序。閉じる辺は アイスランド → ボストン。 + private var triangle: [GeoPointProtocol] { [boston, london, iceland] } + + private func same(_ a: GeoPointProtocol, _ b: GeoPointProtocol) -> Bool { + abs(a.latitude - b.latitude) < 1e-9 && abs(a.longitude - b.longitude) < 1e-9 + } + + private func assertSamePoints( + _ expected: [GeoPointProtocol], + _ actual: [GeoPointProtocol], + file: StaticString = #filePath, + line: UInt = #line + ) { + XCTAssertEqual(expected.count, actual.count, file: file, line: line) + guard expected.count == actual.count else { return } + for i in expected.indices where !same(expected[i], actual[i]) { + XCTFail("point \(i): \(actual[i]) != \(expected[i])", file: file, line: line) + return + } + } + + /// 輪郭(閉じたリングをポリラインとして密度化)から、末尾の重複点を除いたもの。 + private func outlineOf(_ points: [GeoPointProtocol], geodesic: Bool) -> [GeoPointProtocol] { + Array(buildUnwrappedPolylinePath(points + [points[0]], geodesic: geodesic).dropLast()) + } + + func testGreatCircleMidpointBostonToIcelandBulgesNorthwestByAboutFourDegrees() { + let mid = WGS84Geodesic.interpolate(from: boston, to: iceland, fraction: 0.5) + // Web メルカトルでの直線(弦)が同じ経度で通る緯度。 + func merc(_ lat: Double) -> Double { log(tan(Double.pi / 4 + lat * Double.pi / 360)) } + let t = (mid.longitude - boston.longitude) / (iceland.longitude - boston.longitude) + let y = merc(boston.latitude) + t * (merc(iceland.latitude) - merc(boston.latitude)) + let chordLat = (2 * atan(exp(y)) - Double.pi / 2) * 180 / Double.pi + XCTAssertEqual(mid.latitude, 55.63, accuracy: 0.05) + XCTAssertEqual(chordLat, 51.6, accuracy: 0.05) + } + + func testDensifyGeodesicRingInterpolatesClosingEdgeAndKeepsRingOpen() { + let ring = densifyGeodesicRing(triangle) + XCTAssertTrue(same(boston, ring[0])) + // 先頭の重複点を末尾に付けない(閉じて補間した結果から末尾の 1 点だけを除いた長さ)。 + // createInterpolatePoints は分割比を足し込むため、末尾の手前に終点とほぼ同じ点が残ることがある。 + XCTAssertEqual(WGS84Geodesic.createInterpolatePoints(triangle + [boston]).count - 1, ring.count) + let expected = WGS84Geodesic.createInterpolatePoints([iceland, boston]) + assertSamePoints(Array(expected.dropFirst().dropLast()), Array(ring.suffix(expected.count - 2))) + } + + func testDensifyGeodesicRingKeepsClosedRingClosed() { + let closed = triangle + [boston] + assertSamePoints(WGS84Geodesic.createInterpolatePoints(closed), densifyGeodesicRing(closed)) + } + + func testUnwrappedGeodesicFillMatchesOutline() { + let fill = buildUnwrappedPolygonRings(points: triangle, holes: [], geodesic: true).outerRings[0] + assertSamePoints(outlineOf(triangle, geodesic: true), fill) + let mid = WGS84Geodesic.interpolate(from: iceland, to: boston, fraction: 0.5) + let nearest = fill.map { hypot($0.latitude - mid.latitude, $0.longitude - mid.longitude) }.min() ?? .infinity + XCTAssertLessThan(nearest, 0.1) + } + + func testSplitGeodesicFillInterpolatesClosingEdge() { + let rings = buildPolygonRings(points: triangle, holes: [], geodesic: true).outerRings + XCTAssertEqual(rings.count, 1) + assertSamePoints(outlineOf(triangle, geodesic: true), rings[0]) + } + + func testNonGeodesicFillIsUnchanged() { + let legacy = Planar.createInterpolatePoints(triangle).map { $0.normalize() } + let fill = buildUnwrappedPolygonRings(points: triangle, holes: [], geodesic: false).outerRings[0] + assertSamePoints(legacy, fill) + XCTAssertTrue(same(iceland, fill[fill.count - 1])) + assertSamePoints(legacy, buildPolygonRings(points: triangle, holes: [], geodesic: false).outerRings[0]) + } + + func testGeodesicHolesInterpolateClosingEdge() { + let outer: [GeoPointProtocol] = [ + GeoPoint(latitude: 30.0, longitude: -80.0), + GeoPoint(latitude: 30.0, longitude: 10.0), + GeoPoint(latitude: 70.0, longitude: 10.0), + GeoPoint(latitude: 70.0, longitude: -80.0), + ] + let hole = triangle + let unwrapped = buildUnwrappedPolygonRings(points: outer, holes: [hole], geodesic: true) + assertSamePoints(outlineOf(hole, geodesic: true), unwrapped.holeRings[0]) + assertSamePoints(outlineOf(outer, geodesic: true), unwrapped.outerRings[0]) + assertSamePoints( + outlineOf(hole, geodesic: true), + buildPolygonRings(points: outer, holes: [hole], geodesic: true).holeRings[0] + ) + assertSamePoints( + Planar.createInterpolatePoints(hole).map { $0.normalize() }, + buildUnwrappedPolygonRings(points: outer, holes: [hole], geodesic: false).holeRings[0] + ) + } + + func testHitTestFollowsGreatCircleOnClosingEdge() { + // 弦と大圏の間(北西へ膨らんだ部分)の点。 + let mid = WGS84Geodesic.interpolate(from: iceland, to: boston, fraction: 0.5) + let t = (mid.longitude - iceland.longitude) / (boston.longitude - iceland.longitude) + let chordLat = iceland.latitude + t * (boston.latitude - iceland.latitude) + let bulge = GeoPoint(latitude: (mid.latitude + chordLat) / 2, longitude: mid.longitude) + + let manager = PolygonManager() + let state = PolygonState(points: triangle, id: "g", geodesic: true) + manager.registerEntity(PolygonEntity(polygon: 0, state: state)) + XCTAssertEqual(manager.find(position: bulge)?.state.id, "g") + + state.geodesic = false + XCTAssertNil(manager.find(position: bulge)) + } +} diff --git a/api/MapConductorCore.api.swift b/api/MapConductorCore.api.swift index 54c7308..9cb21da 100644 --- a/api/MapConductorCore.api.swift +++ b/api/MapConductorCore.api.swift @@ -828,6 +828,7 @@ public struct PolygonRings { public init(outerRings: [[any MapConductorCore::GeoPointProtocol]], holeRings: [[any MapConductorCore::GeoPointProtocol]]) } public func densifyAndNormalize(_ points: [any MapConductorCore::GeoPointProtocol], geodesic: Swift::Bool, maxSegmentLength: Swift::Double = 10_000.0) -> [any MapConductorCore::GeoPointProtocol] +public func densifyGeodesicRing(_ ring: [any MapConductorCore::GeoPointProtocol], maxSegmentLength: Swift::Double = 10_000.0) -> [any MapConductorCore::GeoPointProtocol] public func buildPolylineSegments(_ points: [any MapConductorCore::GeoPointProtocol], geodesic: Swift::Bool) -> [[any MapConductorCore::GeoPointProtocol]] public func buildPolygonRings(points: [any MapConductorCore::GeoPointProtocol], holes: [[any MapConductorCore::GeoPointProtocol]], geodesic: Swift::Bool) -> MapConductorCore::PolygonRings public func buildUnwrappedPolylinePath(_ points: [any MapConductorCore::GeoPointProtocol], geodesic: Swift::Bool, maxSegmentLength: Swift::Double = 10_000.0) -> [any MapConductorCore::GeoPointProtocol] From 100413fa762d619da77adbe0571591c6665a1bfe Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 23:19:36 -0800 Subject: [PATCH 2/2] Densify the closing edge of non-geodesic polygon rings too The closing edge (last vertex -> first) of a geodesic=false polygon had the same gap: Planar.createInterpolatePoints also only interpolates between neighbouring points, so the fill's closing edge stayed a single straight Web Mercator chord, while the outline - which closes the ring and densifies it as a polyline - interpolated latitude/longitude linearly and bent slightly on screen. (Boston -> London -> Iceland: halfway along the Iceland -> Boston edge, the linear edge passes 53.2N, the chord 54.7N.) Generalise densifyGeodesicRing (unreleased) into densifyPolygonRing(_: geodesic:maxSegmentLength:), which closes the ring before densifying with WGS84Geodesic or Planar, and use it for every ring in buildPolygonRings / buildUnwrappedPolygonRings (like densifyAndNormalize, the linear case keeps its 10 km segments). The hit test keeps testing geodesic=false against the raw vertices: in the lat/lng plane that is the same line as the linear interpolation. Compared with develop the public API only gains densifyPolygonRing. Same change as android-sdk-core and js-sdk-core. --- .../geometry/OverlayGeometry.swift | 34 ++++---- .../polygon/PolygonManager.swift | 4 +- .../GeodesicPolygonRingTests.swift | 77 +++++++++++++++---- api/MapConductorCore.api.swift | 2 +- 4 files changed, 86 insertions(+), 31 deletions(-) diff --git a/Sources/MapConductorCore/geometry/OverlayGeometry.swift b/Sources/MapConductorCore/geometry/OverlayGeometry.swift index 2b852a8..5f6e70d 100644 --- a/Sources/MapConductorCore/geometry/OverlayGeometry.swift +++ b/Sources/MapConductorCore/geometry/OverlayGeometry.swift @@ -32,40 +32,44 @@ public func densifyAndNormalize( return interpolated.map { $0.normalize() } } -/// ポリゴンのリング(外周・穴)を測地線(大圏)で密度化する。 +/// ポリゴンのリング(外周・穴)を、閉じる辺(末尾 → 先頭)も含めて密度化する。 /// -/// `WGS84Geodesic.createInterpolatePoints` は隣り合う点の間しか補間しないため、開いたリング -/// (末尾 ≠ 先頭)を渡すと閉じる辺(末尾 → 先頭)が補間されず、描画側で閉じたときに直線の弦に +/// geodesic なら大圏(`WGS84Geodesic`)、そうでなければ緯度経度の線形補間(`Planar`)。 +/// どちらの `createInterpolatePoints` も隣り合う点の間しか補間しないため、開いたリング +/// (末尾 ≠ 先頭)をそのまま渡すと閉じる辺だけが補間されず、描画側で閉じたときに直線の弦に /// なる。ポリゴンの輪郭はリングを閉じてからポリラインとして密度化するので、塗りと輪郭で -/// 閉じる辺の形が食い違う。ここでは閉じる辺も含めて補間する。 +/// 閉じる辺の形が食い違う(geodesic=false でも、線形補間の辺はメルカトル上でわずかに曲がる)。 +/// ここでは閉じる辺も他の辺と同じ方式で補間する。 /// /// 入力が開いたリングなら開いたまま(末尾に先頭の重複点を付けない)、閉じたリングなら閉じた /// まま返す。正規化はしない。頂点 2 未満の入力はそのまま `createInterpolatePoints` に渡す。 -public func densifyGeodesicRing( +public func densifyPolygonRing( _ ring: [GeoPointProtocol], + geodesic: Bool, maxSegmentLength: Double = 10_000.0 ) -> [GeoPointProtocol] { + func densify(_ points: [GeoPointProtocol]) -> [GeoPointProtocol] { + geodesic + ? WGS84Geodesic.createInterpolatePoints(points, maxSegmentLength: maxSegmentLength) + : Planar.createInterpolatePoints(points, maxSegmentLength: maxSegmentLength) + } guard ring.count >= 2, let first = ring.first, let last = ring.last, !(first.latitude == last.latitude && first.longitude == last.longitude) else { - return WGS84Geodesic.createInterpolatePoints(ring, maxSegmentLength: maxSegmentLength) + return densify(ring) } - return Array( - WGS84Geodesic.createInterpolatePoints(ring + [first], maxSegmentLength: maxSegmentLength).dropLast() - ) + return Array(densify(ring + [first]).dropLast()) } -/// リング用の `densifyAndNormalize`。geodesic のときは閉じる辺も補間する(`densifyGeodesicRing`)。 -/// geodesic=false は従来どおり `densifyAndNormalize` と同じ。 +/// リング用の `densifyAndNormalize`。閉じる辺も補間する(`densifyPolygonRing`)。 +/// `densifyAndNormalize` と同じく、`maxSegmentLength` は geodesic のときだけ使う(線形補間は 10 km)。 private func densifyRingAndNormalize( _ ring: [GeoPointProtocol], geodesic: Bool, maxSegmentLength: Double = 10_000.0 ) -> [GeoPointProtocol] { - guard geodesic else { - return densifyAndNormalize(ring, geodesic: false, maxSegmentLength: maxSegmentLength) - } - return densifyGeodesicRing(ring, maxSegmentLength: maxSegmentLength).map { $0.normalize() } + densifyPolygonRing(ring, geodesic: geodesic, maxSegmentLength: geodesic ? maxSegmentLength : 10_000.0) + .map { $0.normalize() } } // ─── 分割版パイプライン(経度 ±180 に制約のある SDK 向け) ───────────────────── diff --git a/Sources/MapConductorCore/polygon/PolygonManager.swift b/Sources/MapConductorCore/polygon/PolygonManager.swift index fc28e89..76ccbad 100644 --- a/Sources/MapConductorCore/polygon/PolygonManager.swift +++ b/Sources/MapConductorCore/polygon/PolygonManager.swift @@ -98,7 +98,7 @@ public final class PolygonManager: PolygonManagerProtocol { let ring: [GeoPointProtocol] if state.geodesic { // 閉じる辺(末尾 → 先頭)も大圏で補間する(描画と同じ形で判定する)。 - ring = densifyGeodesicRing(basePoints) + ring = densifyPolygonRing(basePoints, geodesic: true) } else { ring = basePoints } @@ -114,7 +114,7 @@ public final class PolygonManager: PolygonManagerProtocol { for holePoints in state.holes { if holePoints.count < 3 { continue } // 穴も外周と同じく geodesic なら大圏で補間する(android-sdk / js-sdk-core と同じ)。 - let holeRing = state.geodesic ? densifyGeodesicRing(holePoints) : holePoints + let holeRing = state.geodesic ? densifyPolygonRing(holePoints, geodesic: true) : holePoints guard let holeFirst = holeRing.first else { continue } let holeLast = holeRing.last let holeIsClosed = holeLast.map { GeoPoint.from(position: holeFirst) == GeoPoint.from(position: $0) } ?? false diff --git a/Tests/MapConductorCoreTests/GeodesicPolygonRingTests.swift b/Tests/MapConductorCoreTests/GeodesicPolygonRingTests.swift index fca474a..11700cb 100644 --- a/Tests/MapConductorCoreTests/GeodesicPolygonRingTests.swift +++ b/Tests/MapConductorCoreTests/GeodesicPolygonRingTests.swift @@ -3,7 +3,8 @@ import XCTest @testable import MapConductorCore -/// geodesic ポリゴンの閉じる辺(末尾 → 先頭)も大圏で補間すること。 +/// ポリゴンの閉じる辺(末尾 → 先頭)も他の辺と同じ方式で補間すること。geodesic は大圏、 +/// geodesic=false は緯度経度の線形補間。どちらも塗り・穴・輪郭が同じ点列になる。 /// /// js-sdk-core の `GeodesicPolygonRing.test.mjs`、android-sdk の `GeodesicPolygonRingTest` と 1 対 1 で対応する。 /// @@ -53,8 +54,8 @@ final class GeodesicPolygonRingTests: XCTestCase { XCTAssertEqual(chordLat, 51.6, accuracy: 0.05) } - func testDensifyGeodesicRingInterpolatesClosingEdgeAndKeepsRingOpen() { - let ring = densifyGeodesicRing(triangle) + func testDensifyPolygonRingInterpolatesClosingEdgeAndKeepsRingOpen() { + let ring = densifyPolygonRing(triangle, geodesic: true) XCTAssertTrue(same(boston, ring[0])) // 先頭の重複点を末尾に付けない(閉じて補間した結果から末尾の 1 点だけを除いた長さ)。 // createInterpolatePoints は分割比を足し込むため、末尾の手前に終点とほぼ同じ点が残ることがある。 @@ -63,9 +64,18 @@ final class GeodesicPolygonRingTests: XCTestCase { assertSamePoints(Array(expected.dropFirst().dropLast()), Array(ring.suffix(expected.count - 2))) } - func testDensifyGeodesicRingKeepsClosedRingClosed() { + func testDensifyPolygonRingKeepsClosedRingClosed() { let closed = triangle + [boston] - assertSamePoints(WGS84Geodesic.createInterpolatePoints(closed), densifyGeodesicRing(closed)) + assertSamePoints(WGS84Geodesic.createInterpolatePoints(closed), densifyPolygonRing(closed, geodesic: true)) + assertSamePoints(Planar.createInterpolatePoints(closed), densifyPolygonRing(closed, geodesic: false)) + } + + func testDensifyPolygonRingNonGeodesicInterpolatesClosingEdgeLinearly() { + let ring = densifyPolygonRing(triangle, geodesic: false) + XCTAssertTrue(same(boston, ring[0])) + XCTAssertEqual(Planar.createInterpolatePoints(triangle + [boston]).count - 1, ring.count) + let expected = Planar.createInterpolatePoints([iceland, boston]) + assertSamePoints(Array(expected.dropFirst().dropLast()), Array(ring.suffix(expected.count - 2))) } func testUnwrappedGeodesicFillMatchesOutline() { @@ -82,12 +92,22 @@ final class GeodesicPolygonRingTests: XCTestCase { assertSamePoints(outlineOf(triangle, geodesic: true), rings[0]) } - func testNonGeodesicFillIsUnchanged() { - let legacy = Planar.createInterpolatePoints(triangle).map { $0.normalize() } - let fill = buildUnwrappedPolygonRings(points: triangle, holes: [], geodesic: false).outerRings[0] - assertSamePoints(legacy, fill) - XCTAssertTrue(same(iceland, fill[fill.count - 1])) - assertSamePoints(legacy, buildPolygonRings(points: triangle, holes: [], geodesic: false).outerRings[0]) + /// geodesic=false の輪郭は閉じる辺も緯度経度で線形補間する(メルカトルではわずかに曲がる)。 + /// 塗りも同じ点列であること。以前は閉じる辺だけ補間されず、メルカトル上の直線(弦)だった。 + func testNonGeodesicFillMatchesOutlineIncludingClosingEdge() { + let unwrapped = buildUnwrappedPolygonRings(points: triangle, holes: [], geodesic: false).outerRings[0] + assertSamePoints(outlineOf(triangle, geodesic: false), unwrapped) + assertSamePoints( + outlineOf(triangle, geodesic: false), + buildPolygonRings(points: triangle, holes: [], geodesic: false).outerRings[0] + ) + // 閉じる辺(アイスランド → ボストン)の緯度経度の中点が点列に含まれる(弦の上ではない)。 + let mid = GeoPoint( + latitude: (iceland.latitude + boston.latitude) / 2, + longitude: (iceland.longitude + boston.longitude) / 2 + ) + let nearest = unwrapped.map { hypot($0.latitude - mid.latitude, $0.longitude - mid.longitude) }.min() ?? .infinity + XCTAssertLessThan(nearest, 0.1) } func testGeodesicHolesInterpolateClosingEdge() { @@ -105,9 +125,22 @@ final class GeodesicPolygonRingTests: XCTestCase { outlineOf(hole, geodesic: true), buildPolygonRings(points: outer, holes: [hole], geodesic: true).holeRings[0] ) + } + + func testNonGeodesicHolesInterpolateClosingEdge() { + let outer: [GeoPointProtocol] = [ + GeoPoint(latitude: 30.0, longitude: -80.0), + GeoPoint(latitude: 30.0, longitude: 10.0), + GeoPoint(latitude: 70.0, longitude: 10.0), + GeoPoint(latitude: 70.0, longitude: -80.0), + ] + let hole = triangle + let unwrapped = buildUnwrappedPolygonRings(points: outer, holes: [hole], geodesic: false) + assertSamePoints(outlineOf(hole, geodesic: false), unwrapped.holeRings[0]) + assertSamePoints(outlineOf(outer, geodesic: false), unwrapped.outerRings[0]) assertSamePoints( - Planar.createInterpolatePoints(hole).map { $0.normalize() }, - buildUnwrappedPolygonRings(points: outer, holes: [hole], geodesic: false).holeRings[0] + outlineOf(hole, geodesic: false), + buildPolygonRings(points: outer, holes: [hole], geodesic: false).holeRings[0] ) } @@ -126,4 +159,22 @@ final class GeodesicPolygonRingTests: XCTestCase { state.geodesic = false XCTAssertNil(manager.find(position: bulge)) } + + /// geodesic=false のタップ判定は生の頂点を緯度経度平面で判定する。閉じる辺は緯度経度の直線 + /// (= 線形補間した塗り・輪郭と同じ線)になり、メルカトルの弦ではない。 + func testNonGeodesicHitTestFollowsLinearClosingEdge() { + let midLng = (iceland.longitude + boston.longitude) / 2 + let linearLat = (iceland.latitude + boston.latitude) / 2 + func merc(_ lat: Double) -> Double { log(tan(Double.pi / 4 + lat * Double.pi / 360)) } + let chordLat = (2 * atan(exp((merc(iceland.latitude) + merc(boston.latitude)) / 2)) - Double.pi / 2) * 180 / Double.pi + // メルカトルの弦は緯度経度の直線より北を通る。その間は塗りの外。 + XCTAssertGreaterThan(chordLat, linearLat + 0.5) + let between = GeoPoint(latitude: (chordLat + linearLat) / 2, longitude: midLng) + let inside = GeoPoint(latitude: linearLat - 0.5, longitude: midLng) + + let manager = PolygonManager() + manager.registerEntity(PolygonEntity(polygon: 0, state: PolygonState(points: triangle, id: "p", geodesic: false))) + XCTAssertNil(manager.find(position: between)) + XCTAssertEqual(manager.find(position: inside)?.state.id, "p") + } } diff --git a/api/MapConductorCore.api.swift b/api/MapConductorCore.api.swift index 9cb21da..ece94cd 100644 --- a/api/MapConductorCore.api.swift +++ b/api/MapConductorCore.api.swift @@ -828,7 +828,7 @@ public struct PolygonRings { public init(outerRings: [[any MapConductorCore::GeoPointProtocol]], holeRings: [[any MapConductorCore::GeoPointProtocol]]) } public func densifyAndNormalize(_ points: [any MapConductorCore::GeoPointProtocol], geodesic: Swift::Bool, maxSegmentLength: Swift::Double = 10_000.0) -> [any MapConductorCore::GeoPointProtocol] -public func densifyGeodesicRing(_ ring: [any MapConductorCore::GeoPointProtocol], maxSegmentLength: Swift::Double = 10_000.0) -> [any MapConductorCore::GeoPointProtocol] +public func densifyPolygonRing(_ ring: [any MapConductorCore::GeoPointProtocol], geodesic: Swift::Bool, maxSegmentLength: Swift::Double = 10_000.0) -> [any MapConductorCore::GeoPointProtocol] public func buildPolylineSegments(_ points: [any MapConductorCore::GeoPointProtocol], geodesic: Swift::Bool) -> [[any MapConductorCore::GeoPointProtocol]] public func buildPolygonRings(points: [any MapConductorCore::GeoPointProtocol], holes: [[any MapConductorCore::GeoPointProtocol]], geodesic: Swift::Bool) -> MapConductorCore::PolygonRings public func buildUnwrappedPolylinePath(_ points: [any MapConductorCore::GeoPointProtocol], geodesic: Swift::Bool, maxSegmentLength: Swift::Double = 10_000.0) -> [any MapConductorCore::GeoPointProtocol]