rivers/04: lake-basin layer — the basin graph (spill + lake-identity + downstream edge), data only

Per terminal basin of DrainageAnalysis (reused, untouched): the spill cell/height on the
render flow surface (boundary minimum of FullFilled, cross-checked bit-for-bit against
BitDecrement(min inside)), lake-identity on classify (in-basin non-ocean classify water
>= ISLA_LAKE_MIN_PX, 20000), and the downstream edge (the reference's FullFilled descent
started at the spill, cross-checked against Plan.Dir). Seabed pits — terminal basins entirely
under the classify sea — tagged and excluded from statistics. Per-lake ownership table.
BasinGraphTool: chain → analysis → layer → CSVs → plate, height digests asserted around it.
DrainageRenderer: five primitives private→internal. No height mutated, no water filled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EppUMXNhSeuA5Mu51UnTyP
This commit is contained in:
Stewart Howe 2026-08-25 00:45:23 -04:00
parent 09d23127e7
commit df286ffb3c
5 changed files with 1239 additions and 5 deletions

478
Core/Scripts/BasinGraph.cs Normal file
View file

@ -0,0 +1,478 @@
using System;
using System.Collections.Generic;
namespace IslaApocalypse.Core
{
/// <summary>Where a terminal basin's spill drains next.</summary>
public enum DownstreamKind : byte
{
/// <summary>The spill walk found no strictly-lower neighbour before reaching anything — a genuinely closed sink (or an exact float flat).</summary>
None = 0,
/// <summary>The spill drains into <c>RegionLabeling.OceanMask</c> — the sea, on the CLASSIFY field.</summary>
Ocean = 1,
/// <summary>The spill drains into another terminal basin's cells (<see cref="BasinNode.DownstreamId"/>).</summary>
Basin = 2,
}
/// <summary>
/// ⭐⭐ ONE NODE OF THE BASIN GRAPH (rivers/04) — a terminal basin of <see cref="DrainageAnalysis"/>,
/// enriched with the three things the analysis left latent: its SPILL, its LAKE-IDENTITY, and its
/// DOWNSTREAM EDGE. Pure data. Nothing here is a height write or a water fill.
///
/// ═══ ⚠⚠ D-046 — WHICH SURFACE EACH FIELD IS READ ON ═══
///
/// RENDER / FLOW surface (<c>Plan.FullFilled</c>, the priority-flood of the eroded render height)
/// <see cref="SpillCell"/>, <see cref="SpillHeightRaw"/>, <see cref="FloorHeightRaw"/>,
/// <see cref="SpillClimbM"/>, <see cref="DownstreamKind"/> walk — everything about WHERE WATER GOES.
/// This is the surface <c>RiverRouting.RouteTo</c> routes on, so a spill height and a rim climb are
/// the same kind of number the router already measures.
///
/// CLASSIFY / WATER surface (<c>isClassifyWater</c> = classify &lt; sea; <c>OceanMask</c>)
/// <see cref="LakeCells"/>, <see cref="IsLake"/>, <see cref="OceanCells"/>, and the OCEAN terminus
/// of the downstream walk — everything about WHAT IS VISIBLY WATER. This is the surface the
/// router's terminus tests already use.
///
/// ⛔ No field compares a classify height to a render height. The two surfaces meet only as
/// MEMBERSHIP (is this basin cell classify-water? is this walk cell ocean?), which is exactly the
/// split the routing already lives by (route on render, `OceanMask` on classify). No new seam.
/// </summary>
public sealed class BasinNode
{
/// <summary>The terminal-basin id, as <c>Plan.BasinId</c> carries it (sparse: pits that filled through gave up their ids).</summary>
public int Id;
/// <summary>Cells with <c>BasinId == Id</c>.</summary>
public long AreaPx;
/// <summary><c>Plan.BasinInflow[Id]</c> — cells whose flow terminates here (the promotion metric).</summary>
public long InflowPx;
/// <summary>The basin's deepest cell on the RENDER height (first in scan order on ties), and its height.</summary>
public int FloorCell;
public float FloorHeightRaw;
/// <summary>
/// The basin's ENTRY cell: its minimum on <c>FullFilled</c>. The priority-flood raises the first cell
/// it steps into from the spill to exactly one ulp above the spill, so this is spill + 1 ulp.
/// </summary>
public int EntryCell;
public float EntryFullFilledRaw;
/// <summary>
/// ⭐⭐ THE SPILL — the lowest cell on the basin's 8-neighbour boundary, read on <c>FullFilled</c>
/// (== the render height there — asserted, see <see cref="SpillOnTerrain"/>). This is the rim cell
/// water would overtop. Ties (same height) resolve to the lowest cell index; <see cref="SpillTies"/>
/// says how many boundary cells sit at exactly this height.
/// </summary>
public int SpillCell;
public float SpillHeightRaw;
public int SpillTies;
/// <summary>⭐ Cross-check (a) vs (b): <c>BitDecrement(EntryFullFilledRaw) == SpillHeightRaw</c>. The uniform-fill-level reading and the rim-walk reading must agree exactly.</summary>
public bool SpillCrossCheckOk;
/// <summary>⭐ The spill cell is real terrain: <c>FullFilled[spill] == render[spill]</c> (it was never raised by the flood).</summary>
public bool SpillOnTerrain;
/// <summary>Spill height above the sea scalar, metres (render surface; may be negative for a rim below the datum).</summary>
public float SpillAboveSeaM;
/// <summary>Floor → spill, metres, unclamped — the basin's depth to its overflow (≈ <c>DrainageAnalysis</c>'s <c>basinDepthM</c>).</summary>
public float DepthToSpillM;
/// <summary>
/// ⭐ THE CLIMB THE CAP IS JUDGED AGAINST: <c>ElevM(spill) ElevM(floor)</c> with elevation clamped at
/// sea exactly as <c>RiverRouting.RouteTo</c> clamps it — so a below-datum lagoon bed climbs from sea
/// level, not from its bed. Same number the router's <c>RimClimbM</c> is. ⚠ The clamp is an ELEVATION
/// rule on the render surface, not a water test — nothing here reads "render &lt; sea" as water.
/// </summary>
public float SpillClimbM;
/// <summary>Cells with <c>BasinId == Id</c> that are classify-water and NOT ocean. Read on CLASSIFY.</summary>
public long LakeCells;
/// <summary>Of those, cells belonging to a SIGNIFICANT body (the router's ≥ floor mask) — for cross-reference with the routing's lake mask.</summary>
public long LakeCellsSignificant;
/// <summary>⚠ Cells with <c>BasinId == Id</c> that are OCEAN on classify — a render depression under the sea. The D-046 seam, made visible rather than hidden.</summary>
public long OceanCells;
/// <summary>⭐ <c>LakeCells &gt;= floor</c> — a significant heightmap lake sits in this basin. This is the graph's lake/dry label.</summary>
public bool IsLake;
/// <summary>
/// ⚠⚠ EVERY cell of this basin is OCEAN on classify — a render depression on the SEABED. The priority-flood runs on
/// the whole render surface, so a deep-enough, large-enough pit under the sea qualifies as a "terminal basin" exactly
/// like a land one; hydrologically it is inert (its cells are D_NONE, its inflow is 0). Kept in the layer, EXCLUDED
/// from every lake/dry, spill and cap statistic, and counted loudly — this is the D-046 seam, not a lake.
/// </summary>
public bool IsSeabed;
/// <summary>Some but not all cells are ocean on classify — a basin straddling the shoreline seam. Treated as land (it has land cells and inflow) and counted.</summary>
public bool IsCoastal;
/// <summary>A basin with at least one land cell — the ones the graph is about.</summary>
public bool IsLand => !IsSeabed;
/// <summary><c>LakeCells &gt; 0</c> — exactly <c>DrainageAnalysis</c>'s <c>basinHasLake</c> (any size), the routing sort. Kept so the two labels can be compared.</summary>
public bool HasAnyLake;
/// <summary>⭐⭐ THE EDGE — where the spill drains next.</summary>
public DownstreamKind Downstream;
/// <summary>The downstream basin id when <see cref="Downstream"/> is <see cref="DownstreamKind.Basin"/>; 0 otherwise.</summary>
public int DownstreamId;
/// <summary>The cell the spill walk ended on: the first ocean cell, the first cell of the next basin, or where it stuck.</summary>
public int DownstreamEntryCell;
/// <summary>The spill walk itself, spill → entry, 1-px cells — the reference's provisional-route descent on <c>FullFilled</c>.</summary>
public List<int> SpillPath = new();
/// <summary>⭐ Cross-check: following <c>Plan.Dir</c> (the analysis's own D8 field) from the first cell past the spill reaches the same node.</summary>
public bool DirWalkAgrees = true;
public DownstreamKind DirWalkKind;
public int DirWalkId;
}
/// <summary>
/// ⭐⭐ THE BASIN GRAPH — the water-bodies layer the flow-through routing model traverses (rivers/04).
///
/// ═══ WHAT IT IS ═══
///
/// <see cref="DrainageAnalysis"/> already found the sinks (<c>BasinId</c>) and already computed the
/// overflow surface (<c>FullFilled</c>). This layer reads those outputs and records, per terminal basin,
/// its spill, whether a significant heightmap lake sits in it, and where its spill drains to. The
/// result is a DAG: an edge always leads to a strictly lower spill, so no chain can cycle.
///
/// ═══ ⛔ THE RED LINE ═══
///
/// **Reads heights, writes none. Creates no water. <c>DrainageAnalysis</c> is consumed, not edited.**
/// The caller asserts both height digests unchanged around <see cref="Build"/>.
///
/// ═══ ⭐ WHY THE SPILL IS EXACT (the Part-0 argument, kept where the code is) ═══
///
/// The routing fill is a Barnes priority-flood with a one-ulp pit epsilon. A depression is entered
/// from the lowest rim cell S popped off the heap (height L, never raised); the first cells inside
/// are raised to <c>BitIncrement(L)</c> and every deeper cell to one ulp above ITS parent. So:
/// • a terminal basin's cells (<c>FullFilled &gt; original</c>, 8-connected) are ONE flood chain from
/// ONE spill, and their minimum on <c>FullFilled</c> is exactly L + 1 ulp;
/// • every boundary cell (8-adjacent, not in the basin) was NOT raised, so <c>FullFilled == original</c>
/// there, and its height is ≥ L (a lower one would have been a lower way in);
/// • the boundary minimum IS S, at exactly L.
/// Both readings are computed and compared per basin (<see cref="BasinNode.SpillCrossCheckOk"/>), and
/// the "spill sits on real terrain" fact is asserted too (<see cref="BasinNode.SpillOnTerrain"/>).
///
/// ═══ ⭐ WHY THE DOWNSTREAM WALK IS ON FullFilled, NOT Plan.Dir ═══
///
/// <c>Plan.Dir</c> is D8 on <c>Filled</c> — the surface with terminal basins REVERTED to real heights.
/// The spill cell is the saddle; on <c>Filled</c> its steepest neighbour may be back INTO its own basin
/// (the reverted floor is lower than the rim), which would name the basin its own downstream. On
/// <c>FullFilled</c> the basin stands at L + ulps above its spill, so the descent from S cannot re-enter
/// it — that is exactly why the reference walked its provisional route on the full fill. <c>Dir</c> is
/// used as the CROSS-CHECK from the first cell past the spill, where re-entry is impossible.
/// </summary>
public sealed class BasinGraph
{
// Neighbour order FIXED, identical to DrainageAnalysis — the deterministic tiebreak.
private static readonly int[] DX = { -1, -1, -1, 0, 0, 1, 1, 1 };
private static readonly int[] DY = { -1, 0, 1, -1, 1, -1, 0, 1 };
public int MapSize;
public float SeaLevel;
/// <summary>The significance floor a basin's in-basin classify water must reach to make it a LAKE basin. A knob (<c>ISLA_LAKE_MIN_PX</c>).</summary>
public int LakeMinPx;
// ---- provenance, recorded on the layer ----
public const string SpillDatum =
"spill = minimum of Plan.FullFilled over the basin's 8-neighbour boundary (== eroded RENDER height there); " +
"cross-checked against BitDecrement(min FullFilled inside the basin); ties → lowest cell index";
public const string LakeDatum =
"lake = cells with BasinId == id AND classify < sea AND NOT OceanMask (CLASSIFY surface), total >= LakeMinPx";
public const string DownstreamMethod =
"edge = steepest descent on Plan.FullFilled from the spill cell (the reference's provisional-route walk), " +
"until OceanMask (classify) or another BasinId; cross-checked by following Plan.Dir from the first cell past the spill";
public List<BasinNode> Nodes = new();
private Dictionary<int, BasinNode> _byId = new();
public BasinNode Of(int id) => _byId.TryGetValue(id, out var b) ? b : null;
// ---- invariant tallies ----
public int SpillCrossCheckFailures, SpillNotOnTerrain, DirWalkDisagreements;
/// <summary>Tallies over LAND basins only (seabed basins excluded — see <see cref="BasinNode.IsSeabed"/>).</summary>
public int ToOcean, ToBasin, Closed, LakeBasins, DryBasins;
/// <summary>⚠ The seam counts: basins entirely under the classify sea, and basins straddling the shoreline.</summary>
public int Seabed, Coastal;
/// <summary>The land basins, in id order — what every statistic and the plate's graph are over.</summary>
public List<BasinNode> LandNodes = new();
/// <summary>
/// ⭐ ONE SIGNIFICANT CLASSIFY-WATER BODY, and which basin (if any) owns it. The reconciliation the whole
/// layer exists for, measured per lake rather than assumed: heightmap lakes and terminal basins coincide
/// only by terrain coincidence, so this says, per significant body, how much of it sits inside a terminal
/// basin and which one — or that it floats free of the hydrology entirely.
/// </summary>
public sealed class LakeBody
{
public int Index; // 1-based, scan order
public long SizePx;
public long CellsInBasins; // cells with BasinId != 0
public int DominantBasinId; // the basin holding most of its cells (0 = none)
public long DominantCells;
public int BasinsTouched; // distinct basins it overlaps
public bool Owned => DominantBasinId != 0 && DominantCells * 2 >= SizePx; // ≥ half inside one basin
public bool Free => CellsInBasins == 0;
}
/// <summary>Every significant body (8-connected, ≥ floor), scan order.</summary>
public List<LakeBody> LakeBodies = new();
public int LakeBodiesOwned, LakeBodiesFree, LakeBodiesSplit;
/// <summary>Non-ocean classify-water cells outside every terminal basin — heightmap water the hydrology never pooled into.</summary>
public long ClassifyWaterCellsOutsideBasins, ClassifyWaterCellsTotal;
public static BasinGraph Build(DrainageAnalysis.Plan plan, float[,] render, int n,
bool[] isOcean, bool[] isClassifyWater, bool[] isSignificantWater, float sea, int lakeMinPx)
{
int total = n * n;
var g = new BasinGraph { MapSize = n, SeaLevel = sea, LakeMinPx = lakeMinPx };
int[] basinId = plan.BasinId;
float[] ff = plan.FullFilled;
int maxId = 0;
for (int i = 0; i < total; i++) if (basinId[i] > maxId) maxId = basinId[i];
// ---- pass 1: per-basin scalars, scan order ----
var area = new long[maxId + 1];
var floorCell = new int[maxId + 1]; var floorH = new float[maxId + 1];
var entryCell = new int[maxId + 1]; var entryFF = new float[maxId + 1];
var lake = new long[maxId + 1]; var lakeSig = new long[maxId + 1]; var ocean = new long[maxId + 1];
for (int id = 0; id <= maxId; id++) { floorCell[id] = -1; floorH[id] = float.MaxValue; entryCell[id] = -1; entryFF[id] = float.MaxValue; }
for (int i = 0; i < total; i++)
{
int id = basinId[i];
if (id == 0) continue;
area[id]++;
float h = render[i / n, i % n];
if (h < floorH[id]) { floorH[id] = h; floorCell[id] = i; }
if (ff[i] < entryFF[id]) { entryFF[id] = ff[i]; entryCell[id] = i; }
if (isOcean[i]) ocean[id]++;
else if (isClassifyWater[i])
{
lake[id]++;
if (isSignificantWater != null && isSignificantWater[i]) lakeSig[id]++;
}
}
// ---- pass 2: boundary minimum on FullFilled (the rim walk) ----
var bMin = new float[maxId + 1]; var bCell = new int[maxId + 1];
for (int id = 0; id <= maxId; id++) { bMin[id] = float.MaxValue; bCell[id] = -1; }
for (int i = 0; i < total; i++)
{
int id = basinId[i];
if (id == 0) continue;
int cx = i / n, cy = i % n;
for (int k = 0; k < 8; k++)
{
int nx = cx + DX[k], ny = cy + DY[k];
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
int ni = nx * n + ny;
if (basinId[ni] == id) continue;
float v = ff[ni];
if (v < bMin[id] || (v == bMin[id] && ni < bCell[id])) { bMin[id] = v; bCell[id] = ni; }
}
}
// ---- pass 3: how many DISTINCT boundary cells tie at the spill height ----
var ties = new HashSet<int>[maxId + 1];
for (int i = 0; i < total; i++)
{
int id = basinId[i];
if (id == 0) continue;
int cx = i / n, cy = i % n;
for (int k = 0; k < 8; k++)
{
int nx = cx + DX[k], ny = cy + DY[k];
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
int ni = nx * n + ny;
if (basinId[ni] == id || ff[ni] != bMin[id]) continue;
(ties[id] ??= new HashSet<int>()).Add(ni);
}
}
float ElevM(float h) => MathF.Max(0f, WorldScale.MetresFromRaw(h - sea));
// ---- per basin: the node ----
for (int id = 1; id <= maxId; id++)
{
if (area[id] == 0) continue;
var b = new BasinNode
{
Id = id, AreaPx = area[id],
InflowPx = id < plan.BasinInflow.Length ? plan.BasinInflow[id] : 0,
FloorCell = floorCell[id], FloorHeightRaw = floorH[id],
EntryCell = entryCell[id], EntryFullFilledRaw = entryFF[id],
SpillCell = bCell[id], SpillHeightRaw = bMin[id],
SpillTies = ties[id]?.Count ?? 0,
LakeCells = lake[id], LakeCellsSignificant = lakeSig[id], OceanCells = ocean[id],
};
b.SpillCrossCheckOk = bCell[id] >= 0 && MathF.BitDecrement(entryFF[id]) == bMin[id];
b.SpillOnTerrain = bCell[id] >= 0 && render[bCell[id] / n, bCell[id] % n] == bMin[id];
b.SpillAboveSeaM = WorldScale.MetresFromRaw(b.SpillHeightRaw - sea);
b.DepthToSpillM = WorldScale.MetresFromRaw(b.SpillHeightRaw - b.FloorHeightRaw);
b.SpillClimbM = ElevM(b.SpillHeightRaw) - ElevM(b.FloorHeightRaw);
b.IsLake = b.LakeCells >= lakeMinPx;
b.HasAnyLake = b.LakeCells > 0;
b.IsSeabed = b.OceanCells == b.AreaPx;
b.IsCoastal = b.OceanCells > 0 && !b.IsSeabed;
if (!b.SpillCrossCheckOk) g.SpillCrossCheckFailures++;
if (!b.SpillOnTerrain) g.SpillNotOnTerrain++;
if (b.IsSeabed) g.Seabed++;
if (b.IsCoastal) g.Coastal++;
// ⭐⭐ THE DOWNSTREAM WALK — the reference's provisional-route descent, started at the spill.
if (bCell[id] >= 0)
{
int c = bCell[id];
b.Downstream = DownstreamKind.None;
for (int guard = 0; guard < 4 * n; guard++)
{
b.SpillPath.Add(c);
if (isOcean[c]) { b.Downstream = DownstreamKind.Ocean; break; }
int bid = basinId[c];
if (bid != 0 && bid != id) { b.Downstream = DownstreamKind.Basin; b.DownstreamId = bid; break; }
int cx = c / n, cy = c % n;
float best = float.MaxValue; int bestN = -1;
for (int k = 0; k < 8; k++)
{
int nx = cx + DX[k], ny = cy + DY[k];
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
int ni = nx * n + ny;
if (ff[ni] < best) { best = ff[ni]; bestN = ni; }
}
if (bestN < 0 || ff[bestN] >= ff[c]) break; // stuck — a closed sink (or an exact flat)
c = bestN;
}
b.DownstreamEntryCell = b.SpillPath[^1];
// ⭐ Cross-check on the analysis's own D8 field, from the first cell PAST the spill.
if (b.SpillPath.Count >= 2)
{
int c2 = b.SpillPath[1];
var (dk, did) = WalkDir(plan, n, isOcean, c2);
b.DirWalkKind = dk; b.DirWalkId = did;
b.DirWalkAgrees = dk == b.Downstream && did == b.DownstreamId;
}
else { b.DirWalkKind = b.Downstream; b.DirWalkId = b.DownstreamId; b.DirWalkAgrees = true; }
if (!b.DirWalkAgrees) g.DirWalkDisagreements++;
}
if (b.IsLand)
{
switch (b.Downstream)
{
case DownstreamKind.Ocean: g.ToOcean++; break;
case DownstreamKind.Basin: g.ToBasin++; break;
default: g.Closed++; break;
}
if (b.IsLake) g.LakeBasins++; else g.DryBasins++;
g.LandNodes.Add(b);
}
g.Nodes.Add(b);
g._byId[id] = b;
}
// ---- the reconciliation, per significant body: which basin owns it? ----
for (int i = 0; i < total; i++)
if (isClassifyWater[i] && !isOcean[i]) { g.ClassifyWaterCellsTotal++; if (basinId[i] == 0) g.ClassifyWaterCellsOutsideBasins++; }
if (isSignificantWater != null)
{
var seen = new bool[total];
var stack = new Stack<int>();
var perBasin = new Dictionary<int, long>();
for (int s = 0; s < total; s++)
{
if (seen[s] || !isSignificantWater[s]) continue;
var body = new LakeBody { Index = g.LakeBodies.Count + 1 };
perBasin.Clear();
seen[s] = true; stack.Push(s);
while (stack.Count > 0)
{
int c = stack.Pop();
body.SizePx++;
int bid = basinId[c];
if (bid != 0) { body.CellsInBasins++; perBasin.TryGetValue(bid, out long cur); perBasin[bid] = cur + 1; }
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 (seen[ni] || !isSignificantWater[ni]) continue;
seen[ni] = true; stack.Push(ni);
}
}
body.BasinsTouched = perBasin.Count;
foreach (var kv in perBasin)
if (kv.Value > body.DominantCells || (kv.Value == body.DominantCells && kv.Key < body.DominantBasinId))
{ body.DominantCells = kv.Value; body.DominantBasinId = kv.Key; }
if (body.Free) g.LakeBodiesFree++; else if (body.Owned) g.LakeBodiesOwned++; else g.LakeBodiesSplit++;
g.LakeBodies.Add(body);
}
}
return g;
}
/// <summary>Follow <c>Plan.Dir</c> from a cell to where its flow ends: the sea, a terminal basin, or nowhere.</summary>
private static (DownstreamKind kind, int id) WalkDir(DrainageAnalysis.Plan plan, int n, bool[] isOcean, int start)
{
int c = start;
for (int guard = 0; guard < 8 * n; guard++)
{
if (isOcean[c]) return (DownstreamKind.Ocean, 0);
sbyte d = plan.Dir[c];
if (d == DrainageAnalysis.D_SEA) return (DownstreamKind.Ocean, 0);
if (d == DrainageAnalysis.D_NONE) return plan.BasinId[c] != 0 ? (DownstreamKind.Basin, plan.BasinId[c]) : (DownstreamKind.None, 0);
int cx = c / n, cy = c % n;
c = (cx + DX[d]) * n + (cy + DY[d]);
}
return (DownstreamKind.None, 0);
}
/// <summary>
/// ⭐ THE CAP PREVIEW — which basins have an UNBROKEN spill-chain to the ocean when every link's
/// <see cref="BasinNode.SpillClimbM"/> must be ≤ <paramref name="capM"/>. A preview of what the
/// flow-through model will trade at a given cap; it decides nothing.
/// </summary>
public bool[] ConnectedAtCap(float capM, out int connected)
{
var state = new Dictionary<int, byte>(); // 1 = yes, 2 = no, 3 = visiting
bool Reach(int id)
{
if (state.TryGetValue(id, out byte s)) return s == 1;
var b = Of(id);
if (b == null) { state[id] = 2; return false; }
state[id] = 3;
bool ok = false;
if (b.SpillClimbM <= capM)
{
if (b.Downstream == DownstreamKind.Ocean) ok = true;
else if (b.Downstream == DownstreamKind.Basin)
{
// The graph is a DAG (an edge always lands on a strictly lower spill); the
// visiting guard is belt-and-braces, never expected to fire.
bool visiting = state.TryGetValue(b.DownstreamId, out byte ds) && ds == 3;
ok = !visiting && Reach(b.DownstreamId);
}
}
state[id] = ok ? (byte)1 : (byte)2;
return ok;
}
var outp = new bool[Nodes.Count];
connected = 0;
for (int i = 0; i < Nodes.Count; i++)
{
outp[i] = Reach(Nodes[i].Id);
if (outp[i]) connected++;
}
return outp;
}
/// <summary>Chain length (edges) from a basin to the ocean, or -1 if the chain ends in a closed sink.</summary>
public int HopsToOcean(int id)
{
int hops = 0; var seen = new HashSet<int>();
var b = Of(id);
while (b != null && seen.Add(b.Id))
{
if (b.Downstream == DownstreamKind.Ocean) return hops + 1;
if (b.Downstream != DownstreamKind.Basin) return -1;
b = Of(b.DownstreamId); hops++;
}
return -1;
}
}
}

View file

@ -0,0 +1,6 @@
[gd_scene format=3 uid="uid://basingraph04"]
[ext_resource type="Script" path="res://Tools/Scripts/BasinGraphTool.cs" id="1_bgt04"]
[node name="BasinGraphTool" type="Node"]
script = ExtResource("1_bgt04")

View file

@ -0,0 +1,151 @@
using System;
using System.Collections.Generic;
using Godot;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
/// <summary>
/// ⭐ THE BASIN-GRAPH PLATE (rivers/04) — the taste gate on the foundation. Presentation only; reads
/// the graph and the height, writes pixels. Nothing here touches data.
///
/// What the eye is meant to check, per the task:
/// • is each SPILL (yellow ring, labelled) at the true low rim where water would actually overtop?
/// • which basins hold REAL LAKES (blue tint, the lake cells brighter) vs DRY sinks (amber tint)?
/// • does "who drains to whom" (the arrows: cyan → ocean, white → another basin, red = closed)
/// look like a physically sane network?
///
/// Label at each spill: <c>"23M/7"</c> = spill 23 m above the sea datum, 7 m climb from the basin
/// floor (the number the cap is judged against). Both on the RENDER surface.
/// </summary>
public static class BasinGraphRenderer
{
private static readonly Color LakeTint = new(0.250f, 0.520f, 1.000f);
private static readonly Color LakeWater = new(0.180f, 0.420f, 0.980f);
private static readonly Color DryTint = new(0.980f, 0.660f, 0.250f);
private static readonly Color OutlineL = new(0.100f, 0.250f, 0.650f);
private static readonly Color OutlineD = new(0.600f, 0.330f, 0.060f);
private static readonly Color Spill = new(1.000f, 0.930f, 0.350f);
private static readonly Color EdgeOcean = new(0.250f, 0.900f, 1.000f);
private static readonly Color EdgeBasin = new(1.000f, 1.000f, 1.000f);
private static readonly Color EdgeNone = new(1.000f, 0.250f, 0.250f);
private static readonly Color Floor = new(0.050f, 0.050f, 0.050f);
private static readonly Color Ink = new(0.941f, 0.949f, 0.961f);
private static readonly Color Shadow = new(0.000f, 0.000f, 0.000f);
private static readonly Color SeabedOutline = new(0.180f, 0.300f, 0.520f);
public static Image Plate(BasinGraph g, DrainageAnalysis.Plan plan, Image img, int n,
bool[] isOcean, bool[] isClassifyWater, string title, string subtitle, string third)
{
int[] basinId = plan.BasinId;
int total = n * n;
// 1. tint every basin cell by lake/dry; lake cells inside a basin drawn as water.
var lakeOf = new Dictionary<int, bool>();
var seabed = new HashSet<int>();
foreach (var b in g.Nodes) { lakeOf[b.Id] = b.IsLake; if (b.IsSeabed) seabed.Add(b.Id); }
for (int i = 0; i < total; i++)
{
int id = basinId[i];
if (id == 0 || seabed.Contains(id)) continue;
int x = i / n, y = i % n;
bool isLakeBasin = lakeOf.TryGetValue(id, out bool l) && l;
if (isClassifyWater[i] && !isOcean[i])
{
img.SetPixel(x, y, isLakeBasin ? LakeWater : img.GetPixel(x, y).Lerp(LakeWater, 0.55f));
continue;
}
img.SetPixel(x, y, img.GetPixel(x, y).Lerp(isLakeBasin ? LakeTint : DryTint, 0.38f));
}
// 2. outline: a basin cell with a 4-neighbour of a different id.
for (int i = 0; i < total; i++)
{
int id = basinId[i];
if (id == 0) continue;
int x = i / n, y = i % n;
bool edge = (x > 0 && basinId[i - n] != id) || (x < n - 1 && basinId[i + n] != id)
|| (y > 0 && basinId[i - 1] != id) || (y < n - 1 && basinId[i + 1] != id);
if (!edge) continue;
if (seabed.Contains(id)) { img.SetPixel(x, y, SeabedOutline); continue; } // the seam: outline only
bool isLakeBasin = lakeOf.TryGetValue(id, out bool l) && l;
img.SetPixel(x, y, isLakeBasin ? OutlineL : OutlineD);
}
int thin = n >= 4096 ? 3 : 2, ring = n >= 4096 ? 16 : 9, ringW = n >= 4096 ? 4 : 3;
int floorR = n >= 4096 ? 6 : 3, head = n >= 4096 ? 22 : 12;
int scale = n >= 4096 ? 3 : 2;
// 3. the edges — the spill walk, arrowhead at the downstream end.
foreach (var b in g.LandNodes)
{
Color c = b.Downstream switch
{
DownstreamKind.Ocean => EdgeOcean,
DownstreamKind.Basin => EdgeBasin,
_ => EdgeNone,
};
var path = b.SpillPath;
if (path.Count >= 2)
{
for (int i = 1; i < path.Count; i++)
DrainageRenderer.Line(img, path[i - 1] / n, path[i - 1] % n, path[i] / n, path[i] % n, n, c, thin);
Arrowhead(img, path, n, c, head, thin);
}
else
{
// A spill that is itself the terminus (the rim cell is ocean) — or stuck on the spot.
DrainageRenderer.Disc(img, b.SpillCell / n, b.SpillCell % n, ring / 2, n, c);
}
}
// 4. spills, floors, labels.
foreach (var b in g.LandNodes)
{
int sx = b.SpillCell / n, sy = b.SpillCell % n;
DrainageRenderer.Ring(img, sx, sy, ring, n, Spill, ringW);
DrainageRenderer.Disc(img, b.FloorCell / n, b.FloorCell % n, floorR, n, Floor);
string lbl = $"{b.SpillAboveSeaM:F0}M/{b.SpillClimbM:F0}"; // TinyFont has no '+' or '^': "spill m above sea / climb m from floor"
Label(img, lbl, sx + ring + 4, sy - TinyFont.Height(scale) / 2, scale, n);
Label(img, $"#{b.Id}", b.FloorCell / n + floorR + 3, b.FloorCell % n - TinyFont.Height(scale) / 2, scale, n);
}
// 5. the legend.
int s = n >= 4096 ? 4 : 3; int lh = TinyFont.Height(s) + 6;
TinyFont.Draw(img, title, 12, 12, s, Ink);
TinyFont.Draw(img, subtitle, 12, 12 + lh, s, Ink);
TinyFont.Draw(img, third, 12, 12 + lh * 2, s, Ink);
TinyFont.Draw(img, "BLUE TINT = LAKE BASIN (SIGNIFICANT CLASSIFY WATER INSIDE) AMBER TINT = DRY SINK BLACK DOT = BASIN FLOOR (#ID)", 12, 12 + lh * 3, s, Ink);
TinyFont.Draw(img, "YELLOW RING = SPILL CELL. LABEL 12M/4 = SPILL 12 M ABOVE SEA / 4 M CLIMB FROM THE BASIN FLOOR TO OVERTOP (RENDER SURFACE)", 12, 12 + lh * 4, s, Ink);
TinyFont.Draw(img, "ARROW = WHERE THE SPILL DRAINS: CYAN TO OCEAN, WHITE INTO ANOTHER BASIN, RED = CLOSED. DATA LAYER ONLY - NOTHING FILLED, NOTHING CARVED", 12, 12 + lh * 5, s, Ink);
TinyFont.Draw(img, "FAINT BLUE OUTLINE, NO MARKS = SEABED PIT (A RENDER DEPRESSION UNDER THE CLASSIFY SEA - THE D-046 SEAM, INERT, EXCLUDED FROM THE GRAPH)", 12, 12 + lh * 6, s, Ink);
return img;
}
private static void Arrowhead(Image img, List<int> path, int n, Color c, int len, int thick)
{
int end = path[^1];
int from = path[Math.Max(0, path.Count - 1 - 24)];
float ex = end / n, ey = end % n, fx = from / n, fy = from % n;
float dx = ex - fx, dy = ey - fy;
float L = MathF.Sqrt(dx * dx + dy * dy);
if (L < 1f) return;
dx /= L; dy /= L;
// two barbs, 30° either side of the reversed direction
const float a = 0.5236f;
float cs = MathF.Cos(a), sn = MathF.Sin(a);
float bx1 = -dx * cs - (-dy) * sn, by1 = -dx * sn + (-dy) * cs;
float bx2 = -dx * cs + (-dy) * sn, by2 = -(-dx) * sn + (-dy) * cs;
DrainageRenderer.Line(img, (int)ex, (int)ey, (int)(ex + bx1 * len), (int)(ey + by1 * len), n, c, thick);
DrainageRenderer.Line(img, (int)ex, (int)ey, (int)(ex + bx2 * len), (int)(ey + by2 * len), n, c, thick);
}
/// <summary>Ink over a one-px black shadow, clamped inside the image so a rim label near the edge is still readable.</summary>
private static void Label(Image img, string text, int x, int y, int scale, int n)
{
int w = TinyFont.Width(text, scale), h = TinyFont.Height(scale);
x = Math.Clamp(x, 0, Math.Max(0, n - w - 1));
y = Math.Clamp(y, 0, Math.Max(0, n - h - 1));
TinyFont.Draw(img, text, x + 1, y + 1, scale, Shadow);
TinyFont.Draw(img, text, x, y, scale, Ink);
}
}
}

View file

@ -0,0 +1,599 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using Godot;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
/// <summary>
/// ⭐⭐ THE LAKE-BASIN LAYER (rivers/04) — build the basin graph and show it, so the developer can
/// trust the spills before anything is routed on them.
///
/// ═══ WHAT THIS TASK IS FOR ═══
///
/// The flow-through routing model (the next task) needs lakes to be NODES in the drainage graph,
/// not free-floating heightmap classifications the router bumps into. This builds that layer from
/// what `DrainageAnalysis` already computed — the sinks (`BasinId`) and the overflow surface
/// (`FullFilled`) — enriching each terminal basin with its SPILL, its LAKE-IDENTITY and its
/// DOWNSTREAM EDGE. → <see cref="BasinGraph"/>.
///
/// ═══ ⛔ THE RED LINE — A DATA LAYER, NOT A TERRAIN WRITE ═══
///
/// **No height is written. No water is filled or created. `DrainageAnalysis` is reused, not
/// rewritten.** Spills and lake-identity are computed and recorded, never stamped. Both height
/// fields are digested before the layer is built and after the plate is drawn, and the tool REFUSES
/// to continue if either changed — the flood-guard discipline every task since erosion has kept.
///
/// ═══ RUNNING IT ═══
///
/// xvfb-run -a Godot_v4.7.2-stable_mono_linux.x86_64 \
/// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/BasinGraphTool.tscn
///
/// ISLA_TASK / ISLA_TASK_SUFFIX / ISLA_BATCH / ISLA_CHAT / ISLA_MAPSIZE / ISLA_CALIB_SIZE / ISLA_SEEDS / ISLA_SKIP_RAW
/// ISLA_LAKE_MIN_PX the significance floor for a basin's in-basin classify water (default 20000 — a KNOB)
/// ISLA_CAP_PREVIEW_M comma list of rim caps to preview connectivity at (default "15,30,60")
/// </summary>
public partial class BasinGraphTool : 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 BasinGraph Graph;
public int TerminalBasinCount;
public long LandCells, EndorheicCells;
public ulong RenderDigest, ClassifyDigest;
public int WaterBodiesKept, WaterBodiesTotal; public long WaterCellsKept, LargestWaterPx;
public float SpillMinM, SpillP25M, SpillMedM, SpillP75M, SpillMaxM;
public float ClimbMinM, ClimbP25M, ClimbMedM, ClimbP75M, ClimbMaxM;
public int[] ClimbBands; // <=5, 5-15, 15-30, 30-60, >60
public Dictionary<float, (int all, int lake, int dry, int direct)> Cap = new();
public int LakeAnyOnly; // HasAnyLake && !IsLake — where this layer's label differs from the routing sort
public int MaxHops;
public float GraphSeconds, 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", 4);
string taskSfx = EnvStr("ISLA_TASK_SUFFIX", "");
string descr = EnvStr("ISLA_BATCH", "lake_basin_layer");
int mapSize = EnvInt("ISLA_MAPSIZE", DefaultMapSize);
int calibSize = EnvInt("ISLA_CALIB_SIZE", DefaultCalibSize);
int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds);
int lakeMinPx = EnvInt("ISLA_LAKE_MIN_PX", RiverRouting.LakeMinTargetPx);
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "1") == "1";
float[] caps = EnvFloats("ISLA_CAP_PREVIEW_M", new[] { 15f, 30f, 60f });
TerrainShapeV1.Assert("BasinGraph");
TerrainShapeV1.AssertErosionDefaultOn("BasinGraph");
string batchRoot = ToolingPaths.BatchRoot(task, taskSfx, descr);
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
DirAccess.MakeDirRecursiveAbsolute(ToolingPaths.BatchScratch(batchRoot));
var anchors = CurveAnchors.Default;
float sea = 0.15f;
// ⚠ The ENUMERATION gates (EndorheicMinDepthM / EndorheicMinAreaPx) are the defaults — they
// define which depressions ARE terminal basins, i.e. the nodes of this graph. Reporting caps
// are irrelevant here: the layer reads BasinId / FullFilled / Dir, not the promoted lists.
var dp = new DrainageAnalysis.Params { SeaLevel = sea };
GD.Print("==================================================================");
GD.Print(" THE LAKE-BASIN LAYER (rivers/04) — the basin graph: spill + lake-identity + downstream edge, per terminal basin");
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($"spill : {BasinGraph.SpillDatum}");
GD.Print($"lake : {BasinGraph.LakeDatum} floor = {lakeMinPx:N0} px (ISLA_LAKE_MIN_PX, a knob)");
GD.Print($"edge : {BasinGraph.DownstreamMethod}");
GD.Print($"D-046 : spill geometry/height on RENDER (the flow surface, as RouteTo routes); lake presence + OCEAN on CLASSIFY (the water surface, as the terminus tests). No cross-surface comparison anywhere.");
GD.Print($"cap view : unbroken spill-chain to the ocean previewed at {string.Join(" / ", Array.ConvertAll(caps, c => c.ToString("F0")))} m (every link's floor→spill climb ≤ cap; clamped at sea as RouteTo clamps)");
GD.Print($"⛔ RED LINE : DATA LAYER ONLY — no height mutated, no water filled, DrainageAnalysis untouched. ASSERTED per seed around build + render.");
GD.Print($"batch : {batchRoot}");
GD.Print("==================================================================");
if (mapSize != 8192)
GD.PrintErr($" ⚠⚠ MAP SIZE {mapSize} — the terminal-basin gates are ABSOLUTE PIXEL COUNTS tuned at 8192; a smaller " +
"map under-produces terminal basins. This run checks the PLUMBING (spill extraction, graph, render), not the character.");
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 = "basins",
Curve = true, ShelfDetail = false, CurveMode = CurveModeKind.Continuous,
Knots = knots, Anchors = anchors, ClimbCalibration = calibration, LowlandCeilingM = 30f,
};
var results = new List<SeedResult>();
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;
// ⭐ THE OCEAN IDENTITY and the water surface — CLASSIFY (→ D-066 / D-046).
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;
// ⭐ THE FLOW SURFACE — the analysis on the eroded RENDER height, exactly as every task since chat2/12.
var plan = DrainageAnalysis.Run(p2.Height, mapSize, isOcean, isClassifyWater, -1f, -1f, dp);
GD.Print($" land {plan.LandCells:N0} — sea-reaching {plan.SeaReachingCells:N0} ({100.0 * plan.SeaReachingCells / Math.Max(1, plan.LandCells):F1} %), " +
$"endorheic {plan.EndorheicCells:N0} ({100.0 * plan.EndorheicCells / Math.Max(1, plan.LandCells):F1} %); terminal basins {plan.TerminalBasinCount}; pits filled through {plan.PitsFilledCount:N0}");
bool[] significant = RegionLabeling.SignificantWaterMask(isClassifyWater, isOcean, mapSize,
lakeMinPx, out int keptBodies, out int totalBodies, out long keptCells, out long largestPx);
GD.Print($" significant water: {keptBodies} of {totalBodies} classify-water bodies >= {lakeMinPx:N0} px ({keptCells:N0} cells; largest {largestPx:N0} px)");
// ═══ ⛔ THE RED-LINE GUARD — both fields digested BEFORE the layer ═══
ulong hRenderBefore = Digest(p2.Height, mapSize);
ulong hClassifyBefore = Digest(p2.HeightClassify, mapSize);
ulong tg0 = Time.GetTicksMsec();
var g = BasinGraph.Build(plan, p2.Height, mapSize, isOcean, isClassifyWater, significant, sea, lakeMinPx);
float graphSec = (Time.GetTicksMsec() - tg0) / 1000f;
if (g.Nodes.Count != plan.TerminalBasinCount)
throw new InvalidOperationException($"[BasinGraph] node count {g.Nodes.Count} != Plan.TerminalBasinCount {plan.TerminalBasinCount} — the layer did not enumerate the analysis's basins exactly.");
var r = new SeedResult
{
Seed = seed, Graph = g, TerminalBasinCount = plan.TerminalBasinCount,
LandCells = plan.LandCells, EndorheicCells = plan.EndorheicCells,
WaterBodiesKept = keptBodies, WaterBodiesTotal = totalBodies, WaterCellsKept = keptCells, LargestWaterPx = largestPx,
GraphSeconds = graphSec,
};
Summarise(r, caps);
GD.Print($" ⚠ SEAM: {g.Seabed} of {g.Nodes.Count} terminal basins are SEABED pits (every cell ocean on classify, inflow 0) — excluded from the graph statistics below; {g.Coastal} straddle the shoreline (kept as land).");
GD.Print($" ⭐ GRAPH (land): {g.LandNodes.Count} basins — {g.LakeBasins} LAKE / {g.DryBasins} DRY (floor {lakeMinPx:N0} px; {r.LakeAnyOnly} hold only a sub-floor puddle)");
GD.Print($" spills → ocean {g.ToOcean} / → another basin {g.ToBasin} / closed {g.Closed}; longest chain {r.MaxHops} hops");
GD.Print($" spill height above sea (m): min {r.SpillMinM:F1} p25 {r.SpillP25M:F1} median {r.SpillMedM:F1} p75 {r.SpillP75M:F1} max {r.SpillMaxM:F1}");
GD.Print($" floor→spill climb (m): min {r.ClimbMinM:F1} p25 {r.ClimbP25M:F1} median {r.ClimbMedM:F1} p75 {r.ClimbP75M:F1} max {r.ClimbMaxM:F1} bands ≤5/515/1530/3060/>60: {string.Join("/", r.ClimbBands)}");
foreach (float cap in caps)
{
var c = r.Cap[cap];
GD.Print($" cap {cap,3:F0} m: {c.all,3} of {g.LandNodes.Count} land basins chain to the ocean ({c.lake} lake / {c.dry} dry; {c.direct} of them directly)");
}
GD.Print($" ✅ invariants: spill cross-check (fill-level vs rim-walk) failures {g.SpillCrossCheckFailures}; spill-not-on-terrain {g.SpillNotOnTerrain}; " +
$"Dir-walk disagreements {g.DirWalkDisagreements}; seabed {g.Seabed} / coastal {g.Coastal}");
if (g.SpillCrossCheckFailures > 0 || g.SpillNotOnTerrain > 0)
GD.PrintErr(" ⚠⚠ A SPILL INVARIANT FAILED — the spill datum is not exact on this seed. Reported, not hidden; see the CSV.");
if (g.DirWalkDisagreements > 0)
GD.PrintErr(" ⚠ The FullFilled walk and the Dir walk disagree on some basin's downstream — listed in the CSV (dir_walk_agrees).");
GD.Print($" ⭐ RECONCILIATION: {g.LakeBodies.Count} significant classify bodies — {g.LakeBodiesOwned} owned by a basin (≥ half inside one), " +
$"{g.LakeBodiesSplit} split across basins, {g.LakeBodiesFree} FREE (in no terminal basin at all); " +
$"{g.ClassifyWaterCellsOutsideBasins:N0} of {g.ClassifyWaterCellsTotal:N0} non-ocean classify-water cells lie outside every basin");
GD.Print($" graph built in {graphSec:F2}s");
WriteBasinCsv(batchRoot, r, caps);
WriteLakeCsv(batchRoot, r);
ulong tr0 = Time.GetTicksMsec();
RenderSeed(batchRoot, r, plan, isOcean, isClassifyWater, p2, mapSize, sea, skipRaw);
r.RenderSeconds = (Time.GetTicksMsec() - tr0) / 1000f;
// ═══ ⛔ …and asserted byte-identical AFTER build + render ═══
ulong hRenderAfter = Digest(p2.Height, mapSize);
ulong hClassifyAfter = Digest(p2.HeightClassify, mapSize);
if (hRenderAfter != hRenderBefore || hClassifyAfter != hClassifyBefore)
throw new InvalidOperationException(
"[BasinGraph] RED-LINE VIOLATION: a height field CHANGED across the layer build / render.\n" +
$" render {hRenderBefore:X16} -> {hRenderAfter:X16}\n" +
$" classify {hClassifyBefore:X16} -> {hClassifyAfter:X16}\n" +
"This task builds a DATA layer — it must never mutate a height or fill water. Refusing to continue.");
r.RenderDigest = hRenderBefore; r.ClassifyDigest = hClassifyBefore;
GD.Print($" ✅ RED LINE HELD: render {hRenderBefore:X16} and classify {hClassifyBefore:X16} byte-identical across build + render — no height mutated, no water filled.");
r.Ms = Time.GetTicksMsec() - t0;
results.Add(r);
}
WriteIndex(batchRoot, mapSize, seeds, results, lakeMinPx, caps, dp, skipRaw);
GD.Print("\n==================================================================");
GD.Print($" DONE — {batchRoot}");
GD.Print(" ⛔ TASTE GATE: the graph is PRESENTED, not routed on. Nothing locked, nothing routed, nothing graduated.");
GD.Print(" ⛔ DATA LAYER ONLY: no height mutated, no water filled — asserted per seed.");
GD.Print("==================================================================");
GetTree().Quit(0);
}
private static void Summarise(SeedResult r, float[] caps)
{
var g = r.Graph;
var spill = new List<float>(); var climb = new List<float>();
r.ClimbBands = new int[5];
foreach (var b in g.LandNodes)
{
spill.Add(b.SpillAboveSeaM); climb.Add(b.SpillClimbM);
r.ClimbBands[b.SpillClimbM <= 5f ? 0 : b.SpillClimbM <= 15f ? 1 : b.SpillClimbM <= 30f ? 2 : b.SpillClimbM <= 60f ? 3 : 4]++;
if (b.HasAnyLake && !b.IsLake) r.LakeAnyOnly++;
int hops = g.HopsToOcean(b.Id);
if (hops > r.MaxHops) r.MaxHops = hops;
}
spill.Sort(); climb.Sort();
(r.SpillMinM, r.SpillP25M, r.SpillMedM, r.SpillP75M, r.SpillMaxM) = Quantiles(spill);
(r.ClimbMinM, r.ClimbP25M, r.ClimbMedM, r.ClimbP75M, r.ClimbMaxM) = Quantiles(climb);
foreach (float cap in caps)
{
bool[] ok = g.ConnectedAtCap(cap, out int connected);
int lake = 0, dry = 0, direct = 0;
connected = 0;
for (int i = 0; i < g.Nodes.Count; i++)
{
if (!ok[i] || !g.Nodes[i].IsLand) continue; // land basins only — a seabed pit "chains" trivially
connected++;
if (g.Nodes[i].IsLake) lake++; else dry++;
if (g.Nodes[i].Downstream == DownstreamKind.Ocean) direct++;
}
r.Cap[cap] = (connected, lake, dry, direct);
}
}
private static (float, float, float, float, float) Quantiles(List<float> sorted)
{
if (sorted.Count == 0) return (0, 0, 0, 0, 0);
float Q(double q) => sorted[Math.Clamp((int)Math.Round(q * (sorted.Count - 1)), 0, sorted.Count - 1)];
return (sorted[0], Q(0.25), Q(0.5), Q(0.75), sorted[^1]);
}
/// <summary>FNV-1a over the raw float bits — "byte-identical", not "numerically close".</summary>
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 string Kind(DownstreamKind k) => k switch
{
DownstreamKind.Ocean => "OCEAN",
DownstreamKind.Basin => "BASIN",
_ => "NONE",
};
private static void WriteBasinCsv(string batchRoot, SeedResult r, float[] caps)
{
var g = r.Graph; int n = g.MapSize;
var capOk = new Dictionary<float, bool[]>();
foreach (float cap in caps) capOk[cap] = g.ConnectedAtCap(cap, out _);
var sb = new StringBuilder();
sb.Append("id,class,area_px,inflow_px,is_lake,has_any_lake,lake_cells,lake_cells_significant,ocean_cells," +
"floor_x,floor_y,floor_raw,spill_x,spill_y,spill_raw,spill_above_sea_m,depth_to_spill_m,spill_climb_m,spill_ties," +
"spill_crosscheck_ok,spill_on_terrain,downstream,downstream_id,downstream_x,downstream_y,spill_path_cells,dir_walk_agrees,dir_walk_kind,dir_walk_id,hops_to_ocean");
foreach (float cap in caps) sb.Append($",chain_ok_cap{cap:F0}");
sb.AppendLine();
for (int i = 0; i < g.Nodes.Count; i++)
{
var b = g.Nodes[i];
sb.Append($"{b.Id},{(b.IsSeabed ? "seabed" : b.IsCoastal ? "coastal" : "inland")},{b.AreaPx},{b.InflowPx},{(b.IsLake ? "yes" : "no")},{(b.HasAnyLake ? "yes" : "no")},{b.LakeCells},{b.LakeCellsSignificant},{b.OceanCells}," +
$"{b.FloorCell / n},{b.FloorCell % n},{b.FloorHeightRaw:R},{b.SpillCell / n},{b.SpillCell % n},{b.SpillHeightRaw:R}," +
$"{b.SpillAboveSeaM:F2},{b.DepthToSpillM:F2},{b.SpillClimbM:F2},{b.SpillTies}," +
$"{(b.SpillCrossCheckOk ? "yes" : "NO")},{(b.SpillOnTerrain ? "yes" : "NO")},{Kind(b.Downstream)},{b.DownstreamId}," +
$"{b.DownstreamEntryCell / n},{b.DownstreamEntryCell % n},{b.SpillPath.Count},{(b.DirWalkAgrees ? "yes" : "NO")},{Kind(b.DirWalkKind)},{b.DirWalkId},{g.HopsToOcean(b.Id)}");
foreach (float cap in caps) sb.Append($",{(capOk[cap][i] ? "yes" : "no")}");
sb.AppendLine();
}
WriteText(Path.Combine(batchRoot, $"basins_{r.Seed}.csv"), sb.ToString());
}
private static void WriteLakeCsv(string batchRoot, SeedResult r)
{
var g = r.Graph;
var sb = new StringBuilder();
sb.AppendLine("body,size_px,cells_in_basins,basins_touched,dominant_basin_id,dominant_cells,dominant_basin_is_lake,status");
foreach (var l in g.LakeBodies)
{
var b = l.DominantBasinId != 0 ? g.Of(l.DominantBasinId) : null;
sb.AppendLine($"{l.Index},{l.SizePx},{l.CellsInBasins},{l.BasinsTouched},{l.DominantBasinId},{l.DominantCells}," +
$"{(b == null ? "" : b.IsLake ? "yes" : "no")},{(l.Free ? "FREE" : l.Owned ? "owned" : "split")}");
}
WriteText(Path.Combine(batchRoot, $"lakes_{r.Seed}.csv"), sb.ToString());
}
private static void RenderSeed(string batchRoot, SeedResult r, DrainageAnalysis.Plan plan, bool[] isOcean,
bool[] isClassifyWater, Pass2Result p2, int n, float sea, bool skipRaw)
{
string dir = Path.Combine(batchRoot, $"{r.Seed}");
DirAccess.MakeDirRecursiveAbsolute(dir);
var g = r.Graph;
Image baseImg = DrainageRenderer.TerrainBase(isOcean, p2.Height, n, sea, p2.HMax);
string caps = "";
foreach (var kv in r.Cap) caps += $"{kv.Key:F0}M:{kv.Value.all} ";
BasinGraphRenderer.Plate(g, plan, baseImg, n, isOcean, isClassifyWater,
$"SEED {r.Seed} - THE BASIN GRAPH: {g.LandNodes.Count} LAND BASINS, {g.LakeBasins} LAKE / {g.DryBasins} DRY (FLOOR {g.LakeMinPx} PX) [+{g.Seabed} SEABED PITS, OUTLINED ONLY]",
$"SPILLS: {g.ToOcean} TO OCEAN / {g.ToBasin} INTO ANOTHER BASIN / {g.Closed} CLOSED. LONGEST CHAIN {r.MaxHops} HOPS. BASINS CHAINING TO THE OCEAN AT CAP {caps.Trim()}",
$"SPILL HEIGHT ABOVE SEA: MEDIAN {r.SpillMedM:F0} M (RANGE {r.SpillMinM:F0}..{r.SpillMaxM:F0}). FLOOR-TO-SPILL CLIMB: MEDIAN {r.ClimbMedM:F0} M (RANGE {r.ClimbMinM:F0}..{r.ClimbMaxM:F0}). TASTE GATE - NOTHING ROUTED, NOTHING LOCKED")
.SavePng(Path.Combine(dir, $"basin_graph_{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<SeedResult> rows,
int lakeMinPx, float[] caps, DrainageAnalysis.Params def, bool skipRaw)
{
var sb = new StringBuilder();
int primary = seeds.Length > 0 ? seeds[0] : 0;
string capHdr = string.Join(" | ", Array.ConvertAll(caps, c => $"chain→ocean @ {c:F0} m"));
sb.AppendLine("# Batch 04 — the lake-basin layer: the basin graph (spill + lake-identity + downstream edge)");
sb.AppendLine();
sb.AppendLine("**⛔ TASTE GATE ON THE FOUNDATION. Nothing is routed, nothing is locked, nothing is graduated.** This is the");
sb.AppendLine("data layer the flow-through routing model (piece 2) will traverse; piece 2 is authored only once the spills");
sb.AppendLine("and the graph read right.");
sb.AppendLine();
sb.AppendLine("**⛔ DATA LAYER ONLY. No height was mutated, no water was filled or created, `DrainageAnalysis` was not");
sb.AppendLine("edited** — asserted per seed by an FNV digest of both height fields taken before the layer was built and");
sb.AppendLine("after the plate was drawn.");
sb.AppendLine();
sb.AppendLine("## 👉 The pick");
sb.AppendLine();
sb.AppendLine($"Open **`{primary}/basin_graph_{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("> **Do the spills (yellow rings) sit where water would actually overflow — the true low rim? Are the");
sb.AppendLine("> lake/dry labels right (blue = a significant classify lake sits in the basin, amber = dry sink)? And does");
sb.AppendLine("> \"who drains to whom\" (cyan arrow → ocean, white arrow → another basin, red = closed) look like a real");
sb.AppendLine("> drainage network?** If the spills are wrong, piece 2 must not be built on this.");
sb.AppendLine(">");
sb.AppendLine("> Each spill is labelled `23M/7`: **23 m above the sea datum**, and **7 m of climb from the basin floor**");
sb.AppendLine("> to overtop — the second number is what a rim cap is judged against. Both read on the RENDER surface.");
sb.AppendLine("> The black dot is the basin floor, with its `#id` (the id `BasinId` carries — sparse, as the analysis leaves it).");
sb.AppendLine();
sb.AppendLine("## ⭐ The graph, per seed");
sb.AppendLine();
sb.AppendLine($"| Seed | terminal basins | ⚠ seabed (excluded) | coastal | **land basins** | **lake** | dry | (sub-floor puddle only) | spill → ocean | → basin | closed | longest chain | {capHdr} |");
sb.AppendLine("|---|---|---|---|---|---|---|---|---|---|---|---|" + string.Concat(Array.ConvertAll(caps, _ => "---|")));
foreach (var r in rows)
{
var g = r.Graph;
sb.Append($"| `{r.Seed}` | {g.Nodes.Count} | {g.Seabed} | {g.Coastal} | **{g.LandNodes.Count}** | **{g.LakeBasins}** | {g.DryBasins} | {r.LakeAnyOnly} | {g.ToOcean} | {g.ToBasin} | {g.Closed} | {r.MaxHops} hops |");
foreach (float cap in caps) { var c = r.Cap[cap]; sb.Append($" **{c.all}** ({c.lake} lake / {c.dry} dry) |"); }
sb.AppendLine();
}
sb.AppendLine();
sb.AppendLine("> ### ⚠⚠ SEABED PITS — half the analysis's \"terminal basins\" are not on land");
sb.AppendLine("> The priority-flood runs on the whole RENDER surface, ocean floor included, so a deep-enough, large-enough");
sb.AppendLine("> depression UNDER the classify sea qualifies as a terminal basin exactly like a land one. Every cell of such");
sb.AppendLine("> a basin is `OceanMask`, its cells are `D_NONE`, its inflow is 0 — it is hydrologically inert, and it is the");
sb.AppendLine("> D-046 seam (render vs classify) made visible. They are kept in the layer and the CSV (`class = seabed`),");
sb.AppendLine("> drawn as a faint outline only, and **excluded from every statistic on this page.** The `land basins` column is");
sb.AppendLine("> the graph; `coastal` basins (some ocean cells, some land) are counted as land and flagged.");
sb.AppendLine();
sb.AppendLine("> **Reading the cap columns.** A basin \"chains to the ocean at cap C\" when every link from it to the sea — its");
sb.AppendLine("> own spill and every downstream basin's spill — climbs ≤ C m from that basin's floor (elevation clamped at sea,");
sb.AppendLine("> exactly as `RouteTo` clamps). This previews what the flow-through model will trade at a given cap; it decides");
sb.AppendLine("> nothing. Piece 2 routes; this only says how many basins *could* connect.");
sb.AppendLine();
sb.AppendLine("## ⭐ Spill-height distributions (metres, render surface)");
sb.AppendLine();
sb.AppendLine("| Seed | spill above sea: min / p25 / median / p75 / max | floor→spill climb: min / p25 / median / p75 / max | climb bands ≤5 / 515 / 1530 / 3060 / >60 |");
sb.AppendLine("|---|---|---|---|");
foreach (var r in rows)
sb.AppendLine($"| `{r.Seed}` | {r.SpillMinM:F1} / {r.SpillP25M:F1} / {r.SpillMedM:F1} / {r.SpillP75M:F1} / {r.SpillMaxM:F1} | " +
$"{r.ClimbMinM:F1} / {r.ClimbP25M:F1} / {r.ClimbMedM:F1} / {r.ClimbP75M:F1} / {r.ClimbMaxM:F1} | {string.Join(" / ", r.ClimbBands)} |");
sb.AppendLine();
sb.AppendLine("## ✅ The invariants, per seed — the spill datum is exact, or it says so");
sb.AppendLine();
sb.AppendLine("| Seed | spill cross-check failures (fill-level vs rim-walk) | spill not on terrain | Dir-walk disagreements | seabed / coastal | render digest | classify digest |");
sb.AppendLine("|---|---|---|---|---|---|---|");
foreach (var r in rows)
{
var g = r.Graph;
sb.AppendLine($"| `{r.Seed}` | {(g.SpillCrossCheckFailures == 0 ? "**0** " : $"**{g.SpillCrossCheckFailures}** ")} | " +
$"{(g.SpillNotOnTerrain == 0 ? "**0** " : $"**{g.SpillNotOnTerrain}** ")} | " +
$"{(g.DirWalkDisagreements == 0 ? "**0** " : $"**{g.DirWalkDisagreements}** ")} | {g.Seabed} / {g.Coastal} | `{r.RenderDigest:X16}` | `{r.ClassifyDigest:X16}` |");
}
sb.AppendLine();
sb.AppendLine("*Cross-check: the basin's minimum on `FullFilled` is the spill + one ulp (the flood's own epsilon), so");
sb.AppendLine("`BitDecrement(min inside) == min over the rim` must hold exactly. \"On terrain\": `FullFilled == render` at the spill");
sb.AppendLine("cell — the rim was never raised by the flood. \"Dir-walk\": following `Plan.Dir` from the first cell past the spill");
sb.AppendLine("reaches the same node as the `FullFilled` descent. A basin holding OCEAN cells is a render depression under");
sb.AppendLine("classify-sea — the D-046 seam, counted rather than hidden.*");
sb.AppendLine();
sb.AppendLine("## ⭐ The reconciliation — does each significant heightmap lake sit in a terminal basin?");
sb.AppendLine();
sb.AppendLine("| Seed | significant bodies (≥ floor) | **owned** (≥ half inside one basin) | split across basins | **FREE** (in no basin) | non-ocean classify-water cells outside every basin |");
sb.AppendLine("|---|---|---|---|---|---|");
foreach (var r in rows)
{
var g = r.Graph;
sb.AppendLine($"| `{r.Seed}` | {g.LakeBodies.Count} | **{g.LakeBodiesOwned}** | {g.LakeBodiesSplit} | **{g.LakeBodiesFree}** | {g.ClassifyWaterCellsOutsideBasins:N0} of {g.ClassifyWaterCellsTotal:N0} ({100.0 * g.ClassifyWaterCellsOutsideBasins / Math.Max(1, g.ClassifyWaterCellsTotal):F1} %) |");
}
sb.AppendLine();
sb.AppendLine("*A FREE body is a classify lake that is not a depression ≥ 2 m / 10,000 px on the RENDER surface (or filled through as a");
sb.AppendLine("pit) — heightmap water the hydrology never pooled into. On the plate these are the dark-teal patches with no tint. They are");
sb.AppendLine("exactly the \"free-floating heightmap lakes\" this layer exists to reconcile; per body detail in `lakes_<seed>.csv`.*");
sb.AppendLine();
foreach (var r in rows)
{
var g = r.Graph; int n = g.MapSize;
sb.AppendLine($"### `{r.Seed}` — every LAND basin, largest first ({g.Seabed} seabed pits omitted; see the CSV)");
sb.AppendLine();
sb.Append("| id | area px | inflow px | lake? | lake cells | spill (x,y) | spill +m | climb m | → | hops |");
foreach (float cap in caps) sb.Append($" @{cap:F0} |");
sb.AppendLine();
sb.AppendLine("|---|---|---|---|---|---|---|---|---|---|" + string.Concat(Array.ConvertAll(caps, _ => "---|")));
var order = new List<BasinNode>(g.LandNodes);
order.Sort((a, b) => b.AreaPx.CompareTo(a.AreaPx));
var capOk = new Dictionary<float, bool[]>();
foreach (float cap in caps) capOk[cap] = g.ConnectedAtCap(cap, out _);
foreach (var b in order)
{
int idx = g.Nodes.IndexOf(b);
string to = b.Downstream switch
{
DownstreamKind.Ocean => "**OCEAN**",
DownstreamKind.Basin => $"#{b.DownstreamId}",
_ => "⚠ closed",
};
int hops = g.HopsToOcean(b.Id);
sb.Append($"| #{b.Id}{(b.IsCoastal ? " coastal" : "")} | {b.AreaPx:N0} | {b.InflowPx:N0} | {(b.IsLake ? "**lake**" : b.HasAnyLake ? "puddle" : "dry")} | {b.LakeCells:N0} | " +
$"({b.SpillCell / n},{b.SpillCell % n}) | {b.SpillAboveSeaM:F1} | {b.SpillClimbM:F1} | {to} | {(hops < 0 ? "" : hops.ToString())} |");
foreach (float cap in caps) sb.Append(capOk[cap][idx] ? " ✅ |" : " — |");
sb.AppendLine();
}
sb.AppendLine();
sb.AppendLine($"*Land {r.LandCells:N0}, endorheic {r.EndorheicCells:N0} ({100.0 * r.EndorheicCells / Math.Max(1, r.LandCells):F1} %) · " +
$"significant water {r.WaterBodiesKept} of {r.WaterBodiesTotal} bodies ≥ {lakeMinPx:N0} px ({r.WaterCellsKept:N0} cells, largest {r.LargestWaterPx:N0} px) · " +
$"graph {r.GraphSeconds:F2}s, plate {r.RenderSeconds:F1}s, seed total {r.Ms / 1000.0:F0}s · grayscale range {r.GMin:F4}..{r.GMax:F4} raw = {WorldScale.MetresFromRaw(r.GMin):F1}..{WorldScale.MetresFromRaw(r.GMax):F1} m.*");
sb.AppendLine();
}
sb.AppendLine("## The two \"lakes\" this layer reconciles — and the datum each is read on (D-046)");
sb.AppendLine();
sb.AppendLine("| Quantity | Surface | Why |");
sb.AppendLine("|---|---|---|");
sb.AppendLine("| spill cell, spill height, floor, climb | **RENDER** (`Plan.FullFilled`, the flood of the eroded render height) | where water GOES — the surface `RouteTo` routes on, so a climb here is the same number as the router's `RimClimbM` |");
sb.AppendLine("| downstream walk | **RENDER** (`FullFilled` descent) | the reference's provisional-route machinery, started at the spill |");
sb.AppendLine("| lake presence (`IsLake`, lake cells) | **CLASSIFY** (`classify < sea` and not `OceanMask`) | what is VISIBLY water — the surface the terminus tests use |");
sb.AppendLine("| the OCEAN terminus of a walk | **CLASSIFY** (`OceanMask`) | as routing: route on render, ocean on classify |");
sb.AppendLine();
sb.AppendLine("**No field compares a classify height to a render height.** The surfaces meet only as membership tests");
sb.AppendLine("(is this basin cell classify-water? is this walk cell ocean?) — the split the routing already lives by. No new seam.");
sb.AppendLine();
sb.AppendLine("## What was run");
sb.AppendLine();
sb.AppendLine($"Chain + drainage analysis + the layer at **{mapSize}** on **{seeds.Length} seeds** (`{string.Join(", ", seeds)}`), all rendered.");
sb.AppendLine($"`ISLA_LAKE_MIN_PX={lakeMinPx:N0}` (the significance floor — a knob); cap preview at {string.Join(" / ", Array.ConvertAll(caps, c => c.ToString("F0")))} m.");
sb.AppendLine();
sb.AppendLine($"**⚠ NOT touched:** `DrainageAnalysis` (reused — the layer reads `BasinId`, `FullFilled`, `Dir`, `BasinInflow`); " +
$"`EndorheicMinDepthM` {def.EndorheicMinDepthM} m / `EndorheicMinAreaPx` {def.EndorheicMinAreaPx:N0} (they define which depressions ARE the nodes).");
sb.AppendLine();
sb.AppendLine("## Files");
sb.AppendLine();
sb.AppendLine("| File | What it is |");
sb.AppendLine("|---|---|");
sb.AppendLine("| `<seed>/basin_graph_<seed>.png` | the graph over the faint terrain: basins tinted lake/dry, spill rings labelled, arrows to the downstream node |");
sb.AppendLine("| `<seed>/grayscale.png` | the eroded render field, no palette |");
sb.AppendLine("| `basins_<seed>.csv` | every `BasinNode`: class (inland/coastal/seabed), area, inflow, lake cells, floor, spill (cell + raw + metres), climb, downstream kind/id/entry, invariants, hops, chain-ok per cap |");
sb.AppendLine("| `lakes_<seed>.csv` | every significant classify body: size, cells inside basins, dominant basin, owned / split / FREE |");
if (skipRaw)
sb.AppendLine("| ~~`<seed>/height.f32`~~ | **deliberately not written** — rivers/01 proved this field byte-identical to `chat2/11_erosion`. |");
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/04_lake_basin_layer.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<int, Pass1Result>();
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 int[] EnvSeeds(string k, int[] fallback)
{
string v = EnvStr(k, null);
if (v == null) return fallback;
var outp = new List<int>();
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<float>();
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;
}
}
}

View file

@ -783,13 +783,13 @@ namespace IslaApocalypse.Tools
for (int k = 0; k < 8 && x + k < x1; k++) img.SetPixel(x + k, y, c);
}
private static void Polyline(Image img, List<(float x, float y)> pts, int n, Color c, int thick)
internal static void Polyline(Image img, List<(float x, float y)> pts, int n, Color c, int thick)
{
for (int i = 1; i < pts.Count; i++)
Line(img, (int)pts[i - 1].x, (int)pts[i - 1].y, (int)pts[i].x, (int)pts[i].y, n, c, thick);
}
private static void Line(Image img, int x0, int y0, int x1, int y1, int n, Color c, int thick)
internal static void Line(Image img, int x0, int y0, int x1, int y1, int n, Color c, int thick)
{
int dx = Math.Abs(x1 - x0), sx = x0 < x1 ? 1 : -1;
int dy = -Math.Abs(y1 - y0), sy = y0 < y1 ? 1 : -1;
@ -811,7 +811,7 @@ namespace IslaApocalypse.Tools
}
}
private static void Disc(Image img, int cx, int cy, int r, int n, Color c)
internal static void Disc(Image img, int cx, int cy, int r, int n, Color c)
{
for (int ox = -r; ox <= r; ox++)
for (int oy = -r; oy <= r; oy++)
@ -822,7 +822,7 @@ namespace IslaApocalypse.Tools
}
}
private static void Ring(Image img, int cx, int cy, int r, int n, Color c, int w)
internal static void Ring(Image img, int cx, int cy, int r, int n, Color c, int w)
{
for (int ox = -r; ox <= r; ox++)
for (int oy = -r; oy <= r; oy++)
@ -834,7 +834,7 @@ namespace IslaApocalypse.Tools
}
}
private static void Square(Image img, int cx, int cy, int r, int n, Color c)
internal static void Square(Image img, int cx, int cy, int r, int n, Color c)
{
for (int ox = -r; ox <= r; ox++)
for (int oy = -r; oy <= r; oy++)