Files
settled-reach/client/ui/implant/apps/atlas/atlas_descend_geometry.gd
T
jpmschweitzerandClaude Fable 5 831625019a fix(ui): T-1142 client — descent bounds gate, fit-and-center, pole wall, east-west wrap, body-name header
Bounds gate: AtlasDescendGeometry.is_on_texture() — ONE helper feeding
both the reticle guard and the click fall-through (the T-1140 lesson:
the visible affordance always matches the click); half-open
[0,tex_w)x[0,tex_h) boundary pinned at the exact edge. Letterbox
clicks no longer show a reticle or descend.

Fit-and-center: pure fit_window_view() (new atlas_window_geometry.gd)
wired into enter(), the FIRST window arrival, and NOTIFICATION_RESIZED
— gated by a _user_adjusted flag so the fit never fights manual
zoom/pan (flag clears only on a fresh enter). Found-own-bug: RESIZED
can fire mid-_ready() before _canvas exists — null-guarded like the
sibling panels.

Pole wall (Jeroen's ruling): clamp_pan_offset_to_pole_wall() clamps
the WINDOW EDGE, not the center, in screen space from the fitted
transform — Y only; wired into the drag handler and every fit (a
fresh fit can itself need the wall on a tiny body — the
window-taller-than-planet case is handled and tested). Three numeric
hand-traces preceded the code; a first-draft test using GJ380c's huge
radius silently never exercised the clamp — replaced with a synthetic
small radius.

East-west wrap (Jeroen's ruling): canonicalize_district_center() —
posmod column wrap (verified against a live Godot process to match
Rust rem_euclid bit-for-bit), clamped row; district_extent() shares
the exact formula (incl. .max(1)) with the server's
normalize_window_center so echoes and cache keys agree on canonical
form. Canonicalization applies only to the FINAL refetch center — the
edge-crossing decision stays in absolute district space (first-pass
math error caught by hand-trace). Seam-adjacent cache-key sharing
tested. Pan offset itself has no x wall — circumnavigation is
seamless.

Header: body proper_name/body_id ahead of the coordinates (the cheap
half of T-1141, noted in code). Drag-pan verified through the REAL
DistrictScreen-to-viewer chain and pinned by test (no fix needed).

140 tests across three suites, 0 failures; 94 sibling tests no
ripple; gdlint clean (atlas_viewer.gd at the 1000-line cap a second
round — structural extraction flagged for maintenance).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 14:53:32 +02:00

202 lines
11 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
extends RefCounted
## Pure geometry helpers for AtlasViewer's T-1138 descent affordance (D-226
## T-1124 amendment §5 entry revision) — factored out to keep atlas_viewer.gd
## under gdlint's max-file-lines cap, same rationale/shape as
## atlas_overlay_colors.gd's split from atlas_marker_overlay.gd (draw_line()/
## draw_string() are CanvasItem instance methods called implicitly on `self`,
## so the actual draw calls stay on AtlasViewer — only the pure lookups/math
## that decide WHERE/WHAT to draw move here):
## const AtlasDescendGeometry := preload("res://ui/implant/apps/atlas/atlas_descend_geometry.gd")
##
## D-243: 2,048 m per district side — the source-canonical unit the server's
## derive_district()/DISTRICT_WINDOW_DEFAULT_N (32) both key off of.
const DISTRICT_M: float = 2048.0
const DISTRICT_WINDOW_DEFAULT_N: int = 32
## Fixed on-screen reticle size (px) — deliberately NOT scaled to the
## window's true planetary footprint.
##
## The true footprint of a DISTRICT_WINDOW_DEFAULT_N=32 window is
## 32 * 2.048 km = ~65.5 km per side (~131 km at the n=64 cap) — at every
## zoom level AtlasViewer's _fit_to_view() ever produces for a whole-planet
## heightmap (MAX_ZOOM=8.0 on a texture that already spans the full body),
## that distance is on the order of a handful of PIXELS. A true-extent
## rectangle would therefore be visually indistinguishable from a dot
## regardless of zoom — not an honest representation, just an illegible one;
## the amendment explicitly rejects "implying more coverage than real" but a
## sub-pixel rectangle fails the OPPOSITE way (implying almost no coverage,
## which is equally dishonest about what a click actually captures).
##
## The resolution (D-226 T-1124 amendment §5's open design point, resolved
## here): a small FIXED-SIZE bracket reticle (reads clearly at any zoom, same
## idiom as the marker overlay's fixed-size POI glyphs elsewhere on this map)
## plus a text label giving the REAL extent in km — "honest" comes from the
## label's number, not from the reticle's pixel size pretending to be to
## scale. This is a reticle, explicitly not scaled to true size, with the
## real extent stated next to it — the amendment's second named option
## (chosen over a true-extent rectangle + zoom-in cut).
const DESCEND_RETICLE_SIZE: float = 28.0
const COLOR_DESCEND_RETICLE: Color = Color(0.70, 0.88, 1.0, 0.85) # matches COLOR_GATE_MARKER family
## The eight line segments (as [from, to] pairs) for the reticle's four
## L-shaped bracket corners — reads as "this is a bounded region", distinct
## from the circular city-marker glyphs and diamond gate markers already on
## this map (D-226's hue=type/shape=identity instinct applied to interaction
## affordances). Flat segment-pair array so the caller's draw_line() loop is
## a one-liner, not a struct AtlasViewer needs to know the shape of.
static func reticle_segments(center: Vector2) -> Array:
var half: float = DESCEND_RETICLE_SIZE * 0.5
var arm: float = half * 0.5
var corners: Array = [
center + Vector2(-half, -half),
center + Vector2(half, -half),
center + Vector2(half, half),
center + Vector2(-half, half),
]
var h_dirs: Array = [Vector2(1, 0), Vector2(-1, 0), Vector2(-1, 0), Vector2(1, 0)]
var v_dirs: Array = [Vector2(0, 1), Vector2(0, 1), Vector2(0, -1), Vector2(0, -1)]
var segments: Array = []
for i in range(4):
segments.append([corners[i], corners[i] + h_dirs[i] * arm])
segments.append([corners[i], corners[i] + v_dirs[i] * arm])
return segments
## Label position (offset from the reticle center, to the right of it) + the
## real-extent text — "~65 x 65 km" for the default n=32 window.
static func reticle_label(center: Vector2) -> Dictionary:
var half: float = DESCEND_RETICLE_SIZE * 0.5
var extent_km: float = float(DISTRICT_WINDOW_DEFAULT_N) * DISTRICT_M / 1000.0
return {
"position": center + Vector2(half + 6.0, 4.0),
"text": "~%.0f × %.0f km" % [extent_km, extent_km],
}
## Whole-body district extent (columns spanning the full equatorial
## circumference; the half-meridian row range, i.e. equator to either pole)
## for a body of `body_radius_km`. Shared by district_pos_at(),
## canonicalize_district_center(), and AtlasWindowViewer's pole-wall pan
## clamp — ONE formula, matching server/src/atlas/layer_proxy.rs's
## normalize_window_center() EXACTLY (T-1142 canonicalization, dudley's
## in-flight server counterpart): `districts_per_circumference =
## round(circumference_m / DISTRICT_M).max(1)`, `half_meridian_districts =
## round(meridian_m / DISTRICT_M / 2.0)`. The `.max(1)` floor on cols matters
## for canonicalization's rem_euclid (a zero modulus panics/undefined-behaves
## on the server; GDScript's `%` on 0 is likewise not safe to rely on) even
## though no real systems.db body is small enough to hit it.
static func district_extent(body_radius_km: float) -> Dictionary:
var circumference_m: float = TAU * body_radius_km * 1000.0
var meridian_m: float = PI * body_radius_km * 1000.0
return {
"cols": maxf(roundf(circumference_m / DISTRICT_M), 1.0),
"rows_half": roundf((meridian_m / DISTRICT_M) * 0.5),
}
## Inverse of the server's derive_district() pixel mapping
## (server/src/atlas/district_profile.rs) — a `true_district_of_pixel`-style
## function, per the amendment's §5 carry-over wording. The server's forward
## mapping (body has a radius) is:
## px = ((dx * DISTRICT_M) / circumference_m mod 1.0) * tex_w
## py = (0.5 + clamp(dy * DISTRICT_M / meridian_m, -0.5, 0.5)) * ta.h.saturating_sub(1)
## (district_profile.rs:1436 — note `.saturating_sub(1)`, NOT a bare `ta.h`;
## confirmed against the ground-truth inverse aliveness_probe.rs:511 too:
## `row / (ta_h - 1) - 0.5`). Columns and rows are DELIBERATELY asymmetric:
## longitude WRAPS (rem_euclid), so a column has no "last pixel" edge case and
## divides by the plain width; latitude CLAMPS at the poles, so row 0 and row
## (h-1) are real, distinct endpoints (the north/south pole pixels) and the
## division must land exactly on them — dividing by `tex_h` instead of
## `tex_h - 1` introduces a systematic drift that grows with |lat_frac|
## (worst at the poles, invisible only at the exact equator row, `tex_h*0.5`,
## where the two formulas round identically). This is why inverting is "pixel
## fraction * district count" for columns but NOT a symmetric operation for
## rows — the row inverse must undo the SAME -1 the forward map applied.
##
## The (wx/circumference_m, wy/meridian_m) fractions are linear scalings of
## the same world-metre quantity the equatorial/meridian district COUNT
## already IS (district_cols = round(circumference_m / DISTRICT_M), the same
## value build_district_grid()'s `cols` converges to for a body tiled
## edge-to-edge) — so inverting is "pixel fraction * district count", not a
## re-derivation of the server's geodesy. Self-contained: does NOT depend on
## district_grid (the whole-body layer) having arrived yet, so descent works
## immediately on entry even before that async layer resolves. The "no
## radius" branch (tiny test bodies, body_radius_km absent/<=0) mirrors the
## server's own fallback: the district grid IS the heightmap grid 1:1.
static func district_pos_at(
canvas_pt: Vector2, tex_w: float, tex_h: float, body_radius_km: float
) -> Vector2i:
if tex_w <= 0.0 or tex_h <= 0.0:
return Vector2i.ZERO
if body_radius_km <= 0.0:
return Vector2i(roundi(canvas_pt.x), roundi(canvas_pt.y))
var extent: Dictionary = district_extent(body_radius_km)
var col: int = roundi((canvas_pt.x / tex_w) * float(extent["cols"]))
# tex_h - 1.0, matching the forward map's ta.h.saturating_sub(1) — NOT a
# bare tex_h (see the docstring above; this was a live bug, T-1138 PR #187
# review, Hoshe: every off-equator click descended into the wrong district).
# Guarded the same way the server's own inverse (aliveness_probe.rs:510,
# `if ta_h > 1 { ... } else { 0.0 }`) guards the same division — a
# degenerate 1px-tall texture would otherwise divide by zero.
var lat_frac: float = ((canvas_pt.y / (tex_h - 1.0)) - 0.5) if tex_h > 1.0 else 0.0
var row: int = roundi(lat_frac * float(extent["rows_half"]) * 2.0)
return Vector2i(col, row)
## T-1142 addendum (Jeroen — pole-wall/east-west-wrap ruling): canonicalize a
## district-window center to the SAME range the server's
## normalize_window_center() (server/src/atlas/layer_proxy.rs) produces —
## column WRAPS (longitude is periodic; rem_euclid into [0, cols)), row
## CLAMPS (latitude terminates at the poles; clamp into [-rows_half,
## rows_half]). Load-bearing that this matches the server bit-for-bit: the
## server echoes back the NORMALIZED center in DistrictWindowLayer.center, so
## a client that requests a raw (un-normalized) center but compares against
## its own raw value in the §2 staleness guard would reject every legitimate
## response for an out-of-range request as "stale". Canonicalizing HERE,
## before the request is even sent, means the client's held `_center` already
## equals what the server will echo — no drift between the two sides'
## "canonical" concepts, and the cache key (built from the same canonicalized
## Vector2i) naturally de-dupes a full-circumnavigation pan back to a
## previously-fetched column.
##
## No-radius bodies (tiny test bodies, BodyParams' own doc) are identity —
## same fallback disposition as normalize_window_center()'s own no-radius
## branch (the forward map's no-radius path has no periodicity concept).
static func canonicalize_district_center(center: Vector2i, body_radius_km: float) -> Vector2i:
if body_radius_km <= 0.0:
return center
var extent: Dictionary = district_extent(body_radius_km)
var cols: int = int(extent["cols"])
var rows_half: int = int(extent["rows_half"])
# GDScript's % on negative operands follows sign-of-dividend (like Rust's
# %, NOT rem_euclid) — posmod() is Godot's rem_euclid equivalent, exactly
# what the server's DistrictPos.rem_euclid(districts_per_circumference) does.
var wrapped_col: int = posmod(center.x, cols)
var clamped_row: int = clampi(center.y, -rows_half, rows_half)
return Vector2i(wrapped_col, clamped_row)
## T-1142 (Jeroen's first hands-on click, PR #187 follow-up): true unless the
## canvas point lies ON the heightmap texture, [0, tex_w) x [0, tex_h). The
## fixed planetary view (T-1138) can letterbox a non-2:1-aspect viewport
## around the 2:1 heightmap — AtlasViewer's mouse-motion/click handlers see
## every screen point in the FULL Control rect, including the letterbox dead
## zone beside/above/below the actual map, and screen_to_canvas() has no
## opinion about whether the resulting canvas point is still ON the texture
## (it is a pure affine inverse — it happily returns x=1400 for a click at
## screen-x 1900 on a 1024px-wide fitted texture). Left unchecked, a letterbox
## click both (a) shows the descend reticle (a promise) and (b) derives a
## DistrictPos from an out-of-range canvas point — Jeroen's exact repro
## (clicked the letterbox, landed at column 12276 on a body whose max valid
## column is ~11236, and the server's clamped-sampling derive at that
## beyond-the-planet position produced uniform green).
##
## ONE named helper, used by BOTH the reticle-show guard and the descend
## click fall-through (never two independent bounds checks that could drift
## — the same "one truth" lesson T-1140's hover/reticle mismatch already
## taught: the visible affordance must always match what the click does).
static func is_on_texture(canvas_pt: Vector2, tex_w: float, tex_h: float) -> bool:
return canvas_pt.x >= 0.0 and canvas_pt.x < tex_w and canvas_pt.y >= 0.0 and canvas_pt.y < tex_h