feat(client): invert the Atlas rung relation — rung sets extent, not spacing

A rung used to fix the gridunit SPACING, with the canvas extent falling out
of spacing x cell count. That is why the top of the ladder was 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" was not a
rendering bug; it was this relation, stated in metres.

Inverted: a rung fixes the EXTENT and the spacing falls out of the canvas
size. The shorter viewport axis spans exactly one cell of the rung's level,
so a widescreen window shows more ground on the long axis rather than less
on the short one. Every rung now shows the ground its name promises —
Region 262x466 km, District 4.1x7.3 km — and the canvas cell count is
viewport-driven and identical at every rung, so derive cost no longer varies
with depth and resize is free.

Consequences that fell out of the inversion rather than being chosen:

- Region leaves the orbital derive set. It was envelope-only because at
  251,658 km nothing finer made sense; at 262 km it is a genuine provincial
  map and takes the full courses-aware derive. Region having no rivers at
  all was much of why the top of the ladder read flat. It also joins the
  deep display ratio for the same reason.
- The S2 station-spacing floor is deleted, not retuned. It guarded an
  O(1/spacing) blowup that the inversion makes structurally impossible (the
  canvas cell count is now constant across rungs, so stations-per-course is
  bounded however deep you scroll). Kept, it would do active harm in the
  opposite direction: a 2,048 m pitch across a 3.6 km District canvas places
  two stations and draws every river as a straight line. Station placement
  gets its own generator pass.
- cap_extent_to_body is superseded and now a documented no-op. A canvas can
  no longer over-request a body by construction. The residual question —
  whether a rung's cell exceeds the whole body — is liveness, not capping,
  and is_rung_live_on_body() answers it by omitting the rung. Empirically it
  never fires on inhabited content: all six rungs are live on all 271
  populated bodies with a radius.
- snap_to_gridunit no longer truncates its multiplier to int. Post-inversion
  the deep rungs run sub-metre (Chunk ~0.12 m at a 1080 px short axis), where
  int(spacing) floors to zero and would collapse every request centre onto
  the origin.

Both sides derive spacing from the same three inputs (rung, echoed cell
extent, body radius) rather than one telling the other, so there is nothing
to keep in sync beyond the constant table itself. Body radius already
reaches the viewer via enter(); no wire change.

Pair session with Jeroen, 2026-07-26. D-243/D-255 amendments to be backfiled.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-26 21:38:08 +02:00
co-authored by Claude
parent f5b34131b0
commit 0a0419abcc
8 changed files with 620 additions and 291 deletions
@@ -20,7 +20,7 @@ func test_set_frame_stores_the_frame_and_triggers_no_crash_on_draw() -> void:
"courses": [], "courses": [],
"settlement_id": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], "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 # No assertion beyond "did not crash" — set_frame()/queue_redraw() with a
# well-formed empty-feature canvas is the baseline no-op path every # well-formed empty-feature canvas is the baseline no-op path every
# richer test below builds on. # 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: func test_clear_frame_drops_the_held_canvas() -> void:
var layer: StepCanvasAnnotationLayer = auto_free(StepCanvasAnnotationLayer.new()) var layer: StepCanvasAnnotationLayer = auto_free(StepCanvasAnnotationLayer.new())
add_child(layer) 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() layer.clear_frame()
assert_that(layer._canvas).is_null() 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: func test_cell_center_world_m_matches_the_servers_own_per_cell_placement() -> void:
var layer: StepCanvasAnnotationLayer = auto_free(StepCanvasAnnotationLayer.new()) var layer: StepCanvasAnnotationLayer = auto_free(StepCanvasAnnotationLayer.new())
add_child(layer) 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 # 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. # 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()) var layer: StepCanvasAnnotationLayer = auto_free(StepCanvasAnnotationLayer.new())
add_child(layer) add_child(layer)
layer.set_frame( 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)) 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) layer.set_frame({"width": 32, "height": 32, "courses": []}, world_center, "Quarter", extent)
var expected: Vector2 = StepCanvasTransport.world_m_to_canvas_local( 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)) 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()) var layer: StepCanvasAnnotationLayer = auto_free(StepCanvasAnnotationLayer.new())
add_child(layer) add_child(layer)
# District spacing 2048m, extent 4x4 -> half-extent 4096m on each axis. # 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() 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: func test_is_true_source_in_canvas_false_for_a_point_outside_the_bounds() -> void:
var layer: StepCanvasAnnotationLayer = auto_free(StepCanvasAnnotationLayer.new()) var layer: StepCanvasAnnotationLayer = auto_free(StepCanvasAnnotationLayer.new())
add_child(layer) 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. # 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() 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: func test_is_true_source_in_canvas_is_conservative_at_the_exact_boundary() -> void:
var layer: StepCanvasAnnotationLayer = auto_free(StepCanvasAnnotationLayer.new()) var layer: StepCanvasAnnotationLayer = auto_free(StepCanvasAnnotationLayer.new())
add_child(layer) 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 # Half-extent is 4096m exactly. A point AT the boundary (x=4096) is
# within epsilon of the edge -> conservatively NOT a true source. # 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() 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: func test_is_true_source_in_canvas_false_for_null() -> void:
var layer: StepCanvasAnnotationLayer = auto_free(StepCanvasAnnotationLayer.new()) var layer: StepCanvasAnnotationLayer = auto_free(StepCanvasAnnotationLayer.new())
add_child(layer) 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() 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). # overhang case (finding 1).
"courses": [{"class": 2, "points": [[-9000, 0], [0, 0], [10, 0]], "terminus": ""}], "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() 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, "height": 4,
"courses": [{"class": 2, "points": [[0, 0], [500, 0], [1000, 0], [1500, 0]], "terminus": "Mouth"}], "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() assert_object(layer).is_not_null()
+150 -74
View File
@@ -7,6 +7,9 @@ extends GdUnitTestSuite
const StepCanvasTransport := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd") 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 # 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) ## The inversion's core contract: the SHORTER canvas axis spans exactly one
assert_float(StepCanvasTransport.spacing_for_rung("Region")).is_equal_approx(204_800.0, 0.01) ## cell of the rung's level, whatever the viewport shape. Must agree with the
assert_float(StepCanvasTransport.spacing_for_rung("District")).is_equal_approx(2_048.0, 0.01) ## server's own StepCanvasRung::spacing_m() — both sides derive it from the
assert_float(StepCanvasTransport.spacing_for_rung("Quarter")).is_equal_approx(512.0, 0.01) ## same three inputs, so this test and its Rust twin pin one contract.
assert_float(StepCanvasTransport.spacing_for_rung("Block")).is_equal_approx(128.0, 0.01) func test_shorter_axis_spans_exactly_one_rung_cell() -> void:
assert_float(StepCanvasTransport.spacing_for_rung("Chunk")).is_equal_approx(64.0, 0.01) 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) assert_float(deep).is_greater(0.0)
func test_display_ratio_shallow_rungs_use_the_five_x_five_fallback() -> void: ## Only Global keeps the shallow fallback now — Region joined the deep band
for rung in ["Global", "Region"]: ## with the extent inversion (it is a real 262x466 km map, not an orbital
assert_float(StepCanvasTransport.display_ratio_for_rung(rung)).is_equal_approx(5.0, 0.001) ## 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("Global")).is_true()
assert_bool(StepCanvasTransport.is_orbital_rung("Region")).is_true() for rung in ["Region", "District", "Quarter", "Block", "Chunk"]:
assert_bool(StepCanvasTransport.is_orbital_rung("District")).is_false() assert_bool(StepCanvasTransport.is_orbital_rung(rung)).override_failure_message(
assert_bool(StepCanvasTransport.is_orbital_rung("Chunk")).is_false() "%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: 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)) 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: ## Post-inversion the snap lattice is the CANVAS pitch, not a per-rung
var snapped: Vector2i = StepCanvasTransport.snap_to_gridunit(Vector2(2100.0, -1000.0), "District") ## constant. A 1x1-cell District canvas puts the whole 2,048 m cell in one
# District spacing = 2048 m: 2100 rounds to 1*2048=2048, -1000 rounds to 0. ## 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.x).is_equal(2048)
assert_int(snapped.y).is_equal(0) 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: 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 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) 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 rung := "District"
var world_center := Vector2(10_000.0, 20_000.0) var world_center := Vector2(10_000.0, 20_000.0)
var local: Vector2 = StepCanvasTransport.world_m_to_canvas_local( 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 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)) 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 original_world := Vector2(51_200.0, -29_500.0)
var local: Vector2 = StepCanvasTransport.world_m_to_canvas_local( 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( 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)) 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: 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 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: ## Half-extent is now half the rung's own CELL on the short axis, whatever
var half: float = StepCanvasTransport.half_extent_m("District", 64) ## the cell count — that is the inversion restated as an invariant.
assert_float(half).is_equal_approx(64.0 * 0.5 * 2048.0, 0.01) 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 / # Rung liveness — replaces the T-1189 extent cap, which the D-255 extent
# pole-smear fix. The Global echo IS the cap (its canvas already equals the # inversion superseded (pair session 2026-07-26). A canvas can no longer
# body's region grid, D-255(a): "the Global canvas IS the whole body at # over-request a body: its cell count is viewport-driven and its GROUND extent
# region spacing"), so no unit conversion is needed — Region shares Global's # is the rung's own cell size. The residual question is whether a rung's cell
# gridunit spacing exactly. # 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: func test_cap_extent_to_body_is_now_a_no_op() -> void:
# T-1183 eyeball: 384x216 requested at Region on GJ1c, whose Global echo var extent := Vector2i(384, 216)
# is 177x88 — the requested extent overruns the body on both axes. assert_that(StepCanvasTransport.cap_extent_to_body(extent, "Region", Vector2i(177, 88))).is_equal(
var capped: Vector2i = StepCanvasTransport.cap_extent_to_body( extent
Vector2i(384, 216), "Region", Vector2i(177, 88)
) )
assert_that(capped).is_equal(Vector2i(177, 88))
func test_cap_extent_to_body_is_a_no_op_when_already_inside_the_grid() -> void: ## All six rungs are live on every populated body in systems.db — the smallest
var capped: Vector2i = StepCanvasTransport.cap_extent_to_body( ## is a 734 km-radius moon whose 4,611 km circumference swallows a Region cell
Vector2i(100, 40), "Region", Vector2i(177, 88) ## seventeen times over. Pinned so a future rung resize that would silently
) ## kill a rung on inhabited worlds fails here first.
assert_that(capped).is_equal(Vector2i(100, 40)) 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, ## ...but a rung whose cell exceeds the body is not a step down the ladder at
## so it caps ANY rung sharing Global's spacing, not just a hardcoded ## all — it would zoom OUT. systems.db has two such rocks (6 km and 11 km
## "Region" check. District's spacing (2048 m) differs from Global's ## radius, both uninhabited).
## (204,800 m), so it must NEVER be capped by the body-grid cell count — func test_region_is_not_live_on_a_sub_region_body() -> void:
## capping cell counts across mismatched spacings would be a unit error. assert_bool(StepCanvasTransport.is_rung_live_on_body("Region", 6.0)).is_false()
func test_cap_extent_to_body_leaves_finer_rungs_uncapped() -> void: assert_bool(StepCanvasTransport.is_rung_live_on_body("District", 6.0)).is_true()
var capped: Vector2i = StepCanvasTransport.cap_extent_to_body(
Vector2i(3000, 3000), "District", Vector2i(177, 88)
)
assert_that(capped).is_equal(Vector2i(3000, 3000))
## Cold-start fallback (T-1189, StepCanvasViewer's own documented choice): ## An absent radius (an asteroid belt is not a sphere and has no
## before any Global response has arrived, `_global_body_extent` is ZERO — ## equirectangular surface at all) must not silently collapse the ladder —
## cap_extent_to_body() must leave the request UNCAPPED on a non-positive ## the caller has a bigger problem than liveness and should see it.
## axis (server clamps independently) rather than clamping to zero cells. func test_liveness_is_permissive_without_a_radius() -> void:
func test_cap_extent_to_body_uncapped_when_global_echo_not_yet_available() -> void: assert_bool(StepCanvasTransport.is_rung_live_on_body("Region", 0.0)).is_true()
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))
# ============================================================================= # =============================================================================
+47 -26
View File
@@ -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) assert_that(v._global_body_extent).is_equal(GJ1C_GLOBAL_EXTENT)
## The T-1183 eyeball regression itself: once the Global echo has landed, ## The T-1183 eyeball regression, restated for the post-inversion ladder
## scrolling to Region and firing its request must produce a CAPPED extent ## (D-255 amendment, pair session 2026-07-26). It used to be fixed by CAPPING
## — never the raw viewport-fit 384x216 that overran the body on both axes. ## Region's extent to the body's region grid; the inversion removes the defect
func test_region_request_extent_is_capped_to_the_landed_global_extent() -> void: ## 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() var v: StepCanvasViewer = _make_viewer()
add_child(v) add_child(v)
v.size = Vector2(1920.0, 1080.0) # the T-1183 eyeball's own viewport 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}, {}) 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) 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() var extent: Vector2i = v._request_extent()
assert_int(extent.x).override_failure_message( # No longer capped to the body grid — the cell count is the window.
"Region's requested extent must never exceed the body's own region-grid width" assert_that(extent).is_equal(StepCanvasTransport.viewport_fit_extent(v.size, "Region"))
).is_less_equal(GJ1C_GLOBAL_EXTENT.x)
assert_int(extent.y).override_failure_message( # ...and the ground it covers is one region across the short axis, which
"Region's requested extent must never exceed the body's own region-grid height" # is what actually prevents the sideways-repeat / pole-smear defect.
).is_less_equal(GJ1C_GLOBAL_EXTENT.y) var spacing: float = StepCanvasTransport.spacing_for_rung(
assert_that(extent).is_equal(GJ1C_GLOBAL_EXTENT) # 1920x1080 viewport-fit exceeds 177x88 on both axes "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()): ## 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) ).is_equal(uncapped)
## Cache-key discipline (T-1182/T-1183, ticket's own explicit call-out): the ## Cache-key discipline (T-1182/T-1183, ticket's own explicit call-out),
## cap must be applied BEFORE _fire_request() builds the request, so the ## outliving the cap it was written for: the extent that becomes part of the
## extent that becomes part of the cache key is the SAME capped value that ## cache key must be the SAME value the request actually carries — a request
## gets served — a request for "the same spot" must always resolve to the ## for "the same spot" must always resolve to the same key, never a key built
## same key, capped or not, never a key built from one extent and served ## from one extent and served under another. The inversion makes this MORE
## under another. ## load-bearing, not less: the extent now also determines gridunit spacing, so
func test_capped_extent_matches_what_the_request_actually_sends() -> void: ## 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() var v: StepCanvasViewer = _make_viewer()
add_child(v) add_child(v)
v.size = Vector2(1920.0, 1080.0) 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, # idempotent and match what was actually requested (no separate,
# divergent cap path). # divergent cap path).
var extent_now: Vector2i = v._request_extent() 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 # 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 # 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 req: Variant = v.get_request()
var center: Vector2i = StepCanvasTransport.snap_to_gridunit(v._world_center, "Region") var center: Vector2i = StepCanvasTransport.snap_to_gridunit(
var canvas := TestStepCanvasViewer._synthetic_canvas(GJ1C_GLOBAL_EXTENT.x, GJ1C_GLOBAL_EXTENT.y) 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) req.get_cache().put("T1189_extent_letterbox_test_body", "Region", center, extent_now, canvas)
assert_bool( 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( ).override_failure_message(
"a cache entry stored under the CAPPED extent must be reachable" "a cache entry stored under the REQUESTED extent must be reachable"
+ " under that same capped extent — key consistency" + " under that same extent — key consistency"
).is_true() ).is_true()
@@ -162,19 +162,29 @@ var _canvas: Variant = null # decoded StepCanvasResponse.canvas — null until
var _world_center: Vector2 = Vector2.ZERO var _world_center: Vector2 = Vector2.ZERO
var _rung: String = StepCanvasTransport.RUNG_DISTRICT var _rung: String = StepCanvasTransport.RUNG_DISTRICT
var _extent_cells: Vector2i = Vector2i.ZERO 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 ## Adopt a new canvas + its request frame (world center, rung, extent, body
## world->screen projection for every drawn feature depends on all three, ## radius) — the world->screen projection for every drawn feature depends on
## so they're set together, matching the terrain layer's own texture-plus- ## all four, so they're set together, matching the terrain layer's own
## frame handoff. ## texture-plus-frame handoff.
func set_frame( 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: ) -> void:
_canvas = canvas _canvas = canvas
_world_center = world_center _world_center = world_center
_rung = rung _rung = rung
_extent_cells = extent_cells _extent_cells = extent_cells
_body_radius_km = body_radius_km
queue_redraw() queue_redraw()
@@ -183,6 +193,13 @@ func clear_frame() -> void:
queue_redraw() 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: func _draw() -> void:
if not _canvas is Dictionary: if not _canvas is Dictionary:
return return
@@ -249,8 +266,8 @@ func _is_true_source_in_canvas(world_m: Variant) -> bool:
if not world_m is Vector2: if not world_m is Vector2:
return false return false
var p: Vector2 = world_m var p: Vector2 = world_m
var half_w_m: float = float(_extent_cells.x) * 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 * StepCanvasTransport.spacing_for_rung(_rung) 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 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 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 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 ## (`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. ## settlement marker lands on the exact cell its id was read from.
func _cell_center_world_m(col: int, row: int) -> Vector2: 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_w: float = float(_extent_cells.x) * 0.5
var half_h: float = float(_extent_cells.y) * 0.5 var half_h: float = float(_extent_cells.y) * 0.5
return Vector2( return Vector2(
@@ -523,4 +540,6 @@ func _cell_center_world_m(col: int, row: int) -> Vector2:
func _world_to_local(world_m: Vector2) -> 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
)
@@ -75,7 +75,9 @@ func refresh() -> void:
clear() clear()
visible = true 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] var subtitle: String = "%s · %.3f km/gridunit" % [_viewer.get_held_rung().to_lower(), spacing_km]
add_component(ImplantHeader.new("ATLAS LEGEND", subtitle)) add_component(ImplantHeader.new("ATLAS LEGEND", subtitle))
add_component(ImplantSeparator.new()) add_component(ImplantSeparator.new())
@@ -29,18 +29,28 @@ const RUNG_LADDER: Array = [
RUNG_GLOBAL, RUNG_REGION, RUNG_DISTRICT, RUNG_QUARTER, RUNG_BLOCK, RUNG_CHUNK 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/ ## 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 ## QUARTER_M/BLOCK_M/CHUNK_M) and must equal StepCanvasRung::extent_m()
## drift from the wire contract it's choosing between. Global has no single ## server-side.
## 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 ## **These are extents, not spacings** (D-255 amendment, pair session
## StepCanvasRung::spacing_m() returns REGION_M for Global too, "a ## 2026-07-26). The original ladder had the relation the other way round: a
## harmless-but-correct value... so ordering/comparison call sites... get a ## rung fixed the gridunit SPACING and the canvas extent fell out of
## sane, documented number rather than 0 or a panic") — mirrored here for the ## `spacing x cell count`. That is what made the top of the ladder unusable —
## same reason. ## at REGION_M spacing a viewport-sized canvas spanned ~251,658 km, six times
const RUNG_SPACING_M: Dictionary = { ## around a rocky body, so the Region rung capped to the body and redrew the
RUNG_GLOBAL: 204_800.0, ## 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_REGION: 204_800.0,
RUNG_DISTRICT: 2_048.0, RUNG_DISTRICT: 2_048.0,
RUNG_QUARTER: 512.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 ## (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 ## 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. ## 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 = { const DISPLAY_RATIO_BY_RUNG: Dictionary = {
RUNG_GLOBAL: DISPLAY_RATIO_SHALLOW, RUNG_GLOBAL: DISPLAY_RATIO_SHALLOW,
RUNG_REGION: DISPLAY_RATIO_SHALLOW, RUNG_REGION: DISPLAY_RATIO_DEEP,
RUNG_DISTRICT: DISPLAY_RATIO_DEEP, RUNG_DISTRICT: DISPLAY_RATIO_DEEP,
RUNG_QUARTER: DISPLAY_RATIO_DEEP, RUNG_QUARTER: DISPLAY_RATIO_DEEP,
RUNG_BLOCK: 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) return clampi(current_index + delta, 0, RUNG_LADDER.size() - 1)
static func spacing_for_rung(rung: String) -> float: ## Metres per gridunit for a canvas of `extent_cells` cells on a body of
return float(RUNG_SPACING_M.get(rung, RUNG_SPACING_M[RUNG_DISTRICT])) ## `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: static func display_ratio_for_rung(rung: String) -> float:
return float(DISPLAY_RATIO_BY_RUNG.get(rung, DISPLAY_RATIO_DEEP)) return float(DISPLAY_RATIO_BY_RUNG.get(rung, DISPLAY_RATIO_DEEP))
## True for the two rungs that ride derive_orbital_at_metres server-side ## True for the rung that rides derive_orbital_at_metres server-side
## (Global/Region, step_canvas.rs's own StepCanvasRung::uses_orbital_derive()) ## (step_canvas.rs's own StepCanvasRung::uses_orbital_derive()) — mirrored
## — mirrored here purely for READABILITY at call sites that branch on it ## here purely for READABILITY at call sites that branch on it (e.g. "does
## (e.g. "does this rung's canvas ever carry courses" — Global/Region never ## this rung's canvas ever carry courses"), not because the client makes any
## do, matching invent_courses_for_canvas()'s own early return), not because ## derivation decision itself (D-255(e): derivation stays server-side).
## the client makes any derivation decision itself (D-255(e): derivation ##
## stays server-side, full stop). ## 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: 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 ## 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 ## of `extent_cells` x `extent_cells` covers, given the rung's own gridunit
## spacing — the request-sizing half of the cursor-anchored step math. ## spacing — the request-sizing half of the cursor-anchored step math.
static func half_extent_m(rung: String, extent_cells: int) -> float: static func half_extent_m(rung: String, extent_cells: Vector2i, body_radius_km: float) -> float:
return float(extent_cells) * 0.5 * spacing_for_rung(rung) 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 ## 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 ## 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. ## request should center on — computed once per scroll notch, not per frame.
static func canvas_local_to_world_m( 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: ) -> 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 ratio: float = display_ratio_for_rung(rung)
var px_per_gridunit: float = maxf(ratio, 0.0001) var px_per_gridunit: float = maxf(ratio, 0.0001)
var half_w_m: float = float(extent_cells.x) * 0.5 * spacing 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 ## display-time scale, this is that same linear map applied to a point
## rather than a texture). ## rather than a texture).
static func world_m_to_canvas_local( 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: ) -> 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 ratio: float = display_ratio_for_rung(rung)
var px_per_gridunit: float = maxf(ratio, 0.0001) var px_per_gridunit: float = maxf(ratio, 0.0001)
var half_w_m: float = float(extent_cells.x) * 0.5 * spacing 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 ## StepCanvasViewer's own cold-start fallback doc) leaves that axis
## uncapped, matching the ticket's "request uncapped and let the server ## uncapped, matching the ticket's "request uncapped and let the server
## clamp" fallback choice. ## 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( static func cap_extent_to_body(
extent: Vector2i, rung: String, global_extent: Vector2i extent: Vector2i, _rung: String, _global_extent: Vector2i
) -> Vector2i: ) -> Vector2i:
if not is_equal_approx(spacing_for_rung(rung), spacing_for_rung(RUNG_GLOBAL)): return extent
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) ## Is `rung` a meaningful step on this body? A rung whose cell is larger than
return Vector2i(w, h) ## 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 ## 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 ## hits are the common case worth protecting). Global ignores center
## entirely server-side (step_canvas_protocol.gd's own doc) so snapping is a ## entirely server-side (step_canvas_protocol.gd's own doc) so snapping is a
## harmless no-op there. ## harmless no-op there.
static func snap_to_gridunit(world_m: Vector2, rung: String) -> Vector2i: static func snap_to_gridunit(
var spacing: float = spacing_for_rung(rung) world_m: Vector2, rung: String, extent_cells: Vector2i, body_radius_km: float
if spacing <= 0.0: ) -> 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)), int(round(world_m.y)))
return Vector2i( 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)
) )
@@ -331,10 +331,27 @@ func get_body_id() -> String:
return _dict_str(_body, "body_id", "") 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: func get_held_rung() -> String:
return _held_rung 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, ## T-1183 test seam: exposes the owned StepCanvasRequest (and, through it,
## get_disk_cache()) so sweep-trigger wiring is directly testable, matching ## get_disk_cache()) so sweep-trigger wiring is directly testable, matching
## the get_cache()/get_disk_cache() accessor pattern StepCanvasRequest ## 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). ## (ignored server-side, per step_canvas_protocol.gd's own doc).
func _fire_request() -> void: func _fire_request() -> void:
var extent: Vector2i = _request_extent() 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) _request.request_now(get_body_id(), _held_rung, center, extent)
@@ -520,7 +539,11 @@ func _pan_drift_fraction() -> float:
func _refloat_now() -> void: func _refloat_now() -> void:
var screen_center: Vector2 = get_rect().size * 0.5 var screen_center: Vector2 = get_rect().size * 0.5
_world_center = StepCanvasTransport.canvas_local_to_world_m( _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 _view_offset = Vector2.ZERO
_fire_request() _fire_request()
@@ -549,7 +572,9 @@ func _on_canvas_ready(canvas: Dictionary) -> void:
_global_body_extent = _held_extent _global_body_extent = _held_extent
_current_canvas_data = canvas _current_canvas_data = canvas
_rebuild_terrain_texture(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() _recompute_canvas_transform()
_refresh_screen_header() _refresh_screen_header()
if _legend_panel: if _legend_panel:
@@ -655,7 +680,11 @@ func _scroll_rung(direction: int, cursor_local: Vector2) -> void:
_reset_to_global() _reset_to_global()
return return
var cursor_world: Vector2 = StepCanvasTransport.canvas_local_to_world_m( 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 _rung_index = new_index
_held_rung = StepCanvasTransport.rung_at_index(_rung_index) _held_rung = StepCanvasTransport.rung_at_index(_rung_index)
@@ -883,7 +912,9 @@ func _refresh_screen_header() -> void:
if _screen_header == null: if _screen_header == null:
return return
var name_label: String = _dict_str(_body, "proper_name", _dict_str(_body, "body_id", "—")) 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 title := "ATLAS — %s" % name_label.to_upper()
var subtitle := "%s · %.3f km/gridunit" % [_held_rung.to_upper(), spacing_km] var subtitle := "%s · %.3f km/gridunit" % [_held_rung.to_upper(), spacing_km]
_screen_header.set_content(title, subtitle) _screen_header.set_content(title, subtitle)
+207 -126
View File
@@ -91,23 +91,58 @@ pub enum StepCanvasRung {
} }
impl StepCanvasRung { impl StepCanvasRung {
/// Cell spacing in metres for every FIXED rung — sourced from `scale::` /// The rung's own world-metre **cell size** — sourced from `scale::`
/// (D-243), never a magic number (mirrors `WindowGranularity::spacing_m`'s /// (D-243), never a magic number. `None` for [`Self::Global`], the sole
/// discipline). [`Self::Global`] has no single spacing value (its /// elastic rung, whose extent is the body itself and therefore cannot be
/// gridunit is "one region", not a metre float) — callers needing /// a constant (D-243's elastic seam).
/// Global's cell pitch use [`Self::global_cell_counts`] instead; this ///
/// method still returns `REGION_M` for `Global` as a harmless-but-correct /// **This is an extent, not a spacing** (D-255 amendment, pair session
/// value (a Global gridunit and a Region gridunit are both "one region" /// 2026-07-26). The original ladder had it the other way round: a rung
/// wide) so ordering/comparison call sites that don't special-case /// fixed the gridunit *spacing* and the canvas extent fell out of
/// `Global` still get a sane, documented number rather than 0 or a panic. /// `spacing × cell count`. That inversion is what made the top of the
pub fn spacing_m(self) -> f64 { /// 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<f64> {
match self { match self {
StepCanvasRung::Global => scale::REGION_M as f64, StepCanvasRung::Global => None,
StepCanvasRung::Region => scale::REGION_M as f64, StepCanvasRung::Region => Some(scale::REGION_M as f64),
StepCanvasRung::District => scale::DISTRICT_M as f64, StepCanvasRung::District => Some(scale::DISTRICT_M as f64),
StepCanvasRung::Quarter => scale::QUARTER_M as f64, StepCanvasRung::Quarter => Some(scale::QUARTER_M as f64),
StepCanvasRung::Block => scale::BLOCK_M as f64, StepCanvasRung::Block => Some(scale::BLOCK_M as f64),
StepCanvasRung::Chunk => scale::CHUNK_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) // Quantization (mirrors layer_proxy::quantize_min_wl_m's discipline)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -667,12 +644,24 @@ fn invent_courses_for_canvas(
river_network: &RiverNetwork, river_network: &RiverNetwork,
canvas_rect: (f64, f64, f64, f64), canvas_rect: (f64, f64, f64, f64),
rung: StepCanvasRung, rung: StepCanvasRung,
step_m: f64,
min_wavelength_m: f64, min_wavelength_m: f64,
) -> Vec<InventedCourse> { ) -> Vec<InventedCourse> {
if rung.uses_orbital_derive() { if rung.uses_orbital_derive() {
return Vec::new(); 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 (win_x0, win_y0, win_x1, win_y1) = canvas_rect;
let edges = river_course::build_edges(river_network); 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 body_radius_km = params.body_radius_km.unwrap_or(0.0);
let (width, height) = resolve_canvas_extent(rung, extent, body_radius_km); let (width, height) = resolve_canvas_extent(rung, extent, body_radius_km);
let min_wavelength_m = min_wl_m as f64; 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 cells = (width * height) as usize;
let half_w = (width / 2) as i32; let half_w = (width / 2) as i32;
@@ -972,6 +963,7 @@ pub fn build_step_canvas(
river_network, river_network,
canvas_rect, canvas_rect,
rung, rung,
step_m,
min_wavelength_m, min_wavelength_m,
) )
}; };
@@ -1640,16 +1632,95 @@ mod tests {
// Rung vocabulary // 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] #[test]
fn spacing_m_matches_d243_constants() { fn extent_m_matches_d243_constants() {
assert_eq!(StepCanvasRung::Region.spacing_m(), scale::REGION_M as f64);
assert_eq!( assert_eq!(
StepCanvasRung::District.spacing_m(), StepCanvasRung::Region.extent_m(),
scale::DISTRICT_M as f64 Some(scale::REGION_M as f64)
); );
assert_eq!(StepCanvasRung::Quarter.spacing_m(), scale::QUARTER_M as f64); assert_eq!(
assert_eq!(StepCanvasRung::Block.spacing_m(), scale::BLOCK_M as f64); StepCanvasRung::District.extent_m(),
assert_eq!(StepCanvasRung::Chunk.spacing_m(), scale::CHUNK_M as f64); 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] #[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] #[test]
fn station_spacing_cap_floors_fine_rungs_to_district() { fn station_budget_is_rung_independent() {
// Chunk (64 m) and Block (128 m) are both finer than the District let (w, h) = (960u32, 540u32);
// floor — capped up to it. let budget = |rung: StepCanvasRung| {
assert_eq!( let spacing = rung.spacing_m(w, h, 6_238.4);
course_station_spacing_m(StepCanvasRung::Chunk), // Stations a course spanning the canvas's long axis would take.
COURSE_STATION_SPACING_FLOOR_M (rung.extent_m().unwrap() * (w as f64 / h as f64)) / spacing
); };
assert_eq!( let district = budget(StepCanvasRung::District);
course_station_spacing_m(StepCanvasRung::Block), for rung in [
COURSE_STATION_SPACING_FLOOR_M StepCanvasRung::Region,
); StepCanvasRung::Quarter,
assert_eq!( StepCanvasRung::Block,
course_station_spacing_m(StepCanvasRung::Quarter), StepCanvasRung::Chunk,
COURSE_STATION_SPACING_FLOOR_M ] {
); let got = budget(rung);
} assert!(
(got - district).abs() < 1e-6,
#[test] "{rung:?} station budget {got} diverges from District's {district} — \
fn station_spacing_cap_is_a_noop_at_and_above_district() { station density must not scale with rung depth"
// District sits exactly at the floor — unaffected. );
assert_eq!( }
course_station_spacing_m(StepCanvasRung::District), // And that shared budget is the canvas width, not an absolute metre
scale::DISTRICT_M as f64 // figure: ~one station per gridunit across the long axis.
); assert!((district - w as f64).abs() < 1e-6, "{district} vs {w}");
// 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
);
} }
// ----------------------------------------------------------------- // -----------------------------------------------------------------
@@ -2088,7 +2165,11 @@ mod tests {
// centre on each axis — the corner cells are ~960 m from centre, // centre on each axis — the corner cells are ~960 m from centre,
// inside the radius; use a coarser rung to guarantee an // inside the radius; use a coarser rung to guarantee an
// outside-radius cell exists). // 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 (width, height) = (24u32, 18u32);
let half_w = (width / 2) as i32; let half_w = (width / 2) as i32;
let half_h = (height / 2) as i32; let half_h = (height / 2) as i32;
@@ -2148,7 +2229,7 @@ mod tests {
1, 1,
3, 3,
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_row, centre_col) = (1usize, 1usize);
let centre_i = centre_row * 3 + centre_col; let centre_i = centre_row * 3 + centre_col;
@@ -2167,7 +2248,7 @@ mod tests {
2, 2,
4, 4,
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)); assert!(ids.iter().all(|&v| v == 0));
} }