"""Routing graph tiles (docs/offroad/PLAN.md R10.2a, R10.3b, Appendix E.7 "Tiles"; platform since docs/backcountry/PLAN.md B13.2: every graph, by key, graphs.py). PC only, never on the VM. python3 tiles.py --graph offroad-official --version 20261021-1a2b3c4d5 \ --attribution "…" [--zoom 11] [--report tiles.json] python3 tiles.py --graph offroad-osm --pois pois-osm.geojsonseq --version … \ --source-version "OpenStreetMap 2026-10-21" --attribution "© OpenStreetMap contributors" MVT built with tippecanoe at ONE zoom, layer `e`: each edge whole in every tile it touches (no clipping: the device dedupes by src|sid|a|b), nothing simplified, dropped or size-limited, coordinates on a 2^14 grid (≈ 1.2 m at z11). Only the graph's edge properties travel (its edge_props), plus `v` (the graph version, Appendix B: YYYYMMDD-). Martin serves the archive as a source named after its layer, e.g. `offroad-graph-official` (every .pmtiles in /data/tiles, by file name) and `pmtiles extract` copies it into offline packs, metadata included: {"graph" (its kind), "version", "zoom", "nodeId"} (pmtiles_meta.py). An OSM graph (kind osm, R10.3b) is its own archive, never joined with an official one (ODbL, RJ-14): layer `e` and layer `p` (its points), and the ODbL notice, licence URI and 4.6 offer in its metadata (odbl_fields(, …), as stamp_odbl writes them). An official build refuses an OSM edge and an OSM build anything that isn't one. A tile over 1 MB is listed in the report (`over_1mb`) with its edges: E.7 records the cause, fixed or accepted. """ import argparse import json import os import shutil import subprocess import sys import tempfile HERE = os.path.dirname(os.path.abspath(__file__)) sys.path[:0] = [HERE, os.path.join(HERE, '..', '..', 'lib')] # route-graph/lib, then tools/geodata/lib import graphs # noqa: E402 import pmtiles_meta # noqa: E402 from pmtiles_read import Reader # noqa: E402 ZOOM = 11 DETAIL = 14 # 2^14 units per tile: ≈ 1.2 m at z11 LAYER = 'e' NODE_ID = 'lat1e5*36000001+lon1e5' # nodeid.py / nodeId.ts MAX_TILE_BYTES = 1 << 20 class TileError(Exception): pass def tippecanoe_args(src, out, attribution, zoom=ZOOM, graph=None, tmp=None, pois=None): """The tippecanoe command line (E.7): one zoom, no clipping, no limits, no simplification, the graph's property whitelist; with `pois`, a second layer `p` (an OSM graph's points).""" s = graphs.spec(graph) layers = ['-l', LAYER] if pois is None else ['-L', f'{LAYER}:{src}', '-L', f'p:{pois}'] args = ['tippecanoe', '-q', '-f', '-o', out, *layers, '-Z', str(zoom), '-z', str(zoom), '-B', str(zoom), '-r1', '--no-clipping', '--no-feature-limit', '--no-tile-size-limit', '--no-line-simplification', f'--full-detail={DETAIL}', f'--low-detail={DETAIL}', '-n', s['name'], '--attribution', attribution] for p in graphs.props(s): args += ['-y', p] if tmp: args += ['-t', tmp] return args if pois is not None else args + [src] def metadata(graph, version, zoom=ZOOM, source_version=None): """The fields stamped into the archive's metadata (Martin's TileJSON; copied into every offline pack): `graph` is the graph's kind. An OSM graph also carries the ODbL notice (§4.2 licence URI, §4.3 notice, §4.6 offer) and the extract's date.""" s = graphs.spec(graph) fields = {'graph': s['kind'], 'version': version, 'zoom': zoom, 'nodeId': NODE_ID} if s['kind'] == 'osm': fields.update(pmtiles_meta.odbl_fields(s['layer'], source_version)) return fields def prepare(src, dst, version, graph=None): """Copies the edges with `v` added. Official: a tier outside the graph's tiers fails (default clean only, RJ-18: Offroad's unverified rows never reach its graph) and so does an OSM edge; OSM: anything that isn't an OSM edge fails (the two graphs never mix, RJ-14).""" s = graphs.spec(graph) osm, tiers = s['kind'] == 'osm', s.get('tiers', ['clean']) n = 0 with open(src, encoding='utf-8') as f, open(dst, 'w', encoding='utf-8', newline='\n') as out: for line in f: if not line.strip(): continue feature = json.loads(line) p = feature['properties'] if osm: if p.get('src') != 'osm' or 'tier' in p: raise TileError(f"{src}: {p.get('src')}|{p.get('sid')} is not an OpenStreetMap edge; the OSM graph takes no other data") elif p.get('src') == 'osm': raise TileError(f"{src}: OpenStreetMap edge {p.get('sid')} in the official graph (ODbL, RJ-14)") elif p.get('tier') not in tiers: raise TileError(f"edge {p.get('src')}|{p.get('sid')}|{p.get('a')}|{p.get('b')} has tier {p.get('tier')!r}") p['v'] = version out.write(json.dumps(feature, separators=(',', ':'), ensure_ascii=False) + '\n') n += 1 if not n: raise TileError(f'{src} has no edges') return n def measure(path, limit=MAX_TILE_BYTES): """Tile count, total, mean and max bytes (as stored: gzipped MVT), and every tile over `limit`.""" reader = Reader(path) sizes = {zxy: length for zxy, (_, length) in reader.tiles.items()} reader.close() total = sum(sizes.values()) return { 'tiles': len(sizes), 'bytes': total, 'mean_tile_bytes': round(total / len(sizes)) if sizes else 0, 'max_tile_bytes': max(sizes.values(), default=0), 'over_1mb': sorted('%d/%d/%d' % zxy for zxy, n in sizes.items() if n > limit), } def prepare_pois(src, dst, version, graph=None): """A graph's points with `v` added; only its poi_kinds are taken (Offroad OSM: fuel and charging points; Backcountry official: access points), and an official graph's never come from OpenStreetMap.""" s = graphs.spec(graph) kinds, osm = s['poi_kinds'], s['kind'] == 'osm' n = 0 with open(src, encoding='utf-8') as f, open(dst, 'w', encoding='utf-8', newline='\n') as out: for line in f: if line.strip(): feature = json.loads(line) if feature['properties'].get('kind') not in kinds: raise TileError(f"{src}: a point of a kind the graph doesn't take ({', '.join(kinds)})") if not osm and feature['properties'].get('src') == 'osm': raise TileError(f"{src}: an OpenStreetMap point in an official graph (ODbL, RJ-14)") feature['properties']['v'] = version out.write(json.dumps(feature, separators=(',', ':'), ensure_ascii=False) + '\n') n += 1 return n def build(src, out, version, attribution, zoom=ZOOM, graph=None, pois=None, source_version=None): if ('poi_kinds' in graphs.spec(graph)) != (pois is not None): raise TileError('a graph with points (poi_kinds: every OSM graph) needs them (pois), and only it has them') with tempfile.TemporaryDirectory(prefix='graph-tiles-') as tmp: seq = os.path.join(tmp, 'edges.geojsonseq') edges = prepare(src, seq, version, graph) points, n_pois = None, 0 if pois is not None: points = os.path.join(tmp, 'pois.geojsonseq') n_pois = prepare_pois(pois, points, version, graph) part = os.path.join(tmp, 'out.pmtiles') subprocess.run(tippecanoe_args(seq, part, attribution, zoom, graph, tmp, points), check=True) # tippecanoe records its command line, temporary paths included: those go, so a rebuild is byte-identical options = pmtiles_meta.read_metadata(part).get('generator_options', '').replace(tmp, '$TMP') pmtiles_meta.update_metadata(part, {**metadata(graph, version, zoom, source_version), 'generator_options': options}) os.makedirs(os.path.dirname(os.path.abspath(out)), exist_ok=True) shutil.copyfile(part, out + '.part') os.replace(out + '.part', out) extra = {'pois': n_pois} if pois is not None else {} return {'edges': edges, **extra, 'version': version, 'zoom': zoom, **measure(out)} def main(argv=None): ap = argparse.ArgumentParser(description=__doc__.split('\n\n')[0]) ap.add_argument('edges', help="the graph's edges, e.g. edges-official.geojsonseq (offroad-graph/official_graph.py)") ap.add_argument('out', help='output .pmtiles') ap.add_argument('--version', required=True, help='graph version, YYYYMMDD-') ap.add_argument('--attribution', required=True, help="credits.json's attribution line") ap.add_argument('--zoom', type=int, default=ZOOM) ap.add_argument('--graph', required=True, help='the graph key (graphs.py), e.g. offroad-official') ap.add_argument('--pois', help="the graph's points (layer p): an OSM graph's, Backcountry's access points") ap.add_argument('--source-version', help='the OSM extract, e.g. "OpenStreetMap 2026-10-21" (ODbL notice)') ap.add_argument('--report', help='write the counts and sizes here (JSON)') a = ap.parse_args(argv) try: report = build(a.edges, a.out, a.version, a.attribution, a.zoom, a.graph, a.pois, a.source_version) except (TileError, graphs.GraphKeyError) as e: print(f'error: {e}', file=sys.stderr) return 2 if a.report: with open(a.report, 'w', encoding='utf-8', newline='\n') as f: json.dump(report, f, indent=1, sort_keys=True) f.write('\n') print(json.dumps(report, sort_keys=True), file=sys.stderr) if report['over_1mb']: print(f"warning: {len(report['over_1mb'])} tiles over 1 MB ({', '.join(report['over_1mb'][:5])}): record the cause " 'in Appendix E.7 (fixed or accepted)', file=sys.stderr) return 0 if __name__ == '__main__': sys.exit(main())