MaksForge

Kozica

Android app

A hiking map of the Polish mountains that works with no signal at all.

Building

Relief is built by us from the three national lidar terrain models, once, and pinned

Context

Phase 4 draws shading and contours, and phase 5 reads a route’s ascent, from one height grid on the phone (phase-4.md). The grid has to cover the nine Regions, which cross from Poland into Slovakia and Czechia. Two things decide its quality more than its resolution: whether it measures the ground or whatever stands on it, and whether it steps at the two state borders.

Most of the Beskidy, Bieszczady and Sudety are forest. A model that measures the canopy draws contours that bulge 20–30 m at every forest edge, and an ascent summed over it climbs at each.

Each of the three countries publishes a lidar terrain model, free to reuse: Poland’s GUGiK NMT (1 m, and 5 m), Slovakia’s ÚGKK DMR 5.0 (1 m) and Czechia’s ČÚZK DMR 4G (5 m). None of their licences is share-alike: GUGiK asks that the source is stated, the other two are CC BY 4.0. Read on 2026-10-22:

Decision

The grid is built by scripts/build-relief.py from the three national terrain models, each fetched sheet by sheet through its agency’s own index, resampled onto Web Mercator at zoom 12 (about 12 m) as it arrives, and merged into one grid across both borders. The build fails on any empty cell inside the Regions, clipped to the three countries.

It is built once, not per release. A workflow started by hand attaches the output to a GitHub release of its own, and scripts/relief.pin holds its tag and hashes; publish-play.yml downloads and verifies it. A new build is a decision, taken when an agency publishes a new survey.

It ships as one fast-follow relief pack for all Regions together, not inside each Region’s.

Alternatives

Copernicus GLO-30. One worldwide 30 m grid in a public bucket, one licence, no seams. It lost because it is a surface model: the canopy problem above, across most of the area the app covers.

Mapterhorn’s tiles. An open project that already merges these three national models, with GLO-30 filling its gaps, published as Terrarium tiles MapLibre reads directly; a Region’s cut is one pmtiles extract. It would have saved the downloaders and the seam. The owner chose to own the build end to end, as the OSM data is built from Geofabrik’s extracts rather than taken ready-made: a third party’s build can change or vanish, and pinning a copy of it still leaves the build itself outside the repository.

The national models with GLO-30 filling gaps in advance. It cannot fail on a gap, which is the point against it: a fourth source and its long fixed notice added for gaps no one has seen. If one appears, it is filled for that gap, in an amendment here.

Built on every release, like the OSM data. The ground does not change between two OSM pins, so each release would re-download several GB from three agencies to rebuild identical bytes, and would fail whenever one of them is down.

Relief inside each Region’s pack. It was the first plan, so relief came and went with its Region. With every Region always on the phone (phase-2c.md) there is no coming and going, and the Regions’ overlaps would stack two shading layers into a dark band at every seam.

Consequences

  • Three downloaders to keep working, each on an agency’s own index. They run only when the relief is rebuilt, so a broken one is found then, not at a release.
  • The build is reproducible from the repository, but only as long as the agencies keep serving the same sheets. The pinned release is the copy that every release actually packs.
  • Three credits on the licences screen, a short line on the map, and no database to offer: unlike ODbL, none of the licences reaches the derived grid.
  • Poland’s heights mix PL-EVRF2007-NH with PL-KRON86-NH, sheet by sheet, because many of the newer PL-EVRF2007-NH 1 m grids are photogrammetric (the amendment below). The KRON86 sheets are Kronstadt heights, like the Baltic heights Slovakia and Czechia use, and all of them differ by well under a metre. Invisible against 20 m contours; a later use that needs better is a new decision.

Amendment — 2026-10-25: what the agencies serve, and a build on the owner’s PC

Reading each agency’s service before writing the build changed three of the Decision’s assumptions; the decision itself, the three national models built by us, stands.

  • Poland’s 5 m NMT is photogrammetric, not lidar. GUGiK’s sheet index marks each 5 m grid’s source as aerial photographs, which in forest is the canopy problem this ADR exists to avoid. GUGiK marks many of its newer PL-EVRF2007-NH 1 m grids as aerial photographs too, and its layers name their sheets in different schemes, so one piece of ground sits under several names. The build therefore chooses by ground, not by name: lidar first, 1 m before 0.5 m, the newer datum, then the newer survey, each taken only where no better one covers the ground, and photogrammetric heights fill only cells no lidar reaches. For the Regions that is 5,041 lidar sheets, mostly 1 m PL-EVRF2007-NH of 2019–2026 with about 100 of the PL-KRON86-NH survey of 2012–14, and 79 photogrammetric ones (phase-4.md § 2). That makes Poland about 215 GB to download.
  • Czechia’s DMR 4G ships as LAZ point files, a regular 5 m grid of points rather than a raster, under CC BY 4.0 (“Data poskytována pod licencí: Creative Commons CC-BY 4.0”, atom.cuzk.gov.cz).
  • Slovakia’s DMR 5.0 has no sheets: one 197 GB archive holding a single 157 GB GeoTIFF. The build reads the same model through ÚGKK’s INSPIRE WCS at 5 m, whose heights are ellipsoidal, and corrects them with ÚGKK’s own height grid (sk_gku_Slovakia_ETRS89h_to_Baltic1957.tif, CC BY 4.0), pinned by hash. ÚGKK’s CC BY 4.0 is now confirmed first-hand: its ALS provision page says “Author ÚGKK SR, License CC-BY 4.0” and asks that the source be given as “ÚGKK SR” (provision page, terms).

The build runs once on the owner’s PC, not in a hand-started workflow. Decided with the owner: 215 GB in one go would pass a hosted runner’s six-hour limit, and splitting it across jobs buys nothing for a build that runs once. This is not the local build ADR-0007 rejected. That one would have put a developer’s machine between a release’s tag and its bundle on every release; this one produces a pinned input, published to its own release with its hashes in scripts/relief.pin, which every release then downloads and checks, and which anyone can rebuild from the committed, deterministic script and the sheet lists published beside the archives: the agencies’ indexes gain a survey year at a time, so the live ones would pick other sheets.

Amendment — 2026-10-25: the Rozkoš reservoir, filled from its shore

The first full build failed its gap check on 7,481 cells in Sudety Środkowe: the Rozkoš reservoir near Česká Skalice, whose surface ČÚZK leaves out of both DMR 4G and DMR 5G. Water is not a missing survey but a surface a terrain model does not measure, and GUGiK’s grids already carry their lakes at the water level. So the named source for this gap is the reservoir’s own shore: the build fills each gap inside the reservoir’s listed bounds that data surrounds at one level, the lowest tenth of the heights around it (the median would lift the water onto its banks), and logs the level. The list is WATER in scripts/build-relief.py; a water body joins it only through a gap a build has found, and every other gap still fails.

Amendment — 2026-11-01: heights in whole metres

Play sends the relief pack whole with every update, although its bytes never change between relief builds (phase-2b.md R10, R11): 268 MB of every update, to every install. Decided with the owner: the grid keeps whole metres instead of tenths. A pixel averages about 12 m of ground whose height varies by metres within it, so the tenths described the averaging, not the ground, and they were two thirds of the archive: relief.pmtiles is 86 MB instead of 257 MB. Measured before the decision, on the test phone and against relief-2026-10-25:

  • The map is unchanged. Three views, zoom 13 to 15.5 over the Tatry, differ by at most 6 of 255 in any channel, which no one can see.
  • An ascent moves by about 0.5 %. Phase 5’s dead band counts a climb only past 5 m, and a height moves by at most 0.5 m: over 112 profiles of 6.4 km, +4.5 m on average, +30 m at most. Read again on the Trails once 0.14.4 rebuilt the network from it, against 0.14.3’s at the same OSM pin: the whole network’s ascent +0.15 %, the median Trail’s +0.05 % with a median absolute change of 0.9 m, but only 60 % of the 2,290 Trails within 1 %, 75 % within 2 % and 90 % within 6.35 %. The large percentages fall on Trails that climb little, where a metre or two across the dead band is a big share: the 871 Trails climbing 300 m or more move +0.03 % in the median and +4.2 % at worst, and the largest change is +65 m on a 396 km regional Trail (+0.5 %).
  • Play sends 96.8 MB for the pack instead of 267.8 MB, read on the test phone in 0.14.3 → 0.14.4 (phase-2b.md R12).

relief-2026-11-01 is relief-2026-10-25 re-encoded, not rebuilt: build-relief.py --reencode reads the published archive and writes each tile through the same terrarium() a full build uses, which rounds to tenths and then to metres, so the two give the same bytes. The contours are drawn from the grid itself, not from the tiles, and stay as they were. The Trail network reads the relief for its ascents, so it is rebuilt from the new one with the release that first packs it.

Hi, I'm Maksymilian.

MaksForge is where I build the small tools I wish existed. Each one solves a single problem.