gap: with the height layer on, every floating navigation mark sinks #268
Labels
No labels
agent
agent:ci
agent:done
agent:failed
agent:needs-input
agent:refined
agent:refining
agent:running
agent:shipped
agent:skip
autonomous
autopilot
driven
local
plan
proposal
qa
qa-gap
research
retro
ship
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
jeroen/cartopolis#268
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
A buoy is modelled so that
y = 0is 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), andmod formstates 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.15m, a pillar−0.55 … 2.60m (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, hullpart([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:
stand_on_terrain'sWater | BridgeDeckarm callsTerrainField::level_over(crates/cartopolis/src/systems/map/map_geometry.rs:1896,crates/cartopolis/src/systems/map/terrain.rs:218).SurfaceGroup::Furniture(crates/geo/src/nav_marks.rs:418,crates/cartopolis/src/systems/map/map_geometry.rs:699and:722), which is in thevolumetricset (crates/cartopolis/src/systems/map/map_geometry.rs:1889), so every vertex takesTerrainField::atat 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:380and:84). The water's median is taken over the polygon's own boundary vertices, i.e. the bank; the mark'sat()is whatever the fill invented mid-stream.docs/notes/terrain-relief.md:138-148anddocs/notes/rijkswaterstaat-water-levels.mdboth 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 = 0is the waterline —crates/geo/src/vector_tiles.rsAdd one field to
SurfaceMesh(crates/geo/src/vector_tiles.rs:1570), e.g.pub afloat: bool, defaultingfalse, plus a#[must_use] pub fn afloat(self) -> Selfbuilder. Document it as "this mesh'sy = 0is 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::Floatingvariant, deliberately: a new group would need its ownbase_color,perceptual_roughness,depth_biasandy_offset, a second material and draw call per cell, and it would break the propertynav_marks.rs:418is 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 ofbuild_meshneeds to know.2. Set the flag at the two places that produce floating geometry
crates/geo/src/nav_marks.rs:build_mark_meshesmarks 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 insidefurniture::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_surfacesBefore the spawn loop at
crates/cartopolis/src/systems/map/map_geometry.rs:1164, and only whenterrain.is_ready(), compute the cell's water level once:terrain.level_overover the positions of everySurfaceGroup::Watermesh insurface_meshes, offset by the cell origin exactly asstand_on_terraindoes today.Then pass it as
build_mesh's existingliftargument (crates/cartopolis/src/systems/map/map_geometry.rs:1750, currentlyNoneat:1166) for both theWatermeshes and everyafloatmesh.liftis the right mechanism and needs no new plumbing: it adds a constantyand skips the terrain path, and water already skipsrefine_for_terrain(stand_on_terrainreturns before it at:1902), so for water this is exactly equivalent to today.Two consequences to get right:
afloatmeshes:lift = None, per-vertexat(). 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.vector_tiles.rs:1695) currently gets a different median per piece. Computing over all of them gives one level for the cell, which is whatdocs/notes/terrain-relief.md:138already describes as the intent ("every water polygon in a tile is merged into oneSurfaceMeshbefore 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'sWater | BridgeDeckarm is reached only by the deck path (map_geometry.rs:1392passesNone). Narrow it toBridgeDeckand update the doc comment abovestand_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.rsAdd a
ShotMetricsfield counting theafloatmeshes 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 waynav_marksis (shot_harness.rs:640,:846,:2664,:2780), and into theDefaultused 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.mdanddocs/notes/canal-moorings.md:55-61(the "straddles the waterline" sections now say which level that is when the height layer is on). Cross-referencedocs/notes/rijkswaterstaat-water-levels.md, which already records why the level is the quay — that stays true and is unchanged by this.Acceptance criteria
SurfaceMeshcarries a documented flag meaning "y = 0is the water surface",falseat every existing construction site.build_mark_meshessets it; the mooring call inmap_geometrysets it; the bench, parked-car, level-crossing, vegetation, paving, crop and ground paths do not.y, computed once per cell fromTerrainField::level_overover all that cell's water positions.at()placement — no behaviour change, no crash, no zero-level fallback.TerrainField::is_ready() == false), every position in the cell is byte-identical to today.stand_on_terrain's level arm is narrowed toBridgeDeck, and its doc comment describes the rule set that now exists.map_geometry's test module builds a syntheticTerrainFieldwhose median over a water polygon differs fromat()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 waterSurfaceMeshand anafloatmesh over it, and asserts the two land on the samey.TerrainSnapshot's fields are private (terrain.rs:97-103), so this needs apub(crate)test constructor interrain.rs; have the existingramp_fieldhelper (terrain.rs:687) use it too rather than adding a second way to build one.Furnituremesh in a water-bearing cell — a bench on the quay — is not levelled onto the water.ShotMetricsgains the fallback count, present in the log line, the CSV header and--dump-state, with theDefaultin the harness tests updated.docs/notes/terrain-relief.md,docs/notes/navigation-marks.mdanddocs/notes/canal-moorings.mdare edited in place to state the rule.CLAUDE.md's rule for afix.Verification
Cannot be run in this pass — this is read-only and does not build. On a machine that can build:
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: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 onfloating_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.mdalready has bank measurements for — and check the moored boats there, which nobody has yet captured.Out of scope
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.docs/notes/terrain-relief.md:138-142). Marks inherit that; fixing it needs the tessellator to keep bodies apart.y_offsetladder. 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.CanopyandTrunkare in the samevolumetricset and are also displaced per vertex across their own width. Whatever that is worth, it is a separate question about things on land.crates/cartopolis/src/systems/map/map_geometry.rs:704-721); both are 2D anyway, so a height change does not reach them.SurfaceGroupvariant, a separate material, or a Layers toggle of its own for floating objects.Open questions
None blocking.
Branch:
fix/268-floating-marks-water-levelOriginal 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.
Both settle, both report
nav_marks=115. The second reportsterrain_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:
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_terrainlifts the two onto the measured ground by twodifferent rules, and the rules disagree over a river:
SurfaceGroup::Watertakesterrain.level_over(...)— deliberately one heightfor the whole body, because a lake is level.
SurfaceGroup::Furnitureis in thevolumetricset, so every vertex takesterrain.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_holesfrom the banks — and the value that landsunder 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:
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: theWater/BridgeDeckbranch takeslevel_over, thevolumetricset takesatper vertexcrates/geo/src/nav_marks.rs—mod form, and theNavMarkdoc comment("water surface at y = 0")
crates/cartopolis/src/systems/map/terrain.rs—fill_holes, which is whatsupplies a height in the middle of a river at all
The same rule hits the moored boats
furniture::BOAT's hull ispart([0, 0.10, 0], [1.20, 0.30, 4.50]), i.e. it spansy = −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
Floatingsurface group that stands onlevel_overlike thewater it sits in, or a per-cell water level published alongside the field for
nav_marksandmooringsto 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.
🤖 Promoted into the build lane by the 7-day retrospective (#279) —
autonomous+ship.Why this one.
without
--height, 11 px with it, identical width and an unmoved waterline. That isgeometry, which is trustworthy on this container's software rasteriser, and the same
A/B is what judges the fix.
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.
same
level_overthe water surface it sits in already takes — instead of a per-vertexground height that AHN never measured and
fill_holesinvented from the banks.Bounds.
stand_on_terrainincrates/cartopolis/src/systems/map/map_geometry.rs, plus whatevernav_marksneeds toread a level. Fix it for anything that floats, marks and
furniture::BOAT's mooringstogether, as the ticket asks — a rule that covers one of them is the same bug waiting.
Do not redesign
fill_holesor the terrain field: the hole in the DTM under a river iscorrect, it is the consumer that is wrong.
One caution. #234 was promoted in the same pass and also edits
map_geometry.rs, inthe crossing placement rather than in
stand_on_terrain. Whichever is picked up secondmust be cut from a
mainthat already holds the first — two branches from one parent,each green alone, is what put
mainin the red on9e5ed895last week (#276).alreadyFiledonly ever sees 50 issues, so every autopilot dedupe key silently expires — #206 was QA'd twice #281🤖 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.
🤖 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:skipstops me.🤖 viberfox issue agent on the VPS — 41 turns · $3.65 · session
08c04448-c7c3-483d-b2b8-525a98724e36. Reply with @agent to continue this session.🤖 Picked this up — working on branch
fix/268-floating-marks-water-levelin a fresh worktree on the VPS. I'll report back here when I'm done.Label an issue
agent:skipto keep me off it, ordrivento keep it in a session you are driving.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
SurfaceMeshgained one documented flag,afloat, meaning "this mesh'sy = 0is the water surface, not the ground". It isfalseat every existing place a mesh is built.map_geometry, not inside the shared furniture builder — benches and parked cars go through the same builder and stand on the ground.--dump-stategainedfloating_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.terrain-relief.md,navigation-marks.md,canal-moorings.md.How I verified it
cargo test -p cartopolis_geo(244 passed) andcargo test -p cartopolis(835 passed), pluscargo fmt --checkover the five crates CI checks. Five new tests inmap_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. Afrom_fntest constructor was added toterrain.rsand the existingramp_fieldhelper 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: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)🤖 viberfox issue agent on the VPS — 96 turns · $8.71 · session
871ed6f0-12c5-49a9-b202-f335ea036047. Reply with @agent to continue this session.🤖 Marked
agent:donewithout a CI verdict on053b6196— no workflow reported on this commit. The branch is waiting for review; check the run yourself before landing it.🤖 Not landing this — CI is red on
fix/268-floating-marks-water-level(053b6196):test cartopolis— failureFix it on the branch and put
shipback to land it.