Rijkswaterstaat vaarwegmarkeringen, PDOK OGC API: is it worth wiring in? #206

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

Problem

docs/direction.md:82 lists Rijkswaterstaat's vaarwegmarkeringen as an unverified frontier candidate. The first half of this ticket was to confirm the interface, the licence and the volume. All three check out, and the row's description is accurate.

Interface. https://api.pdok.nl/rws/vaarwegmarkeringen-nederland/ogc/v1 (the URL the row implies, …/rws/vaarwegmarkeringen/…, 404s). Keyless OGC API Features, two collections — vaarweg_markeringen_drijvend_rd (floating: buoys) and vaarweg_markeringen_vast_rd (fixed: beacons, groyne marks, shore lights). bbox query, cursor paging, limit capped at 1000, Access-Control-Allow-Origin: *, Cache-Control: public, max-age=3600. properties= subsetting is not supported (400), so the full ~40-property feature comes down.

Licence. CC0 1.0, stated by the service's own license link. No obstacle.

Volume, measured 2026-08-30 over the national bbox 3.0,50.6,7.4,55.7:

collection features pages raw GeoJSON wall clock
floating 10,105 11 9.6 MB 3 s
fixed 8,369 9 18.2 MB 4 s

Per z14 cell (the zoom map_geometry streams at — surveyed.rs:35 pins BGT_ZOOM = 14 to map_geometry::GEOMETRY_ZOOM):

cell floating fixed raw
Nijmegen, Waal (8458, 5422) 30 13 57.3 KB
Hollands Diep (8399, 5434) 3 0 4.6 KB
Amsterdam, het IJ (8415, 5383) 2 0 3.7 KB
Rotterdam, Nieuwe Maas (8396, 5418) 1 0 2.8 KB
Groningen, Grote Markt (8490, 5320) 0 0 1.9 KB (two empty answers)

Attributes, censused over 625 floating and 267 fixed marks across five boxes (IJsselmeer, het IJ, Rotterdam, the Waal, Hollands Diep):

  • obj_vorm — 4 values: spar 426, stomp 127, spits 65, bol 7 (spar / can / conical / spherical).
  • obj_kleur — 13 values, all of the form A, A/B, A/B/A or A/B repeterend over {Rood, Groen, Geel, Wit, Zwart, Grijs}: Geel 169, Rood 113, Groen 102, Rood/wit repeterend 101, Groen/wit repeterend 93, then nine tails down to 1.
  • tt_toptek / tt_kleur — topmark, present on 317/625: Cilinder 189, Kegel, punt naar boven 75, Liggend kruis 17, four two-cone forms, Bol 6.
  • licht_klr — a light colour on 166/625 floating marks (Rood 63, Groen 55, Geel 28, Wit 20); licht_kl on 133/267 fixed. sign_kar gives the character (Iso 77, LFl 45, Fl 28, Q 7 …) and sign_perio the period in seconds, on the same subset.
  • Fixed marks carry naut_funct instead of a shape: Kribbaken 121, Bermverlichting 39, Oeverlicht 32, Havenlicht 19, Lichtopstand 19, Lichtenlijn 14, down to Lichttoren (vuurtoren) 2.
  • obj_hoogte is 0,00000 on all 267 fixed marks — the register carries no object height. licht_hgt is the light's height above water and is 0,0 on 183/267.
  • opgeheven (decommissioned) is # on all 892 sampled — unlike the BGT, this API is not returning historical versions.

Worth doing, and the decision was made from the values in docs/direction.md:24-46. Value 1 (measured beats plausible): the only thing standing on open water in this client today is the inferred moored boats of crates/geo/src/moorings.rs:200-204, which the note docs/notes/canal-moorings.md is explicit about being a guess from polygon shape. A buoy is surveyed, individually, with its colour. Value 2: PDOK, CC0, keyless. Value 3: zero features over Groningen, Amsterdam centre and Utrecht centre, which is the degrade-to-nothing shape the streamers already have. Value 4: it adds a form; nothing is replaced. Value 5: 43 marks in the densest cell measured, merged into an existing per-cell mesh — no new upload path. Value 6: the count is a --dump-state field.

The counter-case, and why it does not win: the four cities the client is usually flown around have almost nothing (Rotterdam's Nieuwe Maas cell has one mark, Amsterdam's IJ cell two). This is not the docs/notes/ndw-traffic.md shape, where the measured objects fell nowhere near where anybody stands — the Waal at Nijmegen has 43 in a single 1.5 km cell and the IJsselmeer, the Amer, the Noordzeekanaal and Hollands Diep are dense. It is a layer that appears on the water and nowhere else, which is what it should be.

Approach

Two crates, no cartopy change, no new database.

Where the data comes from — a decision that departs from the ticket boilerplate. The ticket text asks for "an extractor under tools/, a coverage manifest". That is the BGT/BAG/dikes shape, and it is wrong for this source. docs/notes/where-the-map-data-is-built.md moved the four Dutch extractors to cartopy, and the repo's own CLAUDE.md warns that "nothing but a person keeps the two repositories in step". A cross-repo seam is worth paying for a 2 MB-per-cell dataset that needs reprojection and triangulation; it is not worth paying for 18,474 points behind a keyless bbox query with CORS * and an hour of cache control. The in-tree precedent for fetching PDOK directly from the client is crates/cartopolis/src/systems/map/height.rs:53 (AHN) and crates/cartopolis/src/systems/map/aerial.rs:39; the precedent for a per-cell third-party streamer is crates/cartopolis/src/systems/map/tall_structures.rs:1-16 against Overpass. Follow those. No CoverageSlot either — crates/cartopolis/src/systems/map/coverage.rs:1-9 answers "which cells does our host serve", which does not apply here; coverage is an envelope, exactly as aerial.rs decided for the same reason. Say all of this in the commit body.

crates/geo/src/nav_marks.rs (new, Bevy-free, unit-tested):

  • NavMark { x: f32, z: f32, kind: MarkKind, body: ColourPattern, topmark: Option<Topmark>, light: Option<MarkColour> } in cell-local metres.
  • Parsers for the four vocabularies above, each a total function with a "drop what this reader has no form for" arm — the same rule surveyed.rs:71-78 states for PROP_KINDS. obj_kleur is parsed by splitting on / and stripping a trailing repeterend, so Rood/wit repeterend and Geel/zwart/geel both resolve without a 24-entry lookup table.
  • A mesher emitting into SurfaceGroup::Furniture (crates/geo/src/vector_tiles.rs), so marks inherit the group's shared material, its altitude fade (furniture.rs:60-64, FURNITURE_FADE_START 150 m / FURNITURE_FADE_END 450 m) and its Layers-panel toggle for free.
  • Deliberately not new FurnitureKind variants. FurnitureInstance (crates/geo/src/furniture.rs:84-102) carries exactly one paint: u32, and a mark's colour is a pattern of up to three bands plus a separate topmark colour plus a light colour. Widening that struct would touch parking, traffic and moorings to add a field only one form reads.
  • The forms: a floating mark is a body in its colour bands (spar = a tall thin cylinder, can = a flat-topped cylinder, conical = a cone, spherical = a sphere-ish drum) with the topmark on a short staff above it; a fixed mark is a post with the same topmark treatment. Where the register names a light colour, the lantern cap is painted in it — a static colour off the register, using the existing vertex-colour path.
  • The waterline sits at y = 0 in the cell frame with the body straddling it, exactly as furniture::form::BOAT does (docs/notes/canal-moorings.md, "the hull sits 20 cm below y = 0 … that is deliberate: y = 0 is where the water surface is").
  • No collider. Marks are not FurnitureKinds, so collision::sync_prop_colliders never sees them — which is the answer moorings reaches by a different route (prop_radius = 0 for a boat).

crates/cartopolis/src/systems/map/nav_marks.rs (new, the fetch half):

  • async fn load_marks_cell(cx, cy) -> MarkCell, shaped on tall_structures::fetch_cell_text (tall_structures.rs:114-137): cache key features/marks/v1/14/{x}/{y}.json in storage::Namespace::Cache, cache hit short-circuits, miss fetches both collections' /items?f=json&limit=1000&bbox=… and stores the compacted result rather than the raw 940-bytes-per-feature GeoJSON.
  • Awaited inside map_geometry's existing per-cell job at the point surveyed::load_surveyed_cell is awaited (crates/cartopolis/src/systems/map/map_geometry.rs:452), for the reason that module gives at its lines 20-24: this is baked into a merged mesh, so it must be known on the worker that builds it.
  • Two gates before any request is made, because most of the planet is neither Dutch nor wet. First a static Netherlands-plus-shelf envelope (the collections' own extent.spatial.bbox is 2.354, 50.714, 7.555, 55.669), the same "coverage is an envelope, never a status code" rule aerial.rs follows. Second, the cell's water: map_geometry has already parsed the tile at this point, and a cell whose tile carries no water geometry cannot contain a mark. Together these keep an inland or foreign cell at zero requests instead of two 1.9 KB empty answers.
  • Truncation is logged, not silent (furniture.rs:71): a page that comes back with numberReturned == 1000 means the cursor has more, and at z14 it never should.

Wiring:

  • crates/geo/src/lib.rs — pub mod nav_marks;
  • crates/cartopolis/src/systems/map/mod.rs — pub mod nav_marks; plus the flat re-export in systems/mod.rs (CLAUDE.md: the folders are not the import path).
  • map_geometry.rs — call the mesher where the surveyed props are added (map_geometry.rs:575-590), count into the per-cell readout beside cell_places.surveyed.
  • shot_harness.rs — one pub nav_marks: usize field on ShotMetrics (beside surveyed_props at line 601), the CSV header at line 821, and the assignment beside line 2635.
  • docs/notes/navigation-marks.md — new note with the measurements above, the properties= 400, the missing obj_hoogte, and why there is no extractor and no coverage manifest.
  • docs/direction.md — move the row from Candidates, unverified into the Wired list.

Acceptance criteria

  • crates/geo/src/nav_marks.rs exists, has no bevy dependency, and parses obj_vorm, obj_kleur, tt_toptek, tt_kleur, licht_klr/licht_kl and naut_funct from the vocabularies listed above, dropping any value it has no form for rather than substituting one.
  • obj_kleur parsing covers all 13 floating and 11 fixed values observed, including the A/B repeterend and A/B/A forms, driven by a test over the literal strings in the Problem section.
  • A mark whose opgeheven is anything other than # is dropped.
  • Marks are emitted into SurfaceGroup::Furniture and therefore ride the existing altitude fade and Layers toggle; no new DataLayers field is added.
  • No collider is created for any mark.
  • No request is issued for a cell outside the collections' spatial extent, and none for a cell whose parsed tile carries no water geometry. A test covers both.
  • Per-cell responses are cached under storage::Namespace::Cache with a versioned key, and a cache hit issues no network request.
  • A page returning exactly limit features logs a truncation warning once.
  • The per-cell mark count is hard-capped, in the shape of furniture::MAX_FURNITURE_PER_CELL (crates/geo/src/furniture.rs:75), and truncation is logged.
  • ShotMetrics gains nav_marks, present in the shot state line, in --csv and in --dump-state.
  • docs/notes/navigation-marks.md records the measurements, the licence, the endpoint, and the two rejected alternatives (a cartopy extractor; a coverage manifest).
  • docs/direction.md moves the row into Wired, and the note is linked.
  • Nothing in the diff draws a flashing or emissive light (see Out of scope).

Verification

Not runnable in this pass — this is a read-only refinement, and the numbers below are the implementer's to produce.

cargo test -p cartopolis_geo nav_marks
cargo test -p cartopolis
cargo fmt --check                     # workspace-wide; CI gates on it
cargo check -p cartopolis

The headless gate, on a machine where a build is allowed (the container's own docs/notes/headless-shots-software-renderer.md says lavapipe renders geometry here trustworthily; this refinement pass may not run it):

# The Waal at Nijmegen — the densest z14 cell measured, 43 marks.
cargo run -p cartopolis -- --shot /tmp/marks.png \
    --at 51.8500,5.8550,220 --look=-15,0 --dump-state /tmp/marks.json \
    --expect 'nav_marks>=30' --expect 'settled==true'

# The degrade case: Groningen must be unchanged and must cost nothing.
cargo run -p cartopolis -- --shot /tmp/gro.png \
    --at 53.2194,6.5665,220 --look=-15,0 --dump-state /tmp/gro.json \
    --expect 'nav_marks==0'

Only a workstation can judge whether the colours read correctly — colour and lighting are not evidence on a software rasteriser. The geometry half (count, placement on the water rather than the bank, the fade band) is judgeable here.

Re-measuring the source, if any number above is doubted:

curl -s 'https://api.pdok.nl/rws/vaarwegmarkeringen-nederland/ogc/v1/collections?f=json'
curl -s 'https://api.pdok.nl/rws/vaarwegmarkeringen-nederland/ogc/v1/collections/vaarweg_markeringen_drijvend_rd/items?f=json&limit=1000&bbox=5.8447265625,51.84935276370605,5.86669921875,51.862923913602444'

Out of scope

  • The lights actually lighting. licht_klr/sign_kar/sign_perio give a colour, a character and a period, and 27% of floating marks and 50% of fixed ones carry them — but the Furniture group is a shared StandardMaterial lit by vertex colour, with no per-object emissive anywhere in crates/geo/src/furniture.rs. A flashing navigation light is a second material or an emitter and is its own ticket. This ticket paints the lantern cap in the registered colour and stops there.
  • Lighthouses and light towers as structures. obj_hoogte is 0,00000 on every fixed mark sampled, so there is no height to build one from; Lichttoren and Lichtopstand are drawn as the same beacon post as the rest. The tall-landmark lane is systems::tall_structures and takes its heights from OSM.
  • The fairway network itself. nationaal-wegenbestand-vaarwegen and vaarweg-netwerk-data-service-bevaarbaarheid are separate PDOK services and separate frontier questions.
  • Anything in cartopy. No extractor, no coverage manifest, no server/pipeline/ change.
  • Moving vessels. This is fixed and floating marks; AIS is not in this repo.
  • A new Layers-panel row. Marks ride furniture_enabled because they share the mesh group — the same reasoning data_layers.rs:490-499 gives for cars and benches sharing one toggle.

Open questions

None.


Branch: feat/206-navigation-marks-layer

Original request

From the frontier in docs/direction.md: Rijkswaterstaat vaarwegmarkeringen, PDOK OGC API — Every buoy and fixed beacon on the national waterways, each carrying its colour pattern and the colour and character of the light it shows, so a river or a canal holds the objects that are really standing in it: 5 floating and 3 fixed marks in 13 KB over a Hollands Diep cell..

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:82` lists Rijkswaterstaat's *vaarwegmarkeringen* as an unverified frontier candidate. The first half of this ticket was to confirm the interface, the licence and the volume. All three check out, and the row's description is accurate. **Interface.** `https://api.pdok.nl/rws/vaarwegmarkeringen-nederland/ogc/v1` (the URL the row implies, `…/rws/vaarwegmarkeringen/…`, 404s). Keyless OGC API Features, two collections — `vaarweg_markeringen_drijvend_rd` (floating: buoys) and `vaarweg_markeringen_vast_rd` (fixed: beacons, groyne marks, shore lights). `bbox` query, cursor paging, `limit` capped at 1000, `Access-Control-Allow-Origin: *`, `Cache-Control: public, max-age=3600`. `properties=` subsetting is **not** supported (400), so the full ~40-property feature comes down. **Licence.** CC0 1.0, stated by the service's own `license` link. No obstacle. **Volume**, measured 2026-08-30 over the national bbox `3.0,50.6,7.4,55.7`: | collection | features | pages | raw GeoJSON | wall clock | |---|---|---|---|---| | floating | 10,105 | 11 | 9.6 MB | 3 s | | fixed | 8,369 | 9 | 18.2 MB | 4 s | Per z14 cell (the zoom `map_geometry` streams at — `surveyed.rs:35` pins `BGT_ZOOM = 14` to `map_geometry::GEOMETRY_ZOOM`): | cell | floating | fixed | raw | |---|---|---|---| | Nijmegen, Waal (8458, 5422) | 30 | 13 | 57.3 KB | | Hollands Diep (8399, 5434) | 3 | 0 | 4.6 KB | | Amsterdam, het IJ (8415, 5383) | 2 | 0 | 3.7 KB | | Rotterdam, Nieuwe Maas (8396, 5418) | 1 | 0 | 2.8 KB | | Groningen, Grote Markt (8490, 5320) | 0 | 0 | 1.9 KB (two empty answers) | **Attributes**, censused over 625 floating and 267 fixed marks across five boxes (IJsselmeer, het IJ, Rotterdam, the Waal, Hollands Diep): - `obj_vorm` — 4 values: `spar` 426, `stomp` 127, `spits` 65, `bol` 7 (spar / can / conical / spherical). - `obj_kleur` — 13 values, all of the form `A`, `A/B`, `A/B/A` or `A/B repeterend` over {Rood, Groen, Geel, Wit, Zwart, Grijs}: `Geel` 169, `Rood` 113, `Groen` 102, `Rood/wit repeterend` 101, `Groen/wit repeterend` 93, then nine tails down to 1. - `tt_toptek` / `tt_kleur` — topmark, present on 317/625: `Cilinder` 189, `Kegel, punt naar boven` 75, `Liggend kruis` 17, four two-cone forms, `Bol` 6. - `licht_klr` — a light colour on **166/625** floating marks (Rood 63, Groen 55, Geel 28, Wit 20); `licht_kl` on **133/267** fixed. `sign_kar` gives the character (Iso 77, LFl 45, Fl 28, Q 7 …) and `sign_perio` the period in seconds, on the same subset. - Fixed marks carry `naut_funct` instead of a shape: `Kribbaken` 121, `Bermverlichting` 39, `Oeverlicht` 32, `Havenlicht` 19, `Lichtopstand` 19, `Lichtenlijn` 14, down to `Lichttoren (vuurtoren)` 2. - `obj_hoogte` is `0,00000` on all 267 fixed marks — **the register carries no object height.** `licht_hgt` is the light's height above water and is `0,0` on 183/267. - `opgeheven` (decommissioned) is `#` on all 892 sampled — unlike the BGT, this API is not returning historical versions. **Worth doing, and the decision was made from the values in `docs/direction.md:24-46`.** Value 1 (measured beats plausible): the only thing standing on open water in this client today is the *inferred* moored boats of `crates/geo/src/moorings.rs:200-204`, which the note `docs/notes/canal-moorings.md` is explicit about being a guess from polygon shape. A buoy is surveyed, individually, with its colour. Value 2: PDOK, CC0, keyless. Value 3: zero features over Groningen, Amsterdam centre and Utrecht centre, which is the degrade-to-nothing shape the streamers already have. Value 4: it adds a form; nothing is replaced. Value 5: 43 marks in the densest cell measured, merged into an existing per-cell mesh — no new upload path. Value 6: the count is a `--dump-state` field. The counter-case, and why it does not win: the four cities the client is usually flown around have almost nothing (Rotterdam's Nieuwe Maas cell has one mark, Amsterdam's IJ cell two). This is *not* the `docs/notes/ndw-traffic.md` shape, where the measured objects fell nowhere near where anybody stands — the Waal at Nijmegen has 43 in a single 1.5 km cell and the IJsselmeer, the Amer, the Noordzeekanaal and Hollands Diep are dense. It is a layer that appears on the water and nowhere else, which is what it should be. ## Approach Two crates, no cartopy change, no new database. **Where the data comes from — a decision that departs from the ticket boilerplate.** The ticket text asks for "an extractor under `tools/`, a coverage manifest". That is the BGT/BAG/dikes shape, and it is wrong for this source. `docs/notes/where-the-map-data-is-built.md` moved the four Dutch extractors to cartopy, and the repo's own CLAUDE.md warns that "nothing but a person keeps the two repositories in step". A cross-repo seam is worth paying for a 2 MB-per-cell dataset that needs reprojection and triangulation; it is not worth paying for 18,474 points behind a keyless bbox query with CORS `*` and an hour of cache control. The in-tree precedent for fetching PDOK directly from the client is `crates/cartopolis/src/systems/map/height.rs:53` (AHN) and `crates/cartopolis/src/systems/map/aerial.rs:39`; the precedent for a per-cell third-party streamer is `crates/cartopolis/src/systems/map/tall_structures.rs:1-16` against Overpass. Follow those. No `CoverageSlot` either — `crates/cartopolis/src/systems/map/coverage.rs:1-9` answers "which cells does *our host* serve", which does not apply here; coverage is an envelope, exactly as `aerial.rs` decided for the same reason. Say all of this in the commit body. **`crates/geo/src/nav_marks.rs`** (new, Bevy-free, unit-tested): - `NavMark { x: f32, z: f32, kind: MarkKind, body: ColourPattern, topmark: Option<Topmark>, light: Option<MarkColour> }` in cell-local metres. - Parsers for the four vocabularies above, each a total function with a "drop what this reader has no form for" arm — the same rule `surveyed.rs:71-78` states for `PROP_KINDS`. `obj_kleur` is parsed by splitting on `/` and stripping a trailing ` repeterend`, so `Rood/wit repeterend` and `Geel/zwart/geel` both resolve without a 24-entry lookup table. - A mesher emitting into `SurfaceGroup::Furniture` (`crates/geo/src/vector_tiles.rs`), so marks inherit the group's shared material, its altitude fade (`furniture.rs:60-64`, `FURNITURE_FADE_START` 150 m / `FURNITURE_FADE_END` 450 m) and its Layers-panel toggle for free. - **Deliberately not new `FurnitureKind` variants.** `FurnitureInstance` (`crates/geo/src/furniture.rs:84-102`) carries exactly one `paint: u32`, and a mark's colour is a *pattern* of up to three bands plus a separate topmark colour plus a light colour. Widening that struct would touch `parking`, `traffic` and `moorings` to add a field only one form reads. - The forms: a floating mark is a body in its colour bands (spar = a tall thin cylinder, can = a flat-topped cylinder, conical = a cone, spherical = a sphere-ish drum) with the topmark on a short staff above it; a fixed mark is a post with the same topmark treatment. Where the register names a light colour, the lantern cap is painted in it — a static colour off the register, using the existing vertex-colour path. - The waterline sits at `y = 0` in the cell frame with the body straddling it, exactly as `furniture::form::BOAT` does (`docs/notes/canal-moorings.md`, "the hull sits 20 cm below y = 0 … that is deliberate: y = 0 is where the water surface is"). - No collider. Marks are not `FurnitureKind`s, so `collision::sync_prop_colliders` never sees them — which is the answer `moorings` reaches by a different route (`prop_radius` = 0 for a boat). **`crates/cartopolis/src/systems/map/nav_marks.rs`** (new, the fetch half): - `async fn load_marks_cell(cx, cy) -> MarkCell`, shaped on `tall_structures::fetch_cell_text` (`tall_structures.rs:114-137`): cache key `features/marks/v1/14/{x}/{y}.json` in `storage::Namespace::Cache`, cache hit short-circuits, miss fetches both collections' `/items?f=json&limit=1000&bbox=…` and stores the *compacted* result rather than the raw 940-bytes-per-feature GeoJSON. - Awaited inside `map_geometry`'s existing per-cell job at the point `surveyed::load_surveyed_cell` is awaited (`crates/cartopolis/src/systems/map/map_geometry.rs:452`), for the reason that module gives at its lines 20-24: this is baked into a merged mesh, so it must be known on the worker that builds it. - **Two gates before any request is made**, because most of the planet is neither Dutch nor wet. First a static Netherlands-plus-shelf envelope (the collections' own `extent.spatial.bbox` is `2.354, 50.714, 7.555, 55.669`), the same "coverage is an envelope, never a status code" rule `aerial.rs` follows. Second, the cell's water: `map_geometry` has already parsed the tile at this point, and a cell whose tile carries no water geometry cannot contain a mark. Together these keep an inland or foreign cell at zero requests instead of two 1.9 KB empty answers. - Truncation is logged, not silent (`furniture.rs:71`): a page that comes back with `numberReturned == 1000` means the cursor has more, and at z14 it never should. **Wiring:** - `crates/geo/src/lib.rs` — `pub mod nav_marks;` - `crates/cartopolis/src/systems/map/mod.rs` — `pub mod nav_marks;` plus the flat re-export in `systems/mod.rs` (CLAUDE.md: the folders are not the import path). - `map_geometry.rs` — call the mesher where the surveyed props are added (`map_geometry.rs:575-590`), count into the per-cell readout beside `cell_places.surveyed`. - `shot_harness.rs` — one `pub nav_marks: usize` field on `ShotMetrics` (beside `surveyed_props` at line 601), the CSV header at line 821, and the assignment beside line 2635. - `docs/notes/navigation-marks.md` — new note with the measurements above, the `properties=` 400, the missing `obj_hoogte`, and why there is no extractor and no coverage manifest. - `docs/direction.md` — move the row from **Candidates, unverified** into the **Wired** list. ## Acceptance criteria - [ ] `crates/geo/src/nav_marks.rs` exists, has no `bevy` dependency, and parses `obj_vorm`, `obj_kleur`, `tt_toptek`, `tt_kleur`, `licht_klr`/`licht_kl` and `naut_funct` from the vocabularies listed above, dropping any value it has no form for rather than substituting one. - [ ] `obj_kleur` parsing covers all 13 floating and 11 fixed values observed, including the `A/B repeterend` and `A/B/A` forms, driven by a test over the literal strings in the Problem section. - [ ] A mark whose `opgeheven` is anything other than `#` is dropped. - [ ] Marks are emitted into `SurfaceGroup::Furniture` and therefore ride the existing altitude fade and Layers toggle; no new `DataLayers` field is added. - [ ] No collider is created for any mark. - [ ] No request is issued for a cell outside the collections' spatial extent, and none for a cell whose parsed tile carries no water geometry. A test covers both. - [ ] Per-cell responses are cached under `storage::Namespace::Cache` with a versioned key, and a cache hit issues no network request. - [ ] A page returning exactly `limit` features logs a truncation warning once. - [ ] The per-cell mark count is hard-capped, in the shape of `furniture::MAX_FURNITURE_PER_CELL` (`crates/geo/src/furniture.rs:75`), and truncation is logged. - [ ] `ShotMetrics` gains `nav_marks`, present in the `shot state` line, in `--csv` and in `--dump-state`. - [ ] `docs/notes/navigation-marks.md` records the measurements, the licence, the endpoint, and the two rejected alternatives (a cartopy extractor; a coverage manifest). - [ ] `docs/direction.md` moves the row into **Wired**, and the note is linked. - [ ] Nothing in the diff draws a flashing or emissive light (see Out of scope). ## Verification Not runnable in this pass — this is a read-only refinement, and the numbers below are the implementer's to produce. ```bash cargo test -p cartopolis_geo nav_marks cargo test -p cartopolis cargo fmt --check # workspace-wide; CI gates on it cargo check -p cartopolis ``` The headless gate, on a machine where a build is allowed (the container's own `docs/notes/headless-shots-software-renderer.md` says lavapipe renders geometry here trustworthily; this refinement pass may not run it): ```bash # The Waal at Nijmegen — the densest z14 cell measured, 43 marks. cargo run -p cartopolis -- --shot /tmp/marks.png \ --at 51.8500,5.8550,220 --look=-15,0 --dump-state /tmp/marks.json \ --expect 'nav_marks>=30' --expect 'settled==true' # The degrade case: Groningen must be unchanged and must cost nothing. cargo run -p cartopolis -- --shot /tmp/gro.png \ --at 53.2194,6.5665,220 --look=-15,0 --dump-state /tmp/gro.json \ --expect 'nav_marks==0' ``` Only a workstation can judge whether the colours read correctly — colour and lighting are not evidence on a software rasteriser. The geometry half (count, placement on the water rather than the bank, the fade band) is judgeable here. Re-measuring the source, if any number above is doubted: ```bash curl -s 'https://api.pdok.nl/rws/vaarwegmarkeringen-nederland/ogc/v1/collections?f=json' curl -s 'https://api.pdok.nl/rws/vaarwegmarkeringen-nederland/ogc/v1/collections/vaarweg_markeringen_drijvend_rd/items?f=json&limit=1000&bbox=5.8447265625,51.84935276370605,5.86669921875,51.862923913602444' ``` ## Out of scope - **The lights actually lighting.** `licht_klr`/`sign_kar`/`sign_perio` give a colour, a character and a period, and 27% of floating marks and 50% of fixed ones carry them — but the Furniture group is a shared `StandardMaterial` lit by vertex colour, with no per-object emissive anywhere in `crates/geo/src/furniture.rs`. A flashing navigation light is a second material or an emitter and is its own ticket. This ticket paints the lantern cap in the registered colour and stops there. - **Lighthouses and light towers as structures.** `obj_hoogte` is `0,00000` on every fixed mark sampled, so there is no height to build one from; `Lichttoren` and `Lichtopstand` are drawn as the same beacon post as the rest. The tall-landmark lane is `systems::tall_structures` and takes its heights from OSM. - **The fairway network itself.** `nationaal-wegenbestand-vaarwegen` and `vaarweg-netwerk-data-service-bevaarbaarheid` are separate PDOK services and separate frontier questions. - **Anything in cartopy.** No extractor, no coverage manifest, no `server/pipeline/` change. - **Moving vessels.** This is fixed and floating marks; AIS is not in this repo. - **A new Layers-panel row.** Marks ride `furniture_enabled` because they share the mesh group — the same reasoning `data_layers.rs:490-499` gives for cars and benches sharing one toggle. ## Open questions None. --- Branch: `feat/206-navigation-marks-layer` <details><summary>Original request</summary> From the frontier in [`docs/direction.md`](../src/branch/main/docs/direction.md): **Rijkswaterstaat vaarwegmarkeringen, PDOK OGC API** — Every buoy and fixed beacon on the national waterways, each carrying its colour pattern and the colour and character of the light it shows, so a river or a canal holds the objects that are really standing in it: 5 floating and 3 fixed marks in 13 KB over a Hollands Diep cell.. 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:rijkswaterstaat-vaarwegmarkeringen-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 — 46 turns · $4.16 · session a5ec152c-ae99-40e4-b6ec-f00b3a5b7226. 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 — 46 turns · $4.16 · session `a5ec152c-ae99-40e4-b6ec-f00b3a5b7226`. Reply with **@agent** to continue this session.</sub>
Author
Collaborator

🤖 Picked this up — working on branch feat/206-navigation-marks-layer 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/206-navigation-marks-layer` 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

The branch is pushed. Here is the comment for the issue.


Wired it in. The waterways now hold the buoys and beacons Rijkswaterstaat actually surveyed — shape, colour bands, topmark, and the lantern painted in the colour of the light it shows. Nothing else on the map changed: outside the Netherlands, away from water, or with the Furniture layer off, the client draws exactly what it drew before and makes no requests.

Two new files do the work: crates/geo/src/nav_marks.rs reads the register's vocabularies and builds the meshes, crates/cartopolis/src/systems/map/nav_marks.rs fetches and caches a cell. The marks go into the existing SurfaceGroup::Furniture, so they inherit its material, its altitude fade and its Layers row without new wiring, and they get no collider — an invisible wall in a fairway is worse than a buoy you can walk through.

Where I departed from the ticket, and why

  • The water gate reads the undecoded tile, not a parsed one. The ticket said map_geometry has already parsed the tile at that point. It has not — the tile is fetched on the I/O pool and parsed later on a worker, and waiting for the parse would put a ~120 ms decode on the frame loop in the browser build. So vector_tiles::body_mentions_water searches the raw bytes for the water layer names. A layer that exists cannot fail to be named in the body, so a false negative is impossible; a false positive costs one request.
  • A fourth gate: the Furniture toggle. With that layer off, nothing is fetched at all.
  • I dropped four of the register's fixed values — Bermverlichting, Bordverlichting, Aanstraalverlichting, Walkast. Those are verge lighting, sign lighting, floodlighting and a shore cabinet, not navigation marks, and the BGT already surveys street lighting column by column, so drawing them would stand two lampposts on one spot. That is 33 of 260 sampled fixed features.
  • A mark whose colour does not parse is not drawn. The colour is the meaning; a grey buoy asserts a mark that says nothing. Three of 260 fixed marks (Niet toegewezen).

Three things in the data the ticket's census missed, all of which fail by producing nothing rather than by erroring:

  • Geometry is MultiPoint, never Point. A reader matching only Point finds zero marks nationally and looks exactly like a river with none.
  • The fixed collection writes v_toptek and licht_kl where the floating one writes tt_toptek and licht_klr. Reading only the floating keys leaves every river beacon bare.
  • obj_vorm has a fifth value, Pilaar (12 of 998 sampled).

I put two real answers from the service under crates/cartopolis/tests/fixtures/ and test against them, so those three are pinned against the register rather than against my idea of it.

Verified

  • cargo test -p cartopolis_geo — 223 passed, 14 of them new.
  • cargo test -p cartopolis — 806 passed, 12 of them new.
  • cargo fmt --check across the workspace, clean.
  • cargo check for cartopolis, cartopolis_simulator, and the web target with the nightly flags the deploy uses.

Not verified

  • Nothing has been rendered. I could not run --shot in this container, so the two headless gates in the ticket are unrun. The commands are in the ticket and nav_marks is now a --dump-state field, so --expect 'nav_marks>=30' over the Waal and --expect 'nav_marks==0' over Groningen will work as written.
  • Colour needs a real screen. Colour and lighting are not evidence on a software rasteriser, and I chose the seven tones by hand. Also unchecked: whether a 1.4 m buoy is still legible where the Furniture fade begins at 150 m.
  • The Android target check did not run — this container has no NDK. The change adds no platform-specific code and uses only the HTTP and storage seams tall_structures already uses on that target.

docs/notes/navigation-marks.md has the measurements, the licence and the two rejected alternatives; the row has moved into Wired in docs/direction.md.

Branch feat/206-navigation-marks-layer (pushed)

38c2387 feat(map): draw the surveyed buoys and beacons on the waterways

🤖 viberfox issue agent on the VPS — 126 turns · $17.52 · session 8b075b37-96ae-460a-a81f-6e459875e374. Reply with @agent to continue this session.

The branch is pushed. Here is the comment for the issue. --- **Wired it in.** The waterways now hold the buoys and beacons Rijkswaterstaat actually surveyed — shape, colour bands, topmark, and the lantern painted in the colour of the light it shows. Nothing else on the map changed: outside the Netherlands, away from water, or with the Furniture layer off, the client draws exactly what it drew before and makes no requests. Two new files do the work: `crates/geo/src/nav_marks.rs` reads the register's vocabularies and builds the meshes, `crates/cartopolis/src/systems/map/nav_marks.rs` fetches and caches a cell. The marks go into the existing `SurfaceGroup::Furniture`, so they inherit its material, its altitude fade and its Layers row without new wiring, and they get no collider — an invisible wall in a fairway is worse than a buoy you can walk through. **Where I departed from the ticket, and why** - **The water gate reads the undecoded tile, not a parsed one.** The ticket said `map_geometry` has already parsed the tile at that point. It has not — the tile is fetched on the I/O pool and parsed later on a worker, and waiting for the parse would put a ~120 ms decode on the frame loop in the browser build. So `vector_tiles::body_mentions_water` searches the raw bytes for the water layer names. A layer that exists cannot fail to be named in the body, so a false negative is impossible; a false positive costs one request. - **A fourth gate: the Furniture toggle.** With that layer off, nothing is fetched at all. - **I dropped four of the register's fixed values** — `Bermverlichting`, `Bordverlichting`, `Aanstraalverlichting`, `Walkast`. Those are verge lighting, sign lighting, floodlighting and a shore cabinet, not navigation marks, and the BGT already surveys street lighting column by column, so drawing them would stand two lampposts on one spot. That is 33 of 260 sampled fixed features. - **A mark whose colour does not parse is not drawn.** The colour is the meaning; a grey buoy asserts a mark that says nothing. Three of 260 fixed marks (`Niet toegewezen`). **Three things in the data the ticket's census missed**, all of which fail by producing nothing rather than by erroring: - Geometry is `MultiPoint`, never `Point`. A reader matching only `Point` finds zero marks nationally and looks exactly like a river with none. - The fixed collection writes `v_toptek` and `licht_kl` where the floating one writes `tt_toptek` and `licht_klr`. Reading only the floating keys leaves every river beacon bare. - `obj_vorm` has a fifth value, `Pilaar` (12 of 998 sampled). I put two real answers from the service under `crates/cartopolis/tests/fixtures/` and test against them, so those three are pinned against the register rather than against my idea of it. **Verified** - `cargo test -p cartopolis_geo` — 223 passed, 14 of them new. - `cargo test -p cartopolis` — 806 passed, 12 of them new. - `cargo fmt --check` across the workspace, clean. - `cargo check` for `cartopolis`, `cartopolis_simulator`, and the web target with the nightly flags the deploy uses. **Not verified** - **Nothing has been rendered.** I could not run `--shot` in this container, so the two headless gates in the ticket are unrun. The commands are in the ticket and `nav_marks` is now a `--dump-state` field, so `--expect 'nav_marks>=30'` over the Waal and `--expect 'nav_marks==0'` over Groningen will work as written. - **Colour needs a real screen.** Colour and lighting are not evidence on a software rasteriser, and I chose the seven tones by hand. Also unchecked: whether a 1.4 m buoy is still legible where the Furniture fade begins at 150 m. - **The Android target check did not run** — this container has no NDK. The change adds no platform-specific code and uses only the HTTP and storage seams `tall_structures` already uses on that target. `docs/notes/navigation-marks.md` has the measurements, the licence and the two rejected alternatives; the row has moved into **Wired** in `docs/direction.md`. **Branch** `feat/206-navigation-marks-layer` (pushed) ``` 38c2387 feat(map): draw the surveyed buoys and beacons on the waterways ``` --- <sub>🤖 viberfox issue agent on the VPS — 126 turns · $17.52 · session `8b075b37-96ae-460a-a81f-6e459875e374`. Reply with **@agent** to continue this session.</sub>
Author
Collaborator

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

🤖 Merged into main as pull request #207 (38c2387c).

🤖 **Merged into `main`** as pull request #207 (`38c2387c`).
Author
Collaborator

🤖 Deployed to the public services.

main is at c815861d
  waiting for CI on c815861d
  success    wasm & android targets
  success    build apk & publish
  success    already published?
  success    test cartopolis

dispatching the simulator
  run 670 started
  the simulator: success

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

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

deployed c815861d
🤖 **Deployed to the public services.** ``` main is at c815861d waiting for CI on c815861d success wasm & android targets success build apk & publish success already published? success test cartopolis dispatching the simulator run 670 started the simulator: success verifying the running simulator (expecting protocol 21) serving protocol 21 dispatching the web client run 671 started the web client: success deployed c815861d ```
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#206
No description provided.