diff --git a/.pql/changelog/ticket_history/2026-08.sql b/.pql/changelog/ticket_history/2026-08.sql index 8d4754ef8..c3581c25a 100644 --- a/.pql/changelog/ticket_history/2026-08.sql +++ b/.pql/changelog/ticket_history/2026-08.sql @@ -634,3 +634,30 @@ was missing, because each asserted a field it already knew about and none asserted the SET. There is now a test that walks all eight dense planes of EncodedStepCanvas and fails if any is absent from the decode — verified by disabling the fix and watching it fail by name.', NULL, '2026-08-16 13:05:35', '2026-08-16 13:05:35.162', '2026-08-16 13:05:35.162', NULL, 'fc36ad2f1acb58e73b53d53ac6402a95', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FXRSY7QWD8J5X6G1N86WMKEC', 'description', 'Found during T-1213 (2026-08-07). MIN_WL_BANDS_M (layer_proxy.rs) is built from 2*DISTRICT_M (4,096 m) and 2*QUARTER_M (1,024 m) -- Nyquist for a sample spacing equal to the rung''s CELL SIZE. That was correct while a rung fixed SPACING; after D-255''s extent inversion a rung fixes EXTENT and District''s spacing is 2048/540 = 3.8 m, so its true Nyquist floor is ~7.6 m. The bands are off by roughly the canvas cell count (~540x). This is the same class of defect as the Global 2x1 wire-extent sentinel the D-258 amendment documents: a constant that was correct under the pre-inversion model and silently outlived it. The code even states the consequence as though intended -- district_profile.rs''s comment reads ''At District''s real Nyquist floor (4,096 m) every VOXEL_OCTAVE_WAVELENGTHS_M entry is truncated, so relief is always exactly 0.0 there''. IMPORTANT SCOPE NOTE, verified before filing: this is currently INERT for the step canvas. step_canvas_viewer._fire_request() calls request_now(body, rung, center, extent) with no min_wl_m, so it defaults to 0, and quantize_min_wl_m(0) returns 0 (the leading sentinel band) -- no truncation happens on the served path. It therefore only affects the legacy layer_proxy district-window consumer. It is NOT the cause of the flat District/Quarter rungs; that is elev_q''s 80 m quantisation (0-100 across MAX_REGION_ELEVATION_KM = 8.0 km), measured at d1 mean 0.02 with the cutoff already disabled. Fix: derive the cutoff from the resolved canvas spacing rather than the rung cell size. Check the layer_proxy consumer''s expectations first -- MIN_WL_BANDS_M is shared, carries a const assert tying band 4 to OCTAVE_WAVELENGTHS_M[3], and is part of the cache key, so a change there is not local.', 'Found during T-1213 (2026-08-07). MIN_WL_BANDS_M (layer_proxy.rs) is built from 2*DISTRICT_M (4,096 m) and 2*QUARTER_M (1,024 m) -- Nyquist for a sample spacing equal to the rung''s CELL SIZE. That was correct while a rung fixed SPACING; after D-255''s extent inversion a rung fixes EXTENT and District''s spacing is 2048/540 = 3.8 m, so its true Nyquist floor is ~7.6 m. The bands are off by roughly the canvas cell count (~540x). This is the same class of defect as the Global 2x1 wire-extent sentinel the D-258 amendment documents: a constant that was correct under the pre-inversion model and silently outlived it. The code even states the consequence as though intended -- district_profile.rs''s comment reads ''At District''s real Nyquist floor (4,096 m) every VOXEL_OCTAVE_WAVELENGTHS_M entry is truncated, so relief is always exactly 0.0 there''. IMPORTANT SCOPE NOTE, verified before filing: this is currently INERT for the step canvas. step_canvas_viewer._fire_request() calls request_now(body, rung, center, extent) with no min_wl_m, so it defaults to 0, and quantize_min_wl_m(0) returns 0 (the leading sentinel band) -- no truncation happens on the served path. It therefore only affects the legacy layer_proxy district-window consumer. It is NOT the cause of the flat District/Quarter rungs; that is elev_q''s 80 m quantisation (0-100 across MAX_REGION_ELEVATION_KM = 8.0 km), measured at d1 mean 0.02 with the cutoff already disabled. Fix: derive the cutoff from the resolved canvas spacing rather than the rung cell size. Check the layer_proxy consumer''s expectations first -- MIN_WL_BANDS_M is shared, carries a const assert tying band 4 to OCTAVE_WAVELENGTHS_M[3], and is part of the cache key, so a change there is not local. + +--- +NO LONGER INERT IN CONSEQUENCE (2026-08-16, found via T-1213). This ticket''s own +scope note says the stale cutoff "is NOT the cause of the flat District/Quarter +rungs". That was correct, and it is now the cause of something else: it caps +REGION. + +With relief_q reaching the renderer (T-1213 fixed a missing protocol decode) and +driving a hillshade, Region renders as a fine uniform stucco — texture at the +pixel scale, no landform. District and Quarter, on the same code, read as real +terrain (lum p1-p99 74.43 and 73.72 against Region''s 54.30). + +The reason is sampling, and it is this ticket: a Region cell is 379 m of ground, +while the relief field''s own content sits in the 128-1024 m composition band plus +192 m inclusions. The field is at or below Nyquist for that sampling, so every +neighbouring cell differs by an essentially independent draw and the gradient the +hillshade reads is ALIASING rather than slope. Because min_wl_m defaults to 0 on +the served path, nothing truncates the octaves Region cannot resolve — which is +precisely the fix this ticket proposes ("derive the cutoff from the resolved +canvas spacing rather than the rung cell size"). + +So the visible symptom to fix against is now concrete: Region should show the +same kind of ridge-and-valley form District does, at its own scale, and it will +once the octaves below its sampling resolution are cut. The T-1213 work made this +observable; before it, relief_q never arrived and the rung was flat for a +different reason entirely.', NULL, '2026-08-16 16:23:17', '2026-08-16 16:23:17.190', '2026-08-16 16:23:17.190', NULL, '42875477f1917d72cd064d584df2b11f', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-08.sql b/.pql/changelog/tickets/2026-08.sql index c5a06aedc..032ecaadc 100644 --- a/.pql/changelog/tickets/2026-08.sql +++ b/.pql/changelog/tickets/2026-08.sql @@ -611,3 +611,30 @@ asserted the SET. There is now a test that walks all eight dense planes of EncodedStepCanvas and fails if any is absent from the decode — verified by disabling the fix and watching it fail by name.', 'in_progress', 'high', NULL, NULL, 'D-258', '2026-07-26 21:54:15.245', '2026-08-16 13:05:35.162', NULL, 'e611e465ff1fd8de7b71b3de10702fec', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at; INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G0P0F9C3RKKK23ZHHTANCPZW', 'bug', '06FBPPMZNNEV052DBYYY3A897C', 'test_visibility_texture_update_performance flakes under gate load — min-of-7 was not enough', 'Wall-clock perf assertion in client/tests/test_fog_shader.gd:213 (best-of-7 < 0.5 ms, D-059 budget) failed the pre-push gate on 2026-08-16 at 0.606 ms, then passed 23/23 in isolation minutes later on the same build. The failing run was a full-suite pass inside the hook, which runs AFTER the tooling suite and cargo checks, so every one of the seven samples was inflated together by a loaded machine. My manual full-suite run of the identical tree passed 1830/0. Nothing in the pushed change touches the fog path (Atlas protocol decode, colorize, terrain layer, tooling). This is the SECOND recorded hardening cycle for the same failure mode: the estimator is already min-of-7 (T-1210) after median-of-5 flaked three times on 2026-07-26 when a cargo build saturated the cores (0.549/0.503/0.638). Taking the minimum defends against ONE slow sample, not against sustained saturation, which is precisely the condition the gate creates by design. Direction: measure CPU time rather than wall clock (Time.get_ticks_usec measures elapsed, so it prices the scheduler in), or make the budget assertion load-aware, or move the perf assertions out of the correctness gate into a dedicated bench run where the machine is quiet. Do NOT simply raise the 0.5 threshold -- that discards the budget D-059 set without deciding to.', 'backlog', 'medium', NULL, 'client', NULL, '2026-08-16 14:37:49.792', '2026-08-16 14:37:49.792', NULL, '513d27f28c0ff1a50bd36692fb96fa46', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at; +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FXRSY7QWD8J5X6G1N86WMKEC', 'task', '06FB0TNSRZXCHGS16BFHSSGSV4', 'MIN_WL_BANDS_M octave cutoffs are pre-extent-inversion (stale, currently inert)', 'Found during T-1213 (2026-08-07). MIN_WL_BANDS_M (layer_proxy.rs) is built from 2*DISTRICT_M (4,096 m) and 2*QUARTER_M (1,024 m) -- Nyquist for a sample spacing equal to the rung''s CELL SIZE. That was correct while a rung fixed SPACING; after D-255''s extent inversion a rung fixes EXTENT and District''s spacing is 2048/540 = 3.8 m, so its true Nyquist floor is ~7.6 m. The bands are off by roughly the canvas cell count (~540x). This is the same class of defect as the Global 2x1 wire-extent sentinel the D-258 amendment documents: a constant that was correct under the pre-inversion model and silently outlived it. The code even states the consequence as though intended -- district_profile.rs''s comment reads ''At District''s real Nyquist floor (4,096 m) every VOXEL_OCTAVE_WAVELENGTHS_M entry is truncated, so relief is always exactly 0.0 there''. IMPORTANT SCOPE NOTE, verified before filing: this is currently INERT for the step canvas. step_canvas_viewer._fire_request() calls request_now(body, rung, center, extent) with no min_wl_m, so it defaults to 0, and quantize_min_wl_m(0) returns 0 (the leading sentinel band) -- no truncation happens on the served path. It therefore only affects the legacy layer_proxy district-window consumer. It is NOT the cause of the flat District/Quarter rungs; that is elev_q''s 80 m quantisation (0-100 across MAX_REGION_ELEVATION_KM = 8.0 km), measured at d1 mean 0.02 with the cutoff already disabled. Fix: derive the cutoff from the resolved canvas spacing rather than the rung cell size. Check the layer_proxy consumer''s expectations first -- MIN_WL_BANDS_M is shared, carries a const assert tying band 4 to OCTAVE_WAVELENGTHS_M[3], and is part of the cache key, so a change there is not local. + +--- +NO LONGER INERT IN CONSEQUENCE (2026-08-16, found via T-1213). This ticket''s own +scope note says the stale cutoff "is NOT the cause of the flat District/Quarter +rungs". That was correct, and it is now the cause of something else: it caps +REGION. + +With relief_q reaching the renderer (T-1213 fixed a missing protocol decode) and +driving a hillshade, Region renders as a fine uniform stucco — texture at the +pixel scale, no landform. District and Quarter, on the same code, read as real +terrain (lum p1-p99 74.43 and 73.72 against Region''s 54.30). + +The reason is sampling, and it is this ticket: a Region cell is 379 m of ground, +while the relief field''s own content sits in the 128-1024 m composition band plus +192 m inclusions. The field is at or below Nyquist for that sampling, so every +neighbouring cell differs by an essentially independent draw and the gradient the +hillshade reads is ALIASING rather than slope. Because min_wl_m defaults to 0 on +the served path, nothing truncates the octaves Region cannot resolve — which is +precisely the fix this ticket proposes ("derive the cutoff from the resolved +canvas spacing rather than the rung cell size"). + +So the visible symptom to fix against is now concrete: Region should show the +same kind of ridge-and-valley form District does, at its own scale, and it will +once the octaves below its sampling resolution are cut. The T-1213 work made this +observable; before it, relief_q never arrived and the rung was flat for a +different reason entirely.', 'backlog', 'medium', NULL, 'server', 'D-255', '2026-08-07 13:26:56.703', '2026-08-16 16:23:17.190', NULL, '1ed56273a1d81dbb6d0e1614598f744b', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at; diff --git a/client/project.godot b/client/project.godot index 8cfbf2681..92d5d4da6 100644 --- a/client/project.godot +++ b/client/project.godot @@ -17,7 +17,7 @@ config/name="The Settled Reach" ; in the editor and in a shipped build, where res://../project.yaml does not ; exist at all (T-1241). Kept honest by `make check-client-version`, which the ; pre-push hook runs — do not edit this by hand without moving project.yaml too. -config/version="0.4.9" +config/version="0.4.10" run/main_scene="res://scenes/main_menu.tscn" config/features=PackedStringArray("4.6", "GL Compatibility") config/icon="res://icon.svg" diff --git a/client/tests/test_step_canvas_colorize.gd b/client/tests/test_step_canvas_colorize.gd index c731ddd5e..d99d8b751 100644 --- a/client/tests/test_step_canvas_colorize.gd +++ b/client/tests/test_step_canvas_colorize.gd @@ -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 diff --git a/client/ui/implant/apps/atlas/step_canvas/step_canvas_colorize.gd b/client/ui/implant/apps/atlas/step_canvas/step_canvas_colorize.gd index 88d29c2e3..5a5bf24ba 100644 --- a/client/ui/implant/apps/atlas/step_canvas/step_canvas_colorize.gd +++ b/client/ui/implant/apps/atlas/step_canvas/step_canvas_colorize.gd @@ -40,7 +40,6 @@ const COLOR_MOISTURE_WET: Color = Color(0.25, 0.72, 0.65, 1.0) const REGION_TEMP_NONE_DC: int = AtlasOverlayColors.REGION_TEMP_NONE_DC - ## MorphologyZone::Lake discriminant (D-239 §6 order, T-1046) — matches ## AtlasOverlayColors.MORPHOLOGY_LAKE (duplicated here per this file's own ## "no cross-file constant sharing beyond the preloaded module" precedent, @@ -83,10 +82,22 @@ const RUGGEDNESS_FULL_SCALE_Q: int = 4 const RUGGEDNESS_BASELINE_CELLS: int = 4 ## relief_q deviation from flat (50) at which ground is "fully rugged". Each -## step is 8 m, so 25 = 200 m of local relief — a substantial ridge at the -## sub-district scale, and comfortably inside the range the field actually -## uses (Region spans all 101 values; District 35 of them). -const RELIEF_TEXTURE_FULL_SCALE_Q: int = 25 +## step is 8 m, so 60 = 480 m of local relief. +## +## RETUNED 2026-08-16 (T-1213), from 25 to 60. The old value was calibrated +## against a relief_q that never arrived — the plane was missing from the +## protocol decode, so `_ruggedness` always took its elev_q-gradient fallback and +## the "Region spans all 101 values" claim above described the field's potential, +## not what the renderer ever saw. With the plane actually decoding, |relief-50| +## reaches 25 across most of a Region canvas, so nearly every land cell earned a +## mark and the map read as static: 17,599 distinct colours at Region, more than +## twice Global's, for a quarter of the legibility. +## +## 60 puts typical ground low in the density range and keeps the marks for +## genuinely broken terrain, which is what the RimWorld reference does — its +## plains are clean and its ranges are dense. Form now comes from the hillshade; +## the stipple is grain on top of it, not a substitute for it. +const RELIEF_TEXTURE_FULL_SCALE_Q: int = 60 ## How far SIGNED relief may push the base lightness, as a fraction (T-1213). ## @@ -104,18 +115,59 @@ const RELIEF_TEXTURE_FULL_SCALE_Q: int = 25 ## different ground... the reverse is not recoverable". A valley and a ridge ## rendered identically. ## -## So relief now also shades CONTINUOUSLY and SIGNED: rises lighten, hollows -## darken, every cell every time. That is the oldest trick in relief cartography -## and it is what makes ground read as ground. The stipple stays, because -## ruggedness (unsigned) is a genuinely different quantity from elevation -## (signed) — grain over form, not grain instead of it. +## So relief now drives a continuous HILLSHADE instead (see [method _hillshade]): +## slopes facing the light brighten and slopes facing away darken, every cell, +## every time. The stipple stays, because ruggedness (unsigned) is a genuinely +## different quantity from slope (signed) — grain over form, not grain instead +## of it. +## +## An intermediate version shaded by the cell's own signed HEIGHT rather than by +## slope. It moved the numbers (District 13.72 -> 77.01 spread) and still looked +## like moss, because the eye reads landform from light falling ACROSS a +## gradient, not from absolute brightness: height-shading gives a rise and a fall +## the same tone, so no ridge ever reads as a ridge. ## ## Kept gentle for the same reason the stipple is: this rides UNDER the hue and -## must not read as a second colour layer. 0.30 lets a full ±200 m swing move -## lightness by roughly a third, which is visible at a glance without swamping -## the morphology hue the legend is keyed to. +## must not read as a second colour layer. 0.30 moves lightness by roughly a +## third at full swing — visible at a glance without swamping the morphology hue +## the legend is keyed to. const RELIEF_SHADE_STRENGTH: float = 0.30 +## Light direction for the hillshade, normalized, pointing from the upper-left. +## +## The printed-map convention, and not arbitrary: lit from the lower-right the +## brain inverts the read and valleys read as ridges. Kept as two constants +## rather than a Vector2 so the hot path does no allocation — this runs per cell, +## ~700,000 times per rebuild at 1290x540. +const LIGHT_DIR_X: float = -0.7071 +const LIGHT_DIR_Y: float = -0.7071 + +## Multiple of the canvas's OWN mean relief gradient at which a slope reads as +## fully lit or fully shadowed. +## +## NOT A PER-CELL CONSTANT, and the first version of this was — which broke the +## deep rungs exactly the way this file already warned a fixed gradient scale +## would (see RUGGEDNESS_BASELINE_CELLS: "the same 4-cell delta reads 21.86 at +## Region and 0.08 at District, so any single full-scale constant either +## saturates the one or vanishes on the other"). Measured with a fixed scale of +## 8: Region 81.72 spread but District collapsed 77.01 -> 20.01 and Quarter +## 42.56 -> 16.44, because at District's 3.8 m per cell the relief field barely +## changes between neighbours. +## +## So the scale is derived per canvas from that canvas's own mean |gradient| +## (StepCanvasTerrainLayer measures it once per rebuild). A rung whose gradients +## are tiny gets a correspondingly tiny full-scale and still shades; a rung whose +## gradients are large is not blown out. 2.5x the mean puts typical ground +## comfortably inside the range and saves saturation for the steep tail. +## +## The trade, stated plainly: shading becomes RELATIVE to the frame rather than +## absolute, so the same hillside is not guaranteed the same tone at two rungs. +## That is acceptable here and would not be for hue or elevation lightness — +## those are legend-keyed quantities the user reads values off, while hillshade +## is a legibility device whose only job is to make form visible. +const HILLSHADE_GRADIENT_MULTIPLE: float = 2.5 + + ## One decoded plane set, pre-extracted from the five L8 Images + the two ## raw-array fields a caller needs per cell — built once per arrived canvas ## (see StepCanvasTerrainLayer.build_texture()), not re-decoded per pixel. @@ -129,6 +181,11 @@ class CellPlanes: var vegetation: Image var lake_margin_q: Image var relief_q: Image + ## Mean |relief_q central difference| across this canvas, measured once per + ## rebuild by StepCanvasTerrainLayer. The hillshade normalizes against it so + ## one constant works at every rung — see HILLSHADE_GRADIENT_MULTIPLE. 0.0 + ## means flat (or unmeasured), which shades nothing. + var relief_gradient_scale: float = 0.0 var glaciation: Image var temp_dc: Array var width: int @@ -195,11 +252,11 @@ static func _texture(planes: CellPlanes, col: int, row: int, base: Color) -> Col return base var out: Color = base - # FORM FIRST, then grain (T-1213). Signed relief shading runs before the - # stipple so the marks land on already-modelled ground rather than carrying - # the shape by themselves — see RELIEF_SHADE_STRENGTH for why the density-only - # read was the thing making the deep rungs flat. - var shade: float = _signed_relief(planes, col, row) + # FORM FIRST, then grain (T-1213). Hillshade runs before the stipple so the + # marks land on already-modelled ground rather than carrying the shape by + # themselves — see RELIEF_SHADE_STRENGTH for why the density-only read was + # what made the deep rungs flat. + var shade: float = _hillshade(planes, col, row) if shade > 0.0: out = out.lightened(RELIEF_SHADE_STRENGTH * shade) elif shade < 0.0: @@ -221,29 +278,73 @@ static func _texture(planes: CellPlanes, col: int, row: int, base: Color) -> Col return out -## SIGNED local relief as a -1..1 fraction: +1 is a full-scale rise, -1 a full -## -scale hollow, 0.0 flat or unavailable (T-1213). +## Directional HILLSHADE from relief_q as a -1..1 fraction: +1 fully lit, -1 +## fully shadowed, 0.0 flat or unavailable (T-1213). ## -## The signed counterpart to [method _ruggedness], which magnitudes this same -## field for the stipple. Both are wanted and they are not the same question: -## "how broken is this ground" (unsigned, grain) versus "is this up or down" -## (signed, form). +## WHY NOT SHADE BY HEIGHT. The first version of this lightened by the cell's own +## signed relief — high pale, low dark. That reads as blotches, because a human +## reads landform from LIGHT ACROSS A SLOPE, not from absolute brightness: the +## eye recovers shape from the way illumination falls on a gradient. Measured on +## the ladder it moved the numbers (District 13.72 -> 77.01 spread) while still +## looking like moss rather than ground. +## +## So this is the standard cartographic hillshade: take the local gradient of the +## relief field and dot it with a fixed light direction. A slope facing the light +## brightens, the slope behind it darkens, and the PAIR is what makes a ridge +## legible as a ridge. Height alone cannot produce that pairing. +## +## The light comes from the upper-left, the convention in every printed relief +## map — and not a free choice: lit from the lower-right the brain inverts the +## read and valleys pop out as ridges (the classic relief-inversion illusion). ## ## Absent plane reads FLAT (0.0), never a full swing — the same fail-safe -## _ruggedness documents, and for the same reason: `_l8_value` zero-fills a -## missing image, and 0 on this field means maximum hollow, so a payload without -## the plane would render every cell fully shaded rather than unshaded. +## _ruggedness documents: `_l8_value` zero-fills a missing image, and 0 on this +## field means maximum hollow, so a payload without the plane would otherwise +## render every cell fully shaded. ## -## Orbital rungs get no shading from here by construction: they skip the -## sub-district derive, so relief_q is a flat 50 and this returns 0.0. Global -## keeps reading its form from elev_q lightness, which is richly varied at that -## scale — the fallback _ruggedness applies for the stipple. -static func _signed_relief(planes: CellPlanes, col: int, row: int) -> float: +## Orbital rungs shade to nothing by construction: they skip the sub-district +## derive, so relief_q is a flat 50, every gradient is 0 and this returns 0.0. +## Global keeps reading its form from elev_q lightness, which is richly varied at +## that scale. +static func _hillshade(planes: CellPlanes, col: int, row: int) -> float: if planes.relief_q == null: return 0.0 - var rel: int = _l8_value(planes.relief_q, col, row) - var signed: float = (float(rel) - 50.0) / float(RELIEF_TEXTURE_FULL_SCALE_Q) - return clampf(signed, -1.0, 1.0) + # Central differences with the SAMPLE clamped into the canvas, never + # _l8_value's raw out-of-bounds read: that returns 0, and 0 on relief_q means + # maximum hollow rather than "no data" (the fail-safe _ruggedness documents). + # Reading it off the edge would post a full-scale false gradient all the way + # round the canvas and draw a lit/shadowed rim on every frame. + var west: float = _relief_clamped(planes, col - 1, row) + var east: float = _relief_clamped(planes, col + 1, row) + var north: float = _relief_clamped(planes, col, row - 1) + var south: float = _relief_clamped(planes, col, row + 1) + var dzdx: float = (east - west) * 0.5 + var dzdy: float = (south - north) * 0.5 + + # Dot with the upper-left light: brighter where the ground rises toward it. + var lit: float = -(dzdx * LIGHT_DIR_X + dzdy * LIGHT_DIR_Y) + + # Normalize against THIS canvas's own gradients (see + # HILLSHADE_GRADIENT_MULTIPLE). A flat field measures ~0 and must shade + # nothing rather than divide by it — that is the Global case, where relief_q + # is a uniform 50 by construction. + var full_scale: float = planes.relief_gradient_scale * HILLSHADE_GRADIENT_MULTIPLE + if full_scale < 0.001: + return 0.0 + return clampf(lit / full_scale, -1.0, 1.0) + + +## relief_q at (col, row) with the SAMPLE POSITION clamped into the canvas. +## +## Distinct from `_l8_value`, which returns 0 out of bounds. That is the right +## softness for a classification plane, where 0 is a benign "zone 0" fallback, +## and the wrong one here: on relief_q, 0 means maximum hollow. Clamping the +## position instead makes an edge cell compare against itself, so the border +## gradient is 0 — flat, which is the honest answer for ground we cannot see. +static func _relief_clamped(planes: CellPlanes, col: int, row: int) -> float: + var c: int = clampi(col, 0, planes.relief_q.get_width() - 1) + var r: int = clampi(row, 0, planes.relief_q.get_height() - 1) + return float(_l8_value(planes.relief_q, c, r)) ## Local relief as a 0..1 fraction — the largest elev_q step to a 4-neighbour, diff --git a/client/ui/implant/apps/atlas/step_canvas/step_canvas_terrain_layer.gd b/client/ui/implant/apps/atlas/step_canvas/step_canvas_terrain_layer.gd index d127bcba9..d64f268cc 100644 --- a/client/ui/implant/apps/atlas/step_canvas/step_canvas_terrain_layer.gd +++ b/client/ui/implant/apps/atlas/step_canvas/step_canvas_terrain_layer.gd @@ -49,8 +49,12 @@ extends Node2D ## second internal scale field so this node's own footprint math never has ## to know it exists. -const StepCanvasColorize := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_colorize.gd") -const StepCanvasTransport := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd") +const StepCanvasColorize := preload( + "res://ui/implant/apps/atlas/step_canvas/step_canvas_colorize.gd" +) +const StepCanvasTransport := preload( + "res://ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd" +) var _texture: ImageTexture = null var _texture_size: Vector2i = Vector2i.ZERO @@ -124,6 +128,7 @@ func _decode_planes(canvas: Dictionary, width: int, height: int) -> StepCanvasCo planes.vegetation = _decode_l8_plane(canvas.get("vegetation")) planes.lake_margin_q = _decode_l8_plane(canvas.get("lake_margin_q")) planes.relief_q = _decode_l8_plane(canvas.get("relief_q")) + planes.relief_gradient_scale = _measure_relief_gradient(planes.relief_q) planes.glaciation = _decode_l8_plane(canvas.get("glaciation")) planes.temp_dc = canvas.get("temp_dc", []) return planes @@ -187,6 +192,41 @@ func get_plane_variety() -> Dictionary: const VARIETY_SAMPLE_STRIDE: int = 4 +## Mean absolute central-difference of relief_q across the canvas, in relief_q +## steps per cell — the scale the hillshade normalizes against (T-1213). +## +## Measured per canvas rather than fixed, because a per-cell gradient constant +## cannot work at more than one rung: at Region a neighbour is 379 m away and at +## District 3.8 m, so the same terrain produces gradients two orders of magnitude +## apart. A fixed scale of 8 was tried and collapsed District from 77.01 to 20.01 +## spread while Region was fine. +## +## Sampled on the same stride as the variety scan, and for the same reason: this +## is a canvas-wide average, and every 4th cell estimates it as well as every +## cell for a fraction of the cost. Returns 0.0 for an absent or flat plane, +## which the hillshade reads as "shade nothing". +func _measure_relief_gradient(relief: Image) -> float: + if relief == null: + return 0.0 + var w: int = relief.get_width() + var h: int = relief.get_height() + if w < 3 or h < 3: + return 0.0 + var total: float = 0.0 + var count: int = 0 + for row in range(1, h - 1, VARIETY_SAMPLE_STRIDE): + for col in range(1, w - 1, VARIETY_SAMPLE_STRIDE): + var dx: float = absf( + (relief.get_pixel(col + 1, row).r8 - relief.get_pixel(col - 1, row).r8) * 0.5 + ) + var dy: float = absf( + (relief.get_pixel(col, row + 1).r8 - relief.get_pixel(col, row - 1).r8) * 0.5 + ) + total += maxf(dx, dy) + count += 1 + return 0.0 if count == 0 else total / float(count) + + ## Count distinct values per L8 plane. Runs once per canvas adoption, never per ## frame, and only over the planes whose collapse would flatten the map. func _measure_plane_variety(planes: StepCanvasColorize.CellPlanes) -> Dictionary: diff --git a/project.yaml b/project.yaml index 50916a9de..7f9ef250b 100644 --- a/project.yaml +++ b/project.yaml @@ -54,7 +54,11 @@ name: The Settled Reach # rendering the deep rungs as a flat wash even against a build that reads it. # This is the first bump in the series where a warm cache is wrong about CONTENT # rather than merely stale. -version: 0.4.9 +# 0.4.10 changes how a canvas is PAINTED, not how it is derived, so cached +# entries are still valid content — but the client step_canvas cluster is in the +# T-1242 registry and the rule is bump-when-touched, so it moves. Cheap: one +# round of misses against a map that now reads as terrain. +version: 0.4.10 repository: settled-reach