From cfc9fc06909a1f63c128238ac60f70912d06bb4f Mon Sep 17 00:00:00 2001 From: Kenso Grimm Date: Wed, 16 Sep 2026 07:28:27 +0200 Subject: [PATCH] =?UTF-8?q?feat(extract):=20Jefferson=20Avenue=20als=20ger?= =?UTF-8?q?ade=20Stra=C3=9Fe=20statt=20der=20Gasse?= 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 | 65 ++++++++++++++++++++++++- tools/extract/tests/test_park_street.py | 62 +++++++++++++++++++++++ 2 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 tools/extract/tests/test_park_street.py diff --git a/tools/extract/leonida/park.py b/tools/extract/leonida/park.py index bfe4f52..72d3f48 100644 --- a/tools/extract/leonida/park.py +++ b/tools/extract/leonida/park.py @@ -10,12 +10,14 @@ from __future__ import annotations import json import math import tomllib -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from pathlib import Path -from shapely.geometry import LineString, Polygon +from shapely.geometry import LineString, Point, Polygon from shapely.ops import unary_union +from .heights import PALM_KERB + class ParkError(ValueError): """Formfehler in der Parkdatei oder eine Welt, zu der sie nicht mehr passt: der Lauf bricht ab.""" @@ -424,3 +426,62 @@ def _points(geometry) -> list[tuple[float, float]]: elif g.geom_type in ("LineString", "LinearRing"): out.extend(g.coords) return out + + +# --- Welt verändern ---------------------------------------------------------------------------- + +MIN_CENTERLINE = 15.0 # m, wie centerlines() in vectors.py: kürzere Stummel setzten Laternen auf die Kreuzung +MIN_ROAD_AREA = 20.0 # m², wie road_polygons() in vectors.py + + +@dataclass +class World: + """Weltgeometrie zwischen Extraktion und Chunk-Ausgabe, in Welt-x/Welt-y.""" + land: list[Polygon] + surfaces: list[tuple[Polygon, str]] + roads: list[tuple[Polygon, bool]] + centerlines: list[LineString] + buildings: list[tuple[Polygon, dict, bool]] # Grundriss, {"height", "source"}, spekulativ + palms: list[tuple[float, float]] + paths: list[Polygon] = field(default_factory=list) + + +def _lines(geometry, min_length: float = MIN_CENTERLINE) -> list[LineString]: + return [g for g in _pieces(geometry) if g.geom_type == "LineString" and g.length >= min_length] + + +def apply_street(world: World, street: Street) -> World: + """Gebäude und Gasse weg, gerade Straße mit Mittellinie, frei gewordene Fläche wird Stadtfläche.""" + cut = Polygon(street.alley_cut) + + def hit(building) -> bool: + return any(building[0].contains(Point(p)) for p in street.remove_buildings) + + removed = [b[0] for b in world.buildings if hit(b)] + buildings = [b for b in world.buildings if not hit(b)] + line = LineString([street.start, street.end]) + corridor = line.buffer(street.width / 2, cap_style="flat") + + kept, joined = [], [corridor] + for poly, bridge in world.roads: + rest = poly.difference(cut) if poly.intersects(cut) else poly + for piece in _polygons(rest, MIN_ROAD_AREA): + if not bridge and piece.intersects(corridor): + joined.append(piece) + else: + kept.append((piece, bridge)) + roads = kept + [(piece, False) for piece in _polygons(unary_union(joined), MIN_ROAD_AREA)] + + centerlines = [piece for l in world.centerlines + for piece in (_lines(l.difference(cut)) if l.intersects(cut) else [l])] + [line] + + freed = unary_union(removed + [cut]).difference(corridor) + covered = [b[0] for b in buildings if b[0].intersects(freed)] + \ + [poly for poly, kind in world.surfaces if kind == "urban" and poly.intersects(freed)] + if covered: + freed = freed.difference(unary_union(covered)) + surfaces = world.surfaces + [(poly, "urban") for poly in _polygons(freed)] + + 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) diff --git a/tools/extract/tests/test_park_street.py b/tools/extract/tests/test_park_street.py new file mode 100644 index 0000000..4a2e897 --- /dev/null +++ b/tools/extract/tests/test_park_street.py @@ -0,0 +1,62 @@ +from shapely.geometry import LineString, Point, box +from shapely.ops import unary_union + +from leonida.park import Street, World, apply_street + +STREET = Street(start=(150.0, 660.0), end=(150.0, 320.0), width=12.0, + remove_buildings=((155.0, 615.0),), + alley_cut=((90.0, 639.0), (110.0, 639.0), (110.0, 341.0), (90.0, 341.0))) + + +def world(): + """Querstraßen bei y 640–660 und 320–340, dazwischen eine Gasse bei x 95–105.""" + removed = box(140, 600, 170, 630) + return World( + land=[box(-100, 200, 300, 800)], + surfaces=[(box(0, 340, 95, 640), "urban"), (box(105, 340, 200, 640).difference(removed), "urban")], + roads=[(unary_union([box(0, 640, 200, 660), box(0, 320, 200, 340), box(95, 340, 105, 640)]), False)], + centerlines=[LineString([(0, 650), (200, 650)]), LineString([(0, 330), (200, 330)]), + LineString([(100, 650), (100, 330)])], + buildings=[(removed, {"height": 20.9, "source": "district"}, False), + (box(20, 400, 60, 450), {"height": 10.8, "source": "district"}, False), + (box(84, 450, 93, 470), {"height": 8.0, "source": "district"}, False)], + palms=[(150.0, 500.0), (60.0, 500.0), (140.0, 500.0)], + ) + + +def on_road(result, x, y): + return any(poly.contains(Point(x, y)) for poly, _ in result.roads) + + +def test_buildings_under_the_street_go_and_the_others_stay(): + result = apply_street(world(), STREET) + assert [b[0].bounds for b in result.buildings] == [(20.0, 400.0, 60.0, 450.0), (84.0, 450.0, 93.0, 470.0)] + + +def test_alley_is_cut_and_the_straight_street_joins_both_cross_streets(): + result = apply_street(world(), STREET) + assert not on_road(result, 100, 500) + assert on_road(result, 150, 500) and not on_road(result, 143, 500) + assert on_road(result, 100, 650) and on_road(result, 100, 330) + assert on_road(result, 150, 650) and on_road(result, 150, 330) + + +def test_alley_centerline_and_short_stubs_go_and_the_street_gets_its_own(): + result = apply_street(world(), STREET) + assert all(line.distance(Point(100, 500)) > 40 for line in result.centerlines) + assert LineString([(150, 660), (150, 320)]) in result.centerlines + assert all(line.length >= 15 for line in result.centerlines) + + +def test_freed_ground_becomes_urban_but_not_under_the_street_buildings_or_old_urban(): + result = apply_street(world(), STREET) + new = [poly for poly, kind in result.surfaces[2:] if kind == "urban"] + assert any(poly.contains(Point(100, 500)) for poly in new) # alte Gasse + assert any(poly.contains(Point(165, 615)) for poly in new) # alter Grundriss neben der Straße + assert not any(poly.contains(Point(150, 615)) for poly in new) # unter der neuen Straße + assert not any(poly.contains(Point(91, 460)) for poly in new) # stehendes Gebäude + assert not any(poly.contains(Point(92, 500)) for poly in new) # war schon Stadtfläche + + +def test_palms_on_the_new_street_go(): + assert apply_street(world(), STREET).palms == [(60.0, 500.0), (140.0, 500.0)]