diff --git a/api/android-for-arcgis.api b/api/android-for-arcgis.api index 1b544a5..60defdf 100644 --- a/api/android-for-arcgis.api +++ b/api/android-for-arcgis.api @@ -347,6 +347,12 @@ public final class com.mapconductor.arcgis.GeoPointKt { public final class com.mapconductor.arcgis.GeoRectBoundsKt { public static final com.arcgismaps.geometry.Envelope toEnvelope(com.mapconductor.core.features.GeoRectBounds); } +public final class com.mapconductor.arcgis.IdentityWeakTable { + public com.mapconductor.arcgis.IdentityWeakTable(); + public final V get(K); + public final void put(K, V); + public final int getSize(); +} public final class com.mapconductor.arcgis.MapCameraPositionKt { public static final double EARTH_MEAN_RADIUS_METERS; public static final double DEFAULT_MAX_GMAPS_TILT; @@ -361,6 +367,13 @@ public final class com.mapconductor.arcgis.MapCameraPositionKt { public static final com.arcgismaps.mapping.view.Camera withZoomLevel(com.arcgismaps.mapping.view.Camera, double); public static final com.mapconductor.core.map.MapCameraPosition toMapCameraPosition(com.arcgismaps.mapping.view.Camera); } +public final class com.mapconductor.arcgis.WebMercatorTileLevels { + public static final com.mapconductor.arcgis.WebMercatorTileLevels INSTANCE; + public static final int DEFAULT_DPI; + public final double unifiedZoomForLevel(int, int); + public final double resolution(int, int); + public final double scale(double); +} public final class com.mapconductor.arcgis.WrapMapView extends android.widget.FrameLayout { public static final com.mapconductor.arcgis.WrapMapView$Companion Companion; public com.arcgismaps.mapping.view.MapView arcGISMapView; diff --git a/src/main/java/com/mapconductor/arcgis/ArcGISOverlayStacking.kt b/src/main/java/com/mapconductor/arcgis/ArcGISOverlayStacking.kt index 1b9bfe6..caac2c7 100644 --- a/src/main/java/com/mapconductor/arcgis/ArcGISOverlayStacking.kt +++ b/src/main/java/com/mapconductor/arcgis/ArcGISOverlayStacking.kt @@ -3,7 +3,7 @@ package com.mapconductor.arcgis import com.arcgismaps.mapping.layers.Layer import com.mapconductor.core.controller.OverlayKind import com.mapconductor.core.controller.OverlayStacking -import java.util.WeakHashMap +import java.lang.ref.WeakReference /** * 重なり順([OverlayStacking])。ArcGIS では: @@ -18,7 +18,7 @@ import java.util.WeakHashMap * 下になる(SDK の制約。マーカーの帯には置けない)。operational layer の中では一番上に置く。 */ internal object ArcGISOverlayStacking { - private val keys = WeakHashMap() + private val keys = IdentityWeakTable() /** マーカーのタイル: operational layer の中で一番上。 */ val MARKER_TILE = OverlayStacking.Key(OverlayKind.Marker, Int.MIN_VALUE) @@ -36,7 +36,7 @@ internal object ArcGISOverlayStacking { ) { val before = synchronized(keys) { - keys[layer] = key + keys.put(layer, key) OverlayStacking.insertBefore(layers.toList(), key) { keys[it] } } val index = before?.let { layers.indexOf(it) } ?: -1 @@ -48,6 +48,30 @@ internal object ArcGISOverlayStacking { old: Layer, new: Layer, ) { - synchronized(keys) { keys[old]?.let { keys[new] = it } } + synchronized(keys) { keys[old]?.let { keys.put(new, it) } } } } + +/** + * 層 → 値の表。**同一性(===)で引き**、層は弱参照で持つ。 + * + * 以前は `WeakHashMap` だったが、ArcGIS の `Layer` は `equals` / `hashCode` を + * 上書きしていて、載せた後に同じ層を引いても見つからないことがあった(3D で、GroundImage を + * 先に載せてからラスターを足すと、GroundImage の鍵が引けずにラスターが一番上に積まれた)。 + * 層の数は高々数十なので線形探索でよい。呼び出し側で同期する。 + */ +internal class IdentityWeakTable { + private val entries = ArrayList, V>>() + + operator fun get(key: K): V? = entries.firstOrNull { it.first.get() === key }?.second + + fun put( + key: K, + value: V, + ) { + entries.removeAll { it.first.get().let { k -> k == null || k === key } } + entries.add(WeakReference(key) to value) + } + + val size: Int get() = entries.count { it.first.get() != null } +} diff --git a/src/main/java/com/mapconductor/arcgis/WebMercatorTileLevels.kt b/src/main/java/com/mapconductor/arcgis/WebMercatorTileLevels.kt new file mode 100644 index 0000000..a1c793c --- /dev/null +++ b/src/main/java/com/mapconductor/arcgis/WebMercatorTileLevels.kt @@ -0,0 +1,46 @@ +package com.mapconductor.arcgis + +import com.mapconductor.core.zoom.AbstractZoomAltitudeConverter +import kotlin.math.log2 +import kotlin.math.pow + +/** + * WebTiledLayer に渡すレベルの梯子の数字。ローカルのタイル(ラスター・GroundImage)で共有する。 + * + * 梯子は**統一ズーム**(Google Maps 基準・256px タイル)から組む。 + * [AbstractZoomAltitudeConverter.WEB_MERCATOR_INITIAL_MPP_256] が統一ズーム 0 の + * 1px あたりメートル数。512px のタイルは 1 枚で 256px タイル 2 枚ぶんを覆うので、 + * level `z` は統一ズーム `z + 1` に対応する。 + * + * 1 枚のタイルが覆う地面は `resolution * tileSize` なので、解像度は**必ず tileSize 基準**で + * 刻む。256 基準のまま tileSize に 512 を渡すと level L のタイルが L-1 の広さを覆い、 + * ArcGIS は番号だけ 1 段深い (z, x, y) を要求する(ArcGIS 300 で計測。200.x の + * SceneView は自前の 256 格子で (col, row) を数えていたので、その読み替えで合っていた)。 + * ラスターは d0f4819 でこれに直したが、GroundImage は同じ読み替えを自前で持ったまま + * 残っていて、地球の別の場所の(空の)タイルを引いて何も表示されなかった。 + * 二度と片方だけ直すことにならないよう、ここ 1 か所で持つ。 + */ +internal object WebMercatorTileLevels { + /** 統一ズームが基準にしているタイルの一辺(Google 準拠の 256px)。 */ + private const val UNIFIED_TILE_SIZE = 256.0 + const val DEFAULT_DPI = 96 + private const val INCHES_PER_METER = 39.37 + + /** この level のタイルを引くべき統一ズーム。256px なら level そのもの。 */ + fun unifiedZoomForLevel( + level: Int, + tileSize: Int, + ): Double = level + log2(tileSize.toDouble() / UNIFIED_TILE_SIZE) + + /** level のタイル 1px あたりのメートル数。`resolution * tileSize` が level のタイル 1 枚の地面の幅。 */ + fun resolution( + level: Int, + tileSize: Int, + ): Double = AbstractZoomAltitudeConverter.WEB_MERCATOR_INITIAL_MPP_256 / 2.0.pow(unifiedZoomForLevel(level, tileSize)) + + /** + * 縮尺の分母。2D の MapView はこの梯子を見て level を選ぶので、解像度と同じ比率で + * 刻んでいないと 1 段ずれる。3D の SceneView は見ていない(自前の 256px の梯子で選ぶ)。 + */ + fun scale(resolution: Double): Double = resolution * DEFAULT_DPI * INCHES_PER_METER +} diff --git a/src/main/java/com/mapconductor/arcgis/groundimage/ArcGISGroundImageOverlayRenderer.kt b/src/main/java/com/mapconductor/arcgis/groundimage/ArcGISGroundImageOverlayRenderer.kt index 3dd071e..e35e8d9 100644 --- a/src/main/java/com/mapconductor/arcgis/groundimage/ArcGISGroundImageOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/arcgis/groundimage/ArcGISGroundImageOverlayRenderer.kt @@ -9,16 +9,14 @@ import com.arcgismaps.mapping.layers.TileInfo import com.arcgismaps.mapping.layers.WebTiledLayer import com.mapconductor.arcgis.ArcGISGeoViewHolder import com.mapconductor.arcgis.ArcGISOverlayStacking +import com.mapconductor.arcgis.WebMercatorTileLevels import com.mapconductor.core.controller.OverlayKind import com.mapconductor.core.groundimage.AbstractGroundImageOverlayRenderer import com.mapconductor.core.groundimage.GroundImageEntityInterface import com.mapconductor.core.groundimage.GroundImageState import com.mapconductor.core.groundimage.GroundImageTileProvider -import com.mapconductor.core.projection.Earth import com.mapconductor.core.projection.WEB_MERCATOR_MAX_EXTENT_METERS import com.mapconductor.core.tileserver.LocalTileServer -import kotlin.math.PI -import kotlin.math.pow import android.util.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -168,7 +166,7 @@ class ArcGISGroundImageOverlayRenderer( private fun buildWebMercatorTileInfo(tileSize: Int): TileInfo { val spatialReference = SpatialReference(WEB_MERCATOR_WKID) val origin = Point(WEB_MERCATOR_MIN, WEB_MERCATOR_MAX, spatialReference) - val levels = buildWebMercatorLevels(resolveLodReferenceTileSize(tileSize)) + val levels = buildWebMercatorLevels(tileSize) return TileInfo( DEFAULT_DPI, TileImageFormat.Png, @@ -180,22 +178,14 @@ class ArcGISGroundImageOverlayRenderer( ) } - private fun buildWebMercatorLevels(tileSize: Int): List { - val initialResolution = - (2.0 * PI * WEB_MERCATOR_RADIUS_METERS) / tileSize.toDouble() - val levels = mutableListOf() - for (level in MIN_ZOOM..MAX_ZOOM) { - val resolution = initialResolution / 2.0.pow(level.toDouble()) - val scale = resolution * DEFAULT_DPI * INCHES_PER_METER - levels.add(LevelOfDetail(level, resolution, scale)) - } - return levels - } - - private fun resolveLodReferenceTileSize(tileSize: Int): Int = - when (tileSize) { - 512 -> 256 - else -> tileSize + // 梯子はラスターと同じもの([WebMercatorTileLevels])を使う。以前は 512 のときだけ + // 256 基準へ読み替えていて、ArcGIS 300 では level L に L-1 の格子の (x, y) を付けて + // 要求された。GroundImageTileProvider はそれを素直に z=L として描くので、どのタイルも + // 画像の外になり透明だった。 + private fun buildWebMercatorLevels(tileSize: Int): List = + (MIN_ZOOM..MAX_ZOOM).map { level -> + val resolution = WebMercatorTileLevels.resolution(level, tileSize) + LevelOfDetail(level, resolution, WebMercatorTileLevels.scale(resolution)) } private fun buildWebMercatorExtent(): Envelope = @@ -232,11 +222,9 @@ class ArcGISGroundImageOverlayRenderer( companion object { private const val WEB_MERCATOR_WKID = 3857 - private const val WEB_MERCATOR_RADIUS_METERS = Earth.RADIUS_METERS private const val WEB_MERCATOR_MAX = WEB_MERCATOR_MAX_EXTENT_METERS private const val WEB_MERCATOR_MIN = -WEB_MERCATOR_MAX - private const val DEFAULT_DPI = 96 - private const val INCHES_PER_METER = 39.37 + private const val DEFAULT_DPI = WebMercatorTileLevels.DEFAULT_DPI private const val MIN_ZOOM = 0 private const val MAX_ZOOM = 22 } diff --git a/src/main/java/com/mapconductor/arcgis/raster/ArcGISRasterLayerOverlayRenderer.kt b/src/main/java/com/mapconductor/arcgis/raster/ArcGISRasterLayerOverlayRenderer.kt index 1a5a893..cd28760 100644 --- a/src/main/java/com/mapconductor/arcgis/raster/ArcGISRasterLayerOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/arcgis/raster/ArcGISRasterLayerOverlayRenderer.kt @@ -12,6 +12,7 @@ import com.arcgismaps.mapping.layers.WebTiledLayer import com.mapconductor.arcgis.ArcGISGeoViewHolder import com.mapconductor.arcgis.ArcGISMapViewHolder import com.mapconductor.arcgis.ArcGISOverlayStacking +import com.mapconductor.arcgis.WebMercatorTileLevels import com.mapconductor.core.controller.OverlayKind import com.mapconductor.core.projection.WEB_MERCATOR_MAX_EXTENT_METERS import com.mapconductor.core.raster.RasterHeaderRuleSet @@ -22,8 +23,6 @@ import com.mapconductor.core.raster.RasterLayerState import com.mapconductor.core.raster.TileScheme import com.mapconductor.core.tileserver.TileServerRegistry import com.mapconductor.core.zoom.AbstractZoomAltitudeConverter -import kotlin.math.log2 -import kotlin.math.pow import android.util.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -315,7 +314,7 @@ class ArcGISRasterLayerOverlayRenderer( * 512px のタイルは 1 枚で 256px タイル 2 枚ぶんを覆うので、level `z` は統一ズーム * `z + 1` に対応する(コアの `WebMercatorZoomAltitudeConverter` が言う * `zoomOffset`。MapLibre / Mapbox / MapTiler が 1.0、Google / MapKit が 0.0)。 - * その対応を [unifiedZoomForLevel] に書き下しておけば、解像度も縮尺も + * その対応を [WebMercatorTileLevels.unifiedZoomForLevel] に書き下しておけば、解像度も縮尺も * 「統一ズームいくつのときにこの level を引くか」から一意に決まる。 * * 自前で `2πR / tileSize` と DPI を掛け合わせても同じ数にはなるが、どの物差しに @@ -329,10 +328,7 @@ class ArcGISRasterLayerOverlayRenderer( ): List { val levels = mutableListOf() for (level in minZoom..maxZoom) { - val unifiedZoom = unifiedZoomForLevel(level, tileSize) - val resolution = - AbstractZoomAltitudeConverter.WEB_MERCATOR_INITIAL_MPP_256 / - 2.0.pow(unifiedZoom) + val resolution = WebMercatorTileLevels.resolution(level, tileSize) // 縮尺の分母。2D の MapView はこの梯子を見て level を選ぶので、 // 解像度と同じ比率で刻んでいないと 1 段ずれる。 // @@ -341,18 +337,12 @@ class ArcGISRasterLayerOverlayRenderer( // かった(統一ズーム 6.5 で z=7 のまま)。SceneView は自前の // 「タイルは 256px」という梯子で選ぶ。解像度のほうは見ているので、 // タイルの位置は申告どおりに決まる。 - val scale = resolution * DEFAULT_DPI * INCHES_PER_METER + val scale = WebMercatorTileLevels.scale(resolution) levels.add(LevelOfDetail(level, resolution, scale)) } return levels } - /** この level のタイルを引くべき統一ズーム。256px なら level そのもの。 */ - private fun unifiedZoomForLevel( - level: Int, - tileSize: Int, - ): Double = level + log2(tileSize.toDouble() / UNIFIED_TILE_SIZE) - private fun buildWebMercatorExtent(): Envelope = Envelope( WEB_MERCATOR_MIN, @@ -368,11 +358,7 @@ class ArcGISRasterLayerOverlayRenderer( private const val WEB_MERCATOR_WKID = 3857 private const val WEB_MERCATOR_MAX = WEB_MERCATOR_MAX_EXTENT_METERS private const val WEB_MERCATOR_MIN = -WEB_MERCATOR_MAX - private const val DEFAULT_DPI = 96 - private const val INCHES_PER_METER = 39.37 - - /** 統一ズームが基準にしているタイルの一辺(Google 準拠の 256px)。 */ - private const val UNIFIED_TILE_SIZE = 256.0 + private const val DEFAULT_DPI = WebMercatorTileLevels.DEFAULT_DPI private const val DEFAULT_MIN_ZOOM = 0 private const val DEFAULT_MAX_ZOOM = 22 } diff --git a/src/test/java/com/mapconductor/arcgis/IdentityWeakTableTest.kt b/src/test/java/com/mapconductor/arcgis/IdentityWeakTableTest.kt new file mode 100644 index 0000000..75cdddb --- /dev/null +++ b/src/test/java/com/mapconductor/arcgis/IdentityWeakTableTest.kt @@ -0,0 +1,52 @@ +package com.mapconductor.arcgis + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class IdentityWeakTableTest { + /** ArcGIS の Layer のように、載せた後で hashCode / equals が変わる鍵。 */ + private class Shifty( + var hash: Int, + ) { + override fun hashCode(): Int = hash + + override fun equals(other: Any?): Boolean = other is Shifty && other.hash == hash + } + + @Test + fun findsTheKeyAfterItsHashCodeChanges() { + val table = IdentityWeakTable() + val layer = Shifty(1) + table.put(layer, "ground") + + layer.hash = 2 + + assertEquals("ground", table[layer]) + } + + @Test + fun doesNotConfuseEqualButDistinctKeys() { + val table = IdentityWeakTable() + val a = Shifty(7) + val b = Shifty(7) + table.put(a, "raster") + + assertNull(table[b]) + table.put(b, "ground") + assertEquals("raster", table[a]) + assertEquals("ground", table[b]) + assertEquals(2, table.size) + } + + @Test + fun putReplacesTheValueOfTheSameKey() { + val table = IdentityWeakTable() + val a = Shifty(1) + table.put(a, "raster") + table.put(a, "ground") + + assertEquals("ground", table[a]) + assertEquals(1, table.size) + } +} diff --git a/src/test/java/com/mapconductor/arcgis/WebMercatorTileLevelsTest.kt b/src/test/java/com/mapconductor/arcgis/WebMercatorTileLevelsTest.kt new file mode 100644 index 0000000..592a9aa --- /dev/null +++ b/src/test/java/com/mapconductor/arcgis/WebMercatorTileLevelsTest.kt @@ -0,0 +1,71 @@ +package com.mapconductor.arcgis + +import com.mapconductor.core.projection.WEB_MERCATOR_MAX_EXTENT_METERS +import org.junit.Assert.assertEquals +import org.junit.Test +import kotlin.math.PI +import kotlin.math.floor +import kotlin.math.ln +import kotlin.math.pow +import kotlin.math.tan + +/** + * ArcGIS がレベルの梯子から数える (level, col, row) が、ローカルのタイルサーバ + * (GroundImageTileProvider など、素直な XYZ で z を読む)の同じ番号のタイルを指すこと。 + * + * 512px のときだけ 256 基準へ読み替えていた頃は、level L に L-1 の格子の col/row が付き + * (Eswatini の z=15 で x=9617 になる。正しくは 19235)、GroundImage が何も描かれなかった。 + */ +class WebMercatorTileLevelsTest { + // GroundImage サンプルの画像の中心(University of Eswatini) + private val lon = 31.306239 + private val lat = -26.479235 + + private fun mercatorX(lon: Double) = lon / 180.0 * WEB_MERCATOR_MAX_EXTENT_METERS + + private fun mercatorY(lat: Double) = ln(tan(PI / 4 + Math.toRadians(lat) / 2)) / PI * WEB_MERCATOR_MAX_EXTENT_METERS + + /** WebTiledLayer の TileInfo(原点は左上)から ArcGIS が出す col / row。 */ + private fun arcgisColRow( + level: Int, + tileSize: Int, + ): Pair { + val footprint = WebMercatorTileLevels.resolution(level, tileSize) * tileSize + val col = floor((mercatorX(lon) + WEB_MERCATOR_MAX_EXTENT_METERS) / footprint).toInt() + val row = floor((WEB_MERCATOR_MAX_EXTENT_METERS - mercatorY(lat)) / footprint).toInt() + return col to row + } + + /** 素直な XYZ で z=level のときの x / y(タイルの px 数に依らない)。 */ + private fun xyz(level: Int): Pair { + val n = 2.0.pow(level) + val x = floor((lon + 180.0) / 360.0 * n).toInt() + val y = floor((1.0 - ln(tan(Math.toRadians(lat)) + 1.0 / kotlin.math.cos(Math.toRadians(lat))) / PI) / 2.0 * n).toInt() + return x to y + } + + @Test + fun levelNamesTheSameTileAsXyz_for512() { + for (level in listOf(0, 5, 13, 15, 18)) { + assertEquals("level $level", xyz(level), arcgisColRow(level, 512)) + } + } + + @Test + fun levelNamesTheSameTileAsXyz_for256() { + for (level in listOf(0, 5, 13, 15, 18)) { + assertEquals("level $level", xyz(level), arcgisColRow(level, 256)) + } + } + + @Test + fun aLevelCoversTheGroundOfThatZoom_whateverTheTileSize() { + val circumference = 2 * WEB_MERCATOR_MAX_EXTENT_METERS + for (tileSize in listOf(256, 512)) { + for (level in 0..22) { + val footprint = WebMercatorTileLevels.resolution(level, tileSize) * tileSize + assertEquals("tileSize $tileSize level $level", circumference / 2.0.pow(level), footprint, footprint * 1e-6) + } + } + } +}