From 0111866025713ec3ef8f9d376d5e0156c51ffcba Mon Sep 17 00:00:00 2001 From: Ola Skavhaug Date: Sun, 11 Oct 2026 19:45:56 +0200 Subject: [PATCH 01/19] Increment 36 designed: GeoPackage output from rasputin mesh --out X.gpkg (@architect) docs/increments/36-geopackage-output.md: the design. A writer under io/ (sqlite3 from the standard library, no GDAL/OGR/Fiona) chosen by the .gpkg suffix as .vtk and .ply are; layers triangles (PolygonZ with code, name, colour, z_min, z_max, z_mean, area_m2, slope_deg, R-tree indexed), lines (constraint edges with their property bits), land_cover (the clipped, cleaned coded polygons), land_cover_classes (the legend and shares), a run table holding the run record, and the stored QGIS style. Mirror of the reader io/geopackage.py, which reads the files back in the tests. Measured with docs/increments/36-probes/gpkg_write_probe.py on this worktree's .venv (AC power, SQLite 3.53.1): the quick check's Numedalslagen mesh (653,039 triangles) 4.0 s and 203 MB against 0.28 s and 29.2 MB for the binary .vtk, Ola's stopgap 5.1 s; the Romsdalen slope mesh (3,096,202 triangles) 21.5 s and 922 MB at 2.1 GB peak RSS, the stopgap 28.7 s and 4.1 GB. The R-tree is 60 % of the time (about 4.6 us per triangle); feeding it in Morton order was slower, a 256 MB page cache 25 % faster; building blobs and rows per batch of 100,000 cut peak RSS from 5.8 GB to 2.1 GB. Two writes of each mesh gave identical SHA-256; timestamps are fixed text. Left out: the per-triangle source-polygon ID (its own step, as the ROADMAP row says) and bulk R-tree loading (36b, for basin scale: about 8 minutes of index and a 30 GB file at 1e8 triangles). Estimate about 305 net production lines. Byte-identical .vtk/.ply pinned by test_refine_golden's GOLDEN_STRIDE_VTK and the quick check's mesh_sha256. ROADMAP.md: the row numbered 36, status designed, with the figures. Co-Authored-By: Claude Fable 5.1 --- ROADMAP.md | 2 +- docs/increments/36-geopackage-output.md | 315 +++++++++++++++++ docs/increments/36-probes/gpkg_write_probe.py | 317 ++++++++++++++++++ 3 files changed, 633 insertions(+), 1 deletion(-) create mode 100644 docs/increments/36-geopackage-output.md create mode 100644 docs/increments/36-probes/gpkg_write_probe.py diff --git a/ROADMAP.md b/ROADMAP.md index 12277b1d..a80ec3e2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -56,7 +56,7 @@ increment that most needs a picture to check against | 30d | Growing the catchment outline, made faster, with every mesh byte-identical: the same mitred buffer of the domain computed once instead of twice on the reprojected path; the CORINE search region grown from the domain's convex hull instead of taking the hull of the grown domain; and a long staircase outline (SMHI's raster-traced ones) grown in pieces of 1,000 edges and united, behind a gate that keeps smooth outlines on GEOS's single buffer. Measured by `@perf` on Lagan with the European CORINE GeoPackage: 103.4 s on master, about 17 s with all three (`docs/benchmarks/2026-10-07/30d-buffer/README.md`); from the performance review's finding F1 (PR #208). Ola, 2026-10-07: grow the outline in pieces; the gate is an open question, default yes | **merged (#215, 2026-10-07)** (branch `worktree-buffer-speed-2`; 29 net production lines; Python only, no refine or mesh code; Lagan 103.38 s → 16.97 s, meshes byte-identical; push waits on Ola) | `docs/increments/30d-outline-buffer-speed.md` | | 31 | Binary output by default: `rasputin mesh` writes binary `.vtk` (and `.ply`) unless `--ascii` is given, because the text writer formats one number at a time and costs minutes at basin scale (the performance review's finding F6 and question 3, PR #208). Ola, 2026-10-07: "change 3 to default binary". The flag pair `--binary/--ascii` stays; the help says binary is the default; README updated. Python only; no `@perf` run (not refine or mesh code) | **merged (#212, 2026-10-07)** (designed 2026-10-07; red `e3fe391a`/`9008811c`, green `29bc5ad3`; 4 net production lines against about 1, the overrun is `ruff format` wrapping the option); Ola ruled `.ply` binary too, as built; next the push | `docs/increments/31-binary-default.md` | | 32 | Land-cover borders simplified within a band, each class keeping its area exactly (Ola, 2026-10-08: "we need to implement is a CORINE simplification, similar to what we do on the outline of the auto-catchments"): the land-cover coverage cut into borders between junctions, each border reduced by the area-preserving segment collapse of the catchment outline (`reduce_ring`), with junctions and the domain outline fixed, a crossing test and a side test across all borders, and a band no border moves past (two-sided, proved by anchors on the source border). `--features-tolerance` becomes that band, default 50 m (half CORINE's positional accuracy), replacing GEOS's coverage simplifier; start angle 15° when land cover is simplified (band above 0), 25° otherwise. Prototype on the German fused catchment: 5.7 times fewer triangles at 50 m vertical tolerance | **merged (#222, 2026-10-11)** (green `82c0a7d4`, 519 net production lines, one PR; mutation round `30f939b6`, one mutant recorded as not killed); Ola ruled all four questions (2026-10-08); code review round 1 asked for changes (comments, docstrings and `project_structure.md` the change made false), fixed by `4d4f0411` and the docs commit after it; code review rounds 1-3 done (round 3 approved); `@perf`'s short timing check found the runs about 70 % slower (the outline rule on borders clipped to the catchment) and the worst angle far lower (the simplifier placing points millimetres from junctions); fix built (section 15: outline rule first, a 1 m clearance in the simplifier; green `05c8f73a`, 573 counted lines in all; mutation round M7 to M13 killed); its quick check `b192a2c6`: Numedalslagen −11 %, Lagan −1.3 %, outline rule and moved area as before, triangles −42 %; questions 5 and 6 decided on default while Ola was away, reversible; fix code review round 1 asked for prose fixes and the test file's red-step staging removed; fix code review round 3 approved; next the push on Ola's yes | `docs/increments/32-landcover-simplify.md` | -| — | GeoPackage output, so QGIS opens a mesh as it opens any vector layer: `rasputin mesh --out X.gpkg`, chosen by the suffix of `--out` as `.vtk` and `.ply` are (Ola, 2026-10-05: "Is the vtk_to_phkg.py the way to go, or as a direct output from rasputin? I think the latter, and it should go into the roadmap and eventually into the codebase."). Proposed content: a `triangles` layer, one PolygonZ per triangle in the run's CRS (z at each of the three corners, as the stopgap writes), with the attributes the run already knows: land-cover code and class name (the code system's names, as `rasputin palette` has them), mean z, plan area; a layer of the constraint polygons (the `--features` polygons clipped to the domain, with their code); optionally a QGIS style for land cover stored in the file (the `layer_styles` table), so the triangles open coloured. Land-cover colour: when the mesh carries CORINE classes, each triangle and each constraint polygon also gets its class's colour as a `#rrggbb` text column, so a reader can colour by the column without the stored style (in QGIS, a data-defined fill colour). The Graz file of 2026-10-05 carries `clc_color` on its `triangles` and `corine` layers; its stored styles categorise on `clc_code` and do not read the column. The colours are those of `rasputin palette` (`src_python/tin_engine/palettes.py`), the same 26 the Graz file carries: natural colours, as Ola chose over the official CLC legend in 16c's ruling 2 (that legend paints peat bogs blue and the sea almost white). **One attribute is not in the mesh yet**: the ID of the source polygon each triangle lies in (a sub-catchment cell, a parcel). Today only a class code labels triangles, one code system per mesh (16c, 16e's D1); the stopgap finds the cell by a centroid lookup outside rasputin. It needs a per-triangle array from a polygon source's ID property: its own small step, done just before this writer, because the `.vtk` and `.ply` outputs gain the attribute too (Ola, 2026-10-05, accepting the default: "defaults on the cell ID"). Written with the standard library's `sqlite3` beside the existing reader `src_python/tin_engine/io/geopackage.py`, which can read it back in the tests; no GDAL, OGR or Fiona (CLAUDE.md §2). Python only: the C++ core is unchanged and never sees the file (the I/O boundary). Prior art and working spec: Ola's stopgap `vtk_to_gpkg.py`, outside the repository (`rasputin_data/lands/scripts/`), which converts a mesh `.vtk` with `sqlite3`, NumPy, Shapely and PyProj, no GDAL, and its first use, the Mur above Graz (2 451 734 triangles, 10 m DEM, tolerance 10 m, 2026-10-05): **the GeoPackage was 677.9 MB against 110.6 MB for the binary `.vtk`**; the whole stopgap run took 39 s and 4.1 GB peak, and that includes the centroid cell lookup and the CORINE clipping, so it is not the cost of the GeoPackage encoding alone: the design measures the writer on its own. A GeoPackage row holds the triangle's whole ring (four corners, the first repeated) and a bounding box, and the file adds a spatial index, where `.vtk` stores each vertex once. Questions for the design: file size (keep the spatial index or not; at most an opt-in variant of 2D polygons with mean z as an attribute, proposed default: full PolygonZ; writing in chunks to bound memory); the CRS entry for a CRS with no EPSG code; where the run record (increment 25) goes; whether the constraint lines get a layer of their own | proposed by Ola 2026-10-05 ("yes, roadmap row now, build after PR D"); not designed. Built after the Python audit's PR D (branch `audit-encoders`: the file encoders move out of `cli.py` into `io/`, and the mesh writers get one shared check; `docs/increments/python-audit.md` on branch `worktree-python-audit`, `8241e14`), so the new writer joins that shared check instead of a third copy | - | +| 36 | GeoPackage output, so QGIS opens a mesh as it opens any vector layer: `rasputin mesh --out X.gpkg`, chosen by the suffix of `--out` as `.vtk` and `.ply` are (Ola, 2026-10-05: "Is the vtk_to_phkg.py the way to go, or as a direct output from rasputin? I think the latter, and it should go into the roadmap and eventually into the codebase."). Proposed content: a `triangles` layer, one PolygonZ per triangle in the run's CRS (z at each of the three corners, as the stopgap writes), with the attributes the run already knows: land-cover code and class name (the code system's names, as `rasputin palette` has them), mean z, plan area; a layer of the constraint polygons (the `--features` polygons clipped to the domain, with their code); optionally a QGIS style for land cover stored in the file (the `layer_styles` table), so the triangles open coloured. Land-cover colour: when the mesh carries CORINE classes, each triangle and each constraint polygon also gets its class's colour as a `#rrggbb` text column, so a reader can colour by the column without the stored style (in QGIS, a data-defined fill colour). The Graz file of 2026-10-05 carries `clc_color` on its `triangles` and `corine` layers; its stored styles categorise on `clc_code` and do not read the column. The colours are those of `rasputin palette` (`src_python/tin_engine/palettes.py`), the same 26 the Graz file carries: natural colours, as Ola chose over the official CLC legend in 16c's ruling 2 (that legend paints peat bogs blue and the sea almost white). **One attribute is not in the mesh yet**: the ID of the source polygon each triangle lies in (a sub-catchment cell, a parcel). Today only a class code labels triangles, one code system per mesh (16c, 16e's D1); the stopgap finds the cell by a centroid lookup outside rasputin. It needs a per-triangle array from a polygon source's ID property: its own small step, done just before this writer, because the `.vtk` and `.ply` outputs gain the attribute too (Ola, 2026-10-05, accepting the default: "defaults on the cell ID"). Written with the standard library's `sqlite3` beside the existing reader `src_python/tin_engine/io/geopackage.py`, which can read it back in the tests; no GDAL, OGR or Fiona (CLAUDE.md §2). Python only: the C++ core is unchanged and never sees the file (the I/O boundary). Prior art and working spec: Ola's stopgap `vtk_to_gpkg.py`, outside the repository (`rasputin_data/lands/scripts/`), which converts a mesh `.vtk` with `sqlite3`, NumPy, Shapely and PyProj, no GDAL, and its first use, the Mur above Graz (2 451 734 triangles, 10 m DEM, tolerance 10 m, 2026-10-05): **the GeoPackage was 677.9 MB against 110.6 MB for the binary `.vtk`**; the whole stopgap run took 39 s and 4.1 GB peak, and that includes the centroid cell lookup and the CORINE clipping, so it is not the cost of the GeoPackage encoding alone: the design measures the writer on its own. A GeoPackage row holds the triangle's whole ring (four corners, the first repeated) and a bounding box, and the file adds a spatial index, where `.vtk` stores each vertex once. Questions for the design: file size (keep the spatial index or not; at most an opt-in variant of 2D polygons with mean z as an attribute, proposed default: full PolygonZ; writing in chunks to bound memory); the CRS entry for a CRS with no EPSG code; where the run record (increment 25) goes; whether the constraint lines get a layer of their own | **designed** (`@architect`, 2026-10-11, on `81a551b3`; proposed by Ola 2026-10-05, "yes, roadmap row now, build after PR D"; PR D merged as #205, so the writer joins the encoders under `io/`). Measured on this design's probe: the quick check's Numedalslagen mesh (653,039 triangles) written in 4.0 s to 203 MB against 0.28 s and 29 MB for the binary `.vtk`, the stopgap 5.1 s; the Romsdalen slope mesh (3.1 million) in 21.5 s to 922 MB at 2.1 GB peak, the stopgap 28.7 s at 4.1 GB; the R-tree index is 60 % of the time; the same run writes the same bytes. Layers `triangles` (PolygonZ with code, name, colour, z_min, z_max, z_mean, area_m2, slope_deg), `lines`, `land_cover`, `land_cover_classes`, a `run` table with the run record, and the stored QGIS style; about 305 net lines, one PR; questions 1-5 answered with their defaults while Ola is away | `docs/increments/36-geopackage-output.md` | | — | A missing station number refused in plain words. In a reference-polygon file (`rasputin station-catchments --reference`), a feature whose `station` is null becomes the text "None" (`src_python/tin_engine/io/station_set.py@ed12512:89`). No station can match it, since a station number must look like `2.11.0` (`src_python/tin_engine/hydrography.py@ed12512:40`), so the polygon is dropped without a word and the station it belonged to is written with no comparison (`src_python/tin_engine/catchment_batch.py@ed12512:220`); two such polygons are refused as "station None appears twice". In a stations file a null number is refused, but in Pydantic's validation text rather than a sentence naming the file (`src_python/tin_engine/io/station_set.py@ed12512:79`). NVE's files have a number on every station, so only hand-made or third-party files are hit. The fix: both readers refuse a null `station`, naming the file; one or two lines. Plainer GeoJSON error messages in general are not planned until other people feed rasputin their own files | approved by Ola 2026-10-06; not designed (a small fix) | - | | — | A GeoTIFF whose coordinate-system GeoKeys hold garbage refused in plain words. The reader turns each code key into a whole number with `int()`, and a key stored as a double or as text can hold NaN, infinity or letters. Probed 2026-10-06 on hand-made files: NaN or text in `ProjectedCSTypeGeoKey` (3072) stops `rasputin mesh --dem` with Python's conversion message (for NaN, "Invalid value for --dem: cannot convert float NaN to integer"), which names neither the key nor the file, and infinity ends in a Python traceback (`OverflowError`) (`src_python/tin_engine/io/geotiff.py@ed12512:171`). The same holds for every other code key the reader reads: 1025, 4099, 3076, 3074, 3075, 2048 and 2054 (`src_python/tin_engine/io/geotiff.py@ed12512:310`, `src_python/tin_engine/io/geotiff.py@ed12512:176`, `src_python/tin_engine/io/geotiff.py@ed12512:339`, `src_python/tin_engine/io/geotiff.py@ed12512:352`, `src_python/tin_engine/io/geotiff.py@ed12512:362`, `src_python/tin_engine/io/geotiff.py@ed12512:499`, `src_python/tin_engine/io/geotiff.py@ed12512:423`). Worse, a fractional value is cut to a whole number first: 2.5 in 4099 is refused as 2, and 32633.5 in 3072 is read as EPSG:32633 without a word (`src_python/tin_engine/io/geotiff.py@ed12512:321`); 1.5 in 1025, 9001.7 in 4099 and 9001.5 in 3076 are likewise accepted as 1 and 9001 (probed by code review round 1 on this row's branch, exit 0). The parameter keys are already checked (`_one_number`, `src_python/tin_engine/io/geotiff.py@ed12512:454`). The fix: one check for every code key, a whole number or a refusal naming the key and its value. No real file is known to do this | approved by Ola 2026-10-06; not designed (a small fix) | - | | — | tifffile's warning about the NoData tag silenced. On a float32 DEM whose `GDAL_NODATA` (42113) is the lowest float32 written in 17 digits (`-3.4028234663852886e+38`), tifffile prints "parsing GDAL_NODATA tag raised ValueError: ... is not castable to float32" to stderr when the file is opened (`src_python/tin_engine/io/geotiff.py@ed12512:146`). It is noise: rasputin reads the tag itself and accepts the value, since it is that float32 exactly (`src_python/tin_engine/io/geotiff.py@ed12512:535`). The fix: silence that one tifffile message while the reader opens the file, leaving every other tifffile warning visible | approved by Ola 2026-10-06; not designed (a small PR) | - | diff --git a/docs/increments/36-geopackage-output.md b/docs/increments/36-geopackage-output.md new file mode 100644 index 00000000..b708a3e5 --- /dev/null +++ b/docs/increments/36-geopackage-output.md @@ -0,0 +1,315 @@ +# Increment 36: GeoPackage output + +**Status:** designed by `@architect` on `81a551b3`, 2026-10-11. Python only, one +PR; no refine or mesh code, so no `@perf` acceptance run, but a quick check +(section 9). Ola is away: the five questions of section 10 are answered with +their defaults, reversible. + +## 1. What Ola asked + +`rasputin mesh --out X.gpkg` writes a GeoPackage, chosen by the suffix of +`--out` as `.vtk` and `.ply` are, so QGIS opens a mesh as any vector layer +(Ola, 2026-10-05, ROADMAP row 36). The file is also for agents that cannot +read VTK (Ola, 2026-10-11, "intent-driven triangulation"): a script with +`sqlite3` and shapely, or an agent, checks a mesh from the file alone — +triangle count, per-triangle heights, land-cover code, plan area, and the +run record (tolerance, DEM source). Specification by example: Ola's stopgap +converter `../rasputin_data/lands/scripts/vtk_to_gpkg.py` (outside git), +which this increment replaces. + +## 2. Prior art: legacy and literature + +*Literature.* The file format is OGC GeoPackage 1.3 (OGC 12-128r18): the +geometry blob header (section 2.1.3, which `io/geopackage.py` already +decodes), the R-tree spatial index extension (Annex F.3), `gpkg_contents`, +`gpkg_geometry_columns`, `gpkg_spatial_ref_sys` and attribute tables +(`data_type = 'attributes'`, section 2.3). The spatial index is SQLite's +R*Tree module (sqlite.org/rtree.html), fed row by row; the GeoPackage +extension's triggers are left out, since the file is written once and the +extension's `scope` column says `write-only`, as the stopgap does. The stored +QGIS style is QGIS's own `layer_styles` table (QGIS Desktop User Guide, +"Save style to the database"), as the stopgap writes it and QGIS 3.28+ reads +it. Bulk-loading an SQLite R-tree is known and faster: Even Rouault's +`sqlite_rtree_bulk_load` builds the tree in memory and writes the `_node`, +`_parent` and `_rowid` shadow tables directly (SQLite forum, +sqlite.org/forum/forumpost/af937a36b3, 2023-10-22: 3.2 million rows, 26 s +to 6 s; shipped in GDAL 3.8), packing after Leutenegger, Lopez and Edgington, +"STR: a simple and efficient algorithm for R-tree packing", ICDE 1997. Not +built here (section 7: 36b), because the row-by-row index costs 2.5 s on the +quick check's mesh, and the shadow-table format is SQLite's private layout. +No novelty is claimed: this is an encoder for a published format. + +*Legacy.* Nothing. The archived tree has no GeoPackage, SQLite or QGIS code: + + $ git grep -l -i -E "gpkg|geopackage|sqlite|qgis" legacy-archive -- legacy + (no files; exit 1) + +*In this tree.* `src_python/tin_engine/io/geopackage.py` (16b R2, R3) reads +the blob header, `gpkg_contents`, the SRS table and the R-tree; the writer is +its mirror and the reader reads the writer's files back in the tests. +`tests/python/gpkg_fixtures.py` (`write_gpkg`) already writes small test +GeoPackages with `sqlite3` and is kept as a test helper, not promoted. + +## 3. Measured on this worktree (`docs/increments/36-probes/gpkg_write_probe.py`) + +AC power, Python 3.14, SQLite 3.53.1, shapely 2.2.0; single runs. The probe +reads a binary `.vtk` and writes the `triangles` and `lines` layers with +section 4's attributes; `--stopgap` runs Ola's converter on the same mesh. + +| mesh | triangles | `.vtk` | `.vtk` write (`--stats`) | GeoPackage, candidate | of which R-tree | size | peak RSS | stopgap | +|---|---|---|---|---|---|---|---|---| +| test tile `7908_3_10m_z33.tif`, `--tolerance 10` | 69,571 | 2.6 MB | 0.005 s | 0.40 s | 0.23 s | 20.0 MB | — | — | +| quick check's Numedalslagen (`--tolerance 10`, CORINE) | 653,039 | 29.2 MB | 0.28 s | 4.0 s | 2.5 s | 203 MB | 0.56 GB | 5.1 s, 163 MB | +| Romsdalen slope mesh (`romsdalen_slope_2m_25_35.vtk`) | 3,096,202 | 111.5 MB | about 1 s | 21.5 s | 14.2 s | 922 MB | 2.1 GB | 28.7 s, 779 MB, 4.1 GB | + +Per triangle: a row costs about 2.2 µs and 242 bytes, the R-tree entry about +4.6 µs and 56 bytes. The stopgap's file is smaller only because it carries +fewer attributes. The file is 7 to 8 times the binary `.vtk`, as the ROADMAP +row foresaw (Graz: 678 MB against 111 MB): a row stores the triangle's whole +ring (four corners) and a bounding box, where `.vtk` stores each vertex once. + +What the writer must do, each measured as the difference it made: + +- **One transaction, `executemany` in batches of 100,000 rows** (`BEGIN` once, + `COMMIT` once; `journal_mode=OFF`, `synchronous=OFF`, as the stopgap). The + batch size is not sensitive: 10,000 gave the same time on Numedalslagen. +- **The R-tree after the rows, into the virtual table directly** (14.2 s on + Romsdalen) — not through a temporary table (16.6 s). Feeding it in Morton + order was slower (4.5 s against 3.8 s on Numedalslagen): the kernel's + triangle order is already local. `PRAGMA cache_size` of 256 MB during the + index build: 3.8 s to 2.9 s on Numedalslagen (the default cache is 2 MB). +- **Blobs and rows built per batch from array slices**, no whole-mesh list of + tuples and no whole-mesh blob array: peak RSS on Romsdalen 5.8 GB with a + list of all rows, 3.2 GB streaming rows only, 2.1 GB streaming blobs too + (0.3 GB of that is the probe's own VTK read). Checked at 3.1 million + triangles; at the São Francisco scale (about 10⁸ triangles) the rows would + take about 4 minutes and the index 8, for a 30 GB file (section 7). +- **Same run, same bytes**: the second write of each mesh was SHA-256 + identical to the first. Nothing calls `now()`: `gpkg_contents.last_change` + and `layer_styles.update_time` are written as the fixed text + `1970-01-01T00:00:00.000Z` (question 3). + +The blob encoding is one numpy structured array per batch (`GP`, version 0, +flags `0b0011` = little-endian with a 32-byte XY envelope, `srs_id`, then ISO +WKB: byte order 1, type 1003 PolygonZ, one ring of four points), as the +stopgap's `tri_blobs`; `io/geopackage.py`'s `decode_geometry` reads it. + +## 4. The file + +All layers in the run's CRS. Row `fid` = index + 1, so `fid - 1` is the +triangle's or edge's index in the `.vtk` of the same run; rows are inserted +in that order and never reordered. + +**`triangles`** (PolygonZ, `gpkg_geometry_columns.z = 1`), one row per +triangle, in triangle order. Columns: `code INTEGER` (the `.vtk`'s +`land_cover_code`: 0 in no polygon; NULL on every row when the run had no +coded land cover), `land_cover TEXT` (the code's name from the palette, NULL +when the palette lacks the code), `colour TEXT` (`#rrggbb` from the palette; +`#ff00ff` for a code the palette lacks, the preset's NaN colour), `z_min`, +`z_max`, `z_mean REAL` (the three corner heights), `area_m2 REAL` (plan +area), `slope_deg REAL` (the triangle plane's slope, Horn's quantity on a +flat triangle: `atan2(|n_xy|, |n_z|)`). An R-tree index. + +**`lines`** (LineStringZ), one row per constraint edge, in edge order, with +`length_m REAL` (plan length) and one `INTEGER` 0/1 column per vocabulary +property set on at least one edge, named as `write_vtk` names its cell +arrays (ruling 6 of increment 13). Written only when the mesh has edges. +No R-tree (67,240 rows on Numedalslagen draw in one scan). + +**`land_cover`** (MultiPolygon, 2D), one row per coded polygon of the +features after the clip and the clean-up (`FeatureSet.features` with a +`polygon` and a `code`, the polygons `_land_cover` labels from): `code`, +`land_cover`, `colour`, `area_m2`. Written only when a coded source ran. +22 rows on Numedalslagen; cheap, so kept (the ROADMAP's constraint-polygons +layer). Constraint lines from non-coded sources are already in `lines`. + +**`land_cover_classes`** (an attributes table), one row per code present in +`triangles`: `code`, `land_cover`, `colour`, `triangles INTEGER`, +`area_m2 REAL`. The legend, and the class shares an agent reads with one +query. Written only with coded land cover. + +**`run`** (an attributes table), the run record (increment 25) as rows: +`name TEXT PRIMARY KEY`, `wording TEXT`, `value TEXT`, `number REAL` (NULL +for text entries), in record order, straight from `RunRecord.entries` +(`name`, `wording`, `value`, `number`); first `rasputin_version` and +`command` as `--record` writes them, then every entry `as_json` writes. So the tolerance +(`tolerance_m`), the largest error (`max_error_m`), the DEM source +(`dem_source`) and the CRS are in the file. `--record` and `--stats` are +unchanged and still work beside a `.gpkg`. + +**`layer_styles`** (QGIS), one row each for `triangles` and `land_cover`, +`useAsDefault = 1`: a categorised style on `code` with the palette's colours +and labels, the stopgap's QML with its three ``/`