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)