islaApocalypse-v2/Tools/Scripts/ReliefPalette.cs
beezm 8e55326a84 Phase 2a: continuous grade — smooth the upper staircase, preserve the lowlands
rev 3 of the curve redesign. The developer's verdict on the 01 baseline was that the
LOWLANDS ARE GOOD; the fault is the terracing above them. So this adds a second curve
mode that preserves the low plain bit-for-bit and replaces everything above the flood
line with one smooth monotone climb.

Core/ContinuousCurve — piecewise, and the pieces have different loyalties:
- at/below sea: identity, as ever.
- above sea to K2: DELEGATES to HeightCurve's own toe+red branches. Not "equivalent" —
  the same code path, so the same floats. Oracle (d) holds it to that.
- above the ceiling: a Fritsch-Carlson (PCHIP) monotone spline to the 420 m cap,
  C1-joined to the red band's exit slope. Monotone by construction for any ordered
  control points, which retires the 24-corner sweep; a 10k strict-increase sample runs
  per seed anyway, because "cannot fail" is worth a millisecond.
- Build() REFUSES rather than degrades: a ceiling near the old bench, a drama that folds
  the summit under its own onset, control-point secants that are not strictly increasing
  (the no-magnet rule, enforced rather than hoped for).

Only BENCH_*/PLATEAU_* are dropped. SEA/ORANGE_CEIL/RED_CEIL survive because they are
the storm-ladder FLOOD TIERS and they live inside the preserved lowland; PEAK_CAP and
the per-seed spikeMax normalization survive as the summit.

Shelf detail is forced off in continuous mode: the flat benches it de-slabbed no longer
exist, and painting noise on the climb now would pre-judge what erosion should carve.

Oracle, all hard checks passing:
- (a1) curve off is bit-identical to Phase 1's dump.
- (a2) staircase mode is bit-identical to TASK 01's dump — the control is provably the
  control, not a re-derivation. (CurveBaselineTool is pinned to Staircase so the config
  default moving to Continuous cannot drift it.)
- (d) lowlands bit-identical to the staircase over 3.6M cells, every continuous variant,
  both seeds. The lifted_WRONG bookend fails it on 1.6M cells, as intended.
- (f) sea identity per CELL, not per count, including 67M cells at 8192.

The finding, measured and recorded in the batch scratch: the massif SHRANK. Land above
100 m goes 14.9% -> 4.8%, above 220 m 4.5% -> 0.6%. A feather sweep to the practical
floor recovers ~1.3 points, so this is structural, not a tuning miss: the staircase's
highland area was an artifact of the bench and plateau acting as magnets, and a curve
with no magnets preserves the raw distribution's bottom-heavy shape. "No terraces" and
"the same land up high" are not both available from curve work alone.

Exploration batch, not convergence. A tuning pass follows once a direction is picked.

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

225 lines
11 KiB
C#
Raw Permalink 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 Godot;
namespace IslaApocalypse.Tools
{
/// <summary>
/// The hypsometric palettes — a continuous elevation tint, in the physical-atlas tradition.
///
/// ═══ ⚠⚠ THIS IS CARTOGRAPHY, NOT CLASSIFICATION ═══
///
/// There are NO BIOME WORDS here and there must never be any. Nothing in this file knows about
/// jungle, wasteland, desert or snow. It maps ONE FLOAT — height — onto a continuous colour
/// ramp. White at the top is snow-COLOURED cartography; green at the bottom is
/// lowland-COLOURED. Biomes are a stage-5 CLASSIFICATION of finished shape (D-049 §2, D-056),
/// and the entire point of the rewrite's seam is that terrain never learns their names.
///
/// ⚠ CONTINUOUS, NOT BANDED. The stops below are interpolated, never quantized. Hard bands
/// would read as discrete classes — exactly the visual language of a biome map, and exactly the
/// wrong thing to suggest.
///
/// ═══ ⚠ THE STOPS ARE IN ABSOLUTE HEIGHT, AND THEY ARE FIXED ═══
///
/// Every stop is a raw height value, not a normalized fraction, and the same stops are used for
/// every image in a batch. A map coloured on its own min/max cannot be compared with its
/// neighbour — and comparison is the entire point of a batch. Out-of-range values clamp; the
/// run prints the true min/max so saturation shows up as a number rather than a guess.
///
/// ═══ WHY THE STOPS SIT WHERE THEY DO — measured, not eyeballed ═══
///
/// Land height is very bottom-heavy. Measured over all four pinned seeds at 2048 (8.6M land
/// columns): p10 0.214 · p25 0.306 · MEDIAN 0.456 · p75 0.648 · p90 0.835 · p99 1.113 ·
/// p99.9 1.267 · max 1.415.
///
/// A ramp spread linearly from 0.15 to 1.45 would therefore spend 99% of its colour range on
/// 1% of the land, and the map would read as "green with a few white dots". So the land stops
/// are placed on PERCENTILES of the measured distribution instead — the same thing an atlas
/// plate does when it picks non-uniform contour intervals.
///
/// Sea is the mirror problem: depth runs to 6.7 but 43% of it is shallower than 0.5 and the
/// deep floor is featureless. So the bathymetric ramp is COMPRESSED — it is indexed by
/// <c>d / (d + k)</c>, which gives the shallow shelf most of the range and lets the abyss
/// flatten to one dark tone. The interesting bathymetry is near shore.
///
/// ⚠ THE SEABED IS RAW, AND IT IS SUPPOSED TO BE. The submarine coast shelf and the offshore
/// islets are DEFERRED Phase-2 items. Plain, steep bathymetry here is the deferral showing, not
/// a broken render.
/// </summary>
public static class ReliefPalette
{
/// <summary>
/// Named palettes. Gated so the look can be A/B'd like any other change.
/// ⚠ ProvisionalEven is chat2/02's stopgap for the continuous curve — see its array's note.
/// </summary>
public enum Kind { Atlas, Dusk, CostaRica, ProvisionalEven }
/// <summary>
/// Bathymetric compression constant, in raw height units. The sea ramp is indexed by
/// <c>d / (d + SeaCompression)</c>. Smaller = more range spent on the shallows.
/// At 0.35: depth 0.05 → 12.5% of the ramp, 0.5 → 59%, 2.0 → 85%, 6.7 → 95%.
/// </summary>
public const float SeaCompression = 0.35f;
// ---- LAND: (absolute height, colour), interpolated. Placed on measured percentiles. ----
private static readonly (float h, Color c)[] AtlasLand =
{
(0.150f, new Color(0.427f, 0.561f, 0.376f)), // shoreline — soft green
(0.220f, new Color(0.475f, 0.596f, 0.388f)), // p10 lowland green
(0.310f, new Color(0.549f, 0.635f, 0.404f)), // p25
(0.460f, new Color(0.667f, 0.686f, 0.435f)), // p50 yellow-green
(0.650f, new Color(0.780f, 0.729f, 0.494f)), // p75 khaki
(0.840f, new Color(0.808f, 0.678f, 0.478f)), // p90 tan
(0.980f, new Color(0.757f, 0.588f, 0.427f)), // p95 light brown
(1.120f, new Color(0.667f, 0.510f, 0.404f)), // p99 brown
(1.270f, new Color(0.706f, 0.667f, 0.639f)), // p99.9 grey rock
(1.450f, new Color(0.988f, 0.988f, 0.980f)), // peak white
};
private static readonly (float h, Color c)[] AtlasSea =
{
(0.000f, new Color(0.478f, 0.729f, 0.769f)), // waterline — pale teal
(0.125f, new Color(0.325f, 0.596f, 0.706f)), // shelf
(0.310f, new Color(0.196f, 0.451f, 0.627f)), // mid blue
(0.590f, new Color(0.114f, 0.310f, 0.510f)), // deep
(0.760f, new Color(0.063f, 0.196f, 0.376f)), // deeper
(0.880f, new Color(0.035f, 0.118f, 0.259f)), // navy
(1.000f, new Color(0.020f, 0.063f, 0.157f)), // abyss floor — flat
};
// ---- DUSK: warmer land, deeper cooler water. A genuine alternative, not a tweak. ----
private static readonly (float h, Color c)[] DuskLand =
{
(0.150f, new Color(0.353f, 0.494f, 0.353f)),
(0.220f, new Color(0.427f, 0.541f, 0.361f)),
(0.310f, new Color(0.529f, 0.588f, 0.376f)),
(0.460f, new Color(0.663f, 0.639f, 0.408f)),
(0.650f, new Color(0.796f, 0.690f, 0.451f)),
(0.840f, new Color(0.831f, 0.627f, 0.412f)),
(0.980f, new Color(0.780f, 0.522f, 0.361f)),
(1.120f, new Color(0.663f, 0.435f, 0.353f)),
(1.270f, new Color(0.678f, 0.612f, 0.588f)),
(1.450f, new Color(0.980f, 0.973f, 0.949f)),
};
private static readonly (float h, Color c)[] DuskSea =
{
(0.000f, new Color(0.412f, 0.686f, 0.706f)),
(0.125f, new Color(0.263f, 0.545f, 0.647f)),
(0.310f, new Color(0.145f, 0.396f, 0.565f)),
(0.590f, new Color(0.078f, 0.263f, 0.443f)),
(0.760f, new Color(0.043f, 0.161f, 0.318f)),
(0.880f, new Color(0.024f, 0.094f, 0.212f)),
(1.000f, new Color(0.012f, 0.047f, 0.125f)),
};
// ---- COSTA RICA: the WIDE gradient. The hero. -------------------------
//
// ⭐ The point of this ramp is RANGE. The atlas palettes are muted by design — they are
// imitating a printed plate, where ink is expensive and subtlety is the aesthetic. This one
// imitates the modern relief maps the developer pointed at (Costa Rica, Hispaniola), which
// use a wide, vivid gradient so that ELEVATION IS LEGIBLE AT A GLANCE without a hillshade
// doing the work.
//
// Same percentile placement as the others — the stops sit on the measured land distribution,
// not spread evenly — but the colours travel much further: deep green all the way to white,
// through a full yellow and orange stage the atlas ramps skip.
//
// ⚠ HONEST EXPECTATION: this terrain is mostly lowland (median 0.456 of a 1.415 peak), so it
// reads green→yellow with the spine in orange/brown/white. That is ACCURATE for a lowland
// island with a central range, not a palette failure. The fullest spread arrives when Phase
// 2's redistribution curve moves the height distribution; the palette is got right now so
// that Phase 2 inherits it rather than re-tuning it.
private static readonly (float h, Color c)[] CostaRicaLand =
{
(0.150f, new Color(0.078f, 0.361f, 0.216f)), // shoreline — deep green
(0.220f, new Color(0.153f, 0.475f, 0.243f)), // p10 green
(0.310f, new Color(0.322f, 0.596f, 0.259f)), // p25 mid green
(0.460f, new Color(0.569f, 0.706f, 0.278f)), // p50 yellow-green
(0.560f, new Color(0.784f, 0.796f, 0.298f)), // yellow
(0.650f, new Color(0.914f, 0.792f, 0.310f)), // p75 golden
(0.750f, new Color(0.933f, 0.667f, 0.271f)), // yellow-orange
(0.840f, new Color(0.882f, 0.541f, 0.239f)), // p90 orange
(0.980f, new Color(0.749f, 0.412f, 0.220f)), // p95 tan-brown
(1.120f, new Color(0.573f, 0.318f, 0.208f)), // p99 brown
(1.220f, new Color(0.427f, 0.271f, 0.216f)), // dark brown
(1.320f, new Color(0.667f, 0.635f, 0.620f)), // p99.9 grey
(1.450f, new Color(1.000f, 1.000f, 1.000f)), // peak white
};
private static readonly (float h, Color c)[] CostaRicaSea =
{
(0.000f, new Color(0.616f, 0.851f, 0.859f)), // waterline — bright shallow teal
(0.125f, new Color(0.376f, 0.694f, 0.780f)),
(0.310f, new Color(0.208f, 0.510f, 0.686f)),
(0.590f, new Color(0.114f, 0.341f, 0.557f)),
(0.760f, new Color(0.059f, 0.212f, 0.408f)),
(0.880f, new Color(0.031f, 0.125f, 0.278f)),
(1.000f, new Color(0.016f, 0.063f, 0.165f)), // abyss — flat
};
// ---- PROVISIONAL EVEN (chat2/02): the CostaRica colours, re-spaced evenly SEA → PEAK_CAP --
//
// ⚠⚠ PROVISIONAL, AND FLAGGED AS SUCH EVERYWHERE IT APPEARS. The continuous curve spreads
// land across the whole 0420 m band, so the raw-percentile CostaRica stops no longer sit
// where the land is. This ramp is deliberately UNOPINIONATED: the same colour sequence at
// EVEN height spacing, so equal colour distance = equal height distance and nothing is
// emphasized. Final palette calibration waits until a curve profile is CHOSEN — placing
// stops on a distribution still being explored would bake taste into the instrument.
// Grayscale + the histogram stay the honest instruments; this is only so the relief plates
// are readable meanwhile.
private static readonly (float h, Color c)[] ProvisionalEvenLand = BuildProvisionalEven();
private static (float h, Color c)[] BuildProvisionalEven()
{
// SEA → PEAK_CAP through the single yardstick — no new literal for the cap.
float lo = 0.15f;
float hi = 0.15f + IslaApocalypse.Core.WorldScale.RawFromMetres(420f);
var stops = new (float h, Color c)[CostaRicaLand.Length];
for (int i = 0; i < CostaRicaLand.Length; i++)
stops[i] = (lo + (hi - lo) * i / (CostaRicaLand.Length - 1), CostaRicaLand[i].c);
return stops;
}
/// <summary>The hypsometric tint for a height, before any relief shading.</summary>
public static Color Tint(Kind kind, float height, float seaLevel)
{
var (land, sea) = Ramps(kind);
if (height >= seaLevel)
return Sample(land, height);
// Compressed bathymetry: index by d/(d+k), so the shallows get the range.
float depth = seaLevel - height;
float t = depth / (depth + SeaCompression);
return Sample(sea, t);
}
private static ((float h, Color c)[] land, (float h, Color c)[] sea) Ramps(Kind kind) => kind switch
{
Kind.Dusk => (DuskLand, DuskSea),
Kind.CostaRica => (CostaRicaLand, CostaRicaSea),
Kind.ProvisionalEven => (ProvisionalEvenLand, CostaRicaSea), // same sea; land re-spaced
_ => (AtlasLand, AtlasSea),
};
/// <summary>The land stops of a palette, for drawing a legend.</summary>
public static (float h, Color c)[] LandStops(Kind kind) => Ramps(kind).land;
private static Color Sample((float h, Color c)[] stops, float v)
{
if (v <= stops[0].h) return stops[0].c;
for (int i = 1; i < stops.Length; i++)
{
if (v <= stops[i].h)
{
float span = stops[i].h - stops[i - 1].h;
float local = span <= 0f ? 0f : (v - stops[i - 1].h) / span;
return stops[i - 1].c.Lerp(stops[i].c, local);
}
}
return stops[stops.Length - 1].c;
}
/// <summary>The land range the stops cover, for a run header / INDEX.</summary>
public static (float lo, float hi) LandAnchors => (AtlasLand[0].h, AtlasLand[AtlasLand.Length - 1].h);
}
}