From 9d7c01de0270dd7274a88264a97965c7f47ddd05 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 23 Jul 2026 12:54:25 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20T-1170=20B1+B2=20=E2=80=94=20visibi?= =?UTF-8?q?lity-table=20split;=20Region=20skeleton=20chords=20from=20downs?= =?UTF-8?q?tream=20pointers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B1 (Ruling 5c): nature block split out of atlas_window_geometry.gd (954/1000 cap pressure) into atlas_window_geometry_nature.gd; RIVER_ CLASS_VISIBLE_BY_RUNG replaced by SKELETON_CLASS_VISIBLE_BY_RUNG (Region-only now) + COURSE_CLASS_VISIBLE_BY_RUNG (District trunk+ tributary; Quarter all three — the pre-announced Quarter-rivers- return) with width/opacity companion tables as Araminta's single revisit point; deliberately opposite unknown-tag fallbacks per reader (skeleton->full, course->empty), documented. B2 (Ruling 5a): Region dot-scatter upgraded to connected chords via river_downstream — D8 direction decode (0-7 into drainage.rs's (row,col) delta table, antimeridian wrap-aware), sentinel chain ends (MOUTH=8 ring-on-land, EDGE_DRAIN=9 no ring, TERMINAL=10 reserved, decodes like EDGE_DRAIN so the future endorheic server needs no client change). Pure build_skeleton_chords() split from drawing for render-free testability. Chord clip rule (3g pick): segment clips if either endpoint OR midpoint is drawn water — three-point catches both narrow-inlet and long-chord failure modes at one extra lookup; documented. Self-caught during build: first draft misdecoded the pointer as a river_cells INDEX; rewired to direction decode against A1's real convention before leaving the branch. Dual revert-verified (direction sabotage -> 6 named failures incl. the chain-threading pin; midpoint-drop -> exactly the 1 named clip test). Suites: geometry-nature 86/86, geometry 130/130, nature-overlay 24/24; full sweep 3892 with only the 6 known pre-existing garment/gait failures untouched by this batch; gdlint clean. Tickets: T-1170 Co-Authored-By: Claude Fable 5 --- client/tests/test_atlas_window_geometry.gd | 48 ++ .../test_atlas_window_geometry_nature.gd | 591 ++++++++++++++---- .../tests/test_atlas_window_nature_overlay.gd | 70 +++ .../apps/atlas/atlas_window_geometry.gd | 245 +------- .../atlas/atlas_window_geometry_nature.gd | 505 +++++++++++++++ .../apps/atlas/atlas_window_nature_overlay.gd | 243 +++++-- 6 files changed, 1290 insertions(+), 412 deletions(-) create mode 100644 client/ui/implant/apps/atlas/atlas_window_geometry_nature.gd diff --git a/client/tests/test_atlas_window_geometry.gd b/client/tests/test_atlas_window_geometry.gd index 9d407785a..b1e6b812a 100644 --- a/client/tests/test_atlas_window_geometry.gd +++ b/client/tests/test_atlas_window_geometry.gd @@ -929,3 +929,51 @@ func test_recompute_offset_for_held_n_change_differs_for_different_held_n() -> v + " _view_offset — reusing the same offset across the crossing is" + " exactly the live round 4 bug (composite renders off-canvas)" ).is_not_equal(offset_quarter) + + +# ============================================================================= +# T-1172 round 2: cell_index_for_local_offset() — the shared painter/clip +# index formula (see its own doc for the "why shared, not duplicated" case). +# T-1170: these tests moved here from test_atlas_window_geometry_nature.gd — +# the function itself stayed on THIS file (AtlasWindowGeometry) rather than +# moving to atlas_window_geometry_nature.gd, since it is shared with +# AtlasWindowOverlay's terrain painter, a non-nature consumer — see that +## file's own header doc for the full split rationale. +# ============================================================================= + + +func test_cell_index_for_local_offset_top_left_is_zero_zero() -> void: + var cell: Vector2i = AtlasWindowGeometry.cell_index_for_local_offset(0.0, 0.0, 6400, 64) + assert_that(cell).is_equal(Vector2i(0, 0)) + + +## The exact live-repro numbers from T-1172 round 2's trace: a query whose +## district-space local offset is (2594.09, 5593.15) inside a 6400-wide, +## 64-cell-side window must resolve to (col=25, row=55) — pinned directly +## against the LIVE captured values that closed the investigation (both the +## painter's _build_tile_texture() and the clip independently produced this +## exact pair for the same query in the live trace). +func test_cell_index_for_local_offset_matches_the_live_trace_repro() -> void: + var cell: Vector2i = AtlasWindowGeometry.cell_index_for_local_offset( + 2594.0849609375, 5593.15258789062, 6400, 64 + ) + assert_that(cell).override_failure_message( + "must match the live-captured painter/clip agreement point from the" + + " T-1172 round 2 investigation — (col=25, row=55)" + ).is_equal(Vector2i(25, 55)) + + +func test_cell_index_for_local_offset_bottom_right_boundary_clamps_inside() -> void: + # local offset == n (the exclusive upper boundary) must clamp to the LAST + # cell, not overflow to a nonexistent grid_side'th cell. + var cell: Vector2i = AtlasWindowGeometry.cell_index_for_local_offset(6400.0, 6400.0, 6400, 64) + assert_that(cell).is_equal(Vector2i(63, 63)) + + +func test_cell_index_for_local_offset_zero_n_or_grid_side_returns_sentinel() -> void: + assert_that(AtlasWindowGeometry.cell_index_for_local_offset(10.0, 10.0, 0, 64)).is_equal( + Vector2i(-1, -1) + ) + assert_that(AtlasWindowGeometry.cell_index_for_local_offset(10.0, 10.0, 6400, 0)).is_equal( + Vector2i(-1, -1) + ) diff --git a/client/tests/test_atlas_window_geometry_nature.gd b/client/tests/test_atlas_window_geometry_nature.gd index 73486d8d9..124efba75 100644 --- a/client/tests/test_atlas_window_geometry_nature.gd +++ b/client/tests/test_atlas_window_geometry_nature.gd @@ -1,15 +1,26 @@ -## 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. +## T-1156 wave 1 / T-1170: pure-function tests for AtlasWindowGeometryNature's +## Layer-1 nature-overlay pixel mapping (layer1_pixel_to_world_m/ +## world_m_to_district/layer1_pixel_to_canvas_local), per-rung visibility/ +## filter policy (skeleton_class_visible_at_rung/course_class_visible_at_rung/ +## confluences_visible_at_rung/mouths_visible_at_rung/basins_visible_at_rung/ +## attractors_visible_at_rung), the D8 river_downstream decode +## (d8_downstream_target), and course width/opacity readers +## (course_class_width_px/course_class_opacity). 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. +## +## T-1170: this file's SUBJECT preload moved from AtlasWindowGeometry to +## AtlasWindowGeometryNature (the T-1170 split, see that file's own doc) — +## every symbol tested below now lives there. cell_index_for_local_offset() +## STAYED on AtlasWindowGeometry (shared with the non-nature terrain painter) +## — its tests stay in test_atlas_window_geometry.gd, not duplicated here. class_name TestAtlasWindowGeometryNature extends GdUnitTestSuite -const AtlasWindowGeometry := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd") +const AtlasWindowGeometryNature := preload( + "res://ui/implant/apps/atlas/atlas_window_geometry_nature.gd" +) const CELL_PIXEL_SIZE: float = 16.0 const DISTRICT_M: float = 2048.0 @@ -25,7 +36,7 @@ const DISTRICT_M: float = 2048.0 ## 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) + var w: Vector2 = AtlasWindowGeometryNature.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) @@ -36,7 +47,9 @@ func test_layer1_pixel_to_world_m_col_zero_is_world_x_zero() -> void: 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 w: Vector2 = AtlasWindowGeometryNature.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) @@ -46,7 +59,7 @@ func test_layer1_pixel_to_world_m_row_zero_is_north_pole_negative_wy() -> void: 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( + var w: Vector2 = AtlasWindowGeometryNature.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 @@ -57,7 +70,7 @@ func test_layer1_pixel_to_world_m_last_row_is_south_pole_positive_wy() -> void: ## 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) + var w: Vector2 = AtlasWindowGeometryNature.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) @@ -66,7 +79,9 @@ func test_layer1_pixel_to_world_m_mid_row_is_near_equator() -> void: 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 w: Vector2 = AtlasWindowGeometryNature.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) @@ -75,13 +90,13 @@ func test_layer1_pixel_to_world_m_full_width_col_is_full_circumference() -> void ## — 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) + var w: Vector2 = AtlasWindowGeometryNature.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) + var w: Vector2 = AtlasWindowGeometryNature.layer1_pixel_to_world_m(1.0, 1.0, 0.0, 0.0, 6371.0) assert_that(w).is_equal(Vector2.ZERO) @@ -92,13 +107,16 @@ func test_layer1_pixel_to_world_m_zero_grid_dims_returns_zero() -> void: 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)) + var d: Vector2 = AtlasWindowGeometryNature.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. +# AtlasWindowGeometry.district_to_canvas_local() called manually with the +# same intermediate value. # ============================================================================= @@ -107,6 +125,7 @@ func test_world_m_to_district_divides_by_district_m() -> void: ## 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 AtlasWindowGeometry := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd") var radius_km := 6371.0 var grid_w := 256.0 var grid_h := 128.0 @@ -115,14 +134,14 @@ func test_layer1_pixel_to_canvas_local_matches_manual_composition() -> void: var row := 40.0 var col := 80.0 - var result: Vector2 = AtlasWindowGeometry.layer1_pixel_to_canvas_local( + var result: Vector2 = AtlasWindowGeometryNature.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( + var world_m: Vector2 = AtlasWindowGeometryNature.layer1_pixel_to_world_m( row, col, grid_w, grid_h, radius_km ) - var district: Vector2 = AtlasWindowGeometry.world_m_to_district(world_m) + var district: Vector2 = AtlasWindowGeometryNature.world_m_to_district(world_m) var expected: Vector2 = AtlasWindowGeometry.district_to_canvas_local( district, held_center, held_n, CELL_PIXEL_SIZE ) @@ -130,94 +149,471 @@ func test_layer1_pixel_to_canvas_local_matches_manual_composition() -> void: # ============================================================================= -# Per-rung river-class visibility (Araminta's ruling, 2026-07-23) — -# river_class_visible_at_rung() +# T-1170 Ruling 2a-2d/5a: d8_downstream_target() — the river_downstream D8 +# pointer decode. Direction table CONFIRMED against Dudley's A1 +# (server/src/atlas/drainage.rs:35-44): 0=N(-1,0) 1=S(1,0) 2=E(0,1) 3=W(0,-1) +# 4=NE(-1,1) 5=NW(-1,-1) 6=SE(1,1) 7=SW(1,-1). Sentinels: MOUTH=8, +# EDGE_DRAIN=9, TERMINAL=10 (reserved). # ============================================================================= -func test_river_class_visible_at_rung_region_shows_every_class() -> void: +func test_d8_downstream_target_north_decrements_row() -> void: + var target: Variant = AtlasWindowGeometryNature.d8_downstream_target(10.0, 10.0, 0) + assert_that(target).is_equal(Vector2(9.0, 10.0)) + + +func test_d8_downstream_target_south_increments_row() -> void: + var target: Variant = AtlasWindowGeometryNature.d8_downstream_target(10.0, 10.0, 1) + assert_that(target).is_equal(Vector2(11.0, 10.0)) + + +func test_d8_downstream_target_east_increments_col() -> void: + var target: Variant = AtlasWindowGeometryNature.d8_downstream_target(10.0, 10.0, 2) + assert_that(target).is_equal(Vector2(10.0, 11.0)) + + +func test_d8_downstream_target_west_decrements_col() -> void: + var target: Variant = AtlasWindowGeometryNature.d8_downstream_target(10.0, 10.0, 3) + assert_that(target).is_equal(Vector2(10.0, 9.0)) + + +func test_d8_downstream_target_diagonals_move_both_axes() -> void: + # 4=NE, 5=NW, 6=SE, 7=SW — each a diagonal (row, col) delta of magnitude 1 + # on both axes, matching the direction letters' compass meaning. + assert_that(AtlasWindowGeometryNature.d8_downstream_target(10.0, 10.0, 4)).is_equal( + Vector2(9.0, 11.0) + ) # NE + assert_that(AtlasWindowGeometryNature.d8_downstream_target(10.0, 10.0, 5)).is_equal( + Vector2(9.0, 9.0) + ) # NW + assert_that(AtlasWindowGeometryNature.d8_downstream_target(10.0, 10.0, 6)).is_equal( + Vector2(11.0, 11.0) + ) # SE + assert_that(AtlasWindowGeometryNature.d8_downstream_target(10.0, 10.0, 7)).is_equal( + Vector2(11.0, 9.0) + ) # SW + + +## MOUTH (8), EDGE_DRAIN (9), and TERMINAL (10, reserved) are all sentinels +## >= RIVER_DOWNSTREAM_SENTINEL_BASE — every one must decode to `null` (chain +## end, no segment to draw), not a direction lookup. +func test_d8_downstream_target_sentinels_return_null() -> void: + assert_that( + AtlasWindowGeometryNature.d8_downstream_target( + 10.0, 10.0, AtlasWindowGeometryNature.RIVER_DOWNSTREAM_MOUTH + ) + ).is_null() + assert_that( + AtlasWindowGeometryNature.d8_downstream_target( + 10.0, 10.0, AtlasWindowGeometryNature.RIVER_DOWNSTREAM_EDGE_DRAIN + ) + ).is_null() + assert_that( + AtlasWindowGeometryNature.d8_downstream_target( + 10.0, 10.0, AtlasWindowGeometryNature.RIVER_DOWNSTREAM_TERMINAL + ) + ).is_null() + + +## A malformed/out-of-range direction (negative, or >= sentinel base but not +## one of the three named sentinels — e.g. a future reserved value) must also +## decode to null, not crash on an out-of-bounds D8_DIRECTION_DELTAS index. +func test_d8_downstream_target_out_of_range_returns_null_not_crash() -> void: + assert_that(AtlasWindowGeometryNature.d8_downstream_target(10.0, 10.0, -1)).is_null() + assert_that(AtlasWindowGeometryNature.d8_downstream_target(10.0, 10.0, 255)).is_null() + + +## The sentinel base itself (8) is the exact boundary between the last real +## direction (7=SW) and the first sentinel (8=MOUTH) — pin the boundary +## exactly rather than relying only on the interior-value tests above. +func test_d8_downstream_target_boundary_seven_is_direction_eight_is_sentinel() -> void: + assert_that(AtlasWindowGeometryNature.d8_downstream_target(0.0, 0.0, 7)).is_not_null() + assert_that(AtlasWindowGeometryNature.d8_downstream_target(0.0, 0.0, 8)).is_null() + + +# ============================================================================= +# T-1170 Ruling 5a: build_skeleton_chords() — the pure chain-CONSTRUCTION +# function (no draw calls) AtlasWindowNatureOverlay._draw_skeleton_chords() +# delegates to. This is the load-bearing chain-walking logic (visibility +# filtering + D8 decode + sentinel chain-ends), tested here directly rather +# than only through the draw-smoke suite's pixel proof. +# ============================================================================= + + +## Two river cells, cell 0 flows SOUTH (direction 1) into cell 1's own grid +## position — one segment constructed, from cell 0's position to cell 0's +## position + (1, 0) [south]. cls read from river_class at the SAME index. +func test_build_skeleton_chords_constructs_one_segment_for_a_simple_pair() -> void: + var river_cells: Array = [[10, 10], [11, 10]] + var river_class: Array = [ + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, AtlasWindowGeometryNature.RIVER_CLASS_TRUNK + ] + var river_downstream: Array = [1, AtlasWindowGeometryNature.RIVER_DOWNSTREAM_MOUTH] # 1 = S + var chords: Array = AtlasWindowGeometryNature.build_skeleton_chords( + river_cells, river_class, river_downstream, "Region" + ) + assert_int(chords.size()).override_failure_message( + "cell 0 (flows S, a real direction) must construct one segment;" + + " cell 1 (MOUTH sentinel) must construct none — expected exactly 1 total" + ).is_equal(1) + var chord: Dictionary = chords[0] + assert_that(chord["from"]).is_equal(Vector2(10.0, 10.0)) + assert_that(chord["to"]).is_equal(Vector2(11.0, 10.0)) + assert_int(chord["cls"]).is_equal(AtlasWindowGeometryNature.RIVER_CLASS_TRUNK) + + +## Sentinel chain ends: MOUTH, EDGE_DRAIN, and TERMINAL (reserved) must each +## construct ZERO segments for their own cell — a chain-end has no downstream +## neighbor to connect to, regardless of which sentinel flavor. +func test_build_skeleton_chords_sentinel_chain_ends_construct_no_segment() -> void: + var river_cells: Array = [[0, 0], [10, 10], [20, 20]] + var river_class: Array = [ + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, + ] + var river_downstream: Array = [ + AtlasWindowGeometryNature.RIVER_DOWNSTREAM_MOUTH, + AtlasWindowGeometryNature.RIVER_DOWNSTREAM_EDGE_DRAIN, + AtlasWindowGeometryNature.RIVER_DOWNSTREAM_TERMINAL, + ] + var chords: Array = AtlasWindowGeometryNature.build_skeleton_chords( + river_cells, river_class, river_downstream, "Region" + ) + assert_array(chords).override_failure_message( + "every cell is a sentinel chain-end (MOUTH/EDGE_DRAIN/TERMINAL) —" + + " zero segments must be constructed" + ).is_empty() + + +## A downstream direction pointing at a class not visible at this rung's +## SKELETON path is filtered by the UPSTREAM cell's own class, not the +## target's — District shows NO skeleton classes at all (T-1170: skeleton +## draws only at Region now), so a District query must construct zero +## segments regardless of the fixture's directions. +func test_build_skeleton_chords_district_rung_constructs_nothing() -> void: + var river_cells: Array = [[10, 10], [11, 10]] + var river_class: Array = [ + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, AtlasWindowGeometryNature.RIVER_CLASS_TRUNK + ] + var river_downstream: Array = [1, AtlasWindowGeometryNature.RIVER_DOWNSTREAM_MOUTH] + var chords: Array = AtlasWindowGeometryNature.build_skeleton_chords( + river_cells, river_class, river_downstream, "District" + ) + assert_array(chords).is_empty() + + +## river_downstream shorter than river_cells (pre-T-1170 payload / graceful +## empty-Vec decode) — cells with no corresponding index must construct no +## segment, not crash on an out-of-bounds read. +func test_build_skeleton_chords_missing_downstream_entries_construct_nothing() -> void: + var river_cells: Array = [[10, 10], [11, 10], [12, 10]] + var river_class: Array = [ + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, + ] + var river_downstream: Array = [1] # only index 0 has a pointer + var chords: Array = AtlasWindowGeometryNature.build_skeleton_chords( + river_cells, river_class, river_downstream, "Region" + ) + assert_int(chords.size()).is_equal(1) + + +## An empty river_downstream array entirely (the actual wire shape Dudley's +## `#[serde(default)]` produces for a pre-T-1170 payload) must construct zero +## segments, not error. +func test_build_skeleton_chords_empty_downstream_array_constructs_nothing() -> void: + var river_cells: Array = [[10, 10], [11, 10]] + var river_class: Array = [ + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, AtlasWindowGeometryNature.RIVER_CLASS_TRUNK + ] + var chords: Array = AtlasWindowGeometryNature.build_skeleton_chords( + river_cells, river_class, [], "Region" + ) + assert_array(chords).is_empty() + + +## A malformed river_cells entry (not an Array, or too short) is skipped +## entirely — no segment constructed for it, no crash, and it does not +## disturb construction for the OTHER (well-formed) entries in the same +## fixture. +func test_build_skeleton_chords_malformed_cell_entry_is_skipped_not_fatal() -> void: + var river_cells: Array = [[10, 10], "not an array", [12, 10]] + var river_class: Array = [ + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, + ] + var river_downstream: Array = [1, 1, AtlasWindowGeometryNature.RIVER_DOWNSTREAM_MOUTH] + var chords: Array = AtlasWindowGeometryNature.build_skeleton_chords( + river_cells, river_class, river_downstream, "Region" + ) + assert_int(chords.size()).override_failure_message( + "the malformed middle entry must be skipped without disturbing the" + + " well-formed entries around it — expected exactly 1 (cell 0 -> S)" + ).is_equal(1) + + +## river_class shorter than river_cells falls back to RIVER_CLASS_FALLBACK +## (TRUNK) for the missing entry — the same graceful-decode posture +## skeleton_class_visible_at_rung()'s own caller already relies on. +func test_build_skeleton_chords_missing_class_entry_falls_back_to_trunk() -> void: + var river_cells: Array = [[10, 10]] + var river_downstream: Array = [1] + var chords: Array = AtlasWindowGeometryNature.build_skeleton_chords( + river_cells, [], river_downstream, "Region" + ) + assert_int(chords.size()).is_equal(1) + assert_int(chords[0]["cls"]).is_equal(AtlasWindowGeometryNature.RIVER_CLASS_FALLBACK) + + +## REVERT-VERIFICATION pin (per the ticket brief's explicit ask to +## revert-verify the most load-bearing construction path): a chain of THREE +## cells (0 -> 1 -> MOUTH) must construct exactly TWO segments in the correct +## from/to order — proving the chain doesn't just count sentinels correctly +## in isolation (the tests above) but actually threads a multi-hop chain. +## Breaking build_skeleton_chords() to, e.g., always connect cell i to cell +## i+1 by INDEX (the old dot-scatter's adjacency, not a real D8 decode) would +## still pass the single-pair test above by coincidence but fail this one, +## since cell 1's OWN downstream direction (2 = E) does not point at +## cell 2's grid position. +func test_build_skeleton_chords_three_hop_chain_threads_correctly() -> void: + var river_cells: Array = [[0, 0], [1, 0], [1, 5]] # cell 2 is NOT south of cell 1 + var river_class: Array = [ + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, + ] + var river_downstream: Array = [ + 1, # cell 0 -> S -> (1, 0), matches cell 1's own grid position + 2, # cell 1 -> E -> (1, 1) — NOT cell 2's position (1, 5) + AtlasWindowGeometryNature.RIVER_DOWNSTREAM_MOUTH, + ] + var chords: Array = AtlasWindowGeometryNature.build_skeleton_chords( + river_cells, river_class, river_downstream, "Region" + ) + assert_int(chords.size()).is_equal(2) + assert_that(chords[0]["from"]).is_equal(Vector2(0.0, 0.0)) + assert_that(chords[0]["to"]).is_equal(Vector2(1.0, 0.0)) + assert_that(chords[1]["from"]).is_equal(Vector2(1.0, 0.0)) + # cell 1's OWN downstream (E) decodes to (1, 1), NOT cell 2's own listed + # position (1, 5) — pinning that this function trusts the D8 DECODE, not + # a by-index lookup into river_cells, exactly per the doc's "the decoded + # target cell is not required to appear in river_cells" contract. + assert_that(chords[1]["to"]).override_failure_message( + "cell 1's downstream target must be its DECODED D8 neighbor (1,1)," + + " never a by-index lookup into river_cells (which would wrongly" + + " give (1,5), cell 2's own listed position)" + ).is_equal(Vector2(1.0, 1.0)) + + +# ============================================================================= +# T-1170 Ruling 5c: the RIVER_CLASS_VISIBLE_BY_RUNG split — +# skeleton_class_visible_at_rung() (Region+ chord-chain path, Ruling 5a) and +# course_class_visible_at_rung() (District/Quarter windowed path, Ruling 5b). +# ============================================================================= + + +func test_skeleton_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" + AtlasWindowGeometryNature.skeleton_class_visible_at_rung( + AtlasWindowGeometryNature.RIVER_CLASS_STREAM, "Region" ) ).is_true() assert_bool( - AtlasWindowGeometry.river_class_visible_at_rung(AtlasWindowGeometry.RIVER_CLASS_TRUNK, "Region") + AtlasWindowGeometryNature.skeleton_class_visible_at_rung( + AtlasWindowGeometryNature.RIVER_CLASS_TRIBUTARY, "Region" + ) + ).is_true() + assert_bool( + AtlasWindowGeometryNature.skeleton_class_visible_at_rung( + AtlasWindowGeometryNature.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() +## T-1170: the skeleton path no longer draws AT ALL at District/Quarter (the +## chord chain is Region-only — District/Quarter draw courses instead, the +## OTHER table below) — this is a CHANGE from wave 1's original District +## "trunk only" disposition on the single RIVER_CLASS_VISIBLE_BY_RUNG table. +func test_skeleton_class_visible_at_rung_district_and_quarter_show_nothing() -> void: + for cls in [ + AtlasWindowGeometryNature.RIVER_CLASS_STREAM, + AtlasWindowGeometryNature.RIVER_CLASS_TRIBUTARY, + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, + ]: + assert_bool( + AtlasWindowGeometryNature.skeleton_class_visible_at_rung(cls, "District") + ).override_failure_message( + "the skeleton (chord-chain) path must show NOTHING at District —" + + " District draws courses instead (Ruling 5b)" + ).is_false() + assert_bool( + AtlasWindowGeometryNature.skeleton_class_visible_at_rung(cls, "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: +func test_skeleton_class_visible_at_rung_unknown_tag_falls_back_to_region() -> void: assert_bool( - AtlasWindowGeometry.river_class_visible_at_rung(AtlasWindowGeometry.RIVER_CLASS_STREAM, "Bogus") + AtlasWindowGeometryNature.skeleton_class_visible_at_rung( + AtlasWindowGeometryNature.RIVER_CLASS_STREAM, "Bogus" + ) ).is_true() +func test_course_class_visible_at_rung_district_shows_trunk_and_tributary_only() -> void: + assert_bool( + AtlasWindowGeometryNature.course_class_visible_at_rung( + AtlasWindowGeometryNature.RIVER_CLASS_STREAM, "District" + ) + ).override_failure_message("District courses must NOT show streams").is_false() + assert_bool( + AtlasWindowGeometryNature.course_class_visible_at_rung( + AtlasWindowGeometryNature.RIVER_CLASS_TRIBUTARY, "District" + ) + ).is_true() + assert_bool( + AtlasWindowGeometryNature.course_class_visible_at_rung( + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, "District" + ) + ).is_true() + + +## The pre-announced wave-1 revisit executing: Quarter shows ALL THREE +## classes on the course path — "Quarter rivers return". +func test_course_class_visible_at_rung_quarter_shows_every_class() -> void: + assert_bool( + AtlasWindowGeometryNature.course_class_visible_at_rung( + AtlasWindowGeometryNature.RIVER_CLASS_STREAM, "Quarter" + ) + ).override_failure_message("Quarter rivers return — streams must be visible").is_true() + assert_bool( + AtlasWindowGeometryNature.course_class_visible_at_rung( + AtlasWindowGeometryNature.RIVER_CLASS_TRIBUTARY, "Quarter" + ) + ).is_true() + assert_bool( + AtlasWindowGeometryNature.course_class_visible_at_rung( + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, "Quarter" + ) + ).is_true() + + +## Region never carries courses (Ruling 1) — the course table has no Region +## key at all, and this reader must fail to EMPTY (not fall back to "show +## everything", the opposite fallback direction from the skeleton reader) so +## a caller can never accidentally draw course polylines at Region. +func test_course_class_visible_at_rung_region_shows_nothing() -> void: + for cls in [ + AtlasWindowGeometryNature.RIVER_CLASS_STREAM, + AtlasWindowGeometryNature.RIVER_CLASS_TRIBUTARY, + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, + ]: + assert_bool( + AtlasWindowGeometryNature.course_class_visible_at_rung(cls, "Region") + ).override_failure_message( + "courses must never be visible at Region — Region draws the skeleton" + + " chord chain, never windowed course content" + ).is_false() + + +func test_course_class_visible_at_rung_unknown_tag_falls_back_to_empty() -> void: + assert_bool( + AtlasWindowGeometryNature.course_class_visible_at_rung( + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, "Bogus" + ) + ).is_false() + + +# ============================================================================= +# T-1170 Ruling 5c: course_class_width_px() / course_class_opacity() — +# functional-default companion tables to COURSE_CLASS_VISIBLE_BY_RUNG. +# ============================================================================= + + +func test_course_class_width_px_trunk_widest_stream_thinnest() -> void: + var stream_w: float = AtlasWindowGeometryNature.course_class_width_px( + AtlasWindowGeometryNature.RIVER_CLASS_STREAM + ) + var tributary_w: float = AtlasWindowGeometryNature.course_class_width_px( + AtlasWindowGeometryNature.RIVER_CLASS_TRIBUTARY + ) + var trunk_w: float = AtlasWindowGeometryNature.course_class_width_px( + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK + ) + assert_float(trunk_w).override_failure_message( + "trunk course width must be the WIDEST of the three classes" + ).is_greater(tributary_w) + assert_float(tributary_w).override_failure_message( + "tributary course width must be strictly between stream and trunk" + ).is_greater(stream_w) + + +func test_course_class_opacity_trunk_most_opaque_stream_least() -> void: + var stream_o: float = AtlasWindowGeometryNature.course_class_opacity( + AtlasWindowGeometryNature.RIVER_CLASS_STREAM + ) + var trunk_o: float = AtlasWindowGeometryNature.course_class_opacity( + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK + ) + assert_float(trunk_o).is_greater(stream_o) + assert_float(trunk_o).override_failure_message("trunk opacity must be fully opaque (1.0)").is_equal_approx( + 1.0, 0.0001 + ) + + +## An unrecognized class id falls back to the stream (thinnest/most transparent) +## defaults on both tables — the documented, deliberate "unknown -> least +## visually assertive" fallback. +func test_course_class_width_and_opacity_unknown_class_falls_back_to_stream() -> void: + var stream_w: float = AtlasWindowGeometryNature.course_class_width_px( + AtlasWindowGeometryNature.RIVER_CLASS_STREAM + ) + var stream_o: float = AtlasWindowGeometryNature.course_class_opacity( + AtlasWindowGeometryNature.RIVER_CLASS_STREAM + ) + assert_float(AtlasWindowGeometryNature.course_class_width_px(99)).is_equal_approx( + stream_w, 0.0001 + ) + assert_float(AtlasWindowGeometryNature.course_class_opacity(99)).is_equal_approx( + stream_o, 0.0001 + ) + + # ============================================================================= # 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() + assert_bool(AtlasWindowGeometryNature.confluences_visible_at_rung("Region")).is_true() + assert_bool(AtlasWindowGeometryNature.confluences_visible_at_rung("District")).is_false() + assert_bool(AtlasWindowGeometryNature.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() + assert_bool(AtlasWindowGeometryNature.mouths_visible_at_rung("Region")).is_true() + assert_bool(AtlasWindowGeometryNature.mouths_visible_at_rung("District")).is_true() + assert_bool(AtlasWindowGeometryNature.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() + assert_bool(AtlasWindowGeometryNature.basins_visible_at_rung("Region")).is_true() + assert_bool(AtlasWindowGeometryNature.basins_visible_at_rung("District")).is_false() + assert_bool(AtlasWindowGeometryNature.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() + assert_bool(AtlasWindowGeometryNature.attractors_visible_at_rung("Region")).is_true() + assert_bool(AtlasWindowGeometryNature.attractors_visible_at_rung("District")).is_false() + assert_bool(AtlasWindowGeometryNature.attractors_visible_at_rung("Quarter")).is_false() # ============================================================================= @@ -234,7 +630,7 @@ func test_attractors_visible_at_rung_region_only() -> void: ## 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) + assert_float(AtlasWindowGeometryNature.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, @@ -246,7 +642,9 @@ func test_zoom_compensated_size_at_zoom_one_is_unchanged() -> void: 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) + var compensated: float = AtlasWindowGeometryNature.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 @@ -277,50 +675,7 @@ func test_uncompensated_radius_at_orbital_zoom_would_be_sub_pixel() -> void: ## 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) + var result: float = AtlasWindowGeometryNature.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() - - -# ============================================================================= -# T-1172 round 2: cell_index_for_local_offset() — the shared painter/clip -# index formula (see its own doc for the "why shared, not duplicated" case). -# ============================================================================= - - -func test_cell_index_for_local_offset_top_left_is_zero_zero() -> void: - var cell: Vector2i = AtlasWindowGeometry.cell_index_for_local_offset(0.0, 0.0, 6400, 64) - assert_that(cell).is_equal(Vector2i(0, 0)) - - -## The exact live-repro numbers from T-1172 round 2's trace: a query whose -## district-space local offset is (2594.09, 5593.15) inside a 6400-wide, -## 64-cell-side window must resolve to (col=25, row=55) — pinned directly -## against the LIVE captured values that closed the investigation (both the -## painter's _build_tile_texture() and the clip independently produced this -## exact pair for the same query in the live trace). -func test_cell_index_for_local_offset_matches_the_live_trace_repro() -> void: - var cell: Vector2i = AtlasWindowGeometry.cell_index_for_local_offset( - 2594.0849609375, 5593.15258789062, 6400, 64 - ) - assert_that(cell).override_failure_message( - "must match the live-captured painter/clip agreement point from the" - + " T-1172 round 2 investigation — (col=25, row=55)" - ).is_equal(Vector2i(25, 55)) - - -func test_cell_index_for_local_offset_bottom_right_boundary_clamps_inside() -> void: - # local offset == n (the exclusive upper boundary) must clamp to the LAST - # cell, not overflow to a nonexistent grid_side'th cell. - var cell: Vector2i = AtlasWindowGeometry.cell_index_for_local_offset(6400.0, 6400.0, 6400, 64) - assert_that(cell).is_equal(Vector2i(63, 63)) - - -func test_cell_index_for_local_offset_zero_n_or_grid_side_returns_sentinel() -> void: - assert_that(AtlasWindowGeometry.cell_index_for_local_offset(10.0, 10.0, 0, 64)).is_equal( - Vector2i(-1, -1) - ) - assert_that(AtlasWindowGeometry.cell_index_for_local_offset(10.0, 10.0, 6400, 0)).is_equal( - Vector2i(-1, -1) - ) diff --git a/client/tests/test_atlas_window_nature_overlay.gd b/client/tests/test_atlas_window_nature_overlay.gd index 90b9e0a03..bc07cfbf5 100644 --- a/client/tests/test_atlas_window_nature_overlay.gd +++ b/client/tests/test_atlas_window_nature_overlay.gd @@ -442,3 +442,73 @@ func test_mouth_position_on_land_is_not_suppressed() -> void: assert_bool(o._is_drawn_water(Vector2(1.9, 1.9), ctx)).override_failure_message( "a mouth position over drawn land must render exactly as today (not clipped)" ).is_false() + + +# ============================================================================= +# T-1170 Ruling 3g/5a: _segment_touches_drawn_water() — the CHORD SEGMENT +# clip rule (both endpoints + midpoint), replacing the old per-point-only +# clip for the skeleton-chord draw path. The water cell is (0,0) in district +# space, per _mock_4x4_water_corner_window()'s own doc — spans roughly +# [-0.5, 0.5) x [-0.5, 0.5) at this fixture's district granularity. +# ============================================================================= + + +## Both endpoints on land, entirely away from the water cell — no clip. +func test_segment_touches_drawn_water_false_when_fully_on_land() -> void: + var stub := _ViewerStub.new() + stub.district_window = _mock_4x4_water_corner_window() + var o = _make_overlay(stub) + var ctx: Dictionary = _ctx_for(stub) + assert_bool( + o._segment_touches_drawn_water(Vector2(1.0, 1.0), Vector2(1.9, 1.9), ctx) + ).override_failure_message( + "a segment entirely on land (both endpoints, and therefore its" + + " midpoint) must not be clipped" + ).is_false() + + +## Either endpoint alone on water clips the whole segment. +func test_segment_touches_drawn_water_true_when_an_endpoint_is_on_water() -> void: + var stub := _ViewerStub.new() + stub.district_window = _mock_4x4_water_corner_window() + var o = _make_overlay(stub) + var ctx: Dictionary = _ctx_for(stub) + assert_bool( + o._segment_touches_drawn_water(Vector2(-1.9, -1.9), Vector2(1.9, 1.9), ctx) + ).override_failure_message( + "a segment with EITHER endpoint over drawn water must be clipped" + ).is_true() + + +## The decision this rule specifically exists to catch (Ruling 3g's ask, "pick +## the visually cleaner rule, document it, test it"): BOTH endpoints on land, +## on opposite sides of the water cell, with the MIDPOINT landing inside it — +## an endpoints-only rule would miss this entirely (a chord visibly crossing +## open water with neither end clipped). The midpoint sample must catch it. +func test_segment_touches_drawn_water_true_when_only_midpoint_is_on_water() -> void: + var stub := _ViewerStub.new() + stub.district_window = _mock_4x4_water_corner_window() + var o = _make_overlay(stub) + var ctx: Dictionary = _ctx_for(stub) + # The water cell is (col=0, row=0), spanning district [-2,-1) x [-2,-1) in + # this n=4/grid_side=4 fixture (1:1 district-to-cell mapping). Pick + # endpoints that EACH resolve to a DIFFERENT LAND cell adjacent to the + # water corner — (-1.99, -0.9) resolves to (col=0, row=1), land; (-0.9, + # -1.99) resolves to (col=1, row=0), land — but their MIDPOINT + # (-1.445, -1.445) falls squarely inside the water cell (col=0, row=0). + # Verified numerically, not eyeballed (see the two sanity asserts below). + var from_district := Vector2(-1.99, -0.9) + var to_district := Vector2(-0.9, -1.99) + # Sanity: neither endpoint alone is clipped (both resolve to LAND cells) + # — isolates the midpoint as the ONLY reason the segment clips below. + assert_bool(o._is_drawn_water(from_district, ctx)).override_failure_message( + "test setup invariant: the FROM endpoint alone must resolve to land" + ).is_false() + assert_bool(o._is_drawn_water(to_district, ctx)).override_failure_message( + "test setup invariant: the TO endpoint alone must resolve to land" + ).is_false() + assert_bool(o._segment_touches_drawn_water(from_district, to_district, ctx)).override_failure_message( + "a segment whose ENDPOINTS are both on land but whose MIDPOINT lands" + + " on drawn water must still be clipped — this is the exact failure" + + " mode an endpoints-only rule would miss (Ruling 3g's ask)" + ).is_true() diff --git a/client/ui/implant/apps/atlas/atlas_window_geometry.gd b/client/ui/implant/apps/atlas/atlas_window_geometry.gd index e6f1657fe..6f00d33b1 100644 --- a/client/ui/implant/apps/atlas/atlas_window_geometry.gd +++ b/client/ui/implant/apps/atlas/atlas_window_geometry.gd @@ -22,6 +22,15 @@ extends RefCounted ## the SAME wrap/clamp discipline every other piece of this cluster already ## depends on, hence the preload below (no circular dependency: ## atlas_descend_geometry.gd never references this file). +## +## T-1170: the T-1156 wave-1 nature-overlay (river/basin/attractor) pixel +## mapping and per-rung visibility policy (RIVER_CLASS_*, layer1_pixel_to_*, +## *_visible_at_rung, zoom_compensated_size) moved OUT of this file to +## atlas_window_geometry_nature.gd (this file was at 954/1000 gdlint +## max-file-lines when the move happened) — see that file's own header doc. +## cell_index_for_local_offset() (T-1172, near the bottom of this file) stayed +## here since it is shared with AtlasWindowOverlay's terrain painter, a +## non-nature consumer. const AtlasDescendGeometryRef := preload("res://ui/implant/apps/atlas/atlas_descend_geometry.gd") ## D-243 rung spacings, metres/cell — the SAME constants @@ -108,93 +117,6 @@ 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 @@ -775,155 +697,6 @@ static func centered_label_baseline(viewport_size: Vector2, text_size: Vector2) 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) - - ## T-1172 round 2 (coordinator's "reconsider the split" ask): the SHARED ## cell-index formula both AtlasWindowOverlay's terrain painter (which builds ## the drawn `grid_side x grid_side` per-cell texture — `i = row * grid_side diff --git a/client/ui/implant/apps/atlas/atlas_window_geometry_nature.gd b/client/ui/implant/apps/atlas/atlas_window_geometry_nature.gd new file mode 100644 index 000000000..c1c4f7fe4 --- /dev/null +++ b/client/ui/implant/apps/atlas/atlas_window_geometry_nature.gd @@ -0,0 +1,505 @@ +extends RefCounted + +## Nature-overlay (river/basin/attractor) pure geometry + per-rung policy — +## split out of atlas_window_geometry.gd (T-1170, that file was at 954/1000 +## gdlint max-file-lines when this batch started) exactly the same way +## test_atlas_window_geometry_nature.gd was already split from +## test_atlas_window_geometry.gd — one file, one concern, room to grow. Every +## symbol below moved VERBATIM from atlas_window_geometry.gd; no behavior +## change in this split itself. atlas_window_nature_overlay.gd is the only +## runtime consumer (verified: grep across client/ before the move) and now +## preloads THIS file instead. +## +## Contains: +## - T-1156 wave 1 whole-body Layer-1 pixel-space -> canvas-local mapping +## (layer1_pixel_to_world_m/world_m_to_district/layer1_pixel_to_canvas_local) +## - T-1156 wave 1 per-rung skeleton visibility/styling policy (RIVER_CLASS_*, +## CONFLUENCES/MOUTHS/BASINS/ATTRACTORS_VISIBLE_BY_RUNG, dot/ring/attractor +## size consts) — RENAMED this batch (T-1170 Ruling 5c, see below) from +## RIVER_CLASS_VISIBLE_BY_RUNG to SKELETON_CLASS_VISIBLE_BY_RUNG. +## - zoom_compensated_size() — the screen-space marker-size zoom-compensation +## fix (coordinator live-eyeball finding, 2026-07-23). +## +## atlas_window_geometry.gd retains cell_index_for_local_offset() (T-1172 +## round 2) rather than moving it here — that function is shared with +## AtlasWindowOverlay's terrain painter (a non-nature consumer), so it stays +## on the base file both files already depend on, avoiding a nature-file -> +## base-file dependency for a symbol the base file's own painter needs too. +const AtlasDescendGeometryRef := preload("res://ui/implant/apps/atlas/atlas_descend_geometry.gd") + +## atlas_window_geometry.gd never depends on this file (verified: no preload +## of atlas_window_geometry_nature.gd anywhere in that file) — so preloading +## it back here is safe, no circular dependency, matching the pattern +## AtlasWindowOverlay/AtlasWindowWaterClip already use for +## AtlasWindowGeometryRef. +const AtlasWindowGeometryRef := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd") + +## D-243 district spacing, metres/district — this file's own copy of +## AtlasWindowGeometry.DISTRICT_SPACING_M (duplicated, not preloaded-and-read, +## matching this cluster's existing "each file owns its own reading of a +## small pure constant rather than force a dependency" precedent — +## atlas_overlay_colors.gd's header doc states this explicitly; the same +## rationale that kept atlas_window_water_clip.gd's cell_grid_side_for_window() +## a deliberate duplicate rather than a shared call applies here). MUST stay +## numerically identical to the base file's constant — both ultimately trace +## to D-243's 2,048 m district spacing, which is locked project vocabulary, +## not a value expected to drift. +const DISTRICT_SPACING_M: float = 2048.0 + + +# ============================================================================= +# T-1156 wave 1 / T-1170: 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). +# +# T-1170 Ruling 5c (Tyre, 2026-07-23) — THE REVISIT, split on the carrier +# axis: the single RIVER_CLASS_VISIBLE_BY_RUNG table is replaced by TWO +# tables, one per presentation surface — +# - SKELETON_CLASS_VISIBLE_BY_RUNG: the Region+ whole-body skeleton-chord +# path (Ruling 5a) — unchanged posture from wave 1, Region shows every +# class. +# - COURSE_CLASS_VISIBLE_BY_RUNG: the District/Quarter windowed course- +# polyline path (Ruling 5b) — THIS is where "Quarter rivers return" +# (the pre-announced wave-1 fade-down revisit executes): District shows +# trunk+tributary, Quarter shows all three classes. +# Companion per-class width/opacity tables (COURSE_CLASS_WIDTH_PX/ +# COURSE_CLASS_OPACITY) carry FUNCTIONAL DEFAULTS per the ruling brief +# (trunk widest ~2.2px, tributary ~1.4px, stream ~0.9px, screen-space via the +# existing zoom-compensation discipline) — Araminta's forthcoming presentation +# ruling edits THESE TABLES AND ONLY THESE TABLES, same single-revisit-point +# discipline wave 1 established for RIVER_CLASS_VISIBLE_BY_RUNG itself. +# ============================================================================= + +## 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 + + +# ============================================================================= +# T-1170 Ruling 2a-2d/5a: river_downstream D8 pointer decode — the wire +# convention `RiverNetwork.river_downstream` (Vec, index-aligned with +# river_cells) encodes per river cell: a DIRECTION 0-7 into an adjacent D8 +# neighbor, or a SENTINEL >= RIVER_DOWNSTREAM_SENTINEL_BASE marking a chain +# end (MOUTH/EDGE_DRAIN/reserved-TERMINAL). CONFIRMED against Dudley's A1 +# (server/src/atlas/drainage.rs:35-44, landed 0fea69feb, relayed by the +# coordinator) — these are the REAL shipped values, final until/unless the +# server's own encoding changes, in which case this is the one place to +# repoint. +# ============================================================================= + +## Sentinel base — any river_downstream value >= this is a chain-end +## sentinel, not a direction. Direction values are 0-7 (8 real D8 neighbors); +## sentinels start immediately above at 8. +const RIVER_DOWNSTREAM_SENTINEL_BASE: int = 8 +const RIVER_DOWNSTREAM_MOUTH: int = 8 +const RIVER_DOWNSTREAM_EDGE_DRAIN: int = 9 +## TERMINAL is reserved/unused in round 1 (Ruling 2c/7b — future endorheic +## basin support) — this client never expects to see it on real data yet, but +## decodes it identically to EDGE_DRAIN (chain end, no ring) rather than +## treating an unrecognized-but-in-sentinel-range value as an error, so a +## future server enabling TERMINAL needs no client change to degrade +## gracefully (it would just draw as an unmarked chain end until a future +## ticket gives it its own ring treatment, exactly EDGE_DRAIN's own current +## disposition). +const RIVER_DOWNSTREAM_TERMINAL: int = 10 + +## D8 direction index (0-7) -> (row_delta, col_delta), CONFIRMED against +## drainage.rs:35-44's own fdir table order (not assumed/guessed — the +## coordinator relayed this explicitly from Dudley's A1 source): row +## increases SOUTH (matching layer1_pixel_to_world_m()'s own "row 0 = north +## pole" convention, confirmed the same convention on both sides of this +## mapping), col increases EAST and WRAPS at the antimeridian (handled by the +## caller's existing nearest-wrap-image discipline, same as every other +## column value flowing through this file — this table itself has no wrap +## concept, it is pure grid-adjacency). +## 0 = N (-1, 0) 4 = NE (-1, 1) +## 1 = S ( 1, 0) 5 = NW (-1, -1) +## 2 = E ( 0, 1) 6 = SE ( 1, 1) +## 3 = W ( 0, -1) 7 = SW ( 1, -1) +const D8_DIRECTION_DELTAS: Array = [ + Vector2i(-1, 0), # 0 N + Vector2i(1, 0), # 1 S + Vector2i(0, 1), # 2 E + Vector2i(0, -1), # 3 W + Vector2i(-1, 1), # 4 NE + Vector2i(-1, -1), # 5 NW + Vector2i(1, 1), # 6 SE + Vector2i(1, -1), # 7 SW +] + +## Region+ SKELETON path (Ruling 5a) — the whole-body chord-chain draw, built +## from river_downstream. Region shows every class (the full skeleton) — this +## table's posture is UNCHANGED from wave 1's original +## RIVER_CLASS_VISIBLE_BY_RUNG (renamed, not re-tuned). District/Quarter keys +## are retained (both empty) purely so a caller that queries this table by an +## unexpected rung tag gets the same documented "nothing visible" answer wave +## 1 shipped, rather than a KeyError — the SKELETON path itself is only ever +## drawn at Region in practice (District/Quarter draw courses, the OTHER +## table, per Ruling 5b). +const SKELETON_CLASS_VISIBLE_BY_RUNG: Dictionary = { + "Region": [RIVER_CLASS_STREAM, RIVER_CLASS_TRIBUTARY, RIVER_CLASS_TRUNK], + "District": [], + "Quarter": [], +} + +## District/Quarter COURSE path (Ruling 5b/5c) — the windowed polyline draw, +## built from DistrictWindowLayer.courses. District: trunk+tributary (streams +## stay off at District — the ruling's own example enumeration). Quarter: +## ALL THREE classes — "Quarter rivers return", the pre-announced wave-1 +## fade-down revisit executing here. Region is not a key here at all (Region +## never draws courses — it draws the skeleton chord chain, the OTHER table) +## — a caller must not query this table at Region; river_class_visible_at_rung() +## style readers for this table live on this file too and fall back safely +## for an unrecognized tag (see course_class_visible_at_rung()'s own doc). +const COURSE_CLASS_VISIBLE_BY_RUNG: Dictionary = { + "District": [RIVER_CLASS_TRIBUTARY, RIVER_CLASS_TRUNK], + "Quarter": [RIVER_CLASS_STREAM, RIVER_CLASS_TRIBUTARY, RIVER_CLASS_TRUNK], +} + +## 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). +## +## T-1170: these RIVER_DOT_* consts now describe the Region SKELETON path +## ONLY (Ruling 5a's chord-chain draw reuses the same per-class radii the old +## dot-scatter used — chords are drawn at these widths, not a new table). +## RIVER_DOT_RADIUS_DISTRICT_TRUNK/RIVER_DOT_OPACITY_DISTRICT_TRUNK are DEAD +## at District now that District draws courses (Ruling 5b/3g retires the +## District dot-scatter entirely) — left in place, unread by any T-1170 draw +## path, rather than deleted mid-batch: B3 (course polyline drawing) is the +## change that stops calling them; deleting here would be a premature edit to +## a still-referenced-by-wave-1-code constant ahead of that landing. +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 + +## T-1170 Ruling 5c: course polyline per-class width/opacity, District/Quarter +## COURSE path companion tables to COURSE_CLASS_VISIBLE_BY_RUNG above. +## FUNCTIONAL DEFAULTS ONLY (the ruling's own numbers) — Araminta's +## forthcoming presentation ruling edits these two tables and only these two +## tables, same discipline as every other single-revisit-point table in this +## file. Widths are screen-space px at zoom=1.0, routed through +## zoom_compensated_size()/the caller's `_zs()` wrapper before reaching +## draw_polyline() exactly like every other marker size in this cluster (PR +## #195's stroke-width miss is the standing regression class this discipline +## exists to prevent — see zoom_compensated_size()'s own doc). Opacities are +## plain [0,1] alpha multipliers on COLOR_GEN_RIVER, no zoom involvement. +## Trunk widest / stream thinnest, matching the Region skeleton's own +## per-class radius ordering (RIVER_DOT_RADIUS_BY_CLASS_REGION) so the visual +## "trunk is the biggest river" read is consistent whether the player is +## looking at the Region chord chain or a District/Quarter course polyline. +const COURSE_CLASS_WIDTH_PX: Dictionary = { + RIVER_CLASS_STREAM: 0.9, + RIVER_CLASS_TRIBUTARY: 1.4, + RIVER_CLASS_TRUNK: 2.2, +} +const COURSE_CLASS_OPACITY: Dictionary = { + RIVER_CLASS_STREAM: 0.8, + RIVER_CLASS_TRIBUTARY: 0.9, + RIVER_CLASS_TRUNK: 1.0, +} + +## 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. T-1170: also +## the mouth-ring geometry for REAL course termini (Ruling 5b/3e) — one +## geometry, both presentation surfaces (skeleton chord ends at Region, +## course polyline ends at District/Quarter). +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 + + +# ============================================================================= +# 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 in atlas_window_geometry.gd. 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 AtlasWindowGeometry.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 — see that const's +## own doc for why it's a deliberate duplicate, not a preload-and-read). +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 AtlasWindowGeometry.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 (AtlasWindowGeometry.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 AtlasWindowGeometryRef.district_to_canvas_local(district, held_center, held_n, cell_pixel_size) + + +# ============================================================================= +# T-1170 Ruling 2a-2d/5a: river_downstream D8 pointer decode. +# ============================================================================= + + +## Decode one river cell's `river_downstream` wire value into its downstream +## neighbor's (row, col) heightmap-pixel position, or `null` if the value is +## a chain-end sentinel (MOUTH/EDGE_DRAIN/TERMINAL) or an out-of-range/ +## malformed direction. `row`/`col` are the UPSTREAM cell's own pixel +## position (float, matching this file's own row/col domain everywhere +## else); the return value (when non-null) is a Vector2 in that SAME +## (row, col) pixel domain — NOT yet converted to world metres/district/ +## canvas-local, that conversion is the caller's job via the usual +## layer1_pixel_to_world_m()/world_m_to_district() pipeline, exactly as if +## the target were itself an entry read out of `river_cells`. +## +## Deliberately returns the RAW grid-adjacent position rather than looking it +## up in a `river_cells` array — a D8 downstream pointer always names a real +## adjacent grid cell by construction (that is what D8 flow direction means), +## whether or not that specific cell independently appears in whatever +## (possibly filtered) `river_cells` list the caller is iterating. +static func d8_downstream_target(row: float, col: float, downstream_raw: int) -> Variant: + if downstream_raw < 0 or downstream_raw >= RIVER_DOWNSTREAM_SENTINEL_BASE: + return null # sentinel or malformed — no real direction to decode + var delta: Vector2i = D8_DIRECTION_DELTAS[downstream_raw] + return Vector2(row + float(delta.x), col + float(delta.y)) + + +## T-1170 Ruling 5a — pure chord-chain CONSTRUCTION (no draw calls, no water +## clip, no canvas-local conversion): given `river_cells`/`river_class`/ +## `river_downstream` (the raw decoded river_network sub-dict arrays) and a +## `granularity_v2` rung tag, returns an Array of +## `{"from": Vector2, "to": Vector2, "cls": int}` dicts — one per river cell +## whose class is visible at this rung AND whose river_downstream pointer +## resolves to a real direction (not a sentinel, not out of range, not +## missing). `from`/`to` are in the SAME (row, col) heightmap-pixel domain +## `river_cells` entries themselves use — the caller converts to world +## metres/district/canvas-local and applies the water clip, exactly as if it +## had built this list inline (this function exists so that CONSTRUCTION is +## unit-testable without a live render pass — draw_line() itself requires +## one, per this cluster's own "pure function tests are the gate" draw-smoke +## caveat, so the chain-walking logic that actually decides WHICH segments +## exist must not be entangled with the draw call that paints them). +## +## Split out of AtlasWindowNatureOverlay._draw_skeleton_chords() specifically +## so a test can assert "this exact set of segments was constructed from +## this exact fixture" (including the sentinel-chain-end and malformed-input +## cases) without a SubViewport/render context — matching this file's +## existing "geometry/construction here, draw calls in the overlay node" +## split for every other piece of this cluster. +static func build_skeleton_chords( + river_cells: Array, river_class: Array, river_downstream: Array, granularity_v2: String +) -> Array: + var chords: Array = [] + 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 RIVER_CLASS_FALLBACK + if not skeleton_class_visible_at_rung(cls, granularity_v2): + continue + if idx >= river_downstream.size(): + continue # no downstream pointer for this cell yet — no segment + var downstream_raw: int = int(river_downstream[idx]) + var row: float = float(c[0]) + var col: float = float(c[1]) + var target: Variant = d8_downstream_target(row, col, downstream_raw) + if target == null: + continue # sentinel (MOUTH/EDGE_DRAIN/TERMINAL) or malformed direction — chain end + chords.append({"from": Vector2(row, col), "to": target, "cls": cls}) + return chords + + +# ============================================================================= +# T-1156 wave 1 / T-1170: per-rung nature-overlay visibility policy READERS. +# The policy TABLES themselves live up in the top-of-file const block per +# class-definitions-order. +# ============================================================================= + + +## Whether a river cell of `river_class` should draw on the Region+ SKELETON +## path (Ruling 5a) 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 skeleton_class_visible_at_rung(river_class: int, granularity_v2: String) -> bool: + var visible: Array = SKELETON_CLASS_VISIBLE_BY_RUNG.get( + granularity_v2, SKELETON_CLASS_VISIBLE_BY_RUNG["Region"] + ) + return visible.has(river_class) + + +## Whether a river class should draw on the District/Quarter COURSE path +## (Ruling 5b) at `granularity_v2`. No Region key exists in +## COURSE_CLASS_VISIBLE_BY_RUNG (Region never draws courses) — an unrecognized +## OR Region tag both fall back to an EMPTY array (nothing visible), the +## inverse fallback posture from skeleton_class_visible_at_rung() above, +## deliberately: falling back to "show everything" for a course-path query at +## an unexpected rung would risk drawing course polylines at Region, which no +## window response ever carries (courses are windowed-only content, Ruling 1) +## — failing to EMPTY is the safe direction on this specific table. +static func course_class_visible_at_rung(river_class: int, granularity_v2: String) -> bool: + var visible: Array = COURSE_CLASS_VISIBLE_BY_RUNG.get(granularity_v2, []) + 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)) + + +## Per-class course polyline width (screen-space px, zoom=1.0 domain — see the +## const's own doc). Falls back to the stream (thinnest) width for an +## unrecognized class id, matching RIVER_DOT_RADIUS_BY_CLASS_REGION's own +## `.get(cls, 2.2)` call-site fallback shape on the skeleton side (there the +## fallback is trunk/widest — the caller passes a literal default; here the +## table itself owns a documented fallback since this is a NAMED reader, not +## an inline `.get()`). +static func course_class_width_px(river_class: int) -> float: + return float(COURSE_CLASS_WIDTH_PX.get(river_class, COURSE_CLASS_WIDTH_PX[RIVER_CLASS_STREAM])) + + +## Per-class course polyline opacity multiplier on COLOR_GEN_RIVER. Same +## unrecognized-class fallback posture as course_class_width_px() above. +static func course_class_opacity(river_class: int) -> float: + return float(COURSE_CLASS_OPACITY.get(river_class, COURSE_CLASS_OPACITY[RIVER_CLASS_STREAM])) + + +## 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, T-1170 course polylines/chords) 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) diff --git a/client/ui/implant/apps/atlas/atlas_window_nature_overlay.gd b/client/ui/implant/apps/atlas/atlas_window_nature_overlay.gd index aba4ee391..2cf8a90b2 100644 --- a/client/ui/implant/apps/atlas/atlas_window_nature_overlay.gd +++ b/client/ui/implant/apps/atlas/atlas_window_nature_overlay.gd @@ -6,16 +6,25 @@ extends Node2D ## and below UI chrome — same parent, same pan/zoom transform, drawn after so ## river dots/basin fills sit on top of the terrain colorizer. ## +## T-1170 (Ruling 5a): the Region+ river dot-scatter upgraded to CONNECTED +## STRAIGHT CHORDS via river_downstream (_draw_skeleton_chords()) — per +## Ruling 3b this chord chain IS the rung-truncated course at Region +## truncation, not an approximation of it. District/Quarter no longer draw +## the (now-retired) dot-scatter at all; they draw windowed course polylines +## instead (Ruling 5b, _draw_courses(), landing with B3 once the server's +## course inventor — A2/A3 — ships). +## ## 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 +## The drawing IDEAS survive (polygon basins, glyph-free double-ring mouths, +## draw order basins-under-rivers-under-attractors; the dot-scatter idea +## itself is superseded at Region by T-1170's chord chain, see above); 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 +## (AtlasWindowGeometryNature.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. ## @@ -59,6 +68,9 @@ extends Node2D ## this node never needs a _process() self-heal. const AtlasWindowGeometry := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd") +# T-1170: the nature-overlay pixel-mapping + per-rung visibility policy split +# out of atlas_window_geometry.gd — see that file's own doc. +const AtlasWindowGeometryNature := preload("res://ui/implant/apps/atlas/atlas_window_geometry_nature.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") # T-1172: two-waterline clip — see that file's own header doc. @@ -176,17 +188,17 @@ func _draw() -> void: # 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. + # AtlasWindowGeometryNature.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( + if AtlasWindowGeometryNature.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( + if AtlasWindowGeometryNature.attractors_visible_at_rung(granularity_v2) and viewer.is_overlay_visible( "gen_attractors" ): _draw_attractors(ctx) @@ -204,12 +216,12 @@ func _cols_for_wrap(radius_km: float) -> int: ## 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()/ +## AtlasWindowGeometryNature.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"]) + return AtlasWindowGeometryNature.zoom_compensated_size(screen_space_size, ctx["view_zoom"]) ## Pixel (row, col) -> fractional district position, wrap-resolved against @@ -225,10 +237,10 @@ func _zs(screen_space_size: float, ctx: Dictionary) -> float: ## periodic — any wrap-image of the same district resolves to the same ## real-world position). func _district(row: float, col: float, ctx: Dictionary) -> Vector2: - var world_m: Vector2 = AtlasWindowGeometry.layer1_pixel_to_world_m( + var world_m: Vector2 = AtlasWindowGeometryNature.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 district: Vector2 = AtlasWindowGeometryNature.world_m_to_district(world_m) var cols: int = ctx["cols"] if cols > 0: var held_center: Vector2i = ctx["held_center"] @@ -285,50 +297,30 @@ func _is_drawn_water(district: Vector2, ctx: Dictionary) -> bool: return AtlasOverlayColors.is_morphology_water(zone) -## T-1172 clip — retire when T-1170 course invention terminates courses at -## the invented coast. River cells, confluences, and mouths are each dropped -## (strict, no snap) when their resolved composite cell reads as drawn water -## — see AtlasWindowWaterClip's own header doc for the two-waterline -## rationale. Basins are explicitly OUT OF SCOPE (Tyre's rule 4) — untouched. +## T-1172 clip — RETAINED for this Region-skeleton path only (Ruling 3g: the +## clip retires for the District/Quarter COURSE-drawing rungs — see +## _draw_courses() below — because courses carry real rung-consistent +## termini and the clip's job is done there; the Region skeleton path keeps +## drawing against a rung-dependent drawn coast and needs the presentation- +## frame reconciliation until Region itself goes windowed, T-1143 ruling 2). +## River cells, confluences, and mouths are each dropped (strict, no snap) +## when their resolved composite cell reads as drawn water — see +## AtlasWindowWaterClip's own header doc for the two-waterline rationale. +## Basins are explicitly OUT OF SCOPE (Tyre's rule 4) — untouched. +## +## T-1170 Ruling 5a: at Region+, river cells draw as CONNECTED STRAIGHT +## CHORDS (each river cell to its river_downstream neighbor) instead of a +## dot-scatter — see _draw_skeleton_chords() below, called from here. +## District/Quarter no longer reach this function's river-cell/confluence +## loop at all (SKELETON_CLASS_VISIBLE_BY_RUNG has empty District/Quarter +## entries) — they draw via _draw_courses() instead (Ruling 5b), wired from +## _draw(). 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 district: Vector2 = _district(float(c[0]), float(c[1]), ctx) - # T-1172 clip — retire when T-1170 course invention terminates at the invented coast. - if _is_drawn_water(district, ctx): - continue - var p: Vector2 = AtlasWindowGeometry.district_to_canvas_local( - district, ctx["held_center"], ctx["held_n"], ctx["cell_px"] - ) - 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) + _draw_skeleton_chords(rn, ctx) - if AtlasWindowGeometry.confluences_visible_at_rung(granularity_v2): + if AtlasWindowGeometryNature.confluences_visible_at_rung(granularity_v2): for cf: Variant in rn.get("confluences", []): if cf is Array and cf.size() >= 2: var district: Vector2 = _district(float(cf[0]), float(cf[1]), ctx) @@ -337,10 +329,10 @@ func _draw_rivers(rn: Dictionary, ctx: Dictionary) -> void: var p: Vector2 = AtlasWindowGeometry.district_to_canvas_local( district, ctx["held_center"], ctx["held_n"], ctx["cell_px"] ) - var radius: float = _zs(AtlasWindowGeometry.RIVER_CONFLUENCE_RADIUS_REGION, ctx) + var radius: float = _zs(AtlasWindowGeometryNature.RIVER_CONFLUENCE_RADIUS_REGION, ctx) draw_circle(p, radius, COLOR_GEN_RIVER) - if AtlasWindowGeometry.mouths_visible_at_rung(granularity_v2): + if AtlasWindowGeometryNature.mouths_visible_at_rung(granularity_v2): for m: Variant in rn.get("mouths", []): if m is Array and m.size() >= 2: var district: Vector2 = _district(float(m[0]), float(m[1]), ctx) @@ -357,18 +349,153 @@ func _draw_rivers(rn: Dictionary, ctx: Dictionary) -> void: _draw_mouth(p, ctx) +## T-1170 Ruling 5a — the Region+ skeleton-chord draw: each river cell whose +## class is visible at this rung draws a STRAIGHT LINE SEGMENT to its +## `river_downstream` neighbor. `river_network.river_downstream` is a u8 PER +## RIVER CELL (index-aligned with river_cells, the SAME alignment convention +## river_class already uses) encoding a **D8 DIRECTION** (0-7, see +## AtlasWindowGeometryNature.D8_DIRECTION_DELTAS — NOT a river_cells index; +## the target cell's grid position is `c + delta`, decoded via +## AtlasWindowGeometryNature.d8_downstream_target()), with SENTINEL values +## `>= RIVER_DOWNSTREAM_SENTINEL_BASE` for MOUTH/EDGE_DRAIN/reserved-TERMINAL +## (Ruling 2c). Direction-index convention and sentinel values CONFIRMED +## against Dudley's A1 (server/src/atlas/drainage.rs:35-44, landed +## 0fea69feb; relayed by the coordinator, not guessed) — MOUTH=8, +## EDGE_DRAIN=9, TERMINAL=10 (reserved/unused), directions 0-7 = N/S/E/W/NE/ +## NW/SE/SW. Every place this convention is encoded is a SINGLE named +## constant group on AtlasWindowGeometryNature (D8_DIRECTION_DELTAS / +## RIVER_DOWNSTREAM_SENTINEL_BASE / RIVER_DOWNSTREAM_MOUTH / +## RIVER_DOWNSTREAM_EDGE_DRAIN / RIVER_DOWNSTREAM_TERMINAL) — see that file's +## own doc. +## +## Per Ruling 3b, these chords ARE the rung-truncated course at Region (no +## octave warp survives at Region spacing — the invented course degenerates +## exactly to this chord), NOT an approximation of it — one function (the +## server's course inventor, eventually), every rung, this is simply what it +## looks like with zero surviving octaves. +## +## Sentinel dispositions: MOUTH and EDGE_DRAIN both END the chain — no +## downstream segment is drawn for a sentinel-terminated cell (there is no +## real neighbor cell to connect to). EDGE_DRAIN gets NO mouth ring (Ruling +## 3f — pole-edge drains are grid artifacts, not river-meets-sea events; the +## existing mouths array/_draw_mouth() call in _draw_rivers() is already +## scoped to real MOUTH sentinels via rn["mouths"], server-side, per Ruling +## 3f's "extract_river_network stops classifying grid-edge exits into +## mouths" — this function draws NO ring at all, sentinel or otherwise, that +## is _draw_rivers()'s mouths-array job). +## +## `river_downstream` missing or shorter than `river_cells` (pre-T-1170 +## payload — Dudley's `#[serde(default)]` empty-Vec contract, the exact same +## graceful-decode shape river_class already established) means NO chord +## segment can be drawn for that index at all (there is no real downstream +## direction to connect to, unlike the class-fallback case where TRUNK is a +## safe visual default) — those cells draw NOTHING at Region until the field +## arrives, a graceful (not crashing) degradation, documented here rather +## than silently falling back to the old dot-scatter (which would require +## carrying that whole second code path forward past this ticket). The +## decoded target cell is ALSO not required to appear in `river_cells` itself +## (the chord draws to the raw grid position `c + delta`, not to a looked-up +## river-cell entry) — a downstream D8 pointer always names a real adjacent +## grid cell by construction, whether or not that cell independently made it +## into the (possibly rung/threshold-filtered) `river_cells` list. +## +## The actual chain-CONSTRUCTION (which segments exist at all, given the +## fixture and rung) is delegated to +## AtlasWindowGeometryNature.build_skeleton_chords() — a pure function with +## no draw calls, unit-tested directly in +## test_atlas_window_geometry_nature.gd (the sentinel/malformed/visibility +## cases). This function's own job is the remaining per-segment work that DOES +## need the overlay's own state: the water clip (_segment_touches_drawn_water(), +## needs the composite/tile data only the overlay holds) and the actual +## draw_line() call (needs a live render pass). +func _draw_skeleton_chords(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 river_downstream: Array = rn.get("river_downstream", []) + + var chords: Array = AtlasWindowGeometryNature.build_skeleton_chords( + river_cells, river_class, river_downstream, granularity_v2 + ) + for chord: Dictionary in chords: + var from_rc: Vector2 = chord["from"] + var to_rc: Vector2 = chord["to"] + var cls: int = chord["cls"] + + var from_district: Vector2 = _district(from_rc.x, from_rc.y, ctx) + var to_district: Vector2 = _district(to_rc.x, to_rc.y, ctx) + # T-1172 clip (Region-only, retained per Ruling 3g): a segment is + # clipped when EITHER endpoint OR its midpoint resolves to drawn + # water — see _segment_touches_drawn_water()'s own doc for why this + # three-point rule was chosen over an endpoints-only test. + if _segment_touches_drawn_water(from_district, to_district, ctx): + continue + var from_p: Vector2 = AtlasWindowGeometry.district_to_canvas_local( + from_district, ctx["held_center"], ctx["held_n"], ctx["cell_px"] + ) + var to_p: Vector2 = AtlasWindowGeometry.district_to_canvas_local( + to_district, ctx["held_center"], ctx["held_n"], ctx["cell_px"] + ) + var width: float = AtlasWindowGeometryNature.RIVER_DOT_RADIUS_BY_CLASS_REGION.get(cls, 2.2) + draw_line(from_p, to_p, COLOR_GEN_RIVER, _zs(width, ctx)) + + +## T-1172 clip rule for a CHORD SEGMENT (as opposed to a single point, which +## is what the pre-T-1170 dot-scatter clipped): tested at the segment's TWO +## ENDPOINTS AND its MIDPOINT, clipping the whole segment if ANY of those +## three samples resolves to drawn water. **Decision, documented per the +## ruling's ask ("pick the visually cleaner rule, document it, test it"):** +## endpoints-only was rejected because a chord that DIPS through a coastal +## composite cell without either endpoint landing in it (a river cell just +## inland connecting to a river cell just inland on the OTHER side of a +## narrow drawn-water inlet/bay) would draw a visible line segment crossing +## open water with neither end clipped — worse than the old dot-scatter's +## per-point clip, which never had this failure mode since a dot has no +## extent to cross anything. Midpoint-only was rejected symmetrically: a +## long chord whose midpoint happens to land on drawn land while both real +## endpoints sit in drawn water would draw an uncllipped segment starting and +## ending in the ocean. Three-point (both ends + midpoint) catches the +## common cases of both failure modes at negligible extra cost (one more +## _is_drawn_water() lookup per segment) without requiring a full +## segment-rasterization walk — chords at Region spacing (~76 km apart) are +## short enough relative to Region's own 204.8 km composite cell that a +## single midpoint sample is a reasonable proxy for "does this segment pass +## through this cell", matching the coarseness the Region rung already draws +## at everywhere else in this file (204.8 km cells, not sub-cell precision). +func _segment_touches_drawn_water(from_district: Vector2, to_district: Vector2, ctx: Dictionary) -> bool: + if _is_drawn_water(from_district, ctx): + return true + if _is_drawn_water(to_district, ctx): + return true + var mid_district: Vector2 = (from_district + to_district) * 0.5 + return _is_drawn_water(mid_district, 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). +## every rung it's visible at (Region, District; never Quarter). T-1170: +## also the marker for REAL course termini (Ruling 5b/3e) — one geometry +## function, both presentation surfaces. 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) + p, + _zs(AtlasWindowGeometryNature.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 + COLOR_GEN_MOUTH.r, + COLOR_GEN_MOUTH.g, + COLOR_GEN_MOUTH.b, + AtlasWindowGeometryNature.MOUTH_HALO_ALPHA + ) + draw_arc( + p, _zs(AtlasWindowGeometryNature.MOUTH_HALO_RADIUS, ctx), 0.0, TAU, 22, halo, _zs(1.0, ctx) ) - 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 + @@ -410,7 +537,7 @@ func _draw_attractors(ctx: Dictionary) -> void: if not a is Dictionary: continue var strength: float = float(a.get("strength", 0.0)) - if strength < AtlasWindowGeometry.ATTRACTOR_MIN_STRENGTH: + if strength < AtlasWindowGeometryNature.ATTRACTOR_MIN_STRENGTH: continue var pos_rc: Variant = a.get("position") if not pos_rc is Array or pos_rc.size() < 2: