feat: river bed carving with SHORT/LOWGROUND lowland routing A/B (terrain-water task 22, C0b part 2a)
Executes the frozen task-21b plan as real terrain — the first river pass to modify the render map. NO WATER (part 2b waters the gated routing style). RiverCarvePass (D-035 numeric): reruns DrainageAnalysis in-pipeline (deterministic — same seed, same eroded surface, same plan), routes each routed giant's lowland reach to the nearest ocean by deterministic Dijkstra under two config-gated cost models — 'short' (distance + uphill penalty; heads direct) vs 'lowground' (cost ~ elevation above sea; follows the lowest ground and meanders) — then carves every promoted course as a parabolic channel with a smoothstep shoulder. Width/depth grow downstream with sqrt(drainage); bed profile forced monotone non-increasing toward the outlet (water must flow) and clamped to sea + RiverSeaMargin EVERYWHERE, with below-sea cells read-only and the crater core excluded — the erosion flood-guard discipline, asserted per generation by the water-pixel A/B (throws on any change). Pipeline slot: AFTER GenerateTowns (towns read the render map for land/slope/ water checks — carving first would move towns and destabilise every A/B) and BEFORE roads (a full run's A* should see the beds). ISLA_SERVER_CONFIG env override added to ConfigManager: batch/A-B tooling loads an alternate config file and the developer's live ServerConfig.json is never written by tooling again (the task-19 restore near-miss class is retired). Measured, seed 1280587109 (both variants): oracle md5-identical to baseline (BIOM/WBID bitwise equal), 0 newly-below-sea cells, below-sea untouched, island top 457.65 m exact, lowest carved cell exactly sea+margin (37.85 m), and Rivers='off' is bitwise identical to the task-19 blueprint. All routed giants reach the ocean in both styles. Wander (polyline/straight): SHORT 1.02-1.06 vs LOWGROUND 1.24-1.38; SHORT carves 811k m3 (cuts through rises, max cumulative cut 14.6 m), LOWGROUND 481k m3 (goes around, 14.3 m). Pass cost ~25 s (plan 21 s, routing 1.4-2.5 s, carve 0.3 s). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
a53d4e8512
commit
6960c2a2e8
3 changed files with 507 additions and 0 deletions
361
Tools/Scripts/RiverCarvePass.cs
Normal file
361
Tools/Scripts/RiverCarvePass.cs
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
/// <summary>
|
||||
/// River bed carving — C0b part 2a (terrain-water task 22). The first river pass
|
||||
/// that MODIFIES terrain: executes the frozen task-21b plan by carving channel
|
||||
/// beds for the promoted rivers. NO WATER — part 2b puts water into these beds
|
||||
/// once the routing-style gate picks SHORT or LOWGROUND.
|
||||
///
|
||||
/// Standalone numeric (D-035 family). Runs the task-21 DrainageAnalysis in-
|
||||
/// pipeline (deterministic: same seed → same eroded surface → same plan), then:
|
||||
///
|
||||
/// 1. LOWLAND ROUTING (the A/B): each routed giant gets a route from its
|
||||
/// pooling terminal to the nearest OCEAN cell by deterministic Dijkstra.
|
||||
/// SHORT — cost ≈ distance, uphill penalised: heads direct, avoids walls.
|
||||
/// LOWGROUND — cost ≈ elevation above sea: follows the lowest available
|
||||
/// ground and wanders like a real lowland river.
|
||||
/// 2. BED CARVING: every promoted course (trunks, routed giants + their lowland
|
||||
/// reaches, lake-enders, tributaries) is stamped as a parabolic channel with
|
||||
/// a smoothstep shoulder — a bed for water to sit in, not a canyon. Width and
|
||||
/// depth grow downstream with drainage. The bed elevation is made MONOTONE
|
||||
/// NON-INCREASING toward the outlet (water must flow), and is clamped to
|
||||
/// sea + margin everywhere — the flood-guard discipline erosion established:
|
||||
/// below-sea cells are read-only, no carve may create inland below-sea cells,
|
||||
/// so the rendered coastline cannot move. The bed meets the ocean AT the
|
||||
/// coast, where the terrain itself descends through sea level.
|
||||
/// 3. Crater: nothing inside the protected core is modified (erosion's rule).
|
||||
///
|
||||
/// Output-only: the caller applies this to the RENDER map after classify-side
|
||||
/// data (biomes, WBID) is already computed — the oracle stays byte-identical.
|
||||
/// </summary>
|
||||
public static class RiverCarvePass
|
||||
{
|
||||
public const float M_PER_UNIT = 251f;
|
||||
public const byte STYLE_SHORT = 0;
|
||||
public const byte STYLE_LOWGROUND = 1;
|
||||
|
||||
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 };
|
||||
|
||||
// Routing cost constants. SHORT pays lightly for climbing (8 per metre of rise,
|
||||
// so a 10 m wall costs like an 80 px detour — walls are avoided, direction is
|
||||
// kept). LOWGROUND pays for BEING high (1 per metre of elevation per px) plus
|
||||
// heavily for climbing, so the cheapest corridor is the lowest ground even when
|
||||
// that wanders.
|
||||
private const float SHORT_UPHILL_PER_M = 8f;
|
||||
private const float LOWGROUND_ELEV_PER_M = 1f;
|
||||
private const float LOWGROUND_BASE = 0.05f;
|
||||
private const float LOWGROUND_UPHILL_PER_M = 50f;
|
||||
|
||||
// Bed geometry: sizes grow downstream from head to mouth, scaled by
|
||||
// sqrt(drainage / 1e6) so a 2.3M px giant carves roughly 2.3× deeper/wider at
|
||||
// the mouth than a 0.4M px trunk. Kept channel-scale, per the erosion
|
||||
// detailing philosophy.
|
||||
private const float DEPTH_HEAD_M = 1.0f;
|
||||
private const float DEPTH_MOUTH_M = 6.0f; // × sizeFactor × DepthScale
|
||||
private const float HALFWIDTH_HEAD_PX = 2.0f;
|
||||
private const float HALFWIDTH_MOUTH_PX = 12.0f; // × sizeFactor × WidthScale
|
||||
private const float MIN_BED_SLOPE = 0.002f; // m per px of enforced descent
|
||||
|
||||
public class Params
|
||||
{
|
||||
public byte RoutingStyle = STYLE_LOWGROUND;
|
||||
public float WidthScale = 1.0f;
|
||||
public float DepthScale = 1.0f;
|
||||
public float SeaMarginM = 0.2f; // bed floor above sea, everywhere
|
||||
public DrainageAnalysis.Params PlanParams = new();
|
||||
}
|
||||
|
||||
public class RiverStat
|
||||
{
|
||||
public string Name;
|
||||
public string Kind;
|
||||
public bool SouthernCandidate;
|
||||
public long DrainagePx;
|
||||
public int CourseLenPx;
|
||||
public int RouteLenPx; // lowland reach only (routed giants)
|
||||
public float RouteStraightPx;
|
||||
public float WanderRatio; // routeLen / straight-line
|
||||
public bool ReachedOcean;
|
||||
public float MaxCutM;
|
||||
public double VolumeM3;
|
||||
}
|
||||
|
||||
public class Stats
|
||||
{
|
||||
public List<RiverStat> Rivers = new();
|
||||
public long CarvedCells;
|
||||
public double CarvedVolumeM3;
|
||||
public float MaxCutM;
|
||||
public double AnalysisSeconds, RoutingSeconds, CarveSeconds;
|
||||
}
|
||||
|
||||
public static Stats Apply(float[,] height, int mapSize, bool[] isOcean,
|
||||
bool[] isClassifyWater, float southX, float southY,
|
||||
float[,] seaMap, float seaFlat,
|
||||
float craterCx, float craterCy, float craterCoreRadius,
|
||||
Func<double> secondsNow, Params p)
|
||||
{
|
||||
int n = mapSize;
|
||||
var stats = new Stats();
|
||||
float SeaAt(int x, int y) => seaMap != null ? seaMap[x, y] : seaFlat;
|
||||
float coreSq = craterCoreRadius * craterCoreRadius;
|
||||
|
||||
// --- the frozen plan, recomputed deterministically in-pipeline ---
|
||||
double t0 = secondsNow();
|
||||
var plan = DrainageAnalysis.Run(height, mapSize, isOcean, isClassifyWater, southX, southY, p.PlanParams);
|
||||
stats.AnalysisSeconds = secondsNow() - t0;
|
||||
|
||||
// --- lowland routing for the routed giants (the A/B) ---
|
||||
t0 = secondsNow();
|
||||
var giantRoutes = new List<List<(float x, float y)>>();
|
||||
foreach (var g in plan.Giants)
|
||||
{
|
||||
if (g.Kind != "routed") { giantRoutes.Add(null); continue; }
|
||||
giantRoutes.Add(RouteToOcean(height, n, isOcean,
|
||||
(int)g.Terminal.x, (int)g.Terminal.y, p.RoutingStyle, SeaAt));
|
||||
}
|
||||
stats.RoutingSeconds = secondsNow() - t0;
|
||||
|
||||
// --- carve ---
|
||||
t0 = secondsNow();
|
||||
int riverIdx = 0;
|
||||
foreach (var t in plan.Trunks)
|
||||
{
|
||||
riverIdx++;
|
||||
var course = new List<(float x, float y)>(t.Course);
|
||||
course.Reverse(); // head → mouth
|
||||
var rs = CarveRiver($"trunk{riverIdx}", "ocean-trunk", t.DrainageAreaPx,
|
||||
course, null, height, n, SeaAt, coreSq, craterCx, craterCy, p, stats);
|
||||
rs.ReachedOcean = true; // outlet is on the coast by construction
|
||||
foreach (var trib in t.Tributaries)
|
||||
CarveTributary(trib, height, n, SeaAt, coreSq, craterCx, craterCy, p, stats);
|
||||
}
|
||||
int gi = 0;
|
||||
foreach (var g in plan.Giants)
|
||||
{
|
||||
var route = giantRoutes[gi]; gi++;
|
||||
var course = new List<(float x, float y)>(g.Course);
|
||||
course.Reverse(); // head → terminal
|
||||
var rs = CarveRiver($"giant{gi}", g.Kind, g.DrainageAreaPx,
|
||||
course, route, height, n, SeaAt, coreSq, craterCx, craterCy, p, stats);
|
||||
rs.SouthernCandidate = g.SouthernCandidate;
|
||||
rs.ReachedOcean = g.Kind != "routed" || (route != null && route.Count > 0);
|
||||
foreach (var trib in g.Tributaries)
|
||||
CarveTributary(trib, height, n, SeaAt, coreSq, craterCx, craterCy, p, stats);
|
||||
}
|
||||
stats.CarveSeconds = secondsNow() - t0;
|
||||
return stats;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic Dijkstra from the start cell to the nearest ocean cell under
|
||||
/// the selected style's cost model. Returns the path start → ocean (1-px steps),
|
||||
/// or an empty list if no path exists (reported upstream, never asserted away).
|
||||
/// </summary>
|
||||
private static List<(float x, float y)> RouteToOcean(float[,] height, int n,
|
||||
bool[] isOcean, int sx, int sy, byte style, Func<int, int, float> seaAt)
|
||||
{
|
||||
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);
|
||||
|
||||
float ElevM(int x, int y) => MathF.Max(0f, (height[x, y] - seaAt(x, y)) * M_PER_UNIT);
|
||||
|
||||
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;
|
||||
|
||||
while (pq.Count > 0)
|
||||
{
|
||||
int c = pq.Dequeue();
|
||||
if (closed[c]) continue;
|
||||
closed[c] = true;
|
||||
if (isOcean[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, (height[nx, ny] - hc) * M_PER_UNIT);
|
||||
float step = style == STYLE_SHORT
|
||||
? DIST[k] + dhM * SHORT_UPHILL_PER_M
|
||||
: DIST[k] * (LOWGROUND_BASE + ElevM(nx, ny) * LOWGROUND_ELEV_PER_M)
|
||||
+ dhM * LOWGROUND_UPHILL_PER_M;
|
||||
float nc = gcost[c] + step;
|
||||
if (nc < gcost[ni])
|
||||
{
|
||||
gcost[ni] = nc;
|
||||
parent[ni] = c;
|
||||
pq.Enqueue(ni, (nc, ni));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var path = new List<(float x, float y)>();
|
||||
if (goal >= 0)
|
||||
{
|
||||
for (int c = goal; c >= 0; c = parent[c])
|
||||
path.Add((c / n, c % n));
|
||||
path.Reverse();
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
private static void CarveTributary(DrainageAnalysis.Stream trib, float[,] height, int n,
|
||||
Func<int, int, float> seaAt, float coreSq, float craterCx, float craterCy,
|
||||
Params p, Stats stats)
|
||||
{
|
||||
var course = new List<(float x, float y)>(trib.Course);
|
||||
course.Reverse(); // head → confluence
|
||||
CarveRiver(null, "tributary", trib.DrainageAreaPx, course, null,
|
||||
height, n, seaAt, coreSq, craterCx, craterCy, p, stats);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Carves one river: densify the course, build a monotone-descending clamped
|
||||
/// bed profile, stamp the channel. Returns the per-river stat (also appended
|
||||
/// to stats.Rivers unless name is null — tributaries fold into the totals).
|
||||
/// </summary>
|
||||
private static RiverStat CarveRiver(string name, string kind, long drainagePx,
|
||||
List<(float x, float y)> upland, List<(float x, float y)> lowlandRoute,
|
||||
float[,] height, int n, Func<int, int, float> seaAt,
|
||||
float coreSq, float craterCx, float craterCy, Params p, Stats stats)
|
||||
{
|
||||
// Full head→mouth polyline: upland stem, then the lowland reach if any.
|
||||
var pts = new List<(float x, float y)>(upland);
|
||||
if (lowlandRoute != null && lowlandRoute.Count > 1)
|
||||
pts.AddRange(lowlandRoute.GetRange(1, lowlandRoute.Count - 1));
|
||||
|
||||
// Densify to ~1-px samples (plan courses are decimated ×4).
|
||||
var dense = new List<(float x, float y)>();
|
||||
for (int i = 0; i + 1 < pts.Count; i++)
|
||||
{
|
||||
var a = pts[i]; var b = pts[i + 1];
|
||||
float segLen = MathF.Sqrt((b.x - a.x) * (b.x - a.x) + (b.y - a.y) * (b.y - a.y));
|
||||
int steps = Math.Max(1, (int)MathF.Ceiling(segLen));
|
||||
for (int s2 = 0; s2 < steps; s2++)
|
||||
dense.Add((a.x + (b.x - a.x) * s2 / steps, a.y + (b.y - a.y) * s2 / steps));
|
||||
}
|
||||
if (pts.Count > 0) dense.Add(pts[^1]);
|
||||
if (dense.Count < 2) return new RiverStat();
|
||||
|
||||
float sizeFactor = MathF.Sqrt(drainagePx / 1_000_000f);
|
||||
var rs = new RiverStat
|
||||
{
|
||||
Name = name, Kind = kind, DrainagePx = drainagePx,
|
||||
CourseLenPx = dense.Count,
|
||||
RouteLenPx = lowlandRoute?.Count ?? 0
|
||||
};
|
||||
if (lowlandRoute != null && lowlandRoute.Count > 1)
|
||||
{
|
||||
var a = lowlandRoute[0]; var b = lowlandRoute[^1];
|
||||
rs.RouteStraightPx = MathF.Sqrt((b.x - a.x) * (b.x - a.x) + (b.y - a.y) * (b.y - a.y));
|
||||
// Wander = POLYLINE length over straight-line — cell count undercounts
|
||||
// diagonal steps and can read below 1, which is geometrically impossible.
|
||||
float polyLen = 0f;
|
||||
for (int i = 1; i < lowlandRoute.Count; i++)
|
||||
{
|
||||
float sdx = lowlandRoute[i].x - lowlandRoute[i - 1].x;
|
||||
float sdy = lowlandRoute[i].y - lowlandRoute[i - 1].y;
|
||||
polyLen += MathF.Sqrt(sdx * sdx + sdy * sdy);
|
||||
}
|
||||
rs.RouteLenPx = (int)polyLen;
|
||||
rs.WanderRatio = rs.RouteStraightPx > 1f ? polyLen / rs.RouteStraightPx : 1f;
|
||||
}
|
||||
|
||||
// Bed profile: raw = terrain − depth(t); then monotone non-increasing
|
||||
// downstream; then clamped to sea + margin. The clamp can flatten the tail
|
||||
// near the mouth — allowed: non-increasing is what water needs, and the
|
||||
// flood guard is absolute.
|
||||
int m = dense.Count;
|
||||
var bed = new float[m];
|
||||
var depth = new float[m];
|
||||
var halfW = new float[m];
|
||||
for (int i = 0; i < m; i++)
|
||||
{
|
||||
float t = m > 1 ? (float)i / (m - 1) : 1f;
|
||||
depth[i] = (DEPTH_HEAD_M + (DEPTH_MOUTH_M * sizeFactor - DEPTH_HEAD_M) * t) * p.DepthScale;
|
||||
if (depth[i] < 0.5f) depth[i] = 0.5f;
|
||||
halfW[i] = (HALFWIDTH_HEAD_PX + (HALFWIDTH_MOUTH_PX * sizeFactor - HALFWIDTH_HEAD_PX) * t) * p.WidthScale;
|
||||
if (halfW[i] < 1.5f) halfW[i] = 1.5f;
|
||||
int cx = (int)dense[i].x, cy = (int)dense[i].y;
|
||||
bed[i] = height[cx, cy] - depth[i] / M_PER_UNIT;
|
||||
}
|
||||
for (int i = 1; i < m; i++)
|
||||
{
|
||||
float maxAllowed = bed[i - 1] - MIN_BED_SLOPE / M_PER_UNIT;
|
||||
if (bed[i] > maxAllowed) bed[i] = maxAllowed;
|
||||
}
|
||||
for (int i = 0; i < m; i++)
|
||||
{
|
||||
int cx = (int)dense[i].x, cy = (int)dense[i].y;
|
||||
float floor = seaAt(cx, cy) + p.SeaMarginM / M_PER_UNIT;
|
||||
if (bed[i] < floor) bed[i] = floor;
|
||||
}
|
||||
|
||||
// Stamp: parabolic channel to the rim, smoothstep shoulder back to terrain.
|
||||
for (int i = 0; i < m; i++)
|
||||
{
|
||||
float hw = halfW[i];
|
||||
float outer = hw * 2f;
|
||||
int cx0 = (int)MathF.Floor(dense[i].x - outer), cx1 = (int)MathF.Ceiling(dense[i].x + outer);
|
||||
int cy0 = (int)MathF.Floor(dense[i].y - outer), cy1 = (int)MathF.Ceiling(dense[i].y + outer);
|
||||
float rimH = bed[i] + depth[i] / M_PER_UNIT;
|
||||
for (int x = cx0; x <= cx1; x++)
|
||||
{
|
||||
if (x < 0 || x >= n) continue;
|
||||
for (int y = cy0; y <= cy1; y++)
|
||||
{
|
||||
if (y < 0 || y >= n) continue;
|
||||
float rx = x - dense[i].x, ry = y - dense[i].y;
|
||||
float r = MathF.Sqrt(rx * rx + ry * ry);
|
||||
if (r > outer) continue;
|
||||
float ddx = x - craterCx, ddy = y - craterCy;
|
||||
if (ddx * ddx + ddy * ddy < coreSq) continue; // crater core protected
|
||||
float sea = seaAt(x, y);
|
||||
float old = height[x, y];
|
||||
if (old < sea) continue; // below-sea cells read-only
|
||||
float target;
|
||||
if (r <= hw)
|
||||
{
|
||||
float f = r / hw;
|
||||
target = bed[i] + (depth[i] / M_PER_UNIT) * f * f;
|
||||
}
|
||||
else
|
||||
{
|
||||
float f = (r - hw) / hw; // 0..1 across the shoulder
|
||||
f = f * f * (3f - 2f * f); // smoothstep
|
||||
target = rimH + (old - rimH) * f;
|
||||
}
|
||||
float floor = sea + p.SeaMarginM / M_PER_UNIT;
|
||||
if (target < floor) target = floor;
|
||||
if (target < old)
|
||||
{
|
||||
float cutM = (old - target) * M_PER_UNIT;
|
||||
height[x, y] = target;
|
||||
stats.CarvedCells++;
|
||||
stats.CarvedVolumeM3 += cutM;
|
||||
if (cutM > stats.MaxCutM) stats.MaxCutM = cutM;
|
||||
if (cutM > rs.MaxCutM) rs.MaxCutM = cutM;
|
||||
rs.VolumeM3 += cutM;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (name != null) stats.Rivers.Add(rs);
|
||||
return rs;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue