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:
1 parent
8283109ceb
commit
8bc8ca19fc
5 files changed
+162
-23
No files matched your search
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user