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>
202 lines
11 KiB
GDScript
202 lines
11 KiB
GDScript
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
|