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.
339 lines
14 KiB
C#
339 lines
14 KiB
C#
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);
|
||
}
|
||
}
|
||
}
|