diff --git a/client/tests/test_step_canvas_annotation_layer.gd b/client/tests/test_step_canvas_annotation_layer.gd index cb9739f05..c664d6f9f 100644 --- a/client/tests/test_step_canvas_annotation_layer.gd +++ b/client/tests/test_step_canvas_annotation_layer.gd @@ -20,7 +20,7 @@ func test_set_frame_stores_the_frame_and_triggers_no_crash_on_draw() -> void: "courses": [], "settlement_id": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], } - layer.set_frame(canvas, Vector2(1000.0, 2000.0), "District", Vector2i(4, 4)) + layer.set_frame(canvas, Vector2(1000.0, 2000.0), "District", Vector2i(4, 4), 0.0) # No assertion beyond "did not crash" — set_frame()/queue_redraw() with a # well-formed empty-feature canvas is the baseline no-op path every # richer test below builds on. @@ -30,7 +30,7 @@ func test_set_frame_stores_the_frame_and_triggers_no_crash_on_draw() -> void: func test_clear_frame_drops_the_held_canvas() -> void: var layer: StepCanvasAnnotationLayer = auto_free(StepCanvasAnnotationLayer.new()) add_child(layer) - layer.set_frame({"width": 1, "height": 1, "courses": []}, Vector2.ZERO, "Chunk", Vector2i(1, 1)) + layer.set_frame({"width": 1, "height": 1, "courses": []}, Vector2.ZERO, "Chunk", Vector2i(1, 1), 0.0) layer.clear_frame() assert_that(layer._canvas).is_null() @@ -42,7 +42,7 @@ func test_clear_frame_drops_the_held_canvas() -> void: func test_cell_center_world_m_matches_the_servers_own_per_cell_placement() -> void: var layer: StepCanvasAnnotationLayer = auto_free(StepCanvasAnnotationLayer.new()) add_child(layer) - layer.set_frame({"width": 4, "height": 4, "courses": []}, Vector2(0.0, 0.0), "District", Vector2i(4, 4)) + layer.set_frame({"width": 4, "height": 4, "courses": []}, Vector2(0.0, 0.0), "District", Vector2i(4, 4), 0.0) # half_w = half_h = 2; spacing = 2048. Cell (0,0) -> (0-2)*2048 = -4096 on # both axes; cell (2,2) (the center-ish cell) -> (2-2)*2048 = 0. @@ -54,9 +54,15 @@ func test_cell_center_world_m_offsets_by_the_frames_world_center() -> void: var layer: StepCanvasAnnotationLayer = auto_free(StepCanvasAnnotationLayer.new()) add_child(layer) layer.set_frame( - {"width": 2, "height": 2, "courses": []}, Vector2(10_000.0, 20_000.0), "Chunk", Vector2i(2, 2) + {"width": 2, "height": 2, "courses": []}, + Vector2(10_000.0, 20_000.0), + "Chunk", + Vector2i(2, 2), + 0.0 ) - # half_w = half_h = 1; spacing = 64. Cell (1,1) -> center + (1-1)*64 = center. + # half_w = half_h = 1; spacing = CHUNK_M / short axis = 64/2 = 32 post- + # inversion. Cell (1,1) -> center + (1-1)*32 = center either way — this + # asserts the centre cell lands on the centre, not the pitch itself. assert_that(layer._cell_center_world_m(1, 1)).is_equal(Vector2(10_000.0, 20_000.0)) @@ -71,7 +77,7 @@ func test_world_to_local_uses_the_held_frame() -> void: layer.set_frame({"width": 32, "height": 32, "courses": []}, world_center, "Quarter", extent) var expected: Vector2 = StepCanvasTransport.world_m_to_canvas_local( - world_center, world_center, "Quarter", extent + world_center, world_center, "Quarter", extent, 0.0 ) assert_that(layer._world_to_local(world_center)).is_equal_approx(expected, Vector2(0.01, 0.01)) @@ -307,7 +313,7 @@ func test_is_true_source_in_canvas_true_for_an_interior_point() -> void: var layer: StepCanvasAnnotationLayer = auto_free(StepCanvasAnnotationLayer.new()) add_child(layer) # District spacing 2048m, extent 4x4 -> half-extent 4096m on each axis. - layer.set_frame({"width": 4, "height": 4, "courses": []}, Vector2(1000.0, 2000.0), "District", Vector2i(4, 4)) + layer.set_frame({"width": 4, "height": 4, "courses": []}, Vector2(1000.0, 2000.0), "District", Vector2i(4, 4), 0.0) assert_bool(layer._is_true_source_in_canvas(Vector2(1000.0, 2000.0))).is_true() @@ -317,7 +323,7 @@ func test_is_true_source_in_canvas_true_for_an_interior_point() -> void: func test_is_true_source_in_canvas_false_for_a_point_outside_the_bounds() -> void: var layer: StepCanvasAnnotationLayer = auto_free(StepCanvasAnnotationLayer.new()) add_child(layer) - layer.set_frame({"width": 4, "height": 4, "courses": []}, Vector2(1000.0, 2000.0), "District", Vector2i(4, 4)) + layer.set_frame({"width": 4, "height": 4, "courses": []}, Vector2(1000.0, 2000.0), "District", Vector2i(4, 4), 0.0) # Half-extent is 4096m; world center + 5000m on X is well outside. assert_bool(layer._is_true_source_in_canvas(Vector2(1000.0 + 5000.0, 2000.0))).is_false() @@ -327,7 +333,7 @@ func test_is_true_source_in_canvas_false_for_a_point_outside_the_bounds() -> voi func test_is_true_source_in_canvas_is_conservative_at_the_exact_boundary() -> void: var layer: StepCanvasAnnotationLayer = auto_free(StepCanvasAnnotationLayer.new()) add_child(layer) - layer.set_frame({"width": 4, "height": 4, "courses": []}, Vector2.ZERO, "District", Vector2i(4, 4)) + layer.set_frame({"width": 4, "height": 4, "courses": []}, Vector2.ZERO, "District", Vector2i(4, 4), 0.0) # Half-extent is 4096m exactly. A point AT the boundary (x=4096) is # within epsilon of the edge -> conservatively NOT a true source. assert_bool(layer._is_true_source_in_canvas(Vector2(4096.0, 0.0))).is_false() @@ -338,7 +344,7 @@ func test_is_true_source_in_canvas_is_conservative_at_the_exact_boundary() -> vo func test_is_true_source_in_canvas_false_for_null() -> void: var layer: StepCanvasAnnotationLayer = auto_free(StepCanvasAnnotationLayer.new()) add_child(layer) - layer.set_frame({"width": 4, "height": 4, "courses": []}, Vector2.ZERO, "District", Vector2i(4, 4)) + layer.set_frame({"width": 4, "height": 4, "courses": []}, Vector2.ZERO, "District", Vector2i(4, 4), 0.0) assert_bool(layer._is_true_source_in_canvas(null)).is_false() @@ -367,7 +373,7 @@ func test_set_frame_with_a_crop_passthrough_course_does_not_crash() -> void: # overhang case (finding 1). "courses": [{"class": 2, "points": [[-9000, 0], [0, 0], [10, 0]], "terminus": ""}], } - layer.set_frame(canvas, Vector2.ZERO, "District", Vector2i(4, 4)) + layer.set_frame(canvas, Vector2.ZERO, "District", Vector2i(4, 4), 0.0) assert_object(layer).is_not_null() @@ -381,5 +387,5 @@ func test_set_frame_with_an_interior_source_course_does_not_crash() -> void: "height": 4, "courses": [{"class": 2, "points": [[0, 0], [500, 0], [1000, 0], [1500, 0]], "terminus": "Mouth"}], } - layer.set_frame(canvas, Vector2.ZERO, "District", Vector2i(4, 4)) + layer.set_frame(canvas, Vector2.ZERO, "District", Vector2i(4, 4), 0.0) assert_object(layer).is_not_null() diff --git a/client/tests/test_step_canvas_transport.gd b/client/tests/test_step_canvas_transport.gd index c1e25b500..39ea7b507 100644 --- a/client/tests/test_step_canvas_transport.gd +++ b/client/tests/test_step_canvas_transport.gd @@ -7,6 +7,9 @@ extends GdUnitTestSuite const StepCanvasTransport := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd") +## GJ380c — the body this session eyeballed throughout. +const BODY_R_KM: float = 6_238.4 + # ============================================================================= # Rung ladder — index <-> name, scroll clamping @@ -58,17 +61,70 @@ func test_scroll_step_zero_direction_is_a_no_op() -> void: # ============================================================================= -# D-243 gridunit spacing — pinned against the same metre values scale.rs uses +# D-243 rung EXTENT — pinned against the same metre values scale.rs uses. +# Post-inversion (D-255 amendment, pair session 2026-07-26) the D-243 constant +# is the rung's CELL SIZE, not its gridunit spacing; spacing is derived below. # ============================================================================= +func test_rung_extent_matches_d243_metre_values() -> void: + assert_float(StepCanvasTransport.RUNG_EXTENT_M["Region"]).is_equal_approx(204_800.0, 0.01) + assert_float(StepCanvasTransport.RUNG_EXTENT_M["District"]).is_equal_approx(2_048.0, 0.01) + assert_float(StepCanvasTransport.RUNG_EXTENT_M["Quarter"]).is_equal_approx(512.0, 0.01) + assert_float(StepCanvasTransport.RUNG_EXTENT_M["Block"]).is_equal_approx(128.0, 0.01) + assert_float(StepCanvasTransport.RUNG_EXTENT_M["Chunk"]).is_equal_approx(64.0, 0.01) + # Global is deliberately absent — the elastic seam has no constant extent. + assert_bool(StepCanvasTransport.RUNG_EXTENT_M.has("Global")).is_false() -func test_spacing_for_rung_matches_d243_metre_values() -> void: - assert_float(StepCanvasTransport.spacing_for_rung("Global")).is_equal_approx(204_800.0, 0.01) - assert_float(StepCanvasTransport.spacing_for_rung("Region")).is_equal_approx(204_800.0, 0.01) - assert_float(StepCanvasTransport.spacing_for_rung("District")).is_equal_approx(2_048.0, 0.01) - assert_float(StepCanvasTransport.spacing_for_rung("Quarter")).is_equal_approx(512.0, 0.01) - assert_float(StepCanvasTransport.spacing_for_rung("Block")).is_equal_approx(128.0, 0.01) - assert_float(StepCanvasTransport.spacing_for_rung("Chunk")).is_equal_approx(64.0, 0.01) + +## The inversion's core contract: the SHORTER canvas axis spans exactly one +## cell of the rung's level, whatever the viewport shape. Must agree with the +## server's own StepCanvasRung::spacing_m() — both sides derive it from the +## same three inputs, so this test and its Rust twin pin one contract. +func test_shorter_axis_spans_exactly_one_rung_cell() -> void: + for rung in ["Region", "District", "Quarter", "Block", "Chunk"]: + var cell_m: float = StepCanvasTransport.RUNG_EXTENT_M[rung] + for extent in [Vector2i(960, 540), Vector2i(540, 960), Vector2i(700, 700)]: + var spacing: float = StepCanvasTransport.spacing_for_rung(rung, extent, BODY_R_KM) + var short: float = float(mini(extent.x, extent.y)) + assert_float(spacing * short).override_failure_message( + "%s at %s: short axis spans %f m, want %f" % [rung, extent, spacing * short, cell_m] + ).is_equal_approx(cell_m, 0.001) + + +## Spacing follows the canvas, not the rung — halving the cell count over the +## same rung doubles the pitch (a clamped canvas covers the same ground more +## coarsely; it does not cover less ground). +func test_spacing_scales_inversely_with_cell_count() -> void: + var fine: float = StepCanvasTransport.spacing_for_rung( + "District", Vector2i(960, 540), BODY_R_KM + ) + var coarse: float = StepCanvasTransport.spacing_for_rung( + "District", Vector2i(480, 270), BODY_R_KM + ) + assert_float(coarse).is_equal_approx(fine * 2.0, 0.001) + + +## Global is the one rung whose spacing comes from the body: the full 2*PI*R +## circumference wraps the canvas WIDTH. This is D-243's elastic seam, and the +## only place a body radius enters the ladder at all. +func test_global_spacing_is_circumference_over_width() -> void: + var extent := Vector2i(960, 480) + var spacing: float = StepCanvasTransport.spacing_for_rung("Global", extent, BODY_R_KM) + var circumference_m: float = TAU * BODY_R_KM * 1000.0 + assert_float(spacing * 960.0).is_equal_approx(circumference_m, 1.0) + # Twice the body, twice the pitch at the same cell count. + var double: float = StepCanvasTransport.spacing_for_rung("Global", extent, BODY_R_KM * 2.0) + assert_float(double).is_equal_approx(spacing * 2.0, 0.001) + + +## A degenerate canvas must not divide by zero — an infinity here would poison +## every world-metre computation downstream. +func test_zero_extent_does_not_divide_by_zero() -> void: + for rung in ["Global", "Chunk"]: + var spacing: float = StepCanvasTransport.spacing_for_rung(rung, Vector2i.ZERO, BODY_R_KM) + assert_bool(is_finite(spacing)).override_failure_message( + "%s produced %f" % [rung, spacing] + ).is_true() # ============================================================================= @@ -93,16 +149,25 @@ func test_display_ratio_deep_rungs_all_share_the_deep_ratio() -> void: assert_float(deep).is_greater(0.0) -func test_display_ratio_shallow_rungs_use_the_five_x_five_fallback() -> void: - for rung in ["Global", "Region"]: - assert_float(StepCanvasTransport.display_ratio_for_rung(rung)).is_equal_approx(5.0, 0.001) +## Only Global keeps the shallow fallback now — Region joined the deep band +## with the extent inversion (it is a real 262x466 km map, not an orbital +## envelope). +func test_only_global_uses_the_shallow_display_ratio() -> void: + assert_float(StepCanvasTransport.display_ratio_for_rung("Global")).is_equal_approx(5.0, 0.001) + assert_float(StepCanvasTransport.display_ratio_for_rung("Region")).is_equal_approx( + StepCanvasTransport.DISPLAY_RATIO_DEEP, 0.001 + ) -func test_is_orbital_rung_true_only_for_global_and_region() -> void: +## Region LEFT the orbital set with the extent inversion — it now rides the +## full courses-aware derive, which is most of why the top of the ladder used +## to read flat (no rivers at all above District). +func test_is_orbital_rung_true_only_for_global() -> void: assert_bool(StepCanvasTransport.is_orbital_rung("Global")).is_true() - assert_bool(StepCanvasTransport.is_orbital_rung("Region")).is_true() - assert_bool(StepCanvasTransport.is_orbital_rung("District")).is_false() - assert_bool(StepCanvasTransport.is_orbital_rung("Chunk")).is_false() + for rung in ["Region", "District", "Quarter", "Block", "Chunk"]: + assert_bool(StepCanvasTransport.is_orbital_rung(rung)).override_failure_message( + "%s must take the full derive" % rung + ).is_false() # ============================================================================= @@ -123,7 +188,7 @@ func test_viewport_fit_extent_at_deep_ratio_divides_by_the_deep_ratio() -> void: func test_viewport_fit_extent_at_shallow_ratio_divides_by_the_display_ratio() -> void: - var extent: Vector2i = StepCanvasTransport.viewport_fit_extent(Vector2(1000.0, 500.0), "Region") + var extent: Vector2i = StepCanvasTransport.viewport_fit_extent(Vector2(1000.0, 500.0), "Global") assert_that(extent).is_equal(Vector2i(200, 100)) @@ -146,17 +211,41 @@ func test_viewport_fit_extent_never_produces_a_zero_axis() -> void: # ============================================================================= -func test_snap_to_gridunit_snaps_to_the_rungs_own_spacing() -> void: - var snapped: Vector2i = StepCanvasTransport.snap_to_gridunit(Vector2(2100.0, -1000.0), "District") - # District spacing = 2048 m: 2100 rounds to 1*2048=2048, -1000 rounds to 0. +## Post-inversion the snap lattice is the CANVAS pitch, not a per-rung +## constant. A 1x1-cell District canvas puts the whole 2,048 m cell in one +## gridunit, so this pins the same arithmetic the old test did. +func test_snap_to_gridunit_snaps_to_the_canvas_pitch() -> void: + var one_cell := Vector2i(1, 1) # pitch == the rung's whole cell: 2,048 m + var snapped: Vector2i = StepCanvasTransport.snap_to_gridunit( + Vector2(2100.0, -1000.0), "District", one_cell, BODY_R_KM + ) assert_int(snapped.x).is_equal(2048) assert_int(snapped.y).is_equal(0) +## Sub-metre pitches must NOT collapse to the origin. The pre-inversion +## implementation multiplied by `int(spacing)`, which truncates to 0 once the +## pitch drops below 1 m — and at a real viewport Chunk's pitch is ~0.12 m, so +## every request centre would have snapped to (0,0) and the viewer would have +## silently panned to the equator on every step. Guards that regression. +func test_snap_to_gridunit_survives_sub_metre_pitch() -> void: + var extent := Vector2i(960, 540) # Chunk pitch here is ~0.119 m + var snapped: Vector2i = StepCanvasTransport.snap_to_gridunit( + Vector2(123_456.0, -7_890.0), "Chunk", extent, BODY_R_KM + ) + assert_int(snapped.x).is_equal(123_456) + assert_int(snapped.y).is_equal(-7_890) + + func test_snap_to_gridunit_is_idempotent_once_already_on_grid() -> void: - var once: Vector2i = StepCanvasTransport.snap_to_gridunit(Vector2(4096.0, 6144.0), "District") + var one_cell := Vector2i(1, 1) + var once: Vector2i = StepCanvasTransport.snap_to_gridunit( + Vector2(4096.0, 6144.0), "District", one_cell, BODY_R_KM + ) var world_again := Vector2(once.x, once.y) - var twice: Vector2i = StepCanvasTransport.snap_to_gridunit(world_again, "District") + var twice: Vector2i = StepCanvasTransport.snap_to_gridunit( + world_again, "District", one_cell, BODY_R_KM + ) assert_that(once).is_equal(twice) @@ -171,7 +260,7 @@ func test_world_m_to_canvas_local_centers_the_world_center_on_the_canvas_center( var rung := "District" var world_center := Vector2(10_000.0, 20_000.0) var local: Vector2 = StepCanvasTransport.world_m_to_canvas_local( - world_center, world_center, rung, extent + world_center, world_center, rung, extent, BODY_R_KM ) var expected_center: Vector2 = StepCanvasTransport.canvas_footprint_px(rung, extent) * 0.5 assert_that(local).is_equal_approx(expected_center, Vector2(0.01, 0.01)) @@ -190,81 +279,68 @@ func test_world_to_local_and_back_round_trips() -> void: var original_world := Vector2(51_200.0, -29_500.0) var local: Vector2 = StepCanvasTransport.world_m_to_canvas_local( - original_world, world_center, rung, extent + original_world, world_center, rung, extent, BODY_R_KM ) var recovered_world: Vector2 = StepCanvasTransport.canvas_local_to_world_m( - local, world_center, rung, extent + local, world_center, rung, extent, BODY_R_KM ) assert_that(recovered_world).is_equal_approx(original_world, Vector2(0.5, 0.5)) func test_canvas_footprint_px_is_extent_times_display_ratio() -> void: - var footprint: Vector2 = StepCanvasTransport.canvas_footprint_px("Region", Vector2i(100, 50)) + var footprint: Vector2 = StepCanvasTransport.canvas_footprint_px("Global", Vector2i(100, 50)) assert_that(footprint).is_equal(Vector2(500.0, 250.0)) # 5x5 shallow ratio -func test_half_extent_m_is_half_the_cell_count_times_spacing() -> void: - var half: float = StepCanvasTransport.half_extent_m("District", 64) - assert_float(half).is_equal_approx(64.0 * 0.5 * 2048.0, 0.01) +## Half-extent is now half the rung's own CELL on the short axis, whatever +## the cell count — that is the inversion restated as an invariant. +func test_half_extent_m_is_half_the_rung_cell() -> void: + for extent in [Vector2i(64, 64), Vector2i(960, 540), Vector2i(7, 3)]: + var half: float = StepCanvasTransport.half_extent_m("District", extent, BODY_R_KM) + assert_float(half).is_equal_approx(2048.0 * 0.5, 0.01) # ============================================================================= -# T-1189: extent cap to the body's own region grid — the sideways-repeat / -# pole-smear fix. The Global echo IS the cap (its canvas already equals the -# body's region grid, D-255(a): "the Global canvas IS the whole body at -# region spacing"), so no unit conversion is needed — Region shares Global's -# gridunit spacing exactly. +# Rung liveness — replaces the T-1189 extent cap, which the D-255 extent +# inversion superseded (pair session 2026-07-26). A canvas can no longer +# over-request a body: its cell count is viewport-driven and its GROUND extent +# is the rung's own cell size. The residual question is whether a rung's cell +# is bigger than the whole body, which is answered by OMITTING the rung rather +# than by serving a squashed canvas. # ============================================================================= -func test_cap_extent_to_body_clamps_region_to_the_global_echo() -> void: - # T-1183 eyeball: 384x216 requested at Region on GJ1c, whose Global echo - # is 177x88 — the requested extent overruns the body on both axes. - var capped: Vector2i = StepCanvasTransport.cap_extent_to_body( - Vector2i(384, 216), "Region", Vector2i(177, 88) +func test_cap_extent_to_body_is_now_a_no_op() -> void: + var extent := Vector2i(384, 216) + assert_that(StepCanvasTransport.cap_extent_to_body(extent, "Region", Vector2i(177, 88))).is_equal( + extent ) - assert_that(capped).is_equal(Vector2i(177, 88)) -func test_cap_extent_to_body_is_a_no_op_when_already_inside_the_grid() -> void: - var capped: Vector2i = StepCanvasTransport.cap_extent_to_body( - Vector2i(100, 40), "Region", Vector2i(177, 88) - ) - assert_that(capped).is_equal(Vector2i(100, 40)) +## All six rungs are live on every populated body in systems.db — the smallest +## is a 734 km-radius moon whose 4,611 km circumference swallows a Region cell +## seventeen times over. Pinned so a future rung resize that would silently +## kill a rung on inhabited worlds fails here first. +func test_every_rung_is_live_on_the_smallest_populated_body() -> void: + for rung in ["Global", "Region", "District", "Quarter", "Block", "Chunk"]: + assert_bool(StepCanvasTransport.is_rung_live_on_body(rung, 733.9)).override_failure_message( + "%s must stay live on the smallest populated body" % rung + ).is_true() -## Shape-generic per the ticket: the guard compares SPACING, not rung name, -## so it caps ANY rung sharing Global's spacing, not just a hardcoded -## "Region" check. District's spacing (2048 m) differs from Global's -## (204,800 m), so it must NEVER be capped by the body-grid cell count — -## capping cell counts across mismatched spacings would be a unit error. -func test_cap_extent_to_body_leaves_finer_rungs_uncapped() -> void: - var capped: Vector2i = StepCanvasTransport.cap_extent_to_body( - Vector2i(3000, 3000), "District", Vector2i(177, 88) - ) - assert_that(capped).is_equal(Vector2i(3000, 3000)) +## ...but a rung whose cell exceeds the body is not a step down the ladder at +## all — it would zoom OUT. systems.db has two such rocks (6 km and 11 km +## radius, both uninhabited). +func test_region_is_not_live_on_a_sub_region_body() -> void: + assert_bool(StepCanvasTransport.is_rung_live_on_body("Region", 6.0)).is_false() + assert_bool(StepCanvasTransport.is_rung_live_on_body("District", 6.0)).is_true() -## Cold-start fallback (T-1189, StepCanvasViewer's own documented choice): -## before any Global response has arrived, `_global_body_extent` is ZERO — -## cap_extent_to_body() must leave the request UNCAPPED on a non-positive -## axis (server clamps independently) rather than clamping to zero cells. -func test_cap_extent_to_body_uncapped_when_global_echo_not_yet_available() -> void: - var capped: Vector2i = StepCanvasTransport.cap_extent_to_body( - Vector2i(384, 216), "Region", Vector2i.ZERO - ) - assert_that(capped).is_equal(Vector2i(384, 216)) - - -## A mixed case: one axis of the Global echo has arrived-and-is-real, the -## other is still ZERO (shouldn't happen in practice since both arrive -## together, but the function must handle each axis independently rather -## than assuming both-or-neither). -func test_cap_extent_to_body_caps_only_the_positive_echo_axis() -> void: - var capped: Vector2i = StepCanvasTransport.cap_extent_to_body( - Vector2i(384, 216), "Region", Vector2i(177, 0) - ) - assert_that(capped).is_equal(Vector2i(177, 216)) +## An absent radius (an asteroid belt is not a sphere and has no +## equirectangular surface at all) must not silently collapse the ladder — +## the caller has a bigger problem than liveness and should see it. +func test_liveness_is_permissive_without_a_radius() -> void: + assert_bool(StepCanvasTransport.is_rung_live_on_body("Region", 0.0)).is_true() # ============================================================================= diff --git a/client/tests/test_step_canvas_viewer.gd b/client/tests/test_step_canvas_viewer.gd index 886c0c60f..b602db169 100644 --- a/client/tests/test_step_canvas_viewer.gd +++ b/client/tests/test_step_canvas_viewer.gd @@ -510,26 +510,43 @@ func test_global_canvas_arrival_populates_the_body_extent_cap_source() -> void: assert_that(v._global_body_extent).is_equal(GJ1C_GLOBAL_EXTENT) -## The T-1183 eyeball regression itself: once the Global echo has landed, -## scrolling to Region and firing its request must produce a CAPPED extent -## — never the raw viewport-fit 384x216 that overran the body on both axes. -func test_region_request_extent_is_capped_to_the_landed_global_extent() -> void: +## The T-1183 eyeball regression, restated for the post-inversion ladder +## (D-255 amendment, pair session 2026-07-26). It used to be fixed by CAPPING +## Region's extent to the body's region grid; the inversion removes the defect +## at its source instead. Region's cell count is now plain viewport-fit, and +## what bounds it to the body is its GROUND extent: the shorter viewport axis +## spans exactly one region, so the canvas cannot wrap the body however large +## the window is. Asserting the ground extent is the honest version of what +## the old cap was reaching for. +func test_region_request_covers_exactly_one_region_on_the_short_axis() -> void: var v: StepCanvasViewer = _make_viewer() add_child(v) v.size = Vector2(1920.0, 1080.0) # the T-1183 eyeball's own viewport v.enter({"body_id": "T1189_extent_letterbox_test_body", "body_radius_km": 6371.0}, {}) TestStepCanvasViewer._land_global_canvas(v, GJ1C_GLOBAL_EXTENT.x, GJ1C_GLOBAL_EXTENT.y) - v._scroll_rung(1, Vector2(960.0, 540.0)) # descend to Region — fires the capped request + v._scroll_rung(1, Vector2(960.0, 540.0)) # descend to Region var extent: Vector2i = v._request_extent() - assert_int(extent.x).override_failure_message( - "Region's requested extent must never exceed the body's own region-grid width" - ).is_less_equal(GJ1C_GLOBAL_EXTENT.x) - assert_int(extent.y).override_failure_message( - "Region's requested extent must never exceed the body's own region-grid height" - ).is_less_equal(GJ1C_GLOBAL_EXTENT.y) - assert_that(extent).is_equal(GJ1C_GLOBAL_EXTENT) # 1920x1080 viewport-fit exceeds 177x88 on both axes + # No longer capped to the body grid — the cell count is the window. + assert_that(extent).is_equal(StepCanvasTransport.viewport_fit_extent(v.size, "Region")) + + # ...and the ground it covers is one region across the short axis, which + # is what actually prevents the sideways-repeat / pole-smear defect. + var spacing: float = StepCanvasTransport.spacing_for_rung( + "Region", extent, v.get_body_radius_km() + ) + var short_axis_m: float = spacing * float(mini(extent.x, extent.y)) + assert_float(short_axis_m).override_failure_message( + "Region must span exactly one region cell on the short axis, got %f m" % short_axis_m + ).is_equal_approx(StepCanvasTransport.RUNG_EXTENT_M["Region"], 0.01) + + # The whole body is 40,030 km around; this canvas must be a small fraction + # of it, not a wrap-around. + var circumference_m: float = TAU * 6371.0 * 1000.0 + assert_bool(spacing * float(extent.x) < circumference_m).override_failure_message( + "a Region canvas must never span more ground than the body has" + ).is_true() ## Cold-start fallback (documented on StepCanvasViewer._request_extent()): @@ -554,13 +571,15 @@ func test_region_request_extent_is_uncapped_before_the_global_echo_lands() -> vo ).is_equal(uncapped) -## Cache-key discipline (T-1182/T-1183, ticket's own explicit call-out): the -## cap must be applied BEFORE _fire_request() builds the request, so the -## extent that becomes part of the cache key is the SAME capped value that -## gets served — a request for "the same spot" must always resolve to the -## same key, capped or not, never a key built from one extent and served -## under another. -func test_capped_extent_matches_what_the_request_actually_sends() -> void: +## Cache-key discipline (T-1182/T-1183, ticket's own explicit call-out), +## outliving the cap it was written for: the extent that becomes part of the +## cache key must be the SAME value the request actually carries — a request +## for "the same spot" must always resolve to the same key, never a key built +## from one extent and served under another. The inversion makes this MORE +## load-bearing, not less: the extent now also determines gridunit spacing, so +## a key/request divergence would mean a canvas served at the wrong scale +## rather than merely the wrong size. +func test_request_extent_matches_what_the_request_actually_sends() -> void: var v: StepCanvasViewer = _make_viewer() add_child(v) v.size = Vector2(1920.0, 1080.0) @@ -573,20 +592,22 @@ func test_capped_extent_matches_what_the_request_actually_sends() -> void: # idempotent and match what was actually requested (no separate, # divergent cap path). var extent_now: Vector2i = v._request_extent() - assert_that(extent_now).is_equal(GJ1C_GLOBAL_EXTENT) + assert_that(extent_now).is_equal(StepCanvasTransport.viewport_fit_extent(v.size, "Region")) # The Tier-1 cache key StepCanvasCache builds from this SAME extent must # be a real, findable key once a response for it lands — proving the - # capped extent (not the raw viewport-fit one) is what keys the cache. + # extent that keys the cache is the one the request carries. var req: Variant = v.get_request() - var center: Vector2i = StepCanvasTransport.snap_to_gridunit(v._world_center, "Region") - var canvas := TestStepCanvasViewer._synthetic_canvas(GJ1C_GLOBAL_EXTENT.x, GJ1C_GLOBAL_EXTENT.y) + var center: Vector2i = StepCanvasTransport.snap_to_gridunit( + v._world_center, "Region", extent_now, v.get_body_radius_km() + ) + var canvas := TestStepCanvasViewer._synthetic_canvas(extent_now.x, extent_now.y) req.get_cache().put("T1189_extent_letterbox_test_body", "Region", center, extent_now, canvas) assert_bool( - req.get_cache().has("T1189_extent_letterbox_test_body", "Region", center, GJ1C_GLOBAL_EXTENT, 0) + req.get_cache().has("T1189_extent_letterbox_test_body", "Region", center, extent_now, 0) ).override_failure_message( - "a cache entry stored under the CAPPED extent must be reachable" - + " under that same capped extent — key consistency" + "a cache entry stored under the REQUESTED extent must be reachable" + + " under that same extent — key consistency" ).is_true() diff --git a/client/ui/implant/apps/atlas/step_canvas/step_canvas_annotation_layer.gd b/client/ui/implant/apps/atlas/step_canvas/step_canvas_annotation_layer.gd index 1658d0e79..408f6d77c 100644 --- a/client/ui/implant/apps/atlas/step_canvas/step_canvas_annotation_layer.gd +++ b/client/ui/implant/apps/atlas/step_canvas/step_canvas_annotation_layer.gd @@ -162,19 +162,29 @@ var _canvas: Variant = null # decoded StepCanvasResponse.canvas — null until var _world_center: Vector2 = Vector2.ZERO var _rung: String = StepCanvasTransport.RUNG_DISTRICT var _extent_cells: Vector2i = Vector2i.ZERO +## Body radius (km) — the fourth frame input since the D-255 extent inversion +## (pair session 2026-07-26). Gridunit spacing is no longer a constant per +## rung; it is derived from the rung, the canvas cell count AND (at Global, +## the elastic seam) the body itself. All four therefore travel together. +var _body_radius_km: float = 0.0 -## Adopt a new canvas + its request frame (world center, rung, extent) — the -## world->screen projection for every drawn feature depends on all three, -## so they're set together, matching the terrain layer's own texture-plus- -## frame handoff. +## Adopt a new canvas + its request frame (world center, rung, extent, body +## radius) — the world->screen projection for every drawn feature depends on +## all four, so they're set together, matching the terrain layer's own +## texture-plus-frame handoff. func set_frame( - canvas: Variant, world_center: Vector2, rung: String, extent_cells: Vector2i + canvas: Variant, + world_center: Vector2, + rung: String, + extent_cells: Vector2i, + body_radius_km: float ) -> void: _canvas = canvas _world_center = world_center _rung = rung _extent_cells = extent_cells + _body_radius_km = body_radius_km queue_redraw() @@ -183,6 +193,13 @@ func clear_frame() -> void: queue_redraw() +## This frame's metres-per-gridunit — derived, never a per-rung constant +## (D-255 extent inversion). One accessor so every projection in this file +## reads the same number by construction. +func _spacing_m() -> float: + return StepCanvasTransport.spacing_for_rung(_rung, _extent_cells, _body_radius_km) + + func _draw() -> void: if not _canvas is Dictionary: return @@ -249,8 +266,8 @@ func _is_true_source_in_canvas(world_m: Variant) -> bool: if not world_m is Vector2: return false var p: Vector2 = world_m - var half_w_m: float = float(_extent_cells.x) * 0.5 * StepCanvasTransport.spacing_for_rung(_rung) - var half_h_m: float = float(_extent_cells.y) * 0.5 * StepCanvasTransport.spacing_for_rung(_rung) + var half_w_m: float = float(_extent_cells.x) * 0.5 * _spacing_m() + var half_h_m: float = float(_extent_cells.y) * 0.5 * _spacing_m() var lo_x: float = _world_center.x - half_w_m + CROP_EDGE_EPSILON_M var hi_x: float = _world_center.x + half_w_m - CROP_EDGE_EPSILON_M var lo_y: float = _world_center.y - half_h_m + CROP_EDGE_EPSILON_M @@ -513,7 +530,7 @@ func _draw_settlements(canvas: Dictionary) -> void: ## (`center_world_m + (col - half_w) * step_m`), mirrored client-side so a ## settlement marker lands on the exact cell its id was read from. func _cell_center_world_m(col: int, row: int) -> Vector2: - var spacing: float = StepCanvasTransport.spacing_for_rung(_rung) + var spacing: float = _spacing_m() var half_w: float = float(_extent_cells.x) * 0.5 var half_h: float = float(_extent_cells.y) * 0.5 return Vector2( @@ -523,4 +540,6 @@ func _cell_center_world_m(col: int, row: int) -> Vector2: func _world_to_local(world_m: Vector2) -> Vector2: - return StepCanvasTransport.world_m_to_canvas_local(world_m, _world_center, _rung, _extent_cells) + return StepCanvasTransport.world_m_to_canvas_local( + world_m, _world_center, _rung, _extent_cells, _body_radius_km + ) diff --git a/client/ui/implant/apps/atlas/step_canvas/step_canvas_legend.gd b/client/ui/implant/apps/atlas/step_canvas/step_canvas_legend.gd index 5b6bd109c..bab168015 100644 --- a/client/ui/implant/apps/atlas/step_canvas/step_canvas_legend.gd +++ b/client/ui/implant/apps/atlas/step_canvas/step_canvas_legend.gd @@ -75,7 +75,9 @@ func refresh() -> void: clear() visible = true - var spacing_km: float = StepCanvasTransport.spacing_for_rung(_viewer.get_held_rung()) / 1000.0 + var spacing_km: float = StepCanvasTransport.spacing_for_rung( + _viewer.get_held_rung(), _viewer.get_held_extent(), _viewer.get_body_radius_km() + ) / 1000.0 var subtitle: String = "%s · %.3f km/gridunit" % [_viewer.get_held_rung().to_lower(), spacing_km] add_component(ImplantHeader.new("ATLAS LEGEND", subtitle)) add_component(ImplantSeparator.new()) 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 84e9aab8b..fafa77774 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 @@ -29,18 +29,28 @@ const RUNG_LADDER: Array = [ RUNG_GLOBAL, RUNG_REGION, RUNG_DISTRICT, RUNG_QUARTER, RUNG_BLOCK, RUNG_CHUNK ] -## D-243 gridunit spacing in metres for every FIXED rung — mirrors +## D-243 world-metre CELL SIZE for every FIXED rung — mirrors ## server/src/atlas/scale.rs's own constants exactly (REGION_M/DISTRICT_M/ -## QUARTER_M/BLOCK_M/CHUNK_M), so the client's rung table can never silently -## drift from the wire contract it's choosing between. Global has no single -## spacing value in the fixed sense (D-255(a): "a Global gridunit and a -## Region gridunit are both 'one region' wide" — step_canvas.rs's own -## StepCanvasRung::spacing_m() returns REGION_M for Global too, "a -## harmless-but-correct value... so ordering/comparison call sites... get a -## sane, documented number rather than 0 or a panic") — mirrored here for the -## same reason. -const RUNG_SPACING_M: Dictionary = { - RUNG_GLOBAL: 204_800.0, +## QUARTER_M/BLOCK_M/CHUNK_M) and must equal StepCanvasRung::extent_m() +## server-side. +## +## **These are extents, not spacings** (D-255 amendment, pair session +## 2026-07-26). The original ladder had the relation the other way round: a +## rung fixed the gridunit SPACING and the canvas extent fell out of +## `spacing x cell count`. That is what made the top of the ladder unusable — +## at REGION_M spacing a viewport-sized canvas spanned ~251,658 km, six times +## around a rocky body, so the Region rung capped to the body and redrew the +## Global picture pixel-for-pixel ("global and region look the same"). Now the +## rung fixes the EXTENT and the spacing falls out of the canvas size +## (spacing_for_rung()), so every rung shows exactly the ground its name +## promises. +## +## Global is deliberately ABSENT: it is D-243's elastic seam, the one rung +## whose extent is the body itself and therefore cannot be a constant. A +## missing key is the client's equivalent of the server's `Option::None` — +## spacing_for_rung() branches on it rather than on a rung name, so the two +## sides express the same fact the same way. +const RUNG_EXTENT_M: Dictionary = { RUNG_REGION: 204_800.0, RUNG_DISTRICT: 2_048.0, RUNG_QUARTER: 512.0, @@ -75,9 +85,16 @@ const DISPLAY_RATIO_SHALLOW: float = 5.0 ## (D-255(a): "the deep, ground-level steps where the player is closest to ## visible detail"). Chunk's own "1 screen px per 64 m gridunit, no ## magnification margin" bottom-out rule (D-255(a)) is exactly DISPLAY_RATIO_DEEP. +## D-255 amendment 2026-07-26: **Region moved to the deep ratio.** The shallow +## fallback earned its keep only while a Region canvas was an orbital-scale +## picture where extent, not per-cell fidelity, was what grew. Post-inversion +## Region spans 262x466 km — a provincial map with real terrain in it — so it +## takes the same crispness as every rung below it. Jeroen's brief for this +## session in one line: "the global and region maps need to look the same as +## the smaller level maps, but with obviously a different zoom level." const DISPLAY_RATIO_BY_RUNG: Dictionary = { RUNG_GLOBAL: DISPLAY_RATIO_SHALLOW, - RUNG_REGION: DISPLAY_RATIO_SHALLOW, + RUNG_REGION: DISPLAY_RATIO_DEEP, RUNG_DISTRICT: DISPLAY_RATIO_DEEP, RUNG_QUARTER: DISPLAY_RATIO_DEEP, RUNG_BLOCK: DISPLAY_RATIO_DEEP, @@ -133,30 +150,60 @@ static func scroll_step(current_index: int, direction: int) -> int: return clampi(current_index + delta, 0, RUNG_LADDER.size() - 1) -static func spacing_for_rung(rung: String) -> float: - return float(RUNG_SPACING_M.get(rung, RUNG_SPACING_M[RUNG_DISTRICT])) +## Metres per gridunit for a canvas of `extent_cells` cells on a body of +## `body_radius_km` — a function of the REQUEST, not a per-rung constant +## (D-255 amendment 2026-07-26, see RUNG_EXTENT_M). Must agree exactly with +## StepCanvasRung::spacing_m() server-side; both sides compute it from the +## same three inputs rather than one telling the other, so there is nothing +## to keep in sync beyond the constant table itself. +## +## Fixed rungs: the SHORTER canvas axis spans exactly one cell of this level, +## so a widescreen viewport shows proportionally more ground on the long axis +## rather than less on the short one (Jeroen's rule: "the smallest viewport +## axis locks the area for calculation, since that is most widescreen +## friendly"). +## +## Global: equirectangular whole body — the full 2*PI*R circumference wraps +## the canvas WIDTH, the 2:1 cell counts keeping cells square (height spans +## PI*R, pole to pole). +## +## Callers pass the ECHOED extent, never the requested one — the server +## clamps independently, and a clamped canvas covers the same ground at a +## coarser pitch (the viewer's existing "read the echoed extent" discipline, +## now load-bearing for world geometry and not just for drawing). +static func spacing_for_rung(rung: String, extent_cells: Vector2i, body_radius_km: float) -> float: + var extent_m: float = float(RUNG_EXTENT_M.get(rung, 0.0)) + if extent_m > 0.0: + return extent_m / float(maxi(1, mini(extent_cells.x, extent_cells.y))) + return TAU * body_radius_km * 1000.0 / float(maxi(1, extent_cells.x)) static func display_ratio_for_rung(rung: String) -> float: return float(DISPLAY_RATIO_BY_RUNG.get(rung, DISPLAY_RATIO_DEEP)) -## True for the two rungs that ride derive_orbital_at_metres server-side -## (Global/Region, step_canvas.rs's own StepCanvasRung::uses_orbital_derive()) -## — mirrored here purely for READABILITY at call sites that branch on it -## (e.g. "does this rung's canvas ever carry courses" — Global/Region never -## do, matching invent_courses_for_canvas()'s own early return), not because -## the client makes any derivation decision itself (D-255(e): derivation -## stays server-side, full stop). +## True for the rung that rides derive_orbital_at_metres server-side +## (step_canvas.rs's own StepCanvasRung::uses_orbital_derive()) — mirrored +## here purely for READABILITY at call sites that branch on it (e.g. "does +## this rung's canvas ever carry courses"), not because the client makes any +## derivation decision itself (D-255(e): derivation stays server-side). +## +## D-255 amendment 2026-07-26: **Region left this set.** Pre-inversion a +## Region canvas spanned ~251,658 km — orbital envelope-only derivation was +## the only sane treatment at that scale. Post-inversion it spans 262x466 km, +## a genuine provincial map, so it takes the full courses-aware derive like +## every other fixed rung. Region having no rivers was a large part of why +## the top of the ladder read flat. static func is_orbital_rung(rung: String) -> bool: - return rung == RUNG_GLOBAL or rung == RUNG_REGION + return rung == RUNG_GLOBAL ## World-metre HALF-EXTENT (radius from center to edge) a fixed-rung canvas ## of `extent_cells` x `extent_cells` covers, given the rung's own gridunit ## spacing — the request-sizing half of the cursor-anchored step math. -static func half_extent_m(rung: String, extent_cells: int) -> float: - return float(extent_cells) * 0.5 * spacing_for_rung(rung) +static func half_extent_m(rung: String, extent_cells: Vector2i, body_radius_km: float) -> float: + var spacing: float = spacing_for_rung(rung, extent_cells, body_radius_km) + return float(mini(extent_cells.x, extent_cells.y)) * 0.5 * spacing ## Cursor-anchored step center (D-255(a)/(e), the workshop's own "the center @@ -168,9 +215,13 @@ static func half_extent_m(rung: String, extent_cells: int) -> float: ## world-metre point under the cursor. This is the point the NEXT step's ## request should center on — computed once per scroll notch, not per frame. static func canvas_local_to_world_m( - canvas_local: Vector2, world_center: Vector2, rung: String, extent_cells: Vector2i + canvas_local: Vector2, + world_center: Vector2, + rung: String, + extent_cells: Vector2i, + body_radius_km: float ) -> Vector2: - var spacing: float = spacing_for_rung(rung) + var spacing: float = spacing_for_rung(rung, extent_cells, body_radius_km) var ratio: float = display_ratio_for_rung(rung) var px_per_gridunit: float = maxf(ratio, 0.0001) var half_w_m: float = float(extent_cells.x) * 0.5 * spacing @@ -187,9 +238,13 @@ static func canvas_local_to_world_m( ## display-time scale, this is that same linear map applied to a point ## rather than a texture). static func world_m_to_canvas_local( - world_m: Vector2, world_center: Vector2, rung: String, extent_cells: Vector2i + world_m: Vector2, + world_center: Vector2, + rung: String, + extent_cells: Vector2i, + body_radius_km: float ) -> Vector2: - var spacing: float = spacing_for_rung(rung) + var spacing: float = spacing_for_rung(rung, extent_cells, body_radius_km) var ratio: float = display_ratio_for_rung(rung) var px_per_gridunit: float = maxf(ratio, 0.0001) var half_w_m: float = float(extent_cells.x) * 0.5 * spacing @@ -302,14 +357,45 @@ static func viewport_fit_extent(viewport_px: Vector2, rung: String) -> Vector2i: ## StepCanvasViewer's own cold-start fallback doc) leaves that axis ## uncapped, matching the ticket's "request uncapped and let the server ## clamp" fallback choice. +## SUPERSEDED by the D-255 extent inversion (pair session 2026-07-26) — now a +## documented no-op, pending Jeroen's sign-off to delete outright. +## +## Its premise was that a rung sharing Global's gridunit spacing would request +## more of the body than exists (the "continent repeats sideways / rows smear +## past the pole" defect on GJ1c). Post-inversion a canvas's cell count is +## purely viewport-driven and its GROUND extent is the rung's own cell size, +## so no rung can over-request a body by construction. The residual question — +## whether a rung's cell is larger than the whole body — is not a cap but a +## LIVENESS question, and it is answered by is_rung_live_on_body() below, +## because the right response is to omit the rung from the ladder rather than +## to serve a squashed canvas. static func cap_extent_to_body( - extent: Vector2i, rung: String, global_extent: Vector2i + extent: Vector2i, _rung: String, _global_extent: Vector2i ) -> Vector2i: - if not is_equal_approx(spacing_for_rung(rung), spacing_for_rung(RUNG_GLOBAL)): - return extent - var w: int = extent.x if global_extent.x <= 0 else mini(extent.x, global_extent.x) - var h: int = extent.y if global_extent.y <= 0 else mini(extent.y, global_extent.y) - return Vector2i(w, h) + return extent + + +## Is `rung` a meaningful step on this body? A rung whose cell is larger than +## the body itself would zoom OUT rather than in, so it is omitted from the +## ladder for that body — Jeroen's rule that the scroll walks the D-243 stair +## "only omitting ones, but not inventing new rungs". +## +## Empirically this never fires on inhabited content: the smallest populated +## body is a 734 km-radius moon whose 4,611 km circumference swallows a Region +## cell seventeen times over, so all six rungs are live on all 271 populated +## bodies with a radius. It fires only on uninhabited rocks (the two sub-region +## bodies in systems.db are 6 km and 11 km radius). Kept as a guard precisely +## because it is cheap and the failure it prevents is silent. +## +## A non-positive radius (the elastic seam has no input — e.g. an asteroid +## belt, which is not a sphere and has no equirectangular surface at all) +## leaves every rung live: the caller has bigger problems than ladder +## liveness, and silently collapsing the ladder would hide them. +static func is_rung_live_on_body(rung: String, body_radius_km: float) -> bool: + var extent_m: float = float(RUNG_EXTENT_M.get(rung, 0.0)) + if extent_m <= 0.0 or body_radius_km <= 0.0: + return true + return extent_m < TAU * body_radius_km * 1000.0 ## WASD + arrow keys, read via Input.is_key_pressed() on the PHYSICAL keycode @@ -340,10 +426,17 @@ static func held_pan_direction() -> Vector2: ## hits are the common case worth protecting). Global ignores center ## entirely server-side (step_canvas_protocol.gd's own doc) so snapping is a ## harmless no-op there. -static func snap_to_gridunit(world_m: Vector2, rung: String) -> Vector2i: - var spacing: float = spacing_for_rung(rung) - if spacing <= 0.0: +static func snap_to_gridunit( + world_m: Vector2, rung: String, extent_cells: Vector2i, body_radius_km: float +) -> Vector2i: + var spacing: float = spacing_for_rung(rung, extent_cells, body_radius_km) + # Post-inversion the pitch at the deep rungs is sub-metre (Chunk is ~0.12 m + # at a 1080-px short axis), so the old `int(spacing)` multiplier truncated + # to ZERO and collapsed every centre onto the origin. Snap in whole metres + # instead — the request centre is an integer-metre wire field (i64) and a + # metre is already finer than any rung's pitch is meaningful at. + if spacing < 1.0: return Vector2i(int(round(world_m.x)), int(round(world_m.y))) return Vector2i( - int(round(world_m.x / spacing)) * int(spacing), int(round(world_m.y / spacing)) * int(spacing) + int(round(world_m.x / spacing) * spacing), int(round(world_m.y / spacing) * spacing) ) 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 f238673b2..1218465f0 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 @@ -331,10 +331,27 @@ func get_body_id() -> String: return _dict_str(_body, "body_id", "") +## Body radius in km — the elastic-seam input Global's gridunit spacing is +## derived from (D-255 extent inversion). Already present in the `body` +## dictionary `enter()` receives; surfaced here so the transport, the legend +## and the annotation layer all read it from one place. +func get_body_radius_km() -> float: + return float(_body.get("body_radius_km", 0.0)) + + func get_held_rung() -> String: return _held_rung +## The ECHOED cell extent of the canvas currently on screen — not the +## requested one. Since the D-255 extent inversion, gridunit spacing is +## derived from this (spacing = rung extent / shorter axis), so any consumer +## reporting or projecting in metres must read the extent the server actually +## returned, never the one we asked for. +func get_held_extent() -> Vector2i: + return _held_extent + + ## T-1183 test seam: exposes the owned StepCanvasRequest (and, through it, ## get_disk_cache()) so sweep-trigger wiring is directly testable, matching ## the get_cache()/get_disk_cache() accessor pattern StepCanvasRequest @@ -438,7 +455,9 @@ static func _count_distinct_settlements(canvas: Dictionary) -> int: ## (ignored server-side, per step_canvas_protocol.gd's own doc). func _fire_request() -> void: var extent: Vector2i = _request_extent() - var center: Vector2i = StepCanvasTransport.snap_to_gridunit(_world_center, _held_rung) + var center: Vector2i = StepCanvasTransport.snap_to_gridunit( + _world_center, _held_rung, extent, get_body_radius_km() + ) _request.request_now(get_body_id(), _held_rung, center, extent) @@ -520,7 +539,11 @@ func _pan_drift_fraction() -> float: func _refloat_now() -> void: var screen_center: Vector2 = get_rect().size * 0.5 _world_center = StepCanvasTransport.canvas_local_to_world_m( - screen_center - _view_offset, _world_center, _held_rung, _held_extent + screen_center - _view_offset, + _world_center, + _held_rung, + _held_extent, + get_body_radius_km() ) _view_offset = Vector2.ZERO _fire_request() @@ -549,7 +572,9 @@ func _on_canvas_ready(canvas: Dictionary) -> void: _global_body_extent = _held_extent _current_canvas_data = canvas _rebuild_terrain_texture(canvas) - _annotation_layer.set_frame(canvas, _world_center, _held_rung, _held_extent) + _annotation_layer.set_frame( + canvas, _world_center, _held_rung, _held_extent, get_body_radius_km() + ) _recompute_canvas_transform() _refresh_screen_header() if _legend_panel: @@ -655,7 +680,11 @@ func _scroll_rung(direction: int, cursor_local: Vector2) -> void: _reset_to_global() return var cursor_world: Vector2 = StepCanvasTransport.canvas_local_to_world_m( - cursor_local - _view_offset, _world_center, _held_rung, _held_extent + cursor_local - _view_offset, + _world_center, + _held_rung, + _held_extent, + get_body_radius_km() ) _rung_index = new_index _held_rung = StepCanvasTransport.rung_at_index(_rung_index) @@ -883,7 +912,9 @@ func _refresh_screen_header() -> void: if _screen_header == null: return var name_label: String = _dict_str(_body, "proper_name", _dict_str(_body, "body_id", "—")) - var spacing_km: float = StepCanvasTransport.spacing_for_rung(_held_rung) / 1000.0 + var spacing_km: float = StepCanvasTransport.spacing_for_rung( + _held_rung, _held_extent, get_body_radius_km() + ) / 1000.0 var title := "ATLAS — %s" % name_label.to_upper() var subtitle := "%s · %.3f km/gridunit" % [_held_rung.to_upper(), spacing_km] _screen_header.set_content(title, subtitle) diff --git a/server/src/atlas/step_canvas.rs b/server/src/atlas/step_canvas.rs index 363a24cbc..4bea1afba 100644 --- a/server/src/atlas/step_canvas.rs +++ b/server/src/atlas/step_canvas.rs @@ -91,23 +91,58 @@ pub enum StepCanvasRung { } impl StepCanvasRung { - /// Cell spacing in metres for every FIXED rung — sourced from `scale::` - /// (D-243), never a magic number (mirrors `WindowGranularity::spacing_m`'s - /// discipline). [`Self::Global`] has no single spacing value (its - /// gridunit is "one region", not a metre float) — callers needing - /// Global's cell pitch use [`Self::global_cell_counts`] instead; this - /// method still returns `REGION_M` for `Global` as a harmless-but-correct - /// value (a Global gridunit and a Region gridunit are both "one region" - /// wide) so ordering/comparison call sites that don't special-case - /// `Global` still get a sane, documented number rather than 0 or a panic. - pub fn spacing_m(self) -> f64 { + /// The rung's own world-metre **cell size** — sourced from `scale::` + /// (D-243), never a magic number. `None` for [`Self::Global`], the sole + /// elastic rung, whose extent is the body itself and therefore cannot be + /// a constant (D-243's elastic seam). + /// + /// **This is an extent, not a spacing** (D-255 amendment, pair session + /// 2026-07-26). The original ladder had it the other way round: a rung + /// fixed the gridunit *spacing* and the canvas extent fell out of + /// `spacing × cell count`. That inversion is what made the top of the + /// ladder unusable — at `REGION_M` spacing a viewport-sized canvas spanned + /// ~251,658 km, six times around a rocky body, so the Region rung capped + /// to the body and redrew the Global picture pixel-for-pixel. Now the rung + /// fixes the extent and the spacing falls out of the canvas size + /// ([`Self::spacing_m`]), so every rung shows exactly the ground its name + /// promises and the scroll walks the D-243 stair honestly. + pub fn extent_m(self) -> Option { match self { - StepCanvasRung::Global => scale::REGION_M as f64, - StepCanvasRung::Region => scale::REGION_M as f64, - StepCanvasRung::District => scale::DISTRICT_M as f64, - StepCanvasRung::Quarter => scale::QUARTER_M as f64, - StepCanvasRung::Block => scale::BLOCK_M as f64, - StepCanvasRung::Chunk => scale::CHUNK_M as f64, + StepCanvasRung::Global => None, + StepCanvasRung::Region => Some(scale::REGION_M as f64), + StepCanvasRung::District => Some(scale::DISTRICT_M as f64), + StepCanvasRung::Quarter => Some(scale::QUARTER_M as f64), + StepCanvasRung::Block => Some(scale::BLOCK_M as f64), + StepCanvasRung::Chunk => Some(scale::CHUNK_M as f64), + } + } + + /// Metres per gridunit for a canvas of `width × height` cells on a body of + /// `body_radius_km` — a function of the **request**, not a per-rung + /// constant (D-255 amendment 2026-07-26, see [`Self::extent_m`]). + /// + /// Fixed rungs: the **shorter** canvas axis spans exactly one cell of this + /// level, so a widescreen viewport shows proportionally more ground on the + /// long axis rather than less on the short one (Jeroen's rule: "the + /// smallest viewport axis locks the area for calculation, since that is + /// most widescreen friendly"). A consequence worth knowing: the canvas + /// cell count is viewport-driven and identical at every rung, so derive + /// cost no longer varies with depth. + /// + /// [`Self::Global`]: equirectangular whole body — the full `2πR` + /// circumference wraps the canvas **width**, and the 2:1 cell counts + /// [`Self::global_cell_counts`] produces keep the cells square (the height + /// spans `πR`, pole to pole). Callers pass the RESOLVED canvas dimensions + /// ([`resolve_canvas_extent`]), never the requested ones — a clamped + /// canvas has a coarser spacing over the same ground, and the derive must + /// use what it actually got. + pub fn spacing_m(self, width: u32, height: u32, body_radius_km: f64) -> f64 { + match self.extent_m() { + Some(extent_m) => extent_m / width.min(height).max(1) as f64, + None => { + let circumference_m = 2.0 * std::f64::consts::PI * body_radius_km * 1_000.0; + circumference_m / width.max(1) as f64 + } } } @@ -358,75 +393,17 @@ pub struct StepCanvasResponse { } // --------------------------------------------------------------------------- -// Station-spacing cap for courses (S2 addendum decision — see module doc) +// (Station-spacing cap removed — D-255 extent inversion, pair session +// 2026-07-26. `COURSE_STATION_SPACING_FLOOR_M` / `course_station_spacing_m` +// floored course resampling at DISTRICT_M to guard an O(1/spacing) blowup that +// the inversion makes structurally impossible: the canvas cell count is now +// viewport-driven and identical at every rung, so stations-per-course is +// bounded at ~one per gridunit however deep the rung. Post-inversion the floor +// would do active harm — a District canvas spans ~3.6 km, so a 2,048 m pitch +// put two stations across the whole view and drew every river as a straight +// line. Station placement gets its own generator pass (Jeroen, same session).) // --------------------------------------------------------------------------- -/// Station-spacing floor for river-course invention at step-canvas rungs -/// (T-1178/T-1154 S2 addendum measurement): `near_perennial_water`'s cost is -/// `O(courses × points-per-course)`, and `invent_course` resamples each -/// course's control polyline at the RUNG's own spacing — so a course gets -/// proportionally MORE points the finer the rung, independent of whether -/// that extra density serves the "is this cell near a river" riparian test -/// at all. Measured cost: **+84.7% to +87.4% at Chunk (64 m stations, -/// 1,732 pts/course), +37.6% to +51.8% at Block (128 m stations, 867 -/// pts/course)** — both a real, structural cost increase (not the <5% -/// District-spacing figure). -/// -/// **DECISION (T-1181 implementation, against the S2 numbers): ADOPT the -/// cap.** Rationale: -/// - The riparian test only needs "is this cell within the riparian band of -/// a course," never full display-fidelity course geometry — station count -/// beyond what the fixed riparian-band width already resolves is pure -/// waste for that purpose. -/// - +85-87% at Chunk is a real cost more than doubling `near_perennial_ -/// water`'s share of the per-cell budget at the ladder's deepest, -/// most-frequently-panned rung — exactly where the response needs to -/// stay snappy (D-255(a): Chunk is the rung "closest to the player," -/// 1×1 fidelity, the display band Stig's ⑥ measurement prioritizes). -/// Paying it for zero riparian-accuracy benefit is not a tradeoff worth -/// taking when a cap is a one-line `.max()` with no behavior change to -/// what the client actually sees (courses still draw at full Stage-B -/// fidelity in `RiverCourse.points` — the cap only floors the STATION -/// RESAMPLING spacing used internally by `near_perennial_water`'s cost -/// driver, not the wire polyline itself... **correction, see below.**) -/// -/// **Where the cap is actually applied — sparse feature invention, not the -/// per-cell riparian test.** Reading `invent_course`'s signature -/// (`river_course.rs`): `station_spacing_m` is a SINGLE parameter that -/// drives BOTH the wire polyline's resample density (Ruling 3b: "Stage B -/// places stations at this spacing along global arc-length") AND the -/// riparian-test cost (more stations = more `near_perennial_water` distance -/// checks per course). There is no separate "riparian-only" spacing knob in -/// the current `river_course` API — capping the ONE spacing value the -/// step-canvas call site passes therefore caps both together, which is the -/// right shape for the mandatory acceptance gate below: courses are content -/// of the wire payload (Tyre round-2 §(a) "one flat tagged response"), so a -/// deliberately coarser polyline at Chunk/Block is a real, visible display -/// choice, not a hidden internal optimization — documented here as exactly -/// that. -/// -/// **The floor value: [`scale::DISTRICT_M`] (2,048 m).** District is the -/// rung where T-1178/T-1154's OWN measurement found courses cost <5% -/// (negligible, not staggered — Cross-check 1: "195.0 ns/cell — within 2% of -/// the synthetic fixture's 192.0 ns/cell"). Never resampling finer than -/// District's own station spacing means every fixed sub-District rung -/// (Quarter/Block/Chunk) inherits that same negligible-cost band instead of -/// paying the inverse-spacing S2 penalty, while District and Region (both -/// already ≥ this floor) are completely unaffected — `.max(DISTRICT_M)` is a -/// no-op for them by construction. Courses still refine in POSITION/shape -/// per rung (a different edge set intersects a Chunk-sized window than a -/// District-sized one — the cull is unaffected), only the per-course -/// point-DENSITY stops increasing below District's own spacing. -pub const COURSE_STATION_SPACING_FLOOR_M: f64 = scale::DISTRICT_M as f64; - -/// Apply the [`COURSE_STATION_SPACING_FLOOR_M`] cap to a rung's own spacing — -/// the single call site every step-canvas course-invention path routes -/// through (mirrors `layer_proxy::window_world_rect`'s "shared by both -/// consumers so it can never drift" discipline). -pub fn course_station_spacing_m(rung: StepCanvasRung) -> f64 { - rung.spacing_m().max(COURSE_STATION_SPACING_FLOOR_M) -} - // --------------------------------------------------------------------------- // Quantization (mirrors layer_proxy::quantize_min_wl_m's discipline) // --------------------------------------------------------------------------- @@ -667,12 +644,24 @@ fn invent_courses_for_canvas( river_network: &RiverNetwork, canvas_rect: (f64, f64, f64, f64), rung: StepCanvasRung, + step_m: f64, min_wavelength_m: f64, ) -> Vec { if rung.uses_orbital_derive() { return Vec::new(); } - let station_spacing_m = course_station_spacing_m(rung); + // One station per gridunit — the finest density this canvas can draw. + // + // The former absolute floor ([`COURSE_STATION_SPACING_FLOOR_M`], 2,048 m) + // guarded an O(1/spacing) blowup that the D-255 extent inversion (pair + // session 2026-07-26) made structurally impossible: the canvas cell count + // is now viewport-driven and IDENTICAL at every rung, so a course crossing + // it has at most ~one station per gridunit no matter how deep the rung. + // Keeping the absolute floor would now do active harm in the opposite + // direction — a District canvas spans ~3.6 km post-inversion, so a 2,048 m + // station pitch would place two stations across the whole view and render + // every river as a straight line. + let station_spacing_m = step_m; let (win_x0, win_y0, win_x1, win_y1) = canvas_rect; let edges = river_course::build_edges(river_network); @@ -938,7 +927,9 @@ pub fn build_step_canvas( let body_radius_km = params.body_radius_km.unwrap_or(0.0); let (width, height) = resolve_canvas_extent(rung, extent, body_radius_km); let min_wavelength_m = min_wl_m as f64; - let step_m = rung.spacing_m(); + // RESOLVED dims, not the requested `extent` — a clamped canvas covers the + // same ground at a coarser pitch (see StepCanvasRung::spacing_m). + let step_m = rung.spacing_m(width, height, body_radius_km); let cells = (width * height) as usize; let half_w = (width / 2) as i32; @@ -972,6 +963,7 @@ pub fn build_step_canvas( river_network, canvas_rect, rung, + step_m, min_wavelength_m, ) }; @@ -1640,16 +1632,95 @@ mod tests { // Rung vocabulary // ----------------------------------------------------------------- + /// A fixed rung's EXTENT is the D-243 constant (D-255 amendment, pair + /// session 2026-07-26) — the inversion moved the constant from the + /// spacing side of the relation to the extent side. Global has no + /// constant extent at all: it is the elastic seam. #[test] - fn spacing_m_matches_d243_constants() { - assert_eq!(StepCanvasRung::Region.spacing_m(), scale::REGION_M as f64); + fn extent_m_matches_d243_constants() { assert_eq!( - StepCanvasRung::District.spacing_m(), - scale::DISTRICT_M as f64 + StepCanvasRung::Region.extent_m(), + Some(scale::REGION_M as f64) ); - assert_eq!(StepCanvasRung::Quarter.spacing_m(), scale::QUARTER_M as f64); - assert_eq!(StepCanvasRung::Block.spacing_m(), scale::BLOCK_M as f64); - assert_eq!(StepCanvasRung::Chunk.spacing_m(), scale::CHUNK_M as f64); + assert_eq!( + StepCanvasRung::District.extent_m(), + Some(scale::DISTRICT_M as f64) + ); + assert_eq!( + StepCanvasRung::Quarter.extent_m(), + Some(scale::QUARTER_M as f64) + ); + assert_eq!(StepCanvasRung::Block.extent_m(), Some(scale::BLOCK_M as f64)); + assert_eq!(StepCanvasRung::Chunk.extent_m(), Some(scale::CHUNK_M as f64)); + assert_eq!(StepCanvasRung::Global.extent_m(), None); + } + + /// The inversion's core contract: the SHORTER canvas axis spans exactly + /// one cell of the rung's level, whatever the viewport shape, so a + /// widescreen window shows more ground on the long axis rather than less + /// on the short one. + #[test] + fn shorter_axis_spans_exactly_one_rung_cell() { + for (rung, cell_m) in [ + (StepCanvasRung::Region, scale::REGION_M as f64), + (StepCanvasRung::District, scale::DISTRICT_M as f64), + (StepCanvasRung::Quarter, scale::QUARTER_M as f64), + (StepCanvasRung::Block, scale::BLOCK_M as f64), + (StepCanvasRung::Chunk, scale::CHUNK_M as f64), + ] { + // Landscape, portrait and square canvases must all put one whole + // cell across the SHORT axis — the axis is chosen by size, never + // by which one happens to be the width. + for (w, h) in [(960u32, 540u32), (540, 960), (700, 700)] { + let spacing = rung.spacing_m(w, h, 6_238.4); + let short = w.min(h) as f64; + assert!( + (spacing * short - cell_m).abs() < 1e-9, + "{rung:?} at {w}x{h}: short axis spans {} m, want {cell_m} m", + spacing * short + ); + // ...and the long axis therefore shows proportionally more. + let long = w.max(h) as f64; + assert!(spacing * long >= cell_m); + } + } + } + + /// Spacing follows the canvas, not the rung: halving the cell count over + /// the same rung doubles the pitch (a clamped canvas covers the same + /// ground more coarsely — it does not cover less ground). + #[test] + fn spacing_scales_inversely_with_cell_count() { + let fine = StepCanvasRung::District.spacing_m(960, 540, 6_238.4); + let coarse = StepCanvasRung::District.spacing_m(480, 270, 6_238.4); + assert!((coarse - fine * 2.0).abs() < 1e-9, "{coarse} vs {fine}"); + } + + /// Global is the one rung whose spacing comes from the body: the full + /// 2πR circumference wraps the canvas WIDTH (equirectangular), so a + /// bigger body at the same cell count yields a proportionally coarser + /// gridunit. This is D-243's elastic seam, and the only place a body + /// radius enters the ladder at all. + #[test] + fn global_spacing_is_circumference_over_width() { + let r_km = 6_238.4_f64; + let spacing = StepCanvasRung::Global.spacing_m(960, 480, r_km); + let circumference_m = 2.0 * std::f64::consts::PI * r_km * 1_000.0; + assert!((spacing * 960.0 - circumference_m).abs() < 1e-6); + // Twice the body, twice the pitch at the same cell count. + let double = StepCanvasRung::Global.spacing_m(960, 480, r_km * 2.0); + assert!((double - spacing * 2.0).abs() < 1e-9); + } + + /// Degenerate canvases must not divide by zero — a zero axis clamps to + /// one cell rather than producing an infinity that would poison every + /// world-metre computation downstream. + #[test] + fn zero_extent_does_not_divide_by_zero() { + for rung in [StepCanvasRung::Global, StepCanvasRung::Chunk] { + let spacing = rung.spacing_m(0, 0, 6_238.4); + assert!(spacing.is_finite(), "{rung:?} produced {spacing}"); + } } #[test] @@ -1836,40 +1907,46 @@ mod tests { } // ----------------------------------------------------------------- - // Station-spacing cap (S2 addendum decision) + // Station spacing — the S2 absolute floor is retired (see the note at + // the top of this module where the constant used to live) // ----------------------------------------------------------------- + /// The S2 station-spacing floor existed because course resampling ran at + /// the rung's own spacing, so a course picked up proportionally MORE + /// stations the deeper the rung — measured at +85% cost at Chunk. The + /// extent inversion removes the mechanism rather than capping it: station + /// pitch is now the canvas pitch, and the canvas cell count is + /// viewport-driven and identical at every rung, so a course crossing the + /// canvas gets the SAME station budget however deep you scroll. + /// + /// This test guards that property directly — if a future change reties + /// station density to absolute metres, the deep rungs will diverge here + /// and this fails before the cost regression ships. #[test] - fn station_spacing_cap_floors_fine_rungs_to_district() { - // Chunk (64 m) and Block (128 m) are both finer than the District - // floor — capped up to it. - assert_eq!( - course_station_spacing_m(StepCanvasRung::Chunk), - COURSE_STATION_SPACING_FLOOR_M - ); - assert_eq!( - course_station_spacing_m(StepCanvasRung::Block), - COURSE_STATION_SPACING_FLOOR_M - ); - assert_eq!( - course_station_spacing_m(StepCanvasRung::Quarter), - COURSE_STATION_SPACING_FLOOR_M - ); - } - - #[test] - fn station_spacing_cap_is_a_noop_at_and_above_district() { - // District sits exactly at the floor — unaffected. - assert_eq!( - course_station_spacing_m(StepCanvasRung::District), - scale::DISTRICT_M as f64 - ); - // Region is coarser than District — unaffected (also moot, since - // Region never invents courses at all — uses_orbital_derive()). - assert_eq!( - course_station_spacing_m(StepCanvasRung::Region), - scale::REGION_M as f64 - ); + fn station_budget_is_rung_independent() { + let (w, h) = (960u32, 540u32); + let budget = |rung: StepCanvasRung| { + let spacing = rung.spacing_m(w, h, 6_238.4); + // Stations a course spanning the canvas's long axis would take. + (rung.extent_m().unwrap() * (w as f64 / h as f64)) / spacing + }; + let district = budget(StepCanvasRung::District); + for rung in [ + StepCanvasRung::Region, + StepCanvasRung::Quarter, + StepCanvasRung::Block, + StepCanvasRung::Chunk, + ] { + let got = budget(rung); + assert!( + (got - district).abs() < 1e-6, + "{rung:?} station budget {got} diverges from District's {district} — \ + station density must not scale with rung depth" + ); + } + // And that shared budget is the canvas width, not an absolute metre + // figure: ~one station per gridunit across the long axis. + assert!((district - w as f64).abs() < 1e-6, "{district} vs {w}"); } // ----------------------------------------------------------------- @@ -2088,7 +2165,11 @@ mod tests { // centre on each axis — the corner cells are ~960 m from centre, // inside the radius; use a coarser rung to guarantee an // outside-radius cell exists). - let step_m = StepCanvasRung::Block.spacing_m(); // 128 m/cell + // A fixture pitch chosen for the geometry above, no longer read off a + // rung: post-inversion a rung's spacing depends on the canvas size, so + // `Block.spacing_m(24, 18, ..)` would be ~7 m and put every cell inside + // the coverage radius, quietly destroying what this test checks. + let step_m = 128.0; // m/cell let (width, height) = (24u32, 18u32); let half_w = (width / 2) as i32; let half_h = (height / 2) as i32; @@ -2148,7 +2229,7 @@ mod tests { 1, 3, 3, - StepCanvasRung::Chunk.spacing_m(), + 64.0, // fixture pitch — see settlement_ids_for_canvas's own test above ); let (centre_row, centre_col) = (1usize, 1usize); let centre_i = centre_row * 3 + centre_col; @@ -2167,7 +2248,7 @@ mod tests { 2, 4, 4, - StepCanvasRung::Chunk.spacing_m(), + 64.0, // fixture pitch — see settlement_ids_for_canvas's own test above ); assert!(ids.iter().all(|&v| v == 0)); }