feat(ui): rivers as cartographic strokes (D-261, T-1237)

Rivers now appear on the whole-body map for the first time. Five on Ferrath's
Global canvas, drawn as 5 px strokes that stop at the coastline.

Four rules, all client-side over existing server data, computed once on canvas
adoption rather than per draw:

  - fixed 5 px screen-space stroke at every rung
  - contiguous geometry through the river's own cells
  - never drawn over water — ocean and lake end a run
  - culled below 15 px of on-screen length (3x the stroke: below that a line
    is a square, not a river)

TWO THINGS THE MEASUREMENT FOUND THAT THE RECORD DID NOT ANTICIPATE.

First, the cull unit was wrong. A server "course" is an EDGE of the river
network — the stretch between two confluences — not a river. Culling per
course culls per segment, so a long river assembled from many short edges
vanishes entirely. Measured on Ferrath Global: 375 courses, 180 surviving the
water clip, and ZERO surviving a per-course cull. Edges are now chained
end-to-end into rivers before the cull is applied, which also delivers the
other half of D-261's "contiguous": per-course contiguity only makes each edge
unbroken; joining is what makes a river read as one line rather than dashes.
After chaining, 5 rivers survive at Global — the "major systems only from
orbit" behaviour the record predicted, arrived at by a different route.

Second, and worse: uses_orbital_derive() still read `Global | Region` while
the client's mirror had said Global-only since 2026-07-26. The D-255 amendment
claims "Region left the orbital derive set... it now takes the full
courses-aware derive". That was implemented against the MIRROR and never
against the authority, so Region kept running envelope-only and carrying no
courses — the exact thing the amendment said it had stopped doing. Both test
suites stayed green for two days because neither compares itself to the other.
Fixed here, with a note on each side pointing at the other, since the two
cannot be cross-checked automatically.

Also removes the two gates that withheld courses from the orbital rung — the
reason the whole-body map had no rivers at all. Whether a course is worth
drawing is measured in screen pixels, which only the client knows, so the
server now supplies geometry at every rung and the client decides.

The capture harness reports "drawn" alongside "courses", because "375 courses
arrived" and "375 rivers are drawn" are different claims and conflating them
is what made an empty map look like a data problem.

Client suite 1836 / 1810 passed / 26 skipped. Server suite green.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-28 17:51:58 +02:00
co-authored by Claude
parent 41b6ceb47e
commit 474ab90663
8 changed files with 430 additions and 68 deletions
@@ -438,3 +438,64 @@ func test_set_frame_with_an_interior_source_course_does_not_crash() -> void:
}
layer.set_frame(canvas, Vector2.ZERO, "District", Vector2i(4, 4), 0.0)
assert_object(layer).is_not_null()
# -----------------------------------------------------------------------
# D-261 — rivers as cartographic strokes
# -----------------------------------------------------------------------
func _canvas_with_courses(courses: Array) -> Dictionary:
return {"width": 4, "height": 4, "courses": courses}
## A server "course" is an EDGE of the river network — the stretch between two
## confluences — not a river. Culling per course therefore culls per SEGMENT,
## and a long river assembled from many short edges vanishes entirely. Measured
## on Ferrath's Global canvas before chaining: 375 courses, 180 surviving the
## water clip, ZERO surviving the length cull. After chaining: 5 rivers.
func test_edges_chain_into_one_river_before_the_length_cull() -> void:
var layer: StepCanvasAnnotationLayer = auto_free(StepCanvasAnnotationLayer.new())
add_child(layer)
# Three collinear edges, each meeting the next end-to-end. Sized so each
# is individually UNDER the cull and the chain is comfortably over it.
# At District with a 4x4 canvas the pitch is 512 m/gridunit and 2 px per
# gridunit, i.e. 256 m per screen px — so the 15 px floor is 3,840 m.
# 1,500 m per edge: 5.9 px alone (culled), 17.6 px chained (kept).
var step: float = 1500.0
var edges: Array = []
for i in range(3):
edges.append(
{
"class": 2,
"points": [
[float(i) * step, 0.0],
[float(i + 1) * step, 0.0],
],
}
)
layer.set_frame(_canvas_with_courses(edges), Vector2.ZERO, "District", Vector2i(4, 4), 0.0)
assert_int(layer.get_drawn_course_count()).override_failure_message(
"three end-to-end edges must chain into ONE river, not be culled as three segments"
).is_equal(1)
## The cull itself: a river too short to read as a line is not drawn at all,
## rather than drawn as a speck. 5 px wide by 5 px long is a square.
func test_a_river_shorter_than_the_stroke_reads_is_not_drawn() -> void:
var layer: StepCanvasAnnotationLayer = auto_free(StepCanvasAnnotationLayer.new())
add_child(layer)
var tiny: Array = [{"class": 2, "points": [[0.0, 0.0], [0.001, 0.0]]}]
layer.set_frame(_canvas_with_courses(tiny), Vector2.ZERO, "District", Vector2i(4, 4), 0.0)
assert_int(layer.get_drawn_course_count()).override_failure_message(
"a sub-threshold river must be dropped, not drawn as a speck"
).is_equal(0)
## The stroke is a fixed screen-space width at every rung (D-261) — the
## per-class ladder is deliberately flattened until T-1238 restores a
## size-varying width.
func test_stroke_width_is_one_fixed_value() -> void:
assert_float(StepCanvasAnnotationLayer.COURSE_WIDTH_PX).is_equal_approx(5.0, 0.001)
assert_float(StepCanvasAnnotationLayer.MIN_COURSE_LENGTH_PX).override_failure_message(
"the cull must DERIVE from the stroke (3x) so it self-corrects if the width changes"
).is_equal_approx(StepCanvasAnnotationLayer.COURSE_WIDTH_PX * 3.0, 0.001)
+2 -1
View File
@@ -335,7 +335,7 @@ func _log_atlas_view_transform(tree_root: Node, scenario_name: String) -> void:
(
"visual_capture: view-transform[%s] rung=%s world_center=%s held_extent=%s "
+ "canvas_position=%s canvas_scale=%s footprint_px=%s canvas_cells=%dx%d "
+ "courses=%d settlements=%d"
+ "courses=%d drawn=%d settlements=%d"
)
% [
scenario_name,
@@ -348,6 +348,7 @@ func _log_atlas_view_transform(tree_root: Node, scenario_name: String) -> void:
int(summary.get("canvas_width", 0)),
int(summary.get("canvas_height", 0)),
int(summary.get("course_count", 0)),
int(summary.get("drawn_course_count", 0)),
int(summary.get("settlement_count", 0)),
]
)
@@ -63,6 +63,9 @@ const RIVER_CLASS_TRUNK: int = 2
## every class wider). Opacity table is UNCHANGED — Araminta's T-1170 ruling
## (width+opacity sufficient, hue solvable-later) already gives stream a
## faded read; widening the gap in WIDTH is the one lever this pass turns.
## SUPERSEDED BY D-261 (2026-07-28) — kept for the width grammar's rationale
## and because T-1238 restores a size-varying width on top of the new stroke
## model. Not read by the draw path any more; COURSE_WIDTH_PX is.
const COURSE_CLASS_WIDTH_PX: Dictionary = {
RIVER_CLASS_STREAM: 0.6, RIVER_CLASS_TRIBUTARY: 1.2, RIVER_CLASS_TRUNK: 2.4
}
@@ -70,6 +73,37 @@ const COURSE_CLASS_OPACITY: Dictionary = {
RIVER_CLASS_STREAM: 0.8, RIVER_CLASS_TRIBUTARY: 0.9, RIVER_CLASS_TRUNK: 1.0
}
## D-261: a river is a CARTOGRAPHIC STROKE, not terrain drawn to scale.
##
## A real channel is sub-pixel at nearly every Atlas scale — 100 m of water
## against Global's ~17.6 km per screen pixel — so drawing it to scale draws
## nothing. Measured before this record: 375 courses on Ferrath's Global canvas
## changed 458 of 518,400 pixels. The rivers were not failing to render, they
## were correctly beneath notice. One fixed screen-space width at every rung,
## replacing the per-class ladder above (deliberately flattened; T-1238).
const COURSE_WIDTH_PX: float = 5.0
## The minimum on-screen length a course must have to be drawn at all.
##
## DERIVED, not stipulated: a mark reads as a *line* only at roughly 3x its own
## width — 5 px by 5 px is a square, not a river. So the floor is 3 x
## COURSE_WIDTH_PX, and the kilometre thresholds then fall out of each rung's
## own scale instead of being written down: ~264 km at Global, 2.8 km at
## Region, 28 m at District (Ferrath). A level-of-detail ladder with no
## hand-tuned constants, which self-corrects if the stroke width changes.
##
## Measured on the VISIBLE extent, not total river length. A course crossing
## the window always spans it and so always passes; only a course lying wholly
## inside the view AND small is culled — which is exactly "too small to see",
## and needs no total-length field on the wire.
const MIN_COURSE_LENGTH_PX: float = COURSE_WIDTH_PX * 3.0
## Morphology zone ids that ARE water (server: MorphologyZone::OpenOcean = 0,
## Lake = 1). D-261: a course is truncated where it meets either — a blue line
## across open sea asserts something false.
const MORPHOLOGY_OPEN_OCEAN: int = 0
const MORPHOLOGY_LAKE: int = 1
## Source tapering (T-1175 seeded item 2 — "courses taper to a point at
## their upstream source instead of starting at full class width", the
## classic cartographic river grammar the RimWorld reference shows on every
@@ -168,6 +202,10 @@ var _extent_cells: Vector2i = Vector2i.ZERO
## the elastic seam) the body itself. All four therefore travel together.
var _body_radius_km: float = 0.0
## Drawable course geometry, rebuilt once per set_frame() and read every draw
## (D-261). Entries: {points: PackedVector2Array, terminus: String, taper: bool}.
var _prepared_courses: Array = []
## Adopt a new canvas + its request frame (world center, rung, extent, body
## radius) — the world->screen projection for every drawn feature depends on
@@ -185,14 +223,244 @@ func set_frame(
_rung = rung
_extent_cells = extent_cells
_body_radius_km = body_radius_km
_prepare_courses()
queue_redraw()
func clear_frame() -> void:
_canvas = null
_prepared_courses.clear()
queue_redraw()
## Build every course's drawable geometry ONCE per canvas adoption (D-261).
##
## Deliberately not per-frame. `_draw()` runs every frame, and the work here —
## decoding the morphology plane, projecting each point, testing each against
## water — is a pure function of the adopted canvas. Doing it per draw would
## repeat a few hundred courses' worth of lookups for geometry that only
## changes when a new canvas arrives.
##
## Each course becomes zero or more RUNS. A run is a maximal stretch of the
## course that is over land: crossing into ocean or lake ends the run, and
## re-emerging starts a new one. That is how "never drawn over water" is
## implemented — not by clipping a drawn line, but by never building geometry
## there in the first place. Runs shorter than MIN_COURSE_LENGTH_PX are
## dropped entirely rather than drawn as specks.
func _prepare_courses() -> void:
_prepared_courses.clear()
if not _canvas is Dictionary:
return
var d: Dictionary = _canvas
var courses_raw: Variant = d.get("courses", [])
if not courses_raw is Array:
return
var morphology: Variant = _decode_morphology(d)
for course_raw: Variant in (courses_raw as Array):
if not course_raw is Dictionary:
continue
var course: Dictionary = course_raw
var points_raw: Variant = course.get("points")
if not points_raw is Array or (points_raw as Array).size() < 2:
continue
var terminus: String = str(course.get("terminus", ""))
var run := PackedVector2Array()
var run_is_first: bool = true
var first_world_m: Variant = null
for pt: Variant in (points_raw as Array):
if not (pt is Array and pt.size() >= 2):
continue
var world_m := Vector2(float(pt[0]), float(pt[1]))
if first_world_m == null:
first_world_m = world_m
if _is_water_at_world_m(morphology, world_m):
# Water ends the run. Flush what we have and start fresh.
_flush_run(run, run_is_first, first_world_m, "")
if not run.is_empty():
run_is_first = false
run = PackedVector2Array()
continue
run.append(_world_to_local(world_m))
# The final run owns the course's terminus marker — earlier runs were
# cut short by water and have no mouth of their own.
_flush_run(run, run_is_first, first_world_m, terminus)
# Chain the runs into rivers BEFORE culling — see _chain_runs().
_prepared_courses = _chain_runs(_prepared_courses)
_cull_short(_prepared_courses)
## Accept a candidate run if it is long enough on screen to read as a line.
func _flush_run(
run: PackedVector2Array, is_first_run: bool, first_world_m: Variant, terminus: String
) -> void:
if run.size() < 2:
return
_prepared_courses.append(
{
"points": run,
"terminus": terminus,
# Only the FIRST run can begin at the river's true source; a later
# run begins wherever water released it, which is not a headwater
# and must not be tapered to a point (PR #207 finding 1's rule,
# extended to the runs D-261 introduces).
"taper": is_first_run and _is_true_source_in_canvas(first_world_m),
}
)
## Join runs that meet end-to-end into whole rivers, then cull.
##
## THE UNIT PROBLEM. A server "course" is an EDGE of the river network — the
## stretch between two confluences — not a river. Culling per course therefore
## culls per segment, and a 1,000 km river assembled from fifty 20 km edges
## vanishes entirely because no single edge clears the threshold. Measured on
## Ferrath's Global canvas: 375 courses, 180 surviving the water clip, and
## ZERO surviving a per-course length cull.
##
## Chaining also delivers the other half of D-261's "contiguous" requirement.
## Per-course contiguity only makes each edge unbroken; it is the joining that
## makes a river read as one line rather than a row of dashes.
##
## Greedy: take any unused run, extend it downstream while some unused run
## starts where it ends, then upstream likewise. O(n) lookups via an endpoint
## index rather than an O(n^2) scan.
func _chain_runs(runs: Array) -> Array:
if runs.size() < 2:
return runs
var starts: Dictionary = {} # quantised start point -> [run indices]
for i in range(runs.size()):
var key: String = _point_key((runs[i]["points"] as PackedVector2Array)[0])
if not starts.has(key):
starts[key] = []
(starts[key] as Array).append(i)
var used: Dictionary = {}
var chained: Array = []
for i in range(runs.size()):
if used.has(i):
continue
used[i] = true
var entry: Dictionary = runs[i]
var pts := PackedVector2Array(entry["points"])
# Extend downstream: repeatedly find an unused run beginning where this
# one ends. The terminus travels with the LAST link, since that is the
# end that actually reaches the sea.
var terminus: String = str(entry["terminus"])
while true:
var next_i: int = _take_run_starting_at(starts, used, pts[pts.size() - 1])
if next_i < 0:
break
var nxt: Dictionary = runs[next_i]
var npts: PackedVector2Array = nxt["points"]
for j in range(1, npts.size()): # skip the shared joint
pts.append(npts[j])
terminus = str(nxt["terminus"])
chained.append(
{
"points": pts,
"terminus": terminus,
# Taper only if this chain BEGINS at a true source. Extending
# downstream never changes where the chain starts, so the head
# run's own judgement still holds.
"taper": bool(entry["taper"]),
}
)
return chained
## First unused run whose start coincides with `at`, or -1.
static func _take_run_starting_at(starts: Dictionary, used: Dictionary, at: Vector2) -> int:
var key: String = _point_key(at)
if not starts.has(key):
return -1
for idx_raw: Variant in (starts[key] as Array):
var idx: int = idx_raw
if not used.has(idx):
used[idx] = true
return idx
return -1
## Quantise a screen point to a joinable key. Confluence endpoints come from
## the same server coordinate and survive an identical linear projection, so
## they agree closely; rounding to a tenth of a pixel absorbs float drift
## without gluing genuinely separate rivers together.
static func _point_key(p: Vector2) -> String:
return "%d:%d" % [roundi(p.x * 10.0), roundi(p.y * 10.0)]
## Drop chains too short to read as a line (D-261). Done AFTER chaining, so the
## measurement is of a river rather than of one of its segments.
func _cull_short(chains: Array) -> void:
var keep: Array = []
for entry_raw: Variant in chains:
var entry: Dictionary = entry_raw
if _polyline_length_px(entry["points"]) >= MIN_COURSE_LENGTH_PX:
keep.append(entry)
chains.clear()
chains.append_array(keep)
## How many course RUNS survived preparation and will actually be stroked.
##
## Distinct from the canvas's own course count, and the distinction is the
## whole point: "375 courses arrived" and "375 rivers are drawn" are different
## claims, and conflating them is what made an empty map look like a data
## problem. Surfaced in the viewer summary so a capture can tell them apart.
func get_drawn_course_count() -> int:
return _prepared_courses.size()
static func _polyline_length_px(pts: PackedVector2Array) -> float:
var total: float = 0.0
for i in range(1, pts.size()):
total += pts[i].distance_to(pts[i - 1])
return total
## Decode the morphology plane for the water test. Mirrors the terrain layer's
## own `_decode_l8_plane` rather than sharing it — the two layers are siblings
## with no dependency between them, and this is one decode per canvas.
func _decode_morphology(canvas: Dictionary) -> Variant:
var field: Variant = canvas.get("morphology")
if not field is PackedByteArray or (field as PackedByteArray).is_empty():
return null
var img := Image.new()
if img.load_png_from_buffer(field) != OK:
push_warning("StepCanvasAnnotationLayer: morphology decode failed — water clip disabled")
return null
return img
## Is this world point over ocean or lake?
##
## Returns FALSE when the plane is unavailable, so a decode failure degrades to
## the pre-D-261 behaviour (rivers drawn everywhere) rather than to a blank map
## — a missing clip is a cosmetic defect, a suppressed river is a missing one.
func _is_water_at_world_m(morphology: Variant, world_m: Vector2) -> bool:
if morphology == null:
return false
var img: Image = morphology
var spacing: float = _spacing_m()
if spacing <= 0.0:
return false
var col: int = int(
floor((world_m.x - _world_center.x) / spacing + float(_extent_cells.x) * 0.5)
)
var row: int = int(
floor((world_m.y - _world_center.y) / spacing + float(_extent_cells.y) * 0.5)
)
if col < 0 or row < 0 or col >= img.get_width() or row >= img.get_height():
return false
var zone: int = int(img.get_pixel(col, row).r8)
return zone == MORPHOLOGY_OPEN_OCEAN or zone == MORPHOLOGY_LAKE
## This frame's metres-per-gridunit — derived, never a per-rung constant
## (D-255 extent inversion). One accessor so every projection in this file
## reads the same number by construction.
@@ -208,46 +476,19 @@ func _draw() -> void:
_draw_settlements(d)
func _draw_courses(courses: Array) -> void:
for course_raw: Variant in courses:
if not course_raw is Dictionary:
continue
_draw_one_course(course_raw)
func _draw_one_course(course: Dictionary) -> void:
var cls: int = int(course.get("class", RIVER_CLASS_TRUNK))
var points_raw: Variant = course.get("points")
if not points_raw is Array or (points_raw as Array).size() < 2:
return
var screen_pts := PackedVector2Array()
# First WELL-FORMED point's world position — the crop-edge gate (finding
# 1) reads THIS, not screen_pts[0], which is already screen-projected.
var first_world_m: Variant = null
for pt: Variant in points_raw:
if not (pt is Array and pt.size() >= 2):
continue
var world_m := Vector2(float(pt[0]), float(pt[1]))
if first_world_m == null:
first_world_m = world_m
screen_pts.append(_world_to_local(world_m))
if screen_pts.size() < 2:
return
var width: float = float(COURSE_CLASS_WIDTH_PX.get(cls, COURSE_CLASS_WIDTH_PX[RIVER_CLASS_STREAM]))
var opacity: float = float(COURSE_CLASS_OPACITY.get(cls, COURSE_CLASS_OPACITY[RIVER_CLASS_STREAM]))
var color := Color(COLOR_RIVER.r, COLOR_RIVER.g, COLOR_RIVER.b, COLOR_RIVER.a * opacity)
# Finding 1 (PR #207, Araminta, blocking): only taper when points[0] is
# the course's TRUE source (inside this canvas's own declared bounds) —
# see _is_true_source_in_canvas()'s own doc for the crop-overhang gate.
var is_true_source: bool = _is_true_source_in_canvas(first_world_m)
_draw_tapered_course(screen_pts, width, color, is_true_source)
var terminus: String = str(course.get("terminus", ""))
if terminus == COURSE_TERMINUS_MOUTH:
_draw_mouth_ring(screen_pts[screen_pts.size() - 1])
## Stroke the geometry `_prepare_courses()` already built. Everything
## expensive — projection, the water test, the length cull — happened once on
## canvas adoption (D-261); this runs every frame and must stay cheap.
func _draw_courses(_courses: Array) -> void:
for entry_raw: Variant in _prepared_courses:
var entry: Dictionary = entry_raw
var pts: PackedVector2Array = entry["points"]
# One width and one colour for every river (D-261): the per-class
# ladder is deliberately flattened until T-1238 restores a
# size-varying stroke.
_draw_tapered_course(pts, COURSE_WIDTH_PX, COLOR_RIVER, bool(entry["taper"]))
if str(entry["terminus"]) == COURSE_TERMINUS_MOUTH:
_draw_mouth_ring(pts[pts.size() - 1])
## PR #207 finding 1 — the crop-overhang detection gate. `canvas_rect` is
@@ -204,6 +204,14 @@ static func display_ratio_for_rung(rung: String) -> float:
## a genuine provincial map, so it takes the full courses-aware derive like
## every other fixed rung. Region having no rivers was a large part of why
## the top of the ladder read flat.
## **If you change this, change `uses_orbital_derive` in step_canvas.rs in the
## same commit.** That function is the AUTHORITY; this is a mirror. On
## 2026-07-26 this side was changed to Global-only and the server was not, so
## for two days the client believed Region took the full courses-aware derive
## while the server kept it envelope-only and course-free — both suites green
## the whole time, because neither test compares itself to the other. They
## cannot be cross-checked automatically (different languages, no shared
## fixture for this predicate), so the coupling is held by this note.
static func is_orbital_rung(rung: String) -> bool:
return rung == RUNG_GLOBAL
@@ -431,6 +431,9 @@ func get_current_canvas_summary() -> Dictionary:
"canvas_width": int(d.get("width", 0)),
"canvas_height": int(d.get("height", 0)),
"course_count": courses.size(),
"drawn_course_count": (
_annotation_layer.get_drawn_course_count() if _annotation_layer else 0
),
"course_count_by_class": course_count_by_class,
"cliff_count": (d.get("cliffs", []) as Array).size(),
"settlement_count": _count_distinct_settlements(d),