Merge remote-tracking branch 'origin/river-courses'
This commit is contained in:
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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,675 @@ 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 5b/3h: build_course_render_plan() — pure course-polyline
|
||||
# CONSTRUCTION (no draw calls), the course-path counterpart to B2's
|
||||
# build_skeleton_chords(). Synthetic fixtures shaped per Ruling 3h's wire
|
||||
# shape: {class: u8, points: Vec<(i32,i32)> world-metres, terminus: string}
|
||||
# — built BEFORE Dudley's A2 (course inventor) lands, per the ticket brief's
|
||||
# explicit instruction.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
static func _course_fixture(
|
||||
cls: int, points: Array, terminus: String = "None"
|
||||
) -> Dictionary:
|
||||
return {"edge_id": 1, "class": cls, "points": points, "terminus": terminus}
|
||||
|
||||
|
||||
func test_build_course_render_plan_district_trunk_is_visible_and_constructs_points() -> void:
|
||||
var course: Dictionary = _course_fixture(
|
||||
AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, [[0, 0], [2048, 0], [4096, 0]]
|
||||
)
|
||||
var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan(
|
||||
course, "District", Vector2i.ZERO, 64, CELL_PIXEL_SIZE
|
||||
)
|
||||
assert_that(plan).is_not_null()
|
||||
var canvas_pts: PackedVector2Array = plan["canvas_pts"]
|
||||
assert_int(canvas_pts.size()).is_equal(3)
|
||||
assert_int(plan["cls"]).is_equal(AtlasWindowGeometryNature.RIVER_CLASS_TRUNK)
|
||||
assert_str(plan["terminus"]).is_equal("None")
|
||||
|
||||
|
||||
## District does NOT show streams (COURSE_CLASS_VISIBLE_BY_RUNG: District ==
|
||||
## [TRIBUTARY, TRUNK]) — a stream-class course must construct nothing at
|
||||
## District, even with perfectly well-formed points.
|
||||
func test_build_course_render_plan_district_stream_is_not_visible() -> void:
|
||||
var course: Dictionary = _course_fixture(
|
||||
AtlasWindowGeometryNature.RIVER_CLASS_STREAM, [[0, 0], [2048, 0]]
|
||||
)
|
||||
var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan(
|
||||
course, "District", Vector2i.ZERO, 64, CELL_PIXEL_SIZE
|
||||
)
|
||||
assert_that(plan).override_failure_message(
|
||||
"streams must not draw at District — only tributary+trunk are visible there"
|
||||
).is_null()
|
||||
|
||||
|
||||
## Quarter rivers return — ALL THREE classes construct at Quarter, including
|
||||
## streams. This is the wave-1 pre-announced revisit actually landing.
|
||||
func test_build_course_render_plan_quarter_stream_is_visible() -> void:
|
||||
var course: Dictionary = _course_fixture(
|
||||
AtlasWindowGeometryNature.RIVER_CLASS_STREAM, [[0, 0], [512, 0]]
|
||||
)
|
||||
var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan(
|
||||
course, "Quarter", Vector2i.ZERO, 16, CELL_PIXEL_SIZE
|
||||
)
|
||||
assert_that(plan).override_failure_message(
|
||||
"Quarter rivers return — streams must be visible at Quarter"
|
||||
).is_not_null()
|
||||
|
||||
|
||||
## Region never carries courses — a course-shaped fixture queried at "Region"
|
||||
## must construct nothing, regardless of class.
|
||||
func test_build_course_render_plan_region_constructs_nothing() -> void:
|
||||
var course: Dictionary = _course_fixture(
|
||||
AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, [[0, 0], [2048, 0]]
|
||||
)
|
||||
var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan(
|
||||
course, "Region", Vector2i.ZERO, 64, CELL_PIXEL_SIZE
|
||||
)
|
||||
assert_that(plan).is_null()
|
||||
|
||||
|
||||
## Ruling 3h: terminus MOUTH is preserved through to the plan — the caller
|
||||
## (the overlay's draw function) reads this to decide whether to draw a
|
||||
## mouth ring at the LAST canvas point.
|
||||
func test_build_course_render_plan_preserves_mouth_terminus() -> void:
|
||||
var course: Dictionary = _course_fixture(
|
||||
AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, [[0, 0], [2048, 0]], "Mouth"
|
||||
)
|
||||
var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan(
|
||||
course, "District", Vector2i.ZERO, 64, CELL_PIXEL_SIZE
|
||||
)
|
||||
assert_that(plan).is_not_null()
|
||||
assert_str(plan["terminus"]).is_equal(AtlasWindowGeometryNature.COURSE_TERMINUS_MOUTH)
|
||||
|
||||
|
||||
## EdgeDrain, ContinuesBeyondWindow, and the default None terminus are all
|
||||
## preserved verbatim too — the PLAN doesn't collapse them, the DRAW caller
|
||||
## decides presentation (no ring for any of these three).
|
||||
func test_build_course_render_plan_preserves_edge_drain_and_continues_and_none_termini() -> void:
|
||||
for terminus in ["EdgeDrain", "ContinuesBeyondWindow", "None"]:
|
||||
var course: Dictionary = _course_fixture(
|
||||
AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, [[0, 0], [2048, 0]], terminus
|
||||
)
|
||||
var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan(
|
||||
course, "District", Vector2i.ZERO, 64, CELL_PIXEL_SIZE
|
||||
)
|
||||
assert_str(plan["terminus"]).is_equal(terminus)
|
||||
|
||||
|
||||
## A course with no `terminus` key at all (an old/malformed payload) defaults
|
||||
## to COURSE_TERMINUS_NONE (the string "None"), never GDScript `null` or an
|
||||
## empty string — matching the class-fallback graceful-decode posture used
|
||||
## throughout this cluster.
|
||||
func test_build_course_render_plan_missing_terminus_defaults_to_none_string() -> void:
|
||||
var course: Dictionary = {
|
||||
"edge_id": 1, "class": AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, "points": [[0, 0], [100, 0]]
|
||||
}
|
||||
var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan(
|
||||
course, "District", Vector2i.ZERO, 64, CELL_PIXEL_SIZE
|
||||
)
|
||||
assert_str(plan["terminus"]).is_equal(AtlasWindowGeometryNature.COURSE_TERMINUS_NONE)
|
||||
|
||||
|
||||
## A course missing `class` entirely falls back to RIVER_CLASS_FALLBACK
|
||||
## (TRUNK) — same posture as the skeleton path's river_class fallback.
|
||||
func test_build_course_render_plan_missing_class_falls_back_to_trunk() -> void:
|
||||
var course: Dictionary = {"edge_id": 1, "points": [[0, 0], [100, 0]]}
|
||||
var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan(
|
||||
course, "District", Vector2i.ZERO, 64, CELL_PIXEL_SIZE
|
||||
)
|
||||
assert_that(plan).is_not_null()
|
||||
assert_int(plan["cls"]).is_equal(AtlasWindowGeometryNature.RIVER_CLASS_FALLBACK)
|
||||
|
||||
|
||||
## Fewer than 2 points (a degenerate single-point or empty course) has no
|
||||
## line to draw — must construct null, not a 1-point/0-point polyline.
|
||||
func test_build_course_render_plan_fewer_than_two_points_constructs_nothing() -> void:
|
||||
var one_point: Dictionary = _course_fixture(AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, [[0, 0]])
|
||||
var no_points: Dictionary = _course_fixture(AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, [])
|
||||
assert_that(
|
||||
AtlasWindowGeometryNature.build_course_render_plan(
|
||||
one_point, "District", Vector2i.ZERO, 64, CELL_PIXEL_SIZE
|
||||
)
|
||||
).is_null()
|
||||
assert_that(
|
||||
AtlasWindowGeometryNature.build_course_render_plan(
|
||||
no_points, "District", Vector2i.ZERO, 64, CELL_PIXEL_SIZE
|
||||
)
|
||||
).is_null()
|
||||
|
||||
|
||||
## Missing `points` key entirely (not just an empty array) must also
|
||||
## construct nothing, not crash on a null/missing field read.
|
||||
func test_build_course_render_plan_missing_points_key_constructs_nothing() -> void:
|
||||
var course: Dictionary = {"edge_id": 1, "class": AtlasWindowGeometryNature.RIVER_CLASS_TRUNK}
|
||||
var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan(
|
||||
course, "District", Vector2i.ZERO, 64, CELL_PIXEL_SIZE
|
||||
)
|
||||
assert_that(plan).is_null()
|
||||
|
||||
|
||||
## A malformed individual point (not an array, or too short) is skipped —
|
||||
## not fatal to the whole polyline, matching build_skeleton_chords()'s own
|
||||
## "skip the bad entry, keep going" posture — as long as >= 2 valid points
|
||||
## remain.
|
||||
func test_build_course_render_plan_malformed_point_is_skipped_not_fatal() -> void:
|
||||
var course: Dictionary = _course_fixture(
|
||||
AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, [[0, 0], "not a point", [2048, 0], [4096, 0]]
|
||||
)
|
||||
var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan(
|
||||
course, "District", Vector2i.ZERO, 64, CELL_PIXEL_SIZE
|
||||
)
|
||||
assert_that(plan).is_not_null()
|
||||
var canvas_pts: PackedVector2Array = plan["canvas_pts"]
|
||||
assert_int(canvas_pts.size()).override_failure_message(
|
||||
"the malformed point must be skipped, leaving exactly the 3 well-formed points"
|
||||
).is_equal(3)
|
||||
|
||||
|
||||
## Malformed points that leave FEWER than 2 valid entries must still
|
||||
## construct null (the "too many bad points" case, distinct from "some bad
|
||||
## points but enough good ones remain" above).
|
||||
func test_build_course_render_plan_malformed_points_leaving_too_few_constructs_nothing() -> void:
|
||||
var course: Dictionary = _course_fixture(
|
||||
AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, [[0, 0], "bad", "also bad"]
|
||||
)
|
||||
var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan(
|
||||
course, "District", Vector2i.ZERO, 64, CELL_PIXEL_SIZE
|
||||
)
|
||||
assert_that(plan).is_null()
|
||||
|
||||
|
||||
## Points are WORLD METRES (Ruling 3h), not heightmap pixels — cross-checked
|
||||
## against world_m_to_canvas_local() called manually, proving the plan's
|
||||
## conversion path matches the documented one-fewer-step-than-skeleton
|
||||
## pipeline (no layer1_pixel_to_world_m() involved at all).
|
||||
func test_build_course_render_plan_points_are_world_metres_not_pixels() -> void:
|
||||
var held_center := Vector2i(5, 5)
|
||||
var held_n := 64
|
||||
var world_pt := Vector2(10240.0, -4096.0) # 5 districts east, 2 north of origin
|
||||
var course: Dictionary = _course_fixture(
|
||||
AtlasWindowGeometryNature.RIVER_CLASS_TRUNK,
|
||||
[[int(world_pt.x), int(world_pt.y)], [0, 0]]
|
||||
)
|
||||
var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan(
|
||||
course, "District", held_center, held_n, CELL_PIXEL_SIZE
|
||||
)
|
||||
var expected: Vector2 = AtlasWindowGeometryNature.world_m_to_canvas_local(
|
||||
world_pt, held_center, held_n, CELL_PIXEL_SIZE
|
||||
)
|
||||
var canvas_pts: PackedVector2Array = plan["canvas_pts"]
|
||||
assert_that(canvas_pts[0]).is_equal_approx(expected, Vector2.ONE * 0.01)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 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 +834,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 +846,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 +879,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)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
## T-1170 live round (2026-07-23): tests for
|
||||
## AtlasWindowGeometryNature.zoom_compensated_stroke_width() — the
|
||||
## STROKE-WIDTH-specific sibling of zoom_compensated_size(), added after the
|
||||
## course-polyline hairline finding (Araminta's pixel scan of
|
||||
## D-district-courses.png/Q-quarter-courses.png: a UNIFORM 1px hairline for
|
||||
## the ENTIRE visible course, no width/opacity variation at all, in BOTH
|
||||
## District and Quarter captures). Split into its own file rather than
|
||||
## folded into test_atlas_window_geometry_nature.gd, which was already at the
|
||||
## gdlint max-file-lines cap — same file-per-concern precedent as every other
|
||||
## split in this cluster.
|
||||
##
|
||||
## Live A/B evidence (temporary instrumentation, since reverted — the
|
||||
## dossier discipline): draw_line()/draw_polyline() called with a
|
||||
## canvas-local width in [0.6, 1.0) renders as a flat 1px hairline
|
||||
## regardless of the input value, confirmed identically on BOTH APIs (ruling
|
||||
## out a draw_polyline()-specific quirk) — Godot's line rasterizer has a
|
||||
## ~1.0-canvas-local-unit floor that draw_circle()'s radius parameter does
|
||||
## NOT share (confirmed: mouth ring radii at the same District/Quarter zoom
|
||||
## render correctly-sized via the unchanged zoom_compensated_size()/_zs()
|
||||
## path — only the STROKE WIDTH argument was affected). The compensation
|
||||
## MATH itself was never wrong (0.373 * 3.75 round-trips to 1.4 exactly) —
|
||||
## the bug was that nothing floored the intermediate value against Godot's
|
||||
## own rasterizer minimum before handing it to draw_line()/draw_polyline().
|
||||
class_name TestAtlasWindowGeometryStrokeWidth
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const AtlasWindowGeometryNature := preload(
|
||||
"res://ui/implant/apps/atlas/atlas_window_geometry_nature.gd"
|
||||
)
|
||||
|
||||
|
||||
## At zoom=1.0, unchanged from zoom_compensated_size() — no floor engages
|
||||
## when the input is already >= 1.0.
|
||||
func test_zoom_compensated_stroke_width_at_zoom_one_is_unchanged() -> void:
|
||||
assert_float(
|
||||
AtlasWindowGeometryNature.zoom_compensated_stroke_width(2.2, 1.0)
|
||||
).is_equal_approx(2.2, 0.0001)
|
||||
|
||||
|
||||
## The EXACT regression shape this round closes: at District's real fit zoom
|
||||
## (3.75, live capture), the tributary class's raw table width (1.4px)
|
||||
## divides to 0.3733 canvas-local — BELOW the 1.0 floor under the OLD
|
||||
## zoom_compensated_size() path (pinned directly, not just asserted) — and
|
||||
## zoom_compensated_stroke_width() must instead return exactly 1.0 (the
|
||||
## floor), never the sub-floor raw division result.
|
||||
func test_zoom_compensated_stroke_width_district_tributary_hits_the_floor() -> void:
|
||||
var view_zoom := 3.75 # LENDEL's live District fit zoom, capture-confirmed
|
||||
var raw_width_px := 1.4 # COURSE_CLASS_WIDTH_PX[RIVER_CLASS_TRIBUTARY]
|
||||
var unfloored: float = AtlasWindowGeometryNature.zoom_compensated_size(raw_width_px, view_zoom)
|
||||
assert_float(unfloored).override_failure_message(
|
||||
"regression pin: the OLD unfloored division must be BELOW 1.0 at this"
|
||||
+ " zoom — this is the exact numeric shape of the hairline bug"
|
||||
).is_less(1.0)
|
||||
var floored: float = AtlasWindowGeometryNature.zoom_compensated_stroke_width(raw_width_px, view_zoom)
|
||||
assert_float(floored).override_failure_message(
|
||||
"zoom_compensated_stroke_width() must clamp to the 1.0 floor, not the"
|
||||
+ " sub-pixel unfloored value that collapses to Godot's hairline"
|
||||
).is_equal_approx(1.0, 0.0001)
|
||||
|
||||
|
||||
## Same shape at Quarter's real fit zoom (7.5, live capture) — the floor
|
||||
## engages even harder there (raw width divides to 0.1867).
|
||||
func test_zoom_compensated_stroke_width_quarter_tributary_hits_the_floor() -> void:
|
||||
var view_zoom := 7.5 # LENDEL's live Quarter fit zoom, capture-confirmed
|
||||
var raw_width_px := 1.4
|
||||
var floored: float = AtlasWindowGeometryNature.zoom_compensated_stroke_width(raw_width_px, view_zoom)
|
||||
assert_float(floored).is_equal_approx(1.0, 0.0001)
|
||||
|
||||
|
||||
## Regression pin (PR #195 stroke-width-class shape, per the coordinator's
|
||||
## explicit ask): for EACH course class, at District's real fit zoom, the
|
||||
## EFFECTIVE on-screen width the render plan feeds (canvas-local width times
|
||||
## view_zoom, exactly what the canvas transform multiplies at render time)
|
||||
## must equal AT LEAST the table value — never less, since the floor can only
|
||||
## push the effective width UP from what an unfloored divide would produce,
|
||||
## never down. This is the "does the value actually reaching the screen
|
||||
## match the table" pin the coordinator asked for, computed both ways
|
||||
## (floored vs table) rather than eyeballed.
|
||||
func test_effective_stroke_width_at_district_zoom_meets_table_value_per_class() -> void:
|
||||
var view_zoom := 3.75
|
||||
for cls in [
|
||||
AtlasWindowGeometryNature.RIVER_CLASS_STREAM,
|
||||
AtlasWindowGeometryNature.RIVER_CLASS_TRIBUTARY,
|
||||
AtlasWindowGeometryNature.RIVER_CLASS_TRUNK,
|
||||
]:
|
||||
var table_width: float = AtlasWindowGeometryNature.course_class_width_px(cls)
|
||||
var canvas_local: float = AtlasWindowGeometryNature.zoom_compensated_stroke_width(
|
||||
table_width, view_zoom
|
||||
)
|
||||
var effective_screen_px: float = canvas_local * view_zoom
|
||||
assert_float(effective_screen_px).override_failure_message(
|
||||
(
|
||||
"class %d's effective on-screen stroke width (%.3fpx) must be AT"
|
||||
+ " LEAST its table value (%.3fpx) — the floor must never make a"
|
||||
+ " course THINNER than the ruling specifies, only ever thicker"
|
||||
+ " when the literal value would otherwise be sub-pixel"
|
||||
)
|
||||
% [cls, effective_screen_px, table_width]
|
||||
).is_greater_equal(table_width - 0.001)
|
||||
|
||||
|
||||
## Same pin at Quarter's fit zoom (7.5) — the floor engages harder there
|
||||
## (streams' 0.9px table value divides to 0.12 canvas-local, furthest below
|
||||
## the floor of any class/rung combination this batch draws).
|
||||
func test_effective_stroke_width_at_quarter_zoom_meets_table_value_per_class() -> void:
|
||||
var view_zoom := 7.5
|
||||
for cls in [
|
||||
AtlasWindowGeometryNature.RIVER_CLASS_STREAM,
|
||||
AtlasWindowGeometryNature.RIVER_CLASS_TRIBUTARY,
|
||||
AtlasWindowGeometryNature.RIVER_CLASS_TRUNK,
|
||||
]:
|
||||
var table_width: float = AtlasWindowGeometryNature.course_class_width_px(cls)
|
||||
var canvas_local: float = AtlasWindowGeometryNature.zoom_compensated_stroke_width(
|
||||
table_width, view_zoom
|
||||
)
|
||||
var effective_screen_px: float = canvas_local * view_zoom
|
||||
assert_float(effective_screen_px).is_greater_equal(table_width - 0.001)
|
||||
|
||||
|
||||
## At a LOW zoom (well under 1.0, e.g. an extreme zoom-out within a rung —
|
||||
## not just Region's orbital case), the floor must NOT engage: the ordinary
|
||||
## divide-then-scale math must still produce the literal table value exactly,
|
||||
## matching zoom_compensated_size()'s own unfloored behavior. Pins that the
|
||||
## floor is a ONE-DIRECTION safety net, not a blanket override.
|
||||
func test_zoom_compensated_stroke_width_does_not_engage_at_low_zoom() -> void:
|
||||
var view_zoom := 0.1
|
||||
var raw_width_px := 2.2
|
||||
var floored: float = AtlasWindowGeometryNature.zoom_compensated_stroke_width(raw_width_px, view_zoom)
|
||||
var unfloored: float = AtlasWindowGeometryNature.zoom_compensated_size(raw_width_px, view_zoom)
|
||||
assert_float(floored).override_failure_message(
|
||||
"at a zoom where the unfloored value is already well above 1.0, the"
|
||||
+ " floor must be a no-op — identical to zoom_compensated_size()"
|
||||
).is_equal_approx(unfloored, 0.0001)
|
||||
|
||||
|
||||
## A degenerate zero (or negative) view_zoom must not divide-by-zero/produce
|
||||
## infinity/NaN — same total-function guarantee as zoom_compensated_size().
|
||||
func test_zoom_compensated_stroke_width_zero_zoom_does_not_blow_up() -> void:
|
||||
var result: float = AtlasWindowGeometryNature.zoom_compensated_stroke_width(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-1170 live round (2026-07-23, coordinator's mouth-ring finding):
|
||||
# zoom_compensated_ring_radius() — the RADIUS-SMALLER-THAN-STROKE regime.
|
||||
# Same A/B-bracket discipline as the stroke-width suite above, applied to
|
||||
# draw_arc() RING markers (mouth rings), whose radius AND stroke are both
|
||||
# small zoom-compensated values that can cross each other.
|
||||
##
|
||||
## Live A/B evidence (temporary instrumentation, since reverted): at the
|
||||
## PRODUCTION Quarter-rung radius/stroke pair (radius=0.667, stroke=1.0
|
||||
## canvas-local units, Quarter fit zoom 7.5), draw_arc() rendered a SOLID
|
||||
## BLOB, not a hollow ring — confirmed via a re-centered live capture (the
|
||||
## ORIGINAL "zero ring pixels" symptom was a separate viewport-framing crop,
|
||||
## not this bug — see zoom_compensated_ring_radius()'s own doc). Bracket
|
||||
## (stroke fixed at 1.0 canvas-local, radius varied): 0.51 (~stroke/2) ->
|
||||
## blob; 1.0 (=stroke, the production case) -> blob; 1.5 (1.5x stroke) ->
|
||||
## hollow ring recovers; 2.0 (2x stroke) -> hollow ring, cleaner. Floor set
|
||||
## at 2x with margin over the observed 1.0x-blob/1.5x-hollow transition.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## At zoom=1.0 with a radius comfortably above stroke*2 already, the floor
|
||||
## must be a no-op — identical to zoom_compensated_size() directly.
|
||||
func test_zoom_compensated_ring_radius_no_op_when_radius_already_clears_the_floor() -> void:
|
||||
var radius: float = AtlasWindowGeometryNature.zoom_compensated_ring_radius(5.0, 1.5, 1.0)
|
||||
assert_float(radius).is_equal_approx(5.0, 0.0001)
|
||||
|
||||
|
||||
## The EXACT regression shape this round closes: at Quarter's real fit zoom
|
||||
## (7.5, live capture), MOUTH_RING_RADIUS (5.0) divides to 0.667
|
||||
## canvas-local — BELOW its own paired stroke (1.5/7.5 floored to 1.0 via
|
||||
## zoom_compensated_stroke_width) — pinned directly, not just asserted.
|
||||
## zoom_compensated_ring_radius() must instead return stroke * 2.0 (the
|
||||
## floor), never the sub-floor raw division result that produced the blob.
|
||||
func test_zoom_compensated_ring_radius_quarter_mouth_ring_hits_the_floor() -> void:
|
||||
var view_zoom := 7.5 # LENDEL's live Quarter fit zoom, capture-confirmed
|
||||
var raw_radius := 5.0 # MOUTH_RING_RADIUS
|
||||
var stroke: float = AtlasWindowGeometryNature.zoom_compensated_stroke_width(1.5, view_zoom)
|
||||
var unfloored: float = AtlasWindowGeometryNature.zoom_compensated_size(raw_radius, view_zoom)
|
||||
assert_float(unfloored).override_failure_message(
|
||||
"regression pin: the OLD unfloored radius must be AT/BELOW the paired"
|
||||
+ " stroke at this zoom — this is the exact numeric shape of the blob bug"
|
||||
).is_less_equal(stroke)
|
||||
var floored: float = AtlasWindowGeometryNature.zoom_compensated_ring_radius(
|
||||
raw_radius, stroke, view_zoom
|
||||
)
|
||||
assert_float(floored).override_failure_message(
|
||||
"zoom_compensated_ring_radius() must clamp to stroke * 2.0 (the floor),"
|
||||
+ " not the sub-floor unfloored value that renders as a solid blob"
|
||||
).is_equal_approx(stroke * AtlasWindowGeometryNature.RING_RADIUS_STROKE_MULTIPLIER, 0.0001)
|
||||
|
||||
|
||||
## Regression pin (the stroke-width suite's own "effective on-screen value"
|
||||
## shape, applied to the radius/stroke RATIO instead of an absolute value):
|
||||
## for the mouth ring AND halo (the two draw_arc() ring markers in this
|
||||
## cluster), at Quarter's real fit zoom, the floored radius must be AT LEAST
|
||||
## RING_RADIUS_STROKE_MULTIPLIER times its own paired stroke — the actual
|
||||
## geometric property that keeps the ring hollow, verified directly rather
|
||||
## than just re-checking the numeric floor value in isolation.
|
||||
func test_ring_radius_stays_at_least_the_multiplier_above_its_stroke_at_quarter_zoom() -> void:
|
||||
var view_zoom := 7.5
|
||||
# (raw_radius_px, raw_stroke_px) pairs — the mouth ring and halo's own
|
||||
# literal call-site arguments in _draw_mouth().
|
||||
for pair in [[5.0, 1.5], [8.0, 1.0]]:
|
||||
var raw_radius: float = pair[0]
|
||||
var raw_stroke: float = pair[1]
|
||||
var stroke: float = AtlasWindowGeometryNature.zoom_compensated_stroke_width(
|
||||
raw_stroke, view_zoom
|
||||
)
|
||||
var radius: float = AtlasWindowGeometryNature.zoom_compensated_ring_radius(
|
||||
raw_radius, stroke, view_zoom
|
||||
)
|
||||
assert_float(radius).override_failure_message(
|
||||
(
|
||||
"radius %.3f must be at least %.1fx its paired stroke %.3f — a ratio"
|
||||
+ " below this rendered as a SOLID BLOB in the live A/B bracket,"
|
||||
+ " never a hollow ring"
|
||||
)
|
||||
% [radius, AtlasWindowGeometryNature.RING_RADIUS_STROKE_MULTIPLIER, stroke]
|
||||
).is_greater_equal(stroke * AtlasWindowGeometryNature.RING_RADIUS_STROKE_MULTIPLIER - 0.0001)
|
||||
|
||||
|
||||
## At a LOW zoom (e.g. Region's tiny orbital fit, or any zoom where the
|
||||
## naive radius is already well clear of the floor), the floor must NOT
|
||||
## engage — matching zoom_compensated_stroke_width()'s own
|
||||
## does-not-engage-at-low-zoom guarantee. Pins that this is a one-direction
|
||||
## safety net, not a blanket override.
|
||||
func test_zoom_compensated_ring_radius_does_not_engage_at_low_zoom() -> void:
|
||||
var view_zoom := 0.0063 # Lendel's real orbital fit zoom
|
||||
var raw_radius := 5.0 # MOUTH_RING_RADIUS
|
||||
var stroke: float = AtlasWindowGeometryNature.zoom_compensated_stroke_width(1.5, view_zoom)
|
||||
var floored: float = AtlasWindowGeometryNature.zoom_compensated_ring_radius(
|
||||
raw_radius, stroke, view_zoom
|
||||
)
|
||||
var unfloored: float = AtlasWindowGeometryNature.zoom_compensated_size(raw_radius, view_zoom)
|
||||
assert_float(floored).override_failure_message(
|
||||
"at a zoom where the naive radius is already far above the floor, the"
|
||||
+ " floor must be a no-op — identical to zoom_compensated_size()"
|
||||
).is_equal_approx(unfloored, 0.0001)
|
||||
|
||||
|
||||
## A degenerate zero (or negative) view_zoom must not divide-by-zero/produce
|
||||
## infinity/NaN — same total-function guarantee as the stroke-width sibling.
|
||||
func test_zoom_compensated_ring_radius_zero_zoom_does_not_blow_up() -> void:
|
||||
var stroke: float = AtlasWindowGeometryNature.zoom_compensated_stroke_width(1.5, 0.0)
|
||||
var result: float = AtlasWindowGeometryNature.zoom_compensated_ring_radius(5.0, stroke, 0.0)
|
||||
assert_bool(is_finite(result)).override_failure_message(
|
||||
"a degenerate zero view_zoom must not produce inf/NaN"
|
||||
).is_true()
|
||||
@@ -442,3 +442,138 @@ 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()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# T-1170 Ruling 5b (B3): _draw_course_path() early-return gating — the SAME
|
||||
# "call the function directly when its early-return happens BEFORE any
|
||||
# draw_*() call" precedent test_draw_with_null_viewer_is_a_noop() and
|
||||
# test_draw_with_zero_grid_dims_returns_before_any_draw_call() already
|
||||
# establish. Every case below returns before _draw_one_course() is ever
|
||||
# reached, so calling _draw_course_path() directly (no SubViewport/render
|
||||
# context) is safe. This is a SEPARATE data source/gate from the Layer-1
|
||||
# skeleton path above — none of these tests touch _layer1 at all.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## The overlay-bar "gen_rivers" toggle gates the course path too — the SAME
|
||||
## toggle the skeleton path uses (one player-facing "rivers" control covers
|
||||
## both presentation surfaces, per the ruling).
|
||||
func test_draw_course_path_returns_before_any_draw_when_gen_rivers_is_off() -> void:
|
||||
var stub := _ViewerStub.new()
|
||||
stub.overlay_visibility["gen_rivers"] = false
|
||||
stub.district_window = {
|
||||
"n": 64, "granularity_v2": "District",
|
||||
"courses": [{"class": 2, "points": [[0, 0], [100, 0]], "terminus": "None"}],
|
||||
}
|
||||
var o = _make_overlay(stub)
|
||||
o._draw_course_path() # must return before draw_polyline() — no crash outside a render context
|
||||
|
||||
|
||||
## No district window at all (single-window mode hasn't arrived yet) — the
|
||||
## course path must return cleanly, not crash on a null window read.
|
||||
func test_draw_course_path_returns_before_any_draw_when_no_window() -> void:
|
||||
var stub := _ViewerStub.new()
|
||||
stub.district_window = null
|
||||
var o = _make_overlay(stub)
|
||||
o._draw_course_path()
|
||||
|
||||
|
||||
## Ruling 3h decode tolerance: a window WITHOUT a `courses` key at all (the
|
||||
## old/pre-A2 payload shape) must draw NOTHING at District/Quarter except
|
||||
## mouths-on-land from the skeleton (that's the OTHER path's job) — this
|
||||
## path itself must simply return, not error or fall back to a dot-scatter.
|
||||
func test_draw_course_path_missing_courses_field_is_tolerated() -> void:
|
||||
var stub := _ViewerStub.new()
|
||||
stub.district_window = {"n": 64, "granularity_v2": "District"} # no "courses" key
|
||||
var o = _make_overlay(stub)
|
||||
o._draw_course_path()
|
||||
|
||||
|
||||
## An explicitly present but EMPTY courses array must also be tolerated
|
||||
## cleanly (the loop simply iterates zero times).
|
||||
func test_draw_course_path_empty_courses_array_is_tolerated() -> void:
|
||||
var stub := _ViewerStub.new()
|
||||
stub.district_window = {"n": 64, "granularity_v2": "District", "courses": []}
|
||||
var o = _make_overlay(stub)
|
||||
o._draw_course_path()
|
||||
|
||||
|
||||
## A `courses` field that is present but the WRONG TYPE (not an Array — e.g.
|
||||
## a malformed/corrupted payload) must be tolerated the same way as a
|
||||
## missing field, not crash attempting to iterate a non-Array.
|
||||
func test_draw_course_path_non_array_courses_field_is_tolerated() -> void:
|
||||
var stub := _ViewerStub.new()
|
||||
stub.district_window = {"n": 64, "granularity_v2": "District", "courses": "not an array"}
|
||||
var o = _make_overlay(stub)
|
||||
o._draw_course_path()
|
||||
|
||||
@@ -233,6 +233,146 @@ func test_set_overlay_visible_gen_basins_flips_gate_and_redraws_nature_overlay()
|
||||
).is_greater(baseline)
|
||||
|
||||
|
||||
## T-1170 B3 (PR #197 review, Hoshe #3): _on_window_ready() now also calls
|
||||
## _nature_overlay.queue_redraw() (atlas_window_viewer.gd:507) — courses ride
|
||||
## `DistrictWindowLayer.courses`, the SAME `_window` this handler adopts, so
|
||||
## a window arrival that never redraws the nature overlay would leave freshly
|
||||
## arrived courses invisible until an UNRELATED pan/zoom gesture happened to
|
||||
## redraw it. Same spy-and-baseline shape as
|
||||
## test_set_overlay_visible_gen_basins_flips_gate_and_redraws_nature_overlay()
|
||||
## above — this is the exact pattern PR #196 established for "prove a
|
||||
## specific queue_redraw() call site actually fires", applied to the
|
||||
## window-arrival call site instead of the toggle call site.
|
||||
##
|
||||
## **Isolation note (live finding while writing this test):** `_on_window_ready()`
|
||||
## ALSO calls `_fit_and_center()` on the first-ever arrival
|
||||
## (`_awaiting_first_window and not _user_adjusted`), and `_fit_and_center()`
|
||||
## itself already ends in `_apply_transform()`, which redraws the nature
|
||||
## overlay through a SEPARATE, pre-existing call site. That path would mask
|
||||
## a broken/removed line 507 (both call sites fire on a fresh entry's first
|
||||
## arrival, so removing just one wouldn't drop draw_count below baseline).
|
||||
## Setting `_user_adjusted = true` before the response arrives — the SAME
|
||||
## guard a real pan/zoom gesture sets (`_maybe_refloat_window()`/`_zoom_at()`)
|
||||
## — skips the fit-and-center branch, so ONLY line 507 can be the source of
|
||||
## any redraw the assertion below observes. This is a real, reachable state
|
||||
## (any window arrival after the player's first manual pan/zoom), not a
|
||||
## test-only fiction.
|
||||
func test_window_arrival_redraws_nature_overlay() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20), 2)
|
||||
v._user_adjusted = true # isolate line 507 from the first-arrival fit-and-center redraw
|
||||
|
||||
# Swap in the counting spy AFTER enter() (matching the gen_basins test's
|
||||
# own "swap after construction, then let it settle" shape) so enter()'s
|
||||
# own queue_redraw() calls don't pollute the baseline.
|
||||
var spy := _CountingNatureOverlay.new(v)
|
||||
v._nature_overlay.queue_free()
|
||||
v._nature_overlay = spy
|
||||
v._canvas.add_child(spy)
|
||||
|
||||
await get_tree().process_frame
|
||||
await get_tree().process_frame
|
||||
var baseline: int = spy.draw_count
|
||||
assert_int(baseline).override_failure_message(
|
||||
"sanity: the spy must have drawn at least once before the window"
|
||||
+ " arrives, or this test can't distinguish 'redrawn BY the arrival'"
|
||||
+ " from 'never drawn at all'"
|
||||
).is_greater(0)
|
||||
|
||||
var window: Dictionary = _mock_window(Vector2i(10, 20), 2)
|
||||
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", window))
|
||||
await get_tree().process_frame
|
||||
|
||||
assert_that(v.get_district_window()).override_failure_message(
|
||||
"sanity: the response must actually have been adopted (matching echo)"
|
||||
+ " or this test proves nothing about the arrival path specifically"
|
||||
).is_equal(window)
|
||||
assert_int(spy.draw_count).override_failure_message(
|
||||
"_on_window_ready() must queue_redraw() the NATURE overlay — courses"
|
||||
+ " ride the SAME _window this handler adopts, so a window arrival"
|
||||
+ " that doesn't redraw the nature overlay leaves freshly arrived"
|
||||
+ " courses invisible until an unrelated pan/zoom happens to redraw"
|
||||
+ " it — draw_count must have advanced past the baseline (%d)" % baseline
|
||||
).is_greater(baseline)
|
||||
|
||||
|
||||
## The RUNG-SWAP arrival case (a later _on_window_ready() call for a
|
||||
## DIFFERENT granularity_v2 than the one the viewer entered at — e.g. a
|
||||
## wheel-zoom crossing from District into Quarter) — cheap to cover in the
|
||||
## SAME test file per the review's own "if cheap" allowance. Confirms the
|
||||
## redraw fires on EVERY window adoption, not just the first-ever one
|
||||
## (T-1153's progressive-refinement doc is explicit that _window only ever
|
||||
## gets REPLACED, never renulled, on a rung swap).
|
||||
##
|
||||
## **Uses `_window_request.request_now()` directly, NOT `_enter_at_rung()`**
|
||||
## — a live finding while writing this test: `_enter_at_rung()` sets
|
||||
## `_awaiting_first_window = true` again (it's the SAME reset path a fresh
|
||||
## descent uses), which would route the swap response back through
|
||||
## `_fit_and_center()`'s OWN redraw call site, masking line 507 exactly like
|
||||
## the note on the test above. The REAL production rung-swap path,
|
||||
## `_maybe_reselect_rung()`, never touches `_awaiting_first_window` at all —
|
||||
## it only calls `_window_request.request_debounced(...)`. `request_now()`
|
||||
## (the non-debounced sibling, same effect minus the timer) is called
|
||||
## directly here to update `_window_request`'s own `_granularity_v2` — the
|
||||
## exact field `_on_window_ready()`'s echo-matching guard reads — mirroring
|
||||
## the real path's state change without needing a live debounce timer in a
|
||||
## unit test.
|
||||
func test_rung_swap_window_arrival_redraws_nature_overlay() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20), 2)
|
||||
|
||||
# First arrival (District, matches enter()'s own default rung) — settles
|
||||
# the viewer into a held window, exactly as a real progressive-refinement
|
||||
# sequence would before a rung swap. Uses the REAL (non-spy) nature
|
||||
# overlay for this leg — only the swap leg itself needs the spy.
|
||||
var district_window: Dictionary = _mock_window(Vector2i(10, 20), 2)
|
||||
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", district_window))
|
||||
await get_tree().process_frame
|
||||
assert_that(v.get_district_window()).override_failure_message(
|
||||
"sanity: the first (District) arrival must have been adopted before"
|
||||
+ " simulating the swap"
|
||||
).is_equal(district_window)
|
||||
|
||||
# Now swap in the spy and simulate the RUNG SWAP itself — update the
|
||||
# request's echoed granularity_v2 to "Quarter" (what
|
||||
# _maybe_reselect_rung() -> request_debounced() would do on a real
|
||||
# wheel-zoom crossing) WITHOUT touching _awaiting_first_window, so the
|
||||
# response below takes the "not first window" branch — the genuinely
|
||||
# different code path from the test above.
|
||||
var spy := _CountingNatureOverlay.new(v)
|
||||
v._nature_overlay.queue_free()
|
||||
v._nature_overlay = spy
|
||||
v._canvas.add_child(spy)
|
||||
v._window_request.request_now(
|
||||
"GJ380c", Vector2i(10, 20), 2, AtlasWindowRequest.GRANULARITY_V2_QUARTER
|
||||
)
|
||||
|
||||
await get_tree().process_frame
|
||||
await get_tree().process_frame
|
||||
var baseline: int = spy.draw_count
|
||||
assert_int(baseline).override_failure_message(
|
||||
"sanity: the spy must have drawn at least once before the rung-swap"
|
||||
+ " response arrives"
|
||||
).is_greater(0)
|
||||
|
||||
var quarter_window: Dictionary = _mock_window(Vector2i(10, 20), 2)
|
||||
quarter_window["granularity_v2"] = "Quarter"
|
||||
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", quarter_window))
|
||||
await get_tree().process_frame
|
||||
|
||||
assert_that(v.get_district_window()).override_failure_message(
|
||||
"sanity: the rung-swap response must actually have been adopted"
|
||||
).is_equal(quarter_window)
|
||||
assert_int(spy.draw_count).override_failure_message(
|
||||
"a RUNG-SWAP window arrival (a later _on_window_ready() call at a"
|
||||
+ " DIFFERENT granularity_v2 than entry) must ALSO redraw the nature"
|
||||
+ " overlay — draw_count must have advanced past the post-first-"
|
||||
+ " arrival baseline (%d)" % baseline
|
||||
).is_greater(baseline)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# T-1120 capture-API parity (the ticket's explicit note: must survive here too)
|
||||
# =============================================================================
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,685 @@
|
||||
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<u8>, 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
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
# T-1170 Ruling 3h/5b: RiverCourse.terminus wire vocabulary — the
|
||||
# CourseTerminus enum's variant NAMES as they arrive over msgpack (bare
|
||||
# strings, the SAME "unit variant -> string tag" convention granularity_v2
|
||||
# already uses on this same wire — see AtlasMapProtocol's own doc). `None` on
|
||||
# the Rust side (a mid-window course that neither reaches a real mouth nor
|
||||
# the window edge — the ordinary "ends because the chord's amplitude taper
|
||||
# reached zero at a confluence/headwater anchor cell inside this window"
|
||||
## case) decodes to the bare string "None" per rmp_serde's unit-variant
|
||||
## convention — NOT GDScript `null`. Callers must compare against the STRING
|
||||
## constant below, never `== null`.
|
||||
# =============================================================================
|
||||
|
||||
const COURSE_TERMINUS_NONE: String = "None"
|
||||
const COURSE_TERMINUS_MOUTH: String = "Mouth"
|
||||
const COURSE_TERMINUS_EDGE_DRAIN: String = "EdgeDrain"
|
||||
const COURSE_TERMINUS_CONTINUES_BEYOND_WINDOW: String = "ContinuesBeyondWindow"
|
||||
|
||||
## 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
|
||||
|
||||
## T-1170 live round (2026-07-23, coordinator's mouth-ring blob finding):
|
||||
## the minimum radius/stroke RATIO a draw_arc() ring needs to render hollow
|
||||
## rather than degenerate into a solid blob — see
|
||||
## zoom_compensated_ring_radius()'s own doc for the full A/B bracket
|
||||
## evidence (radius=1x stroke -> blob, 1.5x -> hollow, floor set at 2x with
|
||||
## margin over the observed transition).
|
||||
const RING_RADIUS_STROKE_MULTIPLIER: float = 2.0
|
||||
|
||||
## 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 5b/3h: RiverCourse.points are already WORLD METRES on the
|
||||
## wire (unlike the skeleton path's heightmap-pixel `river_cells` — see
|
||||
## Ruling 3h's wire shape doc) — one fewer conversion step than
|
||||
## layer1_pixel_to_canvas_local() above: world metres -> fractional district
|
||||
## (world_m_to_district(), reused verbatim) -> canvas-local
|
||||
## (AtlasWindowGeometry.district_to_canvas_local(), same shared transform
|
||||
## every other drawn feature on this screen uses). No pixel-grid/body-radius
|
||||
## step at all — courses have no heightmap-pixel domain to convert out of.
|
||||
static func world_m_to_canvas_local(
|
||||
world_m: Vector2, held_center: Vector2i, held_n: int, cell_pixel_size: float
|
||||
) -> Vector2:
|
||||
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-1170 Ruling 5b/3h — pure course-polyline CONSTRUCTION (no draw calls):
|
||||
## given one raw `RiverCourse` dict (as decoded off the wire — `class`,
|
||||
## `points` (world-metres `[x,y]` pairs), `terminus` (a bare string tag)) and
|
||||
## the window's own `granularity_v2`/`held_center`/`held_n`/`cell_pixel_size`,
|
||||
## returns `null` if the course should not draw at all at this rung (class
|
||||
## not visible, missing/degenerate points), or
|
||||
## `{"canvas_pts": PackedVector2Array, "cls": int, "terminus": String}`
|
||||
## ready for the caller to draw_polyline() + terminus-marker dispatch.
|
||||
##
|
||||
## Class defaults to RIVER_CLASS_FALLBACK (TRUNK) when missing, the same
|
||||
## graceful-decode posture as the skeleton path's river_class fallback.
|
||||
## `terminus` defaults to COURSE_TERMINUS_NONE when missing — an ordinary
|
||||
## interior/no-marker ending, never crashing on an old/malformed payload.
|
||||
## Malformed individual points are skipped (not fatal to the whole polyline,
|
||||
## matching build_skeleton_chords()'s own "skip the bad entry, keep going"
|
||||
## posture) — if fewer than 2 valid points remain after skipping, returns
|
||||
## `null` (nothing to draw a line between).
|
||||
static func build_course_render_plan(
|
||||
course: Dictionary,
|
||||
granularity_v2: String,
|
||||
held_center: Vector2i,
|
||||
held_n: int,
|
||||
cell_pixel_size: float
|
||||
) -> Variant:
|
||||
var cls: int = int(course.get("class", RIVER_CLASS_FALLBACK))
|
||||
if not course_class_visible_at_rung(cls, granularity_v2):
|
||||
return null
|
||||
var points_raw: Variant = course.get("points")
|
||||
if not points_raw is Array or (points_raw as Array).size() < 2:
|
||||
return null
|
||||
|
||||
var canvas_pts: PackedVector2Array = PackedVector2Array()
|
||||
for pt: Variant in points_raw:
|
||||
if not (pt is Array and pt.size() >= 2):
|
||||
continue # malformed point — skip it, don't fail the whole polyline
|
||||
var world_m := Vector2(float(pt[0]), float(pt[1]))
|
||||
canvas_pts.append(world_m_to_canvas_local(world_m, held_center, held_n, cell_pixel_size))
|
||||
if canvas_pts.size() < 2:
|
||||
return null # too many malformed points left too few to draw a line
|
||||
|
||||
var terminus: String = str(course.get("terminus", COURSE_TERMINUS_NONE))
|
||||
return {"canvas_pts": canvas_pts, "cls": cls, "terminus": terminus}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 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)
|
||||
|
||||
|
||||
## T-1170 live-round finding (2026-07-23, coordinator/Araminta's pixel-scan
|
||||
## of the course captures — D-district-courses.png/Q-quarter-courses.png
|
||||
## showed a UNIFORM 1px hairline for the entire course, no width/opacity
|
||||
## variation at all): zoom_compensated_size() is correct arithmetic (verified:
|
||||
## 2.2 / 3.75 = 0.5867, and 0.5867 * 3.75 round-trips to 2.2 exactly — the
|
||||
## compensation MATH has never been the bug), but it has NO FLOOR against
|
||||
## Godot's own STROKE-WIDTH rasterizer minimum — confirmed empirically via a
|
||||
## live A/B bracket (temporary instrumentation, since reverted): draw_line()/
|
||||
## draw_polyline() called with a width in [0.6, 1.0) canvas-local units
|
||||
## renders as a flat 1px hairline REGARDLESS of the input value, identically
|
||||
## on both APIs (ruling out a draw_polyline()-specific quirk) — Godot's line
|
||||
## rasterizer treats any width below ~1.0 the same as its historical
|
||||
## width=-1.0 "hairline" sentinel, rather than continuing to shrink the
|
||||
## antialiased stroke sub-pixel the way draw_circle()'s radius parameter
|
||||
## does (mouth rings at the SAME District/Quarter zoom levels render
|
||||
## correctly-sized — confirmed, radii have no equivalent floor).
|
||||
##
|
||||
## District/Quarter fit zooms (3.75/7.5+, and the player can zoom further
|
||||
## within a rung) divide COURSE_CLASS_WIDTH_PX's 0.9-2.2px table values down
|
||||
## to 0.12-0.59 canvas-local units — BELOW the 1.0 floor — so every course
|
||||
## class collapses to the identical hairline the moment view_zoom exceeds
|
||||
## roughly `screen_space_size` itself. This is the STROKE-WIDTH-SPECIFIC
|
||||
## sibling of zoom_compensated_size() (which remains correct and unchanged
|
||||
## for radii/point sizes, its own existing floor is a divide-by-zero guard
|
||||
## only, not a rasterizer-minimum guard) — a SEPARATE function because the
|
||||
## two draw families have genuinely different Godot-side minimums, not a
|
||||
## single shared bug.
|
||||
##
|
||||
## The fix clamps the OUTPUT to a 1.0 canvas-local-unit floor — the closest
|
||||
## representable value to "as thin as Godot's rasterizer can actually draw a
|
||||
## non-hairline stroke" — rather than letting the divide produce a
|
||||
## sub-floor value that Godot silently reinterprets as hairline anyway. This
|
||||
## is an honest floor, not a workaround: below it, EVERY value (0.373, 0.6,
|
||||
## 0.9999...) already rendered identically as hairline before this fix, so
|
||||
## clamping to exactly 1.0 changes nothing about what could already be drawn
|
||||
## at that zoom — it only stops different classes/rungs from silently
|
||||
## collapsing to the SAME wrong result and starts drawing the class/opacity
|
||||
## variation the ruling specifies. At extreme zoom-in (small view_zoom
|
||||
## relative to the literal px value) the floor never engages — the same
|
||||
## divide-then-scale math takes over exactly as design intends, matching
|
||||
## zoom_compensated_size()'s own behavior at Region's tiny fit zoom.
|
||||
static func zoom_compensated_stroke_width(screen_space_size: float, view_zoom: float) -> float:
|
||||
return maxf(zoom_compensated_size(screen_space_size, view_zoom), 1.0)
|
||||
|
||||
|
||||
## T-1170 live round (2026-07-23, coordinator's mouth-ring finding): the
|
||||
## RADIUS-SMALLER-THAN-STROKE regime — a THIRD sibling to
|
||||
## zoom_compensated_size()/zoom_compensated_stroke_width(), needed
|
||||
## specifically for draw_arc() RING markers (mouth rings — the only
|
||||
## draw_arc() caller in this file whose radius and stroke width are BOTH
|
||||
## small, zoom-compensated values that can cross each other).
|
||||
##
|
||||
## Root-cause evidence (live A/B bracket, temporary instrumentation, since
|
||||
## reverted — real running client via SR_LIVE=1, real x11/opengl3 driver):
|
||||
## the ORIGINAL "zero ring pixels" report turned out to be a SEPARATE,
|
||||
## already-correct-code issue — the terminus point legitimately sits near
|
||||
## the requesting window's own edge, and the fit-and-center COVER strategy
|
||||
## crops that edge off the visible viewport (screen_p verified computed as
|
||||
## (1755, -191) against a 1920x1080 frame — above the top edge, not a
|
||||
## drawing bug). Panning the view to re-center the SAME point (verified via
|
||||
## AtlasWindowViewer.set_view()) proves the ring genuinely draws — but as a
|
||||
## SOLID BLOB, not a hollow ring, at the production radius/stroke pair
|
||||
## (radius=0.667, stroke=1.0 canvas-local units, Quarter fit zoom 7.5):
|
||||
## draw_arc()'s stroke, centered ON the radius circle, extends inward past
|
||||
## the circle's own center once stroke exceeds ~1x the radius, filling the
|
||||
## hole. Bracket results (stroke fixed at 1.0 canvas-local, radius varied):
|
||||
## radius=0.51 (~stroke/2) -> still a solid blob; radius=1.0 (=stroke) ->
|
||||
## solid blob (the production case); radius=1.5 (1.5x stroke) -> hollow ring
|
||||
## recovers; radius=2.0 (2x stroke) -> hollow ring, cleaner. The blob
|
||||
## persists past the naive geometric threshold (radius > stroke/2, where an
|
||||
## infinitely-thin/perfectly-antialiased ring would already have a hole)
|
||||
## because draw_arc()'s low tessellation (18 points, this file's own call)
|
||||
## plus antialiasing blur eat into the theoretical hole at these tiny
|
||||
## absolute magnitudes — an empirical floor, not a derived one, chosen with
|
||||
## margin over the observed 1.0x-blob/1.5x-hollow transition rather than
|
||||
## shaving the boundary exactly.
|
||||
##
|
||||
## The fix: floor the RADIUS at `stroke * RING_RADIUS_STROKE_MULTIPLIER`
|
||||
## (2.0, the top-of-file const — verified clean in the bracket above)
|
||||
## whenever the naive zoom-compensated radius would fall below it — the
|
||||
## same "floor the OUTPUT, never let a sub-threshold value reach Godot's
|
||||
## renderer" pattern zoom_compensated_stroke_width() already established,
|
||||
## applied to the paired radius/stroke relationship a lone-value floor
|
||||
## can't express (unlike the stroke-width floor, this one's threshold is
|
||||
## RELATIVE to another draw-time value, not an absolute constant). At every
|
||||
## zoom where the naive radius already clears the floor on its own
|
||||
## (Region's dot radii, or any District/Quarter case wide enough), this is
|
||||
## an exact no-op — identical to calling zoom_compensated_size() directly.
|
||||
static func zoom_compensated_ring_radius(
|
||||
screen_space_radius: float, stroke_width_canvas_local: float, view_zoom: float
|
||||
) -> float:
|
||||
var naive_radius: float = zoom_compensated_size(screen_space_radius, view_zoom)
|
||||
return maxf(naive_radius, stroke_width_canvas_local * RING_RADIUS_STROKE_MULTIPLIER)
|
||||
@@ -6,16 +6,37 @@ 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()).
|
||||
##
|
||||
## T-1170 Ruling 5b (B3): course polylines ride `DistrictWindowLayer.courses`
|
||||
## — a SEPARATE data source from `_layer1` above (courses arrive on the
|
||||
## WINDOWED response, `viewer.get_district_window()`, not the whole-body
|
||||
## Layer-1 response this node requests via request_layer1()). _draw() is
|
||||
## therefore two INDEPENDENT gates, not one: the skeleton/basin/attractor
|
||||
## path gates on `_layer1 != null` (unchanged); the course path gates on
|
||||
## `viewer.get_district_window()` being a Dictionary with a `courses` key,
|
||||
## entirely independent of whether Layer-1 has arrived yet — a player who
|
||||
## descends straight to District without the whole-body fetch completing
|
||||
## still sees courses the moment the window arrives. NO T-1172 water clip on
|
||||
## this path (Ruling 3g) — courses carry real rung-consistent termini
|
||||
## server-side (the whole POINT of windowing course invention, Ruling 1d).
|
||||
##
|
||||
## 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 +80,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.
|
||||
@@ -142,8 +166,24 @@ func get_layer1() -> Variant:
|
||||
return _layer1
|
||||
|
||||
|
||||
## T-1170 (B3): TWO INDEPENDENT draw gates, not one — see the class doc's own
|
||||
## "two independent gates" paragraph. The skeleton/basin/attractor path
|
||||
## (Layer-1, whole-body) is unchanged from wave 1; the course path (windowed,
|
||||
## Ruling 5b) is a SEPARATE early-return chain reaching _draw_courses(),
|
||||
## checked regardless of whether `_layer1` has arrived — a player descending
|
||||
## straight into District/Quarter must see courses without waiting on the
|
||||
## whole-body Layer-1 fetch this node happens to also own.
|
||||
func _draw() -> void:
|
||||
if viewer == null or _layer1 == null:
|
||||
if viewer == null:
|
||||
return
|
||||
_draw_skeleton_path()
|
||||
_draw_course_path()
|
||||
|
||||
|
||||
## The pre-T-1170 draw gate, unchanged in shape: whole-body Layer-1
|
||||
## (rivers/basins/attractors), gated on `_layer1` having arrived.
|
||||
func _draw_skeleton_path() -> void:
|
||||
if _layer1 == null:
|
||||
return
|
||||
var rn: Variant = _layer1.get("river_network")
|
||||
if not rn is Dictionary:
|
||||
@@ -176,22 +216,114 @@ 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)
|
||||
|
||||
|
||||
## T-1170 Ruling 5b (B3): the District/Quarter windowed COURSE path — an
|
||||
## entirely separate data source (`viewer.get_district_window()`) and draw
|
||||
## gate from _draw_skeleton_path() above. Gated on `gen_rivers` (the SAME
|
||||
## overlay-bar toggle the skeleton path uses — one player-facing "rivers"
|
||||
## toggle covers both presentation surfaces, matching Araminta's ruling that
|
||||
## the two paths are one continuous feature from the player's perspective,
|
||||
## not two separate layers to independently show/hide).
|
||||
##
|
||||
## Region NEVER reaches this function's draw calls (course_class_visible_at_
|
||||
## rung() has no Region key, always false there — Region draws the skeleton
|
||||
## chord chain, never windowed course content, per Ruling 1). Tile mode
|
||||
## (the Region orbital mosaic) also never carries `district_window` data at
|
||||
## all (get_district_window() is single-window-mode-only, per that
|
||||
## accessor's own doc — courses simply never reach this path in tile mode by
|
||||
## construction, no separate is_tile_mode() guard needed here).
|
||||
func _draw_course_path() -> void:
|
||||
if not viewer.is_overlay_visible("gen_rivers"):
|
||||
return
|
||||
var window: Variant = viewer.get_district_window()
|
||||
if not window is Dictionary:
|
||||
return
|
||||
var w: Dictionary = window
|
||||
var courses: Variant = w.get("courses")
|
||||
if not courses is Array:
|
||||
return # missing `courses` field (old/pre-A2 payload) — draw nothing, see class doc
|
||||
var granularity_v2: String = str(w.get("granularity_v2", "District"))
|
||||
var ctx := {
|
||||
"held_center": viewer.get_held_center(),
|
||||
"held_n": viewer.get_held_n(),
|
||||
"cell_px": viewer.get_cell_pixel_size(),
|
||||
"granularity_v2": granularity_v2,
|
||||
"view_zoom": viewer.get_view_zoom(),
|
||||
}
|
||||
|
||||
for course: Variant in courses:
|
||||
if not course is Dictionary:
|
||||
continue
|
||||
_draw_one_course(course, ctx)
|
||||
|
||||
|
||||
## T-1170 Ruling 5b: one RiverCourse's polyline draw — class-filtered per
|
||||
## COURSE_CLASS_VISIBLE_BY_RUNG, width/opacity from the COURSE_CLASS_WIDTH_PX/
|
||||
## COURSE_CLASS_OPACITY companion tables (zoom-compensated via _zs(), the SAME
|
||||
## discipline every other marker in this cluster follows — PR #195's
|
||||
## stroke-width miss is the standing regression class this exists to
|
||||
## prevent). Points arrive as `Vec<(i32,i32)>` WORLD METRES (Ruling 3h, NOT
|
||||
## heightmap pixels — see world_m_to_canvas_local()'s own doc for why this is
|
||||
## one conversion step shorter than the skeleton path). NO T-1172 water
|
||||
## clip on this path (Ruling 3g) — courses carry real rung-consistent
|
||||
## termini server-side; that is the entire point of windowing course
|
||||
## invention (Ruling 1d).
|
||||
##
|
||||
## Terminus handling (Ruling 3h's CourseTerminus vocabulary):
|
||||
## - Mouth: double-ring at the LAST point (the real coast anchor — mouths
|
||||
## return as real geometry here, per Ruling 3g/3e).
|
||||
## - EdgeDrain: no ring (Ruling 3f — pole-edge drains are grid artifacts,
|
||||
## not river-meets-sea events; same disposition as the skeleton path's
|
||||
## EDGE_DRAIN sentinel).
|
||||
## - ContinuesBeyondWindow: draw to the last point, no marker (the course
|
||||
## keeps going outside this window's crop — nothing to mark AT this
|
||||
## window's edge, the polyline simply stops because the data stops).
|
||||
## - None: an ordinary interior terminus (a headwater/confluence anchor
|
||||
## inside this window) — no marker, same as ContinuesBeyondWindow's "just
|
||||
## stop drawing" treatment; the two differ in MEANING (why the points ran
|
||||
## out) but not in PRESENTATION (neither gets a ring).
|
||||
## The actual gating/construction (class visibility, point decode, terminus
|
||||
## lookup) is delegated to AtlasWindowGeometryNature.build_course_render_plan()
|
||||
## — a pure function with no draw calls, unit-tested directly in
|
||||
## test_atlas_window_geometry_nature.gd, the SAME split B2's
|
||||
## build_skeleton_chords() already established. This function's own job is
|
||||
## just the draw calls the plan feeds.
|
||||
func _draw_one_course(course: Dictionary, ctx: Dictionary) -> void:
|
||||
var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan(
|
||||
course, ctx["granularity_v2"], ctx["held_center"], ctx["held_n"], ctx["cell_px"]
|
||||
)
|
||||
if plan == null:
|
||||
return
|
||||
var canvas_pts: PackedVector2Array = plan["canvas_pts"]
|
||||
var cls: int = plan["cls"]
|
||||
var terminus: String = plan["terminus"]
|
||||
|
||||
var width: float = _zs_stroke(AtlasWindowGeometryNature.course_class_width_px(cls), ctx)
|
||||
var opacity: float = AtlasWindowGeometryNature.course_class_opacity(cls)
|
||||
var color := Color(COLOR_GEN_RIVER.r, COLOR_GEN_RIVER.g, COLOR_GEN_RIVER.b, COLOR_GEN_RIVER.a * opacity)
|
||||
draw_polyline(canvas_pts, color, width)
|
||||
|
||||
if terminus == AtlasWindowGeometryNature.COURSE_TERMINUS_MOUTH:
|
||||
_draw_mouth(canvas_pts[canvas_pts.size() - 1], ctx)
|
||||
# EdgeDrain/ContinuesBeyondWindow/None: no marker — draw to the last
|
||||
# point and stop, per the doc above.
|
||||
|
||||
|
||||
## Circumference in districts, for nearest_wrap_image()'s wrap resolution —
|
||||
## mirrors AtlasWindowOverlay._draw_tile_mosaic()'s own `cols` computation
|
||||
## exactly (same source, same reason: only meaningful in tile/orbital mode on
|
||||
@@ -204,12 +336,48 @@ 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()/
|
||||
## 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.
|
||||
## AtlasWindowGeometryNature.zoom_compensated_size() (see that function's own
|
||||
## doc for the "why divide" rationale). Every draw_circle()/draw_arc() RADIUS
|
||||
## in this file routes through this so Araminta's "constant on-screen size"
|
||||
## ruling holds at every rung/zoom. NOT for stroke widths — see _zs_stroke()
|
||||
## below, added T-1170 live round (2026-07-23) after the course-polyline
|
||||
## hairline finding: draw_line()/draw_polyline() STROKE WIDTH arguments have
|
||||
## a Godot-side rasterizer floor radii don't share (confirmed empirically —
|
||||
## zoom_compensated_stroke_width()'s own doc has the full A/B evidence).
|
||||
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"])
|
||||
|
||||
|
||||
## T-1170 live round (2026-07-23): the STROKE-WIDTH-specific sibling of
|
||||
## _zs() — every draw_line()/draw_polyline()/draw_arc() STROKE WIDTH
|
||||
## argument (never a radius/point-size) in this file routes through this
|
||||
## instead of _zs(), so the width never crosses Godot's ~1.0-canvas-local-
|
||||
## unit line-rasterizer floor and silently collapses to an
|
||||
## indistinguishable hairline. See
|
||||
## AtlasWindowGeometryNature.zoom_compensated_stroke_width()'s own doc for
|
||||
## the full live-repro evidence (the course-path pixel scan that found this:
|
||||
## a uniform 1px hairline with zero class/width variation in both District
|
||||
## and Quarter captures).
|
||||
func _zs_stroke(screen_space_size: float, ctx: Dictionary) -> float:
|
||||
return AtlasWindowGeometryNature.zoom_compensated_stroke_width(screen_space_size, ctx["view_zoom"])
|
||||
|
||||
|
||||
## T-1170 live round (2026-07-23, coordinator's mouth-ring blob finding): the
|
||||
## RING-RADIUS-specific sibling of _zs()/_zs_stroke() — every draw_arc() ring
|
||||
## marker whose radius and stroke are BOTH small, zoom-compensated values
|
||||
## (currently: the two _draw_mouth() rings) routes its RADIUS through this
|
||||
## instead of plain _zs(), so the radius never falls at-or-below its own
|
||||
## paired stroke width and degenerates from a hollow ring into a solid blob.
|
||||
## See AtlasWindowGeometryNature.zoom_compensated_ring_radius()'s own doc for
|
||||
## the full A/B bracket evidence (radius=1x stroke -> blob, 1.5x -> hollow,
|
||||
## floor set at 2x with margin). `stroke_canvas_local` is the ALREADY
|
||||
## zoom-compensated stroke value (this function's own caller passes
|
||||
## _zs_stroke()'s result, not a raw screen-space width) — the floor compares
|
||||
## against the SAME canvas-local units the naive radius divide produces.
|
||||
func _zs_ring_radius(screen_space_radius: float, stroke_canvas_local: float, ctx: Dictionary) -> float:
|
||||
return AtlasWindowGeometryNature.zoom_compensated_ring_radius(
|
||||
screen_space_radius, stroke_canvas_local, ctx["view_zoom"]
|
||||
)
|
||||
|
||||
|
||||
## Pixel (row, col) -> fractional district position, wrap-resolved against
|
||||
@@ -225,10 +393,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 +453,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 +485,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 +505,161 @@ 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_stroke(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:
|
||||
var ring_stroke: float = _zs_stroke(1.5, ctx)
|
||||
draw_arc(
|
||||
p, _zs(AtlasWindowGeometry.MOUTH_RING_RADIUS, ctx), 0.0, TAU, 18, COLOR_GEN_MOUTH, _zs(1.5, ctx)
|
||||
p,
|
||||
_zs_ring_radius(AtlasWindowGeometryNature.MOUTH_RING_RADIUS, ring_stroke, ctx),
|
||||
0.0,
|
||||
TAU,
|
||||
18,
|
||||
COLOR_GEN_MOUTH,
|
||||
ring_stroke
|
||||
)
|
||||
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
|
||||
)
|
||||
var halo_stroke: float = _zs_stroke(1.0, ctx)
|
||||
draw_arc(
|
||||
p,
|
||||
_zs_ring_radius(AtlasWindowGeometryNature.MOUTH_HALO_RADIUS, halo_stroke, ctx),
|
||||
0.0,
|
||||
TAU,
|
||||
22,
|
||||
halo,
|
||||
halo_stroke
|
||||
)
|
||||
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 +
|
||||
@@ -394,7 +685,7 @@ func _draw_basins(ctx: Dictionary) -> void:
|
||||
draw_colored_polygon(pts, COLOR_GEN_BASIN_FILL)
|
||||
var loop: PackedVector2Array = pts.duplicate()
|
||||
loop.append(pts[0])
|
||||
draw_polyline(loop, COLOR_GEN_BASIN_LINE, _zs(0.8, ctx), true)
|
||||
draw_polyline(loop, COLOR_GEN_BASIN_LINE, _zs_stroke(0.8, ctx), true)
|
||||
|
||||
|
||||
## Attractors — Region only, wave 1 (per the ruling; District/Quarter never
|
||||
@@ -410,7 +701,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:
|
||||
@@ -418,7 +709,7 @@ func _draw_attractors(ctx: Dictionary) -> void:
|
||||
var p: Vector2 = _pos(float(pos_rc[0]), float(pos_rc[1]), ctx)
|
||||
var size: float = _zs(5.0 + strength * 4.0, ctx)
|
||||
var color: Color = AtlasOverlayColors.sub_biome_color(str(a.get("sub_biome", "")))
|
||||
_draw_attractor_shape(str(a.get("attractor_type", "")), p, size, color, _zs(1.0, ctx))
|
||||
_draw_attractor_shape(str(a.get("attractor_type", "")), p, size, color, _zs_stroke(1.0, ctx))
|
||||
|
||||
|
||||
## Attractor type -> marker shape — from the retired atlas_marker_overlay.gd
|
||||
|
||||
@@ -504,6 +504,7 @@ func _on_window_ready(window: Dictionary) -> void:
|
||||
_legend_panel.refresh()
|
||||
queue_redraw()
|
||||
_overlay_node.queue_redraw()
|
||||
_nature_overlay.queue_redraw() # T-1170 B3: courses read THIS window too
|
||||
|
||||
|
||||
## T-1153 (design doc §4 "progressive... with visible refinement as tiles
|
||||
|
||||
@@ -14,9 +14,21 @@ extends RefCounted
|
||||
## clip against whichever composite cell is currently ON SCREEN at a given
|
||||
## river dot's position — strict drop, no snap (a dot that lands on drawn
|
||||
## water is simply not drawn; Region's 205 km cells may amputate a river's
|
||||
## final coastal dots, an accepted cost per the ruling). T-1172 clip —
|
||||
## retire when T-1170 course invention terminates courses at the invented
|
||||
## coast.
|
||||
## final coastal dots, an accepted cost per the ruling).
|
||||
##
|
||||
## T-1170 Ruling 3g update (RESTRUCTURED, not blanket-retired): the clip is
|
||||
## RETIRED for the District/Quarter COURSE-drawing rungs
|
||||
## (AtlasWindowNatureOverlay._draw_course_path()/_draw_one_course()) —
|
||||
## courses carry real rung-consistent termini invented server-side against
|
||||
## the SAME rung's drawn coast, so the clip's job is already done there. This
|
||||
## file's clip machinery is STILL LIVE and used at the Region SKELETON path
|
||||
## (_draw_skeleton_chords()/_segment_touches_drawn_water()) — Region still
|
||||
## draws the whole-body skeleton against a rung-dependent drawn coast, which
|
||||
## is precisely the presentation-frame reconciliation this file exists for.
|
||||
## The Region clip is PERMANENT-UNTIL-REGION-GOES-WINDOWED (T-1143 ruling 2's
|
||||
## progressive tiling) — when Region itself becomes a windowed rung, it
|
||||
## inherits windowed courses too, and this file retires entirely at that
|
||||
## point, not before.
|
||||
##
|
||||
## const AtlasWindowWaterClip := preload("res://ui/implant/apps/atlas/atlas_window_water_clip.gd")
|
||||
|
||||
|
||||
@@ -156,6 +156,20 @@ Release build, this session, body GJ338Bd, seed "yolo", 16 cores / Rayon pool 14
|
||||
| Octave-cutoff derive (`min_wavelength_m`-bearing) | — | not measured | **UNBUILT, UNMEASURED** — every planetary-rung and canvas-sampling latency claim resting on "cheaper because fewer octaves" is an unmeasured extrapolation on top of code that doesn't exist yet |
|
||||
| Full-canvas planetary sample, 1920×1080 (~2.07M cells) | — | ~2.6–3.0 s single-thread; ~0.2–0.3 s parallel (claimed) | **ESTIMATED, UNVERIFIED** — depends on both the unbuilt cutoff and the unbenchmarked parallel throughput above; treat as directional only |
|
||||
|
||||
> **UNMEASURED gap closed (2026-07-23, T-1170 A2 discipline item 1 — Dudley).** `zoom_ladder_bench.rs`'s three `#[ignore]`d benches (dormant since T-1149/T-1152) were run `--release --ignored` on this session's hardware (16 cores, same class of machine as the §7 table above) before the T-1170 course inventor landed, per the batch's own discipline requirement. Numbers below are **MEASURED**, not extrapolated — this table row is retired as UNBUILT/UNMEASURED for the octave-cutoff case specifically (the cutoff plumbing has existed since T-1162; what was missing was ever actually running the bench):
|
||||
>
|
||||
> | Sweep (64×64 = 4,096 cells, release) | Total | ns/cell | µs/cell |
|
||||
> |---|---|---|---|
|
||||
> | District spacing (2,048 m), cutoff=0 (uncut) | 12.33 ms | 3,009.4 | 3.009 |
|
||||
> | District spacing (2,048 m), cutoff=2,048 m | 7.31 ms | 1,784.7 | 1.785 |
|
||||
> | Quarter spacing (512 m), cutoff=512 m | 7.47 ms | 1,822.9 | 1.823 |
|
||||
> | Orbital (`derive_orbital_at_metres`), Region spacing (204.8 km) | 5.96 ms | 1,454.2 | 1.454 |
|
||||
> | Full `derive_at_metres` (invention included) at Region spacing, for comparison | 11.18 ms | 2,729.4 | 2.729 |
|
||||
> | Orbital speedup vs. full derive at the same (Region) spacing | — | — | **1.88×** |
|
||||
> | Served Region window at `DISTRICT_WINDOW_MAX_N_REGION` (n=6,400 → 64×64=4,096 cells, `WIRE_CAP_CELLS` capped, real production `build_district_window_layer` path, row-chunked `par_iter`, 16 Rayon threads) | 12.35 ms / 20 calls | — | **0.617 ms/call** |
|
||||
>
|
||||
> Extrapolated full-canvas figures (1600×900 @ 1/2 px-per-cell) from the orbital per-cell rate: ~2,094 ms single-thread @1px/cell, ~524 ms @2px/cell — still **EXTRAPOLATED**, not independently measured at full canvas size (that remains a separate, not-yet-run measurement; the per-cell rate itself is now real). The octave cutoff itself is confirmed cheaper (cutoff=2,048 m runs at ~59% of the uncut cost at District spacing) rather than assumed cheaper. This closes the "not load-bearing for T-1170's design, but the gap closes now" item from the T-1170 ruling's Discipline §1 — the course inventor's Stage A/B split does not depend on these numbers (it deliberately avoids per-candidate `derive_at_metres` calls, Ruling 3b), but the gap this table flagged is no longer open.
|
||||
|
||||
**Interactive-latency verdict:** district and quarter rungs (capped) are comfortably interactive on a warm `TerrainAnalysis` — 1–5 ms per response at current cap sizes. The **only** rung with a real latency question is planetary/canvas-fill, and its numbers are two extrapolation-hops from anything actually measured this session. **No implementation should proceed on the planetary rung's cost story without first (a) prototyping the octave cutoff and re-measuring, and (b) chunked-`par_iter`-ing the window loop and re-measuring** — both are cheap to do (the probe binary already exists) and should happen before, not during, the follow-up ticket.
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
---
|
||||
title: "River Course Invention — T-1170/T-1168 Design Ruling"
|
||||
description: Binding design ruling for course invention + riparian vegetation (Tyre, from the three-audit design pass)
|
||||
type: design
|
||||
status: binding
|
||||
round: T-1170
|
||||
created: 2026-07-23
|
||||
---
|
||||
|
||||
*cracks knuckles* — I've read both tickets in full, the D-226 amendment block (wave-1 carrier note, two-waterline note, T-1137 queue ruling, windowed-family ceiling), D-227, D-243, and the T-1143 zoom-ladder serving model. The audits are code-true and the tension called out in point 1 is real — I created it, and it resolves cleanly once the carrier rule is split on the right axis. Ruling follows.
|
||||
|
||||
---
|
||||
|
||||
# TYRE — Binding Design Ruling: T-1170 (river course invention) + T-1168 (riparian vegetation)
|
||||
|
||||
**Date:** 2026-07-23. **Status:** binding for this batch; implementers code from this directly. Governance capture text in Ruling 6.
|
||||
|
||||
**The keystone insight, stated once up front:** the framework tension between my wave-1 carrier note ("discrete geometry rides whole-body") and my two-waterline note ("invention detail is rung-indexed") dissolves when you split on the correct axis. The wave-1 rule was written for the **skeleton** — a fixed, finite, rung-independent graph, computed once, valid forever. Course geometry is not skeleton; it is **invention** — the linear sibling of the coast crinkle, refined per rung by construction. Skeleton rides whole-body; invention rides the window. The rule wasn't wrong, it was under-specified, and this ruling refines it rather than reversing it.
|
||||
|
||||
---
|
||||
|
||||
## Ruling 1 — Invention locus and wire carrier: server-side, per-window, inside `DistrictWindowLayer`
|
||||
|
||||
**1a. Locus: server, per-window, at serve time — Dudley's option (b). Binding.** Course geometry is invented inside the window derive (`GenWorkItem::DeriveWindow`, background queue per T-1137 — queue discipline [HARD], unchanged), at the window's granularity and `min_wavelength_m`. Dudley's numbers close this: +0.09–0.21 ms against a measured ~5 ms window baseline, under 5% — noise. Whole-body precompute at Quarter detail is **rejected** on payload grounds (~4.4 MB dense-body for geometry 99% never viewed — categorically the wrong shape under D-227 derive-don't-store). Client-side GDScript invention is **rejected** on the determinism surface, not CPU: a byte-exact two-language mirror of `splitmix64`/`value_noise`/octave tables is a standing D-010/D-227 liability the project has deliberately avoided everywhere else, purchased for a compute saving option (b) proves unnecessary.
|
||||
|
||||
**1b. Carrier: a new vector field inside the existing windowed payload. The windowed-family ceiling is untouched — and here is why, precisely.** The ceiling ([HARD], D-226 T-1124 §2) counts **windowed-query fields on `AtlasLayerResponse`**. It exists to prevent a second concurrently-in-flight windowed *query* needing per-field request correlation. Course geometry is not a second query: it is **content of the one windowed payload**, keyed by the same `(body, center, n, granularity)` echo, arriving in the same response, stale-discardable by the same rule. `DistrictWindowLayer` gains `courses: Vec<RiverCourse>` (`#[serde(default)]`), exactly as it holds six dense arrays today. No ceiling impact, no tagged-envelope migration, no new request class.
|
||||
|
||||
**1c. The carrier rule, refined (this is the governance-grade restatement — capture text in Ruling 6):**
|
||||
|
||||
> Three-way, replacing the wave-1 two-way rule: **(i)** rung-independent discrete features — skeletons, graphs, point markers, computed once and valid forever — ride the whole-body overlay family, filtered per-rung client-side. **(ii)** Continuous per-metre fields ride the windowed per-cell arrays. **(iii)** **Rung-indexed invented detail rides the windowed payload regardless of geometric kind** — raster or vector — because rung is a *request parameter*, and only the windowed query carries one. The coast crinkle has always followed (iii) implicitly (it arrives baked into windowed cell verdicts); course polylines are its vector sibling, arriving alongside them.
|
||||
|
||||
**1d. The two-waterline problem dissolves under this carrier.** A whole-body course would need a rung-parametrized terminus (the fork the audit flagged as sharpest). A windowed course does not: the server invents the course *at a known rung*, in the same pass that computes that rung's per-cell water verdicts — so the terminus is resolved against the drawn coast **at that rung**, server-side, consistently. Nothing whole-body ever carries a terminus. The skeleton stays rung-independent, as the two-waterline note requires.
|
||||
|
||||
**1e. Window-independence invariant (binding, tested).** The geometry is a pure function of `(seed, body, edge, rung)` — **never** of the window rect. Stations are generated at deterministic global arc-length positions along the edge; the window *crops*, it never re-parametrizes. Two overlapping windows at the same granularity must produce byte-identical points for the shared stretch. This is the course analog of the coast warp's absolute-world-metre keying and it is a mandatory determinism test (see Discipline).
|
||||
|
||||
---
|
||||
|
||||
## Ruling 2 — Adjacency: persisted downstream pointer. Reconstruction rejected.
|
||||
|
||||
**2a.** `RiverNetwork` gains `river_downstream: Vec<u8>` — additive, `#[serde(default)]`, parallel to `river_cells` (the exact `river_class` precedent). Captured in `extract_river_network` (`drainage.rs:247-349`) where `fdir[i]` is already in scope and currently discarded after its boolean use. One `.map()`, zero new grid passes, ~1 byte/river cell. This is the **opening move** of the batch — everything else consumes it.
|
||||
|
||||
**2b. Reconstruction is rejected** on the Si audit's confluence finding: adjacency alone cannot distinguish inflow from outflow at a 3-river-neighbor confluence, and re-deriving direction from elevation is re-running D8 badly. We had the true answer in hand and threw it away; stop throwing it away. The D-203 memory-frugality rationale for discarding covered the 131 KB *full grid*, not the river-cell projection.
|
||||
|
||||
**2c. Sentinel vocabulary (values in the u8 space above 7):** `MOUTH` (flow reaches raw-sea) · `EDGE_DRAIN` (flow exits the grid's top/bottom edge — a grid artifact, *not* a mouth, per Ruling 3f) · `TERMINAL` — **reserved now, unused in round 1** — flow ends in an interior sink (future endorheic basin / inland delta, see Ruling 7b). Reserving the value costs one enum arm and keeps the future option additive instead of a wire migration.
|
||||
|
||||
**2d.** Each edge is uniquely identified by its **upstream cell** (every river cell has exactly one downstream pointer), so `edge_id = pack(u16,u16) → u32`. This is the seeding key (Ruling 3a).
|
||||
|
||||
---
|
||||
|
||||
## Ruling 3 — Course algorithm
|
||||
|
||||
**3a. Seeding (D-227/D-010 discipline).** `SeedChain::for_body(...).derive(SeedDomain::RiverCourse, edge_id as u64)`, new `SeedDomain` variant, with a distinct `RIVER_COURSE_WARP_SALT` on the noise stream (the `COAST_WARP_SALT` pattern verbatim) — never correlated with coast warp, terrain scatter, or vegetation massif fields at the same position. Pure `(seed, body, edge, rung)`.
|
||||
|
||||
**3b. Two-stage shape — and the stage split is load-bearing for cross-rung stability:**
|
||||
|
||||
- **Stage A — coarse valley-seeking control path. Rung-INDEPENDENT.** Between the two anchor points (upstream/downstream cell centres — skeleton truth, never moved), place control stations at chord/8 (~9.6 km). At each, evaluate k=5 candidate perpendicular offsets within the amplitude envelope; score = bilinear `ta.elev_pct` at the candidate (plus a small continuity penalty against the previous chosen offset to prevent zigzag); pick minimum. **The elevation proxy is the bilinear `TerrainAnalysis` read — the same tradeoff the coast warp already made at `district_profile.rs:1152-1157` — NOT `derive_at_metres` per candidate.** Dudley's numbers make this affordable (+µs/segment); the Si audit's unmeasured-derive-cost gap is thereby not load-bearing for this design (we still close the measurement gap — see Discipline). Stage A is computed identically at every rung, so the coarse course never moves as you refine.
|
||||
- **Stage B — fine warp octaves. Rung-INDEXED.** Perpendicular scalar displacement of intermediate stations via salted multi-octave value noise keyed on global arc-length, octave band from ~chord/2 (~38 km) down to the window's `min_wavelength_m` hard-truncate (the `warp_fbm` cutoff idiom, T-1162 rung discipline, reused exactly). Station spacing = the rung's sample spacing (2,048 m District / 512 m Quarter — Dudley's ~37/~150 points per segment), at global arc-length multiples so windows agree (Ruling 1e).
|
||||
|
||||
**Cross-rung invariant that falls out:** the Quarter course is the District course plus octaves in the (1,024 m..4,096 m) band — displacement between rungs is bounded by the truncated-octave amplitude sum, sub-cell at District spacing. And at Region spacing no octaves survive at all, so **the rung-truncated course degenerates to the straight chord — which means the client's Region-rung skeleton-chord drawing (Ruling 5a) IS the course at that rung**, not an approximation. That's the kind of unification I get excited about: one function, every rung, no special cases.
|
||||
|
||||
**3c. Amplitude envelope.** Sine-shaped taper to **zero at both endpoints** — this is what makes confluences work for free: every edge into and out of a cell meets exactly at the cell-centre anchor, C0-continuous, and the confluence marker draws at the anchor. Peak amplitude ≤ ~8% of chord (~6 km), scaled down by local `ta.slope_deg` (steep terrain → straighter) and up modestly by `river_class` (trunks meander wider). All tunables; taper-to-zero and the ≤ half-cell hard cap are binding.
|
||||
|
||||
**3d. Valley preference is IN for round 1** (costed at noise level by Dudley), via Stage A only. This is the round-1 answer to the wave-1 "rivers may cross invented hills" gap: courses *prefer* invented-terrain valleys at coarse wavelengths; residual fine-octave conflicts (a course crossing a `voxel_relief` hillock) are **accepted and documented**. The converse mechanism — terrain invention becoming course-aware (valley carving along courses) — is explicitly deferred; it belongs at the block/tile rungs and Phase 5 fill, noted in governance as the future direction, not built now.
|
||||
|
||||
**3e. Termination at the invented coast (the T-1172 retirement mechanism).** For an edge whose downstream sentinel is `MOUTH`: walk stations upstream→downstream, sampling the **same rung-consistent morphology water verdict the window's own cells use** (the `open_water`/morphology path, at the window's `min_wavelength_m` — not raw `ocean_frac`, so the terminus agrees with what is drawn). First water station → bisect against the previous land station (fixed 6 iterations) → terminus point, flagged `Mouth` on the wire. If the final anchor still samples land at this rung (drawn coast receded past the raw-sea cell — the inland-mouth oddity, fixed by construction here), extend along the D8 direction up to one cell length probing; if still no water, terminate at the anchor with **no** mouth flag (degenerate, rare, bounded). A few dozen probes per mouth edge, on the background queue — within the T-1137 discipline by construction.
|
||||
|
||||
**3f. Pole-edge drains are not mouths. Binding.** `EDGE_DRAIN` termini get no mouth flag and no double-ring; the course ends at the last in-grid station. Server-side, `extract_river_network` stops classifying grid-edge exits into `mouths` (deliberate Layer-1 golden re-pin, see Discipline). They are grid artifacts, exactly as Jeroen's capture question suspected.
|
||||
|
||||
**3g. T-1172 clip: restructured, not blanket-deleted.** The three marker sites in `atlas_window_nature_overlay.gd` (lines 288/311/347) retire **for the course-drawing rungs** (District/Quarter) — courses carry real rung-consistent termini, the clip's job is done there, and suppressed offshore mouths return as real geometry. The **Region-rung skeleton path keeps a clip**: Region still draws whole-body skeleton against a rung-dependent drawn coast, which is precisely the presentation-frame reconciliation the two-waterline note prescribes. Rewrite the marker comments: the surviving clip is documented as permanent-until-Region-goes-windowed (T-1143 ruling 2's progressive tiling), at which point Region inherits windowed courses and the last clip retires.
|
||||
|
||||
**3h. Wire shape.** `RiverCourse { edge_id: u32, class: u8, points: Vec<(i32,i32)> /* world-metres */, terminus: CourseTerminus /* None | Mouth | EdgeDrain | ContinuesBeyondWindow */ }`. Only edges whose chord-inflated-by-max-amplitude bounding box intersects the window ship; points cropped to window + one station beyond each edge of it. ~1–2 KB typical per window. Well inside every budget.
|
||||
|
||||
---
|
||||
|
||||
## Ruling 4 — T-1168: shape and sequencing
|
||||
|
||||
**4a. The mechanism: a scale-free point-sample distance test. This dissolves the blotch problem rather than mitigating it.** `near_perennial_water(sample_pos) = distance(sample_pos, nearest invented course polyline) ≤ band_m`, where `band_m` defaults to D-239 §8's governed 1–3 tiles (class-scaled: thicket band for trunks, scrub band for streams — tunable constants, governed default). Because the test is a pure point predicate against real course geometry, it is **automatically correct at every sampling density**: at 512 m Quarter spacing it essentially never fires (the band is sub-cell — honest, no over-fattening, the exact Lendel-failure discipline), and at 1 m tile spacing it fires on exactly the 1–3 tile bank strip. No per-rung riparian policy exists or is needed. The audit's skeleton-disc model — any "within N km of a 76 km-spaced skeleton point" test — is **rejected outright**: it is off by 3–4 orders of magnitude from the governed band and cannot be tuned into anything but green blotches.
|
||||
|
||||
**4b. Consequently the 512×256 distance-field-over-grid proposal is also rejected** — a grid quantized at 76 km cannot express a metres-scale band. The test runs against course polylines resident in the derive context: in the window path, T-1170's already-invented courses (coarse bounding-box cull, then point-to-segment distance — most cells cull to zero edges); in the batch path, courses for edges near the district, invented on demand via the same pure function (cachable per derive batch). The Si audit's observation that `gen_queue.rs:626` discards the `Layer1Output` **still gets fixed** — the window path needs `RiverNetwork` (cells + downstream pointers) resident to know which edges exist; retain it in `TerrainAnalysisCache` instead of re-deriving-and-discarding.
|
||||
|
||||
**4c. Hard sequencing: T-1168's riparian test lands AFTER T-1170's course inventor, full stop.** The test is meaningless without course geometry (4a). The **threading scaffolding is exempt** and proceeds in parallel — it is mechanical and fully mapped by the audit: `build_district_profile` gains the riparian signal parameter consumed at `district_profile.rs:1469`; threaded via `derive_all_districts` → `derive_district_profile` (batch, sourced from `cascade.rs`'s already-unpacked `river_network`, the `road_graph` precedent) and `build_district_window_layer` → `derive_window_cell` → `derive_at_metres`/`derive_orbital_at_metres` (window, sourced from the retained `Layer1Output`). Until the course inventor lands, the provider returns "no course in range" — wired but inert, zero behavioral change, zero golden churn.
|
||||
|
||||
**4d. T-1162 composition contract, re-affirmed as binding:** `near_perennial_water` is a separate boolean/geometry signal into `derive_vegetation`; it never touches `moisture_q`, and `moisture_perturb_q` never reads river data. The existing parameter separation already enforces this — the implementer's only job is not to break it. Climate gating stands: extreme-cold Barren beats riparian (ice is geology per D-227); `Marine`/airless precedence unchanged.
|
||||
|
||||
**4e. Experience honesty (what Jeroen will see):** after this batch, District/Quarter windows show continuous meandering rivers with real coast mouths. They will **not** show visible green riparian corridors — under the governed 1–3 tile band that is sub-cell at every Atlas rung, and honestly so. T-1168's visible payoff arrives at the block/tile rungs (opened by T-1143 ruling 1) and Phase 5 ground truth. If Atlas-visible green corridors are *wanted*, that is a design widening of D-239 §8 — flagged to Jeroen in Ruling 7a, one constant away, not an architecture change.
|
||||
|
||||
---
|
||||
|
||||
## Ruling 5 — Per-rung presentation surface
|
||||
|
||||
**5a. Region and coarser (skeleton path, whole-body `layer1`):** the client upgrades dots to **connected straight chords** via `river_downstream` — which per Ruling 3b *is* the rung-truncated course, so this is not an approximation, it is the course at Region truncation. Clip retained (Ruling 3g). Confluence/mouth markers from the skeleton lists (mouths now excluding pole drains).
|
||||
|
||||
**5b. District and Quarter (course path, windowed):** polylines from `DistrictWindowLayer.courses` via the existing `_pos()`/`draw_polyline` plumbing (`_draw_basins` precedent — no new transform code). Mouth markers at `Mouth` termini. No clip. **Quarter rivers return** — the streams class draws at Quarter, executing exactly the pre-announced fade-down revisit from the wave-1 visibility note.
|
||||
|
||||
**5c. The revisit point is preserved and split:** `RIVER_CLASS_VISIBLE_BY_RUNG` is replaced by two tables in `atlas_window_geometry.gd` — `SKELETON_CLASS_VISIBLE_BY_RUNG` (Region+, path 5a) and `COURSE_CLASS_VISIBLE_BY_RUNG` (District/Quarter, path 5b), with per-class width/opacity companion tables. **This is where Araminta's forthcoming presentation ruling plugs in** — implementers land functional defaults (trunk widest, streams thinnest, class-filtered per rung: District trunk+tributary, Quarter all three), and Araminta's ruling edits those tables and only those tables. Same single-revisit-point discipline as wave 1.
|
||||
|
||||
---
|
||||
|
||||
## Ruling 6 — Governance captures (exact text)
|
||||
|
||||
Append to the D-226 amendment block (`governance/decisions/architecture.md`), dated 2026-07-23:
|
||||
|
||||
> **Course-invention carrier note (T-1170, 2026-07-23 — Tyre):** river course geometry below D8 resolution is **invention, not skeleton**, and rides the **windowed payload** as a vector field inside `DistrictWindowLayer` (`courses`, additive) — invented server-side per window on the background queue (T-1137 discipline) at the window's rung, terminated server-side against the same rung's morphology water verdict the window's cells carry. This does **not** count against the windowed-family ceiling (§2 [HARD]): the ceiling counts windowed *query* fields on `AtlasLayerResponse`; courses are content of the single existing windowed payload, same echo key, same staleness semantics. The wave-1 carrier rule is hereby refined three-way: **(i)** rung-independent discrete features (skeletons, graphs, markers — computed once, valid forever) ride the whole-body family, filtered per-rung client-side; **(ii)** continuous per-metre fields ride the windowed per-cell arrays; **(iii)** rung-indexed invented detail rides the windowed payload regardless of geometric kind, because rung is a request parameter and only the windowed query carries one — the coast crinkle has always implicitly been (iii); courses are its vector sibling. The two-waterline note's reconciliation resolves per-rung server-side for courses (the terminus is windowed content at a known rung); the Region-rung skeleton path retains the presentation-frame clip until Region itself goes windowed (T-1143 ruling 2), which retires the last clip site. The skeleton itself gains `river_downstream` (additive per-river-cell D8 pointer with `MOUTH`/`EDGE_DRAIN`/reserved-`TERMINAL` sentinels) on `RiverNetwork` — whole-body, rung-independent, per rule (i). Pole-edge drains are grid artifacts, not mouths — excluded from `mouths` at extraction.
|
||||
|
||||
Append to D-227's amendment trail:
|
||||
|
||||
> **Amended 2026-07-23 (T-1170 — river courses join the invention family):** the "invented deterministically" clause now covers linear features: river courses between D8 cells are pure `(seed, body, edge, rung)` functions — a rung-independent valley-seeking coarse path (bilinear `TerrainAnalysis` proxy, never full re-derivation) plus rung-indexed perpendicular warp octaves under the `min_wavelength_m` truncation discipline, distinct salted stream, amplitude tapered to zero at cell-centre anchors (confluence continuity), stations at global arc-length positions (window-independent). Round-1 relief reconciliation is course-follows-terrain (valley preference); terrain-carves-for-course is the deferred converse, targeted at block/tile rungs.
|
||||
|
||||
Also: fix the stale `scale.rs:107` grid-shape comment (one line, audit nit) in whichever PR touches that file first.
|
||||
|
||||
---
|
||||
|
||||
## Ruling 7 — Jeroen items
|
||||
|
||||
**7a. Riparian visibility at Atlas rungs (genuine design call).** Under D-239 §8's governed 1–3 tile band, riparian vegetation is honestly invisible at every Atlas rung (Ruling 4e) — the drawn river itself is the only visible river response at District/Quarter. If you want the Nile effect — a visible green corridor at Atlas scale — that is a *design widening* of the band (e.g. floodplain-riparian corridors of hundreds of metres on alluvial-plain morphology, class- and morphology-scaled). The mechanism ships scale-free, so this is one governed constant/function away, zero architecture change. Your call, not blocking this batch.
|
||||
|
||||
**7b. Endorheic basins (one paragraph, as asked).** This design does not foreclose deliberate endorheic basins or inland deltas — it deliberately cheapens the future path. The `TERMINAL` downstream sentinel is reserved now (Ruling 2c), so an interior sink becomes an additive drainage-extraction change, not a wire migration; and the termination mechanism (walk stations to the first rung-consistent *water* verdict — which is the morphology verdict, covering lakes, not just ocean) generalizes unchanged to a terminal lake shoreline. When you want endorheic basins, the work is in D8 sink retention and lake morphology, and every piece this batch builds — pointers, courses, termination, riparian — consumes them without modification. Nothing to design today.
|
||||
|
||||
---
|
||||
|
||||
## Discipline — benches, goldens, determinism tests
|
||||
|
||||
1. **Run the dormant bench first.** `zoom_ladder_bench.rs` `--release --ignored`, before the course inventor merges; record the numbers in `docs/architecture/atlas-zoom-ladder-t1143.md` (which itself flags them UNMEASURED). Cheap, closes a documented gap this batch would otherwise inherit blind. Not load-bearing for the design (Ruling 3b avoided depending on it) but the gap closes now.
|
||||
2. **Course-cost bench:** window derive with courses on/off; assert the delta stays under ~5% of the window baseline (Dudley's envelope).
|
||||
3. **Determinism tests (mandatory, new):** (a) overlapping-windows agreement — two windows sharing an edge produce byte-identical course stations in the overlap (Ruling 1e); (b) cross-rung stability — Quarter course points within the truncated-octave amplitude bound of the District polyline (Ruling 3b); (c) salt isolation — course stream uncorrelated with coast warp at shared positions (the existing isolation-test pattern).
|
||||
4. **Goldens, deliberate re-pin discipline** (`UPDATE_GOLDEN=1` + rationale comment, the T-1162 precedent): `cascade_golden` re-pins (mouths list loses pole drains; `river_downstream` added). `window_derivation_golden`: first **verify empirically** whether the 128×64 fixture produces river cells near the sweep positions (audit: unconfirmed); add a course-bearing sweep position either way so courses are actually pinned. `believability`: expected unchanged (the point test cannot fire at district spacing) — **verify, don't assume**; an unexpected re-pin there is a bug signal, not a regen chore.
|
||||
5. **Full `cargo test` before push** (the golden harnesses are separate test binaries; `--lib` misses them), and the live-screenshot pixel-diff eyeball check on the merged result, cold-launched — this batch is rendered-path.
|
||||
|
||||
---
|
||||
|
||||
## Work split and sequencing
|
||||
|
||||
**Dudley — server (owns the cost model he probed):**
|
||||
- **A1** (opening move, unblocks everything): `river_downstream` + sentinels + pole-drain mouth exclusion + Layer-1 golden re-pin.
|
||||
- **A2**: course inventor (Ruling 3a–3d, 3h) in the window derive + `Layer1Output` retention in `TerrainAnalysisCache` + wire field.
|
||||
- **A3**: termination + mouth flags (3e–3f) — with A2.
|
||||
- **A5** (after A2): T-1168 riparian point test wired into the threaded signal, batch + window paths.
|
||||
|
||||
**Stig — client + mechanical threading:**
|
||||
- **B1** (parallel with A2): T-1168 threading scaffolding (Ruling 4c), inert provider, zero behavior change.
|
||||
- **B2** (needs A1 only): Region skeleton-chord drawing.
|
||||
- **B3** (needs A2/A3): course polyline drawing, mouth markers, clip restructure (3g), visibility-table split (5c) with functional defaults for Araminta.
|
||||
|
||||
**Hard edges:** A1 → everything. A2 → {A3, A5, B3}. B1 ∥ A2, converging at A5. Nothing else serializes.
|
||||
|
||||
**Difficulty tier, honestly: challenging but doable — and the architecture is on our side.** Every primitive exists (`value_noise`, `splitmix64`, the cutoff idiom, the queue, the polyline draw path); this batch composes them, it doesn't invent machinery. The one place I'd have called "extremely difficult" — reconciling a whole-body course with a rung-indexed coastline — stopped being a problem the moment courses went windowed. That's the ruling doing the work.
|
||||
|
||||
— Tyre
|
||||
File diff suppressed because one or more lines are too long
@@ -54,8 +54,87 @@ pub struct RiverNetwork {
|
||||
/// empty, never a decode error (the additive T-1124 §1 pattern).
|
||||
#[serde(default)]
|
||||
pub river_class: Vec<u8>,
|
||||
/// Per-`river_cells`-entry D8 downstream pointer (T-1170 Ruling 2a) — same
|
||||
/// index, same length as `river_cells`/`river_class`. Each river cell has
|
||||
/// exactly one downstream direction, so this is the exact same shape as
|
||||
/// `river_class`, captured in `extract_river_network` where `fdir[i]` is
|
||||
/// already in scope (one `.map()`, no new grid pass).
|
||||
///
|
||||
/// **Values 0–7:** an index into `drainage::D8` — the downstream neighbor
|
||||
/// direction, i.e. "this river cell flows toward `D8[value]`".
|
||||
///
|
||||
/// **Sentinel values above 7 (Ruling 2c):**
|
||||
/// - [`RIVER_DOWNSTREAM_MOUTH`] — flow reaches a raw-sea cell (the river's
|
||||
/// mouth in the D8 sense; T-1170's course inventor walks stations from
|
||||
/// here to the invented-coast terminus, Ruling 3e).
|
||||
/// - [`RIVER_DOWNSTREAM_EDGE_DRAIN`] — flow exits the grid's top/bottom
|
||||
/// edge. This is a **grid artifact, not a mouth** (Ruling 3f) — pole-edge
|
||||
/// exits are deliberately excluded from `mouths` at extraction (see
|
||||
/// `extract_river_network`'s doc).
|
||||
/// - [`RIVER_DOWNSTREAM_TERMINAL`] — **reserved, unused in round 1.** Flow
|
||||
/// ends in an interior sink (future endorheic basin / inland delta,
|
||||
/// Ruling 7b). Reserving the value now keeps that future path additive
|
||||
/// (no wire migration) rather than requiring a new sentinel later.
|
||||
///
|
||||
/// Why persist rather than reconstruct from adjacency alone (Ruling 2b):
|
||||
/// at a 3-river-neighbor confluence, adjacency cannot distinguish inflow
|
||||
/// from outflow, and re-deriving direction from elevation is re-running D8
|
||||
/// badly — the true answer (`fdir[i]`) is already computed and in scope at
|
||||
/// extraction time; discarding it and re-deriving later is strictly worse
|
||||
/// than keeping the ~1 byte/river-cell projection. The former D-203
|
||||
/// memory-frugality rationale for discarding covered the 131 KB *full*
|
||||
/// `fdir` grid, not this per-river-cell projection.
|
||||
///
|
||||
/// `#[serde(default)]` — same additive pattern as `river_class`: an absent
|
||||
/// array (pre-T-1170 payload/fixture) decodes to empty, never an error.
|
||||
#[serde(default)]
|
||||
pub river_downstream: Vec<u8>,
|
||||
/// Per-`river_cells`-entry seaward neighbor position (T-1170 PR #197
|
||||
/// review, Hoshe #1) — same index/length shape as `river_class`/
|
||||
/// `river_downstream`. **Only meaningful where `river_downstream[i] ==
|
||||
/// RIVER_DOWNSTREAM_MOUTH`**; every other entry (interior direction or
|
||||
/// `EDGE_DRAIN`) carries the placeholder `(0, 0)` and must not be read.
|
||||
///
|
||||
/// **Why this exists — the second instance of the Ruling-2b anti-pattern.**
|
||||
/// `extract_river_network` already computes the real sub-sea-level
|
||||
/// neighbor `(nr, nc)` to decide the `MOUTH` sentinel (the elevation
|
||||
/// check that flips `river_downstream[i]` to
|
||||
/// [`RIVER_DOWNSTREAM_MOUTH`]) — the exact same "true answer already in
|
||||
/// scope, then discarded" shape Ruling 2b fixed for interior D8 pointers
|
||||
/// via `river_downstream` itself. Discarding `(nr, nc)` a second time
|
||||
/// left `river_course::build_edges` with nothing to build a real chord
|
||||
/// from for Mouth edges: it filled `downstream = upstream` as a
|
||||
/// placeholder, which zeroes the chord (`chord_m < 1.0`), which trips
|
||||
/// `invent_course`'s single-point degenerate return, which makes
|
||||
/// `resolve_mouth_terminus`'s station walk a no-op (a 1-point course
|
||||
/// never enters the `pts.len() >= 2` probe branch either) — every Mouth
|
||||
/// edge silently resolved `CourseTerminus::None` instead of `Mouth`.
|
||||
/// Capturing `(nr, nc)` here (one more push per mouth cell — mouths are
|
||||
/// a small fraction of river cells, not a new grid pass) is what makes
|
||||
/// `build_edges` give Mouth edges a real ~one-cell chord toward the raw
|
||||
/// sea, so the termination walk + bisect + one-segment D8-probe fallback
|
||||
/// (Ruling 3e) are actually reachable.
|
||||
///
|
||||
/// `#[serde(default)]` — same additive pattern as `river_class`/
|
||||
/// `river_downstream`.
|
||||
#[serde(default)]
|
||||
pub river_seaward: Vec<(u16, u16)>,
|
||||
}
|
||||
|
||||
/// [`RiverNetwork::river_downstream`] sentinel: this river cell's D8 flow
|
||||
/// reaches a raw-sea cell (T-1170 Ruling 2c). Values 0–7 are real D8 direction
|
||||
/// indices, so sentinels start at 8.
|
||||
pub const RIVER_DOWNSTREAM_MOUTH: u8 = 8;
|
||||
/// [`RiverNetwork::river_downstream`] sentinel: this river cell's flow exits
|
||||
/// the grid's top/bottom (polar) edge — a grid artifact, never a mouth
|
||||
/// (T-1170 Ruling 2c/3f).
|
||||
pub const RIVER_DOWNSTREAM_EDGE_DRAIN: u8 = 9;
|
||||
/// [`RiverNetwork::river_downstream`] sentinel: **reserved, unused in round
|
||||
/// 1.** Flow terminates in an interior sink (future endorheic basin / inland
|
||||
/// delta, T-1170 Ruling 2c/7b). Reserving this value now is what makes that
|
||||
/// future extension additive rather than a wire migration.
|
||||
pub const RIVER_DOWNSTREAM_TERMINAL: u8 = 10;
|
||||
|
||||
/// One drainage basin / province derived from watershed analysis (D-205).
|
||||
/// Stub — boundary polyline data comes from atlas_province_boundaries.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
@@ -305,6 +305,11 @@ pub fn run_cascade_from_heightmap(
|
||||
// thalweg direction into each DistrictProfile.basin_direction
|
||||
// (T-1047). Pass the map through derive_all_districts.
|
||||
let basin_dirs = snapshot.layer1.as_ref().map(|l1| &l1.district_basin_dirs);
|
||||
// T-1168 Ruling 4c: same `snapshot.layer1` source as
|
||||
// `basin_dirs`/`river_cells` (the `road_graph` precedent
|
||||
// below) — the river network for the batch-path riparian
|
||||
// signal.
|
||||
let river_network = snapshot.layer1.as_ref().map(|l1| &l1.river_network);
|
||||
let districts = district_profile::derive_all_districts(
|
||||
body_seed,
|
||||
params,
|
||||
@@ -312,6 +317,7 @@ pub fn run_cascade_from_heightmap(
|
||||
scale::HEIGHTMAP_CELLS_PER_DISTRICT,
|
||||
&snapshot.body_id,
|
||||
basin_dirs,
|
||||
river_network,
|
||||
);
|
||||
snapshot.layer_district = Some(LayerDistrictOutput { districts });
|
||||
}
|
||||
|
||||
@@ -22,9 +22,11 @@ use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::atlas::body_world_state::RiverNetwork;
|
||||
use crate::atlas::coast_invention;
|
||||
use crate::atlas::features::TerrainAnalysis;
|
||||
use crate::atlas::region_profile::{self, RegionProfile};
|
||||
use crate::atlas::river_course;
|
||||
use crate::atlas::scale::{self, BasinDirection, RegionPos};
|
||||
use crate::seed::SeedChain;
|
||||
use crate::simulation::generator::MorphologyZone;
|
||||
@@ -1230,7 +1232,17 @@ fn invent_primitives(
|
||||
/// the batch path so both derivation paths key the invention noise fields on
|
||||
/// the same world-metre convention. Radius-less bodies fall back to the
|
||||
/// 1-working-pixel = 1-district convention `derive_district` uses.
|
||||
fn pixel_to_world_m(px: f64, py: f64, w: usize, h: usize, radius_km: Option<f64>) -> (f64, f64) {
|
||||
///
|
||||
/// `pub(crate)` (T-1170): also used by the river course inventor
|
||||
/// (`river_course::cell_world_m`) to resolve a river cell's pixel position to
|
||||
/// its world-metre anchor — the SAME mapping, reused rather than duplicated.
|
||||
pub(crate) fn pixel_to_world_m(
|
||||
px: f64,
|
||||
py: f64,
|
||||
w: usize,
|
||||
h: usize,
|
||||
radius_km: Option<f64>,
|
||||
) -> (f64, f64) {
|
||||
match radius_km {
|
||||
Some(r) if r > 0.0 => {
|
||||
let wx = px / w.max(1) as f64 * (std::f64::consts::TAU * r * 1000.0);
|
||||
@@ -1245,6 +1257,42 @@ fn pixel_to_world_m(px: f64, py: f64, w: usize, h: usize, radius_km: Option<f64>
|
||||
}
|
||||
}
|
||||
|
||||
/// Absolute world metres → fractional working-grid pixel — the inverse of
|
||||
/// [`pixel_to_world_m`], and the SAME mapping [`derive_at_metres`]/
|
||||
/// [`derive_orbital_at_metres`] compute inline for their own `(px, py)`
|
||||
/// derivation (T-1170: extracted as a standalone `pub(crate)` helper rather
|
||||
/// than duplicated a third time, for the river course inventor's Stage A
|
||||
/// valley-seeking control path, which needs bilinear `elev_pct` reads at
|
||||
/// arbitrary world positions without paying for a full `DistrictProfile`
|
||||
/// derive per candidate — Ruling 3b, binding: "NOT `derive_at_metres` per
|
||||
/// candidate"). Returns `(px, py)` only — callers that also need latitude
|
||||
/// (temperature-sensitive derivation) still compute it themselves; the course
|
||||
/// inventor's Stage A elevation proxy has no use for latitude.
|
||||
pub(crate) fn world_m_to_pixel(
|
||||
wx: f64,
|
||||
wy: f64,
|
||||
w: usize,
|
||||
h: usize,
|
||||
radius_km: Option<f64>,
|
||||
) -> (f64, f64) {
|
||||
match radius_km {
|
||||
Some(r_km) if r_km > 0.0 => {
|
||||
let circumference_m = std::f64::consts::TAU * r_km * 1000.0;
|
||||
let meridian_m = std::f64::consts::PI * r_km * 1000.0;
|
||||
let px = (wx / circumference_m).rem_euclid(1.0) * w as f64;
|
||||
let lat_frac = (wy / meridian_m).clamp(-0.5, 0.5);
|
||||
let py = (0.5 + lat_frac) * h.saturating_sub(1) as f64;
|
||||
(px, py)
|
||||
}
|
||||
_ => {
|
||||
let dm = scale::DISTRICT_M as f64;
|
||||
let px = (wx / dm).clamp(0.0, w.saturating_sub(1) as f64);
|
||||
let py = (wy / dm).clamp(0.0, h.saturating_sub(1) as f64);
|
||||
(px, py)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public derivation function
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1272,6 +1320,14 @@ fn pixel_to_world_m(px: f64, py: f64, w: usize, h: usize, radius_km: Option<f64>
|
||||
/// - `region_cache` — pre-computed [`RegionProfile`] map keyed by [`RegionPos`];
|
||||
/// if a neighbour region is missing it is derived on the fly. Build with
|
||||
/// [`region_profile::derive_regions_for_body`] before calling this in a loop.
|
||||
/// - `river_network` — the body's [`RiverNetwork`] (T-1168, Ruling 4b/4c),
|
||||
/// consulted for the riparian point test via
|
||||
/// [`river_course::near_perennial_water_at`] (edges near this district
|
||||
/// invented on demand, the same pure function the window path uses).
|
||||
/// `None` when no river network is available (e.g. a body with no Layer-1
|
||||
/// drainage pass, or a caller that predates T-1168) — the riparian signal
|
||||
/// degrades to `false` in that case, matching the pre-T-1168 hardcoded
|
||||
/// default exactly, never a panic or an error.
|
||||
pub fn derive_district_profile(
|
||||
seed: SeedChain,
|
||||
body_params: &BodyParams,
|
||||
@@ -1282,6 +1338,7 @@ pub fn derive_district_profile(
|
||||
body_id: &str,
|
||||
region_cache: &BTreeMap<RegionPos, RegionProfile>,
|
||||
basin_direction: BasinDirection,
|
||||
river_network: Option<&RiverNetwork>,
|
||||
) -> DistrictProfile {
|
||||
let (rx, ry) = pos;
|
||||
let w = ta.w;
|
||||
@@ -1332,6 +1389,24 @@ pub fn derive_district_profile(
|
||||
0.0, // batch path — no octave cutoff, matches derive_district's default
|
||||
);
|
||||
|
||||
// T-1168 Ruling 4b: batch-path riparian signal — edges near this
|
||||
// district invented on demand via the SAME pure function the window
|
||||
// path uses. `river_network.is_none()` degrades to `false` (see this
|
||||
// function's doc), never a panic.
|
||||
let near_perennial_water = river_network
|
||||
.map(|rn| {
|
||||
river_course::near_perennial_water_at(
|
||||
seed,
|
||||
ta,
|
||||
body_params,
|
||||
rn,
|
||||
(world_x_m, world_y_m),
|
||||
scale::DISTRICT_M as f64,
|
||||
0.0, // batch path — no octave cutoff, matches this function's own default
|
||||
)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
build_district_profile(
|
||||
seed,
|
||||
body_params,
|
||||
@@ -1344,6 +1419,7 @@ pub fn derive_district_profile(
|
||||
world_x_m,
|
||||
world_y_m,
|
||||
0.0, // batch path — no octave cutoff, matches derive_district's default
|
||||
near_perennial_water,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1381,6 +1457,18 @@ pub fn derive_district_profile(
|
||||
/// always active wherever `VegetationEnvelope::ceiling_q > 0`, mirroring the
|
||||
/// coast invention's own always-on posture (the ceiling being zero, not a
|
||||
/// separate flag, is what turns it off on airless/dry bodies).
|
||||
///
|
||||
/// ## Riparian signal (T-1168, Ruling 4a-4d)
|
||||
///
|
||||
/// `near_perennial_water` is the T-1168 riparian point test result for
|
||||
/// `(world_x_m, world_y_m)` — a separate boolean signal into
|
||||
/// [`derive_vegetation`], computed by the caller (window path: distance to
|
||||
/// the retained `Layer1Output`'s invented courses; batch path: distance to
|
||||
/// on-demand-invented courses for nearby edges — both via the SAME pure
|
||||
/// [`crate::atlas::river_course::near_perennial_water`] function). **This
|
||||
/// value NEVER touches `moisture_q`** (Ruling 4d, binding, re-affirmed): it
|
||||
/// is threaded straight through to `derive_vegetation` unchanged, after every
|
||||
/// moisture/temperature/morphology field above it has already been resolved.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build_district_profile(
|
||||
seed: SeedChain,
|
||||
@@ -1394,6 +1482,7 @@ fn build_district_profile(
|
||||
world_x_m: f64,
|
||||
world_y_m: f64,
|
||||
min_wavelength_m: f64,
|
||||
near_perennial_water: bool,
|
||||
) -> DistrictProfile {
|
||||
let tectonic_class = derive_tectonic_class(body_params);
|
||||
|
||||
@@ -1458,15 +1547,21 @@ fn build_district_profile(
|
||||
moisture_q,
|
||||
);
|
||||
|
||||
// Vegetation class (T-1025, D-239 §8). No riparian signal at district scale yet
|
||||
// (requires perennial waterway map from L2+); default to false for now.
|
||||
// L2 ChunkContext will override per-tile once drainage data is threaded through.
|
||||
// open_water = the morphology verdict (T-1126) — never a threshold of its own.
|
||||
// Vegetation class (T-1025, D-239 §8). near_perennial_water (T-1168) is
|
||||
// the caller-computed riparian point test result — see this function's
|
||||
// doc for the full threading contract (Ruling 4a-4d). open_water = the
|
||||
// morphology verdict (T-1126) — never a threshold of its own.
|
||||
let open_water = matches!(
|
||||
morphology_zone,
|
||||
MorphologyZone::OpenOcean | MorphologyZone::Lake
|
||||
);
|
||||
let vegetation_class = derive_vegetation(temperature_c, moisture_q, elev_q, false, open_water);
|
||||
let vegetation_class = derive_vegetation(
|
||||
temperature_c,
|
||||
moisture_q,
|
||||
elev_q,
|
||||
near_perennial_water,
|
||||
open_water,
|
||||
);
|
||||
|
||||
DistrictProfile {
|
||||
morphology_zone,
|
||||
@@ -1529,6 +1624,7 @@ pub fn derive_district(
|
||||
dy as f64 * dm,
|
||||
climate,
|
||||
0.0,
|
||||
&[],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1546,6 +1642,19 @@ pub fn derive_district(
|
||||
/// = no cutoff = [`derive_district`]'s existing behavior.
|
||||
///
|
||||
/// `body_id` is required for the D-243 §4 climate edge-fuzz warp domain separation.
|
||||
///
|
||||
/// `nearby_courses` (T-1168, Ruling 4b/4c): pre-invented river courses
|
||||
/// (already culled to the caller's neighbourhood — the window path's own
|
||||
/// bbox cull, `layer_proxy::build_courses_for_window`) consulted for the
|
||||
/// riparian point test via [`river_course::near_perennial_water`]. Passing
|
||||
/// `&[]` (the common case for a position far from any river, and every
|
||||
/// pre-T-1168 caller via [`derive_district`]) is exactly the old hardcoded
|
||||
/// `false` default — byte-identical output for every caller that doesn't
|
||||
/// thread real course geometry through. This is a PRE-INVENTED slice, not a
|
||||
/// `RiverNetwork` — this function is called once per window CELL (thousands
|
||||
/// of times per window), so re-inventing courses on every call here (rather
|
||||
/// than once per window) would be the exact per-candidate-derive cost this
|
||||
/// whole batch's Ruling 3b was written to avoid.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn derive_at_metres(
|
||||
seed: SeedChain,
|
||||
@@ -1556,6 +1665,7 @@ pub fn derive_at_metres(
|
||||
wy: f64,
|
||||
climate: &ClimateConstants,
|
||||
min_wavelength_m: f64,
|
||||
nearby_courses: &[river_course::InventedCourse],
|
||||
) -> DistrictProfile {
|
||||
// World metres -> fractional heightmap pixel + latitude. Mirrors
|
||||
// `derive_district`'s former inline mapping exactly, just keyed on
|
||||
@@ -1645,6 +1755,13 @@ pub fn derive_at_metres(
|
||||
// (derive_all_districts), which threads the true D8 direction from L1;
|
||||
// this on-demand path is the fallback for positions derived outside that
|
||||
// pass, where a meaningful basin_direction isn't available.
|
||||
//
|
||||
// T-1168 Ruling 4a: the riparian point test against the caller-supplied
|
||||
// (already-culled) course slice — the SAME pure predicate the batch path
|
||||
// uses via `near_perennial_water_at`.
|
||||
let near_perennial_water =
|
||||
river_course::near_perennial_water((world_x_m, world_y_m), nearby_courses);
|
||||
|
||||
build_district_profile(
|
||||
seed,
|
||||
¶ms,
|
||||
@@ -1657,6 +1774,7 @@ pub fn derive_at_metres(
|
||||
world_x_m,
|
||||
world_y_m,
|
||||
min_wavelength_m,
|
||||
near_perennial_water,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1790,12 +1908,27 @@ pub fn derive_orbital_at_metres(
|
||||
// the vegetation verdict even though slope/elevation stay
|
||||
// envelope-only at this rung.
|
||||
0.0,
|
||||
// T-1168 Ruling 4e/5a: NO windowed course invention at Region
|
||||
// granularity (`layer_proxy::build_courses_for_window` early-returns
|
||||
// for `WindowGranularity::Region` — the whole-body skeleton path
|
||||
// draws Region-rung rivers instead, Ruling 5a). Always `false` here
|
||||
// — honest, not a gap: even if courses existed at Region, the 1-3 m
|
||||
// riparian band is many orders of magnitude below Region's ~205 km
|
||||
// spacing and could never fire (Ruling 4e).
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
/// Bilinear interpolation of a row-major `f32` field at fractional `(px, py)`.
|
||||
/// Columns wrap (equirectangular); rows clamp at the poles.
|
||||
fn bilinear(field: &[f32], w: usize, h: usize, px: f64, py: f64) -> f32 {
|
||||
///
|
||||
/// `pub(crate)` (T-1170): also the elevation-proxy read the river course
|
||||
/// inventor's Stage A valley-seeking control path uses
|
||||
/// (`river_course::score_candidate`) — the SAME bilinear-`elev_pct` tradeoff
|
||||
/// the coast warp already makes (`invent_primitives`'s step 3), reused rather
|
||||
/// than re-implemented so the two invention fields can never silently drift
|
||||
/// on interpolation semantics.
|
||||
pub(crate) fn bilinear(field: &[f32], w: usize, h: usize, px: f64, py: f64) -> f32 {
|
||||
if w == 0 || h == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
@@ -1869,6 +2002,11 @@ fn bilinear_bool(mask: &[bool], w: usize, h: usize, px: f64, py: f64) -> f32 {
|
||||
/// from the map; missing entries (edge districts with no land cells) default to
|
||||
/// `BasinDirection::North`. When `None` (tests / paths before Layer 1 runs),
|
||||
/// every district gets `BasinDirection::North`.
|
||||
///
|
||||
/// `river_network` (T-1168, Ruling 4c) is threaded straight through to every
|
||||
/// [`derive_district_profile`] call for the batch-path riparian signal — the
|
||||
/// `road_graph` precedent (`cascade.rs`'s already-unpacked
|
||||
/// `layer1.river_network`, same source, same threading pattern).
|
||||
pub fn derive_all_districts(
|
||||
seed: SeedChain,
|
||||
body_params: &BodyParams,
|
||||
@@ -1876,6 +2014,7 @@ pub fn derive_all_districts(
|
||||
grid_cells_per_district: usize,
|
||||
body_id: &str,
|
||||
basin_dirs: Option<&BTreeMap<DistrictPos, BasinDirection>>,
|
||||
river_network: Option<&RiverNetwork>,
|
||||
) -> BTreeMap<DistrictPos, DistrictProfile> {
|
||||
let climate = ClimateConstants::default();
|
||||
let gcpr = grid_cells_per_district.max(1);
|
||||
@@ -1939,6 +2078,7 @@ pub fn derive_all_districts(
|
||||
body_id,
|
||||
®ion_cache,
|
||||
basin_direction,
|
||||
river_network,
|
||||
);
|
||||
out.insert(pos, profile);
|
||||
}
|
||||
@@ -1990,7 +2130,7 @@ mod tests {
|
||||
let hm = test_hm();
|
||||
let ta = test_ta(&hm);
|
||||
let params = BodyParams::default();
|
||||
let districts = derive_all_districts(test_seed(), ¶ms, &ta, 8, "test_body", None);
|
||||
let districts = derive_all_districts(test_seed(), ¶ms, &ta, 8, "test_body", None, None);
|
||||
|
||||
// Expected: ceil(64/8) × ceil(32/8) = 8 × 4 = 32 districts.
|
||||
assert_eq!(districts.len(), 32, "district count mismatch");
|
||||
@@ -2008,7 +2148,7 @@ mod tests {
|
||||
let params = BodyParams::default();
|
||||
|
||||
// Real DistrictPos keys from a baseline (None) run.
|
||||
let baseline = derive_all_districts(test_seed(), ¶ms, &ta, 8, "test_body", None);
|
||||
let baseline = derive_all_districts(test_seed(), ¶ms, &ta, 8, "test_body", None, None);
|
||||
let mut keys = baseline.keys().copied();
|
||||
let pos_east = keys.next().expect("at least one district");
|
||||
let pos_south = keys.next().expect("at least two districts");
|
||||
@@ -2018,8 +2158,15 @@ mod tests {
|
||||
basin_dirs.insert(pos_east, BasinDirection::East);
|
||||
basin_dirs.insert(pos_south, BasinDirection::South);
|
||||
|
||||
let districts =
|
||||
derive_all_districts(test_seed(), ¶ms, &ta, 8, "test_body", Some(&basin_dirs));
|
||||
let districts = derive_all_districts(
|
||||
test_seed(),
|
||||
¶ms,
|
||||
&ta,
|
||||
8,
|
||||
"test_body",
|
||||
Some(&basin_dirs),
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(districts[&pos_east].basin_direction, BasinDirection::East);
|
||||
assert_eq!(districts[&pos_south].basin_direction, BasinDirection::South);
|
||||
@@ -2182,6 +2329,7 @@ mod tests {
|
||||
dp.1 as f64 * dm,
|
||||
&climate,
|
||||
0.0,
|
||||
&[],
|
||||
);
|
||||
assert_district_profiles_eq(&via_wrapper, &via_metres);
|
||||
}
|
||||
@@ -2212,6 +2360,7 @@ mod tests {
|
||||
dp.1 as f64 * dm,
|
||||
&climate,
|
||||
0.0,
|
||||
&[],
|
||||
);
|
||||
assert_district_profiles_eq(&via_wrapper, &via_metres);
|
||||
}
|
||||
@@ -2258,6 +2407,7 @@ mod tests {
|
||||
-567.0 * dm + 512.0,
|
||||
&climate,
|
||||
512.0,
|
||||
&[],
|
||||
);
|
||||
assert!((0..=100).contains(&prof.elev_q));
|
||||
assert!((0..=100).contains(&prof.slope_q));
|
||||
@@ -2280,7 +2430,17 @@ mod tests {
|
||||
for i in 0..20 {
|
||||
let wx = (100 + i * 37) as f64 * dm;
|
||||
let wy = (100 + i * 53) as f64 * dm;
|
||||
let uncut = derive_at_metres(test_seed(), "test_body", &p, &ta, wx, wy, &climate, 0.0);
|
||||
let uncut = derive_at_metres(
|
||||
test_seed(),
|
||||
"test_body",
|
||||
&p,
|
||||
&ta,
|
||||
wx,
|
||||
wy,
|
||||
&climate,
|
||||
0.0,
|
||||
&[],
|
||||
);
|
||||
let cut = derive_at_metres(
|
||||
test_seed(),
|
||||
"test_body",
|
||||
@@ -2290,6 +2450,7 @@ mod tests {
|
||||
wy,
|
||||
&climate,
|
||||
8_193.0, // above the two finest OCTAVE_WAVELENGTHS_M entries
|
||||
&[],
|
||||
);
|
||||
if uncut.elev_q != cut.elev_q || uncut.slope_q != cut.slope_q {
|
||||
any_differs = true;
|
||||
@@ -2322,8 +2483,28 @@ mod tests {
|
||||
for i in 0..12 {
|
||||
let wx = (300 + i * 41) as f64 * dm * 0.1;
|
||||
let wy = (300 + i * 29) as f64 * dm * 0.1;
|
||||
let a = derive_at_metres(test_seed(), "test_body", &p, &ta, wx, wy, &climate, 1_024.0);
|
||||
let b = derive_at_metres(test_seed(), "test_body", &p, &ta, wx, wy, &climate, 1_024.0);
|
||||
let a = derive_at_metres(
|
||||
test_seed(),
|
||||
"test_body",
|
||||
&p,
|
||||
&ta,
|
||||
wx,
|
||||
wy,
|
||||
&climate,
|
||||
1_024.0,
|
||||
&[],
|
||||
);
|
||||
let b = derive_at_metres(
|
||||
test_seed(),
|
||||
"test_body",
|
||||
&p,
|
||||
&ta,
|
||||
wx,
|
||||
wy,
|
||||
&climate,
|
||||
1_024.0,
|
||||
&[],
|
||||
);
|
||||
assert_district_profiles_eq(&a, &b);
|
||||
}
|
||||
}
|
||||
@@ -2355,8 +2536,28 @@ mod tests {
|
||||
for i in 0..20 {
|
||||
let wx = (150 + i * 47) as f64 * dm;
|
||||
let wy = (150 + i * 31) as f64 * dm;
|
||||
let a = derive_at_metres(test_seed(), "test_body", &p, &ta, wx, wy, &climate, 4_096.0);
|
||||
let b = derive_at_metres(test_seed(), "test_body", &p, &ta, wx, wy, &climate, 4_096.0);
|
||||
let a = derive_at_metres(
|
||||
test_seed(),
|
||||
"test_body",
|
||||
&p,
|
||||
&ta,
|
||||
wx,
|
||||
wy,
|
||||
&climate,
|
||||
4_096.0,
|
||||
&[],
|
||||
);
|
||||
let b = derive_at_metres(
|
||||
test_seed(),
|
||||
"test_body",
|
||||
&p,
|
||||
&ta,
|
||||
wx,
|
||||
wy,
|
||||
&climate,
|
||||
4_096.0,
|
||||
&[],
|
||||
);
|
||||
assert_district_profiles_eq(&a, &b);
|
||||
}
|
||||
}
|
||||
@@ -2409,10 +2610,28 @@ mod tests {
|
||||
for i in 0..20 {
|
||||
let wx = (150 + i * 47) as f64 * dm;
|
||||
let wy = (150 + i * 31) as f64 * dm;
|
||||
let district =
|
||||
derive_at_metres(test_seed(), "test_body", &p, &ta, wx, wy, &climate, 4_096.0);
|
||||
let quarter =
|
||||
derive_at_metres(test_seed(), "test_body", &p, &ta, wx, wy, &climate, 1_024.0);
|
||||
let district = derive_at_metres(
|
||||
test_seed(),
|
||||
"test_body",
|
||||
&p,
|
||||
&ta,
|
||||
wx,
|
||||
wy,
|
||||
&climate,
|
||||
4_096.0,
|
||||
&[],
|
||||
);
|
||||
let quarter = derive_at_metres(
|
||||
test_seed(),
|
||||
"test_body",
|
||||
&p,
|
||||
&ta,
|
||||
wx,
|
||||
wy,
|
||||
&climate,
|
||||
1_024.0,
|
||||
&[],
|
||||
);
|
||||
if district.elev_q != quarter.elev_q
|
||||
|| district.slope_q != quarter.slope_q
|
||||
|| district.moisture_q != quarter.moisture_q
|
||||
@@ -2454,6 +2673,7 @@ mod tests {
|
||||
wy,
|
||||
&climate,
|
||||
300_000.0,
|
||||
&[],
|
||||
);
|
||||
// An even more extreme cutoff must produce the SAME result — once
|
||||
// every octave is truncated, going coarser still changes nothing.
|
||||
@@ -2466,6 +2686,7 @@ mod tests {
|
||||
wy,
|
||||
&climate,
|
||||
10_000_000.0,
|
||||
&[],
|
||||
);
|
||||
assert_district_profiles_eq(&far_above, &even_further);
|
||||
}
|
||||
@@ -2507,6 +2728,7 @@ mod tests {
|
||||
base_wy,
|
||||
&climate,
|
||||
2_048.0,
|
||||
&[],
|
||||
)
|
||||
.vegetation_class;
|
||||
|
||||
@@ -2522,8 +2744,17 @@ mod tests {
|
||||
for dx in -2..2 {
|
||||
let wx = base_wx + dx as f64 * qm;
|
||||
let wy = base_wy + dy as f64 * qm;
|
||||
let prof =
|
||||
derive_at_metres(test_seed(), "test_body", &p, &ta, wx, wy, &climate, 1_024.0);
|
||||
let prof = derive_at_metres(
|
||||
test_seed(),
|
||||
"test_body",
|
||||
&p,
|
||||
&ta,
|
||||
wx,
|
||||
wy,
|
||||
&climate,
|
||||
1_024.0,
|
||||
&[],
|
||||
);
|
||||
if prof.vegetation_class != VegetationClass::Marine {
|
||||
*tally.entry(prof.vegetation_class as u8).or_insert(0) += 1;
|
||||
}
|
||||
@@ -2636,7 +2867,17 @@ mod tests {
|
||||
let wx = 40.0 * dm;
|
||||
let wy = 20.0 * dm;
|
||||
let orbital = derive_orbital_at_metres(test_seed(), "test_body", &p, &ta, wx, wy, &climate);
|
||||
let full = derive_at_metres(test_seed(), "test_body", &p, &ta, wx, wy, &climate, 0.0);
|
||||
let full = derive_at_metres(
|
||||
test_seed(),
|
||||
"test_body",
|
||||
&p,
|
||||
&ta,
|
||||
wx,
|
||||
wy,
|
||||
&climate,
|
||||
0.0,
|
||||
&[],
|
||||
);
|
||||
|
||||
// The invented scatter is a bounded perturbation on top of the raw
|
||||
// envelope (detail_scatter's amplitude is capped well under 100 elev_q
|
||||
@@ -2717,6 +2958,7 @@ mod tests {
|
||||
"test_body",
|
||||
&BTreeMap::new(),
|
||||
BasinDirection::North,
|
||||
None,
|
||||
);
|
||||
let p2 = derive_district_profile(
|
||||
test_seed(),
|
||||
@@ -2728,6 +2970,7 @@ mod tests {
|
||||
"test_body",
|
||||
&BTreeMap::new(),
|
||||
BasinDirection::North,
|
||||
None,
|
||||
);
|
||||
// Equality via serialized fields (no PartialEq on MorphologyZone — compare by name).
|
||||
assert_eq!(
|
||||
@@ -2822,7 +3065,7 @@ mod tests {
|
||||
let hm = test_hm();
|
||||
let ta = test_ta(&hm);
|
||||
let params = BodyParams::default();
|
||||
let districts = derive_all_districts(test_seed(), ¶ms, &ta, 8, "test_body", None);
|
||||
let districts = derive_all_districts(test_seed(), ¶ms, &ta, 8, "test_body", None, None);
|
||||
// BTreeMap iterates in sorted key order — verify the first key is (0,0).
|
||||
let first = districts.keys().next().expect("at least one district");
|
||||
assert_eq!(*first, (0, 0), "first district must be at origin");
|
||||
|
||||
+223
-24
@@ -19,7 +19,9 @@
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::atlas::body_world_state::{DrainageBasin, RiverNetwork};
|
||||
use crate::atlas::body_world_state::{
|
||||
DrainageBasin, RiverNetwork, RIVER_DOWNSTREAM_EDGE_DRAIN, RIVER_DOWNSTREAM_MOUTH,
|
||||
};
|
||||
use crate::simulation::generator::TerritorialStatus;
|
||||
|
||||
/// A cell is a river cell when its flow accumulation exceeds this threshold (D-208).
|
||||
@@ -41,6 +43,18 @@ const D8: [(i32, i32); 8] = [
|
||||
(1, -1), // SW
|
||||
];
|
||||
|
||||
/// `(dr, dc)` for D8 direction index `k` (0-7) — the same fixed priority-order
|
||||
/// table [`extract_river_network`]/`flow_direction` use internally, exposed
|
||||
/// `pub(crate)` so downstream consumers of [`crate::atlas::body_world_state::
|
||||
/// RiverNetwork::river_downstream`] (T-1170's river course inventor) can walk
|
||||
/// a river cell's D8 pointer without duplicating the table. Panics on an
|
||||
/// out-of-range index — callers must check against the
|
||||
/// `RIVER_DOWNSTREAM_MOUTH`/`RIVER_DOWNSTREAM_EDGE_DRAIN`/
|
||||
/// `RIVER_DOWNSTREAM_TERMINAL` sentinels (values ≥ 8) before calling this.
|
||||
pub(crate) fn d8_offset(k: u8) -> (i32, i32) {
|
||||
D8[k as usize]
|
||||
}
|
||||
|
||||
/// Result of the full D8 drainage analysis for one body.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DrainageResult {
|
||||
@@ -316,35 +330,80 @@ fn extract_river_network(
|
||||
.map(|i| ((i / w) as u16, (i % w) as u16))
|
||||
.collect();
|
||||
|
||||
// Mouths: river cells that flow to a sea cell or to the polar edge.
|
||||
let mouths: Vec<(u16, u16)> = (0..n)
|
||||
.filter(|&i| {
|
||||
if !is_river[i] {
|
||||
return false;
|
||||
}
|
||||
let r = i / w;
|
||||
let c = i % w;
|
||||
let k = fdir[i];
|
||||
if k < 0 {
|
||||
return true; // no outflow — edge
|
||||
}
|
||||
let (dr, dc) = D8[k as usize];
|
||||
let nr = r as i32 + dr;
|
||||
let nc = (c as i32 + dc).rem_euclid(w as i32) as usize;
|
||||
if nr < 0 || nr >= h as i32 {
|
||||
return true; // polar edge
|
||||
}
|
||||
// Flows into a sub-sea-level cell = mouth
|
||||
elevation[nr as usize * w + nc] < sea_level
|
||||
})
|
||||
.map(|i| ((i / w) as u16, (i % w) as u16))
|
||||
.collect();
|
||||
// Mouths + river_downstream (T-1170 Ruling 2a/2c/3f): a single pass over
|
||||
// `river_cells`, in the SAME order, computing both the D8-downstream
|
||||
// sentinel/pointer AND whether this cell is a mouth. `fdir[i]` is already
|
||||
// in scope here — capturing it as `river_downstream` costs one extra push
|
||||
// per river cell, no new grid pass (Ruling 2a's binding requirement).
|
||||
//
|
||||
// **Pole-edge exits are NOT mouths (Ruling 3f, binding).** A river cell
|
||||
// with no outflow because its D8 walk ran off the grid's top/bottom row
|
||||
// is a grid artifact — the equirectangular projection simply stops there,
|
||||
// there is no sea. The former code classified this the same as a real
|
||||
// sea-adjacent mouth, which rendered double-ring mouth markers in polar
|
||||
// ice with no sea in sight (Jeroen's capture question, T-1170 ticket).
|
||||
// `RIVER_DOWNSTREAM_EDGE_DRAIN` cells are excluded from `mouths` here;
|
||||
// T-1170's course inventor (Ruling 3f) ends their course geometry at the
|
||||
// last in-grid station with no mouth flag.
|
||||
//
|
||||
// A flat-peak interior cell (no outflow, but NOT at a pole row) is D8's
|
||||
// other `k < 0` case — vanishingly rare for a cell that also cleared
|
||||
// `RIVER_THRESHOLD`, but handled the same as `EDGE_DRAIN` (no downstream
|
||||
// neighbor to point at, not a sea mouth) rather than crashing the
|
||||
// pointer's "always points somewhere real" contract.
|
||||
//
|
||||
// river_seaward (T-1170 PR #197 review, Hoshe #1): captured in the SAME
|
||||
// pass, parallel to river_downstream — `(nr, nc)` is already computed
|
||||
// here to decide the MOUTH sentinel; discarding it after the elevation
|
||||
// check (the former code) was the second instance of the exact
|
||||
// discard-then-need-it-later anti-pattern Ruling 2b fixed for
|
||||
// river_downstream itself. Non-mouth entries get the `(0, 0)` placeholder
|
||||
// (documented on the field as unreadable outside the MOUTH case).
|
||||
let mut mouths: Vec<(u16, u16)> = Vec::new();
|
||||
let mut river_downstream: Vec<u8> = Vec::new();
|
||||
let mut river_seaward: Vec<(u16, u16)> = Vec::new();
|
||||
for i in 0..n {
|
||||
if !is_river[i] {
|
||||
continue;
|
||||
}
|
||||
let r = i / w;
|
||||
let c = i % w;
|
||||
let k = fdir[i];
|
||||
if k < 0 {
|
||||
// No outflow at all — edge/flat-peak. Not a mouth (Ruling 3f).
|
||||
river_downstream.push(RIVER_DOWNSTREAM_EDGE_DRAIN);
|
||||
river_seaward.push((0, 0));
|
||||
continue;
|
||||
}
|
||||
let (dr, dc) = D8[k as usize];
|
||||
let nr = r as i32 + dr;
|
||||
let nc = (c as i32 + dc).rem_euclid(w as i32) as usize;
|
||||
if nr < 0 || nr >= h as i32 {
|
||||
// Flow direction points off the polar edge — a grid artifact,
|
||||
// not a mouth (Ruling 3f, the pole-edge-drain fix).
|
||||
river_downstream.push(RIVER_DOWNSTREAM_EDGE_DRAIN);
|
||||
river_seaward.push((0, 0));
|
||||
continue;
|
||||
}
|
||||
if elevation[nr as usize * w + nc] < sea_level {
|
||||
// Flows into a sub-sea-level cell — a real mouth. Capture the
|
||||
// seaward neighbor position (Hoshe #1) alongside the sentinel.
|
||||
mouths.push((r as u16, c as u16));
|
||||
river_downstream.push(RIVER_DOWNSTREAM_MOUTH);
|
||||
river_seaward.push((nr as u16, nc as u16));
|
||||
continue;
|
||||
}
|
||||
river_downstream.push(k as u8);
|
||||
river_seaward.push((0, 0));
|
||||
}
|
||||
|
||||
RiverNetwork {
|
||||
river_cells,
|
||||
confluences,
|
||||
mouths,
|
||||
river_class,
|
||||
river_downstream,
|
||||
river_seaward,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1040,4 +1099,144 @@ mod tests {
|
||||
"the body's max river-cell accumulation should be trunk"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// river_downstream (T-1170 Ruling 2a/2c/3f)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn river_downstream_parallel_to_river_cells() {
|
||||
let elev = slope_grid(512, 256);
|
||||
let result = analyze(&elev, 512, 256, 0.3);
|
||||
let rn = &result.river_network;
|
||||
assert_eq!(
|
||||
rn.river_cells.len(),
|
||||
rn.river_downstream.len(),
|
||||
"river_downstream must be parallel/aligned with river_cells"
|
||||
);
|
||||
assert!(!rn.river_cells.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn river_downstream_values_are_direction_or_sentinel() {
|
||||
// Every entry is either a real D8 index (0-7) or one of the T-1170
|
||||
// sentinels (MOUTH=8, EDGE_DRAIN=9); TERMINAL=10 is reserved/unused.
|
||||
let elev = slope_grid(512, 256);
|
||||
let result = analyze(&elev, 512, 256, 0.3);
|
||||
for &v in &result.river_network.river_downstream {
|
||||
assert!(
|
||||
v <= RIVER_DOWNSTREAM_EDGE_DRAIN,
|
||||
"unexpected river_downstream value {v} (round-1 only emits 0-7, MOUTH=8, \
|
||||
EDGE_DRAIN=9 — TERMINAL=10 is reserved and unused)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn river_downstream_mouth_sentinel_matches_mouths_list() {
|
||||
// Every river cell whose river_downstream is MOUTH must appear in
|
||||
// `mouths`, and every entry of `mouths` must have MOUTH as its
|
||||
// river_downstream — the two are the same underlying classification,
|
||||
// captured in the same pass (Ruling 2a/3f).
|
||||
use crate::atlas::heightmap::load_heightmap_png;
|
||||
let src = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../wiki/star-systems/GJ-1/bodies/GJ1c/heightmap.png");
|
||||
let heightmap =
|
||||
load_heightmap_png(&src, "GJ1c", 0.3).expect("decode committed GJ1c heightmap");
|
||||
let small = heightmap.downsample(256, 128);
|
||||
let result = analyze(&small.data, small.width, small.height, small.sea_level);
|
||||
let rn = &result.river_network;
|
||||
assert!(!rn.mouths.is_empty(), "GJ1c should have real sea mouths");
|
||||
|
||||
let mouth_set: std::collections::BTreeSet<(u16, u16)> = rn.mouths.iter().copied().collect();
|
||||
for (i, &pos) in rn.river_cells.iter().enumerate() {
|
||||
let is_mouth_sentinel = rn.river_downstream[i] == RIVER_DOWNSTREAM_MOUTH;
|
||||
let is_in_mouths_list = mouth_set.contains(&pos);
|
||||
assert_eq!(
|
||||
is_mouth_sentinel, is_in_mouths_list,
|
||||
"cell {pos:?}: MOUTH sentinel ({is_mouth_sentinel}) must agree with \
|
||||
mouths-list membership ({is_in_mouths_list})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pole_edge_drains_are_not_mouths() {
|
||||
// Ruling 3f, binding: a river cell with no valid D8 outflow is a grid
|
||||
// artifact, not a river-meets-sea event.
|
||||
//
|
||||
// **Why this test targets the flat/no-outflow (`k < 0`) case, not a
|
||||
// literal "flow direction points past row 0" case:** `flow_direction`
|
||||
// (this file, `fn flow_direction`) bounds-checks every D8 candidate
|
||||
// BEFORE comparing drops (`if nr < 0 || nr >= h { continue; }`) — a
|
||||
// row-0 cell can therefore never even be ASSIGNED a north-pointing
|
||||
// `fdir` in the first place; the off-grid-direction branch in
|
||||
// `extract_river_network`'s `river_downstream` computation exists as
|
||||
// correct defensive code but is structurally unreachable given this
|
||||
// invariant. The real, reachable "pole-edge-drain" case (confirmed
|
||||
// against the committed GJ1c golden fixture, which has EDGE_DRAIN
|
||||
// cells at several rows including row 0) is `k < 0`: a cell with NO
|
||||
// neighbor at a strictly lower elevation — most commonly a flat
|
||||
// plateau at the grid's fringe, which the depression-fill/flow
|
||||
// algorithm cannot route off of. This fixture constructs exactly
|
||||
// that: a perfectly flat plateau at row 0 (identical elevation
|
||||
// across the whole top row, so no cell in it has a positive-drop
|
||||
// neighbor and `flow_direction` assigns `k=-1` — verified this
|
||||
// reproduces before ever reasoning about mouths) that river cells
|
||||
// from a converging valley drain into, with no ocean anywhere.
|
||||
let (w, h) = (64usize, 64usize);
|
||||
let n = w * h;
|
||||
let center_col = (w / 2) as f32;
|
||||
let elev: Vec<f32> = (0..n)
|
||||
.map(|i| {
|
||||
let r = i / w;
|
||||
let c = (i % w) as f32;
|
||||
if r == 0 {
|
||||
return 0.15; // flat plateau — no cell here has a strictly lower neighbor
|
||||
}
|
||||
// V-shaped valley converging on center_col, sloping down
|
||||
// toward row 0 (but never reaching the plateau's own
|
||||
// elevation until row 1, so row-1 cells drain INTO the flat
|
||||
// row-0 plateau and then have nowhere further to go).
|
||||
let dist_from_center = (c - center_col).abs() / center_col;
|
||||
let valley = 0.15 + dist_from_center * 0.6;
|
||||
let pole_gradient = (r as f32 / h as f32) * 0.25;
|
||||
(valley + pole_gradient).clamp(0.0, 1.0)
|
||||
})
|
||||
.collect();
|
||||
let result = analyze(&elev, w as u32, h as u32, 0.0);
|
||||
let rn = &result.river_network;
|
||||
assert!(
|
||||
!rn.river_cells.is_empty(),
|
||||
"the converging-valley fixture must clear RIVER_THRESHOLD — if this starts \
|
||||
failing, the fixture (not the production code) needs retuning, since an \
|
||||
empty river_cells silently no-ops every assertion below"
|
||||
);
|
||||
assert!(
|
||||
rn.river_downstream.contains(&RIVER_DOWNSTREAM_EDGE_DRAIN),
|
||||
"expected at least one EDGE_DRAIN-sentinel river cell (the flat-plateau case) \
|
||||
on this fixture — downstream values were {:?}",
|
||||
rn.river_downstream
|
||||
);
|
||||
assert!(
|
||||
rn.mouths.is_empty(),
|
||||
"an all-land, pole-draining world must have zero mouths — got {:?}",
|
||||
rn.mouths
|
||||
);
|
||||
assert!(
|
||||
!rn.river_downstream.contains(&RIVER_DOWNSTREAM_MOUTH),
|
||||
"an all-land world must never emit a MOUTH sentinel"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn river_downstream_deterministic() {
|
||||
let elev = slope_grid(64, 32);
|
||||
let r1 = analyze(&elev, 64, 32, 0.3);
|
||||
let r2 = analyze(&elev, 64, 32, 0.3);
|
||||
assert_eq!(
|
||||
r1.river_network.river_downstream, r2.river_network.river_downstream,
|
||||
"river_downstream must be deterministic (D-010/D-208)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ use crate::atlas::cascade::{run_cascade_from_heightmap, CascadeLayer};
|
||||
use crate::atlas::district_profile::{BodyParams, ClimateConstants, DistrictPos};
|
||||
use crate::atlas::features::TerrainAnalysis;
|
||||
use crate::atlas::heightmap::{load_heightmap_png, GRID_H, GRID_W};
|
||||
use crate::atlas::layer1::Layer1Output;
|
||||
use crate::atlas::layer_proxy::{
|
||||
build_district_window_layer, DistrictWindowLayer, WindowGranularity,
|
||||
};
|
||||
@@ -271,7 +272,24 @@ pub enum GenCompletion {
|
||||
BodyAnalyzed {
|
||||
body_id: String,
|
||||
/// The computed world state, ready for `BodyWorldStateCache::insert`.
|
||||
state: BodyWorldState,
|
||||
/// Boxed to keep `GenCompletion` variant sizes balanced — the same
|
||||
/// discipline as `SkeletonGenerated`/`ChunkFilled`/`WindowDerived`
|
||||
/// below, all boxed for the same reason (`GenCompletion`'s overall
|
||||
/// size is bounded by its largest unboxed variant; a `BodyWorldState`
|
||||
/// field carried by value here would force every other variant to
|
||||
/// pay for its full stack size on every match/move).
|
||||
///
|
||||
/// **Not this variant's own recent growth** (T-1170 PR #197 review,
|
||||
/// Tyre issue 2 — the prior comment here overattributed the boxing
|
||||
/// rationale): `BodyWorldState` itself only grew by
|
||||
/// `RiverNetwork`'s two new `Vec` fields (`river_downstream`,
|
||||
/// `river_seaward` — a few bytes/river-cell, Ruling 2a / Hoshe #1).
|
||||
/// The much larger `Layer1Output` retention (T-1170 Ruling 4b) lives
|
||||
/// on `TerrainAnalysisCache` — a queue-scoped struct further down
|
||||
/// this file, never part of `BodyWorldState`/`BodyWorldStateCache` at
|
||||
/// all. The box here predates T-1170 and stays for the pre-existing
|
||||
/// variant-size-balancing reason, unrelated to this ticket's growth.
|
||||
state: Box<BodyWorldState>,
|
||||
},
|
||||
SkeletonGenerated {
|
||||
city_id: u64,
|
||||
@@ -585,9 +603,23 @@ impl Default for GenerationQueue {
|
||||
/// D-227-pure derived window (valid forever, no recency signal to track),
|
||||
/// which body a player keeps panning around IS a recency signal, so
|
||||
/// access-order eviction is the right fit here.
|
||||
///
|
||||
/// **T-1170 (Ruling 4b) — also retains the `Layer1Output` produced alongside
|
||||
/// `TerrainAnalysis`, not just the latter.** The original entry only kept
|
||||
/// `TerrainAnalysis` and discarded `run_layer1`'s `Layer1Output` half (the
|
||||
/// `let (_, ta) = run_layer1(...)` at the old call site) — fine for the
|
||||
/// six-array district/quarter/vegetation classification the window path
|
||||
/// used before this ticket, but it meant the window derive path had no way
|
||||
/// to know which river edges exist near a window without re-running the
|
||||
/// whole ~45 ms drainage pass a second time. Since this cache already pays
|
||||
/// that cost once per body and holds the result for the session, keeping
|
||||
/// BOTH halves of `run_layer1`'s return value is free — `Layer1Output`
|
||||
/// itself is small (a `RiverNetwork` + basin list + attractor list, not the
|
||||
/// full grid) relative to `TerrainAnalysis`'s ~1.5–2 MB of dense per-cell
|
||||
/// Vecs.
|
||||
#[derive(Debug)]
|
||||
struct TerrainAnalysisCache {
|
||||
entries: std::collections::BTreeMap<String, (TerrainAnalysis, u64)>,
|
||||
entries: std::collections::BTreeMap<String, (Layer1Output, TerrainAnalysis, u64)>,
|
||||
/// Monotonic access counter (substitutes for `BodyWorldStateCache`'s
|
||||
/// `SimTick` — there is no tick concept on a background Rayon thread).
|
||||
clock: u64,
|
||||
@@ -607,36 +639,42 @@ impl TerrainAnalysisCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// Look up a cached `TerrainAnalysis` for `body_id`, re-deriving via
|
||||
/// `run_layer1` on a miss and inserting the result (evicting the LRU
|
||||
/// entry first if at capacity). Bumps the access clock on both a hit and
|
||||
/// a fresh insert (both are "this body was just used").
|
||||
/// Look up a cached `(Layer1Output, TerrainAnalysis)` pair for `body_id`,
|
||||
/// re-deriving via `run_layer1` on a miss and inserting the result
|
||||
/// (evicting the LRU entry first if at capacity). Bumps the access clock
|
||||
/// on both a hit and a fresh insert (both are "this body was just used").
|
||||
///
|
||||
/// Returns both halves of `run_layer1`'s output (T-1170 Ruling 4b) — the
|
||||
/// window derive path (`GenWorkItem::DeriveWindow`) needs `Layer1Output`'s
|
||||
/// `RiverNetwork` to know which river edges exist near the requested
|
||||
/// window, in addition to the `TerrainAnalysis` it always needed.
|
||||
fn get_or_derive(
|
||||
&mut self,
|
||||
body_id: &str,
|
||||
heightmap: &crate::atlas::heightmap::BodyHeightmap,
|
||||
) -> TerrainAnalysis {
|
||||
) -> (Layer1Output, TerrainAnalysis) {
|
||||
self.clock += 1;
|
||||
let now = self.clock;
|
||||
if let Some((ta, last_used)) = self.entries.get_mut(body_id) {
|
||||
if let Some((l1, ta, last_used)) = self.entries.get_mut(body_id) {
|
||||
*last_used = now;
|
||||
return ta.clone();
|
||||
return (l1.clone(), ta.clone());
|
||||
}
|
||||
|
||||
let (_, ta) = crate::atlas::layer1::run_layer1(heightmap);
|
||||
let (l1, ta) = crate::atlas::layer1::run_layer1(heightmap);
|
||||
|
||||
if self.entries.len() >= self.capacity && !self.entries.contains_key(body_id) {
|
||||
if let Some(victim) = self
|
||||
.entries
|
||||
.iter()
|
||||
.min_by_key(|(_, (_, last_used))| *last_used)
|
||||
.min_by_key(|(_, (_, _, last_used))| *last_used)
|
||||
.map(|(id, _)| id.clone())
|
||||
{
|
||||
self.entries.remove(&victim);
|
||||
}
|
||||
}
|
||||
self.entries.insert(body_id.to_string(), (ta.clone(), now));
|
||||
ta
|
||||
self.entries
|
||||
.insert(body_id.to_string(), (l1.clone(), ta.clone(), now));
|
||||
(l1, ta)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -705,7 +743,7 @@ fn run_work_item(
|
||||
);
|
||||
GenCompletion::BodyAnalyzed {
|
||||
body_id: body_id.clone(),
|
||||
state: snapshot.into_body_world_state(),
|
||||
state: Box::new(snapshot.into_body_world_state()),
|
||||
}
|
||||
}
|
||||
Err(e) => GenCompletion::Failed {
|
||||
@@ -794,15 +832,22 @@ fn run_work_item(
|
||||
} else {
|
||||
hm
|
||||
};
|
||||
// TerrainAnalysis via the per-body LRU (T-1137 binding decision +
|
||||
// PR #187 review C1): first window on a body pays the ~45 ms
|
||||
// run_layer1 re-derive and populates the cache entry; every
|
||||
// subsequent window on the SAME body (until eviction) hits the
|
||||
// cache and skips straight to the ~7–29 ms per-window pack below.
|
||||
// This is the memoized form of the SAME workaround
|
||||
// aliveness_probe --render uses when CascadeSnapshot.terrain_analysis
|
||||
// is None (it has no cache — a one-shot CLI run doesn't need one).
|
||||
let ta = terrain_cache
|
||||
// (Layer1Output, TerrainAnalysis) via the per-body LRU (T-1137
|
||||
// binding decision + PR #187 review C1, extended T-1170 Ruling
|
||||
// 4b to retain Layer1Output too): first window on a body pays
|
||||
// the ~45 ms run_layer1 re-derive and populates the cache
|
||||
// entry; every subsequent window on the SAME body (until
|
||||
// eviction) hits the cache and skips straight to the ~7–29 ms
|
||||
// per-window pack below. This is the memoized form of the SAME
|
||||
// workaround aliveness_probe --render uses when
|
||||
// CascadeSnapshot.terrain_analysis is None (it has no cache —
|
||||
// a one-shot CLI run doesn't need one).
|
||||
//
|
||||
// `l1.river_network` is what lets the window derive know which
|
||||
// river edges exist near this window (T-1170 A2) without a
|
||||
// second drainage pass — the fix for the former
|
||||
// `let (_, ta) = run_layer1(...)` discard (Ruling 4b).
|
||||
let (l1, ta) = terrain_cache
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get_or_derive(body_id, &working);
|
||||
@@ -812,6 +857,7 @@ fn run_work_item(
|
||||
body_id,
|
||||
body_params,
|
||||
&ta,
|
||||
&l1.river_network,
|
||||
*center,
|
||||
*n,
|
||||
&climate,
|
||||
@@ -1459,32 +1505,39 @@ mod tests {
|
||||
}
|
||||
|
||||
/// A miss re-derives and populates the entry; a subsequent hit for the
|
||||
/// SAME body returns an equal `TerrainAnalysis` (D-227: the same
|
||||
/// heightmap always derives to the same analysis) WITHOUT growing the
|
||||
/// cache — `len()` stays at 1, proving the second call short-circuited
|
||||
/// past `run_layer1` rather than deriving-then-overwriting.
|
||||
/// SAME body returns an equal `(Layer1Output, TerrainAnalysis)` pair
|
||||
/// (D-227: the same heightmap always derives to the same analysis)
|
||||
/// WITHOUT growing the cache — `len()` stays at 1, proving the second
|
||||
/// call short-circuited past `run_layer1` rather than
|
||||
/// deriving-then-overwriting.
|
||||
#[test]
|
||||
fn terrain_analysis_cache_hit_reuses_entry() {
|
||||
let mut cache = TerrainAnalysisCache::new(8);
|
||||
let hm = window_test_hm();
|
||||
|
||||
assert!(!cache.contains("BodyA"));
|
||||
let first = cache.get_or_derive("BodyA", &hm);
|
||||
let (l1_first, ta_first) = cache.get_or_derive("BodyA", &hm);
|
||||
assert_eq!(cache.len(), 1);
|
||||
assert!(cache.contains("BodyA"));
|
||||
|
||||
let second = cache.get_or_derive("BodyA", &hm);
|
||||
let (l1_second, ta_second) = cache.get_or_derive("BodyA", &hm);
|
||||
assert_eq!(
|
||||
cache.len(),
|
||||
1,
|
||||
"a hit must not insert a second entry for the same body"
|
||||
);
|
||||
assert_eq!(
|
||||
first.ocean_mask, second.ocean_mask,
|
||||
ta_first.ocean_mask, ta_second.ocean_mask,
|
||||
"same heightmap → identical re-derived analysis (D-227)"
|
||||
);
|
||||
assert_eq!(first.slope_deg, second.slope_deg);
|
||||
assert_eq!(first.elev_pct, second.elev_pct);
|
||||
assert_eq!(ta_first.slope_deg, ta_second.slope_deg);
|
||||
assert_eq!(ta_first.elev_pct, ta_second.elev_pct);
|
||||
// T-1170 Ruling 4b: Layer1Output (river_network in particular) is
|
||||
// ALSO retained and identically re-derived, not just TerrainAnalysis.
|
||||
assert_eq!(
|
||||
l1_first.river_network.river_cells, l1_second.river_network.river_cells,
|
||||
"Layer1Output.river_network must also be cached/reused, not just TerrainAnalysis"
|
||||
);
|
||||
}
|
||||
|
||||
/// Different bodies get independent entries, and a capacity-2 cache
|
||||
|
||||
+1235
-4
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,7 @@ pub mod layer_proxy;
|
||||
pub mod mosaic;
|
||||
pub mod plugin;
|
||||
pub mod region_profile;
|
||||
pub mod river_course;
|
||||
pub mod road_graph;
|
||||
pub mod scale;
|
||||
pub mod shell;
|
||||
|
||||
@@ -359,7 +359,7 @@ fn drain_generation_completions(
|
||||
);
|
||||
}
|
||||
}
|
||||
cache.insert(state);
|
||||
cache.insert(*state);
|
||||
}
|
||||
GenCompletion::Failed { item, reason } => {
|
||||
tracing::warn!(?item, %reason, "background generation work item failed");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -104,7 +104,11 @@ pub fn chunk_to_region(c: ChunkPos) -> RegionPos {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Number of working-heightmap-grid pixels per district side on the standard
|
||||
/// cascade working resolution (~128×64 working grid; D-203, D-239 §1, T-1023).
|
||||
/// cascade working resolution (512×256 working grid, `heightmap::GRID_W` ×
|
||||
/// `heightmap::GRID_H`; D-203, D-239 §1, T-1023). (T-1170 audit nit: this
|
||||
/// comment previously read "~128×64" — stale since the working grid was
|
||||
/// widened; the actual grid-shape source of truth is `heightmap::GRID_W`/
|
||||
/// `GRID_H`, not a number restated here.)
|
||||
///
|
||||
/// This is NOT a metre-scale constant — it is the `grid_cells_per_district`
|
||||
/// parameter passed to [`crate::atlas::district_profile::derive_all_districts`].
|
||||
|
||||
@@ -146,6 +146,15 @@ pub enum SeedDomain {
|
||||
/// independent entropy — the `assign_block_tags` lesson (distinct
|
||||
/// sub-chains per field, not one shared roll) applies here too.
|
||||
TraitExterior = 16,
|
||||
/// River course invention (T-1170, Ruling 3a): the linear sibling of the
|
||||
/// coastline warp, keyed by `edge_id` (the packed upstream-cell u32 of a
|
||||
/// D8 river edge, see `atlas::river_course`). Distinct domain so the
|
||||
/// course's Stage-B perpendicular warp octaves can never correlate with
|
||||
/// the coast warp, terrain scatter, or vegetation massif fields sampled
|
||||
/// at the same world position (D-224 domain separation, the module's own
|
||||
/// `RIVER_COURSE_WARP_SALT` provides a second, position-keyed layer of
|
||||
/// isolation on top of this domain tag).
|
||||
RiverCourse = 17,
|
||||
}
|
||||
|
||||
/// A position in the deterministic seed tree (D-224).
|
||||
@@ -311,6 +320,7 @@ mod tests {
|
||||
assert_eq!(SeedDomain::TraitDistrict as u64, 14);
|
||||
assert_eq!(SeedDomain::TraitSwerve as u64, 15);
|
||||
assert_eq!(SeedDomain::TraitExterior as u64, 16);
|
||||
assert_eq!(SeedDomain::RiverCourse as u64, 17);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -18,6 +18,44 @@
|
||||
//! Golden captured on x86_64. The downsample and sub-biome cost use f32, so a
|
||||
//! different architecture could in principle round differently — regenerate
|
||||
//! per-arch if CI ever moves off x86_64.
|
||||
//!
|
||||
//! **Deliberate re-pin (T-1170 Ruling 2a/2c/3f, A1):** `RiverNetwork` gained
|
||||
//! an additive `river_downstream: Vec<u8>` field (per-`river_cells`-entry D8
|
||||
//! downstream pointer + MOUTH/EDGE_DRAIN sentinel), and `extract_river_network`
|
||||
//! stopped classifying pole-edge D8 exits (flow running off the grid's
|
||||
//! top/bottom row) as `mouths` — they are grid artifacts, not river-meets-sea
|
||||
//! events (Ruling 3f, Jeroen's capture question). On this fixture (GJ1c,
|
||||
//! 256×128 downsample) that drops `mouths` from 19 to 3: 16 of the 19 were
|
||||
//! pole-edge exits (now `RIVER_DOWNSTREAM_EDGE_DRAIN`), leaving the 3 real
|
||||
//! sea-adjacent mouths (`RIVER_DOWNSTREAM_MOUTH`). `river_cells`/`attractors`
|
||||
//! counts are unchanged (93/256) — this is a pure re-classification + one new
|
||||
//! additive field, not a drainage-algorithm change.
|
||||
//!
|
||||
//! **Attractor cascade correction (T-1170 PR #197 review, Hoshe #4):** the A1
|
||||
//! commit's "pure reclassification" framing overclaimed — `attractors` is
|
||||
//! count-PARITY (256/256), not byte-identical. Mouths dropping 19→3 shrinks
|
||||
//! `TerrainAnalysis::water_dist`'s seed set (`compute_water_dist` seeds from
|
||||
//! `river_network.mouths`), which shifts the water-distance field, which
|
||||
//! feeds `RawAttractor::strength` scoring in `features::extract_attractors`.
|
||||
//! This is a principled, in-scope cascade (the pole-edge cells genuinely
|
||||
//! aren't water-distance sources anymore) — not a bug — but it is a REAL
|
||||
//! field-level change, not a no-op re-tagging. Restated accurately here so
|
||||
//! the record doesn't imply byte-identical attractor output.
|
||||
//!
|
||||
//! **Second deliberate re-pin (T-1170 PR #197 review, Hoshe #1):**
|
||||
//! `RiverNetwork` gained a second additive field, `river_seaward:
|
||||
//! Vec<(u16,u16)>` — the real seaward neighbor position for MOUTH-sentinel
|
||||
//! river cells, captured in the SAME `extract_river_network` pass (the
|
||||
//! elevation check that already computes `(nr, nc)` to decide the MOUTH
|
||||
//! sentinel). Fixes a second instance of the discard-then-need-it-later
|
||||
//! anti-pattern Ruling 2b's `river_downstream` field fixed for interior D8
|
||||
//! pointers: `river_course::build_edges` needs a real seaward cell to give
|
||||
//! Mouth edges a non-degenerate chord (see `river_course.rs`'s
|
||||
//! `build_edges` doc and `layer_proxy::tests::
|
||||
//! all_real_gj1c_mouths_resolve_to_mouth_terminus_not_none` for the full
|
||||
//! bug/fix story). `river_cells`/`mouths`/`attractors` counts unchanged by
|
||||
//! this second re-pin (93/3/256) — purely the new parallel array, 3 non-
|
||||
//! placeholder entries (one per real mouth).
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
|
||||
@@ -1933,6 +1933,7 @@ fn derive_profile_for_body(params: &BodyParams) -> DistrictProfile {
|
||||
"test_body",
|
||||
&BTreeMap::new(),
|
||||
settled_reach_server::atlas::scale::BasinDirection::default(),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -558,6 +558,8 @@ fn generate_atlas_layer_response_fixtures() {
|
||||
confluences: vec![],
|
||||
mouths: vec![(12, 58)],
|
||||
river_class: vec![1, 2],
|
||||
river_downstream: vec![2, 8], // 2=E direction; 8=MOUTH sentinel
|
||||
river_seaward: vec![(0, 0), (12, 60)], // meaningful only for the MOUTH entry
|
||||
},
|
||||
drainage_basins: vec![DrainageBasin {
|
||||
basin_id: 1,
|
||||
@@ -736,6 +738,7 @@ fn generate_atlas_layer_response_fixtures() {
|
||||
moisture_q: vec![90, 55, 0, 100],
|
||||
vegetation: vec![6, 3, 0, 5], // Marine, Forest, Absent, RiparianThicket
|
||||
glaciation: vec![0, 0, 4, 1], // None, None, IceCap, Light
|
||||
courses: vec![],
|
||||
};
|
||||
let ready_with_window = AtlasLayerResponse {
|
||||
body_id: "GJ1c".into(),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -254,6 +254,57 @@
|
||||
"moisture_q": 43,
|
||||
"vegetation": 1
|
||||
},
|
||||
{
|
||||
"label": "river_course",
|
||||
"rung": "district",
|
||||
"wx_m": 36277344,
|
||||
"wy_m": -6830545,
|
||||
"min_wl_m": 4096,
|
||||
"morphology": 8,
|
||||
"tectonic": 0,
|
||||
"glaciation": 1,
|
||||
"precipitation": 2,
|
||||
"slope_q": 2,
|
||||
"elev_q": 8,
|
||||
"ocean_fraction_q": 0,
|
||||
"temperature_dc": 14,
|
||||
"moisture_q": 49,
|
||||
"vegetation": 3
|
||||
},
|
||||
{
|
||||
"label": "river_course",
|
||||
"rung": "quarter",
|
||||
"wx_m": 36277344,
|
||||
"wy_m": -6830545,
|
||||
"min_wl_m": 1024,
|
||||
"morphology": 8,
|
||||
"tectonic": 0,
|
||||
"glaciation": 1,
|
||||
"precipitation": 2,
|
||||
"slope_q": 3,
|
||||
"elev_q": 4,
|
||||
"ocean_fraction_q": 0,
|
||||
"temperature_dc": 34,
|
||||
"moisture_q": 50,
|
||||
"vegetation": 3
|
||||
},
|
||||
{
|
||||
"label": "river_course",
|
||||
"rung": "region",
|
||||
"wx_m": 36277344,
|
||||
"wy_m": -6830545,
|
||||
"min_wl_m": 0,
|
||||
"morphology": 8,
|
||||
"tectonic": 0,
|
||||
"glaciation": 1,
|
||||
"precipitation": 2,
|
||||
"slope_q": 0,
|
||||
"elev_q": 11,
|
||||
"ocean_fraction_q": 0,
|
||||
"temperature_dc": -2,
|
||||
"moisture_q": 49,
|
||||
"vegetation": 3
|
||||
},
|
||||
{
|
||||
"label": "airless_dry/coastal_a",
|
||||
"rung": "district",
|
||||
@@ -509,6 +560,57 @@
|
||||
"moisture_q": 0,
|
||||
"vegetation": 0
|
||||
},
|
||||
{
|
||||
"label": "airless_dry/river_course",
|
||||
"rung": "district",
|
||||
"wx_m": 36277344,
|
||||
"wy_m": -6830545,
|
||||
"min_wl_m": 4096,
|
||||
"morphology": 8,
|
||||
"tectonic": 0,
|
||||
"glaciation": 0,
|
||||
"precipitation": 0,
|
||||
"slope_q": 3,
|
||||
"elev_q": 5,
|
||||
"ocean_fraction_q": 0,
|
||||
"temperature_dc": -2147483648,
|
||||
"moisture_q": 0,
|
||||
"vegetation": 0
|
||||
},
|
||||
{
|
||||
"label": "airless_dry/river_course",
|
||||
"rung": "quarter",
|
||||
"wx_m": 36277344,
|
||||
"wy_m": -6830545,
|
||||
"min_wl_m": 1024,
|
||||
"morphology": 8,
|
||||
"tectonic": 0,
|
||||
"glaciation": 0,
|
||||
"precipitation": 0,
|
||||
"slope_q": 3,
|
||||
"elev_q": 6,
|
||||
"ocean_fraction_q": 0,
|
||||
"temperature_dc": -2147483648,
|
||||
"moisture_q": 0,
|
||||
"vegetation": 0
|
||||
},
|
||||
{
|
||||
"label": "airless_dry/river_course",
|
||||
"rung": "region",
|
||||
"wx_m": 36277344,
|
||||
"wy_m": -6830545,
|
||||
"min_wl_m": 0,
|
||||
"morphology": 8,
|
||||
"tectonic": 0,
|
||||
"glaciation": 0,
|
||||
"precipitation": 0,
|
||||
"slope_q": 0,
|
||||
"elev_q": 5,
|
||||
"ocean_fraction_q": 0,
|
||||
"temperature_dc": -2147483648,
|
||||
"moisture_q": 0,
|
||||
"vegetation": 0
|
||||
},
|
||||
{
|
||||
"label": "volcanic_coast/coastal_a",
|
||||
"rung": "district",
|
||||
@@ -763,5 +865,56 @@
|
||||
"temperature_dc": 555,
|
||||
"moisture_q": 30,
|
||||
"vegetation": 2
|
||||
},
|
||||
{
|
||||
"label": "volcanic_coast/river_course",
|
||||
"rung": "district",
|
||||
"wx_m": 36277344,
|
||||
"wy_m": -6830545,
|
||||
"min_wl_m": 4096,
|
||||
"morphology": 15,
|
||||
"tectonic": 2,
|
||||
"glaciation": 0,
|
||||
"precipitation": 2,
|
||||
"slope_q": 4,
|
||||
"elev_q": 14,
|
||||
"ocean_fraction_q": 0,
|
||||
"temperature_dc": 500,
|
||||
"moisture_q": 47,
|
||||
"vegetation": 3
|
||||
},
|
||||
{
|
||||
"label": "volcanic_coast/river_course",
|
||||
"rung": "quarter",
|
||||
"wx_m": 36277344,
|
||||
"wy_m": -6830545,
|
||||
"min_wl_m": 1024,
|
||||
"morphology": 15,
|
||||
"tectonic": 2,
|
||||
"glaciation": 0,
|
||||
"precipitation": 2,
|
||||
"slope_q": 6,
|
||||
"elev_q": 5,
|
||||
"ocean_fraction_q": 0,
|
||||
"temperature_dc": 547,
|
||||
"moisture_q": 49,
|
||||
"vegetation": 3
|
||||
},
|
||||
{
|
||||
"label": "volcanic_coast/river_course",
|
||||
"rung": "region",
|
||||
"wx_m": 36277344,
|
||||
"wy_m": -6830545,
|
||||
"min_wl_m": 0,
|
||||
"morphology": 15,
|
||||
"tectonic": 2,
|
||||
"glaciation": 0,
|
||||
"precipitation": 2,
|
||||
"slope_q": 0,
|
||||
"elev_q": 18,
|
||||
"ocean_fraction_q": 0,
|
||||
"temperature_dc": 480,
|
||||
"moisture_q": 47,
|
||||
"vegetation": 3
|
||||
}
|
||||
]
|
||||
|
||||
@@ -39,6 +39,7 @@ use settled_reach_server::atlas::district_profile::{
|
||||
use settled_reach_server::atlas::drainage;
|
||||
use settled_reach_server::atlas::features::TerrainAnalysis;
|
||||
use settled_reach_server::atlas::heightmap::BodyHeightmap;
|
||||
use settled_reach_server::atlas::river_course;
|
||||
use settled_reach_server::atlas::scale;
|
||||
use settled_reach_server::seed::{SeedChain, SeedDomain};
|
||||
|
||||
@@ -163,6 +164,20 @@ fn sweep_positions() -> Vec<(&'static str, f64, f64)> {
|
||||
("coastal_c", 2_100_000.0, 1_560_000.0),
|
||||
("inland", 500_000.0, 3_000_000.0),
|
||||
("high_lat", 1_200_000.0, 8_500_000.0),
|
||||
// T-1170 A2 Discipline item 4: empirically verified (probe run against
|
||||
// this fixture, `sample_hm()`/`sample_params()`) that `sample_hm()`
|
||||
// produces a real river-cell chain around working-grid pixel
|
||||
// (row=10, col=116) — NONE of the original five sweep positions
|
||||
// (pixel cols ~1.6-6.7) land anywhere near it. This position converts
|
||||
// that pixel to world metres (same `world_m_to_pixel` inverse the
|
||||
// production mapping uses) so the golden sweep also exercises
|
||||
// `derive_at_metres` genuinely close to invented river geometry —
|
||||
// closing the "believability expected unchanged, verify, don't
|
||||
// assume" discipline item for the district-profile-only fields this
|
||||
// golden already pins (courses themselves are pinned separately
|
||||
// below, `river_course_golden_regression`, since this sweep's
|
||||
// `derive_at_metres` calls never touch `RiverCourse` at all).
|
||||
("river_course", 36_277_344.8, -6_830_545.5),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -182,7 +197,7 @@ fn derive_golden_sample(
|
||||
let prof = if orbital {
|
||||
derive_orbital_at_metres(seed, body_id, params, ta, wx, wy, climate)
|
||||
} else {
|
||||
derive_at_metres(seed, body_id, params, ta, wx, wy, climate, min_wl_m)
|
||||
derive_at_metres(seed, body_id, params, ta, wx, wy, climate, min_wl_m, &[])
|
||||
};
|
||||
GoldenSample {
|
||||
label: label.to_string(),
|
||||
@@ -417,3 +432,201 @@ fn golden_cutoffs_match_the_scale_ladder() {
|
||||
assert_eq!(2 * scale::DISTRICT_M, DISTRICT_MIN_WL_M as i32);
|
||||
assert_eq!(2 * scale::QUARTER_M, QUARTER_MIN_WL_M as i32);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// River course golden (T-1170 A2, Discipline item 4)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Empirically verified (probe run against `sample_hm()`/`sample_params()`):
|
||||
// this fixture body produces a real river-cell chain around working-grid
|
||||
// pixel (row≈10, col=116) — the "river_course" sweep position above converts
|
||||
// that pixel to world metres. This section pins the ACTUAL invented course
|
||||
// geometry for an edge from that chain, at both District and Quarter station
|
||||
// spacing, so courses themselves — not just the district-profile fields the
|
||||
// main golden above covers — are regression-pinned.
|
||||
//
|
||||
// **Mouth coverage (T-1170 PR #197 review, Hoshe #2):** the sample set below
|
||||
// also includes a real Mouth-terminus edge from `sample_hm()` (empirically
|
||||
// probed: 4 mouth edges exist in this fixture) — `invent_course`'s raw output
|
||||
// for that edge is pinned here (edge_id/class/terminus/points), closing the
|
||||
// "zero Mouth coverage in any golden" gap at the invention layer. The FULL
|
||||
// end-to-end path (invent → crop → `resolve_mouth_terminus` →
|
||||
// `CourseTerminus::Mouth`) is covered separately and permanently by
|
||||
// `layer_proxy::tests::all_real_gj1c_mouths_resolve_to_mouth_terminus_not_none`
|
||||
// (in-crate, since `crop_course_to_window`/`resolve_mouth_terminus` are
|
||||
// private to `layer_proxy.rs` and unreachable from this integration test) —
|
||||
// that test is the actual Hoshe #1 acceptance criterion (3/3 real mouths);
|
||||
// this golden's job is regression-pinning the raw invented geometry, not
|
||||
// re-proving the crop/resolve path.
|
||||
|
||||
const RIVER_COURSE_GOLDEN_FILE: &str = "tests/golden/river_course_golden.json";
|
||||
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq, Clone)]
|
||||
struct GoldenCourseSample {
|
||||
rung: String,
|
||||
edge_id: u32,
|
||||
class: u8,
|
||||
terminus: String,
|
||||
/// Points rounded to the nearest metre (D-010 integer boundary at the
|
||||
/// golden-pinning layer — the production wire path itself rounds to
|
||||
/// `i32` metres, `layer_proxy::crop_course_to_window`).
|
||||
points: Vec<(i64, i64)>,
|
||||
}
|
||||
|
||||
fn river_course_golden_samples() -> Vec<GoldenCourseSample> {
|
||||
let hm = sample_hm();
|
||||
let ta = sample_ta(&hm);
|
||||
let params = sample_params();
|
||||
let seed = SeedChain::root(0xC0FFEE_u64).derive(SeedDomain::Body, 7);
|
||||
|
||||
let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level);
|
||||
let edges = river_course::build_edges(&dr.river_network);
|
||||
// Pick the interior edge whose upstream cell is closest to (row=10,
|
||||
// col=116) — deterministic (BTreeMap-free linear scan, fixed tie-break
|
||||
// by edge_id) rather than hardcoding an index that could silently shift
|
||||
// if `build_edges`' ordering ever changes.
|
||||
let target = edges
|
||||
.iter()
|
||||
.filter(|e| e.terminus == river_course::EdgeTerminusKind::Interior)
|
||||
.min_by_key(|e| {
|
||||
let dr = e.upstream.0 as i64 - 10;
|
||||
let dc = e.upstream.1 as i64 - 116;
|
||||
(dr * dr + dc * dc, e.edge_id)
|
||||
})
|
||||
.expect("sample_hm() fixture must have at least one interior river edge near (10, 116)");
|
||||
|
||||
// Hoshe #2: a real Mouth-terminus edge, deterministically selected as the
|
||||
// lowest edge_id among the fixture's mouth edges (fixed tie-break, no
|
||||
// hardcoded index).
|
||||
let mouth_target = edges
|
||||
.iter()
|
||||
.filter(|e| e.terminus == river_course::EdgeTerminusKind::Mouth)
|
||||
.min_by_key(|e| e.edge_id)
|
||||
.expect("sample_hm() fixture must have at least one Mouth edge (empirically verified: 4)");
|
||||
|
||||
let mut out = Vec::new();
|
||||
for (rung, spacing_m, min_wl_m) in [
|
||||
("district", DISTRICT_MIN_WL_M, DISTRICT_MIN_WL_M),
|
||||
("quarter", QUARTER_MIN_WL_M, QUARTER_MIN_WL_M),
|
||||
] {
|
||||
let course = river_course::invent_course(seed, target, &ta, ¶ms, spacing_m, min_wl_m);
|
||||
out.push(GoldenCourseSample {
|
||||
rung: rung.to_string(),
|
||||
edge_id: course.edge_id,
|
||||
class: course.class,
|
||||
terminus: format!("{:?}", course.terminus),
|
||||
points: course
|
||||
.points
|
||||
.iter()
|
||||
.map(|p| (p.0.round() as i64, p.1.round() as i64))
|
||||
.collect(),
|
||||
});
|
||||
|
||||
let mouth_course =
|
||||
river_course::invent_course(seed, mouth_target, &ta, ¶ms, spacing_m, min_wl_m);
|
||||
out.push(GoldenCourseSample {
|
||||
rung: format!("{rung}_mouth"),
|
||||
edge_id: mouth_course.edge_id,
|
||||
class: mouth_course.class,
|
||||
terminus: format!("{:?}", mouth_course.terminus),
|
||||
points: mouth_course
|
||||
.points
|
||||
.iter()
|
||||
.map(|p| (p.0.round() as i64, p.1.round() as i64))
|
||||
.collect(),
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn river_course_golden_regression() {
|
||||
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
let golden_path = manifest.join(RIVER_COURSE_GOLDEN_FILE);
|
||||
|
||||
let run1 = river_course_golden_samples();
|
||||
let run2 = river_course_golden_samples();
|
||||
assert_eq!(
|
||||
run1, run2,
|
||||
"double-derivation mismatch — course invention determinism is broken (D-010/D-227)"
|
||||
);
|
||||
|
||||
let actual_json = serde_json::to_string_pretty(&run1).expect("serialize") + "\n";
|
||||
|
||||
if std::env::var("UPDATE_GOLDEN").is_ok() {
|
||||
std::fs::create_dir_all(golden_path.parent().unwrap()).expect("mkdir golden");
|
||||
std::fs::write(&golden_path, &actual_json).expect("write golden");
|
||||
eprintln!(
|
||||
"Golden written: {} ({} bytes)",
|
||||
golden_path.display(),
|
||||
actual_json.len()
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let golden_json = std::fs::read_to_string(&golden_path).unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"Golden file not found: {}.\n\
|
||||
First run: UPDATE_GOLDEN=1 cargo test --test window_derivation_golden\n{e}",
|
||||
golden_path.display()
|
||||
)
|
||||
});
|
||||
|
||||
let actual_v: serde_json::Value = serde_json::from_str(&actual_json).expect("reparse actual");
|
||||
let golden_v: serde_json::Value = serde_json::from_str(&golden_json).expect("parse golden");
|
||||
|
||||
if actual_v != golden_v {
|
||||
panic!(
|
||||
"River-course golden mismatch — course invention changed.\n\
|
||||
Update: UPDATE_GOLDEN=1 cargo test --test window_derivation_golden\n\
|
||||
Golden: {}\nActual: {}",
|
||||
golden_json.trim(),
|
||||
actual_json.trim()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The course golden's target edge must genuinely differ in point geometry
|
||||
/// between District and Quarter station spacing (more, finer-spaced stations
|
||||
/// at Quarter — Ruling 3b's cross-rung invariant) — otherwise the golden
|
||||
/// would be pinning two identical rungs and the test would give false
|
||||
/// confidence.
|
||||
#[test]
|
||||
fn river_course_rungs_have_different_station_counts() {
|
||||
let samples = river_course_golden_samples();
|
||||
let district = samples.iter().find(|s| s.rung == "district").unwrap();
|
||||
let quarter = samples.iter().find(|s| s.rung == "quarter").unwrap();
|
||||
assert!(
|
||||
quarter.points.len() > district.points.len(),
|
||||
"Quarter's finer station spacing must produce MORE points than District \
|
||||
(district={}, quarter={})",
|
||||
district.points.len(),
|
||||
quarter.points.len()
|
||||
);
|
||||
}
|
||||
|
||||
/// **T-1170 PR #197 review, Hoshe #2 (golden coverage):** the pinned Mouth
|
||||
/// edge samples must be genuine, non-degenerate courses (`terminus == "Mouth"`,
|
||||
/// `points.len() >= 2`) — the direct golden-level check that the Hoshe #1 fix
|
||||
/// (real seaward chord via `RiverNetwork::river_seaward`) reaches this fixture
|
||||
/// too, not just the dedicated GJ1c acceptance test.
|
||||
#[test]
|
||||
fn river_course_mouth_samples_are_non_degenerate() {
|
||||
let samples = river_course_golden_samples();
|
||||
for rung in ["district_mouth", "quarter_mouth"] {
|
||||
let sample = samples
|
||||
.iter()
|
||||
.find(|s| s.rung == rung)
|
||||
.unwrap_or_else(|| panic!("missing golden sample for rung {rung}"));
|
||||
assert_eq!(
|
||||
sample.terminus, "Mouth",
|
||||
"{rung}: build_edges must produce a Mouth-terminus RiverEdge for the pinned target"
|
||||
);
|
||||
assert!(
|
||||
sample.points.len() >= 2,
|
||||
"{rung}: Mouth edge invented a degenerate {}-point course — the chord-length fix \
|
||||
(Hoshe #1) regressed for this fixture",
|
||||
sample.points.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,8 @@ use settled_reach_server::atlas::drainage;
|
||||
use settled_reach_server::atlas::features::TerrainAnalysis;
|
||||
use settled_reach_server::atlas::heightmap::BodyHeightmap;
|
||||
use settled_reach_server::atlas::layer_proxy::{
|
||||
build_district_window_layer, WindowGranularity, DISTRICT_WINDOW_MAX_N_REGION, WIRE_CAP_CELLS,
|
||||
build_district_window_layer, WindowGranularity, DISTRICT_WINDOW_MAX_N,
|
||||
DISTRICT_WINDOW_MAX_N_REGION, WIRE_CAP_CELLS,
|
||||
};
|
||||
use settled_reach_server::atlas::scale;
|
||||
use settled_reach_server::seed::{SeedChain, SeedDomain};
|
||||
@@ -54,6 +55,12 @@ fn bench_ta(hm: &BodyHeightmap) -> TerrainAnalysis {
|
||||
TerrainAnalysis::analyze(hm, &dr)
|
||||
}
|
||||
|
||||
fn bench_river_network(
|
||||
hm: &BodyHeightmap,
|
||||
) -> settled_reach_server::atlas::body_world_state::RiverNetwork {
|
||||
drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level).river_network
|
||||
}
|
||||
|
||||
fn bench_params() -> BodyParams {
|
||||
BodyParams {
|
||||
hydrosphere: Some("ocean".into()),
|
||||
@@ -83,8 +90,17 @@ fn time_derive_sweep(
|
||||
for col in 0..grid_side {
|
||||
let wx = col as f64 * step_m;
|
||||
let wy = row as f64 * step_m;
|
||||
let prof =
|
||||
derive_at_metres(seed, body_id, params, ta, wx, wy, climate, min_wavelength_m);
|
||||
let prof = derive_at_metres(
|
||||
seed,
|
||||
body_id,
|
||||
params,
|
||||
ta,
|
||||
wx,
|
||||
wy,
|
||||
climate,
|
||||
min_wavelength_m,
|
||||
&[],
|
||||
);
|
||||
// Prevent the optimizer from hoisting the call out of the loop.
|
||||
std::hint::black_box(prof.elev_q);
|
||||
}
|
||||
@@ -273,6 +289,7 @@ fn bench_derive_orbital_at_metres_region_spacing() {
|
||||
fn bench_served_region_window_tile_at_wire_cap() {
|
||||
let hm = bench_hm();
|
||||
let ta = bench_ta(&hm);
|
||||
let rn = bench_river_network(&hm);
|
||||
let params = bench_params();
|
||||
let climate = ClimateConstants::default();
|
||||
let seed = SeedChain::root(99).derive(SeedDomain::Body, 1);
|
||||
@@ -293,6 +310,7 @@ fn bench_served_region_window_tile_at_wire_cap() {
|
||||
"bench",
|
||||
¶ms,
|
||||
&ta,
|
||||
&rn,
|
||||
(0, 0),
|
||||
n,
|
||||
&climate,
|
||||
@@ -309,6 +327,7 @@ fn bench_served_region_window_tile_at_wire_cap() {
|
||||
"bench",
|
||||
¶ms,
|
||||
&ta,
|
||||
&rn,
|
||||
(0, 0),
|
||||
n,
|
||||
&climate,
|
||||
@@ -338,3 +357,208 @@ fn bench_served_region_window_tile_at_wire_cap() {
|
||||
" compare: shipped district n=64 cap measures ~5 ms/call (design doc §7, MEASURED)\n"
|
||||
);
|
||||
}
|
||||
|
||||
/// **T-1170 Discipline item 2 (mandatory): course-cost bench.** Window
|
||||
/// derive with courses on vs. off, at District granularity, real cap `n=64`
|
||||
/// — the shape Tyre's cost probe measured (+0.09-0.21 ms against a ~5 ms
|
||||
/// baseline, under 5%). "Off" uses an empty `RiverNetwork` (zero edges to
|
||||
/// invent, exactly the pre-T-1170 cost shape); "on" uses a real body with
|
||||
/// genuine river geometry (GJ1c) so the course inventor's Stage A/B pipeline
|
||||
/// actually runs for the edges that cull into the window, not a synthetic
|
||||
/// gradient body that might have zero river cells at all.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn bench_course_cost_on_vs_off() {
|
||||
use settled_reach_server::atlas::body_world_state::RiverNetwork;
|
||||
use settled_reach_server::atlas::drainage;
|
||||
use settled_reach_server::atlas::heightmap::load_heightmap_png;
|
||||
|
||||
let src = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../wiki/star-systems/GJ-1/bodies/GJ1c/heightmap.png");
|
||||
let heightmap = load_heightmap_png(&src, "GJ1c", 0.3).expect("decode committed GJ1c heightmap");
|
||||
let small = heightmap.downsample(512, 256); // GRID_W x GRID_H, the real production working grid
|
||||
let dr = drainage::analyze(&small.data, small.width, small.height, small.sea_level);
|
||||
let ta = TerrainAnalysis::analyze(&small, &dr);
|
||||
let rn_on = &dr.river_network;
|
||||
let rn_off = RiverNetwork::default();
|
||||
assert!(
|
||||
!rn_on.river_cells.is_empty(),
|
||||
"GJ1c at production working resolution must have river cells for this bench to be meaningful"
|
||||
);
|
||||
|
||||
let params = BodyParams {
|
||||
hydrosphere: Some("ocean".into()),
|
||||
atmosphere: Some("breathable".into()),
|
||||
planet_class: Some("temperate".into()),
|
||||
body_radius_km: Some(6371.0),
|
||||
..Default::default()
|
||||
};
|
||||
let climate = ClimateConstants::default();
|
||||
let seed = SeedChain::root(0xC0FFEE_u64).derive(SeedDomain::Body, 7);
|
||||
let n = DISTRICT_WINDOW_MAX_N; // the real n=64 shipped cap
|
||||
|
||||
// Centre the window on a real river cell — a window at the world origin
|
||||
// (unrelated to where GJ1c's rivers actually are) would cull EVERY edge
|
||||
// out and measure nothing but baseline noise. Convert a real river cell
|
||||
// to world metres (the SAME pixel_to_world_m formula
|
||||
// `district_profile.rs` uses internally — inlined here since that
|
||||
// function is `pub(crate)`, not reachable from an integration test),
|
||||
// then to the DistrictPos the window centres on.
|
||||
let river_cell = dr.river_network.river_cells[dr.river_network.river_cells.len() / 2];
|
||||
let r_km = params.body_radius_km.unwrap();
|
||||
let world_pos = (
|
||||
river_cell.1 as f64 / ta.w as f64 * (std::f64::consts::TAU * r_km * 1000.0),
|
||||
(river_cell.0 as f64 / (ta.h - 1) as f64 - 0.5) * (std::f64::consts::PI * r_km * 1000.0),
|
||||
);
|
||||
let center: (i32, i32) = (
|
||||
(world_pos.0 / scale::DISTRICT_M as f64).floor() as i32,
|
||||
(world_pos.1 / scale::DISTRICT_M as f64).floor() as i32,
|
||||
);
|
||||
|
||||
println!("\n=== T-1170 course-cost bench (District, n={n}, real GJ1c river geometry) ===");
|
||||
println!(" window centred at district {center:?} (river cell {river_cell:?})");
|
||||
|
||||
// Warm-up (allocator/cache warm, not counted).
|
||||
let _ = build_district_window_layer(
|
||||
seed,
|
||||
"GJ1c",
|
||||
¶ms,
|
||||
&ta,
|
||||
&rn_off,
|
||||
center,
|
||||
n,
|
||||
&climate,
|
||||
WindowGranularity::District,
|
||||
0,
|
||||
);
|
||||
let _ = build_district_window_layer(
|
||||
seed,
|
||||
"GJ1c",
|
||||
¶ms,
|
||||
&ta,
|
||||
rn_on,
|
||||
center,
|
||||
n,
|
||||
&climate,
|
||||
WindowGranularity::District,
|
||||
0,
|
||||
);
|
||||
|
||||
let iterations = 1000; // higher count than the other benches — window cost here is ~1 ms, noisy at low n
|
||||
|
||||
let t_off = Instant::now();
|
||||
for _ in 0..iterations {
|
||||
let layer = build_district_window_layer(
|
||||
seed,
|
||||
"GJ1c",
|
||||
¶ms,
|
||||
&ta,
|
||||
&rn_off,
|
||||
center,
|
||||
n,
|
||||
&climate,
|
||||
WindowGranularity::District,
|
||||
0,
|
||||
);
|
||||
std::hint::black_box(layer.morphology.len());
|
||||
}
|
||||
let elapsed_off = t_off.elapsed();
|
||||
let ms_off = elapsed_off.as_secs_f64() * 1000.0 / iterations as f64;
|
||||
|
||||
let t_on = Instant::now();
|
||||
let mut courses_seen = 0usize;
|
||||
for _ in 0..iterations {
|
||||
let layer = build_district_window_layer(
|
||||
seed,
|
||||
"GJ1c",
|
||||
¶ms,
|
||||
&ta,
|
||||
rn_on,
|
||||
center,
|
||||
n,
|
||||
&climate,
|
||||
WindowGranularity::District,
|
||||
0,
|
||||
);
|
||||
courses_seen = layer.courses.len();
|
||||
std::hint::black_box(layer.morphology.len());
|
||||
}
|
||||
let elapsed_on = t_on.elapsed();
|
||||
let ms_on = elapsed_on.as_secs_f64() * 1000.0 / iterations as f64;
|
||||
|
||||
assert!(
|
||||
courses_seen > 0,
|
||||
"bench measured nothing meaningful — the window at {center:?} culled every edge out; \
|
||||
re-pick a district position genuinely near GJ1c's river geometry"
|
||||
);
|
||||
|
||||
let delta_pct = ((ms_on - ms_off) / ms_off) * 100.0;
|
||||
|
||||
println!(
|
||||
" courses OFF (empty RiverNetwork): {:.3} ms/call ({iterations} calls, {:.2} ms total)",
|
||||
ms_off,
|
||||
elapsed_off.as_secs_f64() * 1000.0
|
||||
);
|
||||
println!(
|
||||
" courses ON (real GJ1c network): {:.3} ms/call ({iterations} calls, {:.2} ms total, \
|
||||
{courses_seen} courses in the n={n} window at {center:?})",
|
||||
ms_on,
|
||||
elapsed_on.as_secs_f64() * 1000.0
|
||||
);
|
||||
println!(" delta: {delta_pct:+.1}% (Discipline item 2 budget: < ~5%)\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn bench_near_perennial_water_percell_isolated() {
|
||||
use settled_reach_server::atlas::drainage;
|
||||
use settled_reach_server::atlas::heightmap::load_heightmap_png;
|
||||
use settled_reach_server::atlas::river_course;
|
||||
|
||||
let src = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../wiki/star-systems/GJ-1/bodies/GJ1c/heightmap.png");
|
||||
let heightmap = load_heightmap_png(&src, "GJ1c", 0.3).expect("decode heightmap");
|
||||
let small = heightmap.downsample(512, 256);
|
||||
let dr = drainage::analyze(&small.data, small.width, small.height, small.sea_level);
|
||||
let ta = TerrainAnalysis::analyze(&small, &dr);
|
||||
let params = BodyParams {
|
||||
hydrosphere: Some("ocean".into()),
|
||||
atmosphere: Some("breathable".into()),
|
||||
planet_class: Some("temperate".into()),
|
||||
body_radius_km: Some(6371.0),
|
||||
..Default::default()
|
||||
};
|
||||
let seed = SeedChain::root(0xC0FFEE_u64).derive(SeedDomain::Body, 7);
|
||||
|
||||
let edges = river_course::build_edges(&dr.river_network);
|
||||
let edge = &edges[edges.len() / 2];
|
||||
let course = river_course::invent_course(seed, edge, &ta, ¶ms, 2048.0, 0.0);
|
||||
let courses = vec![course];
|
||||
|
||||
let n = 4096u32;
|
||||
let t0 = Instant::now();
|
||||
let mut count = 0;
|
||||
for i in 0..n {
|
||||
let pos = (i as f64 * 100.0, i as f64 * 37.0);
|
||||
if river_course::near_perennial_water(pos, &courses) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
let elapsed = t0.elapsed();
|
||||
eprintln!(
|
||||
"near_perennial_water: {:.3} ns/call ({n} calls, {} hits)",
|
||||
elapsed.as_secs_f64() * 1e9 / n as f64,
|
||||
count
|
||||
);
|
||||
|
||||
// invent_course cost, isolated.
|
||||
let t1 = Instant::now();
|
||||
for _ in 0..100 {
|
||||
let c = river_course::invent_course(seed, edge, &ta, ¶ms, 2048.0, 0.0);
|
||||
std::hint::black_box(c.points.len());
|
||||
}
|
||||
eprintln!(
|
||||
"invent_course: {:.3} us/call",
|
||||
t1.elapsed().as_secs_f64() * 1e6 / 100.0
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user