ProRail Spoorwegen, PDOK OGC API: is it worth wiring in? #216

Closed
opened 2026-08-30 08:09:35 +00:00 by viberfox-agent · 7 comments
Collaborator

Problem

docs/direction.md:86 carries ProRail Spoorwegen as an unverified candidate. The first half of the ticket — interface, licence, volume, and whether it changes the picture — is answered below from live read-only probes on 2026-08-30. The answer is partly worth it: one of the seven collections earns a place, six do not.

The interface. https://api.pdok.nl/prorail/spoorwegen/ogc/v1 — keyless OGC API Features, bbox query, GeoJSON out (CRS84 by default, longitude first), cursor-paged with limit clamped to 1000 (limit=5000 answers 200 and returns 1000). Access-Control-Allow-Origin: * and Cache-Control: public, max-age=3600, so the web build reaches it directly. properties= subsetting is a 400 — the same shape systems::nav_marks found on the RWS service (crates/cartopolis/src/systems/map/nav_marks.rs:1-48). numberMatched is never returned.

The licence: CC0 1.0, stated by the service's own landing page rel="license" link. No obstacle to caching or redistributing an extract, and no credential needed.

Seven collections, measured over four z14 cells (the zoom map_geometry streams geometry at):

collection Groningen station 8490/5321 Utrecht CS 8424/5405 Grote Markt 8490/5320 Veluwe 8453/5400
spooras (track axes) 22 / 95.7 KB 68 / 169 KB 1 / 14.2 KB 0
wissel (switches) 70 / 112 KB 212 / 313 KB 0 0
overweg (level crossings) 1 / 2.4 KB 8 / 13.3 KB 0 0
kilometrering (km posts) 15 / 8.9 KB 17 / 10.2 KB 2 0
kruising (diamond crossings) 12 / 15.5 KB 4 / 5.7 KB 0 0
station 1 1 0 0
trace (corridor centreline) 2 / 8.1 KB 1 / 4.0 KB 1 0
all seven 245 KB 517 KB 25.7 KB 5.6 KB (7 empty answers)

What is drawn today, and what is not. A railway reaches the client as a Shortbread streets feature with kind = rail, drawn as a 3.2 m ballast ribbon with the rails painted on — crates/geo/src/road_texture.rs:149 classifies it, :198 gives the width, :311–:336 paint sleepers then rails into the texture. Nothing three-dimensional exists anywhere on a railway: FurnitureKind (crates/geo/src/furniture.rs:110-160) has no barrier, mast, cross or signal, FURNITURE_TABLE (:228-250) names no railway key at all, and the module's own rule is that "anything unlisted gets nothing" (:22-24). A level crossing is currently a street ribbon meeting a rail ribbon with no equipment on it; the BGT supplies only the surface under it (crates/geo/src/paving.rs:16), and a railway POI becomes a label and a proximity prompt, not geometry (crates/geo/src/places.rs:152).

So overweg is the collection that changes the picture, and the other six do not:

  • spooras / trace — track centrelines. Drawing them would lay a second set of rail ribbons on top of the OSM ones already drawn at road_texture.rs:198, which is the coplanar z-fight this codebase keeps paying for; substituting them for the OSM rail is the argument paving.rs:7-35 already lost for road surfaces (the ProRail axis carries no texture, no casing and no rung on the depth ladder), and it would delete every railway outside the Netherlands. Value 4.
  • wissel / kruising — switches and diamonds, with real geometry and real attributes (hoekverhouding 1:9, max_snelheid_afbuigend, kromme_stand). There is nothing to draw them on: the rails are a painted texture, not modelled steel, so a switch is invisible at every altitude. Value 6 — no --dump-state field would move for anything a person could see. This becomes worth revisiting only if rails are ever modelled as geometry, which is a different ticket.
  • station — already a POI through places.rs:152.
  • kilometrering — a small unlabelled plate on a post, rotatie null on all 6,000 features sampled, so it has no orientation either. furniture.rs:104-108: "a kind that would need a texture, a curve or a survey to read correctly is better left undrawn." An unlabelled km post is a stick.

The overweg register, censused nationally 2026-08-30 — 3,917 features, 6.1 MB raw, 4 pages:

  • overweg_type: Geen 2,035 · AHOB 1,157 · AHOB-MINI 381 · AOB 137 · ALI 63 · Onbekend 40 · AKI 24 · HALI 21 · WILO 16 · VKL 9 · HAVIO 9 · HBKI 8, and a tail. 100% filled.
  • karakter: Openbare overweg 2,022 · Onbekend 587 · Dienstoverpad 557 · private 263 · public-in-character 214 · footpath 158 · … 100% filled.
  • aantal_sporen (tracks crossed): 1 → 2,091 · 2 → 1,269 · 3 → 232 · 4 → 126 · 0 → 68 · up to 14. 100% filled.
  • vri (road signals): Ja 24. type_hek and aantal_andreas: null on all 3,917 — the register does not record the St Andrew's cross.
  • azimut: null on all 3,917. The orientation has to be derived.
  • levenscyclus_status: Bestaand 2,612, Definitief ontwerp 1,304. This is not a built/not-built flag — the Peizerweg crossing in Groningen reads Definitief ontwerp and exists, as do every sampled spooras and wissel feature. Filtering on it would delete a third of the register. This is the same trap docs/notes/surveyed-street-objects.md records for the BGT's retired trees, in the opposite direction.
  • aantal_ahob is 100% filled but not usable as a physical boom count: the single-track Peizerweg crossing reports 15.

Approach

Mirror systems::nav_marks exactly — it is the in-tree precedent for a keyless CORS-open PDOK collection fetched by the client itself rather than through cartopy, and its module note (crates/cartopolis/src/systems/map/nav_marks.rs:1-48) states the reasoning that applies here unchanged: no extractor, no CoverageSlot (coverage is an envelope), consumed inside map_geometry's existing per-cell worker because the objects end up merged into that cell's furniture mesh.

crates/geo/src/crossings.rs (new, no Bevy) — the pure half:

  • LevelCrossing { pos: [f32; 2], yaw: f32, kind: CrossingKind, tracks: u8 } in tile-local metres, the frame the rest of furniture.rs works in.
  • A CROSSING_TABLE mapping overweg_type codes to forms, following FURNITURE_TABLE's rule at furniture.rs:22-24: a code the table does not name draws nothing, and Geen / Onbekend are explicitly nothing. The half-barrier codes are the ones with a form; the flashing-light-only codes get a light post or nothing, and the table records which.
  • build_crossing_meshes(&[LevelCrossing], max_bytes: Option<usize>) -> Vec<SurfaceMesh>, byte-bounded exactly as build_mark_meshes and build_line_meshes are, emitting into the same SurfaceGroup the furniture uses so it shares that group's material, altitude fade (FURNITURE_FADE_START/_END, furniture.rs:59-62) and toggle.
  • MAX_CROSSINGS_PER_CELL, in the shape of MAX_FURNITURE_PER_CELL (furniture.rs:69) and MAX_MARKS_PER_CELL.
  • Orientation: the booms lie across the road, masts on both sides of the tracks. WayField (furniture.rs:345-420) already answers "which way does the nearest way run", but it indexes "every street, path and railway in the tile" (:345) and reads no kind tag (:371-412) — and an overweg point sits on the rail centreline by the collection's own description, so the nearest way is always the track. This needs a rail-excluding variant of the field (or an equivalent), and it is the one genuinely new piece of logic in the ticket.

crates/cartopolis/src/systems/map/crossings.rs (new) — the streaming half, mirroring nav_marks.rs:52-90 and :408-470: CROSSING_ZOOM = 14 matching GEOMETRY_ZOOM, https://api.pdok.nl + /prorail/spoorwegen/ogc/v1, PAGE_LIMIT = 1000, the collection's own extent.spatial.bbox as the envelope gate, a compacted per-cell cache in storage::Namespace::Cache (the raw feature is ~2 KB of mostly administrative properties), and the per-cell cap applied on load.

Gates before a request is made, three, matching map_geometry.rs:464-478:

  1. the collection's spatial envelope (Netherlands only);
  2. wants.furniture — crossings ride the furniture group's mesh, material and toggle (data_layers.rs:491-499), so a run with it off asks for nothing;
  3. does the undecoded tile body mention a railway at all. vector_tiles::body_mentions_water (crates/geo/src/vector_tiles.rs:3949-3984) is the pattern, and it transfers with one difference worth stating: water is a layer name, rail is a kind value inside streets, and MVT stores string values verbatim in the layer's value table — so a contains_bytes(body, b"rail") test is conservative in the same direction (a tile with a rail feature cannot fail to contain it; a street named "…rail…" is a harmless false positive). Add it beside body_mentions_water with that asymmetry written down.

Wiring:

  • map_geometry.rs:464-478 — fetch the cell's crossings on the I/O pool beside load_marks_cell, behind the three gates.
  • map_geometry.rs:693-704 — build the meshes beside build_mark_meshes, inside the same worker, under the same mesh_bytes cap. No collider, for the reason stated there for marks.
  • world_places.rs:54, :283, :377-384 — the surveyed counter tuple grows from six to seven.
  • shot_harness.rs:633-640, :835 (CSV header), :2653, :2768, :3221 — a level_crossings field beside nav_marks, so --expect can gate it.
  • LICENSE-THIRD-PARTY.md — a record in the documented format (settings.rs:1342-1350, and LICENSE-THIRD-PARTY.md:85-91 for the AHN record's shape). CC0 compels no attribution, but AHN is CC0 and is listed, so this follows the file's own convention. data_layers.rs:2503-2533 is where the Layers panel's source line goes.
  • docs/notes/prorail-level-crossings.md — new note with topic: / triggers: / updated: front matter per docs/notes/README.md: the endpoint and its 404-adjacent path, the licence link, the per-cell and national volume tables above, the full overweg_type / karakter census, and the three findings that will otherwise be rediscovered — azimut null everywhere, levenscyclus_status is not a built flag, aantal_ahob is not a boom count.
  • docs/direction.md:86 — move the ProRail row from Candidates to Wired, naming what was taken and pointing at the note for the six collections that were not.

Acceptance criteria

  • crates/geo still has no Bevy dependency; the crossing type, the code table and the mesh builder are all in crates/geo/src/crossings.rs.
  • An overweg_type the table does not name places nothing — asserted by a test, mirroring furniture.rs:22-24.
  • Geen and Onbekend place nothing.
  • Nothing filters on levenscyclus_status; a test or a comment records why (the Peizerweg crossing reads Definitief ontwerp).
  • Nothing reads aantal_ahob as a boom count, for the reason recorded in the note.
  • Orientation comes from the nearest non-rail way. A unit test builds a field holding one rail segment and one road segment crossing it and asserts the yaw answered at the rail point is the road's, not the track's.
  • build_crossing_meshes honours max_bytes and splits or truncates, exactly as build_mark_meshes does; nothing is uploaded outside map_geometry's existing per-cell worker, build cap and despawn cap.
  • A per-cell cap constant exists and truncating logs, in the shape of nav_marks.rs:434-440.
  • Three gates hold: outside the collection's envelope, with furniture_enabled false, or on a tile body with no rail mention, no HTTP request is made.
  • The rail body test never answers "no rail" for a body containing a kind = rail street; the asymmetry is stated in its doc comment as body_mentions_water's is.
  • level_crossings appears in the shot state log line, the --csv header and --dump-state, and is assertable with --expect.
  • LICENSE-THIRD-PARTY.md gains a ProRail record and parse_notices still parses (the_notices_format_parses).
  • docs/notes/prorail-level-crossings.md exists with the measurements and the three traps; docs/direction.md's ProRail row moves to Wired and says which six collections were rejected and why.
  • The commit body names which of the seven values decided the rejections (4 for the axes, 6 for the switches).

Verification

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

Scene gate — the Peizerweg AHOB crossing, 6.54933, 53.21024, in the z14 cell 8490/5321 measured above:

cargo run -p cartopolis -- --shot /tmp/crossing.png \
    --at 53.21024,6.54933,60 --look=-20,90 \
    --time 12 --clouds 0 --rain 0 \
    --dump-state /tmp/crossing.json \
    --expect 'settled==true' --expect 'level_crossings>=1'

and a control over a cell with no railway (Grote Markt, 53.2194,6.5665) asserting level_crossings==0, which is also what proves the rail gate suppresses the request rather than the parse.

Only elsewhere. This container renders headlessly on lavapipe, so geometry, placement and counts from the two commands above are trustworthy; whether the installation reads as a level crossing at walking distance is colour and lighting, and docs/notes/headless-shots-software-renderer.md says that is not evidence here. Judge the picture on a workstation or through the Android emulator.

Deferred measurements (not taken in this pass, none of them blocking):

  • Bytes and wall clock of a warm vs cold cell fetch on the streamed ring — --csv with --frames across a rail corridor.
  • The national kilometrering total; paging stopped at 6,000 in 6 pages.
  • How many streets features with kind = rail a Shortbread z14 tile carries over Groningen station. Only needed if somebody later re-proposes spooras; it does not change this ticket, which does not draw track geometry.

Out of scope

  • Track axes, switches, diamond crossings, kilometre posts, stations and the corridor centreline — six of the seven collections, rejected above with reasons. The note records them so the check is not repeated.
  • Any change to how a railway is drawn: the 3.2 m ballast ribbon and its painted rails (road_texture.rs:198, :311-336) stay exactly as they are.
  • Modelled rails, which is the prerequisite a switch layer would need.
  • Colliders. Crossings are not FurnitureInstances and reach nothing in collision::sync_prop_colliders, matching the marks.
  • Animation. A boom that lowers would need a train, and there is no train.
  • Any cartopy work. This is a client-side fetch, like nav_marks, height and aerial; nothing is added to server/pipeline/.
  • A Layers-panel toggle of its own. Crossings ride the existing Furniture toggle.

Open questions

None. The licence is CC0 1.0 stated by the service, the API is keyless, and the scope questions are settled by docs/direction.md's values 4 and 6 as argued above.


Branch: feat/216-prorail-level-crossings

Original request

From the frontier in docs/direction.md: ProRail Spoorwegen, PDOK OGC API — Track axes, switches, level crossings and kilometre posts for the whole rail network, none of which the OSM rail ribbons this client already draws carry: 22 track axes and 70 switches, 208 KB, over the Groningen station cell, against one axis and no switches a cell to the north..

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 `docs/direction.md:86` carries ProRail Spoorwegen as an unverified candidate. The first half of the ticket — interface, licence, volume, and whether it changes the picture — is answered below from live read-only probes on 2026-08-30. The answer is **partly worth it**: one of the seven collections earns a place, six do not. **The interface.** `https://api.pdok.nl/prorail/spoorwegen/ogc/v1` — keyless OGC API Features, `bbox` query, GeoJSON out (CRS84 by default, longitude first), cursor-paged with `limit` clamped to 1000 (`limit=5000` answers 200 and returns 1000). `Access-Control-Allow-Origin: *` and `Cache-Control: public, max-age=3600`, so the web build reaches it directly. `properties=` subsetting is a **400** — the same shape `systems::nav_marks` found on the RWS service (`crates/cartopolis/src/systems/map/nav_marks.rs:1-48`). `numberMatched` is never returned. **The licence: CC0 1.0**, stated by the service's own landing page `rel="license"` link. No obstacle to caching or redistributing an extract, and no credential needed. **Seven collections**, measured over four z14 cells (the zoom `map_geometry` streams geometry at): | collection | Groningen station 8490/5321 | Utrecht CS 8424/5405 | Grote Markt 8490/5320 | Veluwe 8453/5400 | |---|---|---|---|---| | `spooras` (track axes) | 22 / 95.7 KB | 68 / 169 KB | 1 / 14.2 KB | 0 | | `wissel` (switches) | 70 / 112 KB | 212 / 313 KB | 0 | 0 | | `overweg` (level crossings) | 1 / 2.4 KB | 8 / 13.3 KB | 0 | 0 | | `kilometrering` (km posts) | 15 / 8.9 KB | 17 / 10.2 KB | 2 | 0 | | `kruising` (diamond crossings) | 12 / 15.5 KB | 4 / 5.7 KB | 0 | 0 | | `station` | 1 | 1 | 0 | 0 | | `trace` (corridor centreline) | 2 / 8.1 KB | 1 / 4.0 KB | 1 | 0 | | **all seven** | **245 KB** | **517 KB** | 25.7 KB | 5.6 KB (7 empty answers) | **What is drawn today, and what is not.** A railway reaches the client as a Shortbread `streets` feature with `kind = rail`, drawn as a **3.2 m ballast ribbon with the rails painted on** — `crates/geo/src/road_texture.rs:149` classifies it, `:198` gives the width, `:311`–`:336` paint sleepers then rails into the texture. Nothing three-dimensional exists anywhere on a railway: `FurnitureKind` (`crates/geo/src/furniture.rs:110-160`) has no barrier, mast, cross or signal, `FURNITURE_TABLE` (`:228-250`) names no `railway` key at all, and the module's own rule is that "anything unlisted gets **nothing**" (`:22-24`). A level crossing is currently a street ribbon meeting a rail ribbon with no equipment on it; the BGT supplies only the *surface* under it (`crates/geo/src/paving.rs:16`), and a `railway` POI becomes a label and a proximity prompt, not geometry (`crates/geo/src/places.rs:152`). **So `overweg` is the collection that changes the picture, and the other six do not:** - `spooras` / `trace` — track centrelines. Drawing them would lay a second set of rail ribbons on top of the OSM ones already drawn at `road_texture.rs:198`, which is the coplanar z-fight this codebase keeps paying for; substituting them for the OSM rail is the argument `paving.rs:7-35` already lost for road surfaces (the ProRail axis carries no texture, no casing and no rung on the depth ladder), and it would delete every railway outside the Netherlands. Value 4. - `wissel` / `kruising` — switches and diamonds, with real geometry and real attributes (`hoekverhouding` 1:9, `max_snelheid_afbuigend`, `kromme_stand`). There is nothing to draw them *on*: the rails are a painted texture, not modelled steel, so a switch is invisible at every altitude. Value 6 — no `--dump-state` field would move for anything a person could see. This becomes worth revisiting only if rails are ever modelled as geometry, which is a different ticket. - `station` — already a POI through `places.rs:152`. - `kilometrering` — a small unlabelled plate on a post, `rotatie` **null on all 6,000 features sampled**, so it has no orientation either. `furniture.rs:104-108`: "a kind that would need a texture, a curve or a survey to read correctly is better left undrawn." An unlabelled km post is a stick. **The `overweg` register, censused nationally 2026-08-30** — 3,917 features, 6.1 MB raw, 4 pages: - `overweg_type`: `Geen` 2,035 · `AHOB` 1,157 · `AHOB-MINI` 381 · `AOB` 137 · `ALI` 63 · `Onbekend` 40 · `AKI` 24 · `HALI` 21 · `WILO` 16 · `VKL` 9 · `HAVIO` 9 · `HBKI` 8, and a tail. 100% filled. - `karakter`: `Openbare overweg` 2,022 · `Onbekend` 587 · `Dienstoverpad` 557 · private 263 · public-in-character 214 · footpath 158 · … 100% filled. - `aantal_sporen` (tracks crossed): 1 → 2,091 · 2 → 1,269 · 3 → 232 · 4 → 126 · 0 → 68 · up to 14. 100% filled. - `vri` (road signals): `Ja` 24. `type_hek` and `aantal_andreas`: **null on all 3,917** — the register does not record the St Andrew's cross. - `azimut`: **null on all 3,917.** The orientation has to be derived. - `levenscyclus_status`: `Bestaand` 2,612, `Definitief ontwerp` 1,304. **This is not a built/not-built flag** — the Peizerweg crossing in Groningen reads `Definitief ontwerp` and exists, as do every sampled `spooras` and `wissel` feature. Filtering on it would delete a third of the register. This is the same trap `docs/notes/surveyed-street-objects.md` records for the BGT's retired trees, in the opposite direction. - `aantal_ahob` is 100% filled but **not usable as a physical boom count**: the single-track Peizerweg crossing reports 15. ## Approach Mirror `systems::nav_marks` exactly — it is the in-tree precedent for a keyless CORS-open PDOK collection fetched by the client itself rather than through cartopy, and its module note (`crates/cartopolis/src/systems/map/nav_marks.rs:1-48`) states the reasoning that applies here unchanged: no extractor, no `CoverageSlot` (coverage is an envelope), consumed inside `map_geometry`'s existing per-cell worker because the objects end up merged into that cell's furniture mesh. **`crates/geo/src/crossings.rs`** (new, no Bevy) — the pure half: - `LevelCrossing { pos: [f32; 2], yaw: f32, kind: CrossingKind, tracks: u8 }` in tile-local metres, the frame the rest of `furniture.rs` works in. - A `CROSSING_TABLE` mapping `overweg_type` codes to forms, following `FURNITURE_TABLE`'s rule at `furniture.rs:22-24`: **a code the table does not name draws nothing**, and `Geen` / `Onbekend` are explicitly nothing. The half-barrier codes are the ones with a form; the flashing-light-only codes get a light post or nothing, and the table records which. - `build_crossing_meshes(&[LevelCrossing], max_bytes: Option<usize>) -> Vec<SurfaceMesh>`, byte-bounded exactly as `build_mark_meshes` and `build_line_meshes` are, emitting into the same `SurfaceGroup` the furniture uses so it shares that group's material, altitude fade (`FURNITURE_FADE_START`/`_END`, `furniture.rs:59-62`) and toggle. - `MAX_CROSSINGS_PER_CELL`, in the shape of `MAX_FURNITURE_PER_CELL` (`furniture.rs:69`) and `MAX_MARKS_PER_CELL`. - Orientation: the booms lie across the **road**, masts on both sides of the tracks. `WayField` (`furniture.rs:345-420`) already answers "which way does the nearest way run", but it indexes "every street, path **and railway** in the tile" (`:345`) and reads no `kind` tag (`:371-412`) — and an `overweg` point sits *on the rail centreline* by the collection's own description, so the nearest way is always the track. This needs a rail-excluding variant of the field (or an equivalent), and it is the one genuinely new piece of logic in the ticket. **`crates/cartopolis/src/systems/map/crossings.rs`** (new) — the streaming half, mirroring `nav_marks.rs:52-90` and `:408-470`: `CROSSING_ZOOM = 14` matching `GEOMETRY_ZOOM`, `https://api.pdok.nl` + `/prorail/spoorwegen/ogc/v1`, `PAGE_LIMIT = 1000`, the collection's own `extent.spatial.bbox` as the envelope gate, a compacted per-cell cache in `storage::Namespace::Cache` (the raw feature is ~2 KB of mostly administrative properties), and the per-cell cap applied on load. **Gates before a request is made**, three, matching `map_geometry.rs:464-478`: 1. the collection's spatial envelope (Netherlands only); 2. `wants.furniture` — crossings ride the furniture group's mesh, material and toggle (`data_layers.rs:491-499`), so a run with it off asks for nothing; 3. **does the undecoded tile body mention a railway at all.** `vector_tiles::body_mentions_water` (`crates/geo/src/vector_tiles.rs:3949-3984`) is the pattern, and it transfers with one difference worth stating: water is a *layer name*, rail is a `kind` **value** inside `streets`, and MVT stores string values verbatim in the layer's value table — so a `contains_bytes(body, b"rail")` test is conservative in the same direction (a tile with a rail feature cannot fail to contain it; a street named "…rail…" is a harmless false positive). Add it beside `body_mentions_water` with that asymmetry written down. **Wiring:** - `map_geometry.rs:464-478` — fetch the cell's crossings on the I/O pool beside `load_marks_cell`, behind the three gates. - `map_geometry.rs:693-704` — build the meshes beside `build_mark_meshes`, inside the same worker, under the same `mesh_bytes` cap. **No collider**, for the reason stated there for marks. - `world_places.rs:54`, `:283`, `:377-384` — the `surveyed` counter tuple grows from six to seven. - `shot_harness.rs:633-640`, `:835` (CSV header), `:2653`, `:2768`, `:3221` — a `level_crossings` field beside `nav_marks`, so `--expect` can gate it. - `LICENSE-THIRD-PARTY.md` — a record in the documented format (`settings.rs:1342-1350`, and `LICENSE-THIRD-PARTY.md:85-91` for the AHN record's shape). CC0 compels no attribution, but AHN is CC0 and is listed, so this follows the file's own convention. `data_layers.rs:2503-2533` is where the Layers panel's source line goes. - `docs/notes/prorail-level-crossings.md` — new note with `topic:` / `triggers:` / `updated:` front matter per `docs/notes/README.md`: the endpoint and its 404-adjacent path, the licence link, the per-cell and national volume tables above, the full `overweg_type` / `karakter` census, and the three findings that will otherwise be rediscovered — `azimut` null everywhere, `levenscyclus_status` is not a built flag, `aantal_ahob` is not a boom count. - `docs/direction.md:86` — move the ProRail row from **Candidates** to **Wired**, naming what was taken and pointing at the note for the six collections that were not. ## Acceptance criteria - [ ] `crates/geo` still has no Bevy dependency; the crossing type, the code table and the mesh builder are all in `crates/geo/src/crossings.rs`. - [ ] An `overweg_type` the table does not name places nothing — asserted by a test, mirroring `furniture.rs:22-24`. - [ ] `Geen` and `Onbekend` place nothing. - [ ] Nothing filters on `levenscyclus_status`; a test or a comment records why (the Peizerweg crossing reads `Definitief ontwerp`). - [ ] Nothing reads `aantal_ahob` as a boom count, for the reason recorded in the note. - [ ] Orientation comes from the nearest **non-rail** way. A unit test builds a field holding one rail segment and one road segment crossing it and asserts the yaw answered at the rail point is the road's, not the track's. - [ ] `build_crossing_meshes` honours `max_bytes` and splits or truncates, exactly as `build_mark_meshes` does; nothing is uploaded outside `map_geometry`'s existing per-cell worker, build cap and despawn cap. - [ ] A per-cell cap constant exists and truncating logs, in the shape of `nav_marks.rs:434-440`. - [ ] Three gates hold: outside the collection's envelope, with `furniture_enabled` false, or on a tile body with no rail mention, **no HTTP request is made**. - [ ] The rail body test never answers "no rail" for a body containing a `kind = rail` street; the asymmetry is stated in its doc comment as `body_mentions_water`'s is. - [ ] `level_crossings` appears in the `shot state` log line, the `--csv` header and `--dump-state`, and is assertable with `--expect`. - [ ] `LICENSE-THIRD-PARTY.md` gains a ProRail record and `parse_notices` still parses (`the_notices_format_parses`). - [ ] `docs/notes/prorail-level-crossings.md` exists with the measurements and the three traps; `docs/direction.md`'s ProRail row moves to Wired and says which six collections were rejected and why. - [ ] The commit body names which of the seven values decided the rejections (4 for the axes, 6 for the switches). ## Verification ```bash cargo fmt --check cargo check -p cartopolis_geo cargo test -p cartopolis_geo crossings cargo check -p cartopolis cargo test -p cartopolis ``` Scene gate — the Peizerweg AHOB crossing, `6.54933, 53.21024`, in the z14 cell `8490/5321` measured above: ```bash cargo run -p cartopolis -- --shot /tmp/crossing.png \ --at 53.21024,6.54933,60 --look=-20,90 \ --time 12 --clouds 0 --rain 0 \ --dump-state /tmp/crossing.json \ --expect 'settled==true' --expect 'level_crossings>=1' ``` and a control over a cell with no railway (Grote Markt, `53.2194,6.5665`) asserting `level_crossings==0`, which is also what proves the rail gate suppresses the request rather than the parse. **Only elsewhere.** This container renders headlessly on lavapipe, so geometry, placement and counts from the two commands above are trustworthy; whether the installation *reads* as a level crossing at walking distance is colour and lighting, and `docs/notes/headless-shots-software-renderer.md` says that is not evidence here. Judge the picture on a workstation or through the Android emulator. **Deferred measurements** (not taken in this pass, none of them blocking): - Bytes and wall clock of a warm vs cold cell fetch on the streamed ring — `--csv` with `--frames` across a rail corridor. - The national `kilometrering` total; paging stopped at 6,000 in 6 pages. - How many `streets` features with `kind = rail` a Shortbread z14 tile carries over Groningen station. Only needed if somebody later re-proposes `spooras`; it does not change this ticket, which does not draw track geometry. ## Out of scope - Track axes, switches, diamond crossings, kilometre posts, stations and the corridor centreline — six of the seven collections, rejected above with reasons. The note records them so the check is not repeated. - Any change to how a railway is drawn: the 3.2 m ballast ribbon and its painted rails (`road_texture.rs:198`, `:311-336`) stay exactly as they are. - Modelled rails, which is the prerequisite a switch layer would need. - Colliders. Crossings are not `FurnitureInstance`s and reach nothing in `collision::sync_prop_colliders`, matching the marks. - Animation. A boom that lowers would need a train, and there is no train. - Any cartopy work. This is a client-side fetch, like `nav_marks`, `height` and `aerial`; nothing is added to `server/pipeline/`. - A Layers-panel toggle of its own. Crossings ride the existing Furniture toggle. ## Open questions None. The licence is CC0 1.0 stated by the service, the API is keyless, and the scope questions are settled by `docs/direction.md`'s values 4 and 6 as argued above. --- Branch: `feat/216-prorail-level-crossings` <details><summary>Original request</summary> From the frontier in [`docs/direction.md`](../src/branch/main/docs/direction.md): **ProRail Spoorwegen, PDOK OGC API** — Track axes, switches, level crossings and kilometre posts for the whole rail network, none of which the OSM rail ribbons this client already draws carry: 22 track axes and 70 switches, 208 KB, over the Groningen station cell, against one axis and no switches a cell to the north.. 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:prorail-spoorwegen-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 — 41 turns · $3.97 · session 7065d027-fa4a-497c-bd34-98bd28b8ac89. 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 — 41 turns · $3.97 · session `7065d027-fa4a-497c-bd34-98bd28b8ac89`. Reply with **@agent** to continue this session.</sub>
Author
Collaborator

🤖 Picked this up — working on branch feat/216-prorail-level-crossings 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/216-prorail-level-crossings` 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

Level crossings are now drawn where ProRail registers them — masts, lamp heads and red-and-white booms standing on the road either side of the track. Before this, a level crossing was a road ribbon meeting a rail ribbon with no equipment on it at all.

What I took, and what I did not. The service publishes seven collections. Only overweg (level crossings) is wired in. Track axes and the corridor centreline would lay a second set of rail ribbons on top of the ones already drawn — value 4, add don't replace. Switches and diamond crossings have real geometry and nothing to stand on, because the rails are a painted texture rather than modelled steel, so they would be invisible at every altitude — value 6. Stations are already points of interest, and kilometre posts are unlabelled plates with no recorded orientation.

The one hard part. The register carries no orientation (azimut is empty on all 3,917 crossings) and puts each crossing's point on the rail centreline. The existing "which way does the nearest way run" index includes railways, so it answered the track every time and would have laid every boom along the rails it is meant to bar. I added a filter to that index that excludes rail, with the rail test sitting next to the road classifier so the two cannot drift apart. A crossing with no road in range is dropped rather than drawn at a guess.

Three things in the register that are not what they look like — all measured against the live service and pinned by a captured feature in the tests:

  • levenscyclus_status is not a built/not-built flag. 1,304 of 3,917 crossings read "Definitief ontwerp", including the Peizerweg crossing in Groningen, which is standing in the street. Filtering on it would delete a third of the register.
  • aantal_ahob is not a count of booms. That same single-track crossing reports 15.
  • The extent the service publishes for the collection reaches Rennes and Copenhagen — it describes the coordinate grid, not the data. I measured the real extent over all 3,917 features and used that instead.

How it is bounded. Three checks run before any network request: the coverage envelope, a railway mention in the not-yet-decoded map tile, and the existing Furniture layer toggle. There is a per-cell cap that warns when it truncates, and the meshes are byte-bounded inside the map streamer's existing worker. No colliders and no animation.

Verified here: cargo fmt --check clean across the workspace; cargo check clean on both crates; the map-data crate's 236 tests and the client's 814 tests all pass, including 20 new ones. A correction along the way: I first asserted the coverage check refuses Brussels. It does not, and cannot — a rectangle drawn round the Dutch corners contains Brussels and Cologne. The test and the note now say so; the cost is one empty request for a border cell that has a railway in it.

Not verified, and it needs you. I could not look at it. This container renders on a software rasteriser, where geometry and counts are trustworthy but colour and lighting are not. Whether the installation actually reads as a level crossing at walking distance is exactly that kind of judgement. The scene gate is:

cargo run -p cartopolis -- --shot /tmp/crossing.png \
    --at 53.21024,6.54933,60 --look=-20,90 --time 12 --clouds 0 --rain 0 \
    --dump-state /tmp/crossing.json \
    --expect 'settled==true' --expect 'level_crossings>=1'

One thing to decide. I named 11 of the 18 installation codes. Five more (WILO, VKL, HAVIO, EBO, ADOB — 39 crossings between them) draw nothing, because I could not establish what the abbreviations expand to and a guessed form is an invented survey. If you know what they are, adding them is one line each in CROSSING_TABLE.

Branch feat/216-prorail-level-crossings is pushed. Note at docs/notes/prorail-level-crossings.md; the frontier list in docs/direction.md moves ProRail from candidates to wired.

Branch feat/216-prorail-level-crossings (pushed)

db60aea feat(map): raise ProRail's registered level crossings into the world

🤖 viberfox issue agent on the VPS — 133 turns · $17.06 · session ce25c591-c467-4ffd-919b-7a1a1dbea273. Reply with @agent to continue this session.

Level crossings are now drawn where ProRail registers them — masts, lamp heads and red-and-white booms standing on the road either side of the track. Before this, a level crossing was a road ribbon meeting a rail ribbon with no equipment on it at all. **What I took, and what I did not.** The service publishes seven collections. Only `overweg` (level crossings) is wired in. Track axes and the corridor centreline would lay a second set of rail ribbons on top of the ones already drawn — value 4, add don't replace. Switches and diamond crossings have real geometry and nothing to stand on, because the rails are a painted texture rather than modelled steel, so they would be invisible at every altitude — value 6. Stations are already points of interest, and kilometre posts are unlabelled plates with no recorded orientation. **The one hard part.** The register carries no orientation (`azimut` is empty on all 3,917 crossings) and puts each crossing's point *on the rail centreline*. The existing "which way does the nearest way run" index includes railways, so it answered the track every time and would have laid every boom along the rails it is meant to bar. I added a filter to that index that excludes rail, with the rail test sitting next to the road classifier so the two cannot drift apart. A crossing with no road in range is dropped rather than drawn at a guess. **Three things in the register that are not what they look like** — all measured against the live service and pinned by a captured feature in the tests: - `levenscyclus_status` is not a built/not-built flag. 1,304 of 3,917 crossings read "Definitief ontwerp", including the Peizerweg crossing in Groningen, which is standing in the street. Filtering on it would delete a third of the register. - `aantal_ahob` is not a count of booms. That same single-track crossing reports 15. - The extent the service publishes for the collection reaches Rennes and Copenhagen — it describes the coordinate grid, not the data. I measured the real extent over all 3,917 features and used that instead. **How it is bounded.** Three checks run before any network request: the coverage envelope, a railway mention in the not-yet-decoded map tile, and the existing Furniture layer toggle. There is a per-cell cap that warns when it truncates, and the meshes are byte-bounded inside the map streamer's existing worker. No colliders and no animation. **Verified here:** `cargo fmt --check` clean across the workspace; `cargo check` clean on both crates; the map-data crate's 236 tests and the client's 814 tests all pass, including 20 new ones. A correction along the way: I first asserted the coverage check refuses Brussels. It does not, and cannot — a rectangle drawn round the Dutch corners contains Brussels and Cologne. The test and the note now say so; the cost is one empty request for a border cell that has a railway in it. **Not verified, and it needs you.** I could not look at it. This container renders on a software rasteriser, where geometry and counts are trustworthy but colour and lighting are not. Whether the installation actually *reads* as a level crossing at walking distance is exactly that kind of judgement. The scene gate is: ``` cargo run -p cartopolis -- --shot /tmp/crossing.png \ --at 53.21024,6.54933,60 --look=-20,90 --time 12 --clouds 0 --rain 0 \ --dump-state /tmp/crossing.json \ --expect 'settled==true' --expect 'level_crossings>=1' ``` **One thing to decide.** I named 11 of the 18 installation codes. Five more (`WILO`, `VKL`, `HAVIO`, `EBO`, `ADOB` — 39 crossings between them) draw nothing, because I could not establish what the abbreviations expand to and a guessed form is an invented survey. If you know what they are, adding them is one line each in `CROSSING_TABLE`. Branch `feat/216-prorail-level-crossings` is pushed. Note at `docs/notes/prorail-level-crossings.md`; the frontier list in `docs/direction.md` moves ProRail from candidates to wired. **Branch** `feat/216-prorail-level-crossings` (pushed) ``` db60aea feat(map): raise ProRail's registered level crossings into the world ``` --- <sub>🤖 viberfox issue agent on the VPS — 133 turns · $17.06 · session `ce25c591-c467-4ffd-919b-7a1a1dbea273`. Reply with **@agent** to continue this session.</sub>
Author
Collaborator

🤖 Marked agent:done without a CI verdict on db60aea6 — 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 `db60aea6` — no workflow reported on this commit. The branch is waiting for review; check the run yourself before landing it.
viberfox-agent 2026-08-30 09:04:47 +00:00
Author
Collaborator

🤖 Merged into main as pull request #227 (db60aea6).

🤖 **Merged into `main`** as pull request #227 (`db60aea6`).
Author
Collaborator

🤖 Deployed to the public services.

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

dispatching the simulator
  run 683 started
  the simulator: success

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

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

deployed bdd05d07
🤖 **Deployed to the public services.** ``` main is at bdd05d07 waiting for CI on bdd05d07 success wasm & android targets success test cartopolis dispatching the simulator run 683 started the simulator: success verifying the running simulator (expecting protocol 21) serving protocol 21 dispatching the web client run 684 started the web client: success deployed bdd05d07 ```
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#216
No description provided.