From c7feb4f69852db90607ef84e97705ed13c1feedb Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 21:23:01 -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. geodesic=false is unchanged. The public API only gains the new function. Same change as js-sdk-core. --- api/android-sdk-core.api | 1 + .../core/geometry/OverlayGeometry.kt | 49 +++++- .../core/polygon/PolygonManager.kt | 6 +- .../core/geometry/GeodesicPolygonRingTest.kt | 153 ++++++++++++++++++ .../core/geometry/OverlayGeometryTest.kt | 3 +- 5 files changed, 204 insertions(+), 8 deletions(-) create mode 100644 src/test/java/com/mapconductor/core/geometry/GeodesicPolygonRingTest.kt diff --git a/api/android-sdk-core.api b/api/android-sdk-core.api index bbdf101..5127224 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 densifyGeodesicRing(java.util.List, 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..eb07e92 100644 --- a/src/main/java/com/mapconductor/core/geometry/OverlayGeometry.kt +++ b/src/main/java/com/mapconductor/core/geometry/OverlayGeometry.kt @@ -25,6 +25,47 @@ fun densifyAndNormalize( false -> Planar.createInterpolatePoints(points) }.map { it.normalize() } +/** + * ポリゴンのリング(外周・穴)を測地線(大圏)で密度化する。 + * + * [WGS84Geodesic.createInterpolatePoints] は隣り合う点の間しか補間しないため、開いたリング + * (末尾 ≠ 先頭)を渡すと閉じる辺(末尾 → 先頭)が補間されず、描画側で閉じたときに直線の弦に + * なる。ポリゴンの輪郭はリングを閉じてからポリラインとして密度化するので、塗りと輪郭で + * 閉じる辺の形が食い違う。ここでは閉じる辺も含めて補間する。 + * + * 入力が開いたリングなら開いたまま(末尾に先頭の重複点を付けない)、閉じたリングなら閉じた + * まま返す。正規化はしない。頂点 2 未満の入力はそのまま [WGS84Geodesic.createInterpolatePoints] に渡す。 + */ +fun densifyGeodesicRing( + ring: List, + maxSegmentLength: Double = 10000.0, +): List { + if (ring.size < 2 || isClosedRing(ring)) { + return WGS84Geodesic.createInterpolatePoints(ring, maxSegmentLength) + } + return WGS84Geodesic.createInterpolatePoints(ring + ring.first(), maxSegmentLength).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]。geodesic のときは閉じる辺も補間する([densifyGeodesicRing])。 + * geodesic=false は従来どおり [densifyAndNormalize] と同じ。 + */ +private fun densifyRingAndNormalize( + ring: List, + geodesic: Boolean, +): List = + if (geodesic) { + densifyGeodesicRing(ring).map { it.normalize() } + } else { + densifyAndNormalize(ring, geodesic = false) + } + /** * ポリライン用パイプライン。密度化・正規化後に子午線で分割したセグメント列を返す。 * 頂点 2 未満の入力、および分割で 2 点未満になったセグメントは除く(空リストになり得る)。 @@ -61,14 +102,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 +178,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..d586420 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.densifyGeodesicRing 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) densifyGeodesicRing(basePoints) 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) densifyGeodesicRing(hole) 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..20ce440 --- /dev/null +++ b/src/test/java/com/mapconductor/core/geometry/GeodesicPolygonRingTest.kt @@ -0,0 +1,153 @@ +package com.mapconductor.core.geometry + +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.features.GeoPointInterface +import com.mapconductor.core.features.normalize +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 ポリゴンの閉じる辺(末尾 → 先頭)も大圏で補間すること。 + * + * 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 densifyGeodesicRing_interpolatesClosingEdge_andKeepsRingOpen() { + val ring = densifyGeodesicRing(triangle) + 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 densifyGeodesicRing_keepsClosedRingClosed() { + val closed = triangle + boston + assertSamePoints(WGS84Geodesic.createInterpolatePoints(closed), densifyGeodesicRing(closed)) + } + + @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()) + } + + @Test + fun nonGeodesicFill_isUnchanged() { + val legacy = Planar.createInterpolatePoints(triangle).map { it.normalize() } + val fill = buildUnwrappedPolygonRings(triangle, emptyList(), geodesic = false).outerRings.single() + assertSamePoints(legacy, fill) + assertTrue(same(iceland, fill.last())) + assertSamePoints(legacy, buildPolygonRings(triangle, emptyList(), geodesic = false).outerRings.single()) + } + + @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()) + assertSamePoints( + Planar.createInterpolatePoints(hole).map { it.normalize() }, + buildUnwrappedPolygonRings(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)) + } +} diff --git a/src/test/java/com/mapconductor/core/geometry/OverlayGeometryTest.kt b/src/test/java/com/mapconductor/core/geometry/OverlayGeometryTest.kt index 5008e33..14dd701 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(densifyGeodesicRing(hole).map { it.normalize() }, rings.holeRings.first()) } @Test From bb8c27d477cb8be05dcb0e0d31d3197eb9659e25 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 23:12:46 -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(ring, geodesic, maxSegmentLength), which closes the ring before densifying with WGS84Geodesic or Planar, and use it for every ring in buildPolygonRings / buildUnwrappedPolygonRings. 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. --- api/android-sdk-core.api | 2 +- .../core/geometry/OverlayGeometry.kt | 39 +++++---- .../core/polygon/PolygonManager.kt | 6 +- .../core/geometry/GeodesicPolygonRingTest.kt | 83 +++++++++++++++---- .../core/geometry/OverlayGeometryTest.kt | 2 +- 5 files changed, 91 insertions(+), 41 deletions(-) diff --git a/api/android-sdk-core.api b/api/android-sdk-core.api index 5127224..df5319a 100644 --- a/api/android-sdk-core.api +++ b/api/android-sdk-core.api @@ -516,7 +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 densifyGeodesicRing(java.util.List, double); + 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 eb07e92..0d6289e 100644 --- a/src/main/java/com/mapconductor/core/geometry/OverlayGeometry.kt +++ b/src/main/java/com/mapconductor/core/geometry/OverlayGeometry.kt @@ -26,24 +26,31 @@ fun densifyAndNormalize( }.map { it.normalize() } /** - * ポリゴンのリング(外周・穴)を測地線(大圏)で密度化する。 + * ポリゴンのリング(外周・穴)を、閉じる辺(末尾 → 先頭)も含めて密度化する。 * - * [WGS84Geodesic.createInterpolatePoints] は隣り合う点の間しか補間しないため、開いたリング - * (末尾 ≠ 先頭)を渡すと閉じる辺(末尾 → 先頭)が補間されず、描画側で閉じたときに直線の弦に + * geodesic なら大圏([WGS84Geodesic])、そうでなければ緯度経度の線形補間([Planar])。 + * どちらの `createInterpolatePoints` も隣り合う点の間しか補間しないため、開いたリング + * (末尾 ≠ 先頭)をそのまま渡すと閉じる辺だけが補間されず、描画側で閉じたときに直線の弦に * なる。ポリゴンの輪郭はリングを閉じてからポリラインとして密度化するので、塗りと輪郭で - * 閉じる辺の形が食い違う。ここでは閉じる辺も含めて補間する。 + * 閉じる辺の形が食い違う(geodesic=false でも、線形補間の辺はメルカトル上でわずかに曲がる)。 + * ここでは閉じる辺も他の辺と同じ方式で補間する。 * * 入力が開いたリングなら開いたまま(末尾に先頭の重複点を付けない)、閉じたリングなら閉じた - * まま返す。正規化はしない。頂点 2 未満の入力はそのまま [WGS84Geodesic.createInterpolatePoints] に渡す。 + * まま返す。正規化はしない。頂点 2 未満の入力はそのまま `createInterpolatePoints` に渡す。 */ -fun densifyGeodesicRing( +fun densifyPolygonRing( ring: List, + geodesic: Boolean, maxSegmentLength: Double = 10000.0, ): List { - if (ring.size < 2 || isClosedRing(ring)) { - return WGS84Geodesic.createInterpolatePoints(ring, maxSegmentLength) - } - return WGS84Geodesic.createInterpolatePoints(ring + ring.first(), maxSegmentLength).dropLast(1) + 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 { @@ -52,19 +59,11 @@ private fun isClosedRing(ring: List): Boolean { return first.latitude == last.latitude && first.longitude == last.longitude } -/** - * リング用の [densifyAndNormalize]。geodesic のときは閉じる辺も補間する([densifyGeodesicRing])。 - * geodesic=false は従来どおり [densifyAndNormalize] と同じ。 - */ +/** リング用の [densifyAndNormalize]。閉じる辺も補間する([densifyPolygonRing])。 */ private fun densifyRingAndNormalize( ring: List, geodesic: Boolean, -): List = - if (geodesic) { - densifyGeodesicRing(ring).map { it.normalize() } - } else { - densifyAndNormalize(ring, geodesic = false) - } +): List = densifyPolygonRing(ring, geodesic).map { it.normalize() } /** * ポリライン用パイプライン。密度化・正規化後に子午線で分割したセグメント列を返す。 diff --git a/src/main/java/com/mapconductor/core/polygon/PolygonManager.kt b/src/main/java/com/mapconductor/core/polygon/PolygonManager.kt index d586420..e325f59 100644 --- a/src/main/java/com/mapconductor/core/polygon/PolygonManager.kt +++ b/src/main/java/com/mapconductor/core/polygon/PolygonManager.kt @@ -1,7 +1,7 @@ package com.mapconductor.core.polygon import com.mapconductor.core.features.GeoPointInterface -import com.mapconductor.core.geometry.densifyGeodesicRing +import com.mapconductor.core.geometry.densifyPolygonRing import com.mapconductor.core.normalizeLng import java.util.concurrent.ConcurrentHashMap import kotlin.math.abs @@ -60,7 +60,7 @@ class PolygonManager : PolygonManagerInterface { // Densify edges to better approximate geodesic/linear edges val ring = try { - if (state.geodesic) densifyGeodesicRing(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) densifyGeodesicRing(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 index 20ce440..2edb524 100644 --- a/src/test/java/com/mapconductor/core/geometry/GeodesicPolygonRingTest.kt +++ b/src/test/java/com/mapconductor/core/geometry/GeodesicPolygonRingTest.kt @@ -2,7 +2,6 @@ package com.mapconductor.core.geometry import com.mapconductor.core.features.GeoPoint import com.mapconductor.core.features.GeoPointInterface -import com.mapconductor.core.features.normalize import com.mapconductor.core.polygon.PolygonEntity import com.mapconductor.core.polygon.PolygonManager import com.mapconductor.core.polygon.PolygonState @@ -22,7 +21,8 @@ import kotlin.math.ln import kotlin.math.tan /** - * geodesic ポリゴンの閉じる辺(末尾 → 先頭)も大圏で補間すること。 + * ポリゴンの閉じる辺(末尾 → 先頭)も他の辺と同じ方式で補間すること。geodesic は大圏、 + * geodesic=false は緯度経度の線形補間。どちらも塗り・穴・輪郭が同じ点列になる。 * * js-sdk-core の `GeodesicPolygonRing.test.mjs`、ios-sdk の `GeodesicPolygonRingTests` と 1 対 1 で対応する。 * @@ -72,8 +72,8 @@ class GeodesicPolygonRingTest { } @Test - fun densifyGeodesicRing_interpolatesClosingEdge_andKeepsRingOpen() { - val ring = densifyGeodesicRing(triangle) + fun densifyPolygonRing_interpolatesClosingEdge_andKeepsRingOpen() { + val ring = densifyPolygonRing(triangle, geodesic = true) assertTrue(same(boston, ring.first())) // 先頭の重複点を末尾に付けない(閉じて補間した結果から末尾の 1 点だけを除いた長さ)。 // createInterpolatePoints は分割比を足し込むため、末尾の手前に終点とほぼ同じ点が残ることがある。 @@ -84,9 +84,19 @@ class GeodesicPolygonRingTest { } @Test - fun densifyGeodesicRing_keepsClosedRingClosed() { + fun densifyPolygonRing_keepsClosedRingClosed() { val 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)) + } + + @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 @@ -105,13 +115,20 @@ class GeodesicPolygonRingTest { assertSamePoints(outlineOf(triangle, true), rings.single()) } + /** + * geodesic=false の輪郭は閉じる辺も緯度経度で線形補間する(メルカトルではわずかに曲がる)。 + * 塗りも同じ点列であること。以前は閉じる辺だけ補間されず、メルカトル上の直線(弦)だった。 + */ @Test - fun nonGeodesicFill_isUnchanged() { - val legacy = Planar.createInterpolatePoints(triangle).map { it.normalize() } - val fill = buildUnwrappedPolygonRings(triangle, emptyList(), geodesic = false).outerRings.single() - assertSamePoints(legacy, fill) - assertTrue(same(iceland, fill.last())) - assertSamePoints(legacy, buildPolygonRings(triangle, emptyList(), geodesic = false).outerRings.single()) + 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 @@ -128,10 +145,22 @@ class GeodesicPolygonRingTest { 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()) - assertSamePoints( - Planar.createInterpolatePoints(hole).map { it.normalize() }, - buildUnwrappedPolygonRings(outer, listOf(hole), geodesic = false).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 @@ -150,4 +179,26 @@ class GeodesicPolygonRingTest { 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 14dd701..74a54a1 100644 --- a/src/test/java/com/mapconductor/core/geometry/OverlayGeometryTest.kt +++ b/src/test/java/com/mapconductor/core/geometry/OverlayGeometryTest.kt @@ -75,7 +75,7 @@ class OverlayGeometryTest { assertEquals(1, rings.outerRings.size) assertEquals(1, rings.holeRings.size) // geodesic の穴は閉じる辺(末尾 → 先頭)も補間する(GeodesicPolygonRingTest)。 - assertEquals(densifyGeodesicRing(hole).map { it.normalize() }, rings.holeRings.first()) + assertEquals(densifyPolygonRing(hole, geodesic = true).map { it.normalize() }, rings.holeRings.first()) } @Test