|
|
|
@@ -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
|
|
|
|
|