diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..f27aa1c --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,110 @@ +name: Test + +# このリポジトリは 3 つの SDK が共有する唯一のレンダラなので、ここが壊れると +# android / ios / web が同時に壊れる。にもかかわらず CI が無かった。 +# +# 2 つ走らせる。片方だけでは足りないため: +# - cargo test : ラスタライザ・ラベル配置・スタイル解釈(Rust 141 本) +# - bindings/js : wasm 越しの表面。Rust が通っても束縛がずれていれば落ちる +# +# wasm は git 管理外(.gitignore: bindings/js/pkg/)なので、JS テストの前に +# wasm-pack でビルドする必要がある。 + +on: + pull_request: + types: [opened, reopened, synchronize, ready_for_review] + branches: + - main + push: + branches: + - main + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + rust: + if: github.event.pull_request.draft == false + runs-on: ubuntu-latest + name: cargo test + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: cargo-${{ runner.os }}-${{ hashFiles('Cargo.lock') }} + restore-keys: cargo-${{ runner.os }}- + + - name: Test + run: cargo test --workspace + + # clippy はまだゲートにしない。現状 17 件の警告があり、初日から赤い CI は + # 読まれなくなる。掃除してから `-D warnings` を足すこと。 + + bindings-js: + if: github.event.pull_request.draft == false + runs-on: ubuntu-latest + name: bindings/js test + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + + - name: Set up Node + uses: actions/setup-node@v4 + with: + # `node --test "test/*.test.mjs"` は Node 21 からグロブを展開する。 + # 20 ではパターンをそのままファイル名として探し、見つからずに落ちる。 + node-version: 22 + + - name: Cache cargo + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + ~/.cargo/bin + target + key: cargo-wasm-${{ runner.os }}-${{ hashFiles('Cargo.lock') }} + restore-keys: cargo-wasm-${{ runner.os }}- + + # cargo で入れる。サードパーティ action も curl | sh も使わない — この + # ワークフローは 3 つの SDK が信頼するバイナリを作る側なので、取り込む + # ものは少ないほどよい。~/.cargo/bin をキャッシュするので 2 回目以降は速い。 + - name: Install wasm-pack + run: command -v wasm-pack || cargo install wasm-pack --locked + + - name: Install + working-directory: bindings/js + run: npm ci + + # `npm run build` は wasm と JS の両方。テストは pkg/ を直接読むので、 + # ここを飛ばすと「モジュールが見つからない」で落ちる。 + - name: Build + working-directory: bindings/js + run: npm run build + + - name: Typecheck + working-directory: bindings/js + run: npm run typecheck + + - name: Test + working-directory: bindings/js + run: npm test diff --git a/Cargo.lock b/Cargo.lock index 13efcce..81de2e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -107,6 +107,12 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "fdeflate" version = "0.3.7" @@ -127,6 +133,22 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "itertools" version = "0.14.0" @@ -223,6 +245,7 @@ name = "mvt-render" version = "0.1.0" dependencies = [ "earcutr", + "mvt-style", "serde", "serde_json", "tiny-skia", @@ -253,6 +276,36 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "mvt-style" +version = "0.1.0" +dependencies = [ + "serde_json", +] + +[[package]] +name = "mvt-style-ffi" +version = "0.1.0" +dependencies = [ + "mvt-style", +] + +[[package]] +name = "mvt-style-jni" +version = "0.1.0" +dependencies = [ + "jni", + "mvt-style", +] + +[[package]] +name = "mvt-style-wasm" +version = "0.1.0" +dependencies = [ + "mvt-style", + "wasm-bindgen", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -350,6 +403,7 @@ version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ + "indexmap", "itoa", "memchr", "serde", diff --git a/Cargo.toml b/Cargo.toml index 0515fe3..b5c6f96 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,10 @@ resolver = "2" members = [ "crates/mvt-render", + "crates/mvt-style", + "crates/mvt-style-jni", + "crates/mvt-style-ffi", + "crates/mvt-style-wasm", "crates/tile-png", "crates/tile-png-jni", "crates/tile-png-ffi", @@ -48,3 +52,10 @@ opt-level = 3 [profile.release.package.miniz_oxide] opt-level = 3 + +# Android renders on the device's critical path. Keep the size-first release +# profile for other consumers, but optimize native Android rendering for speed. +# Pixel 5a measurements and reproduction steps: docs/android-performance.md. +[profile.android-release] +inherits = "release" +opt-level = 3 diff --git a/bindings/android/style/java/com/mapconductor/vectorstyle/NativeStyleCompiler.kt b/bindings/android/style/java/com/mapconductor/vectorstyle/NativeStyleCompiler.kt new file mode 100644 index 0000000..a474160 --- /dev/null +++ b/bindings/android/style/java/com/mapconductor/vectorstyle/NativeStyleCompiler.kt @@ -0,0 +1,44 @@ +package com.mapconductor.vectorstyle + +/** + * The JNI surface of the vector style compiler. + * + * Owned by the `mapconductor-vectortile` repository and copied here by its + * `scripts/sync-sdk.sh`. **Do not edit this file in the SDK**: the next sync + * puts the upstream copy back, silently, and the change goes with it. + * + * Two calls, both JSON in and JSON out. The whole surface is this small + * because the compiler holds no state -- a style and a rule set go in, an + * adjusted style and the deltas come out -- so there is no handle to open or + * free, and no thread the calls have to be made on. + */ +internal object NativeStyleCompiler { + init { + System.loadLibrary("mvt_style_jni") + } + + /** + * The compiled result as JSON: the adjusted style, the mutations, what + * they affect and the diagnostics -- or `{"error": "..."}` when a + * document could not be read. + * + * A document that cannot be read is answered, not thrown: a rule set + * from a server or a style that failed to load is an ordinary thing to + * handle, not an exceptional one. Only a panic becomes an exception. + */ + external fun nativeCompile( + styleJson: String, + rulesJson: String, + ): String + + /** The style's layers and the role each would be given, as a JSON array. */ + external fun nativeDescribe(styleJson: String): String + + /** + * The rules document version this library understands. + * + * Read when the Kotlin side writes a rules document, so the two can + * never disagree about the shape they are exchanging. + */ + external fun nativeSchemaVersion(): Int +} diff --git a/bindings/android/style/jniLibs/arm64-v8a/libmvt_style_jni.so b/bindings/android/style/jniLibs/arm64-v8a/libmvt_style_jni.so new file mode 100755 index 0000000..9517c7b Binary files /dev/null and b/bindings/android/style/jniLibs/arm64-v8a/libmvt_style_jni.so differ diff --git a/bindings/android/style/jniLibs/armeabi-v7a/libmvt_style_jni.so b/bindings/android/style/jniLibs/armeabi-v7a/libmvt_style_jni.so new file mode 100755 index 0000000..33082c8 Binary files /dev/null and b/bindings/android/style/jniLibs/armeabi-v7a/libmvt_style_jni.so differ diff --git a/bindings/android/style/jniLibs/x86_64/libmvt_style_jni.so b/bindings/android/style/jniLibs/x86_64/libmvt_style_jni.so new file mode 100755 index 0000000..b6597e2 Binary files /dev/null and b/bindings/android/style/jniLibs/x86_64/libmvt_style_jni.so differ diff --git a/bindings/android/vectortile/src/main/java/com/mapconductor/vectortile/NativeRenderer.kt b/bindings/android/vectortile/src/main/java/com/mapconductor/vectortile/NativeRenderer.kt index d8e7d93..26c52f4 100644 --- a/bindings/android/vectortile/src/main/java/com/mapconductor/vectortile/NativeRenderer.kt +++ b/bindings/android/vectortile/src/main/java/com/mapconductor/vectortile/NativeRenderer.kt @@ -1,5 +1,11 @@ package com.mapconductor.vectortile +import java.io.Closeable +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.FloatBuffer +import java.util.concurrent.atomic.AtomicBoolean + /** * Thin JNI surface over the Rust renderer. * @@ -12,8 +18,19 @@ internal object NativeRenderer { System.loadLibrary("mvt_render_jni") } - /** Returns an opaque handle; throws IllegalArgumentException on a bad style. */ - external fun nativeNew(styleJson: String): Long + /** + * Returns an opaque handle; throws IllegalArgumentException on a bad style. + * + * [displayTileSize] is how many dp one tile covers where the map shows it + * -- not the pixel count a render is asked for. A label pass draws at + * twice the pixels, and a 256dp tile is still 256dp however many pixels it + * carries. It fixes the size the style draws at and the zoom its + * expressions are read at. + */ + external fun nativeNew( + styleJson: String, + displayTileSize: Int, + ): Long external fun nativeFree(handle: Long) @@ -105,14 +122,31 @@ internal object NativeRenderer { lengths: IntArray, ): Int + /** Draws a transparent label tile into Rust-owned direct memory. */ + external fun nativeRenderLabels( + handle: Long, + z: Int, + x: Int, + y: Int, + tileSize: Int, + data: ByteArray, + lengths: IntArray, + placedOut: IntArray, + ): ByteBuffer? + + external fun nativeFreeLabelPixels(buffer: ByteBuffer) + /** JSON array of layer `type` values the renderer will not draw. */ external fun nativeUnsupportedLayerTypes(handle: Long): String + /** JSON array of the credits the style's sources ask to be shown. */ + external fun nativeAttributions(handle: Long): String + /** JSON array of reasons the style may not render as intended. */ external fun nativeDiagnostics(handle: Long): String /** - * Triangulates a tile for the GPU renderer. Returns one packed array; see + * Triangulates a tile for the GPU renderer. Returns one packed direct buffer; see * [TessellatedTile] for the layout. */ external fun nativeTessellate( @@ -123,14 +157,17 @@ internal object NativeRenderer { tileSize: Int, data: ByteArray, lengths: IntArray, - ): FloatArray + ): ByteBuffer + + /** Releases the native allocation backing [buffer]. */ + external fun nativeFreeTessellation(buffer: ByteBuffer) } /** * A tile triangulated by [NativeRenderer.nativeTessellate]. * - * One packed `float[]` carries everything, so a tile crosses JNI in a single - * call with no object marshalling: + * One packed native buffer carries everything, so a tile crosses JNI without + * copying its vertex data into a Java array: * * ```text * [0] extent @@ -149,13 +186,17 @@ internal object NativeRenderer { * tile. */ internal class TessellatedTile( - packed: FloatArray, -) { + private val storage: ByteBuffer, +) : Closeable { + /** Direct view passed to OpenGL; its storage remains owned by Rust. */ + val packed: FloatBuffer = storage.order(ByteOrder.nativeOrder()).asFloatBuffer() + private val closed = AtomicBoolean(false) val extent: Float = packed[0] val background: FloatArray? = if (packed[1] != 0f) floatArrayOf(packed[2], packed[3], packed[4], packed[5]) else null val batches: List - val vertices: FloatArray + val vertexOffset: Int + val vertexFloatCount: Int /** Milliseconds spent inside the native call, by phase. */ val decodeMs: Float = packed[7] @@ -178,11 +219,25 @@ internal class TessellatedTile( cursor += 2 batch } - vertices = packed.copyOfRange(cursor, packed.size) + vertexOffset = cursor + vertexFloatCount = packed.capacity() - cursor } - val triangleCount: Int get() = vertices.size / (SolidBatchRenderer.VERTEX_STRIDE * 3) + val triangleCount: Int get() = vertexFloatCount / (SolidBatchRenderer.VERTEX_STRIDE * 3) - /** JSON array of reasons the style may not render as intended. */ - external fun nativeDiagnostics(handle: Long): String + override fun close() { + if (closed.compareAndSet(false, true)) NativeRenderer.nativeFreeTessellation(storage) + } +} + +/** Rust-owned premultiplied RGBA for a transparent label tile. */ +internal class NativeLabelPixels( + val buffer: ByteBuffer, + val placed: Int, +) : Closeable { + private val closed = AtomicBoolean(false) + + override fun close() { + if (closed.compareAndSet(false, true)) NativeRenderer.nativeFreeLabelPixels(buffer) + } } diff --git a/bindings/android/vectortile/src/main/java/com/mapconductor/vectortile/VectorTileRenderer.kt b/bindings/android/vectortile/src/main/java/com/mapconductor/vectortile/VectorTileRenderer.kt index 205c124..01eee54 100644 --- a/bindings/android/vectortile/src/main/java/com/mapconductor/vectortile/VectorTileRenderer.kt +++ b/bindings/android/vectortile/src/main/java/com/mapconductor/vectortile/VectorTileRenderer.kt @@ -2,7 +2,11 @@ package com.mapconductor.vectortile import org.json.JSONArray import java.io.Closeable +import java.nio.ByteBuffer import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.locks.ReentrantReadWriteLock +import kotlin.concurrent.read +import kotlin.concurrent.write /** * Renders MapLibre vector styles to raster PNG tiles. @@ -19,6 +23,25 @@ class VectorTileRenderer private constructor( ) : Closeable { private val handle = AtomicLong(handle) + /** + * Keeps [close] from freeing the renderer while a native call is inside it. + * + * Tiles are drawn on the tile server's worker threads while the thread + * that owns the layer decides to drop it -- a provider switch, a page + * leaving, a style change. Freeing the handle then leaves those workers + * holding a pointer into freed memory, and whatever is allocated next + * writes over it: the process died inside `nativeRender` with a fault + * address made of style-layer name bytes, which is what the freed block + * held by the time it was read. + * + * Calls take the read side, so tiles still render in parallel. Only + * [close] takes the write side, and that is what makes it wait for the + * calls already inside the library -- bounded by one tile, since nothing + * here loops. A call that arrives after the free finds the handle cleared + * and throws instead of reading freed memory. + */ + private val liveCalls = ReentrantReadWriteLock() + companion object { const val DEFAULT_TILE_SIZE: Int = 512 @@ -32,19 +55,38 @@ class VectorTileRenderer private constructor( * * 1: fills, lines, circles. * 2: labels. + * 14: drawn for the screen size the host shows a tile at, and read + * from the source level that size belongs to. + * 15: the whole background stack, not its last layer. + * 16: magnified geometry lands inside its tile on the GPU path. + * 17: labels judged against a tile of margin, so neighbours agree. + * 18: glyph pixels align across tile boundaries. + * 19: label bounds include glyph bearings and SDF raster extents. + * 20: that extent decides which tile a label draws in; collisions are + * judged on the layout box again, so labels are as dense as before. */ - const val OUTPUT_VERSION: Int = 13 + const val OUTPUT_VERSION: Int = 23 /** @throws IllegalArgumentException if the style cannot be parsed. */ @JvmStatic - fun create(styleJson: String): VectorTileRenderer = VectorTileRenderer(NativeRenderer.nativeNew(styleJson)) + fun create( + styleJson: String, + displayTileSize: Int, + ): VectorTileRenderer = VectorTileRenderer(NativeRenderer.nativeNew(styleJson, displayTileSize)) } - private fun requireHandle(): Long { - val value = handle.get() - check(value != 0L) { "VectorTileRenderer has been closed" } - return value - } + /** + * Runs one native call with the renderer held alive for its duration. + * + * The handle is read inside the lock. Reading it outside would race + * [close] again, which is the bug this exists to stop. + */ + private inline fun withRenderer(block: (Long) -> T): T = + liveCalls.read { + val value = handle.get() + check(value != 0L) { "VectorTileRenderer has been closed" } + block(value) + } /** * Source tiles needed to draw `z/x/y`, as JSON. Fetch them in order and @@ -54,11 +96,11 @@ class VectorTileRenderer private constructor( z: Int, x: Int, y: Int, - ): String = NativeRenderer.nativePlan(requireHandle(), z, x, y) + ): String = withRenderer { handle -> NativeRenderer.nativePlan(handle, z, x, y) } /** The style's `glyphs` URL template, or null when it has none. */ fun glyphsUrlTemplate(): String? = - NativeRenderer.nativeGlyphsUrlTemplate(requireHandle()).takeIf { it.isNotEmpty() } + withRenderer { handle -> NativeRenderer.nativeGlyphsUrlTemplate(handle) }.takeIf { it.isNotEmpty() } /** * Glyph URLs this tile's labels need and the renderer does not hold. @@ -74,7 +116,7 @@ class VectorTileRenderer private constructor( tiles: List, ): List { val (data, lengths) = pack(tiles) - val urls = JSONArray(NativeRenderer.nativeNeededGlyphs(requireHandle(), z, x, y, data, lengths)) + val urls = JSONArray(withRenderer { handle -> NativeRenderer.nativeNeededGlyphs(handle, z, x, y, data, lengths) }) return (0 until urls.length()).map { urls.getString(it) } } @@ -82,7 +124,7 @@ class VectorTileRenderer private constructor( * Hands one fetched range to the renderer, which keeps it for every later * tile. Returns how many glyphs it gained. */ - fun addGlyphs(pbf: ByteArray): Int = NativeRenderer.nativeAddGlyphs(requireHandle(), pbf) + fun addGlyphs(pbf: ByteArray): Int = withRenderer { handle -> NativeRenderer.nativeAddGlyphs(handle, pbf) } /** * The style's sprite pair — index then image — or an empty list when the @@ -92,13 +134,14 @@ class VectorTileRenderer private constructor( * fetched before a single tile has been drawn. */ fun spriteUrls(pixelRatio: Int = 1): List { - val urls = JSONArray(NativeRenderer.nativeSpriteUrls(requireHandle(), pixelRatio)) + val urls = JSONArray(withRenderer { handle -> NativeRenderer.nativeSpriteUrls(handle, pixelRatio) }) return (0 until urls.length()).map { urls.getString(it) } } /** * Whether a tile at this zoom has to be rasterised on the CPU. * + * Complex visible polygons use the CPU to avoid expensive triangulation. * The GPU path draws flat-coloured triangles; a patterned fill needs an * image repeated across the polygon. Asked of the tile rather than of the * style, because a style that paints prisons with a hatch says nothing @@ -109,11 +152,11 @@ class VectorTileRenderer private constructor( tiles: List, ): Boolean { val (data, lengths) = pack(tiles) - return NativeRenderer.nativeNeedsCpu(requireHandle(), z, data, lengths) != 0 + return withRenderer { handle -> NativeRenderer.nativeNeedsCpu(handle, z, data, lengths) } != 0 } /** Whether the style names a sprite the renderer has not been given. */ - fun needsSprite(): Boolean = NativeRenderer.nativeNeedsSprite(requireHandle()) != 0 + fun needsSprite(): Boolean = withRenderer { handle -> NativeRenderer.nativeNeedsSprite(handle) } != 0 /** * Hands over the fetched pair, and returns how many icons the sheet holds. @@ -126,7 +169,7 @@ class VectorTileRenderer private constructor( fun addSprite( indexJson: String, png: ByteArray, - ): Int = NativeRenderer.nativeAddSprite(requireHandle(), indexJson, png) + ): Int = withRenderer { handle -> NativeRenderer.nativeAddSprite(handle, indexJson, png) } /** * Draws this tile's labels onto pixels something else rasterised. @@ -147,7 +190,24 @@ class VectorTileRenderer private constructor( tiles: List, ): Int { val (data, lengths) = pack(tiles) - return NativeRenderer.nativeDrawLabels(requireHandle(), z, x, y, tileSize, rgba, data, lengths) + return withRenderer { handle -> NativeRenderer.nativeDrawLabels(handle, z, x, y, tileSize, rgba, data, lengths) } + } + + /** Draws a standalone transparent label tile without an RGBA JNI copy. */ + internal fun renderLabels( + z: Int, + x: Int, + y: Int, + tileSize: Int, + tiles: List, + ): NativeLabelPixels? { + val (data, lengths) = pack(tiles) + val placed = IntArray(1) + val pixels = + withRenderer { handle -> NativeRenderer.nativeRenderLabels( + handle, z, x, y, tileSize, data, lengths, placed, + ) } ?: return null + return NativeLabelPixels(pixels, placed[0]) } /** @@ -165,8 +225,8 @@ class VectorTileRenderer private constructor( geometryOnly: Boolean = false, ): ByteArray { val (data, lengths) = pack(tiles) - return NativeRenderer.nativeRender( - requireHandle(), + return withRenderer { handle -> NativeRenderer.nativeRender( + handle, z, x, y, @@ -174,7 +234,7 @@ class VectorTileRenderer private constructor( data, lengths, if (geometryOnly) 1 else 0, - ) + ) } } /** @@ -199,7 +259,7 @@ class VectorTileRenderer private constructor( * unchanged, only the paint applied to it — so recolouring needs no refetch. */ fun setStyle(styleJson: String) { - NativeRenderer.nativeSetStyle(requireHandle(), styleJson) + withRenderer { handle -> NativeRenderer.nativeSetStyle(handle, styleJson) } } /** @@ -213,10 +273,19 @@ class VectorTileRenderer private constructor( tileSize: Int, data: ByteArray, lengths: IntArray, - ): FloatArray = NativeRenderer.nativeTessellate(requireHandle(), z, x, y, tileSize, data, lengths) + ): ByteBuffer = withRenderer { handle -> NativeRenderer.nativeTessellate(handle, z, x, y, tileSize, data, lengths) } /** JSON array of layer `type` values in this style that will not be drawn. */ - fun unsupportedLayerTypes(): String = NativeRenderer.nativeUnsupportedLayerTypes(requireHandle()) + fun unsupportedLayerTypes(): String = withRenderer { handle -> NativeRenderer.nativeUnsupportedLayerTypes(handle) } + + /** + * JSON array of the credits this style's sources ask to be shown. + * + * The host must display these: a style is data under someone's licence, + * and the sample basemap draws OpenStreetMap, whose licence requires the + * credit. May contain HTML — the text is normally a link to the licence. + */ + fun attributions(): String = withRenderer { handle -> NativeRenderer.nativeAttributions(handle) } /** * Reasons the current style may not render as intended, as a JSON array of @@ -226,12 +295,14 @@ class VectorTileRenderer private constructor( * Worth surfacing — the failure mode that matters is a blank tile, and a * style this renderer cannot use should say so. */ - fun diagnostics(): String = NativeRenderer.nativeDiagnostics(requireHandle()) + fun diagnostics(): String = withRenderer { handle -> NativeRenderer.nativeDiagnostics(handle) } /** Releases the native renderer. Safe to call more than once. */ override fun close() { - // getAndSet keeps a double close from freeing the same pointer twice. - val value = handle.getAndSet(0L) - if (value != 0L) NativeRenderer.nativeFree(value) + liveCalls.write { + // getAndSet keeps a double close from freeing the same pointer twice. + val value = handle.getAndSet(0L) + if (value != 0L) NativeRenderer.nativeFree(value) + } } } diff --git a/bindings/ios-style/MvtStyle.xcframework/Info.plist b/bindings/ios-style/MvtStyle.xcframework/Info.plist new file mode 100644 index 0000000..bddbbfb --- /dev/null +++ b/bindings/ios-style/MvtStyle.xcframework/Info.plist @@ -0,0 +1,43 @@ + + + + + AvailableLibraries + + + BinaryPath + libmvt_style_ffi.a + LibraryIdentifier + ios-arm64-simulator + LibraryPath + libmvt_style_ffi.a + SupportedArchitectures + + arm64 + + SupportedPlatform + ios + SupportedPlatformVariant + simulator + + + BinaryPath + libmvt_style_ffi.a + LibraryIdentifier + ios-arm64 + LibraryPath + libmvt_style_ffi.a + SupportedArchitectures + + arm64 + + SupportedPlatform + ios + + + CFBundlePackageType + XFWK + XCFrameworkFormatVersion + 1.0 + + diff --git a/bindings/ios-style/MvtStyle.xcframework/ios-arm64-simulator/libmvt_style_ffi.a b/bindings/ios-style/MvtStyle.xcframework/ios-arm64-simulator/libmvt_style_ffi.a new file mode 100644 index 0000000..65c7845 Binary files /dev/null and b/bindings/ios-style/MvtStyle.xcframework/ios-arm64-simulator/libmvt_style_ffi.a differ diff --git a/bindings/ios-style/MvtStyle.xcframework/ios-arm64/libmvt_style_ffi.a b/bindings/ios-style/MvtStyle.xcframework/ios-arm64/libmvt_style_ffi.a new file mode 100644 index 0000000..206e308 Binary files /dev/null and b/bindings/ios-style/MvtStyle.xcframework/ios-arm64/libmvt_style_ffi.a differ diff --git a/bindings/ios-style/include/mvt_style.h b/bindings/ios-style/include/mvt_style.h new file mode 100644 index 0000000..ba51a8e --- /dev/null +++ b/bindings/ios-style/include/mvt_style.h @@ -0,0 +1,38 @@ +// The vector style compiler for the iOS SDK. Mirrors crates/mvt-style-ffi. +// +// Travels as a source header in ios-vectorstyle rather than inside the +// XCFramework: Xcode merges every linked framework's Headers into one include +// directory, and two frameworks shipping a module.modulemap collide there -- +// which anything depending on both this and MvtRender would hit. +#ifndef MVT_STYLE_H +#define MVT_STYLE_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// Applies a rules document to a style document. +// +// Returns JSON: the adjusted style, the mutations, what they affect and the +// diagnostics -- or {"error": "..."} when either document could not be read. +// NULL only when an argument was not valid UTF-8 or the compiler panicked. +// Release the result with mvt_style_string_free. +char *mvt_style_compile(const char *style_json, const char *rules_json); + +// The style's layers and the role each would be given, as a JSON array, or +// {"error": "..."}. Release with mvt_style_string_free. +char *mvt_style_describe(const char *style_json); + +// The rules document version this library understands. +uint32_t mvt_style_schema_version(void); + +// Releases a string this library returned. NULL is accepted. +void mvt_style_string_free(char *text); + +#ifdef __cplusplus +} +#endif + +#endif // MVT_STYLE_H diff --git a/bindings/ios-style/lib/device/libmvt_style_ffi.a b/bindings/ios-style/lib/device/libmvt_style_ffi.a new file mode 100644 index 0000000..206e308 Binary files /dev/null and b/bindings/ios-style/lib/device/libmvt_style_ffi.a differ diff --git a/bindings/ios-style/lib/sim/libmvt_style_ffi.a b/bindings/ios-style/lib/sim/libmvt_style_ffi.a new file mode 100644 index 0000000..65c7845 Binary files /dev/null and b/bindings/ios-style/lib/sim/libmvt_style_ffi.a differ diff --git a/bindings/ios/Sources/MapConductorVectorTile/MetalTileRasterizer.swift b/bindings/ios/Sources/MapConductorVectorTile/MetalTileRasterizer.swift index fa45148..093f105 100644 --- a/bindings/ios/Sources/MapConductorVectorTile/MetalTileRasterizer.swift +++ b/bindings/ios/Sources/MapConductorVectorTile/MetalTileRasterizer.swift @@ -19,6 +19,7 @@ public final class MetalTileRasterizer { private let commandQueue: MTLCommandQueue private let pipeline: MTLRenderPipelineState private let queue = DispatchQueue(label: "com.mapconductor.vectortile.metal") + private let renderGate = DispatchSemaphore(value: 1) /// Multisample count for the offscreen target. Anti-aliasing comes from /// MSAA rather than per-vertex coverage, which keeps the shader trivial — @@ -120,10 +121,39 @@ public final class MetalTileRasterizer { self.pipeline = try device.makeRenderPipelineState(descriptor: descriptor) } - /// Draws `tile` and encodes the result as PNG, or returns nil if the GPU - /// work fails. Blocks until the tile is done. - public func renderPng(_ tile: TessellatedTile) -> Data? { - queue.sync { drawAndEncode(tile) } + /** + Draws `tile` and encodes the result as PNG, or returns nil if the GPU work + fails. Blocks until the tile is done. + + - Parameter decorate: given the readback in straight-alpha RGBA, in place, + before it is encoded. Used to draw labels, which the tessellator does not + produce: they come from a distance field per glyph and are painted over + the finished ground. A raw buffer rather than an array so the label pass + writes into the readback instead of a copy of it. + */ + public func renderPng( + _ tile: TessellatedTile, + decorate: ((UnsafeMutableRawBufferPointer) -> Void)? = nil + ) -> Data? { + renderPng(tile, isCancelled: { false }, decorate: decorate) + } + + /// Waiting tiles must give their render slot back when the camera moves. + /// Queueing a synchronous block alone cannot cancel that wait. + func renderPng( + _ tile: TessellatedTile, + isCancelled: () -> Bool, + decorate: ((UnsafeMutableRawBufferPointer) -> Void)? = nil + ) -> Data? { + while renderGate.wait(timeout: .now() + .milliseconds(10)) != .success { + if isCancelled() { return nil } + } + defer { renderGate.signal() } + guard !isCancelled() else { return nil } + return queue.sync { + guard !isCancelled() else { return nil } + return drawAndEncode(tile, decorate: decorate) + } } private func targets() throws -> (color: MTLTexture, resolve: MTLTexture) { @@ -157,7 +187,10 @@ public final class MetalTileRasterizer { return (color, resolve) } - private func drawAndEncode(_ tile: TessellatedTile) -> Data? { + private func drawAndEncode( + _ tile: TessellatedTile, + decorate: ((UnsafeMutableRawBufferPointer) -> Void)? = nil + ) -> Data? { guard let (color, resolve) = try? targets() else { return nil } let pass = MTLRenderPassDescriptor() @@ -231,6 +264,7 @@ public final class MetalTileRasterizer { // alpha and there is nothing to un-premultiply. return pixels.withUnsafeMutableBytes { raw in guard let base = raw.baseAddress else { return nil } + decorate?(raw) return TilePngEncoder.encode( rgba: base, width: tileSize, height: tileSize, premultiplied: false ) diff --git a/bindings/ios/Sources/MapConductorVectorTile/VectorTileRenderer.swift b/bindings/ios/Sources/MapConductorVectorTile/VectorTileRenderer.swift index e445a00..a20095e 100644 --- a/bindings/ios/Sources/MapConductorVectorTile/VectorTileRenderer.swift +++ b/bindings/ios/Sources/MapConductorVectorTile/VectorTileRenderer.swift @@ -15,6 +15,56 @@ public enum VectorTileError: Error, CustomStringConvertible { } } +/// Presents `tiles` the way every native entry point takes them: one +/// concatenated buffer plus a length table, `0` marking a tile that failed to +/// fetch. +/// +/// An array-of-arrays would need a marshaller on every platform; this needs +/// none, which is why the JNI, C and wasm surfaces all agree on it. +private func withTiles( + _ tiles: [Data?], + _ body: (UnsafePointer?, Int, UnsafePointer?, Int) -> R +) -> R { + var lengths = [UInt32]() + var payload = Data() + lengths.reserveCapacity(tiles.count) + for tile in tiles { + lengths.append(UInt32(tile?.count ?? 0)) + if let tile { payload.append(tile) } + } + return payload.withUnsafeBytes { payloadBuffer in + lengths.withUnsafeBufferPointer { lengthBuffer in + body( + payloadBuffer.bindMemory(to: UInt8.self).baseAddress, + payload.count, + lengthBuffer.baseAddress, + lengthBuffer.count + ) + } + } +} + +private func takeStrings(_ json: UnsafeMutablePointer?) -> [String] { + guard let json else { return [] } + defer { mvt_string_free(json) } + let text = String(cString: json) + return (try? JSONDecoder().decode([String].self, from: Data(text.utf8))) ?? [] +} + +/// Labels and icons drawn on a transparent ground. +public struct LabelTile { + /// **Premultiplied** RGBA, `tileSize * tileSize * 4` bytes. Empty when + /// nothing was placed. + /// + /// Premultiplied, not straight: encoding these as straight alpha turns + /// every halo grey. + public let pixels: Data + + /// How many labels were placed. Zero means the tile is empty, and the + /// caller should serve one shared transparent image rather than encode it. + public let placed: Int +} + /// Renders MapLibre vector styles to raster PNG tiles. /// /// Rendering is split in two so that **no network I/O happens inside the native @@ -24,12 +74,43 @@ public enum VectorTileError: Error, CustomStringConvertible { public final class VectorTileRenderer { public static let defaultTileSize: UInt32 = 512 + /** + What this renderer draws, as a number that goes up whenever it draws more. + + Rendered tiles are cached on disk across launches, and a cache keyed only + by style and coordinates is keyed by *what the style says*, not by *what + the renderer did with it*. The build that started drawing labels kept + serving the unlabelled PNGs the previous one had cached — correct for the + renderer that made them, wrong for the one asking. + + Kept in step with the Android binding's `OUTPUT_VERSION`: they cache the + output of the same native renderer. + + 18: glyph pixels align across tile boundaries. + 19: label bounds include glyph bearings and SDF raster extents. + 20: that extent decides which tile a label draws in; collisions are + judged on the layout box again, so labels are as dense as before. + 24: Google's finer target grids retain the parent style zoom and paint sizes. + */ + public static let outputVersion = 24 + private var handle: OpaquePointer? - /// - Throws: ``VectorTileError/styleRejected(_:)`` if the style cannot be parsed. - public init(styleJSON: String) throws { + /** + - Parameter displayTileSize: how many points of screen one tile covers + where the host shows it. **Not** the pixel count a render is asked for: + the label pass draws at twice the pixels, and a 256pt tile is still + 256pt however many pixels it carries. It fixes the size the style draws + at and the zoom its expressions are read at, so passing the wrong one + draws every label and every road at the wrong size. 512 is what a + raster source declared at 512 gets. + - Throws: ``VectorTileError/styleRejected(_:)`` if the style cannot be parsed. + */ + public init(styleJSON: String, displayTileSize: Int = 512) throws { var errorPointer: UnsafeMutablePointer? - let created = styleJSON.withCString { mvt_renderer_new($0, &errorPointer) } + let created = styleJSON.withCString { + mvt_renderer_new($0, UInt32(max(1, displayTileSize)), &errorPointer) + } guard let created else { let message = errorPointer.map { pointer -> String in defer { mvt_string_free(pointer) } @@ -40,56 +121,87 @@ public final class VectorTileRenderer { // `MvtRenderer` is an incomplete C struct, so Swift models it as an // OpaquePointer rather than a typed pointer. handle = created + pthread_rwlock_init(&liveCalls, nil) } + /** + Holds the renderer alive for the length of a native call. + + `close()` frees the native renderer, and a call already inside it on + another thread then reads freed memory. That is not hypothetical: the + provider is rebuilt when the map under it changes its tile size, and + ArcGIS's tiles are drawn on a queue of their own, so a render was still + running when the old provider closed — and the app went to the home + screen a second after switching to ArcGIS. android-sdk's binding hit the + same fault (SIGSEGV in `nativeRender`, fault address made of style bytes) + and holds the same lock. + + Calls take the read side, close takes the write side and so waits for + every call in flight. A call that arrives after the close finds the + handle cleared and throws instead of touching freed memory. + */ + private var liveCalls = pthread_rwlock_t() + deinit { close() + pthread_rwlock_destroy(&liveCalls) } - /// Releases the native renderer. Safe to call more than once. + /// Releases the native renderer. Safe to call more than once. Waits for + /// any call in flight; never frees under one. public func close() { + pthread_rwlock_wrlock(&liveCalls) + defer { pthread_rwlock_unlock(&liveCalls) } guard let live = handle else { return } // Clearing first keeps a double close from freeing the same pointer twice. handle = nil mvt_renderer_free(live) } - private func requireHandle() throws -> OpaquePointer { + /// Runs one native call with the renderer held alive for its duration. + private func withRenderer(sourceZoomOffset: Int = 0, _ body: (OpaquePointer) throws -> T) throws -> T { + pthread_rwlock_rdlock(&liveCalls) + defer { pthread_rwlock_unlock(&liveCalls) } guard let live = handle else { throw VectorTileError.closed } - return live + let previous = mvt_set_thread_source_zoom_offset(UInt8(clamping: sourceZoomOffset)) + defer { _ = mvt_set_thread_source_zoom_offset(previous) } + return try body(live) } /// Source tiles needed to draw `z/x/y`, as JSON. Fetch them in order and /// pass the bytes to ``render(z:x:y:tileSize:tiles:)`` positionally. - public func plan(z: UInt8, x: UInt32, y: UInt32) throws -> String { - let live = try requireHandle() - guard let json = mvt_renderer_plan(live, z, x, y) else { return "[]" } - defer { mvt_string_free(json) } - return String(cString: json) + public func plan(z: UInt8, x: UInt32, y: UInt32, sourceZoomOffset: Int = 0) throws -> String { + return try withRenderer(sourceZoomOffset: sourceZoomOffset) { live in + guard let json = mvt_renderer_plan(live, z, x, y) else { return "[]" } + defer { mvt_string_free(json) } + return String(cString: json) + } } /// Replaces the style. Fetched vector tiles stay valid — the geometry is /// unchanged, only the paint applied to it — so recolouring needs no refetch. public func setStyle(_ styleJSON: String) throws { - let live = try requireHandle() - var errorPointer: UnsafeMutablePointer? - let code = styleJSON.withCString { mvt_renderer_set_style(live, $0, &errorPointer) } - guard code == MVT_OK else { - let message = errorPointer.map { pointer -> String in - defer { mvt_string_free(pointer) } - return String(cString: pointer) - } ?? "unknown error" - throw VectorTileError.styleRejected(message) + return try withRenderer { live in + var errorPointer: UnsafeMutablePointer? + let code = styleJSON.withCString { mvt_renderer_set_style(live, $0, &errorPointer) } + guard code == MVT_OK else { + let message = errorPointer.map { pointer -> String in + defer { mvt_string_free(pointer) } + return String(cString: pointer) + } ?? "unknown error" + throw VectorTileError.styleRejected(message) + } } } /// Layer `type` values in the current style that will not be drawn. public func unsupportedLayerTypes() throws -> [String] { - let live = try requireHandle() - guard let json = mvt_renderer_unsupported_layer_types(live) else { return [] } - defer { mvt_string_free(json) } - let text = String(cString: json) - return (try? JSONDecoder().decode([String].self, from: Data(text.utf8))) ?? [] + return try withRenderer { live in + guard let json = mvt_renderer_unsupported_layer_types(live) else { return [] } + defer { mvt_string_free(json) } + let text = String(cString: json) + return (try? JSONDecoder().decode([String].self, from: Data(text.utf8))) ?? [] + } } /// Reasons the current style may not render as intended: unsupported layer @@ -99,11 +211,12 @@ public final class VectorTileRenderer { /// Worth surfacing — the failure mode that matters is a blank tile, and a /// style this renderer cannot use should say so. public func diagnostics() throws -> [String] { - let live = try requireHandle() - guard let json = mvt_renderer_diagnostics(live) else { return [] } - defer { mvt_string_free(json) } - let text = String(cString: json) - return (try? JSONDecoder().decode([String].self, from: Data(text.utf8))) ?? [] + return try withRenderer { live in + guard let json = mvt_renderer_diagnostics(live) else { return [] } + defer { mvt_string_free(json) } + let text = String(cString: json) + return (try? JSONDecoder().decode([String].self, from: Data(text.utf8))) ?? [] + } } /// Rasterises `z/x/y` to PNG bytes. @@ -115,43 +228,30 @@ public final class VectorTileRenderer { x: UInt32, y: UInt32, tileSize: UInt32 = defaultTileSize, - tiles: [Data?] + tiles: [Data?], + sourceZoomOffset: Int = 0 ) throws -> Data { - let live = try requireHandle() - - // The native side takes one concatenated buffer plus a length table, - // which avoids marshalling an array-of-arrays across the ABI. - var lengths = [UInt32]() - var payload = Data() - lengths.reserveCapacity(tiles.count) - for tile in tiles { - lengths.append(UInt32(tile?.count ?? 0)) - if let tile { payload.append(tile) } - } + return try withRenderer(sourceZoomOffset: sourceZoomOffset) { live in - var outPointer: UnsafeMutablePointer? - var outLength = 0 + // The native side takes one concatenated buffer plus a length table, + // which avoids marshalling an array-of-arrays across the ABI. + var outPointer: UnsafeMutablePointer? + var outLength = 0 - let code: Int32 = payload.withUnsafeBytes { payloadBuffer in - lengths.withUnsafeBufferPointer { lengthBuffer in + let code = withTiles(tiles) { data, dataLength, lengths, lengthsCount in mvt_renderer_render( - live, - z, x, y, tileSize, - payloadBuffer.bindMemory(to: UInt8.self).baseAddress, - payload.count, - lengthBuffer.baseAddress, - lengthBuffer.count, - &outPointer, - &outLength + live, z, x, y, tileSize, + data, dataLength, lengths, lengthsCount, + &outPointer, &outLength ) } - } - guard code == MVT_OK, let outPointer else { - throw VectorTileError.renderFailed(code) + guard code == MVT_OK, let outPointer else { + throw VectorTileError.renderFailed(code) + } + defer { mvt_buffer_free(outPointer, outLength) } + return Data(bytes: outPointer, count: outLength) } - defer { mvt_buffer_free(outPointer, outLength) } - return Data(bytes: outPointer, count: outLength) } /// Tessellates `z/x/y` into triangles for GPU drawing. /// @@ -161,43 +261,236 @@ public final class VectorTileRenderer { x: UInt32, y: UInt32, tileSize: UInt32 = defaultTileSize, - tiles: [Data?] + tiles: [Data?], + sourceZoomOffset: Int = 0 ) throws -> TessellatedTile { - let live = try requireHandle() - - var lengths = [UInt32]() - var payload = Data() - lengths.reserveCapacity(tiles.count) - for tile in tiles { - lengths.append(UInt32(tile?.count ?? 0)) - if let tile { payload.append(tile) } - } + return try withRenderer(sourceZoomOffset: sourceZoomOffset) { live in - var outPointer: UnsafeMutablePointer? - var outLength = 0 + var outPointer: UnsafeMutablePointer? + var outLength = 0 - let code: Int32 = payload.withUnsafeBytes { payloadBuffer in - lengths.withUnsafeBufferPointer { lengthBuffer in + let code = withTiles(tiles) { data, dataLength, lengths, lengthsCount in mvt_renderer_tessellate( - live, - z, x, y, tileSize, - payloadBuffer.bindMemory(to: UInt8.self).baseAddress, - payload.count, - lengthBuffer.baseAddress, - lengthBuffer.count, - &outPointer, - &outLength + live, z, x, y, tileSize, + data, dataLength, lengths, lengthsCount, + &outPointer, &outLength + ) + } + + guard code == MVT_OK, let outPointer else { + throw VectorTileError.renderFailed(code) + } + defer { mvt_floats_free(outPointer, outLength) } + return TessellatedTile( + packed: Array(UnsafeBufferPointer(start: outPointer, count: outLength)) + ) + } + } + + // MARK: - Labels and icons + + /// The credits this style's sources ask to be shown. + /// + /// The host must display these: a style is data under someone's licence, + /// and a basemap drawing OpenStreetMap requires the credit. May contain + /// HTML — the text is normally a link to the licence. + public func attributions() throws -> [String] { + takeStrings(try withRenderer { mvt_renderer_attributions($0) }) + } + + /// The style's `glyphs` URL template, or nil when it names none. + public func glyphsURLTemplate() throws -> String? { + return try withRenderer { live in + guard let json = mvt_renderer_glyphs_url_template(live) else { return nil } + defer { mvt_string_free(json) } + return String(cString: json) + } + } + + /// URLs of the glyph ranges this tile's labels need and the store has not + /// got. Fetch them, then feed each back through ``addGlyphs(_:)``. + /// + /// Empty when the style names no template, or everything needed is loaded. + public func neededGlyphs(z: UInt8, x: UInt32, y: UInt32, tiles: [Data?], sourceZoomOffset: Int = 0) throws -> [String] { + return try withRenderer(sourceZoomOffset: sourceZoomOffset) { live in + let json = withTiles(tiles) { data, dataLength, lengths, lengthsCount in + mvt_renderer_needed_glyphs(live, z, x, y, data, dataLength, lengths, lengthsCount) + } + return takeStrings(json) + } + } + + /// Adds one fetched glyph range. Returns how many glyphs it carried. + /// + /// Labels are drawn with whatever is loaded when the tile is drawn, so a + /// range arriving later means redrawing the tiles that wanted it. + @discardableResult + public func addGlyphs(_ pbf: Data) throws -> Int { + return try withRenderer { live in + let count = pbf.withUnsafeBytes { buffer in + mvt_renderer_add_glyphs( + live, buffer.bindMemory(to: UInt8.self).baseAddress, pbf.count + ) + } + guard count >= 0 else { throw VectorTileError.renderFailed(count) } + return Int(count) + } + } + + /// Whether any glyph has been loaded. Labels need at least one. + public func hasGlyphs() throws -> Bool { + try withRenderer { mvt_renderer_has_glyphs($0) == 1 } + } + + /// The sprite sheet's `.json` and `.png` URLs, or nil when the style names + /// no sprite. Fetch both and pass them to ``addSprite(json:png:)``. + public func spriteURLs(pixelRatio: UInt32 = 2) throws -> (json: String, png: String)? { + let urls = takeStrings(try withRenderer { mvt_renderer_sprite_urls($0, pixelRatio) }) + guard urls.count == 2 else { return nil } + return (json: urls[0], png: urls[1]) + } + + /// Adds the fetched sprite sheet. Returns how many icons it carried. + @discardableResult + public func addSprite(json: String, png: Data) throws -> Int { + return try withRenderer { live in + let count = json.withCString { index in + png.withUnsafeBytes { buffer in + mvt_renderer_add_sprite( + live, index, buffer.bindMemory(to: UInt8.self).baseAddress, png.count + ) + } + } + guard count >= 0 else { throw VectorTileError.renderFailed(count) } + return Int(count) + } + } + + /// Whether the style names a sprite the renderer has not been given yet. + public func needsSprite() throws -> Bool { + try withRenderer { mvt_renderer_needs_sprite($0) == 1 } + } + + /// Whether this tile must be drawn on the CPU because the style paints + /// a pattern the GPU cannot draw or a polygon that is too costly to triangulate. + public func needsCPU(z: UInt8, tiles: [Data?], sourceZoomOffset: Int = 0) throws -> Bool { + return try withRenderer(sourceZoomOffset: sourceZoomOffset) { live in + return withTiles(tiles) { data, dataLength, lengths, lengthsCount in + mvt_renderer_needs_cpu(live, z, data, dataLength, lengths, lengthsCount) + } == 1 + } + } + + /// Rasterises the ground alone — fills, lines and circles, no labels or + /// icons — to PNG bytes. + /// + /// The half of a split layer that a GPU draws and that fonts arriving never + /// invalidate. Serve this and ``renderLabels(z:x:y:tileSize:tiles:)`` as two + /// stacked raster layers and the map shows one; the halves are drawn in + /// parallel, and a font landing redraws only the transparent one. + public func renderGeometry( + z: UInt8, + x: UInt32, + y: UInt32, + tileSize: UInt32 = defaultTileSize, + tiles: [Data?], + sourceZoomOffset: Int = 0 + ) throws -> Data { + return try withRenderer(sourceZoomOffset: sourceZoomOffset) { live in + var outPointer: UnsafeMutablePointer? + var outLength = 0 + + let code = withTiles(tiles) { data, dataLength, lengths, lengthsCount in + mvt_renderer_render_geometry( + live, z, x, y, tileSize, + data, dataLength, lengths, lengthsCount, + &outPointer, &outLength ) } + guard code == MVT_OK, let outPointer else { + throw VectorTileError.renderFailed(code) + } + defer { mvt_buffer_free(outPointer, outLength) } + return Data(bytes: outPointer, count: outLength) + } + } + + /// Draws the labels and icons alone, on a transparent ground. + /// + /// The other half of a split layer. A ``LabelTile`` whose `placed` is zero + /// carries no pixels — water and fields are common, and one shared + /// transparent image serves them all. + public func renderLabels( + z: UInt8, + x: UInt32, + y: UInt32, + tileSize: UInt32 = defaultTileSize, + tiles: [Data?], + sourceZoomOffset: Int = 0 + ) throws -> LabelTile { + return try withRenderer(sourceZoomOffset: sourceZoomOffset) { live in + var outPointer: UnsafeMutablePointer? + var outLength = 0 + var placed = 0 + + let code = withTiles(tiles) { data, dataLength, lengths, lengthsCount in + mvt_renderer_render_labels( + live, z, x, y, tileSize, + data, dataLength, lengths, lengthsCount, + &outPointer, &outLength, &placed + ) + } + guard code == MVT_OK else { throw VectorTileError.renderFailed(code) } + guard let outPointer else { return LabelTile(pixels: Data(), placed: placed) } + defer { mvt_buffer_free(outPointer, outLength) } + return LabelTile(pixels: Data(bytes: outPointer, count: outLength), placed: placed) + } + } + + /// Draws labels and icons over pixels the caller already has — a GPU + /// readback, typically — in place. Returns how many labels were placed. + /// + /// `rgba` must hold exactly `tileSize * tileSize * 4` bytes and is read as + /// **straight** alpha, which is what a Metal readback is. + @discardableResult + public func drawLabels( + z: UInt8, + x: UInt32, + y: UInt32, + tileSize: UInt32 = defaultTileSize, + rgba: inout Data, + tiles: [Data?], + sourceZoomOffset: Int = 0 + ) throws -> Int { + try rgba.withUnsafeMutableBytes { pixels in + try drawLabels(z: z, x: x, y: y, tileSize: tileSize, rgba: pixels, tiles: tiles, sourceZoomOffset: sourceZoomOffset) } + } - guard code == MVT_OK, let outPointer else { - throw VectorTileError.renderFailed(code) + /// The same, painting straight into a buffer the caller already holds — a + /// GPU readback, which is the whole reason this overload exists. Copying a + /// tile in and out to add labels costs more than drawing them. + @discardableResult + public func drawLabels( + z: UInt8, + x: UInt32, + y: UInt32, + tileSize: UInt32 = defaultTileSize, + rgba: UnsafeMutableRawBufferPointer, + tiles: [Data?], + sourceZoomOffset: Int = 0 + ) throws -> Int { + return try withRenderer(sourceZoomOffset: sourceZoomOffset) { live in + let placed = withTiles(tiles) { data, dataLength, lengths, lengthsCount in + mvt_renderer_draw_labels( + live, z, x, y, tileSize, + rgba.bindMemory(to: UInt8.self).baseAddress, rgba.count, + data, dataLength, lengths, lengthsCount + ) + } + guard placed >= 0 else { throw VectorTileError.renderFailed(placed) } + return Int(placed) } - defer { mvt_floats_free(outPointer, outLength) } - return TessellatedTile( - packed: Array(UnsafeBufferPointer(start: outPointer, count: outLength)) - ) } diff --git a/bindings/ios/Tests/MapConductorVectorTileTests/LabelBindingTests.swift b/bindings/ios/Tests/MapConductorVectorTileTests/LabelBindingTests.swift new file mode 100644 index 0000000..2a0d9f5 --- /dev/null +++ b/bindings/ios/Tests/MapConductorVectorTileTests/LabelBindingTests.swift @@ -0,0 +1,198 @@ +import XCTest + +@testable import MapConductorVectorTile + +/// Covers the half of the C ABI that iOS had none of: glyphs, sprites, the +/// split ground/label layers, and the credits a style's sources ask for. +/// +/// Runs against the bundled MapLibre demo style and a real vector tile, so it +/// needs no network. The glyph range is a generated fixture carrying a solid +/// square per printable ASCII codepoint — enough for the renderer to place and +/// draw the demo style's country labels. +final class LabelBindingTests: XCTestCase { + private var styleJSON: String! + private var tileData: Data! + private var glyphData: Data! + private var renderer: VectorTileRenderer! + + override func setUpWithError() throws { + let bundle = Bundle.module + let styleURL = try XCTUnwrap(bundle.url(forResource: "demo-style", withExtension: "json")) + let tileURL = try XCTUnwrap(bundle.url(forResource: "tile-0-0-0", withExtension: "pbf")) + let glyphURL = try XCTUnwrap(bundle.url(forResource: "glyphs-0-255", withExtension: "pbf")) + styleJSON = try String(contentsOf: styleURL, encoding: .utf8) + tileData = try Data(contentsOf: tileURL) + glyphData = try Data(contentsOf: glyphURL) + renderer = try VectorTileRenderer(styleJSON: styleJSON) + } + + override func tearDown() { + renderer?.close() + renderer = nil + } + + /// The tile these tests draw. + /// + /// Not z0: the demo style gives `geolines-label` a minzoom of 1 and + /// `countries-label` a minzoom of 2, so at z0 no symbol layer applies and + /// there is correctly nothing to label. z2 has both, plus the ring of + /// neighbours the label pass asks for. + private let target = (z: UInt8(2), x: UInt32(1), y: UInt32(1)) + + /// One entry per plan request, the same bundled tile answering each. + private func tiles() throws -> [Data?] { + let plan = try JSONSerialization.jsonObject( + with: Data(try renderer.plan(z: target.z, x: target.x, y: target.y).utf8) + ) as? [Any] ?? [] + return Array(repeating: tileData, count: plan.count) + } + + // MARK: - Glyphs + + func testReportsTheStylesGlyphTemplate() throws { + XCTAssertEqual( + try renderer.glyphsURLTemplate(), + "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf" + ) + } + + /// The tile's labels name a fontstack, so the renderer must ask for the + /// ranges covering the characters in them. + func testAsksForTheGlyphRangesItsLabelsNeed() throws { + let needed = try renderer.neededGlyphs(z: target.z, x: target.x, y: target.y, tiles: tiles()) + XCTAssertFalse(needed.isEmpty, "the demo style labels countries") + let first = try XCTUnwrap(needed.first) + XCTAssertTrue(first.contains("Open%20Sans%20Semibold") || first.contains("Open Sans Semibold")) + XCTAssertTrue(first.hasSuffix(".pbf")) + } + + /// Adding a range must be visible to the renderer, and must stop it asking + /// for that range again. + func testTakesAGlyphRangeAndStopsAskingForIt() throws { + XCTAssertFalse(try renderer.hasGlyphs()) + XCTAssertEqual(try renderer.addGlyphs(glyphData), 95) + XCTAssertTrue(try renderer.hasGlyphs()) + + let stillNeeded = try renderer.neededGlyphs(z: target.z, x: target.x, y: target.y, tiles: tiles()) + XCTAssertFalse( + stillNeeded.contains { $0.contains("0-255") }, + "0-255 is loaded; asking again would refetch it forever" + ) + } + + /// A malformed range is a bad download, not a reason to take the app down. + func testRejectsGarbageInsteadOfCrashing() { + XCTAssertThrowsError(try renderer.addGlyphs(Data([0xff, 0xff, 0xff, 0xff]))) + } + + // MARK: - Sprites + + /// The demo style names no sprite, so there is nothing to fetch and the + /// renderer must not claim otherwise. + func testAsksForNoSpriteWhenTheStyleNamesNone() throws { + XCTAssertNil(try renderer.spriteURLs()) + XCTAssertFalse(try renderer.needsSprite()) + } + + func testDerivesBothSpriteURLsFromTheStyle() throws { + let styled = try VectorTileRenderer( + styleJSON: """ + {"version": 8, "sprite": "https://example.com/sprite", + "sources": {}, "layers": []} + """ + ) + defer { styled.close() } + let urls = try XCTUnwrap(try styled.spriteURLs(pixelRatio: 2)) + XCTAssertEqual(urls.json, "https://example.com/sprite@2x.json") + XCTAssertEqual(urls.png, "https://example.com/sprite@2x.png") + } + + // MARK: - The split layer + + /// Geometry is the half a GPU draws and fonts never invalidate: it must not + /// change when glyphs arrive. + func testGeometryIsUnchangedByGlyphsArriving() throws { + let before = try renderer.renderGeometry(z: target.z, x: target.x, y: target.y, tiles: tiles()) + XCTAssertEqual(Array(before.prefix(4)), [0x89, 0x50, 0x4E, 0x47], "not a PNG") + + try renderer.addGlyphs(glyphData) + let after = try renderer.renderGeometry(z: target.z, x: target.x, y: target.y, tiles: tiles()) + XCTAssertEqual(before, after, "geometry must not depend on fonts") + } + + /// Without glyphs there is nothing to draw, and the caller is told so + /// rather than handed a megabyte of transparent pixels to encode. + func testAnEmptyLabelTileCarriesNoPixels() throws { + let labels = try renderer.renderLabels(z: target.z, x: target.x, y: target.y, tileSize: 256, tiles: tiles()) + XCTAssertEqual(labels.placed, 0) + XCTAssertTrue(labels.pixels.isEmpty) + } + + /// With glyphs the labels place, and the buffer is exactly one tile of + /// premultiplied RGBA. + func testDrawsLabelsOnceItsGlyphsAreIn() throws { + try renderer.addGlyphs(glyphData) + let size: UInt32 = 256 + let labels = try renderer.renderLabels(z: target.z, x: target.x, y: target.y, tileSize: size, tiles: tiles()) + XCTAssertGreaterThan(labels.placed, 0, "the demo style labels countries") + XCTAssertEqual(labels.pixels.count, Int(size) * Int(size) * 4) + + // Premultiplied: no pixel may carry more colour than alpha. + for index in stride(from: 0, to: labels.pixels.count, by: 4) { + let alpha = labels.pixels[index + 3] + XCTAssertLessThanOrEqual(labels.pixels[index], alpha, "colour exceeds alpha at \(index)") + } + XCTAssertTrue(labels.pixels.contains { $0 != 0 }, "the tile is blank") + } + + /// The other label entry point paints over pixels the caller already has, + /// which is what a Metal readback gives. + func testDrawsLabelsOverAReadback() throws { + try renderer.addGlyphs(glyphData) + let size = 256 + var readback = Data(repeating: 0, count: size * size * 4) + for index in stride(from: 0, to: readback.count, by: 4) { + readback[index] = 255 + readback[index + 3] = 255 + } + let before = readback + + let placed = try renderer.drawLabels( + z: target.z, x: target.x, y: target.y, tileSize: UInt32(size), rgba: &readback, tiles: tiles() + ) + XCTAssertGreaterThan(placed, 0) + XCTAssertNotEqual(before, readback, "nothing was drawn onto the readback") + } + + // MARK: - Credits + + /// A style is data under someone's licence; the credit has to reach Swift + /// with its HTML intact, because it is a link to that licence. + func testCarriesTheCreditOutOfTheStyle() throws { + let credited = try VectorTileRenderer( + styleJSON: """ + {"version": 8, + "sources": {"osm": {"type": "vector", + "tiles": ["https://example.com/{z}/{x}/{y}.pbf"], + "attribution": "© OSM"}}, + "layers": [{"id": "w", "type": "fill", "source": "osm", "source-layer": "water"}]} + """ + ) + defer { credited.close() } + XCTAssertEqual( + try credited.attributions(), + ["© OSM"] + ) + } + + func testInventsNoCreditWhenTheStyleAsksForNone() throws { + XCTAssertTrue(try renderer.attributions().isEmpty) + } + + // MARK: - Rasteriser choice + + /// This style paints no pattern, so the GPU path is usable. + func testSaysTheGpuPathCanDrawThisTile() throws { + XCTAssertFalse(try renderer.needsCPU(z: target.z, tiles: tiles())) + } +} diff --git a/bindings/ios/Tests/MapConductorVectorTileTests/Resources/glyphs-0-255.pbf b/bindings/ios/Tests/MapConductorVectorTileTests/Resources/glyphs-0-255.pbf new file mode 100644 index 0000000..45b2cc3 Binary files /dev/null and b/bindings/ios/Tests/MapConductorVectorTileTests/Resources/glyphs-0-255.pbf differ diff --git a/bindings/ios/Tests/MapConductorVectorTileTests/VectorTileRendererTests.swift b/bindings/ios/Tests/MapConductorVectorTileTests/VectorTileRendererTests.swift index bc20710..643e89e 100644 --- a/bindings/ios/Tests/MapConductorVectorTileTests/VectorTileRendererTests.swift +++ b/bindings/ios/Tests/MapConductorVectorTileTests/VectorTileRendererTests.swift @@ -135,8 +135,22 @@ final class VectorTileRendererTests: XCTestCase { return buffer } + /// Every type the demo style uses is drawn now, `symbol` included -- this + /// asserted the opposite while labels were still missing. + func testReportsNothingUndrawnForAStyleItCanDraw() throws { + XCTAssertEqual(try renderer.unsupportedLayerTypes(), []) + } + + /// A type nothing here draws still has to be named, because the failure + /// mode is a tile that is quietly missing something. func testReportsUndrawnLayerTypes() throws { - XCTAssertTrue(try renderer.unsupportedLayerTypes().contains("symbol")) + let hillshaded = try VectorTileRenderer( + styleJSON: """ + {"version": 8, "sources": {}, "layers": [{"id": "hills", "type": "hillshade"}]} + """ + ) + defer { hillshaded.close() } + XCTAssertEqual(try hillshaded.unsupportedLayerTypes(), ["hillshade"]) } func testDiagnosesAMapboxStandardStyleInsteadOfRenderingBlank() throws { diff --git a/bindings/ios/include/mvt_render.h b/bindings/ios/include/mvt_render.h index 4b5f357..dfa1e20 100644 --- a/bindings/ios/include/mvt_render.h +++ b/bindings/ios/include/mvt_render.h @@ -29,8 +29,18 @@ typedef struct MvtRenderer MvtRenderer; #define MVT_ERR_RENDER_FAILED (-3) #define MVT_ERR_PANIC (-4) +// Scope to one native call on the current thread; restore the returned value. +uint8_t mvt_set_thread_source_zoom_offset(uint8_t offset); + /* Returns NULL on failure; *error_out then holds an owned message. */ -MvtRenderer *mvt_renderer_new(const char *style_json, char **error_out); +/// `display_tile_size` is how many points one tile covers where the host shows +/// it -- not the pixel count a render is asked for (a label pass draws at twice +/// the pixels, and a 256pt tile is still 256pt however many pixels it carries). +/// It sets the size the style draws at and the zoom its expressions are read +/// at. Pass 0 for the usual 512. +MvtRenderer *mvt_renderer_new(const char *style_json, + uint32_t display_tile_size, + char **error_out); void mvt_renderer_free(MvtRenderer *handle); @@ -67,6 +77,135 @@ int mvt_renderer_render(MvtRenderer *handle, uint8_t **out_ptr, size_t *out_len); +/* JSON array of the credits the style's sources ask to be shown. + * + * The host must display these: a style is data under someone's licence. The + * text may contain HTML, because a credit is normally a link to the licence. */ +char *mvt_renderer_attributions(MvtRenderer *handle); + +/* The style's `glyphs` URL template, or NULL when it names none. */ +char *mvt_renderer_glyphs_url_template(MvtRenderer *handle); + +/* JSON array of glyph-range URLs this tile's labels need and the store has not + * got. Empty when the style names no template, or all of them are loaded. + * Tiles are passed exactly as for mvt_renderer_render. */ +char *mvt_renderer_needed_glyphs(MvtRenderer *handle, + uint8_t z, + uint32_t x, + uint32_t y, + const uint8_t *data, + size_t data_len, + const uint32_t *lengths, + size_t lengths_len); + +/* Features in the tile as a JSON array of objects {sourceId, layer, id, + * geometry, lon, lat, bounds:[w,s,e,n], properties:{...}}. Only the tile's + * own requests are read; a magnified ancestor reports its quadrant. Restrict + * with a source-layer name, a MapLibre filter expression (JSON) and/or a + * case-insensitive substring some string property must contain; each may be + * NULL. limit 0 = no limit. NULL when the filter is not JSON. Tiles are + * passed exactly as for mvt_renderer_render. Free with mvt_string_free. */ +char *mvt_renderer_query_features(MvtRenderer *handle, + uint8_t z, + uint32_t x, + uint32_t y, + const uint8_t *data, + size_t data_len, + const uint32_t *lengths, + size_t lengths_len, + const char *source_layer, + const char *filter_json, + const char *text, + uint32_t limit); + +/* Adds one fetched glyph range PBF. Returns the glyph count, or < 0. */ +int mvt_renderer_add_glyphs(MvtRenderer *handle, const uint8_t *pbf, size_t pbf_len); + +/* 1 when any glyph is loaded, 0 when none, < 0 on failure. */ +int mvt_renderer_has_glyphs(MvtRenderer *handle); + +/* JSON array of the sprite sheet's [json, png] URLs at this pixel ratio, or [] + * when the style names no sprite. */ +char *mvt_renderer_sprite_urls(MvtRenderer *handle, uint32_t pixel_ratio); + +/* Adds the fetched sprite sheet. Returns the icon count, or < 0. */ +int mvt_renderer_add_sprite(MvtRenderer *handle, + const char *json, + const uint8_t *png, + size_t png_len); + +/* 1 when the style names a sprite the renderer has not been given yet. */ +int mvt_renderer_needs_sprite(MvtRenderer *handle); + +/* 1 when this tile must be drawn on the CPU because the style paints something + * the GPU path cannot -- today, a patterned fill. */ +int mvt_renderer_needs_cpu(MvtRenderer *handle, + uint8_t z, + const uint8_t *data, + size_t data_len, + const uint32_t *lengths, + size_t lengths_len); + +/* + * Rasterises the ground alone -- fills, lines and circles, no labels or icons. + * + * The half of a split layer that a GPU draws and that fonts arriving never + * invalidate. Output and ownership match mvt_renderer_render. + */ +int mvt_renderer_render_geometry(MvtRenderer *handle, + uint8_t z, + uint32_t x, + uint32_t y, + uint32_t tile_size, + const uint8_t *data, + size_t data_len, + const uint32_t *lengths, + size_t lengths_len, + uint8_t **out_ptr, + size_t *out_len); + +/* + * Draws labels and icons alone on a transparent ground, into a new + * PREMULTIPLIED RGBA buffer of tile_size * tile_size * 4 bytes. + * + * The other half of a split layer: redrawn on its own when a font arrives, + * leaving the ground untouched. *out_placed receives the label count; when it + * is 0 nothing is allocated, *out_ptr stays NULL, and the caller should serve + * one shared transparent tile. Encoding these pixels as straight alpha turns + * every halo grey. Release with mvt_buffer_free. + */ +int mvt_renderer_render_labels(MvtRenderer *handle, + uint8_t z, + uint32_t x, + uint32_t y, + uint32_t tile_size, + const uint8_t *data, + size_t data_len, + const uint32_t *lengths, + size_t lengths_len, + uint8_t **out_ptr, + size_t *out_len, + size_t *out_placed); + +/* + * Draws labels and icons over pixels the caller already has, in place. + * + * `rgba` must hold exactly tile_size * tile_size * 4 bytes and is read as + * STRAIGHT alpha, which is what a GPU readback is. Returns the label count, + * or < 0. + */ +int mvt_renderer_draw_labels(MvtRenderer *handle, + uint8_t z, + uint32_t x, + uint32_t y, + uint32_t tile_size, + uint8_t *rgba, + size_t rgba_len, + const uint8_t *data, + size_t data_len, + const uint32_t *lengths, + size_t lengths_len); + /// Tessellates a tile into a packed float buffer for GPU drawing. /// /// Layout, in floats: diff --git a/bindings/js-style/package.json b/bindings/js-style/package.json new file mode 100644 index 0000000..cc16ff2 --- /dev/null +++ b/bindings/js-style/package.json @@ -0,0 +1,33 @@ +{ + "name": "@mapconductor/vectorstyle", + "version": "0.1.0", + "description": "Declarative adjustments to a MapLibre style, applied the same way on every renderer", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./wasm": "./pkg/mvt_style_wasm_bg.wasm" + }, + "files": [ + "dist", + "pkg" + ], + "scripts": { + "build:wasm": "wasm-pack build ../../crates/mvt-style-wasm --target web --out-dir ../../bindings/js-style/pkg --release", + "build": "npm run build:wasm && npm run build:js", + "build:js": "tsup src/index.ts --format esm --dts --external ../pkg/mvt_style_wasm.js", + "typecheck": "tsc --noEmit", + "clean": "rm -rf dist pkg", + "test": "node --test \"test/*.test.mjs\"" + }, + "license": "Apache-2.0", + "devDependencies": { + "tsup": "^8.5.1", + "typescript": "^5.9.3" + } +} diff --git a/bindings/js-style/src/index.ts b/bindings/js-style/src/index.ts new file mode 100644 index 0000000..eff886b --- /dev/null +++ b/bindings/js-style/src/index.ts @@ -0,0 +1,176 @@ +/** + * Declarative adjustments to a MapLibre style. + * + * A map in the MapConductor SDKs is drawn one of two ways: by a renderer + * that takes a vector style directly (MapLibre, Mapbox, MapTiler), or by + * rasterising the style to PNG tiles and handing those to a backend that + * cannot read a style at all. Both start from the same `style.json`, so + * "adjust the style" belongs before that fork. This package is that step. + * + * Its own package, not a corner of `@mapconductor/vectortile`: an app using + * MapLibre in a browser can adjust a style without fetching the rasteriser, + * and the numbers say that is worth a package — 190 KB of wasm here against + * 705 KB there. + * + * Framework- and core-neutral, like its sibling. The mutations come back as + * the JSON text the renderer bindings produced; the React layer hands that + * to `parseStyleMutations` from `@mapconductor/js-sdk-core`, and a plain + * script tag can read it however it likes. + */ + +/** + * Where the `.wasm` comes from. + * + * A URL in a browser; the bytes themselves anywhere `fetch` cannot read a + * local file, which is every Node test run and every bundler that inlines + * the asset. + */ +export type WasmSource = string | URL | BufferSource; + +interface WasmModule { + default: (wasmUrl?: WasmSource | { module_or_path: WasmSource }) => Promise; + compileStyle: (styleJson: string, rulesJson: string) => string; + describeStyle: (styleJson: string) => string; + rulesSchemaVersion: () => number; +} + +let modulePromise: Promise | null = null; + +/** + * Loads and initialises the wasm module. Repeat calls share one instance. + * + * @param wasmUrl Overrides where the `.wasm` binary is fetched from. Needed + * when the asset is served from a CDN or a non-default base path. + */ +export function loadStyleWasm(wasmUrl?: WasmSource): Promise { + if (!modulePromise) { + modulePromise = (async () => { + // `pkg/` is generated by wasm-pack; the cast pins the narrow + // surface this package relies on. + const mod = (await import('../pkg/mvt_style_wasm.js')) as unknown as WasmModule; + // The object form rather than the bare value: wasm-bindgen + // deprecated passing bytes positionally and warns on every call. + await mod.default(wasmUrl === undefined ? undefined : { module_or_path: wasmUrl }); + return mod; + })().catch((error: unknown) => { + // Let a later call retry rather than caching the failure forever. + modulePromise = null; + throw error; + }); + } + return modulePromise; +} + +/** Which half of a split raster layer a change reaches. */ +export type StyleAffects = 'none' | 'ground' | 'labels' | 'both'; + +export interface CompiledStyle { + /** The adjusted document, to hand to a renderer or to rasterise. */ + readonly style: unknown; + /** + * The per-layer deltas, as the JSON text the compiler produced. + * + * Text rather than parsed values: the shape is defined by + * `StyleMutation` in `@mapconductor/js-sdk-core`, and this package does + * not depend on core -- the same build has to serve React, a plain + * script tag, and anything else. + */ + readonly mutationsJson: string; + /** + * Which half of a split raster layer has to be redrawn. A rule that only + * recolours text need not invalidate every tile on screen. + */ + readonly affects: StyleAffects; + /** + * False when the mutations alone cannot reproduce the document, so a + * live map has to be given the document instead of being patched. + */ + readonly patchable: boolean; + /** + * What the app should know: a rule that matched nothing, a change the + * rasteriser will not draw, an expression it cannot evaluate. + */ + readonly diagnostics: readonly string[]; +} + +/** One layer of a style, and the role the compiler would give it. */ +export interface StyleLayerInfo { + readonly id: string; + readonly kind: string; + readonly sourceLayer: string | null; + readonly roles: readonly string[]; + /** How each role was decided, in the same order as `roles`. */ + readonly evidence: readonly string[]; +} + +export class VectorStyleError extends Error {} + +/** + * Applies a rules document to a style document. + * + * @param style the `style.json`, as text or as the parsed object + * @param rules the rules document, as text or as the parsed object + * @throws VectorStyleError when either document cannot be read + */ +export async function compileStyle( + style: string | object, + rules: string | object, + options: { wasmUrl?: WasmSource } = {}, +): Promise { + const wasm = await loadStyleWasm(options.wasmUrl); + const parsed = readResult(wasm.compileStyle(asText(style), asText(rules))); + return { + style: parsed.style, + mutationsJson: JSON.stringify(parsed.mutations ?? []), + affects: (parsed.affects as StyleAffects) ?? 'none', + patchable: parsed.patchable !== false, + diagnostics: (parsed.diagnostics as string[]) ?? [], + }; +} + +/** + * Every layer in the style, with the role this would give it. + * + * Rules are written against someone else's style, so being able to ask what + * is in it -- and on what evidence a layer counts as a road -- is how the + * rules get written in the first place. + */ +export async function describeStyle( + style: string | object, + options: { wasmUrl?: WasmSource } = {}, +): Promise { + const wasm = await loadStyleWasm(options.wasmUrl); + const parsed: unknown = JSON.parse(wasm.describeStyle(asText(style))); + if (!Array.isArray(parsed)) { + throw new VectorStyleError(errorIn(parsed) ?? 'the style could not be read'); + } + return parsed as StyleLayerInfo[]; +} + +/** + * The rules document version this build understands. + * + * Worth asking on the web and nowhere else: this package and the one that + * writes the rules have their own versions, so an app can end up with a + * newer wrapper over an older wasm. + */ +export async function rulesSchemaVersion(options: { wasmUrl?: WasmSource } = {}): Promise { + return (await loadStyleWasm(options.wasmUrl)).rulesSchemaVersion(); +} + +function asText(document: string | object): string { + return typeof document === 'string' ? document : JSON.stringify(document); +} + +function readResult(text: string): Record { + const parsed: unknown = JSON.parse(text); + const message = errorIn(parsed); + if (message !== undefined) throw new VectorStyleError(message); + return parsed as Record; +} + +function errorIn(parsed: unknown): string | undefined { + if (typeof parsed !== 'object' || parsed === null) return 'the compiler answered with nothing'; + const error = (parsed as { error?: unknown }).error; + return typeof error === 'string' ? error : undefined; +} diff --git a/bindings/js-style/test/compile.test.mjs b/bindings/js-style/test/compile.test.mjs new file mode 100644 index 0000000..6d3d307 --- /dev/null +++ b/bindings/js-style/test/compile.test.mjs @@ -0,0 +1,100 @@ +/** + * The wasm surface, against the same style and rules the Rust golden tests + * use. What is being checked is not the compiler -- that has its own tests + * in Rust -- but that the binding carries the answer across unchanged. + */ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { before, describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { compileStyle, describeStyle, loadStyleWasm, rulesSchemaVersion, VectorStyleError } from '../dist/index.js'; + +const here = new URL('./', import.meta.url); +const fixture = (name) => + readFile(fileURLToPath(new URL(`../../../crates/mvt-style/tests/fixtures/${name}`, here)), 'utf8'); + +const ROAD_EMPHASIS = { + schemaVersion: 1, + rules: [ + { selector: 'all', patch: { color: '#000000' } }, + { selector: { role: 'label' }, patch: { visible: false } }, + { selector: { role: 'road-casing' }, patch: { color: '#303030' } }, + { selector: { role: 'road' }, patch: { color: '#ffffff', widthScale: 1.3 } }, + ], +}; + +describe('@mapconductor/vectorstyle', () => { + // Node cannot `fetch` a local file, which is how the browser build finds + // its binary, so the bytes are handed over directly. + before(async () => { + await loadStyleWasm( + await readFile(fileURLToPath(new URL('../pkg/mvt_style_wasm_bg.wasm', here))), + ); + }); + + it('adjusts a real style and says what it did', async () => { + const out = await compileStyle(await fixture('versatiles-colorful.json'), ROAD_EMPHASIS); + + assert.equal(out.affects, 'both'); + assert.equal(out.patchable, true); + // The same counts the Rust golden records, reached through wasm. + assert.ok( + out.diagnostics.some((d) => d.includes('schema: shortbread')), + out.diagnostics.join('\n'), + ); + assert.ok(out.diagnostics.some((d) => d.includes('rule 3 matched 77 layers'))); + + const mutations = JSON.parse(out.mutationsJson); + assert.ok(mutations.length > 400, `only ${mutations.length} mutations`); + // Every mutation carries what was there before, or nothing could be + // taken back. + for (const mutation of mutations.slice(0, 50)) { + assert.ok('previous' in mutation || mutation.op === 'addLayer', JSON.stringify(mutation)); + } + + const layers = out.style.layers; + assert.equal(layers.length, 280, 'no layer was added or lost'); + }); + + it('leaves alone what no rule named', async () => { + const text = await fixture('versatiles-colorful.json'); + const before = JSON.parse(text); + const out = await compileStyle(text, { + schemaVersion: 1, + rules: [{ selector: { role: 'water' }, patch: { color: '#000' } }], + }); + assert.deepEqual(out.style.sources, before.sources); + assert.deepEqual(out.style.metadata, before.metadata); + }); + + it('describes what is in a style', async () => { + const layers = await describeStyle(await fixture('versatiles-colorful.json')); + assert.equal(layers.length, 280); + const casing = layers.find((l) => l.id.endsWith(':outline') && l.roles.includes('road')); + assert.ok(casing, 'no road casing found'); + assert.deepEqual(casing.roles, ['road', 'road-casing']); + assert.equal(casing.evidence[0], 'shortbread:streets'); + }); + + // A rule set written for a newer build must be refused loudly, not + // applied in part -- this is the one failure the web can actually hit, + // because the wrapper and the wasm are versioned separately. + it('refuses a rules document it does not understand', async () => { + await assert.rejects( + () => compileStyle('{"version":8,"sources":{},"layers":[]}', { schemaVersion: 99, rules: [] }), + (error) => error instanceof VectorStyleError && /schemaVersion/.test(error.message), + ); + }); + + it('refuses a style it cannot read', async () => { + await assert.rejects( + () => compileStyle('{ not json', { schemaVersion: 1, rules: [] }), + VectorStyleError, + ); + }); + + it('reports the rules version it was built with', async () => { + assert.equal(await rulesSchemaVersion(), 1); + }); +}); diff --git a/bindings/js-style/tsconfig.json b/bindings/js-style/tsconfig.json new file mode 100644 index 0000000..01ca6a9 --- /dev/null +++ b/bindings/js-style/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM"], + "strict": true, + "declaration": true, + "skipLibCheck": true, + "noUncheckedIndexedAccess": true, + "esModuleInterop": true, + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/bindings/js/package.json b/bindings/js/package.json index 19972f8..b4b7d47 100644 --- a/bindings/js/package.json +++ b/bindings/js/package.json @@ -26,11 +26,12 @@ "build": "npm run build:wasm && npm run build:js", "typecheck": "tsc --noEmit", "clean": "rm -rf dist pkg", - "build:js": "tsup src/index.ts src/worker.ts --format esm --dts --external ../pkg/mvt_render_wasm.js" + "build:js": "tsup src/index.ts src/worker.ts --format esm --dts --external ../pkg/mvt_render_wasm.js", + "test": "node --test \"test/*.test.mjs\"" }, "license": "Apache-2.0", "devDependencies": { "tsup": "^8.5.1", "typescript": "^5.9.3" } -} \ No newline at end of file +} diff --git a/bindings/js/src/RenderedTileCache.ts b/bindings/js/src/RenderedTileCache.ts new file mode 100644 index 0000000..2bf49ca --- /dev/null +++ b/bindings/js/src/RenderedTileCache.ts @@ -0,0 +1,97 @@ +/** + * Rendered PNG tiles kept across page loads, in the browser's Cache Storage. + * + * This does not make the first view of an area faster — nothing can, the tiles + * have to be fetched and rasterised once. What it removes is paying that cost + * *again* on the next visit. Within one session the map already caches raster + * tiles itself, so this is specifically about surviving a reload. + * + * Entries are content-addressed, so a restyle simply misses rather than needing + * explicit invalidation. + * + * Absent outside a secure context and in Node, where `caches` is undefined. + * {@link RenderedTileCache.open} answers null there and every caller treats a + * missing cache as a miss, so nothing else has to know. + */ +export class RenderedTileCache { + private constructor(private readonly store: Cache) {} + + /** Null where Cache Storage is unavailable — Node, or an insecure origin. */ + static async open(name = 'mapconductor-vectortile'): Promise { + if (typeof caches === 'undefined') return null; + try { + return new RenderedTileCache(await caches.open(name)); + } catch { + // A private window can refuse to open a cache. A map that renders + // everything fresh is slower, not broken. + return null; + } + } + + /** + * Cache Storage is keyed by Request, so a key has to look like a URL. + * + * The scheme is deliberately not http: nothing should ever try to fetch + * one of these, and a relative key would resolve against whatever page + * happened to be open. + */ + private static url(key: string): string { + return `https://vectortile.invalid/${key}`; + } + + async get(key: string): Promise { + try { + const hit = await this.store.match(RenderedTileCache.url(key)); + if (!hit) return null; + const bytes = new Uint8Array(await hit.arrayBuffer()); + return bytes.byteLength > 0 ? bytes : null; + } catch { + return null; + } + } + + async put(key: string, bytes: Uint8Array): Promise { + try { + // A copy, because the caller's array may be a view onto wasm memory + // that the next render overwrites. + const body = new Uint8Array(bytes); + await this.store.put( + RenderedTileCache.url(key), + new Response(body, { headers: { 'Content-Type': 'image/png' } }), + ); + } catch { + // Storage quota, mostly. Not being able to remember a tile is not + // a reason to fail the tile. + } + } + + async clear(): Promise { + try { + const keys = await this.store.keys(); + await Promise.all(keys.map((request) => this.store.delete(request))); + } catch { + // Nothing useful to do if the store will not talk to us. + } + } +} + +/** + * A stable short hash, used for cache keys. + * + * FNV-1a rather than a cryptographic digest: these are cache keys, not + * signatures, and the whole point is to be cheap. The same construction as the + * Android and iOS bindings use, so the three describe a tile the same way. + */ +export function digest(...parts: string[]): string { + let hash = 0xcbf2_9ce4; + for (const part of parts) { + for (let i = 0; i < part.length; i += 1) { + hash ^= part.charCodeAt(i); + hash = Math.imul(hash, 0x0100_0193); + } + // Separator, so ('ab', 'c') and ('a', 'bc') differ. + hash ^= 0xff; + hash = Math.imul(hash, 0x0100_0193); + } + return (hash >>> 0).toString(16).padStart(8, '0'); +} diff --git a/bindings/js/src/SourceTileCache.ts b/bindings/js/src/SourceTileCache.ts new file mode 100644 index 0000000..ee087e1 --- /dev/null +++ b/bindings/js/src/SourceTileCache.ts @@ -0,0 +1,75 @@ +import { digest } from './RenderedTileCache'; + +/** + * Fetched source tiles kept in Cache Storage, so a reload or a remount does + * not pay the network round trip again. + * + * The sibling of {@link RenderedTileCache}: that one keeps the PNGs this + * library draws, this one keeps the `.pbf` it drew them from. Keeping both is + * not redundant — a restyle throws away every rendered tile and keeps every + * source tile, which is what makes recolouring a map cost no network at all. + * + * Cache Storage rather than IndexedDB because the payload is already an HTTP + * response body: no serialisation, and the browser evicts it under the same + * quota rules as anything else it holds for the origin. + * + * Every method swallows its failures and answers "not cached". A disabled + * storage API, a private window or a full quota must slow the map down, never + * break it. + */ +export class SourceTileCache { + private constructor(private readonly store: Cache) {} + + /** + * Opens the store, or returns null where Cache Storage is unavailable — + * a non-secure origin, a private window, or a worker in a browser that + * does not expose it. + */ + static async open(name = 'mapconductor-vectortile-sources'): Promise { + if (typeof caches === 'undefined') return null; + try { + return new SourceTileCache(await caches.open(name)); + } catch { + return null; + } + } + + /** + * Cache Storage keys on a URL, and a source tile's own URL would do — + * except that it can carry an API key, and keys do not belong in a store + * that outlives the session. The digest is stable for the same tile and + * says nothing about the token that fetched it. + */ + private static url(key: string): string { + return `https://vectortile-source.invalid/${digest(key)}`; + } + + async get(key: string): Promise { + try { + const hit = await this.store.match(SourceTileCache.url(key)); + if (!hit) return null; + const bytes = new Uint8Array(await hit.arrayBuffer()); + // A zero-length body is what a "no tile here" answer was stored + // as; treat it as a miss rather than as an empty tile. + return bytes.byteLength > 0 ? bytes : null; + } catch { + return null; + } + } + + async put(key: string, bytes: Uint8Array): Promise { + try { + // Copied, because the caller's view may be over a buffer the wasm + // side reuses, and `Response` reads it asynchronously. + const body = new Uint8Array(bytes); + await this.store.put( + SourceTileCache.url(key), + new Response(body, { + headers: { 'Content-Type': 'application/vnd.mapbox-vector-tile' }, + }), + ); + } catch { + // Over quota, or the store went away. The tile is still drawn. + } + } +} diff --git a/bindings/js/src/TileCache.ts b/bindings/js/src/TileCache.ts index ae93759..127ec25 100644 --- a/bindings/js/src/TileCache.ts +++ b/bindings/js/src/TileCache.ts @@ -1,3 +1,27 @@ +import type { SourceTileCache } from './SourceTileCache'; + +/** What one tile's worth of fetching had to do. For the debug log. */ +export interface SourceFetchStats { + memoryHits: number; + diskHits: number; + networkFetches: number; + sharedWaits: number; + cancelled: number; + queueWaitMs: number; +} + +/** A fresh set of counters, all zero. */ +export function sourceFetchStats(): SourceFetchStats { + return { + memoryHits: 0, + diskHits: 0, + networkFetches: 0, + sharedWaits: 0, + cancelled: 0, + queueWaitMs: 0, + }; +} + /** * LRU cache for fetched source tiles, with single-flight de-duplication. * @@ -20,6 +44,16 @@ export class TileCache { */ constructor(private readonly maxBytes: number = 16 * 1024 * 1024) {} + /** + * Where tiles are kept between sessions, when the caller asked for that. + * + * Behind the single-flight gate rather than in front of it: a cold memory + * cache has the whole ring of neighbours asking for the same file at once, + * and nine Cache Storage reads of one tile is as wasteful as nine + * downloads of it. + */ + persistent: SourceTileCache | null = null; + /** * Returns the bytes for `url`, fetching via `fetcher` on a miss. A `null` * result (404, empty body) is cached too, so a known-missing tile is not @@ -27,10 +61,13 @@ export class TileCache { */ get( url: string, - fetcher: (url: string) => Promise, + fetcher: (url: string, signal?: AbortSignal) => Promise, + signal?: AbortSignal, + stats?: SourceFetchStats, ): Promise { if (this.entries.has(url)) { const cached = this.entries.get(url) ?? null; + if (stats) stats.memoryHits += 1; // Re-insert to mark as most recently used. this.entries.delete(url); this.entries.set(url, cached); @@ -38,13 +75,31 @@ export class TileCache { } const pending = this.inFlight.get(url); - if (pending) return pending; + if (pending) { + if (stats) stats.sharedWaits += 1; + return pending; + } - const request = fetcher(url) - .catch(() => null) - .then((bytes) => { + const request = (async (): Promise<{ bytes: Uint8Array | null; stored: boolean }> => { + const stored = await this.persistent?.get(url); + if (stored) { + if (stats) stats.diskHits += 1; + return { bytes: stored, stored: true }; + } + if (stats) stats.networkFetches += 1; + return { bytes: await fetcher(url, signal), stored: false }; + })() + .catch((error: unknown) => { + if ((error as { name?: string } | null)?.name === 'AbortError' && stats) { + stats.cancelled += 1; + } + return { bytes: null, stored: false }; + }) + .then(({ bytes, stored }) => { this.inFlight.delete(url); this.set(url, bytes); + // Nothing to write back for a tile that came from the store. + if (bytes && !stored) void this.persistent?.put(url, bytes); return bytes; }); this.inFlight.set(url, request); diff --git a/bindings/js/src/TileRenderCore.ts b/bindings/js/src/TileRenderCore.ts index e478562..ad91fc8 100644 --- a/bindings/js/src/TileRenderCore.ts +++ b/bindings/js/src/TileRenderCore.ts @@ -1,5 +1,7 @@ import { loadWasm, type WasmRenderer } from './wasm'; -import { TileCache } from './TileCache'; +import { TileCache, sourceFetchStats, type SourceFetchStats } from './TileCache'; +import { SourceTileCache } from './SourceTileCache'; +import { RenderedTileCache, digest } from './RenderedTileCache'; export interface TileRequest { readonly x: number; @@ -10,9 +12,45 @@ export interface TileRequest { export interface TileRenderCoreOptions { /** A `style.json` URL, its raw text, or the parsed object. */ style: string | object; + /** + * How many pixels a rendered tile has on each edge. Defaults to 512. + * + * On a screen with more device pixels than CSS pixels this is the larger + * number: the map is handed a sharper image for the same ground. What + * that ground is stays {@link displayTileSize}'s job. + */ tileSize?: number; + /** + * The CSS px one tile covers on screen, when that is not the same as + * {@link tileSize}. + * + * Style dimensions are screen units, so the rasteriser needs both: the + * pixel count to fill, and the width those pixels are shown at. They are + * the same number on a 1x screen and differ by the device pixel ratio on + * a Retina one -- and getting it wrong is not subtle. Declare 1024 pixels + * without saying they cover 512 CSS px and every label and road comes out + * half size, with the style read one zoom too deep. + * + * Defaults to {@link tileSize}, which is exactly today's behaviour. + */ + displayTileSize?: number; /** Source tile cache budget in bytes. Defaults to 16 MB. */ cacheBytes?: number; + /** + * Keep rendered tiles in Cache Storage so a reload does not redraw them. + * Off by default: a library helping itself to a user's disk quota behind + * their back is not a favour. + */ + persistRenderedTiles?: boolean; + /** + * Keep fetched source tiles in Cache Storage so reloads and remounts do + * not pay the network round trip again. Off by default, for the same + * reason as {@link persistRenderedTiles}. + * + * Worth having on top of that one: a restyle discards every rendered tile + * and keeps every source tile, so a recolour costs no network at all. + */ + persistSourceTiles?: boolean; wasmUrl?: string | URL; /** * Headers sent with every source tile request — auth tokens, API keys. @@ -23,7 +61,7 @@ export interface TileRenderCoreOptions { * Full control over source tile fetching. Not cloneable, so supplying this * forces main-thread rendering; use `headers` if that is all you need. */ - fetchTile?: (url: string) => Promise; + fetchTile?: (url: string, signal?: AbortSignal) => Promise; onWarning?: (message: string) => void; } @@ -36,14 +74,55 @@ interface PlanEntry { scale: number; offsetX: number; offsetY: number; + /** A neighbour only the label pass reads, so a name at the tile edge can + * be drawn whole. */ + labelsOnly?: boolean; +} + +/** + * Whether the host asked for the per-tile log, read fresh each time. + * + * Off, every call has to cost nothing: these fire per tile per content half, + * so the message itself is built behind a closure rather than passed in. + * Reading the flag is wrapped because `localStorage` throws outright in a + * private window. + */ +function debugging(): boolean { + try { + const global = globalThis as { MAPCONDUCTOR_DEBUG_LOG?: string; localStorage?: Storage }; + return ( + global.MAPCONDUCTOR_DEBUG_LOG === '1' || + global.localStorage?.getItem('MAPCONDUCTOR_DEBUG_LOG') === '1' + ); + } catch { + return false; + } +} + +function debug(message: () => string): void { + if (!debugging()) return; + try { + console.debug(`[MapConductor][VectorTile] ${message()}`); + } catch { + // A log must never be the thing that loses a tile. + } +} + +function describe(stats: SourceFetchStats): string { + return ( + `mvt(mem=${stats.memoryHits} disk=${stats.diskHits} net=${stats.networkFetches} ` + + `shared=${stats.sharedWaits} cancel=${stats.cancelled})` + ); } function makeFetcher( headers?: Record, -): (url: string) => Promise { +): (url: string, signal?: AbortSignal) => Promise { const init = headers && Object.keys(headers).length > 0 ? { headers } : undefined; - return async (url) => { - const response = await fetch(url, init); + return async (url, signal) => { + // The signal is what lets a pan abandon the tiles it no longer wants + // before they reach the renderer. + const response = await fetch(url, init ? { ...init, signal } : { signal }); // A missing tile is normal at the edge of a source's coverage. if (response.status === 404 || response.status === 204) return null; if (!response.ok) throw new Error(`tile fetch failed: ${response.status} ${url}`); @@ -71,11 +150,68 @@ async function resolveStyleText(style: string | object): Promise { * runs on the main thread and inside a Web Worker. Only the transport around it * differs. */ +/** What a request should draw. */ +export type TileContent = + /** Ground and labels in one tile: the single-layer behaviour. */ + | 'full' + /** Fills, lines and circles. No labels, no icons, no glyph dependency. */ + | 'ground' + /** Labels and icons on a transparent ground. */ + | 'labels'; + export class TileRenderCore { private readonly cache: TileCache; - private readonly fetchTile: (url: string) => Promise; + private readonly fetchTile: (url: string, signal?: AbortSignal) => Promise; private disposed = false; + /** Glyph ranges somebody has already claimed, so two tiles do not both + * fetch the same one. */ + private readonly claimed = new Set(); + private glyphsInFlight = 0; + + /** Bumped when glyphs arrive, so tiles drawn before them stop being + * served. Goes in the tile URL the host hands the map. */ + private generation = 0; + + /** + * Whether any tile has been drawn short of glyphs since the last handover. + * + * Most glyph arrivals change nothing: the ranges landed before anything + * needed them. Handing over anyway makes the map refetch and redraw the + * whole viewport to arrive at the same pixels. + */ + private provisional = false; + private notifyTimer: ReturnType | null = null; + + /** Rendered PNGs across page loads. Null when Cache Storage is absent. */ + private rendered: RenderedTileCache | null = null; + + /** Identifies the style these tiles were drawn from, so a restyle simply + * misses rather than needing anything deleted. */ + private styleKey = ''; + + /** + * What this renderer draws, as a number that goes up whenever it draws + * more. + * + * A cache keyed only by style and coordinates is keyed by *what the style + * says*, not by *what the renderer did with it*. Kept in step with the + * Android and iOS bindings: they cache the output of the same core. + */ + static readonly outputVersion = 22; + + /** + * Told when glyphs arrive and the tiles already on screen are missing + * labels because of it. The host must make the map refetch — nothing else + * will replace them. + */ + onGlyphsLoaded: (() => void) | null = null; + + /** How long to let a burst of ranges settle before telling the host. */ + private readonly notifyQuietMs = 400; + /** How long to keep holding that window open while ranges are in flight. */ + private readonly notifyMaxWaitMs = 10_000; + private constructor( private readonly renderer: WasmRenderer, readonly tileSize: number, @@ -91,8 +227,19 @@ export class TileRenderCore { resolveStyleText(options.style), ]); - const renderer = new wasm.VectorTileRenderer(styleText); - const core = new TileRenderCore(renderer, options.tileSize ?? 512, options); + // The renderer needs the CSS px a tile covers on screen, which is the + // declared tile size -- not the pixel count a render asks for (the + // label pass draws at twice the pixels). It sets the size the style + // draws at and the zoom its expressions are read at. + const tileSize = options.tileSize ?? 512; + const renderer = new wasm.VectorTileRenderer( + styleText, + options.displayTileSize ?? tileSize, + ); + const core = new TileRenderCore(renderer, tileSize, options); + core.styleKey = digest(styleText); + if (options.persistRenderedTiles) core.rendered = await RenderedTileCache.open(); + if (options.persistSourceTiles) core.cache.persistent = await SourceTileCache.open(); // Undrawn layer types are reported by the caller via // `unsupportedLayerTypes()`, not warned about here — otherwise the // worker path would surface the same message twice. @@ -107,6 +254,8 @@ export class TileRenderCore { */ async setStyle(styleText: string, onWarning?: (message: string) => void): Promise { this.renderer.setStyle(styleText); + // Rendered tiles are keyed by style, so the old ones stop being found. + this.styleKey = digest(styleText); // The new style may name sources the old one did not, or reuse ones // whose TileJSON was already resolved; resolving again is idempotent. await this.resolveTileJson(onWarning); @@ -162,19 +311,53 @@ export class TileRenderCore { ); } - /** Renders one tile to PNG bytes. Returns null once disposed. */ - async renderTile(request: TileRequest): Promise { + /** + * Fetches everything the plan asks for and packs it the way wasm takes it: + * one concatenated buffer plus a length table, `0` for a tile that failed + * to fetch. + * + * An array-of-arrays would need a marshaller on every platform; this needs + * none, which is why the wasm, JNI and C surfaces all agree on the shape. + * Returns null once disposed. + */ + private async packTiles( + request: TileRequest, + content: TileContent = 'full', + signal?: AbortSignal, + stats?: SourceFetchStats, + ): Promise<{ data: Uint8Array; lengths: Uint32Array } | null> { if (this.disposed) return null; const { z, x, y } = request; const plan = JSON.parse(this.renderer.plan(z, x, y)) as PlanEntry[]; - const fetched = await Promise.all( - plan.map((entry) => this.cache.get(entry.url, this.fetchTile)), + const fetched: (Uint8Array | null)[] = new Array(plan.length).fill(null); + // The tile's own data is asked for before the ring of neighbours it + // only needs to judge labels against. Both sets are wanted, but a + // browser gives a handful of connections per origin, and with the + // plan's own order the neighbours of the first tile queue ahead of the + // second tile's own data -- so the screen fills a tile at a time + // instead of all at once. The result array keeps the plan's order + // regardless; the wasm side reads it positionally. + const ordered = plan + .map((entry, index) => ({ entry, index })) + .sort( + (a, b) => + (a.entry.labelsOnly ? plan.length : 0) + + a.index - + ((b.entry.labelsOnly ? plan.length : 0) + b.index), + ); + await Promise.all( + ordered.map(async ({ entry, index }) => { + // The ground never draws a label, so the ring of neighbours the + // label pass asks for is nine fetches it has no use for. The + // entry stays in place -- the wasm side reads the plan + // positionally -- but holds nothing. + if (content === 'ground' && entry.labelsOnly) return; + fetched[index] = await this.cache.get(entry.url, this.fetchTile, signal, stats); + }), ); if (this.disposed) return null; - // The wasm boundary takes one concatenated buffer plus a length table, - // which avoids marshalling an array-of-arrays on any platform. const lengths = new Uint32Array(fetched.length); let total = 0; fetched.forEach((bytes, i) => { @@ -190,13 +373,362 @@ export class TileRenderCore { data.set(bytes, offset); offset += bytes.byteLength; } + return { data, lengths }; + } + + /** The generation to put in a tile URL, so tiles drawn short of their + * glyphs stop being served once the glyphs land. */ + get glyphGeneration(): number { + return this.generation; + } + + /** + * Renders one tile to PNG bytes, warming its glyphs first. + * + * The tile is drawn with whatever glyphs are already in, exactly as + * MapLibre does it: waiting for the ranges means a tile at low zoom draws + * nothing for many seconds. The ones that arrive later bring their labels + * with them through {@link onGlyphsLoaded}. + */ + async renderTile( + request: TileRequest, + content: TileContent = 'full', + signal?: AbortSignal, + ): Promise { + if (this.disposed || signal?.aborted) return null; + const { z, x, y } = request; + + /* + Two keys, because a rendered tile goes stale for one reason only: it + was drawn while some of its glyphs were still on the way. A tile that + had them all is finished, and no later arrival can change it, so it is + stored under a key with no generation in it and survives every + handover. Only the ones drawn short are tied to the generation they + were drawn at, and only they are redrawn. + */ + const keyFor = (generation: string) => + digest( + this.styleKey, + String(this.tileSize), + `v${TileRenderCore.outputVersion}`, + generation, + `${z}/${x}/${y}`, + ); + // The ground has no glyphs to be short of; one key, forever. + const completeKey = keyFor(content === 'ground' ? 'ground' : `${content}-complete`); + const provisionalKey = + content === 'ground' ? null : keyFor(`${content}-g${this.generation}`); + if (this.rendered) { + for (const candidate of [completeKey, provisionalKey]) { + if (!candidate) continue; + const hit = await this.rendered.get(candidate); + if (hit) return hit; + } + } + + const stats = sourceFetchStats(); + const fetchStarted = performance.now(); + const packed = await this.packTiles(request, content, signal, stats); + const fetchMs = Math.round(performance.now() - fetchStarted); + if (!packed || signal?.aborted) { + debug(() => `tile ${z}/${x}/${y} ${content} cancelled after ${fetchMs}ms ${describe(stats)}`); + return null; + } + + // The ground has no labels, so it has no glyphs to be short of. + let short = false; + if (content !== 'ground') { + short = this.claimGlyphs(request, packed); + if (short) this.provisional = true; + } + // The last chance to bail: past here the work is inside wasm and cannot + // be interrupted, and it is the part that takes the time. + if (this.disposed || signal?.aborted) return null; + + const renderStarted = performance.now(); + let png: Uint8Array | null; + if (content === 'labels') { + const resolution = this.tileSize * TileRenderCore.labelResolution; + const tile = this.renderer.renderLabelsPng( + z, x, y, resolution, packed.data, packed.lengths, + ); + try { + // A tile with nothing on it is common -- water, fields -- and + // an empty result says so rather than costing an encode. + // + // Copied, not handed out: every caller of this method owns what + // it gets back, because the worker transfers it. Returning the + // shared constant detaches it on the first empty tile and every + // one after that fails to post. + png = + tile.placed > 0 + ? tile.takePixels() + : TileRenderCore.emptyTile.slice(); + } finally { + // wasm-bindgen objects are not garbage collected; one leaked + // per tile would be a leak per tile. + tile.free(); + } + } else if (content === 'ground') { + png = this.renderer.renderGeometry( + z, x, y, this.tileSize, packed.data, packed.lengths, + ); + } else { + png = this.renderer.render(z, x, y, this.tileSize, packed.data, packed.lengths); + } + + const store = short ? provisionalKey : completeKey; + if (png && store) void this.rendered?.put(store, png); + debug( + () => + `tile ${z}/${x}/${y} ${content} fetch=${fetchMs}ms ` + + `render=${Math.round(performance.now() - renderStarted)}ms ` + + `bytes=${png?.byteLength ?? 0} ${describe(stats)}`, + ); + return png; + } + + /** + * The grain the label layer is drawn at, as a multiple of the tile size. + * + * Labels are the one thing on a map read as *shapes*, so they show a tile's + * resolution the way nothing else does; the geometry beneath them does not + * need it. + */ + static readonly labelResolution = 2; + + /** One 1x1 transparent PNG, served for every empty label tile. */ + private static readonly emptyTile = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, + 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, + 0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, + 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, + 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, + ]); + + /** + * Starts fetching the glyph ranges this tile wants, and returns whether it + * is about to be drawn without some of them. + * + * Claiming is what stops two tiles wanting the same range from both paying + * for it; a range that fails stays claimed, because a range the server does + * not have will not appear on a retry. + */ + private claimGlyphs( + request: TileRequest, + packed: { data: Uint8Array; lengths: Uint32Array }, + ): boolean { + const { z, x, y } = request; + let needed: string[]; + try { + needed = JSON.parse( + this.renderer.neededGlyphs(z, x, y, packed.data, packed.lengths), + ) as string[]; + } catch { + return false; + } + if (needed.length === 0) return false; + + const mine = needed.filter((url) => !this.claimed.has(url)); + mine.forEach((url) => this.claimed.add(url)); + if (mine.length === 0) return true; + + for (const url of mine) { + this.glyphsInFlight += 1; + void (async () => { + try { + const pbf = await this.fetchTile(url); + if (this.disposed || !pbf) return; + if (this.renderer.addGlyphs(pbf) > 0) this.glyphsArrived(); + } catch { + // A range that will not load or parse is a tile drawn + // without those characters, not a failed render. + } finally { + this.glyphsInFlight -= 1; + } + })(); + } + return true; + } + + /** + * Tells the host that tiles drawn before now are missing labels. + * + * Coalesced: a viewport's worth of ranges lands in a burst, and asking the + * map to refetch on each one redraws everything dozens of times to reach + * the same picture. The window is held open while ranges are still coming, + * because arrivals are spread out and a window that closes between them is + * a handover per range. + */ + private glyphsArrived(): void { + this.generation += 1; + if (this.notifyTimer) return; + + const deadline = Date.now() + this.notifyMaxWaitMs; + const check = () => { + if (this.disposed) return; + if (this.glyphsInFlight > 0 && Date.now() < deadline) { + this.notifyTimer = setTimeout(check, this.notifyQuietMs); + return; + } + this.notifyTimer = null; + // Nothing to hand over to: every tile drawn since the last one had + // all the glyphs it wanted, so what is on screen is already the + // finished picture. + if (!this.provisional) return; + this.provisional = false; + this.onGlyphsLoaded?.(); + }; + this.notifyTimer = setTimeout(check, this.notifyQuietMs); + } - return this.renderer.render(z, x, y, this.tileSize, data, lengths); + /** + * Renders the ground alone — fills, lines and circles, no labels or icons. + * + * Half of a split layer. Serve this and {@link renderLabelTile} as two + * stacked raster layers and the map shows one: the halves are drawn in + * parallel, and a font landing redraws only the transparent one, so a + * label appearing never blanks the map underneath it. + */ + async renderGeometryTile(request: TileRequest): Promise { + const packed = await this.packTiles(request); + if (!packed) return null; + const { z, x, y } = request; + return this.renderer.renderGeometry(z, x, y, this.tileSize, packed.data, packed.lengths); + } + + /** + * Renders the labels and icons alone, on a transparent ground. + * + * The other half of a split layer. `placed` of zero means nothing was + * drawn — water and fields are common — and `pixels` is then empty, so the + * caller can serve one shared transparent image rather than encode a + * megabyte of nothing. + * + * `pixels` is **premultiplied** RGBA. Handing it to a canvas as straight + * alpha turns every halo grey. + */ + async renderLabelTile( + request: TileRequest, + tileSize = this.tileSize, + ): Promise<{ pixels: Uint8Array; placed: number } | null> { + const packed = await this.packTiles(request); + if (!packed) return null; + const { z, x, y } = request; + const tile = this.renderer.renderLabels( + z, x, y, tileSize, packed.data, packed.lengths, + ); + try { + return { pixels: tile.takePixels(), placed: tile.placed }; + } finally { + // wasm-bindgen objects are not garbage collected; one leaked per + // tile would be a leak per tile. + tile.free(); + } + } + + /** + * Fetches the glyph ranges this tile's labels need and hands them to the + * renderer. Returns how many ranges were added. + * + * Labels are drawn with whatever is loaded at the moment the tile is + * drawn, so a tile rendered before this resolves comes out bare and has to + * be redrawn once it does. Call it before rendering, and redraw when the + * count is non-zero. + */ + async warmGlyphs(request: TileRequest): Promise { + const packed = await this.packTiles(request); + if (!packed) return 0; + const { z, x, y } = request; + const urls = JSON.parse( + this.renderer.neededGlyphs(z, x, y, packed.data, packed.lengths), + ) as string[]; + + const ranges = await Promise.all( + urls.map(async (url) => { + try { + return await this.fetchTile(url); + } catch { + // A range that will not load is a tile drawn without those + // characters, not a failed render. + return null; + } + }), + ); + if (this.disposed) return 0; + + let added = 0; + for (const pbf of ranges) { + if (!pbf) continue; + try { + this.renderer.addGlyphs(pbf); + added += 1; + } catch { + // A malformed range is a bad download, not a reason to stop. + } + } + return added; + } + + /** Whether any glyph has been loaded. Labels need at least one. */ + hasGlyphs(): boolean { + return this.renderer.hasGlyphs(); + } + + /** + * Fetches the style's sprite sheet, if it names one, and hands it over. + * Returns how many icons it carried, or 0 when there is no sprite. + * + * Icons are drawn only where the sheet has them, so until this resolves a + * style's shields and pins are simply absent. + */ + async loadSprite(pixelRatio = 2): Promise { + const urls = JSON.parse(this.renderer.spriteUrls(pixelRatio)) as string[]; + const jsonUrl = urls[0]; + const pngUrl = urls[1]; + // Empty when the style names no sprite at all. + if (!jsonUrl || !pngUrl) return 0; + + const [indexBytes, sheet] = await Promise.all([ + this.fetchTile(jsonUrl), + this.fetchTile(pngUrl), + ]); + if (this.disposed || !indexBytes || !sheet) return 0; + return this.renderer.addSprite(new TextDecoder().decode(indexBytes), sheet); + } + + /** Whether the style names a sprite that has not been loaded yet. */ + needsSprite(): boolean { + return this.renderer.needsSprite(); + } + + /** + * Whether this tile has to be drawn on the CPU because the style paints + * something a GPU path cannot — today, a patterned fill. + */ + async needsCpu(request: TileRequest): Promise { + const packed = await this.packTiles(request); + if (!packed) return true; + return this.renderer.needsCpu(request.z, packed.data, packed.lengths); + } + + /** + * Credits the style's sources ask to be shown. + * + * The host must display these: a style is data under someone's licence, + * and a basemap drawing OpenStreetMap requires the credit. May contain + * HTML — the text is normally a link to the licence. + */ + attributions(): string[] { + return JSON.parse(this.renderer.attributions()) as string[]; } dispose(): void { if (this.disposed) return; this.disposed = true; + if (this.notifyTimer) clearTimeout(this.notifyTimer); + this.notifyTimer = null; this.cache.clear(); this.renderer.free(); } diff --git a/bindings/js/src/VectorTileProvider.ts b/bindings/js/src/VectorTileProvider.ts index 2e3c85d..2fed95b 100644 --- a/bindings/js/src/VectorTileProvider.ts +++ b/bindings/js/src/VectorTileProvider.ts @@ -1,11 +1,20 @@ import { TileRenderCore, type TileRenderCoreOptions, type TileRequest } from './TileRenderCore'; import type { WorkerRequest, WorkerResponse } from './protocol'; +import type { TileContent } from './TileRenderCore'; export type { TileRequest }; /** Matches `TileProvider` in `@mapconductor/js-sdk-core`. */ export interface TileProvider { - renderTile(request: TileRequest): Uint8Array | null | Promise; + /** + * @param signal aborted when the map stops waiting for this tile. Honouring + * it is optional but not free to skip: renderers are a fixed resource, so + * a doomed tile is drawn instead of one still on screen. + */ + renderTile( + request: TileRequest, + signal?: AbortSignal, + ): Uint8Array | null | Promise; } /** @@ -54,9 +63,16 @@ class WorkerClient { { resolve: (value: Uint8Array | null) => void; reject: (error: Error) => void } >(); + /** Called when the worker reports that glyphs landed. */ + onGlyphs: ((generation: number) => void) | null = null; + constructor(private readonly worker: Worker) { worker.addEventListener('message', (event: MessageEvent) => { const message = event.data; + if (message.type === 'glyphs') { + this.onGlyphs?.(message.generation); + return; + } if (message.type !== 'render') return; const entry = this.pending.get(message.id); if (!entry) return; @@ -74,6 +90,7 @@ class WorkerClient { unsupportedLayerTypes: string[]; diagnostics: string[]; warnings: string[]; + attributions: string[]; }> { const id = this.nextId++; return new Promise((resolve, reject) => { @@ -87,6 +104,7 @@ class WorkerClient { unsupportedLayerTypes: message.unsupportedLayerTypes ?? [], diagnostics: message.diagnostics ?? [], warnings: message.warnings ?? [], + attributions: message.attributions ?? [], }); }; this.worker.addEventListener('message', onMessage); @@ -116,11 +134,28 @@ class WorkerClient { }); } - render(z: number, x: number, y: number): Promise { + render( + z: number, + x: number, + y: number, + content: TileContent, + signal?: AbortSignal, + ): Promise { const id = this.nextId++; return new Promise((resolve, reject) => { this.pending.set(id, { resolve, reject }); - this.worker.postMessage({ type: 'render', id, z, x, y } satisfies WorkerRequest); + // Told to the worker rather than resolved here: the point is to + // stop the work, not to stop waiting for it. + signal?.addEventListener( + 'abort', + () => { + this.worker.postMessage({ type: 'abort', id } satisfies WorkerRequest); + }, + { once: true }, + ); + this.worker.postMessage( + { type: 'render', id, z, x, y, content } satisfies WorkerRequest, + ); }); } @@ -177,7 +212,7 @@ export class VectorTileProvider implements TileProvider { const core = await TileRenderCore.create(options); const diagnostics = core.diagnostics(); for (const message of diagnostics) options.onWarning?.(message); - return new VectorTileProvider( + const provider = new VectorTileProvider( 'inline', tileSize, core.unsupportedLayerTypes(), @@ -185,6 +220,9 @@ export class VectorTileProvider implements TileProvider { null, core, ); + provider.attributions = core.attributions(); + core.onGlyphsLoaded = () => provider.onGlyphsLoaded?.(core.glyphGeneration); + return provider; } /** Why a worker cannot be used, or null if one can. */ @@ -208,19 +246,22 @@ export class VectorTileProvider implements TileProvider { new Worker(new URL('./worker.js', import.meta.url), { type: 'module' }); const client = new WorkerClient(worker); - const { unsupportedLayerTypes, diagnostics, warnings } = await client.init({ + const { unsupportedLayerTypes, diagnostics, warnings, attributions } = await client.init({ type: 'init', styleText, tileSize, + displayTileSize: options.displayTileSize, cacheBytes: options.cacheBytes ?? 16 * 1024 * 1024, headers: options.headers, wasmUrl: options.wasmUrl ? String(options.wasmUrl) : undefined, + persistRenderedTiles: options.persistRenderedTiles, + persistSourceTiles: options.persistSourceTiles, }); for (const warning of warnings) options.onWarning?.(warning); for (const message of diagnostics) options.onWarning?.(message); - return new VectorTileProvider( + const provider = new VectorTileProvider( 'worker', tileSize, unsupportedLayerTypes, @@ -228,15 +269,76 @@ export class VectorTileProvider implements TileProvider { client, null, ); + provider.attributions = attributions; + client.onGlyphs = (generation) => provider.onGlyphsLoaded?.(generation); + return provider; } - /** Renders one tile to PNG bytes. Returns null once disposed. */ - async renderTile(request: TileRequest): Promise { - if (this.disposed) return null; - if (this.client) return this.client.render(request.z, request.x, request.y); - return this.core!.renderTile(request); + /** + * Renders one tile to PNG bytes. Returns null once disposed. + * + * `content` picks a half of the split layer. The default draws both into + * one tile, which is the single-layer behaviour. + */ + /** + * Renders one tile to PNG bytes, both halves in one image. + * + * This is the shape a tile server calls; {@link renderContent} picks a half. + */ + async renderTile(request: TileRequest, signal?: AbortSignal): Promise { + return this.renderContent(request, 'full', signal); } + /** + * Renders one half of the split layer, or both. + * + * @param signal aborted when the map stops waiting. A tile nobody will look + * at is drawn *instead of* one still on screen, not as well as it. + */ + async renderContent( + request: TileRequest, + content: TileContent = 'full', + signal?: AbortSignal, + ): Promise { + if (this.disposed || signal?.aborted) return null; + if (this.client) { + return this.client.render(request.z, request.x, request.y, content, signal); + } + return this.core!.renderTile(request, content, signal); + } + + /** + * The ground alone: fills, lines and circles, and no glyph dependency. + * + * Mount this and {@link labelTiles} as two stacked raster layers and the map + * shows one map. The halves render in parallel, and a glyph range landing + * redraws only the transparent one, so a label appearing never blanks the + * map beneath it. + */ + readonly groundTiles: TileProvider = { + renderTile: (request, signal) => this.renderContent(request, 'ground', signal), + }; + + /** The labels and icons alone, on a transparent ground. */ + readonly labelTiles: TileProvider = { + renderTile: (request, signal) => this.renderContent(request, 'labels', signal), + }; + + /** + * Credits the style's sources ask to be shown. + * + * These are not optional. A style is data under someone's licence, and a + * basemap drawing OpenStreetMap requires the credit. May contain HTML — + * the credit is normally a link to the licence. + */ + attributions: string[] = []; + + /** + * Told when glyphs arrive and the tiles already on screen were drawn + * without them. Make the map refetch: nothing else will replace them. + */ + onGlyphsLoaded: ((generation: number) => void) | null = null; + /** * Replaces the style and returns the layer types the new one loses. * diff --git a/bindings/js/src/index.ts b/bindings/js/src/index.ts index 09ac71f..27de4c9 100644 --- a/bindings/js/src/index.ts +++ b/bindings/js/src/index.ts @@ -6,8 +6,11 @@ export type { VectorTileProviderOptions, } from './VectorTileProvider'; export { TileRenderCore } from './TileRenderCore'; -export type { TileRenderCoreOptions } from './TileRenderCore'; +export type { TileRenderCoreOptions, TileContent } from './TileRenderCore'; export { TileCache } from './TileCache'; +export type { SourceFetchStats } from './TileCache'; +export { RenderedTileCache } from './RenderedTileCache'; +export { SourceTileCache } from './SourceTileCache'; export { loadWasm } from './wasm'; export type { WasmRenderer } from './wasm'; export type * from './protocol'; diff --git a/bindings/js/src/protocol.ts b/bindings/js/src/protocol.ts index f96eddc..a54dc05 100644 --- a/bindings/js/src/protocol.ts +++ b/bindings/js/src/protocol.ts @@ -1,3 +1,5 @@ +import type { TileContent } from './TileRenderCore'; + /** Messages exchanged with the rendering worker. */ export interface WorkerInitRequest { @@ -6,9 +8,24 @@ export interface WorkerInitRequest { /** Style JSON as text; the main thread never sends a URL it has resolved. */ readonly styleText: string; readonly tileSize: number; + /** + * The CSS px one tile covers, when that differs from `tileSize`. + * + * Must cross the boundary with everything else: the worker *is* the + * default path, so a display size the main thread keeps to itself would + * silently do nothing. + */ + readonly displayTileSize?: number; readonly cacheBytes: number; readonly headers?: Record; readonly wasmUrl?: string; + /** + * The caches live where the rendering does. Without these the worker -- + * which is the default path -- would keep nothing on disk however the + * host asked, and the options would appear to do nothing. + */ + readonly persistRenderedTiles?: boolean; + readonly persistSourceTiles?: boolean; } export interface WorkerInitResponse { @@ -18,6 +35,9 @@ export interface WorkerInitResponse { readonly unsupportedLayerTypes?: string[]; readonly diagnostics?: string[]; readonly warnings?: string[]; + /** Credits the style's sources ask to be shown. The host must display + * these; may contain HTML. */ + readonly attributions?: string[]; } export interface WorkerRenderRequest { @@ -26,6 +46,17 @@ export interface WorkerRenderRequest { readonly z: number; readonly x: number; readonly y: number; + /** Which half of a split layer to draw. Absent means both in one tile. */ + readonly content?: TileContent; +} + +/** + * Pushed by the worker, not answered to a request: glyphs arrived and the tiles + * already on screen were drawn without them. + */ +export interface WorkerGlyphsEvent { + readonly type: 'glyphs'; + readonly generation: number; } export interface WorkerRenderResponse { @@ -49,16 +80,31 @@ export interface WorkerSetStyleResponse { readonly diagnostics?: string[]; } +/** + * Give up on a render already sent. + * + * A map that has panned away is not owed the tile it stopped waiting for, and + * the renderer is a fixed resource: a doomed tile is drawn *instead of* one + * still on screen. + */ +export interface WorkerAbortRequest { + readonly type: 'abort'; + /** The id of the render to drop. Unknown ids are ignored. */ + readonly id: number; +} + export interface WorkerDisposeRequest { readonly type: 'dispose'; } export type WorkerRequest = + | WorkerAbortRequest | WorkerInitRequest | WorkerRenderRequest | WorkerSetStyleRequest | WorkerDisposeRequest; export type WorkerResponse = + | WorkerGlyphsEvent | WorkerInitResponse | WorkerRenderResponse | WorkerSetStyleResponse; diff --git a/bindings/js/src/wasm.ts b/bindings/js/src/wasm.ts index 8170878..46eb13f 100644 --- a/bindings/js/src/wasm.ts +++ b/bindings/js/src/wasm.ts @@ -6,6 +6,19 @@ * force a wasm fetch on pages that never render a tile. */ +/** Labels and icons drawn on a transparent ground. */ +export interface WasmLabelTile { + /** How many labels were placed. Zero means the tile is empty. */ + readonly placed: number; + /** + * Takes the **premultiplied** RGBA pixels, leaving this empty. Crossing + * the wasm boundary copies, and a label tile is megabytes, so it is moved + * rather than cloned. + */ + takePixels(): Uint8Array; + free(): void; +} + /** The subset of the generated binding this package uses. */ export interface WasmRenderer { plan(z: number, x: number, y: number): string; @@ -17,6 +30,75 @@ export interface WasmRenderer { data: Uint8Array, lengths: Uint32Array, ): Uint8Array; + /** The ground alone: fills, lines and circles, no labels or icons. */ + renderGeometry( + z: number, + x: number, + y: number, + tileSize: number, + data: Uint8Array, + lengths: Uint32Array, + ): Uint8Array; + /** Labels and icons alone, on a transparent ground. */ + renderLabels( + z: number, + x: number, + y: number, + tileSize: number, + data: Uint8Array, + lengths: Uint32Array, + ): WasmLabelTile; + /** Labels and icons on a transparent ground, encoded as a PNG. */ + renderLabelsPng( + z: number, + x: number, + y: number, + tileSize: number, + data: Uint8Array, + lengths: Uint32Array, + ): WasmLabelTile; + /** Draws labels over pixels the caller already has, in place. */ + drawLabels( + z: number, + x: number, + y: number, + tileSize: number, + rgba: Uint8Array, + data: Uint8Array, + lengths: Uint32Array, + ): number; + glyphsUrlTemplate(): string | undefined; + neededGlyphs( + z: number, + x: number, + y: number, + data: Uint8Array, + lengths: Uint32Array, + ): string; + /** + * Features in the tile as a JSON array of {sourceId, layer, id, geometry, + * lon, lat, bounds:[w,s,e,n], properties}. Empty strings mean "no + * restriction"; a filter that is not JSON throws. + */ + queryFeatures( + z: number, + x: number, + y: number, + data: Uint8Array, + lengths: Uint32Array, + sourceLayer: string, + filterJson: string, + text: string, + limit: number, + ): string; + addGlyphs(pbf: Uint8Array): number; + hasGlyphs(): boolean; + spriteUrls(pixelRatio: number): string; + addSprite(json: string, png: Uint8Array): number; + needsSprite(): boolean; + needsCpu(z: number, data: Uint8Array, lengths: Uint32Array): boolean; + /** Credits the style's sources ask to be shown. May contain HTML. */ + attributions(): string; setStyle(styleJson: string): void; unresolvedSources(): string; setSourceTiles( @@ -32,7 +114,12 @@ export interface WasmRenderer { interface WasmModule { default: (input?: unknown) => Promise; - VectorTileRenderer: new (styleJson: string) => WasmRenderer; + /** + * `displayTileSize` is the CSS px one tile covers on screen -- not the + * pixel count a render is asked for. It sets the size the style draws at + * and the zoom its expressions are read at; left out it is 512. + */ + VectorTileRenderer: new (styleJson: string, displayTileSize?: number) => WasmRenderer; } let modulePromise: Promise | null = null; diff --git a/bindings/js/src/worker.ts b/bindings/js/src/worker.ts index be979ce..2ab83a8 100644 --- a/bindings/js/src/worker.ts +++ b/bindings/js/src/worker.ts @@ -33,6 +33,9 @@ function reply(message: WorkerResponse, transfer?: Transferable[]): void { ctx.postMessage(message, transfer ?? []); } +/** Renders the host may still call off, by id. */ +const inFlight = new Map(); + ctx.addEventListener('message', (event: MessageEvent) => { const message = event.data; @@ -41,19 +44,29 @@ ctx.addEventListener('message', (event: MessageEvent) => { ready = TileRenderCore.create({ style: message.styleText, tileSize: message.tileSize, + displayTileSize: message.displayTileSize, cacheBytes: message.cacheBytes, headers: message.headers, wasmUrl: message.wasmUrl, + persistRenderedTiles: message.persistRenderedTiles, + persistSourceTiles: message.persistSourceTiles, onWarning: (text) => warnings.push(text), }); ready.then( (created) => { core = created; + // Pushed rather than answered: glyphs land long after the + // render that asked for them, so there is no request to reply + // to. The host makes the map refetch on it. + created.onGlyphsLoaded = () => { + reply({ type: 'glyphs', generation: created.glyphGeneration }); + }; reply({ type: 'init', id: message.id, unsupportedLayerTypes: created.unsupportedLayerTypes(), diagnostics: created.diagnostics(), + attributions: created.attributions(), warnings, }); }, @@ -91,7 +104,17 @@ ctx.addEventListener('message', (event: MessageEvent) => { return; } + if (message.type === 'abort') { + // The render may not have started yet, or may be between its own + // checks. Either way the signal is what it reads. + inFlight.get(message.id)?.abort(); + inFlight.delete(message.id); + return; + } + if (message.type === 'dispose') { + for (const controller of inFlight.values()) controller.abort(); + inFlight.clear(); core?.dispose(); core = null; ready = null; @@ -110,16 +133,31 @@ ctx.addEventListener('message', (event: MessageEvent) => { return; } pending - .then((renderer) => - renderer.renderTile({ z: message.z, x: message.x, y: message.y }), - ) + .then((renderer) => { + const abort = new AbortController(); + inFlight.set(message.id, abort); + return renderer + .renderTile( + { z: message.z, x: message.x, y: message.y }, + message.content ?? 'full', + abort.signal, + ) + .finally(() => inFlight.delete(message.id)); + }) .then((result) => { if (!result) { reply({ type: 'render', id: message.id, result: null }); return; } - // wasm hands back a freshly allocated array each call, so the - // buffer has no other owner and is safe to transfer. + // Transferred, not copied: a tile is hundreds of kilobytes and + // there is one per request. + // + // This holds the core to a contract — every array it returns + // from a render is the caller's to consume. Hand back something + // the core still holds (a cache entry, a shared constant) and + // the first transfer detaches it, after which every later + // request for it dies here with a DataCloneError rather than + // where the sharing was introduced. reply({ type: 'render', id: message.id, result }, [result.buffer]); }) .catch((error: unknown) => { diff --git a/bindings/js/test/cache.test.mjs b/bindings/js/test/cache.test.mjs new file mode 100644 index 0000000..c7d7436 --- /dev/null +++ b/bindings/js/test/cache.test.mjs @@ -0,0 +1,132 @@ +/** + * The source tile cache's three tiers: memory, the persistent store, and the + * network, with one flight per URL across all of them. + * + * Node has no Cache Storage, so the store is stubbed. That is the point: what + * is being checked here is the order the tiers are consulted in and who writes + * back to which, not the browser API underneath. + */ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { TileCache } from '../dist/index.js'; + +/** A stand-in for `SourceTileCache` that records what it was asked. */ +function store(seed = {}) { + const held = new Map(Object.entries(seed)); + const reads = []; + const writes = []; + return { + reads, + writes, + async get(key) { + reads.push(key); + return held.get(key) ?? null; + }, + async put(key, bytes) { + writes.push(key); + held.set(key, bytes); + }, + }; +} + +const bytes = (n) => new Uint8Array([n]); + +describe('TileCache', () => { + it('serves a tile from the store without reaching the network', async () => { + const cache = new TileCache(); + const persistent = store({ 'a.pbf': bytes(7) }); + cache.persistent = persistent; + let fetches = 0; + const stats = { + memoryHits: 0, + diskHits: 0, + networkFetches: 0, + sharedWaits: 0, + cancelled: 0, + queueWaitMs: 0, + }; + + const got = await cache.get( + 'a.pbf', + async () => { + fetches += 1; + return bytes(1); + }, + undefined, + stats, + ); + + assert.deepEqual(got, bytes(7)); + assert.equal(fetches, 0, 'the store answered, so nothing was fetched'); + assert.equal(stats.diskHits, 1); + assert.equal(stats.networkFetches, 0); + // A tile that came out of the store must not be written back into it. + assert.deepEqual(persistent.writes, []); + }); + + it('writes a fetched tile back to the store, once', async () => { + const cache = new TileCache(); + const persistent = store(); + cache.persistent = persistent; + + await cache.get('b.pbf', async () => bytes(2)); + // Settle the write, which is deliberately not awaited by `get`. + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(persistent.writes, ['b.pbf']); + + // The second read is a memory hit: no store read, no second write. + await cache.get('b.pbf', async () => bytes(3)); + assert.deepEqual(persistent.reads, ['b.pbf']); + assert.deepEqual(persistent.writes, ['b.pbf']); + }); + + it('shares one store read between every tile that wants the same source', async () => { + const cache = new TileCache(); + const persistent = store({ 'c.pbf': bytes(9) }); + cache.persistent = persistent; + const stats = { + memoryHits: 0, + diskHits: 0, + networkFetches: 0, + sharedWaits: 0, + cancelled: 0, + queueWaitMs: 0, + }; + + const all = await Promise.all( + Array.from({ length: 9 }, () => + cache.get('c.pbf', async () => bytes(0), undefined, stats), + ), + ); + + assert.equal(persistent.reads.length, 1, 'nine tiles, one store read'); + assert.equal(stats.sharedWaits, 8); + for (const one of all) assert.deepEqual(one, bytes(9)); + }); + + it('counts an aborted fetch as cancelled and caches nothing', async () => { + const cache = new TileCache(); + const stats = { + memoryHits: 0, + diskHits: 0, + networkFetches: 0, + sharedWaits: 0, + cancelled: 0, + queueWaitMs: 0, + }; + const abort = () => { + const error = new Error('aborted'); + error.name = 'AbortError'; + throw error; + }; + + assert.equal(await cache.get('d.pbf', abort, undefined, stats), null); + assert.equal(stats.cancelled, 1); + }); + + it('keeps working when there is no store at all', async () => { + const cache = new TileCache(); + assert.deepEqual(await cache.get('e.pbf', async () => bytes(4)), bytes(4)); + }); +}); diff --git a/bindings/js/test/core-split.test.mjs b/bindings/js/test/core-split.test.mjs new file mode 100644 index 0000000..9f54a2c --- /dev/null +++ b/bindings/js/test/core-split.test.mjs @@ -0,0 +1,261 @@ +/** + * Covers what the Web binding gained when it caught up with Android: the split + * ground/label layers, glyph warming, and the credits a style asks for. + * + * Drives `TileRenderCore` rather than `VectorTileProvider`: the provider's only + * extra job in Node is choosing between a Worker and this, and there is no + * Worker here. Every fetch is answered from a fixture and counted, because half + * of what this layer does is decide what *not* to ask for. + */ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { after, before, describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const here = new URL('./', import.meta.url); +const read = (name) => readFile(fileURLToPath(new URL(`fixtures/${name}`, here))); + +/** + * Not z0: the demo style gives `geolines-label` a minzoom of 1 and + * `countries-label` a minzoom of 2, so at z0 no symbol layer applies and there + * is correctly nothing to label. + */ +const target = { z: 2, x: 1, y: 1 }; + +const isPng = (bytes) => + !!bytes && bytes.length > 4 && [...bytes.slice(0, 4)].join() === '137,80,78,71'; + +describe('split layers', () => { + let TileRenderCore; + let styleText; + let tile; + let glyphs; + const created = []; + + /** Answers source tiles and glyph ranges from the fixtures. */ + const core = async (options = {}) => { + const made = await TileRenderCore.create({ + style: styleText, + tileSize: 256, + wasmUrl: { module_or_path: await read('../../pkg/mvt_render_wasm_bg.wasm') }, + fetchTile: async (url) => { + options.onFetch?.(url); + // Source tiles and glyph ranges are both .pbf on the same + // host; only the path separates them. + if (url.includes('/font/')) return options.starve ? null : glyphs; + return tile; + }, + }); + created.push(made); + return made; + }; + + before(async () => { + ({ TileRenderCore } = await import('../dist/index.js')); + styleText = await readFile( + fileURLToPath(new URL('fixtures/demo-style.json', here)), 'utf8', + ); + tile = new Uint8Array(await read('tile-0-0-0.pbf')); + glyphs = new Uint8Array(await read('glyphs-0-255.pbf')); + }); + + after(() => created.forEach((c) => c.dispose())); + + /** Waits for something the core does on its own. */ + const eventually = async (what, condition, timeout = 10_000) => { + const deadline = Date.now() + timeout; + while (!condition() && Date.now() < deadline) { + await new Promise((done) => setTimeout(done, 25)); + } + assert.ok(condition(), what); + }; + + it('draws both halves and the two-in-one tile', async () => { + const subject = await core(); + assert.ok(isPng(await subject.renderTile(target, 'ground')), 'ground'); + assert.ok(isPng(await subject.renderTile(target, 'labels')), 'labels'); + assert.ok(isPng(await subject.renderTile(target, 'full')), 'both in one'); + }); + + /** + * The point of the split: the ground is drawn once and a font landing must + * not change it. If it did, every glyph range would redraw the whole map + * rather than the transparent half. + */ + it('leaves the ground alone when glyphs arrive', async () => { + const subject = await core(); + const before = await subject.renderTile(target, 'ground'); + await subject.renderTile(target, 'labels'); + await eventually('glyphs never arrived', () => subject.glyphGeneration > 0); + assert.deepEqual(await subject.renderTile(target, 'ground'), before); + }); + + /** + * Compared against a core whose glyph fetches fail rather than an earlier + * render of the same one: the fixture answers instantly, so the range lands + * before the first tile is drawn and there is no "before" to catch. + */ + it('needs its glyphs before it draws a label', async () => { + const fed = await core(); + await fed.renderTile(target, 'labels'); + await eventually('glyphs never arrived', () => fed.glyphGeneration > 0); + const labelled = await fed.renderTile(target, 'labels'); + + const starved = await core({ starve: true }); + const bare = await starved.renderTile(target, 'labels'); + + assert.ok(isPng(labelled) && isPng(bare)); + assert.ok( + labelled.length > bare.length, + `labelled ${labelled.length} vs starved ${bare.length}`, + ); + }); + + /** + * The ground never draws a label, so the ring of neighbours the label pass + * asks for is nine fetches it has no use for. + */ + it('skips the label-only neighbours when drawing the ground', async () => { + const ground = new Set(); + await (await core({ onFetch: (u) => ground.add(u) })).renderTile(target, 'ground'); + + const labels = new Set(); + await (await core({ onFetch: (u) => labels.add(u) })).renderTile(target, 'labels'); + + const only = (set) => [...set].filter((u) => !u.includes('/font/')); + assert.ok( + only(ground).length < only(labels).length, + `ground fetched ${only(ground).length}, labels ${only(labels).length}`, + ); + }); + + /** A range claimed by one tile must not be fetched again by the next. */ + it('claims each glyph range once', async () => { + const counts = new Map(); + const subject = await core({ + onFetch: (u) => counts.set(u, (counts.get(u) ?? 0) + 1), + }); + await subject.renderTile(target, 'labels'); + await eventually('glyphs never arrived', () => subject.glyphGeneration > 0); + await subject.renderTile(target, 'labels'); + + const twice = [...counts].filter(([u, n]) => u.includes('/font/') && n > 1); + assert.deepEqual(twice, [], 'a glyph range was fetched more than once'); + }); + + /** The host is told once for a burst, not once per range. */ + it('reports glyph arrival to the host', async () => { + const subject = await core(); + let told = 0; + subject.onGlyphsLoaded = () => { told += 1; }; + await subject.renderTile(target, 'labels'); + await eventually('the host was never told', () => told > 0, 15_000); + assert.equal(told, 1, 'a burst of ranges must hand over once'); + }); + + it('carries the credit out of the style', async () => { + const credited = await TileRenderCore.create({ + style: { + version: 8, + sources: { + osm: { + type: 'vector', + tiles: ['https://example.com/{z}/{x}/{y}.pbf'], + attribution: '© OpenStreetMap', + }, + }, + layers: [{ id: 'w', type: 'fill', source: 'osm', 'source-layer': 'water' }], + }, + wasmUrl: { module_or_path: await read('../../pkg/mvt_render_wasm_bg.wasm') }, + fetchTile: async () => null, + }); + created.push(credited); + assert.deepEqual(credited.attributions(), ['© OpenStreetMap']); + }); + + it('invents no credit when the style asks for none', async () => { + assert.deepEqual((await core()).attributions(), []); + }); +}); + +describe('giving up and remembering', () => { + let TileRenderCore; + let RenderedTileCache; + let styleText; + let tile; + const created = []; + + before(async () => { + ({ TileRenderCore, RenderedTileCache } = await import('../dist/index.js')); + styleText = await readFile( + fileURLToPath(new URL('fixtures/demo-style.json', here)), 'utf8', + ); + tile = new Uint8Array(await read('tile-0-0-0.pbf')); + }); + + after(() => created.forEach((c) => c.dispose())); + + const core = async () => { + const made = await TileRenderCore.create({ + style: styleText, + tileSize: 256, + wasmUrl: { module_or_path: await read('../../pkg/mvt_render_wasm_bg.wasm') }, + fetchTile: async () => tile, + }); + created.push(made); + return made; + }; + + /** + * A map that has panned away is not owed the tile it stopped waiting for. + * The renderer is a fixed resource, so a doomed tile is drawn *instead of* + * one still on screen. + */ + it('gives up on a tile aborted before it starts', async () => { + const subject = await core(); + const abort = new AbortController(); + abort.abort(); + assert.equal(await subject.renderTile(target, 'full', abort.signal), null); + }); + + /** Aborting mid-fetch has to be caught too, not only at the door. */ + it('gives up on a tile aborted while its sources are in flight', async () => { + const abort = new AbortController(); + const made = await TileRenderCore.create({ + style: styleText, + tileSize: 256, + wasmUrl: { module_or_path: await read('../../pkg/mvt_render_wasm_bg.wasm') }, + fetchTile: async () => { + abort.abort(); + return tile; + }, + }); + created.push(made); + assert.equal(await made.renderTile(target, 'full', abort.signal), null); + }); + + /** A signal nobody trips must not change anything. */ + it('draws normally when the signal is never aborted', async () => { + const subject = await core(); + const abort = new AbortController(); + assert.ok(isPng(await subject.renderTile(target, 'full', abort.signal))); + }); + + /** + * Cache Storage does not exist in Node, and a map that renders everything + * fresh is slower rather than broken. + */ + it('does without Cache Storage rather than failing', async () => { + assert.equal(typeof caches, 'undefined', 'Node grew a Cache Storage'); + assert.equal(await RenderedTileCache.open(), null); + const subject = await TileRenderCore.create({ + style: styleText, + tileSize: 256, + persistRenderedTiles: true, + wasmUrl: { module_or_path: await read('../../pkg/mvt_render_wasm_bg.wasm') }, + fetchTile: async () => tile, + }); + created.push(subject); + assert.ok(isPng(await subject.renderTile(target, 'ground'))); + }); +}); diff --git a/bindings/js/test/core.test.mjs b/bindings/js/test/core.test.mjs new file mode 100644 index 0000000..1387c98 --- /dev/null +++ b/bindings/js/test/core.test.mjs @@ -0,0 +1,227 @@ +/** + * Exercises the wasm surface the Web binding had none of: glyphs, sprites, the + * split ground/label layers, and the credits a style's sources ask for. + * + * Runs against the bundled MapLibre demo style and a real vector tile, so it + * needs no network. The glyph range is a generated fixture carrying a solid + * square per printable ASCII codepoint, which is enough for the renderer to + * place and draw the demo style's country labels. + */ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { after, before, describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const here = new URL('./', import.meta.url); +const read = (name) => readFile(fileURLToPath(new URL(`fixtures/${name}`, here))); + +/** + * Not z0: the demo style gives `geolines-label` a minzoom of 1 and + * `countries-label` a minzoom of 2, so at z0 no symbol layer applies and there + * is correctly nothing to label. z2 has both, plus the ring of neighbours the + * label pass asks for. + */ +const target = { z: 2, x: 1, y: 1 }; + +describe('wasm renderer', () => { + let wasm; + let renderer; + let tileBytes; + let glyphBytes; + let styleText; + + /** + * One entry per plan request: the bundled tile for the tile itself, and + * nothing for the ring of neighbours. + * + * The neighbours used to be handed the same bytes, which is not something + * a tile server can do: a vector source clips its features to the tile + * they belong to, so the same name in two neighbours is the same feature + * in the same place. Repeating one tile nine times instead puts nine + * copies of every label at nine different places, and the labeller -- + * which keeps one copy per name per patch of world -- then quite correctly + * kept a copy belonging to a neighbour and drew nothing here. An empty + * neighbour is an ordinary answer, and the one this fixture can tell the + * truth with. + */ + const tiles = () => { + const plan = JSON.parse(renderer.plan(target.z, target.x, target.y)); + const lengths = new Uint32Array(plan.length); + lengths[0] = tileBytes.byteLength; + const data = new Uint8Array(tileBytes.byteLength); + data.set(tileBytes, 0); + return { data, lengths }; + }; + + before(async () => { + const bytes = await readFile( + fileURLToPath(new URL('../pkg/mvt_render_wasm_bg.wasm', here)), + ); + wasm = await import('../pkg/mvt_render_wasm.js'); + // Node's fetch will not take a file:// URL, so the module is handed + // over as bytes rather than a path. + await wasm.default({ module_or_path: bytes }); + + styleText = await readFile( + fileURLToPath(new URL('fixtures/demo-style.json', here)), + 'utf8', + ); + tileBytes = new Uint8Array(await read('tile-0-0-0.pbf')); + glyphBytes = new Uint8Array(await read('glyphs-0-255.pbf')); + renderer = new wasm.VectorTileRenderer(styleText); + }); + + after(() => renderer?.free()); + + it('reports the style glyph template', () => { + assert.equal( + renderer.glyphsUrlTemplate(), + 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf', + ); + }); + + it('asks for the glyph ranges its labels need', () => { + const { data, lengths } = tiles(); + const needed = JSON.parse( + renderer.neededGlyphs(target.z, target.x, target.y, data, lengths), + ); + assert.ok(needed.length > 0, 'the demo style labels countries'); + assert.ok(needed[0].includes('Open Sans Semibold')); + assert.ok(needed[0].endsWith('.pbf')); + }); + + it('takes a glyph range and stops asking for it', () => { + assert.equal(renderer.hasGlyphs(), false); + assert.equal(renderer.addGlyphs(glyphBytes), 95); + assert.equal(renderer.hasGlyphs(), true); + + const { data, lengths } = tiles(); + const still = JSON.parse( + renderer.neededGlyphs(target.z, target.x, target.y, data, lengths), + ); + assert.ok( + !still.some((url) => url.includes('0-255')), + '0-255 is loaded; asking again would refetch it forever', + ); + }); + + it('rejects a malformed range instead of taking the page down', () => { + assert.throws(() => renderer.addGlyphs(new Uint8Array([0xff, 0xff, 0xff, 0xff]))); + }); + + it('asks for no sprite when the style names none', () => { + assert.deepEqual(JSON.parse(renderer.spriteUrls(2)), []); + assert.equal(renderer.needsSprite(), false); + }); + + it('derives both sprite URLs from the style', () => { + const styled = new wasm.VectorTileRenderer( + '{"version": 8, "sprite": "https://example.com/sprite", "sources": {}, "layers": []}', + ); + try { + assert.deepEqual(JSON.parse(styled.spriteUrls(2)), [ + 'https://example.com/sprite@2x.json', + 'https://example.com/sprite@2x.png', + ]); + } finally { + styled.free(); + } + }); + + it('draws geometry that fonts arriving cannot change', () => { + const bare = new wasm.VectorTileRenderer(styleText); + try { + const { data, lengths } = tiles(); + const before = bare.renderGeometry(target.z, target.x, target.y, 256, data, lengths); + assert.deepEqual([...before.slice(0, 4)], [0x89, 0x50, 0x4e, 0x47], 'not a PNG'); + + bare.addGlyphs(glyphBytes); + const after = bare.renderGeometry(target.z, target.x, target.y, 256, data, lengths); + assert.deepEqual(before, after, 'geometry must not depend on fonts'); + } finally { + bare.free(); + } + }); + + it('carries no pixels for a tile with nothing on it', () => { + const bare = new wasm.VectorTileRenderer(styleText); + try { + const { data, lengths } = tiles(); + const tile = bare.renderLabels(target.z, target.x, target.y, 256, data, lengths); + try { + assert.equal(tile.placed, 0, 'no glyphs, no labels'); + assert.equal(tile.takePixels().byteLength, 0); + } finally { + tile.free(); + } + } finally { + bare.free(); + } + }); + + it('draws premultiplied labels once its glyphs are in', () => { + const { data, lengths } = tiles(); + const size = 256; + const tile = renderer.renderLabels(target.z, target.x, target.y, size, data, lengths); + try { + assert.ok(tile.placed > 0, 'the demo style labels countries'); + const pixels = tile.takePixels(); + assert.equal(pixels.byteLength, size * size * 4); + assert.ok(pixels.some((byte) => byte !== 0), 'the tile is blank'); + // Premultiplied: no pixel may carry more colour than alpha. + for (let i = 0; i < pixels.length; i += 4) { + assert.ok(pixels[i] <= pixels[i + 3], `colour exceeds alpha at ${i}`); + } + } finally { + tile.free(); + } + }); + + it('draws labels over pixels the caller already has', () => { + const { data, lengths } = tiles(); + const size = 256; + const readback = new Uint8Array(size * size * 4); + for (let i = 0; i < readback.length; i += 4) { + readback[i] = 255; + readback[i + 3] = 255; + } + const before = readback.slice(); + const placed = renderer.drawLabels( + target.z, target.x, target.y, size, readback, data, lengths, + ); + assert.ok(placed > 0); + assert.notDeepEqual(readback, before, 'nothing was drawn onto the readback'); + }); + + it('carries the credit out of the style, HTML and all', () => { + const credited = new wasm.VectorTileRenderer( + JSON.stringify({ + version: 8, + sources: { + osm: { + type: 'vector', + tiles: ['https://example.com/{z}/{x}/{y}.pbf'], + attribution: '© OSM', + }, + }, + layers: [{ id: 'w', type: 'fill', source: 'osm', 'source-layer': 'water' }], + }), + ); + try { + assert.deepEqual(JSON.parse(credited.attributions()), [ + '© OSM', + ]); + } finally { + credited.free(); + } + }); + + it('invents no credit when the style asks for none', () => { + assert.deepEqual(JSON.parse(renderer.attributions()), []); + }); + + it('says the GPU path can draw a tile with no pattern in it', () => { + const { data, lengths } = tiles(); + assert.equal(renderer.needsCpu(target.z, data, lengths), false); + }); +}); diff --git a/bindings/js/test/fixtures/demo-style.json b/bindings/js/test/fixtures/demo-style.json new file mode 100644 index 0000000..2cad3c2 --- /dev/null +++ b/bindings/js/test/fixtures/demo-style.json @@ -0,0 +1 @@ +{"id": "43f36e14-e3f5-43c1-84c0-50a9c80dc5c7", "name": "MapLibre", "zoom": 0.8619833357855968, "pitch": 0, "center": [17.65431710431244, 32.954120326746775], "glyphs": "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf", "layers": [{"id": "background", "type": "background", "paint": {"background-color": "#D8F2FF"}, "filter": ["all"], "layout": {"visibility": "visible"}, "maxzoom": 24}, {"id": "coastline", "type": "line", "paint": {"line-blur": 0.5, "line-color": "#198EC8", "line-width": {"stops": [[0, 2], [6, 6], [14, 9], [22, 18]]}}, "filter": ["all"], "layout": {"line-cap": "round", "line-join": "round", "visibility": "visible"}, "source": "maplibre", "maxzoom": 24, "minzoom": 0, "source-layer": "countries"}, {"id": "countries-fill", "type": "fill", "paint": {"fill-color": ["match", ["get", "ADM0_A3"], ["ARM", "ATG", "AUS", "BTN", "CAN", "COG", "CZE", "GHA", "GIN", "HTI", "ISL", "JOR", "KHM", "KOR", "LVA", "MLT", "MNE", "MOZ", "PER", "SAH", "SGP", "SLV", "SOM", "TJK", "TUV", "UKR", "WSM"], "#D6C7FF", ["AZE", "BGD", "CHL", "CMR", "CSI", "DEU", "DJI", "GUY", "HUN", "IOA", "JAM", "LBN", "LBY", "LSO", "MDG", "MKD", "MNG", "MRT", "NIU", "NZL", "PCN", "PYF", "SAU", "SHN", "STP", "TTO", "UGA", "UZB", "ZMB"], "#EBCA8A", ["AGO", "ASM", "ATF", "BDI", "BFA", "BGR", "BLZ", "BRA", "CHN", "CRI", "ESP", "HKG", "HRV", "IDN", "IRN", "ISR", "KNA", "LBR", "LCA", "MAC", "MUS", "NOR", "PLW", "POL", "PRI", "SDN", "TUN", "UMI", "USA", "USG", "VIR", "VUT"], "#C1E599", ["ARE", "ARG", "BHS", "CIV", "CLP", "DMA", "ETH", "GAB", "GRD", "HMD", "IND", "IOT", "IRL", "IRQ", "ITA", "KOS", "LUX", "MEX", "NAM", "NER", "PHL", "PRT", "RUS", "SEN", "SUR", "TZA", "VAT"], "#E7E58F", ["AUT", "BEL", "BHR", "BMU", "BRB", "CYN", "DZA", "EST", "FLK", "GMB", "GUM", "HND", "JEY", "KGZ", "LIE", "MAF", "MDA", "NGA", "NRU", "SLB", "SOL", "SRB", "SWZ", "THA", "TUR", "VEN", "VGB"], "#98DDA1", ["AIA", "BIH", "BLM", "BRN", "CAF", "CHE", "COM", "CPV", "CUB", "ECU", "ESB", "FSM", "GAZ", "GBR", "GEO", "KEN", "LTU", "MAR", "MCO", "MDV", "NFK", "NPL", "PNG", "PRY", "QAT", "SLE", "SPM", "SYC", "TCA", "TKM", "TLS", "VNM", "WEB", "WSB", "YEM", "ZWE"], "#83D5F4", ["ABW", "ALB", "AND", "ATC", "BOL", "COD", "CUW", "CYM", "CYP", "EGY", "FJI", "GGY", "IMN", "KAB", "KAZ", "KWT", "LAO", "MLI", "MNP", "MSR", "MYS", "NIC", "NLD", "PAK", "PAN", "PRK", "ROU", "SGS", "SVN", "SWE", "TGO", "TWN", "VCT", "ZAF"], "#B1BBF9", ["ATA", "GRL"], "#FFFFFF", "#EAB38F"]}, "filter": ["all"], "layout": {"visibility": "visible"}, "source": "maplibre", "maxzoom": 24, "source-layer": "countries"}, {"id": "countries-boundary", "type": "line", "paint": {"line-color": "rgba(255, 255, 255, 1)", "line-width": {"stops": [[1, 1], [6, 2], [14, 6], [22, 12]]}, "line-opacity": {"stops": [[3, 0.5], [6, 1]]}}, "layout": {"line-cap": "round", "line-join": "round", "visibility": "visible"}, "source": "maplibre", "maxzoom": 24, "source-layer": "countries"}, {"id": "geolines", "type": "line", "paint": {"line-color": "#1077B0", "line-opacity": 1, "line-dasharray": [3, 3]}, "filter": ["all", ["!=", "name", "International Date Line"]], "layout": {"visibility": "visible"}, "source": "maplibre", "maxzoom": 24, "source-layer": "geolines"}, {"id": "geolines-label", "type": "symbol", "paint": {"text-color": "#1077B0", "text-halo-blur": 1, "text-halo-color": "rgba(255, 255, 255, 1)", "text-halo-width": 1}, "filter": ["all", ["!=", "name", "International Date Line"]], "layout": {"text-font": ["Open Sans Semibold"], "text-size": {"stops": [[2, 12], [6, 16]]}, "text-field": "{name}", "visibility": "visible", "symbol-placement": "line"}, "source": "maplibre", "maxzoom": 24, "minzoom": 1, "source-layer": "geolines"}, {"id": "countries-label", "type": "symbol", "paint": {"text-color": "rgba(8, 37, 77, 1)", "text-halo-blur": {"stops": [[2, 0.2], [6, 0]]}, "text-halo-color": "rgba(255, 255, 255, 1)", "text-halo-width": {"stops": [[2, 1], [6, 1.6]]}}, "filter": ["all"], "layout": {"text-font": ["Open Sans Semibold"], "text-size": {"stops": [[2, 10], [4, 12], [6, 16]]}, "text-field": {"stops": [[2, "{ABBREV}"], [4, "{NAME}"]]}, "visibility": "visible", "text-max-width": 10, "text-transform": {"stops": [[0, "uppercase"], [2, "none"]]}}, "source": "maplibre", "maxzoom": 24, "minzoom": 2, "source-layer": "centroids"}, {"id": "crimea-fill", "type": "fill", "source": "crimea", "paint": {"fill-color": "#D6C7FF"}}], "bearing": 0, "sources": {"maplibre": {"type": "vector", "tiles": ["https://demotiles.maplibre.org/tiles/{z}/{x}/{y}.pbf"], "minzoom": 0, "maxzoom": 6}, "crimea": {"type": "geojson", "data": {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[34.00905273547181, 46.55925987559425], [33.64325260204026, 46.34533545368038], [33.628682598560204, 46.12569762665683], [33.64292861730951, 46.10476396128129], [33.648473474905984, 46.09033047763651], [33.63876482059936, 46.077976784785335], [33.62782672238245, 46.06747935719011], [33.62911357645072, 46.05708111413949], [33.642686868727424, 46.02192963417187], [33.6429723910654, 46.01521185644708], [33.636224138774026, 46.006705833212465], [33.63052626465907, 45.99692992186792], [33.63193836679693, 45.988472992911284], [33.64276684834442, 45.984575360297384], [33.646928693041986, 45.97981936210982], [33.638745893564305, 45.96829769147004], [33.61958133326394, 45.951176418494185], [33.63181380398527, 45.9445404758078], [33.638921676216, 45.94737012930554], [33.64561542516918, 45.95403251372139], [33.65666403976448, 45.95687114427736], [33.6825817382811, 45.95878100879199], [33.738791807037614, 45.94836945227263], [33.758180142697, 45.94072970008301], [33.77735917288169, 45.92923970233858], [33.75927796793485, 45.92241179584471], [33.72529865009221, 45.91587363154565], [33.70875012326826, 45.91008760988058], [33.69378857293381, 45.91480850795665], [33.69092650243843, 45.89657370898402], [33.693592356906805, 45.87271465766318], [33.69226765972388, 45.86041392418218], [33.6704813511748, 45.8584273836251], [33.65936345808916, 45.85944682601249], [33.653870582376726, 45.86425922279372], [33.65107345584843, 45.87089907254003], [33.63067378180233, 45.88040685247182], [33.61945300059696, 45.88147266102649], [33.60987421595539, 45.88048951126686], [33.59906957603934, 45.877610457390375], [33.57828877687868, 45.86810261756233], [33.55357394560386, 45.84700625141778], [33.530220674480375, 45.84221983655459], [33.5192297395441, 45.84121682367507], [33.50832088442496, 45.84313067048083], [33.48901101848409, 45.85268298292175], [33.482152996405716, 45.854578171799005], [33.46719955896293, 45.849912739405056], [33.42447496599681, 45.83075886348303], [33.40940172404095, 45.82691953557702], [33.37918350072067, 45.802867525073566], [33.37362145339398, 45.79619281922518], [33.33805543634864, 45.78577808972071], [33.26498872665803, 45.75410774187094], [33.22887541283427, 45.75131270772724], [33.19548267281132, 45.7644887297206], [33.1789202379222, 45.78010311364778], [33.1688456078636, 45.78470227904205], [33.161012432811674, 45.77921593899549], [33.15951585299757, 45.76864464913777], [33.165962301438725, 45.762685940125465], [33.1750888126426, 45.759218220695715], [33.181464829753, 45.75490447884948], [33.17613930782352, 45.7437961960276], [33.16369168844906, 45.735912015025065], [32.93692665480876, 45.662114646778264], [32.86839112407645, 45.63044340698664], [32.83803944575723, 45.60834075026611], [32.82702772424804, 45.59576101516498], [32.82433467080986, 45.58705137380335], [32.82563941622885, 45.579605763895614], [32.82993674258438, 45.56978311819469], [32.82851940940563, 45.56227808675749], [32.813310142795274, 45.55930933158257], [32.80213583657516, 45.560145780074464], [32.78258622159436, 45.565158335073846], [32.77333922465823, 45.56689313356526], [32.758306734735356, 45.565030173463356], [32.750177256846115, 45.55943726334968], [32.74340732630185, 45.55261895849793], [32.73524549539499, 45.54598788110354], [32.72031700779701, 45.53735927760957], [32.70536040418847, 45.53169142131733], [32.68589438933773, 45.52663379187257], [32.66370583186284, 45.52563107058867], [32.64312077736798, 45.52188979044979], [32.525284074162556, 45.45838108691365], [32.49490411219156, 45.43524910229854], [32.48107654411925, 45.408986638827514], [32.48514589713025, 45.39458067125969], [32.51256939517424, 45.34060655033625], [32.535915460470335, 45.33777248012882], [32.57027153843481, 45.32510892683359], [32.590830644991826, 45.32038723212662], [32.66380378113439, 45.320421746458976], [32.67760722618917, 45.32609231279554], [32.71316246802607, 45.353283572618125], [32.72817188836078, 45.36074681043402], [32.750518060251466, 45.36371725645313], [32.89973931692998, 45.35412322462227], [32.941197846443885, 45.34245505845169], [32.97701667405008, 45.32596743563991], [33.04296090827762, 45.2853982930032], [33.05274355585479, 45.28154273654923], [33.06850284417635, 45.27703461892352], [33.07825272648239, 45.272210805127315], [33.089426322403455, 45.25656353201492], [33.09897492343546, 45.247820101667884], [33.12384611720435, 45.238235755071685], [33.15767197859745, 45.20755227709648], [33.172959979330074, 45.19681657531794], [33.21837666514142, 45.187878368659824], [33.24017433636709, 45.180191106261134], [33.248571989896675, 45.16588271012458], [33.259649216030766, 45.155918961282026], [33.28309785485047, 45.16064860772312], [33.31767999550894, 45.17535522412791], [33.35458473323109, 45.18598673360148], [33.39725661527919, 45.18973663076909], [33.41344561756824, 45.18490731877088], [33.468468576977216, 45.149132412229676], [33.537128652906205, 45.11719769268973], [33.56161328289443, 45.094099022711475], [33.57837628774928, 45.053145935448015], [33.58247744978442, 45.027377243150454], [33.5851414316958, 45.01816461606674], [33.6031021265521, 44.993103583251695], [33.605922209331794, 44.986905272229734], [33.60843524291815, 44.97039962759274], [33.61943161357851, 44.93184946652454], [33.619484500808824, 44.90819321920554], [33.61549738593425, 44.88894092276257], [33.608561183117274, 44.871288478948514], [33.59889474705494, 44.859790298912856], [33.55904244709464, 44.850057575124595], [33.54667558363471, 44.83724531175508], [33.53701832136994, 44.81871953508235], [33.5303157846202, 44.798338017069625], [33.5249116915937, 44.78918633101301], [33.51669091675143, 44.784809980590666], [33.524785531609865, 44.77183212449111], [33.5302902535075, 44.75724515985675], [33.53710734694323, 44.73034290771247], [33.54650992495621, 44.70989226909535], [33.5481286806762, 44.699106546699085], [33.543995566510915, 44.68230506537358], [33.53580273994743, 44.6726082589706], [33.52337411931097, 44.661863083605255], [33.515320778874354, 44.6491266698327], [33.516377841582795, 44.63464990118433], [33.52466971637648, 44.62863961572572], [33.557474298027785, 44.62473000923737], [33.5710648827386, 44.620853511273225], [33.55105839203679, 44.61506440493406], [33.499905706797676, 44.61452599304897], [33.48451102966331, 44.60992438254493], [33.47658499621011, 44.60714391514574], [33.46705078205747, 44.60616254193252], [33.44476599234898, 44.607062134677875], [33.4353466482458, 44.60509936890821], [33.413591053005575, 44.593500212748125], [33.40543527945235, 44.59055535193136], [33.37510958624222, 44.58564691897425], [33.37074452434078, 44.58851022190515], [33.372237834990756, 44.576810695127364], [33.37913003799301, 44.56412673079859], [33.48759131590526, 44.51024086451031], [33.50011215135888, 44.50041002882833], [33.517917009115365, 44.49074142372788], [33.53836387802215, 44.49164280212756], [33.56041892763031, 44.4966411022441], [33.57822378538677, 44.497542389459795], [33.59062975079095, 44.48975808594983], [33.619577003408466, 44.46229988129974], [33.62635433636015, 44.45336293328907], [33.63175322871038, 44.434828756313124], [33.645537634715026, 44.42498521035591], [33.721007257593925, 44.39946630464436], [33.74168386660085, 44.39560878121904], [33.80727466517129, 44.39454176175843], [33.81841706002561, 44.39552670349164], [33.83909366903248, 44.40143600575672], [33.85149963444792, 44.40143600575945], [33.91467816197718, 44.38387049706651], [33.938111652185, 44.38083293528811], [33.957065210440874, 44.38272116790142], [34.06614966692763, 44.42019923628979], [34.088893936836286, 44.42200415824283], [34.10279321289039, 44.42487551014821], [34.135933345669, 44.44163597968952], [34.14696087047267, 44.44959070749778], [34.16058918507403, 44.466287285335795], [34.170123399227776, 44.48186111741296], [34.182759104731986, 44.49267838558103], [34.22923417224524, 44.49949719774551], [34.24301857824986, 44.50744404277697], [34.263903954150294, 44.53186886058606], [34.26631622520165, 44.53555362837611], [34.26631622520165, 44.54153064468656], [34.27033667695244, 44.545378535987936], [34.2757355693048, 44.54644280144541], [34.285384653508004, 44.54562413743594], [34.299973149863405, 44.54554227040174], [34.32260254971496, 44.543577427039224], [34.3308731933177, 44.54546040325087], [34.340292537420794, 44.55798473830754], [34.38042135640006, 44.631830317636684], [34.41495238900856, 44.673669777529994], [34.424193090575585, 44.68239452736094], [34.42959198292681, 44.68884644523774], [34.469399167794535, 44.730194532749294], [34.47376422969597, 44.73011292571252], [34.47376422969597, 44.72635887754967], [34.475142670296464, 44.723502373339585], [34.499724861011515, 44.74292382044041], [34.532800295801195, 44.752620844929055], [34.61217550038418, 44.76534519537847], [34.65065696715081, 44.777088262503725], [34.72084256772871, 44.811080759265764], [34.756796893391225, 44.82094054159748], [34.82646979041766, 44.81208604604609], [34.84289620758207, 44.816893835303176], [34.856910353686715, 44.82373813182468], [34.889648317948144, 44.817871641692506], [34.90733830566026, 44.820886440346584], [34.922960632465504, 44.83050015059965], [34.92950822531711, 44.83652826953224], [34.94179932067178, 44.84019370922482], [34.95282684547897, 44.841415470643284], [34.98567967978991, 44.840275160795755], [35.0053224583441, 44.83538786296728], [35.017958163849414, 44.82219008824552], [35.02703289780189, 44.80890779582285], [35.037933245998005, 44.79869792240089], [35.08073333784134, 44.793525442788905], [35.1080207326404, 44.824553365795765], [35.130368105574235, 44.86879838545747], [35.15485200090768, 44.90071251697748], [35.17111229780758, 44.90746386008772], [35.21522068940149, 44.91421441031795], [35.233163085981715, 44.925728224907715], [35.25636688416236, 44.95896657181197], [35.27300098099195, 44.96690119386028], [35.29748487632534, 44.95605693543271], [35.30496087491386, 44.96121482614441], [35.315240372954605, 44.965711070514175], [35.31935217217088, 44.96941359539801], [35.36757236298112, 44.94362319076086], [35.36103086422793, 44.97364475976596], [35.362152264014156, 44.98593980935419], [35.374674561627444, 44.997835734117416], [35.389439658813274, 45.00180049366759], [35.42270785247763, 45.00087540764923], [35.43504325012745, 45.00470780964241], [35.43504325012745, 45.011446929213974], [35.40631957913584, 45.02015821022701], [35.40089948016896, 45.025046135473445], [35.39790908073891, 45.03482073400548], [35.40052568024015, 45.042216617888045], [35.40631957913584, 45.051328088783805], [35.40744097892215, 45.06294640963205], [35.41734667704213, 45.0708666385693], [35.469304867139925, 45.10068964922732], [35.5070260597534, 45.113341616151644], [35.54758335202416, 45.12019982412133], [35.59019654390909, 45.11993606213795], [35.63411803553862, 45.11439677872579], [35.70669729572677, 45.09480210570922], [35.771782422456766, 45.06572995732262], [35.78430472007, 45.057941041321754], [35.81250040352472, 45.031852200991295], [35.81941570220667, 45.021152336906454], [35.82763930064016, 44.99895365027004], [35.848198296721705, 44.99208088455586], [35.916977483614176, 45.00172895661731], [35.99360646900681, 44.997896355361604], [36.00893226608571, 45.00926125333629], [36.02539976723364, 45.03288661039673], [36.047827762958946, 45.048074065419456], [36.078666257082034, 45.03883000769565], [36.079137312377895, 45.046610970582435], [36.135020401727616, 45.02125162210126], [36.2241716847341, 45.00751061631556], [36.24398308095806, 45.011474706353084], [36.24828178013877, 45.01649549321965], [36.25332807917695, 45.03247980324494], [36.25743987839326, 45.03842324279259], [36.267158676549116, 45.043573724415154], [36.2783726744118, 45.04555455542638], [36.36740852558336, 45.04833265291825], [36.44029951169139, 45.06787222615526], [36.45375630913995, 45.07631970334319], [36.455251508854985, 45.09202341204062], [36.44142091149291, 45.10709638287736], [36.41432041665814, 45.12872568311289], [36.40852651776157, 45.149160473330085], [36.409997342308856, 45.171615955386955], [36.418312796420764, 45.23001671705953], [36.42672329481775, 45.25186253492981], [36.43756477765089, 45.27227491599612], [36.4497132753354, 45.28542626329343], [36.45905827355429, 45.28753019598713], [36.4814862692796, 45.28845064200263], [36.4909554290368, 45.29213135137758], [36.49637552800283, 45.300940007322055], [36.49394582846682, 45.305015191082816], [36.48871262946426, 45.30935296803605], [36.48460083024801, 45.315924724862185], [36.489647129296515, 45.336413860372005], [36.502169426909745, 45.34731734941451], [36.52104632331191, 45.35033842661815], [36.544281237819945, 45.34731734942025], [36.57455903204905, 45.33601971904315], [36.585399229982954, 45.333917585593355], [36.59810088537549, 45.334837278577254], [36.630808379142394, 45.34048649352954], [36.637536777859964, 45.3511265071989], [36.63099527910589, 45.3741073632589], [36.61359545390113, 45.40895280985421], [36.59845655678569, 45.421547717459106], [36.58331765967199, 45.42731944465129], [36.566309762912795, 45.42548305000767], [36.54836736633254, 45.41210180010589], [36.53285466928139, 45.4090840212946], [36.51565987255873, 45.41957994832251], [36.49117597722616, 45.44279525429408], [36.47043008117939, 45.4458112314303], [36.411182792482634, 45.43610707766504], [36.391371396258705, 45.43991025572652], [36.35959840231365, 45.45407156049933], [36.33960010612526, 45.45695583486963], [36.33025510790637, 45.454464879327446], [36.32053630976225, 45.44856480887407], [36.31156511147125, 45.4438443081136], [36.29885591389362, 45.442795254299995], [36.3072664122906, 45.46115087970253], [36.30016421364425, 45.47320989503609], [36.283717016779036, 45.476355300848866], [36.267082919949445, 45.46704963343626], [36.25213092279836, 45.46115087970253], [36.13681364478941, 45.46219959214511], [36.11700224855986, 45.45721803432335], [36.097003952371466, 45.441483909606006], [36.06952965760803, 45.43046741078453], [36.0655449627526, 45.42553028973455], [36.05134056545904, 45.39535242162091], [36.022557970944945, 45.368441166003805], [35.986486277818386, 45.362926059418186], [35.94723728529826, 45.372380198658874], [35.87220216002379, 45.404075760536614], [35.85388596351393, 45.413916621802144], [35.84715756479628, 45.426379251448395], [35.8524047739447, 45.44386497541683], [35.85950697259193, 45.45933624762881], [35.857824872912545, 45.469953901705], [35.83278027768503, 45.47087138287168], [35.8167068807486, 45.46392436820739], [35.80362388324218, 45.44963442058864], [35.79469305616038, 45.42980210462429], [35.791889556694684, 45.41209230278156], [35.772265060435046, 45.39214572935421], [35.767405661361295, 45.38873311015669], [35.75189296431793, 45.386632934388984], [35.7481549650407, 45.379938103368545], [35.746846665290036, 45.369960021421576], [35.74423006578874, 45.36076812520648], [35.71619507113218, 45.34040932557082], [35.69451467527287, 45.32989869277279], [35.51720627467216, 45.29506847418358], [35.48038698168983, 45.2979608697527], [35.33194061536096, 45.371562726652314], [35.04491375777232, 45.669545248704424], [35.00230056589345, 45.7290693869553], [34.70631294999043, 46.024929846739866], [34.35868883309806, 46.106725558140795], [34.00905273547181, 46.55925987559425]]]}}}}, "version": 8, "metadata": {"maptiler:copyright": "This style was generated on MapTiler Cloud. Usage is governed by the license terms in https://github.com/maplibre/demotiles/blob/gh-pages/LICENSE", "openmaptiles:version": "3.x"}} \ No newline at end of file diff --git a/bindings/js/test/fixtures/glyphs-0-255.pbf b/bindings/js/test/fixtures/glyphs-0-255.pbf new file mode 100644 index 0000000..45b2cc3 Binary files /dev/null and b/bindings/js/test/fixtures/glyphs-0-255.pbf differ diff --git a/bindings/js/test/fixtures/tile-0-0-0.pbf b/bindings/js/test/fixtures/tile-0-0-0.pbf new file mode 100644 index 0000000..b843640 Binary files /dev/null and b/bindings/js/test/fixtures/tile-0-0-0.pbf differ diff --git a/crates/mvt-render-ffi/src/lib.rs b/crates/mvt-render-ffi/src/lib.rs index 3490e05..f9c1998 100644 --- a/crates/mvt-render-ffi/src/lib.rs +++ b/crates/mvt-render-ffi/src/lib.rs @@ -50,6 +50,70 @@ unsafe fn borrow_str<'a>(text: *const c_char) -> Option<&'a str> { CStr::from_ptr(text).to_str().ok() } +/// Rebuilds the plan's per-source slices from the flat buffer the caller packs. +/// +/// Every entry point takes tiles the same way -- one buffer, one length table, +/// `0` for a tile that failed to fetch -- so the unpacking lives here rather +/// than once per function. +/// +/// # Safety +/// `data` must be readable for `data_len` bytes and `lengths` for `lengths_len` +/// elements, and both must stay alive for as long as the returned slices. +unsafe fn unpack_tiles<'a>( + data: *const u8, + data_len: usize, + lengths: *const u32, + lengths_len: usize, +) -> Result>, c_int> { + let data_bytes: &[u8] = if data.is_null() || data_len == 0 { + &[] + } else { + std::slice::from_raw_parts(data, data_len) + }; + let length_table: &[u32] = if lengths.is_null() || lengths_len == 0 { + &[] + } else { + std::slice::from_raw_parts(lengths, lengths_len) + }; + + let mut slices: Vec> = Vec::with_capacity(length_table.len()); + let mut offset = 0usize; + for length in length_table { + let length = *length as usize; + if length == 0 { + slices.push(None); + continue; + } + let end = match offset.checked_add(length) { + Some(end) if end <= data_bytes.len() => end, + _ => return Err(MVT_ERR_BAD_ARGUMENT), + }; + slices.push(Some(&data_bytes[offset..end])); + offset = end; + } + Ok(slices) +} + +/// Hands a Rust byte buffer to the caller, to be released with +/// [`mvt_buffer_free`]. +/// +/// # Safety +/// `out_ptr` and `out_len` must be writable. +unsafe fn hand_over(bytes: Vec, out_ptr: *mut *mut u8, out_len: *mut usize) { + let mut boxed = bytes.into_boxed_slice(); + *out_len = boxed.len(); + *out_ptr = boxed.as_mut_ptr(); + // Ownership moves to the caller until mvt_buffer_free. + std::mem::forget(boxed); +} + +/// Sets a source-level override for subsequent calls on the current thread. +/// Returns the previous value, which the caller must restore after the call. +#[no_mangle] +pub extern "C" fn mvt_set_thread_source_zoom_offset(offset: u8) -> u8 { + mvt_render::set_thread_source_zoom_offset(offset) +} + /// Parses a style and returns a renderer handle, or null on failure. /// /// On failure, and if `error_out` is non-null, it receives an owned message @@ -61,6 +125,12 @@ unsafe fn borrow_str<'a>(text: *const c_char) -> Option<&'a str> { #[no_mangle] pub unsafe extern "C" fn mvt_renderer_new( style_json: *const c_char, + // dp one tile covers where the host shows it. Not the pixel count a render + // is asked for: a label pass draws at twice the pixels, and a 256 dp tile + // is still 256 dp however many pixels it carries. Sets the size the style + // draws at and the zoom its expressions are read at. 512 is the usual + // answer; 0 means "use it". + display_tile_size: u32, error_out: *mut *mut c_char, ) -> *mut MvtRenderer { if !error_out.is_null() { @@ -70,7 +140,15 @@ pub unsafe extern "C" fn mvt_renderer_new( let Some(style) = borrow_str(style_json) else { return Err("style_json is null or not valid UTF-8".to_string()); }; - Renderer::new(style).map_err(|error| error.to_string()) + Renderer::new(style) + .map(|renderer| { + if display_tile_size == 0 { + renderer + } else { + renderer.with_display_tile_size(display_tile_size) + } + }) + .map_err(|error| error.to_string()) })); match result { @@ -165,7 +243,7 @@ pub unsafe extern "C" fn mvt_renderer_plan( .iter() .map(|request| { format!( - r#"{{"sourceId":{},"url":{},"z":{},"x":{},"y":{},"scale":{},"offsetX":{},"offsetY":{}}}"#, + r#"{{"sourceId":{},"url":{},"z":{},"x":{},"y":{},"scale":{},"offsetX":{},"offsetY":{},"labelsOnly":{}}}"#, json_string(&request.source_id), json_string(&request.url), request.z, @@ -174,6 +252,7 @@ pub unsafe extern "C" fn mvt_renderer_plan( request.scale, request.offset_x, request.offset_y, + request.labels_only, ) }) .collect(); @@ -273,32 +352,7 @@ pub unsafe extern "C" fn mvt_renderer_render( *out_len = 0; let result = catch_unwind(AssertUnwindSafe(|| { - let data_bytes: &[u8] = if data.is_null() || data_len == 0 { - &[] - } else { - std::slice::from_raw_parts(data, data_len) - }; - let length_table: &[u32] = if lengths.is_null() || lengths_len == 0 { - &[] - } else { - std::slice::from_raw_parts(lengths, lengths_len) - }; - - let mut slices: Vec> = Vec::with_capacity(length_table.len()); - let mut offset = 0usize; - for length in length_table { - let length = *length as usize; - if length == 0 { - slices.push(None); - continue; - } - let end = match offset.checked_add(length) { - Some(end) if end <= data_bytes.len() => end, - _ => return Err(MVT_ERR_BAD_ARGUMENT), - }; - slices.push(Some(&data_bytes[offset..end])); - offset = end; - } + let slices = unpack_tiles(data, data_len, lengths, lengths_len)?; (*handle) .inner @@ -308,11 +362,7 @@ pub unsafe extern "C" fn mvt_renderer_render( match result { Ok(Ok(png)) => { - let mut boxed = png.into_boxed_slice(); - *out_len = boxed.len(); - *out_ptr = boxed.as_mut_ptr(); - // Ownership moves to the caller until mvt_buffer_free. - std::mem::forget(boxed); + hand_over(png, out_ptr, out_len); MVT_OK } Ok(Err(code)) => code, @@ -354,32 +404,7 @@ pub unsafe extern "C" fn mvt_renderer_tessellate( *out_len = 0; let result = catch_unwind(AssertUnwindSafe(|| { - let data_bytes: &[u8] = if data.is_null() || data_len == 0 { - &[] - } else { - std::slice::from_raw_parts(data, data_len) - }; - let length_table: &[u32] = if lengths.is_null() || lengths_len == 0 { - &[] - } else { - std::slice::from_raw_parts(lengths, lengths_len) - }; - - let mut slices: Vec> = Vec::with_capacity(length_table.len()); - let mut offset = 0usize; - for length in length_table { - let length = *length as usize; - if length == 0 { - slices.push(None); - continue; - } - let end = match offset.checked_add(length) { - Some(end) if end <= data_bytes.len() => end, - _ => return Err(MVT_ERR_BAD_ARGUMENT), - }; - slices.push(Some(&data_bytes[offset..end])); - offset = end; - } + let slices = unpack_tiles(data, data_len, lengths, lengths_len)?; (*handle) .inner @@ -400,6 +425,467 @@ pub unsafe extern "C" fn mvt_renderer_tessellate( } } +/// Credits the style's sources ask to be shown, as a JSON array of strings. +/// Release with [`mvt_string_free`]. +/// +/// The host must display these: a style is data under someone's licence. The +/// text may contain HTML, because a credit is normally a link to the licence. +/// +/// # Safety +/// `handle` must be live. +#[no_mangle] +pub unsafe extern "C" fn mvt_renderer_attributions(handle: *mut MvtRenderer) -> *mut c_char { + json_array_from(handle, |renderer| renderer.attributions()) +} + +/// The style's `glyphs` URL template, or null when it names none. +/// Release with [`mvt_string_free`]. +/// +/// # Safety +/// `handle` must be live. +#[no_mangle] +pub unsafe extern "C" fn mvt_renderer_glyphs_url_template(handle: *mut MvtRenderer) -> *mut c_char { + if handle.is_null() { + return ptr::null_mut(); + } + let result = catch_unwind(AssertUnwindSafe(|| { + (*handle).inner.glyphs_url_template().map(str::to_owned) + })); + match result { + Ok(Some(template)) => into_c_string(template), + _ => ptr::null_mut(), + } +} + +/// URLs of the glyph ranges this tile's labels need and the store has not got, +/// as a JSON array of strings. Release with [`mvt_string_free`]. +/// +/// Empty when the style names no `glyphs` template, or when everything needed +/// is already loaded. Tiles are passed exactly as for [`mvt_renderer_render`]. +/// +/// # Safety +/// `handle` must be live; `data`/`lengths` valid for their lengths. +#[no_mangle] +pub unsafe extern "C" fn mvt_renderer_needed_glyphs( + handle: *mut MvtRenderer, + z: u8, + x: u32, + y: u32, + data: *const u8, + data_len: usize, + lengths: *const u32, + lengths_len: usize, +) -> *mut c_char { + if handle.is_null() { + return ptr::null_mut(); + } + let result = catch_unwind(AssertUnwindSafe(|| { + let Some(template) = (*handle).inner.glyphs_url_template().map(str::to_owned) else { + return String::from("[]"); + }; + let Ok(slices) = unpack_tiles(data, data_len, lengths, lengths_len) else { + return String::from("[]"); + }; + let urls: Vec = (*handle) + .inner + .needed_glyphs(z, x, y, &slices) + .iter() + .map(|request| json_string(&request.url(&template))) + .collect(); + format!("[{}]", urls.join(",")) + })); + match result { + Ok(json) => into_c_string(json), + Err(_) => ptr::null_mut(), + } +} + +/// Features in `z/x/y` as a JSON array (see `Renderer::query_features_json`). +/// +/// `source_layer`, `filter_json` and `text` may be null. Returns null when the +/// handle is null, the tiles do not unpack, or the filter is not JSON. +/// +/// # Safety +/// `handle` must be live; `data`/`lengths` valid for their lengths; the +/// strings NUL-terminated or null. +#[no_mangle] +pub unsafe extern "C" fn mvt_renderer_query_features( + handle: *mut MvtRenderer, + z: u8, + x: u32, + y: u32, + data: *const u8, + data_len: usize, + lengths: *const u32, + lengths_len: usize, + source_layer: *const c_char, + filter_json: *const c_char, + text: *const c_char, + limit: u32, +) -> *mut c_char { + if handle.is_null() { + return ptr::null_mut(); + } + let result = catch_unwind(AssertUnwindSafe(|| { + let Ok(slices) = unpack_tiles(data, data_len, lengths, lengths_len) else { + return None; + }; + let source_layer = optional_str(source_layer); + let filter_json = optional_str(filter_json); + let text = optional_str(text); + (*handle) + .inner + .query_features_json( + z, + x, + y, + &slices, + source_layer, + filter_json, + text, + limit as usize, + ) + .ok() + })); + match result { + Ok(Some(json)) => into_c_string(json), + _ => ptr::null_mut(), + } +} + +/// A nullable C string as an `Option<&str>`; invalid UTF-8 counts as absent. +unsafe fn optional_str<'a>(text: *const c_char) -> Option<&'a str> { + if text.is_null() { + None + } else { + CStr::from_ptr(text).to_str().ok() + } +} + +/// Adds one glyph range PBF. Returns how many glyphs it carried, or a negative +/// error code. +/// +/// # Safety +/// `handle` must be live and `pbf` readable for `pbf_len` bytes. +#[no_mangle] +pub unsafe extern "C" fn mvt_renderer_add_glyphs( + handle: *mut MvtRenderer, + pbf: *const u8, + pbf_len: usize, +) -> c_int { + if handle.is_null() { + return MVT_ERR_NULL_HANDLE; + } + if pbf.is_null() || pbf_len == 0 { + return MVT_ERR_BAD_ARGUMENT; + } + let result = catch_unwind(AssertUnwindSafe(|| { + let bytes = std::slice::from_raw_parts(pbf, pbf_len); + (*handle) + .inner + .add_glyphs(bytes) + .map(|count| count as c_int) + .unwrap_or(MVT_ERR_RENDER_FAILED) + })); + result.unwrap_or(MVT_ERR_PANIC) +} + +/// `1` when any glyph has been loaded, `0` when none, negative on failure. +/// +/// # Safety +/// `handle` must be live. +#[no_mangle] +pub unsafe extern "C" fn mvt_renderer_has_glyphs(handle: *mut MvtRenderer) -> c_int { + bool_call(handle, |renderer| renderer.has_glyphs()) +} + +/// The sprite sheet's `.json` and `.png` URLs at `pixel_ratio`, as a JSON array +/// of two strings, or `[]` when the style names no sprite. Release with +/// [`mvt_string_free`]. +/// +/// # Safety +/// `handle` must be live. +#[no_mangle] +pub unsafe extern "C" fn mvt_renderer_sprite_urls( + handle: *mut MvtRenderer, + pixel_ratio: u32, +) -> *mut c_char { + if handle.is_null() { + return ptr::null_mut(); + } + let result = catch_unwind(AssertUnwindSafe(|| { + let urls: Vec = match (*handle).inner.sprite_urls(pixel_ratio.max(1)) { + Some(request) => vec![ + json_string(&request.json_url), + json_string(&request.png_url), + ], + None => Vec::new(), + }; + format!("[{}]", urls.join(",")) + })); + match result { + Ok(json) => into_c_string(json), + Err(_) => ptr::null_mut(), + } +} + +/// Adds the fetched sprite sheet. Returns how many icons it carried, or a +/// negative error code. +/// +/// # Safety +/// `handle` must be live, `json` a valid NUL-terminated UTF-8 C string, and +/// `png` readable for `png_len` bytes. +#[no_mangle] +pub unsafe extern "C" fn mvt_renderer_add_sprite( + handle: *mut MvtRenderer, + json: *const c_char, + png: *const u8, + png_len: usize, +) -> c_int { + if handle.is_null() { + return MVT_ERR_NULL_HANDLE; + } + let Some(index) = borrow_str(json) else { + return MVT_ERR_BAD_ARGUMENT; + }; + if png.is_null() || png_len == 0 { + return MVT_ERR_BAD_ARGUMENT; + } + let result = catch_unwind(AssertUnwindSafe(|| { + let sheet = std::slice::from_raw_parts(png, png_len); + (*handle) + .inner + .add_sprite(index, sheet) + .map(|count| count as c_int) + .unwrap_or(MVT_ERR_RENDER_FAILED) + })); + result.unwrap_or(MVT_ERR_PANIC) +} + +/// `1` when the style names a sprite the renderer has not been given yet. +/// +/// # Safety +/// `handle` must be live. +#[no_mangle] +pub unsafe extern "C" fn mvt_renderer_needs_sprite(handle: *mut MvtRenderer) -> c_int { + bool_call(handle, |renderer| renderer.needs_sprite()) +} + +/// `1` when this tile has to be drawn on the CPU because the style paints +/// something the GPU path cannot -- today, a patterned fill. +/// +/// # Safety +/// `handle` must be live; `data`/`lengths` valid for their lengths. +#[no_mangle] +pub unsafe extern "C" fn mvt_renderer_needs_cpu( + handle: *mut MvtRenderer, + z: u8, + data: *const u8, + data_len: usize, + lengths: *const u32, + lengths_len: usize, +) -> c_int { + if handle.is_null() { + return MVT_ERR_NULL_HANDLE; + } + let result = catch_unwind(AssertUnwindSafe(|| { + let Ok(slices) = unpack_tiles(data, data_len, lengths, lengths_len) else { + return MVT_ERR_BAD_ARGUMENT; + }; + c_int::from((*handle).inner.needs_cpu(z, &slices)) + })); + result.unwrap_or(MVT_ERR_PANIC) +} + +/// Rasterises the ground alone -- fills, lines and circles, no labels or icons. +/// +/// The half of a split layer that a GPU draws and glyphs never invalidate. +/// Output and ownership match [`mvt_renderer_render`]. +/// +/// # Safety +/// Same contract as [`mvt_renderer_render`]. +#[no_mangle] +pub unsafe extern "C" fn mvt_renderer_render_geometry( + handle: *mut MvtRenderer, + z: u8, + x: u32, + y: u32, + tile_size: u32, + data: *const u8, + data_len: usize, + lengths: *const u32, + lengths_len: usize, + out_ptr: *mut *mut u8, + out_len: *mut usize, +) -> c_int { + if handle.is_null() { + return MVT_ERR_NULL_HANDLE; + } + if out_ptr.is_null() || out_len.is_null() { + return MVT_ERR_BAD_ARGUMENT; + } + *out_ptr = ptr::null_mut(); + *out_len = 0; + + let result = catch_unwind(AssertUnwindSafe(|| { + let slices = unpack_tiles(data, data_len, lengths, lengths_len)?; + (*handle) + .inner + .render_geometry(z, x, y, tile_size, &slices) + .map_err(|_| MVT_ERR_RENDER_FAILED) + })); + + match result { + Ok(Ok(png)) => { + hand_over(png, out_ptr, out_len); + MVT_OK + } + Ok(Err(code)) => code, + Err(_) => MVT_ERR_PANIC, + } +} + +/// Draws the labels and icons alone, on a transparent ground, into a newly +/// allocated **premultiplied** RGBA buffer of `tile_size * tile_size * 4` +/// bytes. +/// +/// The other half of a split layer: a CPU draws this while a GPU draws the +/// ground, and a font arriving refreshes only this one. `*out_placed` receives +/// how many labels were placed; when that is zero nothing is allocated, +/// `*out_ptr` stays null and the caller should serve a transparent tile. +/// +/// Premultiplied, not straight: encoding it as straight alpha turns every +/// halo grey. +/// +/// # Safety +/// `handle` must be live, `data`/`lengths` valid for their lengths, and +/// `out_ptr`, `out_len`, `out_placed` writable. +#[no_mangle] +pub unsafe extern "C" fn mvt_renderer_render_labels( + handle: *mut MvtRenderer, + z: u8, + x: u32, + y: u32, + tile_size: u32, + data: *const u8, + data_len: usize, + lengths: *const u32, + lengths_len: usize, + out_ptr: *mut *mut u8, + out_len: *mut usize, + out_placed: *mut usize, +) -> c_int { + if handle.is_null() { + return MVT_ERR_NULL_HANDLE; + } + if out_ptr.is_null() || out_len.is_null() || out_placed.is_null() { + return MVT_ERR_BAD_ARGUMENT; + } + *out_ptr = ptr::null_mut(); + *out_len = 0; + *out_placed = 0; + + let result = catch_unwind(AssertUnwindSafe(|| { + let slices = unpack_tiles(data, data_len, lengths, lengths_len)?; + (*handle) + .inner + .render_labels_rgba(z, x, y, tile_size, &slices) + .map_err(|_| MVT_ERR_RENDER_FAILED) + })); + + match result { + Ok(Ok((pixels, placed))) => { + *out_placed = placed; + if !pixels.is_empty() { + hand_over(pixels, out_ptr, out_len); + } + MVT_OK + } + Ok(Err(code)) => code, + Err(_) => MVT_ERR_PANIC, + } +} + +/// Draws labels and icons over pixels the caller already has -- a GPU readback, +/// typically -- in place. Returns how many labels were placed, or a negative +/// error code. +/// +/// `rgba` must hold exactly `tile_size * tile_size * 4` bytes and is read as +/// **straight** alpha, which is what a `glReadPixels` result is. +/// +/// # Safety +/// `handle` must be live, `rgba` readable *and writable* for `rgba_len` bytes, +/// and `data`/`lengths` valid for their lengths. +#[no_mangle] +pub unsafe extern "C" fn mvt_renderer_draw_labels( + handle: *mut MvtRenderer, + z: u8, + x: u32, + y: u32, + tile_size: u32, + rgba: *mut u8, + rgba_len: usize, + data: *const u8, + data_len: usize, + lengths: *const u32, + lengths_len: usize, +) -> c_int { + if handle.is_null() { + return MVT_ERR_NULL_HANDLE; + } + if rgba.is_null() || rgba_len == 0 { + return MVT_ERR_BAD_ARGUMENT; + } + let result = catch_unwind(AssertUnwindSafe(|| { + let Ok(slices) = unpack_tiles(data, data_len, lengths, lengths_len) else { + return MVT_ERR_BAD_ARGUMENT; + }; + let pixels = std::slice::from_raw_parts_mut(rgba, rgba_len); + (*handle) + .inner + .draw_labels_onto(z, x, y, tile_size, pixels, &slices) + .map(|placed| placed as c_int) + .unwrap_or(MVT_ERR_RENDER_FAILED) + })); + result.unwrap_or(MVT_ERR_PANIC) +} + +/// Shared shape of the accessors that answer with a list of strings. +/// +/// # Safety +/// `handle` must be live. +unsafe fn json_array_from( + handle: *mut MvtRenderer, + read: impl Fn(&Renderer) -> Vec, +) -> *mut c_char { + if handle.is_null() { + return ptr::null_mut(); + } + let result = catch_unwind(AssertUnwindSafe(|| { + let items: Vec = read(&(*handle).inner) + .iter() + .map(|item| json_string(item)) + .collect(); + format!("[{}]", items.join(",")) + })); + match result { + Ok(json) => into_c_string(json), + Err(_) => ptr::null_mut(), + } +} + +/// Shared shape of the accessors that answer yes or no. +/// +/// # Safety +/// `handle` must be live. +unsafe fn bool_call(handle: *mut MvtRenderer, read: impl Fn(&Renderer) -> bool) -> c_int { + if handle.is_null() { + return MVT_ERR_NULL_HANDLE; + } + let result = catch_unwind(AssertUnwindSafe(|| c_int::from(read(&(*handle).inner)))); + result.unwrap_or(MVT_ERR_PANIC) +} + /// Releases a float buffer returned by [`mvt_renderer_tessellate`]. /// /// # Safety diff --git a/crates/mvt-render-jni/src/lib.rs b/crates/mvt-render-jni/src/lib.rs index 2dc0e4b..d132d98 100644 --- a/crates/mvt-render-jni/src/lib.rs +++ b/crates/mvt-render-jni/src/lib.rs @@ -15,7 +15,7 @@ use std::panic::{catch_unwind, AssertUnwindSafe}; use jni::objects::JByteBuffer; use jni::objects::{JByteArray, JClass, JIntArray, JString}; #[cfg(feature = "tessellate")] -use jni::sys::jfloatArray; +use jni::sys::jobject; use jni::sys::{jbyteArray, jint, jlong, jstring}; use jni::JNIEnv; use mvt_render::Renderer; @@ -56,6 +56,11 @@ pub extern "system" fn Java_com_mapconductor_vectortile_NativeRenderer_nativeNew mut env: JNIEnv, _class: JClass, style_json: JString, + // dp one tile covers where the host shows it. Not the pixel count a render + // is asked for: a label pass draws at twice the pixels, and a 256 dp tile + // is still 256 dp however many pixels it carries. Sets the size the style + // draws at and the zoom its expressions are read at. + display_tile_size: jint, ) -> jlong { guarded(&mut env, 0, |env| { let style: String = match env.get_string(&style_json) { @@ -66,7 +71,10 @@ pub extern "system" fn Java_com_mapconductor_vectortile_NativeRenderer_nativeNew } }; match Renderer::new(&style) { - Ok(renderer) => Box::into_raw(Box::new(renderer)) as jlong, + Ok(renderer) => { + let renderer = renderer.with_display_tile_size(display_tile_size.max(1) as u32); + Box::into_raw(Box::new(renderer)) as jlong + } Err(error) => { throw(env, ILLEGAL_ARGUMENT, &error.to_string()); 0 @@ -150,7 +158,7 @@ fn plan_json(renderer: &Renderer, z: jint, x: jint, y: jint) -> String { // Hand-built rather than pulling serde_json in for six fields; the // only interpolated string is a URL from the style. out.push_str(&format!( - r#"{{"sourceId":{},"url":{},"z":{},"x":{},"y":{},"scale":{},"offsetX":{},"offsetY":{}}}"#, + r#"{{"sourceId":{},"url":{},"z":{},"x":{},"y":{},"scale":{},"offsetX":{},"offsetY":{},"labelsOnly":{}}}"#, json_string(&request.source_id), json_string(&request.url), request.z, @@ -159,6 +167,7 @@ fn plan_json(renderer: &Renderer, z: jint, x: jint, y: jint) -> String { request.scale, request.offset_x, request.offset_y, + request.labels_only, )); } out.push(']'); @@ -334,6 +343,99 @@ pub extern "system" fn Java_com_mapconductor_vectortile_NativeRenderer_nativeGly /// URLs rather than parts: the host has nothing to do with the pair except /// fetch it, and building the string here keeps one copy of the substitution /// rules instead of one per platform. +pub extern "system" fn Java_com_mapconductor_vectortile_NativeRenderer_nativeQueryFeatures( + mut env: JNIEnv, + _class: JClass, + handle: jlong, + z: jint, + x: jint, + y: jint, + data: JByteArray, + lengths: JIntArray, + source_layer: JString, + filter_json: JString, + text: JString, + limit: jint, +) -> jstring { + // Features in the tile as JSON; see `Renderer::query_features_json`. + // Null strings mean "no restriction". A filter that is not JSON throws. + guarded(&mut env, std::ptr::null_mut(), |env| { + let Some(renderer) = (unsafe { renderer(handle) }) else { + throw(env, ILLEGAL_ARGUMENT, "renderer handle is null"); + return std::ptr::null_mut(); + }; + let data_bytes = match env.convert_byte_array(&data) { + Ok(value) => value, + Err(error) => { + throw(env, ILLEGAL_ARGUMENT, &format!("bad tile data: {error}")); + return std::ptr::null_mut(); + } + }; + let count = match env.get_array_length(&lengths) { + Ok(value) => value as usize, + Err(error) => { + throw(env, ILLEGAL_ARGUMENT, &format!("bad length table: {error}")); + return std::ptr::null_mut(); + } + }; + let mut length_table = vec![0i32; count]; + if let Err(error) = env.get_int_array_region(&lengths, 0, &mut length_table) { + throw(env, ILLEGAL_ARGUMENT, &format!("bad length table: {error}")); + return std::ptr::null_mut(); + } + let mut slices: Vec> = Vec::with_capacity(count); + let mut offset = 0usize; + for length in &length_table { + if *length <= 0 { + slices.push(None); + continue; + } + let length = *length as usize; + if offset + length > data_bytes.len() { + slices.push(None); + continue; + } + slices.push(Some(&data_bytes[offset..offset + length])); + offset += length; + } + let source_layer = optional_string(env, &source_layer); + let filter_json = optional_string(env, &filter_json); + let text = optional_string(env, &text); + let json = match renderer.query_features_json( + z as u8, + x as u32, + y as u32, + &slices, + source_layer.as_deref(), + filter_json.as_deref(), + text.as_deref(), + limit.max(0) as usize, + ) { + Ok(json) => json, + Err(error) => { + throw(env, ILLEGAL_ARGUMENT, &format!("{error}")); + return std::ptr::null_mut(); + } + }; + match env.new_string(json) { + Ok(value) => value.into_raw(), + Err(error) => { + throw(env, RUNTIME_EXCEPTION, &format!("{error}")); + std::ptr::null_mut() + } + } + }) +} + +/// A possibly-null Java string. +fn optional_string(env: &mut JNIEnv, value: &JString) -> Option { + if value.is_null() { + return None; + } + env.get_string(value).ok().map(|s| s.into()) +} + +#[no_mangle] pub extern "system" fn Java_com_mapconductor_vectortile_NativeRenderer_nativeNeededGlyphs( mut env: JNIEnv, _class: JClass, @@ -672,6 +774,143 @@ pub extern "system" fn Java_com_mapconductor_vectortile_NativeRenderer_nativeDra }) } +/// Draws a transparent label tile and returns its premultiplied RGBA allocation +/// as a direct buffer. The label count is written to `placed_out[0]`. +#[cfg(feature = "tessellate")] +#[no_mangle] +pub extern "system" fn Java_com_mapconductor_vectortile_NativeRenderer_nativeRenderLabels( + mut env: JNIEnv, + _class: JClass, + handle: jlong, + z: jint, + x: jint, + y: jint, + tile_size: jint, + data: JByteArray, + lengths: JIntArray, + placed_out: JIntArray, +) -> jobject { + guarded(&mut env, std::ptr::null_mut(), |env| { + let Some(renderer) = (unsafe { renderer(handle) }) else { + throw(env, ILLEGAL_ARGUMENT, "renderer handle is null"); + return std::ptr::null_mut(); + }; + if env.get_array_length(&placed_out).unwrap_or(0) < 1 { + throw(env, ILLEGAL_ARGUMENT, "placed output is empty"); + return std::ptr::null_mut(); + } + + let data_bytes = match env.convert_byte_array(&data) { + Ok(value) => value, + Err(error) => { + throw(env, ILLEGAL_ARGUMENT, &format!("bad tile data: {error}")); + return std::ptr::null_mut(); + } + }; + let count = match env.get_array_length(&lengths) { + Ok(value) => value as usize, + Err(error) => { + throw(env, ILLEGAL_ARGUMENT, &format!("bad length table: {error}")); + return std::ptr::null_mut(); + } + }; + let mut length_table = vec![0i32; count]; + if let Err(error) = env.get_int_array_region(&lengths, 0, &mut length_table) { + throw(env, ILLEGAL_ARGUMENT, &format!("bad length table: {error}")); + return std::ptr::null_mut(); + } + let mut slices: Vec> = Vec::with_capacity(count); + let mut offset = 0usize; + for length in &length_table { + if *length <= 0 { + slices.push(None); + continue; + } + let length = *length as usize; + if offset + length > data_bytes.len() { + slices.push(None); + continue; + } + slices.push(Some(&data_bytes[offset..offset + length])); + offset += length; + } + + let (pixels, placed) = match renderer.render_labels_rgba( + z as u8, + x as u32, + y as u32, + tile_size as u32, + &slices, + ) { + Ok(result) => result, + Err(error) => { + throw(env, ILLEGAL_ARGUMENT, &format!("{error}")); + return std::ptr::null_mut(); + } + }; + if env + .set_int_array_region(&placed_out, 0, &[placed as jint]) + .is_err() + { + throw(env, RUNTIME_EXCEPTION, "could not return label count"); + return std::ptr::null_mut(); + } + if pixels.is_empty() { + return std::ptr::null_mut(); + } + + let mut pixels = pixels.into_boxed_slice(); + let byte_len = pixels.len(); + let address = pixels.as_mut_ptr(); + match unsafe { env.new_direct_byte_buffer(address, byte_len) } { + Ok(buffer) => { + std::mem::forget(pixels); + buffer.into_raw() + } + Err(error) => { + throw(env, RUNTIME_EXCEPTION, &format!("{error}")); + std::ptr::null_mut() + } + } + }) +} + +/// Releases the RGBA allocation returned by `nativeRenderLabels`. +#[cfg(feature = "tessellate")] +#[no_mangle] +pub extern "system" fn Java_com_mapconductor_vectortile_NativeRenderer_nativeFreeLabelPixels( + mut env: JNIEnv, + _class: JClass, + buffer: JByteBuffer, +) { + guarded(&mut env, (), |env| { + let address = match env.get_direct_buffer_address(&buffer) { + Ok(address) => address, + Err(error) => { + throw( + env, + ILLEGAL_ARGUMENT, + &format!("not a direct buffer: {error}"), + ); + return; + } + }; + let byte_len = match env.get_direct_buffer_capacity(&buffer) { + Ok(byte_len) => byte_len, + Err(error) => { + throw( + env, + ILLEGAL_ARGUMENT, + &format!("bad direct buffer: {error}"), + ); + return; + } + }; + let slice = std::ptr::slice_from_raw_parts_mut(address, byte_len); + drop(unsafe { Box::from_raw(slice) }); + }); +} + fn empty_json_array(env: &mut JNIEnv) -> jstring { match env.new_string("[]") { Ok(value) => value.into_raw(), @@ -679,6 +918,34 @@ fn empty_json_array(env: &mut JNIEnv) -> jstring { } } +/// JSON array of the credits the style's sources ask to be shown. May contain +/// HTML: the host's attribution overlay renders it. +#[no_mangle] +pub extern "system" fn Java_com_mapconductor_vectortile_NativeRenderer_nativeAttributions( + mut env: JNIEnv, + _class: JClass, + handle: jlong, +) -> jstring { + guarded(&mut env, std::ptr::null_mut(), |env| { + let Some(renderer) = (unsafe { renderer(handle) }) else { + throw(env, ILLEGAL_ARGUMENT, "renderer handle is null"); + return std::ptr::null_mut(); + }; + let credits: Vec = renderer + .attributions() + .iter() + .map(|credit| json_string(credit)) + .collect(); + match env.new_string(format!("[{}]", credits.join(","))) { + Ok(value) => value.into_raw(), + Err(error) => { + throw(env, RUNTIME_EXCEPTION, &format!("{error}")); + std::ptr::null_mut() + } + } + }) +} + #[no_mangle] pub extern "system" fn Java_com_mapconductor_vectortile_NativeRenderer_nativeUnsupportedLayerTypes( mut env: JNIEnv, @@ -705,7 +972,7 @@ pub extern "system" fn Java_com_mapconductor_vectortile_NativeRenderer_nativeUns }) } -/// Triangulates a tile for a GPU, returning one packed `float[]`. +/// Triangulates a tile for a GPU, returning one packed direct byte buffer. /// /// Layout, chosen so a single JNI call carries everything with no object /// marshalling: @@ -717,8 +984,8 @@ pub extern "system" fn Java_com_mapconductor_vectortile_NativeRenderer_nativeUns /// [6] batch count B /// [7..13] timings ms: copy-in, decode, tessellate, /// filter-compile, fill, line -/// [10 .. 10+B*6] per batch: r, g, b, a, firstVertex, vertexCount -/// [10+B*6 ..] interleaved x, y vertex positions +/// [13 .. 13+B*2] per batch: firstVertex, vertexCount +/// [13+B*2 ..] vertices: x, y, r, g, b, a /// ``` /// /// The timings exist because the device-side cost had to be decomposed @@ -738,7 +1005,7 @@ pub extern "system" fn Java_com_mapconductor_vectortile_NativeRenderer_nativeTes tile_size: jint, data: JByteArray, lengths: JIntArray, -) -> jfloatArray { +) -> jobject { guarded(&mut env, std::ptr::null_mut(), |env| { let Some(renderer) = (unsafe { renderer(handle) }) else { throw(env, ILLEGAL_ARGUMENT, "renderer handle is null"); @@ -795,13 +1062,16 @@ pub extern "system" fn Java_com_mapconductor_vectortile_NativeRenderer_nativeTes } }; - match env.new_float_array(packed.len() as i32) { - Ok(array) => { - if env.set_float_array_region(&array, 0, &packed).is_err() { - throw(env, RUNTIME_EXCEPTION, "could not fill the result array"); - return std::ptr::null_mut(); - } - array.into_raw() + // Give Java a view of this allocation instead of copying the packed + // floats into a Java array. Kotlin releases it after glBufferData has + // synchronously consumed the client data. + let mut packed = packed.into_boxed_slice(); + let byte_len = std::mem::size_of_val(packed.as_ref()); + let address = packed.as_mut_ptr().cast::(); + match unsafe { env.new_direct_byte_buffer(address, byte_len) } { + Ok(buffer) => { + std::mem::forget(packed); + buffer.into_raw() } Err(error) => { throw(env, RUNTIME_EXCEPTION, &format!("{error}")); @@ -811,6 +1081,55 @@ pub extern "system" fn Java_com_mapconductor_vectortile_NativeRenderer_nativeTes }) } +/// Releases a packed tessellation returned by `nativeTessellate`. +/// +/// DirectByteBuffer does not own its native address, so the caller must invoke +/// this exactly once after OpenGL has consumed the data. +#[cfg(feature = "tessellate")] +#[no_mangle] +pub extern "system" fn Java_com_mapconductor_vectortile_NativeRenderer_nativeFreeTessellation( + mut env: JNIEnv, + _class: JClass, + buffer: JByteBuffer, +) { + guarded(&mut env, (), |env| { + let address = match env.get_direct_buffer_address(&buffer) { + Ok(address) => address, + Err(error) => { + throw( + env, + ILLEGAL_ARGUMENT, + &format!("not a direct buffer: {error}"), + ); + return; + } + }; + let byte_len = match env.get_direct_buffer_capacity(&buffer) { + Ok(byte_len) => byte_len, + Err(error) => { + throw( + env, + ILLEGAL_ARGUMENT, + &format!("bad direct buffer: {error}"), + ); + return; + } + }; + if byte_len % std::mem::size_of::() != 0 { + throw( + env, + ILLEGAL_ARGUMENT, + "tessellation buffer has invalid size", + ); + return; + } + + let float_len = byte_len / std::mem::size_of::(); + let slice = std::ptr::slice_from_raw_parts_mut(address.cast::(), float_len); + drop(unsafe { Box::from_raw(slice) }); + }); +} + #[cfg(feature = "glbench-compat")] #[no_mangle] pub extern "system" fn Java_com_mapconductor_glbench_NativeTessellator_nativeNew( @@ -863,7 +1182,7 @@ pub extern "system" fn Java_com_mapconductor_glbench_NativeTessellator_nativeTes tile_size: jint, data: JByteArray, lengths: JIntArray, -) -> jni::sys::jfloatArray { +) -> jobject { Java_com_mapconductor_vectortile_NativeRenderer_nativeTessellate( env, class, handle, z, x, y, tile_size, data, lengths, ) diff --git a/crates/mvt-render-wasm/Cargo.toml b/crates/mvt-render-wasm/Cargo.toml index ba7defb..0a15a1c 100644 --- a/crates/mvt-render-wasm/Cargo.toml +++ b/crates/mvt-render-wasm/Cargo.toml @@ -15,3 +15,11 @@ serde_json.workspace = true [target.'cfg(target_arch = "wasm32")'.dependencies] console_error_panic_hook = "0.1" + +# The wasm-opt that wasm-pack downloads (117) predates the features rustc now +# emits by default -- bulk memory, sign extension and the rest -- and refuses to +# validate its own input without them. `-all` lets it read what the compiler +# wrote. Every one of those has been baseline in browsers since 2021; the +# alternative is shipping unoptimised wasm. +[package.metadata.wasm-pack.profile.release] +wasm-opt = ['-O', '-all'] diff --git a/crates/mvt-render-wasm/src/lib.rs b/crates/mvt-render-wasm/src/lib.rs index df4f16d..8f79bfd 100644 --- a/crates/mvt-render-wasm/src/lib.rs +++ b/crates/mvt-render-wasm/src/lib.rs @@ -17,6 +17,58 @@ pub fn start() { console_error_panic_hook::set_once(); } +/// Rebuilds the plan's per-source slices from the flat buffer the host packs. +/// +/// Every entry point takes tiles the same way -- one buffer, one length table, +/// `0` for a tile that failed to fetch -- so it is unpacked in one place. +fn unpack<'a>(data: &'a [u8], lengths: &[u32]) -> Result>, JsError> { + let mut slices: Vec> = Vec::with_capacity(lengths.len()); + let mut offset = 0usize; + for len in lengths { + let len = *len as usize; + if len == 0 { + slices.push(None); + continue; + } + let end = offset + .checked_add(len) + .filter(|e| *e <= data.len()) + .ok_or_else(|| JsError::new("tile length table overruns the data buffer"))?; + slices.push(Some(&data[offset..end])); + offset = end; + } + Ok(slices) +} + +/// A drawn label tile: transparent ground, labels and icons over it. +#[wasm_bindgen] +pub struct LabelTile { + pixels: Vec, + placed: usize, +} + +#[wasm_bindgen] +impl LabelTile { + /// How many labels were placed. Zero means the tile is empty and the host + /// should serve one shared transparent image rather than encoding this. + #[wasm_bindgen(getter)] + pub fn placed(&self) -> usize { + self.placed + } + + /// Takes the **premultiplied** RGBA pixels, `tileSize * tileSize * 4` + /// bytes, leaving this empty. + /// + /// Named for what it does: crossing into JS copies the buffer, and a tile + /// at label resolution is megabytes, so it is moved rather than cloned. + /// Premultiplied, not straight -- encoding it as straight alpha turns + /// every halo grey. + #[wasm_bindgen(js_name = takePixels)] + pub fn take_pixels(&mut self) -> Vec { + std::mem::take(&mut self.pixels) + } +} + #[wasm_bindgen] pub struct VectorTileRenderer { inner: Renderer, @@ -25,9 +77,22 @@ pub struct VectorTileRenderer { #[wasm_bindgen] impl VectorTileRenderer { /// Parses a `style.json`. Throws if the style is unusable. + /// `displayTileSize` is how many CSS px one tile covers where the page + /// shows it -- not the pixel count a render is asked for (a label pass + /// draws at twice the pixels, and a 256 px tile is still 256 px wide on + /// screen however many pixels it carries). It sets the size the style + /// draws at and the zoom its expressions are read at; leaving it out + /// keeps 512, which is what a raster source declared at 512 gets. #[wasm_bindgen(constructor)] - pub fn new(style_json: &str) -> Result { + pub fn new( + style_json: &str, + display_tile_size: Option, + ) -> Result { Renderer::new(style_json) + .map(|renderer| match display_tile_size { + Some(dp) if dp > 0 => renderer.with_display_tile_size(dp), + _ => renderer, + }) .map(|inner| VectorTileRenderer { inner }) .map_err(|e| JsError::new(&e.to_string())) } @@ -39,7 +104,13 @@ impl VectorTileRenderer { } /// Source tiles needed to draw `z/x/y`, as a JSON array of - /// `{ sourceId, url, z, x, y, scale, offsetX, offsetY }`. + /// `{ sourceId, url, z, x, y, scale, offsetX, offsetY, labelsOnly }`. + /// + /// `labelsOnly` marks a neighbour the label pass wants so a name at the + /// tile edge can be drawn whole. Nothing else reads those tiles, so a host + /// drawing the ground alone can leave their entries null and skip the + /// fetch -- but it must leave the *entries*, because the bytes are handed + /// back positionally. /// /// The host must fetch these **in order** and pass the bytes back to /// [`VectorTileRenderer::render`] positionally. @@ -59,6 +130,7 @@ impl VectorTileRenderer { "scale": r.scale, "offsetX": r.offset_x, "offsetY": r.offset_y, + "labelsOnly": r.labels_only, }) }) .collect(); @@ -79,27 +151,238 @@ impl VectorTileRenderer { data: &[u8], lengths: &[u32], ) -> Result, JsError> { - let mut slices: Vec> = Vec::with_capacity(lengths.len()); - let mut offset = 0usize; - for len in lengths { - let len = *len as usize; - if len == 0 { - slices.push(None); - continue; + let slices = unpack(data, lengths)?; + self.inner + .render(z, x, y, tile_size, &slices) + .map_err(|e| JsError::new(&e.to_string())) + } + + /// Rasterises the ground alone -- fills, lines and circles, no labels or + /// icons -- to PNG bytes. + /// + /// The half of a split layer that a GPU can draw and that fonts arriving + /// never invalidate. + #[wasm_bindgen(js_name = renderGeometry)] + pub fn render_geometry( + &self, + z: u8, + x: u32, + y: u32, + tile_size: u32, + data: &[u8], + lengths: &[u32], + ) -> Result, JsError> { + let slices = unpack(data, lengths)?; + self.inner + .render_geometry(z, x, y, tile_size, &slices) + .map_err(|e| JsError::new(&e.to_string())) + } + + /// Draws the labels and icons alone, on a transparent ground. + /// + /// The other half of a split layer: this is redrawn on its own when a font + /// arrives, leaving the ground untouched. + #[wasm_bindgen(js_name = renderLabels)] + pub fn render_labels( + &self, + z: u8, + x: u32, + y: u32, + tile_size: u32, + data: &[u8], + lengths: &[u32], + ) -> Result { + let slices = unpack(data, lengths)?; + self.inner + .render_labels_rgba(z, x, y, tile_size, &slices) + .map(|(pixels, placed)| LabelTile { pixels, placed }) + .map_err(|e| JsError::new(&e.to_string())) + } + + /// Draws the labels and icons alone, on a transparent ground, as a PNG. + /// + /// What a browser actually wants: a raster layer loads an image, and + /// turning raw pixels into one through a canvas costs more than the + /// drawing did. Returns an empty array when nothing was placed, so the + /// host can serve one shared transparent tile instead of encoding a + /// megabyte of nothing. + #[wasm_bindgen(js_name = renderLabelsPng)] + pub fn render_labels_png( + &self, + z: u8, + x: u32, + y: u32, + tile_size: u32, + data: &[u8], + lengths: &[u32], + ) -> Result { + let slices = unpack(data, lengths)?; + self.inner + .render_labels_png(z, x, y, tile_size, &slices) + .map(|(pixels, placed)| LabelTile { pixels, placed }) + .map_err(|e| JsError::new(&e.to_string())) + } + + /// Draws labels and icons over pixels the host already has, in place. + /// + /// `rgba` must hold exactly `tileSize * tileSize * 4` bytes and is read as + /// **straight** alpha, which is what a WebGL readback is. Returns how many + /// labels were placed. + #[wasm_bindgen(js_name = drawLabels)] + pub fn draw_labels( + &self, + z: u8, + x: u32, + y: u32, + tile_size: u32, + rgba: &mut [u8], + data: &[u8], + lengths: &[u32], + ) -> Result { + let slices = unpack(data, lengths)?; + self.inner + .draw_labels_onto(z, x, y, tile_size, rgba, &slices) + .map_err(|e| JsError::new(&e.to_string())) + } + + /// The style's `glyphs` URL template, or `undefined` when it names none. + #[wasm_bindgen(js_name = glyphsUrlTemplate)] + pub fn glyphs_url_template(&self) -> Option { + self.inner.glyphs_url_template().map(str::to_owned) + } + + /// Features in the tile as a JSON array; see `Renderer::query_features_json`. + /// Empty strings mean "no restriction". A filter that is not JSON errors. + #[wasm_bindgen(js_name = queryFeatures)] + #[allow(clippy::too_many_arguments)] + pub fn query_features( + &self, + z: u8, + x: u32, + y: u32, + data: &[u8], + lengths: &[u32], + source_layer: &str, + filter_json: &str, + text: &str, + limit: u32, + ) -> Result { + let slices = unpack(data, lengths)?; + let none_if_empty = |s: &str| { + if s.is_empty() { + None + } else { + Some(s.to_owned()) } - let end = offset - .checked_add(len) - .filter(|e| *e <= data.len()) - .ok_or_else(|| JsError::new("tile length table overruns the data buffer"))?; - slices.push(Some(&data[offset..end])); - offset = end; - } + }; + let source_layer = none_if_empty(source_layer); + let filter_json = none_if_empty(filter_json); + let text = none_if_empty(text); + self.inner + .query_features_json( + z, + x, + y, + &slices, + source_layer.as_deref(), + filter_json.as_deref(), + text.as_deref(), + limit as usize, + ) + .map_err(|e| JsError::new(&e.to_string())) + } + + /// URLs of the glyph ranges this tile's labels need and the store has not + /// got, as a JSON array of strings. + /// + /// Empty when the style names no template or everything is already loaded. + #[wasm_bindgen(js_name = neededGlyphs)] + pub fn needed_glyphs( + &self, + z: u8, + x: u32, + y: u32, + data: &[u8], + lengths: &[u32], + ) -> Result { + let Some(template) = self.inner.glyphs_url_template().map(str::to_owned) else { + return Ok("[]".to_string()); + }; + let slices = unpack(data, lengths)?; + let urls: Vec = self + .inner + .needed_glyphs(z, x, y, &slices) + .iter() + .map(|request| serde_json::Value::String(request.url(&template))) + .collect(); + Ok(serde_json::Value::Array(urls).to_string()) + } + /// Adds one fetched glyph range PBF. Returns how many glyphs it carried. + #[wasm_bindgen(js_name = addGlyphs)] + pub fn add_glyphs(&self, pbf: &[u8]) -> Result { self.inner - .render(z, x, y, tile_size, &slices) + .add_glyphs(pbf) + .map_err(|e| JsError::new(&e.to_string())) + } + + /// Whether any glyph has been loaded. Labels need at least one. + #[wasm_bindgen(js_name = hasGlyphs)] + pub fn has_glyphs(&self) -> bool { + self.inner.has_glyphs() + } + + /// The sprite sheet's `.json` and `.png` URLs at `pixelRatio`, as a JSON + /// array of two strings, or `[]` when the style names no sprite. + #[wasm_bindgen(js_name = spriteUrls)] + pub fn sprite_urls(&self, pixel_ratio: u32) -> String { + let urls: Vec = match self.inner.sprite_urls(pixel_ratio.max(1)) { + Some(request) => vec![ + serde_json::Value::String(request.json_url), + serde_json::Value::String(request.png_url), + ], + None => Vec::new(), + }; + serde_json::Value::Array(urls).to_string() + } + + /// Adds the fetched sprite sheet. Returns how many icons it carried. + #[wasm_bindgen(js_name = addSprite)] + pub fn add_sprite(&self, json: &str, png: &[u8]) -> Result { + self.inner + .add_sprite(json, png) .map_err(|e| JsError::new(&e.to_string())) } + /// Whether the style names a sprite the renderer has not been given yet. + #[wasm_bindgen(js_name = needsSprite)] + pub fn needs_sprite(&self) -> bool { + self.inner.needs_sprite() + } + + /// Whether this tile has to be drawn on the CPU because the style paints + /// something a GPU path cannot -- today, a patterned fill. + #[wasm_bindgen(js_name = needsCpu)] + pub fn needs_cpu(&self, z: u8, data: &[u8], lengths: &[u32]) -> Result { + let slices = unpack(data, lengths)?; + Ok(self.inner.needs_cpu(z, &slices)) + } + + /// Credits the style's sources ask to be shown, as a JSON array of + /// strings. + /// + /// The host must display these: a style is data under someone's licence. + /// May contain HTML, because a credit is normally a link to the licence. + pub fn attributions(&self) -> String { + let items: Vec = self + .inner + .attributions() + .into_iter() + .map(serde_json::Value::String) + .collect(); + serde_json::Value::Array(items).to_string() + } + /// Replaces the style without discarding the renderer. /// /// Vector tiles the host already fetched stay valid — geometry is diff --git a/crates/mvt-render/Cargo.toml b/crates/mvt-render/Cargo.toml index 102add6..66459f2 100644 --- a/crates/mvt-render/Cargo.toml +++ b/crates/mvt-render/Cargo.toml @@ -14,6 +14,12 @@ earcutr = { version = "0.5", optional = true } # path's readback). Already in the tree transitively via tiny-skia, so this # adds no new code to any build. +# Test-only. The renderer must not depend on the style compiler -- they ship +# as separate libraries on purpose -- but the compiler's claim about which +# expressions this renderer can evaluate has to be checked against it. +[dev-dependencies] +mvt-style = { path = "../mvt-style" } + [features] default = [] # Triangulation for the OpenGL ES experiment. Off by default: the CPU @@ -26,6 +32,14 @@ tessellate = ["dep:earcutr"] name = "tessellate_file" required-features = ["tessellate"] +[[example]] +name = "export_stencil_experiment" +required-features = ["tessellate"] + [[example]] name = "tessellate_compare" required-features = ["tessellate"] + +[[example]] +name = "pipeline_cost" +required-features = ["tessellate"] diff --git a/crates/mvt-render/examples/block_cost.rs b/crates/mvt-render/examples/block_cost.rs new file mode 100644 index 0000000..14e5c69 --- /dev/null +++ b/crates/mvt-render/examples/block_cost.rs @@ -0,0 +1,82 @@ +//! Temporary: one block render against the tiles it would replace. +use mvt_render::Renderer; +use std::fs; +use std::path::PathBuf; +use std::time::Instant; + +fn main() { + let dir = PathBuf::from(std::env::args().nth(1).expect("fixture dir")); + let style = fs::read_to_string(dir.join("style.json")).unwrap(); + // What the iPad's ArcGIS view draws: level 13 tiles, 128 pt each, at 2x. + let z: u8 = std::env::args() + .nth(2) + .map(|v| v.parse().unwrap()) + .unwrap_or(13); + let (dp, px): (u32, u32) = (128, 256); + let (cx, cy): (u32, u32) = match z { + 9 => (454, 201), + 11 => (1818, 806), + _ => (7272, 3228), + }; + let (x0, y0) = (cx & !3, cy & !3); + + let read = |q: &mvt_render::SourceTileRequest| { + fs::read(dir.join(format!("{}-{}-{}.mvt", q.z, q.x, q.y))).ok() + }; + + // (a) Sixteen tiles, the way they are drawn now. + let r = Renderer::new(&style).unwrap().with_display_tile_size(dp); + let mut per_tile_ms = 0.0f64; + let mut drawn = 0usize; + for dy in 0..4u32 { + for dx in 0..4u32 { + let (x, y) = (x0 + dx, y0 + dy); + let plan = r.plan(z, x, y); + let owned: Vec>> = plan.requests.iter().map(read).collect(); + let data: Vec> = owned.iter().map(|o| o.as_deref()).collect(); + let started = Instant::now(); + let _ = r.render(z, x, y, px, &data).unwrap(); + per_tile_ms += started.elapsed().as_secs_f64() * 1000.0; + drawn += 1; + } + } + + // (b) The same ground, drawn once. + let r2 = Renderer::new(&style).unwrap().with_display_tile_size(dp); + let plan = r2.plan_source_block(z, x0, y0); + let owned: Vec>> = plan.requests.iter().map(read).collect(); + let data: Vec> = owned.iter().map(|o| o.as_deref()).collect(); + let started = Instant::now(); + let (pixmap, across) = r2.render_source_block(z, x0, y0, px, &data).unwrap(); + let block_ms = started.elapsed().as_secs_f64() * 1000.0; + + // Cutting the block up is what the host would do per tile. + let started = Instant::now(); + let mut cut = 0usize; + for dy in 0..across { + for dx in 0..across { + let rect = + tiny_skia::IntRect::from_xywh((dx * px) as i32, (dy * px) as i32, px, px).unwrap(); + if let Some(piece) = pixmap.clone_rect(rect) { + let _ = piece.encode_png().unwrap(); + cut += 1; + } + } + } + let cut_ms = started.elapsed().as_secs_f64() * 1000.0; + + println!("source plan: {} requests", plan.requests.len()); + println!( + "(a) {drawn} tiles one by one : {per_tile_ms:7.1} ms ({:.1} ms each)", + per_tile_ms / drawn as f64 + ); + println!( + "(b) one {}x{} block : {block_ms:7.1} ms + cut&encode {cut_ms:.1} ms for {cut} tiles", + pixmap.width(), + pixmap.height() + ); + println!( + " total (b) : {:7.1} ms", + block_ms + cut_ms + ); +} diff --git a/crates/mvt-render/examples/export_stencil_experiment.rs b/crates/mvt-render/examples/export_stencil_experiment.rs new file mode 100644 index 0000000..848b2d3 --- /dev/null +++ b/crates/mvt-render/examples/export_stencil_experiment.rs @@ -0,0 +1,81 @@ +//! Exports the Shortbread 8/226/100 forest for the Android stencil experiment. +//! cargo run --release -p mvt-render --features tessellate +//! --example export_stencil_experiment -- /path/to/fixture +//! Input: style.json and 8-226-100.mvt; output: float32-LE rings and reference triangles. +use mvt_render::tessellate::{tessellate, DecodedTile}; +use mvt_render::{mvt, Style}; +use std::{fs, time::Instant}; +fn write(path: &str, data: &[f32]) { + let bytes: Vec = data.iter().flat_map(|v| v.to_le_bytes()).collect(); + fs::write(path, bytes).unwrap(); +} +fn main() { + let dir = std::env::args() + .nth(1) + .expect("fixture directory containing style.json and 8-226-100.mvt"); + let mut style = + Style::parse(&fs::read_to_string(format!("{dir}/style.json")).unwrap()).unwrap(); + style.layers.retain(|l| l.id == "land-forest"); + let bytes = fs::read(format!("{dir}/8-226-100.mvt")).unwrap(); + let start = Instant::now(); + let tile = mvt::decode(&bytes).unwrap(); + println!("decode_ms={:.3}", start.elapsed().as_secs_f64() * 1000.); + let source = style.active_vector_sources(8.)[0].id.clone(); + let decoded = [DecodedTile { + source_id: &source, + tile: &tile, + scale: 1., + offset_x: 0., + offset_y: 0., + }]; + let start = Instant::now(); + let reference = tessellate(&style, 8., 512, 512., &decoded); + println!( + "earcut_ms={:.3} triangles={} extent={}", + start.elapsed().as_secs_f64() * 1000., + reference.triangle_count(), + reference.extent + ); + write(&format!("{dir}/forest-earcut.bin"), &reference.vertices); + let start = Instant::now(); + let mut fans = Vec::new(); + let land = tile.layer("land").unwrap(); + let k = reference.extent as f32 / land.extent as f32; + let color = &reference.vertices[2..6]; + let mut rings = Vec::new(); + rings.extend_from_slice(&reference.extent.to_le_bytes()); + for c in color { + rings.extend_from_slice(&c.to_le_bytes()); + } + let count: usize = land.features.iter().map(|f| f.parts.len()).sum(); + rings.extend_from_slice(&(count as u32).to_le_bytes()); + for feature in &land.features { + for ring in &feature.parts { + rings.extend_from_slice(&(ring.len() as u32).to_le_bytes()); + for p in ring { + rings.extend_from_slice(&(p[0] * k).to_le_bytes()); + rings.extend_from_slice(&(p[1] * k).to_le_bytes()); + } + } + } + fs::write(format!("{dir}/forest-rings.bin"), rings).unwrap(); + for feature in &land.features { + for ring in &feature.parts { + if ring.len() < 3 { + continue; + } + for i in 1..ring.len() - 1 { + for p in [ring[0], ring[i], ring[i + 1]] { + fans.extend_from_slice(&[p[0] * k, p[1] * k]); + fans.extend_from_slice(color); + } + } + } + } + println!( + "fan_pack_ms={:.3} triangles={}", + start.elapsed().as_secs_f64() * 1000., + fans.len() / 18 + ); + write(&format!("{dir}/forest-fans.bin"), &fans); +} diff --git a/crates/mvt-render/examples/label_size_probe.rs b/crates/mvt-render/examples/label_size_probe.rs new file mode 100644 index 0000000..d085ffb --- /dev/null +++ b/crates/mvt-render/examples/label_size_probe.rs @@ -0,0 +1,25 @@ +fn main() { + let style = std::fs::read_to_string(std::env::args().nth(1).unwrap()).unwrap(); + let tile = std::fs::read(std::env::args().nth(2).unwrap()).unwrap(); + let glyphs = std::fs::read(std::env::args().nth(3).unwrap()).unwrap(); + let z: u8 = std::env::args().nth(4).unwrap().parse().unwrap(); + let r = mvt_render::Renderer::new(&style).unwrap(); + r.add_glyphs(&glyphs).ok(); + for size in [256u32, 512, 1024] { + let plan = r.plan(z, 0, 0); + let data: Vec> = plan + .requests + .iter() + .map(|_| Some(tile.as_slice())) + .collect(); + let sizes = r.label_sizes(z, size, &data); + let mut uniq: Vec = sizes.iter().map(|s| format!("{s:.1}")).collect(); + uniq.sort(); + uniq.dedup(); + println!( + "tile_size={size:4} labels={:3} drawn text sizes(px)={:?}", + sizes.len(), + uniq + ); + } +} diff --git a/crates/mvt-render/examples/pipeline_cost.rs b/crates/mvt-render/examples/pipeline_cost.rs new file mode 100644 index 0000000..6f01542 --- /dev/null +++ b/crates/mvt-render/examples/pipeline_cost.rs @@ -0,0 +1,101 @@ +//! Offline native benchmark, also executable on Android via adb. +//! pipeline_cost style.json tile.mvt z x y [glyph-directory] [samples] +//! Only the central source tile is supplied: this isolates native costs and +//! does not stand in for a viewport benchmark with nine distinct neighbours. +use std::{fs, hint::black_box, time::Instant}; + +fn hash(bytes: impl Iterator) -> u64 { + bytes.fold(0xcbf29ce484222325u64, |h, b| { + (h ^ u64::from(b)).wrapping_mul(0x100000001b3) + }) +} + +fn measure(mut body: impl FnMut(), samples: usize) -> f64 { + for _ in 0..3 { + body(); + } + let mut times = Vec::new(); + for _ in 0..samples { + let start = Instant::now(); + body(); + times.push(start.elapsed().as_secs_f64() * 1000.0); + } + times.sort_by(f64::total_cmp); + times[times.len() / 2] +} + +fn main() { + let args: Vec<_> = std::env::args().skip(1).collect(); + assert!( + args.len() >= 5, + "style.json tile.mvt z x y [glyph-directory] [samples]" + ); + let renderer = mvt_render::Renderer::new(&fs::read_to_string(&args[0]).unwrap()).unwrap(); + let bytes = fs::read(&args[1]).unwrap(); + let (z, x, y) = ( + args[2].parse().unwrap(), + args[3].parse().unwrap(), + args[4].parse().unwrap(), + ); + let samples = args.get(6).map_or(21, |v| v.parse::().unwrap()); + assert!(samples > 0); + let plan = renderer.plan(z, x, y); + assert_eq!(plan.requests.iter().filter(|r| !r.labels_only).count(), 1); + let data: Vec<_> = plan + .requests + .iter() + .map(|r| (!r.labels_only).then_some(bytes.as_slice())) + .collect(); + let mut glyphs = 0; + if let Some(dir) = args.get(5).filter(|dir| dir.as_str() != "-") { + let mut paths: Vec<_> = fs::read_dir(dir) + .unwrap() + .map(|e| e.unwrap().path()) + .collect(); + paths.sort(); + for path in paths { + if path.is_file() { + glyphs += renderer.add_glyphs(&fs::read(path).unwrap()).unwrap_or(0); + } + } + } + let mut packed = Vec::new(); + let geometry_ms = measure( + || { + packed = black_box(renderer.tessellate_packed(z, x, y, 512, &data).unwrap()); + }, + samples, + ); + // Exclude the six timing fields from the output fingerprint. + let geometry_hash = hash( + packed[..7] + .iter() + .chain(&packed[13..]) + .flat_map(|f| f.to_bits().to_le_bytes()), + ); + println!("geometry median={geometry_ms:.3}ms decode={:.3}ms tessellate={:.3}ms floats={} hash={geometry_hash:016x} n={samples}", packed[7], packed[8], packed.len()); + if glyphs > 0 { + let missing_ms = measure( + || { + black_box(renderer.needed_glyphs(z, x, y, &data)); + }, + samples, + ); + let mut rgba = vec![0; 1024 * 1024 * 4]; + let mut placed = 0; + let labels_ms = measure( + || { + rgba.fill(0); + placed = black_box( + renderer + .draw_labels_onto(z, x, y, 1024, &mut rgba, &data) + .unwrap(), + ); + }, + samples, + ); + assert!(placed > 0, "label benchmark must draw labels"); + let label_hash = hash(rgba.iter().copied()); + println!("labels median={labels_ms:.3}ms needed={missing_ms:.3}ms placed={placed} glyphs={glyphs} hash={label_hash:016x} n={samples}"); + } +} diff --git a/crates/mvt-render/examples/tessellate_compare.rs b/crates/mvt-render/examples/tessellate_compare.rs index bf04361..e3d7d78 100644 --- a/crates/mvt-render/examples/tessellate_compare.rs +++ b/crates/mvt-render/examples/tessellate_compare.rs @@ -54,7 +54,7 @@ fn main() { offset_x: 0.0, offset_y: 0.0, }]; - let tess = tessellate(&style, z as f64, size, &decoded); + let tess = tessellate(&style, z as f64, size, size as f32, &decoded); let mut candidate = Pixmap::new(size, size).unwrap(); if let Some(bg) = tess.background { diff --git a/crates/mvt-render/examples/tessellate_file.rs b/crates/mvt-render/examples/tessellate_file.rs index 1530323..2ce4845 100644 --- a/crates/mvt-render/examples/tessellate_file.rs +++ b/crates/mvt-render/examples/tessellate_file.rs @@ -52,7 +52,7 @@ fn main() { offset_y: 0.0, }]; let started = Instant::now(); - let result = tessellate(&style, z as f64, size, &decoded); + let result = tessellate(&style, z as f64, size, size as f32, &decoded); times.push(started.elapsed().as_secs_f64() * 1000.0); last = Some(result); } diff --git a/crates/mvt-render/src/clock.rs b/crates/mvt-render/src/clock.rs new file mode 100644 index 0000000..91ca0d3 --- /dev/null +++ b/crates/mvt-render/src/clock.rs @@ -0,0 +1,43 @@ +//! A stopwatch that survives platforms with no clock. +//! +//! `std::time::Instant::now()` **panics** on `wasm32-unknown-unknown`: there is +//! no system clock behind it, and the standard library says so by trapping. +//! That took the whole label pass down in the browser, from a line that only +//! existed to report how long the pass took. +//! +//! The timings in this crate are instrumentation. They are reported alongside a +//! tile so a slow device can be measured; nothing decides anything from them. +//! So on the web they read as zero, and Android and iOS keep the real numbers. + +/// Measures elapsed milliseconds from when it was started. +#[cfg(not(target_arch = "wasm32"))] +#[derive(Debug, Clone, Copy)] +pub struct Stopwatch(std::time::Instant); + +/// The same stopwatch where there is no clock to read. Always zero. +#[cfg(target_arch = "wasm32")] +#[derive(Debug, Clone, Copy)] +pub struct Stopwatch; + +impl Stopwatch { + #[cfg(not(target_arch = "wasm32"))] + pub fn start() -> Stopwatch { + Stopwatch(std::time::Instant::now()) + } + + #[cfg(target_arch = "wasm32")] + pub fn start() -> Stopwatch { + Stopwatch + } + + /// Milliseconds since [`Stopwatch::start`], or zero with no clock. + #[cfg(not(target_arch = "wasm32"))] + pub fn ms(&self) -> f32 { + self.0.elapsed().as_secs_f32() * 1000.0 + } + + #[cfg(target_arch = "wasm32")] + pub fn ms(&self) -> f32 { + 0.0 + } +} diff --git a/crates/mvt-render/src/color.rs b/crates/mvt-render/src/color.rs index 6b694d8..91fc2bf 100644 --- a/crates/mvt-render/src/color.rs +++ b/crates/mvt-render/src/color.rs @@ -44,6 +44,31 @@ impl Color { } } + /// `self` painted over `under`, in straight (non-premultiplied) RGBA. + /// + /// What a style means by stacking two layers that both cover the whole + /// tile. Keeping only the top one is wrong whenever it is not opaque, and + /// real styles lean on that: versatiles' colorful paints its cream + /// background and then four `background-opacity: 0` layers as slots for + /// other layers to be inserted into. Taking the last of those as "the + /// background" left every tile transparent. + pub fn over(self, under: Color) -> Color { + if self.a >= 1.0 || under.a <= 0.0 { + return self; + } + if self.a <= 0.0 { + return under; + } + let a = self.a + under.a * (1.0 - self.a); + let mix = |top: f32, bottom: f32| (top * self.a + bottom * under.a * (1.0 - self.a)) / a; + Color { + r: mix(self.r, under.r), + g: mix(self.g, under.g), + b: mix(self.b, under.b), + a, + } + } + pub fn to_tiny_skia(self) -> tiny_skia::Color { tiny_skia::Color::from_rgba( self.r.clamp(0.0, 1.0), diff --git a/crates/mvt-render/src/decode_cache.rs b/crates/mvt-render/src/decode_cache.rs new file mode 100644 index 0000000..7da0ba4 --- /dev/null +++ b/crates/mvt-render/src/decode_cache.rs @@ -0,0 +1,170 @@ +//! Bounded decoded tiles, sharing work for concurrent requests for one key. +use crate::{model::Tile, LabelTileKey}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex, OnceLock}; + +type Flight = Arc>>>; + +struct State { + entries: crate::memory_cache::MemoryCache>, + flights: HashMap, +} + +pub(crate) struct DecodeCache { + state: Mutex, +} + +impl DecodeCache { + pub(crate) fn new(limit: usize) -> Self { + Self { + state: Mutex::new(State { + entries: crate::memory_cache::MemoryCache::new(limit, 16 << 20), + flights: HashMap::new(), + }), + } + } + + pub(crate) fn get_or_decode( + &self, + key: LabelTileKey, + decode: impl FnOnce() -> Option, + ) -> Option> { + let flight = { + let mut state = self.state.lock().ok()?; + if let Some(tile) = state.entries.get(&key) { + return Some(tile); + } + state.flights.entry(key.clone()).or_default().clone() + }; + // Decode outside the cache lock: different tiles must remain parallel. + // + // `decode` must not panic. A panic here poisons the `OnceLock`, and + // the flight is never taken out of the map -- so every later request + // for that key panics too, and so does every thread already waiting + // on this one. The decoders this is called with + // (`mvt::decode`/`decode_only`) are written to return `Err` on + // anything malformed, which is what makes that safe; a decoder that + // can abort on bad input needs catching before it gets here. + let tile = flight.get_or_init(|| decode().map(Arc::new)).clone(); + let mut state = self.state.lock().ok()?; + // Only the thread still owning this flight publishes/removes it. + // A failed decode is not cached; a subsequent request can retry. + if state + .flights + .get(&key) + .is_some_and(|current| Arc::ptr_eq(current, &flight)) + { + state.flights.remove(&key); + if let Some(tile) = &tile { + let cost = tile.memory_cost() + key.0.capacity(); + state.entries.insert(key, tile.clone(), cost); + } + } + tile + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Barrier, + }; + + fn key(x: u32) -> LabelTileKey { + ("source".into(), 4, x, 6) + } + fn tile() -> Tile { + Tile { layers: Vec::new() } + } + + #[test] + fn concurrent_requests_decode_once_and_share_the_allocation() { + let cache = DecodeCache::new(48); + let start = Barrier::new(16); + let count = AtomicUsize::new(0); + std::thread::scope(|scope| { + let workers: Vec<_> = (0..16) + .map(|_| { + scope.spawn(|| { + start.wait(); + cache + .get_or_decode(key(8), || { + count.fetch_add(1, Ordering::SeqCst); + Some(tile()) + }) + .unwrap() + }) + }) + .collect(); + let tiles: Vec<_> = workers.into_iter().map(|w| w.join().unwrap()).collect(); + assert_eq!(count.load(Ordering::SeqCst), 1); + assert!(tiles.iter().all(|t| Arc::ptr_eq(t, &tiles[0]))); + }); + assert!(cache.state.lock().unwrap().flights.is_empty()); + } + + #[test] + fn unrelated_tiles_decode_in_parallel() { + let cache = DecodeCache::new(48); + let inside_decode = Barrier::new(2); + std::thread::scope(|scope| { + // Both decoders must reach the barrier without holding the cache lock. + let a = scope.spawn(|| { + cache.get_or_decode(key(8), || { + inside_decode.wait(); + Some(tile()) + }) + }); + let b = scope.spawn(|| { + cache.get_or_decode(key(9), || { + inside_decode.wait(); + Some(tile()) + }) + }); + assert!(a.join().unwrap().is_some()); + assert!(b.join().unwrap().is_some()); + }); + } + + #[test] + fn failed_decode_can_be_retried() { + let cache = DecodeCache::new(48); + assert!(cache.get_or_decode(key(8), || None).is_none()); + assert!(cache.get_or_decode(key(8), || Some(tile())).is_some()); + assert!(cache.state.lock().unwrap().flights.is_empty()); + } + + #[test] + fn dense_decodes_over_budget_are_served_but_not_retained() { + let cache = DecodeCache::new(48); + let calls = AtomicUsize::new(0); + for _ in 0..2 { + let decoded = cache.get_or_decode(key(1), || { + calls.fetch_add(1, Ordering::SeqCst); + let geometry = Vec::<[f32; 2]>::with_capacity((16 << 20) / 8 + 1); + let feature = crate::model::Feature::new( + None, crate::model::GeomType::Polygon, vec![geometry], vec![], + ); + Some(Tile { layers: vec![crate::model::Layer::from_tables( + "dense".into(), 4096, vec![], vec![], vec![feature], + )] }) + }); + assert!(decoded.is_some()); + } + assert_eq!(calls.load(Ordering::SeqCst), 2); + assert_eq!(cache.state.lock().unwrap().entries.len(), 0); + } + + #[test] + fn decoded_cache_stays_bounded() { + let cache = DecodeCache::new(2); + for x in 0..3 { + cache.get_or_decode(key(x), || Some(tile())); + } + let mut state = cache.state.lock().unwrap(); + assert_eq!(state.entries.len(), 2); + assert!(state.entries.get(&key(0)).is_none()); + } +} diff --git a/crates/mvt-render/src/expr.rs b/crates/mvt-render/src/expr.rs index 0e87dac..b87430c 100644 --- a/crates/mvt-render/src/expr.rs +++ b/crates/mvt-render/src/expr.rs @@ -157,6 +157,54 @@ impl<'a> Ctx<'a> { } } +/// Every operator [`eval`] handles. +/// +/// Kept beside the match it describes, and checked against `mvt-style`'s +/// copy by a test: that crate tells apps at compile time which expressions +/// will not survive the raster path, and a list that drifts from this one +/// turns that promise into a lie in whichever direction it drifted. +pub const HANDLED_OPS: &[&str] = &[ + "literal", + "get", + "has", + "geometry-type", + "id", + "zoom", + "interpolate", + "interpolate-hcl", + "interpolate-lab", + "step", + "match", + "case", + "coalesce", + "==", + "!=", + "<", + ">", + "<=", + ">=", + "all", + "any", + "none", + "!", + "in", + "!in", + "!has", + "to-number", + "to-boolean", + "to-string", + "concat", + "rgb", + "rgba", + "+", + "-", + "*", + "/", + "%", + "min", + "max", +]; + /// Evaluates an expression, a legacy function object, or a literal. pub fn eval(json: &Json, ctx: &Ctx) -> Val { match json { @@ -737,3 +785,33 @@ mod tests { assert!(Val::Str("x".into()).truthy()); } } + +#[cfg(test)] +mod shared_with_mvt_style { + /// `mvt-style` warns an app when a rule uses an expression this renderer + /// cannot evaluate. Its list is a copy of [`super::HANDLED_OPS`] plus the + /// interpolation types, which are arguments to `interpolate` rather than + /// operators in their own right. + #[test] + fn the_two_lists_of_operators_agree() { + let here: std::collections::BTreeSet<&str> = super::HANDLED_OPS.iter().copied().collect(); + let there: std::collections::BTreeSet<&str> = + mvt_style::RASTER_SUPPORTED_OPS.iter().copied().collect(); + let interpolation: std::collections::BTreeSet<&str> = + ["linear", "exponential", "cubic-bezier"] + .into_iter() + .collect(); + + let missing: Vec<&&str> = here.difference(&there).collect(); + assert!( + missing.is_empty(), + "mvt-style will warn about operators this renderer does handle: {missing:?}" + ); + let extra: Vec<&str> = there.difference(&here).copied().collect(); + let extra: std::collections::BTreeSet<&str> = extra.into_iter().collect(); + assert_eq!( + extra, interpolation, + "mvt-style promises operators this renderer does not handle" + ); + } +} diff --git a/crates/mvt-render/src/lib.rs b/crates/mvt-render/src/lib.rs index 5b50d2d..8d41b2d 100644 --- a/crates/mvt-render/src/lib.rs +++ b/crates/mvt-render/src/lib.rs @@ -34,15 +34,30 @@ //! [`Renderer::unsupported_layer_types`] reports layer types nothing here //! draws at all: raster, hillshade, fill-extrusion. +// A host may request the nearest raster grid while its vector basemap uses +// the lower source level. Scope this override to each native call/thread. +thread_local! { + static EXTRA_SOURCE_ZOOM_OFFSET: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +pub fn set_thread_source_zoom_offset(offset: u8) -> u8 { + EXTRA_SOURCE_ZOOM_OFFSET.with(|current| current.replace(offset.min(8))) +} + +mod clock; pub mod color; pub mod dash; +mod decode_cache; +mod memory_cache; pub mod expr; pub mod filter; pub mod glyphs; pub mod mercator; pub mod model; pub mod mvt; +pub mod query; pub mod raster; +mod seams; pub mod sprite; pub mod style; pub mod symbol; @@ -55,9 +70,10 @@ pub mod tile; pub mod fixtures; pub use color::Color; +pub use query::{FeatureHit, FeatureQuery}; pub use raster::{DecodedTile, RenderStats}; pub use style::{Layer, LayerKind, Source, Style, StyleError}; -pub use tile::{plan_tile, SourceTileRequest, TilePlan}; +pub use tile::{plan_tile, plan_tile_at, SourceTileRequest, TilePlan}; /// Tile size used when the caller does not specify one. Matches the SDK cores' /// `RasterLayerSource.DEFAULT_TILE_SIZE`. @@ -114,11 +130,40 @@ pub struct Renderer { glyphs: std::sync::RwLock, /// Neighbouring tiles decoded for their labels, kept so the ring around a /// tile is decoded once between the nine tiles that want it. - label_tiles: std::sync::RwLock)>>, + label_tiles: decode_cache::DecodeCache, + /// Source tiles decoded in full, kept for the same reason one level up: + /// every child of a magnified tile decodes the whole of its ancestor, and + /// two levels down that is sixteen decodes of one tile. + source_tiles: decode_cache::DecodeCache, + /// Whole source tiles triangulated at a magnified scale, keyed by source + /// tile, output level and pixel size. A tile drawn two levels down from + /// its source shares that source with fifteen siblings; each used to + /// walk and triangulate the source again for its own sixteenth. + #[cfg(feature = "tessellate")] + tessellations: + std::sync::RwLock>>, /// The style's sprite sheet, once the host has fetched it. One sheet /// covers the whole style, so unlike glyphs there is nothing to accumulate /// -- it is either loaded or it is not. sprites: std::sync::RwLock>, + /// How many dp of screen one tile covers where these tiles are shown. + /// + /// Not the same as the pixel size a render is asked for: a label pass is + /// drawn at twice the pixels and scaled down, and a host may serve retina + /// tiles. What this fixes is the **size the style draws at**, because a + /// style's `text-size` and `line-width` are screen units: + /// + /// ```text + /// paint scale = output pixels / display dp + /// style zoom = tile level + log2(display dp / 512) + /// ``` + /// + /// 512 is the usual answer and the default -- a raster source declared at + /// 512 shows level z at map zoom z+1, which is the pairing the second line + /// encodes. ArcGIS's 3D SceneView is the reason this is a knob: it puts a + /// tile in a 256 dp slot whatever pixel size it was given, so a renderer + /// that assumes 512 draws every label and every road at half size there. + display_tile_size: u32, } /// One `{fontstack}/{range}.pbf` the renderer needs and does not have. @@ -155,6 +200,21 @@ type LabelTileKey = (String, u8, u32, u32); /// wants the eight around it, so this is about two screens' worth of ring. const LABEL_TILE_CACHE: usize = 48; +/// Fully decoded source tiles kept. Far fewer than the label cache because a +/// full decode holds every layer: a street-zoom tile is megabytes, and the +/// working set is the handful of ancestors a screen magnifies from. +const SOURCE_TILE_CACHE: usize = 8; + +/// Magnified triangulations kept. One per source tile per output level; a +/// screen magnifying from a handful of ancestors needs that handful. +#[cfg(feature = "tessellate")] +const TESSELLATION_CACHE: usize = 12; + +/// (source, z, x, y) of the source tile, then the output level and pixel size +/// the triangulation was made for. Scale follows from the two levels. +#[cfg(feature = "tessellate")] +type TessellationKey = (String, u8, u32, u32, u8, u32, u8); + /// Where a style's sprite sheet lives: an index and the image it indexes. #[derive(Debug, Clone, PartialEq, Eq)] pub struct SpriteRequest { @@ -167,8 +227,12 @@ impl Renderer { Ok(Renderer { style: Style::parse(style_json)?, glyphs: std::sync::RwLock::new(text::GlyphStore::new()), - label_tiles: std::sync::RwLock::new(Vec::new()), + label_tiles: decode_cache::DecodeCache::new(LABEL_TILE_CACHE), + source_tiles: decode_cache::DecodeCache::new(SOURCE_TILE_CACHE), + #[cfg(feature = "tessellate")] + tessellations: std::sync::RwLock::new(memory_cache::MemoryCache::new(TESSELLATION_CACHE, 32 << 20)), sprites: std::sync::RwLock::new(None), + display_tile_size: DEFAULT_TILE_SIZE, }) } @@ -176,11 +240,87 @@ impl Renderer { Renderer { style, glyphs: std::sync::RwLock::new(text::GlyphStore::new()), - label_tiles: std::sync::RwLock::new(Vec::new()), + label_tiles: decode_cache::DecodeCache::new(LABEL_TILE_CACHE), + source_tiles: decode_cache::DecodeCache::new(SOURCE_TILE_CACHE), + #[cfg(feature = "tessellate")] + tessellations: std::sync::RwLock::new(memory_cache::MemoryCache::new(TESSELLATION_CACHE, 32 << 20)), sprites: std::sync::RwLock::new(None), + display_tile_size: DEFAULT_TILE_SIZE, } } + /// Tells the renderer how many dp one tile covers on screen. + /// + /// Hosts that serve tiles of a declared size pass that size; the default + /// of 512 is what a raster source declared at 512 gets. See + /// [`Renderer::display_tile_size`] for why it is not the pixel count. + pub fn with_display_tile_size(mut self, dp: u32) -> Renderer { + self.set_display_tile_size(dp); + self + } + + pub fn set_display_tile_size(&mut self, dp: u32) { + self.display_tile_size = dp.max(1); + self.forget_tessellations(); + } + + /// Triangulations depend on the style and on the paint scale; both of + /// those changing means starting over. + fn forget_tessellations(&self) { + #[cfg(feature = "tessellate")] + if let Ok(mut cache) = self.tessellations.write() { + cache.clear(); + } + } + + #[cfg(feature = "tessellate")] + fn cached_tessellation( + &self, + key: &TessellationKey, + ) -> Option> { + self.tessellations.write().ok()?.get(key) + } + + #[cfg(feature = "tessellate")] + fn remember_tessellation( + &self, + key: TessellationKey, + t: std::sync::Arc, + ) { + let Ok(mut cache) = self.tessellations.write() else { + return; + }; + let cost = t.vertices.capacity() * std::mem::size_of::() + + t.batches.capacity() * std::mem::size_of::() + + std::mem::size_of::() + key.0.capacity(); + cache.insert(key, t, cost); + } + + /// dp of screen one tile covers. See the field for the two rules it sets. + pub fn display_tile_size(&self) -> u32 { + self.display_tile_size + } + + // A finer target grid is a subdivision of the canonical vector tile. + // Its crop keeps the parent's style zoom and magnified paint sizes. + fn effective_display_tile_size(&self) -> f64 { + self.display_tile_size as f64 / 2f64.powi(EXTRA_SOURCE_ZOOM_OFFSET.with(|current| current.get()) as i32) + } + + /// The zoom the style is read at for a tile of this level. + /// + /// A tile is drawn for the zoom it will be *shown* at, which is its level + /// plus however many doublings its display size is above 256 dp, less the + /// one that 512 already carries. + fn style_zoom(&self, z: u8) -> f64 { + z as f64 + (self.effective_display_tile_size() / DEFAULT_TILE_SIZE as f64).log2() + } + + /// Style units to pixels, for a render asked for at `tile_size` pixels. + fn paint_scale(&self, tile_size: u32) -> f32 { + tile_size as f32 / self.effective_display_tile_size() as f32 + } + /// The style's `glyphs` URL template, if it has one. pub fn glyphs_url_template(&self) -> Option<&str> { self.style.glyphs.as_deref() @@ -217,6 +357,9 @@ impl Renderer { /// Whether this tile has to be rasterised on the CPU. /// + /// Complex visible polygons use the scanline rasteriser to avoid expensive + /// GPU triangulation. Small tiles skip the geometry decode for this check. + /// /// The GPU path draws flat-coloured triangles, and a patterned fill needs /// an image repeated across the polygon, so a tile that has one is drawn /// the slower way rather than drawn wrong. @@ -227,14 +370,14 @@ impl Renderer { /// minzoom on any of them, so every tile at every zoom would have taken /// the slow path for three layers that appear on almost none of them. /// - /// The test is whether the tile carries the layer at all, and it is made + /// The pattern test is whether the tile carries the layer at all, and it is made /// by looking for the layer's name in the undecoded bytes -- a name that /// happens to appear as a property value costs that one tile the slower /// path, which is a better trade than decoding every tile twice to find /// out. pub fn needs_cpu(&self, z: u8, tile_data: &[Option<&[u8]>]) -> bool { - let zoom = z as f64; - self.style + let zoom = self.style_zoom(z); + let patterned = self.style .layers .iter() .filter(|layer| { @@ -251,7 +394,42 @@ impl Renderer { .iter() .flatten() .any(|bytes| contains(bytes, name.as_bytes())) - }) + }); + if patterned { + return true; + } + + // Earcut's hole joining can take seconds for large forest polygons. + // The scanline rasteriser handles the same geometry without that + // triangulation. Ordinary tiles keep the GPU path; decode only inputs + // big enough to contain a polygon over the vertex budget. + const MAX_GPU_POLYGON_VERTICES: usize = 16_384; + let fills: Vec<_> = self.style.layers.iter().filter(|layer| { + layer.kind == LayerKind::Fill && layer.is_visible() && layer.applies_at(zoom) + }).collect(); + if fills.is_empty() { + return false; + } + for bytes in tile_data.iter().flatten() { + if bytes.len() < MAX_GPU_POLYGON_VERTICES * 2 { + continue; + } + let Ok(tile) = mvt::decode(bytes) else { continue }; + for layer in &fills { + let Some(data) = layer.source_layer.as_deref().and_then(|name| tile.layer(name)) else { + continue; + }; + let filter = filter::CompiledFilter::compile(layer.filter.as_ref(), data, zoom); + if data.features.iter().any(|feature| { + feature.geom_type == model::GeomType::Polygon + && feature.parts.iter().map(Vec::len).sum::() > MAX_GPU_POLYGON_VERTICES + && filter.matches(data, feature) + }) { + return true; + } + } + } + false } pub fn has_sprite(&self) -> bool { @@ -303,7 +481,7 @@ impl Renderer { let Ok(store) = self.glyphs.read() else { return Vec::new(); }; - symbol::needed_glyphs(&self.style, z as f64, &decoded, &store) + symbol::needed_glyphs(&self.style, self.style_zoom(z), &decoded, &store) } pub fn style(&self) -> &Style { @@ -318,6 +496,7 @@ impl Renderer { /// recolouring cheap — no refetch, just a re-rasterise. pub fn set_style(&mut self, style_json: &str) -> Result<(), RenderError> { self.style = Style::parse(style_json)?; + self.forget_tessellations(); Ok(()) } @@ -344,6 +523,16 @@ impl Renderer { .collect() } + /// Credits the style's sources ask to be shown, in layer order. + /// + /// The host has to display these. A style is data under someone's licence + /// -- the sample basemap draws OpenStreetMap, which requires the credit -- + /// and this renderer reads that credit out of the style and then hands it + /// on rather than dropping it on the floor. May contain HTML. + pub fn attributions(&self) -> Vec { + self.style.attributions() + } + /// Layer `type` values present in the style that this renderer will skip. pub fn unsupported_layer_types(&self) -> Vec { self.style.unsupported_layer_types() @@ -361,7 +550,112 @@ impl Renderer { /// The source tiles needed to draw `z/x/y`, in the order the bytes must be /// passed back to [`Renderer::render`]. pub fn plan(&self, z: u8, x: u32, y: u32) -> TilePlan { - plan_tile(&self.style, z, x, y) + plan_tile_at( + &self.style, + z, + x, + y, + self.style_zoom(z), + self.source_zoom_offset(), + ) + } + + /// How many levels shallower than the tile its data is read from. + /// + /// A tile shown across 256 dp sits where a 512 dp host would show the + /// level above it, so it has to be drawn from that level's data as well as + /// its paint -- otherwise two hosts looking at the same ground draw + /// different maps, because a vector source's content changes by level. + /// + /// Never negative: sourcing *deeper* than the tile would mean fetching + /// four tiles for one, which is not what magnification does, and a host + /// that shows tiles larger than 512 dp is better served by the level it + /// was given. + fn source_zoom_offset(&self) -> u8 { + let levels = (DEFAULT_TILE_SIZE as f64 / self.display_tile_size as f64).log2(); + let base = if levels <= 0.0 { 0 } else { levels.round() as u8 }; + base.saturating_add(EXTRA_SOURCE_ZOOM_OFFSET.with(|current| current.get())) + } + + /// Draws the whole source tile that `z/x/y` reads from, at the resolution + /// its children are drawn at, in one go. + /// + /// When a tile is magnified from a shallower level, every child of that + /// source repeats the same work: decode it (cached), walk its features + /// through the window, tessellate, rasterise its share. Sixteen children + /// of one source do that sixteen times for one tile's worth of data. This + /// draws it once, over a surface `2^offset` tiles across, which the host + /// can cut into the tiles the map asked for. + /// + /// Returns the pixmap and how many tiles it is across. When nothing is + /// magnified the block is one tile and this is the ordinary path. + #[doc(hidden)] + pub fn render_source_block( + &self, + z: u8, + x: u32, + y: u32, + tile_size: u32, + tile_data: &[Option<&[u8]>], + ) -> Result<(tiny_skia::Pixmap, u32), RenderError> { + let across = self.source_zoom_offset(); + let block_z = z.saturating_sub(across); + let block_x = x >> across; + let block_y = y >> across; + let tiles_across = 1u32 << across; + let out_size = tile_size + .checked_mul(tiles_across) + .ok_or(RenderError::BadTileSize(tile_size))?; + if out_size == 0 || out_size > 8192 { + return Err(RenderError::BadTileSize(out_size)); + } + + // The block is its own source tile drawn whole, so no magnification -- + // but the style is read at the *child's* zoom, which is what makes the + // pieces match the tiles they replace. + let plan = plan_tile_at( + &self.style, + block_z, + block_x, + block_y, + self.style_zoom(z), + 0, + ); + let decoded = self.decode_plan(&plan, tile_data); + let store = self + .glyphs + .read() + .map_err(|_| RenderError::Decode("glyph store poisoned".into()))?; + let sprites = self + .sprites + .read() + .map_err(|_| RenderError::Decode("sprite sheet poisoned".into()))?; + let (pixmap, _) = raster::render_with_glyphs( + &self.style, + self.style_zoom(z), + out_size, + self.effective_display_tile_size() as f32 * tiles_across as f32, + &decoded, + &store, + sprites.as_ref(), + (block_x, block_y), + ) + .ok_or(RenderError::BadTileSize(out_size))?; + Ok((pixmap, tiles_across)) + } + + /// The plan a block render needs: the source tile whole, not a quadrant. + #[doc(hidden)] + pub fn plan_source_block(&self, z: u8, x: u32, y: u32) -> TilePlan { + let across = self.source_zoom_offset(); + plan_tile_at( + &self.style, + z.saturating_sub(across), + x >> across, + y >> across, + self.style_zoom(z), + 0, + ) } /// Rasterises `z/x/y` and returns PNG bytes. @@ -417,8 +711,9 @@ impl Renderer { } let plan = self.plan(z, x, y); - let decode_started = std::time::Instant::now(); - let mut decoded = Vec::with_capacity(plan.requests.len()); + let decode_started = crate::clock::Stopwatch::start(); + let mut decoded: Vec<(&SourceTileRequest, std::sync::Arc)> = + Vec::with_capacity(plan.requests.len()); for (request, data) in plan .requests .iter() @@ -434,27 +729,43 @@ impl Renderer { if request.labels_only { continue; } - let Ok(tile) = mvt::decode(bytes) else { + // Decoded once per source tile, not once per child of it: the + // same cache the CPU path reads. + let key = (request.source_id.clone(), request.z, request.x, request.y); + let Some(tile) = self + .source_tiles + .get_or_decode(key, || mvt::decode(bytes).ok()) + else { continue; }; decoded.push((request, tile)); } - let decode_ms = decode_started.elapsed().as_secs_f32() * 1000.0; + let decode_ms = decode_started.ms(); - let views: Vec> = decoded - .iter() - .map(|(request, tile)| tessellate::DecodedTile { - source_id: &request.source_id, - tile, - scale: request.scale, - offset_x: request.offset_x, - offset_y: request.offset_y, - }) - .collect(); - - let tessellate_started = std::time::Instant::now(); - let result = tessellate::tessellate(&self.style, z as f64, tile_size, &views); - let tessellate_ms = tessellate_started.elapsed().as_secs_f32() * 1000.0; + let tessellate_started = crate::clock::Stopwatch::start(); + let magnified = !decoded.is_empty() && decoded.iter().all(|(r, _)| r.scale > 1.0); + let result = if magnified { + self.tessellate_from_sources(z, tile_size, &decoded) + } else { + let views: Vec> = decoded + .iter() + .map(|(request, tile)| tessellate::DecodedTile { + source_id: &request.source_id, + tile, + scale: request.scale, + offset_x: request.offset_x, + offset_y: request.offset_y, + }) + .collect(); + tessellate::tessellate( + &self.style, + self.style_zoom(z), + tile_size, + self.effective_display_tile_size() as f32, + &views, + ) + }; + let tessellate_ms = tessellate_started.ms(); let mut packed = Vec::with_capacity(13 + result.batches.len() * 2 + result.vertices.len()); packed.push(result.extent as f32); @@ -478,6 +789,123 @@ impl Renderer { Ok(packed) } + /// A magnified tile cut out of whole-source-tile triangulations. + /// + /// Each source tile is triangulated once at this level's scale (offset + /// zero, no window) and kept in [`Renderer::tessellations`]; a child is + /// then that triangulation moved into place and clipped, which is a pass + /// over the vertices instead of over the features. Sources are merged in + /// style layer order, since a batch is one style layer and a style reads + /// each layer from exactly one source. + #[cfg(feature = "tessellate")] + fn tessellate_from_sources( + &self, + z: u8, + tile_size: u32, + decoded: &[(&SourceTileRequest, std::sync::Arc)], + ) -> tessellate::Tessellation { + let zoom = self.style_zoom(z); + let mut parts: Vec<(tessellate::Tessellation, f32)> = Vec::with_capacity(decoded.len()); + for (request, tile) in decoded { + let key = ( + request.source_id.clone(), + request.z, + request.x, + request.y, + z, + tile_size, + EXTRA_SOURCE_ZOOM_OFFSET.with(|current| current.get()), + ); + let whole = match self.cached_tessellation(&key) { + Some(whole) => whole, + None => { + let view = tessellate::DecodedTile { + source_id: &request.source_id, + tile, + scale: request.scale, + offset_x: 0.0, + offset_y: 0.0, + }; + let whole = std::sync::Arc::new(tessellate::tessellate_with( + &self.style, + zoom, + tile_size, + self.effective_display_tile_size() as f32, + std::slice::from_ref(&view), + false, + )); + self.remember_tessellation(key, whole.clone()); + whole + } + }; + // The child's origin, in the whole tile's own extent: `project` + // subtracts `offset * extent` before scaling. + let whole_extent = whole.extent as f32; + let dx = -request.offset_x * whole_extent * request.scale; + let dy = -request.offset_y * whole_extent * request.scale; + parts.push(( + tessellate::translated_clip(&whole, 1.0, dx, dy, whole_extent, whole_extent / 64.0), + request.scale, + )); + } + if parts.len() == 1 { + return parts.pop().map(|(t, _)| t).unwrap_or_default(); + } + // Several sources: unify extents to the first one's and interleave the + // batches in style order. + let extent = parts.first().map(|(t, _)| t.extent).unwrap_or(4096).max(1); + let order: std::collections::HashMap<&str, usize> = self + .style + .layers + .iter() + .enumerate() + .map(|(i, layer)| (layer.id.as_str(), i)) + .collect(); + let mut merged = tessellate::Tessellation { + extent, + background: parts.iter().find_map(|(t, _)| t.background), + ..Default::default() + }; + let mut batches: Vec<(usize, usize, usize)> = Vec::new(); // (layer order, part, batch) + for (pi, (part, _)) in parts.iter().enumerate() { + for (bi, batch) in part.batches.iter().enumerate() { + batches.push(( + order + .get(batch.layer_id.as_str()) + .copied() + .unwrap_or(usize::MAX), + pi, + bi, + )); + } + } + batches.sort_by_key(|(o, pi, bi)| (*o, *pi, *bi)); + for (_, pi, bi) in batches { + let (part, _) = &parts[pi]; + let batch = &part.batches[bi]; + let unify = extent as f32 / part.extent.max(1) as f32; + let first = batch.first_vertex as usize * tessellate::VERTEX_STRIDE; + let end = first + batch.vertex_count as usize * tessellate::VERTEX_STRIDE; + let start = merged.vertices.len(); + for v in part.vertices[first..end].chunks_exact(tessellate::VERTEX_STRIDE) { + merged.vertices.extend_from_slice(&[ + v[0] * unify, + v[1] * unify, + v[2], + v[3], + v[4], + v[5], + ]); + } + merged.batches.push(tessellate::Batch { + first_vertex: (start / tessellate::VERTEX_STRIDE) as u32, + vertex_count: batch.vertex_count, + layer_id: batch.layer_id.clone(), + }); + } + merged + } + /// Same as [`Renderer::render`] but returns the pixmap, for callers that /// want raw RGBA or their own encoder. pub fn render_pixmap( @@ -549,8 +977,9 @@ impl Renderer { }; raster::render_with_glyphs( &self.style, - z as f64, + self.style_zoom(z), tile_size, + self.effective_display_tile_size() as f32, &decoded, store_ref, sprites_ref, @@ -575,11 +1004,11 @@ impl Renderer { let Ok(sprites) = self.sprites.read() else { return Vec::new(); }; - let paint_scale = tile_size as f32 / 512.0; + let paint_scale = self.paint_scale(tile_size); let labels = symbol::collect( &self.style, symbol::Target { - zoom: z as f64, + zoom: self.style_zoom(z), tile_size, paint_scale, xy: (0, 0), @@ -589,28 +1018,14 @@ impl Renderer { sprites.as_ref(), ); symbol::place(labels) + .into_iter() + .filter(|label| symbol::touches_tile(label.ink_bounds, tile_size)) + .collect::>() .iter() .map(|label| label.paint.size) .collect() } - fn cached_label_tile(&self, key: &LabelTileKey) -> Option> { - let cache = self.label_tiles.read().ok()?; - cache - .iter() - .find(|(cached, _)| cached == key) - .map(|(_, tile)| tile.clone()) - } - - fn remember_label_tile(&self, key: LabelTileKey, tile: std::sync::Arc) { - let Ok(mut cache) = self.label_tiles.write() else { - return; - }; - cache.retain(|(cached, _)| cached != &key); - cache.insert(0, (key, tile)); - cache.truncate(LABEL_TILE_CACHE); - } - /// Every label this tile places, with what it says and where it lands. /// For working out why one is missing. #[doc(hidden)] @@ -630,11 +1045,11 @@ impl Renderer { }; let plan = self.plan(z, x, y); let decoded = self.decode_plan_labels(&plan, tile_data); - let paint_scale = tile_size as f32 / 512.0; + let paint_scale = self.paint_scale(tile_size); let labels = symbol::collect( &self.style, symbol::Target { - zoom: z as f64, + zoom: self.style_zoom(z), tile_size, paint_scale, xy: (x, y), @@ -644,6 +1059,9 @@ impl Renderer { sprites.as_ref(), ); symbol::place(labels) + .into_iter() + .filter(|label| symbol::touches_tile(label.ink_bounds, tile_size)) + .collect::>() .into_iter() .map(|label| (label.content, label.bounds, label.sort_key)) .collect() @@ -685,16 +1103,16 @@ impl Renderer { return Ok(0); } - let t0 = std::time::Instant::now(); + let t0 = crate::clock::Stopwatch::start(); let plan = self.plan(z, x, y); let decoded = self.decode_plan_labels(&plan, tile_data); - let decode_ms = t0.elapsed().as_secs_f32() * 1000.0; - let t1 = std::time::Instant::now(); - let paint_scale = tile_size as f32 / 512.0; + let decode_ms = t0.ms(); + let t1 = crate::clock::Stopwatch::start(); + let paint_scale = self.paint_scale(tile_size); let labels = symbol::collect( &self.style, symbol::Target { - zoom: z as f64, + zoom: self.style_zoom(z), tile_size, paint_scale, xy: (x, y), @@ -703,8 +1121,11 @@ impl Renderer { &store, sprites.as_ref(), ); - let placed = symbol::place(labels); - let collect_ms = t1.elapsed().as_secs_f32() * 1000.0; + let placed = symbol::place(labels) + .into_iter() + .filter(|label| symbol::touches_tile(label.ink_bounds, tile_size)) + .collect::>(); + let collect_ms = t1.ms(); if placed.is_empty() { return Ok(0); } @@ -716,20 +1137,220 @@ impl Renderer { // premultiplied data and rejects anything else, and a readback whose // alpha is saturated is both at once. Copying sidesteps having to // prove that at the type level for a buffer that came from GL. - let t2 = std::time::Instant::now(); + let t2 = crate::clock::Stopwatch::start(); pixmap.data_mut().copy_from_slice(rgba); symbol::draw(&mut pixmap, &placed, &store, sprites.as_ref()); rgba.copy_from_slice(pixmap.data()); if std::env::var("MVT_PROFILE").is_ok() { eprintln!( "LABELPHASE decode={decode_ms:.1} collect={collect_ms:.1} draw={:.1} n={}", - t2.elapsed().as_secs_f32() * 1000.0, + t2.ms(), placed.len() ); } Ok(placed.len()) } + /// Draws labels onto a fresh transparent pixmap and returns its native + /// premultiplied RGBA storage. + /// + /// Android's split label layer uses this instead of passing a 4 MiB Java + /// byte array in and out. The returned Vec can be exposed as a direct + /// buffer and handed straight to the PNG encoder. + pub fn render_labels_rgba( + &self, + z: u8, + x: u32, + y: u32, + tile_size: u32, + tile_data: &[Option<&[u8]>], + ) -> Result<(Vec, usize), RenderError> { + if tile_size == 0 || tile_size > 8192 { + return Err(RenderError::BadTileSize(tile_size)); + } + let store = self + .glyphs + .read() + .map_err(|_| RenderError::Decode("glyph store poisoned".into()))?; + let sprites = self + .sprites + .read() + .map_err(|_| RenderError::Decode("sprite sheet poisoned".into()))?; + if store.is_empty() && sprites.is_none() { + return Ok((Vec::new(), 0)); + } + + let plan = self.plan(z, x, y); + let decoded = self.decode_plan_labels(&plan, tile_data); + let paint_scale = self.paint_scale(tile_size); + let labels = symbol::collect( + &self.style, + symbol::Target { + zoom: self.style_zoom(z), + tile_size, + paint_scale, + xy: (x, y), + }, + &decoded, + &store, + sprites.as_ref(), + ); + let placed = symbol::place(labels) + .into_iter() + .filter(|label| symbol::touches_tile(label.ink_bounds, tile_size)) + .collect::>(); + if placed.is_empty() { + return Ok((Vec::new(), 0)); + } + + let Some(mut pixmap) = tiny_skia::Pixmap::new(tile_size, tile_size) else { + return Err(RenderError::BadTileSize(tile_size)); + }; + symbol::draw(&mut pixmap, &placed, &store, sprites.as_ref()); + Ok((pixmap.take(), placed.len())) + } + + /// The same as [`Renderer::render_labels_rgba`], encoded as a PNG. + /// + /// For hosts with no encoder of their own. A browser would otherwise have + /// to round-trip the pixels through a canvas to get an image a raster layer + /// can load, which costs more than the drawing did. + /// + /// The PNG carries **straight** alpha, unlike the raw buffer: encoding + /// un-premultiplies, which is what stops every halo coming out grey. An + /// empty result means nothing was placed and the caller should serve one + /// shared transparent tile. + pub fn render_labels_png( + &self, + z: u8, + x: u32, + y: u32, + tile_size: u32, + tile_data: &[Option<&[u8]>], + ) -> Result<(Vec, usize), RenderError> { + let (pixels, placed) = self.render_labels_rgba(z, x, y, tile_size, tile_data)?; + if placed == 0 || pixels.is_empty() { + return Ok((Vec::new(), placed)); + } + let pixmap = tiny_skia::Pixmap::from_vec( + pixels, + tiny_skia::IntSize::from_wh(tile_size, tile_size) + .ok_or(RenderError::BadTileSize(tile_size))?, + ) + .ok_or(RenderError::BadTileSize(tile_size))?; + let png = pixmap + .encode_png() + .map_err(|e| RenderError::Decode(format!("label tile would not encode: {e}")))?; + Ok((png, placed)) + } + + /// The features in `z/x/y` that a query asks for. + /// + /// Tiles are passed as for [`Self::render`]. Only the tile's own + /// requests are read -- the neighbours fetched for label placement lie + /// outside the tile and are their own tiles' to report -- and a + /// magnified ancestor reports only the quadrant this tile shows. + /// + /// `filter_json` is a MapLibre filter expression; a string that will not + /// parse is an error rather than a silent match-all, because a search + /// that quietly returns everything looks exactly like one that worked. + #[allow(clippy::too_many_arguments)] + pub fn query_features( + &self, + z: u8, + x: u32, + y: u32, + tile_data: &[Option<&[u8]>], + source_layer: Option<&str>, + filter_json: Option<&str>, + text: Option<&str>, + limit: usize, + ) -> Result, RenderError> { + let filter = match filter_json { + Some(text) if !text.trim().is_empty() => Some( + serde_json::from_str::(text) + .map_err(|e| RenderError::Decode(format!("filter: {e}")))?, + ), + _ => None, + }; + let query = query::FeatureQuery { + source_layer: source_layer.map(str::to_owned), + filter, + text: text.filter(|t| !t.is_empty()).map(str::to_owned), + limit, + }; + let plan = self.plan(z, x, y); + let mut hits = Vec::new(); + for (request, data) in plan + .requests + .iter() + .zip(tile_data.iter().chain(std::iter::repeat(&None))) + { + if request.labels_only { + continue; + } + let Some(bytes) = data else { continue }; + if bytes.is_empty() { + continue; + } + let key = (request.source_id.clone(), request.z, request.x, request.y); + let Some(tile) = self + .source_tiles + .get_or_decode(key, || mvt::decode(bytes).ok()) + else { + continue; + }; + for layer in &tile.layers { + let extent = layer.extent as f32; + // The part of the source tile this target tile shows. + let span = extent / request.scale; + let window = [ + request.offset_x * extent, + request.offset_y * extent, + request.offset_x * extent + span, + request.offset_y * extent + span, + ]; + let frame = query::TileFrame { + z: request.z, + x: request.x, + y: request.y, + extent: layer.extent, + }; + query::find_in_layer( + &request.source_id, + layer, + frame, + window, + &query, + plan.zoom, + &mut hits, + ); + if query.limit > 0 && hits.len() >= query.limit { + return Ok(hits); + } + } + } + Ok(hits) + } + + /// [`Self::query_features`] as the JSON the bindings hand across. + #[allow(clippy::too_many_arguments)] + pub fn query_features_json( + &self, + z: u8, + x: u32, + y: u32, + tile_data: &[Option<&[u8]>], + source_layer: Option<&str>, + filter_json: Option<&str>, + text: Option<&str>, + limit: usize, + ) -> Result { + let hits = + self.query_features(z, x, y, tile_data, source_layer, filter_json, text, limit)?; + Ok(query::hits_to_json(&hits)) + } + /// Decodes whatever arrived for a plan, in the plan's own order. fn decode_plan(&self, plan: &TilePlan, tile_data: &[Option<&[u8]>]) -> Vec { self.decode_plan_inner(plan, tile_data, false) @@ -773,26 +1394,31 @@ impl Renderer { // skip it and draw whatever else arrived. let tile = if request.labels_only || labels_only_everywhere { let key = (request.source_id.clone(), request.z, request.x, request.y); - match self.cached_label_tile(&key) { - Some(tile) => tile, - None => { - let wanted = symbol_layers - .get(&request.source_id) - .cloned() - .unwrap_or_default(); - let Ok(tile) = mvt::decode_only(bytes, &wanted) else { - continue; - }; - let tile = std::sync::Arc::new(tile); - self.remember_label_tile(key, tile.clone()); - tile - } - } + let Some(tile) = self.label_tiles.get_or_decode(key, || { + let wanted = symbol_layers + .get(&request.source_id) + .cloned() + .unwrap_or_default(); + mvt::decode_only(bytes, &wanted).ok() + }) else { + continue; + }; + tile } else { - let Ok(tile) = mvt::decode(bytes) else { + // Cached like the label decode above, and for the same reason + // magnified: every child of a source tile decodes the whole of + // it, so a tile drawn two levels down is decoded sixteen times + // for sixteen tiles that then each draw a sixteenth of it. + // ArcGIS's 3D view asks for around 120 tiles to fill an iPad, + // and they come from a handful of ancestors. + let key = (request.source_id.clone(), request.z, request.x, request.y); + let Some(tile) = self + .source_tiles + .get_or_decode(key, || mvt::decode(bytes).ok()) + else { continue; }; - std::sync::Arc::new(tile) + tile }; decoded.push(DecodedTile { source_id: request.source_id.clone(), @@ -840,6 +1466,219 @@ mod tests { .unwrap() } + /// The two rules the display size sets, and what they were before it + /// existed: a tile displayed at 512 dp reads the style at its own level + /// and draws one style unit per pixel. + #[test] + fn display_size_sets_the_style_zoom_and_the_paint_scale() { + let default = Renderer::new(MINIMAL_STYLE).expect("style"); + assert_eq!(default.display_tile_size(), 512); + assert_eq!(default.style_zoom(12), 12.0); + assert_eq!(default.paint_scale(512), 1.0); + // The label pass asks for twice the pixels and is scaled back down. + assert_eq!(default.paint_scale(1024), 2.0); + + // Half the dp is half the ground per tile, so the level that fills it + // is one deeper -- and the style has to be read one zoom shallower for + // the same view. ArcGIS's SceneView is the case this exists for. + let small = Renderer::new(MINIMAL_STYLE) + .expect("style") + .with_display_tile_size(256); + assert_eq!(small.style_zoom(12), 11.0); + assert_eq!(small.paint_scale(256), 1.0); + assert_eq!(small.paint_scale(512), 2.0); + + // Whatever the size, a tile drawn at its own dp draws style units at + // face value. Getting this wrong is what made every label half height. + for dp in [256u32, 512, 1024] { + let renderer = Renderer::new(MINIMAL_STYLE) + .expect("style") + .with_display_tile_size(dp); + assert_eq!(renderer.paint_scale(dp), 1.0, "dp={dp}"); + } + } + + /// A 256 dp host is shown the level above's map, so it has to be drawn + /// from the level above's data too. Getting this wrong is invisible in the + /// sizes and obvious in the content: at zoom 6.5 the z=6 tile carries the + /// 新潟市 label and z=7 does not, so the two hosts drew different maps. + #[test] + fn a_smaller_display_sources_one_level_shallower() { + let style = r#"{ + "version": 8, + "sources": { + "s": { "type": "vector", "tiles": ["https://example.test/{z}/{x}/{y}.pbf"], "maxzoom": 14 } + }, + "layers": [ + { "id": "l", "type": "line", "source": "s", "source-layer": "roads" } + ] + }"#; + let at512 = Renderer::new(style).expect("style"); + assert_eq!(at512.source_zoom_offset(), 0); + let urls: Vec<_> = at512 + .plan(7, 113, 50) + .requests + .iter() + .map(|r| r.url.clone()) + .collect(); + assert!( + urls[0].contains("/7/113/50"), + "512 dp draws its own level: {urls:?}" + ); + + let at256 = Renderer::new(style) + .expect("style") + .with_display_tile_size(256); + assert_eq!(at256.source_zoom_offset(), 1); + let request = &at256.plan(7, 113, 50).requests[0]; + assert!( + request.url.contains("/6/56/25"), + "256 dp draws the parent: {}", + request.url + ); + // Magnified, and placed in the quadrant the tile came from. + assert_eq!(request.scale, 2.0); + assert_eq!(request.offset_x, 0.5); + assert_eq!(request.offset_y, 0.0); + } + + #[test] + fn source_detail_override_matches_parent_style_and_is_thread_local() { + let renderer = Renderer::new(r#"{"version":8,"sources":{},"layers":[]}"#) + .unwrap().with_display_tile_size(256); + let style_zoom = renderer.style_zoom(8); + let paint_scale = renderer.paint_scale(768); + let previous = set_thread_source_zoom_offset(1); + assert_eq!(renderer.source_zoom_offset(), 2); + assert_eq!(renderer.style_zoom(8), style_zoom - 1.0); + assert_eq!(renderer.paint_scale(768), paint_scale * 2.0); + std::thread::spawn(|| { + let other = Renderer::new(r#"{"version":8,"sources":{},"layers":[]}"#) + .unwrap().with_display_tile_size(256); + assert_eq!(other.source_zoom_offset(), 1); + }).join().unwrap(); + assert_eq!(set_thread_source_zoom_offset(previous), 1); + assert_eq!(renderer.source_zoom_offset(), 1); + assert_eq!(renderer.style_zoom(8), style_zoom); + assert_eq!(renderer.paint_scale(768), paint_scale); + } + + #[test] + fn rounded_target_is_a_crop_of_the_canonical_vector_tile() { + let style = r##"{"version":8,"sources":{"s":{"type":"vector","tiles":["https://tiles.test/{z}/{x}/{y}"]}}, + "layers":[{"id":"road","type":"line","source":"s","source-layer":"roads", + "maxzoom":6.5,"paint":{"line-color":"#ff0000","line-width":["interpolate",["linear"],["zoom"],6,4,7,20]}}]}"##; + let bytes = fixtures::encode_tile(&[fixtures::LayerSpec { + name: "roads".into(), extent: 4096, + features: vec![fixtures::FeatureSpec { + geom: fixtures::Geom::Line, + points: vec![(0, 512), (4096, 512)], props: vec![], + }], + }]); + let reference = Renderer::new(style).unwrap(); + let (parent, _) = reference.render_pixmap(6, 0, 0, 1024, &[Some(&bytes)]).unwrap(); + let target = Renderer::new(style).unwrap().with_display_tile_size(256); + let previous = set_thread_source_zoom_offset(1); + let child = target.render_pixmap(8, 0, 0, 256, &[Some(&bytes)]).unwrap().0; + set_thread_source_zoom_offset(previous); + let expected = parent.clone_rect(tiny_skia::IntRect::from_xywh(0, 0, 256, 256).unwrap()).unwrap(); + assert!(expected.pixels().iter().any(|p| p.alpha() > 0)); + assert_eq!(child.data(), expected.data(), "style visibility and line size must match the parent crop"); + #[cfg(feature = "tessellate")] + { + let previous = set_thread_source_zoom_offset(1); + let packed = target.tessellate_packed(8, 0, 0, 256, &[Some(&bytes)]).unwrap(); + set_thread_source_zoom_offset(previous); + let vertices = &packed[13 + packed[6] as usize * 2..]; + assert!(!vertices.is_empty()); + let ys: Vec<_> = vertices.chunks_exact(tessellate::VERTEX_STRIDE).map(|v| v[1]).collect(); + let low = ys.iter().copied().fold(f32::INFINITY, f32::min); + let high = ys.iter().copied().fold(f32::NEG_INFINITY, f32::max); + assert!(((high - low) * 256.0 / packed[0] - 8.0).abs() < 0.01, + "GPU line width must match the CPU parent's eight pixels"); + } + } + + #[test] + fn rounded_target_labels_match_the_parent_crop() { + let style = serde_json::json!({ + "version":8, "glyphs":"https://tiles.test/{fontstack}/{range}.pbf", + "sources":{"s":{"type":"vector","tiles":["https://tiles.test/{z}/{x}/{y}"]}}, + "layers":[{"id":"label","type":"symbol","source":"s","source-layer":"place", "maxzoom":6.5, + "layout":{"text-field":"{name}","text-font":["F"], + "text-size":["interpolate",["linear"],["zoom"],6,20,7,40]}}] + }).to_string(); + let bytes = fixtures::encode_tile(&[fixtures::LayerSpec { + name:"place".into(), extent:4096, + features:vec![fixtures::FeatureSpec { + geom:fixtures::Geom::Point,points:vec![(512,512)],props:vec![("name".into(),"A".into())], + }], + }]); + let reference = Renderer::new(&style).unwrap(); + let target = Renderer::new(&style).unwrap().with_display_tile_size(256); + let glyphs = glyph_pbf("F", &[0x41]); + reference.add_glyphs(&glyphs).unwrap(); + target.add_glyphs(&glyphs).unwrap(); + let (parent, count) = reference.render_labels_rgba(6, 0, 0, 1024, &[Some(&bytes)]).unwrap(); + assert!(count > 0); + let previous = set_thread_source_zoom_offset(1); + let child = target.render_labels_rgba(8, 0, 0, 256, &[Some(&bytes)]).unwrap().0; + set_thread_source_zoom_offset(previous); + let expected: Vec = (0..256).flat_map(|row| parent[row * 1024 * 4..row * 1024 * 4 + 256 * 4].iter().copied()).collect(); + assert_eq!(child, expected, "glyph sizes and placement must match the parent crop"); + } + + /// A label still lands when the tile is drawn from its parent's data. + /// + /// Sourcing a level shallower is the same magnification the renderer does + /// past a source's maxzoom, and the label pass has to follow the request's + /// scale and offset like the geometry does. The geometry path had a test + /// for that and the label path did not. + #[test] + fn labels_survive_being_sourced_from_the_parent() { + let style = Style::parse( + &serde_json::json!({ + "version": 8, + "glyphs": "https://example.test/{fontstack}/{range}.pbf", + "sources": { "s": { "type": "vector", "tiles": ["https://example.test/{z}/{x}/{y}.pbf"] } }, + "layers": [{ + "id": "labels", + "type": "symbol", + "source": "s", + "source-layer": "place", + "layout": { "text-field": "{name}", "text-font": ["F"], "text-size": 20 }, + "paint": { "text-color": "#000000" } + }] + }) + .to_string(), + ) + .unwrap(); + let renderer = Renderer::from_style(style).with_display_tile_size(256); + renderer.add_glyphs(&glyph_pbf("F", &[0x41])).unwrap(); + + // A point in the parent's north-west quadrant, which is the child + // tile (z+1, 2x, 2y) the plan will magnify into. + let tile = fixtures::encode_tile(&[fixtures::LayerSpec { + name: "place".into(), + extent: 4096, + features: vec![fixtures::FeatureSpec { + geom: fixtures::Geom::Point, + points: vec![(1024, 1024)], + props: vec![("name".into(), "A".into())], + }], + }]); + + let plan = renderer.plan(11, 1024, 600); + assert_eq!(plan.requests[0].z, 10, "sources the parent: {plan:?}"); + let placed = renderer + .render_labels_rgba(11, 1024, 600, 256, &[Some(&tile)]) + .expect("draws a label tile") + .1; + assert!(placed > 0, "the parent's label was lost when magnified"); + } + + const MINIMAL_STYLE: &str = r#"{"version":8,"sources":{},"layers":[]}"#; + fn render_sample(tile_size: u32) -> tiny_skia::Pixmap { let renderer = Renderer::from_style(test_style()); let data = fixtures::sample_tile(); @@ -941,6 +1780,40 @@ mod tests { } #[test] + /// Four children of one source tile, drawn for a 256 dp host, triangulate + /// that source once and cut the rest. + #[cfg(feature = "tessellate")] + #[test] + fn magnified_children_share_one_triangulation() { + let renderer = Renderer::from_style(test_style()).with_display_tile_size(256); + let bytes = fixtures::sample_tile(); + let mut lengths = Vec::new(); + for (x, y) in [(0u32, 0u32), (1, 0), (0, 1), (1, 1)] { + let plan = renderer.plan(1, x, y); + let data: Vec> = plan + .requests + .iter() + .map(|r| { + if r.labels_only { + None + } else { + Some(bytes.as_slice()) + } + }) + .collect(); + let packed = renderer + .tessellate_packed(1, x, y, 256, &data) + .expect("packed"); + lengths.push(packed.len()); + } + assert!( + lengths.iter().all(|&n| n > 13), + "children drew nothing: {lengths:?}" + ); + let cached = renderer.tessellations.read().unwrap().len(); + assert_eq!(cached, 1, "one source tile, one triangulation"); + } + fn set_style_recolors_without_new_tile_data() { let mut renderer = Renderer::from_style(test_style()); let data = fixtures::sample_tile(); @@ -1030,7 +1903,14 @@ mod tests { labels_only: false, }]; - let (pixmap, stats) = raster::render(&test_style(), 10.0, 512, &decoded).expect("render"); + let (pixmap, stats) = raster::render( + &test_style(), + 10.0, + 512, + raster::REFERENCE_TILE_SIZE, + &decoded, + ) + .expect("render"); assert_eq!(stats.features_drawn, 1); let (r, g, b, _) = pixel(&pixmap, 100, 100); @@ -1148,6 +2028,34 @@ mod tests { ); } + #[test] + fn complex_visible_polygons_use_the_scanline_renderer() { + let points: Vec<_> = (0..16_385).map(|i| { + let angle = i as f64 * std::f64::consts::TAU / 16_385.0; + ((angle.cos() * 1_000_000.0) as i32, (angle.sin() * 1_000_000.0) as i32) + }).collect(); + let bytes = fixtures::encode_tile(&[fixtures::LayerSpec { + name: "land".into(), extent: 4096, + features: vec![fixtures::FeatureSpec { + geom: fixtures::Geom::Polygon, points, + props: vec![("kind".into(), "forest".into())], + }], + }]); + let style = Style::from_json(&serde_json::json!({"version":8, + "sources":{"src":{"type":"vector","tiles":["https://t/{z}/{x}/{y}.pbf"]}}, + "layers":[{"id":"forest","type":"fill","source":"src","source-layer":"land", + "minzoom":12,"filter":["==","kind","forest"],"paint":{"fill-color":"#00ff00"}}]})).unwrap(); + let renderer = Renderer::from_style(style.clone()); + assert!(renderer.needs_cpu(13, &[Some(&bytes)])); + assert!(!renderer.needs_cpu(10, &[Some(&bytes)]), "not visible at this zoom"); + let mut hidden = style.clone(); + hidden.layers[0].layout.insert("visibility".into(), serde_json::json!("none")); + assert!(!Renderer::from_style(hidden).needs_cpu(13, &[Some(&bytes)])); + let mut unmatched = style; + unmatched.layers[0].filter = Some(serde_json::json!(["==","kind","water"])); + assert!(!Renderer::from_style(unmatched).needs_cpu(13, &[Some(&bytes)]), "unmatched geometry must not force CPU"); + } + /// A patterned fill draws the sheet's icon rather than the flat colour /// underneath it. #[test] @@ -1475,8 +2383,20 @@ mod tests { }], }]); - // A field of opaque red, as the GPU would leave it. + // The standalone label route must be byte-for-byte identical to + // compositing onto a caller-provided transparent buffer. let size = 128u32; + let mut transparent = vec![0; (size * size * 4) as usize]; + let transparent_count = renderer + .draw_labels_onto(10, 512, 300, size, &mut transparent, &[Some(&tile)]) + .expect("draws onto transparent pixels"); + let (direct, direct_count) = renderer + .render_labels_rgba(10, 512, 300, size, &[Some(&tile)]) + .expect("draws a standalone label tile"); + assert_eq!(direct_count, transparent_count); + assert_eq!(direct, transparent); + + // A field of opaque red, as the GPU would leave it. let mut rgba = Vec::with_capacity((size * size * 4) as usize); for _ in 0..(size * size) { rgba.extend_from_slice(&[255, 0, 0, 255]); @@ -1511,6 +2431,100 @@ mod tests { assert!(renderer.render(10, 0, 0, 100_000, &[]).is_err()); } + /// The tile fixture has one cafe at (3072, 1024) of 4096: three quarters + /// east, a quarter down. Searching for it must find it, place it there, + /// and report its properties; searching for something else must not. + #[test] + fn finds_features_by_text_layer_and_filter() { + let renderer = Renderer::from_style(test_style()); + let tile = crate::fixtures::sample_tile(); + let tiles: Vec> = vec![Some(&tile)]; + + let hits = renderer + .query_features(0, 0, 0, &tiles, None, None, Some("CAFE"), 0) + .expect("query"); + assert_eq!(hits.len(), 1, "{hits:?}"); + let cafe = &hits[0]; + assert_eq!(cafe.layer, "poi"); + assert_eq!(cafe.geom_type, model::GeomType::Point); + assert_eq!( + cafe.properties, + vec![("class".to_string(), model::Value::String("cafe".into()))] + ); + // Three quarters of the way east across the world, a quarter down. + assert!((cafe.lon - 90.0).abs() < 1e-6, "lon {}", cafe.lon); + assert!(cafe.lat > 60.0 && cafe.lat < 70.0, "lat {}", cafe.lat); + + // By layer alone. + let roads = renderer + .query_features(0, 0, 0, &tiles, Some("roads"), None, None, 0) + .expect("query"); + assert_eq!(roads.len(), 2); + assert!(roads + .iter() + .all(|h| h.geom_type == model::GeomType::LineString)); + + // By filter expression. + let motorway = renderer + .query_features( + 0, + 0, + 0, + &tiles, + None, + Some(r#"["==", ["get", "class"], "motorway"]"#), + None, + 0, + ) + .expect("query"); + assert_eq!(motorway.len(), 1); + assert_eq!(motorway[0].layer, "roads"); + + // No such thing. + let none = renderer + .query_features(0, 0, 0, &tiles, None, None, Some("bakery"), 0) + .expect("query"); + assert!(none.is_empty()); + + // A filter that is not JSON is an error, not everything. + assert!(renderer + .query_features(0, 0, 0, &tiles, None, Some("not json"), None, 0) + .is_err()); + + // And as JSON for the bindings. + let json = renderer + .query_features_json(0, 0, 0, &tiles, Some("poi"), None, None, 0) + .expect("json"); + let parsed: serde_json::Value = serde_json::from_str(&json).expect("parses"); + assert_eq!(parsed[0]["layer"], "poi"); + assert_eq!(parsed[0]["properties"]["class"], "cafe"); + assert_eq!(parsed[0]["geometry"], "Point"); + } + + /// A magnified ancestor reports only the quadrant the child shows. + #[test] + fn magnified_tiles_report_their_own_quadrant() { + let renderer = Renderer::from_style(test_style()); + let tile = crate::fixtures::sample_tile(); + // z15 is past the source's maxzoom 14, so every z15 tile reads a z14 + // tile. Take the four children of z14 0/0 -- wait, the plan carries + // the offsets; the cafe at (0.75, 0.25) of the ancestor sits in the + // north-east child. + let ne = renderer.plan(15, 1, 0); + assert!(ne.requests[0].scale > 1.0); + let tiles: Vec> = ne.requests.iter().map(|_| Some(tile.as_slice())).collect(); + let hits = renderer + .query_features(15, 1, 0, &tiles, Some("poi"), None, None, 0) + .expect("query"); + assert_eq!(hits.len(), 1, "north-east child holds the cafe"); + let sw = renderer.plan(15, 0, 1); + let tiles: Vec> = sw.requests.iter().map(|_| Some(tile.as_slice())).collect(); + let hits = renderer + .query_features(15, 0, 1, &tiles, Some("poi"), None, None, 0) + .expect("query"); + assert!(hits.is_empty(), "south-west child does not: {hits:?}"); + } + #[test] fn plan_matches_the_positional_render_contract() { let renderer = Renderer::from_style(test_style()); diff --git a/crates/mvt-render/src/memory_cache.rs b/crates/mvt-render/src/memory_cache.rs new file mode 100644 index 0000000..18c85b0 --- /dev/null +++ b/crates/mvt-render/src/memory_cache.rs @@ -0,0 +1,168 @@ +//! Byte-budgeted LRU for expensive native tile data. +use std::collections::{BTreeMap, HashMap}; +use std::hash::Hash; +use std::time::Duration; + +/// When an entry was last used. +/// +/// `std::time::Instant::now()` panics on `wasm32-unknown-unknown` (see +/// `clock.rs`). There the clock reads as one fixed moment, so nothing is ever +/// idle long enough to expire and the cache evicts by count and bytes alone. +#[cfg(not(target_arch = "wasm32"))] +type Instant = std::time::Instant; + +#[cfg(target_arch = "wasm32")] +#[derive(Debug, Clone, Copy)] +struct Instant; + +#[cfg(target_arch = "wasm32")] +impl Instant { + fn now() -> Instant { + Instant + } + + fn duration_since(&self, _earlier: Instant) -> Duration { + Duration::ZERO + } +} + +#[derive(Debug)] +struct Entry { + value: V, + cost: usize, + accessed: Instant, + order: u64, +} + +#[derive(Debug)] +pub(crate) struct MemoryCache { + entries: HashMap>, + order: BTreeMap, + sequence: u64, + bytes: usize, + byte_limit: usize, + count_limit: usize, + idle_ttl: Duration, +} + +impl MemoryCache { + pub(crate) fn new(count_limit: usize, byte_limit: usize) -> Self { + Self { + entries: HashMap::new(), + order: BTreeMap::new(), + sequence: 0, + bytes: 0, + byte_limit, + count_limit, + idle_ttl: Duration::from_secs(120), + } + } + + fn remove_oldest(&mut self) { + if let Some((_, key)) = self.order.pop_first() { + if let Some(entry) = self.entries.remove(&key) { + self.bytes -= entry.cost; + } + } + } + + fn expire(&mut self, now: Instant) { + while let Some((_, key)) = self.order.first_key_value() { + let Some(entry) = self.entries.get(key) else { + break; + }; + if now.duration_since(entry.accessed) < self.idle_ttl { + break; + } + self.remove_oldest(); + } + } + + pub(crate) fn get(&mut self, key: &K) -> Option { + self.get_at(key, Instant::now()) + } + + fn get_at(&mut self, key: &K, now: Instant) -> Option { + self.expire(now); + let entry = self.entries.get_mut(key)?; + self.order.remove(&entry.order); + self.sequence += 1; + entry.order = self.sequence; + entry.accessed = now; + self.order.insert(entry.order, key.clone()); + Some(entry.value.clone()) + } + + pub(crate) fn insert(&mut self, key: K, value: V, cost: usize) { + self.expire(Instant::now()); + if let Some(entry) = self.entries.remove(&key) { + self.bytes -= entry.cost; + self.order.remove(&entry.order); + } + // A single dense tile must not displace the entire working set. + if cost > self.byte_limit || self.count_limit == 0 { + return; + } + while self.entries.len() >= self.count_limit || self.bytes > self.byte_limit - cost { + self.remove_oldest(); + } + self.sequence += 1; + self.bytes += cost; + self.order.insert(self.sequence, key.clone()); + self.entries.insert( + key, + Entry { + value, + cost, + accessed: Instant::now(), + order: self.sequence, + }, + ); + } + + pub(crate) fn clear(&mut self) { + self.entries.clear(); + self.order.clear(); + self.bytes = 0; + } + + #[cfg(test)] + pub(crate) fn len(&self) -> usize { + self.entries.len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn evicts_by_bytes_and_refreshes_on_hit() { + let mut cache = MemoryCache::new(10, 10); + cache.insert(1, "one", 4); + cache.insert(2, "two", 4); + assert_eq!(cache.get(&1), Some("one")); + cache.insert(3, "three", 4); + assert_eq!(cache.get(&2), None); + assert_eq!(cache.get(&1), Some("one")); + assert_eq!(cache.bytes, 8); + cache.insert(4, "oversize", 11); + assert_eq!(cache.bytes, 8); + assert_eq!(cache.get(&4), None); + } + + #[test] + fn enforces_count_replacement_and_idle_expiry() { + let mut cache = MemoryCache::new(2, 100); + cache.insert(1, "old", 20); + cache.insert(1, "new", 30); + assert_eq!(cache.bytes, 30); + cache.insert(2, "two", 10); + cache.insert(3, "three", 10); + assert_eq!(cache.get(&1), None); + assert_eq!(cache.len(), 2); + let now = Instant::now() + Duration::from_secs(121); + assert_eq!(cache.get_at(&3, now), None); + assert_eq!(cache.bytes, 0); + } +} diff --git a/crates/mvt-render/src/model.rs b/crates/mvt-render/src/model.rs index 9e5e16e..ee705aa 100644 --- a/crates/mvt-render/src/model.rs +++ b/crates/mvt-render/src/model.rs @@ -105,6 +105,11 @@ impl Feature { self.props.len() / 2 } + /// `[min_x, min_y, max_x, max_y]` in the tile's own units. + pub fn bounds(&self) -> [f32; 4] { + self.bbox + } + /// The feature's (name index, value index) pairs, in storage order. pub fn prop_pairs(&self) -> impl Iterator + '_ { self.props.chunks_exact(2).map(|pair| (pair[0], pair[1])) @@ -179,6 +184,50 @@ pub struct Tile { } impl Tile { + /// Heap capacity retained by a decoded tile, including geometry and tables. + pub(crate) fn memory_cost(&self) -> usize { + use std::mem::size_of; + size_of::() + + self.layers.capacity() * size_of::() + + self + .layers + .iter() + .map(|layer| { + layer.name.capacity() + + layer.features.capacity() * size_of::() + + layer + .features + .iter() + .map(|f| { + f.props.capacity() * size_of::() + + f.parts.capacity() * size_of::>() + + f.parts + .iter() + .map(|p| p.capacity() * size_of::<[f32; 2]>()) + .sum::() + }) + .sum::() + + layer.property_names.capacity() * size_of::() + + layer + .property_names + .iter() + .map(|s| s.capacity()) + .sum::() + + layer.values.capacity() * size_of::() + + layer + .values + .iter() + .map(|v| match v { + Value::String(s) => s.capacity(), + _ => 0, + }) + .sum::() + + layer.name_index.capacity() * (size_of::<(String, u32)>() + 1) + + layer.name_index.keys().map(|s| s.capacity()).sum::() + }) + .sum::() + } + pub fn layer(&self, name: &str) -> Option<&Layer> { self.layers.iter().find(|l| l.name == name) } diff --git a/crates/mvt-render/src/mvt.rs b/crates/mvt-render/src/mvt.rs index 3f72834..a7097fc 100644 --- a/crates/mvt-render/src/mvt.rs +++ b/crates/mvt-render/src/mvt.rs @@ -164,6 +164,7 @@ fn decode_filtered(buf: &[u8], wanted: Option<&[String]>) -> Result { _ => reader.skip(wire)?, } } + crate::seams::align_shortbread(&mut tile); Ok(tile) } diff --git a/crates/mvt-render/src/query.rs b/crates/mvt-render/src/query.rs new file mode 100644 index 0000000..0a03957 --- /dev/null +++ b/crates/mvt-render/src/query.rs @@ -0,0 +1,219 @@ +//! Features in a tile, looked up rather than drawn. +//! +//! A vector tile is data with a picture in it, not the other way round: every +//! feature the renderer paints carries its properties and its shape. This +//! module reads them back out -- for "what is on screen", "what is under the +//! finger", or a search box -- so a host can answer those on any backend and +//! offline, from the same tiles it draws. +//! +//! Scope, on purpose: this walks the tiles it is handed. It is a viewport's +//! worth of features, not a gazetteer. Anything beyond a few dozen tiles +//! belongs in an index the host builds from packages. + +use crate::filter::CompiledFilter; +use crate::mercator; +use crate::model::{Feature, GeomType, Layer, Value}; +use serde_json::Value as Json; + +/// What to look for. +#[derive(Debug, Clone, Default)] +pub struct FeatureQuery { + /// Only this source-layer (`"pois"`, `"streets"`...). None: every layer. + pub source_layer: Option, + /// A MapLibre filter expression, as JSON. None: every feature. + pub filter: Option, + /// A case-insensitive substring that some string property must contain. + /// The common search box. None: no text test. + pub text: Option, + /// Stop after this many hits per tile. 0: no limit. + pub limit: usize, +} + +/// One feature found. +#[derive(Debug, Clone, PartialEq)] +pub struct FeatureHit { + pub source_id: String, + /// The source-layer the feature is in. + pub layer: String, + pub id: Option, + pub geom_type: GeomType, + /// Properties in the tile's own storage order. + pub properties: Vec<(String, Value)>, + /// A point to hang the feature on: the point itself, the middle vertex of + /// a line, the centre of a polygon's box. + pub lon: f64, + pub lat: f64, + /// West, south, east, north, degrees. + pub bounds: [f64; 4], +} + +/// Where a source tile's units land on the world: `(z, x, y, extent)`. +#[derive(Debug, Clone, Copy)] +pub struct TileFrame { + pub z: u8, + pub x: u32, + pub y: u32, + pub extent: u32, +} + +impl TileFrame { + /// Tile-local units to (lon, lat). + pub fn to_lonlat(&self, px: f32, py: f32) -> (f64, f64) { + let n = (1u64 << self.z) as f64; + let wx = (self.x as f64 + px as f64 / self.extent as f64) / n; + let wy = (self.y as f64 + py as f64 / self.extent as f64) / n; + (mercator::inv_merc_x(wx), mercator::inv_merc_y(wy)) + } +} + +/// Everything in one decoded layer that the query asks for. +/// +/// `window` is the part of the tile that belongs to the target: the whole +/// tile normally, one quadrant of a magnified ancestor. Features outside it +/// belong to a sibling and are that sibling's to report. +pub fn find_in_layer( + source_id: &str, + layer: &Layer, + frame: TileFrame, + window: [f32; 4], + query: &FeatureQuery, + zoom: f64, + out: &mut Vec, +) { + if let Some(wanted) = &query.source_layer { + if &layer.name != wanted { + return; + } + } + let filter = CompiledFilter::compile(query.filter.as_ref(), layer, zoom); + if filter.never_matches() { + return; + } + // The text test is folded over the layer's value table once, like a + // filter key: a feature then matches if any of its values does. + let text_truth: Option> = query.text.as_deref().map(|needle| { + let needle = needle.to_lowercase(); + layer + .values() + .iter() + .map(|value| match value { + Value::String(s) => s.to_lowercase().contains(&needle), + _ => false, + }) + .collect() + }); + let names = layer.property_names(); + let values = layer.values(); + for feature in &layer.features { + if query.limit > 0 && out.len() >= query.limit { + return; + } + if !feature.touches_window(window) { + continue; + } + if let Some(truth) = &text_truth { + let hit = feature + .prop_pairs() + .any(|(_, v)| truth.get(v as usize).copied().unwrap_or(false)); + if !hit { + continue; + } + } + if !filter.matches(layer, feature) { + continue; + } + let properties = feature + .prop_pairs() + .filter_map(|(k, v)| { + Some(( + names.get(k as usize)?.clone(), + values.get(v as usize)?.clone(), + )) + }) + .collect(); + let (px, py) = anchor(feature); + let (lon, lat) = frame.to_lonlat(px, py); + let b = feature.bounds(); + let (west, north) = frame.to_lonlat(b[0], b[1]); + let (east, south) = frame.to_lonlat(b[2], b[3]); + out.push(FeatureHit { + source_id: source_id.to_string(), + layer: layer.name.clone(), + id: feature.id, + geom_type: feature.geom_type, + properties, + lon, + lat, + bounds: [west, south, east, north], + }); + } +} + +/// The point a hit is reported at, in tile units. +fn anchor(feature: &Feature) -> (f32, f32) { + match feature.geom_type { + GeomType::LineString => { + let part = feature.parts.first().map(Vec::as_slice).unwrap_or(&[]); + if part.is_empty() { + let b = feature.bounds(); + return ((b[0] + b[2]) / 2.0, (b[1] + b[3]) / 2.0); + } + let mid = part[part.len() / 2]; + (mid[0], mid[1]) + } + GeomType::Point => feature + .parts + .first() + .and_then(|p| p.first()) + .map(|p| (p[0], p[1])) + .unwrap_or((0.0, 0.0)), + _ => { + let b = feature.bounds(); + ((b[0] + b[2]) / 2.0, (b[1] + b[3]) / 2.0) + } + } +} + +/// Hits as the JSON the bindings hand across: an array of objects with +/// `sourceId`, `layer`, `id`, `geometry`, `lon`, `lat`, `bounds` and +/// `properties`. +pub fn hits_to_json(hits: &[FeatureHit]) -> String { + let array: Vec = hits + .iter() + .map(|hit| { + let properties: serde_json::Map = hit + .properties + .iter() + .map(|(k, v)| (k.clone(), value_to_json(v))) + .collect(); + serde_json::json!({ + "sourceId": hit.source_id, + "layer": hit.layer, + "id": hit.id, + "geometry": match hit.geom_type { + GeomType::Point => "Point", + GeomType::LineString => "LineString", + GeomType::Polygon => "Polygon", + GeomType::Unknown => "Unknown", + }, + "lon": hit.lon, + "lat": hit.lat, + "bounds": hit.bounds, + "properties": properties, + }) + }) + .collect(); + Json::Array(array).to_string() +} + +fn value_to_json(value: &Value) -> Json { + match value { + Value::String(s) => Json::String(s.clone()), + Value::Float(f) => serde_json::Number::from_f64(*f) + .map(Json::Number) + .unwrap_or(Json::Null), + Value::Int(i) => Json::from(*i), + Value::Uint(u) => Json::from(*u), + Value::Bool(b) => Json::Bool(*b), + } +} diff --git a/crates/mvt-render/src/raster.rs b/crates/mvt-render/src/raster.rs index 30a6ac0..86c07b5 100644 --- a/crates/mvt-render/src/raster.rs +++ b/crates/mvt-render/src/raster.rs @@ -17,7 +17,12 @@ use crate::symbol; use crate::text::GlyphStore; /// The reference tile size the style spec's pixel units are defined against. -const REFERENCE_TILE_SIZE: f32 = 512.0; +/// dp of screen one tile covers, when the caller has not said otherwise. +/// +/// Style dimensions are screen units, so turning them into pixels needs both +/// the pixel count asked for and the size the tile will be shown at. 512 is +/// the usual answer -- see `Renderer::display_tile_size`. +pub const REFERENCE_TILE_SIZE: f32 = 512.0; /// A source tile that has been fetched and decoded, ready to draw. pub struct DecodedTile { @@ -46,12 +51,14 @@ pub fn render( style: &Style, zoom: f64, tile_size: u32, + display_tile_size: f32, decoded: &[DecodedTile], ) -> Option<(Pixmap, RenderStats)> { render_with_glyphs( style, zoom, tile_size, + display_tile_size, decoded, &GlyphStore::new(), None, @@ -101,13 +108,15 @@ pub fn render_with_glyphs( style: &Style, zoom: f64, tile_size: u32, + // dp the tile covers on screen; REFERENCE_TILE_SIZE when unknown. + display_tile_size: f32, decoded: &[DecodedTile], glyphs: &GlyphStore, sprites: Option<&crate::sprite::SpriteSheet>, tile_xy: (u32, u32), ) -> Option<(Pixmap, RenderStats)> { let mut pixmap = Pixmap::new(tile_size, tile_size)?; - let paint_scale = tile_size as f32 / REFERENCE_TILE_SIZE; + let paint_scale = tile_size as f32 / display_tile_size.max(1.0); let mut stats = RenderStats { layers_drawn: 0, features_drawn: 0, @@ -177,7 +186,12 @@ pub fn render_with_glyphs( ); // Two pixels of padding: labels that only just clear each other read // as one smear at small sizes. - let placed = symbol::place(labels); + // Placement ran over a tile of margin so that neighbouring tiles agree; + // only what reaches this tile is drawn. + let placed: Vec<_> = symbol::place(labels) + .into_iter() + .filter(|label| symbol::touches_tile(label.ink_bounds, tile_size)) + .collect(); if !placed.is_empty() { stats.layers_drawn += 1; stats.features_drawn += placed.len(); @@ -193,12 +207,35 @@ pub fn render_with_glyphs( /// The part of an overzoomed parent this tile shows, in the parent's units, /// with an eighth of the window as margin. `None` when the tile is its own. pub(crate) fn overzoom_window(decoded: &DecodedTile, extent: u32) -> Option<[f32; 4]> { + window_with_margin(decoded, extent, 0.125) +} + +/// As [`overzoom_window`], but a whole tile of margin on every side. +/// +/// Labels need it. A tile does not decide on its own which of them to draw: it +/// runs a collision contest, and a label that straddles a seam is drawn half by +/// each neighbour, so the two must reach the same verdict. They only do that if +/// they judge it against the same rivals -- which means each tile has to see a +/// whole tile beyond its own edge, exactly what the ring of neighbours gives an +/// unmagnified tile. +/// +/// An eighth of a tile was not enough and the difference was visible: 中野区 +/// sits 19 px east of the seam and 高円寺 40 px west of it. The east tile could +/// not see 高円寺, so it drew 中野区; the west tile saw both, gave the spot to +/// 高円寺, and drew nothing -- leaving 中野区 sliced down the middle at the +/// seam. Nothing was wrong at 512 dp because nothing was magnified there and +/// the window did not exist at all. +pub(crate) fn label_window(decoded: &DecodedTile, extent: u32) -> Option<[f32; 4]> { + window_with_margin(decoded, extent, 1.0) +} + +fn window_with_margin(decoded: &DecodedTile, extent: u32, margin_spans: f32) -> Option<[f32; 4]> { if decoded.scale <= 1.0 { return None; } let extent = extent as f32; let span = extent / decoded.scale; - let margin = span / 8.0; + let margin = span * margin_spans; Some([ decoded.offset_x * extent - margin, decoded.offset_y * extent - margin, @@ -231,7 +268,23 @@ fn draw_background(pixmap: &mut Pixmap, layer: &Layer, zoom: f64) { if color.a <= 0.0 { return; } - pixmap.fill(color.to_tiny_skia()); + if color.a >= 1.0 { + pixmap.fill(color.to_tiny_skia()); + return; + } + // Painted over what is already there rather than replacing it. A style may + // have more than one background layer -- the tessellator's copy of this + // says what real styles do with them -- and `fill` would drop whatever the + // ones below it put down. + let Some(rect) = + tiny_skia::Rect::from_xywh(0.0, 0.0, pixmap.width() as f32, pixmap.height() as f32) + else { + return; + }; + let mut paint = tiny_skia::Paint::default(); + paint.set_color(color.to_tiny_skia()); + paint.blend_mode = tiny_skia::BlendMode::SourceOver; + pixmap.fill_rect(rect, &paint, tiny_skia::Transform::identity(), None); } fn draw_data_layer( diff --git a/crates/mvt-render/src/seams.rs b/crates/mvt-render/src/seams.rs new file mode 100644 index 0000000..c94236a --- /dev/null +++ b/crates/mvt-render/src/seams.rs @@ -0,0 +1,172 @@ +//! Shortbread supplies the same road twice: buffered `streets` geometry and +//! unbuffered `street_labels` geometry. Independently quantized buffer endpoints +//! do not interpolate to the original edge vertex, visibly moving roads at high +//! overzoom. Restore that vertex from the unbuffered copy, matching an exact +//! interior vertex rather than guessing a grid or joining unrelated nearby roads. +use crate::model::{Feature, GeomType, Tile}; +use std::collections::HashMap; + +type References = HashMap<[i32; 2], Vec<[f32; 2]>>; + +pub(crate) fn align_shortbread(tile: &mut Tile) { + let Some(labels) = tile.layer("street_labels") else { + return; + }; + let extent = labels.extent as f32; + let mut references = References::new(); + for feature in &labels.features { + if feature.geom_type != GeomType::LineString { + continue; + } + for part in &feature.parts { + for pair in part.windows(2) { + for (edge, interior) in [(pair[0], pair[1]), (pair[1], pair[0])] { + if on_edge(edge, extent) && !on_edge(interior, extent) { + references.entry(key(interior)).or_default().push(edge); + } + } + } + } + } + let Some(streets) = tile + .layers + .iter_mut() + .find(|l| l.name == "streets" && l.extent as f32 == extent) + else { + return; + }; + for feature in &mut streets.features { + if feature.geom_type != GeomType::LineString { + continue; + } + let mut changed = false; + for part in &mut feature.parts { + let mut replacement: Option> = None; + for (index, pair) in part.windows(2).enumerate() { + let mut crossings = Vec::new(); + for axis in 0..2 { + for border in [0.0, extent] { + let a = pair[0]; + let b = pair[1]; + if !((a[axis] < border && b[axis] > border) + || (b[axis] < border && a[axis] > border)) + { + continue; + } + let t = (border - a[axis]) / (b[axis] - a[axis]); + let other = 1 - axis; + let crossing = a[other] + t * (b[other] - a[other]); + // An exact interior endpoint identifies the same source + // segment. A 1.5-unit bound tolerates endpoint rounding + // without borrowing a different segment's edge vertex. + let mut candidates = pair + .iter() + .filter_map(|p| references.get(&key(*p))) + .flatten() + .filter(|p| p[axis] == border && (p[other] - crossing).abs() <= 1.5); + if let Some(&point) = candidates.next() { + if candidates.all(|p| *p == point) { + crossings.push((t, point)); + } + } + } + } + if !crossings.is_empty() { + let output = replacement.get_or_insert_with(|| part[..=index].to_vec()); + crossings.sort_by(|a, b| a.0.total_cmp(&b.0)); + for (_, point) in crossings { + if output.last() != Some(&point) { + output.push(point); + } + } + } + if let Some(output) = &mut replacement { + output.push(pair[1]); + } + } + if let Some(output) = replacement { + *part = output; + changed = true; + } + } + if changed { + let tags = feature.prop_pairs().flat_map(|(k, v)| [k, v]).collect(); + *feature = Feature::new( + feature.id, + feature.geom_type, + std::mem::take(&mut feature.parts), + tags, + ); + } + } +} +fn on_edge(p: [f32; 2], extent: f32) -> bool { + p[0] == 0.0 || p[0] == extent || p[1] == 0.0 || p[1] == extent +} +fn key(p: [f32; 2]) -> [i32; 2] { + [p[0] as i32, p[1] as i32] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::Layer; + fn fixture(road: Vec<[f32; 2]>, label: Vec<[f32; 2]>) -> Tile { + let layer = |name: &str, part| { + Layer::from_tables( + name.into(), + 4096, + vec![], + vec![], + vec![Feature::new(None, GeomType::LineString, vec![part], vec![])], + ) + }; + Tile { + layers: vec![layer("streets", road), layer("street_labels", label)], + } + } + #[test] + fn restores_actual_shortbread_edge_vertices_including_rounding_disagreement() { + for (road, label, edge) in [ + ( + vec![[4160., 1638.], [3792., 1656.]], + vec![[4096., 1641.], [3792., 1656.]], + [4096., 1641.], + ), + ( + vec![[3772., 1836.], [4160., 1828.]], + vec![[3772., 1836.], [4096., 1830.]], + [4096., 1830.], + ), + ( + vec![[-64., 1921.], [195., 1915.]], + vec![[0., 1919.], [195., 1915.]], + [0., 1919.], + ), + ] { + let mut tile = fixture(road.clone(), label); + align_shortbread(&mut tile); + assert_eq!( + tile.layers[0].features[0].parts[0], + vec![road[0], edge, road[1]] + ); + } + } + #[test] + fn does_not_join_a_nearby_unrelated_road() { + let road = vec![[3772., 1836.], [4160., 1828.]]; + let mut tile = fixture(road.clone(), vec![[3771., 1836.], [4096., 1830.]]); + align_shortbread(&mut tile); + assert_eq!(tile.layers[0].features[0].parts[0], road); + } + #[test] + fn generic_tiles_and_interior_vertices_are_unchanged() { + let road = vec![[50., 50.], [100., 100.]]; + let mut tile = fixture(road.clone(), vec![[0., 100.], [100., 100.]]); + align_shortbread(&mut tile); + assert_eq!(tile.layers[0].features[0].parts[0], road); + tile.layers.pop(); + align_shortbread(&mut tile); + assert_eq!(tile.layers[0].features[0].parts[0], road); + } +} diff --git a/crates/mvt-render/src/style.rs b/crates/mvt-render/src/style.rs index 350367e..0553ca5 100644 --- a/crates/mvt-render/src/style.rs +++ b/crates/mvt-render/src/style.rs @@ -62,6 +62,13 @@ pub struct Source { pub url: Option, pub minzoom: u8, pub maxzoom: u8, + /// Credit the source asks to be shown wherever its data is drawn. + /// + /// The style spec allows HTML here, and every basemap style uses it -- the + /// text is a link to the licence. Kept verbatim rather than stripped: the + /// SDK's attribution overlay renders HTML, and a licence link that arrives + /// as plain text is a licence link nobody can follow. + pub attribution: Option, } impl Source { @@ -425,6 +432,34 @@ impl Style { out } + /// Credits owed for what this style draws, in the order the layers ask for + /// them. + /// + /// Only sources a drawable layer references, so a style that carries a + /// source it never draws does not credit it. Deliberately not filtered by + /// zoom, unlike [`Style::active_vector_sources`]: a credit that blinks out + /// as the user zooms is worse than one shown a little too often, and the + /// renderer draws a source's data above its `maxzoom` anyway by magnifying + /// the deepest tile it has. + pub fn attributions(&self) -> Vec { + let mut out: Vec = Vec::new(); + for layer in &self.layers { + if !layer.kind.is_drawable() || layer.kind == LayerKind::Background { + continue; + } + let Some(id) = layer.source.as_deref() else { + continue; + }; + let Some(text) = self.sources.get(id).and_then(|s| s.attribution.as_deref()) else { + continue; + }; + if !out.iter().any(|seen| seen == text) { + out.push(text.to_owned()); + } + } + out + } + /// Layer `type` values that were parsed but will not be drawn. pub fn unsupported_layer_types(&self) -> Vec { let mut kinds: Vec = self @@ -463,6 +498,11 @@ fn parse_source(id: &str, value: &Json) -> Source { url: get("url").and_then(Json::as_str).map(str::to_owned), minzoom: get("minzoom").and_then(Json::as_u64).unwrap_or(0) as u8, maxzoom: get("maxzoom").and_then(Json::as_u64).unwrap_or(14) as u8, + attribution: get("attribution") + .and_then(Json::as_str) + .map(str::trim) + .filter(|text| !text.is_empty()) + .map(str::to_owned), } } @@ -531,6 +571,103 @@ mod tests { assert_eq!(none.sprite, None); } + /// A style is data under someone's licence. The credit has to survive the + /// trip from the style into the renderer, or the host cannot show it. + #[test] + fn reads_the_credit_a_source_asks_for() { + let style = Style::from_json(&json!({ + "version": 8, + "sources": { + "osm": { + "type": "vector", + "tiles": ["https://example.com/{z}/{x}/{y}.pbf"], + "attribution": "© OpenStreetMap" + }, + "hills": { + "type": "vector", + "tiles": ["https://example.com/h/{z}/{x}/{y}.pbf"], + "attribution": " © Hills " + }, + "unused": { + "type": "vector", + "tiles": ["https://example.com/u/{z}/{x}/{y}.pbf"], + "attribution": "© Nobody" + }, + "bare": { "type": "vector", "tiles": ["https://example.com/b/{z}/{x}/{y}.pbf"] } + }, + "layers": [ + { "id": "bg", "type": "background" }, + { "id": "b", "type": "fill", "source": "bare", "source-layer": "x" }, + { "id": "h", "type": "fill", "source": "hills", "source-layer": "x" }, + { "id": "w", "type": "fill", "source": "osm", "source-layer": "water" }, + { "id": "l", "type": "symbol", "source": "osm", "source-layer": "place" } + ] + })) + .expect("parses"); + + // The HTML is kept whole -- the credit is a licence link, and the + // overlay that shows it renders HTML. + // Surrounding space is trimmed, an absent credit is not invented, and + // a source no drawable layer references is not credited. + assert_eq!( + style.attributions(), + vec![ + "© Hills".to_string(), + "© OpenStreetMap".to_string(), + ] + ); + } + + /// Two layers over one source must not credit it twice; the overlay would + /// print the same line side by side. + #[test] + fn credits_a_shared_source_once() { + let style = sample(); + assert!(style.attributions().is_empty(), "no source declares one"); + + let credited = Style::from_json(&json!({ + "version": 8, + "sources": { + "osm": { + "type": "vector", + "tiles": ["https://example.com/{z}/{x}/{y}.pbf"], + "attribution": "© OpenStreetMap" + } + }, + "layers": [ + { "id": "a", "type": "fill", "source": "osm", "source-layer": "water" }, + { "id": "b", "type": "line", "source": "osm", "source-layer": "roads" }, + { "id": "c", "type": "symbol", "source": "osm", "source-layer": "place" } + ] + })) + .expect("parses"); + assert_eq!(credited.attributions(), vec!["© OpenStreetMap".to_string()]); + } + + /// The renderer magnifies the deepest tile it has past a source's maxzoom, + /// so the data is still on screen there and still has to be credited. + #[test] + fn keeps_the_credit_past_the_source_maxzoom() { + let style = Style::from_json(&json!({ + "version": 8, + "sources": { + "osm": { + "type": "vector", + "tiles": ["https://example.com/{z}/{x}/{y}.pbf"], + "maxzoom": 14, + "attribution": "© OpenStreetMap" + } + }, + "layers": [{ + "id": "w", "type": "fill", "source": "osm", "source-layer": "water", + "minzoom": 4, "maxzoom": 12 + }] + })) + .expect("parses"); + // Well outside the layer's own zoom range, and past the source's. + assert_eq!(style.attributions().len(), 1); + } + fn sample() -> Style { Style::from_json(&json!({ "version": 8, diff --git a/crates/mvt-render/src/symbol.rs b/crates/mvt-render/src/symbol.rs index eff23bb..5a5c9cc 100644 --- a/crates/mvt-render/src/symbol.rs +++ b/crates/mvt-render/src/symbol.rs @@ -109,9 +109,21 @@ pub struct Label { /// Axis-aligned box the label will cover, for collision. Covers both the /// text and the icon when there are both. pub bounds: [f32; 4], + /// As [`Label::bounds`], but out to where the glyphs are actually drawn: + /// font bearings and the SDF border reach past the line height. + /// + /// Used for "does this label draw in this tile" and nothing else. A + /// neighbour's label whose layout box lies outside the tile can still put + /// ink inside it, and filtering on [`Label::bounds`] cut that ink off at + /// the seam. Collisions are judged on [`Label::bounds`], because growing + /// every box by the SDF border drops labels the style expects to fit. + pub ink_bounds: [f32; 4], /// The halves on their own, so a style that marks one of them optional can /// be given the other when the pair will not fit. pub text_bounds: Option<[f32; 4]>, + /// [`Label::text_bounds`] out to the ink, to rebuild [`Label::ink_bounds`] + /// when the icon half is dropped. + pub ink_text_bounds: Option<[f32; 4]>, pub icon_bounds: Option<[f32; 4]>, /// `icon-optional` / `text-optional`: which half may be dropped to save /// the other. A POI layer routinely says the pin is expendable and the @@ -196,7 +208,7 @@ pub fn needed_glyphs( continue; } - let window = crate::raster::overzoom_window(decoded_tile, data_layer.extent); + let window = crate::raster::label_window(decoded_tile, data_layer.extent); for feature in &data_layer.features { // Most of an overzoomed parent is nowhere near this tile. if let Some(window) = window { @@ -318,7 +330,7 @@ pub fn collect( continue; } - let window = crate::raster::overzoom_window(decoded_tile, data_layer.extent); + let window = crate::raster::label_window(decoded_tile, data_layer.extent); for feature in &data_layer.features { // Most of an overzoomed parent is nowhere near this tile. if let Some(window) = window { @@ -445,6 +457,23 @@ pub fn collect( // After the text, because `icon-text-fit` sizes the icon around // it: a shield is only as wide as the number it carries. let icon = icon_placement(layer, &ctx, sprites, paint_scale, spot, text_bounds); + // What the glyphs actually cover: font bearings and the SDF + // border reach past the line height the layout box measures. + // + // It decides one thing only -- whether the label draws in + // this tile, because a neighbour's label whose layout box + // lies outside it can still have ink inside. Collision + // stays on the layout box: judging contests by the ink box + // grows every label by the SDF border and drops labels the + // style expects to fit. Both tiles compute both boxes the + // same way, so they still reach the same verdict. + let ink_text_bounds = text_bounds.map(|nominal| { + if curve.is_some() { + nominal + } else { + text::ink_bounds(&shaped, store, &fonts, &paint, anchor, at) + } + }); // A symbol with neither its glyphs nor its icon yet is not a // label; it would reserve space for nothing and push its @@ -468,6 +497,7 @@ pub fn collect( let Some(bounds) = union(text_bounds, icon_bounds) else { continue; }; + let ink_bounds = union(ink_text_bounds, icon_bounds).unwrap_or(bounds); let allow_overlap = layer .layout_value("text-allow-overlap", &ctx) @@ -483,9 +513,27 @@ pub fn collect( if avoid_edges(layer, &ctx) && leaves_tile(bounds, tile_size) { continue; } - // Most of a neighbour's tile is of no interest here; what is, - // is the label near the shared edge that reaches across it. - if !touches_tile(bounds, tile_size) { + // Kept with a whole tile of margin, not just the ones that + // reach across the edge. + // + // The contest below is what decides which labels survive, + // and neighbouring tiles must reach the same verdict about + // a label that straddles their seam or it gets drawn by one + // of them and sliced down the middle. They only do that if + // they judge it against the same rivals, and a rival that + // does not itself touch this tile still decides the verdict: + // 高円寺 sits 40 px west of a seam and 中野区 19 px east of + // it, overlapping. The west tile saw both and gave the spot + // to 高円寺; the east tile could not see 高円寺 at all, so it + // drew 中野区 -- whose left 6 px belong to the west tile, + // which had drawn nothing there. + // + // A margin of one tile is exactly the data the ring of + // neighbours provides, so it costs no extra fetch, and + // anything further away cannot overlap a label that touches + // this tile. What does not touch the tile is dropped after + // placement instead of before it. + if !touches_tile_within(bounds, tile_size, tile_size as f32) { continue; } @@ -515,7 +563,9 @@ pub fn collect( at, icon, bounds, + ink_bounds, text_bounds, + ink_text_bounds, icon_bounds, icon_optional: layer .layout_value("icon-optional", &ctx) @@ -543,9 +593,14 @@ fn avoid_edges(layer: &Layer, ctx: &Ctx) -> bool { } /// Whether any of the label lands on this tile at all. -fn touches_tile(bounds: [f32; 4], tile_size: u32) -> bool { - let size = tile_size as f32; - bounds[2] > 0.0 && bounds[3] > 0.0 && bounds[0] < size && bounds[1] < size +pub(crate) fn touches_tile(bounds: [f32; 4], tile_size: u32) -> bool { + touches_tile_within(bounds, tile_size, 0.0) +} + +/// As [`touches_tile`], but against the tile grown by `margin` on every side. +fn touches_tile_within(bounds: [f32; 4], tile_size: u32, margin: f32) -> bool { + let size = tile_size as f32 + margin; + bounds[2] > -margin && bounds[3] > -margin && bounds[0] < size && bounds[1] < size } fn leaves_tile(bounds: [f32; 4], tile_size: u32) -> bool { @@ -873,6 +928,7 @@ pub fn place(labels: Vec