Skip to content
Merged
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
48 changes: 44 additions & 4 deletions Sources/MapConductorCore/geometry/OverlayGeometry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,46 @@ public func densifyAndNormalize(
return interpolated.map { $0.normalize() }
}

/// ポリゴンのリング(外周・穴)を、閉じる辺(末尾 → 先頭)も含めて密度化する。
///
/// geodesic なら大圏(`WGS84Geodesic`)、そうでなければ緯度経度の線形補間(`Planar`)。
/// どちらの `createInterpolatePoints` も隣り合う点の間しか補間しないため、開いたリング
/// (末尾 ≠ 先頭)をそのまま渡すと閉じる辺だけが補間されず、描画側で閉じたときに直線の弦に
/// なる。ポリゴンの輪郭はリングを閉じてからポリラインとして密度化するので、塗りと輪郭で
/// 閉じる辺の形が食い違う(geodesic=false でも、線形補間の辺はメルカトル上でわずかに曲がる)。
/// ここでは閉じる辺も他の辺と同じ方式で補間する。
///
/// 入力が開いたリングなら開いたまま(末尾に先頭の重複点を付けない)、閉じたリングなら閉じた
/// まま返す。正規化はしない。頂点 2 未満の入力はそのまま `createInterpolatePoints` に渡す。
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 densify(ring)
}
return Array(densify(ring + [first]).dropLast())
}

/// リング用の `densifyAndNormalize`。閉じる辺も補間する(`densifyPolygonRing`)。
/// `densifyAndNormalize` と同じく、`maxSegmentLength` は geodesic のときだけ使う(線形補間は 10 km)。
private func densifyRingAndNormalize(
_ ring: [GeoPointProtocol],
geodesic: Bool,
maxSegmentLength: Double = 10_000.0
) -> [GeoPointProtocol] {
densifyPolygonRing(ring, geodesic: geodesic, maxSegmentLength: geodesic ? maxSegmentLength : 10_000.0)
.map { $0.normalize() }
}

// ─── 分割版パイプライン(経度 ±180 に制約のある SDK 向け) ─────────────────────
//
// 経度 ±180 を超える座標を受け付けない SDK(ネイティブオーバーレイ系)は、密度化・正規化後に
Expand Down Expand Up @@ -61,14 +101,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)
Expand Down Expand Up @@ -122,14 +162,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
)
}
Expand Down
11 changes: 7 additions & 4 deletions Sources/MapConductorCore/polygon/PolygonManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,8 @@ public final class PolygonManager<ActualPolygon>: PolygonManagerProtocol {

let ring: [GeoPointProtocol]
if state.geodesic {
ring = WGS84Geodesic.createInterpolatePoints(basePoints)
// 閉じる辺(末尾 → 先頭)も大圏で補間する(描画と同じ形で判定する)。
ring = densifyPolygonRing(basePoints, geodesic: true)
} else {
ring = basePoints
}
Expand All @@ -112,10 +113,12 @@ public final class PolygonManager<ActualPolygon>: 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 ? 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
let closedHole = holeIsClosed ? holePoints : (holePoints + [holeFirst])
let closedHole = holeIsClosed ? holeRing : (holeRing + [holeFirst])
if pointInPolygonWindingNumber(testX: testX, testY: testY, ring: closedHole) {
insideHole = true
break
Expand Down
180 changes: 180 additions & 0 deletions Tests/MapConductorCoreTests/GeodesicPolygonRingTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import Foundation
import XCTest

@testable import MapConductorCore

/// ポリゴンの閉じる辺(末尾 → 先頭)も他の辺と同じ方式で補間すること。geodesic は大圏、
/// geodesic=false は緯度経度の線形補間。どちらも塗り・穴・輪郭が同じ点列になる。
///
/// 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 testDensifyPolygonRingInterpolatesClosingEdgeAndKeepsRingOpen() {
let ring = densifyPolygonRing(triangle, geodesic: true)
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 testDensifyPolygonRingKeepsClosedRingClosed() {
let closed = triangle + [boston]
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() {
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])
}

/// 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() {
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]
)
}

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(
outlineOf(hole, geodesic: false),
buildPolygonRings(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<Int>()
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))
}

/// 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<Int>()
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")
}
}
1 change: 1 addition & 0 deletions api/MapConductorCore.api.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 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]
Expand Down
Loading