Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/znippy-zoomies

.nornir/geoparquet-spatial-layout-design.md

GeoParquet spatial layout — one file, read in pieces (2026-07-22)

Answers Rickard's question, verbatim:

"hmm 130mb I think I put on the wiki. Would it be multiple small files? Read when close to the point? OSM 2D and 3D needs to handle that, like a tile server? How does GeoParquet suppose to work?" — and the follow-up: "well make osm 3d 2d able to handle lfs git?"

Short answer. Not multiple small files. One file, read in pieces by HTTP byte range — a properly written GeoParquet is its own spatial index. osm-katana's output was not properly written: rows came out in PBF parse order, so every row group spanned the whole extract and nothing could ever be pruned. Fixed. For one city block in Stockholm the node table now reads 792011 bytes instead of 79189221 — 100× fewer — in 4 row groups out of 575.

And yes: Codeberg's git-LFS serves HTTP Range requests (measured, §4). So a Hilbert-packed GeoParquet committed through LFS behaves like a tile server with no tile server. There is one trap that would silently defeat it, and the reader is built around it.

Companion: workspace_nordisk/.nornir/demo-map-data-sizing.md (the measured sizes this builds on — Sweden 2.28 GiB, Stockholm 130.0 MiB, POI layer 5.3 MiB).

Host stamp. All figures measured on oden (NORNIR_MACHINE=oden, Threadripper PRO 3975WX, 32 physical cores, SMT off, 499 GB RAM, NVIDIA RTX 4090), 2026-07-22. A new host row — never merged with loki · 12 cores.

⚠ Load caveat. The box carried other agents throughout; uptime load averages during this work ranged 44 → 228. Every headline number below is a byte count or a row-group count read out of the Parquet footer — deterministic, load-independent, and reproducible under any load. No wall-clock number is used to justify any decision. The two timings that do appear are labelled as taken under load and are not load-bearing.


1. How GeoParquet is supposed to work

Not "one big blob you download". A Parquet file is a self-describing, seekable archive:

┌──────────────────────────────────────────────────────── nodes.parquet ────────┐
│ row group 0   │ row group 1   │ … │ row group 574 │            FOOTER         │
│ ┌───────────┐ │ ┌───────────┐ │   │ ┌───────────┐ │  per row group, per column│
│ │id  chunk  │ │ │id  chunk  │ │   │ │id  chunk  │ │    · byte offset + length │
│ │geom chunk │ │ │geom chunk │ │   │ │geom chunk │ │    · MIN / MAX statistics │
│ │bbox chunks│ │ │bbox chunks│ │   │ │bbox chunks│ │    · row count            │
│ └───────────┘ │ └───────────┘ │   │ └───────────┘ │  + the `geo` metadata key │
└───────────────────────────────────────────────────────────────────────────────┘

A reader that wants "everything near this point" does exactly three things:

  1. Read the footer (one range request at the end of the file).
  2. Compare each row group's min/max against the viewport. Discard the ones that provably cannot match — without reading a byte of their data.
  3. Fetch the surviving column chunks by their recorded byte offsets — a handful of Range: bytes=A-B requests.

That is the whole mechanism, and it is why the answer to "multiple small files?" is no. Splitting into many small files is a strictly worse version of the same idea: it moves the index out of the file and into the filesystem/URL layout, costs one request per tile, loses Parquet's column pruning, and multiplies footers. The single file already has a spatial index — the footer.

The geo metadata key

Per the GeoParquet 1.1.0 specification:

"A GeoParquet file MUST include a geo key in the Parquet metadata"

with three required fields — version, primary_column, columns — and each geometry column carrying encoding and geometry_types. Two details the spec is precise about and osm-katana was wrong about (§3.1): crs "should be provided in PROJJSON format", and if omitted the default is OGC:CRS84 (longitude/latitude on WGS84) — which is exactly what OSM coordinates are; and bbox, "if specified, MUST be encoded with an array".

covering.bbox — the 1.1 field that makes pruning possible

GeoParquet 1.1 adds covering, a "simplified representation of each geometry". For a bounding box the required shape is exactly:

"covering": { "bbox": {
  "xmin": ["bbox","xmin"], "ymin": ["bbox","ymin"],
  "xmax": ["bbox","xmax"], "ymax": ["bbox","ymax"] } }

pointing at a struct column whose children "MUST be named xmin, ymin, xmax, ymax and MUST be ordered in this same way" and "MUST be of Parquet type FLOAT or DOUBLE".

Why it is not optional. Parquet already records min/max for the WKB geometry column — but those statistics are spatially meaningless. WKB compares lexicographically, and a WKB point is

[0x01][01 00 00 00][8 little-endian bytes of x][8 little-endian bytes of y]
 order   type=Point         ↑ the FIRST differing byte is x's LOW mantissa byte

so the ordering is dominated by the least-significant bits of the longitude mantissa. Sorting or comparing on it tells you nothing about geography. The covering column exists precisely to give the engine four plain numbers it can compare. Dewey Dunnington's survey of lazy GeoParquet reading puts the payoff plainly for the Overture buildings dataset — 237 files pruned to 2 by 1.0 file-level bbox metadata, then to 6 row groups by 1.1 bbox column statistics:

"Of all those independently fetchable chunks, we fulfill our query result by downloading only six if the producer and consumer both speak GeoParquet 1.1!"

…and why the covering alone is still not enough

A covering prunes nothing if the rows are in parse order. Every row group would then contain a random scatter across the whole extract, so every group's min/max is the file's min/max and every group survives every query. This is the state osm-katana's output was in.

Both halves are required, and each is inert without the other:

no covering covering
parse order nothing prunable — osm-katana before groups all span the file — still nothing
Hilbert order groups are compact but unreadable — still nothing prunes

geoparquet.io's best practices name the same pair: apply Hilbert ordering so "spatial queries read fewer row groups", and include a bbox column because "without bbox: must decode WKB geometry and compute intersection (slow); with bbox: compare 4 numbers (fast)".


2. Hilbert ordering

A Hilbert curve maps 2-D space to a 1-D index while preserving locality: points close on the ground get close indices. Sorting rows by that index makes each row group a compact patch of map. It is what Overture Maps does, and what GDAL's Parquet driver offers as SORT_BY_BBOX.

        parse order (before)                    Hilbert order (after)
   ┌───────────────────────────┐          ┌───────────────────────────┐
   │ ·  ▲  ·  ■  ·  ▲  ■  ·  ▲ │          │ ▲▲▲▲  ■■■■  ●●●●  ◆◆◆◆   │
   │ ■  ·  ●  ·  ▲  ·  ·  ◆  · │          │ ▲▲▲▲  ■■■■  ●●●●  ◆◆◆◆   │
   │ ·  ◆  ·  ▲  ·  ■  ●  ·  · │          │ ▲▲▲▲  ■■■■  ●●●●  ◆◆◆◆   │
   └───────────────────────────┘          └───────────────────────────┘
    every row group spans the map          each row group is one patch
    → min/max = file bounds                → min/max = a small box
    → 0 row groups can ever be pruned      → 4 of 575 survive a city block

osm-katana spatial-pack implements it: 31 bits per axis (a 62-bit index, so it stays a non-negative i64; 360°/2³¹ ≈ 1.9 cm of longitude, finer than OSM's own 1e-7° grid), keyed on each feature's bounding-box centre. Rows with no usable geometry keep their input order and sort last, so they occupy their own trailing row groups and never widen a real group's bounds.

It is a separate command, not a change to convert, on purpose. convert is a streaming pipeline whose throughput numbers are the point of the crate; a global sort needs every row in hand and would wreck it. Packing is a post-process, exactly as Overture and GDAL do it.


Since 2026-09-01: band first, Hilbert inside (DELTA 3)

The sort key is no longer the Hilbert index alone. spatial::pack_batches orders rows (min_zoom, hilbert) — the rung a feature first draws at, then the curve — so a reader asking min_zoom <= camera prunes whole row groups off the footer before it looks at a single covering box, and inside a band every row group is as compact as before. A table with tags and no min_zoom gets the column derived by the pack; is_packed refuses a Hilbert-only table so it is upgraded. Numbers and the red arms are in osm-katana-v2-design.md, DELTA 3.

3. The three defects fixed

3.1 The geo key was in the wrong place — output was Parquet-with-WKB, not GeoParquet

The sizing doc reported "no geo metadata key". The truth is sharper and more interesting: the key was being written, into the Arrow schema's metadata — which arrow-rs serialises into the base64 ARROW:schema blob. Arrow-native readers decode that blob and therefore "see" geo; the spec's location, and the only place GDAL/OGR, DuckDB-spatial and geopandas look, is the file-level Parquet key-value metadata, where it was absent. Measured on the shipped pack:

FILE-LEVEL parquet key_value_metadata keys: [b'ARROW:schema']       ← no 'geo'
arrow schema metadata: {b'geo': b'{"version":"1.0.0",…}'}           ← hidden in here

That is why pyarrow appeared to find it while the sizing agent's spec check said it was missing — both were right about different things. Now written in both places (the Arrow copy retained, so nothing that worked stops working). After:

nodes file-level keys: ['ARROW:schema', 'geo']
   geo = {"version":"1.1.0","primary_column":"geometry",
          "columns":{"geometry":{"encoding":"WKB","geometry_types":["Point"]}}}

Two spec violations fixed at the same time:

field before after why
crs "EPSG:4326" (a bare string) omitted spec wants PROJJSON; omitted ⇒ OGC:CRS84, which is exactly what OSM data is
bbox null omitted "MUST be encoded with an array" — null is not legal
version 1.0.0 1.1.0 needed for covering

3.2 node_coords.arrow written unconditionally

75 MB for Stockholm, 1.68 GB for Sweden, left on disk after every convert — even a run that only wanted parquet. It is not removable outright: it is the mmap-backed node store pass 2 resolves way geometry through. So it is still always written, and now optionally deleted: --keep-node-coords false removes it and the _nc_chunk_*.bin spills once the parquet is closed. Default stays true, so no existing behaviour changes.

3.3 THE HEADLINE — spatial ordering + covering

osm-katana spatial-pack <in-dir> -o <out-dir>. Hilbert-sorts and adds covering.bbox.


4. Delivery: does git-LFS actually let you range-read?

This is the crux of "make osm 3d 2d able to handle lfs git?". If LFS object storage serves byte ranges, a Hilbert-packed GeoParquet on LFS is a tile server. If it does not, LFS is a whole-file download channel and 95.6 MiB must land on disk before the first pixel.

Measured live against Codeberg, 2026-07-22 — not assumed.

channel absolute Range suffix bytes=-N HEAD Content-Range total
/{owner}/{repo}/media/branch/… (LFS-aware raw) 206 ✔ 206 ✔ 200 + accept-ranges: bytes correct
/{owner}/{repo}/releases/download/… 206 ✔ 206 ✔ 200 + accept-ranges: bytes correct
/{owner}/{repo}.git/info/lfs/objects/{oid} 206 ✔ 200 — WHOLE FILE ✘ 404 ✘ wrong unless start = 0
/{owner}/{repo}/raw/branch/… (plain git blob) 206 ✔ 416 200, no accept-ranges correct

So: yes, git-LFS on Codeberg serves byte ranges. The full LFS fetch path works unauthenticated on a public repo — POST …/{repo}.git/info/lfs/objects/batch with {"operation":"download","objects":[{"oid":…,"size":…}]} returns a download.href, and that href answers Range with 206 Partial Content. Range bytes verified byte-identical to the same span of the full file.

Three traps, all real, all measured:

  1. Never send a suffix range. The bare LFS object endpoint answers Range: bytes=-8 with 200 and the entire object. The textbook Parquet footer read is exactly a suffix range, so a naïve reader would silently download 95 MiB to find 8 bytes. Use absolute bytes=start-end only.
  2. Never trust Content-Range's total unless the request started at 0 — Forgejo reports size - start there (bytes 100-199/2813744 on a 2813844-byte object). Probe the size once with Range: bytes=0-0, which does report it correctly.
  3. Never use HEAD — 404 on the LFS object endpoint.

Prefer /media/. It is fully RFC-7233 compliant, resolves LFS pointers transparently, and needs no batch call, no git-lfs client and no clone. Release attachments are equally compliant and do not consume the git quota.

(Already established in the sizing doc and not re-derived: Codeberg wikis have no LFS server at all — .wiki.git/info/lfs/objects/batch 404s while normal repos 200 — and the quotas are 750 MiB git + 1.5 GiB LFS/releases per org, 100 MiB per release attachment.)


5. THE NUMBER

Same content in both files; the only difference is row layout. Query: one city block in Gamla Stan, Stockholm — 18.0700,59.3235 .. 18.0740,59.3255, about 227 m × 221 m.

Reproduce with: cargo run -p skade --release --example geo_bbox_prune_bench -- <convert-out> <packed-out>

nodes.parquet — 4705949 rows

projection layout row groups touched bytes read of file
all columns convert (parse order) 31 / 31 79189221 100.00%
all columns spatial-pack (Hilbert) 4 / 575 792011 0.69%
100× fewer bytes
id + geometry convert 31 / 31 75382915 100.00%
id + geometry spatial-pack 4 / 575 497928 0.61%
151× fewer bytes

ways.parquet — 735420 rows

projection layout row groups touched bytes read of file
all columns convert 24 / 24 55722525 100.00%
all columns spatial-pack 8 / 90 5562065 8.83%
10× fewer bytes
id + geometry convert 24 / 24 30950322 100.00%
id + geometry spatial-pack 8 / 90 2664908 8.54%

A whole viewport (nodes + ways, id + geometry): 106333237 → 3162836 bytes. 33.6× fewer — 3.0 MB instead of 101 MB.

Ways prune less well than nodes for a real geometric reason, not a defect: a way's covering is its whole extent, and a motorway that crosses the city has a city-sized bounding box, so it is a candidate for every query. That is inherent to bbox covering of long features and is why nodes — the layer that actually dominates the bytes — is where the win lands.

As byte ranges: what a viewer actually asks the server for

For the same query on the packed nodes.parquet, coalescing chunks within 64 KiB:

6 contiguous ranges, 555390 bytes of data:
   bytes=14291-41723            (27433 B)
   bytes=130139-245994         (115856 B)
   bytes=57700460-57729533      (29074 B)
   bytes=57815269-57972202     (156934 B)
   bytes=58059110-58175885     (116776 B)
   bytes=114271287-114380603   (109317 B)
plus a 659093-byte footer, read once and cached for the session.

Six range requests. That is the tile server, and there is no tile server.

Content equivalence — the gate

osm-katana verify --digest. The set fold is order-independent; ord also pins row order.

                        BEFORE (convert)                      AFTER (spatial-pack)
nodes.parquet     set 17d0dd3f25565c93   ord 08b998890a9eebff  →  set 17d0dd3f25565c93  ord 163fb936f4ee86bd
ways.parquet      set 01c4f5b4774a29ee   ord 12a2c9beb169a909  →  set 01c4f5b4774a29ee  ord 1147fec8c8973ca5
relations.parquet set 1360ec5d868f3197   ord 14295ce611696a1b  →  set 1360ec5d868f3197  ord 14295ce611696a1b

Every set digest is identical — same rows, same values, none lost, none duplicated. ord moved for nodes and ways, which is the entire point; it did not move for relations, because every relation is geometry-less and those sort last in input order. Exactly the expected signature.

The covering column is derived (a pure function of geometry), so digest now excludes it — otherwise a packed table could never compare equal to the convert output it came from, and the gate would be useless precisely when it matters.


6. The two design decisions, both measured

6.1 Covering as FLOAT + BYTE_STREAM_SPLIT, not DOUBLE

The first implementation used f64 and made nodes.parquet 3× larger (79 MB → 241 MB) — because for a point, xmin == xmax and ymin == ymax, so the covering stores each coordinate four times. Priced, on Stockholm's 4705949 nodes, compressed bytes for the four columns:

encoding bytes
f64, plain zstd 132611232
f64, BYTE_STREAM_SPLIT 97321255
f32, plain zstd 46296340
f32, BYTE_STREAM_SPLIT 28774887

4.6× smaller than the naïve choice, and the spec explicitly permits FLOAT. BYTE_STREAM_SPLIT transposes the four bytes of each f32 into four planes; after Hilbert sorting neighbouring rows share sign, exponent and high mantissa almost exactly, so three of the four planes become long runs that zstd flattens.

A covering is a conservative approximation by definition — the reader re-checks the real geometry — so spending 8 bytes to re-state a coordinate the WKB column already holds exactly is pure waste. f32 costs about 2 m of slop at Stockholm's latitude, invisible at row-group granularity (a group spans hundreds of metres at minimum).

Correctness is held by rounding outwards: minima round down, maxima round up, so the stored box always contains the true box and a row group is never pruned away when it might have matched. Round-to-nearest would shrink roughly half of all boxes; the covering_is_conservative test asserts the direction against 20000 real 1e-7° OSM coordinates and checks that the fixture actually exercises the case a naïve cast gets wrong.

6.2 8192 rows per row group

The pruning/compression dial. Swept on Stockholm — bytes read for the city-block query, and packed file size:

rows/group row groups nodes read ways read footer bytes packed total
8192 575 0.69% 8.83% 659093 178486633
16384 288 0.86% 15.59% 330813 179619338
32768 144 2.73% 29.90% 167149 177283892
65536 72 4.15% 48.24% 85849 171899868
131072 36 8.40% 100.00% 44201 167987505

8192 reads 6× fewer bytes than 65536 for 3.8% more file, and at ~200 KB of compressed node data per group it is a sensible HTTP range-request size — which is the delivery this layout exists to serve. (At 131072 the ways table stops pruning entirely: 6 groups, all candidates.)

Honest caveat: the footer grows with the group count, and at 8192 it is 659 KB. Cold-start cost for one query is footer + data: 8192 → ~1.45 MB, 16384 → ~1.33 MB — a wash. Warm (footer cached, which is what panning does) 8192 wins by 6×. 8192 is the default; 16384 is the right choice if cold first-paint dominates and only one query is ever made.

What it costs

convert spatial-pack Δ
nodes.parquet 79282235 115323568 +45%
ways.parquet 55769366 63163065 +13%
relations.parquet 1272672 1280025 +0.6%
total 136324273 (130.0 MiB) 179766658 (171.4 MiB) +31.9%

Where the +36 MB on nodes goes: +29 MB covering (the thing being bought), +3.5 MB because Hilbert order destroys the id column's ascending delta locality, +2.6 MB from smaller row groups. Paid once, on disk, to read 100× less per query. For a viewer that pans, that trade is not close.


7. The reader — does skade's pruning already engage?

Partly, and the missing part was the important one.

capability state before verdict
row-group min/max pruning (cycle-12) present, row_group_survives ✔ sound, reusable
page-level skipping (cycles 13–14) present ✔
ParquetWindows (footer parsed once, bounded memory) present ✔ but windows over every row group — no predicate
spatial::GeoIndex (geohash) present in-memory, requires a full scan first
a spatial predicate absent ✘ ScanFilter constrains exactly one column; a bbox is a conjunction over four
Statistics::Float absent ✘ would have silently disabled pruning on a FLOAT covering

So the seam was there and the predicate was not. A bbox intersection is

xmin ≤ q.xmax   AND   ymin ≤ q.ymax   AND   xmax ≥ q.xmin   AND   ymax ≥ q.ymin

— four one-sided ranges on four different columns. ScanFilter::{Eq,In,Range} each name a single column, and the whole filtered read path is Iceberg-table-oriented, so it could not be expressed at all. Added, in skade/src/geo_parquet.rs:

Pruning is conservative: a group is dropped only when its own statistics prove no row can match. prune_never_drops_a_matching_row checks that against a brute-force scan. A file with no covering keeps every group and reports covering_used: false rather than quietly claiming a win.

No second reader was written (L5): this is a planner over the existing metadata plumbing.

Consuming it remotely — skade/src/geo_source.rs (feature geo-http)

HttpRangeReader implements parquet's ChunkReader, so plan_bbox_query / read_bbox work unchanged against a URL. Built around the three measured LFS traps of §4: absolute ranges only, size probed via bytes=0-0, never HEAD. An in-process memo coalesces repeats — without it every pan re-pays the 659 KB footer. GeoSource::forge_media / forge_release build the two range-compliant Codeberg URLs.

Tests run against a local TcpListener range server (pure Rust, no network, no external service, L9). One of them reproduces Codeberg's LFS-object behaviour exactly — suffix range answered with the whole file — and asserts the reader never emits one.


8. PMTiles vs GeoParquet — honestly, they are different jobs

They are not competitors, and pretending one answer covers both would be wrong.

PMTiles GeoParquet
holds a pre-rendered tile pyramid (vector or raster), z/x/y rows with attributes, columnar
answers "draw the map here at this zoom" "which features match this predicate"
index Hilbert-ordered tile directory, in-file row-group statistics + covering.bbox, in-footer
access HTTP range per tile, no server HTTP range per row group, no server
built-in LOD / generalisation yes — that is the format's point no — you get every vertex at every zoom
attribute query / joins / analytics no yes — DuckDB, Sedona, geopandas, GDAL
update cost re-upload the archive re-upload the file

Both are cloud-native single-file formats read by byte range. The real difference is level of detail. PMTiles has already decided, per zoom level, which features and which vertices matter; a GeoParquet hands you the full-resolution geometry no matter how far out the camera is. That is decisive for a basemap and irrelevant for a query.

Protomaps' own You Might Not Want PMTiles is candid about the boundary — PMTiles fits when "the experience is built on the web platform", "the total information to be explored is over a few megabytes", and "the dataset changes at most daily, or never"; and for large static data read locally, "QGIS is an excellent, open-source solution" over GeoParquet.

Verdict for this demo:

The two compose: PMTiles under the pins, GeoParquet for the pins and everything clickable.


9. What the demo should ship

Recommendation: the POI-only GeoParquet committed straight to git, plus the full Stockholm pack as a release attachment for anyone who wants the basemap.

option size delivery verdict
POI layer, named only 1658708 (1.58 MiB) plain git file, no LFS ship this — 81726 features
POI layer, all tagged nodes 5564300 (5.3 MiB) plain git file, no LFS fine too, if all 246191 tagged nodes are wanted
Stockholm packed, zstd-19 148982556 (142.1 MiB) release attachments the optional basemap — every file under the cap
Stockholm packed, default zstd 179766658 (171.4 MiB) — nodes.parquet over the 100 MiB attachment cap
Stockholm unpacked, zstd-19 100202418 (95.6 MiB) release attachments the sizing doc's pack; no spatial pruning at all
Sweden 2449332105 (2.28 GiB) — no. Exceeds the whole 1.5 GiB org bucket.

Why POI-only first. 1.58 MiB is small enough to commit as a plain git blob: no LFS, no release, no network at demo time, no range reads, no argument. It is exactly the pin dataset facett-geomap's clustering path wants, and facett-geomap culls 1 M points in 355 ns — 81726 pins is nowhere near the rendering budget. Nothing about this option can go wrong at demo time.

And the full packed basemap does fit — confirmed. The packed nodes.parquet at default zstd is 115323568 bytes, which is over Codeberg's 100 MiB (104857600 byte) per-attachment cap: the covering column pushes it over where the unpacked table (79282235) was under. The zstd-19 repack resolves it, and was measured end to end:

table packed (default zstd) packed + zstd-19 under the 100 MiB cap?
nodes.parquet 115323568 101989362 yes, by 2868238 bytes
ways.parquet 63163065 46088567 yes
relations.parquet 1280025 904627 yes
total 179766658 148982556 (142.1 MiB) 9.5% of the 1.5 GiB org bucket

The repack is not a downgrade — it keeps the layout, the covering and the geo key, and prunes at least as well:

nodes.parquet, same city-block query, zstd-19 repack:
    all columns    4/575 row groups     696904 / 101367812 bytes   0.69%   113.6x fewer
    id+geometry    4/575 row groups     443468 /  72065727 bytes   0.62%   170.0x fewer

and verify --digest gives byte-identical set and ord digests to the packed input (17d0dd3f25565c93 / 163fb936f4ee86bd for nodes), so it is a pure recompression. It costs ~190 s of one-off CPU at pack time (measured under load average 119 — indicative only).

If for any reason the repack is not wanted, two fallbacks: ship with --covering false (Hilbert ordering only, schema unchanged, size within noise of the original — prunes nothing today, but the ordering is already right so adding the covering later is a repack, not a re-convert); or split nodes.parquet by Hilbert range into two attachments, which works but is the "multiple small files" answer this whole document argues against.

Not the wiki, for the reason the sizing doc proved: Codeberg wikis have no LFS server at all. Anything put there is a permanent plain-git blob against the smaller 750 MiB git quota.

If you do want it on LFS in a normal repo, that now works properly — §4 established that /media/branch/… serves ranges correctly, so a packed pack on LFS is genuinely range-readable and the viewer never downloads the whole thing. That is the configuration that makes "read when close to the point" literally true over the network.


10. Progressive loading — what still needs facett

Honest answer: the reader half is done; the render half is not, and it is not mine to write.

(facett is owned by other agents. Nothing in facett was touched. The following is the requirement to route to them.)

What already works with zero facett change. facett-demo/src/osm_fly.rs checks its clip cache before it needs a planet: clip_cached is "nodes.parquet and ways.parquet both exist" in <cache_root>/<region>-<src_hash>-geo, and source_hash(None) returns the literal "no-planet". So a pack materialised into $FACETT_OSM_CACHE/stockholm-no-planet-geo/ is already a cache hit and the demo loads straight to flight. skade::geo_source::{PackCache, ensure_pack} does exactly that materialisation from a URL — turning the cache from "a human placed files here" into "self-populating from a remote source". Also verified: facett-osm's read_points / read_ways select columns by name (column_by_name("geometry")), so the appended bbox column is a no-op for them — a packed pack is a drop-in for the existing loader.

What does NOT work today. osm_tabs::load_from does a single eager full read at startup (read_points(path, MAX_POINTS), read_ways(path, MAX_WAYS_2D)) and hands the scene a finished OsmGeometry. There is no path for geometry to arrive later. "Read when close to the point" requires both:

Requirement for the facett agents — incremental map data.

  1. A viewport-driven data request. The 2D/3D map panes need to emit their current view bounds (and ideally a zoom hint) on camera change, debounced, instead of loading once at startup. skade side is ready: skade::geo_parquet::read_bbox(path_or_url, &Bbox, projection, batch_rows, threads) returns exactly the matching rows and a PruneStats saying what it cost.
  2. An append-capable scene. OsmGeometry must accept rows arriving after first paint — extend(points, ways) plus a dedup key (the OSM id column is already in every table and is unique per table) so an overlapping viewport does not double-insert. Today load_from returns an immutable snapshot and the scene is built once.
  3. A "loading" state that is not a stall. Range fetches take a network round trip; the pane should render what it has and fill in, not block.

Nothing else is required — no tile pipeline, no second format, no server. Item 2 is the real work; item 1 is small; item 3 is a UI affordance.

Until those exist, the honest shipping story is §9's option: a small POI file loaded eagerly, which the current code path already handles perfectly.


11. Reproduce

# convert (unchanged), then pack
osm-katana convert Stockholm.osm.pbf -o out --keep-node-coords false
osm-katana spatial-pack out -o packed              # default 8192 rows/group, covering on

# prove content is unchanged (the `set` fold must match; `ord` will not)
osm-katana verify out    --digest
osm-katana verify packed --digest

# the headline: bytes read + row groups touched, before vs after
cargo run -p skade --release --example geo_bbox_prune_bench -- out packed

12. Follow-ups

  1. covering.bbox for ways is weak on long features (8.83% vs nodes' 0.69%) — a city-crossing motorway is a candidate for every query. A finer covering (per-segment rows, or splitting long ways) would help; not worth it for a city-scale demo.
  2. id costs +3.5 MB after packing because Hilbert order destroys its ascending delta. DELTA_BINARY_PACKED will not help a random sequence; a per-row-group sort of id within the group would break the spatial ordering. Accept, or drop id from shipped packs.
  3. Sweden needs an external sort. spatial_pack is in-memory; 105 M nodes will not fit comfortably. node_store.rs already has a chunked external merge sort keyed on i64 — re-pointing it at the Hilbert index instead of the node id is the natural path.
  4. Parquet 2.11 / GeoParquet 2.0 native GEOMETRY logical type would make the covering column unnecessary (statistics become spatial natively). Worth revisiting when arrow-rs ships it — it would delete the +29 MB.
  5. Add --compression-level to osm-katana spatial-pack so the zstd-19 pack is one command rather than a pyarrow post-step (§9 measured it via pyarrow). The sizing doc raised the same gap for convert.