islaApocalypse-v2/Core/Scripts/RegionLabeling.cs
beezm 4e4be6a83e rivers/03: lowland routing — the routed MIX on the pure N=12, courses only
Ports the ROUTING PORTION of the reference's RiverCarvePass (RouteToOcean, the
routed/lake-ender sort, SmoothCourse). NOT CarveRiver (bed stamp) and NOT
AddSteppedWater (water bodies) — those are later tasks.

RED LINE: no height mutated, no water filled, nothing carved. Asserted per seed
by an FNV digest of both height fields before/after routing.

- RiverRouting: deterministic LOWGROUND Dijkstra, uphill penalised so a route may
  cross the basin rim, empty-list-on-no-path. Effective == declared constants
  (verified: private const, no ConfigManager key, no [Export] in the reference).
- The sort is the REFERENCE's — Kind = basinHasLake ? lake-ender : routed. The
  task's stated "a path exists -> routed" cannot discriminate: on an 8-connected
  grid a path to the ocean always exists, confirmed empirically (43/43 probes
  reached). The ocean route is probed for every giant anyway, so the missing
  affordability threshold is reported as a number rather than guessed.
- RegionLabeling.SignificantWaterMask: interim substitute for v2's missing
  water-bodies table — 8-connected classify-water components >= 20,000 px.
- RiverCandidates: the candidate enumeration extracted out of RiverPromotionTool
  so routing ranks the identical set the count gate was judged on. Behaviour
  neutral — rivers/02b's twelve plates are byte-identical across the extraction.
- DrainageRenderer.RoutedMix: three classes, with each routed river's added
  lowland reach and the rim it crossed drawn distinctly from its natural stem.

Taste gate: no count, no K, no style, no default set.
2026-08-24 04:54:30 -04:00

339 lines
14 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>
/// ⭐⭐ SIGNIFICANT WATER (rivers/03) — the interim substitute for the reference's water-bodies
/// table, built with this layer's own connected-component machinery.
///
/// ═══ WHY THIS EXISTS ═══
///
/// The reference builds `isSignificantWater` from `_waterBodies` — cells of any body with
/// `PixelCount >= RiverLakeMinTargetPx` — and a lake-ender routes to THAT rather than to any wet
/// pixel. **v2 has no water-bodies table yet** (a known port gap, `00_ground` §D3 /
/// carry-forward §5), so this labels 8-connected components of classify water directly and keeps
/// the ones at least <paramref name="minPx"/> cells. Same semantics, same threshold, no table.
///
/// ⚠ The size filter is the whole point and it is not a detail: routing a lake-ender to the
/// NEAREST wet pixel put one into a three-cell puddle a few hundred px short of the obvious
/// lagoon — the reference's own task-23 gate finding. "Nearest water" is satisfied by a puddle.
///
/// ⚠ OCEAN IS EXCLUDED. A lake-ender that could reach the ocean is not a lake-ender; including
/// ocean here would let one "terminate" at the coast and quietly become a sea river without ever
/// passing the routed test.
///
/// Pure: reads the mask, writes nothing, creates no water. Same 8-connectivity and same fixed
/// neighbour order as <see cref="Label"/>, so component identity is deterministic.
/// </summary>
public static bool[] SignificantWaterMask(bool[] isClassifyWater, bool[] isOcean, int mapSize,
int minPx, out int bodiesKept, out int bodiesTotal, out long cellsKept, out long largestPx)
{
int n = mapSize;
var seen = new bool[n * n];
var mask = new bool[n * n];
var stack = new Stack<int>();
var component = new List<int>();
bodiesKept = 0; bodiesTotal = 0; cellsKept = 0; largestPx = 0;
for (int s = 0; s < n * n; s++)
{
if (seen[s] || !isClassifyWater[s] || isOcean[s]) continue;
component.Clear();
seen[s] = true;
stack.Push(s);
while (stack.Count > 0)
{
int cur = stack.Pop();
component.Add(cur);
int cx = cur / n, cy = cur % 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] || !isClassifyWater[ni] || isOcean[ni]) continue;
seen[ni] = true;
stack.Push(ni);
}
}
bodiesTotal++;
if (component.Count > largestPx) largestPx = component.Count;
if (component.Count >= minPx)
{
bodiesKept++;
cellsKept += component.Count;
foreach (int c in component) mask[c] = true;
}
}
return mask;
}
/// <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);
}
}
}