feat(ui): river source tapering + width-grammar retune — map fluency pass (T-1175)

The single large polish pass Jeroen requested off the T-1170 captures,
benchmarked against RimWorld's world-map fluency. Courses now draw as
source-tapered ribbons: per-vertex width ramps from a hairline at the
upstream source to full class width over 15% of the course's arc length
(arc-length parameterized, not vertex-indexed, so point density doesn't
change the read), built as one draw_polygon ribbon with mitred joins.
Class width table retuned 0.9/1.4/2.2 -> 0.6/1.2/2.4: a clean ~2x
per-class ladder so a tributary-joins-trunk confluence reads as a join,
trunk held at its visually-proven weight, stream thinner per the
benchmark's thin/consistent/restrained grammar. Opacity untouched
(Araminta's T-1170 ruling stands). Coast-gradient item resolved as
already-correct (the coastal transition zones + elevation lightness
render the shoreline band; capture-verified) — no wire change. Stipple
assessment filed as T-1194. Eight new taper-geometry tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 15:25:30 +02:00
co-authored by Claude Fable 5
parent 0d8984fe53
commit 40cf89c3cb
2 changed files with 248 additions and 5 deletions
@@ -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)