From ad5d0468db4bd8efd0539289bb44736bd6ee1608 Mon Sep 17 00:00:00 2001 From: Kenso Grimm Date: Wed, 16 Sep 2026 06:48:26 +0200 Subject: [PATCH] feat(extract): Flamingo Park affin in den Spielpark einpassen Co-Authored-By: Claude Sonnet 5 --- tools/extract/leonida/park.py | 59 ++++++++++++++++++++++++++ tools/extract/tests/test_park_fit.py | 63 ++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 tools/extract/tests/test_park_fit.py diff --git a/tools/extract/leonida/park.py b/tools/extract/leonida/park.py index 7626973..e7d9a03 100644 --- a/tools/extract/leonida/park.py +++ b/tools/extract/leonida/park.py @@ -7,6 +7,7 @@ Ein Formfehler in der Parkdatei bricht mit ParkError ab, wie bei annotations.tom """ from __future__ import annotations +import math import tomllib from dataclasses import dataclass from pathlib import Path @@ -191,3 +192,61 @@ def _is_number(value) -> bool: def _is_point(value) -> bool: return isinstance(value, list) and len(value) == 2 and all(_is_number(c) for c in value) + + +# --- Einpassung -------------------------------------------------------------------------------- + +REF_LAT, REF_LON = 25.7840, -80.1375 # Bezugspunkt der lokalen Meter (Spec, Befund) +M_PER_DEG_LAT = 111_320.0 +M_PER_DEG_LON = M_PER_DEG_LAT * math.cos(math.radians(REF_LAT)) + + +def local_metres(lat: float, lon: float) -> tuple[float, float]: + """Breite/Länge -> Meter östlich und nördlich des Bezugspunkts.""" + return (lon - REF_LON) * M_PER_DEG_LON, (lat - REF_LAT) * M_PER_DEG_LAT + + +class ParkFit: + """Echter Park -> Spielpark, affin über die Ecken SW, SE und NW (Variante D). + + Bilinear über alle vier Ecken folgte die Anlage der steileren Nordkante im Spiel und verfehlte den + Hood Yard um 4,2 m; affin bleibt sie im Nordosten bis zu 6,4 m vor der Nordstraße, der Rasen füllt + das ganze Parkpolygon trotzdem. + """ + + def __init__(self, config): + self.origin = local_metres(*config.real["sw"]) + se, nw = local_metres(*config.real["se"]), local_metres(*config.real["nw"]) + self.real_u = (se[0] - self.origin[0], se[1] - self.origin[1]) + self.real_v = (nw[0] - self.origin[0], nw[1] - self.origin[1]) + game = config.game + self.game_origin = game["sw"] + self.game_u = (game["se"][0] - game["sw"][0], game["se"][1] - game["sw"][1]) + self.game_v = (game["nw"][0] - game["sw"][0], game["nw"][1] - game["sw"][1]) + self.real_width = math.hypot(*self.real_u) + self.game_width = math.hypot(*self.game_u) + self.scale_u, self.offset_u = config.scale_u, config.offset_u + self.game_quad = Polygon([game["sw"], game["se"], game["ne"], game["nw"]]) + + def park_coords(self, x: float, y: float) -> tuple[float, float]: + """Lokale Meter -> (s, t): s von West nach Ost, t von Süd nach Nord, das echte Parkviereck ist [0, 1]².""" + dx, dy = x - self.origin[0], y - self.origin[1] + (ux, uy), (vx, vy) = self.real_u, self.real_v + det = ux * vy - uy * vx + return (dx * vy - dy * vx) / det, (ux * dy - uy * dx) / det + + def game_point(self, s: float, t: float) -> tuple[float, float]: + u = (self.offset_u + self.scale_u * s * self.real_width) / self.game_width + return (self.game_origin[0] + u * self.game_u[0] + t * self.game_v[0], + self.game_origin[1] + u * self.game_u[1] + t * self.game_v[1]) + + def local_to_game(self, x: float, y: float) -> tuple[float, float]: + return self.game_point(*self.park_coords(x, y)) + + def to_game(self, lat: float, lon: float) -> tuple[float, float]: + return self.local_to_game(*local_metres(lat, lon)) + + def real_quad(self) -> Polygon: + """Das echte Parkviereck in lokalen Metern.""" + (ox, oy), (ux, uy), (vx, vy) = self.origin, self.real_u, self.real_v + return Polygon([(ox, oy), (ox + ux, oy + uy), (ox + ux + vx, oy + uy + vy), (ox + vx, oy + vy)]) diff --git a/tools/extract/tests/test_park_fit.py b/tools/extract/tests/test_park_fit.py new file mode 100644 index 0000000..01ff676 --- /dev/null +++ b/tools/extract/tests/test_park_fit.py @@ -0,0 +1,63 @@ +import math +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from leonida.park import M_PER_DEG_LAT, M_PER_DEG_LON, REF_LAT, REF_LON, ParkFit, load_park, local_metres + +DATA = Path(__file__).resolve().parents[1] / "data" + + +def latlon(x: float, y: float) -> tuple[float, float]: + """Lokale Meter -> Breite/Länge, die Umkehrung von local_metres.""" + return REF_LAT + y / M_PER_DEG_LAT, REF_LON + x / M_PER_DEG_LON + + +# Echter Park 100 × 200 m, Spielpark 200 × 400 m ab (1000 | 1000), Anlage 50 m vom Westrand: +# Welt-x = 1050 + x, Welt-y = 1000 + 2 · y. +FAKE = SimpleNamespace( + real={"sw": latlon(0, 0), "se": latlon(100, 0), "nw": latlon(0, 200)}, + game={"sw": (1000.0, 1000.0), "se": (1200.0, 1000.0), "ne": (1200.0, 1400.0), "nw": (1000.0, 1400.0)}, + scale_u=1.0, offset_u=50.0, +) + + +def test_reference_point_is_the_local_origin(): + assert math.dist(local_metres(REF_LAT, REF_LON), (0.0, 0.0)) < 1e-9 + + +def test_park_coords_run_west_to_east_and_south_to_north(): + s, t = ParkFit(FAKE).park_coords(25.0, 150.0) + assert (s, t) == pytest.approx((0.25, 0.75)) + + +def test_real_corners_and_centre_land_in_the_game_park(): + fit = ParkFit(FAKE) + assert fit.to_game(*latlon(0, 0)) == pytest.approx((1050.0, 1000.0)) + assert fit.to_game(*latlon(100, 0)) == pytest.approx((1150.0, 1000.0)) + assert fit.to_game(*latlon(0, 200)) == pytest.approx((1050.0, 1400.0)) + assert fit.to_game(*latlon(50, 100)) == pytest.approx((1100.0, 1200.0)) + + +def test_quads_cover_real_and_game_park(): + fit = ParkFit(FAKE) + assert fit.real_quad().area == pytest.approx(100 * 200) + assert fit.game_quad.area == pytest.approx(200 * 400) + + +def test_committed_fit_puts_tribune_and_hood_yard_within_one_and_a_half_metres(): + config = load_park(DATA / "flamingo-park.toml") + fit = ParkFit(config) + for anchor in config.anchors: + assert math.dist(fit.to_game(*anchor.real), anchor.game) <= 1.5, anchor.name + + +def test_committed_fit_keeps_the_south_edge_true_to_scale_and_offset_from_the_corner(): + config = load_park(DATA / "flamingo-park.toml") + fit = ParkFit(config) + sw, se, nw = (fit.to_game(*config.real[key]) for key in ("sw", "se", "nw")) + assert math.dist(sw, se) == pytest.approx(fit.real_width, abs=0.01) + assert fit.real_width == pytest.approx(200.57, abs=0.01) + assert math.dist(sw, config.game["sw"]) == pytest.approx(config.offset_u, abs=0.01) + assert math.dist(nw, config.game["nw"]) == pytest.approx(config.offset_u, abs=0.01)