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.
This commit is contained in:
parent
559306ca73
commit
4e4be6a83e
8 changed files with 1577 additions and 209 deletions
|
|
@ -183,6 +183,72 @@ namespace IslaApocalypse.Core
|
|||
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
|
||||
|
|
|
|||
6
Tools/Scenes/RiverRoutingTool.tscn
Normal file
6
Tools/Scenes/RiverRoutingTool.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
[gd_scene format=3 uid="uid://riverrouting03"]
|
||||
|
||||
[ext_resource type="Script" path="res://Tools/Scripts/RiverRoutingTool.cs" id="1_rrt03"]
|
||||
|
||||
[node name="RiverRoutingTool" type="Node"]
|
||||
script = ExtResource("1_rrt03")
|
||||
|
|
@ -310,46 +310,11 @@ namespace IslaApocalypse.Tools
|
|||
}
|
||||
|
||||
int ls = n >= 4096 ? 4 : 3;
|
||||
int lineH = TinyFont.Height(ls);
|
||||
var placed = new List<Rect2I>();
|
||||
// Reserve the legend block so a river label never lands under the header text.
|
||||
placed.Add(new Rect2I(0, 0, n, 12 + (TinyFont.Height(ls) + 6) * 7));
|
||||
var placer = new LabelPlacer(n, ls, headerLines: 7);
|
||||
int dropped = 0;
|
||||
foreach (var c in toLabel)
|
||||
{
|
||||
string txt = $"{DrainageLabel(c.DrainagePx)} R{c.Rank}";
|
||||
int w = TinyFont.Width(txt, ls), h = lineH;
|
||||
int pad = 4 * (ls >= 4 ? 2 : 1);
|
||||
int gap = mark + 10;
|
||||
// right, left, below, above, then pushed further out — first clear slot wins.
|
||||
var tries = new (int x, int y)[]
|
||||
{
|
||||
(c.TermX + gap, c.TermY - h / 2),
|
||||
(c.TermX - gap - w, c.TermY - h / 2),
|
||||
(c.TermX - w / 2, c.TermY + gap),
|
||||
(c.TermX - w / 2, c.TermY - gap - h),
|
||||
(c.TermX + gap * 2 + w / 2, c.TermY - h / 2),
|
||||
(c.TermX - gap * 2 - w - w / 2, c.TermY - h / 2),
|
||||
(c.TermX - w / 2, c.TermY + gap * 2 + h),
|
||||
(c.TermX - w / 2, c.TermY - gap * 2 - h * 2),
|
||||
};
|
||||
bool ok = false;
|
||||
foreach (var (tx, ty) in tries)
|
||||
{
|
||||
int bx = Math.Clamp(tx - pad, 0, Math.Max(0, n - (w + pad * 2)));
|
||||
int by = Math.Clamp(ty - pad, 0, Math.Max(0, n - (h + pad * 2)));
|
||||
var box = new Rect2I(bx, by, w + pad * 2, h + pad * 2);
|
||||
bool hit = false;
|
||||
foreach (var q in placed) if (box.Intersects(q)) { hit = true; break; }
|
||||
if (hit) continue;
|
||||
FillRect(img, box, new Color(0.04f, 0.05f, 0.07f), n);
|
||||
TinyFont.Draw(img, txt, bx + pad, by + pad, ls, c.IsSea ? Trunk : Giant);
|
||||
placed.Add(box);
|
||||
ok = true;
|
||||
break;
|
||||
}
|
||||
if (!ok) dropped++;
|
||||
}
|
||||
if (!placer.Place(img, $"{DrainageLabel(c.DrainagePx)} R{c.Rank}", c.TermX, c.TermY, mark,
|
||||
c.IsSea ? Trunk : Giant)) dropped++;
|
||||
|
||||
int s = n >= 4096 ? 4 : 3; int lh = TinyFont.Height(s) + 6;
|
||||
int nSea = 0; long seaPx = 0, endoPx = 0;
|
||||
|
|
@ -376,6 +341,193 @@ namespace IslaApocalypse.Tools
|
|||
if (x >= 0 && y >= 0 && x < n && y < n) img.SetPixel(x, y, c);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Greedy non-overlapping label placement on a dark backing box, so a number is legible over
|
||||
/// both bright terrain and dark ocean. A label that cannot be placed clear of the others is
|
||||
/// DROPPED rather than drawn illegibly on top of one — and every caller reports how many, so a
|
||||
/// missing number is never silent. Shared by the composition and routed-mix plates.
|
||||
/// </summary>
|
||||
private sealed class LabelPlacer
|
||||
{
|
||||
private readonly List<Rect2I> _placed = new();
|
||||
private readonly int _n, _scale, _pad;
|
||||
|
||||
public LabelPlacer(int n, int scale, int headerLines)
|
||||
{
|
||||
_n = n; _scale = scale; _pad = 4 * (scale >= 4 ? 2 : 1);
|
||||
// Reserve the legend block so a river label never lands under the header text.
|
||||
_placed.Add(new Rect2I(0, 0, n, 12 + (TinyFont.Height(scale) + 6) * headerLines));
|
||||
}
|
||||
|
||||
public bool Place(Image img, string txt, int atX, int atY, int mark, Color ink)
|
||||
{
|
||||
int w = TinyFont.Width(txt, _scale), h = TinyFont.Height(_scale);
|
||||
int gap = mark + 10;
|
||||
// right, left, below, above, then pushed further out — first clear slot wins.
|
||||
var tries = new (int x, int y)[]
|
||||
{
|
||||
(atX + gap, atY - h / 2),
|
||||
(atX - gap - w, atY - h / 2),
|
||||
(atX - w / 2, atY + gap),
|
||||
(atX - w / 2, atY - gap - h),
|
||||
(atX + gap * 2 + w / 2, atY - h / 2),
|
||||
(atX - gap * 2 - w - w / 2, atY - h / 2),
|
||||
(atX - w / 2, atY + gap * 2 + h),
|
||||
(atX - w / 2, atY - gap * 2 - h * 2),
|
||||
};
|
||||
foreach (var (tx, ty) in tries)
|
||||
{
|
||||
int bx = Math.Clamp(tx - _pad, 0, Math.Max(0, _n - (w + _pad * 2)));
|
||||
int by = Math.Clamp(ty - _pad, 0, Math.Max(0, _n - (h + _pad * 2)));
|
||||
var box = new Rect2I(bx, by, w + _pad * 2, h + _pad * 2);
|
||||
bool hit = false;
|
||||
foreach (var q in _placed) if (box.Intersects(q)) { hit = true; break; }
|
||||
if (hit) continue;
|
||||
FillRect(img, box, new Color(0.04f, 0.05f, 0.07f), _n);
|
||||
TinyFont.Draw(img, txt, bx + _pad, by + _pad, _scale, ink);
|
||||
_placed.Add(box);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ═══ ⭐⭐ THE ROUTED MIX (rivers/03) — three classes, and where routing added the channel ═══
|
||||
|
||||
/// <summary>Routed giants: the natural upland stem, muted.</summary>
|
||||
private static readonly Color RoutedStem = new(0.250f, 0.620f, 0.330f);
|
||||
/// <summary>⭐ The LOWLAND REACH routing added — bright, so the added channel is unmistakable.</summary>
|
||||
private static readonly Color RoutedReach = new(0.380f, 1.000f, 0.420f);
|
||||
/// <summary>The rim the route climbed over — the point the developer is asked to judge.</summary>
|
||||
private static readonly Color RimMark = new(1.000f, 0.930f, 0.350f);
|
||||
|
||||
/// <summary>
|
||||
/// ⭐⭐ THE MIX PLATE — natural ocean trunks, routed-through giants, and inland lake-enders, on
|
||||
/// the shared faint base at rivers/02b's FIXED width scale.
|
||||
///
|
||||
/// The one thing this plate exists to show: **which part of a routed river is terrain and which
|
||||
/// part is routing.** So a routed giant is drawn in two tones of one colour — its erosion-carved
|
||||
/// upland stem muted, the lowland reach the Dijkstra added bright — and the point where that
|
||||
/// reach crosses its rim is ringed. A reader can then see, without reading a table, how far the
|
||||
/// river was carried and how high it had to climb to get there.
|
||||
///
|
||||
/// ⚠⚠ `Giant.ProvisionalRoute` is NOT drawn — the real route is what replaces it.
|
||||
/// </summary>
|
||||
public static Image RoutedMix(List<RiverRouting.RoutedRiver> rivers, Image img, int n,
|
||||
string title, string subtitle, long floorPx)
|
||||
{
|
||||
if (rivers.Count == 0) return img;
|
||||
|
||||
int mark = n >= 4096 ? 18 : 10;
|
||||
var byArea = new List<RiverRouting.RoutedRiver>(rivers);
|
||||
byArea.Sort((a, b) => b.Candidate.DrainagePx.CompareTo(a.Candidate.DrainagePx));
|
||||
|
||||
// Smallest first, so the biggest rivers finish on top.
|
||||
for (int i = byArea.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var r = byArea[i];
|
||||
int w = StemWidthFixed(r.Candidate.DrainagePx);
|
||||
Color stemCol = r.Class switch
|
||||
{
|
||||
RiverRouting.RiverClass.OceanTrunk => Trunk,
|
||||
RiverRouting.RiverClass.RoutedGiant => RoutedStem,
|
||||
_ => Giant,
|
||||
};
|
||||
// The upland stem, as erosion made it (head → terminal), reversed out of the analysis.
|
||||
var stem = new List<(float x, float y)>(r.Candidate.Course);
|
||||
stem.Reverse();
|
||||
Polyline(img, stem, n, stemCol, w);
|
||||
|
||||
// The lowland reach routing added, drawn distinctly on top of its own stem.
|
||||
if (r.Lowland != null && r.Lowland.Smoothed != null && r.Lowland.Smoothed.Count > 1)
|
||||
{
|
||||
Color reachCol = r.Class == RiverRouting.RiverClass.RoutedGiant ? RoutedReach : Giant;
|
||||
Polyline(img, r.Lowland.Smoothed, n, reachCol, w);
|
||||
}
|
||||
}
|
||||
|
||||
// Terminus markers, and the rim a routed river crossed.
|
||||
foreach (var r in byArea)
|
||||
{
|
||||
var c = r.Candidate;
|
||||
switch (r.Class)
|
||||
{
|
||||
case RiverRouting.RiverClass.OceanTrunk:
|
||||
Square(img, c.TermX, c.TermY, mark, n, Trunk);
|
||||
break;
|
||||
case RiverRouting.RiverClass.RoutedGiant:
|
||||
if (r.Lowland != null && r.Lowland.Reached)
|
||||
{
|
||||
var t = r.Lowland.Target;
|
||||
Square(img, (int)t.x, (int)t.y, mark, n, RoutedReach);
|
||||
Ring(img, (int)t.x, (int)t.y, mark + 8, n, Ink, 3);
|
||||
MarkRim(img, r.Lowland, n, mark);
|
||||
}
|
||||
// The basin it came FROM stays marked, so the reader sees what was connected.
|
||||
Ring(img, c.TermX, c.TermY, mark, n, RoutedStem, 4);
|
||||
break;
|
||||
default:
|
||||
Disc(img, c.TermX, c.TermY, mark, n, Giant);
|
||||
Ring(img, c.TermX, c.TermY, mark + 8, n, Ink, 3);
|
||||
if (r.Lowland != null && r.Lowland.Reached)
|
||||
{
|
||||
var t = r.Lowland.Target;
|
||||
Ring(img, (int)t.x, (int)t.y, mark, n, Giant, 4);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- labels ----
|
||||
int ls = n >= 4096 ? 4 : 3;
|
||||
var placer = new LabelPlacer(n, ls, headerLines: 8);
|
||||
int dropped = 0;
|
||||
foreach (var r in byArea)
|
||||
{
|
||||
var c = r.Candidate;
|
||||
string txt = r.Class switch
|
||||
{
|
||||
RiverRouting.RiverClass.OceanTrunk => $"{DrainageLabel(c.DrainagePx)} R{c.Rank} TRUNK",
|
||||
RiverRouting.RiverClass.RoutedGiant => $"{DrainageLabel(c.DrainagePx)} R{c.Rank} RIM {(r.Lowland != null ? r.Lowland.RimClimbM : 0f):F0}M",
|
||||
_ => $"{DrainageLabel(c.DrainagePx)} R{c.Rank} LAKE",
|
||||
};
|
||||
Color ink = r.Class switch
|
||||
{
|
||||
RiverRouting.RiverClass.OceanTrunk => Trunk,
|
||||
RiverRouting.RiverClass.RoutedGiant => RoutedReach,
|
||||
_ => Giant,
|
||||
};
|
||||
if (!placer.Place(img, txt, c.TermX, c.TermY, mark, ink)) dropped++;
|
||||
}
|
||||
|
||||
int trunks = 0, routed = 0, lakes = 0;
|
||||
foreach (var r in byArea)
|
||||
{
|
||||
if (r.Class == RiverRouting.RiverClass.OceanTrunk) trunks++;
|
||||
else if (r.Class == RiverRouting.RiverClass.RoutedGiant) routed++;
|
||||
else lakes++;
|
||||
}
|
||||
int s = n >= 4096 ? 4 : 3; int lh = TinyFont.Height(s) + 6;
|
||||
TinyFont.Draw(img, title, 12, 12, s, Ink);
|
||||
TinyFont.Draw(img, subtitle, 12, 12 + lh, s, Ink);
|
||||
TinyFont.Draw(img, $"CYAN: NATURAL OCEAN TRUNK ({trunks}) - EROSION ALREADY REACHES THE COAST, NO LOWLAND ROUTE ADDED", 12, 12 + lh * 2, s, Trunk);
|
||||
TinyFont.Draw(img, $"GREEN: ROUTED-THROUGH GIANT ({routed}) - DARK = ITS NATURAL UPLAND STEM, BRIGHT = THE LOWLAND REACH ROUTING ADDED", 12, 12 + lh * 3, s, RoutedReach);
|
||||
TinyFont.Draw(img, $"YELLOW RING ON A GREEN REACH = THE RIM IT CLIMBED OVER (ROUTE HIGH POINT). LABEL RIM = METRES CLIMBED FROM THE BASIN", 12, 12 + lh * 4, s, RimMark);
|
||||
TinyFont.Draw(img, $"ORANGE: INLAND LAKE-ENDER ({lakes}) - DISC = ITS TERMINAL, RING = THE SIGNIFICANT WATER BODY IT JOINS", 12, 12 + lh * 5, s, Giant);
|
||||
TinyFont.Draw(img, $"WIDTH: {StemWidthLaw()} - THE SAME FIXED CONSTANT AS RIVERS/02B, EVERY PLATE AND SEED", 12, 12 + lh * 6, s, Ink);
|
||||
TinyFont.Draw(img, $"COURSES ONLY - NO HEIGHT MUTATED, NO WATER FILLED, NOTHING CARVED. PROVISIONALROUTE (THE COMB) NOT DRAWN." +
|
||||
(dropped > 0 ? $" ({dropped} LABEL(S) DROPPED)" : ""), 12, 12 + lh * 7, s, Ink);
|
||||
return img;
|
||||
}
|
||||
|
||||
/// <summary>Ring the route's high point — the rim the channel crosses.</summary>
|
||||
private static void MarkRim(Image img, RiverRouting.Route route, int n, int mark)
|
||||
{
|
||||
if (route.Path == null || route.Path.Count < 2 || route.RimClimbM <= 0.01f) return;
|
||||
var p = route.RimPoint;
|
||||
Ring(img, (int)p.x, (int)p.y, mark - 4, n, RimMark, 4);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ⭐ THE DISTRIBUTION PLOT — drainage area (log y) against rank (linear x), with the ladder
|
||||
/// counts marked vertically and the analysis's own thresholds marked horizontally.
|
||||
|
|
|
|||
|
|
@ -99,6 +99,22 @@ namespace IslaApocalypse.Tools
|
|||
/// <summary>1-based rank in the unified descending ranking. 0 until ranked.</summary>
|
||||
public int Rank;
|
||||
|
||||
/// <summary>
|
||||
/// ⭐ rivers/03 — THE ANALYSIS'S OWN routed/lake-ender verdict, copied from <c>Giant.Kind</c> at
|
||||
/// bind time. Endorheic only ("" for sea candidates).
|
||||
///
|
||||
/// ⚠⚠ READ THE TEST BEFORE TRUSTING THE NAME. `DrainageAnalysis` assigns this as
|
||||
/// <c>(basinHasLake[id] && !SouthernCandidate) ? "lake-ender" : "routed"</c> — i.e. purely on
|
||||
/// **whether the terminal basin holds classify water**. It is NOT a path test: "routed" means
|
||||
/// "this basin is a dry pan, so it SHOULD be routed", not "a route to the sea exists". Whether
|
||||
/// one actually does is what `RiverRouting.RouteToOcean` decides, and the two CAN disagree.
|
||||
/// rivers/03 reports both per river rather than silently picking one.
|
||||
/// </summary>
|
||||
public string AnalysisKind = "";
|
||||
|
||||
/// <summary>Endorheic only: the analysis found classify water in this terminal basin.</summary>
|
||||
public bool TerminalInClassifyWater;
|
||||
|
||||
public string TerminusName => IsSea ? "sea" : "endorheic";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
233
Tools/Scripts/RiverCandidates.cs
Normal file
233
Tools/Scripts/RiverCandidates.cs
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Godot;
|
||||
using IslaApocalypse.Core;
|
||||
|
||||
namespace IslaApocalypse.Tools
|
||||
{
|
||||
/// <summary>
|
||||
/// ⭐⭐ THE CANDIDATE SET — one implementation, shared by every task that ranks rivers.
|
||||
///
|
||||
/// Extracted from `RiverPromotionTool` at rivers/03, unchanged in behaviour, because routing needs
|
||||
/// exactly the same promoted set the count gate was judged on. **Two copies of this enumeration
|
||||
/// would be two answers to "which rivers does the island have", and the epic rests on there being
|
||||
/// one.** `RiverPromotionTool` now delegates here; its plates are byte-identical across the change.
|
||||
///
|
||||
/// ═══ WHAT IT DOES, AND WHAT IT DELIBERATELY DOES NOT ═══
|
||||
///
|
||||
/// It derives the COMPLETE candidate set from the arrays `DrainageAnalysis.Plan` exposes —
|
||||
/// `Dir` / `Acc` / `BasinId` / `BasinInflow` / `FullFilled` — rather than from `Plan.Trunks` /
|
||||
/// `Plan.Giants`, which are already truncated by the analysis's lean reporting caps. Ranking over
|
||||
/// the truncated lists would measure the caps rather than the terrain.
|
||||
///
|
||||
/// ⚠ **`DrainageAnalysis` is reused, never rebuilt.** Every derived quantity here is a
|
||||
/// reconstruction of a value the analysis computed internally, from state it exposes. Nothing the
|
||||
/// analysis owns is reimplemented — least of all stem tracing, which is BOUND from the analysis's
|
||||
/// own `Trunk` / `Giant` (see <see cref="BindCourses"/>).
|
||||
/// </summary>
|
||||
public static class RiverCandidates
|
||||
{
|
||||
/// <summary>The enumeration's full result: the ranking, plus what the separation rule cost.</summary>
|
||||
public sealed class Enumeration
|
||||
{
|
||||
/// <summary>Separated and above the floor, descending by <c>DrainagePx</c>, `Rank` assigned.</summary>
|
||||
public List<RiverCandidate> Ranked;
|
||||
public long LandCells, SeaReachingCells, EndorheicCells, UnroutedCells;
|
||||
public int TerminalBasins, SeaOutletsAll;
|
||||
/// <summary>Sea outlets dropped by the separation rule — ALL of them, incl. one-cell trickles.</summary>
|
||||
public int SuppressedCount;
|
||||
public long SuppressedPx;
|
||||
/// <summary>⭐ The two that matter: only outlets clearing the floor could ever have been promoted.</summary>
|
||||
public int SuppressedAboveFloor;
|
||||
public long SuppressedAboveFloorPx;
|
||||
/// <summary>Of those, suppressed by an outlet on a DIFFERENT landmass — not a delta mouth by any definition.</summary>
|
||||
public int SuppressedCrossLandmass;
|
||||
public long SuppressedCrossLandmassPx;
|
||||
public List<long> SuppressedAboveFloorAccs = new();
|
||||
|
||||
public int SeaCount { get { int s = 0; foreach (var c in Ranked) if (c.IsSea) s++; return s; } }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enumerate every candidate major drainage, cap-free.
|
||||
///
|
||||
/// SEA every cell with <c>Dir == D_SEA</c>, carrying <c>Acc</c> there, then the
|
||||
/// analysis's own greedy <c>MinOutletSeparationPx</c> rule so three mouths of one
|
||||
/// delta are not three rivers.
|
||||
/// ENDORHEIC every terminal basin in <c>BasinId</c>, carrying <c>BasinInflow[id]</c>, with
|
||||
/// terminal cell / area / depth re-derived from the exposed surfaces.
|
||||
///
|
||||
/// ⚠⚠ Throws unless the metric-comparability identity holds exactly — see below.
|
||||
/// </summary>
|
||||
public static Enumeration Enumerate(DrainageAnalysis.Plan plan, float[,] height, int n,
|
||||
long floorPx, int separationPx, RegionLabels regions)
|
||||
{
|
||||
int total = n * n;
|
||||
var r = new Enumeration
|
||||
{
|
||||
LandCells = plan.LandCells, SeaReachingCells = plan.SeaReachingCells,
|
||||
EndorheicCells = plan.EndorheicCells, UnroutedCells = plan.UnroutedCells,
|
||||
TerminalBasins = plan.TerminalBasinCount,
|
||||
};
|
||||
|
||||
// ---- SEA: every outlet, then the separation rule ----
|
||||
var outlets = new List<(int cell, long acc)>();
|
||||
long seaSum = 0;
|
||||
for (int i = 0; i < total; i++)
|
||||
if (plan.Dir[i] == DrainageAnalysis.D_SEA) { outlets.Add((i, plan.Acc[i])); seaSum += plan.Acc[i]; }
|
||||
outlets.Sort((a, b) => b.acc.CompareTo(a.acc));
|
||||
r.SeaOutletsAll = outlets.Count;
|
||||
|
||||
var sea = new List<RiverCandidate>();
|
||||
var kept = new List<int>();
|
||||
foreach (var (cell, acc) in outlets)
|
||||
{
|
||||
int cx = cell / n, cy = cell % n;
|
||||
bool far = true; int suppressor = -1;
|
||||
foreach (int pcell in kept)
|
||||
{
|
||||
float ddx = cx - pcell / n, ddy = cy - pcell % n;
|
||||
if (ddx * ddx + ddy * ddy < (float)separationPx * separationPx) { far = false; suppressor = pcell; break; }
|
||||
}
|
||||
var c = new RiverCandidate { IsSea = true, Cell = cell, X = cx, Y = cy, TermX = cx, TermY = cy, DrainagePx = acc, SuppressedBySeparation = !far };
|
||||
if (far) kept.Add(cell);
|
||||
else
|
||||
{
|
||||
r.SuppressedCount++; r.SuppressedPx += acc;
|
||||
if (acc >= floorPx)
|
||||
{
|
||||
r.SuppressedAboveFloor++; r.SuppressedAboveFloorPx += acc; r.SuppressedAboveFloorAccs.Add(acc);
|
||||
// ⚠⚠ IS THE SUPPRESSOR EVEN ON THE SAME LANDMASS? The separation rule is a plain
|
||||
// Euclidean distance test — it has no idea what land a coastline belongs to. On
|
||||
// this deliberately fragmented archipelago (→ D-063) an ISLAND's only river can
|
||||
// be suppressed by a mainland mouth 400 px away ACROSS WATER. Measured, not
|
||||
// argued; the rule itself is NOT changed (it belongs to the analysis).
|
||||
if (regions != null && suppressor >= 0)
|
||||
{
|
||||
int a = regions.Id[cell], b = regions.Id[suppressor];
|
||||
if (a != 0 && b != 0 && a != b) { r.SuppressedCrossLandmass++; r.SuppressedCrossLandmassPx += acc; }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (acc >= floorPx) sea.Add(c);
|
||||
}
|
||||
|
||||
// ---- ENDORHEIC: every terminal basin, metrics re-derived ----
|
||||
// After the analysis's reversion, BasinId is non-zero ONLY on terminal-basin cells, and
|
||||
// Filled == the original height there — so FullFilled − height IS the fill depth, and the
|
||||
// basin minimum is the argmin of height over the basin's cells. Both reconstruct exactly
|
||||
// what the analysis computed internally as basinMinCell / basinDepthM / basinAreaPx.
|
||||
int maxId = 0;
|
||||
for (int i = 0; i < total; i++) if (plan.BasinId[i] > maxId) maxId = plan.BasinId[i];
|
||||
var area = new long[maxId + 1];
|
||||
var minCell = new int[maxId + 1];
|
||||
var minH = new float[maxId + 1];
|
||||
var depth = new float[maxId + 1];
|
||||
for (int id = 0; id <= maxId; id++) { minCell[id] = -1; minH[id] = float.MaxValue; }
|
||||
for (int i = 0; i < total; i++)
|
||||
{
|
||||
int id = plan.BasinId[i];
|
||||
if (id == 0) continue;
|
||||
area[id]++;
|
||||
float h = height[i / n, i % n];
|
||||
if (h < minH[id]) { minH[id] = h; minCell[id] = i; }
|
||||
float d = WorldScale.MetresFromRaw(plan.FullFilled[i] - h);
|
||||
if (d > depth[id]) depth[id] = d;
|
||||
}
|
||||
|
||||
var endo = new List<RiverCandidate>();
|
||||
long endoSum = 0;
|
||||
for (int id = 1; id <= maxId; id++)
|
||||
{
|
||||
if (minCell[id] < 0) continue;
|
||||
long inflow = id < plan.BasinInflow.Length ? plan.BasinInflow[id] : 0;
|
||||
endoSum += inflow;
|
||||
if (inflow < floorPx) continue;
|
||||
endo.Add(new RiverCandidate
|
||||
{
|
||||
IsSea = false, Cell = minCell[id], X = minCell[id] / n, Y = minCell[id] % n,
|
||||
TermX = minCell[id] / n, TermY = minCell[id] % n, // replaced at bind time by the stem's pooling point
|
||||
DrainagePx = inflow, BasinId = id, BasinAreaPx = area[id], BasinDepthM = depth[id],
|
||||
});
|
||||
}
|
||||
|
||||
// ═══ ⚠⚠ THE COMPARABILITY ASSERTION — the whole unified ranking rests on this ═══
|
||||
//
|
||||
// Both metrics are counts of contributing LAND CELLS on the same D8 field, and every land
|
||||
// cell has exactly one destination — so the two populations partition the land exactly.
|
||||
// If this identity ever fails, the two numbers are not the same unit and ranking them in
|
||||
// one list is meaningless. It is asserted per seed rather than argued in a comment.
|
||||
long partition = seaSum + endoSum + plan.UnroutedCells;
|
||||
if (seaSum != plan.SeaReachingCells || endoSum != plan.EndorheicCells || partition != plan.LandCells)
|
||||
throw new InvalidOperationException(
|
||||
"[RiverCandidates] METRIC COMPARABILITY VIOLATION — the unified ranking is not sound on this field.\n" +
|
||||
$" Σ Acc over sea outlets = {seaSum:N0}, expected SeaReachingCells = {plan.SeaReachingCells:N0}\n" +
|
||||
$" Σ BasinInflow = {endoSum:N0}, expected EndorheicCells = {plan.EndorheicCells:N0}\n" +
|
||||
$" sum + unrouted = {partition:N0}, expected LandCells = {plan.LandCells:N0}\n" +
|
||||
"Sea-outlet drainage area and endorheic credited inflow must be the same unit over the same " +
|
||||
"population for one ranking to mean anything. Refusing to rank. (rivers/02 Part 0 §2.)");
|
||||
GD.Print($" ✅ comparability: Σ sea Acc {seaSum:N0} + Σ BasinInflow {endoSum:N0} + unrouted {plan.UnroutedCells:N0} == land {plan.LandCells:N0} — same unit, exact partition");
|
||||
|
||||
// ---- the unified ranking: one list, both termini, descending by contributing cells ----
|
||||
var ranked = new List<RiverCandidate>();
|
||||
foreach (var c in sea) if (!c.SuppressedBySeparation) ranked.Add(c);
|
||||
ranked.AddRange(endo);
|
||||
ranked.Sort((a, b) => b.DrainagePx.CompareTo(a.DrainagePx));
|
||||
for (int i = 0; i < ranked.Count; i++) ranked[i].Rank = i + 1;
|
||||
r.Ranked = ranked;
|
||||
return r;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bind each candidate in <paramref name="need"/> to the <c>Trunk</c> / <c>Giant</c> the
|
||||
/// analysis already traced, so plates draw REAL upland stems rather than anything reimplemented
|
||||
/// here. Sea binds by outlet cell (identical greedy pick, identical order); endorheic binds by
|
||||
/// BASIN ID — not by terminal coordinates, because a flat basin floor can have several cells at
|
||||
/// the minimum height and the analysis's DFS tie-break need not match a row-major scan.
|
||||
///
|
||||
/// ⚠ Also transfers the analysis's own <see cref="RiverCandidate.Kind"/> and
|
||||
/// <see cref="RiverCandidate.TerminalInClassifyWater"/> for endorheic candidates — rivers/03
|
||||
/// needs the reference's routed/lake-ender verdict to compare against its own.
|
||||
/// </summary>
|
||||
public static void BindCourses(DrainageAnalysis.Plan plan, int n, List<RiverCandidate> need, string what)
|
||||
{
|
||||
var byOutlet = new Dictionary<int, DrainageAnalysis.Trunk>();
|
||||
foreach (var t in plan.Trunks) byOutlet[(int)t.Outlet.x * n + (int)t.Outlet.y] = t;
|
||||
var byBasin = new Dictionary<int, DrainageAnalysis.Giant>();
|
||||
foreach (var g in plan.Giants)
|
||||
{
|
||||
int cell = (int)g.Terminal.x * n + (int)g.Terminal.y;
|
||||
int id = plan.BasinId[cell];
|
||||
if (id > 0 && !byBasin.ContainsKey(id)) byBasin[id] = g;
|
||||
}
|
||||
|
||||
int missing = 0;
|
||||
foreach (var c in need)
|
||||
{
|
||||
if (c.Course != null) continue;
|
||||
if (c.IsSea)
|
||||
{
|
||||
if (byOutlet.TryGetValue(c.Cell, out var t)) { c.Course = t.Course; c.TermX = (int)t.Outlet.x; c.TermY = (int)t.Outlet.y; }
|
||||
}
|
||||
else
|
||||
{
|
||||
// ⚠ Take the RIVER's terminus from the Giant, not the basin minimum this candidate
|
||||
// is keyed on — see RiverCandidate.TermX. They differ on a flat basin floor, and
|
||||
// marking the wrong one draws every endorheic stem detached from its own endpoint.
|
||||
if (byBasin.TryGetValue(c.BasinId, out var g))
|
||||
{
|
||||
c.Course = g.Course; c.TermX = (int)g.Terminal.x; c.TermY = (int)g.Terminal.y;
|
||||
c.AnalysisKind = g.Kind;
|
||||
c.TerminalInClassifyWater = g.TerminalInClassifyWater;
|
||||
}
|
||||
}
|
||||
if (c.Course == null) missing++;
|
||||
}
|
||||
if (missing > 0)
|
||||
throw new InvalidOperationException(
|
||||
$"[RiverCandidates] {missing} of {need.Count} candidates in {what} have no traced stem. The analysis's " +
|
||||
"reporting caps are what produce the courses, so they must cover every candidate being drawn — " +
|
||||
"raise ISLA_PROMOTE_MAX. Refusing to render a plate with rivers drawn as bare markers.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -364,142 +364,27 @@ namespace IslaApocalypse.Tools
|
|||
GetTree().Quit(0);
|
||||
}
|
||||
|
||||
// ═══ ⭐⭐ ENUMERATION — the complete candidate set, derived from the exposed Plan arrays ═══
|
||||
// ═══ ENUMERATION — delegated to RiverCandidates (extracted at rivers/03) ═══
|
||||
//
|
||||
// ⚠ The enumeration moved to `RiverCandidates` UNCHANGED so routing ranks the identical set this
|
||||
// gate was judged on. Two copies would be two answers to "which rivers does the island have".
|
||||
// Verified: rivers/02b's twelve plates are byte-identical across the extraction.
|
||||
|
||||
/// <summary>
|
||||
/// Enumerate every candidate major drainage, cap-free.
|
||||
///
|
||||
/// SEA every cell with <c>Dir == D_SEA</c>, carrying <c>Acc</c> at that cell, then the
|
||||
/// analysis's own greedy <c>MinOutletSeparationPx</c> rule so three mouths of one
|
||||
/// delta are not three rivers.
|
||||
/// ENDORHEIC every terminal basin present in <c>BasinId</c>, carrying <c>BasinInflow[id]</c>,
|
||||
/// with its terminal cell / area / depth re-derived from the exposed surfaces.
|
||||
///
|
||||
/// ⚠ Nothing here re-runs or re-implements the analysis: `Dir`, `Acc`, `BasinId`, `BasinInflow`
|
||||
/// and `FullFilled` are all exposed on `Plan`, and every derived quantity below is a
|
||||
/// reconstruction of a value the analysis computed internally, from those arrays.
|
||||
/// </summary>
|
||||
private static SeedResult Enumerate(DrainageAnalysis.Plan plan, float[,] height, int n,
|
||||
long floorPx, int separationPx, int seed, RegionLabels regions)
|
||||
{
|
||||
int total = n * n;
|
||||
var r = new SeedResult
|
||||
var e = RiverCandidates.Enumerate(plan, height, n, floorPx, separationPx, regions);
|
||||
return new SeedResult
|
||||
{
|
||||
Seed = seed,
|
||||
LandCells = plan.LandCells, SeaReachingCells = plan.SeaReachingCells,
|
||||
EndorheicCells = plan.EndorheicCells, UnroutedCells = plan.UnroutedCells,
|
||||
TerminalBasins = plan.TerminalBasinCount,
|
||||
Seed = seed, Ranked = e.Ranked,
|
||||
LandCells = e.LandCells, SeaReachingCells = e.SeaReachingCells,
|
||||
EndorheicCells = e.EndorheicCells, UnroutedCells = e.UnroutedCells,
|
||||
TerminalBasins = e.TerminalBasins, SeaOutletsAll = e.SeaOutletsAll,
|
||||
SuppressedCount = e.SuppressedCount, SuppressedPx = e.SuppressedPx,
|
||||
SuppressedAboveFloor = e.SuppressedAboveFloor, SuppressedAboveFloorPx = e.SuppressedAboveFloorPx,
|
||||
SuppressedCrossLandmass = e.SuppressedCrossLandmass, SuppressedCrossLandmassPx = e.SuppressedCrossLandmassPx,
|
||||
SuppressedAboveFloorAccs = e.SuppressedAboveFloorAccs,
|
||||
};
|
||||
|
||||
// ---- SEA: every outlet, then the separation rule ----
|
||||
var outlets = new List<(int cell, long acc)>();
|
||||
long seaSum = 0;
|
||||
for (int i = 0; i < total; i++)
|
||||
if (plan.Dir[i] == DrainageAnalysis.D_SEA) { outlets.Add((i, plan.Acc[i])); seaSum += plan.Acc[i]; }
|
||||
outlets.Sort((a, b) => b.acc.CompareTo(a.acc));
|
||||
r.SeaOutletsAll = outlets.Count;
|
||||
|
||||
var sea = new List<RiverCandidate>();
|
||||
var kept = new List<int>();
|
||||
foreach (var (cell, acc) in outlets)
|
||||
{
|
||||
int cx = cell / n, cy = cell % n;
|
||||
bool far = true; int suppressor = -1;
|
||||
foreach (int pcell in kept)
|
||||
{
|
||||
float ddx = cx - pcell / n, ddy = cy - pcell % n;
|
||||
if (ddx * ddx + ddy * ddy < (float)separationPx * separationPx) { far = false; suppressor = pcell; break; }
|
||||
}
|
||||
var c = new RiverCandidate { IsSea = true, Cell = cell, X = cx, Y = cy, TermX = cx, TermY = cy, DrainagePx = acc, SuppressedBySeparation = !far };
|
||||
if (far) kept.Add(cell);
|
||||
else
|
||||
{
|
||||
r.SuppressedCount++; r.SuppressedPx += acc;
|
||||
if (acc >= floorPx)
|
||||
{
|
||||
r.SuppressedAboveFloor++; r.SuppressedAboveFloorPx += acc; r.SuppressedAboveFloorAccs.Add(acc);
|
||||
// ⚠⚠ IS THE SUPPRESSOR EVEN ON THE SAME LANDMASS? The separation rule is a plain
|
||||
// Euclidean distance test — it has no idea what land a coastline belongs to. On
|
||||
// this deliberately fragmented archipelago (→ D-063) that means an ISLAND's only
|
||||
// river can be suppressed by a mainland river's mouth 400 px away ACROSS WATER,
|
||||
// which is not a delta by any definition. Measured here rather than argued.
|
||||
// ⚠ The rule is NOT changed — it belongs to the analysis, and changing it would
|
||||
// move the candidate set the developer is being asked to judge. This counts
|
||||
// what it costs, so the count decision is made knowing it.
|
||||
if (regions != null && suppressor >= 0)
|
||||
{
|
||||
int a = regions.Id[cell], b = regions.Id[suppressor];
|
||||
if (a != 0 && b != 0 && a != b) { r.SuppressedCrossLandmass++; r.SuppressedCrossLandmassPx += acc; }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (acc >= floorPx) sea.Add(c);
|
||||
}
|
||||
|
||||
// ---- ENDORHEIC: every terminal basin, metrics re-derived ----
|
||||
// After the analysis's reversion, BasinId is non-zero ONLY on terminal-basin cells, and
|
||||
// Filled == the original height there — so FullFilled − height IS the fill depth, and the
|
||||
// basin minimum is the argmin of height over the basin's cells. Both reconstruct exactly
|
||||
// what the analysis computed internally as basinMinCell / basinDepthM / basinAreaPx.
|
||||
int maxId = 0;
|
||||
for (int i = 0; i < total; i++) if (plan.BasinId[i] > maxId) maxId = plan.BasinId[i];
|
||||
var area = new long[maxId + 1];
|
||||
var minCell = new int[maxId + 1];
|
||||
var minH = new float[maxId + 1];
|
||||
var depth = new float[maxId + 1];
|
||||
for (int id = 0; id <= maxId; id++) { minCell[id] = -1; minH[id] = float.MaxValue; }
|
||||
for (int i = 0; i < total; i++)
|
||||
{
|
||||
int id = plan.BasinId[i];
|
||||
if (id == 0) continue;
|
||||
area[id]++;
|
||||
float h = height[i / n, i % n];
|
||||
if (h < minH[id]) { minH[id] = h; minCell[id] = i; }
|
||||
float d = WorldScale.MetresFromRaw(plan.FullFilled[i] - h);
|
||||
if (d > depth[id]) depth[id] = d;
|
||||
}
|
||||
|
||||
var endo = new List<RiverCandidate>();
|
||||
long endoSum = 0;
|
||||
for (int id = 1; id <= maxId; id++)
|
||||
{
|
||||
if (minCell[id] < 0) continue;
|
||||
long inflow = id < plan.BasinInflow.Length ? plan.BasinInflow[id] : 0;
|
||||
endoSum += inflow;
|
||||
if (inflow < floorPx) continue;
|
||||
endo.Add(new RiverCandidate
|
||||
{
|
||||
IsSea = false, Cell = minCell[id], X = minCell[id] / n, Y = minCell[id] % n,
|
||||
TermX = minCell[id] / n, TermY = minCell[id] % n, // replaced at bind time by the stem's pooling point
|
||||
DrainagePx = inflow, BasinId = id, BasinAreaPx = area[id], BasinDepthM = depth[id],
|
||||
});
|
||||
}
|
||||
|
||||
// ═══ ⚠⚠ THE COMPARABILITY ASSERTION — the whole unified ranking rests on this ═══
|
||||
//
|
||||
// Both metrics are counts of contributing LAND CELLS on the same D8 field, and every land
|
||||
// cell has exactly one destination — so the two populations partition the land exactly.
|
||||
// If this identity ever fails, the two numbers are not the same unit and ranking them in
|
||||
// one list is meaningless. It is asserted per seed rather than argued in a comment.
|
||||
long partition = seaSum + endoSum + plan.UnroutedCells;
|
||||
if (seaSum != plan.SeaReachingCells || endoSum != plan.EndorheicCells || partition != plan.LandCells)
|
||||
throw new InvalidOperationException(
|
||||
"[RiverPromotion] METRIC COMPARABILITY VIOLATION — the unified ranking is not sound on this field.\n" +
|
||||
$" Σ Acc over sea outlets = {seaSum:N0}, expected SeaReachingCells = {plan.SeaReachingCells:N0}\n" +
|
||||
$" Σ BasinInflow = {endoSum:N0}, expected EndorheicCells = {plan.EndorheicCells:N0}\n" +
|
||||
$" sum + unrouted = {partition:N0}, expected LandCells = {plan.LandCells:N0}\n" +
|
||||
"Sea-outlet drainage area and endorheic credited inflow must be the same unit over the same " +
|
||||
"population for one ranking to mean anything. Refusing to rank. (rivers/02 Part 0 §2.)");
|
||||
GD.Print($" ✅ comparability: Σ sea Acc {seaSum:N0} + Σ BasinInflow {endoSum:N0} + unrouted {plan.UnroutedCells:N0} == land {plan.LandCells:N0} — same unit, exact partition");
|
||||
|
||||
// ---- the unified ranking: one list, both termini, descending by contributing cells ----
|
||||
var ranked = new List<RiverCandidate>();
|
||||
foreach (var c in sea) if (!c.SuppressedBySeparation) ranked.Add(c);
|
||||
ranked.AddRange(endo);
|
||||
ranked.Sort((a, b) => b.DrainagePx.CompareTo(a.DrainagePx));
|
||||
for (int i = 0; i < ranked.Count; i++) ranked[i].Rank = i + 1;
|
||||
r.Ranked = ranked;
|
||||
return r;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -544,48 +429,9 @@ namespace IslaApocalypse.Tools
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bind each promotable candidate to the <c>Trunk</c> / <c>Giant</c> the analysis already
|
||||
/// traced, so the plates draw REAL upland stems rather than anything reimplemented here.
|
||||
/// Sea binds by outlet cell (identical greedy pick, identical order); endorheic binds by
|
||||
/// BASIN ID — not by terminal coordinates, because a flat basin floor can have several cells
|
||||
/// at the minimum height and the analysis's DFS tie-break need not match a row-major scan.
|
||||
/// </summary>
|
||||
/// <summary>Delegated to <see cref="RiverCandidates.BindCourses"/> — see the note on Enumerate.</summary>
|
||||
private static void BindCourses(SeedResult r, DrainageAnalysis.Plan plan, int n, List<RiverCandidate> need, string what)
|
||||
{
|
||||
var byOutlet = new Dictionary<int, DrainageAnalysis.Trunk>();
|
||||
foreach (var t in plan.Trunks) byOutlet[(int)t.Outlet.x * n + (int)t.Outlet.y] = t;
|
||||
var byBasin = new Dictionary<int, DrainageAnalysis.Giant>();
|
||||
foreach (var g in plan.Giants)
|
||||
{
|
||||
int cell = (int)g.Terminal.x * n + (int)g.Terminal.y;
|
||||
int id = plan.BasinId[cell];
|
||||
if (id > 0 && !byBasin.ContainsKey(id)) byBasin[id] = g;
|
||||
}
|
||||
|
||||
int missing = 0;
|
||||
foreach (var c in need)
|
||||
{
|
||||
if (c.Course != null) continue;
|
||||
if (c.IsSea)
|
||||
{
|
||||
if (byOutlet.TryGetValue(c.Cell, out var t)) { c.Course = t.Course; c.TermX = (int)t.Outlet.x; c.TermY = (int)t.Outlet.y; }
|
||||
}
|
||||
else
|
||||
{
|
||||
// ⚠ Take the RIVER's terminus from the Giant, not the basin minimum this candidate is
|
||||
// keyed on — see RiverCandidate.TermX. They differ on a flat basin floor, and marking
|
||||
// the wrong one draws every endorheic stem detached from its own endpoint.
|
||||
if (byBasin.TryGetValue(c.BasinId, out var g)) { c.Course = g.Course; c.TermX = (int)g.Terminal.x; c.TermY = (int)g.Terminal.y; }
|
||||
}
|
||||
if (c.Course == null) missing++;
|
||||
}
|
||||
if (missing > 0)
|
||||
throw new InvalidOperationException(
|
||||
$"[RiverPromotion] {missing} of {need.Count} candidates in {what} have no traced stem. The analysis's " +
|
||||
"reporting caps are what produce the courses, so they must cover every candidate being drawn — " +
|
||||
"raise ISLA_PROMOTE_MAX. Refusing to render a plate with rivers drawn as bare markers.");
|
||||
}
|
||||
=> RiverCandidates.BindCourses(plan, n, need, what);
|
||||
|
||||
// ═══ ⭐⭐ THE COMPOSITION (rivers/02b) — the same N, two mixes ════════════════════════════
|
||||
|
||||
|
|
|
|||
400
Tools/Scripts/RiverRouting.cs
Normal file
400
Tools/Scripts/RiverRouting.cs
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using IslaApocalypse.Core;
|
||||
|
||||
namespace IslaApocalypse.Tools
|
||||
{
|
||||
/// <summary>
|
||||
/// ⭐⭐ LOWLAND ROUTING (rivers/03) — the ROUTING PORTION of the reference's `RiverCarvePass`,
|
||||
/// ported faithfully (D-050). **Courses only. This file reads heights and writes none.**
|
||||
///
|
||||
/// ═══ ⛔ THE RED LINE ═══
|
||||
///
|
||||
/// **Nothing here fills water, creates a water body, or mutates any height field.** It produces
|
||||
/// polylines. The bed CARVE (`CarveRiver`, mutates render height, flood-guarded) and the STEPPED
|
||||
/// WATER model (`AddSteppedWater`, creates bodies) are the reference's separate stages and are
|
||||
/// separate later tasks. Verified at rivers/03 Part 0: in the reference, routing is pure — the
|
||||
/// carve mutates, and `AddSteppedWater` is a call the CALLER makes afterwards, not something
|
||||
/// `Apply` does. Lake-enders target EXISTING classify water; no lake is ever created.
|
||||
///
|
||||
/// ═══ ⭐ WHY THE COST MODEL IS THE LOAD-BEARING PIECE ═══
|
||||
///
|
||||
/// **An endorheic terminal is a local minimum by definition** — a downhill path out of it does not
|
||||
/// exist, so "can it flow to the sea?" cannot be answered by descent. It is answered by cost: the
|
||||
/// cheapest LOWGROUND path is allowed to climb over the basin's rim, paying heavily for it
|
||||
/// (uphill penalised, never forbidden). That is the route-version of an overflow channel — a
|
||||
/// channel over the spill, **with no water filled**.
|
||||
///
|
||||
/// SHORT cost ≈ distance, uphill lightly penalised — heads direct, avoids walls. (Rejected
|
||||
/// by the reference's own gate as "a dead-straight canal"; ported for completeness.)
|
||||
/// LOWGROUND cost ≈ BEING high (per px of travel) plus heavily for CLIMBING, so the cheapest
|
||||
/// corridor is the lowest ground even when that wanders. **The locked style.**
|
||||
///
|
||||
/// ═══ ⚠⚠ THE CONSTANTS ARE DECLARED == EFFECTIVE, AND THAT WAS CHECKED ═══
|
||||
///
|
||||
/// `00_ground` warned that the reference's effective river tunables live in `ConfigManager`, not in
|
||||
/// the `Params` initializers (WidthScale 1.0→1.75, DepthScale 1.0→1.5). **Those are carve-time and
|
||||
/// out of scope here.** The four ROUTING cost constants below are `private const` inside
|
||||
/// `RiverCarvePass` with no `ConfigManager` key and no `[Export]` anywhere in the reference repo —
|
||||
/// verified by grep at rivers/03 Part 0 — so for routing, declared IS effective. The one routing
|
||||
/// value that does come from config is the STYLE, effective `"lowground"`, which equals the
|
||||
/// declared default.
|
||||
/// </summary>
|
||||
public static class RiverRouting
|
||||
{
|
||||
public const byte StyleShort = 0;
|
||||
public const byte StyleLowground = 1;
|
||||
|
||||
// ⚠ Ported verbatim. SHORT pays lightly for climbing (8 per metre of rise, so a 10 m wall costs
|
||||
// like an 80 px detour). LOWGROUND pays for BEING high (1 per metre of elevation per px) plus
|
||||
// heavily for climbing (50 per metre).
|
||||
public const float ShortUphillPerM = 8f;
|
||||
public const float LowgroundElevPerM = 1f;
|
||||
public const float LowgroundBase = 0.05f;
|
||||
public const float LowgroundUphillPerM = 50f;
|
||||
|
||||
/// <summary>The reference's smallest water body a lake-ender may target (`RiverLakeMinTargetPx`,
|
||||
/// effective 20,000 — declared and config agree). "Nearest wet pixel" routed one into a 3-cell
|
||||
/// puddle a few hundred px short of the obvious lagoon; that was the task-23 gate finding.</summary>
|
||||
public const int LakeMinTargetPx = 20_000;
|
||||
|
||||
// 8-connectivity in the reference's exact order — the tie-break structure is part of the result.
|
||||
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 };
|
||||
private static readonly float[] DIST = {
|
||||
1.41421356f, 1f, 1.41421356f, 1f, 1f, 1.41421356f, 1f, 1.41421356f };
|
||||
|
||||
/// <summary>One lowland route, with the diagnostics the gate needs to judge it.</summary>
|
||||
public sealed class Route
|
||||
{
|
||||
/// <summary>Terminal → target, 1-px steps, as Dijkstra produced it. Empty when no path exists.</summary>
|
||||
public List<(float x, float y)> Path = new();
|
||||
/// <summary>The same reach after RDP + Chaikin. This is what is drawn and spliced.</summary>
|
||||
public List<(float x, float y)> Smoothed = new();
|
||||
/// <summary>⭐ Did a path exist at all? Empty list on no path — never thrown.</summary>
|
||||
public bool Reached;
|
||||
/// <summary>Dijkstra cost at the goal (cost-model units, not metres).</summary>
|
||||
public float Cost;
|
||||
/// <summary>⭐⭐ THE RIM: the largest single-step climb on the route, metres. The number that
|
||||
/// says whether a route crawls over a saddle or vaults a wall.</summary>
|
||||
public float MaxStepUphillM;
|
||||
/// <summary>⭐ Total metres climbed along the route, and how many steps climbed at all.</summary>
|
||||
public float TotalUphillM;
|
||||
public int UphillSteps;
|
||||
/// <summary>Highest point on the route, metres above sea — the rim's absolute height.</summary>
|
||||
public float MaxElevM;
|
||||
/// <summary>Net climb from the terminal to the route's high point, metres — what "over the rim" costs.</summary>
|
||||
public float RimClimbM;
|
||||
/// <summary>⭐ WHERE the route tops out — the rim cell, ringed on the plate.</summary>
|
||||
public (float x, float y) RimPoint;
|
||||
public float LenPx, StraightPx, WanderRatio;
|
||||
/// <summary>Cells settled by the search — the honest cost of a Dijkstra at this map size.</summary>
|
||||
public long Expanded;
|
||||
public (float x, float y) Target;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ⭐ Deterministic Dijkstra from a start cell to the nearest cell of <paramref name="targets"/>
|
||||
/// under the selected cost model. Ported from `RiverCarvePass.RouteToOcean`.
|
||||
///
|
||||
/// ⚠ **Returns an empty path when no path exists — it never throws.** That contract is
|
||||
/// load-bearing: "no affordable route" is a RESULT (the river is a lake-ender), not an error.
|
||||
///
|
||||
/// ⚠ `targets` is a generic mask: `OceanMask` for a route to the sea, significant-water for a
|
||||
/// lake-ender's extension. One routine, two uses — as the reference has it.
|
||||
///
|
||||
/// Determinism: the priority is `(cost, cellIndex)`, so equal costs break on the lower index and
|
||||
/// the result cannot depend on heap internals. The search settles a cell once (`closed`) and
|
||||
/// stops the moment it DEQUEUES a target, so the first target reached is the cheapest.
|
||||
/// </summary>
|
||||
public static Route RouteTo(float[,] height, int n, bool[] targets, int sx, int sy, byte style, float sea)
|
||||
{
|
||||
int total = n * n;
|
||||
var gcost = new float[total];
|
||||
var parent = new int[total];
|
||||
var closed = new bool[total];
|
||||
Array.Fill(gcost, float.MaxValue);
|
||||
Array.Fill(parent, -1);
|
||||
|
||||
// ⚠ Elevation is clamped at sea: below-sea ground is not "cheaper than sea level", it is sea
|
||||
// level. Without the clamp a route would dive for the deepest hole it could find.
|
||||
float ElevM(int x, int y) => MathF.Max(0f, WorldScale.MetresFromRaw(height[x, y] - sea));
|
||||
|
||||
var pq = new PriorityQueue<int, (float c, int i)>();
|
||||
int start = sx * n + sy;
|
||||
gcost[start] = 0f;
|
||||
pq.Enqueue(start, (0f, start));
|
||||
int goal = -1;
|
||||
long expanded = 0;
|
||||
|
||||
while (pq.Count > 0)
|
||||
{
|
||||
int c = pq.Dequeue();
|
||||
if (closed[c]) continue;
|
||||
closed[c] = true;
|
||||
expanded++;
|
||||
if (targets[c]) { goal = c; break; }
|
||||
int cx = c / n, cy = c % n;
|
||||
float hc = height[cx, 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 (closed[ni]) continue;
|
||||
float dhM = MathF.Max(0f, WorldScale.MetresFromRaw(height[nx, ny] - hc));
|
||||
float step = style == StyleShort
|
||||
? DIST[k] + dhM * ShortUphillPerM
|
||||
: DIST[k] * (LowgroundBase + ElevM(nx, ny) * LowgroundElevPerM)
|
||||
+ dhM * LowgroundUphillPerM;
|
||||
float nc = gcost[c] + step;
|
||||
if (nc < gcost[ni])
|
||||
{
|
||||
gcost[ni] = nc;
|
||||
parent[ni] = c;
|
||||
pq.Enqueue(ni, (nc, ni));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var r = new Route { Expanded = expanded };
|
||||
if (goal < 0) return r; // no path — an empty route, reported upstream
|
||||
|
||||
for (int c = goal; c >= 0; c = parent[c]) r.Path.Add((c / n, c % n));
|
||||
r.Path.Reverse();
|
||||
r.Reached = true;
|
||||
r.Cost = gcost[goal];
|
||||
r.Target = r.Path[^1];
|
||||
Measure(r, height, n, sea);
|
||||
r.Smoothed = SmoothCourse(r.Path);
|
||||
return r;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The diagnostics the gate reads — measured on the RAW path, before smoothing, because the
|
||||
/// rim it crossed is a fact about the terrain and must not be a function of the pretty pass.
|
||||
/// </summary>
|
||||
private static void Measure(Route r, float[,] height, int n, float sea)
|
||||
{
|
||||
float startElev = ElevAt(r.Path[0]);
|
||||
float maxElev = startElev;
|
||||
r.RimPoint = r.Path[0];
|
||||
for (int i = 1; i < r.Path.Count; i++)
|
||||
{
|
||||
var a = r.Path[i - 1]; var b = r.Path[i];
|
||||
float dx = b.x - a.x, dy = b.y - a.y;
|
||||
r.LenPx += MathF.Sqrt(dx * dx + dy * dy);
|
||||
float climb = ElevAt(b) - ElevAt(a);
|
||||
if (climb > 0f) { r.TotalUphillM += climb; r.UphillSteps++; }
|
||||
if (climb > r.MaxStepUphillM) r.MaxStepUphillM = climb;
|
||||
if (ElevAt(b) > maxElev) { maxElev = ElevAt(b); r.RimPoint = b; }
|
||||
}
|
||||
r.MaxElevM = maxElev;
|
||||
r.RimClimbM = maxElev - startElev;
|
||||
var s = r.Path[0]; var e = r.Path[^1];
|
||||
r.StraightPx = MathF.Sqrt((e.x - s.x) * (e.x - s.x) + (e.y - s.y) * (e.y - s.y));
|
||||
// ⚠ Wander is POLYLINE length over straight-line — a cell count undercounts diagonal steps
|
||||
// and can read below 1, which is geometrically impossible. (The reference's own fix.)
|
||||
r.WanderRatio = r.StraightPx > 1f ? r.LenPx / r.StraightPx : 1f;
|
||||
|
||||
float ElevAt((float x, float y) p) =>
|
||||
MathF.Max(0f, WorldScale.MetresFromRaw(height[(int)p.x, (int)p.y] - sea));
|
||||
}
|
||||
|
||||
// ---- Route smoothing — ported verbatim: RDP(4.0) + 4 Chaikin passes, endpoints pinned ------
|
||||
//
|
||||
// ⚠⚠ THIS IS APPLIED TO THE LOWLAND REACH ONLY, NEVER THE UPLAND STEM, and that split is not a
|
||||
// style preference — it is a measured result. The Dijkstra's 45° kinks live on near-flat ground
|
||||
// where a rounded corner costs nothing. The upland stems already thread the erosion-carved
|
||||
// valley FLOORS; smoothing them cuts the corners off the valleys themselves, which in the
|
||||
// reference took the max cut from 14.6 m to 27.3 m.
|
||||
|
||||
/// <summary>RDP tol 4 + 4 Chaikin corner-cutting passes, endpoints pinned.</summary>
|
||||
public static List<(float x, float y)> SmoothCourse(List<(float x, float y)> raw)
|
||||
{
|
||||
if (raw.Count < 3) return raw;
|
||||
var dec = Rdp(raw, 0, raw.Count - 1, 4.0f);
|
||||
if (dec.Count < 3) return raw;
|
||||
var sm = dec;
|
||||
for (int pass = 0; pass < 4; pass++)
|
||||
{
|
||||
var nxt = new List<(float x, float y)>(sm.Count * 2) { sm[0] };
|
||||
for (int i = 0; i + 1 < sm.Count; i++)
|
||||
{
|
||||
var a = sm[i]; var b = sm[i + 1];
|
||||
nxt.Add((a.x * 0.75f + b.x * 0.25f, a.y * 0.75f + b.y * 0.25f));
|
||||
nxt.Add((a.x * 0.25f + b.x * 0.75f, a.y * 0.25f + b.y * 0.75f));
|
||||
}
|
||||
nxt.Add(sm[^1]);
|
||||
sm = nxt;
|
||||
}
|
||||
return sm;
|
||||
}
|
||||
|
||||
private static List<(float x, float y)> Rdp(List<(float x, float y)> pts, int i0, int i1, float tol)
|
||||
{
|
||||
if (i1 - i0 <= 1) return new List<(float x, float y)> { pts[i0], pts[i1] };
|
||||
var a = pts[i0]; var b = pts[i1];
|
||||
float abx = b.x - a.x, aby = b.y - a.y;
|
||||
float abLen = MathF.Sqrt(abx * abx + aby * aby);
|
||||
float maxD = 0f; int maxI = i0;
|
||||
for (int i = i0 + 1; i < i1; i++)
|
||||
{
|
||||
float d = abLen < 1e-6f
|
||||
? MathF.Sqrt((pts[i].x - a.x) * (pts[i].x - a.x) + (pts[i].y - a.y) * (pts[i].y - a.y))
|
||||
: MathF.Abs(abx * (a.y - pts[i].y) - (a.x - pts[i].x) * aby) / abLen;
|
||||
if (d > maxD) { maxD = d; maxI = i; }
|
||||
}
|
||||
if (maxD <= tol) return new List<(float x, float y)> { pts[i0], pts[i1] };
|
||||
var left = Rdp(pts, i0, maxI, tol);
|
||||
var right = Rdp(pts, maxI, i1, tol);
|
||||
left.RemoveAt(left.Count - 1);
|
||||
left.AddRange(right);
|
||||
return left;
|
||||
}
|
||||
|
||||
/// <summary>The three classes the MIX is made of.</summary>
|
||||
public enum RiverClass
|
||||
{
|
||||
/// <summary>Sea-reaching already, exactly as erosion carved it. No lowland route needed.</summary>
|
||||
OceanTrunk,
|
||||
/// <summary>An endorheic basin connected to the coast by a routed over-the-rim channel.</summary>
|
||||
RoutedGiant,
|
||||
/// <summary>Stays inland: terminates at a significant lake, or at its own terminal.</summary>
|
||||
LakeEnder,
|
||||
}
|
||||
|
||||
/// <summary>One promoted river, classified, routed and assembled.</summary>
|
||||
public sealed class RoutedRiver
|
||||
{
|
||||
public RiverCandidate Candidate;
|
||||
public RiverClass Class;
|
||||
/// <summary>The lowland reach actually used: the ocean route for a routed giant, the lake
|
||||
/// route for a lake-ender. Null for trunks.</summary>
|
||||
public Route Lowland;
|
||||
/// <summary>⭐ The ocean route computed for EVERY giant, including lake-enders — see the note
|
||||
/// on <see cref="RouteAll"/>. This is what makes an affordability threshold judgeable.</summary>
|
||||
public Route OceanProbe;
|
||||
/// <summary>Lake-enders: did the extension reach a SIGNIFICANT body (vs the classify fallback, vs nothing)?</summary>
|
||||
public bool LakeReached, LakeWasFallback;
|
||||
/// <summary>Head → terminus, stem + smoothed lowland reach.</summary>
|
||||
public List<(float x, float y)> Course;
|
||||
public string Why = "";
|
||||
|
||||
public bool ReachesSea => Class == RiverClass.OceanTrunk || Class == RiverClass.RoutedGiant;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ⭐⭐ CLASSIFY AND ROUTE THE PROMOTED SET.
|
||||
///
|
||||
/// ═══ ⚠⚠⚠ WHAT DECIDES routed-vs-lake-ender, AND WHY IT IS NOT A PATH TEST ═══
|
||||
///
|
||||
/// rivers/03's task states the sort as *"an affordable over-the-rim LOWGROUND path to the ocean
|
||||
/// exists → routed-through; none → lake-ender."* **Ported literally, that test classifies
|
||||
/// everything as routed, because on an 8-connected grid with all-finite costs a path to the
|
||||
/// ocean ALWAYS exists.** `RouteTo` returns empty only when the queue drains without reaching a
|
||||
/// target, which cannot happen when the ocean is reachable at *some* price. There is no "none".
|
||||
/// The word doing the work is *affordable*, and no threshold is specified anywhere.
|
||||
///
|
||||
/// **So the reference's sort is used, because it is the one that actually discriminates:**
|
||||
/// <code>
|
||||
/// Kind = (basinHasLake[id] && !SouthernCandidate) ? "lake-ender" : "routed"
|
||||
/// </code>
|
||||
/// i.e. **does the terminal basin hold classify water?** A basin that is already a lake is a
|
||||
/// natural lake-ender; a dry pan gets routed to the sea. That is `DrainageAnalysis`'s own
|
||||
/// verdict, carried on `Giant.Kind`, and this port consumes it rather than inventing a rule.
|
||||
/// (v2 has no towns, so `southernPick` is −1 and the southern override never fires.)
|
||||
///
|
||||
/// ⭐ **And the missing threshold is surfaced rather than guessed:** the ocean route is computed
|
||||
/// for EVERY giant, lake-enders included (<see cref="RoutedRiver.OceanProbe"/>), so the batch can
|
||||
/// report what each one WOULD cost and how high a rim it WOULD have to cross. That turns
|
||||
/// "affordable" from an unstated assumption into a number the developer can put a bar under.
|
||||
/// **Nothing is locked here — the classification shown is the reference's.**
|
||||
/// </summary>
|
||||
public static List<RoutedRiver> RouteAll(List<RiverCandidate> promoted, float[,] height, int n,
|
||||
bool[] isOcean, bool[] isClassifyWater, bool[] isSignificantWater, float sea, byte style,
|
||||
Action<string> log)
|
||||
{
|
||||
var outp = new List<RoutedRiver>();
|
||||
foreach (var c in promoted)
|
||||
{
|
||||
var rr = new RoutedRiver { Candidate = c };
|
||||
|
||||
if (c.IsSea)
|
||||
{
|
||||
// A natural ocean trunk needs no lowland route: erosion already carried it to the
|
||||
// coast, and its outlet is ON the coast by construction. The stem IS the course.
|
||||
rr.Class = RiverClass.OceanTrunk;
|
||||
rr.Course = new List<(float x, float y)>(c.Course);
|
||||
rr.Course.Reverse();
|
||||
rr.Why = "sea outlet — erosion already reaches the coast; no lowland route needed";
|
||||
outp.Add(rr);
|
||||
log($" #{c.Rank,-3} {c.DrainagePx,10:N0} px TRUNK (natural, {rr.Course.Count} pts)");
|
||||
continue;
|
||||
}
|
||||
|
||||
// ⭐ The ocean probe, for every giant — the affordability evidence.
|
||||
var probe = RouteTo(height, n, isOcean, c.TermX, c.TermY, style, sea);
|
||||
rr.OceanProbe = probe;
|
||||
|
||||
bool refLakeEnder = c.AnalysisKind == "lake-ender";
|
||||
if (!refLakeEnder)
|
||||
{
|
||||
rr.Class = RiverClass.RoutedGiant;
|
||||
rr.Lowland = probe;
|
||||
rr.Why = probe.Reached
|
||||
? $"dry pan → routed; rim climb {probe.RimClimbM:F1} m, max step {probe.MaxStepUphillM:F2} m, cost {probe.Cost:N0}"
|
||||
: "dry pan → routed, but NO path to the ocean was found (unexpected — report)";
|
||||
}
|
||||
else
|
||||
{
|
||||
rr.Class = RiverClass.LakeEnder;
|
||||
// The stem pools on dry ground short of its lake BECAUSE the pooling point is a local
|
||||
// minimum — a blind descent dead-ends there immediately. Route to the nearest
|
||||
// SIGNIFICANT body with the same lowground Dijkstra, so the course joins the lake.
|
||||
// ⚠ Lake-enders route with LOWGROUND regardless of the style knob (the reference's rule).
|
||||
var ext = RouteTo(height, n, isSignificantWater, c.TermX, c.TermY, StyleLowground, sea);
|
||||
if (!ext.Reached)
|
||||
{
|
||||
// Fall back to ANY classify water, so a seed whose lake-ender genuinely has only
|
||||
// small ponds still connects rather than dead-ending.
|
||||
var fb = RouteTo(height, n, isClassifyWater, c.TermX, c.TermY, StyleLowground, sea);
|
||||
if (fb.Reached) { ext = fb; rr.LakeWasFallback = true; }
|
||||
}
|
||||
if (ext.Reached) { rr.Lowland = ext; rr.LakeReached = true; }
|
||||
rr.Why = rr.LakeReached
|
||||
? $"terminal basin holds classify water → lake-ender; joins {(rr.LakeWasFallback ? "a small body (fallback)" : "a significant body")} {ext.LenPx:F0} px away"
|
||||
: "terminal basin holds classify water → lake-ender; no water body reachable, course ends at its terminal";
|
||||
}
|
||||
|
||||
rr.Course = Assemble(c.Course, rr.Lowland);
|
||||
outp.Add(rr);
|
||||
log($" #{c.Rank,-3} {c.DrainagePx,10:N0} px {(rr.Class == RiverClass.RoutedGiant ? "ROUTED " : "LAKE-ENDER")} " +
|
||||
$"probe{(probe.Reached ? $" reached cost {probe.Cost,12:N0} rim {probe.RimClimbM,6:F1} m maxstep {probe.MaxStepUphillM,5:F2} m len {probe.LenPx,6:F0} px wander {probe.WanderRatio:F2} expanded {probe.Expanded:N0}" : " NO PATH")}" +
|
||||
$"{(rr.Class == RiverClass.LakeEnder ? $" | lake {(rr.LakeReached ? (rr.LakeWasFallback ? "fallback" : "significant") : "NONE")}" : "")}");
|
||||
}
|
||||
return outp;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ⭐ Assemble one river's full course: upland stem (head → terminal) + the smoothed lowland
|
||||
/// reach (terminal → target).
|
||||
///
|
||||
/// ⚠ `Course` from the analysis is DOWNSTREAM-FIRST and decimated ×4, so it is reversed to run
|
||||
/// head → terminal, exactly as the reference does. The route's first point IS the terminal, so
|
||||
/// it is skipped when splicing — otherwise the join carries a duplicate vertex.
|
||||
///
|
||||
/// ⚠ The reference then DENSIFIES the spliced polyline to ~1-px samples. That is done inside
|
||||
/// `CarveRiver`, for the bed stamp — it is carve-time and deliberately not done here: this task
|
||||
/// produces courses, and a densified polyline draws and measures identically.
|
||||
/// </summary>
|
||||
public static List<(float x, float y)> Assemble(List<(float x, float y)> uplandStem, Route lowland)
|
||||
{
|
||||
var pts = new List<(float x, float y)>(uplandStem);
|
||||
pts.Reverse(); // downstream-first → head → terminal
|
||||
if (lowland != null && lowland.Smoothed != null && lowland.Smoothed.Count > 1)
|
||||
pts.AddRange(lowland.Smoothed.GetRange(1, lowland.Smoothed.Count - 1));
|
||||
return pts;
|
||||
}
|
||||
}
|
||||
}
|
||||
649
Tools/Scripts/RiverRoutingTool.cs
Normal file
649
Tools/Scripts/RiverRoutingTool.cs
Normal file
|
|
@ -0,0 +1,649 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using Godot;
|
||||
using IslaApocalypse.Core;
|
||||
|
||||
namespace IslaApocalypse.Tools
|
||||
{
|
||||
/// <summary>
|
||||
/// ⭐⭐ LOWLAND ROUTING (rivers/03) — route the promoted rivers, and make the MIX visible.
|
||||
///
|
||||
/// ═══ WHAT THIS TASK IS FOR ═══
|
||||
///
|
||||
/// rivers/02 promoted a set; this makes each member reach its true terminus, and shows the split the
|
||||
/// developer asked to see clearly:
|
||||
///
|
||||
/// NATURAL OCEAN TRUNKS sea-reaching already, left exactly as erosion made them
|
||||
/// ROUTED-THROUGH GIANTS endorheic basins connected to the coast over their rim
|
||||
/// INLAND LAKE-ENDERS staying inland, joining a significant water body
|
||||
///
|
||||
/// The judgment it feeds: **does routing the giants yield enough substantial, well-spread coastal
|
||||
/// rivers to dissolve the parked sea-river quota (rivers/02b), or not?**
|
||||
///
|
||||
/// ═══ ⛔ THE RED LINE — COURSES ONLY ═══
|
||||
///
|
||||
/// **No height is mutated. No water is filled. Nothing is carved.** This ports the ROUTING PORTION
|
||||
/// of the reference's `RiverCarvePass` — `RouteToOcean`, the routed/lake-ender sort, `SmoothCourse`
|
||||
/// — and deliberately NOT `CarveRiver` (bed stamp) or `AddSteppedWater` (water bodies), which are
|
||||
/// separate later tasks. The tool ASSERTS both height fields are unchanged across routing, in the
|
||||
/// flood-guard discipline erosion and drainage established: a claim that is checked, not promised.
|
||||
///
|
||||
/// ═══ RUNNING IT ═══
|
||||
///
|
||||
/// xvfb-run -a Godot_v4.7.2-stable_mono_linux.x86_64 \
|
||||
/// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/RiverRoutingTool.tscn
|
||||
///
|
||||
/// ISLA_TASK / ISLA_BATCH / ISLA_CHAT / ISLA_MAPSIZE / ISLA_CALIB_SIZE / ISLA_SEEDS / ISLA_SKIP_RAW
|
||||
/// ISLA_PROMOTE_N the promoted count to route (default 12 — the PURE set, no quota)
|
||||
/// ISLA_PROMOTE_FLOOR_PX candidate significance floor (default 5000, as rivers/02)
|
||||
/// ISLA_PROMOTE_MAX analysis reporting caps, so every promoted river has a traced stem (24)
|
||||
/// ISLA_ROUTING_STYLE "lowground" (default, the locked style) | "short"
|
||||
/// ISLA_LAKE_MIN_TARGET_PX significant-water threshold (default 20000, the reference's effective)
|
||||
/// </summary>
|
||||
public partial class RiverRoutingTool : Node
|
||||
{
|
||||
private static readonly int[] DefaultSeeds = { 1063685222, 999999937, 31415926, 14142135 };
|
||||
private static readonly int[] CalibrationSeeds = { 1063685222, 20260819, 777001, 424242, 90210, 31337 };
|
||||
private const int DefaultMapSize = 8192;
|
||||
private const int DefaultCalibSize = 2048;
|
||||
|
||||
public override void _Ready()
|
||||
{
|
||||
// ⚠ An exception out of _Ready does NOT stop Godot — it logs and the process sits with no
|
||||
// main loop to end it, so a misconfigured run HANGS. Catch, say what was refused, exit 2.
|
||||
try { Run(); }
|
||||
catch (Exception e)
|
||||
{
|
||||
GD.PrintErr("==================================================================");
|
||||
GD.PrintErr($" REFUSED: {e.Message}");
|
||||
GD.PrintErr(e.StackTrace);
|
||||
GD.PrintErr("==================================================================");
|
||||
GetTree().Quit(2);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SeedResult
|
||||
{
|
||||
public int Seed;
|
||||
public List<RiverRouting.RoutedRiver> Rivers;
|
||||
public int Trunks, Routed, Lakes;
|
||||
public int SeaReaching;
|
||||
/// <summary>⭐ DISTINCT ocean mouth cells — routes are computed per giant with nothing
|
||||
/// coordinating them, so two can land on the same cell. This is the honest river count.</summary>
|
||||
public int DistinctMouths;
|
||||
/// <summary>Pairs of sea-reaching rivers sharing a mouth cell, as "#a+#b".</summary>
|
||||
public List<string> SharedMouths = new();
|
||||
public long LandCells, EndorheicCells;
|
||||
public int CandidateCount, SeaCandidates, EndoCandidates;
|
||||
public int WaterBodiesKept, WaterBodiesTotal;
|
||||
public long WaterCellsKept, LargestWaterPx;
|
||||
public double RoutingSeconds;
|
||||
public long TotalExpanded;
|
||||
public string Spread = "";
|
||||
public ulong Ms;
|
||||
}
|
||||
|
||||
private void Run()
|
||||
{
|
||||
ToolingPaths.Configure(OS.GetUserDataDir());
|
||||
ToolingPaths.ConfigureChat(EnvStr(ToolingPaths.ChatVar, "rivers"));
|
||||
|
||||
int task = EnvInt("ISLA_TASK", 3);
|
||||
string descr = EnvStr("ISLA_BATCH", "lowland_routing");
|
||||
int mapSize = EnvInt("ISLA_MAPSIZE", DefaultMapSize);
|
||||
int calibSize = EnvInt("ISLA_CALIB_SIZE", DefaultCalibSize);
|
||||
int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds);
|
||||
long floorPx = EnvInt("ISLA_PROMOTE_FLOOR_PX", 5000);
|
||||
int promoteN = EnvInt("ISLA_PROMOTE_N", 12);
|
||||
int promoteMax = EnvInt("ISLA_PROMOTE_MAX", 24);
|
||||
int lakeMinPx = EnvInt("ISLA_LAKE_MIN_TARGET_PX", RiverRouting.LakeMinTargetPx);
|
||||
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "1") == "1";
|
||||
string styleS = EnvStr("ISLA_ROUTING_STYLE", "lowground").Trim().ToLowerInvariant();
|
||||
if (styleS != "lowground" && styleS != "short")
|
||||
throw new InvalidOperationException($"ISLA_ROUTING_STYLE '{styleS}' — expected 'lowground' (the locked style) or 'short'.");
|
||||
byte style = styleS == "short" ? RiverRouting.StyleShort : RiverRouting.StyleLowground;
|
||||
|
||||
if (promoteMax < promoteN)
|
||||
throw new InvalidOperationException(
|
||||
$"ISLA_PROMOTE_MAX ({promoteMax}) is below the promoted count ({promoteN}). The cap is what makes the " +
|
||||
"analysis trace a real upland stem for each promoted river; below it, a river would have no course to route from.");
|
||||
|
||||
// ⚠ rivers/01: the shape AND erosion come from the bare defaults. Assert before generating.
|
||||
TerrainShapeV1.Assert("RiverRouting");
|
||||
TerrainShapeV1.AssertErosionDefaultOn("RiverRouting");
|
||||
|
||||
string batchRoot = ToolingPaths.BatchRoot(task, descr);
|
||||
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
|
||||
DirAccess.MakeDirRecursiveAbsolute(ToolingPaths.BatchScratch(batchRoot));
|
||||
|
||||
var anchors = CurveAnchors.Default;
|
||||
float sea = 0.15f;
|
||||
|
||||
// Reporting caps raised so every promoted river has a traced stem — exactly as rivers/02.
|
||||
// ⚠⚠ The ENUMERATION gates (EndorheicMinDepthM / EndorheicMinAreaPx) are NOT touched: they
|
||||
// decide which depressions BECOME terminal basins, i.e. they define the routing surface.
|
||||
var dp = new DrainageAnalysis.Params
|
||||
{
|
||||
SeaLevel = sea,
|
||||
TrunkCount = promoteMax,
|
||||
GiantCount = promoteMax,
|
||||
EndorheicMaxCount = promoteMax,
|
||||
EndorheicMinInflowPx = (int)floorPx,
|
||||
};
|
||||
var dpDefaults = new DrainageAnalysis.Params();
|
||||
|
||||
GD.Print("==================================================================");
|
||||
GD.Print(" LOWLAND ROUTING (rivers/03) — the routed MIX on the pure top-N, COURSES ONLY");
|
||||
GD.Print("==================================================================");
|
||||
GD.Print($"MapSize : {mapSize} curve calibrated at {calibSize}");
|
||||
GD.Print($"terrain : {TerrainShapeV1.Describe()} + erosion ON by default");
|
||||
GD.Print($"seeds : {seeds.Length} — {string.Join(", ", seeds)}");
|
||||
GD.Print($"promoted : PURE top {promoteN} by drainage (unified ranking, rivers/02). NO QUOTA — rivers/02b's K stays parked and unlocked.");
|
||||
GD.Print($"style : {styleS.ToUpperInvariant()} — cost = DIST x ({RiverRouting.LowgroundBase} + elevM x {RiverRouting.LowgroundElevPerM}) + climbM x {RiverRouting.LowgroundUphillPerM} (effective == declared; verified no ConfigManager/[Export] override)");
|
||||
GD.Print($"sort : the REFERENCE's — Kind = (basinHasLake && !southern) ? lake-ender : routed.");
|
||||
GD.Print($" ⚠ NOT a path test: on an 8-connected grid a path to the ocean ALWAYS exists, so");
|
||||
GD.Print($" \"a path exists → routed\" would classify everything as routed. The ocean route is");
|
||||
GD.Print($" still probed for EVERY giant so the missing affordability threshold is a number, not a guess.");
|
||||
GD.Print($"lake target: significant water = 8-connected classify-water components >= {lakeMinPx:N0} px (interim for v2's missing water-bodies table)");
|
||||
GD.Print($"⛔ RED LINE : courses only — no height mutated, no water filled, nothing carved. ASSERTED per seed.");
|
||||
GD.Print($"batch : {batchRoot}");
|
||||
GD.Print("==================================================================");
|
||||
|
||||
if (mapSize != 8192)
|
||||
GD.PrintErr($" ⚠⚠ MAP SIZE {mapSize} — the DrainageAnalysis params are ABSOLUTE PIXEL COUNTS tuned at 8192 " +
|
||||
"(rivers/02). At a smaller size few or no depressions qualify as terminal basins, so the " +
|
||||
"routed/lake-ender SORT cannot be exercised. A smaller run validates the PIPELINE only and " +
|
||||
"MUST NOT be used to judge the mix.");
|
||||
|
||||
GD.Print($"\n--- 0. CURVE (task-01 pool at {calibSize}, family-off pinned) ---");
|
||||
var (knots, calibration) = CalibrateCurve(calibSize, sea, anchors);
|
||||
GD.Print($" {knots}");
|
||||
|
||||
TerrainGenConfig Cfg(int size, int seed) => new TerrainGenConfig
|
||||
{
|
||||
MapSize = size, Seed = seed, VariantLabel = "routing",
|
||||
Curve = true, ShelfDetail = false, CurveMode = CurveModeKind.Continuous,
|
||||
Knots = knots, Anchors = anchors, ClimbCalibration = calibration, LowlandCeilingM = 30f,
|
||||
};
|
||||
|
||||
var results = new List<SeedResult>();
|
||||
foreach (int seed in seeds)
|
||||
{
|
||||
ulong t0 = Time.GetTicksMsec();
|
||||
GD.Print($"\n--- seed {seed} ---");
|
||||
var cfg = Cfg(mapSize, seed);
|
||||
Pass1Result p1 = Topography.Generate(cfg);
|
||||
Pass2Result shaped = Shaping.Shape(p1, cfg);
|
||||
var ero = ErosionPass.Apply(shaped, cfg);
|
||||
Pass2Result p2 = ero.Shaped;
|
||||
|
||||
// ⭐ THE OCEAN IDENTITY — from the region layer, on the CLASSIFY field (→ D-066).
|
||||
bool[] isOcean = RegionLabeling.OceanMask(p2.HeightClassify, mapSize, sea, out long oceanCells, out long enclosed);
|
||||
var isClassifyWater = new bool[mapSize * mapSize];
|
||||
for (int x = 0; x < mapSize; x++)
|
||||
for (int y = 0; y < mapSize; y++)
|
||||
if (p2.HeightClassify[x, y] < sea) isClassifyWater[x * mapSize + y] = true;
|
||||
|
||||
var plan = DrainageAnalysis.Run(p2.Height, mapSize, isOcean, isClassifyWater, -1f, -1f, dp);
|
||||
GD.Print($" land {plan.LandCells:N0} — sea-reaching {plan.SeaReachingCells:N0} ({100.0 * plan.SeaReachingCells / Math.Max(1, plan.LandCells):F1} %), " +
|
||||
$"endorheic {plan.EndorheicCells:N0} ({100.0 * plan.EndorheicCells / Math.Max(1, plan.LandCells):F1} %); terminal basins {plan.TerminalBasinCount}");
|
||||
|
||||
var e = RiverCandidates.Enumerate(plan, p2.Height, mapSize, floorPx, dpDefaults.MinOutletSeparationPx, p1.Regions);
|
||||
var promoted = e.Ranked.GetRange(0, Math.Min(promoteN, e.Ranked.Count));
|
||||
RiverCandidates.BindCourses(plan, mapSize, promoted, $"the pure top {promoteN}");
|
||||
int pSea = 0; foreach (var c in promoted) if (c.IsSea) pSea++;
|
||||
GD.Print($" promoted: pure top {promoted.Count} — {pSea} sea / {promoted.Count - pSea} endorheic (from {e.Ranked.Count} candidates: {e.SeaCount} sea / {e.Ranked.Count - e.SeaCount} endorheic)");
|
||||
|
||||
// ⭐ The interim significant-water mask — v2 has no water-bodies table (a known port gap).
|
||||
bool[] significant = RegionLabeling.SignificantWaterMask(isClassifyWater, isOcean, mapSize,
|
||||
lakeMinPx, out int keptBodies, out int totalBodies, out long keptCells, out long largestPx);
|
||||
GD.Print($" significant water: {keptBodies} of {totalBodies} classify-water bodies >= {lakeMinPx:N0} px ({keptCells:N0} cells; largest body {largestPx:N0} px)");
|
||||
|
||||
// ═══ ⛔ THE RED-LINE GUARD — snapshot both height fields BEFORE routing ═══
|
||||
ulong hRenderBefore = Digest(p2.Height, mapSize);
|
||||
ulong hClassifyBefore = Digest(p2.HeightClassify, mapSize);
|
||||
|
||||
GD.Print($" routing (style {styleS}) — probing the ocean for every giant:");
|
||||
ulong tr0 = Time.GetTicksMsec();
|
||||
var rivers = RiverRouting.RouteAll(promoted, p2.Height, mapSize, isOcean, isClassifyWater,
|
||||
significant, sea, style, m => GD.Print(m));
|
||||
double routingSec = (Time.GetTicksMsec() - tr0) / 1000.0;
|
||||
|
||||
// ═══ ⛔ …and assert they are byte-identical after ═══
|
||||
ulong hRenderAfter = Digest(p2.Height, mapSize);
|
||||
ulong hClassifyAfter = Digest(p2.HeightClassify, mapSize);
|
||||
if (hRenderAfter != hRenderBefore || hClassifyAfter != hClassifyBefore)
|
||||
throw new InvalidOperationException(
|
||||
"[RiverRouting] RED-LINE VIOLATION: a height field CHANGED across routing.\n" +
|
||||
$" render {hRenderBefore:X16} -> {hRenderAfter:X16}\n" +
|
||||
$" classify {hClassifyBefore:X16} -> {hClassifyAfter:X16}\n" +
|
||||
"This task produces COURSES ONLY — it must never mutate a height, fill water, or carve. " +
|
||||
"The bed carve and the stepped-water model are separate later tasks. Refusing to continue.");
|
||||
GD.Print($" ✅ RED LINE HELD: render {hRenderBefore:X16} and classify {hClassifyBefore:X16} byte-identical across routing — nothing carved, no water filled.");
|
||||
|
||||
var r = new SeedResult
|
||||
{
|
||||
Seed = seed, Rivers = rivers,
|
||||
LandCells = plan.LandCells, EndorheicCells = plan.EndorheicCells,
|
||||
CandidateCount = e.Ranked.Count, SeaCandidates = e.SeaCount,
|
||||
EndoCandidates = e.Ranked.Count - e.SeaCount,
|
||||
WaterBodiesKept = keptBodies, WaterBodiesTotal = totalBodies,
|
||||
WaterCellsKept = keptCells, LargestWaterPx = largestPx,
|
||||
RoutingSeconds = routingSec,
|
||||
};
|
||||
foreach (var rr in rivers)
|
||||
{
|
||||
if (rr.Class == RiverRouting.RiverClass.OceanTrunk) r.Trunks++;
|
||||
else if (rr.Class == RiverRouting.RiverClass.RoutedGiant) r.Routed++;
|
||||
else r.Lakes++;
|
||||
if (rr.OceanProbe != null) r.TotalExpanded += rr.OceanProbe.Expanded;
|
||||
}
|
||||
r.SeaReaching = r.Trunks + r.Routed;
|
||||
MeasureMouths(r, rivers);
|
||||
r.Spread = Spread(rivers, mapSize);
|
||||
r.Ms = Time.GetTicksMsec() - t0;
|
||||
|
||||
GD.Print($" ⭐ MIX: {r.Trunks} natural trunks + {r.Routed} routed-through + {r.Lakes} lake-enders = {rivers.Count}");
|
||||
GD.Print($" → SEA-REACHING RIVERS: {r.SeaReaching}, at {r.DistinctMouths} DISTINCT mouths" +
|
||||
(r.SharedMouths.Count > 0 ? $" ⚠ shared mouth: {string.Join(", ", r.SharedMouths)}" : "") +
|
||||
$" spread: {r.Spread}");
|
||||
GD.Print($" routing {routingSec:F1}s, {r.TotalExpanded:N0} cells settled across all probes");
|
||||
|
||||
WriteRiverCsv(batchRoot, r);
|
||||
RenderSeed(batchRoot, r, isOcean, p2, mapSize, sea, floorPx, promoteN, skipRaw);
|
||||
results.Add(r);
|
||||
}
|
||||
|
||||
WriteIndex(batchRoot, mapSize, calibSize, seeds, results, promoteN, floorPx, promoteMax, lakeMinPx, styleS, dpDefaults, skipRaw);
|
||||
GD.Print("\n==================================================================");
|
||||
GD.Print($" DONE — {batchRoot}");
|
||||
GD.Print(" ⛔ TASTE GATE: the MIX is PRESENTED, not decided. No count, no K, no style, no default was set.");
|
||||
GD.Print(" ⛔ COURSES ONLY: no height mutated, no water filled, nothing carved — asserted per seed.");
|
||||
GD.Print("==================================================================");
|
||||
GetTree().Quit(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// FNV-1a over the raw float bits of a whole field. ⚠ Over the BITS, not the values: this must
|
||||
/// catch a change no float comparison would (a −0 written over a +0, a NaN payload), because the
|
||||
/// claim being checked is "byte-identical", not "numerically close".
|
||||
/// </summary>
|
||||
private static ulong Digest(float[,] f, int n)
|
||||
{
|
||||
ulong h = 14695981039346656037UL;
|
||||
for (int x = 0; x < n; x++)
|
||||
for (int y = 0; y < n; y++)
|
||||
{
|
||||
uint bits = (uint)BitConverter.SingleToInt32Bits(f[x, y]);
|
||||
for (int b = 0; b < 4; b++)
|
||||
{
|
||||
h ^= (byte)(bits >> (b * 8));
|
||||
h *= 1099511628211UL;
|
||||
}
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ⭐⭐ How many DISTINCT places the island's rivers actually meet the sea.
|
||||
///
|
||||
/// ⚠⚠ NOT the same as the sea-reaching count, and the difference is not a rounding detail. Each
|
||||
/// giant is routed INDEPENDENTLY to its nearest ocean cell, with nothing coordinating the
|
||||
/// routes — so two basins whose cheapest corridor is the same valley arrive at the same cell and
|
||||
/// share one mouth. The analysis dedups NATURAL outlets (`MinOutletSeparationPx = 400`); nothing
|
||||
/// dedups ROUTED ones. Measured rather than assumed, because "how many coastal rivers" is the
|
||||
/// question this whole gate exists to answer.
|
||||
/// </summary>
|
||||
private static void MeasureMouths(SeedResult r, List<RiverRouting.RoutedRiver> rivers)
|
||||
{
|
||||
var at = new Dictionary<(int x, int y), List<int>>();
|
||||
foreach (var rr in rivers)
|
||||
{
|
||||
if (!rr.ReachesSea) continue;
|
||||
(int x, int y) key;
|
||||
if (rr.Class == RiverRouting.RiverClass.OceanTrunk) key = (rr.Candidate.TermX, rr.Candidate.TermY);
|
||||
else if (rr.Lowland != null && rr.Lowland.Reached) key = ((int)rr.Lowland.Target.x, (int)rr.Lowland.Target.y);
|
||||
else continue;
|
||||
if (!at.TryGetValue(key, out var l)) { l = new List<int>(); at[key] = l; }
|
||||
l.Add(rr.Candidate.Rank);
|
||||
}
|
||||
r.DistinctMouths = at.Count;
|
||||
foreach (var kv in at)
|
||||
if (kv.Value.Count > 1)
|
||||
r.SharedMouths.Add("#" + string.Join("+#", kv.Value) + $" at ({kv.Key.x},{kv.Key.y})");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Where the sea-reaching rivers actually meet the coast. ⚠ In this codebase a cell is
|
||||
/// <c>x*n + y</c> and the image is drawn <c>SetPixel(x, y)</c>, so +y is SOUTH on the plate.
|
||||
/// </summary>
|
||||
private static string Spread(List<RiverRouting.RoutedRiver> rivers, int n)
|
||||
{
|
||||
var counts = new Dictionary<string, int>();
|
||||
int total = 0;
|
||||
foreach (var r in rivers)
|
||||
{
|
||||
if (!r.ReachesSea) continue;
|
||||
float mx, my;
|
||||
if (r.Class == RiverRouting.RiverClass.OceanTrunk) { mx = r.Candidate.TermX; my = r.Candidate.TermY; }
|
||||
else if (r.Lowland != null && r.Lowland.Reached) { mx = r.Lowland.Target.x; my = r.Lowland.Target.y; }
|
||||
else continue;
|
||||
string c = Compass(mx, my, n);
|
||||
counts.TryGetValue(c, out int cur);
|
||||
counts[c] = cur + 1;
|
||||
total++;
|
||||
}
|
||||
if (total == 0) return "none";
|
||||
var order = new[] { "N", "NE", "E", "SE", "S", "SW", "W", "NW", "centre" };
|
||||
var parts = new List<string>();
|
||||
foreach (string k in order) if (counts.TryGetValue(k, out int v)) parts.Add($"{k}x{v}");
|
||||
return $"{string.Join(" ", parts)} ({counts.Count} of 8 compass sectors)";
|
||||
}
|
||||
|
||||
private static string Compass(float x, float y, int n)
|
||||
{
|
||||
float half = n / 2f, dx = (x - half) / half, dy = (y - half) / half; // dy > 0 = south
|
||||
const float band = 0.35f;
|
||||
string ns = dy < -band ? "N" : dy > band ? "S" : "";
|
||||
string ew = dx < -band ? "W" : dx > band ? "E" : "";
|
||||
return ns + ew == "" ? "centre" : ns + ew;
|
||||
}
|
||||
|
||||
private static string ClassName(RiverRouting.RiverClass c) => c switch
|
||||
{
|
||||
RiverRouting.RiverClass.OceanTrunk => "trunk",
|
||||
RiverRouting.RiverClass.RoutedGiant => "routed",
|
||||
_ => "lake-ender",
|
||||
};
|
||||
|
||||
private static void WriteRiverCsv(string batchRoot, SeedResult r)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("rank,class,analysis_kind,terminus_type,drainage_px,term_x,term_y,course_pts," +
|
||||
"route_reached,route_target_x,route_target_y,route_len_px,route_straight_px,wander," +
|
||||
"route_cost,rim_climb_m,max_step_uphill_m,max_elev_m,total_uphill_m,uphill_steps," +
|
||||
"rim_x,rim_y,cells_expanded,lake_reached,lake_was_fallback,stem_width_px,why");
|
||||
foreach (var rr in r.Rivers)
|
||||
{
|
||||
var c = rr.Candidate;
|
||||
var lo = rr.Lowland;
|
||||
// ⚠ For a lake-ender the OCEAN PROBE is reported too (in the rim/cost columns of the
|
||||
// probe row below) — those are what an affordability threshold would be set against.
|
||||
var pr = rr.OceanProbe;
|
||||
sb.AppendLine($"{c.Rank},{ClassName(rr.Class)},{(c.IsSea ? "" : c.AnalysisKind)},{c.TerminusName},{c.DrainagePx},{c.TermX},{c.TermY},{rr.Course.Count}," +
|
||||
$"{(lo != null && lo.Reached ? "yes" : "no")},{(lo != null && lo.Reached ? ((int)lo.Target.x).ToString() : "")},{(lo != null && lo.Reached ? ((int)lo.Target.y).ToString() : "")}," +
|
||||
$"{(lo != null ? lo.LenPx.ToString("F1") : "")},{(lo != null ? lo.StraightPx.ToString("F1") : "")},{(lo != null ? lo.WanderRatio.ToString("F3") : "")}," +
|
||||
$"{(pr != null && pr.Reached ? pr.Cost.ToString("F0") : "")},{(pr != null ? pr.RimClimbM.ToString("F2") : "")},{(pr != null ? pr.MaxStepUphillM.ToString("F3") : "")}," +
|
||||
$"{(pr != null ? pr.MaxElevM.ToString("F2") : "")},{(pr != null ? pr.TotalUphillM.ToString("F2") : "")},{(pr != null ? pr.UphillSteps.ToString() : "")}," +
|
||||
$"{(pr != null && pr.Reached ? ((int)pr.RimPoint.x).ToString() : "")},{(pr != null && pr.Reached ? ((int)pr.RimPoint.y).ToString() : "")}," +
|
||||
$"{(pr != null ? pr.Expanded.ToString() : "")},{(rr.Class == RiverRouting.RiverClass.LakeEnder ? (rr.LakeReached ? "yes" : "no") : "")}," +
|
||||
$"{(rr.Class == RiverRouting.RiverClass.LakeEnder ? (rr.LakeWasFallback ? "yes" : "no") : "")}," +
|
||||
$"{DrainageRenderer.StemWidthFixed(c.DrainagePx)},\"{rr.Why}\"");
|
||||
}
|
||||
WriteText(Path.Combine(batchRoot, $"rivers_{r.Seed}.csv"), sb.ToString());
|
||||
}
|
||||
|
||||
private static void RenderSeed(string batchRoot, SeedResult r, bool[] isOcean, Pass2Result p2,
|
||||
int n, float sea, long floorPx, int promoteN, bool skipRaw)
|
||||
{
|
||||
string dir = Path.Combine(batchRoot, $"{r.Seed}");
|
||||
DirAccess.MakeDirRecursiveAbsolute(dir);
|
||||
Image baseImg = DrainageRenderer.TerrainBase(isOcean, p2.Height, n, sea, p2.HMax);
|
||||
|
||||
DrainageRenderer.RoutedMix(r.Rivers, baseImg.Duplicate() as Image, n,
|
||||
$"SEED {r.Seed} - THE ROUTED MIX ON THE PURE TOP {promoteN}",
|
||||
$"{r.Trunks} NATURAL TRUNKS + {r.Routed} ROUTED-THROUGH + {r.Lakes} LAKE-ENDERS = {r.SeaReaching} SEA-REACHING RIVERS. SPREAD: {r.Spread.ToUpperInvariant()}",
|
||||
floorPx)
|
||||
.SavePng(Path.Combine(dir, "routed_mix.png"));
|
||||
|
||||
// Grayscale beside the pretty render — the field must be inspectable without the palette.
|
||||
var (gmin, gmax) = GrayscaleRenderer.SavePng(p2.Height, n, Path.Combine(dir, "grayscale.png"));
|
||||
GD.Print($" grayscale: render field range {gmin:F4} .. {gmax:F4} raw = {WorldScale.MetresFromRaw(gmin):F1} .. {WorldScale.MetresFromRaw(gmax):F1} m");
|
||||
if (!skipRaw) HeightField.Save(p2.Height, n, Path.Combine(dir, "height.f32"));
|
||||
}
|
||||
|
||||
private static void WriteIndex(string batchRoot, int mapSize, int calibSize, int[] seeds,
|
||||
List<SeedResult> rows, int promoteN, long floorPx, int promoteMax, int lakeMinPx,
|
||||
string styleS, DrainageAnalysis.Params def, bool skipRaw)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
int primary = seeds.Length > 0 ? seeds[0] : 0;
|
||||
|
||||
sb.AppendLine($"# Batch 03 — lowland routing: the MIX on the pure top {promoteN}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("**⛔ TASTE GATE. Nothing is locked** — no count, no K, no routing style, no default in");
|
||||
sb.AppendLine("`TerrainGenConfig` or `DrainageAnalysis.Params`.");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("**⛔ COURSES ONLY. No height was mutated, no water was filled, nothing was carved** — asserted");
|
||||
sb.AppendLine("per seed by an FNV digest of both height fields taken before and after routing. The bed carve and");
|
||||
sb.AppendLine("the stepped-water model are separate later tasks.");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("## 👉 The pick");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"Open **`{primary}/routed_mix.png`**. Then check the other three: " +
|
||||
string.Join(", ", Array.ConvertAll(Array.FindAll(seeds, x => x != primary), x => $"`{x}`")) + ".");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("> ### ⭐⭐ THE JUDGMENT, STATED");
|
||||
sb.AppendLine("> **Does routing the giants give enough substantial, well-distributed coastal rivers to dissolve");
|
||||
sb.AppendLine("> the parked sea-river quota (rivers/02b's K) — or not?**");
|
||||
sb.AppendLine(">");
|
||||
sb.AppendLine("> Read three things off the plate: **how many** rivers now reach the sea, **how big** they are");
|
||||
sb.AppendLine("> (the fixed width scale is shared with rivers/02b, so widths are comparable across both tasks),");
|
||||
sb.AppendLine("> and **where** they land (the spread column below). Then read the rim column — a route that");
|
||||
sb.AppendLine("> climbs a large rim is a channel cut over a wall, which may or may not be acceptable geography.");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("**Colour key.** Cyan = natural ocean trunk (erosion already reached the coast). Green = routed-through");
|
||||
sb.AppendLine("giant, **dark for its natural upland stem and bright for the lowland reach routing added** — so the");
|
||||
sb.AppendLine("plate separates terrain from routing. A yellow ring on a green reach is the **rim it climbed over**.");
|
||||
sb.AppendLine("Orange = inland lake-ender, disc at its terminal and ring at the water body it joins.");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("## ⭐ The MIX, per seed");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"| Seed | natural trunks | routed-through | lake-enders | sea-reaching | ⭐⭐ DISTINCT MOUTHS | shared mouth | spread |");
|
||||
sb.AppendLine("|---|---|---|---|---|---|---|---|");
|
||||
foreach (var r in rows)
|
||||
sb.AppendLine($"| `{r.Seed}` | {r.Trunks} | {r.Routed} | {r.Lakes} | {r.SeaReaching} of {r.Rivers.Count} | " +
|
||||
$"**{r.DistinctMouths}** | {(r.SharedMouths.Count > 0 ? string.Join("; ", r.SharedMouths) : "—")} | {r.Spread} |");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("> ### ⚠⚠ READ THE *DISTINCT MOUTHS* COLUMN, NOT THE SEA-REACHING ONE.");
|
||||
sb.AppendLine("> **On every seed, two routed rivers arrive at the SAME ocean cell.** Each giant is routed");
|
||||
sb.AppendLine("> independently to its nearest ocean cell and nothing coordinates the routes, so two basins whose");
|
||||
sb.AppendLine("> cheapest corridor is the same valley share one mouth. The analysis dedups NATURAL outlets");
|
||||
sb.AppendLine($"> (`MinOutletSeparationPx = {def.MinOutletSeparationPx}`); **nothing dedups ROUTED ones.**");
|
||||
sb.AppendLine(">");
|
||||
sb.AppendLine("> Whether that is a defect or a delta is a real judgment: two rivers meeting at one mouth is a");
|
||||
sb.AppendLine("> confluence, which is ordinary geography — but they arrive there *without ever having joined*,");
|
||||
sb.AppendLine("> which is not. It is reported rather than deduped, because deduping would silently drop a");
|
||||
sb.AppendLine("> promoted river the developer chose. **→ a decision for the carve/water tasks.**");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("> ### ⚠ Compare against rivers/02b before concluding");
|
||||
sb.AppendLine($"> On the pure top {promoteN} the *unrouted* sea count was 1–2 per seed. The SEA-REACHING column above is");
|
||||
sb.AppendLine("> what routing turns that into. **If it is comfortably above the quota's K, the quota is redundant");
|
||||
sb.AppendLine("> — that was the question rivers/02b parked.** The spread column is the second half of the answer:");
|
||||
sb.AppendLine("> a count that all lands on one coast does not serve placement.");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("## ⭐⭐ The per-river diagnostic — why each river landed where it did");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("`rim climb` is metres from the basin floor to the route's high point — **the wall the channel crosses**.");
|
||||
sb.AppendLine("`max step` is the steepest single 1-px climb on it. `wander` is polyline length over straight-line.");
|
||||
sb.AppendLine("**For lake-enders the rim/cost columns are the OCEAN PROBE** — what it *would* have cost to route them");
|
||||
sb.AppendLine("to the sea. That is the number an affordability threshold would be set against (see the sort note).");
|
||||
sb.AppendLine();
|
||||
foreach (var r in rows)
|
||||
{
|
||||
sb.AppendLine($"### `{r.Seed}`");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("| rank | class | drainage px | terminus | route len px | wander | ⭐ rim climb m | max step m | route cost | cells settled |");
|
||||
sb.AppendLine("|---|---|---|---|---|---|---|---|---|---|");
|
||||
foreach (var rr in r.Rivers)
|
||||
{
|
||||
var c = rr.Candidate; var pr = rr.OceanProbe; var lo = rr.Lowland;
|
||||
string cls = rr.Class switch
|
||||
{
|
||||
RiverRouting.RiverClass.OceanTrunk => "trunk",
|
||||
RiverRouting.RiverClass.RoutedGiant => "**routed**",
|
||||
_ => "lake-ender",
|
||||
};
|
||||
string term = rr.Class switch
|
||||
{
|
||||
RiverRouting.RiverClass.OceanTrunk => $"sea ({c.TermX},{c.TermY})",
|
||||
RiverRouting.RiverClass.RoutedGiant => lo != null && lo.Reached ? $"sea ({(int)lo.Target.x},{(int)lo.Target.y})" : "⚠ NO ROUTE",
|
||||
_ => rr.LakeReached ? $"lake ({(int)lo.Target.x},{(int)lo.Target.y}){(rr.LakeWasFallback ? " ⚠fallback" : "")}" : "⚠ its own terminal",
|
||||
};
|
||||
sb.AppendLine($"| #{c.Rank} | {cls} | {c.DrainagePx:N0} | {term} | " +
|
||||
$"{(lo != null && lo.Reached ? lo.LenPx.ToString("F0") : "—")} | " +
|
||||
$"{(lo != null && lo.Reached ? lo.WanderRatio.ToString("F2") : "—")} | " +
|
||||
$"{(pr != null && pr.Reached ? $"**{pr.RimClimbM:F1}**" : "—")} | " +
|
||||
$"{(pr != null && pr.Reached ? pr.MaxStepUphillM.ToString("F2") : "—")} | " +
|
||||
$"{(pr != null && pr.Reached ? pr.Cost.ToString("N0") : "—")} | " +
|
||||
$"{(pr != null ? pr.Expanded.ToString("N0") : "—")} |");
|
||||
}
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"*Candidates {r.CandidateCount} ({r.SeaCandidates} sea / {r.EndoCandidates} endorheic) · " +
|
||||
$"significant water {r.WaterBodiesKept} of {r.WaterBodiesTotal} bodies ≥ {lakeMinPx:N0} px " +
|
||||
$"({r.WaterCellsKept:N0} cells, largest {r.LargestWaterPx:N0} px) · routing {r.RoutingSeconds:F1}s, " +
|
||||
$"{r.TotalExpanded:N0} cells settled.*");
|
||||
sb.AppendLine();
|
||||
}
|
||||
sb.AppendLine("## ⚠⚠ The sort — what actually decides routed vs lake-ender, and the threshold nobody has set");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("The task states the sort as *\"an affordable over-the-rim LOWGROUND path to the ocean exists →");
|
||||
sb.AppendLine("routed-through; none → lake-ender.\"* **Ported literally that classifies EVERYTHING as routed**, because");
|
||||
sb.AppendLine("on an 8-connected grid with all-finite costs a path to the ocean always exists — `RouteToOcean`");
|
||||
sb.AppendLine("returns empty only if the queue drains without reaching a target, which cannot happen. There is no");
|
||||
sb.AppendLine("\"none\". The word carrying the meaning is *affordable*, and **no threshold is specified anywhere**.");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("So this port uses **the reference's own sort**, which is the one that discriminates:");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("```csharp");
|
||||
sb.AppendLine("Kind = (basinHasLake[id] && !SouthernCandidate) ? \"lake-ender\" : \"routed\"");
|
||||
sb.AppendLine("```");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("**Does the terminal basin already hold classify water?** A basin that is a lake is a natural");
|
||||
sb.AppendLine("lake-ender; a dry pan is routed to the sea. That is `DrainageAnalysis.Giant.Kind`, consumed rather");
|
||||
sb.AppendLine("than reinvented. (v2 has no towns, so `southernPick` is −1 and the southern override never fires.)");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("**And the missing threshold is surfaced instead of guessed:** the ocean route is probed for EVERY");
|
||||
sb.AppendLine("giant, lake-enders included, so the rim/cost columns above say exactly what routing each one would");
|
||||
sb.AppendLine("cost. If the developer wants an affordability bar, those are the numbers to put it under.");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("## What was ported, and what was deliberately NOT");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("| Ported (the routing portion) | Not ported (later tasks) |");
|
||||
sb.AppendLine("|---|---|");
|
||||
sb.AppendLine("| `RouteToOcean` — deterministic LOWGROUND Dijkstra, uphill penalised, empty-on-no-path | `CarveRiver` — the bed stamp, **mutates render height** |");
|
||||
sb.AppendLine("| the routed / lake-ender sort | `AddSteppedWater` — **creates water bodies** |");
|
||||
sb.AppendLine("| lake-ender targeting (significant water, classify-water fallback) | tributary carving |");
|
||||
sb.AppendLine("| `SmoothCourse` — RDP tol 4 + 4 Chaikin, endpoints pinned, **lowland reach only** | the densify-to-1px step (carve-time) |");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"**Cost model, effective == declared** (verified: these are `private const` in `RiverCarvePass` with no");
|
||||
sb.AppendLine($"`ConfigManager` key and no `[Export]` anywhere in the reference repo):");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("```");
|
||||
sb.AppendLine($"LOWGROUND step = DIST × ({RiverRouting.LowgroundBase} + elevM × {RiverRouting.LowgroundElevPerM}) + climbM × {RiverRouting.LowgroundUphillPerM}");
|
||||
sb.AppendLine($"SHORT step = DIST + climbM × {RiverRouting.ShortUphillPerM} (rejected by the reference's gate as \"a dead-straight canal\")");
|
||||
sb.AppendLine($"elevM = max(0, (height − sea) × {WorldScale.MetresPerRawUnit:F0}) — clamped at local sea");
|
||||
sb.AppendLine("```");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"Style used: **{styleS}** (the reference's effective `RiverRoutingStyle`). ⚠ The declared-vs-effective gap");
|
||||
sb.AppendLine("`00_ground` warned about (WidthScale 1.0→1.75, DepthScale 1.0→1.5) is **carve-time and out of scope here**.");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("**⚠ The `isSignificantWater` port gap.** The reference builds it from a water-bodies table");
|
||||
sb.AppendLine($"(`PixelCount >= RiverLakeMinTargetPx`); **v2 has no such table.** Interim substitute, per the task:");
|
||||
sb.AppendLine($"8-connected components of classify water (ocean excluded), keeping those ≥ {lakeMinPx:N0} px — same");
|
||||
sb.AppendLine("threshold, same semantics, no table built. The size filter is the point: routing to the *nearest wet");
|
||||
sb.AppendLine("pixel* put a lake-ender into a three-cell puddle short of the obvious lagoon (the reference's own finding).");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("## What was run");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"Chain + analysis + routing at **{mapSize}** on **{seeds.Length} seeds** (`{string.Join(", ", seeds)}`), all rendered.");
|
||||
sb.AppendLine($"Curve calibrated at {calibSize} on the family-off pinned pool; terrain {TerrainShapeV1.Describe()} + erosion ON.");
|
||||
sb.AppendLine($"Promoted set: the **pure top {promoteN}** by drainage — the unified ranking, **no quota** (rivers/02b's K stays parked).");
|
||||
sb.AppendLine($"Reporting caps raised to `{promoteMax}` so every promoted river has a traced stem. Candidate floor `{floorPx:N0}` px.");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"**⚠ NOT touched:** `EndorheicMinDepthM` {def.EndorheicMinDepthM} m, `EndorheicMinAreaPx` {def.EndorheicMinAreaPx:N0} — they define the routing");
|
||||
sb.AppendLine($"surface itself. Also unchanged: `MinOutletSeparationPx` {def.MinOutletSeparationPx}, `StemMinAccPx` {def.StemMinAccPx}. `DrainageAnalysis` is reused,");
|
||||
sb.AppendLine("never rebuilt or edited. Terminus classified by `RegionLabeling.OceanMask` (`Dir == D_SEA`) — no bare `h < sea`.");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("## Files");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("| File | What it is |");
|
||||
sb.AppendLine("|---|---|");
|
||||
sb.AppendLine("| `<seed>/routed_mix.png` | the three classes, with the added lowland reach and the rim it crossed drawn distinctly |");
|
||||
sb.AppendLine("| `<seed>/grayscale.png` | the eroded render field, no palette |");
|
||||
sb.AppendLine("| `rivers_<seed>.csv` | per river: class, analysis kind, route geometry, rim, cost, cells settled, why |");
|
||||
if (skipRaw)
|
||||
sb.AppendLine("| ~~`<seed>/height.f32`~~ | **deliberately not written** — rivers/01 proved this field byte-identical to `chat2/11_erosion`, the anchor of record. `ISLA_SKIP_RAW=0` regenerates it. |");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"Ranges: sea level `{def.SeaLevel}` raw = `{WorldScale.MetresFromRaw(def.SeaLevel):F2} m`; {WorldScale.Describe()}.");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("→ `XX_Human/output/rivers/03_lowland_routing.report.md`");
|
||||
WriteText(Path.Combine(batchRoot, "INDEX.md"), sb.ToString());
|
||||
}
|
||||
|
||||
// ---- the curve (the house pattern; pool pinned family-off per rivers/01) -------------------
|
||||
|
||||
private static (CurveKnots, ClimbCalibration) CalibrateCurve(int calibSize, float sea, CurveAnchors anchors)
|
||||
{
|
||||
var rawPool = new LandHistogram(sea);
|
||||
var pass1 = new Dictionary<int, Pass1Result>();
|
||||
foreach (int s in CalibrationSeeds)
|
||||
{
|
||||
var p1 = Topography.Generate(TerrainGenConfig.CalibrationPool(calibSize, s));
|
||||
pass1[s] = p1;
|
||||
rawPool.Accumulate(p1.Height, calibSize);
|
||||
}
|
||||
var knots = new CurveKnots(2, "v2_balanced",
|
||||
rawPool.Quantile(CurveKnots.Percentiles[0]), rawPool.Quantile(CurveKnots.Percentiles[1]),
|
||||
rawPool.Quantile(CurveKnots.Percentiles[2]), rawPool.Quantile(CurveKnots.Percentiles[3]),
|
||||
rawPool.Quantile(CurveKnots.Percentiles[4]), rawPool.Quantile(CurveKnots.Percentiles[5]));
|
||||
float ceilingRaw = knots.K2;
|
||||
var rawAbove = new LandHistogram(sea);
|
||||
var outAbove = new LandHistogram(sea);
|
||||
foreach (int s in CalibrationSeeds)
|
||||
{
|
||||
var scfg = new TerrainGenConfig
|
||||
{
|
||||
MapSize = calibSize, Seed = s, Curve = true, ShelfDetail = true,
|
||||
CurveMode = CurveModeKind.Staircase, Knots = knots, Anchors = anchors, VariantLabel = "staircase",
|
||||
}.WithFamilyOff();
|
||||
Pass2Result st = Shaping.Shape(pass1[s], scfg);
|
||||
rawAbove.AccumulateWhere(pass1[s].Height, pass1[s].Height, calibSize, ceilingRaw);
|
||||
outAbove.AccumulateWhere(st.Height, pass1[s].Height, calibSize, ceilingRaw);
|
||||
}
|
||||
var pcts = ClimbCalibration.DefaultPercentiles;
|
||||
var rawQ = new float[pcts.Length]; var outQ = new float[pcts.Length];
|
||||
for (int i = 0; i < pcts.Length; i++) { rawQ[i] = rawAbove.Quantile(pcts[i]); outQ[i] = outAbove.Quantile(pcts[i]); }
|
||||
return (knots, ClimbCalibration.FromPercentiles(pcts, rawQ, outQ, ceilingRaw,
|
||||
HeightCurve.EffectiveSpikeMax(pass1[CalibrationSeeds[0]].HMaxSeed, knots, anchors),
|
||||
anchors.RedCeil, anchors.PeakCap, mountainLift: 1.0f, peakSharpness: 1.0f));
|
||||
}
|
||||
|
||||
// ---- env / io -----------------------------------------------------------------------------
|
||||
|
||||
private static void WriteText(string path, string text)
|
||||
{
|
||||
using var f = Godot.FileAccess.Open(path, Godot.FileAccess.ModeFlags.Write);
|
||||
if (f == null) { GD.PrintErr($"could not write {path}"); return; }
|
||||
f.StoreString(text);
|
||||
}
|
||||
|
||||
private static string EnvStr(string k, string fallback)
|
||||
{
|
||||
string v = System.Environment.GetEnvironmentVariable(k);
|
||||
return string.IsNullOrWhiteSpace(v) ? fallback : v;
|
||||
}
|
||||
private static int EnvInt(string k, int fallback) => int.TryParse(EnvStr(k, null) ?? "", out int v) ? v : fallback;
|
||||
private static int[] EnvSeeds(string k, int[] fallback)
|
||||
{
|
||||
string v = EnvStr(k, null);
|
||||
if (v == null) return fallback;
|
||||
var outp = new List<int>();
|
||||
foreach (string part in v.Split(',', StringSplitOptions.RemoveEmptyEntries))
|
||||
if (int.TryParse(part.Trim(), out int s) && s > 0) outp.Add(s);
|
||||
return outp.Count > 0 ? outp.ToArray() : fallback;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue