islaApocalypse-v2/Tools/Scripts/ReliefRenderTool.cs
beezm 527fab41e5 Fix the ISLA_SOURCE default the batch rename broke
chat1/05 renamed 01_pass1_port to 02_pass1_port, bringing it into line with the
authoring-task naming convention. ReviewBatchTool and ReliefRenderTool both
defaulted ISLA_SOURCE to the old name, so running either without an explicit
override found no .f32 dumps.

That failed quietly, which is the reason to fix it rather than document it:
HeightField.Load returns null on a missing file, so ReliefRenderTool silently
regenerated instead of loading, and ReviewBatchTool skipped 1_noise_grayscale
entirely with only a printed warning. Nothing corrupted; it just cost generation
time and dropped a view, both easy to miss.

Both defaults now name 02_pass1_port — where TerrainGenTool writes by default,
BatchRoot(task 2, "pass1_port"). Generator output and renderer source agree again.

ISLA_SOURCE stays overridable and unchanged in shape. Note in passing that it is a
FULL folder name, prefix included, not a bare descriptor: it names an existing
folder to read rather than composing a new one, so it does not go through BatchRoot.
Commented at both sites so the asymmetry with ISLA_BATCH reads as deliberate.

Verified end to end, not just by build: with no ISLA_SOURCE set, ReliefRenderTool
reports "loaded in 173 ms" against 02_pass1_port, and ReviewBatchTool produces
1_noise_grayscale again.
2026-08-19 23:38:10 -04:00

224 lines
10 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 02_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");
// ⚠ A FULL batch folder name, prefix included — it names an EXISTING folder rather than
// composing a new one, so it is not run through BatchRoot. Tracks TerrainGenTool's
// default output: BatchRoot(task 2, "pass1_port") = 02_pass1_port.
string source = EnvStr("ISLA_SOURCE", "02_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;
}
}
}