using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using Godot;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
///
/// 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
///
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();
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();
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 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 02–03.");
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();
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;
}
}
}