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:
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
+207
-126
@@ -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<f64> {
|
||||
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<InventedCourse> {
|
||||
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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user