Compare commits
7
Commits
958de5420e
...
1c21863b72
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c21863b72 | ||
|
|
f9ab3088ab | ||
|
|
c804f2c239 | ||
|
|
270dba0147 | ||
|
|
d256233faf | ||
|
|
581be31549 | ||
|
|
b9afa19d65 |
@@ -140,3 +140,113 @@ func test_zoom_compensated_stroke_width_zero_zoom_does_not_blow_up() -> void:
|
||||
assert_bool(is_finite(result)).override_failure_message(
|
||||
"a degenerate zero view_zoom must not produce inf/NaN"
|
||||
).is_true()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# T-1170 live round (2026-07-23, coordinator's mouth-ring finding):
|
||||
# zoom_compensated_ring_radius() — the RADIUS-SMALLER-THAN-STROKE regime.
|
||||
# Same A/B-bracket discipline as the stroke-width suite above, applied to
|
||||
# draw_arc() RING markers (mouth rings), whose radius AND stroke are both
|
||||
# small zoom-compensated values that can cross each other.
|
||||
##
|
||||
## Live A/B evidence (temporary instrumentation, since reverted): at the
|
||||
## PRODUCTION Quarter-rung radius/stroke pair (radius=0.667, stroke=1.0
|
||||
## canvas-local units, Quarter fit zoom 7.5), draw_arc() rendered a SOLID
|
||||
## BLOB, not a hollow ring — confirmed via a re-centered live capture (the
|
||||
## ORIGINAL "zero ring pixels" symptom was a separate viewport-framing crop,
|
||||
## not this bug — see zoom_compensated_ring_radius()'s own doc). Bracket
|
||||
## (stroke fixed at 1.0 canvas-local, radius varied): 0.51 (~stroke/2) ->
|
||||
## blob; 1.0 (=stroke, the production case) -> blob; 1.5 (1.5x stroke) ->
|
||||
## hollow ring recovers; 2.0 (2x stroke) -> hollow ring, cleaner. Floor set
|
||||
## at 2x with margin over the observed 1.0x-blob/1.5x-hollow transition.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## At zoom=1.0 with a radius comfortably above stroke*2 already, the floor
|
||||
## must be a no-op — identical to zoom_compensated_size() directly.
|
||||
func test_zoom_compensated_ring_radius_no_op_when_radius_already_clears_the_floor() -> void:
|
||||
var radius: float = AtlasWindowGeometryNature.zoom_compensated_ring_radius(5.0, 1.5, 1.0)
|
||||
assert_float(radius).is_equal_approx(5.0, 0.0001)
|
||||
|
||||
|
||||
## The EXACT regression shape this round closes: at Quarter's real fit zoom
|
||||
## (7.5, live capture), MOUTH_RING_RADIUS (5.0) divides to 0.667
|
||||
## canvas-local — BELOW its own paired stroke (1.5/7.5 floored to 1.0 via
|
||||
## zoom_compensated_stroke_width) — pinned directly, not just asserted.
|
||||
## zoom_compensated_ring_radius() must instead return stroke * 2.0 (the
|
||||
## floor), never the sub-floor raw division result that produced the blob.
|
||||
func test_zoom_compensated_ring_radius_quarter_mouth_ring_hits_the_floor() -> void:
|
||||
var view_zoom := 7.5 # LENDEL's live Quarter fit zoom, capture-confirmed
|
||||
var raw_radius := 5.0 # MOUTH_RING_RADIUS
|
||||
var stroke: float = AtlasWindowGeometryNature.zoom_compensated_stroke_width(1.5, view_zoom)
|
||||
var unfloored: float = AtlasWindowGeometryNature.zoom_compensated_size(raw_radius, view_zoom)
|
||||
assert_float(unfloored).override_failure_message(
|
||||
"regression pin: the OLD unfloored radius must be AT/BELOW the paired"
|
||||
+ " stroke at this zoom — this is the exact numeric shape of the blob bug"
|
||||
).is_less_equal(stroke)
|
||||
var floored: float = AtlasWindowGeometryNature.zoom_compensated_ring_radius(
|
||||
raw_radius, stroke, view_zoom
|
||||
)
|
||||
assert_float(floored).override_failure_message(
|
||||
"zoom_compensated_ring_radius() must clamp to stroke * 2.0 (the floor),"
|
||||
+ " not the sub-floor unfloored value that renders as a solid blob"
|
||||
).is_equal_approx(stroke * AtlasWindowGeometryNature.RING_RADIUS_STROKE_MULTIPLIER, 0.0001)
|
||||
|
||||
|
||||
## Regression pin (the stroke-width suite's own "effective on-screen value"
|
||||
## shape, applied to the radius/stroke RATIO instead of an absolute value):
|
||||
## for the mouth ring AND halo (the two draw_arc() ring markers in this
|
||||
## cluster), at Quarter's real fit zoom, the floored radius must be AT LEAST
|
||||
## RING_RADIUS_STROKE_MULTIPLIER times its own paired stroke — the actual
|
||||
## geometric property that keeps the ring hollow, verified directly rather
|
||||
## than just re-checking the numeric floor value in isolation.
|
||||
func test_ring_radius_stays_at_least_the_multiplier_above_its_stroke_at_quarter_zoom() -> void:
|
||||
var view_zoom := 7.5
|
||||
# (raw_radius_px, raw_stroke_px) pairs — the mouth ring and halo's own
|
||||
# literal call-site arguments in _draw_mouth().
|
||||
for pair in [[5.0, 1.5], [8.0, 1.0]]:
|
||||
var raw_radius: float = pair[0]
|
||||
var raw_stroke: float = pair[1]
|
||||
var stroke: float = AtlasWindowGeometryNature.zoom_compensated_stroke_width(
|
||||
raw_stroke, view_zoom
|
||||
)
|
||||
var radius: float = AtlasWindowGeometryNature.zoom_compensated_ring_radius(
|
||||
raw_radius, stroke, view_zoom
|
||||
)
|
||||
assert_float(radius).override_failure_message(
|
||||
(
|
||||
"radius %.3f must be at least %.1fx its paired stroke %.3f — a ratio"
|
||||
+ " below this rendered as a SOLID BLOB in the live A/B bracket,"
|
||||
+ " never a hollow ring"
|
||||
)
|
||||
% [radius, AtlasWindowGeometryNature.RING_RADIUS_STROKE_MULTIPLIER, stroke]
|
||||
).is_greater_equal(stroke * AtlasWindowGeometryNature.RING_RADIUS_STROKE_MULTIPLIER - 0.0001)
|
||||
|
||||
|
||||
## At a LOW zoom (e.g. Region's tiny orbital fit, or any zoom where the
|
||||
## naive radius is already well clear of the floor), the floor must NOT
|
||||
## engage — matching zoom_compensated_stroke_width()'s own
|
||||
## does-not-engage-at-low-zoom guarantee. Pins that this is a one-direction
|
||||
## safety net, not a blanket override.
|
||||
func test_zoom_compensated_ring_radius_does_not_engage_at_low_zoom() -> void:
|
||||
var view_zoom := 0.0063 # Lendel's real orbital fit zoom
|
||||
var raw_radius := 5.0 # MOUTH_RING_RADIUS
|
||||
var stroke: float = AtlasWindowGeometryNature.zoom_compensated_stroke_width(1.5, view_zoom)
|
||||
var floored: float = AtlasWindowGeometryNature.zoom_compensated_ring_radius(
|
||||
raw_radius, stroke, view_zoom
|
||||
)
|
||||
var unfloored: float = AtlasWindowGeometryNature.zoom_compensated_size(raw_radius, view_zoom)
|
||||
assert_float(floored).override_failure_message(
|
||||
"at a zoom where the naive radius is already far above the floor, the"
|
||||
+ " floor must be a no-op — identical to zoom_compensated_size()"
|
||||
).is_equal_approx(unfloored, 0.0001)
|
||||
|
||||
|
||||
## A degenerate zero (or negative) view_zoom must not divide-by-zero/produce
|
||||
## infinity/NaN — same total-function guarantee as the stroke-width sibling.
|
||||
func test_zoom_compensated_ring_radius_zero_zoom_does_not_blow_up() -> void:
|
||||
var stroke: float = AtlasWindowGeometryNature.zoom_compensated_stroke_width(1.5, 0.0)
|
||||
var result: float = AtlasWindowGeometryNature.zoom_compensated_ring_radius(5.0, stroke, 0.0)
|
||||
assert_bool(is_finite(result)).override_failure_message(
|
||||
"a degenerate zero view_zoom must not produce inf/NaN"
|
||||
).is_true()
|
||||
|
||||
@@ -233,6 +233,146 @@ func test_set_overlay_visible_gen_basins_flips_gate_and_redraws_nature_overlay()
|
||||
).is_greater(baseline)
|
||||
|
||||
|
||||
## T-1170 B3 (PR #197 review, Hoshe #3): _on_window_ready() now also calls
|
||||
## _nature_overlay.queue_redraw() (atlas_window_viewer.gd:507) — courses ride
|
||||
## `DistrictWindowLayer.courses`, the SAME `_window` this handler adopts, so
|
||||
## a window arrival that never redraws the nature overlay would leave freshly
|
||||
## arrived courses invisible until an UNRELATED pan/zoom gesture happened to
|
||||
## redraw it. Same spy-and-baseline shape as
|
||||
## test_set_overlay_visible_gen_basins_flips_gate_and_redraws_nature_overlay()
|
||||
## above — this is the exact pattern PR #196 established for "prove a
|
||||
## specific queue_redraw() call site actually fires", applied to the
|
||||
## window-arrival call site instead of the toggle call site.
|
||||
##
|
||||
## **Isolation note (live finding while writing this test):** `_on_window_ready()`
|
||||
## ALSO calls `_fit_and_center()` on the first-ever arrival
|
||||
## (`_awaiting_first_window and not _user_adjusted`), and `_fit_and_center()`
|
||||
## itself already ends in `_apply_transform()`, which redraws the nature
|
||||
## overlay through a SEPARATE, pre-existing call site. That path would mask
|
||||
## a broken/removed line 507 (both call sites fire on a fresh entry's first
|
||||
## arrival, so removing just one wouldn't drop draw_count below baseline).
|
||||
## Setting `_user_adjusted = true` before the response arrives — the SAME
|
||||
## guard a real pan/zoom gesture sets (`_maybe_refloat_window()`/`_zoom_at()`)
|
||||
## — skips the fit-and-center branch, so ONLY line 507 can be the source of
|
||||
## any redraw the assertion below observes. This is a real, reachable state
|
||||
## (any window arrival after the player's first manual pan/zoom), not a
|
||||
## test-only fiction.
|
||||
func test_window_arrival_redraws_nature_overlay() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20), 2)
|
||||
v._user_adjusted = true # isolate line 507 from the first-arrival fit-and-center redraw
|
||||
|
||||
# Swap in the counting spy AFTER enter() (matching the gen_basins test's
|
||||
# own "swap after construction, then let it settle" shape) so enter()'s
|
||||
# own queue_redraw() calls don't pollute the baseline.
|
||||
var spy := _CountingNatureOverlay.new(v)
|
||||
v._nature_overlay.queue_free()
|
||||
v._nature_overlay = spy
|
||||
v._canvas.add_child(spy)
|
||||
|
||||
await get_tree().process_frame
|
||||
await get_tree().process_frame
|
||||
var baseline: int = spy.draw_count
|
||||
assert_int(baseline).override_failure_message(
|
||||
"sanity: the spy must have drawn at least once before the window"
|
||||
+ " arrives, or this test can't distinguish 'redrawn BY the arrival'"
|
||||
+ " from 'never drawn at all'"
|
||||
).is_greater(0)
|
||||
|
||||
var window: Dictionary = _mock_window(Vector2i(10, 20), 2)
|
||||
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", window))
|
||||
await get_tree().process_frame
|
||||
|
||||
assert_that(v.get_district_window()).override_failure_message(
|
||||
"sanity: the response must actually have been adopted (matching echo)"
|
||||
+ " or this test proves nothing about the arrival path specifically"
|
||||
).is_equal(window)
|
||||
assert_int(spy.draw_count).override_failure_message(
|
||||
"_on_window_ready() must queue_redraw() the NATURE overlay — courses"
|
||||
+ " ride the SAME _window this handler adopts, so a window arrival"
|
||||
+ " that doesn't redraw the nature overlay leaves freshly arrived"
|
||||
+ " courses invisible until an unrelated pan/zoom happens to redraw"
|
||||
+ " it — draw_count must have advanced past the baseline (%d)" % baseline
|
||||
).is_greater(baseline)
|
||||
|
||||
|
||||
## The RUNG-SWAP arrival case (a later _on_window_ready() call for a
|
||||
## DIFFERENT granularity_v2 than the one the viewer entered at — e.g. a
|
||||
## wheel-zoom crossing from District into Quarter) — cheap to cover in the
|
||||
## SAME test file per the review's own "if cheap" allowance. Confirms the
|
||||
## redraw fires on EVERY window adoption, not just the first-ever one
|
||||
## (T-1153's progressive-refinement doc is explicit that _window only ever
|
||||
## gets REPLACED, never renulled, on a rung swap).
|
||||
##
|
||||
## **Uses `_window_request.request_now()` directly, NOT `_enter_at_rung()`**
|
||||
## — a live finding while writing this test: `_enter_at_rung()` sets
|
||||
## `_awaiting_first_window = true` again (it's the SAME reset path a fresh
|
||||
## descent uses), which would route the swap response back through
|
||||
## `_fit_and_center()`'s OWN redraw call site, masking line 507 exactly like
|
||||
## the note on the test above. The REAL production rung-swap path,
|
||||
## `_maybe_reselect_rung()`, never touches `_awaiting_first_window` at all —
|
||||
## it only calls `_window_request.request_debounced(...)`. `request_now()`
|
||||
## (the non-debounced sibling, same effect minus the timer) is called
|
||||
## directly here to update `_window_request`'s own `_granularity_v2` — the
|
||||
## exact field `_on_window_ready()`'s echo-matching guard reads — mirroring
|
||||
## the real path's state change without needing a live debounce timer in a
|
||||
## unit test.
|
||||
func test_rung_swap_window_arrival_redraws_nature_overlay() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20), 2)
|
||||
|
||||
# First arrival (District, matches enter()'s own default rung) — settles
|
||||
# the viewer into a held window, exactly as a real progressive-refinement
|
||||
# sequence would before a rung swap. Uses the REAL (non-spy) nature
|
||||
# overlay for this leg — only the swap leg itself needs the spy.
|
||||
var district_window: Dictionary = _mock_window(Vector2i(10, 20), 2)
|
||||
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", district_window))
|
||||
await get_tree().process_frame
|
||||
assert_that(v.get_district_window()).override_failure_message(
|
||||
"sanity: the first (District) arrival must have been adopted before"
|
||||
+ " simulating the swap"
|
||||
).is_equal(district_window)
|
||||
|
||||
# Now swap in the spy and simulate the RUNG SWAP itself — update the
|
||||
# request's echoed granularity_v2 to "Quarter" (what
|
||||
# _maybe_reselect_rung() -> request_debounced() would do on a real
|
||||
# wheel-zoom crossing) WITHOUT touching _awaiting_first_window, so the
|
||||
# response below takes the "not first window" branch — the genuinely
|
||||
# different code path from the test above.
|
||||
var spy := _CountingNatureOverlay.new(v)
|
||||
v._nature_overlay.queue_free()
|
||||
v._nature_overlay = spy
|
||||
v._canvas.add_child(spy)
|
||||
v._window_request.request_now(
|
||||
"GJ380c", Vector2i(10, 20), 2, AtlasWindowRequest.GRANULARITY_V2_QUARTER
|
||||
)
|
||||
|
||||
await get_tree().process_frame
|
||||
await get_tree().process_frame
|
||||
var baseline: int = spy.draw_count
|
||||
assert_int(baseline).override_failure_message(
|
||||
"sanity: the spy must have drawn at least once before the rung-swap"
|
||||
+ " response arrives"
|
||||
).is_greater(0)
|
||||
|
||||
var quarter_window: Dictionary = _mock_window(Vector2i(10, 20), 2)
|
||||
quarter_window["granularity_v2"] = "Quarter"
|
||||
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", quarter_window))
|
||||
await get_tree().process_frame
|
||||
|
||||
assert_that(v.get_district_window()).override_failure_message(
|
||||
"sanity: the rung-swap response must actually have been adopted"
|
||||
).is_equal(quarter_window)
|
||||
assert_int(spy.draw_count).override_failure_message(
|
||||
"a RUNG-SWAP window arrival (a later _on_window_ready() call at a"
|
||||
+ " DIFFERENT granularity_v2 than entry) must ALSO redraw the nature"
|
||||
+ " overlay — draw_count must have advanced past the post-first-"
|
||||
+ " arrival baseline (%d)" % baseline
|
||||
).is_greater(baseline)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# T-1120 capture-API parity (the ticket's explicit note: must survive here too)
|
||||
# =============================================================================
|
||||
|
||||
@@ -263,6 +263,14 @@ const MOUTH_RING_RADIUS: float = 5.0
|
||||
const MOUTH_HALO_RADIUS: float = 8.0
|
||||
const MOUTH_HALO_ALPHA: float = 0.30
|
||||
|
||||
## T-1170 live round (2026-07-23, coordinator's mouth-ring blob finding):
|
||||
## the minimum radius/stroke RATIO a draw_arc() ring needs to render hollow
|
||||
## rather than degenerate into a solid blob — see
|
||||
## zoom_compensated_ring_radius()'s own doc for the full A/B bracket
|
||||
## evidence (radius=1x stroke -> blob, 1.5x -> hollow, floor set at 2x with
|
||||
## margin over the observed transition).
|
||||
const RING_RADIUS_STROKE_MULTIPLIER: float = 2.0
|
||||
|
||||
## Attractor minimum-strength gate — verbatim from the retired
|
||||
## atlas_marker_overlay.gd GEN_ATTRACTOR_MIN_STRENGTH (:44). Region-only per
|
||||
## the ruling (ATTRACTORS_VISIBLE_BY_RUNG), wave 1 has no attractor rendering
|
||||
@@ -625,3 +633,53 @@ static func zoom_compensated_size(screen_space_size: float, view_zoom: float) ->
|
||||
## zoom_compensated_size()'s own behavior at Region's tiny fit zoom.
|
||||
static func zoom_compensated_stroke_width(screen_space_size: float, view_zoom: float) -> float:
|
||||
return maxf(zoom_compensated_size(screen_space_size, view_zoom), 1.0)
|
||||
|
||||
|
||||
## T-1170 live round (2026-07-23, coordinator's mouth-ring finding): the
|
||||
## RADIUS-SMALLER-THAN-STROKE regime — a THIRD sibling to
|
||||
## zoom_compensated_size()/zoom_compensated_stroke_width(), needed
|
||||
## specifically for draw_arc() RING markers (mouth rings — the only
|
||||
## draw_arc() caller in this file whose radius and stroke width are BOTH
|
||||
## small, zoom-compensated values that can cross each other).
|
||||
##
|
||||
## Root-cause evidence (live A/B bracket, temporary instrumentation, since
|
||||
## reverted — real running client via SR_LIVE=1, real x11/opengl3 driver):
|
||||
## the ORIGINAL "zero ring pixels" report turned out to be a SEPARATE,
|
||||
## already-correct-code issue — the terminus point legitimately sits near
|
||||
## the requesting window's own edge, and the fit-and-center COVER strategy
|
||||
## crops that edge off the visible viewport (screen_p verified computed as
|
||||
## (1755, -191) against a 1920x1080 frame — above the top edge, not a
|
||||
## drawing bug). Panning the view to re-center the SAME point (verified via
|
||||
## AtlasWindowViewer.set_view()) proves the ring genuinely draws — but as a
|
||||
## SOLID BLOB, not a hollow ring, at the production radius/stroke pair
|
||||
## (radius=0.667, stroke=1.0 canvas-local units, Quarter fit zoom 7.5):
|
||||
## draw_arc()'s stroke, centered ON the radius circle, extends inward past
|
||||
## the circle's own center once stroke exceeds ~1x the radius, filling the
|
||||
## hole. Bracket results (stroke fixed at 1.0 canvas-local, radius varied):
|
||||
## radius=0.51 (~stroke/2) -> still a solid blob; radius=1.0 (=stroke) ->
|
||||
## solid blob (the production case); radius=1.5 (1.5x stroke) -> hollow ring
|
||||
## recovers; radius=2.0 (2x stroke) -> hollow ring, cleaner. The blob
|
||||
## persists past the naive geometric threshold (radius > stroke/2, where an
|
||||
## infinitely-thin/perfectly-antialiased ring would already have a hole)
|
||||
## because draw_arc()'s low tessellation (18 points, this file's own call)
|
||||
## plus antialiasing blur eat into the theoretical hole at these tiny
|
||||
## absolute magnitudes — an empirical floor, not a derived one, chosen with
|
||||
## margin over the observed 1.0x-blob/1.5x-hollow transition rather than
|
||||
## shaving the boundary exactly.
|
||||
##
|
||||
## The fix: floor the RADIUS at `stroke * RING_RADIUS_STROKE_MULTIPLIER`
|
||||
## (2.0, the top-of-file const — verified clean in the bracket above)
|
||||
## whenever the naive zoom-compensated radius would fall below it — the
|
||||
## same "floor the OUTPUT, never let a sub-threshold value reach Godot's
|
||||
## renderer" pattern zoom_compensated_stroke_width() already established,
|
||||
## applied to the paired radius/stroke relationship a lone-value floor
|
||||
## can't express (unlike the stroke-width floor, this one's threshold is
|
||||
## RELATIVE to another draw-time value, not an absolute constant). At every
|
||||
## zoom where the naive radius already clears the floor on its own
|
||||
## (Region's dot radii, or any District/Quarter case wide enough), this is
|
||||
## an exact no-op — identical to calling zoom_compensated_size() directly.
|
||||
static func zoom_compensated_ring_radius(
|
||||
screen_space_radius: float, stroke_width_canvas_local: float, view_zoom: float
|
||||
) -> float:
|
||||
var naive_radius: float = zoom_compensated_size(screen_space_radius, view_zoom)
|
||||
return maxf(naive_radius, stroke_width_canvas_local * RING_RADIUS_STROKE_MULTIPLIER)
|
||||
|
||||
@@ -362,6 +362,24 @@ func _zs_stroke(screen_space_size: float, ctx: Dictionary) -> float:
|
||||
return AtlasWindowGeometryNature.zoom_compensated_stroke_width(screen_space_size, ctx["view_zoom"])
|
||||
|
||||
|
||||
## T-1170 live round (2026-07-23, coordinator's mouth-ring blob finding): the
|
||||
## RING-RADIUS-specific sibling of _zs()/_zs_stroke() — every draw_arc() ring
|
||||
## marker whose radius and stroke are BOTH small, zoom-compensated values
|
||||
## (currently: the two _draw_mouth() rings) routes its RADIUS through this
|
||||
## instead of plain _zs(), so the radius never falls at-or-below its own
|
||||
## paired stroke width and degenerates from a hollow ring into a solid blob.
|
||||
## See AtlasWindowGeometryNature.zoom_compensated_ring_radius()'s own doc for
|
||||
## the full A/B bracket evidence (radius=1x stroke -> blob, 1.5x -> hollow,
|
||||
## floor set at 2x with margin). `stroke_canvas_local` is the ALREADY
|
||||
## zoom-compensated stroke value (this function's own caller passes
|
||||
## _zs_stroke()'s result, not a raw screen-space width) — the floor compares
|
||||
## against the SAME canvas-local units the naive radius divide produces.
|
||||
func _zs_ring_radius(screen_space_radius: float, stroke_canvas_local: float, ctx: Dictionary) -> float:
|
||||
return AtlasWindowGeometryNature.zoom_compensated_ring_radius(
|
||||
screen_space_radius, stroke_canvas_local, ctx["view_zoom"]
|
||||
)
|
||||
|
||||
|
||||
## Pixel (row, col) -> fractional district position, wrap-resolved against
|
||||
## the currently-HELD view's own center (ctx["held_center"]) — the
|
||||
## representative wrap-image _pos()'s canvas conversion needs. T-1172: this
|
||||
@@ -616,14 +634,15 @@ func _segment_touches_drawn_water(from_district: Vector2, to_district: Vector2,
|
||||
## also the marker for REAL course termini (Ruling 5b/3e) — one geometry
|
||||
## function, both presentation surfaces.
|
||||
func _draw_mouth(p: Vector2, ctx: Dictionary) -> void:
|
||||
var ring_stroke: float = _zs_stroke(1.5, ctx)
|
||||
draw_arc(
|
||||
p,
|
||||
_zs(AtlasWindowGeometryNature.MOUTH_RING_RADIUS, ctx),
|
||||
_zs_ring_radius(AtlasWindowGeometryNature.MOUTH_RING_RADIUS, ring_stroke, ctx),
|
||||
0.0,
|
||||
TAU,
|
||||
18,
|
||||
COLOR_GEN_MOUTH,
|
||||
_zs_stroke(1.5, ctx)
|
||||
ring_stroke
|
||||
)
|
||||
var halo := Color(
|
||||
COLOR_GEN_MOUTH.r,
|
||||
@@ -631,8 +650,15 @@ func _draw_mouth(p: Vector2, ctx: Dictionary) -> void:
|
||||
COLOR_GEN_MOUTH.b,
|
||||
AtlasWindowGeometryNature.MOUTH_HALO_ALPHA
|
||||
)
|
||||
var halo_stroke: float = _zs_stroke(1.0, ctx)
|
||||
draw_arc(
|
||||
p, _zs(AtlasWindowGeometryNature.MOUTH_HALO_RADIUS, ctx), 0.0, TAU, 22, halo, _zs_stroke(1.0, ctx)
|
||||
p,
|
||||
_zs_ring_radius(AtlasWindowGeometryNature.MOUTH_HALO_RADIUS, halo_stroke, ctx),
|
||||
0.0,
|
||||
TAU,
|
||||
22,
|
||||
halo,
|
||||
halo_stroke
|
||||
)
|
||||
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -89,6 +89,36 @@ pub struct RiverNetwork {
|
||||
/// array (pre-T-1170 payload/fixture) decodes to empty, never an error.
|
||||
#[serde(default)]
|
||||
pub river_downstream: Vec<u8>,
|
||||
/// Per-`river_cells`-entry seaward neighbor position (T-1170 PR #197
|
||||
/// review, Hoshe #1) — same index/length shape as `river_class`/
|
||||
/// `river_downstream`. **Only meaningful where `river_downstream[i] ==
|
||||
/// RIVER_DOWNSTREAM_MOUTH`**; every other entry (interior direction or
|
||||
/// `EDGE_DRAIN`) carries the placeholder `(0, 0)` and must not be read.
|
||||
///
|
||||
/// **Why this exists — the second instance of the Ruling-2b anti-pattern.**
|
||||
/// `extract_river_network` already computes the real sub-sea-level
|
||||
/// neighbor `(nr, nc)` to decide the `MOUTH` sentinel (the elevation
|
||||
/// check that flips `river_downstream[i]` to
|
||||
/// [`RIVER_DOWNSTREAM_MOUTH`]) — the exact same "true answer already in
|
||||
/// scope, then discarded" shape Ruling 2b fixed for interior D8 pointers
|
||||
/// via `river_downstream` itself. Discarding `(nr, nc)` a second time
|
||||
/// left `river_course::build_edges` with nothing to build a real chord
|
||||
/// from for Mouth edges: it filled `downstream = upstream` as a
|
||||
/// placeholder, which zeroes the chord (`chord_m < 1.0`), which trips
|
||||
/// `invent_course`'s single-point degenerate return, which makes
|
||||
/// `resolve_mouth_terminus`'s station walk a no-op (a 1-point course
|
||||
/// never enters the `pts.len() >= 2` probe branch either) — every Mouth
|
||||
/// edge silently resolved `CourseTerminus::None` instead of `Mouth`.
|
||||
/// Capturing `(nr, nc)` here (one more push per mouth cell — mouths are
|
||||
/// a small fraction of river cells, not a new grid pass) is what makes
|
||||
/// `build_edges` give Mouth edges a real ~one-cell chord toward the raw
|
||||
/// sea, so the termination walk + bisect + one-segment D8-probe fallback
|
||||
/// (Ruling 3e) are actually reachable.
|
||||
///
|
||||
/// `#[serde(default)]` — same additive pattern as `river_class`/
|
||||
/// `river_downstream`.
|
||||
#[serde(default)]
|
||||
pub river_seaward: Vec<(u16, u16)>,
|
||||
}
|
||||
|
||||
/// [`RiverNetwork::river_downstream`] sentinel: this river cell's D8 flow
|
||||
|
||||
@@ -351,33 +351,51 @@ fn extract_river_network(
|
||||
// `RIVER_THRESHOLD`, but handled the same as `EDGE_DRAIN` (no downstream
|
||||
// neighbor to point at, not a sea mouth) rather than crashing the
|
||||
// pointer's "always points somewhere real" contract.
|
||||
//
|
||||
// river_seaward (T-1170 PR #197 review, Hoshe #1): captured in the SAME
|
||||
// pass, parallel to river_downstream — `(nr, nc)` is already computed
|
||||
// here to decide the MOUTH sentinel; discarding it after the elevation
|
||||
// check (the former code) was the second instance of the exact
|
||||
// discard-then-need-it-later anti-pattern Ruling 2b fixed for
|
||||
// river_downstream itself. Non-mouth entries get the `(0, 0)` placeholder
|
||||
// (documented on the field as unreadable outside the MOUTH case).
|
||||
let mut mouths: Vec<(u16, u16)> = Vec::new();
|
||||
let river_downstream: Vec<u8> = (0..n)
|
||||
.filter(|&i| is_river[i])
|
||||
.map(|i| {
|
||||
let r = i / w;
|
||||
let c = i % w;
|
||||
let k = fdir[i];
|
||||
if k < 0 {
|
||||
// No outflow at all — edge/flat-peak. Not a mouth (Ruling 3f).
|
||||
return RIVER_DOWNSTREAM_EDGE_DRAIN;
|
||||
}
|
||||
let (dr, dc) = D8[k as usize];
|
||||
let nr = r as i32 + dr;
|
||||
let nc = (c as i32 + dc).rem_euclid(w as i32) as usize;
|
||||
if nr < 0 || nr >= h as i32 {
|
||||
// Flow direction points off the polar edge — a grid artifact,
|
||||
// not a mouth (Ruling 3f, the pole-edge-drain fix).
|
||||
return RIVER_DOWNSTREAM_EDGE_DRAIN;
|
||||
}
|
||||
if elevation[nr as usize * w + nc] < sea_level {
|
||||
// Flows into a sub-sea-level cell — a real mouth.
|
||||
mouths.push((r as u16, c as u16));
|
||||
return RIVER_DOWNSTREAM_MOUTH;
|
||||
}
|
||||
k as u8
|
||||
})
|
||||
.collect();
|
||||
let mut river_downstream: Vec<u8> = Vec::new();
|
||||
let mut river_seaward: Vec<(u16, u16)> = Vec::new();
|
||||
for i in 0..n {
|
||||
if !is_river[i] {
|
||||
continue;
|
||||
}
|
||||
let r = i / w;
|
||||
let c = i % w;
|
||||
let k = fdir[i];
|
||||
if k < 0 {
|
||||
// No outflow at all — edge/flat-peak. Not a mouth (Ruling 3f).
|
||||
river_downstream.push(RIVER_DOWNSTREAM_EDGE_DRAIN);
|
||||
river_seaward.push((0, 0));
|
||||
continue;
|
||||
}
|
||||
let (dr, dc) = D8[k as usize];
|
||||
let nr = r as i32 + dr;
|
||||
let nc = (c as i32 + dc).rem_euclid(w as i32) as usize;
|
||||
if nr < 0 || nr >= h as i32 {
|
||||
// Flow direction points off the polar edge — a grid artifact,
|
||||
// not a mouth (Ruling 3f, the pole-edge-drain fix).
|
||||
river_downstream.push(RIVER_DOWNSTREAM_EDGE_DRAIN);
|
||||
river_seaward.push((0, 0));
|
||||
continue;
|
||||
}
|
||||
if elevation[nr as usize * w + nc] < sea_level {
|
||||
// Flows into a sub-sea-level cell — a real mouth. Capture the
|
||||
// seaward neighbor position (Hoshe #1) alongside the sentinel.
|
||||
mouths.push((r as u16, c as u16));
|
||||
river_downstream.push(RIVER_DOWNSTREAM_MOUTH);
|
||||
river_seaward.push((nr as u16, nc as u16));
|
||||
continue;
|
||||
}
|
||||
river_downstream.push(k as u8);
|
||||
river_seaward.push((0, 0));
|
||||
}
|
||||
|
||||
RiverNetwork {
|
||||
river_cells,
|
||||
@@ -385,6 +403,7 @@ fn extract_river_network(
|
||||
mouths,
|
||||
river_class,
|
||||
river_downstream,
|
||||
river_seaward,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -272,9 +272,23 @@ pub enum GenCompletion {
|
||||
BodyAnalyzed {
|
||||
body_id: String,
|
||||
/// The computed world state, ready for `BodyWorldStateCache::insert`.
|
||||
/// Boxed: `BodyWorldState` grew past clippy's large-enum-variant
|
||||
/// threshold when it began retaining `Layer1Output` (T-1170 Ruling 4b)
|
||||
/// — the box keeps the enum small while the state moves by pointer.
|
||||
/// Boxed to keep `GenCompletion` variant sizes balanced — the same
|
||||
/// discipline as `SkeletonGenerated`/`ChunkFilled`/`WindowDerived`
|
||||
/// below, all boxed for the same reason (`GenCompletion`'s overall
|
||||
/// size is bounded by its largest unboxed variant; a `BodyWorldState`
|
||||
/// field carried by value here would force every other variant to
|
||||
/// pay for its full stack size on every match/move).
|
||||
///
|
||||
/// **Not this variant's own recent growth** (T-1170 PR #197 review,
|
||||
/// Tyre issue 2 — the prior comment here overattributed the boxing
|
||||
/// rationale): `BodyWorldState` itself only grew by
|
||||
/// `RiverNetwork`'s two new `Vec` fields (`river_downstream`,
|
||||
/// `river_seaward` — a few bytes/river-cell, Ruling 2a / Hoshe #1).
|
||||
/// The much larger `Layer1Output` retention (T-1170 Ruling 4b) lives
|
||||
/// on `TerrainAnalysisCache` — a queue-scoped struct further down
|
||||
/// this file, never part of `BodyWorldState`/`BodyWorldStateCache` at
|
||||
/// all. The box here predates T-1170 and stays for the pre-existing
|
||||
/// variant-size-balancing reason, unrelated to this ticket's growth.
|
||||
state: Box<BodyWorldState>,
|
||||
},
|
||||
SkeletonGenerated {
|
||||
|
||||
@@ -1237,6 +1237,11 @@ fn invent_courses_near_window(
|
||||
/// Crop the window's already-invented courses ([`invent_courses_near_window`])
|
||||
/// to the wire [`RiverCourse`] shape (Ruling 3h) — window rect + one station
|
||||
/// beyond each edge, terminus resolution (A3, Ruling 3e/3f).
|
||||
///
|
||||
/// `station_spacing_m` is the rung's own cell spacing (`granularity.spacing_m()`
|
||||
/// — District 2,048 m / Quarter 512 m) — threaded to [`resolve_mouth_terminus`]'s
|
||||
/// land-at-final-anchor probe, which extends "one cell length" (Ruling 3e's own
|
||||
/// words), not one Stage-B segment length (Tyre, PR #197 review issue 1).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn crop_courses_for_wire(
|
||||
invented: &[InventedCourse],
|
||||
@@ -1247,6 +1252,7 @@ fn crop_courses_for_wire(
|
||||
ta: &crate::atlas::features::TerrainAnalysis,
|
||||
climate: &crate::atlas::district_profile::ClimateConstants,
|
||||
min_wavelength_m: f64,
|
||||
station_spacing_m: f64,
|
||||
) -> Vec<RiverCourse> {
|
||||
invented
|
||||
.iter()
|
||||
@@ -1260,6 +1266,7 @@ fn crop_courses_for_wire(
|
||||
ta,
|
||||
climate,
|
||||
min_wavelength_m,
|
||||
station_spacing_m,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
@@ -1280,6 +1287,7 @@ fn crop_course_to_window(
|
||||
ta: &crate::atlas::features::TerrainAnalysis,
|
||||
climate: &crate::atlas::district_profile::ClimateConstants,
|
||||
min_wavelength_m: f64,
|
||||
station_spacing_m: f64,
|
||||
) -> Option<RiverCourse> {
|
||||
let (x0, y0, x1, y1) = window_rect;
|
||||
let inside = |p: &(f64, f64)| p.0 >= x0 && p.0 <= x1 && p.1 >= y0 && p.1 <= y1;
|
||||
@@ -1328,6 +1336,7 @@ fn crop_course_to_window(
|
||||
ta,
|
||||
climate,
|
||||
min_wavelength_m,
|
||||
station_spacing_m,
|
||||
) {
|
||||
Some(mouth_point) => {
|
||||
// Replace the cropped course's tail with the resolved
|
||||
@@ -1373,6 +1382,12 @@ const MOUTH_BISECT_ITERATIONS: u32 = 6;
|
||||
/// probe past the final anchor) samples water, returns `None` — the
|
||||
/// degenerate "drawn coast receded past this edge" case (Ruling 3e), which
|
||||
/// the caller renders with no mouth flag.
|
||||
///
|
||||
/// `station_spacing_m` is the rung's own cell spacing — the probe extends
|
||||
/// exactly "one cell length" past the final anchor (Ruling 3e's own words),
|
||||
/// in the direction of the final Stage-B segment, but scaled to
|
||||
/// `station_spacing_m` rather than that segment's own (possibly much
|
||||
/// shorter, near-zero at a taper-to-zero anchor) length.
|
||||
fn resolve_mouth_terminus(
|
||||
course: &InventedCourse,
|
||||
seed: SeedChain,
|
||||
@@ -1381,6 +1396,7 @@ fn resolve_mouth_terminus(
|
||||
ta: &crate::atlas::features::TerrainAnalysis,
|
||||
climate: &crate::atlas::district_profile::ClimateConstants,
|
||||
min_wavelength_m: f64,
|
||||
station_spacing_m: f64,
|
||||
) -> Option<(f64, f64)> {
|
||||
let is_water = |p: (f64, f64)| -> bool {
|
||||
// `&[]`: the mouth-termination water-verdict probe has no use for
|
||||
@@ -1418,16 +1434,19 @@ fn resolve_mouth_terminus(
|
||||
}
|
||||
prev_land = p;
|
||||
}
|
||||
// Final anchor still land: extend one cell length along the segment's
|
||||
// own direction as a single probe (Ruling 3e: "extend along the D8
|
||||
// direction up to one cell length probing").
|
||||
// Final anchor still land: extend ONE CELL LENGTH (`station_spacing_m` —
|
||||
// Ruling 3e's own words, "up to one cell length probing", not one
|
||||
// Stage-B segment length, which can be much shorter near a
|
||||
// taper-to-zero anchor — Tyre, PR #197 review issue 1) along the final
|
||||
// segment's own direction, as a single probe.
|
||||
if pts.len() >= 2 {
|
||||
let a = pts[pts.len() - 2];
|
||||
let b = pts[pts.len() - 1];
|
||||
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
|
||||
let len = (dx * dx + dy * dy).sqrt();
|
||||
if len > 1e-6 {
|
||||
let probe = (b.0 + dx / len * len, b.1 + dy / len * len); // one more segment-length step
|
||||
let (ux, uy) = (dx / len, dy / len); // unit direction of the final segment
|
||||
let probe = (b.0 + ux * station_spacing_m, b.1 + uy * station_spacing_m);
|
||||
if is_water(probe) {
|
||||
return Some(bisect_to_waterline(b, probe, is_water));
|
||||
}
|
||||
@@ -1589,6 +1608,7 @@ pub fn build_district_window_layer(
|
||||
ta,
|
||||
climate,
|
||||
min_wavelength_m,
|
||||
step_m,
|
||||
);
|
||||
|
||||
DistrictWindowLayer {
|
||||
@@ -1686,6 +1706,7 @@ fn build_district_window_layer_serial(
|
||||
ta,
|
||||
climate,
|
||||
min_wavelength_m,
|
||||
step_m,
|
||||
);
|
||||
DistrictWindowLayer {
|
||||
center,
|
||||
@@ -2946,6 +2967,428 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// **T-1170 PR #197 review, Hoshe #1 acceptance test (blocking, permanent
|
||||
/// — not a throwaway probe).** Every real Mouth edge on the GJ1c golden
|
||||
/// fixture (256×128 downsample, the SAME fixture `cascade_golden.rs`
|
||||
/// pins — 3 mouths: `[(38,47), (38,98), (124,239)]`) must resolve
|
||||
/// `CourseTerminus::Mouth`, not `CourseTerminus::None`.
|
||||
///
|
||||
/// **What this guards:** before the fix, `build_edges` set
|
||||
/// `downstream = upstream` for every Mouth edge (a same-cell
|
||||
/// placeholder — the SAME discard-then-need-it-later anti-pattern
|
||||
/// Ruling 2b's `river_downstream` field fixed for interior pointers,
|
||||
/// applied a second time to the seaward neighbor `extract_river_network`
|
||||
/// already computes and then threw away). That zeroed the chord
|
||||
/// (`chord_m < 1.0`), which tripped `invent_course`'s degenerate
|
||||
/// single-point return, which made `resolve_mouth_terminus`'s station
|
||||
/// walk a no-op (a 1-point course can't reach the `pts.len() >= 2`
|
||||
/// fallback probe either) — all 3 real GJ1c mouths silently resolved
|
||||
/// `CourseTerminus::None` instead of `Mouth`, and since Ruling 3g retired
|
||||
/// the District/Quarter draw-time clip on the promise of real termini,
|
||||
/// mouths would have disappeared entirely at those rungs. The fix:
|
||||
/// `RiverNetwork::river_seaward` (additive, captured in the same
|
||||
/// `extract_river_network` pass) carries the real seaward neighbor
|
||||
/// through to `build_edges`, giving Mouth edges a genuine ~one-cell
|
||||
/// chord to invent a course along.
|
||||
#[test]
|
||||
fn all_real_gj1c_mouths_resolve_to_mouth_terminus_not_none() {
|
||||
use crate::atlas::drainage;
|
||||
use crate::atlas::heightmap::load_heightmap_png;
|
||||
use crate::atlas::river_course;
|
||||
|
||||
let src = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../wiki/star-systems/GJ-1/bodies/GJ1c/heightmap.png");
|
||||
let heightmap =
|
||||
load_heightmap_png(&src, "GJ1c", 0.3).expect("decode committed GJ1c heightmap");
|
||||
let small = heightmap.downsample(256, 128);
|
||||
let dr = drainage::analyze(&small.data, small.width, small.height, small.sea_level);
|
||||
let ta = crate::atlas::features::TerrainAnalysis::analyze(&small, &dr);
|
||||
let rn = &dr.river_network;
|
||||
|
||||
let params = crate::atlas::district_profile::BodyParams {
|
||||
hydrosphere: Some("ocean".into()),
|
||||
atmosphere: Some("breathable".into()),
|
||||
planet_class: Some("temperate".into()),
|
||||
body_radius_km: Some(6371.0),
|
||||
..Default::default()
|
||||
};
|
||||
let climate = crate::atlas::district_profile::ClimateConstants::default();
|
||||
let seed = SeedChain::root(0xC0FFEE_u64).derive(SeedDomain::Body, 1);
|
||||
let station_spacing_m = DISTRICT_M as f64;
|
||||
|
||||
let edges = river_course::build_edges(rn);
|
||||
let mouth_edges: Vec<_> = edges
|
||||
.iter()
|
||||
.filter(|e| e.terminus == river_course::EdgeTerminusKind::Mouth)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
mouth_edges.len(),
|
||||
rn.mouths.len(),
|
||||
"build_edges must produce exactly one Mouth edge per RiverNetwork.mouths entry"
|
||||
);
|
||||
assert_eq!(
|
||||
mouth_edges.len(),
|
||||
3,
|
||||
"GJ1c at this downsample is expected to have 3 real mouths (matches the \
|
||||
committed cascade_golden fixture) — if this count changes, re-verify against \
|
||||
tests/golden/cascade_layer1.json before updating this assertion"
|
||||
);
|
||||
|
||||
let mut resolved_mouth_count = 0;
|
||||
for edge in &mouth_edges {
|
||||
// Sanity: the fix means Mouth edges get a real, non-degenerate
|
||||
// chord toward the seaward neighbor — never upstream==downstream.
|
||||
assert_ne!(
|
||||
edge.upstream, edge.downstream,
|
||||
"Mouth edge {:?} still has a same-cell placeholder downstream — \
|
||||
river_seaward threading regressed",
|
||||
edge.edge_id
|
||||
);
|
||||
|
||||
let course =
|
||||
river_course::invent_course(seed, edge, &ta, ¶ms, station_spacing_m, 0.0);
|
||||
assert!(
|
||||
course.points.len() >= 2,
|
||||
"Mouth edge {:?} invented a degenerate {}-point course — the chord-length \
|
||||
fix regressed",
|
||||
edge.edge_id,
|
||||
course.points.len()
|
||||
);
|
||||
|
||||
// Window rect generous enough to contain the whole short mouth
|
||||
// course (mouths are ~one cell chord, so a wide margin is cheap).
|
||||
let (min_x, max_x) = course
|
||||
.points
|
||||
.iter()
|
||||
.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), p| {
|
||||
(lo.min(p.0), hi.max(p.0))
|
||||
});
|
||||
let (min_y, max_y) = course
|
||||
.points
|
||||
.iter()
|
||||
.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), p| {
|
||||
(lo.min(p.1), hi.max(p.1))
|
||||
});
|
||||
let margin = 50_000.0;
|
||||
let window_rect = (
|
||||
min_x - margin,
|
||||
min_y - margin,
|
||||
max_x + margin,
|
||||
max_y + margin,
|
||||
);
|
||||
|
||||
let wire = crop_course_to_window(
|
||||
&course,
|
||||
window_rect,
|
||||
seed,
|
||||
"GJ1c",
|
||||
¶ms,
|
||||
&ta,
|
||||
&climate,
|
||||
0.0,
|
||||
station_spacing_m,
|
||||
)
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"Mouth edge {:?} cropped to nothing in its own window",
|
||||
edge.edge_id
|
||||
)
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
wire.terminus,
|
||||
CourseTerminus::Mouth,
|
||||
"Mouth edge {:?} (upstream {:?}, downstream {:?}) resolved {:?} instead of \
|
||||
CourseTerminus::Mouth",
|
||||
edge.edge_id,
|
||||
edge.upstream,
|
||||
edge.downstream,
|
||||
wire.terminus
|
||||
);
|
||||
resolved_mouth_count += 1;
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
resolved_mouth_count, 3,
|
||||
"acceptance criterion (Hoshe #1): all 3 real GJ1c mouths must resolve \
|
||||
CourseTerminus::Mouth"
|
||||
);
|
||||
}
|
||||
|
||||
/// **T-1170 PR #197 review round 2 (coordinator's live GJ380c/Lendel
|
||||
/// repro) — the upstream-cut coverage gap in the test above, closed.**
|
||||
///
|
||||
/// `all_real_gj1c_mouths_resolve_to_mouth_terminus_not_none` derives its
|
||||
/// window rect from the invented course's OWN min/max point bbox, so by
|
||||
/// construction that window always contains the WHOLE course (both
|
||||
/// `first_in == 0` and `last_in == n-1`) — it never exercises a window
|
||||
/// that cuts the UPSTREAM anchor while the true downstream terminus
|
||||
/// still falls inside. `crop_course_to_window`'s terminus branch is
|
||||
/// driven entirely by `last_in`/`hi` (the downstream side); this test is
|
||||
/// the direct proof that an upstream cut (`first_in > 0`, i.e. `lo > 0`)
|
||||
/// does NOT collapse the terminus flag to `ContinuesBeyondWindow` — the
|
||||
/// coordinator's hypothesis (a) from the live-server investigation,
|
||||
/// falsified here as a permanent regression case rather than only a
|
||||
/// throwaway probe (`mouth_repro_probe.rs`, deleted after this landed).
|
||||
///
|
||||
/// Window construction: take one real GJ1c mouth edge's full invented
|
||||
/// course, find the TRUE (uncropped) terminus point, then build a window
|
||||
/// rect deliberately offset upstream along the course's own tail
|
||||
/// direction so its near edge sits well past the upstream anchor (cutting
|
||||
/// it out of range) while its far edge still comfortably contains the
|
||||
/// true terminus.
|
||||
#[test]
|
||||
fn mouth_terminus_survives_an_upstream_only_crop() {
|
||||
use crate::atlas::drainage;
|
||||
use crate::atlas::heightmap::load_heightmap_png;
|
||||
use crate::atlas::river_course;
|
||||
|
||||
let src = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../wiki/star-systems/GJ-1/bodies/GJ1c/heightmap.png");
|
||||
let heightmap =
|
||||
load_heightmap_png(&src, "GJ1c", 0.3).expect("decode committed GJ1c heightmap");
|
||||
let small = heightmap.downsample(256, 128);
|
||||
let dr = drainage::analyze(&small.data, small.width, small.height, small.sea_level);
|
||||
let ta = crate::atlas::features::TerrainAnalysis::analyze(&small, &dr);
|
||||
let rn = &dr.river_network;
|
||||
|
||||
let params = crate::atlas::district_profile::BodyParams {
|
||||
hydrosphere: Some("ocean".into()),
|
||||
atmosphere: Some("breathable".into()),
|
||||
planet_class: Some("temperate".into()),
|
||||
body_radius_km: Some(6371.0),
|
||||
..Default::default()
|
||||
};
|
||||
let climate = crate::atlas::district_profile::ClimateConstants::default();
|
||||
let seed = SeedChain::root(0xC0FFEE_u64).derive(SeedDomain::Body, 1);
|
||||
let station_spacing_m = DISTRICT_M as f64;
|
||||
|
||||
let edges = river_course::build_edges(rn);
|
||||
let edge = edges
|
||||
.iter()
|
||||
.find(|e| e.terminus == river_course::EdgeTerminusKind::Mouth)
|
||||
.expect("GJ1c fixture must have at least one Mouth edge");
|
||||
|
||||
let course = river_course::invent_course(seed, edge, &ta, ¶ms, station_spacing_m, 0.0);
|
||||
assert!(
|
||||
course.points.len() >= 2,
|
||||
"need a non-degenerate course to construct a meaningful upstream cut"
|
||||
);
|
||||
let upstream_anchor = course.points[0];
|
||||
let true_end = *course.points.last().unwrap();
|
||||
|
||||
// Window offset upstream along the course's own tail direction (the
|
||||
// last segment), far enough that the upstream anchor falls outside
|
||||
// the window but the true terminus stays comfortably inside.
|
||||
let second_last = course.points[course.points.len().saturating_sub(2)];
|
||||
let (dx, dy) = (true_end.0 - second_last.0, true_end.1 - second_last.1);
|
||||
let len = (dx * dx + dy * dy).sqrt().max(1e-9);
|
||||
let (ux, uy) = (dx / len, dy / len);
|
||||
// Half the upstream->downstream distance keeps the window's near
|
||||
// edge well clear of the upstream anchor for any real mouth chord
|
||||
// (mouth edges are short, ~one D8 step), while the far edge margin
|
||||
// below still comfortably covers the terminus.
|
||||
let chord_m = ((true_end.0 - upstream_anchor.0).powi(2)
|
||||
+ (true_end.1 - upstream_anchor.1).powi(2))
|
||||
.sqrt();
|
||||
let offset_m = (chord_m * 0.5).max(5_000.0);
|
||||
let center = (true_end.0 - ux * offset_m, true_end.1 - uy * offset_m);
|
||||
let half_extent_m = (chord_m * 0.5).max(5_000.0);
|
||||
let window_rect = (
|
||||
center.0 - half_extent_m,
|
||||
center.1 - half_extent_m,
|
||||
center.0 + half_extent_m,
|
||||
center.1 + half_extent_m,
|
||||
);
|
||||
|
||||
// Sanity on the window construction itself (not the code under
|
||||
// test): the upstream anchor must genuinely be cropped out, and the
|
||||
// true terminus must genuinely be inside — otherwise this test
|
||||
// isn't exercising the branch it claims to.
|
||||
let inside = |p: (f64, f64)| {
|
||||
p.0 >= window_rect.0
|
||||
&& p.0 <= window_rect.2
|
||||
&& p.1 >= window_rect.1
|
||||
&& p.1 <= window_rect.3
|
||||
};
|
||||
assert!(
|
||||
!inside(upstream_anchor),
|
||||
"test construction error: upstream anchor {upstream_anchor:?} must be OUTSIDE \
|
||||
the window {window_rect:?} for this to be a real upstream-cut case"
|
||||
);
|
||||
assert!(
|
||||
inside(true_end),
|
||||
"test construction error: true terminus {true_end:?} must be INSIDE the \
|
||||
window {window_rect:?} for this to test the terminus-survives claim"
|
||||
);
|
||||
|
||||
let wire = crop_course_to_window(
|
||||
&course,
|
||||
window_rect,
|
||||
seed,
|
||||
"GJ1c",
|
||||
¶ms,
|
||||
&ta,
|
||||
&climate,
|
||||
0.0,
|
||||
station_spacing_m,
|
||||
)
|
||||
.unwrap_or_else(|| panic!("course cropped to nothing despite containing the terminus"));
|
||||
|
||||
assert_eq!(
|
||||
wire.terminus,
|
||||
CourseTerminus::Mouth,
|
||||
"an upstream-only crop (anchor cut, true terminus still in-window) must NOT \
|
||||
collapse the terminus flag — got {:?} for edge {:?}",
|
||||
wire.terminus,
|
||||
edge.edge_id
|
||||
);
|
||||
}
|
||||
|
||||
/// **T-1170 live GJ380c/Lendel reconciliation dossier (coordinator's
|
||||
/// request) — walk/paint agreement invariant, pinned permanently.**
|
||||
///
|
||||
/// Live capture at GJ380c Quarter n=32 (server-clamped to n=16 — see
|
||||
/// below), district (13195,-2383), reported the ENTIRE visible course
|
||||
/// (44 wire points, `terminus=Mouth`) painting as water tones, with the
|
||||
/// resolved terminus apparently landing deep in open water. Investigated
|
||||
/// via an instrumented reproduction of `resolve_mouth_terminus`'s exact
|
||||
/// walk (temporary probe, deleted after this landed) — DETERMINATION:
|
||||
/// this is not a bug. The window's world-metre rect
|
||||
/// (`x∈[27006976,27039744]`) simply sits ~60+ km from the course's
|
||||
/// upstream land anchor and close to the resolved coastal terminus, so
|
||||
/// the overwhelming majority of what's IN FRAME is genuinely
|
||||
/// Lake/OpenOcean-painted — confirmed by hand-computing the exact RGB
|
||||
/// (`MORPHOLOGY_RGB_OPAQUE` × elevation lightness) for the coordinator's
|
||||
/// sampled pixel colors, which matched Lake/OpenOcean to the rounding
|
||||
/// digit. The walk's own station-by-station classification (178 land
|
||||
/// stations, then 35 water stations, ZERO flip-flops) is perfectly
|
||||
/// monotonic and the resolved terminus sits ~240 m from the true
|
||||
/// land→water crossing — not "tens of km past coast".
|
||||
///
|
||||
/// **The invariant this test pins, since the raw "N consecutive water
|
||||
/// stations" framing turned out not to be the real signal:** the wire
|
||||
/// course's resolved `Mouth` terminus point must land in a window CELL
|
||||
/// whose PAINTED morphology is genuinely `OpenOcean`/`Lake` (never a
|
||||
/// land-family zone) — i.e. the termination walk and the window's own
|
||||
/// per-cell classification, sampled independently via the SAME
|
||||
/// `derive_at_metres` call, must agree at the terminus. This is the
|
||||
/// walk/paint reconciliation the dossier was asked to determine, made
|
||||
/// permanent and mechanism-agnostic (it would catch a REAL divergence —
|
||||
/// wrap slip, sign flip, station-order bug — regardless of which
|
||||
/// specific window happens to expose it).
|
||||
#[test]
|
||||
fn mouth_terminus_lands_in_a_painted_water_cell_on_real_gj380c() {
|
||||
use crate::atlas::body_params_reader::BodyParamsReader;
|
||||
use crate::atlas::drainage;
|
||||
use crate::atlas::heightmap::{load_heightmap_png, GRID_H, GRID_W};
|
||||
use crate::atlas::river_course;
|
||||
|
||||
let manifest = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
let src = manifest.join("../wiki/star-systems/GJ-380/bodies/GJ380c/heightmap.png");
|
||||
let systems_db = manifest.join("data/systems.db");
|
||||
|
||||
let params_reader = BodyParamsReader::open(&systems_db).expect("open committed systems.db");
|
||||
let params = params_reader
|
||||
.read_body_params("GJ380c")
|
||||
.expect("read GJ380c body params from committed systems.db");
|
||||
|
||||
let heightmap = load_heightmap_png(&src, "GJ380c", 0.3).expect("decode GJ380c heightmap");
|
||||
let working = if heightmap.width > GRID_W || heightmap.height > GRID_H {
|
||||
heightmap.downsample(GRID_W, GRID_H)
|
||||
} else {
|
||||
heightmap
|
||||
};
|
||||
|
||||
let dr = drainage::analyze(
|
||||
&working.data,
|
||||
working.width,
|
||||
working.height,
|
||||
working.sea_level,
|
||||
);
|
||||
let ta = crate::atlas::features::TerrainAnalysis::analyze(&working, &dr);
|
||||
let rn = &dr.river_network;
|
||||
|
||||
// The exact real mouth cell from the live capture.
|
||||
let target_px: (u16, u16) = (63, 354);
|
||||
let edges = river_course::build_edges(rn);
|
||||
let edge = edges
|
||||
.iter()
|
||||
.find(|e| e.edge_id == river_course::pack_cell_id(target_px))
|
||||
.expect("GJ380c must have a Mouth edge at the live-captured cell");
|
||||
assert_eq!(
|
||||
edge.edge_id, 4129122,
|
||||
"must be the same edge the live capture reported"
|
||||
);
|
||||
|
||||
let seed = SeedChain::for_body(0, "GJ380c"); // production default world_seed
|
||||
let climate = crate::atlas::district_profile::ClimateConstants::default();
|
||||
|
||||
// The EXACT live window shape: district (13195,-2383), REQUESTED
|
||||
// n=32, Quarter granularity — server-side clamps n=32 down to n=16
|
||||
// at Quarter granularity (`clamp_window_n`: cap_n = sqrt(4096)/4 =
|
||||
// 16), which `build_district_window_layer` does NOT do itself (it
|
||||
// trusts n verbatim by its own doc) — the request-handling layer
|
||||
// (`handle_atlas_request`) applies `clamp_window_n_v2` BEFORE
|
||||
// calling it. Pass the already-clamped n=16 here to match what a
|
||||
// real client request actually receives.
|
||||
let center = (13195, -2383);
|
||||
let clamped_n = 16u32;
|
||||
|
||||
let layer = build_district_window_layer(
|
||||
seed,
|
||||
"GJ380c",
|
||||
¶ms,
|
||||
&ta,
|
||||
rn,
|
||||
center,
|
||||
clamped_n,
|
||||
&climate,
|
||||
WindowGranularity::Quarter,
|
||||
0,
|
||||
);
|
||||
let wire = layer
|
||||
.courses
|
||||
.iter()
|
||||
.find(|c| c.edge_id == edge.edge_id)
|
||||
.expect("edge 4129122 must ship in this window, matching the live capture");
|
||||
assert_eq!(
|
||||
wire.points.len(),
|
||||
44,
|
||||
"point count must match the live capture exactly, confirming this IS the \
|
||||
reported window"
|
||||
);
|
||||
assert_eq!(wire.terminus, CourseTerminus::Mouth);
|
||||
|
||||
// The invariant: the terminus point's containing Quarter cell, read
|
||||
// from THIS SAME layer's own painted `morphology` array, must be
|
||||
// OpenOcean or Lake.
|
||||
let side = WindowGranularity::Quarter.cell_grid_side(clamped_n);
|
||||
let half = side / 2;
|
||||
let step_m = WindowGranularity::Quarter.spacing_m();
|
||||
let center_world_m = (
|
||||
center.0 as f64 * DISTRICT_M as f64,
|
||||
center.1 as f64 * DISTRICT_M as f64,
|
||||
);
|
||||
let (tx, ty) = *wire.points.last().unwrap();
|
||||
let col = ((tx as f64 - center_world_m.0) / step_m + half as f64).round() as i32;
|
||||
let row = ((ty as f64 - center_world_m.1) / step_m + half as f64).round() as i32;
|
||||
assert!(
|
||||
row >= 0 && row < side && col >= 0 && col < side,
|
||||
"terminus point ({tx},{ty}) must map to an in-window cell, got (row={row},col={col})"
|
||||
);
|
||||
let cell_idx = (row * side + col) as usize;
|
||||
let painted = layer.morphology[cell_idx];
|
||||
assert!(
|
||||
painted == crate::simulation::generator::MorphologyZone::OpenOcean as u8
|
||||
|| painted == crate::simulation::generator::MorphologyZone::Lake as u8,
|
||||
"walk/paint DIVERGENCE: the resolved Mouth terminus ({tx},{ty}) landed in a cell \
|
||||
painted with morphology discriminant {painted} (not OpenOcean=0 or Lake=1) — the \
|
||||
termination walk's own water verdict must agree with the window's painted \
|
||||
classification at the SAME position"
|
||||
);
|
||||
}
|
||||
|
||||
/// Clamped-window edge: `n = 1` is the minimum valid window (a single
|
||||
/// district) — no panic, no empty output, exactly one cell per array.
|
||||
#[test]
|
||||
|
||||
@@ -176,18 +176,25 @@ pub fn pack_cell_id(cell: RiverCell) -> u32 {
|
||||
/// always be exactly parallel to `river_cells`, but a mismatched-length
|
||||
/// legacy payload must degrade to "no edges" rather than panic).
|
||||
///
|
||||
/// For `Mouth`/`EdgeDrain` termini there is no real downstream river cell to
|
||||
/// point at — the caller's termination logic (Ruling 3e/3f) walks or
|
||||
/// truncates from the upstream anchor using the edge's own direction, not a
|
||||
/// second real cell. This function still records SOME `downstream` position
|
||||
/// for those edges — one D8 step from the upstream cell in its own flow
|
||||
/// direction, when that direction is known (i.e. `river_downstream < 8`, the
|
||||
/// `Mouth` sentinel case where the OLD code already had a real direction
|
||||
/// before classifying the neighbor as sub-sea-level) — falling back to the
|
||||
/// upstream cell itself (a zero-length probe anchor) for `EdgeDrain`, where
|
||||
/// no direction survived at all. Both are legitimate: Stage A/B degrade
|
||||
/// gracefully to a near-zero-length or short edge, and A3's termination logic
|
||||
/// (layer_proxy) is what actually resolves the real geometry from here.
|
||||
/// **Mouth edges get a REAL seaward chord (T-1170 PR #197 review, Hoshe #1),
|
||||
/// not a same-cell placeholder.** The former code set `downstream = upstream`
|
||||
/// for `Mouth` edges — a zero-length chord (`chord_m < 1.0` in
|
||||
/// [`invent_course`]) that silently tripped the degenerate single-point
|
||||
/// return, which made [`crate::atlas::layer_proxy::resolve_mouth_terminus`]'s
|
||||
/// station walk a no-op (a 1-point course can't even reach that function's
|
||||
/// `pts.len() >= 2` fallback probe) — every Mouth edge resolved
|
||||
/// `CourseTerminus::None` instead of `Mouth`, DESPITE `extract_river_network`
|
||||
/// having already computed the real seaward neighbor to decide the sentinel
|
||||
/// in the first place. `RiverNetwork::river_seaward` (additive, captured in
|
||||
/// the SAME extraction pass) now carries that neighbor through; this
|
||||
/// function reads it for `Mouth` edges, giving them a genuine ~one-cell chord
|
||||
/// toward the raw sea so Stage A/B actually have something to invent and the
|
||||
/// termination walk is reachable.
|
||||
///
|
||||
/// `EdgeDrain` termini still use the upstream cell itself as a zero-length
|
||||
/// placeholder — there is no seaward neighbor for a grid-artifact exit, and
|
||||
/// none is needed: A3's `EdgeDrain` handling (Ruling 3f) never probes for
|
||||
/// water, it just ends the course at the last in-grid station.
|
||||
pub fn build_edges(rn: &RiverNetwork) -> Vec<RiverEdge> {
|
||||
let mut edges = Vec::with_capacity(rn.river_cells.len());
|
||||
for (i, &upstream) in rn.river_cells.iter().enumerate() {
|
||||
@@ -203,14 +210,17 @@ pub fn build_edges(rn: &RiverNetwork) -> Vec<RiverEdge> {
|
||||
let downstream = step_cell(upstream, dr, dc);
|
||||
(downstream, EdgeTerminusKind::Interior)
|
||||
} else if sentinel == RIVER_DOWNSTREAM_MOUTH {
|
||||
// The mouth sentinel is recorded once the D8 walk already found a
|
||||
// real neighbor cell (see `extract_river_network`); we don't have
|
||||
// that direction anymore at this layer, so use the upstream cell
|
||||
// itself as a zero-length placeholder — A3's mouth-walk logic
|
||||
// (layer_proxy, Ruling 3e) resolves the real terminus by sampling
|
||||
// the morphology water verdict outward from `upstream`, not by
|
||||
// trusting this placeholder position.
|
||||
(upstream, EdgeTerminusKind::Mouth)
|
||||
// Real seaward neighbor (Hoshe #1) — falls back to the upstream
|
||||
// cell (the old placeholder) ONLY on a legacy/pre-fix payload
|
||||
// where `river_seaward` is absent or the specific entry is the
|
||||
// unset `(0, 0)` fill AND that happens to differ from a genuine
|
||||
// seaward cell at (0,0) (an acceptable, vanishingly rare
|
||||
// degradation at the map's literal origin — never hit on any
|
||||
// real body, since (0,0) is a pole/edge pixel, never sub-sea
|
||||
// adjacent to an actual river mouth in practice).
|
||||
let seaward = rn.river_seaward.get(i).copied().unwrap_or((0, 0));
|
||||
let downstream = if seaward == (0, 0) { upstream } else { seaward };
|
||||
(downstream, EdgeTerminusKind::Mouth)
|
||||
} else {
|
||||
debug_assert_eq!(sentinel, RIVER_DOWNSTREAM_EDGE_DRAIN);
|
||||
(upstream, EdgeTerminusKind::EdgeDrain)
|
||||
|
||||
@@ -30,6 +30,32 @@
|
||||
//! sea-adjacent mouths (`RIVER_DOWNSTREAM_MOUTH`). `river_cells`/`attractors`
|
||||
//! counts are unchanged (93/256) — this is a pure re-classification + one new
|
||||
//! additive field, not a drainage-algorithm change.
|
||||
//!
|
||||
//! **Attractor cascade correction (T-1170 PR #197 review, Hoshe #4):** the A1
|
||||
//! commit's "pure reclassification" framing overclaimed — `attractors` is
|
||||
//! count-PARITY (256/256), not byte-identical. Mouths dropping 19→3 shrinks
|
||||
//! `TerrainAnalysis::water_dist`'s seed set (`compute_water_dist` seeds from
|
||||
//! `river_network.mouths`), which shifts the water-distance field, which
|
||||
//! feeds `RawAttractor::strength` scoring in `features::extract_attractors`.
|
||||
//! This is a principled, in-scope cascade (the pole-edge cells genuinely
|
||||
//! aren't water-distance sources anymore) — not a bug — but it is a REAL
|
||||
//! field-level change, not a no-op re-tagging. Restated accurately here so
|
||||
//! the record doesn't imply byte-identical attractor output.
|
||||
//!
|
||||
//! **Second deliberate re-pin (T-1170 PR #197 review, Hoshe #1):**
|
||||
//! `RiverNetwork` gained a second additive field, `river_seaward:
|
||||
//! Vec<(u16,u16)>` — the real seaward neighbor position for MOUTH-sentinel
|
||||
//! river cells, captured in the SAME `extract_river_network` pass (the
|
||||
//! elevation check that already computes `(nr, nc)` to decide the MOUTH
|
||||
//! sentinel). Fixes a second instance of the discard-then-need-it-later
|
||||
//! anti-pattern Ruling 2b's `river_downstream` field fixed for interior D8
|
||||
//! pointers: `river_course::build_edges` needs a real seaward cell to give
|
||||
//! Mouth edges a non-degenerate chord (see `river_course.rs`'s
|
||||
//! `build_edges` doc and `layer_proxy::tests::
|
||||
//! all_real_gj1c_mouths_resolve_to_mouth_terminus_not_none` for the full
|
||||
//! bug/fix story). `river_cells`/`mouths`/`attractors` counts unchanged by
|
||||
//! this second re-pin (93/3/256) — purely the new parallel array, 3 non-
|
||||
//! placeholder entries (one per real mouth).
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
|
||||
@@ -559,6 +559,7 @@ fn generate_atlas_layer_response_fixtures() {
|
||||
mouths: vec![(12, 58)],
|
||||
river_class: vec![1, 2],
|
||||
river_downstream: vec![2, 8], // 2=E direction; 8=MOUTH sentinel
|
||||
river_seaward: vec![(0, 0), (12, 60)], // meaningful only for the MOUTH entry
|
||||
},
|
||||
drainage_basins: vec![DrainageBasin {
|
||||
basin_id: 1,
|
||||
|
||||
@@ -8342,6 +8342,380 @@
|
||||
9,
|
||||
2,
|
||||
8
|
||||
],
|
||||
"river_seaward": [
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
39,
|
||||
47
|
||||
],
|
||||
[
|
||||
38,
|
||||
99
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
125,
|
||||
240
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -444,6 +444,20 @@ fn golden_cutoffs_match_the_scale_ladder() {
|
||||
// geometry for an edge from that chain, at both District and Quarter station
|
||||
// spacing, so courses themselves — not just the district-profile fields the
|
||||
// main golden above covers — are regression-pinned.
|
||||
//
|
||||
// **Mouth coverage (T-1170 PR #197 review, Hoshe #2):** the sample set below
|
||||
// also includes a real Mouth-terminus edge from `sample_hm()` (empirically
|
||||
// probed: 4 mouth edges exist in this fixture) — `invent_course`'s raw output
|
||||
// for that edge is pinned here (edge_id/class/terminus/points), closing the
|
||||
// "zero Mouth coverage in any golden" gap at the invention layer. The FULL
|
||||
// end-to-end path (invent → crop → `resolve_mouth_terminus` →
|
||||
// `CourseTerminus::Mouth`) is covered separately and permanently by
|
||||
// `layer_proxy::tests::all_real_gj1c_mouths_resolve_to_mouth_terminus_not_none`
|
||||
// (in-crate, since `crop_course_to_window`/`resolve_mouth_terminus` are
|
||||
// private to `layer_proxy.rs` and unreachable from this integration test) —
|
||||
// that test is the actual Hoshe #1 acceptance criterion (3/3 real mouths);
|
||||
// this golden's job is regression-pinning the raw invented geometry, not
|
||||
// re-proving the crop/resolve path.
|
||||
|
||||
const RIVER_COURSE_GOLDEN_FILE: &str = "tests/golden/river_course_golden.json";
|
||||
|
||||
@@ -481,6 +495,15 @@ fn river_course_golden_samples() -> Vec<GoldenCourseSample> {
|
||||
})
|
||||
.expect("sample_hm() fixture must have at least one interior river edge near (10, 116)");
|
||||
|
||||
// Hoshe #2: a real Mouth-terminus edge, deterministically selected as the
|
||||
// lowest edge_id among the fixture's mouth edges (fixed tie-break, no
|
||||
// hardcoded index).
|
||||
let mouth_target = edges
|
||||
.iter()
|
||||
.filter(|e| e.terminus == river_course::EdgeTerminusKind::Mouth)
|
||||
.min_by_key(|e| e.edge_id)
|
||||
.expect("sample_hm() fixture must have at least one Mouth edge (empirically verified: 4)");
|
||||
|
||||
let mut out = Vec::new();
|
||||
for (rung, spacing_m, min_wl_m) in [
|
||||
("district", DISTRICT_MIN_WL_M, DISTRICT_MIN_WL_M),
|
||||
@@ -498,6 +521,20 @@ fn river_course_golden_samples() -> Vec<GoldenCourseSample> {
|
||||
.map(|p| (p.0.round() as i64, p.1.round() as i64))
|
||||
.collect(),
|
||||
});
|
||||
|
||||
let mouth_course =
|
||||
river_course::invent_course(seed, mouth_target, &ta, ¶ms, spacing_m, min_wl_m);
|
||||
out.push(GoldenCourseSample {
|
||||
rung: format!("{rung}_mouth"),
|
||||
edge_id: mouth_course.edge_id,
|
||||
class: mouth_course.class,
|
||||
terminus: format!("{:?}", mouth_course.terminus),
|
||||
points: mouth_course
|
||||
.points
|
||||
.iter()
|
||||
.map(|p| (p.0.round() as i64, p.1.round() as i64))
|
||||
.collect(),
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -567,3 +604,29 @@ fn river_course_rungs_have_different_station_counts() {
|
||||
quarter.points.len()
|
||||
);
|
||||
}
|
||||
|
||||
/// **T-1170 PR #197 review, Hoshe #2 (golden coverage):** the pinned Mouth
|
||||
/// edge samples must be genuine, non-degenerate courses (`terminus == "Mouth"`,
|
||||
/// `points.len() >= 2`) — the direct golden-level check that the Hoshe #1 fix
|
||||
/// (real seaward chord via `RiverNetwork::river_seaward`) reaches this fixture
|
||||
/// too, not just the dedicated GJ1c acceptance test.
|
||||
#[test]
|
||||
fn river_course_mouth_samples_are_non_degenerate() {
|
||||
let samples = river_course_golden_samples();
|
||||
for rung in ["district_mouth", "quarter_mouth"] {
|
||||
let sample = samples
|
||||
.iter()
|
||||
.find(|s| s.rung == rung)
|
||||
.unwrap_or_else(|| panic!("missing golden sample for rung {rung}"));
|
||||
assert_eq!(
|
||||
sample.terminus, "Mouth",
|
||||
"{rung}: build_edges must produce a Mouth-terminus RiverEdge for the pinned target"
|
||||
);
|
||||
assert!(
|
||||
sample.points.len() >= 2,
|
||||
"{rung}: Mouth edge invented a degenerate {}-point course — the chord-length fix \
|
||||
(Hoshe #1) regressed for this fixture",
|
||||
sample.points.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -420,10 +420,28 @@ fn bench_course_cost_on_vs_off() {
|
||||
|
||||
// Warm-up (allocator/cache warm, not counted).
|
||||
let _ = build_district_window_layer(
|
||||
seed, "GJ1c", ¶ms, &ta, &rn_off, center, n, &climate, WindowGranularity::District, 0,
|
||||
seed,
|
||||
"GJ1c",
|
||||
¶ms,
|
||||
&ta,
|
||||
&rn_off,
|
||||
center,
|
||||
n,
|
||||
&climate,
|
||||
WindowGranularity::District,
|
||||
0,
|
||||
);
|
||||
let _ = build_district_window_layer(
|
||||
seed, "GJ1c", ¶ms, &ta, rn_on, center, n, &climate, WindowGranularity::District, 0,
|
||||
seed,
|
||||
"GJ1c",
|
||||
¶ms,
|
||||
&ta,
|
||||
rn_on,
|
||||
center,
|
||||
n,
|
||||
&climate,
|
||||
WindowGranularity::District,
|
||||
0,
|
||||
);
|
||||
|
||||
let iterations = 1000; // higher count than the other benches — window cost here is ~1 ms, noisy at low n
|
||||
@@ -431,7 +449,16 @@ fn bench_course_cost_on_vs_off() {
|
||||
let t_off = Instant::now();
|
||||
for _ in 0..iterations {
|
||||
let layer = build_district_window_layer(
|
||||
seed, "GJ1c", ¶ms, &ta, &rn_off, center, n, &climate, WindowGranularity::District, 0,
|
||||
seed,
|
||||
"GJ1c",
|
||||
¶ms,
|
||||
&ta,
|
||||
&rn_off,
|
||||
center,
|
||||
n,
|
||||
&climate,
|
||||
WindowGranularity::District,
|
||||
0,
|
||||
);
|
||||
std::hint::black_box(layer.morphology.len());
|
||||
}
|
||||
@@ -442,7 +469,16 @@ fn bench_course_cost_on_vs_off() {
|
||||
let mut courses_seen = 0usize;
|
||||
for _ in 0..iterations {
|
||||
let layer = build_district_window_layer(
|
||||
seed, "GJ1c", ¶ms, &ta, rn_on, center, n, &climate, WindowGranularity::District, 0,
|
||||
seed,
|
||||
"GJ1c",
|
||||
¶ms,
|
||||
&ta,
|
||||
rn_on,
|
||||
center,
|
||||
n,
|
||||
&climate,
|
||||
WindowGranularity::District,
|
||||
0,
|
||||
);
|
||||
courses_seen = layer.courses.len();
|
||||
std::hint::black_box(layer.morphology.len());
|
||||
|
||||
Reference in New Issue
Block a user