diff --git a/client/tests/test_atlas_window_geometry.gd b/client/tests/test_atlas_window_geometry.gd index d1e385609..9d407785a 100644 --- a/client/tests/test_atlas_window_geometry.gd +++ b/client/tests/test_atlas_window_geometry.gd @@ -744,6 +744,154 @@ func test_district_to_canvas_local_zero_held_n_does_not_crash() -> void: assert_that(result).is_equal(Vector2(5, 5) * CELL_PIXEL_SIZE) +# ============================================================================= +# Live round 5: nearest_wrap_image() — the tile-mosaic WRAP half of "the +# mosaic doesn't fully draw" (the left-third-black repro). +# ============================================================================= + + +## Live round 5's OWN repro, pinned exactly: Lendel's wrapped tile +## canonicalizes to column 12739 (`-6400 mod 19139`) — the CORRECT +## request/cache key — but its nearest wrap-image relative to the canonical +## origin (held_center.x = 0) is -6400, the actual visible position +## immediately west of center. +func test_nearest_wrap_image_matches_the_lendel_repro() -> void: + var result: int = AtlasWindowGeometry.nearest_wrap_image(12739, 0, 19139) + assert_int(result).override_failure_message( + "the wrapped tile's nearest wrap-image relative to held_center=0 must be" + + " -6400 (its actual on-screen position), not 12739 (the correct REQUEST" + + " key, but the wrong DRAW position)" + ).is_equal(-6400) + + +## The two Lendel tiles that were NEVER wrapped (already close to +## held_center) must round-trip unchanged — the fix must not perturb tiles +## that were already drawing correctly. +func test_nearest_wrap_image_is_a_noop_for_already_nearby_columns() -> void: + var cols := 19139 + for col: int in [0, 6400]: + var result: int = AtlasWindowGeometry.nearest_wrap_image(col, 0, cols) + assert_int(result).override_failure_message( + "column %d is already the nearest wrap-image to held_center=0 — must" + + " be returned unchanged" % col + ).is_equal(col) + + +## The result must always be a LEGAL wrap-image of the canonical column — +## i.e. `result mod cols == canonical_col mod cols` — regardless of which +## image is nearest. This is the correctness invariant the whole function +## exists to preserve: re-expressing a column for DRAWING must never change +## WHICH district it actually refers to. +func test_nearest_wrap_image_preserves_the_canonical_identity() -> void: + var cols := 19139 + for held_col: int in [-50000, -1, 0, 1, 9569, 19138, 50000]: + var result: int = AtlasWindowGeometry.nearest_wrap_image(12739, held_col, cols) + assert_int(posmod(result, cols)).override_failure_message( + "nearest_wrap_image(12739, %d, %d) = %d must still canonicalize back" + + " to 12739 — it may only pick a DIFFERENT wrap-image, never a" + + " different district" % [held_col, cols, result] + ).is_equal(12739) + + +## The chosen wrap-image must be the CLOSEST one to held_center — never +## farther than half the circumference away (otherwise a different +## wrap-image would have been nearer). +func test_nearest_wrap_image_is_within_half_circumference_of_held_center() -> void: + var cols := 19139 + for canonical_col: int in [0, 1, 9569, 12739, 19138]: + for held_col: int in [-30000, -500, 0, 500, 25000]: + var result: int = AtlasWindowGeometry.nearest_wrap_image(canonical_col, held_col, cols) + var distance: int = absi(result - held_col) + assert_int(distance).override_failure_message( + ( + "nearest_wrap_image(%d, %d, %d) = %d is %d districts from" + + " held_center — must never exceed half the circumference" + + " (%d), or a closer wrap-image exists" + ) + % [canonical_col, held_col, cols, result, distance, cols / 2] + ).is_less_equal(cols / 2) + + +## `cols <= 0` (no-radius bodies, which never tile per compute_tile_grid()'s +## own doc) must be a safe no-op passthrough — no periodicity to resolve. +func test_nearest_wrap_image_zero_cols_is_a_passthrough() -> void: + var result: int = AtlasWindowGeometry.nearest_wrap_image(12739, 0, 0) + assert_int(result).is_equal(12739) + + +## The coordinator's own draw-position counterpart to +## test_compute_tile_grid_tiles_are_all_canonicalized(): the wrapped tile's +## DRAW rect (via district_to_canvas_local(), fed through +## nearest_wrap_image() the way _draw_tile_mosaic() now does) must land +## SUBSTANTIALLY on-canvas when the view covers the whole body — the exact +## Lendel shape (whole-body fit at entry, held_center at the canonical +## origin). A bare `Rect2.intersects()` check is NOT discriminating enough +## here: at Lendel's own whole-body-fit scale, the BUGGY placement (feeding +## the canonical column directly) happens to clip the viewport edge by only +## a couple of px (confirmed by hand-computation — the tile-grid's own +## edge-to-edge tiling means a full-circumference shift lands almost +## exactly one screen-width away, so `intersects()` alone would pass on a +## near-miss that still reads as "the left third is black" visually). +## Asserting a MEANINGFUL overlap FRACTION (at least half the tile's own +## area) is what actually distinguishes "correctly drawn" from "barely +## clipping the edge." +func test_wrapped_tile_draw_rect_lands_substantially_on_canvas_at_whole_body_view() -> void: + var radius_km := 6238.4 # GJ380c (Lendel) — the live-repro body + var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km) + var cols: int = int(extent["cols"]) + var held_center := Vector2i.ZERO + var held_n: int = cols # enter_orbital()'s own whole-body held_n + var tile_n: int = AtlasWindowGeometry.TILE_N + var half_tile: float = float(tile_n) * 0.5 + + # The whole-body fit zoom/viewport (matching enter_orbital()'s own fit). + var viewport := Vector2(1600.0, 900.0) + var fit: Dictionary = AtlasWindowGeometry.fit_window_view( + viewport, held_n, CELL_PIXEL_SIZE, 0.0001, 64.0 + ) + var view_zoom: float = fit["zoom"] + var view_offset: Vector2 = fit["offset"] + + # The wrapped tile's own canonical center — mirrors compute_tile_grid()'s + # own dedup/canonicalize step for Lendel's westmost tile. + var wrapped_raw_col := -6400 + var canonical_col: int = posmod(wrapped_raw_col, cols) + + var draw_col: int = AtlasWindowGeometry.nearest_wrap_image(canonical_col, held_center.x, cols) + var tile_top_left := Vector2(float(draw_col) - half_tile, 0.0 - half_tile) + var local_origin: Vector2 = AtlasWindowGeometry.district_to_canvas_local( + tile_top_left, held_center, held_n, CELL_PIXEL_SIZE + ) + var extent_px: float = float(tile_n) * CELL_PIXEL_SIZE + + # Canvas-local -> screen space: _canvas.position = view_offset, + # _canvas.scale = view_zoom (AtlasWindowViewer._apply_transform()'s own + # transform, mirrored here since this is a pure-geometry test with no + # live Control/Node2D tree). + var screen_top_left: Vector2 = view_offset + local_origin * view_zoom + var screen_extent: Vector2 = Vector2(extent_px, extent_px) * view_zoom + var tile_rect := Rect2(screen_top_left, screen_extent) + var viewport_rect := Rect2(Vector2.ZERO, viewport) + + var overlap: Rect2 = viewport_rect.intersection(tile_rect) + var tile_area: float = screen_extent.x * screen_extent.y + var overlap_fraction: float = 0.0 + if tile_area > 0.0: + overlap_fraction = (overlap.size.x * overlap.size.y) / tile_area + + assert_float(overlap_fraction).override_failure_message( + ( + "the wrapped tile's draw rect %s overlaps the viewport %s by only" + + " %.1f%% of its own area — must be at least 50%% when the view" + + " covers the whole body. This is live round 5's 'left third of the" + + " mosaic is black' repro: drawing the CANONICAL column (%d) directly" + + " (without nearest_wrap_image()) places this tile off-canvas RIGHT" + + " instead of its true position on the LEFT" + ) + % [tile_rect, viewport_rect, overlap_fraction * 100.0, canonical_col] + ).is_greater_equal(0.5) + + ## The core contract this function exists for: recomputing `_view_offset` so ## a KNOWN screen point continues to map to canvas-local ## `new_held_n/2 * cell_px` (the new window's own center) — i.e. feeding the diff --git a/client/tests/test_atlas_window_overlay_draw_smoke.gd b/client/tests/test_atlas_window_overlay_draw_smoke.gd index b7e3e71f8..7c5171505 100644 --- a/client/tests/test_atlas_window_overlay_draw_smoke.gd +++ b/client/tests/test_atlas_window_overlay_draw_smoke.gd @@ -90,6 +90,11 @@ class _TileModeViewerStub: var tiles: Array = [] var held_center: Vector2i = Vector2i.ZERO var held_n: int = 0 + # Live round 5: nearest_wrap_image()'s cols input — 0 here (a no-radius + # passthrough) is fine for these tests, which don't exercise the wrap + # seam itself (that's test_atlas_window_geometry.gd's own coverage); + # this stub only needs to satisfy _draw_tile_mosaic()'s duck-typed call. + var body_radius_km: float = 0.0 func get_district_window() -> Variant: return null @@ -112,6 +117,9 @@ class _TileModeViewerStub: func get_held_n() -> int: return held_n + func get_body_radius_km() -> float: + return body_radius_km + class _TileSetStub: var _tiles: Array = [] diff --git a/client/tests/test_atlas_zoom_ladder.gd b/client/tests/test_atlas_zoom_ladder.gd index 1550f0698..5331edbf3 100644 --- a/client/tests/test_atlas_zoom_ladder.gd +++ b/client/tests/test_atlas_zoom_ladder.gd @@ -13,6 +13,7 @@ extends GdUnitTestSuite const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_request.gd") const AtlasDescendGeometry := preload("res://ui/implant/apps/atlas/atlas_descend_geometry.gd") +const AtlasWindowGeometry := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd") ## Dudley's WINDOW_GRANULARITY_REGION_KEY (server/src/atlas/layer_proxy.rs) — ## `u32::MAX`, a RESERVED KEY-SPACE TAG the real server ALWAYS puts in the @@ -295,6 +296,138 @@ func test_reset_to_canonical_frame_fires_and_re_centers_when_fully_zoomed_out() ).is_equal("Region") +## Live round 5's OWN repro, end to end: enter a TILING body's canonical +## frame, wheel-zoom IN far enough to cross out of tile mode (leaving +## `_held_granularity_v2` STALE at "Region" — a real, expected lag per +## `_maybe_reselect_rung()`'s own "does NOT touch _held_granularity_v2" +## doc, not a bug in that function), then wheel-zoom back OUT past the +## fully-zoomed-out threshold. The reset must fire and land EXACTLY on +## enter_orbital()'s own fit zoom for this body/viewport — not merely +## re-center while leaving `_view_zoom` wherever continued `_zoom_at()` +## scaling left it. Before the fix, the stale "Region" granularity +## satisfied the guard's OLD (center + granularity only) check forever, +## so the reset never fired again and `_view_zoom` kept shrinking via +## plain multiplication all the way to MIN_ZOOM. +func test_reset_after_crossing_out_and_back_snaps_to_the_canonical_fit_zoom() -> 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) — a tiling body, the live-repro shape + v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {}) + assert_bool(v.is_tile_mode()).override_failure_message( + "sanity: Lendel must enter tile mode — this repro needs a TILING body," + + " since that's where _held_granularity_v2 can lag is_tile_mode()" + ).is_true() + + # Zoom IN far enough to cross out of tile mode (matching + # test_zoom_crossing_recomputes_view_offset_so_the_new_window_is_on_screen's + # own gesture shape). + var cursor_pos := Vector2(800.0, 450.0) + for _i in range(60): + v._zoom_at(cursor_pos, 1.15) + if not v.is_tile_mode(): + break + assert_bool(v.is_tile_mode()).override_failure_message( + "sanity: this test needs to actually leave tile mode before zooming back out" + ).is_false() + assert_str(v._held_granularity_v2).override_failure_message( + "sanity: _held_granularity_v2 must be STALE at Region here (no mock response" + + " ever adopted a new value) — this is the exact lagging-field condition" + + " the guard fix targets, not an artificial setup" + ).is_equal("Region") + + # Zoom back OUT past the fully-zoomed-out threshold — the reset must fire + # (possibly after a few more _zoom_at() ticks, matching a real wheel + # gesture rather than asserting it fires on the very first step back). + for _i in range(200): + v._zoom_at(cursor_pos, 1.0 / 1.05) + if v.is_tile_mode(): + break + + assert_bool(v.is_tile_mode()).override_failure_message( + "zooming back out past the threshold must re-fire the reset and land back" + + " in tile mode — the stale-granularity guard bug left this permanently false" + ).is_true() + assert_that(v._held_center).is_equal(Vector2i.ZERO) + + var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km) + var n: int = int(extent["cols"]) + var expected_fit: Dictionary = AtlasWindowGeometry.fit_window_view( + v.size, n, AtlasWindowViewer.CELL_PIXEL_SIZE, AtlasWindowViewer.MIN_ZOOM, AtlasWindowViewer.MAX_ZOOM + ) + assert_float(v._view_zoom).override_failure_message( + ( + "post-reset _view_zoom (%.6f) must equal enter_orbital()'s own fit zoom" + + " (%.6f) for this body/viewport — Jeroen's condition is the ORIGINAL" + + " frame (center AND offset AND fit zoom), not merely re-centered at" + + " whatever zoom continued _zoom_at() scaling left behind" + ) + % [v._view_zoom, expected_fit["zoom"]] + ).is_equal_approx(float(expected_fit["zoom"]), 0.000001) + + +## Live round 5's OWN live-drive repro, exactly: a REAL wheel gesture does +## NOT stop the instant the reset first fires — the coordinator's own +## tmp_drive_ladder.gd keeps sending wheel-down ticks toward a fixed target +## zoom (0.004, chosen below the fit zoom) regardless of the reset. This +## test reproduces that shape directly: continue zooming out PAST the point +## where the reset first re-enters tile mode, all the way to a target zoom +## BELOW the fit value. Before the second live-round-5 fix, `_view_zoom` +## drifted back down from the fit value on every subsequent `_zoom_at()` +## tick (ordinary multiplicative scaling doesn't care that a reset just +## happened) while the mode/center/granularity guard read "already +## canonical" and silently let it drift, landing on whatever the LOOP's +## target zoom happened to be instead of the fit value. +func test_reset_resnaps_even_after_continued_zoom_out_past_the_first_reset() -> 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}, {}) + + var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km) + var n: int = int(extent["cols"]) + var expected_fit: Dictionary = AtlasWindowGeometry.fit_window_view( + v.size, n, AtlasWindowViewer.CELL_PIXEL_SIZE, AtlasWindowViewer.MIN_ZOOM, AtlasWindowViewer.MAX_ZOOM + ) + var fit_zoom: float = float(expected_fit["zoom"]) + + # Zoom IN far enough to leave tile mode (same shape as the test above). + var cursor_pos := Vector2(1100.0, 300.0) # matches tmp_drive_ladder.gd's own aim point + for _i in range(60): + v._zoom_at(cursor_pos, 1.15) + if not v.is_tile_mode(): + break + assert_bool(v.is_tile_mode()).is_false() + + # Zoom back OUT toward a target BELOW the fit zoom — matching + # tmp_drive_ladder.gd's own `_zoom_until(wv, 0.004, false)` exactly + # (Lendel's own fit zoom is ~0.00627, comfortably above this target), + # WITHOUT stopping early the moment tile mode is first regained. A real + # wheel gesture has no way to know when the reset internally fires. + var target_zoom := 0.004 + for _i in range(200): + if v._view_zoom <= target_zoom: + break + v._zoom_at(cursor_pos, 1.0 / 1.05) + + assert_bool(v.is_tile_mode()).override_failure_message( + "after continued zoom-out past the reset point, the view must settle back" + + " into tile mode — a genuinely re-snapped canonical frame can't have zoomed" + + " OUT further than the fit value in the first place" + ).is_true() + assert_float(v._view_zoom).override_failure_message( + ( + "post-reset _view_zoom (%.6f) must equal the canonical fit zoom (%.6f) even" + + " though the wheel gesture continued past the point where the reset first" + + " fired (target was %.6f, BELOW the fit zoom) — the guard must re-fire on" + + " every subsequent tick where the zoom has drifted away from the fit value," + + " not just once" + ) + % [v._view_zoom, fit_zoom, target_zoom] + ).is_equal_approx(fit_zoom, 0.000001) + + ## Not fully zoomed out (a normal District-rung view) must NOT trigger the ## reset — only reaching the top of the ladder resets, not every zoom step. func test_reset_to_canonical_frame_does_not_fire_when_not_fully_zoomed_out() -> void: diff --git a/client/ui/implant/apps/atlas/atlas_window_geometry.gd b/client/ui/implant/apps/atlas/atlas_window_geometry.gd index d3deb0ca8..66d0e9fb4 100644 --- a/client/ui/implant/apps/atlas/atlas_window_geometry.gd +++ b/client/ui/implant/apps/atlas/atlas_window_geometry.gd @@ -406,6 +406,38 @@ static func district_to_canvas_local( return Vector2(local_col, local_row) +## Live round 5 fix (the tile-mosaic WRAP half of "the mosaic doesn't fully +## draw"): `compute_tile_grid()`'s tiles are CANONICAL columns (wrapped into +## `[0, cols)` — the correct, single-valued key for REQUESTS and cache +## coalescing), but a canonical column has infinitely many EQUIVALENT +## on-screen positions (`col`, `col - cols`, `col + cols`, ...), since +## longitude is periodic. `district_to_canvas_local()` is a pure LINEAR +## function with no wrap concept — fed a canonical column directly, it +## places the tile at exactly ONE of those wrap-images, which is only ever +## the visually-correct one by coincidence. Lendel's own repro: the tile +## whose pre-canonicalization center was -6400 canonicalizes to 12739 +## (`-6400 mod 19139`) — correct for the request/cache key, but drawing at +## column 12739 directly places it canvas-local ~22308 (off-canvas RIGHT), +## when the tile's actual visible position (immediately west of the +## canonical origin) is at column -6400 (canvas-local ~3169, the LEFT +## third of the mosaic). +## +## The fix: before handing a tile's canonical column to +## `district_to_canvas_local()`, re-express it as whichever wrap-image +## (`canonical_col + k*cols` for integer `k`) is NEAREST `held_center.x` — +## the representative that's actually near the current view, matching how a +## real, non-tiling single-window pan already resolves the "which +## circumnavigation" question implicitly (screen_center_to_district()'s own +## RAW, un-wrapped output). `cols <= 0` (no-radius bodies, which never tile +## per compute_tile_grid()'s own doc) is a safe no-op passthrough — there is +## no periodicity to resolve. +static func nearest_wrap_image(canonical_col: int, held_center_col: int, cols: int) -> int: + if cols <= 0: + return canonical_col + var delta: int = posmod(canonical_col - held_center_col + cols / 2, cols) - cols / 2 + return held_center_col + delta + + ## Live round 4 fix (the SECOND half of the "pitch black" repro, beyond ## district_to_canvas_local()'s tile-mosaic fix above): `_view_offset` is a ## PURE screen<->canvas-local transform, entirely independent of diff --git a/client/ui/implant/apps/atlas/atlas_window_overlay.gd b/client/ui/implant/apps/atlas/atlas_window_overlay.gd index ca8184e48..9a104dfce 100644 --- a/client/ui/implant/apps/atlas/atlas_window_overlay.gd +++ b/client/ui/implant/apps/atlas/atlas_window_overlay.gd @@ -63,6 +63,8 @@ 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") +# Live round 5: `cols` (circumference in districts) for the mosaic's wrap-image draw fix. +const AtlasDescendGeometryRef := preload("res://ui/implant/apps/atlas/atlas_descend_geometry.gd") ## T-1145 item 3: interim presentation toggle — true renders the smoothed ## Image/ImageTexture composite; false keeps the original crisp per-cell @@ -177,6 +179,21 @@ func _draw() -> void: ## 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). +## +## **Live round 5 fix:** `tile["center"]` is CANONICAL (wrapped into +## `[0, cols)` by `compute_tile_grid()` — correct for REQUESTS/cache keys, +## since longitude is periodic and a canonical column is the single-valued +## key both sides of the wire agree on). But `district_to_canvas_local()` +## is a pure LINEAR function with no wrap concept — handed a canonical +## column directly, it places the tile at exactly ONE of its infinitely +## many equivalent on-screen positions (`col + k*cols`), which is only the +## visually-correct one by coincidence. Lendel's own repro: the tile whose +## true position is immediately WEST of the canonical origin canonicalizes +## to column 12739 (`-6400 mod 19139`) — drawn there directly, it lands +## off-canvas RIGHT, leaving the mosaic's actual LEFT third black. Fixed by +## re-expressing each tile's column via `nearest_wrap_image()` — whichever +## wrap-image is closest to `held_center`, i.e. the one actually near the +## current view — BEFORE handing it to `district_to_canvas_local()`. func _draw_tile_mosaic() -> void: var tile_set = viewer.get_tile_set() if tile_set == null: @@ -187,6 +204,9 @@ func _draw_tile_mosaic() -> void: var held_n: int = viewer.get_held_n() var half_tile: float = float(AtlasWindowGeometryRef.TILE_N) * 0.5 var tiles: Array = tile_set.get_tiles() + var cols: int = int( + AtlasDescendGeometryRef.district_extent(viewer.get_body_radius_km()).get("cols", 0) + ) for i in range(tiles.size()): var tile: Dictionary = tiles[i] @@ -202,8 +222,9 @@ func _draw_tile_mosaic() -> void: continue var center: Vector2i = tile["center"] + var draw_col: int = AtlasWindowGeometryRef.nearest_wrap_image(center.x, held_center.x, cols) var tile_top_left: Vector2 = Vector2( - float(center.x) - half_tile, float(center.y) - half_tile + float(draw_col) - half_tile, float(center.y) - half_tile ) var local_origin: Vector2 = AtlasWindowGeometryRef.district_to_canvas_local( tile_top_left, held_center, held_n, cell_px diff --git a/client/ui/implant/apps/atlas/atlas_window_viewer.gd b/client/ui/implant/apps/atlas/atlas_window_viewer.gd index 77d5fb627..cf742db71 100644 --- a/client/ui/implant/apps/atlas/atlas_window_viewer.gd +++ b/client/ui/implant/apps/atlas/atlas_window_viewer.gd @@ -63,15 +63,9 @@ const MAX_ZOOM: float = 64.0 const ZOOM_STEP: float = 1.15 ## T-1145 item 2: WASD/arrow-key continuous pan speed, in CANVAS px/s at -## zoom=1.0 — the ACTUAL screen-space pan rate is this value times the -## CURRENT _view_zoom (see _process()'s pan tick), so panning covers the -## same amount of TERRAIN per second regardless of zoom level. A fixed -## SCREEN-px/s rate (no zoom scaling) would feel painfully slow zoomed in -## (each screen pixel is a fraction of a district) and uncontrollably fast -## zoomed out — scaling by zoom keeps the "how much world passes per -## second" feel constant, matching the ticket's "speed in screen px/s -## scaled by zoom" wording. ~6 districts/s at zoom=1.0 (96/16) — brisk -## enough to cross a default n=32 window in ~5s, not a crawl. +## zoom=1.0 — actual screen-space rate is this times CURRENT _view_zoom, so +## panning covers the same TERRAIN per second regardless of zoom level. +## ~6 districts/s at zoom=1.0 (96/16) — brisk, not a crawl. const PAN_SPEED_CANVAS_PX_S: float = 96.0 ## T-1145 item 2: cursor-to-edge distance (px) that triggers edge-scroll — @@ -154,12 +148,10 @@ var _held_n: int = 32 ## T-1153: the granularity_v2 tag ("Quarter"/"District"/"Region") this viewer ## is currently HOLDING (the last-adopted _window's own rung) — distinct from ## _window_request.get_granularity_v2(), which is what's most recently been -## REQUESTED (may be a finer/coarser rung already in flight while the held -## composite is still the previous rung's, per the progressive-refinement -## contract: hold the old composite, swap only when the new one arrives). -## Defaults to District — the ladder's historical entry rung, and the correct -## disposition for AtlasDescendGeometry click-through descent (still District, -## see enter()'s own doc). +## REQUESTED (may be a finer/coarser rung already in flight, per the +## progressive-refinement contract: hold the old composite, swap only when +## the new one arrives). Defaults to District — the ladder's historical +## entry rung (see enter()'s own doc). var _held_granularity_v2: String = "District" # ── Pan/zoom state ───────────────────────────────────────────────────────── @@ -255,20 +247,14 @@ func _exit_tree() -> void: SimBridge.atlas_layers_received.disconnect(_on_atlas_layers_received) -## 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. +## Enter the window screen centered on `district_center` at District +## granularity. n defaults to 32, half the server's hard cap. Thin +## District-rung wrapper over _enter_at_rung() (T-1153) — a +## click-to-descend-to-point shortcut; survives as a direct-call test entry. ## ## T-1142: `district_center` is canonicalized (wrap column / clamp row) ## BEFORE it becomes `_held_center` or reaches the request — matching the -## 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. +## server's normalize_window_center() so the echo comparison never mismatches. func enter( body: Dictionary, system: Dictionary, @@ -283,28 +269,19 @@ 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 "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). +## frame (Jeroen's HARD condition: whole body fitted to canvas, centered at +## the canonical origin). 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. ## -## Canonical origin = district (0,0) — "district (0,0) sits at lon 0 / the -## 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). +## Canonical origin = district (0,0) — same quantity +## is_fully_zoomed_out()/_maybe_reset_to_canonical_frame() test against. +## No-radius bodies fall back to the District-rung default window. ## ## **Live round 3 (design doc §4): the rest state must TILE.** A single -## wire-capped Region window covers at most -## `AtlasWindowGeometry.MAX_COVERAGE_M["Region"]`, a fraction of a real -## body's circumference. 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" and stays single-window. +## wire-capped Region window covers only a fraction of a real body's +## circumference. Once `compute_tile_grid()` returns MORE than one tile, +## entry goes through `_enter_tile_mode()` instead of `_enter_at_rung()`. 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: @@ -355,13 +332,8 @@ func _enter_tile_mode(body: Dictionary, system: Dictionary, radius_km: float) -> ## ## **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. +## mirroring AtlasWindowRequest.request_now()'s own `_n` clamp (PR #191 Tyre +## C1). Raw `n` left `_held_n` disagreeing with the server's clamped echo. func _enter_at_rung( body: Dictionary, system: Dictionary, @@ -424,8 +396,7 @@ func leave() -> void: ## Named get_district_window(), NOT get_window() — Node already defines -## get_window() -> Window (the containing OS window); shadowing it with an -## incompatible return type is a Godot parse error (confirmed the hard way). +## get_window() -> Window; shadowing it with an incompatible type errors. func get_district_window() -> Variant: return _window @@ -444,9 +415,8 @@ func get_tile_set() -> Variant: ## Live round 4: currently-HELD reference frame — AtlasWindowOverlay's -## mosaic draw path converts each tile's absolute district center into -## canvas-local space via these (see AtlasWindowGeometry. -## district_to_canvas_local()'s own doc for the shared convention). +## mosaic draw path converts tile centers into canvas-local space via these +## (see AtlasWindowGeometry.district_to_canvas_local()'s own doc). func get_held_center() -> Vector2i: return _held_center @@ -455,6 +425,13 @@ func get_held_n() -> int: return _held_n +## Live round 5: current body's radius — mosaic draw needs `cols` for +## nearest_wrap_image()'s wrap resolution. Mirrors the +## `_body.get("body_radius_km", 0.0)` pattern used throughout this file. +func get_body_radius_km() -> float: + return float(_body.get("body_radius_km", 0.0)) + + ## 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 @@ -587,19 +564,19 @@ func _current_world_extent_m() -> float: ## §5 rung-selection rule + progressive refinement (T-1153): after a zoom ## 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). +## CURRENT screen-center (_screen_center_district(), same formula as +## _maybe_refloat_window()). ## ## **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, -## so a stale large `_held_n` fed into a smaller-rung request desyncs -## `_on_window_ready()`'s `w_n != _held_n` check and drops the refinement -## forever. Same bug as _enter_at_rung(), recurring at the CROSSING boundary. +## re-clamped via `_clamp_window_n_mirror_v2()` for the TARGET rung — a stale +## large `_held_n` desyncs `_on_window_ready()`'s `w_n != _held_n` check and +## drops the refinement forever. ## ## 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). +## one (§6 "no mode flip"). Live round 5: this lag is exactly what made +## `_maybe_reset_to_canonical_frame()`'s OLD guard misfire — see that +## function's own doc. func _maybe_reselect_rung() -> void: if _held_n <= 0: return @@ -653,10 +630,26 @@ func _screen_center_district() -> Vector2i: ## 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()). +## extent 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()). +## +## **Live round 5 fix:** the "already there" guard checked only +## `_held_center`/`_held_granularity_v2` — a LAGGING field (updated only on +## response adoption, §6). A TILING body's `_held_granularity_v2` stays +## "Region" after zooming IN crosses `_tile_mode -> false` (no response +## landed yet); zooming back OUT misread that stale value as "already +## canonical," so `_view_zoom` shrank to MIN_ZOOM instead of the fit value. +## Fixed by also requiring `is_tile_mode()` to match a fresh entry's value. +## +## **Live round 5, SECOND fix (same repro, one tick later):** a real wheel +## gesture keeps sending zoom-out ticks AFTER the reset fires — `_zoom_at()` +## scales `_view_zoom` down every tick regardless, so it drifts below the +## fit value again almost immediately (the fit zoom sits right at the +## fully-zoomed-out threshold by construction). The mode/center/granularity +## guard then reads "already canonical" (true — those never moved) and +## skips re-firing, even though the ZOOM drifted away. `_view_zoom` must +## also be compared against the CURRENT fit zoom. func _maybe_reset_to_canonical_frame() -> bool: var radius_km: float = float(_body.get("body_radius_km", 0.0)) if radius_km <= 0.0: @@ -664,7 +657,18 @@ func _maybe_reset_to_canonical_frame() -> bool: var world_extent_m: float = _current_world_extent_m() if not AtlasWindowGeometry.is_fully_zoomed_out(world_extent_m, radius_km): return false - if _held_center == Vector2i.ZERO and _held_granularity_v2 == AtlasWindowRequest.GRANULARITY_V2_REGION: + var canonical_tile_mode: bool = AtlasWindowGeometry.compute_tile_grid(radius_km).size() > 1 + var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km) + var canonical_n: int = int(extent["cols"]) + var fit: Dictionary = AtlasWindowGeometry.fit_window_view( + get_rect().size, canonical_n, CELL_PIXEL_SIZE, MIN_ZOOM, MAX_ZOOM + ) + if ( + _held_center == Vector2i.ZERO + and _held_granularity_v2 == AtlasWindowRequest.GRANULARITY_V2_REGION + and _tile_mode == canonical_tile_mode + and is_equal_approx(_view_zoom, float(fit["zoom"])) + ): return false # already at the canonical frame — don't fight a zoom-in-from-the-top gesture enter_orbital(_body, _system) return true @@ -699,13 +703,9 @@ func set_view(zoom: float, offset: Vector2) -> void: ## — if so, float a NEW window centered on that point via the debounced path. ## ## 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. +## district space — 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. func _maybe_refloat_window() -> void: if _held_n <= 0: return