Catalog PyPI
PACKAGE EXPLORERView in registry

3tears-geo

PyPI

Slippy-map tile geometry: WKB decoding, zoom bands, and MVT encoding in application code (no PostGIS)

Versions collected1
Direct dependencies5From release 0.72.0
Last observed20 Oct, 15:38 UTC

README

Version 0.72.0 documentation

3tears-geo

Slippy-map tile geometry for the 3tears platform: WKB decoding, zoom bands, and MVT encoding — all in application code.

Why this exists in Python rather than in SQL

Every off-the-shelf tile server (Martin, pg_tileserv, Tegola) assumes PostGIS in the database and calls ST_AsMVT. YugabyteDB ships no postgis extension — verified empirically against yugabytedb/yugabyte:2025.2.5.2-b5, where CREATE EXTENSION postgis fails outright with no control file on the image. There is therefore no ST_Intersects, no ST_Simplify, and no ST_AsMVT to call.

So this package does that work: shapely for the geometry, and mapbox-vector-tile for the encoding. The relevant prior art is Tippecanoe and supercluster, not the Postgres tile servers.

The tiling scheme, stated explicitly

Leaving this implicit guarantees a defect, because the two common conventions differ only in the direction of one axis:

  • Web Mercator (EPSG:3857)
  • XYZ orientation — y increases southward from the top-left origin. OpenStreetMap / MapLibre / Google. Not TMS, whose y increases northward.
  • Source coordinates are WGS84 (EPSG:4326), projected at build time.
  • MVT geometry uses tile-local integer coordinates over a 4096-unit extent, the format default, not varied per layer.

Latitude clamps to ±85.0511287798066°, the bound of the Mercator square.

Public surface

Imported via from threetears.geo import …:

  • tiles — TileId, BoundingBox, tile_for_point, tile_bounds, bounds_to_tile_range, TILE_EXTENT, MAX_MERCATOR_LATITUDE.
  • attributes — coerce_attribute, coerce_attributes, validate_attribute_value, UnsupportedAttributeError.
  • geometry — decode_geometry, geometry_bounds, point_geometry.
  • bands — feature_band, aggregate_band, FeatureSpec, AggregateSpec, TileFeature, simplification_tolerance.
  • mvt — encode_tile, project_to_tile.
  • features — FeatureCache (per-pod source cache + R-Tree), FeatureEntity, FeatureLoader.
  • collection — TileCollection, TileEntity, LayerDefinition, ViewportRequest.
  • scope — check_cache_scope, MAX_CACHE_SCOPE_LENGTH.
  • overlay — KeyedFeature, Overlay, cut, replace_features, missing_features, area_km2, mean_width_m, LayerOverlayError.

threetears.geo.__all__ is the full list.

Every tile source names a cache scope

TileCollection and FeatureCache both require cache_scope, a keyword argument with no default. Their caches are keyed by layer name and version, and nothing in that key says whose layer it is: two sources serving a layer of the same name (two datasources, or a customer's datasource and a platform layer) would otherwise share the pod-local L1 table, the NATS L2 keys, the cross-pod build lock and the registry entry, and whichever built a tile first would answer for both.

The scope goes into the collections' table names (geo_tiles_<scope>, geo_features_<scope>), which are interpolated into SQL and NATS KV keys, so check_cache_scope holds it to a grammar both accept unquoted: a lowercase letter, then lowercase letters, digits and underscores, at most MAX_CACHE_SCOPE_LENGTH characters. ds_<32 hex> for a datasource and ns_<32 hex> for a provider namespace both fit.

Building layers from other layers

threetears.geo.overlay cuts one layer by another, replaces features, and checks coverage. A layer is a sequence of KeyedFeature: a stable id, a WGS84 geometry, and a key that partitions the layer (a state, a country) so only features sharing it are paired. Two layers from different sources disagree at their boundaries by a few meters, so a cut leaves thin slivers and small fragments; each part of a cut is judged on its own and dropped when it is narrower or smaller than the caller's thresholds (mean_width_m, area_km2 are the measures). The thresholds belong to the data: measure them on the layers in hand and pass them in. Measurements use a local equirectangular projection around each shape's own latitude.

Zoom bands: aggregate below, features above

Low zoom is not simplified high zoom. A z4 tile spans a large fraction of a country; rendering it by simplifying and dropping individual features leaves an arbitrary sample of whichever survived. A national view showing 4,000 of 180,000 precincts is not a coarse view of the data — it is a different and misleading dataset.

So each layer declares a crossover. Below it, rows roll up to a coarser declared geography and each bucket becomes one feature carrying real totals. Above it, individual features are simplified per zoom and capped in count. bl-ds-ai-lcv-registration reached the same split independently: its precomputed z4–z10 band holds cluster aggregates, with individual features only from z11.

The cap is a hard limit, not advice. An uncapped tile in a dense metro reaches tens of megabytes, which exceeds the NATS payload ceiling, defeats the L2 hot band, and renders badly. When it binds, features drop by a declared ranking column so the survivors are the important ones, and the result records that it was truncated — a silently capped tile reads as "this is all the data there is".

Feature ids are not MVT ids

MVT feature ids are uint64 by specification. A census geoid or a UUID is silently coerced to 0 by the encoder — no error — which would collapse every feature in a tile onto one id and break any client-side join. So only genuine integers reach the wire-level id; everything else travels as a property, which is exactly what MapLibre's promoteId is for. The id is always present as a property either way, so a client has one place to look.

The R-Tree earns its place via chunk coverage

FeatureCache indexes cached source features in a SQLite R-Tree (built in, unlike SpatiaLite) on the same connection pool as its own managed table, as a BaseCollection subclass rather than a bespoke SQLiteBackend wrapper.

An index alone cannot answer "which features are in this rectangle": it can say what a pod holds, never whether it holds all of them, and a tile built from a partial set is wrong rather than slow — then cached as immutable. So the cache tracks coverage by chunk: it loads the coarse tile containing a request and records that chunk as covered. A run of neighbouring tiles, which overlap almost entirely in source features, then pays one L3 read between them instead of one each.

Once a rectangle's chunks are covered, the R-Tree answers which held features overlap it, and an exact rectangle test follows (the R-Tree stores 32-bit floats rounded outward, so it can over-answer but never under-answer). What a pod holds is bounded by max_cached_rows and evicted least recently read first, rows and index entries together. One read newly holds at most half the bound, summed over every chunk it loads; the chunks past that, or a wide low-zoom rectangle whose entries would pass it, are answered and not held. A rectangle over more than max_chunk_reads uncovered chunks is one loader call, and with no L1 bound every read is one loader call.

Attribute coercion is fixed, not per-caller

MVT carries only strings, numbers and booleans, so every other SQL type needs a declared mapping. Two cases are quietly lossy if chosen badly:

SQLMVTNote
integer / numeric / doublenumberdoubles are IEEE754
text / varcharstring
booleanboolchecked before int, since bool subclasses it
NULLkey omittedMVT has no null
timestamp / datestringISO-8601 UTC; naive stamps read as UTC
JSONB / array / bytesrejectedproject a scalar column instead

Omitting the key for NULL is the only faithful encoding, and it puts a real obligation on the client: a style expression reading that attribute must supply its own fallback. The upside is that "no data" and "a genuine zero" stay distinguishable in the tile — which matters most on a choropleth, where collapsing them shades unmeasured regions as though they were measured.

Versioning policy

3tears-geo versions in lockstep with the rest of the 3tears monorepo: every package shares one version, tracking the framework git tag. All packages move together.

Downloads & popularity

Awaiting observations

No metric observations yet

Download counts and popularity will appear here as the collector receives them.

Versions 1

Collected releases
VersionPublishedDirect dependenciesOpen version
0.72.020 Oct 20265

Package advisories

0
No matching advisories collected

This describes our collected data; it is not a security assessment.

Repository

ACCESSIBLE
pacepace/3tears

Shared repository data · GitHub

License detected in repository
MIT
License branch / ref
develop
Last push
20 Oct 2026
Archived
No
Contributor identities
— · partial collection

Public sponsor counts have not been collected. These signals belong to the repository owner.

Downloads & popularity

0count
Gauge · observed snapshots20 Oct 2026
10.50
2026-10-20: 0 · observed 2026-10-20T17:18:19.8928356Z
20 Oct20 Oct

github · 1 observations shown. Missing days are unknown.

View observed values
Date / source periodValueObserved at
2026-10-2002026-10-20T17:18:19.8928356Z

Metadata history

Meaningful changes
Current collected state Current

Observed since 20 Oct, 15:38 UTC

No superseded metadata states collected. Unchanged observations do not create new revisions.

Record identity

package:00083b7fb35f23be4aad30e0871d3d345f632aec48c2190ca81db74fb4369c51