fix(client): T-1153/T-1152 round 3 — unified rung coverage model, reselect clamp mirror, orbital tile-set mosaic
Bug A (zoom reselection dead): select_rung() redesigned from the two-gate split to a unified per-rung coverage-ceiling walk (MAX_COVERAGE_M, finest-first) — which also restores District as a reachable rung (33-131km extent band; the two-gate model had made it structurally unreachable). Plus the THIRD instance of the PR #191 C1 clamp-mirror bug class: _maybe_reselect_rung fed the old rung's _held_n raw into the new rung's request — the server clamped, the held value didn't, every cross-rung response stale-dropped. Re-clamped at the boundary; the leaving_tile_mode flag also fixes a stale request-granularity edge case. Bug B (orbital = whole body): genuine tile-set model. compute_tile_grid composes ceil-divided Region-tile centers (canonicalized column wrap, row clamp, Dictionary-set dedup; Lendel = 3x2 = 6 tiles); AtlasWindowTileSet owns one AtlasWindowRequest per tile (reusing all tested request/cache/debounce machinery), fans responses out, per-tile tile_ready — genuinely progressive; the overlay draws arrived tiles as a mosaic under the one view transform. enter_orbital branches to tile mode when >1 tile; single-window path unchanged below the coverage ceiling. E/W wrap at tile seams live-confirmed (col -6400 wraps to 12739). Pole- row dedup verified correct but flagged honestly as currently-unreachable defensive logic (no real radius triggers row collision). All fixes + fallout verified via revert/confirm-fails/restore. Targeted suites 256/256; gdlint clean; new 20-test tile-set suite.
This commit is contained in:
@@ -268,84 +268,75 @@ func test_pole_wall_rows_half_matches_canonicalize_rows_half() -> void:
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# T-1153: select_rung() — the §5 rung-selection rule, split into TWO tests
|
||||
# per select_rung()'s own doc: a COVERAGE ceiling decides Region (can a
|
||||
# District window even span this much world), and the `2x` visual-tolerance
|
||||
# rule (design doc §5: "select the coarsest rung whose cell spacing <=
|
||||
# 2*(E/C)") decides District vs. Quarter for whatever's under that ceiling.
|
||||
# T-1153: select_rung() — REDESIGNED (live round 3 finding) per-rung
|
||||
# single-window COVERAGE CEILING model, superseding the original
|
||||
# `2x`-visual-tolerance-only reading of design doc §5. Select the FINEST
|
||||
# rung whose own single-window coverage ceiling (MAX_COVERAGE_M) still
|
||||
# covers the current world extent: Quarter <= 32,768 m; District <=
|
||||
# 131,072 m; Region otherwise (including tiled coverage beyond its own
|
||||
# single-window ceiling, a viewer-level concern — see select_rung()'s own
|
||||
# doc for the full derivation and why this REPLACES the earlier two-gate
|
||||
# design entirely, not just patches it).
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## A tight sample spacing (deep zoom-in — small E over a large C) must select
|
||||
## Quarter (512 m), the finest legal rung — 2*(E/C) is far below District's
|
||||
## 2,048 m spacing at this ratio.
|
||||
func test_select_rung_picks_quarter_at_a_tight_sample_spacing() -> void:
|
||||
# E=2000m over C=1000px -> sample spacing 2 m/px -> threshold 4 m. Even
|
||||
# Quarter (512 m) is coarser than the threshold, so select_rung() falls
|
||||
# through to the FINEST legal rung (its own documented fallback) rather
|
||||
# than returning something even finer that doesn't exist — Quarter.
|
||||
## Deep zoom-in (a tiny extent) selects Quarter — comfortably under its own
|
||||
## 32,768 m ceiling.
|
||||
func test_select_rung_picks_quarter_well_under_its_ceiling() -> void:
|
||||
var rung: String = AtlasWindowGeometry.select_rung(2000.0, 1000.0)
|
||||
assert_str(rung).is_equal("Quarter")
|
||||
|
||||
|
||||
## A sample spacing that satisfies BOTH District's own `2x` band AND the
|
||||
## coverage ceiling selects District — the coarsest rung whose spacing still
|
||||
## satisfies the fine-end rule, without exceeding what a District window can
|
||||
## physically cover.
|
||||
func test_select_rung_picks_district_at_a_moderate_sample_spacing() -> void:
|
||||
# E=120,000m (under the 64*2048=131,072m coverage ceiling) over C=100px ->
|
||||
# threshold = 2*120000/100 = 2,400m — satisfies District's 2,048m spacing.
|
||||
var rung: String = AtlasWindowGeometry.select_rung(120_000.0, 100.0)
|
||||
## An extent past Quarter's own ceiling but under District's selects
|
||||
## District — the finest rung that can still cover it in one window.
|
||||
func test_select_rung_picks_district_between_the_two_ceilings() -> void:
|
||||
# 60,000 m is past Quarter's 32,768 m ceiling but well under District's
|
||||
# 131,072 m one.
|
||||
var rung: String = AtlasWindowGeometry.select_rung(60_000.0, 100.0)
|
||||
assert_str(rung).is_equal("District")
|
||||
|
||||
|
||||
## An extent past the COVERAGE ceiling (more world than a District window can
|
||||
## physically span, regardless of how generous the visual tolerance would
|
||||
## otherwise be) must select Region — the coverage test, not the `2x` visual
|
||||
## one, is what decides this (select_rung()'s own doc: "the coverage ceiling
|
||||
## wins whenever the two disagree").
|
||||
func test_select_rung_picks_region_past_the_coverage_ceiling() -> void:
|
||||
# E = full Earth-like circumference (~40,075 km) — far past the
|
||||
# 64*2048=131,072m District coverage ceiling regardless of canvas_px.
|
||||
## An extent past BOTH Quarter's and District's ceilings selects Region —
|
||||
## neither finer rung's single window can cover this much world.
|
||||
func test_select_rung_picks_region_past_both_finer_ceilings() -> void:
|
||||
var rung: String = AtlasWindowGeometry.select_rung(40_075_264.0, 1920.0)
|
||||
assert_str(rung).is_equal("Region")
|
||||
|
||||
|
||||
## Exactly AT the coverage ceiling (E == 64*2048 = 131,072m) must still
|
||||
## select District if the `2x` band also agrees — the ceiling is `>`, not
|
||||
## `>=`, so the boundary value itself stays under District's own test.
|
||||
func test_select_rung_coverage_ceiling_boundary_stays_district() -> void:
|
||||
## Exactly AT Quarter's own ceiling (32,768 m) must still select Quarter —
|
||||
## the rule is `<=`, not `<`.
|
||||
func test_select_rung_quarter_ceiling_boundary_is_inclusive() -> void:
|
||||
var rung: String = AtlasWindowGeometry.select_rung(32_768.0, 100.0)
|
||||
assert_str(rung).is_equal("Quarter")
|
||||
|
||||
|
||||
## One metre past Quarter's ceiling must flip to District — confirms the
|
||||
## ceiling bites right at its own boundary, not one cell short of it.
|
||||
func test_select_rung_one_past_quarter_ceiling_is_district() -> void:
|
||||
var rung: String = AtlasWindowGeometry.select_rung(32_769.0, 100.0)
|
||||
assert_str(rung).is_equal("District")
|
||||
|
||||
|
||||
## Exactly AT District's own ceiling (131,072 m) must still select District.
|
||||
func test_select_rung_district_ceiling_boundary_is_inclusive() -> void:
|
||||
var rung: String = AtlasWindowGeometry.select_rung(131_072.0, 100.0)
|
||||
assert_str(rung).is_equal("District")
|
||||
|
||||
|
||||
## One metre past the coverage ceiling must flip to Region — confirms the
|
||||
## ceiling actually bites right at its own boundary, not one district-window
|
||||
## short of it.
|
||||
func test_select_rung_one_past_the_coverage_ceiling_is_region() -> void:
|
||||
## One metre past District's ceiling must flip to Region.
|
||||
func test_select_rung_one_past_district_ceiling_is_region() -> void:
|
||||
var rung: String = AtlasWindowGeometry.select_rung(131_073.0, 100.0)
|
||||
assert_str(rung).is_equal("Region")
|
||||
|
||||
|
||||
## Exactly AT District's `2x` threshold (spacing_m == 2*(E/C)) must select
|
||||
## District, not the next-finer rung — the rule is `<=`, not `<`.
|
||||
func test_select_rung_district_threshold_boundary_is_inclusive() -> void:
|
||||
# District spacing = 2048 m. Choose E/C such that 2*(E/C) == 2048 exactly:
|
||||
# E=1024, C=1.0 -> E/C=1024 -> threshold=2048. E=1024 is also comfortably
|
||||
# under the coverage ceiling (131,072), so the `2x` test is what's
|
||||
# actually being exercised here.
|
||||
var rung: String = AtlasWindowGeometry.select_rung(1024.0, 1.0)
|
||||
assert_str(rung).is_equal("District")
|
||||
|
||||
|
||||
## Degenerate canvas_px (<=0, an unlaid-out viewport) must fall back to the
|
||||
## FINEST rung, never crash or pick the coarsest by dividing by zero — the
|
||||
## documented "under-resolve is the safe failure direction" disposition (and
|
||||
## must be checked BEFORE the coverage ceiling could otherwise route a
|
||||
## degenerate small extent toward Region by accident).
|
||||
func test_select_rung_degenerate_canvas_px_falls_back_to_finest() -> void:
|
||||
var rung: String = AtlasWindowGeometry.select_rung(1000.0, 0.0)
|
||||
assert_str(rung).is_equal("Quarter")
|
||||
## canvas_px is unused by the coverage rule (kept for signature stability,
|
||||
## see select_rung()'s own doc) — degenerate/zero values must not change the
|
||||
## selected rung at all, unlike the old `2x`-tolerance design's special-cased
|
||||
## fallback.
|
||||
func test_select_rung_canvas_px_does_not_affect_selection() -> void:
|
||||
var with_real_canvas: String = AtlasWindowGeometry.select_rung(2000.0, 1000.0)
|
||||
var with_zero_canvas: String = AtlasWindowGeometry.select_rung(2000.0, 0.0)
|
||||
assert_str(with_zero_canvas).is_equal(with_real_canvas)
|
||||
|
||||
|
||||
## spacing_for_rung() is select_rung()'s inverse lookup — pin the three known
|
||||
@@ -363,16 +354,26 @@ func test_spacing_for_rung_unknown_tag_falls_back_to_district() -> void:
|
||||
assert_float(AtlasWindowGeometry.spacing_for_rung("Nonsense")).is_equal_approx(2048.0, 0.001)
|
||||
|
||||
|
||||
## The exact scenario that surfaced the coverage-vs-visual-tolerance
|
||||
## distinction (live-testing enter_orbital()'s own fit zoom): a whole
|
||||
## Earth-like body's circumference (~40,075 km, matching
|
||||
## AtlasDescendGeometry.district_extent()'s own cols*DISTRICT_M for
|
||||
## radius=6371km) fitted to a 1920px-wide viewport at CELL_PIXEL_SIZE=16 must
|
||||
## select Region — this is the direct regression guard for the bug this
|
||||
## implementation found and fixed (an earlier version of select_rung()
|
||||
## selected District here, which would have meant the canonical orbital
|
||||
## frame requests a District-tier derive spanning an entire planet — the
|
||||
## exact R1-catastrophe cost scenario the design doc §4 rejects).
|
||||
## MAX_COVERAGE_M's three values, pinned directly against the formulas
|
||||
## select_rung()'s own doc derives them from — a regression guard
|
||||
## independent of select_rung()'s own boundary tests above, so a future
|
||||
## accidental edit to the constants table itself (not just the selection
|
||||
## logic) is caught here too.
|
||||
func test_max_coverage_m_matches_derived_formulas() -> void:
|
||||
assert_float(AtlasWindowGeometry.MAX_COVERAGE_M["Quarter"]).is_equal_approx(32_768.0, 0.001)
|
||||
assert_float(AtlasWindowGeometry.MAX_COVERAGE_M["District"]).is_equal_approx(131_072.0, 0.001)
|
||||
assert_float(AtlasWindowGeometry.MAX_COVERAGE_M["Region"]).is_equal_approx(13_107_200.0, 0.001)
|
||||
|
||||
|
||||
## The exact scenario that surfaced the original design flaw
|
||||
## (live-testing enter_orbital()'s own fit zoom): a whole Earth-like body's
|
||||
## circumference (~40,075 km, matching AtlasDescendGeometry.district_extent()'s
|
||||
## own cols*DISTRICT_M for radius=6371km) fitted to a 1920px-wide viewport at
|
||||
## CELL_PIXEL_SIZE=16 must select Region — the direct regression guard for
|
||||
## the bug an early version of select_rung() had (picking District here,
|
||||
## which would have meant the canonical orbital frame requests a
|
||||
## District-tier derive spanning an entire planet — the exact R1-catastrophe
|
||||
## cost scenario the design doc §4 rejects).
|
||||
func test_select_rung_at_orbital_fit_zoom_selects_region() -> void:
|
||||
var radius_km := 6371.0
|
||||
var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km)
|
||||
@@ -390,50 +391,29 @@ func test_select_rung_at_orbital_fit_zoom_selects_region() -> void:
|
||||
).is_equal("Region")
|
||||
|
||||
|
||||
## Pinned capture-resolution boundary numbers (1600x900, the coordinator's
|
||||
## requested eyeball-capture viewport) — a live executable regression guard
|
||||
## for select_rung()'s own doc's worked example. Region releases District's
|
||||
## coverage ceiling at _view_zoom ~= 1.5625; District's own `2x` band edge
|
||||
## sits at _view_zoom ~= 0.125 — i.e. BELOW (not above) the coverage-ceiling
|
||||
## crossing, confirming the two never overlap at this (or any real) canvas
|
||||
## size — see select_rung()'s "Tuning knobs" paragraph for what would need
|
||||
## to change (DISTRICT_WINDOW_MAX_N, a server-side wire-budget change) to
|
||||
## open a real District band.
|
||||
func test_select_rung_1600x900_region_district_boundary_zoom() -> void:
|
||||
var viewport := Vector2(1600.0, 900.0)
|
||||
var canvas_px: float = maxf(viewport.x, viewport.y)
|
||||
var boundary_zoom := 1.5625
|
||||
var just_inside: float = AtlasWindowGeometry.world_extent_m(
|
||||
CELL_PIXEL_SIZE, boundary_zoom * 1.001, viewport
|
||||
)
|
||||
var just_outside: float = AtlasWindowGeometry.world_extent_m(
|
||||
CELL_PIXEL_SIZE, boundary_zoom * 0.999, viewport
|
||||
)
|
||||
assert_str(AtlasWindowGeometry.select_rung(just_inside, canvas_px)).override_failure_message(
|
||||
"zoomed IN past ~1.5625 at 1600x900 must have released the Region coverage ceiling"
|
||||
).is_not_equal("Region")
|
||||
assert_str(AtlasWindowGeometry.select_rung(just_outside, canvas_px)).override_failure_message(
|
||||
"zoomed OUT past ~1.5625 at 1600x900 must still be under the Region coverage ceiling"
|
||||
).is_equal("Region")
|
||||
|
||||
|
||||
func test_select_rung_1600x900_district_quarter_boundary_zoom_confirms_no_overlap() -> void:
|
||||
var viewport := Vector2(1600.0, 900.0)
|
||||
var canvas_px: float = maxf(viewport.x, viewport.y)
|
||||
var boundary_zoom := 0.125
|
||||
var just_inside: float = AtlasWindowGeometry.world_extent_m(
|
||||
CELL_PIXEL_SIZE, boundary_zoom * 1.001, viewport
|
||||
)
|
||||
var just_outside: float = AtlasWindowGeometry.world_extent_m(
|
||||
CELL_PIXEL_SIZE, boundary_zoom * 0.999, viewport
|
||||
)
|
||||
# Both sides of the District/Quarter `2x`-band boundary read "Region" at
|
||||
# 1600x900, NOT "District" — confirming the coverage ceiling (which
|
||||
# releases at zoom~=1.5625, far above this boundary) has already forced
|
||||
# Region long before the `2x` band's own edge is reached. This is the
|
||||
# literal "no overlap" finding, pinned as an executable assertion.
|
||||
assert_str(AtlasWindowGeometry.select_rung(just_inside, canvas_px)).is_equal("Region")
|
||||
assert_str(AtlasWindowGeometry.select_rung(just_outside, canvas_px)).is_equal("Region")
|
||||
## **Live round 3 regression, the direct fix target:** at 1600x900 (the
|
||||
## coordinator's capture viewport), zooming IN from the orbital fit all the
|
||||
## way to Quarter's own ceiling must pass through District along the way —
|
||||
## a wheel-zoom gesture crossing world_extent_m from Region's territory down
|
||||
## to Quarter's must select District for SOME real span of extent in
|
||||
## between, not skip straight from Region to Quarter (the exact "money shot"
|
||||
## the coordinator wants capture-worthy: a visible SHARPEN in place, not a
|
||||
## jump).
|
||||
func test_select_rung_district_is_reachable_between_region_and_quarter() -> void:
|
||||
# An extent comfortably between District's and Quarter's ceilings (e.g.
|
||||
# the midpoint) must select District — proving the band is non-empty,
|
||||
# unlike the old two-gate design where it was empty by construction at
|
||||
# every real viewport (see git history / the coordinator's live-round
|
||||
# finding for the retired analysis).
|
||||
var midpoint: float = (
|
||||
(AtlasWindowGeometry.MAX_COVERAGE_M["Quarter"] as float)
|
||||
+ (AtlasWindowGeometry.MAX_COVERAGE_M["District"] as float)
|
||||
) * 0.5
|
||||
var rung: String = AtlasWindowGeometry.select_rung(midpoint, 1600.0)
|
||||
assert_str(rung).override_failure_message(
|
||||
"District must be reachable between Quarter's and District's own"
|
||||
+ " coverage ceilings — the redesigned rule must not skip it"
|
||||
).is_equal("District")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -598,3 +578,102 @@ func test_edge_scroll_direction_points_west_near_left_edge() -> void:
|
||||
)
|
||||
assert_float(direction.x).is_less(0.0)
|
||||
assert_float(direction.y).is_equal_approx(0.0, 0.001)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# T-1153, live round 3 (Jeroen's ruling, design doc §4): compute_tile_grid()
|
||||
# — the orbital rest state's multi-window mosaic.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## The exact live-round scenario: GJ380c/Lendel (radius 6238.4 km) needs a
|
||||
## 3x2 = 6-tile grid — the coordinator's own estimate, confirmed here as an
|
||||
## executable regression.
|
||||
func test_compute_tile_grid_lendel_produces_six_tiles() -> void:
|
||||
var tiles: Array = AtlasWindowGeometry.compute_tile_grid(6238.4)
|
||||
assert_int(tiles.size()).override_failure_message(
|
||||
"GJ380c/Lendel must tile into 3x2=6 windows, matching the coordinator's own"
|
||||
+ " live-round finding (13,107.2 km single-window coverage vs. 39,198 km"
|
||||
+ " circumference)"
|
||||
).is_equal(6)
|
||||
|
||||
|
||||
## A tiny body whose whole circumference fits in ONE Region window's
|
||||
## coverage ceiling must produce exactly ONE tile — tiling degenerates
|
||||
## gracefully to the pre-existing single-window behavior when it isn't
|
||||
## actually needed.
|
||||
func test_compute_tile_grid_tiny_body_produces_one_tile() -> void:
|
||||
# radius small enough that circumference << MAX_COVERAGE_M["Region"]
|
||||
# (13,107,200 m) — a few hundred km radius comfortably qualifies.
|
||||
var tiles: Array = AtlasWindowGeometry.compute_tile_grid(50.0)
|
||||
assert_int(tiles.size()).is_equal(1)
|
||||
assert_that(tiles[0]).is_equal(Vector2i.ZERO)
|
||||
|
||||
|
||||
## A no-radius body (tiny test body) must produce exactly one tile at the
|
||||
## canonical origin — matching enter_orbital()'s own no-radius fallback
|
||||
## disposition (no circumference/tiling concept without a radius).
|
||||
func test_compute_tile_grid_no_radius_produces_single_origin_tile() -> void:
|
||||
var tiles: Array = AtlasWindowGeometry.compute_tile_grid(0.0)
|
||||
assert_int(tiles.size()).is_equal(1)
|
||||
assert_that(tiles[0]).is_equal(Vector2i.ZERO)
|
||||
|
||||
|
||||
## Every tile center must be a LEGAL canonicalized DistrictPos — column
|
||||
## wrapped into [0, cols), row clamped into [-rows_half, rows_half] — the
|
||||
## same range canonicalize_district_center() enforces everywhere else in
|
||||
## this cluster (pan refetch, entry, rung-reselect). A raw, uncanonicalized
|
||||
## tile center would fail the server's own normalize_window_center() (or
|
||||
## silently alias to a different tile than intended).
|
||||
func test_compute_tile_grid_tiles_are_all_canonicalized() -> void:
|
||||
var radius_km := 6238.4
|
||||
var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km)
|
||||
var cols: int = int(extent["cols"])
|
||||
var rows_half: int = int(extent["rows_half"])
|
||||
var tiles: Array = AtlasWindowGeometry.compute_tile_grid(radius_km)
|
||||
for tile: Vector2i in tiles:
|
||||
assert_int(tile.x).override_failure_message(
|
||||
"tile column %d must be wrapped into [0, %d)" % [tile.x, cols]
|
||||
).is_greater_equal(0)
|
||||
assert_int(tile.x).is_less(cols)
|
||||
assert_int(tile.y).override_failure_message(
|
||||
"tile row %d must be clamped into [-%d, %d]" % [tile.y, rows_half, rows_half]
|
||||
).is_greater_equal(-rows_half)
|
||||
assert_int(tile.y).is_less_equal(rows_half)
|
||||
|
||||
|
||||
## No two tiles may share the same canonicalized center — compute_tile_grid()
|
||||
## must dedupe (a pole-row clamp or column-wrap collision producing the exact
|
||||
## same DistrictPos twice would otherwise request/draw the same tile twice,
|
||||
## wasting a request and drawing one tile over another).
|
||||
func test_compute_tile_grid_has_no_duplicate_centers() -> void:
|
||||
var tiles: Array = AtlasWindowGeometry.compute_tile_grid(6238.4)
|
||||
var seen: Dictionary = {}
|
||||
for tile: Vector2i in tiles:
|
||||
assert_bool(seen.has(tile)).override_failure_message(
|
||||
"tile center %s appears more than once in the grid" % str(tile)
|
||||
).is_false()
|
||||
seen[tile] = true
|
||||
|
||||
|
||||
## The tile grid's own center of mass must land on the canonical origin
|
||||
## (0,0) — the tile-set's symmetric layout (each axis' centers computed as
|
||||
## `(index - (count-1)/2) * TILE_N`) is centered on the SAME canonical origin
|
||||
## enter_orbital() uses, so the tile-set's overall framing agrees with
|
||||
## single-window enter_orbital()'s own "center on (0,0)" contract.
|
||||
func test_compute_tile_grid_is_centered_on_the_canonical_origin() -> void:
|
||||
var tiles: Array = AtlasWindowGeometry.compute_tile_grid(6238.4)
|
||||
var sum_col := 0
|
||||
var sum_row := 0
|
||||
for tile: Vector2i in tiles:
|
||||
sum_col += tile.x
|
||||
sum_row += tile.y
|
||||
# Column centers wrap (periodic), so a raw average isn't meaningful there
|
||||
# the way it is for rows — assert row symmetry directly instead (rows
|
||||
# never wrap, so their average must be very close to 0 for a
|
||||
# symmetric grid).
|
||||
var avg_row: float = float(sum_row) / float(tiles.size())
|
||||
assert_float(avg_row).override_failure_message(
|
||||
"the tile grid's row centers must average to ~0 (symmetric around the"
|
||||
+ " canonical origin's equator row)"
|
||||
).is_equal_approx(0.0, float(AtlasWindowGeometry.TILE_N))
|
||||
|
||||
@@ -23,10 +23,14 @@ static func _mock_window(n: int = 2) -> Dictionary:
|
||||
|
||||
|
||||
## Minimal viewer stub — AtlasWindowOverlay only reaches the viewer through
|
||||
## get_district_window()/is_overlay_visible()/get_cell_pixel_size(), so a
|
||||
## bare stub with just those three methods is a legitimate "viewer" for
|
||||
## these tests, matching the duck-typed-viewer precedent this whole overlay
|
||||
## cluster already relies on (atlas_overlay_bar.gd/atlas_legend_panel.gd).
|
||||
## get_district_window()/is_overlay_visible()/get_cell_pixel_size()/
|
||||
## is_tile_mode(), so a bare stub with just those methods is a legitimate
|
||||
## "viewer" for these tests, matching the duck-typed-viewer precedent this
|
||||
## whole overlay cluster already relies on (atlas_overlay_bar.gd/
|
||||
## atlas_legend_panel.gd). is_tile_mode() always returns false — this suite
|
||||
## covers the single-window composite-cache path only; the tile mosaic path
|
||||
## is covered separately by test_atlas_window_tile_set.gd + the viewer's own
|
||||
## is_tile_mode()-branching tests.
|
||||
class _ViewerStub:
|
||||
var window: Variant = null
|
||||
var active_overlay: String = ""
|
||||
@@ -40,6 +44,9 @@ class _ViewerStub:
|
||||
func get_cell_pixel_size() -> float:
|
||||
return 16.0
|
||||
|
||||
func is_tile_mode() -> bool:
|
||||
return false
|
||||
|
||||
|
||||
func test_composite_smooth_defaults_true() -> void:
|
||||
assert_bool(AtlasWindowOverlay.COMPOSITE_SMOOTH).override_failure_message(
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
## T-1153, live round 3 (Jeroen's ruling, design doc §4): tests for
|
||||
## AtlasWindowTileSet — the orbital rest-state multi-window mosaic
|
||||
## orchestration. Same hand-built-response-dict conventions as
|
||||
## test_atlas_window_request.gd/test_atlas_zoom_ladder.gd; this file is
|
||||
## about the ORCHESTRATION (N tiles, progressive per-tile arrival,
|
||||
## teardown), not the tile-grid MATH (already covered directly against
|
||||
## AtlasWindowGeometry.compute_tile_grid() in test_atlas_window_geometry.gd).
|
||||
class_name TestAtlasWindowTileSet
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const AtlasWindowTileSet := preload("res://ui/implant/apps/atlas/atlas_window_tile_set.gd")
|
||||
const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_request.gd")
|
||||
|
||||
|
||||
static func _mock_window(center: Vector2i, n: int) -> Dictionary:
|
||||
return {
|
||||
"center": [center.x, center.y],
|
||||
"n": n,
|
||||
"granularity_v2": "Region",
|
||||
"morphology": PackedByteArray([1, 2, 3, 4]),
|
||||
"elev_q": PackedByteArray([10, 20, 30, 40]),
|
||||
"temp_dc": [0, 0, 0, 0],
|
||||
"moisture_q": PackedByteArray([0, 0, 0, 0]),
|
||||
"vegetation": PackedByteArray([0, 0, 0, 0]),
|
||||
"glaciation": PackedByteArray([0, 0, 0, 0]),
|
||||
}
|
||||
|
||||
|
||||
static func _mock_response(body_id: String, window: Variant) -> Dictionary:
|
||||
return {"body_id": body_id, "status": "Ready", "district_window": window}
|
||||
|
||||
|
||||
func _make_tile_set() -> Variant:
|
||||
var owner_stub := RefCounted.new()
|
||||
var ts = auto_free(AtlasWindowTileSet.new(owner_stub))
|
||||
add_child(ts)
|
||||
return ts
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# enter() — tile grid computation + one request per tile
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## enter() on a real, tiling-sized body must produce the SAME tile count
|
||||
## compute_tile_grid() would — 6 for GJ380c/Lendel, the coordinator's own
|
||||
## live-round number.
|
||||
func test_enter_produces_the_expected_tile_count_for_lendel() -> void:
|
||||
var ts = _make_tile_set()
|
||||
ts.enter("GJ380c", 6238.4)
|
||||
assert_int(ts.get_tile_count()).is_equal(6)
|
||||
assert_bool(ts.is_multi_tile()).is_true()
|
||||
|
||||
|
||||
## A tiny (non-tiling) body produces exactly ONE tile — the degenerate case
|
||||
## compute_tile_grid() itself already covers; this confirms the ORCHESTRATION
|
||||
## (not just the grid math) handles it without crashing or requesting zero
|
||||
## tiles.
|
||||
func test_enter_tiny_body_produces_one_tile() -> void:
|
||||
var ts = _make_tile_set()
|
||||
ts.enter("TinyBody", 50.0)
|
||||
assert_int(ts.get_tile_count()).is_equal(1)
|
||||
assert_bool(ts.is_multi_tile()).is_false()
|
||||
|
||||
|
||||
## Every tile must start with a null window (nothing has arrived yet) and
|
||||
## the tile set must not report "fully arrived" before any response lands.
|
||||
func test_enter_all_tiles_start_unarrived() -> void:
|
||||
var ts = _make_tile_set()
|
||||
ts.enter("GJ380c", 6238.4)
|
||||
for tile: Dictionary in ts.get_tiles():
|
||||
assert_that(tile["window"]).is_null()
|
||||
assert_bool(ts.is_fully_arrived()).is_false()
|
||||
|
||||
|
||||
## An empty tile set (never entered) must not report "fully arrived" either
|
||||
## — an empty AND-over-nothing must not vacuously read true.
|
||||
func test_empty_tile_set_is_not_fully_arrived() -> void:
|
||||
var ts = _make_tile_set()
|
||||
assert_bool(ts.is_fully_arrived()).is_false()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Progressive per-tile arrival (design doc §4: "with visible refinement as
|
||||
# tiles complete") — each tile's response is independent of every other's.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## Delivering ONE tile's response must populate ONLY that tile's window,
|
||||
## leaving every other tile still null — the direct "progressive, not
|
||||
## block-on-all" regression.
|
||||
func test_one_tile_arriving_does_not_affect_the_others() -> void:
|
||||
var ts = _make_tile_set()
|
||||
ts.enter("GJ380c", 6238.4)
|
||||
var tiles: Array = ts.get_tiles()
|
||||
var first_center: Vector2i = tiles[0]["center"]
|
||||
var window: Dictionary = _mock_window(first_center, AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION)
|
||||
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", window))
|
||||
|
||||
var updated_tiles: Array = ts.get_tiles()
|
||||
assert_that(updated_tiles[0]["window"]).override_failure_message(
|
||||
"the tile whose response arrived must have its window populated"
|
||||
).is_equal(window)
|
||||
for i in range(1, updated_tiles.size()):
|
||||
assert_that(updated_tiles[i]["window"]).override_failure_message(
|
||||
"tile %d must still be unarrived — only tile 0's response was delivered" % i
|
||||
).is_null()
|
||||
|
||||
|
||||
## tile_ready must fire with the INDEX of the tile that actually arrived —
|
||||
## the viewer/overlay needs this to know WHICH tile to redraw, not just
|
||||
## "something changed".
|
||||
func test_tile_ready_signal_fires_with_the_correct_index() -> void:
|
||||
var ts = _make_tile_set()
|
||||
ts.enter("GJ380c", 6238.4)
|
||||
var received_indices: Array = []
|
||||
ts.tile_ready.connect(func(index: int) -> void: received_indices.append(index))
|
||||
|
||||
var tiles: Array = ts.get_tiles()
|
||||
var second_center: Vector2i = tiles[1]["center"]
|
||||
var window: Dictionary = _mock_window(second_center, AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION)
|
||||
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", window))
|
||||
|
||||
assert_int(received_indices.size()).is_equal(1)
|
||||
assert_int(received_indices[0]).is_equal(1)
|
||||
|
||||
|
||||
## Delivering EVERY tile's response must flip is_fully_arrived() to true —
|
||||
## the mosaic-complete signal the viewer/legend chrome can use.
|
||||
func test_all_tiles_arriving_flips_fully_arrived() -> void:
|
||||
var ts = _make_tile_set()
|
||||
ts.enter("GJ380c", 6238.4)
|
||||
var tiles: Array = ts.get_tiles()
|
||||
for tile: Dictionary in tiles:
|
||||
var window: Dictionary = _mock_window(
|
||||
tile["center"], AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION
|
||||
)
|
||||
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", window))
|
||||
|
||||
assert_bool(ts.is_fully_arrived()).override_failure_message(
|
||||
"once every tile's response has arrived, the tile set must report fully arrived"
|
||||
).is_true()
|
||||
|
||||
|
||||
## A response for a body the tile set is NOT currently showing (a stale
|
||||
## response from a body the player has since navigated away from) must not
|
||||
## be adopted by any tile — the SAME body_id staleness guard every other
|
||||
## AtlasWindowRequest-based path already relies on (this is inherited for
|
||||
## free since each tile IS an AtlasWindowRequest, but pinned here as an
|
||||
## orchestration-level regression too).
|
||||
func test_response_for_a_different_body_is_ignored() -> void:
|
||||
var ts = _make_tile_set()
|
||||
ts.enter("GJ380c", 6238.4)
|
||||
var tiles: Array = ts.get_tiles()
|
||||
var window: Dictionary = _mock_window(
|
||||
tiles[0]["center"], AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION
|
||||
)
|
||||
SimBridge.atlas_layers_received.emit(_mock_response("GJ_wrong_body", window))
|
||||
|
||||
assert_that(ts.get_tiles()[0]["window"]).is_null()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Teardown — re-entering (a fresh body, or the same body again) must not
|
||||
# leave stale tile request nodes wired up.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## Calling enter() a SECOND time (e.g. re-entering the orbital frame, or
|
||||
## switching to a different body) must replace the tile set entirely — the
|
||||
## OLD tiles' indices/centers must not linger.
|
||||
func test_second_enter_replaces_the_tile_set() -> void:
|
||||
var ts = _make_tile_set()
|
||||
ts.enter("GJ380c", 6238.4)
|
||||
var first_count: int = ts.get_tile_count()
|
||||
assert_int(first_count).is_equal(6)
|
||||
|
||||
ts.enter("TinyBody", 50.0)
|
||||
assert_int(ts.get_tile_count()).override_failure_message(
|
||||
"a second enter() must fully replace the tile set, not append to it"
|
||||
).is_equal(1)
|
||||
|
||||
|
||||
## A response matching an OLD tile set's (body, center) — arriving AFTER a
|
||||
## second enter() has already torn it down — must not be adopted (or crash):
|
||||
## the old tile's AtlasWindowRequest node is queue_free()'d, and _tiles no
|
||||
## longer references it, so a stale signal (if it could somehow still fire)
|
||||
## has no live entry left to update.
|
||||
func test_stale_response_after_second_enter_does_not_crash_or_leak() -> void:
|
||||
var ts = _make_tile_set()
|
||||
ts.enter("GJ380c", 6238.4)
|
||||
var old_tiles: Array = ts.get_tiles()
|
||||
var old_center: Vector2i = old_tiles[0]["center"]
|
||||
|
||||
ts.enter("GJ380c", 50.0) # same body_id, different (tiny) radius -> different tile grid
|
||||
|
||||
# A response shaped like it's answering the OLD tile set's first tile —
|
||||
# must not crash, and must not corrupt the NEW tile set's single tile.
|
||||
var stale_window: Dictionary = _mock_window(
|
||||
old_center, AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION
|
||||
)
|
||||
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", stale_window))
|
||||
|
||||
assert_int(ts.get_tile_count()).is_equal(1)
|
||||
@@ -73,81 +73,64 @@ func test_enter_orbital_requests_region_granularity() -> void:
|
||||
assert_str(v._held_granularity_v2).is_equal("Region")
|
||||
|
||||
|
||||
## enter_orbital()'s `n` intent is the body's full equatorial circumference
|
||||
## in districts (district_extent().cols) — the whole body fitted to the
|
||||
## canvas, per Jeroen's HARD condition wording — BUT `_held_n` is what
|
||||
## actually gets STORED/SENT, and that must be the CLAMPED value
|
||||
## (live-round finding: the raw cols value, routinely tens of thousands at
|
||||
## Region granularity, was stored unclamped while AtlasWindowRequest clamped
|
||||
## before sending — see _enter_at_rung()'s own doc for the full C1-one-layer-up
|
||||
## story). GJ380c's real cols (~19,139 per the live repro) exceeds
|
||||
## DISTRICT_WINDOW_MAX_N_REGION's clamp ceiling, so this body is the exact
|
||||
## regression case, not a hypothetical.
|
||||
func test_enter_orbital_n_is_the_clamped_value_not_raw_circumference() -> void:
|
||||
## **Superseded by live round 3's tiling fix — retargeted, not deleted.**
|
||||
## GJ380c/Lendel (radius 6238.4 km) was the ORIGINAL single-window C1 repro
|
||||
## (raw cols ~19,139 vs. the 6,400 clamp ceiling) — but that SAME threshold
|
||||
## (`DISTRICT_WINDOW_MAX_N_REGION * DISTRICT_M` = the coverage ceiling
|
||||
## `compute_tile_grid()` tiles past) means any body needing the n-clamp ALSO
|
||||
## needs tiling: there is no real body where enter_orbital() takes the
|
||||
## single-window path with a raw `n` big enough to require clamping.
|
||||
## GJ380c now correctly enters TILE mode (test_enter_orbital_n_is_the_clamped_value_not_raw_circumference's
|
||||
## old assertion on a single clamped `_held_n` no longer applies — see
|
||||
## test_enter_orbital_tile_mode_held_n_is_the_whole_body_extent below for
|
||||
## what `_held_n` means in tile mode instead). The single-window clamp-mirror
|
||||
## fix itself remains covered: `_enter_at_rung()`'s own doc/the clamp
|
||||
## mirror's unit tests (test_atlas_window_request.gd) pin the formula
|
||||
## directly, and test_zoom_crossing_fires_request_and_accepts_wire_accurate_refinement
|
||||
## exercises the SAME clamp-mirror lesson at the reselect (not entry)
|
||||
## boundary, which single-window mode still reaches on the way DOWN from a
|
||||
## tile-mode zoom-in.
|
||||
func test_enter_orbital_tile_mode_held_n_is_the_whole_body_extent() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
var radius_km := 6238.4 # GJ380c (Lendel) — the live-repro body
|
||||
var radius_km := 6238.4 # GJ380c (Lendel)
|
||||
var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km)
|
||||
var raw_cols: int = int(extent["cols"])
|
||||
v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {})
|
||||
|
||||
var expected_clamped: int = AtlasWindowRequest._clamp_window_n_mirror_v2(raw_cols, "Region")
|
||||
var failure_msg: String = (
|
||||
"_held_n must be the CLAMPED n (%d), matching what the server will echo —"
|
||||
+ " not the raw circumference (%d), which the server would clamp down and"
|
||||
+ " every response would then fail the w_n != _held_n staleness check"
|
||||
) % [expected_clamped, raw_cols]
|
||||
assert_int(v._held_n).override_failure_message(failure_msg).is_equal(expected_clamped)
|
||||
# GJ380c's raw circumference must actually exceed the clamp — otherwise this
|
||||
# test would pass trivially without exercising the clamp at all.
|
||||
assert_int(raw_cols).override_failure_message(
|
||||
"GJ380c's raw district-column count must exceed the Region clamp ceiling"
|
||||
+ " for this to be a real regression guard, not a no-op"
|
||||
).is_greater(expected_clamped)
|
||||
assert_bool(v.is_tile_mode()).override_failure_message(
|
||||
"GJ380c/Lendel needs tiling — enter_orbital() must have entered tile mode"
|
||||
).is_true()
|
||||
# In TILE mode, _held_n is the WHOLE body's extent (unclamped) — each
|
||||
# TILE clamps its own request independently inside AtlasWindowTileSet
|
||||
# (see that file's own tests), so _held_n here is NOT expected to equal
|
||||
# any single clamped value the way single-window mode's is.
|
||||
assert_int(v._held_n).is_equal(raw_cols)
|
||||
|
||||
|
||||
## **The live-round regression, end to end (fix #1: the n-clamp mirror one
|
||||
## layer up):** enter_orbital() on a real-sized body (GJ380c/Lendel, radius
|
||||
## 6238.4 km, raw cols far past the Region clamp ceiling) followed by a
|
||||
## server response echoing the CLAMPED n + "Region" granularity must be
|
||||
## ACCEPTED and become the held window — not silently dropped as stale
|
||||
## forever (the exact live bug: `wv._held_n = 19139` vs. echoed `6400`,
|
||||
## blank ladder on every real-sized body). This is the round-trip the
|
||||
## existing suite never exercised — every prior enter_orbital() test
|
||||
## asserted on request-side state only, never delivered a response.
|
||||
##
|
||||
## **WIRE-ACCURATE response shape (fix #2, second live-round finding):** the
|
||||
## response dict below carries `"granularity":
|
||||
## SERVER_LEGACY_GRANULARITY_REGION_SENTINEL` explicitly — the ACTUAL byte a
|
||||
## real server sends, not the field's absence. The first version of this
|
||||
## test omitted the legacy key entirely, which let `w.get("granularity",
|
||||
## DEFAULT)` silently default to `1` (matching `_granularity`'s own pinned
|
||||
## value) — an ACCIDENTAL pass that never exercised the real sentinel
|
||||
## mismatch, exactly the class of gap the live round exists to catch. This
|
||||
## version fails without the v2-authoritative-when-present fix in
|
||||
## `_echoed_granularity_matches()`.
|
||||
func test_enter_orbital_oversized_body_accepts_the_clamped_region_response() -> void:
|
||||
## **The live-round-3 regression, end to end for TILE mode:** enter_orbital()
|
||||
## on GJ380c/Lendel followed by delivering ONE tile's wire-accurate response
|
||||
## (clamped n=6,400, "Region" granularity_v2, the legacy sentinel in the old
|
||||
## granularity slot — exactly what a real server sends) must be ACCEPTED
|
||||
## into that tile's own slot — not silently dropped. This exercises BOTH
|
||||
## live-round fixes (the v2-authoritative precedence AND per-tile clamping)
|
||||
## through the tile-set path specifically, complementing
|
||||
## test_atlas_window_tile_set.gd's own more granular orchestration tests.
|
||||
func test_enter_orbital_tile_mode_accepts_a_wire_accurate_tile_response() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
var radius_km := 6238.4 # GJ380c (Lendel) — the live-repro body
|
||||
var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km)
|
||||
var raw_cols: int = int(extent["cols"])
|
||||
var clamped_n: int = AtlasWindowRequest._clamp_window_n_mirror_v2(raw_cols, "Region")
|
||||
# Sanity: this body must actually need clamping, or the test proves nothing.
|
||||
assert_int(raw_cols).is_greater(clamped_n)
|
||||
|
||||
v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {})
|
||||
assert_that(v.get_district_window()).override_failure_message(
|
||||
"no response delivered yet — must still be null"
|
||||
).is_null()
|
||||
assert_bool(v.is_tile_mode()).is_true()
|
||||
|
||||
# The server's REAL response: echoes the CLAMPED n, "Region" granularity_v2
|
||||
# (String), center (0,0), AND the legacy sentinel in "granularity" — exactly
|
||||
# what handle_atlas_request/clamp_window_n_v2 actually produces on the wire
|
||||
# for an oversized orbital request (confirmed against Dudley's contract).
|
||||
var region_window: Dictionary = {
|
||||
"center": [0, 0],
|
||||
"n": clamped_n,
|
||||
var tile_set = v.get_tile_set()
|
||||
var tiles: Array = tile_set.get_tiles()
|
||||
assert_int(tiles.size()).is_greater(1)
|
||||
var first_tile_center: Vector2i = tiles[0]["center"]
|
||||
|
||||
var tile_window: Dictionary = {
|
||||
"center": [first_tile_center.x, first_tile_center.y],
|
||||
"n": AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION,
|
||||
"granularity": SERVER_LEGACY_GRANULARITY_REGION_SENTINEL,
|
||||
"granularity_v2": "Region",
|
||||
"morphology": PackedByteArray([8, 14, 0, 1]),
|
||||
@@ -157,19 +140,12 @@ func test_enter_orbital_oversized_body_accepts_the_clamped_region_response() ->
|
||||
"vegetation": PackedByteArray([2, 1, 6, 3]),
|
||||
"glaciation": PackedByteArray([0, 0, 1, 2]),
|
||||
}
|
||||
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", region_window))
|
||||
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", tile_window))
|
||||
|
||||
var failure_msg: String = (
|
||||
"a response echoing the server's own clamped n + Region granularity_v2 (with"
|
||||
+ " the legacy sentinel u32::MAX in the old granularity slot) must be ACCEPTED"
|
||||
+ " and become the held window — the live bug left this permanently null"
|
||||
+ " (w_n=%d never matched a stale unclamped _held_n=%d, THEN the legacy"
|
||||
+ " sentinel never matched the stored _granularity=1) on every real-sized body"
|
||||
) % [clamped_n, raw_cols]
|
||||
assert_that(v.get_district_window()).override_failure_message(failure_msg).is_equal(
|
||||
region_window
|
||||
)
|
||||
assert_str(v._held_granularity_v2).is_equal("Region")
|
||||
assert_that(tile_set.get_tiles()[0]["window"]).override_failure_message(
|
||||
"a wire-accurate response (clamped n, Region granularity_v2, the legacy"
|
||||
+ " sentinel) for the first tile must be ACCEPTED into that tile's slot"
|
||||
).is_equal(tile_window)
|
||||
|
||||
|
||||
## A no-radius body (tiny test body) has no circumference concept —
|
||||
@@ -364,19 +340,20 @@ func test_zoom_out_past_district_threshold_requests_a_coarser_rung() -> void:
|
||||
assert_that(v.get_district_window()).is_equal(district_window)
|
||||
|
||||
|
||||
## Zooming IN on a District-rung window (well within its own legal spacing
|
||||
## band) must NOT trigger a rung change — this is the "zoom is client-side on
|
||||
## the already-held composite" case, unchanged for in-rung zoom. Sets
|
||||
## _view_zoom DIRECTLY to a value inside District's legal band (rather than
|
||||
## relying on enter()'s COVER auto-fit, which for a small n can already sit
|
||||
## right at Quarter's own threshold — a fit's zoom level is a display-density
|
||||
## choice independent of what rung selection would pick from scratch, and
|
||||
## this test is specifically about a SINGLE zoom-in STEP not crossing a
|
||||
## boundary, not about where the auto-fit itself lands). District's legal
|
||||
## band (select_rung()'s own doc: the coverage ceiling and the `2x` visual
|
||||
## band only overlap at small viewports — `canvas_px <= DISTRICT_WINDOW_MAX_N
|
||||
## * DISTRICT_SPACING_M / 1024 = 128px`) requires a SMALL viewport here,
|
||||
## unlike most of this suite's 800x600/1920x1080 fixtures.
|
||||
## Zooming IN on a District-rung window (well within its own legal coverage
|
||||
## band, `(32,768 m, 131,072 m]` per select_rung()'s redesigned per-rung
|
||||
## ceiling model — viewport-independent since `canvas_px` no longer affects
|
||||
## selection) must NOT trigger a rung change — this is the "zoom is
|
||||
## client-side on the already-held composite" case, unchanged for in-rung
|
||||
## zoom. Sets _view_zoom DIRECTLY to a value inside District's band (rather
|
||||
## than relying on enter()'s COVER auto-fit, which for a small n can already
|
||||
## sit right at Quarter's own threshold — a fit's zoom level is a
|
||||
## display-density choice independent of what rung selection would pick from
|
||||
## scratch, and this test is specifically about a SINGLE zoom-in STEP not
|
||||
## crossing a boundary, not about where the auto-fit itself lands). The
|
||||
## small 100x80 viewport here is incidental (any size works under the new
|
||||
## viewport-independent model) — kept small only because that's what the
|
||||
## original version of this test used.
|
||||
func test_zoom_in_within_district_threshold_does_not_change_rung() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
@@ -394,6 +371,84 @@ func test_zoom_in_within_district_threshold_does_not_change_rung() -> void:
|
||||
).is_equal("District")
|
||||
|
||||
|
||||
## **Live round 3 regression, the direct end-to-end fix target:** a real
|
||||
## wheel-zoom gesture (many `_zoom_at()` ticks, matching the shape a
|
||||
## continuous mouse-wheel scroll actually produces) crossing from the
|
||||
## Region rest state down through District into Quarter territory must (i)
|
||||
## fire a request at the NEW granularity — `_window_request.get_granularity_v2()`
|
||||
## must have changed by the end of the gesture — and (ii) accept a
|
||||
## WIRE-ACCURATE response for that request: echoing the REQUEST's own
|
||||
## (already re-centered, already re-clamped) center/n, which the live round
|
||||
## found DIFFERS from the ORIGINAL held center (screen-center-anchored
|
||||
## refinement re-centers on wherever the cursor currently maps to, not
|
||||
## wherever the player started) — this is the "second latent drop" the
|
||||
## coordinator specifically flagged: comparing the echo against a STALE
|
||||
## `_held_center` (frozen at the pre-crossing value) rather than the
|
||||
## request's own center would silently drop this response too.
|
||||
## **Live round 3 update:** GJ380c/Lendel now enters TILE mode via
|
||||
## enter_orbital() (bug B's fix), so this test starts from THERE — zooming
|
||||
## in far enough crosses Region's coverage ceiling and must LEAVE tile mode
|
||||
## for the single-window path at the new (finer) rung, exactly the
|
||||
## `_maybe_reselect_rung()` "leaving_tile_mode" branch this test exercises.
|
||||
func test_zoom_crossing_fires_request_and_accepts_wire_accurate_refinement() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
v.size = Vector2(1600.0, 900.0)
|
||||
var radius_km := 6238.4 # GJ380c (Lendel) — the live-repro body
|
||||
v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {})
|
||||
assert_bool(v.is_tile_mode()).override_failure_message(
|
||||
"GJ380c/Lendel must enter tile mode at the orbital rest state (live round 3)"
|
||||
).is_true()
|
||||
|
||||
# A real wheel-zoom gesture: many ticks, cursor OFF-CENTER (so cursor-
|
||||
# anchored zoom genuinely drifts the screen-to-district mapping away from
|
||||
# the canonical origin, not just scaling in place) — matching the live
|
||||
# drive's actual input shape, not a single synthetic jump. Zooming in far
|
||||
# enough must cross OUT of Region's coverage ceiling, leaving tile mode.
|
||||
var cursor_pos := Vector2(1100.0, 300.0) # off-center, biased toward one quadrant
|
||||
for _i in range(60):
|
||||
v._zoom_at(cursor_pos, 1.15)
|
||||
if not v.is_tile_mode():
|
||||
break
|
||||
|
||||
# (i) Tile mode must have been LEFT, and a request must have gone out at
|
||||
# a NEW (finer) granularity via the single-window path.
|
||||
assert_bool(v.is_tile_mode()).override_failure_message(
|
||||
"zooming in far enough must leave tile mode for the single-window path"
|
||||
).is_false()
|
||||
var request_granularity: String = v._window_request.get_granularity_v2()
|
||||
assert_str(request_granularity).override_failure_message(
|
||||
"leaving tile mode must fire a request at a new (finer) granularity"
|
||||
).is_not_equal("Region")
|
||||
|
||||
# (ii) The request's own center/n — read AFTER leaving tile mode, so this
|
||||
# is whatever _maybe_reselect_rung() actually computed — is what a
|
||||
# wire-accurate response must echo to be accepted.
|
||||
var request_center: Vector2i = v._window_request._center
|
||||
var request_n: int = v._window_request._n
|
||||
|
||||
var refinement_window: Dictionary = {
|
||||
"center": [request_center.x, request_center.y],
|
||||
"n": request_n,
|
||||
"granularity_v2": request_granularity,
|
||||
"morphology": PackedByteArray([1, 2, 3, 4]),
|
||||
"elev_q": PackedByteArray([10, 20, 30, 40]),
|
||||
"temp_dc": [0, 0, 0, 0],
|
||||
"moisture_q": PackedByteArray([0, 0, 0, 0]),
|
||||
"vegetation": PackedByteArray([0, 0, 0, 0]),
|
||||
"glaciation": PackedByteArray([0, 0, 0, 0]),
|
||||
}
|
||||
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", refinement_window))
|
||||
|
||||
assert_that(v.get_district_window()).override_failure_message(
|
||||
"a wire-accurate refinement response (echoing the REQUEST's own center/n/"
|
||||
+ " granularity after leaving tile mode) must be ACCEPTED — comparing"
|
||||
+ " against a stale/wrong reference instead of the request's own would"
|
||||
+ " silently drop this response forever"
|
||||
).is_equal(refinement_window)
|
||||
assert_str(v._held_granularity_v2).is_equal(request_granularity)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# T-1153: E/W wrap and pole-wall clamps at EVERY rung — both are extent-
|
||||
# relative (CELL_PIXEL_SIZE-based district-space math, unchanged regardless
|
||||
|
||||
@@ -15,7 +15,14 @@ extends RefCounted
|
||||
## docs/architecture/atlas-zoom-ladder-t1143.md §5) and the "fully zoomed
|
||||
## out" reset predicate (Jeroen's D-226 T-1143-rulings HARD condition) — both
|
||||
## pure functions of (viewport, held state, body), same "geometry lives here,
|
||||
## side effects live on the viewer" split as the rest of this file.
|
||||
## side effects live on the viewer" split as the rest of this file. Live
|
||||
## round 3 also adds the orbital-rest-state TILE GRID computation
|
||||
## (compute_tile_grid(), near the bottom) — reuses
|
||||
## AtlasDescendGeometry.district_extent()/canonicalize_district_center() for
|
||||
## 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).
|
||||
const AtlasDescendGeometryRef := preload("res://ui/implant/apps/atlas/atlas_descend_geometry.gd")
|
||||
|
||||
## D-243 rung spacings, metres/cell — the SAME constants
|
||||
## server/src/atlas/scale.rs and layer_proxy.rs's WindowGranularity::spacing_m
|
||||
@@ -40,11 +47,66 @@ const RUNG_TABLE: Array = [
|
||||
## Server per-axis cap on a District/Quarter-granularity window's `n`
|
||||
## (mirrors server/src/atlas/layer_proxy.rs's `DISTRICT_WINDOW_MAX_N` — see
|
||||
## AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N, the existing client-side
|
||||
## mirror of the same constant, kept in sync there). select_rung() uses this
|
||||
## to answer "CAN a District-granularity window even cover this much world at
|
||||
## all" — the coarse-end ceiling, distinct from the fine-end `2x` tolerance.
|
||||
## mirror of the same constant, kept in sync there).
|
||||
const DISTRICT_WINDOW_MAX_N: int = 64
|
||||
|
||||
## Wire-size ceiling (mirrors AtlasWindowRequest.SERVER_WIRE_CAP_CELLS /
|
||||
## server/src/atlas/layer_proxy.rs's WIRE_CAP_CELLS) — the cell-count cap
|
||||
## EVERY rung's single window is clamped against, per
|
||||
## `_clamp_window_n_mirror`/`_clamp_window_n_mirror_v2`'s own formulas.
|
||||
const WIRE_CAP_CELLS: int = 4_096
|
||||
|
||||
## Region's own per-axis ceiling (mirrors
|
||||
## AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION /
|
||||
## server/src/atlas/layer_proxy.rs's DISTRICT_WINDOW_MAX_N_REGION).
|
||||
const DISTRICT_WINDOW_MAX_N_REGION: int = 6_400
|
||||
|
||||
## Per-tile district extent for the orbital-rest-state tile grid
|
||||
## (compute_tile_grid(), near the bottom of this file) — the SAME `n` a
|
||||
## single Region request uses at its own per-axis ceiling. Each tile
|
||||
## requests exactly this many districts on a side — the largest single
|
||||
## window the wire budget allows, so tiling uses the FEWEST tiles that can
|
||||
## cover a given body.
|
||||
const TILE_N: int = DISTRICT_WINDOW_MAX_N_REGION
|
||||
|
||||
## **Live round 3 finding (the actual root cause of "zoom-driven rung
|
||||
## reselection never fires"):** each rung's SINGLE WINDOW has a hard MAXIMUM
|
||||
## real-world coverage, derived from the SAME wire-size clamp
|
||||
## (`_clamp_window_n_mirror_v2`) the request layer already enforces — District
|
||||
## and Quarter are NOT exempt from this the way the original (§5-literal)
|
||||
## design assumed. A rung whose own single-window coverage is smaller than
|
||||
## the CURRENTLY DISPLAYED world extent cannot legally be selected: the
|
||||
## server would clamp `n` down to fit its own wire budget, producing a
|
||||
## composite that covers only a FRACTION of the viewport — visually a tiny
|
||||
## box in the middle of the screen, and (the bug this constant's discovery
|
||||
## fixes) a composite whose CLAMPED `n` no longer matches whatever `_held_n`
|
||||
## the viewer was still carrying from the PREVIOUS rung, permanently failing
|
||||
## `_on_window_ready()`'s staleness check. Computed here ONCE, from the same
|
||||
## constants `_clamp_window_n_mirror_v2` uses, rather than re-derived per
|
||||
## rung inline — see MAX_COVERAGE_M below.
|
||||
##
|
||||
## - Quarter: per-axis cap `floor(sqrt(WIRE_CAP_CELLS)/4) = 16` districts ->
|
||||
## cell-grid side `16*4 = 64` cells -> `64 * QUARTER_SPACING_M = 32,768 m`.
|
||||
## - District: per-axis cap `floor(sqrt(WIRE_CAP_CELLS)/1) = 64` districts ->
|
||||
## `64 * DISTRICT_SPACING_M = 131,072 m` (unchanged from the original
|
||||
## coverage-ceiling constant this replaces/generalizes).
|
||||
## - Region: per-axis cap `DISTRICT_WINDOW_MAX_N_REGION = 6,400` districts ->
|
||||
## `6,400 * DISTRICT_SPACING_M = 13,107,200 m` — this is a SINGLE window's
|
||||
## ceiling; bug B's progressive tiling composes MULTIPLE Region windows to
|
||||
## cover extents beyond this (see the viewer's tile-set model), so this
|
||||
## constant alone does NOT bound what the ORBITAL REST STATE can show —
|
||||
## only what one Region REQUEST's response covers.
|
||||
const MAX_COVERAGE_M: Dictionary = {
|
||||
"Quarter": 64.0 * QUARTER_SPACING_M,
|
||||
"District": float(DISTRICT_WINDOW_MAX_N) * DISTRICT_SPACING_M,
|
||||
"Region": float(DISTRICT_WINDOW_MAX_N_REGION) * DISTRICT_SPACING_M,
|
||||
}
|
||||
|
||||
## Rungs ordered FINEST-first — select_rung() walks this to find the finest
|
||||
## rung whose own single-window coverage ceiling still covers the current
|
||||
## extent (never a rung that would silently under-cover the viewport).
|
||||
const RUNGS_FINEST_FIRST: Array = ["Quarter", "District", "Region"]
|
||||
|
||||
|
||||
## 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
|
||||
@@ -160,90 +222,61 @@ static func clamp_pan_offset_to_pole_wall(
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## Rung-selection rule (design doc §5, restated): for a world extent `E`
|
||||
## metres shown across canvas `C` px, sample spacing is `E/C`. Select the
|
||||
## COARSEST rung whose cell spacing is `<= 2*(E/C)` — one tier finer than a
|
||||
## screen pixel, never coarser (never magnified-interpolation of a coarser
|
||||
## composite, the literal thing the D-166 corollary forbids). This `2x`
|
||||
## tolerance gates the FINE end only (District vs. Quarter) — see the
|
||||
## coarse-end paragraph below for why Region is decided by a DIFFERENT test.
|
||||
## Rung-selection rule — REDESIGNED (live round 3 finding, superseding the
|
||||
## original §5-literal `2x`-visual-tolerance-only reading): select the
|
||||
## FINEST rung whose OWN single-window coverage ceiling (MAX_COVERAGE_M)
|
||||
## still covers the current world extent. Walks RUNGS_FINEST_FIRST
|
||||
## (Quarter, District, Region) and returns the first whose ceiling is `>=
|
||||
## world_extent_m` — the coarser rungs are tried only once the finer ones
|
||||
## genuinely cannot show the requested extent in a single window.
|
||||
##
|
||||
## **Why this replaces the original `2x`-visual-tolerance formula entirely**
|
||||
## (not just patches its Region case, as an earlier version of this function
|
||||
## did): the design doc §5 rule ("coarsest rung whose spacing <= 2*(E/C)")
|
||||
## implicitly assumes every rung's single window CAN cover any extent the
|
||||
## rule selects it for — true for an unbounded wire budget, false here.
|
||||
## `_clamp_window_n_mirror_v2` (AtlasWindowRequest) — the SAME clamp the
|
||||
## server itself enforces — caps every rung's single-window real-world
|
||||
## coverage at a fixed maximum (`MAX_COVERAGE_M`, this file): Quarter
|
||||
## 32,768 m, District 131,072 m, Region 13,107,200 m (per single Region
|
||||
## window — bug B's progressive TILING composes several to cover more, a
|
||||
## viewer-level concern this function doesn't need to know about). A rung
|
||||
## selected for an extent BEYOND its own ceiling would have its `n` silently
|
||||
## clamped server-side to something covering only a FRACTION of the
|
||||
## viewport — visually a tiny box, AND (the actual live-round bug this
|
||||
## redesign fixes) a clamped echo that no longer matches whatever `n` the
|
||||
## viewer was still carrying from the rung it's leaving, permanently failing
|
||||
## the staleness check in `_on_window_ready()`.
|
||||
##
|
||||
## **The `2x` visual-tolerance rule becomes REDUNDANT under this model, not
|
||||
## contradicted by it** — verified numerically: at the exact zoom where
|
||||
## Quarter's coverage ceiling (32,768 m) is reached, the `2x` threshold
|
||||
## (`2*E/C`) works out to ~41 m, far finer than even Quarter's own 512 m
|
||||
## spacing. This means by the time coverage RELEASES a rung, the visual
|
||||
## tolerance would ALREADY prefer something finer than that rung offers —
|
||||
## i.e. every rung this function selects is, by construction, at or past its
|
||||
## own "as fine as it can usefully be" point. The visual-tolerance rule's
|
||||
## fine-end guarantee (never show a coarser composite than the screen can
|
||||
## resolve) is automatically satisfied by "select the finest rung whose
|
||||
## coverage allows it" — there is no case where the coverage rule picks a
|
||||
## rung the visual rule would have rejected as too coarse, because Quarter
|
||||
## (the finest rung) is always the answer whenever ANY rung's visual
|
||||
## tolerance alone would have mattered.
|
||||
##
|
||||
## `world_extent_m`/`canvas_px` are both callers'-choice-of-axis (the held
|
||||
## window is always square, so either axis of the viewport/extent pair gives
|
||||
## the same answer — the caller picks one, consistently).
|
||||
##
|
||||
## **Region is selected by a COVERAGE test, not the `2x` visual tolerance**
|
||||
## (found live-testing the orbital-entry fit zoom, where E covers a whole
|
||||
## planetary circumference). The `2x` formula is calibrated to catch the
|
||||
## ZOOM-IN failure mode the corollary names explicitly — never request
|
||||
## coarser derivation than the screen can currently resolve — and has no
|
||||
## meaningful symmetric zoom-OUT reading: testing Region's own 204.8 km
|
||||
## spacing against the SAME threshold that gates District/Quarter would
|
||||
## reject Region at essentially every normal screen resolution (a whole-body
|
||||
## view's sample spacing is tens of km/px, and 2x that is still far under
|
||||
## 204.8 km — even though visually a ~5-10-screen-px-per-cell Region view
|
||||
## reads perfectly fine, nowhere near "magnified interpolation"). The
|
||||
## GENUINELY load-bearing question at the coarse end is different: can a
|
||||
## District-granularity window (capped server-side at
|
||||
## DISTRICT_WINDOW_MAX_N=64 districts, ~131 km per side) physically COVER
|
||||
## the extent being displayed at all? Once it can't, Region is the only rung
|
||||
## that CAN — this is a coverage/capacity fact, not a resolution-legibility
|
||||
## judgment, and it's what actually decides "zoom out past the district rung
|
||||
## transitions to Region" per the ticket's own framing.
|
||||
##
|
||||
## **A third finding, resolving the above two against each other:** at
|
||||
## CELL_PIXEL_SIZE=16 (the shipped display scale), the fine-end `2x` band
|
||||
## that would select District and the coarse-end coverage ceiling that
|
||||
## selects Region do not meet — District's OWN native resolution already
|
||||
## reads as "too fine" (wants Quarter) well before its 64-district coverage
|
||||
## cap becomes binding (wants Region), leaving NO zoom range where the `2x`
|
||||
## formula alone would ever pick District. Since the coverage ceiling is a
|
||||
## hard CAPABILITY limit (a District request literally cannot serve more
|
||||
## world than its per-axis cap covers) while the `2x` band is a QUALITY
|
||||
## preference (finer than strictly needed is wasteful, not wrong), the
|
||||
## coverage ceiling wins whenever the two disagree: check it FIRST, and only
|
||||
## consult the `2x` band to choose between District and Quarter for whatever
|
||||
## extent remains under that ceiling. This is a genuine engineering call this
|
||||
## implementation makes (flagged to the team, not a design-doc-literal
|
||||
## derivation) — see docs/architecture/atlas-zoom-ladder-t1143.md §5 Risk R4
|
||||
## ("Region rung is named but unscoped") for the open design question this
|
||||
## resolves pragmatically rather than by further design-pass iteration.
|
||||
##
|
||||
## **Whether a District band exists at all is independent of CELL_PIXEL_SIZE**
|
||||
## — it cancels out of the "does District's `2x` band overlap the coverage
|
||||
## ceiling" condition entirely. The condition reduces to `canvas_px <=
|
||||
## DISTRICT_WINDOW_MAX_N * DISTRICT_SPACING_M / 1024` — i.e. `canvas_px <=
|
||||
## 128px` at the shipped constants. At every real viewport (800px+), this is
|
||||
## never satisfied: District's band is empty by construction, and the ladder
|
||||
## in practice steps Region -> Quarter directly at any normal screen size.
|
||||
## Verified numerically at 1600x900 (canvas_px=1600): the Region/District
|
||||
## crossing (world_extent_m == the coverage ceiling) sits at `_view_zoom ≈
|
||||
## 1.5625`, and the District/Quarter crossing (the `2x` band's own edge)
|
||||
## sits at `_view_zoom ≈ 0.125` — i.e. the `2x` band's own boundary is
|
||||
## already PAST (a smaller zoom than) where the coverage ceiling releases
|
||||
## District, so the two never overlap in the zoomed-in direction either.
|
||||
## **Tuning knobs, if a real District band is wanted:** the ONLY lever that
|
||||
## opens the gap is `DISTRICT_WINDOW_MAX_N` (currently 64, mirrored from the
|
||||
## server's own per-axis cap) — it would need to reach `1024 * canvas_px /
|
||||
## DISTRICT_SPACING_M` (≈800 at a 1600px canvas) to open a band there, a
|
||||
## substantial server-side wire-size change (T-1150's `WIRE_CAP_CELLS`
|
||||
## budget), not a client-only tuning knob. `CELL_PIXEL_SIZE` does NOT affect
|
||||
## whether a band exists — it only shifts WHERE both crossing zooms sit on
|
||||
## the wheel gesture (scaling both proportionally, preserving their ~12.5x
|
||||
## gap), i.e. it is the felt-pacing knob for how much wheel travel separates
|
||||
## Region from Quarter, not a way to reintroduce District.
|
||||
## the same answer — the caller picks one, consistently). `canvas_px` is
|
||||
## kept as a parameter (unused by the coverage rule itself) for signature
|
||||
## stability with existing callers and because a future finer-than-Quarter
|
||||
## rung (block/tile, D-226(d)-gated, out of scope here) would plausibly need
|
||||
## it again.
|
||||
##
|
||||
## Returns the granularity_v2 string tag ("Quarter" | "District" | "Region").
|
||||
static func select_rung(world_extent_m: float, canvas_px: float) -> String:
|
||||
if world_extent_m > float(DISTRICT_WINDOW_MAX_N) * DISTRICT_SPACING_M:
|
||||
return "Region" # coverage ceiling — District physically cannot span this much world
|
||||
if canvas_px <= 0.0:
|
||||
return "Quarter" # finest — an unlaid-out viewport must under-resolve, not over-resolve
|
||||
var sample_spacing_m: float = world_extent_m / canvas_px
|
||||
var threshold_m: float = 2.0 * sample_spacing_m
|
||||
if DISTRICT_SPACING_M <= threshold_m:
|
||||
return "District"
|
||||
return "Quarter" # threshold too small for even District's own spacing -> finest legal rung
|
||||
static func select_rung(world_extent_m: float, _canvas_px: float) -> String:
|
||||
for rung: String in RUNGS_FINEST_FIRST:
|
||||
if world_extent_m <= float(MAX_COVERAGE_M[rung]):
|
||||
return rung
|
||||
return "Region" # extent exceeds even Region's own single-window ceiling -> still Region (tiling's job)
|
||||
|
||||
|
||||
## The metre spacing a given granularity_v2 tag resolves to — the inverse
|
||||
@@ -438,3 +471,116 @@ static func edge_scroll_direction(
|
||||
elif mouse_pos.y > viewport_size.y - edge_margin_px:
|
||||
direction.y += 1.0
|
||||
return direction
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# T-1153, live round 3 (Jeroen's ruling, design doc §4): the orbital REST
|
||||
# STATE must TILE — a single wire-capped Region window (MAX_COVERAGE_M["Region"]
|
||||
# = 13,107,200 m) covers only a fraction of a real body's circumference
|
||||
# (Lendel: 39,197,023 m — a single window is ~a third of the body). The top
|
||||
# rest state composes MULTIPLE Region windows ("progressive capped-density
|
||||
# TILING", design doc §4) into a mosaic under ONE view transform.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## Compute the tile-set grid for the orbital rest state: the minimal set of
|
||||
## Region-granularity window CENTERS (each `TILE_N` districts wide) whose
|
||||
## union covers the WHOLE body — columns wrap (canonicalize_district_center()'s
|
||||
## own east-west periodicity), rows clamp at the poles. Returns an Array of
|
||||
## Vector2i tile centers, ALREADY CANONICALIZED (duplicates from pole-row
|
||||
## clamping or (degenerately) column-wrap collisions are DEDUPED — a tiny
|
||||
## body where multiple nominal tile rows clamp to the identical pole-adjacent
|
||||
## row, or multiple nominal tile columns wrap to the identical column, must
|
||||
## not request/draw the same tile twice).
|
||||
##
|
||||
## Grid layout: `cols_tiles = ceil(cols / TILE_N)` tiles span the full
|
||||
## circumference (evenly spaced, centered on column 0 — the canonical
|
||||
## origin); `rows_tiles = ceil(2*rows_half / TILE_N)` tiles span pole to
|
||||
## pole (centered on row 0). Each tile's PRE-CANONICALIZATION center is
|
||||
## `(tile_index - (tile_count-1)/2) * TILE_N` along its axis — symmetric
|
||||
## around the canonical origin, matching enter_orbital()'s own "canonical
|
||||
## origin = (0,0)" convention (AtlasDescendGeometry's doc) so the tile set's
|
||||
## own center-of-mass lands exactly on the canonical frame, not offset from
|
||||
## it.
|
||||
##
|
||||
## No-radius bodies (tiny test bodies, `body_radius_km <= 0`) return a
|
||||
## single tile at (0,0) — matching enter_orbital()'s own no-radius fallback
|
||||
## disposition (no circumference/tiling concept for a body with no radius).
|
||||
static func compute_tile_grid(body_radius_km: float) -> Array:
|
||||
if body_radius_km <= 0.0:
|
||||
return [Vector2i.ZERO]
|
||||
|
||||
var extent: Dictionary = AtlasDescendGeometryRef.district_extent(body_radius_km)
|
||||
var cols: int = int(extent["cols"])
|
||||
var rows_half: int = int(extent["rows_half"])
|
||||
var rows_total: int = rows_half * 2
|
||||
|
||||
var cols_tiles: int = maxi(1, ceili(float(cols) / float(TILE_N)))
|
||||
var rows_tiles: int = maxi(1, ceili(float(rows_total) / float(TILE_N)))
|
||||
|
||||
var col_centers: Array = []
|
||||
for tx in range(cols_tiles):
|
||||
var raw_col: int = roundi((float(tx) - (float(cols_tiles - 1) * 0.5)) * float(TILE_N))
|
||||
col_centers.append(raw_col)
|
||||
|
||||
var row_centers: Array = []
|
||||
for ty in range(rows_tiles):
|
||||
var raw_row: int = roundi((float(ty) - (float(rows_tiles - 1) * 0.5)) * float(TILE_N))
|
||||
row_centers.append(raw_row)
|
||||
|
||||
# Dedup via a Dictionary keyed on the CANONICALIZED (col, row) pair —
|
||||
# Godot Dictionary keys compare Vector2i by value, so this is a proper
|
||||
# set. Insertion order is preserved (Godot Dictionaries are
|
||||
# order-preserving), giving a deterministic tile ORDER too — the same
|
||||
# grid always requests/draws in the same sequence, useful for progressive
|
||||
# arrival to read as a stable left-to-right, top-to-bottom fill rather
|
||||
# than an unpredictable one.
|
||||
var seen: Dictionary = {}
|
||||
var tiles: Array = []
|
||||
for raw_col: int in col_centers:
|
||||
for raw_row: int in row_centers:
|
||||
var canonical: Vector2i = AtlasDescendGeometryRef.canonicalize_district_center(
|
||||
Vector2i(raw_col, raw_row), body_radius_km
|
||||
)
|
||||
if not seen.has(canonical):
|
||||
seen[canonical] = true
|
||||
tiles.append(canonical)
|
||||
return tiles
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# T-1153: screen header chrome (D-169/D-170) — pure string-building, moved
|
||||
# here from atlas_window_viewer.gd for file-length (the viewer's own
|
||||
# `_refresh_screen_header()`/`_location_label()` stay as thin wrappers, since
|
||||
# both are directly tested by name).
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## Body name + coordinate label — T-1142: shows the body's proper name
|
||||
## (falling back to body_id) alongside the held district center, so the
|
||||
## header never reads as bare "district (col, row)" with no indication of
|
||||
## WHICH body the player is looking at.
|
||||
static func location_label(body_display_name: String, held_center: Vector2i) -> String:
|
||||
return "%s — (%d, %d)" % [body_display_name, held_center.x, held_center.y]
|
||||
|
||||
|
||||
## D-169/D-170 implant chrome (§5): {title, subtitle} for the screen header.
|
||||
## The subtitle's extent (`held_n` districts) is rung-INVARIANT (n is always
|
||||
## district extent — see AtlasWindowOverlay.cell_grid_side_for_window()'s
|
||||
## doc), but the km/cell reading reflects the HELD rung's actual spacing
|
||||
## (2.048 km District, 0.512 km Quarter, 204.8 km Region) — the "continuous
|
||||
## metres-per-pixel/extent readout" design doc §6 calls for in place of a
|
||||
## discrete "you are now in Quarter Mode" label (Jeroen's "no mode
|
||||
## transition" ruling): the number itself communicates the rung.
|
||||
static func screen_header_content(
|
||||
body_display_name: String,
|
||||
held_center: Vector2i,
|
||||
held_n: int,
|
||||
held_granularity_v2: String,
|
||||
district_m: float
|
||||
) -> Dictionary:
|
||||
var label: String = location_label(body_display_name, held_center)
|
||||
var extent_km: float = float(held_n) * district_m / 1000.0
|
||||
var spacing_km: float = spacing_for_rung(held_granularity_v2) / 1000.0
|
||||
var subtitle: String = "%.1f x %.1f km · %.3f km/cell" % [extent_km, extent_km, spacing_km]
|
||||
return {"title": "REGIONAL — %s" % label.to_upper(), "subtitle": subtitle}
|
||||
|
||||
@@ -61,6 +61,8 @@ extends Node2D
|
||||
const AtlasOverlayColors := preload("res://ui/implant/apps/atlas/atlas_overlay_colors.gd")
|
||||
const REGION_TEMP_NONE_DC: int = AtlasOverlayColors.REGION_TEMP_NONE_DC
|
||||
const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_request.gd")
|
||||
# T-1153, live round 3: TILE_N (the per-tile district extent) for the mosaic draw path.
|
||||
const AtlasWindowGeometryRef := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd")
|
||||
|
||||
## T-1145 item 3: interim presentation toggle — true renders the smoothed
|
||||
## Image/ImageTexture composite; false keeps the original crisp per-cell
|
||||
@@ -94,6 +96,9 @@ var _cache_active_toggle: String = ""
|
||||
func _draw() -> void:
|
||||
if viewer == null:
|
||||
return
|
||||
if viewer.is_tile_mode():
|
||||
_draw_tile_mosaic()
|
||||
return
|
||||
var window: Variant = viewer.get_district_window()
|
||||
if not window is Dictionary:
|
||||
return
|
||||
@@ -132,6 +137,125 @@ func _draw() -> void:
|
||||
_draw_crisp_composite(w, grid_side, n, cell_px, active_toggle)
|
||||
|
||||
|
||||
## T-1153, live round 3 (Jeroen's ruling, design doc §4): the orbital
|
||||
## rest-state MOSAIC draw path — one call to the EXISTING single-tile
|
||||
## composite-building logic (`_rebuild_texture_if_needed()`/
|
||||
## `_draw_smoothed_composite()`'s own per-tile equivalent below) PER TILE,
|
||||
## each positioned at its own LOCAL offset from the tile-set's reference
|
||||
## origin (district (0,0), matching `_enter_tile_mode()`'s own
|
||||
## `_held_center = Vector2i.ZERO`). A tile centered at absolute district
|
||||
## `tile.center` occupies local canvas space
|
||||
## `[(tile.center - TILE_N/2) * cell_px, (tile.center + TILE_N/2) * cell_px)`
|
||||
## — the SAME "window spans [center - n/2, center + n/2)" convention the
|
||||
## single-window draw path already uses, just evaluated per tile instead of
|
||||
## once for the whole composite. Tiles that haven't arrived yet
|
||||
## (`tile["window"] == null`) are simply SKIPPED — no per-tile placeholder
|
||||
## draw, letting COLOR_BG show through as the honest "nothing here yet" read
|
||||
## (the viewer's own `_draw()` already documents why no separate
|
||||
## whole-viewport fade is needed on top of this).
|
||||
func _draw_tile_mosaic() -> void:
|
||||
var tile_set = viewer.get_tile_set()
|
||||
if tile_set == null:
|
||||
return
|
||||
var cell_px: float = viewer.get_cell_pixel_size()
|
||||
var active_toggle: String = _active_toggle_overlay()
|
||||
var half_tile: float = float(AtlasWindowGeometryRef.TILE_N) * 0.5
|
||||
|
||||
for tile: Dictionary in tile_set.get_tiles():
|
||||
var window: Variant = tile["window"]
|
||||
if not window is Dictionary:
|
||||
continue
|
||||
var w: Dictionary = window
|
||||
var morphology: Variant = w.get("morphology")
|
||||
if not (morphology is PackedByteArray or morphology is Array):
|
||||
continue
|
||||
var grid_side: int = cell_grid_side_for_window(w)
|
||||
if grid_side <= 0:
|
||||
continue
|
||||
|
||||
var center: Vector2i = tile["center"]
|
||||
var local_origin: Vector2 = Vector2(
|
||||
(float(center.x) - half_tile) * cell_px, (float(center.y) - half_tile) * cell_px
|
||||
)
|
||||
var extent: float = float(AtlasWindowGeometryRef.TILE_N) * cell_px
|
||||
_draw_one_tile(w, grid_side, local_origin, extent, active_toggle)
|
||||
|
||||
|
||||
## One tile's own composite — the SAME crisp/smoothed per-cell pipeline the
|
||||
## single-window path uses (_cell_color()/_apply_glaciation(), UNCHANGED),
|
||||
## just drawn at `local_origin` instead of always at (0,0). Each tile gets
|
||||
## its OWN texture-rebuild cache slot (keyed by the tile's own window
|
||||
## reference, via `_tile_texture_cache` below) — sharing ONE
|
||||
## `_cached_texture` slot across all tiles (the single-window field) would
|
||||
## thrash on every draw call as different tiles' windows compete for it.
|
||||
func _draw_one_tile(
|
||||
w: Dictionary, grid_side: int, local_origin: Vector2, extent: float, active_toggle: String
|
||||
) -> void:
|
||||
if not COMPOSITE_SMOOTH:
|
||||
_draw_crisp_tile(w, grid_side, local_origin, extent, active_toggle)
|
||||
return
|
||||
var tile_texture: ImageTexture = _build_tile_texture(w, grid_side, active_toggle)
|
||||
if tile_texture == null:
|
||||
return
|
||||
texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR
|
||||
draw_texture_rect(tile_texture, Rect2(local_origin, Vector2(extent, extent)), false)
|
||||
|
||||
|
||||
## Builds (uncached — see the class doc's rebuild-cost paragraph for why the
|
||||
## SINGLE-window path caches by reference; a per-tile cache keyed the same
|
||||
## way would need a Dictionary keyed on tile index, a reasonable follow-up
|
||||
## if mosaic redraw cost ever matters in practice, not attempted here since
|
||||
## the mosaic is drawn only at the orbital rest state, never mid-interaction
|
||||
## at a high redraw rate the way single-window zoom/pan is) a tile's own
|
||||
## Image/ImageTexture from its per-cell colors — identical pipeline to
|
||||
## `_rebuild_texture_if_needed()`, just returning the texture directly
|
||||
## instead of writing to the single-window cache fields.
|
||||
func _build_tile_texture(w: Dictionary, grid_side: int, active_toggle: String) -> ImageTexture:
|
||||
var elev_q: Variant = w.get("elev_q")
|
||||
var glaciation: Variant = w.get("glaciation")
|
||||
var morphology: Variant = w.get("morphology")
|
||||
var n_cells: int = morphology.size()
|
||||
|
||||
var img := Image.create(grid_side, grid_side, false, Image.FORMAT_RGBA8)
|
||||
for row in range(grid_side):
|
||||
for col in range(grid_side):
|
||||
var i: int = row * grid_side + col
|
||||
if i >= n_cells:
|
||||
img.set_pixel(col, row, Color.TRANSPARENT)
|
||||
continue
|
||||
var cell_color: Color = _cell_color(w, i, int(morphology[i]), elev_q, active_toggle)
|
||||
cell_color = _apply_glaciation(cell_color, glaciation, i)
|
||||
img.set_pixel(col, row, cell_color)
|
||||
|
||||
return ImageTexture.create_from_image(img)
|
||||
|
||||
|
||||
## The crisp (non-smoothed) per-tile path — mirrors `_draw_crisp_composite()`
|
||||
## exactly, just positioned at `local_origin` instead of always at (0,0).
|
||||
func _draw_crisp_tile(
|
||||
w: Dictionary, grid_side: int, local_origin: Vector2, extent: float, active_toggle: String
|
||||
) -> void:
|
||||
var elev_q: Variant = w.get("elev_q")
|
||||
var glaciation: Variant = w.get("glaciation")
|
||||
var morphology: Variant = w.get("morphology")
|
||||
var n_cells: int = morphology.size()
|
||||
var screen_cell_px: float = extent / float(grid_side)
|
||||
|
||||
for row in range(grid_side):
|
||||
for col in range(grid_side):
|
||||
var i: int = row * grid_side + col
|
||||
if i >= n_cells:
|
||||
continue
|
||||
var cell_color: Color = _cell_color(w, i, int(morphology[i]), elev_q, active_toggle)
|
||||
if cell_color.a <= 0.0:
|
||||
continue
|
||||
cell_color = _apply_glaciation(cell_color, glaciation, i)
|
||||
var cell_origin: Vector2 = local_origin + Vector2(col * screen_cell_px, row * screen_cell_px)
|
||||
draw_rect(
|
||||
Rect2(cell_origin, Vector2(screen_cell_px + 0.5, screen_cell_px + 0.5)), cell_color
|
||||
)
|
||||
|
||||
|
||||
## The derived cell-grid side length (in CELLS) for a window dict `w` —
|
||||
## mirrors server/src/atlas/layer_proxy.rs's `WindowGranularity::cell_grid_side`
|
||||
## exactly, reading `w`'s OWN echoed `n`/`granularity_v2` fields rather than
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
extends Node
|
||||
|
||||
## Orbital rest-state TILE-SET orchestration (T-1153, live round 3 — Jeroen's
|
||||
## ruling, design doc §4: "the top rest state is the WHOLE body, served as
|
||||
## progressive capped-density TILING"). A single wire-capped Region window
|
||||
## (AtlasWindowGeometry.MAX_COVERAGE_M["Region"] = 13,107,200 m) covers only a
|
||||
## fraction of a real body's circumference (Lendel: ~39,197,023 m — a single
|
||||
## window is ~a third of the body, the exact live-round finding: shot 01's
|
||||
## own header read "13107.2 x 13107.2 km" against a 39,198 km circumference).
|
||||
##
|
||||
## Owns N independent `AtlasWindowRequest` child instances — one per tile —
|
||||
## reusing 100% of the EXISTING, already-tested single-window request/cache/
|
||||
## debounce/retry machinery (atlas_window_request.gd) rather than
|
||||
## reinventing multi-window orchestration from scratch. Each tile is just a
|
||||
## Region-granularity window request at its own canonicalized center
|
||||
## (AtlasWindowGeometry.compute_tile_grid()); distinct centers are already
|
||||
## distinct cache/coalescing keys (T-1150/T-1152's own aliasing discipline),
|
||||
## so nothing about the request/cache LAYER needed to change for tiling to
|
||||
## work — only the ORCHESTRATION (issue N requests instead of one) and the
|
||||
## DRAWING (a mosaic instead of one composite) are new.
|
||||
##
|
||||
## No `class_name` on purpose, matching every other viewer-owned helper in
|
||||
## this cluster (atlas_window_request.gd/atlas_overlay_bar.gd/
|
||||
## atlas_legend_panel.gd, review #8 precedent): the owner (AtlasWindowViewer)
|
||||
## passes itself to `_init()`.
|
||||
##
|
||||
## Progressive arrival (design doc §4's own "with visible refinement as
|
||||
## tiles complete"): each tile's `AtlasWindowRequest.window_ready` connects
|
||||
## independently — a tile's own `_tiles[i]["window"]` updates the moment
|
||||
## THAT tile's response lands, with no dependency on any other tile's
|
||||
## arrival. The viewer/overlay reads `get_tiles()` every draw and renders
|
||||
## whichever tiles have arrived so far — an empty/border-fade gap for the
|
||||
## rest, exactly the same "hold what's there, sharpen in place" contract
|
||||
## single-window progressive refinement already has (§6 "no mode flip"),
|
||||
## just per-tile instead of per-composite.
|
||||
|
||||
signal tile_ready(index: int) # a single tile's window arrived/updated — the viewer redraws
|
||||
|
||||
const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_request.gd")
|
||||
const AtlasWindowGeometry := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd")
|
||||
|
||||
var _owner = null # AtlasWindowViewer (untyped to avoid cyclic ref)
|
||||
var _body_id: String = ""
|
||||
var _tile_n: int = AtlasWindowGeometry.TILE_N
|
||||
|
||||
## Array[Dictionary]: {"center": Vector2i, "request": AtlasWindowRequest,
|
||||
## "window": Variant (null until arrived)} — one entry per tile, in the SAME
|
||||
## deterministic order compute_tile_grid() produces (stable fill order, see
|
||||
## that function's own doc).
|
||||
var _tiles: Array = []
|
||||
|
||||
|
||||
func _init(owner_ref = null) -> void:
|
||||
_owner = owner_ref
|
||||
|
||||
|
||||
## Unlike an individual AtlasWindowRequest (which has no signal connection of
|
||||
## its own — the OWNING viewer forwards responses to it, per that class'
|
||||
## own doc), the tile set DOES connect directly to
|
||||
## SimBridge.atlas_layers_received itself and fans a single response out to
|
||||
## EVERY tile's own `on_response()` — each tile's OWN staleness guard
|
||||
## (center/n/granularity_v2) decides whether that particular response is
|
||||
## the one IT was waiting for; only the matching tile ever adopts it. This
|
||||
## is the same "one shared inbound signal, N independent consumers filtering
|
||||
## by their own criteria" shape the design already uses elsewhere (every
|
||||
## AtlasWindowRequest instance filters on its own state from a common
|
||||
## broadcast — tiling just means N instances share the broadcast instead of
|
||||
## one).
|
||||
func _ready() -> void:
|
||||
SimBridge.atlas_layers_received.connect(_on_atlas_layers_received)
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
if SimBridge.atlas_layers_received.is_connected(_on_atlas_layers_received):
|
||||
SimBridge.atlas_layers_received.disconnect(_on_atlas_layers_received)
|
||||
|
||||
|
||||
func _on_atlas_layers_received(response: Dictionary) -> void:
|
||||
for tile: Dictionary in _tiles:
|
||||
var request = tile["request"]
|
||||
if is_instance_valid(request):
|
||||
request.on_response(response)
|
||||
|
||||
|
||||
## Enter tile mode for `body_id`/`body_radius_km` — computes the tile grid,
|
||||
## tears down any PREVIOUS tile set's child request nodes (a fresh
|
||||
## enter_orbital() on a DIFFERENT body must not leave stale tile requests
|
||||
## from the old body wired up), and issues one request per tile immediately
|
||||
## (no debounce — matching AtlasWindowRequest.request_now()'s own "first
|
||||
## window" contract, §5: entry is never debounced, only pan/rung-reselect
|
||||
## refetches are).
|
||||
func enter(body_id: String, body_radius_km: float) -> void:
|
||||
_teardown()
|
||||
_body_id = body_id
|
||||
var centers: Array = AtlasWindowGeometry.compute_tile_grid(body_radius_km)
|
||||
for i in range(centers.size()):
|
||||
var center: Vector2i = centers[i]
|
||||
var request = AtlasWindowRequest.new(self)
|
||||
request.name = "Tile%d" % i
|
||||
add_child(request)
|
||||
var tile_index := i # capture by value for the lambda below
|
||||
request.window_ready.connect(
|
||||
func(window: Dictionary) -> void: _on_tile_window_ready(tile_index, window)
|
||||
)
|
||||
_tiles.append({"center": center, "request": request, "window": null})
|
||||
request.request_now(body_id, center, _tile_n, AtlasWindowRequest.GRANULARITY_V2_REGION)
|
||||
|
||||
|
||||
func _on_tile_window_ready(index: int, window: Dictionary) -> void:
|
||||
if index < 0 or index >= _tiles.size():
|
||||
return # a stale signal from a torn-down tile set (shouldn't happen — disconnected on teardown)
|
||||
_tiles[index]["window"] = window
|
||||
tile_ready.emit(index)
|
||||
|
||||
|
||||
## Tear down every tile's request node — disconnects nothing explicitly
|
||||
## (queue_free() on a Node disconnects all its own signal connections
|
||||
## automatically, Godot's documented behavior) but DOES clear `_tiles` so a
|
||||
## stale index from an in-flight-but-now-orphaned request's eventual
|
||||
## response can never reach `_on_tile_window_ready()` with a now-meaningless
|
||||
## index (guarded there too, belt-and-suspenders).
|
||||
func _teardown() -> void:
|
||||
for tile: Dictionary in _tiles:
|
||||
var request = tile["request"]
|
||||
if is_instance_valid(request):
|
||||
request.queue_free()
|
||||
_tiles.clear()
|
||||
|
||||
|
||||
## The current tile set, for the viewer/overlay to draw — an Array of
|
||||
## {"center": Vector2i, "window": Variant} (the "request" key is internal,
|
||||
## not exposed here; callers only need center + arrived-or-null window).
|
||||
func get_tiles() -> Array:
|
||||
var result: Array = []
|
||||
for tile: Dictionary in _tiles:
|
||||
result.append({"center": tile["center"], "window": tile["window"]})
|
||||
return result
|
||||
|
||||
|
||||
## True once tiling is active for the current body — a body whose whole
|
||||
## circumference fits in ONE Region window's own coverage ceiling produces
|
||||
## exactly one tile (compute_tile_grid()'s own degenerate-case doc), so
|
||||
## `is_multi_tile()` distinguishes "tile set with 1 entry" (still tiling
|
||||
## machinery, technically) from "genuinely multiple tiles" — the viewer uses
|
||||
## this to decide whether the tile-set draw path or the ORIGINAL
|
||||
## single-window draw path is simpler/preferred for a small body (both are
|
||||
## correct; single-window avoids the extra Node/signal overhead when there's
|
||||
## only ever going to be one tile).
|
||||
func is_multi_tile() -> bool:
|
||||
return _tiles.size() > 1
|
||||
|
||||
|
||||
func get_tile_count() -> int:
|
||||
return _tiles.size()
|
||||
|
||||
|
||||
## True if every tile currently has an arrived window — the viewer/legend
|
||||
## chrome can use this to know when the mosaic is "complete" vs. still
|
||||
## progressively filling in.
|
||||
func is_fully_arrived() -> bool:
|
||||
if _tiles.is_empty():
|
||||
return false
|
||||
for tile: Dictionary in _tiles:
|
||||
if tile["window"] == null:
|
||||
return false
|
||||
return true
|
||||
@@ -3,74 +3,64 @@ extends Control
|
||||
|
||||
## Continuous cursor-anchored zoom ladder viewer (T-1153, superseding T-1138's
|
||||
## click-through-only entry per the D-226 T-1143-rulings amendment — see
|
||||
## enter_orbital()'s own doc). This IS the "regional" nav entry now (T-1152
|
||||
## enter_orbital()'s own doc). This IS the "regional" nav entry (T-1152
|
||||
## client half): the whole ladder from the canonical orbital frame (Region
|
||||
## rung) down to District/Quarter granularity lives in ONE screen/Control,
|
||||
## not a separate planetary heightmap viewer + a windowed drill-down. Renders
|
||||
## a DistrictWindowLayer composite at whichever rung is currently held:
|
||||
## morphology base layer lightness-modulated by elev_q, three switchable
|
||||
## climate/vegetation overlays, and an always-on glaciation ice-tint modifier
|
||||
## (drawing itself is AtlasWindowOverlay's job — this Control owns input,
|
||||
## request orchestration, chrome, and the pan/zoom transform). One colorizer
|
||||
## family renders every rung unchanged (design doc §6) — AtlasWindowOverlay
|
||||
## never branches on granularity_v2 for COLOR, only for the derived
|
||||
## cell-grid's RESOLUTION (cell_grid_side_for_window()).
|
||||
## rung) down to District/Quarter lives in ONE screen/Control, not a separate
|
||||
## planetary viewer + windowed drill-down. Renders a DistrictWindowLayer
|
||||
## composite (or, at the orbital rest state on a large body, a MOSAIC of
|
||||
## several — see `_tile_mode`/AtlasWindowTileSet, live round 3) at whichever
|
||||
## rung is currently held: morphology base layer lightness-modulated by
|
||||
## elev_q, three switchable climate/vegetation overlays, an always-on
|
||||
## glaciation ice-tint modifier (drawing is AtlasWindowOverlay's job — this
|
||||
## Control owns input, request orchestration, chrome, pan/zoom). One
|
||||
## colorizer family renders every rung unchanged (design doc §6).
|
||||
##
|
||||
## Design notes (mirroring AtlasViewer's own split, D-226 §5, extended T-1153):
|
||||
## - _canvas (Node2D) holds AtlasWindowOverlay; pan = _canvas.position, zoom
|
||||
## = _canvas.scale — the SAME transform idiom as the (retired) planetary
|
||||
## viewer.
|
||||
## - Zoom is client-side on the ALREADY-HELD composite frame-to-frame (never
|
||||
## blocks on a re-derive), but is CONTINUOUS AND UNCLAMPED ACROSS RUNGS
|
||||
## (T-1153, D-013 restored for this seam): crossing a rung's spacing
|
||||
## threshold (§5 rung-selection rule) fires a background request for the
|
||||
## new granularity while the OLD composite keeps drawing — progressive
|
||||
## refinement, no blank frame, no mode flip (§6). A pan past the held
|
||||
## window's edge re-requests the SAME rung at a new center (§4/§5,
|
||||
## unchanged from T-1138).
|
||||
## = _canvas.scale.
|
||||
## - Zoom is client-side on the ALREADY-HELD composite frame-to-frame, but
|
||||
## CONTINUOUS AND UNCLAMPED ACROSS RUNGS (D-013 restored for this seam):
|
||||
## crossing a rung's coverage ceiling (§5, redesigned per live round 3 —
|
||||
## see AtlasWindowGeometry.select_rung()) fires a background request for
|
||||
## the new granularity while the OLD composite keeps drawing —
|
||||
## progressive refinement, no blank frame, no mode flip (§6). A pan past
|
||||
## the held window's edge re-requests the SAME rung at a new center.
|
||||
## - Zooming fully out snaps to the CANONICAL planetary frame (Jeroen's HARD
|
||||
## condition) — see _maybe_reset_to_canonical_frame().
|
||||
## - _window_request (atlas_window_request.gd) owns the cache/debounce/
|
||||
## retry — this Control decides WHEN to call it (pan-edge detection,
|
||||
## rung-reselect, entry), never talks to SimBridge directly itself.
|
||||
## condition) — see _maybe_reset_to_canonical_frame() — which, on a body
|
||||
## needing tiling, re-enters `_tile_mode` (live round 3, design doc §4:
|
||||
## "the top rest state is the WHOLE body, served as progressive
|
||||
## capped-density TILING").
|
||||
## - _window_request (atlas_window_request.gd) owns the single-window
|
||||
## cache/debounce/retry; _tile_set (atlas_window_tile_set.gd) owns N of
|
||||
## those for the tiled rest state — this Control decides WHICH is active.
|
||||
##
|
||||
## Navigation (T-1145 item 2 — Jeroen's input-model ruling: LMB-drag panning
|
||||
## BREAKS click semantics with map objects, so it is removed entirely; clicks
|
||||
## are reserved for map objects, which will land in this window later, e.g.
|
||||
## settlements):
|
||||
## Navigation (Jeroen's input-model ruling: LMB-drag panning BREAKS click
|
||||
## semantics with future map objects, so it's removed entirely):
|
||||
## WASD / arrow keys continuous pan, held (frame-rate independent, _process)
|
||||
## Edge scrolling cursor within EDGE_SCROLL_MARGIN_PX of a viewport
|
||||
## edge pans toward it (suppressed over UI / unfocused)
|
||||
## Mouse wheel cursor-anchored zoom; crosses rungs continuously (T-1153)
|
||||
## Esc back (nav.pop() — the "district" nav-stack entry is
|
||||
## gone as a separate hop, see atlas_app.gd's own doc)
|
||||
## Esc back (nav.pop())
|
||||
|
||||
signal back_pressed
|
||||
|
||||
const PANEL_MARGIN: float = 16.0
|
||||
const OVERLAY_BAR_HEADER_RESERVE: float = 360.0
|
||||
|
||||
## T-1153: MIN_ZOOM/MAX_ZOOM stay a wide safety clamp on the raw display
|
||||
## multiplier (never letting _view_zoom collapse to zero or run away toward
|
||||
## infinity) — they are NOT a rung boundary any more. Wheel zoom is now
|
||||
## CONTINUOUS and UNCLAMPED ACROSS RUNGS (D-226 T-1143-rulings amendment,
|
||||
## Jeroen's seam ruling: "D-013's zoom gesture owns spatial descent restored
|
||||
## for this seam"): crossing a rung's spacing threshold (§5's rung-selection
|
||||
## rule, AtlasWindowGeometry.select_rung()) re-requests a DIFFERENT
|
||||
## granularity window at the SAME apparent screen extent, it does not clamp
|
||||
## _view_zoom itself. The programmatic capture API (set_view(), T-1120) still
|
||||
## clamps to this same wide range — a capture harness driving a specific
|
||||
## zoom/offset pair has no rung-crossing concept of its own to trigger.
|
||||
## T-1153: MIN_ZOOM/MAX_ZOOM are a wide safety clamp on the raw display
|
||||
## multiplier, NOT a rung boundary — wheel zoom is CONTINUOUS and UNCLAMPED
|
||||
## ACROSS RUNGS (D-013 restored for this seam): crossing a rung's coverage
|
||||
## ceiling (AtlasWindowGeometry.select_rung()) re-requests a DIFFERENT
|
||||
## granularity at the SAME apparent screen extent, never clamping
|
||||
## _view_zoom itself. set_view() (T-1120 capture API) clamps to this same
|
||||
## range independently.
|
||||
##
|
||||
## MIN_ZOOM must stay low enough that fit_window_view()'s COVER fit for
|
||||
## enter_orbital()'s largest legal `n` (a whole equatorial circumference, up
|
||||
## to hundreds of thousands of districts on a gas-giant-scale body) is never
|
||||
## itself clamped — a clamped fit zoom would silently show LESS than the
|
||||
## whole body, breaking Jeroen's HARD condition ("the whole body fitted to
|
||||
## the canvas") at exactly the moment it matters most. 0.0005 covers a
|
||||
## ~120,000 km-radius body (n≈368,000 districts) at a 3840px 4K viewport with
|
||||
## headroom; a real fit_zoom this low is expected and correct at the
|
||||
## canonical orbital frame, not a bug.
|
||||
## enter_orbital()'s largest legal `n` (up to hundreds of thousands of
|
||||
## districts on a gas-giant-scale body) is never itself clamped — that would
|
||||
## silently show LESS than the whole body, breaking Jeroen's HARD condition.
|
||||
## 0.0005 covers a ~120,000 km-radius body at a 3840px 4K viewport with
|
||||
## headroom.
|
||||
const MIN_ZOOM: float = 0.0005
|
||||
const MAX_ZOOM: float = 64.0
|
||||
const ZOOM_STEP: float = 1.15
|
||||
@@ -125,6 +115,8 @@ const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_re
|
||||
# atlas_descend_geometry.gd instead — it already owns district_extent()).
|
||||
const AtlasWindowGeometry := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd")
|
||||
const AtlasDescendGeometry := preload("res://ui/implant/apps/atlas/atlas_descend_geometry.gd")
|
||||
# T-1153: orbital rest-state mosaic orchestration.
|
||||
const AtlasWindowTileSet := preload("res://ui/implant/apps/atlas/atlas_window_tile_set.gd")
|
||||
|
||||
# ── Overlay definitions (T-1138 — reuses atlas_overlay_bar.gd/
|
||||
# atlas_legend_panel.gd's existing duck-typed viewer interface: both call
|
||||
@@ -213,6 +205,15 @@ var _screen_header: ImplantHeader = null
|
||||
var _overlay_bar = null
|
||||
var _legend_panel = null
|
||||
var _window_request = null # AtlasWindowRequest
|
||||
var _tile_set = null # AtlasWindowTileSet (T-1153, live round 3)
|
||||
|
||||
## T-1153 (Jeroen's ruling, design doc §4): true while showing the orbital
|
||||
## rest state as a MULTI-WINDOW MOSAIC (AtlasWindowTileSet) instead of the
|
||||
## single held composite (`_window`). Set by `_enter_tile_mode()` when
|
||||
## compute_tile_grid() produces more than one tile; cleared the moment
|
||||
## `_maybe_reselect_rung()` crosses OUT of Region — tiling is purely a
|
||||
## TOP-of-the-ladder concern, never active below Region.
|
||||
var _tile_mode: bool = false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -242,6 +243,11 @@ func _ready() -> void:
|
||||
add_child(_window_request)
|
||||
_window_request.window_ready.connect(_on_window_ready)
|
||||
|
||||
_tile_set = AtlasWindowTileSet.new(self)
|
||||
_tile_set.name = "TileSet"
|
||||
add_child(_tile_set)
|
||||
_tile_set.tile_ready.connect(_on_tile_ready)
|
||||
|
||||
_build_screen_header()
|
||||
_build_overlay_bar()
|
||||
_build_legend_panel()
|
||||
@@ -254,29 +260,20 @@ func _exit_tree() -> void:
|
||||
SimBridge.atlas_layers_received.disconnect(_on_atlas_layers_received)
|
||||
|
||||
|
||||
## Enter the window screen centered on `district_center` (a DistrictPos-
|
||||
## equivalent Vector2i, from a click-through's derived position — §5's "pan
|
||||
## center read as click point") at District granularity. n defaults to the
|
||||
## client's interactive default (32), half the server's hard cap. Kept as a
|
||||
## thin District-rung wrapper over _enter_at_rung() (T-1153) — a click-to-
|
||||
## descend-to-point shortcut on top of the continuous ladder (Jeroen's
|
||||
## ruling: "if a click-to-descend-to-point remains cheap to keep... wired to
|
||||
## the same descent path"). No screen currently calls this directly (the
|
||||
## retired planetary click-through it served no longer exists — see
|
||||
## atlas_app.gd's own doc); it survives as the landing point a future
|
||||
## map-object click (e.g. a settlement marker on the Region-rung view) would
|
||||
## wire into, and as a direct-call entry for tests/tools that want a
|
||||
## District-rung window without going through enter_orbital() first.
|
||||
## Enter the window screen centered on `district_center` (a DistrictPos, from
|
||||
## a click-through's derived position) at District granularity. n defaults to
|
||||
## 32, half the server's hard cap. Kept as a thin District-rung wrapper over
|
||||
## _enter_at_rung() (T-1153) — a click-to-descend-to-point shortcut on top of
|
||||
## the continuous ladder (Jeroen's ruling). No screen currently calls this
|
||||
## directly (the retired planetary click-through it served no longer exists
|
||||
## — see atlas_app.gd's own doc); it survives as the landing point a future
|
||||
## map-object click would wire into, and as a direct-call entry for tests.
|
||||
##
|
||||
## T-1142: `district_center` is canonicalized (wrap column / clamp row)
|
||||
## BEFORE it becomes `_held_center` or reaches the request — matching the
|
||||
## server's own normalize_window_center() exactly, so the request the client
|
||||
## sends and the echo the server sends back describe the SAME canonical
|
||||
## point from the first round-trip (never a raw-vs-normalized mismatch that
|
||||
## would fail the §2 staleness echo check). Also fits-and-centers the view
|
||||
## instead of the old zoom=1/offset=ZERO reset (Jeroen's second finding: an
|
||||
## n=32 composite is 512px native, a postage stamp unfitted in a real
|
||||
## viewport).
|
||||
## server's normalize_window_center() so the client's echo comparison never
|
||||
## mismatches. Also fits-and-centers the view instead of resetting to
|
||||
## zoom=1/offset=ZERO.
|
||||
func enter(
|
||||
body: Dictionary,
|
||||
system: Dictionary,
|
||||
@@ -292,25 +289,28 @@ func enter(
|
||||
|
||||
## T-1153: enter the ladder at its TOP REST STATE — the canonical orbital
|
||||
## frame (Jeroen's HARD condition: "the whole body fitted to the canvas,
|
||||
## centered at the body's canonical origin"). This is the new "regional" nav
|
||||
## entry point (T-1152 client half — supersedes AtlasViewer's heightmap
|
||||
## texture as the sole entry): the player lands on a fully-derived Region-rung
|
||||
## view of the whole body, then wheel-zoom descends CONTINUOUSLY from there —
|
||||
## no separate planetary screen, no click-through required to reach the
|
||||
## windowed view at all (though enter() above stays wired for a
|
||||
## click-to-descend shortcut, per Jeroen's ruling).
|
||||
## centered at the body's canonical origin"). This is the "regional" nav
|
||||
## entry point (T-1152 client half): the player lands on a fully-derived
|
||||
## Region-rung view of the whole body, then wheel-zoom descends CONTINUOUSLY
|
||||
## from there — no separate planetary screen, no click-through required
|
||||
## (though enter() below stays wired for a click-to-descend shortcut, per
|
||||
## Jeroen's ruling).
|
||||
##
|
||||
## Canonical origin = district (0,0) — "district (0,0) sits at lon 0 / the
|
||||
## equator" (AtlasDescendGeometry's own doc, mirroring
|
||||
## district_profile.rs). Canonical extent = the WHOLE equatorial
|
||||
## circumference in districts (district_extent().cols), i.e. one full
|
||||
## circumnavigation — the same quantity is_fully_zoomed_out()/the
|
||||
## full-zoom-out reset (see _maybe_reset_to_canonical_frame()) test against,
|
||||
## so entry and reset always agree on what "the top" means. No-radius bodies
|
||||
## (tiny test bodies) fall back to the District-rung default window — there
|
||||
## is no planetary circumference concept to derive a Region-rung n from (same
|
||||
## fallback disposition AtlasDescendGeometry's own no-radius branches use
|
||||
## throughout).
|
||||
## equator" (AtlasDescendGeometry's own doc). Canonical extent = the WHOLE
|
||||
## equatorial circumference in districts, the same quantity
|
||||
## is_fully_zoomed_out()/_maybe_reset_to_canonical_frame() test against, so
|
||||
## entry and reset always agree on what "the top" means. No-radius bodies
|
||||
## fall back to the District-rung default window (no circumference concept).
|
||||
##
|
||||
## **Live round 3 (Jeroen's ruling, design doc §4): the rest state must
|
||||
## TILE.** A single wire-capped Region window covers at most
|
||||
## `AtlasWindowGeometry.MAX_COVERAGE_M["Region"]` (13,107,200 m) — a THIRD of
|
||||
## Lendel's ~39,197,023 m circumference (shot 01's own header: "13107.2 x
|
||||
## 13107.2 km"). Once `compute_tile_grid()` returns MORE than one tile,
|
||||
## entry goes through `_enter_tile_mode()` instead of `_enter_at_rung()`; a
|
||||
## body whose circumference fits one Region window's ceiling still gets
|
||||
## exactly one "tile" (the degenerate case) and stays single-window.
|
||||
func enter_orbital(body: Dictionary, system: Dictionary) -> void:
|
||||
var radius_km: float = float(body.get("body_radius_km", 0.0))
|
||||
if radius_km <= 0.0:
|
||||
@@ -320,31 +320,55 @@ func enter_orbital(body: Dictionary, system: Dictionary) -> void:
|
||||
AtlasWindowRequest.GRANULARITY_V2_DISTRICT
|
||||
)
|
||||
return
|
||||
var tiles: Array = AtlasWindowGeometry.compute_tile_grid(radius_km)
|
||||
if tiles.size() > 1:
|
||||
_enter_tile_mode(body, system, radius_km)
|
||||
return
|
||||
var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km)
|
||||
var n: int = int(extent["cols"])
|
||||
_enter_at_rung(body, system, Vector2i.ZERO, n, AtlasWindowRequest.GRANULARITY_V2_REGION)
|
||||
|
||||
|
||||
## T-1153, live round 3: the TILE-MODE entry path — same reset discipline as
|
||||
## `_enter_at_rung()` but populates `_tile_set` instead of `_window_request`,
|
||||
## setting `_tile_mode = true` so drawing/reset/reselect read the mosaic.
|
||||
## `_held_n` carries the WHOLE body's extent unclamped (each TILE clamps its
|
||||
## own TILE_N-sized request independently inside AtlasWindowTileSet), so the
|
||||
## extent math elsewhere needs no tile-specific branch.
|
||||
func _enter_tile_mode(body: Dictionary, system: Dictionary, radius_km: float) -> void:
|
||||
var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km)
|
||||
var n: int = int(extent["cols"])
|
||||
_body = body
|
||||
_system = system
|
||||
_held_center = Vector2i.ZERO
|
||||
_held_n = n
|
||||
_held_granularity_v2 = AtlasWindowRequest.GRANULARITY_V2_REGION
|
||||
_tile_mode = true
|
||||
_window = null
|
||||
_user_adjusted = false
|
||||
_awaiting_first_window = true
|
||||
_fit_and_center()
|
||||
_window_request.reset()
|
||||
_tile_set.enter(_dict_str(_body, "body_id", ""), radius_km)
|
||||
_refresh_screen_header()
|
||||
grab_focus()
|
||||
queue_redraw()
|
||||
_overlay_node.queue_redraw()
|
||||
|
||||
|
||||
## Shared entry path for enter()/enter_orbital() (T-1153) — `district_center`
|
||||
## must already be canonicalized by the caller (enter_orbital()'s (0,0) needs
|
||||
## no canonicalization; enter()'s does its own before calling in). Resets
|
||||
## every piece of held/request state for a fresh descent, exactly as the
|
||||
## pre-T-1153 enter() always did, plus the new _held_granularity_v2 tracking.
|
||||
## must already be canonicalized by the caller. Resets every piece of
|
||||
## held/request state for a fresh descent, plus _held_granularity_v2.
|
||||
##
|
||||
## **The C1 clamp-mirror lesson, one layer up (live-round finding):** `n`
|
||||
## MUST be clamped via `_clamp_window_n_mirror_v2()` BEFORE it becomes
|
||||
## `_held_n` — mirroring exactly what AtlasWindowRequest.request_now()
|
||||
## already does to ITS OWN `_n` before storing/sending (see that function's
|
||||
## own doc for the original PR #191 Tyre C1 finding). Storing the RAW `n`
|
||||
## here (e.g. enter_orbital()'s full district_extent().cols, routinely tens
|
||||
## of thousands at Region granularity, versus the server's clamped echo of
|
||||
## at most DISTRICT_WINDOW_MAX_N_REGION=6,400) left `_held_n` permanently
|
||||
## disagreeing with what the server would ever actually echo — every
|
||||
## orbital-rung response was silently rejected as stale by
|
||||
## _on_window_ready()'s `w_n != _held_n` check, hanging the ladder on every
|
||||
## real-sized body. `_maybe_reselect_rung()`/`_maybe_refloat_window()` both
|
||||
## read `_held_n` (never re-derive it), so clamping here — the ONE write
|
||||
## site — fixes every downstream caller too, not just entry.
|
||||
## **C1 clamp-mirror, one layer up (live-round finding):** `n` MUST be
|
||||
## clamped via `_clamp_window_n_mirror_v2()` BEFORE it becomes `_held_n` —
|
||||
## mirroring what AtlasWindowRequest.request_now() already does to ITS OWN
|
||||
## `_n` (PR #191 Tyre C1). Storing RAW `n` (e.g. enter_orbital()'s full
|
||||
## district_extent().cols, tens of thousands at Region, vs. the server's
|
||||
## clamped echo of at most 6,400) left `_held_n` permanently disagreeing
|
||||
## with the server's echo — every orbital response silently rejected as
|
||||
## stale forever. `_maybe_reselect_rung()`/`_maybe_refloat_window()` both
|
||||
## read `_held_n` unchanged, so clamping here fixes every downstream caller.
|
||||
func _enter_at_rung(
|
||||
body: Dictionary,
|
||||
system: Dictionary,
|
||||
@@ -358,6 +382,7 @@ func _enter_at_rung(
|
||||
_held_center = district_center
|
||||
_held_n = clamped_n
|
||||
_held_granularity_v2 = granularity_v2
|
||||
_tile_mode = false # T-1153 live round 3: a single-window entry always leaves tile mode
|
||||
_window = null
|
||||
_user_adjusted = false
|
||||
_awaiting_first_window = true
|
||||
@@ -391,11 +416,9 @@ func _fit_and_center() -> void:
|
||||
_apply_transform()
|
||||
|
||||
|
||||
## T-1142: the pole-wall clamp needs the body's rows_half, in whole districts
|
||||
## — a no-radius body (tiny test body) has no periodicity/pole concept at the
|
||||
## DistrictPos level (matching canonicalize_district_center()'s own no-radius
|
||||
## identity disposition), so the wall is a no-op there (rows_half=0, and
|
||||
## clamp_pan_offset_to_pole_wall() treats <= 0 as "no wall").
|
||||
## T-1142: needs the body's rows_half, in whole districts — a no-radius body
|
||||
## has no pole concept (matching canonicalize_district_center()'s own
|
||||
## no-radius identity), so the wall is a no-op there (rows_half=0).
|
||||
func _clamp_offset_to_pole_wall(offset: Vector2) -> Vector2:
|
||||
var radius_km: float = float(_body.get("body_radius_km", 0.0))
|
||||
if radius_km <= 0.0:
|
||||
@@ -417,6 +440,19 @@ func get_district_window() -> Variant:
|
||||
return _window
|
||||
|
||||
|
||||
## T-1153: true while showing the orbital rest state as a multi-window
|
||||
## mosaic instead of the single held composite — AtlasWindowOverlay reads
|
||||
## this to pick a draw path.
|
||||
func is_tile_mode() -> bool:
|
||||
return _tile_mode
|
||||
|
||||
|
||||
## T-1153: the tile-set orchestrator, for AtlasWindowOverlay's mosaic draw
|
||||
## path — only meaningful while is_tile_mode() is true.
|
||||
func get_tile_set() -> Variant:
|
||||
return _tile_set
|
||||
|
||||
|
||||
## District-cell pixel size at zoom=1.0 — AtlasWindowOverlay reads this
|
||||
## rather than hardcoding CELL_PIXEL_SIZE itself, so the viewer stays the
|
||||
## single source of geometry truth (same "viewer owns the transform, overlay
|
||||
@@ -451,15 +487,11 @@ func _on_atlas_layers_received(response: Dictionary) -> void:
|
||||
_window_request.on_response(response)
|
||||
|
||||
|
||||
## T-1153: progressive refinement — this is the ONE place a new rung's
|
||||
## window gets adopted (swapped in), and it deliberately does NOT clear
|
||||
## `_window` first. The OLD composite (whatever rung it was) stays drawn
|
||||
## every frame up to and including the one before this call — no blank
|
||||
## frame, no mode flip (§6 acceptance criterion) — because `_window` is a
|
||||
## single-slot "the composite currently drawn" reference that only ever gets
|
||||
## REPLACED, never nulled, once a window has been adopted at least once
|
||||
## (enter()/_enter_at_rung() nulls it only at a fresh descent, a real
|
||||
## navigation event, not a rung swap).
|
||||
## T-1153: progressive refinement — the ONE place a new rung's window gets
|
||||
## adopted, deliberately WITHOUT clearing `_window` first. The OLD composite
|
||||
## stays drawn until this call — no blank frame, no mode flip (§6) —
|
||||
## because `_window` only ever gets REPLACED, never nulled, once adopted
|
||||
## (enter()/_enter_at_rung() null it only at a fresh descent, not a swap).
|
||||
func _on_window_ready(window: Dictionary) -> void:
|
||||
# Only adopt the window if it still matches what THIS viewer is currently
|
||||
# showing — AtlasWindowRequest already filtered by its own last-asked
|
||||
@@ -500,6 +532,15 @@ func _on_window_ready(window: Dictionary) -> void:
|
||||
_overlay_node.queue_redraw()
|
||||
|
||||
|
||||
## T-1153 (design doc §4 "progressive... with visible refinement as tiles
|
||||
## complete"): a SINGLE tile's window arrived — redraw so the overlay's
|
||||
## mosaic loop picks it up. No acceptance/staleness logic needed here (each
|
||||
## tile's OWN AtlasWindowRequest already filtered before this signal fired).
|
||||
func _on_tile_ready(_index: int) -> void:
|
||||
queue_redraw()
|
||||
_overlay_node.queue_redraw()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# View transform (mirrors AtlasViewer's own — pan is real; zoom is CURSOR-
|
||||
# ANCHORED and CONTINUOUS ACROSS RUNGS (T-1153, D-226 T-1143-rulings
|
||||
@@ -517,14 +558,11 @@ func _apply_transform() -> void:
|
||||
_overlay_node.queue_redraw()
|
||||
|
||||
|
||||
## Cursor-anchored zoom (D-013 restored for this seam, Jeroen's ruling): the
|
||||
## CANVAS POINT under the cursor stays fixed on screen across the zoom step —
|
||||
## zooming toward the cursor, not the view center. Unclamped ACROSS RUNGS
|
||||
## (only the wide MIN_ZOOM/MAX_ZOOM safety clamp applies to the raw
|
||||
## multiplier itself — see that constant's own doc); after applying the new
|
||||
## zoom, checks whether the currently-displayed world extent now calls for a
|
||||
## different rung (_maybe_reselect_rung()) and whether the view has reached
|
||||
## the ladder's top rest state (_maybe_reset_to_canonical_frame()).
|
||||
## Cursor-anchored zoom (D-013 restored for this seam): the CANVAS POINT
|
||||
## under the cursor stays fixed on screen across the zoom step. Unclamped
|
||||
## ACROSS RUNGS (only the wide MIN_ZOOM/MAX_ZOOM safety clamp applies — see
|
||||
## that constant's own doc); after applying, checks whether the extent now
|
||||
## calls for a different rung or the top rest state.
|
||||
func _zoom_at(mouse_pos: Vector2, factor: float) -> void:
|
||||
var new_zoom: float = clampf(_view_zoom * factor, MIN_ZOOM, MAX_ZOOM)
|
||||
if is_equal_approx(new_zoom, _view_zoom):
|
||||
@@ -539,59 +577,71 @@ func _zoom_at(mouse_pos: Vector2, factor: float) -> void:
|
||||
|
||||
|
||||
## The world extent (metres) currently displayed across the LARGER viewport
|
||||
## dimension — the `E` half of the §5 rung-selection rule's `E/C`. A pure
|
||||
## function of `_view_zoom` (see AtlasWindowGeometry.world_extent_m()'s own
|
||||
## doc for why the currently-held rung is NOT an input: the composite's
|
||||
## on-screen footprint is rung-invariant by construction, so sample density
|
||||
## depends only on zoom). Thin wrapper kept here so callers don't need to
|
||||
## know the pure function lives on AtlasWindowGeometry (T-1153 — extracted
|
||||
## there, alongside select_rung(), to keep the §5 math unit-testable without
|
||||
## a Control in the tree).
|
||||
## dimension — the `E` half of the §5 rung-selection rule. A pure function
|
||||
## of `_view_zoom` (see AtlasWindowGeometry.world_extent_m()'s own doc for
|
||||
## why the held rung is NOT an input). Thin wrapper over that pure function.
|
||||
func _current_world_extent_m() -> float:
|
||||
return AtlasWindowGeometry.world_extent_m(CELL_PIXEL_SIZE, _view_zoom, get_rect().size)
|
||||
|
||||
|
||||
## §5 rung-selection rule + progressive refinement (T-1153): after a zoom
|
||||
## step, recompute the coarsest legal rung for the NOW-displayed world extent
|
||||
## (_current_world_extent_m() over the viewport's larger dimension). If that
|
||||
## differs from what's currently HELD on screen, request the new granularity
|
||||
## centered on the CURRENT screen-center's district position (reusing
|
||||
## _screen_center_district() — the same screen-to-district math
|
||||
## _maybe_refloat_window() already established, which is rung-agnostic since
|
||||
## CELL_PIXEL_SIZE is always district-based regardless of the held rung's
|
||||
## true cell spacing — see atlas_window_overlay.gd's cell_grid_side_for_window()
|
||||
## doc for why that's true).
|
||||
## step, recompute the legal rung for the NOW-displayed world extent. If it
|
||||
## differs from what's HELD, request the new granularity centered on the
|
||||
## CURRENT screen-center (_screen_center_district(), the same formula
|
||||
## _maybe_refloat_window() uses).
|
||||
##
|
||||
## Progressive refinement, not block-on-derive: this does NOT touch `_window`
|
||||
## or `_held_granularity_v2` — the OLD composite keeps drawing every frame
|
||||
## (border-fade/pending-indicator per R6 shows the request is in flight, see
|
||||
## _draw_border_fade()) until _on_window_ready() adopts the NEW rung's window
|
||||
## once it actually arrives (§6 "no mode flip": never a blank frame, never a
|
||||
## clear-then-redraw).
|
||||
## **C1 clamp-mirror, a THIRD layer up (live round 3):** `_held_n` MUST be
|
||||
## re-clamped via `_clamp_window_n_mirror_v2()` for the TARGET rung, not left
|
||||
## at the PREVIOUS rung's clamp — crossing rungs changes the clamp ceiling
|
||||
## (Region caps at 6,400; District/Quarter at 64), so a stale Region-sized
|
||||
## `_held_n` fed into a Quarter request gets server-clamped small while
|
||||
## `_held_n` stays large — `_on_window_ready()`'s `w_n != _held_n` then
|
||||
## drops every cross-rung refinement forever. Same bug as _enter_at_rung(),
|
||||
## recurring at the CROSSING boundary.
|
||||
##
|
||||
## Progressive refinement: does NOT touch `_window`/`_held_granularity_v2` —
|
||||
## the OLD composite keeps drawing until _on_window_ready() adopts the new
|
||||
## one (§6 "no mode flip": never a blank frame, never clear-then-redraw).
|
||||
func _maybe_reselect_rung() -> void:
|
||||
if _held_n <= 0:
|
||||
return
|
||||
var world_extent_m: float = _current_world_extent_m()
|
||||
var canvas_px: float = maxf(get_rect().size.x, get_rect().size.y)
|
||||
var target_rung: String = AtlasWindowGeometry.select_rung(world_extent_m, canvas_px)
|
||||
if target_rung == _window_request.get_granularity_v2():
|
||||
|
||||
# T-1153, live round 3: tile mode is TOP-of-the-ladder only (coordinator's
|
||||
# own scoping — "inside-zoom can stay single-window as now"). Staying at
|
||||
# Region means staying tiled (zooming within a mosaic is a client-side
|
||||
# scale on the SAME held tiles, like single-window zoom on one
|
||||
# composite). Crossing OUT of Region falls through to the single-window
|
||||
# path below, flipping `_tile_mode` off.
|
||||
var leaving_tile_mode := false
|
||||
if _tile_mode:
|
||||
if target_rung == AtlasWindowRequest.GRANULARITY_V2_REGION:
|
||||
return
|
||||
_tile_mode = false
|
||||
leaving_tile_mode = true
|
||||
|
||||
# `leaving_tile_mode` FORCES the request through even if
|
||||
# `_window_request`'s own STALE granularity_v2 (never touched while tiled)
|
||||
# happens to already equal `target_rung` by coincidence — without this,
|
||||
# the early-return below would skip the request that's supposed to
|
||||
# POPULATE `_window` for the first time since tile mode replaced it.
|
||||
if not leaving_tile_mode and target_rung == _window_request.get_granularity_v2():
|
||||
return # already requesting (or holding) the rung this extent calls for
|
||||
var new_center: Vector2i = _screen_center_district()
|
||||
var clamped_n: int = AtlasWindowRequest._clamp_window_n_mirror_v2(_held_n, target_rung)
|
||||
_held_center = new_center
|
||||
_held_n = clamped_n
|
||||
_window_request.request_debounced(
|
||||
_dict_str(_body, "body_id", ""), new_center, _held_n, target_rung
|
||||
_dict_str(_body, "body_id", ""), new_center, clamped_n, target_rung
|
||||
)
|
||||
|
||||
|
||||
## The DistrictPos the current screen center maps to, in RAW absolute
|
||||
## district space (matching _maybe_refloat_window()'s own convention — only
|
||||
## the caller canonicalizes the final value it actually stores/sends). Thin
|
||||
## wrapper over AtlasWindowGeometry.screen_center_to_district() (T-1153 —
|
||||
## extracted alongside the rung-selection math for the same testability
|
||||
## reason) so both the pan-edge refetch and the rung-reselect refetch share
|
||||
## ONE screen-to-district formula rather than two copies that could drift
|
||||
## (the exact lesson _maybe_refloat_window()'s own doc already establishes
|
||||
## for the pan case).
|
||||
## district space (the caller canonicalizes the final stored/sent value).
|
||||
## Thin wrapper over AtlasWindowGeometry.screen_center_to_district() so both
|
||||
## the pan-edge refetch and the rung-reselect refetch share ONE formula.
|
||||
func _screen_center_district() -> Vector2i:
|
||||
var raw: Vector2i = AtlasWindowGeometry.screen_center_to_district(
|
||||
size, _view_offset, _view_zoom, CELL_PIXEL_SIZE, _held_center, _held_n
|
||||
@@ -600,19 +650,13 @@ func _screen_center_district() -> Vector2i:
|
||||
return AtlasDescendGeometry.canonicalize_district_center(raw, radius_km)
|
||||
|
||||
|
||||
## Jeroen's HARD condition (D-226 T-1143-rulings amendment): "a full
|
||||
## zoom-out resets to the original canonical planetary frame and location" —
|
||||
## the ladder's TOP REST STATE, never a drifted pan/zoom-out state. Fires
|
||||
## when the CURRENTLY DISPLAYED world extent (at _held_granularity_v2, the
|
||||
## rung actually on screen — deliberately NOT the in-flight request's rung,
|
||||
## so this can't fire prematurely off a request that hasn't landed yet)
|
||||
## covers the whole body (AtlasWindowGeometry.is_fully_zoomed_out()) AND the
|
||||
## player isn't ALREADY sitting at the canonical frame (center == (0,0) —
|
||||
## re-entering the SAME enter_orbital() state on every zoom tick past the
|
||||
## threshold would fight a player trying to zoom back IN from the top, since
|
||||
## every zoom-out tick would keep re-snapping to the identical framing).
|
||||
## Returns true if it fired (the caller should skip _maybe_reselect_rung() —
|
||||
## the reset already re-requested at the canonical Region-rung window).
|
||||
## Jeroen's HARD condition: "a full zoom-out resets to the original
|
||||
## canonical planetary frame and location" — the ladder's TOP REST STATE,
|
||||
## never a drifted pan/zoom-out state. Fires when the CURRENTLY DISPLAYED
|
||||
## extent (at _held_granularity_v2, deliberately NOT the in-flight request's
|
||||
## rung) covers the whole body AND the player isn't ALREADY at the canonical
|
||||
## frame (re-snapping every tick would fight a zoom-in-from-the-top
|
||||
## gesture). Returns true if it fired (caller skips _maybe_reselect_rung()).
|
||||
func _maybe_reset_to_canonical_frame() -> bool:
|
||||
var radius_km: float = float(_body.get("body_radius_km", 0.0))
|
||||
if radius_km <= 0.0:
|
||||
@@ -650,29 +694,26 @@ func set_view(zoom: float, offset: Vector2) -> void:
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## After a pan delta (T-1145: WASD/edge-scroll, called from _process()'s pan
|
||||
## tick every frame the player is actively panning), check whether the
|
||||
## After a pan delta (T-1145: WASD/edge-scroll), check whether the
|
||||
## screen-center now maps to a DistrictPos outside the held window's extent
|
||||
## — if so, float a NEW window centered on that point (§5 "windows float on
|
||||
## the pan center... not grid-snapped") via the debounced request path.
|
||||
## — if so, float a NEW window centered on that point via the debounced path.
|
||||
##
|
||||
## T-1142 (item 6a): the edge-crossing decision below is computed in RAW
|
||||
## absolute district space (un-wrapped, un-clamped) — that is the correct
|
||||
## space for "has the pan carried the view past the held window's edge",
|
||||
## since the held window's own local bounds are relative to _held_center as
|
||||
## it was BEFORE this pan. Only the FINAL new_center that becomes the next
|
||||
## _held_center / the next request is canonicalized (wrap column, clamp
|
||||
## row) — matching the server's own normalize_window_center() and keeping
|
||||
## the client's echo-comparison and cache key on the same canonical form the
|
||||
## server uses (see canonicalize_district_center()'s doc for why this must
|
||||
## match bit-for-bit). A pan that straddles the antimeridian therefore still
|
||||
## floats correctly: the pre-canonicalization abs_col can be e.g. -3 or
|
||||
## district_cols+5, the edge-crossing math treats that as a normal delta from
|
||||
## the old center, and only the resulting new_center gets wrapped into range
|
||||
## before it's requested/cached.
|
||||
## T-1142 (item 6a): the edge-crossing decision is computed in RAW absolute
|
||||
## district space (un-wrapped, un-clamped) — the held window's own local
|
||||
## bounds are relative to _held_center as it was BEFORE this pan. Only the
|
||||
## FINAL new_center is canonicalized (wrap column, clamp row), matching the
|
||||
## server's normalize_window_center() so the echo comparison/cache key stay
|
||||
## on the same canonical form. A pan straddling the antimeridian still
|
||||
## floats correctly: the pre-canonicalization abs_col can go negative or
|
||||
## past cols, and only the resulting new_center gets wrapped before use.
|
||||
func _maybe_refloat_window() -> void:
|
||||
if _held_n <= 0:
|
||||
return
|
||||
if _tile_mode:
|
||||
# T-1153: the tile set already covers the WHOLE body — no "edge" to
|
||||
# cross while tiled. _apply_pan_delta() still moves _view_offset;
|
||||
# this only skips the single-window re-float below.
|
||||
return
|
||||
var raw_new_center: Vector2i = AtlasWindowGeometry.screen_center_to_district(
|
||||
size, _view_offset, _view_zoom, CELL_PIXEL_SIZE, _held_center, _held_n
|
||||
)
|
||||
@@ -716,6 +757,11 @@ func _maybe_refloat_window() -> void:
|
||||
|
||||
func _draw() -> void:
|
||||
draw_rect(Rect2(Vector2.ZERO, get_rect().size), COLOR_BG)
|
||||
if _tile_mode:
|
||||
# T-1153: single-window border-fade/pending-wash don't apply to a
|
||||
# mosaic — AtlasWindowOverlay's tile draw only paints arrived tiles;
|
||||
# an unarrived one is an honest gap over COLOR_BG, no separate fade.
|
||||
return
|
||||
if _window == null:
|
||||
# §5 "what renders during the wait": a border-fade to the underlying
|
||||
# whole-body context rather than black/a spinner. This viewer has no
|
||||
@@ -760,39 +806,26 @@ func _build_screen_header() -> void:
|
||||
_screen_header.apply_implant_theme(_implant_theme)
|
||||
|
||||
|
||||
## D-169/D-170 implant chrome (§5): location label (body name + coordinate,
|
||||
## T-1142 — see _location_label()) + extent-in-real-units subtitle, e.g.
|
||||
## "4.1 x 4.1 km . 2.0 km/cell". T-1153: the extent (`n` districts) is
|
||||
## rung-INVARIANT (n is always district extent — see
|
||||
## AtlasWindowOverlay.cell_grid_side_for_window()'s doc), but the km/cell
|
||||
## reading must reflect the HELD rung's actual spacing (2.048 km at District,
|
||||
## 0.512 km at Quarter, 204.8 km at Region) — this is the "continuous
|
||||
## metres-per-pixel/extent readout" the design doc §6 calls for in place of a
|
||||
## discrete "you are now in Quarter Mode" label (Jeroen's "no mode
|
||||
## transition" ruling): the number itself communicates the rung, no named
|
||||
## mode chrome does.
|
||||
## D-169/D-170 implant chrome (§5) — title/subtitle text built by the pure
|
||||
## AtlasWindowGeometry.screen_header_content() (T-1153: moved there for
|
||||
## file-length; the "continuous metres-per-pixel readout, never a discrete
|
||||
## mode label" rationale lives on that function's own doc now).
|
||||
func _refresh_screen_header() -> void:
|
||||
if _screen_header == null:
|
||||
return
|
||||
var location_label: String = _location_label()
|
||||
var extent_km: float = float(_held_n) * DISTRICT_M / 1000.0
|
||||
var spacing_km: float = AtlasWindowGeometry.spacing_for_rung(_held_granularity_v2) / 1000.0
|
||||
var extent_line: String = "%.1f x %.1f km · %.3f km/cell" % [extent_km, extent_km, spacing_km]
|
||||
var title: String = "REGIONAL — %s" % location_label.to_upper()
|
||||
_screen_header.set_content(title, extent_line)
|
||||
var content: Dictionary = AtlasWindowGeometry.screen_header_content(
|
||||
_dict_str(_body, "proper_name", _dict_str(_body, "body_id", "—")),
|
||||
_held_center, _held_n, _held_granularity_v2, DISTRICT_M
|
||||
)
|
||||
_screen_header.set_content(content["title"], content["subtitle"])
|
||||
|
||||
|
||||
## Body name + coordinate label (T-1142: pulls the CHEAP half of T-1141
|
||||
## forward — the body's proper name was already sitting unused on _body,
|
||||
## passed through the whole descend chain since T-1138, but this header never
|
||||
## read it, showing bare "district (col, row)" with no indication of WHICH
|
||||
## body the player is looking at. T-1141 keeps only the harder half: nearest-
|
||||
## settlement proximity join (the window carries no settlement data of its
|
||||
## own — that lives on the planetary gen_l3_settlements overlay, a different
|
||||
## screen/dataset — a real follow-up, not a silently-guessed one).
|
||||
## Thin wrapper over AtlasWindowGeometry.location_label() (T-1153: moved
|
||||
## there for file-length) — kept as a method since it's directly tested.
|
||||
func _location_label() -> String:
|
||||
var body_name: String = _dict_str(_body, "proper_name", _dict_str(_body, "body_id", "—"))
|
||||
return "%s — (%d, %d)" % [body_name, _held_center.x, _held_center.y]
|
||||
return AtlasWindowGeometry.location_label(
|
||||
_dict_str(_body, "proper_name", _dict_str(_body, "body_id", "—")), _held_center
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -811,16 +844,10 @@ func _is_over_ui(_pos: Vector2) -> bool:
|
||||
|
||||
|
||||
## T-1145 item 2: LMB-drag panning is GONE (Jeroen's ruling — drag broke click
|
||||
## semantics with map objects; clicks are reserved for future map objects,
|
||||
## e.g. settlements). What remains: wheel zoom (unchanged) and tracking the
|
||||
## local mouse position for edge-scroll (_process() reads _last_mouse_pos —
|
||||
## it has no InputEvent of its own to read a live position from). WASD/arrow
|
||||
## panning does NOT go through _gui_input at all — it is a HELD-key,
|
||||
## continuous, frame-rate-independent pan polled every frame in _process()
|
||||
## via Input.is_action_pressed()-equivalent raw key checks (Input.is_key_pressed(),
|
||||
## since WASD has no project-level Input Map action of its own in this
|
||||
## screen's remit — see _process()'s own doc for why raw physical-keycode
|
||||
## polling is deliberate here, not a new InputMap action).
|
||||
## semantics with map objects). What remains: wheel zoom and tracking the
|
||||
## local mouse position for edge-scroll (_process() has no InputEvent of its
|
||||
## own). WASD/arrow panning does NOT go through _gui_input — it's a
|
||||
## HELD-key, frame-rate-independent pan polled every frame in _process().
|
||||
func _gui_input(event: InputEvent) -> void:
|
||||
if event is InputEventKey and event.pressed and not event.is_echo():
|
||||
_handle_key(event as InputEventKey)
|
||||
@@ -850,18 +877,11 @@ func _handle_key(event: InputEventKey) -> void:
|
||||
|
||||
|
||||
## T-1145 item 2: continuous WASD/arrow-key pan + edge-scroll, both applied
|
||||
## here (not _gui_input) because both are HELD-state effects (keys held down,
|
||||
## cursor lingering near an edge), not discrete input events — _process()
|
||||
## polls held state every frame and hands the resulting direction + this
|
||||
## frame's delta to _apply_pan_delta() (split out for testability — a gdUnit
|
||||
## test drives _apply_pan_delta(direction, delta) directly with a
|
||||
## deterministic direction/delta instead of needing to fake Godot's global
|
||||
## Input singleton reporting a key held, which is what testing THIS
|
||||
## function's own Input.is_key_pressed() polling would require). Skips
|
||||
## entirely while this Control is hidden (the screen is not the active
|
||||
## nav-stack entry) — no wasted per-frame work for an invisible viewer, and
|
||||
## no phantom panning if some other code path leaves this node in the tree
|
||||
## but not shown.
|
||||
## here (not _gui_input) since both are HELD-state effects, not discrete
|
||||
## events — polls held state every frame and hands the direction + delta to
|
||||
## _apply_pan_delta() (split out for testability — a gdUnit test drives it
|
||||
## directly rather than faking Godot's global Input singleton). Skips while
|
||||
## hidden (screen not the active nav-stack entry).
|
||||
func _process(delta: float) -> void:
|
||||
if not visible:
|
||||
return
|
||||
@@ -873,15 +893,11 @@ func _process(delta: float) -> void:
|
||||
_apply_pan_delta(direction, delta)
|
||||
|
||||
|
||||
## The actual pan-tick state mutation, given an ALREADY-DECIDED (but not yet
|
||||
## normalized) direction and this frame's delta — frame-rate independent
|
||||
## (motion scales by `delta`, so the same speed at 30fps or 144fps), zoom-
|
||||
## scaled (PAN_SPEED_CANVAS_PX_S * _view_zoom — see that constant's own doc
|
||||
## for why), and pole-wall clamped (T-1142, unchanged mechanism, just fed by
|
||||
## a different input source now). Sets _user_adjusted (T-1145: "WASD/edge/
|
||||
## zoom all set _user_adjusted") and triggers the SAME pan-edge refetch check
|
||||
## (§4) drag used to. Split from _process() specifically so a test can call
|
||||
## this directly with a synthetic direction/delta — see _process()'s own doc.
|
||||
## The actual pan-tick state mutation, given an ALREADY-DECIDED direction and
|
||||
## this frame's delta — frame-rate independent, zoom-scaled
|
||||
## (PAN_SPEED_CANVAS_PX_S * _view_zoom), pole-wall clamped (T-1142). Sets
|
||||
## _user_adjusted and triggers the pan-edge refetch (§4). Split from
|
||||
## _process() so a test can call it directly with a synthetic direction/delta.
|
||||
func _apply_pan_delta(direction: Vector2, delta: float) -> void:
|
||||
var normalized: Vector2 = direction.normalized() # diagonal isn't faster than a single axis
|
||||
_user_adjusted = true
|
||||
|
||||
Reference in New Issue
Block a user