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
235 lines
8.6 KiB
C#
235 lines
8.6 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using IslaApocalypse.Core;
|
|
|
|
namespace IslaApocalypse.Tools
|
|
{
|
|
/// <summary>One connected island of tagged offshore land, as the analysis sees it.</summary>
|
|
public sealed class IslandComponent
|
|
{
|
|
public int Id;
|
|
public long Cells;
|
|
public double CentroidX, CentroidY;
|
|
public int MinX, MinY, MaxX, MaxY;
|
|
|
|
/// <summary>Hemisphere by CENTROID (an island straddling the midline is counted once, where its mass is).</summary>
|
|
public byte Hemisphere;
|
|
|
|
/// <summary>
|
|
/// ⚠ True if any cell of this island is 8-adjacent to land that is NOT tagged offshore —
|
|
/// i.e. the island touches the mainland. The moat exists to make this impossible; this is
|
|
/// the check that it did.
|
|
/// </summary>
|
|
public bool BridgedToMainland;
|
|
}
|
|
|
|
/// <summary>
|
|
/// ⭐ THE OFFSHORE ANALYSIS — counts islands, reads their hemisphere, and catches a land bridge.
|
|
/// Engine-free; used by the pass (to prove its own floor) and by the oracle (to prove it again,
|
|
/// independently, on the finished field).
|
|
///
|
|
/// ═══ THE HEMISPHERE CONVENTION — read from the code, not invented ═══
|
|
///
|
|
/// Pass 1's latitude scalar is <c>y / MapSize</c> (+ a ±0.1 wobble). The spine fades out where
|
|
/// that scalar exceeds 0.65 — "the southern fade" — and the "southern sinker" bites in the
|
|
/// BOTTOM 25 % of rows. So in this codebase, and in the lore it encodes (snow-town north,
|
|
/// shipwreck south): <b>y increases SOUTHWARD. North is the top half of the image.</b>
|
|
///
|
|
/// NORTH y ∈ [0, MapSize/2)
|
|
/// SOUTH y ∈ [MapSize/2, MapSize)
|
|
///
|
|
/// ⚠ The tag uses the clean row midline, NOT the wobbled latitude field. A hemisphere tag keyed
|
|
/// to a field that wanders ±10 % of the map would put the same island in different hemispheres
|
|
/// on different seeds for no geographic reason. The field's ORIENTATION is what is borrowed; its
|
|
/// wobble is not.
|
|
/// </summary>
|
|
public static class OffshoreAnalysis
|
|
{
|
|
// The convention now lives in Core (RegionLabeling, chat2/07) — one definition; these are aliases.
|
|
public const byte HemiNone = RegionLabeling.HemiNone;
|
|
public const byte HemiNorth = RegionLabeling.HemiNorth;
|
|
public const byte HemiSouth = RegionLabeling.HemiSouth;
|
|
|
|
/// <summary>→ <see cref="RegionLabeling.HemisphereOfRow"/>.</summary>
|
|
public static byte HemisphereOfRow(int y, int mapSize) => RegionLabeling.HemisphereOfRow(y, mapSize);
|
|
|
|
public static string HemisphereName(byte h) => RegionLabeling.HemisphereName(h);
|
|
|
|
// 8-connectivity, fixed order.
|
|
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 the 8-connected components of tagged offshore land, and for each, whether it
|
|
/// touches untagged land (a bridge). <paramref name="height"/> + <paramref name="sea"/>
|
|
/// define "land"; <paramref name="tag"/> defines "offshore". Both are needed: the bridge test
|
|
/// is "tagged cell next to a land cell that is not tagged".
|
|
/// </summary>
|
|
public static List<IslandComponent> Components(bool[,] tag, float[,] height, float sea, int mapSize)
|
|
=> Components(tag, height, sea, mapSize, out _);
|
|
|
|
/// <summary>
|
|
/// As above, also returning the per-cell component id map (<c>x * mapSize + y</c>; 0 = not
|
|
/// tagged) — the debris guard needs membership, not just the list.
|
|
/// </summary>
|
|
public static List<IslandComponent> Components(bool[,] tag, float[,] height, float sea, int mapSize,
|
|
out int[] idMap)
|
|
{
|
|
var comps = new List<IslandComponent>();
|
|
int n = mapSize;
|
|
var id = new int[n * n]; // 0 = unvisited / not tagged
|
|
idMap = id;
|
|
if (tag == null) return comps;
|
|
|
|
var stack = new Stack<int>();
|
|
int next = 0;
|
|
|
|
for (int sx = 0; sx < n; sx++)
|
|
{
|
|
for (int sy = 0; sy < n; sy++)
|
|
{
|
|
if (!tag[sx, sy] || id[sx * n + sy] != 0) continue;
|
|
|
|
var c = new IslandComponent
|
|
{
|
|
Id = ++next, MinX = sx, MaxX = sx, MinY = sy, MaxY = sy,
|
|
};
|
|
double sumX = 0, sumY = 0;
|
|
|
|
id[sx * n + sy] = c.Id;
|
|
stack.Push(sx * n + sy);
|
|
|
|
while (stack.Count > 0)
|
|
{
|
|
int cur = stack.Pop();
|
|
int cx = cur / n, cy = cur % n;
|
|
c.Cells++; sumX += cx; sumY += cy;
|
|
if (cx < c.MinX) c.MinX = cx; if (cx > c.MaxX) c.MaxX = cx;
|
|
if (cy < c.MinY) c.MinY = cy; if (cy > c.MaxY) c.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;
|
|
|
|
if (tag[nx, ny])
|
|
{
|
|
int ni = nx * n + ny;
|
|
if (id[ni] != 0) continue;
|
|
id[ni] = c.Id;
|
|
stack.Push(ni);
|
|
}
|
|
else if (height[nx, ny] >= sea)
|
|
{
|
|
// Land, not tagged offshore ⇒ mainland (or a lake-shore) touching
|
|
// this island. The moat should have made this impossible.
|
|
c.BridgedToMainland = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
c.CentroidX = sumX / c.Cells;
|
|
c.CentroidY = sumY / c.Cells;
|
|
c.Hemisphere = HemisphereOfRow((int)Math.Round(c.CentroidY), mapSize);
|
|
comps.Add(c);
|
|
}
|
|
}
|
|
return comps;
|
|
}
|
|
|
|
/// <summary>Island counts per hemisphere, by component centroid.</summary>
|
|
public static (int north, int south) CountByHemisphere(List<IslandComponent> comps)
|
|
{
|
|
int nN = 0, nS = 0;
|
|
foreach (var c in comps)
|
|
{
|
|
if (c.Hemisphere == HemiNorth) nN++;
|
|
else if (c.Hemisphere == HemiSouth) nS++;
|
|
}
|
|
return (nN, nS);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Island SIZE statistics — the thing a count alone hides. 189 islands averaging 66 cells is
|
|
/// noise debris, not an archipelago; 12 islands averaging 900 cells is what the developer
|
|
/// asked for. Cells are map cells (1 column = 1 m at the target scale).
|
|
/// </summary>
|
|
public static (long min, long median, double mean, long max, int belowThreshold)
|
|
SizeSummary(List<IslandComponent> comps, long threshold)
|
|
{
|
|
if (comps.Count == 0) return (0, 0, 0.0, 0, 0);
|
|
var sizes = new List<long>(comps.Count);
|
|
double sum = 0; int below = 0;
|
|
foreach (var c in comps) { sizes.Add(c.Cells); sum += c.Cells; if (c.Cells < threshold) below++; }
|
|
sizes.Sort();
|
|
return (sizes[0], sizes[sizes.Count / 2], sum / sizes.Count, sizes[sizes.Count - 1], below);
|
|
}
|
|
|
|
/// <summary>How many components touch the mainland. Zero is the only acceptable answer.</summary>
|
|
public static int BridgedCount(List<IslandComponent> comps)
|
|
{
|
|
int b = 0;
|
|
foreach (var c in comps) if (c.BridgedToMainland) b++;
|
|
return b;
|
|
}
|
|
|
|
// ═══ chat2/06 — the separation guard's geometry ═══
|
|
|
|
/// <summary>
|
|
/// Per component id, its BOUNDARY cells — tagged cells with at least one 8-neighbour that is
|
|
/// not tagged (or the map edge). The nearest approach between two islands is between
|
|
/// boundary cells, so the separation guard compares boundaries, not bodies: a few hundred
|
|
/// cells per island instead of thousands. <paramref name="surfaced"/> is the pass's list of
|
|
/// every surfaced cell (the bodies), walked once.
|
|
/// </summary>
|
|
public static Dictionary<int, List<(int x, int y)>> BoundaryCells(bool[,] tag, int[] compId, int mapSize,
|
|
IEnumerable<(int x, int y, float h0)> surfaced)
|
|
{
|
|
var result = new Dictionary<int, List<(int x, int y)>>();
|
|
foreach (var (x, y, _) in surfaced)
|
|
{
|
|
if (!tag[x, y]) continue;
|
|
int id = compId[x * mapSize + y];
|
|
if (id == 0) continue;
|
|
bool edge = false;
|
|
for (int k = 0; k < 8 && !edge; k++)
|
|
{
|
|
int nx = x + DX[k], ny = y + DY[k];
|
|
if (nx < 0 || nx >= mapSize || ny < 0 || ny >= mapSize || !tag[nx, ny]) edge = true;
|
|
}
|
|
if (!edge) continue;
|
|
if (!result.TryGetValue(id, out var list)) result[id] = list = new List<(int, int)>();
|
|
list.Add((x, y));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/// <summary>Chebyshev gap between two components' bounding boxes (0 if they overlap). A lower bound on their true distance.</summary>
|
|
public static int BoxGap(IslandComponent a, IslandComponent b)
|
|
{
|
|
int gx = Math.Max(0, Math.Max(a.MinX - b.MaxX, b.MinX - a.MaxX));
|
|
int gy = Math.Max(0, Math.Max(a.MinY - b.MaxY, b.MinY - a.MaxY));
|
|
return Math.Max(gx, gy);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The minimum Chebyshev distance between two boundary sets, early-exiting once it is
|
|
/// known to be below <paramref name="below"/> (the caller only needs "closer than the
|
|
/// minimum or not").
|
|
/// </summary>
|
|
public static int MinChebyshev(List<(int x, int y)> a, List<(int x, int y)> b, int below)
|
|
{
|
|
int best = int.MaxValue;
|
|
if (a == null || b == null) return best;
|
|
foreach (var (ax, ay) in a)
|
|
{
|
|
foreach (var (bx, by) in b)
|
|
{
|
|
int d = Math.Max(Math.Abs(ax - bx), Math.Abs(ay - by));
|
|
if (d < best) { best = d; if (best < below) return best; }
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
}
|
|
}
|