Merge remote-tracking branch 'origin/rivers-ladder'
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
## T-1156 wave 1: pure-function tests for AtlasWindowGeometry's Layer-1
|
||||
## nature-overlay pixel mapping (layer1_pixel_to_world_m/world_m_to_district/
|
||||
## layer1_pixel_to_canvas_local) and per-rung visibility/filter policy
|
||||
## (river_class_visible_at_rung/confluences_visible_at_rung/
|
||||
## mouths_visible_at_rung/basins_visible_at_rung/attractors_visible_at_rung).
|
||||
## Split from test_atlas_window_geometry.gd (already close to the gdlint
|
||||
## max-file-lines cap) — same file-per-concern precedent as
|
||||
## test_atlas_window_colors.gd being separate from test_atlas_window_overlay.gd.
|
||||
class_name TestAtlasWindowGeometryNature
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const AtlasWindowGeometry := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd")
|
||||
|
||||
const CELL_PIXEL_SIZE: float = 16.0
|
||||
const DISTRICT_M: float = 2048.0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# layer1_pixel_to_world_m — the forward mirror of
|
||||
# server/src/atlas/district_profile.rs's pixel_to_world_m(), verified against
|
||||
# that function's source directly (not assumed).
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## Column 0 is world/longitude 0 on every body — no -0.5 centering, unlike
|
||||
## rows (longitude wraps and has no "half" concept the way latitude does).
|
||||
func test_layer1_pixel_to_world_m_col_zero_is_world_x_zero() -> void:
|
||||
var w: Vector2 = AtlasWindowGeometry.layer1_pixel_to_world_m(0.0, 0.0, 256.0, 128.0, 6371.0)
|
||||
assert_float(w.x).is_equal_approx(0.0, 0.001)
|
||||
|
||||
|
||||
## Row 0 is the NORTH POLE — server's own comment: `lat_frac = -0.5 = N pole`
|
||||
## — which the forward map resolves to the MOST NEGATIVE wy (world Y
|
||||
## increases southward, matching AtlasDescendGeometry.district_pos_at()'s own
|
||||
## row-increases-southward convention on the inverse side of this mapping).
|
||||
func test_layer1_pixel_to_world_m_row_zero_is_north_pole_negative_wy() -> void:
|
||||
var radius_km := 6371.0
|
||||
var grid_h := 128.0
|
||||
var w: Vector2 = AtlasWindowGeometry.layer1_pixel_to_world_m(0.0, 0.0, 256.0, grid_h, radius_km)
|
||||
var meridian_m: float = PI * radius_km * 1000.0
|
||||
assert_float(w.y).is_equal_approx(-0.5 * meridian_m, 1.0)
|
||||
|
||||
|
||||
## Row (grid_h - 1) is the SOUTH POLE — `lat_frac = +0.5 = S` — the most
|
||||
## POSITIVE wy, the opposite extreme from row 0.
|
||||
func test_layer1_pixel_to_world_m_last_row_is_south_pole_positive_wy() -> void:
|
||||
var radius_km := 6371.0
|
||||
var grid_h := 128.0
|
||||
var w: Vector2 = AtlasWindowGeometry.layer1_pixel_to_world_m(
|
||||
grid_h - 1.0, 0.0, 256.0, grid_h, radius_km
|
||||
)
|
||||
var meridian_m: float = PI * radius_km * 1000.0
|
||||
assert_float(w.y).is_equal_approx(0.5 * meridian_m, 1.0)
|
||||
|
||||
|
||||
## The equator row (grid_h / 2, approximately — the exact half-height pixel)
|
||||
## is world Y ~0 — halfway between the two poles. Not EXACT (the denominator
|
||||
## is grid_h - 1 = 127, not 128), so the tolerance is loose (200km).
|
||||
func test_layer1_pixel_to_world_m_mid_row_is_near_equator() -> void:
|
||||
var w: Vector2 = AtlasWindowGeometry.layer1_pixel_to_world_m(64.0, 0.0, 256.0, 128.0, 6371.0)
|
||||
assert_float(w.y).is_equal_approx(0.0, 200_000.0)
|
||||
|
||||
|
||||
## Column at grid_w (a full wrap) must equal the FULL circumference — the
|
||||
## wrap point, matching longitude's periodic (not clamped) treatment.
|
||||
func test_layer1_pixel_to_world_m_full_width_col_is_full_circumference() -> void:
|
||||
var radius_km := 6371.0
|
||||
var grid_w := 256.0
|
||||
var w: Vector2 = AtlasWindowGeometry.layer1_pixel_to_world_m(0.0, grid_w, grid_w, 128.0, radius_km)
|
||||
var circumference_m: float = TAU * radius_km * 1000.0
|
||||
assert_float(w.x).is_equal_approx(circumference_m, 5.0)
|
||||
|
||||
|
||||
## No-radius (tiny test body): 1 heightmap pixel = 1 DISTRICT_M metre exactly
|
||||
## — matching pixel_to_world_m()'s own no-radius fallback and
|
||||
## AtlasDescendGeometry.district_pos_at()'s no-radius branch on the inverse side.
|
||||
func test_layer1_pixel_to_world_m_no_radius_is_one_pixel_one_district_m() -> void:
|
||||
var w: Vector2 = AtlasWindowGeometry.layer1_pixel_to_world_m(3.0, 5.0, 64.0, 64.0, 0.0)
|
||||
assert_that(w).is_equal(Vector2(5.0 * DISTRICT_M, 3.0 * DISTRICT_M))
|
||||
|
||||
|
||||
## Degenerate grid dims (grid_w/grid_h <= 0) must not divide-by-zero or crash.
|
||||
func test_layer1_pixel_to_world_m_zero_grid_dims_returns_zero() -> void:
|
||||
var w: Vector2 = AtlasWindowGeometry.layer1_pixel_to_world_m(1.0, 1.0, 0.0, 0.0, 6371.0)
|
||||
assert_that(w).is_equal(Vector2.ZERO)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# world_m_to_district — one division by DISTRICT_M, sub-district precision
|
||||
# preserved (not rounded).
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func test_world_m_to_district_divides_by_district_m() -> void:
|
||||
var d: Vector2 = AtlasWindowGeometry.world_m_to_district(Vector2(DISTRICT_M * 3.5, DISTRICT_M * -2.25))
|
||||
assert_that(d).is_equal_approx(Vector2(3.5, -2.25), Vector2.ONE * 0.001)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# layer1_pixel_to_canvas_local — the full composition, cross-checked against
|
||||
# district_to_canvas_local() called manually with the same intermediate value.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## A river pixel at the held window's own center district must land at
|
||||
## canvas-local half-extent — same invariant
|
||||
## test_district_to_canvas_local_center_district_lands_at_half_extent()
|
||||
## pins for the district-space function this one wraps.
|
||||
func test_layer1_pixel_to_canvas_local_matches_manual_composition() -> void:
|
||||
var radius_km := 6371.0
|
||||
var grid_w := 256.0
|
||||
var grid_h := 128.0
|
||||
var held_center := Vector2i(10, 20)
|
||||
var held_n := 64
|
||||
var row := 40.0
|
||||
var col := 80.0
|
||||
|
||||
var result: Vector2 = AtlasWindowGeometry.layer1_pixel_to_canvas_local(
|
||||
row, col, grid_w, grid_h, radius_km, held_center, held_n, CELL_PIXEL_SIZE
|
||||
)
|
||||
|
||||
var world_m: Vector2 = AtlasWindowGeometry.layer1_pixel_to_world_m(
|
||||
row, col, grid_w, grid_h, radius_km
|
||||
)
|
||||
var district: Vector2 = AtlasWindowGeometry.world_m_to_district(world_m)
|
||||
var expected: Vector2 = AtlasWindowGeometry.district_to_canvas_local(
|
||||
district, held_center, held_n, CELL_PIXEL_SIZE
|
||||
)
|
||||
assert_that(result).is_equal_approx(expected, Vector2.ONE * 0.001)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Per-rung river-class visibility (Araminta's ruling, 2026-07-23) —
|
||||
# river_class_visible_at_rung()
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func test_river_class_visible_at_rung_region_shows_every_class() -> void:
|
||||
assert_bool(
|
||||
AtlasWindowGeometry.river_class_visible_at_rung(AtlasWindowGeometry.RIVER_CLASS_STREAM, "Region")
|
||||
).is_true()
|
||||
assert_bool(
|
||||
AtlasWindowGeometry.river_class_visible_at_rung(
|
||||
AtlasWindowGeometry.RIVER_CLASS_TRIBUTARY, "Region"
|
||||
)
|
||||
).is_true()
|
||||
assert_bool(
|
||||
AtlasWindowGeometry.river_class_visible_at_rung(AtlasWindowGeometry.RIVER_CLASS_TRUNK, "Region")
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_river_class_visible_at_rung_district_shows_trunk_only() -> void:
|
||||
assert_bool(
|
||||
AtlasWindowGeometry.river_class_visible_at_rung(
|
||||
AtlasWindowGeometry.RIVER_CLASS_STREAM, "District"
|
||||
)
|
||||
).is_false()
|
||||
assert_bool(
|
||||
AtlasWindowGeometry.river_class_visible_at_rung(
|
||||
AtlasWindowGeometry.RIVER_CLASS_TRIBUTARY, "District"
|
||||
)
|
||||
).is_false()
|
||||
assert_bool(
|
||||
AtlasWindowGeometry.river_class_visible_at_rung(AtlasWindowGeometry.RIVER_CLASS_TRUNK, "District")
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_river_class_visible_at_rung_quarter_shows_nothing() -> void:
|
||||
assert_bool(
|
||||
AtlasWindowGeometry.river_class_visible_at_rung(AtlasWindowGeometry.RIVER_CLASS_STREAM, "Quarter")
|
||||
).is_false()
|
||||
assert_bool(
|
||||
AtlasWindowGeometry.river_class_visible_at_rung(
|
||||
AtlasWindowGeometry.RIVER_CLASS_TRIBUTARY, "Quarter"
|
||||
)
|
||||
).is_false()
|
||||
assert_bool(
|
||||
AtlasWindowGeometry.river_class_visible_at_rung(AtlasWindowGeometry.RIVER_CLASS_TRUNK, "Quarter")
|
||||
).is_false()
|
||||
|
||||
|
||||
## An unrecognized rung tag falls back to Region's fullest visibility set —
|
||||
## the cluster's existing "unrecognized -> safest/most permissive already-
|
||||
## shipped behavior" posture.
|
||||
func test_river_class_visible_at_rung_unknown_tag_falls_back_to_region() -> void:
|
||||
assert_bool(
|
||||
AtlasWindowGeometry.river_class_visible_at_rung(AtlasWindowGeometry.RIVER_CLASS_STREAM, "Bogus")
|
||||
).is_true()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Feature-group per-rung gates — confluences/mouths/basins/attractors.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func test_confluences_visible_at_rung_region_true_others_false() -> void:
|
||||
assert_bool(AtlasWindowGeometry.confluences_visible_at_rung("Region")).is_true()
|
||||
assert_bool(AtlasWindowGeometry.confluences_visible_at_rung("District")).is_false()
|
||||
assert_bool(AtlasWindowGeometry.confluences_visible_at_rung("Quarter")).is_false()
|
||||
|
||||
|
||||
## Mouths get the one rung-based EXCEPTION in the whole table: District keeps
|
||||
## them visible (a mouth is always a landmark, per the ruling) — the only
|
||||
## feature group where District differs from Region's disposition.
|
||||
func test_mouths_visible_at_rung_region_and_district_true_quarter_false() -> void:
|
||||
assert_bool(AtlasWindowGeometry.mouths_visible_at_rung("Region")).is_true()
|
||||
assert_bool(AtlasWindowGeometry.mouths_visible_at_rung("District")).is_true()
|
||||
assert_bool(AtlasWindowGeometry.mouths_visible_at_rung("Quarter")).is_false()
|
||||
|
||||
|
||||
func test_basins_visible_at_rung_region_only() -> void:
|
||||
assert_bool(AtlasWindowGeometry.basins_visible_at_rung("Region")).is_true()
|
||||
assert_bool(AtlasWindowGeometry.basins_visible_at_rung("District")).is_false()
|
||||
assert_bool(AtlasWindowGeometry.basins_visible_at_rung("Quarter")).is_false()
|
||||
|
||||
|
||||
func test_attractors_visible_at_rung_region_only() -> void:
|
||||
assert_bool(AtlasWindowGeometry.attractors_visible_at_rung("Region")).is_true()
|
||||
assert_bool(AtlasWindowGeometry.attractors_visible_at_rung("District")).is_false()
|
||||
assert_bool(AtlasWindowGeometry.attractors_visible_at_rung("Quarter")).is_false()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Coordinator live-eyeball finding (2026-07-23): zoom_compensated_size() —
|
||||
# marker sizes must stay CONSTANT on screen regardless of _view_zoom
|
||||
# (Araminta's ruling), but draw calls execute inside a Node2D whose .scale IS
|
||||
# _view_zoom — a raw constant gets multiplied by that transform at render
|
||||
# time. This function pre-divides so the transform's multiply cancels back
|
||||
# out to the literal screen-space value.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## At zoom=1.0 (the canvas transform's identity scale) the compensated size
|
||||
## must equal the input unchanged — no over/under-correction at the one zoom
|
||||
## level where compensation is a no-op by construction.
|
||||
func test_zoom_compensated_size_at_zoom_one_is_unchanged() -> void:
|
||||
assert_float(AtlasWindowGeometry.zoom_compensated_size(2.2, 1.0)).is_equal_approx(2.2, 0.0001)
|
||||
|
||||
|
||||
## The exact regression shape: at Lendel's real orbital fit zoom (~0.0063,
|
||||
## live drive script), the compensated size must be much LARGER than the
|
||||
## raw screen-space constant — inversely proportional to zoom — so that once
|
||||
## the canvas transform re-multiplies it by view_zoom at render time, the
|
||||
## EFFECTIVE on-screen size lands back at the literal ruling value, not a
|
||||
## sub-pixel sliver.
|
||||
func test_zoom_compensated_size_at_orbital_zoom_scales_up_inversely() -> void:
|
||||
var view_zoom := 0.0063
|
||||
var screen_space_size := 2.2
|
||||
var compensated: float = AtlasWindowGeometry.zoom_compensated_size(screen_space_size, view_zoom)
|
||||
# Round-trip: compensated * view_zoom must reconstruct the original
|
||||
# screen-space size — this IS the property that makes the on-screen
|
||||
# result zoom-invariant (the canvas transform performs exactly this
|
||||
# multiply at render time).
|
||||
assert_float(compensated * view_zoom).is_equal_approx(screen_space_size, 0.001)
|
||||
assert_float(compensated).override_failure_message(
|
||||
"at a tiny orbital zoom, the compensated size must be dramatically LARGER"
|
||||
+ " than the raw screen-space constant — that's the whole point of the fix"
|
||||
).is_greater(screen_space_size * 10.0)
|
||||
|
||||
|
||||
## The exact BUG this fix closes, pinned as a regression: an UNCOMPENSATED
|
||||
## radius (screen_space_size used directly, the pre-fix behavior) multiplied
|
||||
## by Lendel's real orbital zoom produces a sub-pixel effective size — this
|
||||
## is the "the ruling's px value, at orbital fit zoom, is invisible" claim
|
||||
## from the coordinator's diagnosis, verified numerically rather than just
|
||||
## asserted.
|
||||
func test_uncompensated_radius_at_orbital_zoom_would_be_sub_pixel() -> void:
|
||||
var view_zoom := 0.0063
|
||||
var raw_screen_space_radius := 2.2 # RIVER_DOT_RADIUS_BY_CLASS_REGION[TRUNK]
|
||||
var effective_size_if_uncompensated: float = raw_screen_space_radius * view_zoom
|
||||
assert_float(effective_size_if_uncompensated).override_failure_message(
|
||||
"an uncompensated radius at orbital zoom must be sub-pixel — pinning the"
|
||||
+ " numeric magnitude of the bug this fix closes, not just its existence"
|
||||
).is_less(0.02)
|
||||
|
||||
|
||||
## A degenerate zero (or negative) view_zoom must not divide-by-zero/produce
|
||||
## infinity/NaN — the floor guard keeps this function total.
|
||||
func test_zoom_compensated_size_zero_zoom_does_not_blow_up() -> void:
|
||||
var result: float = AtlasWindowGeometry.zoom_compensated_size(2.2, 0.0)
|
||||
assert_bool(is_finite(result)).override_failure_message(
|
||||
"a degenerate zero view_zoom must not produce inf/NaN"
|
||||
).is_true()
|
||||
@@ -0,0 +1,255 @@
|
||||
## T-1156 wave 1: tests for AtlasWindowNatureOverlay — the whole-body
|
||||
## Layer-1 (river/basin/attractor) draw node on the zoom ladder. Covers
|
||||
## request/response lifecycle (idempotent-per-body, staleness guard, decode
|
||||
## tolerance for a missing river_class array) and the draw-gate wiring
|
||||
## (rung/overlay-bar double-gate), NOT pixel-level draw output — the
|
||||
## coordinate math itself is covered directly in
|
||||
## test_atlas_window_geometry_nature.gd, matching test_atlas_window_overlay.gd's
|
||||
## own "cache/lifecycle here, colorizer pixels elsewhere" split.
|
||||
class_name TestAtlasWindowNatureOverlay
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const AtlasWindowNatureOverlay := preload("res://ui/implant/apps/atlas/atlas_window_nature_overlay.gd")
|
||||
|
||||
|
||||
## Minimal viewer stub — AtlasWindowNatureOverlay only reaches the viewer
|
||||
## through get_held_granularity_v2()/get_body_radius_km()/get_held_center()/
|
||||
## get_held_n()/get_cell_pixel_size()/get_view_zoom()/is_overlay_visible(),
|
||||
## the same duck-typed-viewer precedent test_atlas_window_overlay.gd's
|
||||
## _ViewerStub already establishes for AtlasWindowOverlay. get_view_zoom()
|
||||
## added post-live-eyeball (coordinator finding, 2026-07-23): _draw() now
|
||||
## reads it for the zoom-compensated marker-size fix.
|
||||
class _ViewerStub:
|
||||
var held_granularity_v2: String = "Region"
|
||||
var body_radius_km: float = 6371.0
|
||||
var held_center: Vector2i = Vector2i.ZERO
|
||||
var held_n: int = 64
|
||||
var view_zoom: float = 1.0
|
||||
var overlay_visibility: Dictionary = {"gen_rivers": true, "gen_basins": false, "gen_attractors": false}
|
||||
|
||||
func get_held_granularity_v2() -> String:
|
||||
return held_granularity_v2
|
||||
|
||||
func get_body_radius_km() -> float:
|
||||
return body_radius_km
|
||||
|
||||
func get_held_center() -> Vector2i:
|
||||
return held_center
|
||||
|
||||
func get_held_n() -> int:
|
||||
return held_n
|
||||
|
||||
func get_cell_pixel_size() -> float:
|
||||
return 16.0
|
||||
|
||||
func get_view_zoom() -> float:
|
||||
return view_zoom
|
||||
|
||||
func is_overlay_visible(overlay_id: String) -> bool:
|
||||
return bool(overlay_visibility.get(overlay_id, false))
|
||||
|
||||
|
||||
static func _mock_layer1(river_class: Variant = null) -> Dictionary:
|
||||
var rn: Dictionary = {
|
||||
"river_cells": [[10, 10], [20, 20], [30, 30]],
|
||||
"confluences": [[15, 15]],
|
||||
"mouths": [[40, 40]],
|
||||
}
|
||||
if river_class != null:
|
||||
rn["river_class"] = river_class
|
||||
return {
|
||||
"river_network": rn,
|
||||
"drainage_basins": [{"basin_id": 1, "boundary": [[0, 0], [0, 10], [10, 10], [10, 0]]}],
|
||||
"attractors": [{"position": [10, 10], "strength": 0.5, "attractor_type": "Oasis", "sub_biome": ""}],
|
||||
"grid_w": 256,
|
||||
"grid_h": 128,
|
||||
}
|
||||
|
||||
|
||||
static func _mock_response(body_id: String, layer1: Variant) -> Dictionary:
|
||||
return {"body_id": body_id, "status": "Ready", "layer1": layer1}
|
||||
|
||||
|
||||
## Window-only responses (the OTHER shape SimBridge.atlas_layers_received
|
||||
## carries, per AtlasWindowRequest's own test conventions) must be ignored —
|
||||
## `layer1` is null on that envelope, matching atlas_response_from_raw()'s
|
||||
## "only one of layer1/district_window populated per response" contract.
|
||||
static func _mock_window_response(body_id: String) -> Dictionary:
|
||||
return {"body_id": body_id, "status": "Ready", "district_window": {"n": 32}, "layer1": null}
|
||||
|
||||
|
||||
func _make_overlay(viewer: Variant = null) -> Variant:
|
||||
var o = auto_free(AtlasWindowNatureOverlay.new(viewer if viewer != null else _ViewerStub.new()))
|
||||
add_child(o)
|
||||
return o
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Request lifecycle
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func test_no_layer1_before_any_request() -> void:
|
||||
var o = _make_overlay()
|
||||
assert_that(o.get_layer1()).is_null()
|
||||
|
||||
|
||||
func test_response_for_requested_body_is_adopted() -> void:
|
||||
var o = _make_overlay()
|
||||
o.request_layer1("GJ380c")
|
||||
var layer1: Dictionary = _mock_layer1()
|
||||
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", layer1))
|
||||
assert_that(o.get_layer1()).is_equal(layer1)
|
||||
|
||||
|
||||
## Staleness guard: a response for a body this node never asked for (or
|
||||
## navigated away from) must be ignored — same posture
|
||||
## AtlasGenerationProxy.on_response()'s own body_id guard establishes.
|
||||
func test_response_for_a_different_body_is_ignored() -> void:
|
||||
var o = _make_overlay()
|
||||
o.request_layer1("GJ380c")
|
||||
SimBridge.atlas_layers_received.emit(_mock_response("SomeOtherBody", _mock_layer1()))
|
||||
assert_that(o.get_layer1()).is_null()
|
||||
|
||||
|
||||
## A window-shaped response (the windowed DistrictWindowLayer envelope,
|
||||
## `layer1` null) must be ignored outright — this node only ever adopts the
|
||||
## whole-body Layer-1 envelope.
|
||||
func test_window_only_response_is_ignored() -> void:
|
||||
var o = _make_overlay()
|
||||
o.request_layer1("GJ380c")
|
||||
SimBridge.atlas_layers_received.emit(_mock_window_response("GJ380c"))
|
||||
assert_that(o.get_layer1()).is_null()
|
||||
|
||||
|
||||
## request_layer1() for the SAME body id, after data has already arrived,
|
||||
## must NOT clear the held data — a re-entrant enter_orbital() on the body
|
||||
## already showing keeps drawing rivers instead of flashing them away.
|
||||
func test_request_layer1_same_body_after_arrival_keeps_held_data() -> void:
|
||||
var o = _make_overlay()
|
||||
o.request_layer1("GJ380c")
|
||||
var layer1: Dictionary = _mock_layer1()
|
||||
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", layer1))
|
||||
o.request_layer1("GJ380c")
|
||||
assert_that(o.get_layer1()).is_equal(layer1)
|
||||
|
||||
|
||||
## request_layer1() for a DIFFERENT body id must clear the previous body's
|
||||
## held data immediately — the old body's rivers must never draw over the
|
||||
## new body's terrain during the in-flight gap.
|
||||
func test_request_layer1_different_body_clears_stale_data() -> void:
|
||||
var o = _make_overlay()
|
||||
o.request_layer1("GJ380c")
|
||||
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", _mock_layer1()))
|
||||
o.request_layer1("AnotherBody")
|
||||
assert_that(o.get_layer1()).override_failure_message(
|
||||
"switching bodies must clear the previous body's layer1 data immediately,"
|
||||
+ " not just leave it drawn until the new response arrives"
|
||||
).is_null()
|
||||
|
||||
|
||||
func test_request_layer1_empty_body_id_is_a_noop() -> void:
|
||||
var o = _make_overlay()
|
||||
o.request_layer1("")
|
||||
SimBridge.atlas_layers_received.emit(_mock_response("", _mock_layer1()))
|
||||
assert_that(o.get_layer1()).override_failure_message(
|
||||
"an empty body_id must never be requested/adopted"
|
||||
).is_null()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Decode tolerance — layer1 without river_class (pre-T-1156 payload / the
|
||||
# graceful-fallback empty-array case, Dudley's #[serde(default)] contract).
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## A response with NO river_class key at all (river_network dict omits it —
|
||||
## the msgpack-decode equivalent of Dudley's serde default producing an
|
||||
## empty Vec) must still be adopted without error; per-cell class then falls
|
||||
## back to RIVER_CLASS_FALLBACK (TRUNK) at draw time, not a crash/decode failure.
|
||||
func test_layer1_without_river_class_key_is_still_adopted() -> void:
|
||||
var o = _make_overlay()
|
||||
o.request_layer1("GJ380c")
|
||||
var layer1: Dictionary = _mock_layer1() # no river_class arg -> key absent
|
||||
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", layer1))
|
||||
assert_that(o.get_layer1()).is_equal(layer1)
|
||||
assert_bool((o.get_layer1()["river_network"] as Dictionary).has("river_class")).is_false()
|
||||
|
||||
|
||||
## An explicitly EMPTY river_class array (the actual wire shape Dudley's
|
||||
## `#[serde(default)]` produces for a pre-T-1156 payload) must also decode
|
||||
## without error and be adopted — the per-cell RIVER_CLASS_FALLBACK
|
||||
## resolution this enables is exercised at draw time by
|
||||
## test_atlas_window_nature_overlay_draw_smoke.gd (real-render smoke suite;
|
||||
## draw_circle()/draw_rect() calls require a live render pass under this
|
||||
## engine version — confirmed directly, matching
|
||||
## test_atlas_window_overlay_draw_smoke.gd's own header doc on why a plain
|
||||
## unit test cannot call `_draw()` outside one).
|
||||
func test_layer1_with_empty_river_class_array_is_adopted() -> void:
|
||||
var o = _make_overlay()
|
||||
o.request_layer1("GJ380c")
|
||||
var layer1: Dictionary = _mock_layer1(PackedByteArray([]))
|
||||
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", layer1))
|
||||
assert_that(o.get_layer1()).is_equal(layer1)
|
||||
|
||||
|
||||
## No viewer at all (viewer == null, matching AtlasWindowOverlay's own
|
||||
## "viewer == null -> return" guard convention) is a legal, inert state —
|
||||
## _draw()'s null-viewer early-return (BEFORE any draw_*() call) is safe to
|
||||
## call directly since it never reaches the engine's draw-context requirement.
|
||||
func test_draw_with_null_viewer_is_a_noop() -> void:
|
||||
var o = auto_free(AtlasWindowNatureOverlay.new(null))
|
||||
add_child(o)
|
||||
o._draw()
|
||||
assert_that(o.get_layer1()).is_null()
|
||||
|
||||
|
||||
## grid_w/grid_h missing or zero (a malformed/degenerate layer1) must decode
|
||||
## and adopt cleanly — _draw()'s own grid_w<=0/grid_h<=0 early-return (also
|
||||
## BEFORE any draw_*() call) is exercised the same direct way.
|
||||
func test_draw_with_zero_grid_dims_returns_before_any_draw_call() -> void:
|
||||
var o = _make_overlay()
|
||||
o.request_layer1("GJ380c")
|
||||
var layer1: Dictionary = _mock_layer1()
|
||||
layer1["grid_w"] = 0
|
||||
layer1["grid_h"] = 0
|
||||
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", layer1))
|
||||
o._draw() # grid_w<=0 -> returns before touching the canvas — safe to call directly
|
||||
assert_that(o.get_layer1()).is_not_null()
|
||||
|
||||
|
||||
## PR #195 review (Tyre I1) regression pin: every stroke-WIDTH argument in
|
||||
## _draw_attractor_shape() must route through the pre-compensated `px_w`
|
||||
## param, never a raw numeric literal — Godot multiplies stroke widths by the
|
||||
## canvas scale exactly like radii, so a raw `2.0` rasterizes at ~0.01px at
|
||||
## the Region orbital fit zoom (the identical sub-pixel failure the dot/ring
|
||||
## zoom compensation fixed, missed on glyph outlines in the first pass). The
|
||||
## draw-smoke suite cannot gate this (its own header documents the vacuous-
|
||||
## pass mode under X11 BadMatch), so this is a SOURCE-SCAN pin: parse the
|
||||
## overlay script's _draw_attractor_shape body and assert no draw_arc/
|
||||
## draw_line call carries a bare numeric width literal. Crude but
|
||||
## environment-independent, and it pins the exact regression class (someone
|
||||
## reintroducing a literal width in a new glyph arm).
|
||||
func test_attractor_shape_stroke_widths_are_never_raw_literals() -> void:
|
||||
var src: String = (
|
||||
FileAccess.get_file_as_string("res://ui/implant/apps/atlas/atlas_window_nature_overlay.gd")
|
||||
)
|
||||
var fn_start := src.find("func _draw_attractor_shape(")
|
||||
assert_that(fn_start).override_failure_message(
|
||||
"_draw_attractor_shape must exist in atlas_window_nature_overlay.gd"
|
||||
).is_not_equal(-1)
|
||||
var next_fn := src.find("\nfunc ", fn_start + 1)
|
||||
var body := src.substr(fn_start, (next_fn - fn_start) if next_fn != -1 else -1)
|
||||
var stroke_re := RegEx.new()
|
||||
# A draw_arc/draw_line call whose FINAL (width) argument is a bare numeric
|
||||
# literal: `, <digits[.digits]>)` at call end. px_w-scaled forms
|
||||
# (`px_w`, `2.0 * px_w`) do not match.
|
||||
stroke_re.compile("draw_(arc|line)\\([^\\n]*,\\s*\\d+(\\.\\d+)?\\s*\\)")
|
||||
var hits := stroke_re.search_all(body)
|
||||
var offenders: Array[String] = []
|
||||
for hit in hits:
|
||||
offenders.append(hit.get_string())
|
||||
assert_array(offenders).override_failure_message(
|
||||
"raw numeric stroke width(s) in _draw_attractor_shape — route through"
|
||||
+ " px_w (PR #195 Tyre I1): %s" % [offenders]
|
||||
).is_empty()
|
||||
@@ -0,0 +1,281 @@
|
||||
## T-1156 wave 1: a REAL draw smoke test for AtlasWindowNatureOverlay,
|
||||
## matching test_atlas_window_overlay_draw_smoke.gd's established pattern —
|
||||
## `draw_circle()`/`draw_rect()`/`draw_arc()`/`draw_colored_polygon()` calls
|
||||
## require a live render pass under this engine version (confirmed directly:
|
||||
## a plain unit test calling `_draw()` outside one throws "Drawing is only
|
||||
## allowed inside this node's `_draw()`..."). Render into a REAL SubViewport,
|
||||
## force a settle wait, and assert visible non-background pixels — proving
|
||||
## the river-dot/mouth-ring/basin-polygon/attractor-glyph draw calls actually
|
||||
## paint, not just that the state feeding them is correct (that half is
|
||||
## test_atlas_window_nature_overlay.gd's job).
|
||||
##
|
||||
## **REQUIRES A REAL RENDERING DRIVER — SKIPS (not fails) under
|
||||
## `tests/run-godot`'s hardcoded `--headless`**, same posture/rationale as
|
||||
## test_atlas_window_overlay_draw_smoke.gd's own header doc (dummy driver,
|
||||
## no GPU texture output — SubViewport.get_texture().get_image() returns an
|
||||
## all-zero/unusable image under it).
|
||||
##
|
||||
## To actually exercise this file's assertions, run it with a real driver:
|
||||
## godot4 --display-driver x11 --rendering-driver opengl3 \
|
||||
## -s addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -c \
|
||||
## -a res://tests/test_atlas_window_nature_overlay_draw_smoke.gd
|
||||
##
|
||||
## **Known limitation, verified directly (2026-07-23 zoom-compensation fix
|
||||
## verification):** the shared _BackgroundRect/_render_to_image() harness
|
||||
## (copied from test_atlas_window_overlay_draw_smoke.gd) occasionally fails
|
||||
## to composite the COLOR_BG fill at all under a real X11/opengl3 run in this
|
||||
## environment (sampled pixels read (0,0,0,0), not COLOR_BG — an unrelated,
|
||||
## intermittent X11/SubViewport timing issue, confirmed via an XServer
|
||||
## "BadMatch" warning in that same run's log). When that happens EVERY pixel
|
||||
## reads as "non-background" regardless of what this overlay actually draws,
|
||||
## making a low MIN_NON_BACKGROUND_FRACTION threshold pass VACUOUSLY (it
|
||||
## would pass even with zoom-compensation deliberately broken — confirmed by
|
||||
## direct revert-test). The terrain sibling suite is accidentally immune to
|
||||
## this (its checkerboard composite covers most of the frame regardless of
|
||||
## background correctness); this suite's SPARSE markers are not. **The
|
||||
## reliable, environment-independent regression gate for the zoom-
|
||||
## compensation fix is therefore the pure-function suite in
|
||||
## test_atlas_window_geometry_nature.gd** (zoom_compensated_size()'s own
|
||||
## tests) — this smoke suite is a supplementary "does it actually paint"
|
||||
## check when the harness cooperates, not the primary gate.
|
||||
class_name TestAtlasWindowNatureOverlayDrawSmoke
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const AtlasWindowNatureOverlay := preload("res://ui/implant/apps/atlas/atlas_window_nature_overlay.gd")
|
||||
|
||||
const COLOR_BG: Color = Color("#0d1117") # AtlasWindowViewer.COLOR_BG, mirrored (private const)
|
||||
const VIEWPORT_SIZE: Vector2i = Vector2i(512, 512)
|
||||
const MIN_NON_BACKGROUND_FRACTION: float = 0.0005 # river dots are sparse — a low bar is honest here
|
||||
|
||||
const SKIP_REASON: String = (
|
||||
"no real rendering driver (dummy/headless) — run with e.g."
|
||||
+ " `godot4 --display-driver x11 --rendering-driver opengl3"
|
||||
+ " -s addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -c"
|
||||
+ " -a res://tests/test_atlas_window_nature_overlay_draw_smoke.gd` to exercise this file"
|
||||
)
|
||||
|
||||
|
||||
## Matches test_atlas_window_overlay_draw_smoke.gd's own detection exactly.
|
||||
static func _dummy_renderer_active() -> bool:
|
||||
return DisplayServer.get_name() == "headless"
|
||||
|
||||
|
||||
## Same duck-typed viewer contract as test_atlas_window_nature_overlay.gd's
|
||||
## _ViewerStub, minus the SimBridge-signal machinery this smoke test doesn't
|
||||
## need (layer1 is injected directly via _layer1, not through a response).
|
||||
## view_zoom MUST be kept in sync with whatever zoom _render_to_image() is
|
||||
## called with — the whole point of this smoke suite (post-live-eyeball,
|
||||
## coordinator finding 2026-07-23) is proving markers stay visible at the
|
||||
## REAL orbital fit zoom, not an artificially large test zoom that would
|
||||
## mask the zoom-compensation bug the fix addresses.
|
||||
class _ViewerStub:
|
||||
var held_granularity_v2: String = "Region"
|
||||
var body_radius_km: float = 6371.0
|
||||
var held_center: Vector2i = Vector2i.ZERO
|
||||
var held_n: int = 64
|
||||
var view_zoom: float = 1.0
|
||||
var overlay_visibility: Dictionary = {"gen_rivers": true, "gen_basins": true, "gen_attractors": true}
|
||||
|
||||
func get_held_granularity_v2() -> String:
|
||||
return held_granularity_v2
|
||||
|
||||
func get_body_radius_km() -> float:
|
||||
return body_radius_km
|
||||
|
||||
func get_held_center() -> Vector2i:
|
||||
return held_center
|
||||
|
||||
func get_held_n() -> int:
|
||||
return held_n
|
||||
|
||||
func get_cell_pixel_size() -> float:
|
||||
return 16.0
|
||||
|
||||
func get_view_zoom() -> float:
|
||||
return view_zoom
|
||||
|
||||
func is_overlay_visible(overlay_id: String) -> bool:
|
||||
return bool(overlay_visibility.get(overlay_id, false))
|
||||
|
||||
|
||||
## A dense scatter of river cells spanning the whole held window's canvas
|
||||
## footprint (not clustered in one corner) — a genuine "does the composite
|
||||
## draw something visible across the frame" check, same intent as the
|
||||
## terrain smoke test's checkerboard morphology spread.
|
||||
static func _mock_layer1_dense() -> Dictionary:
|
||||
var river_cells: Array = []
|
||||
var river_class: PackedByteArray = PackedByteArray()
|
||||
for i in range(40):
|
||||
river_cells.append([i * 3, i * 3])
|
||||
river_class.append(2) # trunk — visible at every rung this suite exercises
|
||||
return {
|
||||
"river_network": {
|
||||
"river_cells": river_cells,
|
||||
"river_class": river_class,
|
||||
"confluences": [[60, 60]],
|
||||
"mouths": [[120, 120]],
|
||||
},
|
||||
"drainage_basins": [
|
||||
{"basin_id": 1, "boundary": [[0, 0], [0, 40], [40, 40], [40, 0]]},
|
||||
],
|
||||
"attractors": [
|
||||
{"position": [80, 80], "strength": 0.9, "attractor_type": "Oasis", "sub_biome": ""},
|
||||
],
|
||||
"grid_w": 256,
|
||||
"grid_h": 128,
|
||||
}
|
||||
|
||||
|
||||
## Same _BackgroundRect/_render_to_image/_non_background_fraction shared
|
||||
## rendering infrastructure as test_atlas_window_overlay_draw_smoke.gd —
|
||||
## duplicated rather than imported since gdUnit4 test suites are not
|
||||
## typically composed via inheritance in this codebase (no precedent for a
|
||||
## shared test-infra base class in client/tests/), and the block is small.
|
||||
class _BackgroundRect extends Node2D:
|
||||
var fill_color: Color = Color.BLACK
|
||||
var fill_size: Vector2 = Vector2.ZERO
|
||||
|
||||
func _draw() -> void:
|
||||
draw_rect(Rect2(Vector2.ZERO, fill_size), fill_color)
|
||||
|
||||
|
||||
func _render_to_image(overlay: Node2D, zoom: float = 1.0) -> Image:
|
||||
var sub_viewport := SubViewport.new()
|
||||
sub_viewport.size = VIEWPORT_SIZE
|
||||
sub_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
|
||||
sub_viewport.transparent_bg = false
|
||||
add_child(sub_viewport)
|
||||
auto_free(sub_viewport)
|
||||
|
||||
var bg := _BackgroundRect.new()
|
||||
bg.fill_color = COLOR_BG
|
||||
bg.fill_size = Vector2(VIEWPORT_SIZE)
|
||||
sub_viewport.add_child(bg)
|
||||
bg.queue_redraw()
|
||||
|
||||
var canvas := Node2D.new()
|
||||
canvas.position = Vector2(VIEWPORT_SIZE) * 0.5
|
||||
canvas.scale = Vector2(zoom, zoom)
|
||||
sub_viewport.add_child(canvas)
|
||||
canvas.add_child(overlay)
|
||||
overlay.queue_redraw()
|
||||
|
||||
for _i in range(6):
|
||||
await get_tree().process_frame
|
||||
|
||||
return sub_viewport.get_texture().get_image()
|
||||
|
||||
|
||||
static func _non_background_fraction(image: Image) -> float:
|
||||
var w: int = image.get_width()
|
||||
var h: int = image.get_height()
|
||||
if w <= 0 or h <= 0:
|
||||
return 0.0
|
||||
var total: int = w * h
|
||||
var differing: int = 0
|
||||
var bg_rgb: Color = Color(COLOR_BG.r, COLOR_BG.g, COLOR_BG.b, 1.0)
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
var px: Color = image.get_pixel(x, y)
|
||||
var px_rgb: Color = Color(px.r, px.g, px.b, 1.0)
|
||||
if not px_rgb.is_equal_approx(bg_rgb):
|
||||
differing += 1
|
||||
return float(differing) / float(total)
|
||||
|
||||
|
||||
## Region rung, every toggle on: rivers + confluence + mouth + basin fill +
|
||||
## attractor glyph must all draw SOMETHING — the "does the composite actually
|
||||
## paint" proof the state-only unit suite cannot provide.
|
||||
func test_region_rung_draws_visible_pixels() -> void:
|
||||
if _dummy_renderer_active():
|
||||
print(SKIP_REASON)
|
||||
return
|
||||
var overlay := AtlasWindowNatureOverlay.new()
|
||||
var stub := _ViewerStub.new()
|
||||
overlay.viewer = stub
|
||||
overlay._layer1 = _mock_layer1_dense()
|
||||
overlay._requested_body_id = "SmokeBody"
|
||||
|
||||
# Zoom chosen so the held window's canvas footprint (held_n * cell_px =
|
||||
# 64 * 16 = 1024 units) comfortably fills the 512px viewport.
|
||||
var zoom: float = float(VIEWPORT_SIZE.x) / (float(stub.held_n) * stub.get_cell_pixel_size())
|
||||
stub.view_zoom = zoom
|
||||
var image: Image = await _render_to_image(overlay, zoom)
|
||||
var fraction: float = _non_background_fraction(image)
|
||||
|
||||
assert_float(fraction).override_failure_message(
|
||||
(
|
||||
"the Region-rung nature composite (rivers + confluence + mouth + basin +"
|
||||
+ " attractor, every toggle on) must render VISIBLE non-background pixels —"
|
||||
+ " got only %.4f%% of the frame differing from COLOR_BG"
|
||||
)
|
||||
% (fraction * 100.0)
|
||||
).is_greater(MIN_NON_BACKGROUND_FRACTION)
|
||||
|
||||
|
||||
## District rung: only trunk rivers (class 2, all cells in the dense fixture
|
||||
## are trunk) + the mouth carve-out draw; basins/attractors/confluences are
|
||||
## rung-gated off. Still must produce visible pixels — proving the "fade
|
||||
## down, don't vanish" posture actually leaves SOMETHING on screen.
|
||||
func test_district_rung_still_draws_visible_pixels() -> void:
|
||||
if _dummy_renderer_active():
|
||||
print(SKIP_REASON)
|
||||
return
|
||||
var overlay := AtlasWindowNatureOverlay.new()
|
||||
var stub := _ViewerStub.new()
|
||||
stub.held_granularity_v2 = "District"
|
||||
overlay.viewer = stub
|
||||
overlay._layer1 = _mock_layer1_dense()
|
||||
overlay._requested_body_id = "SmokeBody"
|
||||
|
||||
var zoom: float = float(VIEWPORT_SIZE.x) / (float(stub.held_n) * stub.get_cell_pixel_size())
|
||||
stub.view_zoom = zoom
|
||||
var image: Image = await _render_to_image(overlay, zoom)
|
||||
var fraction: float = _non_background_fraction(image)
|
||||
|
||||
assert_float(fraction).override_failure_message(
|
||||
(
|
||||
"the District-rung composite (trunk rivers + mouth carve-out only) must"
|
||||
+ " still render VISIBLE non-background pixels — got only %.4f%% of the"
|
||||
+ " frame differing from COLOR_BG"
|
||||
)
|
||||
% (fraction * 100.0)
|
||||
).is_greater(MIN_NON_BACKGROUND_FRACTION)
|
||||
|
||||
|
||||
## Coordinator live-eyeball regression (2026-07-23): the ORBITAL TILE MOSAIC
|
||||
## rest state's REAL fit zoom on a Lendel-scale body (~0.0063, confirmed
|
||||
## directly via a live drive script — held_n=19139 districts,
|
||||
## cell_px=16, a ~1600px viewport) — NOT the comfortable ~0.5 zoom the
|
||||
## sibling test above uses. Before the zoom-compensation fix, this exact
|
||||
## zoom magnitude produced ZERO visible river/mouth pixels (a 2.2px trunk
|
||||
## dot rasterized at ~0.014 screen px) despite RVR being on and the policy
|
||||
## table correctly returning full Region visibility — the captures showed
|
||||
## terrain-only with literally nothing drawn. Pins the regression at
|
||||
## production scale, not a toy zoom that could accidentally still pass.
|
||||
func test_orbital_scale_zoom_still_draws_visible_pixels() -> void:
|
||||
if _dummy_renderer_active():
|
||||
print(SKIP_REASON)
|
||||
return
|
||||
var overlay := AtlasWindowNatureOverlay.new()
|
||||
var stub := _ViewerStub.new()
|
||||
stub.held_n = 19139 # Lendel's own raw circumference, live drive script
|
||||
stub.view_zoom = 0.0063 # Lendel's own live orbital fit zoom
|
||||
overlay.viewer = stub
|
||||
overlay._layer1 = _mock_layer1_dense()
|
||||
overlay._requested_body_id = "SmokeBody"
|
||||
|
||||
var image: Image = await _render_to_image(overlay, stub.view_zoom)
|
||||
var fraction: float = _non_background_fraction(image)
|
||||
|
||||
assert_float(fraction).override_failure_message(
|
||||
(
|
||||
"at Lendel's REAL orbital fit zoom (~0.0063), the Region-rung nature"
|
||||
+ " composite must still render VISIBLE non-background pixels — got only"
|
||||
+ " %.4f%% of the frame differing from COLOR_BG. This is exactly the"
|
||||
+ " coordinator's live-eyeball finding: uncompensated screen-space marker"
|
||||
+ " sizes get multiplied by the canvas's own zoom transform, vanishing"
|
||||
+ " sub-pixel at the orbital rest state's tiny fit zoom."
|
||||
)
|
||||
% (fraction * 100.0)
|
||||
).is_greater(MIN_NON_BACKGROUND_FRACTION)
|
||||
@@ -16,6 +16,11 @@ const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_re
|
||||
# T-1142: district_extent()/canonicalize_district_center() — used to derive
|
||||
# real (cols/rows_half) bounds for the wrap/pole-wall tests below.
|
||||
const AtlasDescendGeometry := preload("res://ui/implant/apps/atlas/atlas_descend_geometry.gd")
|
||||
# T-1156 wave 1 round 3: AtlasWindowNatureOverlay has no class_name (matching
|
||||
# atlas_overlay_bar.gd/atlas_window_request.gd's own no-class_name precedent,
|
||||
# review #8) — subclassing it (the _CountingNatureOverlay spy below) needs
|
||||
# the preloaded script's PATH via `extends`, not a global class name.
|
||||
const AtlasWindowNatureOverlay := preload("res://ui/implant/apps/atlas/atlas_window_nature_overlay.gd")
|
||||
|
||||
|
||||
## Build a hand-authored DistrictWindowLayer dict (n=2, matching the shape
|
||||
@@ -131,6 +136,103 @@ func test_set_overlay_visible_unknown_id_is_a_noop() -> void:
|
||||
assert_bool(v.is_overlay_visible("not_a_real_overlay")).is_false()
|
||||
|
||||
|
||||
## PR #195 review (Hoshe): named default-visibility pins for the T-1156
|
||||
## nature overlays at fresh-viewer construction, per Araminta's ruling —
|
||||
## RVR ON (rivers are load-bearing geography, a first-time Atlas viewer sees
|
||||
## them without hunting for a toggle: the single most player-visible behavior
|
||||
## the feature ships), BAS and ATR OFF (secondary/dev-facing analytical
|
||||
## layers, opt-in). Without these, a refactor of the _ready() visibility-init
|
||||
## loop could silently flip the defaults and only a live capture would
|
||||
## notice — gen_basins was previously covered only incidentally by the
|
||||
## toggle-redraw spy test above.
|
||||
func test_nature_overlay_defaults_rivers_on_basins_and_attractors_off() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
assert_bool(v.is_overlay_visible("gen_rivers")).override_failure_message(
|
||||
"gen_rivers must default ON (Araminta's RVR-on ruling, T-1156 wave 1)"
|
||||
).is_true()
|
||||
assert_bool(v.is_overlay_visible("gen_basins")).override_failure_message(
|
||||
"gen_basins must default OFF (opt-in analytical layer)"
|
||||
).is_false()
|
||||
assert_bool(v.is_overlay_visible("gen_attractors")).override_failure_message(
|
||||
"gen_attractors must default OFF (dev-facing detail, opt-in)"
|
||||
).is_false()
|
||||
|
||||
|
||||
## Counts real _draw() invocations on AtlasWindowNatureOverlay — CanvasItem
|
||||
## exposes no public "is a redraw pending" query in this Godot version
|
||||
## (confirmed directly: is_queued_for_redraw() does not exist on Node2D here
|
||||
## — an earlier version of this test assumed it did and failed with
|
||||
## "Invalid call. Nonexistent function"), so the only reliable signal that
|
||||
## queue_redraw() actually had an effect is the engine calling _draw() again
|
||||
## on a subsequent frame. Matches test_atlas_cold_start.gd's own
|
||||
## _CountingOverlay precedent exactly (same file's own doc: "the only
|
||||
## reliable signal... is the engine calling _draw() again") — subclasses the
|
||||
## REAL AtlasWindowNatureOverlay so drawing still runs through genuine
|
||||
## production code, this spy only adds counting.
|
||||
class _CountingNatureOverlay extends AtlasWindowNatureOverlay:
|
||||
var draw_count := 0
|
||||
|
||||
func _draw() -> void:
|
||||
draw_count += 1
|
||||
super._draw()
|
||||
|
||||
|
||||
## Coordinator live-eyeball round 3 (2026-07-23): R1/R2 captures were
|
||||
## byte-identical because a scratch drive script called
|
||||
## set_overlay_visible("BAS", true) — the button LABEL, not the overlay id
|
||||
## ("gen_basins") — which set_overlay_visible()'s own `not
|
||||
## _overlay_visibility.has(overlay_id): push_warning(...); return` guard
|
||||
## silently no-ops on. Real callers (atlas_overlay_bar.gd's
|
||||
## _on_toggle_changed()) always pass def["id"], never the label, so product
|
||||
## code was never actually broken — but this pins the EXACT gate the
|
||||
## coordinator asked to verify: toggling gen_basins via the real viewer API
|
||||
## must (a) flip is_overlay_visible("gen_basins") — the nature overlay's OWN
|
||||
## draw gate, read live via viewer.is_overlay_visible() at _draw() time, not
|
||||
## a stale copy — AND (b) actually cause the NATURE overlay (not just the
|
||||
## terrain overlay/viewer) to redraw on the next frame, proven by swapping in
|
||||
## a _draw()-counting spy (matching test_atlas_cold_start.gd's
|
||||
## _CountingOverlay pattern) and confirming draw_count advances past a
|
||||
## settled baseline after the toggle, with no other gesture.
|
||||
func test_set_overlay_visible_gen_basins_flips_gate_and_redraws_nature_overlay() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
assert_bool(v.is_overlay_visible("gen_basins")).override_failure_message(
|
||||
"gen_basins must default to OFF (Araminta's ruling — BAS defaults off)"
|
||||
).is_false()
|
||||
|
||||
# Swap in the counting spy (matching _CountingOverlay's own swap-after-
|
||||
# construction shape) so entry's own queue_redraw() calls don't pollute
|
||||
# the baseline, then let it settle before touching the toggle.
|
||||
var spy := _CountingNatureOverlay.new(v)
|
||||
v._nature_overlay.queue_free()
|
||||
v._nature_overlay = spy
|
||||
v._canvas.add_child(spy)
|
||||
|
||||
await get_tree().process_frame
|
||||
await get_tree().process_frame
|
||||
var baseline: int = spy.draw_count
|
||||
assert_int(baseline).override_failure_message(
|
||||
"sanity: the spy must have drawn at least once before the toggle, or"
|
||||
+ " this test can't distinguish 'redrawn BY the toggle' from 'never"
|
||||
+ " drawn at all'"
|
||||
).is_greater(0)
|
||||
|
||||
v.set_overlay_visible("gen_basins", true)
|
||||
await get_tree().process_frame
|
||||
|
||||
assert_bool(v.is_overlay_visible("gen_basins")).override_failure_message(
|
||||
"the toggle must flip the draw gate is_overlay_visible() reads live"
|
||||
).is_true()
|
||||
assert_int(spy.draw_count).override_failure_message(
|
||||
"the toggle must queue_redraw() the NATURE overlay specifically —"
|
||||
+ " queue_redraw() on the viewer/terrain overlay alone leaves the"
|
||||
+ " nature node's last frame cached (a Node2D child does not redraw"
|
||||
+ " because its sibling did) — draw_count must have advanced past the"
|
||||
+ " baseline (%d)" % baseline
|
||||
).is_greater(baseline)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# T-1120 capture-API parity (the ticket's explicit note: must survive here too)
|
||||
# =============================================================================
|
||||
|
||||
@@ -109,6 +109,38 @@ func test_enter_orbital_tile_mode_held_n_is_the_whole_body_extent() -> void:
|
||||
assert_int(v._held_n).is_equal(raw_cols)
|
||||
|
||||
|
||||
## Coordinator live-eyeball dossier (2026-07-23, suspect 2 — DIAGNOSED FALSE
|
||||
## but pinned as a regression guard anyway per the coordinator's own
|
||||
## instruction): AtlasWindowNatureOverlay's _draw() reads
|
||||
## viewer.get_held_granularity_v2() to key its per-rung policy tables
|
||||
## (RIVER_CLASS_VISIBLE_BY_RUNG etc.) — if that accessor returned anything
|
||||
## other than the EXACT string "Region" while is_tile_mode() is true (an
|
||||
## empty string, a stale District default, a different-cased tag...), the
|
||||
## visibility tables would silently return their empty/default disposition
|
||||
## and NOTHING would draw, indistinguishable from the live captures'
|
||||
## "literally zero river dots" symptom. Live drive-script evidence
|
||||
## (NATURE_DEBUG print, since removed) confirmed this was NOT the actual bug
|
||||
## — get_held_granularity_v2() already correctly returns "Region" in tile
|
||||
## mode — but this test makes that fact load-bearing instead of merely
|
||||
## observed once, so a future refactor of _enter_tile_mode()'s
|
||||
## _held_granularity_v2 assignment trips a named failure here.
|
||||
func test_get_held_granularity_v2_is_exactly_region_string_in_tile_mode() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
var radius_km := 6238.4 # GJ380c (Lendel) — needs tiling
|
||||
v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {})
|
||||
|
||||
assert_bool(v.is_tile_mode()).override_failure_message(
|
||||
"this test's premise requires tile mode — Lendel must still need tiling"
|
||||
).is_true()
|
||||
assert_str(v.get_held_granularity_v2()).override_failure_message(
|
||||
"AtlasWindowNatureOverlay's _draw() keys its ENTIRE per-rung policy off"
|
||||
+ " this exact string — anything other than the literal 'Region' silently"
|
||||
+ " empties every visibility table and draws nothing, indistinguishable"
|
||||
+ " from the live-capture symptom (zero river dots at the orbital rest state)"
|
||||
).is_equal("Region")
|
||||
|
||||
|
||||
## **The live-round-3 regression, end to end for TILE mode:** enter_orbital()
|
||||
## on GJ380c/Lendel followed by delivering ONE tile's wire-accurate response
|
||||
## (clamped n=6,400, "Region" granularity_v2, the legacy sentinel in the old
|
||||
|
||||
@@ -108,6 +108,93 @@ const MAX_COVERAGE_M: Dictionary = {
|
||||
const RUNGS_FINEST_FIRST: Array = ["Quarter", "District", "Region"]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# T-1156 wave 1: per-rung nature-overlay visibility/styling policy (Araminta's
|
||||
# presentation ruling, 2026-07-23 — supersedes Tyre's provisional
|
||||
# add-detail-as-you-descend mapping the ticket brief originally carried). The
|
||||
# ladder INVERTS: Region/orbital shows the FULL skeleton (the rung whose data
|
||||
# density actually supports a "river system" read at 76 km/dot spacing);
|
||||
# District and Quarter fade the read DOWN, not up, because a single-heightmap-
|
||||
# pixel river course has nothing finer to reveal as the player descends until
|
||||
# T-1170 invents real sub-heightmap courses. This table is the one place that
|
||||
# posture lives — revisit here, and only here, when T-1170 lands. (Consts only
|
||||
# — the READER functions that consult these tables live further down, grouped
|
||||
# with the other layer1_* pixel-mapping functions per class-definitions-order.)
|
||||
# =============================================================================
|
||||
|
||||
## River class ids — mirrors server/src/atlas/body_world_state.rs
|
||||
## RiverNetwork.river_class's own doc exactly (0=stream, 1=tributary,
|
||||
## 2=trunk). A `river_class` array shorter than `river_cells` (pre-T-1156
|
||||
## payload, or the graceful-fallback empty-array case) has no per-cell class
|
||||
## to read — RIVER_CLASS_FALLBACK is what a missing entry resolves to: TRUNK,
|
||||
## so an old/absent river_class array still shows something at every rung
|
||||
## rather than silently vanishing (Dudley's `#[serde(default)]` empty-array
|
||||
## contract makes "index out of range" the normal case for a pre-T-1156
|
||||
## response, not an edge case to special-case away).
|
||||
const RIVER_CLASS_STREAM: int = 0
|
||||
const RIVER_CLASS_TRIBUTARY: int = 1
|
||||
const RIVER_CLASS_TRUNK: int = 2
|
||||
const RIVER_CLASS_FALLBACK: int = RIVER_CLASS_TRUNK
|
||||
|
||||
## Per-rung river-class visibility — which RIVER_CLASS_* ids draw at all, at
|
||||
## each granularity_v2 tag. Region shows every class (the full skeleton);
|
||||
## District shows trunk only; Quarter shows none (rivers off entirely at that
|
||||
## rung per the ruling).
|
||||
const RIVER_CLASS_VISIBLE_BY_RUNG: Dictionary = {
|
||||
"Region": [RIVER_CLASS_STREAM, RIVER_CLASS_TRIBUTARY, RIVER_CLASS_TRUNK],
|
||||
"District": [RIVER_CLASS_TRUNK],
|
||||
"Quarter": [],
|
||||
}
|
||||
|
||||
## Per-rung feature-group toggles beyond river-cell class filtering — whether
|
||||
## confluences/mouths/basins/attractors draw at all at a given rung (each
|
||||
## still additionally gated by its own overlay-bar toggle, RVR/BAS/ATR, where
|
||||
## applicable — this table is the RUNG gate, the overlay bar is the PLAYER
|
||||
## gate, both must pass). Mouths get the one rung-based exception in the whole
|
||||
## table: District keeps them at full Region styling/opacity (a mouth is
|
||||
## always a landmark, per the ruling) while every other District river feature
|
||||
## is suppressed or de-emphasized.
|
||||
const CONFLUENCES_VISIBLE_BY_RUNG: Dictionary = {"Region": true, "District": false, "Quarter": false}
|
||||
const MOUTHS_VISIBLE_BY_RUNG: Dictionary = {"Region": true, "District": true, "Quarter": false}
|
||||
const BASINS_VISIBLE_BY_RUNG: Dictionary = {"Region": true, "District": false, "Quarter": false}
|
||||
const ATTRACTORS_VISIBLE_BY_RUNG: Dictionary = {"Region": true, "District": false, "Quarter": false}
|
||||
|
||||
## Per-rung river dot styling (screen-space px, at zoom=1.0 — the same
|
||||
## "canvas-local px" domain every other drawn feature in this cluster already
|
||||
## uses, scaled by the caller's own view zoom like everything else in
|
||||
## `_canvas`). District trunk dots are smaller AND drawn at reduced opacity
|
||||
## (80% — raised from the ruling's initial 60% in Araminta's PR #195 capture
|
||||
## review: at 1.6px/60% the dot was "essentially invisible without knowing
|
||||
## where to look", underselling the 'a major river crosses near here' intent;
|
||||
## 2.0px/80% keeps the fade-down ladder vs Region's 2.2px/100% without
|
||||
## reading as accidentally-erased) — the "fade down" the ruling describes;
|
||||
## Region dots are full-strength
|
||||
## opacity (alpha baked into the reused COLOR_GEN_RIVER/COLOR_GEN_MOUTH
|
||||
## constants themselves, alpha 1.0). Quarter has no entry — rivers don't draw
|
||||
## there at all, so no radius/opacity is ever looked up for that rung.
|
||||
const RIVER_DOT_RADIUS_BY_CLASS_REGION: Dictionary = {
|
||||
RIVER_CLASS_STREAM: 0.9,
|
||||
RIVER_CLASS_TRIBUTARY: 1.4,
|
||||
RIVER_CLASS_TRUNK: 2.2,
|
||||
}
|
||||
const RIVER_CONFLUENCE_RADIUS_REGION: float = 3.5
|
||||
const RIVER_DOT_RADIUS_DISTRICT_TRUNK: float = 2.0
|
||||
const RIVER_DOT_OPACITY_DISTRICT_TRUNK: float = 0.8
|
||||
|
||||
## Mouth double-ring geometry (Region AND District — mouths never de-emphasize,
|
||||
## per the ruling) — verbatim from the retired atlas_marker_overlay.gd
|
||||
## _draw_gen_rivers() (:537-539), reused exactly, not re-tuned.
|
||||
const MOUTH_RING_RADIUS: float = 5.0
|
||||
const MOUTH_HALO_RADIUS: float = 8.0
|
||||
const MOUTH_HALO_ALPHA: float = 0.30
|
||||
|
||||
## Attractor minimum-strength gate — verbatim from the retired
|
||||
## atlas_marker_overlay.gd GEN_ATTRACTOR_MIN_STRENGTH (:44). Region-only per
|
||||
## the ruling (ATTRACTORS_VISIBLE_BY_RUNG), wave 1 has no attractor rendering
|
||||
## at any other rung to gate.
|
||||
const ATTRACTOR_MIN_STRENGTH: float = 0.15
|
||||
|
||||
|
||||
## Fit-and-center: given the viewport size and the window's side length in
|
||||
## districts, compute the zoom/offset that COVERS the viewport (fills it edge
|
||||
## to edge, no side margins) and centers the composite. Mirrors AtlasViewer's
|
||||
@@ -686,3 +773,152 @@ static func screen_header_content(
|
||||
static func centered_label_baseline(viewport_size: Vector2, text_size: Vector2) -> Vector2:
|
||||
var center: Vector2 = viewport_size * 0.5
|
||||
return center - text_size * 0.5 + Vector2(0.0, text_size.y * 0.5)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# T-1156 wave 1: whole-body Layer-1 (river/basin/attractor) pixel-space ->
|
||||
# canvas-local mapping for the zoom ladder — the nature-overlay counterpart to
|
||||
# the district/canvas machinery above. Layer-1's `river_network`/
|
||||
# `drainage_basins`/`attractors` positions are (row, col) heightmap-pixel
|
||||
# coordinates in a `grid_w`(cols) x `grid_h`(rows) working grid (Rust
|
||||
# `Layer1Output.grid_w/grid_h` = `BodyHeightmap.width/height` = the SAME
|
||||
# `TerrainAnalysis.w/h` river/attractor extraction ran against —
|
||||
# server/src/atlas/layer1.rs, features.rs `TerrainAnalysis::analyze`). This is
|
||||
# NOT the atlas_marker_overlay.gd `_gen_pos()` texture-fraction mapping (that
|
||||
# maps onto a DISPLAYED heightmap texture on the retired planetary screen) —
|
||||
# the ladder has no resident heightmap texture at all, so pixel positions must
|
||||
# go all the way to WORLD METRES -> DISTRICT space -> canvas-local, the same
|
||||
# frame district_to_canvas_local() already establishes for every other drawn
|
||||
# feature on this screen.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## Heightmap pixel (row, col) -> absolute world metres (wx east, wy south),
|
||||
## mirroring server/src/atlas/district_profile.rs's `pixel_to_world_m()`
|
||||
## EXACTLY (verified against that function's source, not assumed): longitude
|
||||
## WRAPS and is addressed by the plain column fraction (`col / grid_w`) against
|
||||
## the full circumference — column 0 sits at world/longitude 0, no -0.5
|
||||
## centering unlike latitude. Latitude CLAMPS at the poles and is addressed by
|
||||
## `row / (grid_h - 1) - 0.5`, i.e. row 0 is exactly the pole (lat_frac -0.5 =
|
||||
## north pole = wy negative-most) and row (grid_h - 1) is exactly the opposite
|
||||
## pole (lat_frac +0.5 = south pole = wy positive-most) — the SAME "row
|
||||
## increases southward" convention AtlasDescendGeometry.district_pos_at()
|
||||
## already assumes for its own (inverse-direction) pixel<->district mapping,
|
||||
## confirmed here to be the same convention layer1's grid uses, not a
|
||||
## different one that happens to share variable names.
|
||||
##
|
||||
## No-radius bodies (body_radius_km <= 0, tiny test bodies): 1 heightmap pixel
|
||||
## = 1 district-spacing metre, matching pixel_to_world_m()'s own no-radius
|
||||
## fallback (`px * scale::DISTRICT_M`) and district_pos_at()'s no-radius
|
||||
## branch on the other side of this mapping. Uses this file's own
|
||||
## DISTRICT_SPACING_M (the same 2,048 m/district constant, this file's
|
||||
## existing name for it — NOT a re-derivation).
|
||||
static func layer1_pixel_to_world_m(
|
||||
row: float, col: float, grid_w: float, grid_h: float, body_radius_km: float
|
||||
) -> Vector2:
|
||||
if grid_w <= 0.0 or grid_h <= 0.0:
|
||||
return Vector2.ZERO
|
||||
if body_radius_km <= 0.0:
|
||||
return Vector2(col * DISTRICT_SPACING_M, row * DISTRICT_SPACING_M)
|
||||
var circumference_m: float = TAU * body_radius_km * 1000.0
|
||||
var meridian_m: float = PI * body_radius_km * 1000.0
|
||||
var wx: float = (col / grid_w) * circumference_m
|
||||
var lat_frac: float = (row / (grid_h - 1.0) - 0.5) if grid_h > 1.0 else 0.0
|
||||
var wy: float = lat_frac * meridian_m
|
||||
return Vector2(wx, wy)
|
||||
|
||||
|
||||
## World metres -> fractional DistrictPos (NOT rounded to an integer district
|
||||
## — a river dot's true position is sub-district-precise even though the
|
||||
## window grid itself is district-granular; rounding here would visibly snap
|
||||
## every river pixel onto a district lattice). DISTRICT_SPACING_M is this
|
||||
## file's own existing constant (2,048 m/district, D-243) — one division, no
|
||||
## re-derivation.
|
||||
static func world_m_to_district(world_m: Vector2) -> Vector2:
|
||||
return world_m / DISTRICT_SPACING_M
|
||||
|
||||
|
||||
## The full pixel(row,col) -> canvas-local composition a nature-overlay draw
|
||||
## call needs in one step: heightmap pixel -> world metres -> fractional
|
||||
## district -> canvas-local (via the EXISTING district_to_canvas_local(),
|
||||
## reused verbatim so a river dot lands in exactly the same coordinate frame
|
||||
## every other drawn feature on this screen already agrees on — pan/zoom/rung
|
||||
## crossings all move the SAME transform under everything drawn into
|
||||
## `_canvas`). Wrap resolution (nearest_wrap_image()) is the CALLER's job, same
|
||||
## split the tile mosaic draw path already uses — this function's `district`
|
||||
## output is the RAW (un-wrapped) fractional position; a caller iterating
|
||||
## river cells against a specific held window picks the nearest wrap-image of
|
||||
## the COLUMN only (rows never wrap, matching every other wrap-aware caller in
|
||||
## this cluster).
|
||||
static func layer1_pixel_to_canvas_local(
|
||||
row: float,
|
||||
col: float,
|
||||
grid_w: float,
|
||||
grid_h: float,
|
||||
body_radius_km: float,
|
||||
held_center: Vector2i,
|
||||
held_n: int,
|
||||
cell_pixel_size: float
|
||||
) -> Vector2:
|
||||
var world_m: Vector2 = layer1_pixel_to_world_m(row, col, grid_w, grid_h, body_radius_km)
|
||||
var district: Vector2 = world_m_to_district(world_m)
|
||||
return district_to_canvas_local(district, held_center, held_n, cell_pixel_size)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# T-1156 wave 1: per-rung nature-overlay visibility policy READERS. The policy
|
||||
# TABLES themselves (RIVER_CLASS_VISIBLE_BY_RUNG etc.) live up in the
|
||||
# top-of-file const block per class-definitions-order (Araminta's ruling,
|
||||
# 2026-07-23, is documented there).
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## Whether a river cell of `river_class` should draw at `granularity_v2`. An
|
||||
## unrecognized rung tag falls back to Region's (fullest) visibility set —
|
||||
## matching this cluster's existing "unrecognized -> most permissive/safest
|
||||
## already-shipped behavior" posture (see AtlasWindowOverlay._filter_for_
|
||||
## granularity_v2()'s own doc for the same fallback shape, there choosing the
|
||||
## safer LINEAR filter for an unknown tag).
|
||||
static func river_class_visible_at_rung(river_class: int, granularity_v2: String) -> bool:
|
||||
var visible: Array = RIVER_CLASS_VISIBLE_BY_RUNG.get(
|
||||
granularity_v2, RIVER_CLASS_VISIBLE_BY_RUNG["Region"]
|
||||
)
|
||||
return visible.has(river_class)
|
||||
|
||||
|
||||
static func confluences_visible_at_rung(granularity_v2: String) -> bool:
|
||||
return bool(CONFLUENCES_VISIBLE_BY_RUNG.get(granularity_v2, true))
|
||||
|
||||
|
||||
static func mouths_visible_at_rung(granularity_v2: String) -> bool:
|
||||
return bool(MOUTHS_VISIBLE_BY_RUNG.get(granularity_v2, true))
|
||||
|
||||
|
||||
static func basins_visible_at_rung(granularity_v2: String) -> bool:
|
||||
return bool(BASINS_VISIBLE_BY_RUNG.get(granularity_v2, true))
|
||||
|
||||
|
||||
static func attractors_visible_at_rung(granularity_v2: String) -> bool:
|
||||
return bool(ATTRACTORS_VISIBLE_BY_RUNG.get(granularity_v2, true))
|
||||
|
||||
|
||||
## Coordinator live-eyeball finding (2026-07-23): Araminta's ruling specifies
|
||||
## nature-overlay marker sizes as SCREEN-SPACE px, constant regardless of
|
||||
## zoom — but every draw call in this cluster (river dots, mouth rings, basin
|
||||
## line widths) executes inside `_canvas`, a Node2D whose `.scale` IS
|
||||
## `_view_zoom` (AtlasWindowViewer._apply_transform()). A raw radius/width
|
||||
## constant handed to draw_circle()/draw_arc()/draw_polyline() therefore gets
|
||||
## multiplied by `_view_zoom` at render time — invisible at the Region
|
||||
## orbital tile mosaic's fit zoom (~0.0063 for Lendel: a 2.2px trunk-river
|
||||
## dot rasterizes at ~0.014 screen px, sub-pixel), even though the SAME
|
||||
## drawing code produces a correctly-sized (visible) mouth ring at District's
|
||||
## much larger fit zoom (~3.75, live capture confirmed this). The fix: every
|
||||
## marker's draw-time radius/width must be pre-divided by `view_zoom` so the
|
||||
## canvas transform's multiply cancels back out to the ruling's literal
|
||||
## screen-space value. `view_zoom` is clamped to a small positive floor
|
||||
## (MIN_ZOOM's own order of magnitude) to avoid a divide-by-zero/near-zero
|
||||
## blowup on a degenerate zero-zoom caller — this floor is far below any
|
||||
## legal `_view_zoom` (AtlasWindowViewer.MIN_ZOOM = 0.0005), so it is inert
|
||||
## for every real caller and only guards a malformed test input.
|
||||
static func zoom_compensated_size(screen_space_size: float, view_zoom: float) -> float:
|
||||
return screen_space_size / maxf(view_zoom, 0.0001)
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
extends Node2D
|
||||
|
||||
## Draws the whole-body Layer-1 nature overlays (rivers/basins/attractors,
|
||||
## T-1156 wave 1) on the AtlasWindowViewer zoom ladder. Child of
|
||||
## AtlasWindowViewer._canvas, ABOVE the terrain composite (AtlasWindowOverlay)
|
||||
## and below UI chrome — same parent, same pan/zoom transform, drawn after so
|
||||
## river dots/basin fills sit on top of the terrain colorizer.
|
||||
##
|
||||
## This is a PORT, not a reactivation, of the retired planetary-screen draw
|
||||
## code (atlas_marker_overlay.gd:523-572, _draw_gen_rivers/_draw_gen_basins/
|
||||
## _draw_gen_attractors) — atlas_marker_overlay.gd stays retired/unreachable.
|
||||
## The drawing IDEAS survive (dot-scatter rivers, polygon basins, glyph-free
|
||||
## double-ring mouths, draw order basins-under-rivers-under-attractors); the
|
||||
## COORDINATE MAPPING does not — the retired code projected onto a resident
|
||||
## displayed heightmap TEXTURE (_gen_pos(), texture-fraction space) that this
|
||||
## ladder screen has no equivalent of. Positions here go all the way through
|
||||
## world metres -> district -> canvas-local
|
||||
## (AtlasWindowGeometry.layer1_pixel_to_canvas_local()), the same frame every
|
||||
## other drawn feature on this screen already shares, wrap-resolved exactly
|
||||
## like the tile mosaic resolves terrain tiles.
|
||||
##
|
||||
## Data source: the WHOLE-BODY Layer-1 response (`layer1` key on the shared
|
||||
## AtlasLayerResponse envelope, SimBridge.atlas_layers_received) — a SEPARATE
|
||||
## fetch from the windowed DistrictWindowLayer composite AtlasWindowOverlay
|
||||
## draws (both responses ride the SAME signal, discriminated by which
|
||||
## envelope key is populated — SimBridge/atlas_map_protocol.gd's decode
|
||||
## always includes both `layer1` and `district_window` keys, only one
|
||||
## non-null per response, per that decoder's own doc). This node connects to
|
||||
## SimBridge.atlas_layers_received DIRECTLY (mirroring
|
||||
## atlas_window_tile_set.gd's own "one shared inbound signal, N independent
|
||||
## consumers filtering by their own criteria" shape) rather than being routed
|
||||
## through AtlasWindowViewer's own _on_atlas_layers_received() — the viewer
|
||||
## stays at the gdlint max-file-lines cap with this node needing zero new
|
||||
## lines in that function. Requested once per body entry via request_layer1()
|
||||
## (call sites: the viewer's _enter_at_rung() and _enter_tile_mode() — every
|
||||
## fresh descent funnels through one of those two; enter() itself is a thin
|
||||
## wrapper over _enter_at_rung and has no call of its own), which owns
|
||||
## clearing stale data on a body change itself
|
||||
## (see that function's own doc, no separate reset() call needed) — cached
|
||||
## thereafter, rivers are static per body, no re-request on pan/zoom/rung
|
||||
## crossing.
|
||||
##
|
||||
## No `class_name` on purpose, matching every other viewer-owned helper in
|
||||
## this cluster (atlas_overlay_bar.gd/atlas_window_request.gd/
|
||||
## atlas_window_tile_set.gd, review #8 precedent): the owner
|
||||
## (AtlasWindowViewer) passes itself to `_init()`.
|
||||
##
|
||||
## Redraw wiring: no _process() poll — AtlasWindowViewer/AtlasWindowOverlay
|
||||
## already call `_overlay_node.queue_redraw()` on every pan/zoom/rung-
|
||||
## crossing/window-arrival event; adding this node as a SIBLING of
|
||||
## AtlasWindowOverlay under `_canvas` means the viewer's existing redraw call
|
||||
## sites need exactly one more line each (`_nature_overlay.queue_redraw()`
|
||||
## alongside the existing `_overlay_node.queue_redraw()`) — trivial wiring on
|
||||
## the viewer, no new redraw PATH. This node's OWN _on_atlas_layers_received()
|
||||
## also queue_redraw()s directly (the direct-signal-connection path bypasses
|
||||
## the viewer's own arrival redraw calls, so it must trigger its own). Once
|
||||
## layer1 arrives for a body it never goes stale until the next
|
||||
## enter()/enter_orbital(), so unlike the tile mosaic's pending-tile poll,
|
||||
## this node never needs a _process() self-heal.
|
||||
|
||||
const AtlasWindowGeometry := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd")
|
||||
const AtlasDescendGeometryRef := preload("res://ui/implant/apps/atlas/atlas_descend_geometry.gd")
|
||||
const AtlasOverlayColors := preload("res://ui/implant/apps/atlas/atlas_overlay_colors.gd")
|
||||
|
||||
## Reused verbatim from the retired atlas_marker_overlay.gd (Araminta's
|
||||
## ruling: "reuse the retired palette exactly") — same values, same source of
|
||||
## truth, just no longer read from the retired file.
|
||||
const COLOR_GEN_RIVER: Color = Color(0.353, 0.647, 0.776, 1.0)
|
||||
const COLOR_GEN_MOUTH: Color = Color(0.353, 0.647, 0.776, 1.0)
|
||||
const COLOR_GEN_BASIN_FILL: Color = Color(0.20, 0.35, 0.55, 0.06)
|
||||
const COLOR_GEN_BASIN_LINE: Color = Color(0.45, 0.65, 0.85, 0.45)
|
||||
|
||||
var viewer = null # AtlasWindowViewer (untyped to avoid cyclic ref)
|
||||
|
||||
## Whole-body Layer1Output dict (river_network/drainage_basins/attractors/
|
||||
## grid_w/grid_h), or null before the first response for the current body.
|
||||
## Set by _on_atlas_layers_received() (this node's own direct signal
|
||||
## connection); request_layer1()/reset() manage the request lifecycle.
|
||||
var _layer1: Variant = null
|
||||
var _requested_body_id: String = ""
|
||||
|
||||
|
||||
func _init(viewer_ref = null) -> void:
|
||||
viewer = viewer_ref
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
SimBridge.atlas_layers_received.connect(_on_atlas_layers_received)
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
if SimBridge.atlas_layers_received.is_connected(_on_atlas_layers_received):
|
||||
SimBridge.atlas_layers_received.disconnect(_on_atlas_layers_received)
|
||||
|
||||
|
||||
## Adopt a Layer-1 response — ignores non-Layer1 responses (the windowed
|
||||
## DistrictWindowLayer envelope leaves `layer1` null, per
|
||||
## atlas_response_from_raw()'s own doc) and responses for a body this node
|
||||
## didn't ask for (the player navigated away while the request was in
|
||||
## flight, or this node never issued a request at all — an empty
|
||||
## `_requested_body_id` must never match an empty response `body_id`,
|
||||
## matching request_layer1()'s own "empty body_id is never a valid request"
|
||||
## guard), matching AtlasGenerationProxy.on_response()'s own staleness guard.
|
||||
func _on_atlas_layers_received(response: Dictionary) -> void:
|
||||
if _requested_body_id.is_empty():
|
||||
return
|
||||
var layer1: Variant = response.get("layer1")
|
||||
if layer1 == null:
|
||||
return
|
||||
if str(response.get("body_id", "")) != _requested_body_id:
|
||||
return
|
||||
_layer1 = layer1
|
||||
queue_redraw()
|
||||
|
||||
|
||||
## Request a body's Layer-1 data — the ONE entry point every fresh-descent
|
||||
## viewer path (enter()/_enter_at_rung()/_enter_tile_mode()) calls, so it owns
|
||||
## its own reset-on-body-change instead of requiring callers to remember to
|
||||
## call reset() first (a single call site per entry path stays a one-line
|
||||
## addition to atlas_window_viewer.gd, keeping that file at its gdlint cap).
|
||||
## Idempotent per body — a second call for the SAME body_id (e.g. a
|
||||
## re-entrant enter_orbital()/rung-crossing re-descent on the body already
|
||||
## showing) is a no-op, keeping the held data drawn rather than flashing it
|
||||
## away and re-fetching. A DIFFERENT body_id clears the stale data FIRST (so
|
||||
## the previous body's rivers never draw over the new body's terrain during
|
||||
## the gap) then re-requests. No-op on an empty body_id (matches
|
||||
## AtlasGenerationProxy.request()'s own guard).
|
||||
func request_layer1(body_id: String) -> void:
|
||||
if body_id.is_empty():
|
||||
return
|
||||
if body_id == _requested_body_id and _layer1 != null:
|
||||
return
|
||||
if body_id != _requested_body_id:
|
||||
_layer1 = null
|
||||
_requested_body_id = body_id
|
||||
SimBridge.request_atlas_layers(body_id)
|
||||
|
||||
|
||||
func get_layer1() -> Variant:
|
||||
return _layer1
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
if viewer == null or _layer1 == null:
|
||||
return
|
||||
var rn: Variant = _layer1.get("river_network")
|
||||
if not rn is Dictionary:
|
||||
return
|
||||
var grid_w: float = float(_layer1.get("grid_w", 0))
|
||||
var grid_h: float = float(_layer1.get("grid_h", 0))
|
||||
if grid_w <= 0.0 or grid_h <= 0.0:
|
||||
return
|
||||
|
||||
var granularity_v2: String = viewer.get_held_granularity_v2()
|
||||
var radius_km: float = viewer.get_body_radius_km()
|
||||
var held_center: Vector2i = viewer.get_held_center()
|
||||
var held_n: int = viewer.get_held_n()
|
||||
var cell_px: float = viewer.get_cell_pixel_size()
|
||||
var cols: int = _cols_for_wrap(radius_km)
|
||||
|
||||
var ctx := {
|
||||
"grid_w": grid_w,
|
||||
"grid_h": grid_h,
|
||||
"radius_km": radius_km,
|
||||
"held_center": held_center,
|
||||
"held_n": held_n,
|
||||
"cell_px": cell_px,
|
||||
"cols": cols,
|
||||
"granularity_v2": granularity_v2,
|
||||
# Coordinator live-eyeball finding (2026-07-23): every marker size
|
||||
# below is drawn as a SCREEN-SPACE constant (Araminta's ruling), but
|
||||
# draw calls execute inside _canvas, whose .scale IS view_zoom — a
|
||||
# raw constant gets multiplied by that transform at render time,
|
||||
# invisible at the Region orbital tile mosaic's tiny fit zoom
|
||||
# (~0.006). zs() below pre-divides by view_zoom so the transform's
|
||||
# multiply cancels back to the literal screen-space value. See
|
||||
# AtlasWindowGeometry.zoom_compensated_size()'s own doc.
|
||||
"view_zoom": viewer.get_view_zoom(),
|
||||
}
|
||||
|
||||
if AtlasWindowGeometry.basins_visible_at_rung(granularity_v2) and viewer.is_overlay_visible(
|
||||
"gen_basins"
|
||||
):
|
||||
_draw_basins(ctx)
|
||||
if viewer.is_overlay_visible("gen_rivers"):
|
||||
_draw_rivers(rn, ctx)
|
||||
if AtlasWindowGeometry.attractors_visible_at_rung(granularity_v2) and viewer.is_overlay_visible(
|
||||
"gen_attractors"
|
||||
):
|
||||
_draw_attractors(ctx)
|
||||
|
||||
|
||||
## Circumference in districts, for nearest_wrap_image()'s wrap resolution —
|
||||
## mirrors AtlasWindowOverlay._draw_tile_mosaic()'s own `cols` computation
|
||||
## exactly (same source, same reason: only meaningful in tile/orbital mode on
|
||||
## a real body; a no-radius body has no periodicity, `cols=0` is
|
||||
## nearest_wrap_image()'s own documented no-op passthrough).
|
||||
func _cols_for_wrap(radius_km: float) -> int:
|
||||
if radius_km <= 0.0:
|
||||
return 0
|
||||
return int(AtlasDescendGeometryRef.district_extent(radius_km).get("cols", 0))
|
||||
|
||||
|
||||
## Zoom-compensated screen-space size — thin per-ctx wrapper over
|
||||
## AtlasWindowGeometry.zoom_compensated_size() (see that function's own doc
|
||||
## for the "why divide" rationale). Every draw_circle()/draw_arc()/
|
||||
## draw_polyline() radius or line-width in this file routes through this so
|
||||
## Araminta's "constant on-screen size" ruling holds at every rung/zoom.
|
||||
func _zs(screen_space_size: float, ctx: Dictionary) -> float:
|
||||
return AtlasWindowGeometry.zoom_compensated_size(screen_space_size, ctx["view_zoom"])
|
||||
|
||||
|
||||
## Pixel (row, col) -> canvas-local, wrap-resolved to whichever longitude
|
||||
## image is nearest the currently-held view — the SAME two-step
|
||||
## (map-then-nearest-wrap) the tile mosaic draw path uses, just for a single
|
||||
## point instead of a tile's four corners.
|
||||
func _pos(row: float, col: float, ctx: Dictionary) -> Vector2:
|
||||
var world_m: Vector2 = AtlasWindowGeometry.layer1_pixel_to_world_m(
|
||||
row, col, ctx["grid_w"], ctx["grid_h"], ctx["radius_km"]
|
||||
)
|
||||
var district: Vector2 = AtlasWindowGeometry.world_m_to_district(world_m)
|
||||
var cols: int = ctx["cols"]
|
||||
if cols > 0:
|
||||
var held_center: Vector2i = ctx["held_center"]
|
||||
var wrapped_col: float = float(
|
||||
AtlasWindowGeometry.nearest_wrap_image(roundi(district.x), held_center.x, cols)
|
||||
)
|
||||
# Preserve the SUB-district fractional offset nearest_wrap_image()'s
|
||||
# integer rounding would otherwise discard — river dots are not
|
||||
# district-lattice-snapped (see world_m_to_district()'s own doc).
|
||||
district.x = wrapped_col + (district.x - roundi(district.x))
|
||||
return AtlasWindowGeometry.district_to_canvas_local(
|
||||
district, ctx["held_center"], ctx["held_n"], ctx["cell_px"]
|
||||
)
|
||||
|
||||
|
||||
func _draw_rivers(rn: Dictionary, ctx: Dictionary) -> void:
|
||||
var granularity_v2: String = ctx["granularity_v2"]
|
||||
var river_cells: Array = rn.get("river_cells", [])
|
||||
var river_class: Array = rn.get("river_class", [])
|
||||
var is_region: bool = granularity_v2 == "Region"
|
||||
|
||||
for idx in range(river_cells.size()):
|
||||
var c: Variant = river_cells[idx]
|
||||
if not (c is Array and c.size() >= 2):
|
||||
continue
|
||||
var cls: int = (
|
||||
int(river_class[idx])
|
||||
if idx < river_class.size()
|
||||
else AtlasWindowGeometry.RIVER_CLASS_FALLBACK
|
||||
)
|
||||
if not AtlasWindowGeometry.river_class_visible_at_rung(cls, granularity_v2):
|
||||
continue
|
||||
var p: Vector2 = _pos(float(c[0]), float(c[1]), ctx)
|
||||
if is_region:
|
||||
var radius: float = AtlasWindowGeometry.RIVER_DOT_RADIUS_BY_CLASS_REGION.get(cls, 2.2)
|
||||
draw_circle(p, _zs(radius, ctx), COLOR_GEN_RIVER)
|
||||
else:
|
||||
# District: trunk-only (already filtered above), reduced size +
|
||||
# opacity — the ruling's "fade down" treatment.
|
||||
var faded := Color(
|
||||
COLOR_GEN_RIVER.r,
|
||||
COLOR_GEN_RIVER.g,
|
||||
COLOR_GEN_RIVER.b,
|
||||
COLOR_GEN_RIVER.a * AtlasWindowGeometry.RIVER_DOT_OPACITY_DISTRICT_TRUNK
|
||||
)
|
||||
draw_circle(p, _zs(AtlasWindowGeometry.RIVER_DOT_RADIUS_DISTRICT_TRUNK, ctx), faded)
|
||||
|
||||
if AtlasWindowGeometry.confluences_visible_at_rung(granularity_v2):
|
||||
for cf: Variant in rn.get("confluences", []):
|
||||
if cf is Array and cf.size() >= 2:
|
||||
var p: Vector2 = _pos(float(cf[0]), float(cf[1]), ctx)
|
||||
var radius: float = _zs(AtlasWindowGeometry.RIVER_CONFLUENCE_RADIUS_REGION, ctx)
|
||||
draw_circle(p, radius, COLOR_GEN_RIVER)
|
||||
|
||||
if AtlasWindowGeometry.mouths_visible_at_rung(granularity_v2):
|
||||
for m: Variant in rn.get("mouths", []):
|
||||
if m is Array and m.size() >= 2:
|
||||
_draw_mouth(_pos(float(m[0]), float(m[1]), ctx), ctx)
|
||||
|
||||
|
||||
## Double-ring sea-terminus marker — verbatim geometry from the retired
|
||||
## atlas_marker_overlay.gd _draw_gen_rivers() (:536-539). Mouths never fade
|
||||
## (Araminta's ruling: "a mouth is always a landmark") — same styling at
|
||||
## every rung it's visible at (Region, District; never Quarter).
|
||||
func _draw_mouth(p: Vector2, ctx: Dictionary) -> void:
|
||||
draw_arc(
|
||||
p, _zs(AtlasWindowGeometry.MOUTH_RING_RADIUS, ctx), 0.0, TAU, 18, COLOR_GEN_MOUTH, _zs(1.5, ctx)
|
||||
)
|
||||
var halo := Color(
|
||||
COLOR_GEN_MOUTH.r, COLOR_GEN_MOUTH.g, COLOR_GEN_MOUTH.b, AtlasWindowGeometry.MOUTH_HALO_ALPHA
|
||||
)
|
||||
draw_arc(p, _zs(AtlasWindowGeometry.MOUTH_HALO_RADIUS, ctx), 0.0, TAU, 22, halo, _zs(1.0, ctx))
|
||||
|
||||
|
||||
## Basins — Region only, binary (no fade), per the ruling. Polygon fill +
|
||||
## boundary polyline, verbatim geometry from the retired
|
||||
## atlas_marker_overlay.gd _draw_gen_basins() (:542-557), coordinate mapping
|
||||
## replaced with _pos() (this file's wrap-aware canvas-local mapping) in place
|
||||
## of the retired _gen_pos() texture-fraction mapping. The FILL polygon's
|
||||
## points are positions (never zoom-compensated — the fill must track the
|
||||
## real district-space shape); only the boundary LINE's width is a
|
||||
## screen-space marker size and goes through _zs().
|
||||
func _draw_basins(ctx: Dictionary) -> void:
|
||||
for b: Variant in _layer1.get("drainage_basins", []):
|
||||
if not b is Dictionary:
|
||||
continue
|
||||
var boundary: Array = b.get("boundary", [])
|
||||
var pts: PackedVector2Array = PackedVector2Array()
|
||||
for pt: Variant in boundary:
|
||||
if pt is Array and pt.size() >= 2:
|
||||
pts.append(_pos(float(pt[0]), float(pt[1]), ctx))
|
||||
if pts.size() < 2:
|
||||
continue
|
||||
if pts.size() >= 3:
|
||||
draw_colored_polygon(pts, COLOR_GEN_BASIN_FILL)
|
||||
var loop: PackedVector2Array = pts.duplicate()
|
||||
loop.append(pts[0])
|
||||
draw_polyline(loop, COLOR_GEN_BASIN_LINE, _zs(0.8, ctx), true)
|
||||
|
||||
|
||||
## Attractors — Region only, wave 1 (per the ruling; District/Quarter never
|
||||
## reach this function since _draw() gates the whole call on
|
||||
## attractors_visible_at_rung()). Ported from the retired
|
||||
## atlas_marker_overlay.gd _draw_gen_attractors()/_draw_attractor_shape()
|
||||
## (:560-...) — the shape vocabulary (7 attractor-type glyphs) is Araminta's
|
||||
## existing design, unchanged; only the coordinate mapping moves to _pos()
|
||||
## and the size is zoom-compensated before reaching the shape drawer (that
|
||||
## function stays a pure "draw this size at this position", zoom-agnostic).
|
||||
func _draw_attractors(ctx: Dictionary) -> void:
|
||||
for a: Variant in _layer1.get("attractors", []):
|
||||
if not a is Dictionary:
|
||||
continue
|
||||
var strength: float = float(a.get("strength", 0.0))
|
||||
if strength < AtlasWindowGeometry.ATTRACTOR_MIN_STRENGTH:
|
||||
continue
|
||||
var pos_rc: Variant = a.get("position")
|
||||
if not pos_rc is Array or pos_rc.size() < 2:
|
||||
continue
|
||||
var p: Vector2 = _pos(float(pos_rc[0]), float(pos_rc[1]), ctx)
|
||||
var size: float = _zs(5.0 + strength * 4.0, ctx)
|
||||
var color: Color = AtlasOverlayColors.sub_biome_color(str(a.get("sub_biome", "")))
|
||||
_draw_attractor_shape(str(a.get("attractor_type", "")), p, size, color, _zs(1.0, ctx))
|
||||
|
||||
|
||||
## Attractor type -> marker shape — from the retired atlas_marker_overlay.gd
|
||||
## _draw_attractor_shape(). `size` AND `px_w` (the 1-screen-px stroke unit)
|
||||
## both arrive ALREADY zoom-compensated from _draw_attractors() — this
|
||||
## function stays a pure "draw at these literal dimensions" primitive with no
|
||||
## ctx/zoom knowledge of its own. px_w exists because Godot multiplies stroke
|
||||
## WIDTH args by the canvas scale exactly like radii (PR #195 review, Tyre
|
||||
## I1: the retired code's raw 1.0/2.0 widths rasterized at ~0.01px at the
|
||||
## Region orbital fit zoom — the same sub-pixel failure the dot/ring
|
||||
## compensation fixed, missed on glyph outlines).
|
||||
func _draw_attractor_shape(
|
||||
atype: String, pos: Vector2, size: float, color: Color, px_w: float
|
||||
) -> void:
|
||||
match atype:
|
||||
"RiverMouth":
|
||||
draw_circle(pos, size, color)
|
||||
"Confluence":
|
||||
draw_circle(pos, size * 0.8, color)
|
||||
draw_arc(pos, size * 1.3, 0.0, TAU, 12, color, px_w)
|
||||
"Alpine", "PassEntrance":
|
||||
var pts := PackedVector2Array(
|
||||
[
|
||||
pos + Vector2(0, -size),
|
||||
pos + Vector2(-size * 0.8, size * 0.6),
|
||||
pos + Vector2(size * 0.8, size * 0.6),
|
||||
]
|
||||
)
|
||||
draw_colored_polygon(pts, color)
|
||||
"Coastal", "NaturalHarbor":
|
||||
draw_arc(pos, size, PI * 0.15, PI * 0.85, 10, color, 2.0 * px_w)
|
||||
"Oasis":
|
||||
draw_circle(pos, size * 0.5, color)
|
||||
for i in range(6):
|
||||
var ang: float = TAU * float(i) / 6.0
|
||||
draw_line(pos, pos + Vector2(cos(ang), sin(ang)) * size, color, px_w)
|
||||
_:
|
||||
draw_circle(pos, size * 0.6, color)
|
||||
@@ -95,6 +95,8 @@ const AtlasWindowGeometry := preload("res://ui/implant/apps/atlas/atlas_window_g
|
||||
const AtlasDescendGeometry := preload("res://ui/implant/apps/atlas/atlas_descend_geometry.gd")
|
||||
# T-1153: orbital rest-state mosaic orchestration.
|
||||
const AtlasWindowTileSet := preload("res://ui/implant/apps/atlas/atlas_window_tile_set.gd")
|
||||
# T-1156 wave 1: whole-body nature overlay (rivers/basins/attractors).
|
||||
const AtlasWindowNatureOverlay := preload("res://ui/implant/apps/atlas/atlas_window_nature_overlay.gd")
|
||||
|
||||
# ── Overlay definitions (T-1138 — reuses atlas_overlay_bar.gd/
|
||||
# atlas_legend_panel.gd's existing duck-typed viewer interface: both call
|
||||
@@ -121,6 +123,10 @@ const OVERLAY_DEFS: Array = [
|
||||
"group": "toggle",
|
||||
"tooltip": "Vegetation — green-family ramp. Marine reads transparent (open water)."
|
||||
},
|
||||
# T-1156 wave 1: per-rung visibility lives in AtlasWindowGeometry's tables.
|
||||
{"id": "gen_rivers", "label": "RVR", "group": "toggle", "tooltip": "Rivers — per-rung class filter."},
|
||||
{"id": "gen_basins", "label": "BAS", "group": "toggle", "tooltip": "Drainage basins — Region only."},
|
||||
{"id": "gen_attractors", "label": "ATR", "group": "toggle", "tooltip": "Attractors — Region only."},
|
||||
]
|
||||
|
||||
# ── Context (set by enter()) ──────────────────────────────────────────────
|
||||
@@ -174,6 +180,7 @@ var _overlay_bar = null
|
||||
var _legend_panel = null
|
||||
var _window_request = null # AtlasWindowRequest
|
||||
var _tile_set = null # AtlasWindowTileSet (T-1153, live round 3)
|
||||
var _nature_overlay = null # AtlasWindowNatureOverlay (T-1156 wave 1)
|
||||
|
||||
## T-1153 (design doc §4): true while showing the orbital rest state as a
|
||||
## MULTI-WINDOW MOSAIC instead of the single held composite (`_window`). Set
|
||||
@@ -193,6 +200,7 @@ func _ready() -> void:
|
||||
|
||||
for def: Dictionary in OVERLAY_DEFS:
|
||||
_overlay_visibility[def["id"]] = false
|
||||
_overlay_visibility["gen_rivers"] = true # T-1156 wave 1: RVR default ON
|
||||
|
||||
_canvas = Node2D.new()
|
||||
_canvas.name = "WindowCanvas"
|
||||
@@ -203,6 +211,10 @@ func _ready() -> void:
|
||||
_overlay_node.viewer = self
|
||||
_canvas.add_child(_overlay_node)
|
||||
|
||||
_nature_overlay = AtlasWindowNatureOverlay.new(self)
|
||||
_nature_overlay.name = "WindowNatureOverlay"
|
||||
_canvas.add_child(_nature_overlay)
|
||||
|
||||
_window_request = AtlasWindowRequest.new(self)
|
||||
_window_request.name = "WindowRequest"
|
||||
add_child(_window_request)
|
||||
@@ -291,6 +303,7 @@ func _enter_tile_mode(body: Dictionary, system: Dictionary, radius_km: float) ->
|
||||
_fit_and_center()
|
||||
_window_request.reset()
|
||||
_tile_set.enter(_dict_str(_body, "body_id", ""), radius_km)
|
||||
_nature_overlay.request_layer1(_dict_str(_body, "body_id", ""))
|
||||
_refresh_screen_header()
|
||||
_legend_panel.refresh()
|
||||
grab_focus()
|
||||
@@ -326,6 +339,7 @@ func _enter_at_rung(
|
||||
_window_request.request_now(
|
||||
_dict_str(_body, "body_id", ""), _held_center, clamped_n, granularity_v2
|
||||
)
|
||||
_nature_overlay.request_layer1(_dict_str(_body, "body_id", ""))
|
||||
_refresh_screen_header()
|
||||
_legend_panel.refresh()
|
||||
grab_focus()
|
||||
@@ -432,6 +446,7 @@ func set_overlay_visible(overlay_id: String, visible_state: bool) -> void:
|
||||
return
|
||||
_overlay_visibility[overlay_id] = visible_state
|
||||
_overlay_node.queue_redraw()
|
||||
_nature_overlay.queue_redraw()
|
||||
_legend_panel.refresh()
|
||||
|
||||
|
||||
@@ -515,6 +530,7 @@ func _apply_transform() -> void:
|
||||
_canvas.scale = Vector2(_view_zoom, _view_zoom)
|
||||
queue_redraw()
|
||||
_overlay_node.queue_redraw()
|
||||
_nature_overlay.queue_redraw()
|
||||
|
||||
|
||||
## Cursor-anchored zoom (D-013): the CANVAS POINT under the cursor stays
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -42,6 +42,18 @@ pub struct RiverNetwork {
|
||||
pub confluences: Vec<(u16, u16)>,
|
||||
/// Positions where rivers reach sea level or the heightmap edge.
|
||||
pub mouths: Vec<(u16, u16)>,
|
||||
/// Quantized river class per entry of `river_cells` (same index, same
|
||||
/// length) — 0=stream, 1=tributary, 2=trunk (T-1156 wave 1). Deterministic
|
||||
/// per body+seed (D-010/D-208): a monotonic function of each cell's flow
|
||||
/// accumulation, binned by `drainage::classify_river_cell`. This is the
|
||||
/// carrier for client-side per-rung filtering (Tyre's binding ruling — no
|
||||
/// new wire field beyond this array; ladder rungs decide which classes to
|
||||
/// draw by filtering this list, not by a server-side windowed query).
|
||||
/// `#[serde(default)]` so pre-T-1156 payloads/consumers (and any golden
|
||||
/// fixture predating this field) still decode — an absent array becomes
|
||||
/// empty, never a decode error (the additive T-1124 §1 pattern).
|
||||
#[serde(default)]
|
||||
pub river_class: Vec<u8>,
|
||||
}
|
||||
|
||||
/// One drainage basin / province derived from watershed analysis (D-205).
|
||||
|
||||
@@ -91,7 +91,10 @@ pub fn analyze(elevation: &[f32], width: u32, height: u32, sea_level: f32) -> Dr
|
||||
// 4. Flow accumulation.
|
||||
let accum = flow_accumulation(&fdir, w, h);
|
||||
|
||||
// 5. River network.
|
||||
// 5. River network. River-class banding (T-1156) anchors on its own
|
||||
// river-restricted max internally — see `extract_river_network` — not on
|
||||
// the grid-wide max computed below, so no dependency ordering between
|
||||
// the two is needed.
|
||||
let river_network = extract_river_network(&accum, &fdir, w, h, sea_level, elevation);
|
||||
|
||||
// 6. Basin labeling.
|
||||
@@ -104,7 +107,10 @@ pub fn analyze(elevation: &[f32], width: u32, height: u32, sea_level: f32) -> Dr
|
||||
let drainage_basins = build_basins(&labels, w, h);
|
||||
|
||||
// Max accumulation for D-209 strength normalization (clamped ≥ 1 so the
|
||||
// division is always well-defined, even on a flat/empty world).
|
||||
// division is always well-defined, even on a flat/empty world). This is
|
||||
// the grid-wide max (includes below-sea-level cells) — distinct from the
|
||||
// river-restricted max `extract_river_network` uses for its own T-1156
|
||||
// river-class banding.
|
||||
let max_accumulation = accum.iter().copied().max().unwrap_or(1).max(1);
|
||||
|
||||
DrainageResult {
|
||||
@@ -258,6 +264,30 @@ fn extract_river_network(
|
||||
.map(|i| ((i / w) as u16, (i % w) as u16))
|
||||
.collect();
|
||||
|
||||
// River-restricted max accumulation — the ceiling for the T-1156 log-band
|
||||
// classifier below. Deliberately NOT the grid-wide `max_accumulation`
|
||||
// (DrainageResult's D-209 normalization denominator, which includes
|
||||
// below-sea-level ocean cells where accumulation typically peaks, just
|
||||
// past a river's mouth): anchoring on that grid-wide value would classify
|
||||
// a wet, large-ocean body's actual wettest *river* cell short of trunk,
|
||||
// producing an entirely riverless District rung (Araminta's per-rung
|
||||
// table shows trunk only at District) on exactly the bodies with the
|
||||
// most river to show. Anchoring on the max among cells that passed the
|
||||
// `is_river` filter guarantees every body with any river cells has its
|
||||
// wettest one classified trunk, by construction — see
|
||||
// `classify_river_cell`'s doc comment.
|
||||
let river_max_accumulation = (0..n)
|
||||
.filter(|&i| is_river[i])
|
||||
.map(|i| accum[i])
|
||||
.max()
|
||||
.unwrap_or(RIVER_THRESHOLD + 1); // unused when river_cells is empty
|
||||
|
||||
// River class per entry of `river_cells`, same order (T-1156 wave 1).
|
||||
let river_class: Vec<u8> = (0..n)
|
||||
.filter(|&i| is_river[i])
|
||||
.map(|i| classify_river_cell(accum[i], river_max_accumulation))
|
||||
.collect();
|
||||
|
||||
// Confluences: river cells with 2+ river neighbors flowing into them.
|
||||
let mut inflow_count = vec![0u8; n];
|
||||
for r in 0..h {
|
||||
@@ -314,6 +344,77 @@ fn extract_river_network(
|
||||
river_cells,
|
||||
confluences,
|
||||
mouths,
|
||||
river_class,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bin a river cell's flow accumulation into a quantized class (T-1156 wave 1):
|
||||
/// 0=stream, 1=tributary, 2=trunk. The client filters the ladder rung's river
|
||||
/// draw by this class (Araminta's per-rung table: Region shows trunk only,
|
||||
/// District adds tributary, Quarter shows everything) — no new wire field,
|
||||
/// this is the sole carrier (Tyre's ruling).
|
||||
///
|
||||
/// **Binning: log-scaled fraction of the log-range between `RIVER_THRESHOLD`
|
||||
/// (the accumulation floor below which a cell isn't a river cell at all) and
|
||||
/// `river_max_accumulation` (the highest flow accumulation among this body's
|
||||
/// own river cells), split into equal thirds.** Rationale for log rather than
|
||||
/// linear: flow accumulation grows combinatorially downstream (each
|
||||
/// confluence roughly sums its tributaries), so a linear split over-populates
|
||||
/// the trunk band with anything past the halfway point and starves it on
|
||||
/// modest bodies. Log-scaling spreads the bands evenly across orders of
|
||||
/// magnitude instead, so a river's headwaters (streams), mid-course
|
||||
/// tributaries, and lower trunk read as three roughly even bands on both a
|
||||
/// wet, many-confluence body and a dry, single-channel one.
|
||||
///
|
||||
/// **The ceiling must be `river_max_accumulation` (max over cells that pass
|
||||
/// the `is_river` filter — `accum > RIVER_THRESHOLD && elevation >=
|
||||
/// sea_level`), never `DrainageResult::max_accumulation` (the grid-wide max
|
||||
/// used elsewhere for D-209 strength normalization).** Flow accumulation
|
||||
/// peaks right at a river's mouth, typically on the ocean-side cell just past
|
||||
/// the coastline — a cell that is *never* a river cell by definition
|
||||
/// (`is_river` requires `elevation >= sea_level`). Anchoring on the grid-wide
|
||||
/// max therefore admits a ceiling no river cell can ever reach: on a wet body
|
||||
/// with a large ocean, where accumulation piles up hardest past the
|
||||
/// coastline, every actual river cell would land short of trunk and the
|
||||
/// District rung (trunk-only per Araminta's table) would render riverless —
|
||||
/// exactly backwards, since that is the body with the most river to show.
|
||||
/// Anchoring on `river_max_accumulation` instead guarantees, by construction,
|
||||
/// that a body's own wettest *river* cell — not its wettest cell overall —
|
||||
/// always lands in the trunk band. Every body with any river cells gets a
|
||||
/// trunk, scaled to its own wet/dry character, which is what "this body's
|
||||
/// main river" should mean, and it holds unconditionally (not merely "if the
|
||||
/// wettest water happens to be fluvial").
|
||||
///
|
||||
/// Using a per-body-relative ceiling at all (rather than an absolute multiple
|
||||
/// of `RIVER_THRESHOLD`, e.g. trunk = accum ≥ 800) is itself deliberate: a
|
||||
/// body whose single river barely clears the threshold would classify every
|
||||
/// cell as `stream` under an absolute scheme, reading as "no real river"
|
||||
/// even though it has exactly one.
|
||||
///
|
||||
/// Determinism (D-010/D-208): pure integer/float arithmetic on
|
||||
/// `(accum, river_max_accumulation)`, no RNG, same body+seed → same class
|
||||
/// every run. Monotonic by construction: `log` and the linear division into
|
||||
/// thirds are both non-decreasing in `accum`, so a strictly higher
|
||||
/// accumulation never produces a strictly lower class.
|
||||
fn classify_river_cell(accum: i32, river_max_accumulation: i32) -> u8 {
|
||||
// Callers only invoke this for cells that passed `is_river` (accum >
|
||||
// RIVER_THRESHOLD == 200), and `river_max_accumulation` is the max over
|
||||
// that same cell set, so both logs below are well-defined (positive
|
||||
// arguments) and `river_max_accumulation > RIVER_THRESHOLD` always holds
|
||||
// when there is at least one river cell.
|
||||
let floor = (RIVER_THRESHOLD as f64).ln();
|
||||
let ceil = (river_max_accumulation as f64)
|
||||
.max(RIVER_THRESHOLD as f64 + 1.0)
|
||||
.ln();
|
||||
let span = (ceil - floor).max(f64::EPSILON);
|
||||
let frac = ((accum as f64).ln() - floor) / span;
|
||||
let frac = frac.clamp(0.0, 1.0);
|
||||
if frac >= 2.0 / 3.0 {
|
||||
2 // trunk
|
||||
} else if frac >= 1.0 / 3.0 {
|
||||
1 // tributary
|
||||
} else {
|
||||
0 // stream
|
||||
}
|
||||
}
|
||||
|
||||
@@ -806,4 +907,137 @@ mod tests {
|
||||
let n = res.drainage_basins.len();
|
||||
assert!((1..=12).contains(&n), "basin count {n} out of range");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// River class (T-1156 wave 1)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn every_river_cell_has_a_class() {
|
||||
let elev = slope_grid(512, 256);
|
||||
let result = analyze(&elev, 512, 256, 0.3);
|
||||
assert_eq!(
|
||||
result.river_network.river_cells.len(),
|
||||
result.river_network.river_class.len(),
|
||||
"river_class must be parallel/aligned with river_cells"
|
||||
);
|
||||
assert!(
|
||||
!result.river_network.river_cells.is_empty(),
|
||||
"test grid should produce river cells"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn river_class_monotonic_with_accumulation() {
|
||||
// A cell with higher accumulation must never have a lower class than
|
||||
// a cell with lower accumulation — the core binning contract.
|
||||
let elev = slope_grid(512, 256);
|
||||
let result = analyze(&elev, 512, 256, 0.3);
|
||||
let rn = &result.river_network;
|
||||
assert!(!rn.river_cells.is_empty());
|
||||
|
||||
// Recover each river cell's accumulation and pair it with its class.
|
||||
let w = 512usize;
|
||||
let mut pairs: Vec<(i32, u8)> = rn
|
||||
.river_cells
|
||||
.iter()
|
||||
.zip(rn.river_class.iter())
|
||||
.map(|(&(r, c), &class)| {
|
||||
let idx = r as usize * w + c as usize;
|
||||
(result.flow_accumulation[idx], class)
|
||||
})
|
||||
.collect();
|
||||
pairs.sort_by_key(|&(accum, _)| accum);
|
||||
|
||||
let mut max_class_seen = 0u8;
|
||||
for (_, class) in pairs {
|
||||
assert!(
|
||||
class >= max_class_seen,
|
||||
"monotonicity violated: saw class {class} after class {max_class_seen} \
|
||||
in ascending-accumulation order"
|
||||
);
|
||||
max_class_seen = max_class_seen.max(class);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn at_least_one_trunk_cell_when_rivers_exist() {
|
||||
let elev = slope_grid(512, 256);
|
||||
let result = analyze(&elev, 512, 256, 0.3);
|
||||
assert!(!result.river_network.river_cells.is_empty());
|
||||
assert!(
|
||||
result.river_network.river_class.contains(&2),
|
||||
"a body with any rivers must have at least one trunk (class 2) cell — \
|
||||
this is the classify_river_cell river_max_accumulation-anchoring guarantee"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn at_least_one_trunk_cell_on_a_real_body_with_a_large_ocean() {
|
||||
// Regression for the grid-wide-max anchoring bug: GJ1c is exactly the
|
||||
// "wet body with a large ocean" shape where flow accumulation peaks
|
||||
// past the coastline (a non-river cell), which starved the trunk band
|
||||
// when the ceiling was anchored on the grid-wide max instead of the
|
||||
// river-restricted max. Same body + downsample as the cascade golden
|
||||
// (tests/golden/cascade_layer1.json) — 93 river cells there, so this
|
||||
// is a real, non-synthetic exercise of the guarantee.
|
||||
use crate::atlas::heightmap::load_heightmap_png;
|
||||
let src = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../wiki/star-systems/GJ-1/bodies/GJ1c/heightmap.png");
|
||||
let heightmap =
|
||||
load_heightmap_png(&src, "GJ1c", 0.3).expect("decode committed GJ1c heightmap");
|
||||
let small = heightmap.downsample(256, 128);
|
||||
let result = analyze(&small.data, small.width, small.height, small.sea_level);
|
||||
assert!(
|
||||
!result.river_network.river_cells.is_empty(),
|
||||
"GJ1c should have river cells at this downsample"
|
||||
);
|
||||
assert!(
|
||||
result.river_network.river_class.contains(&2),
|
||||
"GJ1c's own wettest river cell must classify as trunk — river-restricted \
|
||||
anchoring must not be starved by ocean-cell accumulation past the coastline"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn river_class_deterministic() {
|
||||
let elev = slope_grid(64, 32);
|
||||
let r1 = analyze(&elev, 64, 32, 0.3);
|
||||
let r2 = analyze(&elev, 64, 32, 0.3);
|
||||
assert_eq!(
|
||||
r1.river_network.river_class, r2.river_network.river_class,
|
||||
"river_class must be deterministic (D-010/D-208)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_river_cell_barely_above_threshold_still_gets_a_trunk() {
|
||||
// A body whose single river barely clears RIVER_THRESHOLD must still
|
||||
// classify its own maximum as trunk — the whole point of anchoring
|
||||
// the log-range ceiling at river_max_accumulation instead of an
|
||||
// absolute multiple of RIVER_THRESHOLD.
|
||||
let river_max_accumulation = RIVER_THRESHOLD + 5;
|
||||
assert_eq!(
|
||||
classify_river_cell(river_max_accumulation, river_max_accumulation),
|
||||
2,
|
||||
"the body's own max river-cell accumulation must always classify as trunk"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_river_cell_spans_all_three_classes_on_wide_range() {
|
||||
// Sanity check on the log-binning: a body with a wide dynamic range
|
||||
// (headwater trickles up to a major trunk) should exercise all three
|
||||
// classes, not collapse to two.
|
||||
let river_max_accumulation = 131_000;
|
||||
let low = classify_river_cell(RIVER_THRESHOLD + 1, river_max_accumulation);
|
||||
let mid = classify_river_cell(5_000, river_max_accumulation);
|
||||
let high = classify_river_cell(river_max_accumulation, river_max_accumulation);
|
||||
assert_eq!(low, 0, "just above threshold should be a stream");
|
||||
assert_eq!(mid, 1, "mid-range accumulation should be a tributary");
|
||||
assert_eq!(
|
||||
high, 2,
|
||||
"the body's max river-cell accumulation should be trunk"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -557,6 +557,7 @@ fn generate_atlas_layer_response_fixtures() {
|
||||
river_cells: vec![(12, 58), (12, 59)],
|
||||
confluences: vec![],
|
||||
mouths: vec![(12, 58)],
|
||||
river_class: vec![1, 2],
|
||||
},
|
||||
drainage_basins: vec![DrainageBasin {
|
||||
basin_id: 1,
|
||||
|
||||
@@ -8216,6 +8216,101 @@
|
||||
124,
|
||||
239
|
||||
]
|
||||
],
|
||||
"river_class": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user