Merge remote-tracking branch 'origin/main' into atlas-latitude-fix
# Conflicts: # CHANGELOG.md
This commit is contained in:
@@ -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,26 @@ 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 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)
|
||||
|
||||
@@ -188,3 +188,246 @@ 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)
|
||||
|
||||
|
||||
## 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_float(offset.x).is_equal_approx(17.0, 0.001)
|
||||
assert_float(offset.y).is_equal_approx(144.0, 0.001)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 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 ratio: int = StepCanvasTransport.fit_scale_ratio(
|
||||
Vector2(100.0, 100.0), Vector2(1000.0, 150.0)
|
||||
)
|
||||
assert_int(ratio).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)
|
||||
|
||||
|
||||
## 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_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)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# T-1192: shared legend-column reservation constant
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## 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)
|
||||
|
||||
@@ -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,318 @@ 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)
|
||||
|
||||
|
||||
## 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.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## 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 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)
|
||||
|
||||
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`
|
||||
## 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)
|
||||
|
||||
|
||||
## 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(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)
|
||||
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,11 +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
|
||||
|
||||
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]},
|
||||
|
||||
@@ -36,6 +36,18 @@ 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` — 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
|
||||
## 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")
|
||||
|
||||
@@ -85,6 +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
|
||||
## (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
|
||||
|
||||
|
||||
## 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 +198,59 @@ 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).
|
||||
## 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:
|
||||
var raw: Vector2 = (viewport_px - footprint_px) * 0.5
|
||||
return Vector2(floorf(raw.x), floorf(raw.y))
|
||||
|
||||
|
||||
## 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 / extent_cells.x))
|
||||
var max_y: int = int(floor(viewport_px.y / extent_cells.y))
|
||||
return maxi(1, mini(max_x, max_y))
|
||||
|
||||
|
||||
## 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
|
||||
## 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 +269,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
|
||||
|
||||
@@ -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,35 @@ 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, 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 ─────────────────────────────────────────────────────
|
||||
var _overlay_visibility: Dictionary = {}
|
||||
|
||||
@@ -173,6 +205,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 +288,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 +322,68 @@ 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 `_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
|
||||
)
|
||||
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:
|
||||
var c: Variant = canvas if canvas != null else _terrain_layer._canvas_ref
|
||||
if not c is Dictionary:
|
||||
@@ -339,10 +443,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 +484,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 +616,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:
|
||||
|
||||
Reference in New Issue
Block a user