feat(ui): T-1161 — per-rung composite filter policy (Region NEAREST, District/Quarter LINEAR)
Araminta's ruling (PR #192 follow-up): filter keyed on rung IDENTITY via the window's own echoed granularity_v2 — Region (incl. the orbital tile mosaic, whose tiles are all Region-rung requests) samples NEAREST because GPU bilinear at 204.8 km/cell reads as smoothing-over-absence; District/ Quarter keep LINEAR where cell density earns the blend. One shared helper (_filter_for_granularity_v2) at both draw call sites; unknown/missing wire tags fall back to LINEAR (never trusted into NEAREST). COMPOSITE_ SMOOTH survives as the independent compile-time pipeline axis — the two-axes split is documented in the file header. Washes/border fades untouched per the ruling. Focused suite 44/44; revert-verified (helper hardcoded LINEAR -> exactly the three Region-NEAREST tests fail). Tickets: T-1161 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user