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
203 lines
10 KiB
C#
203 lines
10 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using IslaApocalypse.Core;
|
||
|
||
namespace IslaApocalypse.Tools
|
||
{
|
||
/// <summary>The region pass's numbers, carried on <c>Pass1Result</c> for the report.</summary>
|
||
public sealed class RegionLedger
|
||
{
|
||
public bool RevertOn;
|
||
public long ThresholdCells;
|
||
public int PreIslands, PreNorth, PreSouth; // before the speck revert
|
||
public int PostIslands, PostNorth, PostSouth; // after
|
||
public int RevertedComponents; public long RevertedCells;
|
||
public bool CentreWasLand = true, CentreWasLandPre = true;
|
||
public long PreMin, PreMedian, PreMax, PostMin, PostMedian, PostMax; public double PreMean, PostMean;
|
||
public int[] PreHistogram, PostHistogram;
|
||
public List<(int id, long cells, byte hemi, float newHeight)> Reverted = new();
|
||
}
|
||
|
||
/// <summary>
|
||
/// ⭐ PASS 1c — REGION LABELING + THE SPECK REVERT + THE ISLAND TAG (chat2/07). Runs over the
|
||
/// finished pass-1/1b classify field, IN PLACE, after the shelf/islets and before <c>HMaxSeed</c>
|
||
/// is retaken and anything classifies.
|
||
///
|
||
/// ═══ WHAT IT DOES, IN ORDER ═══
|
||
///
|
||
/// 1. LABEL <see cref="RegionLabeling.Label"/> over the classify field — the general layer.
|
||
/// Pure analysis: no height changes. (The pre-revert table is kept for the instrument.)
|
||
/// 2. REVERT (config-gated: <c>TerrainGenConfig.SpeckRevert</c>; threshold
|
||
/// <c>MinLandComponentFrac</c> × map area) — every NON-MAINLAND component below the
|
||
/// threshold is lowered to seabed. ORIGIN-BLIND: it judges components by size, not by
|
||
/// who made them — a small natural nub goes the same way as an offshore-pass dot.
|
||
/// Fewer, bigger. Two guards, ASSERTED per component, hard failure on violation:
|
||
/// LOWER-ONLY — every touched cell goes DOWN (land → below sea), never up;
|
||
/// COMPONENT-ONLY — only cells of the sub-threshold component are touched, never a
|
||
/// neighbour (the submerged skirt an offshore island leaves behind
|
||
/// is NOT this component and stays — a shoal, by the rule).
|
||
/// Together they make it impossible for "revert" to move the mainland coast.
|
||
/// MAINLAND IS NEVER A CANDIDATE (asserted), however small a pathological seed made it.
|
||
/// The seabed a cell is lowered to is the MEAN height of the component's adjacent sea
|
||
/// cells (its ring), held strictly below sea by BitDecrement — a flat shoal at the
|
||
/// local depth, not a pit and not a reef.
|
||
/// 3. RELABEL after a revert the layer is run again, so the exposed table and ids are those of
|
||
/// the finished field.
|
||
/// 4. TAG BY CONSTRUCTION: every cell of every non-mainland component is an island cell,
|
||
/// hemisphere from its component's centroid. The big organic detached masses are
|
||
/// tagged the same as an offshore-pass dot. Nothing here knows which pass made a cell.
|
||
///
|
||
/// The classify field IS the pass-1 array; pass 2 derives the render field from it and the curve is
|
||
/// identity at and below sea, so a reverted cell is seabed in both — asserted downstream by oracle (k).
|
||
/// </summary>
|
||
public static class RegionPass
|
||
{
|
||
public sealed class Result
|
||
{
|
||
public RegionLabels LabelsPre; // before the revert (== Labels when the revert is off or reverted nothing)
|
||
public RegionLabels Labels; // the finished field's labeling
|
||
public bool[,] IsIsland; // the tag, by construction
|
||
public byte[,] IslandHemisphere;
|
||
public RegionLedger Ledger = new();
|
||
public List<string> Notes = new();
|
||
}
|
||
|
||
/// <summary>The three thresholds of the chat2/07 batch, fractions of the map's area; Mid is the config default.</summary>
|
||
public const float ThresholdLowFrac = 1e-5f; // 168 cells at 4096 — only the smallest natural specks
|
||
public const float ThresholdMidFrac = 3e-5f; // 503 cells at 4096 — the offshore pass's own speck guard, applied to all land
|
||
public const float ThresholdHighFrac = 1e-4f; // 1,678 cells at 4096 — "fewer, bigger": takes small offshore islands too
|
||
|
||
public static Result Apply(float[,] height, int mapSize, float sea, TerrainGenConfig cfg)
|
||
{
|
||
var r = new Result();
|
||
var pre = RegionLabeling.Label(height, mapSize, sea);
|
||
r.LabelsPre = pre;
|
||
var led = r.Ledger;
|
||
led.CentreWasLandPre = pre.CentreWasLand;
|
||
FillPre(led, pre);
|
||
r.Notes.Add($"[Regions] labeled {pre.Regions.Count} land components ({pre.LandCells:N0} land cells): mainland id {pre.MainlandId} " +
|
||
$"({(pre.Mainland == null ? 0 : pre.Mainland.SizeCells):N0} cells, centre {(pre.CentreWasLand ? "is land" : "⚠ NOT LAND — fell back to the largest component")}), " +
|
||
$"{pre.IslandCount} islands (N {led.PreNorth} / S {led.PreSouth}); island cells min {led.PreMin} median {led.PreMedian} mean {led.PreMean:F0} max {led.PreMax}.");
|
||
|
||
RegionLabels final = pre;
|
||
led.RevertOn = cfg.SpeckRevert;
|
||
if (cfg.SpeckRevert)
|
||
{
|
||
long threshold = Math.Max(1L, (long)Math.Round(cfg.MinLandComponentFrac * (double)mapSize * mapSize));
|
||
led.ThresholdCells = threshold;
|
||
float strictlyBelowSea = MathF.BitDecrement(sea);
|
||
int n = mapSize;
|
||
|
||
// Which components go: non-mainland, below the threshold. Mainland is never a candidate.
|
||
var revert = new Dictionary<int, LandRegion>();
|
||
foreach (var c in pre.Regions)
|
||
if (!c.IsMainland && c.SizeCells < threshold) revert[c.Id] = c;
|
||
if (pre.Mainland != null && revert.ContainsKey(pre.MainlandId))
|
||
throw new InvalidOperationException("[RegionPass] the mainland was selected for revert. Refusing.");
|
||
|
||
if (revert.Count > 0)
|
||
{
|
||
// The ring: the mean height of each doomed component's adjacent SEA cells.
|
||
var ringSum = new Dictionary<int, double>();
|
||
var ringCnt = new Dictionary<int, long>();
|
||
foreach (int id in revert.Keys) { ringSum[id] = 0; ringCnt[id] = 0; }
|
||
for (int x = 0; x < n; x++)
|
||
{
|
||
for (int y = 0; y < n; y++)
|
||
{
|
||
int id = pre.Id[x * n + y];
|
||
if (id == 0 || !revert.ContainsKey(id)) continue;
|
||
for (int dx = -1; dx <= 1; dx++)
|
||
{
|
||
int nx = x + dx; if (nx < 0 || nx >= n) continue;
|
||
for (int dy = -1; dy <= 1; dy++)
|
||
{
|
||
int ny = y + dy; if (ny < 0 || ny >= n || (dx == 0 && dy == 0)) continue;
|
||
if (pre.Id[nx * n + ny] != 0) continue; // land (this or another component)
|
||
ringSum[id] += height[nx, ny]; ringCnt[id]++;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
var target = new Dictionary<int, float>();
|
||
foreach (var (id, c) in revert)
|
||
{
|
||
float t = ringCnt[id] > 0 ? (float)(ringSum[id] / ringCnt[id]) : strictlyBelowSea;
|
||
target[id] = MathF.Min(t, strictlyBelowSea); // strictly below sea, whatever the ring says
|
||
}
|
||
|
||
// The revert, with both guards asserted cell by cell.
|
||
var touched = new Dictionary<int, long>();
|
||
foreach (int id in revert.Keys) touched[id] = 0;
|
||
long cells = 0;
|
||
for (int x = 0; x < n; x++)
|
||
{
|
||
for (int y = 0; y < n; y++)
|
||
{
|
||
int id = pre.Id[x * n + y];
|
||
if (id == 0 || !target.TryGetValue(id, out float t)) continue; // COMPONENT-ONLY: nothing else is ever touched
|
||
float h = height[x, y];
|
||
if (h < sea)
|
||
throw new InvalidOperationException($"[RegionPass] component {id} cell ({x},{y}) is not land (h {h:G9} < sea {sea:G9}) — the labeling and the field disagree. Refusing.");
|
||
if (t >= h)
|
||
throw new InvalidOperationException($"[RegionPass] LOWER-ONLY violated at ({x},{y}): {h:G9} → {t:G9}. Refusing.");
|
||
height[x, y] = t;
|
||
touched[id]++; cells++;
|
||
}
|
||
}
|
||
foreach (var (id, c) in revert)
|
||
{
|
||
if (touched[id] != c.SizeCells)
|
||
throw new InvalidOperationException($"[RegionPass] COMPONENT-ONLY violated: component {id} has {c.SizeCells} cells, {touched[id]} touched. Refusing.");
|
||
led.Reverted.Add((id, c.SizeCells, c.Hemisphere, target[id]));
|
||
}
|
||
led.RevertedComponents = revert.Count;
|
||
led.RevertedCells = cells;
|
||
|
||
final = RegionLabeling.Label(height, mapSize, sea);
|
||
if (final.Mainland == null || pre.Mainland == null || final.Mainland.SizeCells != pre.Mainland.SizeCells)
|
||
throw new InvalidOperationException("[RegionPass] the mainland's size changed across the revert. Refusing.");
|
||
}
|
||
r.Notes.Add($"[Regions] speck revert ON (threshold {threshold:N0} cells = {cfg.MinLandComponentFrac:G2} of the map): " +
|
||
$"{led.RevertedComponents} sub-threshold non-mainland components ({led.RevertedCells:N0} cells) lowered to their ring's mean seabed; " +
|
||
$"lower-only and component-only asserted; mainland untouched by definition.");
|
||
}
|
||
else r.Notes.Add("[Regions] speck revert OFF.");
|
||
|
||
r.Labels = final;
|
||
led.CentreWasLand = final.CentreWasLand;
|
||
FillPost(led, final);
|
||
|
||
// ═══ THE TAG, BY CONSTRUCTION ═══
|
||
var tag = new bool[mapSize, mapSize];
|
||
var hemi = new byte[mapSize, mapSize];
|
||
for (int x = 0; x < mapSize; x++)
|
||
for (int y = 0; y < mapSize; y++)
|
||
{
|
||
int id = final.Id[x * mapSize + y];
|
||
if (id == 0 || id == final.MainlandId) continue;
|
||
tag[x, y] = true;
|
||
hemi[x, y] = final.Regions[id - 1].Hemisphere;
|
||
}
|
||
r.IsIsland = tag; r.IslandHemisphere = hemi;
|
||
r.Notes.Add($"[Regions] tag by construction: {final.IslandCount} islands (N {led.PostNorth} / S {led.PostSouth}), " +
|
||
$"{final.LandCells - (final.Mainland?.SizeCells ?? 0):N0} island cells tagged; island cells min {led.PostMin} median {led.PostMedian} mean {led.PostMean:F0} max {led.PostMax}.");
|
||
return r;
|
||
}
|
||
|
||
private static void FillPre(RegionLedger l, RegionLabels lab)
|
||
{
|
||
(l.PreNorth, l.PreSouth) = RegionLabeling.IslandsByHemisphere(lab);
|
||
var (c, mn, med, mean, mx, h) = RegionLabeling.IslandSizes(lab);
|
||
l.PreIslands = c; l.PreMin = mn; l.PreMedian = med; l.PreMean = mean; l.PreMax = mx; l.PreHistogram = h;
|
||
}
|
||
|
||
private static void FillPost(RegionLedger l, RegionLabels lab)
|
||
{
|
||
(l.PostNorth, l.PostSouth) = RegionLabeling.IslandsByHemisphere(lab);
|
||
var (c, mn, med, mean, mx, h) = RegionLabeling.IslandSizes(lab);
|
||
l.PostIslands = c; l.PostMin = mn; l.PostMedian = med; l.PostMean = mean; l.PostMax = mx; l.PostHistogram = h;
|
||
}
|
||
}
|
||
}
|