feat: shelf-edge variation -- the red-line scalloping (terrain-water task 10)

PASS B, replacing the reverted incision. The developer's sketch asked for
the shelf/riser BOUNDARY to be organic, not for water: notches, coves and
small peninsulas where a flat shelf meets its riser, instead of the clean
oval contour the curve produces.

Mechanism (the task's preferred "boundary warp", in its most
monotonic-safe form): every shelf/riser boundary is the contour where the
raw height crosses K3, K4 or K5, so the boundary is warped by SLIDING
THOSE THREE KNOTS per column -- edgeShift = simplex(resolvedSeed + 7507,
12 per island width) x ShelfEdgeVariation. The contours then wander in and
out of the terrain instead of tracing an iso-height line. Noise-warped by
construction, so there is no grid direction for an artifact to line up on
-- the failure mode of the thing this replaces.

Why slide knots rather than perturb a weight or the input height:
monotonicity becomes structural instead of conditional. The curve is
strictly monotonic for ANY ordered knot set, so no derivative bound, no
amplitude-vs-feather-width tuning, no way for a dial to invert a column.
MaxEdgeShift keeps the set ordered (half the smallest margin to a fixed
knot = 12.3 m of input height for the v5 knots); the config dial is
clamped to it, loudly. AssertMonotonic now sweeps 24 corners -- the 8
modulation extremes x {-max, 0, +max} shift -- and checks the bound first.

K1, K2 and K6 never move, which buys the guarantees exactly rather than
statistically: below K2 and above K6 a warped column is bit-identical to
an unwarped one, so the red ceiling still floors every shelf edge (storm
ladder safe), the 420 m cap still caps, and the toe and summit spikes are
untouched. The block slides rigidly, so the bench and mid-riser keep their
exact widths -- shelf interiors stay flat, riser interiors keep their
profile, and only the foothill riser and plateau stretch to absorb it.
The micro-relief mask takes the same shift, so pass A's skin follows the
shelf wherever pass B moved its edge.

Dial ShelfEdgeVariation (default 5 m of INPUT height -- a boundary
displacement, not an elevation change) under the existing TerrainDetail
gate; both passes stay one judged unit. TDTL v2 body records relief and
edge amp/frequency/seed-offset plus the applied clamp bound; writer,
parser and harness follow. No blueprint was written between the revert
and this commit, so v2 only ever means relief + edge warp on disk.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stewart Howe 2026-08-08 20:31:41 -04:00
parent 3b6d166458
commit 492b56a87c
7 changed files with 182 additions and 69 deletions

View file

@ -42,6 +42,11 @@ public sealed class CurveKnots
/// Unchanged from v4: storm-ladder anchors, bench 100±12 m, plateau 220±20 m,
/// strength modulation (span max 25 m), 420 m cap, per-seed spike normalization,
/// modulation fields/seed offsets, the classify-map invariant.
///
/// The curve SHAPE is frozen at v5. Task 10 adds no band, anchor or slope — only
/// a per-column `edgeShift` parameter on Apply, which slides the shelf/riser knot
/// block K3/K4/K5 so those three boundaries stop being clean iso-height contours.
/// It is a new input to the same curve, not a new curve.
/// </summary>
public static class HeightCurve
{
@ -87,11 +92,29 @@ public static class HeightCurve
return Mathf.Lerp(SHELF_SPAN_MAX, SHELF_SPAN_MIN, Mathf.Clamp(strength01, 0f, 1f));
}
/// <summary>
/// The curve for ONE column. <paramref name="edgeShift"/> (task 10 pass B) slides
/// the shelf/riser knot BLOCK — K3/K4/K5 — up or down by a per-column amount,
/// leaving K1/K2/K6 fixed. Every shelf↔riser boundary is the contour where the
/// raw height crosses one of those three knots, so shifting them makes those
/// contours wander instead of tracing a clean iso-height line: the shelf edge
/// scallops. Because the block moves rigidly, the bench and mid-riser bands keep
/// their exact widths (their interior shapes are translated, not distorted); only
/// the foothill riser and the plateau stretch or compress to absorb the shift.
/// Monotonicity is structural, not conditional — the curve is monotonic for ANY
/// strictly ordered knot set, and TerrainDetailPass.MaxEdgeShift keeps the set
/// ordered by construction. Below K2 and above K6 the output is bit-identical to
/// an unwarped column, which is what makes the red-ceiling floor and the 420 m
/// peak cap exact under the warp.
/// </summary>
public static float Apply(float h, float hMaxSeed,
float benchLo, float benchSpan, float plateauLo, float plateauSpan, CurveKnots k)
float benchLo, float benchSpan, float plateauLo, float plateauSpan, CurveKnots k,
float edgeShift)
{
if (h <= SEA) return h;
float k3 = k.K3 + edgeShift, k4 = k.K4 + edgeShift, k5 = k.K5 + edgeShift;
float u, s;
if (h < k.K1)
{
@ -104,27 +127,27 @@ public static class HeightCurve
u = (h - k.K1) / (k.K2 - k.K1);
return ORANGE_CEIL + u * (RED_CEIL - ORANGE_CEIL); // frozen linear rise
}
if (h < k.K3)
if (h < k3)
{
u = (h - k.K2) / (k.K3 - k.K2);
u = (h - k.K2) / (k3 - k.K2);
s = 0.1f * u + 0.9f * (u * u * (3f - 2f * u)); // foothill riser — corner fix 1
return RED_CEIL + s * (benchLo - RED_CEIL);
}
if (h < k.K4)
if (h < k4)
{
u = (h - k.K3) / (k.K4 - k.K3);
u = (h - k3) / (k4 - k3);
return benchLo + u * benchSpan; // bench (min span 6 m — fix 3)
}
float benchTop = benchLo + benchSpan;
if (h < k.K5)
if (h < k5)
{
u = (h - k.K4) / (k.K5 - k.K4);
u = (h - k4) / (k5 - k4);
s = 0.1f * u + 0.9f * (u * u * (3f - 2f * u)); // mid riser — corner fix 1
return benchTop + s * (plateauLo - benchTop);
}
if (h < k.K6)
{
u = (h - k.K5) / (k.K6 - k.K5);
u = (h - k5) / (k.K6 - k5);
return plateauLo + u * plateauSpan; // plateau
}
float plateauTop = plateauLo + plateauSpan;
@ -140,15 +163,25 @@ public static class HeightCurve
/// <summary>
/// Per-generation numeric strict-monotonicity check of the EFFECTIVE curve for
/// the selected preset: all 8 modulation-extreme corners × per-seed spikeMax.
/// The corner fixes lower the slope floors (risers 0.1, spike base 0.05) — the
/// sweep proves they stay strictly positive everywhere. Loud throw on failure.
/// the selected preset: all 8 modulation-extreme corners × the shelf-edge warp
/// extremes (±maxEdgeShift and 0) × per-seed spikeMax — 24 corners. The corner
/// fixes lower the slope floors (risers 0.1, spike base 0.05) and the warp
/// squeezes the foothill riser and the plateau; the sweep proves every slope
/// stays strictly positive at the extremes of both. Also checks the knot set
/// itself stays strictly ordered under the warp. Loud throw on failure.
/// </summary>
public static void AssertMonotonic(float hMaxSeed, CurveKnots k)
public static void AssertMonotonic(float hMaxSeed, CurveKnots k, float maxEdgeShift)
{
if (maxEdgeShift < 0f || k.K2 + maxEdgeShift >= k.K3 || k.K5 + maxEdgeShift >= k.K6)
throw new System.InvalidOperationException(
$"[HeightCurve] EDGE-SHIFT BOUND VIOLATION: maxEdgeShift={maxEdgeShift} does not keep K2<K3±d and K5±d<K6 (preset {k.Name}). Refusing to generate.");
float[] benchLos = { BENCH_BASE - BENCH_AMP, BENCH_BASE + BENCH_AMP };
float[] plateauLos = { PLATEAU_BASE - PLATEAU_AMP, PLATEAU_BASE + PLATEAU_AMP };
float[] spans = { SHELF_SPAN_MIN, SHELF_SPAN_MAX };
float[] edgeShifts = maxEdgeShift > 0f
? new float[] { -maxEdgeShift, 0f, maxEdgeShift }
: new float[] { 0f };
foreach (float bl in benchLos)
{
@ -156,28 +189,31 @@ public static class HeightCurve
{
foreach (float sp in spans)
{
float prevH = -7f;
float prev = Apply(prevH, hMaxSeed, bl, sp, pl, sp, k);
void Check(double hd)
foreach (float es in edgeShifts)
{
float h = (float)hd;
if (h <= prevH) return; // dedupe float32 samples (task-05 fix)
float v = Apply(h, hMaxSeed, bl, sp, pl, sp, k);
if (v <= prev)
throw new System.InvalidOperationException(
$"[HeightCurve] MONOTONICITY VIOLATION at h={h} (preset {k.Name}, hMaxSeed={hMaxSeed}, benchLo={bl}, plateauLo={pl}, span={sp}): {v} <= {prev}. Refusing to generate.");
prev = v;
prevH = h;
}
float prevH = -7f;
float prev = Apply(prevH, hMaxSeed, bl, sp, pl, sp, k, es);
double top = System.Math.Max(2.0, EffectiveSpikeMax(hMaxSeed, k) + 0.5);
for (double hh = -7.0 + 0.01; hh < 0.10; hh += 0.01) Check(hh);
for (double hh = 0.10; hh <= top; hh += 0.0001) Check(hh);
for (double hh = top + 0.05; hh <= top + 6.0; hh += 0.05) Check(hh);
void Check(double hd)
{
float h = (float)hd;
if (h <= prevH) return; // dedupe float32 samples (task-05 fix)
float v = Apply(h, hMaxSeed, bl, sp, pl, sp, k, es);
if (v <= prev)
throw new System.InvalidOperationException(
$"[HeightCurve] MONOTONICITY VIOLATION at h={h} (preset {k.Name}, hMaxSeed={hMaxSeed}, benchLo={bl}, plateauLo={pl}, span={sp}, edgeShift={es}): {v} <= {prev}. Refusing to generate.");
prev = v;
prevH = h;
}
double top = System.Math.Max(2.0, EffectiveSpikeMax(hMaxSeed, k) + 0.5);
for (double hh = -7.0 + 0.01; hh < 0.10; hh += 0.01) Check(hh);
for (double hh = 0.10; hh <= top; hh += 0.0001) Check(hh);
for (double hh = top + 0.05; hh <= top + 6.0; hh += 0.05) Check(hh);
}
}
}
}
GD.Print($"[HeightCurve] Monotonicity assertion passed (v{VERSION} preset '{k.Name}', 8 modulation corners, effective spikeMax {EffectiveSpikeMax(hMaxSeed, k):F6}).");
GD.Print($"[HeightCurve] Monotonicity assertion passed (v{VERSION} preset '{k.Name}', 8 modulation corners × edge shifts ±{maxEdgeShift:F6}, effective spikeMax {EffectiveSpikeMax(hMaxSeed, k):F6}).");
}
}