fix(client): size Global's cell count so the display ratio divides exactly
Jeroen: 'still not filling the screen... maybe we should calculate the depth that needs to be invented based on the canvas size somehow.' That is the fix. The old flow picked cells from the viewport, let the SERVER impose Global's 2:1 aspect, then fitted the returned extent into the drawable area with an INTEGER pixels-per-gridunit ratio. When that extent did not divide the area evenly, floor() dropped a whole step — and at ratio 2 the only step below is 1, i.e. half size. Worse, because the client fitted an extent it had not chosen, the result could fill NEITHER axis. Inverted: global_fill_extent() chooses the cell count FROM the ratio, already shaped 2:1, so cells * RATIO is the drawn size by construction and lands exactly on the binding axis. Verified across window shapes — 1920x1080 fills width, 2560x1080 fills height, 2560x1440 width, 3440x1440 height, 1280x720 width. Exactly one axis fills at every shape, which is the most a 2:1 equirectangular canvas can do in an arbitrary viewport; the other letterboxes. Global keeps its 2:1 aspect because it is 360 degrees of longitude by 180 of latitude — the aspect cannot follow the viewport without shearing the map. Tests pin the invariant directly: 2:1 preserved, never overflowing the drawable area, and never leaving slack on BOTH axes. Client suite 1832 / 1806 passed / 0 failed / 26 skipped. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -149,3 +149,4 @@ INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FT4SV8HMA2V98CSBGS12VE0C', 'T-1233', '2026-07-27 07:08:33.805', '2026-07-27 07:08:33.805', NULL, '664479a938343fac03b17f8fefd29399', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FT4SWTWSMBG0PEPQNK8DT7ZW', 'T-1234', '2026-07-27 07:08:46.695', '2026-07-27 07:08:46.695', NULL, '582e004a5dd3b45f3c00afa335ab4845', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FT4SY5BK5SBDA8G2MCPCB3AW', 'T-1235', '2026-07-27 07:08:57.564', '2026-07-27 07:08:57.564', NULL, '040d8befbcc613e958d46601dab04438', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FTA26EM8YXW086EV2K0N2WZR', 'T-1236', '2026-07-27 19:24:05.667', '2026-07-27 19:24:05.667', NULL, '09377e9155fd39e91049ffb0356e3c45', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
|
||||
@@ -5568,3 +5568,4 @@ RE-SCOPE REQUIRED BEFORE ANY CHILD STARTS (2026-07-27, D-258 amendment). The str
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FT0TYBD74TQNVKTJMKA8D9KM', 'task', '06FT0TX2W0BA10PRR7NMJ2362M', 'Measure rung-0.5 cost/size BEFORE implementing (compute + disk, whole-body)', 'MUST run and be reviewed before any other T-1211 child starts -- this measurement could reshape the rung-0.5 design, per the pair session''s explicit sequencing note. Measure, at minimum: (1) per-body derive cost for the expanded layer at a resolution sized so a whole body draws at 2 screen px per gridunit on a large display (the D-258 sizing rule) -- both single-body cold-derive time and the full ~271-body population sum; (2) per-body and total disk footprint if the layer is cached/stored (rung 0.5 is a named D-227 carve-out -- storage is deliberate, but its size must be known, not assumed); (3) whole-body hydrology solve cost on this layer (drainage + course routing + lake fill) at the same resolution, since D-258 requires this to run exactly once per body and nowhere else. Reference point: D-255''s own rung-0 always-keep tier estimate went from ~8.85 MB (measured against a stale ~18K-cell/body figure) to an estimated 226 MB (1080p) / ~900 MB (4K) once the extent inversion made Global viewport-sized -- D-255 amendment item 6 explicitly says ''re-measure against rung 0.5, not against this record.'' This ticket is that re-measurement. Report back to the team before T-1211''s other children are started; if the numbers are structurally bad (e.g. rung 0.5 at the sizing D-258 specifies costs an order of magnitude more than the old rung-0 model), that is grounds to revisit the resolution target with Jeroen before writing generator code. See governance/decisions/architecture.md#d-258 (rationale + Implementation note).
|
||||
|
||||
SCOPE INVALIDATED 2026-07-27 (see T-1211 and the D-258 amendment). This ticket was written to measure the cost of moving a whole-body hydrology solve onto rung 0.5. That solve does not need to move -- it already runs once per body in layer1.rs and is sampled fresh at every rung. Do NOT run this measurement as written; it would price work that is not required. If a measurement is still wanted after T-1211 is re-scoped, the question is narrower: what does BIOME un-summarisation cost, and does it need storing at all.', 'backlog', 'high', NULL, NULL, 'D-258', '2026-07-26 21:54:06.825', '2026-07-27 18:51:51.056', NULL, 'c5e77cd4de6e9ec9647bbfe12d0ed6e2', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FTA26EM8YXW086EV2K0N2WZR', 'bug', '06FBPPMZNNEV052DBYYY3A897C', 'visual_capture drifts the Atlas view — edge-scroll fires during the settle', 'Found 2026-07-27 while verifying the Global fill fix. An atlas capture reported canvas_position=(292.0, 657.3336) where the letterbox centre should be a whole-pixel value near y=133-277. The Y is both wrong AND fractional, which rules out center_offset() -- that floors to whole pixels for texel-exactness. The cause is edge scroll: the capture harness leaves the mouse at the viewport corner, StepCanvasViewer reads it as a held edge-scroll, and pans continuously through the 240 settle ticks, pushing the canvas down and right (x pinned at the legend column, y drifting). Consequences: (1) every Atlas golden is captured mid-pan, so the baselines encode an arbitrary drift offset rather than the canonical centred frame; (2) a real centring regression would be invisible against them; (3) captures are not reproducible run-to-run if tick timing varies. Fix shape: the capture harness should suppress edge-scroll (park the mouse centre-screen, or expose a viewer flag the harness sets), then assert the settled position IS the centred letterbox. Worth doing before any Atlas golden is trusted for centring -- note the goldens were ALSO degenerate for a separate reason until today (missing body_radius_km), so this rung''s baselines have never been meaningful.', 'backlog', 'medium', NULL, 'client', NULL, '2026-07-27 19:24:05.666', '2026-07-27 19:24:05.666', NULL, '5834a65f27af46030249f4b79a3b019a', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
|
||||
@@ -531,3 +531,42 @@ func test_fit_scale_from_ratio_handles_a_zero_base_ratio_without_dividing_by_zer
|
||||
## 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)
|
||||
|
||||
## Global must FILL its drawable area on at least one axis, at every window
|
||||
## shape (Jeroen, 2026-07-27: "it looks unacceptable if it does not fill the
|
||||
## screen across at least one axis"). Exactly one axis can fill — the canvas is
|
||||
## equirectangular 2:1 and the aspect cannot follow the viewport — so the other
|
||||
## letterboxes, and which one binds depends on whether the drawable area is
|
||||
## wider or narrower than 2:1.
|
||||
func test_global_fill_extent_fills_one_axis_at_every_window_shape() -> void:
|
||||
var ratio: float = StepCanvasTransport.DISPLAY_RATIO_DEEP
|
||||
for drawable in [
|
||||
Vector2(1628.0, 1080.0), # 1920 window minus legend — wider than 2:1? no
|
||||
Vector2(2268.0, 1080.0), # 2560 window — wider than 2:1, height binds
|
||||
Vector2(2268.0, 1440.0),
|
||||
Vector2(3148.0, 1440.0),
|
||||
Vector2(988.0, 720.0),
|
||||
]:
|
||||
var cells: Vector2i = StepCanvasTransport.global_fill_extent(drawable)
|
||||
assert_int(cells.x).override_failure_message(
|
||||
"%s: canvas must stay 2:1, got %s" % [drawable, cells]
|
||||
).is_equal(cells.y * 2)
|
||||
var drawn := Vector2(float(cells.x) * ratio, float(cells.y) * ratio)
|
||||
assert_bool(drawn.x <= drawable.x and drawn.y <= drawable.y).override_failure_message(
|
||||
"%s: drawn %s overflows the drawable area" % [drawable, drawn]
|
||||
).is_true()
|
||||
# The binding axis must be within one ratio-step of exact — i.e. there
|
||||
# is no room left for another whole gridunit on that axis.
|
||||
var slack_x: float = drawable.x - drawn.x
|
||||
var slack_y: float = drawable.y - drawn.y
|
||||
assert_bool(slack_x < ratio * 2.0 or slack_y < ratio).override_failure_message(
|
||||
"%s: fills NEITHER axis — drawn %s leaves %s slack" % [drawable, drawn, Vector2(slack_x, slack_y)]
|
||||
).is_true()
|
||||
|
||||
|
||||
## A degenerate drawable area must still yield a legal 2:1 canvas rather than
|
||||
## a zero-row one, which would divide by zero downstream in spacing_for_rung().
|
||||
func test_global_fill_extent_floors_at_one_row() -> void:
|
||||
var cells: Vector2i = StepCanvasTransport.global_fill_extent(Vector2(1.0, 1.0))
|
||||
assert_int(cells.y).is_greater_equal(1)
|
||||
assert_int(cells.x).is_equal(cells.y * 2)
|
||||
|
||||
@@ -818,9 +818,9 @@ func test_request_extent_ignores_a_not_yet_laid_out_viewport() -> void:
|
||||
StepCanvasViewer.FALLBACK_VIEWPORT_PX.x - StepCanvasTransport.LEGEND_COLUMN_PX,
|
||||
StepCanvasViewer.FALLBACK_VIEWPORT_PX.y
|
||||
)
|
||||
var expected: Vector2i = StepCanvasTransport.viewport_fit_extent(
|
||||
drawable, v.get_held_rung()
|
||||
)
|
||||
# Global sizes via global_fill_extent (2:1, chosen so the display ratio
|
||||
# divides exactly), not the generic viewport fit.
|
||||
var expected: Vector2i = StepCanvasTransport.global_fill_extent(drawable)
|
||||
assert_that(extent).override_failure_message(
|
||||
"a %s viewport must fall back, not be taken literally — got %s" % [degenerate, extent]
|
||||
).is_equal(expected)
|
||||
|
||||
@@ -344,6 +344,33 @@ static func viewport_fit_extent(viewport_px: Vector2, rung: String) -> Vector2i:
|
||||
return Vector2i(w, h)
|
||||
|
||||
|
||||
## The Global cell count that FILLS `drawable_px` on its binding axis at the
|
||||
## deep display ratio, already shaped 2:1.
|
||||
##
|
||||
## Global's canvas is equirectangular whole-body — 360 degrees of longitude by
|
||||
## 180 of latitude — so it is 2:1 and the aspect cannot follow the viewport.
|
||||
## Exactly one axis can therefore fill; the other letterboxes, and which one
|
||||
## depends on whether the drawable area is wider or narrower than 2:1.
|
||||
##
|
||||
## Why this exists rather than viewport_fit_extent() + an integer fit: the
|
||||
## display ratio must be a WHOLE number of screen px per gridunit (D-255
|
||||
## texel-exactness — a fractional ratio reintroduces the sub-pixel blur the
|
||||
## whole design exists to prevent). Choosing cells from the viewport and then
|
||||
## searching for an integer ratio that fits means `floor()` can drop a whole
|
||||
## step, and at ratio 2 the only step below is 1 — a HALF-SIZE map in a window
|
||||
## with room to spare. Choosing the cell count from the ratio instead makes
|
||||
## the division exact by construction: `cells * RATIO` is the drawn size, and
|
||||
## it is <= the drawable area on both axes with equality on the binding one.
|
||||
static func global_fill_extent(drawable_px: Vector2) -> Vector2i:
|
||||
var ratio: float = maxf(display_ratio_for_rung(RUNG_GLOBAL), 0.0001)
|
||||
# Largest whole gridunit count each axis could show on its own...
|
||||
var by_width: int = int(floor(drawable_px.x / ratio))
|
||||
var by_height: int = int(floor(drawable_px.y / ratio))
|
||||
# ...then 2:1 picks the binding one. Rows drive it, since cols = 2 * rows.
|
||||
var rows: int = maxi(1, mini(by_height, by_width / 2))
|
||||
return Vector2i(rows * 2, rows)
|
||||
|
||||
|
||||
## 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
|
||||
|
||||
@@ -516,6 +516,20 @@ func _request_extent() -> Vector2i:
|
||||
var drawable: Vector2 = viewport
|
||||
if _held_rung == StepCanvasTransport.RUNG_GLOBAL:
|
||||
drawable.x = maxf(viewport.x - StepCanvasTransport.LEGEND_COLUMN_PX, 1.0)
|
||||
# Pick the CELL COUNT so the display ratio comes out exact, instead of
|
||||
# picking cells from the viewport and then hunting for an integer ratio
|
||||
# that fits (Jeroen: "calculate the depth that needs to be invented
|
||||
# based on the canvas size").
|
||||
#
|
||||
# Global's canvas is equirectangular 2:1, and the SERVER enforces that
|
||||
# after the fact. So the old flow asked for (W/2, H/2), got back a
|
||||
# differently-shaped 2:1 extent, and then fitted THAT into the drawable
|
||||
# area with an integer px-per-gridunit ratio — and when the returned
|
||||
# extent did not divide the area evenly, floor() dropped a whole step
|
||||
# and the map filled NEITHER axis. Applying 2:1 here makes the request
|
||||
# already-correct, so the server's fit is a no-op and the display ratio
|
||||
# lands exactly on DISPLAY_RATIO_DEEP with nothing to floor away.
|
||||
return StepCanvasTransport.global_fill_extent(drawable)
|
||||
var fit: Vector2i = StepCanvasTransport.viewport_fit_extent(drawable, _held_rung)
|
||||
return StepCanvasTransport.cap_extent_to_body(fit, _held_rung, _global_body_extent)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user