islaApocalypse-v2/Core/Scripts/RegionLabeling.cs
beezm 89e0f85c9f chat2/12: drainage analysis (minimal-first) — the reference river plan ported, analysis only, on the eroded terrain
Core/Scripts/DrainageAnalysis.cs is the reference's DrainageAnalysis ported verbatim: the
Barnes priority-flood routing fill (8-connected, seeded from the four borders, index
tiebreak, pit fills ONE ULP above the parent so every filled cell keeps a strictly
descending path to its spill; the terrain heightmap itself is never written - the fill lives
in its own array), terminal-basin qualification (depth >= 2 m AND area >= 10,000 px; the rest
are pits filled through), D8 flow directions on the routing surface - FOR ANALYSIS ONLY, the
reverted-as-carving landmine stated in the file - Kahn accumulation, the memoised destination
walk crediting a terminal basin with TOTAL inflow, sea-reaching outlets ranked by drainage
area with the outlet separation, main stems by max accumulation, mountain exits from the
along-stem grade, lean tributaries, lean endorheic terminals, and the promoted giants
(provisional routes computed as the reference did, not drawn - routing is a later task).
WorldScale-denominated; Dir / Acc / Filled / FullFilled / BasinId / BasinInflow exposed so the
caller can prove the invariants.

"The sea" is the OCEAN body from the region layer: RegionLabeling.OceanMask - the 4-connected
water component touching the border, on the CLASSIFY field (the water-side complement of the
land contract). Enclosed lagoons, lake beds and island-fringe pockets are ordinary terrain to
the router.

DrainageTool (4 task-11 seeds at 8192, the eroded fields bit-identical to the 11 dumps)
renders the log-scaled accumulation map and the promoted-candidates overlay (trunks cyan,
endorheic giants orange, lean terminals red; nothing carved) and writes the accumulation
.f32. Oracle, all passing: render AND classify fields bit-identical before/after the analysis
(zero terrain cells written), no water added, full fill >= original everywhere with a
non-ascending path to the border from every cell, ocean mask all below sea and on the border,
dir/acc/candidates identical across two runs.

Endorheic basins are the expected first-class output: on every seed the top giants out-drain
the top trunks - the biggest drainages pool inland because erosion delivers the upland network
only and cannot cross the flats.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EY3ZTF6NwzF8ukBHQXSK7
2026-08-22 20:51:51 -04:00

273 lines
12 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
namespace IslaApocalypse.Core
{
/// <summary>One maximal 8-connected component of land, as the region layer exposes it.</summary>
public sealed class LandRegion
{
/// <summary>1-based, assigned in deterministic scan order (x outer, y inner) — stable per seed across runs.</summary>
public int Id;
/// <summary>Cells in the component.</summary>
public long SizeCells;
/// <summary>Centroid in map cells.</summary>
public double CentroidX, CentroidY;
/// <summary>
/// <see cref="RegionLabeling.HemiNorth"/> / <see cref="RegionLabeling.HemiSouth"/>, decided by the
/// CENTROID — one label per component; a straddler is decided by where its mass is, never per cell.
/// </summary>
public byte Hemisphere;
/// <summary>True for exactly one component: the one containing the map centre (or the flagged fallback).</summary>
public bool IsMainland;
/// <summary>Bounding box, inclusive. Convenience for overlays and guards; not part of the contract.</summary>
public int MinX, MinY, MaxX, MaxY;
}
/// <summary>The result of one labeling: the per-cell id map and the per-component table.</summary>
public sealed class RegionLabels
{
public int MapSize;
/// <summary>Per cell, <c>x * MapSize + y</c>: the component id, or 0 for water.</summary>
public int[] Id;
/// <summary>Every component, indexed by <c>Id - 1</c>, in id order.</summary>
public List<LandRegion> Regions;
/// <summary>The mainland's id (0 only if there is no land at all).</summary>
public int MainlandId;
/// <summary>
/// ⚠ Whether the map-centre cell was land. Expected always true (the massif is centred and
/// stable). When false the mainland fell back to the LARGEST component and the caller must
/// report it loudly — the contract's mainland definition did not hold on this field.
/// </summary>
public bool CentreWasLand;
public long LandCells;
public int IslandCount => Regions.Count - (MainlandId > 0 ? 1 : 0);
public LandRegion Mainland => MainlandId > 0 ? Regions[MainlandId - 1] : null;
public LandRegion Of(int id) => Regions[id - 1];
public int IdAt(int x, int y) => Id[x * MapSize + y];
}
/// <summary>
/// ⭐⭐ THE REGION-LABELING LAYER — shared infrastructure (chat2/07). Flood-fills land into distinct
/// components, identifies mainland vs islands, and exposes per-component data. Islands are its first
/// consumer; later phases (biomes, placement, rivers, the crater) CONSUME this layer rather than
/// rebuild it. Engine-free; pure analysis over a <c>float[,]</c>; C++-candidate.
///
/// ═══ THE CONTRACT — build to it exactly (recorded at graduation as the shared-infra contract) ═══
///
/// FIELD It runs on the CLASSIFY (raw, uncurved) height — region identity partitions on the
/// same authoritative field as water bodies and biome regions (D-046), so islands /
/// water / biomes line up by construction. Raw is authoritative for region identity.
/// It does NOT run on the render field.
///
/// CONNECTIVITY Land is 8-CONNECTED. Deliberately the complement of water's 4-connectivity —
/// foreground/background using opposite connectivity is the topologically sound
/// pairing (a diagonal isthmus reads as JOINED; the water on either side of it reads
/// as SEPARATE), not a conflict with the water model.
///
/// COMPONENT A component = a maximal 8-connected set of land cells (land = classify height ≥ sea).
///
/// MAINLAND The component containing the MAP CENTRE (the mountain/massif is always centred and
/// stable) — NOT merely the largest component, because a later fragmentation step
/// could make "largest" flip seed to seed. Every OTHER land component is an island.
/// ⚠ The crater is NOT central — it is a northern-coastline feature, unrelated to the
/// centre or the mountain, and plays no part here.
/// Defensively: if the centre cell is not land, the layer reports it (<see
/// cref="RegionLabels.CentreWasLand"/> = false) and falls back to the largest
/// component, FLAGGED — the caller asserts rather than assumes.
///
/// PER COMPONENT id · sizeCells · centroid (x, y) · hemisphere (north / south, BY THE CENTROID —
/// one label per island; a straddler is decided by its centroid, never per cell) ·
/// isMainland.
///
/// Ids are assigned in deterministic scan order (x outer, y inner, first-seen), so they are stable
/// per seed across runs. Nothing here knows about "offshore" or "stamped" — it labels land.
///
/// ═══ THE HEMISPHERE CONVENTION — read from the code, not invented (chat2/05) ═══
///
/// Pass 1's latitude scalar is <c>y / MapSize</c>; the spine's "southern fade" and the "southern
/// sinker" bite at high y. So y increases SOUTHWARD: NORTH = rows [0, MapSize/2), SOUTH = rows
/// [MapSize/2, MapSize). The clean row midline, never the wobbled latitude field.
/// </summary>
public static class RegionLabeling
{
public const byte HemiNone = 0;
public const byte HemiNorth = 1;
public const byte HemiSouth = 2;
/// <summary>The convention, in one place. Every consumer reads hemisphere through this.</summary>
public static byte HemisphereOfRow(int y, int mapSize) => y < mapSize / 2 ? HemiNorth : HemiSouth;
public static string HemisphereName(byte h) => h switch
{
HemiNorth => "north", HemiSouth => "south", _ => "none",
};
// 8-connectivity, fixed order (determinism: the fill order never changes).
private static readonly int[] DX = { -1, -1, -1, 0, 0, 1, 1, 1 };
private static readonly int[] DY = { -1, 0, 1, -1, 1, -1, 0, 1 };
/// <summary>
/// Label every 8-connected land component of <paramref name="classify"/> (land = height ≥
/// <paramref name="sea"/>). Pure: the field is read, never written.
/// </summary>
public static RegionLabels Label(float[,] classify, int mapSize, float sea)
{
int n = mapSize;
var id = new int[n * n];
var regions = new List<LandRegion>();
var stack = new Stack<int>();
long landCells = 0;
for (int sx = 0; sx < n; sx++)
{
for (int sy = 0; sy < n; sy++)
{
if (classify[sx, sy] < sea || id[sx * n + sy] != 0) continue;
var r = new LandRegion { Id = regions.Count + 1, MinX = sx, MaxX = sx, MinY = sy, MaxY = sy };
double sumX = 0, sumY = 0;
id[sx * n + sy] = r.Id;
stack.Push(sx * n + sy);
while (stack.Count > 0)
{
int cur = stack.Pop();
int cx = cur / n, cy = cur % n;
r.SizeCells++; sumX += cx; sumY += cy;
if (cx < r.MinX) r.MinX = cx; if (cx > r.MaxX) r.MaxX = cx;
if (cy < r.MinY) r.MinY = cy; if (cy > r.MaxY) r.MaxY = cy;
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 (id[ni] != 0 || classify[nx, ny] < sea) continue;
id[ni] = r.Id;
stack.Push(ni);
}
}
r.CentroidX = sumX / r.SizeCells;
r.CentroidY = sumY / r.SizeCells;
r.Hemisphere = HemisphereOfRow((int)Math.Round(r.CentroidY), n);
landCells += r.SizeCells;
regions.Add(r);
}
}
var labels = new RegionLabels { MapSize = n, Id = id, Regions = regions, LandCells = landCells };
// ⭐ MAINLAND = the component containing the map centre. Asserted by the caller; the
// fallback (largest) exists so a run can finish and REPORT the violation rather than crash.
int centre = (n / 2) * n + (n / 2);
labels.CentreWasLand = id[centre] != 0;
if (labels.CentreWasLand) labels.MainlandId = id[centre];
else
{
long best = -1;
foreach (var r in regions) if (r.SizeCells > best) { best = r.SizeCells; labels.MainlandId = r.Id; }
}
if (labels.MainlandId > 0) regions[labels.MainlandId - 1].IsMainland = true;
return labels;
}
/// <summary>
/// Size statistics over the islands (non-mainland components): count, min / median / mean /
/// max cells, and a log-spaced histogram — the instrument that turns "nice pieces vs shattered
/// gravel" into numbers.
/// </summary>
public static (int count, long min, long median, double mean, long max, int[] histogram)
IslandSizes(RegionLabels labels)
{
var sizes = new List<long>();
foreach (var r in labels.Regions) if (!r.IsMainland) sizes.Add(r.SizeCells);
var hist = new int[HistogramEdges.Length + 1];
if (sizes.Count == 0) return (0, 0, 0, 0.0, 0, hist);
sizes.Sort();
double sum = 0;
foreach (long s in sizes) { sum += s; hist[HistogramBin(s)]++; }
return (sizes.Count, sizes[0], sizes[sizes.Count / 2], sum / sizes.Count, sizes[sizes.Count - 1], hist);
}
/// <summary>Histogram bin edges (cells): [0,64) [64,256) [256,1024) [1024,4096) [4096,16384) [16384,∞).</summary>
public static readonly long[] HistogramEdges = { 64, 256, 1024, 4096, 16384 };
public static int HistogramBin(long cells)
{
for (int i = 0; i < HistogramEdges.Length; i++) if (cells < HistogramEdges[i]) return i;
return HistogramEdges.Length;
}
public static string HistogramLabel(int bin) => bin == 0 ? $"<{HistogramEdges[0]}"
: bin < HistogramEdges.Length ? $"{HistogramEdges[bin - 1]}{HistogramEdges[bin] - 1}"
: $"≥{HistogramEdges[^1]}";
// ═══ chat2/12 — THE OCEAN IDENTITY (the water-side complement of the land contract) ═══
//
// Water is 4-CONNECTED (the deliberate complement of land's 8 — a diagonal isthmus joins land and
// separates the water either side). THE OCEAN = the 4-connected water component that touches the
// map border (the Trench guarantees the border is water, so the corner is a safe seed). Every
// other below-sea cell — enclosed lagoons, lake beds, island-fringe pockets — is NOT ocean: to the
// drainage router it is ordinary terrain (a terminal basin or a fill-and-spill), and to a
// "sea-reaching" test it does not count as the sea. Computed on the CLASSIFY field (authoritative).
/// <summary>
/// The ocean mask, row-major (<c>x·n+y</c>): true for every below-sea cell 4-connected to the map
/// border. Pure: reads <paramref name="classify"/>, writes nothing.
/// </summary>
public static bool[] OceanMask(float[,] classify, int mapSize, float sea, out long oceanCells, out long enclosedWaterCells)
{
int n = mapSize;
var ocean = new bool[n * n];
var q = new Queue<int>();
void Seed(int x, int y) { if (classify[x, y] < sea && !ocean[x * n + y]) { ocean[x * n + y] = true; q.Enqueue(x * n + y); } }
for (int x = 0; x < n; x++) { Seed(x, 0); Seed(x, n - 1); }
for (int y = 0; y < n; y++) { Seed(0, y); Seed(n - 1, y); }
int[] dx4 = { -1, 1, 0, 0 }, dy4 = { 0, 0, -1, 1 };
while (q.Count > 0)
{
int c = q.Dequeue(); int cx = c / n, cy = c % n;
for (int k = 0; k < 4; k++)
{
int nx = cx + dx4[k], ny = cy + dy4[k];
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
int ni = nx * n + ny;
if (ocean[ni] || classify[nx, ny] >= sea) continue;
ocean[ni] = true; q.Enqueue(ni);
}
}
oceanCells = 0; enclosedWaterCells = 0;
for (int x = 0; x < n; x++)
for (int y = 0; y < n; y++)
{
if (classify[x, y] >= sea) continue;
if (ocean[x * n + y]) oceanCells++; else enclosedWaterCells++;
}
return ocean;
}
/// <summary>Island counts per hemisphere (non-mainland components, by centroid).</summary>
public static (int north, int south) IslandsByHemisphere(RegionLabels labels)
{
int nN = 0, nS = 0;
foreach (var r in labels.Regions)
{
if (r.IsMainland) continue;
if (r.Hemisphere == HemiNorth) nN++; else if (r.Hemisphere == HemiSouth) nS++;
}
return (nN, nS);
}
}
}