diff --git a/.pql/changelog/ticket_idmap/2026-07.sql b/.pql/changelog/ticket_idmap/2026-07.sql index af634a7e5..ed1753e9e 100644 --- a/.pql/changelog/ticket_idmap/2026-07.sql +++ b/.pql/changelog/ticket_idmap/2026-07.sql @@ -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); diff --git a/.pql/changelog/tickets/2026-07.sql b/.pql/changelog/tickets/2026-07.sql index 1f1460c81..5a8c74c62 100644 --- a/.pql/changelog/tickets/2026-07.sql +++ b/.pql/changelog/tickets/2026-07.sql @@ -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); diff --git a/client/tests/test_step_canvas_transport.gd b/client/tests/test_step_canvas_transport.gd index 4ce78d01a..9fc3ebbad 100644 --- a/client/tests/test_step_canvas_transport.gd +++ b/client/tests/test_step_canvas_transport.gd @@ -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) diff --git a/client/tests/test_step_canvas_viewer.gd b/client/tests/test_step_canvas_viewer.gd index 63c93bce1..1fb355880 100644 --- a/client/tests/test_step_canvas_viewer.gd +++ b/client/tests/test_step_canvas_viewer.gd @@ -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) diff --git a/client/ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd b/client/ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd index 12f61b969..4f2337413 100644 --- a/client/ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd +++ b/client/ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd @@ -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 diff --git a/client/ui/implant/apps/atlas/step_canvas/step_canvas_viewer.gd b/client/ui/implant/apps/atlas/step_canvas/step_canvas_viewer.gd index e2eef7faf..601579d9e 100644 --- a/client/ui/implant/apps/atlas/step_canvas/step_canvas_viewer.gd +++ b/client/ui/implant/apps/atlas/step_canvas/step_canvas_viewer.gd @@ -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)