From 489f95e2141eb5a2863c4893ae4fccfe9bef7951 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 00:05:09 -0800 Subject: [PATCH 1/8] Polygon hit test: count a tap as on an edge only within eps of it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pointOnSegment compared the dot product (p-a)·(p-b), a quantity in square degrees, with eps = 1e-6, so any tap within about 0.001° (~100 m) of the middle of a short edge counted as on that edge. With building footprints the first nearby building in the list was returned instead of the tapped one. Now it is the plain distance to the segment, within 1e-6° (~0.1 m). --- .../core/polygon/PolygonManager.kt | 16 +++-- .../core/polygon/PolygonHitToleranceTest.kt | 65 +++++++++++++++++++ 2 files changed, 74 insertions(+), 7 deletions(-) create mode 100644 src/test/java/com/mapconductor/core/polygon/PolygonHitToleranceTest.kt diff --git a/src/main/java/com/mapconductor/core/polygon/PolygonManager.kt b/src/main/java/com/mapconductor/core/polygon/PolygonManager.kt index 90d0617..4b03e88 100644 --- a/src/main/java/com/mapconductor/core/polygon/PolygonManager.kt +++ b/src/main/java/com/mapconductor/core/polygon/PolygonManager.kt @@ -4,10 +4,8 @@ import com.mapconductor.core.features.GeoPointInterface import com.mapconductor.core.normalizeLng import com.mapconductor.core.spherical.WGS84Geodesic import java.util.concurrent.ConcurrentHashMap -import kotlin.math.abs import kotlin.math.max import kotlin.math.min -import kotlin.math.sqrt interface PolygonManagerInterface { fun registerEntity(entity: PolygonEntityInterface) @@ -164,13 +162,17 @@ class PolygonManager : PolygonManagerInterface { by: Double, eps: Double, ): Boolean { + // 線分までの距離が eps(度)以内なら辺上とみなす。 + // 以前は外積と内積を eps と直接比べていた。内積は度の 2 乗なので、 + // `(p-a)·(p-b) <= 1e-6` は「辺の中点から約 0.001°(~100 m)以内」を意味し、 + // 短い辺を持つ建物ほど、離れたタップを辺上として拾っていた。 val dx = bx - ax val dy = by - ay - val cross = dx * (py - ay) - dy * (px - ax) - val segLen = sqrt(dx * dx + dy * dy) - if (abs(cross) > eps * max(1.0, segLen)) return false - val dot = (px - ax) * (px - bx) + (py - ay) * (py - by) - return dot <= eps * max(1.0, segLen) + val lenSq = dx * dx + dy * dy + val t = if (lenSq > 0.0) (((px - ax) * dx + (py - ay) * dy) / lenSq).coerceIn(0.0, 1.0) else 0.0 + val ex = px - (ax + t * dx) + val ey = py - (ay + t * dy) + return ex * ex + ey * ey <= eps * eps } private fun unwrapLongitudesAround( diff --git a/src/test/java/com/mapconductor/core/polygon/PolygonHitToleranceTest.kt b/src/test/java/com/mapconductor/core/polygon/PolygonHitToleranceTest.kt new file mode 100644 index 0000000..1339257 --- /dev/null +++ b/src/test/java/com/mapconductor/core/polygon/PolygonHitToleranceTest.kt @@ -0,0 +1,65 @@ +package com.mapconductor.core.polygon + +import com.mapconductor.core.features.GeoPoint +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * A tap counts as on a polygon's edge only when it is within `eps` (1e-6°, + * about 0.1 m) of that edge. Taps outside the polygon's bounding box never get + * that far, so the tolerance only matters on and just inside the outline. + * + * js-sdk-core の `PolygonHitTolerance.test.mjs`、ios-sdk の `PolygonHitToleranceTests` と + * 1 対 1 で対応する。 + * + * 辺上の判定が度の 2 乗の量(内積)を 1e-6 と比べていて、短い辺から約 100 m 以内の + * タップをすべて「辺上」として拾っていた。建物の輪郭のように辺が数 m のポリゴンを + * 並べると、タップした建物ではなく、リストで先にある近所の建物が返る。大きな図形では + * 起きないので、サンプルの六角形では見えなかった。 + */ +class PolygonHitToleranceTest { + // ボストン中心部の、一辺が約 10 m の建物 + private val building = + listOf( + GeoPoint(42.3550, -71.0600), + GeoPoint(42.3550, -71.0599), + GeoPoint(42.3551, -71.0599), + GeoPoint(42.3551, -71.0600), + GeoPoint(42.3550, -71.0600), + ) + + private fun managerWith(vararg states: PolygonState): PolygonManager = + PolygonManager().apply { + states.forEach { registerEntity(PolygonEntity(polygon = Unit, state = it)) } + } + + @Test + fun `50 m 離れたタップは、辺の短い建物に当たらない`() { + val manager = managerWith(PolygonState(points = building, id = "small")) + // 建物の東の辺から東へ約 50 m(経度 0.0006°) + assertNull(manager.find(GeoPoint(42.35505, -71.0593))) + // 北へ約 50 m + assertNull(manager.find(GeoPoint(42.3556, -71.05995))) + } + + @Test + fun `内側のタップと辺の上のタップは当たり、0点6 m 外れたタップは当たらない`() { + val manager = managerWith(PolygonState(points = building, id = "small")) + assertEquals("small", manager.find(GeoPoint(42.35505, -71.05995))?.state?.id) + assertEquals("南の辺の上", "small", manager.find(GeoPoint(42.3550, -71.05995))?.state?.id) + assertNull("南の辺から外へ約 0.6 m", manager.find(GeoPoint(42.354995, -71.05995))) + } + + @Test + fun `近所の建物ではなく、タップした建物が返る`() { + // 西隣(約 20 m 西)にもう 1 棟。リストでは先に入れる + val west = building.map { GeoPoint(it.latitude, it.longitude - 0.00025) } + val manager = + managerWith( + PolygonState(points = west, id = "west"), + PolygonState(points = building, id = "tapped"), + ) + assertEquals("tapped", manager.find(GeoPoint(42.35505, -71.05995))?.state?.id) + } +} From 8f16d35e196cd5e78325394c4644a29c1eac5ce4 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 05:40:54 -0800 Subject: [PATCH 2/8] Draw large polygon sets as raster tiles (PolygonTiler) The counterpart of js-sdk-core's polygon tiling, in the same shape: PolygonTilingOptions (2,000 polygons or 50,000 vertices), PolygonTiler (route / update / clear, onTemplate, POLYGON_TILE_LAYER_ID, polygonTileRasterLayerState) and PolygonTileSet (route on the local tile server, version in the URL, in-place changes drawn together after 16 ms). PolygonTileRenderer draws as tile-sw.js does: polygons in Web Mercator with the outline wound against the holes so a nonzero fill needs no hole union, a grid index of bounding boxes, a simplified copy per zoom (pixel grid, repeats dropped, Douglas-Peucker at half a pixel, a polygon under a pixel as one pixel), outlines only on polygons of 4 px or more. PNG through the Rust encoder. An in-place change drops only the cached tiles it overlaps; the rest come back from the cache under the new version. A tile the map abandons mid-draw fails rather than coming back empty. PolygonController gets a tiler: tiled polygons stay in PolygonManager for taps, against their original shapes, and the renderer never sees them. The bookkeeping for a city of polygons runs off the main thread. Also skip the quadratic hole union when no two holes cross, touch or nest (checked on a grid in linear time), still returning holes clockwise. --- api/android-sdk-core.api | 145 +++++++ .../polygon/PolygonTileRendererDeviceTest.kt | 165 ++++++++ .../core/polygon/PolygonTilerDeviceTest.kt | 213 ++++++++++ .../core/polygon/PolygonController.kt | 57 ++- .../core/polygon/PolygonHoleUnion.kt | 107 +++++ .../core/polygon/PolygonTileGeometry.kt | 367 ++++++++++++++++++ .../core/polygon/PolygonTileRenderer.kt | 344 ++++++++++++++++ .../core/polygon/PolygonTileSet.kt | 157 ++++++++ .../mapconductor/core/polygon/PolygonTiler.kt | 92 +++++ .../core/polygon/PolygonTilingOptions.kt | 31 ++ .../core/polygon/PolygonTileGeometryTest.kt | 146 +++++++ .../core/polygon/PolygonTilingTest.kt | 93 +++++ .../core/raster/RasterLayerCoexistenceTest.kt | 82 ++++ 13 files changed, 1996 insertions(+), 3 deletions(-) create mode 100644 src/androidTest/java/com/mapconductor/core/polygon/PolygonTileRendererDeviceTest.kt create mode 100644 src/androidTest/java/com/mapconductor/core/polygon/PolygonTilerDeviceTest.kt create mode 100644 src/main/java/com/mapconductor/core/polygon/PolygonTileGeometry.kt create mode 100644 src/main/java/com/mapconductor/core/polygon/PolygonTileRenderer.kt create mode 100644 src/main/java/com/mapconductor/core/polygon/PolygonTileSet.kt create mode 100644 src/main/java/com/mapconductor/core/polygon/PolygonTiler.kt create mode 100644 src/main/java/com/mapconductor/core/polygon/PolygonTilingOptions.kt create mode 100644 src/test/java/com/mapconductor/core/polygon/PolygonTileGeometryTest.kt create mode 100644 src/test/java/com/mapconductor/core/polygon/PolygonTilingTest.kt create mode 100644 src/test/java/com/mapconductor/core/raster/RasterLayerCoexistenceTest.kt diff --git a/api/android-sdk-core.api b/api/android-sdk-core.api index e1ffa9d..69db9b7 100644 --- a/api/android-sdk-core.api +++ b/api/android-sdk-core.api @@ -2072,6 +2072,16 @@ public final class com.mapconductor.core.polygon.HoleBridgeKt { public static final java.util.List bridgeHolesIntoSingleRing(java.util.List, java.util.List>, double); public static final java.util.List bridgeHolesIntoSingleRingWrapAware(java.util.List, java.util.List>, double); } +public final class com.mapconductor.core.polygon.IntList { + public com.mapconductor.core.polygon.IntList(int); + public com.mapconductor.core.polygon.IntList(int, int, kotlin.jvm.internal.DefaultConstructorMarker); + public final int getSize(); + public final void add(int); + public final int pop(); + public final int[] toArray(); + public final int[] toSortedDistinctArray(); + public com.mapconductor.core.polygon.IntList(); +} final class com.mapconductor.core.polygon.Node { public com.mapconductor.core.polygon.Node(double, double, com.mapconductor.core.features.GeoPointInterface); public final double getX(); @@ -2109,6 +2119,8 @@ public abstract class com.mapconductor.core.polygon.PolygonController getClickListener(); public final void setClickListener(kotlin.jvm.functions.Function1); + public final com.mapconductor.core.polygon.PolygonTiler getTiler(); + public final void setTiler(com.mapconductor.core.polygon.PolygonTiler); public final void dispatchClick(com.mapconductor.core.polygon.PolygonEvent); public java.lang.Object add(java.util.List, kotlin.coroutines.Continuation); public java.lang.Object update(com.mapconductor.core.polygon.PolygonState, kotlin.coroutines.Continuation); @@ -2169,6 +2181,14 @@ public final class com.mapconductor.core.polygon.PolygonFingerPrint { public int hashCode(); public boolean equals(java.lang.Object); } +public final class com.mapconductor.core.polygon.PolygonGridIndex { + public static final com.mapconductor.core.polygon.PolygonGridIndex$Companion Companion; + public com.mapconductor.core.polygon.PolygonGridIndex(java.util.HashMap, int[]); + public final int[] query(com.mapconductor.core.polygon.TilePolygon[], double, double, double, double); +} +public final class com.mapconductor.core.polygon.PolygonGridIndex$Companion { + public final com.mapconductor.core.polygon.PolygonGridIndex build(com.mapconductor.core.polygon.TilePolygon[]); +} public final class com.mapconductor.core.polygon.PolygonHolePieceSplitKt { public static final kotlin.Pair, java.util.List> splitPiece(java.util.List, java.util.List, java.util.List>); public static final com.mapconductor.core.features.GeoPointInterface mirrorLngPoint(com.mapconductor.core.features.GeoPointInterface); @@ -2260,6 +2280,105 @@ public final class com.mapconductor.core.polygon.PolygonState implements com.map public final com.mapconductor.core.polygon.PolygonState copy-lqbfp2Q(java.util.List, java.util.List>, java.lang.String, long, float, long, boolean, boolean, int, java.io.Serializable, kotlin.jvm.functions.Function1); public com.mapconductor.core.polygon.PolygonState(java.util.List, java.util.List, java.lang.String, long, float, long, boolean, boolean, int, java.io.Serializable, kotlin.jvm.functions.Function1, kotlin.jvm.internal.DefaultConstructorMarker); } +public final class com.mapconductor.core.polygon.PolygonTileGeometryKt { + public static final double POLYGON_CELL; + public static final int POLYGON_MAX_CELLS; + public static final double DP_TOLERANCE_PX; + public static final double MIN_OUTLINED_PX; + public static final boolean isVisibleColor(int); + public static final double[] toMercatorRing(java.util.List); + public static final double signedArea(double[]); + public static final double[] reversedRing(double[]); + public static final com.mapconductor.core.polygon.TileShape simplifyPolygon(com.mapconductor.core.polygon.TilePolygon, double); + public static final double[] simplifyRing(double[], double); + public static final double[] douglasPeucker(double[], int, double); + public static final int vertexCountOf(java.util.List); +} +public final class com.mapconductor.core.polygon.PolygonTileRenderer implements com.mapconductor.core.tileserver.TileProviderInterface { + public static final com.mapconductor.core.polygon.PolygonTileRenderer$Companion Companion; + public static final int DEFAULT_CACHE_BYTES; + public com.mapconductor.core.polygon.PolygonTileRenderer(int, int, kotlin.jvm.functions.Function0); + public com.mapconductor.core.polygon.PolygonTileRenderer(int, int, kotlin.jvm.functions.Function0, int, kotlin.jvm.internal.DefaultConstructorMarker); + public final int getTileSize(); + public final void setPolygons(java.util.List); + public final void update(java.util.List>); + public final void clear(); + public byte[] renderTile(com.mapconductor.core.tileserver.TileRequest); + public byte[] renderTile(com.mapconductor.core.tileserver.TileRequest, kotlin.jvm.functions.Function0); + public final android.graphics.Bitmap renderBitmap(int, int, int, int); + public com.mapconductor.core.polygon.PolygonTileRenderer(); +} +public final class com.mapconductor.core.polygon.PolygonTileRenderer$Companion { +} +final class com.mapconductor.core.polygon.PolygonTileRenderer$Model { + public final com.mapconductor.core.polygon.TilePolygon[] getPolygons(); + public final com.mapconductor.core.polygon.PolygonGridIndex getIndex(); + public final float getMaxStrokePx(); + public final java.util.LinkedHashMap> getPerZoom(); +} +final class com.mapconductor.core.polygon.PolygonTileRenderer$Simplified { + public final com.mapconductor.core.polygon.TilePolygon getSource(); + public final com.mapconductor.core.polygon.TileShape getShape(); +} +public final class com.mapconductor.core.polygon.PolygonTileRenderer$TileRenderCancelled extends java.lang.Exception { +} +public final class com.mapconductor.core.polygon.PolygonTileSet { + public static final com.mapconductor.core.polygon.PolygonTileSet$Companion Companion; + public com.mapconductor.core.polygon.PolygonTileSet(com.mapconductor.core.tileserver.LocalTileServer, kotlinx.coroutines.CoroutineScope); + public com.mapconductor.core.polygon.PolygonTileSet(com.mapconductor.core.tileserver.LocalTileServer, kotlinx.coroutines.CoroutineScope, int, kotlin.jvm.internal.DefaultConstructorMarker); + public final java.lang.String getRouteId(); + public final kotlin.jvm.functions.Function2, java.lang.Object> getOnTemplateChanged(); + public final void setOnTemplateChanged(kotlin.jvm.functions.Function2, ? extends java.lang.Object>); + public final int getTileSize(); + public final java.lang.Object setPolygons(java.util.List, kotlin.coroutines.Continuation); + public final boolean has(java.lang.String); + public final void update(com.mapconductor.core.polygon.PolygonState); + public final java.lang.String template(); + public final void dispose(); + public com.mapconductor.core.polygon.PolygonTileSet(); +} +public final class com.mapconductor.core.polygon.PolygonTileSet$Companion { + public final boolean canTile(com.mapconductor.core.polygon.PolygonState); + public final int vertexCount(java.util.List); +} +public final class com.mapconductor.core.polygon.PolygonTiler { + public com.mapconductor.core.polygon.PolygonTiler(com.mapconductor.core.polygon.PolygonTilingOptions); + public com.mapconductor.core.polygon.PolygonTiler(com.mapconductor.core.polygon.PolygonTilingOptions, int, kotlin.jvm.internal.DefaultConstructorMarker); + public final com.mapconductor.core.polygon.PolygonTilingOptions getOptions(); + public final kotlin.jvm.functions.Function3, java.lang.Object> getOnTemplate(); + public final void setOnTemplate(kotlin.jvm.functions.Function3, ? extends java.lang.Object>); + public final java.lang.Object route(java.util.List, kotlin.coroutines.Continuation>); + public final java.util.List tiledStates(); + public final boolean isTiled(java.lang.String); + public final void update(com.mapconductor.core.polygon.PolygonState); + public final java.lang.Object clear(kotlin.coroutines.Continuation); + public com.mapconductor.core.polygon.PolygonTiler(); +} +public final class com.mapconductor.core.polygon.PolygonTilerKt { + public static final java.lang.String POLYGON_TILE_LAYER_ID; + public static final com.mapconductor.core.raster.RasterLayerState polygonTileRasterLayerState(java.lang.String, int); +} +public final class com.mapconductor.core.polygon.PolygonTilingOptions { + public static final com.mapconductor.core.polygon.PolygonTilingOptions$Companion Companion; + public com.mapconductor.core.polygon.PolygonTilingOptions(boolean, int, int); + public com.mapconductor.core.polygon.PolygonTilingOptions(boolean, int, int, int, kotlin.jvm.internal.DefaultConstructorMarker); + public final boolean getEnabled(); + public final int getMinPolygonCount(); + public final int getMinVertexCount(); + public final boolean shouldUseTiles(int, int); + public final boolean component1(); + public final int component2(); + public final int component3(); + public final com.mapconductor.core.polygon.PolygonTilingOptions copy(boolean, int, int); + public java.lang.String toString(); + public int hashCode(); + public boolean equals(java.lang.Object); + public com.mapconductor.core.polygon.PolygonTilingOptions(); +} +public final class com.mapconductor.core.polygon.PolygonTilingOptions$Companion { + public final com.mapconductor.core.polygon.PolygonTilingOptions getDefault(); + public final com.mapconductor.core.polygon.PolygonTilingOptions getDisabled(); +} public final class com.mapconductor.core.polygon.PolygonUnionKt { public static final com.mapconductor.core.polygon.PolygonState unionHoles(com.mapconductor.core.polygon.PolygonState); public static final com.mapconductor.core.polygon.PolygonState union(com.mapconductor.core.polygon.PolygonState); @@ -2270,6 +2389,32 @@ public final class com.mapconductor.core.polygon.PolygonWindingUtilsKt { public static final java.util.List ensureCounterClockwise(java.util.List); public static final java.util.List ensureClockwiseRing(java.util.List); } +public final class com.mapconductor.core.polygon.TilePolygon { + public static final com.mapconductor.core.polygon.TilePolygon$Companion Companion; + public com.mapconductor.core.polygon.TilePolygon(double[][], double[], int, int, float); + public final double[][] getRings(); + public final double[] getBbox(); + public final int getFill(); + public final int getStroke(); + public final float getStrokeWidth(); + public final boolean getFillVisible(); + public final boolean getStrokeVisible(); + public final boolean sameBox(com.mapconductor.core.polygon.TilePolygon); +} +public final class com.mapconductor.core.polygon.TilePolygon$Companion { + public final com.mapconductor.core.polygon.TilePolygon from(com.mapconductor.core.polygon.PolygonState); +} +public abstract class com.mapconductor.core.polygon.TileShape { + public com.mapconductor.core.polygon.TileShape(kotlin.jvm.internal.DefaultConstructorMarker); +} +public final class com.mapconductor.core.polygon.TileShape$Dot extends com.mapconductor.core.polygon.TileShape { + public final double getX(); + public final double getY(); +} +public final class com.mapconductor.core.polygon.TileShape$Rings extends com.mapconductor.core.polygon.TileShape { + public final double[][] getRings(); + public final boolean getOutlined(); +} final class com.mapconductor.core.polygon.Vec { public com.mapconductor.core.polygon.Vec(double, double); public final double getX(); diff --git a/src/androidTest/java/com/mapconductor/core/polygon/PolygonTileRendererDeviceTest.kt b/src/androidTest/java/com/mapconductor/core/polygon/PolygonTileRendererDeviceTest.kt new file mode 100644 index 0000000..1d09385 --- /dev/null +++ b/src/androidTest/java/com/mapconductor/core/polygon/PolygonTileRendererDeviceTest.kt @@ -0,0 +1,165 @@ +package com.mapconductor.core.polygon + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.features.GeoPointInterface +import com.mapconductor.core.tileserver.TileRequest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +/** + * ポリゴンタイルの画素。tile-sw.js の「Polygon tiles」節と同じ描き方であることを固定する。 + * + * 1 単位 = 1 画素(pixelScale = 1)で描かせ、z0 のタイル(世界全体が 256 画素)を見る。 + * 経度 λ は x = (λ + 180) / 360 * 256、赤道は y = 128。 + */ +@RunWith(AndroidJUnit4::class) +class PolygonTileRendererDeviceTest { + private val red = Color(0xFFFF0000) + private val blue = Color(0xFF0000FF) + private val green = Color(0xFF00FF00) + + private fun p( + latitude: Double, + longitude: Double, + ): GeoPointInterface = GeoPoint.fromLatLong(latitude, longitude) + + private fun box( + south: Double, + west: Double, + north: Double, + east: Double, + ): List = listOf(p(south, west), p(south, east), p(north, east), p(north, west)) + + private fun renderer(vararg states: PolygonState) = + PolygonTileRenderer(tileSize = 256, pixelScale = { 1f }).apply { + setPolygons(states.map(TilePolygon::from)) + } + + private fun x(lng: Double) = ((lng + 180) / 360 * 256).toInt() + + private fun alpha(pixel: Int) = pixel ushr 24 + + private fun rgb(pixel: Int) = pixel and 0xFFFFFF + + /** 外周と同じ向きの穴も抜け、枠線は外周にだけ引く */ + @Test + fun holeWoundLikeOutlineIsCutOut_andOnlyOutlineIsStroked() { + val outer = box(-60.0, -90.0, 60.0, 90.0) + val hole = box(-20.0, -30.0, 20.0, 30.0) + // 穴を外周と同じ向きで渡す。nonzero で抜けるのは、描く前に巻きを逆にそろえるから + val bitmap = + renderer( + PolygonState(points = outer, holes = listOf(hole), fillColor = blue, strokeColor = red, strokeWidth = 2.dp), + ).renderBitmap(0, 0, 0)!! + assertEquals("穴の中は透明", 0, alpha(bitmap.getPixel(128, 128))) + assertEquals("塗り", 0x0000FF, rgb(bitmap.getPixel(x(-60.0), 128))) + assertEquals("外周の枠線", 0xFF0000, rgb(bitmap.getPixel(x(-90.0), 128))) + // 穴の縁(経度 -30 度)は塗りの縁で、枠線の色は無い + for (dx in -1..1) { + val pixel = bitmap.getPixel(x(-30.0) + dx, 128) + assertTrue("穴の縁 dx=$dx に枠線の赤: ${Integer.toHexString(pixel)}", (pixel shr 16 and 0xFF) < 0x40) + } + } + + /** 画面上 4 画素未満のポリゴンは枠線を省く */ + @Test + fun polygonUnderFourPixelsHasNoOutline() { + // 経度 2 度 = 1.4 画素、緯度 2 度 ≒ 1.4 画素 + val bitmap = + renderer( + PolygonState(points = box(0.0, 0.0, 4.0, 4.0), fillColor = blue, strokeColor = red, strokeWidth = 1.dp), + ).renderBitmap(0, 0, 0)!! + var reddest = 0 + for (py in 120..130) for (px in 125..135) reddest = maxOf(reddest, bitmap.getPixel(px, py) shr 16 and 0xFF) + assertEquals("枠線の赤が無い", 0, reddest) + } + + /** 1 画素に満たないポリゴンは塗りの色の 1 画素になる */ + @Test + fun subPixelPolygonIsOnePixelOfFill() { + val bitmap = + renderer( + PolygonState(points = box(0.0, 0.0, 0.01, 0.01), fillColor = blue, strokeColor = red), + ).renderBitmap(0, 0, 0)!! + var painted = 0 + for (py in 0 until 256) { + for (px in 0 until 256) { + val pixel = bitmap.getPixel(px, py) + if (alpha(pixel) != 0) { + painted++ + assertEquals(0x0000FF, rgb(pixel)) + } + } + } + assertEquals(1, painted) + } + + /** 何も無いタイルと、見えない色だけのタイルは null(空) */ + @Test + fun tileWithNothingVisibleIsEmpty() { + val r = + renderer( + PolygonState(points = box(0.0, 10.0, 10.0, 20.0), fillColor = Color.Transparent, strokeColor = Color.Transparent), + ) + // z1 の北西のタイル(経度 -180..0、北半球)には何も無い + assertNull(r.renderTile(TileRequest(x = 0, y = 0, z = 1))) + // 北東のタイルにはあるが、見えない色だけ + assertNull(r.renderTile(TileRequest(x = 1, y = 0, z = 1))) + } + + /** その場の変更は、重なるタイルだけ描き直す */ + @Test + fun inPlaceChangeRedrawsOnlyOverlappingTiles() { + val west = PolygonState(points = box(10.0, -100.0, 20.0, -90.0), fillColor = blue, id = "west") + val east = PolygonState(points = box(10.0, 90.0, 20.0, 100.0), fillColor = blue, id = "east") + val r = renderer(west, east) + val westTile = TileRequest(x = 0, y = 0, z = 1) + val eastTile = TileRequest(x = 1, y = 0, z = 1) + val westBefore = r.renderTile(westTile)!! + val eastBefore = r.renderTile(eastTile)!! + + west.fillColor = green + r.update(listOf(0 to TilePolygon.from(west))) + + val westAfter = r.renderTile(westTile)!! + assertNotSame("変えたポリゴンのタイルは描き直す", westBefore, westAfter) + assertSame("触れていないタイルはキャッシュから返す", eastBefore, r.renderTile(eastTile)) + val decoded = decode(westAfter) + // z1: 世界が 512 画素。経度 -95 度は x = 120、北緯 15 度は y = 234 + assertEquals(0x00FF00, rgb(decoded.getPixel(120, 234))) + } + + /** 形が変わったら索引も作り直す */ + @Test + fun movedShapeIsReindexed() { + val moving = PolygonState(points = box(10.0, -100.0, 20.0, -90.0), fillColor = blue, id = "m") + val r = renderer(moving) + assertNull(r.renderTile(TileRequest(x = 1, y = 0, z = 1))) + moving.points = box(10.0, 90.0, 20.0, 100.0) + r.update(listOf(0 to TilePolygon.from(moving))) + assertNotNull("移った先のタイルに出る", r.renderTile(TileRequest(x = 1, y = 0, z = 1))) + assertNull("元のタイルからは消える", r.renderTile(TileRequest(x = 0, y = 0, z = 1))) + } + + /** 取りやめられたタイルは空ではなく失敗にする */ + @Test + fun cancelledTileFailsInsteadOfComingBackEmpty() { + val r = renderer(PolygonState(points = box(0.0, 0.0, 10.0, 10.0), fillColor = blue)) + val thrown = + runCatching { r.renderTile(TileRequest(x = 0, y = 0, z = 0)) { true } }.exceptionOrNull() + assertTrue(thrown is PolygonTileRenderer.TileRenderCancelled) + } + + private fun decode(png: ByteArray): Bitmap = BitmapFactory.decodeByteArray(png, 0, png.size) +} diff --git a/src/androidTest/java/com/mapconductor/core/polygon/PolygonTilerDeviceTest.kt b/src/androidTest/java/com/mapconductor/core/polygon/PolygonTilerDeviceTest.kt new file mode 100644 index 0000000..ae7e618 --- /dev/null +++ b/src/androidTest/java/com/mapconductor/core/polygon/PolygonTilerDeviceTest.kt @@ -0,0 +1,213 @@ +package com.mapconductor.core.polygon + +import android.graphics.BitmapFactory +import androidx.compose.ui.graphics.Color +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.features.GeoPointInterface +import com.mapconductor.core.tileserver.TileServerRegistry +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.net.HttpURLConnection +import java.net.URL + +/** + * PolygonController と PolygonTiler の振り分け。js-sdk-core の PolygonController(tiler プロパティ)と同じ規則: + * タイルにしたポリゴンは PolygonManager に残し(タップ用、元の形)、レンダラには渡さない。 + * + * PolygonTileSet が本物のタイルサーバーに経路を作るので、端末で動かす。 + */ +@RunWith(AndroidJUnit4::class) +class PolygonTilerDeviceTest { + private fun p( + latitude: Double, + longitude: Double, + ): GeoPointInterface = GeoPoint.fromLatLong(latitude, longitude) + + /** 0.001 度角の区画を n 個、ボストンに並べる。 */ + private fun parcels( + n: Int, + fill: Color = Color(0xFF2563EB), + ): List = + List(n) { i -> + val lat = 42.30 + (i / 100) * 0.0012 + val lng = -71.10 + (i % 100) * 0.0012 + PolygonState( + points = listOf(p(lat, lng), p(lat, lng + 0.001), p(lat + 0.001, lng + 0.001), p(lat + 0.001, lng)), + id = "parcel-$i", + fillColor = fill, + ) + } + + private class Renderer : PolygonOverlayRendererInterface { + val onMap = mutableSetOf() + + override suspend fun onAdd(data: List): List = + data.map { it.state.id.also(onMap::add) } + + override suspend fun onChange(data: List>): List = + data.map { it.current.state.id } + + override suspend fun onRemove(data: List>) { + data.forEach { onMap.remove(it.state.id) } + } + + override suspend fun onPostProcess() {} + } + + private class Controller( + renderer: Renderer, + ) : PolygonController(PolygonManager(), renderer) + + private class Templates { + val seen = mutableListOf() + + fun last() = seen.lastOrNull() + } + + private fun controller(): Triple { + val renderer = Renderer() + val controller = Controller(renderer) + val templates = Templates() + controller.tiler = + PolygonTiler().apply { + onTemplate = { template, _ -> synchronized(templates) { templates.seen.add(template) } } + } + return Triple(controller, renderer, templates) + } + + /** しきい値を超えたらネイティブには何も渡さず、タップ用にマネージャへ残す */ + @Test + fun pastThresholdNothingGoesNative_andTapsStillHit() = + runBlocking { + val (controller, renderer, templates) = controller() + val states = parcels(2000) + controller.add(states) + assertTrue(renderer.onMap.isEmpty()) + assertEquals(2000, controller.polygonManager.allEntities().size) + assertNotNull(templates.last()) + // タップ判定は元の形で + assertEquals("parcel-101", controller.find(p(42.3017, -71.0983))?.state?.id) + assertNull(controller.find(p(42.3011, -71.0995))) + } + + /** しきい値を下回ったらネイティブに戻し、上回ったらネイティブから外す */ + @Test + fun crossingThresholdMovesPolygonsBetweenNativeAndTiles() = + runBlocking { + val (controller, renderer, templates) = controller() + controller.add(parcels(1999)) + assertEquals(1999, renderer.onMap.size) + assertNull(templates.last()) + + controller.add(parcels(2000)) + assertTrue("タイルへ移ったらネイティブから消す", renderer.onMap.isEmpty()) + assertEquals(2000, controller.polygonManager.allEntities().size) + assertNotNull(templates.last()) + + controller.add(parcels(10)) + assertEquals(10, renderer.onMap.size) + assertEquals("リストから消えたものは忘れる", 10, controller.polygonManager.allEntities().size) + assertNull(templates.last()) + } + + /** タイルのポリゴンのその場の変更は、版を上げたテンプレートで知らせる */ + @Test + fun inPlaceChangeOfTiledPolygonAnnouncesNewTemplate() = + runBlocking { + val (controller, renderer, templates) = controller() + val states = parcels(2000) + controller.add(states) + val before = templates.last()!! + states[5].fillColor = Color(0xFF16A34A) + controller.update(states[5]) + assertTrue(renderer.onMap.isEmpty()) + withTimeout(2000) { + while (synchronized(templates) { templates.last() } == before) delay(5) + } + assertTrue(templates.last()!! != before) + assertEquals( + Color(0xFF16A34A), + controller.polygonManager + .getEntity("parcel-5") + ?.state + ?.fillColor, + ) + } + + /** タイルはタイルサーバーから PNG で届き、何も無い場所は透明の 200 */ + @Test + fun tilesAreServedAsPng_andEmptyPlacesAsTransparent200() = + runBlocking { + val (controller, _, templates) = controller() + controller.add(parcels(2000)) + val template = templates.last()!! + // z14 で区画の並ぶ場所と、何も無い南半球 + val (x, y) = tileOf(42.31, -71.07, 14) + val drawn = get(template, 14, x, y) + assertEquals(200, drawn.first) + val bitmap = BitmapFactory.decodeByteArray(drawn.second, 0, drawn.second.size) + assertNotNull(bitmap) + var painted = 0 + for (py in 0 until bitmap.height step 4) { + for (px in 0 until bitmap.width step 4) { + if (bitmap.getPixel(px, py) ushr 24 != + 0 + ) { + painted++ + } + } + } + assertTrue("区画が描かれている: $painted", painted > 0) + val empty = get(template, 14, 100, 12000) + assertEquals(200, empty.first) + } + + /** clear でテンプレートを外し、タイル側のポリゴンも忘れる */ + @Test + fun clearRemovesTemplateAndForgetsTiledPolygons() = + runBlocking { + val (controller, _, templates) = controller() + controller.add(parcels(2000)) + controller.clear() + assertNull(templates.last()) + assertTrue(controller.polygonManager.allEntities().isEmpty()) + assertTrue(TileServerRegistry.get().isRunning()) + } + + private fun tileOf( + lat: Double, + lng: Double, + z: Int, + ): Pair { + val n = 1 shl z + val x = ((lng + 180) / 360 * n).toInt() + val s = kotlin.math.sin(Math.toRadians(lat)) + val y = ((0.5 - kotlin.math.ln((1 + s) / (1 - s)) / (4 * Math.PI)) * n).toInt() + return x to y + } + + private fun get( + template: String, + z: Int, + x: Int, + y: Int, + ): Pair { + val url = template.replace("{z}", "$z").replace("{x}", "$x").replace("{y}", "$y") + val connection = URL(url).openConnection() as HttpURLConnection + connection.useCaches = false + return try { + val code = connection.responseCode + code to connection.inputStream.use { it.readBytes() } + } finally { + connection.disconnect() + } + } +} diff --git a/src/main/java/com/mapconductor/core/polygon/PolygonController.kt b/src/main/java/com/mapconductor/core/polygon/PolygonController.kt index 96eb44f..67f2edd 100644 --- a/src/main/java/com/mapconductor/core/polygon/PolygonController.kt +++ b/src/main/java/com/mapconductor/core/polygon/PolygonController.kt @@ -4,8 +4,10 @@ import com.mapconductor.core.controller.OverlayHit import com.mapconductor.core.controller.OverlayKind import com.mapconductor.core.controller.SlottedOverlayController import com.mapconductor.core.features.GeoPointInterface +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext abstract class PolygonController( val polygonManager: PolygonManagerInterface, @@ -19,6 +21,15 @@ abstract class PolygonController( var clickListener: OnPolygonEventHandler? = null + /** + * Set by a provider that can show polygon tiles (see [PolygonTiler]). + * Polygons it tiles stay in [polygonManager], for taps, with no native + * overlay: their entity's `polygon` is null and the renderer never sees + * them. + */ + var tiler: PolygonTiler? = null + private var tiledIds: Set = emptySet() + fun dispatchClick(event: PolygonEvent) { // 配送座標の wrap は PolygonEvent の生成時に一元化済み。 event.state.onClick?.invoke(event) @@ -27,13 +38,22 @@ abstract class PolygonController( override suspend fun add(data: List) { semaphore.withPermit { + val wasTiled = tiledIds + val tiled = tiler?.route(data) ?: emptySet() + tiledIds = tiled + val native = if (tiled.isEmpty()) data else data.filter { it.id !in tiled } val modifiedEntities = mutableListOf>() - val previous = polygonManager.allEntities().map { it.state.id }.toMutableSet() + // Only natively drawn polygons are diffed against the renderer. + val previous = + polygonManager + .allEntities() + .map { it.state.id } + .filterTo(mutableSetOf()) { it !in wasTiled } val added = mutableListOf() val updated = mutableListOf>() val removed = mutableListOf>() - data.forEach { state -> + native.forEach { state -> if (previous.contains(state.id)) { val prevEntity = polygonManager.getEntity(state.id)!! if (state.fingerPrint() == prevEntity.fingerPrint) { @@ -110,13 +130,40 @@ abstract class PolygonController( } } + // Tiled polygons: kept for taps, nothing native. Those that left + // the tiles and the list are forgotten; those that left the tiles + // for native drawing were added above. + if (wasTiled.isNotEmpty()) { + val nativeIds = native.mapTo(HashSet()) { it.id } + for (id in wasTiled) { + if (id !in tiled && id !in nativeIds) polygonManager.removeEntity(id) + } + } + if (tiled.isNotEmpty()) { + val tiledStates = tiler!!.tiledStates() + withContext(Dispatchers.Default) { + for (state in tiledStates) { + polygonManager.registerEntity(PolygonEntity(polygon = inTiles(), state = state)) + } + } + } + renderer.onPostProcess() } } + /** What a tiled polygon's entity holds instead of a native polygon. */ + @Suppress("UNCHECKED_CAST") + private fun inTiles(): ActualPolygon = null as ActualPolygon + override suspend fun update(state: PolygonState) { semaphore.withPermit { val prevEntity = polygonManager.getEntity(state.id) ?: return + if (state.id in tiledIds) { + polygonManager.registerEntity(PolygonEntity(polygon = prevEntity.polygon, state = state)) + tiler?.update(state) + return + } val currentFinger = state.fingerPrint() val prevFinger = prevEntity.fingerPrint if (currentFinger == prevFinger) { @@ -151,7 +198,11 @@ abstract class PolygonController( override suspend fun clear() { semaphore.withPermit { - val entities: List> = polygonManager.allEntities() + val tiled = tiledIds + tiledIds = emptySet() + tiler?.clear() + val entities: List> = + polygonManager.allEntities().filter { it.state.id !in tiled } renderer.onRemove(entities) polygonManager.clear() } diff --git a/src/main/java/com/mapconductor/core/polygon/PolygonHoleUnion.kt b/src/main/java/com/mapconductor/core/polygon/PolygonHoleUnion.kt index 35d6245..8de30b6 100644 --- a/src/main/java/com/mapconductor/core/polygon/PolygonHoleUnion.kt +++ b/src/main/java/com/mapconductor/core/polygon/PolygonHoleUnion.kt @@ -31,6 +31,17 @@ fun unionHoleRings(holes: List>): List 0.0 }) return holes + return holes.mapIndexed { i, hole -> if (signedArea(rings[i]) > 0.0) hole.asReversed() else hole } + } val merged = unionRings(rings) if (merged.isEmpty()) return holes @@ -213,6 +224,102 @@ private fun nextEdge( return best } +/** + * True when no two rings cross, touch or nest. Edges are binned into a uniform + * grid and only pairs sharing a cell are tested, so the cost grows with the + * edge count rather than its square. Same as js-sdk-core's `holesAreDisjoint`. + */ +private fun holesAreDisjoint(rings: List>): Boolean { + val boxes = rings.map(::ringBox) + // A ring inside another: one of its vertices is inside it. + for (i in rings.indices) { + val p = rings[i][0] + for (j in rings.indices) { + if (i == j) continue + val b = boxes[j] + if (p.x >= b[0] && p.x <= b[2] && p.y >= b[1] && p.y <= b[3] && pointInRing(p, rings[j])) { + return false + } + } + } + + val edges = ArrayList() + val edgeRing = ArrayList() + for ((r, ring) in rings.withIndex()) { + for (i in ring.indices) { + edges.add(Edge(ring[i], ring[(i + 1) % ring.size])) + edgeRing.add(r) + } + } + var minX = Double.POSITIVE_INFINITY + var minY = Double.POSITIVE_INFINITY + var maxX = Double.NEGATIVE_INFINITY + var maxY = Double.NEGATIVE_INFINITY + for (b in boxes) { + minX = minOf(minX, b[0]) + minY = minOf(minY, b[1]) + maxX = maxOf(maxX, b[2]) + maxY = maxOf(maxY, b[3]) + } + val side = + kotlin.math + .ceil(kotlin.math.sqrt(edges.size.toDouble())) + .toInt() + .coerceIn(1, 1024) + // Padded by EPS so an edge lying on a cell border is in both cells. + val cellW = ((maxX - minX) / side).takeIf { it != 0.0 } ?: 1.0 + val cellH = ((maxY - minY) / side).takeIf { it != 0.0 } ?: 1.0 + + fun cellOf( + v: Double, + min: Double, + size: Double, + ): Int = + kotlin.math + .floor((v - min) / size) + .toInt() + .coerceIn(0, side - 1) + val cells = HashMap>() + for ((e, edge) in edges.withIndex()) { + val a = edge.a + val b = edge.b + val x0 = cellOf(minOf(a.x, b.x) - EPS, minX, cellW) + val x1 = cellOf(maxOf(a.x, b.x) + EPS, minX, cellW) + val y0 = cellOf(minOf(a.y, b.y) - EPS, minY, cellH) + val y1 = cellOf(maxOf(a.y, b.y) + EPS, minY, cellH) + for (cx in x0..x1) { + for (cy in y0..y1) { + cells.getOrPut(cx * side + cy) { ArrayList(4) }.add(e) + } + } + } + for (bucket in cells.values) { + for (i in bucket.indices) { + for (j in i + 1 until bucket.size) { + val p = bucket[i] + val q = bucket[j] + if (edgeRing[p] != edgeRing[q] && intersectionPoints(edges[p], edges[q]).isNotEmpty()) return false + } + } + } + return true +} + +/** minX, minY, maxX, maxY. */ +private fun ringBox(ring: List): DoubleArray { + var minX = Double.POSITIVE_INFINITY + var minY = Double.POSITIVE_INFINITY + var maxX = Double.NEGATIVE_INFINITY + var maxY = Double.NEGATIVE_INFINITY + for (p in ring) { + minX = minOf(minX, p.x) + maxX = maxOf(maxX, p.x) + minY = minOf(minY, p.y) + maxY = maxOf(maxY, p.y) + } + return doubleArrayOf(minX, minY, maxX, maxY) +} + /** 線分 `other` が線分 `seg` と出会う点(交差・T 字接触・共線重複の端点)。 */ private fun intersectionPoints( seg: Edge, diff --git a/src/main/java/com/mapconductor/core/polygon/PolygonTileGeometry.kt b/src/main/java/com/mapconductor/core/polygon/PolygonTileGeometry.kt new file mode 100644 index 0000000..2dc9ae6 --- /dev/null +++ b/src/main/java/com/mapconductor/core/polygon/PolygonTileGeometry.kt @@ -0,0 +1,367 @@ +package com.mapconductor.core.polygon + +import androidx.compose.ui.graphics.toArgb +import com.mapconductor.core.features.GeoPointInterface +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.floor +import kotlin.math.hypot +import kotlin.math.ln +import kotlin.math.max +import kotlin.math.min +import kotlin.math.sin + +// The geometry of polygon tiles, without any drawing: the same steps as the +// "Polygon tiles" section of js-sdk-core's tile-sw.js, so the three platforms +// draw the same pixels from the same polygons. +// +// Polygons become Web Mercator coordinates in [0, 1], with the outline wound +// one way and the holes the other, so a nonzero fill leaves out the union of +// the holes without computing it. +// +// Each zoom gets its own simplified copy of a polygon, built the first time a +// tile at that zoom needs it: vertices snapped to that zoom's pixel grid, +// repeats dropped, Douglas-Peucker at half a pixel, and a polygon smaller than +// a pixel drawn as one pixel. Taps never see any of this -- they are resolved +// against the original shapes in PolygonManager. + +/** Cells of the polygon index, in Web Mercator units: one z12 tile. */ +internal const val POLYGON_CELL = 1.0 / 4096.0 + +/** A polygon covering more cells than this is kept in a list every query checks. */ +internal const val POLYGON_MAX_CELLS = 256 + +internal const val DP_TOLERANCE_PX = 0.5 + +/** + * Below this size on screen a polygon's outline is left out: a 1 px line round + * a 3 px parcel is the whole parcel, and a city of them zoomed out turns into + * the outline colour. + */ +internal const val MIN_OUTLINED_PX = 4.0 + +private const val MAX_LATITUDE = 85.05112878 + +/** + * One polygon as the tiles draw it. + * + * [rings] are Web Mercator `[x0, y0, x1, y1, ...]` in [0, 1], the outline + * first and wound positive, every hole after it wound negative. [strokeWidth] + * is in dp, the unit of [PolygonState.strokeWidth]. + */ +internal class TilePolygon( + val rings: Array, + /** minX, minY, maxX, maxY of the outline. */ + val bbox: DoubleArray, + val fill: Int, + val stroke: Int, + val strokeWidth: Float, +) { + val fillVisible: Boolean get() = isVisibleColor(fill) + val strokeVisible: Boolean get() = strokeWidth > 0f && isVisibleColor(stroke) + + fun sameBox(other: TilePolygon): Boolean = bbox.contentEquals(other.bbox) + + companion object { + /** Null when the outline has fewer than three points. */ + fun from(state: PolygonState): TilePolygon? { + val rings = ArrayList(1 + state.holes.size) + val all = listOf(state.points) + state.holes + for ((r, points) in all.withIndex()) { + var ring = toMercatorRing(points) + if (ring.size < 6) { + if (r == 0) return null + continue + } + // Outline positive, holes negative: opposite windings under nonzero. + val area = signedArea(ring) + if ((r == 0 && area < 0) || (r > 0 && area > 0)) ring = reversedRing(ring) + rings.add(ring) + } + val outer = rings[0] + var minX = Double.POSITIVE_INFINITY + var minY = Double.POSITIVE_INFINITY + var maxX = Double.NEGATIVE_INFINITY + var maxY = Double.NEGATIVE_INFINITY + var i = 0 + while (i < outer.size) { + minX = min(minX, outer[i]) + maxX = max(maxX, outer[i]) + minY = min(minY, outer[i + 1]) + maxY = max(maxY, outer[i + 1]) + i += 2 + } + return TilePolygon( + rings = rings.toTypedArray(), + bbox = doubleArrayOf(minX, minY, maxX, maxY), + fill = state.fillColor.toArgb(), + stroke = state.strokeColor.toArgb(), + strokeWidth = state.strokeWidth.value, + ) + } + } +} + +internal fun isVisibleColor(argb: Int): Boolean = (argb ushr 24) != 0 + +internal fun toMercatorRing(points: List): DoubleArray { + val ring = DoubleArray(points.size * 2) + for ((i, p) in points.withIndex()) { + val lat = p.latitude.coerceIn(-MAX_LATITUDE, MAX_LATITUDE) + val s = sin(lat * PI / 180.0) + ring[i * 2] = (p.longitude + 180.0) / 360.0 + ring[i * 2 + 1] = 0.5 - ln((1 + s) / (1 - s)) / (4 * PI) + } + return ring +} + +internal fun signedArea(ring: DoubleArray): Double { + var area = 0.0 + val n = ring.size / 2 + var j = n - 1 + for (i in 0 until n) { + area += ring[j * 2] * ring[i * 2 + 1] - ring[i * 2] * ring[j * 2 + 1] + j = i + } + return area / 2 +} + +internal fun reversedRing(ring: DoubleArray): DoubleArray { + val n = ring.size / 2 + val out = DoubleArray(ring.size) + for (i in 0 until n) { + out[i * 2] = ring[(n - 1 - i) * 2] + out[i * 2 + 1] = ring[(n - 1 - i) * 2 + 1] + } + return out +} + +/** The polygon index: polygons by the grid cells their bounding box covers. */ +internal class PolygonGridIndex( + private val cells: HashMap, + private val large: IntArray, +) { + /** + * The polygons whose bounding box meets the given box, each once, in + * ascending index order -- which is draw order. + */ + fun query( + polygons: Array, + minX: Double, + minY: Double, + maxX: Double, + maxY: Double, + ): IntArray { + val out = IntList() + + fun take(i: Int) { + val b = polygons[i]?.bbox ?: return + if (b[0] <= maxX && b[2] >= minX && b[1] <= maxY && b[3] >= minY) out.add(i) + } + for (i in large) take(i) + val cx0 = floor(minX / POLYGON_CELL).toLong() + val cx1 = floor(maxX / POLYGON_CELL).toLong() + val cy0 = floor(minY / POLYGON_CELL).toLong() + val cy1 = floor(maxY / POLYGON_CELL).toLong() + for (cx in cx0..cx1) { + for (cy in cy0..cy1) { + cells[cellKey(cx, cy)]?.forEach(::take) + } + } + // A polygon in several cells was taken once per cell. + return out.toSortedDistinctArray() + } + + companion object { + fun build(polygons: Array): PolygonGridIndex { + val cells = HashMap() + val large = IntList() + for (i in polygons.indices) { + val p = polygons[i] ?: continue + val cx0 = floor(p.bbox[0] / POLYGON_CELL).toLong() + val cx1 = floor(p.bbox[2] / POLYGON_CELL).toLong() + val cy0 = floor(p.bbox[1] / POLYGON_CELL).toLong() + val cy1 = floor(p.bbox[3] / POLYGON_CELL).toLong() + if ((cx1 - cx0 + 1) * (cy1 - cy0 + 1) > POLYGON_MAX_CELLS) { + large.add(i) + continue + } + for (cx in cx0..cx1) { + for (cy in cy0..cy1) { + cells.getOrPut(cellKey(cx, cy)) { IntList() }.add(i) + } + } + } + val packed = HashMap(cells.size * 2) + for ((key, list) in cells) packed[key] = list.toArray() + return PolygonGridIndex(packed, large.toArray()) + } + + private fun cellKey( + cx: Long, + cy: Long, + ): Long = cx * 1_048_576L + cy + } +} + +/** What one polygon looks like at one zoom. */ +internal sealed class TileShape { + /** World-pixel rings at the zoom, `[x0, y0, ...]`, outline first. */ + class Rings( + val rings: Array, + val outlined: Boolean, + ) : TileShape() + + /** A polygon under a pixel, drawn as the pixel at [x], [y]. */ + class Dot( + val x: Double, + val y: Double, + ) : TileShape() +} + +/** + * The polygon as drawn at a zoom whose world is [scale] pixels wide: rings of + * world-pixel points, a dot for a polygon under a pixel, never nothing. + */ +internal fun simplifyPolygon( + p: TilePolygon, + scale: Double, +): TileShape { + val w = (p.bbox[2] - p.bbox[0]) * scale + val h = (p.bbox[3] - p.bbox[1]) * scale + if (w < 1 && h < 1) { + return TileShape.Dot( + floor(((p.bbox[0] + p.bbox[2]) / 2) * scale), + floor(((p.bbox[1] + p.bbox[3]) / 2) * scale), + ) + } + val outer = + simplifyRing(p.rings[0], scale) + ?: return TileShape.Dot( + jsRound(((p.bbox[0] + p.bbox[2]) / 2) * scale), + jsRound(((p.bbox[1] + p.bbox[3]) / 2) * scale), + ) + val rings = ArrayList(p.rings.size) + rings.add(outer) + for (r in 1 until p.rings.size) { + simplifyRing(p.rings[r], scale)?.let(rings::add) + } + return TileShape.Rings(rings.toTypedArray(), outlined = max(w, h) >= MIN_OUTLINED_PX) +} + +/** Snapped to the pixel grid, repeats dropped, Douglas-Peucker; null under three points. */ +internal fun simplifyRing( + ring: DoubleArray, + scale: Double, +): DoubleArray? { + val snapped = DoubleArray(ring.size) + var n = 0 + var px = Double.NaN + var py = Double.NaN + var i = 0 + while (i < ring.size) { + val x = jsRound(ring[i] * scale) + val y = jsRound(ring[i + 1] * scale) + i += 2 + if (x == px && y == py) continue + snapped[n++] = x + snapped[n++] = y + px = x + py = y + } + val simplified = douglasPeucker(snapped, n / 2, DP_TOLERANCE_PX) + return if (simplified.size >= 6) simplified else null +} + +/** `Math.round` as JavaScript has it (halves go up), so pixels land where tile-sw.js puts them. */ +private fun jsRound(v: Double): Double = floor(v + 0.5) + +/** Douglas-Peucker on the first [n] points of [points]; returns the kept points. */ +internal fun douglasPeucker( + points: DoubleArray, + n: Int, + tolerance: Double, +): DoubleArray { + if (n < 3) return points.copyOf(n * 2) + val keep = BooleanArray(n) + keep[0] = true + keep[n - 1] = true + val stack = IntList() + stack.add(0) + stack.add(n - 1) + while (stack.size > 0) { + val b = stack.pop() + val a = stack.pop() + val ax = points[a * 2] + val ay = points[a * 2 + 1] + val dx = points[b * 2] - ax + val dy = points[b * 2 + 1] - ay + val len = hypot(dx, dy) + var best = -1.0 + var bestIndex = -1 + for (i in a + 1 until b) { + val px = points[i * 2] + val py = points[i * 2 + 1] + val d = if (len != 0.0) abs(dy * (px - ax) - dx * (py - ay)) / len else hypot(px - ax, py - ay) + if (d > best) { + best = d + bestIndex = i + } + } + if (best > tolerance) { + keep[bestIndex] = true + stack.add(a) + stack.add(bestIndex) + stack.add(bestIndex) + stack.add(b) + } + } + var kept = 0 + for (k in keep) if (k) kept++ + val out = DoubleArray(kept * 2) + var o = 0 + for (i in 0 until n) { + if (keep[i]) { + out[o++] = points[i * 2] + out[o++] = points[i * 2 + 1] + } + } + return out +} + +/** Total vertices across outlines and holes. */ +internal fun vertexCountOf(states: List): Int { + var n = 0 + for (s in states) { + n += s.points.size + for (h in s.holes) n += h.size + } + return n +} + +/** A growable int array, so indexes do not box every polygon index. */ +internal class IntList( + capacity: Int = 8, +) { + private var data = IntArray(capacity) + var size = 0 + private set + + fun add(v: Int) { + if (size == data.size) data = data.copyOf(max(8, size * 2)) + data[size++] = v + } + + fun pop(): Int = data[--size] + + fun toArray(): IntArray = data.copyOf(size) + + fun toSortedDistinctArray(): IntArray { + if (size == 0) return IntArray(0) + val a = data.copyOf(size) + a.sort() + var n = 1 + for (i in 1 until a.size) if (a[i] != a[n - 1]) a[n++] = a[i] + return if (n == a.size) a else a.copyOf(n) + } +} diff --git a/src/main/java/com/mapconductor/core/polygon/PolygonTileRenderer.kt b/src/main/java/com/mapconductor/core/polygon/PolygonTileRenderer.kt new file mode 100644 index 0000000..72b718e --- /dev/null +++ b/src/main/java/com/mapconductor/core/polygon/PolygonTileRenderer.kt @@ -0,0 +1,344 @@ +package com.mapconductor.core.polygon + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Path +import android.util.LruCache +import androidx.core.graphics.createBitmap +import com.mapconductor.core.ResourceProvider +import com.mapconductor.core.tileserver.TilePngEncoder +import com.mapconductor.core.tileserver.TileProviderInterface +import com.mapconductor.core.tileserver.TileRequest +import java.io.ByteArrayOutputStream +import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.atomic.AtomicReferenceArray +import kotlin.math.max +import kotlin.math.roundToInt + +/** + * Draws a set of polygons into raster tiles: the counterpart of the "Polygon + * tiles" section of js-sdk-core's tile-sw.js, served by [com.mapconductor.core.tileserver.LocalTileServer]. + * + * Geometry is in tile units (a tile is [tileSize] wide whatever its pixels); + * a tile is drawn [pixelScale] pixels per unit, so a stroke of 1 dp is one + * unit wide, as on the web where it is one CSS pixel. + * + * Tiles are cached here without the version the map sees in their URL. An + * in-place change ([update]) drops only the tiles that overlap what changed; + * the map asks again for every tile because the version moved, and the ones + * the change did not touch come straight back from the cache. + * + * [renderTile] runs concurrently on the tile server's threads. Every change + * replaces the model it reads, so a tile is drawn from one consistent set of + * polygons. + */ +internal class PolygonTileRenderer( + val tileSize: Int = 256, + cacheSizeBytes: Int = DEFAULT_CACHE_BYTES, + private val pixelScale: () -> Float = { ResourceProvider.getDensity() }, +) : TileProviderInterface { + private class Simplified( + val source: TilePolygon, + val shape: TileShape, + ) + + /** + * What tiles are drawn from. [perZoom] is shared by the models an + * [update] derives from one [setPolygons]: an entry is used only while its + * source is still the polygon at that index, so a changed polygon is + * simplified again without clearing anything. + */ + private class Model( + val polygons: Array, + val index: PolygonGridIndex, + val maxStrokePx: Float, + val perZoom: LinkedHashMap>, + ) + + @Volatile + private var model: Model? = null + + /** Bumped by every change, so a tile drawn before it is not cached after it. */ + private val generation = AtomicLong() + + private val tileCache = + object : LruCache(cacheSizeBytes) { + override fun sizeOf( + key: Long, + value: ByteArray, + ): Int = value.size + } + private val cacheLock = Any() + + private val scratchBitmap = ThreadLocal() + private val byteStream = ThreadLocal.withInitial { ByteArrayOutputStream(16 * 1024) } + + /** Replaces every polygon. Draw order is list order. */ + fun setPolygons(polygons: List) { + val array = polygons.toTypedArray() + val next = Model(array, PolygonGridIndex.build(array), maxStrokeOf(array), LinkedHashMap(8, 0.75f, true)) + model = next + synchronized(cacheLock) { + generation.incrementAndGet() + tileCache.evictAll() + } + } + + /** + * Replaces the polygons at the given indexes. Only the tiles that + * overlapped one of them, before or after, are drawn again. + */ + fun update(updates: List>) { + val current = model ?: return + val polygons = current.polygons.copyOf() + val touched = ArrayList(updates.size * 2) + var maxStroke = current.maxStrokePx + var reindex = false + for ((index, after) in updates) { + if (index !in polygons.indices) continue + val before = polygons[index] + polygons[index] = after + before?.let { touched.add(it.bbox) } + after?.let { touched.add(it.bbox) } + if (after != null && after.strokeVisible) maxStroke = max(maxStroke, after.strokeWidth) + // A shape that moved needs the index rebuilt; a restyle does not. + if (before == null || after == null || !before.sameBox(after)) reindex = true + } + val index = if (reindex) PolygonGridIndex.build(polygons) else current.index + model = Model(polygons, index, maxStroke, current.perZoom) + synchronized(cacheLock) { + generation.incrementAndGet() + dropTilesIn(touched, maxStroke) + } + } + + fun clear() { + model = null + synchronized(cacheLock) { + generation.incrementAndGet() + tileCache.evictAll() + } + } + + override fun renderTile(request: TileRequest): ByteArray? = renderTile(request) { false } + + override fun renderTile( + request: TileRequest, + isCancelled: () -> Boolean, + ): ByteArray? { + val generationAtStart = generation.get() + val m = model ?: return null + val z = request.z + if (z !in 0..MAX_ZOOM) return null + val n = 1 shl z + val x = ((request.x % n) + n) % n + val y = request.y + if (y !in 0 until n) return null + val ratio = request.pixelRatio.coerceIn(1, 3) + val key = cacheKey(z, x, y, ratio) + synchronized(cacheLock) { tileCache.get(key) }?.let { return it } + + val bitmap = draw(m, x, y, z, ratio, isCancelled) ?: return null + val bytes = encode(bitmap) + synchronized(cacheLock) { + // The polygons may have changed while this tile was drawn. + if (generation.get() == generationAtStart) tileCache.put(key, bytes) + } + return bytes + } + + /** The tile as a bitmap, or null when nothing of any polygon is in it. For tests and [renderTile]. */ + fun renderBitmap( + x: Int, + y: Int, + z: Int, + pixelRatio: Int = 1, + ): Bitmap? = model?.let { draw(it, x, y, z, pixelRatio) { false } }?.copy(Bitmap.Config.ARGB_8888, false) + + private fun draw( + m: Model, + x: Int, + y: Int, + z: Int, + pixelRatio: Int, + isCancelled: () -> Boolean, + ): Bitmap? { + val scale = tileSize.toDouble() * (1L shl z).toDouble() + val originX = x.toDouble() * tileSize + val originY = y.toDouble() * tileSize + val pad = m.maxStrokePx / 2.0 + 1.0 + val candidates = + m.index.query( + m.polygons, + (originX - pad) / scale, + (originY - pad) / scale, + (originX + tileSize + pad) / scale, + (originY + tileSize + pad) / scale, + ) + if (candidates.isEmpty()) return null + + val r = pixelScale() * pixelRatio + val px = (tileSize * r).roundToInt().coerceAtLeast(1) + val bitmap = scratch(px) + val canvas = Canvas(bitmap) + val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL } + val strokePaint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeJoin = Paint.Join.ROUND + strokeCap = Paint.Cap.ROUND + } + val dotPaint = Paint().apply { style = Paint.Style.FILL } + val path = Path().apply { fillType = Path.FillType.WINDING } + val perZoom = perZoomOf(m, z) + var drew = false + for ((k, index) in candidates.withIndex()) { + if (k % CANCEL_CHECK_EVERY == 0 && isCancelled()) throw TileRenderCancelled() + val p = m.polygons[index] ?: continue + val shape = simplifiedAt(perZoom, index, p, scale) + val fillVisible = p.fillVisible + when (shape) { + is TileShape.Dot -> { + if (!fillVisible && !p.strokeVisible) continue + dotPaint.color = if (fillVisible) p.fill else p.stroke + val left = ((shape.x - originX) * r).toFloat() + val top = ((shape.y - originY) * r).toFloat() + canvas.drawRect(left, top, left + r, top + r, dotPaint) + drew = true + } + is TileShape.Rings -> { + if (fillVisible) { + path.rewind() + path.fillType = Path.FillType.WINDING + for (ring in shape.rings) addRing(path, ring, originX, originY, r) + fillPaint.color = p.fill + canvas.drawPath(path, fillPaint) + drew = true + } + if (p.strokeVisible && shape.outlined) { + // Outline only, as the map SDKs draw it. + path.rewind() + addRing(path, shape.rings[0], originX, originY, r) + strokePaint.color = p.stroke + strokePaint.strokeWidth = p.strokeWidth * r + canvas.drawPath(path, strokePaint) + drew = true + } + } + } + } + return if (drew) bitmap else null + } + + private fun addRing( + path: Path, + ring: DoubleArray, + originX: Double, + originY: Double, + r: Float, + ) { + path.moveTo(((ring[0] - originX) * r).toFloat(), ((ring[1] - originY) * r).toFloat()) + var i = 2 + while (i < ring.size) { + path.lineTo(((ring[i] - originX) * r).toFloat(), ((ring[i + 1] - originY) * r).toFloat()) + i += 2 + } + path.close() + } + + private fun perZoomOf( + m: Model, + z: Int, + ): AtomicReferenceArray = + synchronized(m.perZoom) { + // Access order: a lookup makes the zoom the most recent one. + m.perZoom[z] ?: AtomicReferenceArray(m.polygons.size).also { created -> + m.perZoom[z] = created + val it = m.perZoom.keys.iterator() + while (m.perZoom.size > ZOOMS_KEPT && it.hasNext()) { + it.next() + it.remove() + } + } + } + + private fun simplifiedAt( + perZoom: AtomicReferenceArray, + index: Int, + p: TilePolygon, + scale: Double, + ): TileShape { + val cached = perZoom.get(index) + if (cached != null && cached.source === p) return cached.shape + val shape = simplifyPolygon(p, scale) + perZoom.set(index, Simplified(p, shape)) + return shape + } + + private fun scratch(px: Int): Bitmap { + val existing = scratchBitmap.get() + if (existing != null && !existing.isRecycled && existing.width == px && existing.height == px) { + existing.eraseColor(Color.TRANSPARENT) + return existing + } + return createBitmap(px, px).also { scratchBitmap.set(it) } + } + + private fun encode(bitmap: Bitmap): ByteArray { + // Rust first, as for marker tiles: Bitmap.compress is several times slower. + TilePngEncoder.encode(bitmap)?.let { return it } + val out = byteStream.get()!! + out.reset() + bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) + return out.toByteArray() + } + + private fun dropTilesIn( + boxes: List, + strokePx: Float, + ) { + if (boxes.isEmpty()) return + for (key in tileCache.snapshot().keys) { + val z = (key ushr 56).toInt() + val x = ((key ushr 27) and COORD_MASK).toInt() + val y = (key and COORD_MASK).toInt() + val scale = tileSize.toDouble() * (1L shl z).toDouble() + val pad = (strokePx + 2.0) / scale + val tx0 = x * tileSize / scale - pad + val tx1 = (x + 1) * tileSize / scale + pad + val ty0 = y * tileSize / scale - pad + val ty1 = (y + 1) * tileSize / scale + pad + if (boxes.any { b -> b[0] <= tx1 && b[2] >= tx0 && b[1] <= ty1 && b[3] >= ty0 }) { + tileCache.remove(key) + } + } + } + + private fun cacheKey( + z: Int, + x: Int, + y: Int, + ratio: Int, + ): Long = (z.toLong() shl 56) or (ratio.toLong() shl 54) or (x.toLong() shl 27) or y.toLong() + + /** Thrown when the map stops wanting a tile mid-draw: a failure it may retry, never an empty tile. */ + class TileRenderCancelled : Exception("polygon tile no longer wanted") + + companion object { + const val DEFAULT_CACHE_BYTES = 16 * 1024 * 1024 + + /** Simplified copies kept, most recent zooms first. */ + private const val ZOOMS_KEPT = 4 + private const val MAX_ZOOM = 26 + private const val CANCEL_CHECK_EVERY = 256 + private const val COORD_MASK = (1L shl 27) - 1 + + private fun maxStrokeOf(polygons: Array): Float { + var maxStroke = 0f + for (p in polygons) if (p != null && p.strokeVisible) maxStroke = max(maxStroke, p.strokeWidth) + return maxStroke + } + } +} diff --git a/src/main/java/com/mapconductor/core/polygon/PolygonTileSet.kt b/src/main/java/com/mapconductor/core/polygon/PolygonTileSet.kt new file mode 100644 index 0000000..4374627 --- /dev/null +++ b/src/main/java/com/mapconductor/core/polygon/PolygonTileSet.kt @@ -0,0 +1,157 @@ +package com.mapconductor.core.polygon + +import com.mapconductor.core.tileserver.LocalTileServer +import com.mapconductor.core.tileserver.TileServerRegistry +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.UUID +import kotlin.time.Duration.Companion.milliseconds + +/** + * The polygons of one map that are drawn as raster tiles, and their route on + * the local tile server. + * + * Provider-independent: a provider decides which polygons to tile + * ([PolygonTilingOptions.shouldUseTiles], [canTile]), hands them to + * [setPolygons], and shows the returned URL template as a raster layer. + * In-place changes go through [update]; each one ends in a new template (the + * version in it is what makes the map ask again), reported to + * [onTemplateChanged]. Tiles the change did not touch are still cached by the + * renderer and come back without being drawn again. + * + * Same shape as js-sdk-core's `PolygonTileSet`. The web draws in its tile + * service worker and hands the polygons over as Encoded Polylines; here the + * server is in the process, so the renderer reads the states directly. + */ +class PolygonTileSet( + private val server: LocalTileServer = TileServerRegistry.get(), + private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), +) { + val routeId: String = "polygons-${UUID.randomUUID().toString().take(8)}" + + /** Called with the new template after an [update]. */ + var onTemplateChanged: (suspend (String) -> Unit)? = null + + val tileSize: Int get() = TILE_SIZE + + private val renderer = PolygonTileRenderer(TILE_SIZE) + private val lock = Any() + private var indexById: Map = emptyMap() + + @Volatile + private var version = 0 + private var registered = false + private val pending = LinkedHashMap() + private var flushJob: Job? = null + + /** + * Registers [states] (replacing whatever was registered) and returns the + * URL template of their tiles. + */ + suspend fun setPolygons(states: List): String { + cancelPending() + val (polygons, ids) = + withContext(Dispatchers.Default) { + // Draw order is list order: zIndex, then the order given (sortedBy is stable). + val ordered = states.sortedBy { it.zIndex } + val ids = HashMap(ordered.size * 2) + ordered.forEachIndexed { index, state -> ids[state.id] = index } + ordered.map(TilePolygon::from) to ids + } + synchronized(lock) { + renderer.setPolygons(polygons) + if (!registered) server.register(routeId, renderer) + indexById = ids + registered = true + version++ + } + return template() + } + + fun has(id: String): Boolean = synchronized(lock) { indexById.containsKey(id) } + + /** + * One polygon changed in place. A change of zIndex reorders the list and + * needs [setPolygons] instead; everything else is applied here. + */ + fun update(state: PolygonState) { + synchronized(lock) { + if (!indexById.containsKey(state.id)) return + pending[state.id] = state + if (flushJob == null) { + flushJob = + scope.launch { + delay(UPDATE_COALESCE) + flush() + } + } + } + } + + fun template(): String = server.urlTemplate(routeId, TILE_SIZE, version.toString()) + + fun dispose() { + cancelPending() + synchronized(lock) { + if (registered) server.unregister(routeId) + registered = false + indexById = emptyMap() + renderer.clear() + } + } + + private suspend fun flush() { + val template = + synchronized(lock) { + flushJob = null + val updates = + pending.values.mapNotNull { state -> + indexById[state.id]?.let { index -> index to TilePolygon.from(state) } + } + pending.clear() + if (updates.isEmpty() || !registered) return + renderer.update(updates) + version++ + template() + } + onTemplateChanged?.invoke(template) + } + + private fun cancelPending() { + synchronized(lock) { + flushJob?.cancel() + flushJob = null + pending.clear() + } + } + + companion object { + private const val TILE_SIZE = 256 + + /** In-place changes arriving within this window are drawn as one. */ + private val UPDATE_COALESCE = 16.milliseconds + + /** + * Whether a polygon can be drawn in a tile. Geodesic edges are curves + * the tile does not trace, and a ring spanning half the world or more + * crosses the antimeridian; the map SDK draws both. + */ + fun canTile(state: PolygonState): Boolean { + if (state.geodesic || state.points.size < 3) return false + var min = Double.POSITIVE_INFINITY + var max = Double.NEGATIVE_INFINITY + for (p in state.points) { + if (p.longitude < min) min = p.longitude + if (p.longitude > max) max = p.longitude + } + return max - min < 180 + } + + fun vertexCount(states: List): Int = vertexCountOf(states) + } +} diff --git a/src/main/java/com/mapconductor/core/polygon/PolygonTiler.kt b/src/main/java/com/mapconductor/core/polygon/PolygonTiler.kt new file mode 100644 index 0000000..29b4577 --- /dev/null +++ b/src/main/java/com/mapconductor/core/polygon/PolygonTiler.kt @@ -0,0 +1,92 @@ +package com.mapconductor.core.polygon + +import com.mapconductor.core.raster.RasterLayerSource +import com.mapconductor.core.raster.RasterLayerState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** The id of the raster layer that shows tiled polygons. */ +const val POLYGON_TILE_LAYER_ID: String = "mc-polygon-tiles" + +/** The raster layer state for tiled polygons at [template]. */ +fun polygonTileRasterLayerState( + template: String, + tileSize: Int = 256, +): RasterLayerState = + RasterLayerState( + id = POLYGON_TILE_LAYER_ID, + source = RasterLayerSource.UrlTemplate(template = template, tileSize = tileSize), + ) + +/** + * Decides which of a map's polygons are drawn as tiles, keeps them registered + * with the local tile server, and tells the provider where the tiles are. + * + * Every provider's polygon controller uses it the same way: + * 1. [route] with the full list on every composition. It returns the ids now + * in tiles; the controller keeps those in its [PolygonManager] (taps are + * resolved there, against the original shapes) but gives the map SDK + * nothing for them, and removes any native polygon it had drawn for one. + * 2. [update] for an in-place change of a tiled polygon. + * 3. [onTemplate] is how the tiles reach the map: a URL template to show as a + * raster layer, or null to remove it. A provider with a RasterLayer + * controller hands [polygonTileRasterLayerState] to its `upsert`, and + * removes it with `removeById(POLYGON_TILE_LAYER_ID)`. + * + * Same as js-sdk-core's and ios-sdk-core's `PolygonTiler`. + */ +class PolygonTiler( + val options: PolygonTilingOptions = PolygonTilingOptions.Default, +) { + var onTemplate: (suspend (template: String?, tileSize: Int) -> Unit)? = null + + private val tileSet = PolygonTileSet() + + @Volatile + private var tiled: Set = emptySet() + + @Volatile + private var states: List = emptyList() + + init { + tileSet.onTemplateChanged = { template -> onTemplate?.invoke(template, tileSet.tileSize) } + } + + /** Re-decides tiling for [data] and returns the ids drawn as tiles. */ + suspend fun route(data: List): Set { + // A city of polygons is a second of work; none of it belongs on the map's thread. + val (tileable, useTiles) = + withContext(Dispatchers.Default) { + val tileable = data.filter(PolygonTileSet::canTile) + tileable to options.shouldUseTiles(tileable.size, PolygonTileSet.vertexCount(tileable)) + } + val template = if (useTiles) tileSet.setPolygons(tileable) else null + if (template == null) { + tileSet.dispose() + tiled = emptySet() + states = emptyList() + } else { + tiled = withContext(Dispatchers.Default) { tileable.mapTo(HashSet(tileable.size * 2)) { it.id } } + states = tileable + } + onTemplate?.invoke(template, tileSet.tileSize) + return tiled + } + + /** The states drawn as tiles, as of the last [route]. */ + fun tiledStates(): List = states + + fun isTiled(id: String): Boolean = tiled.contains(id) + + /** An in-place change of a tiled polygon; only the tiles it touches are drawn again. */ + fun update(state: PolygonState) { + tileSet.update(state) + } + + suspend fun clear() { + tileSet.dispose() + tiled = emptySet() + states = emptyList() + onTemplate?.invoke(null, tileSet.tileSize) + } +} diff --git a/src/main/java/com/mapconductor/core/polygon/PolygonTilingOptions.kt b/src/main/java/com/mapconductor/core/polygon/PolygonTilingOptions.kt new file mode 100644 index 0000000..8f6644e --- /dev/null +++ b/src/main/java/com/mapconductor/core/polygon/PolygonTilingOptions.kt @@ -0,0 +1,31 @@ +package com.mapconductor.core.polygon + +/** + * Options for polygon tiling. + * + * When enabled and a map holds enough polygons, they are drawn into raster + * tiles by the SDK's local tile server instead of being handed to the map SDK + * one by one. Taps are still resolved against the original shapes in + * [PolygonManager], so tiling changes how polygons look, never what a tap hits. + * + * The counterpart of [com.mapconductor.core.marker.MarkerTilingOptions], and + * the same options as js-sdk-core's and ios-sdk-core's `PolygonTilingOptions`. + */ +data class PolygonTilingOptions( + val enabled: Boolean = true, + /** Tile once a map holds at least this many polygons… */ + val minPolygonCount: Int = 2000, + /** …or at least this many vertices across them, whichever comes first. */ + val minVertexCount: Int = 50_000, +) { + /** The one place the threshold is decided; callers must not restate it. */ + fun shouldUseTiles( + polygonCount: Int, + vertexCount: Int, + ): Boolean = enabled && (polygonCount >= minPolygonCount || vertexCount >= minVertexCount) + + companion object { + val Default: PolygonTilingOptions = PolygonTilingOptions() + val Disabled: PolygonTilingOptions = PolygonTilingOptions(enabled = false) + } +} diff --git a/src/test/java/com/mapconductor/core/polygon/PolygonTileGeometryTest.kt b/src/test/java/com/mapconductor/core/polygon/PolygonTileGeometryTest.kt new file mode 100644 index 0000000..f30811f --- /dev/null +++ b/src/test/java/com/mapconductor/core/polygon/PolygonTileGeometryTest.kt @@ -0,0 +1,146 @@ +package com.mapconductor.core.polygon + +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.features.GeoPointInterface +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * タイルに描く前の幾何: 巻きの正規化、ズームごとの簡略化、グリッド索引。 + * + * js-sdk-core の tile-sw.js「Polygon tiles」節と同じ手順であることを固定する。 + * 画素そのものは端末で見る(androidTest の `PolygonTileRendererDeviceTest`)。 + */ +class PolygonTileGeometryTest { + private fun p( + latitude: Double, + longitude: Double, + ): GeoPointInterface = GeoPoint.fromLatLong(latitude, longitude) + + private fun square( + lat: Double, + lng: Double, + size: Double, + ): List = listOf(p(lat, lng), p(lat, lng + size), p(lat + size, lng + size), p(lat + size, lng)) + + @Test + fun `外周は正、穴は負の巻きにそろう(与えた向きによらない)`() { + val outer = square(0.0, 0.0, 4.0) + val hole = square(1.0, 1.0, 1.0) + for (o in listOf(outer, outer.reversed())) { + for (h in listOf(hole, hole.reversed())) { + val polygon = TilePolygon.from(PolygonState(points = o, holes = listOf(h)))!! + assertTrue(signedArea(polygon.rings[0]) > 0) + assertTrue(signedArea(polygon.rings[1]) < 0) + } + } + } + + @Test + fun `外接矩形は Web Mercator の 0 から 1 で、北が小さい y`() { + val polygon = TilePolygon.from(PolygonState(points = square(0.0, 0.0, 10.0)))!! + assertEquals(0.5, polygon.bbox[0], 1e-12) + assertEquals((10.0 + 180) / 360, polygon.bbox[2], 1e-12) + assertEquals(0.5, polygon.bbox[3], 1e-12) + assertTrue(polygon.bbox[1] < 0.5) + } + + @Test + fun `外周が 3 点未満なら描かない、穴が 3 点未満なら穴だけ捨てる`() { + assertNull(TilePolygon.from(PolygonState(points = listOf(p(0.0, 0.0), p(1.0, 1.0))))) + val polygon = TilePolygon.from(PolygonState(points = square(0.0, 0.0, 4.0), holes = listOf(listOf(p(1.0, 1.0)))))!! + assertEquals(1, polygon.rings.size) + } + + @Test + fun `頂点は画素格子に丸め、続く重複を捨て、半画素の Douglas-Peucker で間引く`() { + val scale = 1000.0 + // 1 画素幅の正方形の辺上に、同じ画素に丸まる点と、直線上の点を足す + val ring = + doubleArrayOf( + 0.0, + 0.0, + 0.0004, + 0.0001, // (0, 0) に丸まる + 0.005, + 0.0, // 直線上 + 0.010, + 0.0, + 0.010, + 0.010, + 0.0, + 0.010, + ) + val simplified = simplifyRing(ring, scale)!! + assertArrayEquals(doubleArrayOf(0.0, 0.0, 10.0, 0.0, 10.0, 10.0, 0.0, 10.0), simplified, 0.0) + } + + @Test + fun `半画素未満のずれは捨て、それを超えるずれは残す`() { + val straight = douglasPeucker(doubleArrayOf(0.0, 0.0, 5.0, 0.4, 10.0, 0.0), 3, DP_TOLERANCE_PX) + assertArrayEquals(doubleArrayOf(0.0, 0.0, 10.0, 0.0), straight, 0.0) + val bent = douglasPeucker(doubleArrayOf(0.0, 0.0, 5.0, 1.0, 10.0, 0.0), 3, DP_TOLERANCE_PX) + assertEquals(6, bent.size) + } + + @Test + fun `1 画素に満たないポリゴンは 1 画素の点になる`() { + val polygon = TilePolygon.from(PolygonState(points = square(0.0, 0.0, 0.001)))!! + // z0: 世界が 256 画素。0.001 度は 0.0007 画素 + val shape = simplifyPolygon(polygon, 256.0) + assertTrue(shape is TileShape.Dot) + shape as TileShape.Dot + assertEquals(128.0, shape.x, 0.0) + assertEquals(127.0, shape.y, 0.0) + } + + @Test + fun `画面上 4 画素未満のポリゴンは枠線を描かない`() { + val polygon = TilePolygon.from(PolygonState(points = square(0.0, 0.0, 1.0)))!! + // 1 度は 256 * 2^z / 360 画素。z2: 2.8 px、z3: 5.7 px + val small = simplifyPolygon(polygon, 256.0 * 4) as TileShape.Rings + val large = simplifyPolygon(polygon, 256.0 * 8) as TileShape.Rings + assertFalse(small.outlined) + assertTrue(large.outlined) + } + + @Test + fun `索引は外接矩形が重なるものだけを、重複なく描く順に返す`() { + val states = + listOf( + square(0.0, 0.0, 0.01), // 0 + square(0.0, 1.0, 0.01), // 1: 離れている + square(0.0, 0.005, 0.2), // 2: 複数のセルにまたがる + square(-60.0, -100.0, 120.0), // 3: 大きい(every query) + ) + val polygons = states.map { TilePolygon.from(PolygonState(points = it)) }.toTypedArray() + val index = PolygonGridIndex.build(polygons) + val b0 = polygons[0]!!.bbox + assertArrayEquals(intArrayOf(0, 2, 3), index.query(polygons, b0[0], b0[1], b0[2], b0[3])) + val b1 = polygons[1]!!.bbox + assertArrayEquals(intArrayOf(1, 3), index.query(polygons, b1[0], b1[1], b1[2], b1[3])) + // 消したポリゴン(null)は返さない + polygons[0] = null + assertArrayEquals(intArrayOf(2, 3), index.query(polygons, b0[0], b0[1], b0[2], b0[3])) + } + + @Test + fun `測地線と、経度の幅が 180 度以上のポリゴンはタイルにしない`() { + assertTrue(PolygonTileSet.canTile(PolygonState(points = square(0.0, 0.0, 1.0)))) + assertFalse(PolygonTileSet.canTile(PolygonState(points = square(0.0, 0.0, 1.0), geodesic = true))) + assertFalse(PolygonTileSet.canTile(PolygonState(points = square(0.0, -90.0, 180.0)))) + assertFalse(PolygonTileSet.canTile(PolygonState(points = listOf(p(0.0, 0.0), p(1.0, 1.0))))) + } + + @Test + fun `頂点数は外周と穴の合計`() { + val state = PolygonState(points = square(0.0, 0.0, 4.0), holes = listOf(square(1.0, 1.0, 1.0))) + assertEquals(16, PolygonTileSet.vertexCount(listOf(state, state))) + assertNotNull(TilePolygon.from(state)) + } +} diff --git a/src/test/java/com/mapconductor/core/polygon/PolygonTilingTest.kt b/src/test/java/com/mapconductor/core/polygon/PolygonTilingTest.kt new file mode 100644 index 0000000..b15662f --- /dev/null +++ b/src/test/java/com/mapconductor/core/polygon/PolygonTilingTest.kt @@ -0,0 +1,93 @@ +package com.mapconductor.core.polygon + +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.features.GeoPointInterface +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sin + +/** + * ポリゴンのタイル描画を支える部品。 + * + * js-sdk-core の `PolygonTiling.test.mjs`、ios-sdk の `PolygonTilingTests` と 1 対 1 で対応する。 + * web の Encoded Polyline の 2 件は、サービスワーカーへ渡すための変換なので + * Android には無い(タイルサーバーが同じプロセスにあり、状態をそのまま読む)。 + * + * - しきい値: ポリゴン数か頂点数のどちらかが届けばタイル。判定はここ 1 か所。 + * - 穴のユニオンの近道: 交わらない穴はそのまま返す。道路網の区画(穴 80 個・穴の頂点 + * 8,000)で 1 個数秒かかっていた二乗の計算を、交わる穴があるときだけに絞る。 + */ +class PolygonTilingTest { + private fun p( + latitude: Double, + longitude: Double, + ): GeoPointInterface = GeoPoint.fromLatLong(latitude, longitude) + + private fun square( + lat: Double, + lng: Double, + size: Double, + ): List = listOf(p(lat, lng), p(lat, lng + size), p(lat + size, lng + size), p(lat + size, lng)) + + /** The same square wound clockwise, the way holes come out of the union. */ + private fun clockwise(ring: List): List = ring.reversed() + + @Test + fun `ポリゴン数か頂点数のどちらかが届けばタイルにする`() { + val o = PolygonTilingOptions.Default + assertFalse(o.shouldUseTiles(1999, 49_999)) + assertTrue(o.shouldUseTiles(2000, 0)) + assertTrue(o.shouldUseTiles(10, 50_000)) + assertFalse(PolygonTilingOptions.Disabled.shouldUseTiles(100_000, 10_000_000)) + } + + @Test + fun `交わらない穴は計算せずにそのまま返す`() { + val holes = listOf(square(0.0, 0.0, 1.0), square(0.0, 2.0, 1.0), square(2.0, 0.0, 1.0)).map(::clockwise) + assertSame(holes, unionHoleRings(holes)) + } + + /** + * Android の union は穴を時計回りにそろえて返す(レンダラーが外周と逆巻きを期待する。 + * `PolygonHoleUnionTest.disjointSquares_remainSeparateWithSameAreas`)。近道でもそれは守る。 + * web の近道は向きをそろえずに返す。 + */ + @Test + fun `近道でも、反時計回りの穴は時計回りにして返す`() { + val holes = listOf(square(0.0, 0.0, 1.0), clockwise(square(0.0, 2.0, 1.0))) + val result = unionHoleRings(holes) + assertEquals(2, result.size) + assertEquals(holes[0].reversed(), result[0]) + assertSame(holes[1], result[1]) + } + + @Test + fun `重なる穴・中に入った穴・接する穴は従来どおり 1 つにまとめる`() { + assertEquals("重なる", 1, unionHoleRings(listOf(square(0.0, 0.0, 2.0), square(1.0, 1.0, 2.0))).size) + assertEquals("中に入った", 1, unionHoleRings(listOf(square(0.0, 0.0, 4.0), square(1.0, 1.0, 1.0))).size) + assertEquals("辺で接する", 1, unionHoleRings(listOf(square(0.0, 0.0, 1.0), square(0.0, 1.0, 1.0))).size) + } + + @Test + fun `交わらない穴が多くても速い(穴 80 個 x 100 頂点)`() { + val holes = + List(80) { i -> + val cx = (i % 10) * 3.0 + val cy = (i / 10) * 3.0 + List(100) { k -> + val a = -2 * PI * k / 100 + p(cy + sin(a), cx + cos(a)) + } + } + val start = System.nanoTime() + val result = unionHoleRings(holes) + val ms = (System.nanoTime() - start) / 1e6 + assertSame(holes, result) + assertTrue("$ms ms", ms < 200) + } +} diff --git a/src/test/java/com/mapconductor/core/raster/RasterLayerCoexistenceTest.kt b/src/test/java/com/mapconductor/core/raster/RasterLayerCoexistenceTest.kt new file mode 100644 index 0000000..93d3f6d --- /dev/null +++ b/src/test/java/com/mapconductor/core/raster/RasterLayerCoexistenceTest.kt @@ -0,0 +1,82 @@ +package com.mapconductor.core.raster + +import com.mapconductor.core.polygon.polygonTileRasterLayerState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * 1 枚の地図に載るラスターレイヤーは 3 系統ある。アプリの RasterLayer(add = composition)、 + * マーカータイル(marker-tile-*)、ポリゴンタイル(mc-polygon-tiles)。 + * + * js-sdk-core の `RasterLayerCoexistence.test.mjs`、ios-sdk の `RasterLayerCoexistenceTests` と + * 1 対 1 で対応する。 + * + * ## なぜここを固定するか + * + * マーカータイルを composition / clear で載せ外ししていたプロバイダでは、composition が + * 「渡されなかったレイヤーを消す」ので、マーカータイルを載せるとアプリの RasterLayer が、 + * アプリの RasterLayer が変わるとマーカータイルが消え、どちらも自分のデータが次に変わるまで + * 戻らなかった(web で起きた)。SDK が自分で載せるレイヤーは upsert / removeById で扱い、 + * add はアプリのレイヤーだけを入れ替える。 + */ +class RasterLayerCoexistenceTest { + private class Renderer( + val onMap: MutableSet, + ) : RasterLayerOverlayRendererInterface { + override val coroutine: CoroutineScope = CoroutineScope(Dispatchers.Unconfined) + + override suspend fun onAdd(data: List): List = + data.map { it.state.id.also(onMap::add) } + + override suspend fun onChange(data: List>): List = + data.map { it.current.state.id } + + override suspend fun onRemove(data: List>) { + data.forEach { onMap.remove(it.state.id) } + } + + override suspend fun onPostProcess() {} + } + + private class Controller( + renderer: Renderer, + ) : RasterLayerController(RasterLayerManager(), renderer) + + private fun layer(id: String) = + RasterLayerState( + id = id, + source = RasterLayerSource.UrlTemplate(template = "https://example.com/$id/{z}/{x}/{y}.png"), + ) + + private fun markerTiles(version: Int) = + RasterLayerState( + id = "marker-tile-m", + source = RasterLayerSource.UrlTemplate(template = "http://127.0.0.1/tiles/m/256/$version/{z}/{x}/{y}.png"), + ) + + @Test + fun `アプリの RasterLayer・マーカータイル・ポリゴンタイルは互いを消さない`() = + runBlocking { + val onMap = mutableSetOf() + val controller = Controller(Renderer(onMap)) + controller.add(listOf(layer("relief"))) + controller.upsert(polygonTileRasterLayerState("http://127.0.0.1/tiles/p/256/1/{z}/{x}/{y}.png")) + controller.upsert(markerTiles(1)) + assertEquals(listOf("marker-tile-m", "mc-polygon-tiles", "relief"), onMap.sorted()) + + // アプリの RasterLayer が変わっても、SDK のタイルは残る + controller.add(listOf(layer("photo"))) + assertEquals(listOf("marker-tile-m", "mc-polygon-tiles", "photo"), onMap.sorted()) + + // マーカータイルの版が変わっても、ほかは残る + controller.upsert(markerTiles(2)) + assertEquals(listOf("marker-tile-m", "mc-polygon-tiles", "photo"), onMap.sorted()) + + // マーカータイルを外しても、ほかは残る + controller.removeById("marker-tile-m") + assertEquals(listOf("mc-polygon-tiles", "photo"), onMap.sorted()) + } +} From 02369faa3d5197162273f68395db7fd407e3eca2 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 06:40:10 -0800 Subject: [PATCH 3/8] Release a map's polygon tiles when its controller is destroyed --- api/android-sdk-core.api | 1 + .../com/mapconductor/core/polygon/PolygonController.kt | 4 +++- .../java/com/mapconductor/core/polygon/PolygonTiler.kt | 10 ++++++++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/api/android-sdk-core.api b/api/android-sdk-core.api index 69db9b7..95c91b8 100644 --- a/api/android-sdk-core.api +++ b/api/android-sdk-core.api @@ -2352,6 +2352,7 @@ public final class com.mapconductor.core.polygon.PolygonTiler { public final boolean isTiled(java.lang.String); public final void update(com.mapconductor.core.polygon.PolygonState); public final java.lang.Object clear(kotlin.coroutines.Continuation); + public final void dispose(); public com.mapconductor.core.polygon.PolygonTiler(); } public final class com.mapconductor.core.polygon.PolygonTilerKt { diff --git a/src/main/java/com/mapconductor/core/polygon/PolygonController.kt b/src/main/java/com/mapconductor/core/polygon/PolygonController.kt index 67f2edd..f3aa949 100644 --- a/src/main/java/com/mapconductor/core/polygon/PolygonController.kt +++ b/src/main/java/com/mapconductor/core/polygon/PolygonController.kt @@ -213,7 +213,9 @@ abstract class PolygonController( .find(position) override fun destroy() { - // No native resources to clean up for polygons + // The tile server outlives the map; without this its route keeps every + // tiled polygon (and the renderer's copies of them) alive. + tiler?.dispose() } override fun has(id: String): Boolean = polygonManager.hasEntity(id) diff --git a/src/main/java/com/mapconductor/core/polygon/PolygonTiler.kt b/src/main/java/com/mapconductor/core/polygon/PolygonTiler.kt index 29b4577..b7e564a 100644 --- a/src/main/java/com/mapconductor/core/polygon/PolygonTiler.kt +++ b/src/main/java/com/mapconductor/core/polygon/PolygonTiler.kt @@ -83,6 +83,16 @@ class PolygonTiler( tileSet.update(state) } + /** + * Releases the tiles when the map goes away. Unlike [clear], it does not + * call [onTemplate]: there is no map left to remove the layer from. + */ + fun dispose() { + tileSet.dispose() + tiled = emptySet() + states = emptyList() + } + suspend fun clear() { tileSet.dispose() tiled = emptySet() From 57d377fb442abbcc4cc619d0189ebde997c92312 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 20:07:32 -0800 Subject: [PATCH 4/8] PolygonController: move a tiled polygon to native drawing when a change makes it untileable Whether a polygon is tiled was decided only on add. Setting geodesic on a tiled polygon in place, or giving it points that span half the world or fewer than three, kept it in the tiles, drawn with straight edges. update() now checks canTile: a polygon that fails it leaves the tiles (its slot in the renderer is emptied, so only the tiles it was in are drawn again) and is handed to the renderer. It stays on the map, so this is not an add. The reverse is left alone: a native polygon that could now be tiled already looks right. Same as js-sdk-core. --- .../polygon/PolygonTileRendererDeviceTest.kt | 17 +++ .../core/polygon/PolygonTilerDeviceTest.kt | 129 +++++++++++++++++- .../core/polygon/PolygonController.kt | 28 +++- .../core/polygon/PolygonTileSet.kt | 41 ++++-- .../mapconductor/core/polygon/PolygonTiler.kt | 15 +- .../core/polygon/PolygonTilingTest.kt | 9 ++ 6 files changed, 224 insertions(+), 15 deletions(-) diff --git a/src/androidTest/java/com/mapconductor/core/polygon/PolygonTileRendererDeviceTest.kt b/src/androidTest/java/com/mapconductor/core/polygon/PolygonTileRendererDeviceTest.kt index 1d09385..8493c5e 100644 --- a/src/androidTest/java/com/mapconductor/core/polygon/PolygonTileRendererDeviceTest.kt +++ b/src/androidTest/java/com/mapconductor/core/polygon/PolygonTileRendererDeviceTest.kt @@ -140,6 +140,23 @@ class PolygonTileRendererDeviceTest { assertEquals(0x00FF00, rgb(decoded.getPixel(120, 234))) } + /** タイルから外したポリゴン(null)は、重なっていたタイルだけ描き直して消える */ + @Test + fun removedPolygonRedrawsOnlyItsTiles() { + val west = PolygonState(points = box(10.0, -100.0, 20.0, -90.0), fillColor = blue, id = "west") + val east = PolygonState(points = box(10.0, 90.0, 20.0, 100.0), fillColor = blue, id = "east") + val r = renderer(west, east) + val westTile = TileRequest(x = 0, y = 0, z = 1) + val eastTile = TileRequest(x = 1, y = 0, z = 1) + assertNotNull(r.renderTile(westTile)) + val eastBefore = r.renderTile(eastTile)!! + + r.update(listOf(0 to null)) + + assertNull("外したポリゴンしか無いタイルは空", r.renderTile(westTile)) + assertSame("触れていないタイルはキャッシュから返す", eastBefore, r.renderTile(eastTile)) + } + /** 形が変わったら索引も作り直す */ @Test fun movedShapeIsReindexed() { diff --git a/src/androidTest/java/com/mapconductor/core/polygon/PolygonTilerDeviceTest.kt b/src/androidTest/java/com/mapconductor/core/polygon/PolygonTilerDeviceTest.kt index ae7e618..bf1f638 100644 --- a/src/androidTest/java/com/mapconductor/core/polygon/PolygonTilerDeviceTest.kt +++ b/src/androidTest/java/com/mapconductor/core/polygon/PolygonTilerDeviceTest.kt @@ -48,15 +48,26 @@ class PolygonTilerDeviceTest { private class Renderer : PolygonOverlayRendererInterface { val onMap = mutableSetOf() + val added = mutableListOf() + val changed = mutableListOf() + val removed = mutableListOf() override suspend fun onAdd(data: List): List = - data.map { it.state.id.also(onMap::add) } + data.map { params -> + params.state.id.also { + onMap.add(it) + added.add(it) + } + } override suspend fun onChange(data: List>): List = - data.map { it.current.state.id } + data.map { params -> params.current.state.id.also { changed.add(it) } } override suspend fun onRemove(data: List>) { - data.forEach { onMap.remove(it.state.id) } + data.forEach { + onMap.remove(it.state.id) + removed.add(it.state.id) + } } override suspend fun onPostProcess() {} @@ -182,6 +193,118 @@ class PolygonTilerDeviceTest { assertTrue(TileServerRegistry.get().isRunning()) } + // ── タイルにできなくなったポリゴン(geodesic・経度の幅 180° 以上・頂点 3 未満) ────── + // + // タイルにするかは add でだけ決めていた。タイルで描いているポリゴンをその場で + // geodesic=true にしたり、点を書き換えてタイルにできない形にしたりすると、update は + // タイルの経路に入ったまま canTile を見直さず、直線のままタイルに描き続けた。 + // js-sdk-core の PolygonTilingInPlace.test.mjs、ios-sdk-core の PolygonTilingInPlaceTests と同じ内容。 + + /** parcel-101 の中と、隣の parcel-102 の中 */ + private val inside101 = 42.3017 to -71.0983 + private val inside102 = 42.3017 to -71.0971 + + /** 混在リストでは geodesic だけネイティブ、タイルには描かれない */ + @Test + fun inAMixedListOnlyTheGeodesicOneGoesNative() = + runBlocking { + val (controller, renderer, templates) = controller() + val g = + PolygonState( + points = listOf(p(42.29, -71.10), p(42.29, -71.099), p(42.291, -71.099), p(42.291, -71.10)), + id = "g", + fillColor = Color(0xFFDC2626), + geodesic = true, + ) + controller.add(parcels(2000) + g) + assertEquals(setOf("g"), renderer.onMap) + val template = templates.last()!! + assertEquals("タイルには描かない", 0, alphaAt(template, 42.2905, -71.0995, 17)) + assertTrue("区画はタイルに描く", alphaAt(template, inside101.first, inside101.second, 17) > 0) + } + + private suspend fun movesToNative(change: (PolygonState) -> Unit): Triple> { + val (controller, renderer, templates) = controller() + val states = parcels(2000) + controller.add(states) + assertTrue(renderer.onMap.isEmpty()) + val before = templates.last()!! + assertTrue(alphaAt(before, inside101.first, inside101.second, 17) > 0) + + change(states[101]) + controller.update(states[101]) + withTimeout(2000) { + while (synchronized(templates) { templates.last() } == before) delay(5) + } + + assertEquals("ネイティブで描く", listOf("parcel-101"), renderer.added) + assertEquals("parcel-101", controller.polygonManager.getEntity("parcel-101")?.polygon) + val after = templates.last()!! + assertEquals("タイルから消える", 0, alphaAt(after, inside101.first, inside101.second, 17)) + assertTrue("隣はタイルに残る", alphaAt(after, inside102.first, inside102.second, 17) > 0) + return Triple(controller, renderer, states) + } + + /** その場で geodesic=true にしたらネイティブへ移り、タイルから消える。タップは引き続き当たる */ + @Test + fun settingGeodesicInPlaceMovesItToNative() = + runBlocking { + val (controller, renderer, states) = movesToNative { it.geodesic = true } + + val hit = controller.find(p(inside101.first, inside101.second)) + assertEquals("parcel-101", hit?.state?.id) + assertEquals("parcel-101", hit?.polygon) + assertEquals("parcel-102", controller.find(p(inside102.first, inside102.second))?.state?.id) + + // その後のその場の変更はネイティブへ + states[101].fillColor = Color(0xFF16A34A) + controller.update(states[101]) + assertEquals(listOf("parcel-101"), renderer.changed) + + // 同じリストを渡し直しても、ネイティブのまま(足しも消しもしない) + controller.add(states) + assertEquals(listOf("parcel-101"), renderer.added) + assertTrue(renderer.removed.isEmpty()) + assertEquals(setOf("parcel-101"), renderer.onMap) + } + + /** その場で点を書き換えて経度の幅 180° 以上にしても同じ */ + @Test + fun pointsSpanningHalfTheWorldInPlaceMoveItToNative() = + runBlocking { + movesToNative { + it.points = listOf(p(42.3012, -71.0988), p(42.3012, -71.0978), p(42.3022, -71.0978), p(42.3022, 120.0)) + } + Unit + } + + /** その場で点を 3 未満にしても同じ */ + @Test + fun fewerThanThreePointsInPlaceMoveItToNative() = + runBlocking { + movesToNative { it.points = listOf(p(42.3012, -71.0988), p(42.3012, -71.0978)) } + Unit + } + + /** The alpha of the tile pixel at a coordinate. */ + private fun alphaAt( + template: String, + lat: Double, + lng: Double, + z: Int, + ): Int { + val (x, y) = tileOf(lat, lng, z) + val (code, bytes) = get(template, z, x, y) + assertEquals(200, code) + val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return 0 + val scale = 256.0 * (1 shl z) + val s = kotlin.math.sin(Math.toRadians(lat)) + val px = (lng + 180) / 360 * scale - x * 256 + val py = (0.5 - kotlin.math.ln((1 + s) / (1 - s)) / (4 * Math.PI)) * scale - y * 256 + val k = bitmap.width / 256.0 + return bitmap.getPixel((px * k).toInt(), (py * k).toInt()) ushr 24 + } + private fun tileOf( lat: Double, lng: Double, diff --git a/src/main/java/com/mapconductor/core/polygon/PolygonController.kt b/src/main/java/com/mapconductor/core/polygon/PolygonController.kt index f3aa949..6f288da 100644 --- a/src/main/java/com/mapconductor/core/polygon/PolygonController.kt +++ b/src/main/java/com/mapconductor/core/polygon/PolygonController.kt @@ -160,8 +160,32 @@ abstract class PolygonController( semaphore.withPermit { val prevEntity = polygonManager.getEntity(state.id) ?: return if (state.id in tiledIds) { - polygonManager.registerEntity(PolygonEntity(polygon = prevEntity.polygon, state = state)) - tiler?.update(state) + if (PolygonTileSet.canTile(state)) { + polygonManager.registerEntity(PolygonEntity(polygon = prevEntity.polygon, state = state)) + tiler?.update(state) + return + } + // No longer drawable in a tile (geodesic, half the world wide, or + // under 3 points): the map SDK draws it from now on. Still on the + // map, so this is not an add. Whether the rest stay in tiles is + // left to the next add(), which decides tiling for the whole list. + tiledIds = tiledIds - state.id + tiler?.remove(state.id) + val polygons = + renderer.onAdd( + listOf( + object : PolygonOverlayRendererInterface.AddParamsInterface { + override val state: PolygonState = state + }, + ), + ) + val polygon = polygons.firstOrNull() + if (polygon != null) { + polygonManager.registerEntity(PolygonEntity(polygon = polygon, state = state)) + } else { + polygonManager.removeEntity(state.id) + } + renderer.onPostProcess() return } val currentFinger = state.fingerPrint() diff --git a/src/main/java/com/mapconductor/core/polygon/PolygonTileSet.kt b/src/main/java/com/mapconductor/core/polygon/PolygonTileSet.kt index 4374627..e835325 100644 --- a/src/main/java/com/mapconductor/core/polygon/PolygonTileSet.kt +++ b/src/main/java/com/mapconductor/core/polygon/PolygonTileSet.kt @@ -47,6 +47,7 @@ class PolygonTileSet( private var version = 0 private var registered = false private val pending = LinkedHashMap() + private val pendingRemovals = ArrayList() private var flushJob: Job? = null /** @@ -83,13 +84,33 @@ class PolygonTileSet( synchronized(lock) { if (!indexById.containsKey(state.id)) return pending[state.id] = state - if (flushJob == null) { - flushJob = - scope.launch { - delay(UPDATE_COALESCE) - flush() - } - } + scheduleFlushLocked() + } + } + + /** + * Takes one polygon out of the tiles. Its slot in the renderer's list is + * left empty, so the others keep their indices and only the tiles it was + * in are drawn again. + */ + fun remove(id: String) { + synchronized(lock) { + val index = indexById[id] ?: return + indexById = indexById - id + pending.remove(id) + pendingRemovals.add(index) + scheduleFlushLocked() + } + } + + /** Called with [lock] held. */ + private fun scheduleFlushLocked() { + if (flushJob == null) { + flushJob = + scope.launch { + delay(UPDATE_COALESCE) + flush() + } } } @@ -109,11 +130,12 @@ class PolygonTileSet( val template = synchronized(lock) { flushJob = null - val updates = + val updates: List> = pending.values.mapNotNull { state -> indexById[state.id]?.let { index -> index to TilePolygon.from(state) } - } + } + pendingRemovals.map { index -> index to null } pending.clear() + pendingRemovals.clear() if (updates.isEmpty() || !registered) return renderer.update(updates) version++ @@ -127,6 +149,7 @@ class PolygonTileSet( flushJob?.cancel() flushJob = null pending.clear() + pendingRemovals.clear() } } diff --git a/src/main/java/com/mapconductor/core/polygon/PolygonTiler.kt b/src/main/java/com/mapconductor/core/polygon/PolygonTiler.kt index b7e564a..97ed549 100644 --- a/src/main/java/com/mapconductor/core/polygon/PolygonTiler.kt +++ b/src/main/java/com/mapconductor/core/polygon/PolygonTiler.kt @@ -27,7 +27,8 @@ fun polygonTileRasterLayerState( * in tiles; the controller keeps those in its [PolygonManager] (taps are * resolved there, against the original shapes) but gives the map SDK * nothing for them, and removes any native polygon it had drawn for one. - * 2. [update] for an in-place change of a tiled polygon. + * 2. [update] for an in-place change of a tiled polygon, or [remove] when the + * change means it can no longer be tiled. * 3. [onTemplate] is how the tiles reach the map: a URL template to show as a * raster layer, or null to remove it. A provider with a RasterLayer * controller hands [polygonTileRasterLayerState] to its `upsert`, and @@ -83,6 +84,18 @@ class PolygonTiler( tileSet.update(state) } + /** + * Takes one polygon out of the tiles, for a change that left it no longer + * [PolygonTileSet.canTile]; only the tiles it was in are drawn again. The + * controller then hands it to the map SDK. + */ + fun remove(id: String) { + if (id !in tiled) return + tiled = tiled - id + states = states.filter { it.id != id } + tileSet.remove(id) + } + /** * Releases the tiles when the map goes away. Unlike [clear], it does not * call [onTemplate]: there is no map left to remove the layer from. diff --git a/src/test/java/com/mapconductor/core/polygon/PolygonTilingTest.kt b/src/test/java/com/mapconductor/core/polygon/PolygonTilingTest.kt index b15662f..fe5eadd 100644 --- a/src/test/java/com/mapconductor/core/polygon/PolygonTilingTest.kt +++ b/src/test/java/com/mapconductor/core/polygon/PolygonTilingTest.kt @@ -46,6 +46,15 @@ class PolygonTilingTest { assertFalse(PolygonTilingOptions.Disabled.shouldUseTiles(100_000, 10_000_000)) } + @Test + fun `geodesic・経度の幅 180 度以上・頂点 3 未満はタイルにしない`() { + assertTrue(PolygonTileSet.canTile(PolygonState(points = square(0.0, 0.0, 1.0)))) + assertFalse(PolygonTileSet.canTile(PolygonState(points = square(0.0, 0.0, 1.0), geodesic = true))) + assertFalse(PolygonTileSet.canTile(PolygonState(points = listOf(p(0.0, -100.0), p(0.0, 80.0), p(10.0, 0.0))))) + assertTrue(PolygonTileSet.canTile(PolygonState(points = listOf(p(0.0, -90.0), p(0.0, 89.0), p(10.0, 0.0))))) + assertFalse(PolygonTileSet.canTile(PolygonState(points = listOf(p(0.0, 0.0), p(1.0, 1.0))))) + } + @Test fun `交わらない穴は計算せずにそのまま返す`() { val holes = listOf(square(0.0, 0.0, 1.0), square(0.0, 2.0, 1.0), square(2.0, 0.0, 1.0)).map(::clockwise) From 1e6bc7962d36b746c9011a05a6c230deba2814c9 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 20:41:22 -0800 Subject: [PATCH 5/8] PolygonTilerDeviceTest: tile one polygon more than the threshold With exactly 2,000 parcels, the one that moved to native drawing left 1,999 tileable ones, so handing the same list back put them all on the native path (the add path's decision, as intended), and the test read that as a failure. --- .../com/mapconductor/core/polygon/PolygonTilerDeviceTest.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/androidTest/java/com/mapconductor/core/polygon/PolygonTilerDeviceTest.kt b/src/androidTest/java/com/mapconductor/core/polygon/PolygonTilerDeviceTest.kt index bf1f638..3d5b30d 100644 --- a/src/androidTest/java/com/mapconductor/core/polygon/PolygonTilerDeviceTest.kt +++ b/src/androidTest/java/com/mapconductor/core/polygon/PolygonTilerDeviceTest.kt @@ -225,7 +225,8 @@ class PolygonTilerDeviceTest { private suspend fun movesToNative(change: (PolygonState) -> Unit): Triple> { val (controller, renderer, templates) = controller() - val states = parcels(2000) + // しきい値(2,000)より 1 つ多い。移った 1 つを除いても、渡し直したときに残りはタイルのまま。 + val states = parcels(2001) controller.add(states) assertTrue(renderer.onMap.isEmpty()) val before = templates.last()!! From d9b91fd7674f8abbc3ad6cb96a6267d4332b15c1 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 16:37:27 -0800 Subject: [PATCH 6/8] Pool the polygon tile renderer's scratch bitmaps instead of one per thread Same as TilePngEncoder: renderTile runs on Google's tile-overlay threads, which outlive their map, and a ThreadLocal left a tile bitmap (native memory) on each of them. (cherry picked from commit 4c9ccdd2a655fcdd6a298227b10ccdf35bebcedd) --- .../core/polygon/PolygonTileRenderer.kt | 66 +++++++++++++++---- 1 file changed, 55 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/mapconductor/core/polygon/PolygonTileRenderer.kt b/src/main/java/com/mapconductor/core/polygon/PolygonTileRenderer.kt index 72b718e..5467497 100644 --- a/src/main/java/com/mapconductor/core/polygon/PolygonTileRenderer.kt +++ b/src/main/java/com/mapconductor/core/polygon/PolygonTileRenderer.kt @@ -8,6 +8,7 @@ import android.graphics.Path import android.util.LruCache import androidx.core.graphics.createBitmap import com.mapconductor.core.ResourceProvider +import com.mapconductor.core.tileserver.ScratchPool import com.mapconductor.core.tileserver.TilePngEncoder import com.mapconductor.core.tileserver.TileProviderInterface import com.mapconductor.core.tileserver.TileRequest @@ -72,8 +73,10 @@ internal class PolygonTileRenderer( } private val cacheLock = Any() - private val scratchBitmap = ThreadLocal() - private val byteStream = ThreadLocal.withInitial { ByteArrayOutputStream(16 * 1024) } + // Pools, not thread-locals: tiles are drawn on the map SDK's threads, which can + // outlive this renderer (see ScratchPool) and would keep a tile bitmap each. + private val scratchBitmaps = ScratchPool() + private val byteStreams = ScratchPool() /** Replaces every polygon. Draw order is list order. */ fun setPolygons(polygons: List) { @@ -141,7 +144,12 @@ internal class PolygonTileRenderer( synchronized(cacheLock) { tileCache.get(key) }?.let { return it } val bitmap = draw(m, x, y, z, ratio, isCancelled) ?: return null - val bytes = encode(bitmap) + val bytes = + try { + encode(bitmap) + } finally { + releaseScratch(bitmap) + } synchronized(cacheLock) { // The polygons may have changed while this tile was drawn. if (generation.get() == generationAtStart) tileCache.put(key, bytes) @@ -155,7 +163,14 @@ internal class PolygonTileRenderer( y: Int, z: Int, pixelRatio: Int = 1, - ): Bitmap? = model?.let { draw(it, x, y, z, pixelRatio) { false } }?.copy(Bitmap.Config.ARGB_8888, false) + ): Bitmap? = + model?.let { draw(it, x, y, z, pixelRatio) { false } }?.let { scratch -> + try { + scratch.copy(Bitmap.Config.ARGB_8888, false) + } finally { + releaseScratch(scratch) + } + } private fun draw( m: Model, @@ -182,6 +197,26 @@ internal class PolygonTileRenderer( val r = pixelScale() * pixelRatio val px = (tileSize * r).roundToInt().coerceAtLeast(1) val bitmap = scratch(px) + try { + return drawInto(bitmap, m, candidates, originX, originY, r, z, scale, isCancelled) + } catch (error: Throwable) { + releaseScratch(bitmap) + throw error + } + } + + /** Draws into [bitmap]; null (and the bitmap back in the pool) when nothing was drawn. */ + private fun drawInto( + bitmap: Bitmap, + m: Model, + candidates: IntArray, + originX: Double, + originY: Double, + r: Float, + z: Int, + scale: Double, + isCancelled: () -> Boolean, + ): Bitmap? { val canvas = Canvas(bitmap) val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL } val strokePaint = @@ -229,6 +264,7 @@ internal class PolygonTileRenderer( } } } + if (!drew) releaseScratch(bitmap) return if (drew) bitmap else null } @@ -278,21 +314,29 @@ internal class PolygonTileRenderer( } private fun scratch(px: Int): Bitmap { - val existing = scratchBitmap.get() - if (existing != null && !existing.isRecycled && existing.width == px && existing.height == px) { + val existing = scratchBitmaps.acquire { !it.isRecycled && it.width == px && it.height == px } + if (existing != null) { existing.eraseColor(Color.TRANSPARENT) return existing } - return createBitmap(px, px).also { scratchBitmap.set(it) } + return createBitmap(px, px) + } + + private fun releaseScratch(bitmap: Bitmap) { + scratchBitmaps.release(bitmap) } private fun encode(bitmap: Bitmap): ByteArray { // Rust first, as for marker tiles: Bitmap.compress is several times slower. TilePngEncoder.encode(bitmap)?.let { return it } - val out = byteStream.get()!! - out.reset() - bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) - return out.toByteArray() + val out = byteStreams.acquire() ?: ByteArrayOutputStream(16 * 1024) + try { + out.reset() + bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) + return out.toByteArray() + } finally { + byteStreams.release(out) + } } private fun dropTilesIn( From 2112ba7ffa18e766fa1b814d87b87faa69582142 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Mon, 9 Nov 2026 06:45:48 -0800 Subject: [PATCH 7/8] holesAreDisjoint: find nearby edges with the union's EdgeGrid The disjoint check had a grid of its own (and ringBox, a copy of ringBounds). It now files the hole edges in the EdgeGrid the union uses, padded by MEET_PAD, the reach of intersectionPoints, and tests each edge only against the edges of other rings near it. The old grid padded by EPS, less than that reach, so it could miss a pair the union would have seen meet. --- .../core/polygon/PolygonHoleUnion.kt | 88 +++++-------------- 1 file changed, 20 insertions(+), 68 deletions(-) diff --git a/src/main/java/com/mapconductor/core/polygon/PolygonHoleUnion.kt b/src/main/java/com/mapconductor/core/polygon/PolygonHoleUnion.kt index 2abd6a2..13b1e99 100644 --- a/src/main/java/com/mapconductor/core/polygon/PolygonHoleUnion.kt +++ b/src/main/java/com/mapconductor/core/polygon/PolygonHoleUnion.kt @@ -403,12 +403,12 @@ private fun nextEdge( } /** - * True when no two rings cross, touch or nest. Edges are binned into a uniform - * grid and only pairs sharing a cell are tested, so the cost grows with the + * True when no two rings cross, touch or nest. Edges are filed in an [EdgeGrid] + * and each is tested only against the edges near it, so the cost grows with the * edge count rather than its square. Same as js-sdk-core's `holesAreDisjoint`. */ private fun holesAreDisjoint(rings: List>): Boolean { - val boxes = rings.map(::ringBox) + val boxes = rings.map(::ringBounds) // A ring inside another: one of its vertices is inside it. for (i in rings.indices) { val p = rings[i][0] @@ -422,82 +422,34 @@ private fun holesAreDisjoint(rings: List>): Boolean { } val edges = ArrayList() - val edgeRing = ArrayList() + val edgeRing = IntArrayList() for ((r, ring) in rings.withIndex()) { for (i in ring.indices) { edges.add(Edge(ring[i], ring[(i + 1) % ring.size])) edgeRing.add(r) } } - var minX = Double.POSITIVE_INFINITY - var minY = Double.POSITIVE_INFINITY - var maxX = Double.NEGATIVE_INFINITY - var maxY = Double.NEGATIVE_INFINITY - for (b in boxes) { - minX = minOf(minX, b[0]) - minY = minOf(minY, b[1]) - maxX = maxOf(maxX, b[2]) - maxY = maxOf(maxY, b[3]) - } - val side = - kotlin.math - .ceil(kotlin.math.sqrt(edges.size.toDouble())) - .toInt() - .coerceIn(1, 1024) - // Padded by EPS so an edge lying on a cell border is in both cells. - val cellW = ((maxX - minX) / side).takeIf { it != 0.0 } ?: 1.0 - val cellH = ((maxY - minY) / side).takeIf { it != 0.0 } ?: 1.0 - - fun cellOf( - v: Double, - min: Double, - size: Double, - ): Int = - kotlin.math - .floor((v - min) / size) - .toInt() - .coerceIn(0, side - 1) - val cells = HashMap>() - for ((e, edge) in edges.withIndex()) { - val a = edge.a - val b = edge.b - val x0 = cellOf(minOf(a.x, b.x) - EPS, minX, cellW) - val x1 = cellOf(maxOf(a.x, b.x) + EPS, minX, cellW) - val y0 = cellOf(minOf(a.y, b.y) - EPS, minY, cellH) - val y1 = cellOf(maxOf(a.y, b.y) + EPS, minY, cellH) - for (cx in x0..x1) { - for (cy in y0..y1) { - cells.getOrPut(cx * side + cy) { ArrayList(4) }.add(e) - } - } - } - for (bucket in cells.values) { - for (i in bucket.indices) { - for (j in i + 1 until bucket.size) { - val p = bucket[i] - val q = bucket[j] - if (edgeRing[p] != edgeRing[q] && intersectionPoints(edges[p], edges[q]).isNotEmpty()) return false - } + // Only an edge whose box comes within MEET_PAD of another's can meet it. + val grid = EdgeGrid(edges, MEET_PAD) + val near = IntArrayList() + for (i in edges.indices) { + val seg = edges[i] + grid.near( + minOf(seg.a.x, seg.b.x) - MEET_PAD, + minOf(seg.a.y, seg.b.y) - MEET_PAD, + maxOf(seg.a.x, seg.b.x) + MEET_PAD, + maxOf(seg.a.y, seg.b.y) + MEET_PAD, + near, + ) + for (k in 0 until near.size) { + val j = near[k] + if (j <= i || edgeRing[j] == edgeRing[i]) continue + if (intersectionPoints(seg, edges[j]).isNotEmpty()) return false } } return true } -/** minX, minY, maxX, maxY. */ -private fun ringBox(ring: List): DoubleArray { - var minX = Double.POSITIVE_INFINITY - var minY = Double.POSITIVE_INFINITY - var maxX = Double.NEGATIVE_INFINITY - var maxY = Double.NEGATIVE_INFINITY - for (p in ring) { - minX = minOf(minX, p.x) - maxX = maxOf(maxX, p.x) - minY = minOf(minY, p.y) - maxY = maxOf(maxY, p.y) - } - return doubleArrayOf(minX, minY, maxX, maxY) -} - /** 線分 `other` が線分 `seg` と出会う点(交差・T 字接触・共線重複の端点)。 */ private fun intersectionPoints( seg: Edge, From 5e82caa77f339126e874451a1801eb74e1d8bcd5 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Mon, 9 Nov 2026 06:45:49 -0800 Subject: [PATCH 8/8] Record the API added on this branch: PolygonTiler.remove, PolygonTileSet.remove Also EdgeGrid and IntArrayList from the hole union (private classes, which the apiCheck dump lists as package-private, like Edge). --- api/android-sdk-core.api | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/api/android-sdk-core.api b/api/android-sdk-core.api index 551a3d4..3deb998 100644 --- a/api/android-sdk-core.api +++ b/api/android-sdk-core.api @@ -2098,10 +2098,21 @@ final class com.mapconductor.core.polygon.Edge { public int hashCode(); public boolean equals(java.lang.Object); } +final class com.mapconductor.core.polygon.EdgeGrid { + public com.mapconductor.core.polygon.EdgeGrid(java.util.List, double); + public final com.mapconductor.core.polygon.IntArrayList near(double, double, double, double, com.mapconductor.core.polygon.IntArrayList); +} public final class com.mapconductor.core.polygon.HoleBridgeKt { public static final java.util.List bridgeHolesIntoSingleRing(java.util.List, java.util.List>, double); public static final java.util.List bridgeHolesIntoSingleRingWrapAware(java.util.List, java.util.List>, double); } +final class com.mapconductor.core.polygon.IntArrayList { + public com.mapconductor.core.polygon.IntArrayList(); + public final int getSize(); + public final int get(int); + public final void add(int); + public final void clear(); +} public final class com.mapconductor.core.polygon.IntList { public com.mapconductor.core.polygon.IntList(int); public com.mapconductor.core.polygon.IntList(int, int, kotlin.jvm.internal.DefaultConstructorMarker); @@ -2363,6 +2374,7 @@ public final class com.mapconductor.core.polygon.PolygonTileSet { public final java.lang.Object setPolygons(java.util.List, kotlin.coroutines.Continuation); public final boolean has(java.lang.String); public final void update(com.mapconductor.core.polygon.PolygonState); + public final void remove(java.lang.String); public final java.lang.String template(); public final void dispose(); public com.mapconductor.core.polygon.PolygonTileSet(); @@ -2381,8 +2393,9 @@ public final class com.mapconductor.core.polygon.PolygonTiler { public final java.util.List tiledStates(); public final boolean isTiled(java.lang.String); public final void update(com.mapconductor.core.polygon.PolygonState); - public final java.lang.Object clear(kotlin.coroutines.Continuation); + public final void remove(java.lang.String); public final void dispose(); + public final java.lang.Object clear(kotlin.coroutines.Continuation); public com.mapconductor.core.polygon.PolygonTiler(); } public final class com.mapconductor.core.polygon.PolygonTilerKt {