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
478 lines
23 KiB
C#
478 lines
23 KiB
C#
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 < 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 < 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 >= 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 > 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 > 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;
|
||
}
|
||
}
|
||
}
|