islaApocalypse-v2/Tools/Scripts/HydrologyRenderer.cs
beezm ccdd6a1996 rivers/05: flow-through routing — river → lake → over the spill → … → sea; the hydrology map and the flow-direction field
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
2026-08-25 22:50:25 -04:00

279 lines
13 KiB
C#

using System;
using System.Collections.Generic;
using Godot;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
/// <summary>
/// ⭐⭐ THE HYDROLOGY MAP (rivers/05) — a first-class reference map, not a diagnostic dump (→ D-056).
///
/// "Here is where the water lives and where it flows": the island's relief, its lakes as water, the
/// flow-direction field as a quiet streamline texture, and the flow-through rivers prominent —
/// chaining visibly through lakes and low ground to the sea, width ∝ √drainage on the fixed law,
/// sea-reaching and lake-terminal distinguished, confluences as merges, dropped rivers as a ghost of
/// their stem only. Presentation only — reads data, writes pixels.
///
/// The companion <see cref="FlowDirection"/> plate is the DATA map of the field on its own: hue by
/// heading, sinks black, walled basins darkened — the one placement / flooding / irrigation reference.
/// </summary>
public static class HydrologyRenderer
{
private static readonly Color RiverSea = new(0.860f, 0.960f, 1.000f);
private static readonly Color RiverLake = new(1.000f, 0.840f, 0.520f);
private static readonly Color RiverEdge = new(0.050f, 0.110f, 0.240f);
private static readonly Color LakeWater = new(0.300f, 0.560f, 0.940f);
private static readonly Color PondWater = new(0.330f, 0.540f, 0.860f);
private static readonly Color Ghost = new(0.620f, 0.220f, 0.200f);
private static readonly Color Junction = new(1.000f, 1.000f, 1.000f);
private static readonly Color Stream = new(0.980f, 0.990f, 1.000f);
private static readonly Color Ink = new(0.941f, 0.949f, 0.961f);
private static readonly Color Ocean = new(0.055f, 0.110f, 0.235f);
private static readonly Color Sink = new(0.020f, 0.020f, 0.020f);
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 };
/// <summary>TinyFont carries only <c>. - : / ( ) 0-9 A-Z</c>; everything else would print as a gap. Map the punctuation the plate text uses.</summary>
public static string Txt(string s) => s
.Replace("%", " PCT").Replace("+", " AND ").Replace(";", " -").Replace(",", " -").Replace("'", "")
.Replace("→", "-").Replace("≤", "UNDER").Replace(">", "OVER").Replace("<", "UNDER").Replace("!", ".").Replace(" ", " ");
/// <summary>The shaded-relief base (the atlas look), quietened — desaturated and darkened a little so the water reads on top of it.</summary>
public static Image Base(float[,] height, int n, float sea)
{
var look = new LookConfig { SeaLevel = sea };
var img = ReliefRenderer.Render(height, n, look);
for (int x = 0; x < n; x++)
for (int y = 0; y < n; y++)
{
Color c = img.GetPixel(x, y);
float l = 0.299f * c.R + 0.587f * c.G + 0.114f * c.B;
Color q = c.Lerp(new Color(l, l, l), 0.38f);
img.SetPixel(x, y, new Color(q.R * 0.88f, q.G * 0.88f, q.B * 0.88f));
}
return img;
}
private static bool[] LakeIds(BasinGraph graph)
{
int max = 0;
foreach (var b in graph.Nodes) if (b.Id > max) max = b.Id;
var lake = new bool[max + 1];
foreach (var b in graph.Nodes) if (b.IsLake) lake[b.Id] = true;
return lake;
}
public static Image Hydrology(FlowThroughRouting.Result r, sbyte[] dir, DrainageAnalysis.Plan plan, BasinGraph graph,
Image img, int n, bool[] isOcean, bool[] isClassifyWater, string title, string subtitle, string third)
{
int total = n * n;
bool[] lakeId = LakeIds(graph);
bool IsLakeWater(int i) => isClassifyWater[i] && !isOcean[i] && plan.BasinId[i] != 0 && plan.BasinId[i] < lakeId.Length && lakeId[plan.BasinId[i]];
// 1. the lakes — every non-ocean classify body drawn as water; the significant ones (lake basins) a touch brighter.
for (int i = 0; i < total; i++)
{
if (!isClassifyWater[i] || isOcean[i]) continue;
img.SetPixel(i / n, i % n, IsLakeWater(i) ? LakeWater : PondWater);
}
// 2. the field as a streamline texture — quiet, present, not shouting.
Streamlines(img, dir, n, isOcean, isClassifyWater, n >= 4096 ? 56 : 28, 0.30f);
// 3. dropped rivers — a ghost of the upland stem only: "considered, dropped".
foreach (var fr in r.Rivers)
{
if (!fr.Dropped || fr.Routed.CellPath == null) continue;
int stem = Math.Min(fr.Routed.StemCells, fr.Routed.CellPath.Count);
for (int i = 0; i < stem; i++)
{
var (x, y) = fr.Routed.CellPath[i];
img.SetPixel(x, y, img.GetPixel(x, y).Lerp(Ghost, 0.55f));
}
}
// 4. the kept rivers — outlined, smallest first so the big ones finish on top; lake spans not drawn.
var kept = new List<FlowThroughRouting.FlowRiver>();
foreach (var fr in r.Rivers) if (!fr.Dropped && fr.Routed.CellPath != null && fr.Routed.CellPath.Count > 0) kept.Add(fr);
kept.Sort((a, b) => a.Candidate.DrainagePx.CompareTo(b.Candidate.DrainagePx));
foreach (var fr in kept) DrawRiver(img, fr, n, RiverEdge, +1, IsLakeWater);
foreach (var fr in kept) DrawRiver(img, fr, n, fr.ReachesSea ? RiverSea : RiverLake, 0, IsLakeWater);
// 5. markers — through the confluence root: a tributary's mouth is its trunk's mouth.
int mark = n >= 4096 ? 14 : 8;
foreach (var fr in kept)
{
if (fr.Routed.Joined)
{
DrainageRenderer.Disc(img, fr.Routed.JunctionCell.x, fr.Routed.JunctionCell.y, Math.Max(3, mark / 2), n, Junction);
continue;
}
if (fr.MouthCell < 0) continue;
int mx = fr.MouthCell / n, my = fr.MouthCell % n;
if (fr.Terminus == FlowThroughRouting.Terminus.Ocean)
{
DrainageRenderer.Square(img, mx, my, mark / 2, n, RiverSea);
DrainageRenderer.Ring(img, mx, my, mark, n, RiverEdge, 3);
}
else
{
DrainageRenderer.Disc(img, mx, my, mark / 2, n, RiverLake);
DrainageRenderer.Ring(img, mx, my, mark, n, RiverEdge, 3);
}
}
// 6. labels.
int ls = n >= 4096 ? 4 : 3;
var placer = new DrainageRenderer.LabelPlacer(n, ls, headerLines: 8);
int droppedLabels = 0;
var byArea = new List<FlowThroughRouting.FlowRiver>(kept);
byArea.Sort((a, b) => b.Candidate.DrainagePx.CompareTo(a.Candidate.DrainagePx));
foreach (var fr in byArea)
{
var c = fr.Candidate;
string tag = fr.Routed.Joined ? $"INTO R{fr.Routed.ConfluenceParentRank}"
: fr.Trunk ? "TRUNK" : fr.Terminus == FlowThroughRouting.Terminus.Ocean ? $"SEA VIA {fr.Chain.Count}" : $"LAKE {fr.TerminusBasinId}";
int lx = fr.Routed.Joined ? fr.Routed.JunctionCell.x : fr.MouthCell / n;
int ly = fr.Routed.Joined ? fr.Routed.JunctionCell.y : fr.MouthCell % n;
if (!placer.Place(img, Txt($"R{c.Rank} {DrainageRenderer.DrainageLabel(c.DrainagePx)} {tag}"), lx, ly, mark,
fr.Routed.Joined ? Junction : fr.ReachesSea ? RiverSea : RiverLake)) droppedLabels++;
}
// 7. the legend, on a dark bar so it reads on the relief.
int s = n >= 4096 ? 4 : 3; int lh = TinyFont.Height(s) + 6;
Bar(img, n, 12 + lh * 8 + 8);
TinyFont.Draw(img, Txt(title), 12, 12, s, Ink);
TinyFont.Draw(img, Txt(subtitle), 12, 12 + lh, s, Ink);
TinyFont.Draw(img, Txt(third), 12, 12 + lh * 2, s, Ink);
TinyFont.Draw(img, Txt($"PALE BLUE RIVER = REACHES THE SEA ({r.Trunks} NATURAL TRUNK + {r.FlowThrough} FLOW-THROUGH). SQUARE = MOUTH. AMBER RIVER = LAKE-TERMINAL ({r.LakeTerminal}), DISC = WHERE IT ENTERS ITS LAKE"), 12, 12 + lh * 3, s, RiverSea);
TinyFont.Draw(img, Txt($"WHITE DOT = CONFLUENCE ({r.Joined} JOINED). FAINT RED GHOST = A RIVER CONSIDERED AND DROPPED ({r.DroppedDry + r.DroppedClosed}) - ITS CHAIN WALLED AT A DRY SINK, SO IT IS NOT DRAWN"), 12, 12 + lh * 4, s, Junction);
TinyFont.Draw(img, Txt($"BLUE = EXISTING LAKES (CLASSIFY WATER). A RIVER'S SPAN ACROSS A LAKE IS WATER, NOT A DRAWN CHANNEL. STREAMLINES = THE FLOW-DIRECTION FIELD AT CAP {r.CapM:F0} M"), 12, 12 + lh * 5, s, LakeWater);
TinyFont.Draw(img, Txt($"WIDTH: {DrainageRenderer.StemWidthLaw()} - AS RIVERS/02B, 03, 03B, 03C"), 12, 12 + lh * 6, s, Ink);
TinyFont.Draw(img, "ROUTING AND DATA ONLY - NO HEIGHT MUTATED, NO WATER FILLED OR CREATED, NO BED CARVED." + (droppedLabels > 0 ? $" ({droppedLabels} LABEL(S) DROPPED)" : ""), 12, 12 + lh * 7, s, Ink);
return img;
}
/// <summary>Draw a river's OWN reach (up to its junction) as discs along its rasterised cells, skipping cells that are lake water.</summary>
private static void DrawRiver(Image img, FlowThroughRouting.FlowRiver fr, int n, Color c, int grow, Func<int, bool> isLakeWater)
{
int w = DrainageRenderer.StemWidthFixed(fr.Candidate.DrainagePx);
int rad = Math.Max(1, w / 2) + grow;
var cells = fr.Routed.CellPath;
int own = fr.Routed.Joined ? OwnLength(fr.Routed) : cells.Count;
for (int i = 0; i < own; i++)
{
var (x, y) = cells[i];
if (x < 0 || y < 0 || x >= n || y >= n) continue;
if (isLakeWater(x * n + y)) continue;
DrainageRenderer.Disc(img, x, y, rad, n, c);
}
}
private static int OwnLength(RiverRouting.RoutedRiver r)
{
for (int i = 0; i < r.CellPath.Count; i++)
if (r.CellPath[i].x == r.JunctionCell.x && r.CellPath[i].y == r.JunctionCell.y) return i + 1;
return r.CellPath.Count;
}
/// <summary>
/// The field as a texture: from a grid of seed cells on land, follow the field for a short run and
/// draw it faint-to-stronger along the flow, with a dot at the downstream end. A quiet island-wide
/// "which way does water go here" that never competes with the rivers.
/// </summary>
private static void Streamlines(Image img, sbyte[] dir, int n, bool[] isOcean, bool[] isClassifyWater, int step, float alpha)
{
int len = (int)(step * 0.7f);
for (int gx = step / 2; gx < n; gx += step)
for (int gy = step / 2; gy < n; gy += step)
{
int c = gx * n + gy;
if (isOcean[c] || isClassifyWater[c] || dir[c] < 0) continue;
for (int k = 0; k < len; k++)
{
sbyte d = dir[c];
if (d < 0) break;
int cx = c / n, cy = c % n;
int t = (cx + DX[d]) * n + (cy + DY[d]);
if (isOcean[t]) break;
float a = alpha * (0.35f + 0.65f * k / len);
img.SetPixel(t / n, t % n, img.GetPixel(t / n, t % n).Lerp(Stream, a));
c = t;
}
img.SetPixel(c / n, c % n, img.GetPixel(c / n, c % n).Lerp(Stream, alpha * 1.4f));
}
}
private static void Bar(Image img, int n, int height)
{
for (int y = 0; y < Math.Min(height, n); y++)
for (int x = 0; x < n; x++)
img.SetPixel(x, y, img.GetPixel(x, y).Lerp(new Color(0.04f, 0.05f, 0.07f), 0.72f));
}
/// <summary>
/// ⭐ THE FLOW-DIRECTION DATA MAP — hue by heading (the wheel: N red, E yellow-green, S cyan, W violet),
/// sinks black, ocean dark, walled basins darkened, lakes as water at half strength so the field still
/// shows through, streamlines on top. Not pretty by design — legible.
/// </summary>
public static Image FlowDirection(sbyte[] dir, int[] acc, DrainageAnalysis.Plan plan, BasinGraph graph, HashSet<int> walled,
int n, bool[] isOcean, bool[] isClassifyWater, string title, string subtitle, string third)
{
var img = Image.CreateEmpty(n, n, false, Image.Format.Rgb8);
long maxAcc = 1;
for (int i = 0; i < acc.Length; i++) if (acc[i] > maxAcc) maxAcc = acc[i];
double lmax = Math.Log(1.0 + maxAcc);
var hue = new Color[8];
for (int k = 0; k < 8; k++)
{
// screen +y is SOUTH, so flip y to get a compass angle; hue 0 at north, clockwise.
float ang = MathF.Atan2(DX[k], -DY[k]); // 0 = north, +π/2 = east
float h = (ang / (2f * MathF.PI) + 1f) % 1f;
hue[k] = Color.FromHsv(h, 0.62f, 0.86f);
}
bool[] lakeId = LakeIds(graph);
for (int i = 0; i < n * n; i++)
{
int x = i / n, y = i % n;
if (isOcean[i]) { img.SetPixel(x, y, Ocean); continue; }
sbyte d = dir[i];
Color c;
if (d < 0) c = Sink;
else
{
// Hue = heading; brightness = log accumulation on the capped field, so the field's own drainage
// tree reads as bright channels on dark slopes and the per-cell heading noise stays quiet.
float v = (float)(Math.Log(1.0 + acc[i]) / lmax);
float b = 0.22f + 0.78f * v;
c = new Color(hue[d].R * b, hue[d].G * b, hue[d].B * b);
}
int id = plan.BasinId[i];
if (id != 0 && walled.Contains(id)) c = c.Lerp(new Color(0.55f, 0.08f, 0.08f), 0.35f);
if (isClassifyWater[i])
c = c.Lerp(id != 0 && id < lakeId.Length && lakeId[id] ? LakeWater : PondWater, 0.45f);
img.SetPixel(x, y, c);
}
int s = n >= 4096 ? 4 : 3; int lh = TinyFont.Height(s) + 6;
Bar(img, n, 12 + lh * 5 + 8);
TinyFont.Draw(img, Txt(title), 12, 12, s, Ink);
TinyFont.Draw(img, Txt(subtitle), 12, 12 + lh, s, Ink);
TinyFont.Draw(img, Txt(third), 12, 12 + lh * 2, s, Ink);
// the wheel, as swatches
string[] names = { "NW", "W", "SW", "N", "S", "NE", "E", "SE" };
int cx0 = 12, cy0 = 12 + lh * 3;
TinyFont.Draw(img, Txt("HUE = HEADING:"), cx0, cy0, s, Ink);
int cursor = cx0 + TinyFont.Width("HUE = HEADING: ", s);
int[] order = { 3, 5, 6, 7, 4, 2, 1, 0 }; // N NE E SE S SW W NW
foreach (int k in order)
{
DrainageRenderer.Square(img, cursor + 8, cy0 + TinyFont.Height(s) / 2, 7, n, hue[k]);
TinyFont.Draw(img, names[k], cursor + 20, cy0, s, Ink);
cursor += 20 + TinyFont.Width(names[k] + " ", s);
}
TinyFont.Draw(img, Txt("BRIGHTNESS = LOG FLOW ACCUMULATION ON THIS FIELD (CHANNELS BRIGHT). BLACK = SINK (A WALLED BASIN FLOOR). RED-TINTED = INSIDE A WALLED BASIN - FLOW ENTERING IT ENDS THERE. BLUE HAZE = CLASSIFY WATER."), 12, 12 + lh * 4, s, Ink);
return img;
}
}
}