feat: drainage-network promotion analysis — the river PLAN (terrain-water task 21, C0b part 1)
Pure analysis over the ERODED render map: changes zero terrain, adds zero water. Priority-flood (task-03 family, Barnes heap+pit, 8-connected) with a one-ulp epsilon so every filled pit keeps a strictly descending routing path — the ~16.5k erosion pits route through; depressions >= 2 m deep and >= 10k px survive as terminal basins (70 on the working seed). D8 flow directions on that routing surface (D8 as a COMPUTATION, not the reverted carving use), Kahn-propagated flow accumulation, then promotion: TRUE-ocean outlets (WBID 1 only — two of the first draft's three 'sea-reaching' trunks actually ended in enclosed lagoons, which is exactly the overclaim the gate must not inherit) ranked by drainage area, top ~3 with outlet separation become trunks; max-accumulation stems; mountain-exit from sustained along-stem grade; lean deduped tributaries; lean endorheic terminals credited with per-basin TOTAL inflow (acc at the deepest cell undercounts flat lagoon beds 10x — measured). Output: console report + a JSON plan sidecar next to the source blueprint — deliberately NOT a blueprint section, so the plan cannot masquerade as realized water. The source .dat is never written (md5-verified). Headless tool, ~21 s analysis on 8K; RIVERPLAN_* env dials. Seed 1280587109 findings for the gate: 3 ocean trunks spread west/north/east (436k/409k/325k px); the island's five LARGEST systems (1.1M-2.3M px) are all endorheic — three end in big enclosed lagoons (classify lakes, 19-20 m basins), two in dry pans; 62.9% of land does not drain to the open ocean, the drainage restatement of task 18's 'erosion cannot cut the lowlands'. The 2.27M-px giant terminates in the SE lagoon ~1.1k px from the southernmost town (filed south report, not enforced). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
78e042b805
commit
ae97e48229
3 changed files with 691 additions and 0 deletions
6
Tools/Scenes/RiverPlanTool.tscn
Normal file
6
Tools/Scenes/RiverPlanTool.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
[gd_scene format=3 uid="uid://rvplantool21"]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://Tools/Scripts/RiverPlanTool.cs" id="1_rpt"]
|
||||||
|
|
||||||
|
[node name="RiverPlanTool" type="Node"]
|
||||||
|
script = ExtResource("1_rpt")
|
||||||
460
Tools/Scripts/DrainageAnalysis.cs
Normal file
460
Tools/Scripts/DrainageAnalysis.cs
Normal file
|
|
@ -0,0 +1,460 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drainage-network promotion — C0b part 1 (terrain-water task 21). PURE ANALYSIS:
|
||||||
|
/// reads the ERODED render heightmap and produces a river PLAN — it changes zero
|
||||||
|
/// terrain and adds zero water. Standalone numeric (D-035 family; no Godot types).
|
||||||
|
///
|
||||||
|
/// Pipeline, built on the task-03 priority-flood family:
|
||||||
|
/// 1. Priority-flood the eroded surface from the map border (Barnes heap+pit
|
||||||
|
/// variant, 8-connected, same as RunPriorityFloodDiagnostics) — but with a
|
||||||
|
/// one-ulp epsilon on pit fills, so every filled cell keeps a STRICTLY
|
||||||
|
/// descending path to its spill. This resolves the ~15,000 erosion pits
|
||||||
|
/// (task-20 finding) for ROUTING ONLY; the terrain itself is never modified.
|
||||||
|
/// 2. Depressions that are deep AND large enough (the endorheic dials) are NOT
|
||||||
|
/// filled through: their cells revert to original heights, so flow entering
|
||||||
|
/// them terminates at the basin minimum. Real closed drainage survives;
|
||||||
|
/// micro-pits route through.
|
||||||
|
/// 3. D8 flow directions on that routing surface. D8 was reverted as a CARVING
|
||||||
|
/// technique (task 10 — grid-aligned scratches in the terrain); using it to
|
||||||
|
/// COMPUTE where water flows is standard hydrology and leaves no mark.
|
||||||
|
/// 4. Flow accumulation by topological (Kahn) propagation — no sort needed.
|
||||||
|
/// 5. Promotion: outlets to the sea ranked by drainage area, top-N (separated)
|
||||||
|
/// become trunks; main stems traced upstream by max-accumulation; the
|
||||||
|
/// mountain-exit point found from the along-stem grade; LEAN tributaries and
|
||||||
|
/// LEAN endorheic terminals marked.
|
||||||
|
///
|
||||||
|
/// The plan's lowland courses are provisional: erosion delivered the UPLAND
|
||||||
|
/// network only (task 18 §3), so below each mountain-exit the traced course is
|
||||||
|
/// "where the routing surface drains", not a designed river. Part 2 (task 22)
|
||||||
|
/// routes the lowland reach properly from the mountain-exit points — which is why
|
||||||
|
/// those points are this analysis's key output.
|
||||||
|
/// </summary>
|
||||||
|
public static class DrainageAnalysis
|
||||||
|
{
|
||||||
|
public const float M_PER_UNIT = 251f;
|
||||||
|
|
||||||
|
// Neighbour order is FIXED (it is the deterministic tiebreak).
|
||||||
|
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 };
|
||||||
|
|
||||||
|
public class Params
|
||||||
|
{
|
||||||
|
// Endorheic qualification: a depression this deep AND this large is a real
|
||||||
|
// closed basin and terminates flow; anything smaller is a pit, filled through.
|
||||||
|
public float EndorheicMinDepthM = 2.0f;
|
||||||
|
public int EndorheicMinAreaPx = 10000;
|
||||||
|
// Endorheic REPORTING is lean: only terminals with at least this much
|
||||||
|
// upstream drainage, at most MaxCount of them.
|
||||||
|
public int EndorheicMinInflowPx = 50000;
|
||||||
|
public int EndorheicMaxCount = 3;
|
||||||
|
|
||||||
|
public int TrunkCount = 3; // ~3 sea-reaching trunks (developer)
|
||||||
|
public int MinOutletSeparationPx = 400; // don't pick 3 mouths of one delta
|
||||||
|
|
||||||
|
public int StemMinAccPx = 1000; // stem tracing stops below this
|
||||||
|
public int TributaryMinAccPx = 30000; // LEAN: a branch must drain this much
|
||||||
|
public int TributaryMaxPerTrunk = 4; // ...and only the top few are marked
|
||||||
|
|
||||||
|
// Mountain-exit: furthest-downstream stem point where the upstream window
|
||||||
|
// still sustains this grade (m per px) over ExitWindowPx.
|
||||||
|
public float ExitGradeMin = 0.05f;
|
||||||
|
public int ExitWindowPx = 100;
|
||||||
|
|
||||||
|
public float SeaLevel = 0.15f; // flat sea scalar (raw units)
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Stream
|
||||||
|
{
|
||||||
|
public List<(float x, float y)> Course = new(); // downstream-first
|
||||||
|
public long DrainageAreaPx;
|
||||||
|
public (float x, float y) Head; // upstream end
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Trunk : Stream
|
||||||
|
{
|
||||||
|
public (float x, float y) Outlet; // last land cell before sea
|
||||||
|
public (float x, float y) MountainExit;
|
||||||
|
public float MountainExitElevM;
|
||||||
|
public bool ExitFound;
|
||||||
|
public List<Stream> Tributaries = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class EndorheicTerminal
|
||||||
|
{
|
||||||
|
public (float x, float y) Terminal; // basin minimum
|
||||||
|
public long DrainageAreaPx;
|
||||||
|
public float BasinDepthM;
|
||||||
|
public long BasinAreaPx;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Plan
|
||||||
|
{
|
||||||
|
public List<Trunk> Trunks = new();
|
||||||
|
public List<EndorheicTerminal> Endorheics = new();
|
||||||
|
public int TerminalBasinCount; // basins that qualified as sinks
|
||||||
|
public long PitsFilledCount; // depressions filled through
|
||||||
|
public long LandCells, SeaReachingCells, EndorheicCells, UnroutedCells;
|
||||||
|
public List<(float x, float y, long acc)> AllOutletsTop = new(); // top 12, pre-separation
|
||||||
|
public Params P;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <param name="isOcean">Row-major mask of THE OCEAN body (WBID == 1) — the
|
||||||
|
/// only water that counts as "the sea" for sea-reaching trunks. Below-sea
|
||||||
|
/// cells that are NOT ocean (enclosed lagoons, below-datum lake beds) are
|
||||||
|
/// ordinary terrain to the router: as depressions they either qualify as
|
||||||
|
/// terminal basins (a river legitimately ENDING in a lagoon/lake — reported as
|
||||||
|
/// such) or fill and spill onward to the true sea. Without this mask the first
|
||||||
|
/// draft called two of its three "sea-reaching" trunks done at enclosed
|
||||||
|
/// lagoons, which is exactly the overclaim the gate must not inherit.</param>
|
||||||
|
public static Plan Run(float[,] height, int mapSize, bool[] isOcean, Params p)
|
||||||
|
{
|
||||||
|
int n = mapSize;
|
||||||
|
int total = n * n;
|
||||||
|
var plan = new Plan { P = p };
|
||||||
|
|
||||||
|
// 1-D row-major copies (idx = x * n + y), same convention as the task-03 pass.
|
||||||
|
float[] original = new float[total];
|
||||||
|
for (int x = 0; x < n; x++)
|
||||||
|
for (int y = 0; y < n; y++)
|
||||||
|
original[x * n + y] = height[x, y];
|
||||||
|
|
||||||
|
// --- 1. Priority-flood with one-ulp epsilon (routing surface only) ---
|
||||||
|
float[] filled = (float[])original.Clone();
|
||||||
|
{
|
||||||
|
bool[] visited = new bool[total];
|
||||||
|
var heap = new PriorityQueue<int, (float h, int idx)>();
|
||||||
|
var pit = new Queue<int>();
|
||||||
|
void Seed(int idx)
|
||||||
|
{
|
||||||
|
if (visited[idx]) return;
|
||||||
|
visited[idx] = true;
|
||||||
|
heap.Enqueue(idx, (filled[idx], idx)); // idx tiebreak => deterministic
|
||||||
|
}
|
||||||
|
for (int x = 0; x < n; x++) { Seed(x * n); Seed(x * n + (n - 1)); }
|
||||||
|
for (int y = 0; y < n; y++) { Seed(y); Seed((n - 1) * n + y); }
|
||||||
|
|
||||||
|
while (heap.Count > 0 || pit.Count > 0)
|
||||||
|
{
|
||||||
|
int c = pit.Count > 0 ? pit.Dequeue() : heap.Dequeue();
|
||||||
|
float fc = filled[c];
|
||||||
|
int cx = c / n, cy = c % n;
|
||||||
|
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 (visited[ni]) continue;
|
||||||
|
visited[ni] = true;
|
||||||
|
if (filled[ni] <= fc)
|
||||||
|
{
|
||||||
|
// One ulp above the parent: strictly descending back out, so
|
||||||
|
// D8 never meets an exact flat inside a filled pit.
|
||||||
|
filled[ni] = MathF.BitIncrement(fc);
|
||||||
|
pit.Enqueue(ni);
|
||||||
|
}
|
||||||
|
else heap.Enqueue(ni, (filled[ni], ni));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 2. Depression components; big+deep ones become terminal sinks ---
|
||||||
|
// Components of (filled > original), 8-connected — the pools. Qualifying
|
||||||
|
// pools revert to ORIGINAL height so flow terminates at their minimum.
|
||||||
|
int[] basinId = new int[total]; // 0 = not in a pool
|
||||||
|
var basinDepthM = new List<float> { 0f };
|
||||||
|
var basinAreaPx = new List<long> { 0L };
|
||||||
|
var basinMinCell = new List<int> { -1 };
|
||||||
|
{
|
||||||
|
var stack = new Stack<int>();
|
||||||
|
int nextId = 1;
|
||||||
|
for (int i = 0; i < total; i++)
|
||||||
|
{
|
||||||
|
if (basinId[i] != 0 || filled[i] <= original[i]) continue;
|
||||||
|
int id = nextId++;
|
||||||
|
long area = 0; float depth = 0f; int minCell = i; float minH = original[i];
|
||||||
|
stack.Push(i); basinId[i] = id;
|
||||||
|
while (stack.Count > 0)
|
||||||
|
{
|
||||||
|
int c = stack.Pop();
|
||||||
|
area++;
|
||||||
|
float d = (filled[c] - original[c]) * M_PER_UNIT;
|
||||||
|
if (d > depth) depth = d;
|
||||||
|
if (original[c] < minH) { minH = original[c]; minCell = c; }
|
||||||
|
int cx = c / n, cy = c % n;
|
||||||
|
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 (basinId[ni] == 0 && filled[ni] > original[ni])
|
||||||
|
{ basinId[ni] = id; stack.Push(ni); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
basinDepthM.Add(depth); basinAreaPx.Add(area); basinMinCell.Add(minCell);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool[] terminal = new bool[nextId];
|
||||||
|
for (int id = 1; id < nextId; id++)
|
||||||
|
{
|
||||||
|
if (basinDepthM[id] >= p.EndorheicMinDepthM && basinAreaPx[id] >= p.EndorheicMinAreaPx)
|
||||||
|
{ terminal[id] = true; plan.TerminalBasinCount++; }
|
||||||
|
else plan.PitsFilledCount++;
|
||||||
|
}
|
||||||
|
// Revert terminal pools to the real surface; re-tag basinId to keep only
|
||||||
|
// terminal pools (routing needs to know "am I in a terminal basin").
|
||||||
|
for (int i = 0; i < total; i++)
|
||||||
|
{
|
||||||
|
if (basinId[i] == 0) continue;
|
||||||
|
if (terminal[basinId[i]]) filled[i] = original[i];
|
||||||
|
else basinId[i] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3. D8 flow directions on the routing surface ---
|
||||||
|
// dir[i] = 0..7 neighbour, SEA (into a below-sea cell), or NONE (sink).
|
||||||
|
const sbyte D_NONE = -1, D_SEA = -2;
|
||||||
|
sbyte[] dir = new sbyte[total];
|
||||||
|
bool IsSea(int idx) => isOcean[idx];
|
||||||
|
for (int i = 0; i < total; i++)
|
||||||
|
{
|
||||||
|
if (IsSea(i)) { dir[i] = D_NONE; continue; }
|
||||||
|
int cx = i / n, cy = i % n;
|
||||||
|
float best = 0f; int bestK = -1; bool bestIsSea = false;
|
||||||
|
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;
|
||||||
|
float drop = (filled[i] - filled[ni]) / DIST[k];
|
||||||
|
if (drop > best) { best = drop; bestK = k; bestIsSea = IsSea(ni); }
|
||||||
|
}
|
||||||
|
dir[i] = bestK < 0 ? D_NONE : (bestIsSea ? D_SEA : (sbyte)bestK);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 4. Flow accumulation (Kahn topological propagation) ---
|
||||||
|
int Target(int i)
|
||||||
|
{
|
||||||
|
if (dir[i] < 0) return -1;
|
||||||
|
int cx = i / n, cy = i % n;
|
||||||
|
return (cx + DX[dir[i]]) * n + (cy + DY[dir[i]]);
|
||||||
|
}
|
||||||
|
int[] acc = new int[total];
|
||||||
|
{
|
||||||
|
byte[] indeg = new byte[total];
|
||||||
|
for (int i = 0; i < total; i++)
|
||||||
|
if (dir[i] >= 0) indeg[Target(i)]++;
|
||||||
|
var q = new Queue<int>();
|
||||||
|
for (int i = 0; i < total; i++)
|
||||||
|
{
|
||||||
|
if (IsSea(i)) continue;
|
||||||
|
acc[i] = 1;
|
||||||
|
if (indeg[i] == 0) q.Enqueue(i);
|
||||||
|
}
|
||||||
|
while (q.Count > 0)
|
||||||
|
{
|
||||||
|
int c = q.Dequeue();
|
||||||
|
if (dir[c] < 0) continue;
|
||||||
|
int t = Target(c);
|
||||||
|
acc[t] += acc[c];
|
||||||
|
if (--indeg[t] == 0 && !IsSea(t)) q.Enqueue(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bookkeeping: where does each cell's flow END — the sea, WHICH terminal
|
||||||
|
// basin, or stuck? Memoised downstream walk. The per-basin totals matter:
|
||||||
|
// crediting a terminal basin only with acc at its deepest cell undercounts
|
||||||
|
// badly when the basin floor is flat (a lagoon bed scatters inflow across
|
||||||
|
// many sub-minima — measured: a 500k-px lagoon system reported under 50k).
|
||||||
|
long[] basinInflow = new long[basinMinCell.Count];
|
||||||
|
{
|
||||||
|
int[] dest = new int[total]; // 0 unknown, -1 sea, -2 stuck, >0 basin id
|
||||||
|
var path = new List<int>(4096);
|
||||||
|
for (int i = 0; i < total; i++)
|
||||||
|
{
|
||||||
|
if (IsSea(i) || dest[i] != 0) continue;
|
||||||
|
int c = i; path.Clear();
|
||||||
|
int result;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
if (dest[c] != 0) { result = dest[c]; break; }
|
||||||
|
path.Add(c);
|
||||||
|
if (dir[c] == D_SEA) { result = -1; break; }
|
||||||
|
if (dir[c] == D_NONE) { result = basinId[c] != 0 ? basinId[c] : -2; break; }
|
||||||
|
c = Target(c);
|
||||||
|
}
|
||||||
|
foreach (int pc in path) dest[pc] = result;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < total; i++)
|
||||||
|
{
|
||||||
|
if (IsSea(i)) continue;
|
||||||
|
plan.LandCells++;
|
||||||
|
if (dest[i] == -1) plan.SeaReachingCells++;
|
||||||
|
else if (dest[i] > 0) { plan.EndorheicCells++; basinInflow[dest[i]]++; }
|
||||||
|
else plan.UnroutedCells++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 5a. Outlets: land cells whose flow enters the sea, ranked by acc ---
|
||||||
|
var outlets = new List<(int cell, long acc)>();
|
||||||
|
for (int i = 0; i < total; i++)
|
||||||
|
if (dir[i] == D_SEA) outlets.Add((i, acc[i]));
|
||||||
|
outlets.Sort((a, b) => b.acc.CompareTo(a.acc));
|
||||||
|
|
||||||
|
foreach (var (cell, a) in outlets.GetRange(0, Math.Min(12, outlets.Count)))
|
||||||
|
plan.AllOutletsTop.Add((cell / n, cell % n, a));
|
||||||
|
|
||||||
|
// Greedy top-N with separation, so three mouths of one delta can't take
|
||||||
|
// all three trunk slots.
|
||||||
|
var picked = new List<int>();
|
||||||
|
foreach (var (cell, _) in outlets)
|
||||||
|
{
|
||||||
|
if (picked.Count >= p.TrunkCount) break;
|
||||||
|
int cx = cell / n, cy = cell % n;
|
||||||
|
bool far = true;
|
||||||
|
foreach (int pcell in picked)
|
||||||
|
{
|
||||||
|
float ddx = cx - pcell / n, ddy = cy - pcell % n;
|
||||||
|
if (ddx * ddx + ddy * ddy < (float)p.MinOutletSeparationPx * p.MinOutletSeparationPx)
|
||||||
|
{ far = false; break; }
|
||||||
|
}
|
||||||
|
if (far) picked.Add(cell);
|
||||||
|
}
|
||||||
|
|
||||||
|
// upstream max-acc walk shared by trunks and tributaries
|
||||||
|
List<int> TraceStem(int fromCell, int minAcc)
|
||||||
|
{
|
||||||
|
var stem = new List<int> { fromCell };
|
||||||
|
int c = fromCell;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
int cx = c / n, cy = c % n;
|
||||||
|
int bestN = -1; long bestA = minAcc - 1;
|
||||||
|
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 (dir[ni] >= 0 && Target(ni) == c && acc[ni] > bestA)
|
||||||
|
{ bestA = acc[ni]; bestN = ni; }
|
||||||
|
}
|
||||||
|
if (bestN < 0) break;
|
||||||
|
stem.Add(bestN);
|
||||||
|
c = bestN;
|
||||||
|
}
|
||||||
|
return stem;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<(float x, float y)> Decimate(List<int> cells, int step = 4)
|
||||||
|
{
|
||||||
|
var pts = new List<(float, float)>();
|
||||||
|
for (int i = 0; i < cells.Count; i += step)
|
||||||
|
pts.Add((cells[i] / n, cells[i] % n));
|
||||||
|
if ((cells.Count - 1) % step != 0)
|
||||||
|
pts.Add((cells[^1] / n, cells[^1] % n));
|
||||||
|
return pts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 5b. Trunks: stems, mountain exits, LEAN tributaries ---
|
||||||
|
foreach (int outletCell in picked)
|
||||||
|
{
|
||||||
|
var t = new Trunk
|
||||||
|
{
|
||||||
|
Outlet = (outletCell / n, outletCell % n),
|
||||||
|
DrainageAreaPx = acc[outletCell]
|
||||||
|
};
|
||||||
|
var stem = TraceStem(outletCell, p.StemMinAccPx);
|
||||||
|
t.Course = Decimate(stem);
|
||||||
|
t.Head = (stem[^1] / n, stem[^1] % n);
|
||||||
|
|
||||||
|
// Mountain-exit: walk the stem downstream-first; the exit is the
|
||||||
|
// furthest-DOWNSTREAM point whose upstream window still sustains the
|
||||||
|
// grade — i.e. where the mountains hand the river to the flats.
|
||||||
|
// Elevation truth is the ORIGINAL eroded surface, not the fill.
|
||||||
|
int w = p.ExitWindowPx;
|
||||||
|
for (int i = 0; i + w < stem.Count; i++)
|
||||||
|
{
|
||||||
|
float rise = (original[stem[i + w]] - original[stem[i]]) * M_PER_UNIT;
|
||||||
|
if (rise / w >= p.ExitGradeMin)
|
||||||
|
{
|
||||||
|
t.ExitFound = true;
|
||||||
|
t.MountainExit = (stem[i] / n, stem[i] % n);
|
||||||
|
t.MountainExitElevM = original[stem[i]] * M_PER_UNIT;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LEAN tributaries: junction branches off the stem with enough drainage,
|
||||||
|
// top few by accumulation.
|
||||||
|
var stemSet = new HashSet<int>(stem);
|
||||||
|
var cands = new List<(int cell, long acc)>();
|
||||||
|
foreach (int sc in stem)
|
||||||
|
{
|
||||||
|
int cx = sc / n, cy = sc % n;
|
||||||
|
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 (stemSet.Contains(ni)) continue;
|
||||||
|
if (dir[ni] >= 0 && Target(ni) == sc && acc[ni] >= p.TributaryMinAccPx)
|
||||||
|
cands.Add((ni, acc[ni]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cands.Sort((a, b) => b.acc.CompareTo(a.acc));
|
||||||
|
// Dedup: two inflow neighbours at adjacent stem cells are one confluence,
|
||||||
|
// not two tributaries — keep only junctions ≥ 30 px apart.
|
||||||
|
var taken = new List<int>();
|
||||||
|
foreach (var (cell, a) in cands)
|
||||||
|
{
|
||||||
|
if (taken.Count >= p.TributaryMaxPerTrunk) break;
|
||||||
|
int cx2 = cell / n, cy2 = cell % n;
|
||||||
|
bool dup = false;
|
||||||
|
foreach (int tc in taken)
|
||||||
|
{
|
||||||
|
float ddx = cx2 - tc / n, ddy = cy2 - tc % n;
|
||||||
|
if (ddx * ddx + ddy * ddy < 30f * 30f) { dup = true; break; }
|
||||||
|
}
|
||||||
|
if (!dup) taken.Add(cell);
|
||||||
|
}
|
||||||
|
foreach (int cell in taken)
|
||||||
|
{
|
||||||
|
long a = acc[cell];
|
||||||
|
var trib = new Stream { DrainageAreaPx = a };
|
||||||
|
var ts = TraceStem(cell, Math.Max(p.StemMinAccPx, (int)(a / 20)));
|
||||||
|
trib.Course = Decimate(ts);
|
||||||
|
trib.Head = (ts[^1] / n, ts[^1] % n);
|
||||||
|
t.Tributaries.Add(trib);
|
||||||
|
}
|
||||||
|
plan.Trunks.Add(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 5c. LEAN endorheic terminals: terminal basins ranked by TOTAL inflow ---
|
||||||
|
{
|
||||||
|
var terms = new List<(int id, long inflow)>();
|
||||||
|
for (int id = 1; id < basinMinCell.Count; id++)
|
||||||
|
{
|
||||||
|
int mc = basinMinCell[id];
|
||||||
|
if (mc < 0 || basinId[mc] != id) continue; // not a terminal basin
|
||||||
|
if (basinInflow[id] >= p.EndorheicMinInflowPx) terms.Add((id, basinInflow[id]));
|
||||||
|
}
|
||||||
|
terms.Sort((a, b) => b.inflow.CompareTo(a.inflow));
|
||||||
|
foreach (var (id, inflow) in terms.GetRange(0, Math.Min(p.EndorheicMaxCount, terms.Count)))
|
||||||
|
{
|
||||||
|
int mc = basinMinCell[id];
|
||||||
|
plan.Endorheics.Add(new EndorheicTerminal
|
||||||
|
{
|
||||||
|
Terminal = (mc / n, mc % n),
|
||||||
|
DrainageAreaPx = inflow,
|
||||||
|
BasinDepthM = basinDepthM[id],
|
||||||
|
BasinAreaPx = basinAreaPx[id]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
}
|
||||||
225
Tools/Scripts/RiverPlanTool.cs
Normal file
225
Tools/Scripts/RiverPlanTool.cs
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
using Godot;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
using IslaApocalypse.Core;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The river-plan tool (C0b part 1, terrain-water task 21). Headless, harness-style:
|
||||||
|
///
|
||||||
|
/// 1. load an EROSION-ON blueprint through the real parser,
|
||||||
|
/// 2. run DrainageAnalysis over its (eroded) heightmap — pure analysis,
|
||||||
|
/// 3. print the full plan report to the console,
|
||||||
|
/// 4. write the plan as a JSON SIDECAR next to the source file.
|
||||||
|
///
|
||||||
|
/// It never writes the blueprint. The sidecar is deliberately NOT a blueprint
|
||||||
|
/// section: sections are for realized world data, and this is a PLAN the developer
|
||||||
|
/// gates before part 2 carves anything — a plan that read as actual water would be
|
||||||
|
/// exactly the masquerade task 21 forbids. Part 2 owns the durable representation.
|
||||||
|
///
|
||||||
|
/// Run: Godot --headless --path <repo> res://Tools/Scenes/RiverPlanTool.tscn
|
||||||
|
/// Env: RIVERPLAN_SRC (source .dat; default user://MapData_Seed_1280587109.dat),
|
||||||
|
/// RIVERPLAN_OUT (sidecar path; default <src dir>/RiverPlan_Seed_<seed>.json),
|
||||||
|
/// RIVERPLAN_* dial overrides (see ReadParams).
|
||||||
|
/// Exit 0 = plan written, 1 = failure.
|
||||||
|
/// </summary>
|
||||||
|
public partial class RiverPlanTool : Node
|
||||||
|
{
|
||||||
|
public override void _Ready()
|
||||||
|
{
|
||||||
|
bool ok = false;
|
||||||
|
try { ok = RunPlan(); }
|
||||||
|
catch (System.Exception e) { GD.PrintErr($"[RiverPlan] EXCEPTION: {e}"); }
|
||||||
|
GD.Print(ok ? "[RiverPlan] RESULT: PLAN WRITTEN" : "[RiverPlan] RESULT: FAIL");
|
||||||
|
GetTree().Quit(ok ? 0 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float EnvF(string k, float d) =>
|
||||||
|
float.TryParse(OS.GetEnvironment(k), NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : d;
|
||||||
|
private static int EnvI(string k, int d) =>
|
||||||
|
int.TryParse(OS.GetEnvironment(k), out var v) ? v : d;
|
||||||
|
|
||||||
|
private static DrainageAnalysis.Params ReadParams()
|
||||||
|
{
|
||||||
|
var p = new DrainageAnalysis.Params();
|
||||||
|
p.EndorheicMinDepthM = EnvF("RIVERPLAN_ENDO_MIN_DEPTH_M", p.EndorheicMinDepthM);
|
||||||
|
p.EndorheicMinAreaPx = EnvI("RIVERPLAN_ENDO_MIN_AREA_PX", p.EndorheicMinAreaPx);
|
||||||
|
p.EndorheicMinInflowPx = EnvI("RIVERPLAN_ENDO_MIN_INFLOW_PX", p.EndorheicMinInflowPx);
|
||||||
|
p.EndorheicMaxCount = EnvI("RIVERPLAN_ENDO_MAX_COUNT", p.EndorheicMaxCount);
|
||||||
|
p.TrunkCount = EnvI("RIVERPLAN_TRUNK_COUNT", p.TrunkCount);
|
||||||
|
p.MinOutletSeparationPx = EnvI("RIVERPLAN_OUTLET_SEPARATION_PX", p.MinOutletSeparationPx);
|
||||||
|
p.StemMinAccPx = EnvI("RIVERPLAN_STEM_MIN_ACC_PX", p.StemMinAccPx);
|
||||||
|
p.TributaryMinAccPx = EnvI("RIVERPLAN_TRIB_MIN_ACC_PX", p.TributaryMinAccPx);
|
||||||
|
p.TributaryMaxPerTrunk = EnvI("RIVERPLAN_TRIB_MAX_PER_TRUNK", p.TributaryMaxPerTrunk);
|
||||||
|
p.ExitGradeMin = EnvF("RIVERPLAN_EXIT_GRADE_MIN", p.ExitGradeMin);
|
||||||
|
p.ExitWindowPx = EnvI("RIVERPLAN_EXIT_WINDOW_PX", p.ExitWindowPx);
|
||||||
|
p.SeaLevel = EnvF("RIVERPLAN_SEA_LEVEL", p.SeaLevel);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool RunPlan()
|
||||||
|
{
|
||||||
|
string src = OS.GetEnvironment("RIVERPLAN_SRC");
|
||||||
|
if (string.IsNullOrEmpty(src))
|
||||||
|
src = ProjectSettings.GlobalizePath("user://MapData_Seed_1280587109.dat");
|
||||||
|
GD.Print($"[RiverPlan] source blueprint: {src}");
|
||||||
|
|
||||||
|
ulong t0 = Time.GetTicksMsec();
|
||||||
|
WorldBlueprint bp = MapDataParser.LoadMapDataFromPath(src);
|
||||||
|
if (bp == null) { GD.PrintErr("[RiverPlan] blueprint load failed."); return false; }
|
||||||
|
if (bp.Erosion == null)
|
||||||
|
GD.PrintErr("[RiverPlan] ⚠ source carries no EROS section — analysing an UNERODED " +
|
||||||
|
"surface; the plan will still compute but is not the C0b input the task means.");
|
||||||
|
ulong t1 = Time.GetTicksMsec();
|
||||||
|
GD.Print($"[RiverPlan] loaded in {(t1 - t0) / 1000.0:F1}s " +
|
||||||
|
$"(seed {bp.Params?.WorldSeed}, {bp.MapSize}², erosion {(bp.Erosion != null ? $"v{bp.Erosion.Version}" : "ABSENT")}).");
|
||||||
|
|
||||||
|
if (bp.WaterBodyIds == null)
|
||||||
|
{ GD.PrintErr("[RiverPlan] source carries no WBID — cannot identify THE OCEAN; refusing."); return false; }
|
||||||
|
// THE OCEAN body (WBID == 1) is the only water that counts as "the sea":
|
||||||
|
// enclosed lagoons are depressions a river may legitimately END in, not
|
||||||
|
// destinations that make a trunk "sea-reaching".
|
||||||
|
int nn = bp.MapSize;
|
||||||
|
bool[] isOcean = new bool[nn * nn];
|
||||||
|
for (int x = 0; x < nn; x++)
|
||||||
|
for (int y = 0; y < nn; y++)
|
||||||
|
isOcean[x * nn + y] = bp.WaterBodyIds[x, y] == 1;
|
||||||
|
|
||||||
|
var p = ReadParams();
|
||||||
|
var plan = DrainageAnalysis.Run(bp.HeightMap, bp.MapSize, isOcean, p);
|
||||||
|
ulong t2 = Time.GetTicksMsec();
|
||||||
|
GD.Print($"[RiverPlan] analysis in {(t2 - t1) / 1000.0:F1}s.");
|
||||||
|
|
||||||
|
// ---- console report ----
|
||||||
|
GD.Print($"[RiverPlan] routing: {plan.LandCells} land cells; " +
|
||||||
|
$"{plan.SeaReachingCells} drain to sea ({100.0 * plan.SeaReachingCells / plan.LandCells:F1}%), " +
|
||||||
|
$"{plan.EndorheicCells} endorheic ({100.0 * plan.EndorheicCells / plan.LandCells:F1}%), " +
|
||||||
|
$"{plan.UnroutedCells} unrouted (should be ~0).");
|
||||||
|
GD.Print($"[RiverPlan] depressions: {plan.PitsFilledCount} pits filled through for routing, " +
|
||||||
|
$"{plan.TerminalBasinCount} qualified as terminal basins " +
|
||||||
|
$"(depth ≥ {p.EndorheicMinDepthM} m and area ≥ {p.EndorheicMinAreaPx} px).");
|
||||||
|
GD.Print("[RiverPlan] top outlets by drainage area (pre-separation):");
|
||||||
|
foreach (var (x, y, a) in plan.AllOutletsTop)
|
||||||
|
GD.Print($"[RiverPlan] ({x},{y}) {a} px");
|
||||||
|
int ti = 0;
|
||||||
|
foreach (var t in plan.Trunks)
|
||||||
|
{
|
||||||
|
ti++;
|
||||||
|
GD.Print($"[RiverPlan] TRUNK {ti}: outlet ({t.Outlet.x:F0},{t.Outlet.y:F0}), " +
|
||||||
|
$"drainage {t.DrainageAreaPx} px, stem {t.Course.Count * 4} px, " +
|
||||||
|
(t.ExitFound
|
||||||
|
? $"mountain-exit ({t.MountainExit.x:F0},{t.MountainExit.y:F0}) at {t.MountainExitElevM:F0} m"
|
||||||
|
: "mountain-exit NOT FOUND (stem never sustains the exit grade)") +
|
||||||
|
$", {t.Tributaries.Count} tributaries.");
|
||||||
|
foreach (var tr in t.Tributaries)
|
||||||
|
GD.Print($"[RiverPlan] trib: joins near head ({tr.Course[0].x:F0},{tr.Course[0].y:F0}), " +
|
||||||
|
$"drainage {tr.DrainageAreaPx} px");
|
||||||
|
}
|
||||||
|
foreach (var e in plan.Endorheics)
|
||||||
|
{
|
||||||
|
ushort wb = bp.WaterBodyIds[(int)e.Terminal.x, (int)e.Terminal.y];
|
||||||
|
GD.Print($"[RiverPlan] ENDORHEIC terminal ({e.Terminal.x:F0},{e.Terminal.y:F0}): " +
|
||||||
|
$"drainage {e.DrainageAreaPx} px into a basin {e.BasinDepthM:F1} m deep, {e.BasinAreaPx} px" +
|
||||||
|
(wb > 1 ? $" — terminates IN classify lake/lagoon WBID {wb} (river-feeds-lake)" : " — dry closed basin") + ".");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the southern-town report (filed fact, not a constraint) ----
|
||||||
|
if (bp.Towns.Count > 0)
|
||||||
|
{
|
||||||
|
TownLocation south = bp.Towns[0];
|
||||||
|
foreach (var t in bp.Towns)
|
||||||
|
if (t.Position.Y > south.Position.Y) south = t;
|
||||||
|
GD.Print($"[RiverPlan] southernmost town: tier {south.Tier} at " +
|
||||||
|
$"({south.Position.X:F0},{south.Position.Y:F0}).");
|
||||||
|
ti = 0;
|
||||||
|
foreach (var t in plan.Trunks)
|
||||||
|
{
|
||||||
|
ti++;
|
||||||
|
float best = float.MaxValue;
|
||||||
|
foreach (var (x, y) in t.Course)
|
||||||
|
{
|
||||||
|
float dx = x - south.Position.X, dy = y - south.Position.Y;
|
||||||
|
float d2 = dx * dx + dy * dy;
|
||||||
|
if (d2 < best) best = d2;
|
||||||
|
}
|
||||||
|
GD.Print($"[RiverPlan] SOUTH REPORT trunk {ti}: outlet y={t.Outlet.y:F0} " +
|
||||||
|
$"({(t.Outlet.y > bp.MapSize * 0.55f ? "southern" : t.Outlet.y < bp.MapSize * 0.45f ? "northern" : "central")} coast); " +
|
||||||
|
$"course passes {Mathf.Sqrt(best):F0} px from the southernmost town.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- JSON sidecar ----
|
||||||
|
string outPath = OS.GetEnvironment("RIVERPLAN_OUT");
|
||||||
|
if (string.IsNullOrEmpty(outPath))
|
||||||
|
outPath = System.IO.Path.Combine(System.IO.Path.GetDirectoryName(src) ?? ".",
|
||||||
|
$"RiverPlan_Seed_{bp.Params?.WorldSeed}.json");
|
||||||
|
System.IO.File.WriteAllText(outPath, ToJson(bp, plan));
|
||||||
|
GD.Print($"[RiverPlan] plan sidecar written: {outPath}");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hand-rolled, invariant-culture JSON for a fixed schema — deterministic output,
|
||||||
|
// no serializer reflection surprises.
|
||||||
|
private static string ToJson(WorldBlueprint bp, DrainageAnalysis.Plan plan)
|
||||||
|
{
|
||||||
|
var ci = CultureInfo.InvariantCulture;
|
||||||
|
var sb = new StringBuilder(1 << 20);
|
||||||
|
void Pt(StringBuilder b, (float x, float y) v) =>
|
||||||
|
b.Append('[').Append(v.x.ToString("F1", ci)).Append(',').Append(v.y.ToString("F1", ci)).Append(']');
|
||||||
|
void Course(List<(float x, float y)> c)
|
||||||
|
{
|
||||||
|
sb.Append('[');
|
||||||
|
for (int i = 0; i < c.Count; i++) { if (i > 0) sb.Append(','); Pt(sb, c[i]); }
|
||||||
|
sb.Append(']');
|
||||||
|
}
|
||||||
|
sb.Append("{\n\"_WARNING\": \"RIVER *PLAN* — analysis output for the task-21 gate. ");
|
||||||
|
sb.Append("Nothing here is realized water or terrain. Part 2 (task 22) consumes this; ");
|
||||||
|
sb.Append("nothing at runtime may read it as water.\",\n");
|
||||||
|
sb.Append($"\"seed\": {bp.Params?.WorldSeed ?? 0}, \"mapSize\": {bp.MapSize},\n");
|
||||||
|
var p = plan.P;
|
||||||
|
sb.Append($"\"params\": {{\"endoMinDepthM\": {p.EndorheicMinDepthM.ToString(ci)}, ");
|
||||||
|
sb.Append($"\"endoMinAreaPx\": {p.EndorheicMinAreaPx}, \"endoMinInflowPx\": {p.EndorheicMinInflowPx}, ");
|
||||||
|
sb.Append($"\"endoMaxCount\": {p.EndorheicMaxCount}, \"trunkCount\": {p.TrunkCount}, ");
|
||||||
|
sb.Append($"\"minOutletSeparationPx\": {p.MinOutletSeparationPx}, \"stemMinAccPx\": {p.StemMinAccPx}, ");
|
||||||
|
sb.Append($"\"tribMinAccPx\": {p.TributaryMinAccPx}, \"tribMaxPerTrunk\": {p.TributaryMaxPerTrunk}, ");
|
||||||
|
sb.Append($"\"exitGradeMin\": {p.ExitGradeMin.ToString(ci)}, \"exitWindowPx\": {p.ExitWindowPx}, ");
|
||||||
|
sb.Append($"\"seaLevel\": {p.SeaLevel.ToString(ci)}}},\n");
|
||||||
|
sb.Append($"\"routing\": {{\"landCells\": {plan.LandCells}, \"seaReaching\": {plan.SeaReachingCells}, ");
|
||||||
|
sb.Append($"\"endorheic\": {plan.EndorheicCells}, \"unrouted\": {plan.UnroutedCells}, ");
|
||||||
|
sb.Append($"\"pitsFilled\": {plan.PitsFilledCount}, \"terminalBasins\": {plan.TerminalBasinCount}}},\n");
|
||||||
|
sb.Append("\"trunks\": [\n");
|
||||||
|
for (int i = 0; i < plan.Trunks.Count; i++)
|
||||||
|
{
|
||||||
|
var t = plan.Trunks[i];
|
||||||
|
sb.Append(" {\"outlet\": "); Pt(sb, t.Outlet);
|
||||||
|
sb.Append($", \"drainageAreaPx\": {t.DrainageAreaPx}, \"exitFound\": {(t.ExitFound ? "true" : "false")}, ");
|
||||||
|
sb.Append("\"mountainExit\": "); Pt(sb, t.MountainExit);
|
||||||
|
sb.Append($", \"mountainExitElevM\": {t.MountainExitElevM.ToString("F1", ci)},\n \"course\": ");
|
||||||
|
Course(t.Course);
|
||||||
|
sb.Append(",\n \"tributaries\": [");
|
||||||
|
for (int j = 0; j < t.Tributaries.Count; j++)
|
||||||
|
{
|
||||||
|
var tr = t.Tributaries[j];
|
||||||
|
if (j > 0) sb.Append(',');
|
||||||
|
sb.Append($"\n {{\"drainageAreaPx\": {tr.DrainageAreaPx}, \"course\": ");
|
||||||
|
Course(tr.Course);
|
||||||
|
sb.Append('}');
|
||||||
|
}
|
||||||
|
sb.Append("]\n }");
|
||||||
|
if (i < plan.Trunks.Count - 1) sb.Append(',');
|
||||||
|
sb.Append('\n');
|
||||||
|
}
|
||||||
|
sb.Append("],\n\"endorheics\": [");
|
||||||
|
for (int i = 0; i < plan.Endorheics.Count; i++)
|
||||||
|
{
|
||||||
|
var e = plan.Endorheics[i];
|
||||||
|
if (i > 0) sb.Append(',');
|
||||||
|
sb.Append("\n {\"terminal\": "); Pt(sb, e.Terminal);
|
||||||
|
sb.Append($", \"drainageAreaPx\": {e.DrainageAreaPx}, ");
|
||||||
|
sb.Append($"\"basinDepthM\": {e.BasinDepthM.ToString("F2", ci)}, \"basinAreaPx\": {e.BasinAreaPx}, ");
|
||||||
|
sb.Append($"\"terminalWbid\": {bp.WaterBodyIds[(int)e.Terminal.x, (int)e.Terminal.y]}}}");
|
||||||
|
}
|
||||||
|
sb.Append("\n]\n}\n");
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue