diff --git a/app/src/androidInstrumentedTest/kotlin/de/westnordost/streetcomplete/data/osm/geometry/PolylabelHoleTest.kt b/app/src/androidInstrumentedTest/kotlin/de/westnordost/streetcomplete/data/osm/geometry/PolylabelHoleTest.kt new file mode 100644 index 00000000000..b14eac023d2 --- /dev/null +++ b/app/src/androidInstrumentedTest/kotlin/de/westnordost/streetcomplete/data/osm/geometry/PolylabelHoleTest.kt @@ -0,0 +1,71 @@ +package de.westnordost.streetcomplete.data.osm.geometry + +import de.westnordost.streetcomplete.data.osm.geometry.polygons.Point +import de.westnordost.streetcomplete.data.osm.geometry.polygons.Polygon +import de.westnordost.streetcomplete.data.osm.geometry.polygons.PolygonAlgorithms +import kotlin.test.Test +import kotlin.test.assertTrue + +class PolylabelHoleTest { + + @Test + fun testSquareDonut() { + val outer = listOf( + Point(0.0, 0.0), + Point(20.0, 0.0), + Point(20.0, 20.0), + Point(0.0, 20.0), + Point(0.0, 0.0) + ) + + val hole = listOf( + Point(8.0, 8.0), + Point(12.0, 8.0), + Point(12.0, 12.0), + Point(8.0, 12.0), + Point(8.0, 8.0) + ) + + val polygon = Polygon(outer, holes = listOf(hole)) + val result = PolygonAlgorithms.polylabel(polygon, precision = 0.5) + + // The result must be inside the outer polygon + assertTrue(isPointInRing(result, outer), "Result should be inside the outer polygon") + + // The result must be outside the hole + assertTrue(!isPointInRing(result, hole), "Result should be outside the hole") + + // Optional: approximate distance to nearest boundary + val dist = PolygonAlgorithms.run { + privatePointToPolygonDist(result, polygon) + } + assertTrue(dist > 3.5, "Result should be reasonably far from edges") + } + + // Helper (copy of existing point-in-ring logic) + private fun isPointInRing(p: Point, ring: List): Boolean { + var inside = false + var j = ring.lastIndex + for (i in ring.indices) { + val xi = ring[i].x + val yi = ring[i].y + val xj = ring[j].x + val yj = ring[j].y + if ((yi > p.y) != (yj > p.y) && + (p.x < (xj - xi) * (p.y - yi) / (yj - yi + 0.0) + xi)) { + inside = !inside + } + j = i + } + return inside + } + + // Expose pointToPolygonDist for distance check (since private in PolygonAlgorithms) + private fun PolygonAlgorithms.privatePointToPolygonDist(p: Point, polygon: Polygon): Double { + val method = PolygonAlgorithms::class.java.getDeclaredMethod( + "pointToPolygonDist", Point::class.java, Polygon::class.java + ) + method.isAccessible = true + return method.invoke(this, p, polygon) as Double + } +} diff --git a/app/src/androidInstrumentedTest/kotlin/de/westnordost/streetcomplete/data/osm/geometry/PolylabelSimpleTest.kt b/app/src/androidInstrumentedTest/kotlin/de/westnordost/streetcomplete/data/osm/geometry/PolylabelSimpleTest.kt new file mode 100644 index 00000000000..da37ac5a89e --- /dev/null +++ b/app/src/androidInstrumentedTest/kotlin/de/westnordost/streetcomplete/data/osm/geometry/PolylabelSimpleTest.kt @@ -0,0 +1,28 @@ +package de.westnordost.streetcomplete.data.osm.geometry + +import de.westnordost.streetcomplete.data.osm.geometry.polygons.Point +import de.westnordost.streetcomplete.data.osm.geometry.polygons.Polygon +import de.westnordost.streetcomplete.data.osm.geometry.polygons.PolygonAlgorithms +import kotlin.test.Test +import kotlin.test.assertEquals + +class PolylabelSimpleTest { + + @Test + fun testSimpleSquare() { + val poly = Polygon( + shape = listOf( + Point(0.0, 0.0), + Point(10.0, 0.0), + Point(10.0, 10.0), + Point(0.0, 10.0), + Point(0.0, 0.0) + ) + ) + + val result = PolygonAlgorithms.polylabel(poly, precision = 1.0) + + assertEquals(5.0, result.x, 1.0) + assertEquals(5.0, result.y, 1.0) + } +} diff --git a/app/src/androidInstrumentedTest/kotlin/de/westnordost/streetcomplete/data/osm/geometry/PolylabelTest.kt b/app/src/androidInstrumentedTest/kotlin/de/westnordost/streetcomplete/data/osm/geometry/PolylabelTest.kt new file mode 100644 index 00000000000..22ed2d0aad9 --- /dev/null +++ b/app/src/androidInstrumentedTest/kotlin/de/westnordost/streetcomplete/data/osm/geometry/PolylabelTest.kt @@ -0,0 +1,35 @@ +package de.westnordost.streetcomplete.data.osm.geometry + +import de.westnordost.streetcomplete.data.osm.geometry.polygons.Point +import de.westnordost.streetcomplete.data.osm.geometry.polygons.Polygon +import de.westnordost.streetcomplete.data.osm.geometry.polygons.PolygonAlgorithms +import kotlin.test.Test +import kotlin.test.assertTrue + +class PolylabelTest { + + @Test + fun testIrregularPolygon() { + val shape = listOf( + Point(100.0, 100.0), + Point(500.0, 120.0), + Point(480.0, 400.0), + Point(200.0, 450.0), + Point(120.0, 300.0), + Point(100.0, 100.0) + ) + + val polygon = Polygon(shape) + + val result = PolygonAlgorithms.polylabel(polygon, precision = 5.0) + + // We don’t hardcode a value — we test geometric properties. + // 1. It must be inside the bounding box: + assertTrue(result.x in 100.0..500.0) + assertTrue(result.y in 100.0..450.0) + + // 2. It must be inside polygon (distance > 0) + val dist = PolygonAlgorithms.pointToPolygonDist(result, polygon) + assertTrue(dist > 0.0) + } +} diff --git a/app/src/androidInstrumentedTest/kotlin/de/westnordost/streetcomplete/data/osm/geometry/PriorityQueueTest.kt b/app/src/androidInstrumentedTest/kotlin/de/westnordost/streetcomplete/data/osm/geometry/PriorityQueueTest.kt new file mode 100644 index 00000000000..48cea694ada --- /dev/null +++ b/app/src/androidInstrumentedTest/kotlin/de/westnordost/streetcomplete/data/osm/geometry/PriorityQueueTest.kt @@ -0,0 +1,30 @@ +package de.westnordost.streetcomplete.data.osm.geometry + +import PriorityQueue +import de.westnordost.streetcomplete.data.osm.geometry.polygons.Cell +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class PriorityQueueTest { + + @Test + fun testPriorityQueueSortsByCellMaxDescending() { + val q = PriorityQueue() + + // distance = distance to polygon center; half=half size + val c1 = Cell(0.0, 0.0, 1.0, 1.0) // max = 1 + 1.414 + val c2 = Cell(0.0, 0.0, 1.0, 5.0) // max = 5 + 1.414 + val c3 = Cell(0.0, 0.0, 1.0, 3.0) // max = 3 + 1.414 + + q.add(c1) + q.add(c2) + q.add(c3) + + // Should extract in descending max order (c2 > c3 > c1) + assertEquals(c2, q.poll()) + assertEquals(c3, q.poll()) + assertEquals(c1, q.poll()) + assertTrue(q.isEmpty) + } +} diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/geometry/ElementGeometryCreator.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/geometry/ElementGeometryCreator.kt index 1b6be1957f1..70467eba624 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/geometry/ElementGeometryCreator.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/geometry/ElementGeometryCreator.kt @@ -1,5 +1,7 @@ package de.westnordost.streetcomplete.data.osm.geometry +import de.westnordost.streetcomplete.data.osm.geometry.polygons.PolygonAlgorithms +import de.westnordost.streetcomplete.data.osm.geometry.polygons.PolygonUtils import de.westnordost.streetcomplete.data.osm.mapdata.Element import de.westnordost.streetcomplete.data.osm.mapdata.ElementType import de.westnordost.streetcomplete.data.osm.mapdata.LatLon @@ -61,7 +63,13 @@ class ElementGeometryCreator { /* ElementGeometry considers polygons that are defined clockwise holes, so ensure that it is defined CCW here. */ if (polyline.isRingDefinedClockwise()) polyline.reverse() - ElementPolygonsGeometry(arrayListOf(polyline), polyline.centerPointOfPolygon()) + /* Current in progress conversion from centroid to visual center */ + val outer = polyline + val holes = emptyList>() // Way as area has no explicit holes + val poly = PolygonUtils.fromLatLon(outer, holes) + val best = PolygonAlgorithms.polylabel(poly, precision = 0.0001) //Precision will be upgraded later to depend on zoom level + ElementPolygonsGeometry(arrayListOf(polyline), LatLon(best.y, best.x)) + /* Current in progress conversion from centroid to visual center */ } else { ElementPolylinesGeometry(arrayListOf(polyline), polyline.centerPointOfPolyline()) } @@ -97,7 +105,15 @@ class ElementGeometryCreator { /* only use first ring that is not a hole if there are multiple this is the same behavior as Leaflet or Tangram */ - return ElementPolygonsGeometry(rings, outer.first().centerPointOfPolygon()) + val outerRing = outer.first() + + /* Current in progress conversion from centroid to visual center */ + val holes = if (rings.size > 1) rings.drop(1) else emptyList() + val poly = PolygonUtils.fromLatLon(outerRing, holes) + val best = PolygonAlgorithms.polylabel(poly, precision = 0.0001) //Precision will be upgraded later to depend on zoom level + return ElementPolygonsGeometry(rings, LatLon(best.y, best.x)) + /* Current in progress conversion from centroid to visual center */ + } private fun createPolylinesGeometry( diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/geometry/polygons/Cell.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/geometry/polygons/Cell.kt new file mode 100644 index 00000000000..e9a50ec549e --- /dev/null +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/geometry/polygons/Cell.kt @@ -0,0 +1,22 @@ +package de.westnordost.streetcomplete.data.osm.geometry.polygons + +import kotlin.math.sqrt + +data class Cell( + val centerX: Double, + val centerY: Double, + val half: Double, // half of the cell size + val distance: Double, // distance between cell center and polygon. Positive if inside +) : Comparable { + + /* max distance to expect, optimistic bound */ + val max: Double = distance + half * SQRT2 + + /* Looking for the most promising cell */ + override fun compareTo(other: Cell): Int = + this.max.compareTo(other.max) + + companion object { + private val SQRT2 = sqrt(2.0) + } +} diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/geometry/polygons/Point.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/geometry/polygons/Point.kt new file mode 100644 index 00000000000..0cbb653a70d --- /dev/null +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/geometry/polygons/Point.kt @@ -0,0 +1,4 @@ +package de.westnordost.streetcomplete.data.osm.geometry.polygons + +/* Simple 2D point for algorithm logic */ +class Point(val x: Double, val y: Double) diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/geometry/polygons/Polygon.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/geometry/polygons/Polygon.kt new file mode 100644 index 00000000000..3154b075d43 --- /dev/null +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/geometry/polygons/Polygon.kt @@ -0,0 +1,8 @@ +package de.westnordost.streetcomplete.data.osm.geometry.polygons + +/** + * Representation of a polygon with + * a list of points that represents the outer shape + * (optional) a list composed of lists of points that each represents a hole in the polygon + */ +class Polygon (val shape: List, val holes: List> = emptyList()) diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/geometry/polygons/PolygonAlgorithms.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/geometry/polygons/PolygonAlgorithms.kt new file mode 100644 index 00000000000..b943fd57c0f --- /dev/null +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/geometry/polygons/PolygonAlgorithms.kt @@ -0,0 +1,151 @@ +package de.westnordost.streetcomplete.data.osm.geometry.polygons + +import PriorityQueue +import kotlin.math.min +import kotlin.math.sqrt + +/* + Implementation of the polylabel algorithm inspired by mapbox's implementation in java. + See here : https://github.com/mapbox/polylabel + */ +object PolygonAlgorithms { + + /* Simple centroid algorithm */ + fun centroid(polygon: Polygon): Point { + val points = polygon.shape + var sumX = 0.0 + var sumY = 0.0 + for (p in points) { + sumX += p.x + sumY += p.y + } + return Point(sumX / points.size, sumY / points.size) + } + + /* Core of the problem : visual center (within the polygon) */ + /* Set up of the variables */ + fun polylabel(polygon: Polygon, precision: Double = 1.0): Point { + val minX = polygon.shape.minOf { it.x } + val maxX = polygon.shape.maxOf { it.x } + val minY = polygon.shape.minOf { it.y } + val maxY = polygon.shape.maxOf { it.y } + + val width = maxX - minX + val height = maxY - minY + val cellSize = (min(width, height) / 10.0) + val halfCell = cellSize / 2.0 + + val queue = PriorityQueue() + + // Initialize grid, skip cells inside holes + var x = minX + while (x < maxX) { + var y = minY + while (y < maxY) { + val center = Point(x + halfCell, y + halfCell) + if (isPointInRing(center, polygon.shape) && + polygon.holes.none { isPointInRing(center, it) }) { + val distance = pointToPolygonDist(center, polygon) + queue.add(Cell(center.x, center.y, halfCell, distance)) + } + y += cellSize + } + x += cellSize + } + + var best: Cell? = null + + while (queue.isNotEmpty()) { + val cell = queue.poll() + + if (best == null || cell.distance > best.distance) best = cell + + if (cell.max - (best?.distance ?: 0.0) <= precision) continue + + val h = cell.half / 2.0 + val children = listOf( + Point(cell.centerX - h, cell.centerY - h), + Point(cell.centerX + h, cell.centerY - h), + Point(cell.centerX - h, cell.centerY + h), + Point(cell.centerX + h, cell.centerY + h) + ) + + for (c in children) { + if (isPointInRing(c, polygon.shape) && + polygon.holes.none { isPointInRing(c, it) }) { + queue.add(Cell(c.x, c.y, h, pointToPolygonDist(c, polygon))) + } + } + } + + return Point(best!!.centerX, best.centerY) + } + + fun pointToPolygonDist(point: Point, polygon: Polygon): Double { + // Is the point inside the outer shape? + val insideOuter = isPointInRing(point, polygon.shape) + + // Distance to outer shape + var minDistSq = ringDistanceSq(point, polygon.shape) + + // Distance to holes + for (hole in polygon.holes) { + val insideHole = isPointInRing(point, hole) + val distSqHole = ringDistanceSq(point, hole) + + if (insideHole) { + // Inside a hole → consider outside polygon + return -sqrt(distSqHole) + } + + // Outside hole, may be closer than outer + minDistSq = minOf(minDistSq, distSqHole) + } + + return if (insideOuter) sqrt(minDistSq) else -sqrt(minDistSq) + } + + private fun pointToSegmentDistSq(pointToObserve: Point, pointA: Point, pointB: Point): Double { + var x = pointA.x + var y = pointA.y + var dx = pointB.x - x + var dy = pointB.y - y + + if (dx != 0.0 || dy != 0.0) { + val t = ((pointToObserve.x - x) * dx + (pointToObserve.y - y) * dy) / (dx * dx + dy * dy) + when { + t > 1 -> { x = pointB.x; y = pointB.y } + t > 0 -> { x += dx * t; y += dy * t } + } + } + + dx = pointToObserve.x - x + dy = pointToObserve.y - y + return dx * dx + dy * dy + } + + private fun isPointInRing(point: Point, ring: List): Boolean { + var inside = false + for (i in ring.indices) { + val pointA = ring[i] + val pointB = ring[(i + 1) % ring.size] + + val intersects = ((pointA.y > point.y) != (pointB.y > point.y)) && + (point.x < (pointB.x - pointA.x) * (point.y - pointA.y) / (pointB.y - pointA.y) + pointA.x) + + if (intersects) inside = !inside + } + return inside + } + + private fun ringDistanceSq(point: Point, ring: List): Double { + var minDistSq = Double.POSITIVE_INFINITY + for (i in ring.indices) { + val pointA = ring[i] + val pointB = ring[(i + 1) % ring.size] + val distSq = pointToSegmentDistSq(point, pointA, pointB) + if (distSq < minDistSq) minDistSq = distSq + } + return minDistSq + } +} diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/geometry/polygons/PolygonUtils.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/geometry/polygons/PolygonUtils.kt new file mode 100644 index 00000000000..b6d9f45516f --- /dev/null +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/geometry/polygons/PolygonUtils.kt @@ -0,0 +1,21 @@ +package de.westnordost.streetcomplete.data.osm.geometry.polygons + +import de.westnordost.streetcomplete.data.osm.mapdata.LatLon + +/* patch to bridge generic polygon logic to osm map logic */ + +object PolygonUtils { + + /* convert LatLon polygon to generic polygon */ + fun fromLatLon(shape: List, holes: List> = emptyList()): Polygon { + val outerPts = shape.map { Point(it.longitude, it.latitude) } + val holePts = holes.map { ring -> ring.map { Point (it.longitude, it.latitude) } } + return Polygon(outerPts, holePts) + } + + /* Provide a visual center (within the polygon) with the OSM LatLon logic */ + fun representativeCenter(polygon: Polygon): LatLon { + val center = PolygonAlgorithms.polylabel(polygon) + return LatLon(center.x, center.y) + } +} diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/geometry/polygons/PriorityQueue.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/geometry/polygons/PriorityQueue.kt new file mode 100644 index 00000000000..57115971c19 --- /dev/null +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/geometry/polygons/PriorityQueue.kt @@ -0,0 +1,69 @@ +/** + * A simple max-heap priority queue that stores elements implementing Comparable. + * + * poll() returns and removes the largest element. + * add() inserts a new element in O(log n). + * + * Internally this uses a binary heap stored in a MutableList. + */ +class PriorityQueue> { + private val items = mutableListOf() + + val isEmpty: Boolean get() = items.isEmpty() + val size: Int get() = items.size + + fun add(element: T) { + items.add(element) + siftUp(items.lastIndex) + } + + fun poll(): T { + if (items.isEmpty()) throw NoSuchElementException("empty queue") + val root = items[0] + val last = items.removeAt(items.lastIndex) + + if (items.isNotEmpty()) { + items[0] = last + siftDown(0) + } + + return root + } + + fun isNotEmpty(): Boolean = items.size != 0 + + private fun siftUp(index: Int) { + var i = index + while (i > 0) { + val parent = (i - 1) / 2 + if (items[i] <= items[parent]) break + + val tmp = items[i] + items[i] = items[parent] + items[parent] = tmp + + i = parent + } + } + + private fun siftDown(index: Int) { + var i = index + val size = items.size + + while (true) { + val left = 2 * i + 1 + val right = left + 1 + var largest = i + + if (left < size && items[left] > items[largest]) largest = left + if (right < size && items[right] > items[largest]) largest = right + if (largest == i) break + + val tmp = items[i] + items[i] = items[largest] + items[largest] = tmp + + i = largest + } + } +}