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: