islaApocalypse-v2/Tools/Scripts/DrainageRenderer.cs
beezm ea301f7a8c rivers/02: river promotion — unified ranking over both termini, and the count gate
Choose the river count against the terrain that actually exists. The M3 count of
3 was tuned on topography the southern stretch (D-065) and coastal fragmentation
(D-063) have since replaced, and TrunkCount/GiantCount/EndorheicMaxCount = 3 are
LEAN REPORTING CAPS, not a statement about this island.

UNIFIED RANKING. The reference promoted from two lists with two quotas — N sea
trunks, N endorheic giants — which encodes the assumption that reaching the sea
is what makes a drainage a river. This terrain does not satisfy it: 54-78% of
land drains inland across the gallery, 106-121 terminal basins per seed. So
selection is unified — every major drainage ranked by contributing-cell count in
ONE list, top N promoted, and the sea/endorheic split FALLS OUT. A quota would
have promoted small coastal drainages over far larger inland ones purely because
of where they end. Deliberate departure from the reference's structure (D-050
noted, approved in chat). Only SELECTION is unified: the per-terminus tag is
retained per river because rivers/03's routing branches on it, and Trunk/Giant
are untouched.

  The metric is the same unit on both sides, and that is now ASSERTED rather
  than argued: sea Acc and endorheic BasinInflow are both counts of contributing
  land cells on the same D8 field, and every land cell has exactly one
  destination, so Σ sea Acc + Σ BasinInflow + unrouted == LandCells exactly. The
  tool refuses to rank unless it holds, per seed. It held on all 8.

DrainageAnalysis.cs is NOT modified. The complete candidate set is re-derived
from state it already exposes (Dir/Acc/BasinId/BasinInflow/FullFilled); the
three internal per-basin values that are not exposed are exactly reconstructible
because terminal basins are reverted to the real surface while FullFilled keeps
the fill. Only REPORTING caps were raised, and only so the analysis's own
TraceStem produces a real upland course per promotable candidate.
EndorheicMinDepthM/MinAreaPx were left alone — they decide which depressions
BECOME terminal basins, i.e. they define the routing surface itself, and a count
chosen on a moved surface would be a count for terrain that does not exist.

TWO FINDINGS.

  1. THE TERRAIN HAS NO NATURAL COUNT. The knee (largest ratio between
     consecutive ranks) lands at rank 19/7/15/5/4/12/3/8 across the eight
     gallery seeds at ratios of 1.33-1.57x, with one uncorroborated 2.37x. A
     break that wanders across the whole plausible range at that strength is not
     a break — on a log axis the candidates fall on a near-straight line.
     N is a DESIGN choice, and the diagnostic says so instead of inventing one.

  2. THE ISLAND'S MAJOR RIVERS ARE INLAND RIVERS. Six of eight seeds have ZERO
     sea-reaching drainages in their top 8; the median at N=16 is one; one seed
     has none at 16. On the primary seed the largest sea drainage ranks 11th at
     736,088 px against 2,329,573 px for the largest endorheic — 3.2x. (That
     outlet is exactly batch 12's recorded top trunk, to the cell.)

A concern I raised and then CLOSED by measuring: MinOutletSeparationPx is a
plain Euclidean test with no notion of which landmass a coastline belongs to, so
on a fragmented archipelago it could suppress an island's only river. Measured:
0-6 cross-landmass suppressions per seed, and — decisively — the largest
suppressed above-floor outlet anywhere is 209,611 px against a smallest ladder
cutoff of 439,592 px, so ZERO suppressed outlets clear any rung on any seed. The
rule provably cannot have altered the ladder. Real but harmless; flagged for
rivers/03, not a blocker. The rule was NOT changed — it belongs to the analysis,
and moving it would move the candidate set the developer is being asked to judge.

Also found: the params are ABSOLUTE pixel counts, so this analysis is only valid
at the size they were tuned for. At 1024 nothing qualifies as endorheic (the
whole endorheic half cannot be exercised) and separation suppresses 15,043 of
15,048 outlets. The tool now refuses loudly to have a non-8192 run read as a
count decision. Flagged for rivers/03: these want to become scale-free fractions
as MinLandComponentFrac already is.

Giant.ProvisionalRoute is never rendered — the "comb" is rivers/03's to replace,
and drawing it would make a count judgment look like a finished network. The
plates draw the real erosion-carved upland stems. Endorheic rivers are marked at
where their stem POOLS, not at the basin's deepest cell: the analysis
distinguishes these deliberately and they sit up to 395 px apart, which drew
every stem detached from its own endpoint until it was fixed.

TASTE GATE: 8/12/16 are presented and nothing is decided. No count is chosen, no
default is set, and neither TerrainGenConfig nor DrainageAnalysis.Params changed.

-> XX_Human/output/rivers/02_promotion.report.md
-> batches/rivers/02_promotion/INDEX.md

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WhkXBQh2tDmcWKpXYcj8vj
2026-08-23 06:13:03 -04:00

338 lines
16 KiB
C#

using System;
using System.Collections.Generic;
using Godot;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
/// <summary>
/// THE DRAINAGE MAPS (chat2/12) — presentation only, for eyeballing that the flow is sane:
///
/// • the LOG-SCALED ACCUMULATION map — drainage spans orders of magnitude, so log(1+acc) over land;
/// the dendritic uplands and the trunks read as bright channels on dark hillslopes; the ocean is a
/// flat dark blue and enclosed (non-ocean) water a dark teal, so the ocean identity is visible too;
/// • the PROMOTED-CANDIDATES overlay — a faint grey terrain, the sea-reaching trunks in cyan (outlet
/// square, mountain-exit white ring, lean tributaries thin), the endorheic giants in orange (pooling
/// terminal disc, lean tributaries thin), the lean endorheic terminals as red rings. Provisional
/// routes are NOT drawn (routing is a later task). Nothing here touches data.
/// </summary>
public static class DrainageRenderer
{
private static readonly Color Ocean = new(0.055f, 0.110f, 0.235f);
private static readonly Color Enclosed = new(0.060f, 0.220f, 0.230f);
private static readonly Color Trunk = new(0.250f, 0.900f, 1.000f);
private static readonly Color Giant = new(1.000f, 0.600f, 0.150f);
private static readonly Color Endo = new(1.000f, 0.250f, 0.250f);
private static readonly Color Exit = new(1.000f, 1.000f, 1.000f);
private static readonly Color Ink = new(0.941f, 0.949f, 0.961f);
/// <summary>log(1 + acc) / log(1 + max) over land; ocean / enclosed water flat.</summary>
public static Image Accumulation(int[] acc, bool[] isOcean, float[,] render, int n, float sea)
{
long max = 1;
for (int i = 0; i < acc.Length; i++) if (acc[i] > max) max = acc[i];
double lmax = Math.Log(1.0 + max);
var img = Image.CreateEmpty(n, n, false, Image.Format.Rgb8);
for (int x = 0; x < n; x++)
for (int y = 0; y < n; y++)
{
int i = x * n + y;
if (isOcean[i]) { img.SetPixel(x, y, Ocean); continue; }
if (render[x, y] < sea) { img.SetPixel(x, y, Enclosed); continue; }
float v = (float)(Math.Log(1.0 + acc[i]) / lmax);
// a dark-to-bright ramp with a cool tint in the channels
float r = 0.06f + 0.94f * v * v, g = 0.08f + 0.92f * v, b = 0.12f + 0.88f * MathF.Sqrt(v);
img.SetPixel(x, y, new Color(MathF.Min(1f, r), MathF.Min(1f, g), MathF.Min(1f, b)));
}
return img;
}
/// <summary>
/// The faint grey terrain every overlay map is drawn on — ocean flat dark blue, enclosed
/// (non-ocean) water dark teal, land a shallow sqrt ramp. Factored out at rivers/02 so the
/// promotion maps sit on the SAME base as the chat2/12 candidates map and can be compared
/// without the eye correcting for two different backgrounds.
/// </summary>
public static Image TerrainBase(bool[] isOcean, float[,] render, int n, float sea, float hMax)
{
var img = Image.CreateEmpty(n, n, false, Image.Format.Rgb8);
float span = MathF.Max(1e-6f, hMax - sea);
for (int x = 0; x < n; x++)
for (int y = 0; y < n; y++)
{
int i = x * n + y;
if (isOcean[i]) { img.SetPixel(x, y, Ocean); continue; }
if (render[x, y] < sea) { img.SetPixel(x, y, Enclosed); continue; }
float t = MathF.Min(1f, (render[x, y] - sea) / span);
float g = 0.30f + 0.45f * MathF.Sqrt(t);
img.SetPixel(x, y, new Color(g, g, g * 0.96f));
}
return img;
}
/// <summary>The candidates over a faint terrain.</summary>
public static Image Candidates(DrainageAnalysis.Plan plan, bool[] isOcean, float[,] render, int n, float sea, float hMax, string title)
{
var img = TerrainBase(isOcean, render, n, sea, hMax);
int thick = n >= 4096 ? 5 : 3, thin = n >= 4096 ? 3 : 2, mark = n >= 4096 ? 18 : 10;
foreach (var g in plan.Giants)
{
foreach (var tr in g.Tributaries) Polyline(img, tr.Course, n, Giant, thin);
Polyline(img, g.Course, n, Giant, thick);
Disc(img, (int)g.Terminal.x, (int)g.Terminal.y, mark, n, Giant);
Ring(img, (int)g.Terminal.x, (int)g.Terminal.y, mark + 8, n, Ink, 3);
if (g.ExitFound) Ring(img, (int)g.MountainExit.x, (int)g.MountainExit.y, mark, n, Exit, 4);
}
foreach (var t in plan.Trunks)
{
foreach (var tr in t.Tributaries) Polyline(img, tr.Course, n, Trunk, thin);
Polyline(img, t.Course, n, Trunk, thick);
Square(img, (int)t.Outlet.x, (int)t.Outlet.y, mark, n, Trunk);
if (t.ExitFound) Ring(img, (int)t.MountainExit.x, (int)t.MountainExit.y, mark, n, Exit, 4);
}
foreach (var e in plan.Endorheics)
Ring(img, (int)e.Terminal.x, (int)e.Terminal.y, mark + 4, n, Endo, 4);
int s = n >= 4096 ? 4 : 3; int lh = TinyFont.Height(s) + 6;
TinyFont.Draw(img, title, 12, 12, s, Ink);
TinyFont.Draw(img, $"CYAN: SEA-REACHING TRUNKS ({plan.Trunks.Count}) - SQUARE = OUTLET WHITE RING = MOUNTAIN EXIT", 12, 12 + lh, s, Ink);
TinyFont.Draw(img, $"ORANGE: ENDORHEIC GIANTS ({plan.Giants.Count}) - DISC = POOLING TERMINAL (EXPECTED, NOT AN ERROR)", 12, 12 + lh * 2, s, Ink);
TinyFont.Draw(img, $"RED RING: LEAN ENDORHEIC TERMINALS ({plan.Endorheics.Count}) THIN LINES: LEAN TRIBUTARIES NOTHING CARVED - ANALYSIS ONLY", 12, 12 + lh * 3, s, Ink);
return img;
}
// ═══ ⭐ THE PROMOTION MAPS (rivers/02) — the count decision, on the map ═══════════════════
//
// Two views, same base, same colour law:
// SEA-REACHING cyan (as chat2/12's trunks)
// ENDORHEIC orange (as chat2/12's giants)
// so a reader carrying chat2/12 in their eye reads these without relearning anything.
//
// ⚠⚠ NEITHER MAP DRAWS `Giant.ProvisionalRoute`. That steepest-descent placeholder — the
// visible "comb" of parallel threads on the flats — is rivers/03's job to replace, and drawing
// it here would make a count look like a river network it is not. What IS drawn is the REAL
// upland stem: the max-accumulation course traced through erosion-carved valleys.
/// <summary>
/// ⭐ THE DIAGNOSTIC MAP — every candidate above the floor, marker AREA ∝ drainage area,
/// colour by terminus. Answers "where are the big drainages, and is the spread north/south?"
/// before any count is chosen.
///
/// ⚠ Marker radius scales as √area so the MARKER'S AREA is proportional to the drainage area —
/// scaling the radius linearly would exaggerate the big ones quadratically and make a knee look
/// like a cliff.
/// </summary>
public static Image PromotionCandidates(List<RiverCandidate> ranked, Image img, int n, string title, int[] ladder)
{
if (ranked.Count == 0) return img;
long maxArea = 1;
foreach (var c in ranked) if (c.DrainagePx > maxArea) maxArea = c.DrainagePx;
float rMax = n >= 4096 ? 46f : 22f, rMin = n >= 4096 ? 6f : 3f;
int ringW = n >= 4096 ? 4 : 2;
// Draw smallest-first so a big marker never hides behind a small one.
for (int i = ranked.Count - 1; i >= 0; i--)
{
var c = ranked[i];
float f = MathF.Sqrt((float)c.DrainagePx / maxArea); // area ∝ drainage
int r = (int)MathF.Round(rMin + (rMax - rMin) * f);
Color col = c.IsSea ? Trunk : Giant;
Disc(img, c.X, c.Y, r, n, col);
Ring(img, c.X, c.Y, r + ringW + 1, n, Ink, ringW); // ink halo: legible on any ground
}
int s = n >= 4096 ? 4 : 3; int lh = TinyFont.Height(s) + 6;
int nSea = 0; foreach (var c in ranked) if (c.IsSea) nSea++;
TinyFont.Draw(img, title, 12, 12, s, Ink);
TinyFont.Draw(img, $"ALL {ranked.Count} CANDIDATES ABOVE THE FLOOR - MARKER AREA IS PROPORTIONAL TO DRAINAGE AREA", 12, 12 + lh, s, Ink);
TinyFont.Draw(img, $"CYAN: SEA-REACHING ({nSea}) ORANGE: ENDORHEIC ({ranked.Count - nSea}) - AN INLAND TERMINUS IS A PASS, NOT A FALLBACK", 12, 12 + lh * 2, s, Ink);
TinyFont.Draw(img, $"NOTHING IS PROMOTED HERE - THIS IS THE DISTRIBUTION THE COUNT ({Join(ladder)}) IS CHOSEN FROM", 12, 12 + lh * 3, s, Ink);
return img;
}
/// <summary>
/// ⭐ THE A/B PLATE — the unified top-N promoted, real upland stems, width ∝ drainage area,
/// terminus markers coloured by type. One plate per N; the developer picks by comparing them.
/// </summary>
public static Image PromotedRivers(List<RiverCandidate> promoted, Image img, int n,
int nPromoted, long floorPx, string title)
{
if (promoted.Count == 0) return img;
long maxArea = 1;
foreach (var c in promoted) if (c.DrainagePx > maxArea) maxArea = c.DrainagePx;
float wMax = n >= 4096 ? 11f : 6f, wMin = n >= 4096 ? 3f : 2f;
int mark = n >= 4096 ? 18 : 10;
// Smallest first, so the biggest rivers finish on top.
for (int i = promoted.Count - 1; i >= 0; i--)
{
var c = promoted[i];
if (c.Course == null || c.Course.Count < 2) continue;
float f = MathF.Sqrt((float)c.DrainagePx / maxArea);
int w = (int)MathF.Round(wMin + (wMax - wMin) * f);
Polyline(img, c.Course, n, c.IsSea ? Trunk : Giant, w);
}
// ⚠ Marked at the RIVER's terminus (where its stem pools), NOT at the basin's deepest cell —
// on a flat basin floor those differ, and marking the deepest cell draws the stem visibly
// detached from its own endpoint. → RiverCandidate.TermX.
foreach (var c in promoted)
{
if (c.IsSea) Square(img, c.TermX, c.TermY, mark, n, Trunk);
else { Disc(img, c.TermX, c.TermY, mark, n, Giant); Ring(img, c.TermX, c.TermY, mark + 8, n, Ink, 3); }
}
int s = n >= 4096 ? 4 : 3; int lh = TinyFont.Height(s) + 6;
int nSea = 0; foreach (var c in promoted) if (c.IsSea) nSea++;
TinyFont.Draw(img, title, 12, 12, s, Ink);
TinyFont.Draw(img, $"UNIFIED TOP {nPromoted} BY DRAINAGE AREA - THE SEA/ENDORHEIC SPLIT FELL OUT, IT WAS NOT QUOTA'D", 12, 12 + lh, s, Ink);
TinyFont.Draw(img, $"CYAN SQUARE: SEA OUTLET ({nSea}) ORANGE DISC: ENDORHEIC TERMINUS ({promoted.Count - nSea}) STEM WIDTH IS PROPORTIONAL TO DRAINAGE", 12, 12 + lh * 2, s, Ink);
TinyFont.Draw(img, $"REAL UPLAND STEMS ONLY - NO LOWLAND ROUTING, NO WATER, NOTHING CARVED (FLOOR {floorPx:N0} PX)", 12, 12 + lh * 3, s, Ink);
return img;
}
/// <summary>
/// ⭐ THE DISTRIBUTION PLOT — drainage area (log y) against rank (linear x), with the ladder
/// counts marked vertically and the analysis's own thresholds marked horizontally.
///
/// **Log y is not a presentation choice, it is the only honest one:** drainage areas span three
/// or more orders of magnitude, so on a linear axis every candidate but the top two or three
/// collapses onto the floor and the knee — the thing this plot exists to show — is invisible.
/// </summary>
public static Image Distribution(List<RiverCandidate> ranked, int[] ladder,
long endorheicMinInflowPx, long stemMinAccPx, long floorPx, string title)
{
const int W = 1600, H = 1000, L = 150, R = 40, T = 120, B = 90;
var img = Image.CreateEmpty(W, H, false, Image.Format.Rgb8);
var bg = new Color(0.07f, 0.08f, 0.10f);
for (int x = 0; x < W; x++) for (int y = 0; y < H; y++) img.SetPixel(x, y, bg);
if (ranked.Count == 0) return img;
double loMin = Math.Log10(Math.Max(1.0, Math.Min(floorPx, ranked[ranked.Count - 1].DrainagePx)));
double hiMax = Math.Log10(Math.Max(10.0, ranked[0].DrainagePx));
loMin = Math.Floor(loMin); hiMax = Math.Ceiling(hiMax);
int plotW = W - L - R, plotH = H - T - B;
int XOf(int rank) => L + (int)((rank - 1) / (double)Math.Max(1, ranked.Count - 1) * plotW);
int YOf(double area) => T + plotH - (int)((Math.Log10(Math.Max(1.0, area)) - loMin) / Math.Max(1e-9, hiMax - loMin) * plotH);
var grid = new Color(0.16f, 0.18f, 0.22f);
for (int d = (int)loMin; d <= (int)hiMax; d++) // decade gridlines
{
int y = YOf(Math.Pow(10, d));
for (int x = L; x < L + plotW; x++) if (y >= 0 && y < H) img.SetPixel(x, y, grid);
TinyFont.Draw(img, $"1E{d}", 12, Math.Max(0, y - 6), 2, new Color(0.60f, 0.64f, 0.70f));
}
// the analysis's own thresholds — so the ladder is read RELATIVE to them, not in a vacuum
DashH(img, YOf(endorheicMinInflowPx), L, L + plotW, new Color(1f, 0.45f, 0.45f));
TinyFont.Draw(img, $"ENDORHEIC MIN INFLOW {endorheicMinInflowPx:N0}", L + 8, YOf(endorheicMinInflowPx) - 22, 2, new Color(1f, 0.45f, 0.45f));
DashH(img, YOf(stemMinAccPx), L, L + plotW, new Color(0.55f, 0.85f, 0.55f));
TinyFont.Draw(img, $"STEM MIN ACC {stemMinAccPx:N0}", L + 8, YOf(stemMinAccPx) - 22, 2, new Color(0.55f, 0.85f, 0.55f));
foreach (int nn in ladder) // the ladder counts
{
if (nn < 1 || nn > ranked.Count) continue;
int x = XOf(nn);
for (int y = T; y < T + plotH; y += 6)
for (int k = 0; k < 3 && y + k < T + plotH; k++) img.SetPixel(x, y + k, new Color(0.95f, 0.90f, 0.35f));
TinyFont.Draw(img, $"N={nn}", x + 6, T + 6, 3, new Color(0.95f, 0.90f, 0.35f));
TinyFont.Draw(img, $"{ranked[nn - 1].DrainagePx:N0}", x + 6, T + 6 + TinyFont.Height(3) + 4, 2, new Color(0.95f, 0.90f, 0.35f));
}
for (int i = 0; i < ranked.Count; i++) // the candidates
{
var c = ranked[i];
int x = XOf(i + 1), y = YOf(c.DrainagePx);
Color col = c.IsSea ? Trunk : Giant;
for (int ox = -3; ox <= 3; ox++)
for (int oy = -3; oy <= 3; oy++)
{
if (ox * ox + oy * oy > 9) continue;
int px = x + ox, py = y + oy;
if (px >= 0 && py >= 0 && px < W && py < H) img.SetPixel(px, py, col);
}
}
TinyFont.Draw(img, title, 12, 12, 3, Ink);
TinyFont.Draw(img, "DRAINAGE AREA (PX, LOG) VS UNIFIED RANK - CYAN SEA-REACHING, ORANGE ENDORHEIC", 12, 12 + TinyFont.Height(3) + 8, 2, Ink);
TinyFont.Draw(img, $"{ranked.Count} CANDIDATES ABOVE THE {floorPx:N0} PX FLOOR - A KNEE IS A SHARP DROP; A SMOOTH CURVE MEANS THE TERRAIN HAS NO NATURAL COUNT", 12, H - 34, 2, new Color(0.70f, 0.74f, 0.80f));
return img;
}
private static string Join(int[] v)
{
var sb = new System.Text.StringBuilder();
for (int i = 0; i < v.Length; i++) { if (i > 0) sb.Append('/'); sb.Append(v[i]); }
return sb.ToString();
}
private static void DashH(Image img, int y, int x0, int x1, Color c)
{
if (y < 0 || y >= img.GetHeight()) return;
for (int x = x0; x < x1; x += 14)
for (int k = 0; k < 8 && x + k < x1; k++) img.SetPixel(x + k, y, c);
}
private static void Polyline(Image img, List<(float x, float y)> pts, int n, Color c, int thick)
{
for (int i = 1; i < pts.Count; i++)
Line(img, (int)pts[i - 1].x, (int)pts[i - 1].y, (int)pts[i].x, (int)pts[i].y, n, c, thick);
}
private static void Line(Image img, int x0, int y0, int x1, int y1, int n, Color c, int thick)
{
int dx = Math.Abs(x1 - x0), sx = x0 < x1 ? 1 : -1;
int dy = -Math.Abs(y1 - y0), sy = y0 < y1 ? 1 : -1;
int err = dx + dy; int r = thick / 2;
int guard = 0;
while (true)
{
for (int ox = -r; ox <= r; ox++)
for (int oy = -r; oy <= r; oy++)
{
int px = x0 + ox, py = y0 + oy;
if (px >= 0 && py >= 0 && px < n && py < n) img.SetPixel(px, py, c);
}
if (x0 == x1 && y0 == y1) break;
if (++guard > 4 * n) break;
int e2 = 2 * err;
if (e2 >= dy) { err += dy; x0 += sx; }
if (e2 <= dx) { err += dx; y0 += sy; }
}
}
private static void Disc(Image img, int cx, int cy, int r, int n, Color c)
{
for (int ox = -r; ox <= r; ox++)
for (int oy = -r; oy <= r; oy++)
{
if (ox * ox + oy * oy > r * r) continue;
int px = cx + ox, py = cy + oy;
if (px >= 0 && py >= 0 && px < n && py < n) img.SetPixel(px, py, c);
}
}
private static void Ring(Image img, int cx, int cy, int r, int n, Color c, int w)
{
for (int ox = -r; ox <= r; ox++)
for (int oy = -r; oy <= r; oy++)
{
int d2 = ox * ox + oy * oy;
if (d2 > r * r || d2 < (r - w) * (r - w)) continue;
int px = cx + ox, py = cy + oy;
if (px >= 0 && py >= 0 && px < n && py < n) img.SetPixel(px, py, c);
}
}
private static void Square(Image img, int cx, int cy, int r, int n, Color c)
{
for (int ox = -r; ox <= r; ox++)
for (int oy = -r; oy <= r; oy++)
{
int px = cx + ox, py = cy + oy;
if (px >= 0 && py >= 0 && px < n && py < n) img.SetPixel(px, py, c);
}
}
}
}