feat(client): size the Global rung to the viewport, not the region grid

Global took its cell counts from `global_cell_counts()` — one gridunit per
region — and discarded the requested extent entirely. On GJ380c that produced
a 191x95 canvas built from a heightmap stored at 512x256: roughly seven times
the available cells thrown away before anything was drawn. The count also
shrank as REGION_M grew, so tuning the scale ladder silently degraded the
opener, which is why the top of the ladder got worse rather than better as
the ladder itself was refined.

Global now fits the largest 2:1 canvas inside the requested extent. It cannot
take its ASPECT from the viewport — the canvas is equirectangular whole-body,
360 degrees of longitude by 180 of latitude, and must stay 2:1 or the cells
stop being square and the map shears — so the existing letterbox absorbs the
remainder. A hostile extent is still clamped; sizing to the request is not
trusting the request.

Global also joins the deep display ratio, making the band uniform. At 5 px
per gridunit a 1920 px window asked for 384 cells across a body whose
heightmap holds 512x256 — discarding stored detail to save work already done.
At 2 px it asks for 960, which is heightmap-native: nothing thrown away,
nothing invented, and the same screen area filled either way.

A body with no radius is not a sphere (asteroid belt, oort cloud) and has no
equirectangular surface to fit. Those degrade to the region grid — a visibly
degenerate 1x1 canvas — rather than a plausible-looking lie at whatever size
the viewport happened to ask for.

project.yaml 0.4.3 -> 0.4.4 invalidates the persisted step-canvas disk cache.
District/Quarter/Block/Chunk kept identical 960x540 cell counts through the
extent inversion, so their cache keys are byte-identical while a District
canvas now covers 3.6 km of ground instead of 1,966 km — a warm cache would
silently serve pre-inversion canvases.

Global still rides the orbital derive, so it carries no courses yet; that is
the next step and is deliberately separate, being a cost question over the
whole body rather than a sizing one.

Pair session with Jeroen, 2026-07-26.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-26 22:45:40 +02:00
co-authored by Claude
parent 0a0419abcc
commit 9c8fcc2f95
4 changed files with 124 additions and 47 deletions
+79 -24
View File
@@ -870,10 +870,37 @@ fn resolve_canvas_extent(
extent: (u32, u32),
body_radius_km: f64,
) -> (u32, u32) {
match rung.global_cell_counts(body_radius_km) {
Some((cols, rows)) => (cols, rows),
None => clamp_step_canvas_extent(extent),
if !rung.is_global() {
return clamp_step_canvas_extent(extent);
}
// Global is VIEWPORT-SIZED like every other rung now (D-255 amendment,
// pair session 2026-07-26) — it used to take its cell counts from
// `global_cell_counts()`, i.e. one gridunit per region. On GJ380c that
// was a 191x95 canvas built from a heightmap stored at 512x256: roughly
// SEVEN TIMES the available cells thrown away before drawing. Worse, the
// count shrank as REGION_M grew, so tuning the scale ladder silently
// degraded the opener.
//
// The one thing Global cannot take from the viewport is its ASPECT: the
// canvas is equirectangular whole-body, 2:1 (360 degrees of longitude by
// 180 of latitude), and cells must stay square or the map shears. So fit
// the largest 2:1 canvas inside the requested extent and let the existing
// letterbox (`center_offset`) absorb the remainder — the viewport is
// ~16:9, so this is normally height-bound.
let (req_w, req_h) = clamp_step_canvas_extent(extent);
let width = req_w.min(req_h.saturating_mul(2)).max(2);
let resolved = (width, (width / 2).max(1));
debug_assert!(
resolved.0 == resolved.1 * 2,
"Global canvas must stay 2:1 or cells are not square"
);
// A body with no radius (an asteroid belt is not a sphere and has no
// equirectangular surface) has nothing to fit — fall back to the region
// grid, which degrades to a 1x1 canvas rather than a plausible-looking lie.
if body_radius_km <= 0.0 {
return rung.global_cell_counts(body_radius_km).unwrap_or(resolved);
}
resolved
}
/// The step-canvas derive core (D-255(f): independent re-derivation, the
@@ -1879,31 +1906,59 @@ mod tests {
}
}
/// Global now SIZES to the request (D-255 amendment 2026-07-26) — it used
/// to derive its cell counts from the body's region grid and discard the
/// wire value, which on GJ380c meant a 191x95 canvas built from a 512x256
/// heightmap. A hostile wire value must still be clamped, but a legitimate
/// one must be honoured.
#[test]
fn global_rung_ignores_wire_extent_entirely() {
// resolve_canvas_extent must derive Global's extent from the body's
// own region grid, never from the (hostile or otherwise) wire
// value — confirmed at u32::MAX, the most adversarial input.
let (w, h) = resolve_canvas_extent(StepCanvasRung::Global, (u32::MAX, u32::MAX), 6371.0);
let expected = StepCanvasRung::Global
.global_cell_counts(6371.0)
.expect("Global has a cell-count shape");
assert_eq!((w, h), expected);
// Sanity: the body-derived shape is nowhere near u32::MAX — proves
// the wire value truly had zero influence, not just a coincidental
// clamp to the same range.
assert!(w < STEP_CANVAS_MAX_EXTENT_AXIS * 10);
assert!(h < STEP_CANVAS_MAX_EXTENT_AXIS * 10);
fn global_rung_sizes_to_the_request() {
let small = resolve_canvas_extent(StepCanvasRung::Global, (480, 270), 6371.0);
let large = resolve_canvas_extent(StepCanvasRung::Global, (960, 540), 6371.0);
assert!(
large.0 > small.0,
"a bigger viewport must get a bigger Global canvas: {small:?} vs {large:?}"
);
// Height-bound at a 16:9 request: 540*2 = 1080 > 960, so width wins.
assert_eq!(large, (960, 480));
}
/// Global's canvas is equirectangular whole-body — 360 degrees of
/// longitude by 180 of latitude. It must stay exactly 2:1 at every
/// viewport shape or the cells stop being square and the map shears.
#[test]
fn global_rung_extent_is_identical_regardless_of_requested_extent() {
// A second confirmation from the opposite direction: Global's
// resolved extent must be the SAME for a tiny request and a huge
// one — extent has literally no effect on Global's output shape.
let tiny = resolve_canvas_extent(StepCanvasRung::Global, (1, 1), 6371.0);
let huge = resolve_canvas_extent(StepCanvasRung::Global, (u32::MAX, u32::MAX), 6371.0);
assert_eq!(tiny, huge);
fn global_rung_stays_two_to_one_at_every_viewport_shape() {
for req in [
(960u32, 540u32), // 16:9 landscape — height-bound
(540, 960), // portrait — height is huge, width binds
(700, 700), // square
(4000, 100), // absurdly wide
(1, 1), // degenerate
(u32::MAX, u32::MAX), // adversarial
] {
let (w, h) = resolve_canvas_extent(StepCanvasRung::Global, req, 6371.0);
assert_eq!(w, h * 2, "request {req:?} produced a non-2:1 canvas {w}x{h}");
assert!(h >= 1, "request {req:?} collapsed the canvas to zero rows");
}
}
/// A hostile wire value must still be clamped to the canvas budget —
/// sizing to the request must not become "trust the request".
#[test]
fn global_rung_still_clamps_an_adversarial_extent() {
let (w, h) = resolve_canvas_extent(StepCanvasRung::Global, (u32::MAX, u32::MAX), 6371.0);
assert!(w <= STEP_CANVAS_MAX_EXTENT_AXIS, "width {w} escaped the axis cap");
assert!(h <= STEP_CANVAS_MAX_EXTENT_AXIS, "height {h} escaped the axis cap");
}
/// A body with no radius is not a sphere (asteroid belt, oort cloud) and
/// has no equirectangular surface to fit. It must degrade to the region
/// grid — a visibly degenerate canvas — rather than a plausible-looking
/// lie at whatever size the viewport happened to ask for.
#[test]
fn global_rung_without_a_radius_does_not_fabricate_a_surface() {
let (w, h) = resolve_canvas_extent(StepCanvasRung::Global, (960, 540), 0.0);
assert_eq!((w, h), (1, 1));
}
// -----------------------------------------------------------------