islaApocalypse-v2/Tools/Scripts/ReliefRenderTool.cs
beezm fdf52f61ee Phase 1 review: raw grayscale, the wide gradient as hero, fixed batch naming
Presentation only. No generation file is touched — Topography, TerrainNoise,
IslandFalloff, Pass1Result, TerrainGenConfig and GenerationScale are all unchanged.

Batch naming, fixed in code. The prefix is the AUTHORING TASK number, not a running
counter: 04_review means "the batch task 04 authored", not "the fifth batch". It had
already drifted — tasks 02 and 03 produced 00_smoke through 05_trophy_10240 across
two tasks, so no folder name said which task made what. Now the task number is an
explicit argument to ToolingPaths.BatchRoot(taskNumber, descriptor), which composes
the prefix itself and REFUSES a descriptor that carries its own. All three tools
take it as ISLA_TASK. Verified: ISLA_BATCH=05_foo is refused with a stated reason
and exit 2, and creates no folder.

Also fixed: an exception out of _Ready does not stop Godot — it logs and the process
sits there with no main loop, so a misconfigured run HUNG rather than failing. A
hang looks like slow work, which is worse than a crash. The batch tools now catch,
print what was refused, and exit non-zero.

Grayscale mode: normalize a field to its own [min,max]. This is the one place
per-image normalization is correct — everywhere else anchors are fixed so images
compare, but here the point is to see one field at full contrast. The range is
printed and indexed so a shade reads back to a height. You cannot judge noise
through a palette: a ramp bends the distribution, a hillshade adds shape the data
does not have.

The wide Costa-Rica palette, and the reframing the developer asked for: the pretty
map is the WIDE GRADIENT RENDERED FLAT, and relief is no longer the hero. On raw
pass-1 noise a hillshade has nothing coherent to shade, so it renders fine fractal
bumpiness as fuzz that actively hides the elevation the colour is showing. Strong
hillshade is retained as diagnostic_relief and labelled a dev view — its bumpiness
is exaggerated slope, not extra terrain. Subtle relief is kept for comparison, with
the honest note that it still fuzzes until Phase 2 carves coherent landforms.

Legend: a colour bar with ticks drawn from the palette's own ramp, so it cannot
drift from the map beside it. Ticks are RELATIVE height and the image says NOT
METRES — the conversion is Phase 2's, and labelling it "m" would invent a fact in
the artefact a reader most trusts. Text comes from a 5x7 bitmap font written for
this, specifically so a legend does not drag in the SubViewport capture path.
2026-08-19 22:55:53 -04:00

221 lines
9.8 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using Godot;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
/// <summary>
/// The taste-gate renderer: re-colours existing height fields into shaded-relief maps across a
/// small set of named looks, and writes an INDEX.md for browsing.
///
/// ⚠ IT RE-COLOURS; IT DOES NOT REGENERATE. Height fields are LOADED from the `.f32` dumps a
/// generation run left behind, so a look change provably cannot move the terrain. Only when a
/// dump is missing for the requested size — a showpiece at 4096, say, where no dump exists —
/// does it generate one, deterministically from the same seed. Same seed, same island.
///
/// ═══ RUNNING IT ═══
///
/// Godot_v4.7.2-stable_mono_linux.x86_64 --headless \
/// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/ReliefRenderTool.tscn
///
/// ISLA_MAPSIZE side in columns (default 2048)
/// ISLA_SEEDS comma-separated positive (default: the 4 pinned seeds)
/// ISLA_LOOKS comma-separated look names (default: atlas,relief,dusk)
/// ISLA_TASK authoring task number (default 3)
/// ISLA_BATCH descriptor, NO prefix (default "relief_taste")
/// ISLA_SOURCE batch to read .f32 from (default 01_pass1_port)
/// ISLA_DUMP_RAW "1" to also dump .f32 when a field had to be generated
/// </summary>
public partial class ReliefRenderTool : Node
{
private static readonly int[] DefaultSeeds = { 1063685222, 20260819, 777001, 424242 };
public override void _Ready()
{
ToolingPaths.Configure(OS.GetUserDataDir());
int mapSize = EnvInt("ISLA_MAPSIZE", 2048);
int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds);
int task = EnvInt("ISLA_TASK", 3); // the task that authored this batch
string batch = EnvStr("ISLA_BATCH", "relief_taste");
string source = EnvStr("ISLA_SOURCE", "01_pass1_port");
bool dumpRaw = EnvStr("ISLA_DUMP_RAW", "0") == "1";
LookConfig[] looks = SelectLooks(EnvStr("ISLA_LOOKS", null));
// ⚠ Composed by BatchRoot, never free-form: the prefix is the AUTHORING TASK number,
// not a counter, and a descriptor carrying its own prefix is refused. → Tools/README.md.
string batchRoot = ToolingPaths.BatchRoot(task, batch);
string sourceRoot = Path.Combine(ToolingPaths.BatchesRoot, source);
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
DirAccess.MakeDirRecursiveAbsolute(ToolingPaths.BatchScratch(batchRoot));
GD.Print("==================================================================");
GD.Print(" SHADED RELIEF — hypsometric tint + hillshade (Phase 1, the look)");
GD.Print("==================================================================");
GD.Print($"MapSize : {mapSize}");
GD.Print($"seeds : {string.Join(", ", seeds)}");
GD.Print($"looks : {string.Join(", ", Array.ConvertAll(looks, l => l.Name))}");
GD.Print($"source : {sourceRoot} (.f32 dumps; generated only if absent)");
GD.Print($"out : {batchRoot}");
GD.Print("------------------------------------------------------------------");
foreach (var l in looks) GD.Print($" {l}");
GD.Print("==================================================================");
var rows = new List<string>();
foreach (int seed in seeds)
{
// --- get the height field: LOAD if a dump exists, generate only as a fallback ---
ulong t0 = Time.GetTicksMsec();
string dumpPath = Path.Combine(sourceRoot, $"{seed}_full", "height.f32");
float[,] height = HeightField.Load(dumpPath, mapSize);
string origin;
if (height != null)
{
origin = "loaded";
}
else
{
var cfg = new TerrainGenConfig { MapSize = mapSize, Seed = seed, VariantLabel = "full" };
Pass1Result r = Topography.Generate(cfg);
height = r.Height;
origin = "generated";
if (dumpRaw)
{
string dir = Path.Combine(batchRoot, $"{seed}_source");
DirAccess.MakeDirRecursiveAbsolute(dir);
HeightField.Save(height, mapSize, Path.Combine(dir, "height.f32"));
}
}
ulong tLoad = Time.GetTicksMsec() - t0;
float hMin = float.MaxValue, hMax = float.MinValue;
for (int x = 0; x < mapSize; x++)
for (int y = 0; y < mapSize; y++)
{
float v = height[x, y];
if (v < hMin) hMin = v;
if (v > hMax) hMax = v;
}
GD.Print($"\n seed {seed} [{origin} in {tLoad} ms] h[{hMin:F3} .. {hMax:F3}]");
foreach (LookConfig look in looks)
{
ulong t1 = Time.GetTicksMsec();
string dir = Path.Combine(batchRoot, $"{seed}_{look.Name}");
DirAccess.MakeDirRecursiveAbsolute(dir);
ReliefRenderer.SavePng(height, mapSize, look, Path.Combine(dir, "relief.png"));
ulong ms = Time.GetTicksMsec() - t1;
GD.Print($" {look.Name,-8} -> {dir}/relief.png {ms} ms");
rows.Add($"| `{seed}_{look.Name}` | {seed} | {look.Name} | {look.Palette} | " +
$"{look.ZExaggeration:F0} | {look.LightAzimuth:F0}°/{look.LightAltitude:F0}° | " +
$"{look.HillshadeStrength:F2} | {ms} ms |");
}
}
WriteIndex(batchRoot, mapSize, seeds, looks, source, rows);
GD.Print("\n==================================================================");
GD.Print($" DONE — {rows.Count} renders in {batchRoot}");
GD.Print("==================================================================");
GetTree().Quit(0);
}
private static LookConfig[] SelectLooks(string csv)
{
LookConfig[] all = LookConfig.Variants();
if (string.IsNullOrWhiteSpace(csv)) return all;
var picked = new List<LookConfig>();
foreach (string want in csv.Split(',', StringSplitOptions.RemoveEmptyEntries))
foreach (LookConfig l in all)
if (string.Equals(l.Name, want.Trim(), StringComparison.OrdinalIgnoreCase))
picked.Add(l);
return picked.Count > 0 ? picked.ToArray() : all;
}
private static void WriteIndex(string batchRoot, int mapSize, int[] seeds,
LookConfig[] looks, string source, List<string> rows)
{
var (landLo, landHi) = ReliefPalette.LandAnchors;
var sb = new StringBuilder();
sb.AppendLine("# Batch — shaded relief, the taste gate (Phase 1)");
sb.AppendLine();
sb.AppendLine("**Presentation only.** These are the *same* height fields as");
sb.AppendLine($"`{source}` — loaded from its `.f32` dumps, not regenerated. Nothing here can move the");
sb.AppendLine("terrain; only its colours and its lighting differ.");
sb.AppendLine();
sb.AppendLine($"- **MapSize:** {mapSize}");
sb.AppendLine($"- **Sea colour boundary:** 0.15 — a *colour* boundary, **not a water surface.** No water is modelled (Phase 2).");
sb.AppendLine($"- **Palette anchors (FIXED across every image):** land {landLo} .. {landHi}; sea compressed by `d/(d+{ReliefPalette.SeaCompression})`");
sb.AppendLine($"- **Seeds:** {string.Join(", ", seeds)}");
sb.AppendLine();
sb.AppendLine("## ⭐ Pick a look");
sb.AppendLine();
sb.AppendLine("Three, deliberately — a subjective gate drowns in a wall of near-duplicates. Each pair");
sb.AppendLine("isolates one question:");
sb.AppendLine();
sb.AppendLine("| Look | Palette | Z-exag | Light | Strength | The question it answers |");
sb.AppendLine("|---|---|---|---|---|---|");
foreach (LookConfig l in looks)
{
string q = l.Name switch
{
"atlas" => "the baseline — classic physical-atlas plate, tint-forward",
"relief" => "**vs `atlas`:** how strong should the relief be? (same palette + light)",
"dusk" => "**vs `atlas`:** warmer palette and a lower sun — better, or too much?",
_ => "",
};
sb.AppendLine($"| `{l.Name}` | {l.Palette} | {l.ZExaggeration:F0} | {l.LightAzimuth:F0}°/{l.LightAltitude:F0}° | {l.HillshadeStrength:F2} | {q} |");
}
sb.AppendLine();
sb.AppendLine("Compare the **same seed** across the three folders. `1063685222` is the reference's own");
sb.AppendLine("commented seed and the one used throughout tasks 0203.");
sb.AppendLine();
sb.AppendLine("> ⚠ **The seabed is raw, and that is expected — not unfinished-because-broken.** The");
sb.AppendLine("> submarine coast shelf and the offshore islets are DEFERRED Phase-2 items; they act only");
sb.AppendLine("> below sea level and are judged once water renders. Relief is deliberately dialled back");
sb.AppendLine("> underwater so the eye is not dragged to the one surface that is knowingly incomplete.");
sb.AppendLine();
sb.AppendLine("> ⚠ **No biome colour here.** This is height + slope + above/below sea, on a continuous");
sb.AppendLine("> ramp. White at the top is snow-*coloured cartography*, not a snow biome.");
sb.AppendLine();
sb.AppendLine("## Renders");
sb.AppendLine();
sb.AppendLine("| Folder | Seed | Look | Palette | Z-exag | Light | Strength | Time |");
sb.AppendLine("|---|---|---|---|---|---|---|---|");
foreach (string r in rows) sb.AppendLine(r);
sb.AppendLine();
sb.AppendLine("`scratch/` is persistent and is never cleaned.");
using var f = Godot.FileAccess.Open(Path.Combine(batchRoot, "INDEX.md"), Godot.FileAccess.ModeFlags.Write);
if (f == null) { GD.PrintErr("could not write INDEX.md"); return; }
f.StoreString(sb.ToString());
}
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 o = new List<int>();
foreach (string p in v.Split(',', StringSplitOptions.RemoveEmptyEntries))
if (int.TryParse(p.Trim(), out int s) && s > 0) o.Add(s);
return o.Count > 0 ? o.ToArray() : fallback;
}
}
}