Fix-Runde 1, vier Befunde aus dem Review (Rulings 25-28): Befund A (Critical): der Versatz nach außen konnte eine Palme über die Uferlinie ins Wasser schieben (218 von 2.433 auf dem echten Mosaik). palm_points() bekommt jetzt land_polys (vor seed), _verge_point() verwirft einen Kandidaten, der nicht auf Land liegt. Befund B (Important): regularize() vereinfachte im Rückfallzweig ein zweites Mal und gröber (1,0 m statt der 0,5 m, die mask_polygons() schon angewandt hatte) und prüfte erneut gegen MIN_AREA - das drückte kleine, treppenrandige Grundrisse unter die Schwelle (202 von 2.085 gebaeude-, 9 von 267 spekulativ-Kandidaten). Der Rückfallzweig vereinfacht jetzt nur noch einmal, mit 0,5 m, ohne zweite Flächenprüfung. Befund C: der globale filterwarnings-Eintrag in pyproject.toml (Fix für die oriented_envelope-RuntimeWarning aus der letzten Runde) war zu weit gefasst und hätte auch eine echte künftige Division durch Null versteckt. Er gehört nur auf den einen Test, der minimum_rotated_rectangle selbst aufruft - pyproject.toml zurück auf den Stand davor, @pytest.mark.filterwarnings auf test_l_shape_is_kept_not_boxed. Befund D (Minor): vier doppelte Palmenpunkte entfernt (dict.fromkeys vor dem return), zwei bisher ungetestete Zweige abgedeckt (spekulativ=True/False, Park-Streuung mit Determinismus-Check). Test zuerst für A und B, beide vor dem jeweiligen Fix rot gesehen (echte Assertion-Fehler, keine Signatur-Fehler). 31/31 Tests grün, weiterhin ohne Warnungen. Auf dem echten Mosaik: 2.352 Gebäude (vorher 2.141, +211 - exakt die Zahl, die Befund B vorher fallen ließ; 0 von regularize verworfen, siehe Bericht für die Erklärung), 267 spekulativ. 2.133 Palmenpunkte (Referenz des Koordinators von 2.176 war mit dem alten, kleineren Gebäudebestand gemessen - mit dem isoliert nachgebauten alten Bestand komme ich auf 2.175, die verbleibende Differenz von 1 ist im Bericht dokumentiert statt verschwiegen). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
108 lines
4.6 KiB
Python
108 lines
4.6 KiB
Python
import hashlib
|
|
import math
|
|
|
|
from shapely.geometry import Point, Polygon
|
|
|
|
DISTRICT_RANGES = {
|
|
"Downtown": (20.0, 60.0),
|
|
"Ocean Beach": (8.0, 25.0),
|
|
"default": (6.0, 15.0),
|
|
}
|
|
LANDMARK_SNAP = 20.0 # Meter: so weit darf ein Wahrzeichenpunkt vom Grundriss entfernt liegen
|
|
|
|
|
|
def _seeded_unit(seed: int, key: str) -> float:
|
|
digest = hashlib.sha256(f"{seed}:{key}".encode()).digest()
|
|
return int.from_bytes(digest[:8], "big") / 2 ** 64
|
|
|
|
|
|
def assign_heights(polys: list[Polygon], landmarks: list[dict],
|
|
districts: list[dict], seed: int) -> list[dict]:
|
|
out = []
|
|
for poly in polys:
|
|
centroid = poly.centroid
|
|
key = f"{centroid.x:.1f},{centroid.y:.1f}"
|
|
hit = None
|
|
for lm in landmarks:
|
|
p = Point(lm["xyz"][0], lm["xyz"][1])
|
|
if poly.contains(p) or poly.distance(p) <= LANDMARK_SNAP:
|
|
if hit is None or lm["height"] > hit["height"]:
|
|
hit = lm
|
|
if hit is not None:
|
|
out.append({"height": float(hit["height"]), "source": "skyline"})
|
|
continue
|
|
name = "default"
|
|
best = math.inf
|
|
for d in districts:
|
|
dist = math.dist((centroid.x, centroid.y), (d["x"], d["y"]))
|
|
if dist < best:
|
|
best, name = dist, d["name"] if d["name"] in DISTRICT_RANGES else "default"
|
|
lo, hi = DISTRICT_RANGES[name]
|
|
# Kleine Grundrisse bleiben niedriger: Fläche skaliert den oberen Rand.
|
|
area_factor = min(1.0, poly.area / 400.0)
|
|
height = lo + (hi - lo) * _seeded_unit(seed, key) * (0.4 + 0.6 * area_factor)
|
|
out.append({"height": round(height, 1), "source": "district"})
|
|
return out
|
|
|
|
|
|
PALM_SPACING = 12.0 # Meter entlang der Mittellinie
|
|
PALM_BEACH_RANGE = 40.0 # Meter Abstand zur nächsten Sandfläche
|
|
PALM_CLEARANCE = 6.0 # Meter Abstand zu Gebäuden
|
|
PALM_KERB = 1.5 # Meter Abstand zum Fahrbahnrand
|
|
# Versatzkandidaten von innen nach außen; genommen wird der erste, der Fahrbahn und
|
|
# Gebäude freihält. Ein fester Wert geht nicht: die Fahrbahn ist an den Mittellinien-
|
|
# punkten p50 5,4 m, p90 11,7 m, p95 14,0 m halbbreit (am echten Mosaik gemessen).
|
|
PALM_VERGE = (6.0, 8.0, 10.0, 12.0, 14.0, 16.0)
|
|
|
|
|
|
def _verge_point(line, p, side: str, roads, buildings, land):
|
|
"""Erster Versatz von innen nach außen, der Fahrbahn und Gebäude freihält und
|
|
auf Land bleibt (Ruling 26: sonst schiebt der Versatz Küstenpalmen ins Wasser)."""
|
|
for dist in PALM_VERGE:
|
|
off = line.parallel_offset(dist, side)
|
|
if off.is_empty:
|
|
continue
|
|
q = off.interpolate(off.project(p))
|
|
if roads is not None and q.distance(roads) < PALM_KERB:
|
|
continue
|
|
if buildings is not None and q.distance(buildings) < PALM_CLEARANCE:
|
|
continue
|
|
if land is not None and not land.contains(q):
|
|
continue
|
|
return [(round(q.x, 2), round(q.y, 2))]
|
|
return []
|
|
|
|
|
|
def palm_points(sand_polys, park_polys, road_lines, building_polys, road_polys,
|
|
land_polys, seed: int):
|
|
"""Palmen entlang strandnaher Mittellinien und verstreut in Parks."""
|
|
from shapely.ops import unary_union
|
|
sand = unary_union(sand_polys) if sand_polys else None
|
|
roads = unary_union(list(road_polys)) if road_polys else None
|
|
buildings = unary_union(list(building_polys)) if building_polys else None
|
|
land = unary_union(land_polys) if land_polys else None
|
|
blocked = unary_union([g for g in (roads, buildings) if g is not None]) \
|
|
if (roads is not None or buildings is not None) else None
|
|
points = []
|
|
for line in road_lines:
|
|
if sand is None or line.distance(sand) > PALM_BEACH_RANGE:
|
|
continue
|
|
# Start bei halber Teilung: die Mittellinien sind an Kreuzungen getrennt, ein
|
|
# Start bei 0 setzt je Ast eine Palme in denselben Punkt.
|
|
pos = min(PALM_SPACING / 2, line.length / 2)
|
|
while pos <= line.length:
|
|
p = line.interpolate(pos)
|
|
for side in ("left", "right"):
|
|
points.extend(_verge_point(line, p, side, roads, buildings, land))
|
|
pos += PALM_SPACING
|
|
for park in park_polys:
|
|
minx, miny, maxx, maxy = park.bounds
|
|
n = int(park.area // 250)
|
|
for k in range(n):
|
|
u = _seeded_unit(seed, f"park:{minx:.0f}:{k}:x")
|
|
v = _seeded_unit(seed, f"park:{miny:.0f}:{k}:y")
|
|
q = Point(minx + u * (maxx - minx), miny + v * (maxy - miny))
|
|
if park.contains(q) and (blocked is None or q.distance(blocked) > PALM_CLEARANCE):
|
|
points.append((round(q.x, 2), round(q.y, 2)))
|
|
return list(dict.fromkeys(points))
|