Replaces terminate-at-first with chaining through the basin graph (rivers/04): one D8 field on FullFilled per seed; each promoted river follows it from its terminal, checked at every basin entered against the floor→spill climb (SpillClimbM) vs ISLA_FLOW_CAP_M (30) — overflow or wall. Lake basins are entered on the real terrain (Plan.Dir, rivers/03c fix B fallback), crossed as water to the entered body's lowest-FullFilled outlet, left over the spill. Keep on OceanMask / IsLake, drop on dry or puddle-only, read through the rivers/03b confluence root (reused verbatim). The field at the cap (walled basins re-pointed onto the real terrain), its accumulation and every cell's destination; hero-lake candidates ranked as data. HydrologyRenderer: the showpiece map on the atlas relief and the flow-direction data map. Heights digested and asserted; nothing filled, nothing carved. RiverRouting.Confluence and DrainageRenderer.LabelPlacer private→internal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EppUMXNhSeuA5Mu51UnTyP
754 lines
37 KiB
C#
754 lines
37 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using IslaApocalypse.Core;
|
||
|
||
namespace IslaApocalypse.Tools
|
||
{
|
||
/// <summary>
|
||
/// ⭐⭐ LOWLAND ROUTING (rivers/03) — the ROUTING PORTION of the reference's `RiverCarvePass`,
|
||
/// ported faithfully (D-050). **Courses only. This file reads heights and writes none.**
|
||
///
|
||
/// ═══ ⛔ THE RED LINE ═══
|
||
///
|
||
/// **Nothing here fills water, creates a water body, or mutates any height field.** It produces
|
||
/// polylines. The bed CARVE (`CarveRiver`, mutates render height, flood-guarded) and the STEPPED
|
||
/// WATER model (`AddSteppedWater`, creates bodies) are the reference's separate stages and are
|
||
/// separate later tasks. Verified at rivers/03 Part 0: in the reference, routing is pure — the
|
||
/// carve mutates, and `AddSteppedWater` is a call the CALLER makes afterwards, not something
|
||
/// `Apply` does. Lake-enders target EXISTING classify water; no lake is ever created.
|
||
///
|
||
/// ═══ ⭐ WHY THE COST MODEL IS THE LOAD-BEARING PIECE ═══
|
||
///
|
||
/// **An endorheic terminal is a local minimum by definition** — a downhill path out of it does not
|
||
/// exist, so "can it flow to the sea?" cannot be answered by descent. It is answered by cost: the
|
||
/// cheapest LOWGROUND path is allowed to climb over the basin's rim, paying heavily for it
|
||
/// (uphill penalised, never forbidden). That is the route-version of an overflow channel — a
|
||
/// channel over the spill, **with no water filled**.
|
||
///
|
||
/// SHORT cost ≈ distance, uphill lightly penalised — heads direct, avoids walls. (Rejected
|
||
/// by the reference's own gate as "a dead-straight canal"; ported for completeness.)
|
||
/// LOWGROUND cost ≈ BEING high (per px of travel) plus heavily for CLIMBING, so the cheapest
|
||
/// corridor is the lowest ground even when that wanders. **The locked style.**
|
||
///
|
||
/// ═══ ⚠⚠ THE CONSTANTS ARE DECLARED == EFFECTIVE, AND THAT WAS CHECKED ═══
|
||
///
|
||
/// `00_ground` warned that the reference's effective river tunables live in `ConfigManager`, not in
|
||
/// the `Params` initializers (WidthScale 1.0→1.75, DepthScale 1.0→1.5). **Those are carve-time and
|
||
/// out of scope here.** The four ROUTING cost constants below are `private const` inside
|
||
/// `RiverCarvePass` with no `ConfigManager` key and no `[Export]` anywhere in the reference repo —
|
||
/// verified by grep at rivers/03 Part 0 — so for routing, declared IS effective. The one routing
|
||
/// value that does come from config is the STYLE, effective `"lowground"`, which equals the
|
||
/// declared default.
|
||
/// </summary>
|
||
public static class RiverRouting
|
||
{
|
||
public const byte StyleShort = 0;
|
||
public const byte StyleLowground = 1;
|
||
|
||
// ⚠ Ported verbatim. SHORT pays lightly for climbing (8 per metre of rise, so a 10 m wall costs
|
||
// like an 80 px detour). LOWGROUND pays for BEING high (1 per metre of elevation per px) plus
|
||
// heavily for climbing (50 per metre).
|
||
public const float ShortUphillPerM = 8f;
|
||
public const float LowgroundElevPerM = 1f;
|
||
public const float LowgroundBase = 0.05f;
|
||
public const float LowgroundUphillPerM = 50f;
|
||
|
||
/// <summary>The reference's smallest water body a lake-ender may target (`RiverLakeMinTargetPx`,
|
||
/// effective 20,000 — declared and config agree). "Nearest wet pixel" routed one into a 3-cell
|
||
/// puddle a few hundred px short of the obvious lagoon; that was the task-23 gate finding.</summary>
|
||
public const int LakeMinTargetPx = 20_000;
|
||
|
||
// 8-connectivity in the reference's exact order — the tie-break structure is part of the result.
|
||
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 };
|
||
|
||
/// <summary>One lowland route, with the diagnostics the gate needs to judge it.</summary>
|
||
public sealed class Route
|
||
{
|
||
/// <summary>Terminal → target, 1-px steps, as Dijkstra produced it. Empty when no path exists.</summary>
|
||
public List<(float x, float y)> Path = new();
|
||
/// <summary>The same reach after RDP + Chaikin. This is what is drawn and spliced.</summary>
|
||
public List<(float x, float y)> Smoothed = new();
|
||
/// <summary>⭐ Did a path exist at all? Empty list on no path — never thrown.</summary>
|
||
public bool Reached;
|
||
/// <summary>Dijkstra cost at the goal (cost-model units, not metres).</summary>
|
||
public float Cost;
|
||
/// <summary>⭐⭐ THE RIM: the largest single-step climb on the route, metres. The number that
|
||
/// says whether a route crawls over a saddle or vaults a wall.</summary>
|
||
public float MaxStepUphillM;
|
||
/// <summary>⭐ Total metres climbed along the route, and how many steps climbed at all.</summary>
|
||
public float TotalUphillM;
|
||
public int UphillSteps;
|
||
/// <summary>Highest point on the route, metres above sea — the rim's absolute height.</summary>
|
||
public float MaxElevM;
|
||
/// <summary>Net climb from the terminal to the route's high point, metres — what "over the rim" costs.</summary>
|
||
public float RimClimbM;
|
||
/// <summary>⭐ WHERE the route tops out — the rim cell, ringed on the plate.</summary>
|
||
public (float x, float y) RimPoint;
|
||
public float LenPx, StraightPx, WanderRatio;
|
||
/// <summary>Cells settled by the search — the honest cost of a Dijkstra at this map size.</summary>
|
||
public long Expanded;
|
||
public (float x, float y) Target;
|
||
}
|
||
|
||
/// <summary>
|
||
/// ⭐ Deterministic Dijkstra from a start cell to the nearest cell of <paramref name="targets"/>
|
||
/// under the selected cost model. Ported from `RiverCarvePass.RouteToOcean`.
|
||
///
|
||
/// ⚠ **Returns an empty path when no path exists — it never throws.** That contract is
|
||
/// load-bearing: "no affordable route" is a RESULT (the river is a lake-ender), not an error.
|
||
///
|
||
/// ⚠ `targets` is a generic mask: `OceanMask` for a route to the sea, significant-water for a
|
||
/// lake-ender's extension. One routine, two uses — as the reference has it.
|
||
///
|
||
/// Determinism: the priority is `(cost, cellIndex)`, so equal costs break on the lower index and
|
||
/// the result cannot depend on heap internals. The search settles a cell once (`closed`) and
|
||
/// stops the moment it DEQUEUES a target, so the first target reached is the cheapest.
|
||
/// </summary>
|
||
public static Route RouteTo(float[,] height, int n, bool[] targets, int sx, int sy, byte style, float sea)
|
||
{
|
||
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);
|
||
|
||
// ⚠ Elevation is clamped at sea: below-sea ground is not "cheaper than sea level", it is sea
|
||
// level. Without the clamp a route would dive for the deepest hole it could find.
|
||
float ElevM(int x, int y) => MathF.Max(0f, WorldScale.MetresFromRaw(height[x, y] - sea));
|
||
|
||
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;
|
||
long expanded = 0;
|
||
|
||
while (pq.Count > 0)
|
||
{
|
||
int c = pq.Dequeue();
|
||
if (closed[c]) continue;
|
||
closed[c] = true;
|
||
expanded++;
|
||
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, WorldScale.MetresFromRaw(height[nx, ny] - hc));
|
||
float step = style == StyleShort
|
||
? DIST[k] + dhM * ShortUphillPerM
|
||
: DIST[k] * (LowgroundBase + ElevM(nx, ny) * LowgroundElevPerM)
|
||
+ dhM * LowgroundUphillPerM;
|
||
float nc = gcost[c] + step;
|
||
if (nc < gcost[ni])
|
||
{
|
||
gcost[ni] = nc;
|
||
parent[ni] = c;
|
||
pq.Enqueue(ni, (nc, ni));
|
||
}
|
||
}
|
||
}
|
||
|
||
var r = new Route { Expanded = expanded };
|
||
if (goal < 0) return r; // no path — an empty route, reported upstream
|
||
|
||
for (int c = goal; c >= 0; c = parent[c]) r.Path.Add((c / n, c % n));
|
||
r.Path.Reverse();
|
||
r.Reached = true;
|
||
r.Cost = gcost[goal];
|
||
r.Target = r.Path[^1];
|
||
Measure(r, height, n, sea);
|
||
r.Smoothed = SmoothCourse(r.Path);
|
||
return r;
|
||
}
|
||
|
||
/// <summary>
|
||
/// The diagnostics the gate reads — measured on the RAW path, before smoothing, because the
|
||
/// rim it crossed is a fact about the terrain and must not be a function of the pretty pass.
|
||
/// </summary>
|
||
private static void Measure(Route r, float[,] height, int n, float sea)
|
||
{
|
||
float startElev = ElevAt(r.Path[0]);
|
||
float maxElev = startElev;
|
||
r.RimPoint = r.Path[0];
|
||
for (int i = 1; i < r.Path.Count; i++)
|
||
{
|
||
var a = r.Path[i - 1]; var b = r.Path[i];
|
||
float dx = b.x - a.x, dy = b.y - a.y;
|
||
r.LenPx += MathF.Sqrt(dx * dx + dy * dy);
|
||
float climb = ElevAt(b) - ElevAt(a);
|
||
if (climb > 0f) { r.TotalUphillM += climb; r.UphillSteps++; }
|
||
if (climb > r.MaxStepUphillM) r.MaxStepUphillM = climb;
|
||
if (ElevAt(b) > maxElev) { maxElev = ElevAt(b); r.RimPoint = b; }
|
||
}
|
||
r.MaxElevM = maxElev;
|
||
r.RimClimbM = maxElev - startElev;
|
||
var s = r.Path[0]; var e = r.Path[^1];
|
||
r.StraightPx = MathF.Sqrt((e.x - s.x) * (e.x - s.x) + (e.y - s.y) * (e.y - s.y));
|
||
// ⚠ Wander is POLYLINE length over straight-line — a cell count undercounts diagonal steps
|
||
// and can read below 1, which is geometrically impossible. (The reference's own fix.)
|
||
r.WanderRatio = r.StraightPx > 1f ? r.LenPx / r.StraightPx : 1f;
|
||
|
||
float ElevAt((float x, float y) p) =>
|
||
MathF.Max(0f, WorldScale.MetresFromRaw(height[(int)p.x, (int)p.y] - sea));
|
||
}
|
||
|
||
// ---- Route smoothing — ported verbatim: RDP(4.0) + 4 Chaikin passes, endpoints pinned ------
|
||
//
|
||
// ⚠⚠ THIS IS APPLIED TO THE LOWLAND REACH ONLY, NEVER THE UPLAND STEM, and that split is not a
|
||
// style preference — it is a measured result. The Dijkstra's 45° kinks live on near-flat ground
|
||
// where a rounded corner costs nothing. The upland stems already thread the erosion-carved
|
||
// valley FLOORS; smoothing them cuts the corners off the valleys themselves, which in the
|
||
// reference took the max cut from 14.6 m to 27.3 m.
|
||
|
||
/// <summary>RDP tol 4 + 4 Chaikin corner-cutting passes, endpoints pinned.</summary>
|
||
public 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>The three classes the MIX is made of.</summary>
|
||
public enum RiverClass
|
||
{
|
||
/// <summary>Sea-reaching already, exactly as erosion carved it. No lowland route needed.</summary>
|
||
OceanTrunk,
|
||
/// <summary>An endorheic basin connected to the coast by a routed over-the-rim channel.</summary>
|
||
RoutedGiant,
|
||
/// <summary>Stays inland: terminates at a significant lake, or at its own terminal.</summary>
|
||
LakeEnder,
|
||
|
||
// ═══ rivers/03b — two new termini, from the three approved DIVERGENCES ═══
|
||
|
||
/// <summary>⭐ rivers/03b (fix 3): a dry-basin router that reached a SIGNIFICANT LAKE before it
|
||
/// reached the sea, and terminates there. In the reference a router targets ocean only, so it
|
||
/// would skirt the lake and carry on — which is what this corrects. **No water is created.**</summary>
|
||
LakeFed,
|
||
|
||
/// <summary>⚠ rivers/03b (fix 1): a dry-basin router whose cheapest route to the sea had to
|
||
/// climb a rim HIGHER THAN THE CAP. The reference routes at any cost, which produced an
|
||
/// uphill river over a 66.7 m wall. Refused: the course ends at its own terminal — a real
|
||
/// terminal basin. **Nothing is filled; it just ends there.**</summary>
|
||
WalledOff,
|
||
}
|
||
|
||
/// <summary>
|
||
/// ⚠⚠ THE THREE DELIBERATE DIVERGENCES FROM THE REFERENCE (rivers/03b), off by default.
|
||
///
|
||
/// Defaults reproduce rivers/03's faithful port EXACTLY — no cap, ocean-only targets, no
|
||
/// confluence — so that batch stays re-runnable bit-for-bit. The refinement task turns them on.
|
||
/// **None of these is a port. Each is a motivated correction of a faithful behaviour that
|
||
/// produced a physically-wrong result**, on the developer's explicit call.
|
||
/// </summary>
|
||
public sealed class Options
|
||
{
|
||
/// <summary>⭐ FIX 1 — the rim cap, metres. A route to the sea that must climb higher than
|
||
/// this above its terminal is refused and the river becomes a walled-off lake-ender.
|
||
/// Infinity = the reference's behaviour (route at any cost).</summary>
|
||
public float RimCapM = float.PositiveInfinity;
|
||
|
||
/// <summary>⭐ FIX 3 — include significant lakes in a ROUTER's target mask, so a river stops
|
||
/// at the nearer of {ocean, significant lake} instead of skirting a lake to reach the sea.
|
||
/// False = the reference's behaviour (routers target ocean only).</summary>
|
||
public bool LakeTargetForRouters;
|
||
|
||
/// <summary>
|
||
/// ⭐⭐ rivers/03c FIX A — lake-termination becomes a PREFERENCE instead of an unconditional
|
||
/// capture. Set > 0 to enable; it then supersedes the plain nearest-of-union rule above.
|
||
///
|
||
/// ⚠⚠ WHY rivers/03b OVERSHOT. "Nearest of {ocean ∪ lake}" lets a lake that is merely a
|
||
/// *little* closer capture a river that had a clear shot at the coast — and it moved **12
|
||
/// rivers** to lake-fed, roughly halving the island's sea mouths (5/5/6/5 → 4/2/3/3). The
|
||
/// rule here is deliberately sea-biased instead:
|
||
/// <code>
|
||
/// lake-fed iff cost_lake < LakePreferRatio × cost_ocean
|
||
/// </code>
|
||
/// so a lake must be MATERIALLY cheaper to reach, not just nearer. **Lower ratio → more sea
|
||
/// rivers.** Both costs are recorded per router whether or not the lake wins, so the knob can
|
||
/// be read off the table without a re-run.
|
||
/// </summary>
|
||
public float LakePreferRatio;
|
||
|
||
/// <summary>
|
||
/// ⭐⭐ rivers/03c FIX B — a NATURAL lake-ender terminates at the water inside its OWN
|
||
/// terminal basin, at any size.
|
||
///
|
||
/// ⚠ The 20,000 px significance threshold is what exiled `999999937 #3` from its own home:
|
||
/// its basin's lake was sub-threshold, so it marched ~5,800 px along the shoreline hunting a
|
||
/// distant "significant" body. A basin's own water is where its flow goes regardless of how
|
||
/// big it is. **The threshold still applies to ROUTERS choosing a DISTANT lake** — a dry
|
||
/// basin still cannot connect itself to a three-cell puddle.
|
||
/// </summary>
|
||
public bool OwnBasinLakeEnder;
|
||
|
||
/// <summary>⭐ FIX 2 — the confluence post-pass: courses laid biggest-first join on true cell
|
||
/// intersection instead of running as parallel duplicates to the same mouth.
|
||
/// False = the reference's behaviour (no dedup, no join).</summary>
|
||
public bool Confluence;
|
||
|
||
/// <summary>rivers/03's faithful settings — every divergence off.</summary>
|
||
public static Options Faithful => new();
|
||
}
|
||
|
||
/// <summary>One promoted river, classified, routed and assembled.</summary>
|
||
public sealed class RoutedRiver
|
||
{
|
||
public RiverCandidate Candidate;
|
||
public RiverClass Class;
|
||
/// <summary>The lowland reach actually used: the ocean route for a routed giant, the lake
|
||
/// route for a lake-ender. Null for trunks.</summary>
|
||
public Route Lowland;
|
||
/// <summary>⭐ The ocean route computed for EVERY giant, including lake-enders — see the note
|
||
/// on <see cref="RouteAll"/>. This is what makes an affordability threshold judgeable.</summary>
|
||
public Route OceanProbe;
|
||
/// <summary>Lake-enders: did the extension reach a SIGNIFICANT body (vs the classify fallback, vs nothing)?</summary>
|
||
public bool LakeReached, LakeWasFallback;
|
||
/// <summary>Head → terminus, stem + smoothed lowland reach.</summary>
|
||
public List<(float x, float y)> Course;
|
||
public string Why = "";
|
||
|
||
// ═══ rivers/03b ═══
|
||
|
||
/// <summary>⚠ The rim climb that was tested against the cap, and whether it was refused.</summary>
|
||
public float CappedRimM;
|
||
public bool RefusedByCap;
|
||
|
||
/// <summary>⭐ rivers/03c FIX A's lever, recorded for EVERY router — lake-fed or not — so the
|
||
/// developer can read off which ratio value flips which river without a re-run.
|
||
/// <see cref="CostRatio"/> is cost_lake / cost_ocean; a river is lake-fed iff it is below
|
||
/// the configured ratio. NaN where the leg was not reachable.</summary>
|
||
public float CostOcean = float.NaN, CostLake = float.NaN, CostRatio = float.NaN;
|
||
/// <summary>Lake-enders (fix B): the route to its own basin's water, for the coast-hugger check.</summary>
|
||
public bool OwnBasinTargeted;
|
||
/// <summary>The class this river WOULD have had under the reference's rules — so every
|
||
/// reclassification the divergences caused is legible rather than silent.</summary>
|
||
public RiverClass FaithfulClass;
|
||
|
||
/// <summary>The full course rasterised to cells — what the confluence test intersects on.</summary>
|
||
public List<(int x, int y)> CellPath;
|
||
/// <summary>⭐ What this river draws: its OWN reach, truncated at its junction if it joined.
|
||
/// The union of every river's own reach is the dendritic tree.</summary>
|
||
public List<(float x, float y)> OwnPath;
|
||
/// <summary>The rank of the river this one flows into, or 0 if it keeps its own terminus.</summary>
|
||
public int ConfluenceParentRank;
|
||
public bool Joined;
|
||
public (int x, int y) JunctionCell;
|
||
/// <summary>How many leading cells of <see cref="CellPath"/> are the NATURAL upland stem.
|
||
/// Everything after is the lowland reach routing added — the plate colours the two apart.</summary>
|
||
public int StemCells;
|
||
|
||
/// <summary>Does this river's own course end at the sea, before any confluence?</summary>
|
||
public bool ReachesSea => Class == RiverClass.OceanTrunk || Class == RiverClass.RoutedGiant;
|
||
}
|
||
|
||
/// <summary>
|
||
/// ⭐⭐ CLASSIFY AND ROUTE THE PROMOTED SET.
|
||
///
|
||
/// ═══ ⚠⚠⚠ WHAT DECIDES routed-vs-lake-ender, AND WHY IT IS NOT A PATH TEST ═══
|
||
///
|
||
/// rivers/03's task states the sort as *"an affordable over-the-rim LOWGROUND path to the ocean
|
||
/// exists → routed-through; none → lake-ender."* **Ported literally, that test classifies
|
||
/// everything as routed, because on an 8-connected grid with all-finite costs a path to the
|
||
/// ocean ALWAYS exists.** `RouteTo` returns empty only when the queue drains without reaching a
|
||
/// target, which cannot happen when the ocean is reachable at *some* price. There is no "none".
|
||
/// The word doing the work is *affordable*, and no threshold is specified anywhere.
|
||
///
|
||
/// **So the reference's sort is used, because it is the one that actually discriminates:**
|
||
/// <code>
|
||
/// Kind = (basinHasLake[id] && !SouthernCandidate) ? "lake-ender" : "routed"
|
||
/// </code>
|
||
/// i.e. **does the terminal basin hold classify water?** A basin that is already a lake is a
|
||
/// natural lake-ender; a dry pan gets routed to the sea. That is `DrainageAnalysis`'s own
|
||
/// verdict, carried on `Giant.Kind`, and this port consumes it rather than inventing a rule.
|
||
/// (v2 has no towns, so `southernPick` is −1 and the southern override never fires.)
|
||
///
|
||
/// ⭐ **And the missing threshold is surfaced rather than guessed:** the ocean route is computed
|
||
/// for EVERY giant, lake-enders included (<see cref="RoutedRiver.OceanProbe"/>), so the batch can
|
||
/// report what each one WOULD cost and how high a rim it WOULD have to cross. That turns
|
||
/// "affordable" from an unstated assumption into a number the developer can put a bar under.
|
||
/// **Nothing is locked here — the classification shown is the reference's.**
|
||
/// </summary>
|
||
public static List<RoutedRiver> RouteAll(List<RiverCandidate> promoted, float[,] height, int n,
|
||
bool[] isOcean, bool[] isClassifyWater, bool[] isSignificantWater, float sea, byte style,
|
||
Action<string> log, Options opt = null, int[] basinId = null)
|
||
{
|
||
opt ??= Options.Faithful;
|
||
if (opt.OwnBasinLakeEnder && basinId == null)
|
||
throw new InvalidOperationException(
|
||
"[RiverRouting] OwnBasinLakeEnder needs Plan.BasinId to know which water is a basin's OWN. " +
|
||
"Pass it; refusing to silently fall back to the distant-significant-body rule that produced the coast-hugger.");
|
||
|
||
// ⭐ FIX 3 — the router's target mask. With the divergence off this is the ocean alone, which
|
||
// is the reference. With it on, a significant lake is an equally valid place for a river to
|
||
// stop, so the Dijkstra halts at whichever it reaches first and a river can no longer skirt
|
||
// a lake on its way to a distant coast.
|
||
bool[] routerTargets = isOcean;
|
||
if (opt.LakeTargetForRouters)
|
||
{
|
||
routerTargets = new bool[n * n];
|
||
for (int i = 0; i < routerTargets.Length; i++)
|
||
routerTargets[i] = isOcean[i] || isSignificantWater[i];
|
||
}
|
||
|
||
var outp = new List<RoutedRiver>();
|
||
foreach (var c in promoted)
|
||
{
|
||
var rr = new RoutedRiver { Candidate = c };
|
||
|
||
if (c.IsSea)
|
||
{
|
||
// A natural ocean trunk needs no lowland route: erosion already carried it to the
|
||
// coast, and its outlet is ON the coast by construction. The stem IS the course.
|
||
rr.Class = RiverClass.OceanTrunk;
|
||
rr.Course = new List<(float x, float y)>(c.Course);
|
||
rr.Course.Reverse();
|
||
rr.Why = "sea outlet — erosion already reaches the coast; no lowland route needed";
|
||
outp.Add(rr);
|
||
log($" #{c.Rank,-3} {c.DrainagePx,10:N0} px TRUNK (natural, {rr.Course.Count} pts)");
|
||
continue;
|
||
}
|
||
|
||
// ⭐ The ocean probe, for every giant — the affordability evidence (rivers/03).
|
||
var probe = RouteTo(height, n, isOcean, c.TermX, c.TermY, style, sea);
|
||
rr.OceanProbe = probe;
|
||
|
||
// ⚠ `basinHasLake` is KEPT as the sort (rivers/03's finding): a basin that already holds a
|
||
// visible lake is a natural lake-ender and its river feeds its own lake — it is not routed
|
||
// anywhere. Only DRY basins are candidate routers. None of the three divergences touches this.
|
||
bool refLakeEnder = c.AnalysisKind == "lake-ender";
|
||
if (!refLakeEnder)
|
||
{
|
||
rr.FaithfulClass = RiverClass.RoutedGiant;
|
||
Route route;
|
||
bool stoppedAtLake;
|
||
|
||
if (opt.LakePreferRatio > 0f)
|
||
{
|
||
// ⭐⭐ rivers/03c FIX A — the two legs are costed SEPARATELY and compared, instead of
|
||
// racing in one search. That is the whole difference: a shared search returns
|
||
// whichever is nearer, this one returns the sea unless the lake is materially cheaper.
|
||
var lakeLeg = RouteTo(height, n, isSignificantWater, c.TermX, c.TermY, style, sea);
|
||
rr.CostOcean = probe.Reached ? probe.Cost : float.NaN;
|
||
rr.CostLake = lakeLeg.Reached ? lakeLeg.Cost : float.NaN;
|
||
rr.CostRatio = probe.Reached && lakeLeg.Reached && probe.Cost > 0f
|
||
? lakeLeg.Cost / probe.Cost : float.NaN;
|
||
|
||
// ⚠ No reachable lake → the sea, always. No reachable ocean → the lake if there is one.
|
||
stoppedAtLake = lakeLeg.Reached && probe.Reached
|
||
&& lakeLeg.Cost < opt.LakePreferRatio * probe.Cost;
|
||
if (lakeLeg.Reached && !probe.Reached) stoppedAtLake = true;
|
||
route = stoppedAtLake ? lakeLeg : probe;
|
||
}
|
||
else
|
||
{
|
||
// rivers/03b — the nearest of the union mask, whichever that turns out to be.
|
||
route = opt.LakeTargetForRouters
|
||
? RouteTo(height, n, routerTargets, c.TermX, c.TermY, style, sea)
|
||
: probe;
|
||
stoppedAtLake = route.Reached
|
||
&& isSignificantWater[(int)route.Target.x * n + (int)route.Target.y]
|
||
&& !isOcean[(int)route.Target.x * n + (int)route.Target.y];
|
||
}
|
||
|
||
rr.Lowland = route;
|
||
rr.CappedRimM = route.Reached ? route.RimClimbM : 0f;
|
||
|
||
if (!route.Reached)
|
||
{
|
||
rr.Class = RiverClass.RoutedGiant;
|
||
rr.Why = "dry pan → routed, but NO path to a target was found (unexpected — report)";
|
||
}
|
||
else if (stoppedAtLake)
|
||
{
|
||
// It ends at a significant lake — because that lake was nearer (03b) or materially
|
||
// cheaper (03c). NO WATER CREATED: the course simply ends at an existing body.
|
||
rr.Class = RiverClass.LakeFed;
|
||
rr.Why = opt.LakePreferRatio > 0f
|
||
? $"dry pan → LAKE-FED: reaching a significant lake costs {rr.CostLake:N0} vs {rr.CostOcean:N0} to the sea (ratio {rr.CostRatio:F3} < {opt.LakePreferRatio:F2}) — materially cheaper, so it ends at the lake"
|
||
: $"dry pan → reached a SIGNIFICANT LAKE at ({(int)route.Target.x},{(int)route.Target.y}) before the sea, {route.LenPx:F0} px away — terminates there (faithful: would have skirted it for the coast)";
|
||
}
|
||
else if (route.RimClimbM > opt.RimCapM)
|
||
{
|
||
// ⭐ FIX 1 — the cheapest way to the sea still climbs a wall. Refuse it. The course
|
||
// ends at its own terminal, a real terminal basin. NOTHING IS FILLED.
|
||
rr.Class = RiverClass.WalledOff;
|
||
rr.RefusedByCap = true;
|
||
rr.Lowland = null;
|
||
rr.Why = $"dry pan → WALLED OFF: cheapest route to the sea climbs {route.RimClimbM:F1} m > cap {opt.RimCapM:F0} m (cost {route.Cost:N0}) — ends at its own terminal";
|
||
}
|
||
else
|
||
{
|
||
rr.Class = RiverClass.RoutedGiant;
|
||
rr.Why = $"dry pan → routed to the SEA; rim climb {route.RimClimbM:F1} m ≤ cap {(float.IsInfinity(opt.RimCapM) ? "none" : opt.RimCapM.ToString("F0") + " m")}, cost {route.Cost:N0}" +
|
||
(opt.LakePreferRatio > 0f && !float.IsNaN(rr.CostRatio) ? $"; the nearest lake was not materially cheaper (ratio {rr.CostRatio:F3} ≥ {opt.LakePreferRatio:F2})" : "");
|
||
}
|
||
}
|
||
else
|
||
{
|
||
rr.Class = RiverClass.LakeEnder;
|
||
// The stem pools on dry ground short of its lake BECAUSE the pooling point is a local
|
||
// minimum — a blind descent dead-ends there immediately. Route with the same lowground
|
||
// Dijkstra so the course actually joins the water.
|
||
// ⚠ Lake-enders route with LOWGROUND regardless of the style knob (the reference's rule).
|
||
Route ext;
|
||
if (opt.OwnBasinLakeEnder)
|
||
{
|
||
// ⭐⭐ rivers/03c FIX B — its OWN basin's water, at any size. This is where its flow
|
||
// goes; it has no business hunting a distant body. Killing the coast-hugger outright.
|
||
var ownWater = new bool[n * n];
|
||
int owned = 0;
|
||
for (int i = 0; i < ownWater.Length; i++)
|
||
if (basinId[i] == c.BasinId && isClassifyWater[i] && !isOcean[i]) { ownWater[i] = true; owned++; }
|
||
if (owned > 0)
|
||
{
|
||
ext = RouteTo(height, n, ownWater, c.TermX, c.TermY, StyleLowground, sea);
|
||
rr.OwnBasinTargeted = ext.Reached;
|
||
}
|
||
else
|
||
{
|
||
// ⚠ Should not happen — basinHasLake is what put it in this branch — but a
|
||
// basin whose water is all ocean-masked would land here. Report, do not crash.
|
||
ext = new Route();
|
||
}
|
||
}
|
||
else
|
||
{
|
||
var far = RouteTo(height, n, isSignificantWater, c.TermX, c.TermY, StyleLowground, sea);
|
||
ext = far;
|
||
if (!ext.Reached)
|
||
{
|
||
// Fall back to ANY classify water, so a seed whose lake-ender genuinely has only
|
||
// small ponds still connects rather than dead-ending.
|
||
var fb = RouteTo(height, n, isClassifyWater, c.TermX, c.TermY, StyleLowground, sea);
|
||
if (fb.Reached) { ext = fb; rr.LakeWasFallback = true; }
|
||
}
|
||
}
|
||
if (ext.Reached) { rr.Lowland = ext; rr.LakeReached = true; }
|
||
rr.FaithfulClass = RiverClass.LakeEnder;
|
||
rr.Why = rr.LakeReached
|
||
? (rr.OwnBasinTargeted
|
||
? $"terminal basin holds classify water → natural lake-ender; terminates at its OWN basin's water {ext.LenPx:F0} px away (fix B — no distant-body hunt, so no coast-hugging)"
|
||
: $"terminal basin holds classify water → natural lake-ender; joins {(rr.LakeWasFallback ? "a small body (fallback)" : "a significant body")} {ext.LenPx:F0} px away")
|
||
: "terminal basin holds classify water → natural lake-ender; no water body reachable, course ends at its terminal";
|
||
}
|
||
|
||
rr.Course = Assemble(c.Course, rr.Lowland);
|
||
outp.Add(rr);
|
||
log($" #{c.Rank,-3} {c.DrainagePx,10:N0} px {ClassLabel(rr.Class),-11} " +
|
||
$"probe{(probe.Reached ? $" cost {probe.Cost,12:N0} rim {probe.RimClimbM,6:F1} m maxstep {probe.MaxStepUphillM,5:F2} m len {probe.LenPx,6:F0} px wander {probe.WanderRatio:F2}" : " NO PATH")}" +
|
||
$"{(rr.Class == RiverClass.LakeEnder ? $" | lake {(rr.LakeReached ? (rr.LakeWasFallback ? "fallback" : "significant") : "NONE")}" : "")}" +
|
||
$"{(rr.RefusedByCap ? " ⚠ REFUSED BY CAP" : "")}" +
|
||
$"{(rr.Class == RiverClass.LakeFed ? $" ⭐ LAKE-FED (ratio {rr.CostRatio:F3})" : "")}" +
|
||
$"{(!float.IsNaN(rr.CostRatio) && rr.Class == RiverClass.RoutedGiant ? $" → SEA (lake ratio {rr.CostRatio:F3})" : "")}" +
|
||
$"{(rr.OwnBasinTargeted ? $" ⭐ own-basin water, {rr.Lowland.LenPx:F0} px" : "")}");
|
||
}
|
||
|
||
if (opt.Confluence) Confluence(outp, log);
|
||
else foreach (var rr in outp) rr.OwnPath = rr.Course;
|
||
return outp;
|
||
}
|
||
|
||
public static string ClassLabel(RiverClass c) => c switch
|
||
{
|
||
RiverClass.OceanTrunk => "TRUNK",
|
||
RiverClass.RoutedGiant => "ROUTED",
|
||
RiverClass.LakeFed => "LAKE-FED",
|
||
RiverClass.WalledOff => "WALLED-OFF",
|
||
_ => "LAKE-ENDER",
|
||
};
|
||
|
||
// ═══ ⭐⭐ FIX 2 — THE CONFLUENCE POST-PASS (rivers/03b) ═══════════════════════════════════
|
||
//
|
||
// ⚠⚠ A DIVERGENCE, NOT A PORT. The reference lays every route independently and never dedups or
|
||
// joins them, which rivers/03 measured: on EVERY seed two routed rivers arrived at the identical
|
||
// ocean cell without ever having met. Two channels reaching the same mouth as parallel
|
||
// duplicates is not geography; two channels that meet and continue as one is.
|
||
//
|
||
// The rule, deliberately strict: courses are laid BIGGEST-FIRST by drainage, and a later course
|
||
// joins an earlier one only on TRUE CELL INTERSECTION — the later course's rasterised cell path
|
||
// actually reaching a cell an earlier one occupies. **Never proximity.** Two rivers running 3 px
|
||
// apart down the same valley stay two rivers; that is a question for the carve's channel width,
|
||
// not for routing to guess at.
|
||
|
||
/// <summary>
|
||
/// Join intersecting courses into a dendritic tree. Biggest-first, so the largest drainage is
|
||
/// the trunk and smaller ones become its tributaries — the later river is truncated at the
|
||
/// FIRST (most-upstream) cell it shares with an already-laid course, and adopts that course's
|
||
/// downstream and terminus from there.
|
||
/// </summary>
|
||
internal static void Confluence(List<RoutedRiver> rivers, Action<string> log)
|
||
{
|
||
var order = new List<RoutedRiver>(rivers);
|
||
order.Sort((a, b) => b.Candidate.DrainagePx.CompareTo(a.Candidate.DrainagePx));
|
||
|
||
// cell -> (the river occupying it, and how far along that river's cell path it sits)
|
||
var owner = new Dictionary<(int x, int y), (RoutedRiver river, int idx)>();
|
||
int joins = 0;
|
||
|
||
foreach (var r in order)
|
||
{
|
||
r.CellPath = Rasterise(r.Course);
|
||
var stemOnly = new List<(float x, float y)>(r.Candidate.Course);
|
||
stemOnly.Reverse();
|
||
r.StemCells = Rasterise(stemOnly).Count;
|
||
if (r.CellPath.Count == 0) { r.OwnPath = r.Course; continue; }
|
||
|
||
// The first cell of THIS course that someone bigger already occupies.
|
||
int hit = -1;
|
||
(RoutedRiver river, int idx) into = default;
|
||
for (int i = 0; i < r.CellPath.Count; i++)
|
||
if (owner.TryGetValue(r.CellPath[i], out into)) { hit = i; break; }
|
||
|
||
if (hit < 0)
|
||
{
|
||
// Keeps its own route and its own mouth.
|
||
r.OwnPath = r.Course;
|
||
for (int i = 0; i < r.CellPath.Count; i++)
|
||
if (!owner.ContainsKey(r.CellPath[i])) owner[r.CellPath[i]] = (r, i);
|
||
continue;
|
||
}
|
||
|
||
// ⭐ It joins. Truncate here and adopt the parent's downstream from the junction on.
|
||
var parent = into.river;
|
||
r.Joined = true;
|
||
r.ConfluenceParentRank = parent.Candidate.Rank;
|
||
r.JunctionCell = r.CellPath[hit];
|
||
joins++;
|
||
|
||
// What it DRAWS is its own reach only, up to the junction — the union of every river's
|
||
// own reach is the tree. Drawing the adopted downstream too would just overdraw the parent.
|
||
r.OwnPath = new List<(float x, float y)>();
|
||
for (int i = 0; i <= hit; i++) r.OwnPath.Add((r.CellPath[i].x, r.CellPath[i].y));
|
||
|
||
// The full course of record: its own reach, then the parent's from the junction to the sea.
|
||
var full = new List<(int x, int y)>();
|
||
for (int i = 0; i <= hit; i++) full.Add(r.CellPath[i]);
|
||
for (int i = into.idx + 1; i < parent.CellPath.Count; i++) full.Add(parent.CellPath[i]);
|
||
r.CellPath = full;
|
||
r.Course = new List<(float x, float y)>();
|
||
foreach (var cpt in full) r.Course.Add((cpt.x, cpt.y));
|
||
|
||
// Only its OWN reach becomes occupiable, so a third river can join this tributary.
|
||
for (int i = 0; i <= hit; i++)
|
||
if (!owner.ContainsKey(full[i])) owner[full[i]] = (r, i);
|
||
|
||
log($" ⭐ CONFLUENCE: #{r.Candidate.Rank} ({r.Candidate.DrainagePx:N0} px) joins #{parent.Candidate.Rank} " +
|
||
$"({parent.Candidate.DrainagePx:N0} px) at ({r.JunctionCell.x},{r.JunctionCell.y}) — " +
|
||
$"{hit} px of its own reach, then adopts #{parent.Candidate.Rank}'s downstream and terminus");
|
||
}
|
||
if (joins == 0) log(" (no confluences — every course keeps its own mouth)");
|
||
}
|
||
|
||
/// <summary>
|
||
/// ⭐ THE ROOT of a confluence chain — the river whose terminus this one actually ends at. A
|
||
/// tributary's mouth is its trunk's mouth, so this is what mouth counting and terminus class
|
||
/// must both be read through.
|
||
/// </summary>
|
||
public static RoutedRiver Root(RoutedRiver r, List<RoutedRiver> all)
|
||
{
|
||
var cur = r;
|
||
// The chain is finite and strictly increasing in drainage (biggest-first laying), so it
|
||
// cannot cycle; the guard is belt-and-braces against a future change to the ordering.
|
||
for (int guard = 0; guard < all.Count + 1 && cur.Joined; guard++)
|
||
{
|
||
RoutedRiver parent = null;
|
||
foreach (var o in all) if (o.Candidate.Rank == cur.ConfluenceParentRank) { parent = o; break; }
|
||
if (parent == null) break;
|
||
cur = parent;
|
||
}
|
||
return cur;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Rasterise a polyline to a deduped 1-px cell path. ⚠ The confluence test is a TRUE CELL
|
||
/// intersection, so the courses must be compared as the cells they occupy, not as the sparse
|
||
/// vertices the analysis decimated them to (stems are decimated ×4, routes are Chaikin-smoothed).
|
||
/// </summary>
|
||
private static List<(int x, int y)> Rasterise(List<(float x, float y)> pts)
|
||
{
|
||
var outp = new List<(int x, int y)>();
|
||
if (pts == null || pts.Count == 0) return outp;
|
||
void Push(int x, int y)
|
||
{
|
||
if (outp.Count > 0 && outp[^1].x == x && outp[^1].y == y) return;
|
||
outp.Add((x, y));
|
||
}
|
||
for (int i = 0; i + 1 < pts.Count; i++)
|
||
{
|
||
var a = pts[i]; var b = pts[i + 1];
|
||
float dx = b.x - a.x, dy = b.y - a.y;
|
||
int steps = Math.Max(1, (int)MathF.Ceiling(MathF.Max(MathF.Abs(dx), MathF.Abs(dy))));
|
||
for (int s = 0; s < steps; s++)
|
||
Push((int)MathF.Round(a.x + dx * s / steps), (int)MathF.Round(a.y + dy * s / steps));
|
||
}
|
||
Push((int)MathF.Round(pts[^1].x), (int)MathF.Round(pts[^1].y));
|
||
return outp;
|
||
}
|
||
|
||
/// <summary>
|
||
/// ⭐ Assemble one river's full course: upland stem (head → terminal) + the smoothed lowland
|
||
/// reach (terminal → target).
|
||
///
|
||
/// ⚠ `Course` from the analysis is DOWNSTREAM-FIRST and decimated ×4, so it is reversed to run
|
||
/// head → terminal, exactly as the reference does. The route's first point IS the terminal, so
|
||
/// it is skipped when splicing — otherwise the join carries a duplicate vertex.
|
||
///
|
||
/// ⚠ The reference then DENSIFIES the spliced polyline to ~1-px samples. That is done inside
|
||
/// `CarveRiver`, for the bed stamp — it is carve-time and deliberately not done here: this task
|
||
/// produces courses, and a densified polyline draws and measures identically.
|
||
/// </summary>
|
||
public static List<(float x, float y)> Assemble(List<(float x, float y)> uplandStem, Route lowland)
|
||
{
|
||
var pts = new List<(float x, float y)>(uplandStem);
|
||
pts.Reverse(); // downstream-first → head → terminal
|
||
if (lowland != null && lowland.Smoothed != null && lowland.Smoothed.Count > 1)
|
||
pts.AddRange(lowland.Smoothed.GetRange(1, lowland.Smoothed.Count - 1));
|
||
return pts;
|
||
}
|
||
}
|
||
}
|