#!/usr/bin/env python3
"""
GATE: the erosion filter running on a diffusion heightfield, in GLSL.
Three rows, and the second and third exist so the first cannot pass by accident.
IDENTITY strength 0, no height offset, no detail layer. The surface the
renderer finds MUST equal the input heightfield. This is the check
erode_heightmap.cpp's --check does for the CPU shim, moved to the
GPU path: a unit error in the metres -> filter-space conversion
cannot survive it, and that conversion is exactly where #45 lost a
session to a 60x factor.
CONTROL the same run at the REAL strength must NOT match. An identity gate
that passes whatever you feed it is measuring nothing; this shows
it discriminates.
SEAM the eroded field's step across an internal tile join must be no
worse than the steps just inside the neighbouring tiles. This is
the one that would catch the per-tile normalisation bug -- and it
needs a block of at least 2x2 tiles to be able to fail at all,
which is why stitch_block defaults to 4.
python gate_heightmap.py /tmp/block.hblk --bin /tmp/erosion_render2
"""
import argparse, os, subprocess, sys
import numpy as np
from stitch_block import read_hblk
DC = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "dc-terrain"))
TILE_PX = 256
def run(binpath, hblk, out, extra):
cmd = [binpath, "--heightmap", hblk, "--out", out] + extra
r = subprocess.run(cmd, cwd=DC, capture_output=True, text=True)
if r.returncode != 0:
print(r.stdout, r.stderr); raise SystemExit(f"renderer failed: {cmd}")
return r.stdout
def load(out, n):
a = np.fromfile(out, dtype="<f4", count=n * n).reshape(n, n)
return a.astype(np.float64)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("hblk"); ap.add_argument("--bin", default="/tmp/erosion_render2")
ap.add_argument("--strength", default="0.15")
ap.add_argument("--tol", type=float, default=1.0, help="identity tolerance, metres")
a = ap.parse_args()
arr, meta = read_hblk(a.hblk)
n = meta["side"] - 2
ref = arr[1:-1, 1:-1].astype(np.float64) # interior = what is rendered
print(f"block {meta['side']}x{meta['side']} px, rendering interior {n}x{n}"
f" ({n*meta['mpp']/1000:.2f} km)\n")
# ---- IDENTITY ---------------------------------------------------------
run(a.bin, a.hblk, "/tmp/gate_id.f32",
["--set", "en_strength", "0", "--set", "en_height_offset_a", "0",
"--set", "en_height_offset_b", "0", "--set", "detail_scale", "0"])
idf = load("/tmp/gate_id.f32", n)
d = np.abs(idf - ref)
id_ok = d.max() <= a.tol
print(f" IDENTITY maxdiff {d.max():8.3f} m rmse {np.sqrt((d**2).mean()):8.4f} m"
f" {'PASS' if id_ok else 'FAIL'}")
# ---- CONTROL ----------------------------------------------------------
run(a.bin, a.hblk, "/tmp/gate_er.f32",
["--set", "en_strength", a.strength, "--set", "detail_scale", "0"])
er = load("/tmp/gate_er.f32", n)
de = np.abs(er - ref)
ctl_ok = de.max() > a.tol
print(f" CONTROL strength {a.strength}: maxdiff {de.max():8.1f} m "
f"rmse {np.sqrt((de**2).mean()):7.1f} m "
f"{'DIFFERS (gate discriminates)' if ctl_ok else 'IDENTICAL -- GATE IS BLIND'}")
# ---- SEAM -------------------------------------------------------------
# MEAN SIGNED step across the join, not mean absolute.
#
# The first version of this check used mean |step| and was BLIND: a 60 m
# per-tile offset injected on purpose moved it from 1.007 to 1.088 against
# a 1.5 threshold, because the terrain's own pixel-to-pixel roughness is
# far larger than the seam and drowns it. A normalisation error is a
# CONSISTENT SIGNED jump; natural roughness averages to about zero. So the
# signed mean isolates exactly the thing being looked for, and is compared
# against the spread of the same statistic at nearby interior columns.
def col_signed_step(f, b):
return float(np.mean(f[:, b + 1] - f[:, b]))
bounds = [k * TILE_PX - 1 for k in range(1, n // TILE_PX)]
if not bounds:
print(" SEAM SKIPPED -- block is 1 tile wide, this check cannot fail")
seam_ok = False
else:
worst_z, rows = 0.0, []
for b in bounds:
at = col_signed_step(er, b)
near = [col_signed_step(er, c) for c in range(b - 24, b + 25)
if abs(c - b) > 2 and 0 <= c < n - 1]
sd = float(np.std(near)) or 1e-9
z = abs(at) / sd
worst_z = max(worst_z, z)
rows.append((b, at, sd, z))
for b, at, sd, z in rows:
print(f" SEAM col {b:>5}: signed step {at:+8.3f} m "
f"neighbours sd {sd:6.3f} m z {z:6.2f}")
seam_ok = bool(worst_z < 5.0)
print(f" worst z {worst_z:.2f} "
f"{'PASS (<5)' if seam_ok else 'FAIL -- the join is a systematic step'}")
print()
if not ctl_ok:
print("VERDICT: the control did not move, so IDENTITY proves nothing."); return 2
if not id_ok:
print("VERDICT: units are WRONG. Do not trust any eroded field."); return 1
if not seam_ok:
print("VERDICT: identity holds but the tile joins are visible."); return 1
print("VERDICT: units exact, erosion active, joins invisible. The shared "
"wrapper's heightmap path is correct on the GPU.")
return 0
if __name__ == "__main__":
sys.exit(main())