From ccdd6a1996cbd2e1868117a1d80b2c9eb5d9f114 Mon Sep 17 00:00:00 2001 From: beezm Date: Tue, 25 Aug 2026 22:50:25 -0400 Subject: [PATCH] =?UTF-8?q?rivers/05:=20flow-through=20routing=20=E2=80=94?= =?UTF-8?q?=20river=20=E2=86=92=20lake=20=E2=86=92=20over=20the=20spill=20?= =?UTF-8?q?=E2=86=92=20=E2=80=A6=20=E2=86=92=20sea;=20the=20hydrology=20ma?= =?UTF-8?q?p=20and=20the=20flow-direction=20field?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01EppUMXNhSeuA5Mu51UnTyP --- Tools/Scenes/FlowThroughTool.tscn | 6 + Tools/Scripts/DrainageRenderer.cs | 2 +- Tools/Scripts/FlowThroughRouting.cs | 642 ++++++++++++++++++++++++++++ Tools/Scripts/FlowThroughTool.cs | 582 +++++++++++++++++++++++++ Tools/Scripts/HydrologyRenderer.cs | 279 ++++++++++++ Tools/Scripts/RiverRouting.cs | 2 +- 6 files changed, 1511 insertions(+), 2 deletions(-) create mode 100644 Tools/Scenes/FlowThroughTool.tscn create mode 100644 Tools/Scripts/FlowThroughRouting.cs create mode 100644 Tools/Scripts/FlowThroughTool.cs create mode 100644 Tools/Scripts/HydrologyRenderer.cs diff --git a/Tools/Scenes/FlowThroughTool.tscn b/Tools/Scenes/FlowThroughTool.tscn new file mode 100644 index 0000000..68d81de --- /dev/null +++ b/Tools/Scenes/FlowThroughTool.tscn @@ -0,0 +1,6 @@ +[gd_scene format=3 uid="uid://flowthrough05"] + +[ext_resource type="Script" path="res://Tools/Scripts/FlowThroughTool.cs" id="1_ftt05"] + +[node name="FlowThroughTool" type="Node"] +script = ExtResource("1_ftt05") diff --git a/Tools/Scripts/DrainageRenderer.cs b/Tools/Scripts/DrainageRenderer.cs index f481e92..f26ec0a 100644 --- a/Tools/Scripts/DrainageRenderer.cs +++ b/Tools/Scripts/DrainageRenderer.cs @@ -347,7 +347,7 @@ namespace IslaApocalypse.Tools /// DROPPED rather than drawn illegibly on top of one — and every caller reports how many, so a /// missing number is never silent. Shared by the composition and routed-mix plates. /// - private sealed class LabelPlacer + internal sealed class LabelPlacer { private readonly List _placed = new(); private readonly int _n, _scale, _pad; diff --git a/Tools/Scripts/FlowThroughRouting.cs b/Tools/Scripts/FlowThroughRouting.cs new file mode 100644 index 0000000..5f0d9b3 --- /dev/null +++ b/Tools/Scripts/FlowThroughRouting.cs @@ -0,0 +1,642 @@ +using System; +using System.Collections.Generic; +using IslaApocalypse.Core; + +namespace IslaApocalypse.Tools +{ + /// + /// ⭐⭐ FLOW-THROUGH ROUTING (rivers/05) — river → lake → over the spill → river → … → sea. + /// + /// ═══ THE MODEL ═══ + /// + /// The reference routes a river to the FIRST terminus it can afford and stops. This replaces that with + /// CHAINING THROUGH THE BASIN GRAPH (rivers/04): a promoted river runs down its erosion-carved stem to + /// its terminal basin and from there follows the terrain's own overflow structure — into the lake or + /// across the dry pan, over the basin's spill, into the next basin, over its spill — until it reaches + /// the coast, walls at a real lake, or walls at a dry sink. + /// + /// THE FIELD one island-wide D8 direction per land cell on Plan.FullFilled (the overflow + /// surface, rivers/04 §0.2: on it every basin's minimum is its spill, so descent leaves + /// each basin over its spill into the next). Cap-independent; computed once per seed. + /// THE WALK a river FOLLOWS the field from its terminal. Each basin it enters is checked once: + /// floor→spill climb (BasinNode.SpillClimbM, the fill-to-overtop metric, applied + /// uniformly to lake and dry basins) ≤ cap → overflow, continue; > cap → walled, stop. + /// DISPOSITION reaches OceanMask → KEEP (flow-through to the sea); walls at an IsLake + /// basin → KEEP (lake-terminal, feeds visible water); walls at a dry or puddle-only basin + /// → DROP the river entirely (a river dead-ending in dry nowhere is worse than no river, + /// and nothing is filled). Read through the CONFLUENCE ROOT: a river that joins a kept + /// river is kept as its tributary, whatever its own chain would have done. + /// CONFLUENCE rivers/03b's, reused verbatim: biggest-first, true cell intersection, never proximity. + /// + /// ═══ ⛔ THE RED LINE ═══ + /// + /// **Courses, a direction field, and data. No height written, no bed carved, no water created or + /// filled.** The caller digests both height fields around this and refuses on any change. + /// + /// ═══ ⚠ D-046 — which surface each step reads ═══ + /// + /// RENDER the field (FullFilled), the climbs (SpillClimbM), the real-terrain descent into + /// a lake (Plan.Dir on Filled), the lowground fallback (RouteTo on p2.Height). + /// CLASSIFY every terminus test: OceanMask for the sea, IsLake (≥ floor) for a lake, and + /// "is this cell the basin's own water" for where a river enters a lake. No bare h < sea. + /// + /// ═══ ⭐ HOW A LAKE BASIN IS CROSSED (the one place the field is not simply followed) ═══ + /// + /// The field inside a basin is the flood's ulp-staircase — it points from anywhere in the basin straight + /// at the spill, IGNORING the lake, because the flood never asked where the low water is. Water entering + /// a lake basin does not skirt the lake to the spill; it runs down to the lake, fills it, and leaves at + /// the spill. So inside an IsLake basin the course is: the REAL-TERRAIN descent from the entry + /// point into the basin's own classify water (Plan.Dir; rivers/03c fix B's lowground route as the + /// fallback when the descent pools short of the water), then the LAKE SPAN (water — recorded, not drawn), + /// then the OUTLET: from the lake's lowest cell on FullFilled (its point nearest the spill in flood + /// terms) along the field over the spill. A dry basin is crossed on the field as a visible line. + /// + public static class FlowThroughRouting + { + 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 const sbyte D_NONE = -1; + + public enum Terminus : byte + { + /// The chain reached OceanMask. + Ocean, + /// Walled at an IsLake basin — a significant lake. + Lake, + /// Walled at a dry (or puddle-only) basin. + DrySink, + /// The walk stuck with no lower neighbour outside any basin — an exact flat. Not expected. + Closed, + } + + /// One basin the chain entered. + public sealed class Hop + { + public int BasinId; + public bool IsLake; + /// Floor→spill climb, metres, clamped at sea as RouteTo clamps — the cap metric. + public float ClimbM; + public bool Walled; + public int EntryCell; + /// The first cell outside the basin on the way out (-1 if walled). + public int SpillCell = -1; + /// ⭐ The cell the river actually ENTERED the lake at (lake basins only; -1 otherwise). + public int LakeEntryCell = -1; + /// Cross-check: the field's exit from this basin lands where BasinGraph's edge says. + public bool EdgeAgreesWithGraph = true; + /// The lake-entry descent had to fall back to the lowground route (the terminal pooled short of the water). + public bool UsedLowgroundFallback; + } + + /// One promoted river, walked, disposed, assembled. + public sealed class FlowRiver + { + public RiverCandidate Candidate; + public List Chain = new(); + /// This river's OWN terminus, before confluence. + public Terminus Terminus; + public int TerminusBasinId; + /// The ocean cell entered, the lake cell entered, or where a dry/closed chain ended. + public int MouthCell = -1; + /// Head → terminus: the upland stem then the lowland chain. Lake spans are straight jumps across water. + public List<(float x, float y)> Course; + /// Lake spans: (entry water cell, outlet water cell) — the parts of the course that are water, not channel. + public List<(int from, int to)> WaterSpans = new(); + public float StemLenPx, LowlandLenPx; + public float TotalLenPx => StemLenPx + LowlandLenPx; + public float MaxHopClimbM, TotalClimbM; + public int LakesPassed; + /// The rivers/03b confluence wrapper — Joined, ConfluenceParentRank, CellPath, OwnPath, StemCells. + public RiverRouting.RoutedRiver Routed; + /// ⭐ The disposition of record — read through the confluence root. + public Terminus RootTerminus; + public bool Dropped; + public bool Trunk => Candidate.IsSea; + public bool ReachesSea => !Dropped && RootTerminus == Terminus.Ocean; + public string Why = ""; + } + + /// The cap-independent field: D8 on FullFilled, 0..7 or (ocean, or no lower neighbour). + public sealed class Field + { + public sbyte[] Dir; + public int N; + public int Target(int i) + { + sbyte d = Dir[i]; + if (d < 0) return -1; + int cx = i / N, cy = i % N; + return (cx + DX[d]) * N + (cy + DY[d]); + } + } + + /// Per-seed precomputation shared by every cap: each lake basin's water cells and outlet cell; every basin's fill volume. + public sealed class Prep + { + public Dictionary> LakeCells = new(); + /// + /// ⭐ Per WATER BODY (an 8-connected component of a lake basin's own classify water): its cell with the lowest + /// FullFilled — the body's point nearest the spill in flood terms, where its overflow leaves. Per body, not + /// per basin: one basin can own several separate lakes (rivers/04 found `1063685222 #699` owning two), and a + /// river that enters one must leave from THAT one, not jump across land to another. + /// + public Dictionary BodyOut = new(); + /// Water cell → its body id (lake basins' own water only). + public Dictionary BodyOf = new(); + /// Per basin id: Σ (FullFilled − render) × metres, over its cells — the volume to fill it to its spill, in metre·cells. + public Dictionary FillVolumeMPx = new(); + public bool[] Scratch; // one reusable target mask for the lowground fallback + } + + public sealed class HeroLake + { + public int BasinId; public long LakeCells; public double FillVolumeMPx; + public float RiverLenPx; public int RiverRank; public int RiversThrough; + public double Score; + } + + public sealed class Result + { + public float CapM; + public List Rivers = new(); + public HashSet WalledIds = new(); + public int Trunks, FlowThrough, LakeTerminal, DroppedDry, DroppedClosed, Joined, RescuedByConfluence, DroppedByConfluence; + public int EdgeAgree, EdgeDisagree, LowgroundFallbacks; + /// The field AT THIS CAP: the ∞ field with every walled basin's cells replaced by D8 on the real terrain, so flow into a walled basin ends there. + public sbyte[] CappedDir; + /// Flow accumulation on the capped field (Kahn), cells; 0 on ocean — the field's own drainage tree, for the data map. + public int[] CappedAcc; + public long LandCells, CellsToSea, CellsToWalledLake, CellsToWalledDry, CellsStuck; + public List HeroLakes = new(); + } + + // ═══ THE FIELD ══════════════════════════════════════════════════════════════════════════ + + /// D8 on FullFilled for every non-ocean cell, the analysis's exact neighbour order and drop/DIST rule. Ocean cells are . + public static Field BuildField(DrainageAnalysis.Plan plan, int n, bool[] isOcean) + { + int total = n * n; + var dir = new sbyte[total]; + float[] ff = plan.FullFilled; + for (int i = 0; i < total; i++) + { + if (isOcean[i]) { dir[i] = D_NONE; continue; } + int cx = i / n, cy = i % n; + float best = 0f; int bestK = -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; + float drop = (ff[i] - ff[nx * n + ny]) / DIST[k]; + if (drop > best) { best = drop; bestK = k; } + } + dir[i] = bestK < 0 ? D_NONE : (sbyte)bestK; + } + return new Field { Dir = dir, N = n }; + } + + public static Prep Prepare(DrainageAnalysis.Plan plan, BasinGraph graph, float[,] height, int n, bool[] isOcean, bool[] isClassifyWater) + { + int total = n * n; + var p = new Prep { Scratch = new bool[total] }; + var isLake = new HashSet(); + foreach (var b in graph.Nodes) if (b.IsLake) isLake.Add(b.Id); + var outFF = new Dictionary(); + for (int i = 0; i < total; i++) + { + int id = plan.BasinId[i]; + if (id == 0) continue; + double d = WorldScale.MetresFromRaw(plan.FullFilled[i] - height[i / n, i % n]); + p.FillVolumeMPx.TryGetValue(id, out double v); p.FillVolumeMPx[id] = v + d; + if (!isLake.Contains(id) || !isClassifyWater[i] || isOcean[i]) continue; + if (!p.LakeCells.TryGetValue(id, out var cells)) { cells = new List(); p.LakeCells[id] = cells; } + cells.Add(i); + } + // Label each lake basin's water into bodies (8-connected, fixed order) and find each body's outlet cell. + int nextBody = 1; + var stack = new Stack(); + foreach (var kv in p.LakeCells) + { + var set = new HashSet(kv.Value); + foreach (int seed in kv.Value) + { + if (p.BodyOf.ContainsKey(seed)) continue; + int body = nextBody++; + p.BodyOf[seed] = body; stack.Push(seed); + int outCell = seed; float outFFv = plan.FullFilled[seed]; + while (stack.Count > 0) + { + int c = stack.Pop(); + if (plan.FullFilled[c] < outFFv || (plan.FullFilled[c] == outFFv && c < outCell)) { outFFv = plan.FullFilled[c]; outCell = 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 (!set.Contains(ni) || p.BodyOf.ContainsKey(ni)) continue; + p.BodyOf[ni] = body; stack.Push(ni); + } + } + p.BodyOut[body] = outCell; + } + } + return p; + } + + /// The shortest 8-connected path THROUGH a body's water from one of its cells to another (BFS, fixed order). Water, not channel — recorded, never drawn. + private static List WaterPath(Prep prep, int from, int to, int n) + { + int body = prep.BodyOf[from]; + var parent = new Dictionary { [from] = -1 }; + var q = new Queue(); q.Enqueue(from); + while (q.Count > 0) + { + int c = q.Dequeue(); + if (c == to) break; + 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 (parent.ContainsKey(ni) || !prep.BodyOf.TryGetValue(ni, out int b) || b != body) continue; + parent[ni] = c; q.Enqueue(ni); + } + } + var path = new List(); + if (!parent.ContainsKey(to)) { path.Add(from); path.Add(to); return path; } + for (int c = to; c >= 0; c = parent[c]) path.Add(c); + path.Reverse(); + return path; + } + + // ═══ THE WALKS ══════════════════════════════════════════════════════════════════════════ + + public static Result Run(List promoted, DrainageAnalysis.Plan plan, BasinGraph graph, Field field, Prep prep, + float[,] height, int n, bool[] isOcean, bool[] isClassifyWater, float sea, float capM, Action log, bool confluence = true) + { + var r = new Result { CapM = capM }; + foreach (var b in graph.LandNodes) if (b.SpillClimbM > capM) r.WalledIds.Add(b.Id); + + foreach (var c in promoted) + { + var fr = Walk(c, plan, graph, field, prep, height, n, isOcean, isClassifyWater, sea, capM, r); + r.Rivers.Add(fr); + } + + // ---- confluence (rivers/03b, reused) over EVERY river, kept or not — a river that meets a kept river's + // channel before its own dead-end is that river's tributary, and its water reaches the sea through it. + var wrappers = new List(); + foreach (var fr in r.Rivers) + { + fr.Routed = new RiverRouting.RoutedRiver + { + Candidate = fr.Candidate, Course = fr.Course, + Class = fr.Trunk ? RiverRouting.RiverClass.OceanTrunk + : fr.Terminus == Terminus.Ocean ? RiverRouting.RiverClass.RoutedGiant + : fr.Terminus == Terminus.Lake ? RiverRouting.RiverClass.LakeEnder + : RiverRouting.RiverClass.WalledOff, + }; + wrappers.Add(fr.Routed); + } + if (confluence) RiverRouting.Confluence(wrappers, log); + else foreach (var w in wrappers) w.OwnPath = w.Course; + + var byRank = new Dictionary(); + foreach (var fr in r.Rivers) byRank[fr.Candidate.Rank] = fr; + foreach (var fr in r.Rivers) + { + var root = RiverRouting.Root(fr.Routed, wrappers); + var rootFr = byRank[root.Candidate.Rank]; + fr.RootTerminus = rootFr.Terminus; + fr.Dropped = fr.RootTerminus == Terminus.DrySink || fr.RootTerminus == Terminus.Closed; + bool ownKept = fr.Terminus == Terminus.Ocean || fr.Terminus == Terminus.Lake; + if (fr.Routed.Joined) + { + r.Joined++; + if (!ownKept && !fr.Dropped) r.RescuedByConfluence++; + if (ownKept && fr.Dropped) r.DroppedByConfluence++; + } + if (fr.Dropped) { if (fr.RootTerminus == Terminus.Closed) r.DroppedClosed++; else r.DroppedDry++; } + else if (fr.Trunk) r.Trunks++; + else if (fr.RootTerminus == Terminus.Ocean) r.FlowThrough++; + else r.LakeTerminal++; + foreach (var h in fr.Chain) { if (h.UsedLowgroundFallback) r.LowgroundFallbacks++; } + } + foreach (var fr in r.Rivers) + for (int i = 0; i < fr.Chain.Count; i++) + if (fr.Chain[i].SpillCell >= 0) { if (fr.Chain[i].EdgeAgreesWithGraph) r.EdgeAgree++; else r.EdgeDisagree++; } + return r; + } + + private static FlowRiver Walk(RiverCandidate c, DrainageAnalysis.Plan plan, BasinGraph graph, Field field, Prep prep, + float[,] height, int n, bool[] isOcean, bool[] isClassifyWater, float sea, float capM, Result res) + { + var fr = new FlowRiver { Candidate = c }; + // The upland stem, head → terminal (the analysis's course is downstream-first, decimated ×4). + fr.Course = new List<(float x, float y)>(c.Course); + fr.Course.Reverse(); + fr.StemLenPx = PolyLen(fr.Course); + + if (c.IsSea) + { + fr.Terminus = Terminus.Ocean; + fr.MouthCell = c.Cell; + fr.Why = "natural ocean trunk — erosion already reaches the coast"; + return fr; + } + + int cur = c.TermX * n + c.TermY; + int basin = plan.BasinId[cur] != 0 ? plan.BasinId[cur] : c.BasinId; + var visited = new HashSet(); + var why = new System.Text.StringBuilder(); + + for (int guard = 0; guard < 256; guard++) + { + var node = graph.Of(basin); + if (node == null || !visited.Add(basin)) + { + fr.Terminus = Terminus.Closed; fr.TerminusBasinId = basin; fr.MouthCell = cur; + why.Append(node == null ? $" → basin #{basin} not in the graph (closed)" : $" → basin #{basin} revisited (closed)"); + break; + } + var hop = new Hop { BasinId = basin, IsLake = node.IsLake, ClimbM = node.SpillClimbM, Walled = node.SpillClimbM > capM, EntryCell = cur }; + fr.Chain.Add(hop); + if (hop.ClimbM > fr.MaxHopClimbM) fr.MaxHopClimbM = hop.ClimbM; + + if (hop.Walled) + { + fr.TerminusBasinId = basin; + if (node.IsLake) + { + var reach = DescendToWater(cur, basin, plan, prep, height, n, isOcean, isClassifyWater, sea, out bool fb); + hop.UsedLowgroundFallback = fb; + if (reach.Count > 1) AppendReach(fr, reach, n); + hop.LakeEntryCell = reach[^1]; + fr.Terminus = Terminus.Lake; fr.MouthCell = reach[^1]; + why.Append($" → #{basin} LAKE, rim {hop.ClimbM:F1} m > cap {capM:F0} m: walls at the lake — LAKE-TERMINAL, kept"); + } + else + { + fr.Terminus = Terminus.DrySink; fr.MouthCell = cur; + why.Append($" → #{basin} DRY{(node.HasAnyLake ? " (puddle only)" : "")}, rim {hop.ClimbM:F1} m > cap {capM:F0} m: walls at a dry sink — DROPPED"); + } + break; + } + + fr.TotalClimbM += hop.ClimbM; + int from = cur; + if (node.IsLake) + { + var reach = DescendToWater(cur, basin, plan, prep, height, n, isOcean, isClassifyWater, sea, out bool fb); + hop.UsedLowgroundFallback = fb; + if (reach.Count > 1) AppendReach(fr, reach, n); + int w = reach[^1]; + hop.LakeEntryCell = w; + int lakeOut = prep.BodyOf.TryGetValue(w, out int body) && prep.BodyOut.TryGetValue(body, out int bo) ? bo : w; + if (lakeOut != w) + { + // The lake span — through the water of the body the river entered, to that body's outlet. + fr.WaterSpans.Add((w, lakeOut)); + var span = WaterPath(prep, w, lakeOut, n); + for (int i = 1; i < span.Count; i++) fr.Course.Add((span[i] / n, span[i] % n)); + } + fr.LakesPassed++; + from = lakeOut; + why.Append($" → #{basin} LAKE, rim {hop.ClimbM:F1} m ≤ cap: through the lake and over its spill"); + } + else why.Append($" → #{basin} dry, rim {hop.ClimbM:F1} m ≤ cap: across the low ground and over its spill"); + + var path = Follow(from, basin, field, plan, isOcean, n, out int status, out int spill); + hop.SpillCell = spill; + if (path.Count > 1) AppendReach(fr, path, n); + int end = path[^1]; + + if (status == 1) + { + fr.Terminus = Terminus.Ocean; fr.MouthCell = end; + hop.EdgeAgreesWithGraph = node.Downstream == DownstreamKind.Ocean; + why.Append(" → the SEA — flow-through, kept"); + break; + } + if (status == 0) + { + fr.Terminus = Terminus.Closed; fr.TerminusBasinId = 0; fr.MouthCell = end; + hop.EdgeAgreesWithGraph = node.Downstream == DownstreamKind.None; + why.Append(" → stuck on an exact flat outside any basin — CLOSED, dropped"); + break; + } + int next = plan.BasinId[end]; + hop.EdgeAgreesWithGraph = node.Downstream == DownstreamKind.Basin && node.DownstreamId == next; + cur = end; basin = next; + } + fr.Why = $"terminal basin #{fr.Chain[0].BasinId}" + why; + return fr; + } + + /// + /// Follow the ∞ field from a cell inside until it reaches the ocean (status 1), + /// enters another terminal basin (status 2), or sticks (status 0). is the first + /// cell outside the basin on the way. + /// + private static List Follow(int start, int basin, Field field, DrainageAnalysis.Plan plan, bool[] isOcean, int n, + out int status, out int spill) + { + var path = new List { start }; + int c = start; spill = -1; status = 0; + for (int guard = 0; guard < 8 * n; guard++) + { + int b = plan.BasinId[c]; + if (b != basin && spill < 0) spill = c; + if (isOcean[c]) { status = 1; return path; } + if (b != basin && b != 0) { status = 2; return path; } + int t = field.Target(c); + if (t < 0) { status = 0; return path; } + c = t; path.Add(c); + } + return path; + } + + /// + /// The real-terrain descent from a point inside a lake basin to the basin's own classify water: + /// Plan.Dir (D8 on Filled) until a water cell; if it pools short (the terminal is a D8 + /// sink by definition), rivers/03c fix B's lowground route to the basin's own water — same rule, same cost model. + /// + private static List DescendToWater(int start, int basin, DrainageAnalysis.Plan plan, Prep prep, float[,] height, int n, + bool[] isOcean, bool[] isClassifyWater, float sea, out bool usedFallback) + { + usedFallback = false; + var path = new List { start }; + int c = start; + bool IsOwnWater(int i) => isClassifyWater[i] && !isOcean[i] && plan.BasinId[i] == basin; + for (int guard = 0; guard < 8 * n; guard++) + { + if (IsOwnWater(c)) return path; + sbyte d = plan.Dir[c]; + if (d < 0) break; + int cx = c / n, cy = c % n; + int t = (cx + DX[d]) * n + (cy + DY[d]); + if (plan.BasinId[t] != basin) break; + c = t; path.Add(c); + } + // Pooled short of the water — route the rest as rivers/03c does for a lake-ender. + if (!prep.LakeCells.TryGetValue(basin, out var cells) || cells.Count == 0) return path; + usedFallback = true; + foreach (int i in cells) prep.Scratch[i] = true; + var route = RiverRouting.RouteTo(height, n, prep.Scratch, start / n, start % n, RiverRouting.StyleLowground, sea); + foreach (int i in cells) prep.Scratch[i] = false; + if (!route.Reached) return path; + var outp = new List(route.Path.Count); + foreach (var p in route.Path) outp.Add((int)p.x * n + (int)p.y); + return outp; + } + + /// Append a 1-px cell reach to the course, RDP+Chaikin-smoothed as rivers/03 smooths every lowland reach (endpoints pinned). + private static void AppendReach(FlowRiver fr, List cells, int n) + { + var pts = new List<(float x, float y)>(cells.Count); + foreach (int i in cells) pts.Add((i / n, i % n)); + fr.LowlandLenPx += PolyLen(pts); + var sm = RiverRouting.SmoothCourse(pts); + int start = fr.Course.Count > 0 && fr.Course[^1].x == sm[0].x && fr.Course[^1].y == sm[0].y ? 1 : 0; + for (int i = start; i < sm.Count; i++) fr.Course.Add(sm[i]); + } + + private static float PolyLen(List<(float x, float y)> pts) + { + float L = 0f; + for (int i = 1; i < pts.Count; i++) + { + float dx = pts[i].x - pts[i - 1].x, dy = pts[i].y - pts[i - 1].y; + L += MathF.Sqrt(dx * dx + dy * dy); + } + return L; + } + + // ═══ THE FIELD AT THE CAP — the artifact ════════════════════════════════════════════════ + + /// + /// The ∞ field with every WALLED basin's cells replaced by D8 on the real terrain (Filled), so + /// flow that reaches a walled basin descends to its floor and ends there — "a cell whose downstream + /// chain walls off drains to that wall, not past it". Then every land cell's destination, memoised. + /// + public static void BuildCappedField(Result r, DrainageAnalysis.Plan plan, BasinGraph graph, Field field, int n, bool[] isOcean) + { + int total = n * n; + var dir = (sbyte[])field.Dir.Clone(); + float[] filled = plan.Filled; + for (int i = 0; i < total; i++) + { + int id = plan.BasinId[i]; + if (id == 0 || !r.WalledIds.Contains(id) || isOcean[i]) continue; + int cx = i / n, cy = i % n; + float best = 0f; int bestK = -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; + float drop = (filled[i] - filled[nx * n + ny]) / DIST[k]; + if (drop > best) { best = drop; bestK = k; } + } + dir[i] = bestK < 0 ? D_NONE : (sbyte)bestK; + } + r.CappedDir = dir; + + // Accumulation on the capped field — Kahn propagation, as the analysis does on its own field. + { + var acc = new int[total]; + var indeg = new byte[total]; + int Tgt(int i) { sbyte d = dir[i]; if (d < 0) return -1; int cx = i / n, cy = i % n; return (cx + DX[d]) * n + (cy + DY[d]); } + for (int i = 0; i < total; i++) if (!isOcean[i] && dir[i] >= 0) { int t = Tgt(i); if (!isOcean[t]) indeg[t]++; } + var q = new Queue(); + for (int i = 0; i < total; i++) { if (isOcean[i]) continue; acc[i] = 1; if (indeg[i] == 0) q.Enqueue(i); } + while (q.Count > 0) + { + int c = q.Dequeue(); + int t = Tgt(c); + if (t < 0 || isOcean[t]) continue; + acc[t] += acc[c]; + if (--indeg[t] == 0) q.Enqueue(t); + } + r.CappedAcc = acc; + } + + // Destinations: -1 sea, >0 basin id (a sink), -2 stuck. + var lakeIds = new HashSet(); + foreach (var b in graph.Nodes) if (b.IsLake) lakeIds.Add(b.Id); + var dest = new int[total]; + var path = new List(4096); + for (int i = 0; i < total; i++) + { + if (isOcean[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); + sbyte d = dir[c]; + if (d < 0) { result = plan.BasinId[c] != 0 ? plan.BasinId[c] : -2; break; } + int cx = c / n, cy = c % n; + int t = (cx + DX[d]) * n + (cy + DY[d]); + if (isOcean[t]) { result = -1; break; } + c = t; + } + foreach (int pc in path) dest[pc] = result; + } + for (int i = 0; i < total; i++) + { + if (isOcean[i]) continue; + r.LandCells++; + int d = dest[i]; + if (d == -1) r.CellsToSea++; + else if (d > 0) { if (lakeIds.Contains(d)) r.CellsToWalledLake++; else r.CellsToWalledDry++; } + else r.CellsStuck++; + } + } + + // ═══ THE HERO-LAKE CANDIDATE — data, not a fill ═════════════════════════════════════════ + + /// + /// Among lakes that a sea-reaching, un-joined river flows THROUGH, rank by the geometric mean of + /// normalised fill volume and normalised attached river length. Recorded intent (procedural, executed + /// post-water-render); nothing is filled here. + /// + public static void RankHeroLakes(Result r, BasinGraph graph, Prep prep) + { + var cand = new Dictionary(); + foreach (var fr in r.Rivers) + { + if (fr.Dropped || fr.Trunk || fr.Routed.Joined || fr.Terminus != Terminus.Ocean) continue; + foreach (var h in fr.Chain) + { + if (!h.IsLake || h.Walled) continue; + if (!cand.TryGetValue(h.BasinId, out var hl)) + { + var node = graph.Of(h.BasinId); + hl = new HeroLake { BasinId = h.BasinId, LakeCells = node.LakeCells, FillVolumeMPx = prep.FillVolumeMPx.TryGetValue(h.BasinId, out double v) ? v : 0 }; + cand[h.BasinId] = hl; + } + hl.RiversThrough++; + if (fr.TotalLenPx > hl.RiverLenPx) { hl.RiverLenPx = fr.TotalLenPx; hl.RiverRank = fr.Candidate.Rank; } + } + } + double maxV = 1, maxL = 1; + foreach (var hl in cand.Values) { if (hl.FillVolumeMPx > maxV) maxV = hl.FillVolumeMPx; if (hl.RiverLenPx > maxL) maxL = hl.RiverLenPx; } + foreach (var hl in cand.Values) hl.Score = Math.Sqrt((hl.FillVolumeMPx / maxV) * (hl.RiverLenPx / maxL)); + r.HeroLakes = new List(cand.Values); + r.HeroLakes.Sort((a, b) => b.Score != a.Score ? b.Score.CompareTo(a.Score) : a.BasinId.CompareTo(b.BasinId)); + } + + public static string TerminusName(Terminus t) => t switch + { + Terminus.Ocean => "sea", Terminus.Lake => "lake", Terminus.DrySink => "dry-sink", _ => "closed", + }; + + public static string ClassName(FlowRiver fr) => + fr.Dropped ? "dropped" : fr.Trunk ? "trunk" : fr.RootTerminus == Terminus.Ocean ? "flow-through" : "lake-terminal"; + } +} diff --git a/Tools/Scripts/FlowThroughTool.cs b/Tools/Scripts/FlowThroughTool.cs new file mode 100644 index 0000000..10bad29 --- /dev/null +++ b/Tools/Scripts/FlowThroughTool.cs @@ -0,0 +1,582 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using Godot; +using IslaApocalypse.Core; + +namespace IslaApocalypse.Tools +{ + /// + /// ⭐⭐ FLOW-THROUGH ROUTING (rivers/05) — the model rework, the hydrology map, and the taste gate. + /// + /// river → lake → over the spill → river → … → sea. The promoted set chains through the basin graph + /// (rivers/04) on the terrain's own overflow structure, cap-gated at every rim, kept if it reaches the + /// sea or a real lake, dropped if it dead-ends dry. Output: the courses, a per-cell flow-direction + /// field, and the hydrology map — a first-class reference artifact (→ D-056). + /// + /// ═══ ⛔ THE RED LINE ═══ + /// + /// **Routing and data only. No render or classify height written, no bed carved, no water body created + /// or filled.** Both height fields are digested before the graph is built and after the last plate is + /// drawn; any change refuses the run (exit 2). + /// + /// ═══ RUNNING IT ═══ + /// + /// xvfb-run -a Godot_v4.7.2-stable_mono_linux.x86_64 \ + /// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/FlowThroughTool.tscn + /// + /// ISLA_TASK / ISLA_TASK_SUFFIX / ISLA_BATCH / ISLA_CHAT / ISLA_MAPSIZE / ISLA_CALIB_SIZE / ISLA_SEEDS / ISLA_SKIP_RAW + /// ISLA_FLOW_CAP_M the rim cap on floor→spill climb (default 30) — the connected-vs-inland knob + /// ISLA_CAP_PREVIEW_M caps to re-run the walks at for the sensitivity table (default "15,30,60") + /// ISLA_LAKE_MIN_PX the lake significance floor (default 20000) + /// ISLA_PROMOTE_N / ISLA_PROMOTE_FLOOR_PX / ISLA_PROMOTE_MAX as rivers/03 (12 / 5000 / 24) + /// + public partial class FlowThroughTool : Node + { + private static readonly int[] DefaultSeeds = { 1063685222, 999999937, 31415926, 14142135 }; + private static readonly int[] CalibrationSeeds = { 1063685222, 20260819, 777001, 424242, 90210, 31337 }; + private const int DefaultMapSize = 8192; + private const int DefaultCalibSize = 2048; + + public override void _Ready() + { + try { Run(); } + catch (Exception e) + { + GD.PrintErr("=================================================================="); + GD.PrintErr($" REFUSED: {e.Message}"); + GD.PrintErr(e.StackTrace); + GD.PrintErr("=================================================================="); + GetTree().Quit(2); + } + } + + private sealed class SeedResult + { + public int Seed; + public FlowThroughRouting.Result Main; + public Dictionary ByCap = new(); + public BasinGraph Graph; + public int DistinctMouths; public List SharedMouths = new(); public string Spread = ""; + public ulong RenderDigest, ClassifyDigest; + public int CandidateCount, SeaCandidates; + public double WalkSeconds, FieldSeconds, RenderSeconds; public ulong Ms; + public float GMin, GMax; + } + + private void Run() + { + ToolingPaths.Configure(OS.GetUserDataDir()); + ToolingPaths.ConfigureChat(EnvStr(ToolingPaths.ChatVar, "rivers")); + + int task = EnvInt("ISLA_TASK", 5); + string taskSfx = EnvStr("ISLA_TASK_SUFFIX", ""); + string descr = EnvStr("ISLA_BATCH", "flow_through_routing"); + int mapSize = EnvInt("ISLA_MAPSIZE", DefaultMapSize); + int calibSize = EnvInt("ISLA_CALIB_SIZE", DefaultCalibSize); + int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds); + long floorPx = EnvInt("ISLA_PROMOTE_FLOOR_PX", 5000); + int promoteN = EnvInt("ISLA_PROMOTE_N", 12); + int promoteMax = EnvInt("ISLA_PROMOTE_MAX", 24); + int lakeMinPx = EnvInt("ISLA_LAKE_MIN_PX", RiverRouting.LakeMinTargetPx); + float capM = EnvFloat("ISLA_FLOW_CAP_M", 30f); + float[] caps = EnvFloats("ISLA_CAP_PREVIEW_M", new[] { 15f, 30f, 60f }); + bool skipRaw = EnvStr("ISLA_SKIP_RAW", "1") == "1"; + if (Array.IndexOf(caps, capM) < 0) { var l = new List(caps) { capM }; l.Sort(); caps = l.ToArray(); } + + if (promoteMax < promoteN) + throw new InvalidOperationException($"ISLA_PROMOTE_MAX ({promoteMax}) is below the promoted count ({promoteN})."); + + TerrainShapeV1.Assert("FlowThrough"); + TerrainShapeV1.AssertErosionDefaultOn("FlowThrough"); + + string batchRoot = ToolingPaths.BatchRoot(task, taskSfx, descr); + DirAccess.MakeDirRecursiveAbsolute(batchRoot); + DirAccess.MakeDirRecursiveAbsolute(ToolingPaths.BatchScratch(batchRoot)); + + var anchors = CurveAnchors.Default; + float sea = 0.15f; + var dp = new DrainageAnalysis.Params + { + SeaLevel = sea, TrunkCount = promoteMax, GiantCount = promoteMax, + EndorheicMaxCount = promoteMax, EndorheicMinInflowPx = (int)floorPx, + }; + var dpDefaults = new DrainageAnalysis.Params(); + + GD.Print("=================================================================="); + GD.Print(" FLOW-THROUGH ROUTING (rivers/05) — river → lake → over the spill → river → … → sea. THE HYDROLOGY MAP."); + GD.Print("=================================================================="); + GD.Print($"MapSize : {mapSize} curve calibrated at {calibSize}"); + GD.Print($"terrain : {TerrainShapeV1.Describe()} + erosion ON by default"); + GD.Print($"seeds : {seeds.Length} — {string.Join(", ", seeds)}"); + GD.Print($"promoted : PURE top {promoteN} by drainage (unified ranking, rivers/02). No quota."); + GD.Print($"model : follow the D8 field on FullFilled from each terminal; at every basin entered, floor→spill climb (SpillClimbM) ≤ cap → overflow, > cap → walled."); + GD.Print($" reaches OceanMask → KEEP; walls at IsLake (≥ {lakeMinPx:N0} px, classify) → KEEP (lake-terminal); walls dry or puddle-only → DROP. Read through the confluence root."); + GD.Print($"cap : ISLA_FLOW_CAP_M = {capM:F0} m (floor→spill, clamped at sea as RouteTo) — sensitivity at {string.Join(" / ", Array.ConvertAll(caps, c => c.ToString("F0")))} m"); + GD.Print($"lakes : entered on the real terrain (Plan.Dir; rivers/03c fix B lowground fallback), left at the lake's lowest FullFilled cell over the spill. The in-lake span is water, not channel."); + GD.Print($"confluence: rivers/03b's, unchanged — biggest-first, true cell intersection."); + GD.Print($"field : per land cell, the FullFilled D8 heading; walled basins' cells re-pointed onto the real terrain so flow entering one ends there. Emitted as a map; serialization DEFERRED."); + GD.Print($"⛔ RED LINE : routing + data only — no height mutated, no water filled, no bed carved. ASSERTED per seed."); + GD.Print($"batch : {batchRoot}"); + GD.Print("=================================================================="); + if (mapSize != 8192) + GD.PrintErr($" ⚠⚠ MAP SIZE {mapSize} — the basin gates are absolute pixel counts tuned at 8192; a smaller map under-produces basins. PLUMBING only."); + + GD.Print($"\n--- 0. CURVE (task-01 pool at {calibSize}, family-off pinned) ---"); + var (knots, calibration) = CalibrateCurve(calibSize, sea, anchors); + GD.Print($" {knots}"); + + TerrainGenConfig Cfg(int size, int seed) => new TerrainGenConfig + { + MapSize = size, Seed = seed, VariantLabel = "flow", + Curve = true, ShelfDetail = false, CurveMode = CurveModeKind.Continuous, + Knots = knots, Anchors = anchors, ClimbCalibration = calibration, LowlandCeilingM = 30f, + }; + + var results = new List(); + foreach (int seed in seeds) + { + ulong t0 = Time.GetTicksMsec(); + GD.Print($"\n--- seed {seed} ---"); + var cfg = Cfg(mapSize, seed); + Pass1Result p1 = Topography.Generate(cfg); + Pass2Result shaped = Shaping.Shape(p1, cfg); + var ero = ErosionPass.Apply(shaped, cfg); + Pass2Result p2 = ero.Shaped; + + bool[] isOcean = RegionLabeling.OceanMask(p2.HeightClassify, mapSize, sea, out long oceanCells, out long enclosed); + var isClassifyWater = new bool[mapSize * mapSize]; + for (int x = 0; x < mapSize; x++) + for (int y = 0; y < mapSize; y++) + if (p2.HeightClassify[x, y] < sea) isClassifyWater[x * mapSize + y] = true; + + var plan = DrainageAnalysis.Run(p2.Height, mapSize, isOcean, isClassifyWater, -1f, -1f, dp); + GD.Print($" land {plan.LandCells:N0} — sea-reaching {100.0 * plan.SeaReachingCells / Math.Max(1, plan.LandCells):F1} %, endorheic {100.0 * plan.EndorheicCells / Math.Max(1, plan.LandCells):F1} %; terminal basins {plan.TerminalBasinCount}"); + + var e = RiverCandidates.Enumerate(plan, p2.Height, mapSize, floorPx, dpDefaults.MinOutletSeparationPx, p1.Regions); + var promoted = e.Ranked.GetRange(0, Math.Min(promoteN, e.Ranked.Count)); + RiverCandidates.BindCourses(plan, mapSize, promoted, $"the pure top {promoteN}"); + int pSea = 0; foreach (var c in promoted) if (c.IsSea) pSea++; + GD.Print($" promoted: pure top {promoted.Count} — {pSea} sea / {promoted.Count - pSea} endorheic (from {e.Ranked.Count} candidates)"); + + bool[] significant = RegionLabeling.SignificantWaterMask(isClassifyWater, isOcean, mapSize, lakeMinPx, + out int keptBodies, out int totalBodies, out long keptCells, out long largestPx); + + // ═══ ⛔ RED-LINE GUARD — digest both fields BEFORE the graph, the field, the walks and the plates ═══ + ulong hRenderBefore = Digest(p2.Height, mapSize); + ulong hClassifyBefore = Digest(p2.HeightClassify, mapSize); + + var graph = BasinGraph.Build(plan, p2.Height, mapSize, isOcean, isClassifyWater, significant, sea, lakeMinPx); + GD.Print($" basin graph: {graph.LandNodes.Count} land basins ({graph.LakeBasins} lake / {graph.DryBasins} dry), {graph.Seabed} seabed pits excluded; spill invariants {graph.SpillCrossCheckFailures}/{graph.SpillNotOnTerrain}/{graph.DirWalkDisagreements}"); + + ulong tf0 = Time.GetTicksMsec(); + var field = FlowThroughRouting.BuildField(plan, mapSize, isOcean); + var prep = FlowThroughRouting.Prepare(plan, graph, p2.Height, mapSize, isOcean, isClassifyWater); + double fieldSec = (Time.GetTicksMsec() - tf0) / 1000.0; + + var r = new SeedResult { Seed = seed, Graph = graph, CandidateCount = e.Ranked.Count, SeaCandidates = e.SeaCount, FieldSeconds = fieldSec }; + ulong tw0 = Time.GetTicksMsec(); + foreach (float cap in caps) + { + bool main = cap == capM; + GD.Print($" walks at cap {cap:F0} m{(main ? " (THE CAP)" : " (sensitivity)")}:"); + var res = FlowThroughRouting.Run(promoted, plan, graph, field, prep, p2.Height, mapSize, isOcean, isClassifyWater, sea, cap, + m => { if (main) GD.Print(m); }, confluence: true); + r.ByCap[cap] = res; + if (main) r.Main = res; + GD.Print($" → {res.Trunks} trunk + {res.FlowThrough} flow-through + {res.LakeTerminal} lake-terminal = {res.Trunks + res.FlowThrough + res.LakeTerminal} kept; " + + $"{res.DroppedDry + res.DroppedClosed} dropped ({res.DroppedDry} dry, {res.DroppedClosed} closed); {res.Joined} joined; walled basins {res.WalledIds.Count}"); + } + r.WalkSeconds = (Time.GetTicksMsec() - tw0) / 1000.0; + + var mainRes = r.Main; + foreach (var fr in mainRes.Rivers) + GD.Print($" #{fr.Candidate.Rank,-3} {fr.Candidate.DrainagePx,10:N0} px {FlowThroughRouting.ClassName(fr).ToUpperInvariant(),-13} " + + $"hops {fr.Chain.Count} lakes {fr.LakesPassed} max rim {fr.MaxHopClimbM,5:F1} m lowland {fr.LowlandLenPx,6:F0} px" + + (fr.Routed.Joined ? $" → into #{fr.Routed.ConfluenceParentRank}" : "") + $" {fr.Why}"); + FlowThroughRouting.BuildCappedField(mainRes, plan, graph, field, mapSize, isOcean); + FlowThroughRouting.RankHeroLakes(mainRes, graph, prep); + MeasureMouths(r, mainRes); + r.Spread = Spread(mainRes, mapSize); + + GD.Print($" ⭐ HYDROLOGY at cap {capM:F0} m: {mainRes.Trunks} trunk + {mainRes.FlowThrough} flow-through + {mainRes.LakeTerminal} lake-terminal kept, {mainRes.DroppedDry + mainRes.DroppedClosed} dropped, {mainRes.Joined} joined" + + $" — {r.DistinctMouths} distinct sea mouths{(r.SharedMouths.Count > 0 ? $" ⚠ shared: {string.Join(", ", r.SharedMouths)}" : "")}; spread {r.Spread}"); + GD.Print($" confluence: {mainRes.RescuedByConfluence} would-be-dropped river(s) rescued by joining a kept river; {mainRes.DroppedByConfluence} kept-on-its-own river(s) dropped by joining a dropped one"); + GD.Print($" field: {100.0 * mainRes.CellsToSea / Math.Max(1, mainRes.LandCells):F1} % of land drains to the sea, {100.0 * mainRes.CellsToWalledLake / Math.Max(1, mainRes.LandCells):F1} % to a walled lake, " + + $"{100.0 * mainRes.CellsToWalledDry / Math.Max(1, mainRes.LandCells):F1} % to a walled dry sink, {100.0 * mainRes.CellsStuck / Math.Max(1, mainRes.LandCells):F2} % stuck; {mainRes.WalledIds.Count} walled basins"); + GD.Print($" edge cross-check vs BasinGraph: {mainRes.EdgeAgree} agree / {mainRes.EdgeDisagree} disagree; lowground fallbacks into lakes {mainRes.LowgroundFallbacks}"); + if (mainRes.HeroLakes.Count > 0) + { + var h = mainRes.HeroLakes[0]; + GD.Print($" ⭐ HERO-LAKE CANDIDATE (data, NOT filled): basin #{h.BasinId} — lake {h.LakeCells:N0} px, fill volume {h.FillVolumeMPx / 1e6:F2} M m·px, river R{h.RiverRank} {h.RiverLenPx:F0} px, {h.RiversThrough} river(s) through; score {h.Score:F3}"); + } + else GD.Print(" hero-lake candidate: none — no sea-reaching river passes through a lake on this seed"); + + WriteRiverCsv(batchRoot, r, caps); + + ulong tr0 = Time.GetTicksMsec(); + RenderSeed(batchRoot, r, plan, isOcean, isClassifyWater, p2, mapSize, sea, capM, skipRaw); + r.RenderSeconds = (Time.GetTicksMsec() - tr0) / 1000.0; + + // ═══ ⛔ …and asserted byte-identical AFTER everything ═══ + ulong hRenderAfter = Digest(p2.Height, mapSize); + ulong hClassifyAfter = Digest(p2.HeightClassify, mapSize); + if (hRenderAfter != hRenderBefore || hClassifyAfter != hClassifyBefore) + throw new InvalidOperationException( + "[FlowThrough] RED-LINE VIOLATION: a height field CHANGED across routing / rendering.\n" + + $" render {hRenderBefore:X16} -> {hRenderAfter:X16}\n" + + $" classify {hClassifyBefore:X16} -> {hClassifyAfter:X16}\n" + + "This task routes and draws — it must never mutate a height, fill water, or carve. Refusing to continue."); + r.RenderDigest = hRenderBefore; r.ClassifyDigest = hClassifyBefore; + GD.Print($" ✅ RED LINE HELD: render {hRenderBefore:X16} and classify {hClassifyBefore:X16} byte-identical — no height mutated, no water filled, no bed carved."); + r.Ms = Time.GetTicksMsec() - t0; + results.Add(r); + } + + WriteIndex(batchRoot, mapSize, seeds, results, promoteN, lakeMinPx, capM, caps, dpDefaults, skipRaw); + GD.Print("\n=================================================================="); + GD.Print($" DONE — {batchRoot}"); + GD.Print(" ⛔ TASTE GATE: the hydrology is PRESENTED, not locked. The cap is a knob; nothing graduated."); + GD.Print(" ⛔ ROUTING + DATA ONLY: no height mutated, no water filled, no bed carved — asserted per seed. Flow field emitted as a map; serialization deferred."); + GD.Print("=================================================================="); + GetTree().Quit(0); + } + + private static ulong Digest(float[,] f, int n) + { + ulong h = 14695981039346656037UL; + for (int x = 0; x < n; x++) + for (int y = 0; y < n; y++) + { + uint bits = (uint)BitConverter.SingleToInt32Bits(f[x, y]); + for (int b = 0; b < 4; b++) { h ^= (byte)(bits >> (b * 8)); h *= 1099511628211UL; } + } + return h; + } + + private static void MeasureMouths(SeedResult r, FlowThroughRouting.Result res) + { + var at = new Dictionary>(); + foreach (var fr in res.Rivers) + { + if (fr.Dropped || fr.Routed.Joined || fr.Terminus != FlowThroughRouting.Terminus.Ocean || fr.MouthCell < 0) continue; + if (!at.TryGetValue(fr.MouthCell, out var l)) { l = new List(); at[fr.MouthCell] = l; } + l.Add(fr.Candidate.Rank); + } + r.DistinctMouths = at.Count; + foreach (var kv in at) if (kv.Value.Count > 1) r.SharedMouths.Add("#" + string.Join("+#", kv.Value)); + } + + private static string Spread(FlowThroughRouting.Result res, int n) + { + var counts = new Dictionary(); int total = 0; + foreach (var fr in res.Rivers) + { + if (fr.Dropped || fr.Routed.Joined || fr.Terminus != FlowThroughRouting.Terminus.Ocean || fr.MouthCell < 0) continue; + string c = Compass(fr.MouthCell / n, fr.MouthCell % n, n); + counts.TryGetValue(c, out int cur); counts[c] = cur + 1; total++; + } + if (total == 0) return "none"; + var order = new[] { "N", "NE", "E", "SE", "S", "SW", "W", "NW", "centre" }; + var parts = new List(); + foreach (string k in order) if (counts.TryGetValue(k, out int v)) parts.Add($"{k}x{v}"); + return $"{string.Join(" ", parts)} ({counts.Count} of 8 compass sectors)"; + } + + private static string Compass(float x, float y, int n) + { + float half = n / 2f, dx = (x - half) / half, dy = (y - half) / half; + const float band = 0.35f; + string ns = dy < -band ? "N" : dy > band ? "S" : ""; + string ew = dx < -band ? "W" : dx > band ? "E" : ""; + return ns + ew == "" ? "centre" : ns + ew; + } + + private static string ChainText(FlowThroughRouting.FlowRiver fr) + { + var parts = new List(); + foreach (var h in fr.Chain) + parts.Add($"#{h.BasinId}{(h.IsLake ? "L" : "d")}:{h.ClimbM:F1}{(h.Walled ? "!" : "")}"); + return string.Join(" > ", parts) + (fr.Terminus == FlowThroughRouting.Terminus.Ocean ? " > SEA" : fr.Terminus == FlowThroughRouting.Terminus.Closed ? " > closed" : ""); + } + + private static void WriteRiverCsv(string batchRoot, SeedResult r, float[] caps) + { + var res = r.Main; int n = r.Graph.MapSize; + var sb = new StringBuilder(); + sb.Append("rank,class,own_terminus,root_terminus,drainage_px,is_sea_candidate,terminal_basin,hops,lakes_passed,chain,max_hop_climb_m,total_climb_m," + + "terminus_basin,mouth_x,mouth_y,joined,confluence_parent_rank,junction_x,junction_y,stem_len_px,lowland_len_px,total_len_px,lowground_fallbacks,edge_disagreements,width_px,why"); + foreach (float cap in caps) sb.Append($",class_at_cap{cap:F0}"); + sb.AppendLine(); + foreach (var fr in res.Rivers) + { + var c = fr.Candidate; var rr = fr.Routed; + int fb = 0, dis = 0; foreach (var h in fr.Chain) { if (h.UsedLowgroundFallback) fb++; if (h.SpillCell >= 0 && !h.EdgeAgreesWithGraph) dis++; } + sb.Append($"{c.Rank},{FlowThroughRouting.ClassName(fr)},{FlowThroughRouting.TerminusName(fr.Terminus)},{FlowThroughRouting.TerminusName(fr.RootTerminus)},{c.DrainagePx},{(c.IsSea ? "yes" : "no")}," + + $"{(c.IsSea ? 0 : (fr.Chain.Count > 0 ? fr.Chain[0].BasinId : c.BasinId))},{fr.Chain.Count},{fr.LakesPassed},\"{ChainText(fr)}\",{fr.MaxHopClimbM:F2},{fr.TotalClimbM:F2}," + + $"{fr.TerminusBasinId},{(fr.MouthCell >= 0 ? (fr.MouthCell / n).ToString() : "")},{(fr.MouthCell >= 0 ? (fr.MouthCell % n).ToString() : "")}," + + $"{(rr.Joined ? "yes" : "no")},{(rr.Joined ? rr.ConfluenceParentRank.ToString() : "")},{(rr.Joined ? rr.JunctionCell.x.ToString() : "")},{(rr.Joined ? rr.JunctionCell.y.ToString() : "")}," + + $"{fr.StemLenPx:F1},{fr.LowlandLenPx:F1},{fr.TotalLenPx:F1},{fb},{dis},{DrainageRenderer.StemWidthFixed(c.DrainagePx)},\"{fr.Why}\""); + foreach (float cap in caps) + { + var other = r.ByCap[cap]; + FlowThroughRouting.FlowRiver o = null; + foreach (var x in other.Rivers) if (x.Candidate.Rank == c.Rank) { o = x; break; } + sb.Append($",{(o == null ? "" : FlowThroughRouting.ClassName(o))}"); + } + sb.AppendLine(); + } + WriteText(Path.Combine(batchRoot, $"rivers_{r.Seed}.csv"), sb.ToString()); + + // The hero-lake ranking, as data. + var hb = new StringBuilder(); + hb.AppendLine("rank,basin_id,lake_cells,fill_volume_m_px,river_rank,river_len_px,rivers_through,score"); + for (int i = 0; i < res.HeroLakes.Count; i++) + { + var h = res.HeroLakes[i]; + hb.AppendLine($"{i + 1},{h.BasinId},{h.LakeCells},{h.FillVolumeMPx:F0},{h.RiverRank},{h.RiverLenPx:F0},{h.RiversThrough},{h.Score:F4}"); + } + WriteText(Path.Combine(batchRoot, $"hero_lakes_{r.Seed}.csv"), hb.ToString()); + } + + private static void RenderSeed(string batchRoot, SeedResult r, DrainageAnalysis.Plan plan, bool[] isOcean, bool[] isClassifyWater, + Pass2Result p2, int n, float sea, float capM, bool skipRaw) + { + string dir = Path.Combine(batchRoot, $"{r.Seed}"); + DirAccess.MakeDirRecursiveAbsolute(dir); + var res = r.Main; + int kept = res.Trunks + res.FlowThrough + res.LakeTerminal; + + Image baseImg = HydrologyRenderer.Base(p2.Height, n, sea); + HydrologyRenderer.Hydrology(res, res.CappedDir, plan, r.Graph, baseImg, n, isOcean, isClassifyWater, + $"SEED {r.Seed} - HYDROLOGY: {kept} RIVERS OF THE PURE TOP {res.Rivers.Count}, CHAINING THROUGH LAKES AND LOW GROUND TO THE SEA. CAP {capM:F0} M.", + $"{res.Trunks} NATURAL TRUNK + {res.FlowThrough} FLOW-THROUGH TO THE SEA + {res.LakeTerminal} LAKE-TERMINAL; {res.DroppedDry + res.DroppedClosed} DROPPED; {res.Joined} JOINED. {r.DistinctMouths} DISTINCT SEA MOUTHS. SPREAD: {r.Spread.ToUpperInvariant()}", + $"THE FIELD: {100.0 * res.CellsToSea / Math.Max(1, res.LandCells):F0}% OF LAND DRAINS TO THE SEA, {100.0 * res.CellsToWalledLake / Math.Max(1, res.LandCells):F0}% TO A WALLED LAKE, {100.0 * res.CellsToWalledDry / Math.Max(1, res.LandCells):F0}% TO A WALLED DRY SINK ({res.WalledIds.Count} WALLED BASINS). TASTE GATE - NOTHING LOCKED") + .SavePng(Path.Combine(dir, $"hydrology_{r.Seed}.png")); + + HydrologyRenderer.FlowDirection(res.CappedDir, res.CappedAcc, plan, r.Graph, res.WalledIds, n, isOcean, isClassifyWater, + $"SEED {r.Seed} - FLOW DIRECTION FIELD AT CAP {capM:F0} M: PER LAND CELL, THE D8 HEADING ON THE OVERFLOW SURFACE (FULLFILLED), CHAINED OVER SPILLS TOWARD THE SEA", + $"{100.0 * res.CellsToSea / Math.Max(1, res.LandCells):F1}% OF LAND DRAINS TO THE SEA; {100.0 * res.CellsToWalledLake / Math.Max(1, res.LandCells):F1}% ENDS IN A WALLED LAKE; {100.0 * res.CellsToWalledDry / Math.Max(1, res.LandCells):F1}% IN A WALLED DRY SINK; {100.0 * res.CellsStuck / Math.Max(1, res.LandCells):F2}% STUCK. {res.WalledIds.Count} WALLED BASINS (RIM > CAP).", + "DATA MAP - THE REFERENCE FOR PLACEMENT, FLOODING (C3) AND IRRIGATION (C4). HELD IN MEMORY; SERIALIZATION DEFERRED TO THE COLUMN WATER-DATA PHASE.") + .SavePng(Path.Combine(dir, $"flow_direction_{r.Seed}.png")); + + var (gmin, gmax) = GrayscaleRenderer.SavePng(p2.Height, n, Path.Combine(dir, "grayscale.png")); + r.GMin = gmin; r.GMax = gmax; + GD.Print($" grayscale: render field range {gmin:F4} .. {gmax:F4} raw = {WorldScale.MetresFromRaw(gmin):F1} .. {WorldScale.MetresFromRaw(gmax):F1} m"); + if (!skipRaw) HeightField.Save(p2.Height, n, Path.Combine(dir, "height.f32")); + } + + private static void WriteIndex(string batchRoot, int mapSize, int[] seeds, List rows, int promoteN, int lakeMinPx, + float capM, float[] caps, DrainageAnalysis.Params def, bool skipRaw) + { + var sb = new StringBuilder(); + int primary = seeds.Length > 0 ? seeds[0] : 0; + sb.AppendLine($"# Batch 05 — flow-through routing: the hydrology map (cap {capM:F0} m)"); + sb.AppendLine(); + sb.AppendLine("**⛔ TASTE GATE. Nothing is locked** — the cap is a knob, the count falls out, nothing is graduated. This is the"); + sb.AppendLine("routing finale: if the hydrology map reads right, routing is done and the next step is the bed carve."); + sb.AppendLine(); + sb.AppendLine("**⛔ ROUTING AND DATA ONLY. No height mutated, no water filled or created, no bed carved** — asserted per seed by an"); + sb.AppendLine("FNV digest of both height fields taken before the basin graph was built and after the last plate was drawn."); + sb.AppendLine("`DrainageAnalysis` and `BasinGraph` reused. The flow-direction field is emitted as a map and held in memory; its"); + sb.AppendLine("serialization is deferred to the blueprint / column water-data phase."); + sb.AppendLine(); + sb.AppendLine("## 👉 The pick"); + sb.AppendLine(); + sb.AppendLine($"Open **`{primary}/hydrology_{primary}.png`**. Then the other three: " + + string.Join(", ", Array.ConvertAll(Array.FindAll(seeds, x => x != primary), x => $"`{x}`")) + "."); + sb.AppendLine(); + sb.AppendLine("> ### ⭐⭐ THE JUDGMENT, STATED"); + sb.AppendLine("> **Does the island's water now read as one connected, natural, gorgeous system — rivers chaining through lakes"); + sb.AppendLine("> and low ground to the sea, dead-ends dropped, the whole network legible?** Then the count, the spread, and"); + sb.AppendLine($"> whether the cap (`ISLA_FLOW_CAP_M`, {capM:F0} m) wants moving — the sensitivity table below says how the counts move."); + sb.AppendLine(">"); + sb.AppendLine("> **Reading the map.** Pale-blue rivers reach the sea (natural trunks and flow-through chains alike; square = mouth)."); + sb.AppendLine("> Amber rivers are lake-terminal (disc = where the river enters its lake). A river's span across a lake is water,"); + sb.AppendLine("> not a drawn channel. White dot = confluence. A faint red ghost is a river's upland stem that was considered and"); + sb.AppendLine("> dropped — its chain walled at a dry sink. The streamline texture is the flow-direction field."); + sb.AppendLine(); + sb.AppendLine("## ⭐⭐ The hydrology, per seed (at the cap)"); + sb.AppendLine(); + sb.AppendLine("| Seed | trunk | **flow-through → sea** | **lake-terminal** | **kept** | dropped (dry / closed) | joined | rescued by confluence | **distinct sea mouths** | spread | land → sea / walled lake / walled dry |"); + sb.AppendLine("|---|---|---|---|---|---|---|---|---|---|---|"); + foreach (var r in rows) + { + var m = r.Main; + sb.AppendLine($"| `{r.Seed}` | {m.Trunks} | **{m.FlowThrough}** | **{m.LakeTerminal}** | **{m.Trunks + m.FlowThrough + m.LakeTerminal}** of {m.Rivers.Count} | {m.DroppedDry} / {m.DroppedClosed} | {m.Joined} | {m.RescuedByConfluence} | **{r.DistinctMouths}**{(r.SharedMouths.Count > 0 ? $" ⚠ {string.Join(", ", r.SharedMouths)}" : "")} | {r.Spread} | " + + $"{100.0 * m.CellsToSea / Math.Max(1, m.LandCells):F0} % / {100.0 * m.CellsToWalledLake / Math.Max(1, m.LandCells):F0} % / {100.0 * m.CellsToWalledDry / Math.Max(1, m.LandCells):F0} % |"); + } + sb.AppendLine(); + sb.AppendLine("## ⭐ The cap — how the counts move (the connected-vs-inland knob, off one run)"); + sb.AppendLine(); + sb.Append("| Seed |"); + foreach (float cap in caps) sb.Append($" @ {cap:F0} m: kept (sea + lake) / dropped / joined |"); + sb.AppendLine(); + sb.AppendLine("|---|" + string.Concat(Array.ConvertAll(caps, _ => "---|"))); + foreach (var r in rows) + { + sb.Append($"| `{r.Seed}` |"); + foreach (float cap in caps) + { + var m = r.ByCap[cap]; + sb.Append($" {(cap == capM ? "**" : "")}{m.Trunks + m.FlowThrough + m.LakeTerminal} ({m.Trunks + m.FlowThrough} + {m.LakeTerminal}) / {m.DroppedDry + m.DroppedClosed} / {m.Joined}{(cap == capM ? "**" : "")} |"); + } + sb.AppendLine(); + } + sb.AppendLine(); + sb.AppendLine("*A higher cap lets rivers overflow deeper basins: more reach the sea, fewer end at lakes or drop. The per-river"); + sb.AppendLine("class at every cap is in `rivers_.csv` (`class_at_capNN` columns), so the flip points are readable per river.*"); + sb.AppendLine(); + sb.AppendLine("## ⭐ The hero-lake candidate — DATA, not filled"); + sb.AppendLine(); + sb.AppendLine("Among lakes a sea-reaching, un-joined river flows *through*, ranked by √(fill volume × attached river length), each"); + sb.AppendLine("normalised to the seed's maximum. Recorded intent: procedural, executed post-water-render. **Nothing is filled.**"); + sb.AppendLine(); + sb.AppendLine("| Seed | basin | lake px | fill volume (M m·px) | river | river length px | rivers through | score | runner-up |"); + sb.AppendLine("|---|---|---|---|---|---|---|---|---|"); + foreach (var r in rows) + { + var hl = r.Main.HeroLakes; + if (hl.Count == 0) { sb.AppendLine($"| `{r.Seed}` | — | | | | | | | no sea-reaching river passes through a lake |"); continue; } + var h = hl[0]; + string ru = hl.Count > 1 ? $"#{hl[1].BasinId} ({hl[1].Score:F2})" : "—"; + sb.AppendLine($"| `{r.Seed}` | **#{h.BasinId}** | {h.LakeCells:N0} | {h.FillVolumeMPx / 1e6:F2} | R{h.RiverRank} | {h.RiverLenPx:F0} | {h.RiversThrough} | {h.Score:F3} | {ru} |"); + } + sb.AppendLine(); + sb.AppendLine("## ⭐ Per river — the chain each one walked"); + sb.AppendLine(); + sb.AppendLine("Chain notation: `#id L|d : climb` per basin entered (L = lake basin, d = dry), `!` = walled there. The climb is the"); + sb.AppendLine("basin's floor→spill (`SpillClimbM`, render surface, clamped at sea as `RouteTo`)."); + sb.AppendLine(); + foreach (var r in rows) + { + sb.AppendLine($"### `{r.Seed}`"); + sb.AppendLine(); + sb.AppendLine("| rank | class | drainage px | hops | lakes | max rim m | chain | terminus | joins | lowland px |"); + sb.AppendLine("|---|---|---|---|---|---|---|---|---|---|"); + foreach (var fr in r.Main.Rivers) + { + var c = fr.Candidate; int n = r.Graph.MapSize; + string cls = fr.Dropped ? "~~dropped~~" : fr.Trunk ? "trunk" : fr.ReachesSea ? "**flow-through**" : "**lake-terminal**"; + string term = fr.Routed.Joined ? $"→ tributary of #{fr.Routed.ConfluenceParentRank}" + : fr.Terminus == FlowThroughRouting.Terminus.Ocean ? $"sea ({fr.MouthCell / n},{fr.MouthCell % n})" + : fr.Terminus == FlowThroughRouting.Terminus.Lake ? $"lake #{fr.TerminusBasinId}" + : fr.Terminus == FlowThroughRouting.Terminus.DrySink ? $"dry sink #{fr.TerminusBasinId} → dropped" : "closed → dropped"; + sb.AppendLine($"| #{c.Rank} | {cls} | {c.DrainagePx:N0} | {fr.Chain.Count} | {fr.LakesPassed} | {(fr.Chain.Count > 0 ? fr.MaxHopClimbM.ToString("F1") : "—")} | `{(c.IsSea ? "trunk" : ChainText(fr))}` | {term} | {(fr.Routed.Joined ? $"#{fr.Routed.ConfluenceParentRank}" : "—")} | {fr.LowlandLenPx:F0} |"); + } + sb.AppendLine(); + sb.AppendLine($"*Candidates {r.CandidateCount} ({r.SeaCandidates} sea) · walled basins at the cap {r.Main.WalledIds.Count} · edge cross-check vs the basin graph {r.Main.EdgeAgree} agree / {r.Main.EdgeDisagree} disagree · " + + $"lowground fallbacks into lakes {r.Main.LowgroundFallbacks} · field {r.FieldSeconds:F1}s, walks ×{caps.Length} {r.WalkSeconds:F1}s, plates {r.RenderSeconds:F0}s, seed {r.Ms / 1000.0:F0}s · " + + $"digests render `{r.RenderDigest:X16}` classify `{r.ClassifyDigest:X16}` · grayscale {r.GMin:F4}..{r.GMax:F4} raw = {WorldScale.MetresFromRaw(r.GMin):F1}..{WorldScale.MetresFromRaw(r.GMax):F1} m.*"); + sb.AppendLine(); + } + sb.AppendLine("## The model, as run"); + sb.AppendLine(); + sb.AppendLine("1. **The field** — per land cell, the D8 heading on `Plan.FullFilled` (the overflow surface), cap-independent; on it a"); + sb.AppendLine(" basin's minimum is its spill, so descent leaves every basin over its spill into the next (rivers/04 §0.2)."); + sb.AppendLine("2. **The walk** — each promoted river follows the field from its terminal. Every basin entered is checked once:"); + sb.AppendLine($" floor→spill climb ≤ {capM:F0} m → overflow; > {capM:F0} m → walled. Uniform for lake and dry basins."); + sb.AppendLine("3. **Lakes** — inside an `IsLake` basin the river runs down the REAL terrain (`Plan.Dir`) into the basin's own classify"); + sb.AppendLine(" water (rivers/03c fix B's lowground route as the fallback when the descent pools short), crosses the lake as water,"); + sb.AppendLine(" and leaves from the lake's lowest `FullFilled` cell over the spill. A dry basin is crossed on the field as a visible line."); + sb.AppendLine($"4. **Disposition** — reaches `OceanMask` → keep; walls at `IsLake` (≥ {lakeMinPx:N0} px, classify) → keep (lake-terminal);"); + sb.AppendLine(" walls dry or puddle-only → drop. Read through the confluence root, so a river that joins a kept river is kept."); + sb.AppendLine("5. **Confluence** — rivers/03b's, unchanged: biggest-first, true cell intersection, never proximity."); + sb.AppendLine("6. **The field at the cap** — every walled basin's cells re-pointed onto the real terrain, so flow entering one ends at"); + sb.AppendLine(" its floor. That is the `flow_direction_.png` plate and the streamline texture on the hydrology map."); + sb.AppendLine(); + sb.AppendLine($"**⚠ NOT touched:** `DrainageAnalysis`, `BasinGraph`; `EndorheicMinDepthM` {def.EndorheicMinDepthM} m / `EndorheicMinAreaPx` {def.EndorheicMinAreaPx:N0}; `MinOutletSeparationPx` {def.MinOutletSeparationPx}."); + sb.AppendLine("Termini by `OceanMask` and `IsLake` only — no bare `h < sea`."); + sb.AppendLine(); + sb.AppendLine("## Files"); + sb.AppendLine(); + sb.AppendLine("| File | What it is |"); + sb.AppendLine("|---|---|"); + sb.AppendLine("| `/hydrology_.png` | **the showpiece** — relief, lakes as water, the field as streamlines, the kept rivers outlined at the fixed width law, confluences, mouths, dropped ghosts |"); + sb.AppendLine("| `/flow_direction_.png` | **the data map** — the field at the cap: hue by heading, sinks black, walled basins darkened |"); + sb.AppendLine("| `/grayscale.png` | the eroded render field, no palette |"); + sb.AppendLine("| `rivers_.csv` | per river: class, own vs root terminus, the chain with per-hop climbs, terminus, confluence, lengths, and the class at every preview cap |"); + sb.AppendLine("| `hero_lakes_.csv` | the hero-lake ranking, as data |"); + if (skipRaw) sb.AppendLine("| ~~`/height.f32`~~ | **deliberately not written** — byte-identical to `chat2/11_erosion` (rivers/01). |"); + sb.AppendLine(); + sb.AppendLine($"Ranges: sea level `{def.SeaLevel}` raw = `{WorldScale.MetresFromRaw(def.SeaLevel):F2} m`; {WorldScale.Describe()}."); + sb.AppendLine(); + sb.AppendLine("→ `XX_Human/output/rivers/05_flow_through_routing.report.md`"); + WriteText(Path.Combine(batchRoot, "INDEX.md"), sb.ToString()); + } + + // ---- the curve (the house pattern; pool pinned family-off per rivers/01) ------------------- + + private static (CurveKnots, ClimbCalibration) CalibrateCurve(int calibSize, float sea, CurveAnchors anchors) + { + var rawPool = new LandHistogram(sea); + var pass1 = new Dictionary(); + foreach (int s in CalibrationSeeds) + { + var p1 = Topography.Generate(TerrainGenConfig.CalibrationPool(calibSize, s)); + pass1[s] = p1; + rawPool.Accumulate(p1.Height, calibSize); + } + var knots = new CurveKnots(2, "v2_balanced", + rawPool.Quantile(CurveKnots.Percentiles[0]), rawPool.Quantile(CurveKnots.Percentiles[1]), + rawPool.Quantile(CurveKnots.Percentiles[2]), rawPool.Quantile(CurveKnots.Percentiles[3]), + rawPool.Quantile(CurveKnots.Percentiles[4]), rawPool.Quantile(CurveKnots.Percentiles[5])); + float ceilingRaw = knots.K2; + var rawAbove = new LandHistogram(sea); + var outAbove = new LandHistogram(sea); + foreach (int s in CalibrationSeeds) + { + var scfg = new TerrainGenConfig + { + MapSize = calibSize, Seed = s, Curve = true, ShelfDetail = true, + CurveMode = CurveModeKind.Staircase, Knots = knots, Anchors = anchors, VariantLabel = "staircase", + }.WithFamilyOff(); + Pass2Result st = Shaping.Shape(pass1[s], scfg); + rawAbove.AccumulateWhere(pass1[s].Height, pass1[s].Height, calibSize, ceilingRaw); + outAbove.AccumulateWhere(st.Height, pass1[s].Height, calibSize, ceilingRaw); + } + var pcts = ClimbCalibration.DefaultPercentiles; + var rawQ = new float[pcts.Length]; var outQ = new float[pcts.Length]; + for (int i = 0; i < pcts.Length; i++) { rawQ[i] = rawAbove.Quantile(pcts[i]); outQ[i] = outAbove.Quantile(pcts[i]); } + return (knots, ClimbCalibration.FromPercentiles(pcts, rawQ, outQ, ceilingRaw, + HeightCurve.EffectiveSpikeMax(pass1[CalibrationSeeds[0]].HMaxSeed, knots, anchors), + anchors.RedCeil, anchors.PeakCap, mountainLift: 1.0f, peakSharpness: 1.0f)); + } + + // ---- env / io ----------------------------------------------------------------------------- + + private static void WriteText(string path, string text) + { + using var f = Godot.FileAccess.Open(path, Godot.FileAccess.ModeFlags.Write); + if (f == null) { GD.PrintErr($"could not write {path}"); return; } + f.StoreString(text); + } + private static string EnvStr(string k, string fallback) + { + string v = System.Environment.GetEnvironmentVariable(k); + return string.IsNullOrWhiteSpace(v) ? fallback : v; + } + private static int EnvInt(string k, int fallback) => int.TryParse(EnvStr(k, null) ?? "", out int v) ? v : fallback; + private static float EnvFloat(string k, float fallback) => + float.TryParse(EnvStr(k, null) ?? "", System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float v) ? v : fallback; + private static int[] EnvSeeds(string k, int[] fallback) + { + string v = EnvStr(k, null); + if (v == null) return fallback; + var outp = new List(); + foreach (string part in v.Split(',', StringSplitOptions.RemoveEmptyEntries)) + if (int.TryParse(part.Trim(), out int s) && s > 0) outp.Add(s); + return outp.Count > 0 ? outp.ToArray() : fallback; + } + private static float[] EnvFloats(string k, float[] fallback) + { + string v = EnvStr(k, null); + if (v == null) return fallback; + var outp = new List(); + foreach (string part in v.Split(',', StringSplitOptions.RemoveEmptyEntries)) + if (float.TryParse(part.Trim(), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float f)) outp.Add(f); + return outp.Count > 0 ? outp.ToArray() : fallback; + } + } +} diff --git a/Tools/Scripts/HydrologyRenderer.cs b/Tools/Scripts/HydrologyRenderer.cs new file mode 100644 index 0000000..6ff7472 --- /dev/null +++ b/Tools/Scripts/HydrologyRenderer.cs @@ -0,0 +1,279 @@ +using System; +using System.Collections.Generic; +using Godot; +using IslaApocalypse.Core; + +namespace IslaApocalypse.Tools +{ + /// + /// ⭐⭐ 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 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. + /// + 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 }; + + /// TinyFont carries only . - : / ( ) 0-9 A-Z; everything else would print as a gap. Map the punctuation the plate text uses. + public static string Txt(string s) => s + .Replace("%", " PCT").Replace("+", " AND ").Replace(";", " -").Replace(",", " -").Replace("'", "") + .Replace("→", "-").Replace("≤", "UNDER").Replace(">", "OVER").Replace("<", "UNDER").Replace("!", ".").Replace(" ", " "); + + /// The shaded-relief base (the atlas look), quietened — desaturated and darkened a little so the water reads on top of it. + 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(); + 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(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; + } + + /// Draw a river's OWN reach (up to its junction) as discs along its rasterised cells, skipping cells that are lake water. + private static void DrawRiver(Image img, FlowThroughRouting.FlowRiver fr, int n, Color c, int grow, Func 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; + } + + /// + /// 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. + /// + 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)); + } + + /// + /// ⭐ 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. + /// + public static Image FlowDirection(sbyte[] dir, int[] acc, DrainageAnalysis.Plan plan, BasinGraph graph, HashSet 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; + } + } +} diff --git a/Tools/Scripts/RiverRouting.cs b/Tools/Scripts/RiverRouting.cs index d7d5284..155040b 100644 --- a/Tools/Scripts/RiverRouting.cs +++ b/Tools/Scripts/RiverRouting.cs @@ -621,7 +621,7 @@ namespace IslaApocalypse.Tools /// FIRST (most-upstream) cell it shares with an already-laid course, and adopts that course's /// downstream and terminus from there. /// - private static void Confluence(List rivers, Action log) + internal static void Confluence(List rivers, Action log) { var order = new List(rivers); order.Sort((a, b) => b.Candidate.DrainagePx.CompareTo(a.Candidate.DrainagePx));