feat(ui): hillshade the deep rungs — form from light across slope (T-1213)

relief_q now reaches the renderer, and the first pass spent it on brightness:
lighten where the ground is high, darken where it is low. That moved the numbers
(District 13.72 -> 77.01 lum spread) and still looked like moss, because the eye
does not read landform from absolute brightness. It reads it from light falling
ACROSS a gradient — height-shading gives a rise and a fall the same tone, so no
ridge ever reads as a ridge.

So relief drives a proper hillshade: the local gradient of the field dotted with
a light from the upper-left. The light direction is not a free choice; lit from
the lower-right the brain inverts the read and valleys pop out as ridges.

THE SCALE IS MEASURED PER CANVAS, not fixed, and the first attempt at this failed
exactly the way this file already warned a fixed gradient constant would (see
RUGGEDNESS_BASELINE_CELLS: "the same 4-cell delta reads 21.86 at Region and 0.08
at District"). With a constant full-scale of 8:

    Region 81.72 but District 77.01 -> 20.01, Quarter 42.56 -> 16.44

because at District's 3.8 m per cell neighbouring cells barely differ. The
terrain layer now measures each canvas's own mean |gradient| once per rebuild and
the hillshade normalizes against it, so one constant works at every rung.

Ladder (tooling/atlas-flatness, lum p1-p99), flat -> shipped:

    Global    145.69 -> 145.69   unchanged; relief_q is flat 50 at orbital
    Region     33.59 ->  54.30
    District   13.72 ->  74.43
    Quarter    11.01 ->  73.72

District and Quarter now read as terrain — ridgelines, valleys, and the stipple
organised into contour-like bands. Judged by eye on the captures, not by the
metric alone.

Stipple full-scale 25 -> 60. The old value was calibrated against a relief_q that
never arrived, so it was tuned to the elev_q fallback; with the real plane nearly
every land cell earned a mark and Region read as static (17,599 distinct colours,
more than twice Global's, for a quarter of the legibility). Form comes from the
hillshade now; the stipple is grain on top of it.

REGION IS NOT FIXED, and the cause is T-1240 rather than this change. It renders
as fine uniform stucco: a Region cell is 379 m of ground while the relief field's
content sits in the 128-1024 m band, so the field is at or below Nyquist and the
gradient the hillshade reads is aliasing, not slope. min_wl_m defaults to 0 on
the served path, so nothing truncates the octaves Region cannot resolve — which
is precisely what T-1240 proposes to fix. That ticket said the stale cutoff was
"currently inert"; it is now the thing capping Region, and T-1240 is updated
with the measurement.

Three tests, on direction rather than magnitude so tuning does not rewrite them:
a hill's west flank lit and east flank shadowed, a uniform field shading nothing,
and the canvas edge not drawing a rim. That last one is a bug this nearly
shipped: `_l8_value` returns 0 out of bounds and 0 on relief_q means MAXIMUM
HOLLOW, so sampling off-canvas posts a full-scale false gradient all the way
round the frame. The sample position is clamped instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-16 18:30:21 +02:00
co-authored by Claude Opus 5
parent b9cd26429e
commit 8787ee1844
7 changed files with 320 additions and 43 deletions
+82 -4
View File
@@ -7,7 +7,9 @@
class_name TestStepCanvasColorize
extends GdUnitTestSuite
const StepCanvasColorize := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_colorize.gd")
const StepCanvasColorize := preload(
"res://ui/implant/apps/atlas/step_canvas/step_canvas_colorize.gd"
)
const AtlasOverlayColors := preload("res://ui/implant/apps/atlas/atlas_overlay_colors.gd")
@@ -22,8 +24,14 @@ static func _l8_image(values: Array, width: int, height: int) -> Image:
static func _planes(
morphology: Array, elev_q: Array, moisture_q: Array, vegetation: Array, glaciation: Array,
temp_dc: Array, width: int, height: int
morphology: Array,
elev_q: Array,
moisture_q: Array,
vegetation: Array,
glaciation: Array,
temp_dc: Array,
width: int,
height: int
) -> StepCanvasColorize.CellPlanes:
var p := StepCanvasColorize.CellPlanes.new()
p.width = width
@@ -66,7 +74,9 @@ func test_moisture_toggle_ramps_dry_to_wet() -> void:
func test_vegetation_toggle_marine_is_transparent() -> void:
var planes := _planes([8], [50], [0], [6], [0], [], 1, 1) # VEGETATION_MARINE = 6
var got: Color = StepCanvasColorize.cell_color(planes, 0, 0, StepCanvasColorize.TOGGLE_VEGETATION)
var got: Color = StepCanvasColorize.cell_color(
planes, 0, 0, StepCanvasColorize.TOGGLE_VEGETATION
)
assert_that(got).is_equal(Color.TRANSPARENT)
@@ -116,6 +126,74 @@ func test_out_of_bounds_cell_index_returns_zero_not_a_crash() -> void:
assert_that(got).is_equal(AtlasOverlayColors.district_window_morphology_color(0))
## T-1213 hillshade. Asserted through cell_color() rather than the private
## helper, so it pins the OBSERVABLE property, and on DIRECTION rather than
## magnitude, so tuning the light or the full-scale constant does not rewrite the
## test.
##
## A HILL, not a ramp: relief 30-70-30, so the west flank climbs toward the
## upper-left light and the east flank falls away from it. The west flank must
## come out brighter than the east.
##
## A monotonic ramp cannot test this and the first version of this test used one:
## with the edge SAMPLE clamped, both end cells compare their interior neighbour
## against themselves and produce the same half-gradient, so they render
## identically and the test fails against correct code. The pairing of a lit face
## and a shadowed face is the whole reason hillshade replaced height-shading —
## shading by height alone gives a rise and a fall the same tone, which is why it
## read as blotches.
func test_hillshade_lights_slopes_facing_the_light() -> void:
var planes := _planes([8, 8, 8], [50, 50, 50], [0, 0, 0], [0, 0, 0], [0, 0, 0], [], 3, 1)
planes.relief_q = _l8_image([30, 70, 30], 3, 1)
# The scale the terrain layer measures per canvas; each flank's central
# difference here is (70-30)/2 = 20.
planes.relief_gradient_scale = 20.0
var west: Color = StepCanvasColorize.cell_color(planes, 0, 0, "")
var east: Color = StepCanvasColorize.cell_color(planes, 2, 0, "")
(
assert_bool(west.v > east.v)
. override_failure_message(
(
(
"west flank %s should be lit and east flank %s shadowed on a hill "
+ "under an upper-left light — if these are equal the hillshade is "
+ "reading height instead of slope"
)
% [west, east]
)
)
. is_true()
)
func test_hillshade_is_flat_where_relief_is_uniform() -> void:
# A uniform relief field has no gradient, so shading must be a no-op — the
# guard against a hillshade that darkens (or lightens) the whole map.
var planes := _planes([8, 8, 8], [50, 50, 50], [0, 0, 0], [0, 0, 0], [0, 0, 0], [], 3, 1)
planes.relief_q = _l8_image([50, 50, 50], 3, 1)
var flat_shaded: Color = StepCanvasColorize.cell_color(planes, 1, 0, "")
var no_relief := _planes([8, 8, 8], [50, 50, 50], [0, 0, 0], [0, 0, 0], [0, 0, 0], [], 3, 1)
no_relief.relief_q = null
var unshaded: Color = StepCanvasColorize.cell_color(no_relief, 1, 0, "")
_assert_color_approx(flat_shaded, unshaded)
## The canvas edge must not invent a cliff. `_l8_value` returns 0 out of bounds
## and 0 on relief_q means MAXIMUM HOLLOW, so sampling off-canvas would post a
## full-scale false gradient and draw a lit/shadowed rim around every frame. The
## sample POSITION is clamped instead, making a border cell compare against
## itself — so on a uniform field the edge must match the interior exactly.
func test_hillshade_does_not_draw_a_rim_at_the_canvas_edge() -> void:
var planes := _planes([8, 8, 8], [50, 50, 50], [0, 0, 0], [0, 0, 0], [0, 0, 0], [], 3, 1)
planes.relief_q = _l8_image([50, 50, 50], 3, 1)
var edge: Color = StepCanvasColorize.cell_color(planes, 0, 0, "")
var interior: Color = StepCanvasColorize.cell_color(planes, 1, 0, "")
_assert_color_approx(edge, interior)
## gdUnit4's generic assert_that() has no Color-typed is_equal_approx() —
## per-channel float comparison, matching test_atlas_window_colors.gd's own
## _assert_color_approx() precedent (this is a deliberate duplicate, same