islaApocalypse-v2/Tools/Scripts/RiverRouting.cs
beezm 4e4be6a83e rivers/03: lowland routing — the routed MIX on the pure N=12, courses only
Ports the ROUTING PORTION of the reference's RiverCarvePass (RouteToOcean, the
routed/lake-ender sort, SmoothCourse). NOT CarveRiver (bed stamp) and NOT
AddSteppedWater (water bodies) — those are later tasks.

RED LINE: no height mutated, no water filled, nothing carved. Asserted per seed
by an FNV digest of both height fields before/after routing.

- RiverRouting: deterministic LOWGROUND Dijkstra, uphill penalised so a route may
  cross the basin rim, empty-list-on-no-path. Effective == declared constants
  (verified: private const, no ConfigManager key, no [Export] in the reference).
- The sort is the REFERENCE's — Kind = basinHasLake ? lake-ender : routed. The
  task's stated "a path exists -> routed" cannot discriminate: on an 8-connected
  grid a path to the ocean always exists, confirmed empirically (43/43 probes
  reached). The ocean route is probed for every giant anyway, so the missing
  affordability threshold is reported as a number rather than guessed.
- RegionLabeling.SignificantWaterMask: interim substitute for v2's missing
  water-bodies table — 8-connected classify-water components >= 20,000 px.
- RiverCandidates: the candidate enumeration extracted out of RiverPromotionTool
  so routing ranks the identical set the count gate was judged on. Behaviour
  neutral — rivers/02b's twelve plates are byte-identical across the extraction.
- DrainageRenderer.RoutedMix: three classes, with each routed river's added
  lowland reach and the rim it crossed drawn distinctly from its natural stem.

Taste gate: no count, no K, no style, no default set.
2026-08-24 04:54:30 -04:00

400 lines
19 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;
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,
}
/// <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 = "";
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] &amp;&amp; !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)
{
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.
var probe = RouteTo(height, n, isOcean, c.TermX, c.TermY, style, sea);
rr.OceanProbe = probe;
bool refLakeEnder = c.AnalysisKind == "lake-ender";
if (!refLakeEnder)
{
rr.Class = RiverClass.RoutedGiant;
rr.Lowland = probe;
rr.Why = probe.Reached
? $"dry pan → routed; rim climb {probe.RimClimbM:F1} m, max step {probe.MaxStepUphillM:F2} m, cost {probe.Cost:N0}"
: "dry pan → routed, but NO path to the ocean was found (unexpected — report)";
}
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 to the nearest
// SIGNIFICANT body with the same lowground Dijkstra, so the course joins the lake.
// ⚠ Lake-enders route with LOWGROUND regardless of the style knob (the reference's rule).
var ext = RouteTo(height, n, isSignificantWater, c.TermX, c.TermY, StyleLowground, sea);
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.Why = rr.LakeReached
? $"terminal basin holds classify water → lake-ender; joins {(rr.LakeWasFallback ? "a small body (fallback)" : "a significant body")} {ext.LenPx:F0} px away"
: "terminal basin holds classify water → 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 {(rr.Class == RiverClass.RoutedGiant ? "ROUTED " : "LAKE-ENDER")} " +
$"probe{(probe.Reached ? $" 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} expanded {probe.Expanded:N0}" : " NO PATH")}" +
$"{(rr.Class == RiverClass.LakeEnder ? $" | lake {(rr.LakeReached ? (rr.LakeWasFallback ? "fallback" : "significant") : "NONE")}" : "")}");
}
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;
}
}
}