open-standards/map/map-rs/crates/map-pmtiles/src/lib.rs
Transaction Science 05eaef966d map-pmtiles: PMTiles v3 export (cloud-native tile ecosystem ingest)
The open geospatial stack converged on PMTiles — a single-file,
range-requestable archive served straight from object storage and read by
MapLibre / pmtiles CLIs / Overture tooling. map's "a tileset is a file,
not a service" is the same idea; emit the incumbent's format so map output
is consumable without a map-specific reader.

New map-pmtiles crate: a self-contained, round-trip-validated PMTiles v3
writer — Hilbert tile-id ordering (z=1 anchors + cross-zoom inverse
verified), the varint clustered directory (delta tile-ids, run-length,
consecutive-offset sentinel), and the 127-byte header. Directories and
metadata written uncompressed (internal compression = none), so no gzip
dep and deterministic bytes. read_tile() reads its own output back
byte-identical for validation.

from_map_tiles filters a tileset to its SURFACE tiles (alt 0, atemporal,
base LOD) — PMTiles' 2-D z/x/y grid can't carry map's altitude/time/LOD
axes (that 4-D coverage is GeoParquet's job). The S2 -> z/x/y projection
is a caller-supplied seam (it lives in map's substrate), keeping this
crate a pure serializer. Note: root-only directory (large tilesets need
leaf dirs); interop with the reference pmtiles libs is the next validation.

4 tests green, clippy clean. Deps: map-schema + serde_json only.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-25 17:13:49 -04:00

473 lines
15 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! PMTiles v3 export — the seam into the cloud-native tile ecosystem.
//!
//! The open geospatial stack converged on a single-file, range-requestable
//! archive: **PMTiles** (a `.pmtiles` blob served straight from object storage,
//! read by MapLibre, the `pmtiles` CLIs, and Overture tooling). map's "a tileset
//! is a file, not a service" doctrine is the same idea; this crate emits the
//! incumbent's format so map output is consumable without a map-specific reader.
//!
//! What's here is the **PMTiles v3 writer**, fully self-contained and
//! round-trip-validated: Hilbert tile-id ordering, the varint clustered
//! directory, and the 127-byte header. Directories and metadata are written
//! uncompressed (`internal compression = none`), so the crate needs no gzip
//! dependency and the bytes are deterministic.
//!
//! The map adapter ([`from_map_tiles`]) filters a tileset to its **surface**
//! tiles — `alt == 0`, atemporal, base LOD — because PMTiles' 2-D z/x/y grid
//! cannot carry map's altitude / time / LOD axes (that 4-D coverage is what
//! GeoParquet is for). The S2 → z/x/y projection is supplied by the caller (it
//! lives in map's substrate), keeping this crate a pure, testable serializer.
use map_schema::{CellKey, S2CellId, Tile};
use serde_json::Value;
/// A PMTiles tile type byte (header field).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum TileType {
/// Unknown / opaque.
Unknown = 0,
/// Mapbox Vector Tile.
Mvt = 1,
/// PNG.
Png = 2,
/// JPEG.
Jpeg = 3,
/// WebP.
Webp = 4,
}
impl TileType {
/// Best-effort tile type from a map codec tag.
pub fn from_codec(codec: &str) -> Self {
let c = codec.to_ascii_lowercase();
if c.starts_with("mvt") {
TileType::Mvt
} else if c.starts_with("png") {
TileType::Png
} else if c.starts_with("jpeg") || c.starts_with("jpg") {
TileType::Jpeg
} else if c.starts_with("webp") {
TileType::Webp
} else {
TileType::Unknown
}
}
}
/// One directory entry: a tile id, its byte range in the tile-data section, and
/// a run length (how many consecutive tile ids share these bytes).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Entry {
tile_id: u64,
offset: u64,
length: u64,
run_length: u64,
}
/// Write a PMTiles v3 archive from `(z, x, y, bytes)` tiles and a metadata JSON
/// value. Tiles are sorted into Hilbert order; the whole directory is the root
/// (suitable up to ~a few thousand tiles — large tilesets need leaf directories,
/// noted but not split here).
pub fn write(
tiles: impl IntoIterator<Item = (u8, u32, u32, Vec<u8>)>,
metadata: &Value,
tile_type: TileType,
) -> Vec<u8> {
let mut rows: Vec<(u8, u64, Vec<u8>)> =
tiles.into_iter().map(|(z, x, y, d)| (z, tile_id(z, x, y), d)).collect();
rows.sort_by_key(|(_, id, _)| *id);
let (mut min_zoom, mut max_zoom) = (u8::MAX, 0u8);
let mut tiledata: Vec<u8> = Vec::new();
let mut entries: Vec<Entry> = Vec::with_capacity(rows.len());
for (z, id, data) in &rows {
min_zoom = min_zoom.min(*z);
max_zoom = max_zoom.max(*z);
entries.push(Entry {
tile_id: *id,
offset: tiledata.len() as u64,
length: data.len() as u64,
run_length: 1,
});
tiledata.extend_from_slice(data);
}
if rows.is_empty() {
min_zoom = 0;
}
let root_dir = serialize_directory(&entries);
let meta_bytes = serde_json::to_vec(metadata).unwrap_or_else(|_| b"{}".to_vec());
let header_len = 127u64;
let root_off = header_len;
let meta_off = root_off + root_dir.len() as u64;
let leaf_off = meta_off + meta_bytes.len() as u64;
let tiledata_off = leaf_off; // no leaf directories
let n = entries.len() as u64;
let header = Header {
root_offset: root_off,
root_length: root_dir.len() as u64,
metadata_offset: meta_off,
metadata_length: meta_bytes.len() as u64,
leaf_offset: leaf_off,
leaf_length: 0,
tile_data_offset: tiledata_off,
tile_data_length: tiledata.len() as u64,
num_addressed_tiles: n,
num_tile_entries: n,
num_tile_contents: n,
clustered: true,
internal_compression: 1, // none
tile_compression: 1, // none
tile_type: tile_type as u8,
min_zoom,
max_zoom,
};
let mut out = header.to_bytes().to_vec();
out.extend_from_slice(&root_dir);
out.extend_from_slice(&meta_bytes);
out.extend_from_slice(&tiledata);
out
}
/// Export the **surface** tiles of a map tileset (alt 0, atemporal, base LOD) to
/// a PMTiles archive. `project` maps an S2 cell to a slippy `(z, x, y)`; tiles it
/// returns `None` for, or non-surface tiles, are skipped. The tile type is taken
/// from the first tile's codec.
pub fn from_map_tiles(
tiles: &[Tile],
project: impl Fn(S2CellId) -> Option<(u8, u32, u32)>,
metadata: &Value,
) -> Vec<u8> {
let tile_type =
tiles.first().map(|t| TileType::from_codec(&t.codec)).unwrap_or(TileType::Unknown);
let projected: Vec<(u8, u32, u32, Vec<u8>)> = tiles
.iter()
.filter(|t| is_surface(&t.key))
.filter_map(|t| project(t.key.s2).map(|(z, x, y)| (z, x, y, t.data.clone())))
.collect();
write(projected, metadata, tile_type)
}
/// Whether a cell is a 2-D-projectable surface tile (PMTiles can't hold altitude
/// bands, time buckets, or non-base LODs).
pub fn is_surface(key: &CellKey) -> bool {
key.alt.0 == 0 && key.time.is_atemporal() && key.lod.0 == 0
}
/// Read a tile's bytes back out of a PMTiles archive (the root-only reader used
/// to round-trip-validate [`write`]; a production reader would also follow leaf
/// directories).
pub fn read_tile(archive: &[u8], z: u8, x: u32, y: u32) -> Option<Vec<u8>> {
let header = Header::from_bytes(archive.get(..127)?)?;
let root = archive.get(header.root_offset as usize..(header.root_offset + header.root_length) as usize)?;
let entries = deserialize_directory(root)?;
let want = tile_id(z, x, y);
let e = entries.iter().find(|e| e.tile_id <= want && want < e.tile_id + e.run_length.max(1))?;
let base = header.tile_data_offset + e.offset;
archive.get(base as usize..(base + e.length) as usize).map(<[u8]>::to_vec)
}
// ── Hilbert tile ids (PMTiles convention) ────────────────────────────────────
/// The first tile id at zoom `z` — `(4^z − 1) / 3`.
fn base_id(z: u8) -> u64 {
((1u64 << (2 * z as u32)) - 1) / 3
}
/// `(z, x, y) → tile id`: zoom base + Hilbert distance.
pub fn tile_id(z: u8, x: u32, y: u32) -> u64 {
base_id(z) + xy2d(z, x as u64, y as u64)
}
/// `tile id → (z, x, y)` — the inverse, for verification.
pub fn zxy(mut id: u64) -> (u8, u32, u32) {
let mut z = 0u8;
while z < 31 && id >= base_id(z + 1) {
z += 1;
}
id -= base_id(z);
let (x, y) = d2xy(z, id);
(z, x as u32, y as u32)
}
fn xy2d(z: u8, mut x: u64, mut y: u64) -> u64 {
let n = 1u64 << z;
let mut d = 0u64;
let mut s = n / 2;
while s > 0 {
let rx = u64::from((x & s) > 0);
let ry = u64::from((y & s) > 0);
d += s * s * ((3 * rx) ^ ry);
// rotate (canonical Hilbert, full-grid n)
if ry == 0 {
if rx == 1 {
x = n - 1 - x;
y = n - 1 - y;
}
std::mem::swap(&mut x, &mut y);
}
s /= 2;
}
d
}
fn d2xy(z: u8, mut t: u64) -> (u64, u64) {
let n = 1u64 << z;
let (mut x, mut y) = (0u64, 0u64);
let mut s = 1u64;
while s < n {
let rx = 1 & (t / 2);
let ry = 1 & (t ^ rx);
// rotate (per-level s)
if ry == 0 {
if rx == 1 {
x = s - 1 - x;
y = s - 1 - y;
}
std::mem::swap(&mut x, &mut y);
}
x += s * rx;
y += s * ry;
t /= 4;
s *= 2;
}
(x, y)
}
// ── Clustered directory (PMTiles v3) ─────────────────────────────────────────
fn serialize_directory(entries: &[Entry]) -> Vec<u8> {
let mut buf = Vec::new();
write_uvarint(&mut buf, entries.len() as u64);
// column: tile-id deltas
let mut last = 0u64;
for e in entries {
write_uvarint(&mut buf, e.tile_id - last);
last = e.tile_id;
}
// column: run lengths
for e in entries {
write_uvarint(&mut buf, e.run_length);
}
// column: lengths
for e in entries {
write_uvarint(&mut buf, e.length);
}
// column: offsets (0 = immediately follows previous; else offset + 1)
for (i, e) in entries.iter().enumerate() {
if i > 0 && e.offset == entries[i - 1].offset + entries[i - 1].length {
write_uvarint(&mut buf, 0);
} else {
write_uvarint(&mut buf, e.offset + 1);
}
}
buf
}
fn deserialize_directory(buf: &[u8]) -> Option<Vec<Entry>> {
let mut pos = 0usize;
let n = read_uvarint(buf, &mut pos)? as usize;
let mut entries = vec![Entry { tile_id: 0, offset: 0, length: 0, run_length: 0 }; n];
let mut last = 0u64;
for e in entries.iter_mut() {
last += read_uvarint(buf, &mut pos)?;
e.tile_id = last;
}
for e in entries.iter_mut() {
e.run_length = read_uvarint(buf, &mut pos)?;
}
for e in entries.iter_mut() {
e.length = read_uvarint(buf, &mut pos)?;
}
for i in 0..n {
let v = read_uvarint(buf, &mut pos)?;
entries[i].offset =
if v == 0 { entries[i - 1].offset + entries[i - 1].length } else { v - 1 };
}
Some(entries)
}
fn write_uvarint(buf: &mut Vec<u8>, mut v: u64) {
while v >= 0x80 {
buf.push((v as u8) | 0x80);
v >>= 7;
}
buf.push(v as u8);
}
fn read_uvarint(buf: &[u8], pos: &mut usize) -> Option<u64> {
let mut result = 0u64;
let mut shift = 0u32;
loop {
let b = *buf.get(*pos)?;
*pos += 1;
result |= ((b & 0x7f) as u64) << shift;
if b & 0x80 == 0 {
return Some(result);
}
shift += 7;
if shift >= 64 {
return None;
}
}
}
// ── 127-byte header ──────────────────────────────────────────────────────────
struct Header {
root_offset: u64,
root_length: u64,
metadata_offset: u64,
metadata_length: u64,
leaf_offset: u64,
leaf_length: u64,
tile_data_offset: u64,
tile_data_length: u64,
num_addressed_tiles: u64,
num_tile_entries: u64,
num_tile_contents: u64,
clustered: bool,
internal_compression: u8,
tile_compression: u8,
tile_type: u8,
min_zoom: u8,
max_zoom: u8,
}
impl Header {
fn to_bytes(&self) -> [u8; 127] {
let mut b = [0u8; 127];
b[..7].copy_from_slice(b"PMTiles");
b[7] = 3; // spec version
let mut put = |off: usize, v: u64| b[off..off + 8].copy_from_slice(&v.to_le_bytes());
put(8, self.root_offset);
put(16, self.root_length);
put(24, self.metadata_offset);
put(32, self.metadata_length);
put(40, self.leaf_offset);
put(48, self.leaf_length);
put(56, self.tile_data_offset);
put(64, self.tile_data_length);
put(72, self.num_addressed_tiles);
put(80, self.num_tile_entries);
put(88, self.num_tile_contents);
b[96] = self.clustered as u8;
b[97] = self.internal_compression;
b[98] = self.tile_compression;
b[99] = self.tile_type;
b[100] = self.min_zoom;
b[101] = self.max_zoom;
// bytes 102..127: bounds (i32 E7 ×4), center zoom, center (i32 ×2) — left
// as world default (0). A reader keys on the directory, not these.
b
}
fn from_bytes(b: &[u8]) -> Option<Self> {
if b.len() < 127 || &b[..7] != b"PMTiles" || b[7] != 3 {
return None;
}
let get = |off: usize| u64::from_le_bytes(b[off..off + 8].try_into().unwrap());
Some(Header {
root_offset: get(8),
root_length: get(16),
metadata_offset: get(24),
metadata_length: get(32),
leaf_offset: get(40),
leaf_length: get(48),
tile_data_offset: get(56),
tile_data_length: get(64),
num_addressed_tiles: get(72),
num_tile_entries: get(80),
num_tile_contents: get(88),
clustered: b[96] != 0,
internal_compression: b[97],
tile_compression: b[98],
tile_type: b[99],
min_zoom: b[100],
max_zoom: b[101],
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn hilbert_anchors_and_round_trip() {
// The z=1 Hilbert order PMTiles uses.
assert_eq!(tile_id(0, 0, 0), 0);
assert_eq!(tile_id(1, 0, 0), 1);
assert_eq!(tile_id(1, 0, 1), 2);
assert_eq!(tile_id(1, 1, 1), 3);
assert_eq!(tile_id(1, 1, 0), 4);
// round-trip across zooms
for (z, x, y) in [(0u8, 0u32, 0u32), (3, 5, 2), (8, 100, 200), (14, 8000, 100)] {
assert_eq!(zxy(tile_id(z, x, y)), (z, x, y), "rt {z}/{x}/{y}");
}
}
#[test]
fn directory_round_trips() {
let entries = vec![
Entry { tile_id: 1, offset: 0, length: 10, run_length: 1 },
Entry { tile_id: 2, offset: 10, length: 20, run_length: 1 }, // consecutive
Entry { tile_id: 9, offset: 100, length: 5, run_length: 1 },
];
let bytes = serialize_directory(&entries);
assert_eq!(deserialize_directory(&bytes).unwrap(), entries);
}
#[test]
fn header_round_trips() {
let h = Header {
root_offset: 127,
root_length: 40,
metadata_offset: 167,
metadata_length: 2,
leaf_offset: 169,
leaf_length: 0,
tile_data_offset: 169,
tile_data_length: 35,
num_addressed_tiles: 3,
num_tile_entries: 3,
num_tile_contents: 3,
clustered: true,
internal_compression: 1,
tile_compression: 1,
tile_type: TileType::Mvt as u8,
min_zoom: 0,
max_zoom: 14,
};
let b = h.to_bytes();
assert_eq!(&b[..7], b"PMTiles");
assert_eq!(b[7], 3);
let h2 = Header::from_bytes(&b).unwrap();
assert_eq!(h2.tile_data_offset, 169);
assert_eq!(h2.max_zoom, 14);
assert_eq!(h2.tile_type, TileType::Mvt as u8);
}
#[test]
fn archive_write_then_read_tile() {
let tiles = vec![
(0u8, 0u32, 0u32, b"root-tile".to_vec()),
(2, 1, 1, b"a-tile".to_vec()),
(2, 3, 2, b"another".to_vec()),
];
let archive = write(tiles, &json!({ "name": "demo" }), TileType::Mvt);
// header is intact
assert_eq!(&archive[..7], b"PMTiles");
// every tile reads back byte-identical, by z/x/y
assert_eq!(read_tile(&archive, 0, 0, 0).unwrap(), b"root-tile");
assert_eq!(read_tile(&archive, 2, 1, 1).unwrap(), b"a-tile");
assert_eq!(read_tile(&archive, 2, 3, 2).unwrap(), b"another");
// a tile that isn't there
assert!(read_tile(&archive, 5, 0, 0).is_none());
}
}