Files
MeshTerm/meshterm/core/mvt.py
T
Jean-Pierre MartineauandClaude Opus 4.8 6b84281d0b Render the map so any terminal font can show it
The braille base map and its labels lean on Unicode a terminal font may not
have: 8-dot braille (U+2840-28FF) that many fonts omit, and place/node names
in scripts or emoji outside a Latin monospace font. A missing glyph renders as
double-width tofu, which both looks wrong and shoves the panel right border.

- Prefer a romanized name (name:latin/name:en) over the local script, and strip
  labels to single-cell characters, so foreign names and emoji node names can no
  longer tofu or knock the grid out of alignment.
- Add a block-element base-map mode, toggled with t: 2x2 quadrant blocks instead
  of 2x4 braille - half the vertical resolution, but glyphs essentially every
  monospace font has. Braille stays the default.
- Zoom with PageUp/PageDown as well as +/-.
- While braille mode is active, force a full repaint each paint so the
  double-width-fallback corruption on the right border is scrubbed on every move.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 21:09:18 -04:00

286 lines
9.3 KiB
Python

"""A tiny, dependency-free Mapbox Vector Tile (MVT) decoder.
Vector tiles are Protocol-Buffer messages (spec: github.com/mapbox/vector-tile-spec). Rather
than pull in a protobuf runtime and a geometry library, this decodes the small subset the map
needs by hand: the protobuf wire format (varints, length-delimited fields) and the MVT
geometry command encoding (MoveTo/LineTo/ClosePath with zig-zag deltas). It is pure and
offline — the network fetch lives in :mod:`meshterm.services.basemap`.
The result is a list of :class:`Layer`, each with decoded :class:`Feature` geometry in
*tile-local* integer coordinates (``0..extent``) and a resolved ``tags`` dict, ready to be
projected to the screen by :class:`meshterm.core.geo.Viewport`.
"""
from __future__ import annotations
import gzip
from dataclasses import dataclass, field
from typing import Any, Optional
# MVT geometry types (Feature.type).
GEOM_POINT = 1
GEOM_LINE = 2
GEOM_POLYGON = 3
# Geometry command ids (low 3 bits of a command integer).
_CMD_MOVE_TO = 1
_CMD_LINE_TO = 2
_CMD_CLOSE = 7
class _Reader:
"""A minimal protobuf wire-format reader over a byte buffer."""
__slots__ = ("b", "i")
def __init__(self, b: bytes) -> None:
self.b = b
self.i = 0
def eof(self) -> bool:
return self.i >= len(self.b)
def varint(self) -> int:
"""Read a base-128 varint."""
result = shift = 0
while True:
byte = self.b[self.i]
self.i += 1
result |= (byte & 0x7F) << shift
if not byte & 0x80:
return result
shift += 7
def tag(self) -> tuple[int, int]:
"""Read a field tag, returning ``(field_number, wire_type)``."""
key = self.varint()
return key >> 3, key & 0x7
def blob(self) -> bytes:
"""Read a length-delimited byte string."""
n = self.varint()
v = self.b[self.i : self.i + n]
self.i += n
return v
def skip(self, wire_type: int) -> None:
"""Skip a field of the given wire type."""
if wire_type == 0:
self.varint()
elif wire_type == 2:
self.blob()
elif wire_type == 5:
self.i += 4
elif wire_type == 1:
self.i += 8
else: # pragma: no cover - groups are obsolete and never appear in MVT
raise ValueError(f"unsupported wire type {wire_type}")
def _zigzag(n: int) -> int:
"""Decode a protobuf zig-zag encoded signed integer."""
return (n >> 1) ^ -(n & 1)
@dataclass(slots=True)
class Feature:
"""One decoded vector-tile feature.
Attributes:
geom_type: :data:`GEOM_POINT`, :data:`GEOM_LINE`, or :data:`GEOM_POLYGON`.
rings: The geometry as a list of parts, each a list of ``(x, y)`` integer points in
tile-local coordinates (``0..extent``). Points have one part holding every point;
lines/polygons have one part per line-string / ring.
tags: Resolved attribute dict (e.g. ``{"class": "primary", "name": "Rue X"}``).
"""
geom_type: int
rings: list[list[tuple[int, int]]]
tags: dict[str, Any]
def get(self, key: str, default: Any = None) -> Any:
"""Return a tag value, or ``default`` if absent."""
return self.tags.get(key, default)
@property
def name(self) -> Optional[str]:
"""The feature's display name, preferring a romanized form over the local script.
The terminal map draws labels in a fixed-width cell grid with whatever font the
user has, so a local-script name (CJK, Arabic, Thai…) tends to render as tofu or,
being double-width, shove the row out of alignment. OpenMapTiles ships a
transliterated ``name:latin`` (and often ``name:en``) beside the local ``name``, so
prefer those; fall back to the local ``name`` only when no latin form exists.
"""
for key in ("name:latin", "name:en", "name_en", "name_int", "name"):
val = self.tags.get(key)
if val:
return str(val)
return None
@dataclass(slots=True)
class Layer:
"""A named vector-tile layer and its decoded features.
Attributes:
name: Layer id (e.g. ``transportation``, ``water``, ``place``).
extent: The tile's internal coordinate extent (typically 4096).
features: The decoded features.
"""
name: str
extent: int
features: list[Feature] = field(default_factory=list)
def _decode_geometry(cmds: list[int]) -> list[list[tuple[int, int]]]:
"""Decode an MVT geometry command stream into a list of point rings."""
rings: list[list[tuple[int, int]]] = []
current: list[tuple[int, int]] = []
x = y = 0
i = 0
n = len(cmds)
while i < n:
command = cmds[i]
i += 1
cmd_id = command & 0x7
count = command >> 3
if cmd_id == _CMD_MOVE_TO:
for _ in range(count):
x += _zigzag(cmds[i])
y += _zigzag(cmds[i + 1])
i += 2
# Each MoveTo starts a new part (multipoint keeps them in one part below).
if current:
rings.append(current)
current = [(x, y)]
elif cmd_id == _CMD_LINE_TO:
for _ in range(count):
x += _zigzag(cmds[i])
y += _zigzag(cmds[i + 1])
i += 2
current.append((x, y))
elif cmd_id == _CMD_CLOSE:
if current:
current.append(current[0]) # close the ring back to its start
if current:
rings.append(current)
return rings
def _decode_value(buf: bytes) -> Any:
"""Decode an MVT attribute Value message into a Python scalar."""
r = _Reader(buf)
while not r.eof():
field_no, wire = r.tag()
if field_no == 1 and wire == 2:
return r.blob().decode("utf-8", "ignore") # string_value
if field_no == 2 and wire == 5: # float_value
import struct
v = struct.unpack("<f", r.b[r.i : r.i + 4])[0]
r.i += 4
return v
if field_no == 3 and wire == 1: # double_value
import struct
v = struct.unpack("<d", r.b[r.i : r.i + 8])[0]
r.i += 8
return v
if field_no in (4, 5) and wire == 0: # int_value / uint_value
return r.varint()
if field_no == 6 and wire == 0: # sint_value
return _zigzag(r.varint())
if field_no == 7 and wire == 0: # bool_value
return bool(r.varint())
r.skip(wire)
return None
def _packed_uints(buf: bytes) -> list[int]:
"""Decode a packed repeated uint32 field into a list of ints."""
r = _Reader(buf)
out: list[int] = []
while not r.eof():
out.append(r.varint())
return out
def _decode_feature(buf: bytes, keys: list[str], values: list[Any]) -> Optional[Feature]:
"""Decode a single Feature message, resolving its tags against the layer's key/value pools."""
r = _Reader(buf)
geom_type = 0
tag_ints: list[int] = []
geom_ints: list[int] = []
while not r.eof():
field_no, wire = r.tag()
if field_no == 2 and wire == 2:
tag_ints = _packed_uints(r.blob())
elif field_no == 3 and wire == 0:
geom_type = r.varint()
elif field_no == 4 and wire == 2:
geom_ints = _packed_uints(r.blob())
else:
r.skip(wire)
if not geom_ints:
return None
tags: dict[str, Any] = {}
for k, v in zip(tag_ints[0::2], tag_ints[1::2]):
if 0 <= k < len(keys) and 0 <= v < len(values):
tags[keys[k]] = values[v]
return Feature(geom_type=geom_type, rings=_decode_geometry(geom_ints), tags=tags)
def _decode_layer(buf: bytes) -> Layer:
"""Decode a single Layer message and all of its features."""
r = _Reader(buf)
name = ""
extent = 4096
keys: list[str] = []
values: list[Any] = []
feature_blobs: list[bytes] = []
while not r.eof():
field_no, wire = r.tag()
if field_no == 1 and wire == 2:
name = r.blob().decode("utf-8", "ignore")
elif field_no == 2 and wire == 2:
feature_blobs.append(r.blob())
elif field_no == 3 and wire == 2:
keys.append(r.blob().decode("utf-8", "ignore"))
elif field_no == 4 and wire == 2:
values.append(_decode_value(r.blob()))
elif field_no == 5 and wire == 0:
extent = r.varint()
else:
r.skip(wire)
layer = Layer(name=name, extent=extent)
for blob in feature_blobs:
feat = _decode_feature(blob, keys, values)
if feat is not None:
layer.features.append(feat)
return layer
def decode_tile(data: bytes) -> list[Layer]:
"""Decode a vector tile (optionally gzip-compressed) into its layers.
Args:
data: Raw ``.pbf`` bytes, gzip-compressed or not.
Returns:
The decoded layers, in the order they appear in the tile. An empty or unparseable
tile yields an empty list rather than raising.
"""
if data[:2] == b"\x1f\x8b":
data = gzip.decompress(data)
r = _Reader(data)
layers: list[Layer] = []
while not r.eof():
field_no, wire = r.tag()
if field_no == 3 and wire == 2: # Tile.layers
layers.append(_decode_layer(r.blob()))
else:
r.skip(wire)
return layers