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:
- Read the footer (one range request at the end of the file).
- Compare each row group's min/max against the viewport. Discard the ones that provably cannot match — without reading a byte of their data.
- Fetch the surviving column chunks by their recorded byte offsets — a handful of
Range: bytes=A-Brequests.
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
geokey 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:
- Never send a suffix range. The bare LFS object endpoint answers
Range: bytes=-8with200and 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 absolutebytes=start-endonly. - Never trust
Content-Range's total unless the request started at 0 — Forgejo reportssize - startthere (bytes 100-199/2813744on a 2813844-byte object). Probe the size once withRange: bytes=0-0, which does report it correctly. - 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:
covering_of— resolves thecovering.bboxdeclaration from the file-levelgeokey (falling back to the Arrow copy), to four leaf column indices.prune_row_groups/plan_bbox_query— decomposes the intersection into the four ranges and feeds each to the existingrow_group_survives. One pruning rule in the codebase, not two (L5). ReturnsPruneStats: row groups touched, rows, bytes read vs total — all counted from the footer, hence exact and load-independent.read_bbox— plan, then decode only the survivors.Statistics::Float/ColumnIndexMetaData::FLOATarms inrow_group_survives/page_survives(f32 → f64widening is exact, so no slack is introduced).
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:
- Queryable feature data → GeoParquet. POIs, search, "what is here", anything with tags, and anything skade/korp should be able to run a predicate over. This is where §5's 100× lives.
- A rendered basemap at world/country zoom → PMTiles would genuinely be better. Zooming out over Sweden in GeoParquet means reading every vertex of every road; PMTiles has already generalised. If the 2D map ever wants a smooth world-to-street zoom over more than a city, PMTiles is the right tool and this design does not replace it.
- At city scale, which is what the demo is, GeoParquet alone is sufficient — Stockholm's whole packed pack is 171 MiB and any viewport reads a few MB of it. Adding a tile pipeline for one city would be a second format and a second build step for no measured gain.
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.
- 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 aPruneStatssaying what it cost.- An append-capable scene.
OsmGeometrymust accept rows arriving after first paint —extend(points, ways)plus a dedup key (the OSMidcolumn is already in every table and is unique per table) so an overlapping viewport does not double-insert. Todayload_fromreturns an immutable snapshot and the scene is built once.- 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
covering.bboxforwaysis 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.idcosts +3.5 MB after packing because Hilbert order destroys its ascending delta.DELTA_BINARY_PACKEDwill not help a random sequence; a per-row-group sort ofidwithin the group would break the spatial ordering. Accept, or dropidfrom shipped packs.- Sweden needs an external sort.
spatial_packis in-memory; 105 M nodes will not fit comfortably.node_store.rsalready has a chunked external merge sort keyed oni64— re-pointing it at the Hilbert index instead of the node id is the natural path. - Parquet 2.11 / GeoParquet 2.0 native
GEOMETRYlogical type would make the covering column unnecessary (statistics become spatial natively). Worth revisiting when arrow-rs ships it — it would delete the +29 MB. - Add
--compression-leveltoosm-katana spatial-packso 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 forconvert.