diff --git a/client/tests/test_atlas_window_overlay.gd b/client/tests/test_atlas_window_overlay.gd index 9eb737291..d7b4c5b93 100644 --- a/client/tests/test_atlas_window_overlay.gd +++ b/client/tests/test_atlas_window_overlay.gd @@ -34,6 +34,13 @@ static func _mock_window(n: int = 2) -> Dictionary: class _ViewerStub: var window: Variant = null var active_overlay: String = "" + # T-1161: the viewer's currently-HELD rung tag — added alongside the + # per-rung filter tests below. Not read by AtlasWindowOverlay today (the + # overlay trusts each window dict's OWN echoed granularity_v2, per + # cell_grid_side_for_window()'s precedent) but a real AtlasWindowViewer + # exposes get_held_granularity_v2() (T-1153), so the stub carries it too + # for parity with the real duck-typed interface. + var held_granularity_v2: String = "District" func get_district_window() -> Variant: return window @@ -47,7 +54,17 @@ class _ViewerStub: func is_tile_mode() -> bool: return false + func get_held_granularity_v2() -> String: + return held_granularity_v2 + +## T-1161 reframe: COMPOSITE_SMOOTH is now Axis 1 of TWO independent axes +## (see the file header doc) — the texture-vs-flat-rects PIPELINE choice. +## This assertion survives unchanged: the composite is still a TEXTURE at +## every rung. Axis 2 (which FILTER that texture samples with) is now a +## per-rung runtime decision covered separately below by the +## _filter_for_granularity_v2() tests — it is no longer bundled into this +## compile-time const. func test_composite_smooth_defaults_true() -> void: assert_bool(AtlasWindowOverlay.COMPOSITE_SMOOTH).override_failure_message( "T-1145 item 3 ships the smoothed composite as the DEFAULT presentation" @@ -265,3 +282,118 @@ func test_rebuild_handles_a_rung_swap_from_district_to_region() -> void: assert_int(o._cached_texture.get_width()).override_failure_message( "a rung swap must rebuild at the NEW rung's derived cell-grid resolution" ).is_equal(2) # region_window's cell_grid_side is also 2 here (200/100) — same size, different data + + +# ============================================================================= +# T-1161: _filter_for_granularity_v2() — the per-rung sampling filter policy +# (Araminta's ruling: Region incl. the orbital tile mosaic -> NEAREST; +# District/Quarter -> LINEAR; no hysteresis, no px-per-cell threshold, keyed +# purely on rung IDENTITY). +# ============================================================================= + + +## Region is the sparse rung the ruling targets — GPU bilinear blending at +## 204.8 km/cell reads as smoothing-over-absence, so it samples NEAREST. +func test_filter_for_granularity_v2_region_is_nearest() -> void: + assert_int(AtlasWindowOverlay._filter_for_granularity_v2("Region")).override_failure_message( + "Region must sample TEXTURE_FILTER_NEAREST — dense-enough rungs get LINEAR," + + " Region is the sparse one the ruling targets" + ).is_equal(CanvasItem.TEXTURE_FILTER_NEAREST) + + +## District is dense enough that the bilinear blend reads as texture, not as +## papering over sparse data — LINEAR is earned. +func test_filter_for_granularity_v2_district_is_linear() -> void: + assert_int(AtlasWindowOverlay._filter_for_granularity_v2("District")).override_failure_message( + "District must sample TEXTURE_FILTER_LINEAR" + ).is_equal(CanvasItem.TEXTURE_FILTER_LINEAR) + + +## Quarter (4x MORE cells than District) is denser still — also LINEAR. +func test_filter_for_granularity_v2_quarter_is_linear() -> void: + assert_int(AtlasWindowOverlay._filter_for_granularity_v2("Quarter")).override_failure_message( + "Quarter must sample TEXTURE_FILTER_LINEAR" + ).is_equal(CanvasItem.TEXTURE_FILTER_LINEAR) + + +## An unrecognized/empty tag must NEVER be trusted into the crisp NEAREST +## treatment — mirrors cell_grid_side_for_window()'s own "unknown -> District" +## fallback posture, failing toward the already-shipped LINEAR look rather +## than an unintended NEAREST for a wire shape this code doesn't recognize. +func test_filter_for_granularity_v2_unknown_falls_back_to_linear() -> void: + assert_int(AtlasWindowOverlay._filter_for_granularity_v2("")).override_failure_message( + "an empty/unrecognized granularity_v2 tag must fall back to LINEAR, never NEAREST" + ).is_equal(CanvasItem.TEXTURE_FILTER_LINEAR) + assert_int(AtlasWindowOverlay._filter_for_granularity_v2("SomeFutureRung")).override_failure_message( + "an unrecognized granularity_v2 tag must fall back to LINEAR, never NEAREST" + ).is_equal(CanvasItem.TEXTURE_FILTER_LINEAR) + + +## Integration-shaped: a Region-rung window dict, drawn through the real +## _draw() entry point via the _ViewerStub duck-typed interface (same +## end-to-end shape as test_draw_builds_a_texture_for_a_region_rung_window() +## above), must drive the NODE's own texture_filter property to NEAREST — +## not just the helper function in isolation. +func test_draw_sets_node_texture_filter_to_nearest_for_region_window() -> void: + var o: AtlasWindowOverlay = auto_free(AtlasWindowOverlay.new()) + var stub := _ViewerStub.new() + stub.held_granularity_v2 = "Region" + stub.window = { + "center": [0, 0], + "n": 200, + "granularity_v2": "Region", + "morphology": PackedByteArray([8, 14, 0, 1]), + "elev_q": PackedByteArray([40, 90, 5, 60]), + "temp_dc": [120, 95, -32768, 60], + "moisture_q": PackedByteArray([50, 30, 90, 20]), + "vegetation": PackedByteArray([2, 1, 6, 3]), + "glaciation": PackedByteArray([0, 0, 1, 2]), + } + o.viewer = stub + o._draw() + assert_int(o.texture_filter).override_failure_message( + "a Region-rung window must drive the node's texture_filter to NEAREST after a draw" + ).is_equal(CanvasItem.TEXTURE_FILTER_NEAREST) + + +## The District-rung counterpart of the above — confirms the node's +## texture_filter lands on LINEAR (not left over from a previous NEAREST +## draw, and not defaulting to NEAREST) for the dense rung. +func test_draw_sets_node_texture_filter_to_linear_for_district_window() -> void: + var o: AtlasWindowOverlay = auto_free(AtlasWindowOverlay.new()) + var stub := _ViewerStub.new() + stub.window = _mock_window() # District (the default/omitted tag) + o.viewer = stub + o._draw() + assert_int(o.texture_filter).override_failure_message( + "a District-rung window must drive the node's texture_filter to LINEAR after a draw" + ).is_equal(CanvasItem.TEXTURE_FILTER_LINEAR) + + +## A rung SWAP (Region -> District, on the SAME node) must flip texture_filter +## along with it — confirms the property is recomputed every draw, not +## sticky from the first rung the node ever rendered. +func test_draw_flips_node_texture_filter_on_a_rung_swap() -> void: + var o: AtlasWindowOverlay = auto_free(AtlasWindowOverlay.new()) + var stub := _ViewerStub.new() + stub.window = { + "center": [0, 0], + "n": 200, + "granularity_v2": "Region", + "morphology": PackedByteArray([8, 14, 0, 1]), + "elev_q": PackedByteArray([40, 90, 5, 60]), + "temp_dc": [120, 95, -32768, 60], + "moisture_q": PackedByteArray([50, 30, 90, 20]), + "vegetation": PackedByteArray([2, 1, 6, 3]), + "glaciation": PackedByteArray([0, 0, 1, 2]), + } + o.viewer = stub + o._draw() + assert_int(o.texture_filter).is_equal(CanvasItem.TEXTURE_FILTER_NEAREST) + + stub.window = _mock_window() # swap to District + o._draw() + assert_int(o.texture_filter).override_failure_message( + "swapping to a District-rung window must flip texture_filter to LINEAR," + + " not leave it stuck at the previous rung's NEAREST" + ).is_equal(CanvasItem.TEXTURE_FILTER_LINEAR) diff --git a/client/ui/implant/apps/atlas/atlas_window_overlay.gd b/client/ui/implant/apps/atlas/atlas_window_overlay.gd index 5ba2405f2..bf5dc2f9c 100644 --- a/client/ui/implant/apps/atlas/atlas_window_overlay.gd +++ b/client/ui/implant/apps/atlas/atlas_window_overlay.gd @@ -25,18 +25,33 @@ extends Node2D ## COMPOSITE_SMOOTH := true renders the composite as an n x n Image (one ## pixel per district, EXACT same per-cell color pipeline this file always ## had — _cell_color()/_apply_glaciation() are UNCHANGED) converted to an -## ImageTexture and drawn scaled with LINEAR filtering, instead of n*n flat -## draw_rect() calls. GPU bilinear sampling between adjacent district pixels -## reads as a terrain gradient rather than hard-edged blocks — the same -## treatment the planetary heightmap already gets (Godot's engine-default -## CanvasItem.texture_filter is LINEAR_WITH_MIPMAPS project-wide, which is -## what AtlasViewer's draw_texture_rect() calls already inherit for free; -## this node sets texture_filter explicitly rather than relying on that -## default, so the choice is visible in code, not implicit). The crisp -## per-cell rect path SURVIVES behind the const (COMPOSITE_SMOOTH := false) -## so T-1143's design pass can compare both renderings directly — this is -## explicitly an INTERIM presentation, not the final answer on district-tier -## legibility (T-1143 owns that design). +## ImageTexture and drawn scaled with texture-filtered sampling, instead of +## n*n flat draw_rect() calls. The crisp per-cell rect path SURVIVES behind +## the const (COMPOSITE_SMOOTH := false) so T-1143's design pass can compare +## both renderings directly — this is explicitly an INTERIM presentation, not +## the final answer on district-tier legibility (T-1143 owns that design). +## +## T-1161 (Araminta's per-rung filter ruling, PR #192 review follow-up): the +## smoothed path's PIPELINE (texture-vs-flat-rects) and its SAMPLING FILTER +## (how the GPU reads that texture) are now two INDEPENDENT axes, not one +## bundled choice: +## - Axis 1 — PIPELINE: COMPOSITE_SMOOTH (compile-time const, unchanged by +## this ticket). true = draw a texture; false = per-cell draw_rect(). The +## composite is a TEXTURE at every rung when COMPOSITE_SMOOTH is true — +## this axis does not vary per rung. +## - Axis 2 — FILTER: _filter_for_granularity_v2() (runtime, keyed on rung +## IDENTITY via granularity_v2, T-1161). Region (incl. the orbital tile +## mosaic) samples TEXTURE_FILTER_NEAREST — GPU bilinear stretch at +## 204.8 km/cell reads as a near-featureless soft gradient, technically +## honest LoD but visually indistinguishable from the coarse-composite +## smoothing-over-absence the mandate was written to kill (T-1161's own +## description). District and Quarter sample TEXTURE_FILTER_LINEAR — cell +## density there reads as texture, not smoothing-over-absence, so the +## bilinear blend is earned. No hysteresis, no px-per-cell threshold — +## the filter is a pure function of which rung's data is being drawn. +## The crisp draw_rect() path has no sampling-filter concept at all (no +## texture involved) — its comparison/debug role per the paragraph above is +## unaffected by this axis. ## ## The texture is REBUILT only when its inputs change (the window object ## itself — a new DistrictWindowLayer arriving is a new Dictionary, checked @@ -252,6 +267,13 @@ func _draw_tile_mosaic() -> void: ## `tile_index` — sharing ONE `_cached_texture` slot across all tiles (the ## single-window field) would thrash on every draw call as different tiles' ## windows compete for it. +## +## T-1161: every mosaic tile is a Region-rung request (atlas_window_tile_set.gd +## requests tiles at AtlasWindowRequest.GRANULARITY_V2_REGION), so the mosaic +## as a whole is in scope for the Region -> NEAREST ruling. As with the +## single-window path, the filter is read from THIS tile's own echoed `w` +## rather than assumed, via the shared `_filter_for_granularity_v2()` helper +## — one policy, two call sites, no duplicated match statement. func _draw_one_tile( tile_index: int, w: Dictionary, @@ -268,7 +290,8 @@ func _draw_one_tile( ) if tile_texture == null: return - texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR + var granularity_v2 := str(w.get("granularity_v2", AtlasWindowRequest.GRANULARITY_V2_DISTRICT)) + texture_filter = _filter_for_granularity_v2(granularity_v2) draw_texture_rect(tile_texture, Rect2(local_origin, Vector2(extent, extent)), false) @@ -367,19 +390,43 @@ static func cell_grid_side_for_window(w: Dictionary) -> int: return n # District — 1:1 +## T-1161 (Araminta's per-rung filter ruling): the sampling filter to use for +## the smoothed composite's texture, keyed on RUNG IDENTITY alone via +## `granularity_v2` — no hysteresis, no px-per-cell/zoom threshold. Region +## (204.8 km/cell — the same rung the orbital tile mosaic draws at, since +## every mosaic tile is itself a Region-rung window per `_draw_tile_mosaic()`) +## reads NEAREST: at that density, GPU bilinear blending between real derived +## samples is technically honest LoD but visually indistinguishable from the +## coarse-composite-stretched smoothing-over-absence the mandate was written +## to kill — the spirit is violated even though the letter ("never magnified +## interpolation") is not. District and Quarter read LINEAR: cell density at +## those rungs is high enough that the blend reads as texture, not as papering +## over sparse data. Mirrors `cell_grid_side_for_window()`'s own posture on an +## unknown/missing tag — an unrecognized wire value must never be trusted into +## the crisp NEAREST treatment, so it falls back to District's LINEAR instead +## of Region's NEAREST (fail toward the safer/already-shipped look). +static func _filter_for_granularity_v2(granularity_v2: String) -> CanvasItem.TextureFilter: + match granularity_v2: + AtlasWindowRequest.GRANULARITY_V2_REGION: + return CanvasItem.TEXTURE_FILTER_NEAREST + _: + return CanvasItem.TEXTURE_FILTER_LINEAR # District, Quarter, and unknown/missing fallback + + ## T-1145 item 3: the smoothed path — build/reuse a `grid_side` x `grid_side` ## ImageTexture (one pixel per DERIVED CELL, T-1152 — not per district, see ## cell_grid_side_for_window()'s doc) and draw it scaled to (n*cell_px), n -## being the window's DISTRICT extent, with LINEAR filtering. texture_filter -## is set on `self` (a CanvasItem property) once per draw — cheap (a property -## write, not a texture rebuild) and correct even the first time this runs -## (Godot's engine default already IS linear, but this makes the choice -## explicit rather than relying on an implicit project-wide default that -## could change). +## being the window's DISTRICT extent. texture_filter is set on `self` (a +## CanvasItem property) once per draw — cheap (a property write, not a +## texture rebuild). T-1161: the filter itself is now PER-RUNG, read from +## `w`'s own echoed `granularity_v2` (the same "response is the source of +## truth" posture cell_grid_side_for_window() already uses) via +## `_filter_for_granularity_v2()`, rather than an unconditional LINEAR. func _draw_smoothed_composite( w: Dictionary, n: int, grid_side: int, cell_px: float, active_toggle: String ) -> void: - texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR + var granularity_v2 := str(w.get("granularity_v2", AtlasWindowRequest.GRANULARITY_V2_DISTRICT)) + texture_filter = _filter_for_granularity_v2(granularity_v2) _rebuild_texture_if_needed(w, grid_side, active_toggle) if _cached_texture == null: return @@ -430,6 +477,13 @@ func _rebuild_texture_if_needed(w: Dictionary, grid_side: int, active_toggle: St ## DERIVED cell-grid side (see cell_grid_side_for_window()); `n` (the ## window's district extent) sizes the on-screen cell pitch so the total ## drawn footprint stays `n * cell_px` regardless of rung. +## +## Note (T-1161): this path never touches the node-level `texture_filter` +## property — draw_rect() has no texture/sampling-filter concept, so there is +## nothing to set. That is inert today only because nothing else reads +## `texture_filter` while this path is active; it is not a bug to fix here, +## just worth stating since the smoothed path now sets that property +## per-rung and a reader might otherwise wonder why this path doesn't. func _draw_crisp_composite( w: Dictionary, grid_side: int, n: int, cell_px: float, active_toggle: String ) -> void: