From c1a97166c5aac49b3be363291f8b6030a96fce6e Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 25 Jul 2026 12:52:06 +0200 Subject: [PATCH 1/4] feat(ui): cap rung extents to the body + fit/center the Global canvas (T-1189, T-1192) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two client fixes for the Atlas frames Jeroen flagged. Region rung no longer requests more planet than exists: cap_extent_to_body() caps the viewport-fit extent at the body's own region grid (the Global canvas extent echo — cols=regions_per_equator, rows=cols/2), matched by gridunit SPACING not rung name, applied before the request/cache key is built; cold-start scroll-before-Global-echo requests uncapped and lets the server clamp (documented). Kills both the side-by-side continent repeat and the past-the-pole stripe smear. Global opener now fit-scales and centers through one shared letterbox mechanism (center_offset/integer_fit_scale/fit_scale, 75%-coverage integer-vs-fractional decision, NEAREST already forced on orbital rungs per D-255's escape hatch) also used for the Region cap's letterbox remainder. Legend column reserved before fitting — pinned to the legend's own width by a cross-constant test, never overlapping the canvas. Also fixes a latent crash: NOTIFICATION_RESIZED fires mid-_ready() before children exist; null guard in the resize path. Co-Authored-By: Claude Fable 5 --- client/tests/test_step_canvas_legend.gd | 12 + client/tests/test_step_canvas_transport.gd | 171 +++++++++++ client/tests/test_step_canvas_viewer.gd | 267 ++++++++++++++++++ .../step_canvas_annotation_layer.gd | 18 +- .../atlas/step_canvas/step_canvas_legend.gd | 6 + .../step_canvas/step_canvas_terrain_layer.gd | 9 + .../step_canvas/step_canvas_transport.gd | 110 ++++++++ .../atlas/step_canvas/step_canvas_viewer.gd | 141 ++++++++- 8 files changed, 721 insertions(+), 13 deletions(-) diff --git a/client/tests/test_step_canvas_legend.gd b/client/tests/test_step_canvas_legend.gd index a8d73fd64..ed29f208d 100644 --- a/client/tests/test_step_canvas_legend.gd +++ b/client/tests/test_step_canvas_legend.gd @@ -7,6 +7,7 @@ class_name TestStepCanvasLegend extends GdUnitTestSuite const LegendScript := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_legend.gd") +const StepCanvasTransport := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd") func test_legend_starts_hidden_before_refresh() -> void: @@ -68,3 +69,14 @@ func test_reposition_sets_a_fixed_panel_margin_position() -> void: auto_free(legend) legend.reposition() assert_that(legend.position).is_equal(Vector2(LegendScript.PANEL_MARGIN, 60.0)) + + +## T-1192: StepCanvasTransport.LEGEND_COLUMN_PX (the Global fit-scale +## reservation StepCanvasViewer applies) must stay derived from this SAME +## panel's own width/margin — a drift here would silently reopen the +## "legend overlaps the canvas" defect on one side while the OTHER side +## thinks it already reserved enough room. +func test_reserved_column_px_matches_the_transport_sides_own_constant() -> void: + assert_float(LegendScript.RESERVED_COLUMN_PX).is_equal_approx( + StepCanvasTransport.LEGEND_COLUMN_PX, 0.01 + ) diff --git a/client/tests/test_step_canvas_transport.gd b/client/tests/test_step_canvas_transport.gd index 53858bf46..2b3905ba2 100644 --- a/client/tests/test_step_canvas_transport.gd +++ b/client/tests/test_step_canvas_transport.gd @@ -188,3 +188,174 @@ func test_canvas_footprint_px_is_extent_times_display_ratio() -> void: func test_half_extent_m_is_half_the_cell_count_times_spacing() -> void: var half: float = StepCanvasTransport.half_extent_m("District", 64) assert_float(half).is_equal_approx(64.0 * 0.5 * 2048.0, 0.01) + + +# ============================================================================= +# T-1189: extent cap to the body's own region grid — the sideways-repeat / +# pole-smear fix. The Global echo IS the cap (its canvas already equals the +# body's region grid, D-255(a): "the Global canvas IS the whole body at +# region spacing"), so no unit conversion is needed — Region shares Global's +# gridunit spacing exactly. +# ============================================================================= + + +func test_cap_extent_to_body_clamps_region_to_the_global_echo() -> void: + # T-1183 eyeball: 384x216 requested at Region on GJ1c, whose Global echo + # is 177x88 — the requested extent overruns the body on both axes. + var capped: Vector2i = StepCanvasTransport.cap_extent_to_body( + Vector2i(384, 216), "Region", Vector2i(177, 88) + ) + assert_that(capped).is_equal(Vector2i(177, 88)) + + +func test_cap_extent_to_body_is_a_no_op_when_already_inside_the_grid() -> void: + var capped: Vector2i = StepCanvasTransport.cap_extent_to_body( + Vector2i(100, 40), "Region", Vector2i(177, 88) + ) + assert_that(capped).is_equal(Vector2i(100, 40)) + + +## Shape-generic per the ticket: the guard compares SPACING, not rung name, +## so it caps ANY rung sharing Global's spacing, not just a hardcoded +## "Region" check. District's spacing (2048 m) differs from Global's +## (204,800 m), so it must NEVER be capped by the body-grid cell count — +## capping cell counts across mismatched spacings would be a unit error. +func test_cap_extent_to_body_leaves_finer_rungs_uncapped() -> void: + var capped: Vector2i = StepCanvasTransport.cap_extent_to_body( + Vector2i(3000, 3000), "District", Vector2i(177, 88) + ) + assert_that(capped).is_equal(Vector2i(3000, 3000)) + + +## Cold-start fallback (T-1189, StepCanvasViewer's own documented choice): +## before any Global response has arrived, `_global_body_extent` is ZERO — +## cap_extent_to_body() must leave the request UNCAPPED on a non-positive +## axis (server clamps independently) rather than clamping to zero cells. +func test_cap_extent_to_body_uncapped_when_global_echo_not_yet_available() -> void: + var capped: Vector2i = StepCanvasTransport.cap_extent_to_body( + Vector2i(384, 216), "Region", Vector2i.ZERO + ) + assert_that(capped).is_equal(Vector2i(384, 216)) + + +## A mixed case: one axis of the Global echo has arrived-and-is-real, the +## other is still ZERO (shouldn't happen in practice since both arrive +## together, but the function must handle each axis independently rather +## than assuming both-or-neither). +func test_cap_extent_to_body_caps_only_the_positive_echo_axis() -> void: + var capped: Vector2i = StepCanvasTransport.cap_extent_to_body( + Vector2i(384, 216), "Region", Vector2i(177, 0) + ) + assert_that(capped).is_equal(Vector2i(177, 216)) + + +# ============================================================================= +# T-1189/T-1192: shared letterbox/centering mechanism +# ============================================================================= + + +func test_center_offset_centers_a_smaller_canvas_in_a_larger_viewport() -> void: + var offset: Vector2 = StepCanvasTransport.center_offset( + Vector2(800.0, 400.0), Vector2(1920.0, 1080.0) + ) + assert_that(offset).is_equal(Vector2((1920.0 - 800.0) * 0.5, (1080.0 - 400.0) * 0.5)) + + +func test_center_offset_is_zero_when_canvas_exactly_fills_the_viewport() -> void: + var offset: Vector2 = StepCanvasTransport.center_offset( + Vector2(1920.0, 1080.0), Vector2(1920.0, 1080.0) + ) + assert_that(offset).is_equal(Vector2.ZERO) + + +func test_center_offset_goes_negative_when_the_canvas_overflows_the_viewport() -> void: + # A canvas bigger than the viewport on an axis crops rather than shrinks + # (matches every fixed rung's own "canvas can exceed the viewport" + # precedent) — a negative offset on that axis is the correct, honest + # result, not clamped to zero. + var offset: Vector2 = StepCanvasTransport.center_offset( + Vector2(2000.0, 400.0), Vector2(1920.0, 1080.0) + ) + assert_float(offset.x).is_less(0.0) + assert_float(offset.y).is_greater(0.0) + + +# ============================================================================= +# T-1192: Global integer-fit scale — D-255 texel-exactness +# ============================================================================= + + +func test_integer_fit_scale_picks_the_largest_multiple_that_fits_both_axes() -> void: + # GJ1c reference case: 177x88 texels at the 5x5 shallow display ratio = + # 885x440 px footprint, fit against a full 1920x1080 viewport. + var scale: int = StepCanvasTransport.integer_fit_scale( + Vector2(885.0, 440.0), Vector2(1920.0, 1080.0) + ) + assert_int(scale).is_equal(2) + + +func test_integer_fit_scale_is_bounded_by_the_tighter_axis() -> void: + # Wide-but-short viewport: x could fit 4x, y only fits 1x — the smaller + # wins (never overflow either axis). + var scale: int = StepCanvasTransport.integer_fit_scale( + Vector2(100.0, 100.0), Vector2(1000.0, 150.0) + ) + assert_int(scale).is_equal(1) + + +func test_integer_fit_scale_never_drops_below_one() -> void: + # A canvas larger than the viewport still gets scale 1 (draw at native + # size and let it exceed/crop), never a shrink below native. + var scale: int = StepCanvasTransport.integer_fit_scale( + Vector2(3000.0, 3000.0), Vector2(800.0, 600.0) + ) + assert_int(scale).is_equal(1) + + +func test_integer_fit_scale_handles_a_zero_canvas_axis_without_dividing_by_zero() -> void: + var scale: int = StepCanvasTransport.integer_fit_scale(Vector2.ZERO, Vector2(800.0, 600.0)) + assert_int(scale).is_equal(1) + + +func test_fit_scale_matches_the_integer_fit_when_coverage_is_high() -> void: + # The GJ1c full-viewport case: integer 2x covers well over the + # FIT_MIN_COVERAGE_RATIO bar, so fit_scale() must agree with + # integer_fit_scale() exactly (no fractional fallback). + var scale: float = StepCanvasTransport.fit_scale( + Vector2(885.0, 440.0), Vector2(1920.0, 1080.0) + ) + assert_float(scale).is_equal_approx(2.0, 0.001) + + +## The GJ1c reference case AFTER the legend column is reserved (T-1192): +## available area shrinks to 1628x1080, so the 2x integer candidate (1770 px +## wide) no longer fits — integer_fit_scale() drops to 1x, which only covers +## ~41% of the tighter available axis, well under FIT_MIN_COVERAGE_RATIO — +## fit_scale() must fall back to the non-integer uniform fit that fills the +## tighter (x) axis exactly, not settle for the sparse 1x frame. +func test_fit_scale_falls_back_to_fractional_fit_on_excessive_letterboxing() -> void: + var scale: float = StepCanvasTransport.fit_scale( + Vector2(885.0, 440.0), Vector2(1628.0, 1080.0) + ) + assert_float(scale).is_greater(1.0) + assert_float(scale).is_less(2.0) + # The fractional fit fills the tighter (x) axis exactly. + assert_float(885.0 * scale).is_equal_approx(1628.0, 0.01) + + +func test_fit_scale_handles_a_zero_canvas_axis_without_dividing_by_zero() -> void: + var scale: float = StepCanvasTransport.fit_scale(Vector2.ZERO, Vector2(800.0, 600.0)) + assert_float(scale).is_equal_approx(1.0, 0.001) + + +# ============================================================================= +# T-1192: shared legend-column reservation constant +# ============================================================================= + + +func test_legend_column_px_is_positive_and_matches_the_legend_panels_own_sizing() -> void: + # Pinned against step_canvas_legend.gd's own RESERVED_COLUMN_PX (260 + + # 16*2 = 292) — the two constants must never drift apart, since the + # "beside, never over" guarantee depends on both sides agreeing on the + # SAME reserved width. + assert_float(StepCanvasTransport.LEGEND_COLUMN_PX).is_equal_approx(292.0, 0.01) diff --git a/client/tests/test_step_canvas_viewer.gd b/client/tests/test_step_canvas_viewer.gd index 4cb6d9b40..b9be56e69 100644 --- a/client/tests/test_step_canvas_viewer.gd +++ b/client/tests/test_step_canvas_viewer.gd @@ -10,6 +10,12 @@ extends GdUnitTestSuite const StepCanvasTransport := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd") +## GJ1c's own region-grid shape from the T-1183 eyeball (177x88) — reused +## across the T-1189/T-1192 section below so every test is grounded in the +## actual regression captured in +## .cache/screenshots/t1183-eyeball-run2/02-region.png. +const GJ1C_GLOBAL_EXTENT := Vector2i(177, 88) + func test_enter_lands_on_the_global_opener() -> void: var v: StepCanvasViewer = auto_free(StepCanvasViewer.new()) @@ -385,3 +391,264 @@ func test_disk_sweep_timeout_handler_runs_background_sweep_for_the_current_body( # consistent, mirroring the enter()-sweep smoke test above. v._on_disk_sweep_timeout() assert_int(v.get_request().get_disk_cache().entry_count("T1183_sweep_smoke_test_body")).is_equal(0) + + +# ============================================================================= +# T-1189: extent cap wired end-to-end (viewer -> transport), plus the +# cache-key consistency the ticket calls out explicitly ("capping happens +# BEFORE the request is issued so keys stay consistent"). No live server — +# a Global canvas is landed via the SAME Tier-1 cache-hit path +# StepCanvasRequest's own tests use (get_cache().put() + request_now()'s +# synchronous cache-hit emit), so the full _on_canvas_ready wiring runs for +# real rather than being shortcut. +# ============================================================================= + + +## Land a Global canvas of the given size into the viewer's OWN cache (Tier +## 1), then fire the request that the real cache-hit path serves +## synchronously — same mechanism test_step_canvas_request.gd's own +## cache-hit tests use, now driven through the viewer so _on_canvas_ready() +## and _global_body_extent actually populate through the real signal wiring. +static func _land_global_canvas(v: StepCanvasViewer, width: int, height: int) -> void: + var req: Variant = v.get_request() + req.get_cache().put( + v.get_body_id(), + "Global", + Vector2i.ZERO, + Vector2i.ZERO, + TestStepCanvasViewer._synthetic_canvas(width, height) + ) + v._fire_request() # Global's own request — served from the cache hit just landed + + +func test_global_canvas_arrival_populates_the_body_extent_cap_source() -> void: + var v: StepCanvasViewer = auto_free(StepCanvasViewer.new()) + add_child(v) + v.enter({"body_id": "T1189_extent_letterbox_test_body", "body_radius_km": 6371.0}, {}) + TestStepCanvasViewer._land_global_canvas(v, GJ1C_GLOBAL_EXTENT.x, GJ1C_GLOBAL_EXTENT.y) + + assert_that(v._global_body_extent).is_equal(GJ1C_GLOBAL_EXTENT) + + +## The T-1183 eyeball regression itself: once the Global echo has landed, +## scrolling to Region and firing its request must produce a CAPPED extent +## — never the raw viewport-fit 384x216 that overran the body on both axes. +func test_region_request_extent_is_capped_to_the_landed_global_extent() -> void: + var v: StepCanvasViewer = auto_free(StepCanvasViewer.new()) + add_child(v) + v.size = Vector2(1920.0, 1080.0) # the T-1183 eyeball's own viewport + v.enter({"body_id": "T1189_extent_letterbox_test_body", "body_radius_km": 6371.0}, {}) + TestStepCanvasViewer._land_global_canvas(v, GJ1C_GLOBAL_EXTENT.x, GJ1C_GLOBAL_EXTENT.y) + + v._scroll_rung(1, Vector2(960.0, 540.0)) # descend to Region — fires the capped request + var extent: Vector2i = v._request_extent() + + assert_int(extent.x).override_failure_message( + "Region's requested extent must never exceed the body's own region-grid width" + ).is_less_equal(GJ1C_GLOBAL_EXTENT.x) + assert_int(extent.y).override_failure_message( + "Region's requested extent must never exceed the body's own region-grid height" + ).is_less_equal(GJ1C_GLOBAL_EXTENT.y) + assert_that(extent).is_equal(GJ1C_GLOBAL_EXTENT) # 1920x1080 viewport-fit exceeds 177x88 on both axes + + +## Cold-start fallback (documented on StepCanvasViewer._request_extent()): +## before ANY Global response has landed, `_global_body_extent` is still +## ZERO — the Region request must go out UNCAPPED (server clamps +## independently) rather than silently collapsing to a zero-cell request. +func test_region_request_extent_is_uncapped_before_the_global_echo_lands() -> void: + var v: StepCanvasViewer = auto_free(StepCanvasViewer.new()) + add_child(v) + v.size = Vector2(1920.0, 1080.0) + v.enter({"body_id": "T1189_extent_letterbox_test_body", "body_radius_km": 6371.0}, {}) + # No _land_global_canvas() call — simulates scrolling in before the + # FIRST (Global) request's response has arrived. + + v._scroll_rung(1, Vector2(960.0, 540.0)) + var extent: Vector2i = v._request_extent() + + var uncapped: Vector2i = StepCanvasTransport.viewport_fit_extent(v.size, "Region") + assert_that(extent).override_failure_message( + "before the Global echo lands, the Region request must be the ordinary" + + " uncapped viewport-fit extent, not silently zeroed" + ).is_equal(uncapped) + + +## Cache-key discipline (T-1182/T-1183, ticket's own explicit call-out): the +## cap must be applied BEFORE _fire_request() builds the request, so the +## extent that becomes part of the cache key is the SAME capped value that +## gets served — a request for "the same spot" must always resolve to the +## same key, capped or not, never a key built from one extent and served +## under another. +func test_capped_extent_matches_what_the_request_actually_sends() -> void: + var v: StepCanvasViewer = auto_free(StepCanvasViewer.new()) + add_child(v) + v.size = Vector2(1920.0, 1080.0) + v.enter({"body_id": "T1189_extent_letterbox_test_body", "body_radius_km": 6371.0}, {}) + TestStepCanvasViewer._land_global_canvas(v, GJ1C_GLOBAL_EXTENT.x, GJ1C_GLOBAL_EXTENT.y) + v._scroll_rung(1, Vector2(960.0, 540.0)) # Region — fires _fire_request() internally + + # _request_extent() is the SAME function _fire_request() calls to build + # the outbound request/cache key — calling it again here must be + # idempotent and match what was actually requested (no separate, + # divergent cap path). + var extent_now: Vector2i = v._request_extent() + assert_that(extent_now).is_equal(GJ1C_GLOBAL_EXTENT) + + # The Tier-1 cache key StepCanvasCache builds from this SAME extent must + # be a real, findable key once a response for it lands — proving the + # capped extent (not the raw viewport-fit one) is what keys the cache. + var req: Variant = v.get_request() + var center: Vector2i = StepCanvasTransport.snap_to_gridunit(v._world_center, "Region") + var canvas := TestStepCanvasViewer._synthetic_canvas(GJ1C_GLOBAL_EXTENT.x, GJ1C_GLOBAL_EXTENT.y) + req.get_cache().put("T1189_extent_letterbox_test_body", "Region", center, extent_now, canvas) + assert_bool( + req.get_cache().has("T1189_extent_letterbox_test_body", "Region", center, GJ1C_GLOBAL_EXTENT, 0) + ).override_failure_message( + "a cache entry stored under the CAPPED extent must be reachable" + + " under that same capped extent — key consistency" + ).is_true() + + +## Shape-generic guard: District's spacing differs from Global's, so its +## request extent must be completely unaffected by a landed Global canvas — +## proving the cap is spacing-keyed, not applied indiscriminately to every +## rung once a Global extent is known. +func test_district_request_extent_is_never_capped_by_the_global_extent() -> void: + var v: StepCanvasViewer = auto_free(StepCanvasViewer.new()) + add_child(v) + v.size = Vector2(1920.0, 1080.0) + v.enter({"body_id": "T1189_extent_letterbox_test_body", "body_radius_km": 6371.0}, {}) + TestStepCanvasViewer._land_global_canvas(v, GJ1C_GLOBAL_EXTENT.x, GJ1C_GLOBAL_EXTENT.y) + + v._scroll_rung(1, Vector2(960.0, 540.0)) # Region + v._scroll_rung(1, Vector2(960.0, 540.0)) # District + var extent: Vector2i = v._request_extent() + + var uncapped: Vector2i = StepCanvasTransport.viewport_fit_extent(v.size, "District") + assert_that(extent).is_equal(uncapped) + + +# ============================================================================= +# T-1189/T-1192: shared letterbox mechanism — centering + Global fit scale. +# ============================================================================= + + +## T-1192's own headline defect: the Global canvas must no longer draw +## top-left-anchored at Vector2.ZERO — once a canvas lands, the viewer must +## have computed a non-zero centering offset (unless the canvas happens to +## exactly fill the viewport, not the case here). +func test_global_canvas_arrival_centers_the_view_not_top_left() -> void: + var v: StepCanvasViewer = auto_free(StepCanvasViewer.new()) + add_child(v) + v.size = Vector2(1920.0, 1080.0) + v.enter({"body_id": "T1189_extent_letterbox_test_body", "body_radius_km": 6371.0}, {}) + TestStepCanvasViewer._land_global_canvas(v, GJ1C_GLOBAL_EXTENT.x, GJ1C_GLOBAL_EXTENT.y) + + assert_that(v._view_offset).override_failure_message( + "a landed Global canvas smaller than the viewport must be CENTERED," + + " never left at the raw top-left Vector2.ZERO anchor" + ).is_not_equal(Vector2.ZERO) + + +## D-255 texel-exactness (T-1192): at a large (4K-class) viewport, GJ1c's +## 885x440 raw footprint (177x88 texels x 5x5 shallow display ratio) clears +## the coverage bar even AFTER the legend column is reserved, so the +## INTEGER fit wins outright — verified end-to-end through the real viewer +## wiring, not just the pure transport function this mirrors. (The T-1183 +## reference 1920x1080 viewport is deliberately NOT used here — at that +## size the legend-column reservation starves the integer candidate below +## the coverage bar and the fractional escape hatch fires instead, covered +## separately by test_global_canvas_uses_fractional_fit_when_legend_column_ +## starves_the_integer_fit() below.) +func test_global_canvas_scale_is_an_integer_multiple_of_the_raw_footprint() -> void: + var v: StepCanvasViewer = auto_free(StepCanvasViewer.new()) + add_child(v) + v.size = Vector2(3840.0, 2160.0) + v.enter({"body_id": "T1189_extent_letterbox_test_body", "body_radius_km": 6371.0}, {}) + TestStepCanvasViewer._land_global_canvas(v, GJ1C_GLOBAL_EXTENT.x, GJ1C_GLOBAL_EXTENT.y) + + assert_that(v._canvas.scale).is_equal(Vector2(v._canvas_scale, v._canvas_scale)) + assert_float(v._canvas_scale).override_failure_message( + "the Global fit scale must be a whole number of texture pixels" + + " when it clears the coverage bar (D-255 texel-exactness)" + ).is_equal_approx(roundf(v._canvas_scale), 0.001) + + +## Fixed rungs must NEVER receive the Global fit multiplier — `_canvas.scale` +## stays 1.0 once the player has descended past Global, even though a +## Global canvas was landed earlier in the same session. +func test_fixed_rung_canvas_scale_stays_one_after_descending_from_global() -> void: + var v: StepCanvasViewer = auto_free(StepCanvasViewer.new()) + add_child(v) + v.size = Vector2(1920.0, 1080.0) + v.enter({"body_id": "T1189_extent_letterbox_test_body", "body_radius_km": 6371.0}, {}) + TestStepCanvasViewer._land_global_canvas(v, GJ1C_GLOBAL_EXTENT.x, GJ1C_GLOBAL_EXTENT.y) + v._scroll_rung(1, Vector2(960.0, 540.0)) # Region + v._terrain_layer.rebuild_from_canvas( + TestStepCanvasViewer._synthetic_canvas(200, 100), v.get_held_rung(), "" + ) + v._recompute_canvas_transform() + + assert_float(v._canvas_scale).is_equal_approx(1.0, 0.001) + assert_that(v._canvas.scale).is_equal(Vector2.ONE) + + +## Legend non-overlap (T-1192: "lay the legend out beside the canvas... never +## over it"): the legend panel sits at a fixed left-column position +## (PANEL_MARGIN, ...) with a known width — once a Global canvas is landed +## and centered, its drawn rect's LEFT edge must be at or past the legend's +## own right edge, never underneath it. +func test_global_canvas_left_edge_never_overlaps_the_legend_column() -> void: + var v: StepCanvasViewer = auto_free(StepCanvasViewer.new()) + add_child(v) + v.size = Vector2(1920.0, 1080.0) + v.enter({"body_id": "T1189_extent_letterbox_test_body", "body_radius_km": 6371.0}, {}) + TestStepCanvasViewer._land_global_canvas(v, GJ1C_GLOBAL_EXTENT.x, GJ1C_GLOBAL_EXTENT.y) + + var canvas_left_edge: float = v._view_offset.x + assert_float(canvas_left_edge).override_failure_message( + "the Global canvas's drawn left edge must be at or past the reserved" + + " legend column — the legend must never be covered by the map" + ).is_greater_equal(StepCanvasTransport.LEGEND_COLUMN_PX - 0.01) + + +## Resize must re-fit/re-center a HELD canvas, not just a freshly-arriving +## one — _notification(NOTIFICATION_RESIZED) wires _recompute_canvas_transform() +## for exactly this case. +func test_resize_recenters_an_already_held_global_canvas() -> void: + var v: StepCanvasViewer = auto_free(StepCanvasViewer.new()) + add_child(v) + v.size = Vector2(1920.0, 1080.0) + v.enter({"body_id": "T1189_extent_letterbox_test_body", "body_radius_km": 6371.0}, {}) + TestStepCanvasViewer._land_global_canvas(v, GJ1C_GLOBAL_EXTENT.x, GJ1C_GLOBAL_EXTENT.y) + var offset_before: Vector2 = v._view_offset + + v.size = Vector2(1280.0, 720.0) + v._notification(Control.NOTIFICATION_RESIZED) + + assert_that(v._view_offset).override_failure_message( + "a resize must re-center the held canvas for the NEW viewport size" + ).is_not_equal(offset_before) + + +## Small-canvas fallback (D-255's own escape hatch): once the legend column +## eats enough of the available width that the integer fit falls under +## FIT_MIN_COVERAGE_RATIO, the viewer must fall back to the fractional fit +## rather than settling for a sparse integer frame — end-to-end through the +## real viewer, mirroring test_fit_scale_falls_back_to_fractional_fit_on_ +## excessive_letterboxing in test_step_canvas_transport.gd. +func test_global_canvas_uses_fractional_fit_when_legend_column_starves_the_integer_fit() -> void: + var v: StepCanvasViewer = auto_free(StepCanvasViewer.new()) + add_child(v) + v.size = Vector2(1920.0, 1080.0) + v.enter({"body_id": "T1189_extent_letterbox_test_body", "body_radius_km": 6371.0}, {}) + TestStepCanvasViewer._land_global_canvas(v, GJ1C_GLOBAL_EXTENT.x, GJ1C_GLOBAL_EXTENT.y) + + # GJ1c at 1920x1080 with the legend column reserved: 2x no longer fits + # (1770 > 1628 available), 1x covers only ~41% of the tighter axis — well + # under the 75% coverage bar, so a non-integer fit must have been chosen. + assert_float(v._canvas_scale).override_failure_message( + "the reserved legend column must starve the 2x integer candidate at" + + " this reference viewport, forcing the fractional fit" + ).is_greater(1.0) diff --git a/client/ui/implant/apps/atlas/step_canvas/step_canvas_annotation_layer.gd b/client/ui/implant/apps/atlas/step_canvas/step_canvas_annotation_layer.gd index 8a936c3ee..780d70f0d 100644 --- a/client/ui/implant/apps/atlas/step_canvas/step_canvas_annotation_layer.gd +++ b/client/ui/implant/apps/atlas/step_canvas/step_canvas_annotation_layer.gd @@ -6,11 +6,19 @@ extends Node2D ## positions world->screen transformed per-frame via a plain linear map ## (StepCanvasTransport.world_m_to_canvas_local()). This is the layer that ## makes `_zs()`/`_zs_stroke()`/`_zs_ring_radius()` structurally -## unnecessary: because this Node2D is NEVER scaled (no `.scale` write -## anywhere in this file, unlike the retired `_canvas.scale` model), a -## constant like COURSE_WIDTH_PX below already IS the on-screen width with -## no compensating division — "by construction, not by discipline" per -## Stig's round-1 design doc. +## unnecessary: this Node2D itself is NEVER scaled (no `.scale` write +## anywhere in THIS file), so a constant like COURSE_WIDTH_PX below already +## IS the width in ITS OWN local space with no compensating division — "by +## construction, not by discipline" per Stig's round-1 design doc. +## +## T-1192 note: the OWNING `_canvas` Node2D (StepCanvasViewer) carries the +## Global integer-fit multiplier as ITS OWN `.scale` (1.0 for every other +## rung) — this layer, as a child, inherits it like the terrain layer does, +## so a marker drawn here lands on the correct enlarged-canvas position AND +## reads visually bigger in step with the enlarged terrain pixels (the +## correct "2x zoom, 2x dot" map read), not a mismatch. Set once per canvas +## adoption by the owner (never per-frame here), so it stays the single +## sanctioned display-time scale, not a second one to reconcile against. ## ## Data source: the SAME decoded StepCanvasResponse `canvas` Dictionary the ## terrain layer reads (`courses`/`cliffs`/`settlement_id` — sparse diff --git a/client/ui/implant/apps/atlas/step_canvas/step_canvas_legend.gd b/client/ui/implant/apps/atlas/step_canvas/step_canvas_legend.gd index 339199aa0..66ad4e0ba 100644 --- a/client/ui/implant/apps/atlas/step_canvas/step_canvas_legend.gd +++ b/client/ui/implant/apps/atlas/step_canvas/step_canvas_legend.gd @@ -15,6 +15,12 @@ extends ImplantPanel const PANEL_MARGIN: float = 16.0 const LEGEND_PANEL_WIDTH: float = 260.0 +## T-1192: must stay derivable from PANEL_MARGIN/LEGEND_PANEL_WIDTH above — +## StepCanvasTransport.LEGEND_COLUMN_PX mirrors this exact sum so the +## Global-rung fit-scale computation reserves precisely this much column, +## never more or less than what the legend actually occupies. +const RESERVED_COLUMN_PX: float = LEGEND_PANEL_WIDTH + PANEL_MARGIN * 2.0 + const AtlasOverlayColors := preload("res://ui/implant/apps/atlas/atlas_overlay_colors.gd") const StepCanvasTransport := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd") diff --git a/client/ui/implant/apps/atlas/step_canvas/step_canvas_terrain_layer.gd b/client/ui/implant/apps/atlas/step_canvas/step_canvas_terrain_layer.gd index 937a91091..4014c1910 100644 --- a/client/ui/implant/apps/atlas/step_canvas/step_canvas_terrain_layer.gd +++ b/client/ui/implant/apps/atlas/step_canvas/step_canvas_terrain_layer.gd @@ -36,6 +36,15 @@ extends Node2D ## already present in the texture — this is presentation resampling of a ## closed input set, not invention of a new one, matching D-255(e)'s own ## "texture-to-viewport resize" exemption. +## +## **T-1192 outer fit scale:** the owning `_canvas` Node2D +## (StepCanvasViewer._recompute_canvas_transform()) may additionally carry +## its OWN `.scale` — the Global-rung integer-fit multiplier, always 1.0 for +## every fixed rung — applied on top of this node's own texel-exact +## `_footprint_px` draw. That is a SECOND texture-to-viewport resize, same +## D-255(e) exemption, kept as a parent-transform multiply rather than a +## second internal scale field so this node's own footprint math never has +## to know it exists. const StepCanvasColorize := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_colorize.gd") const StepCanvasTransport := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd") diff --git a/client/ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd b/client/ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd index 1e8b21403..ae9b44aaf 100644 --- a/client/ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd +++ b/client/ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd @@ -85,6 +85,27 @@ const DISPLAY_RATIO_BY_RUNG: Dictionary = { ## server-side, per step_canvas_protocol.gd's own doc). const FIXED_CANVAS_MAX_AXIS: int = 3_840 +## T-1192: the on-screen column width reserved for the Atlas legend panel — +## mirrors step_canvas_legend.gd's own LEGEND_PANEL_WIDTH + PANEL_MARGIN*2 +## (panel width plus a margin on each side). Shared here (not duplicated as +## a raw literal in step_canvas_viewer.gd) so the Global integer-fit +## computation and the legend's own sizing can never silently drift apart — +## "legend beside, never over" only holds if both sides agree on the SAME +## reserved width. +const LEGEND_COLUMN_PX: float = 260.0 + 16.0 * 2.0 + +## Below this fraction of the viewport's SMALLER axis covered, an integer +## fit "leaves excessive letterboxing" (D-255's own phrase) and the +## non-integer escape hatch fires instead. 0.75: the integer candidate must +## already cover at least three-quarters of the tighter axis to win outright +## — anything looser and the NEXT integer step down/up is visibly a better +## use of the frame. Tuned against the GJ1c reference case at 1920x1080 +## (integer 1x candidate covers ~41% of the tighter available axis after the +## legend-column reservation — well under this bar, so the fractional fit +## wins there, matching D-255's own "if that leaves excessive letterboxing +## on small canvases" framing). See fit_scale() below for the decision. +const FIT_MIN_COVERAGE_RATIO: float = 0.75 + ## The rung name at ladder index `i`, clamped to the legal [0, 5] range — ## the one place RUNG_LADDER is indexed into, so a caller passing an @@ -188,6 +209,62 @@ static func canvas_footprint_px(rung: String, extent_cells: Vector2i) -> Vector2 return Vector2(extent_cells) * ratio +## Shared letterbox/centering mechanism (T-1189 + T-1192, one geometry both +## the Region-rung body-cap remainder AND the Global-rung raw-scale-fill +## defect resolve through — the tickets interact at exactly this seam, +## Jeroen's own instruction: "build ONE letterbox/centering mechanism both +## use, not two"). Given a canvas footprint (px, already at whatever scale +## the caller wants drawn) and the viewport to center it in, returns the +## `Vector2` top-left offset that centers it — the remainder splits evenly +## on both sides (a true letterbox, not edge-anchored). A canvas at or larger +## than the viewport on an axis gets a zero/negative offset on that axis (no +## letterbox needed there — it already fills or overflows, matching ordinary +## pan-and-crop behavior on that axis rather than shrinking the canvas). +static func center_offset(footprint_px: Vector2, viewport_px: Vector2) -> Vector2: + return (viewport_px - footprint_px) * 0.5 + + +## D-255 texel-exactness for the Global opener (T-1192): the largest INTEGER +## scale multiple of `canvas_px` that still fits inside `viewport_px` on +## BOTH axes, floored at 1 (never downscale below native size — a canvas +## larger than the viewport draws at 1x and simply doesn't fit, matching +## every fixed rung's own "canvas can exceed the viewport" precedent rather +## than introducing a NEW sub-1x shrink path here). Callers needing the +## "non-integer fit acceptable on small canvases" escape hatch (D-255's own +## exception, nearest-neighbor only) compute their own fractional scale and +## skip this function — it only ever returns integers by design, so it +## can't accidentally hand back a blurry non-integer multiple. +static func integer_fit_scale(canvas_px: Vector2, viewport_px: Vector2) -> int: + if canvas_px.x <= 0.0 or canvas_px.y <= 0.0: + return 1 + var max_x: int = int(floor(viewport_px.x / canvas_px.x)) + var max_y: int = int(floor(viewport_px.y / canvas_px.y)) + return maxi(1, mini(max_x, max_y)) + + +## The actual scale to draw the Global canvas at (T-1192): the integer fit +## if it covers at least FIT_MIN_COVERAGE_RATIO of the viewport's tighter +## axis, otherwise a UNIFORM fractional fit (same scale both axes, so the +## canvas is never stretched non-uniformly) that fills the tighter axis +## exactly. The fractional branch is legal ONLY under D-255's own +## nearest-neighbor condition — StepCanvasTerrainLayer._filter_for_rung() +## already forces NEAREST for every orbital rung (Global included) +## unconditionally, so this function never has to check or set the filter +## itself; it only chooses the number. +static func fit_scale(canvas_px: Vector2, viewport_px: Vector2) -> float: + if canvas_px.x <= 0.0 or canvas_px.y <= 0.0: + return 1.0 + var int_scale: int = integer_fit_scale(canvas_px, viewport_px) + var covered: Vector2 = canvas_px * float(int_scale) + var coverage_x: float = covered.x / maxf(viewport_px.x, 0.0001) + var coverage_y: float = covered.y / maxf(viewport_px.y, 0.0001) + if minf(coverage_x, coverage_y) >= FIT_MIN_COVERAGE_RATIO: + return float(int_scale) + var frac_x: float = viewport_px.x / canvas_px.x + var frac_y: float = viewport_px.y / canvas_px.y + return maxf(minf(frac_x, frac_y), 0.0001) + + ## Fit a fixed-rung request's extent (in gridunits) to the viewport, capped ## at FIXED_CANVAS_MAX_AXIS per axis (D-255(a)/(b)'s own budget) and at the ## rung's own display ratio — this is the CLIENT's half of "viewport-sized @@ -206,6 +283,39 @@ static func viewport_fit_extent(viewport_px: Vector2, rung: String) -> Vector2i: return Vector2i(w, h) +## Cap a fixed-rung request extent to the body's own region grid (T-1189): +## `derive_orbital_at_metres` wraps longitude/clamps latitude server-side, so +## a viewport-fit extent wider or taller than the body's grid makes a rung +## that SHARES Global's gridunit spacing (currently Region only, matched by +## SPACING not by name — see below) request more of the body than exists, +## producing the visible "continent repeats sideways / rows smear past the +## pole" defect (T-1183 eyeball, GJ1c). `global_extent` is the Global rung's +## own echoed canvas size — `StepCanvasRung::global_cell_counts()` server-side +## IS the body's region grid (cols = regions_per_equator(R), rows = cols/2), +## so it is exactly the cap: no unit conversion needed since Region's +## gridunit spacing equals Global's (both RUNG_SPACING_M[RUNG_GLOBAL]). +## +## Shape-generic per the ticket's own instruction: this caps ANY rung whose +## spacing matches Global's (a spacing comparison, not `rung == +## RUNG_REGION`), so a future rung sharing that spacing is covered for free. +## Every other rung's spacing is strictly finer than Global's, so its +## viewport-fit canvas physically cannot cover more than one region's worth +## of ground per axis at any real viewport size — the guard is a no-op for +## them by construction, not by an explicit exclusion list. A non-positive +## `global_extent` axis (the Global echo genuinely hasn't arrived yet — see +## StepCanvasViewer's own cold-start fallback doc) leaves that axis +## uncapped, matching the ticket's "request uncapped and let the server +## clamp" fallback choice. +static func cap_extent_to_body( + extent: Vector2i, rung: String, global_extent: Vector2i +) -> Vector2i: + if not is_equal_approx(spacing_for_rung(rung), spacing_for_rung(RUNG_GLOBAL)): + return extent + var w: int = extent.x if global_extent.x <= 0 else mini(extent.x, global_extent.x) + var h: int = extent.y if global_extent.y <= 0 else mini(extent.y, global_extent.y) + return Vector2i(w, h) + + ## WASD + arrow keys, read via Input.is_key_pressed() on the PHYSICAL keycode ## (not an InputMap action) — same rationale the retired ## atlas_window_geometry.gd's own held_pan_direction() documented: this diff --git a/client/ui/implant/apps/atlas/step_canvas/step_canvas_viewer.gd b/client/ui/implant/apps/atlas/step_canvas/step_canvas_viewer.gd index 754f3b7e4..13f051e61 100644 --- a/client/ui/implant/apps/atlas/step_canvas/step_canvas_viewer.gd +++ b/client/ui/implant/apps/atlas/step_canvas/step_canvas_viewer.gd @@ -16,8 +16,16 @@ extends Control ## - StepCanvasAnnotationLayer (Node2D child of `_canvas`, drawn AFTER the ## terrain layer): unscaled screen-space courses/settlement markers. ## Both layers live under ONE `_canvas` Node2D whose `.position` is the pan -## offset ONLY — there is no `.scale` write anywhere in this file (the whole -## point of the retirement: "there is no more zoom-scaled canvas"). +## offset PLUS the T-1189/T-1192 letterbox centering offset, and whose +## `.scale` is the T-1192 Global integer-fit multiplier (1.0 for every other +## rung). This is NOT the retired `_canvas.scale` zoom model: that scale +## changed continuously, per input frame, and had to be compensated for at +## every draw call (`_zs`/`_zs_stroke`/`_zs_ring_radius`). This scale is set +## ONCE per canvas adoption (_on_canvas_ready()/resize), identically for both +## child layers via ordinary Node2D transform inheritance, and is exactly +## D-255(e)'s sanctioned "texture-to-viewport resize" — a fixed, closed-set +## display-time scale, not a per-frame invented one. See +## _recompute_canvas_transform() for the one place both fields are set. ## ## Stepped transport (D-255(a)): a discrete rung INDEX (0-5, ## StepCanvasTransport.RUNG_LADDER), never a float zoom. Mouse wheel scrolls @@ -93,11 +101,32 @@ var _world_center: Vector2 = Vector2.ZERO var _held_rung: String = StepCanvasTransport.RUNG_GLOBAL var _held_extent: Vector2i = Vector2i.ZERO -# ── Pan state (position only — NO scale/zoom field anywhere) ───────────── +## T-1189: the body's own region grid (cols, rows), i.e. the LAST ADOPTED +## Global canvas's own width/height — set only in _on_canvas_ready() when +## `_held_rung == RUNG_GLOBAL` (see there). This is the cap source for +## StepCanvasTransport.cap_extent_to_body(): distinct from `_held_extent` +## (which is overwritten by whatever rung is CURRENTLY held, so it stops +## meaning "the body's grid" the moment the player descends past Global). +## ZERO until the first Global response actually arrives — see +## _request_extent()'s own cold-start fallback doc for what happens then. +var _global_body_extent: Vector2i = Vector2i.ZERO + +# ── Pan state ─────────────────────────────────────────────────────────── var _view_offset: Vector2 = Vector2.ZERO var _last_mouse_pos: Vector2 = Vector2(-1.0, -1.0) var _app_has_focus: bool = true +## T-1192 Global fit scale — the ONE display-time scale this viewer ever +## writes (see the class doc). Always 1.0 for a fixed rung (its canvas is +## already texel-exact at its own display ratio; T-1189's letterbox only +## adds centered margin, never an extra scale). For Global, an INTEGER +## multiple when that covers most of the viewport (D-255 texel-exactness), +## else a non-integer fractional fit on small canvases — see +## StepCanvasTransport.fit_scale()'s own doc for the coverage rule; NEAREST +## filtering (required for the non-integer case) is already unconditional +## for every orbital rung via StepCanvasTerrainLayer._filter_for_rung(). +var _canvas_scale: float = 1.0 + # ── Overlay visibility ───────────────────────────────────────────────────── var _overlay_visibility: Dictionary = {} @@ -173,6 +202,7 @@ func enter(body: Dictionary, system: Dictionary) -> void: _world_center = Vector2.ZERO _held_rung = StepCanvasTransport.RUNG_GLOBAL _held_extent = Vector2i.ZERO + _global_body_extent = Vector2i.ZERO # a new body has its own region grid _view_offset = Vector2.ZERO _request.reset() _annotation_layer.clear_frame() @@ -255,11 +285,28 @@ func _fire_request() -> void: _request.request_now(get_body_id(), _held_rung, center, extent) +## T-1189: cap the viewport-fit extent to the body's own region grid BEFORE +## the request is issued, so the cache key (which includes extent) is +## consistent with what actually gets served — never cap the echo +## afterward. Cold-start fallback (documented on `_global_body_extent` +## above): if no Global response has arrived yet for this body, +## `_global_body_extent` is still ZERO and cap_extent_to_body() leaves the +## request uncapped on the affected axis — chosen over "fetch Global first" +## because the ladder is already strictly sequential (enter() always lands +## on Global before any deeper rung is reachable, RegionalScreen/D-255(a)), +## so the ONLY way to reach a Region request before the Global echo lands is +## scrolling in fast while the FIRST request (already in flight) hasn't +## returned — a real but narrow window, not the common case, and the +## viewport-fit extent is already server-clamped independently (D-255(a)/(b): +## "never trust the echo to equal the request"), so this fallback is honest, +## not silently wrong — it just misses the ADDITIONAL client-side +## sideways-repeat guard for that one request. func _request_extent() -> Vector2i: var viewport: Vector2 = get_rect().size if viewport == Vector2.ZERO: viewport = Vector2(1280.0, 720.0) - return StepCanvasTransport.viewport_fit_extent(viewport, _held_rung) + var fit: Vector2i = StepCanvasTransport.viewport_fit_extent(viewport, _held_rung) + return StepCanvasTransport.cap_extent_to_body(fit, _held_rung, _global_body_extent) func _on_step_canvas_received(response: Dictionary) -> void: @@ -272,14 +319,64 @@ func _on_step_canvas_received(response: Dictionary) -> void: ## hold-fetch-swap contract — Stig round-1 §2). func _on_canvas_ready(canvas: Dictionary) -> void: _held_extent = _request.get_held_extent() + if _held_rung == StepCanvasTransport.RUNG_GLOBAL: + _global_body_extent = _held_extent _rebuild_terrain_texture(canvas) _annotation_layer.set_frame(canvas, _world_center, _held_rung, _held_extent) + _recompute_canvas_transform() _refresh_screen_header() if _legend_panel: _legend_panel.refresh() queue_redraw() +## T-1189/T-1192 shared letterbox mechanism: recompute `_canvas.scale` (the +## Global integer-fit multiplier, 1.0 elsewhere) and re-center `_view_offset` +## on the FRESHLY adopted canvas's own footprint. Called once per canvas +## adoption (_on_canvas_ready(), the only place a new texture size can +## appear) and on viewport resize (the fit target itself changed) — never +## per-frame, matching the class doc's "set once per canvas adoption" scale +## discipline. Deliberately overwrites any in-progress pan: a fresh/resized +## canvas re-centers, same as the retired top-left reset it replaces (every +## `_view_offset = Vector2.ZERO` reset site below). +## +## Global reserves StepCanvasTransport.LEGEND_COLUMN_PX on the left before +## fitting/centering (T-1192: "lay the legend out beside the canvas... never +## over it") — the legend is always-visible chrome at every rung, but only +## Global's canvas is small enough at typical viewports for the naive +## full-viewport center to land it under the legend's fixed corner position +## (a fixed rung's viewport-fit canvas already fills the available area by +## construction, so its letterbox remainder is comparatively too small to +## reach the legend column in practice). Fixed rungs center in the FULL +## viewport, unchanged. +func _recompute_canvas_transform() -> void: + if _terrain_layer == null: + return # NOTIFICATION_RESIZED can fire mid-_ready(), before children exist + var raw_footprint: Vector2 = _terrain_layer.get_footprint_px() + if raw_footprint == Vector2.ZERO: + return + _canvas_scale = _letterbox_scale_for(raw_footprint) + _canvas.scale = Vector2(_canvas_scale, _canvas_scale) + _view_offset = _centered_view_offset() + _apply_transform() + + +## The fit scale for a given raw (unscaled) footprint at the CURRENTLY held +## rung — Global's fit_scale() reserving the legend column, 1.0 for every +## fixed rung. Split out from _recompute_canvas_transform() so +## _centered_view_offset() (the drift-check baseline) can share the exact +## same scale decision without re-deriving it, keeping the two callers +## structurally unable to disagree. +func _letterbox_scale_for(raw_footprint: Vector2) -> float: + if _held_rung != StepCanvasTransport.RUNG_GLOBAL: + return 1.0 + var viewport: Vector2 = get_rect().size + var available: Vector2 = Vector2( + maxf(viewport.x - StepCanvasTransport.LEGEND_COLUMN_PX, 1.0), viewport.y + ) + return StepCanvasTransport.fit_scale(raw_footprint, available) + + func _rebuild_terrain_texture(canvas: Variant = null) -> void: var c: Variant = canvas if canvas != null else _terrain_layer._canvas_ref if not c is Dictionary: @@ -339,10 +436,37 @@ func _scroll_rung(direction: int, cursor_local: Vector2) -> void: ## True while at rung 0 but the view has drifted from the canonical -## un-panned Global frame (world_center/view_offset both ZERO) — the -## condition _scroll_rung()'s hard-reset gesture fires on. +## un-panned Global frame — `world_center` ZERO (unchanged) AND +## `view_offset` at its own CENTERED baseline (T-1189/T-1192: no longer the +## literal ZERO top-left corner, now `_centered_view_offset()`'s letterbox +## position) — the condition _scroll_rung()'s hard-reset gesture fires on. func _is_global_view_drifted() -> bool: - return _world_center != Vector2.ZERO or _view_offset != Vector2.ZERO + return _world_center != Vector2.ZERO or _view_offset != _centered_view_offset() + + +## The letterbox-centered `_view_offset` for the CURRENTLY held canvas +## footprint/scale — the "no pan drift" baseline _is_global_view_drifted() +## compares against, what a hard reset restores, and what +## _recompute_canvas_transform() itself applies on a fresh canvas arrival. +## T-1192: Global centers within the viewport MINUS the reserved legend +## column, then shifts right by that column so the canvas never sits under +## the legend's own fixed corner position ("beside, never over"). Falls back +## to ZERO before any canvas has arrived (matches +## `_terrain_layer.get_footprint_px()` returning ZERO pre-arrival — there is +## nothing to center yet). +func _centered_view_offset() -> Vector2: + if _terrain_layer == null: + return Vector2.ZERO # mirrors _recompute_canvas_transform()'s own pre-_ready() guard + var raw_footprint: Vector2 = _terrain_layer.get_footprint_px() + if raw_footprint == Vector2.ZERO: + return Vector2.ZERO + var is_global: bool = _held_rung == StepCanvasTransport.RUNG_GLOBAL + var reserved_left: float = StepCanvasTransport.LEGEND_COLUMN_PX if is_global else 0.0 + var viewport: Vector2 = get_rect().size + var available: Vector2 = Vector2(maxf(viewport.x - reserved_left, 1.0), viewport.y) + var scale: float = _letterbox_scale_for(raw_footprint) + var scaled_footprint: Vector2 = raw_footprint * scale + return StepCanvasTransport.center_offset(scaled_footprint, available) + Vector2(reserved_left, 0.0) ## Hard reset to the Global opener (D-255(a): "a hard full-zoom-out reset to @@ -353,7 +477,7 @@ func _reset_to_global() -> void: _rung_index = 0 _held_rung = StepCanvasTransport.RUNG_GLOBAL _world_center = Vector2.ZERO - _view_offset = Vector2.ZERO + _view_offset = _centered_view_offset() _fire_request() _refresh_screen_header() queue_redraw() @@ -485,6 +609,7 @@ func _notification(what: int) -> void: _position_overlay_bar() if _legend_panel: _legend_panel.reposition() + _recompute_canvas_transform() elif what == NOTIFICATION_APPLICATION_FOCUS_OUT: _app_has_focus = false elif what == NOTIFICATION_APPLICATION_FOCUS_IN: From 915a6790f0774d5d806b5ed4fdd20c331f36f249 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 25 Jul 2026 12:52:28 +0200 Subject: [PATCH 2/4] chore(meta): update changelog Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08bfd0037..8f14f0b30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +### Fixed +- **The Atlas map now fills the screen properly** (T-1189, T-1192) — the whole-body Global view fit-scales and centers in the viewport instead of sitting small in the top-left corner, and the legend sits beside the map instead of on top of it. The Region zoom step no longer shows the planet repeating side-by-side or smearing into stripes past the poles — the map view is capped at the body's actual size and letterboxed, so what you see is the planet once, correctly framed + ### Added - **The Atlas remembers across restarts** (D-255, T-1183) — every map view you visit is now kept on disk as well as in memory: relaunch the game, reopen a body, and previously-visited zoom steps draw instantly from the local cache with no server round-trip — only genuinely new ground fetches. Storage stays tidy on its own: fine-grained views of a body you haven't visited in ~two weeks quietly reclaim their space, each body's deep-zoom footprint is capped, and the whole-body overview of every visited body is kept forever. The cache is update-safe by construction — entries from an older game version are silently refetched, never misread - **The stepped Atlas map** (D-255, T-1182) — the body map is rebuilt on the step-canvas pipe: opening a body lands on the whole-body Global view, and the mouse wheel walks six discrete zoom steps (Global → Region → District → Quarter → Block → 64 m Chunk), cursor-anchored, with edge-scroll and WASD panning and a hard full-zoom-out snap back to Global. Terrain draws texel-exact from the server's data canvas (no client-side scaling — the whole family of zoom-compensation bugs is structurally gone, ~9,400 lines lighter), while river courses and settlement markers stay crisp at constant pixel sizes on a separate annotation layer. While a new step loads, the previous view holds with a pending wash — no blank frames. Within one session, revisited views come from an in-memory cache; the disk cache that survives restarts is the next ticket (T-1183) From 374b19559476793ebd0ad10ff9926d9c355c01c0 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 25 Jul 2026 13:14:12 +0200 Subject: [PATCH 3/4] =?UTF-8?q?fix(ui):=20PR=20#205=20review=20round=20?= =?UTF-8?q?=E2=80=94=20integer=20px-per-gridunit=20fit=20replaces=20fracti?= =?UTF-8?q?onal=20(T-1189,=20T-1192)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fractional fit branch is deleted, resolving both review findings at the root: tyre showed its comments cited D-255 for an exception the record does not contain (the language came from the lead's ticket text, not governance), and hoshe showed it returned sub-1x for a canvas exceeding the viewport on one axis. Replacement: fit_scale_ratio() chooses the largest integer pixels-per-gridunit R fitting both legend-reserved axes, floored at 1 (over-viewport draws native and crops like every fixed rung) — the fine-grained integer lattice (GJ1c 1080p -> 9px/gu = 1593x792, ~98% width; 4K -> 20) that makes the fractional hatch unnecessary. center_offset() floors to whole pixels (half-pixel centering would blur the texel grid). Doc comments cite the real sanction (D-255 amendment 2026-07-25, this branch). Legend column constant is now canonical in transport, read directly by the legend (was an independently-typed literal); its test asserts real geometry. New regression test proves _global_body_extent clears on body switch and never caps another body's requests. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 +- client/tests/test_step_canvas_legend.gd | 30 ++- client/tests/test_step_canvas_transport.gd | 186 ++++++++++++------ client/tests/test_step_canvas_viewer.gd | 116 ++++++++--- .../atlas/step_canvas/step_canvas_legend.gd | 18 +- .../step_canvas/step_canvas_terrain_layer.gd | 7 +- .../step_canvas/step_canvas_transport.gd | 96 ++++----- .../atlas/step_canvas/step_canvas_viewer.gd | 33 ++-- 8 files changed, 312 insertions(+), 176 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f14f0b30..75abe90e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] ### Fixed -- **The Atlas map now fills the screen properly** (T-1189, T-1192) — the whole-body Global view fit-scales and centers in the viewport instead of sitting small in the top-left corner, and the legend sits beside the map instead of on top of it. The Region zoom step no longer shows the planet repeating side-by-side or smearing into stripes past the poles — the map view is capped at the body's actual size and letterboxed, so what you see is the planet once, correctly framed +- **The Atlas map now fills the screen properly** (T-1189, T-1192) — the whole-body Global view now fills the frame at an integer texel scale (never blurry, never a sliver in the corner) and centers in the viewport, with the legend sitting beside the map instead of on top of it. The Region zoom step no longer shows the planet repeating side-by-side or smearing into stripes past the poles — the map view is capped at the body's actual size and letterboxed, so what you see is the planet once, correctly framed ### Added - **The Atlas remembers across restarts** (D-255, T-1183) — every map view you visit is now kept on disk as well as in memory: relaunch the game, reopen a body, and previously-visited zoom steps draw instantly from the local cache with no server round-trip — only genuinely new ground fetches. Storage stays tidy on its own: fine-grained views of a body you haven't visited in ~two weeks quietly reclaim their space, each body's deep-zoom footprint is capped, and the whole-body overview of every visited body is kept forever. The cache is update-safe by construction — entries from an older game version are silently refetched, never misread diff --git a/client/tests/test_step_canvas_legend.gd b/client/tests/test_step_canvas_legend.gd index ed29f208d..c6a3e3055 100644 --- a/client/tests/test_step_canvas_legend.gd +++ b/client/tests/test_step_canvas_legend.gd @@ -71,12 +71,24 @@ func test_reposition_sets_a_fixed_panel_margin_position() -> void: assert_that(legend.position).is_equal(Vector2(LegendScript.PANEL_MARGIN, 60.0)) -## T-1192: StepCanvasTransport.LEGEND_COLUMN_PX (the Global fit-scale -## reservation StepCanvasViewer applies) must stay derived from this SAME -## panel's own width/margin — a drift here would silently reopen the -## "legend overlaps the canvas" defect on one side while the OTHER side -## thinks it already reserved enough room. -func test_reserved_column_px_matches_the_transport_sides_own_constant() -> void: - assert_float(LegendScript.RESERVED_COLUMN_PX).is_equal_approx( - StepCanvasTransport.LEGEND_COLUMN_PX, 0.01 - ) +## T-1192 review fix: RESERVED_COLUMN_PX is now a direct read of +## StepCanvasTransport.LEGEND_COLUMN_PX (not an independently-typed +## literal), so a const-vs-const cross-pin test would be structurally +## unable to fail — it is the SAME value by construction. What's still +## worth proving directly is the geometric guarantee that constant is FOR: +## the legend's own ACTUAL laid-out right edge (`reposition()`'s position.x +## + the panel's real minimum width) must land at or before the reserved +## column, with room to spare — never right up against it, and certainly +## never past it into where the canvas is centered from. +func test_legend_actual_right_edge_stays_inside_the_reserved_column() -> void: + var v: StepCanvasViewer = auto_free(StepCanvasViewer.new()) + add_child(v) + var legend = LegendScript.new(v) + auto_free(legend) + legend.reposition() + + var actual_right_edge: float = legend.position.x + legend.custom_minimum_size.x + assert_float(actual_right_edge).override_failure_message( + "the legend's real laid-out right edge must stay inside the column" + + " StepCanvasViewer reserves for it, with margin to spare" + ).is_less(StepCanvasTransport.LEGEND_COLUMN_PX) diff --git a/client/tests/test_step_canvas_transport.gd b/client/tests/test_step_canvas_transport.gd index 2b3905ba2..e53a525f6 100644 --- a/client/tests/test_step_canvas_transport.gd +++ b/client/tests/test_step_canvas_transport.gd @@ -280,72 +280,143 @@ func test_center_offset_goes_negative_when_the_canvas_overflows_the_viewport() - assert_float(offset.y).is_greater(0.0) -# ============================================================================= -# T-1192: Global integer-fit scale — D-255 texel-exactness -# ============================================================================= - - -func test_integer_fit_scale_picks_the_largest_multiple_that_fits_both_axes() -> void: - # GJ1c reference case: 177x88 texels at the 5x5 shallow display ratio = - # 885x440 px footprint, fit against a full 1920x1080 viewport. - var scale: int = StepCanvasTransport.integer_fit_scale( - Vector2(885.0, 440.0), Vector2(1920.0, 1080.0) +## D-255 texel-exactness: an integer-px/gridunit canvas (fit_scale_ratio()) +## can still land on an ODD-vs-viewport remainder that halves to a .5px +## boundary — GJ1c's own 1593x792 footprint in a 1628x1080 available area +## ((1628-1593)*0.5 = 17.5) is the real case this guards. The offset must be +## FLOORED to a whole pixel, never left fractional (a fractional offset +## would blur the texel grid this whole mechanism exists to keep crisp). +func test_center_offset_floors_a_half_pixel_remainder_to_a_whole_pixel() -> void: + var offset: Vector2 = StepCanvasTransport.center_offset( + Vector2(1593.0, 792.0), Vector2(1628.0, 1080.0) ) - assert_int(scale).is_equal(2) + assert_float(offset.x).is_equal_approx(17.0, 0.001) + assert_float(offset.y).is_equal_approx(144.0, 0.001) -func test_integer_fit_scale_is_bounded_by_the_tighter_axis() -> void: - # Wide-but-short viewport: x could fit 4x, y only fits 1x — the smaller +# ============================================================================= +# T-1192: Global integer PIXELS-PER-GRIDUNIT fit — D-255 amendment +# 2026-07-25 (rung-0's display ratio is viewport-fitted per body to an +# INTEGER px/gridunit ratio; the fractional-fit branch a prior round of this +# ticket carried was a mis-citation of D-255 — the record's actual mandate +# is unqualified texel-exact — and independently a real bug (Hoshe): a +# canvas exceeding the viewport on one axis could fit_scale() down to +# sub-1x, violating the "never below native resolution" invariant. Fixed at +# the root by fitting the INTEGER ratio, not a fraction of the whole +# footprint — this lattice is fine-grained (per gridunit, not per 5px-base +# footprint step), so the coverage-threshold escape hatch this section used +# to need does not come up: the achievable ratios are close enough together +# that the largest one that fits is always a good use of the frame. +# ============================================================================= + + +func test_fit_scale_ratio_picks_the_largest_px_per_gridunit_that_fits_both_axes() -> void: + # GJ1c reference case (T-1183 eyeball): 177x88 gridunits against a full + # 1920x1080 viewport (no legend reservation) — floor(1920/177)=10, + # floor(1080/88)=12, the tighter axis (x) wins. + var ratio: int = StepCanvasTransport.fit_scale_ratio( + Vector2(177.0, 88.0), Vector2(1920.0, 1080.0) + ) + assert_int(ratio).is_equal(10) + + +## The GJ1c reference case AFTER the legend column is reserved (T-1192, the +## StepCanvasViewer end-to-end scenario): available area shrinks to +## 1628x1080 — floor(1628/177)=9, floor(1080/88)=12 — the lead's own cited +## reference number for this exact case. +func test_fit_scale_ratio_matches_the_gj1c_legend_reserved_reference_case() -> void: + var ratio: int = StepCanvasTransport.fit_scale_ratio( + Vector2(177.0, 88.0), Vector2(1628.0, 1080.0) + ) + assert_int(ratio).is_equal(9) + + +## GJ1c at 4K, legend column reserved (3840 - 292 = 3548 available) — the +## lead's own cited reference number for a large viewport. +func test_fit_scale_ratio_matches_the_gj1c_4k_reference_case() -> void: + var ratio: int = StepCanvasTransport.fit_scale_ratio( + Vector2(177.0, 88.0), Vector2(3548.0, 2160.0) + ) + assert_int(ratio).is_equal(20) + + +## Hoshe's repro, now a regression test: a canvas whose BASE footprint +## (885x440 px at the old 5x5-multiple framing) exceeds a narrow 348px-wide +## available viewport used to make fit_scale() return 0.39x — a sub-1x +## downscale violating "never below native resolution". The integer +## px/gridunit ratio floors at 1 instead: draws at native (1 px/gridunit) +## and crops/pans, exactly like every other fixed rung's own +## exceeds-the-viewport precedent. +func test_fit_scale_ratio_floors_at_one_when_gridunits_exceed_a_narrow_viewport() -> void: + var ratio: int = StepCanvasTransport.fit_scale_ratio( + Vector2(177.0, 88.0), Vector2(348.0, 1080.0) + ) + assert_int(ratio).is_equal(1) + + +## A small moon's Global canvas (few gridunits) in a large viewport still +## only wins as large an integer ratio as fits — no special-casing for a +## small canvas, same formula, much larger achievable ratio. +func test_fit_scale_ratio_a_tiny_moon_canvas_wins_a_large_integer_ratio() -> void: + var ratio: int = StepCanvasTransport.fit_scale_ratio( + Vector2(20.0, 20.0), Vector2(1920.0, 1080.0) + ) + assert_int(ratio).is_equal(54) + + +## Exact-fit boundary: the viewport is PRECISELY `extent * 3` on both axes — +## the ratio must land exactly on 3, not overshoot to 4 (floor(exact) must +## not round up) and not undershoot to 2 (an exact multiple is a legal fit, +## not treated as "just barely doesn't fit"). +func test_fit_scale_ratio_exact_multiple_boundary_lands_on_the_multiple() -> void: + var ratio: int = StepCanvasTransport.fit_scale_ratio( + Vector2(100.0, 50.0), Vector2(300.0, 150.0) + ) + assert_int(ratio).is_equal(3) + + +## One pixel short of the exact multiple must drop to the NEXT integer down +## — confirms the boundary isn't fuzzy/off-by-one in the other direction. +func test_fit_scale_ratio_one_pixel_short_of_the_multiple_drops_a_step() -> void: + var ratio: int = StepCanvasTransport.fit_scale_ratio( + Vector2(100.0, 50.0), Vector2(299.0, 150.0) + ) + assert_int(ratio).is_equal(2) + + +func test_fit_scale_ratio_is_bounded_by_the_tighter_axis() -> void: + # Wide-but-short viewport: x could fit 10x, y only fits 1x — the smaller # wins (never overflow either axis). - var scale: int = StepCanvasTransport.integer_fit_scale( + var ratio: int = StepCanvasTransport.fit_scale_ratio( Vector2(100.0, 100.0), Vector2(1000.0, 150.0) ) - assert_int(scale).is_equal(1) + assert_int(ratio).is_equal(1) -func test_integer_fit_scale_never_drops_below_one() -> void: - # A canvas larger than the viewport still gets scale 1 (draw at native - # size and let it exceed/crop), never a shrink below native. - var scale: int = StepCanvasTransport.integer_fit_scale( - Vector2(3000.0, 3000.0), Vector2(800.0, 600.0) - ) - assert_int(scale).is_equal(1) +func test_fit_scale_ratio_handles_a_zero_extent_axis_without_dividing_by_zero() -> void: + var ratio: int = StepCanvasTransport.fit_scale_ratio(Vector2.ZERO, Vector2(800.0, 600.0)) + assert_int(ratio).is_equal(1) -func test_integer_fit_scale_handles_a_zero_canvas_axis_without_dividing_by_zero() -> void: - var scale: int = StepCanvasTransport.integer_fit_scale(Vector2.ZERO, Vector2(800.0, 600.0)) - assert_int(scale).is_equal(1) +## fit_scale_from_ratio() converts the INTEGER px/gridunit ratio into the +## `_canvas.scale` multiplier applied on top of a texture already rendered +## at the rung's own base display ratio (5x5 for Global) — this multiplier +## itself may be a non-integer float (9/5 = 1.8), and that is CORRECT: +## texel-exactness is about the final ratio being a whole number, not the +## Node2D scale field. +func test_fit_scale_from_ratio_divides_by_the_base_display_ratio() -> void: + var scale: float = StepCanvasTransport.fit_scale_from_ratio(9, 5.0) + assert_float(scale).is_equal_approx(1.8, 0.001) -func test_fit_scale_matches_the_integer_fit_when_coverage_is_high() -> void: - # The GJ1c full-viewport case: integer 2x covers well over the - # FIT_MIN_COVERAGE_RATIO bar, so fit_scale() must agree with - # integer_fit_scale() exactly (no fractional fallback). - var scale: float = StepCanvasTransport.fit_scale( - Vector2(885.0, 440.0), Vector2(1920.0, 1080.0) - ) - assert_float(scale).is_equal_approx(2.0, 0.001) +func test_fit_scale_from_ratio_at_the_gj1c_4k_reference_case() -> void: + var scale: float = StepCanvasTransport.fit_scale_from_ratio(20, 5.0) + assert_float(scale).is_equal_approx(4.0, 0.001) -## The GJ1c reference case AFTER the legend column is reserved (T-1192): -## available area shrinks to 1628x1080, so the 2x integer candidate (1770 px -## wide) no longer fits — integer_fit_scale() drops to 1x, which only covers -## ~41% of the tighter available axis, well under FIT_MIN_COVERAGE_RATIO — -## fit_scale() must fall back to the non-integer uniform fit that fills the -## tighter (x) axis exactly, not settle for the sparse 1x frame. -func test_fit_scale_falls_back_to_fractional_fit_on_excessive_letterboxing() -> void: - var scale: float = StepCanvasTransport.fit_scale( - Vector2(885.0, 440.0), Vector2(1628.0, 1080.0) - ) - assert_float(scale).is_greater(1.0) - assert_float(scale).is_less(2.0) - # The fractional fit fills the tighter (x) axis exactly. - assert_float(885.0 * scale).is_equal_approx(1628.0, 0.01) - - -func test_fit_scale_handles_a_zero_canvas_axis_without_dividing_by_zero() -> void: - var scale: float = StepCanvasTransport.fit_scale(Vector2.ZERO, Vector2(800.0, 600.0)) - assert_float(scale).is_equal_approx(1.0, 0.001) +func test_fit_scale_from_ratio_handles_a_zero_base_ratio_without_dividing_by_zero() -> void: + var scale: float = StepCanvasTransport.fit_scale_from_ratio(9, 0.0) + assert_float(scale).is_greater(0.0) # ============================================================================= @@ -353,9 +424,10 @@ func test_fit_scale_handles_a_zero_canvas_axis_without_dividing_by_zero() -> voi # ============================================================================= -func test_legend_column_px_is_positive_and_matches_the_legend_panels_own_sizing() -> void: - # Pinned against step_canvas_legend.gd's own RESERVED_COLUMN_PX (260 + - # 16*2 = 292) — the two constants must never drift apart, since the - # "beside, never over" guarantee depends on both sides agreeing on the - # SAME reserved width. +## StepCanvasTransport is the CANONICAL source for this width (T-1192 +## review fix) — step_canvas_legend.gd's own RESERVED_COLUMN_PX is now a +## direct read of THIS constant, not an independently-typed literal, so +## there is no separate cross-pin test needed here; this just pins the +## canonical value itself (260 panel width + 16*2 margin = 292). +func test_legend_column_px_is_the_panel_width_plus_margin_on_both_sides() -> void: assert_float(StepCanvasTransport.LEGEND_COLUMN_PX).is_equal_approx(292.0, 0.01) diff --git a/client/tests/test_step_canvas_viewer.gd b/client/tests/test_step_canvas_viewer.gd index b9be56e69..6d06d6b5f 100644 --- a/client/tests/test_step_canvas_viewer.gd +++ b/client/tests/test_step_canvas_viewer.gd @@ -529,6 +529,42 @@ func test_district_request_extent_is_never_capped_by_the_global_extent() -> void assert_that(extent).is_equal(uncapped) +## Hoshe (review round 2): _global_body_extent must NOT leak across a body +## switch — land a Global extent for body A, enter() body B, and prove BOTH +## that the cap source itself reads back ZERO for the new body AND that a +## Region request for body B before ITS OWN Global echo lands goes out +## UNCAPPED (never silently capped by body A's leftover grid). The reset +## already exists at StepCanvasViewer.enter() ("a new body has its own +## region grid") — this proves it, through the real enter()/land/enter() +## sequence rather than asserting the field directly only. +func test_global_body_extent_resets_on_a_different_body_and_does_not_leak() -> void: + var v: StepCanvasViewer = auto_free(StepCanvasViewer.new()) + add_child(v) + v.size = Vector2(1920.0, 1080.0) + + v.enter({"body_id": "T1189_extent_letterbox_test_body", "body_radius_km": 6371.0}, {}) + TestStepCanvasViewer._land_global_canvas(v, GJ1C_GLOBAL_EXTENT.x, GJ1C_GLOBAL_EXTENT.y) + assert_that(v._global_body_extent).override_failure_message( + "test setup: body A must actually have a landed cap source" + ).is_equal(GJ1C_GLOBAL_EXTENT) + + v.enter({"body_id": "T1189_extent_cap_body_b", "body_radius_km": 3000.0}, {}) + + assert_that(v._global_body_extent).override_failure_message( + "entering a DIFFERENT body must reset the cap source to ZERO — body" + + " A's region grid must never leak into body B's requests" + ).is_equal(Vector2i.ZERO) + + v._scroll_rung(1, Vector2(960.0, 540.0)) # Region, for body B — no Global echo yet + var extent: Vector2i = v._request_extent() + var uncapped: Vector2i = StepCanvasTransport.viewport_fit_extent(v.size, "Region") + assert_that(extent).override_failure_message( + "body B's Region request, before body B's own Global echo has" + + " landed, must go out UNCAPPED — never capped by body A's stale" + + " leftover region-grid extent" + ).is_equal(uncapped) + + # ============================================================================= # T-1189/T-1192: shared letterbox mechanism — centering + Global fit scale. # ============================================================================= @@ -551,28 +587,46 @@ func test_global_canvas_arrival_centers_the_view_not_top_left() -> void: ).is_not_equal(Vector2.ZERO) -## D-255 texel-exactness (T-1192): at a large (4K-class) viewport, GJ1c's -## 885x440 raw footprint (177x88 texels x 5x5 shallow display ratio) clears -## the coverage bar even AFTER the legend column is reserved, so the -## INTEGER fit wins outright — verified end-to-end through the real viewer -## wiring, not just the pure transport function this mirrors. (The T-1183 -## reference 1920x1080 viewport is deliberately NOT used here — at that -## size the legend-column reservation starves the integer candidate below -## the coverage bar and the fractional escape hatch fires instead, covered -## separately by test_global_canvas_uses_fractional_fit_when_legend_column_ -## starves_the_integer_fit() below.) -func test_global_canvas_scale_is_an_integer_multiple_of_the_raw_footprint() -> void: +## D-255 amendment 2026-07-25 texel-exactness: the `_canvas.scale` value +## itself is NOT required to be a whole number (9/5 = 1.8 is entirely +## legitimate) — what MUST be exact is the resulting on-screen +## pixels-per-gridunit ratio. Verified end-to-end through the real viewer +## wiring: `_canvas_scale * base_display_ratio` (the rung's own display +## ratio, 5.0 for Global) must land on an exact integer, for both the +## legend-reserved 1920x1080 case (R=9, scale=1.8) AND the 4K case (R=20, +## scale=4.0) — the SAME formula, no separate coverage-threshold branch. +func test_global_canvas_scale_yields_an_exact_integer_pixels_per_gridunit_ratio() -> void: + var v: StepCanvasViewer = auto_free(StepCanvasViewer.new()) + add_child(v) + v.size = Vector2(1920.0, 1080.0) + v.enter({"body_id": "T1189_extent_letterbox_test_body", "body_radius_km": 6371.0}, {}) + TestStepCanvasViewer._land_global_canvas(v, GJ1C_GLOBAL_EXTENT.x, GJ1C_GLOBAL_EXTENT.y) + + assert_that(v._canvas.scale).is_equal(Vector2(v._canvas_scale, v._canvas_scale)) + var base_ratio: float = StepCanvasTransport.display_ratio_for_rung("Global") + var effective_px_per_gridunit: float = v._canvas_scale * base_ratio + assert_float(effective_px_per_gridunit).override_failure_message( + "the FINAL on-screen pixels-per-gridunit ratio must be an exact" + + " integer even when the _canvas.scale multiplier itself is not" + ).is_equal_approx(roundf(effective_px_per_gridunit), 0.001) + # The lead's own cited reference number for this exact scenario. + assert_float(effective_px_per_gridunit).is_equal_approx(9.0, 0.001) + + +## Same invariant at a large (4K-class) viewport, where the integer ratio +## (20) happens to make the _canvas.scale multiplier itself a whole number +## too (20/5 = 4.0) — confirms the 1080p case above isn't a coincidence of +## a small viewport, just the same formula at a different achievable ratio. +func test_global_canvas_scale_at_4k_also_yields_an_exact_integer_ratio() -> void: var v: StepCanvasViewer = auto_free(StepCanvasViewer.new()) add_child(v) v.size = Vector2(3840.0, 2160.0) v.enter({"body_id": "T1189_extent_letterbox_test_body", "body_radius_km": 6371.0}, {}) TestStepCanvasViewer._land_global_canvas(v, GJ1C_GLOBAL_EXTENT.x, GJ1C_GLOBAL_EXTENT.y) - assert_that(v._canvas.scale).is_equal(Vector2(v._canvas_scale, v._canvas_scale)) - assert_float(v._canvas_scale).override_failure_message( - "the Global fit scale must be a whole number of texture pixels" - + " when it clears the coverage bar (D-255 texel-exactness)" - ).is_equal_approx(roundf(v._canvas_scale), 0.001) + var base_ratio: float = StepCanvasTransport.display_ratio_for_rung("Global") + var effective_px_per_gridunit: float = v._canvas_scale * base_ratio + assert_float(effective_px_per_gridunit).is_equal_approx(20.0, 0.001) ## Fixed rungs must NEVER receive the Global fit multiplier — `_canvas.scale` @@ -632,23 +686,23 @@ func test_resize_recenters_an_already_held_global_canvas() -> void: ).is_not_equal(offset_before) -## Small-canvas fallback (D-255's own escape hatch): once the legend column -## eats enough of the available width that the integer fit falls under -## FIT_MIN_COVERAGE_RATIO, the viewer must fall back to the fractional fit -## rather than settling for a sparse integer frame — end-to-end through the -## real viewer, mirroring test_fit_scale_falls_back_to_fractional_fit_on_ -## excessive_letterboxing in test_step_canvas_transport.gd. -func test_global_canvas_uses_fractional_fit_when_legend_column_starves_the_integer_fit() -> void: +## Hoshe (review round 2): a narrow viewport where GJ1c's canvas exceeds +## the available width on the gridunit lattice itself (177 gridunits > +## available px after the legend column is reserved) used to make the OLD +## fractional-fit branch return a sub-1x scale (0.39x) — a real downscale +## below native resolution, violating "never below native". The integer +## px/gridunit ratio floors at 1 instead: `_canvas_scale` must never drop +## under 1.0, end-to-end through the real viewer wiring, not just the pure +## transport function this mirrors. +func test_global_canvas_scale_never_drops_below_native_on_a_narrow_viewport() -> void: var v: StepCanvasViewer = auto_free(StepCanvasViewer.new()) add_child(v) - v.size = Vector2(1920.0, 1080.0) + v.size = Vector2(348.0 + StepCanvasTransport.LEGEND_COLUMN_PX, 1080.0) v.enter({"body_id": "T1189_extent_letterbox_test_body", "body_radius_km": 6371.0}, {}) TestStepCanvasViewer._land_global_canvas(v, GJ1C_GLOBAL_EXTENT.x, GJ1C_GLOBAL_EXTENT.y) - # GJ1c at 1920x1080 with the legend column reserved: 2x no longer fits - # (1770 > 1628 available), 1x covers only ~41% of the tighter axis — well - # under the 75% coverage bar, so a non-integer fit must have been chosen. - assert_float(v._canvas_scale).override_failure_message( - "the reserved legend column must starve the 2x integer candidate at" - + " this reference viewport, forcing the fractional fit" - ).is_greater(1.0) + var base_ratio: float = StepCanvasTransport.display_ratio_for_rung("Global") + assert_float(v._canvas_scale * base_ratio).override_failure_message( + "the effective pixels-per-gridunit ratio must floor at 1 (native)," + + " never a sub-1x downscale, even on a viewport this narrow" + ).is_equal_approx(1.0, 0.001) diff --git a/client/ui/implant/apps/atlas/step_canvas/step_canvas_legend.gd b/client/ui/implant/apps/atlas/step_canvas/step_canvas_legend.gd index 66ad4e0ba..659fa12c0 100644 --- a/client/ui/implant/apps/atlas/step_canvas/step_canvas_legend.gd +++ b/client/ui/implant/apps/atlas/step_canvas/step_canvas_legend.gd @@ -12,17 +12,19 @@ extends ImplantPanel ## No `class_name` on purpose, matching every other viewer-owned helper in ## this cluster. +const AtlasOverlayColors := preload("res://ui/implant/apps/atlas/atlas_overlay_colors.gd") +const StepCanvasTransport := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd") + const PANEL_MARGIN: float = 16.0 const LEGEND_PANEL_WIDTH: float = 260.0 -## T-1192: must stay derivable from PANEL_MARGIN/LEGEND_PANEL_WIDTH above — -## StepCanvasTransport.LEGEND_COLUMN_PX mirrors this exact sum so the -## Global-rung fit-scale computation reserves precisely this much column, -## never more or less than what the legend actually occupies. -const RESERVED_COLUMN_PX: float = LEGEND_PANEL_WIDTH + PANEL_MARGIN * 2.0 - -const AtlasOverlayColors := preload("res://ui/implant/apps/atlas/atlas_overlay_colors.gd") -const StepCanvasTransport := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd") +## T-1192 review fix: StepCanvasTransport.LEGEND_COLUMN_PX is now the ONE +## canonical source for this width — this file no longer computes its own +## independently-typed literal sum. A read of the transport constant, not a +## derivation, so the two sides can never drift apart by construction +## (transport is static-only, no scene-tree dependency in this direction — +## legend already preloads it above for AtlasOverlayColors-style helpers). +const RESERVED_COLUMN_PX: float = StepCanvasTransport.LEGEND_COLUMN_PX const MORPHOLOGY_FAMILY_ROWS: Array = [ {"label": "water", "zones": [0, 1]}, diff --git a/client/ui/implant/apps/atlas/step_canvas/step_canvas_terrain_layer.gd b/client/ui/implant/apps/atlas/step_canvas/step_canvas_terrain_layer.gd index 4014c1910..3dea99f6f 100644 --- a/client/ui/implant/apps/atlas/step_canvas/step_canvas_terrain_layer.gd +++ b/client/ui/implant/apps/atlas/step_canvas/step_canvas_terrain_layer.gd @@ -39,8 +39,11 @@ extends Node2D ## ## **T-1192 outer fit scale:** the owning `_canvas` Node2D ## (StepCanvasViewer._recompute_canvas_transform()) may additionally carry -## its OWN `.scale` — the Global-rung integer-fit multiplier, always 1.0 for -## every fixed rung — applied on top of this node's own texel-exact +## its OWN `.scale` — for the Global rung, `StepCanvasTransport. +## fit_scale_from_ratio()`'s per-body integer pixels-per-gridunit fit +## (D-255 amendment 2026-07-25: rung-0's display ratio is viewport-fitted +## per body to an INTEGER px/gridunit ratio, not a fixed 5x5); always 1.0 +## for every fixed rung — applied on top of this node's own texel-exact ## `_footprint_px` draw. That is a SECOND texture-to-viewport resize, same ## D-255(e) exemption, kept as a parent-transform multiply rather than a ## second internal scale field so this node's own footprint math never has diff --git a/client/ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd b/client/ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd index ae9b44aaf..623d1cc8c 100644 --- a/client/ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd +++ b/client/ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd @@ -85,27 +85,16 @@ const DISPLAY_RATIO_BY_RUNG: Dictionary = { ## server-side, per step_canvas_protocol.gd's own doc). const FIXED_CANVAS_MAX_AXIS: int = 3_840 -## T-1192: the on-screen column width reserved for the Atlas legend panel — -## mirrors step_canvas_legend.gd's own LEGEND_PANEL_WIDTH + PANEL_MARGIN*2 -## (panel width plus a margin on each side). Shared here (not duplicated as -## a raw literal in step_canvas_viewer.gd) so the Global integer-fit -## computation and the legend's own sizing can never silently drift apart — -## "legend beside, never over" only holds if both sides agree on the SAME -## reserved width. +## T-1192: the on-screen column width reserved for the Atlas legend panel +## (panel width plus a margin on each side). THIS is the canonical value — +## step_canvas_legend.gd's own RESERVED_COLUMN_PX is a direct read of this +## constant (review fix: was previously an independently-typed literal sum +## on the legend side, which could silently drift from this one), so the +## Global integer-fit computation and the legend's own sizing can never +## disagree by construction — "legend beside, never over" only holds if +## both sides agree on the SAME reserved width. const LEGEND_COLUMN_PX: float = 260.0 + 16.0 * 2.0 -## Below this fraction of the viewport's SMALLER axis covered, an integer -## fit "leaves excessive letterboxing" (D-255's own phrase) and the -## non-integer escape hatch fires instead. 0.75: the integer candidate must -## already cover at least three-quarters of the tighter axis to win outright -## — anything looser and the NEXT integer step down/up is visibly a better -## use of the frame. Tuned against the GJ1c reference case at 1920x1080 -## (integer 1x candidate covers ~41% of the tighter available axis after the -## legend-column reservation — well under this bar, so the fractional fit -## wins there, matching D-255's own "if that leaves excessive letterboxing -## on small canvases" framing). See fit_scale() below for the decision. -const FIT_MIN_COVERAGE_RATIO: float = 0.75 - ## The rung name at ladder index `i`, clamped to the legal [0, 5] range — ## the one place RUNG_LADDER is indexed into, so a caller passing an @@ -220,49 +209,46 @@ static func canvas_footprint_px(rung: String, extent_cells: Vector2i) -> Vector2 ## than the viewport on an axis gets a zero/negative offset on that axis (no ## letterbox needed there — it already fills or overflows, matching ordinary ## pan-and-crop behavior on that axis rather than shrinking the canvas). +## FLOORED to a whole pixel on each axis (D-255 texel-exactness): an +## integer-px/gridunit canvas (fit_scale_ratio()) still produces an +## odd-vs-even remainder half that can land on a .5px boundary — that would +## reintroduce a fractional-pixel blur edge on the very rung this exists to +## keep texel-exact, so the offset itself is snapped to the pixel grid. static func center_offset(footprint_px: Vector2, viewport_px: Vector2) -> Vector2: - return (viewport_px - footprint_px) * 0.5 + var raw: Vector2 = (viewport_px - footprint_px) * 0.5 + return Vector2(floorf(raw.x), floorf(raw.y)) -## D-255 texel-exactness for the Global opener (T-1192): the largest INTEGER -## scale multiple of `canvas_px` that still fits inside `viewport_px` on -## BOTH axes, floored at 1 (never downscale below native size — a canvas -## larger than the viewport draws at 1x and simply doesn't fit, matching -## every fixed rung's own "canvas can exceed the viewport" precedent rather -## than introducing a NEW sub-1x shrink path here). Callers needing the -## "non-integer fit acceptable on small canvases" escape hatch (D-255's own -## exception, nearest-neighbor only) compute their own fractional scale and -## skip this function — it only ever returns integers by design, so it -## can't accidentally hand back a blurry non-integer multiple. -static func integer_fit_scale(canvas_px: Vector2, viewport_px: Vector2) -> int: - if canvas_px.x <= 0.0 or canvas_px.y <= 0.0: +## D-255 premise (2), texel-exact: the largest INTEGER pixels-per-gridunit +## ratio `R` at which a `extent_cells`-gridunit canvas fits inside +## `viewport_px` on BOTH axes, floored at 1 (never below native resolution +## — an over-viewport canvas draws at 1 px/gridunit and crops/pans, same as +## every fixed rung's own "canvas can exceed the viewport" precedent). +## `R` is a PIXELS-PER-GRIDUNIT ratio, not a multiple of the whole footprint +## — this is the fine-grained lattice D-255's amendment (2026-07-25) +## clarifies rung-0's display ratio to be viewport-fitted-per-body against: +## GJ1c's 177x88-gridunit canvas at a 1920x1080 viewport (legend column +## reserved) lands on R=9 px/gridunit (1593x792, ~98% width), not a coarse +## multiple of the BASE 5px/gridunit footprint (885/1770/2655 — the old, +## too-coarse lattice that made a fractional escape hatch look necessary). +static func fit_scale_ratio(extent_cells: Vector2, viewport_px: Vector2) -> int: + if extent_cells.x <= 0.0 or extent_cells.y <= 0.0: return 1 - var max_x: int = int(floor(viewport_px.x / canvas_px.x)) - var max_y: int = int(floor(viewport_px.y / canvas_px.y)) + var max_x: int = int(floor(viewport_px.x / extent_cells.x)) + var max_y: int = int(floor(viewport_px.y / extent_cells.y)) return maxi(1, mini(max_x, max_y)) -## The actual scale to draw the Global canvas at (T-1192): the integer fit -## if it covers at least FIT_MIN_COVERAGE_RATIO of the viewport's tighter -## axis, otherwise a UNIFORM fractional fit (same scale both axes, so the -## canvas is never stretched non-uniformly) that fills the tighter axis -## exactly. The fractional branch is legal ONLY under D-255's own -## nearest-neighbor condition — StepCanvasTerrainLayer._filter_for_rung() -## already forces NEAREST for every orbital rung (Global included) -## unconditionally, so this function never has to check or set the filter -## itself; it only chooses the number. -static func fit_scale(canvas_px: Vector2, viewport_px: Vector2) -> float: - if canvas_px.x <= 0.0 or canvas_px.y <= 0.0: - return 1.0 - var int_scale: int = integer_fit_scale(canvas_px, viewport_px) - var covered: Vector2 = canvas_px * float(int_scale) - var coverage_x: float = covered.x / maxf(viewport_px.x, 0.0001) - var coverage_y: float = covered.y / maxf(viewport_px.y, 0.0001) - if minf(coverage_x, coverage_y) >= FIT_MIN_COVERAGE_RATIO: - return float(int_scale) - var frac_x: float = viewport_px.x / canvas_px.x - var frac_y: float = viewport_px.y / canvas_px.y - return maxf(minf(frac_x, frac_y), 0.0001) +## Convert a target pixels-per-gridunit ratio `R` (fit_scale_ratio()'s +## return) into the `_canvas.scale` multiplier applied ON TOP OF a texture +## already rendered at `base_display_ratio` px/gridunit (canvas_footprint_px() +## — the rung's own DISPLAY_RATIO_BY_RUNG entry). The multiplier itself may +## be fractional (e.g. 9/5 = 1.8) — texel-exactness is NOT about the scale +## factor being a whole number, it is about the FINAL on-screen pixel count +## per gridunit (`R`) being an exact integer, so every source texel lands on +## a whole number of screen pixels with no fractional-pixel blur boundary. +static func fit_scale_from_ratio(ratio: int, base_display_ratio: float) -> float: + return float(ratio) / maxf(base_display_ratio, 0.0001) ## Fit a fixed-rung request's extent (in gridunits) to the viewport, capped diff --git a/client/ui/implant/apps/atlas/step_canvas/step_canvas_viewer.gd b/client/ui/implant/apps/atlas/step_canvas/step_canvas_viewer.gd index 13f051e61..43a1aa512 100644 --- a/client/ui/implant/apps/atlas/step_canvas/step_canvas_viewer.gd +++ b/client/ui/implant/apps/atlas/step_canvas/step_canvas_viewer.gd @@ -119,12 +119,15 @@ var _app_has_focus: bool = true ## T-1192 Global fit scale — the ONE display-time scale this viewer ever ## writes (see the class doc). Always 1.0 for a fixed rung (its canvas is ## already texel-exact at its own display ratio; T-1189's letterbox only -## adds centered margin, never an extra scale). For Global, an INTEGER -## multiple when that covers most of the viewport (D-255 texel-exactness), -## else a non-integer fractional fit on small canvases — see -## StepCanvasTransport.fit_scale()'s own doc for the coverage rule; NEAREST -## filtering (required for the non-integer case) is already unconditional -## for every orbital rung via StepCanvasTerrainLayer._filter_for_rung(). +## adds centered margin, never an extra scale). For Global, the `_canvas.scale` +## multiplier that puts the FINAL on-screen pixels-per-gridunit at the +## largest INTEGER ratio that fits the (legend-reserved) viewport, floored +## at 1 (D-255 amendment 2026-07-25 — rung-0's display ratio is +## viewport-fitted per body to an integer, never a non-integer/sub-1x +## fraction) — see StepCanvasTransport.fit_scale_ratio()'s own doc. The +## multiplier itself (`ratio / base_display_ratio`) may be a non-integer +## float — that is expected and correct, since texel-exactness is about the +## RATIO being integer, not the Node2D scale field's raw value. var _canvas_scale: float = 1.0 # ── Overlay visibility ───────────────────────────────────────────────────── @@ -361,20 +364,24 @@ func _recompute_canvas_transform() -> void: _apply_transform() -## The fit scale for a given raw (unscaled) footprint at the CURRENTLY held -## rung — Global's fit_scale() reserving the legend column, 1.0 for every -## fixed rung. Split out from _recompute_canvas_transform() so -## _centered_view_offset() (the drift-check baseline) can share the exact -## same scale decision without re-deriving it, keeping the two callers -## structurally unable to disagree. +## The `_canvas.scale` multiplier for a given raw (unscaled) footprint at +## the CURRENTLY held rung — Global's per-body integer pixels-per-gridunit +## fit (StepCanvasTransport.fit_scale_ratio()/fit_scale_from_ratio(), +## reserving the legend column), 1.0 for every fixed rung. Split out from +## _recompute_canvas_transform() so _centered_view_offset() (the drift-check +## baseline) can share the exact same scale decision without re-deriving it, +## keeping the two callers structurally unable to disagree. func _letterbox_scale_for(raw_footprint: Vector2) -> float: if _held_rung != StepCanvasTransport.RUNG_GLOBAL: return 1.0 + var base_ratio: float = StepCanvasTransport.display_ratio_for_rung(_held_rung) + var extent_cells: Vector2 = raw_footprint / maxf(base_ratio, 0.0001) var viewport: Vector2 = get_rect().size var available: Vector2 = Vector2( maxf(viewport.x - StepCanvasTransport.LEGEND_COLUMN_PX, 1.0), viewport.y ) - return StepCanvasTransport.fit_scale(raw_footprint, available) + var ratio: int = StepCanvasTransport.fit_scale_ratio(extent_cells, available) + return StepCanvasTransport.fit_scale_from_ratio(ratio, base_ratio) func _rebuild_terrain_texture(canvas: Variant = null) -> void: From 4c44786f99f50a931f4cdac2b491b2b8901f26a3 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 25 Jul 2026 13:14:26 +0200 Subject: [PATCH 4/4] =?UTF-8?q?docs(governance):=20D-255=20amendment=20?= =?UTF-8?q?=E2=80=94=20rung-0=20integer=20viewport-fit=20display=20ratio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records the T-1192 presentation ruling: the Global opener's display ratio is viewport-fitted per body to the largest integer px/gridunit that fits the legend-reserved viewport (floored at 1x1, letterbox centred), texel-exact by construction; fixed rungs keep their tuned constants. Explicitly notes that non-integer resting-state scaling remains unsanctioned and that a PR #205 review finding removed a fractional-fit branch which had cited this record for an exception it does not contain — the mis-citation episode is part of the record so it cannot recur silently. Co-Authored-By: Claude Fable 5 --- governance/decisions/architecture.md | 1 + 1 file changed, 1 insertion(+) diff --git a/governance/decisions/architecture.md b/governance/decisions/architecture.md index 1f4d85b85..dfd3de7fd 100644 --- a/governance/decisions/architecture.md +++ b/governance/decisions/architecture.md @@ -2281,6 +2281,7 @@ Technical foundation decisions that constrain implementation: engine, client-ser - **Implementation:** Phase 4 (epic [T-750](../../.pql)). Implementation chain (measurement-informed): server step-canvas serving via the tagged envelope (the migration), the client two-layer component (RTT terrain + unscaled screen-space annotations) replacing the `_canvas.scale` model, the three-tier cache, the two lake tickets (morphology-sourcing from `HydrologyResult`; basin-outlet→D8 wiring, pre-cleared by T-1170 Ruling 7b). Ticket reconciliations: **T-1176** (this design discussion) closes as delivered; **T-1158** (viewer decomposition) cancelled — the canonical-frame machinery it would extract changes shape under stepped zoom; **T-1175** (nature polish) re-scoped onto the new annotation layer (c1 = CPU-first, its measurement-⑥ blocker discharged); **T-1157** (visual-capture goldens) re-scoped as the stepped mechanism's verification story; **T-1174** (batch-vs-window derive divergence) kept, priority raised (the envelope depends on batch/window derive agreeing); **T-1152/T-1153** stay `done`, their continuous-zoom + coverage-walk + Region-tile-mosaic code and test suites retire with the `_canvas.scale` model. Measurement appendix, per-agent positions, and the full deprecation sweep: `docs/workshops/body-map-viewer/`. - **Cross-reference:** [D-166](#d-166) (development cascade — the 2026-07-23 corollary repoint; this is the stepped successor to the continuous ladder), [D-226](#d-226) (live-pause harness — the windowed-family ceiling re-scoped, item-(d) per-request + floor-partial-restore, the T-1143 rulings this supersedes; item-(4) `AtlasAgentInterface` the cache cap protects), [D-227](#d-227) (derive-don't-store — the four cache/derive amendments: TTL-split, version-tag, seed-chaining, lakes; the discipline every cache tier obeys), [D-243](#d-243) (spatial scale ladder — the rungs gridunit snaps to, the Global rung-0 elastic-seam view, the `gridunit` vocabulary), [D-225](#d-225) (layer-stream proxy — the tagged-envelope deferral this discharges), [D-192](#d-192) (no version handshake — persistent-cache boundary), [D-253](#d-253) (region transient state — the sim-state planes the map-time split carries), [D-010](#d-010) (determinism — server-owns-derivation, client-art-function; the cache-accelerated-pure-function and byte-identical-paths tests), [D-169](#d-169)/[D-170](#d-170) (implant UI — the map component lives in the implant Atlas app, occludes gameplay via HudGroups), [D-239](#d-239) §6 (the frozen 17-zone `MorphologyZone` vocabulary the lake fix reuses, never widens). Tickets: [T-1176](../../.pql) (design), [T-1177](../../.pql)/[T-1178](../../.pql)/[T-1179](../../.pql)/[T-1180](../../.pql) (measurements ①–⑤), the reconciled T-1152/T-1153/T-1157/T-1158/T-1174/T-1175 above. - **Dissent:** None. +- **Amendment (2026-07-25, T-1192 / PR #205 — rung-0 integer viewport-fit display ratio):** Premise (2)'s "texel-exact, drawn at the display ratio" is **clarified for rung 0**: the Global opener's display ratio is **viewport-fitted per body** — the largest integer screen-px per gridunit that fits the legend-reserved viewport on both axes, floored at 1×1 (an over-viewport canvas draws at native ratio and crops/pans like every fixed rung) — rather than a fixed tunable. Texel-exactness is preserved by construction (every gridunit an exact integer pixel square); the letterbox remainder centres the canvas; fixed rungs keep their tuned constant display ratios. **Non-integer resting-state scaling remains unsanctioned** — a PR #205 review finding (Tyre) removed an implemented fractional-fit branch whose comments cited this record for an exception it does not contain; the [D-227](#d-227) 2026-07-23 corollary's "only sanctioned display-time scaling" (the transient between-step magnification) is joined only by this integer viewport fit, nothing else. ---