feat(extract): Flamingo Park hängt seine Straße an den Graphen

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 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Sonnet 5 committed 2026-09-17 10:41:08 +02:00
1 parent 8283109ceb
commit 8bc8ca19fc
5 files changed
+162 -23

No files matched your search

+22 -12
View File
@@ -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)
+45 -1
View File
@@ -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:
+25 -4
View File
@@ -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"]
+18 -6
View File
@@ -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():
+52
View File
@@ -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)