From 8bc8ca19fc0a282d1e2b0a7654d7895a49a99374 Mon Sep 17 00:00:00 2001 From: Kenso Grimm Date: Thu, 17 Sep 2026 10:41:08 +0200 Subject: [PATCH] =?UTF-8?q?feat(extract):=20Flamingo=20Park=20h=C3=A4ngt?= =?UTF-8?q?=20seine=20Stra=C3=9Fe=20an=20den=20Graphen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit World.centerlines wird zu World.graph: apply_street() und apply_park() schneiden und verlängern jetzt den Straßengraphen statt einer losen Liste von Mittellinien, mit den neuen roads.cut_edges()/attach() als Gegenstück zum bestehenden Schneiden der Straßenflächen. apply_park() schneidet dabei einmal am Parkviereck (nicht je Sperrfläche - das schneidet tatsächlich nichts, was der Parkumriss nicht schon geschnitten hätte). check_corners() bleibt unverändert gegen world.roads (den Korridor) messen, mit einem erklärenden Docstring-Absatz und einer Regressionswache dafür in den Tests. MIN_CENTERLINE (15 m) entfällt zugunsten von MIN_EDGE (5 m): Der alte Grund - Laternen wählten ihre Straßenseite über den Listenindex - fällt weg, sobald eine spätere Aufgabe die Seite aus der Geometrie bestimmt. test_park_street.py dokumentiert das jetzt: die beiden 11-m-Enden der geschnittenen Gasse bleiben deshalb stehen, statt wie vorher zu verschwinden. Beim Nachrechnen der Prüfkörper aus dem Auftrag fielen zwei echte Fehler auf: Der Reste-unter- Mindestlänge-Test schnitt ursprünglich bis übers Streckenende hinaus, sodass der Rest schon geometrisch verschwand und der MIN_EDGE-Filter nie geprüft wurde - jetzt schneidet er innerhalb der Strecke, sodass tatsächlich ein zu kurzes Stück verworfen wird. Und die neue Korridor-Regressionswache verglich (12-7)/2 > 2.5, was mit den echten Maßen immer falsch ist (2.5 > 2.5); korrigiert auf >=, denn die Halbdifferenz trifft die Toleranz exakt. Ausserdem musste eine Test-Fixture in test_park_apply.py von einem 5-m-Reststück (exakt an der neuen MIN_EDGE-Grenze, hätte also entgegen der ursprünglichen Testabsicht überlebt) auf 3 m verkürzt werden, damit der Test weiterhin prüft, dass echte Kurzreste wegfallen. run.py bleibt unangetastet: Es ist bereits durch eine frühere Aufgabe gebrochen und wird von der nächsten Aufgabe repariert. Co-Authored-By: Claude Sonnet 5 --- tools/extract/leonida/park.py | 34 ++++++++++------ tools/extract/leonida/roads.py | 46 +++++++++++++++++++++- tools/extract/tests/test_park_apply.py | 29 ++++++++++++-- tools/extract/tests/test_park_street.py | 24 +++++++++--- tools/extract/tests/test_roads_cut.py | 52 +++++++++++++++++++++++++ 5 files changed, 162 insertions(+), 23 deletions(-) create mode 100644 tools/extract/tests/test_roads_cut.py diff --git a/tools/extract/leonida/park.py b/tools/extract/leonida/park.py index 360238a..9a93e9a 100644 --- a/tools/extract/leonida/park.py +++ b/tools/extract/leonida/park.py @@ -17,6 +17,7 @@ from shapely.geometry import LineString, Point, Polygon from shapely.ops import unary_union from .heights import PALM_CLEARANCE, PALM_KERB, palm_points +from .roads import CLASS_WIDTH, Edge, Graph, attach, cut_edges class ParkError(ValueError): @@ -459,7 +460,6 @@ def _points(geometry) -> list[tuple[float, float]]: # --- 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 @@ -469,16 +469,12 @@ class World: land: list[Polygon] surfaces: list[tuple[Polygon, str]] roads: list[tuple[Polygon, bool]] - centerlines: list[LineString] + graph: Graph 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) @@ -501,8 +497,15 @@ def apply_street(world: World, street: Street) -> World: 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] + # Die Gasse verschwindet auch aus dem Graphen, und die Jefferson Avenue kommt als Kante + # dazu - mit attach() an die Querstraße gehängt, damit dort eine echte Kreuzung entsteht + # statt eines freien Endes. Die Klasse folgt der Breite aus der Parkdatei wie bei jeder + # anderen Kante auch. + graph = cut_edges(world.graph, cut) + klass = min(CLASS_WIDTH, key=lambda name: abs(CLASS_WIDTH[name] - street.width)) + graph.edges.append(Edge(a=attach(graph, street.start), b=attach(graph, street.end), + points=[tuple(street.start), tuple(street.end)], + width=street.width, klass=klass)) freed = unary_union(removed + [cut]).difference(corridor) covered = [b[0] for b in buildings if b[0].intersects(freed)] + \ @@ -512,7 +515,7 @@ def apply_street(world: World, street: Street) -> World: 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, + return replace(world, roads=roads, graph=graph, buildings=buildings, surfaces=surfaces, palms=palms) @@ -530,6 +533,11 @@ def check_corners(config, roads: list[tuple[Polygon, bool]], tolerance: float = vom Straßenrand entfernt. 2,5 m Toleranz lässt der weitesten Ecke damit 1,04 m Luft, bevor die Prüfung grundlos abbricht, und schlägt umgekehrt an, sobald eine Straßenkante sich um mehr als ihren heutigen Abstand plus rund einen Meter verschiebt. + + Seit Teil 2 ist world.roads ausdrücklich der gemessene *Korridor*, nicht die gebaute + Fahrbahn: Die Fahrbahn einer 12-m-Straße ist nur 7 m breit, und gegen sie gemessen lägen + die Parkecken plötzlich 2,5 m daneben. Diese Prüfung gehört an den Korridor, weil sie + fragt, ob sich die Extraktion verschoben hat — nicht, wie breit wir bauen. """ for name, (x, y) in config.game.items(): corner = Point(x, y) @@ -543,8 +551,10 @@ def apply_park(world: World, layout: ParkLayout, seed: int) -> World: 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])] + # Ein einziger Schnitt für das ganze Parkviereck, nicht je Sperrfläche: quad ist die Anlage + # selbst, die die alten Kanten ersetzt - die Sperrflächen (Tribüne usw.) liegen innerhalb und + # kappen für sich genommen nichts, das nicht schon der Parkumriss gekappt hätte. + graph = cut_edges(world.graph, quad) 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 @@ -562,7 +572,7 @@ def apply_park(world: World, layout: ParkLayout, seed: int) -> World: 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, + return replace(world, roads=roads, graph=graph, surfaces=surfaces, buildings=buildings, palms=palms, paths=world.paths + layout.paths) diff --git a/tools/extract/leonida/roads.py b/tools/extract/leonida/roads.py index d84ae86..65ba76e 100644 --- a/tools/extract/leonida/roads.py +++ b/tools/extract/leonida/roads.py @@ -18,7 +18,7 @@ from dataclasses import dataclass, field import networkx as nx import numpy as np from scipy import ndimage as ndi -from shapely.geometry import LineString +from shapely.geometry import LineString, Polygon from skimage.morphology import disk from .coords import SLICE @@ -280,6 +280,50 @@ def measure_widths(graph: Graph, distance: np.ndarray) -> Graph: return graph +# Kürzere Reste nach einem Schnitt sind Stummel, die nur eine Kreuzung vortäuschen würden. +MIN_EDGE = 5.0 + + +def cut_edges(graph: Graph, cut: Polygon) -> Graph: + """Alles, was in der Fläche liegt, aus den Kanten herausschneiden. + + Gegenstück zum Schneiden der Straßenflächen: Wo Flamingo Park die alte Gasse entfernt, darf + im Graphen an derselben Stelle keine Kante mehr stehen - sonst führte dort weiterhin eine + Fahrbahn durch den Park, nur ohne die Straßenfläche, die sie einmal begründet hat. + """ + out = Graph(nodes=list(graph.nodes), edges=[]) + for edge in graph.edges: + line = LineString(edge.points) + if not line.intersects(cut): + out.edges.append(edge) + continue + rest = line.difference(cut) + for piece in getattr(rest, "geoms", [rest]): + if piece.geom_type != "LineString" or piece.length < MIN_EDGE: + continue + points = [(round(x, 2), round(y, 2)) for x, y in piece.coords] + out.edges.append(Edge(a=attach(out, points[0]), b=attach(out, points[-1]), + points=points, width=edge.width, klass=edge.klass)) + return _renumber(out) + + +def attach(graph: Graph, point: tuple[float, float], radius: float = JUNCTION_RADIUS) -> int: + """Knoten-ID an dieser Stelle: der nächste innerhalb von radius, sonst ein neuer. + + Damit hängt sich eine von Hand ergänzte Straße (die Jefferson Avenue) an die Querstraße an, + statt als freies Ende danebenzuliegen und ohne Kreuzung zu enden. + """ + best, best_distance = None, radius + for k, node in enumerate(graph.nodes): + distance = ((node.point[0] - point[0]) ** 2 + (node.point[1] - point[1]) ** 2) ** 0.5 + if distance <= best_distance: + best, best_distance = k, distance + if best is not None: + return best + graph.nodes.append(Node((float(point[0]), float(point[1])))) + return len(graph.nodes) - 1 + + def _median_width(line: LineString, distance: np.ndarray) -> float: start, end = WIDTH_MARGIN, line.length - WIDTH_MARGIN if end <= start: diff --git a/tools/extract/tests/test_park_apply.py b/tools/extract/tests/test_park_apply.py index cfaee97..b39e054 100644 --- a/tools/extract/tests/test_park_apply.py +++ b/tools/extract/tests/test_park_apply.py @@ -6,6 +6,7 @@ from shapely.geometry import LineString, Point, Polygon, box from leonida.heights import PALM_CLEARANCE from leonida.park import ParkError, ParkLayout, World, apply_flamingo_park, apply_park, check_corners, load_park +from leonida.roads import Edge, Graph, Node DATA = Path(__file__).resolve().parents[1] / "data" @@ -23,7 +24,12 @@ def 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)])], + # Der zweite Ast endet 3 m vor dem Parkviereck (statt der ehemals 5 m): Genau 5 m wäre + # MIN_EDGE selbst, und der Rest bliebe stehen (5.0 < MIN_EDGE ist falsch) statt zu + # verschwinden - dieser Test prüft aber, dass ein echter Kurzrest wegfällt. + graph=Graph(nodes=[Node((-50.0, 100.0)), Node((150.0, 100.0)), Node((-3.0, 150.0)), Node((10.0, 150.0))], + edges=[Edge(0, 1, [(-50.0, 100.0), (150.0, 100.0)], 12.0, "wohnstrasse"), + Edge(2, 3, [(-3.0, 150.0), (10.0, 150.0)], 6.0, "gasse")]), 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 @@ -36,7 +42,8 @@ def test_old_roads_and_centerlines_end_at_the_park_edge(): 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)] + lines = [LineString(e.points) for e in result.graph.edges] + assert sorted(line.bounds for line in lines) == [(-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(): @@ -101,16 +108,30 @@ def test_a_road_that_grew_inward_over_the_corner_is_caught(): check_corners(corners(), grown) +def test_eckenpruefung_misst_den_korridor_nicht_die_fahrbahn(): + """Regressionswache: world.roads ist der Korridor, und die Prüfung gehört an ihn. + + Eine 12-m-Straße bekommt 7 m Fahrbahn. Gegen die Fahrbahn gemessen lägen die Ecken des + Parks 2,5 m weiter draußen als erlaubt, und jeder Lauf bräche ab. + """ + from leonida.section import SECTION + assert SECTION["wohnstrasse"].corridor - SECTION["wohnstrasse"].carriageway == 5.0 + # Die Halbdifferenz trifft die Standardtoleranz von check_corners (2.5 m) exakt: schon der + # kleinste real gemessene Eckenabstand (0,14 m, siehe check_corners()) reicht addiert, um + # jeden Lauf abbrechen zu lassen, wenn hier gegen die Fahrbahn statt den Korridor gemessen wird. + assert (SECTION["wohnstrasse"].corridor - SECTION["wohnstrasse"].carriageway) / 2 >= 2.5 + + def test_flamingo_park_goes_into_a_world_with_roads_around_the_park(): config = load_park(DATA / "flamingo-park.toml") park = Polygon([config.game[key] for key in ("sw", "se", "ne", "nw")]) ring = park.buffer(15, join_style="mitre").difference(park) - start = World(land=[park.buffer(400)], surfaces=[], roads=[(ring, False)], centerlines=[], buildings=[], palms=[]) + start = World(land=[park.buffer(400)], surfaces=[], roads=[(ring, False)], graph=Graph(), buildings=[], palms=[]) result, messages = apply_flamingo_park(start, DATA / "flamingo-park.toml", seed=20260912) assert {"park", "field", "court", "pool", "parking", "sand"} <= {kind for _, kind in result.surfaces} assert len([b for b in result.buildings if b[1]["source"] == "osm"]) == 11 assert result.paths and result.palms - assert LineString([config.street.start, config.street.end]) in result.centerlines + assert any(e.points == [tuple(config.street.start), tuple(config.street.end)] for e in result.graph.edges) # Bekannter Zustand: die Zufahrt zum Parkplatz quert die Sperrfläche der Tribüne und wird dort # ausgeschnitten (Task 5). Jede weitere oder andere Warnung ist ein echter Befund. assert [m for m in messages if m.startswith("Warnung")] == ["Warnung: Zufahrt schneidet die Sperrfläche tribuene"] diff --git a/tools/extract/tests/test_park_street.py b/tools/extract/tests/test_park_street.py index 4a2e897..2c75e5f 100644 --- a/tools/extract/tests/test_park_street.py +++ b/tools/extract/tests/test_park_street.py @@ -2,6 +2,7 @@ from shapely.geometry import LineString, Point, box from shapely.ops import unary_union from leonida.park import Street, World, apply_street +from leonida.roads import MIN_EDGE, Edge, Graph, Node STREET = Street(start=(150.0, 660.0), end=(150.0, 320.0), width=12.0, remove_buildings=((155.0, 615.0),), @@ -15,8 +16,11 @@ def 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)])], + graph=Graph(nodes=[Node((0.0, 650.0)), Node((200.0, 650.0)), Node((0.0, 330.0)), Node((200.0, 330.0)), + Node((100.0, 650.0)), Node((100.0, 330.0))], + edges=[Edge(0, 1, [(0.0, 650.0), (200.0, 650.0)], 12.0, "wohnstrasse"), + Edge(2, 3, [(0.0, 330.0), (200.0, 330.0)], 12.0, "wohnstrasse"), + Edge(4, 5, [(100.0, 650.0), (100.0, 330.0)], 6.0, "gasse")]), 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)], @@ -41,11 +45,19 @@ def test_alley_is_cut_and_the_straight_street_joins_both_cross_streets(): 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(): +def test_alley_centerline_shrinks_to_its_stubs_and_the_street_gets_its_own(): + """Früher (MIN_CENTERLINE=15) verschwand die geschnittene Gasse ganz, weil ihre beiden Enden + nur 11 m lang sind. cut_edges() kennt nur noch MIN_EDGE (5 m): der alte Grund - Laternen + wählten ihre Straßenseite über den Listenindex und liefen an der Kreuzung sonst ins Leere - + entfällt, sobald eine spätere Aufgabe die Seite aus der Geometrie bestimmt statt aus der + Liste. Die beiden 11-m-Enden bleiben deshalb jetzt als eigene, kurze Kanten stehen. + """ 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) + lines = [LineString(e.points) for e in result.graph.edges] + assert sorted(round(line.length) for line in lines if line.length < 15) == [11, 11] + assert all(line.distance(Point(100, 500)) > 40 for line in lines) + assert any(e.points == [tuple(STREET.start), tuple(STREET.end)] for e in result.graph.edges) + assert all(line.length >= MIN_EDGE for line in lines) def test_freed_ground_becomes_urban_but_not_under_the_street_buildings_or_old_urban(): diff --git a/tools/extract/tests/test_roads_cut.py b/tools/extract/tests/test_roads_cut.py new file mode 100644 index 0000000..c2d07d9 --- /dev/null +++ b/tools/extract/tests/test_roads_cut.py @@ -0,0 +1,52 @@ +"""Kanten an einer Sperrfläche schneiden und neue Kanten anhängen.""" +from __future__ import annotations + +from shapely.geometry import LineString, box + +from leonida.roads import Edge, Graph, MIN_EDGE, Node, attach, cut_edges + + +def strecke() -> Graph: + return Graph(nodes=[Node((0.0, 0.0)), Node((100.0, 0.0))], + edges=[Edge(0, 1, [(0.0, 0.0), (100.0, 0.0)], 12.0, "wohnstrasse")]) + + +def test_schnitt_in_der_mitte_gibt_zwei_kanten(): + out = cut_edges(strecke(), box(40, -5, 60, 5)) + assert len(out.edges) == 2 + längen = sorted(round(LineString(e.points).length) for e in out.edges) + assert längen == [40, 40] + + +def test_reste_unter_der_mindestlaenge_fallen_weg(): + # Schnitt bei 90..96: übrig bleiben 90 m; das 4-m-Stück von 96 bis 100 liegt unter MIN_EDGE + # und fällt weg. (Ein Schnitt bis ans Streckenende, z.B. 96..104, verschluckt den Rest schon + # geometrisch mit - das würde den MIN_EDGE-Filter gar nicht prüfen, weil dann kein Stück unter + # der Grenze übrig bliebe, das erst noch verworfen werden müsste.) + out = cut_edges(strecke(), box(90, -5, 96, 5)) + assert len(out.edges) == 1 + assert round(LineString(out.edges[0].points).length) == 90 + assert MIN_EDGE == 5.0 + + +def test_ganz_verdeckte_kante_verschwindet(): + assert cut_edges(strecke(), box(-10, -10, 110, 10)).edges == [] + + +def test_unberuehrte_kante_bleibt_wie_sie_ist(): + out = cut_edges(strecke(), box(200, -5, 220, 5)) + assert out.edges[0].points == [(0.0, 0.0), (100.0, 0.0)] + assert out.edges[0].klass == "wohnstrasse" + + +def test_attach_findet_einen_nahen_knoten(): + graph = strecke() + assert attach(graph, (100.0, 3.0)) == 1, "3 m entfernt: derselbe Knoten" + assert len(graph.nodes) == 2 + + +def test_attach_legt_sonst_einen_neuen_knoten_an(): + graph = strecke() + neu = attach(graph, (100.0, 40.0)) + assert neu == 2 + assert graph.nodes[neu].point == (100.0, 40.0)