map-tile-toolkit
Slice integer lines, points and polygons into per-tile pieces on a square tile grid, keeping
the geometry's original vertices. A line never gets a new vertex; a polygon ring gets only synthetic
corners outside the tile, to stay closed. Each tile may have a buffer (under half a tile), which widens
the box a tile keeps and the geometry neighboring tiles share. Mosaic and PolygonMosaic reassemble
the tiles into whole features, exactly: unlike clippers that cut new vertices at the tile edge
(Sutherland–Hodgman, geo's boolean ops), a segment crossing a border is the same original segment in
both tiles.
Usage
Slicer takes one geometry at a time and returns a lazy iterator over its slices:
line,point,polygon— every tile the geometry reaches, even tiles holding none of its vertices;tile_line,tile_point,tile_polygon— one given tile, exactly what the first call yields there.
Each slice is computed only when the iterator reaches it, so stopping early stops the work. A line run
borrows the caller's vertices, so a line with millions of vertices costs no extra memory. Slices come in
no particular order, and a line that leaves a tile and comes back gives it several runs. Nothing
panics: bad input yields a TileError.
use coord;
use ;
Lines
A vertex is kept when one of its two segments touches the tile's buffered box, and a run breaks only where a vertex is dropped. So a run ends at the first vertex past the edge, a segment crossing the tile keeps both its outside ends, a single segment outside is bridged, and only a longer excursion splits the line into several runs:
Polygons
polygon(&rings) and tile_polygon(tile, &rings) take one polygon as its rings, exterior first. A tile
gets one piece: the exterior and the holes reaching the tile, each ring closed (its first vertex
repeated; drop it for an MVT ClosePath) and wound like the input.
A ring keeps every vertex with an edge touching the buffered box. Where it leaves the box and comes back,
its outside vertices are compared with the synthetic corners a detour winding around the tile the same
way would need: with more vertices, the detour replaces them (a straight chord when it needs no corner),
otherwise they are kept. A tile the polygon covers gets just the four corners, and a tile inside a hole
gets nothing. Synthetic corners sit one unit outside the buffered box, at tile-local -buffer - 1 or
extent + buffer, so they never land in it.
Each ring is clipped on its own, so a hole crossing the tile keeps its outside vertices even where the exterior was cut short: past the box, the hole can reach outside the exterior. A piece is therefore technically invalid geometry, but only outside the buffered box; what a renderer draws, clipped to the tile (as MVT renderers do), is valid. Measured on random valid polygons with holes, 11.6% of pieces fail OGC validation there: a hole outside the exterior, an exterior and a hole detouring along the same line, or two detours of one ring crossing. Making the outside valid too would need hole-aware detours and a separate route per excursion, which slow the sweep and still miss cases, for no visible gain. Consumers needing OGC-valid pieces should clip them with a general-purpose clipper.
The polygon is sliced row by row: the call reads the rings once, to bound and index them, and the
iterator then holds one tile row at a time (a polygon inside one tile is lent as it is). Tiles the
polygon covers entirely come as FillRun { y, x } row spans, so cost grows with the perimeter, not the
area. fill_ring(&run) is the one ring each of those tiles holds: the four corners, closed. Pieces are
built in the slicer's reused buffer, so the iterator is lending (loop with while let) and polygons
need &mut Slicer. Reuse one slicer per worker: it stops allocating once warmed up, and
shrink_to_fit() releases what an outlier left behind.
signed_area_2x gives a ring's exact orientation, to normalize winding once per feature. MVT wants
exteriors with a positive area in its y-down tile coordinates, and holes with a negative one.
use coord;
use ;
Points
point(p) yields the point in every tile whose buffered box holds it: its own tile, plus up to three
neighbors when it lies within buffer of an edge. The iterator knows its length (one to four).
tile_point(tile, p) returns the point in one tile's frame, if the tile holds it.
use coord;
use ;
Unclipped
A slicer built with_clip_mode(ClipMode::Unclipped) gives each tile a line or polygon reaches (exactly
the tiles the default Clipped mode gives anything) the whole geometry once, in that tile's frame,
for renderers that clip themselves. The calls and items are the same, so one consumer loop serves both
modes: a line comes as one LineSlice per tile holding all of it, and a polygon as one PolygonPiece
per tile with all its rings, in covered tiles too (no FillRun). Slices borrow the input, so the
slicer's cost is finding the tiles; to give a line's tile just once it keeps a set of the tiles found so
far. The consumer pays the whole geometry per tile. Points are unaffected.
use coord;
use ;
Multi-geometries and attributes
The slicer has no multi-geometries and no feature attributes. Slice a MultiLineString, MultiPoint or
MultiPolygon one element at a time, and pair every slice with its feature's id and properties: the
caller does that per tile anyway, and it keeps the slicer free of state that grows with the feature.
Slices of several elements landing in one tile are several slices there, which the caller groups (into
one MultiLineString per tile, say). See
examples/mvt_features.rs
for a round trip emitting per-tile GeoJSON with ids and properties.
use coord;
use ;
Per-vertex data is the Vertex type instead (default Coord<i32>): Measured<M> pairs a position with
any Copy + PartialEq payload (an M value, an id), which rides through slicing and merging unchanged.
A polygon's synthetic corners carry M::default().
use ;
Coordinate space
The slicer is integer-only: extent is both the tile side and its output resolution (the vector-tile
extent). Vertex x belongs to tile x.div_euclid(extent), whose own area is [0, extent) in its
local frame x − tile · extent; buffer vertices, a line's first vertex past an edge and synthetic
corners lie outside it. Add tile · extent to get global coordinates back. Do float and projection work
up front (e.g. with geo); for web mercator at zoom z, project, simplify, then
scale by 2^z · extent / circumference, move the top-left corner to the origin, flip y, and round,
landing in [0, 2^z · extent).
Limits
A segment is walked through exactly the tiles it touches, so cost follows those tiles, not its bounding box: a world-spanning diagonal at z14 (extent 4096) touches about 35,000 tiles. Each call bounds the geometry first and returns any error then, so the iterators cannot fail midway (except as noted):
i32coordinates (Overflow):2^z · extentmust fit (up to z18 at extent 4096), and so must the buffered box of every tile the geometry reaches. A line or polygon spanning more than2^31units on one axis is rejected whole.u32indexing (GeometryTooLarge): at mostu32::MAXrings or edges per polygon, andu32::MAXvertices per piece; the last is the one error a polygon iterator can yield.
There is no limit on how many tiles a geometry reaches: memory stays bounded, and the work is only what the caller pulls.
Merging tiles back
Mosaic is the inverse of Slicer::line: add each tile's runs, in its local frame, and it reassembles
whole features across tile borders, in the global frame. Not every tile is needed. It rejects a tile
inconsistent with those already added (a shared segment that differs, or a tile that owns a vertex but
lacks its segment), names the conflicting tiles, and stays unchanged. purge drops a tile, and
iter_features walks the features. PolygonMosaic does the same for polygon pieces: it keeps only the
ring edges touching each tile's buffered box (so it needs the buffer too), which drops every synthetic
edge, since those lie strictly outside it.
use BTreeMap;
use coord;
use ;
Development
This project uses just: just lists the commands, just test
runs the tests, and just bless regenerates the snapshots. Tests are data-driven: every fixture in
tests/{polylines,polygons,points}/fixtures/ is sliced by both the all-tiles and the one-tile calls,
which must agree, also clipped and unclipped. The results are snapshotted as GeoJSON that renders on a
map; just visualize-tests builds tests/visualize.qgz to inspect them all in QGIS, over the tile grid
in tests/grid.geojson.
License
Licensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE or https://www.apache.org/licenses/LICENSE-2.0)
- MIT license (LICENSE-MIT or https://opensource.org/licenses/MIT) at your option.
Contribution
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual-licensed as above, without any additional terms or conditions.