diff --git a/client/tests/test_step_canvas_annotation_layer.gd b/client/tests/test_step_canvas_annotation_layer.gd index 5c15967d7..c4d71ffc4 100644 --- a/client/tests/test_step_canvas_annotation_layer.gd +++ b/client/tests/test_step_canvas_annotation_layer.gd @@ -74,3 +74,124 @@ func test_world_to_local_uses_the_held_frame() -> void: world_center, world_center, "Quarter", extent ) assert_that(layer._world_to_local(world_center)).is_equal_approx(expected, Vector2(0.01, 0.01)) + + +# ----------------------------------------------------------------------- +# T-1175 seeded item 2 — source-taper ribbon geometry +# ----------------------------------------------------------------------- + + +## A straight 5-point course (evenly spaced, 10px apart along +X) — the +## simplest case for pinning the arc-length taper ramp: cumulative length +## at vertex i is exactly i*10, total 40, so the taper window +## (TAPER_ARC_FRACTION * 40 = 6px) falls strictly inside the first segment. +func _straight_course_points(spacing_px: float = 10.0) -> PackedVector2Array: + var pts := PackedVector2Array() + for i in range(5): + pts.append(Vector2(float(i) * spacing_px, 0.0)) + return pts + + +## Vertex 0 (the source, cumulative length 0) gets the hairline minimum +## width, never the class's full width — this is the taper's whole point. +func test_course_widths_by_arc_length_starts_at_the_taper_minimum() -> void: + var layer: StepCanvasAnnotationLayer = auto_free(StepCanvasAnnotationLayer.new()) + add_child(layer) + var pts := _straight_course_points() + var widths: PackedFloat32Array = layer._course_widths_by_arc_length(pts, 2.4) + assert_float(widths[0]).is_equal_approx( + StepCanvasAnnotationLayer.TAPER_MIN_WIDTH_PX, 0.001 + ) + + +## Vertices beyond TAPER_ARC_FRACTION of the total run hold at the class's +## own full width — the taper does not run the whole length of the course, +## only its own leading fraction (ticket instruction: "not the whole run"). +func test_course_widths_by_arc_length_holds_full_width_past_the_taper_fraction() -> void: + var layer: StepCanvasAnnotationLayer = auto_free(StepCanvasAnnotationLayer.new()) + add_child(layer) + # Total length 40; taper window = 0.15 * 40 = 6px. Vertex 1 (cumulative + # 10px) is already past that window. + var pts := _straight_course_points() + var widths: PackedFloat32Array = layer._course_widths_by_arc_length(pts, 2.4) + assert_float(widths[1]).is_equal_approx(2.4, 0.001) + assert_float(widths[4]).is_equal_approx(2.4, 0.001) + + +## The ramp is monotonically non-decreasing from source to mouth — no +## "wobble" where a later vertex is narrower than an earlier one within the +## taper window. +func test_course_widths_by_arc_length_is_monotonic_within_the_taper_window() -> void: + var layer: StepCanvasAnnotationLayer = auto_free(StepCanvasAnnotationLayer.new()) + add_child(layer) + # Denser spacing (1px) so several vertices fall inside the 6px taper + # window (total length 20, taper window 3px). + var pts := _straight_course_points(1.0) + var widths: PackedFloat32Array = layer._course_widths_by_arc_length(pts, 2.4) + for i in range(1, widths.size()): + assert_float(widths[i]).is_greater_equal(widths[i - 1]) + + +## A degenerate two-point course where both points coincide (zero-length) +## must not divide by zero — every vertex falls back to full width rather +## than crashing or producing NaN. +func test_course_widths_by_arc_length_handles_a_degenerate_zero_length_course() -> void: + var layer: StepCanvasAnnotationLayer = auto_free(StepCanvasAnnotationLayer.new()) + add_child(layer) + var pts := PackedVector2Array([Vector2(5.0, 5.0), Vector2(5.0, 5.0)]) + var widths: PackedFloat32Array = layer._course_widths_by_arc_length(pts, 2.4) + assert_float(widths[0]).is_equal_approx(2.4, 0.001) + assert_float(widths[1]).is_equal_approx(2.4, 0.001) + + +## A legal but minimal two-point course (source directly connected to +## mouth, no interior vertices) still tapers at the source end. +func test_course_widths_by_arc_length_tapers_a_two_point_course() -> void: + var layer: StepCanvasAnnotationLayer = auto_free(StepCanvasAnnotationLayer.new()) + add_child(layer) + var pts := PackedVector2Array([Vector2(0.0, 0.0), Vector2(100.0, 0.0)]) + var widths: PackedFloat32Array = layer._course_widths_by_arc_length(pts, 2.4) + assert_float(widths[0]).is_equal_approx( + StepCanvasAnnotationLayer.TAPER_MIN_WIDTH_PX, 0.001 + ) + # Vertex 1 (the mouth) is at cumulative length 100, far past the + # TAPER_ARC_FRACTION * 100 = 15px taper window — full width. + assert_float(widths[1]).is_equal_approx(2.4, 0.001) + + +## The ribbon polygon for an n-point course has exactly 2n vertices (n on +## each side) — this pins the "side-A then side-B reversed" construction +## produces a closed strip outline with no dropped or duplicated vertex. +func test_draw_tapered_course_ribbon_vertex_count_matches_two_times_point_count() -> void: + var layer: StepCanvasAnnotationLayer = auto_free(StepCanvasAnnotationLayer.new()) + add_child(layer) + layer.set_frame( + { + "width": 4, + "height": 4, + "courses": [{"class": 2, "points": [[0, 0], [10, 0], [20, 0], [30, 0]], "terminus": ""}], + }, + Vector2.ZERO, + "Chunk", + Vector2i(4, 4) + ) + # Draw-call correctness needs a live render pass (this suite's own header + # note); what's pinned here is that _course_widths_by_arc_length()'s + # output size always matches the input point count, which + # _draw_tapered_course() relies on 1:1 to build its 2n-vertex ribbon — + # see the width tests above for the per-vertex ramp itself. + var pts := PackedVector2Array([Vector2(0, 0), Vector2(10, 0), Vector2(20, 0), Vector2(30, 0)]) + var widths: PackedFloat32Array = layer._course_widths_by_arc_length(pts, 2.4) + assert_int(widths.size()).is_equal(pts.size()) + + +## A perpendicular offset at any point along a straight horizontal course +## points along +/-Y, never +/-X — the ribbon must widen ACROSS the flow +## direction, not along it. +func test_segment_normal_is_perpendicular_to_a_straight_horizontal_course() -> void: + var layer: StepCanvasAnnotationLayer = auto_free(StepCanvasAnnotationLayer.new()) + add_child(layer) + var pts := _straight_course_points() + var normal: Vector2 = layer._segment_normal(pts, 2) + assert_float(normal.x).is_equal_approx(0.0, 0.001) + assert_float(absf(normal.y)).is_equal_approx(1.0, 0.001) 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 780d70f0d..3612a9da1 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 @@ -49,16 +49,47 @@ const RIVER_CLASS_TRIBUTARY: int = 1 const RIVER_CLASS_TRUNK: int = 2 ## Course polyline width/opacity per class — LITERAL screen-space px/alpha, -## no zoom compensation needed (this layer is never scaled). Same functional -## defaults as the retired COURSE_CLASS_WIDTH_PX/COURSE_CLASS_OPACITY tables -## (Araminta's ruling, trunk widest/stream thinnest). +## no zoom compensation needed (this layer is never scaled). T-1175 width- +## grammar retune (Stig, RimWorld fluency pass): the T-1182 defaults read +## trunk-vs-tributary correctly in ORDER but too close together in MAGNITUDE +## (0.9/1.4/2.2 — tributary only 0.64x trunk) for the "visible tributary- +## joins-trunk convergence" the benchmark shows (a trunk reading as roughly +## DOUBLE a tributary's width, a tributary roughly double a stream's, is +## what makes the confluence point read as a join rather than a color +## change along one uniform line). Retuned to a ~2x-per-rung ladder +## (0.6/1.2/2.4) while keeping the SAME overall footprint (trunk unchanged +## at the visually-proven 2.2ish, stream thinner so it stays "thin" per the +## benchmark's own "thin/consistent/restrained" phrasing rather than pushing +## 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. const COURSE_CLASS_WIDTH_PX: Dictionary = { - RIVER_CLASS_STREAM: 0.9, RIVER_CLASS_TRIBUTARY: 1.4, RIVER_CLASS_TRUNK: 2.2 + RIVER_CLASS_STREAM: 0.6, RIVER_CLASS_TRIBUTARY: 1.2, RIVER_CLASS_TRUNK: 2.4 } const COURSE_CLASS_OPACITY: Dictionary = { RIVER_CLASS_STREAM: 0.8, RIVER_CLASS_TRIBUTARY: 0.9, RIVER_CLASS_TRUNK: 1.0 } +## 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 +## visible tributary). `points[0]` is the course's own upstream end (Ruling +## 2d/3h's own ordering — layer_proxy.rs's RiverCourse.points is "points +## along the course... cropped to this window", walked source->mouth, the +## SAME direction _draw_mouth_ring() already assumes by ringing the LAST +## point). Width ramps linearly from TAPER_MIN_WIDTH_PX at arc-length 0 to +## the class's own full COURSE_CLASS_WIDTH_PX at TAPER_ARC_FRACTION of the +## course's total length, then holds full width to the mouth — "taper over +## a sensible arc-length fraction, not the whole run" (ticket instruction). +## A stream-class course is thin enough end-to-end that a long taper would +## read as "the whole line fades", so the fraction is deliberately small. +const TAPER_ARC_FRACTION: float = 0.15 +## Never fully zero — a true point-width vertex degenerates the polygon +## triangulation at that end (two coincident vertices) for no visible gain; +## a hairline width reads as "tapered to a point" at any display ratio this +## layer draws at (District..Chunk, 1px/gridunit) while staying a valid strip. +const TAPER_MIN_WIDTH_PX: float = 0.15 + const MOUTH_RING_RADIUS_PX: float = 5.0 const MOUTH_HALO_RADIUS_PX: float = 8.0 const MOUTH_HALO_ALPHA: float = 0.30 @@ -125,13 +156,104 @@ func _draw_one_course(course: Dictionary) -> void: 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) - draw_polyline(screen_pts, color, width, true) + _draw_tapered_course(screen_pts, width, color) var terminus: String = str(course.get("terminus", "")) if terminus == COURSE_TERMINUS_MOUTH: _draw_mouth_ring(screen_pts[screen_pts.size() - 1]) +## Draw one course as a source-tapered ribbon (T-1175 seeded item 2): +## `screen_pts[0]` (the upstream source) narrows to TAPER_MIN_WIDTH_PX, +## ramping linearly by ARC LENGTH (not by vertex index — a course's own +## points are not evenly spaced, so an index-based ramp would taper faster +## or slower depending on point density) to `full_width` at +## TAPER_ARC_FRACTION of the total run, then holds `full_width` to the +## mouth end. Built as a single `draw_polygon()` triangle-strip-shaped +## ribbon: one offset vertex pair (left/right of the course direction) per +## centerline point, side-A vertices first then side-B vertices REVERSED — +## `draw_polygon()` triangulates whatever simple polygon its point winding +## describes, and a strip laid out this way (there-and-back around the +## ribbon's own outline) is always simple (non-self-intersecting) for a +## non-self-crossing centerline, which every real river course is. +func _draw_tapered_course(screen_pts: PackedVector2Array, full_width: float, color: Color) -> void: + var widths := _course_widths_by_arc_length(screen_pts, full_width) + var left := PackedVector2Array() + var right := PackedVector2Array() + for i in range(screen_pts.size()): + var normal: Vector2 = _segment_normal(screen_pts, i) + var half_w: float = widths[i] * 0.5 + left.append(screen_pts[i] + normal * half_w) + right.append(screen_pts[i] - normal * half_w) + + var ribbon := PackedVector2Array() + ribbon.append_array(left) + for i in range(right.size() - 1, -1, -1): + ribbon.append(right[i]) + if ribbon.size() < 3: + return + + var colors := PackedColorArray() + colors.resize(ribbon.size()) + colors.fill(color) + draw_polygon(ribbon, colors) + + +## Per-vertex width for a tapered course — arc-length parameterized so the +## taper reads consistently regardless of how densely a course's own points +## are spaced. `full_width` for every vertex beyond TAPER_ARC_FRACTION of +## the cumulative length from the source (index 0). +func _course_widths_by_arc_length(screen_pts: PackedVector2Array, full_width: float) -> PackedFloat32Array: + var n := screen_pts.size() + var widths := PackedFloat32Array() + widths.resize(n) + if n == 0: + return widths + if n == 1: + widths[0] = full_width + return widths + + var cumulative := PackedFloat32Array() + cumulative.resize(n) + cumulative[0] = 0.0 + for i in range(1, n): + cumulative[i] = cumulative[i - 1] + screen_pts[i].distance_to(screen_pts[i - 1]) + var total_len: float = cumulative[n - 1] + + # A degenerate (zero-length, coincident-point) course has no meaningful + # arc-length ramp — hold every vertex at full width rather than divide + # by zero. + if total_len <= 0.0: + widths.fill(full_width) + return widths + + var taper_len: float = total_len * TAPER_ARC_FRACTION + for i in range(n): + if taper_len <= 0.0 or cumulative[i] >= taper_len: + widths[i] = full_width + else: + var t: float = cumulative[i] / taper_len + widths[i] = lerpf(TAPER_MIN_WIDTH_PX, full_width, t) + return widths + + +## The ribbon-offset direction at vertex `i` — perpendicular to the local +## course tangent, averaged between the incoming and outgoing segment when +## both exist (a mitred join at interior vertices, avoiding a visible kink +## in the ribbon edge at each point) and falling back to the single +## adjacent segment's normal at either end. +func _segment_normal(screen_pts: PackedVector2Array, i: int) -> Vector2: + var n := screen_pts.size() + var dir := Vector2.ZERO + if i > 0: + dir += (screen_pts[i] - screen_pts[i - 1]).normalized() + if i < n - 1: + dir += (screen_pts[i + 1] - screen_pts[i]).normalized() + if dir == Vector2.ZERO: + return Vector2.ZERO + return dir.normalized().orthogonal() + + func _draw_mouth_ring(local_pt: Vector2) -> void: var halo_color := Color(COLOR_MOUTH.r, COLOR_MOUTH.g, COLOR_MOUTH.b, MOUTH_HALO_ALPHA) draw_arc(local_pt, MOUTH_HALO_RADIUS_PX, 0.0, TAU, 24, halo_color, 3.0)