From 1e6a374e96d0c29394ab4ca9d40104d92a221bd7 Mon Sep 17 00:00:00 2001 From: soraefir Date: Sun, 6 Sep 2026 19:44:08 +0200 Subject: [PATCH] BMAP handling --- .../java/net/helcel/beans/map/MapReader.kt | 133 ++++++++++++++++++ .../java/net/helcel/beans/map/MapWorld.kt | 86 +++++++++++ 2 files changed, 219 insertions(+) create mode 100644 app/src/main/java/net/helcel/beans/map/MapReader.kt create mode 100644 app/src/main/java/net/helcel/beans/map/MapWorld.kt diff --git a/app/src/main/java/net/helcel/beans/map/MapReader.kt b/app/src/main/java/net/helcel/beans/map/MapReader.kt new file mode 100644 index 0000000..b304d6e --- /dev/null +++ b/app/src/main/java/net/helcel/beans/map/MapReader.kt @@ -0,0 +1,133 @@ +package net.helcel.beans.map + +import android.graphics.Path +import android.graphics.RectF +import java.io.InputStream + +private const val VERSION = 2 + +/** + * Reads the packed map assets produced by `packmap.js`. + * + * The maps used to ship as SVG, which spends around four megabytes per + * projection spelling coordinates out in decimal ASCII inside XML. The packed + * form says the same thing in a fraction of that, and reading it is a straight + * scan over a byte array instead of an XML parse with a float scanner on top. + * + * The format is documented in `packmap.js`; the two have to agree. + */ +object MapReader { + + fun read(input: InputStream): MapWorld { + val reader = ByteReader(input.readBytes()) + reader.readHeader() + val width = reader.unsigned() * reader.scale + val height = reader.unsigned() * reader.scale + val count = reader.unsigned() + val shapes = ArrayList(count) + repeat(count) { shapes.add(reader.shape()) } + return MapWorld(width, height, shapes) + } +} + +private class ByteReader(private val data: ByteArray) { + + private var pos = 0 + + /** One grid step in user units, read from the header. */ + var scale = 1f + private set + + fun readHeader() { + require( + data.size > 5 && + data[0] == 'B'.code.toByte() && data[1] == 'M'.code.toByte() && + data[2] == 'A'.code.toByte() && data[3] == 'P'.code.toByte() + ) { "not a packed map" } + require(data[4].toInt() == VERSION) { "unsupported packed map version ${data[4]}" } + pos = 5 + scale = 1f / unsigned() + } + + fun unsigned(): Int { + var result = 0 + var shift = 0 + while (true) { + val byte = data[pos++].toInt() + result = result or ((byte and 0x7F) shl shift) + if (byte and 0x80 == 0) return result + shift += 7 + } + } + + /** Zigzag, because deltas run both ways and are almost always tiny. */ + fun signed(): Int { + val value = unsigned() + return if (value and 1 == 1) -((value ushr 1) + 1) else value ushr 1 + } + + fun flag(): Boolean = data[pos++].toInt() != 0 + + fun text(): String { + val length = unsigned() + val value = String(data, pos, length, Charsets.UTF_8) + pos += length + return value + } + + fun shape(): MapShape { + val code = text() + val isState = flag() + val ringCount = unsigned() + + val rings = ArrayList(ringCount) + var nonZero: Path? = null + var evenOdd: Path? = null + var minX = Float.MAX_VALUE + var minY = Float.MAX_VALUE + var maxX = -Float.MAX_VALUE + var maxY = -Float.MAX_VALUE + + repeat(ringCount) { + val points = unsigned() + val useEvenOdd = flag() + val ring = FloatArray(points * 2) + // Each point is a delta from the one before it, the first from the + // origin, so the whole ring decodes in one pass. + var x = 0 + var y = 0 + for (i in 0 until points) { + x += signed() + y += signed() + val fx = x * scale + val fy = y * scale + ring[i * 2] = fx + ring[i * 2 + 1] = fy + if (fx < minX) minX = fx + if (fx > maxX) maxX = fx + if (fy < minY) minY = fy + if (fy > maxY) maxY = fy + } + rings.add(ring) + + val path = if (useEvenOdd) { + evenOdd ?: Path().also { it.fillType = Path.FillType.EVEN_ODD; evenOdd = it } + } else { + nonZero ?: Path().also { nonZero = it } + } + path.moveTo(ring[0], ring[1]) + var i = 2 + while (i < ring.size) { + path.lineTo(ring[i], ring[i + 1]) + i += 2 + } + path.close() + } + + return MapShape( + code, isState, rings, + RectF(minX, minY, maxX, maxY), + nonZero, evenOdd, + ) + } +} diff --git a/app/src/main/java/net/helcel/beans/map/MapWorld.kt b/app/src/main/java/net/helcel/beans/map/MapWorld.kt new file mode 100644 index 0000000..4545f69 --- /dev/null +++ b/app/src/main/java/net/helcel/beans/map/MapWorld.kt @@ -0,0 +1,86 @@ +package net.helcel.beans.map + +import android.graphics.Path +import android.graphics.RectF +import kotlin.math.sqrt + +/** + * One named piece of map geometry: a country outline or a single region. + * + * The geometry is kept twice on purpose. [nonZero] and [evenOdd] are what the + * renderer hands to the canvas, while [rings] keeps the raw points because + * hit testing needs the distance to an edge, which a [Path] cannot give us. + */ +class MapShape( + val code: String, + val isState: Boolean, + val rings: List, + val bounds: RectF, + val nonZero: Path?, + val evenOdd: Path?, +) { + + /** True when ([x], [y]) falls inside the shape. Holes and lakes count as outside. */ + fun contains(x: Float, y: Float): Boolean { + if (x < bounds.left || x > bounds.right || y < bounds.top || y > bounds.bottom) return false + // Even-odd crossing count over every ring at once: rings never overlap, so + // an island still reads as inside and a hole still reads as outside. + var inside = false + for (ring in rings) { + var j = ring.size - 2 + var i = 0 + while (i < ring.size) { + val yi = ring[i + 1] + val yj = ring[j + 1] + if ((yi > y) != (yj > y)) { + val xi = ring[i] + val xj = ring[j] + if (x < xi + (y - yi) * (xj - xi) / (yj - yi)) inside = !inside + } + j = i + i += 2 + } + } + return inside + } + + /** Distance from ([x], [y]) to the shape, `0` when the point is inside it. */ + fun distanceTo(x: Float, y: Float): Float { + if (contains(x, y)) return 0f + var best = Float.MAX_VALUE + for (ring in rings) { + var j = ring.size - 2 + var i = 0 + while (i < ring.size) { + val d = segmentDistanceSq(x, y, ring[j], ring[j + 1], ring[i], ring[i + 1]) + if (d < best) best = d + j = i + i += 2 + } + } + return if (best == Float.MAX_VALUE) Float.MAX_VALUE else sqrt(best) + } + + private fun segmentDistanceSq( + px: Float, py: Float, + ax: Float, ay: Float, + bx: Float, by: Float, + ): Float { + val dx = bx - ax + val dy = by - ay + val len = dx * dx + dy * dy + val t = if (len <= 0f) 0f else (((px - ax) * dx + (py - ay) * dy) / len).coerceIn(0f, 1f) + val cx = px - (ax + t * dx) + val cy = py - (ay + t * dy) + return cx * cx + cy * cy + } +} + +/** The whole map, in SVG user units, as a flat set of named shapes. */ +class MapWorld(val width: Float, val height: Float, val shapes: List) { + + val byCode: Map = shapes.associateBy { it.code } + + /** Country outlines, keyed by ISO code. Drawn as borders when regions are on. */ + val countries: List = shapes.filter { !it.isState } +}