fix(simulation): a river course was one D8 hop, not a river (T-1237)

D-261 asked for contiguous river strokes, and the client got them by
chaining hops back together after the fact. That could not work: each
hop was warped independently, so a shared confluence point arrived as
two points that no longer coincided -- 375 hops rejoined into 260
pieces, and Ferrath's Global map showed scratches rather than
watercourses.

The join belongs before invention, so it now happens on the server.
river_course::build_paths walks the D8 cell graph into whole rivers
from headwater to mouth, edge-drain, or junction with an already-walked
river (including the joint cell, so a tributary visibly meets its
trunk). step_canvas emits one course per river instead of one per cell,
which also drops the per-hop warp and resampling -- a path's shape is
the terrain's, so there is nothing left to invent. It is cheaper too:
one point per river cell rather than three.

The client's _chain_runs() and its endpoint index are deleted. Runs
survive only for the reason D-261 gives them -- water splits a course,
and a river crossing a lake is genuinely two strokes that must not be
rejoined.

Pinned by a real-terrain test on GJ380c rather than a synthetic graph,
because the bug was caught by eye on real terrain: a lake must have an
outflow that runs to sea level, and no such line existed. It asserts
the property the eye was checking -- rivers are long, at least one
reaches the sea, and every path is a contiguous walk.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-06 14:43:41 +02:00
co-authored by Claude Opus 5
parent 474ab90663
commit 4e503c3565
3 changed files with 255 additions and 125 deletions
+59 -43
View File
@@ -649,14 +649,19 @@ fn fixed_canvas_world_rect(
/// return.
#[allow(clippy::too_many_arguments)]
fn invent_courses_for_canvas(
seed: SeedChain,
// Unused since D-261 moved rivers from per-hop invention to whole cell
// paths: a path's shape IS the terrain's, so there is no warp to seed and
// no resampling wavelength to cut off. Kept in the signature because the
// deferred polish (T-1238) reintroduces a per-river warp, and threading
// them back through later is worse than leaving the plumbing in place.
_seed: SeedChain,
params: &BodyParams,
ta: &TerrainAnalysis,
river_network: &RiverNetwork,
canvas_rect: (f64, f64, f64, f64),
rung: StepCanvasRung,
step_m: f64,
min_wavelength_m: f64,
_step_m: f64,
_min_wavelength_m: f64,
) -> Vec<InventedCourse> {
// D-261: courses are invented at EVERY rung, Global included. They used to
// be withheld from the orbital rung, which is why the whole-body map had no
@@ -676,52 +681,63 @@ fn invent_courses_for_canvas(
// direction — a District canvas spans ~3.6 km post-inversion, so a 2,048 m
// station pitch would place two stations across the whole view and render
// every river as a straight line.
let station_spacing_m = step_m;
let (win_x0, win_y0, win_x1, win_y1) = canvas_rect;
let edges = river_course::build_edges(river_network);
// D-261: one course per RIVER, not per D8 hop.
//
// build_edges() emits one edge per river cell — a single step — so every
// "course" used to be one hop, inflated by invent_course into a ~70 km
// straight stub. Measured on Ferrath's Global canvas: 375 courses of 1-4
// points each, median 3. Nothing on that map read as a watercourse, and a
// lake's outflow to the sea — which must exist and must be long — was
// nowhere to be seen.
//
// A path IS the river, so its points are the river's own cells in order:
// no resampling, no per-hop warp, and the shape is a function of the
// terrain rather than of the zoom level (which is what D-261 asks for).
// It is also CHEAPER — the whole network becomes one point per river cell
// instead of three per cell.
let paths = river_course::build_paths(river_network);
let mut courses = Vec::new();
for edge in &edges {
let anchor_a = crate::atlas::district_profile::pixel_to_world_m(
edge.upstream.1 as f64,
edge.upstream.0 as f64,
ta.w,
ta.h,
params.body_radius_km,
);
let anchor_b = crate::atlas::district_profile::pixel_to_world_m(
edge.downstream.1 as f64,
edge.downstream.0 as f64,
ta.w,
ta.h,
params.body_radius_km,
);
let chord_m =
((anchor_a.0 - anchor_b.0).powi(2) + (anchor_a.1 - anchor_b.1).powi(2)).sqrt();
// Same inflation fraction layer_proxy::COURSE_BBOX_INFLATION_FRACTION
// uses — mirrors the Stage-B peak-amplitude bound exactly (0.08 =
// STAGE_B_PEAK_FRACTION_OF_CHORD, pinned equal by a const assert in
// layer_proxy.rs).
let inflate_m = chord_m * 0.08;
let (bx0, bx1) = (
anchor_a.0.min(anchor_b.0) - inflate_m,
anchor_a.0.max(anchor_b.0) + inflate_m,
);
let (by0, by1) = (
anchor_a.1.min(anchor_b.1) - inflate_m,
anchor_a.1.max(anchor_b.1) + inflate_m,
);
for (path_idx, path) in paths.iter().enumerate() {
let points: Vec<river_course::CoursePoint> = path
.cells
.iter()
.map(|&cell| {
crate::atlas::district_profile::pixel_to_world_m(
cell.1 as f64,
cell.0 as f64,
ta.w,
ta.h,
params.body_radius_km,
)
})
.collect();
if points.len() < 2 {
continue;
}
// Cull whole rivers that miss this canvas entirely, cheaply, before
// any per-point work downstream.
let mut bx0 = f64::MAX;
let mut bx1 = f64::MIN;
let mut by0 = f64::MAX;
let mut by1 = f64::MIN;
for p in &points {
bx0 = bx0.min(p.0);
bx1 = bx1.max(p.0);
by0 = by0.min(p.1);
by1 = by1.max(p.1);
}
if bx1 < win_x0 || bx0 > win_x1 || by1 < win_y0 || by0 > win_y1 {
continue;
}
courses.push(river_course::invent_course(
seed,
edge,
ta,
params,
station_spacing_m,
min_wavelength_m,
));
courses.push(InventedCourse {
edge_id: path_idx as u32,
class: path.class,
terminus: path.terminus,
bbox: (bx0, by0, bx1, by1),
points,
});
}
courses