using Godot; namespace IslaApocalypse.Tools { /// /// Renders a raw pass-1 height field as a HYPSOMETRIC height-gradient PNG. /// /// ═══ ⚠ WHY THIS WRITES A Godot.Image DIRECTLY, AND NOT VIA THE CAPTURE PATH ═══ /// /// The reference captured its maps through a SubViewport + a TextureRect whose `_Draw` painted /// overlays — roads, town markers, river polylines — and then read the viewport texture back. /// That machinery exists to COMPOSITE VECTOR OVERLAYS onto a raster. It also carries a real /// cost: it awaits render frames, which is why unattended runs need `xvfb-run` and why /// `--headless` HANGS on it. /// /// Phase 1 has no overlays. A heightmap is a raster and nothing else, so it is written /// per-pixel into an Image and saved. No viewport, no frame awaits, no display needed. /// /// ⚠ DO NOT REINTRODUCE THE CAPTURE PATH BY HABIT. It becomes correct the moment something /// vector needs compositing on top (roads, towns, river centrelines) — and not before. /// /// ═══ THE COLOUR SCHEME ═══ /// /// Two ramps meeting at the sea threshold, Hispaniola-style: /// below — bathymetric: deep navy → shallow cyan-ish blue /// above — hypsometric: shore green → lowland green → tan → brown → grey → white peak /// /// ⚠⚠ THERE ARE NO BIOME WORDS HERE AND THERE MUST NOT BE. Nothing in this file knows about /// jungle, wasteland, snow or desert. It reads a single float per column and picks a colour on /// an elevation ramp. Biomes are a stage-5 CLASSIFICATION of finished shape (D-049 §2, D-056), /// and the whole point of the rewrite's seam is that terrain never learns their names. /// "White at the top" is snow-COLOURED cartography, not a snow biome. /// /// ⚠ The sea threshold is a VISUALIZATION boundary only. No water is modelled: no water bodies, /// no ocean/lake distinction, no flooding. Phase 2 owns all of that. /// public static class HeightMapRenderer { /// /// FIXED ramp anchors in raw height units, deliberately NOT per-run normalized. /// /// Per-run normalization would make every PNG use its full colour range, which looks better /// and lies: two seeds, or two rungs of the ablation ladder, would be coloured by different /// scales and could not be compared by eye. Fixed anchors mean the same colour is the same /// height in every image in a batch. Out-of-range values clamp, and the run prints the true /// min/max so saturation is visible as a number rather than guessed from the picture. /// public const float RampTopHeight = 1.45f; /// The deepest height the bathymetric ramp resolves. Below this, flat abyss colour. public const float RampBottomHeight = -1.00f; // ---- the land ramp: (t in [0,1] above sea) -> colour ---- private static readonly (float t, Color c)[] LandRamp = { (0.00f, new Color(0.36f, 0.55f, 0.35f)), // shore green (0.12f, new Color(0.47f, 0.62f, 0.36f)), // lowland (0.30f, new Color(0.72f, 0.71f, 0.44f)), // dry tan (0.50f, new Color(0.72f, 0.57f, 0.36f)), // brown (0.70f, new Color(0.60f, 0.45f, 0.34f)), // dark brown (0.86f, new Color(0.66f, 0.64f, 0.64f)), // grey rock (1.00f, new Color(0.98f, 0.98f, 0.98f)), // white peak }; // ---- the sea ramp: (t in [0,1], 0 = deepest) -> colour ---- private static readonly (float t, Color c)[] SeaRamp = { (0.00f, new Color(0.03f, 0.06f, 0.18f)), // abyss (0.45f, new Color(0.06f, 0.16f, 0.36f)), // deep (0.75f, new Color(0.11f, 0.30f, 0.52f)), // mid (0.92f, new Color(0.20f, 0.47f, 0.66f)), // shelf (1.00f, new Color(0.42f, 0.68f, 0.79f)), // shallow, at the waterline }; /// Render and save. Returns the absolute path written. public static string SavePng(Pass1Result result, float seaLevel, string absolutePath) { int n = result.MapSize; var img = Image.CreateEmpty(n, n, false, Image.Format.Rgb8); for (int x = 0; x < n; x++) { for (int y = 0; y < n; y++) { float h = result.Height[x, y]; Color c; if (h >= seaLevel) { float t = Mathf.Clamp((h - seaLevel) / (RampTopHeight - seaLevel), 0f, 1f); c = Sample(LandRamp, t); } else { float t = Mathf.Clamp((h - RampBottomHeight) / (seaLevel - RampBottomHeight), 0f, 1f); c = Sample(SeaRamp, t); } // Image is row-major (x = column, y = row); the height field is [x, y] with y // increasing SOUTHWARD, matching the reference's convention. So a larger y is // further down the image, and "the southern sinker" sinks the bottom of the PNG. img.SetPixel(x, y, c); } } Error err = img.SavePng(absolutePath); if (err != Error.Ok) GD.PrintErr($"[HeightMapRenderer] SavePng failed ({err}) for {absolutePath}"); return absolutePath; } private static Color Sample((float t, Color c)[] ramp, float t) { for (int i = 1; i < ramp.Length; i++) { if (t <= ramp[i].t) { float span = ramp[i].t - ramp[i - 1].t; float local = span <= 0f ? 0f : (t - ramp[i - 1].t) / span; return ramp[i - 1].c.Lerp(ramp[i].c, local); } } return ramp[ramp.Length - 1].c; } /// /// Dump the raw float height field beside the PNG, little-endian float32, x-major. /// /// Cheap, and it is what makes a future BYTE-LEVEL port-fidelity check possible — the kind /// of automatic oracle that lets taste-iteration happen without correctness being judged by /// eye. → `Design - Tooling - Iteration and Batching.md`, "build the oracle first". /// public static string SaveRaw(Pass1Result result, string absolutePath) { using var f = FileAccess.Open(absolutePath, FileAccess.ModeFlags.Write); if (f == null) { GD.PrintErr($"[HeightMapRenderer] could not open {absolutePath}: {FileAccess.GetOpenError()}"); return absolutePath; } int n = result.MapSize; for (int x = 0; x < n; x++) for (int y = 0; y < n; y++) f.StoreFloat(result.Height[x, y]); return absolutePath; } } }