From c31cc6220e83b23de06e1ce83f74c38f7d97e9b7 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 23 Jul 2026 13:05:40 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20T-1170=20B3=20=E2=80=94=20course=20?= =?UTF-8?q?polyline=20drawing=20at=20District/Quarter;=20clip=20restructur?= =?UTF-8?q?ed=20per=20Ruling=203g?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _draw() splits into two independent gates: the Layer-1-gated skeleton path (Region chords, clip retained) and the NEW DistrictWindowLayer- gated course path — build_course_render_plan() (pure, render-free- testable) consumed by draw_polyline with _zs-compensated widths and opacities from the Araminta revisit tables; mouth double-rings at Mouth termini only (EdgeDrain/ContinuesBeyondWindow/None: three meanings, one presentation — draw to last point, stop, documented); zero water clip on the course path by construction (courses carry rung-consistent termini). CourseTerminus wire vocabulary kept re- pointable pending A2's real serde names; synthetic Ruling-3h fixtures mean the suites need zero changes when the server payload lands. Real gap found and fixed: window arrival never redrew the nature overlay after the first fit (one line in _on_window_ready — courses would miss every window swap post-pan). Water-clip header rewritten to RESTRUCTURED status (retired on course rungs; permanent at Region until Region goes windowed, T-1143 ruling 2). Revert-verified (visibility-gate bypass -> 4 named failures). geometry-nature 112/112, nature-overlay 58/58, viewer 78/78, zero collateral; full sweep 3928/3928 after the full-import bootstrap; gdlint clean (viewer 1016->1017, pre-existing overage rides T-1158). Tickets: T-1170 Co-Authored-By: Claude Fable 5 --- .../test_atlas_window_geometry_nature.gd | 204 ++++++++++++++++++ .../tests/test_atlas_window_nature_overlay.gd | 65 ++++++ .../atlas/atlas_window_geometry_nature.gd | 77 +++++++ .../apps/atlas/atlas_window_nature_overlay.gd | 126 ++++++++++- .../implant/apps/atlas/atlas_window_viewer.gd | 1 + .../apps/atlas/atlas_window_water_clip.gd | 18 +- 6 files changed, 485 insertions(+), 6 deletions(-) diff --git a/client/tests/test_atlas_window_geometry_nature.gd b/client/tests/test_atlas_window_geometry_nature.gd index 124efba75..4752bff5b 100644 --- a/client/tests/test_atlas_window_geometry_nature.gd +++ b/client/tests/test_atlas_window_geometry_nature.gd @@ -529,6 +529,210 @@ func test_course_class_visible_at_rung_unknown_tag_falls_back_to_empty() -> void ).is_false() +# ============================================================================= +# T-1170 Ruling 5b/3h: build_course_render_plan() — pure course-polyline +# CONSTRUCTION (no draw calls), the course-path counterpart to B2's +# build_skeleton_chords(). Synthetic fixtures shaped per Ruling 3h's wire +# shape: {class: u8, points: Vec<(i32,i32)> world-metres, terminus: string} +# — built BEFORE Dudley's A2 (course inventor) lands, per the ticket brief's +# explicit instruction. +# ============================================================================= + + +static func _course_fixture( + cls: int, points: Array, terminus: String = "None" +) -> Dictionary: + return {"edge_id": 1, "class": cls, "points": points, "terminus": terminus} + + +func test_build_course_render_plan_district_trunk_is_visible_and_constructs_points() -> void: + var course: Dictionary = _course_fixture( + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, [[0, 0], [2048, 0], [4096, 0]] + ) + var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan( + course, "District", Vector2i.ZERO, 64, CELL_PIXEL_SIZE + ) + assert_that(plan).is_not_null() + var canvas_pts: PackedVector2Array = plan["canvas_pts"] + assert_int(canvas_pts.size()).is_equal(3) + assert_int(plan["cls"]).is_equal(AtlasWindowGeometryNature.RIVER_CLASS_TRUNK) + assert_str(plan["terminus"]).is_equal("None") + + +## District does NOT show streams (COURSE_CLASS_VISIBLE_BY_RUNG: District == +## [TRIBUTARY, TRUNK]) — a stream-class course must construct nothing at +## District, even with perfectly well-formed points. +func test_build_course_render_plan_district_stream_is_not_visible() -> void: + var course: Dictionary = _course_fixture( + AtlasWindowGeometryNature.RIVER_CLASS_STREAM, [[0, 0], [2048, 0]] + ) + var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan( + course, "District", Vector2i.ZERO, 64, CELL_PIXEL_SIZE + ) + assert_that(plan).override_failure_message( + "streams must not draw at District — only tributary+trunk are visible there" + ).is_null() + + +## Quarter rivers return — ALL THREE classes construct at Quarter, including +## streams. This is the wave-1 pre-announced revisit actually landing. +func test_build_course_render_plan_quarter_stream_is_visible() -> void: + var course: Dictionary = _course_fixture( + AtlasWindowGeometryNature.RIVER_CLASS_STREAM, [[0, 0], [512, 0]] + ) + var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan( + course, "Quarter", Vector2i.ZERO, 16, CELL_PIXEL_SIZE + ) + assert_that(plan).override_failure_message( + "Quarter rivers return — streams must be visible at Quarter" + ).is_not_null() + + +## Region never carries courses — a course-shaped fixture queried at "Region" +## must construct nothing, regardless of class. +func test_build_course_render_plan_region_constructs_nothing() -> void: + var course: Dictionary = _course_fixture( + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, [[0, 0], [2048, 0]] + ) + var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan( + course, "Region", Vector2i.ZERO, 64, CELL_PIXEL_SIZE + ) + assert_that(plan).is_null() + + +## Ruling 3h: terminus MOUTH is preserved through to the plan — the caller +## (the overlay's draw function) reads this to decide whether to draw a +## mouth ring at the LAST canvas point. +func test_build_course_render_plan_preserves_mouth_terminus() -> void: + var course: Dictionary = _course_fixture( + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, [[0, 0], [2048, 0]], "Mouth" + ) + var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan( + course, "District", Vector2i.ZERO, 64, CELL_PIXEL_SIZE + ) + assert_that(plan).is_not_null() + assert_str(plan["terminus"]).is_equal(AtlasWindowGeometryNature.COURSE_TERMINUS_MOUTH) + + +## EdgeDrain, ContinuesBeyondWindow, and the default None terminus are all +## preserved verbatim too — the PLAN doesn't collapse them, the DRAW caller +## decides presentation (no ring for any of these three). +func test_build_course_render_plan_preserves_edge_drain_and_continues_and_none_termini() -> void: + for terminus in ["EdgeDrain", "ContinuesBeyondWindow", "None"]: + var course: Dictionary = _course_fixture( + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, [[0, 0], [2048, 0]], terminus + ) + var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan( + course, "District", Vector2i.ZERO, 64, CELL_PIXEL_SIZE + ) + assert_str(plan["terminus"]).is_equal(terminus) + + +## A course with no `terminus` key at all (an old/malformed payload) defaults +## to COURSE_TERMINUS_NONE (the string "None"), never GDScript `null` or an +## empty string — matching the class-fallback graceful-decode posture used +## throughout this cluster. +func test_build_course_render_plan_missing_terminus_defaults_to_none_string() -> void: + var course: Dictionary = { + "edge_id": 1, "class": AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, "points": [[0, 0], [100, 0]] + } + var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan( + course, "District", Vector2i.ZERO, 64, CELL_PIXEL_SIZE + ) + assert_str(plan["terminus"]).is_equal(AtlasWindowGeometryNature.COURSE_TERMINUS_NONE) + + +## A course missing `class` entirely falls back to RIVER_CLASS_FALLBACK +## (TRUNK) — same posture as the skeleton path's river_class fallback. +func test_build_course_render_plan_missing_class_falls_back_to_trunk() -> void: + var course: Dictionary = {"edge_id": 1, "points": [[0, 0], [100, 0]]} + var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan( + course, "District", Vector2i.ZERO, 64, CELL_PIXEL_SIZE + ) + assert_that(plan).is_not_null() + assert_int(plan["cls"]).is_equal(AtlasWindowGeometryNature.RIVER_CLASS_FALLBACK) + + +## Fewer than 2 points (a degenerate single-point or empty course) has no +## line to draw — must construct null, not a 1-point/0-point polyline. +func test_build_course_render_plan_fewer_than_two_points_constructs_nothing() -> void: + var one_point: Dictionary = _course_fixture(AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, [[0, 0]]) + var no_points: Dictionary = _course_fixture(AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, []) + assert_that( + AtlasWindowGeometryNature.build_course_render_plan( + one_point, "District", Vector2i.ZERO, 64, CELL_PIXEL_SIZE + ) + ).is_null() + assert_that( + AtlasWindowGeometryNature.build_course_render_plan( + no_points, "District", Vector2i.ZERO, 64, CELL_PIXEL_SIZE + ) + ).is_null() + + +## Missing `points` key entirely (not just an empty array) must also +## construct nothing, not crash on a null/missing field read. +func test_build_course_render_plan_missing_points_key_constructs_nothing() -> void: + var course: Dictionary = {"edge_id": 1, "class": AtlasWindowGeometryNature.RIVER_CLASS_TRUNK} + var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan( + course, "District", Vector2i.ZERO, 64, CELL_PIXEL_SIZE + ) + assert_that(plan).is_null() + + +## A malformed individual point (not an array, or too short) is skipped — +## not fatal to the whole polyline, matching build_skeleton_chords()'s own +## "skip the bad entry, keep going" posture — as long as >= 2 valid points +## remain. +func test_build_course_render_plan_malformed_point_is_skipped_not_fatal() -> void: + var course: Dictionary = _course_fixture( + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, [[0, 0], "not a point", [2048, 0], [4096, 0]] + ) + var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan( + course, "District", Vector2i.ZERO, 64, CELL_PIXEL_SIZE + ) + assert_that(plan).is_not_null() + var canvas_pts: PackedVector2Array = plan["canvas_pts"] + assert_int(canvas_pts.size()).override_failure_message( + "the malformed point must be skipped, leaving exactly the 3 well-formed points" + ).is_equal(3) + + +## Malformed points that leave FEWER than 2 valid entries must still +## construct null (the "too many bad points" case, distinct from "some bad +## points but enough good ones remain" above). +func test_build_course_render_plan_malformed_points_leaving_too_few_constructs_nothing() -> void: + var course: Dictionary = _course_fixture( + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, [[0, 0], "bad", "also bad"] + ) + var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan( + course, "District", Vector2i.ZERO, 64, CELL_PIXEL_SIZE + ) + assert_that(plan).is_null() + + +## Points are WORLD METRES (Ruling 3h), not heightmap pixels — cross-checked +## against world_m_to_canvas_local() called manually, proving the plan's +## conversion path matches the documented one-fewer-step-than-skeleton +## pipeline (no layer1_pixel_to_world_m() involved at all). +func test_build_course_render_plan_points_are_world_metres_not_pixels() -> void: + var held_center := Vector2i(5, 5) + var held_n := 64 + var world_pt := Vector2(10240.0, -4096.0) # 5 districts east, 2 north of origin + var course: Dictionary = _course_fixture( + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, + [[int(world_pt.x), int(world_pt.y)], [0, 0]] + ) + var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan( + course, "District", held_center, held_n, CELL_PIXEL_SIZE + ) + var expected: Vector2 = AtlasWindowGeometryNature.world_m_to_canvas_local( + world_pt, held_center, held_n, CELL_PIXEL_SIZE + ) + var canvas_pts: PackedVector2Array = plan["canvas_pts"] + assert_that(canvas_pts[0]).is_equal_approx(expected, Vector2.ONE * 0.01) + + # ============================================================================= # T-1170 Ruling 5c: course_class_width_px() / course_class_opacity() — # functional-default companion tables to COURSE_CLASS_VISIBLE_BY_RUNG. diff --git a/client/tests/test_atlas_window_nature_overlay.gd b/client/tests/test_atlas_window_nature_overlay.gd index bc07cfbf5..10d453427 100644 --- a/client/tests/test_atlas_window_nature_overlay.gd +++ b/client/tests/test_atlas_window_nature_overlay.gd @@ -512,3 +512,68 @@ func test_segment_touches_drawn_water_true_when_only_midpoint_is_on_water() -> v + " on drawn water must still be clipped — this is the exact failure" + " mode an endpoints-only rule would miss (Ruling 3g's ask)" ).is_true() + + +# ============================================================================= +# T-1170 Ruling 5b (B3): _draw_course_path() early-return gating — the SAME +# "call the function directly when its early-return happens BEFORE any +# draw_*() call" precedent test_draw_with_null_viewer_is_a_noop() and +# test_draw_with_zero_grid_dims_returns_before_any_draw_call() already +# establish. Every case below returns before _draw_one_course() is ever +# reached, so calling _draw_course_path() directly (no SubViewport/render +# context) is safe. This is a SEPARATE data source/gate from the Layer-1 +# skeleton path above — none of these tests touch _layer1 at all. +# ============================================================================= + + +## The overlay-bar "gen_rivers" toggle gates the course path too — the SAME +## toggle the skeleton path uses (one player-facing "rivers" control covers +## both presentation surfaces, per the ruling). +func test_draw_course_path_returns_before_any_draw_when_gen_rivers_is_off() -> void: + var stub := _ViewerStub.new() + stub.overlay_visibility["gen_rivers"] = false + stub.district_window = { + "n": 64, "granularity_v2": "District", + "courses": [{"class": 2, "points": [[0, 0], [100, 0]], "terminus": "None"}], + } + var o = _make_overlay(stub) + o._draw_course_path() # must return before draw_polyline() — no crash outside a render context + + +## No district window at all (single-window mode hasn't arrived yet) — the +## course path must return cleanly, not crash on a null window read. +func test_draw_course_path_returns_before_any_draw_when_no_window() -> void: + var stub := _ViewerStub.new() + stub.district_window = null + var o = _make_overlay(stub) + o._draw_course_path() + + +## Ruling 3h decode tolerance: a window WITHOUT a `courses` key at all (the +## old/pre-A2 payload shape) must draw NOTHING at District/Quarter except +## mouths-on-land from the skeleton (that's the OTHER path's job) — this +## path itself must simply return, not error or fall back to a dot-scatter. +func test_draw_course_path_missing_courses_field_is_tolerated() -> void: + var stub := _ViewerStub.new() + stub.district_window = {"n": 64, "granularity_v2": "District"} # no "courses" key + var o = _make_overlay(stub) + o._draw_course_path() + + +## An explicitly present but EMPTY courses array must also be tolerated +## cleanly (the loop simply iterates zero times). +func test_draw_course_path_empty_courses_array_is_tolerated() -> void: + var stub := _ViewerStub.new() + stub.district_window = {"n": 64, "granularity_v2": "District", "courses": []} + var o = _make_overlay(stub) + o._draw_course_path() + + +## A `courses` field that is present but the WRONG TYPE (not an Array — e.g. +## a malformed/corrupted payload) must be tolerated the same way as a +## missing field, not crash attempting to iterate a non-Array. +func test_draw_course_path_non_array_courses_field_is_tolerated() -> void: + var stub := _ViewerStub.new() + stub.district_window = {"n": 64, "granularity_v2": "District", "courses": "not an array"} + var o = _make_overlay(stub) + o._draw_course_path() diff --git a/client/ui/implant/apps/atlas/atlas_window_geometry_nature.gd b/client/ui/implant/apps/atlas/atlas_window_geometry_nature.gd index c1c4f7fe4..5f2a22f1f 100644 --- a/client/ui/implant/apps/atlas/atlas_window_geometry_nature.gd +++ b/client/ui/implant/apps/atlas/atlas_window_geometry_nature.gd @@ -137,6 +137,24 @@ const D8_DIRECTION_DELTAS: Array = [ Vector2i(1, -1), # 7 SW ] +# ============================================================================= +# T-1170 Ruling 3h/5b: RiverCourse.terminus wire vocabulary — the +# CourseTerminus enum's variant NAMES as they arrive over msgpack (bare +# strings, the SAME "unit variant -> string tag" convention granularity_v2 +# already uses on this same wire — see AtlasMapProtocol's own doc). `None` on +# the Rust side (a mid-window course that neither reaches a real mouth nor +# the window edge — the ordinary "ends because the chord's amplitude taper +# reached zero at a confluence/headwater anchor cell inside this window" +## case) decodes to the bare string "None" per rmp_serde's unit-variant +## convention — NOT GDScript `null`. Callers must compare against the STRING +## constant below, never `== null`. +# ============================================================================= + +const COURSE_TERMINUS_NONE: String = "None" +const COURSE_TERMINUS_MOUTH: String = "Mouth" +const COURSE_TERMINUS_EDGE_DRAIN: String = "EdgeDrain" +const COURSE_TERMINUS_CONTINUES_BEYOND_WINDOW: String = "ContinuesBeyondWindow" + ## Region+ SKELETON path (Ruling 5a) — the whole-body chord-chain draw, built ## from river_downstream. Region shows every class (the full skeleton) — this ## table's posture is UNCHANGED from wave 1's original @@ -342,6 +360,21 @@ static func layer1_pixel_to_canvas_local( return AtlasWindowGeometryRef.district_to_canvas_local(district, held_center, held_n, cell_pixel_size) +## T-1170 Ruling 5b/3h: RiverCourse.points are already WORLD METRES on the +## wire (unlike the skeleton path's heightmap-pixel `river_cells` — see +## Ruling 3h's wire shape doc) — one fewer conversion step than +## layer1_pixel_to_canvas_local() above: world metres -> fractional district +## (world_m_to_district(), reused verbatim) -> canvas-local +## (AtlasWindowGeometry.district_to_canvas_local(), same shared transform +## every other drawn feature on this screen uses). No pixel-grid/body-radius +## step at all — courses have no heightmap-pixel domain to convert out of. +static func world_m_to_canvas_local( + world_m: Vector2, held_center: Vector2i, held_n: int, cell_pixel_size: float +) -> Vector2: + var district: Vector2 = world_m_to_district(world_m) + return AtlasWindowGeometryRef.district_to_canvas_local(district, held_center, held_n, cell_pixel_size) + + # ============================================================================= # T-1170 Ruling 2a-2d/5a: river_downstream D8 pointer decode. # ============================================================================= @@ -415,6 +448,50 @@ static func build_skeleton_chords( return chords +## T-1170 Ruling 5b/3h — pure course-polyline CONSTRUCTION (no draw calls): +## given one raw `RiverCourse` dict (as decoded off the wire — `class`, +## `points` (world-metres `[x,y]` pairs), `terminus` (a bare string tag)) and +## the window's own `granularity_v2`/`held_center`/`held_n`/`cell_pixel_size`, +## returns `null` if the course should not draw at all at this rung (class +## not visible, missing/degenerate points), or +## `{"canvas_pts": PackedVector2Array, "cls": int, "terminus": String}` +## ready for the caller to draw_polyline() + terminus-marker dispatch. +## +## Class defaults to RIVER_CLASS_FALLBACK (TRUNK) when missing, the same +## graceful-decode posture as the skeleton path's river_class fallback. +## `terminus` defaults to COURSE_TERMINUS_NONE when missing — an ordinary +## interior/no-marker ending, never crashing on an old/malformed payload. +## Malformed individual points are skipped (not fatal to the whole polyline, +## matching build_skeleton_chords()'s own "skip the bad entry, keep going" +## posture) — if fewer than 2 valid points remain after skipping, returns +## `null` (nothing to draw a line between). +static func build_course_render_plan( + course: Dictionary, + granularity_v2: String, + held_center: Vector2i, + held_n: int, + cell_pixel_size: float +) -> Variant: + var cls: int = int(course.get("class", RIVER_CLASS_FALLBACK)) + if not course_class_visible_at_rung(cls, granularity_v2): + return null + var points_raw: Variant = course.get("points") + if not points_raw is Array or (points_raw as Array).size() < 2: + return null + + var canvas_pts: PackedVector2Array = PackedVector2Array() + for pt: Variant in points_raw: + if not (pt is Array and pt.size() >= 2): + continue # malformed point — skip it, don't fail the whole polyline + var world_m := Vector2(float(pt[0]), float(pt[1])) + canvas_pts.append(world_m_to_canvas_local(world_m, held_center, held_n, cell_pixel_size)) + if canvas_pts.size() < 2: + return null # too many malformed points left too few to draw a line + + var terminus: String = str(course.get("terminus", COURSE_TERMINUS_NONE)) + return {"canvas_pts": canvas_pts, "cls": cls, "terminus": terminus} + + # ============================================================================= # T-1156 wave 1 / T-1170: per-rung nature-overlay visibility policy READERS. # The policy TABLES themselves live up in the top-of-file const block per diff --git a/client/ui/implant/apps/atlas/atlas_window_nature_overlay.gd b/client/ui/implant/apps/atlas/atlas_window_nature_overlay.gd index 2cf8a90b2..7336266b6 100644 --- a/client/ui/implant/apps/atlas/atlas_window_nature_overlay.gd +++ b/client/ui/implant/apps/atlas/atlas_window_nature_overlay.gd @@ -11,8 +11,20 @@ extends Node2D ## Ruling 3b this chord chain IS the rung-truncated course at Region ## truncation, not an approximation of it. District/Quarter no longer draw ## the (now-retired) dot-scatter at all; they draw windowed course polylines -## instead (Ruling 5b, _draw_courses(), landing with B3 once the server's -## course inventor — A2/A3 — ships). +## instead (Ruling 5b, _draw_courses()). +## +## T-1170 Ruling 5b (B3): course polylines ride `DistrictWindowLayer.courses` +## — a SEPARATE data source from `_layer1` above (courses arrive on the +## WINDOWED response, `viewer.get_district_window()`, not the whole-body +## Layer-1 response this node requests via request_layer1()). _draw() is +## therefore two INDEPENDENT gates, not one: the skeleton/basin/attractor +## path gates on `_layer1 != null` (unchanged); the course path gates on +## `viewer.get_district_window()` being a Dictionary with a `courses` key, +## entirely independent of whether Layer-1 has arrived yet — a player who +## descends straight to District without the whole-body fetch completing +## still sees courses the moment the window arrives. NO T-1172 water clip on +## this path (Ruling 3g) — courses carry real rung-consistent termini +## server-side (the whole POINT of windowing course invention, Ruling 1d). ## ## This is a PORT, not a reactivation, of the retired planetary-screen draw ## code (atlas_marker_overlay.gd:523-572, _draw_gen_rivers/_draw_gen_basins/ @@ -154,8 +166,24 @@ func get_layer1() -> Variant: return _layer1 +## T-1170 (B3): TWO INDEPENDENT draw gates, not one — see the class doc's own +## "two independent gates" paragraph. The skeleton/basin/attractor path +## (Layer-1, whole-body) is unchanged from wave 1; the course path (windowed, +## Ruling 5b) is a SEPARATE early-return chain reaching _draw_courses(), +## checked regardless of whether `_layer1` has arrived — a player descending +## straight into District/Quarter must see courses without waiting on the +## whole-body Layer-1 fetch this node happens to also own. func _draw() -> void: - if viewer == null or _layer1 == null: + if viewer == null: + return + _draw_skeleton_path() + _draw_course_path() + + +## The pre-T-1170 draw gate, unchanged in shape: whole-body Layer-1 +## (rivers/basins/attractors), gated on `_layer1` having arrived. +func _draw_skeleton_path() -> void: + if _layer1 == null: return var rn: Variant = _layer1.get("river_network") if not rn is Dictionary: @@ -204,6 +232,98 @@ func _draw() -> void: _draw_attractors(ctx) +## T-1170 Ruling 5b (B3): the District/Quarter windowed COURSE path — an +## entirely separate data source (`viewer.get_district_window()`) and draw +## gate from _draw_skeleton_path() above. Gated on `gen_rivers` (the SAME +## overlay-bar toggle the skeleton path uses — one player-facing "rivers" +## toggle covers both presentation surfaces, matching Araminta's ruling that +## the two paths are one continuous feature from the player's perspective, +## not two separate layers to independently show/hide). +## +## Region NEVER reaches this function's draw calls (course_class_visible_at_ +## rung() has no Region key, always false there — Region draws the skeleton +## chord chain, never windowed course content, per Ruling 1). Tile mode +## (the Region orbital mosaic) also never carries `district_window` data at +## all (get_district_window() is single-window-mode-only, per that +## accessor's own doc — courses simply never reach this path in tile mode by +## construction, no separate is_tile_mode() guard needed here). +func _draw_course_path() -> void: + if not viewer.is_overlay_visible("gen_rivers"): + return + var window: Variant = viewer.get_district_window() + if not window is Dictionary: + return + var w: Dictionary = window + var courses: Variant = w.get("courses") + if not courses is Array: + return # missing `courses` field (old/pre-A2 payload) — draw nothing, see class doc + var granularity_v2: String = str(w.get("granularity_v2", "District")) + var ctx := { + "held_center": viewer.get_held_center(), + "held_n": viewer.get_held_n(), + "cell_px": viewer.get_cell_pixel_size(), + "granularity_v2": granularity_v2, + "view_zoom": viewer.get_view_zoom(), + } + + for course: Variant in courses: + if not course is Dictionary: + continue + _draw_one_course(course, ctx) + + +## T-1170 Ruling 5b: one RiverCourse's polyline draw — class-filtered per +## COURSE_CLASS_VISIBLE_BY_RUNG, width/opacity from the COURSE_CLASS_WIDTH_PX/ +## COURSE_CLASS_OPACITY companion tables (zoom-compensated via _zs(), the SAME +## discipline every other marker in this cluster follows — PR #195's +## stroke-width miss is the standing regression class this exists to +## prevent). Points arrive as `Vec<(i32,i32)>` WORLD METRES (Ruling 3h, NOT +## heightmap pixels — see world_m_to_canvas_local()'s own doc for why this is +## one conversion step shorter than the skeleton path). NO T-1172 water +## clip on this path (Ruling 3g) — courses carry real rung-consistent +## termini server-side; that is the entire point of windowing course +## invention (Ruling 1d). +## +## Terminus handling (Ruling 3h's CourseTerminus vocabulary): +## - Mouth: double-ring at the LAST point (the real coast anchor — mouths +## return as real geometry here, per Ruling 3g/3e). +## - EdgeDrain: no ring (Ruling 3f — pole-edge drains are grid artifacts, +## not river-meets-sea events; same disposition as the skeleton path's +## EDGE_DRAIN sentinel). +## - ContinuesBeyondWindow: draw to the last point, no marker (the course +## keeps going outside this window's crop — nothing to mark AT this +## window's edge, the polyline simply stops because the data stops). +## - None: an ordinary interior terminus (a headwater/confluence anchor +## inside this window) — no marker, same as ContinuesBeyondWindow's "just +## stop drawing" treatment; the two differ in MEANING (why the points ran +## out) but not in PRESENTATION (neither gets a ring). +## The actual gating/construction (class visibility, point decode, terminus +## lookup) is delegated to AtlasWindowGeometryNature.build_course_render_plan() +## — a pure function with no draw calls, unit-tested directly in +## test_atlas_window_geometry_nature.gd, the SAME split B2's +## build_skeleton_chords() already established. This function's own job is +## just the draw calls the plan feeds. +func _draw_one_course(course: Dictionary, ctx: Dictionary) -> void: + var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan( + course, ctx["granularity_v2"], ctx["held_center"], ctx["held_n"], ctx["cell_px"] + ) + if plan == null: + return + var canvas_pts: PackedVector2Array = plan["canvas_pts"] + var cls: int = plan["cls"] + var terminus: String = plan["terminus"] + + var width: float = _zs(AtlasWindowGeometryNature.course_class_width_px(cls), ctx) + var opacity: float = AtlasWindowGeometryNature.course_class_opacity(cls) + var color := Color(COLOR_GEN_RIVER.r, COLOR_GEN_RIVER.g, COLOR_GEN_RIVER.b, COLOR_GEN_RIVER.a * opacity) + draw_polyline(canvas_pts, color, width) + + if terminus == AtlasWindowGeometryNature.COURSE_TERMINUS_MOUTH: + _draw_mouth(canvas_pts[canvas_pts.size() - 1], ctx) + # EdgeDrain/ContinuesBeyondWindow/None: no marker — draw to the last + # point and stop, per the doc above. + + ## Circumference in districts, for nearest_wrap_image()'s wrap resolution — ## mirrors AtlasWindowOverlay._draw_tile_mosaic()'s own `cols` computation ## exactly (same source, same reason: only meaningful in tile/orbital mode on diff --git a/client/ui/implant/apps/atlas/atlas_window_viewer.gd b/client/ui/implant/apps/atlas/atlas_window_viewer.gd index f89801044..54838b898 100644 --- a/client/ui/implant/apps/atlas/atlas_window_viewer.gd +++ b/client/ui/implant/apps/atlas/atlas_window_viewer.gd @@ -504,6 +504,7 @@ func _on_window_ready(window: Dictionary) -> void: _legend_panel.refresh() queue_redraw() _overlay_node.queue_redraw() + _nature_overlay.queue_redraw() # T-1170 B3: courses read THIS window too ## T-1153 (design doc §4 "progressive... with visible refinement as tiles diff --git a/client/ui/implant/apps/atlas/atlas_window_water_clip.gd b/client/ui/implant/apps/atlas/atlas_window_water_clip.gd index acb5e699f..2fd654a99 100644 --- a/client/ui/implant/apps/atlas/atlas_window_water_clip.gd +++ b/client/ui/implant/apps/atlas/atlas_window_water_clip.gd @@ -14,9 +14,21 @@ extends RefCounted ## clip against whichever composite cell is currently ON SCREEN at a given ## river dot's position — strict drop, no snap (a dot that lands on drawn ## water is simply not drawn; Region's 205 km cells may amputate a river's -## final coastal dots, an accepted cost per the ruling). T-1172 clip — -## retire when T-1170 course invention terminates courses at the invented -## coast. +## final coastal dots, an accepted cost per the ruling). +## +## T-1170 Ruling 3g update (RESTRUCTURED, not blanket-retired): the clip is +## RETIRED for the District/Quarter COURSE-drawing rungs +## (AtlasWindowNatureOverlay._draw_course_path()/_draw_one_course()) — +## courses carry real rung-consistent termini invented server-side against +## the SAME rung's drawn coast, so the clip's job is already done there. This +## file's clip machinery is STILL LIVE and used at the Region SKELETON path +## (_draw_skeleton_chords()/_segment_touches_drawn_water()) — Region still +## draws the whole-body skeleton against a rung-dependent drawn coast, which +## is precisely the presentation-frame reconciliation this file exists for. +## The Region clip is PERMANENT-UNTIL-REGION-GOES-WINDOWED (T-1143 ruling 2's +## progressive tiling) — when Region itself becomes a windowed rung, it +## inherits windowed courses too, and this file retires entirely at that +## point, not before. ## ## const AtlasWindowWaterClip := preload("res://ui/implant/apps/atlas/atlas_window_water_clip.gd")