Files
Jean-Pierre MartineauandClaude Opus 5.5 ffe31dac36 Docstrings and comments of meshterm/core are in ASD-STE100
The prose of each docstring and comment in the core package now follows
ASD-STE100 Simplified Technical English, with Canadian spelling: short
sentences, simple verb tenses, no dashes or semicolons, and one word for
one concept (docs/development/writing-style.md has the rules and the
glossary). The meaning and each reason stay. The code does not change:
the AST of each file, without its docstrings, is the same as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 03:29:03 -04:00

455 lines
18 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""A very small Mapbox Vector Tile (MVT) decoder, with no dependencies.
Vector tiles are Protocol Buffer messages (spec: github.com/mapbox/vector-tile-spec). This
module does not add a protobuf runtime and a geometry library. Instead, it decodes by hand
the small subset that the map needs: the protobuf wire format (varints, length-delimited
fields) and the MVT geometry command encoding (MoveTo, LineTo, and ClosePath with zig-zag
deltas). It is pure and works offline. The network download is in
:mod:`meshterm.services.basemap`.
The result is a list of :class:`Layer`. Each layer has decoded :class:`Feature` geometry in
local tile integer coordinates (``0..extent``) and a resolved ``tags`` dict.
:class:`meshterm.core.geo.Viewport` can then project them to the screen.
"""
from __future__ import annotations
import gzip
import marshal
import struct
from collections.abc import Container
from dataclasses import dataclass, field
from typing import Any
# MVT geometry types (Feature.type).
GEOM_POINT = 1
GEOM_LINE = 2
GEOM_POLYGON = 3
# Geometry command ids (the 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 on 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, and return ``(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:
# Move past the bytes, and do not slice them out. The decoder uses this skip to
# walk past all that it does not want, and a discarded copy of each feature in
# a layer that is not drawn is most of that cost. The length must go into a
# temporary variable first: ``self.i += self.varint()`` adds to the offset as
# it was before the varint moved it.
n = self.varint()
self.i += n
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 signed integer in the protobuf zig-zag encoding."""
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 part is a list of ``(x, y)`` integer
points in local tile coordinates (``0..extent``). Points have one part that
holds all the points. Lines and polygons have one part for each line string or
ring.
tags: The resolved attribute dict (for example,
``{"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 it is absent."""
return self.tags.get(key, default)
@property
def name(self) -> str | None:
"""The name to show for the feature, with a romanized form before the local script.
The terminal map draws labels in a grid of fixed-width cells, with the font that the
user has. Thus a name in a local script (CJK, Arabic, Thai, and other scripts) often
renders as tofu. Or, because it is double-width, it pushes the row out of alignment.
OpenMapTiles has a transliterated ``name:latin`` (and often ``name:en``) next to the
local ``name``, so use those first. Use 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: The layer id (for example, ``transportation``, ``water``, ``place``).
extent: The internal coordinate extent of the tile (usually 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 (a 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]) -> Feature | None:
"""Decode one Feature message, and resolve its tags with the layer key and 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] = {}
# A malformed tile can have an odd tag list. Ignore the last key, which has no value,
# and do not raise: the remainder of this decoder is lenient about bad tiles on purpose.
for k, v in zip(tag_ints[0::2], tag_ints[1::2], strict=False):
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, wanted: Container[str] | None = None) -> Layer:
"""Decode one Layer message, and its features if the caller wants them.
The name arrives in field 1, which is usually the first field written. Thus, after the
walk reads the name, it already knows whether the remainder is worth a copy. From there,
the walk steps over an unwanted layer, and does not copy it out. But protobuf permits
any field order, so ``skipping`` is only an optimization. The decision is the check
after the loop, which is correct in whatever order the fields arrived (and also for a
layer that declares no name at all).
Args:
buf: The bytes of the Layer message.
wanted: The layer names for which to decode features. ``None`` decodes each layer.
Returns:
The layer. A layer that the caller did not ask for is returned with its name and
no features.
"""
r = _Reader(buf)
name = ""
extent = 4096
keys: list[str] = []
values: list[Any] = []
feature_blobs: list[bytes] = []
skipping = False
while not r.eof():
field_no, wire = r.tag()
if field_no == 1 and wire == 2:
name = r.blob().decode("utf-8", "ignore")
skipping = wanted is not None and name not in wanted
elif field_no == 5 and wire == 0:
extent = r.varint()
elif skipping:
r.skip(wire)
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()))
else:
r.skip(wire)
layer = Layer(name=name, extent=extent)
if wanted is not None and name not in wanted:
return layer
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, *, layers: Container[str] | None = None) -> list[Layer]:
"""Decode a vector tile (gzip-compressed or not) into its layers.
Args:
data: The raw ``.pbf`` bytes, gzip-compressed or not.
layers: When given, only the layers whose name is in it are decoded. The function
still returns the other layers, with their name, their extent, and no
features. Thus the result still describes the full tile, and a caller can tell
a real tile from junk without the cost of geometry that it will never draw.
``None`` decodes all the layers.
Returns:
The layers of the tile, in their order in the tile. An empty tile gives an empty
list, and does not raise. Truncated bytes or junk bytes raise. With this,
:class:`~meshterm.services.basemap.BasemapSource` can tell a corrupt cache entry
from a tile that holds nothing that it draws.
"""
if data[:2] == b"\x1f\x8b":
data = gzip.decompress(data)
r = _Reader(data)
out: list[Layer] = []
while not r.eof():
field_no, wire = r.tag()
if field_no == 3 and wire == 2: # Tile.layers
out.append(_decode_layer(r.blob(), layers))
else:
r.skip(wire)
return out
# -- the decoded form, for callers that do not want to decode a tile two times ---------
#: Increased each time :func:`dumps_layers` changes the structure that it writes.
_WIRE_VERSION = 1
def dumps_layers(layers: list[Layer], *, stamp: str = "") -> bytes:
"""Serialize decoded layers to bytes that :func:`loads_layers` can restore.
The decode of a vector tile is by far the most expensive operation of this app. A
153 KB tile costs ~365 ms on the Cortex-A7 of the PicoCalc, even after the layer
narrowing. The result depends only on the bytes and the layer set. When MeshTerm writes
the result down, a later session pays for a ``marshal.loads`` call and some object
construction (measured as ~14x cheaper), and does not parse the protobuf again.
The format is ``marshal`` because it is in the standard library, it runs at C speed,
and it understands the plain tuples, lists, dicts, and scalars to which a decoded tile
reduces. It is not ``pickle``, on purpose: the cache reads a file from disk, and
marshal cannot be made to import a module or call a constructor. It is still safe only
for data that we wrote ourselves. This is why the cache is in the directory of the app
itself, and why each read is guarded (refer to :func:`loads_layers`).
Args:
layers: The decoded layers to write down.
stamp: An opaque token from the caller that describes how these layers were
decoded (usually the layer set). :func:`loads_layers` refuses a blob with a
different stamp. This stops a narrowed decode from going to a caller that
wants more.
Returns:
The encoded bytes.
"""
payload = [
(layer.name, layer.extent, [(f.geom_type, f.rings, f.tags) for f in layer.features])
for layer in layers
]
return marshal.dumps((_WIRE_VERSION, marshal.version, stamp, payload))
def loads_layers(blob: bytes, *, stamp: str = "") -> list[Layer] | None:
"""Restore layers that :func:`dumps_layers` wrote, or ``None`` if they cannot be used.
The blob can be different from what this build expects in many ways: a newer wire
version, a Python whose ``marshal`` writes a different format, a different layer set,
or a file that a power failure truncated. Each of these gives ``None``, which means
"decode the tile again". Nothing here raises into the map: a derived cache that cannot
be read is not an error. It is only a cache miss.
Args:
blob: Bytes that :func:`dumps_layers` made before.
stamp: The token that the caller expects. A blob written with a different token
is rejected.
Returns:
The layers, or ``None`` to mean "re-decode".
"""
try:
version, marshal_version, written_stamp, payload = marshal.loads(blob)
if (version, marshal_version, written_stamp) != (_WIRE_VERSION, marshal.version, stamp):
return None
return [
Layer(
name=name,
extent=extent,
features=[Feature(geom_type=g, rings=r, tags=t) for g, r, t in feats],
)
for name, extent, feats in payload
]
except Exception: # noqa: BLE001 - any malformed blob is simply a cache miss
return None
# -- the memory size of a decoded tile ------------------------------------------------
#: The RAM cost of a decoded tile, in pointer widths: for each point (its ``(x, y)`` tuple,
#: the ints in it, its slot in the ring), for each feature (the object, its tag dict, its
#: ring lists), and for each tag. Fitted by least squares against ``tracemalloc`` over all
#: the tiles in the cache of the PicoCalc (32-bit), and over the same tiles decoded on a
#: 64-bit desktop. The two gave the same counts of words, to within a few percent: 15-16
#: for a point, 42-44 for a feature, 1.5-2 for a tag.
_POINT_WORDS = 16
_FEATURE_WORDS = 44
_TAG_WORDS = 2
#: A pointer, in bytes: the unit of the counts above. The string value of a tag costs half
#: of one pointer for each UTF-8 byte (2.1 bytes measured on the handheld, 3.4 on the
#: desktop).
_WORD = struct.calcsize("P")
def resident_bytes(layers: list[Layer]) -> int:
"""Approximately how much RAM ``layers`` hold, for a cache that has a budget in bytes.
A count of tiles is no use as a measure of the weight of tiles. On the PicoCalc, a
decoded tile is from 1 MB (a suburb at z10) to 4.6 MB (a coastline at z7). A cache that
a count limited held a little more than 50 MB of tiles on a handheld with 100 MB. The
result was the SD-card swap: the map froze for fifteen seconds while the kernel paged
the interpreter back in.
Points alone are also not the measure. A zoomed-out tile is mostly place labels, and
each label carries its name in dozens of languages. A z2 tile of 1.6 MB has almost no
geometry at all. Thus the tags count too, and their text. The walk never touches a
coordinate, so its cost is small compared to the decode that made the layers.
This is an estimate. On the 157 cached tiles of the handheld, it is from 1% under to 9%
over what ``tracemalloc`` measures. On a 64-bit desktop, it is from 6% under to 21%
over.
"""
points = features = tags = text = 0
for layer in layers:
for feat in layer.features:
features += 1
tags += len(feat.tags)
for value in feat.tags.values():
if isinstance(value, str):
# Bytes, not characters: a name in a different script is stored
# wider, and these names fill a zoomed-out tile.
text += len(value.encode())
for ring in feat.rings:
points += len(ring)
words = points * _POINT_WORDS + features * _FEATURE_WORDS + tags * _TAG_WORDS
return words * _WORD + text * _WORD // 2