From 2ef9e6774a68c7f33505d89502e0272f293724c1 Mon Sep 17 00:00:00 2001 From: Kenso Grimm Date: Wed, 16 Sep 2026 07:44:48 +0200 Subject: [PATCH] =?UTF-8?q?feat(extract):=20Parkanlage=20in=20die=20Welt?= =?UTF-8?q?=20einsetzen,=20Palmen=20neu=20setzen,=20Ecken=20pr=C3=BCfen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- tools/extract/leonida/park.py | 39 ++++++++++++- tools/extract/tests/test_park_apply.py | 77 ++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 tools/extract/tests/test_park_apply.py diff --git a/tools/extract/leonida/park.py b/tools/extract/leonida/park.py index 72d3f48..d2375c8 100644 --- a/tools/extract/leonida/park.py +++ b/tools/extract/leonida/park.py @@ -16,7 +16,7 @@ from pathlib import Path from shapely.geometry import LineString, Point, Polygon from shapely.ops import unary_union -from .heights import PALM_KERB +from .heights import PALM_CLEARANCE, PALM_KERB, palm_points class ParkError(ValueError): @@ -485,3 +485,40 @@ def apply_street(world: World, street: Street) -> World: palms = [p for p in world.palms if corridor.distance(Point(p)) >= PALM_KERB] return replace(world, roads=roads, centerlines=centerlines, buildings=buildings, surfaces=surfaces, palms=palms) + + +def check_corners(config, roads: list[tuple[Polygon, bool]], tolerance: float = 2.0) -> None: + """Jede Spielecke muss an einer Straße liegen, sonst hat sich die Extraktion geändert.""" + for name, (x, y) in config.game.items(): + corner = Point(x, y) + if not any(poly.distance(corner) <= tolerance for poly, bridge in roads if not bridge): + raise ParkError(f"Spielecke {name} ({x:g} | {y:g}) liegt weiter als {tolerance:g} m von jeder Straße: " + "Ecken in flamingo-park.toml neu messen") + + +def apply_park(world: World, layout: ParkLayout, seed: int) -> World: + """Alte Straßen, Beläge, Gebäude und Palmen im Park weg, neue Anlage hinein, Palmen neu gesetzt.""" + quad = layout.lawn + roads = [(piece, bridge) for poly, bridge in world.roads + for piece in (_polygons(poly.difference(quad), MIN_ROAD_AREA) if poly.intersects(quad) else [poly])] + centerlines = [piece for line in world.centerlines + for piece in (_lines(line.difference(quad)) if line.intersects(quad) else [line])] + surfaces = [(piece, kind) for poly, kind in world.surfaces + for piece in (_polygons(poly.difference(quad)) if poly.intersects(quad) else [poly])] + buildings = [b for b in world.buildings + if not b[0].intersects(quad) or b[0].intersection(quad).area <= 0.5 * b[0].area] + palms = [p for p in world.palms if not quad.contains(Point(p))] + + roads += [(poly, False) for poly in layout.service] + surfaces += [(quad, "park")] + layout.surfaces + buildings += [(poly, {"height": height, "source": "osm"}, False) for poly, height in layout.buildings] + + reach = quad.buffer(PALM_CLEARANCE + 1) + blocked = unary_union([poly for poly, _ in roads if poly.intersects(reach)] + + [b[0] for b in buildings if b[0].intersects(reach)] + + layout.paths + layout.keepouts).intersection(reach) + lawn = quad.difference(unary_union([poly for poly, _ in layout.surfaces])) + # Abstand zu einer leeren Geometrie ist in shapely NaN, und NaN > 6 verwürfe jede Palme. + palms += palm_points([], _polygons(lawn), [], [] if blocked.is_empty else [blocked], [], [], seed) + return replace(world, roads=roads, centerlines=centerlines, surfaces=surfaces, + buildings=buildings, palms=palms, paths=world.paths + layout.paths) diff --git a/tools/extract/tests/test_park_apply.py b/tools/extract/tests/test_park_apply.py new file mode 100644 index 0000000..3d1eea7 --- /dev/null +++ b/tools/extract/tests/test_park_apply.py @@ -0,0 +1,77 @@ +from types import SimpleNamespace + +import pytest +from shapely.geometry import LineString, Point, box + +from leonida.heights import PALM_CLEARANCE +from leonida.park import ParkError, ParkLayout, World, apply_park, check_corners + +QUAD = box(0, 0, 100, 200) +SERVICE = box(60, 0, 66, 30) + + +def layout(): + return ParkLayout(lawn=QUAD, surfaces=[(box(10, 10, 40, 40), "field")], paths=[box(0, 148, 100, 152)], + service=[SERVICE], buildings=[(box(70, 60, 80, 70), 5.0)], keepouts=[box(60, 160, 90, 190)]) + + +def world(): + return World( + land=[box(-100, -100, 200, 300)], + surfaces=[(box(-30, -30, 130, 230), "urban")], + roads=[(box(-50, 95, 150, 105), False), (box(-20, -20, 0, 220), False)], + centerlines=[LineString([(-50, 100), (150, 100)]), LineString([(-5, 150), (10, 150)])], + buildings=[(box(20, 60, 30, 70), {"height": 9.0, "source": "district"}, False), # ganz im Park + (box(-6, 80, 4, 90), {"height": 9.0, "source": "district"}, False), # 40 % im Park + (box(-4, 120, 6, 130), {"height": 9.0, "source": "district"}, False)], # 60 % im Park + palms=[(50.0, 50.0), (-10.0, 50.0)], + ) + + +def test_old_roads_and_centerlines_end_at_the_park_edge(): + result = apply_park(world(), layout(), seed=1) + assert all(poly.intersection(QUAD).area < 1e-6 for poly, _ in result.roads if not poly.equals(SERVICE)) + assert any(poly.contains(Point(-30, 100)) for poly, _ in result.roads) + assert any(poly.contains(Point(120, 100)) for poly, _ in result.roads) + assert sorted(line.bounds for line in result.centerlines) == [(-50.0, 100.0, 0.0, 100.0), (100.0, 100.0, 150.0, 100.0)] + + +def test_old_surfaces_buildings_and_palms_in_the_park_go(): + result = apply_park(world(), layout(), seed=1) + assert all(poly.intersection(QUAD).area < 1e-6 for poly, kind in result.surfaces if kind == "urban") + assert sorted((b[0].bounds, b[1]["source"]) for b in result.buildings) == [ + ((-6.0, 80.0, 4.0, 90.0), "district"), ((70.0, 60.0, 80.0, 70.0), "osm")] + assert (-10.0, 50.0) in result.palms and (50.0, 50.0) not in result.palms + + +def test_layout_goes_in_with_the_lawn_before_the_fields(): + result = apply_park(world(), layout(), seed=1) + kinds = [kind for _, kind in result.surfaces] + assert kinds.index("park") < kinds.index("field") + assert any(kind == "park" and poly.equals(QUAD) for poly, kind in result.surfaces) + assert result.paths == layout().paths + assert any(poly.equals(SERVICE) and not bridge for poly, bridge in result.roads) + + +def test_new_palms_stand_on_lawn_away_from_roads_paths_buildings_and_keepouts(): + result = apply_park(world(), layout(), seed=1) + new = [Point(p) for p in result.palms if p != (-10.0, 50.0)] + assert len(new) > 5 + for palm in new: + assert QUAD.contains(palm) and not box(10, 10, 40, 40).contains(palm) + assert palm.distance(box(0, 148, 100, 152)) > PALM_CLEARANCE + assert palm.distance(box(60, 160, 90, 190)) > PALM_CLEARANCE + assert all(palm.distance(poly) > PALM_CLEARANCE for poly, _ in result.roads) + assert all(palm.distance(b[0]) > PALM_CLEARANCE for b in result.buildings) + + +def test_same_seed_gives_the_same_palms(): + assert apply_park(world(), layout(), seed=7).palms == apply_park(world(), layout(), seed=7).palms + + +def test_corners_must_lie_at_a_road(): + roads = [(box(-20, -20, 0, 220), False), (box(100, -20, 120, 220), False)] + check_corners(SimpleNamespace(game={"sw": (0.0, 0.0), "se": (100.0, 0.0), "ne": (100.0, 200.0), "nw": (0.0, 200.0)}), roads) + far = SimpleNamespace(game={"sw": (0.0, 0.0), "se": (100.0, 0.0), "ne": (100.0, 200.0), "nw": (30.0, 200.0)}) + with pytest.raises(ParkError, match="Spielecke nw"): + check_corners(far, roads)