feat: 0_height hillshade snapshot + TCRV blueprint section + harness (terrain-water task 05)

0_height: hypsometric storm-ladder tint x Lambert NW hillshade over
the curved heights, captured after topography in both curve modes —
relief is finally visible. Renumbers nothing.

TCRV (50 B, emitted when the curve is on): curve version + input knots
+ output bands, so blueprints are self-describing about the map their
heights went through (H7 spirit). Parsed into
WorldBlueprint.TerrainCurve (null = raw legacy profile); server treats
it as metadata. Round-trip harness compares it when present.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Stewart Howe 2026-08-07 15:24:33 -04:00
parent 67868638be
commit e7de292b3d
5 changed files with 142 additions and 1 deletions

View file

@ -98,7 +98,10 @@ public partial class MapGenerator : TextureRect
// A future water stage slots in as another CaptureStage call at its own boundary.
GenerateTopography();
GD.Print($"{T()} Topography done (height + temperature).");
GD.Print($"{T()} Topography done (height + temperature). TerrainCurve: {(_curveOn ? "v1" : "off")}.");
DrawHeightStageTexture();
await CaptureStage("0_height");
CalculateTrueOcean();
CalculateMainland();
@ -247,6 +250,15 @@ public partial class MapGenerator : TextureRect
TrailRoads = _trailPaths,
WaterBodyIds = _waterBodyIds,
WaterBodies = _waterBodies ?? new List<WaterBodyInfo>(),
TerrainCurve = _curveOn ? new TerrainCurveInfo
{
Version = HeightCurve.VERSION,
T1 = HeightCurve.T1, T2 = HeightCurve.T2, T3 = HeightCurve.T3, T4 = HeightCurve.T4,
HMaxCal = HeightCurve.HMAX_CAL,
Sea = HeightCurve.SEA, OrangeCeil = HeightCurve.ORANGE_CEIL, RedCeil = HeightCurve.RED_CEIL,
PlateauLo = HeightCurve.PLATEAU_LO, PlateauHi = HeightCurve.PLATEAU_HI,
PeakCap = HeightCurve.PEAK_CAP, TailSlope = HeightCurve.TAIL_SLOPE
} : null,
FormatVersion = 2,
Params = new BlueprintParams
{
@ -749,6 +761,65 @@ public partial class MapGenerator : TextureRect
$"deeper than 0.01: {basinDepths.FindAll(d => d > 0.01f).Count}, deeper than 0.05: {basinDepths.FindAll(d => d > 0.05f).Count}.");
}
/// <summary>
/// Paints the height-stage snapshot (task 05): hypsometric tint by storm-ladder
/// band × Lambert hillshade from the height gradient (NW light), over the CURVED
/// heights — this is the snapshot that makes relief visible. Runs in both curve
/// modes; with the curve off it shows the legacy profile under the same bands.
/// Pure numeric pass over the heightmap.
/// </summary>
private void DrawHeightStageTexture()
{
Image img = Image.CreateEmpty(MapSize, MapSize, false, Image.Format.Rgba8);
Vector3 light = new Vector3(-0.55f, -0.55f, 0.63f).Normalized(); // NW, ~39° up
Color deepSea = new Color(0.07f, 0.15f, 0.32f);
Color shallowSea = new Color(0.25f, 0.45f, 0.65f);
Color green = new Color(0.44f, 0.62f, 0.36f); // orange band terrain: lowland green
Color tan = new Color(0.76f, 0.70f, 0.46f); // red band: tan
Color brown = new Color(0.55f, 0.41f, 0.28f); // shoulder + plateau: brown
Color white = new Color(0.97f, 0.97f, 0.98f); // peaks
Color grey = new Color(0.72f, 0.72f, 0.70f);
int n = MapSize;
for (int x = 0; x < n; x++)
{
for (int y = 0; y < n; y++)
{
float h = _heightMap[x, y];
float sea = GetSeaLevel(_tempMap[x, y]);
Color tint;
if (h < sea)
{
float depth = Mathf.Clamp((sea - h) / 0.5f, 0f, 1f);
tint = shallowSea.Lerp(deepSea, depth);
}
else if (h < HeightCurve.ORANGE_CEIL) tint = green;
else if (h < HeightCurve.RED_CEIL) tint = tan;
else if (h < HeightCurve.PLATEAU_HI) tint = brown;
else
{
float t2 = Mathf.Clamp((h - HeightCurve.PLATEAU_HI) / (HeightCurve.PEAK_CAP - HeightCurve.PLATEAU_HI), 0f, 1f);
tint = grey.Lerp(white, t2);
}
// Lambert hillshade on the world-scale gradient (1 px = 1 m, height ×251 m).
int xm = x > 0 ? x - 1 : x, xp = x < n - 1 ? x + 1 : x;
int ym = y > 0 ? y - 1 : y, yp = y < n - 1 ? y + 1 : y;
float gx = (_heightMap[xp, y] - _heightMap[xm, y]) * 251f / (xp - xm == 0 ? 1 : xp - xm);
float gy = (_heightMap[x, yp] - _heightMap[x, ym]) * 251f / (yp - ym == 0 ? 1 : yp - ym);
Vector3 nrm = new Vector3(-gx, -gy, 1f).Normalized();
float shade = Mathf.Clamp(nrm.Dot(light), 0f, 1f);
Color c = tint * (0.35f + 0.65f * shade);
c.A = 1f;
img.SetPixel(x, y, c);
}
}
Texture = ImageTexture.CreateFromImage(img);
}
/// <summary>
/// Paints the water-stage snapshot from the stage's own outputs (the biome grid
/// does not exist yet at this point in the pipeline): ocean deep blue, lakes a

View file

@ -120,6 +120,7 @@ public partial class RoundTripHarness : Node
ok &= CompareRoads("Rugged", a.RuggedRoads, b.RuggedRoads);
ok &= CompareRoads("Trail", a.TrailRoads, b.TrailRoads);
ok &= CompareWater(a, b);
ok &= CompareTerrainCurve(a, b);
if (ok)
GD.Print($"[Harness] Semantic equality holds: {a.MapSize}x{a.MapSize} grid, " +
@ -185,6 +186,29 @@ public partial class RoundTripHarness : Node
return ok;
}
private bool CompareTerrainCurve(WorldBlueprint a, WorldBlueprint b)
{
if (a.TerrainCurve == null && b.TerrainCurve == null)
{
GD.Print("[Harness] TCRV: absent in source — nothing to compare (and none reappeared).");
return true;
}
if (a.TerrainCurve == null || b.TerrainCurve == null)
{
GD.PrintErr("[Harness] TCRV presence mismatch between source and reread.");
return false;
}
var ca = a.TerrainCurve; var cb = b.TerrainCurve;
bool same = ca.Version == cb.Version;
float[] fa = { ca.T1, ca.T2, ca.T3, ca.T4, ca.HMaxCal, ca.Sea, ca.OrangeCeil, ca.RedCeil, ca.PlateauLo, ca.PlateauHi, ca.PeakCap, ca.TailSlope };
float[] fb = { cb.T1, cb.T2, cb.T3, cb.T4, cb.HMaxCal, cb.Sea, cb.OrangeCeil, cb.RedCeil, cb.PlateauLo, cb.PlateauHi, cb.PeakCap, cb.TailSlope };
for (int i = 0; i < fa.Length; i++)
if (System.BitConverter.SingleToInt32Bits(fa[i]) != System.BitConverter.SingleToInt32Bits(fb[i])) same = false;
if (!same) { GD.PrintErr("[Harness] TCRV fields differ."); return false; }
GD.Print($"[Harness] TCRV equal (curve v{ca.Version}).");
return true;
}
private bool CompareRoads(string tier, List<Vector2[]> a, List<Vector2[]> b)
{
if (a.Count != b.Count)