RVO crop parcels (BRP), PDOK OGC API: is it worth wiring in? #203

Closed
opened 2026-08-30 04:56:54 +00:00 by viberfox-agent · 7 comments
Collaborator

Problem

The first half of this ticket was to decide whether the source is worth wiring in. It is. The check below was run on 2026-08-30 against the live service.

The interface exists and is free. https://api.pdok.nl/rvo/gewaspercelen/ogc/v1 (the frontier row's guess at the path was wrong; there is no brpgewaspercelen under /rvo/). One collection, brpgewas, OGC API Features, keyless, bbox query, GeoJSON out, storage CRS EPSG:28992 with CRS84 offered. The collections document names its own licence: https://creativecommons.org/publicdomain/mark/1.0/deed.nl — Public Domain Mark 1.0, so caching and redistributing what we serve needs nobody's agreement. One year is served (jaar: 2025, status: "Definitief", snapshot date 15 May) with no superseded versions in the response, so this does not have the BGT's ghost-version trap (crates/cartopolis/src/systems/map/surveyed.rs:148-165).

Measured volume, per exact z14 cell (the zoom map_geometry streams at, crates/cartopolis/src/systems/map/surveyed.rs:36):

Cell Features GeoJSON Vertices Crop-only features / vertices
8488/5315, farmland N of Groningen (1462 × 1452 m) 308 921 KB 22,824 122 / 13,220
8449/5374, Flevoland arable 199 232 KB 4,954 120 / 3,475
8490/5320, Groningen Grote Markt 0 1 KB 0 0 / 0

Zero inside the city and zero outside the Netherlands is the degrade-to-nothing shape value 3 asks for. Douglas–Peucker at 0.5 m keeps 31–32 % of the crop vertices (5,206 and 1,270 respectively) — call it ~40 KB of coordinates for the heaviest cell measured, against the 3.8 MB of surveyed paving that is already the second-heaviest thing streamed. Volume is not an obstacle.

It changes the picture, and the reason is in this repo's own code. land_group collapses grass, meadow, park, garden, farmland, orchard, scrub, heath, allotments, village_green and recreation_ground into one SurfaceGroup::Grass (crates/geo/src/vector_tiles.rs:1411-1412), which paints one flat palette::GRASS (crates/geo/src/vector_tiles.rs:1224, and :549 on the raster path). That is literally the "one generic green fill" the row describes. Over the Flevoland cell, 93 % of the parcel area is Bouwland carrying 33 distinct crops — pears, consumption potatoes, yellow onions, winter wheat, sugar beet, maize — and today all of it is the same green as a city park.

And it fixes a value-1 defect that already ships. GrassArea::build grows near-field grass blades on every polygon land_group calls Grass (crates/geo/src/turf.rs:104-131), farmland included — so a Dutch potato field currently grows generated grass right now.

Honest counterweight, and it goes in the note: over the Groningen farmland cell 94 % of the parcel area is Grasland, i.e. green repainted as green. About half the country's agricultural area is grassland, so half the countryside gains little beyond a more accurate green. That is not a reason to refuse — a register that confirms the guess is still the register — but nobody should expect the whole country to change.

Two things the check found that the implementation must handle and that are not obvious:

  • Most features are not crops. Over the Groningen cell 186 of 308 are Landschapselement, and 316 of 340 of those sampled over the wider box are Sloot — ditches. Those are water. The Shortbread water_polygons / water_lines layers already draw them (crates/geo/src/vector_tiles.rs:562-570), and drawing a ditch as a land fill puts land where the register says water. Categories seen across four sampled regions: Landschapselement 1616, Grasland 1428, Bouwland 728, Natuurterrein 226, Braakland 1, Overige 1.
  • Parcels straddle cells, so the paving binning rule cannot be copied. surveyed_paving drops any surface whose outer ring leaves its cell (crates/cartopolis/src/systems/map/surveyed.rs:207-215, via to_cell_local's containment test at :371). Measured: 16 % of crop parcels over Groningen farmland and 31 % over Flevoland cross a z14 boundary. Copying that rule would delete a third of Flevoland's fields.

The frontier row's own figures (163 parcels / 501 KB) do not reproduce on a z14 cell and should be corrected to the table above.

Approach

Follow the surveyed-layers shape exactly: an extractor binning into z14 cells, a coverage manifest, per-cell blobs consumed inside map_geometry's existing worker (not a streamer of its own — the polygons are merged into that cell's meshes, the reason crates/cartopolis/src/systems/map/surveyed.rs:20-24 gives), and byte-bounded meshes through the same mesh_bytes ceiling (crates/cartopolis/src/systems/map/map_geometry.rs:442).

The extractor goes in cartopy, not tools/. The ticket text says tools/; that instruction is stale — the four Dutch extractors moved to jeroen/cartopy under server/pipeline/ (docs/notes/where-the-map-data-is-built.md). This ticket is the client half, which is the shape docs/direction.md:97 already records for the BGT's second round: the client goes first, because a client that can name a form the host never sends is harmless while the reverse is silently dropped content.

crates/geo — the pure half

New crates/geo/src/crops.rs, modelled on crates/geo/src/paving.rs:

  • CropParcel { category, code, rings } in cell-local metres, rings open, outer first.
  • CropCategory — Grassland, Arable, Nature, Fallow, Other. Append, never renumber, mirroring PavingKind's contract (crates/geo/src/paving.rs:55-57). Landschapselement gets no variant: it is not drawn, for the ditch reason above.
  • rgb(category, code) -> u32: a curated gewascode → tone table for the crops that cover real area, falling back to the category's tone for any code not listed. An unlisted arable code draws as generic arable, never as a neighbouring crop's colour — the same discipline as land_group's "anything unlisted is skipped rather than guessed" (crates/geo/src/vector_tiles.rs:1408). Per-parcel tone rides in vertex colours so the layer stays one group, one material, one mesh per cell, exactly as Paving, Canopy and Furniture do (crates/geo/src/vector_tiles.rs:1231-1234).
  • build_crop_meshes(&[CropParcel], mesh_bytes) -> Vec<SurfaceMesh>, mirroring paving::build_paving_meshes.

New SurfaceGroup::Crop in crates/geo/src/vector_tiles.rs:

  • base_color → 0xFF_FF_FF (vertex colours carry the tone), perceptual_roughness unchanged at 0.95.
  • y_offset → −0.0080, between Grass at −0.0085 and Forest | Sand at −0.0075 (crates/geo/src/vector_tiles.rs:1284-1289). Half the usual millimetre because a crop parcel only ever overlaps Grass; it is never coplanar with a wood or a beach.
  • depth_rung → 2.5, between Grass 2.0 and Forest | Sand 3.0 (crates/geo/src/vector_tiles.rs:1345-1349). At DEPTH_BIAS_STEP = 64.0 (:310) that is 32 ULP of margin — half the ladder's normal step, still far above the rasteriser's one-ULP noise floor, and it keeps every parcel under water (rung 4), under paving (5) and under the road band.

crates/geo/src/turf.rs: GrassArea::build gains an arable keep-out, in the same shape as the WaterMask and RoadMask it already carries (crates/geo/src/turf.rs:129-131). Blades do not grow on a CropCategory::Arable parcel.

crates/cartopolis — the streaming half

New crates/cartopolis/src/systems/map/crops.rs, modelled on surveyed.rs:

  • static COVERAGE: CoverageSlot = CoverageSlot::new("brp", "/brp/coverage.json", 14) — the shared manifest reader at crates/cartopolis/src/systems/map/coverage.rs:52, whose latch-after-three-attempts policy (:29-31) is exactly right for an additive tree.
  • prefetch_brp_coverage registered beside systems::surveyed::prefetch_bgt_coverage (crates/cartopolis/src/lib.rs:2207), and coverage_revision() folded into MapSurfaces's rebuild revision beside bgt_revision (crates/cartopolis/src/systems/map/map_geometry.rs:306-311) so cells built during the manifest gap are retorn.
  • parse_crop_cell(text) over {"parcels":[[category,code,[ring,…]],…]} — the positional-array wire shape parse_surveyed_paving uses (crates/cartopolis/src/systems/map/surveyed.rs:139-148), with lat/lng pairs and the same exact-duplicate fold.
  • load_crop_cell(cx, cy) fetching {tdbag_url()}/brp/14/{cx}/{cy}.json and caching under brp/v1/14/{cx}/{cy}.json, mirroring crates/cartopolis/src/systems/map/surveyed.rs:308-331.
  • crop_parcels(&cell, cx, cy, tile_m) projecting to cell-local metres. This needs an unclamped variant of to_cell_local (crates/cartopolis/src/systems/map/surveyed.rs:368-386): same Mercator-y maths, without the containment test at :371. A parcel is binned by the extractor on its centroid and drawn whole by that one cell, vertices outside the cell included — the 16–31 % straddle measurement above is why.
  • brp_attribution() beside bgt_attribution() (crates/cartopolis/src/systems/map/surveyed.rs:62), rendered in the credits block at crates/cartopolis/src/systems/gui/data_layers.rs:2507-2524.

crates/cartopolis/src/systems/map/map_geometry.rs: load_crop_cell awaited beside load_surveyed_paving at :455, and the meshes built in the worker beside the paving at :500-505. No Layers-panel toggle — surveyed paving has none either.

Metric: CellPlaces::surveyed becomes a 5-tuple (crates/cartopolis/src/systems/map/world_places.rs:52, :281, :346, :374) and ShotMetrics gains crop_parcels, populated beside surveyed_paving at crates/cartopolis/src/systems/dev/shot_harness.rs:2637.

Documentation

  • New docs/notes/crop-parcels.md with the front-matter format docs/notes/README.md describes: the endpoint, the licence, the per-cell measurements above with their date and method, the ditch decision, the straddle measurement and why the paving binning rule was not copied, and the honest limit that grassland is repainted its own colour.
  • docs/direction.md: move the row from Candidates, unverified (:81) into Wired (:73-78), correcting the volume figures.

Acceptance criteria

  • crates/geo/src/crops.rs exists with CropParcel, CropCategory (an append-only list documented as such) and build_crop_meshes, and no bevy dependency.
  • CropCategory has no variant for Landschapselement; the parser drops that category, and a test asserts a ditch record produces no parcel.
  • SurfaceGroup::Crop exists with y_offset −0.0080 and depth_rung 2.5, and a test asserts its rung lies strictly between Grass and Forest and strictly below Water.
  • A gewascode not in the curated tone table falls back to its category's tone; a test asserts an unknown arable code draws as generic arable and never as another crop's colour.
  • parse_crop_cell reads the positional wire form, drops an outer ring under three points, and folds exact duplicates — tests in the shape of crates/cartopolis/src/systems/map/surveyed.rs:513-546.
  • A parcel whose ring leaves its own cell is projected and drawn whole, not dropped. A test builds a parcel straddling the boundary of cell 8449/5374 and asserts it survives with all its vertices.
  • Crop meshes are built through the same mesh_bytes ceiling as paving and vegetation (crates/cartopolis/src/systems/map/map_geometry.rs:442); no new per-frame upload path is introduced.
  • CoverageSlot::new("brp", "/brp/coverage.json", 14) is declared, prefetched from a system registered beside prefetch_bgt_coverage, and its revision is folded into MapSurfaces's rebuild revisions.
  • With no coverage manifest (every host that has not run the extractor, and everywhere outside the Netherlands), nothing is fetched after the manifest latches and the drawn map is byte-identical to today's.
  • GrassArea::build grows no blades on a CropCategory::Arable parcel; a test asserts blades in a grassland parcel and none in an arable one covering the same ground.
  • ShotMetrics::crop_parcels appears in --dump-state, and an unknown --expect key remains a startup error.
  • brp_attribution() renders in the credits block when coverage is non-empty.
  • docs/notes/crop-parcels.md exists with the endpoint, the Public Domain Mark 1.0 licence, the three measured cells with their date, and the grassland-repaints-green limit.
  • The docs/direction.md row has moved to Wired with the corrected figures.
  • A follow-up ticket is filed against jeroen/cartopy for server/pipeline/brp.ts and its format.test.ts entry, quoting the wire format above verbatim.

Verification

In this container (the implementing session, not this pass):

cargo test -p cartopolis_geo
cargo test -p cartopolis
cargo check -p cartopolis
cargo fmt --check

Run bare cargo fmt --check — the CI gate is workspace-wide.

Re-running the volume check needs no build:

curl -s 'https://api.pdok.nl/rvo/gewaspercelen/ogc/v1/collections?f=json'      # licence link
curl -s 'https://api.pdok.nl/rvo/gewaspercelen/ogc/v1/collections/brpgewas/items?f=json&limit=2000&bbox=5.646973,52.496160,5.668945,52.509535'

Not verifiable here, and not verifiable anywhere until the cartopy half ships. A scripted capture over farmland asserting --expect 'crop_parcels>0' needs a host serving /brp/coverage.json; until then the metric reads 0 everywhere and the tests above are the whole machine gate. Once cartopy serves a cell:

cargo run -p cartopolis -- --shot /tmp/crops.png --at 52.503,5.658,900 --look=-55,0 \
    --time 12 --clouds 0 --rain 0 --dump-state /tmp/crops.json \
    --expect 'crop_parcels>0' --expect 'settled==true'
cargo run -p cartopolis -- --shot /tmp/flick.png --at 52.503,5.658,900 --look=-55,0 \
    --time 12 --clouds 0 --rain 0 --flicker --expect 'flicker_px<=N'

The --flicker run is the one that matters for the new depth rung: 32 ULP is half the ladder's normal step, and a coplanar fight over a whole province is exactly the failure this instrument exists for. It renders here on lavapipe, so it can be run in the container — but the resulting colours are not evidence (docs/notes/headless-shots-software-renderer.md), so whether the crop palette actually reads as a patchwork of fields is a workstation judgement.

Out of scope

  • The extractor. server/pipeline/brp.ts, the /brp/coverage.json manifest generation, the 0.5 m Douglas–Peucker simplification and the centroid binning all live in jeroen/cartopy, a separate repository with its own CI. This ticket pins the wire format and files the follow-up; it does not write the TypeScript.
  • Giving OSM farmland and meadow tones of their own. The client throws away a distinction the tiles already carry, which would improve the countryside outside the Netherlands for nothing. It is a real ticket and it is not this one — BRP names the crop, which OSM never does.
  • A Layers-panel toggle. Surveyed paving has none; crops match.
  • Crop-driven geometry. No furrows, no crop height, no seasonal colour from jaar. Flat fills only.
  • A docs/shots.toml preset over farmland. Worth having once there is data to photograph; not part of this change.
  • Landschapselement. The ditches, hedgerows and copses are 60 % of the features and are already registered in the BGT and drawn from the Shortbread water layers.

Open questions

None. The three decisions that could have been asked were resolved from docs/direction.md's values and are recorded here for the commit body: drawing the crop fill over the existing green rather than replacing SurfaceGroup::Grass is value 4; dropping Landschapselement because the ditches are already drawn as water is value 4 again; and shipping the client half alone behind a crop_parcels count and unit tests, rather than waiting on the other repository, is value 6 plus the precedent docs/direction.md:97 already records for the BGT.

Sources: PDOK BRP OGC API · PDOK dataset page


Branch: feat/203-brp-crop-parcels

Original request

From the frontier in docs/direction.md: RVO crop parcels (BRP), PDOK OGC API — Every agricultural field with the crop grown on it that year, so the country outside the cities stops being one generic green fill: 163 parcels and 501 KB over a farmland cell north of Groningen, 131 parcels and 149 KB over Flevoland arable land, and zero features inside the city, which is the degrade-to-nothing shape the other streamers already have..

The first half of this ticket is deciding whether it is worth doing at all:

  • Is there an interface a client or an extractor can use, and what does it cost per cell?
  • Does the licence allow redistributing what we would cache?
  • Does it change the picture, or only the data behind it?

Not worth it is a valid answer. Record it in docs/direction.md against this row
with the reason, and close this ticket — that is the result, not a failure.

Decide it yourself. This ticket is not being watched, so a question asked
here is a ticket that stops. The seven values at the top of docs/direction.md
exist to settle exactly this kind of ambiguity — pick the reading they support,
say in the commit body which one you applied and why, and build. Only a decision
that would need something nobody can derive from the repository — a credential, a
licence somebody must agree to, a choice about what the project is for — is a
reason to stop.

If it is worth doing, follow the shape the surveyed layers already use:
an extractor under tools/, a coverage manifest, a streamer that adds to what
is drawn rather than replacing it, and no unbounded per-frame upload.

Filed by the autopilot.

🤖 Refined by the viberfox issue agent. Reply with @agent refine and what is wrong to have this rewritten.

## Problem The first half of this ticket was to decide whether the source is worth wiring in. **It is.** The check below was run on 2026-08-30 against the live service. **The interface exists and is free.** `https://api.pdok.nl/rvo/gewaspercelen/ogc/v1` (the frontier row's guess at the path was wrong; there is no `brpgewaspercelen` under `/rvo/`). One collection, `brpgewas`, OGC API Features, keyless, `bbox` query, GeoJSON out, storage CRS EPSG:28992 with CRS84 offered. The collections document names its own licence: `https://creativecommons.org/publicdomain/mark/1.0/deed.nl` — Public Domain Mark 1.0, so caching and redistributing what we serve needs nobody's agreement. One year is served (`jaar: 2025`, `status: "Definitief"`, snapshot date 15 May) with no superseded versions in the response, so this does **not** have the BGT's ghost-version trap (`crates/cartopolis/src/systems/map/surveyed.rs:148-165`). **Measured volume, per exact z14 cell** (the zoom `map_geometry` streams at, `crates/cartopolis/src/systems/map/surveyed.rs:36`): | Cell | Features | GeoJSON | Vertices | Crop-only features / vertices | |---|---|---|---|---| | 8488/5315, farmland N of Groningen (1462 × 1452 m) | 308 | 921 KB | 22,824 | 122 / 13,220 | | 8449/5374, Flevoland arable | 199 | 232 KB | 4,954 | 120 / 3,475 | | 8490/5320, Groningen Grote Markt | 0 | 1 KB | 0 | 0 / 0 | Zero inside the city and zero outside the Netherlands is the degrade-to-nothing shape value 3 asks for. Douglas–Peucker at 0.5 m keeps 31–32 % of the crop vertices (5,206 and 1,270 respectively) — call it ~40 KB of coordinates for the heaviest cell measured, against the 3.8 MB of surveyed paving that is already the second-heaviest thing streamed. Volume is not an obstacle. **It changes the picture, and the reason is in this repo's own code.** `land_group` collapses `grass`, `meadow`, `park`, `garden`, `farmland`, `orchard`, `scrub`, `heath`, `allotments`, `village_green` and `recreation_ground` into one `SurfaceGroup::Grass` (`crates/geo/src/vector_tiles.rs:1411-1412`), which paints one flat `palette::GRASS` (`crates/geo/src/vector_tiles.rs:1224`, and `:549` on the raster path). That is literally the "one generic green fill" the row describes. Over the Flevoland cell, 93 % of the parcel area is `Bouwland` carrying 33 distinct crops — pears, consumption potatoes, yellow onions, winter wheat, sugar beet, maize — and today all of it is the same green as a city park. **And it fixes a value-1 defect that already ships.** `GrassArea::build` grows near-field grass blades on every polygon `land_group` calls `Grass` (`crates/geo/src/turf.rs:104-131`), farmland included — so a Dutch potato field currently grows generated grass right now. Honest counterweight, and it goes in the note: over the Groningen farmland cell 94 % of the parcel *area* is `Grasland`, i.e. green repainted as green. About half the country's agricultural area is grassland, so half the countryside gains little beyond a more accurate green. That is not a reason to refuse — a register that confirms the guess is still the register — but nobody should expect the whole country to change. Two things the check found that the implementation must handle and that are not obvious: - **Most features are not crops.** Over the Groningen cell 186 of 308 are `Landschapselement`, and 316 of 340 of those sampled over the wider box are `Sloot` — ditches. Those are water. The Shortbread `water_polygons` / `water_lines` layers already draw them (`crates/geo/src/vector_tiles.rs:562-570`), and drawing a ditch as a land fill puts land where the register says water. Categories seen across four sampled regions: `Landschapselement` 1616, `Grasland` 1428, `Bouwland` 728, `Natuurterrein` 226, `Braakland` 1, `Overige` 1. - **Parcels straddle cells, so the paving binning rule cannot be copied.** `surveyed_paving` drops any surface whose outer ring leaves its cell (`crates/cartopolis/src/systems/map/surveyed.rs:207-215`, via `to_cell_local`'s containment test at `:371`). Measured: **16 %** of crop parcels over Groningen farmland and **31 %** over Flevoland cross a z14 boundary. Copying that rule would delete a third of Flevoland's fields. The frontier row's own figures (163 parcels / 501 KB) do not reproduce on a z14 cell and should be corrected to the table above. ## Approach Follow the surveyed-layers shape exactly: an extractor binning into z14 cells, a coverage manifest, per-cell blobs consumed **inside `map_geometry`'s existing worker** (not a streamer of its own — the polygons are merged into that cell's meshes, the reason `crates/cartopolis/src/systems/map/surveyed.rs:20-24` gives), and byte-bounded meshes through the same `mesh_bytes` ceiling (`crates/cartopolis/src/systems/map/map_geometry.rs:442`). **The extractor goes in cartopy, not `tools/`.** The ticket text says `tools/`; that instruction is stale — the four Dutch extractors moved to `jeroen/cartopy` under `server/pipeline/` (`docs/notes/where-the-map-data-is-built.md`). This ticket is the **client half**, which is the shape `docs/direction.md:97` already records for the BGT's second round: the client goes first, because a client that can name a form the host never sends is harmless while the reverse is silently dropped content. ### `crates/geo` — the pure half New `crates/geo/src/crops.rs`, modelled on `crates/geo/src/paving.rs`: - `CropParcel { category, code, rings }` in cell-local metres, rings open, outer first. - `CropCategory` — `Grassland`, `Arable`, `Nature`, `Fallow`, `Other`. **Append, never renumber**, mirroring `PavingKind`'s contract (`crates/geo/src/paving.rs:55-57`). `Landschapselement` gets no variant: it is not drawn, for the ditch reason above. - `rgb(category, code) -> u32`: a curated `gewascode` → tone table for the crops that cover real area, falling back to the **category's** tone for any code not listed. An unlisted arable code draws as generic arable, never as a neighbouring crop's colour — the same discipline as `land_group`'s "anything unlisted is skipped rather than guessed" (`crates/geo/src/vector_tiles.rs:1408`). Per-parcel tone rides in vertex colours so the layer stays one group, one material, one mesh per cell, exactly as `Paving`, `Canopy` and `Furniture` do (`crates/geo/src/vector_tiles.rs:1231-1234`). - `build_crop_meshes(&[CropParcel], mesh_bytes) -> Vec<SurfaceMesh>`, mirroring `paving::build_paving_meshes`. New `SurfaceGroup::Crop` in `crates/geo/src/vector_tiles.rs`: - `base_color` → `0xFF_FF_FF` (vertex colours carry the tone), `perceptual_roughness` unchanged at 0.95. - `y_offset` → **−0.0080**, between `Grass` at −0.0085 and `Forest | Sand` at −0.0075 (`crates/geo/src/vector_tiles.rs:1284-1289`). Half the usual millimetre because a crop parcel only ever overlaps `Grass`; it is never coplanar with a wood or a beach. - `depth_rung` → **2.5**, between `Grass` 2.0 and `Forest | Sand` 3.0 (`crates/geo/src/vector_tiles.rs:1345-1349`). At `DEPTH_BIAS_STEP = 64.0` (`:310`) that is 32 ULP of margin — half the ladder's normal step, still far above the rasteriser's one-ULP noise floor, and it keeps every parcel under water (rung 4), under paving (5) and under the road band. `crates/geo/src/turf.rs`: `GrassArea::build` gains an arable keep-out, in the same shape as the `WaterMask` and `RoadMask` it already carries (`crates/geo/src/turf.rs:129-131`). Blades do not grow on a `CropCategory::Arable` parcel. ### `crates/cartopolis` — the streaming half New `crates/cartopolis/src/systems/map/crops.rs`, modelled on `surveyed.rs`: - `static COVERAGE: CoverageSlot = CoverageSlot::new("brp", "/brp/coverage.json", 14)` — the shared manifest reader at `crates/cartopolis/src/systems/map/coverage.rs:52`, whose latch-after-three-attempts policy (`:29-31`) is exactly right for an additive tree. - `prefetch_brp_coverage` registered beside `systems::surveyed::prefetch_bgt_coverage` (`crates/cartopolis/src/lib.rs:2207`), and `coverage_revision()` folded into `MapSurfaces`'s rebuild revision beside `bgt_revision` (`crates/cartopolis/src/systems/map/map_geometry.rs:306-311`) so cells built during the manifest gap are retorn. - `parse_crop_cell(text)` over `{"parcels":[[category,code,[ring,…]],…]}` — the positional-array wire shape `parse_surveyed_paving` uses (`crates/cartopolis/src/systems/map/surveyed.rs:139-148`), with lat/lng pairs and the same exact-duplicate fold. - `load_crop_cell(cx, cy)` fetching `{tdbag_url()}/brp/14/{cx}/{cy}.json` and caching under `brp/v1/14/{cx}/{cy}.json`, mirroring `crates/cartopolis/src/systems/map/surveyed.rs:308-331`. - `crop_parcels(&cell, cx, cy, tile_m)` projecting to cell-local metres. **This needs an unclamped variant of `to_cell_local`** (`crates/cartopolis/src/systems/map/surveyed.rs:368-386`): same Mercator-y maths, without the containment test at `:371`. A parcel is binned by the extractor on its centroid and drawn whole by that one cell, vertices outside the cell included — the 16–31 % straddle measurement above is why. - `brp_attribution()` beside `bgt_attribution()` (`crates/cartopolis/src/systems/map/surveyed.rs:62`), rendered in the credits block at `crates/cartopolis/src/systems/gui/data_layers.rs:2507-2524`. `crates/cartopolis/src/systems/map/map_geometry.rs`: `load_crop_cell` awaited beside `load_surveyed_paving` at `:455`, and the meshes built in the worker beside the paving at `:500-505`. No Layers-panel toggle — surveyed paving has none either. Metric: `CellPlaces::surveyed` becomes a 5-tuple (`crates/cartopolis/src/systems/map/world_places.rs:52`, `:281`, `:346`, `:374`) and `ShotMetrics` gains `crop_parcels`, populated beside `surveyed_paving` at `crates/cartopolis/src/systems/dev/shot_harness.rs:2637`. ### Documentation - New `docs/notes/crop-parcels.md` with the front-matter format `docs/notes/README.md` describes: the endpoint, the licence, the per-cell measurements above with their date and method, the ditch decision, the straddle measurement and why the paving binning rule was not copied, and the honest limit that grassland is repainted its own colour. - `docs/direction.md`: move the row from **Candidates, unverified** (`:81`) into **Wired** (`:73-78`), correcting the volume figures. ## Acceptance criteria - [ ] `crates/geo/src/crops.rs` exists with `CropParcel`, `CropCategory` (an append-only list documented as such) and `build_crop_meshes`, and no `bevy` dependency. - [ ] `CropCategory` has no variant for `Landschapselement`; the parser drops that category, and a test asserts a ditch record produces no parcel. - [ ] `SurfaceGroup::Crop` exists with `y_offset` −0.0080 and `depth_rung` 2.5, and a test asserts its rung lies strictly between `Grass` and `Forest` and strictly below `Water`. - [ ] A `gewascode` not in the curated tone table falls back to its category's tone; a test asserts an unknown arable code draws as generic arable and never as another crop's colour. - [ ] `parse_crop_cell` reads the positional wire form, drops an outer ring under three points, and folds exact duplicates — tests in the shape of `crates/cartopolis/src/systems/map/surveyed.rs:513-546`. - [ ] A parcel whose ring leaves its own cell is projected and drawn whole, not dropped. A test builds a parcel straddling the boundary of cell 8449/5374 and asserts it survives with all its vertices. - [ ] Crop meshes are built through the same `mesh_bytes` ceiling as paving and vegetation (`crates/cartopolis/src/systems/map/map_geometry.rs:442`); no new per-frame upload path is introduced. - [ ] `CoverageSlot::new("brp", "/brp/coverage.json", 14)` is declared, prefetched from a system registered beside `prefetch_bgt_coverage`, and its revision is folded into `MapSurfaces`'s rebuild revisions. - [ ] With no coverage manifest (every host that has not run the extractor, and everywhere outside the Netherlands), nothing is fetched after the manifest latches and the drawn map is byte-identical to today's. - [ ] `GrassArea::build` grows no blades on a `CropCategory::Arable` parcel; a test asserts blades in a grassland parcel and none in an arable one covering the same ground. - [ ] `ShotMetrics::crop_parcels` appears in `--dump-state`, and an unknown `--expect` key remains a startup error. - [ ] `brp_attribution()` renders in the credits block when coverage is non-empty. - [ ] `docs/notes/crop-parcels.md` exists with the endpoint, the Public Domain Mark 1.0 licence, the three measured cells with their date, and the grassland-repaints-green limit. - [ ] The `docs/direction.md` row has moved to **Wired** with the corrected figures. - [ ] A follow-up ticket is filed against `jeroen/cartopy` for `server/pipeline/brp.ts` and its `format.test.ts` entry, quoting the wire format above verbatim. ## Verification In this container (the implementing session, not this pass): ```bash cargo test -p cartopolis_geo cargo test -p cartopolis cargo check -p cartopolis cargo fmt --check ``` Run bare `cargo fmt --check` — the CI gate is workspace-wide. Re-running the volume check needs no build: ```bash curl -s 'https://api.pdok.nl/rvo/gewaspercelen/ogc/v1/collections?f=json' # licence link curl -s 'https://api.pdok.nl/rvo/gewaspercelen/ogc/v1/collections/brpgewas/items?f=json&limit=2000&bbox=5.646973,52.496160,5.668945,52.509535' ``` **Not verifiable here, and not verifiable anywhere until the cartopy half ships.** A scripted capture over farmland asserting `--expect 'crop_parcels>0'` needs a host serving `/brp/coverage.json`; until then the metric reads 0 everywhere and the tests above are the whole machine gate. Once cartopy serves a cell: ```bash cargo run -p cartopolis -- --shot /tmp/crops.png --at 52.503,5.658,900 --look=-55,0 \ --time 12 --clouds 0 --rain 0 --dump-state /tmp/crops.json \ --expect 'crop_parcels>0' --expect 'settled==true' cargo run -p cartopolis -- --shot /tmp/flick.png --at 52.503,5.658,900 --look=-55,0 \ --time 12 --clouds 0 --rain 0 --flicker --expect 'flicker_px<=N' ``` The `--flicker` run is the one that matters for the new depth rung: 32 ULP is half the ladder's normal step, and a coplanar fight over a whole province is exactly the failure this instrument exists for. It renders here on lavapipe, so it can be run in the container — but the resulting **colours are not evidence** (`docs/notes/headless-shots-software-renderer.md`), so whether the crop palette actually reads as a patchwork of fields is a workstation judgement. ## Out of scope - **The extractor.** `server/pipeline/brp.ts`, the `/brp/coverage.json` manifest generation, the 0.5 m Douglas–Peucker simplification and the centroid binning all live in `jeroen/cartopy`, a separate repository with its own CI. This ticket pins the wire format and files the follow-up; it does not write the TypeScript. - **Giving OSM `farmland` and `meadow` tones of their own.** The client throws away a distinction the tiles already carry, which would improve the countryside outside the Netherlands for nothing. It is a real ticket and it is not this one — BRP names the crop, which OSM never does. - **A Layers-panel toggle.** Surveyed paving has none; crops match. - **Crop-driven geometry.** No furrows, no crop height, no seasonal colour from `jaar`. Flat fills only. - **A `docs/shots.toml` preset over farmland.** Worth having once there is data to photograph; not part of this change. - **`Landschapselement`.** The ditches, hedgerows and copses are 60 % of the features and are already registered in the BGT and drawn from the Shortbread water layers. ## Open questions None. The three decisions that could have been asked were resolved from `docs/direction.md`'s values and are recorded here for the commit body: drawing the crop fill *over* the existing green rather than replacing `SurfaceGroup::Grass` is **value 4**; dropping `Landschapselement` because the ditches are already drawn as water is **value 4** again; and shipping the client half alone behind a `crop_parcels` count and unit tests, rather than waiting on the other repository, is **value 6** plus the precedent `docs/direction.md:97` already records for the BGT. Sources: [PDOK BRP OGC API](https://api.pdok.nl/rvo/gewaspercelen/ogc/v1?f=html&lang=en) · [PDOK dataset page](https://www.pdok.nl/ogc-apis/-/article/basisregistratie-gewaspercelen-brp-) --- Branch: `feat/203-brp-crop-parcels` <details><summary>Original request</summary> From the frontier in [`docs/direction.md`](../src/branch/main/docs/direction.md): **RVO crop parcels (BRP), PDOK OGC API** — Every agricultural field with the crop grown on it that year, so the country outside the cities stops being one generic green fill: 163 parcels and 501 KB over a farmland cell north of Groningen, 131 parcels and 149 KB over Flevoland arable land, and zero features inside the city, which is the degrade-to-nothing shape the other streamers already have.. The first half of this ticket is deciding whether it is worth doing at all: - Is there an interface a client or an extractor can use, and what does it cost per cell? - Does the licence allow redistributing what we would cache? - Does it change the picture, or only the data behind it? **Not worth it is a valid answer.** Record it in `docs/direction.md` against this row with the reason, and close this ticket — that is the result, not a failure. **Decide it yourself.** This ticket is not being watched, so a question asked here is a ticket that stops. The seven values at the top of `docs/direction.md` exist to settle exactly this kind of ambiguity — pick the reading they support, say in the commit body which one you applied and why, and build. Only a decision that would need something nobody can derive from the repository — a credential, a licence somebody must agree to, a choice about what the project is *for* — is a reason to stop. If it is worth doing, follow the shape the surveyed layers already use: an extractor under `tools/`, a coverage manifest, a streamer that adds to what is drawn rather than replacing it, and no unbounded per-frame upload. <sub>Filed by the autopilot. <!-- autopilot:rvo-crop-parcels-brp-pdok-ogc-api --></sub> </details> <sub>🤖 Refined by the viberfox issue agent. Reply with **@agent refine** and what is wrong to have this rewritten.</sub>
Author
Collaborator

🤖 Refining this before anything gets built — reading the code, no branch, no commits. If it turns out I'd have to guess at the requirements I'll stop and ask instead.

🤖 Refining this before anything gets built — reading the code, no branch, no commits. If it turns out I'd have to guess at the requirements I'll stop and ask instead.
Author
Collaborator

🤖 Refined. The description above is now the spec; I'll pick it up for implementation on the next poll.

If the direction is wrong, say so now — @agent refine with a correction rewrites it, and agent:skip stops me.


🤖 viberfox issue agent on the VPS — 48 turns · $4.25 · session 80050faf-233e-4f40-a25b-80862f723094. Reply with @agent to continue this session.

🤖 **Refined.** The description above is now the spec; I'll pick it up for implementation on the next poll. If the direction is wrong, say so now — **@agent refine** with a correction rewrites it, and `agent:skip` stops me. --- <sub>🤖 viberfox issue agent on the VPS — 48 turns · $4.25 · session `80050faf-233e-4f40-a25b-80862f723094`. Reply with **@agent** to continue this session.</sub>
Author
Collaborator

🤖 Picked this up — working on branch feat/203-brp-crop-parcels in a fresh worktree on the VPS. I'll report back here when I'm done.

Label an issue agent:skip to keep me off it, or driven to keep it in a session you are driving.

🤖 Picked this up — working on branch `feat/203-brp-crop-parcels` in a fresh worktree on the VPS. I'll report back here when I'm done. <sub>Label an issue `agent:skip` to keep me off it, or `driven` to keep it in a session you are driving.</sub>
Author
Collaborator

Farmland is now drawn as the crop grown on it instead of one flat green — the client half of the RVO crop register (BRP). It draws nothing yet: no host serves the data until the extractor exists (issue #204).

What changed

New, in crates/geo:

  • crops.rs — CropParcel, an append-only CropCategory (Grassland, Arable, Nature, Fallow, Other; no variant for Landschapselement), a table mapping about 60 crop codes to fourteen tones, and build_crop_meshes. An unlisted code draws as its category, never as a neighbouring crop.
  • flat_fill.rs — the triangulation, moved out of paving.rs so both layers share one copy of the winding fix, the byte cap and the colour conversion. paving.rs now calls it and behaves as before.
  • SurfaceGroup::Crop at rung 2.5, height −0.0080: above grass, below wood, water, paving and roads.
  • GrassArea::build takes the cell's parcels and refuses to grow blades on arable ones. That fixes something that ships today — OpenStreetMap files farmland as grass, so Dutch potato fields currently grow generated lawn.

New, in the client: systems/map/crops.rs — a coverage manifest at /brp/coverage.json, per-cell files at /brp/14/{x}/{y}.json, and the projection into the frame the meshes are built in. Parcels are built inside the existing tile worker, beside the surveyed paving, and go through the same byte ceiling. crop_parcels is a new --dump-state field; the credits panel gains a line when coverage loads.

Documentation: docs/notes/crop-parcels.md, and the frontier row moved to "Wired" in docs/direction.md.

Two decisions worth knowing

  • Ditches are not drawn. 60% of the register's features are landscape elements, and 316 of 340 sampled are ditches. The water layers already draw those; filling one with land would put ground where the register says water.
  • A parcel that crosses a cell edge is drawn whole, unlike surveyed paving, which is dropped. I re-measured this: binning each parcel to the cell holding its centroid, 22% of the parcels a Groningen farmland cell owns extend past its edge, and 24% over Flevoland. The ticket said 16% and 31%; those figures counted every parcel the query returned, including ones belonging to neighbouring cells. The conclusion is the same — copying the paving rule would delete about a quarter of the country's fields.

How I verified it

cargo test -p cartopolis_geo (219 passed), cargo test -p cartopolis (794 passed), cargo check -p cartopolis, and cargo fmt --check clean across the workspace. Twenty-one of those tests are new. I re-ran the volume check against the live service on 2026-08-30 and the ticket's three-cell table reproduces exactly.

To build the client here at all I had to install ALSA sound headers into my home directory — this container has neither the headers nor the library, and --no-default-features fails with ten unrelated dead-code errors. Recorded in memory.

Not verified

  • Nothing has ever rendered a crop parcel. No host serves the data, so the new depth rung has never met real geometry. The --flicker check over farmland is what would confirm it, and it needs the extractor first.
  • The colours. This machine's software renderer is not evidence for colour, so whether the fields read as a patchwork needs a workstation.

For you to decide

I could not file the follow-up ticket against jeroen/cartopy: from this container that repository answers 404 to the token and "Repository not found" to git. I filed it as cartopolis issue #204 instead, with the complete wire format, and said in the body that it belongs in the other repository. Please move it or hand it to a session that has the credentials.

Branch feat/203-brp-crop-parcels (pushed)

6e7cb30 feat(map): draw each registered crop parcel in its own tone

🤖 viberfox issue agent on the VPS — 136 turns · $19.86 · session 3f60ff4b-77fc-4a71-a210-355c257369dd. Reply with @agent to continue this session.

Farmland is now drawn as the crop grown on it instead of one flat green — the client half of the RVO crop register (BRP). It draws nothing yet: no host serves the data until the extractor exists (issue #204). ## What changed **New, in `crates/geo`:** - `crops.rs` — `CropParcel`, an append-only `CropCategory` (`Grassland`, `Arable`, `Nature`, `Fallow`, `Other`; no variant for `Landschapselement`), a table mapping about 60 crop codes to fourteen tones, and `build_crop_meshes`. An unlisted code draws as its category, never as a neighbouring crop. - `flat_fill.rs` — the triangulation, moved out of `paving.rs` so both layers share one copy of the winding fix, the byte cap and the colour conversion. `paving.rs` now calls it and behaves as before. - `SurfaceGroup::Crop` at rung 2.5, height −0.0080: above grass, below wood, water, paving and roads. - `GrassArea::build` takes the cell's parcels and refuses to grow blades on arable ones. That fixes something that ships today — OpenStreetMap files farmland as grass, so Dutch potato fields currently grow generated lawn. **New, in the client:** `systems/map/crops.rs` — a coverage manifest at `/brp/coverage.json`, per-cell files at `/brp/14/{x}/{y}.json`, and the projection into the frame the meshes are built in. Parcels are built inside the existing tile worker, beside the surveyed paving, and go through the same byte ceiling. `crop_parcels` is a new `--dump-state` field; the credits panel gains a line when coverage loads. **Documentation:** `docs/notes/crop-parcels.md`, and the frontier row moved to "Wired" in `docs/direction.md`. ## Two decisions worth knowing - **Ditches are not drawn.** 60% of the register's features are landscape elements, and 316 of 340 sampled are ditches. The water layers already draw those; filling one with land would put ground where the register says water. - **A parcel that crosses a cell edge is drawn whole**, unlike surveyed paving, which is dropped. I re-measured this: binning each parcel to the cell holding its centroid, 22% of the parcels a Groningen farmland cell owns extend past its edge, and 24% over Flevoland. The ticket said 16% and 31%; those figures counted every parcel the query returned, including ones belonging to neighbouring cells. The conclusion is the same — copying the paving rule would delete about a quarter of the country's fields. ## How I verified it `cargo test -p cartopolis_geo` (219 passed), `cargo test -p cartopolis` (794 passed), `cargo check -p cartopolis`, and `cargo fmt --check` clean across the workspace. Twenty-one of those tests are new. I re-ran the volume check against the live service on 2026-08-30 and the ticket's three-cell table reproduces exactly. To build the client here at all I had to install ALSA sound headers into my home directory — this container has neither the headers nor the library, and `--no-default-features` fails with ten unrelated dead-code errors. Recorded in memory. ## Not verified - **Nothing has ever rendered a crop parcel.** No host serves the data, so the new depth rung has never met real geometry. The `--flicker` check over farmland is what would confirm it, and it needs the extractor first. - **The colours.** This machine's software renderer is not evidence for colour, so whether the fields read as a patchwork needs a workstation. ## For you to decide I could not file the follow-up ticket against `jeroen/cartopy`: from this container that repository answers 404 to the token and "Repository not found" to git. **I filed it as cartopolis issue #204 instead**, with the complete wire format, and said in the body that it belongs in the other repository. Please move it or hand it to a session that has the credentials. **Branch** `feat/203-brp-crop-parcels` (pushed) ``` 6e7cb30 feat(map): draw each registered crop parcel in its own tone ``` --- <sub>🤖 viberfox issue agent on the VPS — 136 turns · $19.86 · session `3f60ff4b-77fc-4a71-a210-355c257369dd`. Reply with **@agent** to continue this session.</sub>
Author
Collaborator

🤖 Marked agent:done without a CI verdict on 6e7cb309 — no workflow reported on this commit. The branch is waiting for review; check the run yourself before landing it.

🤖 Marked `agent:done` **without a CI verdict** on `6e7cb309` — no workflow reported on this commit. The branch is waiting for review; check the run yourself before landing it.
viberfox-agent 2026-08-30 05:57:09 +00:00
Author
Collaborator

🤖 Merged into main as pull request #205 (6e7cb309).

🤖 **Merged into `main`** as pull request #205 (`6e7cb309`).
Author
Collaborator

🤖 Deployed to the public services.

main is at f0048ffa
  waiting for CI on f0048ffa
  success    wasm & android targets
  success    test cartopolis

dispatching the simulator
  run 665 started
  the simulator: success

verifying the running simulator (expecting protocol 21)
  serving protocol 21

dispatching the web client
  run 666 started
  the web client: success

deployed f0048ffa
🤖 **Deployed to the public services.** ``` main is at f0048ffa waiting for CI on f0048ffa success wasm & android targets success test cartopolis dispatching the simulator run 665 started the simulator: success verifying the running simulator (expecting protocol 21) serving protocol 21 dispatching the web client run 666 started the web client: success deployed f0048ffa ```
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
jeroen/cartopolis#203
No description provided.