diff --git a/Tools/Scenes/SeedGalleryTool.tscn b/Tools/Scenes/SeedGalleryTool.tscn new file mode 100644 index 0000000..01b1475 --- /dev/null +++ b/Tools/Scenes/SeedGalleryTool.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3 uid="uid://cseedgallery04isla"] + +[ext_resource type="Script" path="res://Tools/Scripts/SeedGalleryTool.cs" id="1_sgt"] + +[node name="SeedGalleryTool" type="Node"] +script = ExtResource("1_sgt") diff --git a/Tools/Scripts/SeedGalleryTool.cs b/Tools/Scripts/SeedGalleryTool.cs new file mode 100644 index 0000000..30dddb8 --- /dev/null +++ b/Tools/Scripts/SeedGalleryTool.cs @@ -0,0 +1,580 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using Godot; +using IslaApocalypse.Core; + +namespace IslaApocalypse.Tools +{ + /// + /// ⭐ THE SEED GALLERY (chat2/04) — is continuous_restored good across seeds, or is + /// 1063685222 a lucky draw? + /// + /// ═══ ⚠⚠ THIS TOOL RENDERS. IT DOES NOT TUNE. ═══ + /// + /// The curve is the one committed at tag terrain-curve-v1 and it is PINNED HERE IN CODE — + /// , and are + /// constants with no environment override, deliberately. Every other tool in this phase exposes + /// its knobs to ISLA_* so they can be probed; a GALLERY must not, because a stray + /// environment variable left over from a probe would silently render eight plates of a curve + /// nobody chose and they would look exactly like the real thing. The knobs are printed in the + /// run header and written into the INDEX so the plates can always be traced to a curve. + /// + /// ═══ THE CALIBRATION IS RE-MEASURED, NOT RE-INVENTED ═══ + /// + /// continuous_restored is defined by a calibration measured on task 01's six-seed pool at + /// the ITERATION size, then applied at any size. This tool reproduces that measurement exactly — + /// same pool, same size, same primary seed for the normalization anchor — so the gallery renders + /// the same curve task 03 gated, not a look-alike. → . + /// + /// ⚠ The gallery seeds are NOT the calibration pool. The pool stays fixed at task 01's six; the + /// gallery is eight separate draws, seven of them never previously rendered. + /// + /// ═══ THE CHARACTER NOTE IS A HUMAN JUDGEMENT, AND IS TREATED AS ONE ═══ + /// + /// The INDEX carries a one-word note per seed ("clean" / "sharp mid-slope" / "flat draw"). That is + /// an EYE call, so this tool will not invent it. It reads the notes from + /// scratch/character_notes.tsv if that file exists and prints "(pending)" if it does not. + /// Write the notes after looking at the plates, then re-run with ISLA_INDEX_ONLY=1 to + /// rebuild the INDEX from scratch/metrics.tsv without re-rendering a single pixel. + /// + /// What the tool DOES contribute is an objective companion, and getting it right took one correction + /// worth recording: + /// + /// ⚠ THE CURVE'S NORMALIZED MID-SLOPE CANNOT DISCRIMINATE BETWEEN SEEDS. It is + /// dv/du on a fixed set of control points, and each seed's denormalization is an affine + /// rescale of both axes — which leaves dv/du untouched. It is the SAME NUMBER on every + /// seed by construction, so measuring it per seed answers nothing. (The first cut of this tool + /// reported exactly that and printed an identical 1.86 for every draw.) + /// + /// So the metric that ships is the one the eye is actually reacting to: the **spatial height + /// gradient through the 100–220 m band**, in metres per pixel — how fast the ground climbs + /// through the heights where the yellow→orange transition sits. That is + /// (dOutput/dRaw) × (dRaw/dPixel): the first factor varies by seed because a taller + /// spikeMax spreads the same curve over more raw range, and the second is pure terrain. + /// Both are seed-dependent, and their product is what a render shows. + /// + /// ═══ RUNNING IT ═══ + /// + /// xvfb-run -a Godot_v4.7.2-stable_mono_linux.x86_64 \ + /// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/SeedGalleryTool.tscn + /// + /// ISLA_TASK / ISLA_BATCH / ISLA_SEEDS / ISLA_SKIP_RAW + /// ISLA_MAPSIZE gallery render size (default 8192 — judging size, not calibration size) + /// ISLA_CALIB_SIZE calibration pool size (default 2048 — task 01's, do not change casually) + /// ISLA_INDEX_ONLY "1" to rebuild INDEX.md from scratch/metrics.tsv and skip all rendering + /// + public partial class SeedGalleryTool : Node + { + // ═══ THE CURVE, PINNED. No environment override, by design — see the type header. ═══ + + /// task-03 `continuous_restored`: reproduce the staircase's mountain. + private const float MountainLift = 1.0f; + + /// task-03 `continuous_restored`: a straight run to the cap, no extra summit steepening. + private const float PeakSharpness = 1.0f; + + /// The flood line — hands over at exactly (K2, RED_CEIL). + private const float LowlandCeilingM = 30f; + + /// + /// ⚠ TASK 01'S CALIBRATION POOL, VERBATIM. Not the gallery seeds. Changing this changes the + /// curve, which is the one thing this task must not do. + /// + private static readonly int[] CalibrationSeeds = { 1063685222, 20260819, 777001, 424242, 90210, 31337 }; + + /// + /// ⭐ THE GALLERY. `1063685222` is the ANCHOR — the plate every previous task was judged on — + /// and the other seven are fresh draws never rendered before. + /// + /// ⚠⚠ THESE WERE FIXED BEFORE THE FIRST RENDER AND WERE NEVER SCREENED OR REPLACED. That is + /// the whole point of a spread: hand-picking flattering draws would answer the question + /// "can this curve ever look good?" when the question asked is "does it look good generally?" + /// If a seed in this list turns out ugly, it stays in the gallery and goes in the report. + /// + /// None of the seven appear in the calibration pool, so none of them shaped the curve they + /// are being used to test. + /// + private static readonly int[] GallerySeeds = + { + 1063685222, // ⭐ the anchor — Phase 1's primary, the plate 01/02/03 were judged on + 20260821, // the date this gallery was drawn + 8675309, + 123456789, + 271828182, + 42424242, + 555000111, + 999999937, // a large prime, for no reason beyond being an unconsidered draw + }; + + private const int DefaultGallerySize = 8192; + private const int DefaultCalibSize = 2048; + + public override void _Ready() + { + try { Run(); } + catch (Exception e) + { + GD.PrintErr("=================================================================="); + GD.PrintErr($" REFUSED: {e.Message}"); + GD.PrintErr("=================================================================="); + GetTree().Quit(2); + } + } + + private void Run() + { + ToolingPaths.Configure(OS.GetUserDataDir()); + + int task = EnvInt("ISLA_TASK", 4); + string descr = EnvStr("ISLA_BATCH", "seed_gallery"); + int gallerySize = EnvInt("ISLA_MAPSIZE", DefaultGallerySize); + int calibSize = EnvInt("ISLA_CALIB_SIZE", DefaultCalibSize); + int[] seeds = EnvSeeds("ISLA_SEEDS", GallerySeeds); + bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1"; + bool indexOnly = EnvStr("ISLA_INDEX_ONLY", "0") == "1"; + + string batchRoot = ToolingPaths.BatchRoot(task, descr); + string scratch = ToolingPaths.BatchScratch(batchRoot); + DirAccess.MakeDirRecursiveAbsolute(batchRoot); + DirAccess.MakeDirRecursiveAbsolute(scratch); + + string metricsPath = Path.Combine(scratch, "metrics.tsv"); + string notesPath = Path.Combine(scratch, "character_notes.tsv"); + + var anchors = CurveAnchors.Default; + float sea = 0.15f; + int anchor = seeds[0]; + + GD.Print("=================================================================="); + GD.Print(" SEED GALLERY (chat2/04) — the committed curve, across 8 draws"); + GD.Print("=================================================================="); + GD.Print($"curve : continuous_restored, PINNED — lift {MountainLift:F2}, " + + $"sharpness {PeakSharpness:F2}, ceiling {LowlandCeilingM:F0} m (no env override)"); + GD.Print($"gallery : {gallerySize} calibration pool at {calibSize}"); + GD.Print($"seeds : {string.Join(", ", seeds)} (anchor: {anchor})"); + GD.Print($"batch : {batchRoot}"); + GD.Print($"yardstick : {WorldScale.Describe()}"); + GD.Print("=================================================================="); + + // ═══ INDEX-ONLY: rebuild the contact sheet from the recorded metrics ═══ + if (indexOnly) + { + var recorded = ReadMetrics(metricsPath); + if (recorded.Count == 0) + throw new InvalidOperationException( + $"ISLA_INDEX_ONLY=1 but no metrics at {metricsPath}. Run the gallery first — " + + "the index is rebuilt FROM a render, never instead of one."); + GD.Print($"\n--- INDEX ONLY — {recorded.Count} seeds from {metricsPath} ---"); + WriteIndex(batchRoot, recorded, ReadNotes(notesPath), anchor, gallerySize, calibSize, notesPath); + GD.Print($" INDEX.md rebuilt. No pixels were rendered."); + GetTree().Quit(0); + return; + } + + // ═══ 1. THE CURVE — re-measured exactly as task 03 did ═══ + GD.Print($"\n--- 1. CALIBRATION (task-01 pool at {calibSize}, as task 03) ---"); + + var rawPool = new LandHistogram(sea); + var poolPass1 = new Dictionary(); + foreach (int s in CalibrationSeeds) + { + var p1 = Topography.Generate(new TerrainGenConfig { MapSize = calibSize, Seed = s }); + poolPass1[s] = p1; + rawPool.Accumulate(p1.Height, calibSize); + } + var knots = new CurveKnots(2, "v2_balanced", + rawPool.Quantile(CurveKnots.Percentiles[0]), rawPool.Quantile(CurveKnots.Percentiles[1]), + rawPool.Quantile(CurveKnots.Percentiles[2]), rawPool.Quantile(CurveKnots.Percentiles[3]), + rawPool.Quantile(CurveKnots.Percentiles[4]), rawPool.Quantile(CurveKnots.Percentiles[5])); + + // Above-ceiling land, gated on RAW height, output measured off the staircase. + float ceilingRaw = knots.K2; + var rawAbove = new LandHistogram(sea); + var outAbove = new LandHistogram(sea); + foreach (int s in CalibrationSeeds) + { + var scfg = BaseConfig(calibSize, s, knots, anchors, "staircase"); + scfg.CurveMode = CurveModeKind.Staircase; + scfg.ShelfDetail = true; + Pass2Result st = Shaping.Shape(poolPass1[s], scfg); + rawAbove.AccumulateWhere(poolPass1[s].Height, poolPass1[s].Height, calibSize, ceilingRaw); + outAbove.AccumulateWhere(st.Height, poolPass1[s].Height, calibSize, ceilingRaw); + } + + var pcts = ClimbCalibration.DefaultPercentiles; + var rawQ = new float[pcts.Length]; + var outQ = new float[pcts.Length]; + for (int i = 0; i < pcts.Length; i++) + { + rawQ[i] = rawAbove.Quantile(pcts[i]); + outQ[i] = outAbove.Quantile(pcts[i]); + } + + // ⚠ Anchored on the POOL PRIMARY's spikeMax, exactly as task 03 did — reproducing the + // gated curve matters more here than improving it. (Task 03 §7.4 flags the anchor choice + // as worth revisiting; a gallery is not the place to revisit it.) + var calibration = ClimbCalibration.FromPercentiles( + pcts, rawQ, outQ, ceilingRaw, + HeightCurve.EffectiveSpikeMax(poolPass1[CalibrationSeeds[0]].HMaxSeed, knots, anchors), + anchors.RedCeil, anchors.PeakCap, MountainLift, PeakSharpness); + + GD.Print($" {knots}"); + GD.Print($" {calibration.Describe()}"); + + // ═══ 2. THE GALLERY ═══ + GD.Print($"\n--- 2. GALLERY at {gallerySize} ---"); + var metrics = new List(); + + foreach (int seed in seeds) + { + ulong t0 = Time.GetTicksMsec(); + + var cfg = BaseConfig(gallerySize, seed, knots, anchors, "restored"); + cfg.CurveMode = CurveModeKind.Continuous; + cfg.ClimbCalibration = calibration; + + Pass1Result p1 = Topography.Generate(cfg); + Pass2Result p2 = Shaping.Shape(p1, cfg); + + // ⚠ The two invariants that must hold on EVERY plate, checked per seed rather than + // assumed from task 03's two. A gallery that quietly rendered a broken seed would be + // the worst possible artifact: eight plates, one of them lying. + var classify = ShapingOracle.ClassifyFidelity(p1, p2); + var offCfg = BaseConfig(gallerySize, seed, knots, anchors, "off"); offCfg.Curve = false; + var seaId = ShapingOracle.SeaIdentity(Shaping.Shape(p1, offCfg), p2, sea); + if (!classify.Passed) GD.PrintErr($" ⚠⚠ seed {seed}: {classify}"); + if (!seaId.Passed) GD.PrintErr($" ⚠⚠ seed {seed}: {seaId}"); + + var m = WriteSeed(batchRoot, p1, p2, sea, anchors, skipRaw); + m.ClassifyOk = classify.Passed; + m.SeaOk = seaId.Passed; + m.ElapsedMs = Time.GetTicksMsec() - t0; + metrics.Add(m); + + GD.Print($" {seed,-11} land {m.LandPct,5:F1}% >100m {m.Above100,5:F2}% >220m {m.Above220,5:F2}% " + + $"p90 {WorldScale.MetresFromRaw(m.P90 - sea),6:F1}m " + + $"grad100-220 p50 {m.BandGradP50:F2} p95 {m.BandGradP95:F2} m/px hMax {p1.HMaxSeed:F3} " + + $"{(m.ClassifyOk && m.SeaOk ? "ok" : "⚠ CHECK FAILED")} {m.ElapsedMs / 1000.0:F0}s"); + } + + WriteMetrics(metricsPath, metrics); + WriteIndex(batchRoot, metrics, ReadNotes(notesPath), anchor, gallerySize, calibSize, notesPath); + + bool allOk = metrics.TrueForAll(m => m.ClassifyOk && m.SeaOk); + GD.Print("\n=================================================================="); + GD.Print($" DONE — {batchRoot}"); + GD.Print($" per-seed invariants: {(allOk ? "ALL PASS" : "*** A SEED FAILED — see above ***")}"); + GD.Print($" ⚠ character notes are a HUMAN call — write {notesPath}"); + GD.Print($" then re-run with ISLA_INDEX_ONLY=1 to fill them in."); + GD.Print("=================================================================="); + GetTree().Quit(allOk ? 0 : 3); + } + + // ---- per-seed record ------------------------------------------------- + + private sealed class SeedMetrics + { + public int Seed; + public float HMin, HMax, HMaxSeed; + public double LandPct, Above100, Above220; + public float P50, P75, P90, P99; + public float MaxMidSlope, MinSlope; // curve-space; identical every seed by construction + public float BandGradP50, BandGradP95; // ⭐ metres per pixel through the 100-220 m band + public long BandCells; + public float GrayMin, GrayMax; + public bool ClassifyOk = true, SeaOk = true; + public ulong ElapsedMs; + } + + private static TerrainGenConfig BaseConfig(int mapSize, int seed, CurveKnots k, CurveAnchors a, string label) + => new TerrainGenConfig + { + MapSize = mapSize, Seed = seed, VariantLabel = label, + Curve = true, ShelfDetail = false, Knots = k, Anchors = a, + LowlandCeilingM = LowlandCeilingM, + }; + + private static SeedMetrics WriteSeed(string batchRoot, Pass1Result p1, Pass2Result p2, + float sea, CurveAnchors anchors, bool skipRaw) + { + string dir = Path.Combine(batchRoot, $"{p2.Seed}"); + DirAccess.MakeDirRecursiveAbsolute(dir); + + // ⭐ Plain data beside the pretty render. The grayscale is the honest instrument; the + // relief is for eye-appeal and hillshade. + var (gMin, gMax) = GrayscaleRenderer.SavePng(p2.Height, p2.MapSize, Path.Combine(dir, "grayscale.png")); + if (!skipRaw) HeightField.Save(p2.Height, p2.MapSize, Path.Combine(dir, "height.f32")); + + var look = new LookConfig + { + Name = "hillshade_even", Palette = ReliefPalette.Kind.ProvisionalEven, + ZExaggeration = 18f, LightAzimuth = 315f, LightAltitude = 45f, + HillshadeStrength = 0.30f, SeaLevel = sea, + }; + Image map = ReliefRenderer.Render(p2.Height, p2.MapSize, look); + LegendRenderer.WithLegend(map, look.Palette, sea, anchors.PeakCap, $"SEED {p2.Seed}") + .SavePng(Path.Combine(dir, "relief.png")); + + var (a100, a220) = ShapingOracle.LandAbove(p2, sea); + var land = new LandHistogram(sea); + land.Accumulate(p2.Height, p2.MapSize); + + // ⚠ Curve-space slope: recorded for completeness, but it is the SAME on every seed by + // construction (see the type header). It is NOT the discriminator. + var (minN, _, maxMid, _, _, _) = p2.Continuous.SampleClimbSlopes(); + + // ⭐ THE ACTUAL DISCRIMINATOR: how fast the ground climbs through 100-220 m, in metres + // per pixel. This is what "the yellow-to-orange step looks abrupt" is a reaction to. + var (bandP50, bandP95, bandCells) = MidBandGradient(p2.Height, p2.MapSize, sea); + + return new SeedMetrics + { + Seed = p2.Seed, HMin = p2.HMin, HMax = p2.HMax, HMaxSeed = p1.HMaxSeed, + LandPct = p2.LandFraction(sea) * 100.0, Above100 = a100, Above220 = a220, + P50 = land.Quantile(50), P75 = land.Quantile(75), + P90 = land.Quantile(90), P99 = land.Quantile(99), + MaxMidSlope = maxMid, MinSlope = minN, GrayMin = gMin, GrayMax = gMax, + BandGradP50 = bandP50, BandGradP95 = bandP95, BandCells = bandCells, + }; + } + + /// + /// Median and p95 of the spatial height gradient, in METRES PER PIXEL, over land cells whose + /// height falls in the 100-220 m band — the stretch the developer flagged as reading abrupt. + /// + /// Central differences on the interior; edge cells are skipped rather than one-sided, because + /// the map border is the Trench's synthetic wall and its gradient is not terrain. + /// + /// ⚠ Gradients are collected into a coarse histogram rather than a list: at 8192 the band can + /// hold millions of cells and sorting them all to take two quantiles would cost more than the + /// render. 0.01 m/px bins are far finer than any difference worth reading. + /// + private static (float p50, float p95, long cells) MidBandGradient(float[,] h, int n, float sea) + { + float lo = sea + WorldScale.RawFromMetres(100f); + float hi = sea + WorldScale.RawFromMetres(220f); + + const float binW = 0.01f; // metres per pixel + const int bins = 4000; // up to 40 m/px — far beyond anything real + var hist = new long[bins + 1]; + long cells = 0; + + for (int x = 1; x < n - 1; x++) + { + for (int y = 1; y < n - 1; y++) + { + float v = h[x, y]; + if (v < lo || v > hi) continue; + + float gx = (h[x + 1, y] - h[x - 1, y]) * 0.5f; + float gy = (h[x, y + 1] - h[x, y - 1]) * 0.5f; + float g = WorldScale.MetresFromRaw(MathF.Sqrt(gx * gx + gy * gy)); + + int b = (int)(g / binW); + hist[b >= bins ? bins : b]++; + cells++; + } + } + + if (cells == 0) return (0f, 0f, 0); + + float Q(double q) + { + long target = (long)(q * cells), cum = 0; + for (int i = 0; i <= bins; i++) + { + cum += hist[i]; + if (cum >= target) return (i + 0.5f) * binW; + } + return bins * binW; + } + return (Q(0.50), Q(0.95), cells); + } + + // ---- metrics + notes round-trip ------------------------------------- + + private static void WriteMetrics(string path, List ms) + { + var sb = new StringBuilder(); + sb.AppendLine("seed\tland\ta100\ta220\tp50\tp75\tp90\tp99\tmidslope\tminslope\thmaxseed\tgmin\tgmax\tok\tms\tgradp50\tgradp95\tbandcells"); + foreach (var m in ms) + sb.AppendLine($"{m.Seed}\t{m.LandPct:F2}\t{m.Above100:F3}\t{m.Above220:F3}\t{m.P50:F4}\t{m.P75:F4}\t" + + $"{m.P90:F4}\t{m.P99:F4}\t{m.MaxMidSlope:F4}\t{m.MinSlope:F4}\t{m.HMaxSeed:F4}\t" + + $"{m.GrayMin:F4}\t{m.GrayMax:F4}\t{(m.ClassifyOk && m.SeaOk ? 1 : 0)}\t{m.ElapsedMs}\t" + + $"{m.BandGradP50:F4}\t{m.BandGradP95:F4}\t{m.BandCells}"); + + using var f = Godot.FileAccess.Open(path, Godot.FileAccess.ModeFlags.Write); + if (f == null) { GD.PrintErr($"could not write {path}"); return; } + f.StoreString(sb.ToString()); + } + + private static List ReadMetrics(string path) + { + var outp = new List(); + if (!Godot.FileAccess.FileExists(path)) return outp; + + using var f = Godot.FileAccess.Open(path, Godot.FileAccess.ModeFlags.Read); + if (f == null) return outp; + + string all = f.GetAsText(); + bool header = true; + foreach (string line in all.Split('\n')) + { + if (header) { header = false; continue; } + if (string.IsNullOrWhiteSpace(line)) continue; + string[] c = line.Split('\t'); + if (c.Length < 15) continue; // older rows without the gradient columns still load + outp.Add(new SeedMetrics + { + Seed = int.Parse(c[0]), LandPct = double.Parse(c[1]), + Above100 = double.Parse(c[2]), Above220 = double.Parse(c[3]), + P50 = float.Parse(c[4]), P75 = float.Parse(c[5]), + P90 = float.Parse(c[6]), P99 = float.Parse(c[7]), + MaxMidSlope = float.Parse(c[8]), MinSlope = float.Parse(c[9]), + HMaxSeed = float.Parse(c[10]), GrayMin = float.Parse(c[11]), GrayMax = float.Parse(c[12]), + ClassifyOk = c[13].Trim() == "1", SeaOk = c[13].Trim() == "1", + ElapsedMs = ulong.Parse(c[14].Trim()), + BandGradP50 = c.Length > 15 ? float.Parse(c[15]) : 0f, + BandGradP95 = c.Length > 16 ? float.Parse(c[16]) : 0f, + BandCells = c.Length > 17 ? long.Parse(c[17].Trim()) : 0L, + }); + } + return outp; + } + + /// + /// The human character notes, if they have been written yet. Format: seed\tnote. + /// Absent is a normal state, not an error — the first run cannot have them. + /// + private static Dictionary ReadNotes(string path) + { + var notes = new Dictionary(); + if (!Godot.FileAccess.FileExists(path)) return notes; + + using var f = Godot.FileAccess.Open(path, Godot.FileAccess.ModeFlags.Read); + if (f == null) return notes; + + foreach (string line in f.GetAsText().Split('\n')) + { + if (string.IsNullOrWhiteSpace(line) || line.TrimStart().StartsWith("#")) continue; + string[] c = line.Split('\t'); + if (c.Length < 2) continue; + if (int.TryParse(c[0].Trim(), out int s)) notes[s] = c[1].Trim(); + } + return notes; + } + + // ---- the contact sheet ---------------------------------------------- + + private static void WriteIndex(string batchRoot, List ms, Dictionary notes, + int anchor, int gallerySize, int calibSize, string notesPath) + { + var sb = new StringBuilder(); + sb.AppendLine("# Batch 04 — seed gallery: the committed curve across 8 draws"); + sb.AppendLine(); + sb.AppendLine("**A contact sheet, not a tuning batch.** Every plate is the SAME curve — the one committed"); + sb.AppendLine("at tag `terrain-curve-v1` (chat2/03 `continuous_restored`). The question is whether it holds"); + sb.AppendLine("up across draws, or whether the anchor seed was lucky."); + sb.AppendLine(); + sb.AppendLine($"- **Curve (pinned, no env override):** `mountainLift {MountainLift:F2}` · " + + $"`peakSharpness {PeakSharpness:F2}` · `lowlandCeiling {LowlandCeilingM:F0} m`"); + sb.AppendLine($"- **Rendered at:** {gallerySize} · **calibration pool measured at:** {calibSize} (task 01's six seeds)"); + sb.AppendLine($"- **Anchor:** `{anchor}` — the plate tasks 01–03 were judged on. **Compare the others to it.**"); + sb.AppendLine($"- **Palette:** ⚠ `ProvisionalEven` — evenly spaced SEA → 420 m, **provisional**. The"); + sb.AppendLine(" grayscale is the honest instrument; the relief is for shape and hillshade."); + sb.AppendLine(); + sb.AppendLine("## ⭐ The contact sheet"); + sb.AppendLine(); + + bool anyNotes = notes.Count > 0; + sb.AppendLine("| Seed | Character | Relief | Grayscale | land | >100 m | >220 m | p90 | 100–220 m grade (p50 / p95) |"); + sb.AppendLine("|---|---|---|---|---|---|---|---|---|"); + foreach (var m in ms) + { + string tag = m.Seed == anchor ? " ⭐" : ""; + string note = notes.TryGetValue(m.Seed, out string n) ? n : "*(pending)*"; + string warn = m.ClassifyOk && m.SeaOk ? "" : " ⚠⚠ INVARIANT FAILED"; + sb.AppendLine($"| `{m.Seed}`{tag} | {note}{warn} | [`{m.Seed}/relief.png`]({m.Seed}/relief.png) | " + + $"[`grayscale.png`]({m.Seed}/grayscale.png) | {m.LandPct:F1}% | {m.Above100:F2}% | " + + $"{m.Above220:F2}% | {WorldScale.MetresFromRaw(m.P90 - 0.15f):F0} m | " + + $"{m.BandGradP50:F2} / {m.BandGradP95:F2} m per px |"); + } + sb.AppendLine(); + + if (!anyNotes) + { + sb.AppendLine("> ⚠ **Character notes are pending.** They are an EYE call and this tool will not invent"); + sb.AppendLine($"> them. Write `{Path.GetFileName(notesPath)}` in `scratch/` as `seednote` lines, then"); + sb.AppendLine("> re-run with `ISLA_INDEX_ONLY=1` to rebuild this table without re-rendering."); + sb.AppendLine(); + } + + sb.AppendLine("**100–220 m grade** is the spatial height gradient through the band where the yellow→orange"); + sb.AppendLine("transition sits — metres of climb per pixel, median and p95 over the land in that band. It is"); + sb.AppendLine("the objective companion to the eye's *\"is that step abrupt?\"*: near-constant across seeds"); + sb.AppendLine("means abruptness is a **curve trait** worth a tuning pass; a wide spread means it is a **draw**."); + sb.AppendLine(); + sb.AppendLine("> ⚠ The curve's own normalized mid-slope is deliberately NOT tabulated: it is `dv/du` on a"); + sb.AppendLine("> fixed control polygon, and each seed's denormalization rescales both axes, so it is the same"); + sb.AppendLine("> number on every seed **by construction** and can discriminate nothing."); + sb.AppendLine(); + sb.AppendLine("## Spread"); + sb.AppendLine(); + if (ms.Count > 0) + { + double lo100 = double.MaxValue, hi100 = double.MinValue, sum100 = 0; + float loMid = float.MaxValue, hiMid = float.MinValue; + double sumG = 0; + foreach (var m in ms) + { + lo100 = Math.Min(lo100, m.Above100); hi100 = Math.Max(hi100, m.Above100); sum100 += m.Above100; + loMid = MathF.Min(loMid, m.BandGradP50); hiMid = MathF.Max(hiMid, m.BandGradP50); + sumG += m.BandGradP50; + } + sb.AppendLine($"- **land >100 m:** {lo100:F2} % … {hi100:F2} % (mean {sum100 / ms.Count:F2} %)"); + sb.AppendLine($"- **100–220 m grade (p50):** {loMid:F2} … {hiMid:F2} m per px " + + $"(mean {sumG / ms.Count:F2}) — spread {(hiMid - loMid) / (sumG / ms.Count) * 100:F0}% of the mean"); + sb.AppendLine(); + } + sb.AppendLine("## Disposability"); + sb.AppendLine(); + sb.AppendLine("| Artifact | Keep? |"); + sb.AppendLine("|---|---|"); + sb.AppendLine("| `relief.png`, `INDEX.md` | **keep** — the gallery |"); + sb.AppendLine("| `grayscale.png` | ♻ regenerable from the `.f32` |"); + sb.AppendLine("| `height.f32` | ♻ regenerable from seed + the tagged curve — **large, clear freely** |"); + sb.AppendLine("| `scratch/metrics.tsv`, `scratch/character_notes.tsv` | **keep** — the INDEX is rebuilt from them |"); + sb.AppendLine(); + sb.AppendLine("Every `.f32` here is reproducible from `git checkout terrain-curve-v1` plus the seed, so the"); + sb.AppendLine("bulk of this batch is safe to delete once the gallery has been judged."); + + string index = Path.Combine(batchRoot, "INDEX.md"); + using var f = Godot.FileAccess.Open(index, Godot.FileAccess.ModeFlags.Write); + if (f == null) { GD.PrintErr($"could not write {index}"); return; } + f.StoreString(sb.ToString()); + } + + // ---- env helpers ---------------------------------------------------- + + 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 outp = new List(); + foreach (string part in v.Split(',', StringSplitOptions.RemoveEmptyEntries)) + if (int.TryParse(part.Trim(), out int s) && s > 0) outp.Add(s); + return outp.Count > 0 ? outp.ToArray() : fallback; + } + } +} diff --git a/Tools/Scripts/SeedGalleryTool.cs.uid b/Tools/Scripts/SeedGalleryTool.cs.uid new file mode 100644 index 0000000..136a65d --- /dev/null +++ b/Tools/Scripts/SeedGalleryTool.cs.uid @@ -0,0 +1 @@ +uid://bkh7iu47is60q