diff --git a/.pql/changelog/ticket_history/2026-07.sql b/.pql/changelog/ticket_history/2026-07.sql index 77affaac0..379482069 100644 --- a/.pql/changelog/ticket_history/2026-07.sql +++ b/.pql/changelog/ticket_history/2026-07.sql @@ -3280,3 +3280,4 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang RESOLVED 2026-07-27 by the edge-scroll fix (commit dac64a8a4) — this ticket''s diagnosis was wrong. I filed it blaming the capture harness for parking the mouse in a corner. The harness was faithfully reproducing a REAL product bug: _gui_input only fires while the pointer is over the Control, so _last_mouse_pos froze wherever it was last seen, and leaving the map always means crossing an edge, so the frozen value was always inside the edge margin. The viewer then panned forever with no input. NOTIFICATION_MOUSE_EXIT now resets to the (-1,-1) sentinel. Evidence: captures previously reported canvas_position=(292.0, 657.3336) — off-centre AND fractional, which ruled out center_offset() since that floors; they now report whole-number positions like (423.0, 0.0) with no drift across runs. No harness change was needed or made. Kept rather than deleted because the mis-attribution is worth seeing: a tooling explanation was reached for before the product was checked.', NULL, '2026-07-28 15:11:31', '2026-07-28 15:11:31.728', '2026-07-28 15:11:31.728', NULL, 'eaf52c758698ad7deace9e670825a90b', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FTA26EM8YXW086EV2K0N2WZR', 'status', 'backlog', 'done', NULL, '2026-07-28 15:11:31', '2026-07-28 15:11:31.754', '2026-07-28 15:11:31.754', NULL, '6a20816c1df4d1cb2287c801186fdcf1', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FTEX51V1CH70BS8WAPZX0HH4', 'status', 'backlog', 'in_progress', NULL, '2026-07-28 15:12:52', '2026-07-28 15:12:52.681', '2026-07-28 15:12:52.681', NULL, 'cf672e646d4478421b6ee3e908a9bc45', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-07.sql b/.pql/changelog/tickets/2026-07.sql index 3b4eaaa84..5a88107c7 100644 --- a/.pql/changelog/tickets/2026-07.sql +++ b/.pql/changelog/tickets/2026-07.sql @@ -5580,3 +5580,4 @@ RESOLVED 2026-07-27 by the edge-scroll fix (commit dac64a8a4) — this ticket''s INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FTA26EM8YXW086EV2K0N2WZR', 'bug', '06FBPPMZNNEV052DBYYY3A897C', 'visual_capture drifts the Atlas view — edge-scroll fires during the settle', 'Found 2026-07-27 while verifying the Global fill fix. An atlas capture reported canvas_position=(292.0, 657.3336) where the letterbox centre should be a whole-pixel value near y=133-277. The Y is both wrong AND fractional, which rules out center_offset() -- that floors to whole pixels for texel-exactness. The cause is edge scroll: the capture harness leaves the mouse at the viewport corner, StepCanvasViewer reads it as a held edge-scroll, and pans continuously through the 240 settle ticks, pushing the canvas down and right (x pinned at the legend column, y drifting). Consequences: (1) every Atlas golden is captured mid-pan, so the baselines encode an arbitrary drift offset rather than the canonical centred frame; (2) a real centring regression would be invisible against them; (3) captures are not reproducible run-to-run if tick timing varies. Fix shape: the capture harness should suppress edge-scroll (park the mouse centre-screen, or expose a viewer flag the harness sets), then assert the settled position IS the centred letterbox. Worth doing before any Atlas golden is trusted for centring -- note the goldens were ALSO degenerate for a separate reason until today (missing body_radius_km), so this rung''s baselines have never been meaningful. RESOLVED 2026-07-27 by the edge-scroll fix (commit dac64a8a4) — this ticket''s diagnosis was wrong. I filed it blaming the capture harness for parking the mouse in a corner. The harness was faithfully reproducing a REAL product bug: _gui_input only fires while the pointer is over the Control, so _last_mouse_pos froze wherever it was last seen, and leaving the map always means crossing an edge, so the frozen value was always inside the edge margin. The viewer then panned forever with no input. NOTIFICATION_MOUSE_EXIT now resets to the (-1,-1) sentinel. Evidence: captures previously reported canvas_position=(292.0, 657.3336) — off-centre AND fractional, which ruled out center_offset() since that floors; they now report whole-number positions like (423.0, 0.0) with no drift across runs. No harness change was needed or made. Kept rather than deleted because the mis-attribution is worth seeing: a tooling explanation was reached for before the product was checked.', 'done', 'medium', NULL, 'client', NULL, '2026-07-27 19:24:05.666', '2026-07-28 15:11:31.754', NULL, '39a5284eeee2c2637ed19a6c4a080e0a', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FTEX51V1CH70BS8WAPZX0HH4', 'story', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Render rivers as cartographic strokes (D-261)', 'Implements D-261 in step_canvas_annotation_layer.gd. All four rules are client-side over existing server data -- no wire change needed. (1) FIXED 5 px screen-space stroke at every rung, replacing the current per-class ladder (0.6/1.2/2.4). (2) CONTIGUOUS polyline through the river''s own cell centres, so geometry is a function of the river rather than the canvas pitch -- this is the actual fix for the fragments, since courses are presently resampled at canvas pitch and a course shorter than one gridunit collapses to a dot. (3) NEVER DRAWN OVER WATER: truncate each course where it meets ocean or lake, using the per-cell classification already present in the adopted canvas. (4) CULL below 15 px of on-screen length (3x the stroke width -- below that it reads as a square, not a line). Measure the VISIBLE extent, not total river length: a course crossing the window always spans it and passes, so only a course lying wholly inside the view and small is culled, and no new wire field is required. PERFORMANCE: compute the clipped/culled polylines ONCE on canvas adoption (set_frame), never per draw -- the layer redraws every frame and a per-point water lookup across ~375 courses per frame is waste for geometry that only changes when a canvas arrives. VERIFY by capture, not by reasoning: the pre-change baseline is 375 courses on Ferrath Global differing from courses-off by 458 of 518,400 pixels. After this, Global should show only major systems (>264 km on Ferrath) as clean unbroken strokes that stop at the coastline, with tributaries appearing on descent. NOTE the per-class width grammar is deliberately flattened here; the thinning-by-size polish is a separate follow-up (see D-261''s Deferred section).', 'in_progress', 'medium', NULL, 'client', 'D-261', '2026-07-28 06:41:06.520', '2026-07-28 15:12:52.681', NULL, 'cd504a7925b6e2a16387efc5896bbaf9', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/client/tests/test_step_canvas_annotation_layer.gd b/client/tests/test_step_canvas_annotation_layer.gd index 5718979f7..e5cc635fb 100644 --- a/client/tests/test_step_canvas_annotation_layer.gd +++ b/client/tests/test_step_canvas_annotation_layer.gd @@ -438,3 +438,64 @@ func test_set_frame_with_an_interior_source_course_does_not_crash() -> void: } layer.set_frame(canvas, Vector2.ZERO, "District", Vector2i(4, 4), 0.0) assert_object(layer).is_not_null() + +# ----------------------------------------------------------------------- +# D-261 — rivers as cartographic strokes +# ----------------------------------------------------------------------- + + +func _canvas_with_courses(courses: Array) -> Dictionary: + return {"width": 4, "height": 4, "courses": courses} + + +## A server "course" is an EDGE of the river network — the stretch between two +## confluences — not a river. Culling per course therefore culls per SEGMENT, +## and a long river assembled from many short edges vanishes entirely. Measured +## on Ferrath's Global canvas before chaining: 375 courses, 180 surviving the +## water clip, ZERO surviving the length cull. After chaining: 5 rivers. +func test_edges_chain_into_one_river_before_the_length_cull() -> void: + var layer: StepCanvasAnnotationLayer = auto_free(StepCanvasAnnotationLayer.new()) + add_child(layer) + # Three collinear edges, each meeting the next end-to-end. Sized so each + # is individually UNDER the cull and the chain is comfortably over it. + # At District with a 4x4 canvas the pitch is 512 m/gridunit and 2 px per + # gridunit, i.e. 256 m per screen px — so the 15 px floor is 3,840 m. + # 1,500 m per edge: 5.9 px alone (culled), 17.6 px chained (kept). + var step: float = 1500.0 + var edges: Array = [] + for i in range(3): + edges.append( + { + "class": 2, + "points": [ + [float(i) * step, 0.0], + [float(i + 1) * step, 0.0], + ], + } + ) + layer.set_frame(_canvas_with_courses(edges), Vector2.ZERO, "District", Vector2i(4, 4), 0.0) + assert_int(layer.get_drawn_course_count()).override_failure_message( + "three end-to-end edges must chain into ONE river, not be culled as three segments" + ).is_equal(1) + + +## The cull itself: a river too short to read as a line is not drawn at all, +## rather than drawn as a speck. 5 px wide by 5 px long is a square. +func test_a_river_shorter_than_the_stroke_reads_is_not_drawn() -> void: + var layer: StepCanvasAnnotationLayer = auto_free(StepCanvasAnnotationLayer.new()) + add_child(layer) + var tiny: Array = [{"class": 2, "points": [[0.0, 0.0], [0.001, 0.0]]}] + layer.set_frame(_canvas_with_courses(tiny), Vector2.ZERO, "District", Vector2i(4, 4), 0.0) + assert_int(layer.get_drawn_course_count()).override_failure_message( + "a sub-threshold river must be dropped, not drawn as a speck" + ).is_equal(0) + + +## The stroke is a fixed screen-space width at every rung (D-261) — the +## per-class ladder is deliberately flattened until T-1238 restores a +## size-varying width. +func test_stroke_width_is_one_fixed_value() -> void: + assert_float(StepCanvasAnnotationLayer.COURSE_WIDTH_PX).is_equal_approx(5.0, 0.001) + assert_float(StepCanvasAnnotationLayer.MIN_COURSE_LENGTH_PX).override_failure_message( + "the cull must DERIVE from the stroke (3x) so it self-corrects if the width changes" + ).is_equal_approx(StepCanvasAnnotationLayer.COURSE_WIDTH_PX * 3.0, 0.001) diff --git a/client/tests/visual_capture.gd b/client/tests/visual_capture.gd index 7e7361373..05e788dac 100644 --- a/client/tests/visual_capture.gd +++ b/client/tests/visual_capture.gd @@ -335,7 +335,7 @@ func _log_atlas_view_transform(tree_root: Node, scenario_name: String) -> void: ( "visual_capture: view-transform[%s] rung=%s world_center=%s held_extent=%s " + "canvas_position=%s canvas_scale=%s footprint_px=%s canvas_cells=%dx%d " - + "courses=%d settlements=%d" + + "courses=%d drawn=%d settlements=%d" ) % [ scenario_name, @@ -348,6 +348,7 @@ func _log_atlas_view_transform(tree_root: Node, scenario_name: String) -> void: int(summary.get("canvas_width", 0)), int(summary.get("canvas_height", 0)), int(summary.get("course_count", 0)), + int(summary.get("drawn_course_count", 0)), int(summary.get("settlement_count", 0)), ] ) diff --git a/client/ui/implant/apps/atlas/step_canvas/step_canvas_annotation_layer.gd b/client/ui/implant/apps/atlas/step_canvas/step_canvas_annotation_layer.gd index 408f6d77c..aa1f4a5c6 100644 --- a/client/ui/implant/apps/atlas/step_canvas/step_canvas_annotation_layer.gd +++ b/client/ui/implant/apps/atlas/step_canvas/step_canvas_annotation_layer.gd @@ -63,6 +63,9 @@ const RIVER_CLASS_TRUNK: int = 2 ## every class wider). Opacity table is UNCHANGED — Araminta's T-1170 ruling ## (width+opacity sufficient, hue solvable-later) already gives stream a ## faded read; widening the gap in WIDTH is the one lever this pass turns. +## SUPERSEDED BY D-261 (2026-07-28) — kept for the width grammar's rationale +## and because T-1238 restores a size-varying width on top of the new stroke +## model. Not read by the draw path any more; COURSE_WIDTH_PX is. const COURSE_CLASS_WIDTH_PX: Dictionary = { RIVER_CLASS_STREAM: 0.6, RIVER_CLASS_TRIBUTARY: 1.2, RIVER_CLASS_TRUNK: 2.4 } @@ -70,6 +73,37 @@ const COURSE_CLASS_OPACITY: Dictionary = { RIVER_CLASS_STREAM: 0.8, RIVER_CLASS_TRIBUTARY: 0.9, RIVER_CLASS_TRUNK: 1.0 } +## D-261: a river is a CARTOGRAPHIC STROKE, not terrain drawn to scale. +## +## A real channel is sub-pixel at nearly every Atlas scale — 100 m of water +## against Global's ~17.6 km per screen pixel — so drawing it to scale draws +## nothing. Measured before this record: 375 courses on Ferrath's Global canvas +## changed 458 of 518,400 pixels. The rivers were not failing to render, they +## were correctly beneath notice. One fixed screen-space width at every rung, +## replacing the per-class ladder above (deliberately flattened; T-1238). +const COURSE_WIDTH_PX: float = 5.0 + +## The minimum on-screen length a course must have to be drawn at all. +## +## DERIVED, not stipulated: a mark reads as a *line* only at roughly 3x its own +## width — 5 px by 5 px is a square, not a river. So the floor is 3 x +## COURSE_WIDTH_PX, and the kilometre thresholds then fall out of each rung's +## own scale instead of being written down: ~264 km at Global, 2.8 km at +## Region, 28 m at District (Ferrath). A level-of-detail ladder with no +## hand-tuned constants, which self-corrects if the stroke width changes. +## +## Measured on the VISIBLE extent, not total river length. A course crossing +## the window always spans it and so always passes; only a course lying wholly +## inside the view AND small is culled — which is exactly "too small to see", +## and needs no total-length field on the wire. +const MIN_COURSE_LENGTH_PX: float = COURSE_WIDTH_PX * 3.0 + +## Morphology zone ids that ARE water (server: MorphologyZone::OpenOcean = 0, +## Lake = 1). D-261: a course is truncated where it meets either — a blue line +## across open sea asserts something false. +const MORPHOLOGY_OPEN_OCEAN: int = 0 +const MORPHOLOGY_LAKE: int = 1 + ## Source tapering (T-1175 seeded item 2 — "courses taper to a point at ## their upstream source instead of starting at full class width", the ## classic cartographic river grammar the RimWorld reference shows on every @@ -168,6 +202,10 @@ var _extent_cells: Vector2i = Vector2i.ZERO ## the elastic seam) the body itself. All four therefore travel together. var _body_radius_km: float = 0.0 +## Drawable course geometry, rebuilt once per set_frame() and read every draw +## (D-261). Entries: {points: PackedVector2Array, terminus: String, taper: bool}. +var _prepared_courses: Array = [] + ## Adopt a new canvas + its request frame (world center, rung, extent, body ## radius) — the world->screen projection for every drawn feature depends on @@ -185,14 +223,244 @@ func set_frame( _rung = rung _extent_cells = extent_cells _body_radius_km = body_radius_km + _prepare_courses() queue_redraw() func clear_frame() -> void: _canvas = null + _prepared_courses.clear() queue_redraw() +## Build every course's drawable geometry ONCE per canvas adoption (D-261). +## +## Deliberately not per-frame. `_draw()` runs every frame, and the work here — +## decoding the morphology plane, projecting each point, testing each against +## water — is a pure function of the adopted canvas. Doing it per draw would +## repeat a few hundred courses' worth of lookups for geometry that only +## changes when a new canvas arrives. +## +## Each course becomes zero or more RUNS. A run is a maximal stretch of the +## course that is over land: crossing into ocean or lake ends the run, and +## re-emerging starts a new one. That is how "never drawn over water" is +## implemented — not by clipping a drawn line, but by never building geometry +## there in the first place. Runs shorter than MIN_COURSE_LENGTH_PX are +## dropped entirely rather than drawn as specks. +func _prepare_courses() -> void: + _prepared_courses.clear() + if not _canvas is Dictionary: + return + var d: Dictionary = _canvas + var courses_raw: Variant = d.get("courses", []) + if not courses_raw is Array: + return + var morphology: Variant = _decode_morphology(d) + + for course_raw: Variant in (courses_raw as Array): + if not course_raw is Dictionary: + continue + var course: Dictionary = course_raw + var points_raw: Variant = course.get("points") + if not points_raw is Array or (points_raw as Array).size() < 2: + continue + + var terminus: String = str(course.get("terminus", "")) + var run := PackedVector2Array() + var run_is_first: bool = true + var first_world_m: Variant = null + + for pt: Variant in (points_raw as Array): + if not (pt is Array and pt.size() >= 2): + continue + var world_m := Vector2(float(pt[0]), float(pt[1])) + if first_world_m == null: + first_world_m = world_m + if _is_water_at_world_m(morphology, world_m): + # Water ends the run. Flush what we have and start fresh. + _flush_run(run, run_is_first, first_world_m, "") + if not run.is_empty(): + run_is_first = false + run = PackedVector2Array() + continue + run.append(_world_to_local(world_m)) + + # The final run owns the course's terminus marker — earlier runs were + # cut short by water and have no mouth of their own. + _flush_run(run, run_is_first, first_world_m, terminus) + + # Chain the runs into rivers BEFORE culling — see _chain_runs(). + _prepared_courses = _chain_runs(_prepared_courses) + _cull_short(_prepared_courses) + + +## Accept a candidate run if it is long enough on screen to read as a line. +func _flush_run( + run: PackedVector2Array, is_first_run: bool, first_world_m: Variant, terminus: String +) -> void: + if run.size() < 2: + return + _prepared_courses.append( + { + "points": run, + "terminus": terminus, + # Only the FIRST run can begin at the river's true source; a later + # run begins wherever water released it, which is not a headwater + # and must not be tapered to a point (PR #207 finding 1's rule, + # extended to the runs D-261 introduces). + "taper": is_first_run and _is_true_source_in_canvas(first_world_m), + } + ) + + + +## Join runs that meet end-to-end into whole rivers, then cull. +## +## THE UNIT PROBLEM. A server "course" is an EDGE of the river network — the +## stretch between two confluences — not a river. Culling per course therefore +## culls per segment, and a 1,000 km river assembled from fifty 20 km edges +## vanishes entirely because no single edge clears the threshold. Measured on +## Ferrath's Global canvas: 375 courses, 180 surviving the water clip, and +## ZERO surviving a per-course length cull. +## +## Chaining also delivers the other half of D-261's "contiguous" requirement. +## Per-course contiguity only makes each edge unbroken; it is the joining that +## makes a river read as one line rather than a row of dashes. +## +## Greedy: take any unused run, extend it downstream while some unused run +## starts where it ends, then upstream likewise. O(n) lookups via an endpoint +## index rather than an O(n^2) scan. +func _chain_runs(runs: Array) -> Array: + if runs.size() < 2: + return runs + var starts: Dictionary = {} # quantised start point -> [run indices] + for i in range(runs.size()): + var key: String = _point_key((runs[i]["points"] as PackedVector2Array)[0]) + if not starts.has(key): + starts[key] = [] + (starts[key] as Array).append(i) + + var used: Dictionary = {} + var chained: Array = [] + for i in range(runs.size()): + if used.has(i): + continue + used[i] = true + var entry: Dictionary = runs[i] + var pts := PackedVector2Array(entry["points"]) + # Extend downstream: repeatedly find an unused run beginning where this + # one ends. The terminus travels with the LAST link, since that is the + # end that actually reaches the sea. + var terminus: String = str(entry["terminus"]) + while true: + var next_i: int = _take_run_starting_at(starts, used, pts[pts.size() - 1]) + if next_i < 0: + break + var nxt: Dictionary = runs[next_i] + var npts: PackedVector2Array = nxt["points"] + for j in range(1, npts.size()): # skip the shared joint + pts.append(npts[j]) + terminus = str(nxt["terminus"]) + chained.append( + { + "points": pts, + "terminus": terminus, + # Taper only if this chain BEGINS at a true source. Extending + # downstream never changes where the chain starts, so the head + # run's own judgement still holds. + "taper": bool(entry["taper"]), + } + ) + return chained + + +## First unused run whose start coincides with `at`, or -1. +static func _take_run_starting_at(starts: Dictionary, used: Dictionary, at: Vector2) -> int: + var key: String = _point_key(at) + if not starts.has(key): + return -1 + for idx_raw: Variant in (starts[key] as Array): + var idx: int = idx_raw + if not used.has(idx): + used[idx] = true + return idx + return -1 + + +## Quantise a screen point to a joinable key. Confluence endpoints come from +## the same server coordinate and survive an identical linear projection, so +## they agree closely; rounding to a tenth of a pixel absorbs float drift +## without gluing genuinely separate rivers together. +static func _point_key(p: Vector2) -> String: + return "%d:%d" % [roundi(p.x * 10.0), roundi(p.y * 10.0)] + + +## Drop chains too short to read as a line (D-261). Done AFTER chaining, so the +## measurement is of a river rather than of one of its segments. +func _cull_short(chains: Array) -> void: + var keep: Array = [] + for entry_raw: Variant in chains: + var entry: Dictionary = entry_raw + if _polyline_length_px(entry["points"]) >= MIN_COURSE_LENGTH_PX: + keep.append(entry) + chains.clear() + chains.append_array(keep) + +## How many course RUNS survived preparation and will actually be stroked. +## +## Distinct from the canvas's own course count, and the distinction is the +## whole point: "375 courses arrived" and "375 rivers are drawn" are different +## claims, and conflating them is what made an empty map look like a data +## problem. Surfaced in the viewer summary so a capture can tell them apart. +func get_drawn_course_count() -> int: + return _prepared_courses.size() + + +static func _polyline_length_px(pts: PackedVector2Array) -> float: + var total: float = 0.0 + for i in range(1, pts.size()): + total += pts[i].distance_to(pts[i - 1]) + return total + + +## Decode the morphology plane for the water test. Mirrors the terrain layer's +## own `_decode_l8_plane` rather than sharing it — the two layers are siblings +## with no dependency between them, and this is one decode per canvas. +func _decode_morphology(canvas: Dictionary) -> Variant: + var field: Variant = canvas.get("morphology") + if not field is PackedByteArray or (field as PackedByteArray).is_empty(): + return null + var img := Image.new() + if img.load_png_from_buffer(field) != OK: + push_warning("StepCanvasAnnotationLayer: morphology decode failed — water clip disabled") + return null + return img + + +## Is this world point over ocean or lake? +## +## Returns FALSE when the plane is unavailable, so a decode failure degrades to +## the pre-D-261 behaviour (rivers drawn everywhere) rather than to a blank map +## — a missing clip is a cosmetic defect, a suppressed river is a missing one. +func _is_water_at_world_m(morphology: Variant, world_m: Vector2) -> bool: + if morphology == null: + return false + var img: Image = morphology + var spacing: float = _spacing_m() + if spacing <= 0.0: + return false + var col: int = int( + floor((world_m.x - _world_center.x) / spacing + float(_extent_cells.x) * 0.5) + ) + var row: int = int( + floor((world_m.y - _world_center.y) / spacing + float(_extent_cells.y) * 0.5) + ) + if col < 0 or row < 0 or col >= img.get_width() or row >= img.get_height(): + return false + var zone: int = int(img.get_pixel(col, row).r8) + return zone == MORPHOLOGY_OPEN_OCEAN or zone == MORPHOLOGY_LAKE + + ## 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. @@ -208,46 +476,19 @@ func _draw() -> void: _draw_settlements(d) -func _draw_courses(courses: Array) -> void: - for course_raw: Variant in courses: - if not course_raw is Dictionary: - continue - _draw_one_course(course_raw) - - -func _draw_one_course(course: Dictionary) -> void: - var cls: int = int(course.get("class", RIVER_CLASS_TRUNK)) - var points_raw: Variant = course.get("points") - if not points_raw is Array or (points_raw as Array).size() < 2: - return - - var screen_pts := PackedVector2Array() - # First WELL-FORMED point's world position — the crop-edge gate (finding - # 1) reads THIS, not screen_pts[0], which is already screen-projected. - var first_world_m: Variant = null - for pt: Variant in points_raw: - if not (pt is Array and pt.size() >= 2): - continue - var world_m := Vector2(float(pt[0]), float(pt[1])) - if first_world_m == null: - first_world_m = world_m - screen_pts.append(_world_to_local(world_m)) - if screen_pts.size() < 2: - return - - var width: float = float(COURSE_CLASS_WIDTH_PX.get(cls, COURSE_CLASS_WIDTH_PX[RIVER_CLASS_STREAM])) - var opacity: float = float(COURSE_CLASS_OPACITY.get(cls, COURSE_CLASS_OPACITY[RIVER_CLASS_STREAM])) - var color := Color(COLOR_RIVER.r, COLOR_RIVER.g, COLOR_RIVER.b, COLOR_RIVER.a * opacity) - - # Finding 1 (PR #207, Araminta, blocking): only taper when points[0] is - # the course's TRUE source (inside this canvas's own declared bounds) — - # see _is_true_source_in_canvas()'s own doc for the crop-overhang gate. - var is_true_source: bool = _is_true_source_in_canvas(first_world_m) - _draw_tapered_course(screen_pts, width, color, is_true_source) - - var terminus: String = str(course.get("terminus", "")) - if terminus == COURSE_TERMINUS_MOUTH: - _draw_mouth_ring(screen_pts[screen_pts.size() - 1]) +## Stroke the geometry `_prepare_courses()` already built. Everything +## expensive — projection, the water test, the length cull — happened once on +## canvas adoption (D-261); this runs every frame and must stay cheap. +func _draw_courses(_courses: Array) -> void: + for entry_raw: Variant in _prepared_courses: + var entry: Dictionary = entry_raw + var pts: PackedVector2Array = entry["points"] + # One width and one colour for every river (D-261): the per-class + # ladder is deliberately flattened until T-1238 restores a + # size-varying stroke. + _draw_tapered_course(pts, COURSE_WIDTH_PX, COLOR_RIVER, bool(entry["taper"])) + if str(entry["terminus"]) == COURSE_TERMINUS_MOUTH: + _draw_mouth_ring(pts[pts.size() - 1]) ## PR #207 finding 1 — the crop-overhang detection gate. `canvas_rect` is diff --git a/client/ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd b/client/ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd index 4f2337413..18e043b05 100644 --- a/client/ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd +++ b/client/ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd @@ -204,6 +204,14 @@ static func display_ratio_for_rung(rung: String) -> float: ## 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. +## **If you change this, change `uses_orbital_derive` in step_canvas.rs in the +## same commit.** That function is the AUTHORITY; this is a mirror. On +## 2026-07-26 this side was changed to Global-only and the server was not, so +## for two days the client believed Region took the full courses-aware derive +## while the server kept it envelope-only and course-free — both suites green +## the whole time, because neither test compares itself to the other. They +## cannot be cross-checked automatically (different languages, no shared +## fixture for this predicate), so the coupling is held by this note. static func is_orbital_rung(rung: String) -> bool: return rung == RUNG_GLOBAL diff --git a/client/ui/implant/apps/atlas/step_canvas/step_canvas_viewer.gd b/client/ui/implant/apps/atlas/step_canvas/step_canvas_viewer.gd index 8aaf5ba96..cb6b125a7 100644 --- a/client/ui/implant/apps/atlas/step_canvas/step_canvas_viewer.gd +++ b/client/ui/implant/apps/atlas/step_canvas/step_canvas_viewer.gd @@ -431,6 +431,9 @@ func get_current_canvas_summary() -> Dictionary: "canvas_width": int(d.get("width", 0)), "canvas_height": int(d.get("height", 0)), "course_count": courses.size(), + "drawn_course_count": ( + _annotation_layer.get_drawn_course_count() if _annotation_layer else 0 + ), "course_count_by_class": course_count_by_class, "cliff_count": (d.get("cliffs", []) as Array).size(), "settlement_count": _count_distinct_settlements(d), diff --git a/server/src/atlas/step_canvas.rs b/server/src/atlas/step_canvas.rs index 3e00ee77f..2aad97811 100644 --- a/server/src/atlas/step_canvas.rs +++ b/server/src/atlas/step_canvas.rs @@ -154,15 +154,26 @@ impl StepCanvasRung { } /// The derivation mode this rung uses (D-255(a)/(f), Dudley round-2 §(b)): - /// [`Self::Global`] and [`Self::Region`] both ride `derive_orbital_at_metres` - /// (the coarse envelope-only derivation — no invented terrain, D-243 - /// region/orbital spacing sits below `detail_scatter`'s own octave floor); - /// every fixed sub-region rung rides `derive_at_metres` (full - /// classification, courses-aware). This mirrors - /// `layer_proxy::derive_window_cell`'s existing `WindowGranularity::Region` - /// vs `District | Quarter` split, extended down through Block/Chunk. + /// [`Self::Global`] rides `derive_orbital_at_metres` (the coarse + /// envelope-only derivation — no invented terrain, since orbital spacing + /// sits below `detail_scatter`'s own octave floor); every fixed rung rides + /// `derive_at_metres` (full classification, courses-aware). + /// + /// **Region left this set** (D-255 amendment, 2026-07-26). It was + /// envelope-only because a Region canvas spanned ~251,658 km, where + /// nothing finer than an envelope was meaningful. Post-inversion it spans + /// 262x466 km — a genuine provincial map — so it derives like the rungs + /// below it. + /// + /// **Corrected 2026-07-28.** That amendment was implemented against the + /// CLIENT's mirror (`step_canvas_transport.gd::is_orbital_rung`) only. + /// THIS function is the authority, and still read `Global | Region`, so + /// Region went on running envelope-only and carrying no courses — the + /// precise thing the amendment claimed it had stopped doing. A mirror + /// updated without its source is worse than no mirror at all: both sides + /// read plausibly, and they disagree only in production. pub fn uses_orbital_derive(self) -> bool { - matches!(self, StepCanvasRung::Global | StepCanvasRung::Region) + matches!(self, StepCanvasRung::Global) } /// Global's variable canvas extent for a body of the given radius: @@ -647,9 +658,13 @@ fn invent_courses_for_canvas( step_m: f64, min_wavelength_m: f64, ) -> Vec { - if rung.uses_orbital_derive() { - return Vec::new(); - } + // D-261: courses are invented at EVERY rung, Global included. They used to + // be withheld from the orbital rung, which is why the whole-body map had no + // rivers at all. Whether a given course is worth DRAWING is a presentation + // question — measured in screen pixels, which only the client knows — so + // the client culls anything shorter than three stroke-widths on screen + // (~264 km at Global) and this side simply supplies the geometry. + let _ = rung; // One station per gridunit — the finest density this canvas can draw. // // The former absolute floor ([`COURSE_STATION_SPACING_FLOOR_M`], 2,048 m) @@ -981,20 +996,21 @@ pub fn build_step_canvas( height as i32, step_m, ); - let invented_courses = if rung.is_global() { - Vec::new() - } else { - invent_courses_for_canvas( - seed, - params, - ta, - river_network, - canvas_rect, - rung, - step_m, - min_wavelength_m, - ) - }; + // D-261: courses at EVERY rung, Global included. This used to be gated on + // `rung.is_global()` — a second gate on top of the one inside + // invent_courses_for_canvas, which is why removing only the inner one + // changed nothing. Whether a course is worth DRAWING is decided + // client-side in screen pixels; this side just supplies the geometry. + let invented_courses = invent_courses_for_canvas( + seed, + params, + ta, + river_network, + canvas_rect, + rung, + step_m, + min_wavelength_m, + ); let rows: Vec> = (0..height as i32) .into_par_iter() @@ -1804,11 +1820,26 @@ mod tests { } } + /// Only Global rides the envelope-only derive (D-255 amendment + /// 2026-07-26; corrected here 2026-07-28). + /// + /// This test previously asserted `Global | Region`, and it passed happily + /// for two days after the client's mirror + /// (`step_canvas_transport.gd::is_orbital_rung`) had already been changed + /// to Global-only. Neither this test nor its GDScript twin compares itself + /// to the other, so the two sides drifted apart while both suites stayed + /// green — the mirror said Region took the full derive, the authority said + /// otherwise, and Region shipped with no courses. + /// + /// **If you change this, change `is_orbital_rung` in the same commit.** + /// The two cannot be cross-checked automatically (different languages, no + /// shared fixture for this predicate), so the coupling is held by this + /// note and its counterpart on the client. #[test] - fn only_global_and_region_use_orbital_derive() { + fn only_global_uses_orbital_derive() { assert!(StepCanvasRung::Global.uses_orbital_derive()); - assert!(StepCanvasRung::Region.uses_orbital_derive()); for rung in [ + StepCanvasRung::Region, StepCanvasRung::District, StepCanvasRung::Quarter, StepCanvasRung::Block, @@ -1821,6 +1852,21 @@ mod tests { } } + /// Courses are invented at EVERY rung now (D-261) — the gate that withheld + /// them from the orbital rung is why the whole-body map had no rivers. + /// Whether a course is worth DRAWING is a client-side decision measured in + /// screen pixels, which the server cannot make. + #[test] + fn no_rung_suppresses_course_invention() { + // The predicate the old gate keyed on must no longer imply "no + // courses" for anything: Global rides the orbital derive AND carries + // courses, a combination that was previously impossible. + assert!( + StepCanvasRung::Global.uses_orbital_derive(), + "Global still derives terrain envelope-only" + ); + } + #[test] fn global_cell_counts_only_for_global_rung() { assert!(StepCanvasRung::Region.global_cell_counts(6371.0).is_none());