gap: with the height layer on, every floating navigation mark sinks #268

Closed
opened 2026-09-02 02:42:17 +00:00 by viberfox-agent · 7 comments
Collaborator

Problem

A buoy is modelled so that y = 0 is the water surface: NavMark's frame is documented as "origin at the cell's north-west corner, +x east, +z south, water surface at y = 0" (crates/geo/src/nav_marks.rs:326), and mod form states the rule the shapes depend on — "A floating body straddles y = 0 because that is where the water surface is" (crates/geo/src/nav_marks.rs:353). A can runs −0.45 … 1.15 m, a pillar −0.55 … 2.60 m (crates/geo/src/nav_marks.rs:363-370). Even the fixed forms are anchored to the same line — the groyne mark's "foot is the waterline" (crates/geo/src/nav_marks.rs:372). The moored boats make the identical assumption: "The hull straddles y = 0 because that is where the water surface is … It is the one form here with anything below the ground plane, and it is deliberate" (crates/geo/src/furniture.rs:865, hull part([0, 0.10, 0], [1.20, 0.30, 4.50]) at :868).

With the height layer off that assumption holds, because everything sits at 0. With it on, two different rules place the water and the things floating in it, and they do not agree:

  • Water takes one height for the whole cell, the median of the terrain field under it: stand_on_terrain's Water | BridgeDeck arm calls TerrainField::level_over (crates/cartopolis/src/systems/map/map_geometry.rs:1896, crates/cartopolis/src/systems/map/terrain.rs:218).
  • Nav marks and boats are SurfaceGroup::Furniture (crates/geo/src/nav_marks.rs:418, crates/cartopolis/src/systems/map/map_geometry.rs:699 and :722), which is in the volumetric set (crates/cartopolis/src/systems/map/map_geometry.rs:1889), so every vertex takes TerrainField::at at its own position (:1915, crates/cartopolis/src/systems/map/terrain.rs:178).

Over open water those two numbers are unrelated. AHN's DTM has no data over a river, and the hole is closed by dilating the banks inward — fill_holes, six passes at 25 m per sample, so ~150 m of reach from each side (crates/cartopolis/src/systems/map/terrain.rs:380 and :84). The water's median is taken over the polygon's own boundary vertices, i.e. the bank; the mark's at() is whatever the fill invented mid-stream. docs/notes/terrain-relief.md:138-148 and docs/notes/rijkswaterstaat-water-levels.md both already record that the water level here "is the quay, not the water" — which is fine as long as everything floating on it takes the same wrong number, and today nothing does.

The issue's A/B over the Waal at Nijmegen measured the consequence: the same buoy from the same camera shows 25 px of body with the height layer off and 11 px with it on, at identical width — a sink of most of its freeboard. The direction depends on what the hole was filled from, so elsewhere it will lift instead.

I did not run anything: this pass is read-only, so the mechanism above is read off the code and the two notes, and the pixel measurements are the issue author's.

Approach

Make one number per streamed cell mean "the water surface", and give it to the water and to everything that floats in it. This removes the disagreement rather than tuning either side of it.

1. Mark the meshes whose y = 0 is the waterline — crates/geo/src/vector_tiles.rs

Add one field to SurfaceMesh (crates/geo/src/vector_tiles.rs:1570), e.g. pub afloat: bool, defaulting false, plus a #[must_use] pub fn afloat(self) -> Self builder. Document it as "this mesh's y = 0 is the water surface, not the ground". Construction sites to update: vector_tiles.rs:1697, :1716, :2688, :2718, :2738; crates/geo/src/furniture.rs:1125 (MeshBuf::finish); crates/geo/src/scatter.rs:815; crates/geo/src/flat_fill.rs:135; crates/cartopolis/src/systems/map/map_geometry.rs:1623.

A flag rather than a new SurfaceGroup::Floating variant, deliberately: a new group would need its own base_color, perceptual_roughness, depth_bias and y_offset, a second material and draw call per cell, and it would break the property nav_marks.rs:418 is built on — marks inherit the furniture group's shared material, its altitude fade and its Layers-panel toggle "without a line of client code for any of the three". Nothing downstream of build_mesh needs to know.

2. Set the flag at the two places that produce floating geometry

  • crates/geo/src/nav_marks.rs: build_mark_meshes marks every mesh it returns. All eight forms are waterline-referenced, floating and fixed alike (:363-378).
  • crates/cartopolis/src/systems/map/map_geometry.rs:699-714: the mooring meshes are marked at the call site. The tag cannot go inside furniture::build_furniture_meshes, which also serves the benches (:666) and the parked cars (:686) — those stand on the ground.

Level crossings (map_geometry.rs:740) and everything else stay unflagged.

3. Hoist the water level to the cell, and hand it to both — map_geometry::update_map_surfaces

Before the spawn loop at crates/cartopolis/src/systems/map/map_geometry.rs:1164, and only when terrain.is_ready(), compute the cell's water level once: terrain.level_over over the positions of every SurfaceGroup::Water mesh in surface_meshes, offset by the cell origin exactly as stand_on_terrain does today.

Then pass it as build_mesh's existing lift argument (crates/cartopolis/src/systems/map/map_geometry.rs:1750, currently None at :1166) for both the Water meshes and every afloat mesh. lift is the right mechanism and needs no new plumbing: it adds a constant y and skips the terrain path, and water already skips refine_for_terrain (stand_on_terrain returns before it at :1902), so for water this is exactly equivalent to today.

Two consequences to get right:

  • A cell with no water mesh keeps today's behaviour for its afloat meshes: lift = None, per-vertex at(). That is the honest fallback — there is no water in this cell to level against — and it preserves the current picture rather than inventing one.
  • A cell whose water was byte-split into several meshes (vector_tiles.rs:1695) currently gets a different median per piece. Computing over all of them gives one level for the cell, which is what docs/notes/terrain-relief.md:138 already describes as the intent ("every water polygon in a tile is merged into one SurfaceMesh before the levelling sees it") and removes a latent seam. In the ordinary one-mesh case the number is bit-identical to today's.

After this, stand_on_terrain's Water | BridgeDeck arm is reached only by the deck path (map_geometry.rs:1392 passes None). Narrow it to BridgeDeck and update the doc comment above stand_on_terrain (:1860-1876) so it describes the three rules that actually exist: a bridge deck is straight, a floating thing takes the water's level, everything else follows the ground per vertex.

4. One assertable fact — crates/cartopolis/src/systems/dev/shot_harness.rs

Add a ShotMetrics field counting the afloat meshes in loaded cells that found no water level in their own cell and fell back to the ground — e.g. floating_off_water: usize. That is the one thing this change can get silently wrong, it is a count so --expect '…==0' is exact, and colour is not evidence on a software rasteriser but a count is. Wire it into the three sinks the way nav_marks is (shot_harness.rs:640, :846, :2664, :2780), and into the Default used by the tests at :3234.

5. Notes

Edit in place: docs/notes/terrain-relief.md:136-155 (the "not everything is refined" list gains the floating rule), docs/notes/navigation-marks.md and docs/notes/canal-moorings.md:55-61 (the "straddles the waterline" sections now say which level that is when the height layer is on). Cross-reference docs/notes/rijkswaterstaat-water-levels.md, which already records why the level is the quay — that stays true and is unchanged by this.

Acceptance criteria

  • SurfaceMesh carries a documented flag meaning "y = 0 is the water surface", false at every existing construction site.
  • build_mark_meshes sets it; the mooring call in map_geometry sets it; the bench, parked-car, level-crossing, vegetation, paving, crop and ground paths do not.
  • With the height layer on, a floating mesh and the water meshes of the same streamed cell are placed at the same y, computed once per cell from TerrainField::level_over over all that cell's water positions.
  • With no water mesh in the cell, floating meshes keep the current per-vertex at() placement — no behaviour change, no crash, no zero-level fallback.
  • With the height layer off (TerrainField::is_ready() == false), every position in the cell is byte-identical to today.
  • stand_on_terrain's level arm is narrowed to BridgeDeck, and its doc comment describes the rule set that now exists.
  • A unit test in map_geometry's test module builds a synthetic TerrainField whose median over a water polygon differs from at() at the polygon's interior (a linear ramp will not do — the median of a ramp equals its centre; use a valley or a filled hole), a water SurfaceMesh and an afloat mesh over it, and asserts the two land on the same y. TerrainSnapshot's fields are private (terrain.rs:97-103), so this needs a pub(crate) test constructor in terrain.rs; have the existing ramp_field helper (terrain.rs:687) use it too rather than adding a second way to build one.
  • A test pins that an unflagged Furniture mesh in a water-bearing cell — a bench on the quay — is not levelled onto the water.
  • ShotMetrics gains the fallback count, present in the log line, the CSV header and --dump-state, with the Default in the harness tests updated.
  • docs/notes/terrain-relief.md, docs/notes/navigation-marks.md and docs/notes/canal-moorings.md are edited in place to state the rule.
  • The commit body says why, per CLAUDE.md's rule for a fix.

Verification

Cannot be run in this pass — this is read-only and does not build. On a machine that can build:

cargo test -p cartopolis_geo
cargo test -p cartopolis
cargo fmt --check -p cartopolis -p cartopolis_geo -p cartopolis_core -p cartopolis_simulator -p cartopolis_android

The A/B the issue was filed from, re-run on the fix — the same buoy on the Waal at Nijmegen, with and without --height, everything else pinned:

cargo run -p cartopolis -- --shot /tmp/off.png \
  --at 51.85140,5.86412,6 --look=-5,0 \
  --size 960x540 --settle 1.5 --wait 60 --time 12 --clouds 0 --rain 0 --hide-ui \
  --dump-state /tmp/off.json

cargo run -p cartopolis -- --shot /tmp/on.png --height \
  --at 51.85140,5.86412,6 --look=-5,0 \
  --size 960x540 --settle 1.5 --wait 60 --time 12 --clouds 0 --rain 0 --hide-ui \
  --dump-state /tmp/on.json --expect 'floating_off_water==0'

Both runs should report the same nav_marks; the buoy's visible height above the waterline should match between the two PNGs to within the terrain's own effect on the camera, where it was 25 px against 11 px before. Reading back those PNGs is reading the app's own output, not a screen capture.

Geometry, placement and visibility are trustworthy on a software rasteriser; colour, exposure and lighting are not (docs/notes/headless-shots-software-renderer.md), so judge this on the pixel heights and on floating_off_water, never on how the water looks.

Only a second waterway can answer the open half. The issue could not establish whether the disagreement is a sink everywhere or a sink here and a lift elsewhere. Repeat the A/B over a canal in a city centre — Groningen's Diepenring is the one docs/notes/rijkswaterstaat-water-levels.md already has bank measurements for — and check the moored boats there, which nobody has yet captured.

Out of scope

  • Putting water at its real measured level. Checked and rejected on 2026-08-30 with reasons that still hold (docs/notes/rijkswaterstaat-water-levels.md): the drop is metres, the coplanar stack holds 8 mm, the clipmap is opaque under it and there is no bank geometry. This ticket makes the buoy agree with the water that is drawn; it does not move the water.
  • Per-body water levels. A cell still gets one level, so a hillside pond and the canal below it share a height, and a river can step against its own continuation at a cell edge (docs/notes/terrain-relief.md:138-142). Marks inherit that; fixing it needs the tessellator to keep bodies apart.
  • The y_offset ladder. Water sits at −0.0035 m and Furniture at 0.0 (crates/geo/src/vector_tiles.rs:1298, :1320), so a mark's waterline lands 3.5 mm above the water sheet. That is the coplanar depth ladder working as designed — do not change it here.
  • Tree and canopy placement. Canopy and Trunk are in the same volumetric set and are also displaced per vertex across their own width. Whatever that is worth, it is a separate question about things on land.
  • Colliders and the interaction prompt. Marks have none by design and boats are deliberately not solid (crates/cartopolis/src/systems/map/map_geometry.rs:704-721); both are 2D anyway, so a height change does not reach them.
  • A new SurfaceGroup variant, a separate material, or a Layers toggle of its own for floating objects.

Open questions

None blocking.


Branch: fix/268-floating-marks-water-level

Original request

Found by the QA re-verification pass on #266, walking what #206/#207 shipped.

What I did

Two settled headless captures of the same buoy from the same camera, on the
Waal at Nijmegen. The only difference between them is --height, i.e. Map ▸
Height, the measured AHN relief layer.

cargo run -p cartopolis -- --shot /tmp/close.png \
  --at 51.85140,5.86412,6 --look=-5,0 \
  --size 960x540 --settle 1.5 --wait 60 --time 12 --clouds 0 --rain 0 --hide-ui \
  --dump-state /tmp/close.json           # + --height for the second

Both settle, both report nav_marks=115. The second reports terrain_ready=true,
terrain_relief_m=74.1, ground_m=8.08.

What happened

With the height layer on, the buoy is submerged to its shoulder.

Measured on the two PNGs — the red can's visible extent, windowed to the water so
the roofs on the far bank do not count:

run visible height visible red width
height layer off 25 px 470 px 24 px
height layer on 11 px 154 px 24 px

The width is identical, so this is not distance or scale. The far waterline is at
y ≈ 239–242 in both, so the camera has not moved either. What changed is where the
buoy sits relative to the water: off, it shows a can with freeboard and its lantern
on top; on, only the widest band of the can and the foot of the lantern are above
the surface. The yellow mark further out is reduced to a sliver the same way.

Why

map_geometry::stand_on_terrain lifts the two onto the measured ground by two
different rules
, and the rules disagree over a river:

  • SurfaceGroup::Water takes terrain.level_over(...) — deliberately one height
    for the whole body, because a lake is level.
  • SurfaceGroup::Furniture is in the volumetric set, so every vertex takes
    terrain.at(x, z) at its own position.

A navigation mark is Furniture. AHN's DTM has no ground under a 250 m-wide river,
so the hole is closed by fill_holes from the banks — and the value that lands
under a mid-river buoy is not the level the water body took. cartopolis_geo::nav_marks'
form table is explicit that the arithmetic depends on those agreeing:

A floating body straddles y = 0 because that is where the water surface is

That holds exactly while the height layer is off, which is its default.

What a user would expect instead

A buoy floats on the water whether or not Map ▸ Height is ticked. Today ticking it
sinks every floating mark in the country by most of its freeboard — and, since the
sign of the disagreement depends on what the DTM hole was filled from, it will
raise them somewhere else.

Where the seam is

  • crates/cartopolis/src/systems/map/map_geometry.rs — stand_on_terrain: the
    Water / BridgeDeck branch takes level_over, the volumetric set takes
    at per vertex
  • crates/geo/src/nav_marks.rs — mod form, and the NavMark doc comment
    ("water surface at y = 0")
  • crates/cartopolis/src/systems/map/terrain.rs — fill_holes, which is what
    supplies a height in the middle of a river at all

The same rule hits the moored boats

furniture::BOAT's hull is part([0, 0.10, 0], [1.20, 0.30, 4.50]), i.e. it spans
y = −0.20 … 0.40 and straddles the waterline for the same reason. It is Furniture
too, so it takes the same per-vertex lift. Derived from the code, not observed —
I did not capture a mooring — but if this is fixed it should be fixed for anything
that floats, not for marks alone.

What a person might decide

The shape of the fix is probably "a floating thing takes the water's level, not the
ground's": either a Floating surface group that stands on level_over like the
water it sits in, or a per-cell water level published alongside the field for
nav_marks and moorings to read at mesh time.

What I could not check

Whether the disagreement is a sink everywhere or a sink here and a lift elsewhere —
that needs the same A/B over a second waterway. Colour and lighting are not
evidence on this container's software renderer; the pixel heights above are
geometry, which is.

Filed by the QA pass on #266. 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 A buoy is modelled so that `y = 0` is the water surface: `NavMark`'s frame is documented as "origin at the cell's north-west corner, +x east, +z south, **water surface at y = 0**" (`crates/geo/src/nav_marks.rs:326`), and `mod form` states the rule the shapes depend on — "A floating body straddles y = 0 because that is where the water surface is" (`crates/geo/src/nav_marks.rs:353`). A can runs `−0.45 … 1.15` m, a pillar `−0.55 … 2.60` m (`crates/geo/src/nav_marks.rs:363-370`). Even the *fixed* forms are anchored to the same line — the groyne mark's "foot is the waterline" (`crates/geo/src/nav_marks.rs:372`). The moored boats make the identical assumption: "The hull straddles y = 0 because that is where the water surface is … It is the one form here with anything below the ground plane, and it is deliberate" (`crates/geo/src/furniture.rs:865`, hull `part([0, 0.10, 0], [1.20, 0.30, 4.50])` at `:868`). With the height layer off that assumption holds, because everything sits at 0. With it on, two different rules place the water and the things floating in it, and they do not agree: - Water takes **one height for the whole cell**, the median of the terrain field under it: `stand_on_terrain`'s `Water | BridgeDeck` arm calls `TerrainField::level_over` (`crates/cartopolis/src/systems/map/map_geometry.rs:1896`, `crates/cartopolis/src/systems/map/terrain.rs:218`). - Nav marks and boats are `SurfaceGroup::Furniture` (`crates/geo/src/nav_marks.rs:418`, `crates/cartopolis/src/systems/map/map_geometry.rs:699` and `:722`), which is in the `volumetric` set (`crates/cartopolis/src/systems/map/map_geometry.rs:1889`), so every vertex takes `TerrainField::at` at its own position (`:1915`, `crates/cartopolis/src/systems/map/terrain.rs:178`). Over open water those two numbers are unrelated. AHN's DTM has no data over a river, and the hole is closed by dilating the banks inward — `fill_holes`, six passes at 25 m per sample, so ~150 m of reach from each side (`crates/cartopolis/src/systems/map/terrain.rs:380` and `:84`). The water's median is taken over the polygon's own boundary vertices, i.e. the bank; the mark's `at()` is whatever the fill invented mid-stream. `docs/notes/terrain-relief.md:138-148` and `docs/notes/rijkswaterstaat-water-levels.md` both already record that the water level here "is the quay, not the water" — which is fine as long as everything floating on it takes the *same* wrong number, and today nothing does. The issue's A/B over the Waal at Nijmegen measured the consequence: the same buoy from the same camera shows 25 px of body with the height layer off and 11 px with it on, at identical width — a sink of most of its freeboard. The direction depends on what the hole was filled from, so elsewhere it will lift instead. I did not run anything: this pass is read-only, so the mechanism above is read off the code and the two notes, and the pixel measurements are the issue author's. ## Approach Make one number per streamed cell mean "the water surface", and give it to the water *and* to everything that floats in it. This removes the disagreement rather than tuning either side of it. **1. Mark the meshes whose `y = 0` is the waterline** — `crates/geo/src/vector_tiles.rs` Add one field to `SurfaceMesh` (`crates/geo/src/vector_tiles.rs:1570`), e.g. `pub afloat: bool`, defaulting `false`, plus a `#[must_use] pub fn afloat(self) -> Self` builder. Document it as "this mesh's `y = 0` is the water surface, not the ground". Construction sites to update: `vector_tiles.rs:1697`, `:1716`, `:2688`, `:2718`, `:2738`; `crates/geo/src/furniture.rs:1125` (`MeshBuf::finish`); `crates/geo/src/scatter.rs:815`; `crates/geo/src/flat_fill.rs:135`; `crates/cartopolis/src/systems/map/map_geometry.rs:1623`. A flag rather than a new `SurfaceGroup::Floating` variant, deliberately: a new group would need its own `base_color`, `perceptual_roughness`, `depth_bias` and `y_offset`, a second material and draw call per cell, and it would break the property `nav_marks.rs:418` is built on — marks inherit the furniture group's shared material, its altitude fade and its Layers-panel toggle "without a line of client code for any of the three". Nothing downstream of `build_mesh` needs to know. **2. Set the flag at the two places that produce floating geometry** - `crates/geo/src/nav_marks.rs`: `build_mark_meshes` marks every mesh it returns. All eight forms are waterline-referenced, floating and fixed alike (`:363-378`). - `crates/cartopolis/src/systems/map/map_geometry.rs:699-714`: the mooring meshes are marked at the call site. The tag cannot go inside `furniture::build_furniture_meshes`, which also serves the benches (`:666`) and the parked cars (`:686`) — those stand on the ground. Level crossings (`map_geometry.rs:740`) and everything else stay unflagged. **3. Hoist the water level to the cell, and hand it to both** — `map_geometry::update_map_surfaces` Before the spawn loop at `crates/cartopolis/src/systems/map/map_geometry.rs:1164`, and only when `terrain.is_ready()`, compute the cell's water level once: `terrain.level_over` over the positions of **every** `SurfaceGroup::Water` mesh in `surface_meshes`, offset by the cell origin exactly as `stand_on_terrain` does today. Then pass it as `build_mesh`'s existing `lift` argument (`crates/cartopolis/src/systems/map/map_geometry.rs:1750`, currently `None` at `:1166`) for both the `Water` meshes and every `afloat` mesh. `lift` is the right mechanism and needs no new plumbing: it adds a constant `y` and skips the terrain path, and water already skips `refine_for_terrain` (`stand_on_terrain` returns before it at `:1902`), so for water this is exactly equivalent to today. Two consequences to get right: - **A cell with no water mesh keeps today's behaviour** for its `afloat` meshes: `lift = None`, per-vertex `at()`. That is the honest fallback — there is no water in this cell to level against — and it preserves the current picture rather than inventing one. - **A cell whose water was byte-split** into several meshes (`vector_tiles.rs:1695`) currently gets a different median per piece. Computing over all of them gives one level for the cell, which is what `docs/notes/terrain-relief.md:138` already describes as the intent ("every water polygon in a tile is merged into one `SurfaceMesh` before the levelling sees it") and removes a latent seam. In the ordinary one-mesh case the number is bit-identical to today's. After this, `stand_on_terrain`'s `Water | BridgeDeck` arm is reached only by the deck path (`map_geometry.rs:1392` passes `None`). Narrow it to `BridgeDeck` and update the doc comment above `stand_on_terrain` (`:1860-1876`) so it describes the three rules that actually exist: a bridge deck is straight, a floating thing takes the water's level, everything else follows the ground per vertex. **4. One assertable fact** — `crates/cartopolis/src/systems/dev/shot_harness.rs` Add a `ShotMetrics` field counting the `afloat` meshes in loaded cells that found no water level in their own cell and fell back to the ground — e.g. `floating_off_water: usize`. That is the one thing this change can get silently wrong, it is a count so `--expect '…==0'` is exact, and colour is not evidence on a software rasteriser but a count is. Wire it into the three sinks the way `nav_marks` is (`shot_harness.rs:640`, `:846`, `:2664`, `:2780`), and into the `Default` used by the tests at `:3234`. **5. Notes** Edit in place: `docs/notes/terrain-relief.md:136-155` (the "not everything is refined" list gains the floating rule), `docs/notes/navigation-marks.md` and `docs/notes/canal-moorings.md:55-61` (the "straddles the waterline" sections now say which level that is when the height layer is on). Cross-reference `docs/notes/rijkswaterstaat-water-levels.md`, which already records why the level is the quay — that stays true and is unchanged by this. ## Acceptance criteria - [ ] `SurfaceMesh` carries a documented flag meaning "`y = 0` is the water surface", `false` at every existing construction site. - [ ] `build_mark_meshes` sets it; the mooring call in `map_geometry` sets it; the bench, parked-car, level-crossing, vegetation, paving, crop and ground paths do not. - [ ] With the height layer on, a floating mesh and the water meshes of the same streamed cell are placed at the same `y`, computed once per cell from `TerrainField::level_over` over all that cell's water positions. - [ ] With no water mesh in the cell, floating meshes keep the current per-vertex `at()` placement — no behaviour change, no crash, no zero-level fallback. - [ ] With the height layer off (`TerrainField::is_ready() == false`), every position in the cell is byte-identical to today. - [ ] `stand_on_terrain`'s level arm is narrowed to `BridgeDeck`, and its doc comment describes the rule set that now exists. - [ ] A unit test in `map_geometry`'s test module builds a synthetic `TerrainField` whose median over a water polygon differs from `at()` at the polygon's interior (a linear ramp will not do — the median of a ramp equals its centre; use a valley or a filled hole), a water `SurfaceMesh` and an `afloat` mesh over it, and asserts the two land on the same `y`. `TerrainSnapshot`'s fields are private (`terrain.rs:97-103`), so this needs a `pub(crate)` test constructor in `terrain.rs`; have the existing `ramp_field` helper (`terrain.rs:687`) use it too rather than adding a second way to build one. - [ ] A test pins that an unflagged `Furniture` mesh in a water-bearing cell — a bench on the quay — is *not* levelled onto the water. - [ ] `ShotMetrics` gains the fallback count, present in the log line, the CSV header and `--dump-state`, with the `Default` in the harness tests updated. - [ ] `docs/notes/terrain-relief.md`, `docs/notes/navigation-marks.md` and `docs/notes/canal-moorings.md` are edited in place to state the rule. - [ ] The commit body says why, per `CLAUDE.md`'s rule for a `fix`. ## Verification Cannot be run in this pass — this is read-only and does not build. On a machine that can build: ```bash cargo test -p cartopolis_geo cargo test -p cartopolis cargo fmt --check -p cartopolis -p cartopolis_geo -p cartopolis_core -p cartopolis_simulator -p cartopolis_android ``` The A/B the issue was filed from, re-run on the fix — the same buoy on the Waal at Nijmegen, with and without `--height`, everything else pinned: ```bash cargo run -p cartopolis -- --shot /tmp/off.png \ --at 51.85140,5.86412,6 --look=-5,0 \ --size 960x540 --settle 1.5 --wait 60 --time 12 --clouds 0 --rain 0 --hide-ui \ --dump-state /tmp/off.json cargo run -p cartopolis -- --shot /tmp/on.png --height \ --at 51.85140,5.86412,6 --look=-5,0 \ --size 960x540 --settle 1.5 --wait 60 --time 12 --clouds 0 --rain 0 --hide-ui \ --dump-state /tmp/on.json --expect 'floating_off_water==0' ``` Both runs should report the same `nav_marks`; the buoy's visible height above the waterline should match between the two PNGs to within the terrain's own effect on the camera, where it was 25 px against 11 px before. Reading back those PNGs is reading the app's own output, not a screen capture. Geometry, placement and visibility are trustworthy on a software rasteriser; colour, exposure and lighting are not (`docs/notes/headless-shots-software-renderer.md`), so judge this on the pixel heights and on `floating_off_water`, never on how the water looks. **Only a second waterway can answer the open half.** The issue could not establish whether the disagreement is a sink everywhere or a sink here and a lift elsewhere. Repeat the A/B over a canal in a city centre — Groningen's Diepenring is the one `docs/notes/rijkswaterstaat-water-levels.md` already has bank measurements for — and check the moored boats there, which nobody has yet captured. ## Out of scope - **Putting water at its real measured level.** Checked and rejected on 2026-08-30 with reasons that still hold (`docs/notes/rijkswaterstaat-water-levels.md`): the drop is metres, the coplanar stack holds 8 mm, the clipmap is opaque under it and there is no bank geometry. This ticket makes the buoy agree with the water that is drawn; it does not move the water. - **Per-body water levels.** A cell still gets one level, so a hillside pond and the canal below it share a height, and a river can step against its own continuation at a cell edge (`docs/notes/terrain-relief.md:138-142`). Marks inherit that; fixing it needs the tessellator to keep bodies apart. - **The `y_offset` ladder.** Water sits at −0.0035 m and Furniture at 0.0 (`crates/geo/src/vector_tiles.rs:1298`, `:1320`), so a mark's waterline lands 3.5 mm above the water sheet. That is the coplanar depth ladder working as designed — do not change it here. - **Tree and canopy placement.** `Canopy` and `Trunk` are in the same `volumetric` set and are also displaced per vertex across their own width. Whatever that is worth, it is a separate question about things on land. - **Colliders and the interaction prompt.** Marks have none by design and boats are deliberately not solid (`crates/cartopolis/src/systems/map/map_geometry.rs:704-721`); both are 2D anyway, so a height change does not reach them. - **A new `SurfaceGroup` variant, a separate material, or a Layers toggle of its own** for floating objects. ## Open questions None blocking. --- Branch: `fix/268-floating-marks-water-level` <details><summary>Original request</summary> Found by the QA re-verification pass on #266, walking what #206/#207 shipped. ## What I did Two settled headless captures of the **same buoy from the same camera**, on the Waal at Nijmegen. The only difference between them is `--height`, i.e. Map ▸ Height, the measured AHN relief layer. ``` cargo run -p cartopolis -- --shot /tmp/close.png \ --at 51.85140,5.86412,6 --look=-5,0 \ --size 960x540 --settle 1.5 --wait 60 --time 12 --clouds 0 --rain 0 --hide-ui \ --dump-state /tmp/close.json # + --height for the second ``` Both settle, both report `nav_marks=115`. The second reports `terrain_ready=true`, `terrain_relief_m=74.1`, `ground_m=8.08`. ## What happened **With the height layer on, the buoy is submerged to its shoulder.** Measured on the two PNGs — the red can's visible extent, windowed to the water so the roofs on the far bank do not count: | run | visible height | visible red | width | |---|---|---|---| | height layer off | **25 px** | 470 px | 24 px | | height layer on | **11 px** | 154 px | 24 px | The width is identical, so this is not distance or scale. The far waterline is at y ≈ 239–242 in both, so the camera has not moved either. What changed is where the buoy sits relative to the water: off, it shows a can with freeboard and its lantern on top; on, only the widest band of the can and the foot of the lantern are above the surface. The yellow mark further out is reduced to a sliver the same way. ## Why `map_geometry::stand_on_terrain` lifts the two onto the measured ground by **two different rules**, and the rules disagree over a river: - `SurfaceGroup::Water` takes `terrain.level_over(...)` — deliberately *one* height for the whole body, because a lake is level. - `SurfaceGroup::Furniture` is in the `volumetric` set, so every vertex takes `terrain.at(x, z)` at its own position. A navigation mark is Furniture. AHN's DTM has no ground under a 250 m-wide river, so the hole is closed by `fill_holes` from the banks — and the value that lands under a mid-river buoy is not the level the water body took. `cartopolis_geo::nav_marks`' form table is explicit that the arithmetic depends on those agreeing: > **A floating body straddles y = 0 because that is where the water surface is** That holds exactly while the height layer is off, which is its default. ## What a user would expect instead A buoy floats on the water whether or not Map ▸ Height is ticked. Today ticking it sinks every floating mark in the country by most of its freeboard — and, since the sign of the disagreement depends on what the DTM hole was filled from, it will raise them somewhere else. ## Where the seam is - `crates/cartopolis/src/systems/map/map_geometry.rs` — `stand_on_terrain`: the `Water` / `BridgeDeck` branch takes `level_over`, the `volumetric` set takes `at` per vertex - `crates/geo/src/nav_marks.rs` — `mod form`, and the `NavMark` doc comment ("water surface at y = 0") - `crates/cartopolis/src/systems/map/terrain.rs` — `fill_holes`, which is what supplies a height in the middle of a river at all ## The same rule hits the moored boats `furniture::BOAT`'s hull is `part([0, 0.10, 0], [1.20, 0.30, 4.50])`, i.e. it spans y = −0.20 … 0.40 and straddles the waterline for the same reason. It is Furniture too, so it takes the same per-vertex lift. **Derived from the code, not observed** — I did not capture a mooring — but if this is fixed it should be fixed for anything that floats, not for marks alone. ## What a person might decide The shape of the fix is probably "a floating thing takes the water's level, not the ground's": either a `Floating` surface group that stands on `level_over` like the water it sits in, or a per-cell water level published alongside the field for `nav_marks` and `moorings` to read at mesh time. ## What I could not check Whether the disagreement is a sink everywhere or a sink here and a lift elsewhere — that needs the same A/B over a second waterway. Colour and lighting are not evidence on this container's software renderer; the pixel heights above are geometry, which is. <sub>Filed by the QA pass on #266. 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 7-day retrospective (#279) — autonomous + ship.

Why this one.

  • It is already measured, with one flag between the two runs. 25 px of visible buoy
    without --height, 11 px with it, identical width and an unmoved waterline. That is
    geometry, which is trustworthy on this container's software rasteriser, and the same
    A/B is what judges the fix.
  • It is two shipped layers contradicting each other, both of them value 1. The
    measured ground sinks the surveyed marks. Ticking the more measured of the two boxes
    is what breaks the picture, which is the worst direction for this project's own rule to
    fail in.
  • The fix is a rule, not a taste. A thing that floats takes the water's level — the
    same level_over the water surface it sits in already takes — instead of a per-vertex
    ground height that AHN never measured and fill_holes invented from the banks.

Bounds. stand_on_terrain in
crates/cartopolis/src/systems/map/map_geometry.rs, plus whatever nav_marks needs to
read a level. Fix it for anything that floats, marks and furniture::BOAT's moorings
together, as the ticket asks — a rule that covers one of them is the same bug waiting.
Do not redesign fill_holes or the terrain field: the hole in the DTM under a river is
correct, it is the consumer that is wrong.

One caution. #234 was promoted in the same pass and also edits map_geometry.rs, in
the crossing placement rather than in stand_on_terrain. Whichever is picked up second
must be cut from a main that already holds the first — two branches from one parent,
each green alone, is what put main in the red on 9e5ed895 last week (#276).

🤖 **Promoted into the build lane** by the 7-day retrospective (#279) — `autonomous` + `ship`. **Why this one.** - **It is already measured, with one flag between the two runs.** 25 px of visible buoy without `--height`, 11 px with it, identical width and an unmoved waterline. That is geometry, which is trustworthy on this container's software rasteriser, and the same A/B is what judges the fix. - **It is two shipped layers contradicting each other, both of them value 1.** The measured ground sinks the surveyed marks. Ticking the *more* measured of the two boxes is what breaks the picture, which is the worst direction for this project's own rule to fail in. - **The fix is a rule, not a taste.** A thing that floats takes the water's level — the same `level_over` the water surface it sits in already takes — instead of a per-vertex ground height that AHN never measured and `fill_holes` invented from the banks. **Bounds.** `stand_on_terrain` in `crates/cartopolis/src/systems/map/map_geometry.rs`, plus whatever `nav_marks` needs to read a level. Fix it for **anything that floats**, marks and `furniture::BOAT`'s moorings together, as the ticket asks — a rule that covers one of them is the same bug waiting. Do not redesign `fill_holes` or the terrain field: the hole in the DTM under a river is correct, it is the consumer that is wrong. **One caution.** #234 was promoted in the same pass and also edits `map_geometry.rs`, in the crossing placement rather than in `stand_on_terrain`. Whichever is picked up second must be cut from a `main` that already holds the first — two branches from one parent, each green alone, is what put `main` in the red on `9e5ed895` last week (#276).
Author
Collaborator

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

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

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

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


🤖 viberfox issue agent on the VPS — 41 turns · $3.65 · session 08c04448-c7c3-483d-b2b8-525a98724e36. Reply with @agent to continue this session.

🤖 **Refined.** The description above is now the spec; I'll pick it up for implementation on the next poll. If the direction is wrong, say so now — **@agent refine** with a correction rewrites it, and `agent:skip` stops me. --- <sub>🤖 viberfox issue agent on the VPS — 41 turns · $3.65 · session `08c04448-c7c3-483d-b2b8-525a98724e36`. Reply with **@agent** to continue this session.</sub>
Author
Collaborator

🤖 Picked this up — working on branch fix/268-floating-marks-water-level 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 `fix/268-floating-marks-water-level` 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

Buoys, beacons and moored boats now sit at the same height as the water they float in when the measured height layer is on, instead of standing on whatever ground the height model invented mid-river.

What changed

  • SurfaceMesh gained one documented flag, afloat, meaning "this mesh's y = 0 is the water surface, not the ground". It is false at every existing place a mesh is built.
  • The navigation-mark builder sets it on everything it produces (all eight forms are measured from the waterline, the fixed ones included). The moored-boat meshes are flagged where they are built in map_geometry, not inside the shared furniture builder — benches and parked cars go through the same builder and stand on the ground.
  • Each streamed cell now computes its water level once, from the height field over all of that cell's water polygons, and hands that one number to the water meshes and to every floating mesh. That also removes a smaller existing problem: a water body cut into several meshes by the upload budget used to get a different level per piece.
  • A cell with no water of its own keeps today's behaviour for its floating meshes — no invented level. --dump-state gained floating_off_water, a count of exactly that case, so --expect 'floating_off_water==0' can gate it.
  • stand_on_terrain's one-height-for-the-whole-thing branch is now reached only by bridge decks; its comment describes the three rules that exist.
  • Notes edited in place: terrain-relief.md, navigation-marks.md, canal-moorings.md.

How I verified it

cargo test -p cartopolis_geo (244 passed) and cargo test -p cartopolis (835 passed), plus cargo fmt --check over the five crates CI checks. Five new tests in map_geometry: a buoy over a river-shaped height field lands on the water's level rather than the filled hole under it; a bench on the quay in the same cell does not; a floating mesh with no water in its cell keeps the ground; with the height layer off every position is unchanged; and a water body split into pieces gets one level. A from_fn test constructor was added to terrain.rs and the existing ramp_field helper now uses it, so there is still one way to build a test field.

What is still unverified

No picture. This container cannot render, so the capture the issue was filed from — the same buoy on the Waal at Nijmegen with and without --height — has not been re-run. That needs a machine that can render:

cargo run -p cartopolis -- --shot /tmp/on.png --height \
  --at 51.85140,5.86412,6 --look=-5,0 --size 960x540 \
  --settle 1.5 --wait 60 --time 12 --clouds 0 --rain 0 --hide-ui \
  --dump-state /tmp/on.json --expect 'floating_off_water==0'

The buoy's visible height above the water should now match the run without --height, where it was 25 pixels against 11.

The moored boats have never been captured at all, before or after this change — the sinking was read off the code, not seen. Groningen's Diepenring is the place to look, and it would also answer the issue's open question of whether the old rule sinks things everywhere or lifts them somewhere.

For you to decide

The water itself is still drawn at the bank level rather than its real surface, which was checked and rejected earlier for reasons that still hold. This change only makes the buoy agree with the water that is drawn.

Branch fix/268-floating-marks-water-level (pushed)

053b619 fix(map): float navigation marks and moored boats on the water's own level

🤖 viberfox issue agent on the VPS — 96 turns · $8.71 · session 871ed6f0-12c5-49a9-b202-f335ea036047. Reply with @agent to continue this session.

Buoys, beacons and moored boats now sit at the same height as the water they float in when the measured height layer is on, instead of standing on whatever ground the height model invented mid-river. ## What changed - `SurfaceMesh` gained one documented flag, `afloat`, meaning "this mesh's `y = 0` is the water surface, not the ground". It is `false` at every existing place a mesh is built. - The navigation-mark builder sets it on everything it produces (all eight forms are measured from the waterline, the fixed ones included). The moored-boat meshes are flagged where they are built in `map_geometry`, not inside the shared furniture builder — benches and parked cars go through the same builder and stand on the ground. - Each streamed cell now computes its water level once, from the height field over **all** of that cell's water polygons, and hands that one number to the water meshes and to every floating mesh. That also removes a smaller existing problem: a water body cut into several meshes by the upload budget used to get a different level per piece. - A cell with no water of its own keeps today's behaviour for its floating meshes — no invented level. `--dump-state` gained `floating_off_water`, a count of exactly that case, so `--expect 'floating_off_water==0'` can gate it. - `stand_on_terrain`'s one-height-for-the-whole-thing branch is now reached only by bridge decks; its comment describes the three rules that exist. - Notes edited in place: `terrain-relief.md`, `navigation-marks.md`, `canal-moorings.md`. ## How I verified it `cargo test -p cartopolis_geo` (244 passed) and `cargo test -p cartopolis` (835 passed), plus `cargo fmt --check` over the five crates CI checks. Five new tests in `map_geometry`: a buoy over a river-shaped height field lands on the water's level rather than the filled hole under it; a bench on the quay in the same cell does not; a floating mesh with no water in its cell keeps the ground; with the height layer off every position is unchanged; and a water body split into pieces gets one level. A `from_fn` test constructor was added to `terrain.rs` and the existing `ramp_field` helper now uses it, so there is still one way to build a test field. ## What is still unverified No picture. This container cannot render, so the capture the issue was filed from — the same buoy on the Waal at Nijmegen with and without `--height` — has not been re-run. That needs a machine that can render: ``` cargo run -p cartopolis -- --shot /tmp/on.png --height \ --at 51.85140,5.86412,6 --look=-5,0 --size 960x540 \ --settle 1.5 --wait 60 --time 12 --clouds 0 --rain 0 --hide-ui \ --dump-state /tmp/on.json --expect 'floating_off_water==0' ``` The buoy's visible height above the water should now match the run without `--height`, where it was 25 pixels against 11. The moored boats have never been captured at all, before or after this change — the sinking was read off the code, not seen. Groningen's Diepenring is the place to look, and it would also answer the issue's open question of whether the old rule sinks things everywhere or lifts them somewhere. ## For you to decide The water itself is still drawn at the bank level rather than its real surface, which was checked and rejected earlier for reasons that still hold. This change only makes the buoy agree with the water that is drawn. **Branch** `fix/268-floating-marks-water-level` (pushed) ``` 053b619 fix(map): float navigation marks and moored boats on the water's own level ``` --- <sub>🤖 viberfox issue agent on the VPS — 96 turns · $8.71 · session `871ed6f0-12c5-49a9-b202-f335ea036047`. Reply with **@agent** to continue this session.</sub>
Author
Collaborator

🤖 Marked agent:done without a CI verdict on 053b6196 — 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 `053b6196` — no workflow reported on this commit. The branch is waiting for review; check the run yourself before landing it.
Author
Collaborator

🤖 Not landing this — CI is red on fix/268-floating-marks-water-level (053b6196):

  • test cartopolis — failure

Fix it on the branch and put ship back to land it.

🤖 **Not landing this — CI is red on `fix/268-floating-marks-water-level`** (`053b6196`): - `test cartopolis` — failure Fix it on the branch and put `ship` 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#268
No description provided.