diff --git a/api/android-sdk-core.api b/api/android-sdk-core.api index bbdf101..df5319a 100644 --- a/api/android-sdk-core.api +++ b/api/android-sdk-core.api @@ -516,6 +516,7 @@ public final class com.mapconductor.core.geometry.OverlayGeoJson { } public final class com.mapconductor.core.geometry.OverlayGeometryKt { public static final java.util.List densifyAndNormalize(java.util.List, boolean); + public static final java.util.List densifyPolygonRing(java.util.List, boolean, double); public static final java.util.List> buildPolylineSegments(java.util.List, boolean); public static final com.mapconductor.core.geometry.PolygonRings buildPolygonRings(java.util.List, java.util.List>, boolean); public static final java.util.List closeRing(java.util.List); diff --git a/src/main/java/com/mapconductor/core/geometry/OverlayGeometry.kt b/src/main/java/com/mapconductor/core/geometry/OverlayGeometry.kt index 42afbc0..0d6289e 100644 --- a/src/main/java/com/mapconductor/core/geometry/OverlayGeometry.kt +++ b/src/main/java/com/mapconductor/core/geometry/OverlayGeometry.kt @@ -25,6 +25,46 @@ fun densifyAndNormalize( false -> Planar.createInterpolatePoints(points) }.map { it.normalize() } +/** + * ポリゴンのリング(外周・穴)を、閉じる辺(末尾 → 先頭)も含めて密度化する。 + * + * geodesic なら大圏([WGS84Geodesic])、そうでなければ緯度経度の線形補間([Planar])。 + * どちらの `createInterpolatePoints` も隣り合う点の間しか補間しないため、開いたリング + * (末尾 ≠ 先頭)をそのまま渡すと閉じる辺だけが補間されず、描画側で閉じたときに直線の弦に + * なる。ポリゴンの輪郭はリングを閉じてからポリラインとして密度化するので、塗りと輪郭で + * 閉じる辺の形が食い違う(geodesic=false でも、線形補間の辺はメルカトル上でわずかに曲がる)。 + * ここでは閉じる辺も他の辺と同じ方式で補間する。 + * + * 入力が開いたリングなら開いたまま(末尾に先頭の重複点を付けない)、閉じたリングなら閉じた + * まま返す。正規化はしない。頂点 2 未満の入力はそのまま `createInterpolatePoints` に渡す。 + */ +fun densifyPolygonRing( + ring: List, + geodesic: Boolean, + maxSegmentLength: Double = 10000.0, +): List { + val densify: (List) -> List = + if (geodesic) { + { WGS84Geodesic.createInterpolatePoints(it, maxSegmentLength) } + } else { + { Planar.createInterpolatePoints(it, maxSegmentLength) } + } + if (ring.size < 2 || isClosedRing(ring)) return densify(ring) + return densify(ring + ring.first()).dropLast(1) +} + +private fun isClosedRing(ring: List): Boolean { + val first = ring.first() + val last = ring.last() + return first.latitude == last.latitude && first.longitude == last.longitude +} + +/** リング用の [densifyAndNormalize]。閉じる辺も補間する([densifyPolygonRing])。 */ +private fun densifyRingAndNormalize( + ring: List, + geodesic: Boolean, +): List = densifyPolygonRing(ring, geodesic).map { it.normalize() } + /** * ポリライン用パイプライン。密度化・正規化後に子午線で分割したセグメント列を返す。 * 頂点 2 未満の入力、および分割で 2 点未満になったセグメントは除く(空リストになり得る)。 @@ -61,14 +101,14 @@ fun buildPolygonRings( ): PolygonRings { if (points.size < 3) return PolygonRings(emptyList(), emptyList()) val outerRings = - splitRingByMeridian(densifyAndNormalize(points, geodesic), geodesic) + splitRingByMeridian(densifyRingAndNormalize(points, geodesic), geodesic) .filter { it.size >= 3 } val includeHoles = holes.isNotEmpty() && outerRings.size == 1 val holeRings = if (includeHoles) { holes .filter { it.size >= 3 } - .map { densifyAndNormalize(it, geodesic) } + .map { densifyRingAndNormalize(it, geodesic) } .filter { it.size >= 3 } } else { emptyList() @@ -137,12 +177,12 @@ fun buildUnwrappedPolygonRings( geodesic: Boolean, ): PolygonRings { if (points.size < 3) return PolygonRings(emptyList(), emptyList()) - val outer = unwrapContinuous(densifyAndNormalize(points, geodesic)) + val outer = unwrapContinuous(densifyRingAndNormalize(points, geodesic)) val anchor = outer.first().longitude val holeRings = holes .filter { it.size >= 3 } - .map { unwrapContinuous(densifyAndNormalize(it, geodesic), anchorLng = anchor) } + .map { unwrapContinuous(densifyRingAndNormalize(it, geodesic), anchorLng = anchor) } .filter { it.size >= 3 } return PolygonRings(outerRings = listOf(outer), holeRings = holeRings) } diff --git a/src/main/java/com/mapconductor/core/polygon/PolygonManager.kt b/src/main/java/com/mapconductor/core/polygon/PolygonManager.kt index 90d0617..e325f59 100644 --- a/src/main/java/com/mapconductor/core/polygon/PolygonManager.kt +++ b/src/main/java/com/mapconductor/core/polygon/PolygonManager.kt @@ -1,8 +1,8 @@ package com.mapconductor.core.polygon import com.mapconductor.core.features.GeoPointInterface +import com.mapconductor.core.geometry.densifyPolygonRing import com.mapconductor.core.normalizeLng -import com.mapconductor.core.spherical.WGS84Geodesic import java.util.concurrent.ConcurrentHashMap import kotlin.math.abs import kotlin.math.max @@ -60,7 +60,7 @@ class PolygonManager : PolygonManagerInterface { // Densify edges to better approximate geodesic/linear edges val ring = try { - if (state.geodesic) WGS84Geodesic.createInterpolatePoints(basePoints) else basePoints + if (state.geodesic) densifyPolygonRing(basePoints, geodesic = true) else basePoints } catch (_: Exception) { basePoints } @@ -77,7 +77,7 @@ class PolygonManager : PolygonManagerInterface { if (hole.size < 3) continue val holeRing = try { - if (state.geodesic) WGS84Geodesic.createInterpolatePoints(hole) else hole + if (state.geodesic) densifyPolygonRing(hole, geodesic = true) else hole } catch (_: Exception) { hole } diff --git a/src/test/java/com/mapconductor/core/geometry/GeodesicPolygonRingTest.kt b/src/test/java/com/mapconductor/core/geometry/GeodesicPolygonRingTest.kt new file mode 100644 index 0000000..2edb524 --- /dev/null +++ b/src/test/java/com/mapconductor/core/geometry/GeodesicPolygonRingTest.kt @@ -0,0 +1,204 @@ +package com.mapconductor.core.geometry + +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.features.GeoPointInterface +import com.mapconductor.core.polygon.PolygonEntity +import com.mapconductor.core.polygon.PolygonManager +import com.mapconductor.core.polygon.PolygonState +import com.mapconductor.core.spherical.Planar +import com.mapconductor.core.spherical.WGS84Geodesic +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.atan +import kotlin.math.exp +import kotlin.math.hypot +import kotlin.math.ln +import kotlin.math.tan + +/** + * ポリゴンの閉じる辺(末尾 → 先頭)も他の辺と同じ方式で補間すること。geodesic は大圏、 + * geodesic=false は緯度経度の線形補間。どちらも塗り・穴・輪郭が同じ点列になる。 + * + * js-sdk-core の `GeodesicPolygonRing.test.mjs`、ios-sdk の `GeodesicPolygonRingTests` と 1 対 1 で対応する。 + * + * [WGS84Geodesic.createInterpolatePoints] は隣り合う点の間しか補間しない。ポリゴンの点は開いたリング + * (末尾 ≠ 先頭)で渡されるので、塗りのリングは閉じる辺だけが補間されず直線の弦になっていた。 + * 輪郭はリングを閉じてからポリラインとして密度化するので大圏になり、「輪郭は曲線、塗りの一辺だけ直線」になる。 + * Google のように塗りと輪郭を 1 つのネイティブポリゴンで描くプロバイダは、輪郭まで直線になる。 + */ +class GeodesicPolygonRingTest { + private val boston = GeoPoint.fromLatLong(42.36, -71.06) + private val london = GeoPoint.fromLatLong(51.5, -0.12) + private val iceland = GeoPoint.fromLatLong(64.1, -21.9) + + // 実機検証と同じ順序。閉じる辺は アイスランド → ボストン。 + private val triangle = listOf(boston, london, iceland) + + private fun same( + a: GeoPointInterface, + b: GeoPointInterface, + ): Boolean = abs(a.latitude - b.latitude) < 1e-9 && abs(a.longitude - b.longitude) < 1e-9 + + private fun assertSamePoints( + expected: List, + actual: List, + ) { + assertEquals(expected.size, actual.size) + expected.indices.forEach { i -> assertTrue("point $i: ${actual[i]} != ${expected[i]}", same(expected[i], actual[i])) } + } + + /** 輪郭(閉じたリングをポリラインとして密度化)から、末尾の重複点を除いたもの。 */ + private fun outlineOf( + points: List, + geodesic: Boolean, + ): List = buildUnwrappedPolylinePath(points + points.first(), geodesic).dropLast(1) + + @Test + fun greatCircleMidpoint_bostonToIceland_bulgesNorthwestByAboutFourDegrees() { + val mid = WGS84Geodesic.interpolate(boston, iceland, 0.5) + + // Web メルカトルでの直線(弦)が同じ経度で通る緯度。 + fun merc(lat: Double) = ln(tan(PI / 4 + lat * PI / 360)) + val t = (mid.longitude - boston.longitude) / (iceland.longitude - boston.longitude) + val y = merc(boston.latitude) + t * (merc(iceland.latitude) - merc(boston.latitude)) + val chordLat = (2 * atan(exp(y)) - PI / 2) * 180 / PI + assertTrue("mid ${mid.latitude}", abs(mid.latitude - 55.63) < 0.05) + assertTrue("chord $chordLat", abs(chordLat - 51.6) < 0.05) + } + + @Test + fun densifyPolygonRing_interpolatesClosingEdge_andKeepsRingOpen() { + val ring = densifyPolygonRing(triangle, geodesic = true) + assertTrue(same(boston, ring.first())) + // 先頭の重複点を末尾に付けない(閉じて補間した結果から末尾の 1 点だけを除いた長さ)。 + // createInterpolatePoints は分割比を足し込むため、末尾の手前に終点とほぼ同じ点が残ることがある。 + assertEquals(WGS84Geodesic.createInterpolatePoints(triangle + boston).size - 1, ring.size) + assertNotSame(boston, ring.last()) + val expected = WGS84Geodesic.createInterpolatePoints(listOf(iceland, boston)) + assertSamePoints(expected.subList(1, expected.size - 1), ring.takeLast(expected.size - 2)) + } + + @Test + fun densifyPolygonRing_keepsClosedRingClosed() { + val closed = triangle + boston + assertSamePoints(WGS84Geodesic.createInterpolatePoints(closed), densifyPolygonRing(closed, geodesic = true)) + assertSamePoints(Planar.createInterpolatePoints(closed), densifyPolygonRing(closed, geodesic = false)) + } + + @Test + fun densifyPolygonRing_nonGeodesic_interpolatesClosingEdgeLinearly() { + val ring = densifyPolygonRing(triangle, geodesic = false) + assertTrue(same(boston, ring.first())) + assertEquals(Planar.createInterpolatePoints(triangle + boston).size - 1, ring.size) + val expected = Planar.createInterpolatePoints(listOf(iceland, boston)) + assertSamePoints(expected.subList(1, expected.size - 1), ring.takeLast(expected.size - 2)) + } + + @Test + fun unwrappedGeodesicFill_matchesOutline() { + val fill = buildUnwrappedPolygonRings(triangle, emptyList(), geodesic = true).outerRings.single() + assertSamePoints(outlineOf(triangle, true), fill) + val mid = WGS84Geodesic.interpolate(iceland, boston, 0.5) + val nearest = fill.minOf { hypot(it.latitude - mid.latitude, it.longitude - mid.longitude) } + assertTrue("nearest $nearest", nearest < 0.1) + } + + @Test + fun splitGeodesicFill_interpolatesClosingEdge() { + val rings = buildPolygonRings(triangle, emptyList(), geodesic = true).outerRings + assertEquals(1, rings.size) + assertSamePoints(outlineOf(triangle, true), rings.single()) + } + + /** + * geodesic=false の輪郭は閉じる辺も緯度経度で線形補間する(メルカトルではわずかに曲がる)。 + * 塗りも同じ点列であること。以前は閉じる辺だけ補間されず、メルカトル上の直線(弦)だった。 + */ + @Test + fun nonGeodesicFill_matchesOutline_includingClosingEdge() { + val unwrapped = buildUnwrappedPolygonRings(triangle, emptyList(), geodesic = false).outerRings.single() + assertSamePoints(outlineOf(triangle, false), unwrapped) + assertSamePoints(outlineOf(triangle, false), buildPolygonRings(triangle, emptyList(), geodesic = false).outerRings.single()) + + // 閉じる辺(アイスランド → ボストン)の緯度経度の中点が点列に含まれる(弦の上ではない)。 + val mid = GeoPoint.fromLatLong((iceland.latitude + boston.latitude) / 2, (iceland.longitude + boston.longitude) / 2) + val nearest = unwrapped.minOf { hypot(it.latitude - mid.latitude, it.longitude - mid.longitude) } + assertTrue("nearest $nearest", nearest < 0.1) + } + + @Test + fun geodesicHoles_interpolateClosingEdge() { + val outer = + listOf( + GeoPoint.fromLatLong(30.0, -80.0), + GeoPoint.fromLatLong(30.0, 10.0), + GeoPoint.fromLatLong(70.0, 10.0), + GeoPoint.fromLatLong(70.0, -80.0), + ) + val hole = triangle + val unwrapped = buildUnwrappedPolygonRings(outer, listOf(hole), geodesic = true) + assertSamePoints(outlineOf(hole, true), unwrapped.holeRings.single()) + assertSamePoints(outlineOf(outer, true), unwrapped.outerRings.single()) + assertSamePoints(outlineOf(hole, true), buildPolygonRings(outer, listOf(hole), geodesic = true).holeRings.single()) + } + + @Test + fun nonGeodesicHoles_interpolateClosingEdge() { + val outer = + listOf( + GeoPoint.fromLatLong(30.0, -80.0), + GeoPoint.fromLatLong(30.0, 10.0), + GeoPoint.fromLatLong(70.0, 10.0), + GeoPoint.fromLatLong(70.0, -80.0), + ) + val hole = triangle + val unwrapped = buildUnwrappedPolygonRings(outer, listOf(hole), geodesic = false) + assertSamePoints(outlineOf(hole, false), unwrapped.holeRings.single()) + assertSamePoints(outlineOf(outer, false), unwrapped.outerRings.single()) + assertSamePoints(outlineOf(hole, false), buildPolygonRings(outer, listOf(hole), geodesic = false).holeRings.single()) + } + + @Test + fun hitTest_followsGreatCircleOnClosingEdge() { + // 弦と大圏の間(北西へ膨らんだ部分)の点。 + val mid = WGS84Geodesic.interpolate(iceland, boston, 0.5) + val t = (mid.longitude - iceland.longitude) / (boston.longitude - iceland.longitude) + val chordLat = iceland.latitude + t * (boston.latitude - iceland.latitude) + val bulge = GeoPoint.fromLatLong((mid.latitude + chordLat) / 2, mid.longitude) + + val manager = PolygonManager() + val state = PolygonState(points = triangle, id = "g", geodesic = true) + manager.registerEntity(PolygonEntity(polygon = Unit, state = state)) + assertEquals("g", manager.find(bulge)?.state?.id) + + state.geodesic = false + assertNull(manager.find(bulge)) + } + + /** + * geodesic=false のタップ判定は生の頂点を緯度経度平面で判定する。閉じる辺は緯度経度の直線 + * (= 線形補間した塗り・輪郭と同じ線)になり、メルカトルの弦ではない。 + */ + @Test + fun nonGeodesicHitTest_followsLinearClosingEdge() { + val midLng = (iceland.longitude + boston.longitude) / 2 + val linearLat = (iceland.latitude + boston.latitude) / 2 + + fun merc(lat: Double) = ln(tan(PI / 4 + lat * PI / 360)) + val chordLat = (2 * atan(exp((merc(iceland.latitude) + merc(boston.latitude)) / 2)) - PI / 2) * 180 / PI + // メルカトルの弦は緯度経度の直線より北を通る。その間は塗りの外。 + assertTrue("chord $chordLat linear $linearLat", chordLat > linearLat + 0.5) + val between = GeoPoint.fromLatLong((chordLat + linearLat) / 2, midLng) + val inside = GeoPoint.fromLatLong(linearLat - 0.5, midLng) + + val manager = PolygonManager() + manager.registerEntity(PolygonEntity(polygon = Unit, state = PolygonState(points = triangle, id = "p", geodesic = false))) + assertNull(manager.find(between)) + assertEquals("p", manager.find(inside)?.state?.id) + } +} diff --git a/src/test/java/com/mapconductor/core/geometry/OverlayGeometryTest.kt b/src/test/java/com/mapconductor/core/geometry/OverlayGeometryTest.kt index 5008e33..74a54a1 100644 --- a/src/test/java/com/mapconductor/core/geometry/OverlayGeometryTest.kt +++ b/src/test/java/com/mapconductor/core/geometry/OverlayGeometryTest.kt @@ -74,7 +74,8 @@ class OverlayGeometryTest { val rings = buildPolygonRings(outer, listOf(hole), geodesic = true) assertEquals(1, rings.outerRings.size) assertEquals(1, rings.holeRings.size) - assertEquals(legacyInterpolate(hole, true), rings.holeRings.first()) + // geodesic の穴は閉じる辺(末尾 → 先頭)も補間する(GeodesicPolygonRingTest)。 + assertEquals(densifyPolygonRing(hole, geodesic = true).map { it.normalize() }, rings.holeRings.first()) } @Test