gap: you cannot find out what a navigation mark is #214

Closed
opened 2026-08-30 07:57:45 +00:00 by viberfox-agent · 6 comments
Collaborator

Problem

The client draws every buoy and beacon on the Dutch waterways correctly and then drops every word that says what one is.

The words are read and discarded at parse time. read_feature pulls obj_vorm/naut_funct, obj_kleur, the topmark and the light colour (crates/cartopolis/src/systems/map/nav_marks.rs:337-378) and nothing else. WireMark's own doc comment names what goes: "the light's character and period, the fairway name, the RD coordinates, the sixteen sector definitions" (nav_marks.rs:140-144). naut_funct is read for fixed marks, but only through FixedForm::parse, which collapses the register's 14 values into 3 drawable posts (crates/geo/src/nav_marks.rs, and the census in docs/notes/navigation-marks.md:79-81).

Nothing downstream could name one even if it had the words. MarkCell::marks is Vec<(f64, f64, MarkForm)> (nav_marks.rs:170-173); cell_marks turns it into bare NavMarks (nav_marks.rs:460-472); build_mark_meshes merges those into the cell's SurfaceGroup::Furniture meshes (crates/geo/src/nav_marks.rs:423-447). The MarkCell is moved into the worker closure and dropped. All that survives is a count: cell_places.surveyed.5 (crates/cartopolis/src/systems/map/map_geometry.rs:701), summed by WorldPlaces::surveyed() (crates/cartopolis/src/systems/map/world_places.rs:377-384) for --dump-state.

So the three ways a player asks "what is that?" all miss. picking::prim_picking only sees prims. WorldPlaces::insert_cell builds Interactables from places.furniture and places.pois only (world_places.rs:300-326), so places.nearest(pos, INTERACT_RADIUS) in interact.rs:284 can never return a mark. bag_address's readout chain ends at nearest_berth (crates/cartopolis/src/systems/player/interact.rs:395-399) and knows nothing of marks.

And the layer has no row of its own. Marks ride the Furniture group's mesh, material, altitude fade and toggle — the decision the geo module calls "what makes this layer nearly free" (crates/geo/src/nav_marks.rs:418-421) — so the only control is the Street furniture checkbox at crates/cartopolis/src/systems/gui/data_layers.rs:2388-2392.

Approach

Three parts, all in cartopolis except one small label table in cartopolis_geo.

A. Carry the register's words through to the client

crates/cartopolis/src/systems/map/nav_marks.rs:

  • read_feature additionally reads benaming, vaarwater and naut_funct through the existing prop() helper (nav_marks.rs:311-313), each filtered through is_absent (nav_marks.rs:91-93 — the register writes absence three ways).
  • Replace MarkCell::marks' tuple with a named struct (lat, lng, form, plus the three Option<String>s). parse_items and cell_marks change signature with it; both are only used inside this module and its tests (verified by grep — the only other nav_marks references are map_geometry, world_places, shot_harness and mod.rs).
  • WireMark gains the three fields under #[serde(default)], and cache_key's v1 becomes v2 (nav_marks.rs:296). Without the bump, every cell already in storage::Namespace::Cache decodes with empty names and is never re-fetched (load_marks_cell returns the cached cell unconditionally, nav_marks.rs:413-415) — permanently nameless marks in exactly the cells a user has already visited. Same rule the KINDS table states for itself at nav_marks.rs:97-99.

crates/geo/src/nav_marks.rs gains one pure function: an English label per MarkKind ("spar buoy", "can buoy", "beacon", "groyne mark", …), in the style of world_places::prop_name (world_places.rs:175-200). Nothing else in the geo crate changes — NavMark and MarkForm stay string-free, so the mesh path is untouched.

B. A mark becomes something you can walk up to and ask about

On the prompt, not the HUD line. The issue proposes the nearest_berth shape, i.e. InteractState::address, which the HUD draws as place (crates/cartopolis/src/systems/gui/hud.rs:1029, field documented at hud.rs:64-72). That surface cannot carry this: it is one truncated line whose row count the HUD's own size test bounds (hud.rs:1276-1279), and it is fed by a chain in which the address wins and the berth is only the fallback (interact.rs:395-399). On the Waalkade at Nijmegen a quay building is inside ADDRESS_RADIUS, so a mark line placed in that chain would be shadowed in the exact spot the QA pass stood. The prompt is a separate world-anchored Area (interact.rs:12-15, registered at lib.rs:2732), it already has "Look at" as its verb for a named point (world_places.rs:143-147), and TargetInfo already carries pos so the route-to affordance works.

crates/cartopolis/src/systems/map/world_places.rs:

  • CellPlaces gains marks: Vec<…> (lat/lng already projected to tile-local by the worker, same as everything else arriving here — see the module note at world_places.rs:12-15). map_geometry.rs:698-704 fills it beside the existing surveyed.5 count.
  • InteractKind gains a Mark variant; glyph() returns an icon already in the bundled subset (icons::FLAG, crates/cartopolis/src/systems/gui/icons.rs:164) — there is no buoy or anchor glyph in it, and a glyph outside the subset renders as a tofu box (CLAUDE.md, docs/notes/icon-font.md). verb() returns "Look at". Interactable::seat_spot gains the Mark arm returning None (world_places.rs:87-90).
  • Interactable gains detail: Option<String> and reach: f32, both set at insertion. detail for a POI is kind.detail().map(str::to_owned); for a prop None; for a mark the composed string below. reach is INTERACT_RADIUS for props and POIs, MARK_RADIUS for marks.
  • WorldPlaces::nearest(pos, radius) keeps radius as a ceiling and additionally filters each candidate against its own reach (world_places.rs:398-407).

crates/cartopolis/src/systems/player/interact.rs:

  • New MARK_RADIUS: f32 = 35.0, the same number and the same reason as ADDRESS_RADIUS (interact.rs:32-34): the point is not where you can stand. A mark stands in the fairway and the avatar walks on the bank, so at the 3.5 m INTERACT_RADIUS (interact.rs:26-29) the only way to reach one is to fly over the water. Per-object reach is already the pattern the prim path uses (interact.rs:360-362). places.nearest is called with MARK_RADIUS at interact.rs:284; nearest still wins, so a bench at 2 m beats a buoy at 20 m.
  • TargetInfo::detail becomes Option<String>; line() (interact.rs:118-123) and the change-detection compare (interact.rs:285-291) follow. Keep the compare-before-build rule the comment at interact.rs:257-262 states — compare the strings, do not build a TargetInfo per frame.
  • Pressing E does what a POI does: Emote::Interact and the receipt line (interact.rs:196-203). No seat, no TouchPrim.

The composed line. Name is benaming where the register gives one, else the form's English label. Detail is naut_funct verbatim when present (fixed marks only), else the form's English label; then · and vaarwater when present. Dutch verbatim, with · as the separator, follows the two precedents already on this line: monument_category is kept "in its own language" (interact.rs:459-462) and bag_address joins with · (interact.rs:403). Result: Look at 884.120R (Kribbaken · BOVEN-RIJN EN WAAL), Look at W 12D (spar buoy · BOVEN-RIJN EN WAAL).

C. Its own row in the Layers panel

A sub-row of Street furniture, not a sibling. Marks are built into SurfaceGroup::Furniture, and that group's entities carry FurnitureSurface plus furniture_lod.visibility() (map_geometry.rs:1182-1187), which hides the whole group at once — so a marks row independent of Furniture would build meshes and then hide them. Nesting it is also the honest shape and has a precedent: 3DBAG sits under Buildings via add_enabled_ui(dl.buildings_enabled, …) (data_layers.rs:2258).

  • DataLayers gains nav_marks_enabled (data_layers.rs:499 neighbourhood, plus the Default, apply/save and fresh paths at :758, :819, :1709, :2559).
  • LayerPrefs gains nav_marks: bool with #[serde(default = "detail_default")] (crates/cartopolis/src/systems/net/user_store.rs:229 — an absent key is "not stated", not "off").
  • DetailWants gains marks: bool (map_geometry.rs:390-401); the fetch gate at map_geometry.rs:474 and the build at :698-704 become wants.furniture && wants.marks; wants is assembled at map_geometry.rs:950-957. Toggling costs a ring retear, which is what every flag in that struct already costs (map_geometry.rs:941-950).
  • The row is drawn indented under Street furniture (data_layers.rs:2388-2392), labelled "Navigation marks", with the same coverage honesty the Aerial row carries ("PDOK imagery — Netherlands only", data_layers.rs:~2425) — the register is Dutch waterways only.

D. One new metric

ShotMetrics gains nav_marks_named: usize — marks in the loaded cells carrying a benaming — beside the existing nav_marks (crates/cartopolis/src/systems/dev/shot_harness.rs:633-640), sourced from WorldPlaces the same way. It is the pair lod22_dated/lod22_elements already models (shot_harness.rs:641-646): nav_marks > 0 with nav_marks_named == 0 means the marks arrived and the words did not. Add it to the CSV header at shot_harness.rs:835 and to the zeroed default at :3221.

Acceptance criteria

  • read_feature reads benaming, vaarwater and naut_funct, each treating "", X, # and Niet toegewezen as absent via is_absent (nav_marks.rs:91-93).
  • cache_key is bumped from v1 to v2 (nav_marks.rs:296).
  • cartopolis_geo::nav_marks still exposes no String on NavMark or MarkForm; the only addition there is a pure MarkKind → &'static str label.
  • A mark in a loaded cell is returned by WorldPlaces::nearest from up to MARK_RADIUS (35 m) and not beyond; props and POIs keep INTERACT_RADIUS (3.5 m).
  • The prompt for a fixed mark reads Look at <benaming> (<naut_funct> · <vaarwater>), and for a floating one Look at <benaming> (<form label> · <vaarwater>), with each parenthesised part omitted when the register gives nothing.
  • A mark with no benaming is named by its form label rather than left blank or dropped.
  • Pressing E on a mark plays Emote::Interact and leaves the receipt line; it does not seat the avatar and sends no TouchPrim.
  • InteractKind::Mark returns a glyph that is present in icons::ALL (the existing uniqueness/coverage tests at icons.rs:425-435 must still pass).
  • The Layers panel has a Navigation marks row indented under Street furniture, disabled when Street furniture is off, and it says the register is Netherlands-only.
  • DetailWants gains marks, and the flag-by-flag retear test at map_geometry.rs:2011-2039 covers it (that test enumerates the fields by hand and will not catch a new one on its own).
  • LayerPrefs::nav_marks uses #[serde(default = "detail_default")], so a user.json written before this change reads as the tier default rather than as off.
  • ShotMetrics::nav_marks_named exists, appears in the --dump-state JSON and in the --csv header at shot_harness.rs:835, and is zero in the default at :3221.
  • Unit tests, all runnable without a GPU:
    • parse_items over the committed real captures crates/cartopolis/tests/fixtures/nav_marks_{floating,fixed}.json (nav_marks.rs:493-494) recovers the three new fields for a named feature — this is what pins the property keys against the live register.
    • encode_cell → decode_cell round-trips the three fields.
    • The composed line, both collections, including the every-field-absent fallback.
    • WorldPlaces::insert_cell places a mark at the cell origin offset and nearest finds it at 30 m.
  • docs/notes/navigation-marks.md gains what the client now carries and why the readout is on the prompt rather than the HUD line; its "What is deliberately not drawn" list keeps the pass-side rule as out of scope.

Verification

Runnable here (read-only pass did not run them):

cargo test -p cartopolis nav_marks
cargo test -p cartopolis world_places
cargo test -p cartopolis interact
cargo test -p cartopolis map_geometry
cargo check -p cartopolis_geo
cargo fmt --check

Note the container needs the ALSA headers extracted before cartopolis builds at all (alsa-is-missing-in-this-container); --no-default-features is not a workaround, it drops the audio feature and fails elsewhere.

Headless capture — runs in this container (lavapipe is installed; the CLAUDE.md "no Vulkan driver" line is stale), but was not run by this pass, which is read-only:

# a Waal cell with marks in it; --shot-mode avatar puts the avatar at the anchor
cargo run -p cartopolis -- --shot /tmp/marks.png --shot-mode avatar \
    --at 51.8500,5.8600,12 --look=-10,0 \
    --dump-state /tmp/marks.json --expect 'nav_marks>0' --expect 'nav_marks_named>0'

The count assertions are honest evidence on a software rasteriser. Two things are not verifiable here and belong on a workstation or a phone:

  • Whether the prompt is legible and whether 35 m is the right reach — a buoy at 35 m across water may read as a prompt for nothing. This is the one number in the spec chosen by precedent rather than by measurement, and it is the thing to look at first.
  • Whether icons::FLAG reads as a navigation mark next to the bench and café glyphs.

Also unmeasured, and worth a number before merge: the cache size of a dense cell after the strings are added. The note records the Waal cell at 57 KB on the wire and 4 KB cached (docs/notes/navigation-marks.md:44-52); benaming + vaarwater + naut_funct across 43 marks is the delta. Read it off the features/marks/v2/14/8458/5422.json entry after one run over that cell.

Out of scope

  • What a mark means — "port-hand mark, keep it to your left". The issue's motivating quote points at it, but its own "what a user would expect instead" asks for name, function and fairway, which is what this spec delivers. Deriving the pass-side from colour pattern plus topmark is domain work with a trap in it: Dutch inland waterways run under the BPR/CEVNI rules, not IALA maritime, and a rule that is right for one and wrong for the other tells the player the opposite of the truth about which side to pass. Its own ticket, with a source cited.
  • The light's character and period (sign_kar / sign_perio). Already excluded by the layer's note (docs/notes/navigation-marks.md:133-138) and unchanged here — a flashing lantern is a second material.
  • Clicking a mark. Marks are merged into the cell's Furniture mesh, so a raycast hits the batch, not the object; picking::prim_picking stays prim-only. Proximity is the whole surface.
  • Colliders. Still none, deliberately (docs/notes/navigation-marks.md:151-154) — an invisible wall in a fairway is worse than a buoy you can walk through.
  • A dedicated buoy glyph. Adding one means regenerating the subset font (tools/subset-icon-font.py, docs/notes/icon-font.md) and touching an LFS-tracked include_bytes! asset. Reusing a subset glyph is the safe move; a proper glyph is a font change on its own.
  • A HUD readout line. Explained above — it would be shadowed by the address wherever there is a quay, and the HUD's size test bounds the row count.
  • A cartopy extractor for the register. Already argued down in the note (docs/notes/navigation-marks.md:117-125); nothing here changes that.

Open questions

None.


Branch: feat/214-nav-marks-readout

Original request

Found by the QA pass on #210, walking what #206/#207 shipped as a player would meet it.

What I did

Flew to the Waal at Nijmegen and looked at the marks the layer draws (headless capture, settled, nav_marks=115). They are there and they look right — yellow cans in the fairway, a beacon on the far bank, everything standing at the waterline.

Then I did the thing anyone does next with a thing on a map that obviously means something: tried to find out what it is.

What happened

Nothing. There is no way to ask.

  • Nothing to click. A mark is not a prim and not a FurnitureInstance, so picking::prim_picking never sees it.
  • Nothing to walk up to. systems::interact resolves streamed places, addresses, houseboat berths and scripted prims out of WorldPlaces; a mark never enters WorldPlaces — the only thing #206 put there is a counter (CellPlaces::surveyed.5).
  • Nothing in the Layers panel. The marks ride the Furniture group, so the only row that touches them is Street furniture — which no one would look under for a buoy. (It is on by default on Android, which takes Budget::balanced(); it is off on the phone rung, i.e. Settings ▸ Map ▸ Battery saver or CARTO_QUALITY=phone, where detail_layers is false.)
  • Nothing in the map at all names them. There is no label, no pin, no readout line.

Grepping bears it out: outside its own two modules, nav_marks is referenced only by map_geometry (to build the mesh), world_places (the counter) and shot_harness (the metric).

Why that is a wall rather than a missing nicety

A navigation mark is the one thing on this map whose entire purpose is to tell you something. The layer's own note is explicit about it:

A navigation mark is the one form in the group where the colour is the datum: a red can and a green cone mean opposite things and differ in nothing else at 40 m.

The client went to the trouble of drawing shape, band pattern, topmark and lantern colour correctly — and then a player looking at a red can with a cylinder on top has no way to be told "port-hand mark, keep it to your left", or even what it is called. The register already answers all of it and the answers are dropped at parse time:

field example, live
benaming W 12D (floating), 884.120R (fixed)
vaarwater BOVEN-RIJN EN WAAL
naut_funct Kribbaken, Havenlicht
sign_kar / sign_perio Iso (isophased) / 4

WireMark's doc comment says so plainly: "the light's character and period, the fairway name … is dropped at parse time rather than carried through the cache." That was the right call for the cache; it also means nothing downstream can name a mark.

What a user would expect instead

The same thing this client already does for every other point register it streams. Walk within a few metres of a mark and get a line: name, what it is, which fairway. That is exactly interact.rs's nearest_berth — a lat/lng point register, held outside WorldPlaces, matched by distance and turned into one readout line — and a mark would need no more machinery than a berth does.

A cheaper half-step, if the readout is too much: give the marks their own row in the Layers panel under Map, so at least the layer is findable and can be switched on without knowing it lives under Street furniture.

Where the seam is

  • crates/cartopolis/src/systems/player/interact.rs — nearest_berth is the precedent; there is no nearest_mark
  • crates/cartopolis/src/systems/map/nav_marks.rs — WireMark is where benaming / vaarwater / naut_funct are dropped
  • crates/cartopolis/src/systems/map/world_places.rs — marks reach it as a count only
  • crates/cartopolis/src/systems/gui/data_layers.rs — the Furniture row, ~line 2391

Filed by the QA pass on #210. Not autonomous — a person decides whether this becomes work.

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

## Problem The client draws every buoy and beacon on the Dutch waterways correctly and then drops every word that says what one is. **The words are read and discarded at parse time.** `read_feature` pulls `obj_vorm`/`naut_funct`, `obj_kleur`, the topmark and the light colour (`crates/cartopolis/src/systems/map/nav_marks.rs:337-378`) and nothing else. `WireMark`'s own doc comment names what goes: "the light's character and period, the fairway name, the RD coordinates, the sixteen sector definitions" (`nav_marks.rs:140-144`). `naut_funct` *is* read for fixed marks, but only through `FixedForm::parse`, which collapses the register's 14 values into 3 drawable posts (`crates/geo/src/nav_marks.rs`, and the census in `docs/notes/navigation-marks.md:79-81`). **Nothing downstream could name one even if it had the words.** `MarkCell::marks` is `Vec<(f64, f64, MarkForm)>` (`nav_marks.rs:170-173`); `cell_marks` turns it into bare `NavMark`s (`nav_marks.rs:460-472`); `build_mark_meshes` merges those into the cell's `SurfaceGroup::Furniture` meshes (`crates/geo/src/nav_marks.rs:423-447`). The `MarkCell` is moved into the worker closure and dropped. All that survives is a count: `cell_places.surveyed.5` (`crates/cartopolis/src/systems/map/map_geometry.rs:701`), summed by `WorldPlaces::surveyed()` (`crates/cartopolis/src/systems/map/world_places.rs:377-384`) for `--dump-state`. **So the three ways a player asks "what is that?" all miss.** `picking::prim_picking` only sees prims. `WorldPlaces::insert_cell` builds `Interactable`s from `places.furniture` and `places.pois` only (`world_places.rs:300-326`), so `places.nearest(pos, INTERACT_RADIUS)` in `interact.rs:284` can never return a mark. `bag_address`'s readout chain ends at `nearest_berth` (`crates/cartopolis/src/systems/player/interact.rs:395-399`) and knows nothing of marks. **And the layer has no row of its own.** Marks ride the Furniture group's mesh, material, altitude fade and toggle — the decision the geo module calls "what makes this layer nearly free" (`crates/geo/src/nav_marks.rs:418-421`) — so the only control is the **Street furniture** checkbox at `crates/cartopolis/src/systems/gui/data_layers.rs:2388-2392`. ## Approach Three parts, all in `cartopolis` except one small label table in `cartopolis_geo`. ### A. Carry the register's words through to the client `crates/cartopolis/src/systems/map/nav_marks.rs`: - `read_feature` additionally reads `benaming`, `vaarwater` and `naut_funct` through the existing `prop()` helper (`nav_marks.rs:311-313`), each filtered through `is_absent` (`nav_marks.rs:91-93` — the register writes absence three ways). - Replace `MarkCell::marks`' tuple with a named struct (`lat`, `lng`, `form`, plus the three `Option<String>`s). `parse_items` and `cell_marks` change signature with it; both are only used inside this module and its tests (verified by grep — the only other `nav_marks` references are `map_geometry`, `world_places`, `shot_harness` and `mod.rs`). - `WireMark` gains the three fields under `#[serde(default)]`, and **`cache_key`'s `v1` becomes `v2`** (`nav_marks.rs:296`). Without the bump, every cell already in `storage::Namespace::Cache` decodes with empty names and is never re-fetched (`load_marks_cell` returns the cached cell unconditionally, `nav_marks.rs:413-415`) — permanently nameless marks in exactly the cells a user has already visited. Same rule the `KINDS` table states for itself at `nav_marks.rs:97-99`. `crates/geo/src/nav_marks.rs` gains one pure function: an English label per `MarkKind` ("spar buoy", "can buoy", "beacon", "groyne mark", …), in the style of `world_places::prop_name` (`world_places.rs:175-200`). Nothing else in the geo crate changes — `NavMark` and `MarkForm` stay string-free, so the mesh path is untouched. ### B. A mark becomes something you can walk up to and ask about **On the prompt, not the HUD line.** The issue proposes the `nearest_berth` shape, i.e. `InteractState::address`, which the HUD draws as `place` (`crates/cartopolis/src/systems/gui/hud.rs:1029`, field documented at `hud.rs:64-72`). That surface cannot carry this: it is one truncated line whose row count the HUD's own size test bounds (`hud.rs:1276-1279`), and it is fed by a chain in which the address wins and the berth is only the fallback (`interact.rs:395-399`). On the Waalkade at Nijmegen a quay building is inside `ADDRESS_RADIUS`, so a mark line placed in that chain would be shadowed in the exact spot the QA pass stood. The prompt is a separate world-anchored `Area` (`interact.rs:12-15`, registered at `lib.rs:2732`), it already has "Look at" as its verb for a named point (`world_places.rs:143-147`), and `TargetInfo` already carries `pos` so the route-to affordance works. `crates/cartopolis/src/systems/map/world_places.rs`: - `CellPlaces` gains `marks: Vec<…>` (lat/lng already projected to tile-local by the worker, same as everything else arriving here — see the module note at `world_places.rs:12-15`). `map_geometry.rs:698-704` fills it beside the existing `surveyed.5` count. - `InteractKind` gains a `Mark` variant; `glyph()` returns an icon **already in the bundled subset** (`icons::FLAG`, `crates/cartopolis/src/systems/gui/icons.rs:164`) — there is no buoy or anchor glyph in it, and a glyph outside the subset renders as a tofu box (CLAUDE.md, `docs/notes/icon-font.md`). `verb()` returns `"Look at"`. `Interactable::seat_spot` gains the `Mark` arm returning `None` (`world_places.rs:87-90`). - `Interactable` gains `detail: Option<String>` and `reach: f32`, both set at insertion. `detail` for a POI is `kind.detail().map(str::to_owned)`; for a prop `None`; for a mark the composed string below. `reach` is `INTERACT_RADIUS` for props and POIs, `MARK_RADIUS` for marks. - `WorldPlaces::nearest(pos, radius)` keeps `radius` as a ceiling and additionally filters each candidate against its own `reach` (`world_places.rs:398-407`). `crates/cartopolis/src/systems/player/interact.rs`: - New `MARK_RADIUS: f32 = 35.0`, the same number and the same reason as `ADDRESS_RADIUS` (`interact.rs:32-34`): the point is not where you can stand. A mark stands in the fairway and the avatar walks on the bank, so at the 3.5 m `INTERACT_RADIUS` (`interact.rs:26-29`) the only way to reach one is to fly over the water. Per-object reach is already the pattern the prim path uses (`interact.rs:360-362`). `places.nearest` is called with `MARK_RADIUS` at `interact.rs:284`; nearest still wins, so a bench at 2 m beats a buoy at 20 m. - `TargetInfo::detail` becomes `Option<String>`; `line()` (`interact.rs:118-123`) and the change-detection compare (`interact.rs:285-291`) follow. Keep the compare-before-build rule the comment at `interact.rs:257-262` states — compare the strings, do not build a `TargetInfo` per frame. - Pressing E does what a POI does: `Emote::Interact` and the receipt line (`interact.rs:196-203`). No seat, no `TouchPrim`. **The composed line.** Name is `benaming` where the register gives one, else the form's English label. Detail is `naut_funct` verbatim when present (fixed marks only), else the form's English label; then ` · ` and `vaarwater` when present. Dutch verbatim, with ` · ` as the separator, follows the two precedents already on this line: `monument_category` is kept "in its own language" (`interact.rs:459-462`) and `bag_address` joins with ` · ` (`interact.rs:403`). Result: `Look at 884.120R (Kribbaken · BOVEN-RIJN EN WAAL)`, `Look at W 12D (spar buoy · BOVEN-RIJN EN WAAL)`. ### C. Its own row in the Layers panel A **sub-row of Street furniture**, not a sibling. Marks are built into `SurfaceGroup::Furniture`, and that group's entities carry `FurnitureSurface` plus `furniture_lod.visibility()` (`map_geometry.rs:1182-1187`), which hides the whole group at once — so a marks row independent of Furniture would build meshes and then hide them. Nesting it is also the honest shape and has a precedent: 3DBAG sits under Buildings via `add_enabled_ui(dl.buildings_enabled, …)` (`data_layers.rs:2258`). - `DataLayers` gains `nav_marks_enabled` (`data_layers.rs:499` neighbourhood, plus the `Default`, `apply`/save and `fresh` paths at `:758`, `:819`, `:1709`, `:2559`). - `LayerPrefs` gains `nav_marks: bool` with `#[serde(default = "detail_default")]` (`crates/cartopolis/src/systems/net/user_store.rs:229` — an absent key is "not stated", not "off"). - `DetailWants` gains `marks: bool` (`map_geometry.rs:390-401`); the fetch gate at `map_geometry.rs:474` and the build at `:698-704` become `wants.furniture && wants.marks`; `wants` is assembled at `map_geometry.rs:950-957`. Toggling costs a ring retear, which is what every flag in that struct already costs (`map_geometry.rs:941-950`). - The row is drawn indented under **Street furniture** (`data_layers.rs:2388-2392`), labelled "Navigation marks", with the same coverage honesty the Aerial row carries ("PDOK imagery — Netherlands only", `data_layers.rs:~2425`) — the register is Dutch waterways only. ### D. One new metric `ShotMetrics` gains `nav_marks_named: usize` — marks in the loaded cells carrying a `benaming` — beside the existing `nav_marks` (`crates/cartopolis/src/systems/dev/shot_harness.rs:633-640`), sourced from `WorldPlaces` the same way. It is the pair `lod22_dated`/`lod22_elements` already models (`shot_harness.rs:641-646`): `nav_marks > 0` with `nav_marks_named == 0` means the marks arrived and the words did not. Add it to the CSV header at `shot_harness.rs:835` and to the zeroed default at `:3221`. ## Acceptance criteria - [ ] `read_feature` reads `benaming`, `vaarwater` and `naut_funct`, each treating `""`, `X`, `#` and `Niet toegewezen` as absent via `is_absent` (`nav_marks.rs:91-93`). - [ ] `cache_key` is bumped from `v1` to `v2` (`nav_marks.rs:296`). - [ ] `cartopolis_geo::nav_marks` still exposes no `String` on `NavMark` or `MarkForm`; the only addition there is a pure `MarkKind` → `&'static str` label. - [ ] A mark in a loaded cell is returned by `WorldPlaces::nearest` from up to `MARK_RADIUS` (35 m) and not beyond; props and POIs keep `INTERACT_RADIUS` (3.5 m). - [ ] The prompt for a fixed mark reads `Look at <benaming> (<naut_funct> · <vaarwater>)`, and for a floating one `Look at <benaming> (<form label> · <vaarwater>)`, with each parenthesised part omitted when the register gives nothing. - [ ] A mark with no `benaming` is named by its form label rather than left blank or dropped. - [ ] Pressing E on a mark plays `Emote::Interact` and leaves the receipt line; it does not seat the avatar and sends no `TouchPrim`. - [ ] `InteractKind::Mark` returns a glyph that is present in `icons::ALL` (the existing uniqueness/coverage tests at `icons.rs:425-435` must still pass). - [ ] The Layers panel has a **Navigation marks** row indented under Street furniture, disabled when Street furniture is off, and it says the register is Netherlands-only. - [ ] `DetailWants` gains `marks`, and the flag-by-flag retear test at `map_geometry.rs:2011-2039` covers it (that test enumerates the fields by hand and will not catch a new one on its own). - [ ] `LayerPrefs::nav_marks` uses `#[serde(default = "detail_default")]`, so a `user.json` written before this change reads as the tier default rather than as off. - [ ] `ShotMetrics::nav_marks_named` exists, appears in the `--dump-state` JSON and in the `--csv` header at `shot_harness.rs:835`, and is zero in the default at `:3221`. - [ ] Unit tests, all runnable without a GPU: - `parse_items` over the committed real captures `crates/cartopolis/tests/fixtures/nav_marks_{floating,fixed}.json` (`nav_marks.rs:493-494`) recovers the three new fields for a named feature — this is what pins the property keys against the live register. - `encode_cell` → `decode_cell` round-trips the three fields. - The composed line, both collections, including the every-field-absent fallback. - `WorldPlaces::insert_cell` places a mark at the cell origin offset and `nearest` finds it at 30 m. - [ ] `docs/notes/navigation-marks.md` gains what the client now carries and why the readout is on the prompt rather than the HUD line; its "What is deliberately not drawn" list keeps the pass-side rule as out of scope. ## Verification Runnable here (read-only pass did not run them): ```bash cargo test -p cartopolis nav_marks cargo test -p cartopolis world_places cargo test -p cartopolis interact cargo test -p cartopolis map_geometry cargo check -p cartopolis_geo cargo fmt --check ``` Note the container needs the ALSA headers extracted before `cartopolis` builds at all (`alsa-is-missing-in-this-container`); `--no-default-features` is not a workaround, it drops the `audio` feature and fails elsewhere. Headless capture — **runs in this container** (lavapipe is installed; the CLAUDE.md "no Vulkan driver" line is stale), but was **not** run by this pass, which is read-only: ```bash # a Waal cell with marks in it; --shot-mode avatar puts the avatar at the anchor cargo run -p cartopolis -- --shot /tmp/marks.png --shot-mode avatar \ --at 51.8500,5.8600,12 --look=-10,0 \ --dump-state /tmp/marks.json --expect 'nav_marks>0' --expect 'nav_marks_named>0' ``` The count assertions are honest evidence on a software rasteriser. Two things are **not** verifiable here and belong on a workstation or a phone: - Whether the prompt is legible and whether 35 m is the right reach — a buoy at 35 m across water may read as a prompt for nothing. This is the one number in the spec chosen by precedent rather than by measurement, and it is the thing to look at first. - Whether `icons::FLAG` reads as a navigation mark next to the bench and café glyphs. Also unmeasured, and worth a number before merge: the cache size of a dense cell after the strings are added. The note records the Waal cell at 57 KB on the wire and 4 KB cached (`docs/notes/navigation-marks.md:44-52`); `benaming` + `vaarwater` + `naut_funct` across 43 marks is the delta. Read it off the `features/marks/v2/14/8458/5422.json` entry after one run over that cell. ## Out of scope - **What a mark *means* — "port-hand mark, keep it to your left".** The issue's motivating quote points at it, but its own "what a user would expect instead" asks for name, function and fairway, which is what this spec delivers. Deriving the pass-side from colour pattern plus topmark is domain work with a trap in it: Dutch inland waterways run under the BPR/CEVNI rules, not IALA maritime, and a rule that is right for one and wrong for the other tells the player the opposite of the truth about which side to pass. Its own ticket, with a source cited. - **The light's character and period** (`sign_kar` / `sign_perio`). Already excluded by the layer's note (`docs/notes/navigation-marks.md:133-138`) and unchanged here — a flashing lantern is a second material. - **Clicking a mark.** Marks are merged into the cell's Furniture mesh, so a raycast hits the batch, not the object; `picking::prim_picking` stays prim-only. Proximity is the whole surface. - **Colliders.** Still none, deliberately (`docs/notes/navigation-marks.md:151-154`) — an invisible wall in a fairway is worse than a buoy you can walk through. - **A dedicated buoy glyph.** Adding one means regenerating the subset font (`tools/subset-icon-font.py`, `docs/notes/icon-font.md`) and touching an LFS-tracked `include_bytes!` asset. Reusing a subset glyph is the safe move; a proper glyph is a font change on its own. - **A HUD readout line.** Explained above — it would be shadowed by the address wherever there is a quay, and the HUD's size test bounds the row count. - **A cartopy extractor for the register.** Already argued down in the note (`docs/notes/navigation-marks.md:117-125`); nothing here changes that. ## Open questions None. --- Branch: `feat/214-nav-marks-readout` <details><summary>Original request</summary> Found by the QA pass on #210, walking what #206/#207 shipped as a player would meet it. ## What I did Flew to the Waal at Nijmegen and looked at the marks the layer draws (headless capture, settled, `nav_marks=115`). They are there and they look right — yellow cans in the fairway, a beacon on the far bank, everything standing at the waterline. Then I did the thing anyone does next with a thing on a map that obviously means something: tried to find out what it is. ## What happened Nothing. There is no way to ask. - **Nothing to click.** A mark is not a prim and not a `FurnitureInstance`, so `picking::prim_picking` never sees it. - **Nothing to walk up to.** `systems::interact` resolves streamed places, addresses, houseboat berths and scripted prims out of `WorldPlaces`; a mark never enters `WorldPlaces` — the only thing #206 put there is a counter (`CellPlaces::surveyed.5`). - **Nothing in the Layers panel.** The marks ride the Furniture group, so the only row that touches them is **Street furniture** — which no one would look under for a buoy. (It is on by default on Android, which takes `Budget::balanced()`; it is off on the `phone` rung, i.e. Settings ▸ Map ▸ Battery saver or `CARTO_QUALITY=phone`, where `detail_layers` is false.) - **Nothing in the map at all** names them. There is no label, no pin, no readout line. Grepping bears it out: outside its own two modules, `nav_marks` is referenced only by `map_geometry` (to build the mesh), `world_places` (the counter) and `shot_harness` (the metric). ## Why that is a wall rather than a missing nicety A navigation mark is the one thing on this map whose *entire purpose* is to tell you something. The layer's own note is explicit about it: > A navigation mark is the one form in the group where the colour is the datum: a red can and a green cone mean opposite things and differ in nothing else at 40 m. The client went to the trouble of drawing shape, band pattern, topmark and lantern colour correctly — and then a player looking at a red can with a cylinder on top has no way to be told "port-hand mark, keep it to your left", or even what it is called. The register already answers all of it and the answers are dropped at parse time: | field | example, live | |---|---| | `benaming` | `W 12D` (floating), `884.120R` (fixed) | | `vaarwater` | `BOVEN-RIJN EN WAAL` | | `naut_funct` | `Kribbaken`, `Havenlicht` | | `sign_kar` / `sign_perio` | `Iso (isophased)` / `4` | `WireMark`'s doc comment says so plainly: "the light's character and period, the fairway name … is dropped at parse time rather than carried through the cache." That was the right call for the cache; it also means nothing downstream *can* name a mark. ## What a user would expect instead The same thing this client already does for every other point register it streams. Walk within a few metres of a mark and get a line: name, what it is, which fairway. That is exactly `interact.rs`'s `nearest_berth` — a lat/lng point register, held outside `WorldPlaces`, matched by distance and turned into one readout line — and a mark would need no more machinery than a berth does. A cheaper half-step, if the readout is too much: give the marks their own row in the Layers panel under Map, so at least the layer is findable and can be switched on without knowing it lives under Street furniture. ## Where the seam is - `crates/cartopolis/src/systems/player/interact.rs` — `nearest_berth` is the precedent; there is no `nearest_mark` - `crates/cartopolis/src/systems/map/nav_marks.rs` — `WireMark` is where `benaming` / `vaarwater` / `naut_funct` are dropped - `crates/cartopolis/src/systems/map/world_places.rs` — marks reach it as a count only - `crates/cartopolis/src/systems/gui/data_layers.rs` — the Furniture row, ~line 2391 <sub>Filed by the QA pass on #210. Not `autonomous` — a person decides whether this becomes work.</sub> </details> <sub>🤖 Refined by the viberfox issue agent. Reply with **@agent refine** and what is wrong to have this rewritten.</sub>
Author
Collaborator

🤖 Promoted into the build lane by the 3-day retrospective (#218) — autonomous + ship.

Why this one:

  • It is the gap with the most value behind it. #206 shipped 18,474 surveyed objects whose entire purpose is to tell a person something, and then left no way to ask. A layer that is drawn but cannot be queried is not wired, it is decorated — and the register already answers benaming, vaarwater, naut_funct and the light's character.
  • No taste is being exercised, because the precedent is in the tree. interact.rs's nearest_berth (line 475) is the same shape: a lat/lng point register held outside WorldPlaces, matched by distance, turned into one readout line. nearest_mark copies it. The shape of the panel — which the fences do put out of bounds — is inherited, not invented.
  • A machine can judge it. nearest_mark is a pure distance match over a known cell and is unit-testable directly; add a count to ShotMetrics the way every other layer here did, and --expect gates it.
  • Nothing here is fenced. The fields are added to WireMark, the on-disk cache format under cache/features/marks/v1/ — not NetMessage, no PROTOCOL_HISTORY row, no migration. Bump the v1 path segment so an existing cache is not read with the old shape.

Scope it to the readout. The ticket's cheaper half-step — a Layers-panel row of its own — is a judgement about where a row belongs and is left to the maintainer; build the thing that answers the question, not the thing that relocates a checkbox.

The two gaps left unpromoted are #212 and #215; the retrospective's reasoning for leaving them is on #218.

🤖 **Promoted into the build lane** by the 3-day retrospective (#218) — `autonomous` + `ship`. Why this one: - **It is the gap with the most value behind it.** #206 shipped 18,474 surveyed objects whose entire purpose is to tell a person something, and then left no way to ask. A layer that is drawn but cannot be queried is not wired, it is decorated — and the register already answers `benaming`, `vaarwater`, `naut_funct` and the light's character. - **No taste is being exercised, because the precedent is in the tree.** `interact.rs`'s `nearest_berth` (line 475) is the same shape: a lat/lng point register held outside `WorldPlaces`, matched by distance, turned into one readout line. `nearest_mark` copies it. The shape of the panel — which the fences do put out of bounds — is inherited, not invented. - **A machine can judge it.** `nearest_mark` is a pure distance match over a known cell and is unit-testable directly; add a count to `ShotMetrics` the way every other layer here did, and `--expect` gates it. - **Nothing here is fenced.** The fields are added to `WireMark`, the on-disk cache format under `cache/features/marks/v1/` — not `NetMessage`, no `PROTOCOL_HISTORY` row, no migration. Bump the `v1` path segment so an existing cache is not read with the old shape. Scope it to the readout. The ticket's cheaper half-step — a Layers-panel row of its own — is a judgement about where a row belongs and is left to the maintainer; build the thing that answers the question, not the thing that relocates a checkbox. The two gaps left unpromoted are #212 and #215; the retrospective's reasoning for leaving them is on #218.
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 — 31 turns · $3.33 · session d78dd775-da4a-4268-822c-242a3754c2fd. 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 — 31 turns · $3.33 · session `d78dd775-da4a-4268-822c-242a3754c2fd`. Reply with **@agent** to continue this session.</sub>
Author
Collaborator

🤖 Picked this up — working on branch feat/214-nav-marks-readout 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/214-nav-marks-readout` 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

You can now walk up to a buoy and be told what it is. Pushed as 6a77c90 on feat/214-nav-marks-readout.

What changed

  • The register's words now reach the client. The parser reads three more properties off each mark — benaming (its name), vaarwater (the fairway) and naut_funct (what a fixed mark is) — and carries them through the per-cell cache instead of discarding them. Each goes through the same "nothing here" filter as the rest, because X read as a name would put Look at X on the water.
  • The cache path went from v1 to v2. A cached cell is returned without re-checking, so without this every cell someone had already visited would keep decoding with empty names forever.
  • A mark is now something the avatar can walk up to. The prompt reads Look at 884.120R (Kribbaken · BOVEN-RIJN EN WAAL). Pressing E plays the ordinary interact gesture and leaves the receipt line — no sitting, and nothing sent to the server. A mark with no registered name is called by its shape ("spar buoy").
  • The readout is on the prompt, not the heads-up display's address line. That line is one truncated row, and the building address always wins it — on a quay the mark line would never appear.
  • Reach is 35 metres for marks only, against 3.5 metres for everything else. A buoy stands in the fairway and the avatar walks the bank. The wide reach belongs to the object, so benches did not widen with it, and the nearest thing still wins.
  • The Layers panel has a "Navigation marks" row, indented under Street furniture and greyed out with it, saying the register covers the Netherlands only.
  • New metric nav_marks_named, in the state dump and the CSV. nav_marks above zero with nav_marks_named at zero means the marks arrived and the words did not.

How I verified it

cargo test -p cartopolis_geo — 224 tests pass. cargo test -p cartopolis — 812 tests pass, 0 failures. cargo fmt --check clean. New tests cover the three fields read off the two committed captures of real service responses, the cache round trip, the composed line in both collections including the everything-absent fallback, the 35-metre reach and that a bench does not inherit it, and that the icon is one the bundled font actually contains.

Not verified

I did not run a headless capture, and two things need an eye on a real screen:

  • Whether 35 metres is right. It is the one number here I chose by copying the address readout rather than measuring. A buoy that far out across water may read as a prompt for nothing in particular. This is the thing to look at first.
  • Whether the flag icon reads as a navigation mark beside the bench and café icons. The bundled icon set has no buoy and no anchor; adding one means regenerating the font, which is a separate change.

Also unmeasured: the cache size of a dense cell now that the strings are in it. That needs a live fetch of the Nijmegen cell, which I did not do.

No commits were made.


🤖 viberfox issue agent on the VPS — 332 turns · $48.51 · session e0b46552-b898-4146-a9b3-801fa98f9d54. Reply with @agent to continue this session.

You can now walk up to a buoy and be told what it is. Pushed as `6a77c90` on `feat/214-nav-marks-readout`. ## What changed - **The register's words now reach the client.** The parser reads three more properties off each mark — `benaming` (its name), `vaarwater` (the fairway) and `naut_funct` (what a fixed mark is) — and carries them through the per-cell cache instead of discarding them. Each goes through the same "nothing here" filter as the rest, because `X` read as a name would put *Look at X* on the water. - **The cache path went from `v1` to `v2`.** A cached cell is returned without re-checking, so without this every cell someone had already visited would keep decoding with empty names forever. - **A mark is now something the avatar can walk up to.** The prompt reads `Look at 884.120R (Kribbaken · BOVEN-RIJN EN WAAL)`. Pressing E plays the ordinary interact gesture and leaves the receipt line — no sitting, and nothing sent to the server. A mark with no registered name is called by its shape ("spar buoy"). - **The readout is on the prompt, not the heads-up display's address line.** That line is one truncated row, and the building address always wins it — on a quay the mark line would never appear. - **Reach is 35 metres for marks only**, against 3.5 metres for everything else. A buoy stands in the fairway and the avatar walks the bank. The wide reach belongs to the object, so benches did not widen with it, and the nearest thing still wins. - **The Layers panel has a "Navigation marks" row**, indented under Street furniture and greyed out with it, saying the register covers the Netherlands only. - **New metric `nav_marks_named`**, in the state dump and the CSV. `nav_marks` above zero with `nav_marks_named` at zero means the marks arrived and the words did not. ## How I verified it `cargo test -p cartopolis_geo` — 224 tests pass. `cargo test -p cartopolis` — 812 tests pass, 0 failures. `cargo fmt --check` clean. New tests cover the three fields read off the two committed captures of real service responses, the cache round trip, the composed line in both collections including the everything-absent fallback, the 35-metre reach and that a bench does not inherit it, and that the icon is one the bundled font actually contains. ## Not verified I did not run a headless capture, and two things need an eye on a real screen: - **Whether 35 metres is right.** It is the one number here I chose by copying the address readout rather than measuring. A buoy that far out across water may read as a prompt for nothing in particular. This is the thing to look at first. - **Whether the flag icon reads as a navigation mark** beside the bench and café icons. The bundled icon set has no buoy and no anchor; adding one means regenerating the font, which is a separate change. Also unmeasured: the cache size of a dense cell now that the strings are in it. That needs a live fetch of the Nijmegen cell, which I did not do. _No commits were made._ --- <sub>🤖 viberfox issue agent on the VPS — 332 turns · $48.51 · session `e0b46552-b898-4146-a9b3-801fa98f9d54`. Reply with **@agent** to continue this session.</sub>
Author
Collaborator

🤖 Not shipping this one — the branch conflicts with main (another ticket landed first).

Rebase feat/214-nav-marks-readout onto main, resolve, push, and put the ship label back to land it.

🤖 **Not shipping this one — the branch conflicts with `main`** (another ticket landed first). Rebase `feat/214-nav-marks-readout` onto `main`, resolve, push, and put the `ship` label back to land it.
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#214
No description provided.