Core/Scripts/RegionLabeling.cs is the shared-infra contract, built to the letter: it runs on the CLASSIFY (raw) field; land is 8-connected, the deliberate complement of water's 4 (a diagonal isthmus joins; the water either side stays separate); a component is a maximal 8-connected set of land cells; the MAINLAND is the component containing the map centre — not merely the largest, which a later fragmentation step could flip — with a flagged fallback to the largest if the centre were ever water (asserted, never needed: oracle m); every other component is an island; per component id / sizeCells / centroid / hemisphere (by centroid, one label per island) / isMainland. Ids come from a fixed scan order and are proven stable across two generations (oracle o, 16.8M cells). It knows nothing about offshore or stamped. Engine-free, in Core as C++-candidate math; the hemisphere convention moved there with it, OffshoreAnalysis aliases it. Tools/Scripts/RegionPass.cs is pass 1c: label, revert, relabel, tag. The island tag (renamed IsIsland; IslandHemisphere from the component's centroid; Pass1Result.Regions carries the whole table) is now a CONSEQUENCE of labeling — every non-mainland component. That is the fix for the chat2/06 overlay, which tagged only what the offshore pass raised: 1063685222 has 11 natural islands including a 94,511-cell detached mass, 20260821 has 19, all grey in 06's tags.png and all coloured now. The offshore pass itself is untouched; its internal Tag stays for its own guards and is no longer exported. The speck revert (TerrainGenConfig.SpeckRevert / MinLandComponentFrac) lowers every non-mainland component below the threshold to the mean of its ring of adjacent sea cells, held strictly below sea. Origin-blind: a natural nub goes the same way as an offshore dot (6 natural components / 273 cells on the bare 1063685222 field at threshold_mid — reported as a3r, informational). Lower-only and component-only are asserted cell by cell in the pass and re-proven on the finished fields by oracle n (mainland bit-identical filter OFF vs ON; every changed cell in a sub-threshold island, lowered below sea); the mainland is never a candidate and its size is asserted unchanged across the revert. A reverted offshore island leaves its submerged skirt as a shoal — not this component, by the rule. Classify/render consistency is by construction (pass 1, curve identity at sea) and asserted by oracle k. Deliberately OFF in the bare TerrainGenConfig for the reason the shelf and islets are: the raw field has natural specks, so default-ON would move the calibration pool and every regression dump; the batch turns it on. Thresholds swept on 8 seeds at 4096 (1e-5 / 3e-5 / 1e-4 of the map = 168 / 503 / 1,678 cells): low removes 0–6 nubs per seed, mid (the config default, equal to the offshore guard) 2–11, high 26–36 — most of the offshore islands, the "fewer, bigger" bookend. The count/size table carries natural / pre / post counts per hemisphere, min/median/mean/max and a log-spaced size histogram — the instrument for the southern-stretch step. Oracle, all passing: a1, a3, a4 (8192, 67M cells) with labeling ON + revert OFF; a6 NEW — labeling ON + revert OFF on the 06 preset bit-identical to the 06 batch's render field (labeling is pure analysis); j0; m, n, o, i, j, k, l, b per field. Batch: BatchRoot(7, "region_labeling") — exactly 4 plates (three thresholds on 1063685222, threshold_mid on 20260821, the table's most-natural-islands seed), each with grayscale / .f32 / relief / the labeled-regions overlay / the tag overlay, plus count_size_table.md/.csv. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013EY3ZTF6NwzF8ukBHQXSK7
229 lines
9.7 KiB
C#
229 lines
9.7 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>
|
||
/// 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]}";
|
||
|
||
/// <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);
|
||
}
|
||
}
|
||
}
|