islaApocalypse/Tools/Scripts/RiverCarvePass.cs
beezm 8f5767308a feat: stepped river water + widen + lake-ender join + the task-22 nits (terrain-water task 23, C0b finale)
THE PAYOFF: rivers now carry water. Each carved main river becomes a chain of
stepped flat water-body reaches — a new reach every RiverStepDropM (2 m) of bed
descent, sitting RiverWaterDepthM (1.2 m) above its bed, strictly descending to
the outlet. Reaches are ordinary water bodies (WBID cells + WBTB type 2 'river',
fresh; WSRF derives from body levels as ever), so river water renders through
the C1 path with the task-15 presence rule at its banks, untouched. Existing
bodies are never overwritten — a river MEETS its lake or the sea. Measured on
1280587109: 298 reaches across 6 rivers, 184,620 wet px, levels stepping 278 m
down to 39.5 m; in-engine, one valley frame renders surfaces 46.2-114.5 m.

Beds widened (RiverWidthScale default 1.75). Lake-enders: the stem's pooling
terminal IS a local minimum, so the first extension attempt (blind steepest
descent) dead-ended in 0 steps — replaced with the lowground Dijkstra to the
nearest classify-water cell; the E-lagoon river now joins its lake (87 px).
Smoothing is applied ONLY to lowland routes: smoothing upland stems moved them
off their carved valley floors into the walls (max cut 14.6 -> 27.3 m measured;
split restores valley-floor fidelity). TYPE_RIVER added to the WBTB registry
(parser validation extended; doc updated in the docs commit).

Task-22 nits fixed: max-cut is now CUMULATIVE vs the pre-pass surface (the
honest number: p95 carve 7.0 m; 339 cells island-wide exceed 20 m — localized
outlet-gorge notches where stems cross deposit ridges, deepest 26 m); and
0_height/0_water are re-drawn and re-captured AFTER the river pass so the
exported snapshots show beds and water.

Guards: flood guard holds (water pixels unchanged around the carve; the water
stage touches no heights), BIOME oracle md5-identical to the task-22 baseline,
0_water changed (that IS the river water), island top 457.65 m exact, crater
core excluded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 19:55:04 -04:00

557 lines
22 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
/// <summary>
/// River bed carving — C0b part 2a (terrain-water task 22). The first river pass
/// that MODIFIES terrain: executes the frozen task-21b plan by carving channel
/// beds for the promoted rivers. NO WATER — part 2b puts water into these beds
/// once the routing-style gate picks SHORT or LOWGROUND.
///
/// Standalone numeric (D-035 family). Runs the task-21 DrainageAnalysis in-
/// pipeline (deterministic: same seed → same eroded surface → same plan), then:
///
/// 1. LOWLAND ROUTING (the A/B): each routed giant gets a route from its
/// pooling terminal to the nearest OCEAN cell by deterministic Dijkstra.
/// SHORT — cost ≈ distance, uphill penalised: heads direct, avoids walls.
/// LOWGROUND — cost ≈ elevation above sea: follows the lowest available
/// ground and wanders like a real lowland river.
/// 2. BED CARVING: every promoted course (trunks, routed giants + their lowland
/// reaches, lake-enders, tributaries) is stamped as a parabolic channel with
/// a smoothstep shoulder — a bed for water to sit in, not a canyon. Width and
/// depth grow downstream with drainage. The bed elevation is made MONOTONE
/// NON-INCREASING toward the outlet (water must flow), and is clamped to
/// sea + margin everywhere — the flood-guard discipline erosion established:
/// below-sea cells are read-only, no carve may create inland below-sea cells,
/// so the rendered coastline cannot move. The bed meets the ocean AT the
/// coast, where the terrain itself descends through sea level.
/// 3. Crater: nothing inside the protected core is modified (erosion's rule).
///
/// Output-only: the caller applies this to the RENDER map after classify-side
/// data (biomes, WBID) is already computed — the oracle stays byte-identical.
/// </summary>
public static class RiverCarvePass
{
public const float M_PER_UNIT = 251f;
public const byte STYLE_SHORT = 0;
public const byte STYLE_LOWGROUND = 1;
private static readonly int[] DX = { -1, -1, -1, 0, 0, 1, 1, 1 };
private static readonly int[] DY = { -1, 0, 1, -1, 1, -1, 0, 1 };
private static readonly float[] DIST = {
1.41421356f, 1f, 1.41421356f, 1f, 1f, 1.41421356f, 1f, 1.41421356f };
// Routing cost constants. SHORT pays lightly for climbing (8 per metre of rise,
// so a 10 m wall costs like an 80 px detour — walls are avoided, direction is
// kept). LOWGROUND pays for BEING high (1 per metre of elevation per px) plus
// heavily for climbing, so the cheapest corridor is the lowest ground even when
// that wanders.
private const float SHORT_UPHILL_PER_M = 8f;
private const float LOWGROUND_ELEV_PER_M = 1f;
private const float LOWGROUND_BASE = 0.05f;
private const float LOWGROUND_UPHILL_PER_M = 50f;
// Bed geometry: sizes grow downstream from head to mouth, scaled by
// sqrt(drainage / 1e6) so a 2.3M px giant carves roughly 2.3× deeper/wider at
// the mouth than a 0.4M px trunk. Kept channel-scale, per the erosion
// detailing philosophy.
private const float DEPTH_HEAD_M = 1.0f;
private const float DEPTH_MOUTH_M = 6.0f; // × sizeFactor × DepthScale
private const float HALFWIDTH_HEAD_PX = 2.0f;
private const float HALFWIDTH_MOUTH_PX = 12.0f; // × sizeFactor × WidthScale
private const float MIN_BED_SLOPE = 0.002f; // m per px of enforced descent
public class Params
{
public byte RoutingStyle = STYLE_LOWGROUND;
public float WidthScale = 1.0f;
public float DepthScale = 1.0f;
public float SeaMarginM = 0.2f; // bed floor above sea, everywhere
public DrainageAnalysis.Params PlanParams = new();
}
public class RiverStat
{
public string Name;
public string Kind;
public bool SouthernCandidate;
public long DrainagePx;
public int CourseLenPx;
public int RouteLenPx; // lowland reach only (routed giants)
public float RouteStraightPx;
public float WanderRatio; // routeLen / straight-line
public bool ReachedOcean;
public float MaxCutM;
public double VolumeM3;
}
/// <summary>The carved geometry the water stage consumes (main rivers only).</summary>
public class CarvedRiver
{
public string Name, Kind;
public bool Southern;
public long DrainagePx;
public List<(float x, float y)> Dense; // head → mouth, ~1-px samples
public float[] Bed; // raw units, monotone non-increasing
public float[] HalfW; // px
public bool ReachedWaterTerminal; // lake-enders: extension reached classify water
}
public class Stats
{
public List<RiverStat> Rivers = new();
public List<CarvedRiver> Carved = new(); // for the stepped-water stage (task 23)
internal float[] PrePass; // cumulative-cut baseline
public long CarvedCells;
public double CarvedVolumeM3;
public float MaxCutM; // CUMULATIVE vs pre-pass heights (task-22 nit 1 fixed)
public double AnalysisSeconds, RoutingSeconds, CarveSeconds;
}
public static Stats Apply(float[,] height, int mapSize, bool[] isOcean,
bool[] isClassifyWater, float southX, float southY,
float[,] seaMap, float seaFlat,
float craterCx, float craterCy, float craterCoreRadius,
Func<double> secondsNow, Params p)
{
int n = mapSize;
var stats = new Stats();
float SeaAt(int x, int y) => seaMap != null ? seaMap[x, y] : seaFlat;
float coreSq = craterCoreRadius * craterCoreRadius;
// --- the frozen plan, recomputed deterministically in-pipeline ---
double t0 = secondsNow();
var plan = DrainageAnalysis.Run(height, mapSize, isOcean, isClassifyWater, southX, southY, p.PlanParams);
stats.AnalysisSeconds = secondsNow() - t0;
// --- lowland routing for the routed giants (the A/B) ---
t0 = secondsNow();
var giantRoutes = new List<List<(float x, float y)>>();
foreach (var g in plan.Giants)
{
if (g.Kind != "routed") { giantRoutes.Add(null); continue; }
giantRoutes.Add(RouteToOcean(height, n, isOcean,
(int)g.Terminal.x, (int)g.Terminal.y, p.RoutingStyle, SeaAt));
}
stats.RoutingSeconds = secondsNow() - t0;
// --- carve ---
t0 = secondsNow();
// Pre-pass snapshot: max-cut is measured CUMULATIVELY against the heights
// this pass found, not per-write — overlapping stamps re-cut a cell and the
// per-write number understated the true deepest cut ~4× (task-22 nit 1).
float[] pre = new float[n * n];
for (int x = 0; x < n; x++)
for (int y = 0; y < n; y++)
pre[x * n + y] = height[x, y];
stats.PrePass = pre;
int riverIdx = 0;
foreach (var t in plan.Trunks)
{
riverIdx++;
var course = new List<(float x, float y)>(t.Course);
course.Reverse(); // head → mouth
var rs = CarveRiver($"trunk{riverIdx}", "ocean-trunk", t.DrainageAreaPx,
course, null, height, n, SeaAt, coreSq, craterCx, craterCy, p, stats);
rs.ReachedOcean = true; // outlet is on the coast by construction
foreach (var trib in t.Tributaries)
CarveTributary(trib, height, n, SeaAt, coreSq, craterCx, craterCy, p, stats);
}
int gi = 0;
foreach (var g in plan.Giants)
{
var route = giantRoutes[gi]; gi++;
var course = new List<(float x, float y)>(g.Course);
course.Reverse(); // head → terminal
// Lake-enders (task 23): the stem pools on dry ground short of its lake
// BECAUSE its pooling point is a local minimum — a blind descent walk
// dead-ends there immediately (measured: 0 steps). Route to the nearest
// classify-water cell with the same lowground Dijkstra the routed giants
// use, so the bed (and then the water) actually joins the lake.
bool reachedLake = false;
if (g.Kind == "lake-ender")
{
var ext = RouteToOcean(height, n, isClassifyWater,
(int)g.Terminal.x, (int)g.Terminal.y, STYLE_LOWGROUND, SeaAt);
if (ext.Count > 0) { route = ext; reachedLake = true; }
}
var rs = CarveRiver($"giant{gi}", g.Kind, g.DrainageAreaPx,
course, route, height, n, SeaAt, coreSq, craterCx, craterCy, p, stats);
rs.SouthernCandidate = g.SouthernCandidate;
rs.ReachedOcean = g.Kind == "routed" ? (route != null && route.Count > 0) : reachedLake;
if (stats.Carved.Count > 0) stats.Carved[^1].ReachedWaterTerminal = reachedLake;
foreach (var trib in g.Tributaries)
CarveTributary(trib, height, n, SeaAt, coreSq, craterCx, craterCy, p, stats);
}
stats.CarveSeconds = secondsNow() - t0;
return stats;
}
// ---- Route smoothing (task 23): the road pass's AAA pipeline, numerically ----
// RDP(4.0) decimation + 4 Chaikin corner-cutting passes, endpoints pinned —
// the same constants and structure as MapGenerator.SmoothPath, mirrored here
// because this pass is Godot-free. Kills the 8-connected Dijkstra 45° kinks;
// the bed then carves along the smoothed centreline.
private static List<(float x, float y)> SmoothCourse(List<(float x, float y)> raw)
{
if (raw.Count < 3) return raw;
var dec = Rdp(raw, 0, raw.Count - 1, 4.0f);
if (dec.Count < 3) return raw;
var sm = dec;
for (int pass = 0; pass < 4; pass++)
{
var nxt = new List<(float x, float y)>(sm.Count * 2) { sm[0] };
for (int i = 0; i + 1 < sm.Count; i++)
{
var a = sm[i]; var b = sm[i + 1];
nxt.Add((a.x * 0.75f + b.x * 0.25f, a.y * 0.75f + b.y * 0.25f));
nxt.Add((a.x * 0.25f + b.x * 0.75f, a.y * 0.25f + b.y * 0.75f));
}
nxt.Add(sm[^1]);
sm = nxt;
}
return sm;
}
private static List<(float x, float y)> Rdp(List<(float x, float y)> pts, int i0, int i1, float tol)
{
if (i1 - i0 <= 1) return new List<(float x, float y)> { pts[i0], pts[i1] };
var a = pts[i0]; var b = pts[i1];
float abx = b.x - a.x, aby = b.y - a.y;
float abLen = MathF.Sqrt(abx * abx + aby * aby);
float maxD = 0f; int maxI = i0;
for (int i = i0 + 1; i < i1; i++)
{
float d = abLen < 1e-6f
? MathF.Sqrt((pts[i].x - a.x) * (pts[i].x - a.x) + (pts[i].y - a.y) * (pts[i].y - a.y))
: MathF.Abs(abx * (a.y - pts[i].y) - (a.x - pts[i].x) * aby) / abLen;
if (d > maxD) { maxD = d; maxI = i; }
}
if (maxD <= tol) return new List<(float x, float y)> { pts[i0], pts[i1] };
var left = Rdp(pts, i0, maxI, tol);
var right = Rdp(pts, maxI, i1, tol);
left.RemoveAt(left.Count - 1);
left.AddRange(right);
return left;
}
/// <summary>
/// Deterministic Dijkstra from the start cell to the nearest ocean cell under
/// the selected style's cost model. Returns the path start → ocean (1-px steps),
/// or an empty list if no path exists (reported upstream, never asserted away).
/// </summary>
private static List<(float x, float y)> RouteToOcean(float[,] height, int n,
bool[] targets, int sx, int sy, byte style, Func<int, int, float> seaAt)
{
int total = n * n;
var gcost = new float[total];
var parent = new int[total];
var closed = new bool[total];
Array.Fill(gcost, float.MaxValue);
Array.Fill(parent, -1);
float ElevM(int x, int y) => MathF.Max(0f, (height[x, y] - seaAt(x, y)) * M_PER_UNIT);
var pq = new PriorityQueue<int, (float c, int i)>();
int start = sx * n + sy;
gcost[start] = 0f;
pq.Enqueue(start, (0f, start));
int goal = -1;
while (pq.Count > 0)
{
int c = pq.Dequeue();
if (closed[c]) continue;
closed[c] = true;
if (targets[c]) { goal = c; break; }
int cx = c / n, cy = c % n;
float hc = height[cx, cy];
for (int k = 0; k < 8; k++)
{
int nx = cx + DX[k], ny = cy + DY[k];
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
int ni = nx * n + ny;
if (closed[ni]) continue;
float dhM = MathF.Max(0f, (height[nx, ny] - hc) * M_PER_UNIT);
float step = style == STYLE_SHORT
? DIST[k] + dhM * SHORT_UPHILL_PER_M
: DIST[k] * (LOWGROUND_BASE + ElevM(nx, ny) * LOWGROUND_ELEV_PER_M)
+ dhM * LOWGROUND_UPHILL_PER_M;
float nc = gcost[c] + step;
if (nc < gcost[ni])
{
gcost[ni] = nc;
parent[ni] = c;
pq.Enqueue(ni, (nc, ni));
}
}
}
var path = new List<(float x, float y)>();
if (goal >= 0)
{
for (int c = goal; c >= 0; c = parent[c])
path.Add((c / n, c % n));
path.Reverse();
}
return path;
}
private static void CarveTributary(DrainageAnalysis.Stream trib, float[,] height, int n,
Func<int, int, float> seaAt, float coreSq, float craterCx, float craterCy,
Params p, Stats stats)
{
var course = new List<(float x, float y)>(trib.Course);
course.Reverse(); // head → confluence
CarveRiver(null, "tributary", trib.DrainageAreaPx, course, null,
height, n, seaAt, coreSq, craterCx, craterCy, p, stats);
}
/// <summary>
/// Carves one river: densify the course, build a monotone-descending clamped
/// bed profile, stamp the channel. Returns the per-river stat (also appended
/// to stats.Rivers unless name is null — tributaries fold into the totals).
/// </summary>
private static RiverStat CarveRiver(string name, string kind, long drainagePx,
List<(float x, float y)> upland, List<(float x, float y)> lowlandRoute,
float[,] height, int n, Func<int, int, float> seaAt,
float coreSq, float craterCx, float craterCy, Params p, Stats stats)
{
// Full head→mouth polyline: upland stem, then the lowland reach if any.
// ONLY the lowland reach is smoothed: the Dijkstra 45° kinks live there, on
// near-flat ground where a rounded corner costs nothing. The upland stems
// already thread the carved valley FLOORS — smoothing them off-line cut
// valley walls (measured: max cut 14.6 → 27.3 m before this was split).
var pts = new List<(float x, float y)>(upland);
if (lowlandRoute != null && lowlandRoute.Count > 1)
{
var smoothedRoute = SmoothCourse(lowlandRoute);
pts.AddRange(smoothedRoute.GetRange(1, smoothedRoute.Count - 1));
}
// Densify to ~1-px samples (plan courses are decimated ×4).
var dense = new List<(float x, float y)>();
for (int i = 0; i + 1 < pts.Count; i++)
{
var a = pts[i]; var b = pts[i + 1];
float segLen = MathF.Sqrt((b.x - a.x) * (b.x - a.x) + (b.y - a.y) * (b.y - a.y));
int steps = Math.Max(1, (int)MathF.Ceiling(segLen));
for (int s2 = 0; s2 < steps; s2++)
dense.Add((a.x + (b.x - a.x) * s2 / steps, a.y + (b.y - a.y) * s2 / steps));
}
if (pts.Count > 0) dense.Add(pts[^1]);
if (dense.Count < 2) return new RiverStat();
float sizeFactor = MathF.Sqrt(drainagePx / 1_000_000f);
var rs = new RiverStat
{
Name = name, Kind = kind, DrainagePx = drainagePx,
CourseLenPx = dense.Count,
RouteLenPx = lowlandRoute?.Count ?? 0
};
if (lowlandRoute != null && lowlandRoute.Count > 1)
{
var a = lowlandRoute[0]; var b = lowlandRoute[^1];
rs.RouteStraightPx = MathF.Sqrt((b.x - a.x) * (b.x - a.x) + (b.y - a.y) * (b.y - a.y));
// Wander = POLYLINE length over straight-line — cell count undercounts
// diagonal steps and can read below 1, which is geometrically impossible.
float polyLen = 0f;
for (int i = 1; i < lowlandRoute.Count; i++)
{
float sdx = lowlandRoute[i].x - lowlandRoute[i - 1].x;
float sdy = lowlandRoute[i].y - lowlandRoute[i - 1].y;
polyLen += MathF.Sqrt(sdx * sdx + sdy * sdy);
}
rs.RouteLenPx = (int)polyLen;
rs.WanderRatio = rs.RouteStraightPx > 1f ? polyLen / rs.RouteStraightPx : 1f;
}
// Bed profile: raw = terrain depth(t); then monotone non-increasing
// downstream; then clamped to sea + margin. The clamp can flatten the tail
// near the mouth — allowed: non-increasing is what water needs, and the
// flood guard is absolute.
int m = dense.Count;
var bed = new float[m];
var depth = new float[m];
var halfW = new float[m];
for (int i = 0; i < m; i++)
{
float t = m > 1 ? (float)i / (m - 1) : 1f;
depth[i] = (DEPTH_HEAD_M + (DEPTH_MOUTH_M * sizeFactor - DEPTH_HEAD_M) * t) * p.DepthScale;
if (depth[i] < 0.5f) depth[i] = 0.5f;
halfW[i] = (HALFWIDTH_HEAD_PX + (HALFWIDTH_MOUTH_PX * sizeFactor - HALFWIDTH_HEAD_PX) * t) * p.WidthScale;
if (halfW[i] < 1.5f) halfW[i] = 1.5f;
int cx = (int)dense[i].x, cy = (int)dense[i].y;
bed[i] = height[cx, cy] - depth[i] / M_PER_UNIT;
}
for (int i = 1; i < m; i++)
{
float maxAllowed = bed[i - 1] - MIN_BED_SLOPE / M_PER_UNIT;
if (bed[i] > maxAllowed) bed[i] = maxAllowed;
}
for (int i = 0; i < m; i++)
{
int cx = (int)dense[i].x, cy = (int)dense[i].y;
float floor = seaAt(cx, cy) + p.SeaMarginM / M_PER_UNIT;
if (bed[i] < floor) bed[i] = floor;
}
// Stamp: parabolic channel to the rim, smoothstep shoulder back to terrain.
for (int i = 0; i < m; i++)
{
float hw = halfW[i];
float outer = hw * 2f;
int cx0 = (int)MathF.Floor(dense[i].x - outer), cx1 = (int)MathF.Ceiling(dense[i].x + outer);
int cy0 = (int)MathF.Floor(dense[i].y - outer), cy1 = (int)MathF.Ceiling(dense[i].y + outer);
float rimH = bed[i] + depth[i] / M_PER_UNIT;
for (int x = cx0; x <= cx1; x++)
{
if (x < 0 || x >= n) continue;
for (int y = cy0; y <= cy1; y++)
{
if (y < 0 || y >= n) continue;
float rx = x - dense[i].x, ry = y - dense[i].y;
float r = MathF.Sqrt(rx * rx + ry * ry);
if (r > outer) continue;
float ddx = x - craterCx, ddy = y - craterCy;
if (ddx * ddx + ddy * ddy < coreSq) continue; // crater core protected
float sea = seaAt(x, y);
float old = height[x, y];
if (old < sea) continue; // below-sea cells read-only
float target;
if (r <= hw)
{
float f = r / hw;
target = bed[i] + (depth[i] / M_PER_UNIT) * f * f;
}
else
{
float f = (r - hw) / hw; // 0..1 across the shoulder
f = f * f * (3f - 2f * f); // smoothstep
target = rimH + (old - rimH) * f;
}
float floor = sea + p.SeaMarginM / M_PER_UNIT;
if (target < floor) target = floor;
if (target < old)
{
float cutM = (old - target) * M_PER_UNIT;
// Cumulative depth vs the PRE-PASS surface (nit 1): the
// honest "how deep did we cut here in total" number.
float cumM = (stats.PrePass[x * n + y] - target) * M_PER_UNIT;
height[x, y] = target;
stats.CarvedCells++;
stats.CarvedVolumeM3 += cutM;
if (cumM > stats.MaxCutM) stats.MaxCutM = cumM;
if (cumM > rs.MaxCutM) rs.MaxCutM = cumM;
rs.VolumeM3 += cutM;
}
}
}
}
if (name != null)
{
stats.Rivers.Add(rs);
stats.Carved.Add(new CarvedRiver
{
Name = name, Kind = kind, DrainagePx = drainagePx,
Dense = dense, Bed = bed, HalfW = halfW
});
}
return rs;
}
/// <summary>
/// The stepped-water builder (task 23, part 2b): segments each carved main
/// river into REACHES — flat water bodies stepping down the bed toward the
/// outlet — and stamps their ids into the WBID grid. Reuses the existing
/// levels-not-cells water model exactly: one body per reach, one flat level
/// each; the writer derives WSRF from body levels as it always has. The step
/// drops are the smoothing dial (smaller drop = more, finer steps); tilted
/// water is the deferred model B and is NOT built here.
///
/// Emission is plain data (no engine types): the caller turns reaches into
/// WBTB entries. Wet cells: inside the channel half-width, currently dry in
/// WBID, at/above sea (below-sea cells belong to the ocean/crater-seam rule),
/// bed below the reach level. Existing water bodies are never overwritten —
/// a river MEETS a lake or the sea, it does not repaint them.
/// </summary>
public class Reach
{
public ushort Id;
public string River;
public float Level; // raw units
public int PixelCount;
public double Cx, Cy; // centroid accumulators → mean
}
public static List<Reach> AddSteppedWater(float[,] height, int mapSize,
ushort[,] wbid, ushort firstId, List<CarvedRiver> rivers,
float[,] seaMap, float seaFlat, float craterCx, float craterCy,
float craterCoreRadius, float stepDropM, float waterDepthM)
{
int n = mapSize;
float SeaAt(int x, int y) => seaMap != null ? seaMap[x, y] : seaFlat;
float coreSq = craterCoreRadius * craterCoreRadius;
var reaches = new List<Reach>();
ushort nextId = firstId;
foreach (var r in rivers)
{
int m = r.Dense.Count;
if (m < 2) continue;
int i = 0;
float lastLevel = float.MaxValue;
while (i < m)
{
// Reach spans from i while the bed stays within stepDropM of the
// reach's starting bed; its flat level sits waterDepthM above that
// start (deepening toward the next step — the pool behind a riffle).
float startBed = r.Bed[i];
float level = startBed + waterDepthM / M_PER_UNIT;
if (level >= lastLevel) // enforce strict descent
level = lastLevel - 0.01f / M_PER_UNIT;
int j = i;
while (j < m && r.Bed[j] > startBed - stepDropM / M_PER_UNIT) j++;
var reach = new Reach { Id = nextId, River = r.Name, Level = level };
for (int k2 = i; k2 < j; k2++)
{
float hw = r.HalfW[k2];
int x0 = (int)MathF.Floor(r.Dense[k2].x - hw), x1 = (int)MathF.Ceiling(r.Dense[k2].x + hw);
int y0 = (int)MathF.Floor(r.Dense[k2].y - hw), y1 = (int)MathF.Ceiling(r.Dense[k2].y + hw);
for (int x = x0; x <= x1; x++)
{
if (x < 0 || x >= n) continue;
for (int y = y0; y <= y1; y++)
{
if (y < 0 || y >= n) continue;
if (wbid[x, y] != 0) continue; // never repaint existing water
float rx = x - r.Dense[k2].x, ry = y - r.Dense[k2].y;
if (rx * rx + ry * ry > hw * hw) continue;
float ddx = x - craterCx, ddy = y - craterCy;
if (ddx * ddx + ddy * ddy < coreSq) continue;
float h = height[x, y];
float sea = SeaAt(x, y);
if (h < sea) continue; // ocean/seam territory
if (h >= level) continue; // bank above the water line
wbid[x, y] = nextId;
reach.PixelCount++;
reach.Cx += x; reach.Cy += y;
}
}
}
if (reach.PixelCount > 0)
{
reach.Cx /= reach.PixelCount; reach.Cy /= reach.PixelCount;
reaches.Add(reach);
nextId++;
lastLevel = level;
}
i = j;
}
}
return reaches;
}
}