islaApocalypse-v2/Tools/Scripts/HistogramRenderer.cs
beezm 35b4818e9e Phase 2a: the faithful redistribution curve, re-measured against v2's own output
Ports the reference's v5 height curve and shelf-detail passes onto Phase 1's shape
and re-calibrates them against this repo's actual pass-1 distribution. This is the
BASELINE the reshape gets judged against, not the reshape.

Core (engine-free, D-060):
- WorldScale — THE vertical yardstick. One metres/raw number (251), replacing the
  prototype's three duplicate M_PER_UNIT constants and ~20 bare literals. The
  chunk-height coupling it had there is recorded as a DEFERRED vault decision, not
  inherited. RawFromMetres divides, matching the reference bit-for-bit.
- HeightCurve — the 7 bands, the frozen corner-fix blends, the per-seed spike
  normalization, the 24-corner monotonicity sweep that throws and refuses.
  Identity at and below sea, which everything downstream rests on.
- CurveKnots / CurveAnchors — input knots (measured percentiles) and output anchors
  (storm ladder) split apart and both made parameters, so the anchors are A/B-able
  without editing source. The reference's shipped knots are kept beside the measured
  ones as the fidelity yardstick.
- TerrainDetailPass — micro-relief skin plus the shelf-edge KNOT warp (which slides
  K3/K4/K5, not height — that is what keeps monotonicity structural). The crater
  exclusion is ported and inert until the carve lands.

Tools:
- Shaping — pass 2a, producing the two height fields. classify is bit-for-bit the
  raw pass-1 field; render is curved and detailed. Aliased when the curve is off,
  as the reference did. Pass1Result is left immutable so the oracle can compare.
- LandHistogram — the calibration engine AND the diagnostic. The reference shipped
  six knot literals and threw the measuring instrument away; this rebuilds it.
- ShapingOracle + CurveBaselineTool — four automatic checks before anything is
  looked at, and the batch that runs them.

Measured, not assumed:
- Knots re-measured over a 6-seed / 12.8M-sample pool. They differ from the
  reference's by at most 5.6 m of world height, against a 44.7 m per-seed spread —
  the pass-1 port is faithful.
- Oracle all pass, including pass 1 bit-identical to Phase 1's own .f32 dump.
- Band shares land on 60/13/10/5/8/3/1 to 0.00 pp.
- Knots hold across map size: the 8K delta (5.8 m) sits inside seed noise.

The finding the histograms deliver: 83% of land ends below 100 m and 96% below
220 m, with the median column at 13 m. That is the share targets doing exactly what
they say, not a bug — and it is the developer's call, which is why nothing here
reshapes it and the palette was deliberately left mis-fitted rather than recalibrated
to disguise it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DCWNaDZPfTiAy3meGNGgqt
2026-08-20 01:38:10 -04:00

337 lines
14 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 Godot;
namespace IslaApocalypse.Tools
{
/// <summary>
/// Draws a <see cref="LandHistogram"/> as a labelled plot — the picture the developer reads
/// before deciding what the curve should become.
///
/// ═══ WHY A PLOT AND NOT JUST THE TABLE ═══
///
/// The per-band mass table is the EVIDENCE; this is what makes the evidence obvious at a glance.
/// The three candidate causes of flat upper terrain have three different SHAPES here, and the
/// shape is recognisable in a second where a column of numbers takes a minute:
///
/// • curve squashing — the SHAPED plot spikes hard at the bench and plateau anchors.
/// Mass that was spread out arrives stacked.
/// • noise empty up high — the RAW plot's right-hand tail is a long, flat, almost-invisible
/// sliver. There is nothing above P96 to redistribute.
/// • share allocation — neither plot is odd, and the band overlays simply show that the
/// top two bands were only ever allotted 4 % of the land.
///
/// ⚠ LINEAR Y, DELIBERATELY. A log axis would make the upper tail look substantial — which is
/// precisely the question being asked. If the top of the distribution is a sliver, the plot must
/// show a sliver. The peak bin count is printed so the vertical scale is never a mystery.
///
/// ⚠ Presentation only, and it cannot be otherwise: it is handed a histogram and returns a PNG.
/// It has no access to a height field and no way to produce one.
///
/// Text is <see cref="TinyFont"/> (5×7 bitmap), specifically so this does not drag in the
/// SubViewport capture path — which awaits render frames and is why `--headless` hangs.
/// ⚠ The font is uppercase, digits and <c>. - : / ( )</c> only. Unsupported characters render as
/// blanks, so labels here say "PCT" rather than "%" and avoid commas.
/// </summary>
public static class HistogramRenderer
{
/// <summary>A vertical reference line — a knot, or an output anchor.</summary>
public sealed class Marker
{
public float Value;
public string Label;
/// <summary>Strong markers get a brighter line; use for the ones that carry the argument.</summary>
public bool Strong = true;
}
/// <summary>A shaded span between two values, labelled underneath. The curve's bands.</summary>
public sealed class Band
{
public float Lo, Hi;
public string Label;
/// <summary>Share of land in this band, in percent. Drawn under the label.</summary>
public double SharePercent;
}
public sealed class Options
{
public string Title = "LAND HEIGHT DISTRIBUTION";
public string Subtitle = "";
public string Footer = "";
public string XAxisLabel = "RAW HEIGHT";
/// <summary>Right edge of the x axis, raw. Defaults to the histogram's max land height.</summary>
public float XTop = 0f;
public int Width = 1800;
public int Height = 1000;
public List<Marker> Markers = new();
public List<Band> Bands = new();
}
// A dark plate, so these sit beside the relief renders rather than glaring next to them.
private static readonly Color Paper = new(0.098f, 0.106f, 0.125f);
private static readonly Color Ink = new(0.941f, 0.949f, 0.961f);
private static readonly Color Faint = new(0.565f, 0.596f, 0.643f);
private static readonly Color Grid = new(0.192f, 0.208f, 0.243f);
private static readonly Color BarColor = new(0.380f, 0.760f, 0.780f); // the distribution itself
private static readonly Color MarkStrong = new(0.980f, 0.720f, 0.300f); // knots — the argument
private static readonly Color MarkSoft = new(0.620f, 0.560f, 0.780f); // anchors — context
private static readonly Color BandA = new(0.145f, 0.161f, 0.196f);
private static readonly Color BandB = new(0.118f, 0.129f, 0.157f);
private static readonly Color OverColor = new(0.980f, 0.560f, 0.290f); // bars that exceed the clipped axis
/// <summary>
/// The clipped y-axis ceiling: 3× the 90th-percentile non-empty bin, so ordinary structure
/// fills the plot while a single dominating spike is cut off and MARKED.
///
/// ⚠ Returns <paramref name="peak"/> unchanged when nothing dominates — a plot is only
/// clipped when clipping actually buys legibility, never as a default. The threshold is on
/// the bin DISTRIBUTION rather than a fixed number so it adapts to whatever is handed in.
/// </summary>
private static long ClipCap(LandHistogram h, long peak)
{
var nonEmpty = new List<long>();
for (int i = 0; i < h.BinCount; i++)
if (h.BinCountAt(i) > 0) nonEmpty.Add(h.BinCountAt(i));
if (nonEmpty.Count < 8) return peak;
nonEmpty.Sort();
long p90 = nonEmpty[(int)(nonEmpty.Count * 0.90)];
long cap = Math.Max(1, p90 * 3);
// Only worth clipping if the spike really is off the scale of everything else. On a
// well-spread distribution (the RAW plot) this is false and the axis stays true.
return cap * 2 < peak ? cap : peak;
}
/// <summary>Render and save. Returns the path written.</summary>
public static string SavePng(LandHistogram h, Options o, string absolutePath)
{
Image img = Render(h, o);
Error err = img.SavePng(absolutePath);
if (err != Error.Ok) GD.PrintErr($"[HistogramRenderer] SavePng failed ({err}) for {absolutePath}");
return absolutePath;
}
public static Image Render(LandHistogram h, Options o)
{
int W = o.Width, H = o.Height;
var img = Image.CreateEmpty(W, H, false, Image.Format.Rgb8);
img.Fill(Paper);
// ---- layout, derived from text metrics rather than guessed fractions ----
const int titleScale = 4, labelScale = 2, tickScale = 2;
int marginL = 130, marginR = 50;
// Room for: title, subtitle, the clip banner, and TWO staggered rows of marker labels.
int marginT = 40 + TinyFont.Height(titleScale) + 18 + TinyFont.Height(labelScale) + 20
+ (TinyFont.Height(labelScale) + 5) * 3;
// Room for: x ticks, then TWO staggered rows of two-line band labels.
int marginB = 30 + TinyFont.Height(tickScale) + 14
+ ((TinyFont.Height(labelScale) + 4) * 2 + 6) * 2 + 16;
int plotX = marginL, plotY = marginT;
int plotW = W - marginL - marginR;
int plotH = H - marginT - marginB;
if (plotW < 64 || plotH < 64) return img; // absurd canvas — a broken plot is worse than none
float xLo = h.SeaLevel;
float xHi = o.XTop > xLo ? o.XTop : (h.MaxLand > xLo ? h.MaxLand : xLo + 1f);
float xSpan = xHi - xLo;
int Px(float v) => plotX + (int)MathF.Round((v - xLo) / xSpan * (plotW - 1));
// ---- band shading, behind everything ----
bool alt = false;
foreach (Band b in o.Bands)
{
int x0 = Math.Clamp(Px(b.Lo), plotX, plotX + plotW - 1);
int x1 = Math.Clamp(Px(b.Hi), plotX, plotX + plotW - 1);
Color c = alt ? BandA : BandB;
alt = !alt;
for (int x = x0; x <= x1; x++)
for (int y = plotY; y < plotY + plotH; y++)
img.SetPixel(x, y, c);
}
// ---- horizontal grid at quarters ----
for (int i = 1; i < 4; i++)
{
int gy = plotY + plotH - (int)(plotH * (i / 4.0));
for (int x = plotX; x < plotX + plotW; x++) img.SetPixel(x, gy, Grid);
}
// ---- the bars ----
//
// ⚠ THE VERTICAL SCALE IS CLIPPED, AND THE CLIP IS DRAWN. The curve piles 60 % of land
// into a 14 m band, so one bin can be 20× its neighbours; at full scale every other
// feature — the bench bump, the plateau bump, the whole upper tail — flattens to the
// axis and the plot shows one spike and nothing else. Clipping makes the rest readable;
// COLOURING the clipped part and printing both numbers is what keeps it honest. A
// silently truncated axis would be a lie told in the most trusted artefact in the batch.
long peak = h.PeakBinCount();
long cap = ClipCap(h, peak);
bool clipped = cap < peak;
if (cap > 0)
{
for (int i = 0; i < h.BinCount; i++)
{
long c = h.BinCountAt(i);
if (c == 0) continue;
float lo = h.BinLow(i), hi = lo + h.BinWidth;
if (hi < xLo || lo > xHi) continue;
int x0 = Math.Clamp(Px(lo), plotX, plotX + plotW - 1);
int x1 = Math.Clamp(Px(hi), plotX, plotX + plotW - 1);
if (x1 < x0) x1 = x0;
bool over = c > cap;
int barH = (int)MathF.Round((float)(Math.Min(c, cap) / (double)cap) * (plotH - 1));
// A non-empty bin always paints at least one pixel — otherwise the thin upper
// tail vanishes entirely and the plot argues the opposite of the data.
if (barH < 1) barH = 1;
Color c1 = over ? OverColor : BarColor;
for (int x = x0; x <= x1; x++)
for (int y = plotY + plotH - barH; y < plotY + plotH; y++)
img.SetPixel(x, y, c1);
}
}
// ---- axes ----
for (int x = plotX; x < plotX + plotW; x++) img.SetPixel(x, plotY + plotH, Faint);
for (int y = plotY; y <= plotY + plotH; y++) img.SetPixel(plotX, y, Faint);
// ---- markers (knots / anchors), over the bars ----
// ⚠ Same two-row stagger as the band labels, for the same reason: K3 and K4 are five
// percentiles apart and their labels would otherwise overprint into a false reading.
// The LINE is always drawn even when its label is staggered — the position is the data;
// the text is the convenience.
var mRowRight = new[] { int.MinValue, int.MinValue };
int mRowH = TinyFont.Height(labelScale) + 5;
for (int mi = 0; mi < o.Markers.Count; mi++)
{
Marker m = o.Markers[mi];
if (m.Value < xLo || m.Value > xHi) continue;
int mx = Math.Clamp(Px(m.Value), plotX, plotX + plotW - 1);
Color c = m.Strong ? MarkStrong : MarkSoft;
for (int y = plotY; y <= plotY + plotH; y++)
{
// Dashed for the soft ones, so a dense cluster stays readable.
if (!m.Strong && ((y / 6) & 1) == 0) continue;
img.SetPixel(mx, y, c);
}
int lw = TinyFont.Width(m.Label, labelScale);
int lx = mx - lw / 2;
int row = 0;
if (lx <= mRowRight[0] + 8)
{
row = 1;
if (lx <= mRowRight[1] + 8) continue; // both rows taken — the line still stands
}
lx = Math.Clamp(lx, plotX, plotX + plotW - lw);
TinyFont.Draw(img, m.Label, lx, plotY - TinyFont.Height(labelScale) - 6 - (1 - row) * mRowH,
labelScale, c);
mRowRight[row] = lx + lw;
}
// ---- band labels + shares, under the axis ----
//
// ⚠ STAGGERED ACROSS TWO ROWS, AND A COLLIDING LABEL IS DROPPED RATHER THAN OVERPRINTED.
// The curve's bench and plateau bands are ~6 m wide, so at map scale their labels sit
// almost on top of their neighbours: the first cut rendered "TOE/ORANGERED" and
// "59.9 PCT19 PCT", which is worse than no label because it reads as a value.
// A leader line ties each surviving label to its band, so a dropped one is visibly
// dropped rather than silently mis-attributed.
int bandLabelY = plotY + plotH + 12 + TinyFont.Height(tickScale) + 10;
int rowH = (TinyFont.Height(labelScale) + 4) * 2 + 6;
var rowRight = new[] { int.MinValue, int.MinValue };
for (int bi = 0; bi < o.Bands.Count; bi++)
{
Band b = o.Bands[bi];
int cx = (Px(b.Lo) + Px(b.Hi)) / 2;
string l1 = b.Label.ToUpperInvariant();
string l2 = $"{b.SharePercent:F1} PCT";
int w = Math.Max(TinyFont.Width(l1, labelScale), TinyFont.Width(l2, labelScale));
int row = bi & 1; // stagger: alternate rows first
int left = cx - w / 2;
if (left <= rowRight[row] + 8) // still colliding on that row? try the other
{
row ^= 1;
if (left <= rowRight[row] + 8) continue; // both taken — drop it, do not overprint
}
left = Math.Clamp(left, 2, W - w - 2);
int y = bandLabelY + row * rowH;
// Leader line from the band's centre down to its label.
for (int ly = plotY + plotH + 2; ly < y - 2; ly++)
if ((ly & 1) == 0) img.SetPixel(Math.Clamp(cx, 0, W - 1), ly, Grid);
TinyFont.Draw(img, l1, left, y, labelScale, Faint);
TinyFont.Draw(img, l2, left, y + TinyFont.Height(labelScale) + 4, labelScale, Ink);
rowRight[row] = left + w;
}
// ---- x ticks ----
int tickCount = 8;
for (int i = 0; i <= tickCount; i++)
{
float v = xLo + xSpan * i / tickCount;
int tx = Px(v);
for (int t = 0; t < 6; t++) img.SetPixel(tx, plotY + plotH + t, Faint);
string lab = v.ToString("0.00");
int lw = TinyFont.Width(lab, tickScale);
TinyFont.Draw(img, lab, Math.Clamp(tx - lw / 2, 2, W - lw - 2), plotY + plotH + 10, tickScale, Faint);
}
// ---- y axis: the scale, and the clip if there is one ----
TinyFont.Draw(img, Thousands(cap), 8, plotY - 4, tickScale, clipped ? OverColor : Faint);
TinyFont.Draw(img, "0", 8, plotY + plotH - TinyFont.Height(tickScale), tickScale, Faint);
TinyFont.Draw(img, "COUNT", 8, plotY + plotH / 2, tickScale, Faint);
if (clipped)
{
// Said twice, on the image, in the clip's own colour — because a reader who misses
// this misreads the whole plot.
TinyFont.Draw(img, "CLIPPED", 8, plotY + 6 + TinyFont.Height(tickScale), tickScale, OverColor);
TinyFont.Draw(img, $"TRUE PEAK {Thousands(peak)} - AMBER BARS EXCEED THE CLIPPED AXIS",
plotX, plotY - TinyFont.Height(labelScale) - 6 - mRowH * 2 - 6, labelScale, OverColor);
}
// ---- titles ----
TinyFont.Draw(img, o.Title, marginL, 40, titleScale, Ink);
if (!string.IsNullOrEmpty(o.Subtitle))
TinyFont.Draw(img, o.Subtitle, marginL, 40 + TinyFont.Height(titleScale) + 14, labelScale, Faint);
// ---- footer ----
if (!string.IsNullOrEmpty(o.Footer))
TinyFont.Draw(img, o.Footer, marginL, H - TinyFont.Height(labelScale) - 20, labelScale, Faint);
TinyFont.Draw(img, o.XAxisLabel, plotX + plotW - TinyFont.Width(o.XAxisLabel, labelScale),
H - TinyFont.Height(labelScale) - 20, labelScale, Faint);
return img;
}
/// <summary>
/// Group digits with spaces. ⚠ Not commas: the font has no comma glyph, so "1,234" would
/// render as "1 234" anyway — better to mean it than to have it happen.
/// </summary>
private static string Thousands(long v)
{
string s = v.ToString();
var sb = new System.Text.StringBuilder();
for (int i = 0; i < s.Length; i++)
{
if (i > 0 && (s.Length - i) % 3 == 0) sb.Append(' ');
sb.Append(s[i]);
}
return sb.ToString();
}
}
}