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
337 lines
14 KiB
C#
337 lines
14 KiB
C#
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();
|
||
}
|
||
}
|
||
}
|